ruvnet-brain 4.5.16 → 4.5.17

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.
Files changed (57) hide show
  1. package/README.md +1 -1
  2. package/bin/install.mjs +1 -1
  3. package/config/practical-rule-catalog.json +111 -0
  4. package/package.json +2 -1
  5. package/plugin/.claude-plugin/plugin.json +1 -1
  6. package/plugin/.codex-plugin/plugin.json +1 -1
  7. package/plugin/hooks/codex-hooks.json +13 -1
  8. package/plugin/hooks/hook-contracts.json +20 -4
  9. package/plugin/scripts/adr-currency-gate.mjs +75 -31
  10. package/plugin/scripts/advocacy-outcomes.mjs +18 -4
  11. package/plugin/scripts/agentdb-recall.mjs +161 -26
  12. package/plugin/scripts/capability-inventory-receipt.mjs +16 -7
  13. package/plugin/scripts/capability-registry.mjs +55 -114
  14. package/plugin/scripts/capacity-aware-parallel-work.mjs +1 -1
  15. package/plugin/scripts/codex-hook-adapter.mjs +33 -6
  16. package/plugin/scripts/codex-hook-wrapper.mjs +23 -3
  17. package/plugin/scripts/continuity-brief.mjs +55 -22
  18. package/plugin/scripts/continuity-events.mjs +93 -17
  19. package/plugin/scripts/continuity-hook-policy.mjs +1 -1
  20. package/plugin/scripts/continuity-journal.mjs +77 -29
  21. package/plugin/scripts/decision-gate.mjs +63 -22
  22. package/plugin/scripts/decision-outcomes.mjs +192 -199
  23. package/plugin/scripts/detach.mjs +53 -13
  24. package/plugin/scripts/doc-currency.mjs +993 -0
  25. package/plugin/scripts/ground-ruvnet.sh +16 -3
  26. package/plugin/scripts/grounding-answer.mjs +18 -16
  27. package/plugin/scripts/grounding-stamp.sh +3 -0
  28. package/plugin/scripts/grounding-turn-evidence.mjs +60 -65
  29. package/plugin/scripts/grounding-turn-gate.mjs +34 -29
  30. package/plugin/scripts/grounding-turn-mark.mjs +101 -17
  31. package/plugin/scripts/hook-context-budget.mjs +90 -0
  32. package/plugin/scripts/hook-registry.mjs +129 -1
  33. package/plugin/scripts/hook-shim.mjs +20 -2
  34. package/plugin/scripts/lesson-gate.mjs +1 -1
  35. package/plugin/scripts/project-capture-queue.mjs +22 -15
  36. package/plugin/scripts/project-progression-producer.mjs +15 -8
  37. package/plugin/scripts/project-progression-reader.mjs +14 -4
  38. package/plugin/scripts/project-progression-session-start.mjs +29 -17
  39. package/plugin/scripts/project-progression-sources.mjs +24 -10
  40. package/plugin/scripts/project-progression-store.mjs +7 -2
  41. package/plugin/scripts/project-store-resolver.mjs +10 -7
  42. package/plugin/scripts/project-transition-hook.mjs +20 -12
  43. package/plugin/scripts/session-snapshot-hook.mjs +20 -9
  44. package/plugin/scripts/session-start-budget.mjs +18 -0
  45. package/plugin/scripts/session-start-core.mjs +32 -6
  46. package/plugin/scripts/session-start-trace.mjs +2 -1
  47. package/plugin/scripts/turn-outcome-capture.mjs +7 -5
  48. package/plugin/scripts/unprompted-runtime.mjs +36 -10
  49. package/plugin/scripts/user-settings.mjs +7 -1
  50. package/scripts/doc-currency.mjs +6 -990
  51. package/scripts/hook-qualify-core.mjs +1 -1
  52. package/scripts/model-managed-prompt.mjs +1 -1
  53. package/scripts/model-managed-workflow-service.mjs +113 -14
  54. package/scripts/model-routing-controller.mjs +112 -21
  55. package/scripts/practical-rule-selector.mjs +51 -0
  56. package/scripts/product-integrity-contract.mjs +11 -4
  57. package/scripts/source-scope-receipt.mjs +9 -4
@@ -11,7 +11,7 @@
11
11
  * So this module turns what is OBSERVABLE at a capture boundary into small typed events:
12
12
  *
13
13
  * commit git, by SHA (subject, files, merge flag, branch) authoritative
14
- * release git tags, by tag + target SHA authoritative
14
+ * release local git tag observations, by tag + target SHA publication unverified
15
15
  * gate a test / check / release command and its exit outcome authoritative (tool result)
16
16
  * finding an Agent/Task completion's reported result not authoritative (agent text)
17
17
  * decision a line the assistant marked as a decision, or explicit explicit only is authoritative
@@ -34,6 +34,7 @@ import os from 'node:os';
34
34
  import path from 'node:path';
35
35
  import { execFileSync } from 'node:child_process';
36
36
  import { redactProgression } from './project-progression-contract.mjs';
37
+ import { resolveProjectStore } from './project-store-resolver.mjs';
37
38
 
38
39
  export const CONTINUITY_NAMESPACE = 'continuity-events';
39
40
  export const EVENT_SCHEMA = 'ruvnet-brain.continuity-event';
@@ -191,9 +192,9 @@ export function collectReleases({ checkoutRoot, sinceMs, host, session, project,
191
192
  if (!tag || !Number.isFinite(at) || at < sinceMs) continue;
192
193
  const sha = peeled || object;
193
194
  events.push(makeEvent({
194
- kind: 'release', at, host, session, project, source: 'git', authoritative: true,
195
- summary: `${tag} -> ${String(sha).slice(0, 8)}`, basis: `${tag}@${sha}`,
196
- detail: { tag, sha, channel: tag.startsWith('corpus-') ? 'corpus' : 'code' },
195
+ kind: 'release', at, host, session, project, source: 'git-tag', authoritative: false,
196
+ summary: `LOCAL TAG ${tag} -> ${String(sha).slice(0, 8)} (publication unverified)`, basis: `${tag}@${sha}`,
197
+ detail: { tag, sha, channel: tag.startsWith('corpus-') ? 'corpus' : 'code', publicationVerified: false },
197
198
  }));
198
199
  if (events.length >= max) break;
199
200
  }
@@ -236,12 +237,31 @@ export function currentTurnRecords(lines) {
236
237
  return { userMessage: start >= 0 ? textOf(recs[start].message?.content) : '', records: recs.slice(start + 1) };
237
238
  }
238
239
 
240
+ export { exitOutcome as normalizeToolOutcome };
241
+
239
242
  function exitOutcome(result) {
240
243
  const text = resultText(result?.content);
241
- const code = /\bExit code (\d+)/i.exec(text);
242
- const failed = result?.is_error === true || (code && code[1] !== '0');
244
+ const response = result?.content && typeof result.content === 'object' && !Array.isArray(result.content)
245
+ ? result.content : result;
246
+ const codes = [...text.matchAll(/^(?:Exit code\s*:?|Process exited with code)\s+(-?\d+)\s*$/gim)].map(match => Number(match[1]));
247
+ for (const value of [result, response]) for (const key of ['exit_code', 'exitCode', 'status']) {
248
+ if (Number.isSafeInteger(value?.[key])) codes.push(value[key]);
249
+ }
250
+ const distinct = [...new Set(codes)], code = distinct.length === 1 ? distinct[0] : null;
251
+ const states = [result, response].flatMap(value => [value?.status, value?.outcome]).filter(value => typeof value === 'string').map(value => value.toLowerCase());
252
+ const uncertain = states.some(value => ['unknown', 'unavailable'].includes(value));
253
+ const failed = states.some(value => ['fail', 'failed', 'failure', 'error', 'denied'].includes(value)) || [result, response].some(value => value?.is_error === true || value?.isError === true
254
+ || value?.success === false || value?.ok === false || Boolean(value?.error)) || codes.some(value => value !== 0);
255
+ const interrupted = [result, response].some(value => value?.interrupted === true || value?.cancelled === true || value?.canceled === true || Boolean(value?.signal))
256
+ || states.some(value => ['cancelled', 'canceled', 'interrupted', 'aborted', 'timeout', 'timed_out'].includes(value));
257
+ const running = /\bProcess running with session ID\b|\bScript running with cell ID\b/i.test(text)
258
+ || states.some(value => ['running', 'pending', 'queued'].includes(value))
259
+ || [result, response].some(value => value?.completed === false);
260
+ const outcome = failed ? 'fail' : interrupted ? 'interrupted' : running ? 'pending' : !uncertain && code === 0 ? 'pass' : 'unknown';
243
261
  const tail = text.split('\n').map((l) => l.trim()).filter(Boolean).slice(-1)[0] || '';
244
- return { outcome: failed ? 'fail' : 'pass', exitCode: code ? Number(code[1]) : (failed ? null : 0), tail: bound(tail, 200) };
262
+ const nativeSuccess = [result, response].some(value => value?.is_error === false || value?.isError === false || value?.success === true || value?.ok === true);
263
+ return { outcome, exitCode: running || interrupted ? null : code, tail: bound(tail, 200),
264
+ uncertain, successfulToolResult: !failed && !interrupted && !running && !uncertain && (code === 0 || nativeSuccess) };
245
265
  }
246
266
 
247
267
  /**
@@ -269,7 +289,7 @@ export function collectTurnEvents({ lines = null, lastAssistantMessage = '', hos
269
289
  if (use.name === 'Bash' && typeof input.command === 'string' && GATE_COMMAND.test(input.command)) {
270
290
  const command = bound(input.command, 200);
271
291
  const { outcome, exitCode, tail } = exitOutcome(r);
272
- events.push(makeEvent({ kind: 'gate', at, host, session, project, source: 'tool-result', authoritative: true,
292
+ events.push(makeEvent({ kind: 'gate', at, host, session, project, source: 'tool-result', authoritative: ['pass', 'fail'].includes(outcome),
273
293
  summary: `${outcome.toUpperCase()} ${command}${tail ? ` — ${tail}` : ''}`,
274
294
  basis: `${session}\u0000${use.id}`, detail: { command, outcome, exitCode, description: bound(input.description || '', 120) } }));
275
295
  } else if (use.name === 'Agent' || use.name === 'Task') {
@@ -309,19 +329,75 @@ export function collectTurnEvents({ lines = null, lastAssistantMessage = '', hos
309
329
  * The owner's own user-level AgentDB hooks (~/.claude/settings.json). Read, never modified. Used to
310
330
  * DEFER: where one of them already records a thing, the product does not record it a second time.
311
331
  */
312
- export function userLevelAgentdbHooks({ home = os.homedir() } = {}) {
313
- const found = { turnCapture: false, autocapture: false, ensure: false, settings: path.join(home, '.claude', 'settings.json') };
332
+ export function userLevelAgentdbHooks({ home = os.homedir(), event, projectDir, env = process.env } = {}) {
333
+ const found = { turnCapture: false, autocapture: false, ensure: false, ownership: 'unknown', collisionCandidate: false,
334
+ settings: path.join(home, '.claude', 'settings.json') };
335
+ if (!['Stop', 'PreCompact', 'SessionEnd', 'SessionStart'].includes(event) || !projectDir
336
+ || String(env.RUVNET_HOOK_HOST || 'claude') !== 'claude') return found;
314
337
  let doc;
315
338
  try { doc = JSON.parse(fs.readFileSync(found.settings, 'utf8')); } catch { return found; }
316
- for (const groups of Object.values(doc?.hooks ?? {})) {
317
- for (const group of Array.isArray(groups) ? groups : []) {
318
- for (const hook of Array.isArray(group?.hooks) ? group.hooks : []) {
319
- const command = String(hook?.command ?? '');
320
- if (/agentdb-turn-capture\.mjs/.test(command)) found.turnCapture = true;
321
- if (/agentdb-autocapture\.mjs/.test(command)) found.autocapture = true;
322
- if (/agentdb-ensure\.sh/.test(command)) found.ensure = true;
339
+ if (!doc || typeof doc !== 'object' || Array.isArray(doc) || doc.disableAllHooks === true || doc.allowManagedHooksOnly === true) return found;
340
+ let target, executable;
341
+ try {
342
+ for (const file of [path.join(projectDir, '.claude', 'settings.json'), path.join(projectDir, '.claude', 'settings.local.json')]) {
343
+ let local;
344
+ try { local = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (error) { if (error.code === 'ENOENT') continue; throw error; }
345
+ if (!local || typeof local !== 'object' || Array.isArray(local) || local.disableAllHooks === true || local.allowManagedHooksOnly === true) return found;
346
+ }
347
+ const deadlineAt = Number(env.RUVNET_SESSION_START_DEADLINE_AT) || Infinity;
348
+ target = resolveProjectStore({ projectDir, gitTimeoutMs: 500, deadlineAt }).canonicalAgentDbPath;
349
+ // Bind the direct command to the declared entry of the user's one managed GLOBAL Ruflo.
350
+ // Arbitrary Node handlers, filenames and source comments do not prove their actual target.
351
+ if (!process.getuid) return found; // Native Windows ACL ownership is not attested by POSIX uid metadata.
352
+ const root = path.join(home, '.npm-global', 'lib', 'node_modules', 'ruflo');
353
+ const manifest = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
354
+ const declared = typeof manifest.bin === 'string' ? manifest.bin : manifest.bin?.ruflo;
355
+ if (manifest.name !== 'ruflo' || typeof declared !== 'string' || path.isAbsolute(declared) || declared.split(/[\\/]/).includes('..')) return found;
356
+ executable = fs.realpathSync.native(path.join(home, '.npm-global', 'bin', 'ruflo'));
357
+ const entry = fs.realpathSync.native(path.join(root, declared));
358
+ const relative = path.relative(fs.realpathSync.native(root), entry);
359
+ const stat = fs.statSync(entry);
360
+ if (executable !== entry || relative.startsWith('..') || path.isAbsolute(relative) || !stat.isFile() || stat.uid !== process.getuid()) return found;
361
+ fs.accessSync(executable, fs.constants.X_OK);
362
+ } catch { return found; }
363
+ const groups = doc?.hooks?.[event];
364
+ for (const group of Array.isArray(groups) ? groups : []) {
365
+ if (group?.enabled === false || group?.disabled === true || (group?.matcher && group.matcher !== '*')) continue;
366
+ for (const hook of Array.isArray(group?.hooks) ? group.hooks : []) {
367
+ if (hook?.type !== 'command' || hook.enabled === false || hook.disabled === true || typeof hook.command !== 'string') continue;
368
+ const command = hook.command.trim();
369
+ if (/[$`;&|<>\r\n]/.test(command)) continue;
370
+ const tokens = []; const words = /"([^"\\]*)"|'([^']*)'|([^\s"'\\]+)/g;
371
+ let match, previous = 0, valid = true;
372
+ while ((match = words.exec(command))) {
373
+ if (command.slice(previous, match.index).trim()) { valid = false; break; }
374
+ tokens.push(match[1] ?? match[2] ?? match[3]); previous = words.lastIndex;
375
+ }
376
+ if (!valid || command.slice(previous).trim() || tokens[1] !== 'memory') continue;
377
+ try { if (!path.isAbsolute(tokens[0]) || fs.realpathSync.native(tokens[0]) !== executable) continue; } catch { continue; }
378
+ const options = tokens[2] === 'store' ? ['--path', '-p', '--namespace', '-n', '--key', '-k', '--value']
379
+ : tokens[2] === 'retrieve' ? ['--path', '-p', '--namespace', '-n', '--key', '-k']
380
+ : tokens[2] === 'init' ? ['--path', '-p'] : [];
381
+ for (let i = 3; i < tokens.length; i += 2) {
382
+ if (!options.includes(tokens[i]) || !tokens[i + 1] || tokens[i + 1].startsWith('-')) { valid = false; break; }
323
383
  }
384
+ if (!valid) continue;
385
+ const flag = names => {
386
+ const positions = tokens.flatMap((token, i) => names.includes(token) ? [i] : []);
387
+ return positions.length === 1 ? tokens[positions[0] + 1] : undefined;
388
+ };
389
+ const db = flag(['--path', '-p']);
390
+ if (!db || !path.isAbsolute(db) || db.split(/[\\/]/).includes('..') || path.resolve(db) !== target) continue;
391
+ const namespace = flag(['--namespace', '-n']);
392
+ const captureRegistration = tokens[2] === 'store' && flag(['--key', '-k']) && flag(['--value'])
393
+ && ((event === 'Stop' && namespace === 'turns') || (['PreCompact', 'SessionEnd'].includes(event) && namespace === 'sessions'));
394
+ const ensureRegistration = event === 'SessionStart' && ['init', 'retrieve'].includes(tokens[2]);
395
+ if (captureRegistration || ensureRegistration) found.collisionCandidate = true;
324
396
  }
325
397
  }
398
+ if (found.collisionCandidate) {
399
+ found.target = target; found.event = event;
400
+ found.reason = 'canonical registration alone does not prove capture of the current turn/session';
401
+ }
326
402
  return found;
327
403
  }
@@ -183,7 +183,7 @@ export const CONTINUITY_EVENTS = Object.freeze({
183
183
  registration('grounding-turn-gate', '*', ['claude', 'codex']),
184
184
  ]),
185
185
  PreCompact: Object.freeze([
186
- registration('session-snapshot', '*', ['claude']),
186
+ registration('session-snapshot', '*', ['claude', 'codex']),
187
187
  ]),
188
188
  SessionEnd: Object.freeze([
189
189
  registration('learn-flush', '*', ['claude', 'codex']),
@@ -15,6 +15,7 @@
15
15
  import fs from 'node:fs';
16
16
  import os from 'node:os';
17
17
  import path from 'node:path';
18
+ import { randomUUID } from 'node:crypto';
18
19
  import { spawn, spawnSync } from 'node:child_process';
19
20
  import { fileURLToPath } from 'node:url';
20
21
  import { digestCanonical } from './project-progression-contract.mjs';
@@ -56,7 +57,8 @@ const ms = (iso) => Date.parse(iso || '') || 0;
56
57
 
57
58
  export class ContinuityJournal {
58
59
  /** `ruflo`: the binary (string), null = not installed, undefined = resolve it. */
59
- constructor({ projectRoot, projectDir = projectRoot, env = process.env, fsync = fs.fsyncSync, now = Date.now, ruflo } = {}) {
60
+ constructor({ projectRoot, projectDir = projectRoot, env = process.env, fsync = fs.fsyncSync, now = Date.now, ruflo,
61
+ deadlineAt = Infinity, signal } = {}) {
60
62
  if (typeof projectRoot !== 'string' || !projectRoot) throw new TypeError('projectRoot is required');
61
63
  this.projectRoot = projectRoot;
62
64
  this.projectDir = projectDir;
@@ -67,38 +69,63 @@ export class ContinuityJournal {
67
69
  this.fsync = fsync;
68
70
  this.now = now;
69
71
  this.ruflo = ruflo === undefined ? resolveRuflo() : ruflo;
72
+ this.deadlineAt = deadlineAt;
73
+ this.signal = signal;
74
+ }
75
+
76
+ requireBudget() {
77
+ if (!this.signal && this.deadlineAt === Infinity) return;
78
+ if (this.signal?.aborted || Date.now() >= this.deadlineAt) throw new Error('continuity capture deadline exceeded or aborted; unavailable');
70
79
  }
71
80
 
72
81
  /** Short exclusive section shared by every appender and the compactor (never held across a store call). */
73
82
  withAppendLock(fn) {
74
83
  const lock = path.join(this.swarm, APPEND_LOCK_NAME);
75
- const deadline = Date.now() + APPEND_LOCK_WAIT_MS;
84
+ this.requireBudget();
85
+ const deadline = Math.min(Date.now() + APPEND_LOCK_WAIT_MS, this.deadlineAt);
86
+ const token = `${process.pid} ${Date.now()} ${randomUUID()}\n`;
87
+ const owns = () => {
88
+ try { return !fs.lstatSync(lock).isSymbolicLink() && fs.readFileSync(lock, 'utf8') === token; } catch { return false; }
89
+ };
76
90
  let held = false;
77
91
  // Only EEXIST is retried, and every iteration is deadline-bounded: EACCES/EROFS/ENOSPC used to spin at 100% CPU
78
92
  // forever (re-review B2). Any other error is the outbox's own failure: thrown, reported as "outbox write failed".
79
93
  for (;;) {
80
- try { fs.writeFileSync(lock, `${process.pid} ${Date.now()}\n`, { flag: 'wx', mode: 0o600 }); held = true; break; } catch (error) {
94
+ this.requireBudget();
95
+ try { fs.writeFileSync(lock, token, { flag: 'wx', mode: 0o600 }); held = true; break; } catch (error) {
81
96
  if (error?.code !== 'EEXIST') throw error;
82
97
  }
83
98
  let stale = false;
84
- try { stale = Date.now() - fs.statSync(lock).mtimeMs > APPEND_LOCK_STALE_MS; } catch { /* vanished: retry once more below */ }
99
+ try {
100
+ const st = fs.lstatSync(lock), body = fs.readFileSync(lock, 'utf8');
101
+ const pid = Number(body.trim().split(' ')[0]);
102
+ let dead = false;
103
+ if (Number.isSafeInteger(pid) && pid > 0) try { process.kill(pid, 0); } catch (error) { dead = error.code === 'ESRCH'; }
104
+ stale = !st.isSymbolicLink() && dead && Date.now() - st.mtimeMs > APPEND_LOCK_STALE_MS
105
+ && fs.readFileSync(lock, 'utf8') === body;
106
+ } catch { /* unavailable ownership is not permission to remove a lock */ }
85
107
  let removed = false; // a stale lock we cannot remove is waited out ASLEEP, never spun on (re-review a6 NIT)
86
108
  if (stale) { try { fs.rmSync(lock, { force: true }); removed = true; } catch { /* not ours to remove: wait it out */ } }
87
- // Waited long enough: append anyway (durability first); the compactor's size re-check keeps it from being lost.
88
- if (Date.now() >= deadline) break;
109
+ if (Date.now() >= deadline) throw Object.assign(new Error('outbox append lock unavailable; mutation deferred'), { code: 'EAGAIN' });
89
110
  if (!removed) pause(5);
90
111
  }
91
- try { return fn(); } finally { if (held) fs.rmSync(lock, { force: true }); }
112
+ try {
113
+ if (!owns()) throw Object.assign(new Error('outbox append lock ownership changed; mutation deferred'), { code: 'EAGAIN' });
114
+ return fn(owns);
115
+ } finally { if (held && owns()) fs.rmSync(lock, { force: true }); }
92
116
  }
93
117
 
94
118
  /** Append records with ONE fsync. Refuses to create `.swarm` (absence = project did not adopt). */
95
119
  appendRecords(records) {
120
+ this.requireBudget();
96
121
  if (!records.length) return 0;
97
122
  const stat = fs.lstatSync(this.swarm);
98
123
  if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('.swarm is not a real directory');
99
- return this.withAppendLock(() => {
124
+ return this.withAppendLock((owns) => {
100
125
  const fd = fs.openSync(this.path, fs.constants.O_APPEND | fs.constants.O_CREAT | fs.constants.O_WRONLY | (fs.constants.O_NOFOLLOW ?? 0), 0o600);
101
126
  try {
127
+ this.requireBudget();
128
+ if (!owns()) throw Object.assign(new Error('outbox append lock ownership changed; mutation deferred'), { code: 'EAGAIN' });
102
129
  fs.writeSync(fd, records.map((r) => `${JSON.stringify(r)}\n`).join(''));
103
130
  this.fsync(fd);
104
131
  } finally { fs.closeSync(fd); }
@@ -108,6 +135,7 @@ export class ContinuityJournal {
108
135
 
109
136
  /** Every line, tolerant: unparseable lines are counted, never thrown. */
110
137
  scan() {
138
+ this.requireBudget();
111
139
  let text = '';
112
140
  try { text = fs.readFileSync(this.path, 'utf8'); } catch { /* no outbox yet */ }
113
141
  const events = new Map();
@@ -119,6 +147,7 @@ export class ContinuityJournal {
119
147
  let corrupt = 0;
120
148
  let lines = 0;
121
149
  for (const line of text.split('\n')) {
150
+ this.requireBudget();
122
151
  if (!line.trim()) continue;
123
152
  lines += 1;
124
153
  let rec;
@@ -146,25 +175,35 @@ export class ContinuityJournal {
146
175
 
147
176
  /** `kind:id` of every event already journalled here or committed to the store (dedupe set). */
148
177
  knownIds(scan = this.scan()) {
178
+ this.requireBudget();
149
179
  const ids = new Set();
150
180
  for (const key of scan.events.keys()) { const id = eventIdOf(key); if (id) ids.add(id); }
151
181
  if (storeReady(this.db)) {
152
- const listed = withProgressionReader(this.db, (reader) => reader.listKeys(CONTINUITY_NAMESPACE, { maxEntries: 200_000 }));
153
- if (listed.ok) for (const key of listed.value) { const id = eventIdOf(key); if (id) ids.add(id); }
182
+ const listed = withProgressionReader(this.db, (reader) => reader.listKeys(CONTINUITY_NAMESPACE, { maxEntries: 200_000 }),
183
+ { deadlineAt: this.deadlineAt, signal: this.signal });
184
+ this.requireBudget();
185
+ if (!listed.ok) throw new Error(`continuity deduplication unavailable: ${listed.reason}`);
186
+ for (const key of listed.value) { this.requireBudget(); const id = eventIdOf(key); if (id) ids.add(id); }
154
187
  }
155
188
  return ids;
156
189
  }
157
190
 
158
191
  captureConsent(projectDir = this.projectDir, unknownOriginalPath = false) {
159
- return resolveTurnDb({ projectDir, brainHome: this.brainHome, requestedStorePath: this.db, unknownOriginalPath });
192
+ this.requireBudget();
193
+ const result = resolveTurnDb({ projectDir, brainHome: this.brainHome, requestedStorePath: this.db, unknownOriginalPath,
194
+ deadlineAt: this.deadlineAt, signal: this.signal, gitTimeoutMs: Math.max(1, Math.min(1000, this.deadlineAt - Date.now())) });
195
+ this.requireBudget();
196
+ return result;
160
197
  }
161
198
 
162
199
  /** Journal the events this boundary observed that are not already known. Returns what was added. */
163
200
  record(events) {
201
+ this.requireBudget();
164
202
  const consent = this.captureConsent();
165
203
  if (consent.skipped) throw new Error(`event capture suspended: ${consent.skipped}`);
166
204
  const known = this.knownIds(); const fresh = [];
167
205
  for (const original of events) { const event = privateContinuityEvent(original, consent.contentPathExcludes, consent.capturePath);
206
+ this.requireBudget();
168
207
  const id = `${event.kind}:${event.id}`;
169
208
  if (known.has(id)) continue;
170
209
  known.add(id);
@@ -189,7 +228,7 @@ export class ContinuityJournal {
189
228
  if (failureLines > scan.failures.size) return true;
190
229
  for (const c of scan.committed.values()) if (now - ms(c.committedAt) > RETAIN_COMMITTED_MS) return true;
191
230
  if (scan.notices.some((n) => now - ms(n.at) > QUARANTINE_REPORT_MS || ms(n.at) <= scan.clearedAt)) return true;
192
- if ([...scan.quarantined.values()].some((q) => now - ms(q.at) > QUARANTINE_REPORT_MS)) return true;
231
+ // Quarantine warnings expire in status(); disputed original bytes do not expire here.
193
232
  // An unshrinkable pending backlog must not be rewritten on every capture boundary.
194
233
  return scan.events.size > MAX_EVENT_RECORDS && [...scan.events.keys()].some((key) => scan.committed.has(key));
195
234
  }
@@ -200,17 +239,15 @@ export class ContinuityJournal {
200
239
  */
201
240
  compact({ beforeRename = null } = {}) {
202
241
  const now = this.now();
203
- return this.withAppendLock(() => {
242
+ return this.withAppendLock((owns) => {
204
243
  let before;
205
244
  try { before = fs.statSync(this.path); } catch { return { compacted: false, reason: 'no outbox' }; }
206
245
  const scan = this.scan();
207
246
  const out = [];
208
- const quarantineLive = (q) => now - ms(q.at) <= QUARANTINE_REPORT_MS;
209
247
  const keep = [...scan.events.values()].filter((rec) => {
210
248
  const c = scan.committed.get(rec.key);
211
249
  if (c) return now - ms(c.committedAt) <= RETAIN_COMMITTED_MS;
212
- const q = scan.quarantined.get(rec.key);
213
- return q ? quarantineLive(q) : true;
250
+ return true; // pending and disputed originals are both durable recovery evidence
214
251
  }).sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
215
252
  // Only read-back committed events may leave at the cap. Pending bytes are the recovery copy.
216
253
  const gone = new Set(keep.filter((r) => scan.committed.has(r.key)).slice(0, Math.max(0, keep.length - MAX_EVENT_RECORDS)));
@@ -238,6 +275,11 @@ export class ContinuityJournal {
238
275
  const fd = fs.openSync(tmp, 'w', 0o600);
239
276
  try { fs.writeSync(fd, out.map((r) => `${JSON.stringify(r)}\n`).join('')); this.fsync(fd); } finally { fs.closeSync(fd); }
240
277
  beforeRename?.();
278
+ this.requireBudget();
279
+ if (!owns()) {
280
+ fs.rmSync(tmp, { force: true });
281
+ return { compacted: false, reason: 'outbox append lock ownership changed; compaction deferred' };
282
+ }
241
283
  let now_;
242
284
  try { now_ = fs.statSync(this.path); } catch { now_ = null; }
243
285
  if (!now_ || now_.size !== before.size || now_.mtimeMs !== before.mtimeMs) {
@@ -266,7 +308,8 @@ export class ContinuityJournal {
266
308
  const ready = storeReady(this.db);
267
309
  const notApplicable = !ready ? 'no AgentDB store in this project (.swarm/memory.db is not initialized)'
268
310
  : !this.ruflo ? 'ruflo is not installed' : null;
269
- const capacityPressure = pending.length > MAX_EVENT_RECORDS;
311
+ const retainedQuarantined = [...scan.quarantined.keys()].filter(key => scan.events.has(key) && !scan.committed.has(key)).length;
312
+ const capacityPressure = pending.length + retainedQuarantined > MAX_EVENT_RECORDS;
270
313
  const problem = quarantined.length ? 'quarantined' : corrupt ? 'corrupt' : dropped ? 'dropped'
271
314
  : !notApplicable && capacityPressure ? 'capacity-pressure' : !notApplicable && oldest !== null && now - oldest > STUCK_AFTER_MS ? 'stuck-pending' : null;
272
315
  return {
@@ -274,7 +317,7 @@ export class ContinuityJournal {
274
317
  applicable: !notApplicable, notApplicable,
275
318
  pending: pending.length, oldestPendingAt: oldest, lastCommitAt, eventsToday,
276
319
  lastFailure: lastFailure && (!lastCommitAt || ms(lastFailure.at) > lastCommitAt) ? lastFailure : null,
277
- quarantined, corrupt, dropped, capacityPressure, problem, stuck: Boolean(problem),
320
+ quarantined, retainedQuarantined, corrupt, dropped, capacityPressure, problem, stuck: Boolean(problem),
278
321
  };
279
322
  }
280
323
  }
@@ -289,11 +332,12 @@ export function recordingLine(status, now = Date.now()) {
289
332
  const why = status.problem === 'quarantined' ? `${status.quarantined.length} quarantined (a different row holds its key; never retried — ${clears})`
290
333
  : status.problem === 'corrupt' ? `${status.corrupt} corrupt outbox line(s) removed (${clears})`
291
334
  : status.problem === 'dropped' ? `${status.dropped} uncommitted event(s) dropped at the outbox cap (${clears})`
292
- : status.problem === 'capacity-pressure' ? `pending backlog exceeds soft limit ${MAX_EVENT_RECORDS}; no pending events discarded`
335
+ : status.problem === 'capacity-pressure' ? `retained backlog exceeds soft limit ${MAX_EVENT_RECORDS}; no pending or disputed events discarded`
293
336
  : status.lastFailure ? `last error: ${status.lastFailure.reason || status.lastFailure.error}` : 'not committing';
294
337
  return `AgentDB: recording stuck — ${status.pending} event(s) pending${status.oldestPendingAt && status.problem === 'stuck-pending' ? ` for ${ago(now - status.oldestPendingAt)}` : ''}, ${why}.`
295
338
  + ` Pending events are durable in ${status.outbox} and retry at every capture boundary.`;
296
339
  }
340
+ if (status.retainedQuarantined) return `AgentDB: recording unresolved — ${status.retainedQuarantined} disputed original event(s) retained, not committed; ${status.pending} event(s) pending. No disputed original is deleted automatically.`;
297
341
  if (status.notApplicable) {
298
342
  return `AgentDB: recording n/a — ${status.notApplicable}; ${status.pending} event(s) wait in the outbox (not a failure).`
299
343
  + (status.capacityPressure ? ` Capacity pressure: pending backlog exceeds soft limit ${MAX_EVENT_RECORDS}; no pending events discarded.` : '');
@@ -334,20 +378,15 @@ function defaultStore({ ruflo, db, key, value }) {
334
378
 
335
379
  function defaultReadBack({ ruflo, db, key }) {
336
380
  const fast = withProgressionReader(db, (reader) => reader.readContent(CONTINUITY_NAMESPACE, key));
337
- if (fast.ok) return { content: fast.value, readPath: 'node:sqlite' };
381
+ if (fast.ok) return { key, content: fast.value, readPath: 'node:sqlite' };
338
382
  const cwd = rufloRunDir(db);
339
383
  try {
340
384
  const { executable, args } = rufloInvocation(ruflo, ['memory', 'retrieve', '--key', key, '--namespace', CONTINUITY_NAMESPACE, '--value-only', '--path', db]);
341
385
  const r = spawnSync(executable, args, { cwd, encoding: 'utf8', timeout: 60_000, windowsHide: true, env: { ...process.env, RUFLO_DAEMON_AUTOSTART: '0' } });
342
- return { content: r.status === 0 ? String(r.stdout || '') : null, readPath: `ruflo-cli (${fast.reason})` };
386
+ return { key, content: r.status === 0 ? String(r.stdout || '') : null, readPath: `ruflo-cli (${fast.reason})` };
343
387
  } finally { fs.rmSync(cwd, { recursive: true, force: true }); }
344
388
  }
345
389
 
346
- /** Is a stored row the SAME event (same kind and content id), whoever observed it? */
347
- const sameEvent = (content, event) => {
348
- try { const row = JSON.parse(content); return row?.kind === event.kind && row?.id === event.id; } catch { return false; }
349
- };
350
-
351
390
  /**
352
391
  * Commit every pending event: store → exact read-back → commit line. A refusal is retried with
353
392
  * backoff while the budget lasts; nothing is ever dropped. Failures are ONE record per event per run
@@ -378,7 +417,7 @@ export function drain(journal, {
378
417
  attempts += 1; const result = store({ ruflo, db: journal.db, key: rec.key, value });
379
418
  const back = readBack({ ruflo, db: journal.db, key: rec.key });
380
419
  const content = typeof back.content === 'string' ? back.content.trim() : '';
381
- if (content && (content === value || value === JSON.stringify(rec.event) && sameEvent(content, rec.event))) {
420
+ if (content === value && digestCanonical(rec.event) === rec.digest && (back.key === undefined || back.key === rec.key)) {
382
421
  journal.appendRecords([{ type: 'commit', key: rec.key, digest: rec.digest, committedAt: new Date(now()).toISOString(), readPath: back.readPath,
383
422
  alreadyStored: result.status !== 0 || content !== value, ...(attempts > 1 ? { attempts, lastError: last?.reason } : {}) }]);
384
423
  committed += 1;
@@ -443,13 +482,18 @@ export function launchDrain({ projectRoot, spawnFn = spawn, env = process.env }
443
482
  export function captureContinuityEvents({
444
483
  projectDir, event, payload = {}, host = 'claude', env = process.env, ruflo = resolveRuflo({ env }),
445
484
  readTranscript = (file) => readSettledTranscript(file, { maxMs: 0 }), launch = launchDrain, now = Date.now,
485
+ deadlineAt = Infinity, signal,
446
486
  } = {}) {
447
487
  const report = { event, recorded: 0, launched: false };
448
488
  if (String(env.RUVNET_CONTINUITY_CAPTURE || '').toLowerCase() === 'off') return { ...report, skipped: 'RUVNET_CONTINUITY_CAPTURE=off' };
489
+ try {
490
+ if (signal?.aborted || Date.now() >= deadlineAt) throw new Error('continuity capture unavailable');
449
491
  let resolution;
450
- try { resolution = resolveProjectStore({ projectDir }); } catch { return { ...report, skipped: 'project store could not be resolved' }; }
451
- const journal = new ContinuityJournal({ projectRoot: resolution.projectRoot, projectDir, env, now, ruflo });
492
+ try { resolution = resolveProjectStore({ projectDir, deadlineAt, signal,
493
+ gitTimeoutMs: Math.max(1, Math.min(1000, deadlineAt - Date.now())) }); } catch { return { ...report, skipped: 'project store could not be resolved' }; }
494
+ const journal = new ContinuityJournal({ projectRoot: resolution.projectRoot, projectDir, env, now, ruflo, deadlineAt, signal });
452
495
  try {
496
+ journal.requireBudget();
453
497
  const consent = journal.captureConsent();
454
498
  if (consent.skipped) return { ...report, skipped: consent.skipped.startsWith('no project memory db') ? `not applicable: ${consent.skipped}` : consent.skipped };
455
499
  } catch (error) { return { ...report, skipped: `capture consent unavailable: ${error.message}` }; }
@@ -483,6 +527,10 @@ export function captureContinuityEvents({
483
527
  // A drainer only where it can succeed: an initialized store AND a ruflo to write it (review S4).
484
528
  if (status.pending && status.applicable) report.launched = launch({ projectRoot: resolution.projectRoot, env });
485
529
  return { ...report, status, journal };
530
+ } catch {
531
+ return { ...report, queued: report.recorded > 0, skipped: signal?.aborted ? 'continuity capture aborted; unavailable'
532
+ : Date.now() >= deadlineAt ? 'continuity capture deadline exceeded; unavailable' : 'canonical continuity unavailable' };
533
+ }
486
534
  }
487
535
 
488
536
  /** The detached worker body. */