@opengsd/gsd-core 1.7.0 → 1.9.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 (261) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.opencode/plugins/gsd-core.js +45 -1
  4. package/README.md +2 -0
  5. package/agents/gsd-code-fixer.md +1 -1
  6. package/agents/gsd-codebase-mapper.md +1 -1
  7. package/agents/gsd-debug-session-manager.md +78 -4
  8. package/agents/gsd-debugger.md +87 -29
  9. package/agents/gsd-executor.md +49 -9
  10. package/agents/gsd-intel-updater.md +3 -3
  11. package/agents/gsd-phase-researcher.md +4 -2
  12. package/agents/gsd-plan-checker.md +20 -0
  13. package/agents/gsd-planner.md +44 -59
  14. package/agents/gsd-project-researcher.md +2 -2
  15. package/agents/gsd-ui-auditor.md +0 -40
  16. package/agents/gsd-verifier.md +2 -2
  17. package/bin/install.js +1338 -135
  18. package/commands/gsd/ai-integration-phase.md +1 -1
  19. package/commands/gsd/mempalace-capture.md +9 -5
  20. package/commands/gsd/new-milestone.md +1 -1
  21. package/commands/gsd/plan-phase.md +5 -3
  22. package/commands/gsd/plan-review-convergence.md +7 -2
  23. package/gsd-core/bin/gsd-tools.cjs +2690 -2472
  24. package/gsd-core/bin/lib/adapter-imperative.cjs +8 -1
  25. package/gsd-core/bin/lib/agent-command-router.cjs +20 -5
  26. package/gsd-core/bin/lib/api-coverage.cjs +360 -53
  27. package/gsd-core/bin/lib/audit.cjs +8 -8
  28. package/gsd-core/bin/lib/broken-windows.cjs +716 -0
  29. package/gsd-core/bin/lib/capability-command-router.cjs +733 -0
  30. package/gsd-core/bin/lib/capability-consent.cjs +40 -1
  31. package/gsd-core/bin/lib/capability-lifecycle.cjs +58 -0
  32. package/gsd-core/bin/lib/capability-loader.cjs +23 -1
  33. package/gsd-core/bin/lib/capability-registry.cjs +1450 -160
  34. package/gsd-core/bin/lib/capability-trust.cjs +468 -33
  35. package/gsd-core/bin/lib/capability-validator.cjs +882 -6
  36. package/gsd-core/bin/lib/capability-writer.cjs +6 -1
  37. package/gsd-core/bin/lib/check-command-router.cjs +140 -27
  38. package/gsd-core/bin/lib/cjs-command-router-adapter.cjs +15 -0
  39. package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +209 -31
  40. package/gsd-core/bin/lib/claude-orchestration.cjs +203 -25
  41. package/gsd-core/bin/lib/command-aliases.cjs +14 -0
  42. package/gsd-core/bin/lib/commands.cjs +326 -21
  43. package/gsd-core/bin/lib/config-loader.cjs +214 -30
  44. package/gsd-core/bin/lib/config.cjs +158 -22
  45. package/gsd-core/bin/lib/core-utils.cjs +6 -1
  46. package/gsd-core/bin/lib/decisions.cjs +32 -8
  47. package/gsd-core/bin/lib/docs.cjs +6 -0
  48. package/gsd-core/bin/lib/estimate-cli.cjs +336 -0
  49. package/gsd-core/bin/lib/external-descriptor-trust.cjs +14 -2
  50. package/gsd-core/bin/lib/frontmatter.cjs +125 -15
  51. package/gsd-core/bin/lib/gap-checker.cjs +17 -2
  52. package/gsd-core/bin/lib/host-integration.cjs +215 -8
  53. package/gsd-core/bin/lib/init.cjs +155 -66
  54. package/gsd-core/bin/lib/install-engine.cjs +299 -23
  55. package/gsd-core/bin/lib/install-profiles.cjs +239 -1
  56. package/gsd-core/bin/lib/installer-migrations/005-opencode-baseline-commands-dir.cjs +146 -0
  57. package/gsd-core/bin/lib/installer-migrations/006-pi-extension-cjs-to-js.cjs +91 -0
  58. package/gsd-core/bin/lib/installer-migrations.cjs +44 -5
  59. package/gsd-core/bin/lib/markdown-sectionizer.cjs +107 -0
  60. package/gsd-core/bin/lib/milestone.cjs +248 -14
  61. package/gsd-core/bin/lib/model-catalog.cjs +69 -4
  62. package/gsd-core/bin/lib/model-resolver.cjs +189 -7
  63. package/gsd-core/bin/lib/observability/logger.cjs +7 -2
  64. package/gsd-core/bin/lib/onboard-projection.cjs +11 -8
  65. package/gsd-core/bin/lib/phase-command-router.cjs +10 -1
  66. package/gsd-core/bin/lib/phase-estimation.cjs +398 -0
  67. package/gsd-core/bin/lib/phase-id.cjs +304 -9
  68. package/gsd-core/bin/lib/phase.cjs +258 -17
  69. package/gsd-core/bin/lib/plan-drift-guard.cjs +1 -1
  70. package/gsd-core/bin/lib/plan-scan.cjs +70 -2
  71. package/gsd-core/bin/lib/planning-workspace.cjs +9 -2
  72. package/gsd-core/bin/lib/profile-output.cjs +34 -8
  73. package/gsd-core/bin/lib/review-lane-descriptor.cjs +927 -0
  74. package/gsd-core/bin/lib/review-lane-invocation.cjs +348 -0
  75. package/gsd-core/bin/lib/review-lane-runner.cjs +594 -0
  76. package/gsd-core/bin/lib/review-reviewer-selection.cjs +114 -32
  77. package/gsd-core/bin/lib/roadmap-parser.cjs +61 -10
  78. package/gsd-core/bin/lib/roadmap.cjs +23 -7
  79. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +38 -5
  80. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +23 -9
  81. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +156 -0
  82. package/gsd-core/bin/lib/runtime-name-policy.cjs +15 -2
  83. package/gsd-core/bin/lib/smart-entry.cjs +70 -5
  84. package/gsd-core/bin/lib/state-document.cjs +171 -24
  85. package/gsd-core/bin/lib/state-transition.cjs +50 -11
  86. package/gsd-core/bin/lib/state.cjs +206 -32
  87. package/gsd-core/bin/lib/surface.cjs +51 -9
  88. package/gsd-core/bin/lib/uat-predicate.cjs +6 -4
  89. package/gsd-core/bin/lib/uat.cjs +428 -11
  90. package/gsd-core/bin/lib/ui-consideration-probe.cjs +2 -2
  91. package/gsd-core/bin/lib/unusable-input.cjs +216 -0
  92. package/gsd-core/bin/lib/validate.cjs +44 -8
  93. package/gsd-core/bin/lib/verification.cjs +163 -31
  94. package/gsd-core/bin/lib/verify.cjs +348 -42
  95. package/gsd-core/bin/lib/worktree-safety.cjs +360 -15
  96. package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
  97. package/gsd-core/bin/shared/config-schema.manifest.json +4 -15
  98. package/gsd-core/bin/shared/model-catalog.json +5 -0
  99. package/gsd-core/bin/shared/runtime-aliases.manifest.json +5 -0
  100. package/gsd-core/references/api-coverage.md +37 -7
  101. package/gsd-core/references/checkpoints.md +1 -1
  102. package/gsd-core/references/common-bug-patterns.md +13 -0
  103. package/gsd-core/references/context-budget.md +40 -0
  104. package/gsd-core/references/debugger-bug-taxonomy.md +111 -0
  105. package/gsd-core/references/debugger-fix-acceptance.md +157 -0
  106. package/gsd-core/references/debugger-philosophy.md +1 -0
  107. package/gsd-core/references/debugger-prevention.md +98 -0
  108. package/gsd-core/references/debugger-rca-branching.md +98 -0
  109. package/gsd-core/references/debugger-repro-hardening.md +130 -0
  110. package/gsd-core/references/debugger-sbfl.md +110 -0
  111. package/gsd-core/references/debugger-semantic-recall.md +81 -0
  112. package/gsd-core/references/execute-phase-quota-recovery.md +55 -0
  113. package/gsd-core/references/execute-phase-requirement-revert.md +8 -0
  114. package/gsd-core/references/execute-phase-response-language.md +7 -0
  115. package/gsd-core/references/gate-prompts.md +6 -3
  116. package/gsd-core/references/model-profile-resolution.md +64 -13
  117. package/gsd-core/references/offer-next.md +88 -0
  118. package/gsd-core/references/planner-antipatterns.md +6 -0
  119. package/gsd-core/references/planner-mvp-mode.md +12 -13
  120. package/gsd-core/references/planner-preconditions.md +156 -0
  121. package/gsd-core/references/planner-reversibility.md +132 -0
  122. package/gsd-core/references/planning-config.md +2 -1
  123. package/gsd-core/references/reviewer-instances.md +28 -19
  124. package/gsd-core/references/runtime-aware-dispatch.md +42 -0
  125. package/gsd-core/references/skeleton-template.md +1 -1
  126. package/gsd-core/references/thinking-models-planning.md +3 -1
  127. package/gsd-core/references/ui-consideration-probe.md +2 -2
  128. package/gsd-core/references/worktree-branch-check.md +4 -4
  129. package/gsd-core/templates/DEBUG.md +5 -3
  130. package/gsd-core/templates/summary-minimal.md +4 -0
  131. package/gsd-core/templates/summary-standard.md +4 -0
  132. package/gsd-core/templates/summary.md +7 -0
  133. package/gsd-core/workflows/add-phase.md +2 -0
  134. package/gsd-core/workflows/add-tests.md +3 -1
  135. package/gsd-core/workflows/add-todo.md +32 -1
  136. package/gsd-core/workflows/ai-integration-phase.md +8 -6
  137. package/gsd-core/workflows/audit-fix.md +6 -2
  138. package/gsd-core/workflows/audit-milestone.md +8 -0
  139. package/gsd-core/workflows/autonomous.md +19 -15
  140. package/gsd-core/workflows/check-todos.md +5 -3
  141. package/gsd-core/workflows/cleanup.md +7 -1
  142. package/gsd-core/workflows/code-review-fix.md +14 -6
  143. package/gsd-core/workflows/code-review.md +93 -24
  144. package/gsd-core/workflows/complete-milestone.md +3 -0
  145. package/gsd-core/workflows/debug.md +35 -7
  146. package/gsd-core/workflows/diagnose-issues.md +5 -1
  147. package/gsd-core/workflows/discovery-phase.md +7 -0
  148. package/gsd-core/workflows/discuss-phase/modes/advisor.md +2 -4
  149. package/gsd-core/workflows/discuss-phase/modes/auto.md +0 -6
  150. package/gsd-core/workflows/discuss-phase/templates/context.md +16 -2
  151. package/gsd-core/workflows/discuss-phase-assumptions.md +18 -9
  152. package/gsd-core/workflows/discuss-phase.md +2 -2
  153. package/gsd-core/workflows/do.md +7 -1
  154. package/gsd-core/workflows/docs-update.md +9 -0
  155. package/gsd-core/workflows/eval-review.md +4 -1
  156. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +4 -0
  157. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +160 -0
  158. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +4 -4
  159. package/gsd-core/workflows/execute-phase/steps/regression-gate.md +2 -2
  160. package/gsd-core/workflows/execute-phase.md +110 -149
  161. package/gsd-core/workflows/execute-plan.md +20 -8
  162. package/gsd-core/workflows/explore.md +4 -0
  163. package/gsd-core/workflows/extract-learnings.md +21 -0
  164. package/gsd-core/workflows/graduation.md +3 -0
  165. package/gsd-core/workflows/health.md +7 -1
  166. package/gsd-core/workflows/help/modes/full.md +9 -5
  167. package/gsd-core/workflows/import.md +11 -2
  168. package/gsd-core/workflows/inbox.md +7 -0
  169. package/gsd-core/workflows/ingest-docs.md +19 -10
  170. package/gsd-core/workflows/manager.md +3 -1
  171. package/gsd-core/workflows/map-codebase.md +17 -10
  172. package/gsd-core/workflows/mvp-phase.md +3 -0
  173. package/gsd-core/workflows/new-milestone.md +79 -23
  174. package/gsd-core/workflows/new-project.md +28 -19
  175. package/gsd-core/workflows/new-workspace.md +3 -1
  176. package/gsd-core/workflows/next.md +5 -2
  177. package/gsd-core/workflows/onboard.md +3 -0
  178. package/gsd-core/workflows/plan-phase.md +56 -51
  179. package/gsd-core/workflows/plan-review-convergence.md +61 -12
  180. package/gsd-core/workflows/plant-seed.md +3 -0
  181. package/gsd-core/workflows/profile-user.md +7 -1
  182. package/gsd-core/workflows/progress.md +31 -3
  183. package/gsd-core/workflows/quick.md +33 -10
  184. package/gsd-core/workflows/remove-workspace.md +3 -0
  185. package/gsd-core/workflows/review.md +172 -585
  186. package/gsd-core/workflows/scan.md +10 -2
  187. package/gsd-core/workflows/secure-phase.md +13 -2
  188. package/gsd-core/workflows/settings-integrations.md +3 -0
  189. package/gsd-core/workflows/settings.md +3 -0
  190. package/gsd-core/workflows/ship.md +88 -11
  191. package/gsd-core/workflows/sketch.md +3 -0
  192. package/gsd-core/workflows/smart-entry.md +4 -1
  193. package/gsd-core/workflows/spike.md +7 -1
  194. package/gsd-core/workflows/ui-phase.md +11 -2
  195. package/gsd-core/workflows/ui-review.md +11 -1
  196. package/gsd-core/workflows/undo.md +7 -0
  197. package/gsd-core/workflows/update.md +106 -5
  198. package/gsd-core/workflows/validate-phase.md +13 -2
  199. package/gsd-core/workflows/verify-phase.md +2 -2
  200. package/gsd-core/workflows/verify-work.md +15 -4
  201. package/hooks/dist/gsd-context-monitor.js +27 -9
  202. package/hooks/dist/gsd-cursor-session-start.js +6 -2
  203. package/hooks/dist/gsd-cursor-stop.js +6 -2
  204. package/hooks/dist/gsd-cursor-subagent-start.js +6 -2
  205. package/hooks/dist/gsd-graphify-update.sh +9 -0
  206. package/hooks/dist/gsd-phase-boundary.sh +14 -2
  207. package/hooks/dist/gsd-prompt-guard.js +101 -2
  208. package/hooks/dist/gsd-read-guard.js +100 -2
  209. package/hooks/dist/gsd-read-injection-scanner.js +109 -2
  210. package/hooks/dist/gsd-statusline.js +97 -9
  211. package/hooks/dist/gsd-workflow-guard.js +110 -6
  212. package/hooks/dist/gsd-worktree-path-guard.js +132 -8
  213. package/hooks/dist/lib/cursor-workspace.js +74 -0
  214. package/hooks/gsd-context-monitor.js +27 -9
  215. package/hooks/gsd-cursor-session-start.js +6 -2
  216. package/hooks/gsd-cursor-stop.js +6 -2
  217. package/hooks/gsd-cursor-subagent-start.js +6 -2
  218. package/hooks/gsd-graphify-update.sh +9 -0
  219. package/hooks/gsd-phase-boundary.sh +14 -2
  220. package/hooks/gsd-prompt-guard.js +101 -2
  221. package/hooks/gsd-read-guard.js +100 -2
  222. package/hooks/gsd-read-injection-scanner.js +109 -2
  223. package/hooks/gsd-statusline.js +97 -9
  224. package/hooks/gsd-workflow-guard.js +110 -6
  225. package/hooks/gsd-worktree-path-guard.js +132 -8
  226. package/hooks/lib/cursor-workspace.js +74 -0
  227. package/package.json +10 -8
  228. package/pi/gsd.cjs +34 -3
  229. package/scripts/changeset/lint.cjs +1 -0
  230. package/scripts/changeset/parse.cjs +26 -0
  231. package/scripts/check-coverage-gate.cjs +51 -0
  232. package/scripts/check-glossary-refs.cjs +244 -0
  233. package/scripts/ci-rebase-check.cjs +48 -4
  234. package/scripts/ci-test-scope.cjs +67 -17
  235. package/scripts/gen-adr-index.cjs +528 -0
  236. package/scripts/gen-capability-matrix.cjs +26 -2
  237. package/scripts/gen-capability-registry.cjs +132 -34
  238. package/scripts/gen-emitted-baseline.cjs +145 -0
  239. package/scripts/gen-test-timings.cjs +201 -0
  240. package/scripts/lint-compiled-artifact-sync.cjs +146 -0
  241. package/scripts/lint-emitted-drift-ack.cjs +149 -0
  242. package/scripts/lint-fix-has-regression-test.cjs +131 -0
  243. package/scripts/lint-portable-timeout.cjs +140 -0
  244. package/scripts/lint-resolution-provenance.cjs +9 -0
  245. package/scripts/lint-test-file-count.allowlist.json +1 -0
  246. package/scripts/mutation-matrix.cjs +4 -0
  247. package/scripts/prompt-injection-scan.sh +6 -0
  248. package/scripts/registry-schema.cjs +57 -8
  249. package/scripts/release-notes/conventional-title.cjs +19 -1
  250. package/scripts/release-notes/format-github-release-notes.cjs +7 -3
  251. package/scripts/release-tarball-smoke.cjs +18 -11
  252. package/scripts/run-tests.cjs +420 -58
  253. package/scripts/workflow-size.cjs +16 -8
  254. package/skills/gsd-ai-integration-phase/SKILL.md +1 -1
  255. package/skills/gsd-mempalace-capture/SKILL.md +9 -5
  256. package/skills/gsd-new-milestone/SKILL.md +1 -1
  257. package/skills/gsd-plan-phase/SKILL.md +5 -3
  258. package/skills/gsd-plan-review-convergence/SKILL.md +7 -2
  259. package/vscode/package.json +1 -1
  260. package/scripts/gen-golden-install-parity-zcode.cjs +0 -77
  261. package/scripts/update-size-baseline.cjs +0 -68
@@ -24,7 +24,7 @@ const coreUtilsMod = require("./core-utils.cjs");
24
24
  const { toPosixPath, generateSlugInternal, extractOneLinerFromBody } = coreUtilsMod;
25
25
  // eslint-disable-next-line @typescript-eslint/no-require-imports
26
26
  const phaseIdMod = require("./phase-id.cjs");
27
- const { normalizePhaseName, comparePhaseNum, extractPhaseToken } = phaseIdMod;
27
+ const { normalizePhaseName, comparePhaseNum, extractPhaseToken, PHASE_NUMBER_TOKEN_SOURCE } = phaseIdMod;
28
28
  // eslint-disable-next-line @typescript-eslint/no-require-imports
29
29
  const phaseLocatorMod = require("./phase-locator.cjs");
30
30
  const { getArchivedPhaseDirs, findPhaseInternal } = phaseLocatorMod;
@@ -33,9 +33,14 @@ const roadmapParserMod = require("./roadmap-parser.cjs");
33
33
  const { extractCurrentMilestone, stripShippedMilestones: _stripShippedMilestones, getMilestoneInfo, getMilestonePhaseFilter, getRoadmapPhaseInternal } = roadmapParserMod;
34
34
  // eslint-disable-next-line @typescript-eslint/no-require-imports
35
35
  const modelResolverMod = require("./model-resolver.cjs");
36
- const { resolveModelInternal, resolveEffortInternal, resolveFastModeInternal, resolveEffortForTier, resolveGranularityInternal, assertValidGranularityOverride } = modelResolverMod;
36
+ const { resolveModelInternal, resolveModelForTier, resolveProviderEscalation, resolveEffortInternal, resolveFastModeInternal, resolveEffortForTier, resolveGranularityInternal, assertValidGranularityOverride } = modelResolverMod;
37
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
38
+ const agentCommandRouterMod = require("./agent-command-router.cjs");
39
+ const { AGENT_FAILURE_CLASSES } = agentCommandRouterMod;
37
40
  const model_catalog_cjs_1 = require("./model-catalog.cjs");
38
41
  // eslint-disable-next-line @typescript-eslint/no-require-imports
42
+ const hostIntegrationMod = require("./host-integration.cjs");
43
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
39
44
  const planningWorkspace = require("./planning-workspace.cjs");
40
45
  const { planningDir, planningPaths } = planningWorkspace;
41
46
  // eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -47,6 +52,44 @@ const { MODEL_PROFILES, VALID_PHASE_TYPES } = modelProfiles;
47
52
  const runtime_slash_cjs_1 = require("./runtime-slash.cjs");
48
53
  const clock_cjs_1 = require("./clock.cjs");
49
54
  // ─── Phase Status ─────────────────────────────────────────────────────────────
55
+ /**
56
+ * Phase-status precedence ladder — furthest-along wins (#2408).
57
+ *
58
+ * `cmdStats` builds `phasesByNumber` by scanning on-disk phase directories.
59
+ * When two directories normalize to the same phase key (e.g. `05-real/` and
60
+ * `05-real-stray/`), the status field must be folded by precedence rather
61
+ * than overwritten last-write-wins — otherwise `/gsd-stats` reports whatever
62
+ * directory `fs.readdirSync` happened to yield last, which is non-deterministic
63
+ * across platforms and can silently call a `Complete` phase `Not Started`.
64
+ */
65
+ const PHASE_STATUS_PRECEDENCE = [
66
+ 'Complete',
67
+ 'Needs Review',
68
+ 'Executed',
69
+ 'In Progress',
70
+ 'Planned',
71
+ 'Not Started',
72
+ 'Pending',
73
+ ];
74
+ const PHASE_STATUS_RANK = new Map(PHASE_STATUS_PRECEDENCE.map((s, i) => [s, i]));
75
+ /**
76
+ * Fold two phase statuses by precedence — returns whichever is further along
77
+ * the {@link PHASE_STATUS_PRECEDENCE} ladder. Unrecognized statuses fall behind
78
+ * every recognized one (so a recognized status always wins over an unknown one;
79
+ * two unrecognized statuses favor `a` for determinism).
80
+ */
81
+ function foldPhaseStatus(a, b) {
82
+ const ra = PHASE_STATUS_RANK.get(a);
83
+ const rb = PHASE_STATUS_RANK.get(b);
84
+ if (ra === undefined && rb === undefined)
85
+ return a;
86
+ if (ra === undefined)
87
+ return b;
88
+ if (rb === undefined)
89
+ return a;
90
+ // Lower rank = higher precedence (Complete=0 wins over Not Started=5).
91
+ return ra <= rb ? a : b;
92
+ }
50
93
  /**
51
94
  * Determine phase status by checking plan/summary counts AND verification state.
52
95
  * Introduces "Executed" for phases with all summaries but no passing verification.
@@ -63,12 +106,13 @@ function determinePhaseStatus(plans, summaries, phaseDir, defaultPending) {
63
106
  const files = node_fs_1.default.readdirSync(phaseDir);
64
107
  const verificationFile = files.find(f => f === 'VERIFICATION.md' || f.endsWith('-VERIFICATION.md'));
65
108
  if (verificationFile) {
66
- const content = (0, shell_command_projection_cjs_1.platformReadSync)(node_path_1.default.join(phaseDir, verificationFile)) || '';
109
+ const verificationFilePath = node_path_1.default.join(phaseDir, verificationFile);
110
+ const content = (0, shell_command_projection_cjs_1.platformReadSync)(verificationFilePath) || '';
67
111
  // #1159 (Defect A): read ONLY the frontmatter `status` key to avoid false
68
112
  // matches from historical body metadata such as `previous_status: gaps_found`.
69
113
  // Full-text regexes like /status:\s*gaps_found/ match the substring inside
70
114
  // `previous_status: gaps_found`, producing incorrect phase status labels.
71
- const fm = extractFrontmatter(content);
115
+ const fm = extractFrontmatter(content, verificationFilePath);
72
116
  // Normalise to lower-case to preserve the prior case-insensitive behaviour
73
117
  // while reading only the frontmatter `status` key (not the full body text).
74
118
  const fmStatus = typeof fm['status'] === 'string' ? fm['status'].trim().toLowerCase() : '';
@@ -128,6 +172,9 @@ function cmdListTodos(cwd, area, raw) {
128
172
  const createdMatch = content.match(/^created:\s*(.+)$/m);
129
173
  const titleMatch = content.match(/^title:\s*(.+)$/m);
130
174
  const areaMatch = content.match(/^area:\s*(.+)$/m);
175
+ // #2337: surface severity when present. Omit the key entirely for todos
176
+ // with no severity line so existing consumers of this JSON are unaffected.
177
+ const severityMatch = content.match(/^severity:\s*(.+)$/m);
131
178
  const todoArea = areaMatch ? areaMatch[1].trim() : 'general';
132
179
  // Apply area filter if specified
133
180
  if (area && todoArea !== area)
@@ -139,6 +186,7 @@ function cmdListTodos(cwd, area, raw) {
139
186
  title: titleMatch ? titleMatch[1].trim() : 'Untitled',
140
187
  area: todoArea,
141
188
  path: toPosixPath(node_path_1.default.relative(cwd, node_path_1.default.join(pendingDir, file))),
189
+ ...(severityMatch ? { severity: severityMatch[1].trim() } : {}),
142
190
  });
143
191
  }
144
192
  }
@@ -216,7 +264,7 @@ function cmdListSeeds(cwd, statusFilter, raw) {
216
264
  const content = (0, shell_command_projection_cjs_1.platformReadSync)(safeFilePath);
217
265
  if (content === null)
218
266
  continue;
219
- const fm = extractFrontmatter(content);
267
+ const fm = extractFrontmatter(content, safeFilePath);
220
268
  const status = (fmStr(fm.status) || 'dormant').toLowerCase().trim() || 'dormant';
221
269
  // Match on the raw lowercased status (both sides already normalized);
222
270
  // sanitizeForDisplay is for output, not comparison.
@@ -302,11 +350,12 @@ function cmdHistoryDigest(cwd, raw) {
302
350
  for (const { name: dir, fullPath: dirPath } of allPhaseDirs) {
303
351
  const summaries = node_fs_1.default.readdirSync(dirPath).filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
304
352
  for (const summary of summaries) {
305
- const content = (0, shell_command_projection_cjs_1.platformReadSync)(node_path_1.default.join(dirPath, summary));
353
+ const summaryFilePath = node_path_1.default.join(dirPath, summary);
354
+ const content = (0, shell_command_projection_cjs_1.platformReadSync)(summaryFilePath);
306
355
  if (content === null)
307
356
  continue;
308
357
  try {
309
- const fm = extractFrontmatter(content);
358
+ const fm = extractFrontmatter(content, summaryFilePath);
310
359
  const phaseNum = fm['phase'] || dir.split('-')[0];
311
360
  if (!digest.phases[phaseNum]) {
312
361
  digest.phases[phaseNum] = {
@@ -394,7 +443,8 @@ function cmdResolveGranularity(cwd, phaseType, raw, override) {
394
443
  * { model, profile, effort, effort_rendered, effort_param, effort_propagation,
395
444
  * fast_mode, fast_mode_supported, [unknown_agent] }
396
445
  *
397
- * Flags: --effort <level>, --fast-mode <true|false>, --attempt <n>
446
+ * Flags: --effort <level>, --fast-mode <true|false>, --attempt <n>,
447
+ * --failure-class <class> (#2296), --host <runtime-id> (#2481)
398
448
  */
399
449
  function cmdResolveExecution(cwd, agentType, raw, opts) {
400
450
  if (!agentType) {
@@ -403,7 +453,29 @@ function cmdResolveExecution(cwd, agentType, raw, opts) {
403
453
  opts = opts || {};
404
454
  const config = loadConfig(cwd);
405
455
  const profile = config['model_profile'] || 'balanced';
406
- const model = resolveModelInternal(cwd, agentType);
456
+ // #2068: resolve the model per-attempt so dynamic_routing escalates the MODEL
457
+ // (heavy tier) alongside effort. Gated on an explicit --attempt exactly like the
458
+ // effort resolution below, so the two fields stay symmetric: with no --attempt
459
+ // the model comes from the classic profile path (unchanged for everyone,
460
+ // including dynamic_routing-enabled users who don't pass --attempt), and only an
461
+ // explicit attempt routes through the tier ladder. resolveModelForTier itself
462
+ // still falls back to resolveModelInternal when dynamic_routing is off.
463
+ let model = (opts.attempt !== undefined && opts.attempt !== null)
464
+ ? resolveModelForTier(cwd, agentType, opts.attempt)
465
+ : resolveModelInternal(cwd, agentType);
466
+ // #2296: when the caller reports WHY the previous attempt failed, consult the
467
+ // provider-escalation ladder. Only a quota/rate-limit class warrants it — a
468
+ // heavier tier on the same throttled provider is still throttled, so this
469
+ // ladder swaps providers instead. Gated on an explicit --failure-class so the
470
+ // JSON contract is byte-identical for every existing caller.
471
+ let escalation;
472
+ if (opts.failureClass !== undefined) {
473
+ const applicable = opts.failureClass === AGENT_FAILURE_CLASSES.QUOTA_EXCEEDED;
474
+ const resolved = resolveProviderEscalation(cwd, agentType, opts.attempt, applicable);
475
+ if (resolved.escalated)
476
+ model = resolved.to;
477
+ escalation = { class: opts.failureClass, ...resolved };
478
+ }
407
479
  const effortOpts = {};
408
480
  if (typeof opts.effortOverride === 'string')
409
481
  effortOpts['override'] = opts.effortOverride;
@@ -428,10 +500,54 @@ function cmdResolveExecution(cwd, agentType, raw, opts) {
428
500
  fast_mode: fastMode,
429
501
  fast_mode_supported: fastModeSupported,
430
502
  };
503
+ // ADR-1239 amendment (#2481) / ADR-443 path (a): invocation-time effort for a
504
+ // named host. The host's negotiated `effortSurface` decides WHETHER an argument
505
+ // is emitted; the catalog knows the syntax. Absent --host the contract is
506
+ // byte-identical to before, so every existing caller is unaffected.
507
+ if (typeof opts.host === 'string' && opts.host.length > 0) {
508
+ const surface = effortSurfaceForHost(cwd, opts.host);
509
+ const argvRendered = (0, model_catalog_cjs_1.renderEffortArgv)(opts.host, effort, surface);
510
+ result['host'] = opts.host;
511
+ result['effort_surface'] = surface;
512
+ result['effort_argv'] = argvRendered.argv;
513
+ result['effort_argv_string'] = argvRendered.argv.join(' ');
514
+ result['effort_argv_value'] = argvRendered.value;
515
+ }
431
516
  if (!agentModels)
432
517
  result['unknown_agent'] = true;
518
+ if (escalation)
519
+ result['escalation'] = escalation;
433
520
  output(result, raw, effort);
434
521
  }
522
+ /**
523
+ * ADR-1239 amendment (#2481) — resolve a host's negotiated `effortSurface`.
524
+ *
525
+ * Reads the host's runtime descriptor from the generated capability registry and
526
+ * runs it through the Host-Integration negotiation so the trust-boundary invariant
527
+ * applies here exactly as everywhere else: an unknown host, a missing axis, or the
528
+ * `undocumented` sentinel all degrade to the safe floor rather than being trusted.
529
+ * Never throws — a lookup failure yields `'none'`, which renders no argument.
530
+ */
531
+ function effortSurfaceForHost(cwd, host) {
532
+ void cwd;
533
+ try {
534
+ // Mirrors the lazy-require pattern from runtime-slash.cts §runtimeSlash —
535
+ // capability-registry.cjs is generated and carries no type declarations.
536
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
537
+ const { runtimes } = require('./capability-registry.cjs');
538
+ const declared = runtimes[host]?.runtime?.hostIntegration;
539
+ if (!declared || typeof declared !== 'object')
540
+ return 'none';
541
+ // The descriptor is untrusted JSON; negotiation applies the trust-boundary
542
+ // invariant (effective ⊆ host-declared ∩ engine-known) and fails closed.
543
+ const negotiated = hostIntegrationMod.negotiateHostCapabilities(declared);
544
+ const surface = negotiated?.effective?.effortSurface;
545
+ return typeof surface === 'string' ? surface : 'none';
546
+ }
547
+ catch {
548
+ return 'none';
549
+ }
550
+ }
435
551
  /**
436
552
  * #488 — Replace or inject the `effort:` value in YAML frontmatter.
437
553
  * Unlike injectEffortFrontmatter (install.js), this overwrites an existing value.
@@ -524,6 +640,59 @@ function cmdEffortSync(cwd, raw, opts) {
524
640
  }
525
641
  output({ synced, skipped, changes, dry_run: dryRun, agents_dir: agentsDir }, raw, synced > 0 ? 'changed' : 'ok');
526
642
  }
643
+ /**
644
+ * Detect the phase number for a commit from its `--files` path list.
645
+ *
646
+ * #2539: the extraction is anchored to the directory segment immediately under
647
+ * `.planning/phases/` or `.planning/milestones/<version>-phases/`, then run
648
+ * through the project-code-aware `extractPhaseToken` helper. The prior
649
+ * unanchored `match(/(\d+(?:\.\d+)*)-/)` returned the leftmost digit-run-then-
650
+ * hyphen anywhere in the joined path, so a project_code ending in a digit
651
+ * (e.g. PROJECT_V2) made `…/PROJECT_V2-07-name/…` match the `2-` inside `V2-`
652
+ * before the real `07-` phase token — resolving phase "2" instead of "7".
653
+ *
654
+ * Returns the phase number string (e.g. '07', '45.14'), or null when no phase
655
+ * directory segment is present in any of the file paths (e.g. a commit of
656
+ * `.planning/ROADMAP.md` has no phase segment, so no branch is resolved —
657
+ * matching the prior regex-no-match behaviour).
658
+ */
659
+ function detectPhaseNumberFromFiles(files) {
660
+ if (!files || files.length === 0)
661
+ return null;
662
+ // A phase directory lives one segment below a `phases` parent segment:
663
+ // .planning/phases/<phase-dir>/…
664
+ // .planning/milestones/v1.0-phases/<phase-dir>/…
665
+ // The segment immediately after the `…phases` segment is the phase directory
666
+ // name. extractPhaseToken owns the project-code-aware token read.
667
+ for (const file of files) {
668
+ const norm = String(file).replace(/\\/g, '/').replace(/^\.\//, '');
669
+ const segments = norm.split('/');
670
+ for (let i = 0; i < segments.length - 1; i++) {
671
+ if (segments[i] === 'phases' || segments[i].endsWith('-phases')) {
672
+ const phaseDir = segments[i + 1];
673
+ if (!phaseDir)
674
+ continue;
675
+ const token = extractPhaseToken(phaseDir);
676
+ // extractPhaseToken falls back to returning dirName unchanged when no
677
+ // numeric token is found. normalizePhaseName is the canonical arbiter
678
+ // of "is this a real phase token": it strips the project-code prefix
679
+ // and returns a zero-padded numeric form for a genuine phase token, or
680
+ // the input unchanged otherwise. Accept the token only when it
681
+ // normalizes to a numeric phase form (the single-owner rule shared by
682
+ // every other phase-token reader — see #2528).
683
+ const normalized = normalizePhaseName(token);
684
+ // Built from the single-owner PHASE_NUMBER_TOKEN_SOURCE (the canonical
685
+ // phase-number grammar — #2128 anti-divergence guard) so this read-side
686
+ // acceptance check cannot drift from every other phase-token reader.
687
+ const phaseTokenShape = new RegExp(`^${PHASE_NUMBER_TOKEN_SOURCE}$`, 'i');
688
+ if (token !== phaseDir && phaseTokenShape.test(normalized)) {
689
+ return token;
690
+ }
691
+ }
692
+ }
693
+ }
694
+ return null;
695
+ }
527
696
  function cmdCommit(cwd, message, files, raw, amend, noVerify) {
528
697
  if (!message && !amend) {
529
698
  error('commit message required');
@@ -559,10 +728,21 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
559
728
  if (branchingStrategy && branchingStrategy !== 'none') {
560
729
  let branchName = null;
561
730
  if (branchingStrategy === 'phase') {
562
- // Determine which phase we're committing for from the file paths
563
- const phaseMatch = (files || []).join(' ').match(/(\d+(?:\.\d+)*)-/);
564
- if (phaseMatch) {
565
- const phaseNum = phaseMatch[1];
731
+ // Determine which phase we're committing for from the file paths.
732
+ // #2539: the extraction is anchored to the directory SEGMENT immediately
733
+ // under `.planning/phases/` (or `.planning/milestones/<v>-phases/`) and
734
+ // runs through the project-code-aware extractPhaseToken helper, NOT a
735
+ // free unanchored regex. The prior `match(/(\d+(?:\.\d+)*)-/)` returned
736
+ // the leftmost digit-run-then-hyphen anywhere in the joined path, so a
737
+ // project_code ending in a digit (PROJECT_V2) made `.../PROJECT_V2-07-…`
738
+ // match the `2-` inside `V2-` before the real `07-` phase token —
739
+ // resolving phase "2" instead of phase "7" and silently checking out the
740
+ // wrong branch. extractPhaseToken already owns project-code-aware phase-
741
+ // token parsing (it is the single owner shared by the other 6 call sites
742
+ // — see #2528 for the parallel drift problem in phase-locator/phase),
743
+ // so this is the canonical path-segment-bound read, not a fourth copy.
744
+ const phaseNum = detectPhaseNumberFromFiles(files);
745
+ if (phaseNum) {
566
746
  const phaseInfo = findPhaseInternal(cwd, phaseNum);
567
747
  if (phaseInfo) {
568
748
  branchName = config['phase_branch_template']
@@ -582,10 +762,23 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
582
762
  if (branchName) {
583
763
  const currentBranch = (0, shell_command_projection_cjs_1.execGit)(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd });
584
764
  if (currentBranch.exitCode === 0 && currentBranch.stdout.trim() !== branchName) {
585
- // Create branch if it doesn't exist, or switch to it if it does
765
+ // #2539: the #1278 intent is to CREATE the phase/milestone branch
766
+ // before the FIRST commit on it — not to force-switch an already-
767
+ // checked-out working branch onto a DIFFERENT existing branch. The
768
+ // prior fallback to a bare `git checkout <branch>` silently switched
769
+ // the whole working tree onto an existing unrelated branch in the same
770
+ // call that then committed (the only trace was a reflog entry). So:
771
+ // create-if-absent only. If the resolved branch already exists and the
772
+ // tree is on some other branch, do NOT switch — but never silently: log
773
+ // the resolution so the operator sees that the phase branch was
774
+ // resolved and deliberately not switched to (#2539 AC2: an auto-
775
+ // checkout mid-commit must never happen silently).
586
776
  const create = (0, shell_command_projection_cjs_1.execGit)(['checkout', '-b', branchName], { cwd });
587
777
  if (create.exitCode !== 0) {
588
- (0, shell_command_projection_cjs_1.execGit)(['checkout', branchName], { cwd });
778
+ // `git checkout -b` fails (non-zero) when the branch already exists.
779
+ // The operator is on the branch they intend to be on; commit there.
780
+ process.stderr.write(`Warning: resolved ${branchingStrategy} branch "${branchName}" already exists; ` +
781
+ `committing on the current branch "${currentBranch.stdout.trim()}" instead of switching.\n`);
589
782
  }
590
783
  }
591
784
  }
@@ -594,8 +787,21 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
594
787
  const explicitFiles = files && files.length > 0;
595
788
  const filesToStage = explicitFiles ? files : ['.planning/'];
596
789
  const stagedPaths = [];
790
+ // #2608: a `git add` that fails must abort the commit, not be skipped.
791
+ // #2523 stopped a failed path entering the commit pathspec, but skipping it
792
+ // silently left two bad outcomes: a PARTIAL commit when only some requested
793
+ // paths failed, and a misleading `nothing_to_commit` when all of them did —
794
+ // in both cases the original staging error (permissions, unwritable index in
795
+ // a linked worktree, timeout) was discarded and the operator saw a downstream
796
+ // pathspec error pointing at an innocent file.
797
+ const stagingFailures = [];
798
+ // Paths already in the index BEFORE this call. On a staging failure the
799
+ // rollback below unstages only what THIS call added — unstaging a path the
800
+ // caller had staged themselves would destroy their work.
801
+ const preStaged = new Set((0, shell_command_projection_cjs_1.execGit)(['diff', '--cached', '--name-only'], { cwd })
802
+ .stdout.split('\n').map(s => s.trim()).filter(Boolean));
597
803
  for (const file of filesToStage) {
598
- const fullPath = node_path_1.default.join(cwd, file);
804
+ const fullPath = node_path_1.default.resolve(cwd, file);
599
805
  if (!node_fs_1.default.existsSync(fullPath)) {
600
806
  if (explicitFiles) {
601
807
  // Caller passed an explicit --files list: missing files are skipped.
@@ -605,12 +811,73 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
605
811
  }
606
812
  // Default mode (staging all of .planning/): stage the deletion so
607
813
  // removed planning files are not left dangling in the index.
608
- (0, shell_command_projection_cjs_1.execGit)(['rm', '--cached', '--ignore-unmatch', file], { cwd });
814
+ // This mutates the index exactly like `git add` does, so it fails closed
815
+ // the same way — an unwritable index must not be swallowed here either.
816
+ // `--ignore-unmatch` already makes "no such path" a success, so a non-zero
817
+ // exit is a real I/O failure, not a missing file.
818
+ const rmResult = (0, shell_command_projection_cjs_1.execGit)(['rm', '--cached', '--ignore-unmatch', file], { cwd });
819
+ if (rmResult.exitCode !== 0) {
820
+ const rmErr = rmResult.error;
821
+ stagingFailures.push({
822
+ file,
823
+ error: rmResult.stderr || rmResult.stdout,
824
+ timed_out: rmResult.signal === 'SIGTERM' && rmErr?.code === 'ETIMEDOUT',
825
+ });
826
+ }
609
827
  }
610
828
  else {
611
- (0, shell_command_projection_cjs_1.execGit)(['add', file], { cwd });
612
- stagedPaths.push(file);
829
+ const addResult = (0, shell_command_projection_cjs_1.execGit)(['add', file], { cwd });
830
+ // Only record paths that actually staged — a failed `git add` (permissions,
831
+ // out-of-repo edge) must not enter the commit pathspec (#2523). Mirrors
832
+ // cmdCommitToSubrepo's exitCode-gated push.
833
+ if (addResult.exitCode === 0) {
834
+ stagedPaths.push(file);
835
+ }
836
+ else {
837
+ // `SpawnResultOutput.error` is typed `Error | null`; widen to the errno
838
+ // shape by ANNOTATION rather than assertion — `Error` is assignable to
839
+ // `NodeJS.ErrnoException` (its extra fields are optional), so an `as`
840
+ // cast here trips no-unnecessary-type-assertion.
841
+ const addErr = addResult.error;
842
+ stagingFailures.push({
843
+ file,
844
+ error: addResult.stderr || addResult.stdout,
845
+ // The projection exposes a timeout distinctly (#2608 AC5); this is the
846
+ // same SIGTERM+ETIMEDOUT idiom worktree-safety.cts uses.
847
+ timed_out: addResult.signal === 'SIGTERM' && addErr?.code === 'ETIMEDOUT',
848
+ });
849
+ }
850
+ }
851
+ }
852
+ // #2608: fail closed before `git commit` runs. Checked ahead of the
853
+ // nothing_to_commit branch below so a run where EVERY path failed to stage
854
+ // reports the staging cause rather than "nothing to commit", and ahead of the
855
+ // commit itself so a multi-file scope never partially commits the subset that
856
+ // happened to stage.
857
+ if (stagingFailures.length > 0) {
858
+ // Fail closed AND clean. Without this the paths that DID stage stay in the
859
+ // index with no commit made, so the next bare `git commit` sweeps them up —
860
+ // the same silent partial commit this fix exists to prevent, deferred one
861
+ // step. Mirrors cmdPrSubrepo's rollback-then-error convention. Only paths
862
+ // this call staged are unstaged (preStaged is excluded), and the reset is
863
+ // best-effort: if the index is unwritable — the very failure being reported
864
+ // — the reset cannot succeed either, and the staging error is still what
865
+ // gets returned.
866
+ const toUnstage = stagedPaths.filter(p => !preStaged.has(p));
867
+ if (toUnstage.length > 0) {
868
+ (0, shell_command_projection_cjs_1.execGit)(['reset', '-q', '--', ...toUnstage], { cwd });
613
869
  }
870
+ const first = stagingFailures[0];
871
+ const result = {
872
+ committed: false,
873
+ hash: null,
874
+ reason: first.timed_out ? 'staging_timeout' : 'staging_failed',
875
+ file: first.file,
876
+ error: first.error,
877
+ failures: stagingFailures,
878
+ };
879
+ output(result, raw, 'failed');
880
+ return;
614
881
  }
615
882
  // Commit — when the caller declared a scope (--files), append a pathspec so
616
883
  // only the declared files land in the commit, not the entire index (#2112).
@@ -732,13 +999,44 @@ function cmdCommitToSubrepo(cwd, message, files, raw) {
732
999
  for (const [repo, repoFiles] of Object.entries(grouped)) {
733
1000
  const repoCwd = node_path_1.default.join(cwd, repo);
734
1001
  // Stage files (strip sub-repo prefix for paths relative to that repo)
1002
+ // #2608: this is the sub-repo twin of cmdCommit's staging loop and carried
1003
+ // the identical defect — a failed `git add` was dropped silently and the
1004
+ // function went straight on to commit the subset that happened to stage,
1005
+ // discarding git's stderr. Fails closed per-repo, with the same rollback of
1006
+ // only what this call staged.
1007
+ const preStagedSub = new Set((0, shell_command_projection_cjs_1.execGit)(['diff', '--cached', '--name-only'], { cwd: repoCwd })
1008
+ .stdout.split('\n').map(s => s.trim()).filter(Boolean));
735
1009
  const stagedRelPaths = [];
1010
+ const subStagingFailures = [];
736
1011
  for (const file of repoFiles) {
737
1012
  const relativePath = file.slice(repo.length + 1);
738
1013
  const addResult = (0, shell_command_projection_cjs_1.execGit)(['add', relativePath], { cwd: repoCwd });
739
1014
  if (addResult.exitCode === 0) {
740
1015
  stagedRelPaths.push(relativePath);
741
1016
  }
1017
+ else {
1018
+ const addErr = addResult.error;
1019
+ subStagingFailures.push({
1020
+ file,
1021
+ error: addResult.stderr || addResult.stdout,
1022
+ timed_out: addResult.signal === 'SIGTERM' && addErr?.code === 'ETIMEDOUT',
1023
+ });
1024
+ }
1025
+ }
1026
+ if (subStagingFailures.length > 0) {
1027
+ const toUnstageSub = stagedRelPaths.filter(p => !preStagedSub.has(p));
1028
+ if (toUnstageSub.length > 0) {
1029
+ (0, shell_command_projection_cjs_1.execGit)(['reset', '-q', '--', ...toUnstageSub], { cwd: repoCwd });
1030
+ }
1031
+ const firstSub = subStagingFailures[0];
1032
+ repos[repo] = {
1033
+ committed: false,
1034
+ hash: null,
1035
+ files: repoFiles,
1036
+ reason: firstSub.timed_out ? 'staging_timeout' : 'staging_failed',
1037
+ error: firstSub.error,
1038
+ };
1039
+ continue;
742
1040
  }
743
1041
  // Commit — pathspec limits the commit to the staged files only (#2112)
744
1042
  const isMergeInProgressSub = (0, shell_command_projection_cjs_1.execGit)(['rev-parse', '-q', '--verify', 'MERGE_HEAD'], { cwd: repoCwd }).exitCode === 0;
@@ -924,7 +1222,7 @@ function cmdSummaryExtract(cwd, summaryPath, fields, raw) {
924
1222
  return;
925
1223
  }
926
1224
  const content = node_fs_1.default.readFileSync(fullPath, 'utf-8');
927
- const fm = extractFrontmatter(content);
1225
+ const fm = extractFrontmatter(content, fullPath);
928
1226
  // Parse key-decisions into structured format
929
1227
  const parseDecisions = (decisionsList) => {
930
1228
  if (!decisionsList || !Array.isArray(decisionsList))
@@ -1385,7 +1683,12 @@ function cmdStats(cwd, format, raw) {
1385
1683
  name: existing?.name || phaseName,
1386
1684
  plans: (existing?.plans || 0) + plans,
1387
1685
  summaries: (existing?.summaries || 0) + summaries,
1388
- status,
1686
+ // #2408: fold colliding statuses by precedence rather than overwriting
1687
+ // last-write-wins. fs.readdirSync order is non-deterministic across
1688
+ // platforms, so a naive overwrite can report a Complete phase as Not
1689
+ // Started (or vice versa) depending on read order. The fold picks the
1690
+ // furthest-along status, matching what an operator expects.
1691
+ status: existing ? foldPhaseStatus(existing.status, status) : status,
1389
1692
  });
1390
1693
  }
1391
1694
  }
@@ -1507,6 +1810,8 @@ function cmdCheckCommit(cwd, raw) {
1507
1810
  module.exports = {
1508
1811
  groupFilesBySubrepo,
1509
1812
  determinePhaseStatus,
1813
+ foldPhaseStatus,
1814
+ PHASE_STATUS_PRECEDENCE,
1510
1815
  cmdGenerateSlug,
1511
1816
  cmdCurrentTimestamp,
1512
1817
  cmdListTodos,