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.
- package/README.md +1 -1
- package/package.json +2 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/hooks/codex-hooks.json +1 -31
- package/plugin/hooks/hook-contracts.json +16 -96
- package/plugin/hooks/hooks.json +1 -31
- package/plugin/scripts/agentdb-recall-process.mjs +118 -0
- package/plugin/scripts/agentdb-recall-reader.mjs +22 -0
- package/plugin/scripts/agentdb-recall.mjs +99 -104
- package/plugin/scripts/continuation-gate.mjs +78 -1
- package/plugin/scripts/continuity-brief.mjs +4 -10
- package/plugin/scripts/continuity-events.mjs +6 -85
- package/plugin/scripts/continuity-hook-policy.mjs +3 -3
- package/plugin/scripts/continuity-journal.mjs +22 -16
- package/plugin/scripts/decision-gate.mjs +2 -2
- package/plugin/scripts/decision-recall-gate.mjs +141 -0
- package/plugin/scripts/decision-record.mjs +353 -0
- package/plugin/scripts/deferral-evidence.mjs +134 -0
- package/plugin/scripts/hook-failures.mjs +45 -0
- package/plugin/scripts/hook-shim.mjs +70 -4
- package/plugin/scripts/learn-capture.mjs +67 -34
- package/plugin/scripts/native-turn-transcript.mjs +51 -0
- package/plugin/scripts/project-capture-queue.mjs +2 -0
- package/plugin/scripts/project-memory-enrollment.mjs +203 -0
- package/plugin/scripts/project-progression-reader.mjs +4 -4
- package/plugin/scripts/project-progression-session-start.mjs +7 -27
- package/plugin/scripts/project-progression-store.mjs +18 -6
- package/plugin/scripts/project-store-resolver.mjs +44 -6
- package/plugin/scripts/project-transition-hook.mjs +31 -10
- package/plugin/scripts/session-snapshot-hook.mjs +18 -2
- package/plugin/scripts/session-start-core.mjs +15 -1
- package/plugin/scripts/turn-outcome-capture.mjs +38 -32
- package/plugin/scripts/turn-transport-journal.mjs +3 -1
- 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
|
|
269
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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', '--
|
|
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:
|
|
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;
|
|
418
|
-
const
|
|
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
|
|
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 (
|
|
522
|
+
if (['Stop', 'SessionEnd', 'PreCompact', 'SubagentStop'].includes(event)) {
|
|
517
523
|
let lines = null;
|
|
518
|
-
if (
|
|
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
|
+
}
|