ruvnet-brain 4.5.5 → 4.5.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/bin/install.mjs +24 -6
- 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 +4 -1
- package/kb/forge-update.mjs +50 -9
- package/package.json +4 -3
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/commands/configure.md +13 -1
- package/plugin/hooks/hook-contracts.json +1 -1
- package/plugin/mcp/managed-cli-generation.mjs +91 -0
- package/plugin/mcp/managed-cli-interface.mjs +45 -16
- package/plugin/mcp/server.mjs +3 -2
- package/plugin/scripts/capability-claim-evidence.mjs +9 -2
- package/plugin/scripts/continuation-gate.mjs +3 -1
- package/plugin/scripts/continuity-journal.mjs +11 -11
- package/plugin/scripts/hook-shim.mjs +2 -1
- package/plugin/scripts/kb-copy-proof.mjs +4 -3
- package/plugin/scripts/nightly-scheduler.mjs +6 -2
- package/plugin/scripts/project-capture-queue.mjs +23 -8
- package/plugin/scripts/project-progression-hook.mjs +16 -7
- package/plugin/scripts/project-progression-outbox.mjs +116 -19
- package/plugin/scripts/project-progression-producer.mjs +12 -1
- package/plugin/scripts/project-progression-session-start.mjs +10 -0
- package/plugin/scripts/project-progression-store.mjs +53 -1
- package/plugin/scripts/project-progression-suspension.mjs +60 -0
- package/plugin/scripts/project-transition-hook.mjs +44 -16
- package/plugin/scripts/session-snapshot-hook.mjs +16 -8
- package/plugin/scripts/turn-outcome-capture.mjs +7 -3
- package/scripts/claude-terminal-mod.mjs +89 -0
- package/scripts/model-routing-gateway.mjs +47 -14
- package/scripts/model-routing-launchers.mjs +36 -1
- package/scripts/model-terminal-gateway.mjs +310 -0
- package/scripts/model-terminal-launchers.mjs +300 -0
- package/scripts/release-qualification-contract.mjs +37 -6
- package/scripts/release-qualification.mjs +9 -6
|
@@ -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,17 +156,33 @@ 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
|
+
let ordinal = 0;
|
|
161
|
+
for (const record of readJsonl(this.path)) {
|
|
162
|
+
ordinal += 1;
|
|
96
163
|
requireIdentity(record?.eventKey, 'outbox eventKey');
|
|
97
164
|
requireIdentity(record?.payloadDigest, 'outbox payloadDigest');
|
|
98
165
|
if (record.type === 'snapshot') {
|
|
99
166
|
const prior = snapshots.get(record.eventKey);
|
|
100
167
|
if (prior && prior.payloadDigest !== record.payloadDigest) quarantined.set(record.eventKey, 'outbox event key collision');
|
|
101
|
-
|
|
168
|
+
// Retain identity/recovery metadata, never every historical snapshot's full payload.
|
|
169
|
+
// A second incremental scan materializes only the unresolved payloads selected here.
|
|
170
|
+
else snapshots.set(record.eventKey, { eventKey: record.eventKey, payloadDigest: record.payloadDigest,
|
|
171
|
+
ordinal, recoveryDiagnostics: record.snapshot?.recoveryDiagnostics && {
|
|
172
|
+
originalEventKey: record.snapshot.recoveryDiagnostics.originalEventKey,
|
|
173
|
+
frozenPayloadDigest: record.snapshot.recoveryDiagnostics.frozenPayloadDigest,
|
|
174
|
+
}, snapshotDigest: record.snapshot?.payloadDigest });
|
|
102
175
|
} else if (record.type === 'commit') {
|
|
103
176
|
const prior = committed.get(record.eventKey);
|
|
104
177
|
if (prior && prior !== record.payloadDigest) quarantined.set(record.eventKey, 'outbox commit collision');
|
|
105
178
|
committed.set(record.eventKey, record.payloadDigest);
|
|
179
|
+
} else if (record.type === 'recovery') {
|
|
180
|
+
requireIdentity(record.recoveryEventKey, 'recovery event key');
|
|
181
|
+
requireIdentity(record.recoveryPayloadDigest, 'recovery payload digest');
|
|
182
|
+
if (record.eventKey === record.recoveryEventKey || record.readbackDigest !== record.recoveryPayloadDigest) {
|
|
183
|
+
throw new Error('unverified recovery disposition');
|
|
184
|
+
}
|
|
185
|
+
recoveries.push(record);
|
|
106
186
|
} else {
|
|
107
187
|
throw new Error('unsupported outbox record');
|
|
108
188
|
}
|
|
@@ -113,11 +193,28 @@ export class ProgressionOutbox {
|
|
|
113
193
|
quarantined.set(record.eventKey, 'outbox commit digest mismatch');
|
|
114
194
|
}
|
|
115
195
|
}
|
|
196
|
+
const recovered = new Set(recoveries.filter((record) => {
|
|
197
|
+
const target = snapshots.get(record.recoveryEventKey);
|
|
198
|
+
return !quarantined.has(record.recoveryEventKey)
|
|
199
|
+
&& committed.get(record.recoveryEventKey) === record.recoveryPayloadDigest
|
|
200
|
+
&& target?.snapshotDigest === record.recoveryPayloadDigest
|
|
201
|
+
&& target.recoveryDiagnostics?.originalEventKey === record.eventKey
|
|
202
|
+
&& target.recoveryDiagnostics?.frozenPayloadDigest === record.payloadDigest;
|
|
203
|
+
}).map((record) => `${record.eventKey}:${record.payloadDigest}`));
|
|
116
204
|
this.quarantine = [...quarantined].map(([eventKey, reason]) => ({ eventKey, reason }));
|
|
117
|
-
|
|
118
|
-
.filter((record) => !quarantined.has(record.eventKey) && !committed.has(record.eventKey)
|
|
119
|
-
|
|
120
|
-
.map((record) => record.
|
|
205
|
+
const pending = new Map([...snapshots.values()]
|
|
206
|
+
.filter((record) => !quarantined.has(record.eventKey) && !committed.has(record.eventKey)
|
|
207
|
+
&& !recovered.has(`${record.eventKey}:${record.payloadDigest}`))
|
|
208
|
+
.map((record) => [record.ordinal, record.eventKey]));
|
|
209
|
+
const payloads = [];
|
|
210
|
+
ordinal = 0;
|
|
211
|
+
if (pending.size) {
|
|
212
|
+
for (const record of readJsonl(this.path)) {
|
|
213
|
+
ordinal += 1;
|
|
214
|
+
if (pending.has(ordinal)) payloads.push({ eventKey: pending.get(ordinal), snapshot: record.snapshot });
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return payloads.sort((left, right) => left.eventKey.localeCompare(right.eventKey)).map((record) => record.snapshot);
|
|
121
218
|
}
|
|
122
219
|
|
|
123
220
|
/** Keys pendingSnapshots() refused to replay because their records disagree, with the reason. */
|
|
@@ -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({
|
|
@@ -8,6 +8,7 @@ import { withProgressionReader } from './project-progression-reader.mjs';
|
|
|
8
8
|
import { drainCaptureQueue, queuedWork } from './session-snapshot-hook.mjs';
|
|
9
9
|
import { replayTurnQueue } from './turn-outcome-capture.mjs';
|
|
10
10
|
import { STAGE_BUDGETS_MS } from './session-start-budget.mjs';
|
|
11
|
+
import { operatorProgressionSuspension } from './project-progression-suspension.mjs';
|
|
11
12
|
|
|
12
13
|
const PROGRESSION_NAMESPACE = 'project-progression';
|
|
13
14
|
|
|
@@ -191,6 +192,15 @@ export function restoreProgressionForSession({
|
|
|
191
192
|
},
|
|
192
193
|
} = {}) {
|
|
193
194
|
const projectDir = env.CLAUDE_PROJECT_DIR || cwd;
|
|
195
|
+
try {
|
|
196
|
+
if (operatorProgressionSuspension(env)) {
|
|
197
|
+
// Turn recording is independent: suspension must not strand ordinary AgentDB capture.
|
|
198
|
+
const turns = replayTurnQueue({ projectDir, env, home: env.HOME || os.homedir(), synchronous: true,
|
|
199
|
+
deadlineMs: Date.now() + deadlineMs });
|
|
200
|
+
return { status: 'unavailable', reason: 'operator-suspended', severity: 'info', pendingTurns: turns.pending,
|
|
201
|
+
context: '[RuvNet Brain — PROJECT CONTINUITY UNAVAILABLE]\nAutomatic progression capture, replay and restore are operator-suspended. Evidence and queues are preserved. Ordinary AgentDB memory and explicit checkpoints remain available; no project progression was restored.' };
|
|
202
|
+
}
|
|
203
|
+
} catch { return unknown('restore-failed'); }
|
|
194
204
|
let resolution;
|
|
195
205
|
try {
|
|
196
206
|
resolution = resolveProjectStore({ projectDir });
|
|
@@ -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()) {
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Operator containment only (#390 B). No store, checkpoint, event, turn or search is disabled here.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { randomUUID } from 'node:crypto';
|
|
7
|
+
|
|
8
|
+
const suspendedResults = new WeakSet();
|
|
9
|
+
export function progressionSuspensionFile(env = process.env) {
|
|
10
|
+
return path.join(env.RUVNET_BRAIN_STATE_DIR || path.join(env.HOME || os.homedir(), '.config', 'ruvnet-brain'), 'brain-progression-suspension');
|
|
11
|
+
}
|
|
12
|
+
export function operatorProgressionSuspension(env = process.env) {
|
|
13
|
+
if (env.RUVNET_BRAIN_PROGRESSION_SUSPENDED === '1') return { source: 'environment' };
|
|
14
|
+
if (env.RUVNET_BRAIN_PROGRESSION_SUSPENDED && env.RUVNET_BRAIN_PROGRESSION_SUSPENDED !== '0') {
|
|
15
|
+
throw new Error('invalid automatic progression suspension environment setting');
|
|
16
|
+
}
|
|
17
|
+
let value;
|
|
18
|
+
try { value = JSON.parse(fs.readFileSync(progressionSuspensionFile(env), 'utf8')); }
|
|
19
|
+
catch (error) { if (error.code === 'ENOENT') return null; throw new Error('automatic progression suspension policy unreadable or invalid'); }
|
|
20
|
+
if (value?.schemaVersion !== 1 || typeof value.suspended !== 'boolean') {
|
|
21
|
+
throw new Error('automatic progression suspension policy unreadable or invalid');
|
|
22
|
+
}
|
|
23
|
+
return value.suspended ? { source: 'operator-state' } : null;
|
|
24
|
+
}
|
|
25
|
+
export function automaticProgressionSuspensionResult(env = process.env, fields = {}) {
|
|
26
|
+
const control = operatorProgressionSuspension(env);
|
|
27
|
+
if (!control) return null;
|
|
28
|
+
const result = Object.freeze({ ...fields, progressionSuspended: true, progressionCaptured: false,
|
|
29
|
+
receipt: null, skipped: 'operator suspended automatic project progression', suspensionSource: control.source });
|
|
30
|
+
suspendedResults.add(result);
|
|
31
|
+
return result;
|
|
32
|
+
}
|
|
33
|
+
// A look-alike public flag is never authority to bypass an unexpected capture failure.
|
|
34
|
+
export const isAutomaticProgressionSuspension = (result) => Boolean(result && suspendedResults.has(result));
|
|
35
|
+
|
|
36
|
+
/** Explicit operator configuration: replacing this control file never touches evidence or queues. */
|
|
37
|
+
export function setOperatorProgressionSuspension(suspended, { env = process.env } = {}) {
|
|
38
|
+
if (typeof suspended !== 'boolean') throw new TypeError('suspended must be a boolean');
|
|
39
|
+
const file = progressionSuspensionFile(env);
|
|
40
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
41
|
+
const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`;
|
|
42
|
+
try {
|
|
43
|
+
fs.writeFileSync(temporary, JSON.stringify({ schemaVersion: 1, suspended }), { flag: 'wx', mode: 0o600 });
|
|
44
|
+
fs.renameSync(temporary, file);
|
|
45
|
+
} finally { try { fs.unlinkSync(temporary); } catch { /* renamed or not created */ } }
|
|
46
|
+
return operatorProgressionSuspension(env);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
50
|
+
try {
|
|
51
|
+
const argv = process.argv.slice(2);
|
|
52
|
+
if (argv.length !== 1 || !['--suspend', '--resume', '--status'].includes(argv[0])) {
|
|
53
|
+
throw new Error('usage: project-progression-suspension.mjs --suspend | --resume | --status');
|
|
54
|
+
}
|
|
55
|
+
if (argv[0] !== '--status') setOperatorProgressionSuspension(argv[0] === '--suspend');
|
|
56
|
+
const control = operatorProgressionSuspension();
|
|
57
|
+
console.log(JSON.stringify({ automaticProgression: control ? 'operator-suspended' : 'enabled', source: control?.source || null,
|
|
58
|
+
evidencePreserved: true, explicitCheckpointsAvailable: true }));
|
|
59
|
+
} catch (error) { console.error(error.message); process.exitCode = 1; }
|
|
60
|
+
}
|
|
@@ -5,9 +5,11 @@ import crypto from 'node:crypto';
|
|
|
5
5
|
import os from 'node:os';
|
|
6
6
|
import { developmentHooksSuspended } from './development-maintenance.mjs';
|
|
7
7
|
import { resolveTurnDb } from './turn-outcome-capture.mjs';
|
|
8
|
+
import { automaticProgressionSuspensionResult } from './project-progression-suspension.mjs';
|
|
8
9
|
import { execFileSync } from 'node:child_process';
|
|
9
10
|
import { fileURLToPath } from 'node:url';
|
|
10
11
|
import { redactText } from './continuity-events.mjs';
|
|
12
|
+
import { conditionNotice } from './continuity-journal.mjs';
|
|
11
13
|
import { normalizeHostEvent } from './hook-input.mjs';
|
|
12
14
|
import { resolveProjectStore } from './project-store-resolver.mjs';
|
|
13
15
|
import { withProgressionReader } from './project-progression-reader.mjs';
|
|
@@ -75,20 +77,25 @@ export function normalizeTransition(payload, event, { now = () => new Date().toI
|
|
|
75
77
|
const toolInput = input.tool_input ?? {};
|
|
76
78
|
const response = input.tool_response && typeof input.tool_response === 'object' ? input.tool_response : {};
|
|
77
79
|
const exitCode = [response.exit_code, response.exitCode, response.status].find(Number.isSafeInteger);
|
|
78
|
-
const
|
|
79
|
-
const
|
|
80
|
+
const error = typeof response.error === 'string' && response.error ? redactText(response.error).slice(0, 4096) : undefined;
|
|
81
|
+
const signal = typeof response.signal === 'string' && response.signal ? redactText(response.signal).slice(0, 100) : undefined;
|
|
82
|
+
const failure = Boolean(error) || event === 'PostToolUseFailure' || response.isError === true || input.is_error === true || (Number.isSafeInteger(exitCode) && exitCode !== 0);
|
|
83
|
+
const interrupted = response.interrupted === true || Boolean(signal);
|
|
84
|
+
const declaredOutcome = ['success', 'failure', 'interrupted', 'unknown'].includes(response.outcome) ? response.outcome : null;
|
|
80
85
|
// Measured native Claude Bash PostToolUse has no exit code. Its completion envelope
|
|
81
86
|
// is host-reported success, never an inferred exit-code zero. Other hosts stay unknown.
|
|
82
87
|
const claudeBashCompletion = host === 'claude' && input.tool_name === 'Bash' && event === 'PostToolUse'
|
|
83
88
|
&& typeof response.stdout === 'string' && typeof response.stderr === 'string'
|
|
84
89
|
&& response.interrupted === false && typeof response.isImage === 'boolean'
|
|
85
90
|
&& 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';
|
|
91
|
+
const outcome = event === 'PreToolUse' ? 'pending' : error ? 'failure' : interrupted ? 'interrupted' : failure ? 'failure'
|
|
92
|
+
: declaredOutcome || (Number.isSafeInteger(exitCode) || response.success === true || response.ok === true || claudeBashCompletion ? 'success' : 'unknown');
|
|
88
93
|
return { ...common, kind: 'tool-observation', tool: String(input.tool_name).split('__').at(-1).slice(0, 100),
|
|
89
94
|
intent: semanticIntent(toolInput.description ?? toolInput.command ?? toolInput.cmd ?? toolInput.file_path), outcome,
|
|
90
95
|
...(outcome === 'success' && claudeBashCompletion ? { outcomeEvidence: 'claude-bash-completion' } : {}),
|
|
91
|
-
...(event !== 'PreToolUse' && Number.isSafeInteger(exitCode) ? { exitCode } : {}
|
|
96
|
+
...(event !== 'PreToolUse' && Number.isSafeInteger(exitCode) ? { exitCode } : event !== 'PreToolUse' && (response.exit_code === null || response.exitCode === null) ? { exitCode: null } : {}),
|
|
97
|
+
...(event !== 'PreToolUse' && error ? { error } : {}),
|
|
98
|
+
...(event !== 'PreToolUse' && signal ? { signal } : {}) };
|
|
92
99
|
}
|
|
93
100
|
|
|
94
101
|
export function readTransitionHistory(resolution, { deadlineAt = Infinity } = {}) {
|
|
@@ -107,6 +114,11 @@ export function readTransitionHistory(resolution, { deadlineAt = Infinity } = {}
|
|
|
107
114
|
export function buildTransitionProgression({ resolution, observation, snapshots = [], sessionIdentity, host, sourceIdentity: originalSourceIdentity }) {
|
|
108
115
|
const restored = restoreProjectProgression(snapshots, { expectedProjectIdentity: resolution.projectIdentity });
|
|
109
116
|
if (snapshots.length && !restored.ok) throw new Error('nonempty transition journal has no coherent ancestry');
|
|
117
|
+
return buildRestoredTransitionProgression({ resolution, observation, snapshots, sessionIdentity, host, sourceIdentity: originalSourceIdentity }, restored);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Only the validated result computed in this module can reach this private builder.
|
|
121
|
+
function buildRestoredTransitionProgression({ resolution, observation, snapshots, sessionIdentity, host, sourceIdentity: originalSourceIdentity }, restored) {
|
|
110
122
|
const heads = snapshots.filter((snapshot) => restored.heads.includes(snapshot.eventKey));
|
|
111
123
|
const prior = restored.state;
|
|
112
124
|
const empty = Object.fromEntries(['plan', 'completed', 'inProgress', 'blockers', 'failures', 'decisions', 'changedFiles', 'commands', 'proofArtifacts', 'untested', 'resumeConflicts'].map((key) => [key, []]));
|
|
@@ -138,14 +150,17 @@ export function observeTransitionSource(resolution, projectDir = resolution.chec
|
|
|
138
150
|
|
|
139
151
|
/** Called only by the fenced queue drainer; current heads merge, original observations stay fixed. */
|
|
140
152
|
export function captureNormalizedTransition(job, { readHistory = readTransitionHistory, capture = runSessionSnapshotHook,
|
|
141
|
-
budgetMs = 6500, makeStoreFactory, now = Date.now } = {}) {
|
|
153
|
+
budgetMs = 6500, makeStoreFactory, now = Date.now, env = process.env } = {}) {
|
|
154
|
+
const suspended = automaticProgressionSuspensionResult(env);
|
|
155
|
+
if (suspended) return suspended;
|
|
142
156
|
const deadlineAt = now() + budgetMs;
|
|
143
157
|
const resolution = resolveProjectStore({ projectDir: job.originProjectDir, gitTimeoutMs: Math.max(1, Math.min(500, budgetMs)) });
|
|
144
158
|
const normalized = job.payload.normalizedTransition;
|
|
145
159
|
if (!normalized || normalized.observation?.authoritative !== false || !normalized.observation?.id
|
|
146
160
|
|| normalized.sourceIdentity?.checkoutPath !== resolution.checkoutRoot) throw new Error('invalid normalized transition binding');
|
|
147
161
|
const snapshots = readHistory(resolution, { deadlineAt });
|
|
148
|
-
|
|
162
|
+
const restored = restoreProjectProgression(snapshots, { expectedProjectIdentity: resolution.projectIdentity });
|
|
163
|
+
if (snapshots.length && !restored.ok) {
|
|
149
164
|
throw new Error('nonempty transition journal has no coherent ancestry');
|
|
150
165
|
}
|
|
151
166
|
// A failed first store already fsynced a snapshot to the outbox. If startup/replay committed
|
|
@@ -159,10 +174,10 @@ export function captureNormalizedTransition(job, { readHistory = readTransitionH
|
|
|
159
174
|
if (committed) return { progressionCaptured: true, eventId: normalized.observation.id,
|
|
160
175
|
receipt: { eventKey: committed.eventKey, payloadDigest: committed.payloadDigest,
|
|
161
176
|
readbackDigest: committed.payloadDigest, readPath: 'canonical progression reader' } };
|
|
162
|
-
const progression =
|
|
177
|
+
const progression = buildRestoredTransitionProgression({ resolution, observation: normalized.observation,
|
|
163
178
|
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()),
|
|
179
|
+
sourceIdentity: normalized.sourceIdentity }, restored);
|
|
180
|
+
const result = capture(job.originProjectDir, job.event, { host: job.host, env, budgetMs: Math.max(0, deadlineAt - now()),
|
|
166
181
|
...(makeStoreFactory ? { makeStoreFactory } : {}), writeMetadata: false,
|
|
167
182
|
rawInput: JSON.stringify({ session_id: job.payload.session_id, hook_event_name: job.event, projectProgression: progression }),
|
|
168
183
|
captureTurn: () => ({ recorded: false, skipped: 'transition boundary' }),
|
|
@@ -173,6 +188,8 @@ export function captureNormalizedTransition(job, { readHistory = readTransitionH
|
|
|
173
188
|
export function runProjectTransitionHook(projectDir, event, { payload = {}, host = process.env.RUVNET_HOOK_HOST || 'claude',
|
|
174
189
|
readHistory = readTransitionHistory, capture = runSessionSnapshotHook, env = process.env } = {}) {
|
|
175
190
|
if (developmentHooksSuspended(projectDir)) return { state: 'skipped', reason: 'development hooks suspended' };
|
|
191
|
+
const suspended = automaticProgressionSuspensionResult(env, { state: 'suspended', reason: 'automatic project progression is operator-suspended' });
|
|
192
|
+
if (suspended) return suspended;
|
|
176
193
|
const observation = normalizeTransition(payload, event, { host });
|
|
177
194
|
if (observation.skipped) return { state: 'skipped', reason: observation.skipped };
|
|
178
195
|
const brainHome = env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache', 'ruvnet-brain');
|
|
@@ -183,22 +200,33 @@ export function runProjectTransitionHook(projectDir, event, { payload = {}, host
|
|
|
183
200
|
const transportEvent = event === 'PostToolUseFailure' ? 'PostToolUse' : event;
|
|
184
201
|
// Fsync the selected observation BEFORE any history enumeration/merge. A deadline, corruption,
|
|
185
202
|
// 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,
|
|
203
|
+
const queued = queueCapture({ projectDir: resolution.projectRoot, originProjectDir: projectDir, env, event: transportEvent, host,
|
|
187
204
|
payload: { session_id: payload.session_id, hook_event_name: transportEvent,
|
|
188
205
|
normalizedTransition: { observation, sourceIdentity: observeTransitionSource(resolution, projectDir) } } });
|
|
189
206
|
if (!queued) return { state: 'degraded', reason: 'normalized observation queue unwritable', eventId: observation.id };
|
|
190
207
|
let result = null;
|
|
191
|
-
runOutboxReplay({ projectDir: resolution.projectRoot, budgetMs: Math.min(6500, effectiveBudgetMs(env)),
|
|
192
|
-
captureNormalized: (job, options) => captureNormalizedTransition(job, { ...options, readHistory, capture }),
|
|
208
|
+
runOutboxReplay({ projectDir: resolution.projectRoot, env, budgetMs: Math.min(6500, effectiveBudgetMs(env)),
|
|
209
|
+
captureNormalized: (job, options) => captureNormalizedTransition(job, { ...options, env, readHistory, capture }),
|
|
193
210
|
onCaptured: (captured) => { if (captured?.eventId === observation.id) result = captured; } });
|
|
194
|
-
if (!result?.receipt) replayOutboxDetached({ projectDir: resolution.projectRoot });
|
|
211
|
+
if (!result?.receipt) replayOutboxDetached({ projectDir: resolution.projectRoot, env });
|
|
195
212
|
return { state: result?.progressionCaptured && result.receipt ? 'committed' : 'pending', eventId: observation.id, result };
|
|
196
213
|
}
|
|
197
214
|
|
|
215
|
+
/** One pending-readback condition across prompt/tool boundaries and both CLI entrypoints. */
|
|
216
|
+
export function transitionPendingNotice(projectDir, payload, message) {
|
|
217
|
+
const { projectRoot } = resolveProjectStore({ projectDir, gitTimeoutMs: 500 });
|
|
218
|
+
return conditionNotice({ swarm: path.join(projectRoot, '.swarm'),
|
|
219
|
+
session: normalizeHostEvent(payload)?.session_id, condition: 'project-transition-pending-readback', message });
|
|
220
|
+
}
|
|
221
|
+
|
|
198
222
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
199
223
|
try {
|
|
200
224
|
const payload = JSON.parse(fs.readFileSync(0, 'utf8') || '{}');
|
|
201
|
-
const
|
|
202
|
-
|
|
225
|
+
const projectDir = payload.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
226
|
+
const result = runProjectTransitionHook(projectDir, process.argv[2], { payload });
|
|
227
|
+
if (result.state === 'pending') {
|
|
228
|
+
const message = transitionPendingNotice(projectDir, payload, 'Project memory transition is pending; exact AgentDB readback was not verified at this boundary.');
|
|
229
|
+
if (message) process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: process.argv[2], additionalContext: message } }));
|
|
230
|
+
}
|
|
203
231
|
} catch { process.stdout.write(JSON.stringify({ systemMessage: 'Project memory transition capture degraded; exact readback was not verified.' })); }
|
|
204
232
|
}
|
|
@@ -17,6 +17,7 @@ import { ProjectProgressionStore } from './project-progression-store.mjs';
|
|
|
17
17
|
import { resolveProjectStore } from './project-store-resolver.mjs';
|
|
18
18
|
import { resolveTurnDb, captureTurnOutcome } from './turn-outcome-capture.mjs';
|
|
19
19
|
import { captureContinuityEvents, stopNotice } from './continuity-journal.mjs';
|
|
20
|
+
import { automaticProgressionSuspensionResult } from './project-progression-suspension.mjs';
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
23
|
* The capture boundary's whole budget. hooks.json declares 10s; this keeps the internal work well
|
|
@@ -158,12 +159,15 @@ export function runSessionSnapshotHook(projectDir, event, {
|
|
|
158
159
|
continuity = { recorded: 0, skipped: `continuity capture failed: ${error.message}` };
|
|
159
160
|
}
|
|
160
161
|
const idle = { metadataWritten, progressionCaptured: false, receipt: null, turn, continuity };
|
|
162
|
+
const operatorSuspended = automaticProgressionSuspensionResult(env, idle);
|
|
163
|
+
if (operatorSuspended) return operatorSuspended;
|
|
161
164
|
|
|
162
165
|
if (hasProjectProgression(payload)) {
|
|
163
166
|
if (payload.hook_event_name !== event) {
|
|
164
167
|
throw new Error(`progression boundary mismatch: expected ${event}, received ${payload.hook_event_name}`);
|
|
165
168
|
}
|
|
166
|
-
const result = captureProgression({ host, payload, projectDir,
|
|
169
|
+
const result = captureProgression({ host, payload, projectDir, recoverFrozen: Boolean(ordered),
|
|
170
|
+
canCommit: () => Boolean(ordered) && refreshReplayLock(resolveProjectStore({ projectDir }).projectRoot, ordered), storeFactory: (options) => makeStoreFactory(now() + budgetMs)({ ...options, env }) });
|
|
167
171
|
return { ...idle, progressionCaptured: true, receipt: result.receipt };
|
|
168
172
|
}
|
|
169
173
|
|
|
@@ -205,7 +209,7 @@ export function runSessionSnapshotHook(projectDir, event, {
|
|
|
205
209
|
try { frozen = produce({ resolution, projectDir, payload, host, trigger: event }); } catch { frozen = null; }
|
|
206
210
|
const queued = frozen?.projectProgression ? queueCapture({ projectDir: root, originProjectDir: projectDir, env, event, host,
|
|
207
211
|
payload: { session_id: payload.session_id, hook_event_name: event, projectProgression: frozen.projectProgression } }) : null;
|
|
208
|
-
const handed = queued ? spawnReplay({ projectDir: root, token }) : false;
|
|
212
|
+
const handed = queued ? spawnReplay({ projectDir: root, token, env }) : false;
|
|
209
213
|
if (!handed && token && token !== ordered) releaseReplayLock(root, token);
|
|
210
214
|
return { ...idle, replayed: 0, progressionCaptured: false, deferredToReplayer: Boolean(queued),
|
|
211
215
|
replaySkipped: `${why}; this capture ${queued ? 'queued behind it' : 'NOT queued (queue unwritable)'}`
|
|
@@ -261,7 +265,7 @@ export function runSessionSnapshotHook(projectDir, event, {
|
|
|
261
265
|
// NOT LOST — DEFERRED. capture() fsyncs the snapshot to the durable outbox BEFORE it writes to
|
|
262
266
|
// the store, so a budget overrun leaves the evidence on disk. On a short budget nothing later in
|
|
263
267
|
// this process can settle it, so the lock goes straight to a detached worker.
|
|
264
|
-
handedLock = !ordered && budgetMs < REPLAY_MIN_BUDGET_MS && pendingCount() > 0 && spawnReplay({ projectDir: root, token });
|
|
268
|
+
handedLock = !ordered && budgetMs < REPLAY_MIN_BUDGET_MS && pendingCount() > 0 && spawnReplay({ projectDir: root, token, env });
|
|
265
269
|
return { ...idle, replayed, skipped: `capture deferred: ${error.message}`,
|
|
266
270
|
...(handedLock ? { replaySkipped: 'deferred capture handed to a detached worker' } : {}) };
|
|
267
271
|
}
|
|
@@ -280,9 +284,9 @@ export function runSessionSnapshotHook(projectDir, event, {
|
|
|
280
284
|
// simultaneous SessionEnds lost the second one's final state (4.4.1). So: queued work → hand THIS
|
|
281
285
|
// lock to a worker; and re-check after releasing, for a boundary that queued in between.
|
|
282
286
|
if (!ordered && !handedLock) {
|
|
283
|
-
if (!(queuedWork(root) && spawnReplay({ projectDir: root, token }))) {
|
|
287
|
+
if (!(queuedWork(root) && spawnReplay({ projectDir: root, token, env }))) {
|
|
284
288
|
releaseReplayLock(root, token);
|
|
285
|
-
if (queuedWork(root)) spawnReplay({ projectDir: root });
|
|
289
|
+
if (queuedWork(root)) spawnReplay({ projectDir: root, env });
|
|
286
290
|
}
|
|
287
291
|
}
|
|
288
292
|
}
|
|
@@ -296,10 +300,14 @@ if (process.argv[1] && path.resolve(process.argv[1]).endsWith('session-snapshot-
|
|
|
296
300
|
// Finish evaluating this module before importing its transition consumer.
|
|
297
301
|
void (async () => {
|
|
298
302
|
try {
|
|
299
|
-
const { runProjectTransitionHook } = await import('./project-transition-hook.mjs');
|
|
303
|
+
const { runProjectTransitionHook, transitionPendingNotice } = await import('./project-transition-hook.mjs');
|
|
300
304
|
const payload = JSON.parse(fs.readFileSync(0, 'utf8') || '{}');
|
|
301
|
-
const
|
|
302
|
-
|
|
305
|
+
const projectDir = payload.cwd || projectDirectory();
|
|
306
|
+
const result = runProjectTransitionHook(projectDir, process.argv[2], { payload });
|
|
307
|
+
if (result.state === 'pending') {
|
|
308
|
+
const message = transitionPendingNotice(projectDir, payload, 'Project memory transition remains pending; exact readback was not verified.');
|
|
309
|
+
if (message) process.stdout.write(JSON.stringify({ systemMessage: message }));
|
|
310
|
+
}
|
|
303
311
|
} catch { process.stdout.write(JSON.stringify({ systemMessage: 'Project memory transition capture degraded; exact readback was not verified.' })); }
|
|
304
312
|
})();
|
|
305
313
|
} else if (process.argv[1] && path.resolve(process.argv[1]).endsWith('session-snapshot-hook.mjs')) {
|
|
@@ -110,10 +110,12 @@ export function claudeTurn(lines) {
|
|
|
110
110
|
}
|
|
111
111
|
|
|
112
112
|
function readTail(file, bytes = TRANSCRIPT_TAIL_BYTES) {
|
|
113
|
-
const size = fs.statSync(file).size;
|
|
114
|
-
const offset = Math.max(0, size - bytes);
|
|
115
113
|
const handle = fs.openSync(file, 'r');
|
|
116
114
|
try {
|
|
115
|
+
const stat = fs.fstatSync(handle);
|
|
116
|
+
if (!stat.isFile()) throw new Error('Transcript must be a regular file');
|
|
117
|
+
const size = stat.size;
|
|
118
|
+
const offset = Math.max(0, size - bytes);
|
|
117
119
|
const buf = Buffer.alloc(size - offset);
|
|
118
120
|
fs.readSync(handle, buf, 0, buf.length, offset);
|
|
119
121
|
const lines = buf.toString('utf8').split(/\r?\n/);
|
|
@@ -128,7 +130,9 @@ export function readSettledTranscript(file, { stableMs = 400, maxMs = 2000, slee
|
|
|
128
130
|
const deadline = Date.now() + Math.max(0, maxMs);
|
|
129
131
|
let size = -1;
|
|
130
132
|
for (;;) {
|
|
131
|
-
const
|
|
133
|
+
const stat = fs.statSync(file);
|
|
134
|
+
if (!stat.isFile()) throw new Error('Transcript must be a regular file');
|
|
135
|
+
const now = stat.size;
|
|
132
136
|
if (now === size || Date.now() >= deadline) break;
|
|
133
137
|
size = now;
|
|
134
138
|
pause(Math.min(stableMs, Math.max(0, deadline - Date.now())));
|