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
@@ -18,6 +18,7 @@ import { promisify } from 'node:util';
18
18
  import {
19
19
  resolveRepoSpec,
20
20
  resolveRepoHost,
21
+ resolveGitlabProjectTarget,
21
22
  redactUrlCredentials,
22
23
  detectVcsFamily,
23
24
  isQueryFailure,
@@ -46,6 +47,128 @@ async function execWithTimeout(cmd, args, opts = {}) {
46
47
  ]);
47
48
  }
48
49
 
50
+ /** C0 controls plus DEL — matching them is the POINT, hence the disable. */
51
+ // eslint-disable-next-line no-control-regex
52
+ const CONTROL_BYTE_RE = /[\u0000-\u001f\u007f]/g;
53
+
54
+ /**
55
+ * Replace every C0/DEL control byte with its printable `\uXXXX` escape.
56
+ *
57
+ * `JSON.stringify` covers the payload preview, but NOT `SyntaxError.message` —
58
+ * V8 quotes the offending input INTO that message verbatim, so an ANSI/CR
59
+ * payload reached the operator's terminal through the error text even after
60
+ * the preview was escaped. Both halves go through here.
61
+ *
62
+ * @param {unknown} text
63
+ * @returns {string}
64
+ */
65
+ function escapeControlBytes(text) {
66
+ return String(text).replace(
67
+ CONTROL_BYTE_RE,
68
+ (ch) => `\\u${ch.charCodeAt(0).toString(16).padStart(4, '0')}`,
69
+ );
70
+ }
71
+
72
+ /**
73
+ * Name a parsed JSON value's type WITHOUT quoting any of its content.
74
+ *
75
+ * @param {unknown} value
76
+ * @returns {'null'|'array'|'object'|'string'|'number'|'boolean'|'undefined'}
77
+ */
78
+ function jsonTypeOf(value) {
79
+ if (value === null) return 'null';
80
+ if (Array.isArray(value)) return 'array';
81
+ return /** @type {any} */ (typeof value);
82
+ }
83
+
84
+ /**
85
+ * Parse CLI stdout as JSON, degrading an unparseable payload onto this module's
86
+ * documented failure channel instead of a bare `SyntaxError` (CWE-502).
87
+ *
88
+ * The throw IS that channel, not an escape from it: every call site runs under
89
+ * `checkCiStatus`'s outer catch, whose comment already names "unparseable
90
+ * output" as a case it converts to `console.warn` + `null`. Measured
91
+ * 2026-08-28 at 30940cb, BEFORE this helper existed: an HTML login page from
92
+ * `gh repo view`, a literal `null`, and an empty `glab` stdout ALL already
93
+ * returned `null` with a warn — nothing crashed, and
94
+ * `tests/lib/ci-status-banner.test.mjs` § "error containment" pinned it. So
95
+ * this does not fix a crash — the outer catch already turned non-JSON output
96
+ * into a warn + `null`. The rule this helper closes is lexical
97
+ * (`json-parse-untrusted-input`, see below), not a crash it prevents.
98
+ *
99
+ * What it does fix is the message. The raw parse error (`Unexpected token
100
+ * '<'`) named neither the CLI nor the request, and this banner spawns four
101
+ * different subprocesses — an operator reading that line at session-start
102
+ * could not tell which one returned garbage, which is the same
103
+ * "could not read looks like nothing to report" class #1022/#1039 attacked.
104
+ * Guarding at the parse also satisfies the `json-parse-untrusted-input` rule,
105
+ * which keys on a LEXICALLY enclosing try/catch and cannot see the outer one.
106
+ *
107
+ * Ceiling (BV-004): the payload preview is clamped to 120 characters. An
108
+ * unbounded one would bury the session-start banner it is printed beside — a
109
+ * paginated HTML error page is the realistic worst case. Revisit if a CLI
110
+ * starts emitting a diagnostic that needs more than one line to identify.
111
+ *
112
+ * The preview is emitted through `JSON.stringify`, not raw. It comes from a
113
+ * subprocess whose stdout this module does not control and lands in a
114
+ * `console.warn` beside the session-start banner: a payload carrying ANSI
115
+ * escapes, a `\r`, or a bare newline could otherwise repaint or overwrite the
116
+ * lines around it. `JSON.stringify` escapes every control byte and quotes the
117
+ * result, so the preview stays exactly one line of printable text.
118
+ *
119
+ * `expect` closes the second half of the same gap: valid JSON of the WRONG
120
+ * SHAPE parsed fine and escaped this named channel entirely. Measured
121
+ * 2026-08-28 at 7daa3d2 — a `gh repo view` that printed `null` reached
122
+ * `const { nameWithOwner } = …` and threw a bare
123
+ * `TypeError: Cannot destructure property 'nameWithOwner' of 'null'`, which
124
+ * names neither the CLI nor the request; a `glab api …/pipelines` that printed
125
+ * `null` was swallowed by `!Array.isArray(pipelines) → return null`, silent.
126
+ * Both now fail as the SAME named error the parse failure produces.
127
+ *
128
+ * @param {string} stdout Raw child-process stdout (untrusted)
129
+ * @param {string} label The command that produced it, for the failure message
130
+ * @param {'object'|'array'} [expect] Required shape; omit to accept any JSON
131
+ * @returns {unknown} The parsed value
132
+ * @throws {Error} Named parse/shape failure carrying a bounded payload preview
133
+ */
134
+ function parseCliJson(stdout, label, expect) {
135
+ const raw = String(stdout ?? '');
136
+ // Bounded (BV-004: 120 chars — a paginated HTML error page is the realistic
137
+ // worst case) AND escaped, so it can never break the line it is printed on.
138
+ const preview = raw.trim().slice(0, 120);
139
+ const shown = preview
140
+ ? `got: ${escapeControlBytes(JSON.stringify(preview))}`
141
+ : 'got: (empty stdout)';
142
+
143
+ let parsed;
144
+ try {
145
+ parsed = JSON.parse(raw);
146
+ } catch (err) {
147
+ const reason = escapeControlBytes(err instanceof Error ? err.message : String(err));
148
+ // `cause` preserves the original for a debugger; the reason is ALSO
149
+ // inlined into the message because the outer catch reads `err.message`
150
+ // only — a cause-only wrapper would lose it on the operator-facing line.
151
+ throw new Error(
152
+ `${label} returned unparseable JSON (${reason}) — ${shown}`,
153
+ { cause: err },
154
+ );
155
+ }
156
+
157
+ // A shape mismatch reports the JSON TYPE, never the payload. The parse
158
+ // succeeded, so the bytes add nothing an operator can act on — and
159
+ // `tests/lib/ci-status-banner.test.mjs` § "unexpected benign pipeline
160
+ // metadata" pins that a well-formed-but-wrong-shaped API body must not have
161
+ // its contents echoed anywhere. A type name carries no body content.
162
+ const actual = jsonTypeOf(parsed);
163
+ if (expect === 'array' && actual !== 'array') {
164
+ throw new Error(`${label} returned JSON of an unexpected shape — expected an array, got ${actual}`);
165
+ }
166
+ if (expect === 'object' && actual !== 'object') {
167
+ throw new Error(`${label} returned JSON of an unexpected shape — expected an object, got ${actual}`);
168
+ }
169
+ return parsed;
170
+ }
171
+
49
172
  /**
50
173
  * The one failure this probe can produce that the frozen
51
174
  * `REMOTE_RESOLUTION_REASONS` set has no member for: the async timeout race
@@ -191,48 +314,26 @@ async function getHeadSha(repoRoot, deps = {}) {
191
314
  }
192
315
 
193
316
  /**
194
- * Get GitLab project ID via glab.
317
+ * Run a host-pinned `glab api <path>` request and return parsed JSON.
195
318
  *
196
- * #872: pins to `deps.repoSpec` via `-R` when resolved (host-pinning —
197
- * `glab repo view` otherwise falls back to the ambient `GITLAB_HOST`).
198
- *
199
- * @param {string} repoRoot
200
- * @param {{ execFile?: Function, timeoutMs?: number, repoSpec?: string }} deps
201
- * @returns {Promise<number>}
202
- */
203
- async function getGlabProjectId(repoRoot, deps = {}) {
204
- const args = ['repo', 'view', '--output', 'json'];
205
- if (deps.repoSpec) args.push('-R', deps.repoSpec);
206
- const result = await execWithTimeout(
207
- 'glab',
208
- args,
209
- { cwd: repoRoot, timeoutMs: deps.timeoutMs ?? DEFAULT_TIMEOUT_MS, execFile: deps.execFile },
210
- );
211
- const parsed = JSON.parse(result.stdout);
212
- return parsed.id;
213
- }
214
-
215
- /**
216
- * Run `glab api <path>` and return parsed JSON.
217
- *
218
- * #872: `glab api` has no repo/`-R` concept — it accepts only `--hostname`
219
- * to pin which GitLab instance the request targets. Pinned via
220
- * `deps.repoHost` when resolved.
319
+ * GitLab's API accepts neither a remote URL nor `-R`; callers provide the host
320
+ * proven by `resolveGitlabProjectTarget` so this helper cannot fall back to
321
+ * ambient `GITLAB_HOST` configuration.
221
322
  *
222
323
  * @param {string} apiPath
223
324
  * @param {string} repoRoot
224
- * @param {{ execFile?: Function, timeoutMs?: number, repoHost?: string }} deps
325
+ * @param {{ execFile?: Function, timeoutMs?: number, repoHost: string }} deps
326
+ * @param {'object'|'array'} [expect] Required payload shape (see `parseCliJson`)
225
327
  * @returns {Promise<unknown>}
226
328
  */
227
- async function glabApi(apiPath, repoRoot, deps = {}) {
228
- const args = ['api', apiPath];
229
- if (deps.repoHost) args.push('--hostname', deps.repoHost);
329
+ async function glabApi(apiPath, repoRoot, deps = {}, expect = undefined) {
330
+ const args = ['api', apiPath, '--hostname', deps.repoHost];
230
331
  const result = await execWithTimeout(
231
332
  'glab',
232
333
  args,
233
334
  { cwd: repoRoot, timeoutMs: deps.timeoutMs ?? DEFAULT_TIMEOUT_MS, execFile: deps.execFile },
234
335
  );
235
- return JSON.parse(result.stdout);
336
+ return parseCliJson(result.stdout, `glab api ${apiPath}`, expect);
236
337
  }
237
338
 
238
339
  /**
@@ -244,9 +345,10 @@ async function glabApi(apiPath, repoRoot, deps = {}) {
244
345
  * @param {string} apiPath
245
346
  * @param {string} repoRoot
246
347
  * @param {{ execFile?: Function, timeoutMs?: number, repoHost?: string }} deps
348
+ * @param {'object'|'array'} [expect] Required payload shape (see `parseCliJson`)
247
349
  * @returns {Promise<unknown>}
248
350
  */
249
- async function ghApi(apiPath, repoRoot, deps = {}) {
351
+ async function ghApi(apiPath, repoRoot, deps = {}, expect = undefined) {
250
352
  const args = ['api', apiPath];
251
353
  if (deps.repoHost) args.push('--hostname', deps.repoHost);
252
354
  const result = await execWithTimeout(
@@ -254,7 +356,7 @@ async function ghApi(apiPath, repoRoot, deps = {}) {
254
356
  args,
255
357
  { cwd: repoRoot, timeoutMs: deps.timeoutMs ?? DEFAULT_TIMEOUT_MS, execFile: deps.execFile },
256
358
  );
257
- return JSON.parse(result.stdout);
359
+ return parseCliJson(result.stdout, `gh api ${apiPath}`, expect);
258
360
  }
259
361
 
260
362
  /**
@@ -276,21 +378,40 @@ function ageDaysFrom(isoDate, now) {
276
378
  *
277
379
  * @param {string} repoRoot
278
380
  * @param {number} now
279
- * @param {{ execFile?: Function, timeoutMs?: number, repoSpec?: string, repoHost?: string }} deps
381
+ * @param {{
382
+ * execFile?: Function,
383
+ * timeoutMs?: number,
384
+ * gitlabProject?: { host: string, encodedProjectPath: string },
385
+ * }} deps
280
386
  * @returns {Promise<object|null>}
281
387
  */
282
388
  async function checkGitlab(repoRoot, now, deps = {}) {
283
- const projectId = await getGlabProjectId(repoRoot, deps);
284
- const currentSha = await getHeadSha(repoRoot, deps);
389
+ const project = deps.gitlabProject;
390
+ if (
391
+ !project ||
392
+ typeof project.host !== 'string' ||
393
+ typeof project.encodedProjectPath !== 'string' ||
394
+ project.host === '' ||
395
+ project.encodedProjectPath === ''
396
+ ) {
397
+ return null;
398
+ }
285
399
 
400
+ const currentSha = await getHeadSha(repoRoot, deps);
401
+ const apiDeps = { ...deps, repoHost: project.host };
402
+ const projectPath = `projects/${project.encodedProjectPath}`;
403
+ // `'array'` is load-bearing, not decoration: before it, a `glab api` that
404
+ // returned valid JSON of the wrong shape (`null`, `"ok"`, an object) fell
405
+ // into `!Array.isArray(pipelines) → return null` — a SILENT no-op an operator
406
+ // reads as "nothing to report". It now raises the same named error an
407
+ // unparseable payload does, so the outer catch warns.
286
408
  const pipelines = await glabApi(
287
- `projects/${projectId}/pipelines?order_by=updated_at&sort=desc&per_page=15`,
409
+ `${projectPath}/pipelines?order_by=updated_at&sort=desc&per_page=15`,
288
410
  repoRoot,
289
- deps,
411
+ apiDeps,
412
+ 'array',
290
413
  );
291
414
 
292
- if (!Array.isArray(pipelines)) return null;
293
-
294
415
  const currentPipeline = pipelines.find((p) => p.sha === currentSha);
295
416
 
296
417
  if (!currentPipeline) {
@@ -316,9 +437,10 @@ async function checkGitlab(repoRoot, now, deps = {}) {
316
437
  let allowFailureJobs;
317
438
  try {
318
439
  const jobs = await glabApi(
319
- `projects/${projectId}/pipelines/${currentPipeline.id}/jobs`,
440
+ `${projectPath}/pipelines/${currentPipeline.id}/jobs`,
320
441
  repoRoot,
321
- deps,
442
+ apiDeps,
443
+ 'array',
322
444
  );
323
445
  if (Array.isArray(jobs)) {
324
446
  const softFailed = jobs
@@ -383,9 +505,10 @@ async function checkGitlab(repoRoot, now, deps = {}) {
383
505
  let failingJobName;
384
506
  try {
385
507
  const jobs = await glabApi(
386
- `projects/${projectId}/pipelines/${currentPipeline.id}/jobs`,
508
+ `${projectPath}/pipelines/${currentPipeline.id}/jobs`,
387
509
  repoRoot,
388
- deps,
510
+ apiDeps,
511
+ 'array',
389
512
  );
390
513
  if (Array.isArray(jobs)) {
391
514
  const failedJob = jobs.find((j) => j.status === 'failed');
@@ -433,9 +556,9 @@ async function checkGitlab(repoRoot, now, deps = {}) {
433
556
  * `[HOST/]OWNER/REPO` shape `resolveRepoSpec({ vcs: 'github' })` returns is
434
557
  * exactly the positional's documented input format.
435
558
  *
436
- * The asymmetry with `getGlabProjectId` is real and deliberate: `glab repo
437
- * view` DOES accept `-R`, and `gh api`/`glab api` accept neither `-R` nor a
438
- * positional only `--hostname`. Do not unify these three call sites.
559
+ * GitLab CI differs deliberately: it derives a host-pinned API target from
560
+ * the selected remote, while GitHub still needs this lookup because its API
561
+ * path requires `nameWithOwner`. Do not unify these call sites.
439
562
  *
440
563
  * `nameWithOwner` is NOT derivable from `deps.repoSpec`, so this lookup
441
564
  * cannot be dropped: the spec carries a HOST prefix the `repos/<owner>/<repo>`
@@ -457,12 +580,22 @@ async function checkGithub(repoRoot, deps = {}) {
457
580
  repoViewArgs,
458
581
  { cwd: repoRoot, timeoutMs: deps.timeoutMs ?? DEFAULT_TIMEOUT_MS, execFile: deps.execFile },
459
582
  );
460
- const { nameWithOwner } = JSON.parse(repoViewResult.stdout);
583
+ // `'object'` before the destructuring: a `gh repo view` that printed `null`
584
+ // (or a bare string, or an array) used to throw a bare
585
+ // `TypeError: Cannot destructure property 'nameWithOwner' of 'null'`, whose
586
+ // message names neither the CLI nor the request — the exact identification
587
+ // failure this helper exists to fix.
588
+ const { nameWithOwner } = parseCliJson(
589
+ repoViewResult.stdout,
590
+ `gh ${repoViewArgs.join(' ')}`,
591
+ 'object',
592
+ );
461
593
 
462
594
  const data = await ghApi(
463
595
  `repos/${nameWithOwner}/commits/HEAD/check-runs`,
464
596
  repoRoot,
465
597
  deps,
598
+ 'object',
466
599
  );
467
600
 
468
601
  const checkRuns = data.check_runs;
@@ -523,12 +656,19 @@ async function checkGithub(repoRoot, deps = {}) {
523
656
  * or >= 2 remotes with no preference match) — a benign, measured absence
524
657
  * - Required CLI (glab / gh) not in PATH
525
658
  * - Any CLI invocation times out
526
- * - JSON parse failure on CLI output
527
659
  *
528
660
  * Also returns `null`, but with a `console.warn` trace, when the VCS-detection
529
- * QUERY ITSELF failed (`git` not on PATH, `git remote -v` erroring) or when a
530
- * present CLI rejected its invocation. `null` alone cannot express "could not
531
- * read" see the outer catch and Step 1 for why the warn channel carries it.
661
+ * QUERY ITSELF failed (`git` not on PATH, `git remote -v` erroring), when a
662
+ * present CLI rejected its invocation, or when a CLI returned output this
663
+ * module could not parse (see {@link parseCliJson}). `null` alone cannot
664
+ * express "could not read" — see the outer catch and Step 1 for why the warn
665
+ * channel carries it.
666
+ *
667
+ * The unparseable-output case was listed above as SILENT until 2026-08-28.
668
+ * That was never the behaviour — it has always fallen through to the outer
669
+ * catch's warn branch (measured at 30940cb). The drift survived because the
670
+ * one test covering it asserted only the `null` and let the file-wide
671
+ * `console.warn` spy swallow the rest.
532
672
  *
533
673
  * @param {{
534
674
  * repoRoot?: string,
@@ -540,10 +680,11 @@ async function checkGithub(repoRoot, deps = {}) {
540
680
  * execFile?: Function,
541
681
  * resolveRepoSpec?: (opts: { repoRoot: string, vcs: 'gitlab'|'github' }) => string|undefined,
542
682
  * resolveRepoHost?: (opts: { repoRoot: string, vcs: 'gitlab'|'github' }) => string|undefined,
543
- * }} deps Dependency-injection seam for testing. `resolveRepoSpec`/
544
- * `resolveRepoHost` default to the real `vcs-repo-spec.mjs` exports
545
- * (#872 host-pinning see that module for the `-R` vs `--hostname`
546
- * contract).
683
+ * resolveGitlabProjectTarget?: (opts: { repoRoot: string }) =>
684
+ * { host: string, encodedProjectPath: string }|undefined,
685
+ * }} deps Dependency-injection seam for testing. GitLab defaults to
686
+ * `resolveGitlabProjectTarget`, which proves host and project path from one
687
+ * sanitized remote; GitHub retains the #872 spec/host resolvers.
547
688
  * @returns {Promise<null | {
548
689
  * status: 'green'|'red'|'unknown',
549
690
  * ok: boolean,
@@ -572,6 +713,7 @@ export async function checkCiStatus(opts = {}, deps = {}) {
572
713
 
573
714
  const resolveRepoSpecDep = deps.resolveRepoSpec ?? resolveRepoSpec;
574
715
  const resolveRepoHostDep = deps.resolveRepoHost ?? resolveRepoHost;
716
+ const resolveGitlabProjectTargetDep = deps.resolveGitlabProjectTarget ?? resolveGitlabProjectTarget;
575
717
 
576
718
  const depsWithExec = { execFile: execFileDep, timeoutMs };
577
719
 
@@ -605,22 +747,33 @@ export async function checkCiStatus(opts = {}, deps = {}) {
605
747
  vcs = detected.vcs;
606
748
  }
607
749
 
608
- // Step 1b (#872): resolve the -R/--hostname host-pinning spec ONCE per
609
- // checkCiStatus call a bare glab/gh spawn falls back to the ambient
610
- // GITLAB_HOST/GH_HOST env var, which can silently target the wrong
611
- // instance on a multi-instance host. `cwd: repoRoot` alone does not fix
612
- // this (ambient env still wins over cwd).
613
- const repoSpec = resolveRepoSpecDep({ repoRoot, vcs });
614
- const repoHost = resolveRepoHostDep({ repoRoot, vcs });
615
- const depsWithPinning = { ...depsWithExec, repoSpec, repoHost };
616
-
617
- // Step 2: dispatch to VCS-specific implementation.
750
+ // Step 1b: GitLab's API target must be proven before its first glab spawn.
751
+ // A missing target is never permission to fall back to ambient
752
+ // GITLAB_HOST/repository configuration. GitHub retains its distinct
753
+ // repository lookup because its API path requires `nameWithOwner`.
754
+ //
755
+ // It is also not an ABSENCE: detectVcs just proved a GitLab remote exists,
756
+ // so `!gitlabProject` means "remote present, its form was rejected" — a
757
+ // QUERY FAILURE by the same rule the block above states. Returning null
758
+ // silently put this repo back in the pre-#1039 state where "CI green" and
759
+ // "could not ask" look identical. Reachable inputs measured 2026-08-21:
760
+ // `git://` scheme, a doubled slash in the path, a query string.
618
761
  if (vcs === 'gitlab') {
619
- return await checkGitlab(repoRoot, now, depsWithPinning);
762
+ const gitlabProject = resolveGitlabProjectTargetDep({ repoRoot });
763
+ if (!gitlabProject) {
764
+ console.warn(
765
+ 'WARN ci-status-banner: a GitLab remote was detected but its host/project path ' +
766
+ 'could not be derived, banner suppressed — CI state is UNKNOWN, not "green".',
767
+ );
768
+ return null;
769
+ }
770
+ return await checkGitlab(repoRoot, now, { ...depsWithExec, gitlabProject });
620
771
  }
621
772
 
622
773
  if (vcs === 'github') {
623
- return await checkGithub(repoRoot, depsWithPinning);
774
+ const repoSpec = resolveRepoSpecDep({ repoRoot, vcs });
775
+ const repoHost = resolveRepoHostDep({ repoRoot, vcs });
776
+ return await checkGithub(repoRoot, { ...depsWithExec, repoSpec, repoHost });
624
777
  }
625
778
 
626
779
  // Unknown VCS value — silent no-op.
@@ -648,12 +648,57 @@ export function tokenizeCommand(command) {
648
648
  return tokens;
649
649
  }
650
650
 
651
+ /**
652
+ * Shell RESERVED WORDS and grouping tokens that bash recognises ONLY in COMMAND
653
+ * POSITION — the first word of a statement (#1145). Everywhere else they are
654
+ * ordinary arguments (`echo do`, `git commit -m then`), which is exactly why the
655
+ * drop below is position-gated rather than a blanket text filter.
656
+ *
657
+ * Two classes, both dropped, for the same reason:
658
+ * - OPENERS (`{`, `(`, `!`, `if`, `elif`, `then`, `else`, `while`, `until`,
659
+ * `do`) are followed by a COMMAND. Dropping one moves verb resolution
660
+ * towards the real command — the identical safety argument WRAPPER_UNWRAP
661
+ * makes for a transparent wrapper: a dropped token is a reserved word, never
662
+ * an interpreter, so this cannot turn a match into a miss.
663
+ * - CLOSERS (`}`, `)`, `fi`, `done`, `esac`) end one. In command position they
664
+ * are the whole segment, which then filters out as empty.
665
+ *
666
+ * Deliberately NOT listed:
667
+ * - `for` / `select` / `case` / `in` / `function` — followed by a NAME or WORD,
668
+ * never a command. Dropping them would invent a verb (`for t in a` → `t`)
669
+ * where the truthful answer is "this segment has no command".
670
+ * - `time` — a reserved word AND an external binary, already classified as a
671
+ * transparent wrapper in WRAPPER_UNWRAP. One classification per token, in
672
+ * one table: a second entry here would fork the wrapper-vs-keyword decision
673
+ * the way `VERB_PREFIXES` once forked WRAPPER_UNWRAP (#991).
674
+ *
675
+ * NAMED CEILING (BV-004): only a STANDALONE token is dropped. bash requires
676
+ * whitespace around `{` / `}`, so brace groups are always covered; a subshell
677
+ * written WITHOUT the space (`(rm -rf /)`) lexes as the glued word `(rm` and is
678
+ * not reached — the pre-#1145 behaviour, unchanged. Peeling the paren off the
679
+ * token TEXT would mutate a token five consumers read positionally, which is a
680
+ * larger contract change than this defect justifies. Revisit if a glued-paren
681
+ * form shows up in a real overflow triage or guard-bypass measurement.
682
+ */
683
+ const COMMAND_POSITION_KEYWORDS = new Set([
684
+ '{', '(', '!', 'if', 'elif', 'then', 'else', 'while', 'until', 'do',
685
+ '}', ')', 'fi', 'done', 'esac',
686
+ ]);
687
+
651
688
  /**
652
689
  * Split a tokenized command into chained segments on shell control operators
653
690
  * (`;`, `&&`, `||`, `|`, `&`) and on newline separators (#981). Only UNQUOTED
654
691
  * single-token operators split; an operator that arrived inside quotes stays
655
692
  * part of its segment.
656
693
  *
694
+ * A segment additionally has its COMMAND-POSITION reserved words stripped
695
+ * (#1145) — see {@link COMMAND_POSITION_KEYWORDS}. Without this the first token
696
+ * of a compound statement is `do` / `then` / `{` / `(` and NEVER the command,
697
+ * so every consumer that reads a segment head saw a keyword: measured against
698
+ * the live issue-budget guard with `max-per-session: 1, mode: strict`,
699
+ * `for t in a b c; do glab issue create …; done` and `{ glab issue create …; }`
700
+ * were allowed with NO accounting at all, while the plain form denied at 1/1.
701
+ *
657
702
  * The newline separator is checked by its `operator` field as well as its text,
658
703
  * so the split survives a future change to that token's spelling. Because a
659
704
  * separator token is CONSUMED here, it can never reach a per-segment operand
@@ -678,6 +723,13 @@ function splitSegments(tokens) {
678
723
  current = [];
679
724
  continue;
680
725
  }
726
+ // Reserved word in COMMAND POSITION (`current` still empty) → drop it, so
727
+ // the NEXT token becomes the segment head and is itself judged in command
728
+ // position (`do { rm …` peels both). A keyword anywhere else is an ordinary
729
+ // argument and is kept verbatim (#1145).
730
+ if (current.length === 0 && !tok.quoted && COMMAND_POSITION_KEYWORDS.has(tok.text)) {
731
+ continue;
732
+ }
681
733
  current.push(tok);
682
734
  }
683
735
  segments.push(current);
@@ -763,6 +815,24 @@ export const WRAPPER_UNWRAP = new Map([
763
815
  }],
764
816
  ['command', {}],
765
817
  ['nohup', {}],
818
+ // `exec cmd args` REPLACES the shell with `cmd` — it delegates to a real verb
819
+ // and never executes a command STRING, so by the #982 classification it is a
820
+ // TRANSPARENT wrapper, not an interpreter (contrast `su -c`, which is in
821
+ // SHELL_EXEC_INTERPRETERS for exactly the opposite reason). `-a NAME` is the
822
+ // one value-taking flag; `-c` (clear environment) and `-l` are BOOLEANS here
823
+ // — despite the spelling, `exec -c` carries no payload, so it must NOT reach
824
+ // DASH_C_SHELLS, and it does not: that set is keyed on the RESOLVED verb.
825
+ // Redirect-only `exec > file` keeps working. Measured both namespaces: on the
826
+ // RAW segment the wrapper consumes `exec` and the redirect operator resolves
827
+ // as the verb (`>`, previously `exec`); on scope-gate's paren-peeled,
828
+ // redirect-stripped projection the segment exhausts in wrappers and yields
829
+ // `verb: null, index: -1` — the shape a bare `sudo` already produced. Neither
830
+ // is `rm`/`tee`/`sed`/`dd`, and a redirect target is read from the redirect
831
+ // token stream rather than from the verb, so detection is unaffected:
832
+ // measured `extractBashWriteTargets('exec > src/out.ts')` → `['src/out.ts']`.
833
+ ['exec', {
834
+ argFlags: new Set(['-a']),
835
+ }],
766
836
  // `-o FILE` is the BSD/GNU `time` report destination and it TRUNCATES without
767
837
  // `-a` (BSD time(1): "If file exists and the -a flag is not specified, the
768
838
  // file will be overwritten"). With an empty spec the operand was read as the
@@ -43,13 +43,36 @@ import { hasBlockHeader } from './block-header.mjs';
43
43
  const ALLOWED_AUTONOMY = ['off', 'advisory', 'autonomous-gated'];
44
44
  const DEFAULT_CONFIDENCE_FLOOR = 0.5;
45
45
 
46
+ /**
47
+ * The display-only tail of an option LABEL, stripped before the enum match.
48
+ *
49
+ * The label and the stored value were the SAME string, which made the label
50
+ * unchangeable: both consumers (`skills/session-start/SKILL.md` Phase 1.1 and
51
+ * `skills/bootstrap/SKILL.md` Phase 3.5.1) hand the SELECTED LABEL straight to
52
+ * writeDispatcherAutonomyBlock(), and coerceAutonomy() falls back to 'off' on
53
+ * any value outside the enum. Putting AUQ-003's `(Recommended)` marker into the
54
+ * label would therefore have written `autonomy: off` for an operator who picked
55
+ * `advisory` — a silent downgrade, fail-closed and therefore invisible.
56
+ *
57
+ * Measured before this split (renderDispatcherAutonomyBlock, 2026-08-22):
58
+ * 'advisory' => autonomy: advisory
59
+ * 'Advisory (surface only)' => autonomy: off ← the downgrade
60
+ *
61
+ * Anchored at the end, so it can only ever strip a suffix. The enum values
62
+ * themselves are untouched and stay greppable.
63
+ */
64
+ const RECOMMENDED_SUFFIX = /\s*\((?:Recommended|Empfohlen|Default)\)\s*$/u;
65
+
46
66
  /**
47
67
  * Coordinator-facing AUQ question definition for the one-time capture.
48
68
  * Mirrors the shape of `getInterviewQuestions()` entries in owner-interview.mjs:
49
69
  * { question, header, options: [{ label, description }], multiSelect }
50
70
  *
51
- * Option labels match the ALLOWED_AUTONOMY enum so the selected label maps
52
- * directly to the `autonomy` value passed to writeDispatcherAutonomyBlock().
71
+ * Each label BEGINS with its ALLOWED_AUTONOMY value; option 1 additionally
72
+ * carries the `(Recommended)` marker AUQ-003 puts in the label. `coerceAutonomy()`
73
+ * strips that marker, so the selected label still maps to the enum value written
74
+ * by writeDispatcherAutonomyBlock() — see the comment on RECOMMENDED_SUFFIX for
75
+ * why the two halves must not be the same string.
53
76
  * Option 1 (`off`) is the recommended, fail-closed default.
54
77
  *
55
78
  * @returns {{ question: string, header: string, options: Array<{ label: string, description: string }>, multiSelect: boolean }}
@@ -57,23 +80,23 @@ const DEFAULT_CONFIDENCE_FLOOR = 0.5;
57
80
  export function getDispatcherAutonomyQuestion() {
58
81
  return {
59
82
  question:
60
- 'Cross-repo dispatcher autonomy: how should this repo participate when the free-repo dispatcher routes work to it?',
61
- header: 'Dispatcher Autonomy (one-time)',
83
+ 'When /dispatcher looks across your repos for the next one to work on, how may it treat this repo?',
84
+ header: 'Dispatcher',
62
85
  options: [
63
86
  {
64
- label: 'off',
87
+ label: 'off (Recommended)',
65
88
  description:
66
- '(Recommended) Fail-closed. The dispatcher never routes work to this repo automatically. No behaviour change.',
89
+ 'Nothing changes: this repo is never offered and never started for you. Safe default — you keep picking the work.',
67
90
  },
68
91
  {
69
92
  label: 'advisory',
70
93
  description:
71
- 'The dispatcher surfaces ranked free-repo candidates for operator review no automated dispatch.',
94
+ 'This repo may appear in the ranked suggestions, so you can compare it against the others. You still start each session.',
72
95
  },
73
96
  {
74
97
  label: 'autonomous-gated',
75
98
  description:
76
- 'A deterministic confidence gate is checked first; only dispatches that clear the confidence-floor route automatically.',
99
+ 'Work may start here without asking, but only when the ranking beats the confidence floor written next to this setting.',
77
100
  },
78
101
  ],
79
102
  multiSelect: false,
@@ -128,7 +151,7 @@ export function isDispatcherAutonomyBlockPresent(claudeMdContent) {
128
151
  */
129
152
  function coerceAutonomy(value) {
130
153
  if (value === null || value === undefined) return 'off';
131
- const normalized = String(value).toLowerCase().trim();
154
+ const normalized = String(value).toLowerCase().replace(RECOMMENDED_SUFFIX, '').trim();
132
155
  return ALLOWED_AUTONOMY.includes(normalized) ? normalized : 'off';
133
156
  }
134
157