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.
Files changed (43) hide show
  1. package/README.md +1 -1
  2. package/bin/install.mjs +24 -6
  3. package/config/model-router/claude-terminal-mod/.claude-plugin/plugin.json +1 -0
  4. package/config/model-router/claude-terminal-mod/README.md +22 -0
  5. package/config/model-router/claude-terminal-mod/hooks/hooks.json +1 -0
  6. package/config/model-router/claude-terminal-mod/hooks/policy.default.mjs +2 -0
  7. package/config/model-router/claude-terminal-mod/hooks/register.js +66 -0
  8. package/config/model-router/claude-terminal-mod/hooks/routing.js +55 -0
  9. package/config/model-router/claude-terminal-mod/hooks/runtime.js +2 -0
  10. package/config/model-router/claude-terminal-mod/tests/native.test.ts +86 -0
  11. package/config/model-router/policy.default.mjs +4 -1
  12. package/kb/forge-update.mjs +50 -9
  13. package/package.json +4 -3
  14. package/plugin/.claude-plugin/plugin.json +1 -1
  15. package/plugin/.codex-plugin/plugin.json +1 -1
  16. package/plugin/commands/configure.md +13 -1
  17. package/plugin/hooks/hook-contracts.json +1 -1
  18. package/plugin/mcp/managed-cli-generation.mjs +91 -0
  19. package/plugin/mcp/managed-cli-interface.mjs +45 -16
  20. package/plugin/mcp/server.mjs +3 -2
  21. package/plugin/scripts/capability-claim-evidence.mjs +9 -2
  22. package/plugin/scripts/continuation-gate.mjs +3 -1
  23. package/plugin/scripts/continuity-journal.mjs +11 -11
  24. package/plugin/scripts/hook-shim.mjs +2 -1
  25. package/plugin/scripts/kb-copy-proof.mjs +4 -3
  26. package/plugin/scripts/nightly-scheduler.mjs +6 -2
  27. package/plugin/scripts/project-capture-queue.mjs +23 -8
  28. package/plugin/scripts/project-progression-hook.mjs +16 -7
  29. package/plugin/scripts/project-progression-outbox.mjs +116 -19
  30. package/plugin/scripts/project-progression-producer.mjs +12 -1
  31. package/plugin/scripts/project-progression-session-start.mjs +10 -0
  32. package/plugin/scripts/project-progression-store.mjs +53 -1
  33. package/plugin/scripts/project-progression-suspension.mjs +60 -0
  34. package/plugin/scripts/project-transition-hook.mjs +44 -16
  35. package/plugin/scripts/session-snapshot-hook.mjs +16 -8
  36. package/plugin/scripts/turn-outcome-capture.mjs +7 -3
  37. package/scripts/claude-terminal-mod.mjs +89 -0
  38. package/scripts/model-routing-gateway.mjs +47 -14
  39. package/scripts/model-routing-launchers.mjs +36 -1
  40. package/scripts/model-terminal-gateway.mjs +310 -0
  41. package/scripts/model-terminal-launchers.mjs +300 -0
  42. package/scripts/release-qualification-contract.mjs +37 -6
  43. 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
- 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,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
- for (const record of this.records()) {
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
- else snapshots.set(record.eventKey, record);
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
- return [...snapshots.values()]
118
- .filter((record) => !quarantined.has(record.eventKey) && !committed.has(record.eventKey))
119
- .sort((left, right) => left.eventKey.localeCompare(right.eventKey))
120
- .map((record) => record.snapshot);
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: { ...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({
@@ -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 failure = event === 'PostToolUseFailure' || response.isError === true || input.is_error === true || (Number.isSafeInteger(exitCode) && exitCode !== 0);
79
- const interrupted = response.interrupted === true || response.signal === 'SIGINT';
80
+ 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
- if (snapshots.length && !restoreProjectProgression(snapshots, { expectedProjectIdentity: resolution.projectIdentity }).ok) {
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 = buildTransitionProgression({ resolution, observation: normalized.observation,
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 result = runProjectTransitionHook(payload.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd(), process.argv[2], { payload });
202
- if (result.state === 'pending') process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: process.argv[2], additionalContext: 'Project memory transition is pending; exact AgentDB readback was not verified at this boundary.' } }));
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, storeFactory: (options) => makeStoreFactory(now() + budgetMs)({ ...options, env }) });
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 result = runProjectTransitionHook(payload.cwd || projectDirectory(), process.argv[2], { payload });
302
- if (result.state === 'pending') process.stdout.write(JSON.stringify({ systemMessage: 'Project memory transition remains pending; exact readback was not verified.' }));
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 now = fs.statSync(file).size;
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())));