@opengsd/gsd-core 1.9.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (223) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +2 -3
  3. package/.opencode/plugins/gsd-core.js +8 -1
  4. package/agents/gsd-code-fixer.md +131 -34
  5. package/agents/gsd-debugger.md +12 -246
  6. package/agents/gsd-executor.md +7 -5
  7. package/agents/gsd-integration-checker.md +3 -0
  8. package/agents/gsd-plan-checker.md +9 -0
  9. package/agents/gsd-planner.md +5 -8
  10. package/agents/gsd-roadmapper.md +21 -3
  11. package/agents/gsd-verifier.md +14 -70
  12. package/bin/install.js +503 -341
  13. package/commands/gsd/mempalace-capture.md +1 -1
  14. package/commands/gsd/new-milestone.md +1 -1
  15. package/commands/gsd/plan-phase.md +1 -1
  16. package/gsd-core/bin/gsd-tools.cjs +607 -63
  17. package/gsd-core/bin/lib/active-workstream-store.cjs +25 -0
  18. package/gsd-core/bin/lib/agent-install-check.cjs +38 -6
  19. package/gsd-core/bin/lib/api-coverage.cjs +120 -0
  20. package/gsd-core/bin/lib/audit.cjs +89 -1
  21. package/gsd-core/bin/lib/broken-windows.cjs +36 -6
  22. package/gsd-core/bin/lib/capability-registry.cjs +96 -110
  23. package/gsd-core/bin/lib/capability-validator.cjs +12 -2
  24. package/gsd-core/bin/lib/check-command-router.cjs +43 -1
  25. package/gsd-core/bin/lib/command-aliases.cjs +72 -0
  26. package/gsd-core/bin/lib/commands.cjs +26 -25
  27. package/gsd-core/bin/lib/commonjs-marker.cjs +136 -0
  28. package/gsd-core/bin/lib/config-loader.cjs +1 -0
  29. package/gsd-core/bin/lib/config.cjs +12 -1
  30. package/gsd-core/bin/lib/context-composer.cjs +278 -0
  31. package/gsd-core/bin/lib/context-predicates.cjs +506 -0
  32. package/gsd-core/bin/lib/core-utils.cjs +91 -12
  33. package/gsd-core/bin/lib/docs.cjs +3 -2
  34. package/gsd-core/bin/lib/external-job.cjs +19 -4
  35. package/gsd-core/bin/lib/frontmatter.cjs +84 -12
  36. package/gsd-core/bin/lib/gate-predicate-evaluator.cjs +57 -6
  37. package/gsd-core/bin/lib/git-base-branch.cjs +58 -15
  38. package/gsd-core/bin/lib/graphify.cjs +142 -27
  39. package/gsd-core/bin/lib/gsd2-import.cjs +27 -4
  40. package/gsd-core/bin/lib/host-integration.cjs +13 -1
  41. package/gsd-core/bin/lib/init-command-router.cjs +83 -8
  42. package/gsd-core/bin/lib/init.cjs +1021 -57
  43. package/gsd-core/bin/lib/install-engine.cjs +64 -10
  44. package/gsd-core/bin/lib/install-profiles.cjs +27 -1
  45. package/gsd-core/bin/lib/installer-migration-authoring.cjs +3 -1
  46. package/gsd-core/bin/lib/installer-migration-report.cjs +4 -0
  47. package/gsd-core/bin/lib/installer-migrations/007-retire-config-root-commonjs-marker.cjs +149 -0
  48. package/gsd-core/bin/lib/installer-migrations/008-cursor-retire-commands-surface.cjs +55 -0
  49. package/gsd-core/bin/lib/installer-migrations/009-pi-retire-reserved-hooks-dir.cjs +199 -0
  50. package/gsd-core/bin/lib/installer-migrations.cjs +87 -1
  51. package/gsd-core/bin/lib/io.cjs +28 -3
  52. package/gsd-core/bin/lib/markdown-sectionizer.cjs +6 -0
  53. package/gsd-core/bin/lib/mcp-catalog.cjs +518 -0
  54. package/gsd-core/bin/lib/mcp-server.cjs +135 -3
  55. package/gsd-core/bin/lib/milestone.cjs +106 -51
  56. package/gsd-core/bin/lib/phase-id.cjs +63 -0
  57. package/gsd-core/bin/lib/phase-locator.cjs +138 -45
  58. package/gsd-core/bin/lib/phase.cjs +260 -25
  59. package/gsd-core/bin/lib/plan-dependency-graph.cjs +232 -0
  60. package/gsd-core/bin/lib/planning-workspace.cjs +4 -0
  61. package/gsd-core/bin/lib/project-root.cjs +48 -0
  62. package/gsd-core/bin/lib/prompt-budget.cjs +128 -165
  63. package/gsd-core/bin/lib/retired-artifact-cleanup.cjs +80 -0
  64. package/gsd-core/bin/lib/review-lane-descriptor.cjs +99 -0
  65. package/gsd-core/bin/lib/review-lane-runner.cjs +30 -6
  66. package/gsd-core/bin/lib/roadmap-command-router.cjs +42 -9
  67. package/gsd-core/bin/lib/roadmap-parser.cjs +100 -18
  68. package/gsd-core/bin/lib/roadmap.cjs +37 -7
  69. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +195 -62
  70. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +15 -3
  71. package/gsd-core/bin/lib/runtime-homes.cjs +154 -41
  72. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +105 -41
  73. package/gsd-core/bin/lib/section-manifest.cjs +209 -0
  74. package/gsd-core/bin/lib/shell-command-projection.cjs +113 -27
  75. package/gsd-core/bin/lib/smart-entry.cjs +12 -0
  76. package/gsd-core/bin/lib/state-transition.cjs +73 -8
  77. package/gsd-core/bin/lib/state.cjs +151 -62
  78. package/gsd-core/bin/lib/surface.cjs +12 -1
  79. package/gsd-core/bin/lib/uat-predicate.cjs +11 -1
  80. package/gsd-core/bin/lib/uat.cjs +320 -21
  81. package/gsd-core/bin/lib/unusable-input.cjs +9 -0
  82. package/gsd-core/bin/lib/verification.cjs +29 -12
  83. package/gsd-core/bin/lib/verify.cjs +29 -5
  84. package/gsd-core/bin/lib/workflow-fragments.cjs +557 -0
  85. package/gsd-core/bin/lib/workstream-inventory-builder.cjs +181 -18
  86. package/gsd-core/bin/lib/workstream-inventory.cjs +519 -27
  87. package/gsd-core/bin/lib/workstream.cjs +6 -0
  88. package/gsd-core/bin/lib/worktree-base-ref.cjs +50 -6
  89. package/gsd-core/bin/lib/worktree-safety.cjs +276 -118
  90. package/gsd-core/bin/shared/config-schema.manifest.json +2 -0
  91. package/gsd-core/references/artifact-types.md +10 -3
  92. package/gsd-core/references/autonomous-ui-design-contract.md +42 -0
  93. package/gsd-core/references/debugger-techniques.md +255 -0
  94. package/gsd-core/references/research-documentation-lookup.md +5 -3
  95. package/gsd-core/references/specless-probe-fallback.md +7 -6
  96. package/gsd-core/references/verifier-wiring-patterns.md +100 -0
  97. package/gsd-core/references/worktree-branch-check.md +2 -2
  98. package/gsd-core/templates/summary-complex.md +2 -0
  99. package/gsd-core/templates/summary-minimal.md +2 -0
  100. package/gsd-core/templates/summary-standard.md +2 -0
  101. package/gsd-core/templates/summary.md +2 -0
  102. package/gsd-core/workflows/audit-milestone.md +3 -0
  103. package/gsd-core/workflows/autonomous/steps/converge-banner.md +1 -0
  104. package/gsd-core/workflows/autonomous/steps/converge-dispatch-bg.md +11 -0
  105. package/gsd-core/workflows/autonomous/steps/converge-dispatch-inline.md +7 -0
  106. package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +21 -0
  107. package/gsd-core/workflows/autonomous/steps/converge-loop.md +7 -0
  108. package/gsd-core/workflows/autonomous.md +32 -69
  109. package/gsd-core/workflows/code-review/steps/dispatch-fix.md +39 -0
  110. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +83 -0
  111. package/gsd-core/workflows/code-review.md +42 -145
  112. package/gsd-core/workflows/complete-milestone/steps/git-tag.md +29 -0
  113. package/gsd-core/workflows/complete-milestone.md +23 -81
  114. package/gsd-core/workflows/debug.md +9 -12
  115. package/gsd-core/workflows/diagnose-issues.md +22 -0
  116. package/gsd-core/workflows/discovery-phase.md +4 -4
  117. package/gsd-core/workflows/discuss-phase-assumptions/steps/auto-advance-dispatch.md +15 -0
  118. package/gsd-core/workflows/discuss-phase-assumptions.md +5 -16
  119. package/gsd-core/workflows/docs-update/steps/dispatch-monorepo-packages.md +51 -0
  120. package/gsd-core/workflows/docs-update.md +8 -51
  121. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +34 -2
  122. package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +50 -0
  123. package/gsd-core/workflows/execute-phase/steps/partial-wave.md +31 -0
  124. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +19 -0
  125. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +42 -0
  126. package/gsd-core/workflows/execute-phase/steps/regression-gate.md +43 -37
  127. package/gsd-core/workflows/execute-phase.md +65 -137
  128. package/gsd-core/workflows/execute-plan.md +1 -1
  129. package/gsd-core/workflows/help/modes/full.md +6 -1
  130. package/gsd-core/workflows/ingest-docs.md +2 -1
  131. package/gsd-core/workflows/new-milestone/steps/project-md-milestone-write.md +16 -0
  132. package/gsd-core/workflows/new-milestone/steps/reset-phase-safety.md +19 -0
  133. package/gsd-core/workflows/new-milestone.md +21 -38
  134. package/gsd-core/workflows/new-project/steps/auto-mode-config.md +176 -0
  135. package/gsd-core/workflows/new-project/steps/auto-mode-detection.md +32 -0
  136. package/gsd-core/workflows/new-project/steps/codebase-map-offer.md +18 -0
  137. package/gsd-core/workflows/new-project.md +13 -226
  138. package/gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md +15 -0
  139. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +110 -0
  140. package/gsd-core/workflows/plan-phase/steps/prd-express-gate.md +8 -0
  141. package/gsd-core/workflows/plan-phase/steps/research-only-early-exit.md +17 -0
  142. package/gsd-core/workflows/plan-phase/steps/research-only-modifiers.md +16 -0
  143. package/gsd-core/workflows/plan-phase/steps/reviews-prerequisite.md +17 -0
  144. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +149 -0
  145. package/gsd-core/workflows/plan-phase.md +49 -193
  146. package/gsd-core/workflows/progress/steps/forensic-audit.md +125 -0
  147. package/gsd-core/workflows/progress/steps/mvp-display.md +18 -0
  148. package/gsd-core/workflows/progress.md +11 -153
  149. package/gsd-core/workflows/quick/steps/discussion-phase.md +124 -0
  150. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +111 -0
  151. package/gsd-core/workflows/quick/steps/quick-verification.md +46 -0
  152. package/gsd-core/workflows/quick/steps/research-phase.md +72 -0
  153. package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +37 -0
  154. package/gsd-core/workflows/quick.md +20 -390
  155. package/gsd-core/workflows/resume-project.md +3 -0
  156. package/gsd-core/workflows/review/steps/reviewer-instances-note-1.md +4 -0
  157. package/gsd-core/workflows/review/steps/reviewer-instances-note-2.md +3 -0
  158. package/gsd-core/workflows/review.md +15 -8
  159. package/gsd-core/workflows/section-manifest.json +219 -0
  160. package/gsd-core/workflows/sketch.md +1 -1
  161. package/gsd-core/workflows/spec-phase.md +17 -14
  162. package/gsd-core/workflows/spike-wrap-up.md +20 -5
  163. package/gsd-core/workflows/spike.md +50 -16
  164. package/gsd-core/workflows/sync-skills.md +49 -11
  165. package/gsd-core/workflows/transition/steps/workstream-collision-check.md +17 -0
  166. package/gsd-core/workflows/transition.md +8 -21
  167. package/gsd-core/workflows/ui-phase.md +8 -7
  168. package/gsd-core/workflows/update/steps/channel-banner.md +7 -0
  169. package/gsd-core/workflows/update.md +18 -7
  170. package/gsd-core/workflows/verify-phase.md +4 -7
  171. package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +36 -0
  172. package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +21 -0
  173. package/gsd-core/workflows/verify-work.md +8 -58
  174. package/hooks/dist/gsd-agent-isolation-guard.js +428 -0
  175. package/hooks/dist/gsd-check-update-worker.js +14 -5
  176. package/hooks/dist/gsd-cursor-subagent-start.js +532 -26
  177. package/hooks/dist/gsd-read-injection-scanner.js +7 -0
  178. package/hooks/dist/gsd-statusline.js +72 -6
  179. package/hooks/dist/gsd-worktree-path-guard.js +2 -1
  180. package/hooks/dist/gsd-write-guard.js +359 -0
  181. package/hooks/dist/lib/isolation-sentinel.js +268 -0
  182. package/hooks/dist/managed-hooks-registry.cjs +2 -0
  183. package/hooks/gsd-agent-isolation-guard.js +428 -0
  184. package/hooks/gsd-check-update-worker.js +14 -5
  185. package/hooks/gsd-cursor-subagent-start.js +532 -26
  186. package/hooks/gsd-read-injection-scanner.js +7 -0
  187. package/hooks/gsd-statusline.js +72 -6
  188. package/hooks/gsd-worktree-path-guard.js +2 -1
  189. package/hooks/gsd-write-guard.js +359 -0
  190. package/hooks/hooks.json +12 -0
  191. package/hooks/lib/isolation-sentinel.js +268 -0
  192. package/hooks/managed-hooks-registry.cjs +2 -0
  193. package/package.json +14 -5
  194. package/pi/gsd.cjs +57 -12
  195. package/scripts/build-hooks.js +9 -0
  196. package/scripts/changeset/lint.cjs +9 -2
  197. package/scripts/changeset/serialize.cjs +5 -1
  198. package/scripts/gen-capability-matrix.cjs +1 -1
  199. package/scripts/gen-context-index.cjs +448 -0
  200. package/scripts/gen-inventory-manifest.cjs +101 -1
  201. package/scripts/gen-prompt-budget-parity-corpus.cjs +645 -0
  202. package/scripts/gen-registry.cjs +39 -15
  203. package/scripts/gen-section-manifest.cjs +638 -0
  204. package/scripts/generate-package-identity.cjs +4 -2
  205. package/scripts/lint-allow-test-rule-refs.allowlist.json +17 -31
  206. package/scripts/lint-compiled-artifact-sync.cjs +6 -1
  207. package/scripts/lint-docs-command-form.cjs +195 -0
  208. package/scripts/lint-docs-required.cjs +9 -1
  209. package/scripts/lint-emitted-drift-ack.cjs +215 -20
  210. package/scripts/lint-example-parser-parity.cjs +395 -0
  211. package/scripts/lint-test-file-count.allowlist.json +27 -1
  212. package/scripts/mutation-matrix.cjs +13 -0
  213. package/scripts/prompt-injection-scan.sh +27 -6
  214. package/scripts/registry-schema.cjs +323 -94
  215. package/scripts/run-tests.cjs +3 -2
  216. package/scripts/validate-registry.cjs +10 -6
  217. package/skills/gsd-autonomous/SKILL.md +1 -1
  218. package/skills/gsd-execute-phase/SKILL.md +1 -1
  219. package/skills/gsd-mempalace-capture/SKILL.md +1 -1
  220. package/skills/gsd-new-milestone/SKILL.md +1 -1
  221. package/skills/gsd-plan-phase/SKILL.md +2 -2
  222. package/vscode/package.json +1 -1
  223. package/scripts/gen-emitted-baseline.cjs +0 -145
@@ -29,10 +29,10 @@ const configLoaderMod = require("./config-loader.cjs");
29
29
  const { loadConfig } = configLoaderMod;
30
30
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- core-utils.cjs is an export= CommonJS module
31
31
  const coreUtilsMod = require("./core-utils.cjs");
32
- const { toPosixPath, generateSlugInternal, readSubdirectories } = coreUtilsMod;
32
+ const { toPosixPath, generateSlugInternal, readSubdirectories, findUnsummarizedPlans } = coreUtilsMod;
33
33
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-id.cjs is an export= CommonJS module
34
34
  const phaseIdMod = require("./phase-id.cjs");
35
- const { escapeRegex, normalizePhaseName, phaseMarkdownRegexSource, comparePhaseNum, phaseTokenMatches, OPTIONAL_PROJECT_CODE_PREFIX_SOURCE, OPTIONAL_PHASE_TAG_SOURCE, PHASE_NUMBER_TOKEN_SOURCE, } = phaseIdMod;
35
+ const { escapeRegex, normalizePhaseName, phaseMarkdownRegexSource, comparePhaseNum, phaseTokenMatches, isSentinelPhaseId, OPTIONAL_PROJECT_CODE_PREFIX_SOURCE, OPTIONAL_PHASE_TAG_SOURCE, PHASE_NUMBER_TOKEN_SOURCE, } = phaseIdMod;
36
36
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-locator.cjs is an export= CommonJS module
37
37
  const phaseLocatorMod = require("./phase-locator.cjs");
38
38
  const { findPhaseInternal, getArchivedPhaseDirs } = phaseLocatorMod;
@@ -62,6 +62,9 @@ const verificationMod = require("./verification.cjs");
62
62
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- verify.cjs is an export= CommonJS module
63
63
  const verifyMod = require("./verify.cjs");
64
64
  const { readVerificationStatus } = verificationMod;
65
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- plan-dependency-graph.cjs is an export= CommonJS module
66
+ const planDependencyGraphMod = require("./plan-dependency-graph.cjs");
67
+ const { computeHaltPropagation, buildSummaryFileIndex, isSummaryFileHalted } = planDependencyGraphMod;
65
68
  const { planningDir, withPlanningLock, listAvailableWorkstreams, getActiveWorkstream } = planningWorkspace;
66
69
  const { extractFrontmatter } = frontmatterMod;
67
70
  const { readModifyWriteStateMd, stateExtractField, stateReplaceField, syncStateFrontmatter, withStateLock, updatePerformanceMetricsSection, } = stateMod;
@@ -421,9 +424,26 @@ function extractObjective(content) {
421
424
  const m = content.match(/<objective>\s*\n?\s*(.+)/);
422
425
  return m ? m[1].trim() : null;
423
426
  }
427
+ /**
428
+ * Resolve a raw `depends_on` token to the `RawPlan.id` it refers to
429
+ * (case-folded exact match, falling back to canonical-id matching). Returns
430
+ * `null` when the token does not resolve to any plan in this phase (a typo
431
+ * or a cross-phase reference) — every call site treats that as "ignore this
432
+ * edge", never a throw. Shared by `computeDependencyLevels`'s DAG-edge
433
+ * resolution, the `depends_on` display mapping, and (#2830) the
434
+ * halt-propagation node resolution, so the three can never disagree about
435
+ * which token resolves to which plan.
436
+ */
437
+ function resolveDependencyId(dep, planMap, canonicalToId) {
438
+ const lower = dep.toLowerCase();
439
+ return planMap.has(lower) ? planMap.get(lower).id : (canonicalToId.get(lower) ?? null);
440
+ }
424
441
  // O(V + E). Assigns each in-phase plan its longest-path topological level over the
425
- // in-phase dependsOn DAG (Kahn's algorithm). Returns { level: Map<id,number>, visited: number }.
426
- // visited < rawPlans.length signals a dependency cycle.
442
+ // in-phase dependsOn DAG (Kahn's algorithm). Returns { level: Map<id,number>, visited: number,
443
+ // order: string[] }. visited < rawPlans.length signals a dependency cycle. `order` (#2830) is
444
+ // the exact dequeue order this pass already produces — a valid topological order — passed to
445
+ // computeHaltPropagation as `precomputedOrder` so halt propagation does not re-run Kahn's
446
+ // algorithm a second time over the same graph.
427
447
  function computeDependencyLevels(rawPlans, planMap, canonicalToId) {
428
448
  const level = new Map();
429
449
  const inDeg = new Map();
@@ -434,10 +454,7 @@ function computeDependencyLevels(rawPlans, planMap, canonicalToId) {
434
454
  if (!adj.has(p.id))
435
455
  adj.set(p.id, []);
436
456
  for (const dep of p.dependsOn) {
437
- const depLower = dep.toLowerCase();
438
- const resolvedDep = planMap.has(depLower)
439
- ? planMap.get(depLower).id
440
- : canonicalToId.get(depLower);
457
+ const resolvedDep = resolveDependencyId(dep, planMap, canonicalToId);
441
458
  if (!resolvedDep)
442
459
  continue;
443
460
  if (!adj.has(resolvedDep))
@@ -472,7 +489,7 @@ function computeDependencyLevels(rawPlans, planMap, canonicalToId) {
472
489
  }
473
490
  }
474
491
  }
475
- return { level, visited };
492
+ return { level, visited, order: queue };
476
493
  }
477
494
  function cmdPhasePlanIndex(cwd, phase, raw) {
478
495
  if (!phase) {
@@ -498,7 +515,7 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
498
515
  // phases dir doesn't exist
499
516
  }
500
517
  if (!phaseDir) {
501
- output({ phase: normalized, error: 'Phase not found', plans: [], waves: {}, incomplete: [], has_checkpoints: false }, raw);
518
+ output({ phase: normalized, error: 'Phase not found', plans: [], waves: {}, incomplete: [], runnable: [], has_checkpoints: false }, raw);
502
519
  return;
503
520
  }
504
521
  void phaseDirName; // used only to set phaseDir above
@@ -511,6 +528,12 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
511
528
  const canonical = extractCanonicalPlanId(s);
512
529
  return canonical === exact ? [exact] : [exact, canonical];
513
530
  }));
531
+ // #2830: reverse lookup from a completed plan's id (exact or canonical) to
532
+ // the actual summary filename, so a plan's own SUMMARY frontmatter can be
533
+ // read for its `status`. Shared builder (also used by phase-locator.cts's
534
+ // searchPhaseInDir) so the two can never disagree about which summary
535
+ // belongs to which plan.
536
+ const summaryFileByPlanId = buildSummaryFileIndex(summaryFiles, extractCanonicalPlanId);
514
537
  // ── Pass 1: parse each plan file ─────────────────────────────────────────
515
538
  const rawPlans = [];
516
539
  for (const planFile of planFiles) {
@@ -544,6 +567,14 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
544
567
  filesModified = Array.isArray(fmFiles) ? fmFiles.map(String) : [String(fmFiles)];
545
568
  }
546
569
  const hasSummary = completedPlanIds.has(planId) || completedPlanIds.has(extractCanonicalPlanId(planFile));
570
+ // #2830: a plan can have a SUMMARY (hasSummary=true) and still be halted —
571
+ // a designed stop still writes a completion record, just one whose status
572
+ // says "halted" rather than "complete". Only look up the summary file
573
+ // when one exists; there is nothing to read otherwise.
574
+ const summaryFile = summaryFileByPlanId.get(planId) ?? summaryFileByPlanId.get(extractCanonicalPlanId(planFile));
575
+ const halted = hasSummary && summaryFile !== undefined
576
+ ? isSummaryFileHalted(node_path_1.default.join(phaseDir, summaryFile))
577
+ : false;
547
578
  rawPlans.push({
548
579
  id: planId,
549
580
  declaredWave,
@@ -553,6 +584,7 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
553
584
  filesModified,
554
585
  taskCount,
555
586
  hasSummary,
587
+ halted,
556
588
  });
557
589
  }
558
590
  // ── Pass 2: topological level assignment via depends_on DAG ──────────────
@@ -568,26 +600,47 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
568
600
  }
569
601
  const planMap = new Map(rawPlans.map((p) => [p.id.toLowerCase(), p]));
570
602
  const canonicalToId = new Map(rawPlans.map((p) => [extractCanonicalPlanId(p.id).toLowerCase(), p.id]));
571
- const { level, visited } = computeDependencyLevels(rawPlans, planMap, canonicalToId);
603
+ const { level, visited, order } = computeDependencyLevels(rawPlans, planMap, canonicalToId);
572
604
  if (visited < rawPlans.length) {
573
605
  const cycleNodes = rawPlans.filter((p) => !level.has(p.id)).map((p) => p.id);
574
606
  error(`depends_on cycle detected in phase ${normalized} — cycle involves: ${cycleNodes.join(', ')}`);
575
607
  return;
576
608
  }
609
+ // #2830: single shared halt-propagation pass, reusing the SAME id
610
+ // resolution (planMap/canonicalToId) AND the SAME topological order
611
+ // (`order`, computeDependencyLevels's own Kahn's-algorithm dequeue
612
+ // sequence) — passed as `precomputedOrder` so computeHaltPropagation does
613
+ // NOT run Kahn's algorithm a second time over this graph.
614
+ const haltNodes = rawPlans.map((p) => ({
615
+ id: p.id,
616
+ resolvedDependsOn: p.dependsOn
617
+ .map((dep) => resolveDependencyId(String(dep), planMap, canonicalToId))
618
+ .filter((id) => id !== null),
619
+ halted: p.halted,
620
+ }));
621
+ const { blockedBy } = computeHaltPropagation(haltNodes, order);
577
622
  // ── Pass 3: determine lowest bucket key and build output ─────────────────
578
623
  const anyWaveZero = rawPlans.some((p) => p.declaredWave === 0);
579
624
  const levelOffset = anyWaveZero ? 0 : 1;
580
625
  const plans = [];
581
626
  const waves = {};
582
627
  const incomplete = [];
628
+ const runnable = [];
583
629
  let hasCheckpoints = false;
584
630
  const warnings = [];
585
631
  for (const rawPlan of rawPlans) {
586
632
  if (!rawPlan.autonomous) {
587
633
  hasCheckpoints = true;
588
634
  }
635
+ const blockedByIds = blockedBy.get(rawPlan.id) ?? [];
589
636
  if (!rawPlan.hasSummary) {
590
637
  incomplete.push(rawPlan.id);
638
+ // #2830: the runnable-only view — incomplete AND not transitively
639
+ // blocked by a halted upstream plan. Additive alongside `incomplete`,
640
+ // which keeps its existing "no SUMMARY yet" meaning unchanged.
641
+ if (blockedByIds.length === 0) {
642
+ runnable.push(rawPlan.id);
643
+ }
591
644
  }
592
645
  const computedWave = (level.get(rawPlan.id) ?? 0) + levelOffset;
593
646
  const effectiveWave = computedWave;
@@ -597,6 +650,13 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
597
650
  const plan = {
598
651
  id: rawPlan.id,
599
652
  wave: effectiveWave,
653
+ // DELIBERATELY not `resolveDependencyId`: the emitted field is a DISPLAY
654
+ // mapping, not the DAG resolution. It rewrites a dep only when it names a
655
+ // plan directly (planMap) and otherwise passes it through verbatim — a
656
+ // short canonical prefix like `24-01` stays `24-01` rather than becoming
657
+ // `24-01-auth-hardening`. #3785 pins that contract. Full resolution via
658
+ // canonicalToId is used for the wave DAG and #2830 halt propagation only;
659
+ // routing this line through it too silently changed the output shape.
600
660
  depends_on: rawPlan.dependsOn.map((dep) => {
601
661
  const lower = String(dep).toLowerCase();
602
662
  return planMap.has(lower) ? planMap.get(lower).id : dep;
@@ -606,6 +666,11 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
606
666
  files_modified: rawPlan.filesModified,
607
667
  task_count: rawPlan.taskCount,
608
668
  has_summary: rawPlan.hasSummary,
669
+ // #2830: additive fields — halted is this plan's OWN status; blocked_by
670
+ // names the halted plan(s) transitively upstream of it (empty when not
671
+ // blocked). Neither mutates has_summary/incomplete's existing meaning.
672
+ halted: rawPlan.halted,
673
+ blocked_by: blockedByIds,
609
674
  };
610
675
  plans.push(plan);
611
676
  const waveKey = String(effectiveWave);
@@ -619,6 +684,7 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
619
684
  plans,
620
685
  waves,
621
686
  incomplete,
687
+ runnable,
622
688
  has_checkpoints: hasCheckpoints,
623
689
  };
624
690
  if (planNamingWarning)
@@ -1289,19 +1355,50 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
1289
1355
  }
1290
1356
  updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, parseInt(normalized, 10), cwd);
1291
1357
  const statePath = node_path_1.default.join(planningDir(cwd), 'STATE.md');
1358
+ let stateUpdated = false;
1292
1359
  if (node_fs_1.default.existsSync(statePath)) {
1293
- readModifyWriteStateMd(statePath, (stateContent) => {
1294
- const totalRaw = stateExtractField(stateContent, 'Total Phases');
1360
+ // #2640: report whether STATE.md content actually changed, not just file
1361
+ // existence (fs.existsSync was trivially true). Also ensure the body
1362
+ // transform produces a diff so readModifyWriteStateMd's no-op guard
1363
+ // (#948) doesn't skip the frontmatter resync — without that, the
1364
+ // progress.* frontmatter block stays stale when the body has no
1365
+ // 'Total Phases:' or 'of N' phrase.
1366
+ stateUpdated = readModifyWriteStateMd(statePath, (stateContent) => {
1367
+ let modified = stateContent;
1368
+ const totalRaw = stateExtractField(modified, 'Total Phases');
1295
1369
  if (totalRaw) {
1296
- stateContent =
1297
- stateReplaceField(stateContent, 'Total Phases', String(parseInt(totalRaw, 10) - 1)) ||
1298
- stateContent;
1370
+ modified =
1371
+ stateReplaceField(modified, 'Total Phases', String(parseInt(totalRaw, 10) - 1)) ||
1372
+ modified;
1299
1373
  }
1300
- const ofMatch = stateContent.match(/(\bof\s+)(\d+)(\s*(?:\(|phases?))/i);
1374
+ const ofMatch = modified.match(/(\bof\s+)(\d+)(\s*(?:\(|phases?))/i);
1301
1375
  if (ofMatch) {
1302
- stateContent = stateContent.replace(/(\bof\s+)(\d+)(\s*(?:\(|phases?))/i, `$1${parseInt(ofMatch[2], 10) - 1}$3`);
1376
+ modified = modified.replace(/(\bof\s+)(\d+)(\s*(?:\(|phases?))/i, `$1${parseInt(ofMatch[2], 10) - 1}$3`);
1303
1377
  }
1304
- return stateContent;
1378
+ // #2640: if neither body field was found, the transform is a no-op.
1379
+ // readModifyWriteStateMd's no-op guard (#948) would then skip the
1380
+ // frontmatter resync, leaving progress.* stale. Force a body diff
1381
+ // ONLY when a phase directory was actually removed (targetDir !== null)
1382
+ // so the guard passes and syncStateFrontmatter rebuilds the frontmatter
1383
+ // from the post-deletion disk/ROADMAP state. Without the targetDir gate,
1384
+ // a no-op removal (ROADMAP-only phase, no directory) would inject a
1385
+ // spurious 'Total Phases:' line into a body that intentionally lacked one.
1386
+ if (targetDir && modified === stateContent) {
1387
+ // subdirs was read before the deletion; excluding the removed target
1388
+ // gives the remaining count. Renumbering changes names but not count.
1389
+ const remainingPhases = subdirs.filter((d) => phaseTokenMatches(d, normalized) === false).length;
1390
+ if (totalRaw) {
1391
+ modified =
1392
+ stateReplaceField(modified, 'Total Phases', String(remainingPhases)) || modified;
1393
+ }
1394
+ else {
1395
+ // No 'Total Phases:' field in the body — append one so the no-op
1396
+ // guard sees a diff. syncStateFrontmatter will then rebuild the
1397
+ // frontmatter progress.* block from the real disk/ROADMAP count.
1398
+ modified = `Total Phases: ${remainingPhases}\n` + modified;
1399
+ }
1400
+ }
1401
+ return modified;
1305
1402
  }, cwd);
1306
1403
  }
1307
1404
  output({
@@ -1310,7 +1407,7 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
1310
1407
  renamed_directories: renamedDirs,
1311
1408
  renamed_files: renamedFiles,
1312
1409
  roadmap_updated: true,
1313
- state_updated: node_fs_1.default.existsSync(statePath),
1410
+ state_updated: stateUpdated,
1314
1411
  }, raw);
1315
1412
  }
1316
1413
  function writePlanningFileSet(writes) {
@@ -1393,7 +1490,78 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1393
1490
  : 0;
1394
1491
  let requirementsUpdated = false;
1395
1492
  const warnings = [];
1493
+ // #3057 B3: mirrors `verification_stale_check_indeterminate` on init.cts /
1494
+ // roadmap.cts / uat-predicate.cts's outputs — set on the non-blocking path
1495
+ // below (inside withPlanningLock) alongside the warnings[] entry, so a
1496
+ // caller can assert on the typed field instead of the warning's prose.
1497
+ let staleCheckIndeterminate = false;
1396
1498
  const phaseFullDir = node_path_1.default.join(cwd, phaseInfo['directory']);
1499
+ // #2648: fail-closed plan-coverage gate. phase.complete used to gate ONLY on a
1500
+ // single *-VERIFICATION.md status, so a phase could close "complete" while an
1501
+ // arbitrary number of its plans — including plans a lock/recovery decision
1502
+ // silently dropped — had no completion record (a confirmed production incident
1503
+ // closed a phase with 6/30 plans unexecuted, including its entire final UI
1504
+ // scope, with every tool-reported signal green). Now refuse completion when any
1505
+ // plan lacks a matching *-SUMMARY.md, UNLESS that plan is explicitly retired
1506
+ // via machine-readable `status: superseded` frontmatter (the #2349 marker).
1507
+ //
1508
+ // scanPhasePlans is the superseded-AWARE counter (it drops status: superseded
1509
+ // plans from planFiles before returning), so a deliberately-retired plan never
1510
+ // appears in the unsummarized set and never blocks completion — closing the
1511
+ // Goodhart hole (delete a SUMMARY to raise the %) without regressing the
1512
+ // legitimate lock/recovery pattern (retire a plan instead of executing it).
1513
+ // This is evaluated BEFORE the verification-gate transaction below so a
1514
+ // plan-coverage refusal fails fast without mutating ROADMAP/STATE. The count
1515
+ // path (cmdPhaseComplete's own planCount/summaryCount above) is NOT superseded-
1516
+ // aware (it comes from findPhaseInternal/phase-locator.cts); that is fine for
1517
+ // DISPLAY (the X/Y cell) but must not be the gate — the gate needs the
1518
+ // superseded-adjusted set so retired plans don't re-block the very phases the
1519
+ // marker exists to unblock. Matches roadmap.cts's already-correct-but-unenforced
1520
+ // `summaryCount >= planCount` predicate, now enforced at the completion seam.
1521
+ const coverageScan = scanPhasePlans(phaseFullDir);
1522
+ // #2648 security: fail CLOSED when the phase directory cannot be read.
1523
+ // scanPhasePlans deliberately swallows readdirSync errors and returns an empty
1524
+ // plan set ({planFiles: []}), which is indistinguishable from a readable empty
1525
+ // phase. For a COVERAGE gate that is the wrong posture: "I could not read the
1526
+ // plans" must mean "I cannot prove coverage," not "all plans are summarized" —
1527
+ // otherwise any I/O failure (permissions, ENOTDIR, EBUSY on Windows, a dir
1528
+ // present in ROADMAP.md but missing/unreadable on disk) silently re-opens the
1529
+ // exact hole this gate exists to close. Distinguish the two: a readable
1530
+ // directory with zero plans is a legitimately complete empty phase; an
1531
+ // UNREADABLE directory is a fail-closed refusal. Mirrors cmdPhaseInsert's own
1532
+ // readdirSync-fail-closed posture (a swallow there used to risk writing a
1533
+ // colliding phase number).
1534
+ try {
1535
+ node_fs_1.default.readdirSync(phaseFullDir);
1536
+ }
1537
+ catch (readErr) {
1538
+ error(`Phase ${phaseNum} cannot be completed: its plan directory is unreadable (${phaseInfo['directory']}: ${readErr.code || readErr.message}), so plan coverage cannot be verified. Restore read access and retry — a coverage gate that passes when it cannot read the plans is no gate at all (#2648).`, ERROR_REASON.PHASE_PLAN_COVERAGE_INCOMPLETE);
1539
+ }
1540
+ const unsummarizedPlans = findUnsummarizedPlans(coverageScan.planFiles, coverageScan.summaryFiles);
1541
+ if (unsummarizedPlans.length > 0) {
1542
+ // Sanitize plan filenames before interpolation: they come raw from
1543
+ // readdirSync and could carry C0 control chars / DEL (a committable filename
1544
+ // could spoof the terminal in plain-error mode). Strip them so the message is
1545
+ // safe to print regardless of --json-errors. Path traversal sequences are not
1546
+ // a code-execution vector here (printed only, never reopened from the message).
1547
+ const sanitize = (name) => name.replace(/[\u0000-\u001f\u007f]/g, '?');
1548
+ const listed = unsummarizedPlans.slice(0, 20).map(sanitize).join(', ');
1549
+ const more = unsummarizedPlans.length > 20 ? ` (and ${unsummarizedPlans.length - 20} more)` : '';
1550
+ // Audit surface (#2648 review M1): name how many plans were excluded as
1551
+ // superseded so a reviewer can see WHICH work was declared retired, not just
1552
+ // that some plans are missing summaries. The status: superseded marker is a
1553
+ // committable, review-time-trusted bypass; surfacing its count keeps that
1554
+ // bypass visible rather than silent.
1555
+ const phaseInfoPlanCount = Array.isArray(phaseInfo['plans']) ? phaseInfo['plans'].length : 0;
1556
+ const supersededCount = coverageScan.planFiles.length === 0 ? 0 : Math.max(0, phaseInfoPlanCount - coverageScan.planFiles.length);
1557
+ const supersededNote = supersededCount > 0
1558
+ ? ` ${supersededCount} plan(s) excluded as status: superseded (retired).`
1559
+ : '';
1560
+ error(`Phase ${phaseNum} cannot be completed: ${unsummarizedPlans.length} plan(s) have no completion record (*-SUMMARY.md): ${listed}${more}.` +
1561
+ supersededNote +
1562
+ ` Execute the plans and write their summaries, or retire a plan with machine-readable \`status: superseded\` frontmatter (#2349) if it was deliberately dropped — a retired plan is excluded from this gate. ` +
1563
+ `Completing a phase with unexecuted plans is what lost an entire promised deliverable silently (#2648).`, ERROR_REASON.PHASE_PLAN_COVERAGE_INCOMPLETE);
1564
+ }
1397
1565
  try {
1398
1566
  const phaseFiles = node_fs_1.default.readdirSync(phaseFullDir);
1399
1567
  for (const file of phaseFiles.filter((f) => f.includes('-UAT') && f.endsWith('.md'))) {
@@ -1476,6 +1644,19 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1476
1644
  // suggests the command surface this runtime actually installs
1477
1645
  // ($gsd-… on Codex) rather than a hard-coded Claude-style string.
1478
1646
  const verificationStatus = readVerificationStatus(phaseFullDir, { runtime: (0, runtime_slash_cjs_1.resolveRuntime)(cwd) });
1647
+ // #3057 B3: the staleness check inside readVerificationStatus can itself
1648
+ // fail (fs / scanPhasePlans / clock error), in which case `status` above
1649
+ // was routed as if nothing were stale (unchanged fail-open routing) — but
1650
+ // that must not be silently identical to a check that actually ran and
1651
+ // found nothing stale. Join the SAME advisory channel the UAT/VERIFICATION
1652
+ // pre-scan above already uses (`warnings[]`, rendered by execute-phase.md's
1653
+ // "If has_warnings is true" step) rather than inventing a new one. This
1654
+ // only fires on the non-blocking path (status resolves to 'passed' despite
1655
+ // the indeterminate check) — the blocked path below carries its own note.
1656
+ if (verificationStatus.staleCheckIndeterminate) {
1657
+ staleCheckIndeterminate = true;
1658
+ warnings.push(`verification staleness check could not complete for phase ${phaseNum} — routed as not-stale, but this was not actually verified (#3057)`);
1659
+ }
1479
1660
  if (verificationStatus.status !== 'passed') {
1480
1661
  return verificationStatus;
1481
1662
  }
@@ -1682,7 +1863,17 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1682
1863
  .filter((r) => REQ_ID_SHAPE_RE.test(r));
1683
1864
  for (const reqId of citedReqIds) {
1684
1865
  const reqEscaped = escapeRegex(reqId);
1685
- reqContent = reqContent.replace(new RegExp(`(-\\s*\\[)[ ](\\]\\s*\\*\\*${reqEscaped}\\*\\*)`, 'gi'), '$1x$2');
1866
+ // Surface 1 — the checkbox: - [ ] **REQ-ID** → - [x] **REQ-ID**.
1867
+ // #2945: the flip is CONDITIONAL (porting #2788 defect-2's rollback from
1868
+ // cmdRequirementsMarkComplete). Capture the pre-flip content; if a
1869
+ // traceability row EXISTS for this ID below but its Status write is rejected
1870
+ // (Out/Deferred/Blocked), the checkbox is rolled back so the two surfaces
1871
+ // cannot silently diverge. A requirement recorded as deferred must not read
1872
+ // as shipped.
1873
+ const checkboxRe = new RegExp(`(-\\s*\\[)[ ](\\]\\s*\\*\\*${reqEscaped}\\*\\*)`, 'gi');
1874
+ const beforeCheckbox = reqContent;
1875
+ reqContent = reqContent.replace(checkboxRe, '$1x$2');
1876
+ const checkboxFlipped = reqContent !== beforeCheckbox;
1686
1877
  // Traceability row: | <REQ-ID> | Phase N | Pending|In Progress | ->
1687
1878
  // ... Complete | via the markdown-table seam (ADR-2143 §7). Match the
1688
1879
  // row by its FIRST cell's value (the requirement-ID column) regardless
@@ -1698,13 +1889,33 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1698
1889
  // requirement's write. The "only flip Pending/In Progress ->
1699
1890
  // Complete" gate is folded into the newValue callback so one
1700
1891
  // updateTableCell call both probes and writes.
1701
- const reqUpdate = updateTraceabilityCell(reqContent, reqRowMatch, 'Status', (current) => /^(?:pending|in progress)$/i.test(current.trim()) ? ' Complete ' : current);
1892
+ // #2945: track tableHit (did the callback actually CHANGE the value?) so the
1893
+ // checkbox rollback below can distinguish "row existed and accepted" from
1894
+ // "row existed and rejected".
1895
+ let tableHit = false;
1896
+ const reqUpdate = updateTraceabilityCell(reqContent, reqRowMatch, 'Status', (current) => {
1897
+ // #2788: accept `Gaps Found` too so a phase stranded by revert-phase (the
1898
+ // gaps_found response) can complete without hand-editing the table.
1899
+ if (/^(?:pending|in progress|gaps found)$/i.test(current.trim())) {
1900
+ tableHit = true;
1901
+ return ' Complete ';
1902
+ }
1903
+ return current;
1904
+ });
1702
1905
  if (reqUpdate.ok) {
1703
1906
  reqContent = reqUpdate.value;
1704
1907
  }
1705
1908
  else if (!isPlaceholderReqId(reqId)) {
1706
1909
  traceabilityWriteMisses.push(reqId);
1707
1910
  }
1911
+ // #2945 defect-2 (port of milestone.cts:200-210): if a row EXISTS for this
1912
+ // ID but its Status write was rejected (row reads Out/Deferred/Blocked,
1913
+ // which the callback returned unchanged), roll the checkbox back so the
1914
+ // checkbox and the row cannot silently diverge. reqUpdate.ok === a row
1915
+ // matched (existence probe); !tableHit === the callback did not advance it.
1916
+ if (checkboxFlipped && reqUpdate.ok && !tableHit) {
1917
+ reqContent = beforeCheckbox;
1918
+ }
1708
1919
  }
1709
1920
  }
1710
1921
  // #1159 (Defect B): collect requirement IDs only from ACTIVE sections.
@@ -1946,6 +2157,12 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1946
2157
  const phasePattern = new RegExp(`(?:#{2,4}|-\\s*\\[[ xX]\\])\\s*(?:\\*\\*|__)?\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n*]+)`, 'gi');
1947
2158
  let pm;
1948
2159
  while ((pm = phasePattern.exec(roadmapForPhases)) !== null) {
2160
+ // #2786: skip sentinel phase ids (999.x backlog, 0.x drafts) — stage 1
2161
+ // already skips 999 dirs on disk; stage 2's heading scan must not
2162
+ // advance into backlog headings. Mirrors the /^999(?:\.|$)/ guard
2163
+ // stage 1 uses at line 2536, but via isSentinelPhaseId for both ranges.
2164
+ if (isSentinelPhaseId(pm[1]))
2165
+ continue;
1949
2166
  if (comparePhaseNum(pm[1], phaseNum) > 0) {
1950
2167
  nextPhaseNum = pm[1];
1951
2168
  nextPhaseName = pm[2]
@@ -1987,7 +2204,14 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1987
2204
  let lowestOutstanding = null;
1988
2205
  while ((cbm = cbPattern.exec(milestoneScope)) !== null) {
1989
2206
  const isChecked = cbm[1].toLowerCase() === 'x';
1990
- if (!isChecked && comparePhaseNum(cbm[2], phaseNum) < 0) {
2207
+ // #2949: exclude sentinel-range phase ids (0.x backlog, 999.x) from candidacy.
2208
+ // comparePhaseNum("0.1","12") === -12, so without this guard an unchecked 0.x
2209
+ // backlog row sorts below every real phase and is wrongly selected as next_phase,
2210
+ // corrupting STATE.md and desyncing current_phase from current_phase_name.
2211
+ // isSentinelPhaseId covers both sentinel ranges (SENTINEL_RANGES = [0, 999]); a
2212
+ // real lower-numbered outstanding phase (e.g. Phase 9) is NOT a sentinel and is
2213
+ // still selected, preserving #2028's out-of-order-completion behavior.
2214
+ if (!isChecked && !isSentinelPhaseId(cbm[2]) && comparePhaseNum(cbm[2], phaseNum) < 0) {
1991
2215
  if (lowestOutstanding === null || comparePhaseNum(cbm[2], lowestOutstanding.num) < 0) {
1992
2216
  lowestOutstanding = {
1993
2217
  num: cbm[2],
@@ -2036,7 +2260,6 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
2036
2260
  summaryCount,
2037
2261
  }, {
2038
2262
  clock: clock_cjs_1.realClock,
2039
- progressProvider: () => null, // completePhase derives progress from the roadmap, not disk
2040
2263
  roadmapProvider: () => roadmapContent,
2041
2264
  sourcePath: statePath,
2042
2265
  });
@@ -2063,7 +2286,18 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
2063
2286
  const nextStep = verificationBlocked.next_command
2064
2287
  ? ` Next: ${verificationBlocked.next_command}`
2065
2288
  : '';
2066
- error(`Phase ${phaseNum} verification is incomplete: ${verificationBlocked.next_action}${nextStep}`, ERROR_REASON.PHASE_VERIFICATION_INCOMPLETE);
2289
+ // #3057 B3: purely additive to the message text — does not change WHETHER
2290
+ // this blocks (verificationBlocked was already truthy) or the
2291
+ // ERROR_REASON, only whether the operator can see the staleness check
2292
+ // itself did not complete. The same fact is also attached as a typed
2293
+ // field (`verification_stale_check_indeterminate`) on the JSON-error-mode
2294
+ // payload so a test can assert on it by value instead of regexing this
2295
+ // human-readable note.
2296
+ const staleCheckIndeterminate = verificationBlocked.staleCheckIndeterminate === true;
2297
+ const indeterminateNote = staleCheckIndeterminate
2298
+ ? ' (staleness check could not complete — see #3057)'
2299
+ : '';
2300
+ error(`Phase ${phaseNum} verification is incomplete: ${verificationBlocked.next_action}${nextStep}${indeterminateNote}`, ERROR_REASON.PHASE_VERIFICATION_INCOMPLETE, { verification_stale_check_indeterminate: staleCheckIndeterminate });
2067
2301
  }
2068
2302
  let autoPruned = false;
2069
2303
  try {
@@ -2097,6 +2331,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
2097
2331
  auto_pruned: autoPruned,
2098
2332
  warnings,
2099
2333
  has_warnings: warnings.length > 0,
2334
+ verification_stale_check_indeterminate: staleCheckIndeterminate,
2100
2335
  };
2101
2336
  output(result, raw);
2102
2337
  }