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