ruvnet-brain 4.6.2 → 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.
Files changed (35) hide show
  1. package/README.md +1 -1
  2. package/package.json +2 -1
  3. package/plugin/.claude-plugin/plugin.json +1 -1
  4. package/plugin/.codex-plugin/plugin.json +1 -1
  5. package/plugin/hooks/codex-hooks.json +1 -31
  6. package/plugin/hooks/hook-contracts.json +16 -96
  7. package/plugin/hooks/hooks.json +1 -31
  8. package/plugin/scripts/agentdb-recall-process.mjs +118 -0
  9. package/plugin/scripts/agentdb-recall-reader.mjs +22 -0
  10. package/plugin/scripts/agentdb-recall.mjs +99 -104
  11. package/plugin/scripts/continuation-gate.mjs +78 -1
  12. package/plugin/scripts/continuity-brief.mjs +4 -10
  13. package/plugin/scripts/continuity-events.mjs +6 -85
  14. package/plugin/scripts/continuity-hook-policy.mjs +3 -3
  15. package/plugin/scripts/continuity-journal.mjs +22 -16
  16. package/plugin/scripts/decision-gate.mjs +2 -2
  17. package/plugin/scripts/decision-recall-gate.mjs +141 -0
  18. package/plugin/scripts/decision-record.mjs +353 -0
  19. package/plugin/scripts/deferral-evidence.mjs +134 -0
  20. package/plugin/scripts/hook-failures.mjs +45 -0
  21. package/plugin/scripts/hook-shim.mjs +70 -4
  22. package/plugin/scripts/learn-capture.mjs +67 -34
  23. package/plugin/scripts/native-turn-transcript.mjs +51 -0
  24. package/plugin/scripts/project-capture-queue.mjs +2 -0
  25. package/plugin/scripts/project-memory-enrollment.mjs +203 -0
  26. package/plugin/scripts/project-progression-reader.mjs +4 -4
  27. package/plugin/scripts/project-progression-session-start.mjs +7 -27
  28. package/plugin/scripts/project-progression-store.mjs +18 -6
  29. package/plugin/scripts/project-store-resolver.mjs +44 -6
  30. package/plugin/scripts/project-transition-hook.mjs +31 -10
  31. package/plugin/scripts/session-snapshot-hook.mjs +18 -2
  32. package/plugin/scripts/session-start-core.mjs +15 -1
  33. package/plugin/scripts/turn-outcome-capture.mjs +38 -32
  34. package/plugin/scripts/turn-transport-journal.mjs +3 -1
  35. package/scripts/deferral-replay.mjs +47 -0
@@ -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.' })); }
@@ -15,6 +15,7 @@ import { projectDirectory } from './project-identity.mjs';
15
15
  import { buildProjectProgression } from './project-progression-producer.mjs';
16
16
  import { ProjectProgressionStore } from './project-progression-store.mjs';
17
17
  import { resolveProjectStore } from './project-store-resolver.mjs';
18
+ import { ensureProjectMemory } from './project-memory-enrollment.mjs';
18
19
  import { resolveTurnDb, captureTurnOutcome } from './turn-outcome-capture.mjs';
19
20
  import { captureContinuityEvents, stopNotice } from './continuity-journal.mjs';
20
21
  import { automaticProgressionSuspensionResult } from './project-progression-suspension.mjs';
@@ -129,6 +130,7 @@ export function runSessionSnapshotHook(projectDir, event, {
129
130
  spawnReplay = replayOutboxDetached,
130
131
  ordered = null,
131
132
  captureEvents = captureContinuityEvents,
133
+ enrollMemory = ensureProjectMemory,
132
134
  deadlineAt: inheritedDeadlineAt = Infinity,
133
135
  signal,
134
136
  } = {}) {
@@ -136,6 +138,11 @@ export function runSessionSnapshotHook(projectDir, event, {
136
138
  const checkDeadline = () => { if (signal?.aborted || now() >= deadlineAt) throw new Error('restore deadline exceeded'); };
137
139
  const suspended = (reason) => ({ metadataWritten: false, progressionCaptured: false, receipt: null, skipped: reason,
138
140
  turn: { event, recorded: false, skipped: reason }, continuity: { event, recorded: 0, launched: false, skipped: reason } });
141
+ let enrollmentPayload = {};
142
+ try { enrollmentPayload = rawInput ? JSON.parse(rawInput) : {}; } catch { /* malformed payload */ }
143
+ const enrollment = enrollMemory({ projectDir, env, event, payload: enrollmentPayload, host, deadlineAt });
144
+ if (enrollment.state === 'pending') return { ...suspended('native project memory enrollment pending'), enrollment };
145
+ if (enrollment.state === 'disabled' || enrollment.state === 'unavailable') return { ...suspended(enrollment.reason), enrollment };
139
146
  // Consent is checked before metadata, transcript inspection or any durable capture queue.
140
147
  try {
141
148
  checkDeadline();
@@ -316,8 +323,13 @@ if (process.argv[1] && path.resolve(process.argv[1]).endsWith('session-snapshot-
316
323
  const payload = JSON.parse(fs.readFileSync(0, 'utf8') || '{}');
317
324
  const projectDir = payload.cwd || projectDirectory();
318
325
  const result = runProjectTransitionHook(projectDir, process.argv[2], { payload });
319
- if (result.state === 'pending') {
320
- 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.');
321
333
  if (message) process.stdout.write(JSON.stringify({ systemMessage: message }));
322
334
  }
323
335
  } catch { process.stdout.write(JSON.stringify({ systemMessage: 'Project memory transition capture degraded; exact readback was not verified.' })); }
@@ -333,6 +345,10 @@ if (process.argv[1] && path.resolve(process.argv[1]).endsWith('session-snapshot-
333
345
  if (typeof cwd === 'string' && path.isAbsolute(cwd)) originProjectDir = cwd;
334
346
  } catch { /* malformed input keeps the host's project fallback */ }
335
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
+ }
336
352
  // FAIL LOUDLY, NEVER SILENTLY — AND ONCE. When recording is stuck (events pending past STUCK_AFTER_MS,
337
353
  // a quarantined conflict, a corrupt outbox line, a cap drop) Claude Code shows this systemMessage, at
338
354
  // most once per session per condition (stopNotice; it used to repeat at every turn). "Not applicable"
@@ -26,6 +26,7 @@ import fs from 'node:fs';
26
26
  import os from 'node:os';
27
27
  import path from 'node:path';
28
28
  import { fileURLToPath } from 'node:url';
29
+ import { enrollmentPlan } from './project-memory-enrollment.mjs';
29
30
  import { restoreWithBrief } from './continuity-brief.mjs';
30
31
  import { turnRecordingStatus } from './turn-outcome-capture.mjs';
31
32
  import {
@@ -33,6 +34,7 @@ import {
33
34
  } from './session-start-fsutil.mjs';
34
35
  import { maintainerIssueEntitlement, surfaceIssuePointer } from './session-start-issue-alert.mjs';
35
36
  import { surfaceSignals } from './session-start-signals.mjs';
37
+ import { failureLine, FAILURE_LINE_PREFIX } from './hook-failures.mjs';
36
38
  import { brainState, health, knowledgeCurrency, mcpReadiness, KNOWLEDGE_LINE_PREFIX } from './session-start-health.mjs';
37
39
  import { stableSpine, heartbeat, knowledgeAutoUpdate, footprintCheck } from './session-start-update-plane.mjs';
38
40
  import { FOOTPRINT_LINE_PREFIX } from './brain-confirmation.mjs';
@@ -97,6 +99,7 @@ export async function runSessionStart({
97
99
  probeConsoleAlias = probeCodexConsoleAlias,
98
100
  startedAt = Date.now(),
99
101
  deadlineAt = Infinity,
102
+ scheduleRecovery = true,
100
103
  } = {}) {
101
104
  const hookDeadlineAt = Math.min(deadlineAt, sessionStartDeadlineAt({ host: env.RUVNET_HOOK_HOST || 'claude', env, startedAt }));
102
105
  const lines = [];
@@ -136,7 +139,8 @@ export async function runSessionStart({
136
139
  || s.startsWith('[RuvNet Brain — PROJECT CONTINUITY UNKNOWN]')
137
140
  || s.startsWith('[RuvNet Brain — PROJECT CONTINUITY RESTORED]')
138
141
  || s.startsWith('[RuvNet Brain — COME UP TO SPEED')
139
- || s.startsWith('[RuvNet Brain — MAINTAINER ONLY:');
142
+ || s.startsWith('[RuvNet Brain — MAINTAINER ONLY:')
143
+ || s.startsWith(FAILURE_LINE_PREFIX);
140
144
  };
141
145
  // An alarm's HEADER line always matches isSafeStatus on its own dedicated prefix (above); its
142
146
  // explanatory BODY lines do not, and without this they were silently dropped — a real gap this
@@ -169,6 +173,8 @@ export async function runSessionStart({
169
173
  const home = env.HOME || env.USERPROFILE || os.homedir();
170
174
  const stateDir = env.RUVNET_BRAIN_HOME || path.join(home, '.cache', 'ruvnet-brain');
171
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 */ }
172
178
  const hookDir = path.dirname(fileURLToPath(import.meta.url));
173
179
  const now = Date.now();
174
180
  const consoleInvoke = env.RUVNET_HOOK_HOST === 'codex' ? '$ruvnet-brain:rnbc' : '/rnbc';
@@ -189,6 +195,11 @@ export async function runSessionStart({
189
195
  }
190
196
  };
191
197
 
198
+ // Report only. SessionStart never creates or bootstraps a store (ADR-105 rule 6); enrollment starts at the first
199
+ // consented captured boundary, so a session that merely started leaves no store behind.
200
+ let enrollment;
201
+ try { enrollment = enrollmentPlan({ projectDir: cwd, env, deadlineAt: hookDeadlineAt }); } catch (error) { enrollment = { state: 'unavailable', reason: error.message }; }
202
+ if (enrollment.state === 'pending' || enrollment.state === 'unavailable') emit(`[RuvNet Brain — TURN CAPTURE] Project memory enrollment ${enrollment.state}; restoration is not yet proven.`);
192
203
  const restoreStart = Date.now();
193
204
  const restoreDeadlineAt = Math.min(hookDeadlineAt, restoreStart + STAGE_BUDGETS_MS.restore);
194
205
  const restoreController = new AbortController();
@@ -443,6 +454,9 @@ export async function runSessionStart({
443
454
  stdout.write(output);
444
455
  trace('body-finished');
445
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 */ } }
446
460
  return { ok: true, outputBytes: Buffer.byteLength(output, 'utf8'), platform };
447
461
  }
448
462
 
@@ -23,11 +23,9 @@
23
23
  * • Content = assistant outcome text, files changed, Bash descriptions. NEVER raw user text (a
24
24
  * 2026-07-13 measurement: prompt echoes made 87% of a store noise). Trivial turns are skipped.
25
25
  *
26
- * CODEX: codex-cli 0.158.0's own `stop.command.input` schema (read from the installed binary
27
- * 2026-09-29) carries `last_assistant_message` (nullable) and `transcript_path` (nullable).
28
- * The Codex rollout format is NOT parsed: project-progression-sources.mjs already declares it
29
- * unknown, and the rollout records observed locally (custom_tool_call / function_call with free-form
30
- * inputs) give no stable file-change shape. Codex records therefore carry the outcome text only.
26
+ * CODEX: native response_item JSONL is normalized mechanically through native-turn-transcript.
27
+ * SessionEnd and PreCompact recover a closing outcome when Stop payload text is absent. Tool IDs
28
+ * correlate observable gate outcomes; opaque custom orchestration inputs remain unclassified.
31
29
  *
32
30
  * LATENCY: Stop runs synchronously in the host's turn, and one `ruflo memory store` costs ~0.7s
33
31
  * (measured). So the writes run in ONE detached worker (this file, `--run-steps`), store then
@@ -40,9 +38,11 @@ import path from 'node:path';
40
38
  import crypto from 'node:crypto';
41
39
  import { spawn, spawnSync } from 'node:child_process';
42
40
  import { fileURLToPath } from 'node:url';
41
+ import { nativeTurnLines } from './native-turn-transcript.mjs';
43
42
  import { resolveRuflo, rufloInvocation } from './ruflo-bin.mjs';
44
- import { redactText, userLevelAgentdbHooks } from './continuity-events.mjs';
45
- import { resolveProjectStore } from './project-store-resolver.mjs';
43
+ import { rufloCwdFor, rufloScratchRoot } from './project-progression-store.mjs';
44
+ import { redactText } from './continuity-events.mjs';
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';
48
48
 
@@ -162,7 +162,7 @@ const consentMap = (value) => value !== null && typeof value === 'object'
162
162
  && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype
163
163
  && Object.values(value).every((setting) => setting === 'on' || setting === 'off');
164
164
 
165
- export function resolveTurnDb({ projectDir, brainHome, requestedStorePath, gitTimeoutMs = 1000, unknownOriginalPath = false, deadlineAt = Infinity, signal } = {}) {
165
+ export function resolveTurnDb({ projectDir, brainHome, requestedStorePath, gitTimeoutMs = 1000, unknownOriginalPath = false, deadlineAt = Infinity, signal, enrollmentRoots = {} } = {}) {
166
166
  if (signal?.aborted || Date.now() >= deadlineAt) throw new Error('restore deadline exceeded');
167
167
  const resolved = resolveProjectStore({ projectDir, requestedStorePath, gitTimeoutMs, deadlineAt });
168
168
  if (signal?.aborted || Date.now() >= deadlineAt) throw new Error('restore deadline exceeded');
@@ -173,12 +173,14 @@ export function resolveTurnDb({ projectDir, brainHome, requestedStorePath, gitTi
173
173
  try {
174
174
  policy = JSON.parse(fs.readFileSync(file, 'utf8'));
175
175
  if (policy?.schemaVersion !== 1 || !consentMap(policy.projects)
176
+ || (Object.hasOwn(policy, 'default') && !['on', 'off'].includes(policy.default))
176
177
  || (Object.hasOwn(policy, 'paths') && !consentMap(policy.paths))) throw new Error('invalid policy');
177
178
  contentPathExcludes(policy.contentPathExcludes);
178
179
  } catch { return { skipped: 'turn capture policy unreadable or invalid', projectRoot: resolved.projectRoot }; }
179
180
  }
180
181
  // A path rule wins over a project rule, allowing a linked checkout/subdirectory to opt out.
181
- const setting = policy.paths?.[capturePath] ?? policy.projects?.[resolved.projectRoot];
182
+ const explicitSetting = policy.paths?.[capturePath] ?? policy.projects?.[resolved.projectRoot];
183
+ const setting = explicitSetting ?? policy.default;
182
184
  if (setting !== undefined && !['on', 'off'].includes(setting)) return { skipped: 'invalid turn capture consent', projectRoot: resolved.projectRoot };
183
185
  const db = resolved.canonicalAgentDbPath;
184
186
  const exclusions = contentPathExcludes(policy.contentPathExcludes);
@@ -191,6 +193,9 @@ export function resolveTurnDb({ projectDir, brainHome, requestedStorePath, gitTi
191
193
  if (setting === 'off') return { db, scope: 'project', capturePath, projectRoot: resolved.projectRoot, skipped: 'persisted turn capture opt-out' };
192
194
  let exists = false;
193
195
  try { exists = fs.statSync(db).isFile(); } catch { /* absent */ }
196
+ if (!exists && setting === 'on' && explicitSetting !== 'on' && !globalEnrollmentAllowed({ resolution: resolved, brainHome, ...enrollmentRoots })) {
197
+ return { db, scope: 'project', capturePath, projectRoot: resolved.projectRoot, skipped: 'global capture consent requires a safe Git project root; explicit project/path opt-in required' };
198
+ }
194
199
  if (!exists && setting !== 'on') return { db, scope: 'project', capturePath, projectRoot: resolved.projectRoot, skipped: 'no project memory db; persisted opt-in required' };
195
200
  return { db, scope: 'project', capturePath, projectRoot: resolved.projectRoot, optedIn: setting === 'on',
196
201
  contentPathExcludes: exclusions };
@@ -282,24 +287,17 @@ export function captureTurnOutcome({
282
287
  const project = projectName(projectRoot);
283
288
  const receipts = path.join(brainHome, 'turn-capture', 'receipts.jsonl');
284
289
 
285
- // ONE WRITER PER TURN (ADR-100 §3). Measured 2026-10-01 on this repo's real store: 624 `turns` rows
286
- // in five days for 323 distinct outcomes — this writer (host-tagged) and the owner's user-level
287
- // ~/.claude/hooks/agentdb-turn-capture.mjs both recorded every Claude turn. The user-level hook is
288
- // the owner's and is never edited by the product, so where it is registered the product DEFERS
289
- // (RUVNET_TURN_CAPTURE=force keeps both). Codex turns are not seen by that Claude-only hook.
290
- const deferTo = event === 'Stop' && host === 'claude' && String(env.RUVNET_TURN_CAPTURE || '').toLowerCase() !== 'force'
291
- && userLevelAgentdbHooks({ home, event, projectDir, env }).turnCapture;
292
- if (deferTo) {
293
- report.skipped = 'deferred: an enabled user-level Stop writer explicitly targets the canonical turn store; actual native delivery is unproven';
294
- report.deferredToUserLevel = true;
295
- } else if (event === 'Stop') {
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)) {
296
294
  const sessionKey = String(payload.session_id || payload.transcript_path || '');
297
295
  const message = typeof payload.last_assistant_message === 'string' ? payload.last_assistant_message.trim() : '';
298
296
  let turn = { finalText: '', files: [], actions: [] };
299
- if (host === 'claude' && typeof payload.transcript_path === 'string' && payload.transcript_path) {
297
+ if (typeof payload.transcript_path === 'string' && payload.transcript_path) {
300
298
  // With the closing message in hand, the transcript is read immediately (tool calls are already
301
299
  // flushed); without it, wait for the closing message to land, bounded.
302
- try { turn = claudeTurn(readTranscript(payload.transcript_path, { maxMs: message ? 0 : settleMs })); } catch { /* unreadable */ }
300
+ try { turn = claudeTurn(nativeTurnLines(readTranscript(payload.transcript_path, { maxMs: message || event !== 'Stop' ? 0 : settleMs }), host)); } catch { /* unreadable */ }
303
301
  }
304
302
  if (message && (message.length >= MIN_OUTCOME_CHARS || message.length >= turn.finalText.length)) turn.finalText = message;
305
303
  turn = privateTurn(turn, target.contentPathExcludes, target.capturePath);
@@ -323,12 +321,12 @@ export function captureTurnOutcome({
323
321
  catch (error) { return { ...report, skipped: `durable turn queue invalid: ${redactText(error.message)}` }; }
324
322
  }
325
323
  steps.push({ ...binding, kind: 'store', ruflo, args: ['memory', 'store', '-k', key, '--value', value, '-n', TURN_NAMESPACE,
326
- '--path', db, '--no-upsert', '--tags', `project=${project},host=${host}`, '--provenance', 'agent_output'] });
324
+ '--path', db, '--no-upsert', '--require-native', '--append-only', '--tags', `project=${project},host=${host}`, '--provenance', 'agent_output'] });
327
325
  // This synchronous boundary proves only queuing; the worker's exact receipt proves recording.
328
326
  Object.assign(report, { queued: true, key, value });
329
327
  }
330
328
  }
331
- } else report.skipped = `turn outcomes are recorded at Stop, not ${event}`;
329
+ } else report.skipped = `no turn outcome capture at ${event}`;
332
330
 
333
331
  if (event === 'SessionEnd' || event === 'PreCompact') {
334
332
  if (!fs.existsSync(db)) report.distill = { queued: false, skipped: 'no memory db to distill yet' };
@@ -376,7 +374,7 @@ export function runSteps({ steps = [], receipts } = {}, { run: suppliedRun = spa
376
374
  };
377
375
  const results = [];
378
376
  for (const queued of steps) {
379
- let status = 1; let error = null; let verified = false; let db = ''; let key; let privacyPatterns = [];
377
+ let status = 1; let error = null; let skipped = null; let verified = false; let db = ''; let key; let privacyPatterns = [];
380
378
  const kind = queued.kind === 'journal' ? 'store' : queued.kind;
381
379
  try {
382
380
  if (String(env.RUVNET_TURN_CAPTURE || '').toLowerCase() === 'off') throw new Error('RUVNET_TURN_CAPTURE=off');
@@ -396,7 +394,7 @@ export function runSteps({ steps = [], receipts } = {}, { run: suppliedRun = spa
396
394
  binding = queued.kind === 'journal' ? data.binding : queued;
397
395
  key = data.key; value = maskExcludedPaths(redactText(data.value), target.contentPathExcludes, target.capturePath);
398
396
  if (value !== redactText(data.value)) throw new Error('content exclusions changed; immutable turn retained');
399
- args = ['memory', 'store', '-k', key, '--value', value, '-n', TURN_NAMESPACE, '--path', db, '--no-upsert', '--provenance', 'agent_output'];
397
+ args = ['memory', 'store', '-k', key, '--value', value, '-n', TURN_NAMESPACE, '--path', db, '--no-upsert', '--require-native', '--append-only', '--provenance', 'agent_output'];
400
398
  } else if (kind === 'distill') {
401
399
  if (queued.brainHome !== brainHome) throw new Error('queued consent settings differ from trusted caller');
402
400
  const recipe = queued.args;
@@ -424,21 +422,30 @@ export function runSteps({ steps = [], receipts } = {}, { run: suppliedRun = spa
424
422
  const invocation = rufloInvocation(ruflo, args);
425
423
  const remaining = deadlineMs === undefined ? STEP_TIMEOUT_MS : deadlineMs - Date.now();
426
424
  if (remaining <= 0) throw new Error('turn replay deadline exhausted');
427
- const options = { encoding: 'utf8', timeout: Math.min(STEP_TIMEOUT_MS, remaining), cwd: path.dirname(db), windowsHide: true,
428
- maxBuffer: 1024 * 1024, env: { ...env, ...RUFLO_ENV, CLAUDE_FLOW_MEMORY_PATH: path.dirname(db) } };
425
+ // Measured 2026-10-10 with global Ruflo 3.56.3: cwd=<project>/.swarm litters ruvector.db/.claude-flow there, and
426
+ // CLAUDE_FLOW_MEMORY_PATH=<.swarm> makes Ruflo create a second agentdb-memory.db beside the canonical store.
427
+ // The explicit --path already binds the store, so run from private scratch with neither.
428
+ const options = { encoding: 'utf8', timeout: Math.min(STEP_TIMEOUT_MS, remaining), windowsHide: true,
429
+ cwd: rufloCwdFor(db, { root: rufloScratchRoot({ ...env, RUVNET_BRAIN_HOME: brainHome }) }),
430
+ maxBuffer: 1024 * 1024, env: { ...env, ...RUFLO_ENV } };
431
+ delete options.env.CLAUDE_FLOW_MEMORY_PATH;
429
432
  const existing = kind === 'store' && queued.journalFile ? read({ ruflo, db, key, run, options }) : null;
430
433
  if (existing !== null && existing !== value) throw new Error('existing turn key content differs; no upsert permitted');
431
434
  verified = kind === 'store' && existing === value;
432
435
  const r = verified ? { status: 0 } : run(invocation.executable, invocation.args, options);
433
436
  status = Number.isInteger(r.status) ? r.status : 1;
434
- if (kind === 'store' && !verified) verified = read({ ruflo, db, key, run, options }) === value;
437
+ if (kind === 'store' && !verified && status === 0) verified = read({ ruflo, db, key, run, options }) === value;
435
438
  if (verified) status = 0;
439
+ if (kind === 'distill' && status === 0) {
440
+ const match = /(?:^|\n)\s*skipped:\s*([^\n]+)/i.exec(String(r.stdout || '').replace(/\x1b\[[0-9;]*m/g, ''));
441
+ if (match) skipped = redactText(match[1]).slice(0, 300);
442
+ }
436
443
  if (!verified && (r.error || status !== 0)) error = captureFailureReason(r, status);
437
444
  else if (kind === 'store' && !verified) { status = 1; error = 'exact turn key/content readback failed'; }
438
445
  } catch (e) { status = 1; error = redactText(e.message).slice(0, 300); }
439
446
  if (error) error = maskExcludedPaths(error, privacyPatterns, projectDir);
440
447
  const row = { at: new Date().toISOString(), kind, db: redactText(db), storeIdentity: digest(db), key, status, error,
441
- ...(kind === 'store' ? { verified } : {}) };
448
+ ...(kind === 'store' ? { verified } : {}), ...(skipped ? { skipped } : {}) };
442
449
  results.push(row);
443
450
  if (receipts) {
444
451
  try { appendReceipt(receipts, row); if (verified && queued.journalFile) acknowledgeJournal(queued.journalFile, db); }
@@ -473,8 +480,7 @@ export function turnRecordingStatus({ projectDir = process.cwd(), env = process.
473
480
  let target;
474
481
  try { target = resolveTurnDb({ projectDir, brainHome }); } catch { return { state: 'unknown', line: 'turn recording unavailable: canonical store resolution failed' }; }
475
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'}` };
476
- const deferred = String(env.RUVNET_HOOK_HOST || 'claude') === 'claude' && String(env.RUVNET_TURN_CAPTURE || '').toLowerCase() !== 'force' && userLevelAgentdbHooks({ home, event: 'Stop', projectDir, env }).turnCapture;
477
- const notice = noticeOnFirstUse && !deferred ? firstTurnCaptureNotice({ target, brainHome, policyFile: turnCapturePolicyFile(brainHome), env }) : null;
483
+ const notice = noticeOnFirstUse ? firstTurnCaptureNotice({ target, brainHome, policyFile: turnCapturePolicyFile(brainHome), env }) : null;
478
484
  const status = (value) => ({ ...value, ...(notice ? { notice } : {}) });
479
485
  let rows = [];
480
486
  try {
@@ -26,9 +26,11 @@ export function syncTurnDirectory(dir, { platform = process.platform, io = fs }
26
26
  export function storeData(step) {
27
27
  if (step?.kind !== 'store' || !Array.isArray(step.args) || step.args[0] !== 'memory' || step.args[1] !== 'store') throw new Error('invalid turn store operation');
28
28
  const fields = {}; const aliases = { '-k': 'key', '--key': 'key', '--value': 'value', '-n': 'namespace', '--namespace': 'namespace', '--path': 'db', '--tags': 'tags', '--provenance': 'provenance' };
29
- let strict = false;
29
+ let strict = false; let native = false; let immutable = false;
30
30
  for (let i = 2; i < step.args.length; i++) {
31
31
  const flag = step.args[i];
32
+ if (flag === '--append-only') { if (immutable) throw new Error('duplicate turn store flag'); immutable = true; continue; }
33
+ if (flag === '--require-native') { if (native) throw new Error('duplicate turn store flag'); native = true; continue; }
32
34
  if (flag === '--no-upsert') { if (strict) throw new Error('duplicate turn store flag'); strict = true; continue; }
33
35
  const field = aliases[flag];
34
36
  if (!field || Object.hasOwn(fields, field) || typeof step.args[i + 1] !== 'string') throw new Error('unknown or duplicate turn store flag');
@@ -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));