gstack-autoplan: shadow every published gate, the shadow export, config and docs

Every driver command that prints a gate records the shadow first and
checkpoints after it; answer waits for a live lease and records a dead
one retrospectively; gstack-autoplan shadow exports answered revisions
for taste-replay R2 (SHADOW_BEFORE_ANSWER otherwise); gstack-config
autoplan_shadow / autoplan_shadow_model; gstack-artifact validate checks
the record's hash (GST-537 PR 3).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-11-01 18:47:10 +00:00
1 parent 0e179ea4ea
commit aec80b10a9
11 files changed
+769 -64

No files matched your search

+4 -1
View File
@@ -22,6 +22,7 @@ import {
import { resultLine } from '../lib/result-codes';
import { resolveStateRoot } from '../lib/state-root';
import { validatePublic } from '../lib/autoplan-public';
import { validateShadow } from '../lib/autoplan-shadow-reader';
const HELP = `gstack-artifact — the unattended artifact contract (docs/unattended.md)
@@ -86,7 +87,9 @@ function main(argv: string[]): number {
else { printErrors(errors); process.stdout.write(errors.length ? `ARTIFACT_INVALID: ${errors.length} error(s)\n` : `ARTIFACT_VALID: ${as} ${path.basename(file)}\n`); }
return errors.length ? EXIT.fail : EXIT.ok;
}
const result = validateRun(file);
const run = validateRun(file);
const shadow = validateShadow(run.runDir);
const result = { ...run, valid: run.valid && shadow.length === 0, errors: [...run.errors, ...shadow] };
if (json) {
process.stdout.write(JSON.stringify({ valid: result.valid, run: result.manifest?.run ?? null, status: result.manifest?.status ?? null, run_dir: result.runDir, attested: result.attested ?? 0, errors: result.errors }) + '\n');
} else {
+112 -59
View File
@@ -21,6 +21,7 @@
* gstack-autoplan budget --out <dir> [--spend-cap <usd>] [--max-rounds <n>] --by <human|agent:<id>> the owner's journaled cap change
* gstack-autoplan check --out <dir> --attempt <id> --result <file> --model <id> | --unavailable <reason> the delta check before a carry
* gstack-autoplan checkpoints --from <dir> list checkpoint generations, newest first, with hash ok|bad
* gstack-autoplan shadow --out <dir> | --runs <file> [--json] export answered gates beside their shadow predictions (read-only)
* gstack-autoplan contract print the coordinator contract every lane prompt inherits (plan E2)
*
* Logic: lib/autoplan-run.ts (next/submit/resume), lib/autoplan-gate.ts (close, gate, answer),
@@ -52,6 +53,8 @@ import { createRunner, familyConflict, type RunnerId } from '../lib/outside-voic
import { resultLine } from '../lib/result-codes';
import { release } from '../lib/spend-ledger';
import { COORDINATOR_CONTRACT } from '../lib/coordinator-contract';
import { claimShadow, runShadow, shadowBeforeAnswer, shadowSwitch, type ShadowClaim, type ShadowOutcome } from '../lib/autoplan-shadow';
import { exportShadow, renderShadowExport } from '../lib/autoplan-shadow-reader';
const HELP = `gstack-autoplan — unattended /autoplan: next/submit loop, answer, resume (docs/unattended.md)
DRIVER: ${driverFile()}
@@ -95,6 +98,14 @@ DRIVER: ${driverFile()}
bind the one-reviewer delta check a CARRY_CHECK: line asked for: nothing at High or above carries the owner's
last answer (CARRIED:); otherwise, or when unavailable, the gate goes to the owner as before
checkpoints --from <dir> list checkpoint generations, newest first: run, gate_rev, time, hash ok|bad, current
shadow --out <dir> | --runs <file> [--json]
read-only export for taste-replay R2: one row per item of each answered gate revision, its shadow prediction
(recorded before the answer; status recorded, malformed, failed, missing, retrospective or off) beside the
owner's answer and how he gave it; counts by status, item class, owner_mode and ceo_eligible. An unanswered or
interrogated revision refuses SHADOW_BEFORE_ANSWER, a CEO-only run CEO_ONLY_NO_GATE. --runs: one run dir per line.
Shadow mode itself is off by default: gstack-config set autoplan_shadow on (or GSTACK_AUTOPLAN_SHADOW=1) at the
first next; each gate then gets one hidden model call (autoplan_shadow_model, default claude-opus-5-5) counted
against --spend-cap, and every command that prints a gate prints SHADOW: first
close --out <dir> --phase <p> --deferred "<reason>" --by <human|agent:<id>>
close the phase accepted-with-findings: open voices skipped (dispatched attempts settle unknown at their
reservation), open findings into tasks.jsonl; Critical, urgent and user-challenge findings become gate items
@@ -138,7 +149,7 @@ function nextLine(state: RunState, out: string): string {
* Admission, then under the run lock: bring an existing run forward (GSTACK_VERSION_CHANGED, legacy decision ids)
* and record an admission override as a deviation. Notices go to stdout before the command's own lines (stderr with --json).
*/
function mutating(out: string, json: boolean, fn: () => number): number {
function mutating(out: string, json: boolean, fn: () => number | Report): number | Report {
const admission = admit(process.env);
touchInstallLease(process.env);
return withRunLock(out, () => {
@@ -155,13 +166,48 @@ function mutating(out: string, json: boolean, fn: () => number): number {
notices.push(...migrated.lines);
}
if (notices.length) (json ? process.stderr : process.stdout).write(notices.join('\n') + '\n');
const code = fn();
const result = fn();
if (admission.deviation && fs.existsSync(path.join(out, FILES.run))) { const state = readState(out); if (record(state)) writeState(state); }
if (code === EXIT.ok) checkpointQuietly(out);
return code;
if (typeof result !== 'number') return claimed(out, result);
if (result === EXIT.ok) checkpointQuietly(out);
return result;
});
}
/**
* What a command that can leave a gate reports: the lines before the gate, the lines after it, and its JSON payload.
* `publish` puts the SHADOW line and the gate lines between them once the shadow has run (lib/autoplan-shadow.ts).
*/
interface Report { head: string[]; tail: string[]; payload: Record<string, unknown>; gate: boolean; claim?: ShadowClaim; claimLine?: string }
/** Still under the command's run lock: a report at a gate claims its shadow (the checkpoint waits for publish); any other checkpoints now. */
function claimed(out: string, r: Report): Report {
if (!r.gate) { checkpointQuietly(out); return r; }
try { const c = claimShadow(out); return { ...r, claim: c.claim, claimLine: c.line }; }
catch (e: any) { return { ...r, claimLine: `SHADOW: failed internal (${e?.message ?? e}); the gate stands` }; }
}
const shadowField = (o: ShadowOutcome | undefined, line: string | undefined) => (o ? { shadow: { status: o.status, ...(o.code ? { code: o.code } : {}), ...(o.model ? { model: o.model } : {}), ...(o.usd !== undefined ? { usd: o.usd } : {}) } } : line ? { shadow: { line } } : {});
/**
* The one gate-publication step every command that prints a gate shares (next, submit, close, resume, revise, run, check):
* after the locked step, run the claimed shadow with no lock held, then print SHADOW, the gate and its cost, then
* checkpoint, so a checkpoint taken at a gate holds its shadow record. A shadow failure never fails the command.
*/
async function publish(out: string, json: boolean, r: number | Report): Promise<number> {
if (typeof r === 'number') return r;
const outcome = r.claim ? await runShadow(out, r.claim) : undefined;
const line = outcome?.line ?? r.claimLine;
const state = r.gate ? readState(out) : undefined;
emit(json, [...r.head, ...(line ? [line] : []), ...(state ? gateLines(state, out) : []), ...r.tail], { ...r.payload, ...(state ? gateCostField(state, out) : {}), ...shadowField(outcome, line) });
if (r.gate) gateCheckpoint(out);
return EXIT.ok;
}
function gateCheckpoint(out: string): void {
try { withRunLock(out, () => checkpointQuietly(out), { waitMs: 30_000 }); }
catch (e: any) { process.stderr.write(`CHECKPOINT: skipped for ${out}: ${e instanceof RunError ? `${e.detail} (${e.code})` : e?.message ?? e}; the command's result stands\n`); }
}
/** Set by a command whose success is a checkpoint moment beyond rest: a bound voice, a phase transition, the first next. */
let moment: CheckpointMoment | undefined;
@@ -180,7 +226,7 @@ function emit(json: boolean, lines: string[], payload: Record<string, unknown>):
else process.stdout.write(lines.join('\n') + '\n');
}
function doNext(out: string, rest: string[], json: boolean): number {
function doNext(out: string, rest: string[]): Report {
const cwd = process.cwd();
const initLines: string[] = [];
const fresh = !fs.existsSync(path.join(out, FILES.run));
@@ -194,20 +240,18 @@ function doNext(out: string, rest: string[], json: boolean): number {
if (maxRounds !== undefined && (!Number.isInteger(maxRounds) || maxRounds < 1)) throw new RunError('ARTIFACT_SCHEMA', '--max-rounds needs an integer >= 1', 1);
const carryCheckCapUsd = num(rest, '--carry-check-cap');
if (carryCheckCapUsd !== undefined && !(carryCheckCapUsd >= 0)) throw new RunError('ARTIFACT_SCHEMA', '--carry-check-cap needs a number >= 0', 1);
const shadow = shadowSwitch({ outsideEnabled: !rest.includes('--no-outside'), ceoOnly }, process.env);
if (shadow.line) initLines.push(shadow.line);
initRun({ plan, out, cwd, planHeading: flag(rest, '--plan-heading'), outsideEnabled: !rest.includes('--no-outside'), spendCap: num(rest, '--spend-cap') ?? null, maxRounds,
carryForward: !rest.includes('--no-carry-forward'), carryCheckCapUsd, ceoOnly,
carryForward: !rest.includes('--no-carry-forward'), carryCheckCapUsd, ceoOnly, shadow: shadow.shadow,
deadlineMin: num(rest, '--deadline'), light: rest.includes('--light'), scaffold: rest.includes('--scaffold'), scope: { ui, developerTool: rest.includes('--developer-tool'), agentPrimary: rest.includes('--agent-primary'), dx: rest.includes('--no-dx') ? false : undefined, ...(rest.includes('--docs-only') ? { docsOnly: true } : {}) } }, initLines);
}
const redispatch = rest.flatMap((a, i) => (a === '--redispatch' && rest[i + 1] ? [rest[i + 1]!] : []));
const r = nextStep({ out, cwd, estimateUsd: num(rest, '--estimate-usd'), redispatch });
moment = fresh ? 'init' : r.attempts.some(a => a.new) || r.lines.some(l => l.startsWith('PHASE_DEFERRED: ')) ? 'transition' : undefined;
const status = r.state.status;
const lines = [...initLines, ...r.lines];
if (status === 'gate_pending') lines.push(...gateLines(r.state, out));
if (status === 'running') lines.push(`NEXT: gstack-autoplan next --out ${out}`);
lines.push(resultLineFor(status, out));
emit(json, lines, { run: r.state.run, status, phase: r.phase ?? null, attempts: r.attempts, gate_rev: r.state.gate_rev ?? null, ...gateCostField(r.state, out), out });
return EXIT.ok;
return { head: [...initLines, ...r.lines], tail: [...(status === 'running' ? [`NEXT: gstack-autoplan next --out ${out}`] : []), resultLineFor(status, out)], gate: status === 'gate_pending',
payload: { run: r.state.run, status, phase: r.phase ?? null, attempts: r.attempts, gate_rev: r.state.gate_rev ?? null, out } };
}
/** The gate pointer, its cost so far and the price of `rec` (lib/autoplan-gate-cost.ts), plus the owner-facing render to relay under Capy (lib/captain-brief.ts). */
@@ -228,7 +272,7 @@ function gateCostField(state: RunState, out: string): Record<string, unknown> {
return cost ? { gate_cost: gateCostPayload(cost) } : {};
}
function doSubmit(out: string, rest: string[], json: boolean): number {
function doSubmit(out: string, rest: string[]): number | Report {
const phase = flag(rest, '--phase'); const voice = flag(rest, '--voice'); const result = flag(rest, '--result'); const record = flag(rest, '--record');
const model = flag(rest, '--model'); const attempt = flag(rest, '--attempt'); const unavailable = flag(rest, '--unavailable'); const failed = flag(rest, '--failed');
if (!phase || !voice) return usage('submit needs --phase and --voice');
@@ -240,28 +284,22 @@ function doSubmit(out: string, rest: string[], json: boolean): number {
const r = submitResult({ out, phase, voice, result: result ? path.resolve(result) : undefined, record: record ? path.resolve(record) : undefined, model, attempt, attest: flag(rest, '--attest'),
unavailable, failed, runner: flag(rest, '--runner'), usageUsd: num(rest, '--usage-usd'), reconcile: flag(rest, '--reconcile'), cwd: process.cwd() });
const status = r.state.status;
const lines = [...r.lines];
if (status === 'gate_pending') lines.push(...gateLines(r.state, out));
if (status === 'running') lines.push(`NEXT: gstack-autoplan next --out ${out}`);
lines.push(resultLineFor(status, out));
emit(json, lines, { run: r.state.run, status, phase, voice, attempt: r.state.phases[phase]?.[voice]?.attempt ?? attempt ?? null, closed: r.closed, findings: r.findings, gate_rev: r.state.gate_rev ?? null, ...gateCostField(r.state, out), out });
return EXIT.ok;
return { head: [...r.lines], tail: [...(status === 'running' ? [`NEXT: gstack-autoplan next --out ${out}`] : []), resultLineFor(status, out)], gate: status === 'gate_pending',
payload: { run: r.state.run, status, phase, voice, attempt: r.state.phases[phase]?.[voice]?.attempt ?? attempt ?? null, closed: r.closed, findings: r.findings, gate_rev: r.state.gate_rev ?? null, out } };
}
function doRevise(out: string, rest: string[], json: boolean): number {
function doRevise(out: string, rest: string[]): number | Report {
const plan = flag(rest, '--plan'); const incorporates = listFlag(rest, '--incorporates'); const withdraw = listFlag(rest, '--withdraw'); const settle = listFlag(rest, '--settle');
if (!plan && !withdraw.length && !settle.length) return usage('revise needs --plan <file> (and optionally --incorporates <id,...>), --withdraw <decision id> or --settle <id>');
if (!plan && incorporates.length) return usage('--incorporates binds accepted changes to a revised plan: pass --plan <file>');
refuseCeoOnly(readState(out), 'revise');
const r = reviseRun({ out, plan: plan ? path.resolve(plan) : undefined, incorporates, withdraw, settle, cwd: process.cwd() });
const lines = [...r.lines];
if (r.state.status === 'running' || r.state.status === 'amend_pending') lines.push(nextLine(r.state, out));
lines.push(resultLineFor(r.state.status, out));
emit(json, lines, { run: r.state.run, status: r.state.status, reopened: r.reopened, amendments: pendingAmendments(r.state).map(a => a.decision), gate_rev: r.state.gate_rev ?? null, out });
return EXIT.ok;
const tail = [...(r.state.status === 'running' || r.state.status === 'amend_pending' ? [nextLine(r.state, out)] : []), resultLineFor(r.state.status, out)];
return { head: [...r.lines], tail, gate: r.state.status === 'gate_pending',
payload: { run: r.state.run, status: r.state.status, reopened: r.reopened, amendments: pendingAmendments(r.state).map(a => a.decision), gate_rev: r.state.gate_rev ?? null, out } };
}
function doClose(out: string, rest: string[], json: boolean): number {
function doClose(out: string, rest: string[]): number | Report {
const phase = flag(rest, '--phase'); const reason = flag(rest, '--deferred'); const by = flag(rest, '--by');
if (!phase || !reason?.trim()) return usage('close needs --phase <p> --deferred "<reason>" --by <human|agent:<id>>');
if (by === undefined || !ANSWERED_BY_RE.test(by)) throw new RunError('ANSWER_BY_REQUIRED', `close records who deferred the phase: pass --by human or --by agent:<id> (got ${JSON.stringify(by ?? null)})`, 3);
@@ -273,25 +311,22 @@ function doClose(out: string, rest: string[], json: boolean): number {
const r = closeDeferred(state, phase as Phase, { reason: reason.trim(), by, cause: 'owner' });
moment = 'transition';
const lines = [...r.lines, ...(stopped ? liftStop(state, `${phase} closed deferred by ${by}`) : [])];
if (!currentPhase(state)) lines.push(...finishRun(state), ...(state.status === 'gate_pending' ? gateLines(state, out) : []));
if (!currentPhase(state)) lines.push(...finishRun(state));
else { state.status = 'running'; lines.push(`NEXT: gstack-autoplan next --out ${out}`); }
writeState(state);
lines.push(resultLineFor(state.status, out));
emit(json, lines, { run: state.run, status: state.status, phase, blockers: r.blockers, gate_rev: state.gate_rev ?? null, ...gateCostField(state, out), out });
return EXIT.ok;
return { head: lines, tail: [resultLineFor(state.status, out)], gate: state.status === 'gate_pending', payload: { run: state.run, status: state.status, phase, blockers: r.blockers, gate_rev: state.gate_rev ?? null, out } };
}
/** `check`: the delta reviewer's verdict before a carry (lib/autoplan-carry.ts). */
function doCheck(out: string, rest: string[], json: boolean): number {
function doCheck(out: string, rest: string[]): number | Report {
const unavailable = flag(rest, '--unavailable');
const result = flag(rest, '--result');
if (unavailable === undefined && (!result || !flag(rest, '--model'))) return usage('check needs --attempt <id> and --result <file> --model <id>, or --unavailable "<reason>"');
const r = submitCarryCheck({ out, attempt: flag(rest, '--attempt'), result: result ? path.resolve(result) : undefined, model: flag(rest, '--model'), unavailable, sameFamily: flag(rest, '--same-family') });
moment = 'transition';
const status = r.state.status;
const lines = [...r.lines, ...(status === 'gate_pending' ? gateLines(r.state, out) : []), ...(status === 'amend_pending' || status === 'running' ? [nextLine(r.state, out)] : []), resultLineFor(status, out)];
emit(json, lines, { run: r.state.run, status, gate_rev: r.state.gate_rev ?? null, carry: r.state.carry ?? null, out });
return EXIT.ok;
return { head: [...r.lines], tail: [...(status === 'amend_pending' || status === 'running' ? [nextLine(r.state, out)] : []), resultLineFor(status, out)], gate: status === 'gate_pending',
payload: { run: r.state.run, status, gate_rev: r.state.gate_rev ?? null, carry: r.state.carry ?? null, out } };
}
function doBudget(out: string, rest: string[], json: boolean): number {
@@ -313,13 +348,18 @@ async function doRun(out: string, rest: string[], json: boolean): Promise<number
const deadline = num(rest, '--deadline');
const deadlineAt = deadline ? Date.now() + deadline * 60_000 : undefined;
for (let guard = 0; guard < 40; guard++) {
const code = withRunLock(out, () => doNext(out, rest, true));
if (code !== EXIT.ok) return code;
const step = withRunLock(out, () => { const r = doNext(out, rest); return r.gate ? claimed(out, r) : r; });
const state = readState(out);
if (state.status !== 'awaiting_result') {
if (!json) process.stdout.write([...statusLines(state), ...(state.status === 'gate_pending' ? gateLines(state, out) : [])].join('\n') + '\n');
process.stdout.write(resultLineFor(state.status, out) + '\n'); return EXIT.ok;
const outcome = step.claim ? await runShadow(out, step.claim) : undefined;
const line = outcome?.line ?? step.claimLine;
const at = readState(out);
emit(true, [], { ...step.payload, ...gateCostField(at, out), ...shadowField(outcome, line) });
if (!json) process.stdout.write([...statusLines(at), ...(line ? [line] : []), ...(at.status === 'gate_pending' ? gateLines(at, out) : [])].join('\n') + '\n');
if (step.gate) gateCheckpoint(out);
process.stdout.write(resultLineFor(at.status, out) + '\n'); return EXIT.ok;
}
emit(true, [], step.payload);
const phase = state.phase_order.find(p => state.phases[p]?.snapshot && !state.phases[p]?.closed_at)!;
for (const voice of ['native', 'outside'] as const) {
const v = state.phases[phase]![voice]!;
@@ -341,12 +381,24 @@ async function doRun(out: string, rest: string[], json: boolean): Promise<number
process.stdout.write(resultLineFor('interrupted', out) + '\n');
return EXIT.fail;
}
withRunLock(out, () => doSubmit(out, ['--phase', phase, '--voice', voice, '--result', `${open.result}.raw`, '--model', res.model ?? (voice === 'native' ? nativeModel : outsideModel), '--attempt', open.attempt, '--runner', res.runner], true));
const bound = withRunLock(out, () => doSubmit(out, ['--phase', phase, '--voice', voice, '--result', `${open.result}.raw`, '--model', res.model ?? (voice === 'native' ? nativeModel : outsideModel), '--attempt', open.attempt, '--runner', res.runner]));
if (typeof bound !== 'number') emit(true, [], bound.payload);
}
}
return EXIT.fail;
}
/** `shadow`: the read-only export for taste-replay R2 (lib/autoplan-shadow-reader.ts). */
function doShadow(rest: string[], json: boolean): number {
const runs = flag(rest, '--runs'); const one = flag(rest, '--out');
if (!runs === !one) return usage('shadow needs --out <run dir> or --runs <file> (one run directory per line)');
const dirs = runs ? fs.readFileSync(runs, 'utf8').split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('#')).map(d => path.resolve(d)) : [path.resolve(one!)];
const r = exportShadow(dirs);
for (const x of r.refused) process.stderr.write(resultLine('gstack-autoplan shadow', x.code, x.detail) + '\n');
if (json) process.stdout.write(JSON.stringify(r) + '\n'); else process.stdout.write(renderShadowExport(r).join('\n') + '\n');
return r.rows.length === 0 && r.refused.length ? EXIT.refused : EXIT.ok;
}
async function main(argv: string[]): Promise<number> {
const [cmd, ...rest] = argv;
const json = rest.includes('--json');
@@ -359,15 +411,16 @@ async function main(argv: string[]): Promise<number> {
catch (e: any) { if (!(e instanceof RunError)) throw e; process.stderr.write(resultLine('gstack-autoplan checkpoints', e.code, e.detail) + '\n'); return EXIT.refused; }
}
const outFlag = flag(rest, '--out');
if (!outFlag) return usage(`${cmd} needs --out <dir>`);
const out = path.resolve(outFlag);
if (!outFlag && cmd !== 'shadow') return usage(`${cmd} needs --out <dir>`);
const out = path.resolve(outFlag ?? '.');
try {
if (cmd === 'next') return mutating(out, json, () => doNext(out, rest, json));
if (cmd === 'submit') return mutating(out, json, () => doSubmit(out, rest, json));
if (cmd === 'revise') return mutating(out, json, () => doRevise(out, rest, json));
if (cmd === 'close') return mutating(out, json, () => doClose(out, rest, json));
if (cmd === 'budget') return mutating(out, json, () => doBudget(out, rest, json));
if (cmd === 'check') return mutating(out, json, () => doCheck(out, rest, json));
if (cmd === 'shadow') return doShadow(rest, json);
if (cmd === 'next') return await publish(out, json, mutating(out, json, () => doNext(out, rest)));
if (cmd === 'submit') return await publish(out, json, mutating(out, json, () => doSubmit(out, rest)));
if (cmd === 'revise') return await publish(out, json, mutating(out, json, () => doRevise(out, rest)));
if (cmd === 'close') return await publish(out, json, mutating(out, json, () => doClose(out, rest)));
if (cmd === 'budget') return await publish(out, json, mutating(out, json, () => doBudget(out, rest, json)));
if (cmd === 'check') return await publish(out, json, mutating(out, json, () => doCheck(out, rest)));
if (cmd === 'run') { const admission = admit(process.env); if (admission.lines.length) process.stdout.write(admission.lines.join('\n') + '\n'); return await doRun(out, rest, json); }
if (cmd === 'status') {
const state = readState(out);
@@ -377,13 +430,11 @@ async function main(argv: string[]): Promise<number> {
if (cmd === 'resume') {
const from = flag(rest, '--from');
const restored = from !== undefined ? restoreCheckpoint(from, out) : [];
return withRunLock(out, () => {
return await publish(out, json, withRunLock(out, () => {
const r = resumeRun(out);
checkpointQuietly(out);
const lines = [...restored, ...r.lines, resultLineFor(r.state.status, out)];
emit(json, lines, { run: r.state.run, status: r.state.status, execution_unknown: r.unknown, rebound: r.rebound, out });
return EXIT.ok;
}, { steal: true });
return claimed(out, { head: [...restored, ...r.lines], tail: [resultLineFor(r.state.status, out)], gate: r.state.status === 'gate_pending',
payload: { run: r.state.run, status: r.state.status, execution_unknown: r.unknown, rebound: r.rebound, out } });
}, { steal: true }));
}
if (cmd === 'answer') {
const rev = flag(rest, '--gate-rev'); const reply = flag(rest, '--reply'); const by = flag(rest, '--by');
@@ -392,7 +443,9 @@ async function main(argv: string[]): Promise<number> {
const replyFile = flag(rest, '--owner-reply-file'); const ownerFlag = flag(rest, '--owner-reply');
if (replyFile !== undefined && ownerFlag !== undefined) return usage('--owner-reply and --owner-reply-file are exclusive');
const ownerReply = replyFile !== undefined ? fs.readFileSync(replyFile, 'utf8') : ownerFlag === '-' ? fs.readFileSync(0, 'utf8') : ownerFlag;
return mutating(out, json, () => {
const shadowLines = await shadowBeforeAnswer(out);
if (shadowLines.length) (json ? process.stderr : process.stdout).write(shadowLines.join('\n') + '\n');
return await publish(out, json, mutating(out, json, () => {
const state = readState(out);
refuseCeoOnly(state, 'answer');
const r = answerGate(state, Number(rev), reply, by, { recHash: flag(rest, '--rec-hash'), ownerReply });
@@ -402,19 +455,19 @@ async function main(argv: string[]): Promise<number> {
lines.push(resultLineFor(state.status, out));
emit(json, lines, { run: state.run, status: state.status, outcome: r.outcome, pending: r.pending, reopened: r.reopened, amendments: pendingAmendments(state).map(a => a.decision), gate_rev: state.gate_rev, out });
return EXIT.ok;
});
}));
}
if (cmd === 'recommend') {
const rev = flag(rest, '--gate-rev'); const item = flag(rest, '--item'); const option = flag(rest, '--option'); const basis = flag(rest, '--basis');
if (rev === undefined || !item || !option || !basis?.trim()) return usage('recommend needs --gate-rev <n> --item <id> --option <k> --basis "<why>"');
return mutating(out, json, () => {
return await publish(out, json, mutating(out, json, () => {
const state = readState(out);
refuseCeoOnly(state, 'recommend');
const lines = recommendGate(state, Number(rev), item.toLowerCase(), option.toLowerCase(), basis.trim(), flag(rest, '--by') ?? 'parent');
writeState(state);
emit(json, [...lines, resultLineFor(state.status, out)], { run: state.run, status: state.status, gate_rev: state.gate_rev, out });
return EXIT.ok;
});
}));
}
if (cmd === 'export' && flag(rest, '--public') !== undefined) {
return withRunLock(out, () => {
@@ -425,7 +478,7 @@ async function main(argv: string[]): Promise<number> {
});
}
if (cmd === 'export') {
return mutating(out, json, () => {
return await publish(out, json, mutating(out, json, () => {
const state = readState(out);
const pending = pendingAmendments(state).map(a => a.decision);
if (state.status === 'complete' && pending.length) throw new RunError('PLAN_AMEND_PENDING', `run says complete with accepted changes not in the plan: ${pending.join(', ')}`, 3);
@@ -434,7 +487,7 @@ async function main(argv: string[]): Promise<number> {
for (const w of r.warnings) process.stderr.write(`gstack-autoplan export: ${w}\n`);
emit(json, [...r.files.map(f => `EXPORTED: ${f}`), dispatchLine(state)], { run: state.run, files: r.files, warnings: r.warnings, dispatches: state.dispatches, est_input_tokens: state.est_input_tokens });
return EXIT.ok;
});
}));
}
if (cmd === 'release') {
const attempt = flag(rest, '--attempt');
+21 -2
View File
@@ -221,6 +221,17 @@ CONFIG_HEADER='# gstack configuration — edit freely, changes take effect on ne
# # /codex and /claude-code still run when you ask by name.
# # An unreadable config counts as disabled.
# # Invalid values preserve the old value.
# autoplan_shadow: off # Shadow mode for the CEO-only decider (gstack-autoplan
# # driver runs): on gives every gate one hidden model
# # prediction, recorded before the owner answers, for
# # taste-replay R2. Sends the reviewed plan and the gate
# # to that model on your own API key, about $0.07 per
# # gate, counted against --spend-cap. Read at the first
# # next of each run only. GSTACK_AUTOPLAN_SHADOW=1|0
# # overrides.
# # Invalid values are rejected, the old value kept.
# autoplan_shadow_model: claude-opus-5-5 # The model the shadow decider runs on.
# # Docs: docs/unattended.md#shadow-mode
# browse_extension_id: # Forks and self-built extensions: the 32-letter (a-p)
# # Chrome extension ID the browse sidebar terminal accepts.
# # Empty = the gstack extension. "" clears it.
@@ -309,6 +320,8 @@ lookup_default() {
explain_level) echo "default" ;;
plan_mode_preamble) echo "on" ;; # on | off — off drops the plan-mode preamble on the next ./setup (#3168)
codex_reviews) echo "enabled" ;;
autoplan_shadow) echo "off" ;; # on | off — autoplan shadow mode, fixed at each run's first next (lib/autoplan-shadow.ts)
autoplan_shadow_model) echo "claude-opus-5-5" ;; # the shadow decider's model; needs a lib/pricing.ts row and its family's API key
design_detector) echo "auto" ;; # auto | off — impeccable engine pre-pass in the design skills
design_detector_install_prompted) echo "false" ;; # true once the user answered the one-time engine install offer with "never ask again"
gstack_contributor) echo "false" ;;
@@ -961,6 +974,12 @@ $_gstack_legacy_root"
echo "Error: memorable_recall '$VALUE' not recognized. Valid values: on, off. Existing value left unchanged." >&2
exit 1
fi
# autoplan_shadow is a CONSENT key: `on` sends each gate's plan to a model
# API. Reject like memorable_recall -- a typo must never flip it.
if [ "$KEY" = "autoplan_shadow" ] && [ "$VALUE" != "on" ] && [ "$VALUE" != "off" ]; then
echo "Error: autoplan_shadow '$VALUE' not recognized. Valid values: on, off. Existing value left unchanged." >&2
exit 1
fi
# browse_extension_id (D1) decides which Chrome extension may reach the
# browse daemon's token route and terminal socket. Reject anything that is
# not a Chrome extension ID; "" clears the override.
@@ -1093,7 +1112,7 @@ $_gstack_legacy_root"
report_root_disagreement
for KEY in proactive routing_declined telemetry auto_upgrade update_check update_channel \
skill_prefix explain_level plan_mode_preamble \
codex_reviews gstack_contributor skip_eng_review workspace_root \
codex_reviews autoplan_shadow autoplan_shadow_model gstack_contributor skip_eng_review workspace_root \
artifacts_sync_mode artifacts_sync_mode_prompted artifacts_sync_removals plan_tune_hooks \
timeline_stop_hook design_detector design_detector_install_prompted memorable_recall \
browse_extension_id ship_measure_rule_trials ship_measure_behavior_panels \
@@ -1118,7 +1137,7 @@ $_gstack_legacy_root"
echo "# gstack-config defaults"
for KEY in proactive routing_declined telemetry auto_upgrade update_check update_channel \
skill_prefix explain_level plan_mode_preamble \
codex_reviews gstack_contributor skip_eng_review workspace_root \
codex_reviews autoplan_shadow autoplan_shadow_model gstack_contributor skip_eng_review workspace_root \
artifacts_sync_mode artifacts_sync_mode_prompted artifacts_sync_removals plan_tune_hooks \
timeline_stop_hook design_detector design_detector_install_prompted memorable_recall \
browse_extension_id ship_measure_rule_trials ship_measure_behavior_panels \
+1
View File
@@ -376,6 +376,7 @@ gstack_result_anchor() {
CEO_ONLY_CONFLICT) printf '%s' 'ceo-only-conflict' ;;
CEO_ONLY_AFTER_INIT) printf '%s' 'ceo-only-after-init' ;;
CEO_ONLY_NO_GATE) printf '%s' 'ceo-only-no-gate' ;;
SHADOW_BEFORE_ANSWER) printf '%s' 'shadow-before-answer' ;;
*) return 1 ;;
esac
}
+13
View File
@@ -3216,6 +3216,19 @@ work: they are how a budget-stopped CEO-only run recovers.)
full review in a new directory: `gstack-autoplan next --out <new dir> --plan
<file>` without `--ceo-only`.
<a id="shadow-before-answer"></a>
### `SHADOW_BEFORE_ANSWER`
**Meaning.** `gstack-autoplan shadow` skipped a gate revision whose plan
approval (`p1`) is unanswered or was answered `c` (interrogate). The export
pairs each hidden prediction with the owner's answer, so a revision he has not
answered has nothing to pair it with. Answered revisions of the same run still
export; the command exits 3 only when nothing was exported.
**Fix.** Answer the gate (`gstack-autoplan answer --out <dir> --gate-rev <n>
...`), then run `gstack-autoplan shadow` again. See
[Shadow mode](unattended.md#shadow-mode).
<a id="attempt-mismatch"></a>
### `ATTEMPT_MISMATCH`
+85 -1
View File
@@ -258,7 +258,8 @@ tree no review saw, so /ship runs again;
| File | Schema | Contents |
|------|--------|----------|
| `run.json` | `run` v1 | The manifest: `run` id, `status`, `required_phases`, each phase's `native` and `outside` outcome (`status`, `model`, `provider`), input `snapshot` hashes, `counts`, every artifact's `path` (relative to the run directory) and `sha256`, `session_kind`, `gate_rev`, `deviations`, `consumed_by`. |
| `decisions.jsonl` | `decisions` v1 | One row per decision: `id` (`<run>-<label>` at gate revision 1, `<run>-r<rev>-<label>` after a reopen, since labels restart at each gate), `title`, `options`, `recommended`, `kind` (`auto`, `approval`, `user_challenge`), `status` (`pending`, `approved`, `overridden`, `rejected`, or `auto_kept` in a [CEO-only run](#ceo-only-runs)), `gate_rev`, and once answered `chosen`, `answered_at`, `reply`. |
| `decisions.jsonl` | `decisions` v1 | One row per decision: `id` (`<run>-<label>` at gate revision 1, `<run>-r<rev>-<label>` after a reopen, since labels restart at each gate), `title`, `options`, `recommended`, `kind` (`auto`, `approval`, `user_challenge`), `status` (`pending`, `approved`, `overridden`, `rejected`, or `auto_kept` in a [CEO-only run](#ceo-only-runs)), `gate_rev`, and once answered `chosen`, `answered_at`, `reply`, plus how the owner answered: `owner_mode` (`rec`, `rec_except`, `tokens`, `as_is` or `unparsed`, parsed from the owner's own words), `owner_named` (his words named this item), `rec_hash` (the recommendations answered against) and `answered_role` (`human`, or `agent` relaying his words). |
| `shadow/` | private, not an artifact | [Shadow mode](#shadow-mode) only: `ceo-only.r<rev>.jsonl` (one hidden prediction per gate item, written before the answer), `r<rev>.prompt.md` and one `<call>.reply.txt` per model call, the audit `salt`, and `r<rev>.pending` while a call is in flight. Never in `export --public`, the gate render or the owner brief. |
| `findings.jsonl` | `findings` v1 | One row per reviewer finding: `id`, `phase`, `voice` (`native`/`outside`), `model`, `severity`, `title`, `disposition`, `plan_items`, `native_counterpart` (string or null), `source`. Rows share the review-log row shape, so `gstack-review-log --findings` derives review status from this file. |
| `tasks.jsonl` | `tasks` v2 | Implementation tasks with `id`, `title`, `status`, `depends_on`, `findings` (ids in `findings.jsonl`), `blocked_by`, `acceptance`, optional `pr`/`item`/`tier`. |
| `timing.json` | `timing` v1 | Per-phase `started_at`, `ended_at`, `wall_s`, `outside_s`, `native_s`; `started_at` and `total_wall_s` for the run; the Phase 0 `estimate`. |
@@ -617,6 +618,89 @@ approves, so `gstack-artifact validate` accepts the run as it stands.
does: `budget` then `resume`, or `close --phase ceo --deferred`, which ends
it `reviewed`.
### Shadow mode
Shadow mode measures how often a model decider would pick what the owner
picks, before the CEO-only mode is allowed to decide for him. In a driver run
that turned it on, every gate the driver publishes gets one hidden prediction
per question, written before anyone answers: `action` (`decide`,
`send_back` or `hold`), `pick` (one of that item's options), `confidence`
(0 to 1), `cites` (finding ids from that item) and `reason` (at most 400
characters). The owner still gets the gate and answers it as usual; nothing
routes differently. Taste-replay round 2 (R2, in gstack-evals) compares the
predictions with his answers.
- **Off by default.** A crew turns it on with `gstack-config set
autoplan_shadow on`; `GSTACK_AUTOPLAN_SHADOW=1` or `0` overrides it for one
run. It is read once, at the run's first `next`, and stored in `run.json`
(`shadow`), so a resumed run cannot flip it. The first `next` prints
`SHADOW: on model=<m> ...`, or `SHADOW: off (<reason>)` when it was asked for
but cannot run: no API key for the model's family, a model with no price in
`lib/pricing.ts`, `--no-outside`, or a CEO-only run. R2's collection window
starts at the first recorded row on a crew gate, not at this release: check
one `SHADOW: recorded` line on a real gate before counting days.
- **The model and the cost.** `autoplan_shadow_model` (default
`claude-opus-5-5`) gets one call per gate: about $0.07 on your own API key.
The call sends the reviewed plan's Implementation plan section (cut to 40,000
characters) and the gate questions to that provider, with an egress receipt
of sink `autoplan-shadow` and consent `autoplan_shadow on`. Each call is
reserved against `--spend-cap` under its own ledger id labeled `shadow` and
shows as `shadow=<usd>` on `GATE_SPEND`; it never changes the per-voice price
on `GATE_REC_COST`. When the cap cannot cover a call and its retry, the
shadow is skipped (`SHADOW: failed budget`).
- **The decider.** `autoplan/shadow-decider.md` is a frozen, hand-written
prediction prompt (it is not generated from the CEO-only section). It shows
the reviewers' recommendation as one labeled input and gives the six decision
principles as the owner's tendencies, not as rules, so the model can
disagree with the recommendation. Its sha256 and version are on every row;
an edit bumps both.
- **The reply.** Exactly one JSON object per question. A duplicate, missing
or invalid object marks that item malformed and the page is asked once more;
items still malformed are recorded malformed with no pick. A reply cut at the
4,000-token output limit is asked once more at 8,000 instead. One 90-second
deadline covers both calls.
- **When it runs.** Every driver command that prints a gate (`next`,
`submit`, `close`, `resume`, `revise`, `run` and `check`) runs the
shadow first, prints `SHADOW: recorded model=<m> usd=<x>` or `SHADOW: failed
<code>` (status only, never a pick), then the gate, then checkpoints, so a
checkpoint taken at a gate holds its record. A whole-gate carry asks nothing,
so it gets no shadow. Interactive `/autoplan` and CEO-only runs write none.
A shadow failure (`budget`, `no_key`, `timeout`, `http`, `network`,
`truncated`, `unsafe_dir`, `locked`) never blocks the gate or the answer.
- **The lease.** The run lock is not held during the call: the command takes
a lease (`shadow/r<rev>.pending`, with its pid, host and deadline) under the
lock, calls the model, then retakes the lock (waiting up to 30 seconds) to
publish. Another writer that finds a live lease prints `SHADOW: pending` and
pays nothing, and `answer` waits for the record. A lease whose process died
or whose deadline passed is a crash: its sent calls settle `unknown` at
their reservation (they may have been billed), and `answer` records that
revision first with every row `retrospective: true`, which R2 excludes from
prospective evidence.
- **The record.** `shadow/ceo-only.r<rev>.jsonl`, one row per gate item: the
frozen item, `item_class` (`challenge2`, `challenge1`, `disagree`, `agent`,
`cap_deferred`, `cap_blocker`, `cap_delta`, `p1`), `ceo_eligible`, the
prediction, `rule_pick` (the driver's keep-direction pick, the free
baseline), `rec_hash`, the plan and prompt hashes, the model, the gstack
version, the page's tokens and cost, and `audit`. It is written once per
revision with a link, so a second writer never overwrites it, and its sha256
is journaled (`shadow_recorded`). `gstack-artifact validate <run.json>`
checks each record against that hash and its schema.
- **The 10% audit mark.** A `decide` row carries `audit: true` when
sha256(salt + decision id) mod 1000 < 100, with the salt drawn once per run
into `shadow/salt`. Nothing routes on it yet; the live switch that lets the
mode decide alone will send these items to the owner.
- **The reader.** `gstack-autoplan shadow --out <dir> [--json]`, or `--runs
<file>` with one run directory per line, exports one row per item of each
answered gate revision: the shadow status (`recorded`, `malformed`,
`failed` with its code, `missing`, `retrospective`, or `off` when the run
never turned it on), the prediction, and the owner's answer with its
provenance. It prints counts by status, item class, `owner_mode` and
`ceo_eligible` and computes no agreement number. A revision whose plan
approval is unanswered or answered `c` (interrogate) refuses
`SHADOW_BEFORE_ANSWER`, and a CEO-only run `CEO_ONLY_NO_GATE`. The refusal is
a courtesy for honest tools, not access control: the journaled hash is the
tamper evidence.
## Owner brief
`gstack-owner-brief render --out <dir>` is what the owner sees at the final
+122
View File
@@ -0,0 +1,122 @@
/**
* autoplan-shadow-reader — the read side of shadow mode (lib/autoplan-shadow.ts,
* docs/unattended.md#shadow-mode). `exportShadow` is `gstack-autoplan shadow
* --out <dir> | --runs <file>`, the export gstack-evals reads for taste-replay
* R2: one row per item of each answered gate revision, with the shadow's
* status (recorded, malformed, failed with its code, missing, retrospective, or
* off when the run never turned shadow on), the shadow fields when present, and
* the owner's answer with its provenance (owner_mode, owner_named, carried,
* answered_role, rec_hash at record and at answer). It computes no agreement
* number, only counts. A revision whose plan approval is unanswered or answered
* c (interrogate) refuses SHADOW_BEFORE_ANSWER (a courtesy for honest tools,
* not access control: the journaled sha256 is the tamper evidence), and a
* CEO-only run CEO_ONLY_NO_GATE. Read-only. `validateShadow` is the private
* check `gstack-artifact validate <run.json>` adds: each record's schema and
* its journaled sha256.
*/
import { createHash } from 'node:crypto';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { previousGate } from './autoplan-gate';
import { readDecisions, readJournal, readState } from './autoplan-state';
import type { ResultCodeName } from './result-codes';
import type { ValidationError } from './headless-artifacts';
import { RECORD_RE, SHADOW_ACTIONS, recordFile, shadowDir } from './autoplan-shadow';
export type ShadowStatus = 'recorded' | 'malformed' | 'failed' | 'missing' | 'retrospective' | 'off';
export interface ShadowExportRow {
run: string; gate_rev: number; item: string; decision: string; item_class: string | null; ceo_eligible: boolean; status: ShadowStatus; code?: string;
shadow: Record<string, unknown> | null;
owner: { chosen: string | null; status: string; owner_mode: string | null; owner_named: boolean | null; carried: boolean; carried_from: string | null; answered_role: string | null; answered_by: string | null; rec_hash_at_answer: string | null; rec_hash_at_record: string | null };
}
export interface ShadowExport { rows: ShadowExportRow[]; counts: Record<string, Record<string, number>>; refused: Array<{ run: string; gate_rev?: number; code: ResultCodeName; detail: string }> }
const readRows = (file: string) => fs.readFileSync(file, 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l) as Record<string, any>);
/** Every answered revision of every run, deduped by run, gate_rev and item. */
export function exportShadow(dirs: string[]): ShadowExport {
const rows = new Map<string, ShadowExportRow>();
const refused: ShadowExport['refused'] = [];
for (const dir of dirs) {
let state;
try { state = readState(dir); } catch (e: any) { refused.push({ run: dir, code: e.code ?? 'RUN_NOT_INITIALIZED', detail: `${dir}: ${e.detail ?? e.message}` }); continue; }
if (state.ceo_only) { refused.push({ run: state.run, code: 'CEO_ONLY_NO_GATE', detail: `${dir} is CEO-only run ${state.run}: no gate, nothing answered` }); continue; }
const decisions = readDecisions(dir).filter(r => r.gate_rev >= 1);
const journal = readJournal(dir);
for (const rev of [...new Set(decisions.map(r => r.gate_rev))].sort((a, b) => a - b)) {
const revRows = decisions.filter(r => r.gate_rev === rev);
const p1 = revRows.find(r => (r.label ?? '').toLowerCase() === 'p1');
if (!p1?.chosen || p1.status === 'pending' || p1.chosen === 'c') {
refused.push({ run: state.run, gate_rev: rev, code: 'SHADOW_BEFORE_ANSWER', detail: `${dir} gate_rev ${rev}: the plan approval is ${p1?.chosen === 'c' ? 'answered c (interrogate)' : 'unanswered'}; the export waits for the owner` });
continue;
}
const record = fs.existsSync(recordFile(dir, rev)) ? new Map(readRows(recordFile(dir, rev)).map(r => [String(r.item), r])) : undefined;
const failed = journal.find(r => r.event === 'shadow_failed' && r.gate_rev === rev);
const gate = previousGate(dir, rev);
for (const d of revRows) {
const item = (d.label ?? d.id).toLowerCase();
const s = record?.get(item);
const status: ShadowStatus = s ? (s.retrospective ? 'retrospective' : s.status) : failed ? 'failed' : state.shadow?.enabled ? 'missing' : 'off';
const gateItem = gate?.items.find(i => i.id === item);
const row: ShadowExportRow = {
run: state.run, gate_rev: rev, item, decision: d.id, item_class: s?.item_class ?? gateItem?.item_class ?? null, ceo_eligible: s?.ceo_eligible ?? (item === 'p1' || d.phase === 'ceo'), status,
...(status === 'failed' ? { code: String(failed!.code) } : {}),
shadow: s ? Object.fromEntries(['action', 'pick', 'confidence', 'cites', 'reason', 'malformed', 'rule_pick', 'audit', 'model', 'prompt_version', 'prompt_sha256', 'rec_hash', 'rec_rev', 'recorded_at', 'retrospective'].filter(k => k in s).map(k => [k, s[k]])) : null,
owner: { chosen: d.chosen ?? null, status: d.status, owner_mode: d.owner_mode ?? null, owner_named: d.owner_named ?? null, carried: !!(d.carried_from || d.carried_gate_from), carried_from: d.carried_from ?? d.carried_gate_from ?? null,
answered_role: d.answered_role ?? null, answered_by: d.answered_by ?? null, rec_hash_at_answer: d.rec_hash ?? null, rec_hash_at_record: s?.rec_hash ?? null },
};
rows.set(`${row.run}|${rev}|${item}`, row);
}
}
}
const all = [...rows.values()];
const count = (key: (r: ShadowExportRow) => string) => all.reduce<Record<string, number>>((c, r) => { const k = key(r); c[k] = (c[k] ?? 0) + 1; return c; }, {});
return { rows: all, refused, counts: { status: count(r => r.status), item_class: count(r => r.item_class ?? 'unknown'), owner_mode: count(r => r.owner.owner_mode ?? 'none'), ceo_eligible: count(r => String(r.ceo_eligible)) } };
}
export function renderShadowExport(r: ShadowExport): string[] {
const counts = Object.entries(r.counts).map(([k, c]) => `SHADOW_COUNTS: ${k} ${Object.entries(c).map(([v, n]) => `${v}=${n}`).join(' ') || 'none'}`);
return [...r.rows.map(row => `SHADOW_ROW: ${JSON.stringify(row)}`), ...counts, `SHADOW_EXPORT: rows=${r.rows.length} refused=${r.refused.length} (no agreement number: gstack-evals computes it)`];
}
/** The private check behind gstack-artifact validate: each shadow record matches its journaled sha256 and its rows the schema. */
export function validateShadow(runDir: string): ValidationError[] {
const dir = shadowDir(runDir);
let st: fs.Stats;
try { st = fs.lstatSync(dir); } catch { return []; }
if (!st.isDirectory()) return [{ code: 'ARTIFACT_PATH_ESCAPE', path: 'shadow', message: 'the shadow folder is a symlink or not a directory' }];
const errors: ValidationError[] = [];
const journal = readJournal(runDir);
for (const name of fs.readdirSync(dir)) {
const m = RECORD_RE.exec(name);
if (!m) continue;
const rev = Number(m[1]);
const rel = `shadow/${name}`;
const bytes = fs.readFileSync(path.join(dir, name));
const recorded = journal.filter(r => r.event === 'shadow_recorded' && r.gate_rev === rev).at(-1);
const sha = createHash('sha256').update(bytes).digest('hex');
if (!recorded) errors.push({ code: 'ARTIFACT_STALE', path: rel, message: `no shadow_recorded journal row names gate_rev ${rev}` });
else if (recorded.sha256 !== sha) errors.push({ code: 'ARTIFACT_STALE', path: rel, message: `sha256 ${sha.slice(0, 12)} is not the journaled ${String(recorded.sha256).slice(0, 12)}` });
bytes.toString('utf8').split('\n').filter(Boolean).forEach((line, i) => {
let row: Record<string, any>;
try { row = JSON.parse(line); } catch { errors.push({ code: 'ARTIFACT_SCHEMA', path: `${rel}:${i + 1}`, message: 'not JSON' }); return; }
const problem = rowProblem(row, rev);
if (problem) errors.push({ code: 'ARTIFACT_SCHEMA', path: `${rel}:${i + 1}`, message: problem });
});
}
return errors;
}
function rowProblem(r: Record<string, any>, rev: number): string | undefined {
if (r.schema_version !== 1 || r.gate_rev !== rev) return `schema_version 1 and gate_rev ${rev} expected`;
for (const k of ['run', 'decision', 'item', 'model', 'prompt_sha256', 'recorded_at']) if (typeof r[k] !== 'string' || !r[k]) return `${k} must be a non-empty string`;
if (typeof r.retrospective !== 'boolean' || typeof r.audit !== 'boolean' || typeof r.ceo_eligible !== 'boolean') return 'retrospective, audit and ceo_eligible must be booleans';
if (r.status === 'malformed') return r.pick === null && !r.audit ? undefined : 'a malformed row carries no pick and no audit mark';
if (r.status !== 'recorded') return `status ${JSON.stringify(r.status)} is not recorded or malformed`;
if (!SHADOW_ACTIONS.includes(r.action)) return 'action must be decide, send_back or hold';
if (typeof r.pick !== 'string' || !Array.isArray(r.item_frozen?.options) || !r.item_frozen.options.some((o: { key?: string }) => o.key === r.pick)) return 'pick must be one of the frozen item\'s options';
if (typeof r.confidence !== 'number' || !(r.confidence >= 0 && r.confidence <= 1)) return 'confidence must be a number from 0 to 1';
if (!Array.isArray(r.cites) || typeof r.reason !== 'string' || r.reason.length > 400) return 'cites must be a list and reason a string of at most 400 characters';
if (r.audit && r.action !== 'decide') return 'only a decide row carries the audit mark';
return undefined;
}
+7 -1
View File
@@ -1,6 +1,7 @@
/**
* Result codes for the CEO-only driver run (`gstack-autoplan next --ceo-only`,
* lib/autoplan-ceo-only.ts). Kept beside lib/result-codes.ts, which spreads
* lib/autoplan-ceo-only.ts) and its shadow-mode reader (`gstack-autoplan shadow`,
* lib/autoplan-shadow-reader.ts). Kept beside lib/result-codes.ts, which spreads
* this table into RESULT_CODES; the same rules apply: one stable
* docs/troubleshooting.md anchor, a summary and a fix clause each. The run is
* documented in docs/unattended.md#ceo-only-runs.
@@ -23,4 +24,9 @@ export const AUTOPLAN_RESULT_CODES = {
summary: 'answer, recommend, revise and approvals import need a gate or an approvable run, and a CEO-only run has neither: it ends status=reviewed and is never approved',
fix: 'read <run dir>/ceo-review.md; to get a gate and an approval, start a full review with gstack-autoplan next --out <new dir> --plan <file> (no --ceo-only)',
},
SHADOW_BEFORE_ANSWER: {
anchor: 'shadow-before-answer',
summary: 'gstack-autoplan shadow skipped a gate revision whose plan approval is unanswered or answered c (interrogate): the export pairs predictions with answers, so it waits for the owner',
fix: 'answer the gate (gstack-autoplan answer --gate-rev <n> ...), then run gstack-autoplan shadow again; answered revisions of the same run still export',
},
} as const satisfies Record<string, ResultCode>;
+402
View File
@@ -0,0 +1,402 @@
/**
* GST-537 PR 3, shadow mode through the driver (bin/gstack-autoplan,
* lib/autoplan-shadow.ts): every gate writer records the shadow before it
* prints GATE, the lock is not held during the call, a live lease makes a
* second writer pay nothing and answer wait, a crashed call settles unknown
* (never released) and answer records the revision retrospectively, the
* checkpoint taken at the gate holds the record, owner provenance comes from
* the owner's own words, and the reader exports answered revisions only.
* Model calls go to test/fixtures/shadow-fake-model.ts: never a real network call.
*/
import { afterAll, describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { releaseUndispatched } from '../lib/autoplan-budget';
import { readState } from '../lib/autoplan-state';
import { readLedger } from '../lib/spend-ledger';
import { AUTOPLAN, ROOT, SMALL_PLAN, env as baseEnv, journal, readRun, review } from './helpers/autoplan-run-fixture';
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'ap-shadow-drv-'));
const FAKE = path.join(ROOT, 'test', 'fixtures', 'shadow-fake-model.ts');
const servers: Array<{ kill: (sig?: number) => void }> = [];
afterAll(() => { for (const s of servers) s.kill(); fs.rmSync(TMP, { recursive: true, force: true }); });
const rows = (file: string) => (fs.existsSync(file) ? fs.readFileSync(file, 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l)) : []);
const record = (out: string, rev = 1) => path.join(out, 'shadow', `ceo-only.r${rev}.jsonl`);
const events = (out: string) => journal(out).map(r => r.event);
interface Fake { env: Record<string, string>; requests: () => any[]; receipts: () => any[] }
async function fake(name: string, script: Array<Record<string, unknown>>, extra: Record<string, string> = {}): Promise<Fake> {
const dir = path.join(TMP, `fake-${name}`);
fs.mkdirSync(path.join(dir, 'state'), { recursive: true });
fs.writeFileSync(path.join(dir, 'script.json'), JSON.stringify(script));
servers.push(Bun.spawn(['bun', FAKE, dir], { stdout: 'ignore', stderr: 'inherit' }));
for (let i = 0; i < 200 && !fs.existsSync(path.join(dir, 'port')); i++) await Bun.sleep(25);
const port = fs.readFileSync(path.join(dir, 'port'), 'utf8');
const env = baseEnv({ GSTACK_STATE_ROOT: path.join(dir, 'state'), GSTACK_CAPY: '', GSTACK_AUTOPLAN_SHADOW: '1', ANTHROPIC_API_KEY: 'sk-fake-anthropic', OPENAI_API_KEY: 'sk-fake-openai',
ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}`, OPENAI_BASE_URL: `http://127.0.0.1:${port}/v1`, ...extra });
return { env, requests: () => rows(path.join(dir, 'requests.jsonl')), receipts: () => rows(path.join(dir, 'state', 'security', 'egress.jsonl')) };
}
const ap = (args: string[], e: Record<string, string>) => spawnSync('bun', [AUTOPLAN, ...args], { cwd: ROOT, env: e, encoding: 'utf8', timeout: 120_000 });
const apAsync = (args: string[], e: Record<string, string>) => Bun.spawn(['bun', AUTOPLAN, ...args], { cwd: ROOT, env: e, stdout: 'pipe', stderr: 'pipe' });
function ok(r: { status: number | null; stdout: string; stderr: string }, what: string) { if (r.status !== 0) throw new Error(`${what} failed (${r.status}): ${r.stderr}\n${r.stdout}`); return r; }
function start(name: string, e: Record<string, string>, extra: string[] = []) {
const dir = path.join(TMP, name);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'plan.md'), SMALL_PLAN);
const out = path.join(dir, 'run');
const first = ok(ap(['next', '--out', out, '--plan', path.join(dir, 'plan.md'), '--no-ui', ...extra], e), 'first next');
return { out, first };
}
const attempt = (out: string, phase: string, voice: string) => readRun(out).phases[phase][voice];
const resultFile = (out: string, phase: string, voice: string) => attempt(out, phase, voice).attempts.at(-1).result;
/** next, both fixture reviews, the native submit; returns the outside submit's argv so a test can run it its own way. */
function toOutsideSubmit(out: string, phase: string, e: Record<string, string>): string[] {
ok(ap(['next', '--out', out], e), `next ${phase}`);
for (const voice of ['native', 'outside']) ok(review(path.join(out, `${phase}-${voice}-prompt.md`)), `review ${voice}`);
ok(ap(['submit', '--out', out, '--phase', phase, '--voice', 'native', '--result', resultFile(out, phase, 'native'), '--model', 'anthropic/claude-opus-4-7', '--attempt', attempt(out, phase, 'native').attempt, '--runner', 'host-subagent'], e), 'submit native');
return ['submit', '--out', out, '--phase', phase, '--voice', 'outside', '--result', resultFile(out, phase, 'outside'), '--model', 'openai/gpt-6-astra', '--attempt', attempt(out, phase, 'outside').attempt, '--runner', 'host-subagent'];
}
const phase = (out: string, name: string, e: Record<string, string>) => ok(ap(toOutsideSubmit(out, name, e), e), `submit outside ${name}`);
const lineIndex = (stdout: string, prefix: string) => stdout.split('\n').findIndex(l => l.startsWith(prefix));
const exported = (args: string[], e: Record<string, string>) => ap(['shadow', ...args, '--json'], e);
describe('submit records the shadow before GATE; the record stays private and checkpointed', async () => {
const f = await fake('main', [{ mode: 'valid' }]);
const ckpt = path.join(TMP, 'ckpt-main');
const e = { ...f.env, GSTACK_AUTOPLAN_CHECKPOINT_DIR: ckpt };
const { out, first } = start('main', e);
phase(out, 'ceo', e);
const gate = phase(out, 'eng', e);
const shadowRows = rows(record(out));
test('init prints SHADOW: on and stores the switch in run.json and the init row', () => {
expect(first.stdout).toContain('SHADOW: on model=claude-opus-5-5 (GSTACK_AUTOPLAN_SHADOW=1)');
expect(readRun(out).shadow).toEqual({ enabled: true, model: 'claude-opus-5-5', source: 'env' });
expect(journal(out).find(r => r.event === 'init').shadow.enabled).toBe(true);
});
test('SHADOW: recorded prints before GATE, and GATE_SPEND shows the shadow spend', () => {
expect(lineIndex(gate.stdout, 'SHADOW: recorded model=claude-opus-5-5 usd=0.0140')).toBeGreaterThan(-1);
expect(lineIndex(gate.stdout, 'SHADOW: ')).toBeLessThan(lineIndex(gate.stdout, 'GATE: gate_rev=1'));
expect(gate.stdout).toMatch(/GATE_SPEND: .* attempts=4 shadow=0\.0140/);
const ev = events(out);
expect(ev.indexOf('gate_written')).toBeLessThan(ev.indexOf('shadow_pending'));
expect(ev.indexOf('shadow_pending')).toBeLessThan(ev.indexOf('shadow_dispatched'));
expect(ev.indexOf('shadow_dispatched')).toBeLessThan(ev.indexOf('shadow_recorded'));
expect(f.requests().length).toBe(1);
});
test('one row per item with the frozen item, the rule pick, eligibility, class and hashes', () => {
expect(shadowRows.map(r => [r.item, r.item_class, r.ceo_eligible, r.rule_pick, r.status, r.retrospective])).toEqual([
['uc1', 'challenge2', true, 'a', 'recorded', false], ['uc2', 'challenge2', false, 'a', 'recorded', false], ['p1', 'p1', true, 'a', 'recorded', false]]);
expect(shadowRows[0].decision).toBe(`${readRun(out).run}-uc1`);
expect(shadowRows[0].item_frozen.options.map((o: { key: string }) => o.key)).toEqual(['a', 'b']);
expect(typeof shadowRows[0].audit).toBe('boolean');
});
test('exactly one egress receipt per send, with the shadow sink and consent', () => {
expect(f.receipts().map(r => [r.sink, r.consent])).toEqual([['autoplan-shadow', 'autoplan_shadow on']]);
});
test('the checkpoint taken at the gate holds the shadow record; a restore keeps it byte for byte', () => {
const collection = path.join(ckpt, readRun(out).run);
const current = JSON.parse(fs.readFileSync(path.join(collection, 'current.json'), 'utf8'));
const gen = path.join(collection, 'generations', current.generation, 'run', 'shadow', 'ceo-only.r1.jsonl');
expect(fs.readFileSync(gen).equals(fs.readFileSync(record(out)))).toBe(true);
const restored = path.join(TMP, 'main-restored');
ok(ap(['resume', '--out', restored, '--from', collection], e), 'resume --from');
expect(fs.readFileSync(record(restored)).equals(fs.readFileSync(record(out)))).toBe(true);
expect(f.requests().length).toBe(1);
});
test('the gate render, owner brief, decisions and public export carry no pick, reason or salt', () => {
const salt = fs.readFileSync(path.join(out, 'shadow', 'salt'), 'utf8').trim();
const render = spawnSync('bun', [path.join(ROOT, 'bin', 'gstack-gate'), 'render', path.join(out, 'gate.json'), '--all'], { cwd: ROOT, env: e, encoding: 'utf8', timeout: 60_000 });
const brief = spawnSync('bun', [path.join(ROOT, 'bin', 'gstack-owner-brief'), 'render', '--out', out], { cwd: ROOT, env: e, encoding: 'utf8', timeout: 60_000 });
expect(render.status).toBe(0);
for (const text of [render.stdout, brief.stdout, fs.readFileSync(path.join(out, 'decisions.jsonl'), 'utf8'), fs.readFileSync(path.join(out, 'gate.json'), 'utf8')]) {
expect(text).not.toContain('fake prediction');
expect(text).not.toContain('confidence');
expect(text).not.toContain(salt);
}
const pub = path.join(TMP, 'main-public');
ok(ap(['export', '--out', out, '--public', pub], e), 'export --public');
const all = fs.readdirSync(pub, { recursive: true }).map(String);
expect(all.some(p => p.includes('shadow'))).toBe(false);
for (const p of all) if (fs.statSync(path.join(pub, p)).isFile()) expect(fs.readFileSync(path.join(pub, p), 'utf8')).not.toContain(salt);
expect(readRun(out).salt).toBeUndefined();
});
test('gstack-artifact validate checks the record against its journaled sha256', () => {
const validate = () => spawnSync('bun', [path.join(ROOT, 'bin', 'gstack-artifact'), 'validate', path.join(out, 'run.json')], { cwd: ROOT, env: e, encoding: 'utf8', timeout: 60_000 });
expect(validate().status).toBe(0);
const good = fs.readFileSync(record(out), 'utf8');
fs.writeFileSync(record(out), good.replace('"pick":"a"', '"pick":"b"'));
const bad = validate();
expect(bad.status).toBe(1);
expect(bad.stderr).toContain('(ARTIFACT_STALE)');
expect(bad.stderr).toContain('shadow/ceo-only.r1.jsonl');
fs.writeFileSync(record(out), good);
});
test('printing the gate again pays nothing', () => {
const again = ok(ap(['next', '--out', out], e), 'next again');
expect(again.stdout).toContain('GATE: gate_rev=1');
expect(again.stdout).not.toContain('SHADOW:');
expect(f.requests().length).toBe(1);
});
test('the reader refuses an unanswered revision', () => {
const r = exported(['--out', out], e);
expect(r.status).toBe(3);
expect(r.stderr).toContain('(SHADOW_BEFORE_ANSWER)');
});
test('"Do it" relayed as tokens records owner_mode rec, owner_named false, the rec_hash and the agent role', () => {
ok(ap(['answer', '--out', out, '--gate-rev', '1', '--reply', 'uc1a uc2a p1a', '--by', 'agent:capy', '--owner-reply', 'Do it'], e), 'answer');
const d = rows(path.join(out, 'decisions.jsonl'));
const gateRec = shadowRows[0].rec_hash;
expect(d.map(r => [r.label, r.owner_mode, r.owner_named, r.answered_role, r.rec_hash === gateRec, r.answered_by])).toEqual([
['UC1', 'rec', false, 'agent', true, 'agent:capy'], ['UC2', 'rec', false, 'agent', true, 'agent:capy'], ['P1', 'rec', false, 'agent', true, 'agent:capy']]);
});
test('the reader exports the answered revision with the owner provenance', () => {
const r = exported(['--out', out], e);
expect(r.status).toBe(0);
const x = JSON.parse(r.stdout);
expect(x.rows.map((row: any) => [row.item, row.status, row.shadow.pick, row.owner.chosen, row.owner.owner_mode, row.owner.rec_hash_at_answer === row.owner.rec_hash_at_record])).toEqual([
['uc1', 'recorded', 'a', 'a', 'rec', true], ['uc2', 'recorded', 'a', 'a', 'rec', true], ['p1', 'recorded', 'a', 'a', 'rec', true]]);
expect(x.counts.status).toEqual({ recorded: 3 });
expect(x.counts.ceo_eligible).toEqual({ true: 2, false: 1 });
});
});
describe('a failed shadow never blocks: the gate prints and answers; owner words name items', async () => {
const f = await fake('fail', [{ mode: 'http500' }]);
const e = f.env;
const { out } = start('fail', e);
phase(out, 'ceo', e);
const gate = phase(out, 'eng', e);
test('a 500 journals shadow_failed http, prints SHADOW: failed http, and still prints the gate', () => {
expect(gate.stdout).toContain('SHADOW: failed http');
expect(gate.stdout).toContain('GATE: gate_rev=1');
expect(journal(out).find(r => r.event === 'shadow_failed')).toMatchObject({ gate_rev: 1, code: 'http' });
expect(fs.existsSync(record(out))).toBe(false);
});
test('owner words "uc1a" record tokens and owner_named true for uc1 only', () => {
const r = ok(ap(['answer', '--out', out, '--gate-rev', '1', '--reply', 'uc1a', '--by', 'agent:capy', '--owner-reply', 'uc1a'], e), 'answer uc1a');
expect(r.stdout).toContain('GATE_PENDING:');
const uc1 = rows(path.join(out, 'decisions.jsonl')).find(r => r.label === 'UC1');
expect(uc1).toMatchObject({ owner_mode: 'tokens', owner_named: true, answered_role: 'agent' });
expect(rows(path.join(out, 'decisions.jsonl')).find(r => r.label === 'UC2').owner_mode).toBeUndefined();
});
test('an interrogated plan approval is refused by the reader', () => {
ok(ap(['answer', '--out', out, '--gate-rev', '1', '--reply', 'p1c', '--by', 'human'], e), 'answer p1c');
const r = exported(['--out', out], e);
expect(r.status).toBe(3);
expect(r.stderr).toContain('answered c (interrogate)');
});
test('an unparsed owner reply records owner_mode unparsed; the reader shows failed with its code', () => {
ok(ap(['answer', '--out', out, '--gate-rev', '1', '--reply', 'uc2a p1a', '--by', 'agent:capy', '--owner-reply', 'sounds good to me'], e), 'answer');
const d = rows(path.join(out, 'decisions.jsonl'));
expect(d.find(r => r.label === 'UC2')).toMatchObject({ owner_mode: 'unparsed', owner_named: false, owner_reply_parsed: false });
const x = JSON.parse(exported(['--out', out], e).stdout);
expect(x.rows.map((row: any) => [row.item, row.status, row.code])).toEqual([['uc1', 'failed', 'http'], ['uc2', 'failed', 'http'], ['p1', 'failed', 'http']]);
});
});
describe('the lease: the lock is free during the call, a second writer pays nothing, answer waits', async () => {
const f = await fake('lease', [{ mode: 'valid', delay_ms: 2500 }]);
const e = f.env;
const { out } = start('lease', e);
phase(out, 'ceo', e);
const writer = apAsync(toOutsideSubmit(out, 'eng', e), e);
for (let i = 0; i < 400 && !fs.existsSync(path.join(out, 'shadow', 'r1.pending')); i++) await Bun.sleep(25);
const lease = JSON.parse(fs.readFileSync(path.join(out, 'shadow', 'r1.pending'), 'utf8'));
const second = ap(['next', '--out', out], e);
const answer = apAsync(['answer', '--out', out, '--gate-rev', '1', '--reply', 'uc1a uc2a p1a', '--by', 'human'], e);
const [writerCode, answerCode] = await Promise.all([writer.exited, answer.exited]);
const writerOut = await new Response(writer.stdout).text();
test('the lease names the writer, and the run lock is free while it calls', () => {
expect(lease).toMatchObject({ gate_rev: 1, pid: writer.pid, host: os.hostname(), retrospective: false });
expect(second.status).toBe(0);
});
test('a second writer during the call prints SHADOW: pending and pays nothing; two writers pay once', () => {
expect(second.stdout).toContain('SHADOW: pending (gate_rev 1 is being recorded by pid');
expect(f.requests().length).toBe(1);
expect(writerCode).toBe(0);
expect(writerOut).toContain('SHADOW: recorded');
});
test('answer waits for the record, then answers; the record is prospective', () => {
expect(answerCode).toBe(0);
const ev = events(out);
expect(ev.indexOf('shadow_recorded')).toBeLessThan(ev.indexOf('answered'));
expect(rows(record(out)).every(r => r.retrospective === false)).toBe(true);
expect(readRun(out).status).toBe('complete');
});
});
describe('a crash mid-call: the dispatched call settles unknown and answer records the gate retrospectively', async () => {
const f = await fake('crash', [{ mode: 'valid', delay_ms: 30000 }, { mode: 'valid' }]);
const e = f.env;
const { out } = start('crash', e);
phase(out, 'ceo', e);
const writer = apAsync(toOutsideSubmit(out, 'eng', e), e);
for (let i = 0; i < 400 && !(events(out).includes('shadow_dispatched') && f.requests().length > 0); i++) await Bun.sleep(25);
writer.kill(9);
await writer.exited;
const ledgerBefore = readLedger(path.join(out, 'spend.json'));
const released = releaseUndispatched(readState(out));
const answer = ap(['answer', '--out', out, '--gate-rev', '1', '--reply', 'uc1a uc2a p1a', '--by', 'human'], e);
const ledger = readLedger(path.join(out, 'spend.json'));
test('a dispatched shadow call is never released as undispatched', () => {
expect(ledgerBefore.reserved.map(r => [r.attempt, r.label])).toEqual([['shadow-r1-1', 'shadow']]);
expect(released).toEqual([]);
});
test('answer settles the dead call unknown, records every row retrospective, then answers', () => {
expect(answer.status).toBe(0);
expect(answer.stdout).toMatch(/SHADOW: recorded model=claude-opus-5-5 usd=0\.0140 retrospective/);
expect(ledger.settled.find(s => s.attempt === 'shadow-r1-1')).toMatchObject({ usd: 'unknown', label: 'shadow' });
expect(ledger.released.some(r => r.attempt === 'shadow-r1-1')).toBe(false);
expect(journal(out).find(r => r.event === 'shadow_expired').settled_unknown).toEqual(['shadow-r1-1']);
expect(rows(record(out)).every(r => r.retrospective === true)).toBe(true);
expect(f.requests().length).toBe(2);
});
test('the reader labels those rows retrospective', () => {
const x = JSON.parse(exported(['--out', out], e).stdout);
expect(x.counts.status).toEqual({ retrospective: 3 });
});
});
describe('every gate writer records the shadow before it prints GATE', async () => {
const f = await fake('writers', [{ mode: 'valid' }]);
const off = baseEnv({ GSTACK_CAPY: '' });
/** A gate written with shadow off, then switched on: the next command that prints it records the shadow first. */
const unshadowed = (name: string) => {
const { out } = start(name, off);
phase(out, 'ceo', off); phase(out, 'eng', off);
const run = readRun(out);
run.shadow = { enabled: true, model: 'claude-opus-5-5', source: 'env' };
fs.writeFileSync(path.join(out, 'run.json'), JSON.stringify(run, null, 2) + '\n');
return out;
};
for (const verb of ['next', 'resume'] as const) test(`${verb}`, () => {
const out = unshadowed(`writer-${verb}`);
const r = ok(ap([verb, '--out', out], f.env), verb);
expect(lineIndex(r.stdout, 'SHADOW: recorded')).toBeGreaterThan(-1);
expect(lineIndex(r.stdout, 'SHADOW: recorded')).toBeLessThan(lineIndex(r.stdout, 'GATE: gate_rev=1'));
expect(fs.existsSync(record(out))).toBe(true);
});
test('close --deferred (and a malformed item records malformed)', async () => {
const g = await fake('close', [{ mode: 'bad_option' }, { mode: 'bad_option' }]);
const { out } = start('writer-close', g.env);
phase(out, 'ceo', g.env);
ok(ap(['next', '--out', out], g.env), 'next eng');
const r = ok(ap(['close', '--out', out, '--phase', 'eng', '--deferred', 'out of time', '--by', 'human'], g.env), 'close');
expect(lineIndex(r.stdout, 'SHADOW: recorded')).toBeLessThan(lineIndex(r.stdout, 'GATE: gate_rev=1'));
expect(rows(record(out)).map(x => [x.item, x.item_class, x.status])).toEqual([['uc1', 'challenge2', 'malformed'], ['d1', 'cap_deferred', 'recorded'], ['p1', 'p1', 'recorded']]);
ok(ap(['answer', '--out', out, '--gate-rev', '1', '--reply', 'rec', '--by', 'human', '--rec-hash', rows(record(out))[0].rec_hash], g.env), 'answer');
closeOut = out;
});
test('submit, revise, check and run share the same publish step (source ratchet; submit runs above)', () => {
const bin = fs.readFileSync(AUTOPLAN, 'utf8');
for (const verb of ['next', 'submit', 'revise', 'close', 'check']) expect(bin).toMatch(new RegExp(`if \\(cmd === '${verb}'\\) return await publish\\(out, json, mutating\\(`));
expect(bin).toContain('return await publish(out, json, withRunLock(out, () => {\n const r = resumeRun(out);');
const gateLineCalls = bin.split('\n').filter(l => /gateLines\((state|at), out\)/.test(l) && !l.includes('function gateLines'));
expect(gateLineCalls.length).toBe(2);
expect(gateLineCalls.every(l => l.includes('line ? [line] : []'))).toBe(true);
expect(bin).toContain("const step = withRunLock(out, () => { const r = doNext(out, rest); return r.gate ? claimed(out, r) : r; });");
});
});
let closeOut = '';
describe('the record: once per revision, a reopen writes r2, temp files and symlinks', async () => {
const f = await fake('reopen', [{ mode: 'valid' }]);
const e = f.env;
const { out } = start('reopen', e);
phase(out, 'ceo', e); phase(out, 'eng', e);
const r1 = fs.readFileSync(record(out));
ok(ap(['answer', '--out', out, '--gate-rev', '1', '--reply', 'uc1a uc2a p1d', '--by', 'human'], e), 'answer p1d');
const orphan = path.join(out, 'shadow', 'ceo-only.r2.jsonl.99999.dead.tmp');
fs.writeFileSync(orphan, 'partial');
phase(out, 'ceo', e);
const gate2 = phase(out, 'eng', e);
test('a reopen writes r2 and leaves r1 byte-identical; the orphan temp file is removed', () => {
expect(gate2.stdout).toContain('GATE: gate_rev=2');
expect(fs.readFileSync(record(out)).equals(r1)).toBe(true);
expect(rows(record(out, 2)).map(r => r.decision)).toEqual(rows(record(out, 2)).map(r => `${readRun(out).run}-r2-${r.item}`));
expect(fs.existsSync(orphan)).toBe(false);
expect(f.requests().length).toBe(2);
});
test('re-recording keeps the audit marks: the same salt and decision ids mark the same rows', () => {
const salt = fs.readFileSync(path.join(out, 'shadow', 'salt'), 'utf8').trim();
for (const r of [...rows(record(out)), ...rows(record(out, 2))]) {
const mark = BigInt('0x' + new Bun.CryptoHasher('sha256').update(salt + r.decision).digest('hex')) % 1000n < 100n;
expect(r.audit).toBe(r.action === 'decide' && mark);
}
});
test('a symlinked shadow folder is refused, nothing is written through it, and the gate still prints', async () => {
const g = await fake('symlink', [{ mode: 'valid' }]);
const { out: o2 } = start('symlink', g.env);
const target = path.join(TMP, 'symlink-target');
fs.mkdirSync(target);
fs.symlinkSync(target, path.join(o2, 'shadow'));
phase(o2, 'ceo', g.env);
const r = phase(o2, 'eng', g.env);
expect(r.stdout).toContain('SHADOW: failed unsafe_dir');
expect(r.stdout).toContain('GATE: gate_rev=1');
expect(fs.readdirSync(target)).toEqual([]);
expect(g.requests().length).toBe(0);
});
});
describe('the switch through the driver, and the spend cap', async () => {
const f = await fake('switch', [{ mode: 'valid' }]);
test('off by default: no SHADOW line, run.json says off, and no shadow folder at the gate', () => {
const e = { ...f.env, GSTACK_AUTOPLAN_SHADOW: '' };
const { out, first } = start('switch-default', e);
expect(first.stdout).not.toContain('SHADOW:');
expect(readRun(out).shadow).toEqual({ enabled: false, reason: 'off by default', source: 'default' });
phase(out, 'ceo', e);
expect(phase(out, 'eng', e).stdout).not.toContain('SHADOW:');
expect(fs.existsSync(path.join(out, 'shadow'))).toBe(false);
});
test('the env var wins over config; --no-outside gives off with its reason; resume cannot flip it', () => {
fs.writeFileSync(path.join(f.env.GSTACK_STATE_ROOT!, 'config.yaml'), 'autoplan_shadow: on\n');
const { out: offRun } = start('switch-env', { ...f.env, GSTACK_AUTOPLAN_SHADOW: '0' });
expect(readRun(offRun).shadow).toMatchObject({ enabled: false, source: 'env' });
const { out: cfgRun, first } = start('switch-config', { ...f.env, GSTACK_AUTOPLAN_SHADOW: '' });
expect(first.stdout).toContain('SHADOW: on model=claude-opus-5-5 (autoplan_shadow on)');
ok(ap(['resume', '--out', cfgRun], { ...f.env, GSTACK_AUTOPLAN_SHADOW: '0' }), 'resume');
expect(readRun(cfgRun).shadow).toMatchObject({ enabled: true, source: 'config' });
fs.rmSync(path.join(f.env.GSTACK_STATE_ROOT!, 'config.yaml'));
const { first: noOutside } = start('switch-no-outside', f.env, ['--no-outside']);
expect(noOutside.stdout).toContain('SHADOW: off (--no-outside)');
});
test('a cap that cannot cover the call journals shadow_failed budget and the gate prints', () => {
const { out } = start('switch-cap', f.env, ['--spend-cap', '0.05']);
phase(out, 'ceo', f.env);
const r = phase(out, 'eng', f.env);
expect(r.stdout).toContain('SHADOW: failed budget');
expect(r.stdout).toContain('GATE: gate_rev=1');
expect(journal(out).find(x => x.event === 'shadow_failed').code).toBe('budget');
expect(f.requests().length).toBe(0);
});
});
describe('the reader over many runs', () => {
test('missing, failed, malformed, retrospective and recorded rows; dedupe across a repeated entry; CEO_ONLY_NO_GATE', () => {
const e = baseEnv({ GSTACK_CAPY: '' });
const { out: missing } = start('reader-missing', e);
phase(missing, 'ceo', e); phase(missing, 'eng', e);
const run = readRun(missing);
run.shadow = { enabled: true, model: 'claude-opus-5-5', source: 'env' };
fs.writeFileSync(path.join(missing, 'run.json'), JSON.stringify(run, null, 2) + '\n');
ok(ap(['answer', '--out', missing, '--gate-rev', '1', '--reply', 'uc1a uc2a p1a', '--by', 'human'], e), 'answer missing');
const { out: ceoOnly } = start('reader-ceo-only', e, ['--ceo-only']);
const list = path.join(TMP, 'runs.txt');
fs.writeFileSync(list, [missing, path.join(TMP, 'fail', 'run'), path.join(TMP, 'crash', 'run'), closeOut, path.join(TMP, 'main', 'run'), path.join(TMP, 'main', 'run'), ceoOnly].join('\n') + '\n');
const r = exported(['--runs', list], e);
expect(r.status).toBe(0);
expect(r.stderr).toContain('(CEO_ONLY_NO_GATE)');
const x = JSON.parse(r.stdout);
expect(x.counts.status).toEqual({ missing: 3, failed: 3, retrospective: 3, malformed: 1, recorded: 5 });
expect(x.rows.length).toBe(15);
expect(x.counts.item_class.cap_deferred).toBe(1);
const text = ap(['shadow', '--runs', list], e);
expect(text.stdout).toContain('SHADOW_EXPORT: rows=15 refused=1');
});
});
+1
View File
@@ -105,6 +105,7 @@ describe('the switch is off by default and fixed at init', () => {
const out = path.join(TMP, 'no-field');
fs.cpSync(BASE, out, { recursive: true });
const run = readRun(out);
expect(run.shadow).toEqual({ enabled: false, reason: 'off by default', source: 'default' });
delete run.shadow;
fs.writeFileSync(path.join(out, 'run.json'), JSON.stringify(run, null, 2) + '\n');
expect(claimShadow(out)).toEqual({});
+1
View File
@@ -129,6 +129,7 @@
{ "file": "lib/autoplan-gate.ts", "movedFrom": "(new: phase close, gate build, answer and reopen, B2/B9)" },
{ "file": "lib/autoplan-ceo-only.ts", "movedFrom": "(new: the CEO-only driver run's finish, report and refusals, GST-537)" },
{ "file": "lib/autoplan-shadow.ts", "movedFrom": "(new: shadow mode for the CEO-only decider: switch, lease, call and record, GST-537 PR 3)" },
{ "file": "lib/autoplan-shadow-reader.ts", "movedFrom": "(new: the shadow-mode export for taste-replay R2 and its private validate check, GST-537 PR 3)" },
{ "file": "lib/autoplan-export.ts", "movedFrom": "(new: the unattended export file set, B2/B12)" },
{ "file": "lib/spec-review-cap.ts", "movedFrom": "(new: the spec-review loop at the cap: numbered gaps and deterministic fix verification, B8)" },
{ "file": "bin/gstack-autoplan", "movedFrom": "(new: thin entrypoint over lib/autoplan-run.ts, B2)" },