@opengsd/gsd-core 1.7.0 → 1.9.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 (261) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.opencode/plugins/gsd-core.js +45 -1
  4. package/README.md +2 -0
  5. package/agents/gsd-code-fixer.md +1 -1
  6. package/agents/gsd-codebase-mapper.md +1 -1
  7. package/agents/gsd-debug-session-manager.md +78 -4
  8. package/agents/gsd-debugger.md +87 -29
  9. package/agents/gsd-executor.md +49 -9
  10. package/agents/gsd-intel-updater.md +3 -3
  11. package/agents/gsd-phase-researcher.md +4 -2
  12. package/agents/gsd-plan-checker.md +20 -0
  13. package/agents/gsd-planner.md +44 -59
  14. package/agents/gsd-project-researcher.md +2 -2
  15. package/agents/gsd-ui-auditor.md +0 -40
  16. package/agents/gsd-verifier.md +2 -2
  17. package/bin/install.js +1338 -135
  18. package/commands/gsd/ai-integration-phase.md +1 -1
  19. package/commands/gsd/mempalace-capture.md +9 -5
  20. package/commands/gsd/new-milestone.md +1 -1
  21. package/commands/gsd/plan-phase.md +5 -3
  22. package/commands/gsd/plan-review-convergence.md +7 -2
  23. package/gsd-core/bin/gsd-tools.cjs +2690 -2472
  24. package/gsd-core/bin/lib/adapter-imperative.cjs +8 -1
  25. package/gsd-core/bin/lib/agent-command-router.cjs +20 -5
  26. package/gsd-core/bin/lib/api-coverage.cjs +360 -53
  27. package/gsd-core/bin/lib/audit.cjs +8 -8
  28. package/gsd-core/bin/lib/broken-windows.cjs +716 -0
  29. package/gsd-core/bin/lib/capability-command-router.cjs +733 -0
  30. package/gsd-core/bin/lib/capability-consent.cjs +40 -1
  31. package/gsd-core/bin/lib/capability-lifecycle.cjs +58 -0
  32. package/gsd-core/bin/lib/capability-loader.cjs +23 -1
  33. package/gsd-core/bin/lib/capability-registry.cjs +1450 -160
  34. package/gsd-core/bin/lib/capability-trust.cjs +468 -33
  35. package/gsd-core/bin/lib/capability-validator.cjs +882 -6
  36. package/gsd-core/bin/lib/capability-writer.cjs +6 -1
  37. package/gsd-core/bin/lib/check-command-router.cjs +140 -27
  38. package/gsd-core/bin/lib/cjs-command-router-adapter.cjs +15 -0
  39. package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +209 -31
  40. package/gsd-core/bin/lib/claude-orchestration.cjs +203 -25
  41. package/gsd-core/bin/lib/command-aliases.cjs +14 -0
  42. package/gsd-core/bin/lib/commands.cjs +326 -21
  43. package/gsd-core/bin/lib/config-loader.cjs +214 -30
  44. package/gsd-core/bin/lib/config.cjs +158 -22
  45. package/gsd-core/bin/lib/core-utils.cjs +6 -1
  46. package/gsd-core/bin/lib/decisions.cjs +32 -8
  47. package/gsd-core/bin/lib/docs.cjs +6 -0
  48. package/gsd-core/bin/lib/estimate-cli.cjs +336 -0
  49. package/gsd-core/bin/lib/external-descriptor-trust.cjs +14 -2
  50. package/gsd-core/bin/lib/frontmatter.cjs +125 -15
  51. package/gsd-core/bin/lib/gap-checker.cjs +17 -2
  52. package/gsd-core/bin/lib/host-integration.cjs +215 -8
  53. package/gsd-core/bin/lib/init.cjs +155 -66
  54. package/gsd-core/bin/lib/install-engine.cjs +299 -23
  55. package/gsd-core/bin/lib/install-profiles.cjs +239 -1
  56. package/gsd-core/bin/lib/installer-migrations/005-opencode-baseline-commands-dir.cjs +146 -0
  57. package/gsd-core/bin/lib/installer-migrations/006-pi-extension-cjs-to-js.cjs +91 -0
  58. package/gsd-core/bin/lib/installer-migrations.cjs +44 -5
  59. package/gsd-core/bin/lib/markdown-sectionizer.cjs +107 -0
  60. package/gsd-core/bin/lib/milestone.cjs +248 -14
  61. package/gsd-core/bin/lib/model-catalog.cjs +69 -4
  62. package/gsd-core/bin/lib/model-resolver.cjs +189 -7
  63. package/gsd-core/bin/lib/observability/logger.cjs +7 -2
  64. package/gsd-core/bin/lib/onboard-projection.cjs +11 -8
  65. package/gsd-core/bin/lib/phase-command-router.cjs +10 -1
  66. package/gsd-core/bin/lib/phase-estimation.cjs +398 -0
  67. package/gsd-core/bin/lib/phase-id.cjs +304 -9
  68. package/gsd-core/bin/lib/phase.cjs +258 -17
  69. package/gsd-core/bin/lib/plan-drift-guard.cjs +1 -1
  70. package/gsd-core/bin/lib/plan-scan.cjs +70 -2
  71. package/gsd-core/bin/lib/planning-workspace.cjs +9 -2
  72. package/gsd-core/bin/lib/profile-output.cjs +34 -8
  73. package/gsd-core/bin/lib/review-lane-descriptor.cjs +927 -0
  74. package/gsd-core/bin/lib/review-lane-invocation.cjs +348 -0
  75. package/gsd-core/bin/lib/review-lane-runner.cjs +594 -0
  76. package/gsd-core/bin/lib/review-reviewer-selection.cjs +114 -32
  77. package/gsd-core/bin/lib/roadmap-parser.cjs +61 -10
  78. package/gsd-core/bin/lib/roadmap.cjs +23 -7
  79. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +38 -5
  80. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +23 -9
  81. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +156 -0
  82. package/gsd-core/bin/lib/runtime-name-policy.cjs +15 -2
  83. package/gsd-core/bin/lib/smart-entry.cjs +70 -5
  84. package/gsd-core/bin/lib/state-document.cjs +171 -24
  85. package/gsd-core/bin/lib/state-transition.cjs +50 -11
  86. package/gsd-core/bin/lib/state.cjs +206 -32
  87. package/gsd-core/bin/lib/surface.cjs +51 -9
  88. package/gsd-core/bin/lib/uat-predicate.cjs +6 -4
  89. package/gsd-core/bin/lib/uat.cjs +428 -11
  90. package/gsd-core/bin/lib/ui-consideration-probe.cjs +2 -2
  91. package/gsd-core/bin/lib/unusable-input.cjs +216 -0
  92. package/gsd-core/bin/lib/validate.cjs +44 -8
  93. package/gsd-core/bin/lib/verification.cjs +163 -31
  94. package/gsd-core/bin/lib/verify.cjs +348 -42
  95. package/gsd-core/bin/lib/worktree-safety.cjs +360 -15
  96. package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
  97. package/gsd-core/bin/shared/config-schema.manifest.json +4 -15
  98. package/gsd-core/bin/shared/model-catalog.json +5 -0
  99. package/gsd-core/bin/shared/runtime-aliases.manifest.json +5 -0
  100. package/gsd-core/references/api-coverage.md +37 -7
  101. package/gsd-core/references/checkpoints.md +1 -1
  102. package/gsd-core/references/common-bug-patterns.md +13 -0
  103. package/gsd-core/references/context-budget.md +40 -0
  104. package/gsd-core/references/debugger-bug-taxonomy.md +111 -0
  105. package/gsd-core/references/debugger-fix-acceptance.md +157 -0
  106. package/gsd-core/references/debugger-philosophy.md +1 -0
  107. package/gsd-core/references/debugger-prevention.md +98 -0
  108. package/gsd-core/references/debugger-rca-branching.md +98 -0
  109. package/gsd-core/references/debugger-repro-hardening.md +130 -0
  110. package/gsd-core/references/debugger-sbfl.md +110 -0
  111. package/gsd-core/references/debugger-semantic-recall.md +81 -0
  112. package/gsd-core/references/execute-phase-quota-recovery.md +55 -0
  113. package/gsd-core/references/execute-phase-requirement-revert.md +8 -0
  114. package/gsd-core/references/execute-phase-response-language.md +7 -0
  115. package/gsd-core/references/gate-prompts.md +6 -3
  116. package/gsd-core/references/model-profile-resolution.md +64 -13
  117. package/gsd-core/references/offer-next.md +88 -0
  118. package/gsd-core/references/planner-antipatterns.md +6 -0
  119. package/gsd-core/references/planner-mvp-mode.md +12 -13
  120. package/gsd-core/references/planner-preconditions.md +156 -0
  121. package/gsd-core/references/planner-reversibility.md +132 -0
  122. package/gsd-core/references/planning-config.md +2 -1
  123. package/gsd-core/references/reviewer-instances.md +28 -19
  124. package/gsd-core/references/runtime-aware-dispatch.md +42 -0
  125. package/gsd-core/references/skeleton-template.md +1 -1
  126. package/gsd-core/references/thinking-models-planning.md +3 -1
  127. package/gsd-core/references/ui-consideration-probe.md +2 -2
  128. package/gsd-core/references/worktree-branch-check.md +4 -4
  129. package/gsd-core/templates/DEBUG.md +5 -3
  130. package/gsd-core/templates/summary-minimal.md +4 -0
  131. package/gsd-core/templates/summary-standard.md +4 -0
  132. package/gsd-core/templates/summary.md +7 -0
  133. package/gsd-core/workflows/add-phase.md +2 -0
  134. package/gsd-core/workflows/add-tests.md +3 -1
  135. package/gsd-core/workflows/add-todo.md +32 -1
  136. package/gsd-core/workflows/ai-integration-phase.md +8 -6
  137. package/gsd-core/workflows/audit-fix.md +6 -2
  138. package/gsd-core/workflows/audit-milestone.md +8 -0
  139. package/gsd-core/workflows/autonomous.md +19 -15
  140. package/gsd-core/workflows/check-todos.md +5 -3
  141. package/gsd-core/workflows/cleanup.md +7 -1
  142. package/gsd-core/workflows/code-review-fix.md +14 -6
  143. package/gsd-core/workflows/code-review.md +93 -24
  144. package/gsd-core/workflows/complete-milestone.md +3 -0
  145. package/gsd-core/workflows/debug.md +35 -7
  146. package/gsd-core/workflows/diagnose-issues.md +5 -1
  147. package/gsd-core/workflows/discovery-phase.md +7 -0
  148. package/gsd-core/workflows/discuss-phase/modes/advisor.md +2 -4
  149. package/gsd-core/workflows/discuss-phase/modes/auto.md +0 -6
  150. package/gsd-core/workflows/discuss-phase/templates/context.md +16 -2
  151. package/gsd-core/workflows/discuss-phase-assumptions.md +18 -9
  152. package/gsd-core/workflows/discuss-phase.md +2 -2
  153. package/gsd-core/workflows/do.md +7 -1
  154. package/gsd-core/workflows/docs-update.md +9 -0
  155. package/gsd-core/workflows/eval-review.md +4 -1
  156. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +4 -0
  157. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +160 -0
  158. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +4 -4
  159. package/gsd-core/workflows/execute-phase/steps/regression-gate.md +2 -2
  160. package/gsd-core/workflows/execute-phase.md +110 -149
  161. package/gsd-core/workflows/execute-plan.md +20 -8
  162. package/gsd-core/workflows/explore.md +4 -0
  163. package/gsd-core/workflows/extract-learnings.md +21 -0
  164. package/gsd-core/workflows/graduation.md +3 -0
  165. package/gsd-core/workflows/health.md +7 -1
  166. package/gsd-core/workflows/help/modes/full.md +9 -5
  167. package/gsd-core/workflows/import.md +11 -2
  168. package/gsd-core/workflows/inbox.md +7 -0
  169. package/gsd-core/workflows/ingest-docs.md +19 -10
  170. package/gsd-core/workflows/manager.md +3 -1
  171. package/gsd-core/workflows/map-codebase.md +17 -10
  172. package/gsd-core/workflows/mvp-phase.md +3 -0
  173. package/gsd-core/workflows/new-milestone.md +79 -23
  174. package/gsd-core/workflows/new-project.md +28 -19
  175. package/gsd-core/workflows/new-workspace.md +3 -1
  176. package/gsd-core/workflows/next.md +5 -2
  177. package/gsd-core/workflows/onboard.md +3 -0
  178. package/gsd-core/workflows/plan-phase.md +56 -51
  179. package/gsd-core/workflows/plan-review-convergence.md +61 -12
  180. package/gsd-core/workflows/plant-seed.md +3 -0
  181. package/gsd-core/workflows/profile-user.md +7 -1
  182. package/gsd-core/workflows/progress.md +31 -3
  183. package/gsd-core/workflows/quick.md +33 -10
  184. package/gsd-core/workflows/remove-workspace.md +3 -0
  185. package/gsd-core/workflows/review.md +172 -585
  186. package/gsd-core/workflows/scan.md +10 -2
  187. package/gsd-core/workflows/secure-phase.md +13 -2
  188. package/gsd-core/workflows/settings-integrations.md +3 -0
  189. package/gsd-core/workflows/settings.md +3 -0
  190. package/gsd-core/workflows/ship.md +88 -11
  191. package/gsd-core/workflows/sketch.md +3 -0
  192. package/gsd-core/workflows/smart-entry.md +4 -1
  193. package/gsd-core/workflows/spike.md +7 -1
  194. package/gsd-core/workflows/ui-phase.md +11 -2
  195. package/gsd-core/workflows/ui-review.md +11 -1
  196. package/gsd-core/workflows/undo.md +7 -0
  197. package/gsd-core/workflows/update.md +106 -5
  198. package/gsd-core/workflows/validate-phase.md +13 -2
  199. package/gsd-core/workflows/verify-phase.md +2 -2
  200. package/gsd-core/workflows/verify-work.md +15 -4
  201. package/hooks/dist/gsd-context-monitor.js +27 -9
  202. package/hooks/dist/gsd-cursor-session-start.js +6 -2
  203. package/hooks/dist/gsd-cursor-stop.js +6 -2
  204. package/hooks/dist/gsd-cursor-subagent-start.js +6 -2
  205. package/hooks/dist/gsd-graphify-update.sh +9 -0
  206. package/hooks/dist/gsd-phase-boundary.sh +14 -2
  207. package/hooks/dist/gsd-prompt-guard.js +101 -2
  208. package/hooks/dist/gsd-read-guard.js +100 -2
  209. package/hooks/dist/gsd-read-injection-scanner.js +109 -2
  210. package/hooks/dist/gsd-statusline.js +97 -9
  211. package/hooks/dist/gsd-workflow-guard.js +110 -6
  212. package/hooks/dist/gsd-worktree-path-guard.js +132 -8
  213. package/hooks/dist/lib/cursor-workspace.js +74 -0
  214. package/hooks/gsd-context-monitor.js +27 -9
  215. package/hooks/gsd-cursor-session-start.js +6 -2
  216. package/hooks/gsd-cursor-stop.js +6 -2
  217. package/hooks/gsd-cursor-subagent-start.js +6 -2
  218. package/hooks/gsd-graphify-update.sh +9 -0
  219. package/hooks/gsd-phase-boundary.sh +14 -2
  220. package/hooks/gsd-prompt-guard.js +101 -2
  221. package/hooks/gsd-read-guard.js +100 -2
  222. package/hooks/gsd-read-injection-scanner.js +109 -2
  223. package/hooks/gsd-statusline.js +97 -9
  224. package/hooks/gsd-workflow-guard.js +110 -6
  225. package/hooks/gsd-worktree-path-guard.js +132 -8
  226. package/hooks/lib/cursor-workspace.js +74 -0
  227. package/package.json +10 -8
  228. package/pi/gsd.cjs +34 -3
  229. package/scripts/changeset/lint.cjs +1 -0
  230. package/scripts/changeset/parse.cjs +26 -0
  231. package/scripts/check-coverage-gate.cjs +51 -0
  232. package/scripts/check-glossary-refs.cjs +244 -0
  233. package/scripts/ci-rebase-check.cjs +48 -4
  234. package/scripts/ci-test-scope.cjs +67 -17
  235. package/scripts/gen-adr-index.cjs +528 -0
  236. package/scripts/gen-capability-matrix.cjs +26 -2
  237. package/scripts/gen-capability-registry.cjs +132 -34
  238. package/scripts/gen-emitted-baseline.cjs +145 -0
  239. package/scripts/gen-test-timings.cjs +201 -0
  240. package/scripts/lint-compiled-artifact-sync.cjs +146 -0
  241. package/scripts/lint-emitted-drift-ack.cjs +149 -0
  242. package/scripts/lint-fix-has-regression-test.cjs +131 -0
  243. package/scripts/lint-portable-timeout.cjs +140 -0
  244. package/scripts/lint-resolution-provenance.cjs +9 -0
  245. package/scripts/lint-test-file-count.allowlist.json +1 -0
  246. package/scripts/mutation-matrix.cjs +4 -0
  247. package/scripts/prompt-injection-scan.sh +6 -0
  248. package/scripts/registry-schema.cjs +57 -8
  249. package/scripts/release-notes/conventional-title.cjs +19 -1
  250. package/scripts/release-notes/format-github-release-notes.cjs +7 -3
  251. package/scripts/release-tarball-smoke.cjs +18 -11
  252. package/scripts/run-tests.cjs +420 -58
  253. package/scripts/workflow-size.cjs +16 -8
  254. package/skills/gsd-ai-integration-phase/SKILL.md +1 -1
  255. package/skills/gsd-mempalace-capture/SKILL.md +9 -5
  256. package/skills/gsd-new-milestone/SKILL.md +1 -1
  257. package/skills/gsd-plan-phase/SKILL.md +5 -3
  258. package/skills/gsd-plan-review-convergence/SKILL.md +7 -2
  259. package/vscode/package.json +1 -1
  260. package/scripts/gen-golden-install-parity-zcode.cjs +0 -77
  261. package/scripts/update-size-baseline.cjs +0 -68
@@ -255,8 +255,13 @@ function setCapabilityState(cwd, runtimeConfigDir, desired, opts) {
255
255
  try {
256
256
  // eslint-disable-next-line @typescript-eslint/no-require-imports
257
257
  const runtimeArtifactLayout = require('./runtime-artifact-layout.cjs');
258
+ // #2322: thread the SAME composed registry (loaded above, includeInstalled:true)
259
+ // into layout resolution so the skills kind's stage() closure can bind a
260
+ // third-party capability skill to its declaring capId at staging time —
261
+ // required for BOTH the '*' (full-profile) fill-in and the ownership binding
262
+ // (see resolveRuntimeArtifactLayout's #2322 doc comment).
258
263
  // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
259
- const layout = runtimeArtifactLayout.resolveRuntimeArtifactLayout(runtime, resolvedConfigDir, scope);
264
+ const layout = runtimeArtifactLayout.resolveRuntimeArtifactLayout(runtime, resolvedConfigDir, scope, registry);
260
265
  const commandsGsdDir = _resolveCommandsGsdDir();
261
266
  const manifest = _resolveManifest(commandsGsdDir, resolvedConfigDir);
262
267
  // #1575: applySurface now accepts opts.resolveAttribution so surface-path
@@ -134,7 +134,26 @@ function loadPlanContents(phaseDir) {
134
134
  }
135
135
  }
136
136
  const DESIGNATED_HEADINGS_RE = /^#{1,6}\s+(?:must[_ ]haves?|truths?|tasks?|objective)\b/i;
137
- const XML_DECISION_TAGS_RE = /<(?:objective|tasks?|action)(?:\s[^>]{0,1000})?>((?:(?!<(?:objective|tasks?|action)[\s>])[\s\S])*?)<\/(?:objective|tasks?|action)>/gi;
137
+ // #2372: scanned-tag set must match the planner-canonical surfaces where a D-NN citation
138
+ // is meaningful. `<objective>`/`<tasks>`/`<task>`/`<action>` are the historical core. The
139
+ // planner is also explicitly told (plan-phase.md) to cite decisions in `<read_first>`,
140
+ // `<behavior>`, `<verify>`, `<acceptance_criteria>`, and `<done>` — those are now scanned too,
141
+ // so the gate no longer reports a false coverage gap when a decision is cited in any of them.
142
+ //
143
+ // Implementation: per-tag matching, NOT a single wide alternation. A single alternation
144
+ // like `<(?:a|b|c)>...<\/(?:a|b|c)>` halts the outer tag's body capture at any inner tag
145
+ // in the set, dropping any citation in the outer tag's prefix prose — e.g.
146
+ // `<action>per D-05 <verify>npm test</verify></action>` would lose D-05 because `<verify>`
147
+ // halts the `<action>` body before the citation. Per-tag matching avoids this: each tag's
148
+ // body terminates only at its OWN closing tag, so `<verify>` inside `<action>` is absorbed
149
+ // into `<action>`'s body (D-05 caught) AND `<verify>` is matched separately on its own pass.
150
+ // Each per-tag regex keeps the ReDoS-safe negative-lookahead tempering (#2128).
151
+ const XML_DECISION_TAG_NAMES = ['objective', 'tasks', 'task', 'action', 'read_first', 'behavior', 'verify', 'acceptance_criteria', 'done'];
152
+ function buildXmlDecisionTagRegex(tagName) {
153
+ // Per-tag: body tempering stops only at the SAME tag's reopening or closing — other
154
+ // scanned tags pass through as text into this body. Non-greedy `*?` to first close.
155
+ return new RegExp(`<${tagName}(?:\\s[^>]{0,1000})?>((?:(?!<${tagName}[\\s>])[\\s\\S])*?)<\\/${tagName}>`, 'gi');
156
+ }
138
157
  function stripCommentsAndFences(text) {
139
158
  // HTML-comment stripping stays caller-side (the seam does not strip HTML comments).
140
159
  // Stop-at-next-open body (ReDoS-safe, #2128); an UNCLOSED `<!--` does not match,
@@ -162,9 +181,12 @@ function extractYamlBlock(frontmatter, key) {
162
181
  }
163
182
  function extractXmlTagBodies(text) {
164
183
  const parts = [];
165
- for (const match of text.matchAll(XML_DECISION_TAGS_RE)) {
166
- if (match[1])
167
- parts.push(match[1]);
184
+ for (const tagName of XML_DECISION_TAG_NAMES) {
185
+ const re = buildXmlDecisionTagRegex(tagName);
186
+ for (const match of text.matchAll(re)) {
187
+ if (match[1])
188
+ parts.push(match[1]);
189
+ }
168
190
  }
169
191
  return parts.join('\n');
170
192
  }
@@ -209,7 +231,10 @@ function buildPlanMessage(uncovered) {
209
231
  '',
210
232
  ...uncovered.map((item) => `- **${item.id}** (${item.category || 'uncategorized'}): ${item.text}`),
211
233
  '',
212
- 'Resolve by citing `D-NN:` in a relevant plan\'s `must_haves`/`truths` (or body),',
234
+ 'Resolve by citing `D-NN:` in any of the scanned plan surfaces: front-matter',
235
+ '`must_haves`/`truths`/`objective`, a `## must_haves`/`truths`/`tasks`/`objective`',
236
+ 'heading, or an `<objective>`/`<tasks>`/`<task>`/`<action>`/`<read_first>`/`<behavior>`/`<verify>`/`<acceptance_criteria>`/`<done>`',
237
+ 'tag body. Other locations (prose outside those headings, comments, other XML tags) are not scanned.',
213
238
  'OR move the decision to `### Claude\'s Discretion` / tag it `[informational]` if it should not be tracked.',
214
239
  ].join('\n');
215
240
  }
@@ -235,12 +260,22 @@ function loadDecisionExtraction(contextPath) {
235
260
  }
236
261
  function cmdDecisionCoveragePlan(projectDir, args, raw) {
237
262
  const phaseDir = args[2] ? resolvePath(args[2], projectDir) : '';
238
- const contextPath = args[3] ? resolvePath(args[3], projectDir) : '';
263
+ const contextArg = args[3];
264
+ const contextPath = contextArg ? resolvePath(contextArg, projectDir) : '';
239
265
  if (!gateEnabled(projectDir)) {
240
266
  output({ passed: true, skipped: true, reason: 'workflow.context_coverage_gate is false', total: 0, covered: 0, uncovered: [], message: 'Decision coverage gate disabled by config.' }, raw, undefined);
241
267
  return;
242
268
  }
243
- if (!contextPath || !node_fs_1.default.existsSync(contextPath)) {
269
+ // #2770: an EMPTY/MISSING contextPath argument is a CALLER ERROR (the workflow
270
+ // forgot to pass the path — e.g. a shell variable lost between Bash blocks), not
271
+ // evidence the phase has no CONTEXT.md. Fail closed (mirrors #1365 fail-loud) so a
272
+ // blocking gate cannot silently certify success on a caller mistake.
273
+ if (!contextArg || contextArg === '') {
274
+ output({ passed: false, skipped: false, reason: 'missing context path argument', total: 0, covered: 0, uncovered: [], message: 'Decision coverage gate called without a context path argument — the caller (e.g. the plan-phase workflow) must pass the CONTEXT.md path. An empty argument is a caller error, not evidence there is nothing to check (#2770).' }, raw, undefined);
275
+ return;
276
+ }
277
+ // A REAL path whose file genuinely does not exist is the LEGITIMATE green skip.
278
+ if (!node_fs_1.default.existsSync(contextPath)) {
244
279
  output({ passed: true, skipped: true, reason: 'CONTEXT.md missing', total: 0, covered: 0, uncovered: [], message: 'No CONTEXT.md - nothing to check.' }, raw, undefined);
245
280
  return;
246
281
  }
@@ -645,7 +680,11 @@ function cmdTddReviewCheckpoint(projectDir, args, raw) {
645
680
  const planPath = node_path_1.default.join(phaseDir, file);
646
681
  const content = readIfExists(planPath);
647
682
  // Check frontmatter for type: tdd
648
- const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
683
+ // CRLF-tolerant: a PLAN.md written with Windows line endings (---\r\n...---)
684
+ // must still match. The same CRLF-tolerant form is already used at line 205
685
+ // (extractPlanDesignatedSections); this is the same canonical pattern, applied
686
+ // here for the tdd-classification path. Fixes #2449.
687
+ const frontmatterMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
649
688
  if (frontmatterMatch) {
650
689
  const fm = frontmatterMatch[1];
651
690
  if (/^type:\s*tdd\s*$/m.test(fm)) {
@@ -1010,6 +1049,38 @@ function cmdApiCoverageVerifyPre(projectDir, args, raw) {
1010
1049
  }
1011
1050
  const v = validateCoverageMatrix(matrixText);
1012
1051
  if (v.valid) {
1052
+ if (v.none_declared) {
1053
+ // The declaration is the human override for the detector — it PASSES
1054
+ // even when detection fires (that is acceptance #5's point: the
1055
+ // detector is fallible and the declaration is the reasoned overrule).
1056
+ // But a contradiction must be VISIBLE, not silent: re-run detection
1057
+ // over the phase scope and surface any signals it still finds
1058
+ // (#2365 review S-1).
1059
+ const declScope = readPhaseScope(projectDir, resolvedDir, phaseNumber);
1060
+ const declDetection = detectApiIntegration(declScope.text);
1061
+ const declSignals = declDetection.signals.map((s) => ({ verb: s.verb, noun: s.noun }));
1062
+ // The declaration legitimately wins even over a read error (it is the
1063
+ // human overrule), but if scope was incomplete we say so — the contract
1064
+ // is that contradictions stay visible, not silent (#2365 review).
1065
+ const baseMsg = declDetection.detected
1066
+ ? `api-coverage: COVERAGE.md declares no external API integration, overriding ${declSignals.length} detected signal(s) — confirm the declaration is accurate`
1067
+ : 'api-coverage: COVERAGE.md declares no external API integration — matrix not required';
1068
+ output({
1069
+ block: false,
1070
+ passed: true,
1071
+ coverage_present: true,
1072
+ matrix: coverageFile,
1073
+ counts: v.counts,
1074
+ none_declared: true,
1075
+ detected: declDetection.detected,
1076
+ ...(declDetection.detected ? { signals: declSignals } : {}),
1077
+ ...(declScope.readError ? { scope_read_error: declScope.readError } : {}),
1078
+ message: declScope.readError
1079
+ ? `${baseMsg} (note: phase scope was incompletely read — ${declScope.readError})`
1080
+ : baseMsg,
1081
+ }, raw, undefined);
1082
+ return;
1083
+ }
1013
1084
  output({
1014
1085
  block: false,
1015
1086
  passed: true,
@@ -1044,8 +1115,22 @@ function cmdApiCoverageVerifyPre(projectDir, args, raw) {
1044
1115
  return;
1045
1116
  }
1046
1117
  // (2) no matrix — detect whether this phase integrates an external API.
1047
- const scopeText = readPhaseScope(projectDir, resolvedDir, phaseNumber);
1048
- const detection = detectApiIntegration(scopeText);
1118
+ const scope = readPhaseScope(projectDir, resolvedDir, phaseNumber);
1119
+ if (scope.readError) {
1120
+ // Fail-closed: an unreadable plan could be the one describing the
1121
+ // integration, so we cannot certify "no integration" — block and surface it.
1122
+ output({
1123
+ block: true,
1124
+ passed: false,
1125
+ coverage_present: false,
1126
+ detected: false,
1127
+ message: `api-coverage: could not read the phase scope (${scope.readError}); ` +
1128
+ 'refusing to certify no external-API integration from incomplete scope. ' +
1129
+ 'Fix the unreadable plan file, or add a COVERAGE.md declaration.',
1130
+ }, raw, undefined);
1131
+ return;
1132
+ }
1133
+ const detection = detectApiIntegration(scope.text);
1049
1134
  if (detection.detected) {
1050
1135
  // Surface only verb/noun (typed, bounded) — NOT raw prose snippets — so the
1051
1136
  // gate output cannot relay injected PLAN.md instructions to the orchestrator.
@@ -1070,15 +1155,16 @@ function cmdApiCoverageVerifyPre(projectDir, args, raw) {
1070
1155
  message: 'api-coverage: no external-API integration detected; coverage matrix not required',
1071
1156
  }, raw, undefined);
1072
1157
  }
1073
- /**
1074
- * Read the phase-scope text used for API-integration detection. Uses the
1075
- * resolved plan files (PLAN.md bodies — the planner's own words about what the
1076
- * phase does) and, as a fallback, ONLY THIS PHASE'S ROADMAP section (not the
1077
- * whole roadmap, which would cross-contaminate sibling phases). Strips nothing
1078
- * here — detectApiIntegration strips fenced code itself.
1079
- */
1158
+ /** A filesystem error that is NOT "does not exist" — i.e. a real read failure
1159
+ * (EACCES/EIO/…) the gate must not swallow. `ENOENT` is a legitimate "not
1160
+ * there yet" and is treated as absence, not error. */
1161
+ function isRealReadFailure(err) {
1162
+ const code = err?.code;
1163
+ return err != null && code !== 'ENOENT';
1164
+ }
1080
1165
  function readPhaseScope(projectDir, phaseDir, phaseNumber) {
1081
1166
  const chunks = [];
1167
+ let readError = null;
1082
1168
  try {
1083
1169
  const entries = node_fs_1.default.readdirSync(phaseDir, { withFileTypes: true });
1084
1170
  const plans = entries
@@ -1086,28 +1172,52 @@ function readPhaseScope(projectDir, phaseDir, phaseNumber) {
1086
1172
  .map((e) => e.name)
1087
1173
  .sort();
1088
1174
  for (const p of plans) {
1089
- chunks.push(node_fs_1.default.readFileSync(node_path_1.default.join(phaseDir, p), 'utf8'));
1175
+ try {
1176
+ chunks.push(node_fs_1.default.readFileSync(node_path_1.default.join(phaseDir, p), 'utf8'));
1177
+ }
1178
+ catch (err) {
1179
+ // A plan file that exists but cannot be read — record it and keep
1180
+ // reading the rest so the message names the first failure.
1181
+ if (!readError) {
1182
+ readError = `could not read ${p}: ${err instanceof Error ? err.message : String(err)}`;
1183
+ }
1184
+ }
1090
1185
  }
1091
1186
  }
1092
- catch {
1093
- // ignore — fall through to roadmap
1187
+ catch (err) {
1188
+ // A MISSING phase directory is fine (no plans yet → fall through to the
1189
+ // roadmap). A directory that exists but cannot be enumerated (EACCES/EIO)
1190
+ // is a real read failure the gate must not silently pass (#2365 review).
1191
+ if (isRealReadFailure(err)) {
1192
+ return {
1193
+ text: '',
1194
+ readError: `could not read the phase directory: ${err instanceof Error ? err.message : String(err)}`,
1195
+ };
1196
+ }
1094
1197
  }
1198
+ if (readError)
1199
+ return { text: chunks.join('\n\n'), readError };
1095
1200
  if (chunks.join('').trim().length > 0)
1096
- return chunks.join('\n\n');
1201
+ return { text: chunks.join('\n\n'), readError: null };
1097
1202
  // Fallback: ONLY this phase's ROADMAP section (not the whole file, which
1098
- // would pollute detection with sibling-phase prose). Best-effort; absence or
1099
- // an unresolvable section is non-fatal (detector returns not-detected).
1203
+ // would pollute detection with sibling-phase prose). A MISSING roadmap/section
1204
+ // is non-fatal; a roadmap that exists but cannot be read is a real failure.
1100
1205
  if (phaseNumber) {
1101
1206
  try {
1102
1207
  const section = getRoadmapPhaseWithFallback(projectDir, phaseNumber);
1103
1208
  if (section)
1104
- return section;
1209
+ return { text: section, readError: null };
1105
1210
  }
1106
- catch {
1107
- // ignore
1211
+ catch (err) {
1212
+ if (isRealReadFailure(err)) {
1213
+ return {
1214
+ text: '',
1215
+ readError: `could not read the roadmap fallback: ${err instanceof Error ? err.message : String(err)}`,
1216
+ };
1217
+ }
1108
1218
  }
1109
1219
  }
1110
- return '';
1220
+ return { text: '', readError: null };
1111
1221
  }
1112
1222
  function routeCheckCommand({ args, cwd, raw }) {
1113
1223
  // Normalize dots to hyphens in the subcommand so both forms are accepted.
@@ -1197,4 +1307,7 @@ module.exports = {
1197
1307
  cmdCheckPredicate,
1198
1308
  buildPredicateDeps,
1199
1309
  parsePredicateFlags,
1310
+ // Fail-closed phase-scope reader for the api-coverage gate — exported for
1311
+ // in-process failure-injection tests (#2365 review).
1312
+ readPhaseScope,
1200
1313
  };
@@ -13,6 +13,17 @@
13
13
  // eslint-disable-next-line @typescript-eslint/no-require-imports
14
14
  const commandRoutingHub = require("./command-routing-hub.cjs");
15
15
  const { createHub, ERROR_KINDS } = commandRoutingHub;
16
+ // #2620 (ADR-0174 §6): the Hub defaults to a no-op logger and the live CLI
17
+ // dispatch path never injected the reference DispatchLogger, so GSD_AUDIT and
18
+ // config.audit.enabled were inert. Inject the reference logger ONLY when
19
+ // observability is opt-in enabled; when off, inject nothing so the Hub stays
20
+ // byte-for-byte silent (preserving the default dispatch output contract, incl.
21
+ // --json-errors). Enabling stderr-on-error unconditionally by default is a
22
+ // separate, blast-radius-bearing change (it adds a second stderr line to the
23
+ // --json-errors envelope) — deferred as its own follow-up.
24
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
25
+ const observabilityLogger = require("./observability/logger.cjs");
26
+ const { createDefaultLogger, isAuditEnabled } = observabilityLogger;
16
27
  // Phase 2 (#1646): import ERROR_REASON so the UnknownCommand translation can
17
28
  // pass `sdk_unknown_command` as the second arg to error(), preserving the
18
29
  // JSON-error envelope contract that capability routers' tests assert on.
@@ -73,6 +84,10 @@ function routeHubCommandFamily({ family, args, subcommands, handlers, defaultSub
73
84
  const hub = createHub({
74
85
  cjsRegistry: { [family]: registryHandlers },
75
86
  manifest: { [family]: available },
87
+ // #2620: wire the reference logger onto the live dispatch path (ADR-0174 §6),
88
+ // but only when observability is opt-in enabled — otherwise leave it unset
89
+ // so the Hub falls back to the no-op logger and stays byte-for-byte silent.
90
+ logger: isAuditEnabled() ? createDefaultLogger({ cwd }) : undefined,
76
91
  });
77
92
  const result = hub.dispatch({
78
93
  family,
@@ -14,15 +14,31 @@
14
14
  *
15
15
  * Subcommands:
16
16
  * detect-backend [--runtime <id>] [--agent-sdk-version <ver>] [--no-nested-dispatch]
17
- * Resolves whether the Workflow backend should activate. `--runtime`
18
- * defaults to the GSD_RUNTIME env var (or 'unknown'). Reads the
17
+ * Resolves whether the Workflow backend should activate. Both flags are
18
+ * OPTIONAL (#2590): `--runtime` falls back to the canonical
19
+ * `GSD_RUNTIME > config.runtime > 'claude'` chain, and
20
+ * `--agent-sdk-version` to `GSD_AGENT_SDK_VERSION` then the installed
21
+ * @anthropic-ai/claude-agent-sdk version. Reads the
19
22
  * `claude_orchestration.*` keys from .planning/config.json. Emits
20
23
  * { available, backend, reason }.
21
24
  *
22
- * emit-workflow --waves <path> --run-id <id> [--phase-dir <dir>] [--budget <n>]
25
+ * emit-workflow --waves <path> --run-id <id> [--phase-dir <dir>] [--budget <n>] [--executor-model <id>]
23
26
  * Reads a wave/plan manifest JSON file and emits the generated Workflow
24
27
  * script + summary. The manifest shape matches emitWorkflowScript's input:
25
- * { waves: [{ id, plans: [{ id, brief, files_modified: string[] }] }] }.
28
+ * { waves: [{ id, plans: [{ id, brief, files_modified: string[], use_worktree?: boolean }] }] }.
29
+ * `use_worktree` defaults to true; pass `false` for a plan the inline path
30
+ * (execute-phase.md step 2.5) would also keep out of worktree isolation
31
+ * (submodule-touching plans — #2772 / #2285 finding 1).
32
+ *
33
+ * resolve-wave-dispatch --waves <path> --run-id <id> [--runtime <id>]
34
+ * [--agent-sdk-version <ver>] [--no-nested-dispatch] [--phase-dir <dir>]
35
+ * [--budget <n>] [--executor-model <id>]
36
+ * #2285 — the single composed seam a PRE-wave dispatch-backend selector
37
+ * (`execute:wave:pre`) uses: resolves detect-backend + emit-workflow in
38
+ * ONE call. Emits { backend: 'inline'|'workflow', reason, script?, summary? }.
39
+ * Fail-closed identically to detect-backend/emit-workflow individually —
40
+ * any gate miss, or an emit failure on a malformed --waves manifest,
41
+ * resolves to 'inline' with no script.
26
42
  */
27
43
  var __importDefault = (this && this.__importDefault) || function (mod) {
28
44
  return (mod && mod.__esModule) ? mod : { "default": mod };
@@ -35,31 +51,31 @@ const io = require("./io.cjs");
35
51
  const core = require("./claude-orchestration.cjs");
36
52
  // eslint-disable-next-line @typescript-eslint/no-require-imports
37
53
  const configLoader = require("./config-loader.cjs");
54
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
55
+ const runtimeSlash = require("./runtime-slash.cjs");
56
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- model-resolver.cjs is an export= CommonJS module
57
+ const modelResolver = require("./model-resolver.cjs");
38
58
  const { output } = io;
39
- const { detectWorkflowBackend, emitWorkflowScript } = core;
59
+ const { detectWorkflowBackend, emitWorkflowScript, resolveWaveDispatch } = core;
40
60
  const CAPABLE_HOST = { dispatch: { nested: true, background: true } };
41
61
  function usage(error) {
42
- error('Usage: gsd-tools claude-orchestration <detect-backend|emit-workflow> [...]\n' +
62
+ error('Usage: gsd-tools claude-orchestration <detect-backend|emit-workflow|resolve-wave-dispatch> [...]\n' +
43
63
  ' detect-backend [--runtime <id>] [--agent-sdk-version <ver>] [--no-nested-dispatch]\n' +
44
- ' emit-workflow --waves <path> --run-id <id> [--phase-dir <dir>] [--budget <n>]');
64
+ ' emit-workflow --waves <path> --run-id <id> [--phase-dir <dir>] [--budget <n>] [--executor-model <id>]\n' +
65
+ ' resolve-wave-dispatch --waves <path> --run-id <id> [--runtime <id>] [--agent-sdk-version <ver>] [--no-nested-dispatch] [--phase-dir <dir>] [--budget <n>] [--executor-model <id>]');
45
66
  }
46
67
  function argValue(args, flag) {
47
68
  const i = args.indexOf(flag);
48
69
  return i !== -1 && i + 1 < args.length ? args[i + 1] : undefined;
49
70
  }
50
71
  /**
51
- * Detect whether the Workflow backend should activate for the current/given
52
- * runtime. Reads `claude_orchestration.*` from the project config; runtime and
53
- * SDK version come from flags (the orchestrator already knows these) or env.
72
+ * Resolve the `claude_orchestration.*` config slice from the project config
73
+ * (federated keys are merged by loadConfig as a nested object), flattened into
74
+ * the dotted-key shape `detectWorkflowBackend`/`resolveWaveDispatch` expect. A
75
+ * config read failure degrades to an empty slice — it must not break the core
76
+ * loop. Shared by `detect-backend` and `resolve-wave-dispatch`.
54
77
  */
55
- function cmdDetectBackend(args, cwd, raw) {
56
- const runtimeId = argValue(args, '--runtime') || process.env['GSD_RUNTIME'] || 'unknown';
57
- const agentSdkVersion = argValue(args, '--agent-sdk-version');
58
- const noNested = args.includes('--no-nested-dispatch');
59
- const hostIntegration = noNested ? { dispatch: { nested: false, background: true } } : CAPABLE_HOST;
60
- // Resolve the claude_orchestration.* slice from the project config (federated
61
- // keys are merged by loadConfig as a nested object). A config read failure
62
- // degrades to inline — it must not break the core loop.
78
+ function resolveFlatClaudeOrchestrationConfig(cwd) {
63
79
  let claudeSlice = {};
64
80
  try {
65
81
  const loaded = configLoader.loadConfig(cwd);
@@ -71,18 +87,144 @@ function cmdDetectBackend(args, cwd, raw) {
71
87
  catch {
72
88
  claudeSlice = {};
73
89
  }
74
- // Flatten the nested slice into the dotted-key shape detectWorkflowBackend expects.
75
90
  const flatConfig = {};
76
91
  for (const k of Object.keys(claudeSlice)) {
77
92
  flatConfig['claude_orchestration.' + k] = claudeSlice[k];
78
93
  }
94
+ return flatConfig;
95
+ }
96
+ /**
97
+ * Resolve the installed Agent SDK version (#2590).
98
+ *
99
+ * The `execute:wave:pre` fragment claimed the orchestrator "has no scriptable
100
+ * way to introspect the live Agent SDK version" and told callers to omit the
101
+ * flag — so gate 5 returned `agent_sdk_version_unknown` on every automated run
102
+ * and the Workflow backend never activated, while `capability state` still
103
+ * reported it `active: true`. That claim is true for BASH, but this router runs
104
+ * in Node: the installed package's own package.json is authoritative and
105
+ * requires no flag at all.
106
+ *
107
+ * Resolution is side-effect-free and fails closed to undefined (gate 5 then
108
+ * declines, exactly as before) rather than guessing a version.
109
+ */
110
+ const AGENT_SDK_PKG = node_path_1.default.join('@anthropic-ai', 'claude-agent-sdk', 'package.json');
111
+ function resolveInstalledAgentSdkVersion(cwd) {
112
+ // Walk node_modules up the tree by hand rather than require.resolve: the SDK's
113
+ // `exports` map does not expose './package.json', so require.resolve throws
114
+ // ERR_PACKAGE_PATH_NOT_EXPORTED. Reading the file directly is exports-map
115
+ // independent and cannot execute package code.
116
+ for (const start of [cwd, __dirname]) {
117
+ let dir;
118
+ try {
119
+ dir = node_path_1.default.resolve(start);
120
+ }
121
+ catch {
122
+ continue;
123
+ }
124
+ for (;;) {
125
+ try {
126
+ const pkgPath = node_path_1.default.join(dir, 'node_modules', AGENT_SDK_PKG);
127
+ if (node_fs_1.default.existsSync(pkgPath)) {
128
+ const parsed = JSON.parse(node_fs_1.default.readFileSync(pkgPath, 'utf8'));
129
+ if (typeof parsed.version === 'string' && parsed.version.length > 0)
130
+ return parsed.version;
131
+ }
132
+ }
133
+ catch { /* unreadable/malformed — keep walking */ }
134
+ const parent = node_path_1.default.dirname(dir);
135
+ if (parent === dir)
136
+ break;
137
+ dir = parent;
138
+ }
139
+ }
140
+ return undefined;
141
+ }
142
+ /**
143
+ * Resolve `--runtime`/`--agent-sdk-version`/`--no-nested-dispatch` into the
144
+ * `{ runtimeId, hostIntegration, agentSdkVersion }` triple both `detect-backend`
145
+ * and `resolve-wave-dispatch` pass to the pure detection seam.
146
+ */
147
+ function resolveDetectionArgs(args, cwd) {
148
+ // #2590: the old fallback chain was `--runtime > GSD_RUNTIME > 'unknown'`,
149
+ // diverging from the canonical `GSD_RUNTIME > config.runtime > 'claude'` used
150
+ // by runtime-slash.resolveRuntime — so ANY manual invocation without
151
+ // --runtime reported `runtime_not_claude` on a perfectly ordinary Claude
152
+ // project. Delegate to the canonical resolver instead of re-deriving it.
153
+ const runtimeId = argValue(args, '--runtime') || runtimeSlash.resolveRuntime(cwd || null);
154
+ // Explicit flag wins (lets a caller pin a version); then the environment;
155
+ // then the actually-installed SDK.
156
+ const agentSdkVersion = argValue(args, '--agent-sdk-version')
157
+ || process.env['GSD_AGENT_SDK_VERSION']
158
+ || resolveInstalledAgentSdkVersion(cwd || process.cwd());
159
+ const noNested = args.includes('--no-nested-dispatch');
160
+ const hostIntegration = noNested ? { dispatch: { nested: false, background: true } } : CAPABLE_HOST;
161
+ return { runtimeId, hostIntegration, agentSdkVersion };
162
+ }
163
+ /**
164
+ * #2686 — resolve the `gsd-executor` model this dispatch should carry.
165
+ *
166
+ * Defaults from the project config rather than requiring a flag. The Workflow
167
+ * backend previously emitted no model at all, so `model_overrides` /
168
+ * `model_policy` / `model_profile` were silently inert on that path while the
169
+ * inline path honored them. Reading the same source the inline path reads is
170
+ * what makes the two backends agree by construction: an orchestrator that never
171
+ * learns about a new flag would otherwise silently keep the old bug.
172
+ *
173
+ * `--executor-model` exists only to pin/override. Resolution is side-effect-free
174
+ * and fails closed to `undefined` (emission then omits the key, i.e. exactly the
175
+ * pre-#2686 output) rather than guessing a model.
176
+ */
177
+ function resolveExecutorModel(args, cwd) {
178
+ const pinned = argValue(args, '--executor-model');
179
+ if (pinned !== undefined)
180
+ return pinned;
181
+ try {
182
+ const resolved = modelResolver.resolveModelInternal(cwd, 'gsd-executor');
183
+ return typeof resolved === 'string' ? resolved : undefined;
184
+ }
185
+ catch {
186
+ return undefined;
187
+ }
188
+ }
189
+ /**
190
+ * Read and parse a `--waves <path>` manifest file.
191
+ *
192
+ * #2285 finding 2: a real read/parse failure (`ok:false`) is DISTINCT from a
193
+ * manifest that parsed fine but has no top-level `waves` key (`ok:true, waves:
194
+ * undefined`) — collapsing both into the same sentinel made the missing-key
195
+ * case exit 0 with ZERO output (fail-silent), breaking the "exit 0 => parseable
196
+ * JSON verdict" contract callers rely on. Only the `ok:false` (read/parse threw)
197
+ * case calls `error(...)` and should short-circuit the caller; `ok:true` with a
198
+ * missing/malformed `waves` value must flow through to `emitWorkflowScript`'s
199
+ * own validation (matching how `{"waves": null}` already behaves) so the caller
200
+ * emits an explicit, non-empty verdict instead of silently doing nothing.
201
+ */
202
+ function readWavesManifest(wavesPath, error) {
203
+ try {
204
+ const content = node_fs_1.default.readFileSync(node_path_1.default.resolve(wavesPath), 'utf8');
205
+ const parsed = JSON.parse(content);
206
+ return { ok: true, waves: parsed['waves'] };
207
+ }
208
+ catch (e) {
209
+ error('could not read/parse --waves file "' + wavesPath + '": ' + (e instanceof Error ? e.message : String(e)));
210
+ return { ok: false };
211
+ }
212
+ }
213
+ /**
214
+ * Detect whether the Workflow backend should activate for the current/given
215
+ * runtime. Reads `claude_orchestration.*` from the project config; runtime and
216
+ * SDK version come from flags (the orchestrator already knows these) or env.
217
+ */
218
+ function cmdDetectBackend(args, cwd, raw) {
219
+ const { runtimeId, hostIntegration, agentSdkVersion } = resolveDetectionArgs(args, cwd);
220
+ const flatConfig = resolveFlatClaudeOrchestrationConfig(cwd);
79
221
  const result = detectWorkflowBackend({ runtimeId, hostIntegration, config: flatConfig, agentSdkVersion });
80
222
  output(result, raw);
81
223
  }
82
224
  /**
83
225
  * Emit a Workflow script from a wave/plan manifest file.
84
226
  */
85
- function cmdEmitWorkflow(args, _cwd, raw, error) {
227
+ function cmdEmitWorkflow(args, cwd, raw, error) {
86
228
  const wavesPath = argValue(args, '--waves');
87
229
  const runId = argValue(args, '--run-id');
88
230
  const phaseDir = argValue(args, '--phase-dir') || '.planning/phases/current';
@@ -95,23 +237,17 @@ function cmdEmitWorkflow(args, _cwd, raw, error) {
95
237
  error('emit-workflow requires --run-id <id>');
96
238
  return;
97
239
  }
98
- let waves;
99
- try {
100
- const content = node_fs_1.default.readFileSync(node_path_1.default.resolve(wavesPath), 'utf8');
101
- const parsed = JSON.parse(content);
102
- waves = parsed['waves'];
103
- }
104
- catch (e) {
105
- error('emit-workflow: could not read/parse --waves file "' + wavesPath + '": ' + (e instanceof Error ? e.message : String(e)));
106
- return;
107
- }
240
+ const read = readWavesManifest(wavesPath, (msg) => error('emit-workflow: ' + msg));
241
+ if (!read.ok)
242
+ return; // read/parse failure — error() already surfaced it loudly above
108
243
  const budgetTokens = budgetRaw !== undefined ? parseInt(budgetRaw, 10) : undefined;
109
244
  const budget = (typeof budgetTokens === 'number' && !Number.isNaN(budgetTokens)) ? budgetTokens : undefined;
110
245
  const result = emitWorkflowScript({
111
246
  phaseDir,
112
247
  runId,
113
- waves: waves,
248
+ waves: read.waves,
114
249
  budgetTokens: budget,
250
+ executorModel: resolveExecutorModel(args, cwd),
115
251
  });
116
252
  if (!result.ok) {
117
253
  error('emit-workflow: ' + result.reason);
@@ -119,6 +255,45 @@ function cmdEmitWorkflow(args, _cwd, raw, error) {
119
255
  }
120
256
  output({ script: result.script, summary: result.summary }, raw);
121
257
  }
258
+ /**
259
+ * #2285 — the single composed seam a PRE-wave dispatch-backend selector
260
+ * (`execute:wave:pre`) uses: resolves `detect-backend` + `emit-workflow` in
261
+ * ONE call via `resolveWaveDispatch`. Emits
262
+ * `{ backend: 'inline'|'workflow', reason, script?, summary? }`.
263
+ */
264
+ function cmdResolveWaveDispatch(args, cwd, raw, error) {
265
+ const wavesPath = argValue(args, '--waves');
266
+ const runId = argValue(args, '--run-id');
267
+ const phaseDir = argValue(args, '--phase-dir') || '.planning/phases/current';
268
+ const budgetRaw = argValue(args, '--budget');
269
+ if (!wavesPath) {
270
+ error('resolve-wave-dispatch requires --waves <path>');
271
+ return;
272
+ }
273
+ if (!runId) {
274
+ error('resolve-wave-dispatch requires --run-id <id>');
275
+ return;
276
+ }
277
+ const read = readWavesManifest(wavesPath, (msg) => error('resolve-wave-dispatch: ' + msg));
278
+ if (!read.ok)
279
+ return; // read/parse failure — error() already surfaced it loudly above
280
+ const { runtimeId, hostIntegration, agentSdkVersion } = resolveDetectionArgs(args, cwd);
281
+ const flatConfig = resolveFlatClaudeOrchestrationConfig(cwd);
282
+ const budgetTokens = budgetRaw !== undefined ? parseInt(budgetRaw, 10) : undefined;
283
+ const budget = (typeof budgetTokens === 'number' && !Number.isNaN(budgetTokens)) ? budgetTokens : undefined;
284
+ const result = resolveWaveDispatch({
285
+ runtimeId,
286
+ hostIntegration,
287
+ config: flatConfig,
288
+ agentSdkVersion,
289
+ phaseDir,
290
+ runId,
291
+ waves: read.waves,
292
+ budgetTokens: budget,
293
+ executorModel: resolveExecutorModel(args, cwd),
294
+ });
295
+ output(result, raw);
296
+ }
122
297
  function routeClaudeOrchestrationCommand(opts) {
123
298
  const { args, cwd, raw, error } = opts;
124
299
  // args[0] is the family ('claude-orchestration'); the subcommand is args[1].
@@ -129,6 +304,9 @@ function routeClaudeOrchestrationCommand(opts) {
129
304
  else if (subcommand === 'emit-workflow') {
130
305
  cmdEmitWorkflow(args, cwd, raw, error);
131
306
  }
307
+ else if (subcommand === 'resolve-wave-dispatch') {
308
+ cmdResolveWaveDispatch(args, cwd, raw, error);
309
+ }
132
310
  else {
133
311
  usage(error);
134
312
  }