ruvnet-brain 4.6.3 → 4.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -47,6 +47,9 @@ import {
47
47
  buildCapabilityInventoryReceipt,
48
48
  } from './capability-inventory-receipt.mjs';
49
49
  import { auditCurrentCapabilityEvidence } from './capability-claim-evidence.mjs';
50
+ import { decisionRecordDebt } from './decision-record.mjs';
51
+ import { deferralAtStop } from './deferral-evidence.mjs';
52
+ import { readSettledTranscript } from './turn-outcome-capture.mjs';
50
53
  import { continuationProjectIdentity, authorizedContinuationObjective, authorizedPromiseItems, assistantCommitmentOwned, setAssistantCommitmentState } from './continuation-objective.mjs';
51
54
  import {
52
55
  auditCompletionClaims, readClaudeTurn, extractCommitments, claimClosesPromise,
@@ -419,6 +422,47 @@ function promiseBookkeeping() {
419
422
  }
420
423
  promiseBookkeeping();
421
424
 
425
+ /**
426
+ * NO-DEFERRAL (ADR-106; ADR-043 "must force, not whisper"). A Claude turn that ends by asking
427
+ * permission for, or listing as future, work the owner already authorized (a standing instruction in
428
+ * the session's prompts, or an open promise of this session) is asked ONCE to continue, unless the
429
+ * answer names a genuine blocker class. Pure detector: deferral-evidence.mjs; measured on real
430
+ * transcripts by scripts/deferral-replay.mjs. Codex: the rollout's prompts are not parsed, so
431
+ * authorization cannot be proven there and the check is not enforced (nothing is claimed).
432
+ * Loop safety: stop_hook_active (below) means at most one force per natural stop; a per-prompt counter
433
+ * caps forces at MAX_DEFERRAL_FORCES; a deferral still present on the continued stop is recorded once
434
+ * in the hook-failures ledger ("deferral not resolved") and let through. Fails open.
435
+ */
436
+ const MAX_DEFERRAL_FORCES = 3;
437
+ const deferralState = (() => {
438
+ if (HOST !== 'claude' || String(process.env.RUVNET_NO_DEFERRAL || '').toLowerCase() === 'off' || !hookInput.session_id) return null;
439
+ try {
440
+ const openPromises = authorizedPromiseItems(led.items, hookInput, projectIdentity).length;
441
+ const v = deferralAtStop({ message: String(hookInput.last_assistant_message || ''), transcriptPath: hookInput.transcript_path,
442
+ openPromises, read: readSettledTranscript });
443
+ if (!v) return null;
444
+ const chain = crypto.createHash('sha256').update(JSON.stringify([projectIdentity.projectId, hookInput.session_id, v.prompt])).digest('hex').slice(0, 24);
445
+ const file = `${LEDGER}.deferral-forces.json`;
446
+ let counts = {}; try { counts = JSON.parse(fs.readFileSync(file, 'utf8')) || {}; } catch { /* first */ }
447
+ return { v, chain, file, counts, forced: Number(counts[chain]?.n) || 0 };
448
+ } catch { return null; }
449
+ })();
450
+ function recordDeferralAdvisory(note) {
451
+ try {
452
+ const brainHome = process.env.RUVNET_BRAIN_HOME || path.join(HOME, '.cache', 'ruvnet-brain');
453
+ fs.mkdirSync(brainHome, { recursive: true });
454
+ fs.appendFileSync(path.join(brainHome, 'hook-failures.jsonl'), `${JSON.stringify({ at: new Date(nowMs).toISOString(), hook: 'continuation-gate', event: 'Stop', note })}\n`, { mode: 0o600 });
455
+ console.error(JSON.stringify({ kind: 'continuation-advisory', authority: false, items: [{ text: note, at: new Date(nowMs).toISOString() }] }));
456
+ } catch { /* advisory */ }
457
+ }
458
+ if (deferralState?.v.verdict === 'DEFERRAL' && (hookInput.stop_hook_active || deferralState.forced >= MAX_DEFERRAL_FORCES)) {
459
+ const st = deferralState;
460
+ if (st.forced > 0 && !st.counts[st.chain]?.reported) {
461
+ recordDeferralAdvisory(`deferral not resolved after ${st.forced} force(s): "${st.v.deferral.phrase}"`);
462
+ try { st.counts[st.chain] = { ...st.counts[st.chain], reported: true }; fs.writeFileSync(st.file, JSON.stringify(st.counts)); } catch { /* advisory */ }
463
+ }
464
+ }
465
+
422
466
  /**
423
467
  * LOOP-SAFETY 2 (moved below the bookkeeping above, unchanged in effect): no request is ever
424
468
  * emitted on a stop that is already a continuation.
@@ -665,11 +709,25 @@ const completionWork = ['FAIL', 'UNKNOWN'].includes(completion.verdict) ? [{
665
709
  text: `You claimed "${completion.claims[0].text.slice(0, 160)}" is done; ${completion.problems.join('; ')}. Run the real consumer path, or restate it as UNVERIFIED.`,
666
710
  at: new Date(nowMs).toISOString(), derived: true, kind: 'completion-claim-integrity',
667
711
  }] : [];
712
+ // DECISION RECORDS (ADR-105 §8, ADR-106): a turn that CREATED an ADR in an adopted project must also
713
+ // append its AgentDB decision record. Claude transcript only (Write result `create`); on Codex the
714
+ // rollout is not parsed, so this is not enforced there and nothing is claimed. Fails open.
715
+ const decisionWork = (() => {
716
+ if (HOST !== 'claude') return [];
717
+ try {
718
+ const debt = decisionRecordDebt({ transcriptPath: hookInput.transcript_path, cwd: hookInput.cwd });
719
+ return debt ? [{ text: `you created ${debt.created.join(', ')} this turn but recorded no AgentDB decision record. Run: ${debt.command} — or say why this ADR records no decision.`,
720
+ at: new Date(nowMs).toISOString(), derived: true, kind: 'decision-record-integrity' }] : [];
721
+ } catch { return []; }
722
+ })();
668
723
  const promiseWork = HOST === 'claude'
669
724
  ? authorizedPromiseItems(led.items, hookInput, projectIdentity)
670
725
  .map((i) => ({ text: `you said you would: ${i.text}`, at: i.at, kind: PROMISE_KIND }))
671
726
  : [];
672
- const open = [...capabilityClaimWork(), ...completionWork,
727
+ const deferralWork = deferralState?.v.verdict === 'DEFERRAL' && deferralState.forced < MAX_DEFERRAL_FORCES ? [{
728
+ text: `your answer ends by handing authorized work back ("${deferralState.v.deferral.phrase}") although ${deferralState.v.authorization} authorizes it and no blocker is named. Do that work now`,
729
+ at: new Date(nowMs).toISOString(), derived: true, kind: 'no-deferral' }] : [];
730
+ const open = [...capabilityClaimWork(), ...completionWork, ...decisionWork, ...deferralWork,
673
731
  ...(objective ? [{ text: objective.text, at: objective.at }] : []), ...promiseWork];
674
732
  if (!open.length) process.exit(EXIT_ALLOW); // nothing outstanding: silence is correct
675
733
 
@@ -742,6 +800,11 @@ function claimCooldown(now, windowMs) {
742
800
  catch { return false; } // lost the race / cannot persist → fail closed
743
801
  }
744
802
  if (!claimCooldown(nowMs, COOLDOWN_MS)) process.exit(EXIT_ALLOW);
803
+ if (deferralState && forceable.some((i) => i.kind === 'no-deferral')) {
804
+ try { for (const [k, v] of Object.entries(deferralState.counts)) if (nowMs - Date.parse(v?.at) > 7 * 86_400_000) delete deferralState.counts[k];
805
+ deferralState.counts[deferralState.chain] = { n: deferralState.forced + 1, at: new Date(nowMs).toISOString() };
806
+ fs.writeFileSync(deferralState.file, JSON.stringify(deferralState.counts)); } catch { /* the cooldown lock still bounds it */ }
807
+ }
745
808
 
746
809
  /**
747
810
  * DELIVERY. `additionalContext` in a Stop envelope forces the continuation (same protection as
@@ -760,6 +823,8 @@ const committed = forceable.filter((i) => !i.derived);
760
823
  const observed = forceable.filter((i) => i.derived);
761
824
  const capabilityClaims = forceable.filter((i) => i.kind === 'capability-claim-integrity');
762
825
  const completionClaims = forceable.filter((i) => i.kind === 'completion-claim-integrity');
826
+ const decisionRecords = forceable.filter((i) => i.kind === 'decision-record-integrity');
827
+ const deferrals = forceable.filter((i) => i.kind === 'no-deferral');
763
828
  const promises = forceable.filter((i) => i.kind === PROMISE_KIND);
764
829
  // Every derived item names its own repo in its text; this is for the header, where the ONE repo
765
830
  // this tree points at is the honest thing to say.
@@ -771,6 +836,13 @@ const header = capabilityClaims.length
771
836
  : completionClaims.length
772
837
  ? ['Your proposed final answer claims completion beyond the available verified scope.',
773
838
  'Correct only that claim: report the observed check scope and what remains UNVERIFIED.']
839
+ : deferrals.length
840
+ ? ['Your proposed final answer asks permission for, or postpones, work you are already authorized to do.',
841
+ 'Continue now; stop only on a genuine blocker (missing credential, write outside your authority,',
842
+ 'irreversible action needing consent, hardware-key tap, evidenced outage, or a choice only the owner can make).']
843
+ : decisionRecords.length
844
+ ? ['This turn created an Architecture Decision Record without appending its AgentDB decision record.',
845
+ 'Record the decision now (what, why, shape, evidence), or state why it records no decision.']
774
846
  : committed.length && observed.length
775
847
  ? [`You have unfinished work you committed to, and ${repoLabel} has open work of its own.`,
776
848
  'Do NOT end the turn — continue now.']
@@ -784,6 +856,7 @@ const lines = [
784
856
  ...header,
785
857
  ...(objective ? ['Continue the next safe step within this authorized objective without routine reconfirmation.']
786
858
  : promises.length ? ['Do what you said you would do; that commitment is the only work this authorizes.']
859
+ : deferrals.length ? ['This continues the work the owner already authorized; it authorizes nothing new.']
787
860
  : ['Correct only the answer to the original user request; this does not authorize new project work.']),
788
861
  'Do not expand authority from observed issues, PRs, security alerts, or other task ledgers.',
789
862
  'Stop on explicit cancellation, verified completion, or a genuine blocker/new authority boundary.',
@@ -803,6 +876,10 @@ const lines = [
803
876
  'unresolved absence claim with UNKNOWN until a complete live inventory proves it.']
804
877
  : completionClaims.length
805
878
  ? ['Name the check you ran (a "Verified:" line with the command or artifact) and say what is NOT verified.']
879
+ : deferrals.length
880
+ ? ['If a genuine blocker exists, name it and its class plainly; otherwise do the next step instead of announcing it.']
881
+ : decisionRecords.length
882
+ ? ['A decision record is committed only by an exact readback; if the command reports pending, say so.']
806
883
  : promises.length
807
884
  ? ['A promise closes only when a later answer claims it done with a check run after the last change —',
808
885
  'never by saying so, and never by --done. For blocked, deferred, superseded or disputed assistant',
@@ -34,7 +34,7 @@ import { resolveProjectStore } from './project-store-resolver.mjs';
34
34
  import { restoreProgressionForSession } from './project-progression-session-start.mjs';
35
35
  import { STAGE_BUDGETS_MS } from './session-start-budget.mjs';
36
36
  import { projectDirectory } from './project-identity.mjs';
37
- import { CONTINUITY_NAMESPACE, EVENT_KINDS, makeEvent, userLevelAgentdbHooks } from './continuity-events.mjs';
37
+ import { CONTINUITY_NAMESPACE, EVENT_KINDS, makeEvent } from './continuity-events.mjs';
38
38
  import { ContinuityJournal, drain, launchDrain, recordingLine, storeReady } from './continuity-journal.mjs';
39
39
  import { digestCanonical } from './project-progression-contract.mjs';
40
40
 
@@ -176,7 +176,6 @@ export function buildBrief({ projectDir, env = process.env, home = os.homedir(),
176
176
  const rows = readEvents(journal, options);
177
177
  const status = journal.status();
178
178
  const of = (kind) => rows.filter((r) => r.event?.kind === kind);
179
- const user = userLevelAgentdbHooks({ home });
180
179
 
181
180
  const root = resolution.checkoutRoot;
182
181
  const branch = git(root, ['rev-parse', '--abbrev-ref', 'HEAD'], options);
@@ -200,13 +199,8 @@ export function buildBrief({ projectDir, env = process.env, home = os.homedir(),
200
199
  const ruleLines = lessons.filter(isOwned).map((r) => `• ${oneLine(r.event.summary)} ${tag(r)}`);
201
200
  const storeLessonLines = lessons.filter((r) => !isOwned(r) && r.event.authoritative).map((r) => `• ${oneLine(r.event.summary)} ${tag(r)}`);
202
201
  const detectedLines = lessons.filter((r) => !isOwned(r) && !r.event.authoritative).map((r) => `• ${oneLine(r.event.summary)} ${tag(r)}`);
203
- let lessonNote = '';
204
- if (user.ensure) {
205
- lessonNote = '(owner lesson-* keys and project-state-current are printed by your user-level agentdb-ensure hook; not repeated)';
206
- } else {
207
- for (const l of ownerLessons(journal.db, [path.basename(resolution.projectRoot), 'default', 'lessons'], options)) {
208
- storeLessonLines.push(`• ${oneLine(l.content)} [${oneLine(`${l.ns}/${l.key}`, 80)}]`);
209
- }
202
+ for (const l of ownerLessons(journal.db, [path.basename(resolution.projectRoot), 'default', 'lessons'], options)) {
203
+ storeLessonLines.push(`• ${oneLine(l.content)} [${oneLine(`${l.ns}/${l.key}`, 80)}]`);
210
204
  }
211
205
 
212
206
  const trusted = [
@@ -216,7 +210,7 @@ export function buildBrief({ projectDir, env = process.env, home = os.homedir(),
216
210
  { name: 'SINCE LAST SESSION', cap: SECTION_CAPS.commit, intro: `(${new Date(since).toISOString().slice(0, 16)}Z → now; git, read live): ${changed.length}${changed.length === 20 ? '+' : ''} commit(s) on ${oneLine(branch, 80) || '?'}${newTags.length ? `; local tags (publication unverified): ${newTags.map((r) => oneLine(r.event.detail?.tag, 60)).join(', ')}` : ''}`,
217
211
  items: changed.map((c) => `• ${oneLine(c, 140)}`) },
218
212
  { name: 'DECISIONS', cap: SECTION_CAPS.decision, items: of('decision').reverse().map((r) => `• ${oneLine(r.event.summary)} ${tag(r)}`) },
219
- { name: 'LESSONS FOUND IN THE PROJECT STORE (not confirmed as yours)', cap: SECTION_CAPS.lesson, intro: lessonNote, items: storeLessonLines.reverse() },
213
+ { name: 'LESSONS FOUND IN THE PROJECT STORE (not confirmed as yours)', cap: SECTION_CAPS.lesson, items: storeLessonLines.reverse() },
220
214
  { name: 'DETECTED, UNCONFIRMED (heuristic: owner phrasing or assistant lines; not rules)', cap: SECTION_CAPS.lesson, items: detectedLines.reverse() },
221
215
  { name: 'OPEN ITEMS', cap: SECTION_CAPS.open, items: openItems },
222
216
  { name: 'GATES (latest outcomes)', cap: SECTION_CAPS.gate, items: of('gate').reverse().map((r) => `• ${oneLine(r.event.summary, 160)} ${tag(r)}`) },
@@ -30,12 +30,8 @@
30
30
  */
31
31
  import { nativeTurnLines } from './native-turn-transcript.mjs';
32
32
  import crypto from 'node:crypto';
33
- import fs from 'node:fs';
34
- import os from 'node:os';
35
- import path from 'node:path';
36
33
  import { execFileSync } from 'node:child_process';
37
34
  import { redactProgression } from './project-progression-contract.mjs';
38
- import { resolveProjectStore } from './project-store-resolver.mjs';
39
35
 
40
36
  export const CONTINUITY_NAMESPACE = 'continuity-events';
41
37
  export const EVENT_SCHEMA = 'ruvnet-brain.continuity-event';
@@ -326,79 +322,3 @@ export function collectTurnEvents({ lines = null, lastAssistantMessage = '', hos
326
322
  return events.filter((e) => (seen.has(`${e.kind}:${e.id}`) ? false : seen.add(`${e.kind}:${e.id}`)));
327
323
  }
328
324
 
329
- /**
330
- * The owner's own user-level AgentDB hooks (~/.claude/settings.json). Read, never modified. Used to
331
- * DEFER: where one of them already records a thing, the product does not record it a second time.
332
- */
333
- export function userLevelAgentdbHooks({ home = os.homedir(), event, projectDir, env = process.env } = {}) {
334
- const found = { turnCapture: false, autocapture: false, ensure: false, ownership: 'unknown', collisionCandidate: false,
335
- settings: path.join(home, '.claude', 'settings.json') };
336
- if (!['Stop', 'PreCompact', 'SessionEnd', 'SessionStart'].includes(event) || !projectDir
337
- || String(env.RUVNET_HOOK_HOST || 'claude') !== 'claude') return found;
338
- let doc;
339
- try { doc = JSON.parse(fs.readFileSync(found.settings, 'utf8')); } catch { return found; }
340
- if (!doc || typeof doc !== 'object' || Array.isArray(doc) || doc.disableAllHooks === true || doc.allowManagedHooksOnly === true) return found;
341
- let target, executable;
342
- try {
343
- for (const file of [path.join(projectDir, '.claude', 'settings.json'), path.join(projectDir, '.claude', 'settings.local.json')]) {
344
- let local;
345
- try { local = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (error) { if (error.code === 'ENOENT') continue; throw error; }
346
- if (!local || typeof local !== 'object' || Array.isArray(local) || local.disableAllHooks === true || local.allowManagedHooksOnly === true) return found;
347
- }
348
- const deadlineAt = Number(env.RUVNET_SESSION_START_DEADLINE_AT) || Infinity;
349
- target = resolveProjectStore({ projectDir, gitTimeoutMs: 500, deadlineAt }).canonicalAgentDbPath;
350
- // Bind the direct command to the declared entry of the user's one managed GLOBAL Ruflo.
351
- // Arbitrary Node handlers, filenames and source comments do not prove their actual target.
352
- if (!process.getuid) return found; // Native Windows ACL ownership is not attested by POSIX uid metadata.
353
- const root = path.join(home, '.npm-global', 'lib', 'node_modules', 'ruflo');
354
- const manifest = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
355
- const declared = typeof manifest.bin === 'string' ? manifest.bin : manifest.bin?.ruflo;
356
- if (manifest.name !== 'ruflo' || typeof declared !== 'string' || path.isAbsolute(declared) || declared.split(/[\\/]/).includes('..')) return found;
357
- executable = fs.realpathSync.native(path.join(home, '.npm-global', 'bin', 'ruflo'));
358
- const entry = fs.realpathSync.native(path.join(root, declared));
359
- const relative = path.relative(fs.realpathSync.native(root), entry);
360
- const stat = fs.statSync(entry);
361
- if (executable !== entry || relative.startsWith('..') || path.isAbsolute(relative) || !stat.isFile() || stat.uid !== process.getuid()) return found;
362
- fs.accessSync(executable, fs.constants.X_OK);
363
- } catch { return found; }
364
- const groups = doc?.hooks?.[event];
365
- for (const group of Array.isArray(groups) ? groups : []) {
366
- if (group?.enabled === false || group?.disabled === true || (group?.matcher && group.matcher !== '*')) continue;
367
- for (const hook of Array.isArray(group?.hooks) ? group.hooks : []) {
368
- if (hook?.type !== 'command' || hook.enabled === false || hook.disabled === true || typeof hook.command !== 'string') continue;
369
- const command = hook.command.trim();
370
- if (/[$`;&|<>\r\n]/.test(command)) continue;
371
- const tokens = []; const words = /"([^"\\]*)"|'([^']*)'|([^\s"'\\]+)/g;
372
- let match, previous = 0, valid = true;
373
- while ((match = words.exec(command))) {
374
- if (command.slice(previous, match.index).trim()) { valid = false; break; }
375
- tokens.push(match[1] ?? match[2] ?? match[3]); previous = words.lastIndex;
376
- }
377
- if (!valid || command.slice(previous).trim() || tokens[1] !== 'memory') continue;
378
- try { if (!path.isAbsolute(tokens[0]) || fs.realpathSync.native(tokens[0]) !== executable) continue; } catch { continue; }
379
- const options = tokens[2] === 'store' ? ['--path', '-p', '--namespace', '-n', '--key', '-k', '--value']
380
- : tokens[2] === 'retrieve' ? ['--path', '-p', '--namespace', '-n', '--key', '-k']
381
- : tokens[2] === 'init' ? ['--path', '-p'] : [];
382
- for (let i = 3; i < tokens.length; i += 2) {
383
- if (!options.includes(tokens[i]) || !tokens[i + 1] || tokens[i + 1].startsWith('-')) { valid = false; break; }
384
- }
385
- if (!valid) continue;
386
- const flag = names => {
387
- const positions = tokens.flatMap((token, i) => names.includes(token) ? [i] : []);
388
- return positions.length === 1 ? tokens[positions[0] + 1] : undefined;
389
- };
390
- const db = flag(['--path', '-p']);
391
- if (!db || !path.isAbsolute(db) || db.split(/[\\/]/).includes('..') || path.resolve(db) !== target) continue;
392
- const namespace = flag(['--namespace', '-n']);
393
- const captureRegistration = tokens[2] === 'store' && flag(['--key', '-k']) && flag(['--value'])
394
- && ((event === 'Stop' && namespace === 'turns') || (['PreCompact', 'SessionEnd'].includes(event) && namespace === 'sessions'));
395
- const ensureRegistration = event === 'SessionStart' && ['init', 'retrieve'].includes(tokens[2]);
396
- if (captureRegistration || ensureRegistration) found.collisionCandidate = true;
397
- }
398
- }
399
- if (found.collisionCandidate) {
400
- found.target = target; found.event = event;
401
- found.reason = 'canonical registration alone does not prove capture of the current turn/session';
402
- }
403
- return found;
404
- }
@@ -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
  });
@@ -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
+ }