session-orchestrator 5.3.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 (206) hide show
  1. package/.agents/skills/claude-md-drift-check/SKILL.md +1 -1
  2. package/.agents/skills/navigator/SKILL.md +22 -0
  3. package/.agents/skills/navigator/agents/openai.yaml +5 -0
  4. package/.agents/skills/test-audit/SKILL.md +21 -0
  5. package/.agents/skills/test-audit/agents/openai.yaml +5 -0
  6. package/.claude-plugin/marketplace.json +1 -1
  7. package/.claude-plugin/plugin.json +1 -1
  8. package/.codex-plugin/plugin.json +1 -1
  9. package/.codex-plugin/skills/claude-md-drift-check/SKILL.md +1 -1
  10. package/.codex-plugin/skills/navigator/SKILL.md +22 -0
  11. package/.codex-plugin/skills/navigator/agents/openai.yaml +5 -0
  12. package/.codex-plugin/skills/test-audit/SKILL.md +21 -0
  13. package/.codex-plugin/skills/test-audit/agents/openai.yaml +5 -0
  14. package/.cursor/commands/navigator.md +15 -0
  15. package/.cursor/commands/test-audit.md +14 -0
  16. package/.cursor/rules/060-evolve.mdc +27 -12
  17. package/.cursor/skills/claude-md-drift-check/SKILL.md +1 -1
  18. package/.cursor/skills/navigator/SKILL.md +13 -0
  19. package/.cursor/skills/test-audit/SKILL.md +12 -0
  20. package/.cursor-plugin/plugin.json +1 -1
  21. package/.orchestrator/policy/blocked-commands.json +1 -1
  22. package/AGENTS.md +9 -31
  23. package/CHANGELOG.md +70 -0
  24. package/README.md +14 -10
  25. package/docs/agent-authoring.md +2 -2
  26. package/docs/baseline.md +10 -1
  27. package/docs/ci-setup.md +57 -4
  28. package/docs/components.md +12 -12
  29. package/docs/events-schema.md +11 -8
  30. package/docs/persona-panel.md +5 -4
  31. package/docs/rule-authoring.md +27 -12
  32. package/docs/scope-collision-guard.md +26 -4
  33. package/docs/session-config-reference.md +55 -6
  34. package/docs/session-config-template.md +11 -2
  35. package/hooks/_lib/hook-import-set.json +27 -23
  36. package/hooks/hooks-codex.json +1 -1
  37. package/hooks/hooks-cursor.json +5 -0
  38. package/hooks/hooks-pi.json +5 -0
  39. package/hooks/hooks.json +6 -1
  40. package/hooks/on-session-start.mjs +53 -0
  41. package/hooks/on-stop.mjs +3 -1
  42. package/hooks/post-tool-batch-wave-signal.mjs +3 -1
  43. package/hooks/pre-bash-memory-propose-audit.mjs +52 -3
  44. package/hooks/pre-bash-sessions-ledger-guard.mjs +28 -2
  45. package/hooks/pre-edit-sessions-ledger-guard.mjs +162 -0
  46. package/hooks/pre-task-scope-disjoint.mjs +559 -72
  47. package/hooks/subagent-telemetry.mjs +96 -19
  48. package/package.json +3 -3
  49. package/pi/prompts/navigator.md +12 -0
  50. package/pi/prompts/test-audit.md +12 -0
  51. package/rules/README.md +6 -1
  52. package/rules/_index.md +3 -3
  53. package/rules/always-on/bash-harness-pitfalls.md +7 -1
  54. package/rules/opt-in-domain/prompt-caching.md +5 -1
  55. package/rules/opt-in-stack/backend-data.md +9 -1
  56. package/rules/opt-in-stack/backend.md +7 -1
  57. package/rules/opt-in-stack/frontend.md +6 -1
  58. package/rules/opt-in-stack/security-web.md +7 -1
  59. package/rules/opt-in-stack/swift.md +5 -1
  60. package/scripts/apply-session-learnings.mjs +492 -0
  61. package/scripts/archive-closed-prds.mjs +2 -1
  62. package/scripts/autopilot.mjs +49 -53
  63. package/scripts/backfill-abandoned-sessions.mjs +1 -1
  64. package/scripts/baseline-archetypes.mjs +1 -1
  65. package/scripts/check-package-manager.mjs +62 -5
  66. package/scripts/cursor-install.mjs +1 -4
  67. package/scripts/emit-session.mjs +296 -2
  68. package/scripts/export-hw-learnings.mjs +22 -3
  69. package/scripts/fleet-checkin.mjs +147 -0
  70. package/scripts/lib/agent-frontmatter.mjs +3 -3
  71. package/scripts/lib/auto-dream.mjs +7 -6
  72. package/scripts/lib/autopilot/flags.mjs +6 -1
  73. package/scripts/lib/autopilot/loop.mjs +10 -6
  74. package/scripts/lib/baseline-archetypes.mjs +41 -22
  75. package/scripts/lib/config/host-paths.mjs +79 -0
  76. package/scripts/lib/config/persona-gate-wave.mjs +3 -3
  77. package/scripts/lib/config/reaper.mjs +7 -6
  78. package/scripts/lib/config-schema.mjs +13 -0
  79. package/scripts/lib/config.mjs +71 -10
  80. package/scripts/lib/events.mjs +21 -2
  81. package/scripts/lib/fleet-protocol.mjs +270 -0
  82. package/scripts/lib/gates/gate-baseline.mjs +13 -3
  83. package/scripts/lib/gates/gate-full.mjs +7 -2
  84. package/scripts/lib/gates/gate-helpers.mjs +93 -2
  85. package/scripts/lib/gates/gate-incremental.mjs +23 -22
  86. package/scripts/lib/gates/gate-per-file.mjs +16 -7
  87. package/scripts/lib/instruction-budget-guard.mjs +69 -11
  88. package/scripts/lib/learnings/expiry-sweep.mjs +34 -3
  89. package/scripts/lib/learnings/io.mjs +154 -26
  90. package/scripts/lib/learnings/schema.mjs +163 -15
  91. package/scripts/lib/learnings/sizing-subject.mjs +2 -2
  92. package/scripts/lib/memory-proposals/schema.mjs +11 -5
  93. package/scripts/lib/named-baseline-resolver.mjs +53 -28
  94. package/scripts/lib/named-vault-resolver.mjs +104 -5
  95. package/scripts/lib/orphan-reaper/defaults.mjs +41 -0
  96. package/scripts/lib/orphan-reaper/ps-snapshot.mjs +181 -0
  97. package/scripts/lib/orphan-reaper/reaper-audit.mjs +291 -0
  98. package/scripts/lib/orphan-reaper/reaper-decide.mjs +395 -0
  99. package/scripts/lib/orphan-reaper/scan-throttle.mjs +92 -0
  100. package/scripts/lib/orphan-reaper/scan.mjs +506 -0
  101. package/scripts/lib/orphan-reaper.mjs +43 -1315
  102. package/scripts/lib/owner-yaml.mjs +104 -6
  103. package/scripts/lib/persona-panel/catalog-loader.mjs +1 -1
  104. package/scripts/lib/process-group.mjs +62 -11
  105. package/scripts/lib/project-hygiene.mjs +21 -1
  106. package/scripts/lib/quality-gate.mjs +45 -5
  107. package/scripts/lib/resource-probe.mjs +1 -1
  108. package/scripts/lib/rule-loader.mjs +100 -62
  109. package/scripts/lib/rules-sync.mjs +27 -12
  110. package/scripts/lib/scope-baseline.mjs +3 -4
  111. package/scripts/lib/session-record-repair.mjs +183 -26
  112. package/scripts/lib/session-registry.mjs +27 -2
  113. package/scripts/lib/session-schema/constants.mjs +30 -4
  114. package/scripts/lib/session-schema/validator.mjs +28 -1
  115. package/scripts/lib/session-token-rollup.mjs +245 -52
  116. package/scripts/lib/sessions-canonical.mjs +100 -9
  117. package/scripts/lib/sh-quote.mjs +29 -0
  118. package/scripts/lib/subagents-schema.mjs +30 -3
  119. package/scripts/lib/telemetry/pricing.mjs +118 -71
  120. package/scripts/lib/tests-src-ratio.mjs +140 -23
  121. package/scripts/lib/tmux-layout/layouts.mjs +1 -11
  122. package/scripts/lib/tmux-layout/tmux-shell.mjs +2 -11
  123. package/scripts/lib/ux-grill/reconcile.mjs +1 -1
  124. package/scripts/lib/validate/check-agents.mjs +1 -1
  125. package/scripts/lib/validate/check-hooks-symmetry.mjs +5 -0
  126. package/scripts/lib/validate/check-learnings-shell-writes.mjs +214 -0
  127. package/scripts/lib/validate/check-test-value-bans.mjs +87 -14
  128. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  129. package/scripts/lib/validate/check-unwired-features.mjs +1 -1
  130. package/scripts/lib/validate-vendored-rules.mjs +206 -81
  131. package/scripts/lib/vault-mirror/telemetry.mjs +8 -1
  132. package/scripts/lib/vault-status/board-writer.mjs +48 -11
  133. package/scripts/lib/vault-status/narrative-mirror.mjs +40 -9
  134. package/scripts/lib/wave-executor/dispatch-common.mjs +10 -18
  135. package/scripts/lib/wave-executor/foreign-dispatch.mjs +1 -1
  136. package/scripts/lib/wave-executor/remote-dispatch.mjs +1 -1
  137. package/scripts/lib/wave-resource-gate.mjs +34 -8
  138. package/scripts/memory-propose.mjs +18 -5
  139. package/scripts/release-session-lock.mjs +2 -1
  140. package/scripts/release.mjs +10 -3
  141. package/scripts/repair-invalid-sessions.mjs +37 -4
  142. package/scripts/run-migrate-v2-cross-repo.mjs +158 -30
  143. package/scripts/run-quality-gate.mjs +323 -302
  144. package/scripts/site-numbers.mjs +163 -5
  145. package/scripts/sweep-expired-learnings.mjs +56 -5
  146. package/scripts/sweep-expired-rules.mjs +2 -1
  147. package/scripts/validate-plugin.mjs +7 -0
  148. package/scripts/vault-integration-watcher.mjs +2 -1
  149. package/scripts/vault-mirror.mjs +72 -46
  150. package/scripts/wave-scope-binding.mjs +2 -1
  151. package/skills/_shared/config-reading.md +3 -3
  152. package/skills/_shared/fleet-protocol.md +131 -0
  153. package/skills/_shared/state-ownership.md +5 -0
  154. package/skills/autopilot/SKILL.md +25 -11
  155. package/skills/bootstrap/SKILL.md +1 -1
  156. package/skills/bootstrap/_shared-template.md +1 -1
  157. package/skills/bootstrap/fast-template.md +1 -1
  158. package/skills/bootstrap/private-contract.md +2 -1
  159. package/skills/bootstrap/public-fallback.md +6 -3
  160. package/skills/bootstrap/references/bootstrap-retroactive-flow.md +2 -1
  161. package/skills/claude-md-drift-check/SKILL.md +10 -10
  162. package/skills/claude-md-drift-check/checker.mjs +26 -18
  163. package/skills/evolve/SKILL.md +2 -2
  164. package/skills/evolve/references/evolve-analyze-mode.md +10 -6
  165. package/skills/memory-cleanup/SKILL.md +1 -1
  166. package/skills/navigator/SKILL.md +193 -0
  167. package/skills/navigator/references/account-check.sh +87 -0
  168. package/skills/navigator/references/fleet-decisions.mjs +231 -0
  169. package/skills/navigator/references/slot-check.sh +114 -0
  170. package/skills/persona-panel/SKILL.md +2 -2
  171. package/skills/persona-panel/persona-format.md +2 -2
  172. package/skills/persona-panel/presets/designer-lens.md +1 -1
  173. package/skills/persona-panel/presets/engineer-lens.md +1 -1
  174. package/skills/persona-panel/presets/pm-lens.md +1 -1
  175. package/skills/plan/mode-retro.md +19 -4
  176. package/skills/quality-gates/SKILL.md +6 -4
  177. package/skills/remote-offload/SKILL.md +4 -2
  178. package/skills/session-end/learning-patterns.md +22 -12
  179. package/skills/session-end/metrics-collection.md +5 -6
  180. package/skills/session-end/references/phase-3-documentation-updates.md +2 -2
  181. package/skills/session-end/references/phase-4a-worktree-cleanup.md +2 -0
  182. package/skills/session-end/references/session-summary-template.md +1 -1
  183. package/skills/session-end/session-metrics-write.md +22 -60
  184. package/skills/session-plan/SKILL.md +0 -3
  185. package/skills/session-start/SKILL.md +6 -0
  186. package/skills/session-start/references/operations-contract.md +4 -0
  187. package/skills/session-start/references/phase-1-7-vault-status-board.md +1 -1
  188. package/skills/session-start/references/phase-4-ssot-environment-check.md +2 -2
  189. package/skills/test-audit/SKILL.md +152 -0
  190. package/skills/test-audit/references/campaign.md +111 -0
  191. package/skills/test-audit/references/mutate.sh +50 -0
  192. package/skills/wave-executor/SKILL.md +1 -1
  193. package/skills/wave-executor/references/wave-executor-state-init.md +3 -0
  194. package/skills/wave-executor/references/wave-loop-dispatch.md +3 -1
  195. package/skills/wave-executor/references/wave-loop-review.md +3 -5
  196. package/templates/personas/README.md +5 -5
  197. package/templates/personas/accounting-compliance.v1.md +1 -1
  198. package/templates/personas/accounting-tax-advisor.v1.md +1 -1
  199. package/templates/personas/buyer-p1-cto.v1.md +1 -1
  200. package/templates/personas/buyer-p2-kanzlei.v1.md +1 -1
  201. package/templates/personas/buyer-p3-build.v1.md +1 -1
  202. package/templates/personas/buyer-p4-tech-veto.v1.md +1 -1
  203. package/templates/personas/buyer-p5-solo.v1.md +1 -1
  204. package/templates/personas/buyer-p6-ld.v1.md +1 -1
  205. package/templates/personas/klima-ai-expert.v1.md +1 -1
  206. package/templates/personas/klima-physicist.v1.md +1 -1
@@ -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
@@ -358,9 +376,77 @@ const MAX_TRANSCRIPT_BYTES = 256 * 1024 * 1024;
358
376
  * a completion — see § Liveness (b). Reading it as one would let every real
359
377
  * background-batch collision through, which is the one direction this repair
360
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.
361
385
  */
362
386
  const ASYNC_LAUNCH_ACK = 'Async agent launched successfully';
363
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
+
364
450
  /**
365
451
  * The consequence block spliced VERBATIM into the GUARD INACTIVE banner (#993),
366
452
  * naming the enforcement this hook's outage stops applying.
@@ -750,8 +836,8 @@ export function extractScopeFromPrompt(prompt) {
750
836
 
751
837
  /**
752
838
  * The dispatch's human description — the field the liveness probe matches
753
- * against the transcript's `tool_use` blocks (present in 147/147 measured
754
- * payloads).
839
+ * against the transcript's `tool_use` blocks when an entry carries no exact
840
+ * `useId` (present in 147/147 measured payloads).
755
841
  *
756
842
  * @param {{description?: unknown}} toolInput
757
843
  * @returns {string}
@@ -777,69 +863,401 @@ export function agentIdOf(toolInput) {
777
863
  return 'unnamed-agent';
778
864
  }
779
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
+
780
882
  // ---------------------------------------------------------------------------
781
883
  // Liveness — has an already-recorded agent FINISHED? (§ Liveness)
782
884
  // ---------------------------------------------------------------------------
783
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
+
1009
+ /**
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
+
784
1030
  /**
785
- * Index a session transcript by agent DESCRIPTION → completion state.
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.
786
1035
  *
787
- * Three record shapes are read, all measured in this repo's own transcripts:
788
- * - `tool_use` `{name:'Agent', id, input.description}` — the dispatch.
789
- * - `tool_result` `{tool_use_id, content}` — a completion for the SYNCHRONOUS
790
- * shape, but only when its text is not the {@link ASYNC_LAUNCH_ACK}.
791
- * - a `<task-notification>` record carrying `<tool-use-id>` and
792
- * `<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`.
793
1051
  *
794
- * A description dispatched N times counts as finished only when EVERY one of its
795
- * tool_use ids is finished. Conservative on purpose: one outstanding run of the
796
- * 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.
797
1110
  *
798
1111
  * Pure and total — a malformed line is skipped, never thrown on.
799
1112
  *
800
1113
  * @param {string} raw — the transcript's JSONL text
801
- * @returns {Map<string, boolean>} description → finished?
1114
+ * @returns {{states: Map<string, 'finished'|'running'|'unknown'>,
1115
+ * idsByDesc: Map<string, string[]>}}
802
1116
  */
803
- export function buildTranscriptIndex(raw) {
804
- const out = new Map();
805
- if (typeof raw !== 'string' || raw.length === 0) return out;
806
-
807
- const dispatched = new Map(); // description → tool_use ids
808
- const finishedIds = new Set();
809
-
810
- 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];
811
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; }
812
1141
 
813
- // ASYNC completion — matched on the RAW line: the tags are plain text inside
814
- // a JSON string, so no parse is needed and the `queue-operation` carrier
815
- // record (which has no `message.content`) is covered too.
816
- if (line.includes('task-notification') && line.includes('<status>completed</status>')) {
817
- 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
+ }
818
1149
  }
819
1150
 
820
- if (!line.includes('"tool_use"') && !line.includes('tool_use_id')) continue;
821
- let rec;
822
- try { rec = JSON.parse(line); } catch { continue; }
1151
+ if (!hasToolRow) continue;
823
1152
  const content = rec?.message?.content;
824
1153
  if (!Array.isArray(content)) continue;
825
1154
 
826
1155
  for (const block of content) {
827
- if (block?.type === 'tool_use' && block?.name === DISPATCH_TOOL && typeof block?.id === 'string') {
828
- const desc = typeof block?.input?.description === 'string' ? block.input.description.trim() : '';
829
- if (desc === '') continue;
830
- const ids = dispatched.get(desc) ?? [];
831
- ids.push(block.id);
832
- 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
+ }
833
1169
  continue;
834
1170
  }
835
1171
  if (block?.type === 'tool_result' && typeof block?.tool_use_id === 'string') {
836
- // The launch ACK is not a completion — see § Liveness (b).
837
- 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
+ }
838
1183
  }
839
1184
  }
840
1185
  }
841
1186
 
842
- for (const [desc, ids] of dispatched) out.set(desc, ids.every((id) => finishedIds.has(id)));
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; }
1201
+ }
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);
1213
+ }
1214
+ return { states, idsByDesc };
1215
+ }
1216
+
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
+ }
843
1261
  return out;
844
1262
  }
845
1263
 
@@ -866,10 +1284,16 @@ function resultTextOf(block) {
866
1284
  * been found. The no-collision path — the overwhelming majority — never touches
867
1285
  * the file (§ Liveness, cost containment).
868
1286
  *
869
- * Resolution order per ledger entry:
870
- * 1. transcript evidence for its description → definitive;
871
- * 2. no evidence → the entry's own age against `IN_FLIGHT_TTL_MS`;
872
- * 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
873
1297
  * deny biting rather than inventing a completion).
874
1298
  *
875
1299
  * @param {object} params
@@ -877,9 +1301,13 @@ function resultTextOf(block) {
877
1301
  * @param {number} [params.now]
878
1302
  * @param {number} [params.ttlMs]
879
1303
  * @param {(p: string, enc: string) => string} [params.readFn]
880
- * @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}
881
1306
  */
882
- 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);
883
1311
  let index; // undefined = not loaded yet, null = unavailable
884
1312
  const load = () => {
885
1313
  if (index !== undefined) return index;
@@ -887,7 +1315,7 @@ export function makeFinishedProbe({ transcriptPath, now = Date.now(), ttlMs = IN
887
1315
  try {
888
1316
  if (typeof transcriptPath === 'string' && transcriptPath.length > 0) {
889
1317
  if (statSync(transcriptPath).size <= MAX_TRANSCRIPT_BYTES) {
890
- index = buildTranscriptIndex(readFn(transcriptPath, 'utf8'));
1318
+ index = buildLivenessIndex(readFn(transcriptPath, 'utf8'));
891
1319
  }
892
1320
  }
893
1321
  } catch {
@@ -899,10 +1327,17 @@ export function makeFinishedProbe({ transcriptPath, now = Date.now(), ttlMs = IN
899
1327
  return (entry) => {
900
1328
  try {
901
1329
  const idx = load();
902
- const desc = typeof entry?.desc === 'string' && entry.desc !== '' ? entry.desc : entry?.id;
903
- if (idx !== null && typeof desc === 'string') {
904
- const finished = idx.get(desc);
905
- 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
+ }
906
1341
  }
907
1342
  const at = Date.parse(entry?.at ?? '');
908
1343
  if (Number.isFinite(at)) return now - at > ttlMs;
@@ -1378,7 +1813,10 @@ export function decide({ input, ledger, ledgerCorrupt, waveKey, knownFiles, coll
1378
1813
  }
1379
1814
 
1380
1815
  const desc = agentDescOf(toolInput);
1381
- 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 };
1382
1820
 
1383
1821
  // Row 7: ledger existed but was unparseable. Terminal warn — decided here and
1384
1822
  // returned, never emitted mid-flow. SELF-HEALING since the review: the verdict
@@ -1399,13 +1837,51 @@ export function decide({ input, ledger, ledgerCorrupt, waveKey, knownFiles, coll
1399
1837
  ? ledger.agents.filter((a) => a !== null && typeof a === 'object' && typeof a.id === 'string')
1400
1838
  : [];
1401
1839
 
1402
- // Row 10: same agent re-dispatched (a retry after a failed agent is legitimate).
1403
- // Replace its record instead of letting it collide with its own earlier self.
1404
- 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
+ });
1405
1881
 
1406
1882
  const known = new Set(Array.isArray(knownFiles) ? knownFiles : []);
1407
1883
  const agentScopes = [
1408
- ...others.map((a) => ({ id: a.id, files: promoteDirEntries(a.files, known) })),
1884
+ ...partners.map((p) => ({ id: p.key, files: promoteDirEntries(p.entry.files, known) })),
1409
1885
  { id, files: promoteDirEntries(files, known) },
1410
1886
  ];
1411
1887
 
@@ -1453,28 +1929,35 @@ export function decide({ input, ledger, ledgerCorrupt, waveKey, knownFiles, coll
1453
1929
  // § Liveness — the review's HIGH finding. A collision with an agent that has
1454
1930
  // ALREADY FINISHED is a sequential repair pass, not a race. The probe is called
1455
1931
  // ONLY here, so the transcript is read only on the path that would deny.
1456
- const byId = new Map(others.map((a) => [a.id, a]));
1932
+ const byKey = new Map(partners.map((p) => [p.key, p]));
1457
1933
  const probe = typeof isFinished === 'function' ? isFinished : () => false;
1458
- const finishedIds = new Set();
1934
+ const finishedKeys = new Set();
1459
1935
  const live = [];
1936
+ let selfClaims = 0;
1460
1937
  for (const c of mine) {
1461
- const otherId = c.a === id ? c.b : c.a;
1462
- const entry = byId.get(otherId);
1463
- if (entry !== undefined && probe(entry)) {
1464
- 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++;
1465
1947
  continue;
1466
1948
  }
1467
1949
  live.push(c);
1468
1950
  }
1951
+ const counted = mine.length - selfClaims;
1469
1952
 
1470
1953
  // Row 10a: every colliding prior agent has finished. Allow AND prune their
1471
1954
  // records — leaving them would make the NEXT repair pass pay the transcript
1472
1955
  // scan again for a question already answered.
1473
1956
  if (live.length === 0) {
1474
- const kept = others.filter((a) => !finishedIds.has(a.id));
1957
+ const kept = partners.filter((p) => !finishedKeys.has(p.key)).map((p) => p.entry);
1475
1958
  return {
1476
1959
  action: 'allow',
1477
- telemetry: telemetryFor('allow-finished', mine.length),
1960
+ telemetry: finishedKeys.size > 0 ? telemetryFor('allow-finished', counted) : telemetryFor('allow'),
1478
1961
  ledger: {
1479
1962
  waveKey,
1480
1963
  updated: at,
@@ -1487,13 +1970,15 @@ export function decide({ input, ledger, ledgerCorrupt, waveKey, knownFiles, coll
1487
1970
  // Row 11: the one case this hook exists for.
1488
1971
  const shown = live.slice(0, MAX_REPORTED_COLLISIONS);
1489
1972
  const lines = shown.map((c) => {
1490
- 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)' : '';
1491
1976
  const ev = Array.isArray(c.evidence) ? c.evidence : [];
1492
1977
  const evShown = ev.slice(0, MAX_EVIDENCE_PER_COLLISION).map(clipPath).join(', ');
1493
1978
  const more = ev.length > MAX_EVIDENCE_PER_COLLISION
1494
1979
  ? ` (+${ev.length - MAX_EVIDENCE_PER_COLLISION} more)`
1495
1980
  : '';
1496
- return ` • "${id}" ↔ "${other}" [${c.kind}]: ${evShown}${more}`;
1981
+ return ` • "${id}" ↔ "${other}"${earlier} [${c.kind}]: ${evShown}${more}`;
1497
1982
  });
1498
1983
  const omitted = live.length > shown.length ? `\n (+${live.length - shown.length} further collisions)` : '';
1499
1984
 
@@ -1512,7 +1997,7 @@ export function decide({ input, ledger, ledgerCorrupt, waveKey, knownFiles, coll
1512
1997
 
1513
1998
  // Deliberately NOT persisting the ledger on deny: the dispatch did not happen,
1514
1999
  // so recording it would make the retry-after-fix look like a duplicate.
1515
- return { action: 'deny', telemetry: telemetryFor('deny', mine.length), reason, suggestion };
2000
+ return { action: 'deny', telemetry: telemetryFor('deny', counted), reason, suggestion };
1516
2001
  }
1517
2002
 
1518
2003
  // ---------------------------------------------------------------------------
@@ -1534,7 +2019,9 @@ async function main() {
1534
2019
 
1535
2020
  const waveKey = waveKeyOf(projectDir, sessionId, readFileSync);
1536
2021
  const knownFiles = listTrackedFiles(projectDir);
1537
- 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 });
1538
2025
  const ledgerPath = path.join(projectDir, LEDGER_REL);
1539
2026
 
1540
2027
  // The read-modify-write CYCLE, run under the ledger lock below. Everything