ruvnet-brain 4.7.19 → 4.7.21

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 (250) hide show
  1. package/README.md +2 -2
  2. package/bin/install.mjs +494 -97
  3. package/kb/download-retry.mjs +93 -21
  4. package/kb/forge-guard-injection.mjs +277 -0
  5. package/kb/forge-update.mjs +166 -38
  6. package/kb/lifecycle-evidence-retention.mjs +151 -14
  7. package/kb/model-requirements.mjs +4 -1
  8. package/kb/refresh-run.mjs +175 -27
  9. package/kb/verify-citation.mjs +15 -2
  10. package/package.json +3 -1
  11. package/plugin/.claude-plugin/plugin.json +1 -1
  12. package/plugin/.codex-plugin/plugin.json +1 -1
  13. package/plugin/mcp/managed-cli-interface.mjs +16 -8
  14. package/plugin/scripts/adr-currency-gate.mjs +2 -1
  15. package/plugin/scripts/advisory-contract.mjs +21 -0
  16. package/plugin/scripts/advocacy-route.mjs +5 -4
  17. package/plugin/scripts/agentdb-recall.mjs +15 -5
  18. package/plugin/scripts/automatic-update.mjs +9 -1
  19. package/plugin/scripts/brain-confirmation.mjs +2 -1
  20. package/plugin/scripts/brain-footprint.mjs +72 -11
  21. package/plugin/scripts/capability-registry.mjs +1 -1
  22. package/plugin/scripts/capacity-aware-parallel-work.mjs +2 -1
  23. package/plugin/scripts/codex-overnight-notice.mjs +33 -0
  24. package/plugin/scripts/completion-claim-evidence.mjs +56 -12
  25. package/plugin/scripts/continuation-gate.mjs +28 -9
  26. package/plugin/scripts/decision-gate.mjs +13 -63
  27. package/plugin/scripts/decision-outcomes.mjs +36 -0
  28. package/plugin/scripts/decision-recall-gate.mjs +40 -5
  29. package/plugin/scripts/decision-record.mjs +8 -3
  30. package/plugin/scripts/degradation-watch.mjs +2 -1
  31. package/plugin/scripts/developer-update-lock.mjs +172 -33
  32. package/plugin/scripts/developer-update-owner.mjs +8 -5
  33. package/plugin/scripts/developer-update-policy.mjs +4 -1
  34. package/plugin/scripts/developer-update.mjs +155 -53
  35. package/plugin/scripts/doc-currency.mjs +2 -16
  36. package/plugin/scripts/duplicate-gate.mjs +2 -1
  37. package/plugin/scripts/entrypoint.mjs +34 -0
  38. package/plugin/scripts/footprint-io.mjs +135 -8
  39. package/plugin/scripts/grok-hook-adapter.mjs +2 -1
  40. package/plugin/scripts/ground-ruvnet.sh +6 -3
  41. package/plugin/scripts/grounding-answer.mjs +2 -1
  42. package/plugin/scripts/grounding-code-projection.mjs +2 -1
  43. package/plugin/scripts/grounding-substance.mjs +2 -1
  44. package/plugin/scripts/grounding-turn-gate.mjs +2 -2
  45. package/plugin/scripts/grounding-turn-mark.mjs +2 -2
  46. package/plugin/scripts/hook-context-budget.mjs +2 -1
  47. package/plugin/scripts/hook-input.mjs +2 -2
  48. package/plugin/scripts/hook-shim.mjs +9 -2
  49. package/plugin/scripts/host-update.mjs +29 -5
  50. package/plugin/scripts/identifier-preflight.mjs +2 -1
  51. package/plugin/scripts/kb-copy-proof.mjs +57 -9
  52. package/plugin/scripts/learn-capture.mjs +2 -1
  53. package/plugin/scripts/learning-store.mjs +62 -16
  54. package/plugin/scripts/lesson-bridge.mjs +2 -2
  55. package/plugin/scripts/lesson-promote.mjs +83 -22
  56. package/plugin/scripts/md-stamp.mjs +2 -1
  57. package/plugin/scripts/native-turn-transcript.mjs +98 -6
  58. package/plugin/scripts/nightly-scheduler.mjs +10 -5
  59. package/plugin/scripts/package-recommender.mjs +2 -2
  60. package/plugin/scripts/project-store-resolver.mjs +19 -0
  61. package/plugin/scripts/routing-outcome-capture.mjs +2 -1
  62. package/plugin/scripts/session-snapshot-hook.mjs +24 -4
  63. package/plugin/scripts/session-start-core.mjs +8 -16
  64. package/plugin/scripts/session-start-health.mjs +97 -10
  65. package/plugin/scripts/session-start-issue-alert.mjs +105 -0
  66. package/plugin/scripts/session-start-update-plane.mjs +15 -1
  67. package/plugin/scripts/signal-watch.mjs +2 -1
  68. package/plugin/scripts/spend-guard.mjs +2 -1
  69. package/plugin/scripts/substitution-wall.mjs +2 -1
  70. package/plugin/scripts/turn-outcome-capture.mjs +7 -3
  71. package/plugin/scripts/unprompted-runtime.mjs +20 -3
  72. package/plugin/scripts/update-apply.mjs +186 -15
  73. package/plugin/skills/release-proof/scripts/release-proof.mjs +2 -1
  74. package/plugin/skills/ruvnet-brain/PLAYBOOK.md +2 -2
  75. package/plugin/skills/ruvnet-brain/SKILL.md +3 -2
  76. package/scripts/adr-072-completion.mjs +2 -1
  77. package/scripts/agentdb-context.mjs +2 -1
  78. package/scripts/agentdb-fleet-doctor.mjs +30 -8
  79. package/scripts/approved-runtime.mjs +2 -1
  80. package/scripts/architecture-review-lock.mjs +2 -1
  81. package/scripts/ascii-drift.mjs +2 -1
  82. package/scripts/brain-latency-50.mjs +2 -1
  83. package/scripts/brain-novice-50.mjs +2 -16
  84. package/scripts/brain-score.mjs +13 -2
  85. package/scripts/build-bundle.mjs +2 -1
  86. package/scripts/build-concepts.mjs +2 -1
  87. package/scripts/candidate-ci-receipt.mjs +2 -1
  88. package/scripts/candidate-host-evidence.mjs +2 -1
  89. package/scripts/card-from-source.mjs +2 -1
  90. package/scripts/ci/warm-brain-models.mjs +2 -1
  91. package/scripts/claims-verify.mjs +7 -3
  92. package/scripts/claude-controlled-terminal.mjs +2 -1
  93. package/scripts/claude-terminal-mod.mjs +2 -1
  94. package/scripts/code-release-corpus.mjs +2 -1
  95. package/scripts/codex-hook-trust-reconcile.mjs +67 -1
  96. package/scripts/codex-hook-trust.mjs +40 -1
  97. package/scripts/codex-hook-wrapper-history.mjs +2 -1
  98. package/scripts/codex-host-execution-proof.mjs +2 -1
  99. package/scripts/completion-claim-replay.mjs +2 -1
  100. package/scripts/console-engine.mjs +8 -3
  101. package/scripts/corpus-canary.mjs +2 -1
  102. package/scripts/corpus-candidate.mjs +2 -2
  103. package/scripts/corpus-coverage-sidecar.mjs +2 -1
  104. package/scripts/corpus-dispatch-decision.mjs +2 -1
  105. package/scripts/corpus-dispatch-receipt.mjs +2 -1
  106. package/scripts/corpus-next-seed.mjs +2 -1
  107. package/scripts/corpus-qa.mjs +2 -1
  108. package/scripts/corpus-reconcile.mjs +2 -2
  109. package/scripts/corpus-watchdog.mjs +2 -1
  110. package/scripts/count-chunks.mjs +2 -1
  111. package/scripts/customer-state-matrix.mjs +2 -1
  112. package/scripts/derive-passage-content-map.mjs +2 -1
  113. package/scripts/development-push-check.mjs +2 -1
  114. package/scripts/dispatch-receipt.mjs +2 -1
  115. package/scripts/doc-currency.mjs +2 -1
  116. package/scripts/dream-issue-gate.mjs +2 -1
  117. package/scripts/dual-host-deliberation.mjs +2 -16
  118. package/scripts/dual-host-suggest.mjs +2 -16
  119. package/scripts/eval-brain.mjs +7 -3
  120. package/scripts/execution-policy.mjs +2 -1
  121. package/scripts/execution-preflight.mjs +2 -1
  122. package/scripts/falsify.mjs +2 -1
  123. package/scripts/fix-metaharness-memretrieve.mjs +2 -1
  124. package/scripts/fix-workstream.mjs +2 -1
  125. package/scripts/full-hints.mjs +2 -1
  126. package/scripts/full-suite-gate.mjs +2 -1
  127. package/scripts/grok-host.mjs +68 -0
  128. package/scripts/hook-qualify-core.mjs +3 -1
  129. package/scripts/hook-qualify.mjs +2 -1
  130. package/scripts/hook-retirement-check.mjs +2 -1
  131. package/scripts/host-registry.mjs +2 -1
  132. package/scripts/independent-review-receipt.mjs +2 -1
  133. package/scripts/ingest-new-repos.mjs +2 -1
  134. package/scripts/ingest-repo.mjs +12 -2
  135. package/scripts/integration-evidence.mjs +2 -1
  136. package/scripts/issue-fix.mjs +7 -24
  137. package/scripts/issue-watch.mjs +164 -47
  138. package/scripts/job-heartbeat.sh +145 -13
  139. package/scripts/key-canary.mjs +9 -3
  140. package/scripts/latency-to-surface.mjs +2 -1
  141. package/scripts/learning-replay-cli.mjs +97 -19
  142. package/scripts/learning-replay-execution.mjs +2 -0
  143. package/scripts/learning-replay-fixture.mjs +3 -0
  144. package/scripts/learning-replay-proof.mjs +14 -13
  145. package/scripts/learning-replay.mjs +3 -3
  146. package/scripts/learnings.mjs +2 -1
  147. package/scripts/lesson-ratify.mjs +88 -40
  148. package/scripts/loop-checkpoint.mjs +2 -1
  149. package/scripts/metaharness-receipts.mjs +2 -1
  150. package/scripts/model-catalog.mjs +2 -1
  151. package/scripts/model-currency-evidence.mjs +6 -4
  152. package/scripts/model-currency.mjs +33 -3
  153. package/scripts/model-native-catalog.mjs +2 -1
  154. package/scripts/model-router-agent-hook.mjs +2 -1
  155. package/scripts/model-router-dispatch.mjs +2 -1
  156. package/scripts/model-router-engine.mjs +2 -1
  157. package/scripts/model-router-outcome.mjs +2 -1
  158. package/scripts/model-router-setup.mjs +2 -1
  159. package/scripts/model-routing-eval.mjs +2 -1
  160. package/scripts/model-routing-gateway.mjs +2 -1
  161. package/scripts/model-routing-launchers.mjs +4 -1
  162. package/scripts/model-terminal-gateway.mjs +2 -1
  163. package/scripts/model-terminal-launchers.mjs +2 -1
  164. package/scripts/model-weekly-analyst.mjs +2 -1
  165. package/scripts/model-weekly-cycle.mjs +2 -1
  166. package/scripts/model-weekly-qualification.mjs +2 -1
  167. package/scripts/nightly-gists.sh +30 -21
  168. package/scripts/nightly-two-run-proof.mjs +2 -1
  169. package/scripts/nightly-watchdog.mjs +158 -47
  170. package/scripts/no-silent-substitution.mjs +2 -1
  171. package/scripts/notify.sh +11 -6
  172. package/scripts/ntfy-inbox.mjs +549 -0
  173. package/scripts/onboarding-console.mjs +47 -8
  174. package/scripts/oracle/abstain-threshold-sweep.mjs +2 -1
  175. package/scripts/oracle/abstain-trace.mjs +2 -1
  176. package/scripts/oracle/doc2query-generate.mjs +2 -1
  177. package/scripts/oracle/doc2query-reach.mjs +2 -1
  178. package/scripts/oracle/judge-train.mjs +2 -1
  179. package/scripts/oracle/need-set-split.mjs +2 -1
  180. package/scripts/oracle/produce-questions.mjs +2 -16
  181. package/scripts/oracle/repo-recall.mjs +2 -2
  182. package/scripts/oracle/retrieval-accuracy.mjs +2 -2
  183. package/scripts/oracle/sona-query-adapter-eval.mjs +2 -1
  184. package/scripts/oracle/source-tree.mjs +2 -15
  185. package/scripts/oracle/source-units.mjs +2 -16
  186. package/scripts/oracle/spike-run.mjs +2 -16
  187. package/scripts/oracle/validate-labels.mjs +2 -16
  188. package/scripts/org-repo-count.mjs +2 -1
  189. package/scripts/package-cards.mjs +2 -1
  190. package/scripts/prepublication-evidence.mjs +2 -1
  191. package/scripts/private-overlay.mjs +2 -1
  192. package/scripts/product-integrity-contract.mjs +2 -1
  193. package/scripts/protected-release-invocation.mjs +2 -1
  194. package/scripts/public-verification-abandon.mjs +2 -1
  195. package/scripts/public-verification-aggregate.mjs +2 -1
  196. package/scripts/public-verification-finalizer.mjs +2 -1
  197. package/scripts/public-verification-inputs.mjs +2 -1
  198. package/scripts/public-verification-lane.mjs +2 -1
  199. package/scripts/publication-receipt.mjs +2 -1
  200. package/scripts/published-surface-probe.mjs +2 -1
  201. package/scripts/qe/card-lane-gate.mjs +2 -16
  202. package/scripts/qe/session-start-gate.mjs +2 -16
  203. package/scripts/qe/ux-suite.mjs +2 -1
  204. package/scripts/qualified-candidate-check.mjs +2 -1
  205. package/scripts/rebuild-gists-from-receipts.mjs +2 -1
  206. package/scripts/recommendation-eval.mjs +2 -1
  207. package/scripts/recommendation-floor.mjs +2 -1
  208. package/scripts/recommendation-judge-score.mjs +2 -1
  209. package/scripts/recommendation-real-host-score.mjs +2 -1
  210. package/scripts/recommendation-real-host.mjs +2 -1
  211. package/scripts/refresh-capability-only-store.mjs +2 -1
  212. package/scripts/refresh-model-catalog.mjs +2 -1
  213. package/scripts/rehearse-corpus-pipeline.mjs +2 -1
  214. package/scripts/release-authority.mjs +2 -1
  215. package/scripts/release-evidence-aggregate.mjs +2 -1
  216. package/scripts/release-payload.mjs +2 -1
  217. package/scripts/release-projection.mjs +2 -1
  218. package/scripts/release-proof.mjs +2 -1
  219. package/scripts/release-qualification-contract.mjs +71 -26
  220. package/scripts/release-qualification.mjs +8 -1
  221. package/scripts/release-vector.mjs +2 -1
  222. package/scripts/restore-local-ingests.mjs +2 -1
  223. package/scripts/retrieval-canary.mjs +2 -1
  224. package/scripts/route-cheap.mjs +2 -1
  225. package/scripts/route-gold-rank.mjs +2 -1
  226. package/scripts/route-index-memory.mjs +2 -1
  227. package/scripts/route-latency-warm.mjs +2 -1
  228. package/scripts/router-utilization.mjs +2 -1
  229. package/scripts/routing-flywheel.mjs +2 -1
  230. package/scripts/run-operational-benchmark.mjs +2 -1
  231. package/scripts/run-operational-benchmark.v3.mjs +2 -1
  232. package/scripts/rvf-index-audit.mjs +2 -16
  233. package/scripts/seal-gist-receipt.mjs +2 -1
  234. package/scripts/security-guidance-codex-compat.mjs +2 -1
  235. package/scripts/selfcheck.mjs +2 -1
  236. package/scripts/signal-watch.mjs +2 -1
  237. package/scripts/source-coverage.mjs +2 -1
  238. package/scripts/stabilization-receipt.mjs +2 -1
  239. package/scripts/stamp-sweep.mjs +2 -1
  240. package/scripts/status-honesty.mjs +2 -1
  241. package/scripts/sync-commands.mjs +2 -2
  242. package/scripts/top100-benchmark.mjs +2 -1
  243. package/scripts/top100-corpus.mjs +2 -1
  244. package/scripts/user-model-hook-install.mjs +2 -1
  245. package/scripts/user-model-prompt-hook.mjs +2 -1
  246. package/scripts/verify-bundle.mjs +3 -1
  247. package/scripts/verify-model-catalog.mjs +2 -1
  248. package/scripts/version.mjs +2 -1
  249. package/scripts/wired-check.mjs +11 -5
  250. package/scripts/worktree-integrity.mjs +2 -2
@@ -5,7 +5,8 @@
5
5
  //
6
6
  // must-exist the live KB, the active Stable Spine generation, each registered plugin generation
7
7
  // may-exist bounded state: logs under a size cap, lease-held plugin generations, the newest npx
8
- // copy, lifecycle evidence under its own retention policy, models, small state files
8
+ // copy, lifecycle evidence under its own retention policy, models, small state files,
9
+ // the newest rescued operational files of released KB copies (kb-copy-rescued/)
9
10
  // must-not-exist every other full-KB copy (kb.bak-*, kb.install-preserved-*, kb.pre-update-*, …,
10
11
  // *-quarantine-* dirs), stale install stages and forge candidates, older npx copies,
11
12
  // stale leases, ruflo scratch debris, rotated-away log bytes
@@ -14,6 +15,7 @@
14
15
  // SAFETY (non-negotiable, each one has a test that breaks it and goes red):
15
16
  // * A KB copy is removed only after kbCopyProof() shows nothing in it is unique (every private-store file
16
17
  // byte-identical in live, everything else of public provenance); a KEPT proof is cached (footprint-io).
18
+ // Its operational files (logs, ruflo scratch) are copied out and verified first (rescueOperationalFiles).
17
19
  // * Nothing is followed through a symlink; a removal target must sit directly inside the real directory it
18
20
  // was inventoried in, itself inside an owned root (footprint-io removeWithin).
19
21
  // * Kept: non-terminal transaction trees; every KB sibling and npx copy while someone else holds the refresh
@@ -26,7 +28,9 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
26
28
  import { isKbTree, kbCopyProof } from './kb-copy-proof.mjs';
27
29
  import { brainLocation } from './brain-location.mjs';
28
30
  import { RUFLO_CWD_ARTIFACTS } from './ruflo-bin.mjs';
29
- import { assessMoveLeftovers, cachedKept, cmpVersion, isVolumeMetadata, keptCopyFix, physical, pidAlive, readProofCache, rememberKept, removeWithin, rotate,
31
+ import { isDirectInvocation } from './entrypoint.mjs';
32
+ import { RESCUE_DIR, archiveRoots, archiveUniqueFiles, assessMoveLeftovers, discardArchiveIfMirrored, cachedKept, cmpVersion, isVolumeMetadata,
33
+ keptCopyFix, physical, pidAlive, readProofCache, rememberKept, removeWithin, rescueOperationalFiles, rotate,
30
34
  treeBytes, truncateToTail } from './footprint-io.mjs';
31
35
 
32
36
  export { cmpVersion, physical, treeBytes } from './footprint-io.mjs';
@@ -43,6 +47,7 @@ export const FOOTPRINT_POLICY = Object.freeze({
43
47
  staleForgeCandidateMs: 24 * 3_600_000,
44
48
  staleLeaseMs: 6 * 3_600_000, // plugin/scripts/update-apply.mjs LEASE_FRESH_MS
45
49
  staleRufloRunMs: 3_600_000, // plugin/scripts/ruflo-bin.mjs STALE_RUN_MS
50
+ rescuedCopiesKept: 5, // kb-copy-rescued/<copy>: the newest five released copies' logs are kept
46
51
  });
47
52
 
48
53
  /** Every full-KB copy name this product (or a recovery) has ever created beside the live KB. A superset of
@@ -185,6 +190,10 @@ export function inventoryFootprint({ env = process.env, home = os.homedir(), now
185
190
  }
186
191
  };
187
192
  const classifySibling = (name, full, st) => {
193
+ if (name === `${base}.archive` && st.isDirectory() && !st.isSymbolicLink()) { // #335: written only by --clean --archive
194
+ return void add({ id: 'kb-archive', path: full, class: 'may-exist', kind: 'kb-archive', action: 'keep', bytes: bytes(full),
195
+ reason: 'files unique to released KB copies, archived on request (--clean --archive); never removed automatically' });
196
+ }
188
197
  {
189
198
  const prefix = kbCopyPrefixes(base).find((p) => name.startsWith(p));
190
199
  if (prefix === `${base}.bak-` && !BRAIN_BACKUP_STAMP.test(name.slice(prefix.length))) {
@@ -264,6 +273,20 @@ export function inventoryFootprint({ env = process.env, home = os.homedir(), now
264
273
  reason: 'the one retained rotation of a capped log (replaced at the next rotation)' });
265
274
  continue;
266
275
  }
276
+ // Operational files rescued from released KB copies (footprint-io rescueOperationalFiles), one directory
277
+ // per copy, each file <= 2 MiB and <= 16 MiB per copy (kb-copy-proof RESCUE_*_CAP_BYTES): bounded like a
278
+ // rotated log, by keeping the newest few directories.
279
+ if (name === RESCUE_DIR && st.isDirectory() && !st.isSymbolicLink()) {
280
+ const rescued = names(full).map((n) => ({ n, s: lstat(path.join(full, n)) })).filter((r) => r.s)
281
+ .sort((a, b) => b.s.mtimeMs - a.s.mtimeMs || a.n.localeCompare(b.n));
282
+ rescued.forEach(({ n }, i) => {
283
+ const keep = i < policy.rescuedCopiesKept;
284
+ add({ id: 'rescued', path: path.join(full, n), class: keep ? 'may-exist' : 'must-not-exist', kind: 'rescued-kb-logs',
285
+ action: keep ? 'keep' : 'remove', bytes: bytes(path.join(full, n)),
286
+ reason: keep ? 'logs rescued from a released KB copy (one of the newest kept)' : `older than the newest ${policy.rescuedCopiesKept} rescued KB-copy logs` });
287
+ });
288
+ continue;
289
+ }
267
290
  if (TEXT_LOG.test(name) && st.isFile()) {
268
291
  const over = st.size > policy.textLogCapBytes;
269
292
  add({ id: 'log', path: full, class: over ? 'must-not-exist' : 'may-exist', kind: 'text-log', action: over ? 'truncate' : 'keep', bytes: st.size,
@@ -366,6 +389,7 @@ export function inventoryFootprint({ env = process.env, home = os.homedir(), now
366
389
  const newestVersion = npx.map((n) => n.version).sort(cmpVersion).at(-1);
367
390
  const keepVersion = current && cmpVersion(newestVersion, current) < 0 ? null : newestVersion;
368
391
  const keeper = npx.find((n) => n.running) || npx.filter((n) => n.version === keepVersion).sort((a, b) => b.mtime - a.mtime)[0];
392
+ const refVersion = keeper?.version ?? current ?? newestVersion; // #420 item 3: "older" only when strictly below what is kept
369
393
  for (const n of npx) {
370
394
  const keep = n === keeper || n.running;
371
395
  // An older copy fetched < 2h ago may be running in another terminal; none goes during an update/install (S6).
@@ -373,7 +397,10 @@ export function inventoryFootprint({ env = process.env, home = os.homedir(), now
373
397
  const hold = !keep && (kbBlocked || (recent ? 'fetched in the last 2h; it may be running now' : null));
374
398
  add({ id: 'npx', path: n.dir, version: n.version, bytes: bytes(n.dir), class: keep ? 'may-exist' : 'must-not-exist', kind: 'npx-copy',
375
399
  action: keep || hold ? (keep ? 'keep' : 'report') : 'remove', blocked: hold || null,
376
- reason: keep ? (n.running ? 'the installer running now' : 'the newest installer copy') : `an older installer copy (${n.version})` });
400
+ reason: keep ? (n.running ? 'the installer running now' : 'the newest installer copy')
401
+ : cmpVersion(n.version, refVersion) < 0 ? `an older installer copy (${n.version})`
402
+ : cmpVersion(n.version, refVersion) === 0 ? `a duplicate installer copy at the kept version (${n.version}); one copy is enough`
403
+ : `an installer copy (${n.version}) newer than the kept one (${refVersion}); one copy is enough` });
377
404
  }
378
405
 
379
406
  // ── lifecycle evidence, judged by its own policy ────────────────────────────────────────────
@@ -390,7 +417,7 @@ function summarize({ roots, items, policy, kbCopyDirs, lockHeld }) {
390
417
  const owned = items.filter((i) => i.class !== 'unowned');
391
418
  const kbBytes = sum(items.filter((i) => i.kind === 'live-kb'));
392
419
  const modelBytes = sum(items.filter((i) => i.kind === 'models'));
393
- const budgetBytes = kbBytes + modelBytes + policy.fixedAllowanceBytes;
420
+ const budgetBytes = kbBytes + modelBytes + sum(items.filter((i) => i.kind === 'kb-archive')) + policy.fixedAllowanceBytes;
394
421
  const breakdown = {};
395
422
  for (const i of owned) {
396
423
  const group = i.kind === 'live-kb' ? 'knowledge base' : i.kind === 'models' ? 'models' : /plugin|spine/.test(i.kind) ? 'plugins'
@@ -419,13 +446,40 @@ function summarize({ roots, items, policy, kbCopyDirs, lockHeld }) {
419
446
  * every refusal is returned with its reason. `collectPluginGenerations` is the caller's lease-aware
420
447
  * collector (bin/install.mjs prunePluginGenerations); `pruneEvidence` is kb's pruneLifecycleEvidence.
421
448
  */
422
- export function sweepFootprint({ apply = false, collectPluginGenerations = null, pruneEvidence = null,
449
+ export function sweepFootprint({ apply = false, archive = false, collectPluginGenerations = null, pruneEvidence = null,
423
450
  proveCopy = kbCopyProof, beforeRemove = null, ...options } = {}) {
424
451
  const before = inventoryFootprint(options);
425
452
  const { roots } = before;
426
453
  const owned = [...new Set([roots.kbParent, roots.brainHome, ...roots.brainHomeParents, roots.npxRoot].map(physical))];
427
- const prove = (copyDir) => {
428
- const proof = proveCopy({ copyDir, liveDir: roots.kbDir });
454
+ // #335: copies that STAY on disk this sweep may account for a later copy's files (kept siblings). Only
455
+ // final decisions are added: archive trees, copies a cached proof keeps (outside --archive, which re-proves
456
+ // them), and each copy this sweep keeps. A copy is never a reference for one evaluated before it.
457
+ const archiveDir = path.join(roots.kbParent, `${path.basename(roots.kbDir)}.archive`);
458
+ const stampDir = path.join(archiveDir, new Date(options.now ?? Date.now()).toISOString().replace(/[:.]/g, '-'));
459
+ const archiving = apply && archive;
460
+ const reArchive = (item) => archiving && ['kb-copy', 'quarantine'].includes(item.kind) && item.keptUnique && !item.blocked;
461
+ // Correctness relies on SORTED directory order across concurrent sweeps: inventory and archiveRoots list
462
+ // entries sorted, so two sweeps evaluate copies in the same order and agree which copy of a duplicate pair
463
+ // is the kept reference; with unsorted order each could release the copy the other kept as its reference.
464
+ const refs = [...archiveRoots(archiveDir), ...before.items.filter((i) => i.kind === 'kb-copy' && i.keptUnique && !reArchive(i)).map((i) => i.path)];
465
+ const prove = (copyDir, label = path.basename(copyDir)) => {
466
+ let proof = proveCopy({ copyDir, liveDir: roots.kbDir, keptSiblings: [...refs] });
467
+ if (!proof.disposable && archiving && proof.unique?.length) {
468
+ const archived = archiveUniqueFiles({ copyDir, unique: proof.unique, stampDir, label, now: options.now ?? Date.now() });
469
+ if (archived.ok) {
470
+ refs.push(archived.root);
471
+ const again = proveCopy({ copyDir, liveDir: roots.kbDir, keptSiblings: [...refs] });
472
+ if (again.disposable) proof = { ...again, reason: `archived ${archived.files.length} unique file(s) to ${archived.root}; ${again.reason}` };
473
+ else { // the copy is kept (e.g. unreadable metadata on the re-proof): if it still holds every archived byte, no archive is left behind
474
+ const discard = discardArchiveIfMirrored({ root: archived.root, copyDir, files: archived.files });
475
+ if (discard.discarded) {
476
+ refs.pop();
477
+ proof = { ...again, reason: `${again.reason}; not archived: the copy is kept, so its archive was removed` };
478
+ } else proof = { ...again, reason: `${again.reason}; the archive at ${archived.root} is kept: ${discard.reason}` };
479
+ }
480
+ } else proof = { ...proof, reason: `${proof.reason}; not archived: ${archived.reason}` };
481
+ }
482
+ if (!proof.disposable) refs.push(copyDir);
429
483
  if (apply && !proof.disposable && isKbTree(roots.kbDir)) rememberKept(roots.brainHome, copyDir, roots.kbDir, proof); // a dry run writes nothing
430
484
  return proof;
431
485
  };
@@ -435,8 +489,13 @@ export function sweepFootprint({ apply = false, collectPluginGenerations = null,
435
489
  kept: [{ path: roots.location.path, kind: 'unmounted-brain', reason: before.items[0].reason }], freedBytes: 0, before, after: before };
436
490
  }
437
491
  const record = (item, freed, note) => removed.push({ path: item.path, kind: item.kind, bytes: freed ?? item.bytes, reason: note || item.reason });
438
- for (const item of before.items) {
439
- if (!['remove', 'remove-if-proven', 'rotate', 'truncate'].includes(item.action)) {
492
+ // Rescue retention is applied only AFTER every rescue of this sweep, and never to a destination this sweep used: a
493
+ // retry that re-validates an aged-out rescue and then releases its copy must not also delete that rescue (review C).
494
+ const rescuedNow = new Set(); const rescue = (...args) => { const dest = rescueOperationalFiles(...args); if (dest) rescuedNow.add(dest); };
495
+ const deferred = before.items.filter((i) => i.kind === 'rescued-kb-logs' && i.action === 'remove');
496
+ for (const item of [...before.items.filter((i) => !deferred.includes(i)), ...deferred]) {
497
+ if (deferred.includes(item) && rescuedNow.has(item.path)) { kept.push({ path: item.path, kind: item.kind, reason: 'used by a rescue in this sweep; retention applies at the next sweep' }); continue; }
498
+ if (!['remove', 'remove-if-proven', 'rotate', 'truncate'].includes(item.action) && !reArchive(item)) {
440
499
  if (item.class === 'must-not-exist' || item.class === 'unowned') kept.push({ path: item.path, kind: item.kind, reason: item.blocked || item.reason, fix: item.fix || null });
441
500
  continue;
442
501
  }
@@ -449,6 +508,7 @@ export function sweepFootprint({ apply = false, collectPluginGenerations = null,
449
508
  if (item.kind === 'kb-copy') {
450
509
  const proof = prove(item.path);
451
510
  if (!proof.disposable) { kept.push({ path: item.path, kind: item.kind, reason: `KEPT: ${proof.reason}`, unique: proof.unique }); continue; }
511
+ if (apply) rescue(item.path, proof.rescue, roots.brainHome); // throws -> the copy is kept
452
512
  record(item, apply ? removeWithin(item.path, item.realParent, owned) : item.bytes, proof.reason);
453
513
  continue;
454
514
  }
@@ -456,9 +516,10 @@ export function sweepFootprint({ apply = false, collectPluginGenerations = null,
456
516
  const quarantineReal = physical(item.path);
457
517
  for (const child of names(item.path)) {
458
518
  const childPath = path.join(item.path, child);
459
- const proof = isKbTree(childPath) ? prove(childPath)
519
+ const proof = isKbTree(childPath) ? prove(childPath, `${path.basename(item.path)}--${child}`)
460
520
  : { disposable: false, unique: [], reason: 'not a KB copy; not ours to judge' };
461
521
  if (!proof.disposable) { uniqueLeft = true; kept.push({ path: childPath, kind: 'quarantined-copy', reason: `KEPT: ${proof.reason}`, unique: proof.unique }); continue; }
522
+ if (apply) rescue(childPath, proof.rescue, roots.brainHome, `${path.basename(item.path)}--${child}`); // throws -> kept
462
523
  record({ path: childPath, kind: 'quarantined-copy', bytes: treeBytes(childPath) }, apply ? removeWithin(childPath, quarantineReal, owned) : undefined, proof.reason);
463
524
  }
464
525
  if (!uniqueLeft && apply) removeWithin(item.path, item.realParent, owned);
@@ -488,7 +549,7 @@ export async function loadKbReaders(kbDir) {
488
549
  }
489
550
 
490
551
  // CLI: the detached SessionStart sweep. `node brain-footprint.mjs --sweep [--apply] [--json]`.
491
- if (process.argv[1] && pathToFileURL(physical(process.argv[1])).href === pathToFileURL(physical(fileURLToPath(import.meta.url))).href) {
552
+ if (isDirectInvocation(import.meta.url)) {
492
553
  const argv = process.argv.slice(2);
493
554
  const roots = footprintRoots();
494
555
  const readers = await loadKbReaders(roots.kbDir);
@@ -622,7 +622,7 @@ export const CAPABILITIES = [
622
622
  if (config.err) return row(STATE.UNKNOWN, 'managed native routing settings are unreadable; routing and savings are not inferred');
623
623
  if (!config.missing) {
624
624
  const value = config.value;
625
- if (!value || !path.isAbsolute(value.runtimeRoot || '') || !/^[a-f0-9]{64}$/.test(value.runtimeDigest || '')) return row(STATE.UNKNOWN, 'managed routing declaration is incomplete; no execution claim is made');
625
+ if (!value || !path.isAbsolute(value.runtimeRoot || '') || !/^[a-f0-9]{64}$/.test(value.runtimeDigest || '')) return row(STATE.UNKNOWN, 'managed routing declaration is incomplete (no absolute runtimeRoot or 64-hex runtimeDigest), so routing could not be checked; no execution claim is made');
626
626
  return row(STATE.UNKNOWN, 'a managed native routing runtime is configured. The retired Task dispatch hook is not a prerequisite. Configuration alone does not prove normal entry wiring, model allocation, execution or savings; those runtime facts were not checked by this declaration-only probe.');
627
627
  }
628
628
  return row(STATE.UNKNOWN, 'no managed routing declaration was observed in this inspected location; legacy Task routing receipts or binary presence do not establish current native routing or its absence');
@@ -13,6 +13,7 @@ import { spawnSync } from 'node:child_process';
13
13
  import path from 'node:path';
14
14
  import { fileURLToPath } from 'node:url';
15
15
  import { isHarnessGenerated } from './hook-input.mjs';
16
+ import { isDirectInvocation } from './entrypoint.mjs';
16
17
 
17
18
  export const UNKNOWN_RUNTIME_TOTAL_AGENT_CEILING = 4;
18
19
  const INPUT_LIMIT = 32 * 1024;
@@ -257,4 +258,4 @@ function main() {
257
258
  }
258
259
  }
259
260
 
260
- if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main();
261
+ if (isDirectInvocation(import.meta.url)) main();
@@ -0,0 +1,33 @@
1
+ // codex-overnight-notice.mjs — the unattended nightly never reloads the shared Codex app-server (owner decision,
2
+ // 4.7.21 review r2): a reload reaches Codex sessions already open, and Brain hooks pause in them until re-reviewed in
3
+ // /hooks, which nobody is awake to do. The nightly records this one-line notice instead; the next SessionStart shows
4
+ // it ONCE. Same claim-before-say contract as the other once-only SessionStart lines (.star-ask-shown, turn-capture
5
+ // notices): the file is claimed by an atomic rename BEFORE the line is emitted, so two sessions starting together
6
+ // cannot both show it, and a killed session cannot repeat it.
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+
10
+ export const CODEX_OVERNIGHT_FILE = 'codex-hooks-overnight-notice.json';
11
+ export const CODEX_OVERNIGHT_PREFIX = '[RuvNet Brain — CODEX HOOKS CHANGED OVERNIGHT]';
12
+ export const CODEX_OVERNIGHT_TEXT = 'Codex hooks changed overnight; open Codex sessions will pick them up on restart or after /hooks review.';
13
+
14
+ /** Written by the unattended update. Never throws: an unrecorded notice must not fail the nightly. */
15
+ export function recordCodexOvernightNotice(brainHome, { now = Date.now() } = {}) {
16
+ try {
17
+ fs.mkdirSync(brainHome, { recursive: true });
18
+ const file = path.join(brainHome, CODEX_OVERNIGHT_FILE);
19
+ const tmp = `${file}.tmp-${process.pid}`;
20
+ fs.writeFileSync(tmp, `${JSON.stringify({ schemaVersion: 1, at: new Date(now).toISOString() })}\n`, { mode: 0o600 });
21
+ fs.renameSync(tmp, file);
22
+ return true;
23
+ } catch { return false; }
24
+ }
25
+
26
+ /** Read by SessionStart: the line to show, at most once; '' when there is none or another session claimed it. */
27
+ export function claimCodexOvernightNotice(brainHome) {
28
+ const file = path.join(brainHome, CODEX_OVERNIGHT_FILE);
29
+ const claimed = `${file}.shown-${process.pid}`;
30
+ try { fs.renameSync(file, claimed); } catch { return ''; }
31
+ try { fs.rmSync(claimed, { force: true }); } catch { /* claimed already; a leftover is never shown again */ }
32
+ return `${CODEX_OVERNIGHT_PREFIX} ${CODEX_OVERNIGHT_TEXT}`;
33
+ }
@@ -16,13 +16,14 @@
16
16
  * (3) the answer discloses what is NOT verified.
17
17
  * Anything else is one correction request. No claim recognised → no verdict about the prose.
18
18
  *
19
- * HOSTS. Claude Code transcripts (`transcript_path`, JSONL) are parsed. Codex's Stop payload also
20
- * carries `transcript_path`, but its rollout format is NOT parsed anywhere in this repository
21
- * (turn-outcome-capture.mjs declines it), so on Codex the
22
- * transcript half is UNKNOWN and only the answer-side half (2)+(3) is enforced — disclosed in the
23
- * correction text, never silently treated as proven.
19
+ * HOSTS. Claude Code transcripts (`transcript_path`, JSONL) are parsed. A Codex rollout is read through
20
+ * the shared native-turn-transcript normalizer (#423) and held to a stricter evidence rule: a command
21
+ * counts as a check only with an explicit exit 0 (normalizeToolOutcome `pass`), an unknown native tool
22
+ * is a change boundary, and a rollout without a current-turn boundary yields no evidence (UNKNOWN).
24
23
  */
25
24
  import { readSettledTranscript } from './turn-outcome-capture.mjs';
25
+ import { nativeTurnLines } from './native-turn-transcript.mjs';
26
+ import { normalizeToolOutcome } from './continuity-events.mjs';
26
27
 
27
28
  const MAX_SENTENCE = 400;
28
29
  export const PROMISE_KIND = 'assistant-commitment';
@@ -135,8 +136,17 @@ export function currentTurnRecords(lines) {
135
136
  return { boundaryFound: start >= 0, prompt: start >= 0 ? textOf(recs[start].message?.content) : '', recs: recs.slice(start + 1) };
136
137
  }
137
138
 
138
- /** Ordered events for the current turn of a Claude JSONL transcript. */
139
- export function claudeTurnEvents(lines) {
139
+ // Codex tools that neither change state nor run a check; every other unrecognised tool is a change (fail
140
+ // toward asking). Grounded in codex-cli 0.164.0's tool handlers (plan, view_image, tool_search,
141
+ // mcp_resource/{list_mcp_resources,list_mcp_resource_templates,read_mcp_resource}, current_time,
142
+ // get_context_remaining, wait_for_environment, multi_agents/wait, sleep, request_user_input[_async]) and real
143
+ // rollouts (code-mode `wait` on a cell, `wait_agent`/`list_agents`). web_search is a hosted search, read-only.
144
+ // Acting tools stay changes: spawn_agent, send_message, followup_task, interrupt_agent, js, exec, update_goal.
145
+ const CODEX_NEUTRAL = /^(?:update_plan|view_image|sleep|list_agents|wait_agent|wait|request_user_input(?:_async)?|tool_search|web_search|list_mcp_resources|list_mcp_resource_templates|read_mcp_resource|current_time|get_context_remaining|wait_for_environment)$/;
146
+
147
+ /** Ordered events for the current turn of a Claude JSONL (or normalized Codex) transcript. */
148
+ export function claudeTurnEvents(lines, { host = 'claude' } = {}) {
149
+ const codex = host === 'codex';
140
150
  const { boundaryFound, recs: turnRecs } = currentTurnRecords(lines);
141
151
  const results = new Map();
142
152
  for (const o of turnRecs) {
@@ -145,7 +155,8 @@ export function claudeTurnEvents(lines) {
145
155
  for (const r of c) {
146
156
  if (r?.type === 'tool_result' && r.tool_use_id) {
147
157
  results.set(r.tool_use_id, { error: r.is_error === true || o.toolUseResult?.interrupted === true,
148
- present: textOf(r.content).trim().length > 0 || String(o.toolUseResult?.stdout || '').trim().length > 0 });
158
+ present: textOf(r.content).trim().length > 0 || String(o.toolUseResult?.stdout || '').trim().length > 0,
159
+ ...(codex ? { passed: normalizeToolOutcome(r).outcome === 'pass', nativeOk: r.is_error === false && r.status === 'completed' } : {}) });
149
160
  }
150
161
  }
151
162
  }
@@ -156,8 +167,10 @@ export function claudeTurnEvents(lines) {
156
167
  for (const u of c) {
157
168
  if (u?.type !== 'tool_use') continue;
158
169
  const input = u.input || {};
159
- const result = results.get(u.id) || { present: false, error: false };
160
- if (['Edit', 'Write', 'MultiEdit', 'NotebookEdit'].includes(u.name)) {
170
+ const found = results.get(u.id) || { present: false, error: false };
171
+ // Codex: a check needs an explicit exit 0 (commands) or a completed native status (MCP).
172
+ const { passed, nativeOk, ...result } = codex ? { ...found, error: found.error || !(u.name === 'Bash' ? found.passed : found.nativeOk) } : found;
173
+ if (['Edit', 'Write', 'MultiEdit', 'NotebookEdit'].includes(u.name) || (codex && u.name === 'apply_patch')) {
161
174
  events.push({ kind: 'change', what: `${u.name} ${input.file_path || input.notebook_path || ''}`.trim() });
162
175
  } else if (u.name === 'Bash') {
163
176
  for (const seg of segments(input.command)) {
@@ -169,6 +182,8 @@ export function claudeTurnEvents(lines) {
169
182
  } else if (String(u.name || '').startsWith('mcp__')) {
170
183
  events.push(MCP_MUTATING.test(u.name) ? { kind: 'change', what: u.name }
171
184
  : { kind: 'check', what: u.name, name: u.name.split('__').pop(), ...result });
185
+ } else if (codex && !CODEX_NEUTRAL.test(String(u.name || ''))) {
186
+ events.push({ kind: 'change', what: `codex ${String(u.name || 'tool').slice(0, 60)}` });
172
187
  }
173
188
  }
174
189
  }
@@ -185,9 +200,12 @@ export function postChangeVerification(turn) {
185
200
  staleChecks: events.slice(0, Math.max(0, lastChange)).filter((e) => e.kind === 'check').length };
186
201
  }
187
202
 
188
- export function readClaudeTurn(transcriptPath, { read = readSettledTranscript } = {}) {
203
+ export function readClaudeTurn(transcriptPath, { read = readSettledTranscript, host = 'claude' } = {}) {
189
204
  if (typeof transcriptPath !== 'string' || !/\.jsonl$/i.test(transcriptPath)) return null;
190
- try { return claudeTurnEvents(read(transcriptPath, { maxMs: 0 })); } catch { return null; }
205
+ try {
206
+ const turn = claudeTurnEvents(nativeTurnLines(read(transcriptPath, { maxMs: 0 }), host), { host });
207
+ return host === 'codex' && !turn.boundaryFound ? null : turn;
208
+ } catch { return null; }
191
209
  }
192
210
 
193
211
  /**
@@ -227,6 +245,10 @@ const PROMISE_REJECT = /\?|:\*\*|\b(?:if|unless|once|when|whenever|as\s+soon\s+a
227
245
  const TRIVIAL_PROMISE = /^(?:not|never|be|say|mention|note|admit|point|flag|stop|stay|leave|keep|summari[sz]e|explain|answer|respond|reply|report|remember|give|tell|send|paste|bring|hand|show|walk|notify|post|update\s+you|come\s+back|get\s+back|handle|only|stand|work)\b/i;
228
246
  const OWNER_DEPENDENT_OFFER = /\b(?:say\s+the\s+word|(?:just|simply)\s+(?:ask|say)|ask\s+me|let\s+me\s+know|give\s+me\s+the\s+(?:word|go|nod|ok)|on\s+your\s+(?:word|go|ok|say)|up\s+to\s+you)\b/i;
229
247
  const STANDING_APPROVAL = /^(?:ask|confirm|check\s+with\s+you|clear\s+(?:it|this|that)\s+with\s+you|run\s+.+\s+by\s+you)\b.*\b(?:before|first)\b/i;
248
+ // "hide it the same way", "check for it then": a verb whose only object is a pronoun has no referent
249
+ // once the turn's context is gone (#369), so no later session could ever close it honestly.
250
+ const PRONOUN_OBJECT = /\b(?:it|this|that|them|these|those)\b/i;
251
+ const PRONOUN_ONLY = /^\S+(?:\s+(?:it|this|that|them|these|those|the|same|way|then|again|too|also|now|later|for|to|on|in|into|at|about|with|up|out|back|down|over|off|as|well|a|an|there|here|first|next))*$/i;
230
252
 
231
253
  export const normalizePromise = (value) => String(value || '').toLowerCase().replace(/[`*_"“”'’]/g, '')
232
254
  .replace(/[^a-z0-9./:+-]+/g, ' ').trim().slice(0, 160);
@@ -248,6 +270,7 @@ export function extractCommitments(message) {
248
270
  if (PROMISE_REJECT.test(whole) || OWNER_DEPENDENT_OFFER.test(whole)
249
271
  || STANDING_APPROVAL.test(action.trim()) || TRIVIAL_PROMISE.test(action.trim())) continue;
250
272
  const text = action.replace(/\s+/g, ' ').trim().replace(/[,;:]$/, '');
273
+ if (PRONOUN_OBJECT.test(text) && PRONOUN_ONLY.test(text)) continue;
251
274
  const key = normalizePromise(text);
252
275
  if (key.length < 6 || seen.has(key)) continue;
253
276
  seen.add(key);
@@ -269,3 +292,24 @@ export function claimClosesPromise(claimText, promiseText) {
269
292
  const hit = want.filter((t) => have.has(t)).length;
270
293
  return hit >= 2 && hit / want.length >= 0.4;
271
294
  }
295
+
296
+ const unquoted = (value) => String(value).replace(/"([^"\n]{0,300})"|“([^”\n]{0,300})”/g, (_, a, b) => a ?? b);
297
+
298
+ /**
299
+ * The claim that closes `promiseText`, or null. Only a PASS verdict can close a promise. strippedProse()
300
+ * removes double-quoted text, so `"push the branch itself" is done` extracts only `is done`; that claim
301
+ * may still close the promise through its raw sentence (quotes kept), which becomes the evidence. The
302
+ * sentence must be a recognised claim outside its quotes, so quoting alone never makes a claim (#369).
303
+ */
304
+ export function promiseClosingClaim(completion, answer, promiseText) {
305
+ // DORMANT: auditCompletionClaims no longer returns PASS (only OBSERVED_CHECK/UNKNOWN/FAIL/NONE), so this never closes a promise today.
306
+ if (completion?.verdict !== 'PASS') return null;
307
+ const direct = completion.claims.find((c) => claimClosesPromise(c.text, promiseText));
308
+ if (direct) return direct;
309
+ for (const raw of String(answer || '').split(/(?<=[.!?])\s+|\n+/)) {
310
+ const own = sentences(raw);
311
+ const claim = completion.claims.find((c) => own.includes(c.text));
312
+ if (claim && claimClosesPromise(unquoted(raw), promiseText)) return { ...claim, text: raw.trim() };
313
+ }
314
+ return null;
315
+ }
@@ -53,9 +53,10 @@ import { deferralAtStop } from './deferral-evidence.mjs';
53
53
  import { readSettledTranscript } from './turn-outcome-capture.mjs';
54
54
  import { continuationProjectIdentity, authorizedContinuationObjective, authorizedPromiseItems, assistantCommitmentOwned, setAssistantCommitmentState } from './continuation-objective.mjs';
55
55
  import {
56
- auditCompletionClaims, readClaudeTurn, extractCommitments, claimClosesPromise,
56
+ auditCompletionClaims, readClaudeTurn, extractCommitments, promiseClosingClaim,
57
57
  PROMISE_KIND, PROMISE_CAP_OPEN,
58
58
  } from './completion-claim-evidence.mjs';
59
+ import { writeAtomic } from './user-settings.mjs';
59
60
 
60
61
  const HOME = os.homedir();
61
62
 
@@ -181,7 +182,12 @@ function load() {
181
182
  function save(led) {
182
183
  try {
183
184
  fs.mkdirSync(path.dirname(LEDGER), { recursive: true });
184
- fs.writeFileSync(LEDGER, JSON.stringify({ ...led, updated: new Date().toISOString() }, null, 2) + '\n');
185
+ // writeAtomic (not a bare writeFileSync): writeFileSync truncates the target before writing the
186
+ // new bytes, so a kill -9 / ENOSPC / EIO between those two steps leaves LEDGER empty — and load()'s
187
+ // catch-all JSON.parse failure then reads that as "nothing was ever committed", silently discarding
188
+ // every item a prior turn agreed to do, not just the one being added now. Same house pattern as
189
+ // user-settings.mjs's own writeAtomic(): temp-in-the-same-directory + fsync + rename.
190
+ writeAtomic(LEDGER, JSON.stringify({ ...led, updated: new Date().toISOString() }, null, 2) + '\n');
185
191
  return true;
186
192
  } catch { return false; /* the ledger is advisory — never break a turn over it */ }
187
193
  }
@@ -366,15 +372,15 @@ const HOST = process.env.RUVNET_HOOK_HOST === 'codex' ? 'codex' : 'claude';
366
372
  * COMPLETION CLAIMS (ADR-074 class `completion`, owner 2026-09-30: "You can never ever tell me
367
373
  * something is done and implemented without having tested it end to end"). Audited ONCE per Stop:
368
374
  * the verdict both requests a correction (below, under every loop guard) and is the only evidence
369
- * that may close a captured promise. Claude transcripts are parsed; Codex's rollout format is not
370
- * parsed anywhere in this repo, so on Codex only the answer-side half is enforced and the transcript
371
- * half is reported UNKNOWN (completion-claim-evidence.mjs header). Fails open: a throw is NONE.
375
+ * that may close a captured promise. Claude transcripts and Codex rollouts (through the shared
376
+ * nativeTurnLines reader, #423) are parsed; a Codex rollout that cannot be read or has no current-turn
377
+ * boundary stays UNKNOWN (completion-claim-evidence.mjs header). Fails open: a throw is NONE.
372
378
  */
373
379
  const completion = (() => {
374
380
  const message = String(hookInput.last_assistant_message || '');
375
381
  if (!message || !hookInput.session_id) return { verdict: 'NONE', claims: [] };
376
382
  try {
377
- const turn = HOST === 'claude' ? readClaudeTurn(hookInput.transcript_path) : null;
383
+ const turn = readClaudeTurn(hookInput.transcript_path, { host: HOST });
378
384
  // Claude always sends a JSONL transcript; one we cannot read is OUR failure, and a Stop hook
379
385
  // fails open on its own machinery (ADR-074 failure semantics) — never a correction request.
380
386
  // Missing native evidence stays UNKNOWN; it cannot silently validate a completion claim.
@@ -401,7 +407,7 @@ function promiseBookkeeping() {
401
407
  if (completion.verdict === 'PASS') {
402
408
  for (const item of led.items) {
403
409
  if (!assistantCommitmentOwned(item, hookInput.session_id, projectIdentity)) continue;
404
- const claim = completion.claims.find((c) => claimClosesPromise(c.text, item.text));
410
+ const claim = promiseClosingClaim(completion, hookInput.last_assistant_message, item.text);
405
411
  if (!claim) continue;
406
412
  const verifiedCompletion = completion.verdict === 'PASS' && Boolean(claim);
407
413
  Object.assign(item, { done: verifiedCompletion, state: verifiedCompletion ? 'completed' : item.state, doneAt: at, completionEvidence: { claim: claim.text,
@@ -715,7 +721,19 @@ if (observations.length && STOP_NUDGES_FULL) console.error(JSON.stringify({ kind
715
721
  authority: false, items: observations.map(({ text, at }) => ({ text, at })) }));
716
722
  // ONE correction per turn: a single item however many claims the answer made; the stop_hook_active
717
723
  // exit and the cooldown lock below bound it to one request per stop episode.
718
- const completionWork = ['FAIL', 'UNKNOWN'].includes(completion.verdict) ? [{
724
+ // #425 (owner decision): an UNKNOWN whose ONLY problem is task scope — a check observed after the last
725
+ // change, named in the answer, with what is NOT verified disclosed — is not a correction. The audit
726
+ // recognizes check-scoped sentences only; it has no representation for a scoped task outcome, so
727
+ // re-running checks or rewording cannot satisfy it. The verdict stays UNKNOWN (never PASS: it closes
728
+ // no promise and grants no completion authority); under `full` the limitation is reported, not enforced.
729
+ const scopeOnlyUnknown = completion.verdict === 'UNKNOWN' && completion.namesCheck === true
730
+ && completion.disclosesGaps === true && (completion.verification?.checks?.length || 0) > 0;
731
+ if (scopeOnlyUnknown && STOP_NUDGES_FULL) console.error(JSON.stringify({ kind: 'completion-scope-advisory',
732
+ authority: false, verdict: 'UNKNOWN', text: `"${completion.claims[0].text.slice(0, 160)}": a check ran after the last change`
733
+ + ' and the answer names it and discloses what is not verified. This gate cannot judge whether that task outcome'
734
+ + ' is complete (it only qualifies check-scoped statements), so completion stays UNKNOWN. Not blocking: more checks'
735
+ + ' or rewording cannot change this verdict.' }));
736
+ const completionWork = ['FAIL', 'UNKNOWN'].includes(completion.verdict) && !scopeOnlyUnknown ? [{
719
737
  text: `You claimed "${completion.claims[0].text.slice(0, 160)}" is done; ${completion.problems.join('; ')}. Run the real consumer path, or restate it as UNVERIFIED.`,
720
738
  at: new Date(nowMs).toISOString(), derived: true, kind: 'completion-claim-integrity',
721
739
  }] : [];
@@ -815,7 +833,8 @@ function claimCooldown(now, windowMs) {
815
833
  if (Number.isFinite(prev) && (now - prev) < windowMs) return false; // fresh lock: someone forced recently
816
834
  fs.unlinkSync(LOCK); // stale: clear it so we can re-claim
817
835
  } catch { /* no lock yet */ }
818
- try { fs.writeFileSync(LOCK, new Date(now).toISOString(), { flag: 'wx' }); return true; }
836
+ // A fresh install has no work-ledgers/ folder until the first save(); ENOENT here is not a lost race.
837
+ try { fs.mkdirSync(path.dirname(LOCK), { recursive: true }); fs.writeFileSync(LOCK, new Date(now).toISOString(), { flag: 'wx' }); return true; }
819
838
  catch { return false; } // lost the race / cannot persist → fail closed
820
839
  }
821
840
  if (!claimCooldown(nowMs, COOLDOWN_MS)) process.exit(EXIT_ALLOW);
@@ -25,13 +25,12 @@ import { fileURLToPath } from 'node:url';
25
25
  // an install hint into the middle of a refusal reason, or manufactured stderr on an allow. Read the
26
26
  // signature, do not infer it from the name.
27
27
  import { resolveBash } from './hook-shim-bash.mjs';
28
- import { append as appendOutcome, actionKey, recordRefusal, resolve as resolveOutcome, sweepStale } from './decision-outcomes.mjs';
28
+ import { append as appendOutcome, actionKey, recordRefusal, reportBudget, reportSelfSkipped, resolve as resolveOutcome, sweepStale } from './decision-outcomes.mjs';
29
29
  // Grok sends its own tool names (`write`) and a snake_case event; every policy below reads Claude's
30
30
  // shape. Normalised ONCE here, so the policies (four of them bash, matching tool_name textually) never
31
31
  // see a host-native spelling — the 4.4 measurement was a Grok write silently allowed. hook-input.mjs.
32
32
  import { normalizePayloadText } from './hook-input.mjs';
33
-
34
- const QUIET_FULL = String(process.env.RUVNET_STOP_NUDGES || '').toLowerCase() === 'full';
33
+ import { isDirectInvocation } from './entrypoint.mjs';
35
34
 
36
35
  const SCRIPTS_DIR = path.dirname(fileURLToPath(import.meta.url));
37
36
  const EVENT = process.argv[2] || '';
@@ -93,8 +92,8 @@ export const MIN_HEADROOM_MS = 3000;
93
92
  *
94
93
  * 1. ALLOW ON TIMEOUT. A policy still running at the budget is killed and the write is ALLOWED — a gate
95
94
  * that blocks because it is slow is one users switch off. Never silent: the outcome ledger records
96
- * `budget-exceeded` with the policies that did not vote, and ONE stderr line says the allow was a
97
- * timeout, not a verdict (reportBudget below).
95
+ * `budget-exceeded` with the policies that did not vote; a stderr line saying the allow was a timeout,
96
+ * not a verdict, is printed only on a refusal or under RUVNET_STOP_NUDGES=full (decision-outcomes.mjs).
98
97
  *
99
98
  * 2. OVERSIZE: ONLY THE FIRST 64 KiB IS CHECKED. The host hands this gate the payload through
100
99
  * hook-shim.mjs, which reads at most TRANSPORT_CAP_BYTES (its TABLE entry), and the bash policies cap
@@ -206,7 +205,7 @@ export function policiesFor(event, registry = REGISTRY, all = REFUSAL_POLICIES)
206
205
  /**
207
206
  * Resolved once per invocation by the runtime block below; null means no bash on this host.
208
207
  *
209
- * Declared HERE, and not next to runPolicy() where it reads more naturally: the `if (isMain())`
208
+ * Declared HERE, and not next to runPolicy() where it reads more naturally: the runtime
210
209
  * block runs during module evaluation, so a `let` declared after it sits in the temporal dead zone
211
210
  * and the assignment throws — the identical mistake `speechEventFor` was already a hoisted
212
211
  * `function` to avoid, recorded a few lines further down.
@@ -285,13 +284,14 @@ function payloadInput(payload) { return parsed(payload).tool_input || {}; }
285
284
  /**
286
285
  * The sub-event token unprompted-runtime switches on.
287
286
  *
288
- * A hoisted `function`, not a `const` arrow: the `if (isMain())` block below runs DURING module
287
+ * A hoisted `function`, not a `const` arrow: the runtime block below runs DURING module
289
288
  * evaluation, so an arrow declared after it sits in the temporal dead zone — it threw
290
289
  * `Cannot access 'speechEventFor' before initialization` on this gate's first live refusal.
291
290
  */
292
291
  function speechEventFor(event) { return event === 'bash' ? 'PreToolUse-bash' : 'PreToolUse-write'; }
293
292
  // ── Runtime ──────────────────────────────────────────────────────────────────────────────────────
294
- if (isMain()) {
293
+ // isDirectInvocation never throws (entrypoint.mjs), so an importer cannot be crashed by this guard.
294
+ if (isDirectInvocation(import.meta.url)) {
295
295
  const started = Date.now();
296
296
  const payload = normalizePayloadText(readPayload());
297
297
  const shape = classifyPayload(payload);
@@ -348,14 +348,14 @@ if (isMain()) {
348
348
  }
349
349
  } catch { /* a ledger must never break a tool call */ }
350
350
  };
351
- reportSelfSkipped({ session, selfSkipped });
351
+ reportSelfSkipped({ event: EVENT, session, selfSkipped });
352
352
  if (decision.allow && shape.truncated) {
353
353
  // OVERSIZE POLICY (header): allowed on a check of the first TRANSPORT_CAP_BYTES only. Recorded, never silent.
354
354
  try { appendOutcome({ kind: 'payload-oversize', event: EVENT, session, bytesSeen: shape.bytes, capBytes: TRANSPORT_CAP_BYTES, tool: shape.tool, filePath: shape.filePath, ts: Date.now() }); } catch { /* a ledger must never break a tool call */ }
355
355
  }
356
356
  if (!decision.allow) {
357
357
  measureFinalDecision(false);
358
- reportBudget({ session, unconsulted, trace, started, budgetMs, refused: true });
358
+ reportBudget({ event: EVENT, session, unconsulted, trace, started, budgetMs, refused: true });
359
359
  process.stderr.write(`${decision.reason}\n`);
360
360
  process.exit(REFUSE);
361
361
  }
@@ -373,65 +373,22 @@ if (isMain()) {
373
373
  if (speech.skipped === 'budget') unconsulted.push(SPEECH.id);
374
374
  if (speech.code === REFUSE) {
375
375
  measureFinalDecision(false, [...decision.refusals, SPEECH.id]);
376
- reportBudget({ session, unconsulted, trace, started, budgetMs, refused: true });
376
+ reportBudget({ event: EVENT, session, unconsulted, trace, started, budgetMs, refused: true });
377
377
  process.stderr.write(`${(speech.stderr || '').trim()}\n`);
378
378
  process.exit(REFUSE);
379
379
  }
380
- reportBudget({ session, unconsulted, trace, started, budgetMs });
380
+ reportBudget({ event: EVENT, session, unconsulted, trace, started, budgetMs });
381
381
  measureFinalDecision(true);
382
382
  const context = policyContext(results, speech.stdout || '');
383
383
  if (context) process.stdout.write(context);
384
384
  process.exit(ALLOW);
385
385
  }
386
- reportBudget({ session, unconsulted, trace, started, budgetMs });
386
+ reportBudget({ event: EVENT, session, unconsulted, trace, started, budgetMs });
387
387
  measureFinalDecision(true);
388
388
  const context = policyContext([...results, ...unconsulted.filter((id) => !results.some((r) => r.id === id && r.skipped)).map((id) => ({ id, skipped: 'budget' }))]);
389
389
  if (context) process.stdout.write(context);
390
390
  process.exit(ALLOW);
391
391
  }
392
- /**
393
- * ── A BUDGET THAT CAN BE EXCEEDED SILENTLY FAILS OPEN WITHOUT SAYING SO ──────────────────────────
394
- *
395
- * The old loop did `break` when the budget ran out. Every remaining policy AND the speech chokepoint
396
- * were then skipped with no record anywhere — the gate allowed, and nothing distinguished "five
397
- * policies agreed this was fine" from "we ran out of time and stopped asking". That is this repo's
398
- * signature defect wearing a different hat: silence standing in for a measurement. degradation-watch
399
- * exists because a warning was printed and skimmed; this exists because nothing was printed at all.
400
- *
401
- * TWO CHANNELS, because each fails differently:
402
- * · the outcome ledger (~/.config/ruvnet-brain/, survives `--update`) so a trip COMPOUNDS into
403
- * evidence instead of scrolling past. `kind: 'budget-exceeded'` is inert in report()'s buckets —
404
- * it counts neither as a refusal nor as a resolution, so it cannot move the obedience rate.
405
- * · one stderr line, unconditionally, allow or refuse. Yes, that puts bytes on stderr during an
406
- * exit-0 allow, which tests/unit/decision-gate.test.mjs asserts never happens on an ordinary
407
- * write. That assertion is now a SECOND tripwire and is meant to be: after the parallel batch and
408
- * the applicability skip, an ordinary write measures ~360ms against a 4000ms budget, so a trip
409
- * there is not noise to be tolerated — it is the defect, and the suite should go red for it.
410
- */
411
- function reportBudget({ session, unconsulted, trace, started, budgetMs, refused = false }) {
412
- const elapsed = Date.now() - started;
413
- if (process.env.RUVNET_DECISION_TRACE === '1') {
414
- process.stderr.write(`[decision-gate] ${EVENT} ${elapsed}ms budget=${budgetMs}ms ${JSON.stringify(trace)}\n`);
415
- }
416
- if (!unconsulted.length) return;
417
- try {
418
- appendOutcome({ kind: 'budget-exceeded', event: EVENT, session, unconsulted, elapsedMs: elapsed, budgetMs, ts: Date.now() });
419
- } catch { /* a ledger must never break a tool call */ }
420
- // Quiet by default (2026-10-15): the outcome is already in the ledger above. On an ALLOW, a stderr line only
421
- // makes the host show a hook "error" for a decision that was made. It is kept when the action was refused
422
- // and under RUVNET_STOP_NUDGES=full.
423
- if (refused || QUIET_FULL) process.stderr.write(
424
- `[decision-gate] ${budgetMs}ms budget exhausted after ${elapsed}ms — ${refused ? 'REFUSED; UNCONSULTED' : 'ALLOWED WITHOUT CONSULTING'}: `
425
- + `${unconsulted.join(', ')}. These policies did not vote; ${refused ? 'another policy refused the action.' : 'this allow is a timeout, not a verdict.'}\n`,
426
- );
427
- }
428
- /** Record and print each policy that skipped itself (exit 3), with its own one-line reason. */
429
- function reportSelfSkipped({ session, selfSkipped }) {
430
- for (const r of selfSkipped) {
431
- try { appendOutcome({ kind: 'policy-skipped', event: EVENT, session, policy: r.id, reason: r.reason, ts: Date.now() }); } catch { /* a ledger must never break a tool call */ }
432
- if (QUIET_FULL) process.stderr.write(`[decision-gate] ${r.reason || `${r.id} skipped`} — ${r.id} did not vote; it supplied no authorization verdict.\n`);
433
- }
434
- }
435
392
  /**
436
393
  * Run one policy as a CAPTURED child. Never lets its bytes touch the real streams.
437
394
  *
@@ -494,10 +451,3 @@ function readPayload() {
494
451
  if (process.stdin.isTTY) return '';
495
452
  try { return fs.readFileSync(0, 'utf8'); } catch { return ''; }
496
453
  }
497
- /** Never able to crash a caller that merely imported this (see tests/unit/entrypoint-guard-safety). */
498
- function isMain() {
499
- try {
500
- if (!process.argv[1]) return false;
501
- return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
502
- } catch { return false; }
503
- }