session-orchestrator 3.20.0 → 3.22.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 (202) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor/rules/000-session-orchestrator.mdc +3 -2
  5. package/.cursor/rules/030-wave-execution.mdc +10 -8
  6. package/.cursor/rules/040-discovery.mdc +6 -6
  7. package/.cursor/rules/050-plan.mdc +8 -8
  8. package/CHANGELOG.md +515 -0
  9. package/README.md +16 -11
  10. package/agents/analyst.md +1 -1
  11. package/agents/architect-reviewer.md +1 -1
  12. package/agents/code-implementer.md +4 -2
  13. package/agents/db-specialist.md +1 -1
  14. package/agents/dialectic-deriver.md +1 -1
  15. package/agents/docs-writer.md +1 -1
  16. package/agents/memory-proposal-collector.md +7 -5
  17. package/agents/qa-strategist.md +1 -1
  18. package/agents/security-reviewer.md +1 -1
  19. package/agents/session-reviewer.md +42 -1
  20. package/agents/skill-applied-judge.md +1 -1
  21. package/agents/test-writer.md +1 -1
  22. package/agents/ui-developer.md +1 -1
  23. package/agents/ux-evaluator.md +1 -1
  24. package/commands/eli5.md +33 -0
  25. package/commands/release.md +62 -0
  26. package/commands/test.md +2 -2
  27. package/docs/components.md +6 -5
  28. package/docs/migration-v3.md +9 -6
  29. package/docs/persona-panel.md +3 -1
  30. package/docs/scope-collision-guard.md +167 -0
  31. package/docs/session-config-reference.md +31 -8
  32. package/hooks/_lib/lock-bootstrap.mjs +19 -13
  33. package/hooks/enforce-scope.mjs +103 -3
  34. package/hooks/hooks-codex.json +1 -1
  35. package/hooks/hooks.json +21 -1
  36. package/hooks/on-session-end.mjs +76 -97
  37. package/hooks/on-session-start.mjs +195 -104
  38. package/hooks/on-stop.mjs +127 -12
  39. package/hooks/post-bash-write-verify.mjs +8 -32
  40. package/hooks/pre-auq-clarity.mjs +787 -0
  41. package/hooks/pre-bash-issue-budget.mjs +17 -18
  42. package/hooks/pre-task-scope-disjoint.mjs +1042 -0
  43. package/package.json +3 -1
  44. package/pi/prompts/eli5.md +12 -0
  45. package/pi/prompts/release.md +12 -0
  46. package/scripts/auq-audit.mjs +825 -0
  47. package/scripts/autopilot.mjs +10 -9
  48. package/scripts/emit-session.mjs +42 -0
  49. package/scripts/export-hw-learnings.mjs +61 -2
  50. package/scripts/lib/auq/clarity.mjs +1314 -0
  51. package/scripts/lib/auq/parse.mjs +1006 -0
  52. package/scripts/lib/auq/schema.mjs +1457 -0
  53. package/scripts/lib/autopilot/worktree-pipeline.mjs +5 -5
  54. package/scripts/lib/backlog-scan.mjs +106 -15
  55. package/scripts/lib/build-live-signals.mjs +7 -3
  56. package/scripts/lib/ci-status-banner.mjs +267 -77
  57. package/scripts/lib/config/dispatcher-autonomy-capture.mjs +32 -9
  58. package/scripts/lib/config/vault-integration.mjs +12 -1
  59. package/scripts/lib/dispatcher/rank.mjs +4 -7
  60. package/scripts/lib/gates/gate-full.mjs +3 -3
  61. package/scripts/lib/gates/gate-helpers.mjs +17 -6
  62. package/scripts/lib/git-config-drift.mjs +471 -0
  63. package/scripts/lib/harness-audit/categories/category6.mjs +65 -12
  64. package/scripts/lib/io.mjs +432 -7
  65. package/scripts/lib/issue-budget.mjs +63 -9
  66. package/scripts/lib/learnings/select.mjs +157 -3
  67. package/scripts/lib/memory-cleanup-stamp.mjs +132 -8
  68. package/scripts/lib/mirror-issues-banner.mjs +266 -0
  69. package/scripts/lib/named-vault-resolver.mjs +105 -16
  70. package/scripts/lib/owner-interview.mjs +78 -32
  71. package/scripts/lib/peer-cards/schema.mjs +6 -2
  72. package/scripts/lib/peer-discovery.mjs +73 -22
  73. package/scripts/lib/project-hygiene.mjs +64 -4
  74. package/scripts/lib/reconcile/renderer.mjs +17 -4
  75. package/scripts/lib/reconcile/writer.mjs +69 -30
  76. package/scripts/lib/redact-spans.mjs +89 -0
  77. package/scripts/lib/resource-probe/evaluate.mjs +330 -149
  78. package/scripts/lib/resource-probe/probe-platform.mjs +35 -0
  79. package/scripts/lib/resource-probe.mjs +18 -2
  80. package/scripts/lib/scope-baseline.mjs +77 -17
  81. package/scripts/lib/scope-gate.mjs +658 -0
  82. package/scripts/lib/secret-masker.mjs +262 -0
  83. package/scripts/lib/session-lock.mjs +34 -10
  84. package/scripts/lib/session-registry.mjs +9 -1
  85. package/scripts/lib/spiral-carryover.mjs +23 -2
  86. package/scripts/lib/state-md/mission-status.mjs +164 -58
  87. package/scripts/lib/tmux-layout/vcs-detector.mjs +108 -4
  88. package/scripts/lib/validate/check-agents.mjs +77 -5
  89. package/scripts/lib/validate/check-auq-clarity.mjs +274 -0
  90. package/scripts/lib/validate/check-commands.mjs +2 -20
  91. package/scripts/lib/validate/check-doc-cli-commands.mjs +514 -0
  92. package/scripts/lib/validate/check-hooks-symmetry.mjs +48 -0
  93. package/scripts/lib/validate/check-owner-leakage.mjs +185 -17
  94. package/scripts/lib/validate/check-rules.mjs +153 -9
  95. package/scripts/lib/validate/check-skills.mjs +191 -0
  96. package/scripts/lib/validate/check-test-git-config-target.mjs +665 -0
  97. package/scripts/lib/validate/check-unicode-safety.mjs +22 -2
  98. package/scripts/lib/validate/check-untracked-test-deps.mjs +925 -0
  99. package/scripts/lib/validate/check-unwired-features.mjs +219 -11
  100. package/scripts/lib/validate/check-vcs-repo-flag.mjs +965 -0
  101. package/scripts/lib/validate/frontmatter-block.mjs +61 -0
  102. package/scripts/lib/validate/tier-inference.mjs +46 -8
  103. package/scripts/lib/vault-backfill/glab.mjs +91 -58
  104. package/scripts/lib/vault-backfill/manifest.mjs +28 -8
  105. package/scripts/lib/vault-mirror/namespace.mjs +146 -1
  106. package/scripts/lib/vault-mirror/process.mjs +264 -31
  107. package/scripts/lib/vault-mirror/render-sessions.mjs +115 -4
  108. package/scripts/lib/vault-status/board-writer.mjs +300 -56
  109. package/scripts/lib/vault-status/narrative-mirror.mjs +119 -5
  110. package/scripts/lib/vcs-repo-spec.mjs +680 -30
  111. package/scripts/lib/wave-resource-gate.mjs +67 -73
  112. package/scripts/materialize-wave-scope.mjs +281 -0
  113. package/scripts/print-learnings-index.mjs +30 -3
  114. package/scripts/release.mjs +983 -107
  115. package/scripts/run-quality-gate.mjs +14 -0
  116. package/scripts/site-numbers.mjs +1049 -0
  117. package/scripts/validate-plugin.mjs +64 -0
  118. package/scripts/validate-wave-scope.mjs +286 -12
  119. package/scripts/vault-backfill.mjs +32 -5
  120. package/scripts/vault-mirror.mjs +26 -1
  121. package/skills/_shared/monitor-patterns.md +24 -4
  122. package/skills/_shared/parallel-aware-auq.md +30 -24
  123. package/skills/_shared/parallel-aware-preamble.md +31 -2
  124. package/skills/_shared/state-ownership.md +49 -6
  125. package/skills/bootstrap/SKILL.md +2 -1
  126. package/skills/brainstorm/SKILL.md +18 -18
  127. package/skills/brainstorm/soul.md +12 -0
  128. package/skills/claude-md-drift-check/SKILL.md +9 -1
  129. package/skills/debug/SKILL.md +4 -1
  130. package/skills/discovery/SKILL.md +28 -24
  131. package/skills/discovery/issue-templates.md +4 -4
  132. package/skills/discovery/probes-code.md +2 -2
  133. package/skills/discovery/probes-feature.md +6 -6
  134. package/skills/discovery/probes-infra.md +2 -2
  135. package/skills/discovery/probes-session.md +5 -5
  136. package/skills/dispatcher/SKILL.md +10 -1
  137. package/skills/eli5/SKILL.md +43 -0
  138. package/skills/evolve/SKILL.md +8 -9
  139. package/skills/frontmatter-guard/SKILL.md +9 -1
  140. package/skills/gitlab-ops/SKILL.md +73 -59
  141. package/skills/gitlab-portfolio/SKILL.md +10 -1
  142. package/skills/grill/SKILL.md +6 -6
  143. package/skills/grill/soul.md +16 -0
  144. package/skills/memory-cleanup/SKILL.md +20 -7
  145. package/skills/npm-publish/SKILL.md +23 -51
  146. package/skills/peekaboo-driver/SKILL.md +3 -3
  147. package/skills/persona-panel/SKILL.md +3 -1
  148. package/skills/plan/SKILL.md +18 -16
  149. package/skills/plan/mode-feature.md +1 -1
  150. package/skills/plan/mode-new.md +42 -12
  151. package/skills/plan/soul.md +12 -0
  152. package/skills/reconcile/SKILL.md +3 -3
  153. package/skills/repo-audit/SKILL.md +10 -1
  154. package/skills/session-end/SKILL.md +97 -22
  155. package/skills/session-end/metrics-collection.md +1 -1
  156. package/skills/session-end/phase-3-6-tail.md +37 -2
  157. package/skills/session-end/session-metrics-write.md +4 -10
  158. package/skills/session-plan/SKILL.md +2 -2
  159. package/skills/session-plan/wave-template.md +1 -1
  160. package/skills/session-start/SKILL.md +82 -36
  161. package/skills/session-start/phase-2-5-docs-planning.md +8 -8
  162. package/skills/session-start/phase-4-5-resource-health.md +82 -19
  163. package/skills/session-start/soul.md +110 -0
  164. package/skills/spinout/SKILL.md +5 -1
  165. package/skills/sunset-review/SKILL.md +11 -1
  166. package/skills/test-runner/SKILL.md +2 -2
  167. package/skills/tmux-layout/SKILL.md +7 -2
  168. package/skills/using-orchestrator/SKILL.md +1 -1
  169. package/skills/vault-mirror/SKILL.md +10 -1
  170. package/skills/vault-sync/SKILL.md +10 -1
  171. package/skills/vault-sync/validator.mjs +55 -6
  172. package/skills/wave-executor/wave-loop.md +64 -12
  173. package/skills/write-executable-plan/SKILL.md +6 -6
  174. package/scripts/lib/mission-status-schema.mjs +0 -114
  175. package/scripts/tests/fixtures/fetch-baseline/sample-rule.md +0 -8
  176. package/skills/vault-sync/tests/fixtures/archive-test-vault/90-archive/bad-archived.md +0 -8
  177. package/skills/vault-sync/tests/fixtures/archive-test-vault/_meta/.gitkeep +0 -0
  178. package/skills/vault-sync/tests/fixtures/archive-test-vault/live-note.md +0 -8
  179. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/_meta/.gitkeep +0 -0
  180. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/bad-type.md +0 -8
  181. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/good-note.md +0 -8
  182. package/skills/vault-sync/tests/fixtures/clean-vault/.obsidian/config.md +0 -8
  183. package/skills/vault-sync/tests/fixtures/clean-vault/01-projects/foo/projects-baseline.md +0 -10
  184. package/skills/vault-sync/tests/fixtures/clean-vault/03-daily/daily-2026-04-13.md +0 -8
  185. package/skills/vault-sync/tests/fixtures/clean-vault/README.md +0 -3
  186. package/skills/vault-sync/tests/fixtures/clean-vault/hello-world.md +0 -11
  187. package/skills/vault-sync/tests/fixtures/dangling-link-vault/_meta/.gitkeep +0 -0
  188. package/skills/vault-sync/tests/fixtures/dangling-link-vault/has-dangling.md +0 -9
  189. package/skills/vault-sync/tests/fixtures/dangling-link-vault/real-target.md +0 -8
  190. package/skills/vault-sync/tests/fixtures/empty-vault/_meta/.gitkeep +0 -0
  191. package/skills/vault-sync/tests/fixtures/missing-field-vault/_meta/.gitkeep +0 -0
  192. package/skills/vault-sync/tests/fixtures/missing-field-vault/missing-id.md +0 -7
  193. package/skills/vault-sync/tests/fixtures/nested-tag-vault/03-daily/daily-2026-04-13.md +0 -9
  194. package/skills/vault-sync/tests/fixtures/nested-tag-vault/_meta/.gitkeep +0 -0
  195. package/skills/vault-sync/tests/fixtures/nested-tag-vault/nested-tags-note.md +0 -11
  196. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/README.md +0 -3
  197. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_MOC.md +0 -3
  198. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_meta/.gitkeep +0 -0
  199. package/skills/vault-sync/tests/fixtures/with-moc-vault/_MOC.md +0 -11
  200. package/skills/vault-sync/tests/fixtures/with-moc-vault/_meta/.gitkeep +0 -0
  201. package/skills/vault-sync/tests/fixtures/with-moc-vault/hello-world.md +0 -11
  202. package/skills/vault-sync/tests/schema-drift.test.mjs +0 -133
@@ -0,0 +1,1042 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * pre-task-scope-disjoint.mjs — PreToolUse hook on the subagent-dispatch tool.
4
+ *
5
+ * Blocks a wave from handing the SAME file to two agents, at the moment of
6
+ * dispatch, before either agent has written a byte (issue #1020).
7
+ *
8
+ * ## The measurement that shaped this hook (2026-08-14, this repo)
9
+ *
10
+ * The obvious design — "read the batch of sibling agents out of the payload and
11
+ * compare their file scopes" — is NOT implementable. Three findings, each
12
+ * measured against the 12 most recent archived transcripts of this project
13
+ * (147 dispatch tool_use blocks, 51 batches):
14
+ *
15
+ * 1. THE TOOL IS CALLED `Agent`, NOT `Task`.
16
+ * `jq … select(.type=="tool_use") | .name | sort | uniq -c` over those
17
+ * transcripts: `Agent` 147. There is a separate, unrelated `Task*` family
18
+ * (`TaskCreate` 13, `TaskUpdate` 37, `TaskGet` 27, `TaskList` 6,
19
+ * `TaskStop` 5, `TaskOutput` 54) which is the todo/task surface, not the
20
+ * subagent dispatch. A `hooks.json` matcher of `Task` would therefore fire
21
+ * on the todo tools and NEVER on a dispatch. The matcher must be `Agent`.
22
+ *
23
+ * 2. THE PAYLOAD CARRIES NO STRUCTURED FILE SCOPE.
24
+ * Observed `tool_input` key sets, all 147 blocks:
25
+ * `description,model,prompt,subagent_type` (97)
26
+ * `description,model,prompt,run_in_background,subagent_type` (35)
27
+ * `description,isolation,model,prompt,run_in_background,…` (14)
28
+ * `description,prompt,subagent_type` (1)
29
+ * `has("files") , has("file_scope") , has("scope")` → 441 × false
30
+ * (3 probes × 147 blocks, zero hits). The file scope exists only as PROSE
31
+ * inside `prompt`. That is the only channel available, so this hook parses
32
+ * it — conservatively, and every parse failure resolves to ALLOW.
33
+ *
34
+ * 3. THE SIBLINGS ARE NOT VISIBLE YET AT DISPATCH TIME.
35
+ * Parallel dispatch is real: grouping blocks by `message.id` gives batches
36
+ * of 1 (13×), 2 (6×), 3 (16×), 4 (8×), 5 (6×) and 6 (2×) agents — so the
37
+ * naive per-line count of "1 agent per assistant row" is a measurement
38
+ * artifact of streaming, not the truth. But the agents of a batch land on
39
+ * CONSECUTIVE transcript rows and their results arrive minutes later, so
40
+ * reading `transcript_path` at dispatch time yields ZERO not-yet-dispatched
41
+ * siblings. (What it DOES yield — the state of the ALREADY-dispatched ones
42
+ * — is the liveness signal in § Liveness below.)
43
+ *
44
+ * Conclusion: the only mechanically decidable construction is a LEDGER — carry
45
+ * state across the dispatches of one wave. Each dispatch records its scope; the
46
+ * next dispatch is checked against everything already recorded for that wave.
47
+ * That is what this hook does. The comparison itself is delegated to
48
+ * `findScopeCollisions()` (scripts/lib/scope-gate.mjs); this hook only supplies
49
+ * the three things a pure library cannot: the ledger, `knownFiles` from
50
+ * `git ls-files`, and the liveness probe below.
51
+ *
52
+ * ## Liveness — the ledger has to know that an agent FINISHED (review HIGH)
53
+ *
54
+ * A ledger without a completion notion denies the wrong thing. Measured over 38
55
+ * archived transcripts of this project (346 `Agent` dispatch blocks): 0 of 4
56
+ * same-batch overlaps and 2 of 2 CROSS-dispatch overlaps would have been denied
57
+ * — and both cross-dispatch pairs were legitimate SEQUENTIAL repair passes
58
+ * ("L2 extract redactSpans primitive" 14:14:26 ←→ its fix 14:50:33;
59
+ * "C2 vcs repo-flag checker" 14:28:35 ←→ its fix 15:17:27). Blocking a repair
60
+ * pass is precisely the session outage the matrix below calls the reason this
61
+ * guard is not fail-closed, and because a deny deliberately does not persist the
62
+ * ledger, the re-dispatch met the same stale record — a PERMANENT block.
63
+ *
64
+ * The discriminator is therefore not time and not the agent's name: it is
65
+ * whether the already-recorded agent is STILL IN FLIGHT. Two transcript shapes
66
+ * carry that, both measured in this repo's own transcripts:
67
+ *
68
+ * a) SYNCHRONOUS dispatch — the `tool_result` for the dispatch's `tool_use`
69
+ * id arrives when the agent is done. Batch `msg…`/2026-08-06T07:07:39:
70
+ * five `Agent` rows within 0.44 s, their five results 5–11 MINUTES later.
71
+ * At agent #5's PreToolUse none of #1…#4 has a result → all IN FLIGHT →
72
+ * a real same-batch overlap still DENIES.
73
+ * b) ASYNCHRONOUS dispatch — the `tool_result` arrives in 0.2 s and reads
74
+ * `"Async agent launched successfully"`. That text is a LAUNCH
75
+ * ACKNOWLEDGEMENT, not a completion; treating it as one would let every
76
+ * real background-batch collision through. Completion arrives later as a
77
+ * `<task-notification>` record carrying `<tool-use-id>toolu_…</tool-use-id>`
78
+ * and `<status>completed</status>` (measured: launch 14:14:26.768 →
79
+ * notification 14:24:39.360, ten minutes later).
80
+ *
81
+ * COST CONTAINMENT: the transcript is read ONLY when a collision has already
82
+ * been found — i.e. on the path that is about to deny. The 99 % no-collision
83
+ * path pays nothing. Worst measured transcript in this project is 70 MB and
84
+ * costs 78 ms to read + 129 ms to scan; a typical one is 1–5 MB.
85
+ *
86
+ * 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
89
+ * MEASURED same-batch dispatch spread is 95.7 s, so 30 min is ~19× headroom
90
+ * against the false-ALLOW direction, while both measured sequential repair gaps
91
+ * (36 min, 49 min) sit above it. Revisit trigger: a same-batch spread above
92
+ * ~5 min in `.orchestrator/metrics/`, or a harness change that stops writing
93
+ * `transcript_path` — either invalidates the headroom this number rests on.
94
+ *
95
+ * ## Error-class matrix — why this guard is deliberately NOT fail-closed
96
+ *
97
+ * A deny-capable hook on the DISPATCH path has an asymmetric blast radius: a
98
+ * false positive blocks every agent of the session (the guard becomes a session
99
+ * outage), while a false negative is a double-assignment that three later gates
100
+ * still catch (`validate-wave-scope.mjs`, `enforce-scope.mjs` at write time, and
101
+ * the W5 verification pass). Fail-closed is right for a WRITE guard; it is wrong
102
+ * here. Each row below is a deliberate choice, not an oversight:
103
+ *
104
+ * | # | Condition | Decision | Why |
105
+ * |---|----------------------------------------|---------------------|-----|
106
+ * | 1 | disabled via profile/env | exit 0, silent | repo convention (`shouldRunHook`); not a decision at all |
107
+ * | 2 | repo module failed to load | ALLOW + GUARD INACTIVE on stderr | #992/#993: a broken module must never brick the session — but never SILENTLY, or a crash is indistinguishable from `emitAllow` |
108
+ * | 3 | stdin empty / not JSON | ALLOW | not a real hook call; denying here blocks every dispatch on a harness quirk |
109
+ * | 4 | `tool_name` is not the dispatch tool | ALLOW | not our tool |
110
+ * | 5 | prompt carries no scope marker | ALLOW | 105 of 147 real prompts (71.4 %) have none. Non-extractable ≠ violation; denying these would deny 7 dispatches in 10 |
111
+ * | 6 | scope block present but unparseable | ALLOW | same reason as 5 — the parser is the fragile part, so its failures must resolve to the harmless side |
112
+ * | 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
+ * | 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
+ * | 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 |
117
+ * |11 | collision with a prior agent still IN FLIGHT | **DENY** | the one case this hook exists for |
118
+ * |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 |
120
+ * |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
+ *
122
+ * Rows 7 and 9 use `emitWarn`, which calls `process.exit(0)` and NEVER RETURNS.
123
+ * That is why `decide()` below is a PURE function returning a verdict object and
124
+ * this module emits exactly ONCE, at the end. Warning from inside the checking
125
+ * flow would terminate the process before a later collision could be denied —
126
+ * the recorded failure mode "an inline @returns-never warn helper at a rule-loop
127
+ * warn site flips a later block to ALLOW". The same reason forbids emitting from
128
+ * inside the ledger lock: `process.exit()` skips the release `finally`.
129
+ *
130
+ * ## Ledger concurrency
131
+ *
132
+ * `read → decide → write` is a read-modify-write cycle. `writeJsonAtomicSync`
133
+ * makes the WRITE atomic, never the CYCLE: two dispatches starting together read
134
+ * the same state and the first one's record is lost, so a third dispatch never
135
+ * sees it — a MISSED collision. The cycle therefore runs inside
136
+ * `withFileLock()` (`scripts/lib/file-lock.mjs`, the same primitive behind the
137
+ * PSA-005 STATE.md lock), with a dead-PID stale override and a short timeout;
138
+ * on timeout it degrades to the unlocked cycle (row 14) rather than denying.
139
+ *
140
+ * ## stdout discipline
141
+ *
142
+ * Under the exit-0 protocol (#906, ADR-0011) allow and deny share exit code 0 —
143
+ * the decision lives only in the stdout JSON, so a truncated envelope reads as
144
+ * no-decision and the dispatch PROCEEDS. The reason names agents, paths and
145
+ * witnesses, so it can genuinely grow past the 65 536-byte kernel pipe buffer.
146
+ * Both bounds apply, as required: this module clamps its own payload
147
+ * (`MAX_REPORTED_COLLISIONS` / `MAX_EVIDENCE_PER_COLLISION` / `MAX_PATH_CHARS`),
148
+ * and `emitDeny` writes through `writeStdoutLineSync` (`fs.writeSync(1, …)` with
149
+ * an EAGAIN retry loop) and clamps again. This module never calls `console.log`
150
+ * followed by `process.exit()`.
151
+ *
152
+ * ## Import safety
153
+ *
154
+ * Everything with an effect — the profile gate, `bootstrap()`, `main()` — runs
155
+ * ONLY under {@link invokedAsScript}. Without that guard an `import` of this
156
+ * module executed `main()`, blocked 5 s on stdin and terminated the IMPORTING
157
+ * process with `exit 0`, which under ADR-0011 is itself an ALLOW; the exports
158
+ * below were unimportable in practice. Same precedent as
159
+ * `hooks/post-bash-write-verify.mjs` and `hooks/skill-invocation-telemetry.mjs`.
160
+ *
161
+ * ## Measured cost (2026-08-14, this repo, 1581 tracked files, back-to-back runs)
162
+ *
163
+ * full hook path, per dispatch 69.0 ms (20 runs / 1.380 s wall)
164
+ * ├─ bare node start 41.2 ms (20 runs / 0.824 s — every hook pays this)
165
+ * └─ marginal cost added here 27.8 ms (git rev-parse + ls-files + lock + ledger + compare)
166
+ *
167
+ * same 20 runs with the lock and the rev-parse REMOVED: 1.375 s — so the
168
+ * repairs in this file cost +0.25 ms per dispatch, inside the run-to-run noise.
169
+ *
170
+ * The marginal cost is paid once per dispatch, i.e. ≤ 6× per wave. The transcript
171
+ * scan is NOT in it — it runs only when a collision was already found.
172
+ *
173
+ * ## PSA
174
+ *
175
+ * `git ls-files` / `git rev-parse` only — read-only plumbing that takes no index
176
+ * lock. No git-write command is ever issued (PSA-007).
177
+ *
178
+ * hooks.json registration is deliberately NOT part of this file's change set —
179
+ * arming a PreToolUse hook on the dispatch path affects the very session that
180
+ * builds it, so it is a separate, verified step (W5).
181
+ */
182
+
183
+ import path from 'node:path';
184
+ import { pathToFileURL, fileURLToPath } from 'node:url';
185
+ import { execFileSync } from 'node:child_process';
186
+ import { mkdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
187
+
188
+ import { shouldRunHook } from './_lib/profile-gate.mjs';
189
+
190
+ // ---------------------------------------------------------------------------
191
+ // #993 — late-bound repo dependencies
192
+ //
193
+ // Static imports fail at ESM LINK time: node exits 1 with 0 bytes on stdout,
194
+ // and under the exit-0 protocol that crash is indistinguishable from an
195
+ // explicit allow — the guard would fail open AND silently. Binding late turns
196
+ // the link-time crash into a catchable runtime error, which is what makes the
197
+ // GUARD INACTIVE banner reachable at all. `profile-gate.mjs` and `node:*`
198
+ // builtins stay static — they cannot be the broken repo module.
199
+ // ---------------------------------------------------------------------------
200
+ /** @type {typeof import('../scripts/lib/io.mjs').readStdin} */ let readStdin;
201
+ /** @type {typeof import('../scripts/lib/io.mjs').emitAllow} */ let emitAllow;
202
+ /** @type {typeof import('../scripts/lib/io.mjs').emitDeny} */ let emitDeny;
203
+ /** @type {typeof import('../scripts/lib/io.mjs').emitWarn} */ let emitWarn;
204
+ /** @type {typeof import('../scripts/lib/io.mjs').writeJsonAtomicSync} */ let writeJsonAtomicSync;
205
+ /** @type {typeof import('../scripts/lib/file-lock.mjs').withFileLock} */ let withFileLock;
206
+ let findScopeCollisions;
207
+
208
+ const PLUGIN_ROOT = path.resolve(import.meta.dirname, '..');
209
+
210
+ /** This hook's name — threaded into the guard banner (#993: no hard-wired literal). */
211
+ const HOOK_NAME = 'pre-task-scope-disjoint';
212
+
213
+ /**
214
+ * The dispatch tool's name. MEASURED, not assumed: 147/147 dispatch blocks in
215
+ * the archived transcripts carry `"name":"Agent"`. `Task` is a different tool
216
+ * family (TaskCreate/TaskUpdate/…) — see the header measurement #1.
217
+ */
218
+ const DISPATCH_TOOL = 'Agent';
219
+
220
+ /** Ledger location, relative to the project dir. */
221
+ const LEDGER_REL = path.join('.orchestrator', 'wave-dispatch-scopes.json');
222
+
223
+ /** Mutex for the ledger's read-modify-write cycle — see § Ledger concurrency. */
224
+ const LEDGER_LOCK_REL = path.join('.orchestrator', 'wave-dispatch-scopes.lock');
225
+
226
+ /**
227
+ * Ledger-lock budget. Short on purpose: the whole locked region is a read, a
228
+ * pure comparison and one atomic write (~2 ms measured), so anything near this
229
+ * bound is a dead holder, not contention. On expiry the cycle runs UNLOCKED
230
+ * (matrix row 14) — the lock closes a race, it must never become a new outage.
231
+ */
232
+ const LEDGER_LOCK_TIMEOUT_MS = 2000;
233
+ const LEDGER_LOCK_POLL_MS = 25;
234
+
235
+ /** Payload bounds — see § stdout discipline. */
236
+ const MAX_REPORTED_COLLISIONS = 5;
237
+ const MAX_EVIDENCE_PER_COLLISION = 4;
238
+ const MAX_PATH_CHARS = 120;
239
+
240
+ /** Ledger bound: a wave dispatching more than this is pathological; drop oldest. */
241
+ const MAX_LEDGER_AGENTS = 64;
242
+
243
+ /**
244
+ * Blind-fallback liveness bound — see § Liveness for the measurement, the named
245
+ * ceiling and the revisit trigger. Only reached when the transcript carries NO
246
+ * evidence for that agent.
247
+ */
248
+ const IN_FLIGHT_TTL_MS = 30 * 60 * 1000;
249
+
250
+ /**
251
+ * Transcript-size ceiling for the liveness probe. The largest transcript
252
+ * measured in this project is 70 MB (78 ms read); 256 MiB is ~3.6× that and
253
+ * still far below V8's string limit. A larger file is treated as NO EVIDENCE
254
+ * (→ TTL fallback), never as a completion.
255
+ */
256
+ const MAX_TRANSCRIPT_BYTES = 256 * 1024 * 1024;
257
+
258
+ /**
259
+ * The launch acknowledgement an ASYNC dispatch returns within ~0.2 s. It is NOT
260
+ * a completion — see § Liveness (b). Reading it as one would let every real
261
+ * background-batch collision through, which is the one direction this repair
262
+ * must not take.
263
+ */
264
+ const ASYNC_LAUNCH_ACK = 'Async agent launched successfully';
265
+
266
+ /**
267
+ * The consequence block spliced VERBATIM into the GUARD INACTIVE banner (#993),
268
+ * naming the enforcement this hook's outage stops applying.
269
+ */
270
+ const GUARD_CONSEQUENCE = {
271
+ inactive: [
272
+ ' Consequence: pre-dispatch scope-disjointness checking is OFF — two',
273
+ ' agents in the same wave CAN now be handed the same file without the',
274
+ ' dispatch being blocked. This is a BROKEN GUARD, not a policy decision —',
275
+ ' do not route around it, repair it.',
276
+ ],
277
+ };
278
+
279
+ /**
280
+ * Project dir for banner keying, resolved WITHOUT `platform.mjs` — that module
281
+ * is one of the ones that may have failed to load.
282
+ *
283
+ * @returns {string}
284
+ */
285
+ function bannerProjectDir() {
286
+ return process.env.CLAUDE_PROJECT_DIR || process.cwd();
287
+ }
288
+
289
+ /**
290
+ * Bind every repo dependency late, making a load failure VISIBLE (GUARD INACTIVE
291
+ * banner) instead of a silent exit-1 / 0-byte disarm. Throws on any load failure;
292
+ * the entry-point catch banners. Banner-only: this hook consumes no
293
+ * command-blocker symbol, so no module opts into the `git show HEAD:` fallback.
294
+ *
295
+ * @returns {Promise<void>}
296
+ */
297
+ async function bootstrap() {
298
+ const lib = (...seg) => pathToFileURL(path.join(PLUGIN_ROOT, 'scripts', 'lib', ...seg)).href;
299
+
300
+ const { armGuard } = await import('./_lib/guard-source-loader.mjs');
301
+ const { modules } = await armGuard(
302
+ {
303
+ io: { specifier: lib('io.mjs') },
304
+ scopeGate: { specifier: lib('scope-gate.mjs') },
305
+ fileLock: { specifier: lib('file-lock.mjs') },
306
+ },
307
+ {
308
+ hookName: HOOK_NAME,
309
+ repoRoot: PLUGIN_ROOT,
310
+ projectDir: bannerProjectDir(),
311
+ consequence: GUARD_CONSEQUENCE,
312
+ }
313
+ );
314
+
315
+ ({ readStdin, emitAllow, emitDeny, emitWarn, writeJsonAtomicSync } = modules.io);
316
+ ({ findScopeCollisions } = modules.scopeGate);
317
+ ({ withFileLock } = modules.fileLock);
318
+ }
319
+
320
+ // ---------------------------------------------------------------------------
321
+ // Scope extraction — the fragile part, so every failure resolves to ALLOW
322
+ // ---------------------------------------------------------------------------
323
+
324
+ /**
325
+ * Markers that introduce a file-scope block in a dispatch prompt. Measured
326
+ * coverage: 42 of 147 archived prompts (28.6 %) carry one of these. The other
327
+ * 71.4 % are matrix row 5 — allowed, not denied.
328
+ */
329
+ const SCOPE_MARKER = /^.{0,80}(DATEI[- ]SCOPE|FILE[- ]SCOPE|FILE SCOPE|DEIN SCOPE|SCOPE \(|FILES? IN SCOPE)/im;
330
+
331
+ /**
332
+ * A plausible repo-relative path. Deliberately strict — a false ACCEPT here
333
+ * invents scope entries that could deny a legitimate dispatch, which is the one
334
+ * direction this hook must not fail in. Rejects: absolute paths, `..` escapes,
335
+ * embedded whitespace, bare prose words with no `/` and no extension.
336
+ *
337
+ * @param {string} s
338
+ * @returns {boolean}
339
+ */
340
+ function looksLikeRepoPath(s) {
341
+ if (typeof s !== 'string') return false;
342
+ if (s.length === 0 || s.length > 200) return false;
343
+ if (/\s/.test(s)) return false;
344
+ if (s.startsWith('/') || /^[A-Za-z]:[\\/]/.test(s)) return false;
345
+ if (s.split('/').includes('..')) return false;
346
+ if (!s.includes('/') && !/\.[A-Za-z0-9]{1,8}$/.test(s)) return false;
347
+ return /^[A-Za-z0-9._*/-]+$/.test(s);
348
+ }
349
+
350
+ /**
351
+ * Strip the decorations a coordinator writes around a scope entry — a trailing
352
+ * `(neu)` / `(new)` annotation, list bullets, backticks, quotes, commas.
353
+ *
354
+ * @param {string} line
355
+ * @returns {string}
356
+ */
357
+ function cleanScopeLine(line) {
358
+ return String(line)
359
+ .replace(/\(.*?\)\s*$/, '') // trailing annotation: "(neu)", "(new, W2)"
360
+ .replace(/^[-*+\s]+/, '') // list bullet
361
+ .replace(/[`'"]/g, '') // code/quote decoration
362
+ .replace(/[,;]\s*$/, '') // trailing separator
363
+ .trim();
364
+ }
365
+
366
+ /**
367
+ * Canonicalise a scope entry's SPELLING so two agents writing the same file two
368
+ * ways are not read as disjoint (review LOW). Measured before this existed:
369
+ * `['./scripts/lib/foo.mjs']` vs `['scripts/lib/foo.mjs']` compared `ok: true`
370
+ * — the hook extracts from PROSE, and `looksLikeRepoPath` admits a `./` prefix,
371
+ * so both spellings reach the comparison verbatim.
372
+ *
373
+ * Purely syntactic and meaning-preserving: `./` prefixes, `/./` segments and
374
+ * duplicated slashes are removed. A TRAILING slash is deliberately kept — it is
375
+ * the directory-prefix operator of `pathMatchesPattern`, so stripping it would
376
+ * silently narrow a scope. The `dir` ↔ `dir/` case is handled by
377
+ * {@link promoteDirEntries}, which decides it on evidence rather than guessing.
378
+ *
379
+ * `scope-gate.mjs` is a hook-safe pure library and out of this change's scope,
380
+ * so the normalisation lives on THIS side of the call, applied to both sides of
381
+ * every comparison.
382
+ *
383
+ * @param {string} entry
384
+ * @returns {string}
385
+ */
386
+ export function normalizeScopeEntry(entry) {
387
+ if (typeof entry !== 'string') return '';
388
+ let s = entry.trim();
389
+ if (s === '') return '';
390
+ s = s.replace(/\/{2,}/g, '/'); // `a//b` → `a/b`
391
+ s = s.replace(/(?:^|\/)\.\//g, (m) => (m.startsWith('/') ? '/' : '')); // `./a`, `a/./b`
392
+ while (s.startsWith('./')) s = s.slice(2);
393
+ return s;
394
+ }
395
+
396
+ /**
397
+ * Promote an entry that names a DIRECTORY to its `dir/` prefix form, on
398
+ * evidence. `scripts/lib` and `scripts/lib/` are the same claim, but
399
+ * `pathMatchesPattern` reads only the second as a prefix — measured `ok: true`
400
+ * (disjoint) for that pair before this existed.
401
+ *
402
+ * The promotion is never a guess: an entry is rewritten only when it is NOT a
403
+ * tracked file itself AND at least one tracked file lives beneath it. With no
404
+ * `knownFiles` (git unavailable — matrix row 8) nothing is promoted, which is
405
+ * exactly the pre-existing behaviour rather than a new failure mode.
406
+ *
407
+ * Comparison-only: the ledger stores the unpromoted form, because `knownFiles`
408
+ * can differ between two dispatches and a stored promotion would outlive its
409
+ * evidence.
410
+ *
411
+ * @param {string[]} files
412
+ * @param {Set<string>} known — tracked files
413
+ * @returns {string[]}
414
+ */
415
+ export function promoteDirEntries(files, known) {
416
+ if (!Array.isArray(files) || !(known instanceof Set) || known.size === 0) {
417
+ return Array.isArray(files) ? files : [];
418
+ }
419
+ return files.map((f) => {
420
+ if (typeof f !== 'string' || f === '') return f;
421
+ if (f.includes('*') || f.endsWith('/')) return f; // already a pattern/prefix
422
+ if (known.has(f)) return f; // it IS a tracked file
423
+ const prefix = `${f}/`;
424
+ for (const k of known) if (k.startsWith(prefix)) return prefix;
425
+ return f;
426
+ });
427
+ }
428
+
429
+ /**
430
+ * Extract the declared file scope from a dispatch prompt.
431
+ *
432
+ * Strategy: find a scope marker line, take the FIRST fenced block after it, and
433
+ * accept only lines that survive `looksLikeRepoPath`. Returns `[]` when nothing
434
+ * is confidently extractable — which the caller treats as ALLOW (matrix rows
435
+ * 5 and 6), never as an empty scope that could collide.
436
+ *
437
+ * @param {string} prompt
438
+ * @returns {string[]} repo-relative paths/globs, normalised, deduped, order preserved
439
+ */
440
+ export function extractScopeFromPrompt(prompt) {
441
+ if (typeof prompt !== 'string' || prompt.length === 0) return [];
442
+ const markerMatch = SCOPE_MARKER.exec(prompt);
443
+ if (markerMatch === null) return [];
444
+
445
+ const after = prompt.slice(markerMatch.index + markerMatch[0].length);
446
+ // First fenced block after the marker. Non-greedy body; tolerates a language tag.
447
+ const fence = /```[^\n]*\n([\s\S]*?)```/.exec(after);
448
+ if (fence === null) return [];
449
+
450
+ const out = [];
451
+ const seen = new Set();
452
+ for (const rawLine of fence[1].split('\n')) {
453
+ const cleaned = normalizeScopeEntry(cleanScopeLine(rawLine));
454
+ if (!looksLikeRepoPath(cleaned)) continue;
455
+ if (seen.has(cleaned)) continue;
456
+ seen.add(cleaned);
457
+ out.push(cleaned);
458
+ }
459
+ return out;
460
+ }
461
+
462
+ /**
463
+ * The dispatch's human description — the field the liveness probe matches
464
+ * against the transcript's `tool_use` blocks (present in 147/147 measured
465
+ * payloads).
466
+ *
467
+ * @param {{description?: unknown}} toolInput
468
+ * @returns {string}
469
+ */
470
+ export function agentDescOf(toolInput) {
471
+ return typeof toolInput?.description === 'string' ? toolInput.description.trim() : '';
472
+ }
473
+
474
+ /**
475
+ * Stable agent identity for the ledger. `description` is present in 147/147
476
+ * measured payloads and is what a coordinator uses to name the agent; the
477
+ * subagent_type disambiguates two same-named dispatches of different roles.
478
+ *
479
+ * @param {{description?: unknown, subagent_type?: unknown}} toolInput
480
+ * @returns {string}
481
+ */
482
+ export function agentIdOf(toolInput) {
483
+ const desc = agentDescOf(toolInput);
484
+ const type = typeof toolInput?.subagent_type === 'string' ? toolInput.subagent_type.trim() : '';
485
+ if (desc !== '' && type !== '') return `${desc} (${type})`;
486
+ if (desc !== '') return desc;
487
+ if (type !== '') return type;
488
+ return 'unnamed-agent';
489
+ }
490
+
491
+ // ---------------------------------------------------------------------------
492
+ // Liveness — has an already-recorded agent FINISHED? (§ Liveness)
493
+ // ---------------------------------------------------------------------------
494
+
495
+ /**
496
+ * Index a session transcript by agent DESCRIPTION → completion state.
497
+ *
498
+ * Three record shapes are read, all measured in this repo's own transcripts:
499
+ * - `tool_use` `{name:'Agent', id, input.description}` — the dispatch.
500
+ * - `tool_result` `{tool_use_id, content}` — a completion for the SYNCHRONOUS
501
+ * shape, but only when its text is not the {@link ASYNC_LAUNCH_ACK}.
502
+ * - a `<task-notification>` record carrying `<tool-use-id>` and
503
+ * `<status>completed</status>` — the ASYNC shape's completion.
504
+ *
505
+ * A description dispatched N times counts as finished only when EVERY one of its
506
+ * tool_use ids is finished. Conservative on purpose: one outstanding run of the
507
+ * same agent keeps the deny alive.
508
+ *
509
+ * Pure and total — a malformed line is skipped, never thrown on.
510
+ *
511
+ * @param {string} raw — the transcript's JSONL text
512
+ * @returns {Map<string, boolean>} description → finished?
513
+ */
514
+ export function buildTranscriptIndex(raw) {
515
+ const out = new Map();
516
+ if (typeof raw !== 'string' || raw.length === 0) return out;
517
+
518
+ const dispatched = new Map(); // description → tool_use ids
519
+ const finishedIds = new Set();
520
+
521
+ for (const line of raw.split('\n')) {
522
+ if (line.length < 24) continue;
523
+
524
+ // ASYNC completion — matched on the RAW line: the tags are plain text inside
525
+ // a JSON string, so no parse is needed and the `queue-operation` carrier
526
+ // record (which has no `message.content`) is covered too.
527
+ if (line.includes('task-notification') && line.includes('<status>completed</status>')) {
528
+ for (const m of line.matchAll(/<tool-use-id>([^<]+)<\/tool-use-id>/g)) finishedIds.add(m[1]);
529
+ }
530
+
531
+ if (!line.includes('"tool_use"') && !line.includes('tool_use_id')) continue;
532
+ let rec;
533
+ try { rec = JSON.parse(line); } catch { continue; }
534
+ const content = rec?.message?.content;
535
+ if (!Array.isArray(content)) continue;
536
+
537
+ for (const block of content) {
538
+ if (block?.type === 'tool_use' && block?.name === DISPATCH_TOOL && typeof block?.id === 'string') {
539
+ const desc = typeof block?.input?.description === 'string' ? block.input.description.trim() : '';
540
+ if (desc === '') continue;
541
+ const ids = dispatched.get(desc) ?? [];
542
+ ids.push(block.id);
543
+ dispatched.set(desc, ids);
544
+ continue;
545
+ }
546
+ if (block?.type === 'tool_result' && typeof block?.tool_use_id === 'string') {
547
+ // The launch ACK is not a completion — see § Liveness (b).
548
+ if (!resultTextOf(block).includes(ASYNC_LAUNCH_ACK)) finishedIds.add(block.tool_use_id);
549
+ }
550
+ }
551
+ }
552
+
553
+ for (const [desc, ids] of dispatched) out.set(desc, ids.every((id) => finishedIds.has(id)));
554
+ return out;
555
+ }
556
+
557
+ /**
558
+ * Flatten a `tool_result` block's content to text. The field is a string in some
559
+ * records and an array of `{type:'text', text}` parts in others.
560
+ *
561
+ * @param {{content?: unknown}} block
562
+ * @returns {string}
563
+ */
564
+ function resultTextOf(block) {
565
+ const c = block?.content;
566
+ if (typeof c === 'string') return c;
567
+ if (!Array.isArray(c)) return '';
568
+ let s = '';
569
+ for (const part of c) if (typeof part?.text === 'string') s += part.text;
570
+ return s;
571
+ }
572
+
573
+ /**
574
+ * Build the liveness probe injected into {@link decide}.
575
+ *
576
+ * LAZY: the transcript is read on the FIRST call, i.e. only once a collision has
577
+ * been found. The no-collision path — the overwhelming majority — never touches
578
+ * the file (§ Liveness, cost containment).
579
+ *
580
+ * Resolution order per ledger entry:
581
+ * 1. transcript evidence for its description → definitive;
582
+ * 2. no evidence → the entry's own age against `IN_FLIGHT_TTL_MS`;
583
+ * 3. no usable timestamp either → NOT finished (matrix row 13 — keeps the
584
+ * deny biting rather than inventing a completion).
585
+ *
586
+ * @param {object} params
587
+ * @param {string|undefined} params.transcriptPath
588
+ * @param {number} [params.now]
589
+ * @param {number} [params.ttlMs]
590
+ * @param {(p: string, enc: string) => string} [params.readFn]
591
+ * @returns {(entry: {id: string, desc?: string, at?: string}) => boolean}
592
+ */
593
+ export function makeFinishedProbe({ transcriptPath, now = Date.now(), ttlMs = IN_FLIGHT_TTL_MS, readFn = readFileSync } = {}) {
594
+ let index; // undefined = not loaded yet, null = unavailable
595
+ const load = () => {
596
+ if (index !== undefined) return index;
597
+ index = null;
598
+ try {
599
+ if (typeof transcriptPath === 'string' && transcriptPath.length > 0) {
600
+ if (statSync(transcriptPath).size <= MAX_TRANSCRIPT_BYTES) {
601
+ index = buildTranscriptIndex(readFn(transcriptPath, 'utf8'));
602
+ }
603
+ }
604
+ } catch {
605
+ index = null; // absent / unreadable / oversized → blind, never "finished"
606
+ }
607
+ return index;
608
+ };
609
+
610
+ return (entry) => {
611
+ try {
612
+ const idx = load();
613
+ const desc = typeof entry?.desc === 'string' && entry.desc !== '' ? entry.desc : entry?.id;
614
+ if (idx !== null && typeof desc === 'string') {
615
+ const finished = idx.get(desc);
616
+ if (finished !== undefined) return finished;
617
+ }
618
+ const at = Date.parse(entry?.at ?? '');
619
+ if (Number.isFinite(at)) return now - at > ttlMs;
620
+ return false;
621
+ } catch {
622
+ return false; // row 13
623
+ }
624
+ };
625
+ }
626
+
627
+ // ---------------------------------------------------------------------------
628
+ // Wave identity + ledger
629
+ // ---------------------------------------------------------------------------
630
+
631
+ /**
632
+ * Identify the wave this dispatch belongs to. Derived from the coordinator's
633
+ * own scope file so a wave transition resets the ledger without anyone having to
634
+ * remember to clear it.
635
+ *
636
+ * FALLBACK, stated honestly (review MED): with no readable `wave-scope.json` the
637
+ * key degrades to `<session>|w?|?`, so the ledger spans the whole SESSION and a
638
+ * wave-3 dispatch is compared against wave-1 records. Before the liveness probe
639
+ * existed that was a genuine over-report — a wave-1 agent that had long finished
640
+ * blocked a wave-3 agent, and the doc comment claiming it "over-reports nothing"
641
+ * was wrong. It is now bounded rather than papered over: a prior record only
642
+ * binds while its agent is still IN FLIGHT (§ Liveness), and an agent still
643
+ * running across a wave boundary is a real race, not an artefact of the key.
644
+ * What remains is the blind case (no transcript), bounded by `IN_FLIGHT_TTL_MS`.
645
+ *
646
+ * @param {string} projectDir
647
+ * @param {string} sessionId
648
+ * @param {(p: string, enc: string) => string} readFn — injected `readFileSync`
649
+ * (the module is late-bound, so it cannot be imported at the top level here)
650
+ * @returns {string}
651
+ */
652
+ export function waveKeyOf(projectDir, sessionId, readFn) {
653
+ for (const dir of ['.pi', '.cursor', '.codex', '.claude']) {
654
+ try {
655
+ const raw = readFn(path.join(projectDir, dir, 'wave-scope.json'), 'utf8');
656
+ const data = JSON.parse(raw);
657
+ const wave = data?.wave ?? '?';
658
+ const role = data?.role ?? '?';
659
+ return `${sessionId}|w${wave}|${role}`;
660
+ } catch { /* try next location */ }
661
+ }
662
+ return `${sessionId}|w?|?`;
663
+ }
664
+
665
+ /**
666
+ * Tracked files, for glob expansion inside `findScopeCollisions`. The library is
667
+ * pure and must not spawn — supplying this is precisely the hook's job.
668
+ * Returns `[]` on any git failure (matrix row 8: degrade, never deny).
669
+ *
670
+ * ALIGNED WITH THE CLI (review MED): `scripts/validate-wave-scope.mjs`
671
+ * `knownRepoFiles()` resolves `git rev-parse --show-toplevel` FIRST and lists
672
+ * from there. Without that step a session whose cwd is a SUBDIRECTORY got
673
+ * subdir-relative paths here while the CLI got repo-relative ones — stage 3a
674
+ * then found no witness and the hook ALLOWED what the CLI called a collision.
675
+ * That is the dangerous direction, because the hook is the last gate before the
676
+ * write.
677
+ *
678
+ * @param {string} cwd
679
+ * @returns {string[]}
680
+ */
681
+ export function listTrackedFiles(cwd) {
682
+ const opts = {
683
+ cwd,
684
+ encoding: 'utf8',
685
+ maxBuffer: 32 * 1024 * 1024,
686
+ stdio: ['ignore', 'pipe', 'ignore'],
687
+ };
688
+ try {
689
+ const root = execFileSync('git', ['rev-parse', '--show-toplevel'], opts).trim();
690
+ if (!root) return [];
691
+ const stdout = execFileSync('git', ['ls-files', '-z'], { ...opts, cwd: root });
692
+ return stdout.split('\0').filter((f) => f.length > 0);
693
+ } catch {
694
+ return [];
695
+ }
696
+ }
697
+
698
+ /**
699
+ * Clip a path for the deny reason without losing the discriminating tail.
700
+ *
701
+ * @param {string} p
702
+ * @returns {string}
703
+ */
704
+ function clipPath(p) {
705
+ const s = String(p);
706
+ if (s.length <= MAX_PATH_CHARS) return s;
707
+ return `…${s.slice(-(MAX_PATH_CHARS - 1))}`;
708
+ }
709
+
710
+ // ---------------------------------------------------------------------------
711
+ // Decision — PURE. Returns a verdict; emits nothing, exits nothing.
712
+ //
713
+ // This purity is load-bearing, not stylistic: `emitWarn` and `emitDeny` both
714
+ // call `process.exit()` and never return, so any emit reached from inside the
715
+ // checking flow would terminate before a later collision could be denied — and,
716
+ // since #1020's lock landed, would also skip the lock's release `finally`.
717
+ // ---------------------------------------------------------------------------
718
+
719
+ /**
720
+ * @typedef {{action: 'allow'|'deny'|'warn', reason?: string, suggestion?: string,
721
+ * ledger?: object|null, note?: string}} Verdict
722
+ */
723
+
724
+ /**
725
+ * Decide whether this dispatch may proceed.
726
+ *
727
+ * @param {object} params
728
+ * @param {object} params.input parsed PreToolUse payload
729
+ * @param {object|null} params.ledger previously recorded wave state (null = unreadable)
730
+ * @param {boolean} params.ledgerCorrupt true when the ledger existed but could not be parsed
731
+ * @param {string} params.waveKey current wave identity
732
+ * @param {string[]} params.knownFiles tracked files for glob expansion
733
+ * @param {Function} params.collide `findScopeCollisions` (injected for testability)
734
+ * @param {(entry: object) => boolean} [params.isFinished] liveness probe (§ Liveness)
735
+ * @param {string} [params.nowIso] dispatch timestamp recorded on the entry
736
+ * @returns {Verdict}
737
+ */
738
+ export function decide({ input, ledger, ledgerCorrupt, waveKey, knownFiles, collide, isFinished, nowIso }) {
739
+ const toolName = input?.tool_name;
740
+ // Row 4: not our tool.
741
+ if (toolName !== DISPATCH_TOOL) return { action: 'allow' };
742
+
743
+ const toolInput = input?.tool_input;
744
+ if (toolInput === null || typeof toolInput !== 'object') return { action: 'allow' };
745
+
746
+ const files = extractScopeFromPrompt(toolInput.prompt);
747
+ // Rows 5 + 6: nothing confidently extractable → allow. Non-extractable is not
748
+ // a violation, and denying here would deny ~7 dispatches in 10.
749
+ if (files.length === 0) return { action: 'allow' };
750
+
751
+ const id = agentIdOf(toolInput);
752
+ const desc = agentDescOf(toolInput);
753
+ const at = typeof nowIso === 'string' ? nowIso : new Date().toISOString();
754
+ const self = { id, desc, files, at };
755
+
756
+ // Row 7: ledger existed but was unparseable. Terminal warn — decided here and
757
+ // returned, never emitted mid-flow. SELF-HEALING since the review: the verdict
758
+ // carries a FRESH ledger, so the corruption is repaired by this dispatch
759
+ // instead of disabling the guard for the rest of the wave.
760
+ if (ledgerCorrupt) {
761
+ return {
762
+ action: 'warn',
763
+ ledger: { waveKey, updated: at, agents: [self] },
764
+ note:
765
+ `${HOOK_NAME}: wave dispatch ledger was unreadable — scope-disjointness NOT checked for ` +
766
+ `"${id}"; the ledger has been reset, so the next dispatch is checked again.`,
767
+ };
768
+ }
769
+
770
+ const prior = (ledger !== null && ledger?.waveKey === waveKey && Array.isArray(ledger.agents))
771
+ ? ledger.agents.filter((a) => a !== null && typeof a === 'object' && typeof a.id === 'string')
772
+ : [];
773
+
774
+ // Row 10: same agent re-dispatched (a retry after a failed agent is legitimate).
775
+ // Replace its record instead of letting it collide with its own earlier self.
776
+ const others = prior.filter((a) => a.id !== id);
777
+
778
+ const known = new Set(Array.isArray(knownFiles) ? knownFiles : []);
779
+ const agentScopes = [
780
+ ...others.map((a) => ({ id: a.id, files: promoteDirEntries(a.files, known) })),
781
+ { id, files: promoteDirEntries(files, known) },
782
+ ];
783
+
784
+ let verdictLib;
785
+ try {
786
+ verdictLib = collide(agentScopes, { knownFiles });
787
+ } catch {
788
+ verdictLib = { ok: false, collisions: [], duplicateIds: [] };
789
+ }
790
+
791
+ const nextLedger = {
792
+ waveKey,
793
+ updated: at,
794
+ agents: [...others, self].slice(-MAX_LEDGER_AGENTS),
795
+ };
796
+
797
+ const collisions = Array.isArray(verdictLib?.collisions) ? verdictLib.collisions : [];
798
+ const duplicateIds = Array.isArray(verdictLib?.duplicateIds) ? verdictLib.duplicateIds : [];
799
+
800
+ // Row 9: "not evaluable" — the library's fail-closed shape. The discriminator
801
+ // is NOT `ok !== true`: `ok` means DISJOINT (`collisions.length === 0 &&
802
+ // duplicateIds.length === 0`), so `ok === false` is the NORMAL result of a
803
+ // real collision. Reading `ok` as evaluability turns every genuine collision
804
+ // into a warn — i.e. an ALLOW — which is the exact fail-open this hook exists
805
+ // to prevent. Not-evaluable is `ok === false` with BOTH arrays empty.
806
+ if (verdictLib?.ok !== true && collisions.length === 0 && duplicateIds.length === 0) {
807
+ return {
808
+ action: 'warn',
809
+ ledger: nextLedger,
810
+ note:
811
+ `${HOOK_NAME}: scope collision check not evaluable for "${id}" — dispatch allowed, ` +
812
+ 'disjointness UNVERIFIED.',
813
+ };
814
+ }
815
+
816
+ // Only collisions involving THIS dispatch are actionable here: a pair among
817
+ // already-dispatched agents was either denied at its own dispatch or predates
818
+ // this guard, and re-denying it would block an innocent third agent.
819
+ const mine = collisions.filter((c) => c?.a === id || c?.b === id);
820
+
821
+ if (mine.length === 0) return { action: 'allow', ledger: nextLedger };
822
+
823
+ // § Liveness — the review's HIGH finding. A collision with an agent that has
824
+ // ALREADY FINISHED is a sequential repair pass, not a race. The probe is called
825
+ // ONLY here, so the transcript is read only on the path that would deny.
826
+ const byId = new Map(others.map((a) => [a.id, a]));
827
+ const probe = typeof isFinished === 'function' ? isFinished : () => false;
828
+ const finishedIds = new Set();
829
+ const live = [];
830
+ for (const c of mine) {
831
+ const otherId = c.a === id ? c.b : c.a;
832
+ const entry = byId.get(otherId);
833
+ if (entry !== undefined && probe(entry)) {
834
+ finishedIds.add(otherId);
835
+ continue;
836
+ }
837
+ live.push(c);
838
+ }
839
+
840
+ // Row 10a: every colliding prior agent has finished. Allow AND prune their
841
+ // records — leaving them would make the NEXT repair pass pay the transcript
842
+ // scan again for a question already answered.
843
+ if (live.length === 0) {
844
+ const kept = others.filter((a) => !finishedIds.has(a.id));
845
+ return {
846
+ action: 'allow',
847
+ ledger: { waveKey, updated: at, agents: [...kept, self].slice(-MAX_LEDGER_AGENTS) },
848
+ };
849
+ }
850
+
851
+ // Row 11: the one case this hook exists for.
852
+ const shown = live.slice(0, MAX_REPORTED_COLLISIONS);
853
+ const lines = shown.map((c) => {
854
+ const other = c.a === id ? c.b : c.a;
855
+ const ev = Array.isArray(c.evidence) ? c.evidence : [];
856
+ const evShown = ev.slice(0, MAX_EVIDENCE_PER_COLLISION).map(clipPath).join(', ');
857
+ const more = ev.length > MAX_EVIDENCE_PER_COLLISION
858
+ ? ` (+${ev.length - MAX_EVIDENCE_PER_COLLISION} more)`
859
+ : '';
860
+ return ` • "${id}" ↔ "${other}" [${c.kind}]: ${evShown}${more}`;
861
+ });
862
+ const omitted = live.length > shown.length ? `\n (+${live.length - shown.length} further collisions)` : '';
863
+
864
+ const reason =
865
+ `File-scope collision: this dispatch overlaps ${live.length} STILL-RUNNING ` +
866
+ `agent(s) of the same wave.\n${lines.join('\n')}${omitted}`;
867
+ // The old suggestion said "dispatch them in different waves", which is wrong
868
+ // advice for the case that actually fires: a still-running sibling. A finished
869
+ // agent no longer blocks anything (§ Liveness), so the remedy is ownership or
870
+ // sequencing — never a wave split.
871
+ const suggestion =
872
+ 'Two agents editing one file at the same time race each other (PSA-002). ' +
873
+ 'Give the file exactly ONE owner in the wave plan, or wait for the named ' +
874
+ `agent(s) to finish and re-dispatch — a finished agent no longer blocks. ` +
875
+ `If the ledger is stale, delete ${LEDGER_REL}.`;
876
+
877
+ // Deliberately NOT persisting the ledger on deny: the dispatch did not happen,
878
+ // so recording it would make the retry-after-fix look like a duplicate.
879
+ return { action: 'deny', reason, suggestion };
880
+ }
881
+
882
+ // ---------------------------------------------------------------------------
883
+ // Entry point — exactly ONE terminal emit
884
+ // ---------------------------------------------------------------------------
885
+
886
+ async function main() {
887
+ // Row 3: no input is not a real hook call.
888
+ const input = await readStdin();
889
+ if (!input) return emitAllow();
890
+
891
+ const projectDir = typeof input.cwd === 'string' && input.cwd !== ''
892
+ ? input.cwd
893
+ : bannerProjectDir();
894
+ const sessionId = typeof input.session_id === 'string' ? input.session_id : 'no-session';
895
+
896
+ // Cheap pre-check: skip all I/O for the overwhelmingly common non-dispatch call.
897
+ if (input.tool_name !== DISPATCH_TOOL) return emitAllow();
898
+
899
+ const waveKey = waveKeyOf(projectDir, sessionId, readFileSync);
900
+ const knownFiles = listTrackedFiles(projectDir);
901
+ const isFinished = makeFinishedProbe({ transcriptPath: input.transcript_path });
902
+ const ledgerPath = path.join(projectDir, LEDGER_REL);
903
+
904
+ // The read-modify-write CYCLE, run under the ledger lock below. Everything
905
+ // inside is synchronous and emits NOTHING — an emit here would `process.exit()`
906
+ // past the lock's release `finally` and leave a lock file behind.
907
+ const cycle = () => {
908
+ let ledger = null;
909
+ let ledgerCorrupt = false;
910
+ try {
911
+ ledger = JSON.parse(readFileSync(ledgerPath, 'utf8'));
912
+ if (ledger === null || typeof ledger !== 'object') { ledger = null; ledgerCorrupt = true; }
913
+ } catch (err) {
914
+ // Absent ledger is the normal first-dispatch case, NOT corruption (row 7
915
+ // must not fire on every wave's first agent).
916
+ if (err?.code !== 'ENOENT') ledgerCorrupt = true;
917
+ }
918
+
919
+ const verdict = decide({
920
+ input,
921
+ ledger,
922
+ ledgerCorrupt,
923
+ waveKey,
924
+ knownFiles,
925
+ collide: findScopeCollisions,
926
+ isFinished,
927
+ });
928
+
929
+ if (verdict.ledger) {
930
+ try {
931
+ writeJsonAtomicSync(ledgerPath, verdict.ledger);
932
+ } catch {
933
+ // Ledger persistence is best-effort. Failing to record must not turn an
934
+ // allow into a deny — the next dispatch simply sees less history.
935
+ }
936
+ }
937
+ return verdict;
938
+ };
939
+
940
+ let verdict;
941
+ const lockPath = path.join(projectDir, LEDGER_LOCK_REL);
942
+ try {
943
+ mkdirSync(path.dirname(lockPath), { recursive: true });
944
+ } catch { /* the unlocked fallback below still works */ }
945
+ let locked;
946
+ try {
947
+ locked = await withFileLock(lockPath, cycle, {
948
+ timeoutMs: LEDGER_LOCK_TIMEOUT_MS,
949
+ pollMs: LEDGER_LOCK_POLL_MS,
950
+ staleCheck: 'pid',
951
+ holder: HOOK_NAME,
952
+ tmpPrefix: '.wave-dispatch-scopes.lock',
953
+ warn: () => { /* a stale-lock override is bookkeeping, not an operator decision */ },
954
+ });
955
+ } catch {
956
+ locked = { ok: false, reason: 'fs-error' };
957
+ }
958
+ if (locked?.ok === true) {
959
+ verdict = locked.value;
960
+ } else {
961
+ // Row 14: lock unavailable → run the cycle UNLOCKED rather than deny. The
962
+ // race window returns, which is exactly the pre-lock behaviour — strictly
963
+ // better than blocking the dispatch on a lock-file problem.
964
+ verdict = cycle();
965
+ }
966
+
967
+ if (verdict.action === 'deny') return emitDeny(verdict.reason, verdict.suggestion);
968
+ if (verdict.action === 'warn') return emitWarn(verdict.note);
969
+ return emitAllow();
970
+ }
971
+
972
+ // ---------------------------------------------------------------------------
973
+ // Self-execution guard (§ Import safety).
974
+ //
975
+ // `process.argv[1]` carries the path as passed (symlink-bearing under a
976
+ // symlinked plugin install) while `import.meta.url` is realpath-resolved by
977
+ // node's default loader, so BOTH sides are realpath'd — the same comparison
978
+ // `hooks/post-bash-write-verify.mjs` documents (#938 MED-2).
979
+ // ---------------------------------------------------------------------------
980
+ function invokedAsScript() {
981
+ const entry = process.argv[1];
982
+ if (!entry) return false;
983
+ const self = fileURLToPath(import.meta.url);
984
+ try {
985
+ return realpathSync(entry) === realpathSync(self);
986
+ } catch {
987
+ // argv[1] unresolvable (deleted/renamed mid-run) — best-effort raw compare.
988
+ return entry === self;
989
+ }
990
+ }
991
+
992
+ if (invokedAsScript()) {
993
+ // Row 1 of the matrix: exit 0 immediately (silent no-op) when disabled (#211).
994
+ if (!shouldRunHook(HOOK_NAME)) process.exit(0);
995
+
996
+ // -------------------------------------------------------------------------
997
+ // TWO distinct failure classes, two distinct handlers — do NOT merge them:
998
+ //
999
+ // 1. LOAD failure (`bootstrap()` throws — matrix row 2): the guard never
1000
+ // armed. Under the exit-0 protocol a bare exit-1 crash with 0 bytes of
1001
+ // stdout is, on the only decision-bearing channel, indistinguishable from
1002
+ // an allow. Exit 0 (still fail-OPEN — a broken module must not brick the
1003
+ // session, and `emitAllow` itself may be the symbol that failed to load)
1004
+ // but SAY SO: GUARD INACTIVE. Banner-only — no headFallback module here.
1005
+ // 2. RUNTIME failure inside `main()` (matrix row 12): the guard armed and then
1006
+ // tripped. This hook fails OPEN here, which is the deliberate INVERSION of
1007
+ // `enforce-scope`'s fail-closed handler — and the reason is the asymmetry
1008
+ // named in the matrix header: enforce-scope guards a WRITE (denying one
1009
+ // write is cheap), this guards the DISPATCH path (denying every dispatch
1010
+ // is a session outage). The bug this could hide is caught downstream by
1011
+ // `validate-wave-scope.mjs` and by `enforce-scope` at write time; a
1012
+ // wrongly-denied dispatch is caught by nothing.
1013
+ // -------------------------------------------------------------------------
1014
+ try {
1015
+ await bootstrap();
1016
+ } catch (loadError) {
1017
+ try {
1018
+ const { emitGuardInactiveBanner } = await import('./_lib/guard-source-loader.mjs');
1019
+ // hookName is threaded explicitly (#993 — no hard-wired literal in the loader).
1020
+ emitGuardInactiveBanner({ hookName: HOOK_NAME, error: loadError, consequence: GUARD_CONSEQUENCE });
1021
+ } catch {
1022
+ // Last resort: even the banner helper failed to load. Emit unconditionally —
1023
+ // repeated noise beats a silent disarm.
1024
+ process.stderr.write(
1025
+ `🚨 ${HOOK_NAME}: GUARD INACTIVE — module load failed ` +
1026
+ `(${String(loadError?.message || loadError).split('\n')[0]}). ` +
1027
+ 'Pre-dispatch scope-disjointness checking is OFF. See issue #993.\n'
1028
+ );
1029
+ }
1030
+ process.exit(0); // fail-open, but no longer fail-silent
1031
+ }
1032
+
1033
+ main().catch((e) => {
1034
+ try {
1035
+ process.stderr.write(
1036
+ `⚠ ${HOOK_NAME}: internal hook error — dispatch ALLOWED unchecked ` +
1037
+ `(${String(e?.message ?? e).split('\n')[0]})\n`
1038
+ );
1039
+ } catch { /* stderr may be closed; the allow below is the decision */ }
1040
+ emitAllow();
1041
+ });
1042
+ }