session-orchestrator 3.19.0 → 3.21.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 (158) 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/030-wave-execution.mdc +10 -8
  5. package/CHANGELOG.md +494 -0
  6. package/README.md +16 -11
  7. package/agents/analyst.md +1 -1
  8. package/agents/architect-reviewer.md +1 -1
  9. package/agents/code-implementer.md +4 -2
  10. package/agents/db-specialist.md +1 -1
  11. package/agents/dialectic-deriver.md +1 -1
  12. package/agents/docs-writer.md +1 -1
  13. package/agents/memory-proposal-collector.md +1 -1
  14. package/agents/qa-strategist.md +1 -1
  15. package/agents/security-reviewer.md +1 -1
  16. package/agents/session-reviewer.md +42 -1
  17. package/agents/skill-applied-judge.md +1 -1
  18. package/agents/test-writer.md +1 -1
  19. package/agents/ui-developer.md +1 -1
  20. package/agents/ux-evaluator.md +1 -1
  21. package/commands/release.md +60 -0
  22. package/commands/session.md +6 -2
  23. package/docs/USER-GUIDE.md +1 -1
  24. package/docs/instruction-delivery.md +350 -0
  25. package/docs/migration-v3.md +9 -6
  26. package/docs/persona-panel.md +3 -1
  27. package/docs/scope-collision-guard.md +167 -0
  28. package/docs/session-config-reference.md +1 -41
  29. package/docs/session-config-template.md +0 -23
  30. package/hooks/_lib/guard-source-loader.mjs +304 -91
  31. package/hooks/enforce-commands.mjs +216 -17
  32. package/hooks/enforce-scope.mjs +236 -12
  33. package/hooks/hooks-codex.json +1 -1
  34. package/hooks/hooks.json +11 -1
  35. package/hooks/on-session-end.mjs +52 -5
  36. package/hooks/on-session-start.mjs +7 -4
  37. package/hooks/on-stop.mjs +127 -12
  38. package/hooks/post-bash-write-verify.mjs +8 -32
  39. package/hooks/pre-bash-destructive-guard.mjs +146 -59
  40. package/hooks/pre-bash-sessions-ledger-guard.mjs +493 -66
  41. package/hooks/pre-task-scope-disjoint.mjs +1042 -0
  42. package/package.json +2 -2
  43. package/pi/prompts/release.md +12 -0
  44. package/scripts/autopilot.mjs +3 -1
  45. package/scripts/backfill-learnings-from-vault.mjs +967 -0
  46. package/scripts/emit-session.mjs +45 -40
  47. package/scripts/export-hw-learnings.mjs +61 -2
  48. package/scripts/lib/autopilot/worktree-pipeline.mjs +5 -5
  49. package/scripts/lib/backlog-scan.mjs +106 -15
  50. package/scripts/lib/build-live-signals.mjs +7 -3
  51. package/scripts/lib/ci-status-banner.mjs +207 -23
  52. package/scripts/lib/command-blocker.mjs +322 -62
  53. package/scripts/lib/git-config-drift.mjs +471 -0
  54. package/scripts/lib/hardening.mjs +9 -9
  55. package/scripts/lib/harness-audit/categories/category6.mjs +65 -12
  56. package/scripts/lib/io.mjs +193 -7
  57. package/scripts/lib/learnings/affinity.mjs +434 -0
  58. package/scripts/lib/learnings/candidates.mjs +736 -0
  59. package/scripts/lib/learnings/expiry-sweep.mjs +408 -53
  60. package/scripts/lib/learnings/judgment.mjs +782 -0
  61. package/scripts/lib/learnings/kebab.mjs +128 -0
  62. package/scripts/lib/learnings/select.mjs +704 -0
  63. package/scripts/lib/memory-cleanup-stamp.mjs +132 -8
  64. package/scripts/lib/mirror-issues-banner.mjs +266 -0
  65. package/scripts/lib/named-vault-resolver.mjs +105 -16
  66. package/scripts/lib/peer-cards/schema.mjs +6 -2
  67. package/scripts/lib/reconcile/emitter.mjs +107 -22
  68. package/scripts/lib/reconcile/engine.mjs +9 -15
  69. package/scripts/lib/reconcile/renderer.mjs +141 -25
  70. package/scripts/lib/reconcile/sanitize.mjs +518 -0
  71. package/scripts/lib/reconcile/writer.mjs +134 -1
  72. package/scripts/lib/redact-spans.mjs +89 -0
  73. package/scripts/lib/scope-baseline.mjs +77 -17
  74. package/scripts/lib/scope-gate.mjs +852 -72
  75. package/scripts/lib/secret-masker.mjs +262 -0
  76. package/scripts/lib/session-close-backfill.mjs +2 -2
  77. package/scripts/lib/session-lock.mjs +34 -10
  78. package/scripts/lib/session-record-repair.mjs +551 -0
  79. package/scripts/lib/session-registry.mjs +9 -1
  80. package/scripts/lib/session-schema/serializer.mjs +54 -0
  81. package/scripts/lib/session-schema.mjs +1 -0
  82. package/scripts/lib/session-token-rollup.mjs +68 -6
  83. package/scripts/lib/soul-resolve.mjs +12 -0
  84. package/scripts/lib/state-md/mission-status.mjs +21 -12
  85. package/scripts/lib/tmux-layout/telemetry.mjs +43 -10
  86. package/scripts/lib/tmux-layout/vcs-detector.mjs +108 -4
  87. package/scripts/lib/validate/check-agents.mjs +77 -5
  88. package/scripts/lib/validate/check-banner-parity.mjs +376 -0
  89. package/scripts/lib/validate/check-commands.mjs +2 -20
  90. package/scripts/lib/validate/check-doc-cli-commands.mjs +514 -0
  91. package/scripts/lib/validate/check-guard-requires-parity.mjs +1148 -0
  92. package/scripts/lib/validate/check-hooks-symmetry.mjs +18 -0
  93. package/scripts/lib/validate/check-learning-provenance.mjs +511 -0
  94. package/scripts/lib/validate/check-owner-leakage.mjs +188 -20
  95. package/scripts/lib/validate/check-rules.mjs +31 -5
  96. package/scripts/lib/validate/check-skills.mjs +191 -0
  97. package/scripts/lib/validate/check-test-git-config-target.mjs +665 -0
  98. package/scripts/lib/validate/check-unicode-safety.mjs +22 -2
  99. package/scripts/lib/validate/check-untracked-test-deps.mjs +925 -0
  100. package/scripts/lib/validate/check-unwired-features.mjs +757 -0
  101. package/scripts/lib/validate/check-vcs-repo-flag.mjs +965 -0
  102. package/scripts/lib/validate/frontmatter-block.mjs +61 -0
  103. package/scripts/lib/validate/tier-inference.mjs +46 -8
  104. package/scripts/lib/vault-mirror/namespace.mjs +146 -1
  105. package/scripts/lib/vault-mirror/process.mjs +264 -31
  106. package/scripts/lib/vault-mirror/render-sessions.mjs +115 -4
  107. package/scripts/lib/vault-status/board-writer.mjs +300 -56
  108. package/scripts/lib/vault-status/narrative-mirror.mjs +119 -5
  109. package/scripts/lib/vcs-repo-spec.mjs +500 -19
  110. package/scripts/print-applicable-rules.mjs +170 -7
  111. package/scripts/print-learnings-index.mjs +501 -0
  112. package/scripts/release.mjs +616 -61
  113. package/scripts/repair-invalid-sessions.mjs +209 -0
  114. package/scripts/site-numbers.mjs +1049 -0
  115. package/scripts/sweep-expired-learnings.mjs +192 -32
  116. package/scripts/validate-plugin.mjs +82 -0
  117. package/scripts/validate-wave-scope.mjs +281 -12
  118. package/scripts/vault-mirror.mjs +26 -1
  119. package/skills/_shared/monitor-patterns.md +24 -4
  120. package/skills/_shared/state-ownership.md +17 -0
  121. package/skills/brainstorm/soul.md +47 -1
  122. package/skills/claude-md-drift-check/SKILL.md +9 -1
  123. package/skills/debug/SKILL.md +4 -1
  124. package/skills/discovery/issue-templates.md +4 -4
  125. package/skills/discovery/probes-code.md +2 -2
  126. package/skills/discovery/probes-feature.md +6 -6
  127. package/skills/discovery/probes-infra.md +2 -2
  128. package/skills/discovery/probes-session.md +5 -5
  129. package/skills/dispatcher/SKILL.md +10 -1
  130. package/skills/evolve/SKILL.md +116 -18
  131. package/skills/frontmatter-guard/SKILL.md +9 -1
  132. package/skills/gitlab-ops/SKILL.md +54 -39
  133. package/skills/gitlab-portfolio/SKILL.md +10 -1
  134. package/skills/grill/soul.md +44 -1
  135. package/skills/memory-cleanup/SKILL.md +18 -5
  136. package/skills/npm-publish/SKILL.md +22 -50
  137. package/skills/persona-panel/SKILL.md +3 -1
  138. package/skills/plan/mode-new.md +23 -5
  139. package/skills/plan/soul.md +46 -3
  140. package/skills/repo-audit/SKILL.md +10 -1
  141. package/skills/session-end/SKILL.md +45 -26
  142. package/skills/session-end/metrics-collection.md +1 -1
  143. package/skills/session-end/phase-3-6-tail.md +30 -1
  144. package/skills/session-end/plan-verification.md +1 -5
  145. package/skills/session-end/session-metrics-write.md +6 -10
  146. package/skills/session-plan/SKILL.md +2 -2
  147. package/skills/session-plan/wave-template.md +1 -1
  148. package/skills/session-start/SKILL.md +15 -1
  149. package/skills/session-start/soul.md +41 -1
  150. package/skills/spinout/SKILL.md +5 -1
  151. package/skills/sunset-review/SKILL.md +11 -1
  152. package/skills/tmux-layout/SKILL.md +7 -2
  153. package/skills/vault-mirror/SKILL.md +10 -1
  154. package/skills/vault-sync/SKILL.md +10 -1
  155. package/skills/vault-sync/validator.mjs +55 -6
  156. package/skills/wave-executor/SKILL.md +1 -5
  157. package/skills/wave-executor/wave-loop.md +77 -82
  158. package/scripts/lib/mission-status-schema.mjs +0 -114
@@ -14,7 +14,7 @@
14
14
  import { existsSync, readFileSync } from 'node:fs';
15
15
  import path from 'node:path';
16
16
 
17
- import { tokenizeCommand } from './command-blocker.mjs';
17
+ import { tokenizeCommand, splitChainSegments, resolveSegmentVerb } from './command-blocker.mjs';
18
18
 
19
19
  /**
20
20
  * Find the wave-scope.json file for the given project root.
@@ -573,6 +573,25 @@ export function assertTestSiblingCoverage(fileScope, allowedPaths, opts = {}) {
573
573
  * emits no token at all — an improvement: `tee out.txt <<EOF` used to report
574
574
  * the literal `EOF` as a second write target.)
575
575
  *
576
+ * ## Segment-first, wrapper-aware verb resolution (#996.2)
577
+ *
578
+ * The interpretation is SEGMENT-first: the token stream is split on shell chain
579
+ * operators / newlines by {@link splitChainSegments} BEFORE it is interpreted,
580
+ * and each segment's real command verb is resolved through the shared
581
+ * {@link resolveSegmentVerb} wrapper table. A lone `expectCommand` flag used to
582
+ * read the FIRST word of a segment as the command head, so a transparent wrapper
583
+ * (`sudo`, `env FOO=1`, `timeout 5`, `nice -n 10`, `command`, `/usr/bin/time`)
584
+ * consumed the head slot and the REAL verb (`tee`/`sed`/`dd`) landed in argument
585
+ * position where it was never matched — measured `sudo tee src/x.ts` → `[]`, a
586
+ * DETECTION LOSS. This is the same fail-open class #991 closed in the ledger
587
+ * guard, in a warn-only (#800) consumer. `resolveSegmentVerb` unwraps the
588
+ * wrappers and returns a BASENAME-normalized verb, so an absolute `/usr/bin/tee`
589
+ * resolves to `tee` for free. It ALSO reports wrapper-written file operands
590
+ * (`writesFile: true`, the #992 discriminator) — `/usr/bin/time -o FILE npm test`
591
+ * truncates FILE while the verb is `npm`, a write with no redirect operator and
592
+ * no tee/sed/dd head; those are harvested exactly as {@link extractRedirectTargets}
593
+ * does.
594
+ *
576
595
  * Hook-safe: pure, deterministic, no I/O. Never throws — a non-string / empty
577
596
  * input returns `[]`.
578
597
  *
@@ -582,8 +601,6 @@ export function assertTestSiblingCoverage(fileScope, allowedPaths, opts = {}) {
582
601
  export function extractBashWriteTargets(command) {
583
602
  if (typeof command !== 'string' || command.length === 0) return [];
584
603
 
585
- const tokens = classifyShellTokens(tokenizeCommand(command));
586
-
587
604
  const out = [];
588
605
  const seen = new Set();
589
606
  const add = (value) => {
@@ -594,21 +611,104 @@ export function extractBashWriteTargets(command) {
594
611
  out.push(v);
595
612
  };
596
613
 
597
- // Second pass: interpret the token stream. `mode` tracks a command-head that
598
- // owns following args (tee/sed/dd); `pendingRedirect` marks that the NEXT word
599
- // token is a redirect target.
600
- let mode = null; // null | 'tee' | 'sed' | 'dd'
601
- let pendingRedirect = false;
602
- // A here-doc was opened: `tokenizeCommand` will emit its BODY as an ordinary
603
- // quoted word token, and a single-word body (`hello`) would otherwise be read
604
- // as a `tee`/`sed`/`dd` file argument. Explicit `>` redirect targets stay
605
- // accepted those carry their own operator and cannot be body text.
606
- let heredocOpen = false;
607
- let expectCommand = true; // next word is the command head of this segment
608
- let sedArgs = []; // { value } collected for a `sed` head
609
- let sedInPlace = false;
610
-
611
- const flushSed = () => {
614
+ // Segment-first (#996.2): split on shell chain operators / newlines BEFORE
615
+ // interpreting, then resolve each segment's REAL verb through the shared
616
+ // wrapper table. `out`/`seen`/`add` stay outside the loop so de-duplication
617
+ // and first-seen ordering hold across segments (`echo a > x; echo b >> x`).
618
+ for (const segment of splitChainSegments(tokenizeCommand(command))) {
619
+ // #996.2 namespace fix: resolve the verb in the SAME (paren-peeled,
620
+ // redirect-target-excluded) namespace the interpretation loop's `wordsSeen`
621
+ // counter walks below see {@link verbResolutionSegment}. Passing the RAW
622
+ // segment mis-resolved a LEADING redirect (`> a.txt tee b.ts` verb `>`) or a
623
+ // LEADING subshell paren (`(tee inner.ts)` → verb `(tee`): mode never became
624
+ // `tee`/`sed`/`dd` and the `index + 1` head-skip then swallowed the real
625
+ // write target. For A5's 10 wrapper forms (no leading redirect/paren) the view
626
+ // is a no-op, so verb/index/wrapperArgs — incl. the #992 `writesFile` marks —
627
+ // are byte-identical to the raw call.
628
+ const { verb, index, wrapperArgs } = resolveSegmentVerb(verbResolutionSegment(segment));
629
+ const tokens = classifyShellTokens(segment);
630
+
631
+ // A wrapper can WRITE a file through its OWN option, with no redirect and no
632
+ // tee/sed/dd verb: `/usr/bin/time -o src/report.txt npm test` truncates
633
+ // src/report.txt while the resolved verb is `npm`. `resolveSegmentVerb` marks
634
+ // exactly those operands `writesFile: true` (the #992 discriminator that
635
+ // keeps `nice -n 10` / `sudo -u root` operands OUT). Harvest them exactly as
636
+ // collectRedirectTargets does — the write is otherwise invisible to this pass.
637
+ for (const wa of wrapperArgs) {
638
+ if (wa.writesFile === true && typeof wa.value === 'string') add(wa.value);
639
+ }
640
+
641
+ // `mode` tracks a command-head that owns following args (tee/sed/dd). It is
642
+ // set ONCE per segment from the resolved verb, which `resolveSegmentVerb`
643
+ // returns basename-normalized — so an absolute `/usr/bin/tee` resolves to
644
+ // `tee` for free. `pendingRedirect` marks that the NEXT word is a redirect
645
+ // target; `heredocOpen` that a here-doc BODY word (DATA) follows.
646
+ const mode = verb === 'tee' || verb === 'sed' || verb === 'dd' ? verb : null;
647
+ let pendingRedirect = false;
648
+ let heredocOpen = false;
649
+ const sedArgs = []; // classified word tokens collected for a `sed -i` head
650
+ let sedInPlace = false;
651
+
652
+ // THE ONE CARE POINT of the #996.2 refactor: the first `index + 1` WORD
653
+ // tokens that REACH the `wordsSeen` counter are the wrapper chain + the verb
654
+ // itself, never file arguments. `index` now comes from
655
+ // {@link verbResolutionSegment}, which resolves the verb in the SAME namespace
656
+ // this loop walks — paren-peeled words with redirect targets removed — so
657
+ // `index` is 1:1 with the `word` tokens that reach the counter below (a
658
+ // leading redirect or subshell paren no longer shifts it out of alignment).
659
+ // `peelSubshellParens` emits AT MOST one `word` per raw token.
660
+ const headWordsToSkip = index + 1;
661
+ let wordsSeen = 0;
662
+
663
+ for (const tk of tokens) {
664
+ if (tk.type === 'redirect') {
665
+ pendingRedirect = true;
666
+ continue;
667
+ }
668
+ if (tk.type === 'heredoc') {
669
+ // here-doc: the delimiter emits no token; the BODY arrives later as data
670
+ pendingRedirect = false;
671
+ heredocOpen = true;
672
+ continue;
673
+ }
674
+ if (tk.type === 'in') {
675
+ // input redirect / here-string — not a write target
676
+ pendingRedirect = false;
677
+ continue;
678
+ }
679
+ if (tk.type === 'sep') {
680
+ // Within a segment the ONLY seps are peeled subshell parens — chain
681
+ // operators were already consumed by splitChainSegments. Reset only the
682
+ // pending redirect so `> >(cat)` stays inert (without it the peeled `cat`
683
+ // reads as the redirect target); mode/heredoc reset live at segment end.
684
+ pendingRedirect = false;
685
+ continue;
686
+ }
687
+ // word token
688
+ if (pendingRedirect) {
689
+ add(tk.value);
690
+ pendingRedirect = false;
691
+ continue;
692
+ }
693
+ if (wordsSeen < headWordsToSkip) {
694
+ wordsSeen++; // wrapper chain + verb head — not a file argument
695
+ continue;
696
+ }
697
+ // subsequent argument words, interpreted per active command-head mode
698
+ if (heredocOpen) continue; // DATA, not a file argument
699
+ if (mode === 'tee') {
700
+ if (!isShellFlag(tk.value)) add(tk.value);
701
+ } else if (mode === 'sed') {
702
+ if (/^-i/.test(tk.value)) sedInPlace = true;
703
+ sedArgs.push(tk);
704
+ } else if (mode === 'dd') {
705
+ if (tk.value.startsWith('of=')) add(tk.value.slice(3));
706
+ }
707
+ }
708
+
709
+ // Segment end: flush a pending `sed -i` file argument (last non-flag arg).
710
+ // Only reached once per segment now that each segment carries a single verb,
711
+ // so the former sep-branch / defensive flushes are unnecessary.
612
712
  if (mode === 'sed' && sedInPlace) {
613
713
  for (let i = sedArgs.length - 1; i >= 0; i--) {
614
714
  if (!isShellFlag(sedArgs[i].value)) {
@@ -617,61 +717,7 @@ export function extractBashWriteTargets(command) {
617
717
  }
618
718
  }
619
719
  }
620
- sedArgs = [];
621
- sedInPlace = false;
622
- };
623
-
624
- for (const tk of tokens) {
625
- if (tk.type === 'redirect') {
626
- pendingRedirect = true;
627
- continue;
628
- }
629
- if (tk.type === 'heredoc') {
630
- // here-doc: the delimiter emits no token; the BODY arrives later as data
631
- pendingRedirect = false;
632
- heredocOpen = true;
633
- continue;
634
- }
635
- if (tk.type === 'in') {
636
- // input redirect / here-string — not a write target
637
- pendingRedirect = false;
638
- continue;
639
- }
640
- if (tk.type === 'sep') {
641
- flushSed();
642
- mode = null;
643
- pendingRedirect = false;
644
- heredocOpen = false;
645
- expectCommand = true;
646
- continue;
647
- }
648
- // word token
649
- if (pendingRedirect) {
650
- add(tk.value);
651
- pendingRedirect = false;
652
- continue;
653
- }
654
- if (expectCommand) {
655
- expectCommand = false;
656
- flushSed(); // flush any prior sed segment defensively
657
- if (tk.value === 'tee') { mode = 'tee'; continue; }
658
- if (tk.value === 'sed') { mode = 'sed'; continue; }
659
- if (tk.value === 'dd') { mode = 'dd'; continue; }
660
- mode = null;
661
- continue;
662
- }
663
- // subsequent argument words, interpreted per active command-head mode
664
- if (heredocOpen) continue; // DATA, not a file argument
665
- if (mode === 'tee') {
666
- if (!isShellFlag(tk.value)) add(tk.value);
667
- } else if (mode === 'sed') {
668
- if (/^-i/.test(tk.value)) sedInPlace = true;
669
- sedArgs.push(tk);
670
- } else if (mode === 'dd') {
671
- if (tk.value.startsWith('of=')) add(tk.value.slice(3));
672
- }
673
720
  }
674
- flushSed();
675
721
 
676
722
  return out;
677
723
  }
@@ -776,6 +822,82 @@ function classifyShellTokens(tokens) {
776
822
  return out;
777
823
  }
778
824
 
825
+ /**
826
+ * Project a raw chain segment into the token list used for VERB resolution,
827
+ * in the SAME namespace {@link extractBashWriteTargets}'s interpretation loop
828
+ * walks with its `wordsSeen` counter (#996.2 namespace fix).
829
+ *
830
+ * {@link resolveSegmentVerb} resolves argv[0] from RAW tokens. Two segment shapes
831
+ * put a NON-verb token in raw position 0 and so mis-resolve the command head,
832
+ * losing the write target the pre-#996.2 pass had detected:
833
+ * - a LEADING write-redirect — `> a.txt tee b.ts` resolves the `>` operator as
834
+ * the "verb" (mode never becomes `tee`), and the loop's `index + 1` head-skip
835
+ * then swallows `tee`, dropping `b.ts`.
836
+ * - a LEADING subshell paren — `(tee inner.ts)` keeps the raw token `(tee`
837
+ * (`.replace(/^.*\//,'')` leaves it untouched, no `/`), so the wrapper table
838
+ * never matches: verb is the literal `(tee`, again mode-less. The classified
839
+ * stream peels the paren AFTER, so the raw verb index and the peeled word
840
+ * stream diverge.
841
+ *
842
+ * The projection makes both namespaces agree: peel leading/trailing parens off
843
+ * each unquoted token (matching {@link peelSubshellParens}' WORD output) and drop
844
+ * every write-redirect operator together with its target word — exactly the
845
+ * tokens the interpretation loop keeps OUT of `wordsSeen` (redirect operators are
846
+ * not `word`s; their targets are consumed by the `pendingRedirect` branch before
847
+ * the counter). The resulting segment's verb INDEX is then 1:1 with `wordsSeen`,
848
+ * so `index + 1` skips the wrapper chain + verb and no more.
849
+ *
850
+ * A5's 10 transparent-wrapper forms carry neither a leading redirect nor a paren,
851
+ * so this is a NO-OP for them: `resolveSegmentVerb`'s verb/index/wrapperArgs (incl.
852
+ * the #992 `writesFile` operand marks) are byte-identical to the raw-segment call.
853
+ *
854
+ * @param {Array<{ text: string, quoted: boolean }>} segment
855
+ * @returns {Array<{ text: string, quoted: boolean }>}
856
+ */
857
+ function verbResolutionSegment(segment) {
858
+ const out = [];
859
+ let pendingRedirectTarget = false;
860
+ for (const tok of segment) {
861
+ if (!tok.quoted) {
862
+ // A write redirect consumes the NEXT token as its target — neither the
863
+ // operator nor its target reaches `wordsSeen`, so both must be absent here
864
+ // to keep the verb index aligned.
865
+ if (WRITE_REDIRECT_OP_RE.test(tok.text)) {
866
+ pendingRedirectTarget = true;
867
+ continue;
868
+ }
869
+ // heredoc / input-redirect operators carry no immediate target token that
870
+ // reaches `wordsSeen` (the here-doc delimiter emits nothing; an input target
871
+ // DOES reach the counter and so is kept) — drop only the operator itself.
872
+ if (HEREDOC_OP_RE.test(tok.text) || INPUT_REDIRECT_OP_RE.test(tok.text)) {
873
+ pendingRedirectTarget = false;
874
+ continue;
875
+ }
876
+ // Within a segment the only separator that can appear is an fd-dup `&`
877
+ // (e.g. `2>&1`), which classifyShellTokens also treats as a `sep` that
878
+ // resets the pending redirect — mirror that so the target after it stays.
879
+ if (SHELL_SEPARATOR_OPS.has(tok.text)) {
880
+ pendingRedirectTarget = false;
881
+ continue;
882
+ }
883
+ }
884
+ if (pendingRedirectTarget) {
885
+ pendingRedirectTarget = false;
886
+ continue; // this token is the dropped write-redirect target
887
+ }
888
+ if (tok.quoted) {
889
+ out.push(tok);
890
+ continue;
891
+ }
892
+ let text = tok.text;
893
+ while (text.startsWith('(')) text = text.slice(1);
894
+ while (text.endsWith(')')) text = text.slice(0, -1);
895
+ if (text.length === 0) continue; // pure paren = a separator, not a word
896
+ out.push({ text, quoted: false });
897
+ }
898
+ return out;
899
+ }
900
+
779
901
  /**
780
902
  * Build an actionable suggestion string for a scope violation.
781
903
  *
@@ -795,3 +917,661 @@ export function suggestForScopeViolation(relPath, allowedCsv) {
795
917
  `If '${relPath}' belongs to this wave, add its directory to the plan's wave scope and restart.`
796
918
  );
797
919
  }
920
+
921
+ /**
922
+ * Merge many agents' declared file scopes into ONE deduplicated, order-stable
923
+ * list — the mechanical form of "allowedPaths is the UNION of all agent file
924
+ * scopes" (#1020, wave-loop.md § Scope Manifest #3).
925
+ *
926
+ * Motivation: today the coordinator writes `wave-scope.json` `allowedPaths` from
927
+ * one hand-kept list and the agent briefs from a SECOND, separately formulated
928
+ * list. Nothing couples them; they diverged five times in one session (#1020).
929
+ * Deriving the union FROM the per-agent declarations makes that divergence
930
+ * structurally impossible instead of discipline-dependent.
931
+ *
932
+ * Accepts BOTH input shapes, because the two call sites differ:
933
+ * - `[['a.mjs','b.mjs'], ['c.mjs']]` — bare scope arrays
934
+ * - `[{id:'W1-D1', files:['a.mjs']}, {id:'W1-D2', …}]` — the CLI/plan record
935
+ * shape, which is also {@link findScopeCollisions}' input (one source object
936
+ * feeding both consumers is the whole point of #1020).
937
+ *
938
+ * Order is INSERTION order (first-seen wins), never sorted: the union is written
939
+ * into a manifest that a human reads next to the plan, and a stable order keeps
940
+ * its diff readable across re-unions (#796 rewrites it mid-wave).
941
+ *
942
+ * Fail-closed & no-throw (module convention): a non-array input returns `[]`;
943
+ * non-array / non-object members and non-string, empty entries are skipped.
944
+ * Pure, sync, no I/O — hook-safe per the module header.
945
+ *
946
+ * @param {Array<string[]|{id?: string, files?: string[]}>} scopes
947
+ * @returns {string[]} deduplicated union in first-seen order
948
+ */
949
+ export function unionFileScopes(scopes) {
950
+ if (!Array.isArray(scopes)) return [];
951
+ const out = [];
952
+ const seen = new Set();
953
+ for (const scope of scopes) {
954
+ let files = null;
955
+ if (Array.isArray(scope)) files = scope;
956
+ else if (scope !== null && typeof scope === 'object' && Array.isArray(scope.files)) {
957
+ files = scope.files;
958
+ }
959
+ if (files === null) continue;
960
+ for (const entry of files) {
961
+ if (typeof entry !== 'string' || entry.length === 0) continue;
962
+ if (seen.has(entry)) continue;
963
+ seen.add(entry);
964
+ out.push(entry);
965
+ }
966
+ }
967
+ return out;
968
+ }
969
+
970
+ /**
971
+ * Is this scope entry RECURSIVE — does it grant everything below a directory?
972
+ * `**` is the explicit form; a trailing `/` is the implicit one, because
973
+ * {@link pathMatchesPattern} matches a `dir/` prefix with `startsWith`, i.e. at
974
+ * ANY depth. Both must count, or `tests/` vs `tests/lib/*.mjs` reads as disjoint.
975
+ * @param {string} entry
976
+ * @returns {boolean}
977
+ */
978
+ function isRecursiveScopeEntry(entry) {
979
+ return entry.includes('**') || entry.endsWith('/');
980
+ }
981
+
982
+ /**
983
+ * Literal SUFFIX of a glob entry — the text after its last `*` metachar
984
+ * (`src/**\/*.mjs` → `.mjs`, `src/**` → `''`). Returns `null` for an entry with
985
+ * no `*` at all (a `dir/` prefix), where the concept does not apply: such an
986
+ * entry constrains only the head of a path, never its tail.
987
+ * @param {string} entry
988
+ * @returns {string|null}
989
+ */
990
+ function literalScopeSuffix(entry) {
991
+ const star = entry.lastIndexOf('*');
992
+ return star === -1 ? null : entry.slice(star + 1);
993
+ }
994
+
995
+ /**
996
+ * Normalize the `agentScopes` input of {@link findScopeCollisions} into
997
+ * `{id, declaredId, files}` records. Never throws; malformed members are
998
+ * repaired rather than dropped.
999
+ *
1000
+ * A member with NO usable `id` keeps its files in the check under a synthetic
1001
+ * `<unnamed#i>` id (i = its index). Dropping it instead would be a FALSE
1002
+ * NEGATIVE — the whole point of this function is that an unreviewed scope is
1003
+ * exactly the one that collides.
1004
+ *
1005
+ * @param {Array<{id?: string, files?: string[]}>} agentScopes
1006
+ * @returns {Array<{id: string, declaredId: string|null, files: string[]}>}
1007
+ */
1008
+ function normalizeAgentScopes(agentScopes) {
1009
+ const out = [];
1010
+ for (let i = 0; i < agentScopes.length; i++) {
1011
+ const raw = agentScopes[i];
1012
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) continue;
1013
+ const declaredId = typeof raw.id === 'string' && raw.id.length > 0 ? raw.id : null;
1014
+ const files = Array.isArray(raw.files)
1015
+ ? raw.files.filter((f) => typeof f === 'string' && f.length > 0)
1016
+ : [];
1017
+ out.push({ id: declaredId ?? `<unnamed#${i}>`, declaredId, files });
1018
+ }
1019
+ return out;
1020
+ }
1021
+
1022
+ /**
1023
+ * Classify a SINGLE cross-agent entry pair, in the binding three-stage order.
1024
+ * Returns the collision `kind`, or `null` when the two entries are disjoint.
1025
+ *
1026
+ * Stage order is not cosmetic — see {@link findScopeCollisions} for why the two
1027
+ * exact stages must run BEFORE the approximate one.
1028
+ *
1029
+ * @param {string} x — an entry from agent A
1030
+ * @param {string} y — an entry from agent B
1031
+ * @param {(glob: string) => Set<string>} expand — memoized KNOWN-set expander
1032
+ * @returns {'concrete'|'glob-expanded'|'glob-prefix'|null}
1033
+ */
1034
+ function classifyEntryCollision(x, y, expand) {
1035
+ // Stage 1 — exact string equality. The commonest real case (#1020 Vorfall 3),
1036
+ // and the ONLY stage that works for a file that does not exist yet.
1037
+ if (x === y) return 'concrete';
1038
+
1039
+ const xIsGlob = isGlobScopeEntry(x);
1040
+ const yIsGlob = isGlobScopeEntry(y);
1041
+
1042
+ // Stage 2 — concrete vs glob. `pathMatchesPattern` is DIRECTED (arg 1 is a
1043
+ // literal path, arg 2 becomes the regex); used in that one correct direction
1044
+ // it is exact and needs no filesystem witness.
1045
+ if (!xIsGlob && yIsGlob) return pathMatchesPattern(x, y) ? 'concrete' : null;
1046
+ if (xIsGlob && !yIsGlob) return pathMatchesPattern(y, x) ? 'concrete' : null;
1047
+ if (!xIsGlob && !yIsGlob) return null; // two distinct concrete paths: disjoint
1048
+
1049
+ // Stage 3a — glob ∩ glob, decided by a shared WITNESS from the KNOWN set.
1050
+ const xHits = expand(x);
1051
+ for (const witness of expand(y)) {
1052
+ if (xHits.has(witness)) return 'glob-expanded';
1053
+ }
1054
+
1055
+ // Stage 3b — prefix fallback, for the intersection that exists only in files
1056
+ // NOT YET on disk (the KNOWN set cannot witness those).
1057
+ const xPrefix = literalScopePrefix(x);
1058
+ const yPrefix = literalScopePrefix(y);
1059
+ if (!(xPrefix.startsWith(yPrefix) || yPrefix.startsWith(xPrefix))) return null;
1060
+ if (!(isRecursiveScopeEntry(x) || isRecursiveScopeEntry(y))) return null;
1061
+ // Suffix compatibility is a NECESSARY condition, so filtering on it adds no
1062
+ // false negative: a string ending in both `sx` and `sy` forces the shorter to
1063
+ // be a suffix of the longer. It removes the obvious false positive
1064
+ // `scripts/**\/*.ts` vs `scripts/**\/*.mjs`, which share a prefix and are both
1065
+ // recursive yet can never match the same path.
1066
+ const xSuffix = literalScopeSuffix(x);
1067
+ const ySuffix = literalScopeSuffix(y);
1068
+ if (
1069
+ xSuffix !== null &&
1070
+ ySuffix !== null &&
1071
+ !(xSuffix.endsWith(ySuffix) || ySuffix.endsWith(xSuffix))
1072
+ ) {
1073
+ return null;
1074
+ }
1075
+ return 'glob-prefix';
1076
+ }
1077
+
1078
+ /**
1079
+ * Detect files claimed by TWO agents of the SAME wave, BEFORE dispatch (#1020).
1080
+ *
1081
+ * ## The bug this closes
1082
+ * `tests/scripts/sweep-expired-learnings-cli.test.mjs` was handed to two agents
1083
+ * of one wave (#1020 Vorfall 3). Nothing caught it up front: the pre-dispatch
1084
+ * assertion {@link assertFileScopeSubset} checks each agent against the union
1085
+ * (a SUBSET relation, which two overlapping agents both satisfy), and the
1086
+ * commit-time `wave-scope-commit-guard` only sees the union as well. It surfaced
1087
+ * afterwards, from an agent's own PSA-002 report. Per
1088
+ * `.claude/rules/parallel-sessions.md` § Decision Tree a file inside two
1089
+ * declared scopes of one dispatch round is never a benign sibling signal — it is
1090
+ * a deconfliction gap, and that round ended well by luck, not construction.
1091
+ *
1092
+ * ## The three stages, and why the order is binding
1093
+ * 1. **Exact string equality** → `concrete`. Covers the commonest real case AND
1094
+ * every file that does not exist yet (no filesystem witness required).
1095
+ * 2. **Concrete vs glob** via {@link pathMatchesPattern} → `concrete`. Exact and
1096
+ * I/O-free, because the matcher is used in its one correct direction.
1097
+ * 3. **Glob vs glob** — expand both against
1098
+ * `KNOWN = opts.knownFiles ∪ {every concrete entry of every agent}`;
1099
+ * a non-empty intersection is `glob-expanded`. As a fallback for files not
1100
+ * yet on disk, a literal-prefix containment plus at least one recursive
1101
+ * entry is `glob-prefix`.
1102
+ *
1103
+ * Stage 3 must come LAST because {@link pathMatchesPattern} is DIRECTED and
1104
+ * therefore useless for glob∩glob: it compiles argument 2 into a regex and tests
1105
+ * argument 1 as a literal string. Measured:
1106
+ * `pathMatchesPattern('scripts/**\/*.mjs', 'scripts/lib/*.mjs') === false`, even
1107
+ * though both match `scripts/lib/x.mjs`. {@link assertFileScopeSubset} documents
1108
+ * that boundary at its own glob branch and OVER-approximates coverage, which is
1109
+ * the safe direction for a subset check. For a COLLISION check the sign flips:
1110
+ * the same over-approximation becomes a FALSE NEGATIVE — a missed collision,
1111
+ * i.e. exactly the incident. Hence stages 1 and 2 decide first, and stage 3 is
1112
+ * reached only for pairs neither of them can settle.
1113
+ *
1114
+ * ## Duplicate ids are a SEPARATE finding, not a collision
1115
+ * Two records carrying the same `id` are a malformed plan, not two agents
1116
+ * fighting over a file; reporting them as a self-collision (`a === b`) would be
1117
+ * noise. They are listed in `duplicateIds` and such pairs are skipped in the
1118
+ * pairwise scan. `duplicateIds` is always present (empty when clean) so
1119
+ * consumers need no conditional-key handling.
1120
+ *
1121
+ * ## `knownFiles` is INJECTED, never discovered
1122
+ * The module header's hook-safe invariant (pure, sync, no I/O, no process spawn)
1123
+ * is binding: `hooks/enforce-scope.mjs` reaches this module on a hot path, and
1124
+ * under the exit-0/stdout-JSON protocol a throw here reads as "no decision" =
1125
+ * ALLOW. So `git ls-files` belongs to the CLI layer and its result arrives as a
1126
+ * parameter. An absent/invalid `knownFiles` is not an error — stage 3a simply
1127
+ * has fewer witnesses and stage 3b carries the load.
1128
+ *
1129
+ * Fail-closed & no-throw: a non-array `agentScopes` returns
1130
+ * `{ ok: false, collisions: [], duplicateIds: [] }` ("cannot assert → treat as
1131
+ * failure", the same convention as {@link assertFileScopeSubset}).
1132
+ *
1133
+ * Output ordering is deterministic: agent pairs in input order, then kinds in
1134
+ * stage order (`concrete` → `glob-expanded` → `glob-prefix`); `evidence` holds
1135
+ * the involved entries of that kind, deduplicated in first-seen order.
1136
+ *
1137
+ * @param {Array<{id?: string, files?: string[]}>} agentScopes — one wave's agents
1138
+ * @param {{knownFiles?: string[]}} [opts] — existing repo files (injected)
1139
+ * @returns {{ok: boolean,
1140
+ * collisions: Array<{a: string, b: string, evidence: string[],
1141
+ * kind: 'concrete'|'glob-expanded'|'glob-prefix'}>,
1142
+ * duplicateIds: string[]}}
1143
+ */
1144
+ export function findScopeCollisions(agentScopes, opts = {}) {
1145
+ if (!Array.isArray(agentScopes)) return { ok: false, collisions: [], duplicateIds: [] };
1146
+ const options = opts !== null && typeof opts === 'object' ? opts : {};
1147
+ const agents = normalizeAgentScopes(agentScopes);
1148
+
1149
+ // Duplicate DECLARED ids (synthetic `<unnamed#i>` ids are unique by index).
1150
+ const seenIds = new Set();
1151
+ const dupIds = new Set(); // Set preserves insertion order → stable report
1152
+ for (const agent of agents) {
1153
+ if (agent.declaredId === null) continue;
1154
+ if (seenIds.has(agent.declaredId)) dupIds.add(agent.declaredId);
1155
+ else seenIds.add(agent.declaredId);
1156
+ }
1157
+ const duplicateIds = [...dupIds];
1158
+
1159
+ // KNOWN = injected repo files ∪ every concrete entry of every agent. The
1160
+ // second half matters: a file the wave is about to CREATE is not in
1161
+ // `git ls-files`, but if one agent names it concretely it can still witness
1162
+ // another agent's glob.
1163
+ const known = [];
1164
+ const knownSeen = new Set();
1165
+ const addKnown = (f) => {
1166
+ if (typeof f !== 'string' || f.length === 0 || knownSeen.has(f)) return;
1167
+ knownSeen.add(f);
1168
+ known.push(f);
1169
+ };
1170
+ if (Array.isArray(options.knownFiles)) options.knownFiles.forEach(addKnown);
1171
+ for (const agent of agents) {
1172
+ for (const entry of agent.files) if (!isGlobScopeEntry(entry)) addKnown(entry);
1173
+ }
1174
+
1175
+ const expansions = new Map();
1176
+ const expand = (glob) => {
1177
+ let hits = expansions.get(glob);
1178
+ if (hits === undefined) {
1179
+ hits = new Set(known.filter((f) => pathMatchesPattern(f, glob)));
1180
+ expansions.set(glob, hits);
1181
+ }
1182
+ return hits;
1183
+ };
1184
+
1185
+ const KIND_ORDER = ['concrete', 'glob-expanded', 'glob-prefix'];
1186
+ const collisions = [];
1187
+ for (let i = 0; i < agents.length; i++) {
1188
+ for (let j = i + 1; j < agents.length; j++) {
1189
+ const a = agents[i];
1190
+ const b = agents[j];
1191
+ if (a.id === b.id) continue; // duplicate-id record: reported separately
1192
+ const buckets = new Map();
1193
+ for (const x of a.files) {
1194
+ for (const y of b.files) {
1195
+ const kind = classifyEntryCollision(x, y, expand);
1196
+ if (kind === null) continue;
1197
+ let evidence = buckets.get(kind);
1198
+ if (evidence === undefined) {
1199
+ evidence = new Set();
1200
+ buckets.set(kind, evidence);
1201
+ }
1202
+ evidence.add(x);
1203
+ evidence.add(y);
1204
+ }
1205
+ }
1206
+ for (const kind of KIND_ORDER) {
1207
+ const evidence = buckets.get(kind);
1208
+ if (evidence !== undefined) {
1209
+ collisions.push({ a: a.id, b: b.id, evidence: [...evidence], kind });
1210
+ }
1211
+ }
1212
+ }
1213
+ }
1214
+
1215
+ return { ok: collisions.length === 0 && duplicateIds.length === 0, collisions, duplicateIds };
1216
+ }
1217
+
1218
+ // ---------------------------------------------------------------------------
1219
+ // Empty-`allowedPaths` classification (#1057)
1220
+ // ---------------------------------------------------------------------------
1221
+ //
1222
+ // ## The bug this fixes — and the one it deliberately does NOT
1223
+ //
1224
+ // FIVE distinct repository states produce `allowedPaths.length === 0`, and the
1225
+ // DENY VERDICT IS CORRECT IN ALL FIVE. What collapses is the REASON: every one
1226
+ // of them printed the single sentence {@link suggestForScopeViolation} emits for
1227
+ // an empty allowlist — "update the session plan and restart the wave".
1228
+ //
1229
+ // 1. A Discovery wave, where `[]` is the deliberate read-only contract
1230
+ // (`skills/session-plan/SKILL.md`, `wave-loop.md` § Scope Manifest #5, and
1231
+ // the #256 NO-OP regression lock in tests/hooks/enforce-scope.test.mjs).
1232
+ // There the sentence is CORRECT but misleading — nothing is broken.
1233
+ // 2. Corrupt JSON, which `hooks/enforce-scope.mjs` folds onto `scope = {}`
1234
+ // (#794 GAP-5), and malformed `allowedPaths` shapes, which `Array.isArray`
1235
+ // folds onto `[]` (#558). There the sentence is USELESS — the plan is fine,
1236
+ // the file is not.
1237
+ // 3. A leftover manifest from a session that crashed before deleting it.
1238
+ // There the sentence is ACTIVELY WRONG: restarting the wave does not remove
1239
+ // a file the previous session left behind — only the coordinator can clear
1240
+ // it, and the branch says so WITHOUT handing over the command (see "The
1241
+ // channel belongs to the AGENT" below, which is why).
1242
+ // 4. A writable role whose union came out empty because the coordinator's
1243
+ // `--union` step did not complete. There the operator must re-run `--union`,
1244
+ // not restart.
1245
+ //
1246
+ // The classifier below is the discriminator. It changes NO verdict — see
1247
+ // {@link suggestForEmptyScope}, which only ever selects a different sentence.
1248
+ //
1249
+ // ## The channel belongs to the AGENT — no branch may carry a runnable command
1250
+ //
1251
+ // Every string {@link suggestForEmptyScope} returns travels in
1252
+ // `permissionDecisionReason`, which the harness feeds to the CLAUDE whose Edit
1253
+ // was just denied. A remediation command placed there is an instruction the
1254
+ // DENIED AGENT can run — and for this guard the remediation IS the disarm:
1255
+ // removing `wave-scope.json` drops `hooks/enforce-scope.mjs` at Gate 3
1256
+ // (`if (!scopePath) return emitAllow()`), which takes out project-root
1257
+ // containment (Gate 6) as well as the allowlist check (Gate 7), for the rest of
1258
+ // the session. Measured 2026-08-19 against HEAD 55279a2: `rm -f <manifest>`
1259
+ // exits 0 with no envelope from `hooks/pre-bash-destructive-guard.mjs` (its
1260
+ // only rm rule is `rm-rf-destructive`, which needs `-r`), and with the manifest
1261
+ // gone a `Write` to `/etc` is ALLOWED. The first shape of this branch shipped
1262
+ // exactly that command, so the guard was handing the agent the key to itself.
1263
+ //
1264
+ // The rule this leaves behind: a branch may DIAGNOSE without limit and may name
1265
+ // the manifest as the SUBJECT of its diagnosis; it may never carry a runnable
1266
+ // command, and never present the manifest as a removal TARGET. Operator
1267
+ // remediation stays where the operator already looks —
1268
+ // `skills/session-end/SKILL.md` § 3.0 Defensive Cleanup and
1269
+ // `skills/wave-executor/wave-loop.md` steps 5/7 — not in a channel whose reader
1270
+ // is the denied agent.
1271
+ //
1272
+ // `emitDeny`'s `opts.systemMessage` is NOT the escape hatch this wants: the repo
1273
+ // documents its audience two ways that contradict each other — `scripts/lib/io.mjs`
1274
+ // § "Emitted payload" calls it "what the operator sees";
1275
+ // `skills/hook-development/SKILL.md` § "PreToolUse output schema" calls it
1276
+ // "Explanation shown to Claude". A session-wide guard disarm is not the payload
1277
+ // to bet on whichever of the two is right. Locked by
1278
+ // tests/lib/scope-gate.test.mjs § "no branch carries a runnable shell command".
1279
+ //
1280
+ // ## …and the reader may not be the OWNER (measured 2026-08-19)
1281
+ //
1282
+ // The staleness comparison is per-WORKING-COPY, not per-session. Both clocks
1283
+ // `sessionAgeMs` reads — `.orchestrator/current-session.json` `timestamp` and
1284
+ // `.orchestrator/session.lock` `started_at` — are shared by every session in the
1285
+ // checkout, and it takes the MINIMUM AGE, i.e. the NEWEST of the two. So a second
1286
+ // session starting at 18:51 moves the clock forward for EVERYONE, and every
1287
+ // manifest written before 18:51 — including the live one the first session is
1288
+ // using right now — classifies as `stale-manifest` for every subsequent reader.
1289
+ //
1290
+ // Measured in this repo at HEAD 55279a2: `session.lock` `started_at`
1291
+ // 2026-08-19T12:02:57Z (the running deep session) vs. `current-session.json`
1292
+ // `timestamp` 2026-08-19T18:51:54Z (a parallel session) — 6h49m apart, effective
1293
+ // `sessionStartMs` 18:51:54. Every wave manifest that session wrote between 12:02
1294
+ // and 18:51 is `mtime < sessionStartMs`. It is not hypothetical either: a
1295
+ // parallel session in this working copy was shown this very branch for the
1296
+ // coordinator's LIVE wave-4 manifest and told to delete it; it declined because
1297
+ // the operator works carefully, which is not a control.
1298
+ //
1299
+ // That is why the branch says "either a leftover or a parallel session's live
1300
+ // manifest" and routes to `blocked` instead of to a repair: the classifier cannot
1301
+ // distinguish the two, and the reader is frequently not the owner. PSA-001's
1302
+ // question — "Did I create this file? If not, it is not mine to touch" — is the
1303
+ // only safe posture a string in this channel can carry.
1304
+ //
1305
+ // Revisit trigger: a manifest that records its OWNING session id, which would let
1306
+ // this compare ownership instead of clocks. Until then the ambiguity is stated in
1307
+ // the text rather than resolved by a guess.
1308
+ //
1309
+ // ## Named ceiling (BV-004)
1310
+ //
1311
+ // - This buys a CORRECT DIAGNOSIS, never an unlock and never a command. A
1312
+ // writable wave with a broken union still denies every write; the reader is
1313
+ // told WHICH of the five states it is in, and who owns the repair.
1314
+ // - It cannot see a union that is NON-EMPTY but WRONG. That is
1315
+ // `--assert-subset`'s job and stays there.
1316
+ // - `stale-manifest` degrades to `'unknown'` when no session clock is
1317
+ // readable — never to an allow. Absence is preserved, never guessed.
1318
+ // - There is NO age threshold and no TTL. The comparison is a PROVENANCE
1319
+ // subtraction (manifest mtime vs. this session's start), so a legitimate
1320
+ // 14-hour deep session never ages into a blind spot. Deliberately NOT
1321
+ // `IN_FLIGHT_TTL_MS` — see `hooks/post-bash-write-verify.mjs` § "Why the
1322
+ // minimum, and why NOT a staleness cap" for the argument this inherits.
1323
+ // - Revisit trigger: a second read-only wave role, or a manifest written by a
1324
+ // process whose clock is not this repo's `.orchestrator/` pair.
1325
+ // ---------------------------------------------------------------------------
1326
+
1327
+ /**
1328
+ * The closed set of {@link classifyEmptyScope} verdicts. Shaped after
1329
+ * `DEGRADED_REASONS` in `scripts/lib/mirror-issues-banner.mjs`: a frozen array
1330
+ * so a consumer can enumerate the states rather than re-listing them in prose.
1331
+ *
1332
+ * `'unknown'` is a first-class member, not an error — it is what the classifier
1333
+ * returns when the inputs do not DECIDE, and it maps to the pre-#1057 generic
1334
+ * sentence. Absence-preserving by construction.
1335
+ *
1336
+ * @type {ReadonlyArray<'unreadable'|'read-only-role'|'stale-manifest'|'writer-defect'|'unknown'>}
1337
+ */
1338
+ export const EMPTY_SCOPE_REASONS = Object.freeze([
1339
+ 'unreadable',
1340
+ 'read-only-role',
1341
+ 'stale-manifest',
1342
+ 'writer-defect',
1343
+ 'unknown',
1344
+ ]);
1345
+
1346
+ /**
1347
+ * Wave roles for which an EMPTY `allowedPaths` is the intended contract rather
1348
+ * than a defect. THE list — `skills/session-plan/SKILL.md` § Discovery and
1349
+ * `skills/wave-executor/wave-loop.md` § Scope Manifest #5 describe it; they do
1350
+ * not restate it.
1351
+ *
1352
+ * Canonical casing; comparison is trimmed + case-insensitive (see
1353
+ * {@link isReadOnlyWaveRole}) for the same reason
1354
+ * {@link TEST_SIBLING_EXPANSION_ROLES} is: the manifest on disk is written by
1355
+ * LLM prose and by hand, and `"discovery"` vs `Discovery` must not silently
1356
+ * change which sentence the operator reads.
1357
+ *
1358
+ * @type {ReadonlyArray<string>}
1359
+ */
1360
+ export const READ_ONLY_WAVE_ROLES = Object.freeze(['Discovery']);
1361
+
1362
+ /** Lower-cased lookup for {@link isReadOnlyWaveRole}. @type {ReadonlyMap<string, string>} */
1363
+ const READ_ONLY_ROLE_KEYS = new Map(READ_ONLY_WAVE_ROLES.map((r) => [r.toLowerCase(), r]));
1364
+
1365
+ /**
1366
+ * Is this wave role one for which `allowedPaths: []` is BY DESIGN?
1367
+ *
1368
+ * Trimmed + case-insensitive; a non-string role is never read-only (fail-closed
1369
+ * in the direction that produces a MORE alarming message, never a quieter one).
1370
+ *
1371
+ * @param {unknown} role
1372
+ * @returns {boolean}
1373
+ */
1374
+ export function isReadOnlyWaveRole(role) {
1375
+ if (typeof role !== 'string') return false;
1376
+ return READ_ONLY_ROLE_KEYS.has(role.trim().toLowerCase());
1377
+ }
1378
+
1379
+ /**
1380
+ * Classify WHY a wave manifest grants zero paths. Pure, sync, no I/O — every
1381
+ * observation is passed in, exactly like {@link testSiblingExpansionApplies}.
1382
+ * Never throws.
1383
+ *
1384
+ * Modelled on `readLockDetailed` (`scripts/lib/session-lock.mjs`): a small
1385
+ * closed status union, where "cannot tell" is its own member instead of being
1386
+ * folded into the most alarming one.
1387
+ *
1388
+ * ## Precedence (each rung is load-bearing)
1389
+ *
1390
+ * 1. `parseOk === false` → `'unreadable'`. FIRST, because a manifest that did
1391
+ * not parse has no trustworthy `role` either — reading `role` off `{}` and
1392
+ * reporting "writer defect" would blame the coordinator for a corrupt file.
1393
+ * Only an EXPLICIT `false` classifies; `undefined` means "caller did not
1394
+ * observe it" and falls through.
1395
+ * 2. `role` ∈ {@link READ_ONLY_WAVE_ROLES} → `'read-only-role'`. Before the
1396
+ * clock comparison ON PURPOSE: for a Discovery wave the empty scope is the
1397
+ * contract whether the manifest is one second or one day old, so a stale
1398
+ * Discovery leftover reports the read-only sentence. The cost is named
1399
+ * rather than hidden — it is the one state where a leftover manifest is
1400
+ * described by its role instead of by its age.
1401
+ * 3. `scopeMtimeMs < sessionStartMs` → `'stale-manifest'`. Requires BOTH
1402
+ * clocks to be finite numbers; either one absent ⇒ `'unknown'`, never a
1403
+ * guess in either direction.
1404
+ * 4. A writable role with a manifest at least as new as this session ⇒
1405
+ * `'writer-defect'`.
1406
+ * 5. Everything else ⇒ `'unknown'`.
1407
+ *
1408
+ * @param {{role?: unknown, parseOk?: unknown, scopeMtimeMs?: unknown, sessionStartMs?: unknown}} [input]
1409
+ * @returns {'unreadable'|'read-only-role'|'stale-manifest'|'writer-defect'|'unknown'}
1410
+ */
1411
+ export function classifyEmptyScope(input = {}) {
1412
+ if (input === null || typeof input !== 'object') return 'unknown';
1413
+
1414
+ if (input.parseOk === false) return 'unreadable';
1415
+ if (isReadOnlyWaveRole(input.role)) return 'read-only-role';
1416
+
1417
+ const mtime = typeof input.scopeMtimeMs === 'number' && Number.isFinite(input.scopeMtimeMs)
1418
+ ? input.scopeMtimeMs
1419
+ : null;
1420
+ const started = typeof input.sessionStartMs === 'number' && Number.isFinite(input.sessionStartMs)
1421
+ ? input.sessionStartMs
1422
+ : null;
1423
+ if (mtime === null || started === null) return 'unknown';
1424
+
1425
+ if (mtime < started) return 'stale-manifest';
1426
+ return typeof input.role === 'string' && input.role.trim().length > 0
1427
+ ? 'writer-defect'
1428
+ : 'unknown';
1429
+ }
1430
+
1431
+ /**
1432
+ * The suggestion half of a scope-violation deny, when `allowedPaths` is EMPTY.
1433
+ *
1434
+ * A strict superset of {@link suggestForScopeViolation}'s empty-allowlist
1435
+ * branch: `'unknown'` delegates to it verbatim, so the pre-#1057 sentence has
1436
+ * exactly one copy and every other branch is an ADDITION. Pure, sync, never
1437
+ * throws.
1438
+ *
1439
+ * @param {string} relPath — the project-relative path that was blocked
1440
+ * @param {string} reason — a {@link EMPTY_SCOPE_REASONS} member; anything else
1441
+ * is treated as `'unknown'` (fail-safe toward the generic text)
1442
+ * @param {{role?: unknown, scopePath?: unknown}} [opts]
1443
+ * `scopePath` is the manifest's location, project-relative where possible. It
1444
+ * is named as the SUBJECT of a diagnosis ("this file is a leftover") and never
1445
+ * as the target of a removal — see the module block above, § "The channel
1446
+ * belongs to the AGENT".
1447
+ * @returns {string}
1448
+ */
1449
+ export function suggestForEmptyScope(relPath, reason, opts = {}) {
1450
+ const bag = opts !== null && typeof opts === 'object' ? opts : {};
1451
+ const scopeHint = typeof bag.scopePath === 'string' && bag.scopePath.length > 0
1452
+ ? bag.scopePath
1453
+ : '<state-dir>/wave-scope.json';
1454
+ const rawRole = typeof bag.role === 'string' ? bag.role.trim() : '';
1455
+
1456
+ switch (reason) {
1457
+ case 'unreadable':
1458
+ return (
1459
+ `wave-scope.json is unreadable — failing closed. ` +
1460
+ `The manifest exists but did not parse into a usable scope record, so NO path can be granted. ` +
1461
+ `Inspect '${scopeHint}'; a truncated or half-written manifest is repaired by re-running the ` +
1462
+ `coordinator's scope-manifest step, not by editing the plan — report \`blocked\` and leave the ` +
1463
+ `manifest to the coordinator.`
1464
+ );
1465
+
1466
+ case 'read-only-role': {
1467
+ // Canonical casing from the list, so ' DISCOVERY ' and 'discovery' both
1468
+ // render the documented sentence.
1469
+ const canonical = READ_ONLY_ROLE_KEYS.get(rawRole.toLowerCase()) ?? rawRole;
1470
+ return (
1471
+ `${canonical} wave is read-only — no writes permitted. ` +
1472
+ `An empty allowedPaths is this role's deliberate contract (#256), not a misconfiguration: ` +
1473
+ `report '${relPath}' as a finding instead of editing it.`
1474
+ );
1475
+ }
1476
+
1477
+ case 'stale-manifest':
1478
+ // NO COMMAND, and the manifest is never a removal TARGET here. Two reasons,
1479
+ // both measured (module block, § "The channel belongs to the AGENT" and
1480
+ // § "…and the reader may not be the owner"): the string is read by the agent
1481
+ // whose Edit was just denied, and clearing the manifest disarms the whole
1482
+ // gate — and the file may belong to a PARALLEL session in this working copy
1483
+ // that is still using it. The diagnosis, which is what #1057 bought, stays.
1484
+ return (
1485
+ `'${scopeHint}' was written before the most recent session-start clock in this working copy — ` +
1486
+ `either a leftover from a crashed session, or the live manifest of a PARALLEL session that is ` +
1487
+ `still using it. This guard cannot tell which, and restarting the wave clears neither. ` +
1488
+ `Treat the file as not yours (PSA-001): report \`blocked\` to your coordinator and name it. ` +
1489
+ `Do NOT remove, move or edit it yourself (PSA-003/PSA-007).`
1490
+ );
1491
+
1492
+ case 'writer-defect':
1493
+ return (
1494
+ `the wave's allowedPaths union is empty for role \`${rawRole}\` — ` +
1495
+ `the coordinator's \`--union\` step did not complete. Re-run it; do not hand-edit.`
1496
+ );
1497
+
1498
+ default:
1499
+ return suggestForScopeViolation(relPath, '');
1500
+ }
1501
+ }
1502
+
1503
+ /**
1504
+ * Milliseconds since one of this session's clocks was written, or `null` when
1505
+ * none is readable — the MINIMUM over `.orchestrator/current-session.json`
1506
+ * `timestamp` and `.orchestrator/session.lock` `started_at`.
1507
+ *
1508
+ * MOVED here from `hooks/post-bash-write-verify.mjs` (#1057) so both consumers
1509
+ * share ONE implementation: a lib module may be imported by a hook, but a hook
1510
+ * must never be imported by another hook or by this lib (module header, #554
1511
+ * A2). Behaviour is byte-identical to the original — including the `Math.min`
1512
+ * choice and the dropping of NEGATIVE (future-dated) ages, both of which that
1513
+ * hook's docblock argues at length under "Why the minimum, and why NOT a
1514
+ * staleness cap". That argument is the reason this repo has no TTL here either.
1515
+ *
1516
+ * Sync fs reads at CALL time only (same shape as {@link getEnforcementLevel});
1517
+ * no I/O at import time. Never throws — an unreadable or malformed clock is
1518
+ * simply absent.
1519
+ *
1520
+ * @param {string} repoRoot
1521
+ * @param {number} [now]
1522
+ * @returns {number|null}
1523
+ */
1524
+ export function sessionAgeMs(repoRoot, now = Date.now()) {
1525
+ const dir = path.join(repoRoot, '.orchestrator');
1526
+ const ages = [
1527
+ clockAgeMs(path.join(dir, 'current-session.json'), 'timestamp', now),
1528
+ clockAgeMs(path.join(dir, 'session.lock'), 'started_at', now),
1529
+ ].filter((age) => age !== null);
1530
+ return ages.length > 0 ? Math.min(...ages) : null;
1531
+ }
1532
+
1533
+ /**
1534
+ * Age in ms of one JSON clock file's ISO timestamp field, or `null` when the
1535
+ * file is missing, unparseable, carries no parseable timestamp, or is dated in
1536
+ * the FUTURE. Private helper of {@link sessionAgeMs}; moved verbatim with it.
1537
+ *
1538
+ * @param {string} file
1539
+ * @param {string} field
1540
+ * @param {number} now
1541
+ * @returns {number|null}
1542
+ */
1543
+ function clockAgeMs(file, field, now) {
1544
+ try {
1545
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
1546
+ const startedAt = Date.parse(parsed?.[field]);
1547
+ if (!Number.isFinite(startedAt)) return null;
1548
+ const age = now - startedAt;
1549
+ return age >= 0 ? age : null;
1550
+ } catch {
1551
+ return null;
1552
+ }
1553
+ }
1554
+
1555
+ /**
1556
+ * Absolute epoch-ms at which this session started, or `null` when no clock is
1557
+ * readable — the value {@link classifyEmptyScope} compares a manifest's mtime
1558
+ * against.
1559
+ *
1560
+ * Derived from {@link sessionAgeMs} rather than re-reading the files, so there
1561
+ * is ONE clock policy: `now - min(ages)` is the LATEST of the two recorded start
1562
+ * times, which is exactly the freshness `Math.min` was chosen to express (a
1563
+ * leftover `current-session.json` from a previous session is outvoted by a
1564
+ * freshly-acquired `session.lock`). `now` is threaded through so both halves see
1565
+ * the same instant.
1566
+ *
1567
+ * Never throws. No clock ⇒ `null` ⇒ the caller cannot decide staleness and must
1568
+ * fall back to `'unknown'`.
1569
+ *
1570
+ * @param {string} repoRoot
1571
+ * @param {number} [now]
1572
+ * @returns {number|null}
1573
+ */
1574
+ export function sessionStartedAtMs(repoRoot, now = Date.now()) {
1575
+ const age = sessionAgeMs(repoRoot, now);
1576
+ return age === null ? null : now - age;
1577
+ }