* 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:
@@ -1130,6 +1130,333 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
|
||||
output({ ok: true, row: mutation.row, variant: mutation.variant }, raw, mutation.row);
|
||||
}
|
||||
|
||||
/**
|
||||
* ADR-2782 Phase 5b (#2799) — the seam that lets `invoke_reviewers` iterate declared lanes.
|
||||
*
|
||||
* Replaces ~640 lines of hand-authored per-CLI bash with three subcommands:
|
||||
* plan --selected a,b → resolved lanes (slug, section, availability) as JSON
|
||||
* invoke --slug X → probe + run one lane, writing its review/stub into the run dir
|
||||
* sections --selected a,b → ordered `slug<TAB>reviewsSection`, for write_reviews
|
||||
*
|
||||
* Lanes run SEQUENTIALLY (the workflow loops and calls `invoke` once per lane) because the
|
||||
* original legs did — parallel invocation trips provider rate limits.
|
||||
*/
|
||||
async function routeReviewLane({ args, cwd, raw, error }) {
|
||||
const cp = require('node:child_process');
|
||||
const fsx = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const { REVIEWER_LANES } = require('./lib/review-lane-descriptor.cjs');
|
||||
const { resolveLanePlan } = require('./lib/review-lane-invocation.cjs');
|
||||
const runner = require('./lib/review-lane-runner.cjs');
|
||||
const cfgLoader = require('./lib/config-loader.cjs');
|
||||
|
||||
const flag = (name) => {
|
||||
const i = args.indexOf(name);
|
||||
return i !== -1 && args[i + 1] && !String(args[i + 1]).startsWith('--') ? args[i + 1] : null;
|
||||
};
|
||||
const sub = args[1];
|
||||
const runDir = flag('--run-dir') || '.';
|
||||
const repoRoot = flag('--repo-root') || cwd;
|
||||
|
||||
// Resolved config, read ONCE. Reading in-process (rather than shelling out to config-get per
|
||||
// key, as the legs did) is what removes the stringly `"null"` sentinel the bash had to test for.
|
||||
// `loadConfigResolved` returns a PROVENANCE WRAPPER (`{config, source, degraded, reason}`),
|
||||
// not the config — using the wrapper directly silently resolves every key to undefined, which
|
||||
// reads exactly like "nothing configured" and drops every model override without an error.
|
||||
let resolved = {};
|
||||
try { resolved = (cfgLoader.loadConfigResolved(cwd) || {}).config || {}; } catch { resolved = {}; }
|
||||
const configGet = (key) => {
|
||||
let cur = resolved;
|
||||
for (const part of String(key).split('.')) {
|
||||
if (cur === null || typeof cur !== 'object') return undefined;
|
||||
cur = Object.prototype.hasOwnProperty.call(cur, part) ? cur[part] : undefined;
|
||||
}
|
||||
return cur;
|
||||
};
|
||||
|
||||
const selected = (flag('--selected') || '')
|
||||
.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
const laneBySlug = new Map(REVIEWER_LANES.map((l) => [l.slug, l]));
|
||||
const chosen = selected.length ? selected : REVIEWER_LANES.map((l) => l.slug);
|
||||
|
||||
if (sub === 'sections') {
|
||||
const rows = chosen
|
||||
.map((s) => laneBySlug.get(s))
|
||||
.filter(Boolean)
|
||||
.map((l) => `${l.slug}\t${l.reviewsSection}`);
|
||||
process.stdout.write(rows.join('\n') + (rows.length ? '\n' : ''));
|
||||
return;
|
||||
}
|
||||
|
||||
// Effort argv is resolved per lane by the host's own execution policy, exactly as the legs did
|
||||
// via `resolve-execution … --pick effort_argv_string`. A lane whose slug is not a known host
|
||||
// simply gets none.
|
||||
// Resolved through the SAME `resolve-execution` surface the bash legs used
|
||||
// (`--host <slug> --pick effort_argv_string`), so the host's negotiated effortSurface still
|
||||
// decides whether an argument is emitted and the catalog still owns the syntax (ADR-1239 #2481,
|
||||
// ADR-443's escalation ladder). `cmdResolveExecution` writes to stdout and exits, so it cannot
|
||||
// be called in-process for a value — this spawns the same bounded query the legs did, once per
|
||||
// selected lane. A lane whose slug is not a known host resolves to no effort argument at all.
|
||||
const effortFor = (slug) => {
|
||||
try {
|
||||
const r = cp.spawnSync(
|
||||
process.execPath,
|
||||
[__filename, 'query', 'resolve-execution', 'gsd-plan-checker',
|
||||
// NOT `--raw`: that prints the resolved EFFORT ('low'), ignoring --pick. The picked
|
||||
// field is what carries the host-specific syntax ('--effort low' for claude,
|
||||
// '-c model_reasoning_effort=low' for codex), which is the whole point of asking.
|
||||
'--host', slug, '--pick', 'effort_argv_string'],
|
||||
{ cwd, encoding: 'utf8', timeout: 15000, killSignal: 'SIGKILL', maxBuffer: 1024 * 1024 },
|
||||
);
|
||||
if (r.status !== 0) return [];
|
||||
const s = String(r.stdout || '').trim();
|
||||
return s ? s.split(/\s+/).filter(Boolean) : [];
|
||||
} catch { return []; }
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-lane prompt budget (#2797 semantics, preserved exactly).
|
||||
*
|
||||
* `-1` is the UNSET sentinel and falls back to the central `review.max_prompt_tokens`, because
|
||||
* `0` is a legitimate value meaning "do not trim this lane". Treating 0 as unset would silently
|
||||
* switch a user who deliberately disabled trimming onto the global budget.
|
||||
*
|
||||
* Only the budget VALUE is resolved here. Assembly and trimming stay in `prompt-budget`, which
|
||||
* already owns that machinery and is already tested; the workflow calls it and hands the
|
||||
* trimmed file back via `--prompt-file`. Re-implementing it inside the runner would fork a
|
||||
* tested surface for no gain.
|
||||
*/
|
||||
const budgetFor = (lane) => {
|
||||
if (!lane.promptBudgetKey) return null;
|
||||
const per = configGet(lane.promptBudgetKey);
|
||||
const isNum = (v) => typeof v === 'number' && Number.isFinite(v);
|
||||
if (isNum(per) && per !== -1) return per;
|
||||
const global = configGet('review.max_prompt_tokens');
|
||||
return isNum(global) ? global : null;
|
||||
};
|
||||
|
||||
const plans = chosen.map((slug) => {
|
||||
const lane = laneBySlug.get(slug);
|
||||
if (!lane) return { slug, ok: false, reason: 'malformed_lane', detail: 'no such declared lane' };
|
||||
// Per-lane isolation. resolveLanePlan is documented total, but this map is the seam where a
|
||||
// single throw would take down EVERY selected lane rather than the one that is malformed —
|
||||
// and "a cross-AI review that silently drops a lane" is the failure this epic exists to end,
|
||||
// so losing all of them to one bad manifest is strictly worse. Belt and braces on purpose.
|
||||
let r;
|
||||
try {
|
||||
r = resolveLanePlan({ lane, configGet, runDir, repoRoot, effortArgs: effortFor(slug) });
|
||||
} catch (e) {
|
||||
return { slug, ok: false, reason: 'malformed_lane', detail: `resolver threw: ${e && e.message ? e.message : String(e)}` };
|
||||
}
|
||||
return r.ok
|
||||
? {
|
||||
slug,
|
||||
ok: true,
|
||||
section: lane.reviewsSection,
|
||||
transport: r.plan.transport,
|
||||
promptBudget: budgetFor(lane),
|
||||
promptPath: r.plan.transport === 'spawn' ? r.plan.stdin : r.plan.promptPath,
|
||||
plan: r.plan,
|
||||
}
|
||||
: { slug, ok: false, reason: r.reason, detail: r.detail };
|
||||
});
|
||||
|
||||
if (sub === 'plan') {
|
||||
output(plans.map(({ plan, ...rest }) => rest), raw);
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub !== 'invoke') {
|
||||
error("Usage: review-lane <plan|invoke|sections> [--selected a,b] [--run-dir D] [--repo-root R]");
|
||||
return;
|
||||
}
|
||||
|
||||
const slug = flag('--slug');
|
||||
if (!slug) { error('review-lane invoke requires --slug'); return; }
|
||||
const entry = plans.find((p) => p.slug === slug);
|
||||
if (!entry || !entry.ok) {
|
||||
output({ slug, ok: false, reason: entry ? entry.reason : 'malformed_lane', detail: entry ? entry.detail : 'unknown lane' }, raw);
|
||||
return;
|
||||
}
|
||||
|
||||
// EVERY spawn bounded — `DEFECT.UNBOUNDED-SUBPROCESS` (CONTEXT.md:772). A frozen sync spawn
|
||||
// cannot be interrupted by --test-force-exit and hangs a whole CI chunk to its 10-minute kill.
|
||||
const deps = {
|
||||
spawn: (binary, argv, opts) => {
|
||||
const r = cp.spawnSync(binary, argv, {
|
||||
input: opts.input,
|
||||
encoding: 'utf8',
|
||||
timeout: opts.timeoutMs,
|
||||
killSignal: 'SIGKILL',
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
shell: false, // argv array only — never a shell string (no interpolation of config values)
|
||||
});
|
||||
return {
|
||||
status: r.status,
|
||||
stdout: r.stdout || '',
|
||||
stderr: r.stderr || '',
|
||||
errorCode: r.error && r.error.code ? r.error.code : undefined,
|
||||
};
|
||||
},
|
||||
httpJson: async (url, opts) => {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: opts.method,
|
||||
headers: opts.body ? { 'Content-Type': 'application/json' } : undefined,
|
||||
body: opts.body,
|
||||
signal: AbortSignal.timeout(opts.timeoutMs),
|
||||
});
|
||||
return { ok: res.ok, status: res.status, body: await res.text() };
|
||||
} catch (e) {
|
||||
return { ok: false, status: 0, body: '', error: e && e.message ? e.message : String(e) };
|
||||
}
|
||||
},
|
||||
readFile: (p) => fsx.readFileSync(p, 'utf8'),
|
||||
writeFile: (p, c) => fsx.writeFileSync(p, c, 'utf8'),
|
||||
exists: (p) => fsx.existsSync(p),
|
||||
// PATH scan rather than spawning `command -v` / `where`. Two reasons: it spawns nothing at
|
||||
// all (a probe that costs a process is a probe you avoid running, which is how the original
|
||||
// Kimi probe ended up unbounded), and `shell: true` with an args array is deprecated in
|
||||
// Node 26 (DEP0190) because the arguments are concatenated rather than escaped.
|
||||
hasBinary: (name) => {
|
||||
if (!name || name.includes('/') || name.includes('\\')) {
|
||||
try { return fsx.statSync(name).isFile(); } catch { return false; }
|
||||
}
|
||||
const exts = process.platform === 'win32'
|
||||
? (process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM').split(';').filter(Boolean)
|
||||
: [''];
|
||||
for (const dir of (process.env.PATH || '').split(path.delimiter).filter(Boolean)) {
|
||||
for (const ext of exts) {
|
||||
const candidate = path.join(dir, name + ext);
|
||||
try {
|
||||
const st = fsx.statSync(candidate);
|
||||
if (st.isFile()) return true;
|
||||
} catch { /* next candidate */ }
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
configGet,
|
||||
homeDir: os.homedir(),
|
||||
warn: (m) => process.stderr.write(`${m}\n`),
|
||||
};
|
||||
|
||||
// ADR-1517 reviewer instances resolve THROUGH a lane rather than being lanes themselves
|
||||
// (ADR-2782 D8), so they reuse this seam with three substitutions instead of duplicating the
|
||||
// lane's invocation. Everything the lane declares — probe, channels, timeout, empty-output
|
||||
// policy, handler — then applies to the instance unchanged, which is the whole reason to route
|
||||
// them here: a cross-cutting fix reaches instances for free.
|
||||
const asIdentity = flag('--as');
|
||||
const instanceModel = flag('--model');
|
||||
const instanceAgent = flag('--agent');
|
||||
|
||||
if (asIdentity) {
|
||||
// Write under the INSTANCE name so two instances of one adapter never overwrite each other.
|
||||
const safe = String(asIdentity).replace(/[^A-Za-z0-9._-]/g, '-');
|
||||
entry.plan.reviewPath = `${runDir.replace(/\/+$/, '')}/gsd-review-${safe}.md`;
|
||||
entry.plan.errPath = `${runDir.replace(/\/+$/, '')}/gsd-review-${safe}.err`;
|
||||
if (entry.plan.transport === 'spawn' && entry.plan.outputTarget.kind === 'file') {
|
||||
// A file-arg lane names its output inside argv; retarget both together or the runner reads
|
||||
// the lane's file while the tool writes the instance's.
|
||||
const old = entry.plan.outputTarget.path;
|
||||
entry.plan.argv = entry.plan.argv.map((a) => (a === old ? entry.plan.reviewPath : a));
|
||||
entry.plan.outputTarget = { kind: 'file', path: entry.plan.reviewPath };
|
||||
}
|
||||
}
|
||||
|
||||
// The instance's own model replaces whatever the lane resolved from config. Opaque
|
||||
// pass-through: it reaches the tool as one argv element and is never shell-interpolated.
|
||||
//
|
||||
// Done by RE-RESOLVING with an overridden config rather than by patching argv afterwards. A
|
||||
// post-hoc splice has to guess where the flag belongs when the lane resolved no model at all
|
||||
// (the placeholder expanded to nothing, so there is no position to find), and guessing puts it
|
||||
// before a subcommand — `opencode --model M run …`, the same defect the argv template exists to
|
||||
// prevent. Re-resolving lets the lane's own `{{model}}` placeholder decide the position.
|
||||
if (instanceModel) {
|
||||
const lane = laneBySlug.get(entry.slug);
|
||||
const key = lane && lane.modelConfigKey;
|
||||
// A lane that declares NO model key accepts no model override at all (`cursor`, `qwen`,
|
||||
// `coderabbit`). `review.reviewer_instances` validates that `cli` is a known slug but never
|
||||
// that the slug accepts a model, so a user can configure {"cli":"cursor","model":"gpt-5"},
|
||||
// get a clean config-set and a clean run, and never learn their model was ignored. Say so —
|
||||
// a review silently produced by a different model than the one configured is exactly the
|
||||
// "looked like it worked" failure this epic exists to end.
|
||||
if (!key) {
|
||||
process.stderr.write(
|
||||
`reviewer instance model '${instanceModel}' ignored: lane '${entry.slug}' accepts no ` +
|
||||
`model override (it declares no modelConfigKey). The review will use the CLI's own default.\n`,
|
||||
);
|
||||
}
|
||||
const overridden = resolveLanePlan({
|
||||
lane,
|
||||
configGet: (k) => (key && k === key ? instanceModel : configGet(k)),
|
||||
runDir,
|
||||
repoRoot,
|
||||
effortArgs: effortFor(entry.slug),
|
||||
});
|
||||
if (overridden.ok) {
|
||||
// Preserve any instance retargeting already applied above.
|
||||
const { reviewPath, errPath } = entry.plan;
|
||||
entry.plan = overridden.plan;
|
||||
if (asIdentity) {
|
||||
entry.plan.reviewPath = reviewPath;
|
||||
entry.plan.errPath = errPath;
|
||||
if (entry.plan.transport === 'spawn' && entry.plan.outputTarget.kind === 'file') {
|
||||
const old = entry.plan.outputTarget.path;
|
||||
entry.plan.argv = entry.plan.argv.map((a) => (a === old ? reviewPath : a));
|
||||
entry.plan.outputTarget = { kind: 'file', path: reviewPath };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// `--agent` is OpenCode's native subagent flag and is honoured only by adapters that have the
|
||||
// concept; ignored elsewhere rather than passed to a tool that would reject it.
|
||||
if (instanceAgent && entry.slug === 'opencode' && entry.plan.transport === 'spawn') {
|
||||
const runIdx = entry.plan.argv.indexOf('run');
|
||||
const insertAt = runIdx === -1 ? 0 : runIdx + 1;
|
||||
entry.plan.argv = [
|
||||
...entry.plan.argv.slice(0, insertAt),
|
||||
'--agent',
|
||||
instanceAgent,
|
||||
...entry.plan.argv.slice(insertAt),
|
||||
];
|
||||
}
|
||||
|
||||
// `--prompt-file` lets the caller substitute a budget-trimmed prompt. Applied to whichever
|
||||
// channel this lane actually reads from, so one flag serves both transports.
|
||||
const promptOverride = flag('--prompt-file');
|
||||
if (promptOverride) {
|
||||
if (entry.plan.transport === 'spawn') {
|
||||
if (entry.plan.stdin) entry.plan.stdin = promptOverride;
|
||||
} else {
|
||||
entry.plan.promptPath = promptOverride;
|
||||
}
|
||||
}
|
||||
|
||||
// ADR-2782 D5 rule 4: re-resolve the consented destination at INVOCATION. `undefined` (no
|
||||
// consent record, or a record predating rule 1) means "nothing to compare" and ALLOWS — a
|
||||
// first-party lane ships inside the SHA-pinned distribution and is never consent-gated, so
|
||||
// denying on absence would break every existing local-model user.
|
||||
let consentedHost;
|
||||
if (entry.plan.transport === 'openai-http') {
|
||||
try {
|
||||
const consent = require('./lib/capability-consent.cjs');
|
||||
const projectRoot = require('./lib/project-root.cjs').consentProjectRoot(cwd);
|
||||
// The capability id is kebab; the lane slug may be snake (lm_studio / lm-studio).
|
||||
const capId = String(entry.slug).replace(/_/g, '-');
|
||||
consentedHost = consent.readConsentedReviewerHost({ projectRoot, id: capId });
|
||||
} catch { consentedHost = undefined; }
|
||||
}
|
||||
|
||||
const result = await runner.runLane(entry.plan, deps, {
|
||||
consentedHost,
|
||||
explicitlyRequested: args.includes('--explicit'),
|
||||
repoRoot,
|
||||
});
|
||||
output(result, raw);
|
||||
}
|
||||
|
||||
function routeNormalizeTestCommand({ args, cwd, raw, error }) {
|
||||
// #1857: rewrite a resolved test command to a one-shot form so a
|
||||
// watch-mode runner (vitest/jest) cannot hang a verification gate. Shared
|
||||
@@ -2589,6 +2916,7 @@ const HOST_COMMAND_ROUTERS = {
|
||||
'restore-custom-files': routeRestoreCustomFiles,
|
||||
'from-gsd2': routeFromGsd2,
|
||||
'prompt-budget': routePromptBudget,
|
||||
'review-lane': routeReviewLane,
|
||||
'update-context': routeUpdateContext,
|
||||
'classify-confidence': routeClassifyConfidence,
|
||||
'package-legitimacy': routePackageLegitimacy,
|
||||
|
||||
@@ -210,7 +210,9 @@ const capabilities = {
|
||||
"args": [
|
||||
"--print-timeout",
|
||||
"540s",
|
||||
"-p"
|
||||
"{{model}}",
|
||||
"-p",
|
||||
"{{prompt}}"
|
||||
],
|
||||
"promptChannel": "argv-file-ref",
|
||||
"outputChannel": "stdout",
|
||||
@@ -221,10 +223,9 @@ const capabilities = {
|
||||
"emptyOutput": "handler-owned",
|
||||
"reviewsSection": "Antigravity",
|
||||
"evidenceClass": "source-grounded",
|
||||
"requiresBinaries": [
|
||||
"jq"
|
||||
],
|
||||
"requiresBinaries": [],
|
||||
"promptBudgetKey": null,
|
||||
"modelConfigKey": "review.models.agy",
|
||||
"handler": "antigravity"
|
||||
},
|
||||
"config": {
|
||||
@@ -593,6 +594,8 @@ const capabilities = {
|
||||
"invoke": {
|
||||
"binary": "claude",
|
||||
"args": [
|
||||
"{{model}}",
|
||||
"{{effort}}",
|
||||
"-p",
|
||||
"-"
|
||||
],
|
||||
@@ -607,6 +610,7 @@ const capabilities = {
|
||||
"evidenceClass": "source-grounded",
|
||||
"requiresBinaries": [],
|
||||
"promptBudgetKey": null,
|
||||
"modelConfigKey": "review.models.claude",
|
||||
"handler": null
|
||||
},
|
||||
"config": {
|
||||
@@ -988,6 +992,7 @@ const capabilities = {
|
||||
"evidenceClass": "diff-only",
|
||||
"requiresBinaries": [],
|
||||
"promptBudgetKey": null,
|
||||
"modelConfigKey": null,
|
||||
"handler": null
|
||||
}
|
||||
},
|
||||
@@ -1100,7 +1105,10 @@ const capabilities = {
|
||||
"args": [
|
||||
"exec",
|
||||
"--ephemeral",
|
||||
"{{model}}",
|
||||
"{{effort}}",
|
||||
"--skip-git-repo-check",
|
||||
"{{output}}",
|
||||
"-"
|
||||
],
|
||||
"promptChannel": "stdin",
|
||||
@@ -1115,6 +1123,7 @@ const capabilities = {
|
||||
"evidenceClass": "source-grounded",
|
||||
"requiresBinaries": [],
|
||||
"promptBudgetKey": null,
|
||||
"modelConfigKey": "review.models.codex",
|
||||
"handler": null
|
||||
},
|
||||
"config": {
|
||||
@@ -1361,7 +1370,8 @@ const capabilities = {
|
||||
"ask",
|
||||
"--trust",
|
||||
"--output-format",
|
||||
"text"
|
||||
"text",
|
||||
"{{prompt}}"
|
||||
],
|
||||
"promptChannel": "argv-file-ref",
|
||||
"outputChannel": "stdout",
|
||||
@@ -1374,6 +1384,7 @@ const capabilities = {
|
||||
"evidenceClass": "source-grounded",
|
||||
"requiresBinaries": [],
|
||||
"promptBudgetKey": null,
|
||||
"modelConfigKey": null,
|
||||
"handler": null
|
||||
}
|
||||
},
|
||||
@@ -1603,6 +1614,7 @@ const capabilities = {
|
||||
"invoke": {
|
||||
"binary": "gemini",
|
||||
"args": [
|
||||
"{{model}}",
|
||||
"-p",
|
||||
"-"
|
||||
],
|
||||
@@ -1617,6 +1629,7 @@ const capabilities = {
|
||||
"evidenceClass": "source-grounded",
|
||||
"requiresBinaries": [],
|
||||
"promptBudgetKey": null,
|
||||
"modelConfigKey": "review.models.gemini",
|
||||
"handler": null
|
||||
},
|
||||
"config": {
|
||||
@@ -2108,6 +2121,46 @@ const capabilities = {
|
||||
"skipSharedHooksInstall": true,
|
||||
"namedSubagentsSupported": false
|
||||
}
|
||||
},
|
||||
"reviewer": {
|
||||
"slug": "kimi-code",
|
||||
"flags": [
|
||||
"--kimi-code"
|
||||
],
|
||||
"transport": "spawn",
|
||||
"probe": {
|
||||
"kind": "command-capability",
|
||||
"binary": "kimi",
|
||||
"needle": "--output-format",
|
||||
"timeoutMs": 5000
|
||||
},
|
||||
"invoke": {
|
||||
"binary": "kimi",
|
||||
"args": [
|
||||
"{{model}}",
|
||||
"-p",
|
||||
"{{prompt}}"
|
||||
],
|
||||
"promptChannel": "argv-file-ref",
|
||||
"outputChannel": "stdout",
|
||||
"modelArg": "-m",
|
||||
"effortChannel": "none"
|
||||
},
|
||||
"timeoutFloorMs": 900000,
|
||||
"emptyOutput": "stub-with-stderr",
|
||||
"reviewsSection": "Kimi Code",
|
||||
"evidenceClass": "source-grounded",
|
||||
"requiresBinaries": [],
|
||||
"promptBudgetKey": null,
|
||||
"modelConfigKey": "review.models.kimi-code",
|
||||
"handler": null
|
||||
},
|
||||
"config": {
|
||||
"review.models.kimi-code": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "Model passed to the Kimi Code reviewer lane."
|
||||
}
|
||||
}
|
||||
},
|
||||
"llama-cpp": {
|
||||
@@ -2135,18 +2188,19 @@ const capabilities = {
|
||||
},
|
||||
"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": 120000,
|
||||
"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"
|
||||
},
|
||||
"config": {
|
||||
@@ -2192,18 +2246,19 @@ const capabilities = {
|
||||
},
|
||||
"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": 120000,
|
||||
"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"
|
||||
},
|
||||
"config": {
|
||||
@@ -2473,18 +2528,19 @@ const capabilities = {
|
||||
},
|
||||
"invoke": {
|
||||
"hostConfigKey": "review.ollama_host",
|
||||
"defaultHost": "http://localhost:11434",
|
||||
"path": "/v1/chat/completions",
|
||||
"modelDiscovery": "first-from-models-endpoint",
|
||||
"fallbackModel": "llama3",
|
||||
"effortChannel": "none"
|
||||
},
|
||||
"timeoutFloorMs": 120000,
|
||||
"emptyOutput": "stub-with-stderr",
|
||||
"reviewsSection": "Ollama",
|
||||
"evidenceClass": "source-grounded",
|
||||
"requiresBinaries": [
|
||||
"jq"
|
||||
],
|
||||
"requiresBinaries": [],
|
||||
"promptBudgetKey": "review.max_prompt_tokens_per_reviewer.ollama",
|
||||
"modelConfigKey": "review.models.ollama",
|
||||
"handler": "openai-compatible"
|
||||
},
|
||||
"config": {
|
||||
@@ -2634,6 +2690,8 @@ const capabilities = {
|
||||
"binary": "opencode",
|
||||
"args": [
|
||||
"run",
|
||||
"{{model}}",
|
||||
"{{effort}}",
|
||||
"--format",
|
||||
"json",
|
||||
"-"
|
||||
@@ -2647,11 +2705,10 @@ const capabilities = {
|
||||
"emptyOutput": "stub-with-stderr",
|
||||
"reviewsSection": "OpenCode",
|
||||
"evidenceClass": "source-grounded",
|
||||
"requiresBinaries": [
|
||||
"jq"
|
||||
],
|
||||
"requiresBinaries": [],
|
||||
"promptBudgetKey": null,
|
||||
"handler": null
|
||||
"modelConfigKey": "review.models.opencode",
|
||||
"handler": "opencode"
|
||||
},
|
||||
"config": {
|
||||
"review.models.opencode": {
|
||||
@@ -2986,6 +3043,7 @@ const capabilities = {
|
||||
"evidenceClass": "source-grounded",
|
||||
"requiresBinaries": [],
|
||||
"promptBudgetKey": null,
|
||||
"modelConfigKey": null,
|
||||
"handler": null
|
||||
}
|
||||
},
|
||||
@@ -4291,6 +4349,7 @@ const configKeys = {
|
||||
"review.models.gemini": "gemini",
|
||||
"graphify.enabled": "graphify",
|
||||
"intel.enabled": "intel",
|
||||
"review.models.kimi-code": "kimi-code",
|
||||
"review.models.llama_cpp": "llama-cpp",
|
||||
"review.llama_cpp_host": "llama-cpp",
|
||||
"review.max_prompt_tokens_per_reviewer.llama_cpp": "llama-cpp",
|
||||
@@ -4493,6 +4552,12 @@ const configSchema = {
|
||||
"default": false,
|
||||
"description": "Enable the intel code-intelligence command."
|
||||
},
|
||||
"review.models.kimi-code": {
|
||||
"owner": "kimi-code",
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "Model passed to the Kimi Code reviewer lane."
|
||||
},
|
||||
"review.models.llama_cpp": {
|
||||
"owner": "llama-cpp",
|
||||
"type": "string",
|
||||
@@ -4818,7 +4883,9 @@ const runtimes = {
|
||||
"args": [
|
||||
"--print-timeout",
|
||||
"540s",
|
||||
"-p"
|
||||
"{{model}}",
|
||||
"-p",
|
||||
"{{prompt}}"
|
||||
],
|
||||
"promptChannel": "argv-file-ref",
|
||||
"outputChannel": "stdout",
|
||||
@@ -4829,10 +4896,9 @@ const runtimes = {
|
||||
"emptyOutput": "handler-owned",
|
||||
"reviewsSection": "Antigravity",
|
||||
"evidenceClass": "source-grounded",
|
||||
"requiresBinaries": [
|
||||
"jq"
|
||||
],
|
||||
"requiresBinaries": [],
|
||||
"promptBudgetKey": null,
|
||||
"modelConfigKey": "review.models.agy",
|
||||
"handler": "antigravity"
|
||||
},
|
||||
"config": {
|
||||
@@ -5072,6 +5138,8 @@ const runtimes = {
|
||||
"invoke": {
|
||||
"binary": "claude",
|
||||
"args": [
|
||||
"{{model}}",
|
||||
"{{effort}}",
|
||||
"-p",
|
||||
"-"
|
||||
],
|
||||
@@ -5086,6 +5154,7 @@ const runtimes = {
|
||||
"evidenceClass": "source-grounded",
|
||||
"requiresBinaries": [],
|
||||
"promptBudgetKey": null,
|
||||
"modelConfigKey": "review.models.claude",
|
||||
"handler": null
|
||||
},
|
||||
"config": {
|
||||
@@ -5389,7 +5458,10 @@ const runtimes = {
|
||||
"args": [
|
||||
"exec",
|
||||
"--ephemeral",
|
||||
"{{model}}",
|
||||
"{{effort}}",
|
||||
"--skip-git-repo-check",
|
||||
"{{output}}",
|
||||
"-"
|
||||
],
|
||||
"promptChannel": "stdin",
|
||||
@@ -5404,6 +5476,7 @@ const runtimes = {
|
||||
"evidenceClass": "source-grounded",
|
||||
"requiresBinaries": [],
|
||||
"promptBudgetKey": null,
|
||||
"modelConfigKey": "review.models.codex",
|
||||
"handler": null
|
||||
},
|
||||
"config": {
|
||||
@@ -5650,7 +5723,8 @@ const runtimes = {
|
||||
"ask",
|
||||
"--trust",
|
||||
"--output-format",
|
||||
"text"
|
||||
"text",
|
||||
"{{prompt}}"
|
||||
],
|
||||
"promptChannel": "argv-file-ref",
|
||||
"outputChannel": "stdout",
|
||||
@@ -5663,6 +5737,7 @@ const runtimes = {
|
||||
"evidenceClass": "source-grounded",
|
||||
"requiresBinaries": [],
|
||||
"promptBudgetKey": null,
|
||||
"modelConfigKey": null,
|
||||
"handler": null
|
||||
}
|
||||
},
|
||||
@@ -6054,6 +6129,46 @@ const runtimes = {
|
||||
"skipSharedHooksInstall": true,
|
||||
"namedSubagentsSupported": false
|
||||
}
|
||||
},
|
||||
"reviewer": {
|
||||
"slug": "kimi-code",
|
||||
"flags": [
|
||||
"--kimi-code"
|
||||
],
|
||||
"transport": "spawn",
|
||||
"probe": {
|
||||
"kind": "command-capability",
|
||||
"binary": "kimi",
|
||||
"needle": "--output-format",
|
||||
"timeoutMs": 5000
|
||||
},
|
||||
"invoke": {
|
||||
"binary": "kimi",
|
||||
"args": [
|
||||
"{{model}}",
|
||||
"-p",
|
||||
"{{prompt}}"
|
||||
],
|
||||
"promptChannel": "argv-file-ref",
|
||||
"outputChannel": "stdout",
|
||||
"modelArg": "-m",
|
||||
"effortChannel": "none"
|
||||
},
|
||||
"timeoutFloorMs": 900000,
|
||||
"emptyOutput": "stub-with-stderr",
|
||||
"reviewsSection": "Kimi Code",
|
||||
"evidenceClass": "source-grounded",
|
||||
"requiresBinaries": [],
|
||||
"promptBudgetKey": null,
|
||||
"modelConfigKey": "review.models.kimi-code",
|
||||
"handler": null
|
||||
},
|
||||
"config": {
|
||||
"review.models.kimi-code": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "Model passed to the Kimi Code reviewer lane."
|
||||
}
|
||||
}
|
||||
},
|
||||
"opencode": {
|
||||
@@ -6185,6 +6300,8 @@ const runtimes = {
|
||||
"binary": "opencode",
|
||||
"args": [
|
||||
"run",
|
||||
"{{model}}",
|
||||
"{{effort}}",
|
||||
"--format",
|
||||
"json",
|
||||
"-"
|
||||
@@ -6198,11 +6315,10 @@ const runtimes = {
|
||||
"emptyOutput": "stub-with-stderr",
|
||||
"reviewsSection": "OpenCode",
|
||||
"evidenceClass": "source-grounded",
|
||||
"requiresBinaries": [
|
||||
"jq"
|
||||
],
|
||||
"requiresBinaries": [],
|
||||
"promptBudgetKey": null,
|
||||
"handler": null
|
||||
"modelConfigKey": "review.models.opencode",
|
||||
"handler": "opencode"
|
||||
},
|
||||
"config": {
|
||||
"review.models.opencode": {
|
||||
@@ -6406,6 +6522,7 @@ const runtimes = {
|
||||
"evidenceClass": "source-grounded",
|
||||
"requiresBinaries": [],
|
||||
"promptBudgetKey": null,
|
||||
"modelConfigKey": null,
|
||||
"handler": null
|
||||
}
|
||||
},
|
||||
|
||||
@@ -837,14 +837,27 @@ const VALID_EVIDENCE_CLASSES = new Set(['source-grounded', 'diff-only']);
|
||||
// member per lane has stopped being a vocabulary and become a dispatch table for
|
||||
// bespoke code — at which point the descriptor is a plugin system wearing a
|
||||
// manifest, and that is a decision for an ADR, not for a downstream phase.
|
||||
const VALID_LANE_HANDLERS = new Set(['antigravity', 'openai-compatible']);
|
||||
// `opencode` admitted by Phase 5b (#2799) under the SECOND arm of the rule above:
|
||||
// it serves 1 lane, justified by a documented upstream defect data cannot express.
|
||||
// OpenCode's default `build` agent is an agentic coder, not a prompt→completion
|
||||
// API; on a large review prompt it can end its turn with ZERO output tokens, and
|
||||
// `--format default` then drops the assistant text entirely, silently losing the
|
||||
// reviewer (#1936). The review must therefore be RECONSTRUCTED from the assistant
|
||||
// `text` parts of a `--format json` stream — a parse, not a copy. Expressing that
|
||||
// as data would need an `outputChannel: 'json-parts'` plus a selector expression,
|
||||
// i.e. exactly the ad-hoc interpreter the admission rule exists to prevent.
|
||||
const VALID_LANE_HANDLERS = new Set(['antigravity', 'openai-compatible', 'opencode']);
|
||||
|
||||
// D2 — `transport` selects the invoke sub-shape. A manifest carrying fields from
|
||||
// BOTH sub-shapes, or from NEITHER, has undefined meaning and fails validation.
|
||||
// The discriminator is explicit rather than inferred from field presence, which
|
||||
// is precisely the ambiguity these two sets exist to detect.
|
||||
const SPAWN_ONLY_INVOKE_FIELDS = ['binary', 'args', 'promptChannel', 'outputChannel', 'outputArg', 'modelArg'];
|
||||
const HTTP_ONLY_INVOKE_FIELDS = ['hostConfigKey', 'path', 'modelDiscovery'];
|
||||
// `defaultHost` / `fallbackModel` added by Phase 5b (#2799). Phase 4 federated every
|
||||
// `review.*_host` key with a default of `""`, so the REAL fallback destination and model
|
||||
// (`http://localhost:11434` / `llama3` and friends) existed only inside the bash leg. Once the
|
||||
// lane is invoked from data, an unset host with no declared default would POST to a garbage URL.
|
||||
const HTTP_ONLY_INVOKE_FIELDS = ['hostConfigKey', 'defaultHost', 'path', 'modelDiscovery', 'fallbackModel'];
|
||||
|
||||
// Feature-only fields are as forbidden on a lane-only capability as on a runtime
|
||||
// one; a `role: "reviewer"` capability owns no artefacts and wires no loop point.
|
||||
@@ -1545,7 +1558,14 @@ function validateRuntimeBody(cap) {
|
||||
*/
|
||||
const KNOWN_REVIEWER_FIELDS = new Set([
|
||||
'slug', 'flags', 'transport', 'probe', 'invoke', 'timeoutFloorMs', 'emptyOutput',
|
||||
'reviewsSection', 'evidenceClass', 'requiresBinaries', 'promptBudgetKey', 'handler',
|
||||
'reviewsSection', 'evidenceClass', 'requiresBinaries', 'promptBudgetKey',
|
||||
// `modelConfigKey` added by Phase 5b (#2799). The model key was IMPLICIT
|
||||
// (`review.models.<slug>`) until a shipped lane broke the convention: antigravity's
|
||||
// slug is `antigravity` but its key is `review.models.agy`, so resolving by slug
|
||||
// missed a configured model and silently disabled the pinned-model escape hatch
|
||||
// #2073 added. A convention one shipped lane already violates is not a contract.
|
||||
'modelConfigKey',
|
||||
'handler',
|
||||
]);
|
||||
|
||||
const KNOWN_PROBE_FIELDS = new Set(['kind', 'binary', 'needle', 'timeoutMs', 'hostConfigKey', 'path']);
|
||||
@@ -1796,6 +1816,19 @@ function validateReviewerBodyFields(cap) {
|
||||
}
|
||||
}
|
||||
|
||||
// OPTIONAL, and that is required by D4 rather than a convenience: `modelConfigKey` did not exist
|
||||
// before Phase 5b, so demanding it would fail validation on every reviewer manifest authored
|
||||
// against an earlier GSD — exactly the forward/backward-compatibility break D4 rule 2 forbids.
|
||||
// Absent is read as `null` (this lane accepts no model override). `null` is explicit; an empty
|
||||
// string is neither, and is rejected.
|
||||
if (r.modelConfigKey !== undefined && r.modelConfigKey !== null &&
|
||||
(typeof r.modelConfigKey !== 'string' || r.modelConfigKey.length === 0)) {
|
||||
errors.push(
|
||||
ctx + ' reviewer.modelConfigKey must be a dotted config key or null ' +
|
||||
'(got: ' + describeValue(r.modelConfigKey) + ')',
|
||||
);
|
||||
}
|
||||
|
||||
// `null` is the declared "no per-lane budget"; an empty string is not.
|
||||
if (r.promptBudgetKey !== null && (typeof r.promptBudgetKey !== 'string' || r.promptBudgetKey.length === 0)) {
|
||||
errors.push(
|
||||
|
||||
Reference in New Issue
Block a user