@opengsd/gsd-core 1.7.0-rc.6 → 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 (195) 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 +31 -3
  8. package/agents/gsd-planner.md +29 -36
  9. package/agents/gsd-security-auditor.md +13 -15
  10. package/agents/gsd-verifier.md +2 -2
  11. package/bin/install.js +1157 -84
  12. package/commands/gsd/ai-integration-phase.md +1 -1
  13. package/commands/gsd/mempalace-capture.md +31 -1
  14. package/commands/gsd/new-milestone.md +1 -1
  15. package/commands/gsd/plan-phase.md +5 -3
  16. package/commands/gsd/plan-review-convergence.md +3 -2
  17. package/commands/gsd/surface.md +6 -6
  18. package/gsd-core/bin/gsd-tools.cjs +1866 -2434
  19. package/gsd-core/bin/lib/adapter-imperative.cjs +8 -1
  20. package/gsd-core/bin/lib/agent-command-router.cjs +20 -5
  21. package/gsd-core/bin/lib/api-coverage.cjs +341 -49
  22. package/gsd-core/bin/lib/audit.cjs +7 -6
  23. package/gsd-core/bin/lib/broken-windows.cjs +716 -0
  24. package/gsd-core/bin/lib/capability-command-router.cjs +733 -0
  25. package/gsd-core/bin/lib/capability-registry.cjs +157 -88
  26. package/gsd-core/bin/lib/capability-writer.cjs +6 -1
  27. package/gsd-core/bin/lib/check-command-router.cjs +129 -26
  28. package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +115 -27
  29. package/gsd-core/bin/lib/claude-orchestration.cjs +84 -9
  30. package/gsd-core/bin/lib/clock.cjs +19 -0
  31. package/gsd-core/bin/lib/command-aliases.cjs +14 -0
  32. package/gsd-core/bin/lib/commands.cjs +129 -13
  33. package/gsd-core/bin/lib/config-loader.cjs +20 -4
  34. package/gsd-core/bin/lib/config.cjs +81 -18
  35. package/gsd-core/bin/lib/core-utils.cjs +14 -3
  36. package/gsd-core/bin/lib/decisions.cjs +32 -8
  37. package/gsd-core/bin/lib/docs.cjs +6 -0
  38. package/gsd-core/bin/lib/drift.cjs +4 -4
  39. package/gsd-core/bin/lib/external-descriptor-trust.cjs +14 -2
  40. package/gsd-core/bin/lib/frontmatter.cjs +22 -0
  41. package/gsd-core/bin/lib/gap-checker.cjs +17 -2
  42. package/gsd-core/bin/lib/gsd2-import.cjs +2 -1
  43. package/gsd-core/bin/lib/init.cjs +138 -60
  44. package/gsd-core/bin/lib/install-engine.cjs +301 -25
  45. package/gsd-core/bin/lib/install-profiles.cjs +239 -1
  46. package/gsd-core/bin/lib/installer-migration-authoring.cjs +2 -1
  47. package/gsd-core/bin/lib/installer-migrations/005-opencode-baseline-commands-dir.cjs +146 -0
  48. package/gsd-core/bin/lib/installer-migrations/006-pi-extension-cjs-to-js.cjs +91 -0
  49. package/gsd-core/bin/lib/installer-migrations.cjs +45 -6
  50. package/gsd-core/bin/lib/markdown-sectionizer.cjs +449 -0
  51. package/gsd-core/bin/lib/markdown-table.cjs +698 -0
  52. package/gsd-core/bin/lib/milestone.cjs +463 -43
  53. package/gsd-core/bin/lib/model-catalog.cjs +19 -4
  54. package/gsd-core/bin/lib/model-resolver.cjs +189 -7
  55. package/gsd-core/bin/lib/onboard-projection.cjs +11 -8
  56. package/gsd-core/bin/lib/phase-command-router.cjs +50 -2
  57. package/gsd-core/bin/lib/phase-id.cjs +26 -4
  58. package/gsd-core/bin/lib/phase-lifecycle.cjs +62 -36
  59. package/gsd-core/bin/lib/phase-locator.cjs +23 -2
  60. package/gsd-core/bin/lib/phase.cjs +636 -72
  61. package/gsd-core/bin/lib/plan-scan.cjs +73 -2
  62. package/gsd-core/bin/lib/roadmap-parser.cjs +225 -17
  63. package/gsd-core/bin/lib/roadmap.cjs +113 -52
  64. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +14 -7
  65. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +3 -2
  66. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +24 -9
  67. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +41 -17
  68. package/gsd-core/bin/lib/schema-detect.cjs +2 -1
  69. package/gsd-core/bin/lib/security.cjs +1 -1
  70. package/gsd-core/bin/lib/shell-command-projection.cjs +61 -25
  71. package/gsd-core/bin/lib/smart-entry.cjs +73 -7
  72. package/gsd-core/bin/lib/state-document.cjs +7 -4
  73. package/gsd-core/bin/lib/state-transition.cjs +122 -46
  74. package/gsd-core/bin/lib/state.cjs +456 -137
  75. package/gsd-core/bin/lib/surface.cjs +53 -11
  76. package/gsd-core/bin/lib/template.cjs +2 -1
  77. package/gsd-core/bin/lib/uat.cjs +474 -13
  78. package/gsd-core/bin/lib/ui-safety-gate.cjs +23 -1
  79. package/gsd-core/bin/lib/validate.cjs +12 -8
  80. package/gsd-core/bin/lib/verification.cjs +112 -17
  81. package/gsd-core/bin/lib/verify.cjs +224 -25
  82. package/gsd-core/bin/lib/workstream.cjs +3 -2
  83. package/gsd-core/bin/lib/worktree-safety.cjs +1 -1
  84. package/gsd-core/bin/lib/write-set.cjs +38 -0
  85. package/gsd-core/bin/shared/config-schema.manifest.json +5 -2
  86. package/gsd-core/references/api-coverage.md +37 -7
  87. package/gsd-core/references/checkpoints.md +13 -1
  88. package/gsd-core/references/common-bug-patterns.md +13 -0
  89. package/gsd-core/references/debugger-bug-taxonomy.md +111 -0
  90. package/gsd-core/references/debugger-fix-acceptance.md +157 -0
  91. package/gsd-core/references/debugger-philosophy.md +1 -0
  92. package/gsd-core/references/debugger-prevention.md +98 -0
  93. package/gsd-core/references/debugger-rca-branching.md +98 -0
  94. package/gsd-core/references/debugger-repro-hardening.md +130 -0
  95. package/gsd-core/references/debugger-sbfl.md +110 -0
  96. package/gsd-core/references/debugger-semantic-recall.md +81 -0
  97. package/gsd-core/references/execute-phase-quota-recovery.md +55 -0
  98. package/gsd-core/references/execute-phase-requirement-revert.md +8 -0
  99. package/gsd-core/references/execute-phase-response-language.md +7 -0
  100. package/gsd-core/references/planner-antipatterns.md +6 -0
  101. package/gsd-core/references/planner-mvp-mode.md +12 -13
  102. package/gsd-core/references/planner-preconditions.md +156 -0
  103. package/gsd-core/references/planner-reversibility.md +132 -0
  104. package/gsd-core/references/reviewer-instances.md +9 -7
  105. package/gsd-core/references/skeleton-template.md +1 -1
  106. package/gsd-core/references/thinking-models-planning.md +3 -1
  107. package/gsd-core/templates/DEBUG.md +5 -3
  108. package/gsd-core/workflows/add-phase.md +2 -0
  109. package/gsd-core/workflows/add-tests.md +4 -2
  110. package/gsd-core/workflows/add-todo.md +32 -1
  111. package/gsd-core/workflows/ai-integration-phase.md +4 -2
  112. package/gsd-core/workflows/audit-fix.md +2 -2
  113. package/gsd-core/workflows/check-todos.md +3 -1
  114. package/gsd-core/workflows/cleanup.md +7 -1
  115. package/gsd-core/workflows/code-review.md +17 -5
  116. package/gsd-core/workflows/complete-milestone.md +3 -0
  117. package/gsd-core/workflows/debug.md +27 -5
  118. package/gsd-core/workflows/diagnose-issues.md +1 -1
  119. package/gsd-core/workflows/discovery-phase.md +7 -0
  120. package/gsd-core/workflows/discuss-phase/templates/context.md +16 -2
  121. package/gsd-core/workflows/discuss-phase-assumptions.md +3 -0
  122. package/gsd-core/workflows/do.md +7 -1
  123. package/gsd-core/workflows/docs-update.md +1 -0
  124. package/gsd-core/workflows/eval-review.md +3 -0
  125. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +4 -4
  126. package/gsd-core/workflows/execute-phase/steps/regression-gate.md +2 -2
  127. package/gsd-core/workflows/execute-phase.md +30 -37
  128. package/gsd-core/workflows/execute-plan.md +15 -4
  129. package/gsd-core/workflows/fast.md +8 -22
  130. package/gsd-core/workflows/graduation.md +3 -0
  131. package/gsd-core/workflows/health.md +7 -1
  132. package/gsd-core/workflows/help/modes/full.md +6 -2
  133. package/gsd-core/workflows/import.md +8 -2
  134. package/gsd-core/workflows/inbox.md +7 -0
  135. package/gsd-core/workflows/ingest-docs.md +15 -10
  136. package/gsd-core/workflows/manager.md +3 -1
  137. package/gsd-core/workflows/map-codebase.md +4 -4
  138. package/gsd-core/workflows/mvp-phase.md +3 -0
  139. package/gsd-core/workflows/new-milestone.md +69 -21
  140. package/gsd-core/workflows/new-project.md +17 -15
  141. package/gsd-core/workflows/new-workspace.md +3 -1
  142. package/gsd-core/workflows/onboard.md +3 -0
  143. package/gsd-core/workflows/plan-phase.md +14 -5
  144. package/gsd-core/workflows/plan-review-convergence.md +48 -3
  145. package/gsd-core/workflows/plant-seed.md +3 -0
  146. package/gsd-core/workflows/profile-user.md +7 -1
  147. package/gsd-core/workflows/progress.md +33 -5
  148. package/gsd-core/workflows/quick.md +21 -7
  149. package/gsd-core/workflows/remove-workspace.md +3 -0
  150. package/gsd-core/workflows/review.md +123 -68
  151. package/gsd-core/workflows/scan.md +1 -1
  152. package/gsd-core/workflows/secure-phase.md +4 -1
  153. package/gsd-core/workflows/settings-integrations.md +3 -0
  154. package/gsd-core/workflows/settings.md +3 -0
  155. package/gsd-core/workflows/ship.md +58 -5
  156. package/gsd-core/workflows/sketch.md +3 -0
  157. package/gsd-core/workflows/smart-entry.md +3 -0
  158. package/gsd-core/workflows/spec-phase.md +1 -1
  159. package/gsd-core/workflows/spike.md +7 -1
  160. package/gsd-core/workflows/transition.md +1 -1
  161. package/gsd-core/workflows/ui-phase.md +3 -1
  162. package/gsd-core/workflows/ui-review.md +3 -0
  163. package/gsd-core/workflows/undo.md +7 -0
  164. package/gsd-core/workflows/update.md +2 -0
  165. package/gsd-core/workflows/validate-phase.md +3 -0
  166. package/gsd-core/workflows/verify-phase.md +2 -2
  167. package/gsd-core/workflows/verify-work.md +7 -3
  168. package/hooks/dist/gsd-context-monitor.js +27 -9
  169. package/hooks/dist/gsd-statusline.js +252 -17
  170. package/hooks/gsd-context-monitor.js +27 -9
  171. package/hooks/gsd-statusline.js +252 -17
  172. package/package.json +8 -4
  173. package/pi/gsd.cjs +8 -2
  174. package/scripts/changeset/lint.cjs +1 -0
  175. package/scripts/changeset/parse.cjs +26 -0
  176. package/scripts/check-glossary-refs.cjs +220 -0
  177. package/scripts/ci-rebase-check.cjs +48 -4
  178. package/scripts/ci-test-scope.cjs +39 -1
  179. package/scripts/gen-adr-index.cjs +526 -0
  180. package/scripts/gen-golden-install-parity-zcode.cjs +35 -45
  181. package/scripts/gen-install-tree-fixtures.cjs +75 -0
  182. package/scripts/gen-test-timings.cjs +201 -0
  183. package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -1
  184. package/scripts/lint-portable-timeout.cjs +140 -0
  185. package/scripts/lint-table-schema-drift.cjs +157 -0
  186. package/scripts/lint-test-file-count.allowlist.json +1 -0
  187. package/scripts/release-tarball-smoke.cjs +18 -11
  188. package/scripts/run-tests.cjs +420 -58
  189. package/skills/gsd-ai-integration-phase/SKILL.md +1 -1
  190. package/skills/gsd-mempalace-capture/SKILL.md +31 -1
  191. package/skills/gsd-new-milestone/SKILL.md +1 -1
  192. package/skills/gsd-plan-phase/SKILL.md +5 -3
  193. package/skills/gsd-plan-review-convergence/SKILL.md +3 -2
  194. package/skills/gsd-surface/SKILL.md +6 -6
  195. package/vscode/package.json +1 -1
@@ -12,14 +12,69 @@ const node_path_1 = require("node:path");
12
12
  // eslint-disable-next-line @typescript-eslint/no-require-imports
13
13
  const coreUtils = require("./core-utils.cjs");
14
14
  const { countMatchedSummaries } = coreUtils;
15
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
16
+ const frontmatterMod = require("./frontmatter.cjs");
17
+ const { extractFrontmatter } = frontmatterMod;
15
18
  // Excluded derivative files
16
19
  const PLAN_OUTLINE_RE = /-OUTLINE\.md$/i;
17
20
  const PLAN_PRE_BOUNCE_RE = /\.pre-bounce\.md$/i;
21
+ const PLAN_REVIEW_RE = /-PLAN-REVIEW\.md$/i;
22
+ // #2349: a plan's frontmatter always sits at byte 0 and closes well before the
23
+ // body, so only a bounded prefix is ever needed to read the `status` marker.
24
+ // Capping the read keeps scanPhasePlans — which loops over every phase directory
25
+ // on hot paths (state sync/validate, roadmap progress) — from slurping a
26
+ // pathologically large committed plan file into memory just to inspect one key.
27
+ const PLAN_FRONTMATTER_READ_CAP = 64 * 1024;
28
+ /**
29
+ * #2349: a plan whose frontmatter declares `status: superseded` was deliberately
30
+ * reassigned or never executed — its work moved to a later plan, so it can never
31
+ * gain a matching `*-SUMMARY.md`. Like a retired phase (#1514, one level up), such
32
+ * a plan must be excluded from BOTH the plan and summary counts; otherwise a phase
33
+ * with a deliberately-unexecuted plan reads `completed: false` forever, pinning the
34
+ * milestone below 100%. Reading only the frontmatter `status` key is the same seam
35
+ * verify.cts / phase.cts already use for plan metadata; a plan without the marker is
36
+ * counted exactly as before.
37
+ *
38
+ * This is the only path in scanPhasePlans that opens file *contents* (the rest is
39
+ * filename matching), so it is hardened accordingly: `statSync().isFile()` rejects
40
+ * anything that is not a regular file — a directory, socket, or a symlink resolving
41
+ * to a device such as `/dev/zero` (a git-committable DoS vector; cf. #2378/#2383) —
42
+ * BEFORE any open, and the read is bounded to a fixed prefix. Fail-safe throughout:
43
+ * a non-regular or unreadable plan is treated as a normal (counted) plan, never
44
+ * silently dropped.
45
+ */
46
+ function isPlanSuperseded(planFullPath) {
47
+ let content;
48
+ try {
49
+ const st = (0, node_fs_1.statSync)(planFullPath); // follows symlinks → resolves to the target's real type
50
+ if (!st.isFile())
51
+ return false;
52
+ const length = Math.min(st.size, PLAN_FRONTMATTER_READ_CAP);
53
+ if (length === 0)
54
+ return false;
55
+ const fd = (0, node_fs_1.openSync)(planFullPath, 'r');
56
+ try {
57
+ const buf = Buffer.allocUnsafe(length);
58
+ const bytesRead = (0, node_fs_1.readSync)(fd, buf, 0, length, 0);
59
+ content = buf.toString('utf8', 0, bytesRead);
60
+ }
61
+ finally {
62
+ (0, node_fs_1.closeSync)(fd);
63
+ }
64
+ }
65
+ catch {
66
+ return false;
67
+ }
68
+ const status = extractFrontmatter(content)['status'];
69
+ return typeof status === 'string' && status.trim().toLowerCase() === 'superseded';
70
+ }
18
71
  function isRootPlanFile(fileName) {
19
72
  if (PLAN_OUTLINE_RE.test(fileName))
20
73
  return false;
21
74
  if (PLAN_PRE_BOUNCE_RE.test(fileName))
22
75
  return false;
76
+ if (PLAN_REVIEW_RE.test(fileName))
77
+ return false;
23
78
  if (fileName.endsWith('-PLAN.md') || fileName === 'PLAN.md')
24
79
  return true;
25
80
  // A summary is never a plan. Reject summaries before the loose /PLAN/i
@@ -72,7 +127,16 @@ function scanPhasePlans(phaseDir) {
72
127
  }
73
128
  catch { /* ignore unreadable nested layout */ }
74
129
  }
75
- const planFiles = rootPlanFiles.concat(nestedPlanFiles);
130
+ const allPlanFiles = rootPlanFiles.concat(nestedPlanFiles);
131
+ // #2349: drop plans explicitly marked `status: superseded` from the plan set
132
+ // BEFORE counting, so they inflate neither the denominator (planCount) nor,
133
+ // via countMatchedSummaries below, the numerator (summaryCount). Plans without
134
+ // the marker are untouched, so behaviour is byte-for-behaviour identical for
135
+ // every existing phase — only a phase carrying the new marker changes.
136
+ const supersededPlanFiles = allPlanFiles.filter((f) => isPlanSuperseded((0, node_path_1.join)(phaseDir, f)));
137
+ const planFiles = supersededPlanFiles.length === 0
138
+ ? allPlanFiles
139
+ : allPlanFiles.filter((f) => !supersededPlanFiles.includes(f));
76
140
  const summaryFiles = rootSummaryFiles.concat(nestedSummaryFiles);
77
141
  const planCount = planFiles.length;
78
142
  // Count only summaries that are the PLAN→SUMMARY partner of an existing plan
@@ -84,7 +148,14 @@ function scanPhasePlans(phaseDir) {
84
148
  return {
85
149
  planCount,
86
150
  summaryCount,
87
- completed: planCount > 0 && summaryCount >= planCount,
151
+ // #2349: gate completion on whether the phase had ANY plans on disk
152
+ // (allPlanFiles), NOT on the post-exclusion planCount. A phase whose plans
153
+ // were ALL marked superseded has planCount 0, but it is NOT an unplanned
154
+ // empty phase — there is simply no remaining work, so it must read complete
155
+ // (0 >= 0) rather than being pinned below 100% forever, which is the very
156
+ // failure this fix removes. A genuinely empty phase (no plans authored)
157
+ // still has allPlanFiles.length 0 and stays not-completed, exactly as before.
158
+ completed: allPlanFiles.length > 0 && summaryCount >= planCount,
88
159
  hasNestedPlans,
89
160
  planFiles,
90
161
  summaryFiles,
@@ -14,6 +14,7 @@
14
14
  * - ./phase-id.cjs (escapeRegex, phaseMarkdownRegexSource)
15
15
  * - ./planning-workspace.cjs (planningDir)
16
16
  * - ./shell-command-projection.cjs (platformReadSync)
17
+ * - ./markdown-sectionizer.cjs (tokenizeHeadings, stripTaggedBlocks, withSection)
17
18
  */
18
19
  var __importDefault = (this && this.__importDefault) || function (mod) {
19
20
  return (mod && mod.__esModule) ? mod : { "default": mod };
@@ -173,6 +174,56 @@ function replaceInCurrentMilestone(content, pattern, replacement) {
173
174
  const after = content.slice(offset);
174
175
  return before + after.replace(pattern, replacement);
175
176
  }
177
+ /**
178
+ * Resolve a single phase's detail-section heading (`### Phase N: …`, any level
179
+ * 1–6, via the #2121 phase-id source) and run `edit` against ONLY that
180
+ * section's body. Delegates to `withSection` (markdown-sectionizer.cjs), so a
181
+ * per-phase ROADMAP edit is structurally bounded to that phase's own section —
182
+ * it cannot escape into a sibling phase, a shipped-milestone `<details>` block,
183
+ * or a backticked prose literal (ADR-2143 §4).
184
+ *
185
+ * `content` is expected to already be scoped to the current milestone's raw
186
+ * range(s) by the caller (see `currentMilestoneRawRanges`) — `withPhaseSection`
187
+ * composes with that milestone-level scoping rather than replacing it.
188
+ *
189
+ * The matched phase number must be delimited by whitespace, a colon, an
190
+ * open-paren tag, or end-of-heading — never a bare `\b`. A trailing `\b` sits
191
+ * between the last digit and a following `.` or letter, so it would let a
192
+ * query for phase `1` prefix-match a decimal sub-phase heading like
193
+ * `### Phase 1.1: Sub` or a distinct suffixed phase like `### Phase 1A: …`.
194
+ *
195
+ * The phase token must additionally anchor to the START of the heading text
196
+ * (after an optional leading `[tag]`, mirroring `findRoadmapPhaseInContent`
197
+ * below) — never merely appear anywhere in it. Without this anchor, a query
198
+ * for phase `1` would match a SIBLING phase whose own TITLE happens to
199
+ * mention "Phase 1" (e.g. `### Phase 3: Migrate off Phase 1 legacy pipeline`),
200
+ * and — because `collectSection` picks the first matching heading in document
201
+ * order — that sibling would be hijacked instead of the real Phase 1 section.
202
+ *
203
+ * The section body is bounded by `{ levelBounded: false }`: it ends at the
204
+ * next ATX heading of ANY level, not merely a heading at or above the phase
205
+ * heading's own level. Real ROADMAPs are not guaranteed to use a uniform
206
+ * phase-heading level, so a level-bounded stop could fold a deeper sibling
207
+ * heading (e.g. a `####` phase following a `###` phase) into this phase's
208
+ * body and let `edit` reach into it.
209
+ */
210
+ function withPhaseSection(content, phaseId, edit) {
211
+ const src = phaseMarkdownRegexSource(phaseId);
212
+ const headingRe = new RegExp(`^\\s*(?:\\[[^\\]]{1,200}\\]\\s*)?Phase\\s+${src}(?=[\\s:(]|$)`, 'i');
213
+ return (0, markdown_sectionizer_cjs_1.withSection)(content, (h) => headingRe.test(h.text), edit, { levelBounded: false });
214
+ }
215
+ // ─── Roadmap phase lookup ─────────────────────────────────────────────────────
216
+ // #2199: a bullet/checkbox phase entry, e.g. `- [ ] **Phase 36 — Authentication**`
217
+ // (the bundled roadmapper emits this in bullet-house-style ROADMAPs). The number
218
+ // is captured in group 1, the name in group 2; the separator may be an em-dash,
219
+ // en-dash, hyphen, or colon. Used as a fallback when no ATX heading matches, and
220
+ // to count phases in a milestone that uses the bullet form.
221
+ const BULLET_PHASE_LINE_PATTERN = /^\s*[-*]\s+(?:\[[ xX]\]\s+)?\*\*Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]{0,200}\))?\s*[—–:\-]\s*(.+?)\*\*/im;
222
+ /** Build a bullet-phase-line regex pinned to a specific phase number (#2199). */
223
+ function bulletPhaseLineFor(phaseNum, phaseSource) {
224
+ const num = phaseSource ?? phaseMarkdownRegexSource(phaseNum);
225
+ return new RegExp(`^\\s*[-*]\\s+(?:\\[[ xX]\\]\\s+)?\\*\\*Phase\\s+(${num})${OPTIONAL_PHASE_TAG_SOURCE}\\s*[—–:\\-]\\s*(.+?)\\*\\*`, 'im');
226
+ }
176
227
  function findRoadmapPhaseInContent(content, phaseNum, phaseSource) {
177
228
  // #1729: OPTIONAL_PHASE_TAG_SOURCE after the number tolerates a pre-colon ( ) tag.
178
229
  const headingPattern = new RegExp(`^(?:\\[[^\\]]{1,200}\\]\\s*)?Phase\\s+${phaseSource ?? phaseMarkdownRegexSource(phaseNum)}${OPTIONAL_PHASE_TAG_SOURCE}:\\s*(.+)$`, 'i');
@@ -198,6 +249,22 @@ function findRoadmapPhaseInContent(content, phaseNum, phaseSource) {
198
249
  section,
199
250
  };
200
251
  }
252
+ function findRoadmapBulletPhaseInContent(content, phaseNum, phaseSource) {
253
+ // #2199: bullet/checkbox entry fallback (`- [ ] **Phase N — name**`). Returns
254
+ // the single bullet line as the section (no multi-line body) — used only as a
255
+ // last resort, AFTER heading lookup on scoped + full content has failed, so a
256
+ // heading with a Requirements/Goal section always wins.
257
+ const bulletMatch = content.match(bulletPhaseLineFor(phaseNum, phaseSource));
258
+ if (!bulletMatch)
259
+ return null;
260
+ return {
261
+ found: true,
262
+ phase_number: String(phaseNum),
263
+ phase_name: bulletMatch[2].trim(),
264
+ goal: null,
265
+ section: bulletMatch[0].trim(),
266
+ };
267
+ }
201
268
  function getRoadmapPhaseInternal(cwd, phaseNum) {
202
269
  if (!phaseNum)
203
270
  return null;
@@ -221,12 +288,35 @@ function getRoadmapPhaseInternal(cwd, phaseNum) {
221
288
  if (fullResult)
222
289
  return fullResult;
223
290
  }
291
+ // #2199: no ATX heading matched on scoped or full content — fall back to a
292
+ // bullet/checkbox entry (em-dash/en-dash/hyphen/colon separator). Last resort
293
+ // so a bullet never pre-empts a heading that carries the Requirements section.
294
+ for (const source of roadmapPhaseLookupSources(phaseNum)) {
295
+ const scopedBullet = findRoadmapBulletPhaseInContent(content, phaseNum, source);
296
+ if (scopedBullet)
297
+ return scopedBullet;
298
+ const fullBullet = findRoadmapBulletPhaseInContent(fullContent, phaseNum, source);
299
+ if (fullBullet)
300
+ return fullBullet;
301
+ }
224
302
  return null;
225
303
  }
226
304
  catch {
227
305
  return null;
228
306
  }
229
307
  }
308
+ /**
309
+ * Strip a leading delimiter run (whitespace, em/en-dash, colon, hyphen) from a
310
+ * milestone-name capture. Markdown headings commonly take the shape
311
+ * `## vX.Y — Name` or `## vX.Y: Name`; the raw capture includes the delimiter
312
+ * because `.trim()` only removes whitespace, not punctuation. A name beginning
313
+ * with punctuation is a delimiter-led fragment, not the curated name (#2135).
314
+ * NOTE: do not strip `#` — a name beginning with `#` is a heading-parse failure
315
+ * that should stay loud rather than be silently cleaned.
316
+ */
317
+ function stripLeadingDelimiter(s) {
318
+ return s.replace(/^[\s—–:-]+/, '').trim();
319
+ }
230
320
  function getMilestoneInfo(cwd) {
231
321
  try {
232
322
  const roadmap = (0, shell_command_projection_cjs_1.platformReadSync)(node_path_1.default.join(planningDir(cwd), 'ROADMAP.md'));
@@ -243,23 +333,39 @@ function getMilestoneInfo(cwd) {
243
333
  stateVersion = m[1].trim();
244
334
  }
245
335
  }
246
- catch { /* intentionally empty */ }
336
+ catch {
337
+ /* best-effort (#2245 audit): platformReadSync re-throws for a non-ENOENT
338
+ * failure (e.g. EACCES) reading STATE.md. Consulting STATE.md's
339
+ * `milestone:` field is an OPTIONAL enhancement here — on failure this
340
+ * function already falls back to ROADMAP-only heuristics below, the
341
+ * same fallback path taken when STATE.md simply doesn't exist. */
342
+ }
247
343
  }
248
344
  if (stateVersion) {
249
345
  const escapedVer = escapeRegex(stateVersion);
250
- const headingMatch = roadmap.match(new RegExp(`##[^\\n]*${escapedVer}[:\\s]+([^\\n(]+)`, 'i'));
251
- if (headingMatch) {
252
- if (!headingMatch[0].includes('✅')) {
253
- return { version: stateVersion, name: headingMatch[1].trim() };
254
- }
346
+ // #2135: consult the 🚧 name-bearing marker FIRST. It is the only construct
347
+ // guaranteed to carry the milestone's curated name adjacent to its version
348
+ // (the active-milestone bullet). A `##` heading is often nameless
349
+ // ("## vX.Y — Active Milestone") and, when unanchored, was matched
350
+ // spuriously on a copy quoted inside backticks in this very bullet.
351
+ const listMatch = roadmap.match(new RegExp(`🚧\\s*\\*?\\*?${escapedVer}\\s+([^*\\n]+)`, 'i'));
352
+ if (listMatch) {
353
+ const name = stripLeadingDelimiter(listMatch[1]);
354
+ if (name)
355
+ return { version: stateVersion, name };
255
356
  }
256
- else {
257
- const listMatch = roadmap.match(new RegExp(`🚧\\s*\\*?\\*?${escapedVer}\\s+([^*\\n]+)`, 'i'));
258
- if (listMatch) {
259
- return { version: stateVersion, name: listMatch[1].trim() };
260
- }
261
- return { version: stateVersion, name: 'milestone' };
357
+ // Fall back to the `##` heading — ANCHORED to line start (`^` + `m` flag)
358
+ // so a heading quoted inside backticks or prose mid-line can no longer
359
+ // match. Skip shipped (✅) headings.
360
+ const headingMatch = roadmap.match(new RegExp(`^##[^\\n]*${escapedVer}[:\\s]+([^\\n(]+)`, 'im'));
361
+ if (headingMatch && !headingMatch[0].includes('✅')) {
362
+ // Strip a leading delimiter — `.trim()` removes whitespace, not the
363
+ // em-dash/colon that conventionally separates version from name.
364
+ const name = stripLeadingDelimiter(headingMatch[1]);
365
+ if (name)
366
+ return { version: stateVersion, name };
262
367
  }
368
+ return { version: stateVersion, name: 'milestone' };
263
369
  }
264
370
  const inProgressMatch = roadmap.match(/🚧\s*\*\*v(\d+(?:\.\d+)+)\s+([^*]+)\*\*/);
265
371
  if (inProgressMatch) {
@@ -374,8 +480,26 @@ function getMilestonePhaseFilter(cwd, versionOverride, phaseIdConvention) {
374
480
  if (pm && !/^999\b/.test(pm[1]))
375
481
  milestonePhaseNums.add(pm[1]);
376
482
  }
483
+ // #2199: also count bullet/checkbox phase entries (`- [ ] **Phase N — name**`)
484
+ // so a bullet-house-style ROADMAP populates the milestone phase set instead of
485
+ // collapsing to a zero-count pass-all filter.
486
+ {
487
+ let bm;
488
+ const scanner = new RegExp(BULLET_PHASE_LINE_PATTERN.source, 'gim');
489
+ while ((bm = scanner.exec(roadmap)) !== null) {
490
+ if (!/^999\b/.test(bm[1]))
491
+ milestonePhaseNums.add(bm[1]);
492
+ }
493
+ }
494
+ }
495
+ catch {
496
+ /* best-effort (#2245 audit): the real throw source is platformReadSync
497
+ * at the top of this try (re-throws for a non-ENOENT read failure). On
498
+ * any failure milestonePhaseNums stays empty, which below already
499
+ * degrades to the same pass-all filter this function returns when a
500
+ * ROADMAP genuinely has zero recognizable phase headings — a safe,
501
+ * non-corrupting (over-inclusive, never under-inclusive) degrade. */
377
502
  }
378
- catch { /* intentionally empty */ }
379
503
  if (milestonePhaseNums.size === 0) {
380
504
  const passAll = (() => true);
381
505
  passAll.phaseCount = 0;
@@ -387,12 +511,15 @@ function getMilestonePhaseFilter(cwd, versionOverride, phaseIdConvention) {
387
511
  return id.split('-').map(seg => seg.replace(/^0+(?=\d)/, '') || '0').join('-');
388
512
  }
389
513
  const roadmapUsesHyphenedIds = [...normalized].some(n => n.includes('-'));
390
- // #2043: milestone-prefixed sub-phase components must be zero-padded (≥2 digits)
391
- // — "-\d{2,}" instead of "-0*\d+" — so a single-digit slug word after the phase
514
+ // #2043: milestone-prefixed sub-phase components must be zero-padded — so a
515
+ // single-digit slug word after the phase
392
516
  // number (e.g. dir "46-6-rs-…") captures "46" and is not silently excluded from
393
- // the milestone as a bogus "46-6" id.
517
+ // the milestone as a bogus "46-6" id. #2232: the continuation width is exactly 2
518
+ // (PHASE_CONTINUATION_SEGMENT_SOURCE), so a year-leading slug word (dir
519
+ // "14-2026-photos-…") captures "14" and is not excluded as a bogus "14-2026" id.
520
+ // Built via new RegExp (no /i — the [A-Za-z] letter class does real case handling).
394
521
  const numericRe = roadmapUsesHyphenedIds
395
- ? /^0*(\d+(?:-\d{2,})*[A-Za-z]?(?:\.\d+)*)/
522
+ ? new RegExp(`^0*(\\d+(?:-${phaseIdModule.PHASE_CONTINUATION_SEGMENT_SOURCE})*[A-Za-z]?(?:\\.\\d+)*)`)
396
523
  // phase-id-owner: the [A-Za-z] letter class does real case handling here — this regex carries NO /i flag; kept literal, not source-byte-equal to the canonical PHASE_NUMBER_TOKEN_SOURCE.
397
524
  : /^0*(\d+[A-Za-z]?(?:\.\d+)*)/;
398
525
  function isDirInMilestone(dirName) {
@@ -414,6 +541,85 @@ function getMilestonePhaseFilter(cwd, versionOverride, phaseIdConvention) {
414
541
  isDirInMilestone.missingExplicitVersion = missingExplicitVersion;
415
542
  return isDirInMilestone;
416
543
  }
544
+ /**
545
+ * #2200: raw [start,end) offsets of the current milestone's region(s) in ROADMAP
546
+ * content, for scoping write-path mutations (phase-checkbox flip, Plans-count
547
+ * writer) so they cannot touch a backticked prose literal, a Backlog entry, or a
548
+ * same-numbered phase in a shipped milestone.
549
+ *
550
+ * Mirrors the region selection in `extractCurrentMilestone` (version detection →
551
+ * active heading → next milestone boundary → optional Phase Details section).
552
+ * Returns null when there is no versioned active milestone; callers then fall
553
+ * back to whole-content mutation (the prior behaviour).
554
+ *
555
+ * NOTE: keep the region logic here in sync with extractCurrentMilestone.
556
+ */
557
+ function currentMilestoneRawRanges(content, cwd) {
558
+ if (!cwd)
559
+ return null;
560
+ let version = null;
561
+ try {
562
+ const statePath = node_path_1.default.join(planningDir(cwd), 'STATE.md');
563
+ const stateRaw = (0, shell_command_projection_cjs_1.platformReadSync)(statePath);
564
+ if (stateRaw !== null) {
565
+ const milestoneMatch = stateRaw.match(/^milestone:\s*(.+)/m);
566
+ if (milestoneMatch)
567
+ version = milestoneMatch[1].trim();
568
+ }
569
+ }
570
+ catch { /* ignore */ }
571
+ if (!version) {
572
+ const inProgressMatch = content.match(/(?:🚧|🔄)\s*\*\*v(\d+\.\d+)\s/);
573
+ if (inProgressMatch)
574
+ version = 'v' + inProgressMatch[1];
575
+ }
576
+ if (!version)
577
+ return null;
578
+ const escapedVersion = escapeRegex(version);
579
+ const sectionPattern = new RegExp(`(^#{1,3}\\s+(?!Phase\\s+\\S).*${escapedVersion}\\b[^\\n]*)`, 'gmi');
580
+ const headingMatches = [...content.matchAll(sectionPattern)];
581
+ if (headingMatches.length === 0)
582
+ return null;
583
+ const closedMarkerPattern = /\b(?:CLOSED|ARCHIVED|ABANDONED|SHIPPED|FAILED)\b|✅|🗄/i;
584
+ const activeMarkerPattern = /\b(?:STARTED|ACTIVE|WIP)\b|in\s+progress|🚧|🔄/i;
585
+ const isClosed = (h) => closedMarkerPattern.test(h) && !activeMarkerPattern.test(h);
586
+ const firstMatch = headingMatches[0];
587
+ const selected = headingMatches.find((m) => !isClosed(m[1])) || firstMatch;
588
+ const sectionStart = selected.index ?? 0;
589
+ const computeSectionEnd = (headingText, headingStart) => {
590
+ const level = (headingText.match(/^(#{1,3})\s/) ?? ['', '#'])[1].length;
591
+ const afterHeading = headingStart + headingText.length;
592
+ for (const h of (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content)) {
593
+ if (h.offset <= headingStart)
594
+ continue;
595
+ if (h.offset < afterHeading)
596
+ continue;
597
+ if (h.level > level)
598
+ continue;
599
+ if (/^Phase\s+\S/i.test(h.text))
600
+ continue;
601
+ if (!/v\d+\.\d+|✅|📋|🚧/i.test(h.text))
602
+ continue;
603
+ return h.offset;
604
+ }
605
+ return content.length;
606
+ };
607
+ const sectionEnd = computeSectionEnd(selected[0], sectionStart);
608
+ const selectedVersionToken = selected[1].match(/v\d+(?:\.\d+)+(?:[-.][A-Za-z0-9]+)*/i)?.[0];
609
+ const detailsVersionBoundary = selectedVersionToken
610
+ ? new RegExp(`${escapeRegex(selectedVersionToken)}(?![\\w.-])`, 'i')
611
+ : null;
612
+ const detailsMatch = headingMatches.find((m) => /\(Phase\s+Details\)/i.test(m[1]) &&
613
+ !isClosed(m[1]) &&
614
+ (!detailsVersionBoundary || detailsVersionBoundary.test(m[1])) &&
615
+ (m.index ?? 0) >= sectionEnd);
616
+ let details = null;
617
+ if (detailsMatch) {
618
+ const detailsStart = detailsMatch.index ?? 0;
619
+ details = { start: detailsStart, end: computeSectionEnd(detailsMatch[0], detailsStart) };
620
+ }
621
+ return { primary: { start: sectionStart, end: sectionEnd }, details };
622
+ }
417
623
  module.exports = {
418
624
  stripShippedMilestones,
419
625
  extractCurrentMilestone,
@@ -421,4 +627,6 @@ module.exports = {
421
627
  getRoadmapPhaseInternal,
422
628
  getMilestoneInfo,
423
629
  getMilestonePhaseFilter,
630
+ currentMilestoneRawRanges,
631
+ withPhaseSection,
424
632
  };
@@ -25,6 +25,7 @@ const { findPhaseInternal } = phaseLocatorMod;
25
25
  const roadmapParserModule = require("./roadmap-parser.cjs");
26
26
  const { stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone } = roadmapParserModule;
27
27
  const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
28
+ const markdown_table_cjs_1 = require("./markdown-table.cjs");
28
29
  const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
29
30
  // eslint-disable-next-line @typescript-eslint/no-require-imports
30
31
  const planningWorkspace = require("./planning-workspace.cjs");
@@ -169,9 +170,19 @@ function getRoadmapPhaseWithFallback(cwd, phaseNum) {
169
170
  if (/^999(?:\.|$)/.test(stripProjectCodePrefix(phaseNum)))
170
171
  return null;
171
172
  const roadmapPath = planningPaths(cwd).roadmap;
172
- if (!node_fs_1.default.existsSync(roadmapPath))
173
- return null;
174
- const rawContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
173
+ // Read directly rather than gating on fs.existsSync: existsSync returns false
174
+ // on EACCES/EIO too, which would mask an UNREADABLE roadmap as "missing" and
175
+ // let a blocking gate certify empty scope (#2365 review). Honor the documented
176
+ // contract — null only when genuinely absent (ENOENT), otherwise throw.
177
+ let rawContent;
178
+ try {
179
+ rawContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
180
+ }
181
+ catch (err) {
182
+ if (err?.code === 'ENOENT')
183
+ return null;
184
+ throw err;
185
+ }
175
186
  const milestoneContent = extractCurrentMilestone(rawContent, cwd);
176
187
  const fullContent = stripShippedMilestones(rawContent);
177
188
  // #2121/#2114: iterate the shared lookup-source list (exact → numeric →
@@ -297,29 +308,32 @@ function cmdRoadmapAnalyze(cwd, raw) {
297
308
  let summaryCount = 0;
298
309
  let hasContext = false;
299
310
  let hasResearch = false;
300
- try {
301
- const dirMatch = _phaseDirNames.find(d => phaseTokenMatches(d, normalized));
302
- if (dirMatch) {
303
- const counts = countPhasePlansAndSummaries(node_path_1.default.join(phasesDir, dirMatch));
304
- planCount = counts.planCount;
305
- summaryCount = counts.summaryCount;
306
- hasContext = counts.hasContext;
307
- hasResearch = counts.hasResearch;
308
- if (summaryCount >= planCount && planCount > 0)
309
- diskStatus = 'complete';
310
- else if (summaryCount > 0)
311
- diskStatus = 'partial';
312
- else if (planCount > 0)
313
- diskStatus = 'planned';
314
- else if (hasResearch)
315
- diskStatus = 'researched';
316
- else if (hasContext)
317
- diskStatus = 'discussed';
318
- else
319
- diskStatus = 'empty';
320
- }
311
+ // DEAD catch removed (#2245 audit): _phaseDirNames.find(...) is a pure
312
+ // array lookup on an already-resolved string array, and
313
+ // countPhasePlansAndSummaries is itself fully defensive (its own
314
+ // readdirSync is self-guarded, and it delegates to scanPhasePlans, which
315
+ // never throws) — nothing in this block can throw, so the try/catch could
316
+ // never be triggered.
317
+ const dirMatch = _phaseDirNames.find(d => phaseTokenMatches(d, normalized));
318
+ if (dirMatch) {
319
+ const counts = countPhasePlansAndSummaries(node_path_1.default.join(phasesDir, dirMatch));
320
+ planCount = counts.planCount;
321
+ summaryCount = counts.summaryCount;
322
+ hasContext = counts.hasContext;
323
+ hasResearch = counts.hasResearch;
324
+ if (summaryCount >= planCount && planCount > 0)
325
+ diskStatus = 'complete';
326
+ else if (summaryCount > 0)
327
+ diskStatus = 'partial';
328
+ else if (planCount > 0)
329
+ diskStatus = 'planned';
330
+ else if (hasResearch)
331
+ diskStatus = 'researched';
332
+ else if (hasContext)
333
+ diskStatus = 'discussed';
334
+ else
335
+ diskStatus = 'empty';
321
336
  }
322
- catch { /* intentionally empty */ }
323
337
  // Check ROADMAP checkbox status.
324
338
  // #3537: padding-tolerant fragment — the heading discovered above may use
325
339
  // a different padding than the summary-bullet checkbox below it (mixed
@@ -392,6 +406,39 @@ function cmdRoadmapAnalyze(cwd, raw) {
392
406
  output(result, raw, undefined);
393
407
  }
394
408
  // ─── cmdRoadmapUpdatePlanProgress ─────────────────────────────────────────────
409
+ /**
410
+ * Scope a ROADMAP.md content string down to its "Progress table" writable
411
+ * slice, run `edit` against just that slice, then splice the result back into
412
+ * the original content (ADR-2143 §7). Layered scoping:
413
+ * 1. Milestone scope — everything after the LAST `</details>` close tag
414
+ * (mirrors `replaceInCurrentMilestone`), so a same-numbered phase row in
415
+ * an archived milestone is never touched.
416
+ * 2. Heading scope — within that milestone slice, the `## Progress` heading
417
+ * section (up to the next `#`/`##` heading) when present, else the whole
418
+ * milestone slice (mirrors phase-lifecycle.cjs's `deriveProgressFromRoadmap`
419
+ * read-side scoping, #2012 decoy avoidance — a differently-headed table
420
+ * sharing the same column names must not be picked up instead).
421
+ * `edit` always returns a string and never fails — a no-op edit (table/row not
422
+ * found within the scoped slice) simply returns its input unchanged, mirroring
423
+ * the prior regex `.replace()`'s no-match-is-a-no-op semantics.
424
+ */
425
+ function editProgressTableSlice(content, edit) {
426
+ const lastDetailsClose = content.lastIndexOf('</details>');
427
+ const milestoneOffset = lastDetailsClose === -1 ? 0 : lastDetailsClose + '</details>'.length;
428
+ const before = content.slice(0, milestoneOffset);
429
+ const milestoneSlice = content.slice(milestoneOffset);
430
+ const progressMatch = milestoneSlice.match(/^##[ \t]+Progress\b/im);
431
+ if (!progressMatch || progressMatch.index === undefined) {
432
+ return before + edit(milestoneSlice);
433
+ }
434
+ const headingOffset = progressMatch.index;
435
+ const beforeHeading = milestoneSlice.slice(0, headingOffset);
436
+ const fromHeading = milestoneSlice.slice(headingOffset);
437
+ const nextHeading = fromHeading.search(/\n#{1,2}[ \t]/);
438
+ const scoped = nextHeading >= 0 ? fromHeading.slice(0, nextHeading) : fromHeading;
439
+ const after = nextHeading >= 0 ? fromHeading.slice(nextHeading) : '';
440
+ return before + beforeHeading + edit(scoped) + after;
441
+ }
395
442
  function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
396
443
  if (!phaseNum) {
397
444
  error('phase number required for roadmap update-plan-progress');
@@ -418,7 +465,7 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
418
465
  const verificationPassed = readVerificationStatus(phaseDir).status === 'passed';
419
466
  const isComplete = summaryCount >= planCount && verificationPassed;
420
467
  const status = isComplete ? 'Complete' : summaryCount > 0 ? 'In Progress' : 'Planned';
421
- const today = clock_cjs_1.realClock.today();
468
+ const today = clock_cjs_1.realClock.localToday();
422
469
  if (!node_fs_1.default.existsSync(roadmapPath)) {
423
470
  output({ updated: false, reason: 'ROADMAP.md not found', plan_count: planCount, summary_count: summaryCount }, raw, 'no roadmap');
424
471
  return;
@@ -427,32 +474,46 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
427
474
  withPlanningLock(cwd, () => {
428
475
  let roadmapContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
429
476
  const phasePattern = phaseMarkdownRegexSource(phaseNum);
430
- // Progress table row: update Plans/Status/Date columns (handles 4 or 5 column tables)
431
- const tableRowPattern = new RegExp(`^(\\|\\s*${phasePattern}\\.?\\s[^|]*(?:\\|[^\\n]*))$`, 'im');
432
- roadmapContent = roadmapContent.replace(tableRowPattern, (fullRow) => {
433
- const cells = fullRow.split('|').slice(1, -1); // drop leading/trailing empty from split
434
- const dateShape = /^\d{4}-\d{2}-\d{2}$/;
435
- if (cells.length === 5) {
436
- // 5-col: Phase | Milestone | Plans | Status | Completed
437
- cells[2] = ` ${summaryCount}/${planCount} `;
438
- cells[3] = ` ${status.padEnd(11)}`;
439
- // Preserve only a valid ISO date (#1161: idempotent; self-heal garbage)
440
- const existingDate5 = cells[4].trim();
441
- cells[4] = isComplete
442
- ? (dateShape.test(existingDate5) ? cells[4] : ` ${today} `)
443
- : ' ';
444
- }
445
- else if (cells.length === 4) {
446
- // 4-col: Phase | Plans | Status | Completed
447
- cells[1] = ` ${summaryCount}/${planCount} `;
448
- cells[2] = ` ${status.padEnd(11)}`;
449
- // Preserve only a valid ISO date (#1161: idempotent; self-heal garbage)
450
- const existingDate4 = cells[3].trim();
451
- cells[3] = isComplete
452
- ? (dateShape.test(existingDate4) ? cells[3] : ` ${today} `)
453
- : ' ';
454
- }
455
- return '|' + cells.join('|') + '|';
477
+ // Progress table row: update Plans Complete/Status/Completed columns BY
478
+ // COLUMN NAME (handles 4- or 5-column RoadmapProgress tables regardless of
479
+ // Milestone-column presence) via the markdown-table seam (ADR-2143 §7) —
480
+ // supersedes the prior ordinal cells[]-index regex. Scoped to the current
481
+ // milestone's `## Progress` table (editProgressTableSlice above).
482
+ // #2245 Blocker 4: optional dot must be followed by whitespace-or-end, not
483
+ // dot-OR-whitespace-OR-end as alternatives — the prior form let a bare "."
484
+ // satisfy the whole lookahead, so completing phase "2" over-matched a
485
+ // decimal sub-phase row like "2.5 Extra". Matches "2", "2.", "2 Alpha";
486
+ // rejects "2.5 Extra" (replicates OLD's `\.?\s` intent on the now-TRIMMED
487
+ // cell value, where end-of-string is the trimmed equivalent of "no more
488
+ // characters after the optional dot").
489
+ const phaseCellRe = new RegExp(`^${phasePattern}\\.?(?:\\s|$)`, 'i');
490
+ const rowMatch = (row) => phaseCellRe.test((row['Phase'] ?? '').trim());
491
+ const dateShape = /^\d{4}-\d{2}-\d{2}$/;
492
+ roadmapContent = editProgressTableSlice(roadmapContent, (scoped) => {
493
+ let text = scoped;
494
+ const plansResult = (0, markdown_table_cjs_1.updateTableCell)(text, rowMatch, 'Plans Complete', ` ${summaryCount}/${planCount} `);
495
+ if (plansResult.ok)
496
+ text = plansResult.value;
497
+ const statusResult = (0, markdown_table_cjs_1.updateTableCell)(text, rowMatch, 'Status', ` ${status.padEnd(11)}`);
498
+ if (statusResult.ok)
499
+ text = statusResult.value;
500
+ // Preserve only a valid ISO date (#1161: idempotent; self-heal garbage).
501
+ // Ragged-tolerant (#2245 Blocker 2): probe the CURRENT Completed cell via
502
+ // a no-op updateTableCell write (its own tolerant row scan) rather than
503
+ // findTableWithColumns (which requires the WHOLE table to parse — a
504
+ // ragged SIBLING row elsewhere used to silently no-op this row's date
505
+ // stamp/clear too). The decision (write vs no-op) is folded into the
506
+ // newValue callback so a single updateTableCell call both reads and
507
+ // writes.
508
+ const completedResult = (0, markdown_table_cjs_1.updateTableCell)(text, rowMatch, 'Completed', (current) => {
509
+ if (isComplete) {
510
+ return dateShape.test(current.trim()) ? current : ` ${today} `;
511
+ }
512
+ return ' ';
513
+ });
514
+ if (completedResult.ok)
515
+ text = completedResult.value;
516
+ return text;
456
517
  });
457
518
  // Update plan count in phase detail section.
458
519
  // Three recognised forms (all tolerated; canonical template uses the first):