Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"name": "unic-pr-review",
"source": "./",
"tags": ["productivity", "code-review", "azure-devops"],
"version": "2.1.11"
"version": "2.1.12"
}
]
}
2 changes: 1 addition & 1 deletion apps/claude-code/unic-pr-review/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,5 @@
"keywords": ["pr-review", "azure-devops", "jira", "confluence", "code-review", "unic"],
"license": "LGPL-3.0-or-later",
"name": "unic-pr-review",
"version": "2.1.11"
"version": "2.1.12"
}
2 changes: 1 addition & 1 deletion apps/claude-code/unic-pr-review/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ Load-bearing invariants captured as ADRs. All sixteen must be understood before
- **ADR-0001** — Multi-source intent gathering with shared Atlassian credentials (`.unic-confluence.json` covers both Confluence and Jira)
- **ADR-0002** — Confidence-scored Findings with explicit Severity thresholds (Critical 90-100, Important 80-89, Minor 60-79; drop below 60)
- **ADR-0003** — Interactive Approval Loop as the default write path
- **ADR-0004** — Hard-stop when intent sources are unreachable; empty intent is legitimate
- **ADR-0004** — Hard-stop when intent sources are unreachable; legitimate empty (`[]`) is silent; absent `workItemRefs` key (lost-in-handoff) → loud Notice + continue
- **ADR-0005** — `az` CLI for Azure DevOps reads/writes; `node:https` (or global `fetch`) for Atlassian
- **ADR-0006** — Iteration state lives in the PR's Bot Signature (hidden `<!-- unic-pr-review:iteration=N -->` Iteration Marker), not on disk; detection keys on the Iteration Marker, never on ADO author identity; `doctor` does not probe `az devops user show`
- **ADR-0007** — Re-review uses a delta diff against the prior reviewed Revision
Expand Down
12 changes: 12 additions & 0 deletions apps/claude-code/unic-pr-review/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- (none)

## [2.1.12] — 2026-06-17

### Breaking
- (none)

### Added
- (none)

### Fixed
- Linked Work Items silently dropped on large PRs: `discoverWorkItems` now takes the refs array directly (hoisted to top-level `FETCHER_OUTPUT.workItemRefs`), throws on absent/non-array input, and `review-pr.md` Step 1.5 surfaces a loud Notice when the key is absent (data-loss path) instead of silently treating it as a no-WI PR (#252)
- `renderNotices` now emits the data-loss Summary Notice for `lostInHandoff`, so the durable notice set by `review-pr.md` Step 1.5 actually renders at the top of the Review Summary (and posts to the PR) rather than being silently dropped (#252)

## [2.1.11] — 2026-06-16

### Breaking
Expand Down
2 changes: 1 addition & 1 deletion apps/claude-code/unic-pr-review/CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ A folder bundle at `providers/<name>/` implementing the Source Platform contract
_Avoid_: adapter, backend, driver

**Work Item**:
A normalised task record `{ id, type, url, raw }` returned by `provider.discoverWorkItems(prMetadata)` — an Azure Boards Work Item, a Jira issue, or a manually pasted URL in Pre-PR Mode.
A normalised task record `{ id, type, url, raw }` returned by `provider.discoverWorkItems(workItemRefs)` — takes the refs array hoisted to `FETCHER_OUTPUT.workItemRefs` (top-level, not nested in `prMetadata`) directly. Covers an Azure Boards Work Item, a Jira issue, or a manually pasted URL in Pre-PR Mode.
_Avoid_: ticket, story, task

**Notice**:
Expand Down
8 changes: 5 additions & 3 deletions apps/claude-code/unic-pr-review/agents/ado-fetcher.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,14 +266,16 @@ Otherwise the command exited zero. `git diff` exits zero on success whether or n

### Step 6 — Emit result

**Authoritative-channel rule**: the single inline JSON object you emit here is the **only** authoritative output channel. Never offload required fields to a disk file — the orchestrator never reads a Fetcher-written file. On large PRs, keep the summary-tier fields (`workItemRefs`, `mode`, `changedFiles`, `diffUnavailable`, `warnings`) inline and small; the bulky `prMetadata` blob (full ADO response) may abbreviate as needed, because the orchestrator reads `workItemRefs` from the top-level field, not from `prMetadata`.

Emit exactly one JSON object — no prose, no markdown, no footer (replace each `<…>` placeholder with the real value it names; `prMetadata`, `revisions`, and `threads` are objects, not strings):

```json
{
"prMetadata": {
"pullRequestId": 42,
"workItemRefs": [{ "id": "101", "url": "https://dev.azure.com/org/project/_apis/wit/workitems/101" }]
"pullRequestId": 42
},
"workItemRefs": [{ "id": "101", "url": "https://dev.azure.com/org/project/_apis/wit/workitems/101" }],
"revisions": "<REVISIONS object>",
"threads": "<THREADS object>",
"changedFiles": ["path/to/file.ts"],
Expand All @@ -292,6 +294,6 @@ Emit exactly one JSON object — no prose, no markdown, no footer (replace each
}
```

`prMetadata` is the raw ADO `pullrequests` response enriched with `workItemRefs` from Step 1.5. `workItemRefs` is always an array (empty `[]` when no Work Items are linked or the fetch failed).
`workItemRefs` is a **top-level** field — a small array of `{ id, url }` pairs from Step 1.5, always present (empty `[]` when no Work Items are linked or the fetch failed). It is **not** nested inside `prMetadata` so it survives inline even when the raw `prMetadata` blob is large. `prMetadata` is the raw ADO `pullrequests` response; the orchestrator reads `workItemRefs` from the top-level field, never from `prMetadata.workItemRefs`.

`mode` is one of `"first-review"`, `"re-review"`, `"first-review-fallback"`. `priorRevisionId` and `priorIteration` are `null` except in `re-review` mode (where they carry `PRIOR_SIG.priorRevisionId` / `PRIOR_SIG.priorIteration`). `deltaRawDiff` is the delta diff string (empty in first-review modes). `priorFindings` is an array of `{ threadId, filePath, startLine, severity, title }` objects (empty except in `re-review` mode), where `threadId` is the number id of the ADO Thread carrying that prior finding's bot comment — it is what the Re-review Coordinator keys all thread mapping on. `humanThreads` is an array of `{ threadId, filePath, startLine, status, excerpt }` objects — Human Threads classified in Step 3b (ADR-0016). `filePath` and `startLine` are `null` for non-inline (general comment) threads. Always emitted; empty `[]` when no Human Threads exist. `diffUnavailable` is `false` when a real diff was computed (re-review always, first-review/first-review-fallback when inside a matching clone and the git diff succeeds) and `true` when a diff could not be obtained (no matching clone, missing commonRefCommit or sourceRefCommit, git diff failure, or an empty diff despite a non-empty changedFiles). `warnings` is an array of strings for any non-fatal issues. Never emit `hardStop` — the orchestrator handles all write decisions.
22 changes: 19 additions & 3 deletions apps/claude-code/unic-pr-review/commands/review-pr.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,13 +152,29 @@ Then proceed through the shared steps with these re-review deltas:

#### Step 1.5 — Discover Work Items

Write `FETCHER_OUTPUT.prMetadata` to a temp file (avoids shell-quoting the JSON), then pipe it in:
First distinguish whether `FETCHER_OUTPUT.workItemRefs` is **present** (including an explicit `[]`) or **absent** (the key does not exist on `FETCHER_OUTPUT`). These are two different states — absent means data was lost in the Fetcher→orchestrator handoff, not that the PR has no Work Items linked.

**Key absent — data-loss path:**

Print the following notice to the terminal **before** the aspect fan-out so the Reviewer can Ctrl-C and re-run for intent coverage:

```
⚠ Work Item data was not delivered by the ADO Fetcher (workItemRefs key absent on FETCHER_OUTPUT).
This is a data gap — not a PR with no Work Items linked. The Intent Check will be skipped.
Press Ctrl-C now to abort and re-run for intent coverage, or wait to continue without it.
```

Add `lostInHandoff: true` to `NOTICES_CONTEXT` so the renderer emits a durable Summary Notice at the top of the Review Summary (also posted to the PR when `--post` is used). Set `WORK_ITEMS = []` and continue to Step 1.6. Do **not** stop the run.

**Key present (including `[]`) — normal path:**

Write `FETCHER_OUTPUT.workItemRefs` (the refs array, not the raw `prMetadata` blob) as JSON to a temp file, then pipe it in:

```sh
node "${CLAUDE_PLUGIN_ROOT}/providers/index.mjs" discover-work-items "<URL>" < "<temp file with prMetadata JSON>"
node "${CLAUDE_PLUGIN_ROOT}/providers/index.mjs" discover-work-items "<URL>" < "<temp file with workItemRefs JSON array>"
```

- **Exit 0**: stdout is a JSON array. Store as `WORK_ITEMS`.
- **Exit 0**: stdout is a JSON array. Store as `WORK_ITEMS`. An explicit `[]` (no Work Items linked) stays silent — not a data gap.
- **Exit non-zero**: relay stderr and stop.

#### Step 1.6 — Spawn Intent Checker (only when `WORK_ITEMS` is non-empty)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,12 @@ Reuse `~/.unic-confluence.json` as the shared Atlassian Credential File for both
## Amendment (2026-06)

Work-item discovery is a **Provider contract**. Each Source Platform Provider exposes
`discoverWorkItems(prMetadata) → [{ id, type, url, raw }]`. For ADO, the Fetcher
populates `prMetadata.workItemRefs` from the dedicated `pullrequestworkitems` endpoint
(Step 1.5 of the ADO Fetcher — the `pullrequests` response does not include Work Item
links); `discoverWorkItems` then reads that field (never regex-scraping the description).
`discoverWorkItems(workItemRefs) → [{ id, type, url, raw }]` — takes the refs array
directly (hoisted to `FETCHER_OUTPUT.workItemRefs` top-level, not nested in `prMetadata`).
For ADO, the Fetcher fetches `workItemRefs` from the `pullrequestworkitems` endpoint
(Step 1.5) and emits them as a top-level field; `discoverWorkItems` receives that array
and never regex-scrapes the description. See ADR-0010 amendment (2026-06) for the full
rationale behind the signature change.
For future GitHub/GitLab Providers it will use their respective native linkage endpoints.
The Intent Checker stays Source-Platform-agnostic: it consumes the normalised list
regardless of origin. This separates "where did the Work Items come from?" (Provider
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,17 @@ If a fetched Work Item links to a Confluence page and the Confluence Credential
Work Items discovered natively by a Source Platform Provider (e.g. ADO `workItemRefs` linked to a PR) are **promised intent** and follow the same reachability doctrine as pasted Jira / Confluence URLs. If a linked Work Item is unreachable or returns an auth error, the Plugin halts with a hard-stop. `not-found` remains a soft note — matching the pasted-URL rule — because it signals that the Work Item was genuinely absent from the system (deleted or never created), not that a configuration or credentials problem prevents the Plugin from reaching ADO. Auth errors and unreachable URLs indicate a broken setup the reviewer must fix; a missing Work Item is recoverable missing context.

Org-URL extraction failures (malformed or unrecognised Work Item URL shapes) are treated as unreachable: the Plugin halts and surfaces the offending URL rather than silently passing a wrong `--org` flag to `az boards work-item show`.

## Amendment (2026-06) — Third intent-state: lost-in-handoff → loud Notice + continue

Two existing intent states: (1) **legitimate empty** — no Work Items linked (`workItemRefs = []`) — silent, the Intent Check is simply omitted; (2) **unreachable** — a linked source cannot be fetched — hard-stop. A third state is now recognised:

**(3) Lost-in-handoff** — `workItemRefs` key is **absent** from `FETCHER_OUTPUT` (the field existed in the ADO Fetcher's output contract but was not delivered to the orchestrator, e.g. because the Fetcher agent abbreviated its large inline return on a big PR and dropped the field). This is **not** a legitimate no-WI case and **not** a hard-stop: the PR may well have linked Work Items, but the data did not survive the Fetcher→orchestrator handoff.

Behaviour for the lost-in-handoff state (`review-pr.md` Step 1.5):

1. **Early terminal notice** (before the aspect fan-out): loud print telling the Reviewer that Work Item data was not delivered, the Intent Check will be skipped, and they can Ctrl-C to abort and re-run for intent coverage.
2. **Summary Notice** (durable): a `lostInHandoff: true` flag is added to `NOTICES_CONTEXT` so the renderer emits a Notice at the top of the Review Summary — also posted to the PR when `--post` is used — creating a durable record that intent coverage was absent due to a data gap, not a deliberate design choice.
3. **Continue** — do not stop the run; proceed with `WORK_ITEMS = []` (no Intent Check, no hard-stop).

This state is distinct from legitimate-empty (silent) and unreachable (hard-stop), and is detected by checking `'workItemRefs' in FETCHER_OUTPUT` before inspecting its value.
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,13 @@ Each Source Platform Provider ships as a folder bundle `providers/<name>/` conta
- `fixtures/` — test fixtures co-located with the provider
- `tests/` — the bundle's unit tests

The bundle exports `name`, `label`, `prUrlPattern`, `parsePrUrl(url) → { orgUrl, project, repo, prId }`, `agents.{ fetcher, writer }` (in the `unic-pr-review:*` namespace), and `discoverWorkItems(prMetadata) → [{ id, type, url, raw }]`. `providers/index.mjs` exposes `detectProvider(url) → ProviderModule | null` by testing each registered provider's `prUrlPattern` in first-match-wins order.
The bundle exports `name`, `label`, `prUrlPattern`, `parsePrUrl(url) → { orgUrl, project, repo, prId }`, `agents.{ fetcher, writer }` (in the `unic-pr-review:*` namespace), and `discoverWorkItems(workItemRefs) → [{ id, type, url, raw }]`. `providers/index.mjs` exposes `detectProvider(url) → ProviderModule | null` by testing each registered provider's `prUrlPattern` in first-match-wins order.

## Amendment (2026-06) — discoverWorkItems signature: refs array, not prMetadata blob

`discoverWorkItems` was originally specified as `discoverWorkItems(prMetadata)` — taking the raw PR-metadata object and extracting `prMetadata.workItemRefs` internally. This was changed to `discoverWorkItems(workItemRefs)` — taking the refs array directly — because `workItemRefs` buried inside the large `prMetadata` blob was silently dropped by the ADO Fetcher agent on large PRs (agent improvisation to keep inline output small; the `?? []` fallback then collapsed data-loss into the legitimate "no Work Items linked" state, bypassing the silent-false-negative guard in Fetcher Step 1.5).

The fix decouples the two: the ADO Fetcher now emits `workItemRefs` as a **top-level** field on `FETCHER_OUTPUT` (small summary-tier data, always kept inline), and the orchestrator (`review-pr.md` Step 1.5) reads `FETCHER_OUTPUT.workItemRefs` directly and passes the array to `discoverWorkItems`. The raw `prMetadata` blob is no longer on the discovery data path. `discoverWorkItems` throws on any non-array input — including `undefined` from an absent key — so a handoff data-loss can never be silently swallowed.

## Consequences

Expand Down
2 changes: 1 addition & 1 deletion apps/claude-code/unic-pr-review/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,5 @@
"verify:changelog": "unic-verify-changelog"
},
"type": "module",
"version": "2.1.11"
"version": "2.1.12"
}
31 changes: 18 additions & 13 deletions apps/claude-code/unic-pr-review/providers/azure_devops/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,19 @@ Returns the addressable parts of a PR:

Throws `Not an ADO PR URL: <url>` when the URL does not match `prUrlPattern`.

## `discoverWorkItems(prMetadata)`
## `discoverWorkItems(workItemRefs)`

Reads the `workItemRefs` that the ADO Fetcher populates on `prMetadata` from the
dedicated `pullrequestworkitems` endpoint (Step 1.5 — the `pullrequests` response
does not carry Work Item links), and normalises each entry to
Takes the `workItemRefs` array hoisted by the ADO Fetcher to `FETCHER_OUTPUT.workItemRefs`
(top-level, not nested in `prMetadata`) and normalises each entry to
`{ id, type: "ado-work-item", url, raw }`. It never regex-scrapes the PR
description — work-item discovery is a Provider contract (ADR-0001 amendment).
Returns `[]` when `workItemRefs` is empty or absent.
description — work-item discovery is a Provider contract (ADR-0001 amendment, ADR-0010 amendment).

Throws when `workItemRefs` is not an array (including `undefined` from an absent key).
The orchestrator's loud-Notice path (`review-pr.md` Step 1.5) is the only way to reach
`WORK_ITEMS = []` when `FETCHER_OUTPUT.workItemRefs` is absent — this function must not
silently swallow data-loss.

Returns the normalised array (empty `[]` when the input array is empty — legitimate no-WI case).

## Registered agents

Expand All @@ -41,10 +46,10 @@ Returns `[]` when `workItemRefs` is empty or absent.

## Adding fixtures

PR-metadata fixtures live in `fixtures/`. They mirror the enriched `prMetadata`
shape — the `az devops invoke --area git --resource pullrequests` response with
`workItemRefs` grafted on by the Fetcher (Step 1.5). Add a new fixture file and
reference it from `tests/provider.test.mjs` via the `fixture(name)` helper. The
`fixtures/ado-cli-inventory.json` file catalogues every `az devops invoke` call
the ADO Fetcher agent emits; the root `tests/ado-cli-smoke.test.mjs` asserts the
agent and the inventory stay in sync.
PR-metadata fixtures live in `fixtures/`. They mirror the `az devops invoke --area git --resource pullrequests`
ADO wire-format response. `workItemRefs` is a top-level field on the fixture object (not nested inside
`prMetadata`) — it is fetched separately from the `pullrequestworkitems` endpoint and emitted by the Fetcher
as a top-level `FETCHER_OUTPUT` field. Pass `fixture('...').workItemRefs` directly to `discoverWorkItems`.
Add a new fixture file and reference it from `tests/provider.test.mjs` via the `fixture(name)` helper. The
`fixtures/ado-cli-inventory.json` file catalogues every `az devops invoke` call the ADO Fetcher agent emits;
the root `tests/ado-cli-smoke.test.mjs` asserts the agent and the inventory stay in sync.
Loading
Loading