import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
export type SubagentRunnerOutcome =
| 'clean'
| 'patched'
| 'deferred'
| 'skipped';
export const SUBAGENT_LEDGER_SCHEMA_VERSION = 1;
export type SubagentRunnerKind =
| 'claude-cli'
| 'codex-cli'
| 'cursor-cli'
| 'skipped'
| 'operator-recorder';
/** Headless runners invoked by `subagent-review` (excludes recorder-only kinds). */
export const PROGRAMMATIC_SUBAGENT_RUNNERS = [
'claude-cli',
'codex-cli',
'cursor-cli',
] as const;
export type ProgrammaticSubagentRunner =
(typeof PROGRAMMATIC_SUBAGENT_RUNNERS)[number];
export type SubagentRunnerTerminatedReason =
| 'completed'
| 'rate_limit'
| 'sandbox_denied'
| 'runner_unavailable'
| 'runner_failed'
| 'advisory_violation';
export type SubagentRunnerFallbackLevel =
| 'preferred'
| 'fallback'
| 'failed_all'
| 'not_applicable';
export type SubagentRunnerInvocation = {
runnerKind: SubagentRunnerKind;
reviewedHeadSha: string;
outcome: SubagentRunnerOutcome;
completedAt: string;
terminatedReason: SubagentRunnerTerminatedReason;
/**
* Repo-relative path to `reviews/<ticket>-subagent-review.report.md` (runner
* prose). Legacy artifacts may still store inline stdout/stderr text.
*/
rawOutput?: string;
/**
* Repo-relative path to `reviews/<ticket>-subagent-review.prompt.md`.
* Legacy artifacts may still store inline prompt text.
*/
filledPrompt?: string;
fallbackLevel?: SubagentRunnerFallbackLevel;
/**
* Ledger schema version for this row. Pre-Phase-14 rows omit this field;
* the validator preserves that absence rather than back-filling it.
*/
schemaVersion?: number;
/**
* Free-form identity of the primary agent that drove this ticket
* (e.g. `"claude-code"`, `"codex-cli"`). Defaults to `"unknown"` when a
* row is parsed without it.
*/
primaryAgent?: string;
/**
* Self-reported `runnerStatus` value emitted by the model in its prose,
* when parseable. `null` when the runner did not surface one.
*/
runnerSelfReport?: string | null;
/**
* Originally-requested subagent kind when a fallback fired. `null` when no
* fallback was needed.
*/
fallbackFrom?: SubagentRunnerKind | null;
findings: string[];
probedSurfaces: string[];
patches: string[];
};
export type SubagentRunnerArtifact = {
ticket: string;
invocations: SubagentRunnerInvocation[];
};
export type SpawnResult = {
exitCode: number | null;
timedOut: boolean;
stdout?: string;
stderr?: string;
rawOutput?: string;
/**
* Optional honest termination reason flagged by the spawn closure (e.g. a
* rate-limit signature detected in stdout despite exit code 0). When omitted,
* tryRunner defaults to `'completed'`.
*/
terminatedReason?: SubagentRunnerTerminatedReason;
};
export type RunnerAttemptResult =
| {
status: 'ran';
outcome: 'clean' | 'patched';
terminatedReason: SubagentRunnerTerminatedReason;
rawOutput?: string;
stdout?: string;
stderr?: string;
}
| { status: 'unavailable' }
| { status: 'timeout' };
export function buildRunnerSpawnCommand(
runner: ProgrammaticSubagentRunner,
reviewPrompt: string,
options: {
outputLastMessagePath?: string;
/** Ticket worktree path; required for cursor-cli headless runs. */
workspacePath?: string;
} = {},
): { bin: string; args: string[] } {
if (runner === 'claude-cli') {
return { bin: 'claude', args: ['-p', reviewPrompt] };
}
if (runner === 'cursor-cli') {
return {
bin: 'agent',
args: [
'--print',
'--trust',
'--output-format',
'text',
...(options.workspacePath
? ['--workspace', options.workspacePath]
: []),
reviewPrompt,
],
};
}
return {
bin: 'codex',
args: [
'exec',
...(options.outputLastMessagePath
? ['--output-last-message', options.outputLastMessagePath]
: []),
'--color',
'never',
reviewPrompt,
],
};
}
export function buildSubagentFallbackOrder(
requested: ProgrammaticSubagentRunner,
): ProgrammaticSubagentRunner[] {
return [
requested,
...PROGRAMMATIC_SUBAGENT_RUNNERS.filter((kind) => kind !== requested),
];
}
/**
* Operator-explicit subagent selection. Flag > config > hard error.
* P14.02: SoA ships no silent default; missing both surfaces the contract
* up-front rather than letting a runner be picked silently.
*/
export function resolveSubagentSelection(input: {
flag: ProgrammaticSubagentRunner | undefined;
configField: ProgrammaticSubagentRunner | undefined;
}): { kind: ProgrammaticSubagentRunner; source: 'flag' | 'config' } {
if (input.flag) {
return { kind: input.flag, source: 'flag' };
}
if (input.configField) {
return { kind: input.configField, source: 'config' };
}
throw new Error(
`No subagent selected. Pass --subagent <${PROGRAMMATIC_SUBAGENT_RUNNERS.join('|')}> or set \`subagentRunner\` in orchestrator.config.json. See docs/template/delivery/delivery-orchestrator.md for cross-family best-practice guidance.`,
);
}
/**
* Free-form primary-agent identity. Flag > config > "unknown".
* P14.02: values like `cursor`, `composer`, `copilot`, `aider` pass through
* without enum validation so the field captures whichever execution agent
* actually drove the ticket.
*/
export function resolvePrimaryAgent(input: {
flag: string | undefined;
configField: string | undefined;
}): string {
if (input.flag !== undefined && input.flag.trim() !== '') {
return input.flag.trim();
}
if (input.configField !== undefined && input.configField.trim() !== '') {
return input.configField.trim();
}
return 'unknown';
}
/**
* P14.02 — codex-cli classification fidelity.
*
* Trusts the model's self-reported `runnerStatus: <value>` trailer when
* present. Only escalates to skipped/rate_limit on the runner's authentic
* structured signal — not on stderr text that resembles rate-limit prose.
* Prevents the "stderr noise → silent skipped" misclassification documented
* in the codogotchi P2 subagent-review audit.
*/
export function coerceCodexCliClassification(input: {
exitCode: number | null;
stdout: string;
stderr: string;
}): {
outcome: 'clean' | 'skipped';
terminatedReason: SubagentRunnerTerminatedReason;
runnerSelfReport: string | null;
} {
const runnerSelfReport = parseRunnerStatusTrailer(input.stdout);
if (isCodexCliAuthenticRateLimit(input)) {
return {
outcome: 'skipped',
terminatedReason: 'rate_limit',
runnerSelfReport,
};
}
if (runnerSelfReport === 'completed') {
return {
outcome: 'clean',
terminatedReason: 'completed',
runnerSelfReport,
};
}
if (input.exitCode !== 0) {
return {
outcome: 'skipped',
terminatedReason: 'runner_failed',
runnerSelfReport,
};
}
if (`${input.stdout}${input.stderr}`.trim() === '') {
return {
outcome: 'skipped',
terminatedReason: 'runner_failed',
runnerSelfReport,
};
}
return {
outcome: 'clean',
terminatedReason: 'completed',
runnerSelfReport,
};
}
function parseRunnerStatusTrailer(stdout: string): string | null {
const lines = stdout
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0);
const tail = lines.slice(-50);
for (let i = tail.length - 1; i >= 0; i -= 1) {
const match = /^runnerStatus\s*:\s*(.+)$/i.exec(tail[i]!);
if (match) {
return match[1]!.trim();
}
}
return null;
}
// Authentic rate-limit signal for codex-cli — derived from structured tokens
// (exit code and quoted JSON-shaped values), not from free-text matching.
function isCodexCliAuthenticRateLimit(input: {
exitCode: number | null;
stdout: string;
stderr: string;
}): boolean {
if (input.exitCode === 7) return true;
return hasStructuredRateLimitToken(`${input.stdout}\n${input.stderr}`, [
'error',
'status',
'code',
'type',
]);
}
// Authentic rate-limit signal for claude-cli — Anthropic API structured error
// shape. Prose like "you've hit your limit" alone is NOT authentic; only the
// quoted `{"type":"rate_limit_error"}` (or `"overloaded_error"`) token counts.
function isClaudeCliAuthenticRateLimit(input: {
exitCode: number | null;
stdout: string;
stderr: string;
}): boolean {
return hasStructuredRateLimitToken(`${input.stdout}\n${input.stderr}`, [
'type',
]);
}
function hasStructuredRateLimitToken(blob: string, keys: string[]): boolean {
const allowedKeys = new Set(keys);
return blob
.split('\n')
.map((line) => line.trim())
.filter((line) => line.startsWith('{') && line.endsWith('}'))
.some((line) => {
try {
return jsonContainsRateLimitToken(JSON.parse(line), allowedKeys);
} catch {
return false;
}
});
}
function jsonContainsRateLimitToken(
value: unknown,
allowedKeys: Set<string>,
): boolean {
if (Array.isArray(value)) {
return value.some((item) => jsonContainsRateLimitToken(item, allowedKeys));
}
if (!value || typeof value !== 'object') {
return false;
}
for (const [key, child] of Object.entries(value)) {
if (
allowedKeys.has(key) &&
typeof child === 'string' &&
/^(?:rate_limited|rate_limit_exceeded|RATE_LIMIT(?:_EXCEEDED)?|rate_limit_error|overloaded_error)$/.test(
child,
)
) {
return true;
}
if (jsonContainsRateLimitToken(child, allowedKeys)) {
return true;
}
}
return false;
}
// Authentic usage/rate-limit signal for cursor-cli `agent --print` — billing
// failures surface as short stdout prose (e.g. "out of usage"), not JSON trailers.
function isCursorCliAuthenticRateLimit(input: {
exitCode: number | null;
stdout: string;
stderr: string;
}): boolean {
if (
hasStructuredRateLimitToken(`${input.stdout}\n${input.stderr}`, ['type'])
) {
return true;
}
if (input.exitCode === 0) {
return false;
}
const blob = `${input.stdout}\n${input.stderr}`;
return (
/\byou(?:'re| are) out of usage\b/i.test(blob) ||
/\bout of usage\b/i.test(blob) ||
/\brun out of usage credits\b/i.test(blob)
);
}
/**
* cursor-cli classification fidelity, symmetric to claude/codex-cli.
*/
export function coerceCursorCliClassification(input: {
exitCode: number | null;
stdout: string;
stderr: string;
}): {
outcome: 'clean' | 'skipped';
terminatedReason: SubagentRunnerTerminatedReason;
runnerSelfReport: string | null;
} {
const runnerSelfReport = parseRunnerStatusTrailer(input.stdout);
if (isCursorCliAuthenticRateLimit(input)) {
return {
outcome: 'skipped',
terminatedReason: 'rate_limit',
runnerSelfReport,
};
}
if (runnerSelfReport === 'completed') {
return {
outcome: 'clean',
terminatedReason: 'completed',
runnerSelfReport,
};
}
if (input.exitCode !== 0) {
return {
outcome: 'skipped',
terminatedReason: 'runner_failed',
runnerSelfReport,
};
}
if (`${input.stdout}${input.stderr}`.trim() === '') {
return {
outcome: 'skipped',
terminatedReason: 'runner_failed',
runnerSelfReport,
};
}
return {
outcome: 'clean',
terminatedReason: 'completed',
runnerSelfReport,
};
}
/**
* P14.02 — claude-cli classification fidelity, symmetric to codex-cli.
*
* Trusts the model's self-reported `runnerStatus: <value>` trailer when
* present. Only escalates to skipped/rate_limit on the runner's authentic
* structured signal (Anthropic API `"type":"rate_limit_error"`), not on
* stderr text that resembles rate-limit prose. Prevents prose like
* "you have hit your rate limit" from producing a silent `skipped`.
*/
export function coerceClaudeCliClassification(input: {
exitCode: number | null;
stdout: string;
stderr: string;
}): {
outcome: 'clean' | 'skipped';
terminatedReason: SubagentRunnerTerminatedReason;
runnerSelfReport: string | null;
} {
const runnerSelfReport = parseRunnerStatusTrailer(input.stdout);
if (isClaudeCliAuthenticRateLimit(input)) {
return {
outcome: 'skipped',
terminatedReason: 'rate_limit',
runnerSelfReport,
};
}
if (runnerSelfReport === 'completed') {
return {
outcome: 'clean',
terminatedReason: 'completed',
runnerSelfReport,
};
}
if (input.exitCode !== 0) {
return {
outcome: 'skipped',
terminatedReason: 'runner_failed',
runnerSelfReport,
};
}
if (`${input.stdout}${input.stderr}`.trim() === '') {
return {
outcome: 'skipped',
terminatedReason: 'runner_failed',
runnerSelfReport,
};
}
return {
outcome: 'clean',
terminatedReason: 'completed',
runnerSelfReport,
};
}
/**
* P14.02 — Runner availability fallback.
*
* Attempts the operator-selected runner first. On `unavailable`/`timeout`,
* falls back to the other configured runner. The return value records what
* actually ran (`ranKind`), what was originally requested when fallback
* fired (`fallbackFrom`), and the bucket (`preferred|fallback|failed_all`).
* When both runners are unavailable, `fallbackFrom` preserves the originally
* requested kind so the skipped row remains auditable.
*/
export function runSubagentWithFallback(
requested: ProgrammaticSubagentRunner,
attempt: (kind: ProgrammaticSubagentRunner) => RunnerAttemptResult,
): {
ranKind: ProgrammaticSubagentRunner | 'skipped';
fallbackFrom: ProgrammaticSubagentRunner | null;
fallbackLevel: 'preferred' | 'fallback' | 'failed_all';
result: RunnerAttemptResult;
attemptedKinds: ProgrammaticSubagentRunner[];
} {
const order = buildSubagentFallbackOrder(requested);
const attemptedKinds: ProgrammaticSubagentRunner[] = [];
let lastResult: RunnerAttemptResult = { status: 'unavailable' };
for (const [index, kind] of order.entries()) {
attemptedKinds.push(kind);
const result = attempt(kind);
lastResult = result;
if (result.status === 'ran') {
return {
ranKind: kind,
fallbackFrom: index === 0 ? null : requested,
fallbackLevel: index === 0 ? 'preferred' : 'fallback',
result,
attemptedKinds,
};
}
if (!shouldFallbackToOtherRunner(result)) {
break;
}
}
return {
ranKind: 'skipped',
fallbackFrom: requested,
fallbackLevel: 'failed_all',
result: lastResult,
attemptedKinds,
};
}
export const SUBAGENT_REVIEW_OUTCOME_SUFFIX = '-subagent-review.report.md';
export const SUBAGENT_REVIEW_TRACE_SUFFIX = '-subagent-review.trace.log';
export function deriveSubagentReviewOutcomePath(
reviewsDirPath: string,
ticketId: string,
): string {
return `${reviewsDirPath}/${ticketId}${SUBAGENT_REVIEW_OUTCOME_SUFFIX}`;
}
export function deriveSubagentReviewTracePath(
reviewsDirPath: string,
ticketId: string,
): string {
return `${reviewsDirPath}/${ticketId}${SUBAGENT_REVIEW_TRACE_SUFFIX}`;
}
export function formatRawRunnerOutput(stdout = '', stderr = ''): string {
const sections: string[] = [];
if (stdout.trim() !== '') sections.push(`stdout:\n${stdout.trimEnd()}`);
if (stderr.trim() !== '') sections.push(`stderr:\n${stderr.trimEnd()}`);
return sections.join('\n\n');
}
export type SubagentReviewOutcomeWriteResult = {
absolutePath: string;
relativePath: string;
traceAbsolutePath: string;
traceRelativePath: string;
};
/**
* Persist the runner's model report and local stderr trace sidecar.
* The runner artifact stores `relativePath` in `rawOutput`, not the prose body.
*/
export function writeSubagentReviewOutcome(input: {
repoRoot: string;
reviewsDirPath: string;
ticketId: string;
stdout?: string;
stderr?: string;
/** When set, written as-is as the report body. */
content?: string;
}): SubagentReviewOutcomeWriteResult {
const body = input.content ?? input.stdout ?? '';
const relativePath = deriveSubagentReviewOutcomePath(
input.reviewsDirPath,
input.ticketId,
);
const traceRelativePath = deriveSubagentReviewTracePath(
input.reviewsDirPath,
input.ticketId,
);
const absolutePath = join(input.repoRoot, relativePath);
const traceAbsolutePath = join(input.repoRoot, traceRelativePath);
mkdirSync(dirname(absolutePath), { recursive: true });
writeFileSync(
absolutePath,
body.endsWith('\n') ? body : `${body}\n`,
'utf-8',
);
writeFileSync(traceAbsolutePath, input.stderr ?? '', 'utf-8');
return { absolutePath, relativePath, traceAbsolutePath, traceRelativePath };
}
export function isSubagentReviewOutcomePath(value: string): boolean {
return value.endsWith(SUBAGENT_REVIEW_OUTCOME_SUFFIX);
}
export function isSubagentAdversarialPromptReference(value: string): boolean {
return value.endsWith('-subagent-review.prompt.md');
}
export function classifyRunnerTermination(
exitCode: number | null,
stdout = '',
stderr = '',
): SubagentRunnerTerminatedReason {
const blob = `${stdout}\n${stderr}`.toLowerCase();
if (
/\byou['’]?ve hit your limit\b|\brate[\s_-]?limited\b|\brate[\s_-]?limit\s+(?:exceeded|reached|hit)\b|\b429\s+(too\s+many|rate)|\bquota\s+exceeded\b/.test(
blob,
)
) {
return 'rate_limit';
}
if (/\bsandbox[\s_-]?(denied|blocked|violation)\b/.test(blob)) {
return 'sandbox_denied';
}
if (exitCode !== 0) return 'runner_failed';
if (`${stdout}${stderr}`.trim() === '') return 'runner_failed';
return 'completed';
}
export function isDeliveryDocPath(filePath: string): boolean {
const normalized = filePath.replace(/\\/g, '/').replace(/^\.\//, '');
return (
normalized === 'docs/product/delivery' ||
normalized.startsWith('docs/product/delivery/')
);
}
export function findDeliveryDocPaths(paths: string[]): string[] {
return paths.filter(isDeliveryDocPath);
}
export function tryRunner(
spawnProcess: () => SpawnResult,
checkHasChanges: () => boolean,
): RunnerAttemptResult {
let result: SpawnResult;
try {
// Synchronous spawn — checkHasChanges below is guaranteed post-exit so the
// porcelain sample never races a runner that finishes its writes at exit.
result = spawnProcess();
} catch {
return { status: 'unavailable' };
}
if (result.timedOut) return { status: 'timeout' };
const hasOutputFields =
result.stdout !== undefined ||
result.stderr !== undefined ||
result.rawOutput !== undefined;
const terminatedReason =
result.terminatedReason ??
(hasOutputFields
? classifyRunnerTermination(result.exitCode, result.stdout, result.stderr)
: 'completed');
const hasChanges = checkHasChanges();
return {
status: 'ran',
outcome: hasChanges ? 'patched' : 'clean',
terminatedReason,
stdout: result.stdout,
stderr: result.stderr,
rawOutput:
result.rawOutput ??
(hasOutputFields
? formatRawRunnerOutput(result.stdout, result.stderr)
: undefined),
};
}
/**
* The narrow set of runner-attempt outcomes that justify falling back to the
* other runner. Binary-availability failures and timeouts only — never
* ambiguous output (rate_limit, sandbox_denied, exit-code-0-with-no-work),
* which should surface honestly through terminatedReason instead.
*/
export function shouldFallbackToOtherRunner(
result: RunnerAttemptResult,
): boolean {
return result.status === 'unavailable' || result.status === 'timeout';
}
/**
* @deprecated Legacy outcome resolver from before the advisory-only contract.
* Retained only for test compatibility; runner invocation now uses
* {@link decideAdvisoryRunnerOutcome} which enforces no-write semantics.
*/
export function decideSubagentOutcomeFromRunner(
result: Extract<RunnerAttemptResult, { status: 'ran' }>,
): {
outcome: SubagentRunnerOutcome;
terminatedReason: SubagentRunnerTerminatedReason;
} {
if (result.terminatedReason !== 'completed' && result.outcome === 'clean') {
return { outcome: 'skipped', terminatedReason: result.terminatedReason };
}
return { outcome: result.outcome, terminatedReason: result.terminatedReason };
}
/**
* Advisory-only contract for programmatic subagent runners.
*
* The runner is allowed to read and reason about the diff, but any file write
* it performs (tracked or untracked, committed or working-tree) is a contract
* violation, not a valid review outcome. This is the core P13.03 inversion:
* `patched` is reserved for primary-agent recorder mode. The runner reports
* findings; the primary agent applies any patches and records them separately.
*
* - runner wrote files → `{ outcome: 'skipped', terminatedReason: 'advisory_violation' }`
* - runner completed cleanly and made no writes → preserve `clean`
* - runner reported a non-completed termination reason (rate_limit, etc.) →
* collapse to `skipped` with the original terminatedReason preserved
*/
export function decideAdvisoryRunnerOutcome(
result: Extract<RunnerAttemptResult, { status: 'ran' }>,
info: { runnerWroteFiles: boolean },
): {
outcome: SubagentRunnerOutcome;
terminatedReason: SubagentRunnerTerminatedReason;
} {
if (info.runnerWroteFiles) {
return { outcome: 'skipped', terminatedReason: 'advisory_violation' };
}
if (result.terminatedReason !== 'completed') {
return { outcome: 'skipped', terminatedReason: result.terminatedReason };
}
return { outcome: 'clean', terminatedReason: 'completed' };
}
const VALID_RUNNER_KINDS: SubagentRunnerKind[] = [
'claude-cli',
'codex-cli',
'cursor-cli',
'skipped',
'operator-recorder',
];
const VALID_OUTCOMES: SubagentRunnerOutcome[] = [
'clean',
'patched',
'deferred',
'skipped',
];
const VALID_TERMINATED_REASONS: SubagentRunnerTerminatedReason[] = [
'completed',
'rate_limit',
'sandbox_denied',
'runner_unavailable',
'runner_failed',
'advisory_violation',
];
const VALID_FALLBACK_LEVELS: SubagentRunnerFallbackLevel[] = [
'preferred',
'fallback',
'failed_all',
'not_applicable',
];
export type BuildRunnerInvocationOptions = {
terminatedReason?: SubagentRunnerTerminatedReason;
rawOutput?: string;
filledPrompt?: string;
fallbackLevel?: SubagentRunnerFallbackLevel;
schemaVersion?: number;
primaryAgent?: string;
runnerSelfReport?: string | null;
fallbackFrom?: SubagentRunnerKind | null;
findings?: string[];
probedSurfaces?: string[];
patches?: string[];
completedAt?: string;
};
export function buildRunnerInvocation(
runnerKind: SubagentRunnerKind,
reviewedHeadSha: string,
outcome: SubagentRunnerOutcome,
options: BuildRunnerInvocationOptions = {},
): SubagentRunnerInvocation {
return {
runnerKind,
reviewedHeadSha,
outcome,
completedAt: options.completedAt ?? new Date().toISOString(),
terminatedReason: options.terminatedReason ?? 'completed',
...(options.rawOutput !== undefined
? { rawOutput: options.rawOutput }
: {}),
...(options.filledPrompt !== undefined
? { filledPrompt: options.filledPrompt }
: {}),
...(options.fallbackLevel !== undefined
? { fallbackLevel: options.fallbackLevel }
: {}),
...(options.schemaVersion !== undefined
? { schemaVersion: options.schemaVersion }
: {}),
...(options.primaryAgent !== undefined
? { primaryAgent: options.primaryAgent }
: {}),
...(options.runnerSelfReport !== undefined
? { runnerSelfReport: options.runnerSelfReport }
: {}),
...(options.fallbackFrom !== undefined
? { fallbackFrom: options.fallbackFrom }
: {}),
findings: options.findings ?? [],
probedSurfaces: options.probedSurfaces ?? [],
patches: options.patches ?? [],
};
}
export function buildRunnerArtifact(
ticket: string,
invocations: SubagentRunnerInvocation[],
): SubagentRunnerArtifact {
return { ticket, invocations };
}
function validateInvocation(value: unknown): SubagentRunnerInvocation | null {
if (typeof value !== 'object' || value === null) return null;
const obj = value as Record<string, unknown>;
if (
typeof obj['runnerKind'] !== 'string' ||
!(VALID_RUNNER_KINDS as string[]).includes(obj['runnerKind'])
) {
return null;
}
if (
typeof obj['reviewedHeadSha'] !== 'string' ||
obj['reviewedHeadSha'] === ''
) {
return null;
}
if (
typeof obj['outcome'] !== 'string' ||
!(VALID_OUTCOMES as string[]).includes(obj['outcome'])
) {
return null;
}
if (typeof obj['completedAt'] !== 'string' || obj['completedAt'] === '') {
return null;
}
if (
typeof obj['terminatedReason'] !== 'string' ||
!(VALID_TERMINATED_REASONS as string[]).includes(obj['terminatedReason'])
) {
return null;
}
const findings = validateStringArray(obj['findings']);
if (findings === null) return null;
const probedSurfaces = validateStringArray(obj['probedSurfaces']);
if (probedSurfaces === null) return null;
const patches = validateStringArray(obj['patches']);
if (patches === null) return null;
if (obj['rawOutput'] !== undefined && typeof obj['rawOutput'] !== 'string') {
return null;
}
if (
obj['filledPrompt'] !== undefined &&
typeof obj['filledPrompt'] !== 'string'
) {
return null;
}
if (
obj['fallbackLevel'] !== undefined &&
(typeof obj['fallbackLevel'] !== 'string' ||
!(VALID_FALLBACK_LEVELS as string[]).includes(obj['fallbackLevel']))
) {
return null;
}
if (
obj['schemaVersion'] !== undefined &&
(typeof obj['schemaVersion'] !== 'number' ||
!Number.isInteger(obj['schemaVersion']))
) {
return null;
}
if (
obj['primaryAgent'] !== undefined &&
typeof obj['primaryAgent'] !== 'string'
) {
return null;
}
if (
obj['runnerSelfReport'] !== undefined &&
obj['runnerSelfReport'] !== null &&
typeof obj['runnerSelfReport'] !== 'string'
) {
return null;
}
if (
obj['fallbackFrom'] !== undefined &&
obj['fallbackFrom'] !== null &&
(typeof obj['fallbackFrom'] !== 'string' ||
!(VALID_RUNNER_KINDS as string[]).includes(obj['fallbackFrom']))
) {
return null;
}
// The validator preserves input shape: Phase-14 fields are emitted only when
// present in the source row. Readers should apply `getPrimaryAgent` /
// `getRunnerSelfReport` / `getFallbackFrom` to materialize the documented
// defaults (`"unknown"`, `null`, `null`) for legacy rows. Forward-compat note:
// an unknown future `schemaVersion` (e.g. `999`) parses without throwing — the
// contract for unknown versions is "read what you can; don't enforce".
return {
runnerKind: obj['runnerKind'] as SubagentRunnerKind,
reviewedHeadSha: obj['reviewedHeadSha'] as string,
outcome: obj['outcome'] as SubagentRunnerOutcome,
completedAt: obj['completedAt'] as string,
terminatedReason: obj['terminatedReason'] as SubagentRunnerTerminatedReason,
...(obj['rawOutput'] !== undefined
? { rawOutput: obj['rawOutput'] as string }
: {}),
...(obj['filledPrompt'] !== undefined
? { filledPrompt: obj['filledPrompt'] as string }
: {}),
...(obj['fallbackLevel'] !== undefined
? { fallbackLevel: obj['fallbackLevel'] as SubagentRunnerFallbackLevel }
: {}),
...(obj['schemaVersion'] !== undefined
? { schemaVersion: obj['schemaVersion'] as number }
: {}),
...(obj['primaryAgent'] !== undefined
? { primaryAgent: obj['primaryAgent'] as string }
: {}),
...(obj['runnerSelfReport'] !== undefined
? { runnerSelfReport: obj['runnerSelfReport'] as string | null }
: {}),
...(obj['fallbackFrom'] !== undefined
? { fallbackFrom: obj['fallbackFrom'] as SubagentRunnerKind | null }
: {}),
findings,
probedSurfaces,
patches,
};
}
/**
* Materialize the documented default for a row missing `primaryAgent`. Pre-
* Phase-14 rows render as `"unknown"`; Phase-14 rows preserve whatever the
* writer recorded.
*/
export function getPrimaryAgent(row: SubagentRunnerInvocation): string {
return row.primaryAgent ?? 'unknown';
}
/**
* Materialize the documented default for a row missing `runnerSelfReport`.
* Defaults to `null` (no parseable self-report).
*/
export function getRunnerSelfReport(
row: SubagentRunnerInvocation,
): string | null {
return row.runnerSelfReport ?? null;
}
/**
* Materialize the documented default for a row missing `fallbackFrom`.
* Defaults to `null` (no fallback fired).
*/
export function getFallbackFrom(
row: SubagentRunnerInvocation,
): SubagentRunnerKind | null {
return row.fallbackFrom ?? null;
}
function validateStringArray(value: unknown): string[] | null {
if (!Array.isArray(value)) return null;
for (const entry of value) {
if (typeof entry !== 'string') return null;
}
return value as string[];
}
export function validateRunnerArtifact(
value: unknown,
): SubagentRunnerArtifact | null {
if (typeof value !== 'object' || value === null) return null;
const obj = value as Record<string, unknown>;
if (typeof obj['ticket'] !== 'string' || obj['ticket'] === '') return null;
if (!Array.isArray(obj['invocations'])) return null;
const invocations: SubagentRunnerInvocation[] = [];
for (const raw of obj['invocations']) {
const validated = validateInvocation(raw);
if (!validated) return null;
invocations.push(validated);
}
if (invocations.length === 0) return null;
return { ticket: obj['ticket'] as string, invocations };
}
function isLegacyShape(value: unknown): value is Record<string, unknown> {
if (typeof value !== 'object' || value === null) return false;
const obj = value as Record<string, unknown>;
return !('invocations' in obj) && !('ticket' in obj) && 'runnerKind' in obj;
}
function liftLegacyArtifact(
raw: Record<string, unknown>,
ticket: string,
sourcePath: string,
): SubagentRunnerArtifact {
if (
typeof raw['reviewedHeadSha'] !== 'string' ||
raw['reviewedHeadSha'] === ''
) {
throw new Error(
`Legacy subagent runner artifact at ${sourcePath} is missing required field: reviewedHeadSha.`,
);
}
if (
typeof raw['outcome'] !== 'string' ||
!(VALID_OUTCOMES as string[]).includes(raw['outcome'] as string)
) {
throw new Error(
`Legacy subagent runner artifact at ${sourcePath} is missing required field: outcome.`,
);
}
if (typeof raw['completedAt'] !== 'string' || raw['completedAt'] === '') {
throw new Error(
`Legacy subagent runner artifact at ${sourcePath} is missing required field: completedAt.`,
);
}
if (!(VALID_RUNNER_KINDS as string[]).includes(raw['runnerKind'] as string)) {
throw new Error(
`Legacy subagent runner artifact at ${sourcePath} has invalid runnerKind: ${String(raw['runnerKind'])}.`,
);
}
const invocation: SubagentRunnerInvocation = {
runnerKind: raw['runnerKind'] as SubagentRunnerKind,
reviewedHeadSha: raw['reviewedHeadSha'],
outcome: raw['outcome'] as SubagentRunnerOutcome,
completedAt: raw['completedAt'],
terminatedReason: 'completed',
findings: [],
probedSurfaces: [],
patches: [],
};
return { ticket, invocations: [invocation] };
}
export function readSubagentRunnerArtifact(
path: string,
ticket: string,
): SubagentRunnerArtifact {
const raw = JSON.parse(readFileSync(path, 'utf-8')) as unknown;
if (isLegacyShape(raw)) {
return liftLegacyArtifact(raw as Record<string, unknown>, ticket, path);
}
const validated = validateRunnerArtifact(raw);
if (!validated) {
throw new Error(
`Subagent runner artifact at ${path} is not a valid structured SubagentRunnerArtifact.`,
);
}
return validated;
}
export type ParsedSubagentReviewArgs = {
ticketId?: string;
outcome?: 'clean' | 'patched';
reviewedHeadSha?: string;
patchCommitArgs: string[];
force: boolean;
};
export function parseSubagentReviewArgs(
positionals: string[],
flags: Set<string>,
): ParsedSubagentReviewArgs {
const isOutcome = (value: string | undefined): value is 'clean' | 'patched' =>
value === 'clean' || value === 'patched';
let cursor = 0;
let ticketId: string | undefined;
if (positionals[cursor] !== undefined && !isOutcome(positionals[cursor])) {
ticketId = positionals[cursor];
cursor += 1;
}
let outcome: 'clean' | 'patched' | undefined;
if (isOutcome(positionals[cursor])) {
outcome = positionals[cursor] as 'clean' | 'patched';
cursor += 1;
}
let reviewedHeadSha: string | undefined;
if (outcome) {
const sha = positionals[cursor];
if (sha === undefined || sha.trim() === '') {
throw new Error(
'subagent-review recorder mode requires a HEAD SHA after the outcome: `subagent-review [ticket-id] <clean|patched> <sha> [patch-commit-sha ...]`.',
);
}
reviewedHeadSha = sha;
cursor += 1;
}
const patchCommitArgs = positionals.slice(cursor);
if (!outcome && patchCommitArgs.length > 0) {
throw new Error(
`Unexpected positional argument: \`${patchCommitArgs[0]}\`. Usage: \`subagent-review [ticket-id] [clean|patched <sha> [patch-commit-sha ...]] [--force]\`.`,
);
}
if (outcome === 'clean' && patchCommitArgs.length > 0) {
throw new Error(
'subagent-review patch commits are only allowed when outcome is `patched`.',
);
}
return {
ticketId,
outcome,
reviewedHeadSha,
patchCommitArgs,
force: flags.has('force'),
};
}
export type SubagentReviewModeDecision =
| {
kind: 'recorder';
reviewedHeadSha: string;
outcome: 'clean' | 'patched';
}
| {
kind: 'no-op';
reviewedHeadSha: string;
existingInvocationIndex: number;
}
| { kind: 'invoke-runner' };
export function decideSubagentReviewMode(
args: {
outcome?: 'clean' | 'patched';
reviewedHeadSha?: string;
force: boolean;
},
artifact: SubagentRunnerArtifact | null,
currentHeadSha: string,
): SubagentReviewModeDecision {
if (args.outcome && args.reviewedHeadSha) {
return {
kind: 'recorder',
reviewedHeadSha: args.reviewedHeadSha,
outcome: args.outcome,
};
}
if (!args.force && artifact && currentHeadSha) {
const idx = artifact.invocations.findIndex(
(invocation) =>
invocation.reviewedHeadSha === currentHeadSha &&
invocation.outcome !== 'skipped',
);
if (idx >= 0) {
return {
kind: 'no-op',
reviewedHeadSha: currentHeadSha,
existingInvocationIndex: idx,
};
}
}
return { kind: 'invoke-runner' };
}
export function tryReadSubagentRunnerArtifact(
path: string,
ticket: string,
): SubagentRunnerArtifact | null {
if (!existsSync(path)) return null;
return readSubagentRunnerArtifact(path, ticket);
}
export function appendInvocationToArtifact(
path: string,
ticket: string,
invocation: SubagentRunnerInvocation,
): SubagentRunnerArtifact {
let existing: SubagentRunnerArtifact | null = null;
if (existsSync(path)) {
existing = readSubagentRunnerArtifact(path, ticket);
}
const invocations = existing
? [...existing.invocations, invocation]
: [invocation];
const artifact: SubagentRunnerArtifact = { ticket, invocations };
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, JSON.stringify(artifact, null, 2) + '\n', 'utf-8');
return artifact;
}
tools/delivery
subagent-runner.ts
tools/delivery/subagent-runner.ts