session-orchestrator 3.21.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 (345) 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 +11 -2
  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/040-discovery.mdc +6 -6
  38. package/.cursor/rules/050-plan.mdc +10 -10
  39. package/.cursor/rules/070-gitlab-ops.mdc +73 -57
  40. package/.cursor/rules/080-ecosystem-health.mdc +7 -7
  41. package/.cursor/skills/architecture/SKILL.md +13 -0
  42. package/.cursor/skills/autopilot/SKILL.md +12 -0
  43. package/.cursor/skills/bootstrap/SKILL.md +12 -0
  44. package/.cursor/skills/brainstorm/SKILL.md +13 -0
  45. package/.cursor/skills/claude-md-drift-check/SKILL.md +13 -0
  46. package/.cursor/skills/contract-version-bump/SKILL.md +12 -0
  47. package/.cursor/skills/convergence-monitoring/SKILL.md +12 -0
  48. package/.cursor/skills/daily/SKILL.md +12 -0
  49. package/.cursor/skills/debug/SKILL.md +13 -0
  50. package/.cursor/skills/discovery/SKILL.md +13 -0
  51. package/.cursor/skills/dispatcher/SKILL.md +13 -0
  52. package/.cursor/skills/docs-orchestrator/SKILL.md +13 -0
  53. package/.cursor/skills/domain-model/SKILL.md +13 -0
  54. package/.cursor/skills/ecosystem-health/SKILL.md +13 -0
  55. package/.cursor/skills/eli5/SKILL.md +13 -0
  56. package/.cursor/skills/eval/SKILL.md +12 -0
  57. package/.cursor/skills/evolve/SKILL.md +13 -0
  58. package/.cursor/skills/frontmatter-guard/SKILL.md +13 -0
  59. package/.cursor/skills/gitlab-ops/SKILL.md +13 -0
  60. package/.cursor/skills/gitlab-portfolio/SKILL.md +13 -0
  61. package/.cursor/skills/grill/SKILL.md +13 -0
  62. package/.cursor/skills/hook-development/SKILL.md +13 -0
  63. package/.cursor/skills/journey-audit/SKILL.md +13 -0
  64. package/.cursor/skills/mcp-builder/SKILL.md +13 -0
  65. package/.cursor/skills/memory-cleanup/SKILL.md +12 -0
  66. package/.cursor/skills/mode-selector/SKILL.md +13 -0
  67. package/.cursor/skills/npm-publish/SKILL.md +12 -0
  68. package/.cursor/skills/peekaboo-driver/SKILL.md +13 -0
  69. package/.cursor/skills/persona-panel/SKILL.md +12 -0
  70. package/.cursor/skills/plan/SKILL.md +13 -0
  71. package/.cursor/skills/playwright-driver/SKILL.md +13 -0
  72. package/.cursor/skills/quality-gates/SKILL.md +13 -0
  73. package/.cursor/skills/reconcile/SKILL.md +12 -0
  74. package/.cursor/skills/repo-audit/SKILL.md +13 -0
  75. package/.cursor/skills/session-end/SKILL.md +13 -0
  76. package/.cursor/skills/session-plan/SKILL.md +13 -0
  77. package/.cursor/skills/session-start/SKILL.md +13 -0
  78. package/.cursor/skills/skill-creator/SKILL.md +13 -0
  79. package/.cursor/skills/spinout/SKILL.md +12 -0
  80. package/.cursor/skills/sunset-review/SKILL.md +13 -0
  81. package/.cursor/skills/test-runner/SKILL.md +13 -0
  82. package/.cursor/skills/tmux-layout/SKILL.md +13 -0
  83. package/.cursor/skills/ubiquitous-language/SKILL.md +13 -0
  84. package/.cursor/skills/using-orchestrator/SKILL.md +13 -0
  85. package/.cursor/skills/vault-mirror/SKILL.md +13 -0
  86. package/.cursor/skills/vault-sync/SKILL.md +13 -0
  87. package/.cursor/skills/wave-executor/SKILL.md +13 -0
  88. package/.cursor/skills/write-executable-plan/SKILL.md +13 -0
  89. package/.mcp.json +4 -1
  90. package/CHANGELOG.md +269 -0
  91. package/README.md +18 -15
  92. package/agents/AGENTS.md +23 -4
  93. package/agents/code-implementer.md +2 -1
  94. package/agents/db-specialist.md +2 -1
  95. package/agents/docs-writer.md +3 -1
  96. package/agents/eval-judge.md +1 -1
  97. package/agents/memory-proposal-collector.md +6 -4
  98. package/agents/session-reviewer.md +7 -1
  99. package/agents/test-writer.md +2 -1
  100. package/agents/ui-developer.md +2 -1
  101. package/commands/bootstrap.md +2 -2
  102. package/commands/close.md +3 -1
  103. package/commands/eli5.md +33 -0
  104. package/commands/go.md +1 -1
  105. package/commands/journey-audit.md +43 -0
  106. package/commands/release.md +5 -3
  107. package/commands/test.md +2 -2
  108. package/docs/USER-GUIDE.md +2 -2
  109. package/docs/ci-setup.md +14 -0
  110. package/docs/codex-setup.md +64 -0
  111. package/docs/components.md +7 -6
  112. package/docs/cursor-setup.md +26 -47
  113. package/docs/events-schema.md +76 -4
  114. package/docs/github-mirror-protection.md +197 -0
  115. package/docs/pi-setup.md +2 -0
  116. package/docs/rule-authoring.md +3 -1
  117. package/docs/scope-collision-guard.md +52 -5
  118. package/docs/session-config-reference.md +57 -12
  119. package/docs/session-config-template.md +4 -3
  120. package/docs/telemetry.md +22 -0
  121. package/hooks/_lib/lock-bootstrap.mjs +26 -16
  122. package/hooks/_lib/vcs-create-matcher.mjs +397 -38
  123. package/hooks/enforce-scope.mjs +64 -0
  124. package/hooks/hooks-codex.json +1 -1
  125. package/hooks/hooks-cursor.json +201 -20
  126. package/hooks/hooks-pi.json +1 -1
  127. package/hooks/hooks.json +12 -2
  128. package/hooks/on-session-end.mjs +237 -104
  129. package/hooks/on-session-start.mjs +404 -110
  130. package/hooks/on-stop.mjs +48 -9
  131. package/hooks/post-subagent-discovery-validator.mjs +34 -3
  132. package/hooks/post-tool-batch-wave-signal.mjs +11 -2
  133. package/hooks/pre-auq-clarity.mjs +787 -0
  134. package/hooks/pre-bash-issue-budget.mjs +133 -21
  135. package/hooks/pre-bash-sessions-ledger-guard.mjs +159 -0
  136. package/hooks/pre-bash-staging-fence.mjs +4 -0
  137. package/hooks/pre-task-scope-disjoint.mjs +368 -35
  138. package/hooks/skill-invocation-telemetry.mjs +21 -10
  139. package/monitors/monitors.json +6 -0
  140. package/package.json +3 -1
  141. package/pi/prompts/eli5.md +12 -0
  142. package/pi/prompts/journey-audit.md +12 -0
  143. package/rules/_index.md +9 -1
  144. package/rules/always-on/ask-via-tool.md +62 -0
  145. package/rules/always-on/bash-harness-pitfalls.md +168 -0
  146. package/rules/always-on/build-value.md +47 -0
  147. package/rules/always-on/cross-session-messaging.md +59 -0
  148. package/rules/always-on/loop-and-monitor.md +221 -0
  149. package/rules/always-on/parallel-sessions.md +142 -12
  150. package/rules/always-on/receiving-review.md +108 -0
  151. package/rules/always-on/test-value.md +40 -0
  152. package/rules/always-on/verification-before-completion.md +77 -0
  153. package/scripts/archive-closed-prds.mjs +258 -18
  154. package/scripts/auq-audit.mjs +825 -0
  155. package/scripts/autopilot.mjs +12 -8
  156. package/scripts/backfill-evidence-digest.mjs +376 -0
  157. package/scripts/cursor-install.mjs +89 -48
  158. package/scripts/export-hw-learnings.mjs +143 -2
  159. package/scripts/express-path.mjs +299 -0
  160. package/scripts/generate-cursor-adapter.mjs +253 -0
  161. package/scripts/github-protection-audit.mjs +358 -0
  162. package/scripts/lib/auq/clarity.mjs +1314 -0
  163. package/scripts/lib/auq/parse.mjs +1006 -0
  164. package/scripts/lib/auq/schema.mjs +1457 -0
  165. package/scripts/lib/autopilot/worktree-pipeline.mjs +240 -16
  166. package/scripts/lib/build-live-signals.mjs +24 -5
  167. package/scripts/lib/ci-status-banner.mjs +221 -68
  168. package/scripts/lib/command-blocker.mjs +70 -0
  169. package/scripts/lib/config/dispatcher-autonomy-capture.mjs +32 -9
  170. package/scripts/lib/config/reconcile.mjs +79 -4
  171. package/scripts/lib/config/section-extractor.mjs +235 -36
  172. package/scripts/lib/config/vault-integration.mjs +12 -1
  173. package/scripts/lib/config-schema.mjs +9 -1
  174. package/scripts/lib/config.mjs +57 -6
  175. package/scripts/lib/convergence-monitor.mjs +13 -2
  176. package/scripts/lib/cursor-hook-bridge.mjs +443 -0
  177. package/scripts/lib/dispatcher/cli.mjs +2 -2
  178. package/scripts/lib/dispatcher/rank.mjs +4 -7
  179. package/scripts/lib/express-path.mjs +327 -0
  180. package/scripts/lib/file-lock.mjs +22 -4
  181. package/scripts/lib/gates/gate-full.mjs +84 -11
  182. package/scripts/lib/gates/gate-helpers.mjs +93 -21
  183. package/scripts/lib/git-config-drift.mjs +134 -5
  184. package/scripts/lib/host-identity.mjs +247 -2
  185. package/scripts/lib/instruction-budget-guard.mjs +31 -1
  186. package/scripts/lib/io.mjs +239 -0
  187. package/scripts/lib/issue-budget.mjs +284 -31
  188. package/scripts/lib/learnings/io.mjs +55 -10
  189. package/scripts/lib/learnings/schema.mjs +95 -28
  190. package/scripts/lib/lock-reaper.mjs +7 -1
  191. package/scripts/lib/locks/staging-fence-lock.mjs +5 -1
  192. package/scripts/lib/locks/state-md-lock.mjs +8 -1
  193. package/scripts/lib/memory-banner.mjs +5 -2
  194. package/scripts/lib/memory-paths.mjs +15 -6
  195. package/scripts/lib/mode-selector/scoring.mjs +53 -6
  196. package/scripts/lib/owner-interview.mjs +78 -32
  197. package/scripts/lib/peer-discovery.mjs +73 -22
  198. package/scripts/lib/platform.mjs +72 -9
  199. package/scripts/lib/plugin-root.mjs +143 -19
  200. package/scripts/lib/project-hygiene.mjs +104 -4
  201. package/scripts/lib/quality-gate.mjs +271 -13
  202. package/scripts/lib/reconcile/emitter.mjs +87 -19
  203. package/scripts/lib/reconcile/engine.mjs +281 -13
  204. package/scripts/lib/reconcile/idempotency.mjs +102 -1
  205. package/scripts/lib/reconcile/renderer.mjs +165 -7
  206. package/scripts/lib/reconcile/sanitize.mjs +40 -17
  207. package/scripts/lib/reconcile/writer.mjs +415 -84
  208. package/scripts/lib/resource-probe/evaluate.mjs +330 -149
  209. package/scripts/lib/resource-probe/probe-platform.mjs +35 -0
  210. package/scripts/lib/resource-probe.mjs +18 -2
  211. package/scripts/lib/rule-loader.mjs +37 -2
  212. package/scripts/lib/rules-sync.mjs +51 -8
  213. package/scripts/lib/scope-gate.mjs +90 -0
  214. package/scripts/lib/session-close-backfill.mjs +369 -28
  215. package/scripts/lib/session-discovery.mjs +13 -3
  216. package/scripts/lib/session-end/phase-skip.mjs +37 -4
  217. package/scripts/lib/session-end/worktree-cleanup.mjs +154 -7
  218. package/scripts/lib/session-id.mjs +30 -14
  219. package/scripts/lib/session-identity/own-session.mjs +159 -0
  220. package/scripts/lib/session-lock.mjs +85 -30
  221. package/scripts/lib/session-schema/normalizer.mjs +70 -3
  222. package/scripts/lib/session-schema/validator.mjs +40 -0
  223. package/scripts/lib/session-start-probes.mjs +608 -0
  224. package/scripts/lib/session-transition.mjs +277 -0
  225. package/scripts/lib/sessions-staleness-banner.mjs +124 -57
  226. package/scripts/lib/spiral-carryover.mjs +111 -9
  227. package/scripts/lib/state-md/frontmatter-mutators.mjs +41 -8
  228. package/scripts/lib/state-md/mission-status.mjs +467 -72
  229. package/scripts/lib/state-md/yaml-parser.mjs +145 -16
  230. package/scripts/lib/state-md.mjs +12 -2
  231. package/scripts/lib/telemetry/sync.mjs +46 -8
  232. package/scripts/lib/validate/check-agents.mjs +66 -0
  233. package/scripts/lib/validate/check-auq-clarity.mjs +274 -0
  234. package/scripts/lib/validate/check-cursor-adapter.mjs +102 -0
  235. package/scripts/lib/validate/check-dead-bridge.mjs +24 -2
  236. package/scripts/lib/validate/check-doc-cli-commands.mjs +16 -32
  237. package/scripts/lib/validate/check-hooks-symmetry.mjs +59 -63
  238. package/scripts/lib/validate/check-playwright-mcp-canary.mjs +13 -22
  239. package/scripts/lib/validate/check-plugin-monitors.mjs +10 -4
  240. package/scripts/lib/validate/check-rules.mjs +153 -9
  241. package/scripts/lib/validate/check-test-value-bans.mjs +165 -17
  242. package/scripts/lib/validate/check-unwired-features.mjs +340 -32
  243. package/scripts/lib/validate/repo-files.mjs +275 -0
  244. package/scripts/lib/validate-vendored-rules.mjs +229 -7
  245. package/scripts/lib/vault-backfill/glab.mjs +91 -58
  246. package/scripts/lib/vault-backfill/manifest.mjs +28 -8
  247. package/scripts/lib/vault-mirror/process.mjs +99 -43
  248. package/scripts/lib/vault-mirror/telemetry.mjs +210 -0
  249. package/scripts/lib/vault-staleness-banner.mjs +76 -6
  250. package/scripts/lib/vault-status/board-writer.mjs +211 -10
  251. package/scripts/lib/vault-status/narrative-mirror.mjs +188 -8
  252. package/scripts/lib/vcs-repo-spec.mjs +182 -13
  253. package/scripts/lib/wave-executor/foreign-dispatch.mjs +832 -0
  254. package/scripts/lib/wave-resource-gate.mjs +67 -73
  255. package/scripts/lib/wave-transcript-tail.mjs +869 -0
  256. package/scripts/materialize-wave-scope.mjs +478 -0
  257. package/scripts/mcp-server.sh +11 -2
  258. package/scripts/parse-config.mjs +65 -0
  259. package/scripts/release.mjs +443 -122
  260. package/scripts/run-quality-gate.mjs +14 -0
  261. package/scripts/token-audit.sh +9 -2
  262. package/scripts/validate-plugin.mjs +6 -0
  263. package/scripts/validate-wave-scope.mjs +73 -1
  264. package/scripts/vault-backfill.mjs +32 -5
  265. package/scripts/vault-mirror.mjs +203 -34
  266. package/skills/_shared/monitor-patterns.md +31 -5
  267. package/skills/_shared/parallel-aware-auq.md +31 -25
  268. package/skills/_shared/parallel-aware-preamble.md +35 -4
  269. package/skills/_shared/platform-tools.md +11 -5
  270. package/skills/_shared/state-ownership.md +61 -8
  271. package/skills/autopilot/SKILL.md +5 -1
  272. package/skills/bootstrap/SKILL.md +5 -4
  273. package/skills/bootstrap/_shared-template.md +18 -10
  274. package/skills/bootstrap/deep-template.md +10 -6
  275. package/skills/bootstrap/fast-template.md +15 -8
  276. package/skills/bootstrap/standard-template.md +10 -6
  277. package/skills/brainstorm/SKILL.md +18 -18
  278. package/skills/brainstorm/soul.md +12 -0
  279. package/skills/claude-md-drift-check/checker.mjs +39 -11
  280. package/skills/discovery/SKILL.md +28 -24
  281. package/skills/dispatcher/SKILL.md +1 -1
  282. package/skills/eli5/SKILL.md +43 -0
  283. package/skills/evolve/SKILL.md +8 -9
  284. package/skills/gitlab-ops/SKILL.md +30 -26
  285. package/skills/grill/SKILL.md +6 -6
  286. package/skills/grill/soul.md +16 -0
  287. package/skills/journey-audit/SKILL.md +269 -0
  288. package/skills/memory-cleanup/SKILL.md +2 -2
  289. package/skills/npm-publish/SKILL.md +4 -4
  290. package/skills/peekaboo-driver/SKILL.md +18 -6
  291. package/skills/persona-panel/SKILL.md +1 -1
  292. package/skills/plan/SKILL.md +18 -16
  293. package/skills/plan/mode-feature.md +1 -1
  294. package/skills/plan/mode-new.md +35 -23
  295. package/skills/plan/soul.md +12 -0
  296. package/skills/reconcile/SKILL.md +44 -4
  297. package/skills/session-end/SKILL.md +70 -24
  298. package/skills/session-end/metrics-collection.md +7 -4
  299. package/skills/session-end/phase-3-6-tail.md +48 -5
  300. package/skills/session-end/phase-3-7a-recommendations.md +16 -2
  301. package/skills/session-plan/SKILL.md +6 -1
  302. package/skills/session-plan/wave-template.md +1 -0
  303. package/skills/session-start/SKILL.md +96 -48
  304. package/skills/session-start/phase-2-5-docs-planning.md +8 -8
  305. package/skills/session-start/phase-4-5-resource-health.md +82 -19
  306. package/skills/session-start/phase-7-5-mode-selector.md +15 -3
  307. package/skills/session-start/phase-8-5-express-path.md +77 -12
  308. package/skills/session-start/soul.md +110 -0
  309. package/skills/test-runner/SKILL.md +2 -2
  310. package/skills/using-orchestrator/SKILL.md +1 -1
  311. package/skills/vault-sync/validator.mjs +31 -0
  312. package/skills/wave-executor/SKILL.md +4 -2
  313. package/skills/wave-executor/circuit-breaker.md +34 -9
  314. package/skills/wave-executor/wave-loop.md +129 -24
  315. package/skills/write-executable-plan/SKILL.md +6 -6
  316. package/templates/_shared/journey-manifest.md +110 -0
  317. package/scripts/tests/fixtures/fetch-baseline/sample-rule.md +0 -8
  318. package/skills/vault-sync/tests/fixtures/archive-test-vault/90-archive/bad-archived.md +0 -8
  319. package/skills/vault-sync/tests/fixtures/archive-test-vault/_meta/.gitkeep +0 -0
  320. package/skills/vault-sync/tests/fixtures/archive-test-vault/live-note.md +0 -8
  321. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/_meta/.gitkeep +0 -0
  322. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/bad-type.md +0 -8
  323. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/good-note.md +0 -8
  324. package/skills/vault-sync/tests/fixtures/clean-vault/.obsidian/config.md +0 -8
  325. package/skills/vault-sync/tests/fixtures/clean-vault/01-projects/foo/projects-baseline.md +0 -10
  326. package/skills/vault-sync/tests/fixtures/clean-vault/03-daily/daily-2026-04-13.md +0 -8
  327. package/skills/vault-sync/tests/fixtures/clean-vault/README.md +0 -3
  328. package/skills/vault-sync/tests/fixtures/clean-vault/hello-world.md +0 -11
  329. package/skills/vault-sync/tests/fixtures/dangling-link-vault/_meta/.gitkeep +0 -0
  330. package/skills/vault-sync/tests/fixtures/dangling-link-vault/has-dangling.md +0 -9
  331. package/skills/vault-sync/tests/fixtures/dangling-link-vault/real-target.md +0 -8
  332. package/skills/vault-sync/tests/fixtures/empty-vault/_meta/.gitkeep +0 -0
  333. package/skills/vault-sync/tests/fixtures/missing-field-vault/_meta/.gitkeep +0 -0
  334. package/skills/vault-sync/tests/fixtures/missing-field-vault/missing-id.md +0 -7
  335. package/skills/vault-sync/tests/fixtures/nested-tag-vault/03-daily/daily-2026-04-13.md +0 -9
  336. package/skills/vault-sync/tests/fixtures/nested-tag-vault/_meta/.gitkeep +0 -0
  337. package/skills/vault-sync/tests/fixtures/nested-tag-vault/nested-tags-note.md +0 -11
  338. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/README.md +0 -3
  339. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_MOC.md +0 -3
  340. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_meta/.gitkeep +0 -0
  341. package/skills/vault-sync/tests/fixtures/with-moc-vault/_MOC.md +0 -11
  342. package/skills/vault-sync/tests/fixtures/with-moc-vault/_meta/.gitkeep +0 -0
  343. package/skills/vault-sync/tests/fixtures/with-moc-vault/hello-world.md +0 -11
  344. package/skills/vault-sync/tests/schema-drift.test.mjs +0 -133
  345. package/templates/_shared/rules/parallel-sessions.md +0 -77
@@ -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
+ }
@@ -192,9 +192,20 @@ function _parseInlineObject(raw) {
192
192
  */
193
193
  export function _parseResourceThresholds(kv) {
194
194
  return {
195
+ // Memory thresholds are denominated in the signal `evaluate()` actually
196
+ // judges on — memory_pressure > ram_available > ram_free, in that order
197
+ // (#1089). They are NOT compared against Darwin's `os.freemem()` unless
198
+ // nothing better is published, which on Darwin never happens.
195
199
  'ram-free-min-gb': _coerceInteger(kv, 'ram-free-min-gb', 4),
196
200
  'ram-free-critical-gb': _coerceInteger(kv, 'ram-free-critical-gb', 2),
197
- 'cpu-load-max-pct': _coerceInteger(kv, 'cpu-load-max-pct', 80),
201
+ // 80 → 90 (#1089). At 80 this fired on 15.6% of 1477 measured session
202
+ // starts, largely on the decaying tail of the coordinator's own gate run;
203
+ // at 90 it fires on 12.8%, and under the two-signal rule it no longer caps
204
+ // anything on its own. Both numbers are measurements, not preferences.
205
+ 'cpu-load-max-pct': _coerceInteger(kv, 'cpu-load-max-pct', 90),
206
+ // Unchanged at 5 — but now compared against LIVE PEER SESSIONS rather than
207
+ // the Claude process count. Same number, different denominator: measured
208
+ // firing rate drops from 93.6% to 4.2% (median processes:sessions = 6.0).
198
209
  'concurrent-sessions-warn': _coerceInteger(kv, 'concurrent-sessions-warn', 5),
199
210
  'ssh-no-docker': _coerceBoolean(kv, 'ssh-no-docker', true),
200
211
  };
@@ -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