autoplan: shadow-mode decider module, frozen prompt and spend accounting

The switch (off by default, fixed at init), the lease, one model call per
gate with a page retry and a truncation retry under one deadline, the
once-per-revision record with its journaled sha256, the 10% audit mark,
and shadow-labeled ledger rows that count against the cap, show as
shadow= on GATE_SPEND and stay out of the per-voice price (GST-537 PR 3).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-11-01 18:32:02 +00:00
1 parent aaa637ea43
commit 0e179ea4ea
9 files changed
+883 -12

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>"}
```
+5 -2
View File
@@ -143,10 +143,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 });
+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. */
+11 -5
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,8 +39,12 @@ 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() });
@@ -61,6 +66,7 @@ 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); }
@@ -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;
}
+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 prompt = deciderText.replace('{{PLAN}}', () => plan).replace('{{QUESTIONS}}', () => '```json\n' + JSON.stringify(items.map(i => i.frozen), null, 2) + '\n```')
.replace('{{IDS}}', () => items.map(i => i.item.id).join(', '));
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: state.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);
fs.writeFileSync(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.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;
+277
View File
@@ -0,0 +1,277 @@
/**
* 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 = { 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);
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 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', 'uc2', '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[2]!.findings.map(f => f.id).sort()).toEqual([...page.items[0]!.findings, ...page.items[1]!.findings].map(f => f.id).sort());
expect(page.prompt).toContain('"id": "uc1"');
expect(page.prompt).not.toContain('{{');
});
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[2]], 'duplicate'],
['unknown option', valid.map((v, i) => (i === 2 ? { ...v, pick: 'z' } : v)), 'pick "z" is not an option of p1'],
['foreign cite', valid.map((v, i) => (i === 2 ? { ...v, cites: ['nope'] } : v)), 'cites ["nope"] name a finding outside p1\'s origin'],
['confidence above 1', valid.map((v, i) => (i === 2 ? { ...v, confidence: 1.2 } : v)), 'confidence 1.2 is not a number from 0 to 1'],
['bad action', valid.map((v, i) => (i === 2 ? { ...v, action: 'approve' } : v)), 'action "approve" is not decide, send_back or hold'],
['long reason', valid.map((v, i) => (i === 2 ? { ...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 () => {
const saved = process.env.GSTACK_STATE_ROOT;
process.env.GSTACK_STATE_ROOT = f.env.GSTACK_STATE_ROOT;
afterAll(() => { if (saved === undefined) delete process.env.GSTACK_STATE_ROOT; else process.env.GSTACK_STATE_ROOT = saved; });
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], ['uc2', 'recorded', 'a', 'a', 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 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']);
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/3');
});
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', '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)');
});
});
+1
View File
@@ -128,6 +128,7 @@
{ "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-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'));