@opengsd/gsd-core 1.9.1 → 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 (219) 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 +27 -3
  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 +453 -289
  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 +579 -66
  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 +75 -47
  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 -28
  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/prompt-budget.cjs +128 -165
  62. package/gsd-core/bin/lib/retired-artifact-cleanup.cjs +80 -0
  63. package/gsd-core/bin/lib/review-lane-descriptor.cjs +99 -0
  64. package/gsd-core/bin/lib/review-lane-runner.cjs +30 -6
  65. package/gsd-core/bin/lib/roadmap-command-router.cjs +42 -9
  66. package/gsd-core/bin/lib/roadmap-parser.cjs +100 -18
  67. package/gsd-core/bin/lib/roadmap.cjs +37 -7
  68. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +195 -62
  69. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +15 -3
  70. package/gsd-core/bin/lib/runtime-homes.cjs +154 -41
  71. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +105 -41
  72. package/gsd-core/bin/lib/section-manifest.cjs +209 -0
  73. package/gsd-core/bin/lib/shell-command-projection.cjs +113 -27
  74. package/gsd-core/bin/lib/smart-entry.cjs +12 -0
  75. package/gsd-core/bin/lib/state-transition.cjs +73 -8
  76. package/gsd-core/bin/lib/state.cjs +151 -62
  77. package/gsd-core/bin/lib/surface.cjs +12 -1
  78. package/gsd-core/bin/lib/uat-predicate.cjs +11 -1
  79. package/gsd-core/bin/lib/uat.cjs +320 -21
  80. package/gsd-core/bin/lib/unusable-input.cjs +9 -0
  81. package/gsd-core/bin/lib/verification.cjs +29 -12
  82. package/gsd-core/bin/lib/verify.cjs +10 -2
  83. package/gsd-core/bin/lib/workflow-fragments.cjs +557 -0
  84. package/gsd-core/bin/lib/workstream-inventory-builder.cjs +181 -18
  85. package/gsd-core/bin/lib/workstream-inventory.cjs +519 -27
  86. package/gsd-core/bin/lib/workstream.cjs +6 -0
  87. package/gsd-core/bin/lib/worktree-base-ref.cjs +50 -6
  88. package/gsd-core/bin/lib/worktree-safety.cjs +276 -118
  89. package/gsd-core/bin/shared/config-schema.manifest.json +2 -0
  90. package/gsd-core/references/artifact-types.md +10 -3
  91. package/gsd-core/references/autonomous-ui-design-contract.md +42 -0
  92. package/gsd-core/references/debugger-techniques.md +255 -0
  93. package/gsd-core/references/research-documentation-lookup.md +5 -3
  94. package/gsd-core/references/specless-probe-fallback.md +7 -6
  95. package/gsd-core/references/verifier-wiring-patterns.md +100 -0
  96. package/gsd-core/references/worktree-branch-check.md +2 -2
  97. package/gsd-core/templates/summary-complex.md +2 -0
  98. package/gsd-core/templates/summary-minimal.md +2 -0
  99. package/gsd-core/templates/summary-standard.md +2 -0
  100. package/gsd-core/templates/summary.md +2 -0
  101. package/gsd-core/workflows/audit-milestone.md +3 -0
  102. package/gsd-core/workflows/autonomous/steps/converge-banner.md +1 -0
  103. package/gsd-core/workflows/autonomous/steps/converge-dispatch-bg.md +11 -0
  104. package/gsd-core/workflows/autonomous/steps/converge-dispatch-inline.md +7 -0
  105. package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +21 -0
  106. package/gsd-core/workflows/autonomous/steps/converge-loop.md +7 -0
  107. package/gsd-core/workflows/autonomous.md +32 -69
  108. package/gsd-core/workflows/code-review/steps/dispatch-fix.md +39 -0
  109. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +83 -0
  110. package/gsd-core/workflows/code-review.md +42 -160
  111. package/gsd-core/workflows/complete-milestone/steps/git-tag.md +29 -0
  112. package/gsd-core/workflows/complete-milestone.md +23 -81
  113. package/gsd-core/workflows/debug.md +9 -12
  114. package/gsd-core/workflows/diagnose-issues.md +22 -0
  115. package/gsd-core/workflows/discovery-phase.md +4 -4
  116. package/gsd-core/workflows/discuss-phase-assumptions/steps/auto-advance-dispatch.md +15 -0
  117. package/gsd-core/workflows/discuss-phase-assumptions.md +5 -16
  118. package/gsd-core/workflows/docs-update/steps/dispatch-monorepo-packages.md +51 -0
  119. package/gsd-core/workflows/docs-update.md +8 -51
  120. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +34 -2
  121. package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +50 -0
  122. package/gsd-core/workflows/execute-phase/steps/partial-wave.md +31 -0
  123. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +19 -0
  124. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +42 -0
  125. package/gsd-core/workflows/execute-phase/steps/regression-gate.md +43 -37
  126. package/gsd-core/workflows/execute-phase.md +65 -137
  127. package/gsd-core/workflows/execute-plan.md +1 -1
  128. package/gsd-core/workflows/help/modes/full.md +6 -1
  129. package/gsd-core/workflows/ingest-docs.md +2 -1
  130. package/gsd-core/workflows/new-milestone/steps/project-md-milestone-write.md +16 -0
  131. package/gsd-core/workflows/new-milestone/steps/reset-phase-safety.md +19 -0
  132. package/gsd-core/workflows/new-milestone.md +21 -38
  133. package/gsd-core/workflows/new-project/steps/auto-mode-config.md +176 -0
  134. package/gsd-core/workflows/new-project/steps/auto-mode-detection.md +32 -0
  135. package/gsd-core/workflows/new-project/steps/codebase-map-offer.md +18 -0
  136. package/gsd-core/workflows/new-project.md +13 -226
  137. package/gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md +15 -0
  138. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +110 -0
  139. package/gsd-core/workflows/plan-phase/steps/prd-express-gate.md +8 -0
  140. package/gsd-core/workflows/plan-phase/steps/research-only-early-exit.md +17 -0
  141. package/gsd-core/workflows/plan-phase/steps/research-only-modifiers.md +16 -0
  142. package/gsd-core/workflows/plan-phase/steps/reviews-prerequisite.md +17 -0
  143. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +149 -0
  144. package/gsd-core/workflows/plan-phase.md +49 -193
  145. package/gsd-core/workflows/progress/steps/forensic-audit.md +125 -0
  146. package/gsd-core/workflows/progress/steps/mvp-display.md +18 -0
  147. package/gsd-core/workflows/progress.md +11 -153
  148. package/gsd-core/workflows/quick/steps/discussion-phase.md +124 -0
  149. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +111 -0
  150. package/gsd-core/workflows/quick/steps/quick-verification.md +46 -0
  151. package/gsd-core/workflows/quick/steps/research-phase.md +72 -0
  152. package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +37 -0
  153. package/gsd-core/workflows/quick.md +20 -390
  154. package/gsd-core/workflows/resume-project.md +3 -0
  155. package/gsd-core/workflows/review/steps/reviewer-instances-note-1.md +4 -0
  156. package/gsd-core/workflows/review/steps/reviewer-instances-note-2.md +3 -0
  157. package/gsd-core/workflows/review.md +15 -8
  158. package/gsd-core/workflows/section-manifest.json +219 -0
  159. package/gsd-core/workflows/sketch.md +1 -1
  160. package/gsd-core/workflows/spec-phase.md +17 -14
  161. package/gsd-core/workflows/spike-wrap-up.md +20 -5
  162. package/gsd-core/workflows/spike.md +50 -16
  163. package/gsd-core/workflows/sync-skills.md +49 -11
  164. package/gsd-core/workflows/transition/steps/workstream-collision-check.md +17 -0
  165. package/gsd-core/workflows/transition.md +8 -21
  166. package/gsd-core/workflows/ui-phase.md +8 -7
  167. package/gsd-core/workflows/update/steps/channel-banner.md +7 -0
  168. package/gsd-core/workflows/update.md +18 -7
  169. package/gsd-core/workflows/verify-phase.md +4 -7
  170. package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +36 -0
  171. package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +21 -0
  172. package/gsd-core/workflows/verify-work.md +8 -58
  173. package/hooks/dist/gsd-agent-isolation-guard.js +428 -0
  174. package/hooks/dist/gsd-check-update-worker.js +14 -5
  175. package/hooks/dist/gsd-cursor-subagent-start.js +532 -26
  176. package/hooks/dist/gsd-read-injection-scanner.js +7 -0
  177. package/hooks/dist/gsd-statusline.js +72 -6
  178. package/hooks/dist/gsd-worktree-path-guard.js +2 -1
  179. package/hooks/dist/gsd-write-guard.js +359 -0
  180. package/hooks/dist/lib/isolation-sentinel.js +268 -0
  181. package/hooks/dist/managed-hooks-registry.cjs +2 -0
  182. package/hooks/gsd-agent-isolation-guard.js +428 -0
  183. package/hooks/gsd-check-update-worker.js +14 -5
  184. package/hooks/gsd-cursor-subagent-start.js +532 -26
  185. package/hooks/gsd-read-injection-scanner.js +7 -0
  186. package/hooks/gsd-statusline.js +72 -6
  187. package/hooks/gsd-worktree-path-guard.js +2 -1
  188. package/hooks/gsd-write-guard.js +359 -0
  189. package/hooks/hooks.json +12 -0
  190. package/hooks/lib/isolation-sentinel.js +268 -0
  191. package/hooks/managed-hooks-registry.cjs +2 -0
  192. package/package.json +14 -5
  193. package/pi/gsd.cjs +57 -12
  194. package/scripts/build-hooks.js +9 -0
  195. package/scripts/changeset/lint.cjs +9 -2
  196. package/scripts/changeset/serialize.cjs +5 -1
  197. package/scripts/gen-capability-matrix.cjs +1 -1
  198. package/scripts/gen-context-index.cjs +448 -0
  199. package/scripts/gen-inventory-manifest.cjs +101 -1
  200. package/scripts/gen-prompt-budget-parity-corpus.cjs +645 -0
  201. package/scripts/gen-section-manifest.cjs +638 -0
  202. package/scripts/generate-package-identity.cjs +4 -2
  203. package/scripts/lint-allow-test-rule-refs.allowlist.json +17 -31
  204. package/scripts/lint-compiled-artifact-sync.cjs +6 -1
  205. package/scripts/lint-docs-command-form.cjs +195 -0
  206. package/scripts/lint-docs-required.cjs +9 -1
  207. package/scripts/lint-emitted-drift-ack.cjs +215 -20
  208. package/scripts/lint-example-parser-parity.cjs +395 -0
  209. package/scripts/lint-test-file-count.allowlist.json +27 -1
  210. package/scripts/mutation-matrix.cjs +13 -0
  211. package/scripts/prompt-injection-scan.sh +27 -6
  212. package/scripts/run-tests.cjs +3 -2
  213. package/skills/gsd-autonomous/SKILL.md +1 -1
  214. package/skills/gsd-execute-phase/SKILL.md +1 -1
  215. package/skills/gsd-mempalace-capture/SKILL.md +1 -1
  216. package/skills/gsd-new-milestone/SKILL.md +1 -1
  217. package/skills/gsd-plan-phase/SKILL.md +2 -2
  218. package/vscode/package.json +1 -1
  219. package/scripts/gen-emitted-baseline.cjs +0 -145
@@ -0,0 +1,80 @@
1
+ "use strict";
2
+ /**
3
+ * Conservative cleanup for artifact surfaces retired by a runtime descriptor.
4
+ *
5
+ * A descriptor may stop materializing an artifact kind while old installs
6
+ * still contain files from that surface. Install and profile/surface apply
7
+ * both call this helper so either path converges an existing installation.
8
+ * Only files proven managed by the previous install manifest are removed;
9
+ * modified and unknown files are preserved for the journaled installer
10
+ * migration (or the user) to handle without data loss.
11
+ */
12
+ var __importDefault = (this && this.__importDefault) || function (mod) {
13
+ return (mod && mod.__esModule) ? mod : { "default": mod };
14
+ };
15
+ const node_fs_1 = __importDefault(require("node:fs"));
16
+ const node_path_1 = __importDefault(require("node:path"));
17
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
18
+ const installerMigrations = require("./installer-migrations.cjs");
19
+ const external_descriptor_trust_cjs_1 = require("./external-descriptor-trust.cjs");
20
+ // Generated at build time, so no TypeScript source declaration exists.
21
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
22
+ const capabilityRegistry = require('./capability-registry.cjs');
23
+ function retiredArtifactsFor(runtime) {
24
+ const configured = capabilityRegistry.runtimes?.[runtime]?.runtime?.hostBehaviors?.retiredArtifacts;
25
+ return Array.isArray(configured) ? configured : [];
26
+ }
27
+ function pruneRetiredRuntimeArtifacts(runtime, configDir) {
28
+ const result = { removed: [], preserved: [] };
29
+ const declarations = retiredArtifactsFor(runtime);
30
+ if (declarations.length === 0)
31
+ return result;
32
+ const manifest = installerMigrations.readInstallManifest(configDir);
33
+ for (const declaration of declarations) {
34
+ const destSubpath = declaration.destSubpath;
35
+ const prefix = declaration.prefix;
36
+ const suffix = declaration.suffix;
37
+ if (typeof destSubpath !== 'string' || destSubpath.length === 0 ||
38
+ typeof prefix !== 'string' || prefix.length === 0 ||
39
+ typeof suffix !== 'string' || suffix.length === 0 ||
40
+ !(0, external_descriptor_trust_cjs_1.isPathConfined)(destSubpath, configDir)) {
41
+ continue;
42
+ }
43
+ const destDir = node_path_1.default.resolve(configDir, destSubpath);
44
+ let entries;
45
+ try {
46
+ if (!node_fs_1.default.existsSync(destDir) || node_fs_1.default.lstatSync(destDir).isSymbolicLink())
47
+ continue;
48
+ entries = node_fs_1.default.readdirSync(destDir, { withFileTypes: true });
49
+ }
50
+ catch {
51
+ continue;
52
+ }
53
+ for (const entry of entries) {
54
+ if (!entry.isFile() || !entry.name.startsWith(prefix) || !entry.name.endsWith(suffix))
55
+ continue;
56
+ const relPath = node_path_1.default.posix.join(destSubpath.replace(/\\/g, '/'), entry.name);
57
+ const artifact = installerMigrations.classifyArtifact(configDir, relPath, manifest);
58
+ if (artifact.classification !== 'managed-pristine') {
59
+ result.preserved.push(relPath);
60
+ continue;
61
+ }
62
+ try {
63
+ node_fs_1.default.unlinkSync(node_path_1.default.join(destDir, entry.name));
64
+ result.removed.push(relPath);
65
+ }
66
+ catch {
67
+ result.preserved.push(relPath);
68
+ }
69
+ }
70
+ try {
71
+ if (node_fs_1.default.readdirSync(destDir).length === 0)
72
+ node_fs_1.default.rmdirSync(destDir);
73
+ }
74
+ catch {
75
+ // Non-empty, unreadable, or concurrently changed: preserve the directory.
76
+ }
77
+ }
78
+ return result;
79
+ }
80
+ module.exports = { pruneRetiredRuntimeArtifacts, retiredArtifactsFor };
@@ -52,6 +52,7 @@
52
52
  */
53
53
  Object.defineProperty(exports, "__esModule", { value: true });
54
54
  exports.DOCS_PARITY_VIOLATION = exports.LANE_SLUG_RE = exports.PARITY_VIOLATION = exports.REVIEWER_LANES = exports.ARGV_PLACEHOLDER = void 0;
55
+ exports.mergeReviewerLanes = mergeReviewerLanes;
55
56
  exports.checkReviewerLaneParity = checkReviewerLaneParity;
56
57
  exports.checkReviewerDocsParity = checkReviewerDocsParity;
57
58
  /**
@@ -423,6 +424,104 @@ exports.REVIEWER_LANES = Object.freeze([
423
424
  handler: null,
424
425
  },
425
426
  ].map((lane) => Object.freeze(lane)));
427
+ /**
428
+ * Merge the first-party lane set with installed overlay `reviewer` bodies
429
+ * (ADR-2782 D8: first-party ∪ overlay, first-party wins on slug collision).
430
+ *
431
+ * `routeReviewLane` (gsd-core/bin/gsd-tools.cjs) consumes this to build the lane
432
+ * map its `sections`/`flags`/`plan`/`invoke` subcommands resolve against, so an
433
+ * installed, consented third-party reviewer capability (`role:"reviewer"` with a
434
+ * declared `reviewer` body) becomes selectable, plannable, and invocable through
435
+ * the same surface as a built-in lane — closing #2927, where the overlay was
436
+ * roster-visible (`deriveReviewerSlugs`) and disclosed at install
437
+ * (`collectReviewerLaneSurfaces`) but never reached the invocation path.
438
+ *
439
+ * ADR-2782 D1 deliberately made the manifest `reviewer` body field-identical to
440
+ * `ReviewerLaneCommon` so "Phase 2 harvests the shape into the capability manifest
441
+ * with no translation layer" (CONTEXT.md reviewer-lane-descriptor predicate). The
442
+ * overlay body IS already `ReviewerLane`-shaped; this function MERGES, it does not
443
+ * PROJECT — there is no field renaming, no vocabulary translation. That is the
444
+ * design decision the bug violated by simply never calling anything.
445
+ *
446
+ * Pure and TOTAL — never throws on any input, because third-party overlay
447
+ * manifests are untrusted data reaching this seam at load time. A cap whose
448
+ * `reviewer` body is absent, non-object, or carries an empty/grammar-invalid slug
449
+ * is SKIPPED (contributes no lane) rather than crashing, so one malformed overlay
450
+ * cannot take the whole `review-lane` surface (and every first-party lane with it)
451
+ * down. The slug grammar (`LANE_SLUG_RE`) is enforced HERE even though
452
+ * `resolveLanePlan`'s trust boundary re-checks it, because `sections`/`flags` read
453
+ * lane fields before reaching `resolveLanePlan` — defense in depth against the
454
+ * path-traversal class the invocation boundary exists for.
455
+ *
456
+ * First-party wins: an overlay declaring a slug that already names a first-party
457
+ * lane is silently superseded (the first-party entry is kept, identity-stable),
458
+ * never overwritten. The first-party object is returned by reference, not copied.
459
+ *
460
+ * @param firstParty The frozen first-party lane set (`REVIEWER_LANES`).
461
+ * @param registry The merged capability registry
462
+ * (`loadRegistry({ includeInstalled: true })`); only its
463
+ * `capabilities` map is read. A cap contributes a lane iff it
464
+ * carries an object `reviewer` body with a non-empty,
465
+ * grammar-valid `slug`. The `role:"runtime"` legacy
466
+ * `reviewerCli` alias contributes NO lane here — it has no lane
467
+ * descriptor, and the selection roster (`deriveReviewerSlugs`)
468
+ * is a separate surface.
469
+ * @returns A NEW array: first-party lanes (in order) followed by accepted overlay
470
+ * lanes (in registry iteration order). Callers must not mutate it.
471
+ */
472
+ function mergeReviewerLanes(firstParty, registry) {
473
+ // First-party always wins and is returned by reference (identity-stable), so a
474
+ // collision cannot perturb the first-party entry a consumer already holds.
475
+ const bySlug = new Map();
476
+ const ordered = [];
477
+ for (const lane of firstParty) {
478
+ const slug = typeof lane.slug === 'string' ? lane.slug.trim() : '';
479
+ if (!slug)
480
+ continue; // unreachable for the shipped frozen set, but this is exported
481
+ if (!bySlug.has(slug)) {
482
+ bySlug.set(slug, lane);
483
+ ordered.push(lane);
484
+ }
485
+ }
486
+ const capabilities = (registry && registry.capabilities) || {};
487
+ for (const cap of Object.values(capabilities)) {
488
+ if (cap === null || typeof cap !== 'object' || Array.isArray(cap))
489
+ continue;
490
+ const body = cap.reviewer;
491
+ // C1/C2 (mirroring collectReviewerLaneSurfaces): a missing, null, or
492
+ // non-object reviewer body declares no lane — never an error at this layer.
493
+ if (body === null || typeof body !== 'object' || Array.isArray(body))
494
+ continue;
495
+ const rawSlug = body.slug;
496
+ const slug = typeof rawSlug === 'string' ? rawSlug.trim() : '';
497
+ // Empty/whitespace slug: nothing to key on. Grammar-invalid slug: the
498
+ // path-traversal class — skip rather than admit, even though resolveLanePlan
499
+ // would reject it downstream. Both are skips, not throws.
500
+ if (!slug || !exports.LANE_SLUG_RE.test(slug))
501
+ continue;
502
+ if (bySlug.has(slug))
503
+ continue; // D8: first-party (or an earlier overlay) wins
504
+ // The body is already ReviewerLane-shaped per ADR-2782 D1. We do NOT
505
+ // deep-validate every field here: the invocation trust boundary
506
+ // (resolveLanePlan) is the seam that re-validates a lane before it runs, and
507
+ // `sections`/`flags` are both tolerant of a partial body (flags filters by
508
+ // shape; sections reads slug/reviewsSection which a non-string cap simply
509
+ // renders as undefined). Admitting the body keeps the merge a pure merge.
510
+ //
511
+ // The slug is normalized to its trimmed value on the stored lane so the merge
512
+ // and the selection roster (`deriveReviewerSlugs`, which trims before adding)
513
+ // agree on the canonical key — otherwise a body declaring `slug: ' x '`
514
+ // would key the map on `'x'` but emit the raw `' x '` in `sections` and fail
515
+ // to match a `--selected` value coming from the roster. (The capability
516
+ // validator's grammar check rejects surrounding whitespace before this is
517
+ // reachable through a real install, so this is belt-and-braces parity with the
518
+ // roster, not a live-input guard.)
519
+ const lane = { ...body, slug };
520
+ bySlug.set(slug, lane);
521
+ ordered.push(lane);
522
+ }
523
+ return ordered;
524
+ }
426
525
  /* ------------------------------------------------------------------ *
427
526
  * DEFECT.GENERATIVE-FIX parity (CONTEXT.md:797)
428
527
  * ------------------------------------------------------------------ */
@@ -283,18 +283,32 @@ function antigravityWatermark(workspace, deps) {
283
283
  return { convId, lines: deps.readFile(tx).split(/\r?\n/).filter((l) => l.trim()).length };
284
284
  }
285
285
  catch {
286
- return { convId, lines: 0 };
286
+ // #3118: this conv-id pre-dates this run, so its transcript exists but this run cannot verify
287
+ // its line count. Reporting `lines: 0` would assert a fact we could not check — flag it instead
288
+ // so the fallback can decline rather than silently skip zero and replay a stale response.
289
+ return { convId, lines: 0, unreadable: true };
287
290
  }
288
291
  }
289
- /** Workspace lookup is case-insensitive — the leg's jq did `ascii_downcase` on both sides. */
292
+ /**
293
+ * Workspace lookup is case-insensitive — the leg's jq did `ascii_downcase` on both sides.
294
+ *
295
+ * #3118: a successful `JSON.parse` does not by itself make the payload a usable object —
296
+ * `JSON.parse('null')` succeeds and returns `null`, so a truncated/zeroed cache file slips past
297
+ * the callers' parse-only try/catch. `typeof null === 'object'`, so the guard below must exclude
298
+ * `null` explicitly. Arrays are excluded too (not a workspace map), which also falls out of the
299
+ * `Object.entries`/`hasOwnProperty` lookups below returning nothing for array input.
300
+ */
290
301
  function resolveConvId(cache, workspace) {
291
- if (Object.prototype.hasOwnProperty.call(cache, workspace)) {
292
- const direct = cache[workspace];
302
+ if (cache === null || typeof cache !== 'object')
303
+ return '';
304
+ const record = cache;
305
+ if (Object.prototype.hasOwnProperty.call(record, workspace)) {
306
+ const direct = record[workspace];
293
307
  if (typeof direct === 'string' && direct)
294
308
  return direct;
295
309
  }
296
310
  const target = workspace.toLowerCase();
297
- for (const [k, v] of Object.entries(cache)) {
311
+ for (const [k, v] of Object.entries(record)) {
298
312
  if (k.toLowerCase() === target && typeof v === 'string' && v)
299
313
  return v;
300
314
  }
@@ -324,6 +338,10 @@ function antigravityTranscriptFallback(workspace, mark, deps) {
324
338
  const convId = resolveConvId(cache, workspace);
325
339
  if (!convId)
326
340
  return '';
341
+ // #3118: the watermark could not read this conversation's transcript, so there is no trustworthy
342
+ // skip for it. Declining is the fail-closed answer; skipping 0 would replay a prior run's review.
343
+ if (mark.unreadable === true && convId === mark.convId)
344
+ return '';
327
345
  const tx = transcriptPath(deps.homeDir, convId);
328
346
  if (!deps.exists(tx))
329
347
  return '';
@@ -549,7 +567,13 @@ function runSpawnLane(plan, deps, repoRoot) {
549
567
  ? antigravityArgv(plan.argv, plan.promptPath, repoRoot, deps)
550
568
  : plan.argv;
551
569
  const out = deps.spawn(plan.binary, argv, { input, timeoutMs: plan.timeoutMs });
552
- deps.writeFile(plan.errPath, out.stderr ?? '');
570
+ // #3086: surface spawn errors (ENOENT, ETIMEDOUT, etc.) that would otherwise
571
+ // be silently dropped — the review path read only stdout/stderr and treated
572
+ // an empty-stderr spawn failure as "the model had nothing to say".
573
+ const errContent = out.errorCode
574
+ ? `${out.stderr ?? ''}\n[spawn error: ${out.errorCode}]\n`
575
+ : (out.stderr ?? '');
576
+ deps.writeFile(plan.errPath, errContent);
553
577
  // `file-arg` lanes write the review themselves and their stdout is deliberately discarded (#1698).
554
578
  let review = plan.outputTarget.kind === 'file'
555
579
  ? deps.exists(plan.outputTarget.path)
@@ -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 ───────────────────────────────────────────