ruvnet-brain 4.6.3 → 4.6.4

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.
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  // PostToolUse captures only a fixed workflow vocabulary, then schedules bounded orphan recovery.
3
+ import fs from 'node:fs';
3
4
  import path from 'node:path';
4
5
  import { randomUUID, createHash } from 'node:crypto';
5
6
  import { spawn } from 'node:child_process';
@@ -7,38 +8,70 @@ import { fileURLToPath } from 'node:url';
7
8
  import { readStdinBounded } from './hook-input.mjs';
8
9
  import { learningContext } from './runtime-preferences.mjs';
9
10
  import { learningTarget } from './learning-store.mjs';
10
- import { safeQueue, safeAction, writeExclusive, takeQueueLock, releaseQueueLock } from './learning-queue.mjs';
11
+ import { safeQueue, safeAction, writeExclusive, takeQueueLock, releaseQueueLock, queueFiles } from './learning-queue.mjs';
11
12
 
12
- try {
13
- const context = learningContext();
14
- if (!context.enabled) process.exit(0);
15
- const raw = await readStdinBounded({ maxBytes: 65536, emptyMs: 150 });
16
- const input = JSON.parse(raw.toString());
17
- if (input.hook_event_name && input.hook_event_name !== 'PostToolUse') process.exit(0);
18
- const response = input.tool_response;
19
- if (typeof response === 'string') {
20
- const exit = /^Process exited with code (-?\d+)\s*$/m.exec(response);
21
- if ((exit && Number(exit[1]) !== 0) || /Process running with session ID/.test(response)) process.exit(0);
22
- }
23
- if (input.error || input.is_error === true || response?.error || response?.interrupted === true || response?.signal
24
- || response?.success === false || response?.is_error === true || response?.isError === true
25
- || [response?.exit_code, response?.exitCode, response?.status].some(value => Number.isInteger(value) && value !== 0)) process.exit(0);
26
- const tool = input.tool_name;
27
- if (!['Bash', 'Write', 'Edit', 'MultiEdit'].includes(tool)) process.exit(0);
28
- const action = safeAction(tool, tool === 'Bash' ? input.tool_input?.command : 'edit file');
29
- if (!action) process.exit(0);
30
- const fresh = learningContext();
31
- if (!fresh.enabled || fresh.queueDir !== context.queueDir) process.exit(0);
32
- learningTarget(fresh); // Existing authorized user store is required before creating any user queue bytes.
33
- const dir = safeQueue(fresh, true);
34
- const sid = createHash('sha256').update(String(input.session_id || process.env.CLAUDE_SESSION_ID || 'default')).digest('hex').slice(0, 24);
35
- writeExclusive(path.join(dir, `session-${sid}-${randomUUID()}.jsonl`), JSON.stringify({ tool, action }) + '\n');
36
- // Per-scope lock in the flusher suppresses duplicate workers; capture never waits on the learner.
37
- const token = takeQueueLock(fresh);
38
- if (!token) process.exit(0);
39
- const child = spawn(process.execPath, [fileURLToPath(new URL('./learn-flush.mjs', import.meta.url)), '--supervisor'], {
40
- cwd: fresh.projectDir, detached: true, stdio: 'ignore', windowsHide: true, env: { ...process.env, RUVNET_LEARN_WORKER_TOKEN: token },
41
- });
42
- child.on('error', () => releaseQueueLock(fresh, token)); child.unref();
43
- } catch { /* advisory transport; unsafe/malformed input produces no persisted payload */ }
44
- process.exit(0);
13
+ /**
14
+ * One PostToolUse observation. Since ADR-106 it runs IN-PROCESS from session-snapshot's PostToolUse
15
+ * boundary (one registration instead of two); the script entry below stays for explicit invocation.
16
+ * learningContext() owns consent and brain-off, so the fold keeps ADR-054's OFF contract.
17
+ */
18
+ export function captureLearning(input) {
19
+ try {
20
+ const context = learningContext();
21
+ if (!context.enabled || !input || typeof input !== 'object') return false;
22
+ if (input.hook_event_name && input.hook_event_name !== 'PostToolUse') return false;
23
+ const response = input.tool_response;
24
+ if (typeof response === 'string') {
25
+ const exit = /^Process exited with code (-?\d+)\s*$/m.exec(response);
26
+ if ((exit && Number(exit[1]) !== 0) || /Process running with session ID/.test(response)) return false;
27
+ }
28
+ if (input.error || input.is_error === true || response?.error || response?.interrupted === true || response?.signal
29
+ || response?.success === false || response?.is_error === true || response?.isError === true
30
+ || [response?.exit_code, response?.exitCode, response?.status].some(value => Number.isInteger(value) && value !== 0)) return false;
31
+ const tool = input.tool_name;
32
+ if (!['Bash', 'Write', 'Edit', 'MultiEdit'].includes(tool)) return false;
33
+ const action = safeAction(tool, tool === 'Bash' ? input.tool_input?.command : 'edit file');
34
+ if (!action) return false;
35
+ const fresh = learningContext();
36
+ if (!fresh.enabled || fresh.queueDir !== context.queueDir) return false;
37
+ learningTarget(fresh); // Existing authorized user store is required before creating any user queue bytes.
38
+ const dir = safeQueue(fresh, true);
39
+ const sid = createHash('sha256').update(String(input.session_id || process.env.CLAUDE_SESSION_ID || 'default')).digest('hex').slice(0, 24);
40
+ writeExclusive(path.join(dir, `session-${sid}-${randomUUID()}.jsonl`), JSON.stringify({ tool, action }) + '\n');
41
+ // Per-scope lock in the flusher suppresses duplicate workers; capture never waits on the learner.
42
+ const token = takeQueueLock(fresh);
43
+ if (!token) return true;
44
+ const child = spawn(process.execPath, [fileURLToPath(new URL('./learn-flush.mjs', import.meta.url)), '--supervisor'], {
45
+ cwd: fresh.projectDir, detached: true, stdio: 'ignore', windowsHide: true, env: { ...process.env, RUVNET_LEARN_WORKER_TOKEN: token },
46
+ });
47
+ child.on('error', () => releaseQueueLock(fresh, token)); child.unref();
48
+ return true;
49
+ } catch { return false; /* advisory transport; unsafe/malformed input produces no persisted payload */ }
50
+ }
51
+
52
+ /**
53
+ * Lifecycle recovery (formerly its own SessionStart/SessionEnd registration, ADR-106): when learning
54
+ * is enabled, start learn-flush detached so an orphaned queue is drained by one finite worker. The
55
+ * caller never waits; learn-flush owns the lock, the deadline and the consent re-checks.
56
+ */
57
+ export function scheduleLearningRecovery({ cwd = process.cwd(), env = process.env, spawnFn = spawn } = {}) {
58
+ try {
59
+ const context = learningContext({ env, cwd });
60
+ if (!context.enabled) return false;
61
+ // Nothing queued, nothing to recover. Spawning anyway cost a process at every session boundary and,
62
+ // on Windows, a live worker holds the project directory as its cwd (EBUSY on delete/move).
63
+ if (queueFiles(context, { limit: 1, deadline: Date.now() + 100 }).length === 0) return false;
64
+ const child = spawnFn(process.execPath, [fileURLToPath(new URL('./learn-flush.mjs', import.meta.url))], {
65
+ cwd, detached: true, stdio: 'ignore', windowsHide: true, env });
66
+ child.on?.('error', () => {}); child.unref?.();
67
+ return true;
68
+ } catch { return false; }
69
+ }
70
+
71
+ const isMain = (() => { try { return Boolean(process.argv[1]) && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); } catch { return false; } })();
72
+ if (isMain) {
73
+ try {
74
+ if (learningContext().enabled) captureLearning(JSON.parse((await readStdinBounded({ maxBytes: 65536, emptyMs: 150 })).toString()));
75
+ } catch { /* malformed input produces no persisted payload */ }
76
+ process.exit(0);
77
+ }
@@ -15,7 +15,7 @@ import { normalizeHostEvent } from './hook-input.mjs';
15
15
  import { resolveProjectStore } from './project-store-resolver.mjs';
16
16
  import { withProgressionReader } from './project-progression-reader.mjs';
17
17
  import { redactProgression, restoreProjectProgression, digestCanonical, validateProgressionSnapshot } from './project-progression-contract.mjs';
18
- import { runSessionSnapshotHook, effectiveBudgetMs, queueCapture, replayOutboxDetached, runOutboxReplay } from './session-snapshot-hook.mjs';
18
+ import { runSessionSnapshotHook, queueCapture, replayOutboxDetached } from './session-snapshot-hook.mjs';
19
19
 
20
20
  const EVENTS = new Set(['UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'PostToolUseFailure', 'SubagentStop']);
21
21
  const TOPICS = [
@@ -197,8 +197,23 @@ export function captureNormalizedTransition(job, { readHistory = readTransitionH
197
197
  return { ...result, eventId: normalized.observation.id };
198
198
  }
199
199
 
200
+ /** Queued work older than this is no longer in flight; the boundary says so (continuity-journal STUCK_AFTER_MS). */
201
+ export const QUEUE_STUCK_AFTER_MS = 10 * 60_000;
202
+
203
+ /**
204
+ * Prompt and tool boundaries (UserPromptSubmit, PreToolUse, PostToolUse, PostToolUseFailure,
205
+ * SubagentStop) do only bounded, local work: consent and identity, fsync the observation into the
206
+ * durable queue, hand the queue to the detached replay worker, return. No native store write runs
207
+ * inline. Measured 2026-10-11: each inline commit cost one ~3 s native write per boundary, queued
208
+ * debt made every later boundary spend its full 6.5 s budget, and under load the host killed the
209
+ * hook at its 10 s timeout and discarded its output. A queued-and-handed-off observation is the
210
+ * NORMAL state and says nothing; only an unwritable queue or work stuck past
211
+ * QUEUE_STUCK_AFTER_MS is reported. Exact readback is unchanged: the worker commits each item only
212
+ * by exact readback (captureNormalizedTransition), and Stop/PreCompact/SessionEnd keep their own
213
+ * bounded synchronous budgets.
214
+ */
200
215
  export function runProjectTransitionHook(projectDir, event, { payload = {}, host = process.env.RUVNET_HOOK_HOST || 'claude',
201
- readHistory = readTransitionHistory, capture = runSessionSnapshotHook, env = process.env } = {}) {
216
+ env = process.env, spawnReplay = replayOutboxDetached, now = Date.now } = {}) {
202
217
  if (developmentHooksSuspended(projectDir)) return { state: 'skipped', reason: 'development hooks suspended' };
203
218
  const suspended = automaticProgressionSuspensionResult(env, { state: 'suspended', reason: 'automatic project progression is operator-suspended' });
204
219
  if (suspended) return suspended;
@@ -218,12 +233,16 @@ export function runProjectTransitionHook(projectDir, event, { payload = {}, host
218
233
  payload: { session_id: payload.session_id, hook_event_name: transportEvent,
219
234
  normalizedTransition: { observation, sourceIdentity: observeTransitionSource(resolution, projectDir) } } });
220
235
  if (!queued) return { state: 'degraded', reason: 'normalized observation queue unwritable', eventId: observation.id };
221
- let result = null;
222
- runOutboxReplay({ projectDir: resolution.projectRoot, env, budgetMs: Math.min(6500, effectiveBudgetMs(env)),
223
- captureNormalized: (job, options) => captureNormalizedTransition(job, { ...options, env, readHistory, capture }),
224
- onCaptured: (captured) => { if (captured?.eventId === observation.id) result = captured; } });
225
- if (!result?.receipt) replayOutboxDetached({ projectDir: resolution.projectRoot, env });
226
- return { state: result?.progressionCaptured && result.receipt ? 'committed' : 'pending', eventId: observation.id, result };
236
+ const handed = spawnReplay({ projectDir: resolution.projectRoot, env });
237
+ let oldest = Infinity;
238
+ try {
239
+ for (const name of fs.readdirSync(path.join(resolution.projectRoot, '.swarm'))) {
240
+ if (!/^\.progression-capture-(?:queue|claimed)-.*\.json$/.test(name)) continue;
241
+ oldest = Math.min(oldest, fs.statSync(path.join(resolution.projectRoot, '.swarm', name)).mtimeMs);
242
+ }
243
+ } catch { /* the observation itself is already durable */ }
244
+ if (now() - oldest > QUEUE_STUCK_AFTER_MS) return { state: 'stuck', eventId: observation.id, handed, oldestQueuedAt: new Date(oldest).toISOString() };
245
+ return { state: 'queued', eventId: observation.id, handed };
227
246
  }
228
247
 
229
248
  /** One pending-readback condition across prompt/tool boundaries and both CLI entrypoints. */
@@ -238,8 +257,10 @@ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.me
238
257
  const payload = JSON.parse(fs.readFileSync(0, 'utf8') || '{}');
239
258
  const projectDir = payload.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd();
240
259
  const result = runProjectTransitionHook(projectDir, process.argv[2], { payload });
241
- if (result.state === 'pending') {
242
- const message = transitionPendingNotice(projectDir, payload, 'Project memory transition is pending; exact AgentDB readback was not verified at this boundary.');
260
+ if (result.state === 'stuck' || result.state === 'degraded') {
261
+ const message = transitionPendingNotice(projectDir, payload, result.state === 'stuck'
262
+ ? `Project memory capture is stuck: queued work since ${result.oldestQueuedAt} has not been committed by exact readback.`
263
+ : 'Project memory capture degraded: the observation queue is unwritable.');
243
264
  if (message) process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: process.argv[2], additionalContext: message } }));
244
265
  }
245
266
  } catch { process.stdout.write(JSON.stringify({ systemMessage: 'Project memory transition capture degraded; exact readback was not verified.' })); }
@@ -323,8 +323,13 @@ if (process.argv[1] && path.resolve(process.argv[1]).endsWith('session-snapshot-
323
323
  const payload = JSON.parse(fs.readFileSync(0, 'utf8') || '{}');
324
324
  const projectDir = payload.cwd || projectDirectory();
325
325
  const result = runProjectTransitionHook(projectDir, process.argv[2], { payload });
326
- if (result.state === 'pending') {
327
- const message = transitionPendingNotice(projectDir, payload, 'Project memory transition remains pending; exact readback was not verified.');
326
+ // ADR-106: learning capture rides this boundary instead of a second PostToolUse registration.
327
+ if (process.argv[2] === 'PostToolUse') { try { (await import('./learn-capture.mjs')).captureLearning(payload); } catch { /* advisory */ } }
328
+ // A queued, handed-off observation is normal and silent (ADR-106); only stuck or degraded speaks.
329
+ if (result.state === 'stuck' || result.state === 'degraded') {
330
+ const message = transitionPendingNotice(projectDir, payload, result.state === 'stuck'
331
+ ? `Project memory capture is stuck: queued work since ${result.oldestQueuedAt} has not been committed by exact readback.`
332
+ : 'Project memory capture degraded: the observation queue is unwritable.');
328
333
  if (message) process.stdout.write(JSON.stringify({ systemMessage: message }));
329
334
  }
330
335
  } catch { process.stdout.write(JSON.stringify({ systemMessage: 'Project memory transition capture degraded; exact readback was not verified.' })); }
@@ -340,6 +345,10 @@ if (process.argv[1] && path.resolve(process.argv[1]).endsWith('session-snapshot-
340
345
  if (typeof cwd === 'string' && path.isAbsolute(cwd)) originProjectDir = cwd;
341
346
  } catch { /* malformed input keeps the host's project fallback */ }
342
347
  const result = runSessionSnapshotHook(originProjectDir, process.argv[2] || 'SessionEnd', { rawInput });
348
+ // ADR-106: learning recovery rides SessionEnd instead of a second SessionEnd registration.
349
+ if ((process.argv[2] || 'SessionEnd') === 'SessionEnd') {
350
+ import('./learn-capture.mjs').then((m) => m.scheduleLearningRecovery({ cwd: originProjectDir })).catch(() => {});
351
+ }
343
352
  // FAIL LOUDLY, NEVER SILENTLY — AND ONCE. When recording is stuck (events pending past STUCK_AFTER_MS,
344
353
  // a quarantined conflict, a corrupt outbox line, a cap drop) Claude Code shows this systemMessage, at
345
354
  // most once per session per condition (stopNotice; it used to repeat at every turn). "Not applicable"
@@ -34,6 +34,7 @@ import {
34
34
  } from './session-start-fsutil.mjs';
35
35
  import { maintainerIssueEntitlement, surfaceIssuePointer } from './session-start-issue-alert.mjs';
36
36
  import { surfaceSignals } from './session-start-signals.mjs';
37
+ import { failureLine, FAILURE_LINE_PREFIX } from './hook-failures.mjs';
37
38
  import { brainState, health, knowledgeCurrency, mcpReadiness, KNOWLEDGE_LINE_PREFIX } from './session-start-health.mjs';
38
39
  import { stableSpine, heartbeat, knowledgeAutoUpdate, footprintCheck } from './session-start-update-plane.mjs';
39
40
  import { FOOTPRINT_LINE_PREFIX } from './brain-confirmation.mjs';
@@ -98,6 +99,7 @@ export async function runSessionStart({
98
99
  probeConsoleAlias = probeCodexConsoleAlias,
99
100
  startedAt = Date.now(),
100
101
  deadlineAt = Infinity,
102
+ scheduleRecovery = true,
101
103
  } = {}) {
102
104
  const hookDeadlineAt = Math.min(deadlineAt, sessionStartDeadlineAt({ host: env.RUVNET_HOOK_HOST || 'claude', env, startedAt }));
103
105
  const lines = [];
@@ -137,7 +139,8 @@ export async function runSessionStart({
137
139
  || s.startsWith('[RuvNet Brain — PROJECT CONTINUITY UNKNOWN]')
138
140
  || s.startsWith('[RuvNet Brain — PROJECT CONTINUITY RESTORED]')
139
141
  || s.startsWith('[RuvNet Brain — COME UP TO SPEED')
140
- || s.startsWith('[RuvNet Brain — MAINTAINER ONLY:');
142
+ || s.startsWith('[RuvNet Brain — MAINTAINER ONLY:')
143
+ || s.startsWith(FAILURE_LINE_PREFIX);
141
144
  };
142
145
  // An alarm's HEADER line always matches isSafeStatus on its own dedicated prefix (above); its
143
146
  // explanatory BODY lines do not, and without this they were silently dropped — a real gap this
@@ -170,6 +173,8 @@ export async function runSessionStart({
170
173
  const home = env.HOME || env.USERPROFILE || os.homedir();
171
174
  const stateDir = env.RUVNET_BRAIN_HOME || path.join(home, '.cache', 'ruvnet-brain');
172
175
  resetInjectionDedupe(stateDir, Date.now());
176
+ // ADR-106: advisory hooks fail open, so a failed one is reported here, once, never per fire.
177
+ try { const failed = failureLine(stateDir); if (failed) emit(failed); } catch { /* the summary must not cost the session */ }
173
178
  const hookDir = path.dirname(fileURLToPath(import.meta.url));
174
179
  const now = Date.now();
175
180
  const consoleInvoke = env.RUVNET_HOOK_HOST === 'codex' ? '$ruvnet-brain:rnbc' : '/rnbc';
@@ -449,6 +454,9 @@ export async function runSessionStart({
449
454
  stdout.write(output);
450
455
  trace('body-finished');
451
456
  recordProof({ stages: tracer.table(), restore: restoreProof, bodyFailed, bannerFallback: !bannerEmitted });
457
+ // ADR-106: learning recovery rides SessionStart instead of a second SessionStart registration.
458
+ // learningContext() inside owns consent and brain-off; the detached worker is never awaited.
459
+ if (scheduleRecovery) { try { (await import('./learn-capture.mjs')).scheduleLearningRecovery({ cwd, env }); } catch { /* advisory */ } }
452
460
  return { ok: true, outputBytes: Buffer.byteLength(output, 'utf8'), platform };
453
461
  }
454
462
 
@@ -41,7 +41,7 @@ import { fileURLToPath } from 'node:url';
41
41
  import { nativeTurnLines } from './native-turn-transcript.mjs';
42
42
  import { resolveRuflo, rufloInvocation } from './ruflo-bin.mjs';
43
43
  import { rufloCwdFor, rufloScratchRoot } from './project-progression-store.mjs';
44
- import { redactText, userLevelAgentdbHooks } from './continuity-events.mjs';
44
+ import { redactText } from './continuity-events.mjs';
45
45
  import { resolveProjectStore, globalEnrollmentAllowed } from './project-store-resolver.mjs';
46
46
  import { digest, journalTurn, readJournal, pendingTurnFiles, acknowledgeJournal, appendReceipt, storeData } from './turn-transport-journal.mjs';
47
47
  import { contentPathExcludes, pathIsExcluded, privateTurn, maskExcludedPaths, captureFailureReason, firstTurnCaptureNotice } from './turn-capture-privacy.mjs';
@@ -287,17 +287,10 @@ export function captureTurnOutcome({
287
287
  const project = projectName(projectRoot);
288
288
  const receipts = path.join(brainHome, 'turn-capture', 'receipts.jsonl');
289
289
 
290
- // ONE WRITER PER TURN (ADR-100 §3). Measured 2026-10-01 on this repo's real store: 624 `turns` rows
291
- // in five days for 323 distinct outcomes — this writer (host-tagged) and the owner's user-level
292
- // ~/.claude/hooks/agentdb-turn-capture.mjs both recorded every Claude turn. The user-level hook is
293
- // the owner's and is never edited by the product, so where it is registered the product DEFERS
294
- // (RUVNET_TURN_CAPTURE=force keeps both). Codex turns are not seen by that Claude-only hook.
295
- const deferTo = event === 'Stop' && host === 'claude' && String(env.RUVNET_TURN_CAPTURE || '').toLowerCase() !== 'force'
296
- && userLevelAgentdbHooks({ home, event, projectDir, env }).turnCapture;
297
- if (deferTo) {
298
- report.skipped = 'deferred: an enabled user-level Stop writer explicitly targets the canonical turn store; actual native delivery is unproven';
299
- report.deferredToUserLevel = true;
300
- } else if (['Stop', 'SessionEnd', 'PreCompact'].includes(event)) {
290
+ // ONE WRITER PER TURN (ADR-105 §7): the product is the only writer of project turn history. The
291
+ // former deferral to a user-level ~/.claude hook (ADR-100 §3) could never fire — its probe never
292
+ // reported a writer — and cost a git call on every Stop, so it was removed (ADR-106).
293
+ if (['Stop', 'SessionEnd', 'PreCompact'].includes(event)) {
301
294
  const sessionKey = String(payload.session_id || payload.transcript_path || '');
302
295
  const message = typeof payload.last_assistant_message === 'string' ? payload.last_assistant_message.trim() : '';
303
296
  let turn = { finalText: '', files: [], actions: [] };
@@ -487,8 +480,7 @@ export function turnRecordingStatus({ projectDir = process.cwd(), env = process.
487
480
  let target;
488
481
  try { target = resolveTurnDb({ projectDir, brainHome }); } catch { return { state: 'unknown', line: 'turn recording unavailable: canonical store resolution failed' }; }
489
482
  if (String(env.RUVNET_TURN_CAPTURE || '').toLowerCase() === 'off' || target.skipped) return { state: 'unknown', line: `turn recording n/a — ${target.skipped || 'RUVNET_TURN_CAPTURE=off'}` };
490
- const deferred = String(env.RUVNET_HOOK_HOST || 'claude') === 'claude' && String(env.RUVNET_TURN_CAPTURE || '').toLowerCase() !== 'force' && userLevelAgentdbHooks({ home, event: 'Stop', projectDir, env }).turnCapture;
491
- const notice = noticeOnFirstUse && !deferred ? firstTurnCaptureNotice({ target, brainHome, policyFile: turnCapturePolicyFile(brainHome), env }) : null;
483
+ const notice = noticeOnFirstUse ? firstTurnCaptureNotice({ target, brainHome, policyFile: turnCapturePolicyFile(brainHome), env }) : null;
492
484
  const status = (value) => ({ ...value, ...(notice ? { notice } : {}) });
493
485
  let rows = [];
494
486
  try {
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * deferral-replay.mjs — measure continuation-gate's `no-deferral` check (ADR-106) on REAL Claude Code
4
+ * transcripts, read-only. Same turn model as completion-claim-replay.mjs: a turn runs from one genuine
5
+ * user message to the next and its final assistant text stands in for `last_assistant_message`;
6
+ * authorization is read from the owner prompts up to and including that turn.
7
+ *
8
+ * node scripts/deferral-replay.mjs [--root ~/.claude/projects] [--limit 60] [--skip 0] [--out sample.jsonl]
9
+ */
10
+ import fs from 'node:fs';
11
+ import os from 'node:os';
12
+ import path from 'node:path';
13
+ import { transcripts, turnsOf, finalText } from './completion-claim-replay.mjs';
14
+ import { auditDeferral, ownerPrompts } from '../plugin/scripts/deferral-evidence.mjs';
15
+
16
+ const argv = process.argv.slice(2);
17
+ const opt = (flag, fallback) => { const i = argv.indexOf(flag); return i >= 0 && argv[i + 1] ? argv[i + 1] : fallback; };
18
+ const root = path.resolve(opt('--root', path.join(os.homedir(), '.claude', 'projects')));
19
+ const limit = Number(opt('--limit', '60'));
20
+ const skip = Number(opt('--skip', '0')); // held-out evaluation: skip the newest N transcripts
21
+ const out = opt('--out', null);
22
+ const report = { root, transcripts: 0, turns: 0, deferralPhrase: 0, blocked: 0, unauthorized: 0, forced: 0, byKind: {} };
23
+ const samples = [];
24
+ for (const file of transcripts(root, skip + limit).slice(skip)) {
25
+ report.transcripts += 1;
26
+ const lines = fs.readFileSync(file, 'utf8').split('\n').filter(Boolean);
27
+ const prompts = [];
28
+ for (const turn of turnsOf(lines)) {
29
+ report.turns += 1;
30
+ prompts.push(...ownerPrompts(turn.slice(0, 1)));
31
+ const message = finalText(turn);
32
+ if (!message) continue;
33
+ const v = auditDeferral(message, { prompts });
34
+ if (v.verdict === 'NONE') continue;
35
+ report.deferralPhrase += 1;
36
+ if (v.verdict === 'BLOCKER') report.blocked += 1;
37
+ if (v.verdict === 'UNAUTHORIZED') report.unauthorized += 1;
38
+ if (v.verdict === 'DEFERRAL') {
39
+ report.forced += 1;
40
+ report.byKind[v.deferral.kind] = (report.byKind[v.deferral.kind] || 0) + 1;
41
+ samples.push({ file: path.basename(file), phrase: v.deferral.phrase, authorization: v.authorization, tail: message.slice(-500) });
42
+ }
43
+ }
44
+ }
45
+ report.forcedRate = report.turns ? Number((report.forced / report.turns).toFixed(4)) : 0;
46
+ if (out) fs.writeFileSync(out, samples.map((s) => JSON.stringify(s)).join('\n') + '\n');
47
+ console.log(JSON.stringify(report, null, 2));