chore(#2799): invoke_reviewers and write_reviews iterate declared lanes (#2861)

* chore(#2799): resolve reviewer lanes into executable invocation plans

Phase 5b of ADR-2782. Adds the resolver and runner that let invoke_reviewers
iterate declared lanes instead of hand-authored per-CLI bash.

Five additive descriptor amendments, each forced by a lane that ships today:
- LaneHandler gains 'opencode' — the lane rebuilds its review from assistant
  text parts of a --format json stream; a plain stdout copy re-breaks #1936.
- modelConfigKey — antigravity's key is review.models.agy, not .antigravity,
  so resolving by slug silently dropped a configured model.
- defaultHost/fallbackModel — Phase 4 federated every *_host with a default of
  empty string; the real fallback only existed in the bash.
- args becomes an argv template with a closed four-placeholder vocabulary.
  Positional splicing produced 'codex --model M -o F exec --ephemeral', which
  is not a valid invocation: codex injects in the middle, twice.
- kimi-code lane, with the bounded command-capability probe (needle
  --output-format) that tells Kimi Code from the legacy python kimi-cli.

Parity gate re-pointed: the workflow-text families it scanned are the text this
phase deletes, so they are replaced by descriptor-to-registry parity plus an
anti-parity check that no bespoke leg returns.

jq, curl and external timeout/gtimeout all drop out of the review path.

Refs #2782

* chore(#2799): add review-lane query surface and widen the manifest vocabulary

Adds the gsd-tools 'review-lane' route (plan/invoke/sections) the workflow
loops over, projects all twelve lanes into their capability manifests, and
widens capability-validator for the amendments.

opencode admitted to VALID_LANE_HANDLERS under the second arm of the enum's
own admission rule: one lane, justified by a documented upstream defect data
cannot express (#1936 — the agent can end its turn with zero output tokens and
--format default then drops the assistant text entirely).

Two bugs caught by an end-to-end stub run and fixed here:
- loadConfigResolved returns a provenance wrapper, not the config; using it
  directly resolved every key to undefined, which reads as 'nothing
  configured' and silently dropped every model override.
- hasBinary used shell:true with an args array (Node 26 DEP0190). Replaced
  with a PATH scan that spawns nothing at all.

Refs #2782

* chore(#2799): iterate declared lanes in invoke_reviewers and write_reviews

Replaces the eleven hand-authored per-CLI bash legs with a loop over resolved
lanes, and renders REVIEWS.md sections from each lane's declared
reviewsSection instead of thirteen hardcoded headings. review.md drops from
1104 lines to 507 (61KB to 28.7KB).

Parity gate re-pointed, as agreed: the leg-marker and section-heading families
scanned exactly the text this phase deletes, so they are replaced by
descriptor-to-registry parity in both directions, plus an anti-parity check
that fires if a bespoke leg is ever re-added. Enum, emitting sites and the
Object.keys lock moved together.

The budget-trim helper is hoisted out of the Ollama leg: it was always
lane-agnostic, and any lane may now declare a promptBudgetKey.

Refs #2782

* feat(#2799): bind the consented egress host and re-verify it at invocation

Completes ADR-2782 D5. Rule 1 was recorded in the ADR as delivered by Phase 3
but was not implemented: ConsentRecord had no host field and nothing in the
tree bound one, so this phase's rule-4 comparison had no baseline.

ConsentRecord gains an OPTIONAL reviewerHost. Optional is the whole design:
isValidConsentRecord does not require it, so every record already on disk
stays valid and no re-consent storm fires (D4 rule 5). It is deliberately
excluded from disclosureSignature — the loader has no config resolver, so
folding a config-derived value in would make loader and lifecycle compute
different signatures for the same manifest and re-prompt forever.

Install resolves hostConfigKey (falling back to the lane's declared
defaultHost, which is what the invocation path uses) and records it.
Invocation re-resolves and blocks on mismatch rather than silently
redirecting. Absence allows: no record, or a record predating the field,
means nothing to compare — denying there would break every existing
local-model user on upgrade.

Refs #2782

* test(#2799): cover the resolver, runner and handlers; retarget the parity suites

Adds the golden invocation-plan table (one row per shipped lane, derived from
the bash legs rather than the descriptor types) plus runner coverage for the
probe, empty-output policy, the three handlers and the egress check.

Retargets the existing suites onto the new contract: descriptor-to-registry
parity, the anti-parity check, the opencode handler, and the twelfth lane.

Two corrections found by running them:
- modelConfigKey was required; that breaks D4 rule 2, since a reviewer
  manifest authored before this phase would fail validation on upgrade. It is
  optional, read as null when absent.
- the antigravity non-zero-exit test pre-seeded the transcript, which asserted
  that a STALE entry leaks through — the exact bug the watermark prevents. The
  spawn now appends, as the real tool does.

Refs #2782

* fix(#2799): restore agy --add-dir and the self-report prompt in the handler

Retargeting the three legacy reviewer suites off the deleted bash surfaced two
real regressions in the port, both #2176:

- --add-dir was dropped. Without it agy's permission context never receives the
  cwd repo, so the agent anchors on its own scratch dir and reviews the plan
  text in isolation — the exact failure the Review Instructions forbid. It is
  capability-probed, because an older agy rejects the unknown flag outright and
  a lane that fails to start is worse than one running on the prompt anchor.
- the prompt lost the clause mandating a REVIEWED-WITHOUT-REPO-ACCESS
  self-report, which is what makes a blind review distinguishable from a
  grounded one. antigravity now builds its own prompt variant.

Also ports the #2073 mode-2 cli.log diagnostic, which was dropped: a pinned
model that 404s exits 0 with empty stdout AND an empty transcript, so agy's own
log is the only evidence that anything failed.

The three suites now assert against the plan and the handler instead of
matching fence text, so they no longer need allow-test-rule exemptions.

Refs #2782

* docs(#2799): document the declared lanes, the new flag, and dropped prerequisites

COMMANDS.md gains --kimi-code and replaces the jq-prerequisite paragraph,
which is now false: no lane requires jq, curl or an external timeout. Adds the
changed-egress-destination behavior, since a blocked lane is something a user
can hit.

CONFIGURATION.md records that the model config key is declared per lane rather
than derived from the flag — antigravity's is review.models.agy — and adds
review.models.kimi-code.

reviewer-instances.md now routes an instance through its lane's single
invocation seam instead of a copied per-adapter bash block, which is what lets
a cross-cutting fix reach instances for free. That required implementing the
--model/--agent/--as flags it documents; --model re-resolves through the lane's
argv template rather than splicing, so the flag lands where the lane declares
it rather than ahead of a subcommand.

CONTEXT.md glossary gains both new modules.

Refs #2782

* chore(#2799): drop the stale emitted-drift acknowledgment

The only entry was #2797's, acknowledging COMMENT-ONLY GROWTH in review.md.
That file now shrinks by ~32KB and every emitted hash that moved is
attributable to this diff, so the ack no longer explains anything. Removing
the last entry means removing the file: its presence is the alarm, and an
empty one signals nothing.

Verified by deleting it and re-running the attribution and provenance gates
plus lint:ci — all green without it.

Refs #2782

* docs(#2799): record the Phase 5b vocabulary widenings in ADR-2782

Five additive amendments, each forced by a lane that ships today, plus two
corrections the phase had to make rather than work around:

- D5 rule 1 was recorded as delivered by Phase 3 and was not implemented, so
  this phase's rule-4 comparison had no baseline. Recorded because an ADR
  asserting a rule was delivered is exactly what stops a later phase checking.
- The DEFECT.GENERATIVE-FIX gate is re-pointed: its workflow-text families
  scanned the text this phase deletes.

Also records that D7's 'skip the probe where no bounding mechanism exists'
carve-out is obsolete — in practice it meant the Antigravity lane ran unbounded
on every stock macOS host, which ships neither timeout nor gtimeout.

Refs #2782

* fix(#2799): close four defects found by adversarial review

Two confirmed bugs, both reproduced before fixing:

- resolveLanePlan was not total. An openai-http lane with a missing or
  non-object invoke dereferenced inv.hostConfigKey and threw, contradicting
  the module's own documented contract; the spawn branch guarded correctly and
  the http branch did not. The CLI seam resolves every selected lane in one
  map, so one malformed overlay manifest would have aborted the whole review
  rather than dropping its own lane. Guarded, plus a per-lane try/catch at the
  seam so a throw can never take down siblings.
- A reviewer-instance model was silently dropped for any lane declaring
  modelConfigKey null (cursor, qwen, coderabbit). reviewer_instances validates
  that cli is a known slug but never that the slug accepts a model, so a user
  could configure one, get a clean run, and never learn a different model
  reviewed their plan. Now warns explicitly.

Two hardening fixes:

- The slug is concatenated into artifact paths, so LANE_SLUG_RE is enforced in
  the resolver rather than inherited from a validator that does not run on this
  path — the module documents itself as the overlay-manifest trust boundary, so
  it should not depend on someone else having checked.
- normalizeHost mangled a scheme-less value: new URL('localhost:11434') parses
  with an empty hostname, so it became 'localhost://11434' and was compared and
  requested as if real. An empty hostname now means not-a-URL.

Also documents the one gap that cannot be closed here: the antigravity
watermark is keyed by workspace, so two concurrent reviews of the same repo
share a transcript. agy exposes no per-invocation id to filter on, so the
handler now states which half of its never-stale guarantee actually holds.

Refs #2782

* test(#2799): retarget the remaining eight review.md-asserting suites

The remote runner found 37 failures the local sweep missed (it hit the shell's
two-minute cap before reaching these). All eight extract per-CLI bash from
review.md that this phase deletes; each protects a real invariant, so each is
retargeted onto the plan, the runner or the handler rather than removed.

Three real defects surfaced by doing so:

- effort args never reached ANY lane. model-resolver.cjs exports no
  resolveExecution, so effortFor silently returned [] every time. Restored by
  calling the same bounded resolve-execution query the bash legs used — and
  NOT with --raw, which prints the resolved effort rather than the picked
  field, so claude got 'low' instead of '--effort low'.
- the timeout guidance lost 'a silent empty output is a timeout kill, not a
  crash' — the operator note that exists because of the Codex 0xc0000142
  misdiagnosis. Restored.
- the opencode handler dropped EMPTY assistant text parts. The shipped jq was
  , and  only substitutes for false/null — an empty
  string is truthy in jq and contributed a blank line. Found by a property
  test shrinking to ['', ''].

The opencode property suite no longer spawns jq at all, which deletes the
#2099 hang mechanism it was architected around rather than mitigating it.

Refs #2782

* fix(#2799): register the two new generated modules, and untrack them

The remote runner caught build output committed to git. Both new modules
compile from src/*.cts into gsd-core/bin/lib/*.cjs, and every sibling generated
that way is gitignored and eslint-ignored (ADR-457) - including Phase 1's own
review-lane-descriptor.cjs. Mine were neither, so repo-invariants' "each
bin/lib/*.cjs is linted xor ignored according to migration state" failed.

Registered both in .gitignore and eslint.config.mjs alongside the Phase 1
module, and dropped them from the index. Nothing about the shipped behaviour
changes; the artifacts are rebuilt by build:lib.

This is the new-.cts-module registration ripple, and it is the one part of it I
had not completed - the CONTEXT.md glossary and the inventory manifest were
already done.

Refs #2782

* chore(#2799): backfill changeset pr number to 2861

* chore(#2799): backfill changeset pr number to 2861

---------

Co-authored-by: Test <test@example.com>
This commit is contained in:
Tom Boucher
2026-07-30 12:48:06 -04:00
committed by GitHub
parent 97e9fd50bd
commit 3f6b063fbb
48 changed files with 4452 additions and 2521 deletions

View File

@@ -137,6 +137,22 @@ interface ConsentRecord {
*/
contentHash: string;
consentedAt: string;
/**
* ADR-2782 D5 rule 1 (#2799): the egress destination this consent was granted against, for an
* `openai-http` reviewer lane. Absent for every other capability, and for records written before
* the rule existed.
*
* OPTIONAL BY DESIGN, and `isValidConsentRecord` deliberately does not require it. Making it
* mandatory would invalidate every consent record already on disk, forcing re-consent across all
* installed capabilities — precisely the spurious re-consent storm D4 rule 5 forbids.
*
* Deliberately NOT part of `disclosureSignature`: that signature is computed by BOTH the loader
* and the lifecycle so the two can never drift, and the loader has no config resolver. Folding a
* config-derived value in would make them compute different signatures for the same manifest and
* re-prompt forever. The signature binds the manifest; this field binds the destination; Phase 5b
* re-resolves and compares at invocation, which is where D5 rule 4 puts the check.
*/
reviewerHost?: string;
}
interface ConsentStore {
@@ -455,6 +471,9 @@ function isValidConsentRecord(rec: unknown): rec is ConsentRecord {
// match a recomputed hash and is treated as invalid (fail closed).
if (typeof r['contentHash'] !== 'string' || !r['contentHash']) return false;
if (typeof r['consentedAt'] !== 'string' || !r['consentedAt']) return false;
// Optional (see ConsentRecord.reviewerHost): absent is valid and is the common case. Present but
// non-string is a corrupt record — reject rather than silently comparing against a non-host.
if (r['reviewerHost'] !== undefined && typeof r['reviewerHost'] !== 'string') return false;
return true;
}
@@ -698,8 +717,9 @@ function recordProjectConsent(args: {
integrity: string;
disclosureSignature: string;
contentHash: string;
reviewerHost?: string;
}): void {
const { gsdHome, projectRoot, id, integrity, disclosureSignature, contentHash } = args;
const { gsdHome, projectRoot, id, integrity, disclosureSignature, contentHash, reviewerHost } = args;
if (isUnsafeCapabilityId(id)) {
throw new Error(
`Invalid capability id "${String(id)}": must match /^[a-z][a-z0-9-]*$/ (kebab-case, lowercase). ` +
@@ -744,6 +764,9 @@ function recordProjectConsent(args: {
disclosureSignature,
contentHash,
consentedAt: new Date().toISOString(),
// Written only when the capability actually declares an egress destination, so a spawn lane
// or a non-lane capability keeps a byte-identical record shape.
...(typeof reviewerHost === 'string' && reviewerHost ? { reviewerHost } : {}),
};
writeConsentStore(gsdHome, store);
} finally {
@@ -786,6 +809,39 @@ function revokeProjectConsent(args: { gsdHome?: string; projectRoot: string; id:
}
}
/**
* The egress destination a capability's consent was granted against, or `undefined`.
*
* ADR-2782 D5 rule 4 (#2799) — read on the INVOCATION path, not only at install, because
* `hostConfigKey` names a key in `.planning/config.json`: the one consent-bound value that lives
* outside the SHA-pinned bundle and can be changed by an ordinary pull request with no re-install
* and no integrity check.
*
* `undefined` means "nothing to compare", and the caller MUST treat that as allow, not deny. Two
* legitimate ways to get it: the capability has no consent record at all (first-party lanes ship
* inside the SHA-pinned distribution and are never consent-gated), or the record predates this
* field. Denying on absence would break every existing local-model user on upgrade.
*
* Non-throwing: a missing, corrupt, or oversized store yields `undefined` like any other absence.
*/
function readConsentedReviewerHost(args: {
gsdHome?: string;
projectRoot: string;
id: string;
}): string | undefined {
const { gsdHome, projectRoot, id } = args;
if (isUnsafeCapabilityId(id)) return undefined;
try {
const store = readConsentStore(gsdHome);
const key = consentKey(realpathProject(projectRoot), id);
if (!Object.prototype.hasOwnProperty.call(store.records, key)) return undefined;
const host = store.records[key].reviewerHost;
return typeof host === 'string' && host ? host : undefined;
} catch {
return undefined;
}
}
/**
* #1459 finding 2 (round 6): TEST-ONLY — override the cumulative bundle entry-count cap and return a
* restore() that resets it to the production default. Lets a test prove the streaming walk fails closed
@@ -805,6 +861,7 @@ export = {
consentStorePath,
bundleContentHash,
readConsentStore,
readConsentedReviewerHost,
hasProjectConsent,
recordProjectConsent,
revokeProjectConsent,

View File

@@ -64,7 +64,7 @@ const trustMod = require('./capability-trust.cjs') as {
signatureForManifest: (manifest: Record<string, unknown>, stagedDir?: string) => string;
};
const consentMod = require('./capability-consent.cjs') as {
recordProjectConsent: (args: { gsdHome?: string; projectRoot: string; id: string; integrity: string; disclosureSignature: string; contentHash: string }) => void;
recordProjectConsent: (args: { gsdHome?: string; projectRoot: string; id: string; integrity: string; disclosureSignature: string; contentHash: string; reviewerHost?: string }) => void;
revokeProjectConsent: (args: { gsdHome?: string; projectRoot: string; id: string }) => void;
/** #1459 CB-1/CB-2: recompute the full-bundle content hash (the consent security binding). */
bundleContentHash: (capDir: string) => string;
@@ -826,6 +826,61 @@ function warnIfConsentSkipped(opts: LifecycleOptions, id: string): void {
* Best-effort: a consent-store write failure must not turn a successful install/upgrade into a
* failure (the bundle is already committed) — it is surfaced as a warning, not a throw.
*/
/**
* Resolve an `openai-http` reviewer lane's declared `hostConfigKey` to the destination it currently
* names, or `undefined` when this capability is not such a lane.
*
* Falls back to the lane's declared `defaultHost` when the key is unset, because that is exactly
* what the invocation path will do — binding the config value while the runtime uses the default
* would guarantee a mismatch on the very first review.
*
* Non-throwing: consent binding is best-effort and must never turn a successful install into a
* failure. An unresolvable host simply records nothing, which reads as "not bound" and allows.
*/
function resolveReviewerEgressHost(
opts: LifecycleOptions,
manifest: Record<string, unknown>,
): string | undefined {
try {
const reviewer = manifest['reviewer'];
if (reviewer === null || typeof reviewer !== 'object' || Array.isArray(reviewer)) return undefined;
const r = reviewer as Record<string, unknown>;
if (r['transport'] !== 'openai-http') return undefined;
const invoke = r['invoke'];
if (invoke === null || typeof invoke !== 'object') return undefined;
const inv = invoke as Record<string, unknown>;
const key = typeof inv['hostConfigKey'] === 'string' ? inv['hostConfigKey'] : '';
const fallback = typeof inv['defaultHost'] === 'string' ? inv['defaultHost'] : '';
let configured = '';
if (key) {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const cfgLoader = require('./config-loader.cjs') as {
loadConfigResolved?: (cwd: string) => { config?: Record<string, unknown> };
};
const root = projectRootMod.consentProjectRoot(opts.runtimeDir);
const cfg = cfgLoader.loadConfigResolved ? (cfgLoader.loadConfigResolved(root).config ?? {}) : {};
let cur: unknown = cfg;
for (const part of key.split('.')) {
if (cur === null || typeof cur !== 'object') { cur = undefined; break; }
cur = Object.prototype.hasOwnProperty.call(cur, part)
? (cur as Record<string, unknown>)[part]
: undefined;
}
if (typeof cur === 'string') configured = cur.trim();
}
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { normalizeHost } = require('./review-lane-invocation.cjs') as {
normalizeHost: (s: string) => string;
};
const resolved = normalizeHost(configured || fallback);
return resolved || undefined;
} catch {
return undefined;
}
}
function bindProjectConsent(opts: LifecycleOptions, id: string, integrity: string, manifest: Record<string, unknown>): void {
// #1459 IC-07: a project-scope op WITHOUT a consent store cannot bind — warn (then nothing to do).
if (!shouldBindConsent(opts)) {
@@ -843,6 +898,11 @@ function bindProjectConsent(opts: LifecycleOptions, id: string, integrity: strin
integrity,
disclosureSignature: trustMod.signatureForManifest(manifest),
contentHash: consentMod.bundleContentHash(capDir(opts.runtimeDir, id)),
// ADR-2782 D5 rule 1 (#2799): bind the RESOLVED egress destination, not merely the config key
// that names it. The key lives in `.planning/config.json`, outside the SHA-pinned bundle, so
// without this the user consents to "wherever that key points" — a promise the bundle hash
// cannot keep. Phase 5b re-resolves and compares at invocation (rule 4).
reviewerHost: resolveReviewerEgressHost(opts, manifest),
});
} catch (err) {
// #1459 IC-05/WIN-2: a consent-store write failure (read-only/UNC/NFS store) must NOT turn an

View File

@@ -65,8 +65,21 @@ export type EffortChannel = 'none' | 'argv' | 'env';
/** ADR-2782 D2 — CodeRabbit reviews a diff, not the source tree (`review.md:367`). */
export type EvidenceClass = 'source-grounded' | 'diff-only';
/** ADR-2782 D6 — closed enum of first-party imperative modules. Ported in Phase 5b. */
export type LaneHandler = null | 'antigravity' | 'openai-compatible';
/**
* ADR-2782 D6 — closed enum of first-party imperative modules. Ported in Phase 5b.
*
* `'opencode'` was added by Phase 5b (#2799) as an additive widening, forced by a lane that ships
* today. Phase 1 declared `opencode.handler = null`, but the lane's review is RECONSTRUCTED from
* assistant `text` parts of a `--format json` stream — `--format json` is the primary invocation,
* not a fallback, because the default formatter drops the text when the agent ends its turn with no
* final message (#1936). A data-driven `outputChannel: 'stdout'` copy would write the raw JSON
* envelope into REVIEWS.md as the review, straight back into #1936.
*
* The alternative — an `outputChannel: 'json-parts'` member — was rejected: it pushes a parsing
* language into the descriptor and would want a selector expression next. D6's whole point is that
* divergence escapes to NAMED FIRST-PARTY CODE rather than accreting inside data.
*/
export type LaneHandler = null | 'antigravity' | 'openai-compatible' | 'opencode';
/**
* What a lane does when it produces no usable output.
@@ -83,8 +96,38 @@ export type LaneProbe =
| { kind: 'command-capability'; binary: string; needle: string; timeoutMs: number }
| { kind: 'http-reachable'; hostConfigKey: string; path: string; timeoutMs: number };
/**
* Argv placeholders (Phase 5b, #2799).
*
* `args` is an argv TEMPLATE, not a prefix. The injected pieces — model, effort, output file,
* argv-borne prompt — do not all go in the same place, and no positional rule expresses that:
* `codex` injects the model in the MIDDLE (after the `exec --ephemeral` subcommand) and the output
* file later still, while `gemini` injects the model first and five lanes end with a bare `-` that
* must stay last. Splicing by position silently produced
* `codex --model M -o F exec --ephemeral …`, which is not a valid codex invocation.
*
* So each lane declares WHERE each piece goes. A placeholder expands to zero or more argv elements
* and vanishes when it has nothing to contribute (no model configured, no effort channel, prompt on
* stdin), which is what lets one template serve the configured and unconfigured cases.
*
* This is a closed four-member vocabulary with no expressions, no nesting and no conditionals — a
* placeholder set, deliberately not a template language. The moment it needs a conditional, the
* lane wants a `handler` instead (D6).
*/
export const ARGV_PLACEHOLDER = Object.freeze({
/** `modelArg` + the resolved model, or nothing. */
MODEL: '{{model}}',
/** The host's effort argv, or nothing unless `effortChannel` is `argv`. */
EFFORT: '{{effort}}',
/** `outputArg` + the review path, or nothing unless `outputChannel` is `file-arg`. */
OUTPUT: '{{output}}',
/** The argv-borne prompt, or nothing unless `promptChannel` is `argv`/`argv-file-ref`. */
PROMPT: '{{prompt}}',
} as const);
export interface SpawnInvoke {
binary: string;
/** Argv template — see `ARGV_PLACEHOLDER`. Order here is the order the tool receives. */
args: ReadonlyArray<string>;
promptChannel: PromptChannel;
outputChannel: OutputChannel;
@@ -98,8 +141,19 @@ export interface SpawnInvoke {
export interface HttpInvoke {
/** Dotted config key holding the base URL. */
hostConfigKey: string;
/**
* Base URL used when `hostConfigKey` resolves to empty.
*
* Added by Phase 5b (#2799). Phase 4 federated every `*_host` key with a default of `""`, so the
* REAL fallback (`http://localhost:11434` and friends) only ever existed inside the bash leg. A
* data-driven lane with an empty host and no declared default would POST to a garbage URL, so
* the fallback has to be declared somewhere — and the lane is the only thing that knows it.
*/
defaultHost: string;
path: string;
modelDiscovery: 'none' | 'first-from-models-endpoint';
/** Model used when neither config nor discovery yields one. */
fallbackModel: string;
effortChannel: 'none';
}
@@ -123,6 +177,20 @@ interface ReviewerLaneCommon {
requiresBinaries: ReadonlyArray<string>;
/** Dotted config key for per-lane prompt trimming, or null. */
promptBudgetKey: string | null;
/**
* Dotted config key holding this lane's model override, or null when the lane accepts none.
*
* Added by Phase 5b (#2799). Phase 1 left the model key IMPLICIT (`review.models.<slug>`), and
* that convention is wrong for a lane that ships today: `antigravity`'s slug is `antigravity` but
* its key is `review.models.agy` (`review.md:291`, and Phase 4 federated it under that exact
* name). Resolving by slug would look up `review.models.antigravity`, miss, and silently ignore a
* configured model — the pinned-model escape hatch #2073 added precisely so a 404ing default can
* be overridden.
*
* Declared rather than derived, for the same reason `promptBudgetKey` is: the key is data about
* the lane, and a naming convention that one shipped lane already breaks is not a contract.
*/
modelConfigKey: string | null;
handler: LaneHandler;
}
@@ -153,11 +221,10 @@ const SPAWN_STDIN_STDOUT = {
} as const;
/**
* The eleven lanes shipped today, in `write_reviews` order.
* The twelve declared lanes, in `write_reviews` order.
*
* `kimi-code` is deliberately absent: it is net-new with no leg, and ADR-2782
* lands it in Phase 5b alongside the iteration that can invoke it. Declaring it
* here would make it selectable but not invocable.
* `kimi-code` joined in Phase 5b (#2799, closes #2718) — ADR-2782's phase table lands it here
* rather than in 5a precisely so it arrives together with the iteration that can invoke it.
*/
export const REVIEWER_LANES: ReadonlyArray<ReviewerLane> = Object.freeze([
{
@@ -167,7 +234,7 @@ export const REVIEWER_LANES: ReadonlyArray<ReviewerLane> = Object.freeze([
probe: { kind: 'command-exists', binary: 'gemini' },
invoke: {
binary: 'gemini',
args: ['-p', '-'],
args: ['{{model}}', '-p', '-'],
...SPAWN_STDIN_STDOUT,
modelArg: '-m',
effortChannel: 'none',
@@ -178,6 +245,7 @@ export const REVIEWER_LANES: ReadonlyArray<ReviewerLane> = Object.freeze([
evidenceClass: 'source-grounded',
requiresBinaries: [],
promptBudgetKey: null,
modelConfigKey: 'review.models.gemini',
handler: null,
},
{
@@ -189,7 +257,7 @@ export const REVIEWER_LANES: ReadonlyArray<ReviewerLane> = Object.freeze([
probe: { kind: 'command-exists', binary: 'claude' },
invoke: {
binary: 'claude',
args: ['-p', '-'],
args: ['{{model}}', '{{effort}}', '-p', '-'],
...SPAWN_STDIN_STDOUT,
modelArg: '--model',
effortChannel: 'argv',
@@ -200,6 +268,7 @@ export const REVIEWER_LANES: ReadonlyArray<ReviewerLane> = Object.freeze([
evidenceClass: 'source-grounded',
requiresBinaries: [],
promptBudgetKey: null,
modelConfigKey: 'review.models.claude',
handler: null,
},
{
@@ -213,7 +282,8 @@ export const REVIEWER_LANES: ReadonlyArray<ReviewerLane> = Object.freeze([
probe: { kind: 'command-exists', binary: 'codex' },
invoke: {
binary: 'codex',
args: ['exec', '--ephemeral', '--skip-git-repo-check', '-'],
// Leg order exactly: codex exec --ephemeral --model M $EFFORT --skip-git-repo-check -o F -
args: ['exec', '--ephemeral', '{{model}}', '{{effort}}', '--skip-git-repo-check', '{{output}}', '-'],
promptChannel: 'stdin',
outputChannel: 'file-arg',
outputArg: '-o',
@@ -226,6 +296,7 @@ export const REVIEWER_LANES: ReadonlyArray<ReviewerLane> = Object.freeze([
evidenceClass: 'source-grounded',
requiresBinaries: [],
promptBudgetKey: null,
modelConfigKey: 'review.models.codex',
handler: null,
},
{
@@ -250,6 +321,8 @@ export const REVIEWER_LANES: ReadonlyArray<ReviewerLane> = Object.freeze([
evidenceClass: 'diff-only',
requiresBinaries: [],
promptBudgetKey: null,
// Accepts no model flag at all (review.md:367) — not merely "none configured".
modelConfigKey: null,
handler: null,
},
{
@@ -262,7 +335,7 @@ export const REVIEWER_LANES: ReadonlyArray<ReviewerLane> = Object.freeze([
probe: { kind: 'command-exists', binary: 'opencode' },
invoke: {
binary: 'opencode',
args: ['run', '--format', 'json', '-'],
args: ['run', '{{model}}', '{{effort}}', '--format', 'json', '-'],
...SPAWN_STDIN_STDOUT,
modelArg: '--model',
effortChannel: 'argv',
@@ -271,9 +344,14 @@ export const REVIEWER_LANES: ReadonlyArray<ReviewerLane> = Object.freeze([
emptyOutput: 'stub-with-stderr',
reviewsSection: 'OpenCode',
evidenceClass: 'source-grounded',
requiresBinaries: ['jq'],
// Phase 5b: the handler reconstructs from the JSON stream with JSON.parse, so `jq` — absent on
// stock Windows/Git-Bash (#2589) — is no longer a prerequisite for this lane.
requiresBinaries: [],
promptBudgetKey: null,
handler: null,
modelConfigKey: 'review.models.opencode',
// Phase 5b (#2799): was `null`. The review is REBUILT from assistant `text` parts; a plain
// stdout copy would write the raw JSON envelope as the review (#1936). See LaneHandler.
handler: 'opencode',
},
{
slug: 'qwen',
@@ -293,6 +371,7 @@ export const REVIEWER_LANES: ReadonlyArray<ReviewerLane> = Object.freeze([
evidenceClass: 'source-grounded',
requiresBinaries: [],
promptBudgetKey: null,
modelConfigKey: null,
handler: null,
},
{
@@ -305,7 +384,7 @@ export const REVIEWER_LANES: ReadonlyArray<ReviewerLane> = Object.freeze([
probe: { kind: 'command-exists', binary: 'cursor-agent' },
invoke: {
binary: 'cursor-agent',
args: ['-p', '--mode', 'ask', '--trust', '--output-format', 'text'],
args: ['-p', '--mode', 'ask', '--trust', '--output-format', 'text', '{{prompt}}'],
promptChannel: 'argv-file-ref',
outputChannel: 'stdout',
modelArg: null,
@@ -317,6 +396,7 @@ export const REVIEWER_LANES: ReadonlyArray<ReviewerLane> = Object.freeze([
evidenceClass: 'source-grounded',
requiresBinaries: [],
promptBudgetKey: null,
modelConfigKey: null,
handler: null,
},
{
@@ -330,7 +410,7 @@ export const REVIEWER_LANES: ReadonlyArray<ReviewerLane> = Object.freeze([
probe: { kind: 'command-exists', binary: 'agy' },
invoke: {
binary: 'agy',
args: ['--print-timeout', '540s', '-p'],
args: ['--print-timeout', '540s', '{{model}}', '-p', '{{prompt}}'],
promptChannel: 'argv-file-ref',
outputChannel: 'stdout',
modelArg: '--model',
@@ -340,8 +420,12 @@ export const REVIEWER_LANES: ReadonlyArray<ReviewerLane> = Object.freeze([
emptyOutput: 'handler-owned',
reviewsSection: 'Antigravity',
evidenceClass: 'source-grounded',
requiresBinaries: ['jq'],
// Phase 5b: the handler reads the transcript with JSON.parse per line, not `jq`.
requiresBinaries: [],
promptBudgetKey: null,
// NOT `review.models.antigravity` — the shipped key is `review.models.agy` (review.md:291) and
// Phase 4 federated it under that name. This lane is why the key is declared, not derived.
modelConfigKey: 'review.models.agy',
handler: 'antigravity',
},
{
@@ -356,16 +440,21 @@ export const REVIEWER_LANES: ReadonlyArray<ReviewerLane> = Object.freeze([
},
invoke: {
hostConfigKey: 'review.ollama_host',
defaultHost: 'http://localhost:11434',
path: '/v1/chat/completions',
modelDiscovery: 'first-from-models-endpoint',
fallbackModel: 'llama3',
effortChannel: 'none',
},
timeoutFloorMs: 120_000,
emptyOutput: 'stub-with-stderr',
reviewsSection: 'Ollama',
evidenceClass: 'source-grounded',
requiresBinaries: ['jq'],
// Phase 5b: the openai-compatible handler speaks HTTP directly and parses with JSON.parse, so
// neither `jq` nor `curl` is a prerequisite any more (#2589's stated Windows hazard).
requiresBinaries: [],
promptBudgetKey: 'review.max_prompt_tokens_per_reviewer.ollama',
modelConfigKey: 'review.models.ollama',
handler: 'openai-compatible',
},
{
@@ -380,16 +469,19 @@ export const REVIEWER_LANES: ReadonlyArray<ReviewerLane> = Object.freeze([
},
invoke: {
hostConfigKey: 'review.lm_studio_host',
defaultHost: 'http://localhost:1234',
path: '/v1/chat/completions',
modelDiscovery: 'first-from-models-endpoint',
fallbackModel: 'local-model',
effortChannel: 'none',
},
timeoutFloorMs: 120_000,
emptyOutput: 'stub-with-stderr',
reviewsSection: 'LM Studio',
evidenceClass: 'source-grounded',
requiresBinaries: ['jq'],
requiresBinaries: [],
promptBudgetKey: 'review.max_prompt_tokens_per_reviewer.lm_studio',
modelConfigKey: 'review.models.lm_studio',
handler: 'openai-compatible',
},
{
@@ -404,18 +496,66 @@ export const REVIEWER_LANES: ReadonlyArray<ReviewerLane> = Object.freeze([
},
invoke: {
hostConfigKey: 'review.llama_cpp_host',
defaultHost: 'http://localhost:8080',
path: '/v1/chat/completions',
modelDiscovery: 'first-from-models-endpoint',
fallbackModel: 'local-model',
effortChannel: 'none',
},
timeoutFloorMs: 120_000,
emptyOutput: 'stub-with-stderr',
reviewsSection: 'llama.cpp',
evidenceClass: 'source-grounded',
requiresBinaries: ['jq'],
requiresBinaries: [],
promptBudgetKey: 'review.max_prompt_tokens_per_reviewer.llama_cpp',
modelConfigKey: 'review.models.llama_cpp',
handler: 'openai-compatible',
},
{
// Phase 5b (#2799) — closes #2718. Net-new in this phase BY DESIGN (ADR-2782's phase table):
// declaring it in 5a would have made it selectable but not invocable, producing an empty
// section for the whole 5a → 5b window.
//
// The probe is `command-capability`, not `command-exists`, and that is the entire reason D7's
// vocabulary ships wider than existence: `kimi` is claimed by BOTH the Kimi Code CLI (Node) and
// the legacy Python kimi-cli, which is a separate first-party runtime capability in this repo.
// An existence-only probe registers the wrong tool. The needle `--output-format` appears in
// Kimi Code's `--help` and is absent from the legacy CLI (whose headless flags are `--print` /
// `--work-dir`). Verified in both directions against Kimi Code CLI 0.29.2 and a stub legacy
// binary in closed PR #2776 — analysis carried forward with credit to @drungrin.
//
// The original probe there was an UNBOUNDED `kimi --help | grep` that ran on EVERY /gsd:review
// regardless of flags: a live instance of this repo's named Unbounded Subprocesses defect, and
// the review blocker. Here the bound is declared (`timeoutMs`) and the runner enforces it, and
// the probe runs only for a SELECTED lane.
slug: 'kimi-code',
flags: ['--kimi-code'],
transport: 'spawn',
probe: {
kind: 'command-capability',
binary: 'kimi',
needle: '--output-format',
timeoutMs: 5_000,
},
invoke: {
// Print mode takes the prompt as an ARGUMENT, so the full plan set goes by file reference to
// stay clear of the 32,767-char Windows execFileSync ceiling — same shape as cursor.
binary: 'kimi',
args: ['{{model}}', '-p', '{{prompt}}'],
promptChannel: 'argv-file-ref',
outputChannel: 'stdout',
modelArg: '-m',
effortChannel: 'none',
},
timeoutFloorMs: 900_000,
emptyOutput: 'stub-with-stderr',
reviewsSection: 'Kimi Code',
evidenceClass: 'source-grounded',
requiresBinaries: [],
promptBudgetKey: null,
modelConfigKey: 'review.models.kimi-code',
handler: null,
},
].map((lane) => Object.freeze(lane)) as ReviewerLane[]);
/* ------------------------------------------------------------------ *
@@ -433,12 +573,9 @@ export const PARITY_VIOLATION = Object.freeze({
INVALID_SLUG: 'invalid_slug',
ROSTER_SLUG_UNDECLARED: 'roster_slug_undeclared',
DESCRIPTOR_LANE_NOT_IN_ROSTER: 'descriptor_lane_not_in_roster',
LEG_MARKER_MISSING: 'leg_marker_missing',
LEG_MARKER_DUPLICATED: 'leg_marker_duplicated',
LEG_MARKER_UNDECLARED: 'leg_marker_undeclared',
SECTION_MISSING: 'section_missing',
SECTION_DUPLICATED: 'section_duplicated',
SECTION_UNDECLARED: 'section_undeclared',
REGISTRY_LANE_UNDECLARED: 'registry_lane_undeclared',
DESCRIPTOR_LANE_NOT_IN_REGISTRY: 'descriptor_lane_not_in_registry',
BESPOKE_LEG_PRESENT: 'bespoke_leg_present',
DUPLICATE_SLUG: 'duplicate_slug',
DUPLICATE_FLAG: 'duplicate_flag',
DUPLICATE_SECTION: 'duplicate_section',
@@ -460,19 +597,27 @@ export interface ParityResult {
export interface ParityInput {
descriptor: ReadonlyArray<ReviewerLane>;
roster: ReadonlyArray<string>;
/**
* Reviewer slugs declared by `reviewer` bodies in the GENERATED capability registry.
*
* Phase 5b (#2799). Once `invoke_reviewers` iterates lanes, the registry — not the workflow text —
* is what actually decides which lanes exist at runtime, so this is the surface parity has to
* bind. A missing or malformed value degrades to violations rather than silence, exactly as
* `workflowText` does: a checker that cannot tell "no registry" from "registry agrees" is worse
* than no checker.
*/
registry: ReadonlyArray<string>;
/** Full text of gsd-core/workflows/review.md. */
workflowText: string;
}
/**
* The machine-readable marker that makes an `invoke_reviewers` leg identifiable.
* The marker that used to make a hand-authored `invoke_reviewers` leg identifiable.
*
* The legs are prose-labelled (`**Qwen Code:**`, `**LM Studio (local,
* OpenAI-compatible):**`), and five NON-lane bold labels in the same step have
* the identical bold-then-fence shape (`**Timeout guidance (#2194):**`,
* `**No hook-trust bypass (#2479):**`, `**Maintainer note — …:**`). Inferring
* legs from prose shape would be a heuristic asserting what it cannot prove, so
* each leg carries an explicit marker instead. Phase 5b iterates on these.
* Phase 5b (#2799) deleted every bespoke leg, so this regex flipped polarity: matching one is now
* the VIOLATION (`BESPOKE_LEG_PRESENT`) rather than the requirement. It is retained precisely
* because deleting it would leave nothing stopping a future contributor from quietly re-adding a
* per-CLI block — which is the drift (#2718 → #2781) this whole epic exists to end.
*/
const LEG_MARKER_RE = /<!--\s*reviewer-lane:\s*([a-z0-9_-]+)\s*-->/g;
@@ -495,23 +640,8 @@ const LEG_MARKER_RE = /<!--\s*reviewer-lane:\s*([a-z0-9_-]+)\s*-->/g;
*/
export const LANE_SLUG_RE = /^[a-z0-9][a-z0-9_-]*$/;
/**
* A lane section heading in write_reviews.
*
* Anchored at h2 with an exact ` Review` suffix and NO parenthetical, because:
* - `## OpenCode Review (opencode-deepseek)` is an ADR-1517 reviewer INSTANCE,
* and ADR-2782 D8 states instances are not lanes — two such headings are
* already in the file, so a naive matcher fails on day one;
* - `## Consensus Summary` has no ` Review` suffix;
* - `# Cross-AI Plan Review — Phase {N}` is h1, not h2.
* `[^()\n]+` excludes the parenthetical form rather than stripping it, so an
* instance heading never resolves to a lane.
*/
const SECTION_HEADING_RE = /^##[ \t]+([^()\n\r]+?) Review[ \t]*$/gm;
/** Bounds of the step a marker must appear inside. */
/** Bounds of the step a bespoke leg marker could appear inside. */
const INVOKE_STEP_RE = /<step name="invoke_reviewers">([\s\S]*?)<\/step>/;
const WRITE_STEP_RE = /<step name="write_reviews">([\s\S]*?)<\/step>/;
function sliceStep(workflowText: string, re: RegExp): string {
const m = workflowText.match(re);
@@ -532,21 +662,32 @@ function countOccurrences(haystack: string, re: RegExp): Map<string, number> {
}
/**
* Bidirectional parity across four surfaces: the descriptor, the roster
* (`KNOWN_REVIEWER_SLUGS`), the `invoke_reviewers` legs, and the `write_reviews`
* sections.
* Bidirectional parity across three surfaces: the descriptor, the roster
* (`KNOWN_REVIEWER_SLUGS`), and the generated capability **registry** — plus one anti-parity
* assertion against the workflow.
*
* Bidirectional is the point. A forward-only check ("does each declared lane
* resolve?") misses the failure this exists to catch: #2718 added a lane leg and
* #2781 was the documentation drift that followed. An undeclared leg must fail.
* **Re-pointed by Phase 5b (#2799).** Through Phase 5a this function also required a literal
* `<!-- reviewer-lane: <slug> -->` per lane inside `invoke_reviewers` and a literal
* `## <Section> Review` per lane inside `write_reviews`. Phase 5b deletes exactly that text: the
* workflow now iterates declared lanes and renders sections from `reviewsSection`, so there is no
* per-lane text left to scan. Those two families could not be kept without keeping the
* hand-maintained per-lane blocks this epic exists to delete.
*
* Pure and total: never reads the filesystem, never throws. Empty or malformed
* `workflowText` degrades to violations, so a caller cannot mistake a read
* failure for a clean bill of health.
* What replaced them is the parity that is actually load-bearing once lanes are data: the registry
* is what decides which lanes exist at runtime, so `descriptor ↔ registry` is checked in both
* directions. That is also the mechanical single source #2781/Phase 6 needs for its docs and locale
* gate, which per-leg text could never provide.
*
* CRLF-insensitive: `\r` is stripped before matching, because a Windows
* autocrlf checkout would otherwise leave every marker and heading unmatched
* and report the whole roster missing.
* Bidirectional is still the point. A forward-only check ("does each declared lane resolve?")
* misses the failure this exists to catch: #2718 added a lane and #2781 was the documentation drift
* that followed. A registry lane nobody declared must fail, and so must a re-added bespoke leg.
*
* Pure and total: never reads the filesystem, never throws. Empty or malformed `workflowText` /
* `registry` degrades to violations, so a caller cannot mistake a read failure for a clean bill of
* health.
*
* CRLF-insensitive: `\r` is stripped before matching, because a Windows autocrlf checkout would
* otherwise leave every marker unmatched.
*/
export function checkReviewerLaneParity(input: ParityInput): ParityResult {
const { descriptor, roster } = input;
@@ -569,13 +710,6 @@ export function checkReviewerLaneParity(input: ParityInput): ParityResult {
const seenFlag = new Set<string>();
const seenSection = new Set<string>();
/** A lane that survived validation: slug is a string in the declared grammar. */
interface ValidatedLane {
slug: string;
reviewsSection: string | null;
}
const lanes: ValidatedLane[] = [];
// The declared parameter type says `ReviewerLane[]`, but this function is a
// trust boundary — narrow from `unknown` rather than believing the annotation.
const rawLanes: unknown[] = Array.isArray(descriptor) ? (descriptor as unknown[]) : [];
@@ -591,7 +725,6 @@ export function checkReviewerLaneParity(input: ParityInput): ParityResult {
continue;
}
const section = typeof lane.reviewsSection === 'string' ? lane.reviewsSection : null;
lanes.push({ slug, reviewsSection: section });
if (seenSlug.has(slug)) add(PARITY_VIOLATION.DUPLICATE_SLUG, slug);
seenSlug.add(slug);
@@ -623,33 +756,32 @@ export function checkReviewerLaneParity(input: ParityInput): ParityResult {
if (!rosterSet.has(slug)) add(PARITY_VIOLATION.DESCRIPTOR_LANE_NOT_IN_ROSTER, slug);
}
// --- descriptor <-> invoke_reviewers legs ---
const markerCounts = countOccurrences(
sliceStep(workflowText, INVOKE_STEP_RE),
LEG_MARKER_RE,
// --- descriptor <-> registry ---
//
// The registry is what decides which lanes exist at runtime once the workflow iterates, so this
// replaces the per-leg text checks Phase 5b deleted. A non-array degrades to an empty set, which
// then reports every descriptor lane as missing — loud, not silent.
const registrySet = new Set(
(Array.isArray(input.registry) ? input.registry : []).filter(
(x): x is string => typeof x === 'string',
),
);
for (const lane of lanes) {
const n = markerCounts.get(lane.slug) ?? 0;
if (n === 0) add(PARITY_VIOLATION.LEG_MARKER_MISSING, lane.slug);
else if (n > 1) add(PARITY_VIOLATION.LEG_MARKER_DUPLICATED, lane.slug);
for (const slug of registrySet) {
if (!seenSlug.has(slug)) add(PARITY_VIOLATION.REGISTRY_LANE_UNDECLARED, slug);
}
for (const slug of markerCounts.keys()) {
if (!seenSlug.has(slug)) add(PARITY_VIOLATION.LEG_MARKER_UNDECLARED, slug);
for (const slug of seenSlug) {
if (!registrySet.has(slug)) add(PARITY_VIOLATION.DESCRIPTOR_LANE_NOT_IN_REGISTRY, slug);
}
// --- descriptor <-> write_reviews sections ---
const sectionCounts = countOccurrences(
sliceStep(workflowText, WRITE_STEP_RE),
SECTION_HEADING_RE,
);
for (const lane of lanes) {
if (lane.reviewsSection === null) continue;
const n = sectionCounts.get(lane.reviewsSection) ?? 0;
if (n === 0) add(PARITY_VIOLATION.SECTION_MISSING, lane.reviewsSection);
else if (n > 1) add(PARITY_VIOLATION.SECTION_DUPLICATED, lane.reviewsSection);
}
for (const section of sectionCounts.keys()) {
if (!seenSection.has(section)) add(PARITY_VIOLATION.SECTION_UNDECLARED, section);
// --- anti-parity: no bespoke leg may return ---
//
// Phase 5b deleted every hand-authored per-CLI block. Nothing in the type system stops a future
// contributor from adding one back, and a re-added block is invisible to every other check here
// (it would still be declared, still be in the roster, still be in the registry). Matching a leg
// marker is therefore now the violation.
const markerSlugs = countOccurrences(sliceStep(workflowText, INVOKE_STEP_RE), LEG_MARKER_RE);
for (const slug of markerSlugs.keys()) {
add(PARITY_VIOLATION.BESPOKE_LEG_PRESENT, slug);
}
return { ok: violations.length === 0, violations };

View File

@@ -0,0 +1,482 @@
/**
* Reviewer Lane Invocation Module (ADR-2782 Phase 5b, #2799 — closes #2718).
*
* Turns a DECLARED lane (`review-lane-descriptor.cts`) plus resolved configuration into a concrete,
* inspectable INVOCATION PLAN. Phase 1's module declares; this one resolves; `review-lane-runner`
* executes. The split exists so the interesting half is pure: a plan is a value, so the twelve
* shipped lanes can be asserted against a golden table without spawning anything.
*
* WHY A GOLDEN TABLE AND NOT A FRESH DESIGN (Gall's Law). The 640 lines of hand-authored bash this
* replaces is the simple system that worked, and every leg encodes a hard-won fix — #2494 and #2605
* (empty output), #1698 (Codex stdout teardown noise), #1936 (OpenCode zero-output turns), #2073
* (Antigravity's three modes), #2176 (repo-root anchoring), #2589 (no jq on stock Windows), #2794
* (Qwen's missing sidecar). A resolver designed from the descriptor TYPES would throw that away and
* rebuild the bugs. So each lane's plan was derived from its leg, and `tests/review-lane-invocation`
* asserts all twelve against a frozen table. Old and new cannot literally run in parallel, so that
* table is the strangler-fig substitute — it is what makes this cutover safe rather than hopeful.
*
* PURE. No filesystem, no network, no subprocess, no clock. Configuration arrives through the
* `configGet` seam so a test drives it with a plain object and production wires it to the real
* resolved config. Every function here is total: a malformed lane yields an `unavailable` result,
* never a throw — a resolver that throws on bad input cannot report on it, and this runs against
* third-party overlay manifests (ADR-2782 D4).
*/
import type {
EmptyOutputPolicy,
LaneHandler,
LaneProbe,
ReviewerLane,
} from './review-lane-descriptor.cjs';
import { LANE_SLUG_RE } from './review-lane-descriptor.cjs';
/* ------------------------------------------------------------------ *
* Unavailability — a frozen enum, because the reason is the product
* ------------------------------------------------------------------ */
/**
* Why a lane will not run.
*
* Frozen and exhaustive because the bash it replaces had exactly one outcome for every failure —
* an empty file — and that ambiguity IS the defect class this epic exists to close (#2494/#2605: a
* failed lane was indistinguishable from a reviewer that ran cleanly with nothing to report). The
* caller renders these; tests assert on them. Never assert on the rendered prose.
*
* Adding a member is three coordinated changes: this enum, the emitting site, and the test locking
* `Object.keys(...).sort()`.
*/
export const LANE_UNAVAILABLE = Object.freeze({
MALFORMED_LANE: 'malformed_lane',
UNKNOWN_HANDLER: 'unknown_handler',
UNKNOWN_TRANSPORT: 'unknown_transport',
MISSING_BINARY: 'missing_binary',
MISSING_REQUIRED_BINARY: 'missing_required_binary',
PROBE_FAILED: 'probe_failed',
PROBE_TIMEOUT: 'probe_timeout',
HOST_UNREACHABLE: 'host_unreachable',
EGRESS_HOST_CHANGED: 'egress_host_changed',
BUDGET_TOO_SMALL: 'budget_too_small',
BUDGET_TOOL_FAILED: 'budget_tool_failed',
} as const);
export type LaneUnavailableReason =
(typeof LANE_UNAVAILABLE)[keyof typeof LANE_UNAVAILABLE];
/* ------------------------------------------------------------------ *
* The plan
* ------------------------------------------------------------------ */
/** Where a spawned lane's review actually lands. */
export type OutputTarget =
| { kind: 'stdout' }
/** Codex: the tool writes the review itself and stdout is discarded (#1698). */
| { kind: 'file'; path: string };
export interface SpawnPlan {
transport: 'spawn';
slug: string;
binary: string;
/** Fully resolved argv — model, effort and prompt already folded in, in leg order. */
argv: string[];
/** Prompt delivered on stdin, or `null` for `argv`/`argv-file-ref`/`none` lanes. */
stdin: string | null;
/**
* The assembled prompt file, regardless of how (or whether) this lane consumes it. Carried even
* for `promptChannel: 'none'` so a handler never has to re-derive the path from another field.
*/
promptPath: string;
outputTarget: OutputTarget;
/** Canonical review path: `<runDir>/gsd-review-<slug>.md`. */
reviewPath: string;
/** stderr sidecar — never `/dev/null` (#2494). */
errPath: string;
timeoutMs: number;
emptyOutput: EmptyOutputPolicy;
handler: LaneHandler;
requiresBinaries: readonly string[];
probe: LaneProbe;
}
export interface HttpPlan {
transport: 'openai-http';
slug: string;
/** Resolved base URL, normalized (no trailing slash). */
host: string;
hostConfigKey: string;
/** `host` + the declared path. */
url: string;
/** Models endpoint for discovery, when `modelDiscovery` asks for it. */
modelsUrl: string | null;
/** Configured model, or `null` when discovery should run. */
model: string | null;
fallbackModel: string;
promptPath: string;
reviewPath: string;
errPath: string;
timeoutMs: number;
emptyOutput: EmptyOutputPolicy;
handler: LaneHandler;
requiresBinaries: readonly string[];
probe: LaneProbe;
}
export type LanePlan = SpawnPlan | HttpPlan;
export type ResolveResult =
| { ok: true; plan: LanePlan; warnings: string[] }
| { ok: false; reason: LaneUnavailableReason; detail: string; warnings: string[] };
/** Every handler name the runner can dispatch. Mirrors `LaneHandler` (D6's closed enum). */
const KNOWN_HANDLERS: ReadonlySet<string> = new Set(['antigravity', 'openai-compatible', 'opencode']);
export interface ResolveInput {
lane: ReviewerLane;
/** Resolved config lookup. Returns `undefined` for an absent key. */
configGet: (key: string) => unknown;
/** The run-scoped temp dir (`{run_dir}` in the workflow). */
runDir: string;
/** Absolute repo root, for `argv-file-ref` anchoring (#2176). */
repoRoot: string;
/** Effort argv for lanes whose `effortChannel` is `argv`; empty when the host declares none. */
effortArgs?: readonly string[];
}
/* ------------------------------------------------------------------ *
* Value normalization — the stringly-typed edges the bash lived with
* ------------------------------------------------------------------ */
/**
* A configured string value, or `null` when effectively unset.
*
* Four shapes all mean "not configured", and the bash had to handle three of them by hand:
* - absent / `null` / `undefined`;
* - `""` — the declared default of every federated `review.models.*` key;
* - `"null"` — the LITERAL four characters `config-get --raw` prints for a missing key, which
* every leg tested for (`[ "$X" != "null" ]`). Reading config in-process removes the source of
* this, but a `.planning/config.json` written by an older workflow can still contain it;
* - whitespace-only — never a meaningful model name or host.
*
* A non-string (number, bool, object, array) is NOT coerced. `String(0)` would put `"0"` into argv
* as a model name; a wrong model silently reviewed is worse than no model override.
*/
function configString(raw: unknown): string | null {
if (typeof raw !== 'string') return null;
const trimmed = raw.trim();
if (trimmed === '' || trimmed === 'null' || trimmed === 'undefined') return null;
return trimmed;
}
/**
* Normalize a base URL for storage and for the D5 consent comparison.
*
* Trailing slash, case in the scheme/host, and an explicit default port are all the same
* destination. Without normalizing, a cosmetic `.planning/config.json` edit — adding a trailing
* slash — would read as "the egress destination changed" and block the lane, training the user to
* dismiss the one warning that matters.
*
* Returns the input trimmed when it is not parseable as a URL: an unparseable host is compared
* verbatim rather than silently rewritten.
*/
export function normalizeHost(raw: string): string {
const trimmed = String(raw ?? '').trim();
if (!trimmed) return '';
let u: URL;
try {
u = new URL(trimmed);
} catch {
return trimmed.replace(/\/+$/, '');
}
// `new URL('localhost:11434')` PARSES — protocol `localhost:`, empty hostname — so a plausible
// but scheme-less config value would otherwise be rewritten to `localhost://11434` and compared
// (and requested) as though it were a real destination. No hostname means this is not a URL;
// return it verbatim so it fails visibly rather than silently becoming something else.
if (!u.hostname) return trimmed.replace(/\/+$/, '');
const scheme = u.protocol.toLowerCase();
const isDefaultPort =
(scheme === 'http:' && u.port === '80') || (scheme === 'https:' && u.port === '443');
const port = isDefaultPort ? '' : u.port;
const host = u.hostname.toLowerCase();
const pathPart = u.pathname.replace(/\/+$/, '');
return `${scheme}//${host}${port ? `:${port}` : ''}${pathPart}`;
}
/**
* Classify a lane's output as a review or as empty.
*
* WHITESPACE-ONLY COUNTS AS EMPTY, for every lane. The bash tested `[ ! -s file ]`, which counts
* BYTES — so a reply of three spaces passed as a successful review. Two legs (LM Studio,
* llama.cpp) closed this locally with a case-glob; gemini, claude, codex, qwen and cursor did not.
* Making it uniform is a deliberate, disclosed behavior change (a bug fix that breaks a workaround)
* and is why this phase ships a changeset note.
*
* The `-n` / `-e` / `-E` hazard the two printf-using legs guarded against cannot occur here at all:
* nothing in this path goes through `echo`.
*/
export function isEmptyReview(text: unknown): boolean {
if (typeof text !== 'string') return true;
return text.trim().length === 0;
}
/**
* The standard `argv-file-ref` prompt (#2176).
*
* Two lanes take the prompt as an ARGUMENT rather than on stdin, and a full plan set inline would
* approach the 32,767-character Windows `execFileSync` ceiling — so the argument is a short
* instruction naming the prompt file. It must also carry the ABSOLUTE repo root: an argv-fed CLI
* does not reliably inherit the review's working directory, and without the anchor the reviewer
* reviews the plan text in isolation, which is exactly what the Review Instructions forbid.
*
* `antigravity` deliberately does NOT use this text — its handler owns a variant that additionally
* demands a `REVIEWED-WITHOUT-REPO-ACCESS` self-report.
*/
export function fileRefPrompt(promptPath: string, repoRoot: string): string {
return (
`Read the file at ${promptPath} in full and carry out the review request it contains. ` +
`The repository under review is at ${repoRoot} — resolve every relative file path in the ` +
`review request against that absolute root. Output only the resulting markdown review. ` +
`Do not edit any files.`
);
}
/** Run-dir artifact paths. POSIX-joined: these are workflow-visible strings, not OS paths. */
function artifactPaths(runDir: string, slug: string): {
promptPath: string;
reviewPath: string;
errPath: string;
} {
const base = String(runDir ?? '').replace(/\/+$/, '');
return {
promptPath: `${base}/gsd-review-prompt.md`,
reviewPath: `${base}/gsd-review-${slug}.md`,
errPath: `${base}/gsd-review-${slug}.err`,
};
}
/* ------------------------------------------------------------------ *
* Resolution
* ------------------------------------------------------------------ */
/**
* Resolve one declared lane into an executable plan.
*
* TOTAL: never throws. Every rejection is a typed `LaneUnavailableReason`, because the caller has to
* tell three different unavailabilities apart — a lane that is absent, a lane whose probe failed,
* and a lane blocked on a changed egress destination are not the same event to a user.
*
* NOT resolved here: probe execution, prompt-budget trimming, and the D5 egress-host comparison.
* Those need I/O and live in the runner. This function decides SHAPE.
*/
export function resolveLanePlan(input: ResolveInput): ResolveResult {
const warnings: string[] = [];
const fail = (reason: LaneUnavailableReason, detail: string): ResolveResult => ({
ok: false,
reason,
detail,
warnings,
});
// Trust boundary: the declared type says ReviewerLane, but overlay manifests arrive here.
const raw = input?.lane as unknown;
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
return fail(LANE_UNAVAILABLE.MALFORMED_LANE, `lane is not an object: ${String(raw)}`);
}
const lane = raw as ReviewerLane;
const slug = typeof lane.slug === 'string' ? lane.slug.trim() : '';
if (!slug) {
return fail(LANE_UNAVAILABLE.MALFORMED_LANE, 'lane declares no slug');
}
// The slug is CONCATENATED into artifact paths below, so the grammar is enforced here rather
// than trusted from upstream. `checkReviewerLaneParity` and the capability validator both check
// it too, but neither runs on this path — and this module's whole premise is that it is the
// trust boundary for third-party overlay manifests. A slug of `../../../tmp/evil` would
// otherwise produce a reviewPath outside the run dir that `writeReviewOrStub` happily writes to.
if (!LANE_SLUG_RE.test(slug)) {
return fail(
LANE_UNAVAILABLE.MALFORMED_LANE,
`lane slug '${slug}' is outside the declared grammar ${String(LANE_SLUG_RE)}`,
);
}
// D4 rule 4: an unknown handler FAILS CLOSED. A lane naming imperative code this GSD version does
// not have cannot be run "mostly" — the handler is precisely the part data could not express.
const handler: LaneHandler = lane.handler ?? null;
if (handler !== null && !KNOWN_HANDLERS.has(handler)) {
return fail(
LANE_UNAVAILABLE.UNKNOWN_HANDLER,
`lane '${slug}' names handler '${String(handler)}', which this GSD version does not provide`,
);
}
const { promptPath, reviewPath, errPath } = artifactPaths(input.runDir, slug);
const timeoutMs =
typeof lane.timeoutFloorMs === 'number' && Number.isFinite(lane.timeoutFloorMs) && lane.timeoutFloorMs > 0
? lane.timeoutFloorMs
: 900_000;
const emptyOutput: EmptyOutputPolicy = lane.emptyOutput === 'handler-owned' ? 'handler-owned' : 'stub-with-stderr';
const requiresBinaries = Array.isArray(lane.requiresBinaries)
? lane.requiresBinaries.filter((b): b is string => typeof b === 'string')
: [];
const model = configString(
typeof lane.modelConfigKey === 'string' ? input.configGet(lane.modelConfigKey) : undefined,
);
if (lane.transport === 'openai-http') {
const rawInvoke: unknown = lane.invoke;
// The spawn branch below guards with `inv?.binary`; this one must too. Without it a lane
// declaring `transport: 'openai-http'` and no `invoke` THROWS, which breaks this module's
// documented totality — and a throw here is worse than it looks: the CLI seam resolves every
// selected lane in one `.map`, so one malformed overlay manifest would abort the whole review
// rather than dropping its own lane.
if (rawInvoke === null || typeof rawInvoke !== 'object' || Array.isArray(rawInvoke)) {
return fail(
LANE_UNAVAILABLE.MALFORMED_LANE,
`openai-http lane '${slug}' declares no invoke object`,
);
}
const inv = rawInvoke as {
hostConfigKey?: unknown;
defaultHost?: unknown;
path?: unknown;
fallbackModel?: unknown;
modelDiscovery?: unknown;
};
const hostConfigKey = typeof inv.hostConfigKey === 'string' ? inv.hostConfigKey : '';
const configured = hostConfigKey ? configString(input.configGet(hostConfigKey)) : null;
// Only a STRING declares a host. Coercing an object would produce the literal
// '[object Object]' and normalize THAT as the lane's egress destination.
const declaredDefault = typeof inv.defaultHost === 'string' ? inv.defaultHost : '';
const host = normalizeHost(configured ?? declaredDefault);
if (!host) {
return fail(
LANE_UNAVAILABLE.MALFORMED_LANE,
`lane '${slug}' resolves no host: '${hostConfigKey}' is unset and it declares no defaultHost`,
);
}
const apiPath = typeof inv.path === 'string' && inv.path ? inv.path : '/v1/chat/completions';
const discovers = inv.modelDiscovery === 'first-from-models-endpoint';
return {
ok: true,
warnings,
plan: {
transport: 'openai-http',
slug,
host,
hostConfigKey,
url: `${host}${apiPath}`,
modelsUrl: discovers ? `${host}/v1/models` : null,
model,
fallbackModel: typeof inv.fallbackModel === 'string' ? inv.fallbackModel : 'local-model',
promptPath,
reviewPath,
errPath,
timeoutMs,
emptyOutput,
handler,
requiresBinaries,
probe: lane.probe,
},
};
}
if (lane.transport !== 'spawn') {
return fail(
LANE_UNAVAILABLE.UNKNOWN_TRANSPORT,
`lane '${slug}' declares transport '${String((lane as { transport?: unknown }).transport)}'`,
);
}
const inv = lane.invoke;
const binary = typeof inv?.binary === 'string' ? inv.binary.trim() : '';
if (!binary) {
return fail(LANE_UNAVAILABLE.MALFORMED_LANE, `spawn lane '${slug}' declares no binary`);
}
// Output target first: `{{output}}` needs to know the path, and the target is also what tells the
// runner whether to capture stdout or read a file the tool wrote itself (#1698).
let outputTarget: OutputTarget = { kind: 'stdout' };
let outputExpansion: string[] = [];
if (inv.outputChannel === 'file-arg') {
const outputArg = typeof inv.outputArg === 'string' ? inv.outputArg : '';
if (!outputArg) {
return fail(
LANE_UNAVAILABLE.MALFORMED_LANE,
`lane '${slug}' declares outputChannel 'file-arg' with no outputArg naming the argument`,
);
}
outputExpansion = [outputArg, reviewPath];
outputTarget = { kind: 'file', path: reviewPath };
}
let stdin: string | null = null;
let promptExpansion: string[] = [];
switch (inv.promptChannel) {
case 'stdin':
stdin = promptPath; // the runner streams this file; the plan names it.
break;
case 'argv-file-ref':
promptExpansion = [fileRefPrompt(promptPath, input.repoRoot)];
break;
case 'argv':
promptExpansion = [promptPath];
break;
case 'none':
break; // CodeRabbit reviews the working tree and is fed nothing (review.md:367).
default:
return fail(
LANE_UNAVAILABLE.MALFORMED_LANE,
`lane '${slug}' declares promptChannel '${String(inv.promptChannel)}'`,
);
}
const modelExpansion =
model && typeof inv.modelArg === 'string' && inv.modelArg ? [inv.modelArg, model] : [];
const effortExpansion =
inv.effortChannel === 'argv'
? (input.effortArgs ?? []).filter((a): a is string => typeof a === 'string' && a !== '')
: [];
// Expand the argv template in declared order. A placeholder with nothing to contribute expands to
// ZERO elements and disappears — that is what lets one template serve both the configured and the
// unconfigured case without a conditional in the data.
const expansions: Record<string, string[]> = {
'{{model}}': modelExpansion,
'{{effort}}': effortExpansion,
'{{output}}': outputExpansion,
'{{prompt}}': promptExpansion,
};
const template = Array.isArray(inv.args)
? inv.args.filter((a): a is string => typeof a === 'string')
: [];
const argv: string[] = [];
for (const token of template) {
// Own-property lookup: a lane could declare a literal `constructor` argument, and prototype
// members must never resolve as expansions.
if (Object.prototype.hasOwnProperty.call(expansions, token)) {
argv.push(...expansions[token]);
} else {
argv.push(token);
}
}
return {
ok: true,
warnings,
plan: {
transport: 'spawn',
slug,
binary,
argv,
stdin,
promptPath,
outputTarget,
reviewPath,
errPath,
timeoutMs,
emptyOutput,
handler,
requiresBinaries,
probe: lane.probe,
},
};
}

697
src/review-lane-runner.cts Normal file
View File

@@ -0,0 +1,697 @@
/**
* Reviewer Lane Runner (ADR-2782 Phase 5b, #2799).
*
* Executes an invocation plan from `review-lane-invocation.cts`: probes the lane, spawns the binary
* or calls the HTTP endpoint, applies the empty-output policy, and dispatches the three first-party
* handlers D6 names. This is the module that replaces ~640 lines of hand-authored per-CLI bash.
*
* THREE RUNTIME DEPENDENCIES DISAPPEAR HERE, and that is a correctness win rather than a tidy-up:
* - `jq` — five legs needed it on PATH; it is absent on stock Windows/Git-Bash (#2589). Parsing is
* `JSON.parse` now.
* - `curl` — the three OpenAI-compatible legs shelled out to it. Node's `fetch` replaces it, and
* the raw response body (where an OpenAI-compatible server puts its error JSON on a 4xx/5xx
* while curl still exits 0) is no longer discarded by a pipe.
* - external `timeout` / `gtimeout` — the Antigravity leg probed for one because
* `--print-timeout` cannot fire before a session exists, and STOCK MACOS SHIPS NEITHER, so on a
* stock Mac that leg ran unbounded. `spawnSync`'s native `timeout` is always available, so D7's
* "skip the probe where no bounding mechanism exists" carve-out is no longer needed.
*
* EVERY subprocess call in this file passes `timeout` + `killSignal` + `maxBuffer`. This repo has a
* named `DEFECT.UNBOUNDED-SUBPROCESS` class (`CONTEXT.md:772`) and an unbounded sync spawn is not a
* slow test, it is a hung one: `--test-force-exit` cannot interrupt a synchronous call, so a frozen
* spawn hangs the whole CI chunk to its 10-minute kill with `# fail 0` and no `not ok` (#2099).
*
* TOTAL. No function here throws for a lane-level failure; every one returns a typed outcome. A
* reviewer lane that cannot run must produce a diagnosable artifact, because the ambiguity between
* "failed" and "ran cleanly with nothing to report" IS the defect this epic closes (#2494/#2605).
*/
import type { LanePlan, SpawnPlan, HttpPlan, LaneUnavailableReason } from './review-lane-invocation.cjs';
import {
LANE_UNAVAILABLE,
isEmptyReview,
normalizeHost,
fileRefPrompt as fileRefPromptText,
} from './review-lane-invocation.cjs';
/* ------------------------------------------------------------------ *
* Injected seams
* ------------------------------------------------------------------ */
export interface SpawnOutcome {
status: number | null;
stdout: string;
stderr: string;
/** Node sets this to ETIMEDOUT when `timeout` fired. */
errorCode?: string;
}
export interface RunnerDeps {
/** Bounded synchronous spawn. Production wires `child_process.spawnSync`. */
spawn: (binary: string, argv: string[], opts: { input?: string; timeoutMs: number }) => SpawnOutcome;
/** Bounded HTTP POST/GET returning the RAW body — never pre-parsed, so errors stay diagnosable. */
httpJson: (url: string, opts: { method: 'GET' | 'POST'; body?: string; timeoutMs: number }) =>
Promise<{ ok: boolean; status: number; body: string; error?: string }>;
readFile: (p: string) => string;
writeFile: (p: string, content: string) => void;
exists: (p: string) => boolean;
/** `command -v` equivalent. */
hasBinary: (name: string) => boolean;
/** Resolved config lookup, for the egress-host re-verification. */
configGet: (key: string) => unknown;
/** Home dir, for the Antigravity transcript paths. */
homeDir: string;
warn: (msg: string) => void;
}
export interface LaneRunResult {
slug: string;
ok: boolean;
/** Present when `ok` is false. */
reason?: LaneUnavailableReason;
detail?: string;
/** True when a diagnostic stub was written instead of a real review. */
stubbed: boolean;
}
/* ------------------------------------------------------------------ *
* D5 rules 2–4 — the egress destination is re-verified at INVOCATION
* ------------------------------------------------------------------ */
export interface EgressCheck {
allowed: boolean;
consentedHost?: string;
currentHost?: string;
}
/**
* Compare a lane's re-resolved egress destination against the one the user consented to.
*
* ADR-2782 D5 puts this on the invocation path deliberately. `hostConfigKey` names a key in
* `.planning/config.json`, which is the ONE consent-bound value living outside the SHA-pinned
* bundle: user- and CI-editable at any time, with no re-install and no integrity check. Without
* this check a lane consented against `http://localhost:8080` could be silently redirected to a
* remote host by an ordinary pull request touching a JSON file, and every subsequent review would
* egress plans, requirements, research and decisions to the new destination with no re-prompt.
*
* ABSENCE IS NOT A MISMATCH, and this is the part that must not be "hardened" later by someone who
* reads only the rule name:
* - No consent record at all ⇒ ALLOW. First-party lanes (`ollama`, `lm_studio`, `llama_cpp`) ship
* inside the SHA-pinned distribution and are never consent-gated, so blocking on a missing
* record would break every existing local-model user on upgrade.
* - A consent record predating this feature ⇒ ALLOW. Those records were written before a host was
* recorded; treating the absence as a change would force spurious re-consent across every
* installed capability, which ADR-2782 D4 rule 5 explicitly forbids.
*
* Hosts are compared NORMALIZED, so a trailing slash or an explicit `:80` is not a "destination
* change". A warning that fires on cosmetic edits is a warning users learn to dismiss.
*/
export function checkEgressHost(consentedHost: unknown, currentHost: string): EgressCheck {
const consented = typeof consentedHost === 'string' ? normalizeHost(consentedHost) : '';
if (!consented) return { allowed: true };
const current = normalizeHost(currentHost);
if (consented === current) return { allowed: true, consentedHost: consented, currentHost: current };
return { allowed: false, consentedHost: consented, currentHost: current };
}
/* ------------------------------------------------------------------ *
* Probe (D7) — every probe bounded, and only for a SELECTED lane
* ------------------------------------------------------------------ */
export async function probeLane(
plan: LanePlan,
deps: RunnerDeps,
): Promise<{ available: true } | { available: false; reason: LaneUnavailableReason; detail: string }> {
// Prerequisites first: a missing `jq`-style helper is a clearer failure than whatever the tool
// does without it, and reporting it costs nothing.
for (const bin of plan.requiresBinaries) {
if (!deps.hasBinary(bin)) {
return {
available: false,
reason: LANE_UNAVAILABLE.MISSING_REQUIRED_BINARY,
detail: `lane '${plan.slug}' requires '${bin}' on PATH`,
};
}
}
const probe = plan.probe;
if (!probe || typeof probe !== 'object') {
return { available: false, reason: LANE_UNAVAILABLE.PROBE_FAILED, detail: 'lane declares no probe' };
}
switch (probe.kind) {
case 'command-exists':
return deps.hasBinary(probe.binary)
? { available: true }
: {
available: false,
reason: LANE_UNAVAILABLE.MISSING_BINARY,
detail: `'${probe.binary}' not found on PATH`,
};
case 'command-capability': {
// Existence alone is structurally insufficient here: `kimi` is claimed by BOTH the Kimi Code
// CLI and the legacy Python kimi-cli, and an existence-only probe registers the wrong tool.
if (!deps.hasBinary(probe.binary)) {
return {
available: false,
reason: LANE_UNAVAILABLE.MISSING_BINARY,
detail: `'${probe.binary}' not found on PATH`,
};
}
const out = deps.spawn(probe.binary, ['--help'], { timeoutMs: probe.timeoutMs });
if (out.errorCode === 'ETIMEDOUT') {
return {
available: false,
reason: LANE_UNAVAILABLE.PROBE_TIMEOUT,
detail: `'${probe.binary} --help' exceeded ${probe.timeoutMs}ms`,
};
}
const help = `${out.stdout}\n${out.stderr}`;
return help.includes(probe.needle)
? { available: true }
: {
available: false,
reason: LANE_UNAVAILABLE.PROBE_FAILED,
detail: `'${probe.binary}' is on PATH but its --help lacks '${probe.needle}' — this is a different tool sharing the name`,
};
}
case 'http-reachable': {
// Only a STRING config value names a host. `String(unknown)` would turn an object into the
// literal '[object Object]' and probe that as a URL — a nonsense destination reported as
// "unreachable" rather than as the misconfiguration it is.
const configured = deps.configGet(probe.hostConfigKey);
const base = normalizeHost(
(typeof configured === 'string' ? configured : '') ||
(plan.transport === 'openai-http' ? plan.host : ''),
);
if (!base) {
return { available: false, reason: LANE_UNAVAILABLE.HOST_UNREACHABLE, detail: 'no host resolved' };
}
const r = await deps.httpJson(`${base}${probe.path}`, { method: 'GET', timeoutMs: probe.timeoutMs });
return r.ok
? { available: true }
: {
available: false,
reason: LANE_UNAVAILABLE.HOST_UNREACHABLE,
detail: `${base}${probe.path} unreachable: ${r.error ?? `HTTP ${r.status}`}`,
};
}
default:
return {
available: false,
reason: LANE_UNAVAILABLE.PROBE_FAILED,
detail: `unknown probe kind '${String((probe as { kind?: unknown }).kind)}'`,
};
}
}
/* ------------------------------------------------------------------ *
* Empty-output policy (#2494 / #2605 / #2794)
* ------------------------------------------------------------------ */
/**
* Write the review, or a diagnostic stub when the lane produced nothing usable.
*
* The stub is not cosmetic. Before #2494/#2605 a failed lane left a zero-byte file, `write_reviews`
* rendered it as "a reviewer that ran cleanly with nothing to report", and the lane vanished from
* the cross-AI consensus while `present_results` reported success — a review blind in one eye. The
* stub keeps its "failed or returned empty output" header precisely so it can never be mistaken for
* a real review.
*
* `extraDiagnostics` carries the raw HTTP response body for the OpenAI-compatible lanes: an error
* from such a server arrives with HTTP 4xx/5xx and the JSON in the BODY, so stderr alone is empty
* and the body is the only evidence.
*/
export function writeReviewOrStub(
plan: LanePlan,
content: string,
deps: RunnerDeps,
extraDiagnostics?: string,
): { stubbed: boolean } {
if (!isEmptyReview(content)) {
deps.writeFile(plan.reviewPath, content.endsWith('\n') ? content : `${content}\n`);
return { stubbed: false };
}
const stderr = deps.exists(plan.errPath) ? deps.readFile(plan.errPath) : '';
const parts = [`${plan.slug} review failed or returned empty output. stderr:`, stderr];
if (extraDiagnostics) parts.push('Raw response body:', extraDiagnostics);
deps.writeFile(plan.reviewPath, `${parts.join('\n')}\n`);
return { stubbed: true };
}
/* ------------------------------------------------------------------ *
* Handlers (D6) — named first-party code, never conditionals in data
* ------------------------------------------------------------------ */
/**
* `opencode` — reconstruct the review from the assistant `text` parts of a `--format json` stream.
*
* `--format json` is the PRIMARY invocation, not a fallback: OpenCode's default `build` agent is an
* agentic coder, and on a large review prompt it may run a few `read` tool calls then end its turn
* with ZERO output tokens, so `--format default` yields empty stdout and the reviewer is silently
* lost (#1936). When no assistant text was emitted, surface the stop reason and output-token count
* so the failure is diagnosable rather than a generic empty stub.
*
* The stream is JSONL-ish: one JSON value per line. A line that does not parse is SKIPPED rather
* than failing the lane — a partial stream still carries usable review text, and losing the whole
* review to one malformed line would be strictly worse than the bug this handler exists to fix.
*/
export function handleOpencodeOutput(rawStdout: string): { review: string; diagnostic: string } {
const texts: string[] = [];
let stopReason = '?';
let outputTokens = '?';
for (const line of String(rawStdout ?? '').split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed) continue;
let evt: unknown;
try {
evt = JSON.parse(trimmed);
} catch {
continue;
}
if (evt === null || typeof evt !== 'object') continue;
const e = evt as { type?: unknown; part?: unknown };
const part = (e.part ?? {}) as { text?: unknown; reason?: unknown; tokens?: unknown };
if (e.type === 'text' && typeof part.text === 'string') {
// An EMPTY string is kept, not skipped. The shipped jq was `.part.text // empty`, and `//`
// only substitutes for `false`/`null` — an empty string is truthy in jq, so it survived and
// contributed a blank line. Dropping it here would quietly close up blank lines between
// parts, which is a real (if cosmetic) divergence from the behaviour being ported.
texts.push(part.text);
} else if (e.type === 'step_finish') {
if (typeof part.reason === 'string') stopReason = part.reason;
const tokens = (part.tokens ?? {}) as { output?: unknown };
if (typeof tokens.output === 'number') outputTokens = String(tokens.output);
}
}
return {
review: texts.join('\n'),
diagnostic: `stop reason=${stopReason}, output tokens=${outputTokens}`,
};
}
/** One `PLANNER_RESPONSE` line of an Antigravity transcript. */
interface TranscriptEntry {
source?: unknown;
status?: unknown;
type?: unknown;
content?: unknown;
}
/**
* `antigravity` — three layers, a two-level timeout, and a stale-response watermark.
*
* Layer 1 is stdout, which works on macOS/Linux/WSL. On native Windows `agy -p` silently produces
* no stdout despite the API call succeeding (an upstream `text_drip.go` non-TTY flush bug), so
* layer 2 reads the transcript `agy` always persists to disk. Layer 3 is a diagnostic stub.
*
* THE WATERMARK IS THE SUBTLE PART. Without it, layer 2 reads the last `PLANNER_RESPONSE` in the
* transcript regardless of when it was written — including one from a PREVIOUS invocation in the
* same workspace, silently presenting a stale review as this run's. So the transcript line count is
* snapshotted BEFORE the spawn and only lines appended after it are considered. If the conversation
* id changed, `agy` started a fresh session and every line is new (skip 0).
*
* `takeWatermark` must therefore be called before `runAntigravity`; the plan's outer timeout is the
* external wall-clock cap that `--print-timeout` cannot provide (it cannot fire before a session
* exists — a process can stall pre-session and outlive its own native timeout, #2073 mode 3).
*
* KNOWN LIMIT — the watermark is sequential, not concurrent. `last_conversations.json` is keyed by
* WORKSPACE, so two `/gsd:review` runs against the same repo at the same time resolve the same
* conversation id and share one transcript. The fallback then takes "the latest DONE
* PLANNER_RESPONSE after the watermark" with nothing to tell the two runs apart, so one run could
* read the other's response. This cannot be closed here: `agy` exposes no per-invocation id to
* filter on, and the transcript carries none. It is stated rather than silently tolerated because
* the guarantee this function advertises ("never stale") holds only for sequential use, and a
* future reader deserves to know which half is actually guaranteed.
*/
export function antigravityWatermark(
workspace: string,
deps: RunnerDeps,
): { convId: string; lines: number } {
const cachePath = `${deps.homeDir}/.gemini/antigravity-cli/cache/last_conversations.json`;
if (!deps.exists(cachePath)) return { convId: '', lines: 0 };
let cache: Record<string, unknown>;
try {
cache = JSON.parse(deps.readFile(cachePath)) as Record<string, unknown>;
} catch {
return { convId: '', lines: 0 };
}
const convId = resolveConvId(cache, workspace);
if (!convId) return { convId: '', lines: 0 };
const tx = transcriptPath(deps.homeDir, convId);
if (!deps.exists(tx)) return { convId, lines: 0 };
try {
return { convId, lines: deps.readFile(tx).split(/\r?\n/).filter((l) => l.trim()).length };
} catch {
return { convId, lines: 0 };
}
}
/** Workspace lookup is case-insensitive — the leg's jq did `ascii_downcase` on both sides. */
function resolveConvId(cache: Record<string, unknown>, workspace: string): string {
if (Object.prototype.hasOwnProperty.call(cache, workspace)) {
const direct = cache[workspace];
if (typeof direct === 'string' && direct) return direct;
}
const target = workspace.toLowerCase();
for (const [k, v] of Object.entries(cache)) {
if (k.toLowerCase() === target && typeof v === 'string' && v) return v;
}
return '';
}
function transcriptPath(homeDir: string, convId: string): string {
return `${homeDir}/.gemini/antigravity-cli/brain/${convId}/.system_generated/logs/transcript.jsonl`;
}
/**
* Layer 2: the newest `PLANNER_RESPONSE` written AFTER the watermark, or `''`.
*
* Returning `''` rather than the newest entry overall is the whole point — an empty result lets
* layer 3 fire with an honest diagnostic, where a stale one would be indistinguishable from a
* successful review.
*/
export function antigravityTranscriptFallback(
workspace: string,
mark: { convId: string; lines: number },
deps: RunnerDeps,
): string {
const cachePath = `${deps.homeDir}/.gemini/antigravity-cli/cache/last_conversations.json`;
if (!deps.exists(cachePath)) return '';
let cache: Record<string, unknown>;
try {
cache = JSON.parse(deps.readFile(cachePath)) as Record<string, unknown>;
} catch {
return '';
}
const convId = resolveConvId(cache, workspace);
if (!convId) return '';
const tx = transcriptPath(deps.homeDir, convId);
if (!deps.exists(tx)) return '';
let lines: string[];
try {
lines = deps.readFile(tx).split(/\r?\n/).filter((l) => l.trim());
} catch {
return '';
}
// Same conv-id ⇒ only lines beyond the watermark are this run's. Different id ⇒ fresh session,
// so every line is new.
const skip = convId === mark.convId ? mark.lines : 0;
let latest = '';
for (const line of lines.slice(skip)) {
let entry: TranscriptEntry;
try {
entry = JSON.parse(line) as TranscriptEntry;
} catch {
continue;
}
if (
entry.source === 'MODEL' &&
entry.status === 'DONE' &&
entry.type === 'PLANNER_RESPONSE' &&
typeof entry.content === 'string'
) {
latest = entry.content;
}
}
return latest;
}
/**
* Antigravity's prompt variant (#2176).
*
* Differs from the standard `argv-file-ref` text by one clause, and that clause is load-bearing:
* it REQUIRES the reviewer to self-report when it cannot read the repo. Without it a blind review
* is indistinguishable from a grounded one — the reviewer happily reviews the plan text in
* isolation and its verdict is counted at full weight in the consensus. `stampBlindReview` reads
* this self-report back out.
*/
export function antigravityPrompt(promptPath: string, repoRoot: string): string {
return (
`Read the file at ${promptPath} in full and carry out the review request it contains. ` +
`The repository under review is at ${repoRoot} — resolve every relative file path in the ` +
`review request against that absolute root and verify claims against those files. ` +
`If you cannot read files under ${repoRoot}, begin your output with the exact line ` +
`REVIEWED-WITHOUT-REPO-ACCESS before the review. Output only the resulting markdown review. ` +
`Do not edit any files.`
);
}
/**
* Antigravity's argv adjustments — the two things data cannot express for this lane.
*
* 1. `--add-dir <repo>`, CAPABILITY-PROBED. Without it agy's permission context never receives the
* cwd repo: the agent anchors on its own `~/.gemini/antigravity-cli/scratch` dir and reviews the
* plan text in isolation, which is exactly what the Review Instructions forbid (#2176). It is
* probed rather than assumed because older agy builds reject the unknown flag outright, and a
* lane that fails to start is worse than one that runs on the prompt anchor alone.
* 2. The self-report prompt variant above, swapped in for the standard file-ref text.
*
* Both are argv shape, so they belong here rather than in the descriptor: expressing "add this flag
* only if the binary's --help mentions it" as data would need a conditional, which is precisely
* what the named-handler seam exists to absorb (ADR-2782 D6).
*/
export function antigravityArgv(
argv: readonly string[],
promptPath: string,
repoRoot: string,
deps: RunnerDeps,
): string[] {
const standard = fileRefPromptText(promptPath, repoRoot);
const out = argv.map((a) => (a === standard ? antigravityPrompt(promptPath, repoRoot) : a));
let supportsAddDir = false;
try {
const help = deps.spawn('agy', ['--help'], { timeoutMs: 5_000 });
supportsAddDir = `${help.stdout}\n${help.stderr}`.includes('--add-dir');
} catch {
supportsAddDir = false;
}
if (!supportsAddDir) return out;
// Insert before the trailing `-p <prompt>` pair so the prompt stays last, as the leg had it.
const pIdx = out.lastIndexOf('-p');
if (pIdx === -1) return [...out, '--add-dir', repoRoot];
return [...out.slice(0, pIdx), '--add-dir', repoRoot, ...out.slice(pIdx)];
}
/**
* Layer 3's diagnostic: what `agy` itself logged, when both stdout and the transcript were empty.
*
* #2073 mode 2 is invisible without this. A pinned model that 404s server-side exits **0** with
* empty stdout AND an empty transcript — every signal the other two layers read says "clean run,
* nothing to report". The only evidence is in `agy`'s own log, so a bare generic stub would leave
* the user with a silently missing reviewer and nothing to diagnose.
*
* Mode 3 (a pre-session stall, which `--print-timeout` cannot bound because it cannot fire before a
* session exists) leaves no log line at all, so its tell is stated rather than searched for.
*/
export function antigravityDiagnostic(deps: RunnerDeps): string {
const lines = [
'Antigravity review failed or returned empty output.',
];
const logPath = `${deps.homeDir}/.gemini/antigravity-cli/cli.log`;
if (deps.exists(logPath)) {
try {
const hits = deps
.readFile(logPath)
.split(/\r?\n/)
.filter((l) => /agent executor error|NOT_FOUND|Publisher model/i.test(l))
.slice(-3);
if (hits.length) {
lines.push(
"agy log hint (pinned model may be unavailable — run 'agy models' and set review.models.agy):",
...hits,
);
}
} catch {
/* an unreadable log is not worth failing the lane over */
}
}
lines.push(
'If no agy run started, that is the pre-session-stall case: check whether a new ' +
'~/.gemini/antigravity-cli/brain/<conv-id>/ dir appeared within ~30s of launch.',
);
return lines.join('\n');
}
/**
* Stamp a machine-readable marker when the reviewer plainly ran without repo access (#2176).
*
* BOTH tells are ANCHORED, and that anchoring is load-bearing: a grounded review that merely QUOTES
* `REVIEWED-WITHOUT-REPO-ACCESS` — reviewing this very file, say — must never be mis-stamped. So
* the self-report is only honoured in the first five lines, and the scratch-dir tell only in a
* workspace-DECLARATION phrasing.
*/
export function stampBlindReview(review: string): string {
if (isEmptyReview(review)) return review;
const head = review.split(/\r?\n/).slice(0, 5).join('\n');
const selfReported = head.includes('REVIEWED-WITHOUT-REPO-ACCESS');
const scratchTell = /(workspace|working) (directory|dir)[\s\S]{0,40}antigravity-cli\/scratch/i.test(review);
if (!selfReported && !scratchTell) return review;
return (
'> [reviewed-without-repo-access] This reviewer ran without visibility into the repo under ' +
'review — down-weight its verdict in the Consensus Summary.\n\n' +
review
);
}
/**
* `openai-compatible` — model discovery, the chat-completions round trip, and the served-model
* mismatch warning.
*
* The raw body is returned alongside the content because an OpenAI-compatible server reports errors
* with an HTTP 4xx/5xx and the JSON in the BODY. The bash piped the response straight into `jq`,
* which discarded exactly that evidence; the stub appends it now.
*/
export async function runOpenAiCompatible(
plan: HttpPlan,
promptText: string,
deps: RunnerDeps,
): Promise<{ review: string; rawBody: string }> {
let model = plan.model;
if (!model && plan.modelsUrl) {
const listed = await deps.httpJson(plan.modelsUrl, { method: 'GET', timeoutMs: 2_000 });
if (listed.ok) {
try {
const parsed = JSON.parse(listed.body) as { data?: Array<{ id?: unknown }> };
const first = Array.isArray(parsed.data) ? parsed.data[0] : undefined;
if (first && typeof first.id === 'string' && first.id) model = first.id;
} catch {
/* fall through to the declared fallback */
}
}
}
if (!model) model = plan.fallbackModel;
const body = JSON.stringify({ model, messages: [{ role: 'user', content: promptText }] });
const res = await deps.httpJson(plan.url, { method: 'POST', body, timeoutMs: plan.timeoutMs });
if (!res.ok && !res.body) {
return { review: '', rawBody: res.error ?? `HTTP ${res.status}` };
}
let review = '';
try {
const parsed = JSON.parse(res.body) as {
model?: unknown;
choices?: Array<{ message?: { content?: unknown } }>;
};
// A server that quietly serves a different model than requested produces a review the user
// will attribute to the wrong system. Warn rather than fail — the review is still real.
if (typeof parsed.model === 'string' && parsed.model && parsed.model !== model) {
deps.warn(
`${plan.slug} served model '${parsed.model}' but '${model}' was requested. ` +
`Review may be from a different model.`,
);
}
const content = parsed.choices?.[0]?.message?.content;
if (typeof content === 'string') review = content;
} catch {
/* leave review empty — the raw body carries the diagnosis */
}
return { review, rawBody: res.body };
}
/* ------------------------------------------------------------------ *
* Orchestration
* ------------------------------------------------------------------ */
/**
* Run one resolved lane end to end.
*
* Order is deliberate: egress re-verification happens BEFORE the probe and before any spawn, so a
* lane whose destination changed never receives the plan text even once.
*/
export async function runLane(
plan: LanePlan,
deps: RunnerDeps,
opts: { consentedHost?: unknown; explicitlyRequested?: boolean; repoRoot: string },
): Promise<LaneRunResult> {
const base = { slug: plan.slug, stubbed: false };
if (plan.transport === 'openai-http') {
const egress = checkEgressHost(opts.consentedHost, plan.host);
if (!egress.allowed) {
const detail =
`lane '${plan.slug}' was consented to send plans to ${egress.consentedHost} but ` +
`${plan.hostConfigKey} now resolves to ${egress.currentHost}. Re-consent to allow the new ` +
`destination — this lane will not run against a host you did not approve.`;
deps.warn(detail);
return { ...base, ok: false, reason: LANE_UNAVAILABLE.EGRESS_HOST_CHANGED, detail };
}
}
const probed = await probeLane(plan, deps);
if (!probed.available) {
// D4's carve-out: not finding a lane nobody asked for is normal; failing to run a lane somebody
// asked for is an ERROR. Both are visible; only the second is fatal to the run.
if (opts.explicitlyRequested) deps.warn(`explicitly requested reviewer '${plan.slug}': ${probed.detail}`);
return { ...base, ok: false, reason: probed.reason, detail: probed.detail };
}
return plan.transport === 'spawn'
? runSpawnLane(plan, deps, opts.repoRoot)
: runHttpLane(plan, deps);
}
function runSpawnLane(plan: SpawnPlan, deps: RunnerDeps, repoRoot: string): LaneRunResult {
const input = plan.stdin && deps.exists(plan.stdin) ? deps.readFile(plan.stdin) : undefined;
const mark =
plan.handler === 'antigravity' ? antigravityWatermark(repoRoot, deps) : { convId: '', lines: 0 };
const argv =
plan.handler === 'antigravity'
? antigravityArgv(plan.argv, plan.promptPath, repoRoot, deps)
: plan.argv;
const out = deps.spawn(plan.binary, argv, { input, timeoutMs: plan.timeoutMs });
deps.writeFile(plan.errPath, out.stderr ?? '');
// `file-arg` lanes write the review themselves and their stdout is deliberately discarded (#1698).
let review =
plan.outputTarget.kind === 'file'
? deps.exists(plan.outputTarget.path)
? deps.readFile(plan.outputTarget.path)
: ''
: (out.stdout ?? '');
let extra: string | undefined;
if (plan.handler === 'opencode') {
const rebuilt = handleOpencodeOutput(review);
if (!isEmptyReview(rebuilt.review)) {
review = rebuilt.review;
} else {
review = '';
extra = `OpenCode review returned no assistant text (#1936: agent ended its turn with no final message).\nDiagnostic: ${rebuilt.diagnostic}`;
}
}
if (plan.handler === 'antigravity') {
// A non-zero exit (timeout kill, crash) discards partial output so the transcript fallback and
// the diagnostic stub can take over.
if (out.status !== 0) review = '';
if (isEmptyReview(review)) review = antigravityTranscriptFallback(repoRoot, mark, deps);
review = stampBlindReview(review);
// Layer 3. `emptyOutput: 'handler-owned'` means the generic stub does not fire for this lane,
// so if nothing is written here the lane goes out empty — the #2073 failure itself.
if (isEmptyReview(review)) {
deps.writeFile(plan.reviewPath, `${antigravityDiagnostic(deps)}\n`);
return { slug: plan.slug, ok: true, stubbed: true };
}
}
const { stubbed } = writeReviewOrStub(plan, review, deps, extra);
return { slug: plan.slug, ok: true, stubbed };
}
async function runHttpLane(plan: HttpPlan, deps: RunnerDeps): Promise<LaneRunResult> {
const promptText = deps.exists(plan.promptPath) ? deps.readFile(plan.promptPath) : '';
const { review, rawBody } = await runOpenAiCompatible(plan, promptText, deps);
deps.writeFile(plan.errPath, '');
const { stubbed } = writeReviewOrStub(plan, review, deps, rawBody);
return { slug: plan.slug, ok: true, stubbed };
}