ruvnet-brain 4.4.1 → 4.5.0

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 (102) hide show
  1. package/README.md +3 -3
  2. package/bin/install.mjs +612 -113
  3. package/console/app.js +178 -81
  4. package/console/index.html +1 -1
  5. package/console/install-architecture.html +1 -0
  6. package/console/scope.css +4 -1
  7. package/console/style.css +13 -0
  8. package/console/tips.html +4 -4
  9. package/kb/brain-profile.mjs +1 -0
  10. package/kb/corpus-release-identity.mjs +1 -1
  11. package/kb/forge-update.mjs +41 -17
  12. package/kb/model-requirements.mjs +4 -1
  13. package/kb/update-storage-transaction.mjs +79 -0
  14. package/kb/zip-extract.mjs +22 -0
  15. package/package.json +1 -1
  16. package/plugin/.claude-plugin/plugin.json +1 -1
  17. package/plugin/.codex-plugin/plugin.json +1 -1
  18. package/plugin/commands/brain-console.md +5 -4
  19. package/plugin/commands/configure.md +5 -4
  20. package/plugin/commands/rnb-brief.md +41 -0
  21. package/plugin/commands/rnb.md +80 -0
  22. package/plugin/commands/rnbc.md +80 -0
  23. package/plugin/commands/rvbc.md +5 -4
  24. package/plugin/commands/rvcb.md +5 -4
  25. package/plugin/commands/whats-new.md +4 -4
  26. package/plugin/mcp/server.mjs +10 -2
  27. package/plugin/scripts/advocacy-route.mjs +59 -23
  28. package/plugin/scripts/anticipate.sh +4 -0
  29. package/plugin/scripts/brain-confirmation.mjs +258 -0
  30. package/plugin/scripts/brain-footprint.mjs +494 -0
  31. package/plugin/scripts/brain-location.mjs +47 -0
  32. package/plugin/scripts/capability-registry.mjs +11 -1
  33. package/plugin/scripts/continuity-brief.mjs +324 -0
  34. package/plugin/scripts/continuity-events.mjs +327 -0
  35. package/plugin/scripts/continuity-journal.mjs +500 -0
  36. package/plugin/scripts/decision-gate.mjs +56 -3
  37. package/plugin/scripts/footprint-io.mjs +186 -0
  38. package/plugin/scripts/ground-before-write.sh +8 -1
  39. package/plugin/scripts/ground-ruvnet.sh +103 -12
  40. package/plugin/scripts/grounding-answer.mjs +2 -1
  41. package/plugin/scripts/grounding-stamp.sh +3 -0
  42. package/plugin/scripts/grounding-substance.mjs +1 -1
  43. package/plugin/scripts/grounding-turn-evidence.mjs +17 -2
  44. package/plugin/scripts/hook-input.mjs +78 -4
  45. package/plugin/scripts/kb-copy-proof.mjs +148 -0
  46. package/plugin/scripts/lesson-bridge.mjs +6 -2
  47. package/plugin/scripts/nightly-controller.mjs +8 -1
  48. package/plugin/scripts/node-sqlite.mjs +41 -0
  49. package/plugin/scripts/package-cards.json +797 -0
  50. package/plugin/scripts/package-cards.rvf +0 -0
  51. package/plugin/scripts/package-cards.rvf.idmap.json +1 -0
  52. package/plugin/scripts/package-cards.rvf.meta.json +1 -0
  53. package/plugin/scripts/package-recommender-client.mjs +138 -0
  54. package/plugin/scripts/package-recommender-flag.mjs +30 -0
  55. package/plugin/scripts/package-recommender.mjs +391 -0
  56. package/plugin/scripts/project-progression-outbox.mjs +26 -8
  57. package/plugin/scripts/project-progression-reader.mjs +4 -2
  58. package/plugin/scripts/protect-brain-state.sh +4 -1
  59. package/plugin/scripts/session-snapshot-hook.mjs +27 -3
  60. package/plugin/scripts/session-start-budget.mjs +1 -0
  61. package/plugin/scripts/session-start-core.mjs +34 -4
  62. package/plugin/scripts/session-start-health.mjs +7 -1
  63. package/plugin/scripts/session-start-update-plane.mjs +35 -0
  64. package/plugin/scripts/turn-outcome-capture.mjs +12 -1
  65. package/plugin/scripts/unprompted-runtime.mjs +2 -2
  66. package/plugin/skills/brain-console/SKILL.md +3 -3
  67. package/plugin/skills/rnbc/SKILL.md +24 -0
  68. package/plugin/skills/rvbc/SKILL.md +2 -2
  69. package/scripts/approved-runtime.mjs +2 -2
  70. package/scripts/ci/warm-brain-models.mjs +28 -0
  71. package/scripts/codex-hook-trust.mjs +94 -0
  72. package/scripts/console-runtime-identity.mjs +5 -0
  73. package/scripts/corpus-canary.mjs +46 -6
  74. package/scripts/corpus-dispatch-decision.mjs +2 -2
  75. package/scripts/corpus-promotion.mjs +1 -1
  76. package/scripts/hook-qualify-hosts.mjs +15 -3
  77. package/scripts/host-install-matrix.mjs +63 -2
  78. package/scripts/human-approval-phrases.mjs +46 -0
  79. package/scripts/installed-brain-health.mjs +53 -0
  80. package/scripts/move-brain.mjs +310 -0
  81. package/scripts/onboarding-console.mjs +93 -10
  82. package/scripts/oracle/abstain-threshold-sweep.mjs +62 -0
  83. package/scripts/oracle/abstain-trace.mjs +139 -0
  84. package/scripts/oracle/doc2query-generate.mjs +162 -0
  85. package/scripts/oracle/doc2query-reach.mjs +110 -0
  86. package/scripts/oracle/judge-train.mjs +158 -0
  87. package/scripts/oracle/need-set-split.mjs +48 -0
  88. package/scripts/oracle/sona-query-adapter-eval.mjs +139 -0
  89. package/scripts/package-cards.mjs +374 -0
  90. package/scripts/publication-receipt.mjs +37 -9
  91. package/scripts/recommendation-e2e.mjs +110 -0
  92. package/scripts/recommendation-eval.mjs +105 -0
  93. package/scripts/recommendation-floor.mjs +56 -0
  94. package/scripts/recommendation-judge-score.mjs +74 -0
  95. package/scripts/recommendation-latency.mjs +95 -0
  96. package/scripts/recommendation-real-host-score.mjs +76 -0
  97. package/scripts/recommendation-real-host.mjs +137 -0
  98. package/scripts/release-channel-kind.mjs +1 -1
  99. package/scripts/release-environment-policy.mjs +33 -0
  100. package/scripts/single-source-check.mjs +15 -10
  101. package/scripts/sync-commands.mjs +5 -2
  102. package/scripts/wired-check.mjs +17 -2
@@ -0,0 +1,500 @@
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:
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, and a hard cap of MAX_EVENT_RECORDS drops the oldest, reported.
30
+ */
31
+ import fs from 'node:fs';
32
+ import os from 'node:os';
33
+ import path from 'node:path';
34
+ import { spawn, spawnSync } from 'node:child_process';
35
+ import { fileURLToPath } from 'node:url';
36
+ import { digestCanonical } from './project-progression-contract.mjs';
37
+ import { withProgressionReader } from './project-progression-reader.mjs';
38
+ import { rufloRunDir } from './project-progression-store.mjs';
39
+ import { resolveRuflo, rufloInvocation } from './ruflo-bin.mjs';
40
+ import { resolveProjectStore } from './project-store-resolver.mjs';
41
+ import { readSettledTranscript } from './turn-outcome-capture.mjs';
42
+ import {
43
+ CONTINUITY_NAMESPACE, INITIAL_LOOKBACK_MS, collectCommits, collectReleases, collectTurnEvents, eventIdOf, eventKey,
44
+ } from './continuity-events.mjs';
45
+
46
+ export const OUTBOX_NAME = 'continuity-events-outbox.jsonl';
47
+ const LOCK_NAME = '.continuity-events.lock';
48
+ const APPEND_LOCK_NAME = '.continuity-events-outbox.lock';
49
+ const NOTICE_STATE = '.continuity-stop-notices.json';
50
+ export const DRAIN_BUDGET_MS = 90_000;
51
+ export const RETRY_BACKOFF_MS = Object.freeze([1_000, 3_000, 8_000, 20_000]);
52
+ /** Pending longer than this is no longer "in flight": it is stuck, and the user is told. */
53
+ export const STUCK_AFTER_MS = 10 * 60_000;
54
+ export const QUARANTINE_REPORT_MS = 7 * 86_400_000;
55
+ export const RETAIN_COMMITTED_MS = 7 * 86_400_000;
56
+ export const MAX_EVENT_RECORDS = 2_000;
57
+ const [COMPACT_AT_BYTES, LOCK_STALE_MS, APPEND_LOCK_WAIT_MS, APPEND_LOCK_STALE_MS] = [256 * 1024, 3 * 60_000, 1_000, 30_000];
58
+ const WAL_REFUSAL = /refusing an unsafe sql\.js|active native WAL|database is locked|SQLITE_BUSY/i;
59
+ export const CLEAR_COMMAND = `node "${path.join(path.dirname(fileURLToPath(import.meta.url)), 'continuity-brief.mjs')}" --clear`;
60
+
61
+ const SQLITE_HEADER = Buffer.concat([Buffer.from('SQLite format 3', 'latin1'), Buffer.of(0)]);
62
+ /** Is `db` an initialized SQLite store (not absent, not an empty placeholder)? */
63
+ export function storeReady(db) {
64
+ try {
65
+ const fd = fs.openSync(db, 'r');
66
+ try { const head = Buffer.alloc(16); return fs.readSync(fd, head, 0, 16, 0) === 16 && head.equals(SQLITE_HEADER); } finally { fs.closeSync(fd); }
67
+ } catch { return false; }
68
+ }
69
+ const pause = (t) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, t);
70
+ const ms = (iso) => Date.parse(iso || '') || 0;
71
+
72
+ export class ContinuityJournal {
73
+ /** `ruflo`: the binary (string), null = not installed, undefined = resolve it. */
74
+ constructor({ projectRoot, fsync = fs.fsyncSync, now = Date.now, ruflo } = {}) {
75
+ if (typeof projectRoot !== 'string' || !projectRoot) throw new TypeError('projectRoot is required');
76
+ this.projectRoot = projectRoot;
77
+ this.swarm = path.join(projectRoot, '.swarm');
78
+ this.path = path.join(this.swarm, OUTBOX_NAME);
79
+ this.db = path.join(this.swarm, 'memory.db');
80
+ this.fsync = fsync;
81
+ this.now = now;
82
+ this.ruflo = ruflo === undefined ? resolveRuflo() : ruflo;
83
+ }
84
+
85
+ /** Short exclusive section shared by every appender and the compactor (never held across a store call). */
86
+ withAppendLock(fn) {
87
+ const lock = path.join(this.swarm, APPEND_LOCK_NAME);
88
+ const deadline = Date.now() + APPEND_LOCK_WAIT_MS;
89
+ let held = false;
90
+ // Only EEXIST is retried, and every iteration is deadline-bounded: EACCES/EROFS/ENOSPC used to spin at 100% CPU
91
+ // forever (re-review B2). Any other error is the outbox's own failure: thrown, reported as "outbox write failed".
92
+ for (;;) {
93
+ try { fs.writeFileSync(lock, `${process.pid} ${Date.now()}\n`, { flag: 'wx', mode: 0o600 }); held = true; break; } catch (error) {
94
+ if (error?.code !== 'EEXIST') throw error;
95
+ }
96
+ let stale = false;
97
+ try { stale = Date.now() - fs.statSync(lock).mtimeMs > APPEND_LOCK_STALE_MS; } catch { /* vanished: retry once more below */ }
98
+ let removed = false; // a stale lock we cannot remove is waited out ASLEEP, never spun on (re-review a6 NIT)
99
+ if (stale) { try { fs.rmSync(lock, { force: true }); removed = true; } catch { /* not ours to remove: wait it out */ } }
100
+ // Waited long enough: append anyway (durability first); the compactor's size re-check keeps it from being lost.
101
+ if (Date.now() >= deadline) break;
102
+ if (!removed) pause(5);
103
+ }
104
+ try { return fn(); } finally { if (held) fs.rmSync(lock, { force: true }); }
105
+ }
106
+
107
+ /** Append records with ONE fsync. Refuses to create `.swarm` (absence = project did not adopt). */
108
+ appendRecords(records) {
109
+ if (!records.length) return 0;
110
+ const stat = fs.lstatSync(this.swarm);
111
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('.swarm is not a real directory');
112
+ return this.withAppendLock(() => {
113
+ const fd = fs.openSync(this.path, fs.constants.O_APPEND | fs.constants.O_CREAT | fs.constants.O_WRONLY | (fs.constants.O_NOFOLLOW ?? 0), 0o600);
114
+ try {
115
+ fs.writeSync(fd, records.map((r) => `${JSON.stringify(r)}\n`).join(''));
116
+ this.fsync(fd);
117
+ } finally { fs.closeSync(fd); }
118
+ return records.length;
119
+ });
120
+ }
121
+
122
+ /** Every line, tolerant: unparseable lines are counted, never thrown. */
123
+ scan() {
124
+ let text = '';
125
+ try { text = fs.readFileSync(this.path, 'utf8'); } catch { /* no outbox yet */ }
126
+ const events = new Map();
127
+ const committed = new Map();
128
+ const failures = new Map();
129
+ const quarantined = new Map();
130
+ const notices = [];
131
+ let clearedAt = 0;
132
+ let corrupt = 0;
133
+ let lines = 0;
134
+ for (const line of text.split('\n')) {
135
+ if (!line.trim()) continue;
136
+ lines += 1;
137
+ let rec;
138
+ try { rec = JSON.parse(line); } catch { corrupt += 1; continue; }
139
+ if (rec?.type === 'event' && typeof rec.key === 'string' && typeof rec.digest === 'string' && rec.event) {
140
+ if (!events.has(rec.key)) events.set(rec.key, rec); // a second observation of the same event: first wins
141
+ } else if (rec?.type === 'commit' && typeof rec.key === 'string') committed.set(rec.key, rec);
142
+ else if (rec?.type === 'failure' && typeof rec.key === 'string') {
143
+ const prior = failures.get(rec.key);
144
+ failures.set(rec.key, { ...rec, attempts: (prior?.attempts || 0) + (Number(rec.attempts) || 1) });
145
+ } else if (rec?.type === 'quarantine' && typeof rec.key === 'string') quarantined.set(rec.key, rec);
146
+ else if (rec?.type === 'notice') notices.push(rec);
147
+ else if (rec?.type === 'cleared') clearedAt = Math.max(clearedAt, ms(rec.at));
148
+ else corrupt += 1;
149
+ }
150
+ return { events, committed, failures, quarantined, notices, clearedAt, corrupt, lines, bytes: Buffer.byteLength(text) };
151
+ }
152
+
153
+ /** Events fsynced but not yet read back from AgentDB, oldest key first. A quarantined key never retries. */
154
+ pending(scan = this.scan()) {
155
+ return [...scan.events.values()]
156
+ .filter((rec) => !scan.committed.has(rec.key) && !scan.quarantined.has(rec.key))
157
+ .sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
158
+ }
159
+
160
+ /** `kind:id` of every event already journalled here or committed to the store (dedupe set). */
161
+ knownIds(scan = this.scan()) {
162
+ const ids = new Set();
163
+ for (const key of scan.events.keys()) { const id = eventIdOf(key); if (id) ids.add(id); }
164
+ if (storeReady(this.db)) {
165
+ const listed = withProgressionReader(this.db, (reader) => reader.listKeys(CONTINUITY_NAMESPACE, { maxEntries: 200_000 }));
166
+ if (listed.ok) for (const key of listed.value) { const id = eventIdOf(key); if (id) ids.add(id); }
167
+ }
168
+ return ids;
169
+ }
170
+
171
+ /** Journal the events this boundary observed that are not already known. Returns what was added. */
172
+ record(events) {
173
+ const known = this.knownIds();
174
+ const fresh = [];
175
+ for (const event of events) {
176
+ const id = `${event.kind}:${event.id}`;
177
+ if (known.has(id)) continue;
178
+ known.add(id);
179
+ fresh.push({ type: 'event', key: eventKey(event), digest: digestCanonical(event), journaledAt: new Date(this.now()).toISOString(), event });
180
+ }
181
+ this.appendRecords(fresh);
182
+ return fresh;
183
+ }
184
+
185
+ /** The one documented clear: acknowledges every reported quarantine/corrupt/drop up to now. */
186
+ clearProblems() {
187
+ // Compact first, so raw corrupt lines become a notice dated before the acknowledgement.
188
+ try { this.compact(); } catch { /* the acknowledgement still stands for everything recorded so far */ }
189
+ return this.appendRecords([{ type: 'cleared', at: new Date(this.now()).toISOString() }]);
190
+ }
191
+
192
+ /** Does the file carry anything compact() would remove? (cheap: from one scan) */
193
+ needsCompaction(scan = this.scan()) {
194
+ const now = this.now();
195
+ if (scan.corrupt || scan.bytes > COMPACT_AT_BYTES || scan.events.size > MAX_EVENT_RECORDS) return true;
196
+ const failureLines = scan.lines - scan.events.size - scan.committed.size - scan.quarantined.size - scan.notices.length - (scan.clearedAt ? 1 : 0) - scan.corrupt;
197
+ if (failureLines > scan.failures.size) return true;
198
+ for (const c of scan.committed.values()) if (now - ms(c.committedAt) > RETAIN_COMMITTED_MS) return true;
199
+ return false;
200
+ }
201
+
202
+ /**
203
+ * Rewrite the outbox to what still matters, atomically. Never loses an event that is still pending.
204
+ * `beforeRename` is a test seam (an append landing mid-compaction must abort the rewrite, not vanish).
205
+ */
206
+ compact({ beforeRename = null } = {}) {
207
+ const now = this.now();
208
+ return this.withAppendLock(() => {
209
+ let before;
210
+ try { before = fs.statSync(this.path); } catch { return { compacted: false, reason: 'no outbox' }; }
211
+ const scan = this.scan();
212
+ const out = [];
213
+ let dropped = 0;
214
+ const quarantineLive = (q) => now - ms(q.at) <= QUARANTINE_REPORT_MS;
215
+ const keep = [...scan.events.values()].filter((rec) => {
216
+ const c = scan.committed.get(rec.key);
217
+ if (c) return now - ms(c.committedAt) <= RETAIN_COMMITTED_MS;
218
+ const q = scan.quarantined.get(rec.key);
219
+ return q ? quarantineLive(q) : true;
220
+ }).sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
221
+ // The hard cap: committed events go first (the store holds them), then the oldest pending.
222
+ let over = keep.length - MAX_EVENT_RECORDS;
223
+ if (over > 0) {
224
+ const committedFirst = keep.filter((r) => scan.committed.has(r.key)).slice(0, over);
225
+ over -= committedFirst.length;
226
+ const pendingDrop = over > 0 ? keep.filter((r) => !scan.committed.has(r.key)).slice(0, over) : [];
227
+ dropped = pendingDrop.length;
228
+ const gone = new Set([...committedFirst, ...pendingDrop]);
229
+ keep.splice(0, keep.length, ...keep.filter((r) => !gone.has(r)));
230
+ }
231
+ const kept = new Set(keep.map((r) => r.key));
232
+ for (const rec of keep) {
233
+ out.push(rec);
234
+ if (scan.committed.has(rec.key)) out.push(scan.committed.get(rec.key));
235
+ else if (scan.quarantined.has(rec.key)) out.push(scan.quarantined.get(rec.key));
236
+ else if (scan.failures.has(rec.key)) out.push({ ...scan.failures.get(rec.key), type: 'failure' });
237
+ }
238
+ const notice = { corrupt: 0, dropped: 0, at: null };
239
+ for (const n of scan.notices) {
240
+ if (now - ms(n.at) > QUARANTINE_REPORT_MS || ms(n.at) <= scan.clearedAt) continue;
241
+ notice.corrupt += Number(n.corrupt) || 0; notice.dropped += Number(n.dropped) || 0;
242
+ notice.at = notice.at && notice.at < n.at ? notice.at : n.at;
243
+ }
244
+ if (scan.corrupt || dropped) {
245
+ notice.corrupt += scan.corrupt; notice.dropped += dropped;
246
+ notice.at = notice.at || new Date(now).toISOString();
247
+ }
248
+ if (notice.corrupt || notice.dropped) out.push({ type: 'notice', ...notice });
249
+ if (scan.clearedAt) out.push({ type: 'cleared', at: new Date(scan.clearedAt).toISOString() });
250
+ const tmp = `${this.path}.tmp-${process.pid}`;
251
+ const fd = fs.openSync(tmp, 'w', 0o600);
252
+ try { fs.writeSync(fd, out.map((r) => `${JSON.stringify(r)}\n`).join('')); this.fsync(fd); } finally { fs.closeSync(fd); }
253
+ beforeRename?.();
254
+ let now_;
255
+ try { now_ = fs.statSync(this.path); } catch { now_ = null; }
256
+ if (!now_ || now_.size !== before.size || now_.mtimeMs !== before.mtimeMs) {
257
+ fs.rmSync(tmp, { force: true });
258
+ return { compacted: false, reason: 'the outbox changed during compaction; left as is' };
259
+ }
260
+ fs.renameSync(tmp, this.path);
261
+ return { compacted: true, lines: out.length, kept: kept.size, dropped };
262
+ });
263
+ }
264
+
265
+ status(scan = this.scan()) {
266
+ const now = this.now();
267
+ const pending = this.pending(scan);
268
+ const oldest = pending.length ? Math.min(...pending.map((r) => ms(r.journaledAt || r.event.at) || now)) : null;
269
+ const commits = [...scan.committed.values()];
270
+ const lastCommitAt = commits.reduce((max, c) => Math.max(max, ms(c.committedAt)), 0) || null;
271
+ const lastFailure = [...scan.failures.values()].reduce((latest, f) => (!latest || f.at > latest.at ? f : latest), null);
272
+ const dayStart = new Date(now); dayStart.setHours(0, 0, 0, 0);
273
+ const eventsToday = commits.filter((c) => ms(c.committedAt) >= dayStart.getTime()).length;
274
+ const reportable = (at) => at > scan.clearedAt && now - at <= QUARANTINE_REPORT_MS;
275
+ const quarantined = [...scan.quarantined.values()].filter((q) => reportable(ms(q.at))).map((q) => q.key);
276
+ const live = scan.notices.filter((n) => reportable(ms(n.at)));
277
+ const corrupt = live.reduce((n, x) => n + (Number(x.corrupt) || 0), 0) + (scan.corrupt && reportable(now) ? scan.corrupt : 0);
278
+ const dropped = live.reduce((n, x) => n + (Number(x.dropped) || 0), 0);
279
+ const ready = storeReady(this.db);
280
+ const notApplicable = !ready ? 'no AgentDB store in this project (.swarm/memory.db is not initialized)'
281
+ : !this.ruflo ? 'ruflo is not installed' : null;
282
+ const problem = quarantined.length ? 'quarantined' : corrupt ? 'corrupt' : dropped ? 'dropped'
283
+ : !notApplicable && oldest !== null && now - oldest > STUCK_AFTER_MS ? 'stuck-pending' : null;
284
+ return {
285
+ outbox: this.path, db: this.db, storeReady: ready, rufloPresent: Boolean(this.ruflo),
286
+ applicable: !notApplicable, notApplicable,
287
+ pending: pending.length, oldestPendingAt: oldest, lastCommitAt, eventsToday,
288
+ lastFailure: lastFailure && (!lastCommitAt || ms(lastFailure.at) > lastCommitAt) ? lastFailure : null,
289
+ quarantined, corrupt, dropped, problem, stuck: Boolean(problem),
290
+ };
291
+ }
292
+ }
293
+
294
+ const ago = (t) => (t < 90_000 ? `${Math.max(0, Math.round(t / 1000))}s` : t < 5_400_000 ? `${Math.round(t / 60_000)}m` : `${Math.round(t / 3_600_000)}h`);
295
+
296
+ /** The one-line positive (or loud) confirmation, shared by the brief, --doctor and the Stop line. */
297
+ export function recordingLine(status, now = Date.now()) {
298
+ if (!status) return 'AgentDB: recording unknown — status unavailable';
299
+ if (status.stuck) {
300
+ const clears = `clears itself in ${Math.round(QUARANTINE_REPORT_MS / 86_400_000)}d, or now: ${CLEAR_COMMAND}`;
301
+ const why = status.problem === 'quarantined' ? `${status.quarantined.length} quarantined (a different row holds its key; never retried — ${clears})`
302
+ : status.problem === 'corrupt' ? `${status.corrupt} corrupt outbox line(s) removed (${clears})`
303
+ : status.problem === 'dropped' ? `${status.dropped} uncommitted event(s) dropped at the outbox cap (${clears})`
304
+ : status.lastFailure ? `last error: ${status.lastFailure.reason || status.lastFailure.error}` : 'not committing';
305
+ return `AgentDB: recording stuck — ${status.pending} event(s) pending${status.oldestPendingAt && status.problem === 'stuck-pending' ? ` for ${ago(now - status.oldestPendingAt)}` : ''}, ${why}.`
306
+ + ` Pending events are durable in ${status.outbox} and retry at every capture boundary.`;
307
+ }
308
+ if (status.notApplicable) {
309
+ return `AgentDB: recording n/a — ${status.notApplicable}; ${status.pending} event(s) wait in the outbox (not a failure).`;
310
+ }
311
+ // ✓ only on evidence: a committed, read-back event. "Nothing failed yet" is not proof of recording.
312
+ if (!status.lastCommitAt) return `AgentDB: recording not yet proven — no event committed and read back yet (outbox ${status.pending} pending)`;
313
+ return `AgentDB: recording ✓ (last write ${ago(now - status.lastCommitAt)} ago, ${status.eventsToday} event(s) today, outbox ${status.pending} pending)`;
314
+ }
315
+
316
+ /**
317
+ * The Claude Stop line, at most ONCE per session per condition (review S3: it repeated at every turn).
318
+ * State is a tiny file in the project's own .swarm, bounded to the last 20 sessions.
319
+ */
320
+ export function stopNotice({ journal, status, session }) {
321
+ if (!status?.stuck || !status.problem) return '';
322
+ const file = path.join(journal.swarm, NOTICE_STATE);
323
+ let state = {};
324
+ try { state = JSON.parse(fs.readFileSync(file, 'utf8')) || {}; } catch { /* first notice */ }
325
+ const id = String(session || 'unknown');
326
+ const shown = Array.isArray(state[id]?.conditions) ? state[id].conditions : [];
327
+ if (shown.includes(status.problem)) return '';
328
+ state[id] = { at: new Date(journal.now()).toISOString(), conditions: [...shown, status.problem] };
329
+ const recent = Object.entries(state).sort((a, b) => String(b[1]?.at).localeCompare(String(a[1]?.at))).slice(0, 20);
330
+ try { fs.writeFileSync(file, JSON.stringify(Object.fromEntries(recent)), { mode: 0o600 }); } catch { /* still show it once */ }
331
+ return `[RuvNet Brain] ${recordingLine(status, journal.now())}`;
332
+ }
333
+
334
+ function defaultStore({ ruflo, db, key, value }) {
335
+ const cwd = rufloRunDir(db);
336
+ try {
337
+ const { executable, args } = rufloInvocation(ruflo, ['memory', 'store', '--key', key, '--value', value,
338
+ '--namespace', CONTINUITY_NAMESPACE, '--no-upsert', '--provenance', 'system_observation', '--path', db]);
339
+ const r = spawnSync(executable, args, { cwd, encoding: 'utf8', timeout: 60_000, windowsHide: true,
340
+ env: { ...process.env, RUFLO_DAEMON_AUTOSTART: '0' } });
341
+ return { status: Number.isInteger(r.status) ? r.status : 1, output: `${r.stderr || ''}\n${r.stdout || ''}${r.error ? `\n${r.error.message}` : ''}` };
342
+ } finally { fs.rmSync(cwd, { recursive: true, force: true }); }
343
+ }
344
+
345
+ function defaultReadBack({ ruflo, db, key }) {
346
+ const fast = withProgressionReader(db, (reader) => reader.readContent(CONTINUITY_NAMESPACE, key));
347
+ if (fast.ok) return { content: fast.value, readPath: 'node:sqlite' };
348
+ const cwd = rufloRunDir(db);
349
+ try {
350
+ const { executable, args } = rufloInvocation(ruflo, ['memory', 'retrieve', '--key', key, '--namespace', CONTINUITY_NAMESPACE, '--value-only', '--path', db]);
351
+ const r = spawnSync(executable, args, { cwd, encoding: 'utf8', timeout: 60_000, windowsHide: true, env: { ...process.env, RUFLO_DAEMON_AUTOSTART: '0' } });
352
+ return { content: r.status === 0 ? String(r.stdout || '') : null, readPath: `ruflo-cli (${fast.reason})` };
353
+ } finally { fs.rmSync(cwd, { recursive: true, force: true }); }
354
+ }
355
+
356
+ /** Is a stored row the SAME event (same kind and content id), whoever observed it? */
357
+ const sameEvent = (content, event) => {
358
+ try { const row = JSON.parse(content); return row?.kind === event.kind && row?.id === event.id; } catch { return false; }
359
+ };
360
+
361
+ /**
362
+ * Commit every pending event: store → exact read-back → commit line. A refusal is retried with
363
+ * backoff while the budget lasts; nothing is ever dropped. Failures are ONE record per event per run
364
+ * (attempts counted), merged across runs by compact(). Returns { committed, failed, remaining }.
365
+ */
366
+ export function drain(journal, {
367
+ ruflo = journal.ruflo, store = defaultStore, readBack = defaultReadBack,
368
+ budgetMs = DRAIN_BUDGET_MS, backoff = RETRY_BACKOFF_MS, now = Date.now, sleep = pause,
369
+ } = {}) {
370
+ const deadline = now() + budgetMs;
371
+ let committed = 0;
372
+ let failed = 0;
373
+ // Not applicable is not a failure: nothing is written, the events simply wait (review S4).
374
+ if (!ruflo) return { committed, failed, remaining: journal.pending().length, skipped: 'ruflo not found' };
375
+ if (!storeReady(journal.db)) return { committed, failed, remaining: journal.pending().length, skipped: 'store not initialized' };
376
+ for (const rec of journal.pending()) {
377
+ const value = JSON.stringify(rec.event);
378
+ let done = false;
379
+ let attempts = 0;
380
+ let last = null;
381
+ for (let attempt = 0; !done && now() < deadline; attempt += 1) {
382
+ attempts += 1;
383
+ const result = store({ ruflo, db: journal.db, key: rec.key, value });
384
+ const back = readBack({ ruflo, db: journal.db, key: rec.key });
385
+ const content = typeof back.content === 'string' ? back.content.trim() : '';
386
+ if (content && (content === value || sameEvent(content, rec.event))) {
387
+ journal.appendRecords([{ type: 'commit', key: rec.key, digest: rec.digest, committedAt: new Date(now()).toISOString(), readPath: back.readPath,
388
+ alreadyStored: result.status !== 0 || content !== value, ...(attempts > 1 ? { attempts, lastError: last?.reason } : {}) }]);
389
+ committed += 1;
390
+ done = true;
391
+ } else if (content) {
392
+ // A DIFFERENT row holds our key: never overwritten (--no-upsert), never retried, reported.
393
+ journal.appendRecords([{ type: 'quarantine', key: rec.key, at: new Date(now()).toISOString(), reason: 'a different stored row holds this key' }]);
394
+ failed += 1;
395
+ done = true;
396
+ } else {
397
+ last = { reason: WAL_REFUSAL.test(result.output || '') ? 'wal-contention' : `store exited ${result.status}`, error: String(result.output || '').trim().slice(-300) };
398
+ const wait = backoff[Math.min(attempt, backoff.length - 1)];
399
+ if (attempt >= backoff.length || now() + wait >= deadline) break;
400
+ sleep(wait);
401
+ }
402
+ }
403
+ if (!done && last) {
404
+ failed += 1;
405
+ try { journal.appendRecords([{ type: 'failure', key: rec.key, at: new Date(now()).toISOString(), attempts, ...last }]); } catch { /* the event itself is still durable */ }
406
+ }
407
+ if (!done && now() >= deadline) break;
408
+ }
409
+ try { if (journal.needsCompaction()) journal.compact(); } catch { /* a larger file is not a lost event */ }
410
+ return { committed, failed, remaining: journal.pending().length };
411
+ }
412
+
413
+ /** One drainer per project at a time. Returns a release function, or null if another is live. */
414
+ export function takeLock(journal, { now = Date.now } = {}) {
415
+ const lock = path.join(journal.swarm, LOCK_NAME);
416
+ const body = `${process.pid} ${now()}\n`;
417
+ const create = () => { fs.writeFileSync(lock, body, { flag: 'wx', mode: 0o600 }); return () => { try { if (fs.readFileSync(lock, 'utf8') === body) fs.rmSync(lock, { force: true }); } catch { /* gone */ } }; };
418
+ try { return create(); } catch { /* held */ }
419
+ try {
420
+ const [pid, at] = fs.readFileSync(lock, 'utf8').trim().split(' ').map(Number);
421
+ let alive = false;
422
+ try { process.kill(pid, 0); alive = true; } catch (e) { alive = e?.code === 'EPERM'; }
423
+ if (now() - at > LOCK_STALE_MS && (!alive || now() - at > 10 * LOCK_STALE_MS)) {
424
+ fs.rmSync(lock, { force: true });
425
+ return create();
426
+ }
427
+ } catch { /* unreadable lock: leave it, the next boundary tries again */ }
428
+ return null;
429
+ }
430
+
431
+ /** Start a detached drainer for this project. Never throws; returns whether one was started. */
432
+ export function launchDrain({ projectRoot, spawnFn = spawn, env = process.env } = {}) {
433
+ try {
434
+ const child = spawnFn(process.execPath, [fileURLToPath(import.meta.url), '--drain', projectRoot], {
435
+ cwd: os.tmpdir(), detached: true, stdio: 'ignore', windowsHide: true, env: { ...env, RUFLO_DAEMON_AUTOSTART: '0' },
436
+ });
437
+ child.unref?.();
438
+ return true;
439
+ } catch { return false; }
440
+ }
441
+
442
+ /**
443
+ * THE CAPTURE BOUNDARY'S CALL (session-snapshot-hook.mjs, Stop / PreCompact / SessionEnd on Claude,
444
+ * Stop / SessionEnd on Codex). Reads git and the turn, journals new events with one fsync, and hands
445
+ * the AgentDB writes to a detached drainer — so it fits Codex's 3s SessionEnd with room to spare.
446
+ * Never throws; every skip carries its reason.
447
+ */
448
+ export function captureContinuityEvents({
449
+ projectDir, event, payload = {}, host = 'claude', env = process.env, ruflo = resolveRuflo({ env }),
450
+ readTranscript = (file) => readSettledTranscript(file, { maxMs: 0 }), launch = launchDrain, now = Date.now,
451
+ } = {}) {
452
+ const report = { event, recorded: 0, launched: false };
453
+ if (String(env.RUVNET_CONTINUITY_CAPTURE || '').toLowerCase() === 'off') return { ...report, skipped: 'RUVNET_CONTINUITY_CAPTURE=off' };
454
+ let resolution;
455
+ try { resolution = resolveProjectStore({ projectDir }); } catch { return { ...report, skipped: 'project store could not be resolved' }; }
456
+ const journal = new ContinuityJournal({ projectRoot: resolution.projectRoot, now, ruflo });
457
+ try {
458
+ const st = fs.lstatSync(journal.swarm);
459
+ if (!st.isDirectory() || st.isSymbolicLink()) return { ...report, skipped: '.swarm is not a real directory' };
460
+ } catch { return { ...report, skipped: 'project has not adopted the canonical store' }; }
461
+ // No initialized store: recording is not applicable here, so nothing is journalled (review S3c/S4).
462
+ if (!storeReady(journal.db)) return { ...report, skipped: 'not applicable: no AgentDB store in this project (.swarm/memory.db is not initialized)' };
463
+ const session = typeof payload.session_id === 'string' ? payload.session_id : null;
464
+ const project = path.basename(resolution.projectRoot);
465
+ const scan = journal.scan();
466
+ const lastBoundary = [...scan.events.values()].reduce((max, r) => Math.max(max, ms(r.journaledAt)), 0);
467
+ const sinceMs = lastBoundary ? Math.min(lastBoundary - 86_400_000, now() - 3_600_000) : now() - INITIAL_LOOKBACK_MS;
468
+ const events = [];
469
+ if (resolution.kind === 'git') {
470
+ events.push(...collectCommits({ checkoutRoot: resolution.checkoutRoot, sinceMs, host, session, project }));
471
+ events.push(...collectReleases({ checkoutRoot: resolution.checkoutRoot, sinceMs, host, session, project }));
472
+ }
473
+ if (event === 'Stop') {
474
+ let lines = null;
475
+ if (host === 'claude' && typeof payload.transcript_path === 'string' && payload.transcript_path) {
476
+ try { lines = readTranscript(payload.transcript_path); } catch { /* unreadable transcript: git events still count */ }
477
+ }
478
+ const last = typeof payload.last_assistant_message === 'string' ? payload.last_assistant_message : '';
479
+ events.push(...collectTurnEvents({ lines, lastAssistantMessage: last, host, session, project, env, at: now() }));
480
+ }
481
+ try { report.recorded = journal.record(events).length; } catch (error) { return { ...report, skipped: `outbox write failed: ${error.message}` }; }
482
+ try { if (journal.needsCompaction()) journal.compact(); } catch { /* bounded next time */ }
483
+ const status = journal.status();
484
+ // A drainer only where it can succeed: an initialized store AND a ruflo to write it (review S4).
485
+ if (status.pending && status.applicable) report.launched = launch({ projectRoot: resolution.projectRoot, env });
486
+ return { ...report, status, journal };
487
+ }
488
+
489
+ /** The detached worker body. */
490
+ export function runDrain(projectRoot, options = {}) {
491
+ const journal = new ContinuityJournal({ projectRoot, ...(options.ruflo !== undefined ? { ruflo: options.ruflo } : {}) });
492
+ const release = takeLock(journal);
493
+ if (!release) return { skipped: 'another drainer holds the lock' };
494
+ try { return drain(journal, options); } finally { release(); }
495
+ }
496
+
497
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) && process.argv[2] === '--drain') {
498
+ try { runDrain(process.argv[3]); } catch { /* the events stay durable in the outbox */ }
499
+ process.exit(0);
500
+ }
@@ -25,6 +25,10 @@ import { fileURLToPath } from 'node:url';
25
25
  // signature, do not infer it from the name.
26
26
  import { resolveBash } from './hook-shim-bash.mjs';
27
27
  import { append as appendOutcome, actionKey, recordRefusal, resolve as resolveOutcome, sweepStale } from './decision-outcomes.mjs';
28
+ // Grok sends its own tool names (`write`) and a snake_case event; every policy below reads Claude's
29
+ // shape. Normalised ONCE here, so the policies (four of them bash, matching tool_name textually) never
30
+ // see a host-native spelling — the 4.4 measurement was a Grok write silently allowed. hook-input.mjs.
31
+ import { normalizePayloadText } from './hook-input.mjs';
28
32
 
29
33
  const SCRIPTS_DIR = path.dirname(fileURLToPath(import.meta.url));
30
34
  const EVENT = process.argv[2] || '';
@@ -81,6 +85,46 @@ const SKIPPED_SELF = 3;
81
85
  export const DEFAULT_BUDGET_MS = 2000;
82
86
  export const MIN_HEADROOM_MS = 3000;
83
87
 
88
+ /**
89
+ * ── THE TWO FAIL-OPEN CASES, STATED AS POLICY (4.5) ──────────────────────────────────────────────
90
+ *
91
+ * 1. ALLOW ON TIMEOUT. A policy still running at the budget is killed and the write is ALLOWED — a gate
92
+ * that blocks because it is slow is one users switch off. Never silent: the outcome ledger records
93
+ * `budget-exceeded` with the policies that did not vote, and ONE stderr line says the allow was a
94
+ * timeout, not a verdict (reportBudget below).
95
+ *
96
+ * 2. OVERSIZE: ONLY THE FIRST 64 KiB IS CHECKED. The host hands this gate the payload through
97
+ * hook-shim.mjs, which reads at most TRANSPORT_CAP_BYTES (its TABLE entry), and the bash policies cap
98
+ * their own read at the same number. A bigger Write arrives cut mid-JSON. The policies still run on
99
+ * that prefix and a refusal found there STANDS (measured 2026-10-01: an ungrounded rUv import at the
100
+ * top of a 100 KB file, and a 100 KB write to the protected settings file, are both refused). What
101
+ * the prefix cannot show — a rUv import after the first 64 KiB — is ALLOWED. Measured before 4.5 that
102
+ * allow left no trace at all; it is now recorded as `payload-oversize` (with the session, tool and path
103
+ * recovered from the prefix, so refusals of oversize writes are counted too). No stderr on that allow:
104
+ * a large file is ordinary work, and stderr is what a host renders as a hook error.
105
+ * tests/unit/write-gate-policy.test.mjs holds both, through the real registered command.
106
+ */
107
+ export const TRANSPORT_CAP_BYTES = 65536;
108
+
109
+ /** Pure: is this the complete JSON event, or the transport cap's cut-off prefix of a bigger one? */
110
+ export function classifyPayload(raw) {
111
+ const text = String(raw ?? '');
112
+ const bytes = Buffer.byteLength(text, 'utf8');
113
+ try {
114
+ const o = JSON.parse(text);
115
+ if (o && typeof o === 'object') return { complete: true, truncated: false, bytes };
116
+ } catch { /* fall through */ }
117
+ const str = (k) => new RegExp(`"${k}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)"`).exec(text)?.[1] ?? '';
118
+ return {
119
+ complete: false,
120
+ truncated: bytes >= TRANSPORT_CAP_BYTES,
121
+ bytes,
122
+ session: str('session_id') || str('sessionId'),
123
+ tool: str('tool_name') || str('toolName'),
124
+ filePath: str('file_path'),
125
+ };
126
+ }
127
+
84
128
  /**
85
129
  * THE POLICY REGISTRY — the whole point of this file, and the thing that was previously scattered
86
130
  * across seven hooks.json entries with no relationship to each other.
@@ -218,7 +262,7 @@ function speechEventFor(event) { return event === 'bash' ? 'PreToolUse-bash' : '
218
262
 
219
263
  if (isMain()) {
220
264
  const started = Date.now();
221
- const payload = readPayload();
265
+ const payload = normalizePayloadText(readPayload());
222
266
  const selected = policiesFor(EVENT);
223
267
  // An unknown event is not an occasion to refuse anything. Same rule as unprompted-runtime's
224
268
  // "never speak on a guess", pointed at the other decision. 'write' is the only registered route
@@ -263,9 +307,14 @@ if (isMain()) {
263
307
  // hook: resolve first (did this call retry something we refused?), then open a new debt if we are
264
308
  // about to refuse. Order matters — resolving after recording would close the debt we just opened.
265
309
  // Entirely best-effort: measurement may never affect the verdict, so it runs after `decide`.
266
- const session = sessionOf(payload);
310
+ // An oversize payload is cut mid-JSON (see TRANSPORT_CAP_BYTES): recover session/tool/path from its
311
+ // prefix so its refusals and allows are counted like any other.
312
+ const shape = classifyPayload(payload);
313
+ const session = sessionOf(payload) || (shape.truncated ? shape.session : '');
267
314
  try {
268
- const key = actionKey(payloadTool(payload), toolInput);
315
+ const key = shape.truncated
316
+ ? actionKey(shape.tool, { file_path: shape.filePath })
317
+ : actionKey(payloadTool(payload), toolInput);
269
318
  const ts = Date.now();
270
319
  if (session && key) {
271
320
  sweepStale({ session, ts }); // debts from dead sessions become `abandoned`, never vanish
@@ -275,6 +324,10 @@ if (isMain()) {
275
324
  } catch { /* a ledger must never break a tool call */ }
276
325
 
277
326
  reportSelfSkipped({ session, selfSkipped });
327
+ if (decision.allow && shape.truncated) {
328
+ // OVERSIZE POLICY (header): allowed on a check of the first TRANSPORT_CAP_BYTES only. Recorded, never silent.
329
+ try { appendOutcome({ kind: 'payload-oversize', event: EVENT, session, bytesSeen: shape.bytes, capBytes: TRANSPORT_CAP_BYTES, tool: shape.tool, filePath: shape.filePath, ts: Date.now() }); } catch { /* a ledger must never break a tool call */ }
330
+ }
278
331
  if (!decision.allow) {
279
332
  reportBudget({ session, unconsulted, trace, started, budgetMs });
280
333
  process.stderr.write(`${decision.reason}\n`);