ruvnet-brain 4.6.4 → 4.7.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 (71) hide show
  1. package/README.md +1 -1
  2. package/bin/install.mjs +6 -50
  3. package/config/practical-rule-catalog.json +22 -22
  4. package/console/app.js +1 -1
  5. package/console/architecture.html +2 -2
  6. package/package.json +1 -2
  7. package/plugin/.claude-plugin/plugin.json +1 -1
  8. package/plugin/.codex-plugin/plugin.json +1 -1
  9. package/plugin/commands/checkpoint.md +22 -40
  10. package/plugin/commands/rnb-brief.md +18 -15
  11. package/plugin/hooks/codex-hooks.json +1 -28
  12. package/plugin/hooks/hook-contracts.json +6 -127
  13. package/plugin/hooks/hooks.json +1 -40
  14. package/plugin/mcp/managed-cli-interface.mjs +0 -56
  15. package/plugin/scripts/agentdb-recall-reader.mjs +29 -9
  16. package/plugin/scripts/agentdb-recall.mjs +8 -14
  17. package/plugin/scripts/brain-footprint.mjs +1 -1
  18. package/plugin/scripts/codex-hook-wrapper.mjs +13 -3
  19. package/plugin/scripts/completion-claim-evidence.mjs +1 -1
  20. package/plugin/scripts/continuity-events.mjs +90 -2
  21. package/plugin/scripts/continuity-hook-policy.mjs +5 -14
  22. package/plugin/scripts/decision-gate.mjs +2 -2
  23. package/plugin/scripts/decision-record.mjs +11 -40
  24. package/plugin/scripts/hook-failures.mjs +16 -0
  25. package/plugin/scripts/hook-shim.mjs +1 -2
  26. package/plugin/scripts/learn-capture.mjs +60 -42
  27. package/plugin/scripts/learning-store.mjs +70 -60
  28. package/plugin/scripts/node-sqlite.mjs +1 -1
  29. package/plugin/scripts/ruflo-bin.mjs +71 -1
  30. package/plugin/scripts/ruflo-memory-hooks.mjs +132 -0
  31. package/plugin/scripts/runtime-preferences.mjs +1 -1
  32. package/plugin/scripts/session-snapshot-hook.mjs +85 -341
  33. package/plugin/scripts/session-start-core.mjs +2 -12
  34. package/plugin/scripts/signal-watch.mjs +1 -1
  35. package/plugin/scripts/substitution-wall.mjs +222 -0
  36. package/plugin/scripts/turn-outcome-capture.mjs +68 -74
  37. package/scripts/codex-host-execution-proof.mjs +1 -1
  38. package/scripts/console-engine.mjs +0 -32
  39. package/scripts/health-repair.mjs +1 -81
  40. package/scripts/hook-qualify-core.mjs +2 -2
  41. package/scripts/model-managed-workflow-service.mjs +9 -10
  42. package/scripts/model-routing-controller.mjs +9 -8
  43. package/scripts/no-silent-substitution.mjs +65 -1
  44. package/scripts/onboarding-console.mjs +8 -23
  45. package/scripts/product-integrity-contract.mjs +1 -1
  46. package/scripts/release-qualification-contract.mjs +9 -27
  47. package/scripts/remedy-registry.mjs +0 -18
  48. package/scripts/wired-check.mjs +1 -5
  49. package/plugin/scripts/continuity-brief.mjs +0 -351
  50. package/plugin/scripts/continuity-journal.mjs +0 -553
  51. package/plugin/scripts/learn-flush.mjs +0 -96
  52. package/plugin/scripts/learning-observation.mjs +0 -51
  53. package/plugin/scripts/learning-queue.mjs +0 -135
  54. package/plugin/scripts/learning-worker-supervisor.mjs +0 -79
  55. package/plugin/scripts/project-capture-queue.mjs +0 -357
  56. package/plugin/scripts/project-memory-enrollment.mjs +0 -203
  57. package/plugin/scripts/project-progression-checkpoint.mjs +0 -145
  58. package/plugin/scripts/project-progression-contract.mjs +0 -496
  59. package/plugin/scripts/project-progression-hook.mjs +0 -233
  60. package/plugin/scripts/project-progression-outbox.mjs +0 -225
  61. package/plugin/scripts/project-progression-producer.mjs +0 -309
  62. package/plugin/scripts/project-progression-reader.mjs +0 -304
  63. package/plugin/scripts/project-progression-session-start.mjs +0 -320
  64. package/plugin/scripts/project-progression-sources.mjs +0 -246
  65. package/plugin/scripts/project-progression-store.mjs +0 -723
  66. package/plugin/scripts/project-progression-suspension.mjs +0 -60
  67. package/plugin/scripts/project-transition-hook.mjs +0 -267
  68. package/plugin/scripts/session-snapshot-contract.mjs +0 -105
  69. package/plugin/scripts/turn-transport-journal.mjs +0 -108
  70. package/scripts/qa/progression-validation-benchmark.mjs +0 -66
  71. package/scripts/session-snapshot-contract.mjs +0 -1
@@ -1,723 +0,0 @@
1
- import { spawnSync } from 'node:child_process';
2
- import crypto from 'node:crypto';
3
- import fs from 'node:fs';
4
- import os from 'node:os';
5
- import path from 'node:path';
6
- import { ProgressionOutbox } from './project-progression-outbox.mjs';
7
- import {
8
- createProgressionSnapshot,
9
- digestCanonical,
10
- restoreProjectProgression,
11
- validateProgressionSnapshot,
12
- } from './project-progression-contract.mjs';
13
- import { resolveTurnDb } from './turn-outcome-capture.mjs';
14
- import { privateProgressionState } from './turn-capture-privacy.mjs';
15
- import { resolveProjectStore } from './project-store-resolver.mjs';
16
- import { withProgressionReader } from './project-progression-reader.mjs';
17
- import { resolveRuflo, rufloInvocation, RUFLO_MISSING } from './ruflo-bin.mjs';
18
-
19
- const PROGRESSION_NAMESPACE = 'project-progression';
20
- const RESUME_SCHEMA = 'ruvnet-brain.project-resume';
21
- const RESUME_VERSION = 1;
22
-
23
- function defaultRunner(binary, args, options) {
24
- const invocation = rufloInvocation(binary, args);
25
- return spawnSync(invocation.executable, invocation.args, { ...options, shell: false });
26
- }
27
-
28
- function resultStatus(result) {
29
- return Number.isInteger(result?.status) ? result.status : 1;
30
- }
31
-
32
- function resultText(result, field) {
33
- const value = result?.[field];
34
- return Buffer.isBuffer(value) ? value.toString('utf8') : String(value ?? '');
35
- }
36
-
37
- function parseJson(text, label) {
38
- try { return JSON.parse(text); } catch { throw new Error(`${label} is not JSON`); }
39
- }
40
-
41
- function plainRecord(value) {
42
- return value && typeof value === 'object' && !Array.isArray(value);
43
- }
44
-
45
- function requirePositiveInteger(value, label) {
46
- if (!Number.isSafeInteger(value) || value < 1) throw new TypeError(`${label} must be a positive safe integer`);
47
- }
48
-
49
- function omissionSummary(value) {
50
- return {
51
- count: Array.isArray(value) ? value.length : 1,
52
- sha256: digestCanonical(value),
53
- };
54
- }
55
-
56
- /**
57
- * Deterministically reduce a verified resume payload to fit the host context without pretending
58
- * omitted evidence is empty. The canonical snapshots remain in AgentDB; the projection retains the
59
- * merged goal/action values, head keys and per-omission digests so the omitted values stay addressable.
60
- * Returns null when the required resume identity and goal/action alone cannot fit.
61
- */
62
- export function projectResumePayloadToBound(payload, maxOutputBytes) {
63
- requirePositiveInteger(maxOutputBytes, 'maxOutputBytes');
64
- const summary = structuredClone(payload);
65
- const omissions = [];
66
- summary.projection = { mode: 'bounded-summary', omitted: omissions };
67
- const recordOmission = (target, key, pathName) => {
68
- const value = target[key];
69
- if (value === undefined) return;
70
- const digest = omissionSummary(value);
71
- omissions.push({ path: pathName, ...digest });
72
- target[key] = { omitted: true, ...digest };
73
- };
74
- const size = () => Buffer.byteLength(JSON.stringify(summary), 'utf8');
75
-
76
- // Conflicts are important facts, but their full competing values can dominate the resume context.
77
- // Keep each conflicting field, its count, and a digest of the exact competing values; top-level
78
- // head keys remain, and canonical snapshots retain the source values. No winner is chosen.
79
- if (size() > maxOutputBytes && Array.isArray(summary.state?.resumeConflicts)) {
80
- const originals = summary.state.resumeConflicts;
81
- summary.state.resumeConflicts = originals.map((conflict) => ({
82
- field: conflict.field,
83
- valueCount: (conflict.values ?? []).length,
84
- valuesDigest: digestCanonical(conflict.values ?? []),
85
- }));
86
- omissions.push({
87
- path: 'state.resumeConflicts[].values',
88
- ...omissionSummary(originals.flatMap((conflict) => conflict.values ?? []).map((row) => row.value)),
89
- });
90
- }
91
-
92
- if (size() > maxOutputBytes && Array.isArray(summary.state?.resumeConflicts)) {
93
- const groups = new Map();
94
- for (const conflict of payload.state.resumeConflicts) {
95
- if (!groups.has(conflict.field)) groups.set(conflict.field, []);
96
- groups.get(conflict.field).push(conflict);
97
- }
98
- if (groups.size < payload.state.resumeConflicts.length) {
99
- summary.state.resumeConflicts = [...groups].map(([field, conflicts]) => ({
100
- field, conflictCount: conflicts.length,
101
- valueCount: conflicts.reduce((count, conflict) => count + (conflict.values ?? []).length, 0),
102
- conflictsDigest: digestCanonical(conflicts),
103
- }));
104
- omissions.push({ path: 'state.resumeConflicts', ...omissionSummary(payload.state.resumeConflicts) });
105
- }
106
- }
107
-
108
- if (size() > maxOutputBytes) recordOmission(summary.state, 'journalHeads', 'state.journalHeads');
109
- // Least central detail first. currentGoal and nextAction are deliberately absent from this list.
110
- const stateFields = [
111
- 'observations', 'commands', 'proofArtifacts', 'changedFiles', 'completed', 'untested', 'decisions',
112
- 'plan', 'inProgress', 'blockers', 'failures', 'acceptanceContract', 'provenance',
113
- 'evidence', 'activeStep', 'activeProcess', 'sourceIdentity',
114
- ];
115
- for (const field of stateFields) {
116
- if (size() <= maxOutputBytes) break;
117
- recordOmission(summary.state, field, `state.${field}`);
118
- }
119
- if (size() > maxOutputBytes && summary.evidence) {
120
- recordOmission(summary, 'evidence', 'evidence');
121
- }
122
-
123
- // Never clip or replace the user goal or next action. If those plus the identity/omission ledger
124
- // do not fit, refuse to call this a restore and let SessionStart emit explicit UNKNOWN.
125
- if (size() > maxOutputBytes) return null;
126
- return { payload: summary, rendered: JSON.stringify(summary), projected: true };
127
- }
128
-
129
- function validatePage(page, { offset, pageSize, total }) {
130
- if (!plainRecord(page) || !Array.isArray(page.entries)
131
- || !Number.isSafeInteger(page.total) || page.total < 0
132
- || page.limit !== pageSize || page.offset !== offset
133
- || typeof page.hasMore !== 'boolean') {
134
- throw new Error('malformed pagination page');
135
- }
136
- if (total !== null && page.total !== total) throw new Error('pagination total changed during restoration');
137
- if (page.entries.length > pageSize || offset + page.entries.length > page.total) {
138
- throw new Error('malformed pagination page');
139
- }
140
- const consumed = offset + page.entries.length;
141
- if (page.hasMore) {
142
- if (!Number.isSafeInteger(page.nextOffset) || page.nextOffset <= offset) {
143
- throw new Error('non-advancing pagination page');
144
- }
145
- if (page.nextOffset !== consumed || consumed >= page.total) throw new Error('malformed pagination page');
146
- } else if (page.nextOffset !== null || consumed !== page.total) {
147
- throw new Error('malformed pagination page');
148
- }
149
- return page.total;
150
- }
151
-
152
- function sortRejected(rows) {
153
- return rows.sort((left, right) => String(left.eventKey).localeCompare(String(right.eventKey))
154
- || left.reasons.join('|').localeCompare(right.reasons.join('|')));
155
- }
156
-
157
- /**
158
- * The working directory ruflo runs in. ruflo writes into its cwd on every invocation even when --path
159
- * names the store (measured 2026-10-01, ruflo 3.49.0): `.claude/`, `.claude-flow/`, `ruvector.db`, and
160
- * `<cwd>/.swarm/` holding hnsw.metadata.json — the stored snapshot CONTENT — which it also LOADS when it
161
- * finds one there (ruflo/v3/@claude-flow/cli/src/memory/memory-initializer.ts: getMemoryRoot() = cwd).
162
- * So the cwd must be:
163
- * - never the project tree (it changed the customer's working tree and broke no-op capture detection),
164
- * never inside `.swarm` (nested `.swarm/.swarm`);
165
- * - PER PROJECT (one shared dir would pool every project's snapshot text and let one project load
166
- * another's metadata);
167
- * - private and ours: under the Brain's own home (never a shared /tmp name another user could pre-create
168
- * or symlink), every directory we create checked to be a real directory, owned by us, mode 0700.
169
- * Every ruflo call carries --path, so the store it reads and writes is unaffected by the cwd.
170
- */
171
- export function rufloScratchRoot(env = process.env) {
172
- if (env.RUVNET_RUFLO_CWD_ROOT) return path.resolve(env.RUVNET_RUFLO_CWD_ROOT);
173
- return path.join(env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache', 'ruvnet-brain'), 'ruflo-cwd');
174
- }
175
-
176
- /** Create-or-verify one private directory: a real directory (not a link), owned by us, mode 0700. */
177
- export function ensurePrivateDir(dir) {
178
- try { fs.mkdirSync(dir, { mode: 0o700 }); } catch (error) { if (error?.code !== 'EEXIST') throw error; }
179
- const stat = fs.lstatSync(dir);
180
- if (stat.isSymbolicLink() || !stat.isDirectory()) {
181
- throw new Error(`ruflo scratch directory ${dir} is not a real directory (symlink or file); refusing to use it`);
182
- }
183
- if (process.platform !== 'win32') {
184
- if (stat.uid !== process.getuid()) {
185
- throw new Error(`ruflo scratch directory ${dir} is owned by uid ${stat.uid}, not ${process.getuid()}; refusing to use it`);
186
- }
187
- if ((stat.mode & 0o777) !== 0o700) {
188
- fs.chmodSync(dir, 0o700);
189
- if ((fs.lstatSync(dir).mode & 0o777) !== 0o700) throw new Error(`ruflo scratch directory ${dir} could not be made private (0700)`);
190
- }
191
- }
192
- return dir;
193
- }
194
-
195
- export function rufloCwdFor(storePath, { root = rufloScratchRoot() } = {}) {
196
- const projectKey = crypto.createHash('sha256').update(path.resolve(storePath)).digest('hex').slice(0, 32);
197
- fs.mkdirSync(path.dirname(root), { recursive: true });
198
- ensurePrivateDir(root);
199
- return ensurePrivateDir(path.join(root, projectKey));
200
- }
201
-
202
- /**
203
- * 4.3.40 ran ruflo with cwd `<project>/.swarm`, so upgraded projects still hold ruflo's cwd artifacts
204
- * INSIDE the store directory — including `.swarm/.swarm/hnsw.metadata.json`, a copy of snapshot content.
205
- * Removes exactly those, and only when every path in them is one ruflo is known to write there; anything
206
- * unexpected (or any symlink) leaves that artifact untouched and is REPORTED. The store itself
207
- * (memory.db, -wal/-shm, schema.sql), the outbox and queue files are never candidates.
208
- *
209
- * The allowlist is measured, not guessed: real ruflo 3.49.0 run with cwd=<dir> and --path elsewhere
210
- * writes .claude/{memory.db,.proven-config-version,proven-config.json} (init/store),
211
- * .claude-flow/harness-active-policy.json and .swarm/{hnsw.index,hnsw.metadata.json} and ruvector.db
212
- * (store), .claude-flow/policy/state.json (retrieve). The owner's real 4.3.40 projects also hold
213
- * .swarm/.swarm/agentdb-memory.db(-wal,-shm): ruflo's AgentDB bridge opens <cwd>/.swarm/agentdb-memory.db
214
- * (ruflo/v3/@claude-flow/cli/src/memory/memory-bridge.ts getAgentDbPath), and the native bindings drop
215
- * ruvector.db into whatever cwd they run in (ruflo/scripts/smoke-memory-no-stray-db.mjs, ADR-125 Phase 7).
216
- */
217
- const SQLITE = (name) => [name, `${name}-wal`, `${name}-shm`, `${name}-journal`];
218
- const LEGACY_CWD_ARTIFACTS = Object.freeze({
219
- '.swarm': new Set(['hnsw.index', 'hnsw.metadata.json', ...SQLITE('agentdb-memory.db')]),
220
- '.claude': new Set(['.proven-config-version', 'proven-config.json', ...SQLITE('memory.db')]),
221
- '.claude-flow': new Set(['harness-active-policy.json', 'policy/', 'policy/state.json']),
222
- 'ruvector.db': null, // a regular file
223
- });
224
-
225
- /** Every path under `dir`, relative, directories with a trailing slash; symlinks reported, never followed. */
226
- function relativeEntries(dir) {
227
- const out = [];
228
- const walk = (current, prefix) => {
229
- for (const name of fs.readdirSync(current)) {
230
- const full = path.join(current, name);
231
- const stat = fs.lstatSync(full);
232
- const rel = prefix + name;
233
- if (stat.isSymbolicLink()) out.push({ rel, link: true });
234
- else if (stat.isDirectory()) { out.push({ rel: `${rel}/` }); walk(full, `${rel}/`); }
235
- else out.push({ rel, file: stat.isFile() });
236
- }
237
- };
238
- walk(dir, '');
239
- return out;
240
- }
241
-
242
- /**
243
- * With `dryRun`, reports what WOULD be removed and touches nothing (--doctor). Returns
244
- * { removed: [paths], refused: [{ path, reason }] }; a refusal must be shown to the user, never dropped.
245
- */
246
- const IN_USE_MS = 10 * 60_000;
247
- // The files that carry DATA: the database, its WAL and its rollback journal. Never -shm: it is the WAL
248
- // index, and every reader — including this proof's own read-only open — rewrites it. Counting it made
249
- // every real 4.3.40 store look "changed while it was being checked" (and "in use" on the next run).
250
- const dataFiles = (dbPath) => {
251
- const base = path.basename(dbPath);
252
- return [base, `${base}-wal`, `${base}-journal`];
253
- };
254
- const sqliteFingerprint = (dbPath) => dataFiles(dbPath).map((name) => {
255
- try { const st = fs.statSync(path.join(path.dirname(dbPath), name)); return `${name}:${st.size}:${st.mtimeMs}`; }
256
- catch { return `${name}:absent`; }
257
- });
258
- const sameFiles = (a, b) => a.join('|') === b.join('|');
259
-
260
- /**
261
- * Is every active (namespace, key, content) row of a stray nested AgentDB present, byte-identical, in
262
- * the canonical store? Read-only through the progression reader (node:sqlite, readOnly — no write, no
263
- * checkpoint). Anything it cannot prove — recently written (in use), unreadable, a row missing or
264
- * different — keeps the file and says why.
265
- */
266
- export function proveMirrored(nestedDb, canonicalDb, { now = Date.now(), maxRows = 100_000 } = {}) {
267
- const fingerprint = sqliteFingerprint(nestedDb);
268
- const newest = Math.max(...dataFiles(nestedDb).map((name) => {
269
- try { return fs.statSync(path.join(path.dirname(nestedDb), name)).mtimeMs; } catch { return 0; }
270
- }));
271
- if (now - newest < IN_USE_MS) return { ok: false, reason: `kept: agentdb-memory.db was written ${Math.round((now - newest) / 1000)}s ago (in use)` };
272
- const nested = withProgressionReader(nestedDb, (reader) => reader.allRows({ maxRows }));
273
- if (!nested.ok) return { ok: false, reason: `kept: agentdb-memory.db could not be read to prove it is mirrored (${nested.reason})` };
274
- const canonical = withProgressionReader(canonicalDb, (reader) => reader.allRows({ maxRows }));
275
- if (!canonical.ok) return { ok: false, reason: `kept: memory.db could not be read to prove agentdb-memory.db is mirrored (${canonical.reason})` };
276
- const have = new Map(canonical.value.map((row) => [`${row.namespace}\u0000${row.key}`, row.content]));
277
- const missing = nested.value.filter((row) => row.content === null || have.get(`${row.namespace}\u0000${row.key}`) !== row.content);
278
- if (missing.length) return { ok: false, reason: `kept: ${missing.length} of ${nested.value.length} rows not in memory.db`, missing: missing.length };
279
- return { ok: true, rows: nested.value.length, fingerprint };
280
- }
281
-
282
- export function cleanLegacyRufloDebris(storeDir, { dryRun = false, now = Date.now() } = {}) {
283
- const removed = [];
284
- const refused = [];
285
- for (const [name, allowed] of Object.entries(LEGACY_CWD_ARTIFACTS)) {
286
- const entry = path.join(storeDir, name);
287
- let stat;
288
- try { stat = fs.lstatSync(entry); } catch { continue; } // absent: nothing to do
289
- if (stat.isSymbolicLink()) { refused.push({ path: entry, reason: 'symbolic link' }); continue; }
290
- if (allowed === null) {
291
- if (!stat.isFile()) { refused.push({ path: entry, reason: 'not a regular file' }); continue; }
292
- } else {
293
- if (!stat.isDirectory()) { refused.push({ path: entry, reason: 'not a directory' }); continue; }
294
- const unknown = relativeEntries(entry).filter((item) => item.link || item.file === false || !allowed.has(item.rel))
295
- .map((item) => (item.link ? `${item.rel} (symbolic link)` : item.rel));
296
- if (unknown.length) { refused.push({ path: entry, reason: `unexpected entries: ${unknown.join(', ')}` }); continue; }
297
- }
298
- // A nested AgentDB is a real store (owner's projects: 1-41 rows, still written by open 4.3.40
299
- // sessions). It goes only when every row is PROVEN present, identically, in the canonical memory.db.
300
- const nestedDb = path.join(entry, 'agentdb-memory.db');
301
- const mirror = name === '.swarm' && fs.existsSync(nestedDb) ? proveMirrored(nestedDb, path.join(storeDir, 'memory.db'), { now }) : null;
302
- if (mirror && !mirror.ok) { refused.push({ path: entry, reason: mirror.reason, kept: true }); continue; }
303
- if (!dryRun) {
304
- // The proof is only valid for the bytes it read: anything written since means the file is in use.
305
- if (mirror && !sameFiles(mirror.fingerprint, sqliteFingerprint(nestedDb))) {
306
- refused.push({ path: entry, reason: 'kept: agentdb-memory.db changed while it was being checked (in use)', kept: true });
307
- continue;
308
- }
309
- fs.rmSync(entry, { recursive: true, force: true });
310
- }
311
- removed.push(entry);
312
- }
313
- return { removed, refused };
314
- }
315
-
316
- // What ruflo leaves in a cwd (measured, ruflo 3.49.0): `.swarm/` (hnsw.index, hnsw.metadata.json — a
317
- // copy of every stored value), `.claude/`, `.claude-flow/`, `ruvector.db`. None of it is read back by the
318
- // product: every call carries --path, and the store of record is that file.
319
- const RUFLO_CWD_ARTIFACTS = Object.freeze(['.swarm', '.claude', '.claude-flow', 'ruvector.db']);
320
- const STALE_RUN_MS = 3_600_000;
321
-
322
- /**
323
- * One ruflo invocation's working directory: a fresh private `run-*` directory inside the project's
324
- * scratch dir, removed again by the caller after the call. ruflo copies every snapshot it touches into
325
- * `<cwd>/.swarm/hnsw.metadata.json`; with one cwd per call that copy never accumulates (4.4.0 kept one
326
- * per project that grew without bound and outlived the project). Also clears what 4.4.0 left directly
327
- * in the project scratch dir and any `run-*` older than an hour (a call killed before its cleanup).
328
- */
329
- export function rufloRunDir(storePath, { root = rufloScratchRoot(), now = Date.now() } = {}) {
330
- const projectScratch = rufloCwdFor(storePath, { root });
331
- for (const name of fs.readdirSync(projectScratch)) {
332
- const entry = path.join(projectScratch, name);
333
- let stat;
334
- try { stat = fs.lstatSync(entry); } catch { continue; }
335
- const stale = name.startsWith('run-') && stat.isDirectory() && now - stat.mtimeMs > STALE_RUN_MS;
336
- if (RUFLO_CWD_ARTIFACTS.includes(name) || stale) fs.rmSync(entry, { recursive: true, force: true });
337
- }
338
- return fs.mkdtempSync(path.join(projectScratch, 'run-'));
339
- }
340
-
341
- export class ProjectProgressionStore {
342
- constructor({
343
- projectDir,
344
- requestedStorePath,
345
- env = process.env,
346
- brainHome = env.RUVNET_BRAIN_HOME || path.join(env.HOME || os.homedir(), '.cache', 'ruvnet-brain'),
347
- rufloBinary = resolveRuflo(),
348
- runner = defaultRunner,
349
- clock = () => new Date().toISOString(),
350
- fsync,
351
- // The read-only fast path (project-progression-reader.mjs). READS ONLY: `ruflo memory store`
352
- // stays the sole writer of memory.db. Pass `reader: null` to force every read through the CLI.
353
- reader = withProgressionReader,
354
- deadlineAt = Infinity,
355
- signal,
356
- } = {}) {
357
- if (!rufloBinary) throw new Error(RUFLO_MISSING);
358
- this.deadlineAt = deadlineAt;
359
- this.signal = signal;
360
- this.resolution = resolveProjectStore({ projectDir, requestedStorePath, deadlineAt });
361
- // Opening continuity is never authorization to remove another store or its artifacts.
362
- this.legacyDebris = { removed: [], refused: [], skipped: 'automatic cleanup disabled; existing stores preserved' };
363
- this.brainHome = brainHome;
364
- this.rufloBinary = rufloBinary;
365
- this.runner = runner;
366
- this.clock = clock;
367
- this.reader = typeof reader === 'function' ? reader : null;
368
- this.lastReadPath = null;
369
- this.outbox = new ProgressionOutbox({ projectRoot: this.resolution.projectRoot, fsync });
370
- }
371
-
372
- /**
373
- * Run one read through the in-process reader, or report that the CLI must serve it.
374
- * Structural errors propagate unchanged — only "I cannot answer authoritatively" falls back.
375
- */
376
- readFast(work) {
377
- if (this.signal?.aborted || Date.now() >= this.deadlineAt) throw new Error('restore deadline exceeded');
378
- if (!this.reader) return { ok: false, reason: 'reader disabled' };
379
- return this.reader(this.resolution.canonicalAgentDbPath, work, { deadlineAt: this.deadlineAt, signal: this.signal });
380
- }
381
-
382
- run(args) {
383
- const cwd = rufloRunDir(this.resolution.canonicalAgentDbPath);
384
- try {
385
- return this.runner(this.rufloBinary, args, {
386
- cwd,
387
- encoding: 'utf8',
388
- timeout: 120_000,
389
- env: { ...process.env, RUFLO_DAEMON_AUTOSTART: '0', CLAUDE_FLOW_MEMORY_PATH: undefined },
390
- });
391
- } finally {
392
- fs.rmSync(cwd, { recursive: true, force: true });
393
- }
394
- }
395
-
396
- validateSnapshot(snapshot) {
397
- const verdict = validateProgressionSnapshot(snapshot, {
398
- expectedProjectIdentity: this.resolution.projectIdentity,
399
- });
400
- if (!verdict.ok) throw new Error(`invalid progression snapshot: ${verdict.errors.join(', ')}`);
401
- }
402
-
403
- requireCaptureConsent(snapshot) {
404
- const capturePath = snapshot.sourceIdentity.capturePath;
405
- const target = resolveTurnDb({ projectDir: capturePath ?? snapshot.sourceIdentity.checkoutPath,
406
- brainHome: this.brainHome, requestedStorePath: this.resolution.canonicalAgentDbPath,
407
- unknownOriginalPath: !capturePath });
408
- if (target.skipped) throw new Error(`progression capture suspended: ${target.skipped}`);
409
- if (digestCanonical(privateProgressionState(snapshot.completeProjectState, target.contentPathExcludes, capturePath ?? snapshot.sourceIdentity.checkoutPath))
410
- !== digestCanonical(snapshot.completeProjectState)) throw new Error('progression capture suspended: content exclusions changed; frozen snapshot retained');
411
- }
412
-
413
- appendExact(snapshot, { onPhase = () => {} } = {}) {
414
- this.validateSnapshot(snapshot);
415
- this.requireCaptureConsent(snapshot);
416
- const stored = this.run([
417
- 'memory', 'store', '--key', snapshot.eventKey, '--value', JSON.stringify(snapshot),
418
- '--namespace', PROGRESSION_NAMESPACE, '--no-upsert', '--require-native', '--append-only', '--provenance', 'system_observation',
419
- '--path', this.resolution.canonicalAgentDbPath,
420
- ]);
421
- const alreadyStored = resultStatus(stored) !== 0;
422
- if (alreadyStored) {
423
- const diagnostic = `${resultText(stored, 'stderr')}\n${resultText(stored, 'stdout')}`.replace(/\x1b\[[0-9;]*m/g, '');
424
- const isDuplicate = line => {
425
- const reason = line.trim().replace(/^\[ERROR\]\s*/, '').replace(/^Failed to store:\s*/, '');
426
- return reason === 'immutable append rejected: logical key already exists'
427
- || reason === `key "${snapshot.eventKey}" already exists in namespace "${PROGRESSION_NAMESPACE}" — pass upsert=true (--upsert on the CLI) to update it`;
428
- };
429
- const lines = diagnostic.split(/\r?\n/).map(line => line.trim()).filter(Boolean);
430
- const duplicate = lines.some(isDuplicate) && lines.filter(line => /^\[ERROR\]|^Failed to store:/i.test(line)).every(isDuplicate)
431
- && !/\b(?:unavailable|refus(?:ed|ing|al)|permission denied|native WAL|unsafe write)\b/i.test(diagnostic);
432
- if (resultStatus(stored) !== 1 || stored.error || stored.signal || !duplicate) {
433
- throw new Error(`progression native append failed: ${resultText(stored, 'stderr').trim() || resultText(stored, 'stdout').trim() || 'unknown error'}`);
434
- }
435
- } else onPhase('stored');
436
-
437
- // THE READ-BACK, through the fast path when it is available.
438
- //
439
- // This is not a shortcut past the verification — it IS the verification, taken by an independent
440
- // route. Reading the row back with the same CLI process family that just wrote it proves the CLI
441
- // agrees with itself; reading the bytes off disk with node:sqlite proves the row is really there.
442
- // It also matters for the budget: a capture boundary gets 8s, and a cold `ruflo memory` call
443
- // measured ~3s in an isolated HOME (matching the 3.0-3.4s figure from the original report), so
444
- // replay + capture at two CLI calls each did not fit and left the new snapshot uncommitted in the
445
- // outbox. One CLI write plus a ~1ms read fits with room to spare. The CLI remains the fallback.
446
- let readbackText = null;
447
- const fast = this.readFast((reader) => reader.readContent(PROGRESSION_NAMESPACE, snapshot.eventKey));
448
- if (fast.ok && typeof fast.value === 'string') {
449
- this.lastReadPath = 'node:sqlite';
450
- readbackText = fast.value;
451
- } else {
452
- this.lastReadPath = `ruflo-cli (${fast.ok ? 'row absent' : fast.reason})`;
453
- const retrieved = this.run([
454
- 'memory', 'retrieve', '--key', snapshot.eventKey, '--namespace', PROGRESSION_NAMESPACE,
455
- '--value-only', '--path', this.resolution.canonicalAgentDbPath,
456
- ]);
457
- if (resultStatus(retrieved) !== 0) {
458
- const storeFailure = alreadyStored
459
- ? `; store failed: ${resultText(stored, 'stderr').trim() || 'unknown error'}`
460
- : '';
461
- throw new Error(`progression readback failed: ${resultText(retrieved, 'stderr').trim() || 'unknown error'}${storeFailure}`);
462
- }
463
- readbackText = resultText(retrieved, 'stdout');
464
- }
465
- let readback;
466
- try { readback = JSON.parse(readbackText); } catch { throw new Error('progression readback is not JSON'); }
467
- if (readback.payloadDigest !== snapshot.payloadDigest || digestCanonical(readback) !== digestCanonical(snapshot)) {
468
- throw new Error('progression readback digest mismatch');
469
- }
470
- onPhase('readback-verified');
471
- return {
472
- eventKey: snapshot.eventKey,
473
- payloadDigest: snapshot.payloadDigest,
474
- readbackDigest: readback.payloadDigest,
475
- alreadyStored,
476
- committedAt: this.clock(),
477
- };
478
- }
479
-
480
- capture(snapshot, { onPhase = () => {} } = {}) {
481
- this.validateSnapshot(snapshot);
482
- this.requireCaptureConsent(snapshot);
483
- this.outbox.appendSnapshot(snapshot);
484
- onPhase('outbox-fsynced');
485
- const receipt = this.appendExact(snapshot, { onPhase });
486
- this.outbox.markCommitted(receipt);
487
- return receipt;
488
- }
489
-
490
- /** Frozen queue recovery never rewrites the conflicting row or marks its key committed. */
491
- captureFrozen(snapshot, { canCommit } = {}) {
492
- if (typeof canCommit !== 'function') throw new Error('frozen recovery requires replay fencing');
493
- const fenced = () => { if (!canCommit()) throw new Error('progression recovery lost replay fencing'); };
494
- fenced();
495
- try { return { snapshot, receipt: this.capture(snapshot, { onPhase: fenced }) }; } catch (error) {
496
- if (error.message !== 'progression readback digest mismatch') throw error;
497
- }
498
- // An independently retrieved exact canonical value must be valid for this project. A failed,
499
- // absent, malformed or foreign read is never evidence that the immutable key collided.
500
- const exact = this.retrieveSnapshots([snapshot.eventKey]);
501
- if (exact.rejected.length || exact.snapshots.length !== 1) throw new Error('immutable collision is not verified');
502
- const existing = exact.snapshots[0];
503
- this.validateSnapshot(existing);
504
- if (existing.eventKey !== snapshot.eventKey || existing.payloadDigest === snapshot.payloadDigest) {
505
- throw new Error('immutable collision is not verified');
506
- }
507
- const identity = { originalEventKey: snapshot.eventKey,
508
- frozenPayloadDigest: snapshot.payloadDigest, existingPayloadDigest: existing.payloadDigest };
509
- const created = createProgressionSnapshot({ ...snapshot,
510
- dedupId: `immutable-recovery:${digestCanonical(identity)}` });
511
- // Preserve the frozen state and source exactly; diagnostics carry no instruction authority.
512
- const { payloadDigest: _digest, ...body } = created;
513
- body.redactions = snapshot.redactions;
514
- body.recoveryDiagnostics = { kind: 'immutable-event-key-collision', authoritative: false, ...identity };
515
- const recovered = Object.freeze({ ...body, payloadDigest: digestCanonical(body) });
516
- this.validateSnapshot(recovered);
517
- this.requireCaptureConsent(recovered);
518
- fenced();
519
- const receipt = this.capture(recovered, { onPhase: fenced });
520
- fenced();
521
- this.outbox.markRecovered(snapshot, receipt);
522
- return { snapshot: recovered, receipt };
523
- }
524
-
525
- replay() {
526
- const receipts = [];
527
- for (const snapshot of this.outbox.pendingSnapshots()) {
528
- const receipt = this.appendExact(snapshot);
529
- this.outbox.markCommitted(receipt);
530
- receipts.push(receipt);
531
- }
532
- return receipts;
533
- }
534
-
535
- listSnapshotKeys({ pageSize = 100, maxEntries = 10_000 } = {}) {
536
- requirePositiveInteger(pageSize, 'pageSize');
537
- requirePositiveInteger(maxEntries, 'maxEntries');
538
- if (pageSize > maxEntries) throw new Error('pageSize exceeds the enumeration bound');
539
- const fast = this.readFast((reader) => reader.listKeys(PROGRESSION_NAMESPACE, { maxEntries }));
540
- if (fast.ok) {
541
- this.lastReadPath = 'node:sqlite';
542
- return fast.value;
543
- }
544
- this.lastReadPath = `ruflo-cli (${fast.reason})`;
545
- return this.listSnapshotKeysViaCli({ pageSize, maxEntries });
546
- }
547
-
548
- listSnapshotKeysViaCli({ pageSize = 100, maxEntries = 10_000 } = {}) {
549
- const keys = [];
550
- const seen = new Set();
551
- let offset = 0;
552
- let total = null;
553
- let protocol = null;
554
- let limit = pageSize;
555
- do {
556
- const listed = this.run([
557
- 'memory', 'list', '--namespace', PROGRESSION_NAMESPACE,
558
- '--limit', String(limit), '--offset', String(offset), '--page-info', '--format', 'json',
559
- '--path', this.resolution.canonicalAgentDbPath,
560
- ]);
561
- if (resultStatus(listed) !== 0) {
562
- throw new Error(`progression structural pagination failed: ${resultText(listed, 'stderr').trim() || 'unknown error'}`);
563
- }
564
- const page = parseJson(resultText(listed, 'stdout'), 'progression pagination page');
565
- const nextProtocol = Array.isArray(page) ? 'array' : 'page';
566
- if (protocol && protocol !== nextProtocol) throw new Error('enumeration protocol changed during restoration');
567
- protocol = nextProtocol;
568
- if (protocol === 'array') {
569
- // Global Ruflo's CLI returns the first `limit` entries and ignores offset/page-info.
570
- // Grow from zero until its result is shorter than the requested limit. The current
571
- // CLI honors limit (memory.js -> listEntries); a full response at our cap is ambiguous
572
- // and must never become a partial successful restore.
573
- if (page.length > limit) throw new Error('malformed pagination array');
574
- const current = new Set();
575
- for (const entry of page) {
576
- if (!plainRecord(entry) || typeof entry.key !== 'string' || !entry.key
577
- || entry.namespace !== PROGRESSION_NAMESPACE) throw new Error('malformed pagination entry');
578
- if (current.has(entry.key)) throw new Error(`duplicate progression key in array: ${entry.key}`);
579
- current.add(entry.key);
580
- }
581
- if ([...seen].some((key) => !current.has(key))) throw new Error('enumeration keys changed during restoration');
582
- if (page.length < limit) return [...current].sort();
583
- if (limit === maxEntries) throw new Error('progression enumeration reached its bound without proving completeness');
584
- for (const key of current) seen.add(key);
585
- limit = Math.min(maxEntries, limit * 2);
586
- continue;
587
- }
588
- total = validatePage(page, { offset, pageSize, total });
589
- if (total > maxEntries) throw new Error('progression enumeration exceeds its bound');
590
- for (const entry of page.entries) {
591
- if (!plainRecord(entry) || typeof entry.key !== 'string' || !entry.key
592
- || entry.namespace !== PROGRESSION_NAMESPACE) throw new Error('malformed pagination entry');
593
- if (seen.has(entry.key)) throw new Error(`duplicate progression key across pages: ${entry.key}`);
594
- seen.add(entry.key);
595
- keys.push(entry.key);
596
- }
597
- if (!page.hasMore) break;
598
- offset = page.nextOffset;
599
- } while (true);
600
- if (keys.length !== total) throw new Error('pagination did not enumerate the declared total');
601
- return keys.sort();
602
- }
603
-
604
- retrieveSnapshots(keys) {
605
- // The whole batch through ONE read-only handle, or the whole batch through the CLI. Never a
606
- // mixture: a half-served batch would make "exactly these rows, read exactly this way" untrue.
607
- const fast = this.readFast((reader) => {
608
- const snapshots = [];
609
- const rejected = [];
610
- for (const key of keys) {
611
- const content = reader.readContent(PROGRESSION_NAMESPACE, key);
612
- if (content === null) throw new Error(`progression exact retrieval failed for ${key}: row not found`);
613
- let snapshot;
614
- try { snapshot = JSON.parse(content); } catch {
615
- rejected.push({ eventKey: key, reasons: ['readback is not JSON'] });
616
- continue;
617
- }
618
- if (!plainRecord(snapshot) || snapshot.eventKey !== key) {
619
- rejected.push({ eventKey: key, reasons: ['exact key/payload identity mismatch'] });
620
- continue;
621
- }
622
- snapshots.push(snapshot);
623
- }
624
- return { snapshots, rejected: sortRejected(rejected) };
625
- });
626
- if (fast.ok) {
627
- this.lastReadPath = 'node:sqlite';
628
- return fast.value;
629
- }
630
- this.lastReadPath = `ruflo-cli (${fast.reason})`;
631
- return this.retrieveSnapshotsViaCli(keys);
632
- }
633
-
634
- retrieveSnapshotsViaCli(keys) {
635
- const snapshots = [];
636
- const rejected = [];
637
- for (const key of keys) {
638
- const retrieved = this.run([
639
- 'memory', 'retrieve', '--key', key, '--namespace', PROGRESSION_NAMESPACE,
640
- '--value-only', '--path', this.resolution.canonicalAgentDbPath,
641
- ]);
642
- if (resultStatus(retrieved) !== 0) {
643
- throw new Error(`progression exact retrieval failed for ${key}: ${resultText(retrieved, 'stderr').trim() || 'unknown error'}`);
644
- }
645
- let snapshot;
646
- try { snapshot = JSON.parse(resultText(retrieved, 'stdout')); } catch {
647
- rejected.push({ eventKey: key, reasons: ['readback is not JSON'] });
648
- continue;
649
- }
650
- if (!plainRecord(snapshot) || snapshot.eventKey !== key) {
651
- rejected.push({ eventKey: key, reasons: ['exact key/payload identity mismatch'] });
652
- continue;
653
- }
654
- snapshots.push(snapshot);
655
- }
656
- return { snapshots, rejected: sortRejected(rejected) };
657
- }
658
-
659
- /** How many durable snapshots are fsynced but not yet committed to the canonical store. */
660
- pendingReplayCount() {
661
- try { return this.outbox.pendingSnapshots().length; } catch { return null; }
662
- }
663
-
664
- /**
665
- * @param {{ replayPending?: boolean }} options
666
- * `replayPending: false` restores from COMMITTED rows only. SessionStart uses it because replay
667
- * is a WRITE, a write is a `ruflo memory store` process, and one of those alone costs more than
668
- * the entire SessionStart budget — so a restore that replayed would time out and report UNKNOWN
669
- * precisely when there was durable evidence to show. Pending work is REPORTED here and replayed
670
- * at the next capture boundary (Stop / PreCompact / SessionEnd) or by /checkpoint, which are the
671
- * boundaries that already own a write budget. The outbox's fsync-then-commit ordering and its
672
- * replay-required semantics are untouched: nothing is dropped, only deferred.
673
- */
674
- restoreLatest({ pageSize = 100, maxEntries = 10_000, maxOutputBytes = 64 * 1024,
675
- replayPending = true, projectToBound = false } = {}) {
676
- requirePositiveInteger(maxOutputBytes, 'maxOutputBytes');
677
- if (replayPending) this.replay();
678
- const pendingReplay = replayPending ? 0 : this.pendingReplayCount();
679
- const keys = this.listSnapshotKeys({ pageSize, maxEntries });
680
- const exact = this.retrieveSnapshots(keys);
681
- const restored = restoreProjectProgression(exact.snapshots, {
682
- expectedProjectIdentity: this.resolution.projectIdentity,
683
- });
684
- const rejectedCandidates = sortRejected([
685
- ...exact.rejected,
686
- ...restored.rejected.filter((row) => row.reasons.some((reason) => reason !== 'causally stale')),
687
- ]);
688
- if (!restored.ok) {
689
- const error = new Error('no coherent progression state could be restored');
690
- error.rejectedCandidates = rejectedCandidates;
691
- error.structurallyEnumerated = keys.length;
692
- error.pendingReplay = pendingReplay;
693
- throw error;
694
- }
695
- const payload = {
696
- schema: RESUME_SCHEMA,
697
- schemaVersion: RESUME_VERSION,
698
- projectIdentity: this.resolution.projectIdentity,
699
- heads: restored.heads,
700
- state: restored.state,
701
- evidence: {
702
- structurallyEnumerated: keys.length,
703
- exactRetrieved: keys.length,
704
- causallyStale: restored.causallyStale.length,
705
- rejectedCandidates,
706
- readPath: this.lastReadPath,
707
- pendingReplay,
708
- },
709
- };
710
- let rendered = JSON.stringify(payload);
711
- let finalPayload = payload;
712
- let projected = false;
713
- if (Buffer.byteLength(rendered, 'utf8') > maxOutputBytes) {
714
- const bounded = projectToBound ? projectResumePayloadToBound(payload, maxOutputBytes) : null;
715
- if (!bounded) {
716
- throw new Error(`resume payload${projectToBound ? ' and mandatory bounded summary' : ''}`
717
- + ` exceeds the ${maxOutputBytes}-byte output bound`);
718
- }
719
- ({ payload: finalPayload, rendered, projected } = bounded);
720
- }
721
- return { payload: finalPayload, rendered, pendingReplay, projected };
722
- }
723
- }