session-orchestrator 5.2.0 → 5.4.0

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 (427) hide show
  1. package/.agents/skills/architecture/SKILL.md +3 -1
  2. package/.agents/skills/autopilot/SKILL.md +5 -1
  3. package/.agents/skills/autopilot/agents/openai.yaml +5 -0
  4. package/.agents/skills/bootstrap/SKILL.md +5 -1
  5. package/.agents/skills/bootstrap/agents/openai.yaml +5 -0
  6. package/.agents/skills/brainstorm/SKILL.md +5 -1
  7. package/.agents/skills/brainstorm/agents/openai.yaml +5 -0
  8. package/.agents/skills/claude-md-drift-check/SKILL.md +4 -2
  9. package/.agents/skills/close/SKILL.md +5 -1
  10. package/.agents/skills/close/agents/openai.yaml +5 -0
  11. package/.agents/skills/convergence-monitoring/SKILL.md +4 -2
  12. package/.agents/skills/debug/SKILL.md +5 -1
  13. package/.agents/skills/debug/agents/openai.yaml +5 -0
  14. package/.agents/skills/discovery/SKILL.md +5 -1
  15. package/.agents/skills/discovery/agents/openai.yaml +5 -0
  16. package/.agents/skills/dispatcher/SKILL.md +5 -1
  17. package/.agents/skills/dispatcher/agents/openai.yaml +5 -0
  18. package/.agents/skills/docs-orchestrator/SKILL.md +3 -1
  19. package/.agents/skills/ecosystem-health/SKILL.md +3 -1
  20. package/.agents/skills/eli5/SKILL.md +5 -1
  21. package/.agents/skills/eli5/agents/openai.yaml +5 -0
  22. package/.agents/skills/eval/SKILL.md +6 -2
  23. package/.agents/skills/eval/agents/openai.yaml +5 -0
  24. package/.agents/skills/evolve/SKILL.md +6 -2
  25. package/.agents/skills/evolve/agents/openai.yaml +5 -0
  26. package/.agents/skills/frontmatter-guard/SKILL.md +3 -1
  27. package/.agents/skills/gitlab-ops/SKILL.md +3 -1
  28. package/.agents/skills/gitlab-portfolio/SKILL.md +3 -1
  29. package/.agents/skills/go/SKILL.md +5 -1
  30. package/.agents/skills/go/agents/openai.yaml +5 -0
  31. package/.agents/skills/grill/SKILL.md +5 -1
  32. package/.agents/skills/grill/agents/openai.yaml +5 -0
  33. package/.agents/skills/harness-audit/SKILL.md +5 -1
  34. package/.agents/skills/harness-audit/agents/openai.yaml +5 -0
  35. package/.agents/skills/hook-development/SKILL.md +3 -1
  36. package/.agents/skills/mcp-builder/SKILL.md +3 -1
  37. package/.agents/skills/memory-cleanup/SKILL.md +5 -1
  38. package/.agents/skills/memory-cleanup/agents/openai.yaml +5 -0
  39. package/.agents/skills/mode-selector/SKILL.md +3 -1
  40. package/.agents/skills/navigator/SKILL.md +22 -0
  41. package/.agents/skills/navigator/agents/openai.yaml +5 -0
  42. package/.agents/skills/npm-publish/SKILL.md +4 -2
  43. package/.agents/skills/peekaboo-driver/SKILL.md +3 -1
  44. package/.agents/skills/persona-panel/SKILL.md +5 -1
  45. package/.agents/skills/persona-panel/agents/openai.yaml +5 -0
  46. package/.agents/skills/plan/SKILL.md +5 -1
  47. package/.agents/skills/plan/agents/openai.yaml +5 -0
  48. package/.agents/skills/playwright-driver/SKILL.md +3 -1
  49. package/.agents/skills/portfolio/SKILL.md +5 -1
  50. package/.agents/skills/portfolio/agents/openai.yaml +5 -0
  51. package/.agents/skills/quality-gates/SKILL.md +3 -1
  52. package/.agents/skills/reconcile/SKILL.md +5 -1
  53. package/.agents/skills/reconcile/agents/openai.yaml +5 -0
  54. package/.agents/skills/release/SKILL.md +5 -1
  55. package/.agents/skills/release/agents/openai.yaml +5 -0
  56. package/.agents/skills/remote-offload/SKILL.md +3 -1
  57. package/.agents/skills/repo-audit/SKILL.md +5 -1
  58. package/.agents/skills/repo-audit/agents/openai.yaml +5 -0
  59. package/.agents/skills/session/SKILL.md +21 -0
  60. package/.agents/skills/session/agents/openai.yaml +5 -0
  61. package/.agents/skills/session-end/SKILL.md +3 -1
  62. package/.agents/skills/session-plan/SKILL.md +3 -1
  63. package/.agents/skills/session-start/SKILL.md +3 -1
  64. package/.agents/skills/spinout/SKILL.md +5 -1
  65. package/.agents/skills/spinout/agents/openai.yaml +5 -0
  66. package/.agents/skills/sunset-review/SKILL.md +5 -1
  67. package/.agents/skills/sunset-review/agents/openai.yaml +5 -0
  68. package/.agents/skills/templates-ack/SKILL.md +21 -0
  69. package/.agents/skills/templates-ack/agents/openai.yaml +5 -0
  70. package/.agents/skills/test/SKILL.md +5 -1
  71. package/.agents/skills/test/agents/openai.yaml +5 -0
  72. package/.agents/skills/test-audit/SKILL.md +21 -0
  73. package/.agents/skills/test-audit/agents/openai.yaml +5 -0
  74. package/.agents/skills/test-runner/SKILL.md +3 -1
  75. package/.agents/skills/tmux-layout/SKILL.md +3 -1
  76. package/.agents/skills/using-orchestrator/SKILL.md +3 -1
  77. package/.agents/skills/ux-grill/SKILL.md +5 -1
  78. package/.agents/skills/ux-grill/agents/openai.yaml +5 -0
  79. package/.agents/skills/vault-mirror/SKILL.md +3 -1
  80. package/.agents/skills/vault-sync/SKILL.md +3 -1
  81. package/.agents/skills/wave-executor/SKILL.md +3 -1
  82. package/.agents/skills/write-executable-plan/SKILL.md +3 -1
  83. package/.claude-plugin/marketplace.json +1 -1
  84. package/.claude-plugin/plugin.json +1 -1
  85. package/.codex-plugin/plugin.json +4 -4
  86. package/.codex-plugin/skills/claude-md-drift-check/SKILL.md +1 -1
  87. package/.codex-plugin/skills/convergence-monitoring/SKILL.md +1 -3
  88. package/.codex-plugin/skills/eval/SKILL.md +1 -1
  89. package/.codex-plugin/skills/evolve/SKILL.md +1 -1
  90. package/.codex-plugin/skills/navigator/SKILL.md +22 -0
  91. package/.codex-plugin/skills/{convergence-monitoring → navigator}/agents/openai.yaml +2 -2
  92. package/.codex-plugin/skills/npm-publish/SKILL.md +1 -3
  93. package/.codex-plugin/skills/session/SKILL.md +1 -1
  94. package/.codex-plugin/skills/test-audit/SKILL.md +21 -0
  95. package/.codex-plugin/skills/{npm-publish → test-audit}/agents/openai.yaml +1 -1
  96. package/.cursor/commands/eval.md +1 -1
  97. package/.cursor/commands/navigator.md +15 -0
  98. package/.cursor/commands/session.md +1 -1
  99. package/.cursor/commands/test-audit.md +14 -0
  100. package/.cursor/rules/000-session-orchestrator.mdc +0 -2
  101. package/.cursor/rules/050-plan.mdc +1 -1
  102. package/.cursor/rules/060-evolve.mdc +27 -12
  103. package/.cursor/skills/claude-md-drift-check/SKILL.md +1 -1
  104. package/.cursor/skills/convergence-monitoring/SKILL.md +1 -0
  105. package/.cursor/skills/eval/SKILL.md +1 -1
  106. package/.cursor/skills/navigator/SKILL.md +13 -0
  107. package/.cursor/skills/npm-publish/SKILL.md +1 -0
  108. package/.cursor/skills/test-audit/SKILL.md +12 -0
  109. package/.cursor-plugin/plugin.json +1 -1
  110. package/.orchestrator/policy/blocked-commands.json +12 -3
  111. package/AGENTS.md +9 -30
  112. package/CHANGELOG.md +206 -0
  113. package/README.md +13 -9
  114. package/SECURITY.md +12 -0
  115. package/agents/dialectic-deriver.md +13 -10
  116. package/agents/eval-judge.md +67 -45
  117. package/agents/skill-applied-judge.md +34 -19
  118. package/commands/session.md +7 -3
  119. package/docs/agent-authoring.md +2 -2
  120. package/docs/baseline.md +22 -7
  121. package/docs/ci-setup.md +57 -4
  122. package/docs/codex-setup.md +14 -2
  123. package/docs/components.md +12 -10
  124. package/docs/events-schema.md +65 -15
  125. package/docs/persona-panel.md +5 -4
  126. package/docs/rule-authoring.md +84 -17
  127. package/docs/scope-collision-guard.md +26 -4
  128. package/docs/session-config-reference.md +154 -12
  129. package/docs/session-config-template.md +41 -3
  130. package/docs/telemetry.md +2 -0
  131. package/hooks/_lib/hook-import-set.json +87 -6
  132. package/hooks/_lib/subagent-transcript.mjs +582 -31
  133. package/hooks/config-protection.mjs +11 -3
  134. package/hooks/cwd-change-restore.mjs +11 -3
  135. package/hooks/enforce-commands.mjs +70 -23
  136. package/hooks/enforce-scope.mjs +143 -33
  137. package/hooks/hooks-codex.json +1 -1
  138. package/hooks/hooks-cursor.json +5 -0
  139. package/hooks/hooks-pi.json +5 -0
  140. package/hooks/hooks.json +6 -1
  141. package/hooks/loop-guard.mjs +11 -3
  142. package/hooks/on-session-end.mjs +58 -23
  143. package/hooks/on-session-start.mjs +101 -11
  144. package/hooks/on-stop.mjs +170 -22
  145. package/hooks/operator-steer.mjs +11 -3
  146. package/hooks/post-bash-issue-budget-refund.mjs +18 -8
  147. package/hooks/post-bash-write-verify.mjs +3 -2
  148. package/hooks/post-edit-import-probe.mjs +17 -9
  149. package/hooks/post-edit-validate.mjs +13 -5
  150. package/hooks/post-subagent-discovery-validator.mjs +98 -13
  151. package/hooks/post-tool-batch-wave-signal.mjs +202 -38
  152. package/hooks/post-tool-failure-corrective-context.mjs +11 -5
  153. package/hooks/post-tooluse-frontend-slop.mjs +10 -4
  154. package/hooks/pre-auq-clarity.mjs +15 -2
  155. package/hooks/pre-bash-destructive-guard.mjs +80 -9
  156. package/hooks/pre-bash-issue-budget.mjs +16 -11
  157. package/hooks/pre-bash-memory-propose-audit.mjs +137 -56
  158. package/hooks/pre-bash-sessions-ledger-guard.mjs +419 -22
  159. package/hooks/pre-bash-staging-fence.mjs +335 -31
  160. package/hooks/pre-bash-templates-first.mjs +19 -14
  161. package/hooks/pre-edit-sessions-ledger-guard.mjs +162 -0
  162. package/hooks/pre-task-scope-disjoint.mjs +792 -74
  163. package/hooks/subagent-telemetry.mjs +111 -38
  164. package/hooks/wave-scope-commit-guard.mjs +197 -100
  165. package/monitors/monitors.json +1 -1
  166. package/output-styles/wave-summary.md +1 -1
  167. package/package.json +3 -3
  168. package/pi/prompts/eval.md +1 -1
  169. package/pi/prompts/navigator.md +12 -0
  170. package/pi/prompts/session.md +1 -1
  171. package/pi/prompts/test-audit.md +12 -0
  172. package/rules/README.md +7 -2
  173. package/rules/_index.md +3 -3
  174. package/rules/always-on/bash-harness-pitfalls.md +7 -1
  175. package/rules/opt-in-domain/prompt-caching.md +6 -2
  176. package/rules/opt-in-stack/backend-data.md +10 -2
  177. package/rules/opt-in-stack/backend.md +10 -4
  178. package/rules/opt-in-stack/frontend.md +7 -2
  179. package/rules/opt-in-stack/security-web.md +10 -4
  180. package/rules/opt-in-stack/swift.md +6 -2
  181. package/scripts/apply-session-learnings.mjs +492 -0
  182. package/scripts/archive-closed-prds.mjs +2 -1
  183. package/scripts/autopilot.mjs +70 -53
  184. package/scripts/backfill-abandoned-sessions.mjs +118 -16
  185. package/scripts/baseline-archetypes.mjs +1 -1
  186. package/scripts/check-package-manager.mjs +62 -5
  187. package/scripts/check-sessions-integrity.mjs +300 -0
  188. package/scripts/cursor-install.mjs +1 -4
  189. package/scripts/dialectic-deriver.mjs +50 -13
  190. package/scripts/emit-session.mjs +371 -31
  191. package/scripts/eval-session.mjs +65 -3
  192. package/scripts/export-hw-learnings.mjs +22 -3
  193. package/scripts/fleet-checkin.mjs +147 -0
  194. package/scripts/generate-agents-skills.mjs +102 -29
  195. package/scripts/generate-cursor-adapter.mjs +61 -16
  196. package/scripts/lib/agent-frontmatter.mjs +3 -3
  197. package/scripts/lib/agent-status.mjs +2 -31
  198. package/scripts/lib/auq/clarity.mjs +10 -2
  199. package/scripts/lib/auq/parse.mjs +12 -31
  200. package/scripts/lib/auq/schema.mjs +56 -41
  201. package/scripts/lib/auto-dialectic.mjs +304 -15
  202. package/scripts/lib/auto-dream.mjs +7 -6
  203. package/scripts/lib/autopilot/flags.mjs +18 -2
  204. package/scripts/lib/autopilot/kill-switches.mjs +6 -3
  205. package/scripts/lib/autopilot/loop.mjs +19 -2
  206. package/scripts/lib/autopilot/stall-sampler.mjs +80 -23
  207. package/scripts/lib/baseline-archetypes.mjs +41 -22
  208. package/scripts/lib/ci-status-banner.mjs +376 -16
  209. package/scripts/lib/command-blocker.mjs +275 -28
  210. package/scripts/lib/config/dialectic.mjs +12 -3
  211. package/scripts/lib/config/gate.mjs +74 -0
  212. package/scripts/lib/config/host-paths.mjs +79 -0
  213. package/scripts/lib/config/persona-gate-wave.mjs +3 -3
  214. package/scripts/lib/config/reaper.mjs +163 -0
  215. package/scripts/lib/config-schema.mjs +13 -0
  216. package/scripts/lib/config.mjs +85 -10
  217. package/scripts/lib/convergence-monitor.mjs +74 -11
  218. package/scripts/lib/ecosystem-health.mjs +11 -0
  219. package/scripts/lib/eval/engine.mjs +421 -53
  220. package/scripts/lib/eval/judge.mjs +463 -40
  221. package/scripts/lib/eval/schema.mjs +10 -1
  222. package/scripts/lib/events-rotation.mjs +221 -25
  223. package/scripts/lib/events-schema.mjs +114 -0
  224. package/scripts/lib/events.mjs +545 -7
  225. package/scripts/lib/fleet-protocol.mjs +270 -0
  226. package/scripts/lib/frontmatter-guard.mjs +21 -10
  227. package/scripts/lib/gates/gate-baseline.mjs +40 -5
  228. package/scripts/lib/gates/gate-full.mjs +35 -5
  229. package/scripts/lib/gates/gate-helpers.mjs +336 -23
  230. package/scripts/lib/gates/gate-incremental.mjs +49 -23
  231. package/scripts/lib/gates/gate-per-file.mjs +42 -8
  232. package/scripts/lib/gitlab-portfolio/markdown-writer.mjs +6 -1
  233. package/scripts/lib/instruction-budget-guard.mjs +208 -8
  234. package/scripts/lib/io.mjs +42 -8
  235. package/scripts/lib/issue-close-strip-labels.mjs +207 -49
  236. package/scripts/lib/js-mask.mjs +197 -0
  237. package/scripts/lib/learnings/evolve-telemetry.mjs +11 -7
  238. package/scripts/lib/learnings/expiry-sweep.mjs +34 -3
  239. package/scripts/lib/learnings/io.mjs +154 -26
  240. package/scripts/lib/learnings/schema.mjs +163 -15
  241. package/scripts/lib/learnings/sizing-subject.mjs +2 -2
  242. package/scripts/lib/maintenance-due-banner.mjs +53 -88
  243. package/scripts/lib/memory-proposals/schema.mjs +11 -5
  244. package/scripts/lib/named-baseline-resolver.mjs +53 -28
  245. package/scripts/lib/named-vault-resolver.mjs +104 -5
  246. package/scripts/lib/orphan-reaper/defaults.mjs +41 -0
  247. package/scripts/lib/orphan-reaper/ps-snapshot.mjs +181 -0
  248. package/scripts/lib/orphan-reaper/reaper-audit.mjs +291 -0
  249. package/scripts/lib/orphan-reaper/reaper-decide.mjs +395 -0
  250. package/scripts/lib/orphan-reaper/scan-throttle.mjs +92 -0
  251. package/scripts/lib/orphan-reaper/scan.mjs +506 -0
  252. package/scripts/lib/orphan-reaper.mjs +316 -0
  253. package/scripts/lib/owner-yaml.mjs +104 -6
  254. package/scripts/lib/peer-cards/merger.mjs +48 -10
  255. package/scripts/lib/peer-cards/reader.mjs +78 -2
  256. package/scripts/lib/persona-panel/catalog-loader.mjs +1 -1
  257. package/scripts/lib/process-group.mjs +950 -0
  258. package/scripts/lib/project-hygiene.mjs +21 -1
  259. package/scripts/lib/quality-gate.mjs +152 -33
  260. package/scripts/lib/reconcile/backlog.mjs +368 -0
  261. package/scripts/lib/reconcile/engine.mjs +55 -188
  262. package/scripts/lib/reconcile/rule-expiry-sweep.mjs +302 -60
  263. package/scripts/lib/reconcile/sanitize.mjs +69 -3
  264. package/scripts/lib/reconcile-nudge-banner.mjs +138 -45
  265. package/scripts/lib/resource-probe/parsers.mjs +31 -0
  266. package/scripts/lib/resource-probe.mjs +1 -1
  267. package/scripts/lib/rule-loader.mjs +141 -74
  268. package/scripts/lib/rules-sync.mjs +27 -12
  269. package/scripts/lib/scope-baseline.mjs +3 -4
  270. package/scripts/lib/scope-echo.mjs +39 -2
  271. package/scripts/lib/scope-gate.mjs +605 -1
  272. package/scripts/lib/session-close-backfill.mjs +33 -6
  273. package/scripts/lib/session-id.mjs +9 -20
  274. package/scripts/lib/session-invocation.mjs +20 -0
  275. package/scripts/lib/session-record-repair.mjs +183 -26
  276. package/scripts/lib/session-registry.mjs +27 -2
  277. package/scripts/lib/session-schema/constants.mjs +56 -2
  278. package/scripts/lib/session-schema/normalizer.mjs +56 -4
  279. package/scripts/lib/session-schema/validator.mjs +28 -1
  280. package/scripts/lib/session-schema.mjs +8 -3
  281. package/scripts/lib/session-start-probes.mjs +95 -10
  282. package/scripts/lib/session-token-rollup.mjs +245 -52
  283. package/scripts/lib/sessions-canonical.mjs +118 -4
  284. package/scripts/lib/sessions-integrity-banner.mjs +7 -1
  285. package/scripts/lib/sessions-staleness-banner.mjs +193 -51
  286. package/scripts/lib/sh-quote.mjs +29 -0
  287. package/scripts/lib/skill-evidence-window.mjs +891 -0
  288. package/scripts/lib/skill-evolution/candidate-intake.mjs +133 -12
  289. package/scripts/lib/skill-evolution/engine.mjs +18 -9
  290. package/scripts/lib/skill-judge.mjs +45 -3
  291. package/scripts/lib/subagents-schema.mjs +30 -3
  292. package/scripts/lib/tail-window.mjs +56 -0
  293. package/scripts/lib/telemetry/pricing.mjs +118 -71
  294. package/scripts/lib/telemetry/schema.mjs +30 -0
  295. package/scripts/lib/telemetry/sync.mjs +61 -6
  296. package/scripts/lib/telemetry-flush-health-banner.mjs +4 -22
  297. package/scripts/lib/test-runner/issue-reconcile.mjs +48 -16
  298. package/scripts/lib/tests-src-ratio.mjs +140 -23
  299. package/scripts/lib/tmux-layout/layouts.mjs +1 -11
  300. package/scripts/lib/tmux-layout/telemetry-stats.mjs +72 -13
  301. package/scripts/lib/tmux-layout/tmux-shell.mjs +2 -11
  302. package/scripts/lib/user-invocable-skills.mjs +23 -3
  303. package/scripts/lib/ux-grill/reconcile.mjs +48 -22
  304. package/scripts/lib/validate/check-agents-skills.mjs +26 -15
  305. package/scripts/lib/validate/check-agents.mjs +1 -1
  306. package/scripts/lib/validate/check-cursor-adapter.mjs +1 -0
  307. package/scripts/lib/validate/check-entry-guard.mjs +13 -50
  308. package/scripts/lib/validate/check-hook-entry-guards.mjs +636 -0
  309. package/scripts/lib/validate/check-hooks-symmetry.mjs +5 -0
  310. package/scripts/lib/validate/check-learnings-shell-writes.mjs +214 -0
  311. package/scripts/lib/validate/check-pi-prompts.mjs +1 -0
  312. package/scripts/lib/validate/check-rules.mjs +7 -5
  313. package/scripts/lib/validate/check-skill-links.mjs +9 -1
  314. package/scripts/lib/validate/check-skill-script-paths.mjs +239 -27
  315. package/scripts/lib/validate/check-test-git-config-target.mjs +24 -34
  316. package/scripts/lib/validate/check-test-value-bans.mjs +87 -14
  317. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  318. package/scripts/lib/validate/check-untracked-test-deps.mjs +7 -102
  319. package/scripts/lib/validate/check-unwired-features.mjs +130 -27
  320. package/scripts/lib/validate/check-validator-registration.mjs +34 -10
  321. package/scripts/lib/validate/confidential-names.mjs +10 -0
  322. package/scripts/lib/validate-vendored-rules.mjs +210 -84
  323. package/scripts/lib/vault-mirror/namespace.mjs +46 -8
  324. package/scripts/lib/vault-mirror/process.mjs +10 -3
  325. package/scripts/lib/vault-mirror/render-sessions.mjs +12 -2
  326. package/scripts/lib/vault-mirror/telemetry.mjs +8 -1
  327. package/scripts/lib/vault-status/board-writer.mjs +48 -11
  328. package/scripts/lib/vault-status/narrative-mirror.mjs +71 -16
  329. package/scripts/lib/vault-yaml.mjs +118 -0
  330. package/scripts/lib/wave-executor/dispatch-common.mjs +10 -18
  331. package/scripts/lib/wave-executor/foreign-dispatch.mjs +1 -1
  332. package/scripts/lib/wave-executor/remote-dispatch.mjs +1 -1
  333. package/scripts/lib/wave-resource-gate.mjs +34 -8
  334. package/scripts/lib/worktree/lifecycle.mjs +153 -1
  335. package/scripts/memory-propose.mjs +18 -5
  336. package/scripts/release-session-lock.mjs +306 -0
  337. package/scripts/release.mjs +40 -8
  338. package/scripts/repair-invalid-sessions.mjs +37 -4
  339. package/scripts/resolve-session-invocation.mjs +59 -0
  340. package/scripts/run-migrate-v2-cross-repo.mjs +158 -30
  341. package/scripts/run-quality-gate.mjs +357 -197
  342. package/scripts/site-numbers.mjs +163 -5
  343. package/scripts/sweep-expired-learnings.mjs +56 -5
  344. package/scripts/sweep-expired-rules.mjs +16 -4
  345. package/scripts/validate-plugin.mjs +19 -0
  346. package/scripts/validate-wave-scope.mjs +32 -105
  347. package/scripts/vault-integration-watcher.mjs +2 -1
  348. package/scripts/vault-mirror.mjs +81 -47
  349. package/scripts/wave-scope-binding.mjs +2 -1
  350. package/skills/_shared/config-reading.md +3 -3
  351. package/skills/_shared/fleet-protocol.md +131 -0
  352. package/skills/_shared/platform-tools.md +23 -11
  353. package/skills/_shared/state-ownership.md +5 -0
  354. package/skills/autopilot/SKILL.md +47 -18
  355. package/skills/bootstrap/SKILL.md +1 -1
  356. package/skills/bootstrap/_shared-template.md +1 -1
  357. package/skills/bootstrap/fast-template.md +1 -1
  358. package/skills/bootstrap/private-contract.md +2 -1
  359. package/skills/bootstrap/public-fallback.md +6 -3
  360. package/skills/bootstrap/references/bootstrap-retroactive-flow.md +2 -1
  361. package/skills/claude-md-drift-check/SKILL.md +10 -10
  362. package/skills/claude-md-drift-check/checker.mjs +26 -18
  363. package/skills/convergence-monitoring/README.md +8 -1
  364. package/skills/convergence-monitoring/SIGNALS.md +50 -6
  365. package/skills/convergence-monitoring/SKILL.md +15 -6
  366. package/skills/eval/SKILL.md +39 -24
  367. package/skills/eval/rubric-v1.md +1 -0
  368. package/skills/eval/rubric-v2.md +457 -0
  369. package/skills/evolve/SKILL.md +3 -3
  370. package/skills/evolve/references/evolve-analyze-mode.md +10 -6
  371. package/skills/evolve/references/evolve-dialectic-mode.md +42 -25
  372. package/skills/gitlab-ops/SKILL.md +3 -2
  373. package/skills/memory-cleanup/SKILL.md +1 -1
  374. package/skills/navigator/SKILL.md +193 -0
  375. package/skills/navigator/references/account-check.sh +87 -0
  376. package/skills/navigator/references/fleet-decisions.mjs +231 -0
  377. package/skills/navigator/references/slot-check.sh +114 -0
  378. package/skills/npm-publish/SKILL.md +1 -1
  379. package/skills/persona-panel/SKILL.md +2 -2
  380. package/skills/persona-panel/persona-format.md +2 -2
  381. package/skills/persona-panel/presets/designer-lens.md +1 -1
  382. package/skills/persona-panel/presets/engineer-lens.md +1 -1
  383. package/skills/persona-panel/presets/pm-lens.md +1 -1
  384. package/skills/plan/mode-retro.md +19 -4
  385. package/skills/quality-gates/SKILL.md +6 -4
  386. package/skills/reconcile/SKILL.md +11 -0
  387. package/skills/remote-offload/SKILL.md +4 -2
  388. package/skills/session-end/SKILL.md +13 -16
  389. package/skills/session-end/discovery-scan.md +1 -1
  390. package/skills/session-end/learning-patterns.md +22 -12
  391. package/skills/session-end/metrics-collection.md +5 -6
  392. package/skills/session-end/phase-3-6-tail.md +55 -9
  393. package/skills/session-end/references/phase-3-documentation-updates.md +2 -2
  394. package/skills/session-end/references/phase-4a-worktree-cleanup.md +2 -0
  395. package/skills/session-end/references/phase-5-issue-cleanup.md +9 -14
  396. package/skills/session-end/references/session-summary-template.md +1 -1
  397. package/skills/session-end/session-metrics-write.md +32 -60
  398. package/skills/session-plan/SKILL.md +17 -8
  399. package/skills/session-plan/references/session-plan-task-classification.md +2 -2
  400. package/skills/session-start/SKILL.md +6 -0
  401. package/skills/session-start/references/operations-contract.md +4 -0
  402. package/skills/session-start/references/phase-1-7-vault-status-board.md +1 -1
  403. package/skills/session-start/references/phase-4-ssot-environment-check.md +4 -3
  404. package/skills/test-audit/SKILL.md +152 -0
  405. package/skills/test-audit/references/campaign.md +111 -0
  406. package/skills/test-audit/references/mutate.sh +50 -0
  407. package/skills/ux-grill/SKILL.md +1 -1
  408. package/skills/wave-executor/SKILL.md +9 -5
  409. package/skills/wave-executor/circuit-breaker.md +2 -0
  410. package/skills/wave-executor/references/wave-executor-state-init.md +8 -3
  411. package/skills/wave-executor/references/wave-loop-dispatch.md +5 -2
  412. package/skills/wave-executor/references/wave-loop-review.md +3 -5
  413. package/templates/personas/README.md +5 -5
  414. package/templates/personas/accounting-compliance.v1.md +1 -1
  415. package/templates/personas/accounting-tax-advisor.v1.md +1 -1
  416. package/templates/personas/buyer-p1-cto.v1.md +1 -1
  417. package/templates/personas/buyer-p2-kanzlei.v1.md +1 -1
  418. package/templates/personas/buyer-p3-build.v1.md +1 -1
  419. package/templates/personas/buyer-p4-tech-veto.v1.md +1 -1
  420. package/templates/personas/buyer-p5-solo.v1.md +1 -1
  421. package/templates/personas/buyer-p6-ld.v1.md +1 -1
  422. package/templates/personas/klima-ai-expert.v1.md +1 -1
  423. package/templates/personas/klima-physicist.v1.md +1 -1
  424. package/.cursor/commands/convergence-monitoring.md +0 -13
  425. package/.cursor/commands/npm-publish.md +0 -13
  426. package/pi/prompts/convergence-monitoring.md +0 -11
  427. package/pi/prompts/npm-publish.md +0 -11
@@ -76,7 +76,24 @@
76
76
  * real background-batch collision through. Completion arrives later as a
77
77
  * `<task-notification>` record carrying `<tool-use-id>toolu_…</tool-use-id>`
78
78
  * and `<status>completed</status>` (measured: launch 14:14:26.768 →
79
- * notification 14:24:39.360, ten minutes later).
79
+ * notification 14:24:39.360, ten minutes later). Any TERMINAL status
80
+ * finishes the id, not only `completed` — see {@link TERMINAL_STATUS_RE}.
81
+ * #1455: a 429'd agent notified `failed`, was resumed via SendMessage
82
+ * (new tool-use-id), and its original id never finished → permanent deny.
83
+ * Only a HARNESS-WRITTEN carrier record counts (#1459 Pkt 2 Rest,
84
+ * {@link notificationCarrierText}): a complete block inside a `tool_result`
85
+ * (a `Read` of a fixture), assistant text or a typed prompt is a forgery
86
+ * that would false-ALLOW an overlap with a still-running agent.
87
+ * c) EXACT ID, POSITIVE PROOF, RESUME (#1480, #1459 P1). A ledger entry keeps
88
+ * its dispatch's PreToolUse `tool_use_id` as `useId`, and liveness is
89
+ * looked up by THAT id; the description is only the fallback for entries
90
+ * without one, the ledger `id` never (B3). A dispatch finishes only on
91
+ * positive evidence — a terminal carrier, `is_error`, or a result in
92
+ * {@link RESULT_COMPLETION_FORMS}; any other result form is "unknown",
93
+ * i.e. no evidence (B2, row 13). A `SendMessage` to the dispatch's task id
94
+ * re-opens it until a terminal carrier for the dispatch, that SendMessage or
95
+ * the task id follows — a resumed agent is running, not done (#1459 P1;
96
+ * {@link buildLivenessIndex}).
80
97
  *
81
98
  * COST CONTAINMENT: the transcript is read ONLY when a collision has already
82
99
  * been found — i.e. on the path that is about to deny. The 99 % no-collision
@@ -84,8 +101,9 @@
84
101
  * costs 78 ms to read + 129 ms to scan; a typical one is 1–5 MB.
85
102
  *
86
103
  * BLIND FALLBACK + ITS CEILING (BV-004): when the transcript is unavailable or
87
- * carries no record of that agent at all, liveness falls back to the ledger
88
- * entry's own age, with `IN_FLIGHT_TTL_MS` = 30 min. Named ceiling: the largest
104
+ * carries no positive evidence for that agent (no record, or only an unknown
105
+ * result form), liveness falls back to the ledger entry's own age, with
106
+ * `IN_FLIGHT_TTL_MS` = 30 min. Named ceiling: the largest
89
107
  * MEASURED same-batch dispatch spread is 95.7 s, so 30 min is ~19× headroom
90
108
  * against the false-ALLOW direction, while both measured sequential repair gaps
91
109
  * (36 min, 49 min) sit above it. Revisit trigger: a same-batch spread above
@@ -112,11 +130,11 @@
112
130
  * | 7 | ledger unreadable / corrupt | WARN + ALLOW + SELF-HEAL | loss of state is not evidence of a violation; loud so it gets noticed. The verdict now CARRIES a fresh ledger, so the corruption is repaired on the spot — without it the guard stayed OFF for the whole remaining wave, visible only in one `systemMessage` |
113
131
  * | 8 | `git ls-files` failed | ALLOW (degraded) | glob-vs-glob expansion degrades, concrete collisions are still found. A git outage is not a scope violation |
114
132
  * | 9 | `findScopeCollisions` → not evaluable | WARN + ALLOW | the library says "not evaluable". Denying on a verdict with no witness is an assertion without evidence |
115
- * |10 | same agent id re-dispatched, same scope| ALLOW (ledger replace) | a retry after a failed agent is legitimate; treating it as a duplicate would make the guard block every retry — a self-lock vector |
116
- * |10a| collision, but EVERY colliding prior agent has FINISHED | ALLOW (+ prune) | the sequential repair pass. Its ledger records are pruned, so the state cannot re-block the next one either |
133
+ * |10 | same agent id re-dispatched | ALLOW; predecessor pruned only when FINISHED | a retry after a failed agent is legitimate (a failed agent is finished), so the guard must not self-lock. #1480 B4: an unfinished predecessor is KEPT as a claim — a third agent hitting only its scope still collides. With distinct `tool_use_id`s on both it is an ordinary partner (row 11); without them it never collides with its own retry. Replaced WITHOUT a probe only where no claim is lost: the same `tool_use_id` (the same dispatch evaluated again), or — neither carrying an id — a predecessor wholly covered by the retry's scope |
134
+ * |10a| collision, but EVERY colliding prior agent has FINISHED (positive evidence) | ALLOW (+ prune) | the sequential repair pass. Its ledger records are pruned, so the state cannot re-block the next one either |
117
135
  * |11 | collision with a prior agent still IN FLIGHT | **DENY** | the one case this hook exists for |
118
136
  * |12 | unexpected throw | ALLOW + stderr | as row 2 |
119
- * |13 | liveness probe throws / no evidence at all | treat as IN FLIGHT | keeps row 11 biting; the blind case is bounded by `IN_FLIGHT_TTL_MS`, never unbounded |
137
+ * |13 | liveness probe throws / no positive evidence (no record, unknown result form) | treat as IN FLIGHT | keeps row 11 biting; the blind case is bounded by `IN_FLIGHT_TTL_MS`, never unbounded. A dispatch re-opened by a SendMessage with no completion after it is in flight outright, without TTL |
120
138
  * |14 | ledger lock not acquirable in `LEDGER_LOCK_TIMEOUT_MS` | run UNLOCKED (degraded) | the lock removes the read-modify-write race (below); failing to take it must not deny, so the cycle degrades to the pre-lock behaviour |
121
139
  *
122
140
  * Every row that reaches a verdict from `decide()` — 5–11 and 13–14 — also
@@ -288,6 +306,29 @@ const SHAPE_NONE = 'none';
288
306
  /** The per-dispatch observability record (#1092) — see § Observability. */
289
307
  const SCOPE_EVENT = 'orchestrator.wave_dispatch.scope_checked';
290
308
 
309
+ /**
310
+ * The worktree-base observability record (#1413).
311
+ *
312
+ * Same `wave_dispatch` domain and same `_checked` verb as `SCOPE_EVENT`, and
313
+ * written for EVERY `isolation: "worktree"` dispatch rather than only the
314
+ * alarming one. That is HR-105 applied at the source: a numerator-only stream
315
+ * cannot tell "genuinely rare" from "silently broken", and the firing rate this
316
+ * warning must stay under is then unfalsifiable. With every outcome in one
317
+ * stream the rate is `stale:true / all records of this name`.
318
+ *
319
+ * #1424 widened "every outcome" from two to three: the `stale: true|false`
320
+ * split was already covered, but each NON-measurement (peer STATE.md, no
321
+ * STATE.md, no `session-start-ref`, a git failure) returned early and emitted
322
+ * nothing — so the gate in front of the split reproduced exactly the
323
+ * zero-records ambiguity the split had removed. Those now carry
324
+ * `stale: null` plus a `skipped` reason, and only `isolation !== "worktree"`
325
+ * stays silent (HR-101: it is ~90% of all dispatches).
326
+ */
327
+ const WORKTREE_BASE_EVENT = 'orchestrator.wave_dispatch.worktree_base_checked';
328
+
329
+ /** State-dir candidates, in the same order `waveKeyOf()` probes them. */
330
+ const STATE_DIR_CANDIDATES = ['.pi', '.cursor', '.codex', '.claude'];
331
+
291
332
  /**
292
333
  * The receive-side instruction line `renderScopeEchoInstruction()` renders and
293
334
  * the coordinator appends immediately after the fenced block
@@ -335,9 +376,77 @@ const MAX_TRANSCRIPT_BYTES = 256 * 1024 * 1024;
335
376
  * a completion — see § Liveness (b). Reading it as one would let every real
336
377
  * background-batch collision through, which is the one direction this repair
337
378
  * must not take.
379
+ *
380
+ * Recognised only as the PREFIX of the result text (after trimStart): a finished
381
+ * sync report that merely QUOTES this sentence is not a launch receipt, and read
382
+ * as one it stayed "running" without TTL (R1 LOW). Census 2026-09-30, 5,881
383
+ * local transcripts: 4,684 of 4,684 ACK results begin with it, also in their
384
+ * first content part.
338
385
  */
339
386
  const ASYNC_LAUNCH_ACK = 'Async agent launched successfully';
340
387
 
388
+ /**
389
+ * The `tool_result` forms that PROVE a dispatch finished (#1480 B2), one line
390
+ * per form. A result matching none of them is NOT a completion but "no
391
+ * evidence" — the entry falls back to the TTL rule (matrix row 13). `is_error`
392
+ * is handled beside this list (the dispatch never ran), and the ACK is tested
393
+ * FIRST, so a launch receipt can never read as a completion.
394
+ *
395
+ * Census 2026-09-30 (local transcripts, 14 days, counts only), Agent
396
+ * tool_results: ACK 4,730; sync completion carrying the `<usage>`/`total_tokens`
397
+ * trailer and `agentId:` 690 (690/690); `is_error` 37; neither 38. Of those 38,
398
+ * "This agent's report was delivered…" is a completion (35/35 without a
399
+ * terminal carrier before or after, 2026-09-30), "Fork started — processing in
400
+ * b…" is not (a start receipt without the ACK wording — before this list it was
401
+ * read as a completion: the B2 fail-open).
402
+ *
403
+ * Named ceiling (BV-004): any other wording reads as "unknown", so a fresh entry
404
+ * binds for up to IN_FLIGHT_TTL_MS although its agent may be done — a bounded
405
+ * false-DENY, never a false-ALLOW. Revisit-Trigger: a census re-run finds an
406
+ * Agent tool_result wording in neither this list nor ASYNC_LAUNCH_ACK — classify
407
+ * it, then add ONE line here.
408
+ */
409
+ const RESULT_COMPLETION_FORMS = Object.freeze([
410
+ Object.freeze({ at: 'anywhere', text: '<usage>' }), // sync trailer, 690/690
411
+ Object.freeze({ at: 'anywhere', text: 'total_tokens' }), // sync trailer, 690/690
412
+ Object.freeze({ at: 'start', text: "This agent's report was delivered" }), // 35/35 without carrier
413
+ ]);
414
+
415
+ /** The tool that addresses — and can RESUME — an already-dispatched agent (#1459 P1). */
416
+ const SEND_MESSAGE_TOOL = 'SendMessage';
417
+
418
+ /**
419
+ * A `SendMessage` result that reached nobody (`{"success":false,…"No agent
420
+ * named…"}`, census 2026-09-30): such a send activates nothing.
421
+ */
422
+ const SEND_NO_EFFECT = '"success":false';
423
+
424
+ /**
425
+ * The harness's `agentId:` line in an Agent tool_result (ACK or sync trailer) —
426
+ * the task id a `SendMessage` addresses. Only a line that BEGINS with it, id on
427
+ * the same line, read as the LAST match over the content parts joined with
428
+ * `\n`: a sync result embeds the agent's own report ahead of the harness
429
+ * trailer, and a report ending in `agentId:` must not swallow the trailer's
430
+ * line (R1 MEDIUM — a wrong task id let a resumed agent read as finished).
431
+ * Census 2026-09-30, 5,881 local transcripts: in 5,375 of 5,375 Agent results
432
+ * carrying `agentId:`, the last one starts a line and sits in the last part.
433
+ */
434
+ const AGENT_ID_LINE_RE = /^agentId:[ \t]*([A-Za-z0-9_-]+)/gm;
435
+
436
+ /** Per-dispatch liveness states — see {@link buildLivenessIndex}. */
437
+ const LIVE_FINISHED = 'finished';
438
+ const LIVE_RUNNING = 'running';
439
+ const LIVE_UNKNOWN = 'unknown';
440
+
441
+ /**
442
+ * `<task-notification>` statuses that mean the task is NO LONGER RUNNING (#1455).
443
+ * Enumerated from evidence, not guessed — census 2026-09-25 over the operator's
444
+ * local Claude Code transcripts (`rg "<tool-use-id>…<status>X</status>"` on
445
+ * `*.jsonl`): completed, failed 1430 lines, killed 609, stopped 28.
446
+ * `running` is deliberately absent — it is the one non-terminal spelling.
447
+ */
448
+ const TERMINAL_STATUS_RE = /<status>(?:completed|failed|killed|stopped)<\/status>/;
449
+
341
450
  /**
342
451
  * The consequence block spliced VERBATIM into the GUARD INACTIVE banner (#993),
343
452
  * naming the enforcement this hook's outage stops applying.
@@ -727,8 +836,8 @@ export function extractScopeFromPrompt(prompt) {
727
836
 
728
837
  /**
729
838
  * The dispatch's human description — the field the liveness probe matches
730
- * against the transcript's `tool_use` blocks (present in 147/147 measured
731
- * payloads).
839
+ * against the transcript's `tool_use` blocks when an entry carries no exact
840
+ * `useId` (present in 147/147 measured payloads).
732
841
  *
733
842
  * @param {{description?: unknown}} toolInput
734
843
  * @returns {string}
@@ -754,69 +863,401 @@ export function agentIdOf(toolInput) {
754
863
  return 'unnamed-agent';
755
864
  }
756
865
 
866
+ /**
867
+ * A dispatch's EXACT identity (#1480 A): the PreToolUse payload's `tool_use_id`,
868
+ * or a ledger entry's stored `useId`. On Claude Code it equals the transcript's
869
+ * `tool_use` id, so liveness becomes a lookup instead of a description match;
870
+ * other harnesses may omit it, and `''` then means "no exact id". Taken
871
+ * verbatim, never re-spelled — and dropped rather than clipped past
872
+ * MAX_AGENT_ID_CHARS: a clipped id could never match, two clipped ids could
873
+ * match wrongly.
874
+ *
875
+ * @param {unknown} value
876
+ * @returns {string}
877
+ */
878
+ function exactUseId(value) {
879
+ return typeof value === 'string' && value.trim() !== '' && value.length <= MAX_AGENT_ID_CHARS ? value : '';
880
+ }
881
+
757
882
  // ---------------------------------------------------------------------------
758
883
  // Liveness — has an already-recorded agent FINISHED? (§ Liveness)
759
884
  // ---------------------------------------------------------------------------
760
885
 
886
+ const NOTIFICATION_OPEN = '<task-notification>';
887
+ const NOTIFICATION_CLOSE = '</task-notification>';
888
+
889
+ /**
890
+ * A notification head in the harness's own tag order: `<task-id>`,
891
+ * `<tool-use-id>`, optional `<output-file>`, `<status>` — anchored at the opener.
892
+ * Group 1 is the task id (the resume key, #1459 P1), group 2 the tool-use id,
893
+ * group 3 the whole `<status>` element.
894
+ */
895
+ const NOTIFICATION_HEAD_RE = /^<task-notification>\s*<task-id>([^<]*)<\/task-id>\s*<tool-use-id>([^<]+)<\/tool-use-id>\s*(?:<output-file>[^<]*<\/output-file>\s*)?(<status>[^<]*<\/status>)/;
896
+
897
+ /** `origin.kind` / `commandMode` value the harness stamps on a notification carrier. */
898
+ const CARRIER_KIND = 'task-notification';
899
+
900
+ /** `queue-operation` operations measured to carry a notification (dequeue carries no content). */
901
+ const CARRIER_QUEUE_OPS = new Set(['enqueue', 'remove']);
902
+
903
+ /** First line of the harness preamble some `user` carriers put before the block. */
904
+ const SYSTEM_NOTIFICATION_MARK = '[SYSTEM NOTIFICATION - NOT USER INPUT]\n';
905
+
906
+ /**
907
+ * The notification text of a HARNESS-WRITTEN carrier record, or `null` for any
908
+ * other record (#1459 Pkt 2 Rest). Census 2026-09-28 over the operator's local
909
+ * transcripts (11,005 files, 2026-08-06 → 2026-09-28): every terminal id the
910
+ * pre-fix raw-line match accepted from a real carrier came from exactly one of
911
+ * three record forms, each of which carries ids the other two do not:
912
+ * - `user` with STRING `message.content` and `origin.kind: 'task-notification'`
913
+ * (a typed prompt carries `origin.kind: 'human'`). The text begins with the
914
+ * opener, or with the harness preamble ({@link SYSTEM_NOTIFICATION_MARK},
915
+ * tag-free, ending in a blank line) directly followed by the opener;
916
+ * - `queue-operation` with `operation` `enqueue`/`remove` and string `content`;
917
+ * - `attachment` of type `queued_command` with `commandMode: 'task-notification'`.
918
+ * Everything else — `tool_result` content, assistant text, tool inputs, typed
919
+ * prompts — is a quote or a forgery and yields `null`, i.e. no finished id.
920
+ *
921
+ * The guard on the `user` form is `origin.kind`, which only the harness sets; the
922
+ * preamble rule merely strips a tag-free prefix and is no control on its own.
923
+ *
924
+ * Named residual (BV-004): a `queue-operation` record has no field that tells a
925
+ * harness notification from an operator prompt typed while the agent was busy,
926
+ * so an OPERATOR could forge through that one channel. An agent cannot through
927
+ * the one producer censused: text a subagent or peer delivers via `SendMessage`
928
+ * arrives wrapped by the harness, so it never begins with the opener. Measured
929
+ * 2026-09-28 in a second run (count only, 11,008 transcript files, three more
930
+ * than the census above): 7,131 records carry such a message, 1,801 of them as
931
+ * `queue-operation`, 2,486 as `queued_command` with `commandMode: 'prompt'`;
932
+ * 0 of the 7,131 begin with `<task-notification>`. Other queue producers
933
+ * (scheduled wake-ups, a `/loop` body) were not censused. Revisit if the harness
934
+ * adds a discriminator here (like `commandMode` on the attachment) or stops
935
+ * wrapping.
936
+ *
937
+ * @param {any} rec — one parsed transcript record
938
+ * @returns {string|null}
939
+ */
940
+ function notificationCarrierText(rec) {
941
+ if (rec?.type === 'user' && rec?.origin?.kind === CARRIER_KIND && typeof rec?.message?.content === 'string') {
942
+ const text = rec.message.content;
943
+ if (!text.startsWith(SYSTEM_NOTIFICATION_MARK)) return text;
944
+ const open = text.indexOf('<');
945
+ return open !== -1 && text.startsWith(`\n\n${NOTIFICATION_OPEN}`, open - 2) ? text.slice(open) : null;
946
+ }
947
+ if (rec?.type === 'queue-operation' && CARRIER_QUEUE_OPS.has(rec?.operation) && typeof rec?.content === 'string') {
948
+ return rec.content;
949
+ }
950
+ const att = rec?.type === 'attachment' ? rec?.attachment : undefined;
951
+ if (att?.type === 'queued_command' && att?.commandMode === CARRIER_KIND && typeof att?.prompt === 'string') {
952
+ return att.prompt;
953
+ }
954
+ return null;
955
+ }
956
+
957
+ /**
958
+ * The HEAD of a carrier text's FIRST `<task-notification>` block (#1459 Pkt 2) —
959
+ * its task id, its tool-use id and whether its status is TERMINAL; at most one
960
+ * head per carrier. Only a terminal head finishes an id; a non-terminal one only
961
+ * contributes the task id (#1459 P1), which can open a dispatch, never close it.
962
+ * (Named `finishedNotificationIds` until #1480; the ceiling below is unchanged.)
963
+ *
964
+ * The text must BEGIN with the opener (a block further in is a quote) and its
965
+ * head must follow the harness tag order ({@link NOTIFICATION_HEAD_RE}).
966
+ * `<summary>` and `<result>` carry free text that can QUOTE another
967
+ * notification's `<tool-use-id>` and `<status>`, or even forge a whole block
968
+ * (R2 F1: a summary that closes itself and opens a complete fake block defeated
969
+ * the earlier cut-then-split approach). So nothing past the block's HEAD is read:
970
+ * the head runs from the opener to the earliest `<summary>`, `<result>` or
971
+ * `</task-notification>`. Fail-closed — the deny stays — when the text is not a
972
+ * carrier's, does not begin with the opener, has no closing tag (truncated
973
+ * block), or its head is out of order or carries no terminal status.
974
+ *
975
+ * Named ceiling (BV-004, #1467): a batch of several DIFFERENT notifications in
976
+ * one carrier reports only the first as finished (the rest stay running, the
977
+ * safe direction but a false-DENY source). Census 2026-09-30 over the operator's
978
+ * local transcripts of all repos (11,684 files, 2026-08-06 → 2026-09-30, at
979
+ * f58480e5): 23 preamble-form `user` carriers hold 2+ distinct terminal ids; 108
980
+ * non-first ids are finished by no other carrier, and 0 of them is an `Agent`
981
+ * dispatch id (105 Bash, 3 Monitor) — the only ids this index resolves, so no
982
+ * Agent liveness is lost today. The 2026-09-28 figures (31 / 155) were not
983
+ * reproduced. No split is built: it would gain nothing, and its safety is
984
+ * unproven. `origin` has no per-block discriminator, so a split is safe only if
985
+ * the harness escapes `<` in `<summary>`/`<result>` — unescaped, a block forged
986
+ * there is byte-identical to a genuine `\n\n`-joined batch. The census suggests
987
+ * escaping (12,039 of 25,031 results carry `&lt;`, 3 a raw `<` + letter) but
988
+ * does not establish it for every harness version. Revisit-Trigger: a re-run
989
+ * census finds a non-first terminal id that is an `Agent` dispatch id finished
990
+ * by no other carrier or `tool_result`; a split then needs escaping proven first.
991
+ *
992
+ * @param {string|null} text — a carrier's text from {@link notificationCarrierText}
993
+ * @returns {{taskId: string, toolUseId: string, terminal: boolean}|null}
994
+ */
995
+ function notificationHead(text) {
996
+ if (typeof text !== 'string' || !text.startsWith(NOTIFICATION_OPEN)) return null;
997
+ const close = text.indexOf(NOTIFICATION_CLOSE);
998
+ if (close === -1) return null;
999
+ let headEnd = close;
1000
+ for (const tag of ['<summary>', '<result>']) {
1001
+ const at = text.indexOf(tag);
1002
+ if (at !== -1 && at < headEnd) headEnd = at;
1003
+ }
1004
+ const head = text.slice(0, headEnd).match(NOTIFICATION_HEAD_RE);
1005
+ if (head === null) return null;
1006
+ return { taskId: head[1].trim(), toolUseId: head[2], terminal: TERMINAL_STATUS_RE.test(head[3]) };
1007
+ }
1008
+
761
1009
  /**
762
- * Index a session transcript by agent DESCRIPTION → completion state.
1010
+ * Classify an Agent dispatch's OWN `tool_result` (#1480 B2): `done` only on
1011
+ * positive evidence (`is_error`, or one of {@link RESULT_COMPLETION_FORMS}),
1012
+ * `ack` for the launch acknowledgement (a text PREFIX, see
1013
+ * {@link ASYNC_LAUNCH_ACK}), `unknown` for everything else. The ACK is tested
1014
+ * before the forms, so no ACK wording can ever read as a completion.
1015
+ *
1016
+ * @param {{is_error?: unknown}} block
1017
+ * @param {string} text — {@link resultTextOf}(block)
1018
+ * @returns {'done'|'ack'|'unknown'}
1019
+ */
1020
+ function dispatchResultForm(block, text) {
1021
+ if (block?.is_error === true) return 'done';
1022
+ const lead = text.trimStart();
1023
+ if (lead.startsWith(ASYNC_LAUNCH_ACK)) return 'ack';
1024
+ for (const form of RESULT_COMPLETION_FORMS) {
1025
+ if (form.at === 'start' ? lead.startsWith(form.text) : text.includes(form.text)) return 'done';
1026
+ }
1027
+ return 'unknown';
1028
+ }
1029
+
1030
+ /**
1031
+ * The LAST line-leading `agentId:` a harness-written dispatch result names, or
1032
+ * `''` — see {@link AGENT_ID_LINE_RE}. Parts are joined with `\n` HERE (unlike
1033
+ * {@link resultTextOf}), so a part boundary is a line boundary and report text
1034
+ * cannot run into the trailer's line.
763
1035
  *
764
- * Three record shapes are read, all measured in this repo's own transcripts:
765
- * - `tool_use` `{name:'Agent', id, input.description}` — the dispatch.
766
- * - `tool_result` `{tool_use_id, content}` — a completion for the SYNCHRONOUS
767
- * shape, but only when its text is not the {@link ASYNC_LAUNCH_ACK}.
768
- * - a `<task-notification>` record carrying `<tool-use-id>` and
769
- * `<status>completed</status>` — the ASYNC shape's completion.
1036
+ * @param {{content?: unknown}} block
1037
+ * @returns {string}
1038
+ */
1039
+ function lastAgentIdIn(block) {
1040
+ const c = block?.content;
1041
+ const text = typeof c === 'string'
1042
+ ? c
1043
+ : Array.isArray(c) ? c.map((part) => (typeof part?.text === 'string' ? part.text : '')).join('\n') : '';
1044
+ let last = '';
1045
+ for (const m of text.matchAll(AGENT_ID_LINE_RE)) last = m[1];
1046
+ return last;
1047
+ }
1048
+
1049
+ /**
1050
+ * Append `value` to the array stored under `key`.
770
1051
  *
771
- * A description dispatched N times counts as finished only when EVERY one of its
772
- * tool_use ids is finished. Conservative on purpose: one outstanding run of the
773
- * same agent keeps the deny alive.
1052
+ * @template T
1053
+ * @param {Map<string, T[]>} map
1054
+ * @param {string} key
1055
+ * @param {T} value
1056
+ */
1057
+ function pushTo(map, key, value) {
1058
+ const list = map.get(key);
1059
+ if (list === undefined) map.set(key, [value]);
1060
+ else list.push(value);
1061
+ }
1062
+
1063
+ /**
1064
+ * Index a session transcript by DISPATCH tool-use id → liveness state, plus
1065
+ * description → dispatch ids for ledger entries that carry no exact id.
1066
+ *
1067
+ * Record shapes read, all measured (census 2026-09-30 for the SendMessage half):
1068
+ * - `tool_use` `{name:'Agent', id, input.description}` — the dispatch U.
1069
+ * - `tool_use` `{name:'SendMessage', id, input.to}` — addresses a dispatch by
1070
+ * its task id T(U) (= agentId); 1,055 via a notification task id, 33 via the
1071
+ * ACK's agentId, 0 via the description.
1072
+ * - `tool_result` for U — classified by {@link dispatchResultForm}; for a
1073
+ * SendMessage only whether it reached nobody ({@link SEND_NO_EFFECT} or
1074
+ * `is_error`).
1075
+ * - a `<task-notification>` CARRIER record — only a harness-written carrier
1076
+ * ({@link notificationCarrierText}), only its first block's head
1077
+ * ({@link notificationHead}); a terminal `<status>` finishes (#1455: `failed`
1078
+ * too).
1079
+ *
1080
+ * T(U) is the last line-leading `agentId:` ({@link lastAgentIdIn}) of U's own
1081
+ * ACK or positive result, else the task
1082
+ * id of the first carrier head carrying U's tool-use id.
1083
+ *
1084
+ * RESUME (#1459 P1). U is ACTIVATED by its dispatch and by every SendMessage
1085
+ * with `to === T(U)` whose result is not a no-effect (a send without a result
1086
+ * yet counts — the safe direction). U is FINISHED when a completion lies AFTER
1087
+ * its last activation, by transcript line position: a terminal carrier with
1088
+ * tool-use id U, with the id of an activating SendMessage, or with task id T(U);
1089
+ * when the dispatch itself is the last activation, also U's own positive
1090
+ * result. Measured 2026-09-30: a resumed agent completes under the
1091
+ * SendMessage's id and the same task id, never again under U (455 cases); a
1092
+ * message queued to a running agent completes under U with the same task id
1093
+ * (566). Activations only OPEN; closing still needs a carrier or U's own
1094
+ * harness-written result.
1095
+ *
1096
+ * States: `finished`; `running` — no result, an ACK, or a SendMessage as last
1097
+ * activation with no completion after it (no TTL); `unknown` — only a result
1098
+ * form without positive evidence, which the probe treats as no evidence (TTL).
1099
+ * A repeated `tool_use` record (same Agent or SendMessage id) is the SAME event,
1100
+ * not a new activation: its position is the FIRST occurrence, so a copy after
1101
+ * the completion cannot re-open a finished agent (R1 D4, a false-DENY). A
1102
+ * repeated carrier still closes. Hardening without a measured case: census
1103
+ * 2026-09-30 (5,881 local transcripts) found 0 repeated Agent and 0 repeated
1104
+ * SendMessage `tool_use` records.
1105
+ *
1106
+ * Named ceiling (BV-004): `to` is matched against T(U) only — a SendMessage that
1107
+ * addresses the agent by name or description re-opens nothing (0 of ~6,200
1108
+ * measured 2026-09-30). Revisit-Trigger: a census re-run finds a SendMessage to
1109
+ * a dispatch whose `to` is not its task id.
774
1110
  *
775
1111
  * Pure and total — a malformed line is skipped, never thrown on.
776
1112
  *
777
1113
  * @param {string} raw — the transcript's JSONL text
778
- * @returns {Map<string, boolean>} description → finished?
1114
+ * @returns {{states: Map<string, 'finished'|'running'|'unknown'>,
1115
+ * idsByDesc: Map<string, string[]>}}
779
1116
  */
780
- export function buildTranscriptIndex(raw) {
781
- const out = new Map();
782
- if (typeof raw !== 'string' || raw.length === 0) return out;
783
-
784
- const dispatched = new Map(); // description → tool_use ids
785
- const finishedIds = new Set();
786
-
787
- for (const line of raw.split('\n')) {
1117
+ function buildLivenessIndex(raw) {
1118
+ const states = new Map();
1119
+ const idsByDesc = new Map();
1120
+ if (typeof raw !== 'string' || raw.length === 0) return { states, idsByDesc };
1121
+
1122
+ const dispatchPos = new Map(); // U → FIRST line position of its tool_use
1123
+ const results = new Map(); // U → [{pos, form, agentId}]
1124
+ const sendsByTo = new Map(); // SendMessage `to` → [{id, pos}]
1125
+ const sendIds = new Set();
1126
+ const sendNoEffect = new Set(); // SendMessage ids whose result reached nobody
1127
+ const headsByUse = new Map(); // carrier tool-use id → [{pos, taskId, terminal}]
1128
+ const terminalByTask = new Map(); // carrier task id → [pos] of terminal heads
1129
+
1130
+ const lines = raw.split('\n');
1131
+ for (let pos = 0; pos < lines.length; pos++) {
1132
+ const line = lines[pos];
788
1133
  if (line.length < 24) continue;
1134
+ // Substring prefilter only — the verdict needs the PARSED record: a raw-line
1135
+ // match let a forged block in any record finish an id (#1459 Pkt 2 Rest).
1136
+ const hasNotification = line.includes('task-notification');
1137
+ const hasToolRow = line.includes('"tool_use"') || line.includes('tool_use_id');
1138
+ if (!hasNotification && !hasToolRow) continue;
1139
+ let rec;
1140
+ try { rec = JSON.parse(line); } catch { continue; }
789
1141
 
790
- // ASYNC completion — matched on the RAW line: the tags are plain text inside
791
- // a JSON string, so no parse is needed and the `queue-operation` carrier
792
- // record (which has no `message.content`) is covered too.
793
- if (line.includes('task-notification') && line.includes('<status>completed</status>')) {
794
- for (const m of line.matchAll(/<tool-use-id>([^<]+)<\/tool-use-id>/g)) finishedIds.add(m[1]);
1142
+ // Completion (and the task id) — only from a harness-written carrier record.
1143
+ if (hasNotification) {
1144
+ const head = notificationHead(notificationCarrierText(rec));
1145
+ if (head !== null) {
1146
+ pushTo(headsByUse, head.toolUseId, { pos, taskId: head.taskId, terminal: head.terminal });
1147
+ if (head.terminal && head.taskId !== '') pushTo(terminalByTask, head.taskId, pos);
1148
+ }
795
1149
  }
796
1150
 
797
- if (!line.includes('"tool_use"') && !line.includes('tool_use_id')) continue;
798
- let rec;
799
- try { rec = JSON.parse(line); } catch { continue; }
1151
+ if (!hasToolRow) continue;
800
1152
  const content = rec?.message?.content;
801
1153
  if (!Array.isArray(content)) continue;
802
1154
 
803
1155
  for (const block of content) {
804
- if (block?.type === 'tool_use' && block?.name === DISPATCH_TOOL && typeof block?.id === 'string') {
805
- const desc = typeof block?.input?.description === 'string' ? block.input.description.trim() : '';
806
- if (desc === '') continue;
807
- const ids = dispatched.get(desc) ?? [];
808
- ids.push(block.id);
809
- dispatched.set(desc, ids);
1156
+ if (block?.type === 'tool_use' && typeof block?.id === 'string') {
1157
+ if (block.name === DISPATCH_TOOL) {
1158
+ if (!results.has(block.id)) { // first occurrence only — see D4 above
1159
+ results.set(block.id, []);
1160
+ dispatchPos.set(block.id, pos);
1161
+ const desc = typeof block?.input?.description === 'string' ? block.input.description.trim() : '';
1162
+ if (desc !== '') pushTo(idsByDesc, desc, block.id);
1163
+ }
1164
+ } else if (block.name === SEND_MESSAGE_TOOL && typeof block?.input?.to === 'string' && block.input.to !== ''
1165
+ && !sendIds.has(block.id)) {
1166
+ sendIds.add(block.id);
1167
+ pushTo(sendsByTo, block.input.to, { id: block.id, pos });
1168
+ }
810
1169
  continue;
811
1170
  }
812
1171
  if (block?.type === 'tool_result' && typeof block?.tool_use_id === 'string') {
813
- // The launch ACK is not a completion — see § Liveness (b).
814
- if (!resultTextOf(block).includes(ASYNC_LAUNCH_ACK)) finishedIds.add(block.tool_use_id);
1172
+ const own = results.get(block.tool_use_id);
1173
+ if (own !== undefined) {
1174
+ const text = resultTextOf(block);
1175
+ const form = dispatchResultForm(block, text);
1176
+ // An error text is not a harness trailer — learn T(U) from ACK/completion only.
1177
+ const agentId = form === 'ack' || (form === 'done' && block.is_error !== true) ? lastAgentIdIn(block) : '';
1178
+ own.push({ pos, form, agentId });
1179
+ } else if (sendIds.has(block.tool_use_id)
1180
+ && (block.is_error === true || resultTextOf(block).includes(SEND_NO_EFFECT))) {
1181
+ sendNoEffect.add(block.tool_use_id);
1182
+ }
1183
+ }
1184
+ }
1185
+ }
1186
+
1187
+ for (const [useId, pos] of dispatchPos) {
1188
+ const own = results.get(useId) ?? [];
1189
+ const taskId = own.find((r) => r.agentId !== '')?.agentId
1190
+ ?? (headsByUse.get(useId) ?? []).find((h) => h.taskId !== '')?.taskId
1191
+ ?? '';
1192
+
1193
+ let lastPos = pos;
1194
+ let lastIsSend = false;
1195
+ const closers = [useId];
1196
+ if (taskId !== '') {
1197
+ for (const send of sendsByTo.get(taskId) ?? []) {
1198
+ if (sendNoEffect.has(send.id)) continue;
1199
+ closers.push(send.id);
1200
+ if (send.pos > lastPos) { lastPos = send.pos; lastIsSend = true; }
815
1201
  }
816
1202
  }
1203
+
1204
+ const closedAfter = closers.some((id) => (headsByUse.get(id) ?? []).some((h) => h.terminal && h.pos > lastPos))
1205
+ || (taskId !== '' && (terminalByTask.get(taskId) ?? []).some((p) => p > lastPos))
1206
+ || (!lastIsSend && own.some((r) => r.form === 'done' && r.pos > lastPos));
1207
+
1208
+ let state;
1209
+ if (closedAfter) state = LIVE_FINISHED;
1210
+ else if (lastIsSend || own.length === 0 || own.some((r) => r.form === 'ack')) state = LIVE_RUNNING;
1211
+ else state = LIVE_UNKNOWN;
1212
+ states.set(useId, state);
817
1213
  }
1214
+ return { states, idsByDesc };
1215
+ }
818
1216
 
819
- for (const [desc, ids] of dispatched) out.set(desc, ids.every((id) => finishedIds.has(id)));
1217
+ /**
1218
+ * Description-level verdict for a ledger entry WITHOUT an exact id: `false` if
1219
+ * any of the description's dispatches is running, `true` only if every one is
1220
+ * finished, `undefined` (no evidence → TTL) otherwise. `excludeUseId` drops the
1221
+ * CURRENT dispatch, whose `tool_use` may already sit in the transcript at
1222
+ * PreToolUse time and would make a finished same-named predecessor look alive.
1223
+ *
1224
+ * @param {{states: Map<string, string>, idsByDesc: Map<string, string[]>}} index
1225
+ * @param {string} desc
1226
+ * @param {string} excludeUseId
1227
+ * @returns {boolean|undefined}
1228
+ */
1229
+ function descFinished(index, desc, excludeUseId) {
1230
+ let seen = false;
1231
+ let unknown = false;
1232
+ for (const useId of index.idsByDesc.get(desc) ?? []) {
1233
+ if (useId === excludeUseId) continue;
1234
+ seen = true;
1235
+ const state = index.states.get(useId);
1236
+ if (state === LIVE_RUNNING) return false;
1237
+ if (state !== LIVE_FINISHED) unknown = true;
1238
+ }
1239
+ return seen && !unknown ? true : undefined;
1240
+ }
1241
+
1242
+ /**
1243
+ * Index a session transcript by agent DESCRIPTION → completion state — the
1244
+ * description view of {@link buildLivenessIndex}.
1245
+ *
1246
+ * A description dispatched N times counts as finished only when EVERY one of its
1247
+ * tool_use ids is finished, and as running as soon as one is. Conservative on
1248
+ * purpose: one outstanding run of the same agent keeps the deny alive. A
1249
+ * description with no running run but an `unknown` one is absent (no evidence).
1250
+ *
1251
+ * @param {string} raw — the transcript's JSONL text
1252
+ * @returns {Map<string, boolean>} description → finished? (absent = no evidence)
1253
+ */
1254
+ export function buildTranscriptIndex(raw) {
1255
+ const index = buildLivenessIndex(raw);
1256
+ const out = new Map();
1257
+ for (const desc of index.idsByDesc.keys()) {
1258
+ const finished = descFinished(index, desc, '');
1259
+ if (finished !== undefined) out.set(desc, finished);
1260
+ }
820
1261
  return out;
821
1262
  }
822
1263
 
@@ -843,10 +1284,16 @@ function resultTextOf(block) {
843
1284
  * been found. The no-collision path — the overwhelming majority — never touches
844
1285
  * the file (§ Liveness, cost containment).
845
1286
  *
846
- * Resolution order per ledger entry:
847
- * 1. transcript evidence for its description → definitive;
848
- * 2. no evidence → the entry's own age against `IN_FLIGHT_TTL_MS`;
849
- * 3. no usable timestamp either → NOT finished (matrix row 13 — keeps the
1287
+ * Resolution order per ledger entry (#1480 A):
1288
+ * 1. an entry WITH `useId` → that exact dispatch's state: finished / running
1289
+ * are definitive, `unknown` or no record is no evidence;
1290
+ * 2. an entry WITHOUT `useId` (legacy, or a harness sending no `tool_use_id`)
1291
+ * → its non-empty description via {@link descFinished}, the current
1292
+ * dispatch (`selfUseId`) excluded. Never the ledger `id` — a
1293
+ * `desc (type)` string or a bare subagent type, which matched unrelated
1294
+ * dispatches (B3);
1295
+ * 3. no evidence → the entry's own age against `IN_FLIGHT_TTL_MS`;
1296
+ * 4. no usable timestamp either → NOT finished (matrix row 13 — keeps the
850
1297
  * deny biting rather than inventing a completion).
851
1298
  *
852
1299
  * @param {object} params
@@ -854,9 +1301,13 @@ function resultTextOf(block) {
854
1301
  * @param {number} [params.now]
855
1302
  * @param {number} [params.ttlMs]
856
1303
  * @param {(p: string, enc: string) => string} [params.readFn]
857
- * @returns {(entry: {id: string, desc?: string, at?: string}) => boolean}
1304
+ * @param {string} [params.selfUseId] the CURRENT dispatch's `tool_use_id`
1305
+ * @returns {(entry: {id: string, desc?: string, at?: string, useId?: string}) => boolean}
858
1306
  */
859
- export function makeFinishedProbe({ transcriptPath, now = Date.now(), ttlMs = IN_FLIGHT_TTL_MS, readFn = readFileSync } = {}) {
1307
+ export function makeFinishedProbe({
1308
+ transcriptPath, now = Date.now(), ttlMs = IN_FLIGHT_TTL_MS, readFn = readFileSync, selfUseId,
1309
+ } = {}) {
1310
+ const excludeUseId = exactUseId(selfUseId);
860
1311
  let index; // undefined = not loaded yet, null = unavailable
861
1312
  const load = () => {
862
1313
  if (index !== undefined) return index;
@@ -864,7 +1315,7 @@ export function makeFinishedProbe({ transcriptPath, now = Date.now(), ttlMs = IN
864
1315
  try {
865
1316
  if (typeof transcriptPath === 'string' && transcriptPath.length > 0) {
866
1317
  if (statSync(transcriptPath).size <= MAX_TRANSCRIPT_BYTES) {
867
- index = buildTranscriptIndex(readFn(transcriptPath, 'utf8'));
1318
+ index = buildLivenessIndex(readFn(transcriptPath, 'utf8'));
868
1319
  }
869
1320
  }
870
1321
  } catch {
@@ -876,10 +1327,17 @@ export function makeFinishedProbe({ transcriptPath, now = Date.now(), ttlMs = IN
876
1327
  return (entry) => {
877
1328
  try {
878
1329
  const idx = load();
879
- const desc = typeof entry?.desc === 'string' && entry.desc !== '' ? entry.desc : entry?.id;
880
- if (idx !== null && typeof desc === 'string') {
881
- const finished = idx.get(desc);
882
- if (finished !== undefined) return finished;
1330
+ if (idx !== null) {
1331
+ const useId = exactUseId(entry?.useId);
1332
+ if (useId !== '') {
1333
+ const state = idx.states.get(useId);
1334
+ if (state === LIVE_FINISHED) return true;
1335
+ if (state === LIVE_RUNNING) return false;
1336
+ } else {
1337
+ const desc = typeof entry?.desc === 'string' ? entry.desc.trim() : '';
1338
+ const finished = desc === '' ? undefined : descFinished(idx, desc, excludeUseId);
1339
+ if (finished !== undefined) return finished;
1340
+ }
883
1341
  }
884
1342
  const at = Date.parse(entry?.at ?? '');
885
1343
  if (Number.isFinite(at)) return now - at > ttlMs;
@@ -981,6 +1439,163 @@ export function listTrackedFiles(cwd) {
981
1439
  }
982
1440
  }
983
1441
 
1442
+ // ---------------------------------------------------------------------------
1443
+ // Stale worktree base (#1413) — a WARNING, never a decision
1444
+ //
1445
+ // `isolation: "worktree"` makes the HARNESS create `<repo>/.claude/worktrees/
1446
+ // agent-<hex>`; no code in this repo creates it and the `Agent` payload carries
1447
+ // no base-ref field (the 147-dispatch key census in the module docblock). The
1448
+ // base is the SESSION-START commit, and it does not follow a mid-session commit:
1449
+ // measured 2026-09-19 (session s18), worktrees created 25 minutes AFTER commit
1450
+ // `240efda6` still stood on its parent `8f15f77b`. A fix agent then silently
1451
+ // edits the OLD code, and its test run is structurally red on top — `validate-
1452
+ // plugin` is vitest's globalSetup and `check-guard-requires-parity.mjs` compares
1453
+ // against `git show HEAD:` inside the stale worktree.
1454
+ //
1455
+ // We have no lever on the base, so the only available fix is to be LOUD. This
1456
+ // never touches the verdict: the two functions below are TOTAL (every failure
1457
+ // resolves to silence), and the caller ignores their result for the decision.
1458
+ // ---------------------------------------------------------------------------
1459
+
1460
+ /**
1461
+ * Facts for the worktree-base check, as a DISCRIMINATED record with three
1462
+ * outcomes rather than two (#1424):
1463
+ *
1464
+ * - `{stale: true|false, head, session_start_ref, subagent_type?}` — measured.
1465
+ * - `{stale: null, skipped: <reason>}` — the dispatch WAS on the `worktree`
1466
+ * branch, but the question could not be answered honestly. Still emitted,
1467
+ * because HR-105 needs the denominator: while every such case returned bare
1468
+ * `null` and the caller emitted nothing, "the check never had anything to
1469
+ * say" and "the check was silently disarmed" both read as zero records —
1470
+ * precisely the shape this event exists to close.
1471
+ * - `null` — `isolation !== 'worktree'`. SILENT by construction, and the one
1472
+ * outcome that must stay so: `isolation` appeared in 14 of 147 measured
1473
+ * dispatches, so recording the other ~90% would put the denominator on the
1474
+ * whole hot dispatch path (HR-101).
1475
+ *
1476
+ * IDENTITY GATE (`.claude/rules/identity-and-locks.md`): `session-start-ref`
1477
+ * lives in STATE.md, a SHARED working-copy artefact that routinely belongs to a
1478
+ * peer session. It is read only when STATE.md's own `session-id` equals this
1479
+ * process's raw id (hook payload `session_id`, else `CLAUDE_CODE_SESSION_ID`) —
1480
+ * a process-local witness REPLACES the shared one, never unions with it. On
1481
+ * mismatch, absence or any read failure this reports `stale: null`: a confident
1482
+ * warning about somebody else's session is worse than no warning at all. The
1483
+ * test runs PER CANDIDATE (`continue`, not `break`) — see the loop below.
1484
+ *
1485
+ * `parseStateMd` is imported DYNAMICALLY and only on the rare `worktree` branch
1486
+ * (14 of 147 measured dispatches carried `isolation` at all): it is a leaf module
1487
+ * with zero imports and already inside the committed hook-import closure, so
1488
+ * reuse costs no new edge, and binding it late keeps a load failure catchable
1489
+ * instead of disarming the guard at ESM link time (§ #993 late-bound deps).
1490
+ *
1491
+ * @param {{tool_input?: unknown}} input — the raw hook payload
1492
+ * @param {string} projectDir
1493
+ * @param {string} sessionId — `input.session_id`, or `'no-session'`
1494
+ * @returns {Promise<{head: string, session_start_ref: string, stale: boolean,
1495
+ * subagent_type?: string}|{stale: null, skipped: string}|null>}
1496
+ */
1497
+ async function worktreeBaseFacts(input, projectDir, sessionId) {
1498
+ // The applicability gate sits OUTSIDE the try on purpose: everything below it
1499
+ // resolves to a `skipped` RECORD, so a throw here must not be able to mint one
1500
+ // for a dispatch that was never on the `worktree` branch at all.
1501
+ const toolInput = input?.tool_input;
1502
+ if (toolInput === null || typeof toolInput !== 'object') return null;
1503
+ if (toolInput.isolation !== 'worktree') return null;
1504
+
1505
+ try {
1506
+ const ownId = sessionId && sessionId !== 'no-session'
1507
+ ? sessionId
1508
+ : (process.env.CLAUDE_CODE_SESSION_ID || '');
1509
+ if (!ownId) return { stale: null, skipped: 'no-own-id' };
1510
+
1511
+ const { parseStateMd } = await import(
1512
+ pathToFileURL(path.join(PLUGIN_ROOT, 'scripts', 'lib', 'state-md', 'yaml-parser.mjs')).href
1513
+ );
1514
+
1515
+ // #1424: the identity test lives INSIDE the loop. Breaking at the first
1516
+ // PARSEABLE STATE.md and testing `session-id` afterwards meant a single
1517
+ // left-over `.pi/STATE.md` — `.pi` sorts before `.claude` in
1518
+ // STATE_DIR_CANDIDATES — disarmed the check for a session whose own
1519
+ // `.claude/STATE.md` sat right there, readable and matching. `sawStateMd`
1520
+ // keeps the two silences apart afterwards: "nothing to read" is a different
1521
+ // fact from "read, and none of it was mine".
1522
+ let frontmatter = null;
1523
+ let sawStateMd = false;
1524
+ for (const dir of STATE_DIR_CANDIDATES) {
1525
+ try {
1526
+ const parsed = parseStateMd(readFileSync(path.join(projectDir, dir, 'STATE.md'), 'utf8'));
1527
+ if (!parsed?.frontmatter) continue;
1528
+ sawStateMd = true;
1529
+ if (parsed.frontmatter['session-id'] !== ownId) continue;
1530
+ frontmatter = parsed.frontmatter;
1531
+ break;
1532
+ } catch { /* try the next state dir */ }
1533
+ }
1534
+ if (frontmatter === null) {
1535
+ return { stale: null, skipped: sawStateMd ? 'identity-mismatch' : 'no-state-md' };
1536
+ }
1537
+
1538
+ const startRef = typeof frontmatter['session-start-ref'] === 'string'
1539
+ ? frontmatter['session-start-ref'].trim()
1540
+ : '';
1541
+ if (startRef === '') return { stale: null, skipped: 'no-start-ref' };
1542
+
1543
+ let head = '';
1544
+ try {
1545
+ head = execFileSync('git', ['rev-parse', 'HEAD'], {
1546
+ cwd: projectDir,
1547
+ encoding: 'utf8',
1548
+ stdio: ['ignore', 'pipe', 'ignore'],
1549
+ }).trim();
1550
+ } catch {
1551
+ return { stale: null, skipped: 'git-error' };
1552
+ }
1553
+ if (head === '') return { stale: null, skipped: 'git-error' };
1554
+
1555
+ const facts = { head, session_start_ref: startRef, stale: head !== startRef };
1556
+ if (typeof toolInput.subagent_type === 'string' && toolInput.subagent_type !== '') {
1557
+ facts.subagent_type = toolInput.subagent_type;
1558
+ }
1559
+ return facts;
1560
+ } catch {
1561
+ // Total by construction: no parser, an unreadable state dir, anything else.
1562
+ // Still a RECORD rather than silence — the dispatch WAS on the `worktree`
1563
+ // branch, and a broken probe that emits nothing is indistinguishable from a
1564
+ // probe that had nothing to report (#1424 / HR-105).
1565
+ return { stale: null, skipped: 'probe-error' };
1566
+ }
1567
+ }
1568
+
1569
+ /**
1570
+ * The operator-facing warning. It names the ACTION, not just the condition —
1571
+ * a warning the coordinator cannot act on is noise (HR-106).
1572
+ *
1573
+ * NO `⚠ ` PREFIX HERE: this text is handed to `emitWarn`, which prefixes it on
1574
+ * both channels it writes (stderr and the `systemMessage` payload). Prefixing
1575
+ * here too produced `⚠ ⚠ …`.
1576
+ *
1577
+ * @param {{head: string, session_start_ref: string}} facts
1578
+ * @returns {string}
1579
+ */
1580
+ function staleWorktreeWarning(facts) {
1581
+ const head = facts.head.slice(0, 12);
1582
+ const base = facts.session_start_ref.slice(0, 12);
1583
+ return [
1584
+ `${HOOK_NAME}: STALE WORKTREE BASE (#1413) — this dispatch uses `
1585
+ + `isolation: "worktree", but HEAD (${head}) has moved past this session's `
1586
+ + `session-start-ref (${base}).`,
1587
+ ' The harness bases a new agent worktree on the SESSION-START commit and offers',
1588
+ ' no base-ref field, so the agent will silently get the OLDER code — and its test',
1589
+ ' run is structurally red on top (check-guard-requires-parity compares against',
1590
+ ' `git show HEAD:`, and validate-plugin is vitest\'s globalSetup).',
1591
+ ' DO ONE OF: dispatch this wave IN-PLACE (omit `isolation`) — or verify the base',
1592
+ ' before trusting the agent\'s result:',
1593
+ ' git worktree list --porcelain | awk -v h="$(git rev-parse HEAD)" '
1594
+ + '\'/^worktree /{w=$2} /^HEAD /{if (w ~ /\\.claude\\/worktrees\\/agent-/ && $2 != h) '
1595
+ + 'print "STALE " substr($2,1,12) " " w}\'',
1596
+ ].join('\n');
1597
+ }
1598
+
984
1599
  /**
985
1600
  * Clip a path for the deny reason without losing the discriminating tail.
986
1601
  *
@@ -1198,7 +1813,10 @@ export function decide({ input, ledger, ledgerCorrupt, waveKey, knownFiles, coll
1198
1813
  }
1199
1814
 
1200
1815
  const desc = agentDescOf(toolInput);
1201
- const self = { id, desc, files, at };
1816
+ // #1480 A: the exact dispatch id travels with the entry, so liveness is never
1817
+ // guessed from a description when the harness names the dispatch itself.
1818
+ const useId = exactUseId(input?.tool_use_id);
1819
+ const self = useId === '' ? { id, desc, files, at } : { id, desc, files, at, useId };
1202
1820
 
1203
1821
  // Row 7: ledger existed but was unparseable. Terminal warn — decided here and
1204
1822
  // returned, never emitted mid-flow. SELF-HEALING since the review: the verdict
@@ -1219,13 +1837,51 @@ export function decide({ input, ledger, ledgerCorrupt, waveKey, knownFiles, coll
1219
1837
  ? ledger.agents.filter((a) => a !== null && typeof a === 'object' && typeof a.id === 'string')
1220
1838
  : [];
1221
1839
 
1222
- // Row 10: same agent re-dispatched (a retry after a failed agent is legitimate).
1223
- // Replace its record instead of letting it collide with its own earlier self.
1224
- const others = prior.filter((a) => a.id !== id);
1840
+ // Row 10 (#1480 B4): a predecessor with the SAME agent id is no longer dropped
1841
+ // before the check. Dropping it unconditionally erased the claim of a still
1842
+ // RUNNING agent: [a,b] running, re-dispatched as [b,c], and a third agent on
1843
+ // `a` was allowed while the first run still wrote it. Now:
1844
+ // - the same `tool_use_id` → the same dispatch evaluated again → replaced;
1845
+ // - FINISHED (probe true) → pruned below, so a retry after a failed agent
1846
+ // stays allowed — a failed agent finishes via its `failed` carrier,
1847
+ // `is_error` or its report;
1848
+ // - both dispatches carry distinct exact ids and the predecessor is NOT
1849
+ // finished → an ordinary collision partner (row 11);
1850
+ // - otherwise no exact statement is possible → it stays as a CLAIM (a third
1851
+ // agent hitting only its scope still collides) but never collides with
1852
+ // this retry.
1853
+ // Named ceiling (BV-004): without exact ids a same-id retry cannot tell "my
1854
+ // earlier run is still going" from "it failed", so the retry is allowed while
1855
+ // the earlier claim is kept — the pre-#1480 behaviour for the retry itself,
1856
+ // and a claim a third agent can hit until the earlier run finishes or its TTL
1857
+ // lapses. Revisit-Trigger: a harness without `tool_use_id` in the PreToolUse
1858
+ // payload dispatches a measurable share of this repo's waves.
1859
+ // A predecessor wholly covered by this retry's scope, neither carrying an
1860
+ // exact id, adds nothing but ledger growth — both are resolved through the
1861
+ // same description and this entry is the younger one — so it is superseded.
1862
+ const superseded = (a) => a.id === id && (
1863
+ (useId !== '' && exactUseId(a.useId) === useId)
1864
+ || (useId === '' && exactUseId(a.useId) === '' && desc !== '' && a.desc === desc
1865
+ && Array.isArray(a.files) && a.files.every((f) => files.includes(f)))
1866
+ );
1867
+ const others = prior.filter((a) => !superseded(a));
1868
+
1869
+ // One comparison key per record, unique by construction: several records may
1870
+ // share an agent id now, and `findScopeCollisions` reports equal ids as
1871
+ // duplicates rather than comparing them. The readable id stays the key where
1872
+ // it is unique, so the deny text and every single-record wave are unchanged.
1873
+ const used = new Set([id]);
1874
+ const partners = others.map((entry) => {
1875
+ let key = entry.id;
1876
+ for (let n = 2; used.has(key); n++) key = `${entry.id} #${n}`;
1877
+ used.add(key);
1878
+ const selfClaim = entry.id === id && !(useId !== '' && exactUseId(entry.useId) !== '');
1879
+ return { key, entry, selfClaim };
1880
+ });
1225
1881
 
1226
1882
  const known = new Set(Array.isArray(knownFiles) ? knownFiles : []);
1227
1883
  const agentScopes = [
1228
- ...others.map((a) => ({ id: a.id, files: promoteDirEntries(a.files, known) })),
1884
+ ...partners.map((p) => ({ id: p.key, files: promoteDirEntries(p.entry.files, known) })),
1229
1885
  { id, files: promoteDirEntries(files, known) },
1230
1886
  ];
1231
1887
 
@@ -1273,28 +1929,35 @@ export function decide({ input, ledger, ledgerCorrupt, waveKey, knownFiles, coll
1273
1929
  // § Liveness — the review's HIGH finding. A collision with an agent that has
1274
1930
  // ALREADY FINISHED is a sequential repair pass, not a race. The probe is called
1275
1931
  // ONLY here, so the transcript is read only on the path that would deny.
1276
- const byId = new Map(others.map((a) => [a.id, a]));
1932
+ const byKey = new Map(partners.map((p) => [p.key, p]));
1277
1933
  const probe = typeof isFinished === 'function' ? isFinished : () => false;
1278
- const finishedIds = new Set();
1934
+ const finishedKeys = new Set();
1279
1935
  const live = [];
1936
+ let selfClaims = 0;
1280
1937
  for (const c of mine) {
1281
- const otherId = c.a === id ? c.b : c.a;
1282
- const entry = byId.get(otherId);
1283
- if (entry !== undefined && probe(entry)) {
1284
- finishedIds.add(otherId);
1938
+ const otherKey = c.a === id ? c.b : c.a;
1939
+ const partner = byKey.get(otherKey);
1940
+ if (partner !== undefined && probe(partner.entry)) {
1941
+ finishedKeys.add(otherKey);
1942
+ continue;
1943
+ }
1944
+ // Row 10: an unfinished same-id claim without exact ids is kept, not collided with.
1945
+ if (partner?.selfClaim === true) {
1946
+ selfClaims++;
1285
1947
  continue;
1286
1948
  }
1287
1949
  live.push(c);
1288
1950
  }
1951
+ const counted = mine.length - selfClaims;
1289
1952
 
1290
1953
  // Row 10a: every colliding prior agent has finished. Allow AND prune their
1291
1954
  // records — leaving them would make the NEXT repair pass pay the transcript
1292
1955
  // scan again for a question already answered.
1293
1956
  if (live.length === 0) {
1294
- const kept = others.filter((a) => !finishedIds.has(a.id));
1957
+ const kept = partners.filter((p) => !finishedKeys.has(p.key)).map((p) => p.entry);
1295
1958
  return {
1296
1959
  action: 'allow',
1297
- telemetry: telemetryFor('allow-finished', mine.length),
1960
+ telemetry: finishedKeys.size > 0 ? telemetryFor('allow-finished', counted) : telemetryFor('allow'),
1298
1961
  ledger: {
1299
1962
  waveKey,
1300
1963
  updated: at,
@@ -1307,13 +1970,15 @@ export function decide({ input, ledger, ledgerCorrupt, waveKey, knownFiles, coll
1307
1970
  // Row 11: the one case this hook exists for.
1308
1971
  const shown = live.slice(0, MAX_REPORTED_COLLISIONS);
1309
1972
  const lines = shown.map((c) => {
1310
- const other = c.a === id ? c.b : c.a;
1973
+ const otherKey = c.a === id ? c.b : c.a;
1974
+ const other = byKey.get(otherKey)?.entry.id ?? otherKey;
1975
+ const earlier = other === id ? ' (an earlier dispatch of the same agent)' : '';
1311
1976
  const ev = Array.isArray(c.evidence) ? c.evidence : [];
1312
1977
  const evShown = ev.slice(0, MAX_EVIDENCE_PER_COLLISION).map(clipPath).join(', ');
1313
1978
  const more = ev.length > MAX_EVIDENCE_PER_COLLISION
1314
1979
  ? ` (+${ev.length - MAX_EVIDENCE_PER_COLLISION} more)`
1315
1980
  : '';
1316
- return ` • "${id}" ↔ "${other}" [${c.kind}]: ${evShown}${more}`;
1981
+ return ` • "${id}" ↔ "${other}"${earlier} [${c.kind}]: ${evShown}${more}`;
1317
1982
  });
1318
1983
  const omitted = live.length > shown.length ? `\n (+${live.length - shown.length} further collisions)` : '';
1319
1984
 
@@ -1332,7 +1997,7 @@ export function decide({ input, ledger, ledgerCorrupt, waveKey, knownFiles, coll
1332
1997
 
1333
1998
  // Deliberately NOT persisting the ledger on deny: the dispatch did not happen,
1334
1999
  // so recording it would make the retry-after-fix look like a duplicate.
1335
- return { action: 'deny', telemetry: telemetryFor('deny', mine.length), reason, suggestion };
2000
+ return { action: 'deny', telemetry: telemetryFor('deny', counted), reason, suggestion };
1336
2001
  }
1337
2002
 
1338
2003
  // ---------------------------------------------------------------------------
@@ -1354,7 +2019,9 @@ async function main() {
1354
2019
 
1355
2020
  const waveKey = waveKeyOf(projectDir, sessionId, readFileSync);
1356
2021
  const knownFiles = listTrackedFiles(projectDir);
1357
- const isFinished = makeFinishedProbe({ transcriptPath: input.transcript_path });
2022
+ // `selfUseId`: this dispatch's own tool_use may already stand in the transcript
2023
+ // and must not make a finished same-named predecessor look alive (#1480 A).
2024
+ const isFinished = makeFinishedProbe({ transcriptPath: input.transcript_path, selfUseId: input.tool_use_id });
1358
2025
  const ledgerPath = path.join(projectDir, LEDGER_REL);
1359
2026
 
1360
2027
  // The read-modify-write CYCLE, run under the ledger lock below. Everything
@@ -1440,8 +2107,59 @@ async function main() {
1440
2107
  } catch { /* observability is best-effort — it never blocks the decision */ }
1441
2108
  }
1442
2109
 
1443
- if (verdict.action === 'deny') return emitDeny(verdict.reason, verdict.suggestion);
1444
- if (verdict.action === 'warn') return emitWarn(verdict.note);
2110
+ // #1413 — the stale-worktree-base warning, also awaited BEFORE the terminal
2111
+ // emit for the same process.exit() reason as the block above. It reads
2112
+ // `verdict` not at all: it can never turn an allow into a deny, and a git or
2113
+ // STATE.md failure resolves to silence rather than to a wrong accusation.
2114
+ //
2115
+ // ROUTED TO THE OPERATOR, not to stderr alone (w3-5): under the exit-0
2116
+ // PreToolUse protocol stderr goes nowhere — `io.mjs:545` calls it the "Debug
2117
+ // channel … Invisible under exit 0" — so the first cut of this check announced
2118
+ // the s18 incident class to a log and to nobody. The visible channel is the
2119
+ // top-level `systemMessage`, i.e. `emitWarn`, which carries NO
2120
+ // `permissionDecision` and therefore still means ALLOW (io.mjs § "which is
2121
+ // precisely what keeps warn non-blocking").
2122
+ //
2123
+ // The note is CARRIED to the single terminal emit below rather than emitted
2124
+ // here, because `emitWarn` calls `process.exit(0)` and never returns — warning
2125
+ // inline would terminate the process before a collision DENY could be emitted,
2126
+ // flipping a block into an allow (§ stdout discipline, the same reason
2127
+ // `decide()` is pure).
2128
+ //
2129
+ // `null` here means "not a worktree dispatch" and stays silent; ANY other
2130
+ // shape is emitted, including the `{stale: null, skipped: …}` non-measurements
2131
+ // (#1424). The warning still fires on `stale === true` alone — a skipped probe
2132
+ // accuses nobody.
2133
+ const worktreeBase = await worktreeBaseFacts(input, projectDir, sessionId);
2134
+ let staleNote = null;
2135
+ if (worktreeBase !== null) {
2136
+ if (worktreeBase.stale === true) staleNote = staleWorktreeWarning(worktreeBase);
2137
+ try {
2138
+ const { emitEvent, sessionAttribution } = await import(
2139
+ pathToFileURL(path.join(PLUGIN_ROOT, 'scripts', 'lib', 'events.mjs')).href
2140
+ );
2141
+ await emitEvent(
2142
+ WORKTREE_BASE_EVENT,
2143
+ { hook: HOOK_NAME, ...worktreeBase, ...sessionAttribution(projectDir) },
2144
+ { repoRoot: projectDir }
2145
+ );
2146
+ } catch { /* observability is best-effort — it never blocks the decision */ }
2147
+ }
2148
+
2149
+ // ONE terminal emit, with the #1413 note folded in where a visible channel is
2150
+ // free. On DENY the dispatch does not happen and `systemMessage` already
2151
+ // carries the ⛔ headline, so the note stays on the debug channel — a stale
2152
+ // base is moot for a dispatch that was just blocked.
2153
+ if (verdict.action === 'deny') {
2154
+ if (staleNote !== null) {
2155
+ try { console.error(`⚠ ${staleNote}`); } catch { /* stderr may be closed */ }
2156
+ }
2157
+ return emitDeny(verdict.reason, verdict.suggestion);
2158
+ }
2159
+ if (verdict.action === 'warn') {
2160
+ return emitWarn(staleNote === null ? verdict.note : `${verdict.note}\n\n${staleNote}`);
2161
+ }
2162
+ if (staleNote !== null) return emitWarn(staleNote);
1445
2163
  return emitAllow();
1446
2164
  }
1447
2165