ruvnet-brain 4.3.21 → 4.3.26

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 (143) hide show
  1. package/README.md +5 -5
  2. package/bin/install.mjs +275 -60
  3. package/console/app.js +189 -9
  4. package/console/index.html +70 -24
  5. package/console/scope.css +137 -0
  6. package/console/scope.html +144 -0
  7. package/console/scope.js +209 -0
  8. package/console/style.css +26 -0
  9. package/console/tips.html +1 -0
  10. package/kb/corpus-release-identity.mjs +239 -0
  11. package/kb/update-storage-transaction.mjs +20 -3
  12. package/package.json +9 -2
  13. package/plugin/.claude-plugin/plugin.json +2 -2
  14. package/plugin/.codex-plugin/plugin.json +1 -1
  15. package/plugin/commands/checkpoint.md +61 -0
  16. package/plugin/hooks/codex-hooks.json +64 -1
  17. package/plugin/hooks/hook-contracts.json +299 -6
  18. package/plugin/hooks/hooks.json +81 -1
  19. package/plugin/mcp/server.mjs +23 -0
  20. package/plugin/scripts/advocacy-catalog.mjs +245 -0
  21. package/plugin/scripts/advocacy-route.mjs +460 -0
  22. package/plugin/scripts/continuation-gate.mjs +25 -2
  23. package/plugin/scripts/continuation-objective.mjs +7 -1
  24. package/plugin/scripts/continuity-hook-policy.mjs +190 -15
  25. package/plugin/scripts/coverage-integrity.mjs +7 -0
  26. package/plugin/scripts/gates.mjs +113 -10
  27. package/plugin/scripts/grounding-turn-gate.mjs +167 -0
  28. package/plugin/scripts/grounding-turn-mark.mjs +91 -0
  29. package/plugin/scripts/hook-shim.mjs +14 -0
  30. package/plugin/scripts/nightly-scheduler.mjs +37 -4
  31. package/plugin/scripts/project-progression-checkpoint.mjs +145 -0
  32. package/plugin/scripts/project-progression-contract.mjs +16 -0
  33. package/plugin/scripts/project-progression-hook.mjs +3 -0
  34. package/plugin/scripts/project-progression-producer.mjs +252 -0
  35. package/plugin/scripts/project-progression-reader.mjs +271 -0
  36. package/plugin/scripts/project-progression-session-start.mjs +93 -16
  37. package/plugin/scripts/project-progression-sources.mjs +220 -0
  38. package/plugin/scripts/project-progression-store.mjs +106 -13
  39. package/plugin/scripts/ruvnet-gate1-pattern.mjs +29 -0
  40. package/plugin/scripts/session-snapshot-hook.mjs +115 -7
  41. package/plugin/scripts/session-start-budget.mjs +59 -0
  42. package/plugin/scripts/session-start-core.mjs +234 -457
  43. package/plugin/scripts/session-start-fsutil.mjs +61 -0
  44. package/plugin/scripts/session-start-health.mjs +64 -0
  45. package/plugin/scripts/session-start-hook-description.mjs +45 -0
  46. package/plugin/scripts/session-start-issue-alert.mjs +77 -0
  47. package/plugin/scripts/session-start-repo-identity.mjs +54 -0
  48. package/plugin/scripts/session-start-signals.mjs +73 -0
  49. package/plugin/scripts/session-start-trace.mjs +86 -0
  50. package/plugin/scripts/session-start-update-plane.mjs +104 -0
  51. package/plugin/scripts/unprompted-runtime.mjs +32 -2
  52. package/plugin/skills/ruvnet-brain/PLAYBOOK.md +26 -2
  53. package/plugin/skills/ruvnet-brain/SKILL.md +67 -2
  54. package/scripts/adr-072-completion.mjs +1 -1
  55. package/scripts/agentdb-fleet-doctor.mjs +5 -1
  56. package/scripts/approved-runtime.mjs +197 -0
  57. package/scripts/brain-novice-50.mjs +16 -1
  58. package/scripts/brain-score.mjs +23 -5
  59. package/scripts/build-bundle.mjs +971 -530
  60. package/scripts/build-concepts.mjs +36 -116
  61. package/scripts/console-engine.test.mjs +8 -7
  62. package/scripts/console-runtime-identity.mjs +4 -0
  63. package/scripts/corpus-aggregates.mjs +94 -77
  64. package/scripts/corpus-candidate.mjs +475 -222
  65. package/scripts/corpus-next-seed.mjs +225 -0
  66. package/scripts/corpus-promotion.mjs +58 -0
  67. package/scripts/corpus-reconcile.mjs +411 -105
  68. package/scripts/doc-currency.mjs +16 -1
  69. package/scripts/dual-host-deliberation.mjs +25 -2
  70. package/scripts/dual-host-suggest.mjs +17 -1
  71. package/scripts/falsify.mjs +13 -3
  72. package/scripts/gist-receipts.mjs +482 -87
  73. package/scripts/github-health-watch.mjs +12 -2
  74. package/scripts/handoff-asset.mjs +34 -0
  75. package/scripts/hook-retirement-check.mjs +8 -1
  76. package/scripts/host-registry.mjs +1 -1
  77. package/scripts/ingest-gists.mjs +74 -101
  78. package/scripts/job-heartbeat.sh +77 -14
  79. package/scripts/learning-replay-execution.mjs +10 -4
  80. package/scripts/nightly-gists.sh +27 -13
  81. package/scripts/nightly-two-run-proof.mjs +1 -1
  82. package/scripts/nightly-watchdog.mjs +61 -4
  83. package/scripts/onboarding-console.mjs +364 -28
  84. package/scripts/oracle/produce-questions.mjs +293 -0
  85. package/scripts/oracle/producer-hosts.mjs +235 -0
  86. package/scripts/oracle/repo-recall.mjs +448 -0
  87. package/scripts/oracle/retrieval-accuracy.mjs +818 -0
  88. package/scripts/oracle/source-tree.mjs +165 -0
  89. package/scripts/oracle/source-units.mjs +391 -0
  90. package/scripts/oracle/spike-run.mjs +98 -0
  91. package/scripts/oracle/unit-inventory.mjs +141 -0
  92. package/scripts/oracle/unit-sampling.mjs +128 -0
  93. package/scripts/oracle/validate-labels.mjs +250 -0
  94. package/scripts/private-overlay.mjs +248 -0
  95. package/scripts/product-integrity-contract.mjs +1 -1
  96. package/scripts/proxy/claude-proxied.sh +6 -0
  97. package/scripts/proxy/proxy-revert.sh +5 -0
  98. package/scripts/proxy/proxy-up.sh +6 -0
  99. package/scripts/proxy/proxy-verify.mjs +4 -0
  100. package/scripts/public-inputs.mjs +409 -0
  101. package/scripts/public-verification-inputs.mjs +112 -26
  102. package/scripts/public-verification-lane.mjs +1 -1
  103. package/scripts/published-surface-probe.mjs +34 -4
  104. package/scripts/qe/card-lane-gate.mjs +16 -1
  105. package/scripts/qe/session-start-gate.mjs +16 -1
  106. package/scripts/rebuild-gists-from-receipts.mjs +58 -78
  107. package/scripts/record-lesson.mjs +4 -1
  108. package/scripts/rehearse-corpus-pipeline.mjs +994 -0
  109. package/scripts/release-abort-stale.mjs +5 -1
  110. package/scripts/release-authority.mjs +104 -12
  111. package/scripts/release-channel-kind.mjs +86 -0
  112. package/scripts/release-convergence-watchdog.mjs +7 -2
  113. package/scripts/release-projection.mjs +177 -72
  114. package/scripts/release-transaction-provider.mjs +47 -10
  115. package/scripts/release-transaction.mjs +40 -11
  116. package/scripts/release.mjs +252 -17
  117. package/scripts/retrieval-canary.mjs +87 -0
  118. package/scripts/rvf-index-audit.mjs +573 -13
  119. package/scripts/rvf-wire.mjs +269 -0
  120. package/scripts/seal-gist-receipt.mjs +65 -0
  121. package/scripts/selfcheck.mjs +42 -21
  122. package/scripts/source-coverage.mjs +253 -24
  123. package/scripts/status-honesty.mjs +25 -0
  124. package/scripts/sync-census.mjs +0 -0
  125. package/scripts/sync-version.mjs +2 -0
  126. package/scripts/trismart.mjs +42 -0
  127. package/scripts/updater-manifest.mjs +162 -0
  128. package/scripts/verify-channels.mjs +17 -5
  129. package/scripts/wired-check.mjs +48 -10
  130. package/tri-smart-skill/QUICKSTART.md +37 -0
  131. package/tri-smart-skill/README.md +92 -0
  132. package/tri-smart-skill/install.cmd +14 -0
  133. package/tri-smart-skill/install.command +13 -0
  134. package/tri-smart-skill/install.mjs +51 -0
  135. package/tri-smart-skill/install.sh +9 -0
  136. package/tri-smart-skill/tri-smart/SKILL.md +90 -0
  137. package/tri-smart-skill/tri-smart/evals/evals.json +25 -0
  138. package/tri-smart-skill/tri-smart/references/protocol.md +25 -0
  139. package/tri-smart-skill/tri-smart/references/provider-cli.md +18 -0
  140. package/tri-smart-skill/tri-smart/scripts/review.mjs +154 -0
  141. package/tri-smart-skill/tri-smart/scripts/setup.mjs +97 -0
  142. package/tri-smart-skill/tri-smart/scripts/verify-access.mjs +107 -0
  143. package/scripts/corpus-seed-publish.mjs +0 -110
@@ -7,6 +7,7 @@ import {
7
7
  validateProgressionSnapshot,
8
8
  } from './project-progression-contract.mjs';
9
9
  import { resolveProjectStore } from './project-store-resolver.mjs';
10
+ import { withProgressionReader } from './project-progression-reader.mjs';
10
11
  import { resolveRuflo, rufloInvocation, RUFLO_MISSING } from './ruflo-bin.mjs';
11
12
 
12
13
  const PROGRESSION_NAMESPACE = 'project-progression';
@@ -75,15 +76,29 @@ export class ProjectProgressionStore {
75
76
  runner = defaultRunner,
76
77
  clock = () => new Date().toISOString(),
77
78
  fsync,
79
+ // The read-only fast path (project-progression-reader.mjs). READS ONLY: `ruflo memory store`
80
+ // stays the sole writer of memory.db. Pass `reader: null` to force every read through the CLI.
81
+ reader = withProgressionReader,
78
82
  } = {}) {
79
83
  if (!rufloBinary) throw new Error(RUFLO_MISSING);
80
84
  this.resolution = resolveProjectStore({ projectDir, requestedStorePath });
81
85
  this.rufloBinary = rufloBinary;
82
86
  this.runner = runner;
83
87
  this.clock = clock;
88
+ this.reader = typeof reader === 'function' ? reader : null;
89
+ this.lastReadPath = null;
84
90
  this.outbox = new ProgressionOutbox({ projectRoot: this.resolution.projectRoot, fsync });
85
91
  }
86
92
 
93
+ /**
94
+ * Run one read through the in-process reader, or report that the CLI must serve it.
95
+ * Structural errors propagate unchanged — only "I cannot answer authoritatively" falls back.
96
+ */
97
+ readFast(work) {
98
+ if (!this.reader) return { ok: false, reason: 'reader disabled' };
99
+ return this.reader(this.resolution.canonicalAgentDbPath, work);
100
+ }
101
+
87
102
  run(args) {
88
103
  return this.runner(this.rufloBinary, args, {
89
104
  cwd: path.dirname(this.resolution.canonicalAgentDbPath),
@@ -110,18 +125,36 @@ export class ProjectProgressionStore {
110
125
  const alreadyStored = resultStatus(stored) !== 0;
111
126
  if (!alreadyStored) onPhase('stored');
112
127
 
113
- const retrieved = this.run([
114
- 'memory', 'retrieve', '--key', snapshot.eventKey, '--namespace', PROGRESSION_NAMESPACE,
115
- '--value-only', '--path', this.resolution.canonicalAgentDbPath,
116
- ]);
117
- if (resultStatus(retrieved) !== 0) {
118
- const storeFailure = alreadyStored
119
- ? `; store failed: ${resultText(stored, 'stderr').trim() || 'unknown error'}`
120
- : '';
121
- throw new Error(`progression readback failed: ${resultText(retrieved, 'stderr').trim() || 'unknown error'}${storeFailure}`);
128
+ // THE READ-BACK, through the fast path when it is available.
129
+ //
130
+ // This is not a shortcut past the verification — it IS the verification, taken by an independent
131
+ // route. Reading the row back with the same CLI process family that just wrote it proves the CLI
132
+ // agrees with itself; reading the bytes off disk with node:sqlite proves the row is really there.
133
+ // It also matters for the budget: a capture boundary gets 8s, and a cold `ruflo memory` call
134
+ // measured ~3s in an isolated HOME (matching the 3.0-3.4s figure from the original report), so
135
+ // replay + capture at two CLI calls each did not fit and left the new snapshot uncommitted in the
136
+ // outbox. One CLI write plus a ~1ms read fits with room to spare. The CLI remains the fallback.
137
+ let readbackText = null;
138
+ const fast = this.readFast((reader) => reader.readContent(PROGRESSION_NAMESPACE, snapshot.eventKey));
139
+ if (fast.ok && typeof fast.value === 'string') {
140
+ this.lastReadPath = 'node:sqlite';
141
+ readbackText = fast.value;
142
+ } else {
143
+ this.lastReadPath = `ruflo-cli (${fast.ok ? 'row absent' : fast.reason})`;
144
+ const retrieved = this.run([
145
+ 'memory', 'retrieve', '--key', snapshot.eventKey, '--namespace', PROGRESSION_NAMESPACE,
146
+ '--value-only', '--path', this.resolution.canonicalAgentDbPath,
147
+ ]);
148
+ if (resultStatus(retrieved) !== 0) {
149
+ const storeFailure = alreadyStored
150
+ ? `; store failed: ${resultText(stored, 'stderr').trim() || 'unknown error'}`
151
+ : '';
152
+ throw new Error(`progression readback failed: ${resultText(retrieved, 'stderr').trim() || 'unknown error'}${storeFailure}`);
153
+ }
154
+ readbackText = resultText(retrieved, 'stdout');
122
155
  }
123
156
  let readback;
124
- try { readback = JSON.parse(resultText(retrieved, 'stdout')); } catch { throw new Error('progression readback is not JSON'); }
157
+ try { readback = JSON.parse(readbackText); } catch { throw new Error('progression readback is not JSON'); }
125
158
  if (readback.payloadDigest !== snapshot.payloadDigest || digestCanonical(readback) !== digestCanonical(snapshot)) {
126
159
  throw new Error('progression readback digest mismatch');
127
160
  }
@@ -158,6 +191,16 @@ export class ProjectProgressionStore {
158
191
  requirePositiveInteger(pageSize, 'pageSize');
159
192
  requirePositiveInteger(maxEntries, 'maxEntries');
160
193
  if (pageSize > maxEntries) throw new Error('pageSize exceeds the enumeration bound');
194
+ const fast = this.readFast((reader) => reader.listKeys(PROGRESSION_NAMESPACE, { maxEntries }));
195
+ if (fast.ok) {
196
+ this.lastReadPath = 'node:sqlite';
197
+ return fast.value;
198
+ }
199
+ this.lastReadPath = `ruflo-cli (${fast.reason})`;
200
+ return this.listSnapshotKeysViaCli({ pageSize, maxEntries });
201
+ }
202
+
203
+ listSnapshotKeysViaCli({ pageSize = 100, maxEntries = 10_000 } = {}) {
161
204
  const keys = [];
162
205
  const seen = new Set();
163
206
  let offset = 0;
@@ -214,6 +257,36 @@ export class ProjectProgressionStore {
214
257
  }
215
258
 
216
259
  retrieveSnapshots(keys) {
260
+ // The whole batch through ONE read-only handle, or the whole batch through the CLI. Never a
261
+ // mixture: a half-served batch would make "exactly these rows, read exactly this way" untrue.
262
+ const fast = this.readFast((reader) => {
263
+ const snapshots = [];
264
+ const rejected = [];
265
+ for (const key of keys) {
266
+ const content = reader.readContent(PROGRESSION_NAMESPACE, key);
267
+ if (content === null) throw new Error(`progression exact retrieval failed for ${key}: row not found`);
268
+ let snapshot;
269
+ try { snapshot = JSON.parse(content); } catch {
270
+ rejected.push({ eventKey: key, reasons: ['readback is not JSON'] });
271
+ continue;
272
+ }
273
+ if (!plainRecord(snapshot) || snapshot.eventKey !== key) {
274
+ rejected.push({ eventKey: key, reasons: ['exact key/payload identity mismatch'] });
275
+ continue;
276
+ }
277
+ snapshots.push(snapshot);
278
+ }
279
+ return { snapshots, rejected: sortRejected(rejected) };
280
+ });
281
+ if (fast.ok) {
282
+ this.lastReadPath = 'node:sqlite';
283
+ return fast.value;
284
+ }
285
+ this.lastReadPath = `ruflo-cli (${fast.reason})`;
286
+ return this.retrieveSnapshotsViaCli(keys);
287
+ }
288
+
289
+ retrieveSnapshotsViaCli(keys) {
217
290
  const snapshots = [];
218
291
  const rejected = [];
219
292
  for (const key of keys) {
@@ -238,9 +311,25 @@ export class ProjectProgressionStore {
238
311
  return { snapshots, rejected: sortRejected(rejected) };
239
312
  }
240
313
 
241
- restoreLatest({ pageSize = 100, maxEntries = 10_000, maxOutputBytes = 64 * 1024 } = {}) {
314
+ /** How many durable snapshots are fsynced but not yet committed to the canonical store. */
315
+ pendingReplayCount() {
316
+ try { return this.outbox.pendingSnapshots().length; } catch { return null; }
317
+ }
318
+
319
+ /**
320
+ * @param {{ replayPending?: boolean }} options
321
+ * `replayPending: false` restores from COMMITTED rows only. SessionStart uses it because replay
322
+ * is a WRITE, a write is a `ruflo memory store` process, and one of those alone costs more than
323
+ * the entire SessionStart budget — so a restore that replayed would time out and report UNKNOWN
324
+ * precisely when there was durable evidence to show. Pending work is REPORTED here and replayed
325
+ * at the next capture boundary (Stop / PreCompact / SessionEnd) or by /checkpoint, which are the
326
+ * boundaries that already own a write budget. The outbox's fsync-then-commit ordering and its
327
+ * replay-required semantics are untouched: nothing is dropped, only deferred.
328
+ */
329
+ restoreLatest({ pageSize = 100, maxEntries = 10_000, maxOutputBytes = 64 * 1024, replayPending = true } = {}) {
242
330
  requirePositiveInteger(maxOutputBytes, 'maxOutputBytes');
243
- this.replay();
331
+ if (replayPending) this.replay();
332
+ const pendingReplay = replayPending ? 0 : this.pendingReplayCount();
244
333
  const keys = this.listSnapshotKeys({ pageSize, maxEntries });
245
334
  const exact = this.retrieveSnapshots(keys);
246
335
  const restored = restoreProjectProgression(exact.snapshots, {
@@ -253,6 +342,8 @@ export class ProjectProgressionStore {
253
342
  if (!restored.ok) {
254
343
  const error = new Error('no coherent progression state could be restored');
255
344
  error.rejectedCandidates = rejectedCandidates;
345
+ error.structurallyEnumerated = keys.length;
346
+ error.pendingReplay = pendingReplay;
256
347
  throw error;
257
348
  }
258
349
  const payload = {
@@ -266,12 +357,14 @@ export class ProjectProgressionStore {
266
357
  exactRetrieved: keys.length,
267
358
  causallyStale: restored.causallyStale.length,
268
359
  rejectedCandidates,
360
+ readPath: this.lastReadPath,
361
+ pendingReplay,
269
362
  },
270
363
  };
271
364
  const rendered = JSON.stringify(payload);
272
365
  if (Buffer.byteLength(rendered, 'utf8') > maxOutputBytes) {
273
366
  throw new Error(`resume payload exceeds the ${maxOutputBytes}-byte output bound`);
274
367
  }
275
- return { payload, rendered };
368
+ return { payload, rendered, pendingReplay };
276
369
  }
277
370
  }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * ruvnet-gate1-pattern.mjs — the ONE copy of ground-ruvnet.sh's "Gate 1" regex outside that file.
3
+ *
4
+ * ground-ruvnet.sh (POSIX/bash) and grounding-turn-mark.mjs (Node) cannot literally `import` one
5
+ * another's source — one is a shell script, the other is JS. The task this file exists for is
6
+ * explicit: "copy it verbatim ... do not redefine a second, drifting copy ... or keep it
7
+ * byte-identical with a test that fails if they diverge". A shared data file both languages could
8
+ * read was considered and rejected: ground-ruvnet.sh is a hot, heavily-tuned, every-prompt hook
9
+ * (see its own header on the 38s-regression bounded-read fix), and editing it to add a file-read
10
+ * indirection for this one string is exactly the kind of non-surgical touch that risks a working,
11
+ * extensively-measured script for a feature that does not need it to change at all.
12
+ *
13
+ * So the copy lives here, in JS, and tests/unit/ruvnet-gate1-pattern.test.mjs parses
14
+ * ground-ruvnet.sh's own "Gate 1" grep line and asserts this string matches it byte-for-byte. A
15
+ * future edit to either side that is not mirrored in the other goes red immediately, which is the
16
+ * actual guarantee "byte-identical with a test that fails if they diverge" asks for.
17
+ *
18
+ * SOURCE OF TRUTH: plugin/scripts/ground-ruvnet.sh, the line beginning
19
+ * `if printf '%s' "$TEXT" | grep -qiE '...'; then` under the "Gate 1: does the task touch the rUv
20
+ * ecosystem?" comment. Copied 2026-09-12, case-insensitive (`-i`) to match `grep -qiE`.
21
+ */
22
+ export const RUVNET_GATE1_PATTERN =
23
+ '\\bruvnet\\b|\\bruflo\\b|\\bruvector\\b|\\brvf\\b|\\bagentdb\\b|\\bagenticow\\b|\\brulake\\b|\\bruview\\b|\\brupixel\\b|\\bruv-fann\\b|\\bagentic-flow\\b|\\bsynthlang\\b|\\bdspy\\b|\\bqudag\\b|\\bsafla\\b|\\bmetaharness\\b|\\bcve-bench\\b|\\bsparc\\b|\\bswarms?\\b|\\bclaude-flow\\b|\\brUv\\b';
24
+
25
+ /** Case-insensitive, matching the shell side's `grep -qiE`. A fresh RegExp per call — `.test()` on a
26
+ * shared `g`/`y` instance is stateful and a caller-shared singleton here would be a subtle footgun. */
27
+ export function ruvnetGate1Matches(text) {
28
+ return new RegExp(RUVNET_GATE1_PATTERN, 'i').test(String(text ?? ''));
29
+ }
@@ -1,11 +1,22 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { spawnSync } from 'node:child_process';
3
4
  import {
4
5
  captureProjectTransition,
5
6
  hasProjectProgression,
6
7
  } from './project-progression-hook.mjs';
7
8
  import { createSessionSnapshot } from './session-snapshot-contract.mjs';
8
9
  import { projectDirectory } from './project-identity.mjs';
10
+ import { buildProjectProgression } from './project-progression-producer.mjs';
11
+ import { ProjectProgressionStore } from './project-progression-store.mjs';
12
+ import { resolveProjectStore } from './project-store-resolver.mjs';
13
+
14
+ /**
15
+ * The capture boundary's whole budget. hooks.json declares 10s; this keeps the internal work well
16
+ * inside it so the host never has to kill us, and so a slow store degrades to "no snapshot this
17
+ * time" rather than to a hung turn. Capture is advisory: it fails open, always.
18
+ */
19
+ export const CAPTURE_BUDGET_MS = 8_000;
9
20
 
10
21
  function regularOrAbsent(file) {
11
22
  try {
@@ -43,22 +54,118 @@ export function writeSessionSnapshot(projectDir, event) {
43
54
  }
44
55
  }
45
56
 
57
+ /**
58
+ * A deadline-bounded `ruflo` runner. The store's own 120s per-call timeout is right for a deliberate
59
+ * CLI invocation and far too generous for a lifecycle hook, so the remaining budget caps every call.
60
+ */
61
+ function boundedStoreFactory(deadlineAt) {
62
+ return (options) => new ProjectProgressionStore({
63
+ ...options,
64
+ runner: (binary, args, runOptions) => {
65
+ const remaining = deadlineAt - Date.now();
66
+ if (remaining < 1) throw new Error('capture budget exceeded');
67
+ const result = spawnSync(binary, args, {
68
+ ...runOptions,
69
+ timeout: Math.min(runOptions.timeout ?? remaining, remaining),
70
+ shell: false,
71
+ });
72
+ if (result.error) throw new Error(`capture budget exceeded: ${result.error.message}`);
73
+ return result;
74
+ },
75
+ });
76
+ }
77
+
78
+ /**
79
+ * THE AUTOMATIC CAPTURE BOUNDARY.
80
+ *
81
+ * Before this, `captureProjectTransition` could only run when a host payload already carried a
82
+ * `projectProgression` extension — and no host emits one, so nothing was ever captured. Now the
83
+ * producer BUILDS that extension from real sources (git, the work ledger, the owner's own
84
+ * `project-state-current` note, the prior head, and a bounded transcript reference) whenever the
85
+ * payload does not supply one. An explicitly supplied extension still wins: that is how
86
+ * /ruvnet-brain:checkpoint hands over a state the model actually wrote.
87
+ *
88
+ * Two things are deliberately NOT done here:
89
+ * • `.swarm` is never created. Its absence means the project has not adopted the brain, and a
90
+ * lifecycle hook that plants a store in every repository the user opens is trespass (see above).
91
+ * • No exception escapes. A capture boundary that can fail a turn is worse than a missed snapshot.
92
+ */
46
93
  export function runSessionSnapshotHook(projectDir, event, {
47
94
  rawInput = '',
48
95
  host = process.env.RUVNET_HOOK_HOST || 'claude',
49
96
  captureProgression = captureProjectTransition,
97
+ produce = buildProjectProgression,
98
+ budgetMs = CAPTURE_BUDGET_MS,
99
+ now = Date.now,
50
100
  } = {}) {
51
101
  const metadataWritten = writeSessionSnapshot(projectDir, event);
52
102
  let payload;
53
103
  try { payload = rawInput ? JSON.parse(rawInput) : {}; } catch { payload = {}; }
54
- if (!hasProjectProgression(payload)) {
55
- return { metadataWritten, progressionCaptured: false, receipt: null };
104
+ const idle = { metadataWritten, progressionCaptured: false, receipt: null };
105
+
106
+ if (hasProjectProgression(payload)) {
107
+ if (payload.hook_event_name !== event) {
108
+ throw new Error(`progression boundary mismatch: expected ${event}, received ${payload.hook_event_name}`);
109
+ }
110
+ const result = captureProgression({ host, payload, projectDir });
111
+ return { ...idle, progressionCaptured: true, receipt: result.receipt };
112
+ }
113
+
114
+ // A payload with no session identity is not a real lifecycle event (an empty `{}` from a probe,
115
+ // a malformed host). Capturing against an invented session id would fabricate a journal entry.
116
+ if (typeof payload.session_id !== 'string' || !payload.session_id) {
117
+ return { ...idle, skipped: 'no session identity in the host payload' };
118
+ }
119
+
120
+ let resolution;
121
+ try { resolution = resolveProjectStore({ projectDir }); } catch {
122
+ return { ...idle, skipped: 'project store could not be resolved' };
56
123
  }
57
- if (payload.hook_event_name !== event) {
58
- throw new Error(`progression boundary mismatch: expected ${event}, received ${payload.hook_event_name}`);
124
+ if (!fs.existsSync(path.dirname(resolution.canonicalAgentDbPath))) {
125
+ return { ...idle, skipped: 'project has not adopted the canonical store' };
126
+ }
127
+
128
+ const deadlineAt = now() + budgetMs;
129
+ const storeFactory = boundedStoreFactory(deadlineAt);
130
+
131
+ // Commit anything a previously interrupted session left durable-but-uncommitted. SessionStart is
132
+ // forbidden from doing this (ADR-073 §5) because replay is a write; a capture boundary already
133
+ // owns a write budget, so this is where that debt is settled.
134
+ let replayed = 0;
135
+ try {
136
+ replayed = storeFactory({ projectDir, requestedStorePath: resolution.canonicalAgentDbPath }).replay().length;
137
+ } catch { /* the new capture below is still worth attempting */ }
138
+
139
+ let produced;
140
+ try {
141
+ produced = produce({ resolution, payload, host, trigger: event });
142
+ } catch (error) {
143
+ return { ...idle, replayed, skipped: `producer failed: ${error.message}` };
144
+ }
145
+ if (produced.skipped) return { ...idle, replayed, skipped: produced.skipped.reason };
146
+
147
+ let result;
148
+ try {
149
+ result = captureProgression({
150
+ host,
151
+ payload: { ...payload, hook_event_name: event, projectProgression: produced.projectProgression },
152
+ projectDir,
153
+ storeFactory,
154
+ });
155
+ } catch (error) {
156
+ // NOT LOST — DEFERRED. capture() fsyncs the snapshot to the durable outbox BEFORE it writes to
157
+ // the store, so a budget overrun here leaves the evidence on disk and the next capture boundary
158
+ // (or /checkpoint) commits it. Reporting that plainly is the whole difference between a bounded
159
+ // hook and a lossy one, so the reason is returned rather than thrown at a lifecycle boundary.
160
+ return { ...idle, replayed, skipped: `capture deferred: ${error.message}` };
59
161
  }
60
- const result = captureProgression({ host, payload, projectDir });
61
- return { metadataWritten, progressionCaptured: true, receipt: result.receipt };
162
+ return {
163
+ metadataWritten,
164
+ progressionCaptured: true,
165
+ replayed,
166
+ receipt: result.receipt,
167
+ provenance: produced.provenance,
168
+ };
62
169
  }
63
170
 
64
171
  if (process.argv[1] && path.resolve(process.argv[1]).endsWith('session-snapshot-hook.mjs')) {
@@ -68,7 +175,8 @@ if (process.argv[1] && path.resolve(process.argv[1]).endsWith('session-snapshot-
68
175
  try {
69
176
  runSessionSnapshotHook(projectDirectory(), process.argv[2] || 'SessionEnd', { rawInput });
70
177
  } catch (error) {
178
+ // ADVISORY, ALWAYS. A capture boundary fires at Stop, PreCompact and SessionEnd; one that can
179
+ // return a non-zero status can interrupt a turn, a compaction, or a clean exit. Report and exit 0.
71
180
  process.stderr.write(`[project-progression] ${error.message}\n`);
72
- process.exitCode = 1;
73
181
  }
74
182
  }
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env node
2
+ // session-start-budget.mjs — the DERIVED-SUM latency contract for SessionStart (ADR-067 pattern,
3
+ // added 2026-09-11 after two independent reviewers flagged the original per-stage timeouts as
4
+ // unaccountable numbers with no relationship to the hook's own declared budget).
5
+ //
6
+ // Every stage the hook can run declares its OWN cost HERE, once. Nothing downstream invents a
7
+ // second number: session-start-trace.mjs enforces these at runtime (skip-with-note on overrun),
8
+ // and tests/unit/session-start-budget.test.mjs sums them and fails the build the moment the total
9
+ // creeps past hooks.json's own declared SessionStart timeout — so a new stage, or a raised budget,
10
+ // is a reviewable diff instead of a silent latency regression nobody notices until a stranger's
11
+ // session hangs.
12
+ //
13
+ // `restore` is continuity's project-progression restore (plugin/scripts/project-progression-
14
+ // session-start.mjs) — accounted for HERE because it shares this hook's wall-clock budget, but the
15
+ // stage itself is NOT this lane's code and is not wrapped by session-start-trace.mjs.
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+
20
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
21
+ export const HOOKS_JSON_PATH = path.resolve(HERE, '..', 'hooks', 'hooks.json');
22
+
23
+ /** SessionStart's own declared timeout in ms, read LIVE from hooks.json — never hand-copied, so a
24
+ * future retiming there is the one and only place this contract has to agree with. */
25
+ export function sessionStartTimeoutMs(hooksJsonPath = HOOKS_JSON_PATH) {
26
+ const doc = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8'));
27
+ const entry = doc?.hooks?.SessionStart?.[0]?.hooks?.[0];
28
+ const timeoutSec = typeof entry?.timeout === 'number' ? entry.timeout : null;
29
+ if (!timeoutSec) throw new Error(`${hooksJsonPath}: could not read SessionStart's declared timeout`);
30
+ return timeoutSec * 1000;
31
+ }
32
+
33
+ // Per-stage budgets, ms. Every stage session-start-core.mjs (or continuity's restore) can run
34
+ // appears here exactly once. Reviewer-mandated ceilings: restore <= 1000, banner <= 200.
35
+ export const STAGE_BUDGETS_MS = {
36
+ restore: 1000, // continuity lane's project-progression restore — NOT this lane's code
37
+ misc: 250, // settings/nightly/health/console-offer/auto-pref/star — small fs reads
38
+ // The cache read itself is budgeted at 100ms internally (session-start-issue-alert.mjs's own
39
+ // ISSUE_POINTER_BUDGET_MS, per correction #2's exact wording); this stage's total also carries
40
+ // the repo-scoping git check (session-start-repo-identity.mjs), bounded separately at up to
41
+ // 1000ms under real load — never a network call either way.
42
+ 'issue-pointer': 1100,
43
+ 'signal-surface': 400, // bounded CI-signal transition poll (see session-start-signals.mjs)
44
+ 'router-nudge': 50, // one fs.existsSync + at-most-one-time write
45
+ 'stable-spine': 300, // seed-dispatch decision + a single detach launch
46
+ heartbeat: 300, // update-check dispatch launch
47
+ 'ascii-drift': 300, // optional ascii->svg drift advisory, already spawnSync-timeout bounded
48
+ banner: 200, // version/readiness/health banner assembly — pure fs reads, no subprocess
49
+ };
50
+
51
+ export function sumBudgetsMs(budgets = STAGE_BUDGETS_MS) {
52
+ return Object.values(budgets).reduce((sum, ms) => sum + ms, 0);
53
+ }
54
+
55
+ // Measured node process boot (interpreter start + module graph load) BEFORE any stage code runs —
56
+ // real, unavoidable overhead the stage budgets above do not (and must not) account for. A named
57
+ // constant, not folded silently into one stage's budget, so a boot-time regression shows up as its
58
+ // own line instead of quietly eating an unrelated stage's headroom.
59
+ export const MEASURED_NODE_BOOT_MS = 250;