ruvnet-brain 4.5.4 → 4.5.6

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 (58) hide show
  1. package/README.md +2 -2
  2. package/bin/install.mjs +162 -27
  3. package/config/model-router/catalog.template.json +126 -52
  4. package/config/model-router/claude-terminal-mod/.claude-plugin/plugin.json +1 -0
  5. package/config/model-router/claude-terminal-mod/README.md +22 -0
  6. package/config/model-router/claude-terminal-mod/hooks/hooks.json +1 -0
  7. package/config/model-router/claude-terminal-mod/hooks/policy.default.mjs +2 -0
  8. package/config/model-router/claude-terminal-mod/hooks/register.js +66 -0
  9. package/config/model-router/claude-terminal-mod/hooks/routing.js +55 -0
  10. package/config/model-router/claude-terminal-mod/hooks/runtime.js +2 -0
  11. package/config/model-router/claude-terminal-mod/tests/native.test.ts +86 -0
  12. package/config/model-router/policy.default.mjs +97 -75
  13. package/config/model-router/qualification-contract.json +124 -0
  14. package/config/model-router/routing-eval-cases.json +275 -0
  15. package/config/model-router/routing-policy.template.json +76 -0
  16. package/config/model-router/weekly-analyst-instruction.md +60 -0
  17. package/data/model-catalog.json +44 -49
  18. package/package.json +6 -3
  19. package/plugin/.claude-plugin/plugin.json +1 -1
  20. package/plugin/.codex-plugin/plugin.json +1 -1
  21. package/plugin/mcp/managed-cli-interface.mjs +9 -4
  22. package/plugin/scripts/capability-claim-evidence.mjs +9 -2
  23. package/plugin/scripts/codex-hook-adapter.mjs +18 -9
  24. package/plugin/scripts/project-capture-queue.mjs +7 -1
  25. package/plugin/scripts/project-progression-hook.mjs +16 -7
  26. package/plugin/scripts/project-progression-outbox.mjs +96 -15
  27. package/plugin/scripts/project-progression-producer.mjs +12 -1
  28. package/plugin/scripts/project-progression-store.mjs +53 -1
  29. package/plugin/scripts/project-transition-hook.mjs +19 -8
  30. package/plugin/scripts/session-snapshot-hook.mjs +2 -1
  31. package/scripts/claude-terminal-mod.mjs +89 -0
  32. package/scripts/codex-hook-trust-reconcile.mjs +247 -0
  33. package/scripts/codex-routed.sh +3 -36
  34. package/scripts/goldie-weekly.sh +8 -64
  35. package/scripts/metaharness-router.mjs +7 -1
  36. package/scripts/model-analyst-sandbox.mjs +54 -0
  37. package/scripts/model-currency-evidence.mjs +139 -0
  38. package/scripts/model-currency.mjs +230 -0
  39. package/scripts/model-native-catalog.mjs +111 -0
  40. package/scripts/model-native-qualification.mjs +251 -0
  41. package/scripts/model-router-agent-hook.mjs +136 -0
  42. package/scripts/model-router-dispatch.mjs +161 -0
  43. package/scripts/model-router-engine.mjs +155 -104
  44. package/scripts/model-routing-eval.mjs +108 -0
  45. package/scripts/model-routing-gateway.mjs +453 -0
  46. package/scripts/model-routing-launchers.mjs +209 -0
  47. package/scripts/model-routing-policy-promotion.mjs +203 -0
  48. package/scripts/model-terminal-gateway.mjs +310 -0
  49. package/scripts/model-terminal-launchers.mjs +300 -0
  50. package/scripts/model-weekly-analyst.mjs +299 -0
  51. package/scripts/model-weekly-assessment.mjs +91 -0
  52. package/scripts/model-weekly-cycle.mjs +183 -0
  53. package/scripts/model-weekly-qualification.mjs +362 -0
  54. package/scripts/native-subscription-usage.mjs +57 -0
  55. package/scripts/release-qualification-contract.mjs +50 -1
  56. package/scripts/release-qualification.mjs +9 -6
  57. package/scripts/security-guidance-codex-compat.mjs +142 -0
  58. package/scripts/user-model-prompt-hook.mjs +69 -0
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
- "version": "4.5.4",
4
- "description": "One-command installer for RuvNet Brain \u2014 a portable, source-grounded brain over rUv's RuvNet building blocks, delivered as a Claude Code plugin so Claude uses the stack instead of fighting it.",
3
+ "version": "4.5.6",
4
+ "description": "One-command installer for RuvNet Brain — a portable, source-grounded brain over rUv's RuvNet building blocks, delivered as a Claude Code plugin so Claude uses the stack instead of fighting it.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "ruvnet-brain": "bin/install.mjs"
@@ -40,6 +40,8 @@
40
40
  "test:all": "npm run test:unit && npm run test:mesh && npm run test:mutation && npm run test:regression && npm run test:integration && npm test",
41
41
  "metaharness:receipts": "node scripts/metaharness-receipts.mjs",
42
42
  "route:cheap": "node scripts/route-cheap.mjs",
43
+ "routing:eval": "node scripts/model-routing-eval.mjs",
44
+ "hooks:security-guidance:compat": "node scripts/security-guidance-codex-compat.mjs",
43
45
  "route:receipt": "node scripts/dispatch-receipt.mjs",
44
46
  "trismart": "node scripts/trismart.mjs",
45
47
  "metaharness:fix": "node scripts/fix-metaharness-memretrieve.mjs --apply",
@@ -144,6 +146,7 @@
144
146
  },
145
147
  "dependencies": {
146
148
  "@metaharness/flywheel": "^0.1.7",
147
- "@metaharness/router": "^0.4.0"
149
+ "@metaharness/router": "^0.4.0",
150
+ "ws": "^8.22.0"
148
151
  }
149
152
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
3
  "description": "RuvNet brain transplant for Claude Code — grounds every RuvNet decision in real source across 77 rUv repositories, prefers Ruflo / RuVector-RVF / AgentDB over training-prior defaults (pgvector, Pinecone, hand-rolled cosine), and can pull in any RuvNet repo on demand. Ships a UserPromptSubmit retrieve-and-inject grounding hook and a PreToolUse write gate that refuses ungrounded rUv-product code until search_ruvnet has been consulted (ADR-0012 / ADR-067).",
4
- "version": "4.5.4",
4
+ "version": "4.5.6",
5
5
  "author": {
6
6
  "name": "Stuart Kerr"
7
7
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
- "version": "4.5.4",
3
+ "version": "4.5.6",
4
4
  "description": "Source-grounded RuvNet knowledge, lifecycle enforcement, and learning for Codex.",
5
5
  "author": {
6
6
  "name": "Stuart Kerr"
@@ -253,9 +253,10 @@ export function normalizeManagedExecution(execution) {
253
253
  const output = [execution?.stdout, execution?.stderr].filter(Boolean)
254
254
  .join(execution?.stdout && execution?.stderr ? '\n' : '');
255
255
  const contradictoryFailure = /(?:^|\n)\s*(?:❌|\[ERROR\])|invalid pragma command|key not found/i.test(output);
256
+ if (execution?.error) return { outcome: 'failure', output, contradictoryFailure };
256
257
  if (execution?.signal) return { outcome: 'interrupted', output, signal: execution.signal, contradictoryFailure };
257
- if (execution?.error || contradictoryFailure || execution?.code === null || execution?.code === undefined) {
258
- return { outcome: execution?.error || contradictoryFailure ? 'failure' : 'unknown', output, contradictoryFailure };
258
+ if (contradictoryFailure || execution?.code === null || execution?.code === undefined) {
259
+ return { outcome: contradictoryFailure ? 'failure' : 'unknown', output, contradictoryFailure };
259
260
  }
260
261
  return { outcome: execution.code === 0 ? 'success' : 'failure', output, contradictoryFailure };
261
262
  }
@@ -296,15 +297,19 @@ function managedProgressionCapture({ executable, argv, projectRoot, env, event,
296
297
  function resultOf(executable, argv, result) {
297
298
  const output = [result.stdout, result.stderr].filter(Boolean).join(result.stdout && result.stderr ? '\n' : '');
298
299
  const normalized = normalizeManagedExecution(result);
300
+ const terminal = { outcome: normalized.outcome, code: result.code ?? null, signal: result.signal ?? null,
301
+ error: result.error ?? null, stdout: result.stdout ?? '', stderr: result.stderr ?? '' };
299
302
  if (normalized.outcome !== 'success') {
300
- const reason = result.error || (normalized.contradictoryFailure ? 'fatal output despite exit 0' : result.code == null ? 'no terminal exit status' : `exit ${result.code}`);
303
+ const reason = result.error || (result.signal ? `signal ${result.signal}` : result.code != null && result.code !== 0 ? `exit ${result.code}` : normalized.contradictoryFailure ? 'fatal output without successful completion' : 'no terminal exit status');
301
304
  return {
302
- content: [{ type: 'text', text: output || `${executable} ${argv.join(' ')} failed: ${reason}` }],
305
+ content: [{ type: 'text', text: [output, `${executable} ${argv.join(' ')} failed: ${reason}`].filter(Boolean).join('\n') }],
306
+ structuredContent: terminal,
303
307
  isError: true,
304
308
  };
305
309
  }
306
310
  return {
307
311
  content: [{ type: 'text', text: output || `${executable} ${argv.join(' ')} completed successfully` }],
312
+ structuredContent: terminal,
308
313
  isError: false,
309
314
  };
310
315
  }
@@ -3,6 +3,7 @@ import crypto from 'node:crypto';
3
3
  import fs from 'node:fs';
4
4
  import os from 'node:os';
5
5
  import path from 'node:path';
6
+ import { redactText } from './continuity-events.mjs';
6
7
 
7
8
  const HEX40 = /^[a-f0-9]{40}$/;
8
9
  const HEX64 = /^[a-f0-9]{64}$/;
@@ -157,7 +158,13 @@ export function recordManagedCliObservation({ toolName, executable, argv, execut
157
158
  observedAt = new Date().toISOString() } = {}) {
158
159
  try {
159
160
  const output = [execution?.stdout, execution?.stderr].filter(Boolean).join('\n');
160
- const reachable = execution?.code === 0 && !execution?.error;
161
+ const contradictoryFailure = /(?:^|\n)\s*(?:❌|\[ERROR\])|invalid pragma command|key not found/i.test(output);
162
+ const reachable = execution?.code === 0 && !execution?.error && !execution?.signal && !contradictoryFailure;
163
+ const outcome = execution?.error ? 'failure' : execution?.signal ? 'interrupted' : contradictoryFailure ? 'failure'
164
+ : Number.isSafeInteger(execution?.code) ? execution.code === 0 ? 'success' : 'failure' : 'unknown';
165
+ const terminal = { outcome, exitCode: Number.isSafeInteger(execution?.code) ? execution.code : null,
166
+ error: typeof execution?.error === 'string' ? redactText(execution.error).slice(0, 4096) : null,
167
+ signal: typeof execution?.signal === 'string' ? redactText(execution.signal).slice(0, 100) : null };
161
168
  const version = VERSION.exec(output)?.[1] || null;
162
169
  let observationClass = null;
163
170
  let fields = {};
@@ -169,7 +176,7 @@ export function recordManagedCliObservation({ toolName, executable, argv, execut
169
176
  observationClass = 'health';
170
177
  const negative = /\b(?:critical|degraded|error|failed|failure|unhealthy)\b/i.test(output);
171
178
  const positive = /\b(?:all checks passed|healthy|overall\s*[:=-]?\s*(?:ok|pass)|status\s*[:=-]\s*(?:ok|pass|healthy))\b/i.test(output);
172
- fields = { healthVerdict: negative ? 'FAIL' : reachable && positive ? 'PASS' : 'UNKNOWN', reachable };
179
+ fields = { healthVerdict: negative || ['failure', 'interrupted'].includes(outcome) ? 'FAIL' : reachable && positive ? 'PASS' : 'UNKNOWN', reachable, terminal };
173
180
  }
174
181
  if (!observationClass) return null;
175
182
  const payload = {
@@ -216,21 +216,20 @@ function validPostToolUseOutput(value) {
216
216
  if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
217
217
  const topLevelKeys = new Set([
218
218
  'continue', 'stopReason', 'suppressOutput', 'systemMessage',
219
- 'terminalSequence', 'decision', 'reason', 'hookSpecificOutput',
219
+ 'decision', 'reason', 'hookSpecificOutput',
220
220
  ]);
221
221
  if (Object.keys(value).some((key) => !topLevelKeys.has(key))) return false;
222
222
  if (value.continue !== undefined && typeof value.continue !== 'boolean') return false;
223
223
  if (value.stopReason !== undefined && typeof value.stopReason !== 'string') return false;
224
224
  if (value.suppressOutput !== undefined && typeof value.suppressOutput !== 'boolean') return false;
225
225
  if (value.systemMessage !== undefined && typeof value.systemMessage !== 'string') return false;
226
- if (value.terminalSequence !== undefined && typeof value.terminalSequence !== 'string') return false;
227
226
  if (value.decision !== undefined && value.decision !== 'block') return false;
228
227
  if (value.decision === 'block' && (typeof value.reason !== 'string' || !value.reason.trim())) return false;
229
228
  if (value.hookSpecificOutput !== undefined) {
230
229
  const specific = value.hookSpecificOutput;
231
230
  if (!specific || typeof specific !== 'object' || Array.isArray(specific)) return false;
232
231
  const specificKeys = new Set([
233
- 'hookEventName', 'additionalContext', 'updatedToolOutput', 'updatedMCPToolOutput',
232
+ 'hookEventName', 'additionalContext',
234
233
  ]);
235
234
  if (Object.keys(specific).some((key) => !specificKeys.has(key))) return false;
236
235
  if (specific.hookEventName !== 'PostToolUse') return false;
@@ -268,13 +267,23 @@ if (!parsed) {
268
267
  process.exit(0);
269
268
  }
270
269
 
271
- // A body can emit syntactically valid JSON that is still invalid for Codex's event-specific wire
272
- // schema (wrong event name, unsupported fields, or bad field types). Preserve the advisory as text
273
- // inside the known-good PostToolUse envelope instead of forwarding a payload the host rejects.
270
+ // Codex 0.160.0 post-tool-use.command.output (extracted from the installed binary on
271
+ // 2026-10-04) rejects terminalSequence, updatedToolOutput, and telemetry fields. The
272
+ // host additionally rejects updatedMCPToolOutput semantically after schema parsing. Keep
273
+ // supported control fields even when a shared body includes incompatible metadata: wrapping
274
+ // the whole output as context alone would silently discard a security block.
274
275
  if (event === 'PostToolUse' && !validPostToolUseOutput(parsed)) {
275
- process.stdout.write(JSON.stringify({
276
- hookSpecificOutput: { hookEventName: event, additionalContext: stdout.trim() },
277
- }));
276
+ const normalized = {};
277
+ for (const key of ['continue', 'stopReason', 'suppressOutput', 'systemMessage', 'reason']) {
278
+ const type = ['continue', 'suppressOutput'].includes(key) ? 'boolean' : 'string';
279
+ if (typeof parsed[key] === type) normalized[key] = parsed[key];
280
+ }
281
+ if (parsed.decision === 'block') {
282
+ normalized.decision = 'block';
283
+ if (!normalized.reason?.trim()) normalized.reason = stdout.trim();
284
+ }
285
+ normalized.hookSpecificOutput = { hookEventName: event, additionalContext: stdout.trim() };
286
+ process.stdout.write(JSON.stringify(normalized));
278
287
  process.exit(0);
279
288
  }
280
289
 
@@ -6,6 +6,7 @@ import { spawn, spawnSync } from 'node:child_process';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { developmentHooksSuspended } from './development-maintenance.mjs';
8
8
  import { resolveTurnDb } from './turn-outcome-capture.mjs';
9
+ import { enrichStateWithObservation } from './project-progression-hook.mjs';
9
10
  import { buildProjectProgression } from './project-progression-producer.mjs';
10
11
  import { resolveProjectStore } from './project-store-resolver.mjs';
11
12
  import { redactProgression } from './project-progression-contract.mjs';
@@ -57,6 +58,10 @@ export function queueCapture({ projectDir, originProjectDir = projectDir, event,
57
58
  if (!progression && !payload?.normalizedTransition) {
58
59
  try { progression = buildProjectProgression({ resolution: resolveProjectStore({ projectDir: originProjectDir }), projectDir: originProjectDir, payload, host, trigger: event }).projectProgression; } catch { return null; }
59
60
  }
61
+ // Freeze the bounded native observation before discarding raw host input. Replay receives no
62
+ // tool fields, so the native writer cannot append it twice.
63
+ if (progression && (payload?.tool_name || payload?.tool_input)) progression = { ...progression,
64
+ completeProjectState: enrichStateWithObservation(progression.completeProjectState, { ...payload, hook_event_name: event }) };
60
65
  // This queue is durable: never serialize arbitrary host prompts, tool input or output.
61
66
  const minimized = { session_id: payload?.session_id, hook_event_name: event,
62
67
  ...(progression ? { projectProgression: progression } : {}),
@@ -273,7 +278,8 @@ export function runOutboxReplay({ projectDir, token = process.env.RUVNET_REPLAY_
273
278
  for (const snapshot of store.outbox.pendingSnapshots()) {
274
279
  if (now() >= deadlineAt) return replayed;
275
280
  if (!refreshReplayLock(projectDir, held)) return replayed;
276
- store.outbox.markCommitted(store.appendExact(snapshot));
281
+ store.captureFrozen(snapshot, { canCommit: () => refreshReplayLock(projectDir, held) });
282
+ // captureFrozen commits only the verified key, retaining conflicting original history.
277
283
  replayed += 1;
278
284
  }
279
285
  for (const file of queuedCaptures(projectDir)) {
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { createProgressionSnapshot } from './project-progression-contract.mjs';
5
5
  import { ProjectProgressionStore } from './project-progression-store.mjs';
6
+ import { redactText } from './continuity-events.mjs';
6
7
 
7
8
  const HOSTS = new Set(['claude', 'codex']);
8
9
  const CAPTURE_TRIGGERS = new Set([
@@ -105,15 +106,17 @@ function toolAction(payload) {
105
106
  const exitCode = Number.isSafeInteger(explicitCode)
106
107
  ? explicitCode
107
108
  : textualCode ? Number(textualCode[1]) : undefined;
108
- const interrupted = responseRecord?.interrupted === true || responseRecord?.signal === 'SIGINT';
109
+ const error = boundedText(typeof responseRecord?.error === 'string' ? redactText(responseRecord.error) : undefined);
110
+ const signal = boundedText(typeof responseRecord?.signal === 'string' ? redactText(responseRecord.signal) : undefined);
111
+ const interrupted = responseRecord?.interrupted === true || Boolean(signal);
109
112
  const explicitError = responseRecord?.isError === true || payload.is_error === true;
110
113
  const declaredOutcome = ['success', 'failure', 'interrupted', 'unknown'].includes(responseRecord?.outcome)
111
114
  ? responseRecord.outcome : null;
112
- const failed = explicitError || (Number.isSafeInteger(exitCode) && exitCode !== 0);
115
+ const failed = explicitError || Boolean(error) || (Number.isSafeInteger(exitCode) && exitCode !== 0);
113
116
  const terminal = failed || Number.isSafeInteger(exitCode)
114
117
  || responseRecord?.success === true || responseRecord?.ok === true;
115
118
  const outcome = payload.hook_event_name === 'PostToolUse'
116
- ? (interrupted ? 'interrupted' : failed ? 'failure' : declaredOutcome || (terminal ? 'success' : 'unknown'))
119
+ ? (error ? 'failure' : interrupted ? 'interrupted' : failed ? 'failure' : declaredOutcome || (terminal ? 'success' : 'unknown'))
117
120
  : 'pending';
118
121
  const observation = {
119
122
  trigger: payload.hook_event_name,
@@ -121,7 +124,9 @@ function toolAction(payload) {
121
124
  ...(command ? { command } : {}),
122
125
  ...(filePath && !command ? { filePath } : {}),
123
126
  outcome,
124
- ...(Number.isSafeInteger(exitCode) ? { exitCode } : {}),
127
+ ...(Number.isSafeInteger(exitCode) ? { exitCode } : responseRecord?.exit_code === null || responseRecord?.exitCode === null ? { exitCode: null } : {}),
128
+ ...(error ? { error } : {}),
129
+ ...(signal ? { signal } : {}),
125
130
  ...(interrupted ? { interrupted: true } : {}),
126
131
  ...(explicitError ? { isError: true } : {}),
127
132
  };
@@ -137,7 +142,7 @@ function toolAction(payload) {
137
142
  return observation;
138
143
  }
139
144
 
140
- function enrichStateWithObservation(state, payload) {
145
+ export function enrichStateWithObservation(state, payload) {
141
146
  requireRecord(state, 'completeProjectState');
142
147
  const observation = toolAction(payload);
143
148
  if (!observation) return state;
@@ -164,6 +169,8 @@ export function captureProjectTransition({
164
169
  payload,
165
170
  projectDir,
166
171
  adapterVersion = readProgressionAdapterVersion(),
172
+ recoverFrozen = false,
173
+ canCommit,
167
174
  storeFactory = (options) => new ProjectProgressionStore(options),
168
175
  } = {}) {
169
176
  const normalizedHost = requireString(host, 'host').toLowerCase();
@@ -191,7 +198,7 @@ export function captureProjectTransition({
191
198
  aliased(progression, 'sourceIdentity', 'source_identity'),
192
199
  store.resolution.checkoutRoot, projectDir,
193
200
  );
194
- const snapshot = createProgressionSnapshot({
201
+ let snapshot = createProgressionSnapshot({
195
202
  projectIdentity: store.resolution.projectIdentity,
196
203
  sourceIdentity,
197
204
  hostIdentity: { host: normalizedHost, adapterVersion },
@@ -206,7 +213,9 @@ export function captureProjectTransition({
206
213
  payload,
207
214
  ),
208
215
  });
209
- const receipt = store.capture(snapshot);
216
+ let receipt;
217
+ if (recoverFrozen) ({ snapshot, receipt } = store.captureFrozen(snapshot, { canCommit }));
218
+ else receipt = store.capture(snapshot);
210
219
  verifyReceipt(snapshot, receipt);
211
220
  return { snapshot, receipt };
212
221
  }
@@ -2,6 +2,66 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
 
4
4
  const OUTBOX_NAME = 'project-progression-outbox.jsonl';
5
+ const READ_CHUNK_BYTES = 64 * 1024;
6
+
7
+ // Decode only complete records, so a multibyte character crossing reads stays intact.
8
+ function* readJsonl(file) {
9
+ let fd;
10
+ try { fd = fs.openSync(file, 'r'); } catch (error) { if (error.code === 'ENOENT') return; throw error; }
11
+ const chunk = Buffer.alloc(READ_CHUNK_BYTES);
12
+ let parts = [];
13
+ let line = 0;
14
+ const parse = (bytes, terminated) => {
15
+ if (!bytes.length) return undefined;
16
+ const text = bytes.toString('utf8');
17
+ if (!text.trim()) return undefined;
18
+ try { return JSON.parse(text); } catch {
19
+ if (!terminated) return undefined;
20
+ throw new Error(`malformed outbox record at line ${line}`);
21
+ }
22
+ };
23
+ try {
24
+ let length;
25
+ while ((length = fs.readSync(fd, chunk, 0, chunk.length, null))) {
26
+ let start = 0;
27
+ for (let end = 0; end < length; end += 1) {
28
+ if (chunk[end] !== 10) continue;
29
+ parts.push(Buffer.from(chunk.subarray(start, end)));
30
+ line += 1;
31
+ const record = parse(Buffer.concat(parts), true);
32
+ if (record !== undefined) yield record;
33
+ parts = []; start = end + 1;
34
+ }
35
+ if (start < length) parts.push(Buffer.from(chunk.subarray(start, length)));
36
+ }
37
+ if (parts.length) {
38
+ line += 1;
39
+ const record = parse(Buffer.concat(parts), false);
40
+ if (record !== undefined) yield record;
41
+ }
42
+ } finally { fs.closeSync(fd); }
43
+ }
44
+
45
+ // Inspect only the final record; preceding history is never decoded or copied.
46
+ function finalRecord(fd, size) {
47
+ const parts = [];
48
+ let position = size;
49
+ while (position > 0) {
50
+ const length = Math.min(READ_CHUNK_BYTES, position);
51
+ position -= length;
52
+ const chunk = Buffer.alloc(length);
53
+ let read = 0;
54
+ while (read < length) {
55
+ const count = fs.readSync(fd, chunk, read, length - read, position + read);
56
+ if (!count) throw new Error('outbox tail changed during inspection');
57
+ read += count;
58
+ }
59
+ const delimiter = chunk.lastIndexOf(10);
60
+ parts.unshift(delimiter < 0 ? chunk : chunk.subarray(delimiter + 1));
61
+ if (delimiter >= 0) break;
62
+ }
63
+ return Buffer.concat(parts).toString('utf8');
64
+ }
5
65
 
6
66
  function requireIdentity(value, label) {
7
67
  if (typeof value !== 'string' || !value) throw new TypeError(`${label} must be a non-empty string`);
@@ -31,8 +91,7 @@ export class ProgressionOutbox {
31
91
  const last = Buffer.alloc(1);
32
92
  fs.readSync(fd, last, 0, 1, size - 1);
33
93
  if (last[0] !== 10) {
34
- const content = fs.readFileSync(fd, 'utf8');
35
- try { JSON.parse(content.slice(content.lastIndexOf('\n') + 1)); }
94
+ try { JSON.parse(finalRecord(fd, size)); }
36
95
  catch { throw new Error('incomplete outbox final record; preserve and recover the torn tail before appending'); }
37
96
  separator = '\n';
38
97
  }
@@ -66,18 +125,23 @@ export class ProgressionOutbox {
66
125
  return this.appendRecord({ type: 'commit', ...receipt });
67
126
  }
68
127
 
69
- records() {
70
- if (!fs.existsSync(this.path)) return [];
71
- const content = fs.readFileSync(this.path, 'utf8');
72
- const lines = content.split('\n');
73
- if (lines.at(-1) === '') lines.pop();
74
- else {
75
- // A missing delimiter is not a missing record; only a crash-torn JSON suffix is incomplete.
76
- try { JSON.parse(lines.at(-1)); } catch { lines.pop(); }
128
+ /** Disposition of one frozen payload, never a commit for its conflicting original key. */
129
+ markRecovered(snapshot, receipt) {
130
+ for (const [label, value] of Object.entries({ eventKey: snapshot?.eventKey,
131
+ payloadDigest: snapshot?.payloadDigest, recoveryEventKey: receipt?.eventKey,
132
+ recoveryPayloadDigest: receipt?.payloadDigest, readbackDigest: receipt?.readbackDigest,
133
+ committedAt: receipt?.committedAt })) requireIdentity(value, label);
134
+ if (receipt.eventKey === snapshot.eventKey || receipt.readbackDigest !== receipt.payloadDigest) {
135
+ throw new Error('unverified recovery disposition');
77
136
  }
78
- return lines.filter(Boolean).map((line, index) => {
79
- try { return JSON.parse(line); } catch { throw new Error(`malformed outbox record at line ${index + 1}`); }
80
- });
137
+ return this.appendRecord({ type: 'recovery', eventKey: snapshot.eventKey,
138
+ payloadDigest: snapshot.payloadDigest, recoveryEventKey: receipt.eventKey,
139
+ recoveryPayloadDigest: receipt.payloadDigest, readbackDigest: receipt.readbackDigest,
140
+ committedAt: receipt.committedAt });
141
+ }
142
+
143
+ records() {
144
+ return [...readJsonl(this.path)];
81
145
  }
82
146
 
83
147
  /**
@@ -92,7 +156,8 @@ export class ProgressionOutbox {
92
156
  const snapshots = new Map();
93
157
  const committed = new Map();
94
158
  const quarantined = new Map();
95
- for (const record of this.records()) {
159
+ const recoveries = [];
160
+ for (const record of readJsonl(this.path)) {
96
161
  requireIdentity(record?.eventKey, 'outbox eventKey');
97
162
  requireIdentity(record?.payloadDigest, 'outbox payloadDigest');
98
163
  if (record.type === 'snapshot') {
@@ -103,6 +168,13 @@ export class ProgressionOutbox {
103
168
  const prior = committed.get(record.eventKey);
104
169
  if (prior && prior !== record.payloadDigest) quarantined.set(record.eventKey, 'outbox commit collision');
105
170
  committed.set(record.eventKey, record.payloadDigest);
171
+ } else if (record.type === 'recovery') {
172
+ requireIdentity(record.recoveryEventKey, 'recovery event key');
173
+ requireIdentity(record.recoveryPayloadDigest, 'recovery payload digest');
174
+ if (record.eventKey === record.recoveryEventKey || record.readbackDigest !== record.recoveryPayloadDigest) {
175
+ throw new Error('unverified recovery disposition');
176
+ }
177
+ recoveries.push(record);
106
178
  } else {
107
179
  throw new Error('unsupported outbox record');
108
180
  }
@@ -113,9 +185,18 @@ export class ProgressionOutbox {
113
185
  quarantined.set(record.eventKey, 'outbox commit digest mismatch');
114
186
  }
115
187
  }
188
+ const recovered = new Set(recoveries.filter((record) => {
189
+ const target = snapshots.get(record.recoveryEventKey)?.snapshot;
190
+ return !quarantined.has(record.recoveryEventKey)
191
+ && committed.get(record.recoveryEventKey) === record.recoveryPayloadDigest
192
+ && target?.payloadDigest === record.recoveryPayloadDigest
193
+ && target.recoveryDiagnostics?.originalEventKey === record.eventKey
194
+ && target.recoveryDiagnostics?.frozenPayloadDigest === record.payloadDigest;
195
+ }).map((record) => `${record.eventKey}:${record.payloadDigest}`));
116
196
  this.quarantine = [...quarantined].map(([eventKey, reason]) => ({ eventKey, reason }));
117
197
  return [...snapshots.values()]
118
- .filter((record) => !quarantined.has(record.eventKey) && !committed.has(record.eventKey))
198
+ .filter((record) => !quarantined.has(record.eventKey) && !committed.has(record.eventKey)
199
+ && !recovered.has(`${record.eventKey}:${record.payloadDigest}`))
119
200
  .sort((left, right) => left.eventKey.localeCompare(right.eventKey))
120
201
  .map((record) => record.snapshot);
121
202
  }
@@ -29,6 +29,7 @@
29
29
  * within those bounds.
30
30
  */
31
31
  import crypto from 'node:crypto';
32
+ import { enrichStateWithObservation } from './project-progression-hook.mjs';
32
33
  import fs from 'node:fs';
33
34
  import path from 'node:path';
34
35
  import { digestCanonical, fieldAuthorityAllows, redactProgression, restoreProjectProgression } from './project-progression-contract.mjs';
@@ -250,6 +251,16 @@ export function buildProjectProgression({
250
251
  completeProjectState,
251
252
  });
252
253
  const projectProgression = redactedProgression;
254
+ // Compare the same redacted observation the native writer will append, without returning it
255
+ // twice. Tool input and terminal evidence must participate before the no-op decision.
256
+ const observedState = redactProgression(enrichStateWithObservation(
257
+ projectProgression.completeProjectState, { ...payload, hook_event_name: trigger },
258
+ )).value;
259
+ // A committed sequence is not an event identity: several boundaries may freeze before replay.
260
+ // Bind all immutable captured fields (including the native observation and occurrence time).
261
+ projectProgression.dedupId += `:${digestCanonical({
262
+ ...projectProgression, completeProjectState: observedState,
263
+ })}`;
253
264
 
254
265
  // RETENTION (ADR-073). Three capture boundaries per session times every session is unbounded
255
266
  // growth unless a capture that changes nothing writes nothing. Compare what a snapshot MEANS —
@@ -257,7 +268,7 @@ export function buildProjectProgression({
257
268
  // differ (sequence, timestamp, dedup id, the trigger that happens to be firing, and the evidence
258
269
  // block's own timestamps), because comparing those would make every capture look novel.
259
270
  const meaning = digestCanonical({
260
- state: { ...projectProgression.completeProjectState, activeStep: null, evidence: null },
271
+ state: { ...observedState, activeStep: null, evidence: null },
261
272
  source: projectProgression.sourceIdentity,
262
273
  });
263
274
  const priorMeaning = heads.length === 1 ? digestCanonical({
@@ -5,6 +5,7 @@ import os from 'node:os';
5
5
  import path from 'node:path';
6
6
  import { ProgressionOutbox } from './project-progression-outbox.mjs';
7
7
  import {
8
+ createProgressionSnapshot,
8
9
  digestCanonical,
9
10
  restoreProjectProgression,
10
11
  validateProgressionSnapshot,
@@ -87,10 +88,26 @@ export function projectResumePayloadToBound(payload, maxOutputBytes) {
87
88
  });
88
89
  }
89
90
 
91
+ if (size() > maxOutputBytes && Array.isArray(summary.state?.resumeConflicts)) {
92
+ const groups = new Map();
93
+ for (const conflict of payload.state.resumeConflicts) {
94
+ if (!groups.has(conflict.field)) groups.set(conflict.field, []);
95
+ groups.get(conflict.field).push(conflict);
96
+ }
97
+ if (groups.size < payload.state.resumeConflicts.length) {
98
+ summary.state.resumeConflicts = [...groups].map(([field, conflicts]) => ({
99
+ field, conflictCount: conflicts.length,
100
+ valueCount: conflicts.reduce((count, conflict) => count + (conflict.values ?? []).length, 0),
101
+ conflictsDigest: digestCanonical(conflicts),
102
+ }));
103
+ omissions.push({ path: 'state.resumeConflicts', ...omissionSummary(payload.state.resumeConflicts) });
104
+ }
105
+ }
106
+
90
107
  if (size() > maxOutputBytes) recordOmission(summary.state, 'journalHeads', 'state.journalHeads');
91
108
  // Least central detail first. currentGoal and nextAction are deliberately absent from this list.
92
109
  const stateFields = [
93
- 'commands', 'proofArtifacts', 'changedFiles', 'completed', 'untested', 'decisions',
110
+ 'observations', 'commands', 'proofArtifacts', 'changedFiles', 'completed', 'untested', 'decisions',
94
111
  'plan', 'inProgress', 'blockers', 'failures', 'acceptanceContract', 'provenance',
95
112
  'evidence', 'activeStep', 'activeProcess', 'sourceIdentity',
96
113
  ];
@@ -450,6 +467,41 @@ export class ProjectProgressionStore {
450
467
  return receipt;
451
468
  }
452
469
 
470
+ /** Frozen queue recovery never rewrites the conflicting row or marks its key committed. */
471
+ captureFrozen(snapshot, { canCommit } = {}) {
472
+ if (typeof canCommit !== 'function') throw new Error('frozen recovery requires replay fencing');
473
+ const fenced = () => { if (!canCommit()) throw new Error('progression recovery lost replay fencing'); };
474
+ fenced();
475
+ try { return { snapshot, receipt: this.capture(snapshot, { onPhase: fenced }) }; } catch (error) {
476
+ if (error.message !== 'progression readback digest mismatch') throw error;
477
+ }
478
+ // An independently retrieved exact canonical value must be valid for this project. A failed,
479
+ // absent, malformed or foreign read is never evidence that the immutable key collided.
480
+ const exact = this.retrieveSnapshots([snapshot.eventKey]);
481
+ if (exact.rejected.length || exact.snapshots.length !== 1) throw new Error('immutable collision is not verified');
482
+ const existing = exact.snapshots[0];
483
+ this.validateSnapshot(existing);
484
+ if (existing.eventKey !== snapshot.eventKey || existing.payloadDigest === snapshot.payloadDigest) {
485
+ throw new Error('immutable collision is not verified');
486
+ }
487
+ const identity = { originalEventKey: snapshot.eventKey,
488
+ frozenPayloadDigest: snapshot.payloadDigest, existingPayloadDigest: existing.payloadDigest };
489
+ const created = createProgressionSnapshot({ ...snapshot,
490
+ dedupId: `immutable-recovery:${digestCanonical(identity)}` });
491
+ // Preserve the frozen state and source exactly; diagnostics carry no instruction authority.
492
+ const { payloadDigest: _digest, ...body } = created;
493
+ body.redactions = snapshot.redactions;
494
+ body.recoveryDiagnostics = { kind: 'immutable-event-key-collision', authoritative: false, ...identity };
495
+ const recovered = Object.freeze({ ...body, payloadDigest: digestCanonical(body) });
496
+ this.validateSnapshot(recovered);
497
+ this.requireCaptureConsent(recovered);
498
+ fenced();
499
+ const receipt = this.capture(recovered, { onPhase: fenced });
500
+ fenced();
501
+ this.outbox.markRecovered(snapshot, receipt);
502
+ return { snapshot: recovered, receipt };
503
+ }
504
+
453
505
  replay() {
454
506
  const receipts = [];
455
507
  for (const snapshot of this.outbox.pendingSnapshots()) {
@@ -75,20 +75,25 @@ export function normalizeTransition(payload, event, { now = () => new Date().toI
75
75
  const toolInput = input.tool_input ?? {};
76
76
  const response = input.tool_response && typeof input.tool_response === 'object' ? input.tool_response : {};
77
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';
78
+ const error = typeof response.error === 'string' && response.error ? redactText(response.error).slice(0, 4096) : undefined;
79
+ const signal = typeof response.signal === 'string' && response.signal ? redactText(response.signal).slice(0, 100) : undefined;
80
+ const failure = Boolean(error) || event === 'PostToolUseFailure' || response.isError === true || input.is_error === true || (Number.isSafeInteger(exitCode) && exitCode !== 0);
81
+ const interrupted = response.interrupted === true || Boolean(signal);
82
+ const declaredOutcome = ['success', 'failure', 'interrupted', 'unknown'].includes(response.outcome) ? response.outcome : null;
80
83
  // Measured native Claude Bash PostToolUse has no exit code. Its completion envelope
81
84
  // is host-reported success, never an inferred exit-code zero. Other hosts stay unknown.
82
85
  const claudeBashCompletion = host === 'claude' && input.tool_name === 'Bash' && event === 'PostToolUse'
83
86
  && typeof response.stdout === 'string' && typeof response.stderr === 'string'
84
87
  && response.interrupted === false && typeof response.isImage === 'boolean'
85
88
  && 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';
89
+ const outcome = event === 'PreToolUse' ? 'pending' : error ? 'failure' : interrupted ? 'interrupted' : failure ? 'failure'
90
+ : declaredOutcome || (Number.isSafeInteger(exitCode) || response.success === true || response.ok === true || claudeBashCompletion ? 'success' : 'unknown');
88
91
  return { ...common, kind: 'tool-observation', tool: String(input.tool_name).split('__').at(-1).slice(0, 100),
89
92
  intent: semanticIntent(toolInput.description ?? toolInput.command ?? toolInput.cmd ?? toolInput.file_path), outcome,
90
93
  ...(outcome === 'success' && claudeBashCompletion ? { outcomeEvidence: 'claude-bash-completion' } : {}),
91
- ...(event !== 'PreToolUse' && Number.isSafeInteger(exitCode) ? { exitCode } : {}) };
94
+ ...(event !== 'PreToolUse' && Number.isSafeInteger(exitCode) ? { exitCode } : event !== 'PreToolUse' && (response.exit_code === null || response.exitCode === null) ? { exitCode: null } : {}),
95
+ ...(event !== 'PreToolUse' && error ? { error } : {}),
96
+ ...(event !== 'PreToolUse' && signal ? { signal } : {}) };
92
97
  }
93
98
 
94
99
  export function readTransitionHistory(resolution, { deadlineAt = Infinity } = {}) {
@@ -107,6 +112,11 @@ export function readTransitionHistory(resolution, { deadlineAt = Infinity } = {}
107
112
  export function buildTransitionProgression({ resolution, observation, snapshots = [], sessionIdentity, host, sourceIdentity: originalSourceIdentity }) {
108
113
  const restored = restoreProjectProgression(snapshots, { expectedProjectIdentity: resolution.projectIdentity });
109
114
  if (snapshots.length && !restored.ok) throw new Error('nonempty transition journal has no coherent ancestry');
115
+ return buildRestoredTransitionProgression({ resolution, observation, snapshots, sessionIdentity, host, sourceIdentity: originalSourceIdentity }, restored);
116
+ }
117
+
118
+ // Only the validated result computed in this module can reach this private builder.
119
+ function buildRestoredTransitionProgression({ resolution, observation, snapshots, sessionIdentity, host, sourceIdentity: originalSourceIdentity }, restored) {
110
120
  const heads = snapshots.filter((snapshot) => restored.heads.includes(snapshot.eventKey));
111
121
  const prior = restored.state;
112
122
  const empty = Object.fromEntries(['plan', 'completed', 'inProgress', 'blockers', 'failures', 'decisions', 'changedFiles', 'commands', 'proofArtifacts', 'untested', 'resumeConflicts'].map((key) => [key, []]));
@@ -145,7 +155,8 @@ export function captureNormalizedTransition(job, { readHistory = readTransitionH
145
155
  if (!normalized || normalized.observation?.authoritative !== false || !normalized.observation?.id
146
156
  || normalized.sourceIdentity?.checkoutPath !== resolution.checkoutRoot) throw new Error('invalid normalized transition binding');
147
157
  const snapshots = readHistory(resolution, { deadlineAt });
148
- if (snapshots.length && !restoreProjectProgression(snapshots, { expectedProjectIdentity: resolution.projectIdentity }).ok) {
158
+ const restored = restoreProjectProgression(snapshots, { expectedProjectIdentity: resolution.projectIdentity });
159
+ if (snapshots.length && !restored.ok) {
149
160
  throw new Error('nonempty transition journal has no coherent ancestry');
150
161
  }
151
162
  // A failed first store already fsynced a snapshot to the outbox. If startup/replay committed
@@ -159,9 +170,9 @@ export function captureNormalizedTransition(job, { readHistory = readTransitionH
159
170
  if (committed) return { progressionCaptured: true, eventId: normalized.observation.id,
160
171
  receipt: { eventKey: committed.eventKey, payloadDigest: committed.payloadDigest,
161
172
  readbackDigest: committed.payloadDigest, readPath: 'canonical progression reader' } };
162
- const progression = buildTransitionProgression({ resolution, observation: normalized.observation,
173
+ const progression = buildRestoredTransitionProgression({ resolution, observation: normalized.observation,
163
174
  snapshots, sessionIdentity: job.payload.session_id, host: job.host,
164
- sourceIdentity: normalized.sourceIdentity });
175
+ sourceIdentity: normalized.sourceIdentity }, restored);
165
176
  const result = capture(job.originProjectDir, job.event, { host: job.host, budgetMs: Math.max(0, deadlineAt - now()),
166
177
  ...(makeStoreFactory ? { makeStoreFactory } : {}), writeMetadata: false,
167
178
  rawInput: JSON.stringify({ session_id: job.payload.session_id, hook_event_name: job.event, projectProgression: progression }),
@@ -163,7 +163,8 @@ export function runSessionSnapshotHook(projectDir, event, {
163
163
  if (payload.hook_event_name !== event) {
164
164
  throw new Error(`progression boundary mismatch: expected ${event}, received ${payload.hook_event_name}`);
165
165
  }
166
- const result = captureProgression({ host, payload, projectDir, storeFactory: (options) => makeStoreFactory(now() + budgetMs)({ ...options, env }) });
166
+ const result = captureProgression({ host, payload, projectDir, recoverFrozen: Boolean(ordered),
167
+ canCommit: () => Boolean(ordered) && refreshReplayLock(resolveProjectStore({ projectDir }).projectRoot, ordered), storeFactory: (options) => makeStoreFactory(now() + budgetMs)({ ...options, env }) });
167
168
  return { ...idle, progressionCaptured: true, receipt: result.receipt };
168
169
  }
169
170