ruvnet-brain 4.5.3 → 4.5.5
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 +2 -2
- package/bin/install.mjs +148 -31
- package/config/model-router/catalog.template.json +126 -52
- package/config/model-router/policy.default.mjs +94 -75
- package/config/model-router/qualification-contract.json +124 -0
- package/config/model-router/routing-eval-cases.json +275 -0
- package/config/model-router/routing-policy.template.json +76 -0
- package/config/model-router/weekly-analyst-instruction.md +60 -0
- package/data/model-catalog.json +44 -49
- package/package.json +3 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/hooks/codex-hooks.json +40 -3
- package/plugin/hooks/hook-contracts.json +218 -19
- package/plugin/hooks/hooks.json +51 -2
- package/plugin/scripts/agentdb-recall.mjs +101 -30
- package/plugin/scripts/codex-hook-adapter.mjs +18 -9
- package/plugin/scripts/continuity-hook-policy.mjs +9 -0
- package/plugin/scripts/continuity-journal.mjs +33 -33
- package/plugin/scripts/ground-ruvnet.sh +5 -5
- package/plugin/scripts/hook-shim.mjs +4 -30
- package/plugin/scripts/project-capture-queue.mjs +333 -0
- package/plugin/scripts/project-progression-contract.mjs +1 -1
- package/plugin/scripts/project-progression-hook.mjs +3 -3
- package/plugin/scripts/project-progression-producer.mjs +53 -28
- package/plugin/scripts/project-progression-session-start.mjs +44 -4
- package/plugin/scripts/project-progression-store.mjs +14 -0
- package/plugin/scripts/project-transition-hook.mjs +204 -0
- package/plugin/scripts/session-snapshot-hook.mjs +44 -272
- package/plugin/scripts/session-start-budget.mjs +2 -2
- package/plugin/scripts/turn-outcome-capture.mjs +125 -47
- package/plugin/scripts/turn-transport-journal.mjs +106 -0
- package/scripts/codex-hook-trust-reconcile.mjs +247 -0
- package/scripts/codex-routed.sh +3 -36
- package/scripts/goldie-weekly.sh +8 -64
- package/scripts/metaharness-router.mjs +7 -1
- package/scripts/model-analyst-sandbox.mjs +54 -0
- package/scripts/model-currency-evidence.mjs +139 -0
- package/scripts/model-currency.mjs +230 -0
- package/scripts/model-native-catalog.mjs +111 -0
- package/scripts/model-native-qualification.mjs +251 -0
- package/scripts/model-router-agent-hook.mjs +136 -0
- package/scripts/model-router-dispatch.mjs +161 -0
- package/scripts/model-router-engine.mjs +155 -104
- package/scripts/model-routing-eval.mjs +108 -0
- package/scripts/model-routing-gateway.mjs +420 -0
- package/scripts/model-routing-launchers.mjs +174 -0
- package/scripts/model-routing-policy-promotion.mjs +203 -0
- package/scripts/model-weekly-analyst.mjs +299 -0
- package/scripts/model-weekly-assessment.mjs +91 -0
- package/scripts/model-weekly-cycle.mjs +183 -0
- package/scripts/model-weekly-qualification.mjs +362 -0
- package/scripts/native-subscription-usage.mjs +57 -0
- package/scripts/release-qualification-contract.mjs +54 -0
- package/scripts/security-guidance-codex-compat.mjs +142 -0
- package/scripts/user-model-prompt-hook.mjs +69 -0
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/** Prompt-time canonical AgentDB recall (ADR-101, G-022).
|
|
3
|
-
* Every
|
|
3
|
+
* Every nonempty human prompt searches curated signal, turn outcomes and project/default namespaces in
|
|
4
4
|
* .swarm/memory.db. Recalled records are untrusted evidence, never instructions.
|
|
5
5
|
* Global Ruflo executes in isolated scratch directories; all processes share one
|
|
6
|
-
* <=2s deadline. ground-ruvnet.sh delivers <=600 bytes
|
|
6
|
+
* <=2s deadline. ground-ruvnet.sh delivers <=600 bytes on each eligible prompt.
|
|
7
7
|
*/
|
|
8
8
|
import fs from 'node:fs';
|
|
9
9
|
import path from 'node:path';
|
|
@@ -33,10 +33,10 @@ export function agentdbStores(projectDir = process.cwd(), gitTimeoutMs) {
|
|
|
33
33
|
return { root: resolved.projectRoot, stores };
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
/**
|
|
36
|
+
/** Every nonempty human prompt can change or authorize project work. */
|
|
37
37
|
export function recallTrigger(prompt) {
|
|
38
38
|
const text = String(prompt || '').trim();
|
|
39
|
-
if (!text || isHarnessGenerated(text)
|
|
39
|
+
if (!text || isHarnessGenerated(text)) return null;
|
|
40
40
|
return { kinds: ['prompt'] };
|
|
41
41
|
}
|
|
42
42
|
|
|
@@ -48,17 +48,18 @@ const STOP = new Set(('the a an and or but for nor so yet to of in on at by with
|
|
|
48
48
|
|
|
49
49
|
/** Up to `max` distinctive words from the prompt, in order. */
|
|
50
50
|
export function promptKeywords(prompt, max = 6) {
|
|
51
|
-
const words =
|
|
51
|
+
const words = redactText(prompt).toLowerCase().replace(/[^a-z0-9.\s-]/g, ' ').split(/\s+/)
|
|
52
52
|
.map((w) => w.replace(/^[-.]+|[-.]+$/g, '')).filter((w) => w.length >= 3 && !STOP.has(w) && !/^\d+$/.test(w));
|
|
53
53
|
return [...new Set(words)].slice(0, max);
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
export function recallQuery(prompt) {
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
if (/
|
|
60
|
-
if (/
|
|
61
|
-
|
|
57
|
+
const safePrompt = redactText(prompt);
|
|
58
|
+
const terms = promptKeywords(safePrompt, 14);
|
|
59
|
+
if (/where are we|status|catch me up/i.test(safePrompt)) terms.push('project status progress');
|
|
60
|
+
if (/score|grade|north.star/i.test(safePrompt)) terms.push('scorecard north star');
|
|
61
|
+
if (/releas|publish|workflow run|dispatch/i.test(safePrompt)) terms.push('release decision authority');
|
|
62
|
+
return terms.join(' ') || safePrompt.trim().slice(0, 200);
|
|
62
63
|
}
|
|
63
64
|
|
|
64
65
|
export function parseSearchJson(stdout) {
|
|
@@ -79,12 +80,64 @@ function clean(value, limit) {
|
|
|
79
80
|
return out;
|
|
80
81
|
}
|
|
81
82
|
|
|
82
|
-
const NOISE_KEY = /^(?:verify[-_]|probe[-_]|test[-_]|rnb-quality-probe)/i;
|
|
83
|
-
|
|
84
|
-
|
|
83
|
+
const NOISE_KEY = /^(?:verify[-_]|probe[-_]|test[-_]|rnb-quality-probe|session[-_]|turn[-_]|project-progress[-_]|cevt[-_])/i;
|
|
84
|
+
const SIGNAL_NAMESPACES = new Set(['lessons', 'patterns', 'pattern']);
|
|
85
|
+
/** Turn knowledge is the actual redacted outcome, never its session/transcript wrapper. */
|
|
86
|
+
function turnOutcomeClauses(value, prompt) {
|
|
87
|
+
const outcome = /(?:^|\|\|\s*)OUTCOME:\s*([\s\S]*)/i.exec(redactText(value))?.[1]?.split(/\s*\|\|\s*[A-Z][A-Z ]*:/)[0];
|
|
88
|
+
if (!outcome) return [];
|
|
89
|
+
// Context-delivery vocabulary is not a result-bearing match (e.g. "project memory").
|
|
90
|
+
const terms = new Set(promptKeywords(prompt, 40).filter(t =>
|
|
91
|
+
!/^(?:previous|prior|canonical|project|memory|context|supplied|provided|automatically|recall|checks|results|concluded|identify|occurred|recover|read|files|tools|answer|concisely|disclose)$/.test(t)));
|
|
92
|
+
if (!terms.size) return [];
|
|
93
|
+
const clauses = outcome.split(/(?<=[.!?])\s+/).map((clause, index) => ({ clause, index,
|
|
94
|
+
relevance: promptKeywords(clause, 100).filter((term) => terms.has(term)).length }));
|
|
95
|
+
clauses.sort((a, b) => b.relevance - a.relevance);
|
|
96
|
+
const selected = []; let bytes = 9;
|
|
97
|
+
for (const entry of clauses.filter(c => c.relevance)) {
|
|
98
|
+
const size = Buffer.byteLength(entry.clause) + 1;
|
|
99
|
+
if (bytes + size <= 280) { selected.push(entry); bytes += size; }
|
|
100
|
+
}
|
|
101
|
+
if (!selected.length && clauses[0]?.relevance) return [clean(clauses[0].clause, 270)];
|
|
102
|
+
return selected.sort((a, b) => a.index - b.index).map(e => e.clause);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function turnOutcomeExcerpt(value, prompt) {
|
|
106
|
+
const clauses = turnOutcomeClauses(value, prompt);
|
|
107
|
+
return clauses.length ? clean('OUTCOME: ' + clauses.join(' '), 280) : '';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Quote a substantive exact-value passage, including the remedy in structured lessons. */
|
|
111
|
+
export function evidenceExcerpt(value, key, prompt = '') {
|
|
85
112
|
const text = redactText(value);
|
|
113
|
+
if (/^project-state-current/i.test(key)) {
|
|
114
|
+
// Checkpoints often lead with version/hash metadata. Quote one actual field,
|
|
115
|
+
// selected by task terms, instead of spending the evidence budget on identity.
|
|
116
|
+
const first = text.indexOf('{'); const last = text.lastIndexOf('}');
|
|
117
|
+
if (first >= 0 && last > first) {
|
|
118
|
+
const json = text.slice(first, last + 1);
|
|
119
|
+
for (const candidate of [json, json.replace(/\\"/g, '"')]) {
|
|
120
|
+
try {
|
|
121
|
+
const object = JSON.parse(candidate);
|
|
122
|
+
if (!object || Array.isArray(object) || typeof object !== 'object') continue;
|
|
123
|
+
const terms = promptKeywords(prompt, 14);
|
|
124
|
+
const fields = Object.entries(object).filter(([name, field]) => typeof field === 'string'
|
|
125
|
+
&& !/^(?:source|sha|version|candidate|shipped|host|timestamp|at)$/i.test(name));
|
|
126
|
+
fields.sort(([a, av], [b, bv]) => {
|
|
127
|
+
const weight = (name, field) => terms.filter(t => (name + ' ' + field).toLowerCase().includes(t)).length
|
|
128
|
+
+ (/^(?:next|nextAction|blockers?|automaticMemory|status|scope)$/i.test(name) ? 0.25 : 0);
|
|
129
|
+
return weight(b, bv) - weight(a, av);
|
|
130
|
+
});
|
|
131
|
+
if (fields.length) return clean(fields[0][0] + ': ' + fields[0][1], 110);
|
|
132
|
+
} catch { /* An unparseable historical value is quoted as text below. */ }
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
86
136
|
let start = -1;
|
|
87
|
-
if (/^
|
|
137
|
+
if (/^lesson[-_]/i.test(key)) {
|
|
138
|
+
const match = /\bWORKED(?:\([^)]*\))?\s*:/i.exec(text);
|
|
139
|
+
if (match) start = match.index;
|
|
140
|
+
} else if (/^scorecard/i.test(key)) {
|
|
88
141
|
const match = /\b(?:OVERALL\s*[:=]?\s*\d|Ops\s+\d|Brain.Score overall\s+\d|Continuity\s*=\s*\d)/i.exec(text);
|
|
89
142
|
if (match) start = match.index;
|
|
90
143
|
} else if (/^decision-agentdb-read-write/i.test(key)) {
|
|
@@ -93,19 +146,17 @@ export function evidenceExcerpt(value, key) {
|
|
|
93
146
|
return clean(start >= 0 ? text.slice(start) : text, 110);
|
|
94
147
|
}
|
|
95
148
|
|
|
96
|
-
export function pickRows(results) {
|
|
97
|
-
const candidates = results.flatMap((r) => r.rows.filter((p) => p.namespace === r.namespace && !NOISE_KEY.test(p.key)
|
|
149
|
+
export function pickRows(results, limit = 3) {
|
|
150
|
+
const candidates = results.flatMap((r) => r.rows.filter((p) => p.namespace === r.namespace && (!NOISE_KEY.test(p.key) || p.namespace === 'turns' && /^turn[-_]/i.test(p.key))
|
|
98
151
|
&& (!r.family || p.key.toLowerCase().includes(r.family))
|
|
99
|
-
&& Number.isFinite(p.score) && p.score >= MIN_RELEVANCE));
|
|
100
|
-
const
|
|
101
|
-
|
|
152
|
+
&& Number.isFinite(p.score) && p.score >= MIN_RELEVANCE).map((p) => ({ ...p, targeted: Boolean(r.family) })));
|
|
153
|
+
const signal = (p) => SIGNAL_NAMESPACES.has(p.namespace) ? 2 : p.namespace === 'turns' ? 1 : 0;
|
|
154
|
+
const ranked = candidates.sort((a, b) => Number(b.targeted) - Number(a.targeted) || signal(b) - signal(a) || b.score - a.score);
|
|
155
|
+
// Curated lessons and patterns are signal; lifecycle transcript telemetry is not.
|
|
156
|
+
// Do not reserve a slot for a weak match just because its namespace was searched.
|
|
102
157
|
const chosen = [];
|
|
103
|
-
for (const ns of [...new Set(results.map((r) => r.namespace))]) {
|
|
104
|
-
const row = ranked.find((r) => r.namespace === ns);
|
|
105
|
-
if (row) chosen.push(row);
|
|
106
|
-
}
|
|
107
158
|
for (const row of ranked) {
|
|
108
|
-
if (chosen.length >=
|
|
159
|
+
if (chosen.length >= limit) break;
|
|
109
160
|
if (!chosen.some((r) => r.key === row.key && r.namespace === row.namespace)) chosen.push(row);
|
|
110
161
|
}
|
|
111
162
|
return chosen.map((p) => ({ store: 'memory.db', key: p.key, namespace: p.namespace,
|
|
@@ -119,7 +170,10 @@ export function formatBlock({ picks, status }) {
|
|
|
119
170
|
const shown = picks.slice();
|
|
120
171
|
const render = (limit) => heading + shown.map((p) =>
|
|
121
172
|
`\n${JSON.stringify(clean(p.key, 72))} [${clean(p.namespace, 32)}]: ${JSON.stringify(clean(p.preview, limit))}`).join('');
|
|
122
|
-
let limit = 110;
|
|
173
|
+
let limit = Math.min(280, Math.max(110, ...shown.map(p => Buffer.byteLength(String(p.preview || '')))));
|
|
174
|
+
// Preserve substantive multi-fact passages; drop lower-ranked records before
|
|
175
|
+
// clipping every record into incomplete facts merely to fill three slots.
|
|
176
|
+
while (shown.length > 1 && Buffer.byteLength(render(limit) + '\n') > BLOCK_MAX_BYTES) shown.pop();
|
|
123
177
|
while (limit > 0 && Buffer.byteLength(render(limit) + '\n') > BLOCK_MAX_BYTES) limit -= 1;
|
|
124
178
|
while (shown.length && Buffer.byteLength(render(limit) + '\n') > BLOCK_MAX_BYTES) shown.pop();
|
|
125
179
|
return render(limit);
|
|
@@ -179,27 +233,44 @@ export async function recall({ prompt, projectDir = process.cwd(), env = process
|
|
|
179
233
|
const store = stores[0];
|
|
180
234
|
const query = recallQuery(prompt);
|
|
181
235
|
const scratchFor = scratch || ((storePath) => rufloCwdFor(storePath, { root: rufloScratchRoot(env) }));
|
|
182
|
-
const namespaces = [...new Set([path.basename(root), 'default'])];
|
|
236
|
+
const namespaces = [...new Set(['lessons', 'patterns', 'pattern', 'turns', path.basename(root), 'default'])];
|
|
183
237
|
const family = /score|grade|north.star/i.test(prompt) ? 'scorecard'
|
|
184
238
|
: /where are we|status|catch me up/i.test(prompt) ? 'project-state-current'
|
|
185
239
|
: /releas|publish|workflow run|dispatch/i.test(prompt) ? 'release'
|
|
186
240
|
: /requirement|always|every prompt/i.test(prompt) ? 'decision-agentdb' : null;
|
|
187
241
|
const jobs = namespaces.flatMap((namespace) => [{ namespace, family: null, args: ['--format', 'json', '-q', query, '-n', namespace, '--limit', '12'] },
|
|
188
|
-
...(family ? [{ namespace, family, args: ['--format', 'json', '-q', family, '-n', namespace, '-t', 'keyword', '--limit', '4'] }] : [])]);
|
|
242
|
+
...(family && !SIGNAL_NAMESPACES.has(namespace) && namespace !== 'turns' ? [{ namespace, family, args: ['--format', 'json', '-q', family, '-n', namespace, '-t', 'keyword', '--limit', '4'] }] : [])]);
|
|
189
243
|
const results = await Promise.all(jobs.map(async ({ namespace, family: recordFamily, args }) => ({ namespace, family: recordFamily,
|
|
190
244
|
...await searchOnce({ ruflo: bin, store, deadline, env, scratch: scratchFor, args }) })));
|
|
191
245
|
let status = results.every((r) => r.state === 'ok') ? 'ok'
|
|
192
246
|
: results.some((r) => r.state === 'timed out') ? 'timed out' : 'unavailable';
|
|
193
|
-
|
|
247
|
+
// Overfetch a bounded six exact values so rejected turn metadata cannot hide
|
|
248
|
+
// the next useful outcome; at most three verified excerpts are delivered.
|
|
249
|
+
const candidates = pickRows(results, 6);
|
|
194
250
|
// Ruflo previews are ~60 characters. Read the actual selected values within
|
|
195
251
|
// the same deadline so the block contains useful evidence rather than titles.
|
|
196
252
|
const retrieved = await Promise.all(candidates.map(async (p) => {
|
|
197
253
|
const r = await searchOnce({ ruflo: bin, store, deadline, env, scratch: scratchFor, operation: 'retrieve',
|
|
198
254
|
args: ['-k', p.key, '-n', p.namespace, '--value-only'] });
|
|
199
|
-
if (r.state === 'ok' && r.value && !r.value.startsWith('[WARN]'))
|
|
255
|
+
if (r.state === 'ok' && r.value && !r.value.startsWith('[WARN]')) {
|
|
256
|
+
const clauses = p.namespace === 'turns' ? turnOutcomeClauses(r.value, prompt) : null;
|
|
257
|
+
const preview = clauses ? turnOutcomeExcerpt(r.value, prompt) : evidenceExcerpt(r.value, p.key, prompt);
|
|
258
|
+
return { pick: preview ? { ...p, preview, clauses } : null, state: 'ok' };
|
|
259
|
+
}
|
|
200
260
|
return { pick: null, state: r.state === 'ok' ? 'unavailable' : r.state };
|
|
201
261
|
}));
|
|
202
|
-
const
|
|
262
|
+
const seenClauses = new Set();
|
|
263
|
+
const picks = retrieved.flatMap(({ pick }) => {
|
|
264
|
+
if (!pick) return [];
|
|
265
|
+
const { clauses, ...p } = pick;
|
|
266
|
+
if (!clauses) return [p];
|
|
267
|
+
const unique = clauses.filter(clause => {
|
|
268
|
+
const id = clause.toLowerCase().replace(/[`*_-]/g, '').replace(/\s+/g, ' ').trim();
|
|
269
|
+
if (seenClauses.has(id)) return false;
|
|
270
|
+
seenClauses.add(id); return true;
|
|
271
|
+
});
|
|
272
|
+
return unique.length ? [{ ...p, preview: clean('OUTCOME: ' + unique.join(' '), 280) }] : [];
|
|
273
|
+
}).slice(0, 3);
|
|
203
274
|
if (retrieved.some((r) => r.state !== 'ok')) status = retrieved.some((r) => r.state === 'timed out') ? 'timed out reading exact values' : 'unavailable exact values';
|
|
204
275
|
return { block: formatBlock({ picks, status }), picks, stores, status: { 'memory.db': status } };
|
|
205
276
|
} catch { return empty; }
|
|
@@ -216,21 +216,20 @@ function validPostToolUseOutput(value) {
|
|
|
216
216
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
217
217
|
const topLevelKeys = new Set([
|
|
218
218
|
'continue', 'stopReason', 'suppressOutput', 'systemMessage',
|
|
219
|
-
'
|
|
219
|
+
'decision', 'reason', 'hookSpecificOutput',
|
|
220
220
|
]);
|
|
221
221
|
if (Object.keys(value).some((key) => !topLevelKeys.has(key))) return false;
|
|
222
222
|
if (value.continue !== undefined && typeof value.continue !== 'boolean') return false;
|
|
223
223
|
if (value.stopReason !== undefined && typeof value.stopReason !== 'string') return false;
|
|
224
224
|
if (value.suppressOutput !== undefined && typeof value.suppressOutput !== 'boolean') return false;
|
|
225
225
|
if (value.systemMessage !== undefined && typeof value.systemMessage !== 'string') return false;
|
|
226
|
-
if (value.terminalSequence !== undefined && typeof value.terminalSequence !== 'string') return false;
|
|
227
226
|
if (value.decision !== undefined && value.decision !== 'block') return false;
|
|
228
227
|
if (value.decision === 'block' && (typeof value.reason !== 'string' || !value.reason.trim())) return false;
|
|
229
228
|
if (value.hookSpecificOutput !== undefined) {
|
|
230
229
|
const specific = value.hookSpecificOutput;
|
|
231
230
|
if (!specific || typeof specific !== 'object' || Array.isArray(specific)) return false;
|
|
232
231
|
const specificKeys = new Set([
|
|
233
|
-
'hookEventName', 'additionalContext',
|
|
232
|
+
'hookEventName', 'additionalContext',
|
|
234
233
|
]);
|
|
235
234
|
if (Object.keys(specific).some((key) => !specificKeys.has(key))) return false;
|
|
236
235
|
if (specific.hookEventName !== 'PostToolUse') return false;
|
|
@@ -268,13 +267,23 @@ if (!parsed) {
|
|
|
268
267
|
process.exit(0);
|
|
269
268
|
}
|
|
270
269
|
|
|
271
|
-
//
|
|
272
|
-
//
|
|
273
|
-
//
|
|
270
|
+
// Codex 0.160.0 post-tool-use.command.output (extracted from the installed binary on
|
|
271
|
+
// 2026-10-04) rejects terminalSequence, updatedToolOutput, and telemetry fields. The
|
|
272
|
+
// host additionally rejects updatedMCPToolOutput semantically after schema parsing. Keep
|
|
273
|
+
// supported control fields even when a shared body includes incompatible metadata: wrapping
|
|
274
|
+
// the whole output as context alone would silently discard a security block.
|
|
274
275
|
if (event === 'PostToolUse' && !validPostToolUseOutput(parsed)) {
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
276
|
+
const normalized = {};
|
|
277
|
+
for (const key of ['continue', 'stopReason', 'suppressOutput', 'systemMessage', 'reason']) {
|
|
278
|
+
const type = ['continue', 'suppressOutput'].includes(key) ? 'boolean' : 'string';
|
|
279
|
+
if (typeof parsed[key] === type) normalized[key] = parsed[key];
|
|
280
|
+
}
|
|
281
|
+
if (parsed.decision === 'block') {
|
|
282
|
+
normalized.decision = 'block';
|
|
283
|
+
if (!normalized.reason?.trim()) normalized.reason = stdout.trim();
|
|
284
|
+
}
|
|
285
|
+
normalized.hookSpecificOutput = { hookEventName: event, additionalContext: stdout.trim() };
|
|
286
|
+
process.stdout.write(JSON.stringify(normalized));
|
|
278
287
|
process.exit(0);
|
|
279
288
|
}
|
|
280
289
|
|
|
@@ -125,6 +125,7 @@ export const CONTINUITY_EVENTS = Object.freeze({
|
|
|
125
125
|
registration('session-start', 'startup|resume|clear|compact|fork', ['claude', 'codex']),
|
|
126
126
|
]),
|
|
127
127
|
UserPromptSubmit: Object.freeze([
|
|
128
|
+
registration('session-snapshot', '*', ['claude', 'codex']),
|
|
128
129
|
registration('unprompted-speech', '*', ['claude', 'codex']),
|
|
129
130
|
registration('ground-ruvnet', '*', ['claude', 'codex']),
|
|
130
131
|
// Context-only recommendation for clearly substantial, independently splittable work. The
|
|
@@ -142,6 +143,7 @@ export const CONTINUITY_EVENTS = Object.freeze({
|
|
|
142
143
|
// Claude's `Edit` shape for decision-gate.mjs's own policies (protect-state, hijack-ruvnet,
|
|
143
144
|
// ground-before-write, adr-currency all then see tool_name:"Edit" exactly as on Claude).
|
|
144
145
|
PreToolUse: Object.freeze([
|
|
146
|
+
registration('session-snapshot', '*', ['claude', 'codex']),
|
|
145
147
|
registration('decision-gate', '^(Write|Edit|MultiEdit|NotebookEdit|apply_patch)$', ['claude', 'codex']),
|
|
146
148
|
]),
|
|
147
149
|
// Extended to Codex 2026-09-12: a real MCP `search_ruvnet` call was measured to fire PostToolUse
|
|
@@ -151,8 +153,15 @@ export const CONTINUITY_EVENTS = Object.freeze({
|
|
|
151
153
|
// no change either (proven live: the real MCP tool_response's `content[0].text` carries the exact
|
|
152
154
|
// "Searched N RuvNet repos" banner and query text the script already looks for).
|
|
153
155
|
PostToolUse: Object.freeze([
|
|
156
|
+
registration('session-snapshot', '*', ['claude', 'codex']),
|
|
154
157
|
registration('grounding-stamp', '^(?:.*__)?search_ruvnet$', ['claude', 'codex']),
|
|
155
158
|
]),
|
|
159
|
+
PostToolUseFailure: Object.freeze([
|
|
160
|
+
registration('session-snapshot', '*', ['claude']),
|
|
161
|
+
]),
|
|
162
|
+
SubagentStop: Object.freeze([
|
|
163
|
+
registration('session-snapshot', '*', ['claude', 'codex']),
|
|
164
|
+
]),
|
|
156
165
|
Stop: Object.freeze([
|
|
157
166
|
registration('continuation-gate', '*', ['claude', 'codex']),
|
|
158
167
|
// Codex added 2026-09-29 (owner requirement: every turn's outcome recorded on BOTH hosts —
|
|
@@ -1,33 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
2
|
+
* Durable material events: fsync the outbox, store through canonical Ruflo, then exact-read back.
|
|
3
|
+
* Ruflo refuses sql.js writes during native WAL contention (#2735/#2878); a refused event stays
|
|
4
|
+
* pending and the bounded worker retries with backoff. Only verified bytes produce a commit receipt.
|
|
5
|
+
* Current original-path consent governs capture and every replay attempt; opt-out retains debt.
|
|
6
|
+
* One kind:id denotes one event, even when concurrent sessions observe it. A conflicting stored row
|
|
7
|
+
* is quarantined. Missing store/Ruflo is not applicable, while pending debt or corruption is reported
|
|
8
|
+
* by the SessionStart brief, doctor, and a once-per-session Claude Stop notice.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* read path project-progression-store.mjs uses) and compared. Only then is a `commit` line
|
|
15
|
-
* appended. A refusal is retried with backoff inside the worker's budget; whatever is left stays
|
|
16
|
-
* pending and is retried by the next boundary's worker.
|
|
17
|
-
* 3. status(): pending count, oldest pending, last commit, last failure — surfaced by the SessionStart
|
|
18
|
-
* brief, `--doctor`, and (Claude) a Stop line when recording is stuck. Never swallowed.
|
|
19
|
-
* WHAT "✗" MEANS, AND WHAT KEEPS THE FILE SMALL (independent review S3/S4, 2026-10-01):
|
|
20
|
-
* • ONE EVENT, ONE KEY: two lines under one key are one event seen twice (first wins); only a stored row
|
|
21
|
-
* that is NOT that kind:id is a conflict, and that key is quarantined.
|
|
22
|
-
* • NOT APPLICABLE IS NOT A FAILURE: no initialized store → nothing journalled; no ruflo → events wait,
|
|
23
|
-
* no drainer, and the line says "n/a", never ✗.
|
|
24
|
-
* • A PROBLEM IS REPORTED, THEN CLEARS: quarantine/corrupt lines age out after QUARANTINE_REPORT_MS or
|
|
25
|
-
* with `continuity-brief.mjs --clear`; the Stop line shows once per session per condition.
|
|
26
|
-
* • BOUNDED. A failure is ONE record per event (attempt count, last error), never a line per attempt;
|
|
27
|
-
* compact() rewrites the file atomically (lock + size re-check, so a concurrent append is never
|
|
28
|
-
* lost): committed events older than RETAIN_COMMITTED_MS leave (the store keeps them and dedupes),
|
|
29
|
-
* corrupt lines become one notice; MAX_EVENT_RECORDS bounds committed history, never pending events.
|
|
30
|
-
* A prolonged outage can grow the pending file beyond that soft cap; status reports capacity pressure.
|
|
10
|
+
* Failures retain one attempts/last-error record per event. Lock-protected compaction checks the file
|
|
11
|
+
* size before atomic replacement so concurrent appends survive. It removes committed history after
|
|
12
|
+
* RETAIN_COMMITTED_MS, ages quarantine/corrupt notices after QUARANTINE_REPORT_MS, and bounds committed
|
|
13
|
+
* history at MAX_EVENT_RECORDS. Pending events are never dropped; prolonged outages report pressure.
|
|
31
14
|
*/
|
|
32
15
|
import fs from 'node:fs';
|
|
33
16
|
import os from 'node:os';
|
|
@@ -39,7 +22,7 @@ import { withProgressionReader } from './project-progression-reader.mjs';
|
|
|
39
22
|
import { rufloRunDir } from './project-progression-store.mjs';
|
|
40
23
|
import { resolveRuflo, rufloInvocation } from './ruflo-bin.mjs';
|
|
41
24
|
import { resolveProjectStore } from './project-store-resolver.mjs';
|
|
42
|
-
import { readSettledTranscript } from './turn-outcome-capture.mjs';
|
|
25
|
+
import { resolveTurnDb, readSettledTranscript } from './turn-outcome-capture.mjs';
|
|
43
26
|
import {
|
|
44
27
|
CONTINUITY_NAMESPACE, INITIAL_LOOKBACK_MS, collectCommits, collectReleases, collectTurnEvents, eventIdOf, eventKey,
|
|
45
28
|
} from './continuity-events.mjs';
|
|
@@ -72,9 +55,11 @@ const ms = (iso) => Date.parse(iso || '') || 0;
|
|
|
72
55
|
|
|
73
56
|
export class ContinuityJournal {
|
|
74
57
|
/** `ruflo`: the binary (string), null = not installed, undefined = resolve it. */
|
|
75
|
-
constructor({ projectRoot, fsync = fs.fsyncSync, now = Date.now, ruflo } = {}) {
|
|
58
|
+
constructor({ projectRoot, projectDir = projectRoot, env = process.env, fsync = fs.fsyncSync, now = Date.now, ruflo } = {}) {
|
|
76
59
|
if (typeof projectRoot !== 'string' || !projectRoot) throw new TypeError('projectRoot is required');
|
|
77
60
|
this.projectRoot = projectRoot;
|
|
61
|
+
this.projectDir = projectDir;
|
|
62
|
+
this.brainHome = env.RUVNET_BRAIN_HOME || path.join(env.HOME || os.homedir(), '.cache', 'ruvnet-brain');
|
|
78
63
|
this.swarm = path.join(projectRoot, '.swarm');
|
|
79
64
|
this.path = path.join(this.swarm, OUTBOX_NAME);
|
|
80
65
|
this.db = path.join(this.swarm, 'memory.db');
|
|
@@ -169,15 +154,21 @@ export class ContinuityJournal {
|
|
|
169
154
|
return ids;
|
|
170
155
|
}
|
|
171
156
|
|
|
157
|
+
captureConsent(projectDir = this.projectDir, unknownOriginalPath = false) {
|
|
158
|
+
return resolveTurnDb({ projectDir, brainHome: this.brainHome, requestedStorePath: this.db, unknownOriginalPath });
|
|
159
|
+
}
|
|
160
|
+
|
|
172
161
|
/** Journal the events this boundary observed that are not already known. Returns what was added. */
|
|
173
162
|
record(events) {
|
|
163
|
+
const consent = this.captureConsent();
|
|
164
|
+
if (consent.skipped) throw new Error(`event capture suspended: ${consent.skipped}`);
|
|
174
165
|
const known = this.knownIds();
|
|
175
166
|
const fresh = [];
|
|
176
167
|
for (const event of events) {
|
|
177
168
|
const id = `${event.kind}:${event.id}`;
|
|
178
169
|
if (known.has(id)) continue;
|
|
179
170
|
known.add(id);
|
|
180
|
-
fresh.push({ type: 'event', key: eventKey(event), digest: digestCanonical(event), journaledAt: new Date(this.now()).toISOString(), event });
|
|
171
|
+
fresh.push({ type: 'event', capturePath: consent.capturePath, key: eventKey(event), digest: digestCanonical(event), journaledAt: new Date(this.now()).toISOString(), event });
|
|
181
172
|
}
|
|
182
173
|
this.appendRecords(fresh);
|
|
183
174
|
return fresh;
|
|
@@ -378,6 +369,11 @@ export function drain(journal, {
|
|
|
378
369
|
let attempts = 0;
|
|
379
370
|
let last = null;
|
|
380
371
|
for (let attempt = 0; !done && now() < deadline; attempt += 1) {
|
|
372
|
+
let consent;
|
|
373
|
+
try { consent = journal.captureConsent(rec.capturePath || journal.projectRoot, !rec.capturePath); }
|
|
374
|
+
catch (error) { consent = { skipped: `capture consent unavailable: ${error.message}` }; }
|
|
375
|
+
if (consent.skipped) return { committed, failed, remaining: journal.pending().length, skipped: consent.skipped };
|
|
376
|
+
if (now() >= deadline) break;
|
|
381
377
|
attempts += 1;
|
|
382
378
|
const result = store({ ruflo, db: journal.db, key: rec.key, value });
|
|
383
379
|
const back = readBack({ ruflo, db: journal.db, key: rec.key });
|
|
@@ -452,7 +448,11 @@ export function captureContinuityEvents({
|
|
|
452
448
|
if (String(env.RUVNET_CONTINUITY_CAPTURE || '').toLowerCase() === 'off') return { ...report, skipped: 'RUVNET_CONTINUITY_CAPTURE=off' };
|
|
453
449
|
let resolution;
|
|
454
450
|
try { resolution = resolveProjectStore({ projectDir }); } catch { return { ...report, skipped: 'project store could not be resolved' }; }
|
|
455
|
-
const journal = new ContinuityJournal({ projectRoot: resolution.projectRoot, now, ruflo });
|
|
451
|
+
const journal = new ContinuityJournal({ projectRoot: resolution.projectRoot, projectDir, env, now, ruflo });
|
|
452
|
+
try {
|
|
453
|
+
const consent = journal.captureConsent();
|
|
454
|
+
if (consent.skipped) return { ...report, skipped: consent.skipped.startsWith('no project memory db') ? `not applicable: ${consent.skipped}` : consent.skipped };
|
|
455
|
+
} catch (error) { return { ...report, skipped: `capture consent unavailable: ${error.message}` }; }
|
|
456
456
|
try {
|
|
457
457
|
const st = fs.lstatSync(journal.swarm);
|
|
458
458
|
if (!st.isDirectory() || st.isSymbolicLink()) return { ...report, skipped: '.swarm is not a real directory' };
|
|
@@ -487,7 +487,7 @@ export function captureContinuityEvents({
|
|
|
487
487
|
|
|
488
488
|
/** The detached worker body. */
|
|
489
489
|
export function runDrain(projectRoot, options = {}) {
|
|
490
|
-
const journal = new ContinuityJournal({ projectRoot, ...(options.ruflo !== undefined ? { ruflo: options.ruflo } : {}) });
|
|
490
|
+
const journal = new ContinuityJournal({ projectRoot, ...(options.env ? { env: options.env } : {}), ...(options.ruflo !== undefined ? { ruflo: options.ruflo } : {}) });
|
|
491
491
|
const release = takeLock(journal);
|
|
492
492
|
if (!release) return { skipped: 'another drainer holds the lock' };
|
|
493
493
|
try { return drain(journal, options); } finally { release(); }
|
|
@@ -68,7 +68,7 @@ if printf '%s' "$TEXT" | grep -qiE '\[Your previous response|\[Request interrupt
|
|
|
68
68
|
fi
|
|
69
69
|
|
|
70
70
|
# Read canonical memory before the quiet path; useful evidence may exist even
|
|
71
|
-
# for a worktree with no local .swarm.
|
|
71
|
+
# for a worktree with no local .swarm. Off/absent/no-match keep the cheap return.
|
|
72
72
|
_ADB_OUT=""
|
|
73
73
|
if [ "${RUVNET_AGENTDB_FIRST:-on}" != "off" ]; then
|
|
74
74
|
_ADB_OUT=$(printf '%s' "$INPUT" | "${RUVNET_NODE_BIN:-node}" "$(dirname "$0")/agentdb-recall.mjs" 2>/dev/null)
|
|
@@ -145,8 +145,8 @@ inj_seen() {
|
|
|
145
145
|
}
|
|
146
146
|
inj_mark() { [ -n "$INJ_DIR" ] && mkdir -p "$INJ_DIR" 2>/dev/null && : > "$INJ_DIR/$1" 2>/dev/null; return 0; }
|
|
147
147
|
|
|
148
|
-
# ── Canonical AgentDB recall (ADR-101): every
|
|
149
|
-
# Priority 0
|
|
148
|
+
# ── Canonical AgentDB recall (ADR-101): every nonempty human prompt. ──────────────
|
|
149
|
+
# Priority 0 preserves safety output. Recall is delivered on every eligible prompt.
|
|
150
150
|
if [ -n "$_ADB_OUT" ]; then
|
|
151
151
|
_ADB_ID=$(printf '%s\n' "$_ADB_OUT" | head -n 1 | tr -cd 'A-Za-z0-9' | cut -c1-12)
|
|
152
152
|
[ -n "$_ADB_ID" ] && printf '%s\n' "$_ADB_OUT" | sed '1d' | out_to "0-0-agentdb-recall-$_ADB_ID"
|
|
@@ -652,7 +652,7 @@ if [ -n "$BLK" ]; then
|
|
|
652
652
|
INJ_USED=0
|
|
653
653
|
for _f in $(cd "$BLK" 2>/dev/null && ls | grep -v '\.' | sort -t- -k2,2n -k1,1n); do
|
|
654
654
|
_rest=${_f#*-}; _prio=${_rest%%-*}; _id=${_rest#*-}
|
|
655
|
-
if [ "$_id" != "resume" ] && inj_seen "$_id"; then
|
|
655
|
+
if [ "$_id" != "resume" ] && [ "${_id#agentdb-recall-}" = "$_id" ] && inj_seen "$_id"; then
|
|
656
656
|
if [ -f "$BLK/$_f.short" ]; then echo short > "$BLK/$_f.pick"; INJ_SHORTENED=$((INJ_SHORTENED + 1)); fi
|
|
657
657
|
continue
|
|
658
658
|
fi
|
|
@@ -665,7 +665,7 @@ if [ -n "$BLK" ]; then
|
|
|
665
665
|
if [ "$_id" = "flywheel" ]; then claim_flywheel_day || continue; fi
|
|
666
666
|
echo full > "$BLK/$_f.pick"
|
|
667
667
|
[ "$_prio" != "0" ] && INJ_USED=$((INJ_USED + _size))
|
|
668
|
-
[ "$_id" != "resume" ] && inj_mark "$_id"
|
|
668
|
+
[ "$_id" != "resume" ] && [ "${_id#agentdb-recall-}" = "$_id" ] && inj_mark "$_id"
|
|
669
669
|
done
|
|
670
670
|
for _f in $(cd "$BLK" 2>/dev/null && ls | grep -v '\.' | sort -n); do
|
|
671
671
|
case "$(cat "$BLK/$_f.pick" 2>/dev/null)" in
|
|
@@ -197,29 +197,9 @@ if (!entry) {
|
|
|
197
197
|
// forgets to declare one fails toward the pre-ADR-054 behaviour rather than toward silent death.
|
|
198
198
|
if (BRAIN_OFF && entry.offBehavior === 'silence') process.exit(0);
|
|
199
199
|
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
// shell body's "emit nothing" verdict. Read a bounded copy here, in the Node process that is
|
|
203
|
-
// already running, and skip the interpreter entirely only when BOTH prompt intent and project
|
|
204
|
-
// state prove that no advisory can fire. The regex deliberately over-approximates the shell gates:
|
|
205
|
-
// false positives take the established body; false negatives would be a product defect.
|
|
200
|
+
// Read a bounded payload before dispatch. The ground body owns the quiet-prompt
|
|
201
|
+
// decision because it resolves canonical memory beyond the hook process's cwd.
|
|
206
202
|
let hookInput = null;
|
|
207
|
-
const GROUND_RELEVANT = /ruvnet|ruflo|ruvector|\brvf\b|agentdb|agenticow|rulake|ruview|rupixel|ruv-fann|agentic-flow|synthlang|dspy|qudag|safla|metaharness|cve-bench|sparc|swarm|claude-flow|pinecone|pgvector|chroma|weaviate|faiss|milvus|qdrant|hnswlib|annoy|vector|langchain|llama|autogen|crew-ai|semantic-kernel|embedding|retrieval|prompt compression|token cost|post-quantum|quantum-resistant|\badr\b|decision|architect|design|plan|spec|refactor|migrat|implement|build|write|add|change|fix|update|deploy|create|enhance|set up|setup|wire|integrate|test|coverage|audit|review|benchmark|lint|scan|debug|optimi|app|feature|service|system|backend|frontend|\bapi\b|module|pipeline|infra|database|schema|workflow|roadmap|milestone|autonomous|unattended|do not stop|keep working|keep going|soak run|harness|quality|readiness|evolve|self-improv|hardening|cheaper|cheap|lower cost|compute arbitrage|cascade|scorecard|score .*repo/i;
|
|
208
|
-
|
|
209
|
-
function projectCanSpeakWithoutPrompt() {
|
|
210
|
-
if (process.env.RUVNET_AUTONOMOUS === '1') return true;
|
|
211
|
-
try {
|
|
212
|
-
if (fs.existsSync(path.join(process.cwd(), '.claude-flow')) ||
|
|
213
|
-
fs.existsSync(path.join(process.cwd(), '.swarm'))) return true;
|
|
214
|
-
for (const name of ['package.json', '.mcp.json']) {
|
|
215
|
-
try {
|
|
216
|
-
if (/claude-flow|ruflo/i.test(fs.readFileSync(path.join(process.cwd(), name), 'utf8'))) return true;
|
|
217
|
-
} catch { /* absent/unreadable project metadata cannot create an advisory */ }
|
|
218
|
-
}
|
|
219
|
-
} catch { /* fail toward running the body below */ return true; }
|
|
220
|
-
return false;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
203
|
function readHookInput(limit) {
|
|
224
204
|
return new Promise((resolve) => {
|
|
225
205
|
const chunks = [];
|
|
@@ -398,14 +378,8 @@ function dispatchHook() {
|
|
|
398
378
|
if (entry.stdinBytes) {
|
|
399
379
|
readHookInput(entry.stdinBytes).then((input) => {
|
|
400
380
|
hookInput = input;
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
try {
|
|
404
|
-
const parsed = JSON.parse(text);
|
|
405
|
-
text = parsed?.prompt ?? parsed?.user_prompt ?? parsed?.input ?? text;
|
|
406
|
-
} catch { /* raw/malformed input is classified as-is */ }
|
|
407
|
-
if (!GROUND_RELEVANT.test(String(text)) && !projectCanSpeakWithoutPrompt()) process.exit(0);
|
|
408
|
-
}
|
|
381
|
+
// The body resolves canonical project memory before its quiet return. A cwd-only
|
|
382
|
+
// preflight here would hide eligible memory in nested directories and worktrees.
|
|
409
383
|
process.exit(dispatchHook());
|
|
410
384
|
}).catch(() => {
|
|
411
385
|
hookInput = Buffer.alloc(0);
|