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.
- 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.mjs +19 -7
- package/plugin/scripts/continuation-gate.mjs +78 -1
- package/plugin/scripts/continuity-brief.mjs +4 -10
- package/plugin/scripts/continuity-events.mjs +0 -80
- package/plugin/scripts/continuity-hook-policy.mjs +3 -3
- 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/project-transition-hook.mjs +31 -10
- package/plugin/scripts/session-snapshot-hook.mjs +11 -2
- package/plugin/scripts/session-start-core.mjs +9 -1
- package/plugin/scripts/turn-outcome-capture.mjs +6 -14
- package/scripts/deferral-replay.mjs +47 -0
|
@@ -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
|
|
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
|
|
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
|
-
|
|
204
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
});
|
|
@@ -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
|
+
}
|