fix(#4255): resolve reviewer-lane effort from the lane, not from gsd-plan-checker (#4275)

`review-lane plan` resolved every cross-AI reviewer lane's reasoning effort by
spawning `query resolve-execution gsd-plan-checker --host <slug>`. The agent id
was a hardcoded literal, so `--host` chose only the argv RENDERING while the
LEVEL always came from the installed plan-checker's frontmatter — `low` under
every shipped model profile. Every prompt-fed lane therefore ran at a fast
structural verifier's effort, and because the rendered argument is a CLI config
override it silently beat the effort the operator had configured for that CLI.
At `low` a large source-grounded prompt makes a model end its turn with no final
message, so the lane came back empty and its stub read as a crash.

Effort is a property of the review, so the lane declares it. Two new fields on
ReviewerLane — `effortConfigKey` (`review.effort.<slug>`) and `defaultEffort` —
carried through each capability manifest and the generated registry, set on the
three lanes with an argv effort channel and null on the other nine. A new pure
`resolveLaneEffort()` resolves config key -> lane default -> nothing, where
"nothing" emits no effort argument at all and the reviewer CLI's own
configuration decides; `inherit` selects that path explicitly and an
unrecognized level falls back to the lane default rather than being forwarded to
a CLI that would reject it. The host's negotiated effortSurface still gates the
rendering, so ADR-1239/#2481's trust boundary holds on this path too. Resolving
in-process also removes up to twelve subprocess spawns per review.

The empty-output stub now names the effort the lane ran at and distinguishes a
clean exit from a timeout kill, a non-zero exit, and a process that never ran —
`status` is null for both a timeout and a signal, so those were indistinguishable
before. The hint is hedged: a clean empty exit is most often a model stopping
short, but it is also consistent with a CLI writing its output elsewhere.

Also: the capability validator now knows both fields, rejects a malformed key or
an out-of-vocabulary default, and rejects a default declared without a config
key (a level the operator could never override). An existing end-to-end row in
tests/effort-surface-axis.test.cjs asserted the old coupling; it now configures
the lane's own key and pins the decoupling in the same real spawn, with the
agent execution tier set to a level that must not appear.

Emitted-Drift-Ack-Growth: review.md — the effort/model resolution-order table this fix adds. The workflow is where an operator looks to find out which knob set a lane's model and effort; leaving the new key undocumented there is the same invisibility that made the plan-checker coupling survive this long.

Emitted-Drift-Ack-Growth: review.md — the effort/model resolution-order table this fix adds. The workflow is where an operator looks to find out which knob set a lane's model and effort, so leaving the new key undocumented there is the same invisibility that let the plan-checker coupling survive.

Claude-Session: https://claude.ai/code/session_01CRMEuzNMWn3gs5uUW2ghcF

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
This commit is contained in:
Behruz Nassre Esfahani
2026-09-05 02:25:44 -07:00
committed by GitHub
parent 925a363879
commit 5ad9a36f35
26 changed files with 759 additions and 61 deletions

View File

@@ -1322,7 +1322,8 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
const fsx = require('node:fs');
const os = require('node:os');
const { REVIEWER_LANES, mergeReviewerLanes } = require('./lib/review-lane-descriptor.cjs');
const { resolveLanePlan } = require('./lib/review-lane-invocation.cjs');
const { resolveLanePlan, resolveLaneEffort } = require('./lib/review-lane-invocation.cjs');
const modelCatalog = require('./lib/model-catalog.cjs');
const runner = require('./lib/review-lane-runner.cjs');
const cfgLoader = require('./lib/config-loader.cjs');
const capabilityLoader = require('./lib/capability-loader.cjs');
@@ -1334,13 +1335,15 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
const sub = args[1];
// Fail fast on an unrecognized subcommand. Without this check, `sub` fell through
// to the `sub !== 'invoke'` usage-error branch far below (after loading the
// capability registry AND building a per-lane plan for every lane — which itself
// spawns one child `query resolve-execution` process per lane via `effortFor`,
// up to 12 subprocess spawns for the default lane set) before ever reporting the
// error. That made an invalid subcommand slow instead of instant, and under bench
// load (many sequential node spawns) `review-lane bogus` could exceed a caller's
// spawn timeout and be killed before writing anything to stderr — the CI-observed
// failure was empty stdout AND stderr, not the expected usage message (#3148).
// capability registry AND building a per-lane plan for every lane) before ever
// reporting the error. That made an invalid subcommand slow instead of instant, and
// under bench load `review-lane bogus` could exceed a caller's spawn timeout and be
// killed before writing anything to stderr — the CI-observed failure was empty stdout
// AND stderr, not the expected usage message (#3148). The plan path used to be far
// heavier still: it spawned one child `query resolve-execution` process PER LANE to
// fetch effort, up to 12 subprocess spawns for the default set. #4255 resolves effort
// in-process from the lane's own declaration, so that cost is gone; the fail-fast
// check stays because building 12 plans is still work an unrecognized sub should skip.
// `plan`/`invoke` are the only subs that need the expensive plan-building path
// below; `sections`/`flags` return earlier still. Anything else errors here, before
// any of that work starts.
@@ -1427,46 +1430,29 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
return;
}
// Effort argv is resolved per lane by the host's own execution policy, through the SAME
// `resolve-execution` surface the bash legs used (`--host <slug>`), 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.
// Effort argv is resolved from the LANE's own review configuration (#4255), then rendered
// through the host's negotiated `effortSurface` so ADR-1239/#2481's trust-boundary invariant
// still decides whether an argument is emitted at all and the catalog still owns the syntax.
//
// NOT `--raw` and NOT `--pick` (#2295). `--raw` prints only the resolved EFFORT ('low') with
// no host-specific rendering at all. `--pick effort_argv_string` used to be the answer — the
// rendered array re-joined into a string ('-c model_reasoning_effort=low') — but the caller
// then had to `.split(/\s+/)` that string back apart to get an argv array, and re-splitting a
// string the callee just joined is a lossy round trip: any argv element that legitimately
// contains a space would come back split into two argv elements, corrupting the very argv it
// was rendered to preserve. Reading the UNPICKED object instead gives both `effort_argv` (a
// real string array, used verbatim, no re-splitting) and `effort_argv_value` (the bare level,
// #2295's `plan.effort`) from the one spawn.
const EMPTY_EFFORT = { argv: [], value: null };
const effortFor = (slug) => {
// What this replaced: a `query resolve-execution gsd-plan-checker --host <slug>` spawn per
// lane. The agent id was a hardcoded literal, so the `--host` argument chose only the argv
// RENDERING while the LEVEL always came from the installed plan-checker's frontmatter — `low`
// under every shipped model profile. Every prompt-fed reviewer therefore ran at a fast
// structural verifier's effort, and because the rendered argument is a CLI config override it
// silently beat the effort the operator had configured for that CLI. At `low` a large
// source-grounded prompt makes a model end its turn with no final message, so the lane came
// back empty and the stub read as a crash.
//
// `resolveLaneEffort` is pure and lives beside the other lane resolution; this closure only
// injects the rendering, which needs the registry and the catalog.
const renderLaneEffort = (host, level) => {
try {
const r = cp.spawnSync(
process.execPath,
[__filename, 'query', 'resolve-execution', 'gsd-plan-checker', '--host', slug],
{ cwd, encoding: 'utf8', timeout: 15000, killSignal: 'SIGKILL', maxBuffer: 1024 * 1024 },
);
if (r.status !== 0) return EMPTY_EFFORT;
let parsed;
try {
parsed = JSON.parse(String(r.stdout || ''));
} catch { return EMPTY_EFFORT; }
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return EMPTY_EFFORT;
const argv = Array.isArray(parsed.effort_argv)
? parsed.effort_argv.filter((a) => typeof a === 'string' && a !== '')
: [];
const value = typeof parsed.effort_argv_value === 'string' && parsed.effort_argv_value
? parsed.effort_argv_value
: null;
return { argv, value };
} catch { return EMPTY_EFFORT; }
const surface = commands.effortSurfaceForHost(cwd, host);
const r = modelCatalog.renderEffortArgv(host, level, surface);
return { argv: Array.isArray(r && r.argv) ? r.argv : [], value: (r && r.value) || null };
} catch { return { argv: [], value: null }; }
};
const effortFor = (lane) => resolveLaneEffort(lane, configGet, renderLaneEffort);
/**
* Per-lane prompt budget (#2797 semantics, preserved exactly).
@@ -1498,7 +1484,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
// so losing all of them to one bad manifest is strictly worse. Belt and braces on purpose.
let r;
try {
const effort = effortFor(slug);
const effort = effortFor(lane);
r = resolveLanePlan({ lane, configGet, runDir, repoRoot, effortArgs: effort.argv, effortValue: effort.value });
} catch (e) {
return { slug, ok: false, reason: 'malformed_lane', detail: `resolver threw: ${e && e.message ? e.message : String(e)}` };
@@ -1658,7 +1644,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
`model override (it declares no modelConfigKey). The review will use the CLI's own default.\n`,
);
}
const instanceEffort = effortFor(entry.slug);
const instanceEffort = effortFor(lane);
const overridden = resolveLanePlan({
lane,
configGet: (k) => (key && k === key ? instanceModel : configGet(k)),

View File

@@ -233,6 +233,8 @@ const capabilities = {
"requiresBinaries": [],
"promptBudgetKey": "review.max_prompt_tokens_per_reviewer.antigravity",
"modelConfigKey": "review.models.agy",
"effortConfigKey": null,
"defaultEffort": null,
"handler": "antigravity"
},
"config": {
@@ -650,6 +652,8 @@ const capabilities = {
"requiresBinaries": [],
"promptBudgetKey": "review.max_prompt_tokens_per_reviewer.claude",
"modelConfigKey": "review.models.claude",
"effortConfigKey": "review.effort.claude",
"defaultEffort": "high",
"handler": null
},
"config": {
@@ -667,6 +671,11 @@ const capabilities = {
"type": "number",
"default": -1,
"description": "Outer wall-clock timeout override (seconds) for the Claude reviewer lane. Unset is -1, a sentinel: 0 or a negative number is also treated as unset (a timeout has no legitimate zero/negative value), so no second sentinel is needed. Falls back to the lane's built-in timeoutFloorMs when unset."
},
"review.effort.claude": {
"type": "string",
"default": "",
"description": "Reasoning effort for the Claude reviewer lane: minimal, low, medium, high, xhigh, max, or inherit. Unset falls back to the lane's declared review default (high); inherit emits no effort argument so the CLI's own configuration decides. An unrecognized value falls back to the lane default rather than being forwarded."
}
}
},
@@ -1093,6 +1102,8 @@ const capabilities = {
"requiresBinaries": [],
"promptBudgetKey": "review.max_prompt_tokens_per_reviewer.coderabbit",
"modelConfigKey": null,
"effortConfigKey": null,
"defaultEffort": null,
"handler": null
},
"config": {
@@ -1252,6 +1263,8 @@ const capabilities = {
"requiresBinaries": [],
"promptBudgetKey": "review.max_prompt_tokens_per_reviewer.codex",
"modelConfigKey": "review.models.codex",
"effortConfigKey": "review.effort.codex",
"defaultEffort": "high",
"handler": null
},
"config": {
@@ -1269,6 +1282,11 @@ const capabilities = {
"type": "number",
"default": -1,
"description": "Outer wall-clock timeout override (seconds) for the Codex reviewer lane. Unset is -1, a sentinel: 0 or a negative number is also treated as unset (a timeout has no legitimate zero/negative value), so no second sentinel is needed. Falls back to the lane's built-in timeoutFloorMs when unset."
},
"review.effort.codex": {
"type": "string",
"default": "",
"description": "Reasoning effort for the Codex reviewer lane: minimal, low, medium, high, xhigh, max, or inherit. Unset falls back to the lane's declared review default (high); inherit emits no effort argument so the CLI's own configuration decides. An unrecognized value falls back to the lane default rather than being forwarded."
}
}
},
@@ -1524,6 +1542,8 @@ const capabilities = {
"requiresBinaries": [],
"promptBudgetKey": "review.max_prompt_tokens_per_reviewer.cursor",
"modelConfigKey": "review.models.cursor",
"effortConfigKey": null,
"defaultEffort": null,
"handler": null
},
"config": {
@@ -1805,6 +1825,8 @@ const capabilities = {
"requiresBinaries": [],
"promptBudgetKey": "review.max_prompt_tokens_per_reviewer.gemini",
"modelConfigKey": "review.models.gemini",
"effortConfigKey": null,
"defaultEffort": null,
"handler": null
},
"config": {
@@ -2409,6 +2431,8 @@ const capabilities = {
"requiresBinaries": [],
"promptBudgetKey": "review.max_prompt_tokens_per_reviewer.kimi-code",
"modelConfigKey": "review.models.kimi-code",
"effortConfigKey": null,
"defaultEffort": null,
"handler": null
},
"config": {
@@ -2521,6 +2545,8 @@ const capabilities = {
"requiresBinaries": [],
"promptBudgetKey": "review.max_prompt_tokens_per_reviewer.llama_cpp",
"modelConfigKey": "review.models.llama_cpp",
"effortConfigKey": null,
"defaultEffort": null,
"handler": "openai-compatible"
},
"config": {
@@ -2585,6 +2611,8 @@ const capabilities = {
"requiresBinaries": [],
"promptBudgetKey": "review.max_prompt_tokens_per_reviewer.lm_studio",
"modelConfigKey": "review.models.lm_studio",
"effortConfigKey": null,
"defaultEffort": null,
"handler": "openai-compatible"
},
"config": {
@@ -2873,6 +2901,8 @@ const capabilities = {
"requiresBinaries": [],
"promptBudgetKey": "review.max_prompt_tokens_per_reviewer.ollama",
"modelConfigKey": "review.models.ollama",
"effortConfigKey": null,
"defaultEffort": null,
"handler": "openai-compatible"
},
"config": {
@@ -3066,6 +3096,8 @@ const capabilities = {
"requiresBinaries": [],
"promptBudgetKey": "review.max_prompt_tokens_per_reviewer.opencode",
"modelConfigKey": "review.models.opencode",
"effortConfigKey": "review.effort.opencode",
"defaultEffort": "high",
"handler": "opencode"
},
"config": {
@@ -3083,6 +3115,11 @@ const capabilities = {
"type": "number",
"default": -1,
"description": "Outer wall-clock timeout override (seconds) for the OpenCode reviewer lane. Unset is -1, a sentinel: 0 or a negative number is also treated as unset (a timeout has no legitimate zero/negative value), so no second sentinel is needed. Falls back to the lane's built-in timeoutFloorMs when unset."
},
"review.effort.opencode": {
"type": "string",
"default": "",
"description": "Reasoning effort for the OpenCode reviewer lane: minimal, low, medium, high, xhigh, max, or inherit. Unset falls back to the lane's declared review default (high); inherit emits no effort argument so the CLI's own configuration decides. An unrecognized value falls back to the lane default rather than being forwarded."
}
}
},
@@ -3425,6 +3462,8 @@ const capabilities = {
"requiresBinaries": [],
"promptBudgetKey": "review.max_prompt_tokens_per_reviewer.qwen",
"modelConfigKey": null,
"effortConfigKey": null,
"defaultEffort": null,
"handler": null
},
"config": {
@@ -4869,6 +4908,7 @@ const configKeys = {
"review.models.claude": "claude",
"review.max_prompt_tokens_per_reviewer.claude": "claude",
"review.timeouts.claude": "claude",
"review.effort.claude": "claude",
"claude_orchestration.enabled": "claude-orchestration",
"claude_orchestration.execution_backend": "claude-orchestration",
"claude_orchestration.min_agent_sdk_version": "claude-orchestration",
@@ -4879,6 +4919,7 @@ const configKeys = {
"review.models.codex": "codex",
"review.max_prompt_tokens_per_reviewer.codex": "codex",
"review.timeouts.codex": "codex",
"review.effort.codex": "codex",
"review.models.cursor": "cursor",
"review.max_prompt_tokens_per_reviewer.cursor": "cursor",
"workflow.drift_threshold": "drift",
@@ -4928,6 +4969,7 @@ const configKeys = {
"review.models.opencode": "opencode",
"review.max_prompt_tokens_per_reviewer.opencode": "opencode",
"review.timeouts.opencode": "opencode",
"review.effort.opencode": "opencode",
"workflow.pattern_mapper": "pattern-mapper",
"profile-pipeline.enabled": "profile-pipeline",
"review.max_prompt_tokens_per_reviewer.qwen": "qwen",
@@ -5007,6 +5049,12 @@ const configSchema = {
"default": -1,
"description": "Outer wall-clock timeout override (seconds) for the Claude reviewer lane. Unset is -1, a sentinel: 0 or a negative number is also treated as unset (a timeout has no legitimate zero/negative value), so no second sentinel is needed. Falls back to the lane's built-in timeoutFloorMs when unset."
},
"review.effort.claude": {
"owner": "claude",
"type": "string",
"default": "",
"description": "Reasoning effort for the Claude reviewer lane: minimal, low, medium, high, xhigh, max, or inherit. Unset falls back to the lane's declared review default (high); inherit emits no effort argument so the CLI's own configuration decides. An unrecognized value falls back to the lane default rather than being forwarded."
},
"claude_orchestration.enabled": {
"owner": "claude-orchestration",
"type": "boolean",
@@ -5081,6 +5129,12 @@ const configSchema = {
"default": -1,
"description": "Outer wall-clock timeout override (seconds) for the Codex reviewer lane. Unset is -1, a sentinel: 0 or a negative number is also treated as unset (a timeout has no legitimate zero/negative value), so no second sentinel is needed. Falls back to the lane's built-in timeoutFloorMs when unset."
},
"review.effort.codex": {
"owner": "codex",
"type": "string",
"default": "",
"description": "Reasoning effort for the Codex reviewer lane: minimal, low, medium, high, xhigh, max, or inherit. Unset falls back to the lane's declared review default (high); inherit emits no effort argument so the CLI's own configuration decides. An unrecognized value falls back to the lane default rather than being forwarded."
},
"review.models.cursor": {
"owner": "cursor",
"type": "string",
@@ -5391,6 +5445,12 @@ const configSchema = {
"default": -1,
"description": "Outer wall-clock timeout override (seconds) for the OpenCode reviewer lane. Unset is -1, a sentinel: 0 or a negative number is also treated as unset (a timeout has no legitimate zero/negative value), so no second sentinel is needed. Falls back to the lane's built-in timeoutFloorMs when unset."
},
"review.effort.opencode": {
"owner": "opencode",
"type": "string",
"default": "",
"description": "Reasoning effort for the OpenCode reviewer lane: minimal, low, medium, high, xhigh, max, or inherit. Unset falls back to the lane's declared review default (high); inherit emits no effort argument so the CLI's own configuration decides. An unrecognized value falls back to the lane default rather than being forwarded."
},
"workflow.pattern_mapper": {
"owner": "pattern-mapper",
"type": "boolean",
@@ -5638,6 +5698,8 @@ const runtimes = {
"requiresBinaries": [],
"promptBudgetKey": "review.max_prompt_tokens_per_reviewer.antigravity",
"modelConfigKey": "review.models.agy",
"effortConfigKey": null,
"defaultEffort": null,
"handler": "antigravity"
},
"config": {
@@ -5926,6 +5988,8 @@ const runtimes = {
"requiresBinaries": [],
"promptBudgetKey": "review.max_prompt_tokens_per_reviewer.claude",
"modelConfigKey": "review.models.claude",
"effortConfigKey": "review.effort.claude",
"defaultEffort": "high",
"handler": null
},
"config": {
@@ -5943,6 +6007,11 @@ const runtimes = {
"type": "number",
"default": -1,
"description": "Outer wall-clock timeout override (seconds) for the Claude reviewer lane. Unset is -1, a sentinel: 0 or a negative number is also treated as unset (a timeout has no legitimate zero/negative value), so no second sentinel is needed. Falls back to the lane's built-in timeoutFloorMs when unset."
},
"review.effort.claude": {
"type": "string",
"default": "",
"description": "Reasoning effort for the Claude reviewer lane: minimal, low, medium, high, xhigh, max, or inherit. Unset falls back to the lane's declared review default (high); inherit emits no effort argument so the CLI's own configuration decides. An unrecognized value falls back to the lane default rather than being forwarded."
}
}
},
@@ -6306,6 +6375,8 @@ const runtimes = {
"requiresBinaries": [],
"promptBudgetKey": "review.max_prompt_tokens_per_reviewer.codex",
"modelConfigKey": "review.models.codex",
"effortConfigKey": "review.effort.codex",
"defaultEffort": "high",
"handler": null
},
"config": {
@@ -6323,6 +6394,11 @@ const runtimes = {
"type": "number",
"default": -1,
"description": "Outer wall-clock timeout override (seconds) for the Codex reviewer lane. Unset is -1, a sentinel: 0 or a negative number is also treated as unset (a timeout has no legitimate zero/negative value), so no second sentinel is needed. Falls back to the lane's built-in timeoutFloorMs when unset."
},
"review.effort.codex": {
"type": "string",
"default": "",
"description": "Reasoning effort for the Codex reviewer lane: minimal, low, medium, high, xhigh, max, or inherit. Unset falls back to the lane's declared review default (high); inherit emits no effort argument so the CLI's own configuration decides. An unrecognized value falls back to the lane default rather than being forwarded."
}
}
},
@@ -6578,6 +6654,8 @@ const runtimes = {
"requiresBinaries": [],
"promptBudgetKey": "review.max_prompt_tokens_per_reviewer.cursor",
"modelConfigKey": "review.models.cursor",
"effortConfigKey": null,
"defaultEffort": null,
"handler": null
},
"config": {
@@ -7084,6 +7162,8 @@ const runtimes = {
"requiresBinaries": [],
"promptBudgetKey": "review.max_prompt_tokens_per_reviewer.kimi-code",
"modelConfigKey": "review.models.kimi-code",
"effortConfigKey": null,
"defaultEffort": null,
"handler": null
},
"config": {
@@ -7272,6 +7352,8 @@ const runtimes = {
"requiresBinaries": [],
"promptBudgetKey": "review.max_prompt_tokens_per_reviewer.opencode",
"modelConfigKey": "review.models.opencode",
"effortConfigKey": "review.effort.opencode",
"defaultEffort": "high",
"handler": "opencode"
},
"config": {
@@ -7289,6 +7371,11 @@ const runtimes = {
"type": "number",
"default": -1,
"description": "Outer wall-clock timeout override (seconds) for the OpenCode reviewer lane. Unset is -1, a sentinel: 0 or a negative number is also treated as unset (a timeout has no legitimate zero/negative value), so no second sentinel is needed. Falls back to the lane's built-in timeoutFloorMs when unset."
},
"review.effort.opencode": {
"type": "string",
"default": "",
"description": "Reasoning effort for the OpenCode reviewer lane: minimal, low, medium, high, xhigh, max, or inherit. Unset falls back to the lane's declared review default (high); inherit emits no effort argument so the CLI's own configuration decides. An unrecognized value falls back to the lane default rather than being forwarded."
}
}
},
@@ -7500,6 +7587,8 @@ const runtimes = {
"requiresBinaries": [],
"promptBudgetKey": "review.max_prompt_tokens_per_reviewer.qwen",
"modelConfigKey": null,
"effortConfigKey": null,
"defaultEffort": null,
"handler": null
},
"config": {

View File

@@ -1849,9 +1849,26 @@ const KNOWN_REVIEWER_FIELDS = new Set([
// `timeoutConfigKey` added by #3274, same optional/backward-compatible shape as
// `modelConfigKey` above: a manifest authored before this field existed must keep validating.
'timeoutConfigKey',
// `effortConfigKey` / `defaultEffort` added by #4255, same optional/backward-compatible shape
// as the two above. Lane effort used to be resolved by querying the `gsd-plan-checker` AGENT
// through a hardcoded id, so every prompt-fed lane ran at that verifier's frontmatter effort;
// it is a property of the review, so the lane declares it. A manifest authored before these
// fields existed must keep validating — absent is read as "this lane declares no review
// effort", which emits no effort argument at all.
'effortConfigKey',
'defaultEffort',
'handler',
]);
/**
* The effort levels a lane may declare as its review default (#4255, #3533 vocabulary).
*
* `inherit` is deliberately NOT a member. It is a legitimate CONFIGURED value — it selects "emit
* no argument, the CLI's own configuration decides" — but as a DECLARED default it would be a
* second spelling of `null` and split one behaviour across two shapes.
*/
const REVIEWER_EFFORT_LEVELS = new Set(['minimal', 'low', 'medium', 'high', 'xhigh', 'max']);
/**
* The closed `runtime.hostBehaviors` vocabulary (ADR-1016, closed via #2801).
*
@@ -2407,6 +2424,39 @@ function validateReviewerBodyFields(cap) {
);
}
// OPTIONAL, mirroring the two keys above (#4255). Absent/null means "this lane declares no
// review effort", which is the shape that emits no effort argument at all — the correct state
// for the nine lanes with no effort channel to feed. An empty string is neither.
if (r.effortConfigKey !== undefined && r.effortConfigKey !== null &&
(typeof r.effortConfigKey !== 'string' || r.effortConfigKey.length === 0)) {
errors.push(
ctx + ' reviewer.effortConfigKey must be a dotted config key or null ' +
'(got: ' + describeValue(r.effortConfigKey) + ')',
);
}
// A declared default must be a level the effort axis knows, or the lane would render an
// argument the reviewer CLI rejects and kill itself on every run. Closed vocabulary, checked
// here rather than at resolution time so a malformed manifest fails at the trust boundary.
if (r.defaultEffort !== undefined && r.defaultEffort !== null
&& !(typeof r.defaultEffort === 'string' && REVIEWER_EFFORT_LEVELS.has(r.defaultEffort))) {
errors.push(
ctx + ' reviewer.defaultEffort must be one of ' +
Array.from(REVIEWER_EFFORT_LEVELS).join('/') + ' or null ' +
'(got: ' + describeValue(r.defaultEffort) + ')',
);
}
// A default with nothing to configure it through is a value the operator cannot change — the
// shape #4255 exists to end. Declared together or not at all.
if ((r.defaultEffort !== undefined && r.defaultEffort !== null)
&& (r.effortConfigKey === undefined || r.effortConfigKey === null)) {
errors.push(
ctx + ' reviewer.defaultEffort is declared without an effortConfigKey, so the level ' +
'could never be overridden',
);
}
// `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(

View File

@@ -354,6 +354,20 @@ declared in the manifest — timeout floor, probe, prompt/output channel, empty-
behaviour that data genuinely cannot express is a named first-party `handler` (ADR-2782 D6), never
a bespoke block here.
**Effort and model resolution (#4255).** A lane's reasoning effort and model each resolve through
their own declared key, and the resolution order is inspectable rather than implicit:
| piece | order, highest first |
|---|---|
| model | pinned reviewer-instance `--model` → the lane's `modelConfigKey` (`review.models.<slug>`) → the CLI's own default |
| effort | the lane's `effortConfigKey` (`review.effort.<slug>`) → the lane's declared `defaultEffort` → **nothing emitted**, so the CLI's own configuration applies |
Both come from the LANE. Effort in particular is never read from an agent's execution settings:
until #4255 it was resolved by querying `gsd-plan-checker`, so every prompt-fed lane ran at that
verifier's `low` and, because the rendered argument is a CLI config override, it silently beat the
effort the operator had configured for the reviewer CLI itself. A lane that declares no effort
emits no argument at all — a value borrowed from an unrelated agent is worse than no value.
**Timeout guidance (#2194):** prompt-fed source-grounded reviews are slow — measured ~570 s for
Codex at `xhigh` effort and ~525 s for headless Claude on a large plan set. Each lane declares its
own `timeoutFloorMs` and the runner enforces it internally, but the **Bash tool call wrapping the