chore(#2794): single-source the reviewer invocation contract + parity assertion (#2820)

* chore(#2794): single-source the reviewer invocation contract

Phase 1 of epic #2782 (ADR-2782). Introduces one core descriptor table as
the declared contract for all 11 cross-AI reviewer lanes, and the
DEFECT.GENERATIVE-FIX parity assertion the roster has never had.

The lane contract lived in three unrelated surfaces — the roster, ~640
lines of hand-authored per-CLI bash in invoke_reviewers, and the
write_reviews section headings — so cross-cutting fixes landed per-leg
(#2494 and #2605 were the same empty-output defect filed twice).

- src/review-lane-descriptor.cts: frozen table declaring per lane the
  slug, flags, probe, invoke shape, timeout floor, empty-output policy,
  REVIEWS.md section, evidence class, required binaries, prompt-budget
  key and handler. Field names track ADR-2782 D1/D2/D6/D7 verbatim so
  Phase 2 harvests the shape with no translation layer. It declares;
  it does not execute — invoke_reviewers iterates in Phase 5b.
- checkReviewerLaneParity: bidirectional parity across descriptor,
  roster, invoke_reviewers legs and write_reviews sections. Forward-only
  would miss the failure it exists to catch (#2718 added a leg, #2781
  was the drift). ADR-1517 instance headings are exempt per D8.
- Legs carry an explicit <!-- reviewer-lane: slug --> marker; five
  non-lane bold labels share the bold-then-fence shape a heuristic
  matcher would key on.
- ADR-2782 D4: an explicitly-flagged reviewer that cannot run is now an
  error in both the core module and the workflow prose that mirrors it.
  A code-only change would be unobservable — the module has no
  production caller; the workflow narrates the policy. Discovery paths
  (--all, review.default_reviewers) stay lenient.
- Fixes the qwen leg, the last one discarding stderr to /dev/null.

Two ADR-2782 D2 vocabulary widenings were forced by surveying the
shipped legs: promptChannel 'none' (CodeRabbit is fed no prompt) and
outputChannel 'file-arg' (Codex writes via -o and discards stdout,
#1698). Both are additive and closed; Phase 2 owns the validator.

Closes #2690

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#2794): make the parity checker total and pin the lane slug grammar

Findings from the orthogonal review passes.

Spec axis — the module claimed its vocabulary tracked ADR-2782 D1/D2
"verbatim" while diverging in three undisclosed ways, which is the
translation layer Phase 2 was supposed to be spared:
- `transport` moves from `invoke.transport` to the LANE level, a sibling
  of `probe`/`invoke`, exactly as D1's manifest example places it. The
  nested form read better as a TS discriminated union; the union is now
  discriminated at the lane level instead, which costs nothing.
- The header and the CONTEXT.md glossary now enumerate all FOUR
  widenings (adding `outputArg` and `flags[]`), not two.

Standards axis — CLAUDE.md requires a fast-check property test for a
parser, and `checkReviewerLaneParity` parses markdown for markers and
headings. Adding one found two real defects that the hand-written
matrix missed:
- NOT TOTAL: a malformed descriptor entry threw on `lane.flags`
  iteration, contradicting the module's own "never throws" claim. Every
  field is now narrowed from `unknown` at the trust boundary and
  reported as MALFORMED_LANE / INVALID_SLUG. This matters because
  Phase 2 feeds this function third-party overlay data, and a parity
  gate that crashes is indistinguishable from one never run.
- SILENT GRAMMAR MISMATCH: LEG_MARKER_RE captures only [a-z0-9_-], so a
  slug outside that class was unmatchable — its marker could be present
  and correct and the scan would still report LEG_MARKER_MISSING
  forever. LANE_SLUG_RE now pins the grammar and a violating slug is
  reported INVALID_SLUG. A loud named violation beats a silent miss.

Generators are document-shaped, not writer-seeded (CONTRIBUTING #2371):
seeding from the module's own matchers could only produce documents
those matchers already recognize.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#2794): register the new bin/lib module in the ESLint ignore list

The remote runner caught this; lint:ci did not, because the invariant
lives in the test suite rather than the lint chain:

  tests/repo-invariants.test.cjs
  "each bin/lib/*.cjs is linted xor ignored according to migration state"
  -> tsc-generated bin/lib modules not yet added to ESLint ignore list:
     review-lane-descriptor.cjs

Adding a src/*.cts module ripples to six surfaces (.gitignore, the
ESLint ignore list, docs/INVENTORY-MANIFEST.json, the CONTEXT.md
glossary, the capability/inventory manifests, and any size baseline).
The other five were covered; this was the miss.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(#2794): amend ADR-2782 D1/D2/D8 with the vocabulary Phase 1 surfaced

Building the Phase 1 descriptor table against all eleven shipped legs is
the first time every lane's contract was written in one place, and it
surfaced four cases the ADR's original survey did not cover. Amending
the design lock rather than diverging from it, so Phase 2 (#2795)
implements the manifest validator against the amended vocabulary instead
of rediscovering the gaps.

All four are additive widenings of closed enums; no decision reverses:

- D2 promptChannel gains `none` — coderabbit is fed no prompt at all, it
  reviews the working-tree diff.
- D2 outputChannel gains `file-arg` — the ADR called a file-writing lane
  a shape a real CLI *could* take; codex already is one, writing via
  -o/--output-last-message and discarding stdout (#1698).
- D2 gains `outputArg`, required iff file-arg — knowing the review lands
  in a file is useless without the argument naming it.
- D1 `flag` becomes `flags[]` and D8's uniqueness flattens across lanes —
  antigravity is selected by both --antigravity and --agy, which a
  single-valued field cannot express.

This is the same evidence path that produced the openai-http transport:
the vocabulary widens on a lane that exists, under review, never on
speculation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(#2794): backfill changeset pr number to 2820

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-07-29 07:31:32 -04:00
committed by GitHub
parent 9624167eec
commit 8b44a0da43
15 changed files with 1864 additions and 22 deletions

View File

@@ -50,9 +50,11 @@ command -v jq >/dev/null 2>&1 && echo "jq:available" || echo "jq:missing"
**jq-dependent reviewer lanes.** `jq` is a production prerequisite for the
`ollama`, `lm_studio`, `llama_cpp`, `opencode`, and `antigravity` lanes only. If
`detect_clis` reports `jq:missing`, treat those five as **undetected** — they
follow the same "known-but-undetected" path as a missing CLI (info note, ignored;
and if they were the only selected reviewers, fail with the actionable message
below rather than producing an empty review). Tell the user to install jq:
follow the same "known-but-undetected" path as a missing CLI. Which path that is
depends on how the lane was selected (see the precedence rules below): reached
through `review.default_reviewers` or `--all` it is an info note and the lane is
ignored; named by an explicit flag it is an **error**, because the user asserted
that lane. Tell the user to install jq:
```
NOTE: jq is not on PATH — the ollama, lm_studio, llama_cpp, opencode, and
@@ -85,10 +87,22 @@ Reviewer-selection precedence:
3. `review.default_reviewers`
4. No key + no flags → all detected reviewers
**Explicit reviewer flags are an assertion, not a preference (ADR-2782 D4).** A lane the user
named on the command line and that cannot run is an **error**, surfaced and non-silent — even
when other named lanes did run. Do not proceed with a thinner reviewer set and report success:
`--gemini --qwen` on a host without `qwen` fails, it does not quietly become a Gemini-only
review. This applies however the lane became unavailable — binary missing, prerequisite `jq`
absent, or a local server not reachable.
The asymmetry is deliberate: *not finding a lane nobody asked for is normal; failing to run a
lane somebody asked for is an error.* A user who wants "whatever is available" has `--all`; a
user who wants a preferred set has `review.default_reviewers`. Both stay lenient below.
`review.default_reviewers` behavior:
- Value must be a non-empty array of slug strings (configured via `gsd config-set review.default_reviewers '["gemini","codex"]'`)
- Unknown slugs warn and are ignored
- Known-but-undetected slugs emit an info note and are ignored
- Known-but-undetected slugs emit an info note and are ignored — a configured default is a
preference evaluated across many hosts, so a subset being present is expected, not an error
- If all configured reviewers are unavailable, fail with an actionable message
**Reviewer instances (#1517, optional):** if `review.reviewer_instances` is configured,
@@ -303,6 +317,7 @@ For each selected CLI, invoke in sequence (not parallel — avoid rate limits):
**Timeout guidance (#2194):** prompt-fed source-grounded reviews are slow — measured ~570s for Codex at `xhigh` effort and ~525s for headless Claude on a large plan set. Each of the Gemini / Claude / Codex blocks below MUST be invoked with a high Bash `timeout:` — at least `900000` (15 min), and `1200000` (20 min) for Codex `xhigh` or headless Claude — so a lane is not killed mid-review. On Claude Code, raise the host cap via `BASH_MAX_TIMEOUT_MS` if a review can exceed it. A silent empty output after a long run is a **timeout kill, not a crash** — the Codex `0xc0000142` misdiagnosis persisted because the empty-output branches below cannot distinguish the two; treat an empty result on a slow lane as a dropped lane and re-run with more time rather than diagnosing a CLI/sandbox failure. A cross-AI review that silently drops a lane is blind in one eye.
<!-- reviewer-lane: gemini -->
**Gemini:**
```bash
# #2494: capture stderr to a .err sidecar (not /dev/null) and stub an empty
@@ -326,6 +341,7 @@ if [ ! -s {run_dir}/gsd-review-gemini.md ]; then
fi
```
<!-- reviewer-lane: claude -->
**Claude (separate session):**
```bash
# #2494: same guard as the Gemini block above — stderr to a .err sidecar
@@ -341,6 +357,7 @@ if [ ! -s {run_dir}/gsd-review-claude.md ]; then
fi
```
<!-- reviewer-lane: codex -->
**Codex:**
```bash
# No hook-trust bypass flag — see the #2479 note above. Capture stderr to a .err
@@ -362,6 +379,7 @@ if [ ! -s {run_dir}/gsd-review-codex.md ]; then
fi
```
<!-- reviewer-lane: coderabbit -->
**CodeRabbit:**
Note: CodeRabbit reviews the current git diff/working tree — it does not accept a prompt or model flag. It may take up to 5 minutes. Use `timeout: 360000` on the Bash tool call. The source-grounding requirement in the build_prompt Review Instructions applies only to the prompt-fed reviewers above; CodeRabbit is a diff-only reviewer and never receives it. Treat its output as a diff observation, not a grounded plan-level verdict.
@@ -378,6 +396,7 @@ if [ ! -s {run_dir}/gsd-review-coderabbit.md ]; then
fi
```
<!-- reviewer-lane: opencode -->
**OpenCode (via GitHub Copilot):**
OpenCode's default `build` agent is an agentic coder, not a prompt→completion API.
@@ -426,14 +445,22 @@ else
fi
```
<!-- reviewer-lane: qwen -->
**Qwen Code:**
```bash
cat {run_dir}/gsd-review-prompt.md | qwen - 2>/dev/null > {run_dir}/gsd-review-qwen.md
# #2794: the last leg still sending stderr to /dev/null. Every other lane
# captures it to a .err sidecar and appends it to the stub (#2494/#2605); qwen
# wrote a bare "failed or returned empty output." with no diagnostic at all, so
# a missing binary, an auth prompt, and a rate-limit were indistinguishable from
# each other and from a clean empty review.
cat {run_dir}/gsd-review-prompt.md | qwen - 2>{run_dir}/gsd-review-qwen.err > {run_dir}/gsd-review-qwen.md
if [ ! -s {run_dir}/gsd-review-qwen.md ]; then
echo "Qwen review failed or returned empty output." > {run_dir}/gsd-review-qwen.md
echo "Qwen review failed or returned empty output. stderr:" > {run_dir}/gsd-review-qwen.md
cat {run_dir}/gsd-review-qwen.err >> {run_dir}/gsd-review-qwen.md 2>/dev/null
fi
```
<!-- reviewer-lane: cursor -->
**Cursor:**
```bash
# cursor-agent is a SEPARATE binary from the `cursor` IDE launcher; print mode (-p) takes the
@@ -453,6 +480,7 @@ if [ ! -s {run_dir}/gsd-review-cursor.md ]; then
fi
```
<!-- reviewer-lane: antigravity -->
**Antigravity CLI:**
**Maintainer note — why this block has three layers (last updated against agy 1.0.16):**
@@ -638,6 +666,7 @@ if [ -s {run_dir}/gsd-review-antigravity.md ] && \
fi
```
<!-- reviewer-lane: ollama -->
**Ollama (local, OpenAI-compatible):**
Read host and model from config. All three local backends share the same `/v1/chat/completions` endpoint — only host and model differ. Use `jq --rawfile` to safely encode the multi-line prompt as JSON without shell-escaping issues.
@@ -741,6 +770,7 @@ echo "Ollama review skipped: prompt budget (${OLLAMA_REVIEWER_BUDGET} tokens) to
fi
```
<!-- reviewer-lane: lm_studio -->
**LM Studio (local, OpenAI-compatible):**
```bash
# Resolve prompt budget for LM Studio: per-reviewer override > global default > null
@@ -826,6 +856,7 @@ echo "LM Studio review skipped: prompt budget (${LM_STUDIO_REVIEWER_BUDGET} toke
fi
```
<!-- reviewer-lane: llama_cpp -->
**llama.cpp (local, OpenAI-compatible):**
```bash
# Resolve prompt budget for llama.cpp: per-reviewer override > global default > null