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,167 @@
1
+ # Scope-Collision Guard — Pre-Dispatch File-Scope Deconfliction
2
+
3
+ > Reference for the mechanism that stops a wave from handing the SAME file to two agents (issue #1020).
4
+ > Five moving parts: `scripts/materialize-wave-scope.mjs` (the canonical declaration writer), the per-agent scope files, `scripts/validate-wave-scope.mjs` (`--assert-disjoint` / `--union`), `findScopeCollisions()` + `unionFileScopes()` in [`scripts/lib/scope-gate.mjs`](../scripts/lib/scope-gate.mjs), and the `PreToolUse` hook [`hooks/pre-task-scope-disjoint.mjs`](../hooks/pre-task-scope-disjoint.mjs).
5
+ > The coordinator-side **runbook** is `skills/wave-executor/wave-loop.md` § Scope Manifest 3.1–3.3 — this document does not restate it. What lives here instead: how the mechanism works, how it fails, what it deliberately does not see, and how to debug it.
6
+
7
+ ## 1. What the pre-existing gates could not see
8
+
9
+ `assertFileScopeSubset()` (#796) checks each agent's scope **⊆** the wave's `allowedPaths` union, and `wave-scope-commit-guard` checks writes against that same union. A file claimed by two agents is a subset **twice over**, and the union grants it exactly once — so a double assignment is structurally invisible to both. It surfaced only afterwards, from an agent's own PSA-002 report (`findScopeCollisions()` header: `tests/scripts/sweep-expired-learnings-cli.test.mjs` handed to two agents of one wave). Per `.claude/rules/parallel-sessions.md` § Decision Tree, a file inside two declared scopes of one dispatch round is never a benign sibling signal — it is a deconfliction gap.
10
+
11
+ Two things follow, and both are the point of #1020:
12
+
13
+ - `allowedPaths` is **computed** from the per-agent declarations (`unionFileScopes()`), not transcribed by hand.
14
+ - Disjointness is asserted on the **declarations**, before the union exists.
15
+
16
+ ## 2. The chain, in order
17
+
18
+ | # | Step | Artefact | Mechanism |
19
+ |---|------|----------|-----------|
20
+ | 1 | Materialize declarations | `<state-dir>/filescopes/wave-<N>/<agent-id>.json` (one per agent, plus `coordinator.json`) and `<state-dir>/filescopes/wave-<N>.scopes.json` | one canonical `[{id, files}, …]` stdin array → `materialize-wave-scope.mjs` |
21
+ | 2 | Assert disjointness | the materialized sidecar array `[{id, files}, …]` | `validate-wave-scope.mjs --assert-disjoint` → `findScopeCollisions()` |
22
+ | 3 | Compute the union | stdout of `--union` → `allowedPaths` | `expandTestSiblings(unionFileScopes(scopes), { role })` |
23
+ | 4 | Inject | `FILE-SCOPE — exactly these:` + a fenced block in each agent prompt | the per-agent file from step 1 |
24
+ | 5 | Dispatch | `.orchestrator/wave-dispatch-scopes.json` (ledger) | `hooks/pre-task-scope-disjoint.mjs`, `PreToolUse` matcher `Agent` |
25
+
26
+ `<state-dir>` is the first of `.pi` / `.cursor` / `.codex` / `.claude` that carries a `wave-scope.json` — the same precedence `findScopeFile()` and the hook's `waveKeyOf()` use.
27
+
28
+ ### 2.1 Why `--union` runs last
29
+
30
+ A union computed over colliding scopes **launders the defect into the artefact meant to prevent it**: `allowedPaths` then grants the contested file, and every later gate — `--assert-subset`, `enforce-scope` Gate 7, the commit guard — sees a perfectly legal write. `validate()` in `validate-wave-scope.mjs` enforces the order in code: `--assert-subset` → `--assert-disjoint` → `--union`, and `--union` returns early because it is a QUERY MODE that replaces the manifest echo on stdout.
31
+
32
+ The same ordering argument applies one level up: step 2 runs on the **declared** scopes, before step 3 expands test siblings. See § 6 for the limit that buys.
33
+
34
+ ### 2.2 Why the scope files are not temp files
35
+
36
+ Steps 1, 2, 3, 4 and the `--assert-subset` assertion all read the *same* file, addressed by wave and agent id. A `$TMPDIR` copy is the one failure in this chain that **costs no error**: the injector finds nothing, no `FILE-SCOPE` block reaches the prompt, `extractScopeFromPrompt()` returns `[]`, and the hook allows the dispatch exactly as it did before #1020 — signal-free (matrix rows 5/6 below). The scope files are control state like `wave-scope.json` itself, never a wave territory; writing them legitimately trips `bash-write-verify` once per wave rollover, and widening `allowedPaths` to silence that would grant agents write access to the deconfliction record.
37
+
38
+ The coordinator's **own** planned direct edits belong in `coordinator.json` in the identical form. They are not dispatches, so the hook can never see them (§ 6); the CLI check is the only gate that covers them.
39
+
40
+ ## 3. The collision algorithm
41
+
42
+ `findScopeCollisions(agentScopes, { knownFiles })` compares every cross-agent entry pair through `classifyEntryCollision()`, in three binding stages:
43
+
44
+ 1. **Exact string equality** → kind `concrete`. The commonest real case, and the only stage that works for a file that **does not exist yet**.
45
+ 2. **Concrete vs glob** via `pathMatchesPattern(concrete, glob)` → kind `concrete`. Exact and I/O-free. Two *distinct concrete* paths are disjoint and return immediately.
46
+ 3. **Glob ∩ glob**, in two sub-stages:
47
+ - **3a — witness:** expand both entries against `KNOWN = knownFiles ∪ {every concrete entry of every agent}`; a non-empty intersection is `glob-expanded`. The second half of that union matters — a file the wave is about to *create* is not in `git ls-files`, but a concrete claim by one agent can still witness another's glob.
48
+ - **3b — prefix fallback:** for the intersection that exists only in files not yet on disk. Requires literal-prefix containment in either direction, at least one **recursive** entry (`**`, or a trailing `/`, which `pathMatchesPattern` matches by `startsWith` at any depth), and compatible literal suffixes. The suffix filter is a necessary condition, so it adds no false negative while removing `scripts/**/*.ts` vs `scripts/**/*.mjs`.
49
+
50
+ `knownFiles` is **injected, never discovered**: `scope-gate.mjs` is hook-safe (pure, sync, no I/O, no spawn) because `enforce-scope.mjs` reaches it on a hot path, and under the exit-0/stdout-JSON protocol a throw there reads as "no decision" = ALLOW. The CLI spawns `git ls-files` in `knownRepoFiles()`; the hook does the same in `listTrackedFiles()`, both resolving `git rev-parse --show-toplevel` first so a session started in a subdirectory produces repo-relative paths on both sides.
51
+
52
+ Duplicate agent ids are reported separately (`duplicateIds`), not as a self-collision. A record with no usable id runs as `<unnamed#i>` rather than being dropped — an unreviewed scope is exactly the one that collides.
53
+
54
+ ### 3.1 Why `pathMatchesPattern` alone cannot do stage 3
55
+
56
+ The matcher is **directed**: argument 2 is compiled into a regex, argument 1 is tested as a literal string. Measured in this working tree on 2026-08-14:
57
+
58
+ ```
59
+ $ node --input-type=module -e "import { pathMatchesPattern } from './scripts/lib/scope-gate.mjs';
60
+ console.log(pathMatchesPattern('scripts/**/*.mjs','scripts/lib/*.mjs'));
61
+ console.log(pathMatchesPattern('scripts/lib/x.mjs','scripts/**/*.mjs'),
62
+ pathMatchesPattern('scripts/lib/x.mjs','scripts/lib/*.mjs'));"
63
+ false
64
+ true true
65
+ ```
66
+
67
+ Both globs match `scripts/lib/x.mjs`, yet the direct comparison says `false`. For `assertFileScopeSubset()` that inexactness is *safe*: its glob branch reduces to verbatim presence plus literal-prefix coverage and therefore **over-approximates coverage**, which at worst accepts a union it could not fully prove. For a **collision** check the sign flips — the same over-approximation becomes a **false negative**, i.e. a missed collision, i.e. the incident. That is why the two exact stages decide first and stage 3 is reached only for pairs neither can settle.
68
+
69
+ ## 4. The hook
70
+
71
+ `hooks/pre-task-scope-disjoint.mjs` is a `PreToolUse` hook on matcher **`Agent`** (registered in `hooks/hooks.json`). It blocks a wave from handing the same file to two agents at the moment of dispatch, before either has written a byte.
72
+
73
+ It cannot compare a batch of siblings directly — the header records why, measured against 12 archived transcripts of this repo (147 dispatch `tool_use` blocks, 51 batches, measured 2026-08-14): the dispatch tool is named `Agent` and not `Task`; the payload carries **no** structured file scope (`files` / `file_scope` / `scope` → 441 probes, zero hits), so the scope exists only as prose inside `prompt`; and the not-yet-dispatched siblings of a batch are not visible in the transcript at dispatch time. What remains is a **ledger**: each dispatch records its scope under a wave key, and the next dispatch is checked against everything already recorded.
74
+
75
+ - **Ledger:** `.orchestrator/wave-dispatch-scopes.json` (gitignored), keyed `<session-id>|w<wave>|<role>` from `<state-dir>/wave-scope.json`.
76
+ - **Lock:** `.orchestrator/wave-dispatch-scopes.lock` — the read-modify-write cycle runs under `withFileLock()` (the primitive behind the PSA-005 STATE.md lock). `writeJsonAtomicSync` makes the write atomic, never the *cycle*.
77
+ - **Scope extraction:** `extractScopeFromPrompt()` finds a scope marker line and takes the FIRST fenced block after it, accepting only lines that survive a deliberately strict `looksLikeRepoPath()`. `normalizeScopeEntry()` then folds `./`, `//` and `/./` spellings together, and `promoteDirEntries()` rewrites `scripts/lib` → `scripts/lib/` **on evidence** (not a tracked file itself, at least one tracked file beneath it) — both because two spellings of one path previously compared as disjoint.
78
+ - **DENY** fires on exactly one condition: a collision involving THIS dispatch with a prior agent of the same wave that is **still in flight** (§ 5). The reason names the agent pair, the collision kind and the evidence paths; the suggestion is "give the file exactly ONE owner, or wait for the named agent(s) and re-dispatch", plus the ledger path to delete if the state is stale. The ledger is deliberately **not** persisted on a deny — the dispatch did not happen, so recording it would make the retry-after-fix look like a duplicate.
79
+
80
+ ### 4.1 Error-class matrix — deliberately fail-**open**
81
+
82
+ The blast radius is asymmetric. A false positive on the dispatch path blocks every agent of the session — the guard becomes a session outage, and nothing downstream catches a dispatch that never happened. A false negative is a double assignment that three later gates still catch (`validate-wave-scope.mjs`, `enforce-scope.mjs` at write time, the W5 verification pass). Fail-closed is right for a WRITE guard (`enforce-scope.mjs` is), and wrong here. Each row is a choice, not an oversight:
83
+
84
+ | # | Condition | Decision |
85
+ |---|-----------|----------|
86
+ | 1 | disabled via profile/env | exit 0, silent |
87
+ | 2 | repo module failed to load | ALLOW + `GUARD INACTIVE` banner on stderr |
88
+ | 3 | stdin empty / not JSON | ALLOW |
89
+ | 4 | `tool_name` is not `Agent` | ALLOW |
90
+ | 5 | prompt carries no scope marker | ALLOW |
91
+ | 6 | scope block present but unparseable | ALLOW |
92
+ | 7 | ledger unreadable / corrupt | WARN + ALLOW + **self-heal** (the verdict carries a fresh ledger) |
93
+ | 8 | `git ls-files` failed | ALLOW, degraded (stage 3a loses witnesses; concrete collisions still found) |
94
+ | 9 | `findScopeCollisions` not evaluable | WARN + ALLOW |
95
+ | 10 | same agent id re-dispatched | ALLOW, ledger record replaced (a retry must not self-lock) |
96
+ | 10a | collision, but every colliding prior agent has FINISHED | ALLOW + prune those records |
97
+ | 11 | collision with a prior agent still IN FLIGHT | **DENY** |
98
+ | 12 | unexpected throw in `main()` | ALLOW + stderr |
99
+ | 13 | liveness probe throws / no evidence at all | treated as IN FLIGHT (bounded by the TTL, § 5) |
100
+ | 14 | ledger lock not acquirable within its budget | run the cycle UNLOCKED (degraded), never deny |
101
+
102
+ Two structural rules keep this matrix honest, both recorded in the hook header:
103
+
104
+ - `decide()` is a **pure function returning a verdict**; the module emits exactly once, at the end. `emitWarn`/`emitDeny` call `process.exit(0)` and never return, so a warn emitted from inside the checking flow would terminate the process before a later collision could be denied — and would skip the lock's release `finally`.
105
+ - Row 9's discriminator is **not** `ok !== true`. `ok` means *disjoint*, so `ok === false` is the normal result of a real collision; "not evaluable" is `ok === false` with BOTH result arrays empty. Reading `ok` as evaluability would turn every genuine collision into a warn, i.e. an allow — the exact fail-open the hook exists to prevent.
106
+
107
+ ## 5. The liveness probe
108
+
109
+ A ledger with no notion of completion denies the wrong thing. Measured over 38 archived transcripts of this repo (346 `Agent` dispatch blocks; hook header, 2026-08-14): 0 of 4 same-batch overlaps and **2 of 2 cross-dispatch overlaps** would have been denied — and both cross-dispatch pairs were legitimate **sequential repair passes** (a dispatch and its later fix). Because a deny deliberately does not persist the ledger, the re-dispatch would have met the same stale record: a permanent block.
110
+
111
+ The discriminator is therefore neither time nor the agent's name, but whether the recorded agent is **still in flight**. Two transcript shapes carry that:
112
+
113
+ - **Synchronous dispatch** — the `tool_result` for the dispatch's `tool_use` id arrives when the agent is done (measured: five `Agent` rows within 0.44 s, their results 5–11 minutes later). At the fifth agent's `PreToolUse` none of the first four has a result → all in flight → a real same-batch overlap still denies.
114
+ - **Asynchronous dispatch** — the `tool_result` arrives in ~0.2 s and reads `Async agent launched successfully`. **That text is a launch receipt, not a completion.** Treating it as one would let every real background-batch collision through. The completion arrives later as a `<task-notification>` record carrying `<tool-use-id>` and `<status>completed</status>` (measured: launch 14:14:26.768 → notification 14:24:39.360).
115
+
116
+ `buildTranscriptIndex()` reads all three record shapes and counts a description as finished only when **every** one of its dispatch ids is finished. Cost containment: the transcript is read **only once a collision has already been found** — i.e. only on the path that is about to deny; the no-collision path pays nothing. Transcripts above 256 MiB are treated as *no evidence*, never as a completion.
117
+
118
+ **Blind fallback and its named ceiling (BV-004).** With no transcript, or none carrying a record of that agent, liveness falls back to the ledger entry's own age with `IN_FLIGHT_TTL_MS = 30 min`. The ceiling is derived: the largest **measured** same-batch dispatch spread is 95.7 s, so 30 min is ~19× headroom against the false-ALLOW direction, while both measured sequential repair gaps (36 min, 49 min) sit above it. **Revisit trigger:** a same-batch spread above ~5 min appearing in `.orchestrator/metrics/`, or a harness change that stops writing `transcript_path` — either invalidates the headroom the number rests on.
119
+
120
+ ## 6. Named limits
121
+
122
+ Complete list of what this guard does **not** see, or sees only approximately:
123
+
124
+ 1. **The blind TTL window.** Without transcript evidence the only liveness signal is the 30-minute TTL above. Inside that window a finished agent still blocks (false deny, recoverable by deleting the ledger); outside it a running agent no longer blocks (false allow). Revisit trigger as stated in § 5.
125
+ 2. **Test-sibling collisions.** Disjointness is asserted on the **declared** scopes (step 2), before `expandTestSiblings()` runs (step 3). Two agents whose production files share a basename receive the *same* emitted sibling glob (`tests/**/{basename}*.test.mjs`), which a declared-scope check cannot see. Revisit if a wave is ever scoped by basename family instead of by directory.
126
+ 3. **Prose extraction fails only toward ALLOW.** The hook's only channel is the `FILE-SCOPE` prose block. A missing marker, a missing fence, a decorated path that fails `looksLikeRepoPath()` — all resolve to allow (rows 5/6). Measured: 42 of 147 archived prompts (28.6 %) carried a scope marker at all, so denying the non-extractable case would have denied ~7 dispatches in 10. The CLI check (step 2) is the gate that does not depend on prose.
127
+ 4. **Coordinator-direct edits are invisible to the hook.** They are not dispatches, so no ledger entry exists for them. They participate in the CLI check via `coordinator.json` only — and 2 of the 5 divergences that motivated #1020 were coordinator-direct edits.
128
+ 5. **The wave-key fallback.** With no readable `wave-scope.json`, `waveKeyOf()` degrades to `<session>|w?|?` and the ledger spans the whole session, so a wave-3 dispatch is compared against wave-1 records. Bounded, not eliminated, by the liveness probe: a prior record binds only while its agent is in flight.
129
+ 6. **Glob ∩ glob without witnesses.** Stage 3a needs tracked files; with git unavailable (row 8) or for files not yet on disk, only stage 3b's prefix fallback carries the load — and it requires at least one recursive entry, so two non-recursive globs that intersect only in an unborn file are not detected.
130
+ 7. **Only collisions involving the current dispatch are actionable.** A pair among already-dispatched agents was either denied at its own dispatch or predates the guard; re-denying it would block an innocent third agent.
131
+ 8. **Lock loss reopens the race.** On lock timeout the cycle runs unlocked (row 14) — two dispatches starting together can then read the same ledger state and one record is lost. That is the pre-lock behaviour, chosen over denying on a lock-file problem.
132
+
133
+ ## 7. Debugging
134
+
135
+ **A dispatch was denied and you do not believe it.** Read `.orchestrator/wave-dispatch-scopes.json`: it carries `waveKey`, `updated`, and one `{id, desc, files, at}` record per already-dispatched agent. The deny reason names the other agent — find its record and compare its `files` to the ones in your prompt's `FILE-SCOPE` block. Three outcomes:
136
+
137
+ - The other agent is genuinely running and the overlap is real → fix the ownership in the session plan (one file, one agent), rewrite the affected `filescopes/wave-<N>/*.json`, re-assert, re-dispatch.
138
+ - The other agent has finished, but the ledger still binds it → the transcript carried no evidence (§ 5) and you are inside the TTL window. Delete `.orchestrator/wave-dispatch-scopes.json`; the next dispatch rebuilds it.
139
+ - The `waveKey` names an older wave → the `<session>|w?|?` fallback (limit 5). Check that `<state-dir>/wave-scope.json` exists and is readable, then delete the ledger.
140
+
141
+ **A dispatch was NOT denied and should have been.** Work down the allow rows: is `FILE-SCOPE` present in the prompt with a fenced block right after it (rows 5/6)? Is the hook armed at all (`GUARD INACTIVE` on stderr = row 2)? Did a `systemMessage` warning appear (rows 7/9)? Cross-check the same scopes through the CLI, which does not depend on prose:
142
+
143
+ ```bash
144
+ node scripts/validate-wave-scope.mjs --assert-disjoint "$WAVE_SCOPES_SIDECAR" < <state-dir>/wave-scope.json
145
+ ```
146
+
147
+ Exit 1 prints one `ERROR:` line per collision (`agents "A" and "B" both claim [...]`) and one per duplicate id. Exit 0 — nothing on stderr, the manifest echoed back on stdout — means the declared scopes really are disjoint and the hook was right to allow; the divergence is then in the prompt, not in the plan.
148
+
149
+ **Reset.** Delete `.orchestrator/wave-dispatch-scopes.json` (and `.orchestrator/wave-dispatch-scopes.lock` if a dead holder is suspected). Both are gitignored, both are rebuilt on the next dispatch, and neither is shared with any other mechanism. After the final wave, `<state-dir>/filescopes/` is deleted along with `wave-scope.json` — a stale `wave-<N>/` directory left behind is a scope claim nobody re-verified.
150
+
151
+ **Disable.** The hook honours the repo's profile gate (`shouldRunHook('pre-task-scope-disjoint')`, row 1) — a silent exit 0, no decision at all.
152
+
153
+ ## 8. Provenance of the numbers
154
+
155
+ Every figure above is quoted from a measurement recorded next to the code that carries it, with its date:
156
+
157
+ - Transcript-shape figures (147 dispatch blocks / 12 transcripts / 51 batches / 441 zero-hit probes / 28.6 % marker coverage; 346 blocks / 38 transcripts for liveness; the 95.7 s, 36 min and 49 min spreads; the 0.44 s and 10-minute observations) — header of `hooks/pre-task-scope-disjoint.mjs`, measured 2026-08-14 against this project's archived transcripts.
158
+ - The directedness transcript in § 3.1 — run in this working tree on 2026-08-14; the command is printed with it.
159
+
160
+ Re-measure before citing any of these downstream. A count re-briefed later is a claim about the past (`.claude/rules/parallel-sessions.md` § PSA-006).
161
+
162
+ ## See Also
163
+
164
+ - `skills/wave-executor/wave-loop.md` § Scope Manifest — the coordinator runbook (steps 3.1–3.3) and § Pre-Dispatch: File-Scope Injection (the prompt block shape).
165
+ - `.claude/rules/parallel-sessions.md` § Decision Tree (why a file in two declared scopes is never a benign sibling signal), § PSA-006 (measurement discipline).
166
+ - `hooks/enforce-scope.mjs` — the write-time gate, fail-**closed**; the deliberate inversion of this hook's posture.
167
+ - [`docs/adr/0011-guard-degradation-semantics.md`](adr/0011-guard-degradation-semantics.md) — the exit-0 hook protocol (#906) and why a truncated stdout envelope reads as no-decision, i.e. as ALLOW.
@@ -423,7 +423,7 @@ Introduced by Epic #157 / issue #166. Lets session-start sense the host (RAM, CP
423
423
  |-------|------|---------|-------------|
424
424
  | `resource-awareness` | boolean | `true` | Master toggle for the env-aware runtime. When `false`, skips Phase 4.5 adaptive wave sizing and the host banner. |
425
425
  | `enable-host-banner` | boolean | `true` | Whether `hooks/on-session-start.mjs` emits the host + resource banner at the top of every session. Set `false` to silence. |
426
- | `resource-thresholds` | object | see below | Numeric thresholds that drive Phase 4.5 adaptive rules. Unset sub-keys fall back to defaults. Sub-keys: `ram-free-min-gb`, `ram-free-critical-gb`, `cpu-load-max-pct`, `concurrent-sessions-warn`, `ssh-no-docker`, `zombie-threshold-min`. |
426
+ | `resource-thresholds` | object | see below | Numeric thresholds that drive Phase 4.5 adaptive rules. Unset sub-keys fall back to the single canonical default set (`DEFAULT_RESOURCE_THRESHOLDS` in `scripts/lib/resource-probe/evaluate.mjs`). Sub-keys: `ram-free-min-gb`, `ram-free-critical-gb`, `cpu-load-max-pct`, `concurrent-sessions-warn`, `ssh-no-docker`, `zombie-threshold-min`. |
427
427
 
428
428
  ### resource-thresholds
429
429
 
@@ -431,17 +431,40 @@ Sub-key defaults:
431
431
 
432
432
  ```yaml
433
433
  resource-thresholds:
434
- ram-free-min-gb: 4 # below this, cap agents-per-wave at 2
435
- ram-free-critical-gb: 2 # below this, recommend coordinator-direct
436
- cpu-load-max-pct: 80 # sustained above this, cap agents-per-wave at 2
437
- concurrent-sessions-warn: 5 # warn when host has this many Claude sessions
434
+ ram-free-min-gb: 4 # soft memory signal (see precedence below)
435
+ ram-free-critical-gb: 2 # hard memory signal coordinator-direct
436
+ cpu-load-max-pct: 90 # soft CPU signal, judged on min(1m, 5m)
437
+ concurrent-sessions-warn: 5 # soft signal at this many live peer SESSIONS
438
438
  ssh-no-docker: true # when session is over SSH, steer the plan away from Docker-based tests
439
439
  zombie-threshold-min: 30 # age (minutes) above which an idle Claude/Node process is a zombie candidate
440
440
  ```
441
441
 
442
- **`zombie-threshold-min`** (default: `30`): When set, the resource probe runs a secondary `ps` pass that counts Claude and Node processes older than this many minutes **and** with CPU% ≤ 1%. These are "zombie candidates" — stale sessions or orphaned workers that still hold RAM. The probe exposes them via `zombie_processes_count` in the snapshot. The evaluator escalates the verdict to at least `warn` when `zombie_processes_count >= 1` **and** `claude_processes_count > 0` (i.e., there are active Claude processes alongside the zombies). The reason string surfaces the threshold and count so the session-start banner gives actionable context. Set to `0` to disable zombie detection entirely (the field is omitted from the default snapshot when absent from config).
443
-
444
- Rationale: originated from the 2026-04-19 incident where 8 parallel Claude sessions on one Mac caused a hard freeze. The adaptive rules cap concurrent agent load when the host is under pressure, before a wave ever spawns subagents. See Epic #157 and Sub-Epic #158.
442
+ **No single threshold caps a wave (#1089).** A cap requires either one *hard*
443
+ signal (→ `critical`, coordinator-direct) or **two independent soft signals**
444
+ agreeing (→ `warn`, cap 2). One soft signal alone is reported and acted on by
445
+ nobody. Rationale and the measured firing rates are in
446
+ [`.claude/rules/host-resources.md`](../.claude/rules/host-resources.md); the full
447
+ rule table is in `skills/session-start/phase-4-5-resource-health.md`.
448
+
449
+ **What the memory thresholds are compared against** is chosen by precedence, not
450
+ by configuration: `memory_pressure_pct_free` (macOS, hard `<15%` / soft `<30%`)
451
+ outranks `ram_available_gb`, which outranks `ram_free_gb`. The two GB-denominated
452
+ keys above therefore apply to *available* RAM on macOS and to `os.freemem()` on
453
+ Linux/Windows, where it is accurate. They are never compared against Darwin's
454
+ `Pages free`, whose median across 1477 measured session starts was **0.4 GB** on
455
+ hosts with 24-128 GB installed — gating on it fired `ram-free-critical-gb` on
456
+ 84.0% of all starts.
457
+
458
+ **`concurrent-sessions-warn` counts SESSIONS, not processes.** The live count
459
+ comes from the session registry (`detectPeers()` — self excluded,
460
+ heartbeat-fresh). Until #1089 it was compared against `claude_processes_count`, a
461
+ measured 6x unit error (median processes:sessions = 6.0) that fired the threshold
462
+ on 93.6% of starts instead of 4.2%. When the registry is unreadable the probe
463
+ falls back to the process count rescaled by that same factor.
464
+
465
+ **`zombie-threshold-min`** (default: `30`): When set, the resource probe runs a secondary `ps` pass that counts Claude and Node processes older than this many minutes **and** with CPU% ≤ 1%. These are "zombie candidates" — stale sessions or orphaned workers that still hold RAM. The probe exposes them via `zombie_processes_count` in the snapshot. Since #1089 this is a *soft* signal: it is reported when `zombie_processes_count >= 1` **and** there is a live peer/process context, but on its own it caps nothing — sweeping stale sessions is housekeeping advice, not a reason to shrink a wave. The reason string surfaces the threshold and count so the session-start banner gives actionable context. Set to `0` to disable zombie detection entirely (the field is omitted from the default snapshot when absent from config).
466
+
467
+ Rationale: originated from the 2026-04-19 incident where 8 parallel Claude sessions on one Mac caused a hard freeze. That hazard is real and the rules still escalate for it — what #1089 changed is that they now recognise it, instead of reporting it on 99.0% of session starts where it was not happening. See Epic #157, Sub-Epic #158, and `.claude/rules/host-resources.md`.
445
468
 
446
469
  ### isolation graduation
447
470
 
@@ -15,8 +15,8 @@
15
15
  *
16
16
  * Schema v2 (Epic #583 D4 #587):
17
17
  * {
18
- * session_id: string, // semantic OR UUID whatever resolveSessionId returned
19
- * semantic_session_id: string, // ALWAYS the semantic form (closes D4)
18
+ * session_id: string, // native raw identity OR generated UUID; sole live lock/registry ownership key
19
+ * semantic_session_id: string, // attribution/history label only; never ownership
20
20
  * started_at: ISO,
21
21
  * last_heartbeat: ISO, // basis for liveness; replaces PID-liveness checks
22
22
  * mode: string, // "deep"|"feature"|"housekeeping"|"session"|...
@@ -47,10 +47,12 @@ import { writeJsonAtomicSync } from '../../scripts/lib/io.mjs';
47
47
  *
48
48
  * @param {object} opts
49
49
  * @param {string} opts.repoRoot — absolute path to the repository root.
50
- * @param {string} opts.sessionId — the resolved session id (semantic OR UUID).
51
- * @param {string} [opts.semanticSessionId] the semantic form, ALWAYS surfaced
52
- * even when sessionId is a UUID (closes D4 issue #587). When omitted, the
53
- * field is populated by mirroring sessionId.
50
+ * @param {string} opts.sessionId — the physical raw session id from the native
51
+ * harness, or a generated UUID when no trustworthy raw id exists. This is the
52
+ * only live lock/registry ownership key.
53
+ * @param {string} [opts.semanticSessionId] the semantic attribution/history
54
+ * label, surfaced separately and never used for ownership. When omitted, the
55
+ * field is populated by mirroring sessionId for backward-compatible display only.
54
56
  * @param {string} opts.mode — session mode (e.g. "deep", "feature").
55
57
  * @param {number} [opts.ttlHours=4] — lock TTL in hours.
56
58
  * @param {Function} [opts._acquireImpl] — DI for tests (defaults to importing acquire from session-lock.mjs).
@@ -88,9 +90,10 @@ export async function bootstrapLock({
88
90
 
89
91
  // Step 1: try to acquire. If a fresh acquire succeeds, we are done.
90
92
  // If a stale-PID-dead/-alive lock exists, force-overwrite it (the prior
91
- // session has died; we own the worktree now).
92
- // If the existing lock has the same sessionId, force-overwrite so the
93
- // last_heartbeat gets refreshed.
93
+ // session has died; the current raw owner can take the worktree).
94
+ // Only an exact match of the existing physical raw sessionId permits the
95
+ // same-session force-refresh. semantic_session_id, STATE.md `session`, and
96
+ // an owner proof never make a different raw id the same owner.
94
97
  let acquireResult;
95
98
  try {
96
99
  // quiet: true suppresses the unknown-mode stderr WARN in acquire() (#592 MED-2).
@@ -169,9 +172,10 @@ export async function bootstrapLock({
169
172
  // last_heartbeat is the basis for liveness — set to started_at on bootstrap
170
173
  // so an immediate liveness check (< ttl_hours from now) succeeds.
171
174
  last_heartbeat: startedAt,
172
- // semantic_session_id is ALWAYS the semantic form, even when session_id is
173
- // a UUID-v4 (closes D4 #587). Fallback to mirroring session_id if no
174
- // semantic was provided.
175
+ // semantic_session_id is an attribution/history label, normally semantic
176
+ // even when the physical session_id is a UUID-v4. Fall back to mirroring
177
+ // session_id only for backward-compatible display when no label was provided;
178
+ // it never changes the raw ownership key.
175
179
  semantic_session_id:
176
180
  typeof semanticSessionId === 'string' && semanticSessionId.length > 0
177
181
  ? semanticSessionId
@@ -190,7 +194,9 @@ export async function bootstrapLock({
190
194
  // Step 2b (#987 Part 1): persist the durable ownership proof at lock
191
195
  // genesis. `enriched` is byte-identical to the on-disk lock at this point
192
196
  // (the v2 overlay never touches pid/host/started_at), so the proof written
193
- // here will verify via isLockOwnedByProof() against any later re-read.
197
+ // here will verify via isLockOwnedByProof() against any later re-read. The
198
+ // proof is supplementary evidence only: it never bridges a raw-id mismatch
199
+ // through semantic_session_id or STATE.md `session` equality.
194
200
  // This single call covers BOTH the plain-acquire and the forceAcquire
195
201
  // branch — both flow through the enriched write above. Best-effort like
196
202
  // the surrounding breadcrumb writes: writeOwnerProof() is no-throw by
@@ -20,6 +20,12 @@
20
20
  * G7 relative path matches an allowedPaths pattern
21
21
  * G8 (all passed) → allow
22
22
  *
23
+ * Empty-allowedPaths reasoning (#1057): the VERDICT for an empty allowlist is
24
+ * unchanged (deny-all, the #256 contract), but the deny REASON is now classified
25
+ * — Discovery's read-only contract, an unreadable manifest, a crashed session's
26
+ * leftover, an incomplete `--union`, or undecidable. See
27
+ * `scripts/lib/scope-gate.mjs` § Empty-`allowedPaths` classification.
28
+ *
23
29
  * Exit codes: 0 = allow 2 = deny
24
30
  *
25
31
  * SECURITY notes (inline refs):
@@ -88,6 +94,10 @@ let findScopeFile;
88
94
  let pathMatchesPattern;
89
95
  let suggestForScopeViolation;
90
96
  let readJson;
97
+ // #1057 — empty-`allowedPaths` classification + the session clock it needs.
98
+ let classifyEmptyScope;
99
+ let suggestForEmptyScope;
100
+ let sessionStartedAtMs;
91
101
 
92
102
  const PLUGIN_ROOT = path.resolve(import.meta.dirname, '..');
93
103
 
@@ -137,6 +147,13 @@ async function bootstrap() {
137
147
  platform: { specifier: lib('platform.mjs') },
138
148
  hardening: { specifier: lib('hardening.mjs') },
139
149
  common: { specifier: lib('common.mjs') },
150
+ // #1057. Bound DIRECTLY rather than through the `hardening.mjs` barrel:
151
+ // that barrel re-exports an explicit, frozen symbol list shared by six
152
+ // hooks, and widening it for one hook's need would drag three symbols
153
+ // into five unrelated import surfaces. `hardening` already imports
154
+ // `scope-gate` transitively, so this adds no new failure mode — a broken
155
+ // scope-gate already banners GUARD INACTIVE through that edge.
156
+ scopeGate: { specifier: lib('scope-gate.mjs') },
140
157
  },
141
158
  {
142
159
  hookName: HOOK_NAME,
@@ -151,6 +168,7 @@ async function bootstrap() {
151
168
  ({ resolveProjectDir } = modules.platform);
152
169
  ({ findScopeFile, pathMatchesPattern, suggestForScopeViolation } = modules.hardening);
153
170
  ({ readJson } = modules.common);
171
+ ({ classifyEmptyScope, suggestForEmptyScope, sessionStartedAtMs } = modules.scopeGate);
154
172
  }
155
173
 
156
174
  async function main() {
@@ -183,15 +201,31 @@ async function main() {
183
201
  if (!scopePath) return emitAllow();
184
202
 
185
203
  // SECURITY-REQ-08: read scope file once; pass parsed object to all subsequent checks
204
+ //
205
+ // #1057: `parseOk` records WHETHER that read produced a usable scope RECORD.
206
+ // Both fold-to-`{}` / fold-to-`[]` paths (corrupt JSON #794 GAP-5, malformed
207
+ // shapes #558) already deny and CONTINUE to deny — the flag exists so the deny
208
+ // REASON can say "the manifest is broken" instead of "update the session plan
209
+ // and restart the wave", which is the one instruction that cannot help here.
186
210
  let scope;
211
+ let parseOk = true;
187
212
  try {
188
213
  scope = await readJson(scopePath);
214
+ if (scope === null || typeof scope !== 'object' || Array.isArray(scope)) {
215
+ parseOk = false;
216
+ scope = {};
217
+ }
189
218
  } catch {
219
+ parseOk = false;
190
220
  scope = {};
191
221
  }
192
222
 
193
223
  const enforcement = scope.enforcement ?? 'strict';
194
224
  const allowedPaths = Array.isArray(scope.allowedPaths) ? scope.allowedPaths : [];
225
+ // A PRESENT-but-non-array `allowedPaths` (#558: null / string / object) is a
226
+ // MALFORMED record, not an empty grant — same class as unparseable JSON, so it
227
+ // earns the same reason. Absent is different and stays `parseOk`.
228
+ if (scope.allowedPaths !== undefined && !Array.isArray(scope.allowedPaths)) parseOk = false;
195
229
  const gatesEnabled = scope.gates?.['path-guard'] !== false;
196
230
 
197
231
  // Gate 4: path-guard gate explicitly disabled
@@ -200,6 +234,48 @@ async function main() {
200
234
  // Gate 5: enforcement is turned off
201
235
  if (enforcement === 'off') return emitAllow();
202
236
 
237
+ // -------------------------------------------------------------------------
238
+ // #1057 — WHY is the allowlist empty?
239
+ //
240
+ // FIVE repo states produce `allowedPaths.length === 0` and the DENY IS RIGHT
241
+ // IN ALL FIVE; only the instruction differs (Discovery's read-only contract,
242
+ // a corrupt manifest, a crashed session's leftover, an incomplete `--union`,
243
+ // or genuinely undecidable). This block is therefore VERDICT-NEUTRAL: it
244
+ // selects a sentence, never a decision — `suggest()` below is the only
245
+ // consumer, and its `'unknown'` branch is byte-identical to the pre-#1057 text.
246
+ //
247
+ // Computed AFTER the early-exit gates (a disabled or `off` wave pays no
248
+ // fs.stat) and BEFORE the first deny site, so all three deny sites share one
249
+ // explanation instead of drifting apart.
250
+ // -------------------------------------------------------------------------
251
+ const emptyScopeReason =
252
+ allowedPaths.length === 0
253
+ ? classifyEmptyScope({
254
+ role: scope.role,
255
+ parseOk,
256
+ scopeMtimeMs: await mtimeMsOf(scopePath),
257
+ sessionStartMs: sessionStartedAtMs(projectRoot),
258
+ })
259
+ : null;
260
+ const scopeRelRaw = relativeFromRoot(projectRoot, scopePath);
261
+ const scopeHint = (scopeRelRaw ?? scopePath).split(path.sep).join('/');
262
+
263
+ /**
264
+ * The suggestion half of every deny below.
265
+ *
266
+ * With a NON-empty allowlist this is byte-identical to the pre-#1057 call.
267
+ * With an empty one it routes through the classifier — whose `'unknown'`
268
+ * branch delegates back to `suggestForScopeViolation(target, '')`, i.e. the
269
+ * same sentence as before. Strictly an addition.
270
+ *
271
+ * @param {string} target
272
+ * @returns {string}
273
+ */
274
+ const suggest = (target) =>
275
+ emptyScopeReason === null
276
+ ? suggestForScopeViolation(target, allowedPaths.join(', '))
277
+ : suggestForEmptyScope(target, emptyScopeReason, { role: scope.role, scopePath: scopeHint });
278
+
203
279
  // SECURITY-REQ-06: resolve relative file_path against projectRoot, not process.cwd()
204
280
  const absPathInput = path.isAbsolute(filePath)
205
281
  ? filePath
@@ -264,7 +340,7 @@ async function main() {
264
340
  // Gate 6: path must be inside the project root
265
341
  if (!isPathInside(resolvedPath, projectRoot)) {
266
342
  const reason = `Scope violation: path outside project root`;
267
- const suggestion = suggestForScopeViolation(filePath, allowedPaths.join(', '));
343
+ const suggestion = suggest(filePath);
268
344
  return enforcement === 'strict'
269
345
  ? emitDeny(reason, suggestion)
270
346
  : emitWarn(`${reason} — ${suggestion}`);
@@ -276,7 +352,7 @@ async function main() {
276
352
  // SECURITY-REQ-04: null return means outside root — deny rather than pass null to pathMatchesPattern
277
353
  if (relPath === null) {
278
354
  const reason = `Scope violation: '${filePath}' outside project root`;
279
- const suggestion = suggestForScopeViolation(filePath, allowedPaths.join(', '));
355
+ const suggestion = suggest(filePath);
280
356
  return enforcement === 'strict'
281
357
  ? emitDeny(reason, suggestion)
282
358
  : emitWarn(`${reason} — ${suggestion}`);
@@ -297,7 +373,7 @@ async function main() {
297
373
 
298
374
  if (!matched) {
299
375
  const reason = `Scope violation: '${normalizedRel}' not in allowed paths [${allowedPaths.join(', ')}]`;
300
- const suggestion = suggestForScopeViolation(normalizedRel, allowedPaths.join(', '));
376
+ const suggestion = suggest(normalizedRel);
301
377
  return enforcement === 'strict'
302
378
  ? emitDeny(reason, suggestion)
303
379
  : emitWarn(`${reason} — ${suggestion}`);
@@ -307,6 +383,30 @@ async function main() {
307
383
  return emitAllow();
308
384
  }
309
385
 
386
+ /**
387
+ * `mtimeMs` of a file, or `null` when it cannot be stat'ed.
388
+ *
389
+ * The PROVENANCE half of the #1057 staleness comparison. It is fed into a
390
+ * SUBTRACTION against this session's start time — deliberately NOT compared to a
391
+ * TTL: an absolute age cap blinds the check in exactly the regime it exists for
392
+ * (a legitimate long deep session ages into the blind spot with nothing having
393
+ * gone wrong). Same argument `hooks/post-bash-write-verify.mjs` makes for
394
+ * `sessionAgeMs` under "Why the minimum, and why NOT a staleness cap".
395
+ *
396
+ * Never throws — an unstat-able manifest simply yields an undecidable clock, and
397
+ * {@link classifyEmptyScope} degrades to `'unknown'`, never to an allow.
398
+ *
399
+ * @param {string} file
400
+ * @returns {Promise<number|null>}
401
+ */
402
+ async function mtimeMsOf(file) {
403
+ try {
404
+ return (await fs.stat(file)).mtimeMs;
405
+ } catch {
406
+ return null;
407
+ }
408
+ }
409
+
310
410
  const COORDINATOR_CARVEOUT_PATHS = Object.freeze([
311
411
  '.claude/STATE.md',
312
412
  '.codex/STATE.md',
@@ -7,7 +7,7 @@
7
7
  "hooks": [
8
8
  {
9
9
  "type": "command",
10
- "command": "echo '🎯 Session Orchestrator v3.20.0 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
10
+ "command": "echo '🎯 Session Orchestrator v3.22.0 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
11
11
  "async": false
12
12
  },
13
13
  {
package/hooks/hooks.json CHANGED
@@ -6,7 +6,7 @@
6
6
  "hooks": [
7
7
  {
8
8
  "type": "command",
9
- "command": "echo '🎯 Session Orchestrator v3.20.0 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
9
+ "command": "echo '🎯 Session Orchestrator v3.22.0 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
10
10
  "async": false
11
11
  },
12
12
  {
@@ -95,6 +95,26 @@
95
95
  "timeout": 5
96
96
  }
97
97
  ]
98
+ },
99
+ {
100
+ "matcher": "Agent",
101
+ "hooks": [
102
+ {
103
+ "type": "command",
104
+ "command": "sh \"$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh\" \"$CLAUDE_PLUGIN_ROOT/hooks/pre-task-scope-disjoint.mjs\"",
105
+ "timeout": 5
106
+ }
107
+ ]
108
+ },
109
+ {
110
+ "matcher": "AskUserQuestion",
111
+ "hooks": [
112
+ {
113
+ "type": "command",
114
+ "command": "sh \"$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh\" \"$CLAUDE_PLUGIN_ROOT/hooks/pre-auq-clarity.mjs\"",
115
+ "timeout": 5
116
+ }
117
+ ]
98
118
  }
99
119
  ],
100
120
  "PostToolUse": [