session-orchestrator 5.3.0 → 5.5.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 (260) hide show
  1. package/.agents/skills/claude-md-drift-check/SKILL.md +1 -1
  2. package/.agents/skills/test-audit/SKILL.md +21 -0
  3. package/.agents/skills/test-audit/agents/openai.yaml +5 -0
  4. package/.claude-plugin/marketplace.json +1 -1
  5. package/.claude-plugin/plugin.json +1 -1
  6. package/.codex-plugin/plugin.json +1 -1
  7. package/.codex-plugin/skills/claude-md-drift-check/SKILL.md +1 -1
  8. package/.codex-plugin/skills/test-audit/SKILL.md +21 -0
  9. package/.codex-plugin/skills/test-audit/agents/openai.yaml +5 -0
  10. package/.cursor/commands/test-audit.md +14 -0
  11. package/.cursor/rules/060-evolve.mdc +28 -13
  12. package/.cursor/skills/claude-md-drift-check/SKILL.md +1 -1
  13. package/.cursor/skills/test-audit/SKILL.md +12 -0
  14. package/.cursor-plugin/plugin.json +1 -1
  15. package/.orchestrator/policy/blocked-commands.json +1 -1
  16. package/AGENTS.md +9 -31
  17. package/CHANGELOG.md +162 -0
  18. package/README.md +14 -10
  19. package/docs/agent-authoring.md +2 -2
  20. package/docs/baseline.md +10 -1
  21. package/docs/ci-setup.md +154 -4
  22. package/docs/components.md +11 -11
  23. package/docs/events-schema.md +25 -19
  24. package/docs/install.md +14 -2
  25. package/docs/migration-v4.md +24 -17
  26. package/docs/persona-panel.md +5 -4
  27. package/docs/plugin-architecture-v3.md +1 -1
  28. package/docs/rule-authoring.md +27 -12
  29. package/docs/scope-collision-guard.md +40 -16
  30. package/docs/session-config-reference.md +61 -12
  31. package/docs/session-config-template.md +11 -2
  32. package/docs/vault-docs-architecture.md +8 -4
  33. package/hooks/_lib/hook-import-set.json +42 -29
  34. package/hooks/_lib/lock-bootstrap.mjs +118 -26
  35. package/hooks/config-protection.mjs +1 -1
  36. package/hooks/enforce-commands.mjs +28 -13
  37. package/hooks/enforce-scope.mjs +201 -23
  38. package/hooks/hooks-codex.json +1 -1
  39. package/hooks/hooks-cursor.json +5 -0
  40. package/hooks/hooks-pi.json +5 -0
  41. package/hooks/hooks.json +6 -1
  42. package/hooks/on-session-start.mjs +57 -2
  43. package/hooks/on-stop.mjs +70 -168
  44. package/hooks/post-bash-write-verify.mjs +98 -17
  45. package/hooks/post-edit-validate.mjs +15 -5
  46. package/hooks/post-tool-batch-wave-signal.mjs +32 -152
  47. package/hooks/pre-bash-memory-propose-audit.mjs +71 -10
  48. package/hooks/pre-bash-sessions-ledger-guard.mjs +28 -2
  49. package/hooks/pre-bash-staging-fence.mjs +29 -8
  50. package/hooks/pre-edit-sessions-ledger-guard.mjs +162 -0
  51. package/hooks/pre-task-scope-disjoint.mjs +1143 -279
  52. package/hooks/subagent-telemetry.mjs +96 -19
  53. package/hooks/wave-scope-commit-guard.mjs +168 -72
  54. package/package.json +2 -6
  55. package/pi/prompts/test-audit.md +12 -0
  56. package/rules/README.md +6 -1
  57. package/rules/_index.md +3 -3
  58. package/rules/always-on/bash-harness-pitfalls.md +7 -1
  59. package/rules/opt-in-domain/prompt-caching.md +5 -1
  60. package/rules/opt-in-stack/backend-data.md +9 -1
  61. package/rules/opt-in-stack/backend.md +7 -1
  62. package/rules/opt-in-stack/frontend.md +6 -1
  63. package/rules/opt-in-stack/security-web.md +7 -1
  64. package/rules/opt-in-stack/swift.md +5 -1
  65. package/scripts/apply-session-learnings.mjs +513 -0
  66. package/scripts/archive-closed-prds.mjs +40 -12
  67. package/scripts/autopilot.mjs +57 -54
  68. package/scripts/backfill-abandoned-sessions.mjs +81 -6
  69. package/scripts/backfill-learnings-from-vault.mjs +49 -22
  70. package/scripts/baseline-archetypes.mjs +1 -1
  71. package/scripts/check-package-manager.mjs +62 -5
  72. package/scripts/codex-install.mjs +3 -0
  73. package/scripts/cursor-install.mjs +47 -6
  74. package/scripts/emit-session.mjs +298 -2
  75. package/scripts/export-hw-learnings.mjs +40 -10
  76. package/scripts/fleet-checkin.mjs +170 -0
  77. package/scripts/lib/agent-frontmatter.mjs +3 -3
  78. package/scripts/lib/auto-dream.mjs +7 -6
  79. package/scripts/lib/autopilot/flags.mjs +6 -1
  80. package/scripts/lib/autopilot/loop.mjs +32 -13
  81. package/scripts/lib/autopilot/stall-sampler.mjs +13 -9
  82. package/scripts/lib/baseline-archetypes.mjs +41 -22
  83. package/scripts/lib/blocked-commands-policy.mjs +40 -0
  84. package/scripts/lib/common.mjs +41 -0
  85. package/scripts/lib/config/host-paths.mjs +79 -0
  86. package/scripts/lib/config/persona-gate-wave.mjs +3 -3
  87. package/scripts/lib/config/reaper.mjs +7 -6
  88. package/scripts/lib/config/vault-integration.mjs +151 -16
  89. package/scripts/lib/config-schema.mjs +13 -0
  90. package/scripts/lib/config.mjs +75 -10
  91. package/scripts/lib/eval/engine.mjs +13 -0
  92. package/scripts/lib/events-retention-banner.mjs +393 -0
  93. package/scripts/lib/events-rotation.mjs +21 -6
  94. package/scripts/lib/events.mjs +72 -5
  95. package/scripts/lib/fleet-protocol.mjs +288 -0
  96. package/scripts/lib/gates/gate-baseline.mjs +13 -3
  97. package/scripts/lib/gates/gate-full.mjs +7 -2
  98. package/scripts/lib/gates/gate-helpers.mjs +93 -2
  99. package/scripts/lib/gates/gate-incremental.mjs +23 -22
  100. package/scripts/lib/gates/gate-per-file.mjs +16 -7
  101. package/scripts/lib/hardening.mjs +1 -0
  102. package/scripts/lib/harness-audit/categories/category4.mjs +8 -6
  103. package/scripts/lib/instruction-budget-guard.mjs +77 -11
  104. package/scripts/lib/learnings/expiry-sweep.mjs +420 -22
  105. package/scripts/lib/learnings/io.mjs +274 -37
  106. package/scripts/lib/learnings/schema.mjs +163 -15
  107. package/scripts/lib/learnings/sizing-subject.mjs +2 -2
  108. package/scripts/lib/lock-reaper.mjs +38 -14
  109. package/scripts/lib/maintenance-due-banner.mjs +23 -8
  110. package/scripts/lib/memory-proposals/schema.mjs +11 -5
  111. package/scripts/lib/named-baseline-resolver.mjs +53 -28
  112. package/scripts/lib/named-vault-resolver.mjs +147 -5
  113. package/scripts/lib/orphan-reaper/defaults.mjs +41 -0
  114. package/scripts/lib/orphan-reaper/no-follow-append.mjs +87 -0
  115. package/scripts/lib/orphan-reaper/ps-snapshot.mjs +181 -0
  116. package/scripts/lib/orphan-reaper/reaper-audit.mjs +323 -0
  117. package/scripts/lib/orphan-reaper/reaper-decide.mjs +395 -0
  118. package/scripts/lib/orphan-reaper/scan-throttle.mjs +144 -0
  119. package/scripts/lib/orphan-reaper/scan.mjs +510 -0
  120. package/scripts/lib/orphan-reaper/trigger.mjs +173 -0
  121. package/scripts/lib/orphan-reaper.mjs +47 -1315
  122. package/scripts/lib/owner-yaml.mjs +104 -6
  123. package/scripts/lib/persona-panel/catalog-loader.mjs +1 -1
  124. package/scripts/lib/platform.mjs +206 -2
  125. package/scripts/lib/plugin-update-banner.mjs +3 -1
  126. package/scripts/lib/process-group.mjs +134 -22
  127. package/scripts/lib/project-hygiene.mjs +21 -1
  128. package/scripts/lib/quality-gate.mjs +45 -5
  129. package/scripts/lib/resource-probe.mjs +1 -1
  130. package/scripts/lib/rule-loader.mjs +100 -62
  131. package/scripts/lib/rules-sync.mjs +27 -12
  132. package/scripts/lib/scope-baseline.mjs +119 -29
  133. package/scripts/lib/scope-echo.mjs +7 -0
  134. package/scripts/lib/scope-gate.mjs +194 -27
  135. package/scripts/lib/session-close-backfill.mjs +129 -4
  136. package/scripts/lib/session-end/tail-runner.mjs +10 -5
  137. package/scripts/lib/session-lock-shape.mjs +6 -3
  138. package/scripts/lib/session-lock.mjs +320 -67
  139. package/scripts/lib/session-record-repair.mjs +183 -26
  140. package/scripts/lib/session-registry.mjs +67 -17
  141. package/scripts/lib/session-schema/constants.mjs +30 -4
  142. package/scripts/lib/session-schema/validator.mjs +28 -1
  143. package/scripts/lib/session-start-probes.mjs +242 -68
  144. package/scripts/lib/session-token-rollup.mjs +252 -54
  145. package/scripts/lib/sessions-canonical.mjs +100 -9
  146. package/scripts/lib/sessions-staleness-banner.mjs +3 -1
  147. package/scripts/lib/sh-quote.mjs +29 -0
  148. package/scripts/lib/subagents-schema.mjs +30 -3
  149. package/scripts/lib/tail-window.mjs +27 -3
  150. package/scripts/lib/telemetry/pricing.mjs +121 -71
  151. package/scripts/lib/telemetry/sync.mjs +17 -0
  152. package/scripts/lib/tests-src-ratio.mjs +215 -23
  153. package/scripts/lib/tmux-layout/layouts.mjs +1 -11
  154. package/scripts/lib/tmux-layout/telemetry-stats.mjs +12 -0
  155. package/scripts/lib/tmux-layout/tmux-shell.mjs +2 -11
  156. package/scripts/lib/ux-grill/reconcile.mjs +1 -1
  157. package/scripts/lib/validate/check-agents.mjs +1 -1
  158. package/scripts/lib/validate/check-hooks-symmetry.mjs +5 -0
  159. package/scripts/lib/validate/check-learnings-shell-writes.mjs +214 -0
  160. package/scripts/lib/validate/check-test-value-bans.mjs +388 -27
  161. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  162. package/scripts/lib/validate/check-unwired-features.mjs +1 -1
  163. package/scripts/lib/validate/check-vcs-repo-flag.mjs +1 -1
  164. package/scripts/lib/validate-vendored-rules.mjs +206 -81
  165. package/scripts/lib/vault-mirror/auto-commit.mjs +5 -3
  166. package/scripts/lib/vault-mirror/namespace.mjs +47 -26
  167. package/scripts/lib/vault-mirror/process.mjs +350 -62
  168. package/scripts/lib/vault-mirror/pseudonym-map.mjs +14 -5
  169. package/scripts/lib/vault-mirror/render-learnings.mjs +66 -3
  170. package/scripts/lib/vault-mirror/render-sessions.mjs +28 -4
  171. package/scripts/lib/vault-mirror/telemetry.mjs +25 -11
  172. package/scripts/lib/vault-status/board-writer.mjs +53 -12
  173. package/scripts/lib/vault-status/narrative-mirror.mjs +40 -9
  174. package/scripts/lib/vcs-repo-spec.mjs +8 -0
  175. package/scripts/lib/wave-executor/dispatch-common.mjs +10 -18
  176. package/scripts/lib/wave-executor/foreign-dispatch.mjs +1 -1
  177. package/scripts/lib/wave-executor/remote-dispatch.mjs +1 -1
  178. package/scripts/lib/wave-resource-gate.mjs +34 -8
  179. package/scripts/materialize-wave-scope.mjs +7 -4
  180. package/scripts/memory-propose.mjs +18 -5
  181. package/scripts/release-session-lock.mjs +2 -1
  182. package/scripts/release.mjs +11 -3
  183. package/scripts/repair-invalid-sessions.mjs +37 -4
  184. package/scripts/run-migrate-v2-cross-repo.mjs +171 -31
  185. package/scripts/run-quality-gate.mjs +331 -307
  186. package/scripts/self-update.mjs +229 -0
  187. package/scripts/site-numbers.mjs +163 -5
  188. package/scripts/sweep-expired-learnings.mjs +603 -20
  189. package/scripts/sweep-expired-rules.mjs +2 -1
  190. package/scripts/validate-plugin.mjs +7 -0
  191. package/scripts/validate-wave-scope.mjs +21 -2
  192. package/scripts/vault-backfill.mjs +50 -37
  193. package/scripts/vault-integration-watcher.mjs +2 -1
  194. package/scripts/vault-mirror.mjs +238 -75
  195. package/scripts/wave-scope-binding.mjs +2 -1
  196. package/skills/_shared/config-reading.md +3 -3
  197. package/skills/_shared/fleet-protocol.md +134 -0
  198. package/skills/_shared/state-ownership.md +41 -12
  199. package/skills/autopilot/SKILL.md +25 -11
  200. package/skills/bootstrap/SKILL.md +6 -4
  201. package/skills/bootstrap/_shared-template.md +97 -2
  202. package/skills/bootstrap/fast-template.md +4 -1
  203. package/skills/bootstrap/private-contract.md +2 -1
  204. package/skills/bootstrap/public-fallback.md +6 -3
  205. package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +5 -3
  206. package/skills/bootstrap/references/bootstrap-retroactive-flow.md +2 -1
  207. package/skills/bootstrap/standard-template.md +2 -0
  208. package/skills/claude-md-drift-check/SKILL.md +10 -10
  209. package/skills/claude-md-drift-check/checker.mjs +26 -18
  210. package/skills/discovery/probes/ssot-code-diff.mjs +12 -6
  211. package/skills/discovery/probes/vault-narrative-staleness.mjs +6 -2
  212. package/skills/discovery/probes/vault-staleness.mjs +9 -3
  213. package/skills/docs-orchestrator/SKILL.md +12 -5
  214. package/skills/docs-orchestrator/audience-mapping.md +5 -3
  215. package/skills/evolve/SKILL.md +13 -4
  216. package/skills/evolve/references/evolve-analyze-mode.md +42 -18
  217. package/skills/memory-cleanup/SKILL.md +1 -1
  218. package/skills/persona-panel/SKILL.md +2 -2
  219. package/skills/persona-panel/persona-format.md +2 -2
  220. package/skills/persona-panel/presets/designer-lens.md +1 -1
  221. package/skills/persona-panel/presets/engineer-lens.md +1 -1
  222. package/skills/persona-panel/presets/pm-lens.md +1 -1
  223. package/skills/plan/mode-retro.md +19 -4
  224. package/skills/quality-gates/SKILL.md +6 -4
  225. package/skills/remote-offload/SKILL.md +4 -2
  226. package/skills/session-end/learning-patterns.md +23 -13
  227. package/skills/session-end/metrics-collection.md +6 -7
  228. package/skills/session-end/phase-3-2-docs-verification.md +7 -6
  229. package/skills/session-end/plan-verification.md +25 -9
  230. package/skills/session-end/references/phase-3-documentation-updates.md +13 -3
  231. package/skills/session-end/references/phase-4a-worktree-cleanup.md +2 -0
  232. package/skills/session-end/references/session-summary-template.md +1 -1
  233. package/skills/session-end/session-metrics-write.md +65 -68
  234. package/skills/session-plan/SKILL.md +0 -3
  235. package/skills/session-start/SKILL.md +8 -2
  236. package/skills/session-start/references/operations-contract.md +6 -0
  237. package/skills/session-start/references/phase-1-2-session-lock.md +32 -7
  238. package/skills/session-start/references/phase-1-7-vault-status-board.md +1 -1
  239. package/skills/session-start/references/phase-4-ssot-environment-check.md +10 -3
  240. package/skills/test-audit/SKILL.md +152 -0
  241. package/skills/test-audit/references/campaign.md +111 -0
  242. package/skills/test-audit/references/mutate.sh +50 -0
  243. package/skills/vault-mirror/SKILL.md +21 -11
  244. package/skills/wave-executor/SKILL.md +2 -2
  245. package/skills/wave-executor/circuit-breaker.md +1 -1
  246. package/skills/wave-executor/references/wave-executor-state-init.md +3 -0
  247. package/skills/wave-executor/references/wave-loop-dispatch.md +11 -1
  248. package/skills/wave-executor/references/wave-loop-review.md +4 -6
  249. package/skills/wave-executor/references/wave-loop-scope-manifest.md +13 -5
  250. package/templates/personas/README.md +5 -5
  251. package/templates/personas/accounting-compliance.v1.md +1 -1
  252. package/templates/personas/accounting-tax-advisor.v1.md +1 -1
  253. package/templates/personas/buyer-p1-cto.v1.md +1 -1
  254. package/templates/personas/buyer-p2-kanzlei.v1.md +1 -1
  255. package/templates/personas/buyer-p3-build.v1.md +1 -1
  256. package/templates/personas/buyer-p4-tech-veto.v1.md +1 -1
  257. package/templates/personas/buyer-p5-solo.v1.md +1 -1
  258. package/templates/personas/buyer-p6-ld.v1.md +1 -1
  259. package/templates/personas/klima-ai-expert.v1.md +1 -1
  260. package/templates/personas/klima-physicist.v1.md +1 -1
@@ -3,26 +3,37 @@
3
3
  * Syncs canonical rules from the plugin's rules/ library into a consumer repo's .claude/rules/.
4
4
  *
5
5
  * Stdlib-only, cross-platform, no Zod, no third-party parsers. ESM module.
6
- * Preserves local rules (files without the plugin source header).
6
+ * Preserves local rules — files carrying the plugin provenance header in
7
+ * neither accepted position (see `isPluginOwnedContent`).
7
8
  */
8
9
 
9
10
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
10
11
  import { join, basename, dirname, resolve } from 'node:path';
11
12
  import { fileURLToPath } from 'node:url';
12
- import { validateRuleContent, scanVendoringLeaks } from './validate-vendored-rules.mjs';
13
+ import {
14
+ validateRuleContent,
15
+ scanVendoringLeaks,
16
+ isPluginOwnedContent,
17
+ } from './validate-vendored-rules.mjs';
13
18
  import { isMainModule } from './is-main-module.mjs';
14
19
 
15
20
  // The vendoring sanitizer (issue #1098) lives in validate-vendored-rules.mjs —
16
21
  // it has that module's shape ("judge one rule file → findings") and its
17
22
  // standalone CLI needs it too. Re-exported here so importers that predate the
18
23
  // move (and this file's own tests) keep resolving it from rules-sync.mjs.
19
- export { scanVendoringLeaks };
24
+ //
25
+ // isPluginOwnedContent (#1449) — the ownership predicate below — lives there
26
+ // for the same reason as PLUGIN_HEADER_PREFIX's copy: this module imports
27
+ // validate-vendored-rules.mjs, so defining it here and importing it back into
28
+ // the validator's provenance probe would make the two modules import each
29
+ // other. Re-exported so the ownership rule is reachable from the sync module.
30
+ export { scanVendoringLeaks, isPluginOwnedContent };
20
31
 
21
32
  // Exported (issue #722 Epic A Wave 2) for external consumers (e.g. tests).
22
- // NOT imported by validate-vendored-rules.mjs — that module imports
23
- // validateRuleContent FROM this file (see the pre-write gate below), so
24
- // importing this constant back would create a module-load cycle; it keeps
25
- // its own textually-identical copy instead.
33
+ // NOT imported by validate-vendored-rules.mjs — this module imports
34
+ // validateRuleContent FROM that file (see the pre-write gate below), so
35
+ // importing this constant back there would create a module-load cycle; it
36
+ // keeps its own textually-identical copy instead.
26
37
  export const PLUGIN_HEADER_PREFIX = '<!-- source: session-orchestrator plugin';
27
38
 
28
39
  /**
@@ -170,8 +181,9 @@ function escapeRegex(s) {
170
181
  *
171
182
  * When `validate` is true (the default — issue #722 Epic A Wave 2),
172
183
  * validateRuleContent() runs as a pre-write gate on every source file before
173
- * it is copied. A file with any error-severity violation (paths-frontmatter,
174
- * missing provenance header, unfilled placeholder) is recorded in `errors[]`
184
+ * it is copied. A file with any error-severity violation (frontmatter-not-at-top,
185
+ * globs-paths-mismatch, missing provenance header, unfilled placeholder) is
186
+ * recorded in `errors[]`
175
187
  * and its write is skipped entirely — it is never added to
176
188
  * written/skipped/preserved. Warn-severity violations (zero-match-globs,
177
189
  * foreign-glob) do NOT block the write; they are collected into the
@@ -405,9 +417,12 @@ export function syncRules({
405
417
  continue;
406
418
  }
407
419
 
408
- const firstLine = targetContent.split('\n')[0] ?? '';
409
- if (!firstLine.startsWith(PLUGIN_HEADER_PREFIX)) {
410
- // Local file — preserve it
420
+ // Ownership accepts BOTH header positions (#1449): line 1 (the format
421
+ // every copy vendored before #1449 carries, frontmatter or not) and the
422
+ // first non-blank line after a line-1 frontmatter (the current format).
423
+ // So an old-format copy is rewritten into the new format below, never
424
+ // preserved; only a file with no header in either position is local.
425
+ if (!isPluginOwnedContent(targetContent)) {
411
426
  preserved.push(fileName);
412
427
  continue;
413
428
  }
@@ -141,8 +141,11 @@ import { resolveBaselineRange, isQueryFailure } from './vcs-repo-spec.mjs';
141
141
  // F1 bug this filter exists to prevent — a caller could hand
142
142
  // `writeBaseline()` an unfiltered count with no `filterExcluded()` pass at
143
143
  // all). `computeDrift()` filters the numerator at measure time by running
144
- // the live `git diff --name-only` output through the same
145
- // `filterExcluded()` helper.
144
+ // the live change set (committed range ∪ working tree ∪ untracked, see its
145
+ // JSDoc) through the same `filterExcluded()` helper. One classification sits
146
+ // OUTSIDE this list on purpose: untracked paths under `.orchestrator/` (see
147
+ // `isUntrackedRuntimeWrite()` below) — it keys on tracked status, which only
148
+ // the live numerator has, so a pattern here could not express it.
146
149
  // Deliberately narrows skills/session-end/plan-verification.md:42-45's
147
150
  // per-session-state exclusion from "the whole .claude/ directory" down to
148
151
  // just the session ARTEFACTS — `.claude/rules/**` stays COUNTED because a
@@ -165,6 +168,24 @@ export const DRIFT_EXCLUDE_PATTERNS = [
165
168
  '.codex/STATE.md', '.codex/wave-scope.json', '.codex/metrics/**',
166
169
  '.cursor/STATE.md', '.cursor/wave-scope.json', '.cursor/metrics/**',
167
170
  '.pi/STATE.md', '.pi/wave-scope.json', '.pi/metrics/**',
171
+ // The plugin's OWN mid-session writes. They only became visible to the
172
+ // numerator when it started reading the working tree and untracked files
173
+ // (#1027). Two classes are excluded HERE, whatever their tracked status:
174
+ // - `.orchestrator/metrics/**` — a consumer repo keeps its `*.jsonl`
175
+ // TRACKED by design (skills/bootstrap/standard-template.md "Gitignore
176
+ // guidance") and hooks append to it all session.
177
+ // - `<state-dir>/filescopes/**` and `<state-dir>/worktrees/**` — bootstrap
178
+ // writes no ignore line for either. An agent worktree is a nested repo,
179
+ // so `ls-files --others` lists each as ONE `.claude/worktrees/<id>/`
180
+ // entry (measured 2026-10-02): one false file per isolated agent.
181
+ // Every OTHER plugin write under `.orchestrator/` (runtime/, tmp/, debug/,
182
+ // session.lock, current-session.json, ...) is excluded by
183
+ // `isUntrackedRuntimeWrite()` while untracked, which is how a consumer repo
184
+ // holds them. Its deliverables (`steering/`, `policy/`, `bootstrap.lock`)
185
+ // are tracked and count.
186
+ '.claude/filescopes/**', '.codex/filescopes/**', '.cursor/filescopes/**', '.pi/filescopes/**',
187
+ '.claude/worktrees/**', '.codex/worktrees/**', '.cursor/worktrees/**', '.pi/worktrees/**',
188
+ '.orchestrator/metrics/**',
168
189
  ];
169
190
 
170
191
  // ---------------------------------------------------------------------------
@@ -218,6 +239,38 @@ function filterExcluded(files) {
218
239
  );
219
240
  }
220
241
 
242
+ /**
243
+ * Is this UNTRACKED path the plugin's own runtime output? Same rule as
244
+ * `isOwnRuntimeArtifact()` in scripts/lib/project-hygiene.mjs (untracked
245
+ * under `.orchestrator/` = ours), mirrored rather than imported: it is not
246
+ * exported, and importing that module would pull js-yaml and the command
247
+ * blocker into this one for a one-line prefix test. Applied to the
248
+ * `ls-files --others` source only — a tracked `.orchestrator/` file is a
249
+ * deliverable or a ledger, and `DRIFT_EXCLUDE_PATTERNS` decides which.
250
+ *
251
+ * Ceiling (BV-004): a runtime file a consumer has COMMITTED (e.g. a
252
+ * `session.lock` swept in by `git add -A`) counts whenever the plugin
253
+ * rewrites it, and a NEW `steering/`/`policy/` file counts only once it is
254
+ * tracked. Revisit if a drift WARN traces to a tracked runtime file, or a
255
+ * missed WARN to an uncommitted steering/policy deliverable.
256
+ *
257
+ * @param {string} file repo-root-relative path from `git ls-files --others`
258
+ * @returns {boolean}
259
+ */
260
+ function isUntrackedRuntimeWrite(file) {
261
+ return file.startsWith('.orchestrator/');
262
+ }
263
+
264
+ /** Split git `--name-only`/`ls-files` stdout into trimmed non-empty paths. */
265
+ function outputPaths(stdout) {
266
+ return stdout.split('\n').map((line) => line.trim()).filter((line) => line.length > 0);
267
+ }
268
+
269
+ // Per-query bound for the three numerator git calls — the same 10 s that
270
+ // scripts/lib/project-hygiene.mjs gives its read-only git queries. A timeout
271
+ // throws like any other failure and lands in the same skip.
272
+ const DRIFT_GIT_TIMEOUT_MS = 10_000;
273
+
221
274
  /**
222
275
  * Internal: read + parse STATE.md's frontmatter exactly once. Shared by
223
276
  * `readBaseline()` and `computeDrift()` so a single `computeDrift()` call
@@ -464,13 +517,38 @@ export async function writeBaseline({ repoRoot, intent, ownerBoundary, plannedFi
464
517
  }
465
518
 
466
519
  /**
467
- * Compute the S2 warn-only scope-drift ratio: how many files have actually
468
- * changed since the session's frozen `session-start-ref`, filtered through
520
+ * Compute the S2 warn-only scope-drift ratio: how many files differ in this
521
+ * checkout — committed since the session's frozen `session-start-ref`, plus
522
+ * whatever is uncommitted or untracked right now — filtered through
469
523
  * `DRIFT_EXCLUDE_PATTERNS`, against the `plannedFiles` count frozen by
470
524
  * `writeBaseline()`. Sync (`readFileSync` + `execFileSync('git', ...)`
471
525
  * only). Never throws, never denies — this is a WARN-only tripwire; the
472
526
  * CALLER decides whether/how to surface `breached`.
473
527
  *
528
+ * Numerator (#1027): the deduplicated union of three git queries — the
529
+ * committed range diff, the working tree against HEAD (staged + unstaged),
530
+ * and untracked files (`ls-files --others --exclude-standard`, minus the
531
+ * plugin's own untracked `.orchestrator/` writes, see
532
+ * `isUntrackedRuntimeWrite()`). Until #1027 only the first was read, so
533
+ * uncommitted edits and every NEW module — the typical shape of an
534
+ * overshoot — were invisible until someone committed them. All three must
535
+ * succeed: if any fails (including ENOBUFS and the per-query timeout) the
536
+ * result is the `unresolvable-ref` skip, never a ratio over a partial
537
+ * numerator. That reason is deliberately not split by cause: its readers
538
+ * (skills/wave-executor/references/wave-loop-review.md step 7a,
539
+ * skills/session-end/plan-verification.md step 5) render every skip
540
+ * silently or verbatim and none branches on it.
541
+ *
542
+ * Named ceiling (BV-004) — over-report: only the committed source is tied to
543
+ * `session-start-ref`. The working-tree and untracked sources read the
544
+ * checkout as it is NOW, so a file that was already dirty when the session
545
+ * started, and a same-checkout peer session's files, count as this session's
546
+ * drift. Fixing it means persisting a start-dirty snapshot at freeze time and
547
+ * subtracting it — a storage change, out of scope for this warn-only
548
+ * tripwire. Revisit if a drift WARN traces to pre-session dirt or to a
549
+ * peer's files (`git status` at session start shows the former,
550
+ * PSA-001 signals the latter).
551
+ *
474
552
  * Skip precedence (first match wins, so `reason` is deterministic):
475
553
  * `no-state-md` → `unreadable-state-md` → `no-baseline` →
476
554
  * `stale-baseline` → (`no-baseline-ref` | `unresolvable-ref`)
@@ -509,13 +587,14 @@ export async function writeBaseline({ repoRoot, intent, ownerBoundary, plannedFi
509
587
  *
510
588
  * Named ceiling (BV-004) on the local-branch tail of the resolution chain:
511
589
  * when the chain lands on a LOCAL `main`/`master` and HEAD is already that
512
- * branch, `main...HEAD` has merge-base === HEAD and the diff is empty, so
513
- * the ratio reads `0` instead of skipping. That is the honest floor of the
514
- * available information (no remote-tracking ref exists to say where the
515
- * session started) and it is harmless here because both a `0` ratio and a
516
- * skip are silent under `wave-loop.md`'s WARN-on-breach rule. Revisit if
517
- * this function ever gains a non-warn consumer that treats `skipped:false`
518
- * as "measured successfully".
590
+ * branch, `main...HEAD` has merge-base === HEAD and the COMMITTED source is
591
+ * empty, so the session's commits are invisible and the ratio counts only
592
+ * the uncommitted and untracked files instead of skipping. That is the
593
+ * honest floor of the available information (no remote-tracking ref exists
594
+ * to say where the session started); it can only under-report, and an
595
+ * under-report is as silent as a skip under `wave-loop-review.md`'s
596
+ * WARN-on-breach rule. Revisit if this function ever gains a non-warn
597
+ * consumer that treats `skipped:false` as "measured successfully".
519
598
  *
520
599
  * @param {object} args
521
600
  * @param {string|undefined} args.repoRoot
@@ -578,36 +657,47 @@ export function computeDrift({ repoRoot, threshold = 2.0 } = {}) {
578
657
  refUsed = resolved.range;
579
658
  }
580
659
 
581
- let stdout;
660
+ // Untracked files can be numerous, so the 1 MiB default buffer would turn
661
+ // a large tree into an ENOBUFS skip.
662
+ const gitOpts = { cwd, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, timeout: DRIFT_GIT_TIMEOUT_MS };
663
+ const changedFiles = new Set();
582
664
  try {
583
- stdout = execFileSync('git', ['diff', '--name-only', diffRange], { cwd, encoding: 'utf8' });
665
+ const committed = execFileSync('git', ['diff', '--name-only', diffRange], gitOpts);
666
+ const workingTree = execFileSync('git', ['diff', '--name-only', 'HEAD'], gitOpts);
667
+ // `--full-name` + `:/` keep these paths in the same repo-root-relative
668
+ // space as `git diff --name-only`, even if `cwd` is a subdirectory.
669
+ const untracked = execFileSync('git', ['ls-files', '--others', '--exclude-standard', '--full-name', '--', ':/'], gitOpts);
670
+ for (const file of [...outputPaths(committed), ...outputPaths(workingTree)]) changedFiles.add(file);
671
+ for (const file of outputPaths(untracked)) {
672
+ if (!isUntrackedRuntimeWrite(file)) changedFiles.add(file);
673
+ }
584
674
  } catch {
585
- // The range resolved but the diff against it failed — a present-but-dead
586
- // `session-start-ref` (rebase, force-push, deleted commit), or a
587
- // resolved base that vanished between resolution and diff. Neither can
588
- // produce a trustworthy numerator.
675
+ // The range resolved but a numerator query failed — a present-but-dead
676
+ // `session-start-ref` (rebase, force-push, deleted commit), a resolved
677
+ // base that vanished between resolution and diff, a working-tree or
678
+ // untracked query git could not answer, output over `maxBuffer`
679
+ // (ENOBUFS), or a query past `DRIFT_GIT_TIMEOUT_MS`. None can produce a
680
+ // trustworthy numerator, and a partial one must not be reported as
681
+ // measured. All share `unresolvable-ref`: every reader renders a skip
682
+ // silently and none branches on the reason (census in the JSDoc above).
589
683
  return { ok: true, skipped: true, reason: 'unresolvable-ref' };
590
684
  }
591
685
 
592
- const changedFiles = stdout
593
- .split('\n')
594
- .map((line) => line.trim())
595
- .filter((line) => line.length > 0);
596
-
597
686
  // Same `filterExcluded()` primitive `writeBaseline()`'s denominator
598
- // (`countPlannedFiles()`) calls (#894 review finding F1) — both sides of
599
- // the ratio are provably produced by one function.
600
- const actualFiles = filterExcluded(changedFiles).length;
687
+ // (`countPlannedFiles()`) calls (#894 review finding F1), so the pattern
688
+ // filter on both sides of the ratio is one function. The untracked
689
+ // `.orchestrator/` rule above is numerator-only by construction: a planned
690
+ // path carries no tracked status to test.
691
+ const actualFiles = filterExcluded([...changedFiles]).length;
601
692
 
602
693
  const plannedFilesRaw = baseline.plannedFiles;
603
694
  const plannedFiles = typeof plannedFilesRaw === 'number' && Number.isFinite(plannedFilesRaw)
604
695
  ? plannedFilesRaw
605
696
  : 0;
606
697
 
607
- // Mirrors the existing over_delivery_ratio div-by-zero guard in
608
- // skills/wave-executor/wave-loop.md (`files_changed / max(planned_files_count, 1)`,
609
- // in the per-wave metrics section). Deliberately un-line-pinned: that file is
610
- // edited often enough that a pinned number goes stale, as this very comment did.
698
+ // Division-by-zero guard: a baseline that froze zero planned files (no declared
699
+ // file scope, or a non-numeric count coerced to 0 above) would otherwise yield
700
+ // Infinity or NaN; `max(plannedFiles, 1)` keeps the ratio finite and comparable.
611
701
  const filesRatio = round2(actualFiles / Math.max(plannedFiles, 1));
612
702
  const breached = filesRatio >= threshold;
613
703
 
@@ -51,6 +51,7 @@ import { join, resolve } from 'node:path';
51
51
 
52
52
  import { digestSha256Short } from './crypto-digest-utils.mjs';
53
53
  import { isMainModule } from './is-main-module.mjs';
54
+ import { isCoordinatorRecordId } from './scope-gate.mjs';
54
55
 
55
56
  /** Marker the agent must emit. Case-sensitive by design — a lowercase lookalike is not an echo. */
56
57
  export const SCOPE_ECHO_MARKER = 'SCOPE-DIGEST:';
@@ -329,6 +330,11 @@ function parseJsonl(raw) {
329
330
  * only iterates the entries is unaffected. (A file that parses to a non-array or
330
331
  * to an empty scope stays a silent skip: an empty scope is legitimate.)
331
332
  *
333
+ * `coordinator.json` is skipped too (#1493.2): the coordinator's planned direct
334
+ * edits are a declaration, never a dispatch, so no injection or echo can ever
335
+ * claim its digest — digesting it reported the coordinator's own scope as
336
+ * `injection-missing` on every wave where it planned an edit.
337
+ *
332
338
  * @param {string} stateDir
333
339
  * @param {number} wave
334
340
  * @param {{readDir?: typeof readdirSync, readFile?: typeof readFileSync}} [io]
@@ -347,6 +353,7 @@ export function digestScopeFiles(stateDir, wave, { readDir = readdirSync, readFi
347
353
  for (const name of names) {
348
354
  const file = typeof name === 'string' ? name : name?.name;
349
355
  if (typeof file !== 'string' || !file.endsWith('.json')) continue;
356
+ if (isCoordinatorRecordId(file.slice(0, -'.json'.length))) continue;
350
357
  let parsed;
351
358
  try {
352
359
  parsed = JSON.parse(readFile(join(dir, file), 'utf8'));
@@ -16,6 +16,9 @@ import path from 'node:path';
16
16
 
17
17
  import { tokenizeCommand, splitChainSegments, resolveSegmentVerb } from './command-blocker.mjs';
18
18
 
19
+ /** The harness state dirs a `wave-scope.json` may live in, highest precedence first. */
20
+ const SCOPE_FILE_DIRS = Object.freeze(['.pi', '.cursor', '.codex', '.claude']);
21
+
19
22
  /**
20
23
  * Find the wave-scope.json file for the given project root.
21
24
  *
@@ -28,11 +31,12 @@ import { tokenizeCommand, splitChainSegments, resolveSegmentVerb } from './comma
28
31
  * Returns the absolute path string, or null if none exist.
29
32
  * Sync (uses fs.existsSync).
30
33
  *
34
+ * @deprecated Use findOwnScopeFile because first-file selection ignores manifest ownership and can select a peer session's manifest.
31
35
  * @param {string} projectRoot — absolute path to project root
32
36
  * @returns {string|null}
33
37
  */
34
38
  export function findScopeFile(projectRoot) {
35
- for (const dir of ['.pi', '.cursor', '.codex', '.claude']) {
39
+ for (const dir of SCOPE_FILE_DIRS) {
36
40
  const candidate = path.join(projectRoot, dir, 'wave-scope.json');
37
41
  if (existsSync(candidate)) {
38
42
  return candidate;
@@ -41,6 +45,67 @@ export function findScopeFile(projectRoot) {
41
45
  return null;
42
46
  }
43
47
 
48
+ /**
49
+ * The manifest that governs THIS session: walk the {@link findScopeFile}
50
+ * precedence (`.pi` > `.cursor` > `.codex` > `.claude`) and return the first
51
+ * candidate that is NOT provably bound to another session (#1504 point 6).
52
+ *
53
+ * `findScopeFile` stops at the first file that EXISTS. When that file is a
54
+ * peer's — another harness session's manifest in the same working copy — every
55
+ * guard that stands down on a foreign manifest stood down, and this session's
56
+ * own `.claude/wave-scope.json` one rung lower was never read: a peer's
57
+ * manifest switched this session's scope gates off.
58
+ *
59
+ * Disposition per candidate, decided INSIDE the loop (an identity check after
60
+ * the loop makes the first readable candidate the veto-holder —
61
+ * `.claude/rules/identity-and-locks.md`):
62
+ * - `'own'` or `'unknown'` → return it. `'unknown'` covers an unbound/legacy
63
+ * manifest, a corrupt or unreadable one (judged as `{}`), and an empty
64
+ * `ownIds` (a harness that supplies no session id). Each means ENFORCE
65
+ * under the binding contract, so each stays the decider exactly as under
66
+ * `findScopeFile` — a truncated manifest cannot skip itself.
67
+ * - `'foreign'` → remember the first one and `continue`.
68
+ *
69
+ * When EVERY existing candidate is foreign, `path` is null and `foreignPath`
70
+ * names the first of them, so the caller reaches its existing foreign
71
+ * disposition (allow + `orchestrator.scope.foreign_session_ignored`) unchanged.
72
+ *
73
+ * `classify` is injected, not imported: importing the identity module here
74
+ * would add it to the import closure of every hook that reaches this file
75
+ * through the `hardening.mjs` barrel, while only the guards that stand down on
76
+ * a foreign manifest need it — and each of them already binds it. Pass
77
+ * `classifyManifestSession` from `session-identity/own-session.mjs`.
78
+ *
79
+ * COST: identical to `findScopeFile` when no manifest exists (≤4 `existsSync`).
80
+ * With one, one extra sync read + parse of that small file. The caller still
81
+ * reads the returned file itself and classifies THAT read, so a rebind between
82
+ * the two reads is judged at the same decision point as before.
83
+ *
84
+ * Sync. Never throws (unless `classify` does).
85
+ *
86
+ * @param {string} projectRoot absolute path to the project root
87
+ * @param {Set<string>} ownIds process-local own ids (`readProcessLocalSessionIds`)
88
+ * @param {(scope: object, ownIds: Set<string>) => { verdict: string }} classify
89
+ * @returns {{ path: string|null, foreignPath: string|null }}
90
+ */
91
+ export function findOwnScopeFile(projectRoot, ownIds, classify) {
92
+ let foreignPath = null;
93
+ for (const dir of SCOPE_FILE_DIRS) {
94
+ const candidate = path.join(projectRoot, dir, 'wave-scope.json');
95
+ if (!existsSync(candidate)) continue;
96
+ let scope = {};
97
+ try {
98
+ const parsed = JSON.parse(readFileSync(candidate, 'utf8'));
99
+ if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) scope = parsed;
100
+ } catch {
101
+ // unreadable / corrupt → `{}` → no binding → 'unknown' → this candidate decides
102
+ }
103
+ if (classify(scope, ownIds).verdict !== 'foreign') return { path: candidate, foreignPath };
104
+ foreignPath ??= candidate;
105
+ }
106
+ return { path: null, foreignPath };
107
+ }
108
+
44
109
  /**
45
110
  * Read the enforcement level from a scope file.
46
111
  * Defaults to "strict" (fail-closed) on parse error or missing field.
@@ -190,7 +255,8 @@ function literalScopePrefix(entry) {
190
255
  * verbatim presence + literal-prefix coverage; it does not prove that e.g.
191
256
  * `src/**\/*.ts` ⊆ `src/**\/*.js` is false. The concrete-path branch above is
192
257
  * exact and carries the incident-relevant load (the union the coordinator
193
- * actually writes is verbatim, deduplicated agent scopes). Erring toward
258
+ * actually writes is the deduplicated agent scopes in canonical spelling, see
259
+ * {@link unionFileScopes}). Erring toward
194
260
  * over-approximating coverage keeps a legitimate union from being rejected on a
195
261
  * glob technicality rather than pretending to a precision this matcher lacks.
196
262
  *
@@ -211,16 +277,21 @@ export function assertFileScopeSubset(fileScope, allowedPaths) {
211
277
  const missing = [];
212
278
  for (const entry of fileScope) {
213
279
  if (typeof entry !== 'string' || entry.length === 0) continue;
280
+ // The AGENT side is compared in canonical spelling — the spelling the union
281
+ // emits and the write path Gate 7 matches. `allowedPaths` stays as written:
282
+ // a hand-written `./x` there is a pattern Gate 7 cannot match, and spelling
283
+ // it away here would report coverage the hook then denies.
284
+ const spelled = scopeEntrySpelling(entry);
214
285
  let covered;
215
- if (isGlobScopeEntry(entry)) {
286
+ if (isGlobScopeEntry(spelled)) {
216
287
  // GLOB entry: verbatim presence OR literal-prefix coverage.
217
- const prefix = literalScopePrefix(entry);
218
- covered = allowedPaths.some((p) => p === entry || pathMatchesPattern(prefix, p));
288
+ const prefix = literalScopePrefix(spelled);
289
+ covered = allowedPaths.some((p) => p === spelled || pathMatchesPattern(prefix, p));
219
290
  } else {
220
291
  // CONCRETE entry: must match ≥1 allowedPaths pattern.
221
- covered = allowedPaths.some((p) => pathMatchesPattern(entry, p));
292
+ covered = allowedPaths.some((p) => pathMatchesPattern(spelled, p));
222
293
  }
223
- if (!covered) missing.push(entry);
294
+ if (!covered) missing.push(entry); // as declared, so the plan entry is findable
224
295
  }
225
296
  return { ok: missing.length === 0, missing };
226
297
  }
@@ -329,13 +400,16 @@ export function testSiblingExpansionApplies(opts = {}) {
329
400
  *
330
401
  * ## Why a GLOB and not a computed path
331
402
  * The emitted sibling is `tests/**\/{basename}*.test.mjs`, never a concrete
332
- * path. Measured over all 430 tracked production `.mjs` in this repo:
333
- * a naive 1:1 mirror (`scripts/lib/X.mjs → tests/lib/X.test.mjs`) is right
334
- * 301/430 = 70.0% (`hooks/_lib` 0/5, `scripts/ci/` 0/1); a same-basename test
335
- * ANYWHERE under `tests/` is right 368/430 = 85.6%. The glob takes the 85.6%
336
- * form, and its failure mode is HARMLESS — it grants write access to files that
337
- * may not exist. A computed concrete path is wrong 30% of the time AND still
338
- * denies the real test, which is the original bug wearing a new face.
403
+ * path: a same-basename test ANYWHERE under `tests/` resolves for far more
404
+ * production files than a naive 1:1 mirror (`scripts/lib/X.mjs →
405
+ * tests/lib/X.test.mjs`). The DATED figures — population, both ratios, the
406
+ * measuring command and the SHA — live in ONE place:
407
+ * skills/wave-executor/references/wave-loop-scope-manifest.md § Test-Sibling
408
+ * Expansion (#970). Cite and re-measure them there, never restate them here: an
409
+ * undated copy drifted to a second, contradicting set of numbers (#1026.5,
410
+ * PSA-006 item 4). The glob's failure mode is HARMLESS — it grants write access
411
+ * to files that may not exist. A computed concrete path is wrong more often AND
412
+ * still denies the real test, which is the original bug wearing a new face.
339
413
  *
340
414
  * ## Required behaviours (each is a nameable regression)
341
415
  * 1. `[]` → `[]` STRUCTURALLY. Discovery waves use an empty scope as a
@@ -403,8 +477,11 @@ export function testSiblingsFor(entries, opts = {}) {
403
477
  : DEFAULT_TEST_PATH_PATTERNS;
404
478
  const out = [];
405
479
  const seen = new Set();
406
- for (const entry of Array.isArray(entries) ? entries : []) {
407
- if (typeof entry !== 'string' || entry.length === 0) continue;
480
+ for (const declared of Array.isArray(entries) ? entries : []) {
481
+ if (typeof declared !== 'string' || declared.length === 0) continue;
482
+ // Canonical spelling, as the union carries it: a verbatim `./tests/x.mjs`
483
+ // misses `tests/**` and would be mirrored as if it were production code.
484
+ const entry = scopeEntrySpelling(declared);
408
485
  if (path.isAbsolute(entry)) continue; // behaviour 3
409
486
  if (isGlobScopeEntry(entry)) continue; // no single basename to mirror
410
487
  if (isTestPathEntry(entry, testPatterns)) continue; // behaviours 2 + 4
@@ -940,6 +1017,53 @@ export function isPeerRecordId(id) {
940
1017
  return typeof id === 'string' && id.startsWith(PEER_RECORD_PREFIX);
941
1018
  }
942
1019
 
1020
+ /**
1021
+ * Record id of the COORDINATOR's own planned direct edits (#1020): one
1022
+ * `{id: 'coordinator', files}` record in a wave's aggregate sidecar, and
1023
+ * `<state-dir>/filescopes/wave-<N>/coordinator.json` in the per-agent shape.
1024
+ * Exactly lowercase — `materialize-wave-scope.mjs` rejects any other casing.
1025
+ *
1026
+ * The coordinator is a declarant but never a DISPATCH: no `FILE-SCOPE` block is
1027
+ * injected for it, no digest is echoed back, no agent is counted for it. Every
1028
+ * consumer that joins scope files against dispatches must skip it, or it reports
1029
+ * the coordinator's own scope as an omitted injection (#1493.2). SSOT beside
1030
+ * {@link PEER_RECORD_PREFIX} for the same reason that one lives here.
1031
+ */
1032
+ export const COORDINATOR_RECORD_ID = 'coordinator';
1033
+
1034
+ /**
1035
+ * Is this record id the coordinator's (see {@link COORDINATOR_RECORD_ID})?
1036
+ * Exact match; a non-string id is not the coordinator.
1037
+ *
1038
+ * @param {unknown} id
1039
+ * @returns {boolean}
1040
+ */
1041
+ export function isCoordinatorRecordId(id) {
1042
+ return id === COORDINATOR_RECORD_ID;
1043
+ }
1044
+
1045
+ /**
1046
+ * Is this repo-relative path inside a wave's per-agent scope declarations,
1047
+ * `<state-dir>/filescopes/` (shape (a) and the aggregate sidecar, #1020)?
1048
+ *
1049
+ * Those files are coordinator CONTROL STATE like `wave-scope.json` itself,
1050
+ * written by `materialize-wave-scope.mjs` once per wave — never a wave
1051
+ * territory, so a write there is not a scope violation (#1027 Pkt 5). The
1052
+ * caller passes the manifest's own repo-relative path, so the state dir is the
1053
+ * one THIS manifest lives in, never a guessed `.claude/`.
1054
+ *
1055
+ * @param {string} relPath repo-relative, forward slashes
1056
+ * @param {string} scopeRelPath repo-relative path of the active `wave-scope.json`
1057
+ * @returns {boolean}
1058
+ */
1059
+ export function isScopeDeclarationPath(relPath, scopeRelPath) {
1060
+ if (typeof relPath !== 'string' || typeof scopeRelPath !== 'string') return false;
1061
+ const scopeRel = scopeRelPath.replace(/\\/g, '/');
1062
+ const cut = scopeRel.lastIndexOf('/');
1063
+ if (cut <= 0) return false;
1064
+ return relPath.startsWith(`${scopeRel.slice(0, cut)}/filescopes/`);
1065
+ }
1066
+
943
1067
  /**
944
1068
  * Merge many agents' declared file scopes into ONE deduplicated, order-stable
945
1069
  * list — the mechanical form of "allowedPaths is the UNION of all agent file
@@ -965,6 +1089,14 @@ export function isPeerRecordId(id) {
965
1089
  * non-array / non-object members and non-string, empty entries are skipped.
966
1090
  * Pure, sync, no I/O — hook-safe per the module header.
967
1091
  *
1092
+ * ENTRIES ARE EMITTED IN CANONICAL SPELLING ({@link scopeEntrySpelling}) and
1093
+ * deduplicated on it. Gate 7 matches the repo-relative WRITE path against each
1094
+ * entry, so a verbatim `./scripts/lib/foo.mjs` was a pattern no write could
1095
+ * ever match — the declaring agent's own write was denied — and kept a second
1096
+ * spelling of `scripts/lib/foo.mjs` beside it. The agent-side checks
1097
+ * ({@link assertFileScopeSubset}, {@link testSiblingsFor}) compare the same
1098
+ * spelling, so a per-agent file that keeps the plan's `./x` still passes.
1099
+ *
968
1100
  * PEER RECORDS ARE EXCLUDED (#1195 follow-through). A record whose id starts
969
1101
  * with `peer-session-` declares a territory NO agent of this wave may write —
970
1102
  * it exists so a peer's paths take part in the DISJOINTNESS check and so
@@ -995,9 +1127,10 @@ export function unionFileScopes(scopes) {
995
1127
  if (files === null) continue;
996
1128
  for (const entry of files) {
997
1129
  if (typeof entry !== 'string' || entry.length === 0) continue;
998
- if (seen.has(entry)) continue;
999
- seen.add(entry);
1000
- out.push(entry);
1130
+ const spelled = scopeEntrySpelling(entry);
1131
+ if (seen.has(spelled)) continue;
1132
+ seen.add(spelled);
1133
+ out.push(spelled);
1001
1134
  }
1002
1135
  }
1003
1136
  return out;
@@ -1122,8 +1255,11 @@ function globsDisagreeOnLiteralSegment(x, y) {
1122
1255
  * NEGATIVE — the whole point of this function is that an unreviewed scope is
1123
1256
  * exactly the one that collides.
1124
1257
  *
1258
+ * `files` holds each entry's comparison SPELLING ({@link scopeEntrySpelling});
1259
+ * `declared` holds the entry as written, index-aligned, for the evidence report.
1260
+ *
1125
1261
  * @param {Array<{id?: string, files?: string[]}>} agentScopes
1126
- * @returns {Array<{id: string, declaredId: string|null, files: string[]}>}
1262
+ * @returns {Array<{id: string, declaredId: string|null, files: string[], declared: string[]}>}
1127
1263
  */
1128
1264
  function normalizeAgentScopes(agentScopes) {
1129
1265
  const out = [];
@@ -1131,14 +1267,43 @@ function normalizeAgentScopes(agentScopes) {
1131
1267
  const raw = agentScopes[i];
1132
1268
  if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) continue;
1133
1269
  const declaredId = typeof raw.id === 'string' && raw.id.length > 0 ? raw.id : null;
1134
- const files = Array.isArray(raw.files)
1270
+ const declared = Array.isArray(raw.files)
1135
1271
  ? raw.files.filter((f) => typeof f === 'string' && f.length > 0)
1136
1272
  : [];
1137
- out.push({ id: declaredId ?? `<unnamed#${i}>`, declaredId, files });
1273
+ const files = declared.map(scopeEntrySpelling);
1274
+ out.push({ id: declaredId ?? `<unnamed#${i}>`, declaredId, files, declared });
1138
1275
  }
1139
1276
  return out;
1140
1277
  }
1141
1278
 
1279
+ /**
1280
+ * One scope entry's canonical SPELLING — THE spelling rule of this module.
1281
+ * Used by the collision comparison (#1026.3), by {@link unionFileScopes} (whose
1282
+ * output IS `allowedPaths`, so Gate 7 sees this spelling), and on the agent side
1283
+ * of {@link assertFileScopeSubset} / {@link testSiblingsFor}, so a per-agent file
1284
+ * that keeps the plan's declaration verbatim still agrees with the union.
1285
+ * Equal-meaning spellings compared unequal before: `./scripts/lib/foo.mjs` vs
1286
+ * `scripts/lib/foo.mjs` was reported disjoint (measured 2026-10-03 @ 8292050e).
1287
+ * It changes no grant's MEANING — only how one is spelled.
1288
+ *
1289
+ * Collapses runs of `/` and drops `.` segments (`./a`, `a/./b`). A leading `/`
1290
+ * (absolute entry) and a trailing `/` survive: the trailing slash is meaning,
1291
+ * not spelling — `scripts/lib/` is a prefix grant, while `scripts/lib` grants
1292
+ * only the literal path `scripts/lib` (`pathMatchesPattern('scripts/lib/foo.mjs',
1293
+ * 'scripts/lib') === false`), so those two stay disjoint.
1294
+ *
1295
+ * NAMED CEILING: `..` segments are left alone — `a/../b` is not provably `b`
1296
+ * when `a` is a glob segment. Revisit if a plan ever declares a `..` entry.
1297
+ *
1298
+ * @param {string} entry - a non-empty scope entry
1299
+ * @returns {string}
1300
+ */
1301
+ function scopeEntrySpelling(entry) {
1302
+ const spelled = entry.replace(/\/{2,}/g, '/').replace(/(^|\/)(?:\.\/)+/g, '$1');
1303
+ // `./` alone would spell as '' — keep the entry rather than invent a meaning.
1304
+ return spelled.length > 0 ? spelled : entry;
1305
+ }
1306
+
1142
1307
  /**
1143
1308
  * Classify a SINGLE cross-agent entry pair, in the binding three-stage order.
1144
1309
  * Returns the collision `kind`, or `null` when the two entries are disjoint.
@@ -1316,17 +1481,19 @@ export function findScopeCollisions(agentScopes, opts = {}) {
1316
1481
  const b = agents[j];
1317
1482
  if (a.id === b.id) continue; // duplicate-id record: reported separately
1318
1483
  const buckets = new Map();
1319
- for (const x of a.files) {
1320
- for (const y of b.files) {
1321
- const kind = classifyEntryCollision(x, y, expand);
1484
+ // Classify on the comparison spelling; report the entry AS DECLARED, so
1485
+ // the operator can find it in the plan (#1026.3).
1486
+ for (let xi = 0; xi < a.files.length; xi++) {
1487
+ for (let yi = 0; yi < b.files.length; yi++) {
1488
+ const kind = classifyEntryCollision(a.files[xi], b.files[yi], expand);
1322
1489
  if (kind === null) continue;
1323
1490
  let evidence = buckets.get(kind);
1324
1491
  if (evidence === undefined) {
1325
1492
  evidence = new Set();
1326
1493
  buckets.set(kind, evidence);
1327
1494
  }
1328
- evidence.add(x);
1329
- evidence.add(y);
1495
+ evidence.add(a.declared[xi]);
1496
+ evidence.add(b.declared[yi]);
1330
1497
  }
1331
1498
  }
1332
1499
  for (const kind of KIND_ORDER) {