ruvnet-brain 4.6.3 → 4.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/bin/install.mjs +6 -50
- package/config/practical-rule-catalog.json +22 -22
- package/console/app.js +1 -1
- package/console/architecture.html +2 -2
- package/package.json +2 -2
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/commands/checkpoint.md +22 -40
- package/plugin/commands/rnb-brief.md +18 -15
- package/plugin/hooks/codex-hooks.json +1 -58
- package/plugin/hooks/hook-contracts.json +19 -220
- package/plugin/hooks/hooks.json +1 -70
- package/plugin/mcp/managed-cli-interface.mjs +0 -56
- package/plugin/scripts/agentdb-recall-reader.mjs +29 -9
- package/plugin/scripts/agentdb-recall.mjs +27 -21
- package/plugin/scripts/brain-footprint.mjs +1 -1
- package/plugin/scripts/codex-hook-wrapper.mjs +13 -3
- package/plugin/scripts/completion-claim-evidence.mjs +1 -1
- package/plugin/scripts/continuation-gate.mjs +78 -1
- package/plugin/scripts/continuity-events.mjs +90 -82
- package/plugin/scripts/continuity-hook-policy.mjs +5 -14
- package/plugin/scripts/decision-gate.mjs +3 -3
- package/plugin/scripts/decision-recall-gate.mjs +141 -0
- package/plugin/scripts/decision-record.mjs +324 -0
- package/plugin/scripts/deferral-evidence.mjs +134 -0
- package/plugin/scripts/hook-failures.mjs +61 -0
- package/plugin/scripts/hook-shim.mjs +71 -6
- package/plugin/scripts/learn-capture.mjs +88 -37
- 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 +132 -0
- package/plugin/scripts/runtime-preferences.mjs +1 -1
- package/plugin/scripts/session-snapshot-hook.mjs +85 -332
- package/plugin/scripts/session-start-core.mjs +7 -9
- package/plugin/scripts/signal-watch.mjs +1 -1
- package/plugin/scripts/substitution-wall.mjs +222 -0
- package/plugin/scripts/turn-outcome-capture.mjs +74 -88
- package/scripts/codex-host-execution-proof.mjs +1 -1
- package/scripts/console-engine.mjs +0 -32
- package/scripts/deferral-replay.mjs +47 -0
- 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-routing-controller.mjs +9 -8
- 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 +9 -27
- package/scripts/remedy-registry.mjs +0 -18
- package/scripts/wired-check.mjs +1 -5
- package/plugin/scripts/continuity-brief.mjs +0 -357
- 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 -246
- 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,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
|
-
}
|