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
@@ -28,13 +28,10 @@
28
28
  * detail.status: 'detected-unconfirmed'`, written only to the project's own local store; the brief never
29
29
  * presents one as a standing rule. RUVNET_CONTINUITY_LESSON_DETECT=off turns that detector off.
30
30
  */
31
+ import { nativeTurnLines } from './native-turn-transcript.mjs';
31
32
  import crypto from 'node:crypto';
32
- import fs from 'node:fs';
33
- import os from 'node:os';
34
- import path from 'node:path';
35
33
  import { execFileSync } from 'node:child_process';
36
34
  import { redactProgression } from './project-progression-contract.mjs';
37
- import { resolveProjectStore } from './project-store-resolver.mjs';
38
35
 
39
36
  export const CONTINUITY_NAMESPACE = 'continuity-events';
40
37
  export const EVENT_SCHEMA = 'ruvnet-brain.continuity-event';
@@ -265,12 +262,12 @@ function exitOutcome(result) {
265
262
  }
266
263
 
267
264
  /**
268
- * Typed events from one Claude JSONL turn. Codex rollouts are not parsed (project-progression-sources
269
- * declares that format unknown); Codex gets decisions/lessons from `last_assistant_message` only.
265
+ * Typed events from the current native turn. Codex JSONL is normalized mechanically; opaque
266
+ * custom-tool orchestration inputs cannot manufacture a gate outcome or semantic decision.
270
267
  */
271
268
  export function collectTurnEvents({ lines = null, lastAssistantMessage = '', host, session, project, env = process.env, at = Date.now() }) {
272
269
  const events = [];
273
- const turn = lines ? currentTurnRecords(lines) : { userMessage: '', records: [] };
270
+ const turn = lines ? currentTurnRecords(nativeTurnLines(lines, host)) : { userMessage: '', records: [] };
274
271
  const uses = new Map();
275
272
  const assistantTexts = [];
276
273
  for (const rec of turn.records) {
@@ -306,9 +303,9 @@ export function collectTurnEvents({ lines = null, lastAssistantMessage = '', hos
306
303
  for (const text of assistantTexts) {
307
304
  for (const line of text.split('\n')) {
308
305
  if (DECISION_LINE.test(line)) {
309
- events.push(makeEvent({ kind: 'decision', at, host, session, project, source: 'assistant-detected', authoritative: false, summary: line.replace(/\*\*/g, '') }));
306
+ events.push(makeEvent({ kind: 'decision', at, host, session, project, source: 'assistant-detected', authoritative: false, detail: { status: 'detected-unconfirmed' }, summary: line.replace(/\*\*/g, '') }));
310
307
  } else if (LESSON_LINE.test(line)) {
311
- events.push(makeEvent({ kind: 'lesson', at, host, session, project, source: 'assistant-detected', authoritative: false, summary: line.replace(/\*\*/g, '') }));
308
+ events.push(makeEvent({ kind: 'lesson', at, host, session, project, source: 'assistant-detected', authoritative: false, detail: { status: 'detected-unconfirmed' }, summary: line.replace(/\*\*/g, '') }));
312
309
  }
313
310
  }
314
311
  }
@@ -325,79 +322,3 @@ export function collectTurnEvents({ lines = null, lastAssistantMessage = '', hos
325
322
  return events.filter((e) => (seen.has(`${e.kind}:${e.id}`) ? false : seen.add(`${e.kind}:${e.id}`)));
326
323
  }
327
324
 
328
- /**
329
- * The owner's own user-level AgentDB hooks (~/.claude/settings.json). Read, never modified. Used to
330
- * DEFER: where one of them already records a thing, the product does not record it a second time.
331
- */
332
- export function userLevelAgentdbHooks({ home = os.homedir(), event, projectDir, env = process.env } = {}) {
333
- const found = { turnCapture: false, autocapture: false, ensure: false, ownership: 'unknown', collisionCandidate: false,
334
- settings: path.join(home, '.claude', 'settings.json') };
335
- if (!['Stop', 'PreCompact', 'SessionEnd', 'SessionStart'].includes(event) || !projectDir
336
- || String(env.RUVNET_HOOK_HOST || 'claude') !== 'claude') return found;
337
- let doc;
338
- try { doc = JSON.parse(fs.readFileSync(found.settings, 'utf8')); } catch { return found; }
339
- if (!doc || typeof doc !== 'object' || Array.isArray(doc) || doc.disableAllHooks === true || doc.allowManagedHooksOnly === true) return found;
340
- let target, executable;
341
- try {
342
- for (const file of [path.join(projectDir, '.claude', 'settings.json'), path.join(projectDir, '.claude', 'settings.local.json')]) {
343
- let local;
344
- try { local = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (error) { if (error.code === 'ENOENT') continue; throw error; }
345
- if (!local || typeof local !== 'object' || Array.isArray(local) || local.disableAllHooks === true || local.allowManagedHooksOnly === true) return found;
346
- }
347
- const deadlineAt = Number(env.RUVNET_SESSION_START_DEADLINE_AT) || Infinity;
348
- target = resolveProjectStore({ projectDir, gitTimeoutMs: 500, deadlineAt }).canonicalAgentDbPath;
349
- // Bind the direct command to the declared entry of the user's one managed GLOBAL Ruflo.
350
- // Arbitrary Node handlers, filenames and source comments do not prove their actual target.
351
- if (!process.getuid) return found; // Native Windows ACL ownership is not attested by POSIX uid metadata.
352
- const root = path.join(home, '.npm-global', 'lib', 'node_modules', 'ruflo');
353
- const manifest = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
354
- const declared = typeof manifest.bin === 'string' ? manifest.bin : manifest.bin?.ruflo;
355
- if (manifest.name !== 'ruflo' || typeof declared !== 'string' || path.isAbsolute(declared) || declared.split(/[\\/]/).includes('..')) return found;
356
- executable = fs.realpathSync.native(path.join(home, '.npm-global', 'bin', 'ruflo'));
357
- const entry = fs.realpathSync.native(path.join(root, declared));
358
- const relative = path.relative(fs.realpathSync.native(root), entry);
359
- const stat = fs.statSync(entry);
360
- if (executable !== entry || relative.startsWith('..') || path.isAbsolute(relative) || !stat.isFile() || stat.uid !== process.getuid()) return found;
361
- fs.accessSync(executable, fs.constants.X_OK);
362
- } catch { return found; }
363
- const groups = doc?.hooks?.[event];
364
- for (const group of Array.isArray(groups) ? groups : []) {
365
- if (group?.enabled === false || group?.disabled === true || (group?.matcher && group.matcher !== '*')) continue;
366
- for (const hook of Array.isArray(group?.hooks) ? group.hooks : []) {
367
- if (hook?.type !== 'command' || hook.enabled === false || hook.disabled === true || typeof hook.command !== 'string') continue;
368
- const command = hook.command.trim();
369
- if (/[$`;&|<>\r\n]/.test(command)) continue;
370
- const tokens = []; const words = /"([^"\\]*)"|'([^']*)'|([^\s"'\\]+)/g;
371
- let match, previous = 0, valid = true;
372
- while ((match = words.exec(command))) {
373
- if (command.slice(previous, match.index).trim()) { valid = false; break; }
374
- tokens.push(match[1] ?? match[2] ?? match[3]); previous = words.lastIndex;
375
- }
376
- if (!valid || command.slice(previous).trim() || tokens[1] !== 'memory') continue;
377
- try { if (!path.isAbsolute(tokens[0]) || fs.realpathSync.native(tokens[0]) !== executable) continue; } catch { continue; }
378
- const options = tokens[2] === 'store' ? ['--path', '-p', '--namespace', '-n', '--key', '-k', '--value']
379
- : tokens[2] === 'retrieve' ? ['--path', '-p', '--namespace', '-n', '--key', '-k']
380
- : tokens[2] === 'init' ? ['--path', '-p'] : [];
381
- for (let i = 3; i < tokens.length; i += 2) {
382
- if (!options.includes(tokens[i]) || !tokens[i + 1] || tokens[i + 1].startsWith('-')) { valid = false; break; }
383
- }
384
- if (!valid) continue;
385
- const flag = names => {
386
- const positions = tokens.flatMap((token, i) => names.includes(token) ? [i] : []);
387
- return positions.length === 1 ? tokens[positions[0] + 1] : undefined;
388
- };
389
- const db = flag(['--path', '-p']);
390
- if (!db || !path.isAbsolute(db) || db.split(/[\\/]/).includes('..') || path.resolve(db) !== target) continue;
391
- const namespace = flag(['--namespace', '-n']);
392
- const captureRegistration = tokens[2] === 'store' && flag(['--key', '-k']) && flag(['--value'])
393
- && ((event === 'Stop' && namespace === 'turns') || (['PreCompact', 'SessionEnd'].includes(event) && namespace === 'sessions'));
394
- const ensureRegistration = event === 'SessionStart' && ['init', 'retrieve'].includes(tokens[2]);
395
- if (captureRegistration || ensureRegistration) found.collisionCandidate = true;
396
- }
397
- }
398
- if (found.collisionCandidate) {
399
- found.target = target; found.event = event;
400
- found.reason = 'canonical registration alone does not prove capture of the current turn/session';
401
- }
402
- return found;
403
- }
@@ -122,8 +122,8 @@ const registration = (id, matcher, hosts) => Object.freeze({ id, matcher, hosts:
122
122
  */
123
123
  export const CONTINUITY_EVENTS = Object.freeze({
124
124
  SessionStart: Object.freeze([
125
+ // learn-flush is no longer its own registration (ADR-106): session-start schedules it detached.
125
126
  registration('session-start', 'startup|resume|clear|compact|fork', ['claude', 'codex']),
126
- registration('learn-flush', 'startup|resume|clear|compact|fork', ['claude', 'codex']),
127
127
  ]),
128
128
  UserPromptSubmit: Object.freeze([
129
129
  registration('session-snapshot', '*', ['claude', 'codex']),
@@ -154,7 +154,7 @@ export const CONTINUITY_EVENTS = Object.freeze({
154
154
  // no change either (proven live: the real MCP tool_response's `content[0].text` carries the exact
155
155
  // "Searched N RuvNet repos" banner and query text the script already looks for).
156
156
  PostToolUse: Object.freeze([
157
- registration('learn-capture', '^(?:Bash|Write|Edit|MultiEdit|exec_command|apply_patch|functions(?:\\.|__)(?:exec_command|apply_patch))$', ['claude', 'codex']),
157
+ // learn-capture runs in-process inside session-snapshot's PostToolUse boundary (ADR-106).
158
158
  registration('session-snapshot', '*', ['claude', 'codex']),
159
159
  registration('grounding-stamp', '^(?:.*__)?search_ruvnet$', ['claude', 'codex']),
160
160
  ]),
@@ -186,7 +186,7 @@ export const CONTINUITY_EVENTS = Object.freeze({
186
186
  registration('session-snapshot', '*', ['claude', 'codex']),
187
187
  ]),
188
188
  SessionEnd: Object.freeze([
189
- registration('learn-flush', '*', ['claude', 'codex']),
189
+ // learn-flush is scheduled by session-snapshot's SessionEnd boundary (ADR-106).
190
190
  registration('session-snapshot', '*', ['claude', 'codex']),
191
191
  ]),
192
192
  });
@@ -12,6 +12,7 @@
12
12
  * RETAIN_COMMITTED_MS, ages quarantine/corrupt notices after QUARANTINE_REPORT_MS, and bounds committed
13
13
  * history at MAX_EVENT_RECORDS. Pending events are never dropped; prolonged outages report pressure.
14
14
  */
15
+ import { nativeTurnLines } from './native-turn-transcript.mjs';
15
16
  import fs from 'node:fs';
16
17
  import os from 'node:os';
17
18
  import path from 'node:path';
@@ -137,7 +138,9 @@ export class ContinuityJournal {
137
138
  scan() {
138
139
  this.requireBudget();
139
140
  let text = '';
140
- try { text = fs.readFileSync(this.path, 'utf8'); } catch { /* no outbox yet */ }
141
+ try { text = fs.readFileSync(this.path, 'utf8'); } catch (error) {
142
+ if (error.code !== 'ENOENT') throw new Error(`continuity outbox unreadable: ${error.code || 'I/O error'}`);
143
+ }
141
144
  const events = new Map();
142
145
  const committed = new Map();
143
146
  const failures = new Map();
@@ -367,23 +370,23 @@ export function stopNotice({ journal, status, session }) {
367
370
  }
368
371
  function defaultStore({ ruflo, db, key, value }) {
369
372
  const cwd = rufloRunDir(db);
373
+ const source = JSON.parse(value).source;
374
+ const provenance = ['assistant-detected', 'owner-correction-detected', 'agent-result'].includes(source) ? 'agent_output' : 'system_observation';
370
375
  try {
371
376
  const { executable, args } = rufloInvocation(ruflo, ['memory', 'store', '--key', key, '--value', value,
372
- '--namespace', CONTINUITY_NAMESPACE, '--no-upsert', '--provenance', 'system_observation', '--path', db]);
377
+ '--namespace', CONTINUITY_NAMESPACE, '--no-upsert', '--require-native', '--append-only', '--provenance', provenance, '--path', db]);
373
378
  const r = spawnSync(executable, args, { cwd, encoding: 'utf8', timeout: 60_000, windowsHide: true,
374
- env: { ...process.env, RUFLO_DAEMON_AUTOSTART: '0' } });
379
+ env: { ...process.env, RUFLO_DAEMON_AUTOSTART: '0', CLAUDE_FLOW_MEMORY_PATH: undefined } });
375
380
  return { status: Number.isInteger(r.status) ? r.status : 1, output: `${r.stderr || ''}\n${r.stdout || ''}${r.error ? `\n${r.error.message}` : ''}` };
376
381
  } finally { fs.rmSync(cwd, { recursive: true, force: true }); }
377
382
  }
378
383
 
379
384
  function defaultReadBack({ ruflo, db, key }) {
380
- const fast = withProgressionReader(db, (reader) => reader.readContent(CONTINUITY_NAMESPACE, key));
381
- if (fast.ok) return { key, content: fast.value, readPath: 'node:sqlite' };
382
385
  const cwd = rufloRunDir(db);
383
386
  try {
384
387
  const { executable, args } = rufloInvocation(ruflo, ['memory', 'retrieve', '--key', key, '--namespace', CONTINUITY_NAMESPACE, '--value-only', '--path', db]);
385
- const r = spawnSync(executable, args, { cwd, encoding: 'utf8', timeout: 60_000, windowsHide: true, env: { ...process.env, RUFLO_DAEMON_AUTOSTART: '0' } });
386
- return { key, content: r.status === 0 ? String(r.stdout || '') : null, readPath: `ruflo-cli (${fast.reason})` };
388
+ const r = spawnSync(executable, args, { cwd, encoding: 'utf8', timeout: 60_000, windowsHide: true, env: { ...process.env, RUFLO_DAEMON_AUTOSTART: '0', CLAUDE_FLOW_MEMORY_PATH: undefined } });
389
+ return { key, content: r.status === 0 ? String(r.stdout || '') : null, readPath: 'ruflo-cli' };
387
390
  } finally { fs.rmSync(cwd, { recursive: true, force: true }); }
388
391
  }
389
392
 
@@ -414,15 +417,18 @@ export function drain(journal, {
414
417
  if (now() >= deadline) break;
415
418
  const value = JSON.stringify(privateContinuityEvent(rec.event, consent.contentPathExcludes, consent.capturePath));
416
419
  if (value !== JSON.stringify(rec.event)) return { committed, failed, remaining: journal.pending().length, skipped: 'content exclusions changed; immutable continuity retained' };
417
- attempts += 1; const result = store({ ruflo, db: journal.db, key: rec.key, value });
418
- const back = readBack({ ruflo, db: journal.db, key: rec.key });
420
+ attempts += 1;
421
+ const prior = readBack({ ruflo, db: journal.db, key: rec.key });
422
+ const alreadyStored = typeof prior.content === 'string' && prior.content.trim() === value && (prior.key === undefined || prior.key === rec.key);
423
+ const result = alreadyStored ? { status: 0 } : store({ ruflo, db: journal.db, key: rec.key, value });
424
+ const back = alreadyStored ? prior : readBack({ ruflo, db: journal.db, key: rec.key });
419
425
  const content = typeof back.content === 'string' ? back.content.trim() : '';
420
- if (content === value && digestCanonical(rec.event) === rec.digest && (back.key === undefined || back.key === rec.key)) {
426
+ if (result.status === 0 && content === value && digestCanonical(rec.event) === rec.digest && (back.key === undefined || back.key === rec.key)) {
421
427
  journal.appendRecords([{ type: 'commit', key: rec.key, digest: rec.digest, committedAt: new Date(now()).toISOString(), readPath: back.readPath,
422
- alreadyStored: result.status !== 0 || content !== value, ...(attempts > 1 ? { attempts, lastError: last?.reason } : {}) }]);
428
+ alreadyStored, ...(attempts > 1 ? { attempts, lastError: last?.reason } : {}) }]);
423
429
  committed += 1;
424
430
  done = true;
425
- } else if (content) {
431
+ } else if (content && (content !== value || digestCanonical(rec.event) !== rec.digest || (back.key !== undefined && back.key !== rec.key))) {
426
432
  // A DIFFERENT row holds our key: never overwritten (--no-upsert), never retried, reported.
427
433
  journal.appendRecords([{ type: 'quarantine', key: rec.key, at: new Date(now()).toISOString(), reason: 'a different stored row holds this key' }]);
428
434
  failed += 1;
@@ -466,7 +472,7 @@ export function takeLock(journal, { now = Date.now } = {}) {
466
472
  export function launchDrain({ projectRoot, spawnFn = spawn, env = process.env } = {}) {
467
473
  try {
468
474
  const child = spawnFn(process.execPath, [fileURLToPath(import.meta.url), '--drain', projectRoot], {
469
- cwd: os.tmpdir(), detached: true, stdio: 'ignore', windowsHide: true, env: { ...env, RUFLO_DAEMON_AUTOSTART: '0' },
475
+ cwd: os.tmpdir(), detached: true, stdio: 'ignore', windowsHide: true, env: { ...env, RUFLO_DAEMON_AUTOSTART: '0', CLAUDE_FLOW_MEMORY_PATH: undefined },
470
476
  });
471
477
  child.unref?.();
472
478
  return true;
@@ -513,13 +519,13 @@ export function captureContinuityEvents({
513
519
  events.push(...collectCommits({ checkoutRoot: resolution.checkoutRoot, sinceMs, host, session, project }));
514
520
  events.push(...collectReleases({ checkoutRoot: resolution.checkoutRoot, sinceMs, host, session, project }));
515
521
  }
516
- if (event === 'Stop') {
522
+ if (['Stop', 'SessionEnd', 'PreCompact', 'SubagentStop'].includes(event)) {
517
523
  let lines = null;
518
- if (host === 'claude' && typeof payload.transcript_path === 'string' && payload.transcript_path) {
524
+ if (typeof payload.transcript_path === 'string' && payload.transcript_path) {
519
525
  try { lines = readTranscript(payload.transcript_path); } catch { /* unreadable transcript: git events still count */ }
520
526
  }
521
527
  const last = typeof payload.last_assistant_message === 'string' ? payload.last_assistant_message : ''; const policy = journal.captureConsent();
522
- if (!policy.skipped && !(lines && turnReferencesExcludedResource(claudeTurn(lines), policy.contentPathExcludes, projectDir))) events.push(...collectTurnEvents({ lines, lastAssistantMessage: last, host, session, project, env, at: now() }));
528
+ if (!policy.skipped && !(lines && turnReferencesExcludedResource(claudeTurn(nativeTurnLines(lines, host)), policy.contentPathExcludes, projectDir))) events.push(...collectTurnEvents({ lines, lastAssistantMessage: last, host, session, project, env, at: now() }));
523
529
  }
524
530
  try { report.recorded = journal.record(events).length; } catch (error) { return { ...report, skipped: `outbox write failed: ${error.message}` }; }
525
531
  try { if (journal.needsCompaction()) journal.compact(); } catch { /* bounded next time */ }
@@ -162,7 +162,7 @@ const POLICY = (id, file, interpreter = 'bash') => ({ id, file, interpreter });
162
162
  const REFUSAL_POLICIES = [
163
163
  POLICY('protect-state', 'protect-brain-state.sh'),
164
164
  POLICY('hijack-ruvnet', 'hijack-ruvnet.sh'),
165
- POLICY('ground-before-write', 'ground-before-write.sh'),
165
+ POLICY('ground-before-write', 'ground-before-write.sh'), POLICY('decision-recall', 'decision-recall-gate.mjs', 'node'), // ADR-106
166
166
  // adr-currency-gate fires on the EDIT, where the pre-push gate fires on the push. Same rule, same
167
167
  // machinery (it calls doc-currency.mjs, never a second copy of the logic) — moved to the earliest
168
168
  // moment it has enough information. On 2026-08-13 four ADRs went stale together and were caught
@@ -181,7 +181,7 @@ const SPEECH = { id: 'unprompted-speech', file: 'unprompted-runtime.mjs', interp
181
181
 
182
182
  /** Which policies apply to which PreToolUse sub-event, mirroring the matchers they replaced. */
183
183
  const REGISTRY = {
184
- 'write': ['protect-state', 'hijack-ruvnet', 'ground-before-write', 'adr-currency', 'duplicate-code'],
184
+ 'write': ['protect-state', 'hijack-ruvnet', 'ground-before-write', 'decision-recall', 'adr-currency', 'duplicate-code'],
185
185
  };
186
186
 
187
187
  export function policiesFor(event, registry = REGISTRY, all = REFUSAL_POLICIES) {
@@ -0,0 +1,141 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * decision-recall-gate.mjs — "read AgentDB before deciding" as a FACT check (ADR-105 §8, ADR-106).
4
+ *
5
+ * A POLICY OF decision-gate.mjs, NOT A HOOK: exit 0 allow, exit 2 + stderr refuse; composed by the
6
+ * one PreToolUse refuser (ADR-067) on both hosts, so it adds no registration.
7
+ *
8
+ * WHEN IT LOOKS: a write that CREATES an ADR file (Write to a path that does not exist yet, or an
9
+ * apply_patch `*** Add File:`), or an Edit/MultiEdit that changes an ADR's status line. Ordinary ADR
10
+ * edits (wording, review stamps) are never checked.
11
+ *
12
+ * THE FACT: a `decision-record.mjs --consult` receipt for this project younger than two hours. The
13
+ * receipt proves the consult RAN; it does not require history to have been available — an AgentDB
14
+ * outage must not wedge decisions, so an `unavailable` receipt allows and says history was unknown.
15
+ *
16
+ * AT MOST ONE REFUSAL PER PATH PER SESSION (the duplicate-gate shape): the retry is allowed and
17
+ * recorded. Only in an adopted project (canonical `.swarm/memory.db` exists). Opt-outs win
18
+ * (ADR-105 §7): RUVNET_DECISION_RECALL_GATE=off, RUVNET_AGENTDB_FIRST=off, the brain-off sentinel.
19
+ * Fails open on its own errors.
20
+ */
21
+ import fs from 'node:fs';
22
+ import os from 'node:os';
23
+ import path from 'node:path';
24
+ import crypto from 'node:crypto';
25
+ import { fileURLToPath } from 'node:url';
26
+ import { resolveProjectStore } from './project-store-resolver.mjs';
27
+ import { isAdrPath, freshConsultReceipt, brainHomeOf } from './decision-record.mjs';
28
+
29
+ const ALLOW = 0;
30
+ const REFUSE = 2;
31
+ const STATUS_LINE = /^\s*(?:status:\s*\S.*|\*\*Status\*\*\s*:.*)$/im;
32
+ /** The document's status line, ignoring fenced code (a YAML example is not the ADR's status). */
33
+ const STATUS_LINES = new RegExp(STATUS_LINE.source, 'gim');
34
+ /** Every status line of the document (frontmatter AND body), ignoring fenced code. */
35
+ export const statusOf = (text) => [...String(text || '').replace(/^(```|~~~)[^\n]*\n[\s\S]*?^\1[^\n]*$/gm, '').matchAll(STATUS_LINES)]
36
+ .map((m) => m[0].trim()).join('\n');
37
+
38
+ function readText(file) { try { return fs.readFileSync(file, 'utf8'); } catch { return null; } }
39
+
40
+ /**
41
+ * Which ADR decision, if any, this tool call makes. A status change is judged on the WHOLE document:
42
+ * the status of the file now versus the status after this edit is applied, so a whole-file Write over
43
+ * an existing ADR is seen (review finding 5) and a `status:` line inside a code fence is not (finding 6).
44
+ */
45
+ export function adrDecisionIn(payload, { read = readText } = {}) {
46
+ const tool = String(payload?.tool_name || '');
47
+ const input = payload?.tool_input || {};
48
+ const cwd = typeof payload?.cwd === 'string' ? payload.cwd : process.cwd();
49
+ const abs = (p) => (path.isAbsolute(p) ? p : path.resolve(cwd, p));
50
+ if (['Write', 'Edit', 'MultiEdit'].includes(tool) && isAdrPath(input.file_path)) {
51
+ const before = read(abs(input.file_path));
52
+ if (before === null) return tool === 'Write' ? { path: input.file_path, kind: 'new ADR' } : null;
53
+ let after = before;
54
+ if (tool === 'Write') after = String(input.content ?? '');
55
+ else {
56
+ const edits = tool === 'Edit' ? [input] : (Array.isArray(input.edits) ? input.edits : []);
57
+ for (const e of edits) {
58
+ const from = String(e.old_string ?? ''); const to = String(e.new_string ?? '');
59
+ if (!from) continue;
60
+ after = e.replace_all ? after.split(from).join(to) : after.replace(from, () => to);
61
+ }
62
+ }
63
+ return statusOf(after) !== statusOf(before) ? { path: input.file_path, kind: 'ADR status change' } : null;
64
+ }
65
+ if (tool === 'apply_patch') {
66
+ const text = typeof input === 'string' ? input : JSON.stringify(input);
67
+ const patch = typeof input === 'string' ? input : String(input.input ?? input.patch ?? input.command ?? text);
68
+ const added = [...patch.matchAll(/\*\*\* Add File: ([^\n\\"]+)/g)].map((m) => m[1].trim()).find(isAdrPath);
69
+ if (added) return { path: added, kind: 'new ADR' };
70
+ // An Update File hunk on an ADR: compare the status lines it removes with the ones it adds.
71
+ for (const section of patch.split(/^\*\*\* (?=Update File: )/m).slice(1)) {
72
+ const file = /^Update File: ([^\n]+)/.exec(section)?.[1]?.trim();
73
+ if (!isAdrPath(file)) continue;
74
+ const lines = section.split('\n');
75
+ const side = (sign) => lines.filter((l) => l.startsWith(sign) && !l.startsWith(sign.repeat(3))).map((l) => l.slice(1)).join('\n');
76
+ if (statusOf(side('+')) !== statusOf(side('-'))) return { path: file, kind: 'ADR status change' };
77
+ }
78
+ }
79
+ return null;
80
+ }
81
+
82
+ function brainOff(env) {
83
+ const dir = env.RUVNET_BRAIN_STATE_DIR || path.join(env.HOME || os.homedir(), '.config', 'ruvnet-brain');
84
+ try { fs.statSync(path.join(dir, 'brain-off')); return true; } catch (e) { return !(e && (e.code === 'ENOENT' || e.code === 'ENOTDIR')); }
85
+ }
86
+
87
+ const ackFile = (brainHome) => path.join(brainHome, 'decision-recall-acks.json');
88
+ function readAcks(file) { try { const o = JSON.parse(fs.readFileSync(file, 'utf8')); return o && typeof o === 'object' ? o : {}; } catch { return {}; } }
89
+
90
+ /** The verdict. Returns { code, stderr?, stdout? }. */
91
+ export function evaluate(payload, { env = process.env, now = Date.now() } = {}) {
92
+ if (['off'].includes(String(env.RUVNET_DECISION_RECALL_GATE || '').toLowerCase())
93
+ || String(env.RUVNET_AGENTDB_FIRST || '').toLowerCase() === 'off' || brainOff(env)) return { code: ALLOW };
94
+ const decision = adrDecisionIn(payload);
95
+ if (!decision) return { code: ALLOW };
96
+ let resolution;
97
+ try { resolution = resolveProjectStore({ projectDir: payload.cwd || process.cwd(), gitTimeoutMs: 400 }); } catch { return { code: ALLOW }; }
98
+ try { if (!fs.statSync(resolution.canonicalAgentDbPath).isFile()) return { code: ALLOW }; } catch { return { code: ALLOW }; }
99
+ const brainHome = brainHomeOf(env);
100
+ const receipt = freshConsultReceipt({ projectRoot: resolution.projectRoot, brainHome, now });
101
+ if (receipt) {
102
+ return /^ok-/.test(receipt.outcome) ? { code: ALLOW }
103
+ : { code: ALLOW, stdout: `AgentDB history was ${receipt.outcome} when this decision was consulted (${receipt.at}); state in the ${decision.kind} that prior decisions are UNKNOWN.` };
104
+ }
105
+ const session = String(payload.session_id || 'no-session');
106
+ const key = crypto.createHash('sha256').update(`${session}\u0000${resolution.projectRoot}\u0000${decision.path}`).digest('hex').slice(0, 32);
107
+ const file = ackFile(brainHome);
108
+ const acks = readAcks(file);
109
+ if (acks[key]) {
110
+ acks[key].retriedAt = new Date(now).toISOString();
111
+ try { fs.writeFileSync(file, JSON.stringify(acks)); } catch { /* the ledger never blocks a write */ }
112
+ return { code: ALLOW, stdout: `This ${decision.kind} was written without an AgentDB consult (retry after one refusal). Record it afterwards with decision-record.mjs --record.` };
113
+ }
114
+ // Keep the ledger bounded: drop acknowledgements older than a week.
115
+ for (const [k, v] of Object.entries(acks)) if (now - Date.parse(v?.at) > 7 * 86_400_000) delete acks[k];
116
+ acks[key] = { at: new Date(now).toISOString(), kind: decision.kind };
117
+ try { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, JSON.stringify(acks), { mode: 0o600 }); } catch { return { code: ALLOW }; }
118
+ const script = path.join(path.dirname(fileURLToPath(import.meta.url)), 'decision-record.mjs');
119
+ return { code: REFUSE, stderr: [
120
+ `⛔ BLOCKED (once) — ${decision.kind} ${decision.path}: run decision-record.mjs --consult "<topic>" first (AgentDB decisions unread).`,
121
+ 'Consult first (exact curated records, then ranked, one deadline; failure is reported, never empty):',
122
+ ` node ${JSON.stringify(script)} --consult "<the decision topic>"`,
123
+ 'Then write the ADR, and record the decision after it:',
124
+ ` node ${JSON.stringify(script)} --record --what "…" --why "…" --shape "…" --evidence adr:${decision.path}`,
125
+ 'This path is refused at most once per session; retrying is allowed and recorded. RUVNET_DECISION_RECALL_GATE=off disables it.',
126
+ ].join('\n') };
127
+ }
128
+
129
+ function isMain() {
130
+ try { return Boolean(process.argv[1]) && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); } catch { return false; }
131
+ }
132
+ if (isMain()) {
133
+ let verdict = { code: ALLOW };
134
+ try {
135
+ const raw = fs.readFileSync(0, 'utf8');
136
+ verdict = evaluate(JSON.parse(raw || '{}'));
137
+ } catch { verdict = { code: ALLOW }; }
138
+ if (verdict.stdout) process.stdout.write(verdict.stdout);
139
+ if (verdict.stderr) process.stderr.write(`${verdict.stderr}\n`);
140
+ process.exit(verdict.code);
141
+ }