ruvnet-brain 4.7.20 → 4.7.22

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 (260) hide show
  1. package/README.md +2 -2
  2. package/bin/install.mjs +618 -104
  3. package/console/activity.js +122 -38
  4. package/kb/download-retry.mjs +93 -21
  5. package/kb/forge-guard-injection.mjs +277 -0
  6. package/kb/forge-update.mjs +166 -38
  7. package/kb/lifecycle-evidence-retention.mjs +151 -14
  8. package/kb/model-requirements.mjs +4 -1
  9. package/kb/refresh-run.mjs +175 -27
  10. package/kb/verify-citation.mjs +15 -2
  11. package/package.json +3 -1
  12. package/plugin/.claude-plugin/plugin.json +1 -1
  13. package/plugin/.codex-plugin/plugin.json +1 -1
  14. package/plugin/mcp/managed-cli-interface.mjs +16 -8
  15. package/plugin/scripts/adr-currency-gate.mjs +2 -1
  16. package/plugin/scripts/advisory-contract.mjs +21 -0
  17. package/plugin/scripts/advocacy-route.mjs +5 -4
  18. package/plugin/scripts/agentdb-recall-process.mjs +115 -5
  19. package/plugin/scripts/agentdb-recall-reader.mjs +44 -2
  20. package/plugin/scripts/agentdb-recall.mjs +49 -17
  21. package/plugin/scripts/automatic-update.mjs +9 -1
  22. package/plugin/scripts/brain-confirmation.mjs +2 -1
  23. package/plugin/scripts/brain-footprint.mjs +72 -11
  24. package/plugin/scripts/capability-registry.mjs +1 -1
  25. package/plugin/scripts/capacity-aware-parallel-work.mjs +2 -1
  26. package/plugin/scripts/codex-overnight-notice.mjs +33 -0
  27. package/plugin/scripts/completion-claim-evidence.mjs +241 -23
  28. package/plugin/scripts/continuation-gate.mjs +75 -11
  29. package/plugin/scripts/decision-gate.mjs +13 -63
  30. package/plugin/scripts/decision-outcomes.mjs +36 -0
  31. package/plugin/scripts/decision-recall-gate.mjs +40 -5
  32. package/plugin/scripts/decision-record.mjs +8 -3
  33. package/plugin/scripts/degradation-watch.mjs +2 -1
  34. package/plugin/scripts/developer-update-lock.mjs +172 -33
  35. package/plugin/scripts/developer-update-owner.mjs +8 -5
  36. package/plugin/scripts/developer-update-policy.mjs +4 -1
  37. package/plugin/scripts/developer-update.mjs +155 -53
  38. package/plugin/scripts/doc-currency.mjs +2 -16
  39. package/plugin/scripts/duplicate-gate.mjs +2 -1
  40. package/plugin/scripts/entrypoint.mjs +34 -0
  41. package/plugin/scripts/footprint-io.mjs +135 -8
  42. package/plugin/scripts/grok-hook-adapter.mjs +2 -1
  43. package/plugin/scripts/ground-ruvnet.sh +6 -3
  44. package/plugin/scripts/grounding-answer.mjs +2 -1
  45. package/plugin/scripts/grounding-code-projection.mjs +2 -1
  46. package/plugin/scripts/grounding-substance.mjs +2 -1
  47. package/plugin/scripts/grounding-turn-gate.mjs +2 -2
  48. package/plugin/scripts/grounding-turn-mark.mjs +2 -2
  49. package/plugin/scripts/hook-context-budget.mjs +2 -1
  50. package/plugin/scripts/hook-input.mjs +2 -2
  51. package/plugin/scripts/hook-shim.mjs +9 -2
  52. package/plugin/scripts/host-update.mjs +29 -5
  53. package/plugin/scripts/identifier-preflight.mjs +2 -1
  54. package/plugin/scripts/kb-copy-proof.mjs +57 -9
  55. package/plugin/scripts/learn-capture.mjs +2 -1
  56. package/plugin/scripts/learning-store.mjs +62 -16
  57. package/plugin/scripts/lesson-bridge.mjs +2 -2
  58. package/plugin/scripts/lesson-gate.mjs +7 -39
  59. package/plugin/scripts/lesson-promote.mjs +83 -22
  60. package/plugin/scripts/lesson-scope.mjs +51 -0
  61. package/plugin/scripts/lesson-store.mjs +1 -1
  62. package/plugin/scripts/md-stamp.mjs +2 -1
  63. package/plugin/scripts/native-turn-transcript.mjs +98 -6
  64. package/plugin/scripts/nightly-scheduler.mjs +10 -5
  65. package/plugin/scripts/package-recommender.mjs +2 -2
  66. package/plugin/scripts/project-store-resolver.mjs +19 -0
  67. package/plugin/scripts/routing-outcome-capture.mjs +2 -1
  68. package/plugin/scripts/ruflo-bin.mjs +14 -6
  69. package/plugin/scripts/ruflo-memory-hooks.mjs +157 -19
  70. package/plugin/scripts/session-snapshot-hook.mjs +25 -5
  71. package/plugin/scripts/session-start-core.mjs +7 -16
  72. package/plugin/scripts/session-start-health.mjs +98 -10
  73. package/plugin/scripts/session-start-issue-alert.mjs +63 -0
  74. package/plugin/scripts/session-start-update-plane.mjs +15 -1
  75. package/plugin/scripts/signal-watch.mjs +2 -1
  76. package/plugin/scripts/spend-guard.mjs +2 -1
  77. package/plugin/scripts/substitution-wall.mjs +2 -1
  78. package/plugin/scripts/turn-outcome-capture.mjs +7 -3
  79. package/plugin/scripts/unprompted-runtime.mjs +20 -3
  80. package/plugin/scripts/update-apply.mjs +186 -15
  81. package/plugin/skills/release-proof/scripts/release-proof.mjs +2 -1
  82. package/plugin/skills/ruvnet-brain/PLAYBOOK.md +2 -2
  83. package/plugin/skills/ruvnet-brain/SKILL.md +3 -2
  84. package/scripts/adr-072-completion.mjs +2 -1
  85. package/scripts/agentdb-context.mjs +2 -1
  86. package/scripts/agentdb-fleet-doctor.mjs +30 -8
  87. package/scripts/approved-runtime.mjs +2 -1
  88. package/scripts/architecture-review-lock.mjs +2 -1
  89. package/scripts/ascii-drift.mjs +2 -1
  90. package/scripts/brain-latency-50.mjs +2 -1
  91. package/scripts/brain-novice-50.mjs +2 -16
  92. package/scripts/brain-score.mjs +13 -2
  93. package/scripts/build-bundle.mjs +2 -1
  94. package/scripts/build-concepts.mjs +2 -1
  95. package/scripts/candidate-ci-receipt.mjs +2 -1
  96. package/scripts/candidate-host-evidence.mjs +2 -1
  97. package/scripts/card-from-source.mjs +2 -1
  98. package/scripts/ci/warm-brain-models.mjs +2 -1
  99. package/scripts/claims-verify.mjs +7 -3
  100. package/scripts/claude-controlled-terminal.mjs +2 -1
  101. package/scripts/claude-terminal-mod.mjs +2 -1
  102. package/scripts/code-release-corpus.mjs +2 -1
  103. package/scripts/codex-boot-surface-proof.mjs +208 -0
  104. package/scripts/codex-hook-trust-reconcile.mjs +67 -1
  105. package/scripts/codex-hook-trust.mjs +40 -1
  106. package/scripts/codex-hook-wrapper-history.mjs +2 -1
  107. package/scripts/codex-host-execution-proof.mjs +2 -1
  108. package/scripts/completion-claim-replay.mjs +2 -1
  109. package/scripts/console-engine.mjs +8 -3
  110. package/scripts/corpus-canary.mjs +2 -1
  111. package/scripts/corpus-candidate.mjs +2 -2
  112. package/scripts/corpus-coverage-sidecar.mjs +2 -1
  113. package/scripts/corpus-dispatch-decision.mjs +2 -1
  114. package/scripts/corpus-dispatch-receipt.mjs +2 -1
  115. package/scripts/corpus-next-seed.mjs +2 -1
  116. package/scripts/corpus-qa.mjs +2 -1
  117. package/scripts/corpus-reconcile.mjs +2 -2
  118. package/scripts/corpus-watchdog.mjs +2 -1
  119. package/scripts/count-chunks.mjs +2 -1
  120. package/scripts/customer-state-matrix.mjs +2 -1
  121. package/scripts/derive-passage-content-map.mjs +2 -1
  122. package/scripts/development-push-check.mjs +2 -1
  123. package/scripts/dispatch-receipt.mjs +2 -1
  124. package/scripts/doc-currency.mjs +2 -1
  125. package/scripts/dream-issue-gate.mjs +2 -1
  126. package/scripts/dual-host-deliberation.mjs +2 -16
  127. package/scripts/dual-host-suggest.mjs +2 -16
  128. package/scripts/eval-brain.mjs +7 -3
  129. package/scripts/execution-policy.mjs +2 -1
  130. package/scripts/execution-preflight.mjs +2 -1
  131. package/scripts/falsify.mjs +2 -1
  132. package/scripts/fix-metaharness-memretrieve.mjs +2 -1
  133. package/scripts/fix-workstream.mjs +2 -1
  134. package/scripts/full-hints.mjs +2 -1
  135. package/scripts/full-suite-gate.mjs +2 -1
  136. package/scripts/grok-host.mjs +68 -0
  137. package/scripts/hook-qualify-core.mjs +3 -1
  138. package/scripts/hook-qualify.mjs +2 -1
  139. package/scripts/hook-retirement-check.mjs +2 -1
  140. package/scripts/host-registry.mjs +2 -1
  141. package/scripts/independent-review-receipt.mjs +2 -1
  142. package/scripts/ingest-new-repos.mjs +2 -1
  143. package/scripts/ingest-repo.mjs +12 -2
  144. package/scripts/integration-evidence.mjs +2 -1
  145. package/scripts/issue-fix.mjs +7 -24
  146. package/scripts/issue-watch.mjs +116 -40
  147. package/scripts/job-heartbeat.sh +54 -19
  148. package/scripts/key-canary.mjs +9 -3
  149. package/scripts/latency-to-surface.mjs +2 -1
  150. package/scripts/learning-replay-cli.mjs +97 -19
  151. package/scripts/learning-replay-execution.mjs +2 -0
  152. package/scripts/learning-replay-fixture.mjs +3 -0
  153. package/scripts/learning-replay-proof.mjs +14 -13
  154. package/scripts/learning-replay.mjs +3 -3
  155. package/scripts/learnings.mjs +2 -1
  156. package/scripts/lesson-ratify.mjs +88 -40
  157. package/scripts/loop-checkpoint.mjs +2 -1
  158. package/scripts/metaharness-receipts.mjs +2 -1
  159. package/scripts/model-catalog.mjs +2 -1
  160. package/scripts/model-currency-evidence.mjs +6 -4
  161. package/scripts/model-currency.mjs +33 -3
  162. package/scripts/model-native-catalog.mjs +2 -1
  163. package/scripts/model-router-agent-hook.mjs +2 -1
  164. package/scripts/model-router-dispatch.mjs +2 -1
  165. package/scripts/model-router-engine.mjs +2 -1
  166. package/scripts/model-router-outcome.mjs +2 -1
  167. package/scripts/model-router-setup.mjs +2 -1
  168. package/scripts/model-routing-eval.mjs +2 -1
  169. package/scripts/model-routing-gateway.mjs +2 -1
  170. package/scripts/model-routing-launchers.mjs +4 -1
  171. package/scripts/model-terminal-gateway.mjs +2 -1
  172. package/scripts/model-terminal-launchers.mjs +2 -1
  173. package/scripts/model-weekly-analyst.mjs +2 -1
  174. package/scripts/model-weekly-cycle.mjs +2 -1
  175. package/scripts/model-weekly-qualification.mjs +2 -1
  176. package/scripts/nightly-gists.sh +26 -20
  177. package/scripts/nightly-two-run-proof.mjs +2 -1
  178. package/scripts/nightly-watchdog.mjs +158 -47
  179. package/scripts/no-silent-substitution.mjs +2 -1
  180. package/scripts/notify.sh +11 -6
  181. package/scripts/ntfy-inbox.mjs +41 -18
  182. package/scripts/onboarding-console.mjs +123 -23
  183. package/scripts/oracle/abstain-threshold-sweep.mjs +2 -1
  184. package/scripts/oracle/abstain-trace.mjs +2 -1
  185. package/scripts/oracle/doc2query-generate.mjs +2 -1
  186. package/scripts/oracle/doc2query-reach.mjs +2 -1
  187. package/scripts/oracle/judge-train.mjs +2 -1
  188. package/scripts/oracle/need-set-split.mjs +2 -1
  189. package/scripts/oracle/produce-questions.mjs +2 -16
  190. package/scripts/oracle/repo-recall.mjs +2 -2
  191. package/scripts/oracle/retrieval-accuracy.mjs +2 -2
  192. package/scripts/oracle/sona-query-adapter-eval.mjs +2 -1
  193. package/scripts/oracle/source-tree.mjs +2 -15
  194. package/scripts/oracle/source-units.mjs +2 -16
  195. package/scripts/oracle/spike-run.mjs +2 -16
  196. package/scripts/oracle/validate-labels.mjs +2 -16
  197. package/scripts/org-repo-count.mjs +2 -1
  198. package/scripts/package-cards.mjs +2 -1
  199. package/scripts/prepublication-evidence.mjs +2 -1
  200. package/scripts/private-overlay.mjs +2 -1
  201. package/scripts/product-integrity-contract.mjs +2 -1
  202. package/scripts/protected-release-invocation.mjs +2 -1
  203. package/scripts/public-verification-abandon.mjs +2 -1
  204. package/scripts/public-verification-aggregate.mjs +2 -1
  205. package/scripts/public-verification-finalizer.mjs +2 -1
  206. package/scripts/public-verification-inputs.mjs +2 -1
  207. package/scripts/public-verification-lane.mjs +2 -1
  208. package/scripts/publication-receipt.mjs +2 -1
  209. package/scripts/published-surface-probe.mjs +2 -1
  210. package/scripts/qe/card-lane-gate.mjs +2 -16
  211. package/scripts/qe/session-start-gate.mjs +2 -16
  212. package/scripts/qe/ux-suite.mjs +2 -1
  213. package/scripts/qualified-candidate-check.mjs +2 -1
  214. package/scripts/rebuild-gists-from-receipts.mjs +2 -1
  215. package/scripts/recall-latency-probe.mjs +48 -0
  216. package/scripts/recommendation-eval.mjs +2 -1
  217. package/scripts/recommendation-floor.mjs +2 -1
  218. package/scripts/recommendation-judge-score.mjs +2 -1
  219. package/scripts/recommendation-real-host-score.mjs +2 -1
  220. package/scripts/recommendation-real-host.mjs +2 -1
  221. package/scripts/refresh-capability-only-store.mjs +2 -1
  222. package/scripts/refresh-model-catalog.mjs +2 -1
  223. package/scripts/rehearse-corpus-pipeline.mjs +2 -1
  224. package/scripts/release-authority.mjs +2 -1
  225. package/scripts/release-evidence-aggregate.mjs +2 -1
  226. package/scripts/release-payload.mjs +2 -1
  227. package/scripts/release-projection.mjs +2 -1
  228. package/scripts/release-proof.mjs +2 -1
  229. package/scripts/release-qualification-contract.mjs +82 -28
  230. package/scripts/release-qualification.mjs +8 -1
  231. package/scripts/release-vector.mjs +2 -1
  232. package/scripts/restore-local-ingests.mjs +2 -1
  233. package/scripts/retrieval-canary.mjs +2 -1
  234. package/scripts/route-cheap.mjs +2 -1
  235. package/scripts/route-gold-rank.mjs +2 -1
  236. package/scripts/route-index-memory.mjs +2 -1
  237. package/scripts/route-latency-warm.mjs +2 -1
  238. package/scripts/router-utilization.mjs +2 -1
  239. package/scripts/routing-flywheel.mjs +2 -1
  240. package/scripts/run-operational-benchmark.mjs +2 -1
  241. package/scripts/run-operational-benchmark.v3.mjs +2 -1
  242. package/scripts/rvf-index-audit.mjs +2 -16
  243. package/scripts/seal-gist-receipt.mjs +2 -1
  244. package/scripts/security-guidance-codex-compat.mjs +2 -1
  245. package/scripts/selfcheck.mjs +2 -1
  246. package/scripts/signal-watch.mjs +2 -1
  247. package/scripts/source-coverage.mjs +2 -1
  248. package/scripts/stabilization-receipt.mjs +2 -1
  249. package/scripts/stamp-sweep.mjs +2 -1
  250. package/scripts/status-honesty.mjs +2 -1
  251. package/scripts/sync-commands.mjs +2 -2
  252. package/scripts/top100-benchmark.mjs +2 -1
  253. package/scripts/top100-corpus.mjs +2 -1
  254. package/scripts/user-model-hook-install.mjs +2 -1
  255. package/scripts/user-model-prompt-hook.mjs +2 -1
  256. package/scripts/verify-bundle.mjs +3 -1
  257. package/scripts/verify-model-catalog.mjs +2 -1
  258. package/scripts/version.mjs +2 -1
  259. package/scripts/wired-check.mjs +13 -5
  260. 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';
@@ -105,6 +106,54 @@ function segmentMutates(segment) {
105
106
  return /(?:^|[^0-9&>])>{1,2}\s*(?!&|\/dev\/null)(['"]?)([^\s'"]+)/.test(segment)
106
107
  && !TMP_PATH.test((/>{1,2}\s*['"]?([^\s'"]+)/.exec(segment) || [])[1] || '');
107
108
  }
109
+ /**
110
+ * #369/#425: the exit status a transcript reports is the WHOLE command's. It is the status of one check only
111
+ * when the command is one simple command, or an `&&` chain (which reaches its end only if every part passed).
112
+ * Returns the reason a command is NOT such a shape (pipe, `||`, `;`, `&`, newline, substitution, subshell,
113
+ * redirection other than 2>&1 / /dev/null), or null. When unsure it names a reason: never binds by default.
114
+ */
115
+ export function compoundReason(command) {
116
+ const s = String(command || '');
117
+ // A leading `!` (also after `&&`, after optional VAR=x prefixes) inverts the exit status: a failing check reports success.
118
+ if (/(?:^|&&)\s*(?:[A-Za-z_]\w*=\S*\s+)*!\s/.test(s)) return 'negated exit status (`!`)';
119
+ let q = '';
120
+ for (let i = 0; i < s.length; i++) {
121
+ const c = s[i];
122
+ const n = s[i + 1];
123
+ if (q === "'") { if (c === "'") q = ''; continue; }
124
+ if (c === '\\') { i++; continue; }
125
+ if (q === '"') {
126
+ if (c === '"') q = '';
127
+ else if (c === '`' || (c === '$' && n === '(')) return 'command substitution';
128
+ continue;
129
+ }
130
+ if (c === "'" || c === '"') { q = c; continue; }
131
+ if (c === '|') return n === '|' ? '`||` fallback' : 'pipe';
132
+ if (c === ';') return '`;` sequence';
133
+ if (c === '\n' || c === '\r') return 'multiple lines';
134
+ if (c === '`' || c === '(' || c === ')' || (c === '$' && n === '(')) return 'subshell or substitution';
135
+ if (c === '&') {
136
+ if (n === '&') { i++; continue; }
137
+ if (/^&>/.test(s.slice(i))) return 'redirection';
138
+ return 'background `&`';
139
+ }
140
+ if (c === '>' || c === '<') {
141
+ const m = /^(?:>&\d|>>?\s*\/dev\/null)/.exec(s.slice(i));
142
+ if (m) { i += m[0].length - 1; continue; }
143
+ return 'redirection';
144
+ }
145
+ }
146
+ return null;
147
+ }
148
+ const SAFE_REDIRECT = /\s*\d?>>?\s*\/dev\/null|\s*\d?>&\d/g;
149
+ /** The command a claim would name: safe redirects (2>&1, >/dev/null) are not part of it. */
150
+ const withoutSafeRedirects = (seg) => seg.replace(SAFE_REDIRECT, '').trim();
151
+ /** For an unbound segment: the leading command a claim could have named (cut at the first operator). */
152
+ function headsOf(seg) {
153
+ const lead = seg.replace(/^[\s(]*(?:\$\()?/, '');
154
+ const cut = (t) => t.split(/[|;&<>()`$\n]/)[0].trim();
155
+ return [...new Set([cut(seg), cut(lead)].filter((h) => h.length >= 4))];
156
+ }
108
157
  const segmentChecks = (segment) => !TRIVIAL.test(segment) && !segmentMutates(segment);
109
158
  function checkName(segment) {
110
159
  const words = segment.replace(/^(?:[A-Z_][A-Z0-9_]*=\S+\s+)+/, '').split(/\s+/);
@@ -132,12 +181,24 @@ export function currentTurnRecords(lines) {
132
181
  const isToolResult = Array.isArray(c) && c.some((x) => x?.type === 'tool_result');
133
182
  if (o?.type === 'user' && (o.message?.role || 'user') === 'user' && !isToolResult && !o.isMeta && textOf(c).trim()) start = i;
134
183
  });
135
- return { boundaryFound: start >= 0, prompt: start >= 0 ? textOf(recs[start].message?.content) : '', recs: recs.slice(start + 1) };
184
+ // `openerId` identifies the turn by its opening record (uuid, else its position among the main-thread records),
185
+ // never by the prompt text: a repeated "continue" is a different turn with the same words.
186
+ const openerId = start < 0 ? '' : (typeof recs[start].uuid === 'string' && recs[start].uuid ? `uuid:${recs[start].uuid}` : `pos:${start}`);
187
+ return { boundaryFound: start >= 0, prompt: start >= 0 ? textOf(recs[start].message?.content) : '', openerId, recs: recs.slice(start + 1) };
136
188
  }
137
189
 
138
- /** Ordered events for the current turn of a Claude JSONL transcript. */
139
- export function claudeTurnEvents(lines) {
140
- const { boundaryFound, recs: turnRecs } = currentTurnRecords(lines);
190
+ // Codex tools that neither change state nor run a check; every other unrecognised tool is a change (fail
191
+ // toward asking). Grounded in codex-cli 0.164.0's tool handlers (plan, view_image, tool_search,
192
+ // mcp_resource/{list_mcp_resources,list_mcp_resource_templates,read_mcp_resource}, current_time,
193
+ // get_context_remaining, wait_for_environment, multi_agents/wait, sleep, request_user_input[_async]) and real
194
+ // rollouts (code-mode `wait` on a cell, `wait_agent`/`list_agents`). web_search is a hosted search, read-only.
195
+ // Acting tools stay changes: spawn_agent, send_message, followup_task, interrupt_agent, js, exec, update_goal.
196
+ 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)$/;
197
+
198
+ /** Ordered events for the current turn of a Claude JSONL (or normalized Codex) transcript. */
199
+ export function claudeTurnEvents(lines, { host = 'claude' } = {}) {
200
+ const codex = host === 'codex';
201
+ const { boundaryFound, prompt: turnPrompt, openerId, recs: turnRecs } = currentTurnRecords(lines);
141
202
  const results = new Map();
142
203
  for (const o of turnRecs) {
143
204
  const c = o?.message?.content;
@@ -145,7 +206,8 @@ export function claudeTurnEvents(lines) {
145
206
  for (const r of c) {
146
207
  if (r?.type === 'tool_result' && r.tool_use_id) {
147
208
  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 });
209
+ present: textOf(r.content).trim().length > 0 || String(o.toolUseResult?.stdout || '').trim().length > 0,
210
+ ...(codex ? { passed: normalizeToolOutcome(r).outcome === 'pass', nativeOk: r.is_error === false && r.status === 'completed' } : {}) });
149
211
  }
150
212
  }
151
213
  }
@@ -156,23 +218,32 @@ export function claudeTurnEvents(lines) {
156
218
  for (const u of c) {
157
219
  if (u?.type !== 'tool_use') continue;
158
220
  const input = u.input || {};
159
- const result = results.get(u.id) || { present: false, error: false };
160
- if (['Edit', 'Write', 'MultiEdit', 'NotebookEdit'].includes(u.name)) {
221
+ const found = results.get(u.id) || { present: false, error: false };
222
+ // Codex: a check needs an explicit exit 0 (commands) or a completed native status (MCP).
223
+ const { passed, nativeOk, ...result } = codex ? { ...found, error: found.error || !(u.name === 'Bash' ? found.passed : found.nativeOk) } : found;
224
+ if (['Edit', 'Write', 'MultiEdit', 'NotebookEdit'].includes(u.name) || (codex && u.name === 'apply_patch')) {
161
225
  events.push({ kind: 'change', what: `${u.name} ${input.file_path || input.notebook_path || ''}`.trim() });
162
226
  } else if (u.name === 'Bash') {
227
+ const unbound = compoundReason(input.command);
163
228
  for (const seg of segments(input.command)) {
164
229
  if (segmentMutates(seg)) events.push({ kind: 'change', what: seg.slice(0, 120) });
165
- else if (segmentChecks(seg)) events.push({ kind: 'check', what: seg.slice(0, 120), name: checkName(seg), ...result });
230
+ else if (segmentChecks(seg)) {
231
+ const cmd = unbound ? seg : withoutSafeRedirects(seg);
232
+ events.push({ kind: 'check', what: cmd.slice(0, 120), command: cmd.slice(0, 400), name: checkName(seg), ...result,
233
+ ...(unbound ? { unbound: `the command uses ${unbound}, so its exit status is not this check's`, heads: headsOf(seg) } : {}) });
234
+ }
166
235
  }
167
236
  } else if (u.name === 'Agent' || u.name === 'Task') {
168
237
  if (!READ_ONLY_AGENTS.test(String(input.subagent_type || ''))) events.push({ kind: 'change', what: `${u.name} ${input.subagent_type || 'agent'}` });
169
238
  } else if (String(u.name || '').startsWith('mcp__')) {
170
239
  events.push(MCP_MUTATING.test(u.name) ? { kind: 'change', what: u.name }
171
240
  : { kind: 'check', what: u.name, name: u.name.split('__').pop(), ...result });
241
+ } else if (codex && !CODEX_NEUTRAL.test(String(u.name || ''))) {
242
+ events.push({ kind: 'change', what: `codex ${String(u.name || 'tool').slice(0, 60)}` });
172
243
  }
173
244
  }
174
245
  }
175
- return { boundaryFound, events };
246
+ return { boundaryFound, prompt: turnPrompt, openerId, events };
176
247
  }
177
248
 
178
249
  /** Verification that ran AFTER the last state change this turn, with a present, non-error result. */
@@ -180,14 +251,21 @@ export function postChangeVerification(turn) {
180
251
  const events = turn?.events || [];
181
252
  let lastChange = -1;
182
253
  events.forEach((e, i) => { if (e.kind === 'change') lastChange = i; });
183
- const checks = events.slice(lastChange + 1).filter((e) => e.kind === 'check' && e.present && !e.error);
184
- return { lastChange: lastChange >= 0 ? events[lastChange].what : null, checks,
254
+ const after = events.slice(lastChange + 1).filter((e) => e.kind === 'check' && e.present && !e.error);
255
+ // `checks` is STRICT (binds a claim or a promise). `maskedChecks` ran with output and no error, but the command
256
+ // is a pipe / `||` / `;` / file redirect, so its exit status is not attributable: counted only as "a check ran".
257
+ const checks = after.filter((e) => !e.unbound);
258
+ const maskedChecks = after.filter((e) => e.unbound);
259
+ return { lastChange: lastChange >= 0 ? events[lastChange].what : null, checks, maskedChecks,
185
260
  staleChecks: events.slice(0, Math.max(0, lastChange)).filter((e) => e.kind === 'check').length };
186
261
  }
187
262
 
188
- export function readClaudeTurn(transcriptPath, { read = readSettledTranscript } = {}) {
263
+ export function readClaudeTurn(transcriptPath, { read = readSettledTranscript, host = 'claude' } = {}) {
189
264
  if (typeof transcriptPath !== 'string' || !/\.jsonl$/i.test(transcriptPath)) return null;
190
- try { return claudeTurnEvents(read(transcriptPath, { maxMs: 0 })); } catch { return null; }
265
+ try {
266
+ const turn = claudeTurnEvents(nativeTurnLines(read(transcriptPath, { maxMs: 0 }), host), { host });
267
+ return host === 'codex' && !turn.boundaryFound ? null : turn;
268
+ } catch { return null; }
191
269
  }
192
270
 
193
271
  /**
@@ -196,16 +274,19 @@ export function readClaudeTurn(transcriptPath, { read = readSettledTranscript }
196
274
  */
197
275
  export function auditCompletionClaims(message, { turn = null, host = 'claude' } = {}) {
198
276
  const claims = extractCompletionClaims(message);
199
- if (!claims.length) return { verdict: 'NONE', claims };
277
+ if (!claims.length) return { verdict: 'NONE', claims, outcomes: [] };
200
278
  const text = String(message || '');
201
279
  const verification = turn ? postChangeVerification(turn) : null;
202
- const names = verification?.checks.map((c) => String(c.name || '').toLowerCase()).filter((n) => n.length >= 3) || [];
280
+ // Naming a check that ran is not binding it: masked checks count here so an inline mention is not 'unnamed'; transcriptOk below still uses strict checks only.
281
+ const names = verification ? [...verification.checks, ...verification.maskedChecks].map((c) => String(c.name || '').toLowerCase()).filter((n) => n.length >= 3) : [];
203
282
  const namesCheck = NAMED_CHECK.test(text) || names.some((n) => text.toLowerCase().includes(n));
204
283
  const disclosesGaps = DISCLOSES_GAPS.test(strippedProse(text).replace(/```[\s\S]*?```/g, ''))
205
284
  || DISCLOSES_GAPS.test(text);
206
285
  const problems = [];
207
286
  if (!turn) problems.push(`this host's (${host}) transcript is unavailable or not parsed, so whether a check ran after the change is UNKNOWN`);
208
- else if (!verification.checks.length) {
287
+ else if (!verification.checks.length && verification.maskedChecks.length) {
288
+ problems.push('a check ran after the last change, but its command uses a pipe, `||`, `;` or a file redirect, so its exit status is not attributable to it; completion is UNKNOWN');
289
+ } else if (!verification.checks.length) {
209
290
  problems.push(verification.lastChange
210
291
  ? `no end-to-end check ran this turn after the last change (${verification.lastChange})`
211
292
  : 'no end-to-end check ran this turn');
@@ -218,7 +299,81 @@ export function auditCompletionClaims(message, { turn = null, host = 'claude' }
218
299
  if (transcriptOk && namesCheck && disclosesGaps && !scopedCheck) problems.push('observed checks cover only their executed scope; whole-task completion is UNKNOWN');
219
300
  const verdict = transcriptOk && namesCheck && disclosesGaps && scopedCheck ? 'OBSERVED_CHECK'
220
301
  : namesCheck && disclosesGaps ? 'UNKNOWN' : 'FAIL';
221
- return { verdict, claims, verification, namesCheck, disclosesGaps, problems };
302
+ return { verdict, claims, verification, namesCheck, disclosesGaps, problems, outcomes: claimOutcomes(claims, text, turn) };
303
+ }
304
+
305
+ // ── per-claim outcomes (#425) ───────────────────────────────────────────────────────────────────
306
+ // Evidence per claimed outcome, NOT completion authority: `authority:false` always, the verdict above is
307
+ // computed without it, and no state here is PASS. A claim is `observed-check` only when its own sentence
308
+ // (or a line-start `Verified:` line) NAMES the command of a check that ran after the last change, produced
309
+ // output and did not error. Anything else is reported as what it is: stale, failed, or unbound.
310
+ const VERIFIED_LINE = /(?:^|\n)[ \t]*(?:[-*•][ \t]*|\*\*)*verified\b[^\n:—–]{0,4}?(?:\*\*)?[ \t]*[:—–][ \t]*([^\n]+)/gi;
311
+ // Env-var prefixes are part of the command (`SKIP_HOOKS=1 npm run x` is not `npm run x`): never stripped.
312
+ const squash = (value) => String(value || '').replace(/\s+/g, ' ').trim().toLowerCase();
313
+ /** `hay` contains `cmd` as a whole command: not preceded by an env-var assignment or a word character. */
314
+ export function containsCommand(hay, cmd) {
315
+ const h = String(hay || ''); const c = String(cmd || '');
316
+ if (!c) return false;
317
+ for (let at = h.indexOf(c); at >= 0; at = h.indexOf(c, at + 1)) {
318
+ const before = h.slice(0, at);
319
+ if (/[\w./:=-]$/.test(before) || /^[\w:-]/.test(h.slice(at + c.length))) continue;
320
+ if (/(?:^|[\s`'"(])[A-Za-z_][A-Za-z0-9_]*=\S*\s+$/.test(before)) continue;
321
+ return true;
322
+ }
323
+ return false;
324
+ }
325
+
326
+ function rawSentenceOf(message, claim) {
327
+ for (const raw of String(message || '').split(/(?<=[.!?])\s+|\n+/)) if (sentences(raw).includes(claim.text)) return raw;
328
+ return claim.text;
329
+ }
330
+
331
+ export function claimOutcomes(claims, message, turn) {
332
+ const events = turn?.events || [];
333
+ let lastChange = -1;
334
+ events.forEach((e, i) => { if (e.kind === 'change') lastChange = i; });
335
+ const checks = events.map((e, i) => ({ e, i })).filter(({ e }) => e.kind === 'check' && String(e.command || '').length >= 4);
336
+ const verifiedLines = [...String(message || '').matchAll(VERIFIED_LINE)].map((m) => squash(m[1])).join('\n');
337
+ return (claims || []).map((claim) => {
338
+ const own = squash(rawSentenceOf(message, claim));
339
+ const namedAll = checks.filter(({ e }) => { const c = squash(e.command); const hit = (h) => containsCommand(own, h) || containsCommand(verifiedLines, h);
340
+ return hit(c) || (e.unbound && (e.heads || []).some((h) => hit(squash(h)))); });
341
+ const named = namedAll.filter(({ e }) => !e.unbound);
342
+ const good = named.filter(({ e, i }) => i > lastChange && e.present && !e.error);
343
+ const state = good.length ? 'observed-check'
344
+ : named.some(({ e, i }) => i > lastChange && !(e.present && !e.error)) ? 'failed-check'
345
+ : named.length ? 'stale' : 'unbound';
346
+ const reason = state === 'unbound' ? namedAll.find(({ e }) => e.unbound)?.e.unbound : undefined;
347
+ return { claim: claim.text.slice(0, 200), state, ...(reason ? { reason } : {}),
348
+ checks: (state === 'observed-check' ? good : named).slice(-3).map(({ e }) => ({ command: e.command, name: e.name })), authority: false };
349
+ });
350
+ }
351
+
352
+ /**
353
+ * #425 (sparkling): the audit category of a correction, independent of wording. A correction is delivered
354
+ * at most once per session + user turn + category, so rewording the answer or waiting cannot rearm it.
355
+ */
356
+ export function completionCategory(completion) {
357
+ const ids = (completion?.problems || []).map((p) => /unavailable or not parsed/.test(p) ? 'transcript-unavailable'
358
+ : /no end-to-end check ran/.test(p) ? 'no-check' : /not attributable/.test(p) ? 'check-unattributed' : /does not name the check/.test(p) ? 'check-unnamed'
359
+ : /does not disclose/.test(p) ? 'gaps-undisclosed' : /observed checks cover only/.test(p) ? 'scope' : 'other');
360
+ return `${completion?.verdict || 'NONE'}:${[...new Set(ids)].sort().join('+')}`;
361
+ }
362
+
363
+ /** Correction copy for a FAIL/UNKNOWN verdict. When checks DID run, ask for an outcome-by-outcome report. */
364
+ export function completionCorrectionText(completion) {
365
+ const lead = `You claimed "${completion.claims[0].text.slice(0, 160)}" is done; ${completion.problems.join('; ')}.`;
366
+ return (completion.verification?.checks?.length || 0) + (completion.verification?.maskedChecks?.length || 0) > 0
367
+ ? `${lead} A check ran, but it does not cover every claim: report outcome by outcome, naming the check that ran for each claim or restating it as UNVERIFIED.`
368
+ : `${lead} Run the real consumer path, or restate it as UNVERIFIED.`;
369
+ }
370
+
371
+ /** Non-blocking advisory text: which claims have an observed check and which do not. */
372
+ export function outcomeAdvisoryText(completion) {
373
+ const lines = (completion.outcomes || []).map((o) => `- ${o.state}: "${o.claim.slice(0, 100)}"${o.checks.length ? ` (${o.checks.map((c) => c.command).join('; ')})` : ''}`);
374
+ const masked = !(completion.verification?.checks?.length) && (completion.verification?.maskedChecks?.length || 0) > 0;
375
+ return `A check ran after the last change${masked ? ' (its command uses a pipe, `||`, `;` or a redirect, so its exit status is not attributable to it)' : ''} and the answer names it and discloses what is not verified. Per claimed outcome:\n${lines.join('\n')}\n`
376
+ + 'This is evidence per outcome, not completion authority: completion stays UNKNOWN. Not blocking: more checks or rewording cannot change it.';
222
377
  }
223
378
 
224
379
  // ── promises (first-person commitments) ─────────────────────────────────────────────────────────
@@ -227,6 +382,10 @@ const PROMISE_REJECT = /\?|:\*\*|\b(?:if|unless|once|when|whenever|as\s+soon\s+a
227
382
  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
383
  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
384
  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;
385
+ // "hide it the same way", "check for it then": a verb whose only object is a pronoun has no referent
386
+ // once the turn's context is gone (#369), so no later session could ever close it honestly.
387
+ const PRONOUN_OBJECT = /\b(?:it|this|that|them|these|those)\b/i;
388
+ 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
389
 
231
390
  export const normalizePromise = (value) => String(value || '').toLowerCase().replace(/[`*_"“”'’]/g, '')
232
391
  .replace(/[^a-z0-9./:+-]+/g, ' ').trim().slice(0, 160);
@@ -248,13 +407,31 @@ export function extractCommitments(message) {
248
407
  if (PROMISE_REJECT.test(whole) || OWNER_DEPENDENT_OFFER.test(whole)
249
408
  || STANDING_APPROVAL.test(action.trim()) || TRIVIAL_PROMISE.test(action.trim())) continue;
250
409
  const text = action.replace(/\s+/g, ' ').trim().replace(/[,;:]$/, '');
410
+ if (PRONOUN_OBJECT.test(text) && PRONOUN_ONLY.test(text)) continue;
251
411
  const key = normalizePromise(text);
252
412
  if (key.length < 6 || seen.has(key)) continue;
253
413
  seen.add(key);
254
414
  out.push({ text, key });
255
415
  }
256
416
  }
257
- return out.slice(0, PROMISE_CAP_PER_TURN);
417
+ return out.slice(0, PROMISE_CAP_PER_TURN).map((p) => {
418
+ const evidenceCommands = promiseCommands(message, p.key);
419
+ return evidenceCommands.length ? { ...p, evidenceCommands } : p;
420
+ });
421
+ }
422
+
423
+ // #369: inline-code commands in the promise's own sentence are what the promise says it will run. They are
424
+ // recorded at capture (max 2) so a later observed check can be matched to them; prose alone never is.
425
+ const COMMAND_SPAN = /`([^`\n]{4,160})`/g;
426
+ function promiseCommands(message, key) {
427
+ for (const raw of String(message || '').split(/(?<=[.!?])\s+|\n+/)) {
428
+ const prose = strippedProse(raw).replace(/^\s*(?:\d+[.)]|[-*•])\s+/, '').trim();
429
+ const action = PROMISE_LEAD.exec(prose)?.[1] ?? prose;
430
+ if (normalizePromise(action) !== key) continue;
431
+ const found = [...raw.matchAll(COMMAND_SPAN)].map((m) => squash(m[1])).filter((c) => /\s/.test(c) || c.startsWith('./'));
432
+ return [...new Set(found)].slice(0, 2);
433
+ }
434
+ return [];
258
435
  }
259
436
 
260
437
  const STOP = new Set(['the', 'a', 'an', 'and', 'or', 'to', 'of', 'in', 'on', 'for', 'with', 'it', 'is', 'are',
@@ -269,3 +446,44 @@ export function claimClosesPromise(claimText, promiseText) {
269
446
  const hit = want.filter((t) => have.has(t)).length;
270
447
  return hit >= 2 && hit / want.length >= 0.4;
271
448
  }
449
+
450
+ const unquoted = (value) => String(value).replace(/"([^"\n]{0,300})"|“([^”\n]{0,300})”/g, (_, a, b) => a ?? b);
451
+
452
+ /**
453
+ * The claim that closes `promiseText`, or null. Only a PASS verdict can close a promise. strippedProse()
454
+ * removes double-quoted text, so `"push the branch itself" is done` extracts only `is done`; that claim
455
+ * may still close the promise through its raw sentence (quotes kept), which becomes the evidence. The
456
+ * sentence must be a recognised claim outside its quotes, so quoting alone never makes a claim (#369).
457
+ */
458
+ export function promiseClosingClaim(completion, answer, promiseText) {
459
+ // DORMANT: auditCompletionClaims no longer returns PASS (only OBSERVED_CHECK/UNKNOWN/FAIL/NONE), so this never closes a promise today.
460
+ if (completion?.verdict !== 'PASS') return null;
461
+ return matchingClaim(completion, answer, promiseText);
462
+ }
463
+
464
+ function matchingClaim(completion, answer, promiseText) {
465
+ const direct = completion.claims.find((c) => claimClosesPromise(c.text, promiseText));
466
+ if (direct) return direct;
467
+ for (const raw of String(answer || '').split(/(?<=[.!?])\s+|\n+/)) {
468
+ const own = sentences(raw);
469
+ const claim = completion.claims.find((c) => own.includes(c.text));
470
+ if (claim && claimClosesPromise(unquoted(raw), promiseText)) return { ...claim, text: raw.trim() };
471
+ }
472
+ return null;
473
+ }
474
+
475
+ /**
476
+ * #369: the evidence that moves a promise to the NON-completed state `evidenced`, or null. Never `done`, never
477
+ * PASS, never authority. ALL must hold: a command the promise itself named ran after the last change with
478
+ * output and no error; the answer names that command and states what is not verified; a claim sentence
479
+ * matches the promise; the verdict is not FAIL. (Same-session ownership is checked by the caller.)
480
+ */
481
+ export function promiseEvidence(completion, answer, item) {
482
+ const commands = Array.isArray(item?.evidenceCommands) ? item.evidenceCommands.map(squash) : [];
483
+ if (!commands.length || !completion?.claims?.length || !['OBSERVED_CHECK', 'UNKNOWN'].includes(completion.verdict)
484
+ || completion.disclosesGaps !== true) return null;
485
+ const said = squash(answer);
486
+ const check = (completion.verification?.checks || []).find((c) => !c.unbound && commands.includes(squash(c.command)) && containsCommand(said, squash(c.command)));
487
+ const claim = check && matchingClaim(completion, answer, item.text);
488
+ return claim ? { command: squash(check.command), check: check.what, claim: claim.text, auditVerdict: completion.verdict, authority: false } : null;
489
+ }