@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
@@ -38,7 +38,7 @@ const phaseLocatorMod = require("./phase-locator.cjs");
38
38
  const { findPhaseInternal, getArchivedPhaseDirs } = phaseLocatorMod;
39
39
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- roadmap-parser.cjs is an export= CommonJS module
40
40
  const roadmapParserMod = require("./roadmap-parser.cjs");
41
- const { stripShippedMilestones, extractCurrentMilestone, getMilestonePhaseFilter } = roadmapParserMod;
41
+ const { stripShippedMilestones, extractCurrentMilestone, getMilestonePhaseFilter, currentMilestoneRawRanges, withPhaseSection } = roadmapParserMod;
42
42
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- planning-workspace.cjs is an export= CommonJS module
43
43
  const planningWorkspace = require("./planning-workspace.cjs");
44
44
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- frontmatter.cjs is an export= CommonJS module
@@ -49,6 +49,8 @@ const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs")
49
49
  const runtime_slash_cjs_1 = require("./runtime-slash.cjs");
50
50
  const clock_cjs_1 = require("./clock.cjs");
51
51
  const state_transition_cjs_1 = require("./state-transition.cjs");
52
+ const markdown_table_cjs_1 = require("./markdown-table.cjs");
53
+ const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
52
54
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- uat-predicate.cjs is an export= CommonJS module
53
55
  const uatPredicate = require("./uat-predicate.cjs");
54
56
  const { evaluateUatPassed } = uatPredicate;
@@ -67,6 +69,56 @@ const looksLikePlanFile = (f) => /\.md$/i.test(f) &&
67
69
  /PLAN/i.test(f) &&
68
70
  !PLAN_OUTLINE_RE.test(f) &&
69
71
  !PLAN_PRE_BOUNCE_RE.test(f);
72
+ /**
73
+ * Scope an `updateTableCell` call to the `## Traceability` (or
74
+ * `## Traceability Status`) heading's own section — up to the next H1/H2
75
+ * heading — instead of handing it the WHOLE REQUIREMENTS.md content.
76
+ *
77
+ * F1 (#2245 review, BLOCKER): `updateTableCell` binds to the FIRST GFM table
78
+ * found in whatever text it is given. The shipped requirements template
79
+ * (gsd-core/templates/requirements.md) puts an `## Out of Scope` table
80
+ * (`| Feature | Reason |`, no `Status` column) BEFORE `## Traceability` — so
81
+ * an unscoped whole-file call targets the Out-of-Scope table instead, fails
82
+ * with `{ok:false, reason:'unknown column: Status'}`, and the real
83
+ * Traceability row is never flipped, while the checkbox surface still flips
84
+ * and the command reports success (the #2140 silent-divergence class one
85
+ * level deeper). Mirrors `editProgressHeadingSlice` below, which scopes
86
+ * `## Progress` writes to that heading's own slice for the same reason.
87
+ *
88
+ * Falls back to running `updateTableCell` against the whole `text` when no
89
+ * `## Traceability` heading exists — matching the previous (unscoped)
90
+ * behaviour for a REQUIREMENTS.md whose traceability table sits under some
91
+ * other heading, or with no heading at all (never worse than before this fix).
92
+ */
93
+ function updateTraceabilityCell(text, match, column, newValue) {
94
+ const headingMatch = text.match(/^##[ \t]+Traceability(?:[ \t]+Status)?\b/im);
95
+ if (!headingMatch || headingMatch.index === undefined) {
96
+ return (0, markdown_table_cjs_1.updateTableCell)(text, match, column, newValue);
97
+ }
98
+ const headingOffset = headingMatch.index;
99
+ const before = text.slice(0, headingOffset);
100
+ const fromHeading = text.slice(headingOffset);
101
+ const nextHeadingOffset = fromHeading.search(/\n#{1,2}[ \t]/);
102
+ const scoped = nextHeadingOffset >= 0 ? fromHeading.slice(0, nextHeadingOffset) : fromHeading;
103
+ const after = nextHeadingOffset >= 0 ? fromHeading.slice(nextHeadingOffset) : '';
104
+ const result = (0, markdown_table_cjs_1.updateTableCell)(scoped, match, column, newValue);
105
+ if (!result.ok)
106
+ return result;
107
+ return { ok: true, value: before + result.value + after };
108
+ }
109
+ /**
110
+ * Extract the MAJOR version segment from a version-ish string: "v1", "v1.3",
111
+ * "V1.0", and "1.0" all yield "1"; "v2" yields "2". Used (#2334 BLOCKER fix)
112
+ * to compare a `## v<N> ...` REQUIREMENTS.md heading against the current
113
+ * milestone's version at MAJOR-version granularity only — "v1" heading vs
114
+ * milestone "v1.3" is the SAME major version and must not be treated as a
115
+ * version mismatch. Returns null when `raw` has no leading digit run (not a
116
+ * version-shaped string), which the caller treats as "cannot resolve".
117
+ */
118
+ function extractMajorVersion(raw) {
119
+ const m = raw.trim().match(/^v?(\d+)/i);
120
+ return m ? m[1] : null;
121
+ }
70
122
  function describeNonCanonicalPlans(dirFiles, matchedFiles) {
71
123
  const matched = new Set(matchedFiles);
72
124
  const offenders = dirFiles.filter((f) => looksLikePlanFile(f) && !matched.has(f));
@@ -88,8 +140,13 @@ function extractCanonicalPlanId(filename) {
88
140
  // or a single-digit-plus-letter id ("3A"); a *bare* single digit is a slug word,
89
141
  // so "46-6-rs-…" is not paired into a "46-6" id while "3A-01" stays intact.
90
142
  const tokenRe = /^(?:\d{2,}[A-Z]?|\d[A-Z])(?:\.\d+)*$/i;
143
+ // #2232: the PAIRED plan component is a zero-padded continuation segment
144
+ // (exactly 2 digits), so a ≥3-digit slug word (a year) is not paired into a
145
+ // bogus "14-2026" id. The leading phase component keeps tokenRe's unbounded
146
+ // \d{2,} — phase numbers ≥100 are legitimate; only continuations are capped.
147
+ const planTokenRe = new RegExp(`^(?:${phaseIdMod.PHASE_CONTINUATION_SEGMENT_SOURCE}[A-Z]?|\\d[A-Z])(?:\\.\\d+)*$`, 'i');
91
148
  const phaseIdx = parts.findIndex((p) => tokenRe.test(p));
92
- if (phaseIdx >= 0 && phaseIdx + 1 < parts.length && tokenRe.test(parts[phaseIdx + 1])) {
149
+ if (phaseIdx >= 0 && phaseIdx + 1 < parts.length && planTokenRe.test(parts[phaseIdx + 1])) {
93
150
  return `${parts[phaseIdx]}-${parts[phaseIdx + 1]}`;
94
151
  }
95
152
  return base;
@@ -313,9 +370,21 @@ function cmdFindPhase(cwd, phase, raw) {
313
370
  .filter((e) => e.isDirectory())
314
371
  .map((e) => e.name)
315
372
  .sort((a, b) => comparePhaseNum(a, b));
316
- const match = dirs.find((d) => phaseTokenMatches(d, normalized));
317
- if (!match)
373
+ // #2237: fail loud when multiple directories match the same bare phase
374
+ // number — prevents cross-project file writes when unrelated projects
375
+ // share a .planning/phases/ tree.
376
+ const matches = dirs.filter((d) => phaseTokenMatches(d, normalized));
377
+ if (matches.length === 0)
318
378
  continue;
379
+ if (matches.length > 1) {
380
+ output({
381
+ ...notFound,
382
+ ambiguous_matches: matches,
383
+ warning: `Phase ${normalized} is ambiguous: ${matches.length} directories match (${matches.map(m => `"${m}"`).join(', ')}). Set a distinct project_code in .planning/config.json to scope resolution.`,
384
+ }, raw, '');
385
+ return;
386
+ }
387
+ const match = matches[0];
319
388
  const dirMatch = match.match(new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}(${PHASE_NUMBER_TOKEN_SOURCE})-?(.*)`, 'i')) || match.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})-?(.*)`, 'i'));
320
389
  const phaseNumber = dirMatch ? dirMatch[1] : normalized;
321
390
  const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null;
@@ -552,6 +621,29 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
552
621
  result['warnings'] = warnings;
553
622
  output(result, raw);
554
623
  }
624
+ // #2390 — phase.add title-shape heuristic. A description at or under this many
625
+ // characters, and with no sentence-ending punctuation followed by more text,
626
+ // reads as a short Title. Anything longer or multi-sentence reads as a Goal,
627
+ // not a Title. phase.add still writes the phase verbatim (it never mangles
628
+ // ROADMAP.md), but when the description looks goal-shaped the JSON result
629
+ // gains a `warning` key naming the gap, so the caller — or the orchestrating
630
+ // add-phase workflow — can split title vs. goal instead of the whole paragraph
631
+ // landing silently in the `### Phase N:` header.
632
+ const PHASE_ADD_TITLE_MAX_LEN = 80;
633
+ const PHASE_ADD_MULTI_SENTENCE_RE = /[.!?]['")\]]?\s+\S/;
634
+ function describeGoalShapedTitle(description) {
635
+ const trimmed = description.trim();
636
+ const tooLong = trimmed.length > PHASE_ADD_TITLE_MAX_LEN;
637
+ const multiSentence = PHASE_ADD_MULTI_SENTENCE_RE.test(trimmed);
638
+ if (!tooLong && !multiSentence)
639
+ return null;
640
+ const reasons = [
641
+ tooLong ? `${trimmed.length} chars (over the ${PHASE_ADD_TITLE_MAX_LEN}-char title threshold)` : null,
642
+ multiSentence ? 'multiple sentences' : null,
643
+ ].filter(Boolean).join(', ');
644
+ return (`description looks goal-shaped, not title-shaped (${reasons}). It was written verbatim ` +
645
+ `as the phase title; consider a short title with the detail moved to **Goal:**.`);
646
+ }
555
647
  function cmdPhaseAdd(cwd, description, raw, customId) {
556
648
  if (!description) {
557
649
  error('description required for phase add');
@@ -639,6 +731,7 @@ function cmdPhaseAdd(cwd, description, raw, customId) {
639
731
  (0, shell_command_projection_cjs_1.platformWriteSync)(roadmapPath, updatedContent);
640
732
  return { newPhaseId: _newPhaseId, dirName: _dirName };
641
733
  });
734
+ const titleWarning = describeGoalShapedTitle(description);
642
735
  const result = {
643
736
  phase_number: typeof newPhaseId === 'number' ? newPhaseId : String(newPhaseId),
644
737
  padded: typeof newPhaseId === 'number' ? String(newPhaseId).padStart(2, '0') : String(newPhaseId),
@@ -647,7 +740,9 @@ function cmdPhaseAdd(cwd, description, raw, customId) {
647
740
  directory: toPosixPath(node_path_1.default.join(node_path_1.default.relative(cwd, planningDir(cwd)), 'phases', dirName)),
648
741
  naming_mode: config.phase_naming,
649
742
  };
650
- output(result, raw, result.padded);
743
+ if (titleWarning)
744
+ result['warning'] = titleWarning;
745
+ output(result, raw, result['padded']);
651
746
  }
652
747
  function cmdPhaseAddBatch(cwd, descriptions, raw) {
653
748
  if (!Array.isArray(descriptions) || descriptions.length === 0) {
@@ -760,8 +855,27 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
760
855
  const phasesDir = node_path_1.default.join(planningDir(cwd), 'phases');
761
856
  const normalizedBase = normalizePhaseName(afterPhase);
762
857
  const decimalSet = new Set();
763
- try {
764
- const entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true });
858
+ // #2245 audit: existsSync-guarded, mirroring cmdPhaseNextDecimal's identical
859
+ // scan above — a missing phasesDir (no decimal sub-phases yet) is the
860
+ // expected, silent case (empty decimalSet). A readdirSync failure once the
861
+ // dir is confirmed to EXIST is a genuine anomaly; swallowing it used to let
862
+ // `phase insert` proceed with an incomplete decimalSet and risk writing a
863
+ // decimal phase number that collides with an existing on-disk directory
864
+ // the scan simply never saw — surfaced loud instead, like the sibling.
865
+ if (node_fs_1.default.existsSync(phasesDir)) {
866
+ // Initialized (not just declared) so TS's definite-assignment check is
867
+ // satisfied without relying on control-flow narrowing through error()'s
868
+ // `never` return, which TS does not propagate through a destructured
869
+ // module-property function reference — error() still halts the process
870
+ // before `dirs` below is ever computed from this placeholder value.
871
+ let entries = [];
872
+ try {
873
+ entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true });
874
+ }
875
+ catch (e) {
876
+ const msg = e instanceof Error ? e.message : String(e);
877
+ error(`Failed to scan phase directories for existing decimal phases: ${msg}`);
878
+ }
765
879
  const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
766
880
  const decimalPattern = new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}${escapeRegex(normalizedBase)}\\.(\\d+)`);
767
881
  for (const dir of dirs) {
@@ -770,9 +884,6 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
770
884
  decimalSet.add(parseInt(dm[1], 10));
771
885
  }
772
886
  }
773
- catch {
774
- /* intentionally empty */
775
- }
776
887
  const rmPhasePattern = new RegExp(`#{2,4}\\s*Phase\\s+${phaseMarkdownRegexSource(normalizedBase)}\\.(\\d+)${OPTIONAL_PHASE_TAG_SOURCE}\\s*:`, 'gi');
777
888
  let rmMatch;
778
889
  while ((rmMatch = rmPhasePattern.exec(rawContent)) !== null) {
@@ -940,19 +1051,186 @@ function decrementRoadmapPaddedPhaseNumber(raw, removedInt) {
940
1051
  return raw;
941
1052
  return String(num - 1).padStart(raw.length, '0');
942
1053
  }
1054
+ /**
1055
+ * Return the RAW text of the `dataRowIndex`-th data row line (0-based, in
1056
+ * file order — header and delimiter rows excluded) of the FIRST GFM table
1057
+ * found in `sectionText`, or `null` when the table or that row doesn't exist.
1058
+ *
1059
+ * F8 (#2245 review, nit) support helper: addresses a table row by its
1060
+ * STRUCTURAL position rather than by matching its (possibly non-unique)
1061
+ * trimmed cell content — see the Progress-ordinal renumber's padding-recovery
1062
+ * use below for why content-matching is unsafe here (two rows with identical
1063
+ * trimmed Phase text, or a row whose already-rewritten new value coincides
1064
+ * with another row's pre-edit text, would otherwise resolve to the wrong line).
1065
+ */
1066
+ function findDataRowLine(sectionText, dataRowIndex) {
1067
+ const lines = sectionText.split(/\r?\n/);
1068
+ let headerIdx = -1;
1069
+ for (let i = 0; i < lines.length; i++) {
1070
+ const trimmed = lines[i].trim();
1071
+ if (trimmed.startsWith('|') && trimmed.indexOf('|', 1) !== -1) {
1072
+ headerIdx = i;
1073
+ break;
1074
+ }
1075
+ }
1076
+ if (headerIdx === -1)
1077
+ return null;
1078
+ let seen = -1;
1079
+ for (let i = headerIdx + 2; i < lines.length; i++) {
1080
+ if (!lines[i].trim().startsWith('|'))
1081
+ break;
1082
+ seen += 1;
1083
+ if (seen === dataRowIndex)
1084
+ return lines[i];
1085
+ }
1086
+ return null;
1087
+ }
943
1088
  function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, removedInt, cwd) {
944
1089
  withPlanningLock(cwd, () => {
945
1090
  let content = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
946
1091
  const escaped = escapeRegex(targetPhase);
947
- content = content.replace(new RegExp(`\\n?(?<h>#{2,4})\\s*Phase\\s+${escaped}${OPTIONAL_PHASE_TAG_SOURCE}\\s*:[\\s\\S]*?(?=\\n\\k<h>(?!#)\\s+Phase\\s+[^\\n:]+\\s*:|$)`, 'i'), '');
1092
+ // SECTION-DELETION (not a section-body edit) — removes the phase's ENTIRE
1093
+ // detail section INCLUDING its own heading line. Migrated onto deleteSection
1094
+ // (ADR-2143 §4 / markdown-sectionizer T7): it locates the target heading via
1095
+ // tokenizeHeadings + this predicate, then splices out the range from that
1096
+ // heading's own start through the next heading of the SAME-OR-HIGHER level —
1097
+ // whatever that heading's text is. This fixes a data-loss bug in the prior
1098
+ // hand-rolled regex, whose lookahead only recognised ANOTHER "Phase N:"
1099
+ // heading as a stop boundary: removing the LAST phase in a roadmap left no
1100
+ // such heading to stop at, so the lazy `[\s\S]*?` scan ran to EOF and swept
1101
+ // away everything after it — including a trailing `## Progress` heading and
1102
+ // its tracking table.
1103
+ const phaseHeadingRe = new RegExp(`^Phase\\s+${escaped}${OPTIONAL_PHASE_TAG_SOURCE}\\s*:`, 'i');
1104
+ content = (0, markdown_sectionizer_cjs_1.deleteSection)(content, (h) => h.level >= 2 && h.level <= 4 && phaseHeadingRe.test(h.text));
948
1105
  content = content.replace(new RegExp(`\\n?-\\s*\\[[ x]\\]\\s*.*Phase\\s+${escaped}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s][^\\n]*`, 'gi'), '');
949
- content = content.replace(new RegExp(`\\n?\\|\\s*${escaped}\\.?\\s[^|]*\\|[^\\n]*`, 'gi'), '');
1106
+ // ROW-DELETION (not a cell update) — removes the WHOLE Progress-table row
1107
+ // for a removed phase via deleteTableRow (ADR-2143 §7 row-removal sibling
1108
+ // of updateTableCell). Scoped to the `## Progress` section — mirroring
1109
+ // deriveProgressFromRoadmap's read-side scoping (phase-lifecycle.cts) —
1110
+ // so a same-numbered row in an earlier, unrelated table (e.g. a
1111
+ // `| Phase | Requirements | Count |` table preceding `## Progress`,
1112
+ // #2012) is never touched. Matches the row by its FIRST cell only: for an
1113
+ // integer removal, a zero-pad-insensitive leading-integer comparison
1114
+ // (`01.`, `1.`, `1 `, bare `1` all match phase 1; a decimal sub-phase
1115
+ // cell like `2.5` never matches an integer removal); for a decimal
1116
+ // removal, the exact decimal token. This replaces the prior regex's
1117
+ // `\.?\s` requirement, which silently left a COMPACT unpadded row (e.g.
1118
+ // `|2|0/2|Planned|-|`) undeleted — its closing `|` follows the digit with
1119
+ // no whitespace to match (#2245 audit) — and which was also unscoped to
1120
+ // any particular table.
1121
+ const progressHeadingMatch = content.match(/^##[ \t]+Progress\b/im);
1122
+ if (progressHeadingMatch && progressHeadingMatch.index !== undefined) {
1123
+ const headingOffset = progressHeadingMatch.index;
1124
+ const before = content.slice(0, headingOffset);
1125
+ const fromHeading = content.slice(headingOffset);
1126
+ const nextHeadingOffset = fromHeading.search(/\n#{1,2}[ \t]/);
1127
+ const progressSection = nextHeadingOffset >= 0 ? fromHeading.slice(0, nextHeadingOffset) : fromHeading;
1128
+ const rest = nextHeadingOffset >= 0 ? fromHeading.slice(nextHeadingOffset) : '';
1129
+ const matchRemovedProgressRow = (row) => {
1130
+ const firstCellRaw = (Object.values(row)[0] ?? '').trim();
1131
+ if (isDecimal) {
1132
+ return new RegExp(`^${escaped}\\.?(?:\\s|$)`, 'i').test(firstCellRaw);
1133
+ }
1134
+ const leadingMatch = firstCellRaw.match(/^0*(\d+)(\.\d+)?/);
1135
+ if (!leadingMatch || leadingMatch[2])
1136
+ return false;
1137
+ return parseInt(leadingMatch[1], 10) === removedInt;
1138
+ };
1139
+ const deleteResult = (0, markdown_table_cjs_1.deleteTableRow)(progressSection, matchRemovedProgressRow);
1140
+ if (deleteResult.ok) {
1141
+ content = before + deleteResult.value + rest;
1142
+ }
1143
+ }
950
1144
  if (!isDecimal) {
951
1145
  // #1729: fold an optional pre-colon ( ) tag into the suffix capture so it
952
1146
  // is re-emitted verbatim — a tagged later phase still gets renumbered.
953
1147
  content = content.replace(/(#{2,4}\s*Phase\s+)(\d+(?:\.\d+)?)((?:\s*\([^)\n]{0,200}\))?\s*:)/gi, (_match, prefix, num, suffix) => `${prefix}${decrementRoadmapPhaseToken(num, removedInt)}${suffix}`);
954
1148
  content = content.replace(/(-\s*\[[ x]\]\s*.*?Phase\s+)(\d+)(\s*:|\s+)/gi, (_match, prefix, num, suffix) => `${prefix}${decrementRoadmapPhaseNumber(num, removedInt)}${suffix}`);
955
- content = content.replace(/(\|\s*)(\d+)(\.\s)/g, (_match, prefix, num, suffix) => `${prefix}${decrementRoadmapPhaseNumber(num, removedInt)}${suffix}`);
1149
+ // ORDINAL-RENUMBER — CELL EDIT (not row-deletion) — migrated onto
1150
+ // updateTableCell (ADR-2143 §7, sibling of the deleteTableRow scoping
1151
+ // directly above). The prior whole-document regex
1152
+ // `/(\|\s*)(\d+)(\.\s)/g` rewrote ANY `| N. ` cell anywhere in the
1153
+ // file — including a same-shaped cell in an UNRELATED, earlier table
1154
+ // (e.g. a `| Phase | Requirements | Count |` table, or a decoy table,
1155
+ // preceding `## Progress`; #2245-class scoping defect, same family as
1156
+ // the row-delete fix above). Scoped here to the `## Progress` section
1157
+ // only, mirroring that same section-slice-then-splice-back pattern.
1158
+ //
1159
+ // Loops because updateTableCell only rewrites the FIRST matching row
1160
+ // per call. `processedOrdinalRows` tracks by row INDEX (stable across
1161
+ // iterations — this only edits cell content, it never inserts/deletes
1162
+ // rows) so an already-decremented row's new value — which may still
1163
+ // numerically exceed `removedInt` — is never re-selected and
1164
+ // decremented a second time (matching on the row's CURRENT value alone,
1165
+ // without this guard, would keep re-firing on each pass).
1166
+ //
1167
+ // `phaseCellShapeRe` is the exact digit+dot-space shape the old regex
1168
+ // required: a decimal sub-phase ordinal like `2.5` (no whitespace
1169
+ // between the dot and the next character) never matches it, so it is
1170
+ // left untouched — identical decimal-safety to the prior behaviour.
1171
+ //
1172
+ // updateTableCell hands the callback the TRIMMED, UNESCAPED cell value
1173
+ // only, so the row's original leading/trailing alignment padding is
1174
+ // recovered by a narrow, anchored lookup within that row's OWN raw
1175
+ // line — addressed by ROW INDEX (`matchedRowIndex`, via
1176
+ // `findDataRowLine`), not by searching the whole section for content
1177
+ // matching the trimmed value (F8 #2245 review: two rows with identical
1178
+ // trimmed Phase text, or a row whose already-rewritten new value
1179
+ // coincides with another row's pre-edit text, would otherwise resolve
1180
+ // to the WRONG row's padding — the first/leftmost content match found).
1181
+ // The lookup searches for `escapeCell(current)` (F3 #2245 review: the
1182
+ // ESCAPED form, e.g. `Foo \| Bar`) — the raw line always carries the
1183
+ // escaped form, so searching for the unescaped `current` would
1184
+ // silently fail to find an escaped-pipe cell's own line — preserving
1185
+ // every other byte of the row (ADR-2143 §7 byte-parity) while only the
1186
+ // digits actually change.
1187
+ const ordinalHeadingMatch = content.match(/^##[ \t]+Progress\b/im);
1188
+ if (ordinalHeadingMatch && ordinalHeadingMatch.index !== undefined) {
1189
+ const ordinalHeadingOffset = ordinalHeadingMatch.index;
1190
+ const ordinalBefore = content.slice(0, ordinalHeadingOffset);
1191
+ const ordinalFromHeading = content.slice(ordinalHeadingOffset);
1192
+ const ordinalNextHeadingOffset = ordinalFromHeading.search(/\n#{1,2}[ \t]/);
1193
+ let ordinalSection = ordinalNextHeadingOffset >= 0
1194
+ ? ordinalFromHeading.slice(0, ordinalNextHeadingOffset)
1195
+ : ordinalFromHeading;
1196
+ const ordinalRest = ordinalNextHeadingOffset >= 0 ? ordinalFromHeading.slice(ordinalNextHeadingOffset) : '';
1197
+ const phaseCellShapeRe = /^(\d+)(\.\s)/;
1198
+ const processedOrdinalRows = new Set();
1199
+ let matchedRowIndex = null;
1200
+ for (;;) {
1201
+ matchedRowIndex = null;
1202
+ const cellResult = (0, markdown_table_cjs_1.updateTableCell)(ordinalSection, (row, index) => {
1203
+ if (processedOrdinalRows.has(index))
1204
+ return false;
1205
+ const m = phaseCellShapeRe.exec(row['Phase'] ?? '');
1206
+ if (!m)
1207
+ return false;
1208
+ const num = parseInt(m[1], 10);
1209
+ if (!Number.isInteger(num) || num <= removedInt || num === 999)
1210
+ return false;
1211
+ processedOrdinalRows.add(index);
1212
+ matchedRowIndex = index;
1213
+ return true;
1214
+ }, 'Phase', (current) => {
1215
+ const m = phaseCellShapeRe.exec(current);
1216
+ if (!m)
1217
+ return current;
1218
+ const decremented = decrementRoadmapPhaseNumber(m[1], removedInt);
1219
+ const newContent = `${decremented}${m[2]}${current.slice(m[0].length)}`;
1220
+ const targetLine = matchedRowIndex === null ? null : findDataRowLine(ordinalSection, matchedRowIndex);
1221
+ const padMatch = targetLine
1222
+ ? new RegExp(`^[ \\t]*\\|(\\s*)${escapeRegex((0, markdown_table_cjs_1.escapeCell)(current))}(\\s*)\\|`).exec(targetLine)
1223
+ : null;
1224
+ const leadPad = padMatch ? padMatch[1] : ' ';
1225
+ const trailPad = padMatch ? padMatch[2] : ' ';
1226
+ return `${leadPad}${(0, markdown_table_cjs_1.escapeCell)(newContent)}${trailPad}`;
1227
+ });
1228
+ if (!cellResult.ok)
1229
+ break;
1230
+ ordinalSection = cellResult.value;
1231
+ }
1232
+ content = ordinalBefore + ordinalSection + ordinalRest;
1233
+ }
956
1234
  content = content.replace(/(?<![0-9-])(\d{2})-(\d{2})(?=(?:(?:-[A-Za-z][A-Za-z0-9-]*)?-(?:PLAN|SUMMARY)\.md)|(?![0-9-]))/g, (_match, phaseNum, planNum) => `${decrementRoadmapPaddedPhaseNumber(phaseNum, removedInt)}-${planNum}`);
957
1235
  content = content.replace(/(\*\*Depends on\*\*\s*:\s*Phase\s+)(\d+(?:\.\d+)?)\b/gi, (_match, prefix, num) => `${prefix}${decrementRoadmapPhaseToken(num, removedInt)}`);
958
1236
  content = content.replace(/(Depends on:\*\*\s*Phase\s+)(\d+(?:\.\d+)?)\b/gi, (_match, prefix, num) => `${prefix}${decrementRoadmapPhaseToken(num, removedInt)}`);
@@ -990,8 +1268,18 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
990
1268
  renamedDirs = renamed.renamedDirs;
991
1269
  renamedFiles = renamed.renamedFiles;
992
1270
  }
993
- catch {
994
- /* intentionally empty */
1271
+ catch (e) {
1272
+ // #2245 audit (was ERROR-HIDING): renameDecimalPhases/renameIntegerPhases
1273
+ // rename subsequent phase directories ON DISK one at a time — a mid-loop
1274
+ // failure leaves SOME directories already renumbered and others not, with
1275
+ // no way to recover which (the callee's own renamedDirs/renamedFiles never
1276
+ // reach this scope when it throws). Silently swallowing this and falling
1277
+ // through to updateRoadmapAfterPhaseRemoval below used to rewrite
1278
+ // ROADMAP.md's phase numbers assuming the ENTIRE renumbering succeeded,
1279
+ // permanently desyncing ROADMAP.md from the actual (partially-renamed)
1280
+ // on-disk directory names. Surface loud instead of compounding it.
1281
+ const msg = e instanceof Error ? e.message : String(e);
1282
+ error(`Failed to renumber phase directories after removing phase ${targetPhase}: ${msg}`);
995
1283
  }
996
1284
  updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, parseInt(normalized, 10), cwd);
997
1285
  const statePath = node_path_1.default.join(planningDir(cwd), 'STATE.md');
@@ -1085,7 +1373,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1085
1373
  const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
1086
1374
  const statePath = node_path_1.default.join(planningDir(cwd), 'STATE.md');
1087
1375
  const phasesDir = node_path_1.default.join(planningDir(cwd), 'phases');
1088
- const today = clock_cjs_1.realClock.today();
1376
+ const today = clock_cjs_1.realClock.localToday();
1089
1377
  const phaseInfoRaw = findPhaseInternal(cwd, phaseNum);
1090
1378
  if (!phaseInfoRaw) {
1091
1379
  error(`Phase ${phaseNum} not found`);
@@ -1130,7 +1418,11 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1130
1418
  }
1131
1419
  }
1132
1420
  catch {
1133
- /* intentionally empty */
1421
+ /* best-effort (#2245 audit): this is an ADVISORY pre-scan of UAT/
1422
+ * VERIFICATION files for `warnings` in the phase-complete output — the
1423
+ * actual completion GATE is readVerificationStatus below (a separate
1424
+ * mechanism). A readdirSync/readFileSync failure here just means fewer
1425
+ * warnings are surfaced this run, not a blocked or corrupted completion. */
1134
1426
  }
1135
1427
  let nextPhaseNum = null;
1136
1428
  let nextPhaseName = null;
@@ -1153,50 +1445,143 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1153
1445
  // so completing an already-checked phase (idempotent re-run) checked the
1154
1446
  // wrong phase's box. Mirrors the tight pattern used by phase-insert
1155
1447
  // (`]\\s*(?:\\*\\*)?Phase`).
1156
- const checkboxPattern = new RegExp(`(-\\s*\\[)[ ](\\]\\s*(?:\\*\\*)?\\s*Phase\\s+${phaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s][^\\n]*)`, 'i');
1157
- roadmapContent = roadmapContent.replace(checkboxPattern, `$1x$2 (completed ${today})`);
1158
- const tableRowPattern = new RegExp(`^(\\|\\s*${phaseEscaped}\\.?\\s[^|]*(?:\\|[^\\n]*))$`, 'im');
1159
- // Scope the Progress-row search to the ## Progress section so the regex
1160
- // doesn't bind to an earlier table (e.g. | Phase | Requirements | Count |)
1161
- // whose rows also start with the phase number. (#2012)
1162
- const updateProgressRow = (fullRow) => {
1163
- const cells = fullRow.split('|').slice(1, -1);
1164
- const dateShape = /^\d{4}-\d{2}-\d{2}$/;
1165
- if (cells.length === 5) {
1166
- cells[2] = ` ${summaryCount}/${planCount} `;
1167
- cells[3] = ' Complete ';
1168
- // Preserve only a valid ISO date (#1161: idempotent; self-heal garbage)
1169
- const existingDate5 = cells[4].trim();
1170
- cells[4] = dateShape.test(existingDate5) ? cells[4] : ` ${today} `;
1171
- }
1172
- else if (cells.length === 4) {
1173
- cells[1] = ` ${summaryCount}/${planCount} `;
1174
- cells[2] = ' Complete ';
1175
- // Preserve only a valid ISO date (#1161: idempotent; self-heal garbage)
1176
- const existingDate4 = cells[3].trim();
1177
- cells[3] = dateShape.test(existingDate4) ? cells[3] : ` ${today} `;
1448
+ // #2067/#2200: line-anchored (^, optional leading indent) so an
1449
+ // inline / backticked prose literal cannot match. Milestone-scoped below
1450
+ // (mutateMilestonePhase) so a Backlog entry or a same-numbered shipped-
1451
+ // milestone phase cannot be flipped either.
1452
+ // ADR-2143 §4 note / #2245 audit: this is the phase-LIST checkbox — it
1453
+ // lives in the milestone's `- [ ] Phase N: …` checklist, OUTSIDE any
1454
+ // `### Phase N` detail section, so there is no section for
1455
+ // withPhaseSection to bind to. Migrated onto the sectionizer's
1456
+ // `updateBullet` bullet-write seam: the pattern itself is unchanged,
1457
+ // only the "find the right line, splice it back" plumbing moved off a
1458
+ // whole-slice `.replace()` onto the seam. Applied per single physical
1459
+ // line by updateBullet, so the pattern no longer needs the `m` flag
1460
+ // (it never sees more than one line at a time); see
1461
+ // planCountBodyPattern below for the sites that were migrated onto
1462
+ // withPhaseSection instead.
1463
+ //
1464
+ // #2245 review Fix 6: this is behaviour-preserving for GSD-GENERATED
1465
+ // inputs (the only shape ROADMAP.md ever actually has), NOT byte-parity
1466
+ // across every conceivable input. `updateBullet` is fence-aware — a
1467
+ // checkbox-shaped line inside a fenced (``` / ~~~) code block is never
1468
+ // offered to `match`/`transform` — whereas the retired whole-slice
1469
+ // `.replace()` had no such fence tracking and would have flipped a
1470
+ // bullet-shaped line inside a fence too. That divergence has no live
1471
+ // bug because a GSD-authored ROADMAP.md milestone checklist never puts
1472
+ // its own `- [ ] Phase N: …` entries inside a fenced code block, but it
1473
+ // is a real (and correct) behavioural difference on pathological input.
1474
+ const checkboxPattern = new RegExp(`^[ \\t]*(-\\s*\\[)[ ](\\]\\s*(?:\\*\\*)?\\s*Phase\\s+${phaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s][^\\n]*)`, 'i');
1475
+ // Progress table row: update Plans Complete/Status/Completed columns BY
1476
+ // COLUMN NAME (handles 4- or 5-column RoadmapProgress tables) via the
1477
+ // markdown-table seam (ADR-2143 §7) — supersedes the prior ordinal
1478
+ // cells[]-index regex. Applied inside mutateMilestonePhase below (per
1479
+ // milestone window), further scoped to the ## Progress heading within
1480
+ // that window so the row lookup doesn't bind to an earlier table (e.g.
1481
+ // | Phase | Requirements | Count |) whose rows also start with the
1482
+ // phase number (#2012).
1483
+ // #2245 Blocker 4: optional dot must be followed by whitespace-or-end,
1484
+ // not dot-OR-whitespace-OR-end as alternatives — the prior form let a
1485
+ // bare "." satisfy the whole lookahead, so completing phase "2"
1486
+ // over-matched a decimal sub-phase row like "2.5 Extra". Matches "2",
1487
+ // "2.", "2 Alpha"; rejects "2.5 Extra".
1488
+ const phaseCellRe = new RegExp(`^${phaseEscaped}\\.?(?:\\s|$)`, 'i');
1489
+ const rowMatch = (row) => phaseCellRe.test((row['Phase'] ?? '').trim());
1490
+ const dateShape = /^\d{4}-\d{2}-\d{2}$/;
1491
+ /**
1492
+ * Within `text` (already scoped to one milestone window by the
1493
+ * caller), scope further to the `## Progress` heading section (up to
1494
+ * the next `#`/`##` heading) when present, run `edit` against just
1495
+ * that slice, and splice the result back — falling back to the whole
1496
+ * `text` when no `## Progress` heading exists (mirrors phase-
1497
+ * lifecycle.cjs's deriveProgressFromRoadmap read-side scoping).
1498
+ */
1499
+ const editProgressHeadingSlice = (text, edit) => {
1500
+ const progressMatch = text.match(/^##[ \t]+Progress\b/im);
1501
+ if (!progressMatch || progressMatch.index === undefined) {
1502
+ return edit(text);
1178
1503
  }
1179
- return '|' + cells.join('|') + '|';
1504
+ const headingOffset = progressMatch.index;
1505
+ const beforeHeading = text.slice(0, headingOffset);
1506
+ const fromHeading = text.slice(headingOffset);
1507
+ const nextHeading = fromHeading.search(/\n#{1,2}[ \t]/);
1508
+ const scoped = nextHeading >= 0 ? fromHeading.slice(0, nextHeading) : fromHeading;
1509
+ const after = nextHeading >= 0 ? fromHeading.slice(nextHeading) : '';
1510
+ return beforeHeading + edit(scoped) + after;
1180
1511
  };
1181
- const progressIdx = roadmapContent.indexOf('## Progress');
1182
- if (progressIdx >= 0) {
1183
- const beforeProgress = roadmapContent.slice(0, progressIdx);
1184
- const progressSection = roadmapContent.slice(progressIdx);
1185
- roadmapContent = beforeProgress + progressSection.replace(tableRowPattern, updateProgressRow);
1512
+ // ADR-2143 §4: the plan-count write is now routed through
1513
+ // withPhaseSection (see mutateMilestonePhase below), which hands this
1514
+ // pattern ONLY phase N's own detail-section body — so the pattern no
1515
+ // longer needs its own `#{2,4}\s*Phase\s+N` anchor + skip-ahead-past-
1516
+ // interior-headings lookahead; the section boundary itself confines
1517
+ // the match (the #2067/#2200 boundary-crossing class is now
1518
+ // structurally impossible for this site rather than regex-enforced).
1519
+ const planCountBodyPattern = /(\*\*Plans:\*\*\s*)[^\n]+/i;
1520
+ const phaseInfoSummaries = phaseInfo['summaries'];
1521
+ // #2200: apply the phase-checkbox flip, the plan-count write, and the
1522
+ // per-plan checkbox flips ONLY within the current milestone's region(s)
1523
+ // (primary section + optional Phase Details section). A bullet/heading in
1524
+ // a shipped milestone, a Backlog section, or a backticked prose literal is
1525
+ // outside the window and stays untouched. With no versioned active
1526
+ // milestone, fall back to whole-content mutation (prior behaviour).
1527
+ const mutateMilestonePhase = (slice) => {
1528
+ let s = slice;
1529
+ s = (0, markdown_sectionizer_cjs_1.updateBullet)(s, (_bulletText, rawLine) => checkboxPattern.test(rawLine), (rawLine) => rawLine.replace(checkboxPattern, `$1x$2 (completed ${today})`));
1530
+ s = editProgressHeadingSlice(s, (scoped) => {
1531
+ let text = scoped;
1532
+ const plansResult = (0, markdown_table_cjs_1.updateTableCell)(text, rowMatch, 'Plans Complete', ` ${summaryCount}/${planCount} `);
1533
+ if (plansResult.ok)
1534
+ text = plansResult.value;
1535
+ const statusResult = (0, markdown_table_cjs_1.updateTableCell)(text, rowMatch, 'Status', ' Complete ');
1536
+ if (statusResult.ok)
1537
+ text = statusResult.value;
1538
+ // Preserve only a valid ISO date (#1161: idempotent; self-heal
1539
+ // garbage). Ragged-tolerant (#2245 Blocker 2): decide via the
1540
+ // CURRENT Completed cell inside a single updateTableCell callback
1541
+ // (its own tolerant row scan) rather than gating on
1542
+ // findTableWithColumns (which requires the WHOLE table to parse —
1543
+ // a ragged SIBLING row elsewhere used to silently no-op this
1544
+ // row's date stamp too).
1545
+ const completedResult = (0, markdown_table_cjs_1.updateTableCell)(text, rowMatch, 'Completed', (current) => dateShape.test(current.trim()) ? current : ` ${today} `);
1546
+ if (completedResult.ok)
1547
+ text = completedResult.value;
1548
+ return text;
1549
+ });
1550
+ // ADR-2143 §4: the plan-count write and the per-plan checkbox flips
1551
+ // are both scoped to phase N's OWN detail section via
1552
+ // withPhaseSection — the edit callback below only ever sees that
1553
+ // section's body, so neither regex can escape into a sibling
1554
+ // phase's section, a shipped milestone, or a Backlog entry.
1555
+ s = withPhaseSection(s, phaseNum, (body) => {
1556
+ let b = body.replace(planCountBodyPattern, `$1${summaryCount}/${planCount} plans complete`);
1557
+ for (const summaryFile of phaseInfoSummaries) {
1558
+ const planId = summaryFile.replace('-SUMMARY.md', '').replace('SUMMARY.md', '');
1559
+ if (!planId)
1560
+ continue;
1561
+ const planEscaped = escapeRegex(planId);
1562
+ const planCheckboxPattern = new RegExp(`(-\\s*\\[) (\\]\\s*(?:\\*\\*)?${planEscaped}(?:\\*\\*)?)`, 'i');
1563
+ b = b.replace(planCheckboxPattern, '$1x$2');
1564
+ }
1565
+ return b;
1566
+ });
1567
+ return s;
1568
+ };
1569
+ const milestoneRanges = currentMilestoneRawRanges(roadmapContent, cwd);
1570
+ if (milestoneRanges) {
1571
+ // Splice later windows first so an earlier window's offsets are not
1572
+ // shifted by a length-changing mutation in a later window.
1573
+ const windows = [milestoneRanges.details, milestoneRanges.primary]
1574
+ .filter((w) => w !== null)
1575
+ .sort((a, b) => b.start - a.start);
1576
+ for (const w of windows) {
1577
+ roadmapContent =
1578
+ roadmapContent.slice(0, w.start)
1579
+ + mutateMilestonePhase(roadmapContent.slice(w.start, w.end))
1580
+ + roadmapContent.slice(w.end);
1581
+ }
1186
1582
  }
1187
1583
  else {
1188
- roadmapContent = roadmapContent.replace(tableRowPattern, updateProgressRow);
1189
- }
1190
- const planCountPattern = new RegExp(`(#{2,4}\\s*Phase\\s+${phaseEscaped}(?:(?!\\n#{1,4}\\s)[\\s\\S])*?\\*\\*Plans:\\*\\*\\s*)[^\\n]+`, 'i');
1191
- roadmapContent = roadmapContent.replace(planCountPattern, `$1${summaryCount}/${planCount} plans complete`);
1192
- const phaseInfoSummaries = phaseInfo['summaries'];
1193
- for (const summaryFile of phaseInfoSummaries) {
1194
- const planId = summaryFile.replace('-SUMMARY.md', '').replace('SUMMARY.md', '');
1195
- if (!planId)
1196
- continue;
1197
- const planEscaped = escapeRegex(planId);
1198
- const planCheckboxPattern = new RegExp(`(-\\s*\\[) (\\]\\s*(?:\\*\\*)?${planEscaped}(?:\\*\\*)?)`, 'i');
1199
- roadmapContent = (roadmapContent).replace(planCheckboxPattern, '$1x$2');
1584
+ roadmapContent = mutateMilestonePhase(roadmapContent);
1200
1585
  }
1201
1586
  writes.push({
1202
1587
  filePath: roadmapPath,
@@ -1212,22 +1597,74 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1212
1597
  const reqMatch = sectionText.match(/\*\*Requirements:?\*\*[^\S\n]*:?[^\S\n]*([^\n]+)/i);
1213
1598
  const originalReqContent = node_fs_1.default.readFileSync(reqPath, 'utf-8');
1214
1599
  let reqContent = originalReqContent;
1600
+ // #2316: `citedReqIds` — the REQ-IDs ROADMAP's own **Requirements:**
1601
+ // line for this phase actually cites — is hoisted out of the
1602
+ // `if (reqMatch)` block (previously scoped only inside it) so the
1603
+ // ghost-ID cross-check below (~#2316-1) can consult it. `TBD` is the
1604
+ // literal placeholder `phase.add`/`-batch`/`-insert` seed
1605
+ // (`**Requirements**: TBD`, src/phase.cts:833,920,1078) — never a
1606
+ // real REQ-ID, so it is filtered out wherever a cited-ID list feeds
1607
+ // a warning (#2316-7 boundary).
1608
+ const isPlaceholderReqId = (id) => id.toUpperCase() === 'TBD';
1609
+ let citedReqIds = [];
1610
+ // #2316-1: Traceability-row writes that matched NO row (ghost or
1611
+ // otherwise) — the `if (reqUpdate.ok)` below previously had no
1612
+ // `else`, discarding this fact silently instead of surfacing it.
1613
+ const traceabilityWriteMisses = [];
1215
1614
  if (reqMatch) {
1216
- const reqIds = reqMatch[1]
1615
+ // #2334 HIGH 3: filter the tokenized capture to the REQ-ID SHAPE —
1616
+ // the SAME shape bodyReqIds (`\*\*([A-Z][A-Z0-9]*-\d+)\*\*`, below)
1617
+ // and tableReqIds (`([A-Z][A-Z0-9]*-\d+)`, below) already require —
1618
+ // so the ghost-ID / unregistered comparisons stay shape-symmetric.
1619
+ // Without this, `[^\n]+` split on `[,\s]+` turned EVERY word after
1620
+ // the ID list into a "cited REQ-ID": the shipped
1621
+ // `templates/roadmap.md:32` line
1622
+ // `**Requirements**: [REQ-01, REQ-02] <!-- brackets optional, ... -->`
1623
+ // warned to register `<!--`, `brackets`, `optional`, `-->`, etc., and
1624
+ // `**Requirements:** None` warned to register the literal word
1625
+ // `None`. This subsumes the `TBD` placeholder special-case (`TBD`
1626
+ // does not match the REQ-ID shape either); `isPlaceholderReqId` is
1627
+ // kept below as a defensive no-op for any caller that still hands
1628
+ // it a raw token.
1629
+ const REQ_ID_SHAPE_RE = /^[A-Z][A-Z0-9]*-\d+$/i;
1630
+ citedReqIds = reqMatch[1]
1217
1631
  .replace(/[\[\]]/g, '')
1218
1632
  .split(/[,\s]+/)
1219
1633
  .map((r) => r.trim())
1220
- .filter(Boolean);
1221
- for (const reqId of reqIds) {
1634
+ .filter(Boolean)
1635
+ .filter((r) => REQ_ID_SHAPE_RE.test(r));
1636
+ for (const reqId of citedReqIds) {
1222
1637
  const reqEscaped = escapeRegex(reqId);
1223
1638
  reqContent = reqContent.replace(new RegExp(`(-\\s*\\[)[ ](\\]\\s*\\*\\*${reqEscaped}\\*\\*)`, 'gi'), '$1x$2');
1224
- reqContent = reqContent.replace(new RegExp(`(\\|\\s*${reqEscaped}\\s*\\|[^|]+\\|)\\s*(?:Pending|In Progress)\\s*(\\|)`, 'gi'), '$1 Complete $2');
1639
+ // Traceability row: | <REQ-ID> | Phase N | Pending|In Progress | ->
1640
+ // ... Complete | via the markdown-table seam (ADR-2143 §7). Match the
1641
+ // row by its FIRST cell's value (the requirement-ID column) regardless
1642
+ // of that column's HEADER name — real tables head it `REQ-ID`, others
1643
+ // `Requirement` (#2769/#2203); this mirrors the prior regex's first-cell
1644
+ // `\|\s*<id>\s*\|` anchor, not a by-name lookup. Object.values(row) is in
1645
+ // header order, so [0] is the first column. Case-insensitive.
1646
+ const reqRowMatch = (row) => (Object.values(row)[0] ?? '').trim().toLowerCase() === reqId.toLowerCase();
1647
+ // Ragged-tolerant (#2245 Blocker 2): drive the write purely off
1648
+ // updateTableCell's own tolerant row scan — a DIFFERENT
1649
+ // requirement's row elsewhere in the same table having a
1650
+ // mismatched cell count must never silently no-op THIS
1651
+ // requirement's write. The "only flip Pending/In Progress ->
1652
+ // Complete" gate is folded into the newValue callback so one
1653
+ // updateTableCell call both probes and writes.
1654
+ const reqUpdate = updateTraceabilityCell(reqContent, reqRowMatch, 'Status', (current) => /^(?:pending|in progress)$/i.test(current.trim()) ? ' Complete ' : current);
1655
+ if (reqUpdate.ok) {
1656
+ reqContent = reqUpdate.value;
1657
+ }
1658
+ else if (!isPlaceholderReqId(reqId)) {
1659
+ traceabilityWriteMisses.push(reqId);
1660
+ }
1225
1661
  }
1226
1662
  }
1227
1663
  // #1159 (Defect B): collect requirement IDs only from ACTIVE sections.
1228
1664
  // Requirements under headings whose text contains "deferred", "backlog",
1229
- // "future", or "v2" (case-insensitive) are explicitly out of current scope
1230
- // and must not be flagged as missing from the Traceability table.
1665
+ // "future", or an OFF-milestone `v<N>` (case-insensitive) are explicitly
1666
+ // out of current scope and must not be flagged as missing from the
1667
+ // Traceability table.
1231
1668
  //
1232
1669
  // Strategy: walk lines, track heading depth, and toggle a "deferred" flag
1233
1670
  // when a heading matching the pattern is encountered. A sub-heading (higher
@@ -1235,7 +1672,47 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1235
1672
  // opens a same-or-shallower heading that does NOT match the pattern.
1236
1673
  // Lines inside fenced code blocks (``` or ~~~) are treated as content, not
1237
1674
  // headings, to avoid false deferred-section detection from code examples.
1238
- const DEFERRED_HEADING_RE = /\b(?:deferred|backlog|future|v\d+)\b/i;
1675
+ //
1676
+ // #2334 BLOCKER fix (regresses closed bug #1159 against GSD's OWN
1677
+ // shipped template): #2316-4a dropped the bare `v\d+` alternative
1678
+ // entirely to stop it over-matching an ACTIVE heading like "## v1
1679
+ // Requirements" — but the shipped `templates/requirements.md:35`
1680
+ // scaffold ships `## v2 Requirements` / "Deferred to future release"
1681
+ // as its ONLY deferred marker, and `v\d+` was the ONLY alternative
1682
+ // that ever matched a bare version heading (the deferred-ness lives
1683
+ // in body prose, not the heading text). Dropping it regressed #1159
1684
+ // for every project scaffolded from the shipped template.
1685
+ //
1686
+ // Fix: make the `v<N>` alternative MILESTONE-AWARE instead of
1687
+ // deleting it. A `## v<N> ...` heading is deferred ONLY when `<N>`
1688
+ // (MAJOR version only — "v1" vs milestone "v1.3" is the SAME major
1689
+ // version) does not match the CURRENT milestone's major version,
1690
+ // resolved via `stateExtractField` against STATE.md's `milestone:`
1691
+ // frontmatter field (the same seam `getMilestoneInfo`/state.cts's
1692
+ // frontmatter builder already use — no bespoke frontmatter parsing).
1693
+ // "## v1 Requirements" while the milestone is v1.x is the ACTIVE
1694
+ // milestone's own section (#2316's original ask) and must NOT be
1695
+ // swallowed; "## v2 Requirements" while the milestone is v1.x is a
1696
+ // genuinely future milestone (#1159's ask, and the literal shipped-
1697
+ // template shape) and MUST stay suppressed. `deferred`/`backlog`/
1698
+ // `future` are unaffected by milestone resolution — a genuinely
1699
+ // deferred heading always spells one of those words too (see
1700
+ // #2316-5 regression guard: "## Deferred v2 Requirements", "##
1701
+ // Future Backlog", "## Deferred", "## Backlog", "## Future").
1702
+ //
1703
+ // Fail-safe: when the milestone version cannot be resolved at all
1704
+ // (no STATE.md, or no `milestone:` field), fall back to the OLD
1705
+ // pre-#2316-4a behavior and treat every `v\d+` heading as deferred.
1706
+ // A false "deferred" here only ever SUPPRESSES a warning — strictly
1707
+ // safer than spamming a warning on every v\d+-headed scaffold when
1708
+ // we cannot tell whether it names the active milestone.
1709
+ const DEFERRED_KEYWORD_RE = /\b(?:deferred|backlog|future)\b/i;
1710
+ const HEADING_VERSION_RE = /\bv(\d+)(?:\.\d+)*\b/i;
1711
+ const stateRawForMilestone = node_fs_1.default.existsSync(statePath) ? node_fs_1.default.readFileSync(statePath, 'utf-8') : null;
1712
+ const currentMilestoneRaw = stateRawForMilestone
1713
+ ? stateExtractField(stateRawForMilestone, 'milestone')
1714
+ : null;
1715
+ const currentMilestoneMajor = currentMilestoneRaw ? extractMajorVersion(currentMilestoneRaw) : null;
1239
1716
  const bodyReqIds = [];
1240
1717
  // deferredDepth: the heading level that opened the current deferred block,
1241
1718
  // or 0 when we are in an active section.
@@ -1259,11 +1736,21 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1259
1736
  }
1260
1737
  // Heading at same level or shallower than current deferred opener,
1261
1738
  // or no active deferred block yet.
1262
- if (DEFERRED_HEADING_RE.test(text)) {
1739
+ if (DEFERRED_KEYWORD_RE.test(text)) {
1263
1740
  deferredDepth = depth; // enter a deferred block
1264
1741
  }
1265
1742
  else {
1266
- deferredDepth = 0; // back in an active section
1743
+ const versionMatch = text.match(HEADING_VERSION_RE);
1744
+ if (versionMatch) {
1745
+ const headingMajor = versionMatch[1];
1746
+ deferredDepth =
1747
+ currentMilestoneMajor === null || headingMajor !== currentMilestoneMajor
1748
+ ? depth // unresolved milestone (fail-safe) or off-milestone version -> deferred
1749
+ : 0; // same major version as the current milestone -> active
1750
+ }
1751
+ else {
1752
+ deferredDepth = 0; // back in an active section
1753
+ }
1267
1754
  }
1268
1755
  continue;
1269
1756
  }
@@ -1283,7 +1770,11 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1283
1770
  ? reqContent.slice(traceabilityHeadingMatch.index)
1284
1771
  : '';
1285
1772
  const tableReqIds = new Set();
1286
- const tableRowPat = /^\|\s*([A-Z][A-Z0-9]*-\d+)\s*\|/gm;
1773
+ // #2203: match REQ-IDs in any pipe-delimited cell (not just the first
1774
+ // column) so a traceability table that leads with a status column (e.g.
1775
+ // | ☐ | REQ-01 | …) is parsed correctly instead of reporting every row
1776
+ // as missing.
1777
+ const tableRowPat = /\|\s*([A-Z][A-Z0-9]*-\d+)\s*\|/g;
1287
1778
  let tableMatch;
1288
1779
  while ((tableMatch = tableRowPat.exec(traceabilitySection)) !== null) {
1289
1780
  tableReqIds.add(tableMatch[1]);
@@ -1292,8 +1783,68 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1292
1783
  if (unregistered.length > 0) {
1293
1784
  warnings.push(`REQUIREMENTS.md: ${unregistered.length} REQ-ID(s) found in body but missing from Traceability table: ${unregistered.join(', ')} — add them manually to keep traceability in sync`);
1294
1785
  }
1786
+ // #2316-1: ghost REQ-IDs — cited by ROADMAP's own **Requirements:**
1787
+ // line for this phase, but registered NOWHERE in REQUIREMENTS.md
1788
+ // (neither its body nor its Traceability table). The `unregistered`
1789
+ // check above only ever compares REQUIREMENTS.md's own body against
1790
+ // its own Traceability table; it never consults `citedReqIds`, so an
1791
+ // ID that ROADMAP cites but REQUIREMENTS.md never defines at all was
1792
+ // previously invisible to every guard. `TBD` (the phase.add/-batch/
1793
+ // -insert placeholder) is excluded — see #2316-7 boundary.
1794
+ //
1795
+ // #2334 HIGH 2: classify "ghost" by PROBING THE ACTUAL WRITE
1796
+ // SURFACES this same function just wrote to (:1947 checkbox,
1797
+ // :1967 Traceability row) — case-insensitively — mirroring
1798
+ // milestone.cts's `notFound`/`hasRow`/`doneCheckbox` classification
1799
+ // (src/milestone.cts:117-141,209-215), instead of set-differencing
1800
+ // `bodyReqIds` (deferred-filtered, case-sensitive, bold-only) and
1801
+ // `tableReqIds` (case-sensitive) against `citedReqIds`. Those two
1802
+ // indexes can disagree with the writes: an ID under a `##
1803
+ // Deferred` heading gets its checkbox ticked by the write loop
1804
+ // above but is deliberately EXCLUDED from `bodyReqIds` by the
1805
+ // deferred-heading filter (#1159), so the old set-diff reported it
1806
+ // as an unregistered ghost in the SAME response that just ticked
1807
+ // its checkbox; a case-mismatched citation (`known-01` vs
1808
+ // `**KNOWN-01**`) lands its write via the writes' case-insensitive
1809
+ // regexes but failed the old set-diff's case-SENSITIVE
1810
+ // `Array.includes`/`Set.has`. An ID whose checkbox OR Traceability
1811
+ // row actually matched is registered — not a ghost — regardless of
1812
+ // which section (deferred or not) it lives under.
1813
+ const reqIsRegisteredAnywhere = (id) => {
1814
+ const reqEscaped = escapeRegex(id);
1815
+ // Surface 1 — checkbox, EITHER state (`[ ]` or `[x]`), case-
1816
+ // insensitive: existence check, not the write's space-only match.
1817
+ if (new RegExp(`-\\s*\\[[ xX]\\]\\s*\\*\\*${reqEscaped}\\*\\*`, 'i').test(reqContent)) {
1818
+ return true;
1819
+ }
1820
+ // Surface 2 — Traceability row exists at all (any Status value),
1821
+ // via the SAME no-op-probe-through-updateTraceabilityCell
1822
+ // technique milestone.cts's `hasRow` uses (:210-214): a case-
1823
+ // insensitive first-cell match, regardless of current Status.
1824
+ const rowProbeMatch = (row) => (Object.values(row)[0] ?? '').trim().toLowerCase() === id.toLowerCase();
1825
+ return updateTraceabilityCell(reqContent, rowProbeMatch, 'Status', (current) => current).ok;
1826
+ };
1827
+ const ghostReqIds = citedReqIds.filter((id) => !isPlaceholderReqId(id) && !reqIsRegisteredAnywhere(id));
1828
+ if (ghostReqIds.length > 0) {
1829
+ warnings.push(`ROADMAP Phase ${phaseNum} cites REQ-ID(s) not registered anywhere in REQUIREMENTS.md (neither body nor Traceability table): ${ghostReqIds.join(', ')} — add them to REQUIREMENTS.md or correct the ROADMAP citation`);
1830
+ }
1831
+ // #2316-1 cont.: a cited ID whose Traceability-row write matched no
1832
+ // row for a reason OTHER than being a ghost (e.g. a malformed table)
1833
+ // still deserves a warning instead of a silent discard — but skip
1834
+ // IDs already reported above as ghosts to avoid a duplicate message
1835
+ // for the same root cause.
1836
+ const traceabilityWriteFailures = traceabilityWriteMisses.filter((id) => !ghostReqIds.includes(id));
1837
+ if (traceabilityWriteFailures.length > 0) {
1838
+ warnings.push(`REQUIREMENTS.md: Traceability row write skipped for REQ-ID(s) cited by ROADMAP (no matching row found): ${traceabilityWriteFailures.join(', ')}`);
1839
+ }
1295
1840
  writes.push({ filePath: reqPath, before: originalReqContent, after: reqContent });
1296
- requirementsUpdated = true;
1841
+ // #2316-3: `requirements_updated` must reflect whether REQUIREMENTS.md
1842
+ // content actually CHANGED, not merely that the file existed in the
1843
+ // transaction — mirrors the `writes.push({filePath,before,after})`
1844
+ // diff-tracking pattern used for the ROADMAP write above. A phase
1845
+ // whose citations match nothing (ghost REQ-IDs only) must report
1846
+ // `false`, not a bare "the file was present" `true`.
1847
+ requirementsUpdated = reqContent !== originalReqContent;
1297
1848
  }
1298
1849
  }
1299
1850
  try {
@@ -1319,7 +1870,13 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1319
1870
  }
1320
1871
  }
1321
1872
  catch {
1322
- /* intentionally empty */
1873
+ /* best-effort (#2245 audit): stage 1 of a deliberate 3-stage
1874
+ * cascading fallback for locating the next phase (disk dirs → roadmap
1875
+ * headings/checkboxes → lowest-outstanding-checkbox override, #2028
1876
+ * below). A disk-scan failure here is indistinguishable from "found
1877
+ * nothing on disk" and correctly falls through to stage 2, which
1878
+ * derives the same information independently from ROADMAP.md content
1879
+ * — not a silent data-loss path. */
1323
1880
  }
1324
1881
  if (isLastPhase && roadmapContent !== null) {
1325
1882
  try {
@@ -1355,10 +1912,13 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1355
1912
  }
1356
1913
  }
1357
1914
  catch {
1358
- /* intentionally empty */
1915
+ /* best-effort (#2245 audit): stage 2 of the next-phase cascade
1916
+ * (see stage 1's comment above) — a failure here just leaves
1917
+ * isLastPhase as stage 1 left it; stage 3 (#2028) below runs next
1918
+ * regardless and provides a further, independent override. */
1359
1919
  }
1360
1920
  }
1361
- // #2028: don't stamp "Milestone complete" when a LOWER-numbered phase is
1921
+ // #2028: don't stamp "All phases complete" when a LOWER-numbered phase is
1362
1922
  // still outstanding. The two blocks above only clear isLastPhase when a
1363
1923
  // HIGHER-numbered phase exists, so completing the numerically-highest phase
1364
1924
  // out of order (e.g. Phase 10 before Phase 9) wrongly read as milestone-end.
@@ -1396,7 +1956,11 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1396
1956
  }
1397
1957
  }
1398
1958
  catch {
1399
- /* intentionally empty */
1959
+ /* best-effort (#2245 audit): stage 3 (#2028) of the next-phase
1960
+ * cascade — a failure here simply leaves isLastPhase/nextPhaseNum
1961
+ * as stages 1-2 already determined them; this stage only ever
1962
+ * overrides toward "not last" when it finds a genuinely lower
1963
+ * outstanding phase, never the reverse. */
1400
1964
  }
1401
1965
  }
1402
1966
  if (node_fs_1.default.existsSync(statePath)) {