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,233 @@
1
+ import { matchBlockHeader } from './block-header.mjs';
2
+
3
+ /**
4
+ * remote-hosts.mjs — Parser for the `remote-hosts:` Session Config block (#1160).
5
+ *
6
+ * Lets a repo DECLARE ssh-reachable hosts that heavy wave roles (test / ui / perf)
7
+ * may be offloaded to instead of shrinking the wave under local resource pressure.
8
+ * This module only declares — it never probes the network, never dispatches, and
9
+ * never decides. Placement is `scripts/lib/wave-resource-gate.mjs`; the dispatch
10
+ * adapter lives in `scripts/lib/wave-executor/`.
11
+ *
12
+ * Exports:
13
+ * REMOTE_HOST_DEFAULTS — per-host defaults
14
+ * ALLOWED_REMOTE_ROLES — the agent-mapping roles a host may accept
15
+ * _parseRemoteHosts(content) — PURE, no side effects beyond a stderr WARN when a
16
+ * record is dropped. Returns [] when absent/empty.
17
+ *
18
+ * Block shape:
19
+ * remote-hosts:
20
+ * - alias: m5 # required; becomes `-H <alias>` argv
21
+ * roles-allowed: [test, ui, perf] # subset of ALLOWED_REMOTE_ROLES
22
+ * repo-path: ~/Projects/Alice # optional
23
+ * claude-path: ~/.local/bin/claude # optional
24
+ *
25
+ * Modelled on custom-phases.mjs (per-entry SAFE-regex validation, drop-with-warn,
26
+ * column-0 block termination, silent enum fallback). The one shape it adds is the
27
+ * inline `[a, b]` list for `roles-allowed`.
28
+ */
29
+
30
+ /** Per-host defaults. */
31
+ export const REMOTE_HOST_DEFAULTS = Object.freeze({
32
+ 'roles-allowed': ['test', 'ui', 'perf'],
33
+ 'repo-path': null,
34
+ 'claude-path': null,
35
+ });
36
+
37
+ /**
38
+ * The roles a remote host may accept. A deliberate SUBSET of `ALLOWED_ROLES` in
39
+ * scripts/lib/config.mjs — impl / db / security / compliance / docs stay local.
40
+ *
41
+ * NOT the wave-role enum ("Impl-Core", "Quality", …): those are two different
42
+ * enums and conflating them is the documented trap (see resolveApwCap's docstring
43
+ * in wave-resource-gate.mjs). The wave→agent-mapping-role translation is
44
+ * `OFFLOADABLE_WAVE_ROLES` in that same module.
45
+ *
46
+ * @type {readonly string[]}
47
+ */
48
+ export const ALLOWED_REMOTE_ROLES = Object.freeze(['test', 'ui', 'perf']);
49
+
50
+ // A host `alias` is an ssh destination that reaches argv as `-H <alias>`: letters,
51
+ // digits, hyphen, underscore, dot only. No spaces, no shell metacharacters.
52
+ //
53
+ // The first character is anchored separately from the rest because a charset
54
+ // cannot express a POSITION: an alias of `-H` passes any hyphen-inclusive
55
+ // allowlist and then reaches argv as an OPTION token, where the CLI swallows the
56
+ // operand behind it. Interior hyphens (`m5-box`) stay legal.
57
+ const SAFE_NAME_RE = /^[A-Za-z0-9._][A-Za-z0-9._-]*$/;
58
+
59
+ // `repo-path` / `claude-path` are filesystem paths handed to a remote shell —
60
+ // same allowlist as custom-phases' `review`, with the same leading-hyphen anchor
61
+ // as SAFE_NAME_RE above (`--x` is a flag, not a path).
62
+ const SAFE_PATH_RE = /^[A-Za-z0-9._~/][A-Za-z0-9._~/-]*$/;
63
+
64
+ /**
65
+ * Parse the top-level `remote-hosts:` YAML list block from markdown content.
66
+ *
67
+ * A record missing `alias`, or carrying an unsafe `alias` / `repo-path` /
68
+ * `claude-path`, is DROPPED with a stderr WARN. Unknown entries in
69
+ * `roles-allowed` are filtered out with a WARN; a record whose list is empty
70
+ * after filtering is dropped (it could never be selected anyway).
71
+ *
72
+ * @param {string} content — full CLAUDE.md / AGENTS.md file content
73
+ * @returns {Array<{alias: string, 'roles-allowed': string[], 'repo-path': string|null, 'claude-path': string|null}>}
74
+ */
75
+ export function _parseRemoteHosts(content) {
76
+ const lines = String(content ?? '').split(/\r?\n/);
77
+ let inBlock = false;
78
+ const blockLines = [];
79
+
80
+ for (const rawLine of lines) {
81
+ const line = rawLine.replace(/\r$/, '');
82
+
83
+ if (!inBlock) {
84
+ if (matchBlockHeader(line, 'remote-hosts')) inBlock = true;
85
+ continue;
86
+ }
87
+
88
+ // Block terminates at the first non-indented, non-empty line (next top-level key).
89
+ if (line.length > 0 && !/^\s/.test(line)) break;
90
+
91
+ blockLines.push(line);
92
+ }
93
+
94
+ if (blockLines.length === 0) return [];
95
+
96
+ const records = [];
97
+ /** @type {Record<string, string>|null} */
98
+ let current = null;
99
+
100
+ const flush = () => {
101
+ if (current === null) return;
102
+ const rec = _validateRecord(current);
103
+ if (rec !== null) records.push(rec);
104
+ current = null;
105
+ };
106
+
107
+ for (const rawLine of blockLines) {
108
+ // Strip inline comments + trailing whitespace, preserve leading indent.
109
+ const clean = rawLine.replace(/\s*#.*$/, '').replace(/\s+$/, '');
110
+ if (!clean.trim()) continue;
111
+
112
+ const dashMatch = clean.match(/^\s*-\s+(.*)$/);
113
+ if (dashMatch) {
114
+ flush();
115
+ current = {};
116
+ const inlineKv = dashMatch[1].match(/^([a-zA-Z][a-zA-Z0-9_-]*):\s*(.*)$/);
117
+ if (inlineKv) _assignKv(current, inlineKv[1], inlineKv[2]);
118
+ continue;
119
+ }
120
+
121
+ const kvMatch = clean.match(/^\s+([a-zA-Z][a-zA-Z0-9_-]*):\s*(.*)$/);
122
+ if (kvMatch && current !== null) {
123
+ _assignKv(current, kvMatch[1], kvMatch[2]);
124
+ }
125
+ }
126
+
127
+ flush();
128
+
129
+ return records;
130
+ }
131
+
132
+ /**
133
+ * Assign a raw key/value onto a record being built, stripping surrounding quotes.
134
+ * Unknown keys are silently ignored (additive-friendly).
135
+ *
136
+ * @param {Record<string, string>} record
137
+ * @param {string} key
138
+ * @param {string} rawValue
139
+ */
140
+ function _assignKv(record, key, rawValue) {
141
+ let v = rawValue.trim();
142
+ if (v.startsWith('"') && v.endsWith('"') && v.length >= 2) v = v.slice(1, -1);
143
+ else if (v.startsWith("'") && v.endsWith("'") && v.length >= 2) v = v.slice(1, -1);
144
+
145
+ switch (key) {
146
+ case 'alias':
147
+ case 'roles-allowed':
148
+ case 'repo-path':
149
+ case 'claude-path':
150
+ record[key] = v;
151
+ break;
152
+ default:
153
+ break;
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Parse an inline `[a, b]` (or bare `a, b`) list into trimmed non-empty strings.
159
+ *
160
+ * @param {string} raw
161
+ * @returns {string[]}
162
+ */
163
+ function _parseInlineList(raw) {
164
+ const inner = raw.trim().replace(/^\[/, '').replace(/\]$/, '');
165
+ return inner
166
+ .split(',')
167
+ .map((s) => s.trim().replace(/^["']|["']$/g, ''))
168
+ .filter((s) => s !== '');
169
+ }
170
+
171
+ /**
172
+ * Validate + normalise a raw record.
173
+ *
174
+ * @param {Record<string, string>} raw
175
+ * @returns {{alias: string, 'roles-allowed': string[], 'repo-path': string|null, 'claude-path': string|null}|null}
176
+ */
177
+ function _validateRecord(raw) {
178
+ const alias = (raw.alias ?? '').trim();
179
+ if (alias === '') {
180
+ process.stderr.write('remote-hosts: dropped record missing required field: alias\n');
181
+ return null;
182
+ }
183
+ if (!SAFE_NAME_RE.test(alias)) {
184
+ process.stderr.write(
185
+ `remote-hosts: dropped record with unsafe alias: ${JSON.stringify(alias)}\n`,
186
+ );
187
+ return null;
188
+ }
189
+
190
+ let rolesAllowed = [...REMOTE_HOST_DEFAULTS['roles-allowed']];
191
+ const rawRoles = (raw['roles-allowed'] ?? '').trim();
192
+ if (rawRoles !== '') {
193
+ const declared = _parseInlineList(rawRoles);
194
+ const kept = declared.filter((r) => ALLOWED_REMOTE_ROLES.includes(r));
195
+ const dropped = declared.filter((r) => !ALLOWED_REMOTE_ROLES.includes(r));
196
+ if (dropped.length > 0) {
197
+ process.stderr.write(
198
+ `remote-hosts: host '${alias}' declares unknown role(s) ${dropped.join(', ')} ` +
199
+ `(allowed: ${ALLOWED_REMOTE_ROLES.join(', ')}) — ignored\n`,
200
+ );
201
+ }
202
+ if (kept.length === 0) {
203
+ process.stderr.write(
204
+ `remote-hosts: dropped record '${alias}' — roles-allowed is empty after filtering\n`,
205
+ );
206
+ return null;
207
+ }
208
+ rolesAllowed = kept;
209
+ }
210
+
211
+ const paths = {};
212
+ for (const key of ['repo-path', 'claude-path']) {
213
+ let value = REMOTE_HOST_DEFAULTS[key];
214
+ const rawPath = (raw[key] ?? '').trim();
215
+ if (rawPath !== '' && rawPath !== 'null' && rawPath !== 'none') {
216
+ if (!SAFE_PATH_RE.test(rawPath)) {
217
+ process.stderr.write(
218
+ `remote-hosts: dropped record '${alias}' with shell metacharacter in ${key}: ${JSON.stringify(rawPath)}\n`,
219
+ );
220
+ return null;
221
+ }
222
+ value = rawPath;
223
+ }
224
+ paths[key] = value;
225
+ }
226
+
227
+ return {
228
+ alias,
229
+ 'roles-allowed': rolesAllowed,
230
+ 'repo-path': paths['repo-path'],
231
+ 'claude-path': paths['claude-path'],
232
+ };
233
+ }
@@ -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