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.
- package/README.md +2 -2
- package/bin/install.mjs +162 -27
- package/config/model-router/catalog.template.json +126 -52
- package/config/model-router/claude-terminal-mod/.claude-plugin/plugin.json +1 -0
- package/config/model-router/claude-terminal-mod/README.md +22 -0
- package/config/model-router/claude-terminal-mod/hooks/hooks.json +1 -0
- package/config/model-router/claude-terminal-mod/hooks/policy.default.mjs +2 -0
- package/config/model-router/claude-terminal-mod/hooks/register.js +66 -0
- package/config/model-router/claude-terminal-mod/hooks/routing.js +55 -0
- package/config/model-router/claude-terminal-mod/hooks/runtime.js +2 -0
- package/config/model-router/claude-terminal-mod/tests/native.test.ts +86 -0
- package/config/model-router/policy.default.mjs +97 -75
- package/config/model-router/qualification-contract.json +124 -0
- package/config/model-router/routing-eval-cases.json +275 -0
- package/config/model-router/routing-policy.template.json +76 -0
- package/config/model-router/weekly-analyst-instruction.md +60 -0
- package/data/model-catalog.json +44 -49
- package/package.json +6 -3
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/mcp/managed-cli-interface.mjs +9 -4
- package/plugin/scripts/capability-claim-evidence.mjs +9 -2
- package/plugin/scripts/codex-hook-adapter.mjs +18 -9
- package/plugin/scripts/project-capture-queue.mjs +7 -1
- package/plugin/scripts/project-progression-hook.mjs +16 -7
- package/plugin/scripts/project-progression-outbox.mjs +96 -15
- package/plugin/scripts/project-progression-producer.mjs +12 -1
- package/plugin/scripts/project-progression-store.mjs +53 -1
- package/plugin/scripts/project-transition-hook.mjs +19 -8
- package/plugin/scripts/session-snapshot-hook.mjs +2 -1
- package/scripts/claude-terminal-mod.mjs +89 -0
- package/scripts/codex-hook-trust-reconcile.mjs +247 -0
- package/scripts/codex-routed.sh +3 -36
- package/scripts/goldie-weekly.sh +8 -64
- package/scripts/metaharness-router.mjs +7 -1
- package/scripts/model-analyst-sandbox.mjs +54 -0
- package/scripts/model-currency-evidence.mjs +139 -0
- package/scripts/model-currency.mjs +230 -0
- package/scripts/model-native-catalog.mjs +111 -0
- package/scripts/model-native-qualification.mjs +251 -0
- package/scripts/model-router-agent-hook.mjs +136 -0
- package/scripts/model-router-dispatch.mjs +161 -0
- package/scripts/model-router-engine.mjs +155 -104
- package/scripts/model-routing-eval.mjs +108 -0
- package/scripts/model-routing-gateway.mjs +453 -0
- package/scripts/model-routing-launchers.mjs +209 -0
- package/scripts/model-routing-policy-promotion.mjs +203 -0
- package/scripts/model-terminal-gateway.mjs +310 -0
- package/scripts/model-terminal-launchers.mjs +300 -0
- package/scripts/model-weekly-analyst.mjs +299 -0
- package/scripts/model-weekly-assessment.mjs +91 -0
- package/scripts/model-weekly-cycle.mjs +183 -0
- package/scripts/model-weekly-qualification.mjs +362 -0
- package/scripts/native-subscription-usage.mjs +57 -0
- package/scripts/release-qualification-contract.mjs +50 -1
- package/scripts/release-qualification.mjs +9 -6
- package/scripts/security-guidance-codex-compat.mjs +142 -0
- 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
|
-
"description": "One-command installer for RuvNet Brain
|
|
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
|
+
"version": "4.5.6",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Stuart Kerr"
|
|
7
7
|
},
|
|
@@ -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 (
|
|
258
|
-
return { outcome:
|
|
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 || (
|
|
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
|
|
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
|
|
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
|
-
'
|
|
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',
|
|
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
|
-
//
|
|
272
|
-
//
|
|
273
|
-
//
|
|
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
|
-
|
|
276
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
|
79
|
-
|
|
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
|
-
|
|
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: { ...
|
|
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
|
|
79
|
-
const
|
|
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
|
-
|
|
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 =
|
|
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,
|
|
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
|
|