`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>
666 lines
29 KiB
TypeScript
666 lines
29 KiB
TypeScript
/**
|
|
* 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,
|
|
EvidenceClass,
|
|
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[];
|
|
/**
|
|
* The configured model that was ACTUALLY APPLIED to this invocation, or `null` (#2295).
|
|
*
|
|
* Not merely "what `review.models.<slug>` says". A lane can declare a `modelConfigKey` and no
|
|
* `modelArg` — a shape a third-party overlay body can reach — and then the configured value
|
|
* never enters argv and the CLI reviews under its own default. Recording the config value in
|
|
* that case would attribute the review to a model that never ran, which is the inverse of the
|
|
* failure #2295 exists to end. So this mirrors the argv expansion: set only when `{{model}}`
|
|
* really expanded to something.
|
|
*/
|
|
model: string | null;
|
|
/**
|
|
* The reasoning effort GSD ACTUALLY APPLIED to this invocation, or `null` (#2295).
|
|
*
|
|
* Shares the same applied-not-merely-configured rule `model` above documents. A lane whose
|
|
* `effortChannel` is not `argv` receives no effort argument at all — the placeholder's
|
|
* expansion is structurally empty for that lane — and recording an effort level in that case
|
|
* would attribute the review to a setting that never reached the tool. So this is set only
|
|
* when the effort argv really expanded into this invocation's argv.
|
|
*/
|
|
effort: string | null;
|
|
/** 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;
|
|
/**
|
|
* The lane's declared evidence class, carried onto the plan so the runner can VERIFY the
|
|
* declaration against the review's actual output (#3194): a `source-grounded` lane whose
|
|
* review cites no `file:line` evidence is stamped and down-weighted in the Consensus
|
|
* Summary, while `diff-only` lanes are exempt (their verdict is already folded in as a
|
|
* diff observation).
|
|
*
|
|
* NORMALIZED, not trusted: this module is the overlay-manifest trust boundary and a
|
|
* third-party body can declare any value. Anything that is not exactly `'diff-only'`
|
|
* resolves as `'source-grounded'` — the fail-toward-verification direction, since the
|
|
* only behavioral consequence is whether the lane's OWN review gets down-weighted.
|
|
*/
|
|
evidenceClass: EvidenceClass;
|
|
handler: LaneHandler;
|
|
requiresBinaries: readonly string[];
|
|
probe: LaneProbe;
|
|
/**
|
|
* Per-invocation environment pairs merged over the inherited environment at spawn, or `null`
|
|
* when the lane declares none (#2483). Only string-valued own entries survive resolution — a
|
|
* non-string value is dropped, not coerced, for the same reason model values are not (below).
|
|
*/
|
|
env: Readonly<Record<string, string>> | null;
|
|
}
|
|
|
|
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;
|
|
/** Declared evidence class, carried for run-time verification — see `SpawnPlan`. */
|
|
evidenceClass: EvidenceClass;
|
|
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[];
|
|
/**
|
|
* The bare reasoning-effort level (`'low'`) GSD resolved for this lane's host, or `undefined`
|
|
* (#2295). The per-host ARGV RENDERING of this same level arrives separately in `effortArgs` —
|
|
* `'low'` renders as `--effort low` for one host and `-c model_reasoning_effort=low` for
|
|
* another, and the runner needs the bare level (for the recorded model suffix) independently
|
|
* of whichever rendering actually reached argv.
|
|
*/
|
|
effortValue?: 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.
|
|
*
|
|
* Exported and shared with the runner's model-recovery arms (#2295) — "what counts as unset" has
|
|
* ONE source, so the plan resolver and the runner's recovered-model normalization cannot disagree.
|
|
*/
|
|
export 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}`;
|
|
}
|
|
|
|
/**
|
|
* Resolve a lane's outer wall-clock timeout in milliseconds (#3274).
|
|
*
|
|
* `timeoutConfigKey` resolves in SECONDS — the user-facing convention this repo already uses for
|
|
* timeout-shaped config keys (`workflow.cross_ai_timeout`, `graphify.build_timeout`), distinct from
|
|
* the internal millisecond unit `timeoutFloorMs` carries. Anything that is not a positive finite
|
|
* number is treated as unset and falls back to `floorMs`, never coerced: a wrong-typed config value
|
|
* silently becoming a wrong-but-plausible timeout is worse than falling back cleanly. `0` and
|
|
* negative values are deliberately treated as unset too — a timeout has no legitimate zero or
|
|
* negative value, so no second sentinel (unlike the prompt-budget keys, which use -1) is needed.
|
|
*/
|
|
/**
|
|
* The reasoning effort a reviewer lane runs at, and its host-rendered argv (#4255).
|
|
*
|
|
* `argv` is spliced into `{{effort}}`; `value` is the bare level the runner folds into the
|
|
* recorded model designation (`gpt-5.6-sol (reasoning=high)`, #2295). Both are empty/null when
|
|
* this lane emits no effort argument, which is a real and correct outcome — see `resolveLaneEffort`.
|
|
*/
|
|
export interface LaneEffort {
|
|
argv: readonly string[];
|
|
value: string | null;
|
|
/** Where `value` came from, for diagnostics: the config key, the lane default, or nothing. */
|
|
source: 'config' | 'lane-default' | 'none';
|
|
}
|
|
|
|
/** Levels GSD's effort axis accepts (#3533). `inherit` selects the no-argument path. */
|
|
const EFFORT_LEVELS: ReadonlySet<string> = new Set([
|
|
'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'inherit',
|
|
]);
|
|
|
|
/**
|
|
* Resolve one lane's reasoning effort from REVIEW configuration (#4255).
|
|
*
|
|
* Resolution order, highest first:
|
|
* 1. `lane.effortConfigKey` — the per-lane review effort the operator set
|
|
* 2. `lane.defaultEffort` — the lane's declared review default (`high` for prompt-fed,
|
|
* source-grounded lanes)
|
|
* 3. nothing — no effort argument is emitted and the reviewer CLI's own configuration decides
|
|
*
|
|
* A configured `'inherit'` selects (3) explicitly. An unrecognized level is REFUSED rather than
|
|
* passed to the host: it falls back to the lane default, because forwarding a typo would render an
|
|
* argument the CLI rejects and kill the lane outright.
|
|
*
|
|
* What this function deliberately does NOT do is consult any agent's execution settings. Before
|
|
* #4255 the level came from `gsd-plan-checker`'s installed frontmatter through a hardcoded agent
|
|
* id, so every lane ran at a fast structural verifier's `low` — and, because the rendered argument
|
|
* is a CLI config override, it silently beat the effort the operator had configured for that CLI
|
|
* itself. A value inherited from an unrelated agent is worse than no value at all, which is why
|
|
* (3) emits nothing rather than falling back to some other agent's number.
|
|
*
|
|
* `renderArgv` is injected (the host table and the ADR-2481 surface negotiation live in
|
|
* `model-catalog` / `commands`, above this module's layer) so this stays a pure function of its
|
|
* inputs and the golden lane table can assert it without a spawn.
|
|
*/
|
|
export function resolveLaneEffort(
|
|
lane: ReviewerLane,
|
|
configGet: (key: string) => unknown,
|
|
renderArgv: (host: string, level: string) => { argv: readonly string[]; value: string | null },
|
|
): LaneEffort {
|
|
const none: LaneEffort = { argv: [], value: null, source: 'none' };
|
|
if (!lane || typeof lane !== 'object') return none;
|
|
const configured = lane.effortConfigKey ? configString(configGet(lane.effortConfigKey)) : null;
|
|
const valid = configured !== null && EFFORT_LEVELS.has(configured) ? configured : null;
|
|
const level = valid ?? configString(lane.defaultEffort);
|
|
if (level === null || level === 'inherit') return none;
|
|
const rendered = renderArgv(lane.slug, level);
|
|
const argv = (rendered.argv ?? []).filter((a): a is string => typeof a === 'string' && a !== '');
|
|
if (argv.length === 0) return none;
|
|
return {
|
|
argv,
|
|
value: configString(rendered.value) ?? level,
|
|
source: valid !== null ? 'config' : 'lane-default',
|
|
};
|
|
}
|
|
|
|
export function resolveTimeoutMs(
|
|
timeoutConfigKey: string | null | undefined,
|
|
floorMs: number,
|
|
configGet: (key: string) => unknown,
|
|
): number {
|
|
const configuredSeconds = typeof timeoutConfigKey === 'string' ? configGet(timeoutConfigKey) : undefined;
|
|
return typeof configuredSeconds === 'number' && Number.isFinite(configuredSeconds) && configuredSeconds > 0
|
|
? configuredSeconds * 1000
|
|
: floorMs;
|
|
}
|
|
|
|
/** Buffer (seconds) a lane's native inner timeout sits under its resolved outer wall-clock cap
|
|
* (#3274). Matches the shipped 600s outer / 540s native relationship exactly when unconfigured:
|
|
* floor(600000/1000) - 60 = 540. */
|
|
const NATIVE_TIMEOUT_BUFFER_SECONDS = 60;
|
|
|
|
/**
|
|
* Render the `{{nativeTimeout}}` argv placeholder from a lane's resolved outer timeout (#3274).
|
|
*
|
|
* Clamped to a 1-second floor so a very small configured (or, today, only-ever-default) outer
|
|
* timeout never produces a zero or negative duration string a CLI would reject or misinterpret.
|
|
*/
|
|
export function nativeTimeoutToken(timeoutMs: number): string {
|
|
const seconds = Math.max(1, Math.floor(timeoutMs / 1000) - NATIVE_TIMEOUT_BUFFER_SECONDS);
|
|
return `${seconds}s`;
|
|
}
|
|
|
|
/**
|
|
* 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 floorMs =
|
|
typeof lane.timeoutFloorMs === 'number' && Number.isFinite(lane.timeoutFloorMs) && lane.timeoutFloorMs > 0
|
|
? lane.timeoutFloorMs
|
|
: 900_000;
|
|
const timeoutMs = resolveTimeoutMs(lane.timeoutConfigKey, floorMs, input.configGet);
|
|
const emptyOutput: EmptyOutputPolicy = lane.emptyOutput === 'handler-owned' ? 'handler-owned' : 'stub-with-stderr';
|
|
// #3194: only an EXACT 'diff-only' declaration exempts a lane from evidence verification.
|
|
// Anything else — including a missing or garbage value on a third-party overlay body —
|
|
// resolves as 'source-grounded', so the runner verifies rather than trusts it.
|
|
const evidenceClass: EvidenceClass = lane.evidenceClass === 'diff-only' ? 'diff-only' : 'source-grounded';
|
|
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,
|
|
evidenceClass,
|
|
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,
|
|
'{{nativeTimeout}}': [nativeTimeoutToken(timeoutMs)],
|
|
};
|
|
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);
|
|
}
|
|
}
|
|
|
|
// Per-invocation env pairs (#2483). Own string-valued entries only — a non-string is dropped,
|
|
// never coerced, and prototype members never resolve (same lookup discipline as the argv
|
|
// expansions above). An empty or absent declaration resolves to `null`, so the runner has one
|
|
// shape to test.
|
|
let env: Record<string, string> | null = null;
|
|
const declaredEnv: unknown = inv.env;
|
|
if (declaredEnv !== null && typeof declaredEnv === 'object' && !Array.isArray(declaredEnv)) {
|
|
const source = declaredEnv as Record<string, unknown>;
|
|
const pairs: Record<string, string> = {};
|
|
for (const k of Object.keys(source)) {
|
|
const v = source[k];
|
|
if (typeof v === 'string') pairs[k] = v;
|
|
}
|
|
if (Object.keys(pairs).length > 0) env = pairs;
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
warnings,
|
|
plan: {
|
|
transport: 'spawn',
|
|
slug,
|
|
binary,
|
|
argv,
|
|
model: modelExpansion.length > 0 ? model : null,
|
|
effort: effortExpansion.length > 0 ? (configString(input.effortValue) ?? null) : null,
|
|
stdin,
|
|
promptPath,
|
|
outputTarget,
|
|
reviewPath,
|
|
errPath,
|
|
timeoutMs,
|
|
emptyOutput,
|
|
evidenceClass,
|
|
handler,
|
|
requiresBinaries,
|
|
probe: lane.probe,
|
|
env,
|
|
},
|
|
};
|
|
}
|