ruvnet-brain 4.6.3 → 4.7.0

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 (75) hide show
  1. package/README.md +1 -1
  2. package/bin/install.mjs +6 -50
  3. package/config/practical-rule-catalog.json +22 -22
  4. package/console/app.js +1 -1
  5. package/console/architecture.html +2 -2
  6. package/package.json +2 -2
  7. package/plugin/.claude-plugin/plugin.json +1 -1
  8. package/plugin/.codex-plugin/plugin.json +1 -1
  9. package/plugin/commands/checkpoint.md +22 -40
  10. package/plugin/commands/rnb-brief.md +18 -15
  11. package/plugin/hooks/codex-hooks.json +1 -58
  12. package/plugin/hooks/hook-contracts.json +19 -220
  13. package/plugin/hooks/hooks.json +1 -70
  14. package/plugin/mcp/managed-cli-interface.mjs +0 -56
  15. package/plugin/scripts/agentdb-recall-reader.mjs +29 -9
  16. package/plugin/scripts/agentdb-recall.mjs +27 -21
  17. package/plugin/scripts/brain-footprint.mjs +1 -1
  18. package/plugin/scripts/codex-hook-wrapper.mjs +13 -3
  19. package/plugin/scripts/completion-claim-evidence.mjs +1 -1
  20. package/plugin/scripts/continuation-gate.mjs +78 -1
  21. package/plugin/scripts/continuity-events.mjs +90 -82
  22. package/plugin/scripts/continuity-hook-policy.mjs +5 -14
  23. package/plugin/scripts/decision-gate.mjs +3 -3
  24. package/plugin/scripts/decision-recall-gate.mjs +141 -0
  25. package/plugin/scripts/decision-record.mjs +324 -0
  26. package/plugin/scripts/deferral-evidence.mjs +134 -0
  27. package/plugin/scripts/hook-failures.mjs +61 -0
  28. package/plugin/scripts/hook-shim.mjs +71 -6
  29. package/plugin/scripts/learn-capture.mjs +88 -37
  30. package/plugin/scripts/learning-store.mjs +70 -60
  31. package/plugin/scripts/node-sqlite.mjs +1 -1
  32. package/plugin/scripts/ruflo-bin.mjs +71 -1
  33. package/plugin/scripts/ruflo-memory-hooks.mjs +132 -0
  34. package/plugin/scripts/runtime-preferences.mjs +1 -1
  35. package/plugin/scripts/session-snapshot-hook.mjs +85 -332
  36. package/plugin/scripts/session-start-core.mjs +7 -9
  37. package/plugin/scripts/signal-watch.mjs +1 -1
  38. package/plugin/scripts/substitution-wall.mjs +222 -0
  39. package/plugin/scripts/turn-outcome-capture.mjs +74 -88
  40. package/scripts/codex-host-execution-proof.mjs +1 -1
  41. package/scripts/console-engine.mjs +0 -32
  42. package/scripts/deferral-replay.mjs +47 -0
  43. package/scripts/health-repair.mjs +1 -81
  44. package/scripts/hook-qualify-core.mjs +2 -2
  45. package/scripts/model-managed-workflow-service.mjs +9 -10
  46. package/scripts/model-routing-controller.mjs +9 -8
  47. package/scripts/no-silent-substitution.mjs +65 -1
  48. package/scripts/onboarding-console.mjs +8 -23
  49. package/scripts/product-integrity-contract.mjs +1 -1
  50. package/scripts/release-qualification-contract.mjs +9 -27
  51. package/scripts/remedy-registry.mjs +0 -18
  52. package/scripts/wired-check.mjs +1 -5
  53. package/plugin/scripts/continuity-brief.mjs +0 -357
  54. package/plugin/scripts/continuity-journal.mjs +0 -553
  55. package/plugin/scripts/learn-flush.mjs +0 -96
  56. package/plugin/scripts/learning-observation.mjs +0 -51
  57. package/plugin/scripts/learning-queue.mjs +0 -135
  58. package/plugin/scripts/learning-worker-supervisor.mjs +0 -79
  59. package/plugin/scripts/project-capture-queue.mjs +0 -357
  60. package/plugin/scripts/project-memory-enrollment.mjs +0 -203
  61. package/plugin/scripts/project-progression-checkpoint.mjs +0 -145
  62. package/plugin/scripts/project-progression-contract.mjs +0 -496
  63. package/plugin/scripts/project-progression-hook.mjs +0 -233
  64. package/plugin/scripts/project-progression-outbox.mjs +0 -225
  65. package/plugin/scripts/project-progression-producer.mjs +0 -309
  66. package/plugin/scripts/project-progression-reader.mjs +0 -304
  67. package/plugin/scripts/project-progression-session-start.mjs +0 -320
  68. package/plugin/scripts/project-progression-sources.mjs +0 -246
  69. package/plugin/scripts/project-progression-store.mjs +0 -723
  70. package/plugin/scripts/project-progression-suspension.mjs +0 -60
  71. package/plugin/scripts/project-transition-hook.mjs +0 -246
  72. package/plugin/scripts/session-snapshot-contract.mjs +0 -105
  73. package/plugin/scripts/turn-transport-journal.mjs +0 -108
  74. package/scripts/qa/progression-validation-benchmark.mjs +0 -66
  75. package/scripts/session-snapshot-contract.mjs +0 -1
@@ -26,14 +26,14 @@ 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';
30
- import { restoreWithBrief } from './continuity-brief.mjs';
29
+ import { restore as restoreFromRuflo } from './ruflo-memory-hooks.mjs';
31
30
  import { turnRecordingStatus } from './turn-outcome-capture.mjs';
32
31
  import {
33
32
  read, json, exists, mkdir, write, runNode,
34
33
  } from './session-start-fsutil.mjs';
35
34
  import { maintainerIssueEntitlement, surfaceIssuePointer } from './session-start-issue-alert.mjs';
36
35
  import { surfaceSignals } from './session-start-signals.mjs';
36
+ import { failureLine, FAILURE_LINE_PREFIX } from './hook-failures.mjs';
37
37
  import { brainState, health, knowledgeCurrency, mcpReadiness, KNOWLEDGE_LINE_PREFIX } from './session-start-health.mjs';
38
38
  import { stableSpine, heartbeat, knowledgeAutoUpdate, footprintCheck } from './session-start-update-plane.mjs';
39
39
  import { FOOTPRINT_LINE_PREFIX } from './brain-confirmation.mjs';
@@ -92,7 +92,7 @@ export async function runSessionStart({
92
92
  stdout = process.stdout,
93
93
  stderr = process.stderr,
94
94
  platform = process.platform,
95
- restoreContinuity = restoreWithBrief,
95
+ restoreContinuity = restoreFromRuflo,
96
96
  runHeartbeat = true,
97
97
  sessionSource = 'startup',
98
98
  probeConsoleAlias = probeCodexConsoleAlias,
@@ -137,7 +137,8 @@ export async function runSessionStart({
137
137
  || s.startsWith('[RuvNet Brain — PROJECT CONTINUITY UNKNOWN]')
138
138
  || s.startsWith('[RuvNet Brain — PROJECT CONTINUITY RESTORED]')
139
139
  || s.startsWith('[RuvNet Brain — COME UP TO SPEED')
140
- || s.startsWith('[RuvNet Brain — MAINTAINER ONLY:');
140
+ || s.startsWith('[RuvNet Brain — MAINTAINER ONLY:')
141
+ || s.startsWith(FAILURE_LINE_PREFIX);
141
142
  };
142
143
  // An alarm's HEADER line always matches isSafeStatus on its own dedicated prefix (above); its
143
144
  // explanatory BODY lines do not, and without this they were silently dropped — a real gap this
@@ -170,6 +171,8 @@ export async function runSessionStart({
170
171
  const home = env.HOME || env.USERPROFILE || os.homedir();
171
172
  const stateDir = env.RUVNET_BRAIN_HOME || path.join(home, '.cache', 'ruvnet-brain');
172
173
  resetInjectionDedupe(stateDir, Date.now());
174
+ // ADR-106: advisory hooks fail open, so a failed one is reported here, once, never per fire.
175
+ try { const failed = failureLine(stateDir); if (failed) emit(failed); } catch { /* the summary must not cost the session */ }
173
176
  const hookDir = path.dirname(fileURLToPath(import.meta.url));
174
177
  const now = Date.now();
175
178
  const consoleInvoke = env.RUVNET_HOOK_HOST === 'codex' ? '$ruvnet-brain:rnbc' : '/rnbc';
@@ -190,11 +193,6 @@ export async function runSessionStart({
190
193
  }
191
194
  };
192
195
 
193
- // Report only. SessionStart never creates or bootstraps a store (ADR-105 rule 6); enrollment starts at the first
194
- // consented captured boundary, so a session that merely started leaves no store behind.
195
- let enrollment;
196
- try { enrollment = enrollmentPlan({ projectDir: cwd, env, deadlineAt: hookDeadlineAt }); } catch (error) { enrollment = { state: 'unavailable', reason: error.message }; }
197
- if (enrollment.state === 'pending' || enrollment.state === 'unavailable') emit(`[RuvNet Brain — TURN CAPTURE] Project memory enrollment ${enrollment.state}; restoration is not yet proven.`);
198
196
  const restoreStart = Date.now();
199
197
  const restoreDeadlineAt = Math.min(hookDeadlineAt, restoreStart + STAGE_BUDGETS_MS.restore);
200
198
  const restoreController = new AbortController();
@@ -49,7 +49,7 @@
49
49
  // SINGLE WRITER (DDD-0013 Context 2 invariant 3): this file is the ONLY writer of pending.jsonl.
50
50
  // scripts/signal-watch.mjs (the poller) only ever reads it.
51
51
  //
52
- // CONTRACT (matches every other node hook body in this dir — md-stamp.mjs, learn-flush.mjs):
52
+ // CONTRACT (matches every other node hook body in this dir — md-stamp.mjs):
53
53
  // dispatched via hook-shim.mjs (mode: advisory). ALWAYS exits 0; never throws to the caller; a
54
54
  // parse/IO failure degrades to silence, never a crash the harness has to survive (DDD invariant 4:
55
55
  // "observation never blocks and never manufactures a refusal").
@@ -0,0 +1,222 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * substitution-wall.mjs — "if you are rebuilding rUv's storage, stop and call his tool."
4
+ *
5
+ * THE RULING (owner, permanent): this plugin may only CALL rUv's memory tools (`ruflo memory
6
+ * store|retrieve|search|list|stats|backup|cleanup|compress`, `ruflo hooks ...`). It must never
7
+ * hand-roll its own storage, queue, journal or outbox for agent memory. Three incidents made the
8
+ * rule: 2026-07-13 (router), 2026-07-16 (router-optimizer), 2026-10-12 (a persistence layer in front
9
+ * of `ruflo memory store`: a 3.38 GB outbox, snapshots, queues, replay workers). The old CI gate
10
+ * (scripts/no-silent-substitution.mjs) said "none" on all of it: it only knew four rUv packages and
11
+ * counted any spawn of the `ruflo` binary as "rUv is used".
12
+ *
13
+ * WHY rUv's store is the answer, not a journal in front of it: `ruflo memory store` owns its own
14
+ * durability — it fails closed on a locked/damaged DB and reports the native bridge error
15
+ * (ruflo/v3/docs/releases/v3.32.34.md), and reports `persistWarning` instead of a blind success
16
+ * (ruflo/v3/@claude-flow/cli/__tests__/memory-store-persist-warning-2968.test.ts).
17
+ *
18
+ * ONE PURE FUNCTION, two callers: the decision-gate policy below (PreToolUse Write/Edit/MultiEdit/NotebookEdit/apply_patch)
19
+ * and the CI gate scripts/no-silent-substitution.mjs. No I/O, no clock, no network inside
20
+ * substitutionVerdict(); the CLI block at the bottom does the reading.
21
+ *
22
+ * ADR-106: refuse only on a FACT. The fact is a hand-built durable transport in code: a persistence
23
+ * primitive (fsync/append/O_APPEND) AND a transport construct (outbox/journal/queue/replay/spool/
24
+ * drain) AND a mechanism that only a transport needs (fsync durability, a lock file, or a
25
+ * self re-exec worker). Weaker evidence — state appended next to agent memory, or score ranking over
26
+ * it — WARNS through additionalContext and never blocks.
27
+ */
28
+ import fs from 'node:fs';
29
+ import path from 'node:path';
30
+ import { fileURLToPath } from 'node:url';
31
+
32
+ export const RUV_TOOL = 'ruflo memory store|retrieve|search (or `ruflo hooks session-end|post-task`)';
33
+ /** Repo-relative scope: our own executable code. Tests and fixtures are never in scope. */
34
+ export const SCOPE = /(?:^|\/)(?:plugin\/scripts|scripts)\/(?:[^/]+\/)*[^/]+\.(?:mjs|cjs|js|sh)$/;
35
+ /** The rulebook names every signal in order to police it; exempting it is not a loophole. */
36
+ const EXEMPT = new Set(['plugin/scripts/substitution-wall.mjs', 'scripts/no-silent-substitution.mjs']);
37
+ const TEST = /(?:^|\/)(?:tests?|__tests__|fixtures?)\/|\.test\.[cm]?js$|\.spec\.[cm]?js$/;
38
+
39
+ export function inScope(p) {
40
+ const rel = String(p || '').split(path.sep).join('/');
41
+ return SCOPE.test(rel) && !TEST.test(rel) && ![...EXEMPT].some((e) => rel === e || rel.endsWith(`/${e}`));
42
+ }
43
+
44
+ /** Code only: comments describe a design, they do not implement one (and our headers name incidents). */
45
+ export function codeOf(content, p = '') {
46
+ const s = String(content || '');
47
+ if (/\.sh$/.test(p)) return s.split('\n').filter((l) => !/^\s*#/.test(l)).join('\n');
48
+ return s.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/(^|[^:'"`\\])\/\/[^\n]*/g, '$1');
49
+ }
50
+
51
+ // pending/flush/retry joined after review M6: decision-record kept an fsynced `pending` dir with a `--flush` replay.
52
+ // Bare "pending"/"retry" are status words (14 false refusals measured over this tree); only their storage/replay forms count.
53
+ const TRANSPORT = /outbox|journal|spool|replay|\bdrain\w*|queue(?!d)|\bpending(?:Dir|Path|Records?|Writes?|Files?|Queue)\b|\bflush(?:Pending|Queue|Outbox|Journal)\w*|['"]--(?:flush|retry)['"]|\bretry(?:Queue|Pending|Worker|Dir)\w*/i;
54
+ /** The distinct transport words in code: an edit to a debt file that adds a NEW one is a new transport. */
55
+ export const transportTerms = (content, p = '') => new Set((codeOf(content, p).match(new RegExp(TRANSPORT.source, 'gi')) || []).map((t) => t.toLowerCase()));
56
+
57
+ const RULES = {
58
+ persist: /\b(?:appendFileSync|appendFile|fsyncSync|fdatasyncSync|createWriteStream)\b|\bO_APPEND\b|flags?\s*:\s*['"]a\+?['"]/,
59
+ // A write-then-rename (the usual atomic JSON-file store) persists as surely as an append. It counts toward a
60
+ // TRANSPORT only: as a bare warn signal it flagged a settings writer (runtime-preferences.mjs) falsely.
61
+ atomicWrite: /\bwriteFileSync\([^;]{0,200};[\s\S]{0,300}?\brenameSync\(/,
62
+ durable: /\b(?:fsyncSync|fdatasyncSync)\b/,
63
+ // `readback` is verification (store, then retrieve) and `queued` is a status word: neither is a transport.
64
+ transport: TRANSPORT,
65
+ lock: /['"]wx\+?['"]|\.lock\b/,
66
+ worker: /spawn\(\s*process\.execPath\s*,\s*\[\s*(?:fileURLToPath\(\s*import\.meta\.url\s*\)|__filename|SELF\b)|['"]--(?:replay|drain)[\w-]*['"]/,
67
+ memorySink: /\.swarm\b|memory\.db\b|['"]memory['"]\s*,\s*['"](?:store|retrieve)['"]/,
68
+ rank: /\b(?:cosine|tf-?idf|bm25)\b/i,
69
+ sqlite: /node:sqlite|\bDatabaseSync\b|better-sqlite3|sql\.js/,
70
+ storeWrite: /\b(?:INSERT|UPDATE|DELETE|REPLACE)\b[^;'"`]{0,40}\bmemory_entries\b/i,
71
+ };
72
+
73
+ /** The signals present in this code, as a sorted list of names (deterministic). */
74
+ export function signalsOf(content, p = '') {
75
+ const code = codeOf(content, p);
76
+ const hits = Object.entries(RULES).filter(([, re]) => re.test(code)).map(([k]) => k);
77
+ if (hits.includes('rank') && !/\.sort\(/.test(code)) hits.splice(hits.indexOf('rank'), 1);
78
+ return hits.sort();
79
+ }
80
+
81
+ const isTransport = (s) => (s.includes('persist') || s.includes('atomicWrite')) && s.includes('transport')
82
+ && (s.includes('durable') || s.includes('lock') || s.includes('worker'));
83
+
84
+ /**
85
+ * The verdict for one file's FULL proposed content.
86
+ * before — the file's content before this change (null for a new file). A file that already
87
+ * refused and gains no new signal is existing debt being edited (often removed): WARN.
88
+ * allow — repo-relative paths the OWNER exempted in config (never a header in the file itself).
89
+ * Returns { verdict: 'refuse'|'warn'|'pass', signals, tool, reason }.
90
+ */
91
+ export function substitutionVerdict({ path: p, content, before = null, allow = [] }) {
92
+ const rel = String(p || '').split(path.sep).join('/');
93
+ const base = { signals: [], tool: RUV_TOOL, reason: '' };
94
+ if (!inScope(rel)) return { verdict: 'pass', ...base };
95
+ const signals = signalsOf(content, rel);
96
+ const out = (verdict, reason) => ({ verdict, signals, tool: RUV_TOOL, reason });
97
+ if (isTransport(signals)) {
98
+ const allowed = allow.find((a) => rel === a || rel.endsWith(`/${a}`));
99
+ if (allowed) return out('warn', `owner allowlist entry ${allowed}`);
100
+ if (before !== null) {
101
+ const prior = signalsOf(before, rel);
102
+ const priorTerms = transportTerms(before, rel);
103
+ const added = [...transportTerms(content, rel)].filter((t) => !priorTerms.has(t));
104
+ if (isTransport(prior) && signals.every((s) => prior.includes(s)) && !added.length) return out('warn', 'existing debt; this change adds no new transport signal');
105
+ if (added.length) return out('refuse', `existing debt gains a new transport signal (${added.join(', ')})`);
106
+ }
107
+ return out('refuse', 'hand-built durable transport for state');
108
+ }
109
+ if (signals.includes('sqlite') && signals.includes('storeWrite')) return out('refuse', "writes rUv's memory_entries table directly instead of calling ruflo memory store");
110
+ if (signals.includes('persist') && signals.includes('memorySink')) return out('warn', 'appends state next to agent memory');
111
+ if (signals.includes('sqlite') && signals.includes('memorySink')) return out('warn', "opens rUv's store directly (couples to its private schema)");
112
+ if (signals.includes('rank') && signals.includes('memorySink')) return out('warn', 'ranks agent-memory content by score');
113
+ return out('pass', '');
114
+ }
115
+
116
+ /** The refusal/warning text. Short, actionable, and says how an exception is really declared. */
117
+ export function message(rel, v) {
118
+ if (v.verdict === 'pass') return '';
119
+ const head = v.verdict === 'refuse'
120
+ ? `⛔ BLOCKED — ${rel} rebuilds rUv's storage (${v.reason}; signals: ${v.signals.join(', ')}).`
121
+ : `⚠ substitution-wall — ${rel}: ${v.reason} (signals: ${v.signals.join(', ')}).`;
122
+ return [head,
123
+ `Call rUv's tool instead: ${v.tool}. Its store owns durability and fails closed; do not put an outbox, queue, journal, lock or replay worker in front of it.`,
124
+ ...(v.verdict === 'refuse' ? ['A `HAND-ROLLED: ... REAL TOOL: ...` header does NOT lift this. A real exception is an owner-approved entry in config/state-files.json `substitutionAllow`.'] : []),
125
+ ].join('\n');
126
+ }
127
+
128
+ /** Codex's apply_patch: per file, the content after its hunks (Add = the + lines; Update = hunks applied). */
129
+ function patchChanges(patch, read) {
130
+ const out = []; let cur = null; let hunk = null;
131
+ // A hunk with no context, or context that is not in the file, fails CLOSED (review N3): its lines are
132
+ // appended to the after-text, so a pure-addition outbox module is judged, never skipped.
133
+ const flush = () => {
134
+ if (cur && hunk && cur.after !== null && hunk.new.length) {
135
+ const norm = (t) => t.replace(/\r\n?/g, '\n').replace(/[ \t]+$/gm, '');
136
+ const from = norm(hunk.old.join('\n')); const to = hunk.new.join('\n'); const hay = norm(cur.after);
137
+ cur.after = from && hay.includes(from) ? hay.replace(from, () => to) : `${hay}\n${to}`;
138
+ }
139
+ hunk = null;
140
+ };
141
+ for (const line of String(patch || '').split(/\r?\n/)) {
142
+ const m = /^\*\*\* (Add|Update|Delete) File: (.+)$/.exec(line);
143
+ if (m || /^\*\*\* (?:End Patch|Move to:)/.test(line)) { flush(); if (m) { cur = m[1] === 'Delete' ? null : { file: m[2].trim(), add: m[1] === 'Add', lines: [] }; if (cur) { cur.before = cur.add ? null : read(cur.file); cur.after = cur.add ? '' : cur.before; out.push(cur); } } continue; }
144
+ if (!cur) continue;
145
+ if (cur.add) { if (line.startsWith('+')) cur.lines.push(line.slice(1)); continue; }
146
+ if (line.startsWith('@@')) { flush(); hunk = { old: [], new: [] }; continue; }
147
+ if (!hunk) hunk = { old: [], new: [] };
148
+ if (line.startsWith('-')) hunk.old.push(line.slice(1)); else if (line.startsWith('+')) hunk.new.push(line.slice(1));
149
+ else if (line.startsWith(' ') || line === '') { hunk.old.push(line.slice(1)); hunk.new.push(line.slice(1)); }
150
+ }
151
+ flush();
152
+ for (const c of out) if (c.add) c.after = c.lines.join('\n'); // once, not per line (review 3: 80k lines took 53 s)
153
+ return out.filter((c) => c.after !== null).map(({ file, before, after }) => ({ file, before, after }));
154
+ }
155
+
156
+ /**
157
+ * Every file a write tool call would produce, as { file, before, after }. Write/Edit/MultiEdit,
158
+ * NotebookEdit (new_source; .ipynb is out of SCOPE, judged for completeness) and Codex's apply_patch
159
+ * (the patch text arrives as tool_input.command / .input, or as the bare string).
160
+ */
161
+ export function proposedChanges(payload, read) {
162
+ const tool = String(payload?.tool_name || '');
163
+ const input = payload?.tool_input ?? {};
164
+ if (tool === 'apply_patch') return patchChanges(typeof input === 'string' ? input : input.command || input.input || input.patch, read);
165
+ // The REAL Codex path (review N2): codex-hook-adapter.mjs rewrites apply_patch into an Edit whose
166
+ // new_string is the whole patch and which has no old_string. Judge that patch, not the Edit.
167
+ if (tool === 'Edit' && !input.old_string && /^\s*\*\*\* Begin Patch/.test(String(input.new_string ?? ''))) return patchChanges(input.new_string, read);
168
+ if (tool === 'NotebookEdit') return typeof input.notebook_path === 'string' ? [{ file: input.notebook_path, before: read(input.notebook_path), after: String(input.new_source ?? '') }] : [];
169
+ const one = proposedContent(payload, read);
170
+ return one ? [{ file: input.file_path, ...one }] : [];
171
+ }
172
+
173
+ /** The file after an Edit/MultiEdit/Write, or null when this tool call is not a file write we judge. */
174
+ export function proposedContent(payload, read) {
175
+ const tool = String(payload?.tool_name || '');
176
+ const input = payload?.tool_input || {};
177
+ if (!['Write', 'Edit', 'MultiEdit'].includes(tool) || typeof input.file_path !== 'string') return null;
178
+ const before = read(input.file_path);
179
+ if (tool === 'Write') return { before, after: String(input.content ?? '') };
180
+ if (before === null) return null;
181
+ let after = before;
182
+ for (const e of (tool === 'Edit' ? [input] : (Array.isArray(input.edits) ? input.edits : []))) {
183
+ const from = String(e?.old_string ?? ''); const to = String(e?.new_string ?? '');
184
+ if (from) after = e.replace_all ? after.split(from).join(to) : after.replace(from, () => to);
185
+ }
186
+ return { before, after };
187
+ }
188
+
189
+ /** Repo identity: the wall REFUSES only inside this repo; in any other project it can only warn. */
190
+ function repoRootOf(file) {
191
+ let dir = path.dirname(file);
192
+ for (let i = 0; i < 12; i += 1) {
193
+ try { if (JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')).name === 'ruvnet-brain') return dir; } catch { /* keep walking */ }
194
+ const up = path.dirname(dir); if (up === dir) break; dir = up;
195
+ }
196
+ return null;
197
+ }
198
+
199
+ function isMain() {
200
+ try { return Boolean(process.argv[1]) && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); } catch { return false; }
201
+ }
202
+ if (isMain()) {
203
+ let code = 0;
204
+ try {
205
+ const payload = JSON.parse(fs.readFileSync(0, 'utf8') || '{}');
206
+ const cwd = typeof payload.cwd === 'string' ? payload.cwd : process.cwd();
207
+ const abs = (p) => (path.isAbsolute(p) ? p : path.resolve(cwd, p));
208
+ const changes = proposedChanges(payload, (p) => { try { return fs.readFileSync(abs(p), 'utf8'); } catch { return null; } });
209
+ for (const change of String(process.env.RUVNET_SUBSTITUTION_WALL || '').toLowerCase() !== 'off' ? changes : []) {
210
+ const file = abs(change.file);
211
+ const root = repoRootOf(file);
212
+ const rel = root ? path.relative(root, file) : String(payload.tool_input.file_path);
213
+ let allow = [];
214
+ if (root) { try { allow = JSON.parse(fs.readFileSync(path.join(root, 'config', 'state-files.json'), 'utf8')).substitutionAllow?.map((a) => a.path) || []; } catch { /* no config: no exemptions */ } }
215
+ let v = substitutionVerdict({ path: rel, content: change.after, before: change.before, allow });
216
+ if (v.verdict === 'refuse' && !root) v = { ...v, verdict: 'warn', reason: `${v.reason} (outside the ruvnet-brain repo: advisory)` };
217
+ if (v.verdict === 'refuse') { process.stderr.write(`${message(rel, v)}\n`); code = 2; break; }
218
+ else if (v.verdict === 'warn') process.stdout.write(message(rel, v));
219
+ }
220
+ } catch { code = 0; /* fail open on the wall's own errors */ }
221
+ process.exit(code);
222
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * turn-outcome-capture.mjs — record what each turn CONCLUDED, and make it recallable knowledge.
3
3
  *
4
- * Called from session-snapshot-hook.mjs's runSessionSnapshotHook, so it rides the capture boundary
4
+ * Called from session-snapshot-hook.mjs's hook entry, so it rides the capture boundary
5
5
  * both hosts already register (Claude: hooks.json; Codex: codex-hooks.json via codex-hook-adapter,
6
6
  * which sets RUVNET_HOOK_HOST=codex). Two jobs:
7
7
  *
@@ -27,10 +27,11 @@
27
27
  * SessionEnd and PreCompact recover a closing outcome when Stop payload text is absent. Tool IDs
28
28
  * correlate observable gate outcomes; opaque custom orchestration inputs remain unclassified.
29
29
  *
30
- * LATENCY: Stop runs synchronously in the host's turn, and one `ruflo memory store` costs ~0.7s
31
- * (measured). So the writes run in ONE detached worker (this file, `--run-steps`), store then
32
- * distill in order; the hook itself only reads, fingerprints and spawns. A project-local journal containing redacted content and canonical binding is fsynced BEFORE
33
- * spawning; fresh sessions replay stable keys without upsert and require exact readback receipts. Advisory always: nothing here throws to the caller.
30
+ * LATENCY AND FAILURE (owner ruling 2026-10-12): the turn is written DIRECTLY with one bounded
31
+ * `ruflo memory store` and proven by exact-key readback inside the hook's deadline (~0.5-1.3 s measured
32
+ * on a live-size store). There is no journal, outbox or replay worker. A refused or unverified write is
33
+ * recorded in the hook-failures ledger, which the next SessionStart summarizes. Only `memory distill run`
34
+ * (SessionEnd/PreCompact) is detached. Advisory always: nothing here throws to the caller.
34
35
  */
35
36
  import fs from 'node:fs';
36
37
  import os from 'node:os';
@@ -39,17 +40,26 @@ import crypto from 'node:crypto';
39
40
  import { spawn, spawnSync } from 'node:child_process';
40
41
  import { fileURLToPath } from 'node:url';
41
42
  import { nativeTurnLines } from './native-turn-transcript.mjs';
42
- import { resolveRuflo, rufloInvocation } from './ruflo-bin.mjs';
43
- import { rufloCwdFor, rufloScratchRoot } from './project-progression-store.mjs';
44
- import { redactText, userLevelAgentdbHooks } from './continuity-events.mjs';
43
+ import { resolveRuflo, rufloInvocation, rufloCwdFor, rufloScratchRoot } from './ruflo-bin.mjs';
44
+ import { redactText } from './continuity-events.mjs';
45
45
  import { resolveProjectStore, globalEnrollmentAllowed } from './project-store-resolver.mjs';
46
- import { digest, journalTurn, readJournal, pendingTurnFiles, acknowledgeJournal, appendReceipt, storeData } from './turn-transport-journal.mjs';
46
+ import { recordHookFailure } from './hook-failures.mjs';
47
+
48
+ const digest = (value) => crypto.createHash('sha256').update(String(value)).digest('hex');
49
+ function appendReceipt(file, row) {
50
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
51
+ try { if (fs.statSync(file).size > RECEIPTS_MAX_BYTES) fs.renameSync(file, `${file}.1`); } catch { /* absent */ } // bounded like hook-failures
52
+ fs.appendFileSync(file, `${JSON.stringify(row)}\n`, { mode: 0o600 });
53
+ }
54
+ const flagValue = (args, flag) => { const i = args.indexOf(flag); return i >= 0 ? args[i + 1] : undefined; };
47
55
  import { contentPathExcludes, pathIsExcluded, privateTurn, maskExcludedPaths, captureFailureReason, firstTurnCaptureNotice } from './turn-capture-privacy.mjs';
48
56
 
49
57
  export const TURN_NAMESPACE = 'turns';
50
58
  export const MIN_OUTCOME_CHARS = 200;
51
59
  const TRANSCRIPT_TAIL_BYTES = 2 * 1024 * 1024;
52
60
  const STEP_TIMEOUT_MS = 60_000;
61
+ export const RECEIPTS_MAX_BYTES = 256 * 1024;
62
+ export const TURN_STATUS_FRESH_MS /* ✓ only when the newest receipt is verified and younger */ = 24 * 3_600_000;
53
63
  const RUFLO_ENV = { RUFLO_DAEMON_AUTOSTART: '0' };
54
64
 
55
65
  function textOf(content) {
@@ -222,7 +232,7 @@ const projectName = (projectDir) => redactText(path.basename(path.resolve(projec
222
232
 
223
233
  /** Detached worker launch: the hook returns immediately; the worker runs the steps in order. */
224
234
  export function launchDetached(steps, { receipts, brainHome, projectDir, env = process.env }) {
225
- const child = spawn(process.execPath, [fileURLToPath(import.meta.url), '--run-steps', JSON.stringify({ steps: steps.map((step) => step.kind === 'store' && step.journalFile ? { kind: 'journal', journalFile: step.journalFile, db: step.args[step.args.indexOf('--path') + 1] } : step), receipts })], {
235
+ const child = spawn(process.execPath, [fileURLToPath(import.meta.url), '--run-steps', JSON.stringify({ steps, receipts })], {
226
236
  cwd: os.tmpdir(), detached: true, stdio: 'ignore', windowsHide: true, env: { ...env, ...RUFLO_ENV, ...(brainHome ? { RUVNET_BRAIN_HOME: brainHome } : {}), ...(projectDir ? { RUVNET_TURN_PROJECT_DIR: projectDir } : {}) },
227
237
  });
228
238
  child.unref();
@@ -269,14 +279,18 @@ export function captureTurnOutcome({
269
279
  readTranscript = readSettledTranscript,
270
280
  settleMs = 2000,
271
281
  now = () => new Date(),
282
+ deadlineAt,
283
+ run,
284
+ write = runSteps,
272
285
  } = {}) {
273
286
  const report = { event, host, queued: false, recorded: false, distill: { queued: false } };
274
287
  if (String(env.RUVNET_TURN_CAPTURE || '').toLowerCase() === 'off') {
275
288
  return { ...report, skipped: 'RUVNET_TURN_CAPTURE=off', distill: { queued: false, skipped: 'RUVNET_TURN_CAPTURE=off' } };
276
289
  }
277
- if (!ruflo) return { ...report, skipped: 'ruflo not found', distill: { queued: false, skipped: 'ruflo not found' } };
290
+ // Review M1: a capture that cannot happen is LOUD (a ledger row with a fixed reason, never turn text).
291
+ const loud = (reason, store) => { recordHookFailure({ hook: 'turn-capture', event, error: reason.slice(0, 160), ...(store ? { store: digest(store).slice(0, 16) } : {}) }, { brainHome }); return reason; };
278
292
  let target;
279
- try { target = resolveTurnDb({ projectDir, brainHome }); } catch (error) { return { ...report, skipped: `store resolution failed: ${redactText(error.message)}` }; }
293
+ try { target = resolveTurnDb({ projectDir, brainHome, ...(deadlineAt ? { deadlineAt } : {}) }); } catch (error) { return { ...report, skipped: loud(`store resolution failed: ${redactText(error.message)}`) }; }
280
294
  const { db, scope, projectRoot } = target;
281
295
  Object.assign(report, { db, scope });
282
296
  if (target.skipped) return { ...report, skipped: target.skipped, distill: { queued: false, skipped: target.skipped } };
@@ -287,17 +301,10 @@ export function captureTurnOutcome({
287
301
  const project = projectName(projectRoot);
288
302
  const receipts = path.join(brainHome, 'turn-capture', 'receipts.jsonl');
289
303
 
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)) {
304
+ // ONE WRITER PER TURN (ADR-105 §7): the product is the only writer of project turn history. The
305
+ // former deferral to a user-level ~/.claude hook (ADR-100 §3) could never fire — its probe never
306
+ // reported a writer — and cost a git call on every Stop, so it was removed (ADR-106).
307
+ if (['Stop', 'SessionEnd', 'PreCompact'].includes(event)) {
301
308
  const sessionKey = String(payload.session_id || payload.transcript_path || '');
302
309
  const message = typeof payload.last_assistant_message === 'string' ? payload.last_assistant_message.trim() : '';
303
310
  let turn = { finalText: '', files: [], actions: [] };
@@ -320,16 +327,9 @@ export function captureTurnOutcome({
320
327
  const at = now();
321
328
  const key = `turn-${project}-${digest(`${identity}:${fingerprint}`).slice(0, 40)}`;
322
329
  dedupe = { stateFile, identity, fingerprint, key };
323
- let value = buildTurnRecord({ turn, project, host, session: payload.session_id, at });
324
- // Retry uses the original durable content (including its timestamp), never a new value.
325
- const pendingFile = path.join(path.dirname(db), 'turn-outbox', `${digest(key)}.json`);
326
- if (fs.existsSync(pendingFile)) {
327
- try { const pending = readJournal(pendingFile, db); value = pending.value; }
328
- catch (error) { return { ...report, skipped: `durable turn queue invalid: ${redactText(error.message)}` }; }
329
- }
330
- steps.push({ ...binding, kind: 'store', ruflo, args: ['memory', 'store', '-k', key, '--value', value, '-n', TURN_NAMESPACE,
330
+ const value = buildTurnRecord({ turn, project, host, session: payload.session_id, at });
331
+ steps.push({ ...binding, kind: 'store', ruflo, args: ['memory', 'store', '--key', key, '--value', value, '--namespace', TURN_NAMESPACE,
331
332
  '--path', db, '--no-upsert', '--require-native', '--append-only', '--tags', `project=${project},host=${host}`, '--provenance', 'agent_output'] });
332
- // This synchronous boundary proves only queuing; the worker's exact receipt proves recording.
333
333
  Object.assign(report, { queued: true, key, value });
334
334
  }
335
335
  }
@@ -343,20 +343,26 @@ export function captureTurnOutcome({
343
343
  }
344
344
  }
345
345
  if (!steps.length) return report;
346
+ if (!ruflo) return { ...report, queued: false, skipped: loud('ruflo not found', db), distill: { queued: false, skipped: 'ruflo not found' } };
346
347
 
347
348
  try {
348
349
  // Only explicit persisted consent permits creating a project store directory.
349
350
  if (target.optedIn) fs.mkdirSync(path.dirname(db), { recursive: true, mode: 0o700 });
350
- if (report.queued) {
351
- const step = steps.find((item) => item.kind === 'store');
352
- step.journalFile = journalTurn(step, db, report.key, { onDurability: (evidence) => { report.durability = evidence; } });
353
- fs.appendFileSync(path.join(path.dirname(db), 'agentdb-turns.jsonl'),
354
- `${JSON.stringify({ ts: Date.now(), key: report.key, hash: crypto.createHash('sha256').update(report.value).digest('hex'), len: report.value.length })}\n`, { mode: 0o600 });
351
+ // The turn is written NOW, bounded by the hook's deadline, and proven by exact-key readback (owner
352
+ // ruling 2026-10-12: no outbox, no journal, no replay). A refusal is recorded by runSteps in the
353
+ // hook-failures ledger, so SessionStart reports it. Only distillation runs detached.
354
+ const store = steps.filter((item) => item.kind === 'store');
355
+ if (store.length) {
356
+ const [row] = write({ steps: store, receipts, event }, { env, home, projectDir: binding.projectDir, brainHome,
357
+ ...(deadlineAt ? { deadlineMs: deadlineAt } : {}), ...(run ? { run } : {}) });
358
+ Object.assign(report, { recorded: row?.verified === true, receipt: row });
359
+ if (row?.verified && dedupe) markTurnQueued(dedupe.stateFile, dedupe.identity, dedupe.fingerprint, dedupe.key);
355
360
  }
356
- report.launch = launch(steps, { receipts, brainHome, projectDir: binding.projectDir, env });
357
- if (dedupe) markTurnQueued(dedupe.stateFile, dedupe.identity, dedupe.fingerprint, dedupe.key);
361
+ const detached = steps.filter((item) => item.kind !== 'store');
362
+ if (detached.length) report.launch = launch(detached, { receipts, brainHome, projectDir: binding.projectDir, env });
358
363
  } catch (error) {
359
- return { ...report, queued: false, recorded: false, distill: { queued: false, skipped: `launch failed: ${redactText(error.message)}` }, skipped: `launch failed: ${redactText(error.message)}` };
364
+ const reason = loud(`launch failed: ${redactText(error.message)}`, db);
365
+ return { ...report, queued: false, recorded: false, distill: { queued: false, skipped: reason }, skipped: reason };
360
366
  }
361
367
  return report;
362
368
  }
@@ -369,39 +375,39 @@ function readBack({ ruflo, db, key, run, options }) {
369
375
  }
370
376
 
371
377
  /** The detached worker: bounded steps, safe error evidence, exact content readback. */
372
- export function runSteps({ steps = [], receipts } = {}, { run: suppliedRun = spawnSync, read = readBack,
378
+ export function runSteps({ steps = [], receipts, event = '' } = {}, { run: suppliedRun = spawnSync, read = readBack,
373
379
  env = process.env, deadlineMs, home = os.homedir(),
374
380
  projectDir = env.RUVNET_TURN_PROJECT_DIR || process.cwd(),
375
381
  brainHome = env.RUVNET_BRAIN_HOME || path.join(home, '.cache', 'ruvnet-brain') } = {}) {
376
382
  const run = (binary, args, options) => {
377
383
  if (deadlineMs === undefined) return suppliedRun(binary, args, options);
378
384
  const remaining = deadlineMs - Date.now();
379
- if (remaining <= 0) return { status: 1, error: new Error('turn replay deadline exhausted') };
385
+ if (remaining <= 0) return { status: 1, error: new Error('turn write deadline exhausted') };
380
386
  return suppliedRun(binary, args, { ...options, timeout: Math.min(options.timeout, remaining) });
381
387
  };
382
388
  const results = [];
383
389
  for (const queued of steps) {
384
390
  let status = 1; let error = null; let skipped = null; let verified = false; let db = ''; let key; let privacyPatterns = [];
385
- const kind = queued.kind === 'journal' ? 'store' : queued.kind;
391
+ const kind = queued.kind;
386
392
  try {
387
393
  if (String(env.RUVNET_TURN_CAPTURE || '').toLowerCase() === 'off') throw new Error('RUVNET_TURN_CAPTURE=off');
388
- if (kind === 'store' && queued.kind !== 'journal') { const data = storeData(queued); key = data.key; db = data.db; }
389
- if (queued.kind !== 'journal' && (!queued.projectRoot || !queued.projectDir || !queued.brainHome)) throw new Error('queued step has no canonical project binding');
390
- const target = resolveTurnDb({ projectDir, brainHome, gitTimeoutMs: 1000 });
394
+ if (kind === 'store') { key = flagValue(queued.args, '--key'); db = flagValue(queued.args, '--path'); }
395
+ if ((!queued.projectRoot || !queued.projectDir || !queued.brainHome)) throw new Error('queued step has no canonical project binding');
396
+ const bound = deadlineMs === undefined ? {} : { deadlineAt: deadlineMs }; // review M4: git obeys the hook deadline
397
+ const target = resolveTurnDb({ projectDir, brainHome, gitTimeoutMs: 1000, ...bound });
391
398
  if (target.skipped) throw new Error(target.skipped);
392
399
  privacyPatterns = target.contentPathExcludes;
393
400
  db = target.db;
394
401
  let args; let value; let binding;
395
402
  if (kind === 'store') {
396
- const data = queued.kind === 'journal' ? readJournal(queued.journalFile, db) : storeData(queued);
397
- if (queued.kind === 'journal' && queued.db !== db) throw new Error('foreign turn queue store rejected');
398
- if (queued.kind !== 'journal' && data.db !== db) throw new Error('foreign turn store rejected');
399
- if (data.legacyBrainHome && data.legacyBrainHome !== brainHome) throw new Error('queued consent settings differ from trusted caller');
400
- if (queued.kind !== 'journal' && queued.brainHome !== brainHome) throw new Error('queued consent settings differ from trusted caller');
401
- binding = queued.kind === 'journal' ? data.binding : queued;
403
+ const data = { key: flagValue(queued.args, '--key'), value: flagValue(queued.args, '--value'), db: flagValue(queued.args, '--path') };
404
+ if (typeof data.key !== 'string' || typeof data.value !== 'string') throw new Error('invalid turn store step');
405
+ if (data.db !== db) throw new Error('foreign turn store rejected');
406
+ if (queued.brainHome !== brainHome) throw new Error('turn consent settings differ from trusted caller');
407
+ binding = queued;
402
408
  key = data.key; value = maskExcludedPaths(redactText(data.value), target.contentPathExcludes, target.capturePath);
403
- if (value !== redactText(data.value)) throw new Error('content exclusions changed; immutable turn retained');
404
- args = ['memory', 'store', '-k', key, '--value', value, '-n', TURN_NAMESPACE, '--path', db, '--no-upsert', '--require-native', '--append-only', '--provenance', 'agent_output'];
409
+ if (value !== redactText(data.value)) throw new Error('content exclusions changed; turn not written');
410
+ args = ['memory', 'store', '--key', key, '--value', value, '--namespace', TURN_NAMESPACE, '--path', db, '--no-upsert', '--require-native', '--append-only', '--provenance', 'agent_output'];
405
411
  } else if (kind === 'distill') {
406
412
  if (queued.brainHome !== brainHome) throw new Error('queued consent settings differ from trusted caller');
407
413
  const recipe = queued.args;
@@ -421,14 +427,14 @@ export function runSteps({ steps = [], receipts } = {}, { run: suppliedRun = spa
421
427
  const rootStat = fs.statSync(target.projectRoot);
422
428
  if (binding.rootIdentity && binding.rootIdentity !== `${rootStat.dev}:${rootStat.ino}`) throw new Error('queued canonical project identity changed');
423
429
  if (binding.projectRoot !== target.projectRoot) throw new Error('queued canonical project/store identity changed');
424
- const origin = resolveTurnDb({ projectDir: binding.projectDir, brainHome, requestedStorePath: db, gitTimeoutMs: 1000 });
430
+ const origin = resolveTurnDb({ projectDir: binding.projectDir, brainHome, requestedStorePath: db, gitTimeoutMs: 1000, ...bound });
425
431
  if (origin.skipped) throw new Error(origin.skipped);
426
432
  if (origin.projectRoot !== target.projectRoot || origin.db !== db) throw new Error('queued canonical capture origin changed');
427
433
  const ruflo = resolveRuflo({ env, home });
428
- if (!ruflo) throw new Error('ruflo not found for durable replay');
434
+ if (!ruflo) throw new Error('ruflo not found');
429
435
  const invocation = rufloInvocation(ruflo, args);
430
436
  const remaining = deadlineMs === undefined ? STEP_TIMEOUT_MS : deadlineMs - Date.now();
431
- if (remaining <= 0) throw new Error('turn replay deadline exhausted');
437
+ if (remaining <= 0) throw new Error('turn write deadline exhausted');
432
438
  // Measured 2026-10-10 with global Ruflo 3.56.3: cwd=<project>/.swarm litters ruvector.db/.claude-flow there, and
433
439
  // CLAUDE_FLOW_MEMORY_PATH=<.swarm> makes Ruflo create a second agentdb-memory.db beside the canonical store.
434
440
  // The explicit --path already binds the store, so run from private scratch with neither.
@@ -436,10 +442,7 @@ export function runSteps({ steps = [], receipts } = {}, { run: suppliedRun = spa
436
442
  cwd: rufloCwdFor(db, { root: rufloScratchRoot({ ...env, RUVNET_BRAIN_HOME: brainHome }) }),
437
443
  maxBuffer: 1024 * 1024, env: { ...env, ...RUFLO_ENV } };
438
444
  delete options.env.CLAUDE_FLOW_MEMORY_PATH;
439
- const existing = kind === 'store' && queued.journalFile ? read({ ruflo, db, key, run, options }) : null;
440
- if (existing !== null && existing !== value) throw new Error('existing turn key content differs; no upsert permitted');
441
- verified = kind === 'store' && existing === value;
442
- const r = verified ? { status: 0 } : run(invocation.executable, invocation.args, options);
445
+ const r = run(invocation.executable, invocation.args, options);
443
446
  status = Number.isInteger(r.status) ? r.status : 1;
444
447
  if (kind === 'store' && !verified && status === 0) verified = read({ ruflo, db, key, run, options }) === value;
445
448
  if (verified) status = 0;
@@ -454,41 +457,21 @@ export function runSteps({ steps = [], receipts } = {}, { run: suppliedRun = spa
454
457
  const row = { at: new Date().toISOString(), kind, db: redactText(db), storeIdentity: digest(db), key, status, error,
455
458
  ...(kind === 'store' ? { verified } : {}), ...(skipped ? { skipped } : {}) };
456
459
  results.push(row);
457
- if (receipts) {
458
- try { appendReceipt(receipts, row); if (verified && queued.journalFile) acknowledgeJournal(queued.journalFile, db); }
459
- catch { /* failed receipt persistence retains the journal for exact-key retry */ }
460
+ if (receipts) { try { appendReceipt(receipts, row); } catch { /* the ledger row below still reports a failure */ } }
461
+ if (error || (kind === 'store' && !verified)) {
462
+ recordHookFailure({ hook: kind === 'store' ? 'turn-capture' : 'turn-distill', event, error: String(error || 'exact turn key/content readback failed').slice(0, 160), ...(db ? { store: digest(db).slice(0, 16) } : {}) }, { brainHome });
460
463
  }
461
464
  }
462
465
  return results;
463
466
  }
464
467
 
465
- /** Replay only bounded canonical project entries; consent suspends rather than deletes them. */
466
- export function replayTurnQueue({ projectDir = process.cwd(), env = process.env, home = os.homedir(),
467
- brainHome = env.RUVNET_BRAIN_HOME || path.join(home, '.cache', 'ruvnet-brain'), limit = 10,
468
- launch = launchDetached, synchronous = false, runner, deadlineMs } = {}) {
469
- if (String(env.RUVNET_TURN_CAPTURE || '').toLowerCase() === 'off') return { queued: false, skipped: 'RUVNET_TURN_CAPTURE=off' };
470
- try {
471
- const target = resolveTurnDb({ projectDir, brainHome });
472
- if (target.skipped) return { queued: false, skipped: target.skipped };
473
- const steps = pendingTurnFiles(target.db, limit).map((journalFile) => ({ kind: 'journal', journalFile, db: target.db }));
474
- if (!steps.length) return { queued: false, count: 0 };
475
- if (synchronous) {
476
- const results = runSteps({ steps, receipts: path.join(brainHome, 'turn-capture', 'receipts.jsonl') }, { env, home, projectDir, brainHome, ...(runner ? { run: runner } : {}), deadlineMs });
477
- const pending = pendingTurnFiles(target.db, 25).length;
478
- return { queued: false, count: steps.length, pending, failed: results.filter((row) => row.status !== 0).length, verified: results.filter((row) => row.verified === true).length, results };
479
- }
480
- return { queued: true, count: steps.length, pending: steps.length, launch: launch(steps, { receipts: path.join(brainHome, 'turn-capture', 'receipts.jsonl'), projectDir, brainHome, env }) };
481
- } catch (error) { return { queued: false, skipped: redactText(error.message) }; }
482
- }
483
-
484
468
  /** Bounded recent evidence, isolated by canonical db. A continuity success cannot mask turn failures. */
485
469
  export function turnRecordingStatus({ projectDir = process.cwd(), env = process.env, home = os.homedir(), now = Date.now(), noticeOnFirstUse = false } = {}) {
486
470
  const brainHome = env.RUVNET_BRAIN_HOME || path.join(home, '.cache', 'ruvnet-brain');
487
471
  let target;
488
472
  try { target = resolveTurnDb({ projectDir, brainHome }); } catch { return { state: 'unknown', line: 'turn recording unavailable: canonical store resolution failed' }; }
489
473
  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;
474
+ const notice = noticeOnFirstUse ? firstTurnCaptureNotice({ target, brainHome, policyFile: turnCapturePolicyFile(brainHome), env }) : null;
492
475
  const status = (value) => ({ ...value, ...(notice ? { notice } : {}) });
493
476
  let rows = [];
494
477
  try {
@@ -497,13 +480,16 @@ export function turnRecordingStatus({ projectDir = process.cwd(), env = process.
497
480
  }).filter((r) => r.kind === 'store' && (r.storeIdentity === crypto.createHash('sha256').update(target.db).digest('hex') || r.db === target.db) && now - Date.parse(r.at) < 7 * 86_400_000).slice(-20);
498
481
  } catch { /* first run */ }
499
482
  rows = [...new Map(rows.map((row) => [row.key || row.at, row])).values()];
500
- let pending = 0;
501
- try { pending = pendingTurnFiles(target.db, 25).length; } catch { return status({ state: 'warn', line: 'turn recording durable queue unsafe or unreadable' }); }
502
483
  const historical = rows.filter((r) => r.status === 0 && r.verified === undefined);
503
484
  const failed = rows.filter((r) => r.status !== 0 || r.verified === false);
504
485
  if (failed.length) return status({ state: 'warn', line: `turn recording failing ${failed.length}/${rows.length} — ${redactText(failed.at(-1).error || 'write has no exact readback evidence').slice(0, 300)}` });
505
486
  if (historical.length) return status({ state: 'unknown', line: `turn recording unverified historical (${historical.length} receipts without exact readback evidence)` });
506
- if (pending) return status({ state: 'unknown', line: `turn recording pending durable replay (${pending} entries); not yet proven` });
487
+ // Review M1: ✓ needs the NEWEST receipt verified, < TURN_STATUS_FRESH_MS old, and no later turn-capture ledger row.
488
+ const newest = rows.at(-1); const id = digest(target.db).slice(0, 16); let laterFailure = null;
489
+ try { laterFailure = readTail(path.join(brainHome, 'hook-failures.jsonl'), 64 * 1024).flatMap((l) => { try { return [JSON.parse(l)]; } catch { return []; } })
490
+ .findLast((r) => r.hook === 'turn-capture' && (!r.store || r.store === id) && (!newest || Date.parse(r.at) > Date.parse(newest.at))); } catch { /* no ledger */ }
491
+ if (laterFailure) return status({ state: 'warn', line: `turn recording failing — ${redactText(String(laterFailure.error || 'capture skipped')).slice(0, 200)} (since ${laterFailure.at})` });
492
+ if (newest && now - Date.parse(newest.at) >= TURN_STATUS_FRESH_MS) return status({ state: 'unknown', line: `turn recording not proven recently — newest exact readback ${newest.at}` });
507
493
  return status(rows.length ? { state: 'ok', line: `turn recording ✓ (${rows.length}/${rows.length} exact readbacks)` }
508
494
  : { state: 'unknown', line: 'turn recording not yet proven — no exact readback receipt' });
509
495
  }