@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
@@ -15,11 +15,14 @@ const { countMatchedSummaries } = coreUtils;
15
15
  // Excluded derivative files
16
16
  const PLAN_OUTLINE_RE = /-OUTLINE\.md$/i;
17
17
  const PLAN_PRE_BOUNCE_RE = /\.pre-bounce\.md$/i;
18
+ const PLAN_REVIEW_RE = /-PLAN-REVIEW\.md$/i;
18
19
  function isRootPlanFile(fileName) {
19
20
  if (PLAN_OUTLINE_RE.test(fileName))
20
21
  return false;
21
22
  if (PLAN_PRE_BOUNCE_RE.test(fileName))
22
23
  return false;
24
+ if (PLAN_REVIEW_RE.test(fileName))
25
+ return false;
23
26
  if (fileName.endsWith('-PLAN.md') || fileName === 'PLAN.md')
24
27
  return true;
25
28
  // A summary is never a plan. Reject summaries before the loose /PLAN/i
@@ -15,25 +15,42 @@
15
15
  * per-instance CLI flags). An instance is available iff its base `cli` is
16
16
  * detected. The instance→cli mapping lives HERE (single source; see the parity
17
17
  * test in tests/review-reviewer-instances.test.cjs — DEFECT.GENERATIVE-FIX).
18
+ *
19
+ * KNOWN_REVIEWER_SLUGS (post-review #2092): registry-derived, not a flat
20
+ * hand-maintained array. Each capability-runtime descriptor that is a valid
21
+ * reviewer CLI declares `runtime.hostBehaviors.reviewerCli: true`
22
+ * (capabilities/<id>/capability.json); this module reads that flag off the
23
+ * generated capability-registry.cjs at require-time. A handful of reviewer
24
+ * CLIs are NOT install-time runtimes at all (no capabilities/<id>/ descriptor
25
+ * exists) — those stay a small hardcoded tail:
26
+ * - `gemini` — hook-event dialect name only (see runtime-hooks-surface.cts);
27
+ * the Gemini CLI reviewer is not an installable runtime (#1928 folded
28
+ * gemini into antigravity's descriptor).
29
+ * - `coderabbit` / `ollama` / `lm_studio` / `llama_cpp` — third-party
30
+ * review/model CLIs with no GSD install surface at all.
18
31
  */
19
32
  Object.defineProperty(exports, "__esModule", { value: true });
20
33
  exports.INSTANCE_NAME_PATTERN = exports.KNOWN_REVIEWER_SLUGS = void 0;
21
34
  exports.normalizeConfiguredDefaultReviewers = normalizeConfiguredDefaultReviewers;
22
35
  exports.normalizeReviewerInstances = normalizeReviewerInstances;
23
36
  exports.resolveReviewerSelection = resolveReviewerSelection;
24
- exports.KNOWN_REVIEWER_SLUGS = [
37
+ const NON_RUNTIME_REVIEWER_SLUGS = [
25
38
  'gemini',
26
- 'claude',
27
- 'codex',
28
39
  'coderabbit',
29
- 'opencode',
30
- 'qwen',
31
- 'cursor',
32
- 'antigravity',
33
40
  'ollama',
34
41
  'lm_studio',
35
42
  'llama_cpp',
36
43
  ];
44
+ function deriveRuntimeReviewerSlugs() {
45
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
46
+ const registry = require('./capability-registry.cjs');
47
+ const runtimes = registry.runtimes || {};
48
+ return Object.keys(runtimes).filter((id) => runtimes[id]?.runtime?.hostBehaviors?.reviewerCli === true);
49
+ }
50
+ exports.KNOWN_REVIEWER_SLUGS = [
51
+ ...deriveRuntimeReviewerSlugs(),
52
+ ...NON_RUNTIME_REVIEWER_SLUGS,
53
+ ];
37
54
  /** Instance names are lowercase slugs that must not shadow a built-in slug. */
38
55
  exports.INSTANCE_NAME_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
39
56
  function normalizeConfiguredDefaultReviewers(rawValue) {
@@ -14,6 +14,7 @@
14
14
  * - ./phase-id.cjs (escapeRegex, phaseMarkdownRegexSource)
15
15
  * - ./planning-workspace.cjs (planningDir)
16
16
  * - ./shell-command-projection.cjs (platformReadSync)
17
+ * - ./markdown-sectionizer.cjs (tokenizeHeadings, stripTaggedBlocks, withSection)
17
18
  */
18
19
  var __importDefault = (this && this.__importDefault) || function (mod) {
19
20
  return (mod && mod.__esModule) ? mod : { "default": mod };
@@ -173,6 +174,56 @@ function replaceInCurrentMilestone(content, pattern, replacement) {
173
174
  const after = content.slice(offset);
174
175
  return before + after.replace(pattern, replacement);
175
176
  }
177
+ /**
178
+ * Resolve a single phase's detail-section heading (`### Phase N: …`, any level
179
+ * 1–6, via the #2121 phase-id source) and run `edit` against ONLY that
180
+ * section's body. Delegates to `withSection` (markdown-sectionizer.cjs), so a
181
+ * per-phase ROADMAP edit is structurally bounded to that phase's own section —
182
+ * it cannot escape into a sibling phase, a shipped-milestone `<details>` block,
183
+ * or a backticked prose literal (ADR-2143 §4).
184
+ *
185
+ * `content` is expected to already be scoped to the current milestone's raw
186
+ * range(s) by the caller (see `currentMilestoneRawRanges`) — `withPhaseSection`
187
+ * composes with that milestone-level scoping rather than replacing it.
188
+ *
189
+ * The matched phase number must be delimited by whitespace, a colon, an
190
+ * open-paren tag, or end-of-heading — never a bare `\b`. A trailing `\b` sits
191
+ * between the last digit and a following `.` or letter, so it would let a
192
+ * query for phase `1` prefix-match a decimal sub-phase heading like
193
+ * `### Phase 1.1: Sub` or a distinct suffixed phase like `### Phase 1A: …`.
194
+ *
195
+ * The phase token must additionally anchor to the START of the heading text
196
+ * (after an optional leading `[tag]`, mirroring `findRoadmapPhaseInContent`
197
+ * below) — never merely appear anywhere in it. Without this anchor, a query
198
+ * for phase `1` would match a SIBLING phase whose own TITLE happens to
199
+ * mention "Phase 1" (e.g. `### Phase 3: Migrate off Phase 1 legacy pipeline`),
200
+ * and — because `collectSection` picks the first matching heading in document
201
+ * order — that sibling would be hijacked instead of the real Phase 1 section.
202
+ *
203
+ * The section body is bounded by `{ levelBounded: false }`: it ends at the
204
+ * next ATX heading of ANY level, not merely a heading at or above the phase
205
+ * heading's own level. Real ROADMAPs are not guaranteed to use a uniform
206
+ * phase-heading level, so a level-bounded stop could fold a deeper sibling
207
+ * heading (e.g. a `####` phase following a `###` phase) into this phase's
208
+ * body and let `edit` reach into it.
209
+ */
210
+ function withPhaseSection(content, phaseId, edit) {
211
+ const src = phaseMarkdownRegexSource(phaseId);
212
+ const headingRe = new RegExp(`^\\s*(?:\\[[^\\]]{1,200}\\]\\s*)?Phase\\s+${src}(?=[\\s:(]|$)`, 'i');
213
+ return (0, markdown_sectionizer_cjs_1.withSection)(content, (h) => headingRe.test(h.text), edit, { levelBounded: false });
214
+ }
215
+ // ─── Roadmap phase lookup ─────────────────────────────────────────────────────
216
+ // #2199: a bullet/checkbox phase entry, e.g. `- [ ] **Phase 36 — Authentication**`
217
+ // (the bundled roadmapper emits this in bullet-house-style ROADMAPs). The number
218
+ // is captured in group 1, the name in group 2; the separator may be an em-dash,
219
+ // en-dash, hyphen, or colon. Used as a fallback when no ATX heading matches, and
220
+ // to count phases in a milestone that uses the bullet form.
221
+ const BULLET_PHASE_LINE_PATTERN = /^\s*[-*]\s+(?:\[[ xX]\]\s+)?\*\*Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]{0,200}\))?\s*[—–:\-]\s*(.+?)\*\*/im;
222
+ /** Build a bullet-phase-line regex pinned to a specific phase number (#2199). */
223
+ function bulletPhaseLineFor(phaseNum, phaseSource) {
224
+ const num = phaseSource ?? phaseMarkdownRegexSource(phaseNum);
225
+ return new RegExp(`^\\s*[-*]\\s+(?:\\[[ xX]\\]\\s+)?\\*\\*Phase\\s+(${num})${OPTIONAL_PHASE_TAG_SOURCE}\\s*[—–:\\-]\\s*(.+?)\\*\\*`, 'im');
226
+ }
176
227
  function findRoadmapPhaseInContent(content, phaseNum, phaseSource) {
177
228
  // #1729: OPTIONAL_PHASE_TAG_SOURCE after the number tolerates a pre-colon ( ) tag.
178
229
  const headingPattern = new RegExp(`^(?:\\[[^\\]]{1,200}\\]\\s*)?Phase\\s+${phaseSource ?? phaseMarkdownRegexSource(phaseNum)}${OPTIONAL_PHASE_TAG_SOURCE}:\\s*(.+)$`, 'i');
@@ -198,6 +249,22 @@ function findRoadmapPhaseInContent(content, phaseNum, phaseSource) {
198
249
  section,
199
250
  };
200
251
  }
252
+ function findRoadmapBulletPhaseInContent(content, phaseNum, phaseSource) {
253
+ // #2199: bullet/checkbox entry fallback (`- [ ] **Phase N — name**`). Returns
254
+ // the single bullet line as the section (no multi-line body) — used only as a
255
+ // last resort, AFTER heading lookup on scoped + full content has failed, so a
256
+ // heading with a Requirements/Goal section always wins.
257
+ const bulletMatch = content.match(bulletPhaseLineFor(phaseNum, phaseSource));
258
+ if (!bulletMatch)
259
+ return null;
260
+ return {
261
+ found: true,
262
+ phase_number: String(phaseNum),
263
+ phase_name: bulletMatch[2].trim(),
264
+ goal: null,
265
+ section: bulletMatch[0].trim(),
266
+ };
267
+ }
201
268
  function getRoadmapPhaseInternal(cwd, phaseNum) {
202
269
  if (!phaseNum)
203
270
  return null;
@@ -221,12 +288,35 @@ function getRoadmapPhaseInternal(cwd, phaseNum) {
221
288
  if (fullResult)
222
289
  return fullResult;
223
290
  }
291
+ // #2199: no ATX heading matched on scoped or full content — fall back to a
292
+ // bullet/checkbox entry (em-dash/en-dash/hyphen/colon separator). Last resort
293
+ // so a bullet never pre-empts a heading that carries the Requirements section.
294
+ for (const source of roadmapPhaseLookupSources(phaseNum)) {
295
+ const scopedBullet = findRoadmapBulletPhaseInContent(content, phaseNum, source);
296
+ if (scopedBullet)
297
+ return scopedBullet;
298
+ const fullBullet = findRoadmapBulletPhaseInContent(fullContent, phaseNum, source);
299
+ if (fullBullet)
300
+ return fullBullet;
301
+ }
224
302
  return null;
225
303
  }
226
304
  catch {
227
305
  return null;
228
306
  }
229
307
  }
308
+ /**
309
+ * Strip a leading delimiter run (whitespace, em/en-dash, colon, hyphen) from a
310
+ * milestone-name capture. Markdown headings commonly take the shape
311
+ * `## vX.Y — Name` or `## vX.Y: Name`; the raw capture includes the delimiter
312
+ * because `.trim()` only removes whitespace, not punctuation. A name beginning
313
+ * with punctuation is a delimiter-led fragment, not the curated name (#2135).
314
+ * NOTE: do not strip `#` — a name beginning with `#` is a heading-parse failure
315
+ * that should stay loud rather than be silently cleaned.
316
+ */
317
+ function stripLeadingDelimiter(s) {
318
+ return s.replace(/^[\s—–:-]+/, '').trim();
319
+ }
230
320
  function getMilestoneInfo(cwd) {
231
321
  try {
232
322
  const roadmap = (0, shell_command_projection_cjs_1.platformReadSync)(node_path_1.default.join(planningDir(cwd), 'ROADMAP.md'));
@@ -243,23 +333,39 @@ function getMilestoneInfo(cwd) {
243
333
  stateVersion = m[1].trim();
244
334
  }
245
335
  }
246
- catch { /* intentionally empty */ }
336
+ catch {
337
+ /* best-effort (#2245 audit): platformReadSync re-throws for a non-ENOENT
338
+ * failure (e.g. EACCES) reading STATE.md. Consulting STATE.md's
339
+ * `milestone:` field is an OPTIONAL enhancement here — on failure this
340
+ * function already falls back to ROADMAP-only heuristics below, the
341
+ * same fallback path taken when STATE.md simply doesn't exist. */
342
+ }
247
343
  }
248
344
  if (stateVersion) {
249
345
  const escapedVer = escapeRegex(stateVersion);
250
- const headingMatch = roadmap.match(new RegExp(`##[^\\n]*${escapedVer}[:\\s]+([^\\n(]+)`, 'i'));
251
- if (headingMatch) {
252
- if (!headingMatch[0].includes('✅')) {
253
- return { version: stateVersion, name: headingMatch[1].trim() };
254
- }
346
+ // #2135: consult the 🚧 name-bearing marker FIRST. It is the only construct
347
+ // guaranteed to carry the milestone's curated name adjacent to its version
348
+ // (the active-milestone bullet). A `##` heading is often nameless
349
+ // ("## vX.Y — Active Milestone") and, when unanchored, was matched
350
+ // spuriously on a copy quoted inside backticks in this very bullet.
351
+ const listMatch = roadmap.match(new RegExp(`🚧\\s*\\*?\\*?${escapedVer}\\s+([^*\\n]+)`, 'i'));
352
+ if (listMatch) {
353
+ const name = stripLeadingDelimiter(listMatch[1]);
354
+ if (name)
355
+ return { version: stateVersion, name };
255
356
  }
256
- else {
257
- const listMatch = roadmap.match(new RegExp(`🚧\\s*\\*?\\*?${escapedVer}\\s+([^*\\n]+)`, 'i'));
258
- if (listMatch) {
259
- return { version: stateVersion, name: listMatch[1].trim() };
260
- }
261
- return { version: stateVersion, name: 'milestone' };
357
+ // Fall back to the `##` heading — ANCHORED to line start (`^` + `m` flag)
358
+ // so a heading quoted inside backticks or prose mid-line can no longer
359
+ // match. Skip shipped (✅) headings.
360
+ const headingMatch = roadmap.match(new RegExp(`^##[^\\n]*${escapedVer}[:\\s]+([^\\n(]+)`, 'im'));
361
+ if (headingMatch && !headingMatch[0].includes('✅')) {
362
+ // Strip a leading delimiter — `.trim()` removes whitespace, not the
363
+ // em-dash/colon that conventionally separates version from name.
364
+ const name = stripLeadingDelimiter(headingMatch[1]);
365
+ if (name)
366
+ return { version: stateVersion, name };
262
367
  }
368
+ return { version: stateVersion, name: 'milestone' };
263
369
  }
264
370
  const inProgressMatch = roadmap.match(/🚧\s*\*\*v(\d+(?:\.\d+)+)\s+([^*]+)\*\*/);
265
371
  if (inProgressMatch) {
@@ -374,8 +480,26 @@ function getMilestonePhaseFilter(cwd, versionOverride, phaseIdConvention) {
374
480
  if (pm && !/^999\b/.test(pm[1]))
375
481
  milestonePhaseNums.add(pm[1]);
376
482
  }
483
+ // #2199: also count bullet/checkbox phase entries (`- [ ] **Phase N — name**`)
484
+ // so a bullet-house-style ROADMAP populates the milestone phase set instead of
485
+ // collapsing to a zero-count pass-all filter.
486
+ {
487
+ let bm;
488
+ const scanner = new RegExp(BULLET_PHASE_LINE_PATTERN.source, 'gim');
489
+ while ((bm = scanner.exec(roadmap)) !== null) {
490
+ if (!/^999\b/.test(bm[1]))
491
+ milestonePhaseNums.add(bm[1]);
492
+ }
493
+ }
494
+ }
495
+ catch {
496
+ /* best-effort (#2245 audit): the real throw source is platformReadSync
497
+ * at the top of this try (re-throws for a non-ENOENT read failure). On
498
+ * any failure milestonePhaseNums stays empty, which below already
499
+ * degrades to the same pass-all filter this function returns when a
500
+ * ROADMAP genuinely has zero recognizable phase headings — a safe,
501
+ * non-corrupting (over-inclusive, never under-inclusive) degrade. */
377
502
  }
378
- catch { /* intentionally empty */ }
379
503
  if (milestonePhaseNums.size === 0) {
380
504
  const passAll = (() => true);
381
505
  passAll.phaseCount = 0;
@@ -414,6 +538,85 @@ function getMilestonePhaseFilter(cwd, versionOverride, phaseIdConvention) {
414
538
  isDirInMilestone.missingExplicitVersion = missingExplicitVersion;
415
539
  return isDirInMilestone;
416
540
  }
541
+ /**
542
+ * #2200: raw [start,end) offsets of the current milestone's region(s) in ROADMAP
543
+ * content, for scoping write-path mutations (phase-checkbox flip, Plans-count
544
+ * writer) so they cannot touch a backticked prose literal, a Backlog entry, or a
545
+ * same-numbered phase in a shipped milestone.
546
+ *
547
+ * Mirrors the region selection in `extractCurrentMilestone` (version detection →
548
+ * active heading → next milestone boundary → optional Phase Details section).
549
+ * Returns null when there is no versioned active milestone; callers then fall
550
+ * back to whole-content mutation (the prior behaviour).
551
+ *
552
+ * NOTE: keep the region logic here in sync with extractCurrentMilestone.
553
+ */
554
+ function currentMilestoneRawRanges(content, cwd) {
555
+ if (!cwd)
556
+ return null;
557
+ let version = null;
558
+ try {
559
+ const statePath = node_path_1.default.join(planningDir(cwd), 'STATE.md');
560
+ const stateRaw = (0, shell_command_projection_cjs_1.platformReadSync)(statePath);
561
+ if (stateRaw !== null) {
562
+ const milestoneMatch = stateRaw.match(/^milestone:\s*(.+)/m);
563
+ if (milestoneMatch)
564
+ version = milestoneMatch[1].trim();
565
+ }
566
+ }
567
+ catch { /* ignore */ }
568
+ if (!version) {
569
+ const inProgressMatch = content.match(/(?:🚧|🔄)\s*\*\*v(\d+\.\d+)\s/);
570
+ if (inProgressMatch)
571
+ version = 'v' + inProgressMatch[1];
572
+ }
573
+ if (!version)
574
+ return null;
575
+ const escapedVersion = escapeRegex(version);
576
+ const sectionPattern = new RegExp(`(^#{1,3}\\s+(?!Phase\\s+\\S).*${escapedVersion}\\b[^\\n]*)`, 'gmi');
577
+ const headingMatches = [...content.matchAll(sectionPattern)];
578
+ if (headingMatches.length === 0)
579
+ return null;
580
+ const closedMarkerPattern = /\b(?:CLOSED|ARCHIVED|ABANDONED|SHIPPED|FAILED)\b|✅|🗄/i;
581
+ const activeMarkerPattern = /\b(?:STARTED|ACTIVE|WIP)\b|in\s+progress|🚧|🔄/i;
582
+ const isClosed = (h) => closedMarkerPattern.test(h) && !activeMarkerPattern.test(h);
583
+ const firstMatch = headingMatches[0];
584
+ const selected = headingMatches.find((m) => !isClosed(m[1])) || firstMatch;
585
+ const sectionStart = selected.index ?? 0;
586
+ const computeSectionEnd = (headingText, headingStart) => {
587
+ const level = (headingText.match(/^(#{1,3})\s/) ?? ['', '#'])[1].length;
588
+ const afterHeading = headingStart + headingText.length;
589
+ for (const h of (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content)) {
590
+ if (h.offset <= headingStart)
591
+ continue;
592
+ if (h.offset < afterHeading)
593
+ continue;
594
+ if (h.level > level)
595
+ continue;
596
+ if (/^Phase\s+\S/i.test(h.text))
597
+ continue;
598
+ if (!/v\d+\.\d+|✅|📋|🚧/i.test(h.text))
599
+ continue;
600
+ return h.offset;
601
+ }
602
+ return content.length;
603
+ };
604
+ const sectionEnd = computeSectionEnd(selected[0], sectionStart);
605
+ const selectedVersionToken = selected[1].match(/v\d+(?:\.\d+)+(?:[-.][A-Za-z0-9]+)*/i)?.[0];
606
+ const detailsVersionBoundary = selectedVersionToken
607
+ ? new RegExp(`${escapeRegex(selectedVersionToken)}(?![\\w.-])`, 'i')
608
+ : null;
609
+ const detailsMatch = headingMatches.find((m) => /\(Phase\s+Details\)/i.test(m[1]) &&
610
+ !isClosed(m[1]) &&
611
+ (!detailsVersionBoundary || detailsVersionBoundary.test(m[1])) &&
612
+ (m.index ?? 0) >= sectionEnd);
613
+ let details = null;
614
+ if (detailsMatch) {
615
+ const detailsStart = detailsMatch.index ?? 0;
616
+ details = { start: detailsStart, end: computeSectionEnd(detailsMatch[0], detailsStart) };
617
+ }
618
+ return { primary: { start: sectionStart, end: sectionEnd }, details };
619
+ }
417
620
  module.exports = {
418
621
  stripShippedMilestones,
419
622
  extractCurrentMilestone,
@@ -421,4 +624,6 @@ module.exports = {
421
624
  getRoadmapPhaseInternal,
422
625
  getMilestoneInfo,
423
626
  getMilestonePhaseFilter,
627
+ currentMilestoneRawRanges,
628
+ withPhaseSection,
424
629
  };
@@ -25,6 +25,7 @@ const { findPhaseInternal } = phaseLocatorMod;
25
25
  const roadmapParserModule = require("./roadmap-parser.cjs");
26
26
  const { stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone } = roadmapParserModule;
27
27
  const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
28
+ const markdown_table_cjs_1 = require("./markdown-table.cjs");
28
29
  const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
29
30
  // eslint-disable-next-line @typescript-eslint/no-require-imports
30
31
  const planningWorkspace = require("./planning-workspace.cjs");
@@ -297,29 +298,32 @@ function cmdRoadmapAnalyze(cwd, raw) {
297
298
  let summaryCount = 0;
298
299
  let hasContext = false;
299
300
  let hasResearch = false;
300
- try {
301
- const dirMatch = _phaseDirNames.find(d => phaseTokenMatches(d, normalized));
302
- if (dirMatch) {
303
- const counts = countPhasePlansAndSummaries(node_path_1.default.join(phasesDir, dirMatch));
304
- planCount = counts.planCount;
305
- summaryCount = counts.summaryCount;
306
- hasContext = counts.hasContext;
307
- hasResearch = counts.hasResearch;
308
- if (summaryCount >= planCount && planCount > 0)
309
- diskStatus = 'complete';
310
- else if (summaryCount > 0)
311
- diskStatus = 'partial';
312
- else if (planCount > 0)
313
- diskStatus = 'planned';
314
- else if (hasResearch)
315
- diskStatus = 'researched';
316
- else if (hasContext)
317
- diskStatus = 'discussed';
318
- else
319
- diskStatus = 'empty';
320
- }
301
+ // DEAD catch removed (#2245 audit): _phaseDirNames.find(...) is a pure
302
+ // array lookup on an already-resolved string array, and
303
+ // countPhasePlansAndSummaries is itself fully defensive (its own
304
+ // readdirSync is self-guarded, and it delegates to scanPhasePlans, which
305
+ // never throws) — nothing in this block can throw, so the try/catch could
306
+ // never be triggered.
307
+ const dirMatch = _phaseDirNames.find(d => phaseTokenMatches(d, normalized));
308
+ if (dirMatch) {
309
+ const counts = countPhasePlansAndSummaries(node_path_1.default.join(phasesDir, dirMatch));
310
+ planCount = counts.planCount;
311
+ summaryCount = counts.summaryCount;
312
+ hasContext = counts.hasContext;
313
+ hasResearch = counts.hasResearch;
314
+ if (summaryCount >= planCount && planCount > 0)
315
+ diskStatus = 'complete';
316
+ else if (summaryCount > 0)
317
+ diskStatus = 'partial';
318
+ else if (planCount > 0)
319
+ diskStatus = 'planned';
320
+ else if (hasResearch)
321
+ diskStatus = 'researched';
322
+ else if (hasContext)
323
+ diskStatus = 'discussed';
324
+ else
325
+ diskStatus = 'empty';
321
326
  }
322
- catch { /* intentionally empty */ }
323
327
  // Check ROADMAP checkbox status.
324
328
  // #3537: padding-tolerant fragment — the heading discovered above may use
325
329
  // a different padding than the summary-bullet checkbox below it (mixed
@@ -392,6 +396,39 @@ function cmdRoadmapAnalyze(cwd, raw) {
392
396
  output(result, raw, undefined);
393
397
  }
394
398
  // ─── cmdRoadmapUpdatePlanProgress ─────────────────────────────────────────────
399
+ /**
400
+ * Scope a ROADMAP.md content string down to its "Progress table" writable
401
+ * slice, run `edit` against just that slice, then splice the result back into
402
+ * the original content (ADR-2143 §7). Layered scoping:
403
+ * 1. Milestone scope — everything after the LAST `</details>` close tag
404
+ * (mirrors `replaceInCurrentMilestone`), so a same-numbered phase row in
405
+ * an archived milestone is never touched.
406
+ * 2. Heading scope — within that milestone slice, the `## Progress` heading
407
+ * section (up to the next `#`/`##` heading) when present, else the whole
408
+ * milestone slice (mirrors phase-lifecycle.cjs's `deriveProgressFromRoadmap`
409
+ * read-side scoping, #2012 decoy avoidance — a differently-headed table
410
+ * sharing the same column names must not be picked up instead).
411
+ * `edit` always returns a string and never fails — a no-op edit (table/row not
412
+ * found within the scoped slice) simply returns its input unchanged, mirroring
413
+ * the prior regex `.replace()`'s no-match-is-a-no-op semantics.
414
+ */
415
+ function editProgressTableSlice(content, edit) {
416
+ const lastDetailsClose = content.lastIndexOf('</details>');
417
+ const milestoneOffset = lastDetailsClose === -1 ? 0 : lastDetailsClose + '</details>'.length;
418
+ const before = content.slice(0, milestoneOffset);
419
+ const milestoneSlice = content.slice(milestoneOffset);
420
+ const progressMatch = milestoneSlice.match(/^##[ \t]+Progress\b/im);
421
+ if (!progressMatch || progressMatch.index === undefined) {
422
+ return before + edit(milestoneSlice);
423
+ }
424
+ const headingOffset = progressMatch.index;
425
+ const beforeHeading = milestoneSlice.slice(0, headingOffset);
426
+ const fromHeading = milestoneSlice.slice(headingOffset);
427
+ const nextHeading = fromHeading.search(/\n#{1,2}[ \t]/);
428
+ const scoped = nextHeading >= 0 ? fromHeading.slice(0, nextHeading) : fromHeading;
429
+ const after = nextHeading >= 0 ? fromHeading.slice(nextHeading) : '';
430
+ return before + beforeHeading + edit(scoped) + after;
431
+ }
395
432
  function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
396
433
  if (!phaseNum) {
397
434
  error('phase number required for roadmap update-plan-progress');
@@ -418,7 +455,7 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
418
455
  const verificationPassed = readVerificationStatus(phaseDir).status === 'passed';
419
456
  const isComplete = summaryCount >= planCount && verificationPassed;
420
457
  const status = isComplete ? 'Complete' : summaryCount > 0 ? 'In Progress' : 'Planned';
421
- const today = clock_cjs_1.realClock.today();
458
+ const today = clock_cjs_1.realClock.localToday();
422
459
  if (!node_fs_1.default.existsSync(roadmapPath)) {
423
460
  output({ updated: false, reason: 'ROADMAP.md not found', plan_count: planCount, summary_count: summaryCount }, raw, 'no roadmap');
424
461
  return;
@@ -427,32 +464,46 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
427
464
  withPlanningLock(cwd, () => {
428
465
  let roadmapContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
429
466
  const phasePattern = phaseMarkdownRegexSource(phaseNum);
430
- // Progress table row: update Plans/Status/Date columns (handles 4 or 5 column tables)
431
- const tableRowPattern = new RegExp(`^(\\|\\s*${phasePattern}\\.?\\s[^|]*(?:\\|[^\\n]*))$`, 'im');
432
- roadmapContent = roadmapContent.replace(tableRowPattern, (fullRow) => {
433
- const cells = fullRow.split('|').slice(1, -1); // drop leading/trailing empty from split
434
- const dateShape = /^\d{4}-\d{2}-\d{2}$/;
435
- if (cells.length === 5) {
436
- // 5-col: Phase | Milestone | Plans | Status | Completed
437
- cells[2] = ` ${summaryCount}/${planCount} `;
438
- cells[3] = ` ${status.padEnd(11)}`;
439
- // Preserve only a valid ISO date (#1161: idempotent; self-heal garbage)
440
- const existingDate5 = cells[4].trim();
441
- cells[4] = isComplete
442
- ? (dateShape.test(existingDate5) ? cells[4] : ` ${today} `)
443
- : ' ';
444
- }
445
- else if (cells.length === 4) {
446
- // 4-col: Phase | Plans | Status | Completed
447
- cells[1] = ` ${summaryCount}/${planCount} `;
448
- cells[2] = ` ${status.padEnd(11)}`;
449
- // Preserve only a valid ISO date (#1161: idempotent; self-heal garbage)
450
- const existingDate4 = cells[3].trim();
451
- cells[3] = isComplete
452
- ? (dateShape.test(existingDate4) ? cells[3] : ` ${today} `)
453
- : ' ';
454
- }
455
- return '|' + cells.join('|') + '|';
467
+ // Progress table row: update Plans Complete/Status/Completed columns BY
468
+ // COLUMN NAME (handles 4- or 5-column RoadmapProgress tables regardless of
469
+ // Milestone-column presence) via the markdown-table seam (ADR-2143 §7) —
470
+ // supersedes the prior ordinal cells[]-index regex. Scoped to the current
471
+ // milestone's `## Progress` table (editProgressTableSlice above).
472
+ // #2245 Blocker 4: optional dot must be followed by whitespace-or-end, not
473
+ // dot-OR-whitespace-OR-end as alternatives — the prior form let a bare "."
474
+ // satisfy the whole lookahead, so completing phase "2" over-matched a
475
+ // decimal sub-phase row like "2.5 Extra". Matches "2", "2.", "2 Alpha";
476
+ // rejects "2.5 Extra" (replicates OLD's `\.?\s` intent on the now-TRIMMED
477
+ // cell value, where end-of-string is the trimmed equivalent of "no more
478
+ // characters after the optional dot").
479
+ const phaseCellRe = new RegExp(`^${phasePattern}\\.?(?:\\s|$)`, 'i');
480
+ const rowMatch = (row) => phaseCellRe.test((row['Phase'] ?? '').trim());
481
+ const dateShape = /^\d{4}-\d{2}-\d{2}$/;
482
+ roadmapContent = editProgressTableSlice(roadmapContent, (scoped) => {
483
+ let text = scoped;
484
+ const plansResult = (0, markdown_table_cjs_1.updateTableCell)(text, rowMatch, 'Plans Complete', ` ${summaryCount}/${planCount} `);
485
+ if (plansResult.ok)
486
+ text = plansResult.value;
487
+ const statusResult = (0, markdown_table_cjs_1.updateTableCell)(text, rowMatch, 'Status', ` ${status.padEnd(11)}`);
488
+ if (statusResult.ok)
489
+ text = statusResult.value;
490
+ // Preserve only a valid ISO date (#1161: idempotent; self-heal garbage).
491
+ // Ragged-tolerant (#2245 Blocker 2): probe the CURRENT Completed cell via
492
+ // a no-op updateTableCell write (its own tolerant row scan) rather than
493
+ // findTableWithColumns (which requires the WHOLE table to parse — a
494
+ // ragged SIBLING row elsewhere used to silently no-op this row's date
495
+ // stamp/clear too). The decision (write vs no-op) is folded into the
496
+ // newValue callback so a single updateTableCell call both reads and
497
+ // writes.
498
+ const completedResult = (0, markdown_table_cjs_1.updateTableCell)(text, rowMatch, 'Completed', (current) => {
499
+ if (isComplete) {
500
+ return dateShape.test(current.trim()) ? current : ` ${today} `;
501
+ }
502
+ return ' ';
503
+ });
504
+ if (completedResult.ok)
505
+ text = completedResult.value;
506
+ return text;
456
507
  });
457
508
  // Update plan count in phase detail section.
458
509
  // Three recognised forms (all tolerated; canonical template uses the first):