@opengsd/gsd-core 1.6.0-rc.3 → 1.6.1
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/plugin.json +1 -1
- package/agents/gsd-advisor-researcher.md +2 -0
- package/agents/gsd-ai-researcher.md +2 -0
- package/agents/gsd-assumptions-analyzer.md +2 -0
- package/agents/gsd-doc-classifier.md +2 -0
- package/agents/gsd-doc-synthesizer.md +2 -0
- package/agents/gsd-domain-researcher.md +2 -0
- package/agents/gsd-eval-auditor.md +6 -9
- package/agents/gsd-phase-researcher.md +2 -0
- package/agents/gsd-project-researcher.md +2 -0
- package/agents/gsd-research-synthesizer.md +2 -0
- package/agents/gsd-ui-researcher.md +2 -0
- package/bin/install.js +219 -5
- package/gemini-extension.json +1 -1
- package/gsd-core/bin/gsd-tools.cjs +11 -1
- package/gsd-core/bin/lib/capability-registry.cjs +48 -48
- package/gsd-core/bin/lib/command-aliases.cjs +10 -1
- package/gsd-core/bin/lib/decisions.cjs +27 -0
- package/gsd-core/bin/lib/eval-command-router.cjs +21 -0
- package/gsd-core/bin/lib/eval.cjs +60 -0
- package/gsd-core/bin/lib/frontmatter.cjs +132 -13
- package/gsd-core/bin/lib/init.cjs +131 -26
- package/gsd-core/bin/lib/io.cjs +1 -0
- package/gsd-core/bin/lib/milestone.cjs +8 -0
- package/gsd-core/bin/lib/phase.cjs +29 -3
- package/gsd-core/bin/lib/plan-scan.cjs +2 -2
- package/gsd-core/bin/lib/roadmap.cjs +12 -1
- package/gsd-core/bin/lib/shell-command-projection.cjs +31 -1
- package/gsd-core/bin/lib/state.cjs +34 -8
- package/gsd-core/bin/lib/uat-predicate.cjs +13 -6
- package/gsd-core/bin/lib/verification.cjs +67 -6
- package/gsd-core/bin/lib/verify.cjs +8 -1
- package/gsd-core/bin/shared/config-defaults.manifest.json +3 -0
- package/gsd-core/bin/shared/config-schema.manifest.json +2 -1
- package/gsd-core/bin/shared/model-catalog.json +8 -8
- package/gsd-core/references/untrusted-input-boundary.md +13 -0
- package/gsd-core/workflows/autonomous.md +53 -46
- package/gsd-core/workflows/complete-milestone.md +27 -8
- package/gsd-core/workflows/execute-phase.md +1 -1
- package/gsd-core/workflows/manager.md +17 -7
- package/gsd-core/workflows/new-project.md +78 -12
- package/gsd-core/workflows/profile-user.md +6 -2
- package/gsd-core/workflows/progress.md +37 -4
- package/gsd-core/workflows/quick.md +3 -1
- package/gsd-core/workflows/settings-advanced.md +10 -10
- package/gsd-core/workflows/ship.md +3 -1
- package/gsd-core/workflows/spec-phase.md +3 -1
- package/gsd-core/workflows/transition.md +14 -12
- package/gsd-core/workflows/ui-review.md +2 -6
- package/gsd-core/workflows/verify-work.md +44 -0
- package/hooks/dist/gsd-read-injection-scanner.js +49 -25
- package/hooks/gsd-read-injection-scanner.js +49 -25
- package/hooks/hooks.json +1 -1
- package/package.json +1 -1
- package/scripts/check-alias-drift.cjs +5 -0
- package/scripts/prompt-injection-scan.sh +5 -0
|
@@ -205,20 +205,59 @@ function reconstructFrontmatter(obj) {
|
|
|
205
205
|
}
|
|
206
206
|
return lines.join('\n');
|
|
207
207
|
}
|
|
208
|
+
/**
|
|
209
|
+
* Slice a frontmatter YAML body into per-top-level-key raw text segments. Each segment
|
|
210
|
+
* runs from a column-0 `key:` line through the line before the next column-0 key (or the
|
|
211
|
+
* end), capturing all nested indented content. Used by `spliceFrontmatter` for per-key
|
|
212
|
+
* identity preservation (#1572): a structurally-unchanged key keeps its original raw
|
|
213
|
+
* text, so the lossy `reconstructFrontmatter` never touches object-lists the caller did
|
|
214
|
+
* not modify (e.g. must_haves.artifacts / .prohibitions).
|
|
215
|
+
*/
|
|
216
|
+
function sliceTopLevelFrontmatterSegments(yaml) {
|
|
217
|
+
const lines = yaml.split(/\r?\n/);
|
|
218
|
+
const segments = [];
|
|
219
|
+
let current = null;
|
|
220
|
+
for (const line of lines) {
|
|
221
|
+
// A column-0 `key:` (no leading whitespace) starts a new top-level segment.
|
|
222
|
+
if (/^[A-Za-z0-9_-]+:/.test(line)) {
|
|
223
|
+
if (current)
|
|
224
|
+
segments.push({ key: current.key, raw: current.raw.join('\n') });
|
|
225
|
+
const keyName = line.match(/^([A-Za-z0-9_-]+):/)[1];
|
|
226
|
+
current = { key: keyName, raw: [line] };
|
|
227
|
+
}
|
|
228
|
+
else if (current) {
|
|
229
|
+
current.raw.push(line);
|
|
230
|
+
}
|
|
231
|
+
// Stray lines before the first top-level key (rare in frontmatter) are dropped.
|
|
232
|
+
}
|
|
233
|
+
if (current)
|
|
234
|
+
segments.push({ key: current.key, raw: current.raw.join('\n') });
|
|
235
|
+
return segments;
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Regenerate one frontmatter key's serialization, fail-closed if the lossy
|
|
239
|
+
* `reconstructFrontmatter` cannot represent the value (#1572 codex review). Object-list
|
|
240
|
+
* items (e.g. must_haves.artifacts `{path, provides}` maps) serialize as the literal
|
|
241
|
+
* string "[object Object]"; rather than silently emit that and destroy the data, refuse
|
|
242
|
+
* so the caller (cmdFrontmatterSet/Merge) errors out WITHOUT writing — directing the
|
|
243
|
+
* user to edit the file directly. The reported #1572 case (mutating an UNRELATED field)
|
|
244
|
+
* is unaffected: unchanged keys preserve their original raw text and never reach here.
|
|
245
|
+
*/
|
|
246
|
+
function regenerateFrontmatterKey(key, value) {
|
|
247
|
+
const rendered = reconstructFrontmatter({ [key]: value });
|
|
248
|
+
if (/\[object Object\]/.test(rendered)) {
|
|
249
|
+
throw new Error(`frontmatter: cannot faithfully serialize key "${key}" — it contains a nested object-list ` +
|
|
250
|
+
`(e.g. must_haves.artifacts) the frontmatter writer cannot represent, and serializing it would ` +
|
|
251
|
+
`emit "[object Object]". Edit the file directly instead of using frontmatter set/merge.`);
|
|
252
|
+
}
|
|
253
|
+
return rendered;
|
|
254
|
+
}
|
|
208
255
|
function spliceFrontmatter(content, newObj) {
|
|
209
256
|
const match = content.match(/^---\r?\n[\s\S]+?\r?\n---/);
|
|
210
257
|
if (match) {
|
|
211
|
-
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
// / `{ statement, status, … }` maps). When the caller is writing back a value that is
|
|
215
|
-
// STRUCTURALLY UNCHANGED from the original parse (the canonical CRUD round-trip and the
|
|
216
|
-
// #644 prohibition schema round-trip both do this), regenerating from the lossy object would
|
|
217
|
-
// silently mangle those blocks. Detect that case by deep-equality against a re-parse of the
|
|
218
|
-
// original frontmatter and preserve the ORIGINAL raw text verbatim — a true no-op splice.
|
|
219
|
-
// This touches neither the parser (`extractFrontmatter`) nor `parseMustHavesBlock`; it only
|
|
220
|
-
// makes the existing splice faithful when nothing changed. A genuine mutation (different
|
|
221
|
-
// object) still flows through `reconstructFrontmatter` exactly as before.
|
|
258
|
+
const fmBlock = match[0];
|
|
259
|
+
// Whole-document no-op guard: a true no-op returns content verbatim (byte-exact,
|
|
260
|
+
// including any formatting the lossy serializer would normalize).
|
|
222
261
|
try {
|
|
223
262
|
if (frontmatterDeepEqual(extractFrontmatter(content), newObj)) {
|
|
224
263
|
return content;
|
|
@@ -227,10 +266,63 @@ function spliceFrontmatter(content, newObj) {
|
|
|
227
266
|
catch {
|
|
228
267
|
/* fall through to regeneration on any comparison hiccup */
|
|
229
268
|
}
|
|
230
|
-
|
|
231
|
-
|
|
269
|
+
// Per-key identity preservation (#1572). `reconstructFrontmatter` is a deliberately
|
|
270
|
+
// lossy serializer — it cannot faithfully re-emit nested object-list items (e.g.
|
|
271
|
+
// must_haves.artifacts / .prohibitions, whose items are `{ path, provides }` /
|
|
272
|
+
// `{ statement, status }` maps; `extractFrontmatter` flattens those to scalar
|
|
273
|
+
// strings, so a round-trip drops `provides:` and collapses the list to a malformed
|
|
274
|
+
// inline array). For any top-level key whose value is STRUCTURALLY UNCHANGED between
|
|
275
|
+
// the original parse and `newObj`, preserve that key's ORIGINAL raw text verbatim;
|
|
276
|
+
// regenerate only keys that actually changed. This generalizes the whole-document
|
|
277
|
+
// no-op guard above to per-key fidelity, so mutating `wave` no longer destroys an
|
|
278
|
+
// unrelated `must_haves` block. Keys absent from the original (genuinely new) are
|
|
279
|
+
// regenerated and appended; keys absent from `newObj` are preserved (never silently
|
|
280
|
+
// deleted by a set/merge).
|
|
281
|
+
const fmLines = fmBlock.split(/\r?\n/);
|
|
282
|
+
const inner = fmLines.slice(1, -1).join('\n'); // drop the opening `---` and closing `---`
|
|
283
|
+
let originalParsed;
|
|
284
|
+
try {
|
|
285
|
+
originalParsed = extractFrontmatter(fmBlock);
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
originalParsed = {};
|
|
289
|
+
}
|
|
290
|
+
const segments = sliceTopLevelFrontmatterSegments(inner);
|
|
291
|
+
const emitted = [];
|
|
292
|
+
const seen = new Set();
|
|
293
|
+
for (const seg of segments) {
|
|
294
|
+
seen.add(seg.key);
|
|
295
|
+
if (Object.prototype.hasOwnProperty.call(newObj, seg.key)) {
|
|
296
|
+
// Key is in newObj: preserve original raw text if structurally unchanged,
|
|
297
|
+
// otherwise regenerate. The key SET is defined by newObj — keys that were in
|
|
298
|
+
// the original but are absent from newObj are intentionally dropped (the real
|
|
299
|
+
// cmdSet/cmdMerge flow always passes the full merged object, so this only
|
|
300
|
+
// matters for direct unit callers and matches spliceFrontmatter's contract:
|
|
301
|
+
// the result frontmatter IS newObj).
|
|
302
|
+
if (frontmatterDeepEqual(newObj[seg.key], originalParsed[seg.key])) {
|
|
303
|
+
emitted.push(seg.raw); // unchanged → preserve original raw text verbatim
|
|
304
|
+
}
|
|
305
|
+
else {
|
|
306
|
+
emitted.push(regenerateFrontmatterKey(seg.key, newObj[seg.key])); // changed → regenerate (fail-closed on object-lists)
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
// else: key absent from newObj → drop (not emitted).
|
|
310
|
+
}
|
|
311
|
+
// Append genuinely-new keys not present in the original frontmatter.
|
|
312
|
+
for (const k of Object.keys(newObj)) {
|
|
313
|
+
if (!seen.has(k)) {
|
|
314
|
+
emitted.push(regenerateFrontmatterKey(k, newObj[k]));
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
const yamlStr = emitted.join('\n');
|
|
318
|
+
return `---\n${yamlStr}\n---` + content.slice(fmBlock.length);
|
|
232
319
|
}
|
|
320
|
+
// No existing frontmatter — generate from scratch, fail-closed on unrepresentable values.
|
|
233
321
|
const yamlStr = reconstructFrontmatter(newObj);
|
|
322
|
+
if (/\[object Object\]/.test(yamlStr)) {
|
|
323
|
+
throw new Error('frontmatter: cannot faithfully serialize the requested frontmatter — it contains a nested ' +
|
|
324
|
+
'object-list (e.g. must_haves.artifacts) the writer cannot represent. Edit the file directly.');
|
|
325
|
+
}
|
|
234
326
|
return `---\n${yamlStr}\n---\n\n` + content;
|
|
235
327
|
}
|
|
236
328
|
/**
|
|
@@ -434,9 +526,35 @@ function cmdFrontmatterSet(cwd, filePath, field, value, raw) {
|
|
|
434
526
|
}
|
|
435
527
|
fm[field] = parsedValue;
|
|
436
528
|
const newContent = spliceFrontmatter(content, fm);
|
|
529
|
+
// #1660: a no-op set (newContent unchanged) with a dict-valued field means the lossy
|
|
530
|
+
// frontmatter parser made the new value's projection equal the original's — the change
|
|
531
|
+
// did not apply (bites object-list fields like must_haves). Detection lives in the pure
|
|
532
|
+
// exported helper noOpObjectListSetError so the mutation gate (property/unit set) covers
|
|
533
|
+
// it — the cmd path itself is not in that set.
|
|
534
|
+
const noOpErr = noOpObjectListSetError(content, newContent, parsedValue);
|
|
535
|
+
if (noOpErr) {
|
|
536
|
+
output({ error: noOpErr, field }, raw, undefined);
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
437
539
|
(0, shell_command_projection_cjs_1.platformWriteSync)(fullPath, newContent);
|
|
438
540
|
output({ updated: true, field, value: parsedValue }, raw, 'true');
|
|
439
541
|
}
|
|
542
|
+
/**
|
|
543
|
+
* #1660: detect a frontmatter `set` that would be a silent no-op on a dict-valued field.
|
|
544
|
+
* Returns an error message when the splice produced no content change but the new value
|
|
545
|
+
* is a dict (object-list fields like must_haves, whose `{path, provides}` items flatten to
|
|
546
|
+
* scalar strings under extractFrontmatter so a replacement can deep-equal the original's
|
|
547
|
+
* projection), else null. Scalars and scalar arrays round-trip faithfully, so idempotent
|
|
548
|
+
* sets of those are intentionally NOT flagged. Pure and unit-tested directly (the cmd path
|
|
549
|
+
* is not in Stryker's property/unit set, so the detection must be testable in isolation).
|
|
550
|
+
*/
|
|
551
|
+
function noOpObjectListSetError(originalContent, newContent, parsedValue) {
|
|
552
|
+
if (newContent !== originalContent)
|
|
553
|
+
return null;
|
|
554
|
+
if (parsedValue === null || typeof parsedValue !== 'object' || Array.isArray(parsedValue))
|
|
555
|
+
return null;
|
|
556
|
+
return 'frontmatter set had no effect — the supplied value is equivalent to the existing field under the frontmatter parser, which cannot faithfully round-trip object-list fields like must_haves. Edit the file directly.';
|
|
557
|
+
}
|
|
440
558
|
function cmdFrontmatterMerge(cwd, filePath, data, raw) {
|
|
441
559
|
if (!filePath || !data) {
|
|
442
560
|
error('file and data required');
|
|
@@ -489,6 +607,7 @@ module.exports = {
|
|
|
489
607
|
parseFrontmatter: extractFrontmatter,
|
|
490
608
|
reconstructFrontmatter,
|
|
491
609
|
spliceFrontmatter,
|
|
610
|
+
noOpObjectListSetError,
|
|
492
611
|
parseMustHavesBlock,
|
|
493
612
|
FRONTMATTER_SCHEMAS,
|
|
494
613
|
cmdFrontmatterGet,
|
|
@@ -43,6 +43,10 @@ const security_cjs_1 = require("./security.cjs");
|
|
|
43
43
|
const runtime_homes_cjs_1 = require("./runtime-homes.cjs");
|
|
44
44
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- frontmatter.cjs is an export= CommonJS module
|
|
45
45
|
const frontmatterMod = require("./frontmatter.cjs");
|
|
46
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports -- verification.cjs is an export= CommonJS module
|
|
47
|
+
const verificationMod = require("./verification.cjs");
|
|
48
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports -- uat-predicate.cjs is an export= CommonJS module
|
|
49
|
+
const uatPredicateMod = require("./uat-predicate.cjs");
|
|
46
50
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- agent-install-check.cjs is an export= CommonJS module
|
|
47
51
|
const agentInstallCheck = require("./agent-install-check.cjs");
|
|
48
52
|
const { checkAgentsInstalled } = agentInstallCheck;
|
|
@@ -61,6 +65,8 @@ const { pruneOrphanedWorktrees } = worktreeSafety;
|
|
|
61
65
|
const { planningPaths, planningDir, planningRoot, findContextMdIn, } = planningWorkspace;
|
|
62
66
|
const { determinePhaseStatus } = commandsMod;
|
|
63
67
|
const { extractFrontmatter } = frontmatterMod;
|
|
68
|
+
const { readVerificationStatus } = verificationMod;
|
|
69
|
+
const { evaluateUatPassed } = uatPredicateMod;
|
|
64
70
|
// Unused but imported for structural parity
|
|
65
71
|
void stripShippedMilestones;
|
|
66
72
|
// Accept all bold/colon variants of the Requirements header (#2769)
|
|
@@ -71,6 +77,45 @@ function listPhaseSummaryFiles(phaseDir) {
|
|
|
71
77
|
function listPhasePlanFiles(phaseDir) {
|
|
72
78
|
return scanPhasePlans(phaseDir)['planFiles'];
|
|
73
79
|
}
|
|
80
|
+
function verificationNextCommand(status, phaseNumber, slashRuntime) {
|
|
81
|
+
if (status === 'gaps_found') {
|
|
82
|
+
return `${(0, runtime_slash_cjs_1.formatGsdSlash)('plan-phase', slashRuntime)} ${phaseNumber} --gaps`;
|
|
83
|
+
}
|
|
84
|
+
if (status === 'human_needed' || status === 'stale') {
|
|
85
|
+
return `${(0, runtime_slash_cjs_1.formatGsdSlash)('verify-work', slashRuntime)} ${phaseNumber}`;
|
|
86
|
+
}
|
|
87
|
+
if (status === 'missing' || status === 'unknown') {
|
|
88
|
+
return `${(0, runtime_slash_cjs_1.formatGsdSlash)('execute-phase', slashRuntime)} ${phaseNumber}`;
|
|
89
|
+
}
|
|
90
|
+
return '';
|
|
91
|
+
}
|
|
92
|
+
function projectCompletionStatus(implementationComplete, verificationPassed) {
|
|
93
|
+
if (implementationComplete && verificationPassed)
|
|
94
|
+
return 'complete';
|
|
95
|
+
if (implementationComplete)
|
|
96
|
+
return 'executed';
|
|
97
|
+
return 'incomplete';
|
|
98
|
+
}
|
|
99
|
+
function buildPhaseCompletionProjection(cwd, phaseNumber, phaseDir, planCount, summaryCount, slashRuntime) {
|
|
100
|
+
const implementationComplete = planCount > 0 && summaryCount >= planCount;
|
|
101
|
+
const phaseFullDir = phaseDir ? node_path_1.default.join(cwd, phaseDir) : '';
|
|
102
|
+
const verificationStatus = implementationComplete
|
|
103
|
+
? readVerificationStatus(phaseFullDir)
|
|
104
|
+
: { status: 'not_required', next_action: '', next_command: '' };
|
|
105
|
+
const projectedVerificationStatus = verificationStatus.status;
|
|
106
|
+
const projectedVerificationAction = verificationStatus.next_action;
|
|
107
|
+
const verificationPassed = projectedVerificationStatus === 'passed';
|
|
108
|
+
const phaseComplete = implementationComplete && verificationPassed;
|
|
109
|
+
return {
|
|
110
|
+
implementation_complete: implementationComplete,
|
|
111
|
+
verification_status: projectedVerificationStatus,
|
|
112
|
+
verification_passed: verificationPassed,
|
|
113
|
+
phase_complete: phaseComplete,
|
|
114
|
+
completion_status: projectCompletionStatus(implementationComplete, verificationPassed),
|
|
115
|
+
verification_next_action: projectedVerificationAction,
|
|
116
|
+
verification_next_command: verificationNextCommand(projectedVerificationStatus, phaseNumber, slashRuntime),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
74
119
|
function getLatestCompletedMilestone(cwd) {
|
|
75
120
|
const milestonesPath = node_path_1.default.join(planningRoot(cwd), 'MILESTONES.md');
|
|
76
121
|
const content = (0, shell_command_projection_cjs_1.platformReadSync)(milestonesPath);
|
|
@@ -636,6 +681,7 @@ function cmdInitVerifyWork(cwd, phase, raw) {
|
|
|
636
681
|
error('phase required for init verify-work');
|
|
637
682
|
}
|
|
638
683
|
const config = loadConfig(cwd);
|
|
684
|
+
const _slashRuntime = (0, runtime_slash_cjs_1.resolveRuntime)(cwd);
|
|
639
685
|
let phaseInfo = findPhaseInternal(cwd, phase);
|
|
640
686
|
if (phaseInfo?.['archived']) {
|
|
641
687
|
const roadmapPhase = getRoadmapPhaseInternal(cwd, phase);
|
|
@@ -664,15 +710,30 @@ function cmdInitVerifyWork(cwd, phase, raw) {
|
|
|
664
710
|
};
|
|
665
711
|
}
|
|
666
712
|
}
|
|
713
|
+
const phaseDir = phaseInfo?.['directory'] || null;
|
|
714
|
+
const planCount = phaseInfo?.['plans']?.length || 0;
|
|
715
|
+
const summaryCount = phaseInfo?.['summaries']?.length || 0;
|
|
716
|
+
const completion = buildPhaseCompletionProjection(cwd, phaseInfo?.['phase_number'] || phase, phaseDir, planCount, summaryCount, _slashRuntime);
|
|
717
|
+
const uatReport = phaseDir
|
|
718
|
+
? evaluateUatPassed(node_path_1.default.join(cwd, phaseDir), {
|
|
719
|
+
policy: { requireVerification: true },
|
|
720
|
+
})
|
|
721
|
+
: null;
|
|
667
722
|
const result = {
|
|
668
723
|
planner_model: resolveModelInternal(cwd, 'gsd-planner'),
|
|
669
724
|
checker_model: resolveModelInternal(cwd, 'gsd-plan-checker'),
|
|
670
725
|
commit_docs: config.commit_docs,
|
|
671
726
|
phase_found: !!phaseInfo,
|
|
672
|
-
phase_dir:
|
|
727
|
+
phase_dir: phaseDir,
|
|
673
728
|
phase_number: phaseInfo?.['phase_number'] || null,
|
|
674
729
|
phase_name: phaseInfo?.['phase_name'] || null,
|
|
675
730
|
has_verification: phaseInfo?.['has_verification'] || false,
|
|
731
|
+
phase_completion: {
|
|
732
|
+
...completion,
|
|
733
|
+
uat_passed: uatReport?.passed ?? false,
|
|
734
|
+
uat_blockers: uatReport?.blockers ?? [],
|
|
735
|
+
ready_to_transition: completion.phase_complete && (uatReport?.passed ?? false),
|
|
736
|
+
},
|
|
676
737
|
};
|
|
677
738
|
output(withProjectRoot(cwd, result), raw);
|
|
678
739
|
}
|
|
@@ -1035,18 +1096,23 @@ function cmdInitManager(cwd, raw) {
|
|
|
1035
1096
|
let hasResearch = false;
|
|
1036
1097
|
let lastActivity = null;
|
|
1037
1098
|
let isActive = false;
|
|
1099
|
+
let completion = buildPhaseCompletionProjection(cwd, phaseNum, null, planCount, summaryCount, _slashRuntime);
|
|
1038
1100
|
try {
|
|
1039
1101
|
const dirs = _phaseDirEntries.filter(isDirInMilestone);
|
|
1040
1102
|
const dirMatch = dirs.find((d) => phaseTokenMatches(d, normalized));
|
|
1041
1103
|
if (dirMatch) {
|
|
1042
1104
|
const fullDir = node_path_1.default.join(phasesDir, dirMatch);
|
|
1105
|
+
const phaseDirRel = toPosixPath(node_path_1.default.relative(cwd, fullDir));
|
|
1043
1106
|
const phaseFiles = node_fs_1.default.readdirSync(fullDir);
|
|
1044
1107
|
planCount = listPhasePlanFiles(fullDir).length;
|
|
1045
1108
|
summaryCount = listPhaseSummaryFiles(fullDir).length;
|
|
1046
1109
|
hasContext = findContextMdIn(fullDir) !== null;
|
|
1047
1110
|
hasResearch = phaseFiles.some((f) => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md');
|
|
1048
|
-
|
|
1111
|
+
completion = buildPhaseCompletionProjection(cwd, phaseNum, phaseDirRel, planCount, summaryCount, _slashRuntime);
|
|
1112
|
+
if (completion.phase_complete)
|
|
1049
1113
|
diskStatus = 'complete';
|
|
1114
|
+
else if (completion.implementation_complete)
|
|
1115
|
+
diskStatus = 'executed';
|
|
1050
1116
|
else if (summaryCount > 0)
|
|
1051
1117
|
diskStatus = 'partial';
|
|
1052
1118
|
else if (planCount > 0)
|
|
@@ -1079,7 +1145,7 @@ function cmdInitManager(cwd, raw) {
|
|
|
1079
1145
|
/* intentionally empty */
|
|
1080
1146
|
}
|
|
1081
1147
|
const roadmapComplete = _checkboxStates.get(phaseNum) || false;
|
|
1082
|
-
if (roadmapComplete && diskStatus !== 'complete') {
|
|
1148
|
+
if (roadmapComplete && completion.phase_complete && diskStatus !== 'complete') {
|
|
1083
1149
|
diskStatus = 'complete';
|
|
1084
1150
|
}
|
|
1085
1151
|
phases.push({
|
|
@@ -1093,6 +1159,7 @@ function cmdInitManager(cwd, raw) {
|
|
|
1093
1159
|
plan_count: planCount,
|
|
1094
1160
|
summary_count: summaryCount,
|
|
1095
1161
|
roadmap_complete: roadmapComplete,
|
|
1162
|
+
...completion,
|
|
1096
1163
|
last_activity: lastActivity,
|
|
1097
1164
|
is_active: isActive,
|
|
1098
1165
|
});
|
|
@@ -1107,22 +1174,42 @@ function cmdInitManager(cwd, raw) {
|
|
|
1107
1174
|
phase['display_name'] = name;
|
|
1108
1175
|
}
|
|
1109
1176
|
}
|
|
1110
|
-
|
|
1177
|
+
function normalizePhaseNumber(value) {
|
|
1178
|
+
return value
|
|
1179
|
+
.split('.')
|
|
1180
|
+
.map((part) => {
|
|
1181
|
+
const match = /^(\d+)([A-Z]?)$/i.exec(part);
|
|
1182
|
+
if (!match)
|
|
1183
|
+
return part;
|
|
1184
|
+
return `${Number(match[1])}${match[2].toUpperCase()}`;
|
|
1185
|
+
})
|
|
1186
|
+
.join('.');
|
|
1187
|
+
}
|
|
1188
|
+
const completedNums = new Set(phases
|
|
1189
|
+
.filter((p) => p['phase_complete'] === true)
|
|
1190
|
+
.map((p) => normalizePhaseNumber(p['number'])));
|
|
1191
|
+
const phaseMap = new Map(phases.map((p) => [normalizePhaseNumber(p['number']), p]));
|
|
1111
1192
|
const _allCompletedPattern = /-\s*\[x\]\s*.*Phase\s+(\d+[A-Z]?(?:\.\d+)*)[:\s]/gi;
|
|
1112
1193
|
let _allMatch;
|
|
1113
1194
|
while ((_allMatch = _allCompletedPattern.exec(rawContent)) !== null) {
|
|
1114
|
-
|
|
1195
|
+
const phaseNum = normalizePhaseNumber(_allMatch[1]);
|
|
1196
|
+
const phase = phaseMap.get(phaseNum);
|
|
1197
|
+
if (!phase || phase['phase_complete'] === true) {
|
|
1198
|
+
completedNums.add(phaseNum);
|
|
1199
|
+
}
|
|
1115
1200
|
}
|
|
1116
|
-
const phaseMap = new Map(phases.map((p) => [p['number'], p]));
|
|
1117
1201
|
function reaches(from, to, visited = new Set()) {
|
|
1118
|
-
|
|
1202
|
+
const normalizedFrom = normalizePhaseNumber(from);
|
|
1203
|
+
const normalizedTo = normalizePhaseNumber(to);
|
|
1204
|
+
if (visited.has(normalizedFrom))
|
|
1119
1205
|
return false;
|
|
1120
|
-
visited.add(
|
|
1121
|
-
const p = phaseMap.get(
|
|
1206
|
+
visited.add(normalizedFrom);
|
|
1207
|
+
const p = phaseMap.get(normalizedFrom);
|
|
1122
1208
|
if (!p || !p['dep_phases'] || p['dep_phases'].length === 0)
|
|
1123
1209
|
return false;
|
|
1124
|
-
if (p['dep_phases'].
|
|
1210
|
+
if (p['dep_phases'].some((dep) => normalizePhaseNumber(dep) === normalizedTo)) {
|
|
1125
1211
|
return true;
|
|
1212
|
+
}
|
|
1126
1213
|
return p['dep_phases'].some((dep) => reaches(dep, to, visited));
|
|
1127
1214
|
}
|
|
1128
1215
|
function hasDepRelationship(numA, numB) {
|
|
@@ -1134,8 +1221,8 @@ function cmdInitManager(cwd, raw) {
|
|
|
1134
1221
|
phase['deps_satisfied'] = true;
|
|
1135
1222
|
}
|
|
1136
1223
|
else {
|
|
1137
|
-
const depNums = phase['depends_on'].match(/\d+(?:\.\d+)*/
|
|
1138
|
-
phase['deps_satisfied'] = depNums.every((n) => completedNums.has(n));
|
|
1224
|
+
const depNums = phase['depends_on'].match(/\d+[A-Z]?(?:\.\d+)*/gi) || [];
|
|
1225
|
+
phase['deps_satisfied'] = depNums.every((n) => completedNums.has(normalizePhaseNumber(n)));
|
|
1139
1226
|
phase['dep_phases'] = depNums;
|
|
1140
1227
|
}
|
|
1141
1228
|
}
|
|
@@ -1167,7 +1254,16 @@ function cmdInitManager(cwd, raw) {
|
|
|
1167
1254
|
continue;
|
|
1168
1255
|
if (/^999(?:\.|$)/.test(phase['number']))
|
|
1169
1256
|
continue;
|
|
1170
|
-
if (phase['disk_status'] === '
|
|
1257
|
+
if (phase['disk_status'] === 'executed') {
|
|
1258
|
+
recommendedActions.push({
|
|
1259
|
+
phase: phase['number'],
|
|
1260
|
+
phase_name: phase['name'],
|
|
1261
|
+
action: 'verify',
|
|
1262
|
+
reason: `Implementation complete; verification ${phase['verification_status']}`,
|
|
1263
|
+
command: phase['verification_next_command'],
|
|
1264
|
+
});
|
|
1265
|
+
}
|
|
1266
|
+
else if (phase['disk_status'] === 'planned' && phase['deps_satisfied']) {
|
|
1171
1267
|
recommendedActions.push({
|
|
1172
1268
|
phase: phase['number'],
|
|
1173
1269
|
phase_name: phase['name'],
|
|
@@ -1211,7 +1307,7 @@ function cmdInitManager(cwd, raw) {
|
|
|
1211
1307
|
return true;
|
|
1212
1308
|
});
|
|
1213
1309
|
const nonBacklogPhases = phases.filter((p) => !/^999(?:\.|$)/.test(p['number']));
|
|
1214
|
-
const completedCount = nonBacklogPhases.filter((p) => p['
|
|
1310
|
+
const completedCount = nonBacklogPhases.filter((p) => p['phase_complete'] === true).length;
|
|
1215
1311
|
const sanitizeFlags = (rawVal) => {
|
|
1216
1312
|
const val = typeof rawVal === 'string' ? rawVal : '';
|
|
1217
1313
|
if (!val)
|
|
@@ -1238,7 +1334,7 @@ function cmdInitManager(cwd, raw) {
|
|
|
1238
1334
|
phases,
|
|
1239
1335
|
phase_count: phases.length,
|
|
1240
1336
|
completed_count: completedCount,
|
|
1241
|
-
in_progress_count: phases.filter((p) => ['partial', 'planned', 'discussed', 'researched'].includes(p['disk_status'])).length,
|
|
1337
|
+
in_progress_count: phases.filter((p) => ['executed', 'partial', 'planned', 'discussed', 'researched'].includes(p['disk_status'])).length,
|
|
1242
1338
|
recommended_actions: filteredActions,
|
|
1243
1339
|
waiting_signal: waitingSignal,
|
|
1244
1340
|
all_complete: completedCount === nonBacklogPhases.length && nonBacklogPhases.length > 0,
|
|
@@ -1258,6 +1354,7 @@ function cmdInitProgress(cwd, raw) {
|
|
|
1258
1354
|
}
|
|
1259
1355
|
const config = loadConfig(cwd);
|
|
1260
1356
|
const milestone = getMilestoneInfo(cwd);
|
|
1357
|
+
const _slashRuntime = (0, runtime_slash_cjs_1.resolveRuntime)(cwd);
|
|
1261
1358
|
const phasesDir = node_path_1.default.join(planningDir(cwd), 'phases');
|
|
1262
1359
|
const phases = [];
|
|
1263
1360
|
let currentPhase = null;
|
|
@@ -1307,24 +1404,29 @@ function cmdInitProgress(cwd, raw) {
|
|
|
1307
1404
|
const plans = listPhasePlanFiles(phasePath);
|
|
1308
1405
|
const summaries = listPhaseSummaryFiles(phasePath);
|
|
1309
1406
|
const hasResearch = phaseFiles.some((f) => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md');
|
|
1310
|
-
const
|
|
1407
|
+
const phaseDirRel = toPosixPath(node_path_1.default.relative(cwd, node_path_1.default.join(planningDir(cwd), 'phases', dir)));
|
|
1408
|
+
const completion = buildPhaseCompletionProjection(cwd, phaseNumber, phaseDirRel, plans.length, summaries.length, _slashRuntime);
|
|
1409
|
+
const status = completion.phase_complete
|
|
1311
1410
|
? 'complete'
|
|
1312
|
-
:
|
|
1313
|
-
? '
|
|
1314
|
-
:
|
|
1315
|
-
? '
|
|
1316
|
-
:
|
|
1411
|
+
: completion.implementation_complete
|
|
1412
|
+
? 'executed'
|
|
1413
|
+
: plans.length > 0
|
|
1414
|
+
? 'in_progress'
|
|
1415
|
+
: hasResearch
|
|
1416
|
+
? 'researched'
|
|
1417
|
+
: 'pending';
|
|
1317
1418
|
const phaseInfo = {
|
|
1318
1419
|
number: phaseNumber,
|
|
1319
1420
|
name: phaseName,
|
|
1320
|
-
directory:
|
|
1421
|
+
directory: phaseDirRel,
|
|
1321
1422
|
status,
|
|
1322
1423
|
plan_count: plans.length,
|
|
1323
1424
|
summary_count: summaries.length,
|
|
1324
1425
|
has_research: hasResearch,
|
|
1426
|
+
...completion,
|
|
1325
1427
|
};
|
|
1326
1428
|
phases.push(phaseInfo);
|
|
1327
|
-
if (!currentPhase && (status === 'in_progress' || status === 'researched')) {
|
|
1429
|
+
if (!currentPhase && (status === 'executed' || status === 'in_progress' || status === 'researched')) {
|
|
1328
1430
|
currentPhase = phaseInfo;
|
|
1329
1431
|
}
|
|
1330
1432
|
if (!nextPhase && status === 'pending') {
|
|
@@ -1340,7 +1442,8 @@ function cmdInitProgress(cwd, raw) {
|
|
|
1340
1442
|
if (!seenPhaseNums.has(stripped)) {
|
|
1341
1443
|
const checkboxComplete = roadmapCheckboxStates.get(num) === true ||
|
|
1342
1444
|
roadmapCheckboxStates.get(stripped) === true;
|
|
1343
|
-
const
|
|
1445
|
+
const completion = buildPhaseCompletionProjection(cwd, num, null, 0, 0, _slashRuntime);
|
|
1446
|
+
const status = 'not_started';
|
|
1344
1447
|
const phaseInfo = {
|
|
1345
1448
|
number: num,
|
|
1346
1449
|
name: name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''),
|
|
@@ -1349,9 +1452,11 @@ function cmdInitProgress(cwd, raw) {
|
|
|
1349
1452
|
plan_count: 0,
|
|
1350
1453
|
summary_count: 0,
|
|
1351
1454
|
has_research: false,
|
|
1455
|
+
roadmap_complete: checkboxComplete,
|
|
1456
|
+
...completion,
|
|
1352
1457
|
};
|
|
1353
1458
|
phases.push(phaseInfo);
|
|
1354
|
-
if (!nextPhase && !currentPhase &&
|
|
1459
|
+
if (!nextPhase && !currentPhase && !checkboxComplete) {
|
|
1355
1460
|
nextPhase = phaseInfo;
|
|
1356
1461
|
}
|
|
1357
1462
|
}
|
|
@@ -1373,7 +1478,7 @@ function cmdInitProgress(cwd, raw) {
|
|
|
1373
1478
|
phases,
|
|
1374
1479
|
phase_count: phases.length,
|
|
1375
1480
|
completed_count: phases.filter((p) => p['status'] === 'complete').length,
|
|
1376
|
-
in_progress_count: phases.filter((p) =>
|
|
1481
|
+
in_progress_count: phases.filter((p) => ['executed', 'in_progress'].includes(p['status'])).length,
|
|
1377
1482
|
current_phase: currentPhase,
|
|
1378
1483
|
next_phase: nextPhase,
|
|
1379
1484
|
paused_at: pausedAt,
|
package/gsd-core/bin/lib/io.cjs
CHANGED
|
@@ -167,6 +167,7 @@ const ERROR_REASON = Object.freeze({
|
|
|
167
167
|
SDK_MISSING_ARG: 'sdk_missing_arg',
|
|
168
168
|
// workflow / phase
|
|
169
169
|
PHASE_NOT_FOUND: 'phase_not_found',
|
|
170
|
+
PHASE_VERIFICATION_INCOMPLETE: 'phase_verification_incomplete',
|
|
170
171
|
SUMMARY_NO_PLANNING: 'summary_no_planning',
|
|
171
172
|
// graphify
|
|
172
173
|
GRAPHIFY_NO_GRAPH: 'graphify_no_graph',
|
|
@@ -165,6 +165,14 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
|
|
|
165
165
|
})();
|
|
166
166
|
while ((pm = phasePattern.exec(scopedContent)) !== null) {
|
|
167
167
|
const phaseNum = pm[1];
|
|
168
|
+
// Phase 0 (pre-milestone) and Phase 999 (backlog) are sentinels, not
|
|
169
|
+
// real phases — they legitimately have no directory and must not block
|
|
170
|
+
// milestone completion. Mirrors the engine-wide sentinel convention
|
|
171
|
+
// (phase-id getMilestoneFromPhaseId, roadmap-command-router SENTINELS,
|
|
172
|
+
// the #1445 /^999/ progress filters). (#1580)
|
|
173
|
+
const major = parseInt(phaseNum, 10);
|
|
174
|
+
if (major === 0 || major === 999)
|
|
175
|
+
continue;
|
|
168
176
|
const normalized = normalizePhaseName(phaseNum);
|
|
169
177
|
// A phase has disk_status: 'no_directory' when no phase directory
|
|
170
178
|
// with a matching token exists on disk. Use the same phaseTokenMatches
|
|
@@ -52,6 +52,9 @@ const clock_cjs_1 = require("./clock.cjs");
|
|
|
52
52
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- uat-predicate.cjs is an export= CommonJS module
|
|
53
53
|
const uatPredicate = require("./uat-predicate.cjs");
|
|
54
54
|
const { evaluateUatPassed } = uatPredicate;
|
|
55
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports -- verification.cjs is an export= CommonJS module
|
|
56
|
+
const verificationMod = require("./verification.cjs");
|
|
57
|
+
const { readVerificationStatus } = verificationMod;
|
|
55
58
|
const { planningDir, withPlanningLock } = planningWorkspace;
|
|
56
59
|
const { extractFrontmatter } = frontmatterMod;
|
|
57
60
|
const { readModifyWriteStateMd, stateExtractField, stateReplaceField, stateReplaceFieldWithFallback, syncStateFrontmatter, withStateLock, updatePerformanceMetricsSection, } = stateMod;
|
|
@@ -1077,8 +1080,8 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
1077
1080
|
: 0;
|
|
1078
1081
|
let requirementsUpdated = false;
|
|
1079
1082
|
const warnings = [];
|
|
1083
|
+
const phaseFullDir = node_path_1.default.join(cwd, phaseInfo['directory']);
|
|
1080
1084
|
try {
|
|
1081
|
-
const phaseFullDir = node_path_1.default.join(cwd, phaseInfo['directory']);
|
|
1082
1085
|
const phaseFiles = node_fs_1.default.readdirSync(phaseFullDir);
|
|
1083
1086
|
for (const file of phaseFiles.filter((f) => f.includes('-UAT') && f.endsWith('.md'))) {
|
|
1084
1087
|
const content = node_fs_1.default.readFileSync(node_path_1.default.join(phaseFullDir, file), 'utf-8');
|
|
@@ -1113,7 +1116,11 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
1113
1116
|
let nextPhaseNum = null;
|
|
1114
1117
|
let nextPhaseName = null;
|
|
1115
1118
|
let isLastPhase = true;
|
|
1116
|
-
withPlanningLock(cwd, () => {
|
|
1119
|
+
const verificationBlocked = withPlanningLock(cwd, () => {
|
|
1120
|
+
const verificationStatus = readVerificationStatus(phaseFullDir);
|
|
1121
|
+
if (verificationStatus.status !== 'passed') {
|
|
1122
|
+
return verificationStatus;
|
|
1123
|
+
}
|
|
1117
1124
|
const runPhaseCompleteTransaction = () => {
|
|
1118
1125
|
const writes = [];
|
|
1119
1126
|
let roadmapContent = null;
|
|
@@ -1280,7 +1287,19 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
1280
1287
|
if (isLastPhase && roadmapContent !== null) {
|
|
1281
1288
|
try {
|
|
1282
1289
|
const roadmapForPhases = extractCurrentMilestone(roadmapContent, cwd);
|
|
1283
|
-
|
|
1290
|
+
// #1591: match BOTH heading-style phases (`### Phase N:`) AND
|
|
1291
|
+
// checkbox-list items, INCLUDING the canonical bold form the roadmap
|
|
1292
|
+
// template emits (`- [ ] **Phase N: Name**`). When the active
|
|
1293
|
+
// milestone's checklist is `- [ ]` items inside a <details> block
|
|
1294
|
+
// (and the next phase has no directory yet, so the disk-based
|
|
1295
|
+
// resolver finds nothing), this roadmap-enumeration fallback is the
|
|
1296
|
+
// only path that can find the next phase. The prior heading-only
|
|
1297
|
+
// pattern missed checkbox items, and a checkbox-only broadening still
|
|
1298
|
+
// missed the bold template rows → is_last_phase=true on a mid-milestone
|
|
1299
|
+
// phase. Allow optional `**`/`__` emphasis after the marker and stop
|
|
1300
|
+
// the name capture at emphasis so bold names slug cleanly; the number
|
|
1301
|
+
// capture is unchanged.
|
|
1302
|
+
const phasePattern = /(?:#{2,4}|-\s*\[[ xX]\])\s*(?:\*\*|__)?\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n*]+)/gi;
|
|
1284
1303
|
let pm;
|
|
1285
1304
|
while ((pm = phasePattern.exec(roadmapForPhases)) !== null) {
|
|
1286
1305
|
if (comparePhaseNum(pm[1], phaseNum) > 0) {
|
|
@@ -1381,7 +1400,14 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
1381
1400
|
else {
|
|
1382
1401
|
runPhaseCompleteTransaction();
|
|
1383
1402
|
}
|
|
1403
|
+
return null;
|
|
1384
1404
|
});
|
|
1405
|
+
if (verificationBlocked) {
|
|
1406
|
+
const nextStep = verificationBlocked.next_command
|
|
1407
|
+
? ` Next: ${verificationBlocked.next_command}`
|
|
1408
|
+
: '';
|
|
1409
|
+
error(`Phase ${phaseNum} verification is incomplete: ${verificationBlocked.next_action}${nextStep}`, ERROR_REASON.PHASE_VERIFICATION_INCOMPLETE);
|
|
1410
|
+
}
|
|
1385
1411
|
let autoPruned = false;
|
|
1386
1412
|
try {
|
|
1387
1413
|
const configPath = node_path_1.default.join(planningDir(cwd), 'config.json');
|
|
@@ -63,8 +63,8 @@ function scanPhasePlans(phaseDir) {
|
|
|
63
63
|
if ((0, node_fs_1.existsSync)(nestedDir)) {
|
|
64
64
|
try {
|
|
65
65
|
const nestedFiles = (0, node_fs_1.readdirSync)(nestedDir);
|
|
66
|
-
nestedPlanFiles = nestedFiles.filter(isNestedPlanFile);
|
|
67
|
-
nestedSummaryFiles = nestedFiles.filter(isNestedSummaryFile);
|
|
66
|
+
nestedPlanFiles = nestedFiles.filter(isNestedPlanFile).map((file) => `plans/${file}`);
|
|
67
|
+
nestedSummaryFiles = nestedFiles.filter(isNestedSummaryFile).map((file) => `plans/${file}`);
|
|
68
68
|
hasNestedPlans = nestedPlanFiles.length > 0;
|
|
69
69
|
}
|
|
70
70
|
catch { /* ignore unreadable nested layout */ }
|
|
@@ -249,6 +249,15 @@ function cmdRoadmapAnalyze(cwd, raw) {
|
|
|
249
249
|
const phasePattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+(\d+[A-Z]?(?:[.-]\d+)*)\s*:\s*([^\n]+)/gi;
|
|
250
250
|
const phases = [];
|
|
251
251
|
let match;
|
|
252
|
+
// Phase 0 (pre-milestone) and Phase 999 (backlog) are sentinels, not real
|
|
253
|
+
// phases. They legitimately have no directory and must never be surfaced as
|
|
254
|
+
// current/next phase or counted in phase_count. Mirrors the engine-wide
|
|
255
|
+
// sentinel convention (phase-id getMilestoneFromPhaseId, roadmap-command-router
|
|
256
|
+
// SENTINELS, the #1445 /^999/ progress filters). (#1580)
|
|
257
|
+
const isSentinelPhase = (num) => {
|
|
258
|
+
const major = parseInt(num, 10);
|
|
259
|
+
return major === 0 || major === 999;
|
|
260
|
+
};
|
|
252
261
|
// Build phase directory lookup once (O(1) readdir instead of O(N) per phase)
|
|
253
262
|
const _phaseDirNames = (() => {
|
|
254
263
|
try {
|
|
@@ -262,6 +271,8 @@ function cmdRoadmapAnalyze(cwd, raw) {
|
|
|
262
271
|
})();
|
|
263
272
|
while ((match = phasePattern.exec(content)) !== null) {
|
|
264
273
|
const phaseNum = match[1];
|
|
274
|
+
if (isSentinelPhase(phaseNum))
|
|
275
|
+
continue;
|
|
265
276
|
const phaseName = match[2].replace(/\(INSERTED\)/i, '').trim();
|
|
266
277
|
// Extract goal from the section
|
|
267
278
|
const sectionStart = match.index;
|
|
@@ -362,7 +373,7 @@ function cmdRoadmapAnalyze(cwd, raw) {
|
|
|
362
373
|
checklistPhases.add(checklistMatch[1]);
|
|
363
374
|
}
|
|
364
375
|
const detailPhases = new Set(phases.map(p => p.number));
|
|
365
|
-
const missingDetails = [...checklistPhases].filter(p => !detailPhases.has(p));
|
|
376
|
+
const missingDetails = [...checklistPhases].filter(p => !detailPhases.has(p) && !isSentinelPhase(p));
|
|
366
377
|
const result = {
|
|
367
378
|
milestones,
|
|
368
379
|
phases,
|