ruvnet-brain 4.6.3 → 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 (75) 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 +2 -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 -58
  12. package/plugin/hooks/hook-contracts.json +19 -220
  13. package/plugin/hooks/hooks.json +1 -70
  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 +27 -21
  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/continuation-gate.mjs +78 -1
  21. package/plugin/scripts/continuity-events.mjs +90 -82
  22. package/plugin/scripts/continuity-hook-policy.mjs +5 -14
  23. package/plugin/scripts/decision-gate.mjs +3 -3
  24. package/plugin/scripts/decision-recall-gate.mjs +141 -0
  25. package/plugin/scripts/decision-record.mjs +324 -0
  26. package/plugin/scripts/deferral-evidence.mjs +134 -0
  27. package/plugin/scripts/hook-failures.mjs +61 -0
  28. package/plugin/scripts/hook-shim.mjs +71 -6
  29. package/plugin/scripts/learn-capture.mjs +88 -37
  30. package/plugin/scripts/learning-store.mjs +70 -60
  31. package/plugin/scripts/node-sqlite.mjs +1 -1
  32. package/plugin/scripts/ruflo-bin.mjs +71 -1
  33. package/plugin/scripts/ruflo-memory-hooks.mjs +132 -0
  34. package/plugin/scripts/runtime-preferences.mjs +1 -1
  35. package/plugin/scripts/session-snapshot-hook.mjs +85 -332
  36. package/plugin/scripts/session-start-core.mjs +7 -9
  37. package/plugin/scripts/signal-watch.mjs +1 -1
  38. package/plugin/scripts/substitution-wall.mjs +222 -0
  39. package/plugin/scripts/turn-outcome-capture.mjs +74 -88
  40. package/scripts/codex-host-execution-proof.mjs +1 -1
  41. package/scripts/console-engine.mjs +0 -32
  42. package/scripts/deferral-replay.mjs +47 -0
  43. package/scripts/health-repair.mjs +1 -81
  44. package/scripts/hook-qualify-core.mjs +2 -2
  45. package/scripts/model-managed-workflow-service.mjs +9 -10
  46. package/scripts/model-routing-controller.mjs +9 -8
  47. package/scripts/no-silent-substitution.mjs +65 -1
  48. package/scripts/onboarding-console.mjs +8 -23
  49. package/scripts/product-integrity-contract.mjs +1 -1
  50. package/scripts/release-qualification-contract.mjs +9 -27
  51. package/scripts/remedy-registry.mjs +0 -18
  52. package/scripts/wired-check.mjs +1 -5
  53. package/plugin/scripts/continuity-brief.mjs +0 -357
  54. package/plugin/scripts/continuity-journal.mjs +0 -553
  55. package/plugin/scripts/learn-flush.mjs +0 -96
  56. package/plugin/scripts/learning-observation.mjs +0 -51
  57. package/plugin/scripts/learning-queue.mjs +0 -135
  58. package/plugin/scripts/learning-worker-supervisor.mjs +0 -79
  59. package/plugin/scripts/project-capture-queue.mjs +0 -357
  60. package/plugin/scripts/project-memory-enrollment.mjs +0 -203
  61. package/plugin/scripts/project-progression-checkpoint.mjs +0 -145
  62. package/plugin/scripts/project-progression-contract.mjs +0 -496
  63. package/plugin/scripts/project-progression-hook.mjs +0 -233
  64. package/plugin/scripts/project-progression-outbox.mjs +0 -225
  65. package/plugin/scripts/project-progression-producer.mjs +0 -309
  66. package/plugin/scripts/project-progression-reader.mjs +0 -304
  67. package/plugin/scripts/project-progression-session-start.mjs +0 -320
  68. package/plugin/scripts/project-progression-sources.mjs +0 -246
  69. package/plugin/scripts/project-progression-store.mjs +0 -723
  70. package/plugin/scripts/project-progression-suspension.mjs +0 -60
  71. package/plugin/scripts/project-transition-hook.mjs +0 -246
  72. package/plugin/scripts/session-snapshot-contract.mjs +0 -105
  73. package/plugin/scripts/turn-transport-journal.mjs +0 -108
  74. package/scripts/qa/progression-validation-benchmark.mjs +0 -66
  75. package/scripts/session-snapshot-contract.mjs +0 -1
@@ -1,320 +0,0 @@
1
- import fs from 'node:fs';
2
- import path from 'node:path';
3
- import os from 'node:os';
4
- import { spawnSync } from 'node:child_process';
5
- import { ProjectProgressionStore } from './project-progression-store.mjs';
6
- import { resolveProjectStore } from './project-store-resolver.mjs';
7
- import { withProgressionReader } from './project-progression-reader.mjs';
8
- import { drainCaptureQueue, queuedWork } from './session-snapshot-hook.mjs';
9
- import { replayTurnQueue } from './turn-outcome-capture.mjs';
10
- import { STAGE_BUDGETS_MS } from './session-start-budget.mjs';
11
- import { operatorProgressionSuspension } from './project-progression-suspension.mjs';
12
-
13
- const PROGRESSION_NAMESPACE = 'project-progression';
14
-
15
- export const SESSION_CONTINUITY_LIMIT_BYTES = 8 * 1024;
16
- // Keep the restore's enforced wall-clock ceiling identical to the SessionStart latency contract.
17
- // The fast path normally completes in milliseconds; when the managed CLI fallback cannot fit this
18
- // stage, the caller reports UNKNOWN with its reason instead of consuming the rest of the hook budget.
19
- export const SESSION_CONTINUITY_DEADLINE_MS = STAGE_BUDGETS_MS.restore;
20
-
21
- const RESTORED_HEADER = '[RuvNet Brain — PROJECT CONTINUITY RESTORED]';
22
- const UNKNOWN_HEADER = '[RuvNet Brain — PROJECT CONTINUITY UNKNOWN]';
23
-
24
- const UNKNOWN_EXPLANATIONS = Object.freeze({
25
- 'canonical-path': 'The canonical project AgentDB path could not be verified.',
26
- 'initialization-failed': 'The canonical project AgentDB could not be initialized through managed Ruflo.',
27
- 'managed-ruflo-unavailable': 'The managed global Ruflo CLI is unavailable.',
28
- 'pagination-unavailable': 'Structural AgentDB pagination is unavailable on the managed global Ruflo CLI.',
29
- 'malformed-store': 'Structural AgentDB output is malformed or internally inconsistent.',
30
- 'exact-readback': 'An exact-listed AgentDB row could not be read back by its exact key.',
31
- 'outbox-replay': 'The durable progression outbox could not be replayed safely.',
32
- 'output-bound': 'The full checkpoint and a goal/action-preserving bounded summary do not fit the host context; no checkpoint state was injected.',
33
- 'no-coherent-state': 'No coherent progression head survived validation.',
34
- 'restore-failed': 'The exact structural restore did not complete.',
35
- 'deadline-exceeded': 'The SessionStart restore deadline expired; no project restoration is claimed and durable work is retained.',
36
- // MEASURED, and named rather than hidden. The in-process read path costs ~15ms for six snapshots;
37
- // the `ruflo memory` CLI fallback costs 3634ms for the same six and does NOT fit the 2500ms
38
- // restore deadline. Both produce an identical result, so this is a SPEED limit, not a correctness
39
- // one — but a user whose store is encrypted, or whose Node lacks node:sqlite, will lose continuity
40
- // past a handful of snapshots, and they deserve to be told which of the two it was.
41
- 'fallback-too-slow': 'The in-process read path was unavailable, and the managed Ruflo CLI fallback'
42
- + ' could not finish inside the restore deadline.',
43
- });
44
-
45
- /**
46
- * UNKNOWN IS NOT NEUTRAL WHEN THERE WAS SOMETHING TO FIND.
47
- *
48
- * "I could not read your store" and "your store is empty" render almost identically to a reader, and
49
- * the first is a FAILURE: verified progression exists on disk and the session is about to proceed
50
- * without it. So when the canonical store exists and holds progression rows, the banner says so in
51
- * as many words and the result carries `severity: 'error'` for any surface that colours its output.
52
- * When we cannot even count the rows, the severity stays 'warning' — claiming a failure we cannot
53
- * evidence would be the same sin one step over.
54
- */
55
- function unknown(reason, { rowCount = null } = {}) {
56
- const explanation = UNKNOWN_EXPLANATIONS[reason] ?? UNKNOWN_EXPLANATIONS['restore-failed'];
57
- const failing = Number.isInteger(rowCount) && rowCount > 0;
58
- const header = failing ? `${UNKNOWN_HEADER} — RESTORE FAILED` : UNKNOWN_HEADER;
59
- const evidence = failing
60
- ? ` ${rowCount} verified progression row(s) are present in the canonical store and could NOT be restored;`
61
- + ' treat this as a failure to recover known state, not as a project without history.'
62
- : '';
63
- return {
64
- status: 'unknown',
65
- reason,
66
- severity: failing ? 'error' : 'warning',
67
- rowCount,
68
- context: `${header}\n${explanation}${evidence}`
69
- + ' Do not claim project state was restored; verify the canonical store before relying on remembered state.',
70
- };
71
- }
72
-
73
- /**
74
- * Count committed progression rows WITHOUT paying for a restore. Used only to decide how loudly an
75
- * UNKNOWN should speak, so it never throws and never falls back to a CLI spawn: a count we cannot
76
- * take cheaply is reported as null ("cannot tell"), which downgrades the banner rather than the run.
77
- */
78
- function committedRowCount(canonicalAgentDbPath, options) {
79
- try {
80
- const result = withProgressionReader(canonicalAgentDbPath,
81
- (reader) => reader.listKeys(PROGRESSION_NAMESPACE).length, options);
82
- return result.ok ? result.value : null;
83
- } catch { return null; }
84
- }
85
-
86
- function classify(error) {
87
- const message = String(error?.message ?? error ?? '');
88
- if (/deadline exceeded|deadline exhausted/i.test(message)) return 'deadline-exceeded';
89
- if (/ruflo was not found|managed global ruflo/i.test(message)) return 'managed-ruflo-unavailable';
90
- if (/canonical agentdb initialization failed/i.test(message)) return 'initialization-failed';
91
- if (/structural pagination failed/i.test(message)
92
- && /unknown option|page-info|--offset|unsupported/i.test(message)) return 'pagination-unavailable';
93
- if (/malformed pagination|pagination total changed|non-advancing pagination|duplicate progression key/i.test(message)) {
94
- return 'malformed-store';
95
- }
96
- if (/exact retrieval failed|exact key\/payload identity mismatch|readback is not json/i.test(message)) {
97
- return 'exact-readback';
98
- }
99
- if (/outbox|replay/i.test(message)) return 'outbox-replay';
100
- if (/resume payload.*bound/i.test(message)) return 'output-bound';
101
- if (/no coherent progression state/i.test(message)) return 'no-coherent-state';
102
- return 'restore-failed';
103
- }
104
-
105
- function validResume(result) {
106
- if (!result || typeof result !== 'object' || typeof result.rendered !== 'string') return false;
107
- let parsed;
108
- try { parsed = JSON.parse(result.rendered); } catch { return false; }
109
- return parsed?.schema === 'ruvnet-brain.project-resume'
110
- && parsed.schemaVersion === 1
111
- && Array.isArray(parsed.heads)
112
- && parsed.heads.length > 0
113
- && parsed.state && typeof parsed.state === 'object'
114
- && Array.isArray(parsed.state.resumeConflicts)
115
- && JSON.stringify(parsed) === JSON.stringify(result.payload);
116
- }
117
-
118
- function resultStatus(result) {
119
- return Number.isInteger(result?.status) ? result.status : 1;
120
- }
121
-
122
- function resultText(result, field) {
123
- const value = result?.[field];
124
- return Buffer.isBuffer(value) ? value.toString('utf8') : String(value ?? '');
125
- }
126
-
127
- /**
128
- * One line, only when there is something to say. Durable-but-uncommitted snapshots are evidence the
129
- * session must know about: they will be committed at the next capture boundary, and until then the
130
- * restored head is not the newest thing that happened.
131
- */
132
- function pendingNotice(pendingReplay) {
133
- if (!Number.isInteger(pendingReplay) || pendingReplay < 1) return '';
134
- return `\n${pendingReplay} uncommitted snapshot(s) pending replay; they commit at the next capture`
135
- + ' boundary (Stop / PreCompact / SessionEnd) or when you run /ruvnet-brain:checkpoint.';
136
- }
137
-
138
- function isProject(resolution) {
139
- if (resolution.kind === 'git') return true;
140
- return ['.swarm', '.claude-flow', 'package.json', 'pyproject.toml', 'Cargo.toml', 'go.mod']
141
- .some((name) => fs.existsSync(path.join(resolution.projectRoot, name)));
142
- }
143
-
144
- /**
145
- * ADR-073 §6 — continuity-unavailable. A directory that is not a writable adopted project has no
146
- * continuity to restore and no store to blame, so this is neither a success nor a failure: it is the
147
- * absence of the question. Distinct from `unknown`, which means the question was asked and missed.
148
- */
149
- function unavailable(reason) {
150
- return {
151
- status: 'unavailable',
152
- reason,
153
- severity: 'info',
154
- context: '[RuvNet Brain — PROJECT CONTINUITY UNAVAILABLE]\n'
155
- + 'This working directory is not a writable adopted project. No AgentDB store was created and no project state was restored.',
156
- };
157
- }
158
-
159
- /**
160
- * Restore one canonical project's progression at the shared SessionStart boundary.
161
- * Empty projects are silent. Any adopted-but-unverifiable store is explicit UNKNOWN.
162
- */
163
- export function restoreProgressionForSession({
164
- env = process.env,
165
- cwd = process.cwd(),
166
- storeFactory,
167
- maxOutputBytes = SESSION_CONTINUITY_LIMIT_BYTES,
168
- deadlineMs = SESSION_CONTINUITY_DEADLINE_MS,
169
- deadlineAt: inheritedDeadlineAt = Infinity,
170
- signal,
171
- writable = (projectRoot) => {
172
- try { fs.accessSync(projectRoot, fs.constants.W_OK); return true; } catch { return false; }
173
- },
174
- } = {}) {
175
- const deadlineAt = Math.min(inheritedDeadlineAt, Date.now() + deadlineMs);
176
- const checkDeadline = () => { if (signal?.aborted || Date.now() >= deadlineAt) throw new Error('restore deadline exceeded'); };
177
- const boundedRunner = (binary, args, options) => {
178
- checkDeadline();
179
- const result = spawnSync(binary, args, { ...options,
180
- timeout: Math.max(1, Math.floor(Math.min(options.timeout || Infinity, deadlineAt - Date.now()))), killSignal: 'SIGKILL' });
181
- if (result.error) throw new Error('restore deadline exceeded');
182
- checkDeadline(); return result;
183
- };
184
- const projectDir = env.CLAUDE_PROJECT_DIR || cwd;
185
- try {
186
- checkDeadline();
187
- if (operatorProgressionSuspension(env)) {
188
- // Turn recording is independent: suspension must not strand ordinary AgentDB capture.
189
- const turns = replayTurnQueue({ projectDir, env, home: env.HOME || os.homedir(), synchronous: true,
190
- deadlineMs: deadlineAt, runner: boundedRunner });
191
- return { status: 'unavailable', reason: 'operator-suspended', severity: 'info', pendingTurns: turns.pending,
192
- context: '[RuvNet Brain — PROJECT CONTINUITY UNAVAILABLE]\nAutomatic progression capture, replay and restore are operator-suspended. Evidence and queues are preserved. Ordinary AgentDB memory and explicit checkpoints remain available; no project progression was restored.' };
193
- }
194
- } catch (error) { return unknown(classify(error)); }
195
- let resolution;
196
- try {
197
- resolution = resolveProjectStore({ projectDir, deadlineAt });
198
- checkDeadline();
199
- } catch (error) {
200
- return unknown(/deadline|timed out/i.test(error.message) ? 'deadline-exceeded' : 'canonical-path');
201
- }
202
-
203
- if (!isProject(resolution)) return unavailable('non-project');
204
- if (!writable(resolution.projectRoot)) return unavailable('read-only');
205
- const initializing = !fs.existsSync(resolution.canonicalAgentDbPath);
206
- const rowCount = initializing ? 0 : committedRowCount(resolution.canonicalAgentDbPath, { deadlineAt, signal });
207
- const miss = (reason) => unknown(reason, { rowCount });
208
-
209
- const prefix = `${RESTORED_HEADER}\n`;
210
- // Reserve room for the status prefix, the bounded-summary explanation, and any pending-outbox
211
- // notice appended after the payload. The final complete context is still checked below.
212
- const payloadLimit = maxOutputBytes - Buffer.byteLength(prefix, 'utf8') - 400;
213
- if (!Number.isSafeInteger(payloadLimit) || payloadLimit < 1) return miss('output-bound');
214
-
215
- let store;
216
- try {
217
- checkDeadline();
218
- const makeStore = storeFactory ?? ((options) => new ProjectProgressionStore({
219
- ...options,
220
- env,
221
- runner: boundedRunner,
222
- }));
223
- store = makeStore({
224
- projectDir,
225
- requestedStorePath: resolution.canonicalAgentDbPath,
226
- deadlineAt, signal,
227
- });
228
- const home = env.HOME || env.USERPROFILE || os.homedir();
229
- const turns = replayTurnQueue({ projectDir, env, home, synchronous: true, runner: boundedRunner, deadlineMs: deadlineAt });
230
- checkDeadline();
231
- if (turns.pending > 0 || turns.failed > 0) {
232
- const failed = miss('outbox-replay');
233
- return { ...failed, pendingTurns: turns.pending,
234
- context: `${failed.context} Durable turn recording remains pending; no older checkpoint was injected.` };
235
- }
236
- const suspended = ['persisted turn capture opt-out', 'turn capture policy unreadable or invalid'].includes(turns.skipped);
237
- if (turns.skipped && !suspended && !['RUVNET_TURN_CAPTURE=off',
238
- 'no project memory db; persisted opt-in required'].includes(turns.skipped)) return miss('outbox-replay');
239
- if (suspended && initializing) return {
240
- status: 'unavailable', reason: 'capture-suspended', severity: 'info',
241
- context: '[RuvNet Brain — PROJECT CONTINUITY UNAVAILABLE]\nCapture consent suspends replay; no canonical store was created.',
242
- };
243
- if (suspended && (store.pendingReplayCount?.() > 0 || queuedWork(resolution.projectRoot) > 0)) {
244
- const failed = miss('outbox-replay');
245
- return { ...failed, context: `${failed.context} Replay is suspended by capture consent; no older checkpoint was injected.` };
246
- }
247
- // ADR-105 rule 6: restoring a session never creates a store. A project's store is created only by the
248
- // consent-gated native enrollment (project-memory-enrollment.mjs), never by SessionStart and never when
249
- // consent is absent or RUVNET_TURN_CAPTURE=off.
250
- if (initializing) return {
251
- status: 'unavailable', reason: 'not-enrolled', severity: 'info',
252
- context: '[RuvNet Brain — PROJECT CONTINUITY UNAVAILABLE]\nNo canonical project store exists yet; it is created by consent-gated native enrollment at the first captured boundary. Nothing to restore.',
253
- };
254
- if (!suspended) {
255
- const queue = drainCaptureQueue({ projectDir, budgetMs: Math.max(0, deadlineAt - Date.now()),
256
- deadlineAt, signal, env, makeStoreFactory: () => () => store });
257
- checkDeadline();
258
- if (queue.state !== 'settled') {
259
- const failed = miss('outbox-replay');
260
- return { ...failed, pendingReplay: queue.pending,
261
- context: `${failed.context} Durable capture work remains pending; no older checkpoint was injected.` };
262
- }
263
- }
264
- // A pending snapshot is newer observable work. Never label an older committed head restored
265
- // while that work remains unverified. Replay uses the same managed exact-readback store path.
266
- let restored;
267
- try {
268
- restored = store.restoreLatest({ maxOutputBytes: payloadLimit, replayPending: !suspended, projectToBound: true });
269
- checkDeadline();
270
- } catch (error) {
271
- if (store.pendingReplayCount?.() > 0) {
272
- const failed = miss('outbox-replay');
273
- return { ...failed, pendingReplay: store.pendingReplayCount(),
274
- context: `${failed.context} ${store.pendingReplayCount()} durable snapshot(s) remain pending; no older checkpoint was injected.` };
275
- }
276
- throw error;
277
- }
278
- if (!validResume(restored)) return miss('malformed-store');
279
- const summaryNotice = restored.projected
280
- ? '\n[BOUNDED CONTINUITY SUMMARY] The merged current goal and next action are preserved exactly; '
281
- + 'a null value means the journal heads conflict. '
282
- + 'Other omitted details remain in the canonical AgentDB records; consult the listed head keys '
283
- + 'and omission digests. Omitted fields are marked and are not empty.'
284
- : '';
285
- const context = `${prefix}${summaryNotice}\n${restored.rendered}${pendingNotice(restored.pendingReplay)}`;
286
- if (Buffer.byteLength(context, 'utf8') > maxOutputBytes) return miss('output-bound');
287
- return {
288
- status: restored.projected ? 'restored-summary' : 'restored',
289
- severity: restored.projected ? 'warning' : 'info',
290
- degraded: restored.projected === true,
291
- pendingReplay: restored.pendingReplay,
292
- context,
293
- };
294
- } catch (error) {
295
- // A structurally enumerated, genuinely empty namespace is normal for a newly adopted project.
296
- if (/no coherent progression state/i.test(String(error?.message ?? ''))
297
- && Array.isArray(error?.rejectedCandidates) && error.rejectedCandidates.length === 0) {
298
- if (initializing && !fs.existsSync(resolution.canonicalAgentDbPath)) return miss('initialization-failed');
299
- const pending = pendingNotice(error?.pendingReplay);
300
- return {
301
- status: initializing ? 'initialized' : 'empty',
302
- severity: 'info',
303
- pendingReplay: error?.pendingReplay ?? 0,
304
- context: (initializing
305
- ? '[RuvNet Brain — PROJECT CONTINUITY INITIALIZED]\nThe canonical AgentDB store is ready; no prior progression snapshot exists yet.'
306
- : '[RuvNet Brain — PROJECT CONTINUITY EMPTY]\nThe canonical AgentDB store was structurally enumerated and contains no prior progression snapshot.')
307
- + pending,
308
- };
309
- }
310
- // NAME WHICH PATH RAN OUT OF TIME. "restore-failed" over a store full of rows tells the user
311
- // nothing they can act on; "the fast path was unavailable because <reason>, and the CLI fallback
312
- // is too slow for this deadline" tells them exactly what to fix.
313
- const readPath = typeof store?.lastReadPath === 'string' ? store.lastReadPath : '';
314
- if (/deadline exceeded/i.test(String(error?.message ?? '')) && readPath.startsWith('ruflo-cli')) {
315
- const missed = miss('fallback-too-slow');
316
- return { ...missed, context: `${missed.context} Read path: ${readPath}.` };
317
- }
318
- return miss(classify(error));
319
- }
320
- }
@@ -1,246 +0,0 @@
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 checkDeadline(deadlineAt, signal) {
30
- if (signal?.aborted || Date.now() >= deadlineAt) throw new Error('restore deadline exceeded');
31
- }
32
- function git(cwd, args, deadlineAt = Infinity, signal) {
33
- checkDeadline(deadlineAt, signal);
34
- try {
35
- return execFileSync('git', args, {
36
- cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 64 * 1024 * 1024,
37
- ...(Number.isFinite(deadlineAt) ? { timeout: Math.max(1, Math.floor(deadlineAt - Date.now())), killSignal: 'SIGKILL' } : {}),
38
- });
39
- } catch (error) {
40
- checkDeadline(deadlineAt, signal);
41
- if (error.code === 'ETIMEDOUT' && Number.isFinite(deadlineAt)) throw new Error('restore deadline exceeded');
42
- return null;
43
- }
44
- }
45
-
46
- /**
47
- * The Brain's OWN state that may sit inside the customer's project: the AgentDB store and its sidecars
48
- * (`.swarm/` — memory.db, -wal/-shm, the outbox and queue files) and what ruflo leaves in a cwd
49
- * (`.claude-flow/`, `ruvector.db`). It is not the customer's source. Every capture writes `.swarm/`, so
50
- * counting it made every later boundary look like a changed tree: the no-op path was never taken and
51
- * memory.db grew at every boundary. Excluded by pathspec, so it does not depend on the user's gitignore
52
- * (it only looked fine on machines whose global gitignore lists `.swarm/`).
53
- */
54
- export const BRAIN_STATE_PATHSPEC_EXCLUDES = Object.freeze([':(exclude).swarm', ':(exclude).claude-flow', ':(exclude)ruvector.db']);
55
- const SOURCE_PATHSPEC = ['--', '.', ...BRAIN_STATE_PATHSPEC_EXCLUDES];
56
-
57
- /**
58
- * The source identity, with each digest defined EXACTLY (each one EXCLUDING the Brain's own state,
59
- * BRAIN_STATE_PATHSPEC_EXCLUDES above):
60
- * trackedDigest sha256 of `git ls-files -s` (mode + blob oid + stage + path for every tracked file)
61
- * untrackedDigest sha256 of one `<content-sha256> <path>` line per untracked, non-ignored file —
62
- * the NAMES alone would call two different working trees identical
63
- * dirtyTreeDigest sha256 of `git diff HEAD` (staged AND unstaged, against the commit)
64
- *
65
- * KNOWN RACE, recorded rather than pretended away: the working tree can change while these three
66
- * commands run. HEAD is read before and after; when they differ, `headStable` is false and the
67
- * digests describe a tree that existed at no single instant. A capture still happens — a slightly
68
- * smeared snapshot is worth far more than no snapshot — but it never claims to be atomic.
69
- */
70
- export function readSourceIdentity({ checkoutRoot, kind = 'git', deadlineAt = Infinity, signal } = {}) {
71
- checkDeadline(deadlineAt, signal);
72
- const worktreeId = sha256(checkoutRoot);
73
- if (kind !== 'git') {
74
- // A non-git project has no index, no HEAD and no diff. Say that in the fields rather than
75
- // fabricating hashes of nothing, and keep every value a non-empty string as the contract requires.
76
- return {
77
- identity: {
78
- checkoutPath: checkoutRoot, worktreeId, branch: 'non-git', head: 'non-git',
79
- trackedDigest: ABSENT_DIGEST, untrackedDigest: ABSENT_DIGEST, dirtyTreeDigest: ABSENT_DIGEST,
80
- },
81
- headStable: true,
82
- kind,
83
- };
84
- }
85
-
86
- const headBefore = git(checkoutRoot, ['rev-parse', 'HEAD'], deadlineAt, signal)?.trim() || 'unborn';
87
- const branch = git(checkoutRoot, ['rev-parse', '--abbrev-ref', 'HEAD'], deadlineAt, signal)?.trim() || 'detached';
88
- const tracked = git(checkoutRoot, ['ls-files', '-s', ...SOURCE_PATHSPEC], deadlineAt, signal);
89
- const untrackedList = git(checkoutRoot, ['ls-files', '--others', '--exclude-standard', ...SOURCE_PATHSPEC], deadlineAt, signal);
90
- const diff = git(checkoutRoot, ['diff', 'HEAD', ...SOURCE_PATHSPEC], deadlineAt, signal);
91
- const headAfter = git(checkoutRoot, ['rev-parse', 'HEAD'], deadlineAt, signal)?.trim() || 'unborn';
92
-
93
- const untrackedLines = String(untrackedList ?? '').split('\n').filter(Boolean).map((relative) => {
94
- checkDeadline(deadlineAt, signal);
95
- let content;
96
- if (Number.isFinite(deadlineAt) && fs.statSync(path.join(checkoutRoot, relative)).size > 64 * 1024 * 1024) throw new Error('source identity file exceeds bounded read');
97
- try { content = fs.readFileSync(path.join(checkoutRoot, relative)); } catch { return `unreadable ${relative}`; }
98
- const digest = sha256(content); checkDeadline(deadlineAt, signal);
99
- return `${digest} ${relative}`;
100
- });
101
- checkDeadline(deadlineAt, signal);
102
-
103
- return {
104
- identity: {
105
- checkoutPath: checkoutRoot,
106
- worktreeId,
107
- branch,
108
- head: headBefore,
109
- trackedDigest: tracked === null ? ABSENT_DIGEST : sha256(tracked),
110
- untrackedDigest: untrackedList === null ? ABSENT_DIGEST : sha256(untrackedLines.join('\n')),
111
- dirtyTreeDigest: diff === null ? ABSENT_DIGEST : sha256(diff),
112
- },
113
- headStable: headBefore === headAfter,
114
- headAfter,
115
- kind,
116
- };
117
- }
118
-
119
- /**
120
- * The user's own work ledger, read-only, resolved exactly as continuation-gate.mjs resolves it:
121
- * RUVNET_WORK_LEDGER, else ~/.config/ruvnet-brain/work-ledgers/<projectId with ':' → '-'>.json.
122
- * This is the AUTHORITATIVE source for open work, because the user put it there.
123
- */
124
- export function readWorkLedger({ projectId, env = process.env, home = os.homedir() } = {}) {
125
- const file = env.RUVNET_WORK_LEDGER
126
- || path.join(home, '.config', 'ruvnet-brain', 'work-ledgers', `${String(projectId).replace(':', '-')}.json`);
127
- let parsed;
128
- try { parsed = JSON.parse(fs.readFileSync(file, 'utf8')); } catch {
129
- return { file, present: false, open: [], done: [], objective: null };
130
- }
131
- const items = Array.isArray(parsed?.items) ? parsed.items : [];
132
- const text = (item) => (typeof item?.text === 'string' ? item.text : '');
133
- return {
134
- file,
135
- present: true,
136
- open: items.filter((item) => item && !item.done).map(text).filter(Boolean),
137
- done: items.filter((item) => item?.done).map(text).filter(Boolean),
138
- objective: parsed?.objective && typeof parsed.objective === 'object' ? parsed.objective : null,
139
- };
140
- }
141
-
142
- /**
143
- * The owner's own convention: append-only `project-state-current-<epochms>[-slug]` free-text notes.
144
- * 378 of them exist in this repo's canonical store. They are narrative, not schema, so only the head
145
- * of the newest one is carried, as CONTEXT — never as an instruction and never as authority.
146
- */
147
- export function readOwnerNote(readRows, { limit = 600 } = {}) {
148
- let rows;
149
- try { rows = readRows(); } catch { return null; }
150
- if (!Array.isArray(rows) || rows.length === 0) return null;
151
- const newest = rows
152
- .filter((row) => typeof row?.key === 'string' && row.key.startsWith('project-state-current'))
153
- .sort((left, right) => String(left.key).localeCompare(String(right.key)))
154
- .at(-1);
155
- if (!newest || typeof newest.content !== 'string' || !newest.content.trim()) return null;
156
- const excerpt = newest.content.slice(0, limit);
157
- return {
158
- key: newest.key,
159
- namespace: newest.namespace ?? null,
160
- excerpt,
161
- truncated: newest.content.length > limit,
162
- excerptSha256: sha256(excerpt),
163
- };
164
- }
165
-
166
- function firstSentence(value) {
167
- const collapsed = String(value).replace(/\s+/g, ' ').trim();
168
- if (!collapsed) return '';
169
- const stop = collapsed.search(/[.!?](\s|$)/);
170
- const sentence = stop > 0 ? collapsed.slice(0, stop + 1) : collapsed;
171
- return sentence.length <= DERIVED_TEXT_LIMIT ? sentence : `${sentence.slice(0, DERIVED_TEXT_LIMIT)}…`;
172
- }
173
-
174
- function claudeTranscriptRows(text) {
175
- const rows = [];
176
- const lines = text.split('\n');
177
- // The first line of a mid-file tail is almost always a fragment; dropping it is correct, not lossy.
178
- for (const line of lines.slice(1)) {
179
- if (!line.trim()) continue;
180
- try { rows.push(JSON.parse(line)); } catch { /* a partial line in a live-appended file */ }
181
- }
182
- return rows;
183
- }
184
-
185
- function textOf(message) {
186
- if (typeof message?.content === 'string') return message.content;
187
- if (!Array.isArray(message?.content)) return '';
188
- return message.content.filter((block) => block?.type === 'text' && typeof block.text === 'string')
189
- .map((block) => block.text).join('\n');
190
- }
191
-
192
- /**
193
- * A BOUNDED, NON-VERBATIM reading of the transcript tail.
194
- *
195
- * THE PRIVACY BOUNDARY, stated as a rule rather than a hope: the prompt and the assistant's reply
196
- * are READ here and are NEVER RETURNED. What comes back is a REFERENCE (path, line span, byte span,
197
- * and the sha256 of the exact bytes read, so an auditor can prove what was consulted) plus at most
198
- * two DERIVED single sentences, each capped at DERIVED_TEXT_LIMIT. Anything inferred this way is
199
- * marked non-authoritative by the producer; the ledger and the owner's note outrank it always.
200
- *
201
- * Unknown or unreadable formats return `skipped` with a reason. A host whose transcript we cannot
202
- * parse must cost the capture nothing at all.
203
- */
204
- export function readTranscriptReference(transcriptPath, { host = 'claude', tailBytes = TRANSCRIPT_TAIL_BYTES } = {}) {
205
- if (typeof transcriptPath !== 'string' || !transcriptPath) {
206
- return { skipped: 'no transcript path supplied' };
207
- }
208
- if (host !== 'claude') return { skipped: `transcript format unknown for host ${host}` };
209
- let stat;
210
- try { stat = fs.statSync(transcriptPath); } catch { return { skipped: 'transcript is unreadable' }; }
211
- if (!stat.isFile() || stat.size === 0) return { skipped: 'transcript is empty or not a file' };
212
- if (!/\.jsonl$/i.test(transcriptPath)) return { skipped: 'transcript is not a JSONL transcript' };
213
-
214
- const byteOffset = Math.max(0, stat.size - tailBytes);
215
- const byteLength = stat.size - byteOffset;
216
- let buffer;
217
- try {
218
- const handle = fs.openSync(transcriptPath, 'r');
219
- try {
220
- buffer = Buffer.alloc(byteLength);
221
- fs.readSync(handle, buffer, 0, byteLength, byteOffset);
222
- } finally { fs.closeSync(handle); }
223
- } catch { return { skipped: 'transcript tail could not be read' }; }
224
-
225
- const rows = claudeTranscriptRows(buffer.toString('utf8'));
226
- if (rows.length === 0) return { skipped: 'transcript tail contained no parseable records' };
227
- const lastUser = [...rows].reverse()
228
- .find((row) => row?.type === 'user' && row.message?.role === 'user' && textOf(row.message).trim());
229
- const lastAssistant = [...rows].reverse()
230
- .find((row) => row?.type === 'assistant' && textOf(row.message).trim());
231
-
232
- return {
233
- reference: {
234
- path: transcriptPath,
235
- byteOffset,
236
- byteLength,
237
- recordsRead: rows.length,
238
- excerptSha256: sha256(buffer),
239
- format: 'claude-jsonl',
240
- },
241
- // DERIVED ONLY. Callers must not treat these as the user's words; they are a bounded
242
- // single-sentence reduction, recorded so an abandoned session still knows what it was doing.
243
- derivedGoal: lastUser ? firstSentence(textOf(lastUser.message)) : '',
244
- derivedNextAction: lastAssistant ? firstSentence(textOf(lastAssistant.message)) : '',
245
- };
246
- }