@opengsd/gsd-core 1.7.0 → 1.8.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 (165) 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 +14 -0
  4. package/README.md +2 -0
  5. package/agents/gsd-debug-session-manager.md +42 -4
  6. package/agents/gsd-debugger.md +87 -29
  7. package/agents/gsd-executor.md +29 -2
  8. package/agents/gsd-planner.md +29 -36
  9. package/agents/gsd-verifier.md +2 -2
  10. package/bin/install.js +1152 -80
  11. package/commands/gsd/ai-integration-phase.md +1 -1
  12. package/commands/gsd/mempalace-capture.md +9 -5
  13. package/commands/gsd/new-milestone.md +1 -1
  14. package/commands/gsd/plan-phase.md +5 -3
  15. package/commands/gsd/plan-review-convergence.md +3 -2
  16. package/gsd-core/bin/gsd-tools.cjs +1878 -2507
  17. package/gsd-core/bin/lib/adapter-imperative.cjs +8 -1
  18. package/gsd-core/bin/lib/agent-command-router.cjs +20 -5
  19. package/gsd-core/bin/lib/api-coverage.cjs +338 -45
  20. package/gsd-core/bin/lib/broken-windows.cjs +716 -0
  21. package/gsd-core/bin/lib/capability-command-router.cjs +733 -0
  22. package/gsd-core/bin/lib/capability-registry.cjs +155 -86
  23. package/gsd-core/bin/lib/capability-writer.cjs +6 -1
  24. package/gsd-core/bin/lib/check-command-router.cjs +128 -25
  25. package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +115 -27
  26. package/gsd-core/bin/lib/claude-orchestration.cjs +84 -9
  27. package/gsd-core/bin/lib/command-aliases.cjs +14 -0
  28. package/gsd-core/bin/lib/commands.cjs +81 -4
  29. package/gsd-core/bin/lib/config-loader.cjs +14 -2
  30. package/gsd-core/bin/lib/config.cjs +69 -18
  31. package/gsd-core/bin/lib/core-utils.cjs +6 -1
  32. package/gsd-core/bin/lib/decisions.cjs +32 -8
  33. package/gsd-core/bin/lib/docs.cjs +6 -0
  34. package/gsd-core/bin/lib/external-descriptor-trust.cjs +14 -2
  35. package/gsd-core/bin/lib/gap-checker.cjs +17 -2
  36. package/gsd-core/bin/lib/init.cjs +111 -47
  37. package/gsd-core/bin/lib/install-engine.cjs +298 -23
  38. package/gsd-core/bin/lib/install-profiles.cjs +239 -1
  39. package/gsd-core/bin/lib/installer-migrations/005-opencode-baseline-commands-dir.cjs +146 -0
  40. package/gsd-core/bin/lib/installer-migrations/006-pi-extension-cjs-to-js.cjs +91 -0
  41. package/gsd-core/bin/lib/installer-migrations.cjs +44 -5
  42. package/gsd-core/bin/lib/markdown-sectionizer.cjs +107 -0
  43. package/gsd-core/bin/lib/milestone.cjs +246 -12
  44. package/gsd-core/bin/lib/model-catalog.cjs +19 -4
  45. package/gsd-core/bin/lib/model-resolver.cjs +189 -7
  46. package/gsd-core/bin/lib/onboard-projection.cjs +11 -8
  47. package/gsd-core/bin/lib/phase-id.cjs +26 -4
  48. package/gsd-core/bin/lib/phase.cjs +201 -12
  49. package/gsd-core/bin/lib/plan-scan.cjs +70 -2
  50. package/gsd-core/bin/lib/roadmap-parser.cjs +7 -4
  51. package/gsd-core/bin/lib/roadmap.cjs +13 -3
  52. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +7 -1
  53. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +22 -8
  54. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +16 -0
  55. package/gsd-core/bin/lib/smart-entry.cjs +69 -4
  56. package/gsd-core/bin/lib/state-document.cjs +7 -4
  57. package/gsd-core/bin/lib/state-transition.cjs +22 -1
  58. package/gsd-core/bin/lib/state.cjs +65 -11
  59. package/gsd-core/bin/lib/surface.cjs +51 -9
  60. package/gsd-core/bin/lib/uat.cjs +420 -5
  61. package/gsd-core/bin/lib/validate.cjs +12 -8
  62. package/gsd-core/bin/lib/verification.cjs +112 -17
  63. package/gsd-core/bin/lib/verify.cjs +220 -22
  64. package/gsd-core/bin/shared/config-schema.manifest.json +3 -2
  65. package/gsd-core/references/api-coverage.md +37 -7
  66. package/gsd-core/references/checkpoints.md +1 -1
  67. package/gsd-core/references/common-bug-patterns.md +13 -0
  68. package/gsd-core/references/debugger-bug-taxonomy.md +111 -0
  69. package/gsd-core/references/debugger-fix-acceptance.md +157 -0
  70. package/gsd-core/references/debugger-philosophy.md +1 -0
  71. package/gsd-core/references/debugger-prevention.md +98 -0
  72. package/gsd-core/references/debugger-rca-branching.md +98 -0
  73. package/gsd-core/references/debugger-repro-hardening.md +130 -0
  74. package/gsd-core/references/debugger-sbfl.md +110 -0
  75. package/gsd-core/references/debugger-semantic-recall.md +81 -0
  76. package/gsd-core/references/execute-phase-quota-recovery.md +55 -0
  77. package/gsd-core/references/execute-phase-requirement-revert.md +8 -0
  78. package/gsd-core/references/execute-phase-response-language.md +7 -0
  79. package/gsd-core/references/planner-antipatterns.md +6 -0
  80. package/gsd-core/references/planner-mvp-mode.md +12 -13
  81. package/gsd-core/references/planner-preconditions.md +156 -0
  82. package/gsd-core/references/planner-reversibility.md +132 -0
  83. package/gsd-core/references/reviewer-instances.md +9 -7
  84. package/gsd-core/references/skeleton-template.md +1 -1
  85. package/gsd-core/references/thinking-models-planning.md +3 -1
  86. package/gsd-core/templates/DEBUG.md +5 -3
  87. package/gsd-core/workflows/add-phase.md +2 -0
  88. package/gsd-core/workflows/add-tests.md +3 -1
  89. package/gsd-core/workflows/add-todo.md +32 -1
  90. package/gsd-core/workflows/ai-integration-phase.md +4 -2
  91. package/gsd-core/workflows/audit-fix.md +2 -2
  92. package/gsd-core/workflows/check-todos.md +3 -1
  93. package/gsd-core/workflows/cleanup.md +7 -1
  94. package/gsd-core/workflows/code-review.md +17 -5
  95. package/gsd-core/workflows/complete-milestone.md +3 -0
  96. package/gsd-core/workflows/debug.md +25 -5
  97. package/gsd-core/workflows/diagnose-issues.md +1 -1
  98. package/gsd-core/workflows/discovery-phase.md +7 -0
  99. package/gsd-core/workflows/discuss-phase/templates/context.md +16 -2
  100. package/gsd-core/workflows/discuss-phase-assumptions.md +3 -0
  101. package/gsd-core/workflows/do.md +7 -1
  102. package/gsd-core/workflows/docs-update.md +1 -0
  103. package/gsd-core/workflows/eval-review.md +3 -0
  104. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +4 -4
  105. package/gsd-core/workflows/execute-phase/steps/regression-gate.md +2 -2
  106. package/gsd-core/workflows/execute-phase.md +25 -34
  107. package/gsd-core/workflows/execute-plan.md +15 -4
  108. package/gsd-core/workflows/graduation.md +3 -0
  109. package/gsd-core/workflows/health.md +7 -1
  110. package/gsd-core/workflows/help/modes/full.md +6 -2
  111. package/gsd-core/workflows/import.md +8 -2
  112. package/gsd-core/workflows/inbox.md +7 -0
  113. package/gsd-core/workflows/ingest-docs.md +15 -10
  114. package/gsd-core/workflows/manager.md +3 -1
  115. package/gsd-core/workflows/map-codebase.md +4 -4
  116. package/gsd-core/workflows/mvp-phase.md +3 -0
  117. package/gsd-core/workflows/new-milestone.md +69 -21
  118. package/gsd-core/workflows/new-project.md +17 -15
  119. package/gsd-core/workflows/new-workspace.md +3 -1
  120. package/gsd-core/workflows/onboard.md +3 -0
  121. package/gsd-core/workflows/plan-phase.md +14 -5
  122. package/gsd-core/workflows/plan-review-convergence.md +48 -3
  123. package/gsd-core/workflows/plant-seed.md +3 -0
  124. package/gsd-core/workflows/profile-user.md +7 -1
  125. package/gsd-core/workflows/progress.md +31 -3
  126. package/gsd-core/workflows/quick.md +19 -7
  127. package/gsd-core/workflows/remove-workspace.md +3 -0
  128. package/gsd-core/workflows/review.md +89 -73
  129. package/gsd-core/workflows/scan.md +1 -1
  130. package/gsd-core/workflows/secure-phase.md +3 -0
  131. package/gsd-core/workflows/settings-integrations.md +3 -0
  132. package/gsd-core/workflows/settings.md +3 -0
  133. package/gsd-core/workflows/ship.md +50 -3
  134. package/gsd-core/workflows/sketch.md +3 -0
  135. package/gsd-core/workflows/smart-entry.md +3 -0
  136. package/gsd-core/workflows/spike.md +7 -1
  137. package/gsd-core/workflows/ui-phase.md +3 -1
  138. package/gsd-core/workflows/ui-review.md +3 -0
  139. package/gsd-core/workflows/undo.md +7 -0
  140. package/gsd-core/workflows/update.md +2 -0
  141. package/gsd-core/workflows/validate-phase.md +3 -0
  142. package/gsd-core/workflows/verify-phase.md +2 -2
  143. package/gsd-core/workflows/verify-work.md +7 -3
  144. package/hooks/dist/gsd-context-monitor.js +27 -9
  145. package/hooks/dist/gsd-statusline.js +88 -3
  146. package/hooks/gsd-context-monitor.js +27 -9
  147. package/hooks/gsd-statusline.js +88 -3
  148. package/package.json +6 -4
  149. package/pi/gsd.cjs +8 -2
  150. package/scripts/changeset/lint.cjs +1 -0
  151. package/scripts/changeset/parse.cjs +26 -0
  152. package/scripts/check-glossary-refs.cjs +220 -0
  153. package/scripts/ci-rebase-check.cjs +48 -4
  154. package/scripts/gen-adr-index.cjs +526 -0
  155. package/scripts/gen-test-timings.cjs +201 -0
  156. package/scripts/lint-portable-timeout.cjs +140 -0
  157. package/scripts/lint-test-file-count.allowlist.json +1 -0
  158. package/scripts/release-tarball-smoke.cjs +18 -11
  159. package/scripts/run-tests.cjs +420 -58
  160. package/skills/gsd-ai-integration-phase/SKILL.md +1 -1
  161. package/skills/gsd-mempalace-capture/SKILL.md +9 -5
  162. package/skills/gsd-new-milestone/SKILL.md +1 -1
  163. package/skills/gsd-plan-phase/SKILL.md +5 -3
  164. package/skills/gsd-plan-review-convergence/SKILL.md +3 -2
  165. package/vscode/package.json +1 -1
@@ -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
  }
@@ -645,7 +670,11 @@ function cmdTddReviewCheckpoint(projectDir, args, raw) {
645
670
  const planPath = node_path_1.default.join(phaseDir, file);
646
671
  const content = readIfExists(planPath);
647
672
  // Check frontmatter for type: tdd
648
- const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
673
+ // CRLF-tolerant: a PLAN.md written with Windows line endings (---\r\n...---)
674
+ // must still match. The same CRLF-tolerant form is already used at line 205
675
+ // (extractPlanDesignatedSections); this is the same canonical pattern, applied
676
+ // here for the tdd-classification path. Fixes #2449.
677
+ const frontmatterMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
649
678
  if (frontmatterMatch) {
650
679
  const fm = frontmatterMatch[1];
651
680
  if (/^type:\s*tdd\s*$/m.test(fm)) {
@@ -1010,6 +1039,38 @@ function cmdApiCoverageVerifyPre(projectDir, args, raw) {
1010
1039
  }
1011
1040
  const v = validateCoverageMatrix(matrixText);
1012
1041
  if (v.valid) {
1042
+ if (v.none_declared) {
1043
+ // The declaration is the human override for the detector — it PASSES
1044
+ // even when detection fires (that is acceptance #5's point: the
1045
+ // detector is fallible and the declaration is the reasoned overrule).
1046
+ // But a contradiction must be VISIBLE, not silent: re-run detection
1047
+ // over the phase scope and surface any signals it still finds
1048
+ // (#2365 review S-1).
1049
+ const declScope = readPhaseScope(projectDir, resolvedDir, phaseNumber);
1050
+ const declDetection = detectApiIntegration(declScope.text);
1051
+ const declSignals = declDetection.signals.map((s) => ({ verb: s.verb, noun: s.noun }));
1052
+ // The declaration legitimately wins even over a read error (it is the
1053
+ // human overrule), but if scope was incomplete we say so — the contract
1054
+ // is that contradictions stay visible, not silent (#2365 review).
1055
+ const baseMsg = declDetection.detected
1056
+ ? `api-coverage: COVERAGE.md declares no external API integration, overriding ${declSignals.length} detected signal(s) — confirm the declaration is accurate`
1057
+ : 'api-coverage: COVERAGE.md declares no external API integration — matrix not required';
1058
+ output({
1059
+ block: false,
1060
+ passed: true,
1061
+ coverage_present: true,
1062
+ matrix: coverageFile,
1063
+ counts: v.counts,
1064
+ none_declared: true,
1065
+ detected: declDetection.detected,
1066
+ ...(declDetection.detected ? { signals: declSignals } : {}),
1067
+ ...(declScope.readError ? { scope_read_error: declScope.readError } : {}),
1068
+ message: declScope.readError
1069
+ ? `${baseMsg} (note: phase scope was incompletely read — ${declScope.readError})`
1070
+ : baseMsg,
1071
+ }, raw, undefined);
1072
+ return;
1073
+ }
1013
1074
  output({
1014
1075
  block: false,
1015
1076
  passed: true,
@@ -1044,8 +1105,22 @@ function cmdApiCoverageVerifyPre(projectDir, args, raw) {
1044
1105
  return;
1045
1106
  }
1046
1107
  // (2) no matrix — detect whether this phase integrates an external API.
1047
- const scopeText = readPhaseScope(projectDir, resolvedDir, phaseNumber);
1048
- const detection = detectApiIntegration(scopeText);
1108
+ const scope = readPhaseScope(projectDir, resolvedDir, phaseNumber);
1109
+ if (scope.readError) {
1110
+ // Fail-closed: an unreadable plan could be the one describing the
1111
+ // integration, so we cannot certify "no integration" — block and surface it.
1112
+ output({
1113
+ block: true,
1114
+ passed: false,
1115
+ coverage_present: false,
1116
+ detected: false,
1117
+ message: `api-coverage: could not read the phase scope (${scope.readError}); ` +
1118
+ 'refusing to certify no external-API integration from incomplete scope. ' +
1119
+ 'Fix the unreadable plan file, or add a COVERAGE.md declaration.',
1120
+ }, raw, undefined);
1121
+ return;
1122
+ }
1123
+ const detection = detectApiIntegration(scope.text);
1049
1124
  if (detection.detected) {
1050
1125
  // Surface only verb/noun (typed, bounded) — NOT raw prose snippets — so the
1051
1126
  // gate output cannot relay injected PLAN.md instructions to the orchestrator.
@@ -1070,15 +1145,16 @@ function cmdApiCoverageVerifyPre(projectDir, args, raw) {
1070
1145
  message: 'api-coverage: no external-API integration detected; coverage matrix not required',
1071
1146
  }, raw, undefined);
1072
1147
  }
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
- */
1148
+ /** A filesystem error that is NOT "does not exist" — i.e. a real read failure
1149
+ * (EACCES/EIO/…) the gate must not swallow. `ENOENT` is a legitimate "not
1150
+ * there yet" and is treated as absence, not error. */
1151
+ function isRealReadFailure(err) {
1152
+ const code = err?.code;
1153
+ return err != null && code !== 'ENOENT';
1154
+ }
1080
1155
  function readPhaseScope(projectDir, phaseDir, phaseNumber) {
1081
1156
  const chunks = [];
1157
+ let readError = null;
1082
1158
  try {
1083
1159
  const entries = node_fs_1.default.readdirSync(phaseDir, { withFileTypes: true });
1084
1160
  const plans = entries
@@ -1086,28 +1162,52 @@ function readPhaseScope(projectDir, phaseDir, phaseNumber) {
1086
1162
  .map((e) => e.name)
1087
1163
  .sort();
1088
1164
  for (const p of plans) {
1089
- chunks.push(node_fs_1.default.readFileSync(node_path_1.default.join(phaseDir, p), 'utf8'));
1165
+ try {
1166
+ chunks.push(node_fs_1.default.readFileSync(node_path_1.default.join(phaseDir, p), 'utf8'));
1167
+ }
1168
+ catch (err) {
1169
+ // A plan file that exists but cannot be read — record it and keep
1170
+ // reading the rest so the message names the first failure.
1171
+ if (!readError) {
1172
+ readError = `could not read ${p}: ${err instanceof Error ? err.message : String(err)}`;
1173
+ }
1174
+ }
1090
1175
  }
1091
1176
  }
1092
- catch {
1093
- // ignore — fall through to roadmap
1177
+ catch (err) {
1178
+ // A MISSING phase directory is fine (no plans yet → fall through to the
1179
+ // roadmap). A directory that exists but cannot be enumerated (EACCES/EIO)
1180
+ // is a real read failure the gate must not silently pass (#2365 review).
1181
+ if (isRealReadFailure(err)) {
1182
+ return {
1183
+ text: '',
1184
+ readError: `could not read the phase directory: ${err instanceof Error ? err.message : String(err)}`,
1185
+ };
1186
+ }
1094
1187
  }
1188
+ if (readError)
1189
+ return { text: chunks.join('\n\n'), readError };
1095
1190
  if (chunks.join('').trim().length > 0)
1096
- return chunks.join('\n\n');
1191
+ return { text: chunks.join('\n\n'), readError: null };
1097
1192
  // 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).
1193
+ // would pollute detection with sibling-phase prose). A MISSING roadmap/section
1194
+ // is non-fatal; a roadmap that exists but cannot be read is a real failure.
1100
1195
  if (phaseNumber) {
1101
1196
  try {
1102
1197
  const section = getRoadmapPhaseWithFallback(projectDir, phaseNumber);
1103
1198
  if (section)
1104
- return section;
1199
+ return { text: section, readError: null };
1105
1200
  }
1106
- catch {
1107
- // ignore
1201
+ catch (err) {
1202
+ if (isRealReadFailure(err)) {
1203
+ return {
1204
+ text: '',
1205
+ readError: `could not read the roadmap fallback: ${err instanceof Error ? err.message : String(err)}`,
1206
+ };
1207
+ }
1108
1208
  }
1109
1209
  }
1110
- return '';
1210
+ return { text: '', readError: null };
1111
1211
  }
1112
1212
  function routeCheckCommand({ args, cwd, raw }) {
1113
1213
  // Normalize dots to hyphens in the subcommand so both forms are accepted.
@@ -1197,4 +1297,7 @@ module.exports = {
1197
1297
  cmdCheckPredicate,
1198
1298
  buildPredicateDeps,
1199
1299
  parsePredicateFlags,
1300
+ // Fail-closed phase-scope reader for the api-coverage gate — exported for
1301
+ // in-process failure-injection tests (#2365 review).
1302
+ readPhaseScope,
1200
1303
  };
@@ -22,7 +22,20 @@
22
22
  * emit-workflow --waves <path> --run-id <id> [--phase-dir <dir>] [--budget <n>]
23
23
  * Reads a wave/plan manifest JSON file and emits the generated Workflow
24
24
  * script + summary. The manifest shape matches emitWorkflowScript's input:
25
- * { waves: [{ id, plans: [{ id, brief, files_modified: string[] }] }] }.
25
+ * { waves: [{ id, plans: [{ id, brief, files_modified: string[], use_worktree?: boolean }] }] }.
26
+ * `use_worktree` defaults to true; pass `false` for a plan the inline path
27
+ * (execute-phase.md step 2.5) would also keep out of worktree isolation
28
+ * (submodule-touching plans — #2772 / #2285 finding 1).
29
+ *
30
+ * resolve-wave-dispatch --waves <path> --run-id <id> [--runtime <id>]
31
+ * [--agent-sdk-version <ver>] [--no-nested-dispatch] [--phase-dir <dir>]
32
+ * [--budget <n>]
33
+ * #2285 — the single composed seam a PRE-wave dispatch-backend selector
34
+ * (`execute:wave:pre`) uses: resolves detect-backend + emit-workflow in
35
+ * ONE call. Emits { backend: 'inline'|'workflow', reason, script?, summary? }.
36
+ * Fail-closed identically to detect-backend/emit-workflow individually —
37
+ * any gate miss, or an emit failure on a malformed --waves manifest,
38
+ * resolves to 'inline' with no script.
26
39
  */
27
40
  var __importDefault = (this && this.__importDefault) || function (mod) {
28
41
  return (mod && mod.__esModule) ? mod : { "default": mod };
@@ -36,30 +49,26 @@ const core = require("./claude-orchestration.cjs");
36
49
  // eslint-disable-next-line @typescript-eslint/no-require-imports
37
50
  const configLoader = require("./config-loader.cjs");
38
51
  const { output } = io;
39
- const { detectWorkflowBackend, emitWorkflowScript } = core;
52
+ const { detectWorkflowBackend, emitWorkflowScript, resolveWaveDispatch } = core;
40
53
  const CAPABLE_HOST = { dispatch: { nested: true, background: true } };
41
54
  function usage(error) {
42
- error('Usage: gsd-tools claude-orchestration <detect-backend|emit-workflow> [...]\n' +
55
+ error('Usage: gsd-tools claude-orchestration <detect-backend|emit-workflow|resolve-wave-dispatch> [...]\n' +
43
56
  ' 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>]');
57
+ ' emit-workflow --waves <path> --run-id <id> [--phase-dir <dir>] [--budget <n>]\n' +
58
+ ' resolve-wave-dispatch --waves <path> --run-id <id> [--runtime <id>] [--agent-sdk-version <ver>] [--no-nested-dispatch] [--phase-dir <dir>] [--budget <n>]');
45
59
  }
46
60
  function argValue(args, flag) {
47
61
  const i = args.indexOf(flag);
48
62
  return i !== -1 && i + 1 < args.length ? args[i + 1] : undefined;
49
63
  }
50
64
  /**
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.
65
+ * Resolve the `claude_orchestration.*` config slice from the project config
66
+ * (federated keys are merged by loadConfig as a nested object), flattened into
67
+ * the dotted-key shape `detectWorkflowBackend`/`resolveWaveDispatch` expect. A
68
+ * config read failure degrades to an empty slice — it must not break the core
69
+ * loop. Shared by `detect-backend` and `resolve-wave-dispatch`.
54
70
  */
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.
71
+ function resolveFlatClaudeOrchestrationConfig(cwd) {
63
72
  let claudeSlice = {};
64
73
  try {
65
74
  const loaded = configLoader.loadConfig(cwd);
@@ -71,11 +80,56 @@ function cmdDetectBackend(args, cwd, raw) {
71
80
  catch {
72
81
  claudeSlice = {};
73
82
  }
74
- // Flatten the nested slice into the dotted-key shape detectWorkflowBackend expects.
75
83
  const flatConfig = {};
76
84
  for (const k of Object.keys(claudeSlice)) {
77
85
  flatConfig['claude_orchestration.' + k] = claudeSlice[k];
78
86
  }
87
+ return flatConfig;
88
+ }
89
+ /**
90
+ * Resolve `--runtime`/`--agent-sdk-version`/`--no-nested-dispatch` into the
91
+ * `{ runtimeId, hostIntegration, agentSdkVersion }` triple both `detect-backend`
92
+ * and `resolve-wave-dispatch` pass to the pure detection seam.
93
+ */
94
+ function resolveDetectionArgs(args) {
95
+ const runtimeId = argValue(args, '--runtime') || process.env['GSD_RUNTIME'] || 'unknown';
96
+ const agentSdkVersion = argValue(args, '--agent-sdk-version');
97
+ const noNested = args.includes('--no-nested-dispatch');
98
+ const hostIntegration = noNested ? { dispatch: { nested: false, background: true } } : CAPABLE_HOST;
99
+ return { runtimeId, hostIntegration, agentSdkVersion };
100
+ }
101
+ /**
102
+ * Read and parse a `--waves <path>` manifest file.
103
+ *
104
+ * #2285 finding 2: a real read/parse failure (`ok:false`) is DISTINCT from a
105
+ * manifest that parsed fine but has no top-level `waves` key (`ok:true, waves:
106
+ * undefined`) — collapsing both into the same sentinel made the missing-key
107
+ * case exit 0 with ZERO output (fail-silent), breaking the "exit 0 => parseable
108
+ * JSON verdict" contract callers rely on. Only the `ok:false` (read/parse threw)
109
+ * case calls `error(...)` and should short-circuit the caller; `ok:true` with a
110
+ * missing/malformed `waves` value must flow through to `emitWorkflowScript`'s
111
+ * own validation (matching how `{"waves": null}` already behaves) so the caller
112
+ * emits an explicit, non-empty verdict instead of silently doing nothing.
113
+ */
114
+ function readWavesManifest(wavesPath, error) {
115
+ try {
116
+ const content = node_fs_1.default.readFileSync(node_path_1.default.resolve(wavesPath), 'utf8');
117
+ const parsed = JSON.parse(content);
118
+ return { ok: true, waves: parsed['waves'] };
119
+ }
120
+ catch (e) {
121
+ error('could not read/parse --waves file "' + wavesPath + '": ' + (e instanceof Error ? e.message : String(e)));
122
+ return { ok: false };
123
+ }
124
+ }
125
+ /**
126
+ * Detect whether the Workflow backend should activate for the current/given
127
+ * runtime. Reads `claude_orchestration.*` from the project config; runtime and
128
+ * SDK version come from flags (the orchestrator already knows these) or env.
129
+ */
130
+ function cmdDetectBackend(args, cwd, raw) {
131
+ const { runtimeId, hostIntegration, agentSdkVersion } = resolveDetectionArgs(args);
132
+ const flatConfig = resolveFlatClaudeOrchestrationConfig(cwd);
79
133
  const result = detectWorkflowBackend({ runtimeId, hostIntegration, config: flatConfig, agentSdkVersion });
80
134
  output(result, raw);
81
135
  }
@@ -95,22 +149,15 @@ function cmdEmitWorkflow(args, _cwd, raw, error) {
95
149
  error('emit-workflow requires --run-id <id>');
96
150
  return;
97
151
  }
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
- }
152
+ const read = readWavesManifest(wavesPath, (msg) => error('emit-workflow: ' + msg));
153
+ if (!read.ok)
154
+ return; // read/parse failure — error() already surfaced it loudly above
108
155
  const budgetTokens = budgetRaw !== undefined ? parseInt(budgetRaw, 10) : undefined;
109
156
  const budget = (typeof budgetTokens === 'number' && !Number.isNaN(budgetTokens)) ? budgetTokens : undefined;
110
157
  const result = emitWorkflowScript({
111
158
  phaseDir,
112
159
  runId,
113
- waves: waves,
160
+ waves: read.waves,
114
161
  budgetTokens: budget,
115
162
  });
116
163
  if (!result.ok) {
@@ -119,6 +166,44 @@ function cmdEmitWorkflow(args, _cwd, raw, error) {
119
166
  }
120
167
  output({ script: result.script, summary: result.summary }, raw);
121
168
  }
169
+ /**
170
+ * #2285 — the single composed seam a PRE-wave dispatch-backend selector
171
+ * (`execute:wave:pre`) uses: resolves `detect-backend` + `emit-workflow` in
172
+ * ONE call via `resolveWaveDispatch`. Emits
173
+ * `{ backend: 'inline'|'workflow', reason, script?, summary? }`.
174
+ */
175
+ function cmdResolveWaveDispatch(args, cwd, raw, error) {
176
+ const wavesPath = argValue(args, '--waves');
177
+ const runId = argValue(args, '--run-id');
178
+ const phaseDir = argValue(args, '--phase-dir') || '.planning/phases/current';
179
+ const budgetRaw = argValue(args, '--budget');
180
+ if (!wavesPath) {
181
+ error('resolve-wave-dispatch requires --waves <path>');
182
+ return;
183
+ }
184
+ if (!runId) {
185
+ error('resolve-wave-dispatch requires --run-id <id>');
186
+ return;
187
+ }
188
+ const read = readWavesManifest(wavesPath, (msg) => error('resolve-wave-dispatch: ' + msg));
189
+ if (!read.ok)
190
+ return; // read/parse failure — error() already surfaced it loudly above
191
+ const { runtimeId, hostIntegration, agentSdkVersion } = resolveDetectionArgs(args);
192
+ const flatConfig = resolveFlatClaudeOrchestrationConfig(cwd);
193
+ const budgetTokens = budgetRaw !== undefined ? parseInt(budgetRaw, 10) : undefined;
194
+ const budget = (typeof budgetTokens === 'number' && !Number.isNaN(budgetTokens)) ? budgetTokens : undefined;
195
+ const result = resolveWaveDispatch({
196
+ runtimeId,
197
+ hostIntegration,
198
+ config: flatConfig,
199
+ agentSdkVersion,
200
+ phaseDir,
201
+ runId,
202
+ waves: read.waves,
203
+ budgetTokens: budget,
204
+ });
205
+ output(result, raw);
206
+ }
122
207
  function routeClaudeOrchestrationCommand(opts) {
123
208
  const { args, cwd, raw, error } = opts;
124
209
  // args[0] is the family ('claude-orchestration'); the subcommand is args[1].
@@ -129,6 +214,9 @@ function routeClaudeOrchestrationCommand(opts) {
129
214
  else if (subcommand === 'emit-workflow') {
130
215
  cmdEmitWorkflow(args, cwd, raw, error);
131
216
  }
217
+ else if (subcommand === 'resolve-wave-dispatch') {
218
+ cmdResolveWaveDispatch(args, cwd, raw, error);
219
+ }
132
220
  else {
133
221
  usage(error);
134
222
  }
@@ -16,15 +16,20 @@
16
16
  * → { ok:true, script, summary } | { ok:false, reason }
17
17
  * Maps GSD's wave/plan model 1:1 onto Workflow primitives:
18
18
  * wave → sequential `parallel()` stage barriers,
19
- * plan → `agent(brief, { agentType:'gsd-executor', isolation:'worktree' })`,
19
+ * plan → `agent(brief, { agentType:'gsd-executor', isolation:'worktree' })`
20
+ * — UNLESS the plan's `use_worktree` is explicitly `false`, in which case
21
+ * `isolation` is omitted entirely for that plan (#2772 / #2285 finding 1:
22
+ * a submodule-touching plan must never be forced into worktree isolation
23
+ * the inline path (execute-phase.md step 2.5) would keep it out of),
20
24
  * files_modified overlap → forces plans into separate sequential stages
21
25
  * (the same overlap rule execute-phase already applies inline),
22
26
  * resumeFromRunId → wired to the phase run id,
23
27
  * budgetTokens → a shared token pool.
24
- * The emitted script composes the SAME gsd-executor agent and worktree
25
- * isolation the inline path uses, so it produces the same artifacts/commits
26
- * (criterion 2). It is a generated string consumed by the orchestrator; this
27
- * module never invokes the Workflow tool itself.
28
+ * The emitted script composes the SAME gsd-executor agent the inline path
29
+ * uses, with per-plan worktree isolation mirroring the inline path's own
30
+ * per-plan decision, so it produces the same artifacts/commits (criterion 2).
31
+ * It is a generated string consumed by the orchestrator; this module never
32
+ * invokes the Workflow tool itself.
28
33
  *
29
34
  * Design laws:
30
35
  * - Gall's Law: ship a small working slice that composes existing primitives
@@ -259,6 +264,17 @@ function partitionStages(plans) {
259
264
  function quoteString(s) {
260
265
  return JSON.stringify(s);
261
266
  }
267
+ /**
268
+ * Render the `agent()` options object for a single plan — `isolation: "worktree"`
269
+ * ONLY when the plan's `use_worktree` is not explicitly `false` (#2772 / #2285
270
+ * finding 1). This is the single place that decides worktree isolation for the
271
+ * Workflow backend; it must never diverge from the inline path's per-plan gate.
272
+ */
273
+ function agentOptions(p) {
274
+ return p.use_worktree === false
275
+ ? '{ agentType: "gsd-executor" }'
276
+ : '{ agentType: "gsd-executor", isolation: "worktree" }';
277
+ }
262
278
  /**
263
279
  * True if `s` is a safe identifier/path token to interpolate into the generated
264
280
  * script WITHOUT requiring a string-literal context — i.e. it contains no
@@ -318,6 +334,9 @@ function emitWorkflowScript(input) {
318
334
  if (!isScriptableIdentifier(p.id)) {
319
335
  return { ok: false, reason: 'waves[' + i + '].plans[' + j + '].id must not contain newlines/quotes/backslash/control chars' };
320
336
  }
337
+ if (p.use_worktree !== undefined && typeof p.use_worktree !== 'boolean') {
338
+ return { ok: false, reason: 'waves[' + i + '].plans[' + j + '].use_worktree must be a boolean if present' };
339
+ }
321
340
  if (seenIds.has(p.id)) {
322
341
  return { ok: false, reason: 'waves[' + i + '] has duplicate plan id "' + p.id + '"' };
323
342
  }
@@ -336,8 +355,9 @@ function emitWorkflowScript(input) {
336
355
  lines.push('// GSD Workflow script — generated by the claude-orchestration capability (#1143)');
337
356
  lines.push('// phase: ' + phaseDir);
338
357
  lines.push('// BETA: preview-grade; on any failure the orchestrator falls back to inline dispatch.');
339
- lines.push('// Composes the SAME gsd-executor agent + worktree isolation as the inline path,');
340
- lines.push('// so artifacts (SUMMARY.md) and commits are produced identically.');
358
+ lines.push('// Composes the SAME gsd-executor agent as the inline path, so artifacts (SUMMARY.md)');
359
+ lines.push('// and commits are produced identically. Worktree isolation is per-plan (use_worktree)');
360
+ lines.push('// and mirrors execute-phase.md step 2.5\'s submodule gate exactly (#2772 / #2285).');
341
361
  lines.push('resumeFromRunId(' + quoteString(runId) + ')');
342
362
  if (budgetTokens !== null) {
343
363
  lines.push('budget(' + budgetTokens + ')');
@@ -361,13 +381,13 @@ function emitWorkflowScript(input) {
361
381
  if (stagePlans.length === 1) {
362
382
  const p = stagePlans[0];
363
383
  lines.push('parallel(');
364
- lines.push(' agent(' + quoteString(p.brief) + ', { agentType: "gsd-executor", isolation: "worktree" })');
384
+ lines.push(' agent(' + quoteString(p.brief) + ', ' + agentOptions(p) + ')');
365
385
  lines.push(')');
366
386
  }
367
387
  else {
368
388
  lines.push('parallel(');
369
389
  for (const p of stagePlans) {
370
- lines.push(' agent(' + quoteString(p.brief) + ', { agentType: "gsd-executor", isolation: "worktree" }),');
390
+ lines.push(' agent(' + quoteString(p.brief) + ', ' + agentOptions(p) + '),');
371
391
  }
372
392
  // Replace trailing comma on the last agent line with nothing.
373
393
  const lastIdx = lines.length - 1;
@@ -393,9 +413,64 @@ function emitWorkflowScript(input) {
393
413
  },
394
414
  };
395
415
  }
416
+ /**
417
+ * #2285 — single composed decision seam for a PRE-wave dispatch-backend selector
418
+ * (e.g. the `execute:wave:pre` claude-orchestration contribution). Composes
419
+ * `detectWorkflowBackend` (gate ladder) with `emitWorkflowScript` (wave→plan
420
+ * mapping) into ONE call so the orchestrator (and its CLI wrapper,
421
+ * `claude-orchestration resolve-wave-dispatch`) never has to re-implement the
422
+ * two-step "detect, then maybe emit" sequencing.
423
+ *
424
+ * Fail-closed at every layer, matching the two composed functions:
425
+ * - `detectWorkflowBackend` resolving anything other than `'workflow'` →
426
+ * `inline` immediately; `emitWorkflowScript` is never invoked (no wasted
427
+ * work, no risk of a bad emit masking a correct inline fallback).
428
+ * - `detectWorkflowBackend` resolves `'workflow'` but `emitWorkflowScript`
429
+ * fails (`ok:false` — e.g. a malformed wave manifest) → `inline`, carrying
430
+ * the emit failure reason so the caller can surface it. Never a partial or
431
+ * broken script.
432
+ *
433
+ * This is the designated non-CLI-router, non-test caller of
434
+ * `detectWorkflowBackend` and `emitWorkflowScript` — the standalone CLI
435
+ * subcommands (`detect-backend`, `emit-workflow`) remain for inspection/
436
+ * debugging, but the orchestrator's real per-wave dispatch decision goes
437
+ * through this seam.
438
+ *
439
+ * Never throws on bad input.
440
+ */
441
+ function resolveWaveDispatch(input) {
442
+ if (input === null || input === undefined || typeof input !== 'object') {
443
+ return { backend: 'inline', reason: 'invalid_input' };
444
+ }
445
+ const detected = detectWorkflowBackend({
446
+ runtimeId: input.runtimeId,
447
+ hostIntegration: input.hostIntegration,
448
+ config: input.config,
449
+ agentSdkVersion: input.agentSdkVersion,
450
+ });
451
+ if (detected.backend !== 'workflow') {
452
+ return { backend: 'inline', reason: detected.reason };
453
+ }
454
+ const emitted = emitWorkflowScript({
455
+ phaseDir: input.phaseDir,
456
+ waves: input.waves,
457
+ runId: input.runId,
458
+ budgetTokens: input.budgetTokens,
459
+ });
460
+ if (!emitted.ok) {
461
+ return { backend: 'inline', reason: 'emit_failed: ' + emitted.reason };
462
+ }
463
+ return {
464
+ backend: 'workflow',
465
+ reason: detected.reason,
466
+ script: emitted.script,
467
+ summary: emitted.summary,
468
+ };
469
+ }
396
470
  module.exports = {
397
471
  detectWorkflowBackend,
398
472
  emitWorkflowScript,
473
+ resolveWaveDispatch,
399
474
  compareSemver,
400
475
  isValidSemver,
401
476
  WORKFLOW_TOOL_FLOOR_VERSION,
@@ -728,6 +728,20 @@ exports.NON_FAMILY_COMMAND_ALIASES = [
728
728
  ],
729
729
  "mutation": true
730
730
  },
731
+ {
732
+ "canonical": "requirements.ready-ids",
733
+ "aliases": [
734
+ "requirements ready-ids"
735
+ ],
736
+ "mutation": false
737
+ },
738
+ {
739
+ "canonical": "requirements.revert-phase",
740
+ "aliases": [
741
+ "requirements revert-phase"
742
+ ],
743
+ "mutation": true
744
+ },
731
745
  {
732
746
  "canonical": "stats.json",
733
747
  "aliases": [