ruvnet-brain 4.3.36 → 4.3.38

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 (46) hide show
  1. package/README.md +2 -2
  2. package/bin/install.mjs +32 -9
  3. package/kb/forge-update.mjs +1867 -0
  4. package/package.json +3 -1
  5. package/plugin/.claude-plugin/plugin.json +1 -1
  6. package/plugin/.codex-plugin/plugin.json +1 -1
  7. package/plugin/hooks/codex-hooks.json +6 -1
  8. package/plugin/hooks/hook-contracts.json +11 -9
  9. package/plugin/scripts/capability-claim-evidence.mjs +11 -2
  10. package/plugin/scripts/capacity-aware-parallel-work.mjs +71 -15
  11. package/plugin/scripts/completion-claim-evidence.mjs +262 -0
  12. package/plugin/scripts/continuation-gate.mjs +105 -21
  13. package/plugin/scripts/continuation-objective.mjs +15 -0
  14. package/plugin/scripts/continuity-hook-policy.mjs +10 -3
  15. package/plugin/scripts/decision-gate.mjs +22 -2
  16. package/plugin/scripts/duplicate-gate.mjs +503 -0
  17. package/plugin/scripts/grounding-turn-evidence.mjs +339 -0
  18. package/plugin/scripts/grounding-turn-gate.mjs +77 -25
  19. package/plugin/scripts/grounding-turn-mark.mjs +61 -14
  20. package/plugin/scripts/hook-input.mjs +15 -0
  21. package/plugin/scripts/hook-shim.mjs +3 -1
  22. package/plugin/scripts/host-update.mjs +45 -0
  23. package/plugin/scripts/nightly-scheduler.mjs +34 -0
  24. package/plugin/scripts/session-snapshot-hook.mjs +11 -1
  25. package/plugin/scripts/session-start-budget.mjs +1 -0
  26. package/plugin/scripts/session-start-core.mjs +15 -2
  27. package/plugin/scripts/session-start-health.mjs +101 -1
  28. package/plugin/scripts/session-start-update-plane.mjs +71 -1
  29. package/plugin/scripts/turn-outcome-capture.mjs +292 -0
  30. package/scripts/completion-claim-replay.mjs +114 -0
  31. package/scripts/corpus-canary.mjs +399 -0
  32. package/scripts/corpus-dispatch-decision.mjs +2 -2
  33. package/scripts/corpus-promotion.mjs +49 -0
  34. package/scripts/corpus-reconcile.mjs +27 -3
  35. package/scripts/corpus-watchdog.mjs +45 -6
  36. package/scripts/derive-passage-content-map.mjs +71 -0
  37. package/scripts/duplicate-gate-replay.mjs +98 -0
  38. package/scripts/grounding-turn-replay.mjs +131 -0
  39. package/scripts/nightly-watchdog.mjs +3 -3
  40. package/scripts/public-verification-inputs.mjs +24 -2
  41. package/scripts/release-transaction-provider.mjs +29 -6
  42. package/scripts/release.mjs +177 -74
  43. package/scripts/retrieval-canary.mjs +41 -4
  44. package/scripts/retrieval-passage-identity.mjs +58 -0
  45. package/scripts/sync-census.mjs +0 -0
  46. package/scripts/wired-check.mjs +6 -0
@@ -0,0 +1,292 @@
1
+ /**
2
+ * turn-outcome-capture.mjs — record what each turn CONCLUDED, and make it recallable knowledge.
3
+ *
4
+ * Called from session-snapshot-hook.mjs's runSessionSnapshotHook, so it rides the capture boundary
5
+ * both hosts already register (Claude: hooks.json; Codex: codex-hooks.json via codex-hook-adapter,
6
+ * which sets RUVNET_HOOK_HOST=codex). Two jobs:
7
+ *
8
+ * Stop → one AgentDB record per distinct turn outcome, namespace `turns`
9
+ * SessionEnd/PreCompact → `ruflo memory distill run` on the same db (ruflo ADR-174: memory_entries
10
+ * is the RECORDING tier; distill mines it into episodes/reasoning_patterns/
11
+ * causal_edges, the KNOWLEDGE tier, which stays empty unless it is run)
12
+ *
13
+ * WHERE: the project's `.swarm/memory.db` when it exists, else the machine-wide
14
+ * `~/.claude/global-memory/.swarm/memory.db` (outside every repository). `.swarm` is NEVER created
15
+ * inside a project — see session-snapshot-hook.mjs's writeSessionSnapshot for why that is trespass.
16
+ *
17
+ * WHAT (measured facts carried over from the owner's local Claude hook, 2026-09-29):
18
+ * • The Brain's continuation gate continues most turns, so the turn's REAL final Stop carries
19
+ * stop_hook_active=true. It is NOT skipped; exact repeats are dropped by a per-session
20
+ * fingerprint of (finalText, files) instead.
21
+ * • The transcript may not contain the closing message yet when Stop fires, so the payload's
22
+ * `last_assistant_message` wins when present; only without it is the transcript waited on
23
+ * (bounded) until it stops growing.
24
+ * • Content = assistant outcome text, files changed, Bash descriptions. NEVER raw user text (a
25
+ * 2026-07-13 measurement: prompt echoes made 87% of a store noise). Trivial turns are skipped.
26
+ *
27
+ * CODEX: codex-cli 0.158.0's own `stop.command.input` schema (read from the installed binary
28
+ * 2026-09-29) carries `last_assistant_message` (nullable) and `transcript_path` (nullable).
29
+ * The Codex rollout format is NOT parsed: project-progression-sources.mjs already declares it
30
+ * unknown, and the rollout records observed locally (custom_tool_call / function_call with free-form
31
+ * inputs) give no stable file-change shape. Codex records therefore carry the outcome text only.
32
+ *
33
+ * LATENCY: Stop runs synchronously in the host's turn, and one `ruflo memory store` costs ~0.7s
34
+ * (measured). So the writes run in ONE detached worker (this file, `--run-steps`), store then
35
+ * distill in order; the hook itself only reads, fingerprints and spawns. A breadcrumb line is
36
+ * appended next to the db BEFORE the spawn, and the worker appends a receipt per step, so a lost
37
+ * write is visible rather than silent. Advisory always: nothing here throws to the caller.
38
+ */
39
+ import fs from 'node:fs';
40
+ import os from 'node:os';
41
+ import path from 'node:path';
42
+ import crypto from 'node:crypto';
43
+ import { spawn, spawnSync } from 'node:child_process';
44
+ import { fileURLToPath } from 'node:url';
45
+ import { resolveRuflo, rufloInvocation } from './ruflo-bin.mjs';
46
+
47
+ export const TURN_NAMESPACE = 'turns';
48
+ export const MIN_OUTCOME_CHARS = 200;
49
+ const TRANSCRIPT_TAIL_BYTES = 2 * 1024 * 1024;
50
+ const STEP_TIMEOUT_MS = 60_000;
51
+ const RUFLO_ENV = { RUFLO_DAEMON_AUTOSTART: '0' };
52
+
53
+ function textOf(content) {
54
+ if (typeof content === 'string') return content;
55
+ if (!Array.isArray(content)) return '';
56
+ return content.filter((c) => c && c.type === 'text' && typeof c.text === 'string').map((c) => c.text).join('\n');
57
+ }
58
+
59
+ function patchFiles(patch) {
60
+ const out = new Set();
61
+ for (const line of String(patch || '').split(/\r?\n/)) {
62
+ const m = /^\*\*\* (?:Add|Update|Delete) File: (.+)$/.exec(line) || /^\*\*\* Move to: (.+)$/.exec(line);
63
+ if (m) out.add(m[1].trim());
64
+ }
65
+ return [...out];
66
+ }
67
+
68
+ /**
69
+ * The current turn of a Claude JSONL transcript = every record after the last genuine user message
70
+ * (a user record carrying text, not a tool_result). User text is used ONLY to find that boundary.
71
+ */
72
+ export function claudeTurn(lines) {
73
+ const recs = [];
74
+ for (const l of lines) { try { recs.push(JSON.parse(l)); } catch { /* partial or foreign line */ } }
75
+ let start = 0;
76
+ recs.forEach((o, i) => {
77
+ const role = o?.message?.role || o?.role;
78
+ const c = o?.message?.content;
79
+ const isToolResult = Array.isArray(c) && c.some((x) => x && x.type === 'tool_result');
80
+ if (role === 'user' && !isToolResult && textOf(c).trim()) start = i + 1;
81
+ });
82
+ const texts = [];
83
+ const files = new Set();
84
+ const actions = [];
85
+ for (const o of recs.slice(start)) {
86
+ if ((o?.message?.role || o?.role) !== 'assistant') continue;
87
+ const c = o.message?.content;
88
+ const t = textOf(c).trim();
89
+ if (t) texts.push(t);
90
+ if (!Array.isArray(c)) continue;
91
+ for (const u of c) {
92
+ if (!u || u.type !== 'tool_use') continue;
93
+ const inp = u.input || {};
94
+ if (['Edit', 'Write', 'MultiEdit', 'NotebookEdit'].includes(u.name)) {
95
+ const f = inp.file_path || inp.notebook_path;
96
+ if (typeof f === 'string' && f) files.add(f);
97
+ } else if (u.name === 'apply_patch') {
98
+ for (const f of patchFiles(typeof inp === 'string' ? inp : inp.command || inp.input)) files.add(f);
99
+ } else if (u.name === 'Bash' && typeof inp.description === 'string' && inp.description) {
100
+ actions.push(inp.description);
101
+ }
102
+ }
103
+ }
104
+ // The closing message usually carries the outcome; when it is short (a tool-heavy turn) the turn's
105
+ // other assistant text is kept too, newest last, so the record still says what happened.
106
+ const last = texts.length ? texts[texts.length - 1] : '';
107
+ const finalText = last.length >= MIN_OUTCOME_CHARS ? last : texts.join('\n').slice(-2500);
108
+ return { finalText, files: [...files], actions };
109
+ }
110
+
111
+ function readTail(file, bytes = TRANSCRIPT_TAIL_BYTES) {
112
+ const size = fs.statSync(file).size;
113
+ const offset = Math.max(0, size - bytes);
114
+ const handle = fs.openSync(file, 'r');
115
+ try {
116
+ const buf = Buffer.alloc(size - offset);
117
+ fs.readSync(handle, buf, 0, buf.length, offset);
118
+ const lines = buf.toString('utf8').split(/\r?\n/);
119
+ if (offset > 0) lines.shift(); // the first line of a tail is almost always cut mid-record
120
+ return lines;
121
+ } finally { fs.closeSync(handle); }
122
+ }
123
+
124
+ /** Wait (bounded) until the transcript stops growing, then return its tail lines. */
125
+ export function readSettledTranscript(file, { stableMs = 400, maxMs = 2000, sleep } = {}) {
126
+ const pause = sleep || ((ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms));
127
+ const deadline = Date.now() + Math.max(0, maxMs);
128
+ let size = -1;
129
+ for (;;) {
130
+ const now = fs.statSync(file).size;
131
+ if (now === size || Date.now() >= deadline) break;
132
+ size = now;
133
+ pause(Math.min(stableMs, Math.max(0, deadline - Date.now())));
134
+ }
135
+ return readTail(file);
136
+ }
137
+
138
+ export function buildTurnRecord({ turn, project, host, session, at = new Date() }) {
139
+ const parts = [`[turn ${at.toISOString()} project=${project} host=${host}]`,
140
+ `OUTCOME: ${turn.finalText.replace(/\s+/g, ' ').slice(0, 2500)}`];
141
+ if (turn.files.length) parts.push(`FILES CHANGED: ${turn.files.slice(0, 25).join(', ')}`);
142
+ if (turn.actions.length) parts.push(`ACTIONS: ${turn.actions.slice(-15).join(' • ')}`);
143
+ parts.push(`SESSION: ${session || '?'}`);
144
+ return parts.join(' || ').slice(0, 4000);
145
+ }
146
+
147
+ /** Project db if the project already has one; otherwise the machine-wide db outside every repo. */
148
+ export function resolveTurnDb({ projectDir, home = os.homedir(), env = process.env } = {}) {
149
+ const projectDb = path.join(projectDir, '.swarm', 'memory.db');
150
+ try { if (fs.statSync(projectDb).isFile()) return { db: projectDb, scope: 'project' }; } catch { /* absent */ }
151
+ const globalDb = env.RUVNET_TURN_GLOBAL_DB || path.join(home, '.claude', 'global-memory', '.swarm', 'memory.db');
152
+ return { db: globalDb, scope: 'global' };
153
+ }
154
+
155
+ const projectName = (projectDir) => path.basename(path.resolve(projectDir)).replace(/[^A-Za-z0-9._-]/g, '_') || 'project';
156
+
157
+ /** Detached worker launch: the hook returns immediately; the worker runs the steps in order. */
158
+ export function launchDetached(steps, { receipts }) {
159
+ const child = spawn(process.execPath, [fileURLToPath(import.meta.url), '--run-steps', JSON.stringify({ steps, receipts })], {
160
+ cwd: os.tmpdir(), detached: true, stdio: 'ignore', windowsHide: true, env: { ...process.env, ...RUFLO_ENV },
161
+ });
162
+ child.unref();
163
+ return { launched: true, pid: child.pid };
164
+ }
165
+
166
+ function sameTurnSeen(stateFile, sessionKey, fingerprint) {
167
+ let last = {};
168
+ try { last = JSON.parse(fs.readFileSync(stateFile, 'utf8')) || {}; } catch { /* first run */ }
169
+ if (last[sessionKey] === fingerprint) return true;
170
+ const keys = Object.keys(last);
171
+ for (const k of keys.slice(0, Math.max(0, keys.length - 200))) delete last[k];
172
+ try {
173
+ fs.mkdirSync(path.dirname(stateFile), { recursive: true, mode: 0o700 });
174
+ fs.writeFileSync(stateFile, JSON.stringify({ ...last, [sessionKey]: fingerprint }), { mode: 0o600 });
175
+ } catch { /* dedupe is best effort; a duplicate record beats a lost one */ }
176
+ return false;
177
+ }
178
+
179
+ /**
180
+ * Capture this turn's outcome (Stop) and/or queue distillation (SessionEnd, PreCompact).
181
+ * Returns a plain report; `skipped` / `distill.skipped` carry the reason whenever nothing is written.
182
+ */
183
+ export function captureTurnOutcome({
184
+ projectDir, event, payload = {}, host = 'claude',
185
+ env = process.env, home = os.homedir(),
186
+ brainHome = env.RUVNET_BRAIN_HOME || path.join(home, '.cache', 'ruvnet-brain'),
187
+ ruflo = resolveRuflo({ env, home }),
188
+ launch = launchDetached,
189
+ readTranscript = readSettledTranscript,
190
+ settleMs = 2000,
191
+ now = () => new Date(),
192
+ } = {}) {
193
+ const report = { event, host, recorded: false, distill: { queued: false } };
194
+ if (String(env.RUVNET_TURN_CAPTURE || '').toLowerCase() === 'off') {
195
+ return { ...report, skipped: 'RUVNET_TURN_CAPTURE=off', distill: { queued: false, skipped: 'RUVNET_TURN_CAPTURE=off' } };
196
+ }
197
+ if (!ruflo) return { ...report, skipped: 'ruflo not found', distill: { queued: false, skipped: 'ruflo not found' } };
198
+ const { db, scope } = resolveTurnDb({ projectDir, home, env });
199
+ Object.assign(report, { db, scope });
200
+ const steps = [];
201
+ const project = projectName(projectDir);
202
+ const receipts = path.join(brainHome, 'turn-capture', 'receipts.jsonl');
203
+
204
+ if (event === 'Stop') {
205
+ const sessionKey = String(payload.session_id || payload.transcript_path || '');
206
+ const message = typeof payload.last_assistant_message === 'string' ? payload.last_assistant_message.trim() : '';
207
+ let turn = { finalText: '', files: [], actions: [] };
208
+ if (host === 'claude' && typeof payload.transcript_path === 'string' && payload.transcript_path) {
209
+ // With the closing message in hand, the transcript is read immediately (tool calls are already
210
+ // flushed); without it, wait for the closing message to land, bounded.
211
+ try { turn = claudeTurn(readTranscript(payload.transcript_path, { maxMs: message ? 0 : settleMs })); } catch { /* unreadable */ }
212
+ }
213
+ if (message && (message.length >= MIN_OUTCOME_CHARS || message.length >= turn.finalText.length)) turn.finalText = message;
214
+ if (!sessionKey) report.skipped = 'no session identity in the host payload';
215
+ else if (turn.finalText.length < MIN_OUTCOME_CHARS && !turn.files.length) {
216
+ report.skipped = host === 'codex' && !message ? 'codex payload carried no last_assistant_message' : 'trivial turn';
217
+ } else {
218
+ const fingerprint = crypto.createHash('sha256').update(JSON.stringify([turn.finalText, turn.files])).digest('hex');
219
+ const stateFile = path.join(brainHome, 'turn-capture', 'last-turn.json');
220
+ if (sameTurnSeen(stateFile, `${host}:${sessionKey}`, fingerprint)) report.skipped = 'same turn outcome already recorded';
221
+ else {
222
+ const at = now();
223
+ const key = `turn-${project}-${at.getTime()}`;
224
+ const value = buildTurnRecord({ turn, project, host, session: payload.session_id, at });
225
+ steps.push({ kind: 'store', ruflo, args: ['memory', 'store', '-k', key, '--value', value, '-n', TURN_NAMESPACE,
226
+ '--path', db, '--tags', `project=${project},host=${host}`, '--provenance', 'agent_output'] });
227
+ Object.assign(report, { recorded: true, key, value });
228
+ }
229
+ }
230
+ } else report.skipped = `turn outcomes are recorded at Stop, not ${event}`;
231
+
232
+ if (event === 'SessionEnd' || event === 'PreCompact') {
233
+ if (!fs.existsSync(db)) report.distill = { queued: false, skipped: 'no memory db to distill yet' };
234
+ else {
235
+ steps.push({ kind: 'distill', ruflo, args: ['memory', 'distill', 'run', '--db', db, '--namespace', TURN_NAMESPACE, '--max-entries', '500'] });
236
+ report.distill = { queued: true, db };
237
+ }
238
+ }
239
+ if (!steps.length) return report;
240
+
241
+ try {
242
+ // The machine-wide db lives outside every repository; its directory is created on first use so a
243
+ // fresh machine records from its first turn. A project's `.swarm` is never created (scope check).
244
+ if (scope === 'global') fs.mkdirSync(path.dirname(db), { recursive: true, mode: 0o700 });
245
+ if (report.recorded) {
246
+ fs.appendFileSync(path.join(path.dirname(db), 'agentdb-turns.jsonl'),
247
+ `${JSON.stringify({ ts: Date.now(), key: report.key, project, host, value: report.value })}\n`, { mode: 0o600 });
248
+ }
249
+ report.launch = launch(steps, { receipts });
250
+ } catch (error) {
251
+ return { ...report, recorded: false, distill: { queued: false, skipped: `launch failed: ${error.message}` }, skipped: `launch failed: ${error.message}` };
252
+ }
253
+ return report;
254
+ }
255
+
256
+ /** The detached worker: run each step in order, bounded, and append one receipt per step. */
257
+ export function runSteps({ steps = [], receipts } = {}, { run = spawnSync } = {}) {
258
+ const results = [];
259
+ for (const step of steps) {
260
+ let status = null;
261
+ let error = null;
262
+ const db = step.args[step.args.indexOf(step.kind === 'store' ? '--path' : '--db') + 1];
263
+ try {
264
+ const { executable, args } = rufloInvocation(step.ruflo, step.args);
265
+ // CONTAINMENT (measured 2026-09-29, ruflo 3.48.0): even with an explicit --path, `memory store`
266
+ // writes `.swarm/hnsw.index` and `ruvector.db` relative to its CWD. Run from an inherited cwd,
267
+ // that planted `.swarm/` + `ruvector.db` in a repository that never adopted the brain — the
268
+ // exact trespass session-snapshot-hook.mjs forbids. So every step runs INSIDE the db's own
269
+ // directory, with ruflo's memory root pinned there too.
270
+ const home = path.dirname(db);
271
+ const r = run(executable, args, { stdio: 'ignore', timeout: STEP_TIMEOUT_MS, cwd: home, windowsHide: true,
272
+ env: { ...process.env, ...RUFLO_ENV, CLAUDE_FLOW_MEMORY_PATH: home } });
273
+ status = r.status;
274
+ if (r.error) error = r.error.message;
275
+ } catch (e) { error = e.message; }
276
+ const row = { at: new Date().toISOString(), kind: step.kind, db,
277
+ key: step.kind === 'store' ? step.args[step.args.indexOf('-k') + 1] : undefined, status, error };
278
+ results.push(row);
279
+ if (receipts) {
280
+ try {
281
+ fs.mkdirSync(path.dirname(receipts), { recursive: true, mode: 0o700 });
282
+ fs.appendFileSync(receipts, `${JSON.stringify(row)}\n`, { mode: 0o600 });
283
+ } catch { /* receipts are best effort */ }
284
+ }
285
+ }
286
+ return results;
287
+ }
288
+
289
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) && process.argv[2] === '--run-steps') {
290
+ try { runSteps(JSON.parse(process.argv[3] || '{}')); } catch { /* a detached worker has no one to report to */ }
291
+ process.exit(0);
292
+ }
@@ -0,0 +1,114 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * completion-claim-replay.mjs — measure the completion-claim gate and promise capture against REAL
4
+ * Claude Code transcripts, read-only (ADR-074 acceptance #5: "Measure Stop latency and false-positive
5
+ * rate").
6
+ *
7
+ * It replays every turn of every top-level `*.jsonl` under the transcript root through the SAME pure
8
+ * functions continuation-gate.mjs calls at Stop (plugin/scripts/completion-claim-evidence.mjs), and
9
+ * reports how often the gate would ARM (a completion claim is present), BLOCK (FAIL verdict) and
10
+ * capture a PROMISE. It never writes into the transcript root; `--out` writes a JSONL sample of
11
+ * blocks and promises for a human to label true/false by eye.
12
+ *
13
+ * node scripts/completion-claim-replay.mjs [--root ~/.claude/projects] [--limit 40] [--out file.jsonl]
14
+ *
15
+ * Turn = records from one genuine user message to the next; the turn's final assistant text stands
16
+ * in for the Stop payload's `last_assistant_message`. That approximation is disclosed in the report.
17
+ */
18
+ import fs from 'node:fs';
19
+ import os from 'node:os';
20
+ import path from 'node:path';
21
+ import { fileURLToPath } from 'node:url';
22
+ import { auditCompletionClaims, claudeTurnEvents, extractCommitments } from '../plugin/scripts/completion-claim-evidence.mjs';
23
+
24
+ const argv = process.argv.slice(2);
25
+ const opt = (flag, fallback) => { const i = argv.indexOf(flag); return i >= 0 && argv[i + 1] ? argv[i + 1] : fallback; };
26
+
27
+ // The transcript walkers below are exported for scripts/grounding-turn-replay.mjs (one turn model,
28
+ // two gates); main() runs only when this file is the entrypoint.
29
+ const textOf = (c) => (typeof c === 'string' ? c : Array.isArray(c)
30
+ ? c.filter((x) => x?.type === 'text' && typeof x.text === 'string').map((x) => x.text).join('\n') : '');
31
+
32
+ export function transcripts(root, limit) {
33
+ const files = [];
34
+ for (const dir of fs.readdirSync(root)) {
35
+ const full = path.join(root, dir);
36
+ try {
37
+ for (const name of fs.readdirSync(full)) {
38
+ if (!name.endsWith('.jsonl')) continue;
39
+ const file = path.join(full, name);
40
+ const st = fs.statSync(file);
41
+ if (st.isFile() && st.size > 20_000) files.push({ file, mtime: st.mtimeMs });
42
+ }
43
+ } catch { /* not a directory */ }
44
+ }
45
+ return files.sort((a, b) => b.mtime - a.mtime).slice(0, limit).map((f) => f.file);
46
+ }
47
+
48
+ export function turnsOf(lines) {
49
+ const starts = [];
50
+ lines.forEach((l, i) => {
51
+ try {
52
+ const o = JSON.parse(l);
53
+ const c = o?.message?.content;
54
+ const toolResult = Array.isArray(c) && c.some((x) => x?.type === 'tool_result');
55
+ if (o?.type === 'user' && !o.isSidechain && !o.isMeta && !toolResult && textOf(c).trim()) starts.push(i);
56
+ } catch { /* torn */ }
57
+ });
58
+ return starts.map((s, k) => lines.slice(s, starts[k + 1] ?? lines.length));
59
+ }
60
+
61
+ export function finalText(turnLines) {
62
+ for (let i = turnLines.length - 1; i >= 0; i -= 1) {
63
+ try {
64
+ const o = JSON.parse(turnLines[i]);
65
+ if (o?.type === 'assistant' && !o.isSidechain) { const t = textOf(o.message?.content).trim(); if (t) return t; }
66
+ } catch { /* torn */ }
67
+ }
68
+ return '';
69
+ }
70
+
71
+ function main() {
72
+ const root = path.resolve(opt('--root', path.join(os.homedir(), '.claude', 'projects')));
73
+ const limit = Number(opt('--limit', '40'));
74
+ const out = opt('--out', null);
75
+ const report = { root, transcripts: 0, turns: 0, armed: 0, blocked: 0, passed: 0, promises: 0, turnsWithPromise: 0,
76
+ reasons: {}, auditMs: [] };
77
+ const samples = [];
78
+ for (const file of transcripts(root, limit)) {
79
+ report.transcripts += 1;
80
+ const lines = fs.readFileSync(file, 'utf8').split('\n').filter(Boolean);
81
+ for (const turnLines of turnsOf(lines)) {
82
+ const message = finalText(turnLines);
83
+ if (!message) continue;
84
+ report.turns += 1;
85
+ const t0 = performance.now();
86
+ const audit = auditCompletionClaims(message, { turn: claudeTurnEvents(turnLines) });
87
+ report.auditMs.push(performance.now() - t0);
88
+ const promises = extractCommitments(message);
89
+ if (promises.length) { report.turnsWithPromise += 1; report.promises += promises.length; }
90
+ if (audit.verdict !== 'NONE') report.armed += 1;
91
+ if (audit.verdict === 'PASS') report.passed += 1;
92
+ if (audit.verdict === 'FAIL') {
93
+ report.blocked += 1;
94
+ for (const p of audit.problems) { const k = p.replace(/\(.*\)/, '').trim(); report.reasons[k] = (report.reasons[k] || 0) + 1; }
95
+ }
96
+ if (audit.verdict === 'FAIL' || promises.length) {
97
+ samples.push({ file: path.basename(file), verdict: audit.verdict, claims: audit.claims.map((c) => c.text),
98
+ problems: audit.problems, promises: promises.map((p) => p.text), tail: message.slice(-700) });
99
+ }
100
+ }
101
+ }
102
+ const ms = report.auditMs.sort((a, b) => a - b);
103
+ const summary = { ...report, auditMs: undefined,
104
+ armRate: report.turns ? +(report.armed / report.turns).toFixed(3) : 0,
105
+ blockRate: report.turns ? +(report.blocked / report.turns).toFixed(3) : 0,
106
+ promiseTurnRate: report.turns ? +(report.turnsWithPromise / report.turns).toFixed(3) : 0,
107
+ auditP50Ms: +(ms[Math.floor(ms.length / 2)] || 0).toFixed(2), auditP99Ms: +(ms[Math.floor(ms.length * 0.99)] || 0).toFixed(2),
108
+ approximation: 'final assistant text of each turn stands in for last_assistant_message' };
109
+ if (out) fs.writeFileSync(out, samples.map((s) => JSON.stringify(s)).join('\n') + '\n');
110
+ console.log(JSON.stringify(summary, null, 2));
111
+ }
112
+
113
+ const isMain = (() => { try { return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); } catch { return false; } })();
114
+ if (isMain) main();