ruvnet-brain 4.5.2 → 4.5.4
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 +2 -2
- package/bin/install.mjs +9 -4
- package/kb/lifecycle-evidence-retention.mjs +56 -2
- package/kb/verify-citation.mjs +48 -27
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/hooks/codex-hooks.json +40 -3
- package/plugin/hooks/hook-contracts.json +218 -19
- package/plugin/hooks/hooks.json +51 -2
- package/plugin/scripts/agentdb-recall.mjs +297 -0
- package/plugin/scripts/continuity-hook-policy.mjs +9 -0
- package/plugin/scripts/continuity-journal.mjs +51 -52
- package/plugin/scripts/ground-ruvnet.sh +17 -3
- package/plugin/scripts/hook-shim.mjs +4 -30
- package/plugin/scripts/project-capture-queue.mjs +333 -0
- package/plugin/scripts/project-progression-contract.mjs +1 -1
- package/plugin/scripts/project-progression-hook.mjs +3 -3
- package/plugin/scripts/project-progression-outbox.mjs +19 -4
- package/plugin/scripts/project-progression-producer.mjs +53 -28
- package/plugin/scripts/project-progression-session-start.mjs +44 -4
- package/plugin/scripts/project-progression-store.mjs +14 -0
- package/plugin/scripts/project-store-resolver.mjs +16 -7
- package/plugin/scripts/project-transition-hook.mjs +204 -0
- package/plugin/scripts/session-snapshot-hook.mjs +46 -274
- package/plugin/scripts/session-start-budget.mjs +2 -2
- package/plugin/scripts/session-start-core.mjs +5 -0
- package/plugin/scripts/turn-outcome-capture.mjs +246 -70
- package/plugin/scripts/turn-transport-journal.mjs +106 -0
- package/scripts/learning-replay-cli.mjs +15 -0
- package/scripts/learning-replay.mjs +1 -1
- package/scripts/nightly-wrapper.sh +5 -5
- package/scripts/release-qualification-contract.mjs +87 -0
- package/scripts/release-qualification.mjs +10 -1
- package/scripts/wired-check.mjs +1 -0
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
3
4
|
import { spawnSync } from 'node:child_process';
|
|
4
5
|
import { ProjectProgressionStore } from './project-progression-store.mjs';
|
|
5
6
|
import { resolveProjectStore } from './project-store-resolver.mjs';
|
|
6
7
|
import { withProgressionReader } from './project-progression-reader.mjs';
|
|
8
|
+
import { drainCaptureQueue, queuedWork } from './session-snapshot-hook.mjs';
|
|
9
|
+
import { replayTurnQueue } from './turn-outcome-capture.mjs';
|
|
7
10
|
import { STAGE_BUDGETS_MS } from './session-start-budget.mjs';
|
|
8
11
|
|
|
9
12
|
const PROGRESSION_NAMESPACE = 'project-progression';
|
|
@@ -219,20 +222,57 @@ export function restoreProgressionForSession({
|
|
|
219
222
|
};
|
|
220
223
|
const makeStore = storeFactory ?? ((options) => new ProjectProgressionStore({
|
|
221
224
|
...options,
|
|
225
|
+
env,
|
|
222
226
|
runner: boundedRunner,
|
|
223
227
|
}));
|
|
224
228
|
store = makeStore({
|
|
225
229
|
projectDir,
|
|
226
230
|
requestedStorePath: resolution.canonicalAgentDbPath,
|
|
227
231
|
});
|
|
232
|
+
const home = env.HOME || env.USERPROFILE || os.homedir();
|
|
233
|
+
const turns = replayTurnQueue({ projectDir, env, home, synchronous: true, runner: boundedRunner, deadlineMs: deadlineAt });
|
|
234
|
+
if (turns.pending > 0 || turns.failed > 0) {
|
|
235
|
+
const failed = miss('outbox-replay');
|
|
236
|
+
return { ...failed, pendingTurns: turns.pending,
|
|
237
|
+
context: `${failed.context} Durable turn recording remains pending; no older checkpoint was injected.` };
|
|
238
|
+
}
|
|
239
|
+
const suspended = ['persisted turn capture opt-out', 'turn capture policy unreadable or invalid'].includes(turns.skipped);
|
|
240
|
+
if (turns.skipped && !suspended && !['RUVNET_TURN_CAPTURE=off',
|
|
241
|
+
'no project memory db; persisted opt-in required'].includes(turns.skipped)) return miss('outbox-replay');
|
|
242
|
+
if (suspended && initializing) return {
|
|
243
|
+
status: 'unavailable', reason: 'capture-suspended', severity: 'info',
|
|
244
|
+
context: '[RuvNet Brain — PROJECT CONTINUITY UNAVAILABLE]\nCapture consent suspends replay; no canonical store was created.',
|
|
245
|
+
};
|
|
246
|
+
if (suspended && (store.pendingReplayCount?.() > 0 || queuedWork(resolution.projectRoot) > 0)) {
|
|
247
|
+
const failed = miss('outbox-replay');
|
|
248
|
+
return { ...failed, context: `${failed.context} Replay is suspended by capture consent; no older checkpoint was injected.` };
|
|
249
|
+
}
|
|
228
250
|
if (initializing) {
|
|
229
251
|
fs.mkdirSync(path.dirname(resolution.canonicalAgentDbPath), { recursive: true, mode: 0o700 });
|
|
230
252
|
initializeCanonicalStore(store, resolution);
|
|
231
253
|
}
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
254
|
+
if (!suspended) {
|
|
255
|
+
const queue = drainCaptureQueue({ projectDir, budgetMs: Math.max(0, deadlineAt - Date.now()),
|
|
256
|
+
makeStoreFactory: () => () => store });
|
|
257
|
+
if (queue.state !== 'settled') {
|
|
258
|
+
const failed = miss('outbox-replay');
|
|
259
|
+
return { ...failed, pendingReplay: queue.pending,
|
|
260
|
+
context: `${failed.context} Durable capture work remains pending; no older checkpoint was injected.` };
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
// A pending snapshot is newer observable work. Never label an older committed head restored
|
|
264
|
+
// while that work remains unverified. Replay uses the same managed exact-readback store path.
|
|
265
|
+
let restored;
|
|
266
|
+
try {
|
|
267
|
+
restored = store.restoreLatest({ maxOutputBytes: payloadLimit, replayPending: !suspended, projectToBound: true });
|
|
268
|
+
} catch (error) {
|
|
269
|
+
if (store.pendingReplayCount?.() > 0) {
|
|
270
|
+
const failed = miss('outbox-replay');
|
|
271
|
+
return { ...failed, pendingReplay: store.pendingReplayCount(),
|
|
272
|
+
context: `${failed.context} ${store.pendingReplayCount()} durable snapshot(s) remain pending; no older checkpoint was injected.` };
|
|
273
|
+
}
|
|
274
|
+
throw error;
|
|
275
|
+
}
|
|
236
276
|
if (!validResume(restored)) return miss('malformed-store');
|
|
237
277
|
const summaryNotice = restored.projected
|
|
238
278
|
? '\n[BOUNDED CONTINUITY SUMMARY] The merged current goal and next action are preserved exactly; '
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
restoreProjectProgression,
|
|
10
10
|
validateProgressionSnapshot,
|
|
11
11
|
} from './project-progression-contract.mjs';
|
|
12
|
+
import { resolveTurnDb } from './turn-outcome-capture.mjs';
|
|
12
13
|
import { resolveProjectStore } from './project-store-resolver.mjs';
|
|
13
14
|
import { withProgressionReader } from './project-progression-reader.mjs';
|
|
14
15
|
import { resolveRuflo, rufloInvocation, RUFLO_MISSING } from './ruflo-bin.mjs';
|
|
@@ -323,6 +324,8 @@ export class ProjectProgressionStore {
|
|
|
323
324
|
constructor({
|
|
324
325
|
projectDir,
|
|
325
326
|
requestedStorePath,
|
|
327
|
+
env = process.env,
|
|
328
|
+
brainHome = env.RUVNET_BRAIN_HOME || path.join(env.HOME || os.homedir(), '.cache', 'ruvnet-brain'),
|
|
326
329
|
rufloBinary = resolveRuflo(),
|
|
327
330
|
runner = defaultRunner,
|
|
328
331
|
clock = () => new Date().toISOString(),
|
|
@@ -336,6 +339,7 @@ export class ProjectProgressionStore {
|
|
|
336
339
|
// Best effort: a cleanup that cannot run must never stop a capture or a restore.
|
|
337
340
|
try { this.legacyDebris = cleanLegacyRufloDebris(path.dirname(this.resolution.canonicalAgentDbPath)); }
|
|
338
341
|
catch (error) { this.legacyDebris = { removed: [], refused: [{ path: null, reason: error.message }] }; }
|
|
342
|
+
this.brainHome = brainHome;
|
|
339
343
|
this.rufloBinary = rufloBinary;
|
|
340
344
|
this.runner = runner;
|
|
341
345
|
this.clock = clock;
|
|
@@ -374,8 +378,17 @@ export class ProjectProgressionStore {
|
|
|
374
378
|
if (!verdict.ok) throw new Error(`invalid progression snapshot: ${verdict.errors.join(', ')}`);
|
|
375
379
|
}
|
|
376
380
|
|
|
381
|
+
requireCaptureConsent(snapshot) {
|
|
382
|
+
const capturePath = snapshot.sourceIdentity.capturePath;
|
|
383
|
+
const target = resolveTurnDb({ projectDir: capturePath ?? snapshot.sourceIdentity.checkoutPath,
|
|
384
|
+
brainHome: this.brainHome, requestedStorePath: this.resolution.canonicalAgentDbPath,
|
|
385
|
+
unknownOriginalPath: !capturePath });
|
|
386
|
+
if (target.skipped) throw new Error(`progression capture suspended: ${target.skipped}`);
|
|
387
|
+
}
|
|
388
|
+
|
|
377
389
|
appendExact(snapshot, { onPhase = () => {} } = {}) {
|
|
378
390
|
this.validateSnapshot(snapshot);
|
|
391
|
+
this.requireCaptureConsent(snapshot);
|
|
379
392
|
const stored = this.run([
|
|
380
393
|
'memory', 'store', '--key', snapshot.eventKey, '--value', JSON.stringify(snapshot),
|
|
381
394
|
'--namespace', PROGRESSION_NAMESPACE, '--no-upsert', '--provenance', 'system_observation',
|
|
@@ -429,6 +442,7 @@ export class ProjectProgressionStore {
|
|
|
429
442
|
|
|
430
443
|
capture(snapshot, { onPhase = () => {} } = {}) {
|
|
431
444
|
this.validateSnapshot(snapshot);
|
|
445
|
+
this.requireCaptureConsent(snapshot);
|
|
432
446
|
this.outbox.appendSnapshot(snapshot);
|
|
433
447
|
onPhase('outbox-fsynced');
|
|
434
448
|
const receipt = this.appendExact(snapshot, { onPhase });
|
|
@@ -40,21 +40,24 @@ function isWithin(root, candidate) {
|
|
|
40
40
|
return relative === '' || (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith(`..${path.sep}`));
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
function gitValue(cwd, args) {
|
|
43
|
+
function gitValue(cwd, args, gitTimeoutMs) {
|
|
44
44
|
try {
|
|
45
45
|
return execFileSync('git', args, {
|
|
46
46
|
cwd,
|
|
47
47
|
encoding: 'utf8',
|
|
48
48
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
49
|
+
...(gitTimeoutMs ? { timeout: gitTimeoutMs, killSignal: 'SIGKILL' } : {}),
|
|
49
50
|
}).trim();
|
|
50
|
-
} catch {
|
|
51
|
+
} catch (error) {
|
|
52
|
+
// A timed-out Git identity check is not evidence for a non-git project.
|
|
53
|
+
if (error.code === 'ETIMEDOUT') throw new Error('Git project identity timed out');
|
|
51
54
|
return null;
|
|
52
55
|
}
|
|
53
56
|
}
|
|
54
57
|
|
|
55
|
-
function gitProject(projectDir) {
|
|
56
|
-
const commonValue = gitValue(projectDir, ['rev-parse', '--path-format=absolute', '--git-common-dir']);
|
|
57
|
-
const checkoutValue = gitValue(projectDir, ['rev-parse', '--show-toplevel']);
|
|
58
|
+
function gitProject(projectDir, gitTimeoutMs) {
|
|
59
|
+
const commonValue = gitValue(projectDir, ['rev-parse', '--path-format=absolute', '--git-common-dir'], gitTimeoutMs);
|
|
60
|
+
const checkoutValue = gitValue(projectDir, ['rev-parse', '--show-toplevel'], gitTimeoutMs);
|
|
58
61
|
if (!commonValue || !checkoutValue) return null;
|
|
59
62
|
const gitCommonDir = canonicalDirectory(commonValue, 'Git common directory');
|
|
60
63
|
const checkoutRoot = canonicalDirectory(checkoutValue, 'Git checkout root');
|
|
@@ -68,9 +71,10 @@ function gitProject(projectDir) {
|
|
|
68
71
|
};
|
|
69
72
|
}
|
|
70
73
|
|
|
71
|
-
export function resolveProjectStore({ projectDir = process.cwd(), requestedStorePath } = {}) {
|
|
74
|
+
export function resolveProjectStore({ projectDir = process.cwd(), requestedStorePath, gitTimeoutMs } = {}) {
|
|
72
75
|
const canonicalInput = canonicalDirectory(projectDir, 'projectDir');
|
|
73
|
-
|
|
76
|
+
if (gitTimeoutMs !== undefined && (!Number.isSafeInteger(gitTimeoutMs) || gitTimeoutMs <= 0)) throw new TypeError('gitTimeoutMs must be a positive integer');
|
|
77
|
+
const git = gitProject(canonicalInput, gitTimeoutMs);
|
|
74
78
|
const resolved = git ?? {
|
|
75
79
|
gitCommonDir: null,
|
|
76
80
|
checkoutRoot: canonicalInput,
|
|
@@ -82,6 +86,11 @@ export function resolveProjectStore({ projectDir = process.cwd(), requestedStore
|
|
|
82
86
|
if (!isWithin(resolved.projectRoot, resolvedAgentDbPath)) {
|
|
83
87
|
throw new Error('store symlink escape rejected');
|
|
84
88
|
}
|
|
89
|
+
// A hard link passes every path check yet writes into a foreign inode (ADR-0102 G-053, measured: the
|
|
90
|
+
// Stop boundary's progression/continuity writers changed a hard-linked foreign store).
|
|
91
|
+
let storeStat = null;
|
|
92
|
+
try { storeStat = fs.statSync(resolvedAgentDbPath); } catch { /* not created yet */ }
|
|
93
|
+
if (storeStat && storeStat.nlink > 1) throw new Error('store hard link rejected');
|
|
85
94
|
if (requestedStorePath !== undefined) {
|
|
86
95
|
if (typeof requestedStorePath !== 'string' || !requestedStorePath.trim()) {
|
|
87
96
|
throw new TypeError('requestedStorePath must be a non-empty path');
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/** Observed transitions are context, never instructions or completion claims. */
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import { developmentHooksSuspended } from './development-maintenance.mjs';
|
|
7
|
+
import { resolveTurnDb } from './turn-outcome-capture.mjs';
|
|
8
|
+
import { execFileSync } from 'node:child_process';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
import { redactText } from './continuity-events.mjs';
|
|
11
|
+
import { normalizeHostEvent } from './hook-input.mjs';
|
|
12
|
+
import { resolveProjectStore } from './project-store-resolver.mjs';
|
|
13
|
+
import { withProgressionReader } from './project-progression-reader.mjs';
|
|
14
|
+
import { redactProgression, restoreProjectProgression, digestCanonical, validateProgressionSnapshot } from './project-progression-contract.mjs';
|
|
15
|
+
import { runSessionSnapshotHook, effectiveBudgetMs, queueCapture, replayOutboxDetached, runOutboxReplay } from './session-snapshot-hook.mjs';
|
|
16
|
+
|
|
17
|
+
const EVENTS = new Set(['UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'PostToolUseFailure', 'SubagentStop']);
|
|
18
|
+
const TOPICS = [
|
|
19
|
+
[/\b(agentdb|memory|recall|capture|checkpoint)\b/i, 'project memory'],
|
|
20
|
+
[/\b(test|tests|vitest|pytest|qa|check)\b/i, 'validation'],
|
|
21
|
+
[/\b(release|publish|deploy|deployment)\b/i, 'release'],
|
|
22
|
+
[/\b(doc|docs|documentation|readme)\b/i, 'documentation'],
|
|
23
|
+
[/\b(security|authentication|permission|secret)\b/i, 'security'],
|
|
24
|
+
[/\b(runtime|hook|hooks|process|session)\b/i, 'runtime integration'],
|
|
25
|
+
[/\b(ui|interface|page|browser)\b/i, 'user interface'],
|
|
26
|
+
];
|
|
27
|
+
const ACTIONS = ['fix', 'repair', 'implement', 'add', 'remove', 'update', 'audit', 'review', 'verify', 'test', 'explain', 'build'];
|
|
28
|
+
const MEANINGFUL_TOOLS = /(?:^|__)(?:Write|Edit|MultiEdit|NotebookEdit|apply_patch|Bash|exec_command|write_stdin|agent_spawn|Task|Agent)$/i;
|
|
29
|
+
|
|
30
|
+
// Classification deliberately returns dictionary words only. Redaction alone cannot
|
|
31
|
+
// distinguish a password from an ordinary word in arbitrary free text.
|
|
32
|
+
export function semanticIntent(text) {
|
|
33
|
+
const safe = String(text ?? '').slice(0, 8192);
|
|
34
|
+
const action = ACTIONS.find((word) => new RegExp(`\\b${word}\\b`, 'i').test(safe)) || 'discuss';
|
|
35
|
+
const topics = TOPICS.filter(([pattern]) => pattern.test(safe)).map(([, label]) => label);
|
|
36
|
+
return { action, subjects: topics.length ? topics : ['project work'] };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Selected user task clause, not a raw prompt archive and never authority to resume. */
|
|
40
|
+
export function selectedUserIntent(value) {
|
|
41
|
+
if (typeof value !== 'string') return null;
|
|
42
|
+
const bounded = value.slice(0, 64 * 1024);
|
|
43
|
+
const redacted = redactText(bounded);
|
|
44
|
+
const prose = redacted.replace(/```[\s\S]*?(?:```|$)|~~~[\s\S]*?(?:~~~|$)/g, '');
|
|
45
|
+
const lines = prose.split(/\r?\n/).filter((line) => line.trim()
|
|
46
|
+
&& !/^\s*(?:>|\{|\[|(?:tool|stdout|stderr|output|log|trace|result)\s*:|\d{4}-\d\d-\d\d)/i.test(line));
|
|
47
|
+
const selected = lines.find((line) => /\b(?:fix|repair|implement|add|remove|update|audit|review|verify|test|explain|build|why|how|what)\b/i.test(line));
|
|
48
|
+
if (!selected) return null;
|
|
49
|
+
const clause = selected.trim().split(/(?<=[.!?])\s/)[0].replace(/\s+/g, ' ');
|
|
50
|
+
if (/\[REDACTED|password|passwd|credential|private key|authorization|bearer|api.?key|secret|(?:access|auth)[-_ ]?token/i.test(clause)) return null;
|
|
51
|
+
let excerpt = '';
|
|
52
|
+
for (const character of clause) {
|
|
53
|
+
if (Buffer.byteLength(excerpt + character, 'utf8') > 240) break;
|
|
54
|
+
excerpt += character;
|
|
55
|
+
}
|
|
56
|
+
if (!excerpt) return null;
|
|
57
|
+
return { text: excerpt, source: 'user-prompt-excerpt', authoritative: false,
|
|
58
|
+
redacted: bounded !== redacted, truncated: excerpt !== clause || bounded !== value };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function normalizeTransition(payload, event, { now = () => new Date().toISOString(), eventId = () => crypto.randomUUID(), host } = {}) {
|
|
62
|
+
const input = normalizeHostEvent(payload);
|
|
63
|
+
if (!EVENTS.has(event)) return { skipped: 'unsupported transition' };
|
|
64
|
+
if (!input || typeof input.session_id !== 'string' || !input.session_id) return { skipped: 'no session identity' };
|
|
65
|
+
const common = { id: eventId(), occurredAt: now(), trigger: event, source: 'host-observation', authoritative: false };
|
|
66
|
+
if (event === 'UserPromptSubmit') {
|
|
67
|
+
const text = input.prompt ?? input.user_prompt;
|
|
68
|
+
if (typeof text !== 'string' || !text.trim()) return { skipped: 'no user intent supplied' };
|
|
69
|
+
const selectedIntent = selectedUserIntent(text);
|
|
70
|
+
return { ...common, kind: 'user-goal-observation', intent: semanticIntent(text),
|
|
71
|
+
...(selectedIntent ? { selectedIntent } : {}), outcome: 'requested' };
|
|
72
|
+
}
|
|
73
|
+
if (event === 'SubagentStop') return { ...common, kind: 'child-observation', outcome: 'child-stopped', parentGoalChanged: false };
|
|
74
|
+
if (!MEANINGFUL_TOOLS.test(String(input.tool_name ?? ''))) return { skipped: 'non-material tool observation' };
|
|
75
|
+
const toolInput = input.tool_input ?? {};
|
|
76
|
+
const response = input.tool_response && typeof input.tool_response === 'object' ? input.tool_response : {};
|
|
77
|
+
const exitCode = [response.exit_code, response.exitCode, response.status].find(Number.isSafeInteger);
|
|
78
|
+
const failure = event === 'PostToolUseFailure' || response.isError === true || input.is_error === true || (Number.isSafeInteger(exitCode) && exitCode !== 0);
|
|
79
|
+
const interrupted = response.interrupted === true || response.signal === 'SIGINT';
|
|
80
|
+
// Measured native Claude Bash PostToolUse has no exit code. Its completion envelope
|
|
81
|
+
// is host-reported success, never an inferred exit-code zero. Other hosts stay unknown.
|
|
82
|
+
const claudeBashCompletion = host === 'claude' && input.tool_name === 'Bash' && event === 'PostToolUse'
|
|
83
|
+
&& typeof response.stdout === 'string' && typeof response.stderr === 'string'
|
|
84
|
+
&& response.interrupted === false && typeof response.isImage === 'boolean'
|
|
85
|
+
&& typeof response.noOutputExpected === 'boolean';
|
|
86
|
+
const outcome = event === 'PreToolUse' ? 'pending' : interrupted ? 'interrupted' : failure ? 'failure'
|
|
87
|
+
: Number.isSafeInteger(exitCode) || response.success === true || response.ok === true || claudeBashCompletion ? 'success' : 'unknown';
|
|
88
|
+
return { ...common, kind: 'tool-observation', tool: String(input.tool_name).split('__').at(-1).slice(0, 100),
|
|
89
|
+
intent: semanticIntent(toolInput.description ?? toolInput.command ?? toolInput.cmd ?? toolInput.file_path), outcome,
|
|
90
|
+
...(outcome === 'success' && claudeBashCompletion ? { outcomeEvidence: 'claude-bash-completion' } : {}),
|
|
91
|
+
...(event !== 'PreToolUse' && Number.isSafeInteger(exitCode) ? { exitCode } : {}) };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function readTransitionHistory(resolution, { deadlineAt = Infinity } = {}) {
|
|
95
|
+
const remaining = () => { if (Date.now() >= deadlineAt) throw new Error('transition history deadline exceeded'); };
|
|
96
|
+
remaining();
|
|
97
|
+
const result = withProgressionReader(resolution.canonicalAgentDbPath, (reader) => {
|
|
98
|
+
const keys = reader.listKeys('project-progression');
|
|
99
|
+
// Complete ancestry under the shared deadline; never a lifetime row cap or latest-N window.
|
|
100
|
+
remaining();
|
|
101
|
+
return keys.map((key) => { remaining(); return JSON.parse(reader.readContent('project-progression', key)); });
|
|
102
|
+
});
|
|
103
|
+
if (!result.ok) throw new Error('canonical transition history unavailable');
|
|
104
|
+
return result.value;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function buildTransitionProgression({ resolution, observation, snapshots = [], sessionIdentity, host, sourceIdentity: originalSourceIdentity }) {
|
|
108
|
+
const restored = restoreProjectProgression(snapshots, { expectedProjectIdentity: resolution.projectIdentity });
|
|
109
|
+
if (snapshots.length && !restored.ok) throw new Error('nonempty transition journal has no coherent ancestry');
|
|
110
|
+
const heads = snapshots.filter((snapshot) => restored.heads.includes(snapshot.eventKey));
|
|
111
|
+
const prior = restored.state;
|
|
112
|
+
const empty = Object.fromEntries(['plan', 'completed', 'inProgress', 'blockers', 'failures', 'decisions', 'changedFiles', 'commands', 'proofArtifacts', 'untested', 'resumeConflicts'].map((key) => [key, []]));
|
|
113
|
+
const state = prior ? structuredClone(prior) : { ...empty, currentGoal: null, nextAction: null, acceptanceContract: null, activeProcess: null, activeStep: null };
|
|
114
|
+
delete state.sourceIdentity;
|
|
115
|
+
delete state.journalHeads;
|
|
116
|
+
state.activeStep = observation.trigger;
|
|
117
|
+
state.observations = [...(Array.isArray(state.observations) ? state.observations : []), observation];
|
|
118
|
+
if (observation.kind === 'tool-observation') {
|
|
119
|
+
state.commands = [...state.commands, observation];
|
|
120
|
+
if (['failure', 'interrupted'].includes(observation.outcome)) state.failures = [...state.failures, observation];
|
|
121
|
+
}
|
|
122
|
+
state.evidence = { ...(state.evidence ?? {}), transition: { originalEventId: observation.id, originalOccurredAt: observation.occurredAt,
|
|
123
|
+
sourceMeasurement: 'head-only; tree digests not measured at this boundary', authoritative: false } };
|
|
124
|
+
// Source identity was measured at observation time, never reconstructed by a later drainer.
|
|
125
|
+
const sourceIdentity = originalSourceIdentity || observeTransitionSource(resolution);
|
|
126
|
+
return redactProgression({ canonicalAgentDbPath: resolution.canonicalAgentDbPath, sourceIdentity,
|
|
127
|
+
sequence: Math.max(0, ...heads.map((item) => item.sequence)) + 1, occurredAt: observation.occurredAt,
|
|
128
|
+
parentEventKeys: restored.heads, dedupId: `${host}:${sessionIdentity}:${observation.id}`, completeProjectState: state }).value;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function observeTransitionSource(resolution, projectDir = resolution.checkoutRoot) {
|
|
132
|
+
let head = 'unmeasured';
|
|
133
|
+
try { head = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: resolution.checkoutRoot, encoding: 'utf8', timeout: 300, stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { /* explicitly unmeasured */ }
|
|
134
|
+
return { checkoutPath: resolution.checkoutRoot, capturePath: fs.realpathSync.native(projectDir),
|
|
135
|
+
worktreeId: crypto.createHash('sha256').update(resolution.checkoutRoot).digest('hex'), branch: 'unmeasured', head,
|
|
136
|
+
trackedDigest: 'unmeasured-at-transition', untrackedDigest: 'unmeasured-at-transition', dirtyTreeDigest: 'unmeasured-at-transition' };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Called only by the fenced queue drainer; current heads merge, original observations stay fixed. */
|
|
140
|
+
export function captureNormalizedTransition(job, { readHistory = readTransitionHistory, capture = runSessionSnapshotHook,
|
|
141
|
+
budgetMs = 6500, makeStoreFactory, now = Date.now } = {}) {
|
|
142
|
+
const deadlineAt = now() + budgetMs;
|
|
143
|
+
const resolution = resolveProjectStore({ projectDir: job.originProjectDir, gitTimeoutMs: Math.max(1, Math.min(500, budgetMs)) });
|
|
144
|
+
const normalized = job.payload.normalizedTransition;
|
|
145
|
+
if (!normalized || normalized.observation?.authoritative !== false || !normalized.observation?.id
|
|
146
|
+
|| normalized.sourceIdentity?.checkoutPath !== resolution.checkoutRoot) throw new Error('invalid normalized transition binding');
|
|
147
|
+
const snapshots = readHistory(resolution, { deadlineAt });
|
|
148
|
+
if (snapshots.length && !restoreProjectProgression(snapshots, { expectedProjectIdentity: resolution.projectIdentity }).ok) {
|
|
149
|
+
throw new Error('nonempty transition journal has no coherent ancestry');
|
|
150
|
+
}
|
|
151
|
+
// A failed first store already fsynced a snapshot to the outbox. If startup/replay committed
|
|
152
|
+
// that snapshot before returning to its observation job, verify the same event rather than
|
|
153
|
+
// turn one observed action into a second event at a new sequence.
|
|
154
|
+
const dedupId = `${job.host}:${job.payload.session_id}:${normalized.observation.id}`;
|
|
155
|
+
const committed = snapshots.find((snapshot) => snapshot.dedupId === dedupId
|
|
156
|
+
&& validateProgressionSnapshot(snapshot, { expectedProjectIdentity: resolution.projectIdentity }).ok
|
|
157
|
+
&& digestCanonical(snapshot.sourceIdentity) === digestCanonical(normalized.sourceIdentity)
|
|
158
|
+
&& snapshot.completeProjectState.observations?.some((value) => digestCanonical(value) === digestCanonical(normalized.observation)));
|
|
159
|
+
if (committed) return { progressionCaptured: true, eventId: normalized.observation.id,
|
|
160
|
+
receipt: { eventKey: committed.eventKey, payloadDigest: committed.payloadDigest,
|
|
161
|
+
readbackDigest: committed.payloadDigest, readPath: 'canonical progression reader' } };
|
|
162
|
+
const progression = buildTransitionProgression({ resolution, observation: normalized.observation,
|
|
163
|
+
snapshots, sessionIdentity: job.payload.session_id, host: job.host,
|
|
164
|
+
sourceIdentity: normalized.sourceIdentity });
|
|
165
|
+
const result = capture(job.originProjectDir, job.event, { host: job.host, budgetMs: Math.max(0, deadlineAt - now()),
|
|
166
|
+
...(makeStoreFactory ? { makeStoreFactory } : {}), writeMetadata: false,
|
|
167
|
+
rawInput: JSON.stringify({ session_id: job.payload.session_id, hook_event_name: job.event, projectProgression: progression }),
|
|
168
|
+
captureTurn: () => ({ recorded: false, skipped: 'transition boundary' }),
|
|
169
|
+
captureEvents: () => ({ recorded: 0, skipped: 'transition boundary' }) });
|
|
170
|
+
return { ...result, eventId: normalized.observation.id };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function runProjectTransitionHook(projectDir, event, { payload = {}, host = process.env.RUVNET_HOOK_HOST || 'claude',
|
|
174
|
+
readHistory = readTransitionHistory, capture = runSessionSnapshotHook, env = process.env } = {}) {
|
|
175
|
+
if (developmentHooksSuspended(projectDir)) return { state: 'skipped', reason: 'development hooks suspended' };
|
|
176
|
+
const observation = normalizeTransition(payload, event, { host });
|
|
177
|
+
if (observation.skipped) return { state: 'skipped', reason: observation.skipped };
|
|
178
|
+
const brainHome = env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache', 'ruvnet-brain');
|
|
179
|
+
const consent = resolveTurnDb({ projectDir, brainHome, gitTimeoutMs: 500 });
|
|
180
|
+
if (consent.skipped) return { state: 'skipped', reason: consent.skipped };
|
|
181
|
+
const resolution = resolveProjectStore({ projectDir, gitTimeoutMs: 500 });
|
|
182
|
+
if (!fs.existsSync(resolution.canonicalAgentDbPath)) return { state: 'skipped', reason: 'no adopted canonical store' };
|
|
183
|
+
const transportEvent = event === 'PostToolUseFailure' ? 'PostToolUse' : event;
|
|
184
|
+
// Fsync the selected observation BEFORE any history enumeration/merge. A deadline, corruption,
|
|
185
|
+
// or long-lived project must leave it pending rather than erase it or fabricate root ancestry.
|
|
186
|
+
const queued = queueCapture({ projectDir: resolution.projectRoot, originProjectDir: projectDir, event: transportEvent, host,
|
|
187
|
+
payload: { session_id: payload.session_id, hook_event_name: transportEvent,
|
|
188
|
+
normalizedTransition: { observation, sourceIdentity: observeTransitionSource(resolution, projectDir) } } });
|
|
189
|
+
if (!queued) return { state: 'degraded', reason: 'normalized observation queue unwritable', eventId: observation.id };
|
|
190
|
+
let result = null;
|
|
191
|
+
runOutboxReplay({ projectDir: resolution.projectRoot, budgetMs: Math.min(6500, effectiveBudgetMs(env)),
|
|
192
|
+
captureNormalized: (job, options) => captureNormalizedTransition(job, { ...options, readHistory, capture }),
|
|
193
|
+
onCaptured: (captured) => { if (captured?.eventId === observation.id) result = captured; } });
|
|
194
|
+
if (!result?.receipt) replayOutboxDetached({ projectDir: resolution.projectRoot });
|
|
195
|
+
return { state: result?.progressionCaptured && result.receipt ? 'committed' : 'pending', eventId: observation.id, result };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
199
|
+
try {
|
|
200
|
+
const payload = JSON.parse(fs.readFileSync(0, 'utf8') || '{}');
|
|
201
|
+
const result = runProjectTransitionHook(payload.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd(), process.argv[2], { payload });
|
|
202
|
+
if (result.state === 'pending') process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: process.argv[2], additionalContext: 'Project memory transition is pending; exact AgentDB readback was not verified at this boundary.' } }));
|
|
203
|
+
} catch { process.stdout.write(JSON.stringify({ systemMessage: 'Project memory transition capture degraded; exact readback was not verified.' })); }
|
|
204
|
+
}
|