ruvnet-brain 4.6.4 → 4.7.1

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 (82) hide show
  1. package/README.md +1 -1
  2. package/bin/install.mjs +65 -54
  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/.grok-plugin/plugin.json +12 -0
  10. package/plugin/commands/checkpoint.md +22 -40
  11. package/plugin/commands/rnb-brief.md +18 -15
  12. package/plugin/hooks/codex-hooks.json +1 -28
  13. package/plugin/hooks/grok-hooks.json +95 -0
  14. package/plugin/hooks/hook-contracts.json +6 -127
  15. package/plugin/hooks/hooks.json +1 -40
  16. package/plugin/host-adapters/grok.json +11 -0
  17. package/plugin/mcp/managed-cli-interface.mjs +0 -56
  18. package/plugin/scripts/agentdb-recall-reader.mjs +29 -9
  19. package/plugin/scripts/agentdb-recall.mjs +35 -27
  20. package/plugin/scripts/automated-run.mjs +42 -0
  21. package/plugin/scripts/brain-footprint.mjs +1 -1
  22. package/plugin/scripts/checkpoint-keys.mjs +46 -0
  23. package/plugin/scripts/codex-hook-wrapper.mjs +13 -3
  24. package/plugin/scripts/completion-claim-evidence.mjs +1 -1
  25. package/plugin/scripts/continuation-gate.mjs +6 -0
  26. package/plugin/scripts/continuity-events.mjs +90 -2
  27. package/plugin/scripts/continuity-hook-policy.mjs +5 -14
  28. package/plugin/scripts/decision-gate.mjs +2 -2
  29. package/plugin/scripts/decision-record.mjs +11 -40
  30. package/plugin/scripts/grok-hook-adapter.mjs +155 -0
  31. package/plugin/scripts/hook-failures.mjs +16 -0
  32. package/plugin/scripts/hook-shim.mjs +1 -2
  33. package/plugin/scripts/learn-capture.mjs +60 -42
  34. package/plugin/scripts/learning-store.mjs +70 -60
  35. package/plugin/scripts/node-sqlite.mjs +1 -1
  36. package/plugin/scripts/ruflo-bin.mjs +71 -1
  37. package/plugin/scripts/ruflo-memory-hooks.mjs +204 -0
  38. package/plugin/scripts/runtime-preferences.mjs +1 -1
  39. package/plugin/scripts/session-snapshot-hook.mjs +85 -341
  40. package/plugin/scripts/session-start-core.mjs +2 -12
  41. package/plugin/scripts/signal-watch.mjs +1 -1
  42. package/plugin/scripts/substitution-wall.mjs +222 -0
  43. package/plugin/scripts/turn-outcome-capture.mjs +68 -74
  44. package/scripts/codex-host-execution-proof.mjs +1 -1
  45. package/scripts/console-engine.mjs +0 -32
  46. package/scripts/health-repair.mjs +1 -81
  47. package/scripts/hook-qualify-core.mjs +2 -2
  48. package/scripts/model-managed-workflow-service.mjs +9 -10
  49. package/scripts/model-native-qualification.mjs +17 -5
  50. package/scripts/model-routing-controller.mjs +9 -8
  51. package/scripts/model-routing-execution-adapters.mjs +24 -2
  52. package/scripts/model-weekly-cycle.mjs +13 -12
  53. package/scripts/no-silent-substitution.mjs +65 -1
  54. package/scripts/onboarding-console.mjs +8 -23
  55. package/scripts/product-integrity-contract.mjs +1 -1
  56. package/scripts/release-qualification-contract.mjs +21 -30
  57. package/scripts/remedy-registry.mjs +0 -18
  58. package/scripts/user-model-prompt-hook.mjs +11 -10
  59. package/scripts/wired-check.mjs +1 -5
  60. package/plugin/scripts/continuity-brief.mjs +0 -351
  61. package/plugin/scripts/continuity-journal.mjs +0 -553
  62. package/plugin/scripts/learn-flush.mjs +0 -96
  63. package/plugin/scripts/learning-observation.mjs +0 -51
  64. package/plugin/scripts/learning-queue.mjs +0 -135
  65. package/plugin/scripts/learning-worker-supervisor.mjs +0 -79
  66. package/plugin/scripts/project-capture-queue.mjs +0 -357
  67. package/plugin/scripts/project-memory-enrollment.mjs +0 -203
  68. package/plugin/scripts/project-progression-checkpoint.mjs +0 -145
  69. package/plugin/scripts/project-progression-contract.mjs +0 -496
  70. package/plugin/scripts/project-progression-hook.mjs +0 -233
  71. package/plugin/scripts/project-progression-outbox.mjs +0 -225
  72. package/plugin/scripts/project-progression-producer.mjs +0 -309
  73. package/plugin/scripts/project-progression-reader.mjs +0 -304
  74. package/plugin/scripts/project-progression-session-start.mjs +0 -320
  75. package/plugin/scripts/project-progression-sources.mjs +0 -246
  76. package/plugin/scripts/project-progression-store.mjs +0 -723
  77. package/plugin/scripts/project-progression-suspension.mjs +0 -60
  78. package/plugin/scripts/project-transition-hook.mjs +0 -267
  79. package/plugin/scripts/session-snapshot-contract.mjs +0 -105
  80. package/plugin/scripts/turn-transport-journal.mjs +0 -108
  81. package/scripts/qa/progression-validation-benchmark.mjs +0 -66
  82. package/scripts/session-snapshot-contract.mjs +0 -1
@@ -1,370 +1,114 @@
1
- import { DETACHED_REPLAY_BUDGET_MS, REPLAY_LOCK_STALE_MS, REPLAY_LOCK_ABANDON_MS, pidAlive, queueCapture, queuedCaptures, queuedWork, processStart, reclaimOrphans, takeReplayLock, refreshReplayLock, adoptReplayLock, releaseReplayLock, replayOutboxDetached, runOutboxReplay, drainCaptureQueue } from './project-capture-queue.mjs';
2
- export { DETACHED_REPLAY_BUDGET_MS, REPLAY_LOCK_STALE_MS, REPLAY_LOCK_ABANDON_MS, pidAlive, queueCapture, queuedCaptures, queuedWork, processStart, reclaimOrphans, takeReplayLock, refreshReplayLock, adoptReplayLock, releaseReplayLock, replayOutboxDetached, runOutboxReplay, drainCaptureQueue } from './project-capture-queue.mjs';
1
+ /**
2
+ * The lifecycle memory boundary. It only CALLS ruflo (owner ruling 2026-10-12): the hand-rolled
3
+ * progression store, outbox, capture queue and replay worker were deleted after a live-size copy of the
4
+ * owner's store (1,763 cumulative snapshots, 751 MB; a 3.38 GB outbox; 36k queued jobs) ran Stop,
5
+ * PreCompact and SessionEnd into the 8.5 s watchdog on every fire.
6
+ *
7
+ * Stop → the turn outcome: `ruflo memory store` in namespace `turns` + exact-key readback
8
+ * PreCompact/SessionEnd → turn distillation (detached `ruflo memory distill run`) and one append-only
9
+ * `project-state-auto-<ms>` checkpoint, read back by exact key
10
+ * PostToolUse → learning observation (consent-gated) via `ruflo memory store`
11
+ *
12
+ * A refused or unverified write is recorded in the hook-failures ledger and summarized at the next
13
+ * SessionStart. Advisory always: exit 0. `.swarm` is never created here.
14
+ */
3
15
  import fs from 'node:fs';
4
- import path from 'node:path';
5
16
  import os from 'node:os';
6
- import { spawnSync } from 'node:child_process';
17
+ import path from 'node:path';
18
+ import { spawn } from 'node:child_process';
7
19
  import { fileURLToPath } from 'node:url';
8
- import { ProgressionOutbox } from './project-progression-outbox.mjs';
9
- import {
10
- captureProjectTransition,
11
- hasProjectProgression,
12
- } from './project-progression-hook.mjs';
13
- import { createSessionSnapshot } from './session-snapshot-contract.mjs';
14
20
  import { projectDirectory } from './project-identity.mjs';
15
- import { buildProjectProgression } from './project-progression-producer.mjs';
16
- import { ProjectProgressionStore } from './project-progression-store.mjs';
17
- import { resolveProjectStore } from './project-store-resolver.mjs';
18
- import { ensureProjectMemory } from './project-memory-enrollment.mjs';
19
21
  import { resolveTurnDb, captureTurnOutcome } from './turn-outcome-capture.mjs';
20
- import { captureContinuityEvents, stopNotice } from './continuity-journal.mjs';
21
- import { automaticProgressionSuspensionResult } from './project-progression-suspension.mjs';
22
+ import { recordHookFailure } from './hook-failures.mjs';
23
+ import { redactText } from './continuity-events.mjs';
22
24
 
23
- /**
24
- * The capture boundary's whole budget. hooks.json declares 10s; this keeps the internal work well
25
- * inside it so the host never has to kill us, and so a slow store degrades to "no snapshot this
26
- * time" rather than to a hung turn. Capture is advisory: it fails open, always.
27
- */
25
+ /** hooks.json gives this boundary 10 s; the shim's watchdog fires at 8.5 s. Plan inside it. */
28
26
  export const CAPTURE_BUDGET_MS = 8_000;
29
27
 
30
- /**
31
- * Replaying an interrupted session's outbox costs one `ruflo` write per pending snapshot, each ~3s
32
- * cold (project-progression-store.mjs). Under this budget there is room for the NEW snapshot or the
33
- * old ones, not both — and the new one is the one nothing else will ever write.
34
- */
35
- export const REPLAY_MIN_BUDGET_MS = 4_000;
36
-
37
28
  /**
38
29
  * The budget this invocation really has. The Codex wrapper hands its own kill deadline down as
39
- * RUVNET_CODEX_BUDGET_MS (2200ms at SessionEnd, which Codex caps at 3s); planning for 8s there meant
40
- * being SIGKILLed mid-write with nothing reported. 300ms is left for the adapter → shim → body spawns.
30
+ * RUVNET_CODEX_BUDGET_MS (2200 ms at SessionEnd, which Codex caps at 3 s); 300 ms is left for the
31
+ * adapter → shim → body spawns.
41
32
  */
42
33
  export function effectiveBudgetMs(env = process.env) {
43
34
  const handed = Number(env.RUVNET_CODEX_BUDGET_MS);
44
35
  return Number.isFinite(handed) && handed > 0 ? Math.max(0, Math.min(CAPTURE_BUDGET_MS, handed - 300)) : CAPTURE_BUDGET_MS;
45
36
  }
46
37
 
47
- function regularOrAbsent(file) {
48
- try {
49
- const stat = fs.lstatSync(file);
50
- return stat.isFile() && !stat.isSymbolicLink();
51
- } catch (error) {
52
- return error?.code === 'ENOENT';
53
- }
54
- }
55
-
56
- export function writeSessionSnapshot(projectDir, event) {
57
- const swarm = path.join(projectDir, '.swarm');
58
- const target = path.join(swarm, 'agentdb-sessions.jsonl');
59
- try {
60
- // WE DO NOT CREATE `.swarm` — WE ONLY WRITE INTO ONE THAT EXISTS.
61
- //
62
- // This hook runs machine-wide, so `mkdirSync(swarm)` planted a `.swarm/` directory in EVERY
63
- // repository the user opened, alongside a session receipt they never asked for. Measured
64
- // 2026-08-14 by the both-hosts conformance gate, in a temp project with no git and no brain
65
- // artifacts: PreCompact, PostToolUse and SessionEnd each left `.swarm` behind. ADR-058 D5 —
66
- // never touch what we do not own — and the owner's report was blunter: opening the plugin in
67
- // another project produced files and errors he did not ask for.
68
- //
69
- // `.swarm` is Ruflo's own convention and `ruflo init` creates it, so its PRESENCE is the
70
- // project's opt-in and its ABSENCE is a project that has not adopted the brain. Writing a
71
- // receipt into a store that exists is participation; conjuring the store is trespass.
72
- if (!fs.existsSync(swarm)) return false;
73
- const stat = fs.lstatSync(swarm);
74
- if (!stat.isDirectory() || stat.isSymbolicLink()) return false;
75
- if (!regularOrAbsent(target)) return false;
76
- fs.appendFileSync(target, `${JSON.stringify(createSessionSnapshot({ event }))}\n`, { mode: 0o600 });
77
- return true;
78
- } catch {
79
- return false;
80
- }
81
- }
82
-
83
- /**
84
- * A deadline-bounded `ruflo` runner. The store's own 120s per-call timeout is right for a deliberate
85
- * CLI invocation and far too generous for a lifecycle hook, so the remaining budget caps every call.
86
- */
87
- export function boundedStoreFactory(deadlineAt) {
88
- return (options) => new ProjectProgressionStore({
89
- ...options,
90
- runner: (binary, args, runOptions) => {
91
- const remaining = deadlineAt - Date.now();
92
- if (remaining < 1) throw new Error('capture budget exceeded');
93
- const result = spawnSync(binary, args, {
94
- ...runOptions,
95
- timeout: Math.min(runOptions.timeout ?? remaining, remaining),
96
- shell: false,
97
- });
98
- if (result.error) throw new Error(`capture budget exceeded: ${result.error.message}`);
99
- return result;
100
- },
101
- });
102
- }
103
-
104
38
  /**
105
- * THE AUTOMATIC CAPTURE BOUNDARY.
106
- *
107
- * Before this, `captureProjectTransition` could only run when a host payload already carried a
108
- * `projectProgression` extension — and no host emits one, so nothing was ever captured. Now the
109
- * producer BUILDS that extension from real sources (git, the work ledger, the owner's own
110
- * `project-state-current` note, the prior head, and a bounded transcript reference) whenever the
111
- * payload does not supply one. An explicitly supplied extension still wins: that is how
112
- * /ruvnet-brain:checkpoint hands over a state the model actually wrote.
113
- *
114
- * Two things are deliberately NOT done here:
115
- * • `.swarm` is never created. Its absence means the project has not adopted the brain, and a
116
- * lifecycle hook that plants a store in every repository the user opens is trespass (see above).
117
- * • No exception escapes. A capture boundary that can fail a turn is worse than a missed snapshot.
39
+ * What one checkpoint needs: a store plus an exact readback, each measured at 0.5-1.3 s on a live-size
40
+ * store (ADR-106). With less left (Codex gives SessionEnd ~1.9 s for everything) it is handed to a
41
+ * detached, bounded child instead of being started with a ~1 ms timeout (review M4).
118
42
  */
119
- export function runSessionSnapshotHook(projectDir, event, {
120
- rawInput = '',
121
- env = process.env,
122
- host = process.env.RUVNET_HOOK_HOST || 'claude',
123
- captureProgression = captureProjectTransition,
124
- produce = buildProjectProgression,
125
- budgetMs = effectiveBudgetMs(),
126
- now = Date.now,
127
- captureTurn = captureTurnOutcome,
128
- writeMetadata = true,
129
- makeStoreFactory = boundedStoreFactory,
130
- spawnReplay = replayOutboxDetached,
131
- ordered = null,
132
- captureEvents = captureContinuityEvents,
133
- enrollMemory = ensureProjectMemory,
134
- deadlineAt: inheritedDeadlineAt = Infinity,
135
- signal,
136
- } = {}) {
137
- const deadlineAt = Math.min(inheritedDeadlineAt, now() + budgetMs);
138
- const checkDeadline = () => { if (signal?.aborted || now() >= deadlineAt) throw new Error('restore deadline exceeded'); };
139
- const suspended = (reason) => ({ metadataWritten: false, progressionCaptured: false, receipt: null, skipped: reason,
140
- turn: { event, recorded: false, skipped: reason }, continuity: { event, recorded: 0, launched: false, skipped: reason } });
141
- let enrollmentPayload = {};
142
- try { enrollmentPayload = rawInput ? JSON.parse(rawInput) : {}; } catch { /* malformed payload */ }
143
- const enrollment = enrollMemory({ projectDir, env, event, payload: enrollmentPayload, host, deadlineAt });
144
- if (enrollment.state === 'pending') return { ...suspended('native project memory enrollment pending'), enrollment };
145
- if (enrollment.state === 'disabled' || enrollment.state === 'unavailable') return { ...suspended(enrollment.reason), enrollment };
146
- // Consent is checked before metadata, transcript inspection or any durable capture queue.
147
- try {
148
- checkDeadline();
149
- const consent = resolveTurnDb({ projectDir, brainHome: env.RUVNET_BRAIN_HOME || path.join(env.HOME || os.homedir(), '.cache', 'ruvnet-brain'),
150
- gitTimeoutMs: Math.max(1, Math.min(500, deadlineAt - now())), deadlineAt, signal });
151
- checkDeadline();
152
- if (consent.skipped) return suspended(consent.skipped);
153
- } catch (error) {
154
- return suspended(`capture consent unavailable: ${error.message}`);
155
- }
156
- // The detached worker re-runs a QUEUED boundary; its session receipt was already written then.
157
- const metadataWritten = writeMetadata ? writeSessionSnapshot(projectDir, event) : false;
158
- let payload;
159
- try { payload = rawInput ? JSON.parse(rawInput) : {}; } catch { payload = {}; }
160
- // TURN OUTCOMES FIRST: consent and canonical store availability govern whether a turn is queued
161
- // (a project without a store requires persisted opt-in — turn-outcome-capture.mjs).
162
- // It only reads and spawns a detached writer, so it costs the progression budget below nothing.
163
- let turn;
164
- try { checkDeadline(); turn = captureTurn({ projectDir, event, payload, host, env, deadlineAt, signal }); checkDeadline(); } catch (error) {
165
- turn = { recorded: false, skipped: `turn capture failed: ${error.message}` };
166
- }
167
- // MATERIAL EVENTS (continuity-journal.mjs): commits, releases, gates, findings, decisions, lessons —
168
- // journalled to the durable outbox with one fsync and committed by a detached drainer. Like turn
169
- // capture it is independent of the progression lock below and costs this boundary only a read.
170
- let continuity;
171
- try { checkDeadline(); continuity = captureEvents({ projectDir, event, payload, host, env, deadlineAt, signal }); checkDeadline(); } catch (error) {
172
- continuity = { recorded: 0, skipped: `continuity capture failed: ${error.message}` };
173
- }
174
- const idle = { metadataWritten, progressionCaptured: false, receipt: null, turn, continuity };
175
- const operatorSuspended = automaticProgressionSuspensionResult(env, idle);
176
- if (operatorSuspended) return operatorSuspended;
177
-
178
- if (hasProjectProgression(payload)) {
179
- checkDeadline();
180
- if (payload.hook_event_name !== event) {
181
- throw new Error(`progression boundary mismatch: expected ${event}, received ${payload.hook_event_name}`);
182
- }
183
- const result = captureProgression({ host, payload, projectDir, env, recoverFrozen: Boolean(ordered),
184
- canCommit: () => now() < deadlineAt && !signal?.aborted && Boolean(ordered)
185
- && refreshReplayLock(resolveProjectStore({ projectDir, deadlineAt }).projectRoot, ordered),
186
- storeFactory: (options) => makeStoreFactory(deadlineAt)({ ...options, env, deadlineAt, signal }) });
187
- return { ...idle, progressionCaptured: true, receipt: result.receipt };
188
- }
189
-
190
- // A payload with no session identity is not a real lifecycle event (an empty `{}` from a probe,
191
- // a malformed host). Capturing against an invented session id would fabricate a journal entry.
192
- if (typeof payload.session_id !== 'string' || !payload.session_id) {
193
- return { ...idle, skipped: 'no session identity in the host payload' };
194
- }
195
-
196
- let resolution;
197
- try { checkDeadline(); resolution = resolveProjectStore({ projectDir, deadlineAt }); } catch {
198
- return { ...idle, skipped: 'project store could not be resolved' };
199
- }
200
- if (!fs.existsSync(path.dirname(resolution.canonicalAgentDbPath))) {
201
- return { ...idle, skipped: 'project has not adopted the canonical store' };
43
+ export const CHECKPOINT_NEED_MS = 2_600;
44
+
45
+ async function checkpointNow({ projectDir, event, payload, env, deadlineAt, brainHome }) {
46
+ // Same consent as turn capture: a persisted opt-out, or a project without a store, writes nothing.
47
+ const consent = resolveTurnDb({ projectDir, brainHome, deadlineAt });
48
+ if (consent.skipped) return { stored: false, skipped: consent.skipped };
49
+ const { checkpoint } = await import('./ruflo-memory-hooks.mjs');
50
+ const saved = await checkpoint({ projectDir, event, payload, env, deadlineAt });
51
+ if (saved.stored === false && saved.key || saved.stored && saved.verified !== true) {
52
+ recordHookFailure({ hook: 'checkpoint', event, error: saved.skipped || 'exact checkpoint readback failed' }, { brainHome });
202
53
  }
54
+ return saved;
55
+ }
203
56
 
204
- const root = resolution.projectRoot;
205
- const pendingCount = () => {
206
- try { return new ProgressionOutbox({ projectRoot: root }).pendingSnapshots().length; } catch { return 0; }
207
- };
57
+ /** The detached checkpoint: its own full budget, the same consent, the same ledger on failure. */
58
+ export function detachCheckpoint({ projectDir, event, payload, env, brainHome }) {
59
+ const job = JSON.stringify({ projectDir, session_id: payload?.session_id ?? null });
60
+ const childEnv = { ...env, RUVNET_CHECKPOINT_JOB: job, RUVNET_BRAIN_HOME: brainHome };
61
+ delete childEnv.RUVNET_CODEX_BUDGET_MS;
62
+ const child = spawn(process.execPath, [fileURLToPath(import.meta.url), '--checkpoint-detached', event],
63
+ { cwd: os.tmpdir(), detached: true, stdio: 'ignore', windowsHide: true, env: childEnv });
64
+ child.unref();
65
+ return { detached: true, pid: child.pid };
66
+ }
208
67
 
209
- // CAUSAL ORDER. The producer links a new snapshot to the COMMITTED heads
210
- // (project-progression-producer.mjs), so a snapshot produced while older work is uncommitted would
211
- // not descend from it and the project would end with two unrelated heads
212
- // (tests/acceptance/cross-host-project-resume.test.mjs). "Older work" is BOTH the outbox (captures
213
- // interrupted after their fsync) AND the capture queue (whole boundaries waiting for a worker).
214
- //
215
- // THE REPLAY LOCK IS THE RIGHT TO COMMIT IN ORDER (4.4.0 re-review S-A). Every boundary takes it
216
- // before doing anything that commits. If it cannot — a live worker holds it — or older work is
217
- // queued, or (on a short budget) outbox debt cannot be replayed here, this boundary QUEUES itself
218
- // behind that work and the lock passes to a DETACHED, bounded worker that drains everything in
219
- // order. On Codex no boundary has the replay budget (Stop 3700ms effective, SessionEnd 1900ms, no
220
- // PreCompact). Any boundary, of any budget, therefore also drains a queue a dead worker stranded.
221
- // `ordered` is the worker's own re-entry: it already holds the lock and is draining in order.
222
- let token = ordered;
223
- const handOff = (why) => {
224
- let frozen;
225
- try { checkDeadline(); frozen = produce({ resolution, projectDir, payload, host, env, trigger: event, deadlineAt, signal }); checkDeadline(); } catch { frozen = null; }
226
- const queued = frozen?.projectProgression ? queueCapture({ projectDir: root, originProjectDir: projectDir, env, event, host,
227
- payload: { session_id: payload.session_id, hook_event_name: event, projectProgression: frozen.projectProgression } }) : null;
228
- const handed = queued ? spawnReplay({ projectDir: root, token, env }) : false;
229
- if (!handed && token && token !== ordered) releaseReplayLock(root, token);
230
- return { ...idle, replayed: 0, progressionCaptured: false, deferredToReplayer: Boolean(queued),
231
- replaySkipped: `${why}; this capture ${queued ? 'queued behind it' : 'NOT queued (queue unwritable)'}`
232
- + `${queued ? (handed ? ', handed to a detached worker' : ' (the current lock holder hands the queue to a worker when it releases)') : ''}` };
233
- };
234
- if (!ordered) {
235
- token = takeReplayLock(root);
236
- if (!token) return handOff('a worker is committing older work');
237
- const queuedAhead = queuedWork(root);
238
- if (queuedAhead) return handOff(`${queuedAhead} older capture(s) queued`);
239
- if (budgetMs < REPLAY_MIN_BUDGET_MS) {
240
- const pending = pendingCount();
241
- if (pending) return handOff(`outbox replay deferred: budget ${budgetMs}ms < ${REPLAY_MIN_BUDGET_MS}ms; ${pending} pending`);
242
- }
68
+ export async function runLifecycleBoundary(event, payload, { env = process.env, deadlineAt = Date.now() + effectiveBudgetMs(env) } = {}) {
69
+ const projectDir = typeof payload?.cwd === 'string' && path.isAbsolute(payload.cwd) ? payload.cwd : projectDirectory();
70
+ const host = env.RUVNET_HOOK_HOST || 'claude';
71
+ if (event === 'PostToolUse') {
72
+ const { captureLearning } = await import('./learn-capture.mjs');
73
+ return { learning: captureLearning(payload, { env, detach: true }) };
243
74
  }
244
-
245
- let handedLock = false;
246
- try {
247
- checkDeadline();
248
- const storeFactory = (options) => makeStoreFactory(deadlineAt)({ ...options, env, deadlineAt, signal });
249
- let replayed = 0;
250
- if (budgetMs >= REPLAY_MIN_BUDGET_MS) {
251
- try {
252
- replayed = storeFactory({ projectDir, requestedStorePath: resolution.canonicalAgentDbPath }).replay().length;
253
- } catch { /* the debt stays durable in the outbox; this capture is still worth attempting */ }
254
- }
255
-
256
- // Before COMMITTING anything, re-check the lock is still ours: with three racers, a stale-lock
257
- // put-back can leave a holder that no longer owns it. One that lost it queues itself instead.
258
- if (!ordered && !refreshReplayLock(root, token)) {
259
- token = null;
260
- handedLock = true; // nothing of ours to release
261
- return handOff('the lock was taken over before this capture committed');
262
- }
263
-
264
- let produced;
265
- try {
266
- checkDeadline();
267
- produced = produce({ resolution, projectDir, payload, host, env, trigger: event, deadlineAt, signal });
268
- checkDeadline();
269
- } catch (error) {
270
- return { ...idle, replayed, skipped: `producer failed: ${error.message}` };
271
- }
272
- if (produced.skipped) return { ...idle, replayed, skipped: produced.skipped.reason };
273
-
274
- let result;
275
- try {
276
- result = captureProgression({
277
- host,
278
- payload: { ...payload, hook_event_name: event, projectProgression: produced.projectProgression },
279
- projectDir,
280
- env,
281
- storeFactory,
282
- });
283
- } catch (error) {
284
- // NOT LOST — DEFERRED. capture() fsyncs the snapshot to the durable outbox BEFORE it writes to
285
- // the store, so a budget overrun leaves the evidence on disk. On a short budget nothing later in
286
- // this process can settle it, so the lock goes straight to a detached worker.
287
- handedLock = !ordered && budgetMs < REPLAY_MIN_BUDGET_MS && pendingCount() > 0 && spawnReplay({ projectDir: root, token, env });
288
- return { ...idle, replayed, skipped: `capture deferred: ${error.message}`,
289
- ...(handedLock ? { replaySkipped: 'deferred capture handed to a detached worker' } : {}) };
290
- }
291
- return {
292
- metadataWritten,
293
- progressionCaptured: true,
294
- turn,
295
- continuity,
296
- replayed,
297
- receipt: result.receipt,
298
- provenance: produced.provenance,
299
- };
300
- } finally {
301
- // A boundary that fired while this one held the lock queued itself and could not start a worker
302
- // (this lock was in the way). Releasing without looking stranded it until the next boundary — two
303
- // simultaneous SessionEnds lost the second one's final state (4.4.1). So: queued work → hand THIS
304
- // lock to a worker; and re-check after releasing, for a boundary that queued in between.
305
- if (!ordered && !handedLock) {
306
- if (!(queuedWork(root) && spawnReplay({ projectDir: root, token, env }))) {
307
- releaseReplayLock(root, token);
308
- if (queuedWork(root)) spawnReplay({ projectDir: root, env });
309
- }
75
+ if (!['Stop', 'PreCompact', 'SessionEnd'].includes(event)) return { skipped: `no lifecycle memory at ${event}` };
76
+ const turn = captureTurnOutcome({ projectDir, event, payload, host, env, deadlineAt });
77
+ if (event === 'Stop') return { turn };
78
+ try { // the observation rows are distilled by rUv's `memory distill run`, detached and bounded (review M3)
79
+ const { learningContext } = await import('./runtime-preferences.mjs');
80
+ if (learningContext().enabled) (await import('./learn-capture.mjs')).detachLearning('--distill', { env });
81
+ } catch (error) { recordHookFailure({ hook: 'learn-distill', event, error: `detach failed: ${error.code || 'spawn'}` }); }
82
+ if (String(env.RUVNET_TURN_CAPTURE || '').toLowerCase() === 'off') return { turn, checkpoint: { stored: false, skipped: 'RUVNET_TURN_CAPTURE=off' } };
83
+ const brainHome = env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache', 'ruvnet-brain');
84
+ if (deadlineAt - Date.now() < CHECKPOINT_NEED_MS) {
85
+ try { return { turn, checkpoint: { stored: false, ...detachCheckpoint({ projectDir, event, payload, env, brainHome }) } }; } catch (error) {
86
+ recordHookFailure({ hook: 'checkpoint', event, error: `skipped: hook budget too small and detach failed (${error.code || 'spawn'})` }, { brainHome });
87
+ return { turn, checkpoint: { stored: false, skipped: 'hook budget too small; detach failed' } };
310
88
  }
311
89
  }
90
+ return { turn, checkpoint: await checkpointNow({ projectDir, event, payload, env, deadlineAt, brainHome }) };
312
91
  }
313
92
 
314
- if (process.argv[1] && path.resolve(process.argv[1]).endsWith('session-snapshot-hook.mjs') && process.argv[2] === '--replay-outbox') {
315
- try { runOutboxReplay({ projectDir: process.cwd() }); } catch { /* the debt stays durable in the outbox */ }
316
- } else if (process.argv[1] && path.resolve(process.argv[1]).endsWith('session-snapshot-hook.mjs')
317
- && ['UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'PostToolUseFailure', 'SubagentStop'].includes(process.argv[2])) {
318
- // Compatibility entrypoint uses the same minimized transition producer as direct registrations.
319
- // Finish evaluating this module before importing its transition consumer.
320
- void (async () => {
321
- try {
322
- const { runProjectTransitionHook, transitionPendingNotice } = await import('./project-transition-hook.mjs');
323
- const payload = JSON.parse(fs.readFileSync(0, 'utf8') || '{}');
324
- const projectDir = payload.cwd || projectDirectory();
325
- const result = runProjectTransitionHook(projectDir, process.argv[2], { payload });
326
- // ADR-106: learning capture rides this boundary instead of a second PostToolUse registration.
327
- if (process.argv[2] === 'PostToolUse') { try { (await import('./learn-capture.mjs')).captureLearning(payload); } catch { /* advisory */ } }
328
- // A queued, handed-off observation is normal and silent (ADR-106); only stuck or degraded speaks.
329
- if (result.state === 'stuck' || result.state === 'degraded') {
330
- const message = transitionPendingNotice(projectDir, payload, result.state === 'stuck'
331
- ? `Project memory capture is stuck: queued work since ${result.oldestQueuedAt} has not been committed by exact readback.`
332
- : 'Project memory capture degraded: the observation queue is unwritable.');
333
- if (message) process.stdout.write(JSON.stringify({ systemMessage: message }));
334
- }
335
- } catch { process.stdout.write(JSON.stringify({ systemMessage: 'Project memory transition capture degraded; exact readback was not verified.' })); }
336
- })();
337
- } else if (process.argv[1] && path.resolve(process.argv[1]).endsWith('session-snapshot-hook.mjs')) {
338
- // projectDirectory() is the SAME derivation the Console's detector uses. Deriving it here
339
- // independently is what let this hook write a receipt the Console then reported as missing (#85).
340
- const rawInput = fs.readFileSync(0, 'utf8');
341
- try {
342
- let originProjectDir = projectDirectory();
343
- try {
344
- const cwd = JSON.parse(rawInput || '{}').cwd;
345
- if (typeof cwd === 'string' && path.isAbsolute(cwd)) originProjectDir = cwd;
346
- } catch { /* malformed input keeps the host's project fallback */ }
347
- const result = runSessionSnapshotHook(originProjectDir, process.argv[2] || 'SessionEnd', { rawInput });
348
- // ADR-106: learning recovery rides SessionEnd instead of a second SessionEnd registration.
349
- if ((process.argv[2] || 'SessionEnd') === 'SessionEnd') {
350
- import('./learn-capture.mjs').then((m) => m.scheduleLearningRecovery({ cwd: originProjectDir })).catch(() => {});
351
- }
352
- // FAIL LOUDLY, NEVER SILENTLY — AND ONCE. When recording is stuck (events pending past STUCK_AFTER_MS,
353
- // a quarantined conflict, a corrupt outbox line, a cap drop) Claude Code shows this systemMessage, at
354
- // most once per session per condition (stopNotice; it used to repeat at every turn). "Not applicable"
355
- // (no store, no ruflo) is never stuck. Codex is excluded on purpose: its Stop schema turns any `reason`
356
- // into a BLOCK (codex-hook-adapter.mjs), and a recording problem must never hold a turn open.
357
- const host = process.env.RUVNET_HOOK_HOST || 'claude';
358
- const { status, journal } = result?.continuity || {};
359
- if (host === 'claude' && status?.stuck && journal) {
360
- let session = null;
361
- try { session = JSON.parse(rawInput || '{}').session_id || null; } catch { /* no session: still once per 'unknown' */ }
362
- const message = stopNotice({ journal, status, session });
363
- if (message) process.stdout.write(JSON.stringify({ systemMessage: message }));
93
+ if (process.argv[1] && path.resolve(process.argv[1]).endsWith('session-snapshot-hook.mjs')) {
94
+ const loud = (event) => (error) => {
95
+ // ADVISORY, ALWAYS: exit 0 — but LOUD (review M1): the shim records only nonzero exits, so a thrown
96
+ // error is a ledger row here. A fixed-length reason, never payload text.
97
+ recordHookFailure({ hook: 'session-snapshot', event, error: `threw: ${redactText(String(error?.message || error)).slice(0, 120)}` });
98
+ process.stderr.write(`[session-snapshot] ${error?.message}\n`);
99
+ };
100
+ if (process.argv[2] === '--checkpoint-detached') {
101
+ const event = process.argv[3] || 'SessionEnd';
102
+ let job = {}; try { job = JSON.parse(process.env.RUVNET_CHECKPOINT_JOB || '{}'); } catch { /* invalid job: nothing to do */ }
103
+ const brainHome = process.env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache', 'ruvnet-brain');
104
+ if (typeof job.projectDir === 'string' && path.isAbsolute(job.projectDir)) {
105
+ checkpointNow({ projectDir: job.projectDir, event, payload: { session_id: job.session_id }, env: process.env,
106
+ deadlineAt: Date.now() + CAPTURE_BUDGET_MS, brainHome }).catch(loud(event));
364
107
  }
365
- } catch (error) {
366
- // ADVISORY, ALWAYS. A capture boundary fires at Stop, PreCompact and SessionEnd; one that can
367
- // return a non-zero status can interrupt a turn, a compaction, or a clean exit. Report and exit 0.
368
- process.stderr.write(`[project-progression] ${error.message}\n`);
108
+ } else {
109
+ const event = process.argv[2] || 'SessionEnd';
110
+ let payload = {};
111
+ try { payload = JSON.parse(fs.readFileSync(0, 'utf8') || '{}'); } catch { /* malformed or absent input */ }
112
+ runLifecycleBoundary(event, payload).catch(loud(event));
369
113
  }
370
114
  }
@@ -26,8 +26,7 @@ import fs from 'node:fs';
26
26
  import os from 'node:os';
27
27
  import path from 'node:path';
28
28
  import { fileURLToPath } from 'node:url';
29
- import { enrollmentPlan } from './project-memory-enrollment.mjs';
30
- import { restoreWithBrief } from './continuity-brief.mjs';
29
+ import { restore as restoreFromRuflo } from './ruflo-memory-hooks.mjs';
31
30
  import { turnRecordingStatus } from './turn-outcome-capture.mjs';
32
31
  import {
33
32
  read, json, exists, mkdir, write, runNode,
@@ -93,13 +92,12 @@ export async function runSessionStart({
93
92
  stdout = process.stdout,
94
93
  stderr = process.stderr,
95
94
  platform = process.platform,
96
- restoreContinuity = restoreWithBrief,
95
+ restoreContinuity = restoreFromRuflo,
97
96
  runHeartbeat = true,
98
97
  sessionSource = 'startup',
99
98
  probeConsoleAlias = probeCodexConsoleAlias,
100
99
  startedAt = Date.now(),
101
100
  deadlineAt = Infinity,
102
- scheduleRecovery = true,
103
101
  } = {}) {
104
102
  const hookDeadlineAt = Math.min(deadlineAt, sessionStartDeadlineAt({ host: env.RUVNET_HOOK_HOST || 'claude', env, startedAt }));
105
103
  const lines = [];
@@ -195,11 +193,6 @@ export async function runSessionStart({
195
193
  }
196
194
  };
197
195
 
198
- // Report only. SessionStart never creates or bootstraps a store (ADR-105 rule 6); enrollment starts at the first
199
- // consented captured boundary, so a session that merely started leaves no store behind.
200
- let enrollment;
201
- try { enrollment = enrollmentPlan({ projectDir: cwd, env, deadlineAt: hookDeadlineAt }); } catch (error) { enrollment = { state: 'unavailable', reason: error.message }; }
202
- if (enrollment.state === 'pending' || enrollment.state === 'unavailable') emit(`[RuvNet Brain — TURN CAPTURE] Project memory enrollment ${enrollment.state}; restoration is not yet proven.`);
203
196
  const restoreStart = Date.now();
204
197
  const restoreDeadlineAt = Math.min(hookDeadlineAt, restoreStart + STAGE_BUDGETS_MS.restore);
205
198
  const restoreController = new AbortController();
@@ -454,9 +447,6 @@ export async function runSessionStart({
454
447
  stdout.write(output);
455
448
  trace('body-finished');
456
449
  recordProof({ stages: tracer.table(), restore: restoreProof, bodyFailed, bannerFallback: !bannerEmitted });
457
- // ADR-106: learning recovery rides SessionStart instead of a second SessionStart registration.
458
- // learningContext() inside owns consent and brain-off; the detached worker is never awaited.
459
- if (scheduleRecovery) { try { (await import('./learn-capture.mjs')).scheduleLearningRecovery({ cwd, env }); } catch { /* advisory */ } }
460
450
  return { ok: true, outputBytes: Buffer.byteLength(output, 'utf8'), platform };
461
451
  }
462
452
 
@@ -49,7 +49,7 @@
49
49
  // SINGLE WRITER (DDD-0013 Context 2 invariant 3): this file is the ONLY writer of pending.jsonl.
50
50
  // scripts/signal-watch.mjs (the poller) only ever reads it.
51
51
  //
52
- // CONTRACT (matches every other node hook body in this dir — md-stamp.mjs, learn-flush.mjs):
52
+ // CONTRACT (matches every other node hook body in this dir — md-stamp.mjs):
53
53
  // dispatched via hook-shim.mjs (mode: advisory). ALWAYS exits 0; never throws to the caller; a
54
54
  // parse/IO failure degrades to silence, never a crash the harness has to survive (DDD invariant 4:
55
55
  // "observation never blocks and never manufactures a refusal").