session-orchestrator 3.22.0 → 3.24.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 (316) 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/remote-offload/SKILL.md +13 -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 +446 -0
  91. package/README.md +22 -17
  92. package/agents/AGENTS.md +23 -4
  93. package/agents/code-implementer.md +2 -1
  94. package/agents/db-specialist.md +2 -2
  95. package/agents/docs-writer.md +3 -1
  96. package/agents/eval-judge.md +1 -1
  97. package/agents/session-reviewer.md +7 -1
  98. package/agents/test-writer.md +2 -1
  99. package/agents/ui-developer.md +2 -1
  100. package/commands/bootstrap.md +2 -2
  101. package/commands/close.md +3 -1
  102. package/commands/go.md +1 -1
  103. package/commands/journey-audit.md +43 -0
  104. package/docs/USER-GUIDE.md +2 -2
  105. package/docs/ci-setup.md +194 -25
  106. package/docs/codex-setup.md +64 -0
  107. package/docs/components.md +7 -7
  108. package/docs/cursor-setup.md +26 -47
  109. package/docs/events-schema.md +120 -10
  110. package/docs/github-mirror-protection.md +197 -0
  111. package/docs/pi-setup.md +2 -0
  112. package/docs/rule-authoring.md +3 -1
  113. package/docs/scope-collision-guard.md +49 -2
  114. package/docs/session-config-reference.md +89 -9
  115. package/docs/session-config-template.md +38 -7
  116. package/docs/telemetry/telemetry-claims.md +11 -10
  117. package/docs/telemetry.md +52 -1
  118. package/hooks/_lib/atomic-json.mjs +111 -0
  119. package/hooks/_lib/lock-bootstrap.mjs +8 -4
  120. package/hooks/_lib/subagent-paths.mjs +143 -0
  121. package/hooks/_lib/vcs-create-matcher.mjs +397 -38
  122. package/hooks/cwd-change-restore.mjs +9 -29
  123. package/hooks/enforce-scope.mjs +93 -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 +2 -2
  128. package/hooks/on-session-end.mjs +486 -19
  129. package/hooks/on-session-start.mjs +263 -12
  130. package/hooks/on-stop.mjs +392 -24
  131. package/hooks/post-bash-write-verify.mjs +104 -4
  132. package/hooks/post-subagent-discovery-validator.mjs +182 -21
  133. package/hooks/post-tool-batch-wave-signal.mjs +165 -42
  134. package/hooks/post-tool-failure-corrective-context.mjs +9 -32
  135. package/hooks/pre-bash-issue-budget.mjs +117 -4
  136. package/hooks/pre-bash-memory-propose-audit.mjs +13 -7
  137. package/hooks/pre-bash-sessions-ledger-guard.mjs +159 -0
  138. package/hooks/pre-bash-staging-fence.mjs +4 -0
  139. package/hooks/pre-task-scope-disjoint.mjs +368 -35
  140. package/hooks/skill-invocation-telemetry.mjs +21 -10
  141. package/hooks/subagent-telemetry.mjs +11 -26
  142. package/monitors/monitors.json +6 -0
  143. package/package.json +1 -1
  144. package/pi/prompts/journey-audit.md +12 -0
  145. package/rules/_index.md +9 -1
  146. package/rules/always-on/ask-via-tool.md +62 -0
  147. package/rules/always-on/bash-harness-pitfalls.md +168 -0
  148. package/rules/always-on/build-value.md +47 -0
  149. package/rules/always-on/cross-session-messaging.md +59 -0
  150. package/rules/always-on/loop-and-monitor.md +221 -0
  151. package/rules/always-on/parallel-sessions.md +142 -12
  152. package/rules/always-on/receiving-review.md +108 -0
  153. package/rules/always-on/test-value.md +40 -0
  154. package/rules/always-on/verification-before-completion.md +77 -0
  155. package/scripts/archive-closed-prds.mjs +258 -18
  156. package/scripts/autopilot.mjs +31 -12
  157. package/scripts/backfill-abandoned-sessions.mjs +80 -11
  158. package/scripts/backfill-evidence-digest.mjs +376 -0
  159. package/scripts/cursor-install.mjs +89 -48
  160. package/scripts/emit-event.mjs +10 -2
  161. package/scripts/export-hw-learnings.mjs +143 -2
  162. package/scripts/express-path.mjs +299 -0
  163. package/scripts/generate-cursor-adapter.mjs +253 -0
  164. package/scripts/github-protection-audit.mjs +358 -0
  165. package/scripts/lib/auq/parse.mjs +5 -29
  166. package/scripts/lib/auto-dialectic.mjs +68 -0
  167. package/scripts/lib/autopilot/worktree-pipeline.mjs +318 -18
  168. package/scripts/lib/build-live-signals.mjs +49 -27
  169. package/scripts/lib/ci-status-banner.mjs +158 -11
  170. package/scripts/lib/cold-start-detector.mjs +23 -14
  171. package/scripts/lib/command-blocker.mjs +70 -0
  172. package/scripts/lib/config/block-header.mjs +55 -0
  173. package/scripts/lib/config/discovery-validator.mjs +7 -2
  174. package/scripts/lib/config/health-endpoints.mjs +383 -0
  175. package/scripts/lib/config/reconcile.mjs +79 -4
  176. package/scripts/lib/config/remote-hosts.mjs +233 -0
  177. package/scripts/lib/config/section-extractor.mjs +235 -36
  178. package/scripts/lib/config-schema.mjs +9 -1
  179. package/scripts/lib/config.mjs +87 -8
  180. package/scripts/lib/convergence-monitor.mjs +13 -2
  181. package/scripts/lib/cursor-hook-bridge.mjs +443 -0
  182. package/scripts/lib/dispatcher/cli.mjs +2 -2
  183. package/scripts/lib/dispatcher/enumerate.mjs +2 -17
  184. package/scripts/lib/events-schema.mjs +48 -0
  185. package/scripts/lib/events.mjs +238 -5
  186. package/scripts/lib/evolve/autonomy-verdict.mjs +9 -4
  187. package/scripts/lib/evolve/autopilot-effectiveness.mjs +18 -1
  188. package/scripts/lib/express-path.mjs +327 -0
  189. package/scripts/lib/file-lock.mjs +22 -4
  190. package/scripts/lib/gates/gate-full.mjs +81 -8
  191. package/scripts/lib/gates/gate-helpers.mjs +76 -15
  192. package/scripts/lib/git-config-drift.mjs +134 -5
  193. package/scripts/lib/gitlab-portfolio/cli.mjs +3 -15
  194. package/scripts/lib/harness-audit/categories/category1.mjs +17 -6
  195. package/scripts/lib/host-identity.mjs +247 -2
  196. package/scripts/lib/instruction-budget-guard.mjs +31 -1
  197. package/scripts/lib/issue-budget.mjs +229 -30
  198. package/scripts/lib/learnings/io.mjs +55 -10
  199. package/scripts/lib/learnings/schema.mjs +95 -28
  200. package/scripts/lib/lock-reaper.mjs +7 -1
  201. package/scripts/lib/locks/staging-fence-lock.mjs +5 -1
  202. package/scripts/lib/locks/state-md-lock.mjs +8 -1
  203. package/scripts/lib/memory-banner.mjs +25 -10
  204. package/scripts/lib/memory-paths.mjs +15 -6
  205. package/scripts/lib/mode-selector/scoring.mjs +53 -6
  206. package/scripts/lib/peer-discovery.mjs +20 -2
  207. package/scripts/lib/platform.mjs +72 -9
  208. package/scripts/lib/plugin-root.mjs +143 -19
  209. package/scripts/lib/project-hygiene.mjs +43 -3
  210. package/scripts/lib/quality-gate.mjs +271 -13
  211. package/scripts/lib/reconcile/emitter.mjs +87 -19
  212. package/scripts/lib/reconcile/engine.mjs +517 -18
  213. package/scripts/lib/reconcile/idempotency.mjs +102 -1
  214. package/scripts/lib/reconcile/renderer.mjs +148 -3
  215. package/scripts/lib/reconcile/sanitize.mjs +40 -17
  216. package/scripts/lib/reconcile/writer.mjs +415 -84
  217. package/scripts/lib/rule-loader.mjs +37 -2
  218. package/scripts/lib/rules-sync.mjs +51 -8
  219. package/scripts/lib/scope-gate.mjs +126 -0
  220. package/scripts/lib/session-close-backfill.mjs +427 -37
  221. package/scripts/lib/session-discovery.mjs +69 -5
  222. package/scripts/lib/session-end/phase-skip.mjs +38 -5
  223. package/scripts/lib/session-end/worktree-cleanup.mjs +154 -7
  224. package/scripts/lib/session-id.mjs +30 -14
  225. package/scripts/lib/session-identity/own-session.mjs +220 -0
  226. package/scripts/lib/session-lock.mjs +85 -30
  227. package/scripts/lib/session-schema/normalizer.mjs +70 -3
  228. package/scripts/lib/session-schema/validator.mjs +40 -0
  229. package/scripts/lib/session-start-probes.mjs +608 -0
  230. package/scripts/lib/session-transition.mjs +277 -0
  231. package/scripts/lib/sessions-canonical.mjs +446 -0
  232. package/scripts/lib/sessions-staleness-banner.mjs +124 -57
  233. package/scripts/lib/spiral-carryover.mjs +90 -9
  234. package/scripts/lib/state-md/frontmatter-mutators.mjs +41 -8
  235. package/scripts/lib/state-md/mission-status.mjs +350 -52
  236. package/scripts/lib/state-md/yaml-parser.mjs +145 -16
  237. package/scripts/lib/state-md.mjs +12 -2
  238. package/scripts/lib/telemetry/schema.mjs +74 -8
  239. package/scripts/lib/telemetry/sync.mjs +91 -16
  240. package/scripts/lib/tmux-layout/telemetry.mjs +14 -2
  241. package/scripts/lib/validate/check-agents.mjs +66 -0
  242. package/scripts/lib/validate/check-cursor-adapter.mjs +102 -0
  243. package/scripts/lib/validate/check-dead-bridge.mjs +24 -2
  244. package/scripts/lib/validate/check-doc-cli-commands.mjs +25 -65
  245. package/scripts/lib/validate/check-hooks-emit-event-guard.mjs +370 -0
  246. package/scripts/lib/validate/check-hooks-symmetry.mjs +29 -63
  247. package/scripts/lib/validate/check-playwright-mcp-canary.mjs +13 -22
  248. package/scripts/lib/validate/check-plugin-monitors.mjs +10 -4
  249. package/scripts/lib/validate/check-skill-script-paths.mjs +436 -0
  250. package/scripts/lib/validate/check-test-value-bans.mjs +165 -17
  251. package/scripts/lib/validate/check-untracked-test-deps.mjs +10 -0
  252. package/scripts/lib/validate/check-unwired-features.mjs +333 -32
  253. package/scripts/lib/validate/check-validator-registration.mjs +248 -0
  254. package/scripts/lib/validate/check-vcs-repo-flag.mjs +6 -28
  255. package/scripts/lib/validate/markdown-fences.mjs +196 -0
  256. package/scripts/lib/validate/repo-files.mjs +275 -0
  257. package/scripts/lib/validate-vendored-rules.mjs +229 -7
  258. package/scripts/lib/vault-mirror/process.mjs +99 -43
  259. package/scripts/lib/vault-mirror/telemetry.mjs +210 -0
  260. package/scripts/lib/vault-staleness-banner.mjs +76 -6
  261. package/scripts/lib/vault-status/board-lock.mjs +185 -0
  262. package/scripts/lib/vault-status/board-writer.mjs +381 -141
  263. package/scripts/lib/vault-status/narrative-mirror.mjs +190 -27
  264. package/scripts/lib/wave-executor/foreign-dispatch.mjs +832 -0
  265. package/scripts/lib/wave-executor/remote-dispatch.mjs +504 -0
  266. package/scripts/lib/wave-resource-gate.mjs +127 -7
  267. package/scripts/lib/wave-transcript-tail.mjs +889 -0
  268. package/scripts/materialize-wave-scope.mjs +228 -15
  269. package/scripts/mcp-server.sh +11 -2
  270. package/scripts/memory-propose.mjs +132 -8
  271. package/scripts/parse-config.mjs +65 -0
  272. package/scripts/promote-vault-strict.mjs +4 -15
  273. package/scripts/site-numbers.mjs +36 -4
  274. package/scripts/token-audit.sh +9 -2
  275. package/scripts/validate-plugin.mjs +29 -0
  276. package/scripts/validate-wave-scope.mjs +67 -0
  277. package/scripts/vault-consolidate.mjs +3 -11
  278. package/scripts/vault-integration-watcher.mjs +2 -4
  279. package/scripts/vault-mirror.mjs +305 -51
  280. package/skills/_shared/monitor-patterns.md +31 -5
  281. package/skills/_shared/parallel-aware-auq.md +31 -2
  282. package/skills/_shared/parallel-aware-preamble.md +19 -4
  283. package/skills/_shared/platform-tools.md +11 -5
  284. package/skills/_shared/state-ownership.md +29 -2
  285. package/skills/autopilot/SKILL.md +5 -1
  286. package/skills/bootstrap/SKILL.md +3 -3
  287. package/skills/bootstrap/_shared-template.md +18 -10
  288. package/skills/bootstrap/deep-template.md +10 -6
  289. package/skills/bootstrap/fast-template.md +15 -8
  290. package/skills/bootstrap/standard-template.md +10 -6
  291. package/skills/claude-md-drift-check/checker.mjs +39 -11
  292. package/skills/contract-version-bump/SKILL.md +1 -1
  293. package/skills/dispatcher/SKILL.md +1 -1
  294. package/skills/ecosystem-health/SKILL.md +4 -1
  295. package/skills/ecosystem-health/wizard.md +5 -0
  296. package/skills/evolve/SKILL.md +38 -1
  297. package/skills/journey-audit/SKILL.md +270 -0
  298. package/skills/peekaboo-driver/SKILL.md +15 -3
  299. package/skills/persona-panel/SKILL.md +1 -1
  300. package/skills/reconcile/SKILL.md +46 -3
  301. package/skills/remote-offload/SKILL.md +89 -0
  302. package/skills/session-end/SKILL.md +17 -4
  303. package/skills/session-end/metrics-collection.md +7 -4
  304. package/skills/session-end/phase-3-6-tail.md +20 -9
  305. package/skills/session-end/phase-3-7a-recommendations.md +16 -2
  306. package/skills/session-plan/SKILL.md +6 -1
  307. package/skills/session-plan/wave-template.md +1 -0
  308. package/skills/session-start/SKILL.md +54 -17
  309. package/skills/session-start/phase-7-5-mode-selector.md +15 -3
  310. package/skills/session-start/phase-8-5-express-path.md +77 -12
  311. package/skills/vault-sync/validator.mjs +31 -0
  312. package/skills/wave-executor/SKILL.md +5 -3
  313. package/skills/wave-executor/circuit-breaker.md +34 -9
  314. package/skills/wave-executor/wave-loop.md +143 -22
  315. package/templates/_shared/journey-manifest.md +110 -0
  316. package/templates/_shared/rules/parallel-sessions.md +0 -77
@@ -0,0 +1,383 @@
1
+ /**
2
+ * health-endpoints.mjs — Content-scoped parser for the `health-endpoints:`
3
+ * Session Config key, plus the block-form reading of `ecosystem-health:`.
4
+ *
5
+ * WHY THIS EXISTS (#1174): `health-endpoints` was read off the FLAT KV map via
6
+ * `_coerceList`, which bails to `null` the moment a value contains `{` and
7
+ * cannot see a nested YAML block at all. The ecosystem wizard
8
+ * (`scripts/lib/ecosystem-wizard/config-writer.mjs` `_buildEcosystemSnippetLines`)
9
+ * writes exactly the block form:
10
+ *
11
+ * ecosystem-health:
12
+ * health-endpoints:
13
+ * - name: API
14
+ * url: https://api.example.com/health
15
+ *
16
+ * Measured before the fix: the flat KV map ended up with bare `name` / `url`
17
+ * entries (last one wins → silent data loss), `health-endpoints` resolved to
18
+ * `null`, and because the header line `ecosystem-health:` carries no value it
19
+ * never matched the KV regex either — so the whole feature stayed dark while
20
+ * the wizard reported success. Consumers (`skills/ecosystem-health/SKILL.md`,
21
+ * `docs/session-config-reference.md`) expect an array of `{ name, url }`.
22
+ *
23
+ * ACCEPTED FORMS (contract — every one of these is written by some producer in
24
+ * this repo today):
25
+ *
26
+ * A) inline object array (docs/USER-GUIDE.md):
27
+ * health-endpoints: [{name: "API", url: "https://a/health"}, {name: "W", url: "http://w:8080/z"}]
28
+ *
29
+ * B) nested block, top-level OR one level under `ecosystem-health:`
30
+ * (the wizard's output, skills/ecosystem-health/wizard.md):
31
+ * health-endpoints:
32
+ * - name: API
33
+ * url: https://a/health
34
+ * Block list items may themselves be inline objects
35
+ * (`- { name: API, url: … }`) — the form docs/session-config-template.md
36
+ * uses.
37
+ *
38
+ * C) bare list of URLs — each URL becomes its own name
39
+ * (`{ name: <url>, url: <url> }`). Both spellings are accepted, because
40
+ * an operator extending the wizard's BLOCK by hand writes the block one:
41
+ * health-endpoints: [https://a/health, https://b/health]
42
+ * and
43
+ * health-endpoints:
44
+ * - https://a/health
45
+ *
46
+ * Empty list (`[]`) → `[]`. `none` / `null` / key absent → `null`.
47
+ *
48
+ * NOT accepted (deliberately): the `name=url` shorthand. An item carrying `=`
49
+ * is treated verbatim as a Form-C URL, never split.
50
+ *
51
+ * Malformed input (an entry missing `name` or `url`, an unmatched brace) →
52
+ * `null` plus exactly ONE `console.warn` line prefixed `config: health-endpoints:`.
53
+ * Never throws: a broken config key must not take down every session-start.
54
+ *
55
+ * Consumers: `scripts/lib/config.mjs` (`health-endpoints`, `ecosystem-health`).
56
+ *
57
+ * The header line itself (indent + optional inline value) is matched via the
58
+ * shared `matchBlockHeaderDetailed()` (#1185) rather than a private regex —
59
+ * this was the one parser in `scripts/lib/config/` whose header needed BOTH
60
+ * arbitrary indent (nested under `ecosystem-health:`) AND an inline value
61
+ * (Form A/C), which the plain `matchBlockHeader()` contract deliberately
62
+ * excludes. See `scripts/lib/config/block-header.mjs` for the shared matcher.
63
+ */
64
+
65
+ import { matchBlockHeader, matchBlockHeaderDetailed } from './block-header.mjs';
66
+ import { _coerceBoolean } from './coercers.mjs';
67
+
68
+ /**
69
+ * A scalar that is a URL: a scheme followed by `//`. Used to tell a Form-C
70
+ * bare-URL list item (`- https://a/health`) from a `key: value` pair
71
+ * (`- name: API`) inside a block. `https://…` would otherwise match the
72
+ * key/value regex with key `https`, which is why the check must come first.
73
+ */
74
+ const URL_SCALAR_RE = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//;
75
+
76
+ /**
77
+ * Strip a trailing YAML comment. Only a `#` PRECEDED BY WHITESPACE counts, so a
78
+ * URL fragment (`https://a/health#frag`) survives untouched.
79
+ * @param {string} s
80
+ * @returns {string}
81
+ */
82
+ function stripComment(s) {
83
+ return s.replace(/\s+#.*$/, '');
84
+ }
85
+
86
+ /**
87
+ * Strip one layer of matching surrounding quotes.
88
+ * @param {string} v
89
+ * @returns {string}
90
+ */
91
+ function unquote(v) {
92
+ if (v.length >= 2 && ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'")))) {
93
+ return v.slice(1, -1);
94
+ }
95
+ return v;
96
+ }
97
+
98
+ /**
99
+ * Split on commas that sit at brace/bracket depth 0 and outside quotes — so a
100
+ * comma inside a quoted URL never splits an entry.
101
+ * @param {string} s
102
+ * @returns {string[]}
103
+ */
104
+ function splitTopLevel(s) {
105
+ const out = [];
106
+ let depth = 0;
107
+ let quote = null;
108
+ let cur = '';
109
+ for (const ch of s) {
110
+ if (quote) {
111
+ cur += ch;
112
+ if (ch === quote) quote = null;
113
+ continue;
114
+ }
115
+ if (ch === '"' || ch === "'") { quote = ch; cur += ch; continue; }
116
+ if (ch === '{' || ch === '[') depth++;
117
+ else if (ch === '}' || ch === ']') depth--;
118
+ if (ch === ',' && depth === 0) { out.push(cur); cur = ''; continue; }
119
+ cur += ch;
120
+ }
121
+ out.push(cur);
122
+ return out;
123
+ }
124
+
125
+ /**
126
+ * Split `{…}, {…}` into the inner text of each object. Quote-aware, so a `}`
127
+ * inside a quoted value does not close an object.
128
+ * @param {string} s
129
+ * @returns {string[]|null} null on an unmatched brace.
130
+ */
131
+ function splitObjects(s) {
132
+ const out = [];
133
+ let depth = 0;
134
+ let quote = null;
135
+ let cur = '';
136
+ for (const ch of s) {
137
+ if (quote) {
138
+ if (depth > 0) cur += ch;
139
+ if (ch === quote) quote = null;
140
+ continue;
141
+ }
142
+ if (ch === '"' || ch === "'") {
143
+ quote = ch;
144
+ if (depth > 0) cur += ch;
145
+ continue;
146
+ }
147
+ if (ch === '{') {
148
+ depth++;
149
+ if (depth === 1) { cur = ''; continue; }
150
+ } else if (ch === '}') {
151
+ depth--;
152
+ if (depth < 0) return null;
153
+ if (depth === 0) { out.push(cur); cur = ''; continue; }
154
+ }
155
+ if (depth > 0) cur += ch;
156
+ }
157
+ if (depth !== 0 || quote) return null;
158
+ return out;
159
+ }
160
+
161
+ /**
162
+ * Parse the inner text of one `{ name: X, url: Y }` object.
163
+ * @param {string} inner
164
+ * @returns {{name: string, url: string}|null} null when name or url is missing.
165
+ */
166
+ function parseObjectBody(inner) {
167
+ let name = null;
168
+ let url = null;
169
+ for (const pair of splitTopLevel(inner)) {
170
+ const colonIdx = pair.indexOf(':');
171
+ if (colonIdx === -1) continue;
172
+ const k = pair.slice(0, colonIdx).trim().toLowerCase();
173
+ const v = unquote(pair.slice(colonIdx + 1).trim());
174
+ if (k === 'name') name = v;
175
+ else if (k === 'url') url = v;
176
+ }
177
+ if (!name || !url) return null;
178
+ return { name, url };
179
+ }
180
+
181
+ /**
182
+ * Parse an inline value (everything right of `health-endpoints:` on one line).
183
+ * @param {string} raw
184
+ * @returns {{ok: true, value: {name: string, url: string}[]|null} | {ok: false}}
185
+ */
186
+ function parseInlineValue(raw) {
187
+ let v = raw.trim();
188
+ // A trailing comment is only stripped when the value ends with `]` — that
189
+ // keeps `#` inside an unbracketed value (a URL fragment) intact.
190
+ const bracketed = v.match(/^(\[.*\])\s*(?:#.*)?$/s);
191
+ if (bracketed) v = bracketed[1];
192
+
193
+ if (v === '' || v === 'none' || v === 'null') return { ok: true, value: null };
194
+
195
+ const stripped = v.replace(/^\[/, '').replace(/\]$/, '').trim();
196
+ if (stripped === '') return { ok: true, value: [] };
197
+
198
+ if (stripped.includes('{')) {
199
+ const bodies = splitObjects(stripped);
200
+ if (bodies === null) return { ok: false };
201
+ const entries = [];
202
+ for (const body of bodies) {
203
+ const entry = parseObjectBody(body);
204
+ if (entry === null) return { ok: false };
205
+ entries.push(entry);
206
+ }
207
+ return entries.length === 0 ? { ok: false } : { ok: true, value: entries };
208
+ }
209
+
210
+ // Form C — bare list of URLs; the URL is its own name.
211
+ const urls = splitTopLevel(stripped).map((s) => unquote(s.trim())).filter((s) => s.length > 0);
212
+ if (urls.length === 0) return { ok: true, value: [] };
213
+ return { ok: true, value: urls.map((u) => ({ name: u, url: u })) };
214
+ }
215
+
216
+ /**
217
+ * Parse the indented block that follows a valueless `health-endpoints:` header.
218
+ * Terminates at the first non-blank line indented no deeper than the header —
219
+ * the same rule `issue-budget.mjs` uses, generalised so a header nested under
220
+ * `ecosystem-health:` terminates correctly too.
221
+ *
222
+ * @param {string[]} lines — all document lines
223
+ * @param {number} start — index of the first line after the header
224
+ * @param {number} headerIndent — indent width of the header line
225
+ * @returns {{ok: true, value: {name: string, url: string}[]|null} | {ok: false}}
226
+ */
227
+ function parseBlock(lines, start, headerIndent) {
228
+ /** @type {{name: string|null, url: string|null}[]} */
229
+ const entries = [];
230
+ let cur = null;
231
+
232
+ for (let i = start; i < lines.length; i++) {
233
+ const line = lines[i].replace(/\r$/, '');
234
+ if (line.trim() === '') continue;
235
+ const indent = line.match(/^[ \t]*/)[0].length;
236
+ if (indent <= headerIndent) break;
237
+
238
+ const clean = stripComment(line).replace(/\s+$/, '');
239
+ if (clean.trim() === '') continue;
240
+
241
+ const itemMatch = clean.match(/^[ \t]*-\s*(.*)$/);
242
+ if (itemMatch) {
243
+ const rest = itemMatch[1].trim();
244
+ if (rest.startsWith('{')) {
245
+ const bodies = splitObjects(rest);
246
+ if (bodies === null || bodies.length === 0) return { ok: false };
247
+ for (const body of bodies) {
248
+ const entry = parseObjectBody(body);
249
+ if (entry === null) return { ok: false };
250
+ entries.push(entry);
251
+ }
252
+ cur = null;
253
+ continue;
254
+ }
255
+ // Form C inside a block: a bare URL item is its own name — the same rule
256
+ // the inline form applies. Without this, `- https://a/health` fell into
257
+ // assignKV, matched as key `https`, and the whole key WARNed to null
258
+ // while the identical inline list parsed fine.
259
+ const scalar = unquote(rest);
260
+ if (URL_SCALAR_RE.test(scalar)) {
261
+ entries.push({ name: scalar, url: scalar });
262
+ cur = null;
263
+ continue;
264
+ }
265
+ cur = { name: null, url: null };
266
+ entries.push(cur);
267
+ if (rest === '') continue;
268
+ if (!assignKV(cur, rest)) return { ok: false };
269
+ continue;
270
+ }
271
+
272
+ const kvMatch = clean.match(/^[ \t]*[a-zA-Z][a-zA-Z0-9_-]*:\s*/);
273
+ if (!kvMatch) return { ok: false };
274
+ if (cur === null) return { ok: false };
275
+ if (!assignKV(cur, clean.trim())) return { ok: false };
276
+ }
277
+
278
+ if (entries.length === 0) return { ok: true, value: null };
279
+ for (const e of entries) {
280
+ if (!e.name || !e.url) return { ok: false };
281
+ }
282
+ return { ok: true, value: entries.map((e) => ({ name: e.name, url: e.url })) };
283
+ }
284
+
285
+ /**
286
+ * Assign one `key: value` line into an in-progress entry. Unknown keys are
287
+ * ignored (forward-compatible with a future `timeout:` sub-key); a line that
288
+ * is not a `key: value` pair at all is a parse failure.
289
+ *
290
+ * @param {{name: string|null, url: string|null}} entry
291
+ * @param {string} text
292
+ * @returns {boolean} false when the line is not a key/value pair.
293
+ */
294
+ function assignKV(entry, text) {
295
+ const m = text.match(/^([a-zA-Z][a-zA-Z0-9_-]*):\s*(.*)$/);
296
+ if (!m) return false;
297
+ const k = m[1].toLowerCase();
298
+ const v = unquote(m[2].trim());
299
+ if (k === 'name') entry.name = v;
300
+ else if (k === 'url') entry.url = v;
301
+ return true;
302
+ }
303
+
304
+ /**
305
+ * Parse `health-endpoints` from the raw markdown content.
306
+ *
307
+ * Scans the FULL document (not just the `## Session Config` fence), matching
308
+ * every sibling block parser in this directory, and takes the FIRST
309
+ * `health-endpoints:` occurrence — top-level or nested under `ecosystem-health:`.
310
+ *
311
+ * @param {string} content — full file contents
312
+ * @returns {{name: string, url: string}[]|null} `null` when absent, explicitly
313
+ * `none`/`null`, or malformed (malformed additionally emits one WARN).
314
+ */
315
+ export function _parseHealthEndpoints(content) {
316
+ if (typeof content !== 'string' || content === '') return null;
317
+
318
+ const lines = content.split(/\r?\n/);
319
+ for (let i = 0; i < lines.length; i++) {
320
+ const line = lines[i].replace(/\r$/, '');
321
+ const m = matchBlockHeaderDetailed(line, 'health-endpoints');
322
+ if (!m) continue;
323
+
324
+ const headerIndent = m.indent;
325
+ const inline = m.value ?? '';
326
+ const result = inline !== ''
327
+ ? parseInlineValue(inline)
328
+ : parseBlock(lines, i + 1, headerIndent);
329
+
330
+ if (!result.ok) {
331
+ console.warn(
332
+ `config: health-endpoints: malformed value at line ${i + 1} — expected a list of {name, url} entries; ignoring.`
333
+ );
334
+ return null;
335
+ }
336
+ return result.value;
337
+ }
338
+ return null;
339
+ }
340
+
341
+ /**
342
+ * Read `ecosystem-health` in its BLOCK form (`ecosystem-health:` with no value,
343
+ * followed by an indented body — what the wizard writes). The scalar form
344
+ * (`ecosystem-health: true`) is read by the caller off the KV map and takes
345
+ * precedence; this function only answers the case the KV map cannot see.
346
+ *
347
+ * A non-empty block means the feature is configured, hence enabled — unless the
348
+ * block carries an explicit `enabled: false`.
349
+ *
350
+ * @param {string} content — full file contents
351
+ * @returns {boolean|null} `null` when no block form is present (caller keeps its default).
352
+ */
353
+ export function _parseEcosystemHealthBlockEnabled(content) {
354
+ if (typeof content !== 'string' || content === '') return null;
355
+
356
+ const lines = content.split(/\r?\n/);
357
+ let inBlock = false;
358
+ const blockLines = [];
359
+ for (const rawLine of lines) {
360
+ const line = rawLine.replace(/\r$/, '');
361
+ if (!inBlock) {
362
+ if (matchBlockHeader(line, 'ecosystem-health')) inBlock = true;
363
+ continue;
364
+ }
365
+ if (line.length > 0 && !/^\s/.test(line)) break;
366
+ blockLines.push(line);
367
+ }
368
+
369
+ const meaningful = blockLines.filter((l) => stripComment(l).trim() !== '');
370
+ if (!inBlock || meaningful.length === 0) return null;
371
+
372
+ for (const l of meaningful) {
373
+ const m = stripComment(l).trim().match(/^enabled:\s*(\S+)$/i);
374
+ // Same truth table as the scalar path — `_coerceBoolean` accepts only
375
+ // true/false (case-insensitively) and THROWS on anything else. A local
376
+ // `=== 'true'` comparison silently mapped `yes` to false, so the block form
377
+ // disagreed with `ecosystem-health: yes` on the very same document.
378
+ // NB: `_coerceBoolean` uses its `key` argument for BOTH the map lookup and
379
+ // the error text, so the synthetic map must be keyed by that same string.
380
+ if (m) return _coerceBoolean(new Map([['ecosystem-health.enabled', m[1]]]), 'ecosystem-health.enabled', false);
381
+ }
382
+ return true;
383
+ }
@@ -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
  }