@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/agents/gsd-executor.md +2 -1
- package/agents/gsd-security-auditor.md +13 -15
- package/agents/gsd-ui-checker.md +2 -0
- package/agents/gsd-ui-researcher.md +1 -0
- package/bin/install.js +975 -196
- package/commands/gsd/mempalace-capture.md +27 -1
- package/commands/gsd/surface.md +6 -6
- package/gsd-core/bin/gsd-tools.cjs +63 -2
- package/gsd-core/bin/lib/api-coverage.cjs +3 -4
- package/gsd-core/bin/lib/audit.cjs +7 -6
- package/gsd-core/bin/lib/capability-registry.cjs +503 -87
- package/gsd-core/bin/lib/capability-validator.cjs +56 -18
- package/gsd-core/bin/lib/check-command-router.cjs +1 -1
- package/gsd-core/bin/lib/clock.cjs +19 -0
- package/gsd-core/bin/lib/commands.cjs +48 -9
- package/gsd-core/bin/lib/config-loader.cjs +6 -2
- package/gsd-core/bin/lib/config.cjs +12 -0
- package/gsd-core/bin/lib/core-utils.cjs +8 -2
- package/gsd-core/bin/lib/drift.cjs +4 -4
- package/gsd-core/bin/lib/frontmatter.cjs +22 -0
- package/gsd-core/bin/lib/gsd2-import.cjs +2 -1
- package/gsd-core/bin/lib/host-integration.cjs +33 -8
- package/gsd-core/bin/lib/init.cjs +60 -53
- package/gsd-core/bin/lib/install-engine.cjs +93 -22
- package/gsd-core/bin/lib/installer-migration-authoring.cjs +2 -1
- package/gsd-core/bin/lib/installer-migration-report.cjs +3 -0
- package/gsd-core/bin/lib/installer-migrations.cjs +1 -1
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +342 -0
- package/gsd-core/bin/lib/markdown-table.cjs +698 -0
- package/gsd-core/bin/lib/mcp-server.cjs +18 -7
- package/gsd-core/bin/lib/milestone.cjs +217 -31
- package/gsd-core/bin/lib/phase-command-router.cjs +50 -2
- 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 +436 -61
- package/gsd-core/bin/lib/plan-scan.cjs +3 -0
- package/gsd-core/bin/lib/review-reviewer-selection.cjs +24 -7
- package/gsd-core/bin/lib/roadmap-parser.cjs +218 -13
- package/gsd-core/bin/lib/roadmap.cjs +100 -49
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +242 -44
- package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +3 -2
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +24 -14
- package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +19 -5
- package/gsd-core/bin/lib/runtime-homes.cjs +22 -0
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +526 -29
- package/gsd-core/bin/lib/runtime-name-policy.cjs +63 -5
- package/gsd-core/bin/lib/schema-detect.cjs +2 -1
- package/gsd-core/bin/lib/security.cjs +7 -37
- package/gsd-core/bin/lib/shell-command-projection.cjs +176 -27
- package/gsd-core/bin/lib/smart-entry.cjs +4 -3
- package/gsd-core/bin/lib/stale-bake-guard.cjs +30 -10
- package/gsd-core/bin/lib/state-transition.cjs +100 -45
- package/gsd-core/bin/lib/state.cjs +391 -126
- package/gsd-core/bin/lib/surface.cjs +12 -8
- package/gsd-core/bin/lib/template.cjs +2 -1
- package/gsd-core/bin/lib/uat.cjs +54 -8
- package/gsd-core/bin/lib/ui-consideration-probe.cjs +249 -0
- package/gsd-core/bin/lib/ui-safety-gate.cjs +23 -1
- package/gsd-core/bin/lib/verify.cjs +4 -3
- 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 +2 -0
- package/gsd-core/bin/shared/model-catalog.json +8 -3
- package/gsd-core/references/checkpoints.md +12 -0
- package/gsd-core/references/ui-consideration-probe.md +73 -0
- package/gsd-core/templates/UI-SPEC.md +25 -0
- package/gsd-core/templates/VALIDATION.md +2 -0
- package/gsd-core/workflows/add-tests.md +1 -1
- package/gsd-core/workflows/audit-milestone.md +7 -4
- package/gsd-core/workflows/debug.md +2 -0
- package/gsd-core/workflows/execute-phase.md +5 -3
- package/gsd-core/workflows/fast.md +8 -22
- package/gsd-core/workflows/plan-phase.md +6 -0
- package/gsd-core/workflows/progress.md +2 -2
- package/gsd-core/workflows/quick.md +2 -0
- package/gsd-core/workflows/review.md +42 -3
- package/gsd-core/workflows/secure-phase.md +1 -1
- package/gsd-core/workflows/settings-advanced.md +7 -4
- package/gsd-core/workflows/ship.md +8 -2
- package/gsd-core/workflows/spec-phase.md +1 -1
- package/gsd-core/workflows/transition.md +1 -1
- package/gsd-core/workflows/ui-phase.md +146 -1
- package/gsd-core/workflows/validate-phase.md +2 -2
- package/hooks/dist/gsd-statusline.js +164 -14
- package/hooks/dist/gsd-windsurf-pre-command.js +275 -0
- package/hooks/dist/gsd-windsurf-pre-write.js +132 -0
- package/hooks/dist/managed-hooks-registry.cjs +2 -0
- package/hooks/gsd-statusline.js +164 -14
- package/hooks/gsd-windsurf-pre-command.js +275 -0
- package/hooks/gsd-windsurf-pre-write.js +132 -0
- package/hooks/managed-hooks-registry.cjs +2 -0
- package/package.json +10 -4
- package/pi/gsd.cjs +354 -0
- package/scripts/build-hooks.js +3 -0
- package/scripts/ci-test-scope.cjs +39 -1
- package/scripts/gen-golden-install-parity-zcode.cjs +35 -35
- package/scripts/gen-install-tree-fixtures.cjs +75 -0
- package/scripts/gen-registry.cjs +128 -0
- package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -1
- package/scripts/lint-table-schema-drift.cjs +157 -0
- package/scripts/lint-test-file-count.allowlist.json +2 -1
- package/scripts/registry-schema.cjs +565 -0
- package/scripts/validate-registry.cjs +117 -0
- package/skills/gsd-mempalace-capture/SKILL.md +27 -1
- package/skills/gsd-surface/SKILL.md +6 -6
- package/vscode/browser.js +197 -0
- package/vscode/extension.js +383 -0
- package/vscode/host-binding.js +113 -0
- 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
|
-
|
|
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 {
|
|
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;
|
|
@@ -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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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.
|
|
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/
|
|
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
|
-
|
|
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):
|