Merge commit '16db02a13f143cee232a0abb03ca7e911b41e69f' into HEAD

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

# Conflicts:
#	bin/gstack-config
#	bin/gstack-doctor-components.sh
This commit is contained in:
garrytan committed 2026-11-02 08:30:29 +00:00
commit b3b2e799d5
26 files changed
+1774 -113

No files matched your search

+76
View File
@@ -0,0 +1,76 @@
<!-- gstack autoplan shadow decider, prompt version 1. Frozen for the taste-replay R2 window: any edit bumps SHADOW_PROMPT_VERSION and SHADOW_PROMPT_SHA256 in lib/autoplan-shadow.ts. -->
# Predict the owner's answer at an autoplan approval gate
You are predicting how one specific person, the owner of this plan, will answer
the questions at the final approval gate of an autoplan review. You are not
deciding for him and nothing you write is shown to him. Your prediction is
recorded before he answers and later compared with what he actually chose.
Be a forecaster, not an advocate. A good prediction is one that turns out to
match his answer. Do not argue for what you think is best for the plan.
## What you have
- The plan's Implementation plan section, as reviewed.
- Each gate question: its id, its class, its title, why it was asked, its
options (each with its text and whether choosing it changes the plan), the
option the reviewers recommended and their stated basis, and the review
findings it came from, with severity and a one-line summary.
The reviewers' recommendation is one input among several. It is often right
and sometimes wrong. Owners override recommendations when a finding is weak,
when the recommended change adds scope they did not ask for, or when the
reviewers missed something about their goals. Weigh it; do not copy it.
## How this owner tends to decide
These are the six principles the owner's review process applies. Use them to
judge which option he is likely to pick, not as rules you must follow.
1. Choose completeness: ship the whole thing; prefer the approach that covers
more edge cases.
2. Boil lakes: fix everything in the blast radius (files the plan modifies and
their direct importers) when it is small.
3. Pragmatic: when two options fix the same thing, pick the cleaner one, fast.
4. DRY: reject what duplicates existing functionality; reuse what exists.
5. Explicit over clever: a short obvious fix beats a large abstraction.
6. Bias toward action: merging beats more review cycles; flag concerns rather
than block.
## For each question, predict
- action: what the owner would do with it.
- `decide`: he answers it now by picking one option.
- `send_back`: he sends the plan back for more work before answering.
- `hold`: he leaves it open or asks questions first.
- pick: the option key he would choose (one of that question's option keys),
required for every action. For `send_back` or `hold`, give the option he
would most likely settle on afterwards.
- confidence: a number from 0 to 1, the probability that he picks exactly that
option.
- cites: the finding ids from that question's findings that most shaped your
prediction (an empty list when none did). Cite only ids listed for that
question.
- reason: one or two sentences, at most 400 characters.
## The plan
{{PLAN}}
## The questions
{{QUESTIONS}}
## Output contract
Reply with exactly one fenced `json` block holding one JSON array and nothing
else. The array has exactly one object per question id listed below, in any
order, with no duplicates and no other ids:
{{IDS}}
Each object has exactly these fields:
```json
{"id": "<question id>", "action": "decide | send_back | hold", "pick": "<option key>", "confidence": 0.0, "cites": ["<finding id>"], "reason": "<at most 400 characters>"}
```
+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 {
+114 -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),
@@ -54,6 +55,8 @@ import { estimateCostUsd, pricingRow } from '../lib/pricing';
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()}
@@ -100,6 +103,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
@@ -143,7 +154,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, () => {
@@ -160,13 +171,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;
@@ -185,7 +231,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, price?: NextOptions['price']): number {
function doNext(out: string, rest: string[], price?: NextOptions['price']): Report {
const cwd = process.cwd();
const initLines: string[] = [];
const fresh = !fs.existsSync(path.join(out, FILES.run));
@@ -199,20 +245,18 @@ function doNext(out: string, rest: string[], json: boolean, price?: NextOptions[
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, price });
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). */
@@ -233,7 +277,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');
@@ -245,28 +289,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);
@@ -278,25 +316,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 {
@@ -333,13 +368,18 @@ async function doRun(out: string, rest: string[], json: boolean): Promise<number
// refused before any request left this machine bills nothing, so its attempt stays open for the rerun.
const NOT_SENT = new Set(['runner_unavailable', 'cli_missing', 'model_unselected']);
for (let guard = 0; guard < 40; guard++) {
const code = withRunLock(out, () => doNext(out, rest, true, price));
if (code !== EXIT.ok) return code;
const step = withRunLock(out, () => { const r = doNext(out, rest, price); 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)!;
const lost = (['native', 'outside'] as const).flatMap(voice => (state.phases[phase]![voice]!.attempts ?? []).filter(a => a.state === 'dispatched' && a.sent_at).map(a => [voice, a.attempt] as const));
if (lost.length) { withRunLock(out, () => { for (const [voice, attempt] of lost) retireAttempt(out, phase as Phase, voice, attempt, 'unknown'); }); continue; }
@@ -369,7 +409,10 @@ async function doRun(out: string, rest: string[], json: boolean): Promise<number
}
const usd = usdOf(voice, res.usage);
const usage = usd === undefined ? [] : ['--usage-usd', String(usd)];
try { withRunLock(out, () => doSubmit(out, ['--phase', phase, '--voice', voice, '--result', `${open.result}.raw`, '--model', res.model ?? modelOf(voice), '--attempt', open.attempt, '--runner', res.runner, ...usage], true)); }
try {
const bound = withRunLock(out, () => doSubmit(out, ['--phase', phase, '--voice', voice, '--result', `${open.result}.raw`, '--model', res.model ?? modelOf(voice), '--attempt', open.attempt, '--runner', res.runner, ...usage]));
if (typeof bound !== 'number') emit(true, [], bound.payload);
}
catch (e) {
if (voice !== 'outside' || !(e instanceof RunError)) { withRunLock(out, () => { retire(); const s = readState(out); s.status = 'interrupted'; writeState(s); }); throw e; }
withRunLock(out, () => markOutside(out, phase as Phase, 'unavailable', `${res.runner} result refused: ${e.code}`));
@@ -379,6 +422,17 @@ async function doRun(out: string, rest: string[], json: boolean): Promise<number
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');
@@ -391,15 +445,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);
@@ -409,13 +464,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');
@@ -426,7 +479,9 @@ async function main(argv: string[]): Promise<number> {
const wordsBy = flag(rest, '--words-by') ?? 'owner';
if (wordsBy !== 'owner' && (wordsBy !== by || !by.startsWith('agent:'))) return usage(`--words-by must be owner or the answering agent (--by ${by ?? '<unset>'}), got ${wordsBy}`);
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, ...(wordsBy === 'owner' ? {} : { wordsBy }) });
@@ -436,19 +491,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, () => {
@@ -459,7 +514,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);
@@ -468,7 +523,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
@@ -229,6 +229,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.
@@ -318,6 +329,8 @@ lookup_default() {
plan_mode_preamble) echo "on" ;; # on | off — off drops the plan-mode preamble on the next ./setup (#3168)
claude_loadout) echo "full" ;; # a modules/loadouts.json name — written by ./setup --loadout (docs/loadouts.md)
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" ;;
@@ -979,6 +992,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.
@@ -1111,7 +1130,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 claude_loadout \
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 \
@@ -1136,7 +1155,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 claude_loadout \
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
@@ -392,6 +392,7 @@ gstack_result_anchor() {
GSTACK_CLEAN_TARGET_REFUSED) printf '%s' 'gstack-clean-target-refused' ;;
GSTACK_CLEAN_PARTIAL) printf '%s' 'gstack-clean-partial' ;;
GSTACK_CLEAN_INTERNAL) printf '%s' 'gstack-clean-internal' ;;
SHADOW_BEFORE_ANSWER) printf '%s' 'shadow-before-answer' ;;
*) return 1 ;;
esac
}
+12
View File
@@ -0,0 +1,12 @@
**Shadow mode for the CEO-only decider: in a driver run that turns it on, every approval gate gets one hidden model prediction per question, recorded before the owner answers (GST-537).** Before the CEO-only mode is allowed to decide for the owner, we need to know how often it would pick what he picks. The only decider before this was a fixed rule that always keeps the plan's direction, which agrees with the reviewers on every past item, so it measured nothing. Now a model (Opus 5.5 by default, about $0.07 per gate on your own API key) predicts each answer, and taste-replay round 2 in gstack-evals compares the predictions with the owner's real answers. Nothing routes to the owner differently.
### Added
- **`gstack-config set autoplan_shadow on`** (off by default; `GSTACK_AUTOPLAN_SHADOW=1|0` overrides it for one run) turns shadow mode on at a run's first `next`, stored in `run.json` so a resumed run cannot flip it. `autoplan_shadow_model` picks the model (default `claude-opus-5-5`). It stays off with `SHADOW: off (<reason>)` when there is no API key for the model, the model has no price, the run is `--no-outside` or CEO-only.
- **Every driver command that prints a gate** (`next`, `submit`, `close`, `resume`, `revise`, `run`, `check`) runs the shadow first and prints `SHADOW: recorded model=<m> usd=<x>` or `SHADOW: failed <code>`, then the gate, then the checkpoint. The prediction goes to the private `<run>/shadow/ceo-only.r<rev>.jsonl`: action, pick, confidence, cites and reason per question, beside the keep-direction rule's pick, the frozen question, the prompt hash and the cost, plus a 10% audit mark for a later live switch. The run lock is free during the call; another writer pays nothing while a call is in flight, `answer` waits for it, and after a crash `answer` records that gate first, marked retrospective. A shadow failure never blocks a gate or an answer.
- **Shadow calls count against `--spend-cap`** under their own ledger ids labeled `shadow`, show as `shadow=<usd>` on `GATE_SPEND`, and never change the per-voice price on `GATE_REC_COST`. A call that was sent and then lost to a crash settles `unknown`, never released as undispatched.
- **`gstack-autoplan shadow --out <dir> | --runs <file> [--json]`**, a read-only export for gstack-evals: one row per item of each answered gate, its shadow status (recorded, malformed, failed, missing, retrospective, off) and the owner's answer with how he gave it. An unanswered or interrogated gate refuses `SHADOW_BEFORE_ANSWER`.
- **Answered decision rows record how the owner answered**, parsed from his own words: `owner_mode` (`rec`, `rec_except`, `tokens`, `as_is`, `unparsed`), `owner_named`, the `rec_hash` answered against and `answered_role`.
- `gstack-artifact validate <run.json>` checks each shadow record against its journaled sha256.
### Changed
- The shared model-call transport can write a caller's own egress receipt, returns the provider's stop reason (a reply cut at the output limit is retried at 8,000 tokens, not treated as malformed), and sends the output limit to OpenAI models as `max_completion_tokens` when a caller sets one. Existing callers send the same bytes as before.
+13
View File
@@ -3389,6 +3389,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`
+86 -1
View File
@@ -267,7 +267,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`. |
@@ -659,6 +660,90 @@ 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`, `finding`, `p1`), `ceo_eligible`
(false for a `finding` item, which a CEO-only run leaves to the owner), 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
+5 -2
View File
@@ -151,10 +151,13 @@ export function changeBudget(state: RunState, o: BudgetChange): string[] {
return lines;
}
/** Reservations with no dispatch journaled (a crash between reserve and journal) are proven never dispatched, so released. */
/**
* Reservations with no dispatch journaled (a crash between reserve and journal) are proven never dispatched, so released.
* A shadow call journals `shadow_dispatched` before its send: it counts as dispatched and is never released here.
*/
export function releaseUndispatched(state: RunState): string[] {
const file = path.join(state.out, FILES.spend);
const dispatched = new Set(readJournal(state.out).filter(r => r.event === 'dispatched').map(r => r.attempt));
const dispatched = new Set(readJournal(state.out).filter(r => r.event === 'dispatched' || r.event === 'shadow_dispatched').map(r => r.attempt));
return readLedger(file).reserved.filter(r => !dispatched.has(r.attempt)).map(r => {
release(file, r.attempt);
journal(state.out, { event: 'released_undispatched', attempt: r.attempt, estimate_usd: r.estimate_usd });
+2 -3
View File
@@ -21,7 +21,7 @@ import { buildGate, finishGate } from './autoplan-gate';
import { FINDING_ORIGIN } from './autoplan-confirm';
import { carryLines } from './autoplan-carry';
import { aggregateFindings, exportRun } from './autoplan-export';
import { decisionRows, keepDirection, type GateItem } from './gate-list';
import { decisionRows, ruleKept, type GateItem } from './gate-list';
import { MARKER_RE } from './autoplan-scope';
import { RunError, journal, voiceRows, writeDecisions, type RunState } from './autoplan-state';
import type { DecisionRow, FindingRow } from './headless-artifacts';
@@ -64,8 +64,7 @@ export function finishRun(state: RunState): string[] {
if (plan) throw new RunError('CEO_ONLY_CONFLICT', `the plan ${plan} is the report path ${report}`, 3);
const list = buildGate(state, 1);
const items = list.items.filter(i => i.id !== 'p1' && !i.origin?.endsWith(`|${FINDING_ORIGIN}`));
const kept = (item: GateItem) => (item.cap && item.kind === 'approval' ? null : keepDirection(item));
const rows: DecisionRow[] = decisionRows({ ...list, items }).map((row, i) => ({ ...row, kind: 'auto', status: 'auto_kept', chosen: kept(items[i]!), default_if_unanswered: kept(items[i]!), source: 'ceo-only' }));
const rows: DecisionRow[] = decisionRows({ ...list, items }).map((row, i) => ({ ...row, kind: 'auto', status: 'auto_kept', chosen: ruleKept(items[i]!), default_if_unanswered: ruleKept(items[i]!), source: 'ceo-only' }));
writeDecisions(state.out, rows);
state.status = 'reviewed';
exportRun(state);
+10 -5
View File
@@ -18,8 +18,10 @@ import { readLedger, summarize, type SpendSummary } from './spend-ledger';
export interface GateCost {
summary: SpendSummary;
/** Attempts the ledger has seen: settled plus still reserved. */
/** Reviewer attempts the ledger has seen: settled plus still reserved, shadow calls excluded. */
attempts: number;
/** What shadow calls (ledger label `shadow`, lib/autoplan-shadow.ts) spent or hold: in summary and the cap, never in the per-voice price. */
shadow_usd: number;
/** Phases a `rec` reply would reopen through its plan-changing recommendations, in run order; empty when none changes the plan. */
reopens: string[];
voices: number;
@@ -72,11 +74,14 @@ export function priorChoices(outDir: string, gateRev: number): Record<string, st
export function gateCost(outDir: string, list: GateList, run: GateCostRun, o: { defaultPerVoiceUsd?: number } = {}): GateCost {
const ledger = readLedger(path.join(outDir, 'spend.json'), run.spend_cap_usd);
const summary = summarize(ledger);
const seen = [...ledger.settled.map(s => s.reserved_usd), ...ledger.reserved.map(r => r.estimate_usd)].filter(n => n > 0);
const voice = (r: { label?: string }) => r.label !== 'shadow';
const settled = ledger.settled.filter(voice), reserved = ledger.reserved.filter(voice);
const seen = [...settled.map(s => s.reserved_usd), ...reserved.map(r => r.estimate_usd)].filter(n => n > 0);
const per_voice_usd = seen.length ? seen[seen.length - 1]! : o.defaultPerVoiceUsd ?? 0;
const reopens = recReopens(list, run, priorChoices(outDir, list.gate_rev));
const voices = reopens.length * (run.outside_enabled ? 2 : 1);
return { summary, attempts: ledger.settled.length + ledger.reserved.length, reopens, voices, per_voice_usd, rec_more_usd: Math.round(voices * per_voice_usd * 100) / 100 };
const shadow_usd = Math.round([...ledger.settled.filter(s => !voice(s)).map(s => (s.usd === 'unknown' ? s.reserved_usd : s.usd)), ...ledger.reserved.filter(r => !voice(r)).map(r => r.estimate_usd)].reduce((a, b) => a + b, 0) * 1e6) / 1e6;
return { summary, attempts: settled.length + reserved.length, shadow_usd, reopens, voices, per_voice_usd, rec_more_usd: Math.round(voices * per_voice_usd * 100) / 100 };
}
/** The two printed lines: the ledger at this gate, and the price of `rec`. */
@@ -87,7 +92,7 @@ export function renderGateCost(c: GateCost): string[] {
const unknown = s.unknown_count ? `; ${s.unknown_count} host-subagent attempt(s) priced unknown, counted at their reservation (read the actual figure in the host's cost view)` : '';
const after = s.headroom_usd === null ? '' : ` → headroom ${usd(s.headroom_usd - c.rec_more_usd)}${s.headroom_usd - c.rec_more_usd < 0 ? ' (over the cap: raise it with gstack-autoplan budget before answering rec)' : ''}`;
return [
`GATE_SPEND: spent=${usd(s.spent_usd)} worst_case=${usd(s.worst_case_usd)} cap=${cap} headroom=${headroom} attempts=${c.attempts}${unknown}`,
`GATE_SPEND: spent=${usd(s.spent_usd)} worst_case=${usd(s.worst_case_usd)} cap=${cap} headroom=${headroom} attempts=${c.attempts}${c.shadow_usd ? ` shadow=${c.shadow_usd.toFixed(4)}` : ''}${unknown}`,
c.reopens.length
? `GATE_REC_COST: rec reopens up to ${c.reopens.join(',')} ≈ ${c.voices} voice(s) × ${usd(c.per_voice_usd)} = +${usd(c.rec_more_usd)} at most (the run's per-voice reservation)${after}`
: 'GATE_REC_COST: rec reopens nothing (no recommended option changes the plan): +0.00',
@@ -97,7 +102,7 @@ export function renderGateCost(c: GateCost): string[] {
/** The JSON payload beside the printed lines. */
export function gateCostPayload(c: GateCost): Record<string, unknown> {
const s = c.summary;
return { spent_usd: s.spent_usd, worst_case_usd: s.worst_case_usd, cap_usd: s.cap_usd, headroom_usd: s.headroom_usd, attempts: c.attempts, unknown_count: s.unknown_count, rec_reopens: c.reopens, rec_voices: c.voices, per_voice_usd: c.per_voice_usd, rec_more_usd: c.rec_more_usd };
return { spent_usd: s.spent_usd, worst_case_usd: s.worst_case_usd, cap_usd: s.cap_usd, headroom_usd: s.headroom_usd, attempts: c.attempts, ...(c.shadow_usd ? { shadow_usd: c.shadow_usd } : {}), unknown_count: s.unknown_count, rec_reopens: c.reopens, rec_voices: c.voices, per_voice_usd: c.per_voice_usd, rec_more_usd: c.rec_more_usd };
}
/** One sentence for the owner brief. */
+22 -19
View File
@@ -28,8 +28,8 @@ import { evaluateCarry, gateEvidence, voidPendingCheck } from './autoplan-carry'
import { buildEli5, capyRelayLine } from './captain-brief';
import { FINDING_ORIGIN, previousReview, reviewRows, settledIds } from './autoplan-confirm';
import { phaseClose } from './autoplan-timing';
import { ANSWERED_BY_RE, applyReply, autoDefault, decisionId, decisionRows, keepDirection, parseReply, pendingHint, recHash, validateGateList, type GateChoice, type GateItem, type GateList, type GateOption, type ParsedReply } from './gate-list';
import type { FindingRow } from './headless-artifacts';
import { ANSWERED_BY_RE, applyReply, autoDefault, decisionId, decisionRows, keepDirection, parseReply, pendingHint, recHash, replyShape, validateGateList, type GateChoice, type GateItem, type GateList, type GateOption, type ParsedReply } from './gate-list';
import type { DecisionRow, FindingRow } from './headless-artifacts';
import { readJsonl, sha256File } from './headless-artifacts';
import { closeDraftDirectionsFile, loadBriefInputs } from './owner-brief';
import { vaguePlanTerms } from './plan-reality';
@@ -133,21 +133,21 @@ function capItems(state: RunState, next: { d: () => string; uc: () => string }):
const rows: OpenRow[] = [...voiceRows(state, phase, 'native'), ...voiceRows(state, phase, 'outside'),
...(def.carried_file ? readJsonl(path.join(state.out, def.carried_file)).rows as unknown as OpenRow[] : [])];
push({ id: next.d(), title: `${phase} closed accepted-with-findings at round ${def.round} (${def.reason}): ${def.open} open finding(s), ${severityText(def.counts)}, carried into tasks.jsonl`,
kind: 'auto', recommended: 'a', phase, cap: { phase, kind: 'deferred' }, why: `closed deferred by ${def.by}`, cost: `b grants ${phase} one more round`,
kind: 'auto', recommended: 'a', phase, cap: { phase, kind: 'deferred' }, item_class: 'cap_deferred', why: `closed deferred by ${def.by}`, cost: `b grants ${phase} one more round`,
options: [{ key: 'a', text: 'accept with findings, deferred to the build', changes_plan: false }, reopen(phase)] }, `deferred-${phase}`);
for (const id of def.blockers) {
const f = rows.find(r => r.id === id);
if (!f) continue;
const risk = f.severity === 'Critical' || f.urgent;
push({ id: next.uc(), title: `${f.severity}${f.urgent ? ' urgent' : ''}${f.user_challenge ? ' user challenge' : ''} left open when ${phase} closed deferred: ${f.title} [${id}]`,
kind: 'approval', recommended: risk ? 'b' : 'a', recommended_basis: risk ? 'an open Critical or urgent finding' : 'plan direction; a user challenge', phase, cap: { phase, kind: 'blocker', ref: id },
kind: 'approval', recommended: risk ? 'b' : 'a', recommended_basis: risk ? 'an open Critical or urgent finding' : 'plan direction; a user challenge', phase, cap: { phase, kind: 'blocker', ref: id }, item_class: 'cap_blocker',
why: `${phase} closed deferred (${def.reason}) with this finding open; only you can accept it`,
options: [{ key: 'a', text: 'carry it into the build as a blocking task (you accept the risk)', changes_plan: false }, reopen(phase)] }, id);
}
}
for (const delta of pendingDeltas(state)) {
push({ id: next.uc(), title: `plan changed after ${delta.phase} cap: unreviewed delta (+${delta.added} -${delta.removed} lines, ${delta.old_plan_sha256.slice(0, 12)} -> ${delta.new_plan_sha256.slice(0, 12)})`,
kind: 'approval', recommended: 'a', recommended_basis: 'the run budget; the delta is reviewed during the build', phase: delta.phase, cap: { phase: delta.phase, kind: 'delta', ref: String(delta.k) },
kind: 'approval', recommended: 'a', recommended_basis: 'the run budget; the delta is reviewed during the build', phase: delta.phase, cap: { phase: delta.phase, kind: 'delta', ref: String(delta.k) }, item_class: 'cap_delta',
why: `revise v${delta.k} changed the plan after ${delta.phase} used round ${delta.round} of ${delta.cap}, so ${delta.phase} never reviewed it`,
options: [{ key: 'a', text: 'accept the unreviewed delta into the approved plan', changes_plan: false }, reopen(delta.phase)] }, `delta-${delta.k}`);
}
@@ -189,7 +189,7 @@ export function challengeDefault(row: ReconciliationRow, n: FindingRow | undefin
/** A rerun challenge naming no scope, cost or direction: the agent's own decision, recorded, reopening nothing. */
function agentDecision(id: string, title: string, phase: string, why: string): GateItem {
return { id, title, kind: 'auto', recommended: 'a', phase, reopens: false, why: `${why}; it names no scope, cost or direction change, so on a rerun it is the agent's decision`,
return { id, title, kind: 'auto', recommended: 'a', phase, reopens: false, item_class: 'agent', why: `${why}; it names no scope, cost or direction change, so on a rerun it is the agent's decision`,
cost: 'agent decision; b is recorded, no phase reruns', options: [KEEP('keep your direction'), { key: 'b', text: 'accept the change (agent decision; no phase reruns)', changes_plan: false }] };
}
@@ -218,14 +218,14 @@ export function buildGate(state: RunState, gateRev: number): GateList {
const title = `${n.title} [${row.native_id} + ${row.outside_id}]`;
if (reran && !n.changes && !o.changes) { push(agentDecision(`d${++d}`, title, phase, 'both voices recommend changing your stated direction'), ids); continue; }
const def = challengeDefault(row, n, o);
push({ id: `uc${++uc}`, title, kind: 'approval', recommended: def.recommended, recommended_basis: def.basis, phase, ...(n.changes ?? o.changes ? { changes: n.changes ?? o.changes } : {}),
push({ id: `uc${++uc}`, title, kind: 'approval', recommended: def.recommended, recommended_basis: def.basis, phase, item_class: 'challenge2', ...(n.changes ?? o.changes ? { changes: n.changes ?? o.changes } : {}),
why: `both voices recommend changing your stated direction; blind spot: ${o.title}; cost if wrong: ${o.resolution ?? n.resolution ?? 'unstated'}`,
options: [KEEP('keep your direction (the plan as reviewed)'), { key: 'b', text: def.text, changes_plan: true }] }, ids);
continue;
}
if (row.disposition === 'disagree') {
challenged.add(o.id); challenged.add(n!.id);
push({ id: `d${++d}`, title: `${n!.title} [${row.native_id} vs ${row.outside_id}]`, kind: 'auto', recommended: 'a', phase,
push({ id: `d${++d}`, title: `${n!.title} [${row.native_id} vs ${row.outside_id}]`, kind: 'auto', recommended: 'a', phase, item_class: 'disagree',
why: `voices disagree: ${row.resolution}`, cost: `b reopens ${phase}${phase === 'eng' ? '' : ' and eng'}`,
options: [{ key: 'a', text: `native position (${row.severity.native})`, changes_plan: false }, { key: 'b', text: `outside position (${row.severity.outside})`, changes_plan: true }] }, ids);
}
@@ -234,7 +234,7 @@ export function buildGate(state: RunState, gateRev: number): GateList {
if (!f.user_challenge || challenged.has(f.id) || f.settled_by) continue;
const why = `one voice (${f.voice}) recommends changing your stated direction`;
if (reran && !f.changes) { push(agentDecision(`d${++d}`, `${f.title} [${f.id}]`, phase, why), f.id); continue; }
push({ id: `d${++d}`, title: `${f.title} [${f.id}]`, kind: 'auto', recommended: 'a', phase, why, ...(f.changes ? { changes: f.changes } : {}),
push({ id: `d${++d}`, title: `${f.title} [${f.id}]`, kind: 'auto', recommended: 'a', phase, item_class: 'challenge1', why, ...(f.changes ? { changes: f.changes } : {}),
cost: `b reopens ${phase}${phase === 'eng' ? '' : ' and eng'}`, options: [KEEP('keep your direction'), { key: 'b', text: 'accept the change', changes_plan: true }] }, f.id);
}
// An open Critical or High finding the items above do not cover: rec applies its fix to the plan.
@@ -247,7 +247,7 @@ export function buildGate(state: RunState, gateRev: number): GateList {
if (!severity) continue;
const ids = group.map(g => g.id);
const fixes = [...new Set(group.map(g => g.resolution).filter((r): r is string => !!r))];
push({ id: `d${++d}`, title: `${severity}: ${f.title} [${ids.join(' + ')}]`, kind: 'auto', recommended: 'a', phase, finding: ids,
push({ id: `d${++d}`, title: `${severity}: ${f.title} [${ids.join(' + ')}]`, kind: 'auto', recommended: 'a', phase, item_class: 'finding', finding: ids,
why: `an open ${severity} finding (${group.map(g => g.voice).join(' + ')}); rec applies its fix to the plan`, cost: `a reopens ${phase}${phase === 'eng' ? '' : ' and eng'} once the plan is revised`,
options: [{ key: 'a', text: `apply the fix to the plan: ${fixes.length ? fixes.join('; ') : `resolve "${f.title}"`}`, changes_plan: true }, { key: 'b', text: 'carry it into the build as a blocking task (no plan change)', changes_plan: false }] }, ids.join('+'), FINDING_ORIGIN);
}
@@ -266,7 +266,7 @@ function planApproval(state: RunState, items: GateItem[]): GateItem {
const changes = items.filter(i => i.id !== 'p1' && i.options.find(o => o.key === i.recommended)?.changes_plan).map(i => `${i.id}${i.recommended}`);
const planFile = fs.existsSync(state.plan_source) ? state.plan_source : state.active_plan;
const flags = vaguePlanTerms(fs.readFileSync(planFile, 'utf8')).map(v => `PLAN_REALITY ${JSON.stringify(v.phrase)} has no number, arm list or rule body (${path.basename(state.plan_input ?? planFile)}:${v.line}: ${v.text.length > 120 ? `${v.text.slice(0, 117)}...` : v.text}) (report only)`);
return { id: 'p1', title: 'Plan approval', kind: 'approval', recommended: changes.length ? 'b' : 'a', ...(flags.length ? { flags } : {}),
return { id: 'p1', title: 'Plan approval', kind: 'approval', recommended: changes.length ? 'b' : 'a', item_class: 'p1', ...(flags.length ? { flags } : {}),
options: [{ key: 'a', text: 'approve as-is' }, { key: 'b', text: changes.length ? `approve with the recommended changes: ${changes.join(', ')}` : 'approve with the overrides above' },
{ key: 'c', text: 'interrogate (ask questions; stays pending)' },
{ key: 'd', text: `rerun every phase on the current plan, Eng last (to review an edited plan instead: gstack-autoplan revise --out ${state.out} --plan <file>)` }, { key: 'e', text: 'reject' }] };
@@ -304,7 +304,7 @@ export function recommendGate(state: RunState, gateRev: number, itemId: string,
}
/** The gate list the owner last answered: gate.json, or its archive (`gate.r<rev>[.v<k>].json`) once a reopen moved it. */
function previousGate(out: string, rev: number): GateList | undefined {
export function previousGate(out: string, rev: number): GateList | undefined {
if (rev < 1) return undefined;
const re = new RegExp(`^gate\\.r${rev}(?:\\.v(\\d+))?\\.json$`);
const archived = fs.readdirSync(out).map(f => [f, re.exec(f)] as const).filter(([, m]) => m).sort((a, b) => Number(b[1]![1] ?? 1) - Number(a[1]![1] ?? 1)).map(([f]) => f);
@@ -380,15 +380,15 @@ export function amendmentSteps(state: RunState, out: string): string {
edit.length ? `edit the plan, then gstack-autoplan revise --out ${out} --plan ${state.plan_input ?? state.plan_source} --incorporates ${edit.join(',')}` : ''].filter(Boolean).join('; then ');
}
/** The owner's words must not contradict the relayed tokens: when they parse, both must resolve to the same choices. */
function checkOwnerReply(list: GateList, gateRev: number, prior: Record<string, string>, relayed: ParsedReply, ownerReply: string): boolean {
/** The owner's words must not contradict the relayed tokens: when they parse, both must resolve to the same choices. Returns the owner's parse. */
function checkOwnerReply(list: GateList, gateRev: number, prior: Record<string, string>, relayed: ParsedReply, ownerReply: string): ParsedReply {
const owner = parseReply(ownerReply, list, gateRev, prior);
if (!owner.valid) return false;
if (!owner.valid) return owner;
const choices = (p: ParsedReply) => JSON.stringify(p.choices.map(c => [c.id, c.chosen]).sort());
if (choices(owner) !== choices(relayed) || owner.pending.join() !== relayed.pending.join()) {
throw new RunError('GATE_OWNER_REPLY_MISMATCH', `the owner's reply ${JSON.stringify(ownerReply)} resolves to ${owner.choices.map(c => `${c.id}${c.chosen}`).join(' ')}${owner.pending.length ? ` (pending ${owner.pending.join(',')})` : ''}; the relayed reply resolves to ${relayed.choices.map(c => `${c.id}${c.chosen}`).join(' ')}${relayed.pending.length ? ` (pending ${relayed.pending.join(',')})` : ''}`, 3);
}
return true;
return owner;
}
/**
@@ -418,16 +418,19 @@ export function answerGate(state: RunState, gateRev: number, reply: string, answ
if (takesRecs && (list.rec_rev ?? 0) > 0 && o.recHash === undefined) throw new RunError('GATE_REC_STALE', `recommend changed this gate (rec_rev=${list.rec_rev}); \`rec\` and \`p1b\` take its recommendations, so pass --rec-hash from the render the owner saw (current rec_hash=${currentHash})`, 3);
const ownerParsed = o.ownerReply !== undefined && !o.carry ? checkOwnerReply(list, gateRev, prior, parsed, o.ownerReply) : undefined;
const provenance = o.carry ? { owner_reply: o.carry.words, carried_gate_from: o.carry.from, carried_gate_rev: o.carry.gate_rev, carry_reason: o.carry.reason, ...(o.carry.by ? { carried_answered_by: o.carry.by } : {}) }
: o.ownerReply !== undefined && o.wordsBy ? { answer_words: o.ownerReply, answer_words_parsed: ownerParsed, words_by: o.wordsBy }
: o.ownerReply !== undefined ? { owner_reply: o.ownerReply, owner_reply_parsed: ownerParsed } : {};
: o.ownerReply !== undefined && o.wordsBy ? { answer_words: o.ownerReply, answer_words_parsed: ownerParsed!.valid, words_by: o.wordsBy }
: o.ownerReply !== undefined ? { owner_reply: o.ownerReply, owner_reply_parsed: ownerParsed!.valid } : {};
const shape = o.carry || o.wordsBy ? undefined : replyShape(o.ownerReply ?? reply, list, ownerParsed ?? parsed);
const itemOf = (id: string) => list.items.find(i => i.id === id)!;
const optionOf = (c: GateChoice) => itemOf(c.id).options.find(o => o.key === c.chosen)!;
const plan = parsed.choices.find(c => c.id === 'p1');
const lines: string[] = o.carry ? [] : voidPendingCheck(state, `the owner answered gate_rev ${gateRev}`);
const answeredAt = now();
const owner = (r: DecisionRow): Partial<DecisionRow> => (shape && r.gate_rev === list.gate_rev && r.answered_at === answeredAt
? { owner_mode: shape.mode, owner_named: shape.named.has((r.label ?? '').toLowerCase()), rec_hash: currentHash, answered_role: answeredBy?.startsWith('agent:') ? 'agent' : 'human' } : {});
const record = (answered = parsed) => writeDecisions(state.out, applyReply(rows, answered, answeredAt, answeredBy, provenance).map(r => {
const c = r.gate_rev === list.gate_rev && r.chosen && !r.chosen_text ? itemOf((r.label ?? '').toLowerCase())?.options.find(o => o.key === r.chosen) : undefined;
return c ? { ...r, chosen_text: c.text } : r;
return { ...(c ? { ...r, chosen_text: c.text } : r), ...owner(r) };
}));
if (!plan || parsed.pending.length) {
// Approvals unanswered: record what was answered, but not an auto default that p1b would change (the final answer decides it).
+2 -4
View File
@@ -23,7 +23,7 @@ import * as path from 'node:path';
import { exportRun } from './autoplan-export';
import { applyRenames, approve, closeDraftDirections, finishGate, planReopen } from './autoplan-gate';
import { carryLines, voidPendingCheck } from './autoplan-carry';
import { atCap, escalateLine, pendingDeltas, roundCap, roundOf } from './autoplan-budget';
import { atCap, pendingDeltas, roundCap, roundOf } from './autoplan-budget';
import { diffStat, lineDiff } from './autoplan-confirm';
import { SETTLEABLE_CHANGES } from './autoplan-reconcile';
import { normalizeRevision, reviewHash, scaffold } from './autoplan-plan-text';
@@ -112,9 +112,7 @@ function approveOrRegate(state: RunState, at: string): string[] {
if (pendingDeltas(state).length) {
finishGate(state);
const carried = carryLines(state);
if (state.status !== 'gate_pending') return carried;
const escalate = escalateLine(state);
return [...carried, `GATE: gate_rev=${state.gate_rev} (a plan change after a round cap needs the owner) render with: gstack-gate render ${path.join(state.out, 'gate.json')}`, ...(escalate ? [escalate] : [])];
return carried;
}
return [...approve(state, at), `APPROVED: gate_rev=${state.gate_rev} (every accepted change is in the plan or settled)`];
}
+13 -7
View File
@@ -21,6 +21,7 @@ import * as path from 'node:path';
import { canonicalRows, parseReceipt, parseRecordFindings, parseReviewResult, type CanonicalFinding } from './autoplan-reconcile';
import { amendmentSteps, applyGuidance, closeDeferred, closePhase } from './autoplan-gate';
import { finishRun } from './autoplan-ceo-only';
import type { ShadowSwitch } from './autoplan-shadow';
import { GSTACK_VERSION } from './autoplan-migrate';
import { driftLine, normalizeFindingFile, reviewHash, scaffold } from './autoplan-plan-text';
import { PHASES, SNAPSHOT_TOOL, attemptFile, bindPromptResult, detectScope, driverFile, extractHeading, phaseOrder, reviewSkillFile, scopeLines, scopeSourceLabel, sectionLoadFor, snapshotTool, writePrompts, type Phase, type Scope, type ScopeFlags } from './autoplan-prompts';
@@ -38,14 +39,18 @@ import { annotateConfirm, confirmBlock } from './autoplan-confirm';
/** An unreadable run.lock younger than this is a lock some other gstack is still writing, never a stale one. */
const LOCK_WRITE_GRACE_MS = 30_000;
/** Exclusive ownership for one command: a lock whose pid is alive is never stolen; a dead pid's lock is reclaimed. */
export function withRunLock<T>(out: string, fn: () => T, opts: { steal?: boolean } = {}): T {
/**
* Exclusive ownership for one command: a lock whose pid is alive is never stolen; a dead pid's lock is reclaimed.
* `waitMs`: retry a live owner's lock for that long before RUN_LOCKED (the shadow's publishing step, lib/autoplan-shadow.ts).
*/
export function withRunLock<T>(out: string, fn: () => T, opts: { steal?: boolean; waitMs?: number } = {}): T {
const waitUntil = Date.now() + (opts.waitMs ?? 0);
fs.mkdirSync(out, { recursive: true });
const lock = path.join(out, FILES.lock);
const mine = JSON.stringify({ pid: process.pid, host: os.hostname(), at: now() });
const staged = `${lock}.${process.pid}.${crypto.randomBytes(4).toString('hex')}`;
fs.writeFileSync(staged, mine);
for (let i = 0; ; i++) {
for (let reclaims = 0; ;) {
try {
fs.linkSync(staged, lock);
fs.unlinkSync(staged);
@@ -61,9 +66,10 @@ export function withRunLock<T>(out: string, fn: () => T, opts: { steal?: boolean
} else if (owner.host === os.hostname() && typeof owner.pid === 'number') {
try { process.kill(owner.pid, 0); alive = true; } catch (k: any) { alive = k.code === 'EPERM'; }
} else if (owner.host && owner.host !== os.hostname()) alive = !opts.steal;
if (alive && Date.now() < waitUntil) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100); continue; }
if (alive) { fs.rmSync(staged, { force: true }); throw new RunError('RUN_LOCKED', unreadable ? `${lock} is being written by another command` : `pid ${owner.pid} on ${owner.host} since ${owner.at}`, 3); }
try { fs.unlinkSync(lock); } catch { /* raced with its owner's exit */ }
if (i > 50) { fs.rmSync(staged, { force: true }); throw new RunError('RUN_LOCKED', `could not reclaim ${lock}`, 3); }
if (++reclaims > 50) { fs.rmSync(staged, { force: true }); throw new RunError('RUN_LOCKED', `could not reclaim ${lock}`, 3); }
}
}
try { return fn(); } finally { try { if (fs.readFileSync(lock, 'utf8') === mine) fs.unlinkSync(lock); } catch { /* already gone */ } }
@@ -72,7 +78,7 @@ export function withRunLock<T>(out: string, fn: () => T, opts: { steal?: boolean
// ---------------------------------------------------------------------------
// init
// ---------------------------------------------------------------------------
export interface InitOptions { plan: string; out: string; cwd: string; scope?: ScopeFlags; ceoOnly?: boolean; planHeading?: string; outsideEnabled?: boolean; spendCap?: number | null; sessionKind?: string; deadlineMin?: number; light?: boolean; runId?: string; scaffold?: boolean; maxRounds?: number; carryForward?: boolean; carryCheckCapUsd?: number }
export interface InitOptions { plan: string; out: string; cwd: string; scope?: ScopeFlags; ceoOnly?: boolean; shadow?: ShadowSwitch; planHeading?: string; outsideEnabled?: boolean; spendCap?: number | null; sessionKind?: string; deadlineMin?: number; light?: boolean; runId?: string; scaffold?: boolean; maxRounds?: number; carryForward?: boolean; carryCheckCapUsd?: number }
/** `lines` receives what init itself reports (SCAFFOLDED:). A plan with one section heading missing refuses, or with `scaffold` is reviewed from a scaffolded copy. */
export function initRun(o: InitOptions, lines: string[] = []): RunState {
@@ -120,12 +126,12 @@ export function initRun(o: InitOptions, lines: string[] = []): RunState {
deviations: ['autoplan guard: not enforced by this host; publication order is unverified (GUARD_NOT_INSTALLED)'],
...(o.deadlineMin ? { deadline_at: new Date(Date.now() + o.deadlineMin * 60_000).toISOString() } : {}),
...(o.light ? { light: true } : {}),
...(o.ceoOnly ? { ceo_only: true } : {}), gstack_version: GSTACK_VERSION, plan_input: input, scope_flags: { ...(o.scope ?? {}) },
...(o.ceoOnly ? { ceo_only: true } : {}), ...(o.shadow ? { shadow: o.shadow } : {}), gstack_version: GSTACK_VERSION, plan_input: input, scope_flags: { ...(o.scope ?? {}) },
...(o.planHeading ? { plan_heading: o.planHeading } : {}), ...(scaffolded ? { scaffolded: true } : {}),
...(o.carryForward === false ? { carry_forward: false } : {}), ...(o.carryCheckCapUsd !== undefined ? { carry_check_cap_usd: o.carryCheckCapUsd } : {}),
};
fs.writeFileSync(path.join(out, 'timing.json'), JSON.stringify({ schema_version: 1, run, phases: [], started_at: now(), session_kind: state.session_kind }, null, 2) + '\n');
journal(out, { event: 'init', run, plan_source: source, scope, phase_order: order, ...(o.ceoOnly ? { ceo_only: true } : {}), ...(consentMissing ? { outside: 'not_run', reason: 'consent_missing' } : {}) });
journal(out, { event: 'init', run, plan_source: source, scope, phase_order: order, ...(o.ceoOnly ? { ceo_only: true } : {}), ...(o.shadow ? { shadow: o.shadow } : {}), ...(consentMissing ? { outside: 'not_run', reason: 'consent_missing' } : {}) });
writeState(state);
return state;
}
+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 ?? ((s?.item_class ?? gateItem?.item_class) !== 'finding' && (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;
}
+442
View File
@@ -0,0 +1,442 @@
/**
* autoplan-shadow — shadow mode for the CEO-only decider (GST-537 PR 3,
* docs/unattended.md#shadow-mode). In a driver run that turned it on at init
* (`autoplan_shadow on` or GSTACK_AUTOPLAN_SHADOW=1; off by default), every
* gate the driver publishes gets one hidden model prediction per question,
* written before anyone answers: action (decide, send_back, hold), pick,
* confidence, cites and reason, beside the free keep-direction rule's pick.
* Nothing routes differently and a shadow failure never blocks a gate or an
* answer. The decider prompt is the frozen, hand-written
* autoplan/shadow-decider.md (its sha256 is pinned below and on every row).
*
* The publication is two-phase so the run lock is never held during the call:
* `claimShadow` runs under the run lock of the command that wrote the gate and
* takes a lease (`<run>/shadow/r<rev>.pending`: pid, host, deadline; journaled
* `shadow_pending`); `runShadow` calls the model with no lock held (each call
* reserved under its own ledger id labeled `shadow` and journaled
* `shadow_dispatched` before the send), then retakes the lock with a bounded
* wait and publishes `<run>/shadow/ceo-only.r<rev>.jsonl` with link (a second
* writer never overwrites) plus `shadow_recorded` or `shadow_failed`. A live
* lease makes every other writer pay nothing, and `answer` waits for it
* (`shadowBeforeAnswer`). Only a dead lease (its pid gone, or past its
* deadline) is a crash: its dispatched calls settle `unknown`, never released,
* and `answer` then records the revision with every row `retrospective`.
* This module is the only one that names the shadow folder's paths; the reader
* for gstack-evals and the private validate check are in
* lib/autoplan-shadow-reader.ts.
*/
import * as crypto from 'node:crypto';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { extractHeading } from './autoplan-prompts';
import { GSTACK_VERSION } from './autoplan-migrate';
import { withRunLock } from './autoplan-run';
import { FILES, RunError, journal, now, readJournal, readState, voiceRows, type RunState } from './autoplan-state';
import { decisionId, recHash, ruleKept, type GateItem, type GateList } from './gate-list';
import type { FindingRow } from './headless-artifacts';
import { readJsonl } from './headless-artifacts';
import { apiKeyFor, callModel, type ApiCallResult } from './outside-voice-api';
import { modelFamily } from './outside-voice-runner';
import { estimateCostUsd, hasPricing } from './pricing';
import { readLedger, release, reserve, settle, summarize } from './spend-ledger';
import { resolveSourceRoot } from './source-root';
import { readConfigKey } from './state-root';
export const SHADOW_PROMPT_VERSION = 1;
/** sha256 of autoplan/shadow-decider.md at SHADOW_PROMPT_VERSION; a prompt edit bumps both (test/autoplan-shadow.test.ts). */
export const SHADOW_PROMPT_SHA256 = 'e4857932ae831b7a6d933c42c182ccd7711c68338fec6901799264f645589344';
export const DEFAULT_SHADOW_MODEL = 'claude-opus-5-5';
/** One deadline for the call and its one retry. */
export const SHADOW_DEADLINE_MS = 90_000;
export const SHADOW_MAX_TOKENS = 4_000;
/** A reply cut at the output limit is asked once more at this limit, inside the same deadline. */
export const SHADOW_RAISED_MAX_TOKENS = 8_000;
export const SHADOW_SINK = 'autoplan-shadow';
export const SHADOW_CONSENT = 'autoplan_shadow on';
const PLAN_CHARS = 40_000;
const REASON_CHARS = 400;
const RELOCK_MS = 30_000;
const LEASE_GRACE_MS = 30_000;
export const SHADOW_ACTIONS = ['decide', 'send_back', 'hold'] as const;
const ROOT = path.resolve(import.meta.dir, '..');
export const shadowPromptFile = () => path.join(resolveSourceRoot(ROOT), 'autoplan', 'shadow-decider.md');
export const shadowDir = (out: string) => path.join(out, 'shadow');
export const recordFile = (out: string, rev: number) => path.join(shadowDir(out), `ceo-only.r${rev}.jsonl`);
export const RECORD_RE = /^ceo-only\.r(\d+)\.jsonl$/;
const leaseFile = (out: string, rev: number) => path.join(shadowDir(out), `r${rev}.pending`);
const saltFile = (out: string) => path.join(shadowDir(out), 'salt');
const sha256 = (data: string | Buffer) => crypto.createHash('sha256').update(data).digest('hex');
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
// ---------------------------------------------------------------------------
// the switch, fixed at init
// ---------------------------------------------------------------------------
export interface ShadowSwitch { enabled: boolean; model?: string; reason?: string; source: 'env' | 'config' | 'default' }
/**
* GSTACK_AUTOPLAN_SHADOW (1|on|0|off), else gstack-config autoplan_shadow (default off). On also needs a priced model
* (autoplan_shadow_model, default claude-opus-5-5), its family's API key, and a run that is neither --no-outside nor
* CEO-only. Stored in run.json at init and never re-read. The line is printed only when shadow was asked for.
*/
export function shadowSwitch(o: { outsideEnabled: boolean; ceoOnly: boolean }, env: NodeJS.ProcessEnv = process.env): { shadow: ShadowSwitch; line?: string } {
const fromEnv = !!env.GSTACK_AUTOPLAN_SHADOW?.trim();
const value = (fromEnv ? env.GSTACK_AUTOPLAN_SHADOW! : readConfigKey('autoplan_shadow', env) ?? '').trim().toLowerCase();
const source = fromEnv ? 'env' as const : value ? 'config' as const : 'default' as const;
const label = fromEnv ? `GSTACK_AUTOPLAN_SHADOW=${value}` : `autoplan_shadow ${value}`;
const off = (reason: string) => ({ shadow: { enabled: false, reason, source }, line: `SHADOW: off (${reason})` });
if (!value || value === 'off' || value === '0') return { shadow: { enabled: false, reason: source === 'default' ? 'off by default' : label, source } };
if (value !== 'on' && value !== '1') return off(`${label} is not on or off`);
if (o.ceoOnly) return off('a CEO-only run writes no gate');
if (!o.outsideEnabled) return off('--no-outside');
const model = (readConfigKey('autoplan_shadow_model', env) ?? '').trim() || DEFAULT_SHADOW_MODEL;
const family = modelFamily(model);
const keyName = family === 'openai' ? 'OPENAI_API_KEY' : family === 'anthropic' ? 'ANTHROPIC_API_KEY' : `no API transport for family ${family}`;
if (!apiKeyFor(family, env)) return off(`no API key for ${model} (${keyName})`);
if (!hasPricing(model)) return off(`${model} has no price in lib/pricing.ts, so --spend-cap cannot bound it`);
return { shadow: { enabled: true, model, source }, line: `SHADOW: on model=${model} (${label}); one hidden model call per gate page, counted against --spend-cap` };
}
// ---------------------------------------------------------------------------
// the lease
// ---------------------------------------------------------------------------
interface Lease { gate_rev: number; pid: number; host: string; started_at: string; deadline: string; retrospective: boolean }
export interface ShadowClaim { gate_rev: number; retrospective: boolean }
function readLease(out: string, rev: number): Lease | undefined {
try { return JSON.parse(fs.readFileSync(leaseFile(out, rev), 'utf8')) as Lease; } catch { return undefined; }
}
/** Live: before its deadline plus a grace, and (on this host) its pid still running. */
function leaseLive(l: Lease, at = Date.now()): boolean {
if (!(at <= Date.parse(l.deadline) + LEASE_GRACE_MS)) return false;
if (l.host !== os.hostname()) return true;
try { process.kill(l.pid, 0); return true; } catch (e: any) { return e.code === 'EPERM'; }
}
function dropLease(out: string, rev: number): void {
const l = readLease(out, rev);
if (l && l.pid === process.pid && l.host === os.hostname()) fs.rmSync(leaseFile(out, rev), { force: true });
}
const resultRow = (rows: Array<Record<string, any>>, rev: number) => rows.find(r => (r.event === 'shadow_recorded' || r.event === 'shadow_failed') && r.gate_rev === rev);
const callIds = (rows: Array<Record<string, any>>, rev: number) => rows.filter(r => r.event === 'shadow_dispatched' && r.gate_rev === rev).map(r => String(r.attempt));
/** The shadow folder: created on first use; a symlink or a non-directory is refused, never followed. */
function safeDir(out: string): string | undefined {
const dir = shadowDir(out);
try {
const st = fs.lstatSync(dir);
if (st.isSymbolicLink()) return `${dir} is a symlink`;
if (!st.isDirectory()) return `${dir} is not a directory`;
} catch (e: any) {
if (e.code !== 'ENOENT') return `${dir}: ${e.code ?? e.message}`;
fs.mkdirSync(dir, { mode: 0o700 });
}
return undefined;
}
/** A dead lease: its dispatched calls settle unknown at their reservation (they may have been billed), the rest are released. */
function expireLease(out: string, rev: number, rows: Array<Record<string, any>>): void {
const file = path.join(out, FILES.spend);
const sent = new Set(callIds(rows, rev));
const open = readLedger(file).reserved.filter(r => r.attempt.startsWith(`shadow-r${rev}-`));
for (const r of open) if (sent.has(r.attempt)) settle(file, r.attempt, 'unknown'); else release(file, r.attempt);
fs.rmSync(leaseFile(out, rev), { force: true });
journal(out, { event: 'shadow_expired', gate_rev: rev, settled_unknown: open.filter(r => sent.has(r.attempt)).map(r => r.attempt), released: open.filter(r => !sent.has(r.attempt)).map(r => r.attempt) });
}
/**
* Under the run lock: claim the current gate revision's shadow, or say why not (nothing printed when shadow is off, the
* run is not at a gate, or the revision already has its result). A live lease means another writer is paying: this one
* pays nothing. `retrospective` (answer's crash path) claims only a revision whose shadow_pending has no result.
*/
export function claimShadow(out: string, o: { retrospective?: boolean } = {}): { claim?: ShadowClaim; line?: string } {
const state = readState(out);
const rev = state.gate_rev;
if (!state.shadow?.enabled || state.ceo_only || state.status !== 'gate_pending' || !rev) return {};
const rows = readJournal(out);
if (resultRow(rows, rev) || fs.existsSync(recordFile(out, rev))) return {};
const pending = rows.some(r => r.event === 'shadow_pending' && r.gate_rev === rev);
if (o.retrospective && !pending) return {};
const unsafe = safeDir(out);
if (unsafe) {
journal(out, { event: 'shadow_failed', gate_rev: rev, code: 'unsafe_dir', detail: unsafe });
return { line: `SHADOW: failed unsafe_dir (${unsafe})` };
}
const held = readLease(out, rev);
if (held && leaseLive(held)) return { line: `SHADOW: pending (gate_rev ${rev} is being recorded by pid ${held.pid} on ${held.host} until ${held.deadline}; this command pays nothing)` };
if (held || pending) expireLease(out, rev, rows);
const started = Date.now();
const lease: Lease = { gate_rev: rev, pid: process.pid, host: os.hostname(), started_at: new Date(started).toISOString(), deadline: new Date(started + SHADOW_DEADLINE_MS + RELOCK_MS).toISOString(), retrospective: !!o.retrospective };
const staged = `${leaseFile(out, rev)}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
fs.writeFileSync(staged, JSON.stringify(lease));
try { fs.linkSync(staged, leaseFile(out, rev)); } catch (e: any) { if (e.code !== 'EEXIST') throw e; return { line: `SHADOW: pending (gate_rev ${rev} was claimed by another writer)` }; } finally { fs.rmSync(staged, { force: true }); }
journal(out, { event: 'shadow_pending', ...lease });
return { claim: { gate_rev: rev, retrospective: lease.retrospective } };
}
// ---------------------------------------------------------------------------
// the page: one prompt per gate
// ---------------------------------------------------------------------------
interface PageFinding { id: string; severity: string; title: string }
export interface PageItem { item: GateItem; findings: PageFinding[]; frozen: Record<string, unknown> }
export interface Page { prompt: string; items: PageItem[]; plan_sha256: string; prompt_sha256: string; request_sha256: string }
/** Every finding the gate items can name: both voices of every phase, plus findings a deferred close carried. */
function findingIndex(state: RunState): Map<string, FindingRow> {
const rows: FindingRow[] = [];
for (const phase of state.phase_order) {
rows.push(...voiceRows(state, phase, 'native'), ...voiceRows(state, phase, 'outside'));
const carried = state.phases[phase]?.deferred?.carried_file;
if (carried && fs.existsSync(path.join(state.out, carried))) rows.push(...readJsonl(path.join(state.out, carried)).rows as unknown as FindingRow[]);
}
return new Map(rows.map(r => [r.id, r]));
}
/** The question as frozen in the record and sent in the prompt; p1 lists every finding the other items name. */
export function buildPage(state: RunState, list: GateList, deciderText: string): Page {
const index = findingIndex(state);
const own = (item: GateItem) => (item.origin?.split('|')[1] ?? '').split('+').filter(id => index.has(id));
const named = [...new Set(list.items.filter(i => i.id !== 'p1').flatMap(own))];
const items = list.items.map(item => {
const findings = (item.id === 'p1' ? named : own(item)).map(id => { const f = index.get(id)!; return { id, severity: f.severity, title: f.title.split('\n')[0]!.slice(0, 200) }; });
const frozen = { id: item.id, item_class: item.item_class ?? null, kind: item.kind, title: item.title, why: item.why ?? null, phase: item.phase ?? null,
options: item.options.map(o => ({ key: o.key, text: o.text, changes_plan: o.changes_plan ?? null })), recommended: item.recommended, recommended_basis: item.recommended_basis ?? null, findings };
return { item, findings, frozen };
});
const planText = fs.existsSync(state.active_plan) ? fs.readFileSync(state.active_plan, 'utf8') : '';
const plan = (extractHeading(planText, 'Implementation plan') ?? planText).slice(0, PLAN_CHARS);
const slots: Record<string, string> = { PLAN: plan, QUESTIONS: '```json\n' + JSON.stringify(items.map(i => i.frozen), null, 2) + '\n```', IDS: items.map(i => i.item.id).join(', ') };
const prompt = deciderText.replace(/\{\{(PLAN|QUESTIONS|IDS)\}\}/g, (_, k: string) => slots[k]!);
return { prompt, items, plan_sha256: sha256(planText), prompt_sha256: sha256(deciderText), request_sha256: sha256(prompt) };
}
// ---------------------------------------------------------------------------
// the reply contract
// ---------------------------------------------------------------------------
export type Verdict = { ok: true; action: typeof SHADOW_ACTIONS[number]; pick: string; confidence: number; cites: string[]; reason: string } | { ok: false; why: string };
export interface ParsedPage { byId: Map<string, Verdict>; foreign: string[]; clean: boolean }
function jsonArray(text: string): unknown {
const fenced = /```(?:json)?\s*\n([\s\S]*?)```/.exec(text)?.[1];
for (const candidate of [fenced, text.trim(), text.slice(text.indexOf('['), text.lastIndexOf(']') + 1)]) {
if (!candidate) continue;
try { return JSON.parse(candidate); } catch { /* next candidate */ }
}
return undefined;
}
/** Exactly one valid object per requested id; a duplicate, omission or invalid field marks that item malformed, a foreign id the page. */
export function parsePage(text: string, items: PageItem[]): ParsedPage {
const byId = new Map<string, Verdict>();
const parsed = jsonArray(text);
if (!Array.isArray(parsed)) {
for (const i of items) byId.set(i.item.id, { ok: false, why: 'no JSON array in the reply' });
return { byId, foreign: [], clean: false };
}
const want = new Map(items.map(i => [i.item.id, i]));
const seen = new Map<string, unknown[]>();
const foreign: string[] = [];
for (const obj of parsed) {
const id = (obj as { id?: unknown })?.id;
if (typeof id === 'string' && want.has(id)) (seen.get(id) ?? seen.set(id, []).get(id)!).push(obj);
else foreign.push(typeof id === 'string' ? id : JSON.stringify(id ?? null));
}
for (const [id, pi] of want) {
const objs = seen.get(id) ?? [];
byId.set(id, objs.length === 0 ? { ok: false, why: 'missing' } : objs.length > 1 ? { ok: false, why: 'duplicate' } : verdict(objs[0] as Record<string, unknown>, pi));
}
return { byId, foreign, clean: foreign.length === 0 && [...byId.values()].every(v => v.ok) };
}
function verdict(o: Record<string, unknown>, pi: PageItem): Verdict {
const cites = Array.isArray(o.cites) ? o.cites : null;
const allowed = new Set(pi.findings.map(f => f.id));
if (!SHADOW_ACTIONS.includes(o.action as typeof SHADOW_ACTIONS[number])) return { ok: false, why: `action ${JSON.stringify(o.action ?? null)} is not decide, send_back or hold` };
if (typeof o.pick !== 'string' || !pi.item.options.some(op => op.key === o.pick)) return { ok: false, why: `pick ${JSON.stringify(o.pick ?? null)} is not an option of ${pi.item.id}` };
if (typeof o.confidence !== 'number' || !Number.isFinite(o.confidence) || o.confidence < 0 || o.confidence > 1) return { ok: false, why: `confidence ${JSON.stringify(o.confidence ?? null)} is not a number from 0 to 1` };
if (!cites || cites.some(c => typeof c !== 'string' || !allowed.has(c))) return { ok: false, why: `cites ${JSON.stringify(o.cites ?? null)} name a finding outside ${pi.item.id}'s origin` };
if (typeof o.reason !== 'string' || o.reason.length > REASON_CHARS) return { ok: false, why: `reason is not a string of at most ${REASON_CHARS} characters` };
return { ok: true, action: o.action as typeof SHADOW_ACTIONS[number], pick: o.pick, confidence: o.confidence, cites: cites as string[], reason: o.reason };
}
/** The 10% audit mark: sha256(salt + decision id) mod 1000 < 100. */
export function auditMark(salt: string, decision: string): boolean {
return BigInt('0x' + sha256(salt + decision)) % 1000n < 100n;
}
// ---------------------------------------------------------------------------
// the call and the record
// ---------------------------------------------------------------------------
export interface ShadowOutcome { status: 'recorded' | 'failed' | 'dropped'; code?: string; model?: string; usd?: number; malformed?: number; line: string }
export interface RunShadowOptions { env?: NodeJS.ProcessEnv; deadlineMs?: number }
class ShadowFailure extends Error { constructor(public code: string, detail: string) { super(detail); } }
interface Reply { result: ApiCallResult; usd: number | 'unknown'; attempt: string }
/** Classify a callModel error: no key, an HTTP status (not billed, settles 0), or a timeout or network loss (may be billed, settles unknown). */
function failureOf(e: any): { code: string; usd: number | 'unknown' } {
const msg = String(e?.message ?? e);
if (/^no API key/.test(msg)) return { code: 'no_key', usd: 0 };
if (/^(anthropic|openai) \d{3}:/.test(msg)) return { code: 'http', usd: 0 };
return { code: /timeout|abort/i.test(msg) || e?.name === 'AbortError' || e?.name === 'TimeoutError' ? 'timeout' : 'network', usd: 'unknown' };
}
/**
* Outside any lock: the page is asked once; a reply cut at the output limit is asked once more at 8,000 tokens, and a
* malformed one once more as a fresh request; one 90 s deadline covers both. Each call is reserved under its own
* ledger id (label shadow), journaled shadow_dispatched before the send and settled at its actual usage. Then the
* lock is retaken with a bounded wait and the record published. Never throws: every failure is an outcome.
*/
export async function runShadow(out: string, claim: ShadowClaim, o: RunShadowOptions = {}): Promise<ShadowOutcome> {
const env = o.env ?? process.env;
const deadline = Date.now() + (o.deadlineMs ?? SHADOW_DEADLINE_MS);
const rev = claim.gate_rev;
const replies: Reply[] = [];
try {
const state = readState(out);
const list = JSON.parse(fs.readFileSync(path.join(out, 'gate.json'), 'utf8')) as GateList;
if (list.gate_rev !== rev) throw new ShadowFailure('stale_gate', `gate.json is gate_rev ${list.gate_rev}, the claim is ${rev}`);
const model = state.shadow!.model!;
const page = buildPage(state, list, fs.readFileSync(shadowPromptFile(), 'utf8'));
writeAtomic(path.join(shadowDir(out), `r${rev}.prompt.md`), page.prompt);
const inputTokens = Math.ceil(Buffer.byteLength(page.prompt) / 3);
const estimate = (maxTokens: number) => estimateCostUsd({ input: inputTokens, output: maxTokens }, model);
const ledgerFile = path.join(out, FILES.spend);
const headroom = summarize(readLedger(ledgerFile, state.spend_cap_usd)).headroom_usd;
const worst = estimate(SHADOW_MAX_TOKENS) + estimate(SHADOW_RAISED_MAX_TOKENS);
if (headroom !== null && headroom < worst) throw new ShadowFailure('budget', `headroom ${headroom.toFixed(4)} < ${worst.toFixed(4)} (one call plus its retry)`);
const ask = async (maxTokens: number): Promise<Reply> => {
const remaining = deadline - Date.now();
if (remaining <= 0) throw new ShadowFailure('timeout', `the ${(o.deadlineMs ?? SHADOW_DEADLINE_MS) / 1000} s deadline passed before the call`);
const attempt = `shadow-r${rev}-${callIds(readJournal(out), rev).length + 1}`;
try { reserve(ledgerFile, attempt, estimate(maxTokens), { cap: readState(out).spend_cap_usd, label: 'shadow' }); }
catch (e: any) { throw new ShadowFailure('budget', e.message); }
journal(out, { event: 'shadow_dispatched', gate_rev: rev, attempt, model, max_tokens: maxTokens, estimate_usd: estimate(maxTokens), retrospective: claim.retrospective });
let result: ApiCallResult;
try {
result = await callModel({ model, prompt: page.prompt, timeoutMs: remaining, env, maxTokens, receipt: { sink: SHADOW_SINK, consent: SHADOW_CONSENT, payloadClass: 'plan-and-gate' } });
} catch (e: any) {
const f = failureOf(e);
settle(ledgerFile, attempt, f.usd);
throw new ShadowFailure(f.code, String(e?.message ?? e));
}
const { input_tokens: inTok, output_tokens: outTok } = result.usage;
const usd: number | 'unknown' = typeof inTok === 'number' && typeof outTok === 'number' ? estimateCostUsd({ input: inTok, output: outTok }, model) : 'unknown';
settle(ledgerFile, attempt, usd);
writeAtomic(path.join(shadowDir(out), `${attempt}.reply.txt`), result.text);
const reply = { result, usd, attempt };
replies.push(reply);
return reply;
};
const truncated = (r: Reply) => r.result.stop_reason === 'max_tokens' || r.result.stop_reason === 'length';
const first = await ask(SHADOW_MAX_TOKENS);
let parsed: ParsedPage[];
if (truncated(first)) {
const raised = await ask(SHADOW_RAISED_MAX_TOKENS);
if (truncated(raised)) throw new ShadowFailure('truncated', `the reply was cut at ${SHADOW_RAISED_MAX_TOKENS} output tokens`);
parsed = [parsePage(raised.result.text, page.items)];
} else {
parsed = [parsePage(first.result.text, page.items)];
if (!parsed[0]!.clean) {
try {
const retry = await ask(SHADOW_MAX_TOKENS);
if (!truncated(retry)) parsed.push(parsePage(retry.result.text, page.items));
} catch (e) { if (!(e instanceof ShadowFailure)) throw e; }
}
}
return publish(out, claim, { rows: shadowRows(out, state, list, page, parsed, replies), model, replies });
} catch (e: any) {
const failure = e instanceof ShadowFailure ? e : new ShadowFailure('internal', String(e?.message ?? e));
return publish(out, claim, { failure, replies });
}
}
const usdOf = (replies: Reply[]) => (replies.some(r => r.usd === 'unknown') ? 'unknown' as const : Math.round(replies.reduce((s, r) => s + (r.usd as number), 0) * 1e6) / 1e6);
/** One row per gate item: the frozen question, what the model predicted (the retry's verdict when valid, else the first's), the free rule's pick and the audit mark. */
function shadowRows(out: string, state: RunState, list: GateList, page: Page, parsed: ParsedPage[], replies: Reply[]): Array<Record<string, unknown>> {
const at = now();
const usage = replies.reduce((u, r) => ({ input_tokens: u.input_tokens + (r.result.usage.input_tokens ?? 0), output_tokens: u.output_tokens + (r.result.usage.output_tokens ?? 0) }), { input_tokens: 0, output_tokens: 0 });
return page.items.map(({ item, frozen }) => {
const verdicts = parsed.map(p => p.byId.get(item.id)!);
const k = verdicts.map((v, i) => [v, i] as const).reverse().find(([v]) => v.ok);
const decision = decisionId(list.run, list.gate_rev, item.id);
const base = {
schema_version: 1, run: list.run, gate_rev: list.gate_rev, decision, item: item.id, item_class: item.item_class ?? null, phase: item.phase ?? null,
ceo_eligible: item.item_class !== 'finding' && (item.id === 'p1' || item.phase === 'ceo'), item_frozen: frozen, rec_hash: recHash(list), rec_rev: list.rec_rev ?? 0,
plan_sha256: page.plan_sha256, prompt_version: SHADOW_PROMPT_VERSION, prompt_sha256: page.prompt_sha256, request_sha256: page.request_sha256,
model: replies[replies.length - 1]?.result.model ?? state.shadow?.model, gstack_version: GSTACK_VERSION, rule_pick: ruleKept(item),
retrospective: false, page_usage: usage, page_usd: usdOf(replies), recorded_at: at,
};
if (!k) return { ...base, status: 'malformed', malformed: (verdicts[verdicts.length - 1] as { why: string }).why, action: null, pick: null, confidence: null, cites: [], reason: null, audit: false };
const v = k[0] as Extract<Verdict, { ok: true }>;
return { ...base, status: 'recorded', action: v.action, pick: v.pick, confidence: v.confidence, cites: v.cites, reason: v.reason, reply: k[1] + 1,
audit: v.action === 'decide' && auditMark(saltOf(out), decision) };
});
}
/** The per-run audit salt, drawn once into the shadow folder (never run.json). */
function saltOf(out: string): string {
try { return fs.readFileSync(saltFile(out), 'utf8').trim(); } catch { /* first use */ }
try { fs.writeFileSync(saltFile(out), crypto.randomBytes(16).toString('hex') + '\n', { flag: 'wx', mode: 0o600 }); } catch (e: any) { if (e.code !== 'EEXIST') throw e; }
return fs.readFileSync(saltFile(out), 'utf8').trim();
}
function writeAtomic(file: string, text: string): void {
const tmp = `${file}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
fs.writeFileSync(tmp, text);
fs.renameSync(tmp, file);
}
/** Under the run lock (a bounded wait, never RUN_LOCKED at once): link the record, journal the result, drop the lease. */
function publish(out: string, claim: ShadowClaim, r: { rows?: Array<Record<string, unknown>>; model?: string; failure?: ShadowFailure; replies: Reply[] }): ShadowOutcome {
const rev = claim.gate_rev;
const usd = usdOf(r.replies);
const attempts = r.replies.map(x => x.attempt);
try {
return withRunLock(out, () => {
const rows = readJournal(out);
if (fs.existsSync(recordFile(out, rev)) || resultRow(rows, rev)) { dropLease(out, rev); return { status: 'dropped' as const, line: `SHADOW: dropped (gate_rev ${rev} already has its result; this call's cost stays in the ledger)` }; }
if (r.failure) {
journal(out, { event: 'shadow_failed', gate_rev: rev, code: r.failure.code, detail: r.failure.message.slice(0, 500), attempts, usd, retrospective: claim.retrospective });
dropLease(out, rev);
return { status: 'failed' as const, code: r.failure.code, line: `SHADOW: failed ${r.failure.code}` };
}
const salted: Array<Record<string, unknown>> = r.rows!.map(row => ({ ...row, retrospective: claim.retrospective }));
const text = salted.map(row => JSON.stringify(row)).join('\n') + '\n';
const final = recordFile(out, rev);
for (const e of fs.readdirSync(shadowDir(out))) if (e.startsWith(`ceo-only.r${rev}.jsonl.`) && e.endsWith('.tmp')) fs.rmSync(path.join(shadowDir(out), e), { force: true });
const tmp = `${final}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
fs.writeFileSync(tmp, text, { flag: 'wx' });
try { fs.linkSync(tmp, final); } catch (e: any) { if (e.code !== 'EEXIST') throw e; dropLease(out, rev); return { status: 'dropped' as const, line: `SHADOW: dropped (gate_rev ${rev} was recorded by another writer)` }; } finally { fs.rmSync(tmp, { force: true }); }
const malformed = salted.filter(row => row.status === 'malformed').length;
journal(out, { event: 'shadow_recorded', gate_rev: rev, sha256: sha256(text), model: r.model, usd, items: salted.length, malformed, attempts, retrospective: claim.retrospective });
dropLease(out, rev);
const usdText = usd === 'unknown' ? 'unknown' : usd.toFixed(4);
return { status: 'recorded' as const, model: r.model, usd: usd === 'unknown' ? undefined : usd, malformed,
line: `SHADOW: recorded model=${r.model} usd=${usdText}${malformed ? ` malformed=${malformed}/${salted.length}` : ''}${claim.retrospective ? ' retrospective' : ''}` };
}, { waitMs: RELOCK_MS });
} catch (e: any) {
const code = e instanceof RunError && e.code === 'RUN_LOCKED' ? 'locked' : 'internal';
return { status: 'failed', code, line: `SHADOW: failed ${code} (${e?.message ?? e}); the gate stands` };
}
}
/**
* Before `answer` takes the lock: wait for a live lease to finish (up to its deadline), then, when the revision has a
* shadow_pending and still no result (a crash), record it first with every row retrospective. Never throws.
*/
export async function shadowBeforeAnswer(out: string, o: RunShadowOptions = {}): Promise<string[]> {
try {
const state = readState(out);
const rev = state.gate_rev;
if (!state.shadow?.enabled || state.status !== 'gate_pending' || !rev) return [];
for (let l = readLease(out, rev); l && leaseLive(l); l = readLease(out, rev)) await sleep(200);
const rows = readJournal(out);
if (resultRow(rows, rev) || fs.existsSync(recordFile(out, rev)) || !rows.some(r => r.event === 'shadow_pending' && r.gate_rev === rev)) return [];
const { claim, line } = withRunLock(out, () => claimShadow(out, { retrospective: true }), { waitMs: RELOCK_MS });
if (!claim) return line ? [line] : [];
return [(await runShadow(out, claim, o)).line];
} catch (e: any) {
return [`SHADOW: failed internal (${e?.message ?? e}); the answer proceeds`];
}
}
+2
View File
@@ -74,6 +74,8 @@ export interface RunState extends RunManifest {
carry_forward?: boolean; carry_check_cap_usd?: number; carry?: import('./autoplan-carry').CarryRecord;
/** `next --ceo-only` (lib/autoplan-ceo-only.ts): the CEO phase alone, no gate, ends `reviewed`; never set by a scope marker. */
ceo_only?: boolean;
/** Shadow mode (lib/autoplan-shadow.ts): fixed at init from GSTACK_AUTOPLAN_SHADOW or autoplan_shadow and never re-read; absent reads off. */
shadow?: import('./autoplan-shadow').ShadowSwitch;
}
export const TERMINAL_VOICE_STATUSES = ['completed', 'disabled', 'unavailable', 'failed', 'skipped'] as const;
+17
View File
@@ -41,9 +41,12 @@ export interface GateItem {
carried?: { from: string; chosen: string; gate_rev: number };
/** A round-cap item (lib/autoplan-budget.ts): `b` grants the phase one more round and reopens it. */
cap?: { phase: string; kind: 'deferred' | 'blocker' | 'delta'; ref?: string };
/** The buildGate branch that wrote it (lib/autoplan-gate.ts); `kind` alone mislabels disagreements and single-voice challenges. */
item_class?: ItemClass;
/** An open Critical or High finding (its ids, a paired twin included): `a` applies the reviewer's fix to the plan. */
finding?: string[];
}
export type ItemClass = 'challenge2' | 'challenge1' | 'disagree' | 'agent' | 'cap_deferred' | 'cap_blocker' | 'cap_delta' | 'finding' | 'p1';
export interface GateList { schema_version: 1; run: string; gate_rev: number; items: GateItem[]; title?: string;
/** Bumped by every `recommend`, so a `rec` relayed before it no longer matches `recHash`. */
rec_rev?: number;
@@ -219,6 +222,20 @@ export function keepDirection(item: GateItem): string {
return item.options.find(o => o.changes_plan === false)?.key ?? (item.id === 'p1' && item.options.some(o => o.key === 'a') ? 'a' : item.recommended);
}
/** The driver's own pick when nobody answers (a CEO-only run, and the shadow's free baseline): the direction kept; a cap approval stays undecided. */
export function ruleKept(item: GateItem): string | null {
return (item.cap && item.kind === 'approval') || item.finding ? null : keepDirection(item);
}
/** How the owner's own words answered: their reply mode, and the item ids they named with an `<id><option>` token. */
export function replyShape(words: string, list: GateList, parsed: ParsedReply): { mode: 'rec' | 'rec_except' | 'tokens' | 'as_is' | 'unparsed'; named: Set<string> } {
if (!parsed.valid) return { mode: 'unparsed', named: new Set() };
const tokens = tokenize(words, list);
const named = new Set(tokens.flatMap(t => { const m = /^([a-z]*[0-9]+)([a-z])$/.exec(t); return m && list.items.some(i => i.id === m[1]) ? [m[1]!] : []; }));
const mode = parsed.mode === 'rec' ? (tokens.includes('except') ? 'rec_except' : 'rec') : parsed.mode === 'as-is' ? 'as_is' : 'tokens';
return { mode, named };
}
/** What an unanswered `auto` item resolves to: its recommendation, unless that changes the plan (then the plan's direction). */
export function autoDefault(item: GateItem): string {
return item.options.find(o => o.key === item.recommended)?.changes_plan ? keepDirection(item) : item.recommended;
+3
View File
@@ -41,6 +41,8 @@ export interface DecisionRow {
recommended_basis?: string; changes?: string; origin?: string; carried_from?: string; chosen_text?: string;
/** An agent-relayed answer: the owner's verbatim words, and whether they parsed under the gate grammar. */
owner_reply?: string; owner_reply_parsed?: boolean; recommended_by?: string;
/** How the owner's own words answered (lib/gate-list.ts replyShape): their mode, whether they named this item, the rec_hash answered against, and who typed the reply. */
owner_mode?: 'rec' | 'rec_except' | 'tokens' | 'as_is' | 'unparsed'; owner_named?: boolean; rec_hash?: string; answered_role?: 'human' | 'agent';
/** An agent's own words (`--words-by agent:<id>`): never the owner's, so never carried or imported as the owner's. */
answer_words?: string; answer_words_parsed?: boolean; words_by?: string;
/** A whole-gate carry (lib/autoplan-carry.ts): the earlier p1 decision whose owner words this answer reuses, its gate_rev, why, who relayed them, items taken under "do it", and the delta check attempt. */
@@ -179,6 +181,7 @@ export const ARTIFACT_SCHEMAS = {
kind: { enum: ['auto', 'approval', 'user_challenge'] }, status: { enum: ['pending', 'approved', 'overridden', 'rejected', 'auto_kept'] },
default_if_unanswered: nullableStr, gate_rev: { type: 'integer', minimum: 1 }, phase: str, chosen: nullableStr,
answered_at: str, reply: str, source: str, cost: str,
owner_mode: { enum: ['rec', 'rec_except', 'tokens', 'as_is', 'unparsed'] }, owner_named: { type: 'boolean' }, rec_hash: str, answered_role: { enum: ['human', 'agent'] },
recommended_basis: str, changes: str, origin: str, carried_from: str, chosen_text: str,
carried_gate_from: str, carried_gate_rev: { type: 'integer', minimum: 1 }, carry_reason: str, carried_answered_by: str, carried_under: str, carry_check: str,
},
+15 -9
View File
@@ -11,10 +11,12 @@
import { sha256Hex, writeReceipt } from './egress-receipt';
import { modelFamily, type ModelFamily } from './outside-voice-runner';
export interface ApiCallRequest { model: string; prompt: string; timeoutMs: number; env?: Record<string, string | undefined>; signal?: AbortSignal; maxTokens?: number }
/** Who sends and on whose consent: replaces the outside-voice receipt for a caller with its own sink (lib/autoplan-shadow.ts). */
export interface ApiReceipt { sink: string; consent: string; payloadClass?: string }
export interface ApiCallRequest { model: string; prompt: string; timeoutMs: number; env?: Record<string, string | undefined>; signal?: AbortSignal; maxTokens?: number; receipt?: ApiReceipt }
export interface ApiUsage { input_tokens?: number; output_tokens?: number }
/** `truncated`: the provider stopped at the output token limit (Anthropic `max_tokens`, OpenAI `length`), so the text is cut off. */
export interface ApiCallResult { text: string; model: string; usage: ApiUsage; provider: 'openai' | 'anthropic'; status: number; truncated: boolean }
/** `truncated`: the provider stopped at the output token limit (Anthropic `max_tokens`, OpenAI `length`), so the text is cut off; `stop_reason`: the provider's stop or finish reason when it sent one. */
export interface ApiCallResult { text: string; model: string; usage: ApiUsage; provider: 'openai' | 'anthropic'; status: number; truncated: boolean; stop_reason?: string }
/** Anthropic requires max_tokens; a full review needs more than 8192. `run` sends this to OpenAI too, where reasoning tokens count toward it. */
export const DEFAULT_MAX_TOKENS = 16_384;
@@ -29,11 +31,13 @@ export function apiKeyFor(family: ModelFamily, env: Record<string, string | unde
return undefined;
}
function receipted(url: string, body: string, provider: string): void {
function receipted(url: string, body: string, provider: string, req: ApiCallRequest): void {
const receipt = req.receipt;
try {
writeReceipt({
sink: 'outside-voice-api', host: new URL(url).host, payloadClass: 'review-prompt', bytes: Buffer.byteLength(body), sha256: sha256Hex(body),
consent: `user ran gstack-outside-voice --runner api (${provider.toUpperCase()}_API_KEY configured)`,
env: req.env,
sink: receipt?.sink ?? 'outside-voice-api', host: new URL(url).host, payloadClass: receipt?.payloadClass ?? 'review-prompt', bytes: Buffer.byteLength(body), sha256: sha256Hex(body),
consent: receipt?.consent ?? `user ran gstack-outside-voice --runner api (${provider.toUpperCase()}_API_KEY configured)`,
});
} catch (err) {
process.stderr.write(`[outside-voice] egress receipt could not be written (${(err as Error).message}); proceeding (fail-open)\n`);
@@ -41,7 +45,7 @@ function receipted(url: string, body: string, provider: string): void {
}
async function post(url: string, headers: Record<string, string>, body: string, req: ApiCallRequest, provider: string): Promise<{ status: number; json: any }> {
receipted(url, body, provider);
receipted(url, body, provider, req);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(new Error(`timeout after ${req.timeoutMs} ms`)), req.timeoutMs);
req.signal?.addEventListener('abort', () => controller.abort(req.signal!.reason), { once: true });
@@ -67,7 +71,8 @@ export async function callModel(req: ApiCallRequest): Promise<ApiCallResult> {
const { status, json } = await post(`${base}/v1/messages`, { 'x-api-key': auth.key, 'anthropic-version': '2023-06-01' }, body, req, 'anthropic');
if (status < 200 || status >= 300) throw new Error(`anthropic ${status}: ${json?.error?.message ?? json?.raw ?? JSON.stringify(json)}`);
const text = Array.isArray(json?.content) ? json.content.filter((c: any) => c?.type === 'text').map((c: any) => c.text).join('\n') : '';
return { text, model: typeof json?.model === 'string' ? json.model : model, usage: { input_tokens: json?.usage?.input_tokens, output_tokens: json?.usage?.output_tokens }, provider: 'anthropic', status, truncated: json?.stop_reason === 'max_tokens' };
const stop = typeof json?.stop_reason === 'string' ? json.stop_reason : undefined;
return { text, model: typeof json?.model === 'string' ? json.model : model, usage: { input_tokens: json?.usage?.input_tokens, output_tokens: json?.usage?.output_tokens }, provider: 'anthropic', status, truncated: stop === 'max_tokens', ...(stop ? { stop_reason: stop } : {}) };
}
const base = (env.OPENAI_BASE_URL ?? 'https://api.openai.com/v1').replace(/\/$/, '');
const body = JSON.stringify({ model, messages: [{ role: 'user', content: req.prompt }], ...(req.maxTokens ? { max_completion_tokens: req.maxTokens } : {}) });
@@ -75,5 +80,6 @@ export async function callModel(req: ApiCallRequest): Promise<ApiCallResult> {
if (status < 200 || status >= 300) throw new Error(`openai ${status}: ${json?.error?.message ?? json?.raw ?? JSON.stringify(json)}`);
const content = json?.choices?.[0]?.message?.content;
const text = typeof content === 'string' ? content : Array.isArray(content) ? content.map((c: any) => c?.text ?? '').join('\n') : '';
return { text, model: typeof json?.model === 'string' ? json.model : model, usage: { input_tokens: json?.usage?.prompt_tokens, output_tokens: json?.usage?.completion_tokens }, provider: 'openai', status, truncated: json?.choices?.[0]?.finish_reason === 'length' };
const finish = json?.choices?.[0]?.finish_reason;
return { text, model: typeof json?.model === 'string' ? json.model : model, usage: { input_tokens: json?.usage?.prompt_tokens, output_tokens: json?.usage?.completion_tokens }, provider: 'openai', status, truncated: finish === 'length', ...(typeof finish === 'string' ? { stop_reason: finish } : {}) };
}
+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>;
+411
View File
@@ -0,0 +1,411 @@
/**
* 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], ['d1', 'finding', false, null, 'recorded', false], ['d2', 'finding', false, null, 'recorded', false],
['uc2', 'challenge2', false, 'a', 'recorded', false], ['d3', 'finding', false, null, 'recorded', false], ['d4', 'finding', false, null, '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('a finding item is never ceo_eligible and has no rule pick: a CEO-only run leaves an open Critical or High finding to the owner', () => {
const findings = shadowRows.filter(r => r.item_class === 'finding');
expect(findings.map(r => [r.item, r.phase])).toEqual([['d1', 'ceo'], ['d2', 'ceo'], ['d3', 'eng'], ['d4', 'eng']]);
expect(findings.every(r => r.ceo_eligible === false && r.rule_pick === null)).toBe(true);
});
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 d1a d2a uc2a d3a d4a p1b', '--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'], ['D1', 'rec', false, 'agent', true, 'agent:capy'], ['D2', 'rec', false, 'agent', true, 'agent:capy'],
['UC2', 'rec', false, 'agent', true, 'agent:capy'], ['D3', 'rec', false, 'agent', true, 'agent:capy'], ['D4', '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], ['d1', 'recorded', 'a', 'a', 'rec', true], ['d2', 'recorded', 'a', 'a', 'rec', true],
['uc2', 'recorded', 'a', 'a', 'rec', true], ['d3', 'recorded', 'a', 'a', 'rec', true], ['d4', 'recorded', 'a', 'a', 'rec', true], ['p1', 'recorded', 'a', 'b', 'rec', true]]);
expect(x.counts.status).toEqual({ recorded: 7 });
expect(x.counts.ceo_eligible).toEqual({ true: 2, false: 5 });
});
});
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'], ['d1', 'failed', 'http'], ['d2', 'failed', 'http'], ['uc2', 'failed', 'http'], ['d3', 'failed', 'http'], ['d4', '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: 7 });
});
});
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', 'finding', 'recorded'], ['d2', 'finding', 'recorded'], ['d3', '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, price); 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', '--estimate-usd', '0.001']);
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: 7, failed: 7, retrospective: 7, malformed: 1, recorded: 11 });
expect(x.rows.length).toBe(33);
expect(x.counts.item_class.cap_deferred).toBe(1);
expect(x.rows.filter((row: any) => row.item_class === 'finding').every((row: any) => row.ceo_eligible === false)).toBe(true);
const text = ap(['shadow', '--runs', list], e);
expect(text.stdout).toContain('SHADOW_EXPORT: rows=33 refused=1');
});
});
+312
View File
@@ -0,0 +1,312 @@
/**
* GST-537 PR 3, shadow mode for the CEO-only decider (lib/autoplan-shadow.ts):
* the frozen decider prompt, the switch, the reply contract, the 10% audit
* mark, the call (truncation, the OpenAI output bound, the 90 s deadline, the
* shadow's own egress receipt), and the gate-cost exclusion of shadow spend.
* Model calls go to test/fixtures/shadow-fake-model.ts through
* ANTHROPIC_BASE_URL and OPENAI_BASE_URL: never a real network call.
*/
import { afterAll, describe, expect, test } from 'bun:test';
import * as crypto from 'node:crypto';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { SHADOW_PROMPT_SHA256, SHADOW_PROMPT_VERSION, auditMark, buildPage, claimShadow, parsePage, recordFile, runShadow, shadowPromptFile, shadowSwitch } from '../lib/autoplan-shadow';
import { callModel } from '../lib/outside-voice-api';
import { gateCost, renderGateCost } from '../lib/autoplan-gate-cost';
import { readState } from '../lib/autoplan-state';
import { reserve, settle, readLedger } from '../lib/spend-ledger';
import type { GateList } from '../lib/gate-list';
import { ROOT, readRun, runPhase, startSmallRun } from './helpers/autoplan-run-fixture';
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'ap-shadow-'));
const FAKE = path.join(ROOT, 'test', 'fixtures', 'shadow-fake-model.ts');
const servers: Array<{ kill: () => 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)) : []);
/** A fake model server in its own process, answering step n of its script for the n-th request. */
async function fake(name: string, script: Array<Record<string, unknown>>) {
const dir = path.join(TMP, `fake-${name}`);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'script.json'), JSON.stringify(script));
const proc = Bun.spawn(['bun', FAKE, dir], { stdout: 'ignore', stderr: 'inherit' });
servers.push(proc);
for (let i = 0; i < 200 && !fs.existsSync(path.join(dir, 'port')); i++) await Bun.sleep(25);
const port = Number(fs.readFileSync(path.join(dir, 'port'), 'utf8'));
const state = path.join(dir, 'state');
fs.mkdirSync(state, { recursive: true });
const env = { HOME: state, GSTACK_HOME: state, GSTACK_STATE_ROOT: state, 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` };
return { dir, env, requests: () => rows(path.join(dir, 'requests.jsonl')), receipts: () => rows(path.join(state, 'security', 'egress.jsonl')) };
}
// One run at its gate (uc1 from CEO, uc2 from Eng, p1), shadow off; each case copies it and turns shadow on in run.json.
const BASE = startSmallRun(path.join(TMP, 'base'));
runPhase(BASE, 'ceo');
runPhase(BASE, 'eng');
let copies = 0;
function gateRun(model = 'claude-opus-5-5'): string {
const out = path.join(TMP, `run-${++copies}`);
fs.cpSync(BASE, out, { recursive: true });
const run = readRun(out);
run.shadow = { enabled: true, model, source: 'env' };
fs.writeFileSync(path.join(out, 'run.json'), JSON.stringify(run, null, 2) + '\n');
return out;
}
const gateOf = (out: string) => JSON.parse(fs.readFileSync(path.join(out, 'gate.json'), 'utf8')) as GateList;
const decider = () => fs.readFileSync(shadowPromptFile(), 'utf8');
describe('the decider prompt is frozen and predicts; it imports no mandate', () => {
test('its sha256 is pinned beside its version, and every row stores it', () => {
expect(SHADOW_PROMPT_VERSION).toBe(1);
expect(crypto.createHash('sha256').update(fs.readFileSync(shadowPromptFile())).digest('hex')).toBe(SHADOW_PROMPT_SHA256);
});
test('no take-the-recommendation or keep-the-direction rule; the recommendation is a labeled input', () => {
const text = decider();
expect(text).not.toMatch(/\(recommended\)/i);
expect(text).not.toMatch(/keep (the|your|its) (plan's )?direction/i);
expect(text).not.toMatch(/sections\/ceo-only\.md/);
expect(text).toContain("The reviewers' recommendation is one input among several");
for (const slot of ['{{PLAN}}', '{{QUESTIONS}}', '{{IDS}}']) expect(text.split(slot).length).toBe(2);
expect(text.trimEnd().endsWith('```')).toBe(true);
});
test('the CEO-only driver module does not name the CEO-only skill section', () => {
expect(fs.readFileSync(path.join(ROOT, 'lib', 'autoplan-ceo-only.ts'), 'utf8')).not.toContain('sections/ceo-only.md');
});
});
describe('the switch is off by default and fixed at init', () => {
const state = fs.mkdtempSync(path.join(TMP, 'cfg-'));
const keys = { GSTACK_STATE_ROOT: state, ANTHROPIC_API_KEY: 'sk-fake', OPENAI_API_KEY: '' };
const on = { outsideEnabled: true, ceoOnly: false };
test('off by default, with no line', () => {
expect(shadowSwitch(on, keys)).toEqual({ shadow: { enabled: false, reason: 'off by default', source: 'default' } });
});
test('config on turns it on with the default model; the env var wins over config', () => {
fs.writeFileSync(path.join(state, 'config.yaml'), 'autoplan_shadow: on\n');
const r = shadowSwitch(on, keys);
expect(r.shadow).toEqual({ enabled: true, model: 'claude-opus-5-5', source: 'config' });
expect(r.line).toStartWith('SHADOW: on model=claude-opus-5-5');
expect(shadowSwitch(on, { ...keys, GSTACK_AUTOPLAN_SHADOW: '0' }).shadow).toMatchObject({ enabled: false, source: 'env' });
fs.rmSync(path.join(state, 'config.yaml'));
expect(shadowSwitch(on, { ...keys, GSTACK_AUTOPLAN_SHADOW: '1' }).shadow).toMatchObject({ enabled: true, source: 'env' });
});
test('no key, --no-outside, a CEO-only run, an unpriced model or a bad value give off with the reason', () => {
const env = { ...keys, GSTACK_AUTOPLAN_SHADOW: 'on' };
expect(shadowSwitch(on, { ...env, ANTHROPIC_API_KEY: '' }).line).toBe('SHADOW: off (no API key for claude-opus-5-5 (ANTHROPIC_API_KEY))');
expect(shadowSwitch({ ...on, outsideEnabled: false }, env).line).toBe('SHADOW: off (--no-outside)');
expect(shadowSwitch({ ...on, ceoOnly: true }, env).line).toBe('SHADOW: off (a CEO-only run writes no gate)');
fs.writeFileSync(path.join(state, 'config.yaml'), 'autoplan_shadow_model: claude-unpriced-9\n');
expect(shadowSwitch(on, env).line).toBe('SHADOW: off (claude-unpriced-9 has no price in lib/pricing.ts, so --spend-cap cannot bound it)');
fs.rmSync(path.join(state, 'config.yaml'));
expect(shadowSwitch(on, { ...keys, GSTACK_AUTOPLAN_SHADOW: 'yes' }).line).toBe('SHADOW: off (GSTACK_AUTOPLAN_SHADOW=yes is not on or off)');
});
test('a run.json without the field reads off: nothing is claimed', () => {
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({});
expect(fs.existsSync(path.join(out, 'shadow'))).toBe(false);
});
});
describe('the reply contract: exactly one valid object per requested id', () => {
const out = gateRun();
const page = buildPage(readState(out), gateOf(out), decider());
const valid = page.items.map(i => ({ id: i.item.id, action: 'decide', pick: 'a', confidence: 0.6, cites: i.findings.slice(0, 1).map(f => f.id), reason: 'r' }));
const P1 = valid.findIndex(v => v.id === 'p1');
const reply = (objs: unknown[]) => '```json\n' + JSON.stringify(objs) + '\n```';
test('the page carries each item as rendered, with its findings, and p1 lists every finding the others name', () => {
expect(page.items.map(i => i.item.id)).toEqual(['uc1', 'd1', 'd2', 'uc2', 'd3', 'd4', 'p1']);
expect(page.items[0]!.frozen).toMatchObject({ item_class: 'challenge2', kind: 'approval', recommended: 'a' });
expect(page.items[0]!.findings.map(f => f.severity).every(Boolean)).toBe(true);
expect(page.items[P1]!.findings.map(f => f.id).sort()).toEqual(page.items.filter((_, i) => i !== P1).flatMap(i => i.findings).map(f => f.id).sort());
expect(page.prompt).toContain('"id": "uc1"');
expect(page.prompt).not.toContain('{{');
});
test('slot names inside the plan text stay plan text; the template slots are still filled', () => {
const planFile = path.join(TMP, 'slot-plan.md');
fs.writeFileSync(planFile, '## Implementation plan\n\nfill {{QUESTIONS}} and {{IDS}} in {{PLAN}}\n');
const p = buildPage({ ...readState(out), active_plan: planFile }, gateOf(out), decider());
expect(p.prompt).toContain('fill {{QUESTIONS}} and {{IDS}} in {{PLAN}}');
expect(p.prompt).toContain('"id": "uc1"');
expect(p.prompt).toContain('uc1, d1, d2, uc2, d3, d4, p1');
expect(p.prompt.split('{{').length).toBe(4);
});
test('a good reply is clean', () => {
expect(parsePage(reply(valid), page.items).clean).toBe(true);
});
const cases: Array<[string, unknown[] | string, string]> = [
['missing', valid.slice(0, 2), 'missing'],
['duplicate', [...valid, valid[P1]], 'duplicate'],
['unknown option', valid.map((v, i) => (i === P1 ? { ...v, pick: 'z' } : v)), 'pick "z" is not an option of p1'],
['foreign cite', valid.map((v, i) => (i === P1 ? { ...v, cites: ['nope'] } : v)), 'cites ["nope"] name a finding outside p1\'s origin'],
['confidence above 1', valid.map((v, i) => (i === P1 ? { ...v, confidence: 1.2 } : v)), 'confidence 1.2 is not a number from 0 to 1'],
['bad action', valid.map((v, i) => (i === P1 ? { ...v, action: 'approve' } : v)), 'action "approve" is not decide, send_back or hold'],
['long reason', valid.map((v, i) => (i === P1 ? { ...v, reason: 'x'.repeat(401) } : v)), 'reason is not a string of at most 400 characters'],
['no JSON', 'I would approve.', 'no JSON array in the reply'],
];
for (const [name, objs, why] of cases) test(`${name} marks that item malformed`, () => {
const p = parsePage(typeof objs === 'string' ? objs : reply(objs), page.items);
expect(p.clean).toBe(false);
expect(p.byId.get('p1')).toEqual({ ok: false, why });
});
test('a foreign id leaves the items valid but the page unclean, so it is asked again', () => {
const p = parsePage(reply([...valid, { ...valid[0], id: 'zz9' }]), page.items);
expect(p.foreign).toEqual(['zz9']);
expect([...p.byId.values()].every(v => v.ok)).toBe(true);
expect(p.clean).toBe(false);
});
});
describe('the 10% audit mark', () => {
test('on a fixed 2,000-row corpus the marked share is what the hash gives', () => {
const marked = Array.from({ length: 2000 }, (_, i) => auditMark('fixed-salt-0001', `run-fixture-r${1 + (i % 3)}-d${i}`)).filter(Boolean).length;
expect(marked).toBe(Array.from({ length: 2000 }, (_, i) => BigInt('0x' + crypto.createHash('sha256').update(`fixed-salt-0001run-fixture-r${1 + (i % 3)}-d${i}`).digest('hex')) % 1000n < 100n).filter(Boolean).length);
expect(marked / 2000).toBeGreaterThanOrEqual(0.08);
expect(marked / 2000).toBeLessThanOrEqual(0.12);
});
});
describe('callModel: the shadow receipt, stop_reason and the OpenAI output bound (existing callers unchanged)', async () => {
const f = await fake('transport', [{ mode: 'valid' }, { mode: 'truncated' }, { mode: 'valid' }, { mode: 'truncated' }]);
test('a receipt override writes exactly one receipt with its sink and consent; the default stays the outside-voice receipt', async () => {
await callModel({ model: 'claude-opus-5-5', prompt: 'p', timeoutMs: 5000, env: f.env, maxTokens: 10, receipt: { sink: 'autoplan-shadow', consent: 'autoplan_shadow on', payloadClass: 'plan-and-gate' } });
expect(f.receipts().map(r => [r.sink, r.consent, r.payload_class ?? r.payloadClass])).toEqual([['autoplan-shadow', 'autoplan_shadow on', 'plan-and-gate']]);
const cut = await callModel({ model: 'claude-opus-5-5', prompt: 'p', timeoutMs: 5000, env: f.env });
expect(cut.stop_reason).toBe('max_tokens');
expect(f.receipts().at(-1)!.sink).toBe('outside-voice-api');
expect(f.receipts().length).toBe(2);
});
test('OpenAI gets max_completion_tokens only when the caller set an output limit, and its finish_reason comes back', async () => {
const plain = await callModel({ model: 'gpt-6.1-sol', prompt: 'p', timeoutMs: 5000, env: f.env });
const bounded = await callModel({ model: 'gpt-6.1-sol', prompt: 'p', timeoutMs: 5000, env: f.env, maxTokens: 4000 });
const [a, b] = f.requests().slice(2).map(r => r.body);
expect(Object.keys(a)).toEqual(['model', 'messages']);
expect(b.max_completion_tokens).toBe(4000);
expect(plain.stop_reason).toBe('stop');
expect(bounded.stop_reason).toBe('length');
});
});
describe('runShadow: retry, truncation, failures and the deadline', async () => {
const ids = (out: string) => rows(recordFile(out, 1));
const shadowed = async (name: string, script: Array<Record<string, unknown>>, model?: string, deadlineMs?: number) => {
const f = await fake(name, script);
const out = gateRun(model);
const { claim } = claimShadow(out);
expect(claim).toEqual({ gate_rev: 1, retrospective: false });
const started = Date.now();
const outcome = await runShadow(out, claim!, { env: { ...process.env, ...f.env }, deadlineMs });
return { f, out, outcome, ms: Date.now() - started };
};
test('a good reply records every item, keeps the prompt and the raw reply, and settles one shadow-labeled call', async () => {
const { f, out, outcome } = await shadowed('good', [{ mode: 'valid' }]);
expect(outcome.status).toBe('recorded');
expect(outcome.line).toMatch(/^SHADOW: recorded model=claude-opus-5-5 usd=0\.0140$/);
expect(ids(out).map(r => [r.item, r.status, r.pick, r.rule_pick, r.prompt_sha256 === SHADOW_PROMPT_SHA256])).toEqual([['uc1', 'recorded', 'a', 'a', true], ['d1', 'recorded', 'a', null, true], ['d2', 'recorded', 'a', null, true],
['uc2', 'recorded', 'a', 'a', true], ['d3', 'recorded', 'a', null, true], ['d4', 'recorded', 'a', null, true], ['p1', 'recorded', 'a', 'a', true]]);
expect(f.requests().map(r => r.body.max_tokens)).toEqual([4000]);
expect(fs.existsSync(path.join(out, 'shadow', 'r1.prompt.md'))).toBe(true);
expect(fs.readFileSync(path.join(out, 'shadow', 'shadow-r1-1.reply.txt'), 'utf8')).toContain('fake prediction');
expect(readLedger(path.join(out, 'spend.json')).settled.filter(s => s.label === 'shadow').map(s => [s.attempt, s.usd])).toEqual([['shadow-r1-1', 0.014]]);
expect(fs.existsSync(path.join(out, 'shadow', 'r1.pending'))).toBe(false);
});
test('a link at a reply path is replaced, never written through', async () => {
const f = await fake('linked-reply', [{ mode: 'valid' }]);
const out = gateRun();
const { claim } = claimShadow(out);
const sentinel = path.join(TMP, 'sentinel.txt');
fs.writeFileSync(sentinel, 'keep');
const reply = path.join(out, 'shadow', 'shadow-r1-1.reply.txt');
fs.symlinkSync(sentinel, reply);
expect((await runShadow(out, claim!, { env: { ...process.env, ...f.env } })).status).toBe('recorded');
expect(fs.readFileSync(sentinel, 'utf8')).toBe('keep');
expect(fs.lstatSync(reply).isSymbolicLink()).toBe(false);
expect(fs.readFileSync(reply, 'utf8')).toContain('fake prediction');
});
test('a cap changed during the call admits the retry against the new cap and is not written back over', async () => {
const f = await fake('cap-moved', [{ mode: 'truncated', delay_ms: 400 }, { mode: 'valid' }]);
const out = gateRun();
const { claim } = claimShadow(out);
const pending = runShadow(out, claim!, { env: { ...process.env, ...f.env } });
await Bun.sleep(150);
const run = readRun(out);
run.spend_cap_usd = 2;
fs.writeFileSync(path.join(out, 'run.json'), JSON.stringify(run, null, 2) + '\n');
expect((await pending).status).toBe('recorded');
expect(f.requests().length).toBe(2);
expect(readLedger(path.join(out, 'spend.json')).cap_usd).toBe(2);
});
test('a malformed page is asked once more; items valid in either reply record, the rest record malformed', async () => {
const fixed = await shadowed('missing-then-valid', [{ mode: 'missing' }, { mode: 'valid' }]);
expect(fixed.f.requests().length).toBe(2);
expect(ids(fixed.out).every(r => r.status === 'recorded')).toBe(true);
const still = await shadowed('bad-twice', [{ mode: 'bad_option' }, { mode: 'bad_option' }]);
expect(still.f.requests().length).toBe(2);
expect(ids(still.out).map(r => r.status)).toEqual(['malformed', 'recorded', 'recorded', 'recorded', 'recorded', 'recorded', 'recorded']);
expect(ids(still.out)[0]).toMatchObject({ pick: null, audit: false, malformed: 'pick "z" is not an option of uc1' });
expect(still.outcome.line).toContain('malformed=1/7');
});
test('a truncated reply raises max_tokens once to 8,000 (no page retry); truncated twice fails truncated', async () => {
const raised = await shadowed('truncated-once', [{ mode: 'truncated' }, { mode: 'valid' }]);
expect(raised.f.requests().map(r => r.body.max_tokens)).toEqual([4000, 8000]);
expect(raised.outcome.status).toBe('recorded');
const twice = await shadowed('truncated-twice', [{ mode: 'truncated' }]);
expect(twice.f.requests().length).toBe(2);
expect(twice.outcome.line).toBe('SHADOW: failed truncated');
expect(fs.existsSync(recordFile(twice.out, 1))).toBe(false);
});
test('an OpenAI shadow model sends max_completion_tokens and treats finish_reason length as truncated', async () => {
const r = await shadowed('openai', [{ mode: 'truncated' }, { mode: 'valid' }], 'gpt-6.1-sol');
expect(r.f.requests().map(x => [x.path, x.body.max_completion_tokens])).toEqual([['/v1/chat/completions', 4000], ['/v1/chat/completions', 8000]]);
expect(r.outcome.status).toBe('recorded');
});
test('a 500 fails http and settles the call at 0; the journal names the code', async () => {
const r = await shadowed('http500', [{ mode: 'http500' }]);
expect(r.outcome.line).toBe('SHADOW: failed http');
expect(readLedger(path.join(r.out, 'spend.json')).settled.filter(s => s.label === 'shadow').map(s => s.usd)).toEqual([0]);
});
test('one deadline covers the call and its retry: a slow malformed reply leaves the retry no time', async () => {
const r = await shadowed('deadline-retry', [{ mode: 'missing', delay_ms: 1200 }, { mode: 'valid', delay_ms: 1200 }], undefined, 1500);
expect(r.ms).toBeLessThan(2400);
expect(r.outcome.status).toBe('recorded');
expect(ids(r.out).map(x => x.status)).toEqual(['recorded', 'recorded', 'recorded', 'recorded', 'recorded', 'recorded', 'malformed']);
const slow = await shadowed('deadline-call', [{ mode: 'valid', delay_ms: 3000 }], undefined, 800);
expect(slow.outcome.line).toBe('SHADOW: failed timeout');
expect(slow.ms).toBeLessThan(2500);
expect(readLedger(path.join(slow.out, 'spend.json')).settled.filter(s => s.label === 'shadow').map(s => s.usd)).toEqual(['unknown']);
});
test('a record already published makes a later claim a no-op: no second call is paid', async () => {
const { f, out } = await shadowed('noop', [{ mode: 'valid' }]);
const before = fs.readFileSync(recordFile(out, 1));
expect(claimShadow(out)).toEqual({});
expect(f.requests().length).toBe(1);
expect(fs.readFileSync(recordFile(out, 1)).equals(before)).toBe(true);
});
});
describe('gate cost: shadow spend counts against the cap and shows in GATE_SPEND, never in the per-voice price', () => {
test('a $5 voice settle followed by a shadow settle keeps GATE_REC_COST at x 5.00', () => {
const out = path.join(TMP, 'cost');
fs.mkdirSync(out, { recursive: true });
const file = path.join(out, 'spend.json');
reserve(file, 'r-a1', 5, { cap: 5.2, label: 'ceo/native' }); settle(file, 'r-a1', 'unknown');
reserve(file, 'shadow-r1-1', 0.15, { label: 'shadow' }); settle(file, 'shadow-r1-1', 0.014);
const list: GateList = { schema_version: 1, run: 'r', gate_rev: 1, items: [
{ id: 'd1', title: 't', kind: 'auto', recommended: 'b', phase: 'ceo', options: [{ key: 'a', text: 'keep', changes_plan: false }, { key: 'b', text: 'change', changes_plan: true }] },
{ id: 'p1', title: 'Plan approval', kind: 'approval', recommended: 'b', options: [{ key: 'a', text: 'x' }, { key: 'b', text: 'y' }] }] };
const c = gateCost(out, list, { phase_order: ['ceo', 'eng'], outside_enabled: true, spend_cap_usd: 5.2 });
expect(c.per_voice_usd).toBe(5);
expect(c.attempts).toBe(1);
expect(c.shadow_usd).toBe(0.014);
expect(c.summary.worst_case_usd).toBe(5.014);
const [spend, rec] = renderGateCost(c);
expect(spend).toContain('attempts=1 shadow=0.0140');
expect(rec).toContain('× 5.00');
expect(rec).toContain('(over the cap: raise it with gstack-autoplan budget before answering rec)');
});
});
+2
View File
@@ -137,6 +137,8 @@
{ "file": "lib/autoplan-run.ts", "movedFrom": "(new: next/submit/resume over lib/autoplan-state.ts, B2)" },
{ "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)" },
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
/**
* A local stand-in for the Anthropic Messages and OpenAI Chat Completions APIs,
* so the shadow-mode tests (test/autoplan-shadow*.test.ts) never make a real
* network call: the tests point ANTHROPIC_BASE_URL and OPENAI_BASE_URL at it.
* Usage: bun test/fixtures/shadow-fake-model.ts <dir>. It writes <dir>/port,
* appends every request to <dir>/requests.jsonl, and answers the n-th request
* with step n of <dir>/script.json (the last step repeats; default valid):
* { "mode": valid | missing | duplicate | foreign | bad_option | bad_cite | bad_conf | garbage | truncated | send_back | http500,
* "delay_ms"?: n }. A valid reply picks each question's first option with the
* question's first finding as its cite; usage is 1,000 input and 500 output tokens.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
const dir = process.argv[2]!;
interface Step { mode: string; delay_ms?: number }
interface Question { id: string; options: Array<{ key: string }>; findings: Array<{ id: string }> }
let served = 0;
function questions(prompt: string): Question[] {
const at = prompt.indexOf('## The questions');
const block = /```json\n([\s\S]*?)\n```/.exec(prompt.slice(at))?.[1];
return block ? JSON.parse(block) : [];
}
function answer(mode: string, qs: Question[]): string {
const rows: Array<Record<string, unknown>> = qs.map(q => ({ id: q.id, action: mode === 'send_back' ? 'send_back' : 'decide', pick: q.options[0]!.key, confidence: 0.75, cites: q.findings.slice(0, 1).map(f => f.id), reason: 'fake prediction' }));
if (mode === 'missing') rows.pop();
if (mode === 'duplicate') rows.push({ ...rows[0]! });
if (mode === 'foreign') rows.push({ ...rows[0]!, id: 'zz9' });
if (mode === 'bad_option') rows[0]!.pick = 'z';
if (mode === 'bad_cite') rows[0]!.cites = ['not-a-finding'];
if (mode === 'bad_conf') rows[0]!.confidence = 1.5;
if (mode === 'garbage') return 'I would approve it.';
if (mode === 'truncated') return '```json\n[{"id": "' + (qs[0]?.id ?? 'p1') + '", "act';
return 'Here is my prediction.\n```json\n' + JSON.stringify(rows, null, 2) + '\n```\n';
}
const server = Bun.serve({
port: 0, hostname: '127.0.0.1',
async fetch(req) {
const url = new URL(req.url);
const body = await req.json() as Record<string, any>;
let steps: Step[] = [{ mode: 'valid' }];
try { steps = JSON.parse(fs.readFileSync(path.join(dir, 'script.json'), 'utf8')); } catch { /* default */ }
const step = steps[Math.min(served, steps.length - 1)]!;
served++;
fs.appendFileSync(path.join(dir, 'requests.jsonl'), JSON.stringify({ n: served, path: url.pathname, mode: step.mode, body, at: Date.now() }) + '\n');
if (step.delay_ms) await Bun.sleep(step.delay_ms);
if (step.mode === 'http500') return Response.json({ error: { message: 'fake overloaded' } }, { status: 500 });
const text = answer(step.mode, questions(String(body.messages?.[0]?.content ?? '')));
const cut = step.mode === 'truncated';
if (url.pathname.endsWith('/v1/messages')) return Response.json({ model: body.model, content: [{ type: 'text', text }], stop_reason: cut ? 'max_tokens' : 'end_turn', usage: { input_tokens: 1000, output_tokens: 500 } });
return Response.json({ model: body.model, choices: [{ message: { content: text }, finish_reason: cut ? 'length' : 'stop' }], usage: { prompt_tokens: 1000, completion_tokens: 500 } });
},
});
fs.writeFileSync(path.join(dir, 'port.tmp'), String(server.port));
fs.renameSync(path.join(dir, 'port.tmp'), path.join(dir, 'port'));
+1
View File
@@ -210,6 +210,7 @@ export const FREE_FIXTURES: Readonly<Record<string, readonly string[]>> = {
'test/fixtures/review-gate-integrity-opus-reports.json': ['test/gate-integrity-templates.test.ts'],
'test/fixtures/review-wrong-tree/**': ['test/review-dispatch.test.ts'],
'test/fixtures/session-update-heartbeat.sh': ['test/session-update-heartbeat.test.ts'],
'test/fixtures/shadow-fake-model.ts': ['test/autoplan-shadow-driver.test.ts', 'test/autoplan-shadow.test.ts'],
'test/fixtures/shard-cli-parity/**': ['test/shard-cli-parity.test.ts'],
'test/fixtures/shard-equivalence/**': ['test/shard-engine-equivalence.test.ts'],
'test/fixtures/shared-libs-claude-version-probe.json': ['test/shared-libs-fixture.test.ts'],