ruvnet-brain 4.3.21 → 4.3.26

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 (143) hide show
  1. package/README.md +5 -5
  2. package/bin/install.mjs +275 -60
  3. package/console/app.js +189 -9
  4. package/console/index.html +70 -24
  5. package/console/scope.css +137 -0
  6. package/console/scope.html +144 -0
  7. package/console/scope.js +209 -0
  8. package/console/style.css +26 -0
  9. package/console/tips.html +1 -0
  10. package/kb/corpus-release-identity.mjs +239 -0
  11. package/kb/update-storage-transaction.mjs +20 -3
  12. package/package.json +9 -2
  13. package/plugin/.claude-plugin/plugin.json +2 -2
  14. package/plugin/.codex-plugin/plugin.json +1 -1
  15. package/plugin/commands/checkpoint.md +61 -0
  16. package/plugin/hooks/codex-hooks.json +64 -1
  17. package/plugin/hooks/hook-contracts.json +299 -6
  18. package/plugin/hooks/hooks.json +81 -1
  19. package/plugin/mcp/server.mjs +23 -0
  20. package/plugin/scripts/advocacy-catalog.mjs +245 -0
  21. package/plugin/scripts/advocacy-route.mjs +460 -0
  22. package/plugin/scripts/continuation-gate.mjs +25 -2
  23. package/plugin/scripts/continuation-objective.mjs +7 -1
  24. package/plugin/scripts/continuity-hook-policy.mjs +190 -15
  25. package/plugin/scripts/coverage-integrity.mjs +7 -0
  26. package/plugin/scripts/gates.mjs +113 -10
  27. package/plugin/scripts/grounding-turn-gate.mjs +167 -0
  28. package/plugin/scripts/grounding-turn-mark.mjs +91 -0
  29. package/plugin/scripts/hook-shim.mjs +14 -0
  30. package/plugin/scripts/nightly-scheduler.mjs +37 -4
  31. package/plugin/scripts/project-progression-checkpoint.mjs +145 -0
  32. package/plugin/scripts/project-progression-contract.mjs +16 -0
  33. package/plugin/scripts/project-progression-hook.mjs +3 -0
  34. package/plugin/scripts/project-progression-producer.mjs +252 -0
  35. package/plugin/scripts/project-progression-reader.mjs +271 -0
  36. package/plugin/scripts/project-progression-session-start.mjs +93 -16
  37. package/plugin/scripts/project-progression-sources.mjs +220 -0
  38. package/plugin/scripts/project-progression-store.mjs +106 -13
  39. package/plugin/scripts/ruvnet-gate1-pattern.mjs +29 -0
  40. package/plugin/scripts/session-snapshot-hook.mjs +115 -7
  41. package/plugin/scripts/session-start-budget.mjs +59 -0
  42. package/plugin/scripts/session-start-core.mjs +234 -457
  43. package/plugin/scripts/session-start-fsutil.mjs +61 -0
  44. package/plugin/scripts/session-start-health.mjs +64 -0
  45. package/plugin/scripts/session-start-hook-description.mjs +45 -0
  46. package/plugin/scripts/session-start-issue-alert.mjs +77 -0
  47. package/plugin/scripts/session-start-repo-identity.mjs +54 -0
  48. package/plugin/scripts/session-start-signals.mjs +73 -0
  49. package/plugin/scripts/session-start-trace.mjs +86 -0
  50. package/plugin/scripts/session-start-update-plane.mjs +104 -0
  51. package/plugin/scripts/unprompted-runtime.mjs +32 -2
  52. package/plugin/skills/ruvnet-brain/PLAYBOOK.md +26 -2
  53. package/plugin/skills/ruvnet-brain/SKILL.md +67 -2
  54. package/scripts/adr-072-completion.mjs +1 -1
  55. package/scripts/agentdb-fleet-doctor.mjs +5 -1
  56. package/scripts/approved-runtime.mjs +197 -0
  57. package/scripts/brain-novice-50.mjs +16 -1
  58. package/scripts/brain-score.mjs +23 -5
  59. package/scripts/build-bundle.mjs +971 -530
  60. package/scripts/build-concepts.mjs +36 -116
  61. package/scripts/console-engine.test.mjs +8 -7
  62. package/scripts/console-runtime-identity.mjs +4 -0
  63. package/scripts/corpus-aggregates.mjs +94 -77
  64. package/scripts/corpus-candidate.mjs +475 -222
  65. package/scripts/corpus-next-seed.mjs +225 -0
  66. package/scripts/corpus-promotion.mjs +58 -0
  67. package/scripts/corpus-reconcile.mjs +411 -105
  68. package/scripts/doc-currency.mjs +16 -1
  69. package/scripts/dual-host-deliberation.mjs +25 -2
  70. package/scripts/dual-host-suggest.mjs +17 -1
  71. package/scripts/falsify.mjs +13 -3
  72. package/scripts/gist-receipts.mjs +482 -87
  73. package/scripts/github-health-watch.mjs +12 -2
  74. package/scripts/handoff-asset.mjs +34 -0
  75. package/scripts/hook-retirement-check.mjs +8 -1
  76. package/scripts/host-registry.mjs +1 -1
  77. package/scripts/ingest-gists.mjs +74 -101
  78. package/scripts/job-heartbeat.sh +77 -14
  79. package/scripts/learning-replay-execution.mjs +10 -4
  80. package/scripts/nightly-gists.sh +27 -13
  81. package/scripts/nightly-two-run-proof.mjs +1 -1
  82. package/scripts/nightly-watchdog.mjs +61 -4
  83. package/scripts/onboarding-console.mjs +364 -28
  84. package/scripts/oracle/produce-questions.mjs +293 -0
  85. package/scripts/oracle/producer-hosts.mjs +235 -0
  86. package/scripts/oracle/repo-recall.mjs +448 -0
  87. package/scripts/oracle/retrieval-accuracy.mjs +818 -0
  88. package/scripts/oracle/source-tree.mjs +165 -0
  89. package/scripts/oracle/source-units.mjs +391 -0
  90. package/scripts/oracle/spike-run.mjs +98 -0
  91. package/scripts/oracle/unit-inventory.mjs +141 -0
  92. package/scripts/oracle/unit-sampling.mjs +128 -0
  93. package/scripts/oracle/validate-labels.mjs +250 -0
  94. package/scripts/private-overlay.mjs +248 -0
  95. package/scripts/product-integrity-contract.mjs +1 -1
  96. package/scripts/proxy/claude-proxied.sh +6 -0
  97. package/scripts/proxy/proxy-revert.sh +5 -0
  98. package/scripts/proxy/proxy-up.sh +6 -0
  99. package/scripts/proxy/proxy-verify.mjs +4 -0
  100. package/scripts/public-inputs.mjs +409 -0
  101. package/scripts/public-verification-inputs.mjs +112 -26
  102. package/scripts/public-verification-lane.mjs +1 -1
  103. package/scripts/published-surface-probe.mjs +34 -4
  104. package/scripts/qe/card-lane-gate.mjs +16 -1
  105. package/scripts/qe/session-start-gate.mjs +16 -1
  106. package/scripts/rebuild-gists-from-receipts.mjs +58 -78
  107. package/scripts/record-lesson.mjs +4 -1
  108. package/scripts/rehearse-corpus-pipeline.mjs +994 -0
  109. package/scripts/release-abort-stale.mjs +5 -1
  110. package/scripts/release-authority.mjs +104 -12
  111. package/scripts/release-channel-kind.mjs +86 -0
  112. package/scripts/release-convergence-watchdog.mjs +7 -2
  113. package/scripts/release-projection.mjs +177 -72
  114. package/scripts/release-transaction-provider.mjs +47 -10
  115. package/scripts/release-transaction.mjs +40 -11
  116. package/scripts/release.mjs +252 -17
  117. package/scripts/retrieval-canary.mjs +87 -0
  118. package/scripts/rvf-index-audit.mjs +573 -13
  119. package/scripts/rvf-wire.mjs +269 -0
  120. package/scripts/seal-gist-receipt.mjs +65 -0
  121. package/scripts/selfcheck.mjs +42 -21
  122. package/scripts/source-coverage.mjs +253 -24
  123. package/scripts/status-honesty.mjs +25 -0
  124. package/scripts/sync-census.mjs +0 -0
  125. package/scripts/sync-version.mjs +2 -0
  126. package/scripts/trismart.mjs +42 -0
  127. package/scripts/updater-manifest.mjs +162 -0
  128. package/scripts/verify-channels.mjs +17 -5
  129. package/scripts/wired-check.mjs +48 -10
  130. package/tri-smart-skill/QUICKSTART.md +37 -0
  131. package/tri-smart-skill/README.md +92 -0
  132. package/tri-smart-skill/install.cmd +14 -0
  133. package/tri-smart-skill/install.command +13 -0
  134. package/tri-smart-skill/install.mjs +51 -0
  135. package/tri-smart-skill/install.sh +9 -0
  136. package/tri-smart-skill/tri-smart/SKILL.md +90 -0
  137. package/tri-smart-skill/tri-smart/evals/evals.json +25 -0
  138. package/tri-smart-skill/tri-smart/references/protocol.md +25 -0
  139. package/tri-smart-skill/tri-smart/references/provider-cli.md +18 -0
  140. package/tri-smart-skill/tri-smart/scripts/review.mjs +154 -0
  141. package/tri-smart-skill/tri-smart/scripts/setup.mjs +97 -0
  142. package/tri-smart-skill/tri-smart/scripts/verify-access.mjs +107 -0
  143. package/scripts/corpus-seed-publish.mjs +0 -110
@@ -0,0 +1,271 @@
1
+ /**
2
+ * project-progression-reader.mjs — the READ-ONLY fast path for project continuity restoration.
3
+ *
4
+ * WHY THIS EXISTS (measured, not supposed). `restoreProgressionForSession` gives the whole restore
5
+ * SESSION_CONTINUITY_DEADLINE_MS = 2500ms, and every read it performs is a separate `ruflo memory`
6
+ * PROCESS. Measured on this machine (ruflo 3.41.2, Node v24.18.0, warm Transformers cache), in an
7
+ * isolated temp git repo, with `plugin/scripts/project-progression-session-start.mjs` unchanged:
8
+ *
9
+ * 1 snapshot → restore 923 / 952 / 1001 ms → "PROJECT CONTINUITY RESTORED"
10
+ * 6 snapshots → restore 2519 / 2530 / 3181 ms → "PROJECT CONTINUITY UNKNOWN" (restore-failed)
11
+ *
12
+ * The cost is O(N) PROCESS SPAWNS: one `memory list` plus one `memory retrieve` per snapshot, each
13
+ * ~400ms of Node + CLI boot. So continuity does not fail loudly on day one — it decays silently as
14
+ * the journal grows, and the seventh checkpoint is the one that switches a project's memory off.
15
+ * A grep of the installed CLI (memory/memory-initializer.js, commands/memory.js, memory/memory-bridge.js)
16
+ * found exactly three memory env switches — CLAUDE_FLOW_DB_PATH, CLAUDE_FLOW_DISABLE_BRIDGE,
17
+ * CLAUDE_FLOW_MEMORY_PATH (plus RUFLO_MEMORY_SCAN_ON_WRITE) — and none of them skips CLI boot, so
18
+ * there is no flag to reach for. The per-call floor stays ~400ms however it is invoked.
19
+ *
20
+ * WHAT THIS DOES. Node 24 ships `node:sqlite`, so the same rows can be read in-process, read-only,
21
+ * in about a millisecond. `~/.claude/hooks/agentdb-ensure.sh` already reads this exact file directly
22
+ * with `sqlite3`, so direct reads of memory.db are the established precedent, not a new liberty.
23
+ *
24
+ * WHAT THIS DELIBERATELY DOES NOT DO. It never writes. `ruflo memory store` remains the ONLY writer
25
+ * of memory.db (upstream #2786/#3155/#3196 keep memory.db and agentdb-memory.db separate on purpose;
26
+ * a second writer is exactly the two-writers-one-path footgun the memory policy exists to stop). It
27
+ * is also strictly a FALLBACK-ABLE optimisation: any reason it cannot answer authoritatively —
28
+ * no node:sqlite, absent file, an encrypted image (CLAUDE_FLOW_ENCRYPT_AT_REST writes an "RFE1"
29
+ * blob, not a SQLite file), a WAL sidecar it cannot open read-only, a schema that is not this one —
30
+ * raises ProgressionReaderUnavailable, and the caller replays the whole operation through the CLI.
31
+ *
32
+ * Reading directly also AVOIDS a hazard the CLI has: `getEntry` bumps access_count and then rewrites
33
+ * the WHOLE database image (memory-initializer.js:3070-3077). Every "read" during a restore is
34
+ * therefore a full-image write under a lock. This path takes no lock and mutates nothing.
35
+ *
36
+ * STRUCTURAL GUARANTEES ARE UNCHANGED, because they are the point of the restore and not negotiable:
37
+ * • COMPLETE ENUMERATION — one statement returns every active row in the namespace; a count past
38
+ * the caller's bound is an error, never a silent truncation (the CLI path's own failure mode).
39
+ * • EXACT KEY/PAYLOAD IDENTITY — rows are fetched by (namespace, key); two active rows sharing one
40
+ * key is a structural error, not a LIMIT-1 coin toss.
41
+ * • DIGEST VERIFICATION — untouched: the caller still validates payloadDigest on every snapshot.
42
+ */
43
+ import fs from 'node:fs';
44
+ import { createRequire } from 'node:module';
45
+
46
+ /** Rows that `ruflo memory` itself considers live (memory-initializer.js ACTIVE_MEMORY_ROW_SQL). */
47
+ const ACTIVE_ROW_SQL = "(status = 'active' OR status IS NULL)";
48
+
49
+ /**
50
+ * Every SQLite database file begins with this 16-byte header (sqlite.org/fileformat.html §1.3):
51
+ * the ASCII text "SQLite format 3" followed by one NUL. Built from bytes rather than written as a
52
+ * string escape so no editor, patch tool, or copy-paste can silently turn that NUL into a space —
53
+ * which would make the header never match and quietly disable this whole fast path.
54
+ */
55
+ const SQLITE_HEADER = Buffer.concat([Buffer.from('SQLite format 3', 'latin1'), Buffer.of(0)]);
56
+
57
+ /**
58
+ * Raised when this reader cannot answer AUTHORITATIVELY. It is never a verdict about the data — it
59
+ * means "ask the CLI instead". A structural violation of the guarantees above throws a plain Error,
60
+ * which must propagate: downgrading "two rows claim one key" to a CLI retry would convert a
61
+ * detectable corruption into a silent, successful, wrong restore.
62
+ */
63
+ export class ProgressionReaderUnavailable extends Error {
64
+ constructor(reason) {
65
+ super(`canonical progression reader unavailable: ${reason}`);
66
+ this.name = 'ProgressionReaderUnavailable';
67
+ this.reason = reason;
68
+ }
69
+ }
70
+
71
+ /**
72
+ * THE PINNED SCHEMA FINGERPRINT — captured from the REAL canonical store on this machine
73
+ * (ruflo 3.41.2, a real project's own <project>/.swarm/memory.db), not transcribed from source:
74
+ *
75
+ * PRAGMA user_version -> 0
76
+ * PRAGMA table_info(memory_entries) -> the 18 columns below
77
+ *
78
+ * WHY PIN IT. Reading someone else's table is only safe while it is the table you measured. If a
79
+ * later ruflo renames `content`, adds a second liveness column, or partitions rows, a reader that
80
+ * merely SELECTs would answer confidently and WRONGLY — and the wrong answer here is "this project
81
+ * has no history", which is indistinguishable from a fresh project. So a fingerprint mismatch is not
82
+ * an error: it is this module standing down so the CLI, which OWNS the schema, answers instead.
83
+ * Slower is a cost. Silently empty is a lie.
84
+ */
85
+ const SCHEMA_FINGERPRINT = Object.freeze({
86
+ userVersion: 0,
87
+ columns: Object.freeze([
88
+ 'access_count', 'content', 'created_at', 'embedding', 'embedding_dimensions', 'embedding_model',
89
+ 'expires_at', 'id', 'key', 'last_accessed_at', 'metadata', 'namespace', 'owner_id',
90
+ 'provenance_type', 'status', 'tags', 'type', 'updated_at',
91
+ ]),
92
+ });
93
+
94
+ /** The fingerprint this module requires, so the doctor and tests can name it exactly. */
95
+ export function expectedSchemaFingerprint() {
96
+ return { userVersion: SCHEMA_FINGERPRINT.userVersion, columns: [...SCHEMA_FINGERPRINT.columns] };
97
+ }
98
+
99
+ function assertSchemaFingerprint(database) {
100
+ let columns;
101
+ let userVersion;
102
+ try {
103
+ columns = database.prepare('PRAGMA table_info(memory_entries)').all().map((row) => String(row.name)).sort();
104
+ userVersion = database.prepare('PRAGMA user_version').get()?.user_version;
105
+ } catch (error) {
106
+ throw new ProgressionReaderUnavailable(`schema mismatch: ${error.message}`);
107
+ }
108
+ if (columns.length === 0) throw new ProgressionReaderUnavailable('schema mismatch: memory_entries is absent');
109
+ if (userVersion !== SCHEMA_FINGERPRINT.userVersion) {
110
+ throw new ProgressionReaderUnavailable(
111
+ `schema fingerprint mismatch: user_version ${userVersion} is not ${SCHEMA_FINGERPRINT.userVersion}`);
112
+ }
113
+ if (columns.join(',') !== SCHEMA_FINGERPRINT.columns.join(',')) {
114
+ const missing = SCHEMA_FINGERPRINT.columns.filter((name) => !columns.includes(name));
115
+ const added = columns.filter((name) => !SCHEMA_FINGERPRINT.columns.includes(name));
116
+ throw new ProgressionReaderUnavailable('schema fingerprint mismatch: memory_entries columns differ'
117
+ + `${missing.length ? ` (missing ${missing.join('/')})` : ''}`
118
+ + `${added.length ? ` (unexpected ${added.join('/')})` : ''}`);
119
+ }
120
+ }
121
+
122
+ let sqliteBinding;
123
+ function databaseSync() {
124
+ if (sqliteBinding === undefined) {
125
+ try {
126
+ sqliteBinding = createRequire(import.meta.url)('node:sqlite').DatabaseSync ?? null;
127
+ } catch { sqliteBinding = null; }
128
+ }
129
+ return sqliteBinding;
130
+ }
131
+
132
+ /** True when this Node build exposes node:sqlite at all. Exported for diagnostics and tests. */
133
+ export function canonicalReaderSupported() {
134
+ return typeof databaseSync() === 'function';
135
+ }
136
+
137
+ function looksLikeSqliteFile(dbPath) {
138
+ let handle;
139
+ try {
140
+ handle = fs.openSync(dbPath, 'r');
141
+ } catch {
142
+ return false;
143
+ }
144
+ try {
145
+ const head = Buffer.alloc(SQLITE_HEADER.length);
146
+ const read = fs.readSync(handle, head, 0, head.length, 0);
147
+ return read === head.length && head.equals(SQLITE_HEADER);
148
+ } catch {
149
+ return false;
150
+ } finally {
151
+ fs.closeSync(handle);
152
+ }
153
+ }
154
+
155
+ function requireKey(value) {
156
+ if (typeof value !== 'string' || !value) throw new Error('malformed progression row: missing key');
157
+ return value;
158
+ }
159
+
160
+ /**
161
+ * Open a read-only view of one canonical memory.db.
162
+ *
163
+ * @returns {{ listKeys: Function, readContent: Function, close: Function }}
164
+ * @throws {ProgressionReaderUnavailable} when the CLI must be used instead.
165
+ */
166
+ export function openProgressionReader(dbPath) {
167
+ const DatabaseSync = databaseSync();
168
+ if (typeof DatabaseSync !== 'function') throw new ProgressionReaderUnavailable('node:sqlite is unavailable');
169
+ if (typeof dbPath !== 'string' || !dbPath) throw new ProgressionReaderUnavailable('no canonical store path');
170
+ let stat;
171
+ try { stat = fs.statSync(dbPath); } catch { throw new ProgressionReaderUnavailable('canonical store does not exist'); }
172
+ if (!stat.isFile()) throw new ProgressionReaderUnavailable('canonical store is not a regular file');
173
+ // An encrypted image (CLAUDE_FLOW_ENCRYPT_AT_REST) starts with the vault's "RFE1" magic, so the
174
+ // header check covers encryption, truncation and any other non-SQLite blob in one test — before
175
+ // node:sqlite gets a chance to report the generic "file is not a database".
176
+ if (!looksLikeSqliteFile(dbPath)) throw new ProgressionReaderUnavailable('canonical store is not a plain SQLite image');
177
+
178
+ let database;
179
+ try {
180
+ database = new DatabaseSync(dbPath, { readOnly: true });
181
+ } catch (error) {
182
+ throw new ProgressionReaderUnavailable(`read-only open failed: ${error.message}`);
183
+ }
184
+
185
+ try {
186
+ assertSchemaFingerprint(database);
187
+ } catch (error) {
188
+ try { database.close(); } catch { /* the fingerprint verdict is the news */ }
189
+ throw error;
190
+ }
191
+
192
+ const prepare = (sql) => {
193
+ try { return database.prepare(sql); } catch (error) {
194
+ throw new ProgressionReaderUnavailable(`schema mismatch: ${error.message}`);
195
+ }
196
+ };
197
+ let listStatement;
198
+ let readStatement;
199
+ try {
200
+ // Prepared eagerly so an unexpected schema (or a WAL image this process cannot read) is
201
+ // reported as UNAVAILABLE now, before the caller has committed to the fast path.
202
+ listStatement = prepare(`SELECT key FROM memory_entries WHERE ${ACTIVE_ROW_SQL} AND namespace = ? ORDER BY key`);
203
+ readStatement = prepare(`SELECT content FROM memory_entries WHERE ${ACTIVE_ROW_SQL} AND namespace = ? AND key = ?`);
204
+ } catch (error) {
205
+ try { database.close(); } catch { /* the open failure is the news */ }
206
+ throw error;
207
+ }
208
+
209
+ const query = (statement, params) => {
210
+ try { return statement.all(...params); } catch (error) {
211
+ throw new ProgressionReaderUnavailable(`read failed: ${error.message}`);
212
+ }
213
+ };
214
+
215
+ return {
216
+ /** Every active key in `namespace`, sorted, deduplicated-by-error, bounded. */
217
+ listKeys(namespace, { maxEntries = 10_000 } = {}) {
218
+ if (!Number.isSafeInteger(maxEntries) || maxEntries < 1) throw new TypeError('maxEntries must be a positive safe integer');
219
+ const rows = query(listStatement, [namespace]);
220
+ if (rows.length > maxEntries) throw new Error('progression enumeration exceeds its bound');
221
+ const keys = [];
222
+ const seen = new Set();
223
+ for (const row of rows) {
224
+ const key = requireKey(row?.key);
225
+ if (seen.has(key)) throw new Error(`duplicate progression key in store: ${key}`);
226
+ seen.add(key);
227
+ keys.push(key);
228
+ }
229
+ return keys;
230
+ },
231
+
232
+ /** The exact stored value for one (namespace, key), or null when that row does not exist. */
233
+ readContent(namespace, key) {
234
+ requireKey(key);
235
+ const rows = query(readStatement, [namespace, key]);
236
+ if (rows.length === 0) return null;
237
+ if (rows.length > 1) throw new Error(`duplicate progression key in store: ${key}`);
238
+ const content = rows[0]?.content;
239
+ // A non-text column is not this schema; fall back rather than guess at an encoding.
240
+ if (typeof content !== 'string') throw new ProgressionReaderUnavailable('progression row content is not text');
241
+ return content;
242
+ },
243
+
244
+ close() {
245
+ try { database.close(); } catch { /* closing a spent read handle is never news */ }
246
+ },
247
+ };
248
+ }
249
+
250
+ /**
251
+ * Run `work(reader)` against a read-only view, closing it afterwards.
252
+ * Returns `{ ok: true, value }`, or `{ ok: false, reason }` when the CLI must be used instead.
253
+ * Structural errors are NOT converted — they propagate, by design (see ProgressionReaderUnavailable).
254
+ */
255
+ export function withProgressionReader(dbPath, work) {
256
+ let reader;
257
+ try {
258
+ reader = openProgressionReader(dbPath);
259
+ } catch (error) {
260
+ if (error instanceof ProgressionReaderUnavailable) return { ok: false, reason: error.reason };
261
+ throw error;
262
+ }
263
+ try {
264
+ return { ok: true, value: work(reader) };
265
+ } catch (error) {
266
+ if (error instanceof ProgressionReaderUnavailable) return { ok: false, reason: error.reason };
267
+ throw error;
268
+ } finally {
269
+ reader.close();
270
+ }
271
+ }
@@ -3,6 +3,9 @@ import path from 'node:path';
3
3
  import { spawnSync } from 'node:child_process';
4
4
  import { ProjectProgressionStore } from './project-progression-store.mjs';
5
5
  import { resolveProjectStore } from './project-store-resolver.mjs';
6
+ import { withProgressionReader } from './project-progression-reader.mjs';
7
+
8
+ const PROGRESSION_NAMESPACE = 'project-progression';
6
9
 
7
10
  export const SESSION_CONTINUITY_LIMIT_BYTES = 8 * 1024;
8
11
  export const SESSION_CONTINUITY_DEADLINE_MS = 2_500;
@@ -21,17 +24,56 @@ const UNKNOWN_EXPLANATIONS = Object.freeze({
21
24
  'output-bound': 'The verified resume payload exceeds the host context bound.',
22
25
  'no-coherent-state': 'No coherent progression head survived validation.',
23
26
  'restore-failed': 'The exact structural restore did not complete.',
27
+ // MEASURED, and named rather than hidden. The in-process read path costs ~15ms for six snapshots;
28
+ // the `ruflo memory` CLI fallback costs 3634ms for the same six and does NOT fit the 2500ms
29
+ // restore deadline. Both produce an identical result, so this is a SPEED limit, not a correctness
30
+ // one — but a user whose store is encrypted, or whose Node lacks node:sqlite, will lose continuity
31
+ // past a handful of snapshots, and they deserve to be told which of the two it was.
32
+ 'fallback-too-slow': 'The in-process read path was unavailable, and the managed Ruflo CLI fallback'
33
+ + ' could not finish inside the restore deadline.',
24
34
  });
25
35
 
26
- function unknown(reason) {
36
+ /**
37
+ * UNKNOWN IS NOT NEUTRAL WHEN THERE WAS SOMETHING TO FIND.
38
+ *
39
+ * "I could not read your store" and "your store is empty" render almost identically to a reader, and
40
+ * the first is a FAILURE: verified progression exists on disk and the session is about to proceed
41
+ * without it. So when the canonical store exists and holds progression rows, the banner says so in
42
+ * as many words and the result carries `severity: 'error'` for any surface that colours its output.
43
+ * When we cannot even count the rows, the severity stays 'warning' — claiming a failure we cannot
44
+ * evidence would be the same sin one step over.
45
+ */
46
+ function unknown(reason, { rowCount = null } = {}) {
27
47
  const explanation = UNKNOWN_EXPLANATIONS[reason] ?? UNKNOWN_EXPLANATIONS['restore-failed'];
48
+ const failing = Number.isInteger(rowCount) && rowCount > 0;
49
+ const header = failing ? `${UNKNOWN_HEADER} — RESTORE FAILED` : UNKNOWN_HEADER;
50
+ const evidence = failing
51
+ ? ` ${rowCount} verified progression row(s) are present in the canonical store and could NOT be restored;`
52
+ + ' treat this as a failure to recover known state, not as a project without history.'
53
+ : '';
28
54
  return {
29
55
  status: 'unknown',
30
56
  reason,
31
- context: `${UNKNOWN_HEADER}\n${explanation} Do not claim project state was restored; verify the canonical store before relying on remembered state.`,
57
+ severity: failing ? 'error' : 'warning',
58
+ rowCount,
59
+ context: `${header}\n${explanation}${evidence}`
60
+ + ' Do not claim project state was restored; verify the canonical store before relying on remembered state.',
32
61
  };
33
62
  }
34
63
 
64
+ /**
65
+ * Count committed progression rows WITHOUT paying for a restore. Used only to decide how loudly an
66
+ * UNKNOWN should speak, so it never throws and never falls back to a CLI spawn: a count we cannot
67
+ * take cheaply is reported as null ("cannot tell"), which downgrades the banner rather than the run.
68
+ */
69
+ function committedRowCount(canonicalAgentDbPath) {
70
+ try {
71
+ const result = withProgressionReader(canonicalAgentDbPath,
72
+ (reader) => reader.listKeys(PROGRESSION_NAMESPACE).length);
73
+ return result.ok ? result.value : null;
74
+ } catch { return null; }
75
+ }
76
+
35
77
  function classify(error) {
36
78
  const message = String(error?.message ?? error ?? '');
37
79
  if (/ruflo was not found|managed global ruflo/i.test(message)) return 'managed-ruflo-unavailable';
@@ -95,16 +137,33 @@ function initializeCanonicalStore(store, resolution) {
95
137
  }
96
138
  }
97
139
 
140
+ /**
141
+ * One line, only when there is something to say. Durable-but-uncommitted snapshots are evidence the
142
+ * session must know about: they will be committed at the next capture boundary, and until then the
143
+ * restored head is not the newest thing that happened.
144
+ */
145
+ function pendingNotice(pendingReplay) {
146
+ if (!Number.isInteger(pendingReplay) || pendingReplay < 1) return '';
147
+ return `\n${pendingReplay} uncommitted snapshot(s) pending replay; they commit at the next capture`
148
+ + ' boundary (Stop / PreCompact / SessionEnd) or when you run /ruvnet-brain:checkpoint.';
149
+ }
150
+
98
151
  function isProject(resolution) {
99
152
  if (resolution.kind === 'git') return true;
100
153
  return ['.swarm', '.claude-flow', 'package.json', 'pyproject.toml', 'Cargo.toml', 'go.mod']
101
154
  .some((name) => fs.existsSync(path.join(resolution.projectRoot, name)));
102
155
  }
103
156
 
104
- function availableContext(reason) {
157
+ /**
158
+ * ADR-073 §6 — continuity-unavailable. A directory that is not a writable adopted project has no
159
+ * continuity to restore and no store to blame, so this is neither a success nor a failure: it is the
160
+ * absence of the question. Distinct from `unknown`, which means the question was asked and missed.
161
+ */
162
+ function unavailable(reason) {
105
163
  return {
106
164
  status: 'unavailable',
107
165
  reason,
166
+ severity: 'info',
108
167
  context: '[RuvNet Brain — PROJECT CONTINUITY UNAVAILABLE]\n'
109
168
  + 'This working directory is not a writable adopted project. No AgentDB store was created and no project state was restored.',
110
169
  };
@@ -132,14 +191,17 @@ export function restoreProgressionForSession({
132
191
  return unknown('canonical-path');
133
192
  }
134
193
 
135
- if (!isProject(resolution)) return availableContext('non-project');
136
- if (!writable(resolution.projectRoot)) return availableContext('read-only');
194
+ if (!isProject(resolution)) return unavailable('non-project');
195
+ if (!writable(resolution.projectRoot)) return unavailable('read-only');
137
196
  const initializing = !fs.existsSync(resolution.canonicalAgentDbPath);
197
+ const rowCount = initializing ? 0 : committedRowCount(resolution.canonicalAgentDbPath);
198
+ const miss = (reason) => unknown(reason, { rowCount });
138
199
 
139
200
  const prefix = `${RESTORED_HEADER}\n`;
140
201
  const payloadLimit = maxOutputBytes - Buffer.byteLength(prefix, 'utf8');
141
- if (!Number.isSafeInteger(payloadLimit) || payloadLimit < 1) return unknown('output-bound');
202
+ if (!Number.isSafeInteger(payloadLimit) || payloadLimit < 1) return miss('output-bound');
142
203
 
204
+ let store;
143
205
  try {
144
206
  const deadlineAt = Date.now() + deadlineMs;
145
207
  const boundedRunner = (binary, args, options) => {
@@ -153,7 +215,7 @@ export function restoreProgressionForSession({
153
215
  ...options,
154
216
  runner: boundedRunner,
155
217
  }));
156
- const store = makeStore({
218
+ store = makeStore({
157
219
  projectDir,
158
220
  requestedStorePath: resolution.canonicalAgentDbPath,
159
221
  });
@@ -161,23 +223,38 @@ export function restoreProgressionForSession({
161
223
  fs.mkdirSync(path.dirname(resolution.canonicalAgentDbPath), { recursive: true, mode: 0o700 });
162
224
  initializeCanonicalStore(store, resolution);
163
225
  }
164
- const restored = store.restoreLatest({ maxOutputBytes: payloadLimit });
165
- if (!validResume(restored)) return unknown('malformed-store');
166
- const context = `${prefix}${restored.rendered}`;
167
- if (Buffer.byteLength(context, 'utf8') > maxOutputBytes) return unknown('output-bound');
168
- return { status: 'restored', context };
226
+ // COMMITTED ROWS ONLY (ADR-073 §5). Replay is a write, a write is a `ruflo memory store`
227
+ // process, and one of those costs more than this entire boundary's budget. Pending durable
228
+ // snapshots are REPORTED below and replayed at the next capture boundary or by /checkpoint.
229
+ const restored = store.restoreLatest({ maxOutputBytes: payloadLimit, replayPending: false });
230
+ if (!validResume(restored)) return miss('malformed-store');
231
+ const context = `${prefix}${restored.rendered}${pendingNotice(restored.pendingReplay)}`;
232
+ if (Buffer.byteLength(context, 'utf8') > maxOutputBytes) return miss('output-bound');
233
+ return { status: 'restored', severity: 'info', pendingReplay: restored.pendingReplay, context };
169
234
  } catch (error) {
170
235
  // A structurally enumerated, genuinely empty namespace is normal for a newly adopted project.
171
236
  if (/no coherent progression state/i.test(String(error?.message ?? ''))
172
237
  && Array.isArray(error?.rejectedCandidates) && error.rejectedCandidates.length === 0) {
173
- if (initializing && !fs.existsSync(resolution.canonicalAgentDbPath)) return unknown('initialization-failed');
238
+ if (initializing && !fs.existsSync(resolution.canonicalAgentDbPath)) return miss('initialization-failed');
239
+ const pending = pendingNotice(error?.pendingReplay);
174
240
  return {
175
241
  status: initializing ? 'initialized' : 'empty',
176
- context: initializing
242
+ severity: 'info',
243
+ pendingReplay: error?.pendingReplay ?? 0,
244
+ context: (initializing
177
245
  ? '[RuvNet Brain — PROJECT CONTINUITY INITIALIZED]\nThe canonical AgentDB store is ready; no prior progression snapshot exists yet.'
178
- : '[RuvNet Brain — PROJECT CONTINUITY EMPTY]\nThe canonical AgentDB store was structurally enumerated and contains no prior progression snapshot.',
246
+ : '[RuvNet Brain — PROJECT CONTINUITY EMPTY]\nThe canonical AgentDB store was structurally enumerated and contains no prior progression snapshot.')
247
+ + pending,
179
248
  };
180
249
  }
181
- return unknown(classify(error));
250
+ // NAME WHICH PATH RAN OUT OF TIME. "restore-failed" over a store full of rows tells the user
251
+ // nothing they can act on; "the fast path was unavailable because <reason>, and the CLI fallback
252
+ // is too slow for this deadline" tells them exactly what to fix.
253
+ const readPath = typeof store?.lastReadPath === 'string' ? store.lastReadPath : '';
254
+ if (/deadline exceeded/i.test(String(error?.message ?? '')) && readPath.startsWith('ruflo-cli')) {
255
+ const missed = miss('fallback-too-slow');
256
+ return { ...missed, context: `${missed.context} Read path: ${readPath}.` };
257
+ }
258
+ return miss(classify(error));
182
259
  }
183
260
  }
@@ -0,0 +1,220 @@
1
+ /**
2
+ * project-progression-sources.mjs — where a snapshot's facts actually come from.
3
+ *
4
+ * Every field a capture stores must be traceable to a REAL source: the git index, the user's own
5
+ * work ledger, the owner's own `project-state-current` note, the previous snapshot head, or a
6
+ * bounded transcript reference. Nothing here invents state, and nothing here copies the
7
+ * conversation: the transcript contributes a REFERENCE (path + line/byte span + a sha256 of the
8
+ * exact excerpt read) plus tightly-bounded DERIVED strings, never the prompt or the reply itself.
9
+ *
10
+ * Kept separate from the producer so each file stays small and so the readers can be tested against
11
+ * real fixtures without constructing a whole snapshot.
12
+ */
13
+ import crypto from 'node:crypto';
14
+ import fs from 'node:fs';
15
+ import os from 'node:os';
16
+ import path from 'node:path';
17
+ import { execFileSync } from 'node:child_process';
18
+
19
+ /** The transcript-derived bound. Deliberately far below the hook's 4096-byte observation limit. */
20
+ export const DERIVED_TEXT_LIMIT = 240;
21
+ /** How much of a transcript tail may be READ (never stored) to derive those bounded strings. */
22
+ export const TRANSCRIPT_TAIL_BYTES = 256 * 1024;
23
+
24
+ const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex');
25
+
26
+ /** A stable digest for "there is genuinely nothing to digest here", never an empty string. */
27
+ const ABSENT_DIGEST = sha256('ruvnet-brain:absent');
28
+
29
+ function git(cwd, args) {
30
+ try {
31
+ return execFileSync('git', args, {
32
+ cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 64 * 1024 * 1024,
33
+ });
34
+ } catch { return null; }
35
+ }
36
+
37
+ /**
38
+ * The source identity, with each digest defined EXACTLY:
39
+ * trackedDigest sha256 of `git ls-files -s` (mode + blob oid + stage + path for every tracked file)
40
+ * untrackedDigest sha256 of one `<content-sha256> <path>` line per untracked, non-ignored file —
41
+ * the NAMES alone would call two different working trees identical
42
+ * dirtyTreeDigest sha256 of `git diff HEAD` (staged AND unstaged, against the commit)
43
+ *
44
+ * KNOWN RACE, recorded rather than pretended away: the working tree can change while these three
45
+ * commands run. HEAD is read before and after; when they differ, `headStable` is false and the
46
+ * digests describe a tree that existed at no single instant. A capture still happens — a slightly
47
+ * smeared snapshot is worth far more than no snapshot — but it never claims to be atomic.
48
+ */
49
+ export function readSourceIdentity({ checkoutRoot, kind = 'git' } = {}) {
50
+ const worktreeId = sha256(checkoutRoot);
51
+ if (kind !== 'git') {
52
+ // A non-git project has no index, no HEAD and no diff. Say that in the fields rather than
53
+ // fabricating hashes of nothing, and keep every value a non-empty string as the contract requires.
54
+ return {
55
+ identity: {
56
+ checkoutPath: checkoutRoot, worktreeId, branch: 'non-git', head: 'non-git',
57
+ trackedDigest: ABSENT_DIGEST, untrackedDigest: ABSENT_DIGEST, dirtyTreeDigest: ABSENT_DIGEST,
58
+ },
59
+ headStable: true,
60
+ kind,
61
+ };
62
+ }
63
+
64
+ const headBefore = git(checkoutRoot, ['rev-parse', 'HEAD'])?.trim() || 'unborn';
65
+ const branch = git(checkoutRoot, ['rev-parse', '--abbrev-ref', 'HEAD'])?.trim() || 'detached';
66
+ const tracked = git(checkoutRoot, ['ls-files', '-s']);
67
+ const untrackedList = git(checkoutRoot, ['ls-files', '--others', '--exclude-standard']);
68
+ const diff = git(checkoutRoot, ['diff', 'HEAD']);
69
+ const headAfter = git(checkoutRoot, ['rev-parse', 'HEAD'])?.trim() || 'unborn';
70
+
71
+ const untrackedLines = String(untrackedList ?? '').split('\n').filter(Boolean).map((relative) => {
72
+ let content;
73
+ try { content = fs.readFileSync(path.join(checkoutRoot, relative)); } catch { return `unreadable ${relative}`; }
74
+ return `${sha256(content)} ${relative}`;
75
+ });
76
+
77
+ return {
78
+ identity: {
79
+ checkoutPath: checkoutRoot,
80
+ worktreeId,
81
+ branch,
82
+ head: headBefore,
83
+ trackedDigest: tracked === null ? ABSENT_DIGEST : sha256(tracked),
84
+ untrackedDigest: untrackedList === null ? ABSENT_DIGEST : sha256(untrackedLines.join('\n')),
85
+ dirtyTreeDigest: diff === null ? ABSENT_DIGEST : sha256(diff),
86
+ },
87
+ headStable: headBefore === headAfter,
88
+ headAfter,
89
+ kind,
90
+ };
91
+ }
92
+
93
+ /**
94
+ * The user's own work ledger, read-only, resolved exactly as continuation-gate.mjs resolves it:
95
+ * RUVNET_WORK_LEDGER, else ~/.config/ruvnet-brain/work-ledgers/<projectId with ':' → '-'>.json.
96
+ * This is the AUTHORITATIVE source for open work, because the user put it there.
97
+ */
98
+ export function readWorkLedger({ projectId, env = process.env, home = os.homedir() } = {}) {
99
+ const file = env.RUVNET_WORK_LEDGER
100
+ || path.join(home, '.config', 'ruvnet-brain', 'work-ledgers', `${String(projectId).replace(':', '-')}.json`);
101
+ let parsed;
102
+ try { parsed = JSON.parse(fs.readFileSync(file, 'utf8')); } catch {
103
+ return { file, present: false, open: [], done: [], objective: null };
104
+ }
105
+ const items = Array.isArray(parsed?.items) ? parsed.items : [];
106
+ const text = (item) => (typeof item?.text === 'string' ? item.text : '');
107
+ return {
108
+ file,
109
+ present: true,
110
+ open: items.filter((item) => item && !item.done).map(text).filter(Boolean),
111
+ done: items.filter((item) => item?.done).map(text).filter(Boolean),
112
+ objective: parsed?.objective && typeof parsed.objective === 'object' ? parsed.objective : null,
113
+ };
114
+ }
115
+
116
+ /**
117
+ * The owner's own convention: append-only `project-state-current-<epochms>[-slug]` free-text notes.
118
+ * 378 of them exist in this repo's canonical store. They are narrative, not schema, so only the head
119
+ * of the newest one is carried, as CONTEXT — never as an instruction and never as authority.
120
+ */
121
+ export function readOwnerNote(readRows, { limit = 600 } = {}) {
122
+ let rows;
123
+ try { rows = readRows(); } catch { return null; }
124
+ if (!Array.isArray(rows) || rows.length === 0) return null;
125
+ const newest = rows
126
+ .filter((row) => typeof row?.key === 'string' && row.key.startsWith('project-state-current'))
127
+ .sort((left, right) => String(left.key).localeCompare(String(right.key)))
128
+ .at(-1);
129
+ if (!newest || typeof newest.content !== 'string' || !newest.content.trim()) return null;
130
+ const excerpt = newest.content.slice(0, limit);
131
+ return {
132
+ key: newest.key,
133
+ namespace: newest.namespace ?? null,
134
+ excerpt,
135
+ truncated: newest.content.length > limit,
136
+ excerptSha256: sha256(excerpt),
137
+ };
138
+ }
139
+
140
+ function firstSentence(value) {
141
+ const collapsed = String(value).replace(/\s+/g, ' ').trim();
142
+ if (!collapsed) return '';
143
+ const stop = collapsed.search(/[.!?](\s|$)/);
144
+ const sentence = stop > 0 ? collapsed.slice(0, stop + 1) : collapsed;
145
+ return sentence.length <= DERIVED_TEXT_LIMIT ? sentence : `${sentence.slice(0, DERIVED_TEXT_LIMIT)}…`;
146
+ }
147
+
148
+ function claudeTranscriptRows(text) {
149
+ const rows = [];
150
+ const lines = text.split('\n');
151
+ // The first line of a mid-file tail is almost always a fragment; dropping it is correct, not lossy.
152
+ for (const line of lines.slice(1)) {
153
+ if (!line.trim()) continue;
154
+ try { rows.push(JSON.parse(line)); } catch { /* a partial line in a live-appended file */ }
155
+ }
156
+ return rows;
157
+ }
158
+
159
+ function textOf(message) {
160
+ if (typeof message?.content === 'string') return message.content;
161
+ if (!Array.isArray(message?.content)) return '';
162
+ return message.content.filter((block) => block?.type === 'text' && typeof block.text === 'string')
163
+ .map((block) => block.text).join('\n');
164
+ }
165
+
166
+ /**
167
+ * A BOUNDED, NON-VERBATIM reading of the transcript tail.
168
+ *
169
+ * THE PRIVACY BOUNDARY, stated as a rule rather than a hope: the prompt and the assistant's reply
170
+ * are READ here and are NEVER RETURNED. What comes back is a REFERENCE (path, line span, byte span,
171
+ * and the sha256 of the exact bytes read, so an auditor can prove what was consulted) plus at most
172
+ * two DERIVED single sentences, each capped at DERIVED_TEXT_LIMIT. Anything inferred this way is
173
+ * marked non-authoritative by the producer; the ledger and the owner's note outrank it always.
174
+ *
175
+ * Unknown or unreadable formats return `skipped` with a reason. A host whose transcript we cannot
176
+ * parse must cost the capture nothing at all.
177
+ */
178
+ export function readTranscriptReference(transcriptPath, { host = 'claude', tailBytes = TRANSCRIPT_TAIL_BYTES } = {}) {
179
+ if (typeof transcriptPath !== 'string' || !transcriptPath) {
180
+ return { skipped: 'no transcript path supplied' };
181
+ }
182
+ if (host !== 'claude') return { skipped: `transcript format unknown for host ${host}` };
183
+ let stat;
184
+ try { stat = fs.statSync(transcriptPath); } catch { return { skipped: 'transcript is unreadable' }; }
185
+ if (!stat.isFile() || stat.size === 0) return { skipped: 'transcript is empty or not a file' };
186
+ if (!/\.jsonl$/i.test(transcriptPath)) return { skipped: 'transcript is not a JSONL transcript' };
187
+
188
+ const byteOffset = Math.max(0, stat.size - tailBytes);
189
+ const byteLength = stat.size - byteOffset;
190
+ let buffer;
191
+ try {
192
+ const handle = fs.openSync(transcriptPath, 'r');
193
+ try {
194
+ buffer = Buffer.alloc(byteLength);
195
+ fs.readSync(handle, buffer, 0, byteLength, byteOffset);
196
+ } finally { fs.closeSync(handle); }
197
+ } catch { return { skipped: 'transcript tail could not be read' }; }
198
+
199
+ const rows = claudeTranscriptRows(buffer.toString('utf8'));
200
+ if (rows.length === 0) return { skipped: 'transcript tail contained no parseable records' };
201
+ const lastUser = [...rows].reverse()
202
+ .find((row) => row?.type === 'user' && row.message?.role === 'user' && textOf(row.message).trim());
203
+ const lastAssistant = [...rows].reverse()
204
+ .find((row) => row?.type === 'assistant' && textOf(row.message).trim());
205
+
206
+ return {
207
+ reference: {
208
+ path: transcriptPath,
209
+ byteOffset,
210
+ byteLength,
211
+ recordsRead: rows.length,
212
+ excerptSha256: sha256(buffer),
213
+ format: 'claude-jsonl',
214
+ },
215
+ // DERIVED ONLY. Callers must not treat these as the user's words; they are a bounded
216
+ // single-sentence reduction, recorded so an abandoned session still knows what it was doing.
217
+ derivedGoal: lastUser ? firstSentence(textOf(lastUser.message)) : '',
218
+ derivedNextAction: lastAssistant ? firstSentence(textOf(lastAssistant.message)) : '',
219
+ };
220
+ }