ruvnet-brain 4.6.4 → 4.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +1 -1
  2. package/bin/install.mjs +6 -50
  3. package/config/practical-rule-catalog.json +22 -22
  4. package/console/app.js +1 -1
  5. package/console/architecture.html +2 -2
  6. package/package.json +1 -2
  7. package/plugin/.claude-plugin/plugin.json +1 -1
  8. package/plugin/.codex-plugin/plugin.json +1 -1
  9. package/plugin/commands/checkpoint.md +22 -40
  10. package/plugin/commands/rnb-brief.md +18 -15
  11. package/plugin/hooks/codex-hooks.json +1 -28
  12. package/plugin/hooks/hook-contracts.json +6 -127
  13. package/plugin/hooks/hooks.json +1 -40
  14. package/plugin/mcp/managed-cli-interface.mjs +0 -56
  15. package/plugin/scripts/agentdb-recall-reader.mjs +29 -9
  16. package/plugin/scripts/agentdb-recall.mjs +8 -14
  17. package/plugin/scripts/brain-footprint.mjs +1 -1
  18. package/plugin/scripts/codex-hook-wrapper.mjs +13 -3
  19. package/plugin/scripts/completion-claim-evidence.mjs +1 -1
  20. package/plugin/scripts/continuity-events.mjs +90 -2
  21. package/plugin/scripts/continuity-hook-policy.mjs +5 -14
  22. package/plugin/scripts/decision-gate.mjs +2 -2
  23. package/plugin/scripts/decision-record.mjs +11 -40
  24. package/plugin/scripts/hook-failures.mjs +16 -0
  25. package/plugin/scripts/hook-shim.mjs +1 -2
  26. package/plugin/scripts/learn-capture.mjs +60 -42
  27. package/plugin/scripts/learning-store.mjs +70 -60
  28. package/plugin/scripts/node-sqlite.mjs +1 -1
  29. package/plugin/scripts/ruflo-bin.mjs +71 -1
  30. package/plugin/scripts/ruflo-memory-hooks.mjs +132 -0
  31. package/plugin/scripts/runtime-preferences.mjs +1 -1
  32. package/plugin/scripts/session-snapshot-hook.mjs +85 -341
  33. package/plugin/scripts/session-start-core.mjs +2 -12
  34. package/plugin/scripts/signal-watch.mjs +1 -1
  35. package/plugin/scripts/substitution-wall.mjs +222 -0
  36. package/plugin/scripts/turn-outcome-capture.mjs +68 -74
  37. package/scripts/codex-host-execution-proof.mjs +1 -1
  38. package/scripts/console-engine.mjs +0 -32
  39. package/scripts/health-repair.mjs +1 -81
  40. package/scripts/hook-qualify-core.mjs +2 -2
  41. package/scripts/model-managed-workflow-service.mjs +9 -10
  42. package/scripts/model-routing-controller.mjs +9 -8
  43. package/scripts/no-silent-substitution.mjs +65 -1
  44. package/scripts/onboarding-console.mjs +8 -23
  45. package/scripts/product-integrity-contract.mjs +1 -1
  46. package/scripts/release-qualification-contract.mjs +9 -27
  47. package/scripts/remedy-registry.mjs +0 -18
  48. package/scripts/wired-check.mjs +1 -5
  49. package/plugin/scripts/continuity-brief.mjs +0 -351
  50. package/plugin/scripts/continuity-journal.mjs +0 -553
  51. package/plugin/scripts/learn-flush.mjs +0 -96
  52. package/plugin/scripts/learning-observation.mjs +0 -51
  53. package/plugin/scripts/learning-queue.mjs +0 -135
  54. package/plugin/scripts/learning-worker-supervisor.mjs +0 -79
  55. package/plugin/scripts/project-capture-queue.mjs +0 -357
  56. package/plugin/scripts/project-memory-enrollment.mjs +0 -203
  57. package/plugin/scripts/project-progression-checkpoint.mjs +0 -145
  58. package/plugin/scripts/project-progression-contract.mjs +0 -496
  59. package/plugin/scripts/project-progression-hook.mjs +0 -233
  60. package/plugin/scripts/project-progression-outbox.mjs +0 -225
  61. package/plugin/scripts/project-progression-producer.mjs +0 -309
  62. package/plugin/scripts/project-progression-reader.mjs +0 -304
  63. package/plugin/scripts/project-progression-session-start.mjs +0 -320
  64. package/plugin/scripts/project-progression-sources.mjs +0 -246
  65. package/plugin/scripts/project-progression-store.mjs +0 -723
  66. package/plugin/scripts/project-progression-suspension.mjs +0 -60
  67. package/plugin/scripts/project-transition-hook.mjs +0 -267
  68. package/plugin/scripts/session-snapshot-contract.mjs +0 -105
  69. package/plugin/scripts/turn-transport-journal.mjs +0 -108
  70. package/scripts/qa/progression-validation-benchmark.mjs +0 -66
  71. package/scripts/session-snapshot-contract.mjs +0 -1
@@ -1,203 +0,0 @@
1
- /** Consent-gated lazy enrollment. Hooks queue derived events before a detached native bootstrap. */
2
- import fs from 'node:fs';
3
- import os from 'node:os';
4
- import path from 'node:path';
5
- import crypto from 'node:crypto';
6
- import { spawn, spawnSync } from 'node:child_process';
7
- import { fileURLToPath } from 'node:url';
8
- import { resolveProjectStore, globalEnrollmentAllowed } from './project-store-resolver.mjs';
9
- import { resolveTurnDb, turnCapturePolicyFile } from './turn-outcome-capture.mjs';
10
- import { resolveRuflo, rufloInvocation } from './ruflo-bin.mjs';
11
- import { redactText } from './continuity-events.mjs';
12
- import { enrichStateWithObservation } from './project-progression-hook.mjs';
13
- import { redactProgression } from './project-progression-contract.mjs';
14
- import { normalizeHostEvent } from './hook-input.mjs';
15
- import { maskExcludedPaths, privateProgressionState } from './turn-capture-privacy.mjs';
16
-
17
- const canonical = (value) => { try { return fs.realpathSync.native(value); } catch { return path.resolve(value); } };
18
- export const ENROLLMENT_NAMESPACE = 'project-enrollment';
19
- const receiptName = '.memory-enrollment.json';
20
- const queueName = '.memory-enrollment-pending';
21
-
22
- export function enrollmentPlan({ projectDir, env = process.env, deadlineAt = Infinity,
23
- temporaryRoots = [os.tmpdir(), '/tmp', '/private/tmp'], systemRoots = ['/System', '/Library', '/usr', '/etc', '/var', '/private/var', '/proc', '/sys', '/dev'] } = {}) {
24
- const home = canonical(env.HOME || env.USERPROFILE || os.homedir());
25
- const brainHome = canonical(env.RUVNET_BRAIN_HOME || path.join(home, '.cache', 'ruvnet-brain'));
26
- const resolution = resolveProjectStore({ projectDir, gitTimeoutMs: 500, deadlineAt });
27
- // The consent guard and the plan must agree on which roots are excluded from default enrollment.
28
- const consent = resolveTurnDb({ projectDir, brainHome, gitTimeoutMs: 500, deadlineAt, enrollmentRoots: { temporaryRoots, systemRoots } });
29
- if (consent.skipped) return { ...resolution, state: 'disabled', reason: consent.skipped };
30
- let policy = {};
31
- try { policy = JSON.parse(fs.readFileSync(turnCapturePolicyFile(brainHome), 'utf8')); } catch { /* resolveTurnDb validates any present policy */ }
32
- const explicit = policy.paths?.[canonical(projectDir)] ?? policy.projects?.[resolution.projectRoot];
33
- const hasDb = fs.existsSync(resolution.canonicalAgentDbPath);
34
- // RUVNET_TURN_CAPTURE=off means turn capture is off: it never creates a store. (It does not suspend progression or
35
- // continuity capture on an existing store; queueBoundary additionally withholds the assistant text while it is off.)
36
- if (!hasDb && String(env.RUVNET_TURN_CAPTURE || '').toLowerCase() === 'off') return { ...resolution, state: 'disabled', reason: 'RUVNET_TURN_CAPTURE=off' };
37
- if (!hasDb && explicit !== 'on') {
38
- if (resolution.kind !== 'git') return { ...resolution, state: 'disabled', reason: 'non-Git enrollment requires explicit project opt-in' };
39
- if (!globalEnrollmentAllowed({ resolution, home, brainHome, temporaryRoots, systemRoots })) {
40
- return { ...resolution, state: 'disabled', reason: 'global enrollment excludes home, temporary, system and Brain cache roots' };
41
- }
42
- }
43
- let enrollmentPending = false;
44
- const receipt = path.join(resolution.projectRoot, '.swarm', receiptName);
45
- if (fs.existsSync(receipt)) {
46
- const stat = fs.lstatSync(receipt);
47
- if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) throw new Error('unsafe enrollment receipt');
48
- // An unparsable receipt is a torn write from a killed hook: it means "not ready", never "unavailable forever".
49
- try { enrollmentPending = JSON.parse(fs.readFileSync(receipt, 'utf8')).state === 'pending'; } catch { enrollmentPending = true; }
50
- }
51
- return { ...resolution, state: hasDb && !enrollmentPending ? 'existing' : 'pending', brainHome, contentPathExcludes: consent.contentPathExcludes || [] };
52
- }
53
-
54
- /** Create-if-absent. A hook killed mid-write leaves a torn FINAL file, which every reader tolerates (an unparsable
55
- * receipt means pending; an unparsable queue entry is preserved as .corrupt). No hard-link or rename dance: a link
56
- * window leaves nlink 2 on a killed hook, which the readers' nlink check would then reject forever. */
57
- function createExclusive(file, text) {
58
- let fd;
59
- try { fd = fs.openSync(file, 'wx', 0o600); } catch (error) { if (error.code === 'EEXIST') return; throw error; }
60
- try { fs.writeFileSync(fd, text); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
61
- }
62
-
63
- function safeDirectory(directory) {
64
- fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
65
- const stat = fs.lstatSync(directory);
66
- if (!stat.isDirectory() || stat.isSymbolicLink() || canonical(directory) !== directory) throw new Error('enrollment directory is not canonical');
67
- }
68
-
69
- /** Only a small derived outcome is queued; never prompts, transcripts, tool input or environment. */
70
- function queueBoundary(plan, { event, payload, host, projectDir, env = process.env }) {
71
- if (!event || typeof payload?.session_id !== 'string' || !payload.session_id) return false;
72
- const directory = path.join(plan.projectRoot, '.swarm', queueName);
73
- safeDirectory(directory);
74
- const turnCaptureOff = String(env.RUVNET_TURN_CAPTURE || '').toLowerCase() === 'off';
75
- const text = turnCaptureOff ? '' : String(payload.last_assistant_message || '');
76
- const filtered = maskExcludedPaths(text, plan.contentPathExcludes, projectDir);
77
- const last = filtered === text ? redactText(text).slice(0, 12000) : '[REDACTED:excluded-resource-outcome]';
78
- const normalized = normalizeHostEvent(payload) || payload;
79
- const observed = enrichStateWithObservation({ commands: [] }, { ...normalized, hook_event_name: event },
80
- { contentPathExcludes: plan.contentPathExcludes, projectDir }).commands[0];
81
- const tool = observed ? { tool_name: redactText(observed.tool), tool_input: {
82
- ...(observed.command ? { command: redactText(observed.command) } : {}),
83
- ...(observed.filePath ? { file_path: redactText(observed.filePath) } : {}) },
84
- tool_response: JSON.parse(redactText(JSON.stringify(Object.fromEntries(Object.entries(observed)
85
- .filter(([key]) => ['outcome', 'exitCode', 'error', 'signal', 'interrupted', 'isError', 'stdout', 'stderr', 'result'].includes(key)))))) } : {};
86
- let strategic = {};
87
- if (payload.projectProgression || payload.project_progression) {
88
- const extension = redactProgression(payload.projectProgression || payload.project_progression).value;
89
- extension.completeProjectState = privateProgressionState(extension.completeProjectState, plan.contentPathExcludes, projectDir);
90
- strategic = { projectProgression: extension };
91
- }
92
- const boundary = { projectDir: canonical(projectDir), event, host,
93
- payload: { session_id: payload.session_id, hook_event_name: event, ...tool, ...strategic, ...(last ? { last_assistant_message: last } : {}) } };
94
- const digest = crypto.createHash('sha256').update(JSON.stringify(boundary)).digest('hex');
95
- const file = path.join(directory, `${digest}.json`);
96
- createExclusive(file, JSON.stringify(boundary));
97
- const dirFd = fs.openSync(directory, 'r'); try { fs.fsyncSync(dirFd); } finally { fs.closeSync(dirFd); }
98
- return true;
99
- }
100
-
101
- export function ensureProjectMemory({ projectDir, env = process.env, event, payload, host = env.RUVNET_HOOK_HOST || 'claude', deadlineAt = Infinity,
102
- launch = launchEnrollment, planOptions = {} } = {}) {
103
- try {
104
- const plan = enrollmentPlan({ ...planOptions, projectDir, env, deadlineAt });
105
- if (plan.state === 'disabled') return plan;
106
- if (plan.state === 'existing') {
107
- const queue = path.join(plan.projectRoot, '.swarm', queueName);
108
- if (!fs.existsSync(queue) || !fs.readdirSync(queue).some((name) => /^[a-f0-9]{64}\.json$/.test(name))) return plan;
109
- }
110
- safeDirectory(path.join(plan.projectRoot, '.swarm'));
111
- if (plan.state === 'pending') {
112
- const receipt = path.join(plan.projectRoot, '.swarm', receiptName);
113
- createExclusive(receipt, JSON.stringify({ schemaVersion: 1, state: 'pending', canonicalAgentDbPath: plan.canonicalAgentDbPath }));
114
- }
115
- const queued = plan.state === 'pending' && queueBoundary(plan, { event, payload, host, projectDir, env });
116
- const launched = launch({ projectDir, env });
117
- return { ...plan, state: plan.state === 'existing' ? 'existing' : 'pending', queued, launched };
118
- } catch (error) { return { state: 'unavailable', reason: error.message }; }
119
- }
120
-
121
- export function launchEnrollment({ projectDir, env }) {
122
- const child = spawn(process.execPath, [fileURLToPath(import.meta.url), '--enroll', canonical(projectDir)], {
123
- cwd: os.tmpdir(), env: { ...env, RUFLO_DAEMON_AUTOSTART: '0', CLAUDE_FLOW_MEMORY_PATH: undefined }, detached: true, windowsHide: true, stdio: 'ignore',
124
- });
125
- child.unref();
126
- return Boolean(child.pid);
127
- }
128
-
129
- /** Exact native append and same-path readback; existing stores are never initialized or replaced. */
130
- export async function enrollProjectMemory({ projectDir, env = process.env, run = spawnSync, replay } = {}) {
131
- const plan = enrollmentPlan({ projectDir, env });
132
- if (plan.state === 'disabled') return plan;
133
- const swarm = path.join(plan.projectRoot, '.swarm');
134
- safeDirectory(swarm);
135
- const lock = path.join(swarm, '.memory-enrollment.lock');
136
- const token = `${process.pid} ${crypto.randomUUID()}`;
137
- try { fs.writeFileSync(lock, token, { flag: 'wx', mode: 0o600 }); } catch (error) {
138
- if (error.code !== 'EEXIST') throw error;
139
- const stat = fs.lstatSync(lock);
140
- if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) return { ...plan, state: 'pending', reason: 'unsafe enrollment lock' };
141
- const previous = fs.readFileSync(lock, 'utf8');
142
- const pid = Number(previous.split(' ')[0]);
143
- let dead = false;
144
- if (Number.isSafeInteger(pid) && pid > 0) try { process.kill(pid, 0); } catch (failure) { dead = failure.code === 'ESRCH'; }
145
- if (!dead || fs.readFileSync(lock, 'utf8') !== previous) return { ...plan, state: 'pending', reason: 'enrollment worker busy' };
146
- fs.unlinkSync(lock);
147
- try { fs.writeFileSync(lock, token, { flag: 'wx', mode: 0o600 }); } catch { return { ...plan, state: 'pending', reason: 'enrollment worker busy' }; }
148
- }
149
- const workerDeadlineAt = Date.now() + 60000;
150
- const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'rnb-enrollment-worker-'));
151
- try {
152
- const binary = resolveRuflo({ env, home: env.HOME || os.homedir() });
153
- if (!binary) return { ...plan, state: 'pending', reason: 'global Ruflo unavailable' };
154
- const key = `project-bootstrap-${plan.projectIdentity.id}`;
155
- const value = JSON.stringify({ schemaVersion: 1, projectIdentity: plan.projectIdentity });
156
- const invoke = (args) => { const call = rufloInvocation(binary, args); return run(call.executable, call.args,
157
- { cwd: scratch, env: { ...env, RUFLO_DAEMON_AUTOSTART: '0', CLAUDE_FLOW_MEMORY_PATH: undefined }, encoding: 'utf8', timeout: Math.max(1, Math.min(30000, workerDeadlineAt - Date.now())), maxBuffer: 256 * 1024, shell: false }); };
158
- if (plan.state !== 'existing') {
159
- invoke(['memory', 'store', '--key', key, '--value', value, '--namespace', ENROLLMENT_NAMESPACE,
160
- '--path', plan.canonicalAgentDbPath, '--require-native', '--append-only', '--no-upsert', '--no-embedding']);
161
- // Concurrent workers may lose the strict insertion race; exact readback still establishes readiness.
162
- const result = invoke(['memory', 'retrieve', '--key', key, '--namespace', ENROLLMENT_NAMESPACE,
163
- '--path', plan.canonicalAgentDbPath, '--value-only']);
164
- if (result.status !== 0 || String(result.stdout || '').trim() !== value) return { ...plan, state: 'pending', reason: 'native bootstrap readback unavailable' };
165
- const receipt = path.join(swarm, receiptName);
166
- const temporary = `${receipt}.${crypto.randomUUID()}.tmp`;
167
- fs.writeFileSync(temporary, JSON.stringify({ schemaVersion: 1, state: 'ready', key,
168
- canonicalAgentDbPath: plan.canonicalAgentDbPath, verifiedAt: new Date().toISOString() }), { flag: 'wx', mode: 0o600 });
169
- fs.renameSync(temporary, receipt);
170
- }
171
- const queue = path.join(swarm, queueName);
172
- if (fs.existsSync(queue)) {
173
- safeDirectory(queue);
174
- const capture = replay || (await import('./session-snapshot-hook.mjs')).runSessionSnapshotHook;
175
- for (const name of fs.readdirSync(queue).filter((name) => /^[a-f0-9]{64}\.json$/.test(name)).slice(0, 32)) {
176
- if (Date.now() > workerDeadlineAt - 8000) break;
177
- const file = path.join(queue, name);
178
- const stat = fs.lstatSync(file);
179
- if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.size > 256000) continue;
180
- let boundary;
181
- try { boundary = JSON.parse(fs.readFileSync(file, 'utf8')); } catch {
182
- // Preserve the evidence, stop it blocking every later replay.
183
- try { fs.renameSync(file, `${file}.corrupt`); } catch { /* retry next boundary */ }
184
- continue;
185
- }
186
- // Original path privacy is rechecked, never replaced by the primary worktree identity.
187
- const consent = enrollmentPlan({ projectDir: boundary.projectDir, env });
188
- if (consent.state === 'disabled' || consent.canonicalAgentDbPath !== plan.canonicalAgentDbPath) continue;
189
- const result = capture(boundary.projectDir, boundary.event, { rawInput: JSON.stringify(boundary.payload), host: boundary.host,
190
- env, enrollMemory: () => ({ state: 'existing' }) });
191
- if (result?.turn?.queued || result?.turn?.recorded || result?.continuity?.recorded > 0 || result?.progressionCaptured || result?.deferredToReplayer) fs.unlinkSync(file);
192
- }
193
- }
194
- return { ...plan, state: 'ready' };
195
- } finally {
196
- fs.rmSync(scratch, { recursive: true, force: true });
197
- try { if (fs.readFileSync(lock, 'utf8') === token) fs.unlinkSync(lock); } catch { /* ownership changed */ }
198
- }
199
- }
200
-
201
- if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) && process.argv[2] === '--enroll') {
202
- enrollProjectMemory({ projectDir: process.argv[3] }).catch(() => { /* pending queue survives; retry at the next boundary */ });
203
- }
@@ -1,145 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * project-progression-checkpoint.mjs — the EXPLICIT capture boundary behind /ruvnet-brain:checkpoint.
4
- *
5
- * The automatic boundaries (Stop, PreCompact, SessionEnd) capture what can be READ from the machine:
6
- * git, the work ledger, the owner's note, a bounded transcript reference. They cannot read what only
7
- * the model knows — the acceptance contract it is working to, the decision it just made and why, the
8
- * exact next action. This entry point lets the model hand that over, once, deliberately.
9
- *
10
- * IT IS NOT A SECOND WRITER. Everything still goes through the producer and
11
- * captureProjectTransition, so the snapshot is validated, redacted, fsynced to the outbox, written
12
- * by `ruflo memory store`, and read back by its exact key before this prints a receipt. The model's
13
- * `--json` contributes FIELDS; it never contributes a write path.
14
- *
15
- * The supplied fields are merged OVER the produced ones, and each supplied field is recorded in
16
- * `provenance` as `model-checkpoint` with `authoritative: false`. A model asserting its own goal is
17
- * a useful record and is not the same kind of fact as a line the user wrote in their ledger, and the
18
- * snapshot must not lose that distinction on the way in.
19
- *
20
- * Usage:
21
- * node project-progression-checkpoint.mjs --json '<completeProjectState fragment>' [--session <id>]
22
- * node project-progression-checkpoint.mjs --json-file <path> [--project-dir <dir>]
23
- */
24
- import fs from 'node:fs';
25
- import path from 'node:path';
26
- import { fileURLToPath } from 'node:url';
27
- import { captureProjectTransition } from './project-progression-hook.mjs';
28
- import { buildProjectProgression } from './project-progression-producer.mjs';
29
- import { ProjectProgressionStore } from './project-progression-store.mjs';
30
- import { resolveProjectStore } from './project-store-resolver.mjs';
31
- import { projectDirectory } from './project-identity.mjs';
32
-
33
- /** Fields a checkpoint may contribute. Anything else is ignored rather than silently stored. */
34
- export const CHECKPOINT_FIELDS = Object.freeze([
35
- 'currentGoal', 'acceptanceContract', 'nextAction', 'activeStep',
36
- 'decisions', 'blockers', 'failures', 'completed', 'inProgress', 'plan',
37
- 'changedFiles', 'commands', 'proofArtifacts', 'untested',
38
- ]);
39
-
40
- const ARRAY_FIELDS = new Set([
41
- 'decisions', 'blockers', 'failures', 'completed', 'inProgress', 'plan',
42
- 'changedFiles', 'commands', 'proofArtifacts', 'untested',
43
- ]);
44
-
45
- export function parseArgs(argv) {
46
- const out = {};
47
- for (let index = 0; index < argv.length; index += 1) {
48
- const flag = argv[index];
49
- if (!flag.startsWith('--')) continue;
50
- out[flag.slice(2)] = argv[index + 1]?.startsWith('--') ? true : argv[index + 1];
51
- }
52
- return out;
53
- }
54
-
55
- export function readCheckpointState({ json, 'json-file': jsonFile } = {}) {
56
- let text = json;
57
- if (typeof jsonFile === 'string' && jsonFile) text = fs.readFileSync(jsonFile, 'utf8');
58
- if (typeof text !== 'string' || !text.trim()) throw new Error('a checkpoint needs --json or --json-file');
59
- let parsed;
60
- try { parsed = JSON.parse(text); } catch { throw new Error('checkpoint state is not JSON'); }
61
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('checkpoint state must be an object');
62
- const state = {};
63
- for (const field of CHECKPOINT_FIELDS) {
64
- if (!Object.prototype.hasOwnProperty.call(parsed, field)) continue;
65
- if (ARRAY_FIELDS.has(field) && !Array.isArray(parsed[field])) throw new Error(`checkpoint ${field} must be an array`);
66
- state[field] = parsed[field];
67
- }
68
- if (Object.keys(state).length === 0) {
69
- throw new Error(`a checkpoint must set at least one of: ${CHECKPOINT_FIELDS.join(', ')}`);
70
- }
71
- return state;
72
- }
73
-
74
- export function runCheckpoint({
75
- projectDir = projectDirectory(),
76
- state,
77
- host = process.env.RUVNET_HOOK_HOST || 'claude',
78
- sessionId = process.env.CLAUDE_SESSION_ID || `checkpoint-${Date.now()}`,
79
- produce = buildProjectProgression,
80
- capture = captureProjectTransition,
81
- storeFactory,
82
- } = {}) {
83
- const resolution = resolveProjectStore({ projectDir });
84
- if (!fs.existsSync(path.dirname(resolution.canonicalAgentDbPath))) {
85
- throw new Error(`this project has not adopted the canonical store (${resolution.canonicalAgentDbPath})`);
86
- }
87
- const payload = { session_id: sessionId, hook_event_name: 'checkpoint' };
88
- const produced = produce({ resolution, payload, host, trigger: 'checkpoint' });
89
-
90
- // Commit any durable-but-uncommitted snapshot first, so an explicit checkpoint also settles the
91
- // debt SessionStart is forbidden from settling. Same ordering as the automatic boundaries.
92
- const store = (storeFactory ?? ((options) => new ProjectProgressionStore(options)))({
93
- projectDir, requestedStorePath: resolution.canonicalAgentDbPath,
94
- });
95
- let replayed = 0;
96
- try { replayed = store.replay().length; } catch { /* the checkpoint itself is still worth writing */ }
97
-
98
- const provenance = { ...produced.projectProgression.completeProjectState.provenance };
99
- for (const field of Object.keys(state)) provenance[field] = { source: 'model-checkpoint', authoritative: false };
100
-
101
- const result = capture({
102
- host,
103
- projectDir,
104
- storeFactory,
105
- payload: {
106
- ...payload,
107
- projectProgression: {
108
- ...produced.projectProgression,
109
- completeProjectState: {
110
- ...produced.projectProgression.completeProjectState,
111
- ...state,
112
- provenance,
113
- },
114
- },
115
- },
116
- });
117
- return { receipt: result.receipt, replayed, provenance, sequence: result.snapshot.sequence };
118
- }
119
-
120
- if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
121
- const args = parseArgs(process.argv.slice(2));
122
- try {
123
- const outcome = runCheckpoint({
124
- state: readCheckpointState(args),
125
- ...(typeof args['project-dir'] === 'string' ? { projectDir: args['project-dir'] } : {}),
126
- ...(typeof args.session === 'string' ? { sessionId: args.session } : {}),
127
- });
128
- // THE RECEIPT IS THE POINT. It names the exact key, and it says the row was read BACK by that
129
- // key and matched — which is the only evidence that distinguishes a checkpoint from a claim.
130
- console.log(JSON.stringify({
131
- checkpoint: 'stored',
132
- eventKey: outcome.receipt.eventKey,
133
- sequence: outcome.sequence,
134
- payloadDigest: outcome.receipt.payloadDigest,
135
- readbackDigest: outcome.receipt.readbackDigest,
136
- readbackVerified: outcome.receipt.readbackDigest === outcome.receipt.payloadDigest,
137
- alreadyStored: outcome.receipt.alreadyStored,
138
- replayedPending: outcome.replayed,
139
- committedAt: outcome.receipt.committedAt,
140
- }, null, 2));
141
- } catch (error) {
142
- console.error(`[checkpoint] ${error.message}`);
143
- process.exitCode = 1;
144
- }
145
- }