@pareto4/pp-core 1.21.1 → 1.23.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/bin/install.js +205 -28
  3. package/commands/pp-help.md +2 -1
  4. package/gemini-extension.json +1 -1
  5. package/hooks/dist/pp-check-update-worker.js +1 -1
  6. package/hooks/dist/pp-context-monitor.js +3 -3
  7. package/hooks/dist/pp-statusline.js +88 -14
  8. package/hooks/dist/pp-update-banner.js +44 -4
  9. package/hooks/dist/pp-workflow-guard.js +3 -3
  10. package/hooks/pp-check-update-worker.js +1 -1
  11. package/hooks/pp-context-monitor.js +3 -3
  12. package/hooks/pp-statusline.js +88 -14
  13. package/hooks/pp-update-banner.js +44 -4
  14. package/hooks/pp-workflow-guard.js +3 -3
  15. package/package.json +2 -2
  16. package/pp-core/bin/lib/audience-command.cjs +271 -0
  17. package/pp-core/bin/lib/audience.cjs +293 -0
  18. package/pp-core/bin/lib/commands.cjs +26 -17
  19. package/pp-core/bin/lib/discovery-charter.cjs +13 -1
  20. package/pp-core/bin/lib/discovery-flow-page.cjs +191 -0
  21. package/pp-core/bin/lib/discovery-for-phase.cjs +33 -5
  22. package/pp-core/bin/lib/discovery-input-check.cjs +11 -11
  23. package/pp-core/bin/lib/discovery-processes.cjs +97 -4
  24. package/pp-core/bin/lib/discovery-session-check.cjs +7 -7
  25. package/pp-core/bin/lib/discovery-status.cjs +419 -0
  26. package/pp-core/bin/lib/discovery-stories.cjs +36 -5
  27. package/pp-core/bin/lib/discovery-tests.cjs +398 -0
  28. package/pp-core/bin/lib/explain-command.cjs +48 -0
  29. package/pp-core/bin/lib/gate-card.cjs +97 -0
  30. package/pp-core/bin/lib/hook-messages.cjs +132 -0
  31. package/pp-core/bin/lib/init-contract.cjs +116 -76
  32. package/pp-core/bin/lib/init-interactive.cjs +110 -49
  33. package/pp-core/bin/lib/init.cjs +44 -3
  34. package/pp-core/bin/lib/io.cjs +38 -0
  35. package/pp-core/bin/lib/message-labels.cjs +75 -0
  36. package/pp-core/bin/lib/messages/core.cjs +20 -0
  37. package/pp-core/bin/lib/messages/discovery.cjs +267 -0
  38. package/pp-core/bin/lib/messages/gate.cjs +257 -0
  39. package/pp-core/bin/lib/messages/hook.cjs +114 -0
  40. package/pp-core/bin/lib/messages/init.cjs +513 -0
  41. package/pp-core/bin/lib/messages/progress.cjs +110 -0
  42. package/pp-core/bin/lib/messages.cjs +208 -0
  43. package/pp-core/bin/lib/vault-command-router.cjs +16 -3
  44. package/pp-core/bin/lib/violation-render.cjs +78 -0
  45. package/pp-core/bin/pp-tools.cjs +92 -23
  46. package/pp-core/bin/shared/config-schema.manifest.json +1 -0
  47. package/pp-core/references/audience-moments.md +119 -0
  48. package/pp-core/references/audience.md +114 -0
  49. package/pp-core/references/discovery-build-consumption.md +5 -2
  50. package/pp-core/references/discovery-charter.md +2 -2
  51. package/pp-core/references/discovery-id-conventions.md +1 -1
  52. package/pp-core/references/discovery-processes.md +31 -7
  53. package/pp-core/references/discovery-session-output.md +1 -1
  54. package/pp-core/references/discovery-tests.md +186 -0
  55. package/pp-core/templates/project-charter.md +3 -4
  56. package/pp-core/workflows/add-phase.md +2 -0
  57. package/pp-core/workflows/add-tests.md +2 -0
  58. package/pp-core/workflows/add-todo.md +2 -0
  59. package/pp-core/workflows/ai-integration-phase.md +2 -0
  60. package/pp-core/workflows/audit-milestone.md +2 -0
  61. package/pp-core/workflows/autonomous.md +2 -0
  62. package/pp-core/workflows/check-todos.md +2 -0
  63. package/pp-core/workflows/code-review-fix.md +2 -0
  64. package/pp-core/workflows/code-review.md +2 -0
  65. package/pp-core/workflows/complete-milestone.md +10 -1
  66. package/pp-core/workflows/discovery-sprint/charter.md +3 -2
  67. package/pp-core/workflows/discovery-sprint/prefill.md +3 -0
  68. package/pp-core/workflows/discovery-sprint/prototype.md +14 -0
  69. package/pp-core/workflows/discovery-sprint/session-13-gates.md +2 -0
  70. package/pp-core/workflows/discovery-sprint/sessions/01-kick-off.md +4 -1
  71. package/pp-core/workflows/discovery-sprint/sessions/03-to-be.md +135 -76
  72. package/pp-core/workflows/discovery-sprint/sessions/09-qa.md +94 -64
  73. package/pp-core/workflows/discovery-sprint/sessions/13-wrap-up.md +3 -2
  74. package/pp-core/workflows/discovery-sprint/vault-context.md +15 -0
  75. package/pp-core/workflows/discovery-sprint.md +16 -2
  76. package/pp-core/workflows/discovery-to-build/steps/handoff-gates.md +16 -9
  77. package/pp-core/workflows/discovery-to-build/templates/discovery-references.md +1 -1
  78. package/pp-core/workflows/discovery-to-build/templates/pp-handoff.md +9 -1
  79. package/pp-core/workflows/discovery-to-build.md +10 -4
  80. package/pp-core/workflows/discuss-phase/discovery.md +2 -2
  81. package/pp-core/workflows/discuss-phase-assumptions.md +2 -0
  82. package/pp-core/workflows/discuss-phase.md +1 -1
  83. package/pp-core/workflows/edit-phase.md +2 -0
  84. package/pp-core/workflows/eval-review.md +2 -0
  85. package/pp-core/workflows/execute-phase/steps/check-query-gate.md +25 -1
  86. package/pp-core/workflows/execute-phase/steps/vault-fetch.md +1 -0
  87. package/pp-core/workflows/execute-phase/steps/vault-load-gate.md +2 -0
  88. package/pp-core/workflows/execute-phase.md +1 -1
  89. package/pp-core/workflows/execute-plan.md +2 -0
  90. package/pp-core/workflows/extract-learnings.md +2 -0
  91. package/pp-core/workflows/help/modes/business.md +56 -0
  92. package/pp-core/workflows/help.md +12 -2
  93. package/pp-core/workflows/import.md +10 -1
  94. package/pp-core/workflows/ingest-docs.md +2 -0
  95. package/pp-core/workflows/init.md +17 -1
  96. package/pp-core/workflows/insert-phase.md +2 -0
  97. package/pp-core/workflows/list-workspaces.md +2 -0
  98. package/pp-core/workflows/manager.md +2 -0
  99. package/pp-core/workflows/map-codebase.md +2 -0
  100. package/pp-core/workflows/milestone-summary.md +2 -0
  101. package/pp-core/workflows/new-milestone.md +10 -1
  102. package/pp-core/workflows/new-project.md +12 -2
  103. package/pp-core/workflows/new-workspace.md +2 -0
  104. package/pp-core/workflows/next.md +8 -0
  105. package/pp-core/workflows/plan-milestone-gaps.md +10 -1
  106. package/pp-core/workflows/plan-phase.md +2 -1
  107. package/pp-core/workflows/plan-review-convergence.md +1 -1
  108. package/pp-core/workflows/progress.md +10 -0
  109. package/pp-core/workflows/quick.md +11 -1
  110. package/pp-core/workflows/remove-phase.md +2 -0
  111. package/pp-core/workflows/remove-workspace.md +2 -0
  112. package/pp-core/workflows/resume-project.md +25 -0
  113. package/pp-core/workflows/review.md +2 -0
  114. package/pp-core/workflows/scan.md +2 -0
  115. package/pp-core/workflows/secure-phase.md +2 -0
  116. package/pp-core/workflows/settings.md +41 -0
  117. package/pp-core/workflows/ship.md +2 -0
  118. package/pp-core/workflows/spec-phase.md +1 -1
  119. package/pp-core/workflows/ui-phase.md +2 -0
  120. package/pp-core/workflows/ui-review.md +2 -0
  121. package/pp-core/workflows/ultraplan-phase.md +2 -0
  122. package/pp-core/workflows/validate-phase.md +2 -0
  123. package/pp-core/workflows/vault-review.md +2 -0
  124. package/pp-core/workflows/verify-phase.md +2 -0
  125. package/pp-core/workflows/verify-work.md +2 -0
  126. package/scripts/affected-tests-lib.cjs +26 -12
  127. package/scripts/ci-release-only.cjs +158 -0
  128. package/scripts/live-runs/driver.cjs +326 -0
  129. package/scripts/live-runs/run.cjs +906 -0
  130. package/scripts/live-runs/scenarios.cjs +326 -0
  131. package/scripts/live-runs/scratch-project.cjs +132 -0
  132. package/scripts/live-runs/scrub.cjs +291 -0
  133. package/scripts/live-runs/turn-guard.cjs +107 -0
  134. package/scripts/live-runs/walk-setup.cjs +198 -0
  135. package/scripts/run-tests.cjs +151 -132
  136. package/scripts/test-env.cjs +64 -0
@@ -0,0 +1,906 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * run.cjs - Phase 44 REACH-02 live-run CLI (D-01 .. D-12).
5
+ *
6
+ * `node scripts/live-runs/run.cjs --scenario s1|s2|s3` is the only way a live run
7
+ * starts. It is never required by `npm test` or CI: a live run costs money and needs
8
+ * a login, so it runs only when someone asks for it by name (D-02). Inside the test
9
+ * suite it refuses to spawn a model unless PP_LIVE_CLAUDE_BIN points at a fake.
10
+ *
11
+ * What it ties together:
12
+ * - scripts/live-runs/scratch-project.cjs and scenarios.cjs: a throwaway project that
13
+ * holds this build, and the fixed user turns of the scenario;
14
+ * - scripts/live-runs/driver.cjs: one `claude -p` turn at a time, with caps;
15
+ * - scripts/live-runs/scrub.cjs: the transcript is scrubbed before the first write (D-11);
16
+ * - tests/helpers/live-run-check.cjs: the verdict (D-09, D-10), loaded lazily because
17
+ * tests/ does not ship in the npm package.
18
+ *
19
+ * Outcomes (D-03): a run is pass, fail or invalid. "Cannot run here" (no CLI, not
20
+ * logged in, rate limited, nested launch refused, cost cap reached) is a recorded
21
+ * `<scenario>-r<round>.not-run.json` and exit 3, never a crash and never a pass.
22
+ * Runs are numbered per round (D-06): a fix re-runs all of a scenario's runs as round
23
+ * r+1 while round r stays on disk as evidence.
24
+ *
25
+ * Modes: run (default), --preflight, --status, --recheck DIR, --fixture-check.
26
+ * Exit codes: 0 ok, 1 failed (a fail verdict, an unsettled round, a re-check mismatch),
27
+ * 2 usage, 3 not run.
28
+ */
29
+
30
+ const { execFileSync } = require('node:child_process');
31
+ const crypto = require('node:crypto');
32
+ const fs = require('node:fs');
33
+ const os = require('node:os');
34
+ const path = require('node:path');
35
+
36
+ const driver = require('./driver.cjs');
37
+ const scrub = require('./scrub.cjs');
38
+ const { createScratchProject, REPO_ROOT } = require('./scratch-project.cjs');
39
+ const { SCENARIOS } = require('./scenarios.cjs');
40
+ const { ExitError, runMain } = require('../lib/cli-exit.cjs');
41
+
42
+ const DEFAULTS = Object.freeze({
43
+ runs: 3,
44
+ round: 1,
45
+ maxAttempts: 6,
46
+ turnWallMs: 600000,
47
+ turnBudgetUsd: 3,
48
+ ledgerCapUsd: 40,
49
+ out: path.join(REPO_ROOT, '.planning', 'phases', '44-proof-live-runs-walks', 'runs'),
50
+ });
51
+
52
+ const EXIT = Object.freeze({ OK: 0, FAILED: 1, USAGE: 2, NOT_RUN: 3 });
53
+
54
+ /** Base name of one attempt's files: scenario, round 1-9, run 1-5, attempt 1-99. */
55
+ const RESULT_NAME_RE = /^s[1-3]-r[1-9]-run[1-5]-a(?:[1-9]|[1-9][0-9])$/;
56
+
57
+ const OUTPUT_NAME_RES = Object.freeze([
58
+ /^ledger\.json$/,
59
+ /^preflight\.json$/,
60
+ /^s[1-3]-r[1-9]\.summary\.json$/,
61
+ /^s[1-3]-r[1-9]\.not-run\.json$/,
62
+ /^s[1-3]-r[1-9]-run[1-5]-a(?:[1-9]|[1-9][0-9])\.(?:jsonl|result\.json)$/,
63
+ ]);
64
+
65
+ const SCENARIO_IDS = Object.freeze(Object.keys(SCENARIOS));
66
+ const SCHEMA_VERSION = 1;
67
+ const DETAIL_MAX = 300;
68
+ const HARNESS_INVALID = Object.freeze(['timeout', 'crashed', 'no_session_id']);
69
+ const NOT_RUN_KINDS = Object.freeze(['auth', 'rate_limited', 'launch_refused', 'cli_missing']);
70
+ /** The not-run reason for a CLI that refused the harness's arguments: recorded, but never an OK outcome. */
71
+ const HARNESS_REJECTED = 'cli_rejected';
72
+ const MAX_CORPUS_FILE_BYTES = 1024 * 1024;
73
+
74
+ const USAGE_TEXT = [
75
+ 'usage: node scripts/live-runs/run.cjs --scenario s1|s2|s3 [options]',
76
+ ' node scripts/live-runs/run.cjs --preflight | --status --scenario <s> | --fixture-check --scenario <s> | --recheck DIR',
77
+ '',
78
+ 'options:',
79
+ ' --scenario s1|s2|s3 the scenario (required except for --recheck and --preflight)',
80
+ ' --runs N runs per round, 1-5 (default 3)',
81
+ ' --only N run only run number N of the round',
82
+ ' --round R round number, 1-9 (default 1); a fix re-runs everything as round R+1',
83
+ ' --max-attempts N attempts per round, from --runs up to 15 (default 6)',
84
+ ' --turn-wall-ms N wall time per turn, 1000-1800000 (default 600000)',
85
+ ' --turn-budget-usd X dollar cap per turn, 0.05-10 (default 3)',
86
+ ' --ledger-cap-usd X dollar cap across the phase, 0.5-200 (default 40)',
87
+ ' --out DIR evidence directory (default the phase runs/ directory)',
88
+ ' --force run again even when a run already has a pass or fail verdict',
89
+ ' --keep-scratch keep the scratch project and print where it is',
90
+ ' --preflight check the CLI and the login with one cheap turn',
91
+ ' --status offline: is the round settled? (needs --scenario and --round, and --runs N',
92
+ ' unless the round has a summary file to take its run count from)',
93
+ ' --fixture-check offline: is the scenario fixture the project the scenario needs?',
94
+ ' --recheck DIR offline: re-run the checker over kept transcripts',
95
+ ' --help this text',
96
+ '',
97
+ 'exit codes: 0 ok, 1 failed or unsettled, 2 usage, 3 not run (reason in <scenario>-r<round>.not-run.json)',
98
+ ].join('\n');
99
+
100
+ // ── Checker (loaded lazily: tests/ does not ship in the npm package) ─────────
101
+
102
+ function loadChecker() {
103
+ const file = path.join(REPO_ROOT, 'tests', 'helpers', 'live-run-check.cjs');
104
+ try {
105
+ return require(file);
106
+ } catch (err) {
107
+ if (err && err.code === 'MODULE_NOT_FOUND' && String(err.message).includes('live-run-check.cjs')) {
108
+ throw new ExitError(EXIT.USAGE, 'live runs need a repository checkout: tests/helpers/live-run-check.cjs is missing (run from a repository checkout)');
109
+ }
110
+ throw err;
111
+ }
112
+ }
113
+
114
+ // ── Arguments ────────────────────────────────────────────────────────────────
115
+
116
+ const usageError = (message) => new ExitError(EXIT.USAGE, message);
117
+
118
+ /** A number flag, fail closed: set but empty, non-numeric or out of range is a usage error. */
119
+ function numberFlag(name, raw, { min, max, integer = false }) {
120
+ if (typeof raw !== 'string' || raw.trim() === '') throw usageError(`${name} needs a value`);
121
+ const n = Number(raw);
122
+ if (!Number.isFinite(n) || (integer && !Number.isInteger(n)) || n < min || n > max) {
123
+ throw usageError(`${name} must be ${integer ? 'an integer' : 'a number'} from ${min} to ${max}, got "${raw}"`);
124
+ }
125
+ return n;
126
+ }
127
+
128
+ const VALUE_FLAGS = Object.freeze(['--scenario', '--runs', '--only', '--round', '--max-attempts', '--turn-wall-ms', '--turn-budget-usd', '--ledger-cap-usd', '--out', '--recheck']);
129
+ const MODE_FLAGS = Object.freeze({ '--preflight': 'preflight', '--status': 'status', '--fixture-check': 'fixture-check', '--recheck': 'recheck' });
130
+ const BOOL_FLAGS = Object.freeze(['--force', '--keep-scratch', '--help', '--preflight', '--status', '--fixture-check']);
131
+
132
+ /** Parse argv into one options object. Throws ExitError(2) for anything it does not understand. */
133
+ function parseArgs(argv) {
134
+ const raw = {};
135
+ const flags = new Set();
136
+ for (let i = 0; i < argv.length; i++) {
137
+ const flag = argv[i];
138
+ if (VALUE_FLAGS.includes(flag)) {
139
+ const value = argv[i + 1];
140
+ if (value === undefined || (value.startsWith('--') && value !== '')) throw usageError(`${flag} needs a value`);
141
+ raw[flag] = value;
142
+ i++;
143
+ } else if (BOOL_FLAGS.includes(flag)) {
144
+ flags.add(flag);
145
+ } else {
146
+ throw usageError(`unknown option: ${flag}`);
147
+ }
148
+ }
149
+
150
+ const modes = Object.keys(MODE_FLAGS).filter((f) => flags.has(f) || raw[f] !== undefined);
151
+ if (modes.length > 1) throw usageError(`pick one mode, got ${modes.join(' and ')}`);
152
+ const mode = modes.length === 1 ? MODE_FLAGS[modes[0]] : 'run';
153
+
154
+ const opts = {
155
+ mode,
156
+ help: flags.has('--help'),
157
+ force: flags.has('--force'),
158
+ keepScratch: flags.has('--keep-scratch'),
159
+ scenario: null,
160
+ runs: DEFAULTS.runs,
161
+ runsGiven: raw['--runs'] !== undefined,
162
+ only: null,
163
+ round: DEFAULTS.round,
164
+ maxAttempts: DEFAULTS.maxAttempts,
165
+ turnWallMs: DEFAULTS.turnWallMs,
166
+ turnBudgetUsd: DEFAULTS.turnBudgetUsd,
167
+ ledgerCapUsd: DEFAULTS.ledgerCapUsd,
168
+ out: DEFAULTS.out,
169
+ recheckDir: null,
170
+ };
171
+ if (opts.help) return opts;
172
+
173
+ if (raw['--scenario'] !== undefined) {
174
+ if (!SCENARIO_IDS.includes(raw['--scenario'])) throw usageError(`--scenario must be one of ${SCENARIO_IDS.join(', ')}, got "${raw['--scenario']}"`);
175
+ opts.scenario = raw['--scenario'];
176
+ }
177
+ if (raw['--runs'] !== undefined) opts.runs = numberFlag('--runs', raw['--runs'], { min: 1, max: 5, integer: true });
178
+ if (raw['--round'] !== undefined) opts.round = numberFlag('--round', raw['--round'], { min: 1, max: 9, integer: true });
179
+ if (raw['--only'] !== undefined) opts.only = numberFlag('--only', raw['--only'], { min: 1, max: opts.runs, integer: true });
180
+ if (raw['--max-attempts'] !== undefined) opts.maxAttempts = numberFlag('--max-attempts', raw['--max-attempts'], { min: opts.runs, max: 15, integer: true });
181
+ else if (opts.maxAttempts < opts.runs) opts.maxAttempts = opts.runs;
182
+ if (raw['--turn-wall-ms'] !== undefined) opts.turnWallMs = numberFlag('--turn-wall-ms', raw['--turn-wall-ms'], { min: 1000, max: 1800000, integer: true });
183
+ if (raw['--turn-budget-usd'] !== undefined) opts.turnBudgetUsd = numberFlag('--turn-budget-usd', raw['--turn-budget-usd'], { min: 0.05, max: 10 });
184
+ if (raw['--ledger-cap-usd'] !== undefined) opts.ledgerCapUsd = numberFlag('--ledger-cap-usd', raw['--ledger-cap-usd'], { min: 0.5, max: 200 });
185
+ if (raw['--out'] !== undefined) opts.out = path.resolve(raw['--out']);
186
+ if (raw['--recheck'] !== undefined) {
187
+ if (raw['--recheck'] === '') throw usageError('--recheck needs a directory');
188
+ opts.recheckDir = path.resolve(raw['--recheck']);
189
+ }
190
+
191
+ const needsScenario = mode === 'run' || mode === 'status' || mode === 'fixture-check';
192
+ if (needsScenario && !opts.scenario) throw usageError(`--scenario is required (${SCENARIO_IDS.join(', ')})`);
193
+ return opts;
194
+ }
195
+
196
+ // ── Output paths ─────────────────────────────────────────────────────────────
197
+
198
+ /** A path for one of the harness's own files: the name must be a known shape and the path must stay under `out`. */
199
+ function outputPath(out, name) {
200
+ if (!OUTPUT_NAME_RES.some((re) => re.test(name))) throw new Error(`run.cjs: refusing to write unexpected file name ${JSON.stringify(name)}`);
201
+ const root = path.resolve(out);
202
+ const full = path.join(root, name);
203
+ if (!full.startsWith(root + path.sep)) throw new Error(`run.cjs: ${name} resolves outside ${root}`);
204
+ return full;
205
+ }
206
+
207
+ const attemptBase = (scenario, round, run, attempt) => `${scenario}-r${round}-run${run}-a${attempt}`;
208
+
209
+ function writeJson(out, name, value) {
210
+ fs.writeFileSync(outputPath(out, name), `${JSON.stringify(value, null, 2)}\n`);
211
+ }
212
+
213
+ function readJsonIfPresent(file) {
214
+ if (!fs.existsSync(file)) return null;
215
+ try {
216
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
217
+ } catch {
218
+ return undefined;
219
+ }
220
+ }
221
+
222
+ const cut = (s) => String(s).trim().slice(0, DETAIL_MAX);
223
+
224
+ // ── Shipped markdown (the provenance corpus) ─────────────────────────────────
225
+
226
+ const corpusCache = new Map();
227
+
228
+ /** The text of every .md file under commands/, agents/ and pp-core/ (regular files, at most 1 MB each). */
229
+ function shippedCorpus(repoRoot) {
230
+ const root = path.resolve(repoRoot);
231
+ if (corpusCache.has(root)) return corpusCache.get(root);
232
+ const texts = [];
233
+ const walk = (dir) => {
234
+ let names;
235
+ try { names = fs.readdirSync(dir); } catch { return; }
236
+ for (const name of names.sort()) {
237
+ if (name === 'node_modules') continue;
238
+ const full = path.join(dir, name);
239
+ const st = fs.lstatSync(full);
240
+ if (st.isDirectory()) walk(full);
241
+ else if (st.isFile() && name.endsWith('.md') && st.size <= MAX_CORPUS_FILE_BYTES) texts.push(fs.readFileSync(full, 'utf8'));
242
+ }
243
+ };
244
+ for (const top of ['commands', 'agents', 'pp-core']) walk(path.join(root, top));
245
+ corpusCache.set(root, texts);
246
+ return texts;
247
+ }
248
+
249
+ // ── Ledger ───────────────────────────────────────────────────────────────────
250
+
251
+ /** The phase-wide cost ledger. An unreadable ledger stops the run: it must never reset the spend. */
252
+ function readLedger(out, capUsd) {
253
+ const file = path.join(path.resolve(out), 'ledger.json');
254
+ const data = readJsonIfPresent(file);
255
+ if (data === undefined) throw new ExitError(EXIT.FAILED, `ledger.json in ${out} cannot be read; fix or remove it before another run spends money`);
256
+ const spent = data && Number.isFinite(data.spent_usd) ? data.spent_usd : 0;
257
+ const entries = data && Array.isArray(data.entries) ? data.entries : [];
258
+ return { cap_usd: capUsd, spent_usd: spent, entries };
259
+ }
260
+
261
+ // ── Evidence on disk ─────────────────────────────────────────────────────────
262
+
263
+ const ATTEMPT_FILE_RE = /^(s[1-3])-r([1-9])-run([1-5])-a([1-9][0-9]?)\.(jsonl|result\.json)$/;
264
+
265
+ /** Every attempt of one round found on disk: { run, attempt, base, result } sorted by run then attempt. */
266
+ function readAttempts(out, scenario, round) {
267
+ const root = path.resolve(out);
268
+ let names = [];
269
+ try { names = fs.readdirSync(root); } catch { return []; }
270
+ const byBase = new Map();
271
+ for (const name of names) {
272
+ const m = ATTEMPT_FILE_RE.exec(name);
273
+ if (!m || m[1] !== scenario || Number(m[2]) !== round) continue;
274
+ const base = name.slice(0, name.indexOf('.'));
275
+ if (!byBase.has(base)) byBase.set(base, { run: Number(m[3]), attempt: Number(m[4]), base, result: null, resultFile: null });
276
+ if (m[5] === 'result.json') {
277
+ const data = readJsonIfPresent(path.join(root, name));
278
+ byBase.get(base).resultFile = name;
279
+ byBase.get(base).result = data === undefined ? { verdict: 'invalid', invalid_reason: 'unreadable_result' } : data;
280
+ }
281
+ }
282
+ return [...byBase.values()].sort((a, b) => a.run - b.run || a.attempt - b.attempt);
283
+ }
284
+
285
+ const hasVerdict = (a) => Boolean(a.result) && (a.result.verdict === 'pass' || a.result.verdict === 'fail');
286
+
287
+ /**
288
+ * One round's state. Per run: the highest attempt with a pass or fail verdict, else the
289
+ * highest attempt. `runs` is how many runs the round has (1..runs).
290
+ */
291
+ function summarise(out, scenario, round, runs) {
292
+ const attempts = readAttempts(out, scenario, round);
293
+ const rows = [];
294
+ let passed = 0;
295
+ let failed = 0;
296
+ let invalid = 0;
297
+ let missing = 0;
298
+ for (let run = 1; run <= runs; run++) {
299
+ const mine = attempts.filter((a) => a.run === run);
300
+ if (mine.length === 0) {
301
+ missing++;
302
+ rows.push({ run, attempt: null, verdict: 'missing', invalid_reason: null, file: null });
303
+ continue;
304
+ }
305
+ const decided = [...mine].reverse().find(hasVerdict);
306
+ const chosen = decided || mine[mine.length - 1];
307
+ const verdict = chosen.result ? chosen.result.verdict : 'invalid';
308
+ if (verdict === 'pass') passed++;
309
+ else if (verdict === 'fail') failed++;
310
+ else invalid++;
311
+ rows.push({
312
+ run,
313
+ attempt: chosen.attempt,
314
+ verdict: chosen.result ? verdict : 'invalid',
315
+ invalid_reason: chosen.result ? chosen.result.invalid_reason || null : 'no_result_file',
316
+ file: chosen.resultFile,
317
+ });
318
+ }
319
+ const costOf = (r) => {
320
+ if (!r) return 0;
321
+ if (Number.isFinite(r.charged_usd)) return r.charged_usd;
322
+ return Number.isFinite(r.cost_usd) ? r.cost_usd : 0;
323
+ };
324
+ const cost = attempts.reduce((sum, a) => sum + costOf(a.result), 0);
325
+ return {
326
+ scenario,
327
+ round,
328
+ target_runs: runs,
329
+ runs: rows,
330
+ passed,
331
+ failed,
332
+ invalid,
333
+ missing,
334
+ settled: passed + failed === runs,
335
+ cost_usd: Math.round(cost * 1e6) / 1e6,
336
+ };
337
+ }
338
+
339
+ function readNotRun(out, scenario, round) {
340
+ const name = `${scenario}-r${round}.not-run.json`;
341
+ const data = readJsonIfPresent(outputPath(out, name));
342
+ if (data === null) return null;
343
+ return data === undefined ? { reason: 'unreadable', detail: `${name} cannot be read` } : data;
344
+ }
345
+
346
+ // ── Offline modes ────────────────────────────────────────────────────────────
347
+
348
+ const defaultIo = () => ({
349
+ out: (t) => process.stdout.write(t),
350
+ err: (t) => process.stderr.write(t),
351
+ });
352
+ const say = (io, text) => (io.out || defaultIo().out)(`${text}\n`);
353
+ const warn = (io, text) => (io.err || defaultIo().err)(`${text}\n`);
354
+
355
+ /**
356
+ * How many runs the round has: --runs when given, else the largest of the target_runs its
357
+ * summary file and its not-run record recorded (a round that stopped as not run has no
358
+ * summary) and the highest run number on disk. A count is never smaller than the runs
359
+ * the folder holds, so a summary left from an earlier, smaller invocation cannot hide a
360
+ * later failed run. With no recorded target_runs, the count is unknown and --status
361
+ * refuses to guess (a round run with --runs 1 or 5 would be judged against the default 3).
362
+ */
363
+ function statusRuns(opts) {
364
+ if (opts.runsGiven) return opts.runs;
365
+ const validTarget = (t) => Number.isInteger(t) && t >= 1 && t <= 5;
366
+ const summary = readJsonIfPresent(outputPath(opts.out, `${opts.scenario}-r${opts.round}.summary.json`));
367
+ const notRun = readJsonIfPresent(outputPath(opts.out, `${opts.scenario}-r${opts.round}.not-run.json`));
368
+ const recorded = [summary && summary.target_runs, notRun && notRun.target_runs].filter(validTarget);
369
+ if (recorded.length === 0) {
370
+ throw usageError(`--status needs --runs N: ${opts.scenario} round ${opts.round} has no readable summary or not-run file to take the run count from`);
371
+ }
372
+ const seen = readAttempts(opts.out, opts.scenario, opts.round).reduce((m, a) => Math.max(m, a.run), 0);
373
+ return Math.max(seen, ...recorded);
374
+ }
375
+
376
+ /**
377
+ * Exit 0 when the round is settled (every run has a pass or fail verdict; the counts
378
+ * show any fail), or when a not-run record explains the missing or invalid runs of a
379
+ * round with no fail. A fail next to a not-run record exits 1: the record must never
380
+ * make a round with a failed run look settled. A `cli_rejected` record exits 1 as well:
381
+ * the CLI refused the harness's own arguments, which is a harness bug to fix, not a
382
+ * reason the round cannot run here (D-03).
383
+ */
384
+ function status(opts) {
385
+ const io = opts.io || defaultIo();
386
+ const s = summarise(opts.out, opts.scenario, opts.round, statusRuns(opts));
387
+ say(io, `${opts.scenario} round ${opts.round}: ${s.passed} passed, ${s.failed} failed, ${s.invalid} invalid, ${s.missing} missing`);
388
+ if (s.settled) return EXIT.OK;
389
+ if (s.failed > 0) return EXIT.FAILED;
390
+ const notRun = readNotRun(opts.out, opts.scenario, opts.round);
391
+ if (notRun) {
392
+ say(io, `${opts.scenario} round ${opts.round}: not run (${notRun.reason}: ${notRun.detail || 'no detail'})`);
393
+ return notRun.reason === HARNESS_REJECTED ? EXIT.FAILED : EXIT.OK;
394
+ }
395
+ return EXIT.FAILED;
396
+ }
397
+
398
+ const sortedUnique = (list) => [...new Set(list)].sort();
399
+ const sameList = (a, b) => JSON.stringify(sortedUnique(a)) === JSON.stringify(sortedUnique(b));
400
+
401
+ /** Re-run the checker over every kept transcript in `dir` and compare with what each result file recorded. */
402
+ function recheck(dir, opts = {}) {
403
+ const io = opts.io || defaultIo();
404
+ const root = path.resolve(dir);
405
+ let names = [];
406
+ try { names = fs.readdirSync(root).sort(); } catch { names = []; }
407
+ const resultFiles = names.filter((n) => n.endsWith('.result.json') && RESULT_NAME_RE.test(n.slice(0, -'.result.json'.length)));
408
+
409
+ if (resultFiles.length === 0) {
410
+ // a cli_rejected record is a harness bug, so it explains nothing (see status)
411
+ const notRun = names
412
+ .filter((n) => n.endsWith('.not-run.json'))
413
+ .some((n) => {
414
+ const record = readJsonIfPresent(path.join(root, n));
415
+ return !record || record.reason !== HARNESS_REJECTED;
416
+ });
417
+ const pre = readJsonIfPresent(path.join(root, 'preflight.json'));
418
+ const preflightExplains = Boolean(pre) && pre.auth !== 'ok';
419
+ if (notRun || preflightExplains) {
420
+ say(io, `no results in ${root}; a not-run or failed preflight record explains it`);
421
+ return EXIT.OK;
422
+ }
423
+ warn(io, `nothing to re-check in ${root}: no result files and no not-run record`);
424
+ return EXIT.FAILED;
425
+ }
426
+
427
+ const checker = loadChecker();
428
+ const corpus = shippedCorpus(REPO_ROOT);
429
+ const mismatches = [];
430
+ for (const file of resultFiles) {
431
+ const base = file.slice(0, -'.result.json'.length);
432
+ const recorded = readJsonIfPresent(path.join(root, file));
433
+ if (!recorded) {
434
+ mismatches.push(`${file}: the result file cannot be read`);
435
+ continue;
436
+ }
437
+ if (recorded.transcript !== `${base}.jsonl`) {
438
+ mismatches.push(`${file}: transcript field ${JSON.stringify(recorded.transcript)} is not ${base}.jsonl`);
439
+ continue;
440
+ }
441
+ const scenario = SCENARIOS[recorded.scenario];
442
+ if (!scenario || !base.startsWith(`${recorded.scenario}-`)) {
443
+ mismatches.push(`${file}: unknown scenario ${JSON.stringify(recorded.scenario)}`);
444
+ continue;
445
+ }
446
+ const transcriptFile = path.join(root, `${base}.jsonl`);
447
+ if (!fs.existsSync(transcriptFile)) {
448
+ mismatches.push(`${file}: ${base}.jsonl is missing`);
449
+ continue;
450
+ }
451
+ if (HARNESS_INVALID.includes(recorded.invalid_reason)) {
452
+ if (recorded.verdict !== 'invalid') mismatches.push(`${file}: recorded ${recorded.invalid_reason} but verdict ${recorded.verdict}`);
453
+ continue;
454
+ }
455
+ const { events } = checker.parseTranscript(fs.readFileSync(transcriptFile, 'utf8'));
456
+ const again = checker.checkRun({ scenario: recorded.scenario, events, turns: scenario.turns, corpus });
457
+ if (again.verdict !== recorded.verdict) mismatches.push(`${file}: recorded verdict ${recorded.verdict}, checker now says ${again.verdict}`);
458
+ if (!sameList(again.jargon, recorded.jargon || [])) mismatches.push(`${file}: jargon hits differ (recorded ${JSON.stringify(recorded.jargon || [])}, now ${JSON.stringify(again.jargon)})`);
459
+ const nowTokens = again.provenance_failures.map((f) => f.token);
460
+ const thenTokens = (recorded.provenance_failures || []).map((f) => f.token);
461
+ if (!sameList(nowTokens, thenTokens)) mismatches.push(`${file}: provenance failures differ (recorded ${JSON.stringify(thenTokens)}, now ${JSON.stringify(nowTokens)})`);
462
+ }
463
+
464
+ if (mismatches.length > 0) {
465
+ for (const m of mismatches) warn(io, `MISMATCH ${m}`);
466
+ say(io, `re-check: ${mismatches.length} mismatch(es) in ${resultFiles.length} result file(s)`);
467
+ return EXIT.FAILED;
468
+ }
469
+ say(io, `re-check: ${resultFiles.length} result file(s) agree with the checker`);
470
+ return EXIT.OK;
471
+ }
472
+
473
+ /** Build the scenario's starting project, ask the scenario whether it is what it needs, then remove it. */
474
+ function fixtureCheck(opts) {
475
+ const io = opts.io || defaultIo();
476
+ const scenario = SCENARIOS[opts.scenario];
477
+ let project;
478
+ try {
479
+ project = createScratchProject({
480
+ label: scenario.id,
481
+ audience: scenario.audience,
482
+ fixture: scenario.fixture,
483
+ initialCommit: scenario.initialCommit,
484
+ commands: [scenario.command],
485
+ tmpRoot: opts.tmpRoot,
486
+ });
487
+ const res = scenario.fixtureCheck(project.projectDir, { personHome: project.personHome });
488
+ if (res.ok) {
489
+ say(io, `fixture ${scenario.id}: ok (${String(res.detail).split('\n')[0]})`);
490
+ return EXIT.OK;
491
+ }
492
+ say(io, `fixture ${scenario.id}: FAILED ${res.detail}`);
493
+ return EXIT.FAILED;
494
+ } catch (err) {
495
+ say(io, `fixture ${scenario.id}: FAILED ${cut(err.message)}`);
496
+ return EXIT.FAILED;
497
+ } finally {
498
+ if (project) project.cleanup();
499
+ }
500
+ }
501
+
502
+ // ── Preflight and the run loop ───────────────────────────────────────────────
503
+
504
+ /**
505
+ * The paths that make up the shipped build: package.json and its `files` list. The
506
+ * dirty check looks only here, so uncommitted planning files and run evidence (the
507
+ * default --out sits inside the repo) do not mark every run build-dirty.
508
+ */
509
+ function shippedPaths(repoRoot = REPO_ROOT) {
510
+ try {
511
+ const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8'));
512
+ if (Array.isArray(pkg.files) && pkg.files.length > 0) {
513
+ return ['package.json', ...pkg.files.filter((f) => typeof f === 'string' && f !== '' && !f.startsWith('!'))];
514
+ }
515
+ } catch {
516
+ // no readable package.json: fall back to the whole repository
517
+ }
518
+ return null;
519
+ }
520
+
521
+ /** What the harness can record about this build and machine without a model. */
522
+ function preflight(opts) {
523
+ const env = opts.env || process.env;
524
+ const git = (args) => {
525
+ try {
526
+ return execFileSync('git', args, { cwd: REPO_ROOT, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 30000 });
527
+ } catch {
528
+ return null;
529
+ }
530
+ };
531
+ const head = git(['rev-parse', 'HEAD']);
532
+ const shipped = shippedPaths();
533
+ const porcelain = git(shipped ? ['status', '--porcelain', '--', ...shipped] : ['status', '--porcelain']);
534
+ let userClaudeMd = null;
535
+ try {
536
+ const file = path.join(env.HOME || os.homedir(), '.claude', 'CLAUDE.md');
537
+ if (fs.existsSync(file)) userClaudeMd = crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex').slice(0, 12);
538
+ } catch {
539
+ userClaudeMd = null;
540
+ }
541
+ return {
542
+ cliVersion: driver.cliVersion({ env }),
543
+ buildCommit: head === null ? null : head.trim(),
544
+ buildDirty: porcelain === null ? true : porcelain.trim() !== '',
545
+ userClaudeMd,
546
+ };
547
+ }
548
+
549
+ const UNTRUSTED_RE = /not (?:been )?trusted|trust this (?:folder|workspace|directory)|workspace trust/i;
550
+ const PROBE_PROMPT = 'Reply with the single word ready.';
551
+ const PROBE_BUDGET_USD = 0.25;
552
+ const PROBE_WALL_MS = 120000;
553
+
554
+ const nowIso = () => new Date().toISOString();
555
+ const roundCost = (n) => Math.round(n * 1e6) / 1e6;
556
+
557
+ /** Record "cannot run here" (D-03). The detail is scrubbed and cut to 300 characters (T-44-16). */
558
+ function recordNotRun(opts, pre, reason, detail, secrets) {
559
+ const scenario = opts.scenario;
560
+ const safe = cut(scrub.scrubText(String(detail || ''), secrets || scrub.hostSecrets({ env: opts.env })));
561
+ writeJson(opts.out, `${scenario}-r${opts.round}.not-run.json`, {
562
+ schema: SCHEMA_VERSION,
563
+ scenario,
564
+ round: opts.round,
565
+ // --status reads the run count from here when the round stopped before its summary
566
+ target_runs: opts.runs,
567
+ reason,
568
+ detail: safe,
569
+ at: nowIso(),
570
+ cli_version: pre.cliVersion,
571
+ build_commit: pre.buildCommit,
572
+ build_dirty: pre.buildDirty,
573
+ });
574
+ say(opts.io, `${scenario} round ${opts.round}: not run (${reason}: ${safe || 'no detail'})`);
575
+ return EXIT.NOT_RUN;
576
+ }
577
+
578
+ /** The limits of one attempt that a reader of the evidence must know about (Pitfalls 4 and 5, A4). */
579
+ function limitsOf({ facts, events, pre, stderr }) {
580
+ const limits = [];
581
+ const hasInit = events.some((e) => e && e.type === 'system' && e.subtype === 'init');
582
+ if (hasInit && !facts.tools.includes('AskUserQuestion')) limits.push('headless-no-ask-tool');
583
+ if (pre.userClaudeMd) limits.push(`user-claude-md:${pre.userClaudeMd}`);
584
+ if (UNTRUSTED_RE.test(stderr)) limits.push('untrusted-workspace');
585
+ if (facts.permissionDenials.length > 0) limits.push(`permission-denials:${facts.permissionDenials.length}`);
586
+ if (pre.buildDirty) limits.push('build-dirty');
587
+ return limits;
588
+ }
589
+
590
+ /** Turn outcomes that end before any model spend: nothing is charged for them. */
591
+ const NO_SPEND_KINDS = Object.freeze(['cli_missing', 'auth', 'launch_refused', 'cli_rejected']);
592
+
593
+ /**
594
+ * A crash that never reached the model: the CLI exited on its own (no wall-time kill, no
595
+ * signal) and printed no `system/init` event. In stream-json mode the CLI prints init
596
+ * before any API call, so this is the CLI refusing its arguments or failing to start.
597
+ */
598
+ function cliRejected(turn, events, kind) {
599
+ if (kind !== 'crashed' || turn.timedOut || turn.spawnError || !Number.isInteger(turn.status)) return false;
600
+ return !events.some((e) => e && e.type === 'system' && e.subtype === 'init');
601
+ }
602
+
603
+ /**
604
+ * What one turn is charged on the ledger: the largest total_cost_usd its result events
605
+ * report, else (a timeout, a crash, a rate limit, a result with no cost) the whole turn
606
+ * budget, because the turn may have spent up to it. A turn that never reached the model
607
+ * (NO_SPEND_KINDS, including a `cliRejected` crash) costs nothing. The charges of an
608
+ * attempt's turns are summed: if the CLI reports a resumed session's cost cumulatively
609
+ * this over-counts, which is the safe side of a cap.
610
+ */
611
+ function turnCharge(events, kind, budgetUsd) {
612
+ const costs = events
613
+ .filter((e) => e && e.type === 'result' && typeof e.total_cost_usd === 'number' && Number.isFinite(e.total_cost_usd))
614
+ .map((e) => e.total_cost_usd);
615
+ if (costs.length > 0) return Math.max(...costs);
616
+ if (NO_SPEND_KINDS.includes(kind)) return 0;
617
+ return budgetUsd;
618
+ }
619
+
620
+ const ledgerCapDetail = (ledger, pending, budgetUsd) =>
621
+ `the phase cost ledger is at $${(ledger.spent_usd + pending).toFixed(2)} of its $${ledger.cap_usd.toFixed(2)} cap; one more turn may spend up to $${budgetUsd.toFixed(2)}`;
622
+
623
+ /**
624
+ * One attempt: a fresh scratch project, the scenario's turns, a scrubbed transcript and
625
+ * a result. Returns { notRun: { reason, detail, secrets }, base, costUsd } when the model
626
+ * cannot be reached (or the next turn would pass the ledger cap), else
627
+ * { verdict, base, line, costUsd }. `costUsd` is always what the attempt is charged,
628
+ * turns that ran before a not-run included. `reserve(usd)`, when given, is called just
629
+ * before each turn with the attempt's charge so far plus the whole turn budget.
630
+ */
631
+ function runAttempt({ opts, scenario, checker, corpus, pre, run, attempt, ledger, reserve }) {
632
+ const env = opts.env || process.env;
633
+ const base = attemptBase(scenario.id, opts.round, run, attempt);
634
+ const project = createScratchProject({
635
+ label: scenario.id,
636
+ audience: scenario.audience,
637
+ fixture: scenario.fixture,
638
+ initialCommit: scenario.initialCommit,
639
+ commands: [scenario.command],
640
+ tmpRoot: opts.tmpRoot,
641
+ });
642
+ try {
643
+ const secrets = scrub.hostSecrets({ projectDir: project.projectDir, personHome: project.personHome, env });
644
+ const raw = [];
645
+ const stderrAll = [];
646
+ let durationMs = 0;
647
+ let sessionId = null;
648
+ let harnessInvalid = null;
649
+ let charged = 0;
650
+
651
+ for (let i = 0; i < scenario.turns.length; i++) {
652
+ if (ledger && ledger.spent_usd + charged + opts.turnBudgetUsd > ledger.cap_usd) {
653
+ return { notRun: { reason: 'ledger_cap', detail: ledgerCapDetail(ledger, charged, opts.turnBudgetUsd), secrets }, base, costUsd: charged };
654
+ }
655
+ if (reserve) reserve(roundCost(charged + opts.turnBudgetUsd));
656
+ const turn = driver.runTurn({
657
+ cwd: project.projectDir,
658
+ personHome: project.personHome,
659
+ prompt: scenario.turns[i],
660
+ sessionId,
661
+ wallMs: opts.turnWallMs,
662
+ budgetUsd: opts.turnBudgetUsd,
663
+ env,
664
+ audience: 'business',
665
+ });
666
+ const { events } = driver.parseLines(turn.stdout);
667
+ const cls = driver.classifyTurn(turn, events);
668
+ const rejected = cliRejected(turn, events, cls.kind);
669
+ charged = roundCost(charged + turnCharge(events, rejected ? 'cli_rejected' : cls.kind, opts.turnBudgetUsd));
670
+ if (NOT_RUN_KINDS.includes(cls.kind)) return { notRun: { reason: cls.kind, detail: cls.detail, secrets }, base, costUsd: charged };
671
+ // A first turn the CLI refused is not retried: every retry would be refused the same
672
+ // way. A refused later turn stays an invalid crashed attempt (turn 1's output is kept).
673
+ if (rejected && i === 0) return { notRun: { reason: 'cli_rejected', detail: cls.detail, secrets }, base, costUsd: charged };
674
+ raw.push(turn.stdout);
675
+ stderrAll.push(turn.stderr);
676
+ durationMs += turn.durationMs;
677
+ if (cls.kind === 'timeout' || cls.kind === 'crashed') {
678
+ harnessInvalid = cls.kind;
679
+ break;
680
+ }
681
+ sessionId = driver.sessionIdOf(events);
682
+ if (!sessionId && i < scenario.turns.length - 1) {
683
+ harnessInvalid = 'no_session_id';
684
+ break;
685
+ }
686
+ }
687
+
688
+ const scrubbed = scrub.scrubJsonl(raw.join('\n'), secrets);
689
+ const { events } = checker.parseTranscript(scrubbed);
690
+ const { facts, ...checked } = checker.checkRun({ scenario: scenario.id, events, turns: scenario.turns, corpus });
691
+ const verdict = harnessInvalid ? 'invalid' : checked.verdict;
692
+ const result = {
693
+ ...checked,
694
+ schema: SCHEMA_VERSION,
695
+ scenario: scenario.id,
696
+ round: opts.round,
697
+ run,
698
+ attempt,
699
+ verdict,
700
+ invalid_reason: harnessInvalid || checked.invalid_reason,
701
+ model: facts.model,
702
+ cli_version: facts.cliVersion || pre.cliVersion,
703
+ build_commit: pre.buildCommit,
704
+ build_dirty: pre.buildDirty,
705
+ models_used: facts.modelsUsed,
706
+ cost_usd: facts.costUsd,
707
+ charged_usd: charged,
708
+ num_turns: facts.numTurns,
709
+ duration_ms: durationMs,
710
+ permission_denials: facts.permissionDenials,
711
+ subagent_stats: facts.subagentStats,
712
+ limits: limitsOf({ facts, events, pre, stderr: stderrAll.join('\n') }),
713
+ transcript: `${base}.jsonl`,
714
+ at: nowIso(),
715
+ };
716
+ fs.writeFileSync(outputPath(opts.out, `${base}.jsonl`), scrubbed);
717
+ writeJson(opts.out, `${base}.result.json`, scrub.scrubEvent(result, secrets));
718
+ const reason = result.invalid_reason || result.fail_reason;
719
+ const chargedNote = charged === roundCost(facts.costUsd) ? '' : ` (charged $${charged.toFixed(2)})`;
720
+ return {
721
+ verdict,
722
+ base,
723
+ costUsd: charged,
724
+ line: `${base}: ${verdict}${reason ? ` (${reason})` : ''} $${facts.costUsd.toFixed(2)}${chargedNote}`,
725
+ };
726
+ } finally {
727
+ if (opts.keepScratch) say(opts.io, `scratch kept: ${project.projectDir} (person home ${project.personHome})`);
728
+ else project.cleanup();
729
+ }
730
+ }
731
+
732
+ /** Run one scenario's runs for one round: resumable, capped, and every outcome recorded. */
733
+ function runScenario(opts) {
734
+ const io = opts.io || defaultIo();
735
+ const scenario = SCENARIOS[opts.scenario];
736
+ const checker = loadChecker();
737
+ const pre = preflight(opts);
738
+ fs.mkdirSync(opts.out, { recursive: true });
739
+ // The round's records from an earlier invocation are stale: this one ends by writing
740
+ // a fresh summary or a fresh not-run record. A stale summary left beside an early stop
741
+ // would give --status an old, smaller run count.
742
+ for (const name of [`${scenario.id}-r${opts.round}.not-run.json`, `${scenario.id}-r${opts.round}.summary.json`]) {
743
+ const stale = outputPath(opts.out, name);
744
+ if (fs.existsSync(stale)) fs.unlinkSync(stale);
745
+ }
746
+ const ledger = readLedger(opts.out, opts.ledgerCapUsd);
747
+ const corpus = shippedCorpus(REPO_ROOT);
748
+
749
+ if (pre.cliVersion === null) return recordNotRun(opts, pre, 'cli_missing', 'the claude binary could not be run (claude --version failed)');
750
+
751
+ // Write-ahead charge: before each turn, ledger.json on disk already holds the attempt's
752
+ // charge so far plus the whole turn budget, as a `pending` entry. Ctrl-C (or any kill)
753
+ // ends run.cjs inside spawnSync before it can record the turn, and a JS signal handler
754
+ // cannot run until the synchronous loop ends, so the reservation is what keeps an
755
+ // interrupted turn on the permanent ledger, on the safe side of the cap. After the
756
+ // attempt, the pending entry is replaced by the real charge (or dropped when it is 0).
757
+ const ledgerFile = outputPath(opts.out, 'ledger.json');
758
+ const reserveFor = (base) => (pendingUsd) => writeJson(opts.out, 'ledger.json', {
759
+ ...ledger,
760
+ spent_usd: roundCost(ledger.spent_usd + pendingUsd),
761
+ entries: [...ledger.entries, { file: base, cost_usd: pendingUsd, at: nowIso(), pending: true }],
762
+ });
763
+
764
+ const targets = opts.only ? [opts.only] : Array.from({ length: opts.runs }, (_, i) => i + 1);
765
+ for (const run of targets) {
766
+ const highest = (list) => list.filter((a) => a.run === run).pop();
767
+ // Settled the way summarise sees it: any attempt of this run has a pass or fail
768
+ // verdict, even when a later forced attempt ended invalid.
769
+ let settled = !opts.force && readAttempts(opts.out, scenario.id, opts.round).some((a) => a.run === run && hasVerdict(a));
770
+ while (!settled) {
771
+ const all = readAttempts(opts.out, scenario.id, opts.round);
772
+ if (all.length >= opts.maxAttempts) break;
773
+ if (ledger.spent_usd + opts.turnBudgetUsd > ledger.cap_usd) {
774
+ return recordNotRun(opts, pre, 'ledger_cap', ledgerCapDetail(ledger, 0, opts.turnBudgetUsd));
775
+ }
776
+ const prior = highest(all);
777
+ const attempt = prior ? prior.attempt + 1 : 1;
778
+ if (attempt > 99) break;
779
+ const hadLedger = fs.existsSync(ledgerFile);
780
+ const outcome = runAttempt({ opts, scenario, checker, corpus, pre, run, attempt, ledger, reserve: reserveFor(attemptBase(scenario.id, opts.round, run, attempt)) });
781
+ if (!outcome.notRun || outcome.costUsd > 0) {
782
+ ledger.spent_usd = roundCost(ledger.spent_usd + outcome.costUsd);
783
+ const entry = { file: outcome.base, cost_usd: outcome.costUsd, at: nowIso() };
784
+ if (outcome.notRun) entry.not_run = outcome.notRun.reason;
785
+ ledger.entries.push(entry);
786
+ writeJson(opts.out, 'ledger.json', ledger);
787
+ } else if (hadLedger) {
788
+ writeJson(opts.out, 'ledger.json', ledger);
789
+ } else if (fs.existsSync(ledgerFile)) {
790
+ fs.unlinkSync(ledgerFile);
791
+ }
792
+ if (outcome.notRun) return recordNotRun(opts, pre, outcome.notRun.reason, outcome.notRun.detail, outcome.notRun.secrets);
793
+ say(io, outcome.line);
794
+ settled = outcome.verdict === 'pass' || outcome.verdict === 'fail';
795
+ }
796
+ }
797
+
798
+ const summary = summarise(opts.out, scenario.id, opts.round, opts.runs);
799
+ writeJson(opts.out, `${scenario.id}-r${opts.round}.summary.json`, { schema: SCHEMA_VERSION, at: nowIso(), ...summary });
800
+ say(io, `${scenario.id} round ${opts.round}: ${summary.passed} passed, ${summary.failed} failed, ${summary.invalid} invalid, ${summary.missing} missing`);
801
+ return targets.every((n) => summary.runs[n - 1].verdict === 'pass') ? EXIT.OK : EXIT.FAILED;
802
+ }
803
+
804
+ /** One cheap probe turn: is the CLI there and logged in? Writes preflight.json; exit 3 unless auth is ok. */
805
+ function runPreflight(opts) {
806
+ const io = opts.io || defaultIo();
807
+ const env = opts.env || process.env;
808
+ const pre = preflight(opts);
809
+ fs.mkdirSync(opts.out, { recursive: true });
810
+ let auth;
811
+ let detail = '';
812
+ if (pre.cliVersion === null) {
813
+ auth = 'cli_missing';
814
+ detail = 'the claude binary could not be run (claude --version failed)';
815
+ } else {
816
+ const root = opts.tmpRoot || os.tmpdir();
817
+ const cwd = fs.mkdtempSync(path.join(root, 'pp-live-probe-'));
818
+ const personHome = fs.mkdtempSync(path.join(root, 'pp-live-home-'));
819
+ try {
820
+ const turn = driver.runTurn({ cwd, personHome, prompt: PROBE_PROMPT, sessionId: null, wallMs: PROBE_WALL_MS, budgetUsd: PROBE_BUDGET_USD, env, audience: 'business' });
821
+ const cls = driver.classifyTurn(turn, driver.parseLines(turn.stdout).events);
822
+ auth = cls.kind === 'ok' ? 'ok' : cls.kind;
823
+ detail = cut(scrub.scrubText(cls.detail, scrub.hostSecrets({ projectDir: cwd, personHome, env })));
824
+ } finally {
825
+ fs.rmSync(cwd, { recursive: true, force: true });
826
+ fs.rmSync(personHome, { recursive: true, force: true });
827
+ }
828
+ }
829
+ writeJson(opts.out, 'preflight.json', {
830
+ schema: SCHEMA_VERSION,
831
+ at: nowIso(),
832
+ cli_version: pre.cliVersion,
833
+ build_commit: pre.buildCommit,
834
+ build_dirty: pre.buildDirty,
835
+ user_claude_md: pre.userClaudeMd,
836
+ auth,
837
+ detail,
838
+ });
839
+ say(io, `preflight: auth ${auth}${detail ? ` (${detail})` : ''}; claude ${pre.cliVersion || 'not found'}; build ${pre.buildCommit ? pre.buildCommit.slice(0, 8) : 'unknown'}${pre.buildDirty ? ' (dirty)' : ''}`);
840
+ return auth === 'ok' ? EXIT.OK : EXIT.NOT_RUN;
841
+ }
842
+
843
+ // ── Entry ────────────────────────────────────────────────────────────────────
844
+
845
+ const GUARD_MESSAGE = 'live runs never run inside the test suite (D-02); point PP_LIVE_CLAUDE_BIN at a fake to test the harness';
846
+
847
+ /**
848
+ * Run the CLI. `io` is `{ out, err, tmpRoot }`: the two writers default to stdout and
849
+ * stderr, `tmpRoot` is where scratch projects go (tests pass a tracked temp dir).
850
+ */
851
+ function main(argv = process.argv.slice(2), env = process.env, io = {}) {
852
+ const writers = { ...defaultIo(), ...io };
853
+ let parsed;
854
+ try {
855
+ parsed = parseArgs(argv);
856
+ } catch (err) {
857
+ if (!(err instanceof ExitError)) throw err;
858
+ warn(writers, err.message);
859
+ warn(writers, USAGE_TEXT);
860
+ return err.code;
861
+ }
862
+ if (parsed.help) {
863
+ say(writers, USAGE_TEXT);
864
+ return EXIT.OK;
865
+ }
866
+ const opts = { ...parsed, env, io: writers, tmpRoot: io.tmpRoot };
867
+
868
+ const spawnsModel = opts.mode === 'run' || opts.mode === 'preflight';
869
+ if (spawnsModel && env.NODE_TEST_CONTEXT && !env.PP_LIVE_CLAUDE_BIN) {
870
+ warn(writers, GUARD_MESSAGE);
871
+ return EXIT.USAGE;
872
+ }
873
+
874
+ try {
875
+ switch (opts.mode) {
876
+ case 'status': return status(opts);
877
+ case 'recheck': return recheck(opts.recheckDir, opts);
878
+ case 'fixture-check': return fixtureCheck(opts);
879
+ case 'preflight': return runPreflight(opts);
880
+ default: return runScenario(opts);
881
+ }
882
+ } catch (err) {
883
+ if (!(err instanceof ExitError)) throw err;
884
+ if (err.hasUserMessage && err.code !== 0) warn(writers, err.message);
885
+ return err.code;
886
+ }
887
+ }
888
+
889
+ module.exports = {
890
+ DEFAULTS,
891
+ EXIT,
892
+ RESULT_NAME_RE,
893
+ parseArgs,
894
+ main,
895
+ runScenario,
896
+ preflight,
897
+ recheck,
898
+ status,
899
+ fixtureCheck,
900
+ summarise,
901
+ shippedCorpus,
902
+ shippedPaths,
903
+ readLedger,
904
+ };
905
+
906
+ if (require.main === module) runMain(() => main(process.argv.slice(2), process.env));