@opengsd/gsd-core 1.7.0-rc.5 → 1.7.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 (112) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/agents/gsd-executor.md +2 -1
  4. package/agents/gsd-security-auditor.md +13 -15
  5. package/agents/gsd-ui-checker.md +2 -0
  6. package/agents/gsd-ui-researcher.md +1 -0
  7. package/bin/install.js +975 -196
  8. package/commands/gsd/mempalace-capture.md +27 -1
  9. package/commands/gsd/surface.md +6 -6
  10. package/gsd-core/bin/gsd-tools.cjs +63 -2
  11. package/gsd-core/bin/lib/api-coverage.cjs +3 -4
  12. package/gsd-core/bin/lib/audit.cjs +7 -6
  13. package/gsd-core/bin/lib/capability-registry.cjs +503 -87
  14. package/gsd-core/bin/lib/capability-validator.cjs +56 -18
  15. package/gsd-core/bin/lib/check-command-router.cjs +1 -1
  16. package/gsd-core/bin/lib/clock.cjs +19 -0
  17. package/gsd-core/bin/lib/commands.cjs +48 -9
  18. package/gsd-core/bin/lib/config-loader.cjs +6 -2
  19. package/gsd-core/bin/lib/config.cjs +12 -0
  20. package/gsd-core/bin/lib/core-utils.cjs +8 -2
  21. package/gsd-core/bin/lib/drift.cjs +4 -4
  22. package/gsd-core/bin/lib/frontmatter.cjs +22 -0
  23. package/gsd-core/bin/lib/gsd2-import.cjs +2 -1
  24. package/gsd-core/bin/lib/host-integration.cjs +33 -8
  25. package/gsd-core/bin/lib/init.cjs +60 -53
  26. package/gsd-core/bin/lib/install-engine.cjs +93 -22
  27. package/gsd-core/bin/lib/installer-migration-authoring.cjs +2 -1
  28. package/gsd-core/bin/lib/installer-migration-report.cjs +3 -0
  29. package/gsd-core/bin/lib/installer-migrations.cjs +1 -1
  30. package/gsd-core/bin/lib/markdown-sectionizer.cjs +342 -0
  31. package/gsd-core/bin/lib/markdown-table.cjs +698 -0
  32. package/gsd-core/bin/lib/mcp-server.cjs +18 -7
  33. package/gsd-core/bin/lib/milestone.cjs +217 -31
  34. package/gsd-core/bin/lib/phase-command-router.cjs +50 -2
  35. package/gsd-core/bin/lib/phase-lifecycle.cjs +62 -36
  36. package/gsd-core/bin/lib/phase-locator.cjs +23 -2
  37. package/gsd-core/bin/lib/phase.cjs +436 -61
  38. package/gsd-core/bin/lib/plan-scan.cjs +3 -0
  39. package/gsd-core/bin/lib/review-reviewer-selection.cjs +24 -7
  40. package/gsd-core/bin/lib/roadmap-parser.cjs +218 -13
  41. package/gsd-core/bin/lib/roadmap.cjs +100 -49
  42. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +242 -44
  43. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +3 -2
  44. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +24 -14
  45. package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +19 -5
  46. package/gsd-core/bin/lib/runtime-homes.cjs +22 -0
  47. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +526 -29
  48. package/gsd-core/bin/lib/runtime-name-policy.cjs +63 -5
  49. package/gsd-core/bin/lib/schema-detect.cjs +2 -1
  50. package/gsd-core/bin/lib/security.cjs +7 -37
  51. package/gsd-core/bin/lib/shell-command-projection.cjs +176 -27
  52. package/gsd-core/bin/lib/smart-entry.cjs +4 -3
  53. package/gsd-core/bin/lib/stale-bake-guard.cjs +30 -10
  54. package/gsd-core/bin/lib/state-transition.cjs +100 -45
  55. package/gsd-core/bin/lib/state.cjs +391 -126
  56. package/gsd-core/bin/lib/surface.cjs +12 -8
  57. package/gsd-core/bin/lib/template.cjs +2 -1
  58. package/gsd-core/bin/lib/uat.cjs +54 -8
  59. package/gsd-core/bin/lib/ui-consideration-probe.cjs +249 -0
  60. package/gsd-core/bin/lib/ui-safety-gate.cjs +23 -1
  61. package/gsd-core/bin/lib/verify.cjs +4 -3
  62. package/gsd-core/bin/lib/workstream.cjs +3 -2
  63. package/gsd-core/bin/lib/worktree-safety.cjs +1 -1
  64. package/gsd-core/bin/lib/write-set.cjs +38 -0
  65. package/gsd-core/bin/shared/config-schema.manifest.json +2 -0
  66. package/gsd-core/bin/shared/model-catalog.json +8 -3
  67. package/gsd-core/references/checkpoints.md +12 -0
  68. package/gsd-core/references/ui-consideration-probe.md +73 -0
  69. package/gsd-core/templates/UI-SPEC.md +25 -0
  70. package/gsd-core/templates/VALIDATION.md +2 -0
  71. package/gsd-core/workflows/add-tests.md +1 -1
  72. package/gsd-core/workflows/audit-milestone.md +7 -4
  73. package/gsd-core/workflows/debug.md +2 -0
  74. package/gsd-core/workflows/execute-phase.md +5 -3
  75. package/gsd-core/workflows/fast.md +8 -22
  76. package/gsd-core/workflows/plan-phase.md +6 -0
  77. package/gsd-core/workflows/progress.md +2 -2
  78. package/gsd-core/workflows/quick.md +2 -0
  79. package/gsd-core/workflows/review.md +42 -3
  80. package/gsd-core/workflows/secure-phase.md +1 -1
  81. package/gsd-core/workflows/settings-advanced.md +7 -4
  82. package/gsd-core/workflows/ship.md +8 -2
  83. package/gsd-core/workflows/spec-phase.md +1 -1
  84. package/gsd-core/workflows/transition.md +1 -1
  85. package/gsd-core/workflows/ui-phase.md +146 -1
  86. package/gsd-core/workflows/validate-phase.md +2 -2
  87. package/hooks/dist/gsd-statusline.js +164 -14
  88. package/hooks/dist/gsd-windsurf-pre-command.js +275 -0
  89. package/hooks/dist/gsd-windsurf-pre-write.js +132 -0
  90. package/hooks/dist/managed-hooks-registry.cjs +2 -0
  91. package/hooks/gsd-statusline.js +164 -14
  92. package/hooks/gsd-windsurf-pre-command.js +275 -0
  93. package/hooks/gsd-windsurf-pre-write.js +132 -0
  94. package/hooks/managed-hooks-registry.cjs +2 -0
  95. package/package.json +10 -4
  96. package/pi/gsd.cjs +354 -0
  97. package/scripts/build-hooks.js +3 -0
  98. package/scripts/ci-test-scope.cjs +39 -1
  99. package/scripts/gen-golden-install-parity-zcode.cjs +35 -35
  100. package/scripts/gen-install-tree-fixtures.cjs +75 -0
  101. package/scripts/gen-registry.cjs +128 -0
  102. package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -1
  103. package/scripts/lint-table-schema-drift.cjs +157 -0
  104. package/scripts/lint-test-file-count.allowlist.json +2 -1
  105. package/scripts/registry-schema.cjs +565 -0
  106. package/scripts/validate-registry.cjs +117 -0
  107. package/skills/gsd-mempalace-capture/SKILL.md +27 -1
  108. package/skills/gsd-surface/SKILL.md +6 -6
  109. package/vscode/browser.js +197 -0
  110. package/vscode/extension.js +383 -0
  111. package/vscode/host-binding.js +113 -0
  112. package/vscode/package.json +96 -0
@@ -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,43 @@ 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
+ }
70
109
  function describeNonCanonicalPlans(dirFiles, matchedFiles) {
71
110
  const matched = new Set(matchedFiles);
72
111
  const offenders = dirFiles.filter((f) => looksLikePlanFile(f) && !matched.has(f));
@@ -313,9 +352,21 @@ function cmdFindPhase(cwd, phase, raw) {
313
352
  .filter((e) => e.isDirectory())
314
353
  .map((e) => e.name)
315
354
  .sort((a, b) => comparePhaseNum(a, b));
316
- const match = dirs.find((d) => phaseTokenMatches(d, normalized));
317
- if (!match)
355
+ // #2237: fail loud when multiple directories match the same bare phase
356
+ // number — prevents cross-project file writes when unrelated projects
357
+ // share a .planning/phases/ tree.
358
+ const matches = dirs.filter((d) => phaseTokenMatches(d, normalized));
359
+ if (matches.length === 0)
318
360
  continue;
361
+ if (matches.length > 1) {
362
+ output({
363
+ ...notFound,
364
+ ambiguous_matches: matches,
365
+ 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.`,
366
+ }, raw, '');
367
+ return;
368
+ }
369
+ const match = matches[0];
319
370
  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
371
  const phaseNumber = dirMatch ? dirMatch[1] : normalized;
321
372
  const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null;
@@ -760,8 +811,27 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
760
811
  const phasesDir = node_path_1.default.join(planningDir(cwd), 'phases');
761
812
  const normalizedBase = normalizePhaseName(afterPhase);
762
813
  const decimalSet = new Set();
763
- try {
764
- const entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true });
814
+ // #2245 audit: existsSync-guarded, mirroring cmdPhaseNextDecimal's identical
815
+ // scan above — a missing phasesDir (no decimal sub-phases yet) is the
816
+ // expected, silent case (empty decimalSet). A readdirSync failure once the
817
+ // dir is confirmed to EXIST is a genuine anomaly; swallowing it used to let
818
+ // `phase insert` proceed with an incomplete decimalSet and risk writing a
819
+ // decimal phase number that collides with an existing on-disk directory
820
+ // the scan simply never saw — surfaced loud instead, like the sibling.
821
+ if (node_fs_1.default.existsSync(phasesDir)) {
822
+ // Initialized (not just declared) so TS's definite-assignment check is
823
+ // satisfied without relying on control-flow narrowing through error()'s
824
+ // `never` return, which TS does not propagate through a destructured
825
+ // module-property function reference — error() still halts the process
826
+ // before `dirs` below is ever computed from this placeholder value.
827
+ let entries = [];
828
+ try {
829
+ entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true });
830
+ }
831
+ catch (e) {
832
+ const msg = e instanceof Error ? e.message : String(e);
833
+ error(`Failed to scan phase directories for existing decimal phases: ${msg}`);
834
+ }
765
835
  const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
766
836
  const decimalPattern = new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}${escapeRegex(normalizedBase)}\\.(\\d+)`);
767
837
  for (const dir of dirs) {
@@ -770,9 +840,6 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
770
840
  decimalSet.add(parseInt(dm[1], 10));
771
841
  }
772
842
  }
773
- catch {
774
- /* intentionally empty */
775
- }
776
843
  const rmPhasePattern = new RegExp(`#{2,4}\\s*Phase\\s+${phaseMarkdownRegexSource(normalizedBase)}\\.(\\d+)${OPTIONAL_PHASE_TAG_SOURCE}\\s*:`, 'gi');
777
844
  let rmMatch;
778
845
  while ((rmMatch = rmPhasePattern.exec(rawContent)) !== null) {
@@ -940,19 +1007,186 @@ function decrementRoadmapPaddedPhaseNumber(raw, removedInt) {
940
1007
  return raw;
941
1008
  return String(num - 1).padStart(raw.length, '0');
942
1009
  }
1010
+ /**
1011
+ * Return the RAW text of the `dataRowIndex`-th data row line (0-based, in
1012
+ * file order — header and delimiter rows excluded) of the FIRST GFM table
1013
+ * found in `sectionText`, or `null` when the table or that row doesn't exist.
1014
+ *
1015
+ * F8 (#2245 review, nit) support helper: addresses a table row by its
1016
+ * STRUCTURAL position rather than by matching its (possibly non-unique)
1017
+ * trimmed cell content — see the Progress-ordinal renumber's padding-recovery
1018
+ * use below for why content-matching is unsafe here (two rows with identical
1019
+ * trimmed Phase text, or a row whose already-rewritten new value coincides
1020
+ * with another row's pre-edit text, would otherwise resolve to the wrong line).
1021
+ */
1022
+ function findDataRowLine(sectionText, dataRowIndex) {
1023
+ const lines = sectionText.split(/\r?\n/);
1024
+ let headerIdx = -1;
1025
+ for (let i = 0; i < lines.length; i++) {
1026
+ const trimmed = lines[i].trim();
1027
+ if (trimmed.startsWith('|') && trimmed.indexOf('|', 1) !== -1) {
1028
+ headerIdx = i;
1029
+ break;
1030
+ }
1031
+ }
1032
+ if (headerIdx === -1)
1033
+ return null;
1034
+ let seen = -1;
1035
+ for (let i = headerIdx + 2; i < lines.length; i++) {
1036
+ if (!lines[i].trim().startsWith('|'))
1037
+ break;
1038
+ seen += 1;
1039
+ if (seen === dataRowIndex)
1040
+ return lines[i];
1041
+ }
1042
+ return null;
1043
+ }
943
1044
  function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, removedInt, cwd) {
944
1045
  withPlanningLock(cwd, () => {
945
1046
  let content = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
946
1047
  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'), '');
1048
+ // SECTION-DELETION (not a section-body edit) — removes the phase's ENTIRE
1049
+ // detail section INCLUDING its own heading line. Migrated onto deleteSection
1050
+ // (ADR-2143 §4 / markdown-sectionizer T7): it locates the target heading via
1051
+ // tokenizeHeadings + this predicate, then splices out the range from that
1052
+ // heading's own start through the next heading of the SAME-OR-HIGHER level —
1053
+ // whatever that heading's text is. This fixes a data-loss bug in the prior
1054
+ // hand-rolled regex, whose lookahead only recognised ANOTHER "Phase N:"
1055
+ // heading as a stop boundary: removing the LAST phase in a roadmap left no
1056
+ // such heading to stop at, so the lazy `[\s\S]*?` scan ran to EOF and swept
1057
+ // away everything after it — including a trailing `## Progress` heading and
1058
+ // its tracking table.
1059
+ const phaseHeadingRe = new RegExp(`^Phase\\s+${escaped}${OPTIONAL_PHASE_TAG_SOURCE}\\s*:`, 'i');
1060
+ content = (0, markdown_sectionizer_cjs_1.deleteSection)(content, (h) => h.level >= 2 && h.level <= 4 && phaseHeadingRe.test(h.text));
948
1061
  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'), '');
1062
+ // ROW-DELETION (not a cell update) — removes the WHOLE Progress-table row
1063
+ // for a removed phase via deleteTableRow (ADR-2143 §7 row-removal sibling
1064
+ // of updateTableCell). Scoped to the `## Progress` section — mirroring
1065
+ // deriveProgressFromRoadmap's read-side scoping (phase-lifecycle.cts) —
1066
+ // so a same-numbered row in an earlier, unrelated table (e.g. a
1067
+ // `| Phase | Requirements | Count |` table preceding `## Progress`,
1068
+ // #2012) is never touched. Matches the row by its FIRST cell only: for an
1069
+ // integer removal, a zero-pad-insensitive leading-integer comparison
1070
+ // (`01.`, `1.`, `1 `, bare `1` all match phase 1; a decimal sub-phase
1071
+ // cell like `2.5` never matches an integer removal); for a decimal
1072
+ // removal, the exact decimal token. This replaces the prior regex's
1073
+ // `\.?\s` requirement, which silently left a COMPACT unpadded row (e.g.
1074
+ // `|2|0/2|Planned|-|`) undeleted — its closing `|` follows the digit with
1075
+ // no whitespace to match (#2245 audit) — and which was also unscoped to
1076
+ // any particular table.
1077
+ const progressHeadingMatch = content.match(/^##[ \t]+Progress\b/im);
1078
+ if (progressHeadingMatch && progressHeadingMatch.index !== undefined) {
1079
+ const headingOffset = progressHeadingMatch.index;
1080
+ const before = content.slice(0, headingOffset);
1081
+ const fromHeading = content.slice(headingOffset);
1082
+ const nextHeadingOffset = fromHeading.search(/\n#{1,2}[ \t]/);
1083
+ const progressSection = nextHeadingOffset >= 0 ? fromHeading.slice(0, nextHeadingOffset) : fromHeading;
1084
+ const rest = nextHeadingOffset >= 0 ? fromHeading.slice(nextHeadingOffset) : '';
1085
+ const matchRemovedProgressRow = (row) => {
1086
+ const firstCellRaw = (Object.values(row)[0] ?? '').trim();
1087
+ if (isDecimal) {
1088
+ return new RegExp(`^${escaped}\\.?(?:\\s|$)`, 'i').test(firstCellRaw);
1089
+ }
1090
+ const leadingMatch = firstCellRaw.match(/^0*(\d+)(\.\d+)?/);
1091
+ if (!leadingMatch || leadingMatch[2])
1092
+ return false;
1093
+ return parseInt(leadingMatch[1], 10) === removedInt;
1094
+ };
1095
+ const deleteResult = (0, markdown_table_cjs_1.deleteTableRow)(progressSection, matchRemovedProgressRow);
1096
+ if (deleteResult.ok) {
1097
+ content = before + deleteResult.value + rest;
1098
+ }
1099
+ }
950
1100
  if (!isDecimal) {
951
1101
  // #1729: fold an optional pre-colon ( ) tag into the suffix capture so it
952
1102
  // is re-emitted verbatim — a tagged later phase still gets renumbered.
953
1103
  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
1104
  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}`);
1105
+ // ORDINAL-RENUMBER — CELL EDIT (not row-deletion) — migrated onto
1106
+ // updateTableCell (ADR-2143 §7, sibling of the deleteTableRow scoping
1107
+ // directly above). The prior whole-document regex
1108
+ // `/(\|\s*)(\d+)(\.\s)/g` rewrote ANY `| N. ` cell anywhere in the
1109
+ // file — including a same-shaped cell in an UNRELATED, earlier table
1110
+ // (e.g. a `| Phase | Requirements | Count |` table, or a decoy table,
1111
+ // preceding `## Progress`; #2245-class scoping defect, same family as
1112
+ // the row-delete fix above). Scoped here to the `## Progress` section
1113
+ // only, mirroring that same section-slice-then-splice-back pattern.
1114
+ //
1115
+ // Loops because updateTableCell only rewrites the FIRST matching row
1116
+ // per call. `processedOrdinalRows` tracks by row INDEX (stable across
1117
+ // iterations — this only edits cell content, it never inserts/deletes
1118
+ // rows) so an already-decremented row's new value — which may still
1119
+ // numerically exceed `removedInt` — is never re-selected and
1120
+ // decremented a second time (matching on the row's CURRENT value alone,
1121
+ // without this guard, would keep re-firing on each pass).
1122
+ //
1123
+ // `phaseCellShapeRe` is the exact digit+dot-space shape the old regex
1124
+ // required: a decimal sub-phase ordinal like `2.5` (no whitespace
1125
+ // between the dot and the next character) never matches it, so it is
1126
+ // left untouched — identical decimal-safety to the prior behaviour.
1127
+ //
1128
+ // updateTableCell hands the callback the TRIMMED, UNESCAPED cell value
1129
+ // only, so the row's original leading/trailing alignment padding is
1130
+ // recovered by a narrow, anchored lookup within that row's OWN raw
1131
+ // line — addressed by ROW INDEX (`matchedRowIndex`, via
1132
+ // `findDataRowLine`), not by searching the whole section for content
1133
+ // matching the trimmed value (F8 #2245 review: two rows with identical
1134
+ // trimmed Phase text, or a row whose already-rewritten new value
1135
+ // coincides with another row's pre-edit text, would otherwise resolve
1136
+ // to the WRONG row's padding — the first/leftmost content match found).
1137
+ // The lookup searches for `escapeCell(current)` (F3 #2245 review: the
1138
+ // ESCAPED form, e.g. `Foo \| Bar`) — the raw line always carries the
1139
+ // escaped form, so searching for the unescaped `current` would
1140
+ // silently fail to find an escaped-pipe cell's own line — preserving
1141
+ // every other byte of the row (ADR-2143 §7 byte-parity) while only the
1142
+ // digits actually change.
1143
+ const ordinalHeadingMatch = content.match(/^##[ \t]+Progress\b/im);
1144
+ if (ordinalHeadingMatch && ordinalHeadingMatch.index !== undefined) {
1145
+ const ordinalHeadingOffset = ordinalHeadingMatch.index;
1146
+ const ordinalBefore = content.slice(0, ordinalHeadingOffset);
1147
+ const ordinalFromHeading = content.slice(ordinalHeadingOffset);
1148
+ const ordinalNextHeadingOffset = ordinalFromHeading.search(/\n#{1,2}[ \t]/);
1149
+ let ordinalSection = ordinalNextHeadingOffset >= 0
1150
+ ? ordinalFromHeading.slice(0, ordinalNextHeadingOffset)
1151
+ : ordinalFromHeading;
1152
+ const ordinalRest = ordinalNextHeadingOffset >= 0 ? ordinalFromHeading.slice(ordinalNextHeadingOffset) : '';
1153
+ const phaseCellShapeRe = /^(\d+)(\.\s)/;
1154
+ const processedOrdinalRows = new Set();
1155
+ let matchedRowIndex = null;
1156
+ for (;;) {
1157
+ matchedRowIndex = null;
1158
+ const cellResult = (0, markdown_table_cjs_1.updateTableCell)(ordinalSection, (row, index) => {
1159
+ if (processedOrdinalRows.has(index))
1160
+ return false;
1161
+ const m = phaseCellShapeRe.exec(row['Phase'] ?? '');
1162
+ if (!m)
1163
+ return false;
1164
+ const num = parseInt(m[1], 10);
1165
+ if (!Number.isInteger(num) || num <= removedInt || num === 999)
1166
+ return false;
1167
+ processedOrdinalRows.add(index);
1168
+ matchedRowIndex = index;
1169
+ return true;
1170
+ }, 'Phase', (current) => {
1171
+ const m = phaseCellShapeRe.exec(current);
1172
+ if (!m)
1173
+ return current;
1174
+ const decremented = decrementRoadmapPhaseNumber(m[1], removedInt);
1175
+ const newContent = `${decremented}${m[2]}${current.slice(m[0].length)}`;
1176
+ const targetLine = matchedRowIndex === null ? null : findDataRowLine(ordinalSection, matchedRowIndex);
1177
+ const padMatch = targetLine
1178
+ ? new RegExp(`^[ \\t]*\\|(\\s*)${escapeRegex((0, markdown_table_cjs_1.escapeCell)(current))}(\\s*)\\|`).exec(targetLine)
1179
+ : null;
1180
+ const leadPad = padMatch ? padMatch[1] : ' ';
1181
+ const trailPad = padMatch ? padMatch[2] : ' ';
1182
+ return `${leadPad}${(0, markdown_table_cjs_1.escapeCell)(newContent)}${trailPad}`;
1183
+ });
1184
+ if (!cellResult.ok)
1185
+ break;
1186
+ ordinalSection = cellResult.value;
1187
+ }
1188
+ content = ordinalBefore + ordinalSection + ordinalRest;
1189
+ }
956
1190
  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
1191
  content = content.replace(/(\*\*Depends on\*\*\s*:\s*Phase\s+)(\d+(?:\.\d+)?)\b/gi, (_match, prefix, num) => `${prefix}${decrementRoadmapPhaseToken(num, removedInt)}`);
958
1192
  content = content.replace(/(Depends on:\*\*\s*Phase\s+)(\d+(?:\.\d+)?)\b/gi, (_match, prefix, num) => `${prefix}${decrementRoadmapPhaseToken(num, removedInt)}`);
@@ -990,8 +1224,18 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
990
1224
  renamedDirs = renamed.renamedDirs;
991
1225
  renamedFiles = renamed.renamedFiles;
992
1226
  }
993
- catch {
994
- /* intentionally empty */
1227
+ catch (e) {
1228
+ // #2245 audit (was ERROR-HIDING): renameDecimalPhases/renameIntegerPhases
1229
+ // rename subsequent phase directories ON DISK one at a time — a mid-loop
1230
+ // failure leaves SOME directories already renumbered and others not, with
1231
+ // no way to recover which (the callee's own renamedDirs/renamedFiles never
1232
+ // reach this scope when it throws). Silently swallowing this and falling
1233
+ // through to updateRoadmapAfterPhaseRemoval below used to rewrite
1234
+ // ROADMAP.md's phase numbers assuming the ENTIRE renumbering succeeded,
1235
+ // permanently desyncing ROADMAP.md from the actual (partially-renamed)
1236
+ // on-disk directory names. Surface loud instead of compounding it.
1237
+ const msg = e instanceof Error ? e.message : String(e);
1238
+ error(`Failed to renumber phase directories after removing phase ${targetPhase}: ${msg}`);
995
1239
  }
996
1240
  updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, parseInt(normalized, 10), cwd);
997
1241
  const statePath = node_path_1.default.join(planningDir(cwd), 'STATE.md');
@@ -1085,7 +1329,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1085
1329
  const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
1086
1330
  const statePath = node_path_1.default.join(planningDir(cwd), 'STATE.md');
1087
1331
  const phasesDir = node_path_1.default.join(planningDir(cwd), 'phases');
1088
- const today = clock_cjs_1.realClock.today();
1332
+ const today = clock_cjs_1.realClock.localToday();
1089
1333
  const phaseInfoRaw = findPhaseInternal(cwd, phaseNum);
1090
1334
  if (!phaseInfoRaw) {
1091
1335
  error(`Phase ${phaseNum} not found`);
@@ -1130,7 +1374,11 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1130
1374
  }
1131
1375
  }
1132
1376
  catch {
1133
- /* intentionally empty */
1377
+ /* best-effort (#2245 audit): this is an ADVISORY pre-scan of UAT/
1378
+ * VERIFICATION files for `warnings` in the phase-complete output — the
1379
+ * actual completion GATE is readVerificationStatus below (a separate
1380
+ * mechanism). A readdirSync/readFileSync failure here just means fewer
1381
+ * warnings are surfaced this run, not a blocked or corrupted completion. */
1134
1382
  }
1135
1383
  let nextPhaseNum = null;
1136
1384
  let nextPhaseName = null;
@@ -1153,50 +1401,143 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1153
1401
  // so completing an already-checked phase (idempotent re-run) checked the
1154
1402
  // wrong phase's box. Mirrors the tight pattern used by phase-insert
1155
1403
  // (`]\\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} `;
1404
+ // #2067/#2200: line-anchored (^, optional leading indent) so an
1405
+ // inline / backticked prose literal cannot match. Milestone-scoped below
1406
+ // (mutateMilestonePhase) so a Backlog entry or a same-numbered shipped-
1407
+ // milestone phase cannot be flipped either.
1408
+ // ADR-2143 §4 note / #2245 audit: this is the phase-LIST checkbox — it
1409
+ // lives in the milestone's `- [ ] Phase N: …` checklist, OUTSIDE any
1410
+ // `### Phase N` detail section, so there is no section for
1411
+ // withPhaseSection to bind to. Migrated onto the sectionizer's
1412
+ // `updateBullet` bullet-write seam: the pattern itself is unchanged,
1413
+ // only the "find the right line, splice it back" plumbing moved off a
1414
+ // whole-slice `.replace()` onto the seam. Applied per single physical
1415
+ // line by updateBullet, so the pattern no longer needs the `m` flag
1416
+ // (it never sees more than one line at a time); see
1417
+ // planCountBodyPattern below for the sites that were migrated onto
1418
+ // withPhaseSection instead.
1419
+ //
1420
+ // #2245 review Fix 6: this is behaviour-preserving for GSD-GENERATED
1421
+ // inputs (the only shape ROADMAP.md ever actually has), NOT byte-parity
1422
+ // across every conceivable input. `updateBullet` is fence-aware — a
1423
+ // checkbox-shaped line inside a fenced (``` / ~~~) code block is never
1424
+ // offered to `match`/`transform` — whereas the retired whole-slice
1425
+ // `.replace()` had no such fence tracking and would have flipped a
1426
+ // bullet-shaped line inside a fence too. That divergence has no live
1427
+ // bug because a GSD-authored ROADMAP.md milestone checklist never puts
1428
+ // its own `- [ ] Phase N: …` entries inside a fenced code block, but it
1429
+ // is a real (and correct) behavioural difference on pathological input.
1430
+ const checkboxPattern = new RegExp(`^[ \\t]*(-\\s*\\[)[ ](\\]\\s*(?:\\*\\*)?\\s*Phase\\s+${phaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s][^\\n]*)`, 'i');
1431
+ // Progress table row: update Plans Complete/Status/Completed columns BY
1432
+ // COLUMN NAME (handles 4- or 5-column RoadmapProgress tables) via the
1433
+ // markdown-table seam (ADR-2143 §7) — supersedes the prior ordinal
1434
+ // cells[]-index regex. Applied inside mutateMilestonePhase below (per
1435
+ // milestone window), further scoped to the ## Progress heading within
1436
+ // that window so the row lookup doesn't bind to an earlier table (e.g.
1437
+ // | Phase | Requirements | Count |) whose rows also start with the
1438
+ // phase number (#2012).
1439
+ // #2245 Blocker 4: optional dot must be followed by whitespace-or-end,
1440
+ // not dot-OR-whitespace-OR-end as alternatives — the prior form let a
1441
+ // bare "." satisfy the whole lookahead, so completing phase "2"
1442
+ // over-matched a decimal sub-phase row like "2.5 Extra". Matches "2",
1443
+ // "2.", "2 Alpha"; rejects "2.5 Extra".
1444
+ const phaseCellRe = new RegExp(`^${phaseEscaped}\\.?(?:\\s|$)`, 'i');
1445
+ const rowMatch = (row) => phaseCellRe.test((row['Phase'] ?? '').trim());
1446
+ const dateShape = /^\d{4}-\d{2}-\d{2}$/;
1447
+ /**
1448
+ * Within `text` (already scoped to one milestone window by the
1449
+ * caller), scope further to the `## Progress` heading section (up to
1450
+ * the next `#`/`##` heading) when present, run `edit` against just
1451
+ * that slice, and splice the result back — falling back to the whole
1452
+ * `text` when no `## Progress` heading exists (mirrors phase-
1453
+ * lifecycle.cjs's deriveProgressFromRoadmap read-side scoping).
1454
+ */
1455
+ const editProgressHeadingSlice = (text, edit) => {
1456
+ const progressMatch = text.match(/^##[ \t]+Progress\b/im);
1457
+ if (!progressMatch || progressMatch.index === undefined) {
1458
+ return edit(text);
1178
1459
  }
1179
- return '|' + cells.join('|') + '|';
1460
+ const headingOffset = progressMatch.index;
1461
+ const beforeHeading = text.slice(0, headingOffset);
1462
+ const fromHeading = text.slice(headingOffset);
1463
+ const nextHeading = fromHeading.search(/\n#{1,2}[ \t]/);
1464
+ const scoped = nextHeading >= 0 ? fromHeading.slice(0, nextHeading) : fromHeading;
1465
+ const after = nextHeading >= 0 ? fromHeading.slice(nextHeading) : '';
1466
+ return beforeHeading + edit(scoped) + after;
1180
1467
  };
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);
1468
+ // ADR-2143 §4: the plan-count write is now routed through
1469
+ // withPhaseSection (see mutateMilestonePhase below), which hands this
1470
+ // pattern ONLY phase N's own detail-section body — so the pattern no
1471
+ // longer needs its own `#{2,4}\s*Phase\s+N` anchor + skip-ahead-past-
1472
+ // interior-headings lookahead; the section boundary itself confines
1473
+ // the match (the #2067/#2200 boundary-crossing class is now
1474
+ // structurally impossible for this site rather than regex-enforced).
1475
+ const planCountBodyPattern = /(\*\*Plans:\*\*\s*)[^\n]+/i;
1476
+ const phaseInfoSummaries = phaseInfo['summaries'];
1477
+ // #2200: apply the phase-checkbox flip, the plan-count write, and the
1478
+ // per-plan checkbox flips ONLY within the current milestone's region(s)
1479
+ // (primary section + optional Phase Details section). A bullet/heading in
1480
+ // a shipped milestone, a Backlog section, or a backticked prose literal is
1481
+ // outside the window and stays untouched. With no versioned active
1482
+ // milestone, fall back to whole-content mutation (prior behaviour).
1483
+ const mutateMilestonePhase = (slice) => {
1484
+ let s = slice;
1485
+ s = (0, markdown_sectionizer_cjs_1.updateBullet)(s, (_bulletText, rawLine) => checkboxPattern.test(rawLine), (rawLine) => rawLine.replace(checkboxPattern, `$1x$2 (completed ${today})`));
1486
+ s = editProgressHeadingSlice(s, (scoped) => {
1487
+ let text = scoped;
1488
+ const plansResult = (0, markdown_table_cjs_1.updateTableCell)(text, rowMatch, 'Plans Complete', ` ${summaryCount}/${planCount} `);
1489
+ if (plansResult.ok)
1490
+ text = plansResult.value;
1491
+ const statusResult = (0, markdown_table_cjs_1.updateTableCell)(text, rowMatch, 'Status', ' Complete ');
1492
+ if (statusResult.ok)
1493
+ text = statusResult.value;
1494
+ // Preserve only a valid ISO date (#1161: idempotent; self-heal
1495
+ // garbage). Ragged-tolerant (#2245 Blocker 2): decide via the
1496
+ // CURRENT Completed cell inside a single updateTableCell callback
1497
+ // (its own tolerant row scan) rather than gating on
1498
+ // findTableWithColumns (which requires the WHOLE table to parse —
1499
+ // a ragged SIBLING row elsewhere used to silently no-op this
1500
+ // row's date stamp too).
1501
+ const completedResult = (0, markdown_table_cjs_1.updateTableCell)(text, rowMatch, 'Completed', (current) => dateShape.test(current.trim()) ? current : ` ${today} `);
1502
+ if (completedResult.ok)
1503
+ text = completedResult.value;
1504
+ return text;
1505
+ });
1506
+ // ADR-2143 §4: the plan-count write and the per-plan checkbox flips
1507
+ // are both scoped to phase N's OWN detail section via
1508
+ // withPhaseSection — the edit callback below only ever sees that
1509
+ // section's body, so neither regex can escape into a sibling
1510
+ // phase's section, a shipped milestone, or a Backlog entry.
1511
+ s = withPhaseSection(s, phaseNum, (body) => {
1512
+ let b = body.replace(planCountBodyPattern, `$1${summaryCount}/${planCount} plans complete`);
1513
+ for (const summaryFile of phaseInfoSummaries) {
1514
+ const planId = summaryFile.replace('-SUMMARY.md', '').replace('SUMMARY.md', '');
1515
+ if (!planId)
1516
+ continue;
1517
+ const planEscaped = escapeRegex(planId);
1518
+ const planCheckboxPattern = new RegExp(`(-\\s*\\[) (\\]\\s*(?:\\*\\*)?${planEscaped}(?:\\*\\*)?)`, 'i');
1519
+ b = b.replace(planCheckboxPattern, '$1x$2');
1520
+ }
1521
+ return b;
1522
+ });
1523
+ return s;
1524
+ };
1525
+ const milestoneRanges = currentMilestoneRawRanges(roadmapContent, cwd);
1526
+ if (milestoneRanges) {
1527
+ // Splice later windows first so an earlier window's offsets are not
1528
+ // shifted by a length-changing mutation in a later window.
1529
+ const windows = [milestoneRanges.details, milestoneRanges.primary]
1530
+ .filter((w) => w !== null)
1531
+ .sort((a, b) => b.start - a.start);
1532
+ for (const w of windows) {
1533
+ roadmapContent =
1534
+ roadmapContent.slice(0, w.start)
1535
+ + mutateMilestonePhase(roadmapContent.slice(w.start, w.end))
1536
+ + roadmapContent.slice(w.end);
1537
+ }
1186
1538
  }
1187
1539
  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');
1540
+ roadmapContent = mutateMilestonePhase(roadmapContent);
1200
1541
  }
1201
1542
  writes.push({
1202
1543
  filePath: roadmapPath,
@@ -1221,7 +1562,24 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1221
1562
  for (const reqId of reqIds) {
1222
1563
  const reqEscaped = escapeRegex(reqId);
1223
1564
  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');
1565
+ // Traceability row: | <REQ-ID> | Phase N | Pending|In Progress | ->
1566
+ // ... Complete | via the markdown-table seam (ADR-2143 §7). Match the
1567
+ // row by its FIRST cell's value (the requirement-ID column) regardless
1568
+ // of that column's HEADER name — real tables head it `REQ-ID`, others
1569
+ // `Requirement` (#2769/#2203); this mirrors the prior regex's first-cell
1570
+ // `\|\s*<id>\s*\|` anchor, not a by-name lookup. Object.values(row) is in
1571
+ // header order, so [0] is the first column. Case-insensitive.
1572
+ const reqRowMatch = (row) => (Object.values(row)[0] ?? '').trim().toLowerCase() === reqId.toLowerCase();
1573
+ // Ragged-tolerant (#2245 Blocker 2): drive the write purely off
1574
+ // updateTableCell's own tolerant row scan — a DIFFERENT
1575
+ // requirement's row elsewhere in the same table having a
1576
+ // mismatched cell count must never silently no-op THIS
1577
+ // requirement's write. The "only flip Pending/In Progress ->
1578
+ // Complete" gate is folded into the newValue callback so one
1579
+ // updateTableCell call both probes and writes.
1580
+ const reqUpdate = updateTraceabilityCell(reqContent, reqRowMatch, 'Status', (current) => /^(?:pending|in progress)$/i.test(current.trim()) ? ' Complete ' : current);
1581
+ if (reqUpdate.ok)
1582
+ reqContent = reqUpdate.value;
1225
1583
  }
1226
1584
  }
1227
1585
  // #1159 (Defect B): collect requirement IDs only from ACTIVE sections.
@@ -1283,7 +1641,11 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1283
1641
  ? reqContent.slice(traceabilityHeadingMatch.index)
1284
1642
  : '';
1285
1643
  const tableReqIds = new Set();
1286
- const tableRowPat = /^\|\s*([A-Z][A-Z0-9]*-\d+)\s*\|/gm;
1644
+ // #2203: match REQ-IDs in any pipe-delimited cell (not just the first
1645
+ // column) so a traceability table that leads with a status column (e.g.
1646
+ // | ☐ | REQ-01 | …) is parsed correctly instead of reporting every row
1647
+ // as missing.
1648
+ const tableRowPat = /\|\s*([A-Z][A-Z0-9]*-\d+)\s*\|/g;
1287
1649
  let tableMatch;
1288
1650
  while ((tableMatch = tableRowPat.exec(traceabilitySection)) !== null) {
1289
1651
  tableReqIds.add(tableMatch[1]);
@@ -1319,7 +1681,13 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1319
1681
  }
1320
1682
  }
1321
1683
  catch {
1322
- /* intentionally empty */
1684
+ /* best-effort (#2245 audit): stage 1 of a deliberate 3-stage
1685
+ * cascading fallback for locating the next phase (disk dirs → roadmap
1686
+ * headings/checkboxes → lowest-outstanding-checkbox override, #2028
1687
+ * below). A disk-scan failure here is indistinguishable from "found
1688
+ * nothing on disk" and correctly falls through to stage 2, which
1689
+ * derives the same information independently from ROADMAP.md content
1690
+ * — not a silent data-loss path. */
1323
1691
  }
1324
1692
  if (isLastPhase && roadmapContent !== null) {
1325
1693
  try {
@@ -1355,10 +1723,13 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1355
1723
  }
1356
1724
  }
1357
1725
  catch {
1358
- /* intentionally empty */
1726
+ /* best-effort (#2245 audit): stage 2 of the next-phase cascade
1727
+ * (see stage 1's comment above) — a failure here just leaves
1728
+ * isLastPhase as stage 1 left it; stage 3 (#2028) below runs next
1729
+ * regardless and provides a further, independent override. */
1359
1730
  }
1360
1731
  }
1361
- // #2028: don't stamp "Milestone complete" when a LOWER-numbered phase is
1732
+ // #2028: don't stamp "All phases complete" when a LOWER-numbered phase is
1362
1733
  // still outstanding. The two blocks above only clear isLastPhase when a
1363
1734
  // HIGHER-numbered phase exists, so completing the numerically-highest phase
1364
1735
  // out of order (e.g. Phase 10 before Phase 9) wrongly read as milestone-end.
@@ -1396,7 +1767,11 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1396
1767
  }
1397
1768
  }
1398
1769
  catch {
1399
- /* intentionally empty */
1770
+ /* best-effort (#2245 audit): stage 3 (#2028) of the next-phase
1771
+ * cascade — a failure here simply leaves isLastPhase/nextPhaseNum
1772
+ * as stages 1-2 already determined them; this stage only ever
1773
+ * overrides toward "not last" when it finds a genuinely lower
1774
+ * outstanding phase, never the reverse. */
1400
1775
  }
1401
1776
  }
1402
1777
  if (node_fs_1.default.existsSync(statePath)) {