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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/rules/000-session-orchestrator.mdc +3 -2
- package/.cursor/rules/030-wave-execution.mdc +10 -8
- package/.cursor/rules/040-discovery.mdc +6 -6
- package/.cursor/rules/050-plan.mdc +8 -8
- package/CHANGELOG.md +515 -0
- package/README.md +16 -11
- package/agents/analyst.md +1 -1
- package/agents/architect-reviewer.md +1 -1
- package/agents/code-implementer.md +4 -2
- package/agents/db-specialist.md +1 -1
- package/agents/dialectic-deriver.md +1 -1
- package/agents/docs-writer.md +1 -1
- package/agents/memory-proposal-collector.md +7 -5
- package/agents/qa-strategist.md +1 -1
- package/agents/security-reviewer.md +1 -1
- package/agents/session-reviewer.md +42 -1
- package/agents/skill-applied-judge.md +1 -1
- package/agents/test-writer.md +1 -1
- package/agents/ui-developer.md +1 -1
- package/agents/ux-evaluator.md +1 -1
- package/commands/eli5.md +33 -0
- package/commands/release.md +62 -0
- package/commands/test.md +2 -2
- package/docs/components.md +6 -5
- package/docs/migration-v3.md +9 -6
- package/docs/persona-panel.md +3 -1
- package/docs/scope-collision-guard.md +167 -0
- package/docs/session-config-reference.md +31 -8
- package/hooks/_lib/lock-bootstrap.mjs +19 -13
- package/hooks/enforce-scope.mjs +103 -3
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +21 -1
- package/hooks/on-session-end.mjs +76 -97
- package/hooks/on-session-start.mjs +195 -104
- package/hooks/on-stop.mjs +127 -12
- package/hooks/post-bash-write-verify.mjs +8 -32
- package/hooks/pre-auq-clarity.mjs +787 -0
- package/hooks/pre-bash-issue-budget.mjs +17 -18
- package/hooks/pre-task-scope-disjoint.mjs +1042 -0
- package/package.json +3 -1
- package/pi/prompts/eli5.md +12 -0
- package/pi/prompts/release.md +12 -0
- package/scripts/auq-audit.mjs +825 -0
- package/scripts/autopilot.mjs +10 -9
- package/scripts/emit-session.mjs +42 -0
- package/scripts/export-hw-learnings.mjs +61 -2
- package/scripts/lib/auq/clarity.mjs +1314 -0
- package/scripts/lib/auq/parse.mjs +1006 -0
- package/scripts/lib/auq/schema.mjs +1457 -0
- package/scripts/lib/autopilot/worktree-pipeline.mjs +5 -5
- package/scripts/lib/backlog-scan.mjs +106 -15
- package/scripts/lib/build-live-signals.mjs +7 -3
- package/scripts/lib/ci-status-banner.mjs +267 -77
- package/scripts/lib/config/dispatcher-autonomy-capture.mjs +32 -9
- package/scripts/lib/config/vault-integration.mjs +12 -1
- package/scripts/lib/dispatcher/rank.mjs +4 -7
- package/scripts/lib/gates/gate-full.mjs +3 -3
- package/scripts/lib/gates/gate-helpers.mjs +17 -6
- package/scripts/lib/git-config-drift.mjs +471 -0
- package/scripts/lib/harness-audit/categories/category6.mjs +65 -12
- package/scripts/lib/io.mjs +432 -7
- package/scripts/lib/issue-budget.mjs +63 -9
- package/scripts/lib/learnings/select.mjs +157 -3
- package/scripts/lib/memory-cleanup-stamp.mjs +132 -8
- package/scripts/lib/mirror-issues-banner.mjs +266 -0
- package/scripts/lib/named-vault-resolver.mjs +105 -16
- package/scripts/lib/owner-interview.mjs +78 -32
- package/scripts/lib/peer-cards/schema.mjs +6 -2
- package/scripts/lib/peer-discovery.mjs +73 -22
- package/scripts/lib/project-hygiene.mjs +64 -4
- package/scripts/lib/reconcile/renderer.mjs +17 -4
- package/scripts/lib/reconcile/writer.mjs +69 -30
- package/scripts/lib/redact-spans.mjs +89 -0
- package/scripts/lib/resource-probe/evaluate.mjs +330 -149
- package/scripts/lib/resource-probe/probe-platform.mjs +35 -0
- package/scripts/lib/resource-probe.mjs +18 -2
- package/scripts/lib/scope-baseline.mjs +77 -17
- package/scripts/lib/scope-gate.mjs +658 -0
- package/scripts/lib/secret-masker.mjs +262 -0
- package/scripts/lib/session-lock.mjs +34 -10
- package/scripts/lib/session-registry.mjs +9 -1
- package/scripts/lib/spiral-carryover.mjs +23 -2
- package/scripts/lib/state-md/mission-status.mjs +164 -58
- package/scripts/lib/tmux-layout/vcs-detector.mjs +108 -4
- package/scripts/lib/validate/check-agents.mjs +77 -5
- package/scripts/lib/validate/check-auq-clarity.mjs +274 -0
- package/scripts/lib/validate/check-commands.mjs +2 -20
- package/scripts/lib/validate/check-doc-cli-commands.mjs +514 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +48 -0
- package/scripts/lib/validate/check-owner-leakage.mjs +185 -17
- package/scripts/lib/validate/check-rules.mjs +153 -9
- package/scripts/lib/validate/check-skills.mjs +191 -0
- package/scripts/lib/validate/check-test-git-config-target.mjs +665 -0
- package/scripts/lib/validate/check-unicode-safety.mjs +22 -2
- package/scripts/lib/validate/check-untracked-test-deps.mjs +925 -0
- package/scripts/lib/validate/check-unwired-features.mjs +219 -11
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +965 -0
- package/scripts/lib/validate/frontmatter-block.mjs +61 -0
- package/scripts/lib/validate/tier-inference.mjs +46 -8
- package/scripts/lib/vault-backfill/glab.mjs +91 -58
- package/scripts/lib/vault-backfill/manifest.mjs +28 -8
- package/scripts/lib/vault-mirror/namespace.mjs +146 -1
- package/scripts/lib/vault-mirror/process.mjs +264 -31
- package/scripts/lib/vault-mirror/render-sessions.mjs +115 -4
- package/scripts/lib/vault-status/board-writer.mjs +300 -56
- package/scripts/lib/vault-status/narrative-mirror.mjs +119 -5
- package/scripts/lib/vcs-repo-spec.mjs +680 -30
- package/scripts/lib/wave-resource-gate.mjs +67 -73
- package/scripts/materialize-wave-scope.mjs +281 -0
- package/scripts/print-learnings-index.mjs +30 -3
- package/scripts/release.mjs +983 -107
- package/scripts/run-quality-gate.mjs +14 -0
- package/scripts/site-numbers.mjs +1049 -0
- package/scripts/validate-plugin.mjs +64 -0
- package/scripts/validate-wave-scope.mjs +286 -12
- package/scripts/vault-backfill.mjs +32 -5
- package/scripts/vault-mirror.mjs +26 -1
- package/skills/_shared/monitor-patterns.md +24 -4
- package/skills/_shared/parallel-aware-auq.md +30 -24
- package/skills/_shared/parallel-aware-preamble.md +31 -2
- package/skills/_shared/state-ownership.md +49 -6
- package/skills/bootstrap/SKILL.md +2 -1
- package/skills/brainstorm/SKILL.md +18 -18
- package/skills/brainstorm/soul.md +12 -0
- package/skills/claude-md-drift-check/SKILL.md +9 -1
- package/skills/debug/SKILL.md +4 -1
- package/skills/discovery/SKILL.md +28 -24
- package/skills/discovery/issue-templates.md +4 -4
- package/skills/discovery/probes-code.md +2 -2
- package/skills/discovery/probes-feature.md +6 -6
- package/skills/discovery/probes-infra.md +2 -2
- package/skills/discovery/probes-session.md +5 -5
- package/skills/dispatcher/SKILL.md +10 -1
- package/skills/eli5/SKILL.md +43 -0
- package/skills/evolve/SKILL.md +8 -9
- package/skills/frontmatter-guard/SKILL.md +9 -1
- package/skills/gitlab-ops/SKILL.md +73 -59
- package/skills/gitlab-portfolio/SKILL.md +10 -1
- package/skills/grill/SKILL.md +6 -6
- package/skills/grill/soul.md +16 -0
- package/skills/memory-cleanup/SKILL.md +20 -7
- package/skills/npm-publish/SKILL.md +23 -51
- package/skills/peekaboo-driver/SKILL.md +3 -3
- package/skills/persona-panel/SKILL.md +3 -1
- package/skills/plan/SKILL.md +18 -16
- package/skills/plan/mode-feature.md +1 -1
- package/skills/plan/mode-new.md +42 -12
- package/skills/plan/soul.md +12 -0
- package/skills/reconcile/SKILL.md +3 -3
- package/skills/repo-audit/SKILL.md +10 -1
- package/skills/session-end/SKILL.md +97 -22
- package/skills/session-end/metrics-collection.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +37 -2
- package/skills/session-end/session-metrics-write.md +4 -10
- package/skills/session-plan/SKILL.md +2 -2
- package/skills/session-plan/wave-template.md +1 -1
- package/skills/session-start/SKILL.md +82 -36
- package/skills/session-start/phase-2-5-docs-planning.md +8 -8
- package/skills/session-start/phase-4-5-resource-health.md +82 -19
- package/skills/session-start/soul.md +110 -0
- package/skills/spinout/SKILL.md +5 -1
- package/skills/sunset-review/SKILL.md +11 -1
- package/skills/test-runner/SKILL.md +2 -2
- package/skills/tmux-layout/SKILL.md +7 -2
- package/skills/using-orchestrator/SKILL.md +1 -1
- package/skills/vault-mirror/SKILL.md +10 -1
- package/skills/vault-sync/SKILL.md +10 -1
- package/skills/vault-sync/validator.mjs +55 -6
- package/skills/wave-executor/wave-loop.md +64 -12
- package/skills/write-executable-plan/SKILL.md +6 -6
- package/scripts/lib/mission-status-schema.mjs +0 -114
- package/scripts/tests/fixtures/fetch-baseline/sample-rule.md +0 -8
- package/skills/vault-sync/tests/fixtures/archive-test-vault/90-archive/bad-archived.md +0 -8
- package/skills/vault-sync/tests/fixtures/archive-test-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/archive-test-vault/live-note.md +0 -8
- package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/bad-type.md +0 -8
- package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/good-note.md +0 -8
- package/skills/vault-sync/tests/fixtures/clean-vault/.obsidian/config.md +0 -8
- package/skills/vault-sync/tests/fixtures/clean-vault/01-projects/foo/projects-baseline.md +0 -10
- package/skills/vault-sync/tests/fixtures/clean-vault/03-daily/daily-2026-04-13.md +0 -8
- package/skills/vault-sync/tests/fixtures/clean-vault/README.md +0 -3
- package/skills/vault-sync/tests/fixtures/clean-vault/hello-world.md +0 -11
- package/skills/vault-sync/tests/fixtures/dangling-link-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/dangling-link-vault/has-dangling.md +0 -9
- package/skills/vault-sync/tests/fixtures/dangling-link-vault/real-target.md +0 -8
- package/skills/vault-sync/tests/fixtures/empty-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/missing-field-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/missing-field-vault/missing-id.md +0 -7
- package/skills/vault-sync/tests/fixtures/nested-tag-vault/03-daily/daily-2026-04-13.md +0 -9
- package/skills/vault-sync/tests/fixtures/nested-tag-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/nested-tag-vault/nested-tags-note.md +0 -11
- package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/README.md +0 -3
- package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_MOC.md +0 -3
- package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/with-moc-vault/_MOC.md +0 -11
- package/skills/vault-sync/tests/fixtures/with-moc-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/with-moc-vault/hello-world.md +0 -11
- 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
|
+
}
|