ruvnet-brain 4.5.3 → 4.5.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.
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  /** Prompt-time canonical AgentDB recall (ADR-101, G-022).
3
- * Every nontrivial prompt searches the project and legacy default namespaces in
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 with session digest dedupe.
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
- /** Explicit acknowledgements are the only human prompts skipped. */
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) || /^(?:ok(?:ay)?|yes|no|thanks?(?: you)?|got it|sounds good|great|sure|yep|yup|done|👍)[.!\s]*$/i.test(text)) return null;
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 = String(prompt || '').toLowerCase().replace(/[^a-z0-9.\s-]/g, ' ').split(/\s+/)
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 terms = promptKeywords(redactText(prompt), 14);
58
- if (/where are we|status|catch me up/i.test(prompt)) terms.push('project status progress');
59
- if (/score|grade|north.star/i.test(prompt)) terms.push('scorecard north star');
60
- if (/releas|publish|workflow run|dispatch/i.test(prompt)) terms.push('release decision authority');
61
- return terms.join(' ') || String(prompt).trim().slice(0, 200);
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
- /** Prefer the substantive passage in long rubric/requirement records, never a generated summary. */
84
- export function evidenceExcerpt(value, key) {
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 (/^scorecard/i.test(key)) {
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 ranked = candidates.sort((a, b) => b.score - a.score);
101
- // Represent both namespaces when relevant; neither may hide historical requirements.
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 >= 3) break;
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
- const candidates = pickRows(results);
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]')) return { pick: { ...p, preview: evidenceExcerpt(r.value, p.key) }, state: 'ok' };
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 picks = retrieved.flatMap((r) => r.pick ? [r.pick] : []);
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; }
@@ -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
- * continuity-journal.mjs — durable outbox FIRST, then AgentDB, then an exact read-back. Never silent.
3
- * THE GUARANTEE, and the failure it is built around. `ruflo memory store` refuses a write while another
4
- * process holds the store's native WAL sidecars: memory-initializer.ts storeEntry/getEntry check
5
- * `hasNativeWalSidecars(dbPath)` and return `walRefusalError` ("active native WAL connection — refusing
6
- * an unsafe sql.js whole-image write"; ruflo #2735/#2878, search_ruvnet:
7
- * ruflo/v3/@claude-flow/cli/src/memory/memory-initializer.ts). That is ordinary under concurrency, so an
8
- * event that is only ever handed to `ruflo` once is an event that can be lost. Here:
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
- * 1. append(): every event is written to `.swarm/continuity-events-outbox.jsonl` and fsynced BEFORE
11
- * any store call. From that instant it cannot be lost by a crash, a SIGKILL or a refusal.
12
- * 2. drain(): `ruflo memory store --no-upsert --path <db>` (the ONLY writer of memory.db), then the
13
- * row is read back by exact key through the read-only node:sqlite reader (the same independent
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. Acks/off/no-match keep the cheap return.
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 nontrivial human prompt. ──────────────
149
- # Priority 0 and digest dedupe preserve safety output and avoid re-sending evidence.
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
- // The ground hook is registered on every prompt. On Windows, starting Git Bash and then jq can
201
- // consume most of the hook's five-second declaration before an unrelated prompt reaches the
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
- if (hookId === 'ground-ruvnet') {
402
- let text = input.toString('utf8');
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);