mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-03 07:18:03 +01:00
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:
1 parent
0e179ea4ea
commit
aec80b10a9
11 files changed
+769
-64
No files matched your search
+4
-1
@@ -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
@@ -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
@@ -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 \
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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)" },
|
||||
|
||||
Reference in new issue
Block a user