@opengsd/gsd-core 1.9.0 → 1.10.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 (223) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +2 -3
  3. package/.opencode/plugins/gsd-core.js +8 -1
  4. package/agents/gsd-code-fixer.md +131 -34
  5. package/agents/gsd-debugger.md +12 -246
  6. package/agents/gsd-executor.md +7 -5
  7. package/agents/gsd-integration-checker.md +3 -0
  8. package/agents/gsd-plan-checker.md +9 -0
  9. package/agents/gsd-planner.md +5 -8
  10. package/agents/gsd-roadmapper.md +21 -3
  11. package/agents/gsd-verifier.md +14 -70
  12. package/bin/install.js +503 -341
  13. package/commands/gsd/mempalace-capture.md +1 -1
  14. package/commands/gsd/new-milestone.md +1 -1
  15. package/commands/gsd/plan-phase.md +1 -1
  16. package/gsd-core/bin/gsd-tools.cjs +607 -63
  17. package/gsd-core/bin/lib/active-workstream-store.cjs +25 -0
  18. package/gsd-core/bin/lib/agent-install-check.cjs +38 -6
  19. package/gsd-core/bin/lib/api-coverage.cjs +120 -0
  20. package/gsd-core/bin/lib/audit.cjs +89 -1
  21. package/gsd-core/bin/lib/broken-windows.cjs +36 -6
  22. package/gsd-core/bin/lib/capability-registry.cjs +96 -110
  23. package/gsd-core/bin/lib/capability-validator.cjs +12 -2
  24. package/gsd-core/bin/lib/check-command-router.cjs +43 -1
  25. package/gsd-core/bin/lib/command-aliases.cjs +72 -0
  26. package/gsd-core/bin/lib/commands.cjs +26 -25
  27. package/gsd-core/bin/lib/commonjs-marker.cjs +136 -0
  28. package/gsd-core/bin/lib/config-loader.cjs +1 -0
  29. package/gsd-core/bin/lib/config.cjs +12 -1
  30. package/gsd-core/bin/lib/context-composer.cjs +278 -0
  31. package/gsd-core/bin/lib/context-predicates.cjs +506 -0
  32. package/gsd-core/bin/lib/core-utils.cjs +91 -12
  33. package/gsd-core/bin/lib/docs.cjs +3 -2
  34. package/gsd-core/bin/lib/external-job.cjs +19 -4
  35. package/gsd-core/bin/lib/frontmatter.cjs +84 -12
  36. package/gsd-core/bin/lib/gate-predicate-evaluator.cjs +57 -6
  37. package/gsd-core/bin/lib/git-base-branch.cjs +58 -15
  38. package/gsd-core/bin/lib/graphify.cjs +142 -27
  39. package/gsd-core/bin/lib/gsd2-import.cjs +27 -4
  40. package/gsd-core/bin/lib/host-integration.cjs +13 -1
  41. package/gsd-core/bin/lib/init-command-router.cjs +83 -8
  42. package/gsd-core/bin/lib/init.cjs +1021 -57
  43. package/gsd-core/bin/lib/install-engine.cjs +64 -10
  44. package/gsd-core/bin/lib/install-profiles.cjs +27 -1
  45. package/gsd-core/bin/lib/installer-migration-authoring.cjs +3 -1
  46. package/gsd-core/bin/lib/installer-migration-report.cjs +4 -0
  47. package/gsd-core/bin/lib/installer-migrations/007-retire-config-root-commonjs-marker.cjs +149 -0
  48. package/gsd-core/bin/lib/installer-migrations/008-cursor-retire-commands-surface.cjs +55 -0
  49. package/gsd-core/bin/lib/installer-migrations/009-pi-retire-reserved-hooks-dir.cjs +199 -0
  50. package/gsd-core/bin/lib/installer-migrations.cjs +87 -1
  51. package/gsd-core/bin/lib/io.cjs +28 -3
  52. package/gsd-core/bin/lib/markdown-sectionizer.cjs +6 -0
  53. package/gsd-core/bin/lib/mcp-catalog.cjs +518 -0
  54. package/gsd-core/bin/lib/mcp-server.cjs +135 -3
  55. package/gsd-core/bin/lib/milestone.cjs +106 -51
  56. package/gsd-core/bin/lib/phase-id.cjs +63 -0
  57. package/gsd-core/bin/lib/phase-locator.cjs +138 -45
  58. package/gsd-core/bin/lib/phase.cjs +260 -25
  59. package/gsd-core/bin/lib/plan-dependency-graph.cjs +232 -0
  60. package/gsd-core/bin/lib/planning-workspace.cjs +4 -0
  61. package/gsd-core/bin/lib/project-root.cjs +48 -0
  62. package/gsd-core/bin/lib/prompt-budget.cjs +128 -165
  63. package/gsd-core/bin/lib/retired-artifact-cleanup.cjs +80 -0
  64. package/gsd-core/bin/lib/review-lane-descriptor.cjs +99 -0
  65. package/gsd-core/bin/lib/review-lane-runner.cjs +30 -6
  66. package/gsd-core/bin/lib/roadmap-command-router.cjs +42 -9
  67. package/gsd-core/bin/lib/roadmap-parser.cjs +100 -18
  68. package/gsd-core/bin/lib/roadmap.cjs +37 -7
  69. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +195 -62
  70. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +15 -3
  71. package/gsd-core/bin/lib/runtime-homes.cjs +154 -41
  72. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +105 -41
  73. package/gsd-core/bin/lib/section-manifest.cjs +209 -0
  74. package/gsd-core/bin/lib/shell-command-projection.cjs +113 -27
  75. package/gsd-core/bin/lib/smart-entry.cjs +12 -0
  76. package/gsd-core/bin/lib/state-transition.cjs +73 -8
  77. package/gsd-core/bin/lib/state.cjs +151 -62
  78. package/gsd-core/bin/lib/surface.cjs +12 -1
  79. package/gsd-core/bin/lib/uat-predicate.cjs +11 -1
  80. package/gsd-core/bin/lib/uat.cjs +320 -21
  81. package/gsd-core/bin/lib/unusable-input.cjs +9 -0
  82. package/gsd-core/bin/lib/verification.cjs +29 -12
  83. package/gsd-core/bin/lib/verify.cjs +29 -5
  84. package/gsd-core/bin/lib/workflow-fragments.cjs +557 -0
  85. package/gsd-core/bin/lib/workstream-inventory-builder.cjs +181 -18
  86. package/gsd-core/bin/lib/workstream-inventory.cjs +519 -27
  87. package/gsd-core/bin/lib/workstream.cjs +6 -0
  88. package/gsd-core/bin/lib/worktree-base-ref.cjs +50 -6
  89. package/gsd-core/bin/lib/worktree-safety.cjs +276 -118
  90. package/gsd-core/bin/shared/config-schema.manifest.json +2 -0
  91. package/gsd-core/references/artifact-types.md +10 -3
  92. package/gsd-core/references/autonomous-ui-design-contract.md +42 -0
  93. package/gsd-core/references/debugger-techniques.md +255 -0
  94. package/gsd-core/references/research-documentation-lookup.md +5 -3
  95. package/gsd-core/references/specless-probe-fallback.md +7 -6
  96. package/gsd-core/references/verifier-wiring-patterns.md +100 -0
  97. package/gsd-core/references/worktree-branch-check.md +2 -2
  98. package/gsd-core/templates/summary-complex.md +2 -0
  99. package/gsd-core/templates/summary-minimal.md +2 -0
  100. package/gsd-core/templates/summary-standard.md +2 -0
  101. package/gsd-core/templates/summary.md +2 -0
  102. package/gsd-core/workflows/audit-milestone.md +3 -0
  103. package/gsd-core/workflows/autonomous/steps/converge-banner.md +1 -0
  104. package/gsd-core/workflows/autonomous/steps/converge-dispatch-bg.md +11 -0
  105. package/gsd-core/workflows/autonomous/steps/converge-dispatch-inline.md +7 -0
  106. package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +21 -0
  107. package/gsd-core/workflows/autonomous/steps/converge-loop.md +7 -0
  108. package/gsd-core/workflows/autonomous.md +32 -69
  109. package/gsd-core/workflows/code-review/steps/dispatch-fix.md +39 -0
  110. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +83 -0
  111. package/gsd-core/workflows/code-review.md +42 -145
  112. package/gsd-core/workflows/complete-milestone/steps/git-tag.md +29 -0
  113. package/gsd-core/workflows/complete-milestone.md +23 -81
  114. package/gsd-core/workflows/debug.md +9 -12
  115. package/gsd-core/workflows/diagnose-issues.md +22 -0
  116. package/gsd-core/workflows/discovery-phase.md +4 -4
  117. package/gsd-core/workflows/discuss-phase-assumptions/steps/auto-advance-dispatch.md +15 -0
  118. package/gsd-core/workflows/discuss-phase-assumptions.md +5 -16
  119. package/gsd-core/workflows/docs-update/steps/dispatch-monorepo-packages.md +51 -0
  120. package/gsd-core/workflows/docs-update.md +8 -51
  121. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +34 -2
  122. package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +50 -0
  123. package/gsd-core/workflows/execute-phase/steps/partial-wave.md +31 -0
  124. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +19 -0
  125. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +42 -0
  126. package/gsd-core/workflows/execute-phase/steps/regression-gate.md +43 -37
  127. package/gsd-core/workflows/execute-phase.md +65 -137
  128. package/gsd-core/workflows/execute-plan.md +1 -1
  129. package/gsd-core/workflows/help/modes/full.md +6 -1
  130. package/gsd-core/workflows/ingest-docs.md +2 -1
  131. package/gsd-core/workflows/new-milestone/steps/project-md-milestone-write.md +16 -0
  132. package/gsd-core/workflows/new-milestone/steps/reset-phase-safety.md +19 -0
  133. package/gsd-core/workflows/new-milestone.md +21 -38
  134. package/gsd-core/workflows/new-project/steps/auto-mode-config.md +176 -0
  135. package/gsd-core/workflows/new-project/steps/auto-mode-detection.md +32 -0
  136. package/gsd-core/workflows/new-project/steps/codebase-map-offer.md +18 -0
  137. package/gsd-core/workflows/new-project.md +13 -226
  138. package/gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md +15 -0
  139. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +110 -0
  140. package/gsd-core/workflows/plan-phase/steps/prd-express-gate.md +8 -0
  141. package/gsd-core/workflows/plan-phase/steps/research-only-early-exit.md +17 -0
  142. package/gsd-core/workflows/plan-phase/steps/research-only-modifiers.md +16 -0
  143. package/gsd-core/workflows/plan-phase/steps/reviews-prerequisite.md +17 -0
  144. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +149 -0
  145. package/gsd-core/workflows/plan-phase.md +49 -193
  146. package/gsd-core/workflows/progress/steps/forensic-audit.md +125 -0
  147. package/gsd-core/workflows/progress/steps/mvp-display.md +18 -0
  148. package/gsd-core/workflows/progress.md +11 -153
  149. package/gsd-core/workflows/quick/steps/discussion-phase.md +124 -0
  150. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +111 -0
  151. package/gsd-core/workflows/quick/steps/quick-verification.md +46 -0
  152. package/gsd-core/workflows/quick/steps/research-phase.md +72 -0
  153. package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +37 -0
  154. package/gsd-core/workflows/quick.md +20 -390
  155. package/gsd-core/workflows/resume-project.md +3 -0
  156. package/gsd-core/workflows/review/steps/reviewer-instances-note-1.md +4 -0
  157. package/gsd-core/workflows/review/steps/reviewer-instances-note-2.md +3 -0
  158. package/gsd-core/workflows/review.md +15 -8
  159. package/gsd-core/workflows/section-manifest.json +219 -0
  160. package/gsd-core/workflows/sketch.md +1 -1
  161. package/gsd-core/workflows/spec-phase.md +17 -14
  162. package/gsd-core/workflows/spike-wrap-up.md +20 -5
  163. package/gsd-core/workflows/spike.md +50 -16
  164. package/gsd-core/workflows/sync-skills.md +49 -11
  165. package/gsd-core/workflows/transition/steps/workstream-collision-check.md +17 -0
  166. package/gsd-core/workflows/transition.md +8 -21
  167. package/gsd-core/workflows/ui-phase.md +8 -7
  168. package/gsd-core/workflows/update/steps/channel-banner.md +7 -0
  169. package/gsd-core/workflows/update.md +18 -7
  170. package/gsd-core/workflows/verify-phase.md +4 -7
  171. package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +36 -0
  172. package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +21 -0
  173. package/gsd-core/workflows/verify-work.md +8 -58
  174. package/hooks/dist/gsd-agent-isolation-guard.js +428 -0
  175. package/hooks/dist/gsd-check-update-worker.js +14 -5
  176. package/hooks/dist/gsd-cursor-subagent-start.js +532 -26
  177. package/hooks/dist/gsd-read-injection-scanner.js +7 -0
  178. package/hooks/dist/gsd-statusline.js +72 -6
  179. package/hooks/dist/gsd-worktree-path-guard.js +2 -1
  180. package/hooks/dist/gsd-write-guard.js +359 -0
  181. package/hooks/dist/lib/isolation-sentinel.js +268 -0
  182. package/hooks/dist/managed-hooks-registry.cjs +2 -0
  183. package/hooks/gsd-agent-isolation-guard.js +428 -0
  184. package/hooks/gsd-check-update-worker.js +14 -5
  185. package/hooks/gsd-cursor-subagent-start.js +532 -26
  186. package/hooks/gsd-read-injection-scanner.js +7 -0
  187. package/hooks/gsd-statusline.js +72 -6
  188. package/hooks/gsd-worktree-path-guard.js +2 -1
  189. package/hooks/gsd-write-guard.js +359 -0
  190. package/hooks/hooks.json +12 -0
  191. package/hooks/lib/isolation-sentinel.js +268 -0
  192. package/hooks/managed-hooks-registry.cjs +2 -0
  193. package/package.json +14 -5
  194. package/pi/gsd.cjs +57 -12
  195. package/scripts/build-hooks.js +9 -0
  196. package/scripts/changeset/lint.cjs +9 -2
  197. package/scripts/changeset/serialize.cjs +5 -1
  198. package/scripts/gen-capability-matrix.cjs +1 -1
  199. package/scripts/gen-context-index.cjs +448 -0
  200. package/scripts/gen-inventory-manifest.cjs +101 -1
  201. package/scripts/gen-prompt-budget-parity-corpus.cjs +645 -0
  202. package/scripts/gen-registry.cjs +39 -15
  203. package/scripts/gen-section-manifest.cjs +638 -0
  204. package/scripts/generate-package-identity.cjs +4 -2
  205. package/scripts/lint-allow-test-rule-refs.allowlist.json +17 -31
  206. package/scripts/lint-compiled-artifact-sync.cjs +6 -1
  207. package/scripts/lint-docs-command-form.cjs +195 -0
  208. package/scripts/lint-docs-required.cjs +9 -1
  209. package/scripts/lint-emitted-drift-ack.cjs +215 -20
  210. package/scripts/lint-example-parser-parity.cjs +395 -0
  211. package/scripts/lint-test-file-count.allowlist.json +27 -1
  212. package/scripts/mutation-matrix.cjs +13 -0
  213. package/scripts/prompt-injection-scan.sh +27 -6
  214. package/scripts/registry-schema.cjs +323 -94
  215. package/scripts/run-tests.cjs +3 -2
  216. package/scripts/validate-registry.cjs +10 -6
  217. package/skills/gsd-autonomous/SKILL.md +1 -1
  218. package/skills/gsd-execute-phase/SKILL.md +1 -1
  219. package/skills/gsd-mempalace-capture/SKILL.md +1 -1
  220. package/skills/gsd-new-milestone/SKILL.md +1 -1
  221. package/skills/gsd-plan-phase/SKILL.md +2 -2
  222. package/vscode/package.json +1 -1
  223. package/scripts/gen-emitted-baseline.cjs +0 -145
@@ -24,6 +24,9 @@ const { planningDir } = planningWorkspace;
24
24
  // eslint-disable-next-line @typescript-eslint/no-require-imports
25
25
  const configLoaderMod = require("./config-loader.cjs");
26
26
  const { loadConfig } = configLoaderMod;
27
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
28
+ const cliExitMod = require("./cli-exit.cjs");
29
+ const { ExitError } = cliExitMod;
27
30
  // ─── W021 Implementation ──────────────────────────────────────────────────────
28
31
  /**
29
32
  * Check each phase entry in a milestone-prefixed ROADMAP.md for W021 violations.
@@ -110,12 +113,40 @@ function routeRoadmapCommand({ roadmap, args, cwd, raw, error }) {
110
113
  'annotate-dependencies': () => roadmap.cmdRoadmapAnnotateDependencies(cwd, args[2], raw),
111
114
  'validate': () => {
112
115
  const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
113
- let roadmapContent = '';
116
+ const warnings = [];
117
+ // #2978: structural validation. A verb named "validate" that cannot
118
+ // produce a negative result provides false assurance. Before the
119
+ // opt-in milestone-prefix check, verify the file is structurally a
120
+ // roadmap at all.
121
+ let roadmapContent;
114
122
  try {
115
123
  roadmapContent = node_fs_1.default.readFileSync(roadmapPath, 'utf8');
116
124
  }
117
125
  catch {
118
- // ROADMAP.md missing — return empty warnings
126
+ // ROADMAP.md missing — not silent success.
127
+ warnings.push({ code: 'V001', message: 'ROADMAP.md not found or unreadable' });
128
+ const result = { warnings };
129
+ process.stdout.write(raw ? JSON.stringify(result) : JSON.stringify(result, null, 2));
130
+ throw new ExitError(1);
131
+ }
132
+ // Empty or whitespace-only.
133
+ if (roadmapContent.trim() === '') {
134
+ warnings.push({ code: 'V002', message: 'ROADMAP.md is empty' });
135
+ }
136
+ // Malformed frontmatter — a `---` opener with no matching closer.
137
+ // Tolerate a leading BOM (#3057) before the fence.
138
+ const contentAfterBom = roadmapContent.replace(/^\uFEFF/, '');
139
+ if (contentAfterBom.startsWith('---')) {
140
+ const closeMatch = contentAfterBom.slice(3).match(/\r?\n---\s*(\r?\n|$)/);
141
+ if (!closeMatch) {
142
+ warnings.push({ code: 'V003', message: 'ROADMAP.md frontmatter is malformed (unterminated --- fence)' });
143
+ }
144
+ }
145
+ // No recognizable phase structure — at least one `### Phase N:` heading.
146
+ // Mirrors the phase-heading pattern used across roadmap-parser.cts.
147
+ const hasPhaseEntry = /^#{2,4}\s*Phase\s+\S/im.test(roadmapContent);
148
+ if (!hasPhaseEntry && !warnings.some((w) => w.code === 'V002')) {
149
+ warnings.push({ code: 'V004', message: 'ROADMAP.md contains no recognizable phase entries (no "### Phase N:" headings)' });
119
150
  }
120
151
  // W021 only fires when phase_id_convention is explicitly 'milestone-prefixed'.
121
152
  // Authoritative source: .planning/config.json (set by the upgrade command).
@@ -141,14 +172,16 @@ function routeRoadmapCommand({ roadmap, args, cwd, raw, error }) {
141
172
  }
142
173
  }
143
174
  }
144
- const warnings = (convention === 'milestone-prefixed')
145
- ? checkW021(roadmapContent)
146
- : [];
175
+ if (convention === 'milestone-prefixed') {
176
+ warnings.push(...checkW021(roadmapContent));
177
+ }
147
178
  const result = { warnings };
148
- if (raw)
149
- process.stdout.write(JSON.stringify(result));
150
- else
151
- process.stdout.write(JSON.stringify(result, null, 2));
179
+ process.stdout.write(raw ? JSON.stringify(result) : JSON.stringify(result, null, 2));
180
+ // #2978: exit non-zero on any warning, per the documented contract
181
+ // ("exits non-zero on any error or warning").
182
+ if (warnings.length > 0) {
183
+ throw new ExitError(1);
184
+ }
152
185
  },
153
186
  'upgrade': () => {
154
187
  const dryRun = !args.includes('--apply');
@@ -36,21 +36,76 @@ const unusableInputMod = require("./unusable-input.cjs");
36
36
  const { UNUSABLE_REASON, warnUnusableInput } = unusableInputMod;
37
37
  const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
38
38
  // ─── Roadmap milestone scoping ───────────────────────────────────────────────
39
+ /**
40
+ * Markers that classify a MILESTONE HEADING (or `<summary>`) as closed/shipped
41
+ * versus still active. Hoisted to module scope in #2562 — three call sites
42
+ * (`extractCurrentMilestone`, `currentMilestoneRawRanges`,
43
+ * `isMilestoneShippedInRoadmap`) previously kept byte-identical copies.
44
+ */
45
+ const MILESTONE_CLOSED_MARKER_PATTERN = /\b(?:CLOSED|ARCHIVED|ABANDONED|SHIPPED|FAILED)\b|✅|🗄/i;
46
+ const MILESTONE_ACTIVE_MARKER_PATTERN = /\b(?:STARTED|ACTIVE|WIP)\b|in\s+progress|🚧|🔄/i;
47
+ function isClosedMilestoneHeading(headingText) {
48
+ return MILESTONE_CLOSED_MARKER_PATTERN.test(headingText) && !MILESTONE_ACTIVE_MARKER_PATTERN.test(headingText);
49
+ }
39
50
  /**
40
51
  * Strip shipped milestone content wrapped in <details> blocks.
41
52
  */
42
53
  function stripShippedMilestones(content) {
43
54
  return (0, markdown_sectionizer_cjs_1.stripTaggedBlocks)(content, 'details');
44
55
  }
56
+ /**
57
+ * #2562: is the milestone `version` marked SHIPPED by the ROADMAP itself?
58
+ *
59
+ * Scoped deliberately narrowly, because a false positive here reproduces the
60
+ * exact symptom #2562 reports ("milestone complete" while phases are unstarted):
61
+ *
62
+ * - Only a MILESTONE HEADING (`^#{1,3}` that is not a `Phase N:` heading) or a
63
+ * `<summary>` line can carry the signal. A bullet or checklist item that
64
+ * merely NAMES the version (`- [x] 03-01: ship the v2.0 login endpoint ✅`)
65
+ * is prose about a phase, not a milestone verdict, and is ignored.
66
+ * - The version token is boundary-matched with `(?![\w.-])` (mirrors the #730
67
+ * sub-milestone boundary at `extractCurrentMilestone`), so `v2.0` does not
68
+ * match inside `v2.0.1` — `\b` alone would, since `.` is a non-word char.
69
+ * - Shipped/active classification reuses the same marker patterns the milestone
70
+ * sectioniser uses, so an in-progress marker on the line always wins.
71
+ *
72
+ * Both patterns are anchored and use only complementary character classes
73
+ * (`[^\n]`, `[^<]`, `[^>]`) with no overlapping alternation, so matching stays
74
+ * linear in the ROADMAP's length — an untrusted ROADMAP cannot drive backtracking.
75
+ */
76
+ function isMilestoneShippedInRoadmap(content, version) {
77
+ const boundedVersion = `${escapeRegex(version)}(?![\\w.-])`;
78
+ const candidates = [
79
+ // A milestone heading: `## v2.0 Launch — ✅ SHIPPED`.
80
+ new RegExp(`^#{1,3}[^\\S\\n]+(?!Phase\\s+\\S)[^\\n]*${boundedVersion}[^\\n]*$`, 'gmi'),
81
+ // A collapsed shipped block's own summary: `<summary>✅ v2.0 … SHIPPED</summary>`.
82
+ new RegExp(`<summary[^>]*>[^<]*${boundedVersion}[^<]*<\\/summary>`, 'gi'),
83
+ ];
84
+ for (const pattern of candidates) {
85
+ for (const match of content.matchAll(pattern)) {
86
+ if (isClosedMilestoneHeading(match[0]))
87
+ return true;
88
+ }
89
+ }
90
+ return false;
91
+ }
45
92
  /**
46
93
  * Extract the current milestone section from ROADMAP.md by positive lookup.
94
+ *
95
+ * @param content - ROADMAP.md content.
96
+ * @param cwd - Project working directory, used to read the companion STATE.md
97
+ * for the current `milestone:` version.
98
+ * @param ws - #2562: workstream name, so the companion STATE.md is read from
99
+ * `.planning/workstreams/<ws>/` instead of the project root. Omitted (the
100
+ * default) preserves the prior `planningDir(cwd)` resolution exactly,
101
+ * including its `GSD_WORKSTREAM` env fallback.
47
102
  */
48
- function extractCurrentMilestone(content, cwd) {
103
+ function extractCurrentMilestone(content, cwd, ws) {
49
104
  if (!cwd)
50
105
  return stripShippedMilestones(content);
51
106
  let version = null;
52
107
  try {
53
- const statePath = node_path_1.default.join(planningDir(cwd), 'STATE.md');
108
+ const statePath = node_path_1.default.join(planningDir(cwd, ws), 'STATE.md');
54
109
  const stateRaw = (0, shell_command_projection_cjs_1.platformReadSync)(statePath);
55
110
  if (stateRaw !== null) {
56
111
  const milestoneMatch = stateRaw.match(/^milestone:\s*(.+)/m);
@@ -97,9 +152,7 @@ function extractCurrentMilestone(content, cwd) {
97
152
  return stripShippedMilestones(content);
98
153
  }
99
154
  const allMatches = headingMatches;
100
- const closedMarkerPattern = /\b(?:CLOSED|ARCHIVED|ABANDONED|SHIPPED|FAILED)\b|✅|🗄/i;
101
- const activeMarkerPattern = /\b(?:STARTED|ACTIVE|WIP)\b|in\s+progress|🚧|🔄/i;
102
- const isClosed = (h) => closedMarkerPattern.test(h) && !activeMarkerPattern.test(h);
155
+ const isClosed = isClosedMilestoneHeading;
103
156
  const firstMatch = allMatches[0];
104
157
  const selected = allMatches.find((m) => !isClosed(m[1])) || firstMatch;
105
158
  const sectionStart = selected.index;
@@ -156,26 +209,36 @@ function extractCurrentMilestone(content, cwd) {
156
209
  const detailsStart = detailsMatch.index ?? 0;
157
210
  detailsSection = content.slice(detailsStart, computeSectionEnd(detailsMatch[0], detailsStart));
158
211
  }
212
+ // #2947: the preamble strip removes `### Phase N:` detail headings from the
213
+ // pre-milestone region so they don't duplicate the ones inside the selected
214
+ // milestone section. But when the phase list lives under a non-version-bearing
215
+ // `## Phases` heading (the shipped greenfield template's own shape) and the
216
+ // selected version-bearing heading is a LATER progress/notes sub-heading with
217
+ // NO phase details of its own, stripping the preamble phases silently drops
218
+ // every phase (phase_count: 0, exit 0). Only strip preamble phase details when
219
+ // the selected milestone section actually contains its own — otherwise the
220
+ // preamble phases ARE this milestone's phases and must be preserved.
221
+ const currentSectionHasPhaseDetails = /^#{2,4}\s*Phase\s+\S/im.test(currentSection);
159
222
  const preamble = (0, markdown_sectionizer_cjs_1.stripTaggedBlocks)(beforeMilestones, 'details')
160
223
  // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
161
- .replace(/^#{2,4}\s*Phase\s+[\w][\w.-]*(?:\s*\([^)\n]{0,200}\))?\s*:[^\n]*(?:\n(?!#{1,6}\s)[^\n]*)*\n?/gim, '')
224
+ .replace(currentSectionHasPhaseDetails ? /^#{2,4}\s*Phase\s+[\w][\w.-]*(?:\s*\([^)\n]{0,200}\))?\s*:[^\n]*(?:\n(?!#{1,6}\s)[^\n]*)*\n?/gim : /$/, '')
162
225
  .replace(/^#{1,4}\s*Phase Details\b[^\n]*\n?/gim, '');
163
226
  return detailsSection
164
227
  ? preamble + currentSection + '\n' + detailsSection
165
228
  : preamble + currentSection;
166
229
  }
167
- /**
168
- * Replace a pattern only in the current milestone section of ROADMAP.md.
169
- */
170
230
  function replaceInCurrentMilestone(content, pattern, replacement) {
231
+ const apply = (src) => typeof replacement === 'function'
232
+ ? src.replace(pattern, replacement)
233
+ : src.replace(pattern, replacement);
171
234
  const lastDetailsClose = content.lastIndexOf('</details>');
172
235
  if (lastDetailsClose === -1) {
173
- return content.replace(pattern, replacement);
236
+ return apply(content);
174
237
  }
175
238
  const offset = lastDetailsClose + '</details>'.length;
176
239
  const before = content.slice(0, offset);
177
240
  const after = content.slice(offset);
178
- return before + after.replace(pattern, replacement);
241
+ return before + apply(after);
179
242
  }
180
243
  /**
181
244
  * Resolve a single phase's detail-section heading (`### Phase N: …`, any level
@@ -452,16 +515,24 @@ function getMilestoneInfo(cwd) {
452
515
  * free-form ROADMAPs that lack versioned milestone headings. When absent or
453
516
  * any other value, the warning is suppressed — legacy/default projects must
454
517
  * never see spurious warnings.
518
+ * @param ws - #2562: workstream name, so the ROADMAP/STATE pair is read from
519
+ * `.planning/workstreams/<ws>/` instead of the project root. Required by any
520
+ * caller that iterates workstreams (it cannot set `GSD_WORKSTREAM` per
521
+ * iteration). Omitted (the default) preserves the prior `planningDir(cwd)`
522
+ * resolution exactly, including its `GSD_WORKSTREAM` env fallback — every
523
+ * pre-#2562 call site is unaffected.
455
524
  */
456
- function getMilestonePhaseFilter(cwd, versionOverride, phaseIdConvention) {
525
+ function getMilestonePhaseFilter(cwd, versionOverride, phaseIdConvention, ws) {
457
526
  const milestonePhaseNums = new Set();
458
527
  let missingExplicitVersion = false;
528
+ let versionScoped = false;
529
+ let versionSectionFound = false;
459
530
  try {
460
- const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
531
+ const roadmapPath = node_path_1.default.join(planningDir(cwd, ws), 'ROADMAP.md');
461
532
  const roadmapContent = (0, shell_command_projection_cjs_1.platformReadSync)(roadmapPath);
462
533
  if (roadmapContent === null)
463
534
  throw new Error('missing');
464
- let roadmap = extractCurrentMilestone(roadmapContent, cwd);
535
+ let roadmap = extractCurrentMilestone(roadmapContent, cwd, ws);
465
536
  const hasVersionedMilestonesGlobal = /^#{1,3}\s+.*v\d+\.\d+/mi.test(roadmapContent);
466
537
  const hasPhaseHeadings = /#{2,4}\s*(?:\[[^\]]{1,200}\]\s*)?Phase\s+[\w]/i.test(roadmapContent);
467
538
  if (!hasVersionedMilestonesGlobal && hasPhaseHeadings && phaseIdConvention === 'milestone-prefixed') {
@@ -493,6 +564,8 @@ function getMilestonePhaseFilter(cwd, versionOverride, phaseIdConvention) {
493
564
  }
494
565
  }
495
566
  else {
567
+ versionScoped = true;
568
+ versionSectionFound = true;
496
569
  const sectionStart = sectionMatch.index;
497
570
  const headingLevel = (sectionMatch[1].match(/^(#{1,3})\s/) ?? ['', '#'])[1].length;
498
571
  const afterHeading = sectionStart + sectionMatch[0].length;
@@ -552,12 +625,20 @@ function getMilestonePhaseFilter(cwd, versionOverride, phaseIdConvention) {
552
625
  const passAll = (() => true);
553
626
  passAll.phaseCount = 0;
554
627
  passAll.missingExplicitVersion = missingExplicitVersion;
628
+ passAll.versionScoped = false;
629
+ // #2562: preserved through the pass-all degrade precisely BECAUSE
630
+ // `versionScoped` is reset here — this is the only surviving evidence that
631
+ // the current milestone exists in the ROADMAP and simply has no phases yet.
632
+ passAll.versionSectionFound = versionSectionFound;
555
633
  return passAll;
556
634
  }
557
- const normalized = new Set([...milestonePhaseNums].map(n => n.split('-').map(seg => (seg.replace(/^0+(?=\d)/, '') || '0')).join('-').toLowerCase()));
558
635
  function normalizePhaseIdSegments(id) {
559
636
  return id.split('-').map(seg => seg.replace(/^0+(?=\d)/, '') || '0').join('-');
560
637
  }
638
+ // #2562: derive BOTH sides of every membership comparison from
639
+ // normalizePhaseIdSegments. This set previously inlined a byte-identical
640
+ // second copy of that logic — the drift-prone shape this issue is about.
641
+ const normalized = new Set([...milestonePhaseNums].map(n => normalizePhaseIdSegments(n).toLowerCase()));
561
642
  const roadmapUsesHyphenedIds = [...normalized].some(n => n.includes('-'));
562
643
  // #2043: milestone-prefixed sub-phase components must be zero-padded — so a
563
644
  // single-digit slug word after the phase
@@ -587,6 +668,8 @@ function getMilestonePhaseFilter(cwd, versionOverride, phaseIdConvention) {
587
668
  }
588
669
  isDirInMilestone.phaseCount = milestonePhaseNums.size;
589
670
  isDirInMilestone.missingExplicitVersion = missingExplicitVersion;
671
+ isDirInMilestone.versionScoped = versionScoped;
672
+ isDirInMilestone.versionSectionFound = versionSectionFound;
590
673
  return isDirInMilestone;
591
674
  }
592
675
  /**
@@ -628,9 +711,7 @@ function currentMilestoneRawRanges(content, cwd) {
628
711
  const headingMatches = [...content.matchAll(sectionPattern)];
629
712
  if (headingMatches.length === 0)
630
713
  return null;
631
- const closedMarkerPattern = /\b(?:CLOSED|ARCHIVED|ABANDONED|SHIPPED|FAILED)\b|✅|🗄/i;
632
- const activeMarkerPattern = /\b(?:STARTED|ACTIVE|WIP)\b|in\s+progress|🚧|🔄/i;
633
- const isClosed = (h) => closedMarkerPattern.test(h) && !activeMarkerPattern.test(h);
714
+ const isClosed = isClosedMilestoneHeading;
634
715
  const firstMatch = headingMatches[0];
635
716
  const selected = headingMatches.find((m) => !isClosed(m[1])) || firstMatch;
636
717
  const sectionStart = selected.index ?? 0;
@@ -671,6 +752,7 @@ function currentMilestoneRawRanges(content, cwd) {
671
752
  module.exports = {
672
753
  stripShippedMilestones,
673
754
  extractCurrentMilestone,
755
+ isMilestoneShippedInRoadmap,
674
756
  replaceInCurrentMilestone,
675
757
  getRoadmapPhaseInternal,
676
758
  getMilestoneInfo,
@@ -265,7 +265,11 @@ function cmdRoadmapAnalyze(cwd, raw) {
265
265
  // Extract all phase headings: ## Phase N: Name or ### Phase N: Name
266
266
  // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
267
267
  // phase-id-owner: uses the [.-] (dot-or-dash) separator variant, not the canonical dot-only token; a swap to PHASE_NUMBER_TOKEN_SOURCE would drop hyphenated phase-id matches.
268
- const phasePattern = /#{2,4}\s*(?:\[[^\]]{1,200}\]\s*)?Phase\s+(\d+[A-Z]?(?:[.-]\d+)*)(?:\s*\([^)\n]{0,200}\))?\s*:\s*([^\n]+)/gi;
268
+ // #3036: widen the id capture to accept non-numeric-leading ids (e.g. B7, P0.3-2)
269
+ // that get-phase/execute-phase already resolve. An optional leading letter prefix
270
+ // ([A-Za-z]?) covers letter-prefixed ids without breaking numeric-leading ones.
271
+ // phase-id-owner: uses the [.-] (dot-or-dash) separator variant, not the canonical dot-only token; a swap to PHASE_NUMBER_TOKEN_SOURCE would drop hyphenated phase-id matches.
272
+ const phasePattern = /#{2,4}\s*(?:\[[^\]]{1,200}\]\s*)?Phase\s+([A-Za-z]?\d+[A-Z]?(?:[.-]\d+)*)(?:\s*\([^)\n]{0,200}\))?\s*:\s*([^\n]+)/gi;
269
273
  const phases = [];
270
274
  let match;
271
275
  // Phase 0 (pre-milestone) and Phase 999 (backlog) are sentinels, not real
@@ -297,8 +301,9 @@ function cmdRoadmapAnalyze(cwd, raw) {
297
301
  const sectionStart = match.index;
298
302
  const restOfContent = content.slice(sectionStart);
299
303
  // #3691: `\d` → `\d[\d.]*` so decimal phase headings (e.g. `### Phase 02.3:`) are
300
- // recognised as section boundaries.
301
- const nextHeader = restOfContent.match(/\n#{2,4}\s+(?:\[[^\]]{1,200}\]\s*)?Phase\s+\d[\d.-]*/i);
304
+ // recognised as section boundaries. #3036: `[A-Za-z]?\d` so non-numeric-leading ids
305
+ // (e.g. B7) are also recognised.
306
+ const nextHeader = restOfContent.match(/\n#{2,4}\s+(?:\[[^\]]{1,200}\]\s*)?Phase\s+[A-Za-z]?\d[\d.-]*/i);
302
307
  const sectionEnd = nextHeader ? sectionStart + nextHeader.index : content.length;
303
308
  const section = content.slice(sectionStart, sectionEnd);
304
309
  const goalMatch = section.match(/\*\*Goal(?::\*\*|\*\*:)\s*([^\n]+)/i);
@@ -389,7 +394,9 @@ function cmdRoadmapAnalyze(cwd, raw) {
389
394
  // IDs (e.g. `1-01`) match the detail-heading scanner above; otherwise they truncate
390
395
  // at the dash (`1-01` -> `1`) and every such phase reports a phantom missing detail.
391
396
  // phase-id-owner: uses the [.-] (dot-or-dash) separator variant, not the canonical dot-only token; a swap to PHASE_NUMBER_TOKEN_SOURCE would drop hyphenated phase-id matches.
392
- const checklistPattern = /-\s*\[[ x]\]\s*\*\*Phase\s+(\d+[A-Z]?(?:[.-]\d+)*)/gi;
397
+ // #3036: widen to accept non-numeric-leading ids (same widening as the detail-heading pattern above).
398
+ // phase-id-owner: uses the [.-] (dot-or-dash) separator variant, not the canonical dot-only token; a swap to PHASE_NUMBER_TOKEN_SOURCE would drop hyphenated phase-id matches.
399
+ const checklistPattern = /-\s*\[[ x]\]\s*\*\*Phase\s+([A-Za-z]?\d+[A-Z]?(?:[.-]\d+)*)/gi;
393
400
  const checklistPhases = new Set();
394
401
  let checklistMatch;
395
402
  while ((checklistMatch = checklistPattern.exec(content)) !== null) {
@@ -468,8 +475,13 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
468
475
  // cmdPhaseComplete's gate (phase.cts:1436). Previously the checkbox fired the
469
476
  // moment the last plan summary landed — before gsd-verifier had verified.
470
477
  const phaseDir = node_path_1.default.join(cwd, phaseInfo.directory);
471
- const verificationPassed = readVerificationStatus(phaseDir).status === 'passed';
478
+ const verificationResult = readVerificationStatus(phaseDir);
479
+ const verificationPassed = verificationResult.status === 'passed';
472
480
  const isComplete = summaryCount >= planCount && verificationPassed;
481
+ // #3057 B3: routing above is unchanged (an indeterminate staleness check
482
+ // still routes as if nothing were stale) — this only makes the fact visible
483
+ // to whatever reads this command's JSON output.
484
+ const verificationStaleCheckIndeterminate = verificationResult.staleCheckIndeterminate === true;
473
485
  const status = isComplete ? 'Complete' : summaryCount > 0 ? 'In Progress' : 'Planned';
474
486
  const today = clock_cjs_1.realClock.localToday();
475
487
  if (!node_fs_1.default.existsSync(roadmapPath)) {
@@ -526,11 +538,28 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
526
538
  // `**Plans**: N plans` — bold word + outer colon (gsd-core/templates/roadmap.md)
527
539
  // `**Plans:** N plans` — bold "Plans:" (colon inside bold)
528
540
  // `Plans: N plans` — plain text header
529
- const planCountPattern = new RegExp(`(#{2,4}\\s*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])(?:(?!\\n#{1,4}\\s)[\\s\\S])*?(?:\\*\\*Plans\\*\\*:|\\*\\*Plans:\\*\\*|(?:^|\\n)Plans:)\\s*)[^\\n]+`, 'i');
541
+ //
542
+ // #2853: the verb owns the count token ONLY — it must not destroy hand-written
543
+ // prose a human placed after the count (e.g. "(11-16 are gap closure ...)").
544
+ // Group $1 = phase header → `Plans:` label + trailing whitespace (unchanged).
545
+ // Group $2 = the existing count token to replace: matches `N/N plans complete`,
546
+ // `N/N plans executed`, or the bare template `N plans` form. Group $3 = whatever
547
+ // else is on the line (`[^\r\n]*`, so CRLF `\r` is preserved).
548
+ //
549
+ // Trailing text is preserved ONLY when a real count token ($2) was present —
550
+ // i.e. an annotation a human wrote after a real count. When $2 is absent the
551
+ // line is the fresh-template bracketed placeholder (`[Number of plans…]`) or
552
+ // other freeform guidance, not user prose: the count replaces the whole token,
553
+ // preserving the pre-#2853 clean-output behaviour on the template path.
554
+ const planCountPattern = new RegExp(`(#{2,4}\\s*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])(?:(?!\\n#{1,4}\\s)[\\s\\S])*?(?:\\*\\*Plans\\*\\*:|\\*\\*Plans:\\*\\*|(?:^|\\n)Plans:)\\s*)(\\d+\\s*\\/\\s*\\d+\\s+plans(?:\\s+(?:complete|executed))?|\\d+\\s+plans)?([^\\r\\n]*)`, 'i');
530
555
  const planCountText = isComplete
531
556
  ? `${summaryCount}/${planCount} plans complete`
532
557
  : `${summaryCount}/${planCount} plans executed`;
533
- roadmapContent = replaceInCurrentMilestone(roadmapContent, planCountPattern, `$1${planCountText}`);
558
+ roadmapContent = replaceInCurrentMilestone(roadmapContent, planCountPattern, (_match, label, existingCount, trailing) => {
559
+ // Preserve trailing text only when a real count preceded it.
560
+ const suffix = existingCount ? trailing : '';
561
+ return `${label}${planCountText}${suffix}`;
562
+ });
534
563
  // If complete: check checkbox
535
564
  if (isComplete) {
536
565
  const checkboxPattern = new RegExp(`(-\\s*\\[)[ ](\\]\\s*.*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s][^\\n]*)`, 'i');
@@ -628,6 +657,7 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
628
657
  summary_count: summaryCount,
629
658
  status,
630
659
  complete: isComplete,
660
+ verification_stale_check_indeterminate: verificationStaleCheckIndeterminate,
631
661
  }, raw, `${summaryCount}/${planCount} ${status}`);
632
662
  }
633
663
  // ─── cmdRoadmapAnnotateDependencies ───────────────────────────────────────────