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.
Files changed (35) hide show
  1. package/README.md +2 -2
  2. package/bin/install.mjs +9 -4
  3. package/kb/lifecycle-evidence-retention.mjs +56 -2
  4. package/kb/verify-citation.mjs +48 -27
  5. package/package.json +1 -1
  6. package/plugin/.claude-plugin/plugin.json +1 -1
  7. package/plugin/.codex-plugin/plugin.json +1 -1
  8. package/plugin/hooks/codex-hooks.json +40 -3
  9. package/plugin/hooks/hook-contracts.json +218 -19
  10. package/plugin/hooks/hooks.json +51 -2
  11. package/plugin/scripts/agentdb-recall.mjs +297 -0
  12. package/plugin/scripts/continuity-hook-policy.mjs +9 -0
  13. package/plugin/scripts/continuity-journal.mjs +51 -52
  14. package/plugin/scripts/ground-ruvnet.sh +17 -3
  15. package/plugin/scripts/hook-shim.mjs +4 -30
  16. package/plugin/scripts/project-capture-queue.mjs +333 -0
  17. package/plugin/scripts/project-progression-contract.mjs +1 -1
  18. package/plugin/scripts/project-progression-hook.mjs +3 -3
  19. package/plugin/scripts/project-progression-outbox.mjs +19 -4
  20. package/plugin/scripts/project-progression-producer.mjs +53 -28
  21. package/plugin/scripts/project-progression-session-start.mjs +44 -4
  22. package/plugin/scripts/project-progression-store.mjs +14 -0
  23. package/plugin/scripts/project-store-resolver.mjs +16 -7
  24. package/plugin/scripts/project-transition-hook.mjs +204 -0
  25. package/plugin/scripts/session-snapshot-hook.mjs +46 -274
  26. package/plugin/scripts/session-start-budget.mjs +2 -2
  27. package/plugin/scripts/session-start-core.mjs +5 -0
  28. package/plugin/scripts/turn-outcome-capture.mjs +246 -70
  29. package/plugin/scripts/turn-transport-journal.mjs +106 -0
  30. package/scripts/learning-replay-cli.mjs +15 -0
  31. package/scripts/learning-replay.mjs +1 -1
  32. package/scripts/nightly-wrapper.sh +5 -5
  33. package/scripts/release-qualification-contract.mjs +87 -0
  34. package/scripts/release-qualification.mjs +10 -1
  35. 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
- // COMMITTED ROWS ONLY (ADR-073 §5). Replay is a write, a write is a `ruflo memory store`
233
- // process, and one of those costs more than this entire boundary's budget. Pending durable
234
- // snapshots are REPORTED below and replayed at the next capture boundary or by /checkpoint.
235
- const restored = store.restoreLatest({ maxOutputBytes: payloadLimit, replayPending: false, projectToBound: true });
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
- const git = gitProject(canonicalInput);
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
+ }