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.
- package/README.md +1 -1
- package/bin/install.mjs +65 -54
- package/config/practical-rule-catalog.json +22 -22
- package/console/app.js +1 -1
- package/console/architecture.html +2 -2
- package/package.json +1 -2
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/.grok-plugin/plugin.json +12 -0
- package/plugin/commands/checkpoint.md +22 -40
- package/plugin/commands/rnb-brief.md +18 -15
- package/plugin/hooks/codex-hooks.json +1 -28
- package/plugin/hooks/grok-hooks.json +95 -0
- package/plugin/hooks/hook-contracts.json +6 -127
- package/plugin/hooks/hooks.json +1 -40
- package/plugin/host-adapters/grok.json +11 -0
- package/plugin/mcp/managed-cli-interface.mjs +0 -56
- package/plugin/scripts/agentdb-recall-reader.mjs +29 -9
- package/plugin/scripts/agentdb-recall.mjs +35 -27
- package/plugin/scripts/automated-run.mjs +42 -0
- package/plugin/scripts/brain-footprint.mjs +1 -1
- package/plugin/scripts/checkpoint-keys.mjs +46 -0
- package/plugin/scripts/codex-hook-wrapper.mjs +13 -3
- package/plugin/scripts/completion-claim-evidence.mjs +1 -1
- package/plugin/scripts/continuation-gate.mjs +6 -0
- package/plugin/scripts/continuity-events.mjs +90 -2
- package/plugin/scripts/continuity-hook-policy.mjs +5 -14
- package/plugin/scripts/decision-gate.mjs +2 -2
- package/plugin/scripts/decision-record.mjs +11 -40
- package/plugin/scripts/grok-hook-adapter.mjs +155 -0
- package/plugin/scripts/hook-failures.mjs +16 -0
- package/plugin/scripts/hook-shim.mjs +1 -2
- package/plugin/scripts/learn-capture.mjs +60 -42
- package/plugin/scripts/learning-store.mjs +70 -60
- package/plugin/scripts/node-sqlite.mjs +1 -1
- package/plugin/scripts/ruflo-bin.mjs +71 -1
- package/plugin/scripts/ruflo-memory-hooks.mjs +204 -0
- package/plugin/scripts/runtime-preferences.mjs +1 -1
- package/plugin/scripts/session-snapshot-hook.mjs +85 -341
- package/plugin/scripts/session-start-core.mjs +2 -12
- package/plugin/scripts/signal-watch.mjs +1 -1
- package/plugin/scripts/substitution-wall.mjs +222 -0
- package/plugin/scripts/turn-outcome-capture.mjs +68 -74
- package/scripts/codex-host-execution-proof.mjs +1 -1
- package/scripts/console-engine.mjs +0 -32
- package/scripts/health-repair.mjs +1 -81
- package/scripts/hook-qualify-core.mjs +2 -2
- package/scripts/model-managed-workflow-service.mjs +9 -10
- package/scripts/model-native-qualification.mjs +17 -5
- package/scripts/model-routing-controller.mjs +9 -8
- package/scripts/model-routing-execution-adapters.mjs +24 -2
- package/scripts/model-weekly-cycle.mjs +13 -12
- package/scripts/no-silent-substitution.mjs +65 -1
- package/scripts/onboarding-console.mjs +8 -23
- package/scripts/product-integrity-contract.mjs +1 -1
- package/scripts/release-qualification-contract.mjs +21 -30
- package/scripts/remedy-registry.mjs +0 -18
- package/scripts/user-model-prompt-hook.mjs +11 -10
- package/scripts/wired-check.mjs +1 -5
- package/plugin/scripts/continuity-brief.mjs +0 -351
- package/plugin/scripts/continuity-journal.mjs +0 -553
- package/plugin/scripts/learn-flush.mjs +0 -96
- package/plugin/scripts/learning-observation.mjs +0 -51
- package/plugin/scripts/learning-queue.mjs +0 -135
- package/plugin/scripts/learning-worker-supervisor.mjs +0 -79
- package/plugin/scripts/project-capture-queue.mjs +0 -357
- package/plugin/scripts/project-memory-enrollment.mjs +0 -203
- package/plugin/scripts/project-progression-checkpoint.mjs +0 -145
- package/plugin/scripts/project-progression-contract.mjs +0 -496
- package/plugin/scripts/project-progression-hook.mjs +0 -233
- package/plugin/scripts/project-progression-outbox.mjs +0 -225
- package/plugin/scripts/project-progression-producer.mjs +0 -309
- package/plugin/scripts/project-progression-reader.mjs +0 -304
- package/plugin/scripts/project-progression-session-start.mjs +0 -320
- package/plugin/scripts/project-progression-sources.mjs +0 -246
- package/plugin/scripts/project-progression-store.mjs +0 -723
- package/plugin/scripts/project-progression-suspension.mjs +0 -60
- package/plugin/scripts/project-transition-hook.mjs +0 -267
- package/plugin/scripts/session-snapshot-contract.mjs +0 -105
- package/plugin/scripts/turn-transport-journal.mjs +0 -108
- package/scripts/qa/progression-validation-benchmark.mjs +0 -66
- package/scripts/session-snapshot-contract.mjs +0 -1
|
@@ -1,370 +1,114 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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
|
|
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 {
|
|
21
|
-
import {
|
|
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 (
|
|
40
|
-
*
|
|
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
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
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
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
|
|
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')
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
const
|
|
323
|
-
|
|
324
|
-
const
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
}
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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 {
|
|
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 =
|
|
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
|
|
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").
|