@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.
Files changed (56) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/agents/gsd-advisor-researcher.md +2 -0
  3. package/agents/gsd-ai-researcher.md +2 -0
  4. package/agents/gsd-assumptions-analyzer.md +2 -0
  5. package/agents/gsd-doc-classifier.md +2 -0
  6. package/agents/gsd-doc-synthesizer.md +2 -0
  7. package/agents/gsd-domain-researcher.md +2 -0
  8. package/agents/gsd-eval-auditor.md +6 -9
  9. package/agents/gsd-phase-researcher.md +2 -0
  10. package/agents/gsd-project-researcher.md +2 -0
  11. package/agents/gsd-research-synthesizer.md +2 -0
  12. package/agents/gsd-ui-researcher.md +2 -0
  13. package/bin/install.js +219 -5
  14. package/gemini-extension.json +1 -1
  15. package/gsd-core/bin/gsd-tools.cjs +11 -1
  16. package/gsd-core/bin/lib/capability-registry.cjs +48 -48
  17. package/gsd-core/bin/lib/command-aliases.cjs +10 -1
  18. package/gsd-core/bin/lib/decisions.cjs +27 -0
  19. package/gsd-core/bin/lib/eval-command-router.cjs +21 -0
  20. package/gsd-core/bin/lib/eval.cjs +60 -0
  21. package/gsd-core/bin/lib/frontmatter.cjs +132 -13
  22. package/gsd-core/bin/lib/init.cjs +131 -26
  23. package/gsd-core/bin/lib/io.cjs +1 -0
  24. package/gsd-core/bin/lib/milestone.cjs +8 -0
  25. package/gsd-core/bin/lib/phase.cjs +29 -3
  26. package/gsd-core/bin/lib/plan-scan.cjs +2 -2
  27. package/gsd-core/bin/lib/roadmap.cjs +12 -1
  28. package/gsd-core/bin/lib/shell-command-projection.cjs +31 -1
  29. package/gsd-core/bin/lib/state.cjs +34 -8
  30. package/gsd-core/bin/lib/uat-predicate.cjs +13 -6
  31. package/gsd-core/bin/lib/verification.cjs +67 -6
  32. package/gsd-core/bin/lib/verify.cjs +8 -1
  33. package/gsd-core/bin/shared/config-defaults.manifest.json +3 -0
  34. package/gsd-core/bin/shared/config-schema.manifest.json +2 -1
  35. package/gsd-core/bin/shared/model-catalog.json +8 -8
  36. package/gsd-core/references/untrusted-input-boundary.md +13 -0
  37. package/gsd-core/workflows/autonomous.md +53 -46
  38. package/gsd-core/workflows/complete-milestone.md +27 -8
  39. package/gsd-core/workflows/execute-phase.md +1 -1
  40. package/gsd-core/workflows/manager.md +17 -7
  41. package/gsd-core/workflows/new-project.md +78 -12
  42. package/gsd-core/workflows/profile-user.md +6 -2
  43. package/gsd-core/workflows/progress.md +37 -4
  44. package/gsd-core/workflows/quick.md +3 -1
  45. package/gsd-core/workflows/settings-advanced.md +10 -10
  46. package/gsd-core/workflows/ship.md +3 -1
  47. package/gsd-core/workflows/spec-phase.md +3 -1
  48. package/gsd-core/workflows/transition.md +14 -12
  49. package/gsd-core/workflows/ui-review.md +2 -6
  50. package/gsd-core/workflows/verify-work.md +44 -0
  51. package/hooks/dist/gsd-read-injection-scanner.js +49 -25
  52. package/hooks/gsd-read-injection-scanner.js +49 -25
  53. package/hooks/hooks.json +1 -1
  54. package/package.json +1 -1
  55. package/scripts/check-alias-drift.cjs +5 -0
  56. 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
- // Identity-preservation (additive, lossless round-trip): `reconstructFrontmatter` is a
212
- // deliberately lossy serializer — it cannot faithfully re-emit nested object-list items
213
- // (e.g. must_haves.artifacts / must_haves.prohibitions, whose items are `{ path, provides }`
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
- const yamlStr = reconstructFrontmatter(newObj);
231
- return `---\n${yamlStr}\n---` + content.slice(match[0].length);
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: phaseInfo?.['directory'] || null,
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
- if (summaryCount >= planCount && planCount > 0)
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
- const completedNums = new Set(phases.filter((p) => p['disk_status'] === 'complete').map((p) => p['number']));
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
- completedNums.add(_allMatch[1]);
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
- if (visited.has(from))
1202
+ const normalizedFrom = normalizePhaseNumber(from);
1203
+ const normalizedTo = normalizePhaseNumber(to);
1204
+ if (visited.has(normalizedFrom))
1119
1205
  return false;
1120
- visited.add(from);
1121
- const p = phaseMap.get(from);
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'].includes(to))
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+)*/g) || [];
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'] === 'planned' && phase['deps_satisfied']) {
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['disk_status'] === 'complete').length;
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 status = summaries.length >= plans.length && plans.length > 0
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
- : plans.length > 0
1313
- ? 'in_progress'
1314
- : hasResearch
1315
- ? 'researched'
1316
- : 'pending';
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: toPosixPath(node_path_1.default.relative(cwd, node_path_1.default.join(planningDir(cwd), 'phases', dir))),
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 status = checkboxComplete ? 'complete' : 'not_started';
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 && status !== 'complete') {
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) => p['status'] === 'in_progress').length,
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,
@@ -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
- const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi;
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,