session-orchestrator 3.22.0 → 3.23.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 (268) 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/commands/autopilot-multi.md +14 -0
  5. package/.cursor/commands/autopilot.md +14 -0
  6. package/.cursor/commands/bootstrap.md +14 -0
  7. package/.cursor/commands/brainstorm.md +14 -0
  8. package/.cursor/commands/close.md +13 -0
  9. package/.cursor/commands/contract-version-bump.md +14 -0
  10. package/.cursor/commands/debug.md +14 -0
  11. package/.cursor/commands/discovery.md +14 -0
  12. package/.cursor/commands/dispatcher.md +14 -0
  13. package/.cursor/commands/eli5.md +14 -0
  14. package/.cursor/commands/eval.md +14 -0
  15. package/.cursor/commands/evolve.md +14 -0
  16. package/.cursor/commands/go.md +14 -0
  17. package/.cursor/commands/grill.md +14 -0
  18. package/.cursor/commands/harness-audit.md +13 -0
  19. package/.cursor/commands/journey-audit.md +14 -0
  20. package/.cursor/commands/memory-cleanup.md +14 -0
  21. package/.cursor/commands/persona-panel.md +14 -0
  22. package/.cursor/commands/plan.md +14 -0
  23. package/.cursor/commands/portfolio.md +14 -0
  24. package/.cursor/commands/reconcile.md +14 -0
  25. package/.cursor/commands/release.md +14 -0
  26. package/.cursor/commands/repo-audit.md +13 -0
  27. package/.cursor/commands/session.md +14 -0
  28. package/.cursor/commands/spinout.md +14 -0
  29. package/.cursor/commands/sunset-review.md +14 -0
  30. package/.cursor/commands/templates-ack.md +14 -0
  31. package/.cursor/commands/test.md +14 -0
  32. package/.cursor/hooks.json +60 -0
  33. package/.cursor/rules/000-session-orchestrator.mdc +8 -0
  34. package/.cursor/rules/010-session-workflow.mdc +9 -1
  35. package/.cursor/rules/020-quality-gates.mdc +1 -1
  36. package/.cursor/rules/030-wave-execution.mdc +1 -1
  37. package/.cursor/rules/050-plan.mdc +2 -2
  38. package/.cursor/rules/070-gitlab-ops.mdc +73 -57
  39. package/.cursor/rules/080-ecosystem-health.mdc +7 -7
  40. package/.cursor/skills/architecture/SKILL.md +13 -0
  41. package/.cursor/skills/autopilot/SKILL.md +12 -0
  42. package/.cursor/skills/bootstrap/SKILL.md +12 -0
  43. package/.cursor/skills/brainstorm/SKILL.md +13 -0
  44. package/.cursor/skills/claude-md-drift-check/SKILL.md +13 -0
  45. package/.cursor/skills/contract-version-bump/SKILL.md +12 -0
  46. package/.cursor/skills/convergence-monitoring/SKILL.md +12 -0
  47. package/.cursor/skills/daily/SKILL.md +12 -0
  48. package/.cursor/skills/debug/SKILL.md +13 -0
  49. package/.cursor/skills/discovery/SKILL.md +13 -0
  50. package/.cursor/skills/dispatcher/SKILL.md +13 -0
  51. package/.cursor/skills/docs-orchestrator/SKILL.md +13 -0
  52. package/.cursor/skills/domain-model/SKILL.md +13 -0
  53. package/.cursor/skills/ecosystem-health/SKILL.md +13 -0
  54. package/.cursor/skills/eli5/SKILL.md +13 -0
  55. package/.cursor/skills/eval/SKILL.md +12 -0
  56. package/.cursor/skills/evolve/SKILL.md +13 -0
  57. package/.cursor/skills/frontmatter-guard/SKILL.md +13 -0
  58. package/.cursor/skills/gitlab-ops/SKILL.md +13 -0
  59. package/.cursor/skills/gitlab-portfolio/SKILL.md +13 -0
  60. package/.cursor/skills/grill/SKILL.md +13 -0
  61. package/.cursor/skills/hook-development/SKILL.md +13 -0
  62. package/.cursor/skills/journey-audit/SKILL.md +13 -0
  63. package/.cursor/skills/mcp-builder/SKILL.md +13 -0
  64. package/.cursor/skills/memory-cleanup/SKILL.md +12 -0
  65. package/.cursor/skills/mode-selector/SKILL.md +13 -0
  66. package/.cursor/skills/npm-publish/SKILL.md +12 -0
  67. package/.cursor/skills/peekaboo-driver/SKILL.md +13 -0
  68. package/.cursor/skills/persona-panel/SKILL.md +12 -0
  69. package/.cursor/skills/plan/SKILL.md +13 -0
  70. package/.cursor/skills/playwright-driver/SKILL.md +13 -0
  71. package/.cursor/skills/quality-gates/SKILL.md +13 -0
  72. package/.cursor/skills/reconcile/SKILL.md +12 -0
  73. package/.cursor/skills/repo-audit/SKILL.md +13 -0
  74. package/.cursor/skills/session-end/SKILL.md +13 -0
  75. package/.cursor/skills/session-plan/SKILL.md +13 -0
  76. package/.cursor/skills/session-start/SKILL.md +13 -0
  77. package/.cursor/skills/skill-creator/SKILL.md +13 -0
  78. package/.cursor/skills/spinout/SKILL.md +12 -0
  79. package/.cursor/skills/sunset-review/SKILL.md +13 -0
  80. package/.cursor/skills/test-runner/SKILL.md +13 -0
  81. package/.cursor/skills/tmux-layout/SKILL.md +13 -0
  82. package/.cursor/skills/ubiquitous-language/SKILL.md +13 -0
  83. package/.cursor/skills/using-orchestrator/SKILL.md +13 -0
  84. package/.cursor/skills/vault-mirror/SKILL.md +13 -0
  85. package/.cursor/skills/vault-sync/SKILL.md +13 -0
  86. package/.cursor/skills/wave-executor/SKILL.md +13 -0
  87. package/.cursor/skills/write-executable-plan/SKILL.md +13 -0
  88. package/.mcp.json +4 -1
  89. package/CHANGELOG.md +168 -0
  90. package/README.md +18 -15
  91. package/agents/AGENTS.md +23 -4
  92. package/agents/code-implementer.md +2 -1
  93. package/agents/db-specialist.md +2 -1
  94. package/agents/docs-writer.md +3 -1
  95. package/agents/eval-judge.md +1 -1
  96. package/agents/session-reviewer.md +7 -1
  97. package/agents/test-writer.md +2 -1
  98. package/agents/ui-developer.md +2 -1
  99. package/commands/bootstrap.md +2 -2
  100. package/commands/close.md +3 -1
  101. package/commands/go.md +1 -1
  102. package/commands/journey-audit.md +43 -0
  103. package/docs/USER-GUIDE.md +2 -2
  104. package/docs/ci-setup.md +14 -0
  105. package/docs/codex-setup.md +64 -0
  106. package/docs/components.md +6 -6
  107. package/docs/cursor-setup.md +26 -47
  108. package/docs/events-schema.md +76 -4
  109. package/docs/github-mirror-protection.md +197 -0
  110. package/docs/pi-setup.md +2 -0
  111. package/docs/rule-authoring.md +3 -1
  112. package/docs/scope-collision-guard.md +49 -2
  113. package/docs/session-config-reference.md +26 -4
  114. package/docs/session-config-template.md +4 -3
  115. package/docs/telemetry.md +22 -0
  116. package/hooks/_lib/lock-bootstrap.mjs +8 -4
  117. package/hooks/_lib/vcs-create-matcher.mjs +397 -38
  118. package/hooks/enforce-scope.mjs +64 -0
  119. package/hooks/hooks-codex.json +1 -1
  120. package/hooks/hooks-cursor.json +201 -20
  121. package/hooks/hooks-pi.json +1 -1
  122. package/hooks/hooks.json +2 -2
  123. package/hooks/on-session-end.mjs +211 -10
  124. package/hooks/on-session-start.mjs +214 -11
  125. package/hooks/on-stop.mjs +48 -9
  126. package/hooks/post-subagent-discovery-validator.mjs +34 -3
  127. package/hooks/post-tool-batch-wave-signal.mjs +11 -2
  128. package/hooks/pre-bash-issue-budget.mjs +117 -4
  129. package/hooks/pre-bash-sessions-ledger-guard.mjs +159 -0
  130. package/hooks/pre-bash-staging-fence.mjs +4 -0
  131. package/hooks/pre-task-scope-disjoint.mjs +368 -35
  132. package/hooks/skill-invocation-telemetry.mjs +21 -10
  133. package/monitors/monitors.json +6 -0
  134. package/package.json +1 -1
  135. package/pi/prompts/journey-audit.md +12 -0
  136. package/rules/_index.md +9 -1
  137. package/rules/always-on/ask-via-tool.md +62 -0
  138. package/rules/always-on/bash-harness-pitfalls.md +168 -0
  139. package/rules/always-on/build-value.md +47 -0
  140. package/rules/always-on/cross-session-messaging.md +59 -0
  141. package/rules/always-on/loop-and-monitor.md +221 -0
  142. package/rules/always-on/parallel-sessions.md +142 -12
  143. package/rules/always-on/receiving-review.md +108 -0
  144. package/rules/always-on/test-value.md +40 -0
  145. package/rules/always-on/verification-before-completion.md +77 -0
  146. package/scripts/archive-closed-prds.mjs +258 -18
  147. package/scripts/autopilot.mjs +5 -0
  148. package/scripts/backfill-evidence-digest.mjs +376 -0
  149. package/scripts/cursor-install.mjs +89 -48
  150. package/scripts/export-hw-learnings.mjs +143 -2
  151. package/scripts/express-path.mjs +299 -0
  152. package/scripts/generate-cursor-adapter.mjs +253 -0
  153. package/scripts/github-protection-audit.mjs +358 -0
  154. package/scripts/lib/autopilot/worktree-pipeline.mjs +240 -16
  155. package/scripts/lib/build-live-signals.mjs +24 -5
  156. package/scripts/lib/ci-status-banner.mjs +158 -11
  157. package/scripts/lib/command-blocker.mjs +70 -0
  158. package/scripts/lib/config/reconcile.mjs +79 -4
  159. package/scripts/lib/config/section-extractor.mjs +235 -36
  160. package/scripts/lib/config-schema.mjs +9 -1
  161. package/scripts/lib/config.mjs +57 -6
  162. package/scripts/lib/convergence-monitor.mjs +13 -2
  163. package/scripts/lib/cursor-hook-bridge.mjs +443 -0
  164. package/scripts/lib/dispatcher/cli.mjs +2 -2
  165. package/scripts/lib/express-path.mjs +327 -0
  166. package/scripts/lib/file-lock.mjs +22 -4
  167. package/scripts/lib/gates/gate-full.mjs +81 -8
  168. package/scripts/lib/gates/gate-helpers.mjs +76 -15
  169. package/scripts/lib/git-config-drift.mjs +134 -5
  170. package/scripts/lib/host-identity.mjs +247 -2
  171. package/scripts/lib/instruction-budget-guard.mjs +31 -1
  172. package/scripts/lib/issue-budget.mjs +229 -30
  173. package/scripts/lib/learnings/io.mjs +55 -10
  174. package/scripts/lib/learnings/schema.mjs +95 -28
  175. package/scripts/lib/lock-reaper.mjs +7 -1
  176. package/scripts/lib/locks/staging-fence-lock.mjs +5 -1
  177. package/scripts/lib/locks/state-md-lock.mjs +8 -1
  178. package/scripts/lib/memory-banner.mjs +5 -2
  179. package/scripts/lib/memory-paths.mjs +15 -6
  180. package/scripts/lib/mode-selector/scoring.mjs +53 -6
  181. package/scripts/lib/platform.mjs +72 -9
  182. package/scripts/lib/plugin-root.mjs +143 -19
  183. package/scripts/lib/project-hygiene.mjs +43 -3
  184. package/scripts/lib/quality-gate.mjs +271 -13
  185. package/scripts/lib/reconcile/emitter.mjs +87 -19
  186. package/scripts/lib/reconcile/engine.mjs +281 -13
  187. package/scripts/lib/reconcile/idempotency.mjs +102 -1
  188. package/scripts/lib/reconcile/renderer.mjs +148 -3
  189. package/scripts/lib/reconcile/sanitize.mjs +40 -17
  190. package/scripts/lib/reconcile/writer.mjs +415 -84
  191. package/scripts/lib/rule-loader.mjs +37 -2
  192. package/scripts/lib/rules-sync.mjs +51 -8
  193. package/scripts/lib/scope-gate.mjs +90 -0
  194. package/scripts/lib/session-close-backfill.mjs +369 -28
  195. package/scripts/lib/session-discovery.mjs +13 -3
  196. package/scripts/lib/session-end/phase-skip.mjs +37 -4
  197. package/scripts/lib/session-end/worktree-cleanup.mjs +154 -7
  198. package/scripts/lib/session-id.mjs +30 -14
  199. package/scripts/lib/session-identity/own-session.mjs +159 -0
  200. package/scripts/lib/session-lock.mjs +85 -30
  201. package/scripts/lib/session-schema/normalizer.mjs +70 -3
  202. package/scripts/lib/session-schema/validator.mjs +40 -0
  203. package/scripts/lib/session-start-probes.mjs +608 -0
  204. package/scripts/lib/session-transition.mjs +277 -0
  205. package/scripts/lib/sessions-staleness-banner.mjs +124 -57
  206. package/scripts/lib/spiral-carryover.mjs +90 -9
  207. package/scripts/lib/state-md/frontmatter-mutators.mjs +41 -8
  208. package/scripts/lib/state-md/mission-status.mjs +350 -52
  209. package/scripts/lib/state-md/yaml-parser.mjs +145 -16
  210. package/scripts/lib/state-md.mjs +12 -2
  211. package/scripts/lib/telemetry/sync.mjs +46 -8
  212. package/scripts/lib/validate/check-agents.mjs +66 -0
  213. package/scripts/lib/validate/check-cursor-adapter.mjs +102 -0
  214. package/scripts/lib/validate/check-dead-bridge.mjs +24 -2
  215. package/scripts/lib/validate/check-doc-cli-commands.mjs +16 -32
  216. package/scripts/lib/validate/check-hooks-symmetry.mjs +29 -63
  217. package/scripts/lib/validate/check-playwright-mcp-canary.mjs +13 -22
  218. package/scripts/lib/validate/check-plugin-monitors.mjs +10 -4
  219. package/scripts/lib/validate/check-test-value-bans.mjs +165 -17
  220. package/scripts/lib/validate/check-unwired-features.mjs +340 -32
  221. package/scripts/lib/validate/repo-files.mjs +275 -0
  222. package/scripts/lib/validate-vendored-rules.mjs +229 -7
  223. package/scripts/lib/vault-mirror/process.mjs +99 -43
  224. package/scripts/lib/vault-mirror/telemetry.mjs +210 -0
  225. package/scripts/lib/vault-staleness-banner.mjs +76 -6
  226. package/scripts/lib/vault-status/board-writer.mjs +211 -10
  227. package/scripts/lib/vault-status/narrative-mirror.mjs +188 -8
  228. package/scripts/lib/wave-executor/foreign-dispatch.mjs +832 -0
  229. package/scripts/lib/wave-transcript-tail.mjs +869 -0
  230. package/scripts/materialize-wave-scope.mjs +209 -12
  231. package/scripts/mcp-server.sh +11 -2
  232. package/scripts/parse-config.mjs +65 -0
  233. package/scripts/token-audit.sh +9 -2
  234. package/scripts/validate-plugin.mjs +3 -0
  235. package/scripts/validate-wave-scope.mjs +67 -0
  236. package/scripts/vault-mirror.mjs +203 -34
  237. package/skills/_shared/monitor-patterns.md +31 -5
  238. package/skills/_shared/parallel-aware-auq.md +1 -1
  239. package/skills/_shared/parallel-aware-preamble.md +4 -2
  240. package/skills/_shared/platform-tools.md +11 -5
  241. package/skills/_shared/state-ownership.md +29 -2
  242. package/skills/autopilot/SKILL.md +5 -1
  243. package/skills/bootstrap/SKILL.md +3 -3
  244. package/skills/bootstrap/_shared-template.md +18 -10
  245. package/skills/bootstrap/deep-template.md +10 -6
  246. package/skills/bootstrap/fast-template.md +15 -8
  247. package/skills/bootstrap/standard-template.md +10 -6
  248. package/skills/claude-md-drift-check/checker.mjs +39 -11
  249. package/skills/dispatcher/SKILL.md +1 -1
  250. package/skills/journey-audit/SKILL.md +269 -0
  251. package/skills/peekaboo-driver/SKILL.md +15 -3
  252. package/skills/persona-panel/SKILL.md +1 -1
  253. package/skills/reconcile/SKILL.md +41 -1
  254. package/skills/session-end/SKILL.md +17 -4
  255. package/skills/session-end/metrics-collection.md +7 -4
  256. package/skills/session-end/phase-3-6-tail.md +11 -3
  257. package/skills/session-end/phase-3-7a-recommendations.md +16 -2
  258. package/skills/session-plan/SKILL.md +6 -1
  259. package/skills/session-plan/wave-template.md +1 -0
  260. package/skills/session-start/SKILL.md +30 -16
  261. package/skills/session-start/phase-7-5-mode-selector.md +15 -3
  262. package/skills/session-start/phase-8-5-express-path.md +77 -12
  263. package/skills/vault-sync/validator.mjs +31 -0
  264. package/skills/wave-executor/SKILL.md +4 -2
  265. package/skills/wave-executor/circuit-breaker.md +34 -9
  266. package/skills/wave-executor/wave-loop.md +102 -19
  267. package/templates/_shared/journey-manifest.md +110 -0
  268. package/templates/_shared/rules/parallel-sessions.md +0 -77
@@ -648,12 +648,57 @@ export function tokenizeCommand(command) {
648
648
  return tokens;
649
649
  }
650
650
 
651
+ /**
652
+ * Shell RESERVED WORDS and grouping tokens that bash recognises ONLY in COMMAND
653
+ * POSITION — the first word of a statement (#1145). Everywhere else they are
654
+ * ordinary arguments (`echo do`, `git commit -m then`), which is exactly why the
655
+ * drop below is position-gated rather than a blanket text filter.
656
+ *
657
+ * Two classes, both dropped, for the same reason:
658
+ * - OPENERS (`{`, `(`, `!`, `if`, `elif`, `then`, `else`, `while`, `until`,
659
+ * `do`) are followed by a COMMAND. Dropping one moves verb resolution
660
+ * towards the real command — the identical safety argument WRAPPER_UNWRAP
661
+ * makes for a transparent wrapper: a dropped token is a reserved word, never
662
+ * an interpreter, so this cannot turn a match into a miss.
663
+ * - CLOSERS (`}`, `)`, `fi`, `done`, `esac`) end one. In command position they
664
+ * are the whole segment, which then filters out as empty.
665
+ *
666
+ * Deliberately NOT listed:
667
+ * - `for` / `select` / `case` / `in` / `function` — followed by a NAME or WORD,
668
+ * never a command. Dropping them would invent a verb (`for t in a` → `t`)
669
+ * where the truthful answer is "this segment has no command".
670
+ * - `time` — a reserved word AND an external binary, already classified as a
671
+ * transparent wrapper in WRAPPER_UNWRAP. One classification per token, in
672
+ * one table: a second entry here would fork the wrapper-vs-keyword decision
673
+ * the way `VERB_PREFIXES` once forked WRAPPER_UNWRAP (#991).
674
+ *
675
+ * NAMED CEILING (BV-004): only a STANDALONE token is dropped. bash requires
676
+ * whitespace around `{` / `}`, so brace groups are always covered; a subshell
677
+ * written WITHOUT the space (`(rm -rf /)`) lexes as the glued word `(rm` and is
678
+ * not reached — the pre-#1145 behaviour, unchanged. Peeling the paren off the
679
+ * token TEXT would mutate a token five consumers read positionally, which is a
680
+ * larger contract change than this defect justifies. Revisit if a glued-paren
681
+ * form shows up in a real overflow triage or guard-bypass measurement.
682
+ */
683
+ const COMMAND_POSITION_KEYWORDS = new Set([
684
+ '{', '(', '!', 'if', 'elif', 'then', 'else', 'while', 'until', 'do',
685
+ '}', ')', 'fi', 'done', 'esac',
686
+ ]);
687
+
651
688
  /**
652
689
  * Split a tokenized command into chained segments on shell control operators
653
690
  * (`;`, `&&`, `||`, `|`, `&`) and on newline separators (#981). Only UNQUOTED
654
691
  * single-token operators split; an operator that arrived inside quotes stays
655
692
  * part of its segment.
656
693
  *
694
+ * A segment additionally has its COMMAND-POSITION reserved words stripped
695
+ * (#1145) — see {@link COMMAND_POSITION_KEYWORDS}. Without this the first token
696
+ * of a compound statement is `do` / `then` / `{` / `(` and NEVER the command,
697
+ * so every consumer that reads a segment head saw a keyword: measured against
698
+ * the live issue-budget guard with `max-per-session: 1, mode: strict`,
699
+ * `for t in a b c; do glab issue create …; done` and `{ glab issue create …; }`
700
+ * were allowed with NO accounting at all, while the plain form denied at 1/1.
701
+ *
657
702
  * The newline separator is checked by its `operator` field as well as its text,
658
703
  * so the split survives a future change to that token's spelling. Because a
659
704
  * separator token is CONSUMED here, it can never reach a per-segment operand
@@ -678,6 +723,13 @@ function splitSegments(tokens) {
678
723
  current = [];
679
724
  continue;
680
725
  }
726
+ // Reserved word in COMMAND POSITION (`current` still empty) → drop it, so
727
+ // the NEXT token becomes the segment head and is itself judged in command
728
+ // position (`do { rm …` peels both). A keyword anywhere else is an ordinary
729
+ // argument and is kept verbatim (#1145).
730
+ if (current.length === 0 && !tok.quoted && COMMAND_POSITION_KEYWORDS.has(tok.text)) {
731
+ continue;
732
+ }
681
733
  current.push(tok);
682
734
  }
683
735
  segments.push(current);
@@ -763,6 +815,24 @@ export const WRAPPER_UNWRAP = new Map([
763
815
  }],
764
816
  ['command', {}],
765
817
  ['nohup', {}],
818
+ // `exec cmd args` REPLACES the shell with `cmd` — it delegates to a real verb
819
+ // and never executes a command STRING, so by the #982 classification it is a
820
+ // TRANSPARENT wrapper, not an interpreter (contrast `su -c`, which is in
821
+ // SHELL_EXEC_INTERPRETERS for exactly the opposite reason). `-a NAME` is the
822
+ // one value-taking flag; `-c` (clear environment) and `-l` are BOOLEANS here
823
+ // — despite the spelling, `exec -c` carries no payload, so it must NOT reach
824
+ // DASH_C_SHELLS, and it does not: that set is keyed on the RESOLVED verb.
825
+ // Redirect-only `exec > file` keeps working. Measured both namespaces: on the
826
+ // RAW segment the wrapper consumes `exec` and the redirect operator resolves
827
+ // as the verb (`>`, previously `exec`); on scope-gate's paren-peeled,
828
+ // redirect-stripped projection the segment exhausts in wrappers and yields
829
+ // `verb: null, index: -1` — the shape a bare `sudo` already produced. Neither
830
+ // is `rm`/`tee`/`sed`/`dd`, and a redirect target is read from the redirect
831
+ // token stream rather than from the verb, so detection is unaffected:
832
+ // measured `extractBashWriteTargets('exec > src/out.ts')` → `['src/out.ts']`.
833
+ ['exec', {
834
+ argFlags: new Set(['-a']),
835
+ }],
766
836
  // `-o FILE` is the BSD/GNU `time` report destination and it TRUNCATES without
767
837
  // `-a` (BSD time(1): "If file exists and the -a flag is not specified, the
768
838
  // file will be overwritten"). With an empty spec the operand was read as the
@@ -24,6 +24,76 @@ import { matchBlockHeader } from './block-header.mjs';
24
24
  * Consumers: `scripts/lib/config.mjs`, `skills/session-end/SKILL.md` Phase 3.6.8.
25
25
  */
26
26
 
27
+ /**
28
+ * CLOSED set of valid `reconcile.targets` members.
29
+ *
30
+ * At module scope on purpose: `targets` has TWO parse sites (the bracket-list
31
+ * branch and the bare-scalar `case 'targets'`), whereas `mode` has one. An
32
+ * inline literal duplicated across two sites is a second register that drifts —
33
+ * the shape mirrors `VALID_MODES` below, hoisted because the duplication is real.
34
+ *
35
+ * `global` is documented-but-unimplemented and is deliberately NOT a member:
36
+ * admitting a value nothing implements is the same defect class as the
37
+ * unvalidated pass-through this constant replaces.
38
+ */
39
+ const VALID_TARGETS = Object.freeze(['repo-local', 'baseline']);
40
+
41
+ /** Fallback when every declared target was dropped (mirrors the `defaults` object). */
42
+ const DEFAULT_TARGETS = Object.freeze(['repo-local']);
43
+
44
+ /**
45
+ * Report dropped `reconcile.targets` members on stderr.
46
+ *
47
+ * stderr, NEVER stdout: `scripts/lib/config.mjs` consumers parse this parser's
48
+ * downstream output as JSON, so a diagnostic on stdout would corrupt it.
49
+ *
50
+ * Prior art for "make an attributable drop VISIBLE, not merely recorded":
51
+ * `warnDroppedStoreRecords` in `scripts/lib/reconcile/engine.mjs`.
52
+ *
53
+ * @param {string[]} dropped
54
+ */
55
+ function warnUnknownTargets(dropped) {
56
+ try {
57
+ console.warn(
58
+ `⚠️ reconcile.targets: dropped unknown value(s) ${dropped.map((d) => JSON.stringify(d)).join(', ')} — ` +
59
+ `valid targets are ${VALID_TARGETS.join(' | ')}. The unknown value is IGNORED (no rule is ` +
60
+ `written for it); if every declared target was dropped, the parser falls back to ` +
61
+ `[${DEFAULT_TARGETS.join(', ')}].`,
62
+ );
63
+ } catch {
64
+ // A diagnostic must never become the failure it reports on.
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Keep only {@link VALID_TARGETS} members, de-duplicated, order-preserving.
70
+ *
71
+ * Rejection is DROP + WARN, never a throw — not even under `enforcement: strict`:
72
+ * (a) this module's stated contract ("Tolerant parser: malformed values silently
73
+ * fall back to defaults") is obeyed by every other key here; making one key
74
+ * throw is an internal inconsistency;
75
+ * (b) `_parseReconcile` runs from `scripts/lib/config.mjs` at session-start, so a
76
+ * throw would fail session-start on a config typo;
77
+ * (c) the SILENCE was the defect, not the tolerance — the WARN closes it.
78
+ *
79
+ * Fail-loud belongs one layer up (`claude-md-drift-check` Check 6 could ERROR on
80
+ * an unknown value); that is deliberately NOT bundled here.
81
+ *
82
+ * @param {string[]} items
83
+ * @returns {string[]}
84
+ */
85
+ function filterTargets(items) {
86
+ const kept = [];
87
+ const dropped = [];
88
+ for (const item of items) {
89
+ if (VALID_TARGETS.includes(item)) kept.push(item);
90
+ else dropped.push(item);
91
+ }
92
+ if (dropped.length > 0) warnUnknownTargets(dropped);
93
+ const uniq = [...new Set(kept)];
94
+ return uniq.length > 0 ? uniq : [...DEFAULT_TARGETS];
95
+ }
96
+
27
97
  /**
28
98
  * Parse the top-level `reconcile:` YAML block from markdown content.
29
99
  *
@@ -32,8 +102,13 @@ import { matchBlockHeader } from './block-header.mjs';
32
102
  * reconcile.mode: 'warn' — advisory only; rules NEVER auto-applied,
33
103
  * every write is operator-AUQ-gated (enum: off|warn)
34
104
  * reconcile.targets: ['repo-local']
35
- * — where approved rules are written;
36
- * repo-local = `.claude/rules/` in v1
105
+ * — where approved rules are written.
106
+ * CLOSED enum, see {@link VALID_TARGETS}:
107
+ * repo-local = `.claude/rules/` in this repo;
108
+ * baseline = `<baselineRoot>/proposals/`
109
+ * (issue #1099). An unknown member is DROPPED
110
+ * with a stderr WARN — see {@link filterTargets}
111
+ * for why a drop and not a throw.
37
112
  * reconcile.rule-expiry-days: null — CRITICAL: must default to null so the
38
113
  * reconcile engine (`emitter.mjs`
39
114
  * `computeExpiresAt`) falls back to per-type
@@ -132,7 +207,7 @@ export function _parseReconcile(content) {
132
207
  .split(',')
133
208
  .map((s) => s.trim())
134
209
  .filter(Boolean);
135
- if (items.length > 0) targets = items;
210
+ if (items.length > 0) targets = filterTargets(items);
136
211
  continue;
137
212
  }
138
213
 
@@ -206,7 +281,7 @@ export function _parseReconcile(content) {
206
281
  // targets inline-list with no brackets (e.g. targets: repo-local) — single value
207
282
  case 'targets': {
208
283
  if (v && !v.startsWith('[')) {
209
- targets = [v];
284
+ targets = filterTargets([v]);
210
285
  }
211
286
  break;
212
287
  }
@@ -131,7 +131,8 @@ export function findSessionConfigBlock(content, { occurrence = 'first' } = {}) {
131
131
  /**
132
132
  * Extract the raw ## Session Config block lines from markdown content.
133
133
  * - CRLF-tolerant
134
- * - Skips code fence lines (``` alone on a line)
134
+ * - Skips code fence lines (``` alone or with an info string)
135
+ * - Skips lines inside a multi-line `<!-- … -->` block (`htmlCommentSkipper`)
135
136
  * - Strips trailing whitespace from each line
136
137
  * @param {string} content
137
138
  * @returns {string[]} lines of the Session Config block
@@ -140,6 +141,7 @@ export function _extractConfigSection(content) {
140
141
  const lines = content.split(/\r?\n/);
141
142
  const result = [];
142
143
  let inSection = false;
144
+ const skipHtmlComment = htmlCommentSkipper();
143
145
 
144
146
  for (const rawLine of lines) {
145
147
  const line = rawLine.replace(/\r$/, '');
@@ -152,8 +154,17 @@ export function _extractConfigSection(content) {
152
154
  if (inSection) {
153
155
  // Next ## header closes the section
154
156
  if (/^## /.test(line)) break;
155
- // Skip standalone code fences
156
- if (line.trim() === '```') continue;
157
+ // Commented-out config is documentation, not config (#1097 review).
158
+ // Shared with `collectUnparsableLines` so the two accept-sets cannot
159
+ // drift — see `htmlCommentSkipper`.
160
+ if (skipHtmlComment(line)) continue;
161
+ // Skip code fences — opener WITH an info string included (#1097).
162
+ // The old predicate was `line.trim() === '```'`, which skips a bare
163
+ // fence but lets ```` ```yaml ```` through as a config line. Harmless
164
+ // while an unmatched line was silently dropped; a false "unparsable"
165
+ // report the moment `collectUnparsableLines` below started naming them —
166
+ // and the fenced form is what one fleet repo actually writes.
167
+ if (isCodeFence(line)) continue;
157
168
  // Strip trailing whitespace and collect
158
169
  result.push(line.replace(/\s+$/, ''));
159
170
  }
@@ -162,6 +173,135 @@ export function _extractConfigSection(content) {
162
173
  return result;
163
174
  }
164
175
 
176
+ /**
177
+ * True when `line` is a fenced-code-block delimiter — a bare ``` / ~~~ or an
178
+ * opener carrying an info string (```yaml).
179
+ *
180
+ * @param {string} line
181
+ * @returns {boolean}
182
+ */
183
+ function isCodeFence(line) {
184
+ return /^\s*(?:```|~~~)/.test(line);
185
+ }
186
+
187
+ /**
188
+ * THE multi-line `<!-- … -->` state machine for the Session Config block —
189
+ * the one place that knows whether a line is commented-out documentation.
190
+ *
191
+ * Exported-by-use (same argument as `_matchKVLine`): `_extractConfigSection`
192
+ * feeds `_parseKV`, and `collectUnparsableLines` classifies — so both call this
193
+ * and their accept-sets are identical BY CONSTRUCTION. They were not, and the
194
+ * divergence ran in exactly the dangerous direction: the collector skipped
195
+ * commented blocks while the extractor read them, so
196
+ *
197
+ * persistence: true
198
+ * <!--
199
+ * enforcement: strict
200
+ * -->
201
+ *
202
+ * yielded a LIVE `enforcement: strict` (measured: `_parseKV` returned it) that
203
+ * `collectUnparsableLines` reported as a clean block — and `parse-config.mjs`
204
+ * branches its own #1097 gate on `config.enforcement`. Commenting a key out is
205
+ * the most ordinary way to disable it; it must not arm the strictest path.
206
+ *
207
+ * Returns a fresh closure per call — it carries per-document state, so a shared
208
+ * instance would leak an unterminated comment from one document into the next.
209
+ *
210
+ * Ceiling (deliberate): the opener must be at the START of a line
211
+ * (`/^\s*<!--/`). A comment opened mid-line (`waves: 5 <!--`) therefore does
212
+ * NOT swallow the following lines — which keeps every single-line trailing
213
+ * `<!-- … -->` on a heading or key line behaving exactly as before, the form
214
+ * this repo's own convention encourages. Revisit if a fleet CLAUDE.md is ever
215
+ * measured opening a multi-line comment after a value.
216
+ *
217
+ * @returns {(line: string) => boolean} true when the line is inside (or opens)
218
+ * a multi-line HTML comment and must be ignored by both consumers.
219
+ */
220
+ function htmlCommentSkipper() {
221
+ let inComment = false;
222
+ return function skipHtmlComment(line) {
223
+ if (inComment) {
224
+ if (line.includes('-->')) inComment = false;
225
+ return true;
226
+ }
227
+ if (/^\s*<!--/.test(line) && !line.includes('-->')) {
228
+ inComment = true;
229
+ return true;
230
+ }
231
+ return false;
232
+ };
233
+ }
234
+
235
+ /**
236
+ * Replace C0/C1 control characters with `?` before a line is handed to a
237
+ * reporter.
238
+ *
239
+ * `collectUnparsableLines` returns text taken verbatim from a file that is
240
+ * itself the thing being reported as malformed, and its only consumer prints
241
+ * it on stderr after a `WARN unparsable Session Config line N:` prefix. A
242
+ * planted `\x1b[2K…\r` erases that prefix and rewrites the operator's terminal
243
+ * line — the report of a defect becoming the vehicle for hiding it. Sanitising
244
+ * at the SOURCE (rather than at the one printer) means every future consumer
245
+ * inherits the safe form.
246
+ *
247
+ * @param {string} text
248
+ * @returns {string}
249
+ */
250
+ function sanitizeReportText(text) {
251
+ // eslint-disable-next-line no-control-regex -- the control chars ARE the target
252
+ return text.replace(/[\u0000-\u001F\u007F-\u009F]/g, '?');
253
+ }
254
+
255
+ /**
256
+ * THE key/value predicate for a Session Config line — the one place that knows
257
+ * which of the two accepted spellings a line is in.
258
+ *
259
+ * Exported-by-use rather than by name: `_parseKV` builds the map from it and
260
+ * `collectUnparsableLines` decides "this line carries a key" from it, so the
261
+ * accept-set of the two can never drift. A classifier with its own regex would
262
+ * eventually report a line the map DID parse (noise) or stay silent on one it
263
+ * did not (the #1097 defect, unchanged).
264
+ *
265
+ * Format 1: `- **key:** value`
266
+ * Format 2: `key: value`, incl. the YAML list-item form `- key: value` (#497)
267
+ * and any leading indentation (sub-keys of a nested block land here too).
268
+ *
269
+ * @param {string} line
270
+ * @returns {{ key: string, value: string } | null} null when the line carries
271
+ * no key/value pair in either format.
272
+ */
273
+ function _matchKVLine(line) {
274
+ let key;
275
+ let value;
276
+
277
+ // Format 1: - **key:** value
278
+ const fmt1 = line.match(/^\s*-\s+\*\*([^*:]+):\*\*\s*(.*)/);
279
+ if (fmt1) {
280
+ key = fmt1[1].trim();
281
+ value = fmt1[2].trim();
282
+ } else {
283
+ // Format 2: key: value — supports both plain "key: value" and
284
+ // YAML list-item form "- key: value" (issue #497). Key starts with
285
+ // letter; rest is alphanum/hyphen/underscore.
286
+ const fmt2 = line.match(/^\s*(?:-\s+)?([a-zA-Z][a-zA-Z0-9_-]+):\s+(.*)/);
287
+ if (!fmt2) return null;
288
+ key = fmt2[1].trim();
289
+ value = fmt2[2].trim();
290
+ }
291
+
292
+ if (!key) return null;
293
+
294
+ // Strip inline YAML comment (matches block-parser behaviour in _parseVaultSync etc.)
295
+ value = value.replace(/\s+#.*$/, '').trim();
296
+
297
+ // Strip surrounding double quotes from value
298
+ if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) {
299
+ value = value.slice(1, -1);
300
+ }
301
+
302
+ return { key, value };
303
+ }
304
+
165
305
  /**
166
306
  * Parse the key-value pairs from extracted Session Config lines.
167
307
  * Supports Format 1: `- **key:** value`
@@ -176,39 +316,9 @@ export function _parseKV(lines) {
176
316
 
177
317
  for (const line of lines) {
178
318
  if (!line.trim()) continue;
179
-
180
- let key;
181
- let value;
182
-
183
- // Format 1: - **key:** value
184
- const fmt1 = line.match(/^\s*-\s+\*\*([^*:]+):\*\*\s*(.*)/);
185
- if (fmt1) {
186
- key = fmt1[1].trim();
187
- value = fmt1[2].trim();
188
- } else {
189
- // Format 2: key: value — supports both plain "key: value" and
190
- // YAML list-item form "- key: value" (issue #497). Key starts with
191
- // letter; rest is alphanum/hyphen/underscore.
192
- const fmt2 = line.match(/^\s*(?:-\s+)?([a-zA-Z][a-zA-Z0-9_-]+):\s+(.*)/);
193
- if (fmt2) {
194
- key = fmt2[1].trim();
195
- value = fmt2[2].trim();
196
- } else {
197
- continue;
198
- }
199
- }
200
-
201
- if (!key) continue;
202
-
203
- // Strip inline YAML comment (matches block-parser behaviour in _parseVaultSync etc.)
204
- value = value.replace(/\s+#.*$/, '').trim();
205
-
206
- // Strip surrounding double quotes from value
207
- if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) {
208
- value = value.slice(1, -1);
209
- }
210
-
211
- allPairs.push([key, value]);
319
+ const match = _matchKVLine(line);
320
+ if (!match) continue;
321
+ allPairs.push([match.key, match.value]);
212
322
  }
213
323
 
214
324
  // Last match wins: build the map by iterating in order
@@ -218,3 +328,92 @@ export function _parseKV(lines) {
218
328
  }
219
329
  return kv;
220
330
  }
331
+
332
+ // ---------------------------------------------------------------------------
333
+ // Fail-loud: lines inside the block that carry no meaning (#1097)
334
+ // ---------------------------------------------------------------------------
335
+
336
+ /**
337
+ * A line that opens a nested block: `vault-integration:` with no value, in any
338
+ * of the four fleet spellings (column-zero, `- key:`, `**key:**`, `- **key:**`)
339
+ * and at any indent. Deliberately NOT imported from block-header.mjs: that
340
+ * matcher answers "does this line open the block for THIS key" for a known key,
341
+ * while here the key is unknown by construction.
342
+ */
343
+ const NESTED_BLOCK_HEADER_RE = /^\s*(?:-\s+)?(?:\*\*)?[a-zA-Z][a-zA-Z0-9_.-]*:(?:\*\*)?\s*$/;
344
+
345
+ /** A YAML sequence element that is not a `key: value` pair (` - some-value`). */
346
+ const LIST_ITEM_RE = /^\s*-\s+\S/;
347
+
348
+ /** Documentation, not config: `# yaml comment` and `<!-- html -->` / `> quote`. */
349
+ const COMMENT_RE = /^\s*(?:#|<!--|>)/;
350
+
351
+ /**
352
+ * Collect the lines inside `## Session Config` that carry no meaning for any
353
+ * parser — the fail-loud half of #1097.
354
+ *
355
+ * The measured defect this exists to end: a key the extractor cannot read is
356
+ * simply absent from the KV map, and every consumer then applies its DEFAULT.
357
+ * For a boolean that default is `false`, so a mis-spelled `vault-integration`
358
+ * block reads exactly like a deliberately disabled one — no error, anywhere, in
359
+ * any log (fleet evidence: a repo whose vault mirror sat at
360
+ * `skipped-vault-disabled` for two months with the key present in its CLAUDE.md).
361
+ *
362
+ * A line is accounted for — and therefore NOT reported — when it is:
363
+ * blank · a code fence · a `#`/`<!--`/`>` documentation line · a key/value
364
+ * pair in either accepted format (`_matchKVLine`) · a nested-block header
365
+ * (`key:` with no value) · a bare YAML list element (`- value`).
366
+ * Everything else is prose sitting where config is expected, which is the only
367
+ * shape a broken key can take once the six forms above are excluded.
368
+ *
369
+ * Line numbers are 1-based and count from the START OF THE DOCUMENT, not from
370
+ * the block — they are for an operator opening the file at that line. The text
371
+ * is run through `sanitizeReportText`, because it is quoted straight into a
372
+ * terminal by the one consumer that prints it.
373
+ *
374
+ * The accept-set is shared with the reader, not merely aligned with it: the
375
+ * `#`/`>` and blank/fence forms are literal here, and the multi-line HTML
376
+ * comment goes through the same `htmlCommentSkipper` call `_extractConfigSection`
377
+ * makes. A form the reader accepts but the classifier does not is noise; a form
378
+ * the READER accepts and the classifier waves through as documentation is the
379
+ * live-commented-out-key defect this shape exists to make unreachable.
380
+ *
381
+ * @param {string} content — full markdown document
382
+ * @returns {Array<{ line: number, text: string }>} empty when the block is
383
+ * well-formed, absent, or the content is not a string.
384
+ */
385
+ export function collectUnparsableLines(content) {
386
+ if (typeof content !== 'string' || content === '') return [];
387
+
388
+ const lines = content.split(/\r?\n/);
389
+ const unparsable = [];
390
+ let inSection = false;
391
+ const skipHtmlComment = htmlCommentSkipper();
392
+
393
+ for (let i = 0; i < lines.length; i++) {
394
+ const line = lines[i].replace(/\r$/, '');
395
+
396
+ if (isSessionConfigHeading(line)) {
397
+ inSection = true;
398
+ continue;
399
+ }
400
+ if (!inSection) continue;
401
+ if (/^## /.test(line)) break;
402
+
403
+ // A multi-line HTML comment: everything up to `-->` is documentation.
404
+ // Same call, same order, as `_extractConfigSection` — that shared call is
405
+ // what makes the two accept-sets identical rather than merely similar.
406
+ if (skipHtmlComment(line)) continue;
407
+
408
+ if (!line.trim()) continue;
409
+ if (isCodeFence(line)) continue;
410
+ if (COMMENT_RE.test(line)) continue;
411
+ if (_matchKVLine(line)) continue;
412
+ if (NESTED_BLOCK_HEADER_RE.test(line)) continue;
413
+ if (LIST_ITEM_RE.test(line)) continue;
414
+
415
+ unparsable.push({ line: i + 1, text: sanitizeReportText(line.trim()) });
416
+ }
417
+
418
+ return unparsable;
419
+ }
@@ -8,7 +8,15 @@
8
8
  * Never throws. Never mutates input.
9
9
  */
10
10
 
11
- const ENFORCEMENT_VALUES = new Set(['strict', 'warn', 'off']);
11
+ /**
12
+ * The values `enforcement:` accepts. Exported (#1097) so the gates that BRANCH
13
+ * on enforcement — `scripts/parse-config.mjs`'s unparsable-line gate is the
14
+ * first — read the same Set the validator judges against, instead of re-typing
15
+ * the literal `'strict'` beside a schema that could later add a mode.
16
+ *
17
+ * @type {Set<string>}
18
+ */
19
+ export const ENFORCEMENT_VALUES = new Set(['strict', 'warn', 'off']);
12
20
  // Exported (issue #836) so cross-layer mode-vocabulary parity tests can use
13
21
  // this Set as the single source of truth for "the modes a Session Config
14
22
  // `*.mode` key accepts" — rather than re-deriving a duplicate literal list
@@ -14,6 +14,10 @@
14
14
  * events-rotation.mjs — _parseEventsRotation
15
15
  * vault-integration.mjs — _parseVaultIntegration + _parseResourceThresholds
16
16
  *
17
+ * One parser lives OUTSIDE that directory: `_parseExpressPath` in
18
+ * scripts/lib/express-path.mjs, alongside the express-path decision it configures.
19
+ * See that module's header for the reason and the revisit trigger.
20
+ *
17
21
  * Originally ported from parse-config.sh (v2) plus its helper libs
18
22
  * config-yaml-parser.sh and config-json-coercion.sh. Windows + CRLF safe.
19
23
  *
@@ -72,6 +76,11 @@ import { _parseSkillEvolution } from './config/skill-evolution.mjs';
72
76
  import { _parseDispatcherAutonomy, resolveDispatcherAutonomy } from './config/dispatcher-autonomy.mjs';
73
77
  import { loadHostPaths, resolveHostPath } from './config/host-paths.mjs';
74
78
  import { resolveNamedBaseline } from './named-baseline-resolver.mjs';
79
+ // express-path lives one level UP from config/ (see its module header for why):
80
+ // the parser is a sibling of config/state-md-lock.mjs in every respect except
81
+ // its directory. Only the pure parser is imported here — `evaluateExpressPath`
82
+ // and its telemetry stay out of this module's import graph by construction.
83
+ import { _parseExpressPath } from './express-path.mjs';
75
84
 
76
85
  // Re-export the two functions that external callers import directly from this module.
77
86
  export { _coerceEnum, _coerceCollisionRisk } from './config/coercers.mjs';
@@ -226,24 +235,57 @@ export function parseSessionConfig(mdContent, { hostPaths } = {}) {
226
235
  if (agentMapping !== null) {
227
236
  // Validate role keys against canonical list from skills/_shared/config-reading.md
228
237
  const ALLOWED_ROLES = ['impl', 'test', 'db', 'ui', 'security', 'compliance', 'docs', 'perf'];
238
+ // Dispatch channels a mapping value may name via a `<channel>:<target>`
239
+ // prefix (#1150). Without a colon the value is a plain agent name and is
240
+ // dispatched natively, exactly as before.
241
+ // impl: code-implementer → native Agent dispatch (unchanged)
242
+ // impl: cursor:composer-2.5 → foreign model over the Cursor channel
243
+ // An unknown prefix is a typo or a channel this build cannot route, and
244
+ // must fail loudly: silently accepting it would dispatch to an agent that
245
+ // does not exist, which surfaces only as an empty wave much later.
246
+ const KNOWN_CHANNELS = ['cursor', 'session-orchestrator'];
247
+ // Accumulate every defect, then report once. A config with two bad entries
248
+ // must name BOTH — throwing on the first one makes the operator re-run the
249
+ // parse per defect, and each re-run hides the ones behind it.
229
250
  const invalidKeys = [];
251
+ const valueProblems = [];
230
252
  for (const [k, v] of Object.entries(agentMapping)) {
231
253
  if (!ALLOWED_ROLES.includes(k)) {
232
254
  invalidKeys.push(k);
233
255
  continue;
234
256
  }
235
257
  if (typeof v !== 'string' || v === '') {
236
- throw new Error(
237
- `config.mjs: agent-mapping role '${k}' has invalid value '${v}' (expected non-empty string)`
238
- );
258
+ valueProblems.push(`role '${k}' has invalid value '${v}' (expected non-empty string)`);
259
+ continue;
260
+ }
261
+ const colonIdx = v.indexOf(':');
262
+ if (colonIdx !== -1) {
263
+ const channel = v.slice(0, colonIdx).trim();
264
+ const target = v.slice(colonIdx + 1).trim();
265
+ if (!KNOWN_CHANNELS.includes(channel)) {
266
+ valueProblems.push(
267
+ `role '${k}' names unknown channel '${channel}' in value '${v}' ` +
268
+ `(known channels: ${KNOWN_CHANNELS.join(', ')}; a value without ':' is a plain agent name)`
269
+ );
270
+ } else if (target === '') {
271
+ valueProblems.push(
272
+ `role '${k}' has channel '${channel}' but no target in value '${v}' ` +
273
+ `(expected '${channel}:<model-or-agent>')`
274
+ );
275
+ }
239
276
  }
240
277
  }
278
+ const problems = [];
241
279
  if (invalidKeys.length > 0) {
242
- throw new Error(
243
- `config.mjs: agent-mapping contains invalid role key(s): ${invalidKeys.join(', ')} ` +
244
- `(allowed: ${ALLOWED_ROLES.join(', ')})`
280
+ problems.push(
281
+ `invalid role key(s): ${invalidKeys.join(', ')} (allowed: ${ALLOWED_ROLES.join(', ')})`
245
282
  );
246
283
  }
284
+ problems.push(...valueProblems);
285
+ if (problems.length > 0) {
286
+ // Still fail loud — only the reporting shape changed, never the verdict.
287
+ throw new Error(`config.mjs: agent-mapping ${problems.join('; ')}`);
288
+ }
247
289
  }
248
290
  const enforcementGates = _coerceBoolObject(kv, 'enforcement-gates');
249
291
 
@@ -286,6 +328,14 @@ export function parseSessionConfig(mdContent, { hostPaths } = {}) {
286
328
  // events-rotation: parsed from full content (standalone top-level block)
287
329
  const eventsRotation = _parseEventsRotation(mdContent);
288
330
 
331
+ // express-path: parsed from full content (standalone top-level block, #214/#1119).
332
+ // Documented in docs/session-config-template.md:585 and
333
+ // docs/session-config-reference.md:1519 since #214, but never in the key schema —
334
+ // measured 2026-08-23, a probe carrying `express-path:\n enabled: false` produced
335
+ // 88 keys and none of them was `express-path`. The key was dropped even when it
336
+ // stood in the file, so the documented default was prose rather than a value.
337
+ const expressPath = _parseExpressPath(mdContent);
338
+
289
339
  // vault-mirror.quality: parsed from full content (PRD F1.2 / issue #504)
290
340
  const vaultMirror = _parseVaultMirrorQuality(mdContent);
291
341
 
@@ -482,6 +532,7 @@ export function parseSessionConfig(mdContent, { hostPaths } = {}) {
482
532
  'context-coverage': contextCoverage,
483
533
  'worktree-orphans': worktreeOrphans,
484
534
  'events-rotation': eventsRotation,
535
+ 'express-path': expressPath,
485
536
  'test': testConfig,
486
537
  'gitlab-portfolio': gitlabPortfolio,
487
538
  'wave-reviewers': waveReviewers,
@@ -312,12 +312,23 @@ function evaluateSignals(state, alreadyEmitted, latestWave) {
312
312
  }
313
313
 
314
314
  /**
315
+ * Poll delay.
316
+ *
317
+ * The timer is deliberately NOT `unref()`d: it is the only handle this process
318
+ * holds, so an unref'd timer drains the event loop and node exits 0 the instant
319
+ * the first tick is scheduled — a monitor that supervises nothing while looking
320
+ * like a clean shutdown (it has already emitted `tail.started` and writes
321
+ * nothing to stderr). Measured 2026-08-25 on the unref'd variant:
322
+ * `node scripts/lib/convergence-monitor.mjs --tail --interval=1` returned
323
+ * exit 0 after 47 ms instead of running until SIGTERM. This is the mechanism
324
+ * behind #980 (convergence-monitor never fires) — the same defect A1 measured
325
+ * in `scripts/lib/wave-transcript-tail.mjs`, which this sleep() was copied to.
326
+ *
315
327
  * @param {number} ms
316
328
  */
317
329
  function sleep(ms) {
318
330
  return new Promise((resolve) => {
319
- const t = setTimeout(resolve, ms);
320
- t.unref?.();
331
+ setTimeout(resolve, ms);
321
332
  });
322
333
  }
323
334