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.
- package/README.md +1 -1
- package/package.json +2 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/hooks/codex-hooks.json +1 -31
- package/plugin/hooks/hook-contracts.json +16 -96
- package/plugin/hooks/hooks.json +1 -31
- package/plugin/scripts/agentdb-recall.mjs +19 -7
- package/plugin/scripts/continuation-gate.mjs +78 -1
- package/plugin/scripts/continuity-brief.mjs +4 -10
- package/plugin/scripts/continuity-events.mjs +0 -80
- package/plugin/scripts/continuity-hook-policy.mjs +3 -3
- package/plugin/scripts/decision-gate.mjs +2 -2
- package/plugin/scripts/decision-recall-gate.mjs +141 -0
- package/plugin/scripts/decision-record.mjs +353 -0
- package/plugin/scripts/deferral-evidence.mjs +134 -0
- package/plugin/scripts/hook-failures.mjs +45 -0
- package/plugin/scripts/hook-shim.mjs +70 -4
- package/plugin/scripts/learn-capture.mjs +67 -34
- package/plugin/scripts/project-transition-hook.mjs +31 -10
- package/plugin/scripts/session-snapshot-hook.mjs +11 -2
- package/plugin/scripts/session-start-core.mjs +9 -1
- package/plugin/scripts/turn-outcome-capture.mjs +6 -14
- package/scripts/deferral-replay.mjs +47 -0
|
@@ -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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
if (
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
process.
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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 === '
|
|
242
|
-
const message = transitionPendingNotice(projectDir, payload,
|
|
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
|
-
|
|
327
|
-
|
|
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
|
|
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-
|
|
291
|
-
//
|
|
292
|
-
//
|
|
293
|
-
|
|
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
|
|
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));
|