Files
gstack/test/autoplan-shadow-driver.test.ts
Garry Tanandcapy-ai[bot] 9d9d4c31bb v1.93.0.0 wave(browse): #3700 #3773 #3779 #3817 #3842 (#3858)
* feat(loadouts): Opus 5.5 lite loadout with explicit model detection (GSTA-161)

modules/loadouts.json gains the lite loadout (eleven coaching preamble
pieces dropped; upgrade-check and brain-sync kept) and a models map that
sends only claude-opus-5-5 (and its dated snapshots) to it. Detection is
explicit: Capy passes --model to the installer, Claude Code opts in with
./setup --loadout lite; anything unmapped, invalid or unreadable serves full.
The checkout always stays the full render.

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

* docs(loadouts): Opus 5.5 lite, detection order, Capy SKILL_ROOT, setup --loadout; troubleshooting anchors; changelog fragment (GSTA-161)

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

* fix(loadouts): claude_loadout read through CONFIG_BIN; capy skill context ceiling for the --model line (GSTA-161)

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

* fix: pre-landing review fixes (prune keeps a reused loadout folder, GSTACK_LOADOUT_SKILL_ROOT, claude_loadout guard test) (GSTA-161)

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

* fix: pre-landing review fixes (Capy skill passes the model as GSTACK_MODEL so an older installer ignores it) (GSTA-161)

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

* fix: pre-landing review fixes (prune only owned, unused folders; full root from the canonical Claude install; fallback reason and source) (GSTA-161)

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

* docs: loadout source example, install and resolve commands, Capy launch-file note (GSTA-161)

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

* feat(autoplan): --ceo-only runs the CEO review alone with zero questions (GST-537)

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

* fix: pre-landing review fixes

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

* test: classify autoplan-ceo-only in the Codex host metadata

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

* fix: select autoplan-ceo-only on the skills it runs; say it needs Claude Code

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

* test: run autoplan-ceo-only in the marathon lane

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

* docs: changelog entry for /autoplan --ceo-only

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

* test: count the autoplan-ceo-only paid file in the shard inventory

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

* feat: gstack-autoplan next --ceo-only runs the CEO review alone and ends reviewed (GST-537)

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

* fix: budget and close recover a budget-stopped CEO-only run; reviewed is at rest for checkpoints

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

* fix: CEO-only finish leaves owner-only cap items undecided, refuses marker phase flags and a plan at the report path, and replaces ceo-review.md atomically

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

* fix: the CEO-only report-path guard compares real paths and refuses at the finish when the report file is the plan

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

* docs: CEO_ONLY_CONFLICT covers a plan linked to the report path

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

* test(browse): failing-first device and touch tests (GSTA-227 F3, #3058)

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

* fix: the report-path guard keeps the lexical check, skips safe hard links and compares bigint file ids

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

* browse: per-session tabs for parallel agents; tab pins stop moving the shared active tab (#1961)

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

* browse: keep the server.ts tab-pin anchor comment for source guards

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

* feat(browse): per-tab phone emulation and touch input ($B device, tap, swipe, pinch, touch; #3058, GSTA-227 F3)

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

* test: copy lib/result-codes-autoplan.ts into the fixtures that copy the result-code tables, and keep it out of derived closures

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

* docs(qa, aside): touch pointer in /qa, Aside touch recipe verified on Aside 1.26, report Phones row (GSTA-227 F3, #3058)

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

* browse: drop the sessions paragraph from /browse to stay inside the parity cap

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

* changelog: F3 phone emulation and touch entry

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

* browse: hide the ps window in the session harness walk

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

* docs: the decisions.jsonl status list names auto_kept

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

* test(browse): session cap, LRU eviction, headed release and scoped auto-session rows

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

* browse: sessions keep tab <id> picks, sidebar claude stays on the user's tab, pinned rebuild and state load rebind the scope

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

* fix: pre-landing review fixes (touch cancel with no finger held is a no-op)

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

* fix: Step 11 review fixes (headed tab adoption, pinned viewport reset, CLI retry keeps --session, rebuild id remap, --json notice, evicted-session reset, device kept after annotated captures, render opts out of sessions, session: client ids reserved)

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

* fix: headed viewport auto keeps the user's active tab; render's closetab opts out of sessions

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

* docs: sessions, phone emulation and touch in BROWSER.md; session: client ids reserved; TODOS #1961/#3058 shipped

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

* test: copy lib/result-codes-browse.ts into the fixtures that copy the result-code modules

lib/result-codes.ts imports ./result-codes-browse, so fixtures with an explicit
lib list failed with 'Cannot find module'. pr-dependencies lists it with the other
reason-code modules as selection data. GSTA-227

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

* feat: gstack-clean reclaims per-repo .gstack scratch and logs, dry run by default (#2627)

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

* browse: name the Aside touch recipe's coordinates <tap-x>/<tap-y> with a numeric grammar

The generated-bash lint (INV-3) rejected the free-text <x>/<y> placeholders. GSTA-227

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

* fix(clean): leave hard-linked and unreadable logs alone; cut through a no-follow descriptor checked against the evaluated inode

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

* test(clean): tests that expect tmp removal see a quiet ps, so a codex elsewhere on the host cannot flake them

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

* fix(clean): an unlistable tmp/ skips its group; liveness asked once per group on apply; BROWSER.md says which logs reset at daemon start

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

* fix(clean): write output synchronously so --json reaches a pipe whole; test fixes for root, wrapper git and the ps stub

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

* fix(clean): every change rechecks the verified folder and the measured file; full writes; EPIPE ends output quietly

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

* test(clean): the early-close test checks the bin's own exit status

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

* docs: list gstack-clean among the standalone CLIs

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

* test(clean): copied-install fixtures and the dependency list carry lib/result-codes-clean.ts

The remote pregate failed 11 cases in gstack-doctor, plan-tune-cathedral and
timeline-stop-hook-slug: each copies an explicit list of lib/result-codes-*.ts
files into a temp install, and lib/result-codes.ts now imports
./result-codes-clean. Same lists GSTA-168 extended for result-codes-eval.

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

* chore: regenerate gstack-doctor-components.sh for the gstack-clean result codes

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

* qa: fold the touch pointer into the Phases sentence to fit the parity size cap after the main merge

qa's union was 95988 bytes (ratio 1.142 > maxSizeRatio 1.142, train 75). Now 95975 (1.1419). GSTA-227

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

* docs: #3058 troubleshooting 'Fixed in' lines name v1.92.10.0 after the main merge

GSTA-227

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

* test(clean): classify gstack-tasks rows files (tasks-*) as kept, after main added the producer

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

* docs: #3058 troubleshooting 'Fixed in' lines name v1.92.20.0 after the main merge

GSTA-227

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

* fix: pre-landing review fixes (restore the lib/result-codes-plan.ts ratchet row the base merge dropped)

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

* test(clean): the producer scan ends a name at a colon, so prose after a path names the file (train 78)

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

* docs: #3058 troubleshooting 'Fixed in' lines name v1.92.30.0 after the main merge

GSTA-227

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

* outside-voice-api: caller receipts, stop_reason, OpenAI max_completion_tokens

A caller can write its own egress receipt (sink, consent, payload class);
the default stays the outside-voice receipt, so existing callers send and
record the same bytes. The result carries the provider's stop reason, and
an OpenAI request gets max_completion_tokens only when the caller sets an
output limit (GST-537 PR 3).

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

* autoplan gate: item classes and the owner's answer provenance

Gate items carry item_class, ruleKept() names the keep-direction pick, and
answered decision rows record owner_mode, owner_named, rec_hash and
answered_role parsed from the owner's own words (GST-537 PR 3).

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

* 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>

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

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

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

* changelog: shadow mode for the CEO-only decider (GST-537 PR 3)

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

* autoplan shadow: /ship review fixes

- register test/fixtures/shadow-fake-model.ts in FREE_FIXTURES (free-fixtures inventory failed)
- withRunLock: count dead-owner reclaims apart from waitMs iterations, so a long wait can still reclaim
- buildPage: fill the decider template in one pass, so slot names inside the plan stay plan text
- revise regate: the shared publish step prints the GATE and ESCALATE lines once

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

* autoplan shadow: reply files never follow a link; the retry reserves against the current cap

- a model reply is written by temp file and rename, so a link at the reply path is replaced, not written through
- each shadow reservation reads the run's current spend cap, so a budget change during the call is not undone

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

* outside-voice-api: write the egress receipt under the caller's env

callModel resolved the receipt's state root from process.env even when the caller passed env, so the shadow
tests wrote to the real ~/.gstack (the free-suite home-containment guard caught it). The receipt now uses
req.env; the shadow tests give the fake model a private HOME, GSTACK_HOME and GSTACK_STATE_ROOT, and the receipt
test no longer sets process.env to pass.

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

* fix(autoplan): CEO-only runs leave main's open Critical/High finding items to the owner (no decision row)

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

* fix(autoplan): shadow pages carry main's finding items; a finding item is never ceo_eligible

After merging #3767 (main 90a170c), open Critical/High findings are gate items
(item_class finding). The shadow records one row per gate item, so its pages now
include them. A CEO-only run leaves those items to the owner, so their rows are
ceo_eligible false (rule_pick was already null). The shadow fixtures follow
main's gate (items, counts, "Do it" tokens); the two merge adapters follow
main's doNext(out, rest, price) and its per-voice reservation (--estimate-usd).

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

* docs: #3058 troubleshooting 'Fixed in' lines name v1.92.61.0 after the main merge

GSTA-227

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

* fix(investigate): drop the five-phase step procedure (GST-938)

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

* changelog: GST-938 fragment wording

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

* fix(investigate): keep the web-search sanitize guard; update BROWSER.md and docs/skills.md (review pass 1)

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

* changelog: GST-938 wording

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

* test(investigate): pin the web-search guard; changelog size (adversarial review)

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

* test(clean): classify restamp-staging.json, the restamp producer main gained after #3779's base (wave browse)

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

* fix: pre-landing review fixes (wave browse): scoped newtab keeps a concurrent tab switch; shadow validate catches a deleted record; a failed shadow publish is journaled

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

* fix: a failed shadow publish journals under the run lock and never throws (wave browse review pass 2)

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

* docs(unattended): shadow publish failures and the missing-record validate check (wave browse docs audit)

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

* changelog: wave browse integrator fixes fragment

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

* fix: shadow reader dedupes by run folder, so two runs started in the same second both count (wave browse CI free-suite 16)

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

* chore: regenerate doctor result anchors in generator order (wave browse rebuild without #3818)

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

* changelog: name the wave fragment after the rebuilt wave

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

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-11-02 14:49:46 +00:00

427 lines
28 KiB
TypeScript

/**
* 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.rmSync(record(out));
const missing = validate();
expect(missing.status).toBe(1);
expect(missing.stderr).toContain('(ARTIFACT_STALE)');
expect(missing.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('two runs that share a run id are both counted; the same folder listed twice is counted once', () => {
const copy = path.join(TMP, 'fail-copy', 'run');
fs.cpSync(path.join(TMP, 'fail', 'run'), copy, { recursive: true });
expect(readRun(copy).run).toBe(readRun(path.join(TMP, 'fail', 'run')).run);
const list = path.join(TMP, 'runs-same-id.txt');
fs.writeFileSync(list, [path.join(TMP, 'fail', 'run'), copy, copy].join('\n') + '\n');
const r = exported(['--runs', list], baseEnv({ GSTACK_CAPY: '' }));
expect(r.status).toBe(0);
expect(JSON.parse(r.stdout).counts.status).toEqual({ failed: 14 });
});
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');
});
});