@pareto4/pp-core 1.11.1 → 1.12.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 (255) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +31 -0
  3. package/agents/pp-code-fixer.md +1 -1
  4. package/agents/pp-debug-session-manager.md +1 -1
  5. package/agents/pp-debugger.md +1 -1
  6. package/agents/pp-executor.md +20 -10
  7. package/agents/pp-intel-updater.md +1 -1
  8. package/agents/pp-phase-researcher.md +1 -1
  9. package/agents/pp-plan-checker.md +5 -5
  10. package/agents/pp-planner.md +10 -24
  11. package/agents/pp-project-researcher.md +1 -1
  12. package/agents/pp-research-synthesizer.md +1 -1
  13. package/agents/pp-ui-researcher.md +1 -1
  14. package/agents/pp-verifier.md +8 -4
  15. package/bin/install.js +429 -82
  16. package/commands/pp-autonomous.md +8 -6
  17. package/commands/pp-discovery-to-build.md +1 -1
  18. package/commands/pp-discovery.md +3 -2
  19. package/commands/pp-discuss-phase.md +2 -2
  20. package/commands/pp-execute-phase.md +3 -1
  21. package/commands/pp-graphify.md +5 -5
  22. package/commands/pp-import.md +1 -1
  23. package/commands/pp-map-codebase.md +1 -1
  24. package/commands/pp-ns-project.md +2 -3
  25. package/commands/pp-ns-workflow.md +1 -1
  26. package/commands/pp-plan-phase.md +2 -1
  27. package/commands/pp-verify-work.md +2 -1
  28. package/gemini-extension.json +1 -1
  29. package/hooks/dist/pp-cursor-session-start.js +2 -2
  30. package/hooks/dist/pp-session-state.sh +1 -1
  31. package/hooks/dist/pp-statusline.js +21 -2
  32. package/hooks/pp-cursor-session-start.js +2 -2
  33. package/hooks/pp-session-state.sh +1 -1
  34. package/hooks/pp-statusline.js +21 -2
  35. package/package.json +18 -5
  36. package/pareto-skills/pp-project-setup/SKILL.md +5 -63
  37. package/pp-core/bin/detect-build-commands.sh +182 -0
  38. package/pp-core/bin/lib/capability-registry.cjs +65 -7
  39. package/pp-core/bin/lib/check-command-router.cjs +79 -38
  40. package/pp-core/bin/lib/clusters.cjs +2 -3
  41. package/pp-core/bin/lib/command-aliases.cjs +36 -0
  42. package/pp-core/bin/lib/command-arg-projection.cjs +99 -1
  43. package/pp-core/bin/lib/commands.cjs +21 -6
  44. package/pp-core/bin/lib/compose-bindings.cjs +70 -6
  45. package/pp-core/bin/lib/config-schema.cjs +16 -0
  46. package/pp-core/bin/lib/config.cjs +142 -27
  47. package/pp-core/bin/lib/core-utils.cjs +43 -7
  48. package/pp-core/bin/lib/discovery-input-check.cjs +218 -0
  49. package/pp-core/bin/lib/discovery-rebaseline.cjs +1372 -0
  50. package/pp-core/bin/lib/discovery-research-track.cjs +979 -0
  51. package/pp-core/bin/lib/discovery-rollup.cjs +476 -0
  52. package/pp-core/bin/lib/discovery-screens.cjs +1995 -0
  53. package/pp-core/bin/lib/discovery-sweep.cjs +390 -0
  54. package/pp-core/bin/lib/discovery-table.cjs +98 -0
  55. package/pp-core/bin/lib/dispatch-plan.cjs +472 -0
  56. package/pp-core/bin/lib/frontmatter.cjs +17 -2
  57. package/pp-core/bin/lib/init-command-router.cjs +8 -5
  58. package/pp-core/bin/lib/init-contract.cjs +93 -4
  59. package/pp-core/bin/lib/init.cjs +124 -134
  60. package/pp-core/bin/lib/install-profiles.cjs +5 -0
  61. package/pp-core/bin/lib/install-run.cjs +536 -0
  62. package/pp-core/bin/lib/io.cjs +15 -0
  63. package/pp-core/bin/lib/loop-resolver.cjs +24 -0
  64. package/pp-core/bin/lib/milestone.cjs +345 -48
  65. package/pp-core/bin/lib/phase-command-router.cjs +23 -1
  66. package/pp-core/bin/lib/phase-locator.cjs +15 -6
  67. package/pp-core/bin/lib/phase-scope.cjs +91 -0
  68. package/pp-core/bin/lib/phase-tier.cjs +467 -0
  69. package/pp-core/bin/lib/phase.cjs +247 -21
  70. package/pp-core/bin/lib/phases-command-router.cjs +2 -0
  71. package/pp-core/bin/lib/plan-graph.cjs +368 -0
  72. package/pp-core/bin/lib/planning-workspace.cjs +53 -0
  73. package/pp-core/bin/lib/platform-bindings.cjs +32 -2
  74. package/pp-core/bin/lib/profile-output.cjs +11 -3
  75. package/pp-core/bin/lib/prohibition-enforcement.cjs +112 -0
  76. package/pp-core/bin/lib/roadmap-parser.cjs +366 -99
  77. package/pp-core/bin/lib/roadmap.cjs +141 -39
  78. package/pp-core/bin/lib/runtime-hooks-surface.cjs +1 -1
  79. package/pp-core/bin/lib/shell-command-projection.cjs +24 -0
  80. package/pp-core/bin/lib/state-command-router.cjs +82 -13
  81. package/pp-core/bin/lib/state-document.cjs +124 -7
  82. package/pp-core/bin/lib/state.cjs +784 -257
  83. package/pp-core/bin/lib/template.cjs +22 -6
  84. package/pp-core/bin/lib/vault-command-router.cjs +57 -6
  85. package/pp-core/bin/lib/vault-practices.cjs +6 -36
  86. package/pp-core/bin/lib/vault-write-back.cjs +20 -4
  87. package/pp-core/bin/lib/vault.cjs +555 -23
  88. package/pp-core/bin/lib/verify.cjs +53 -31
  89. package/pp-core/bin/lib/workstream.cjs +1 -1
  90. package/pp-core/bin/lib/worktree-safety.cjs +26 -15
  91. package/pp-core/bin/portable-timeout.sh +89 -0
  92. package/pp-core/bin/pp-tools.cjs +149 -9
  93. package/pp-core/bin/shared/config-defaults.manifest.json +7 -0
  94. package/pp-core/bin/shared/config-schema.manifest.json +3 -0
  95. package/pp-core/references/autonomous-best-effort.md +461 -0
  96. package/pp-core/references/autonomous-lifecycle.md +146 -0
  97. package/pp-core/references/autonomous-smart-discuss.md +16 -12
  98. package/pp-core/references/checkpoints.md +1 -1
  99. package/pp-core/references/discovery-id-conventions.md +202 -0
  100. package/pp-core/references/discovery-rebaseline.md +254 -0
  101. package/pp-core/references/discovery-research-track.md +283 -0
  102. package/pp-core/references/discovery-traceability.md +400 -0
  103. package/pp-core/references/loop-hook-dispatch.md +7 -0
  104. package/pp-core/references/mandatory-initial-read.md +1 -1
  105. package/pp-core/references/planner-gap-closure.md +1 -0
  106. package/pp-core/references/planner-human-verify-mode.md +1 -1
  107. package/pp-core/references/planner-load-graph-context.md +1 -1
  108. package/pp-core/references/planner-plan-graph.md +66 -0
  109. package/pp-core/references/planner-tdd.md +1 -0
  110. package/pp-core/references/planning-config.md +8 -0
  111. package/pp-core/references/sketch-tooling.md +4 -0
  112. package/pp-core/references/tdd.md +1 -0
  113. package/pp-core/templates/claude-md.md +1 -1
  114. package/pp-core/templates/phase-prompt.md +6 -2
  115. package/pp-core/templates/prototype-review/review.js +303 -0
  116. package/pp-core/templates/state.md +6 -0
  117. package/pp-core/workflows/_runtime-launcher.snippet.sh +1 -1
  118. package/pp-core/workflows/add-backlog.md +1 -1
  119. package/pp-core/workflows/add-phase.md +1 -1
  120. package/pp-core/workflows/add-tests.md +1 -1
  121. package/pp-core/workflows/add-todo.md +1 -1
  122. package/pp-core/workflows/ai-integration-phase.md +2 -2
  123. package/pp-core/workflows/audit-fix.md +12 -16
  124. package/pp-core/workflows/audit-milestone.md +1 -1
  125. package/pp-core/workflows/audit-uat.md +1 -1
  126. package/pp-core/workflows/autonomous.md +68 -306
  127. package/pp-core/workflows/check-todos.md +1 -1
  128. package/pp-core/workflows/cleanup.md +1 -1
  129. package/pp-core/workflows/code-review-fix.md +1 -1
  130. package/pp-core/workflows/code-review.md +19 -7
  131. package/pp-core/workflows/complete-milestone.md +41 -60
  132. package/pp-core/workflows/debug.md +1 -1
  133. package/pp-core/workflows/diagnose-issues.md +1 -1
  134. package/pp-core/workflows/discovery-sprint/gap-analysis.md +140 -0
  135. package/pp-core/workflows/discovery-sprint/input-package.md +140 -0
  136. package/pp-core/workflows/discovery-sprint/prefill.md +92 -0
  137. package/pp-core/workflows/discovery-sprint/prototype.md +257 -0
  138. package/pp-core/workflows/discovery-sprint/rebaseline.md +276 -0
  139. package/pp-core/workflows/discovery-sprint/session-13-gates.md +24 -0
  140. package/pp-core/workflows/discovery-sprint/sessions/01-kick-off.md +92 -0
  141. package/pp-core/workflows/discovery-sprint/sessions/02-as-is.md +98 -0
  142. package/pp-core/workflows/discovery-sprint/sessions/03-to-be.md +215 -0
  143. package/pp-core/workflows/discovery-sprint/sessions/04-ux-design.md +146 -0
  144. package/pp-core/workflows/discovery-sprint/sessions/05-architecture.md +98 -0
  145. package/pp-core/workflows/discovery-sprint/sessions/06-security.md +107 -0
  146. package/pp-core/workflows/discovery-sprint/sessions/07-identity.md +98 -0
  147. package/pp-core/workflows/discovery-sprint/sessions/08-integration.md +85 -0
  148. package/pp-core/workflows/discovery-sprint/sessions/09-qa.md +109 -0
  149. package/pp-core/workflows/discovery-sprint/sessions/10-infrastructure.md +119 -0
  150. package/pp-core/workflows/discovery-sprint/sessions/11-roadmap.md +92 -0
  151. package/pp-core/workflows/discovery-sprint/sessions/12-delivery-model.md +98 -0
  152. package/pp-core/workflows/discovery-sprint/sessions/13-wrap-up.md +205 -0
  153. package/pp-core/workflows/discovery-sprint/templates/context-template.md +266 -0
  154. package/pp-core/workflows/discovery-sprint.md +79 -1714
  155. package/pp-core/workflows/discovery-to-build/steps/architecture-deliverables.md +5 -5
  156. package/pp-core/workflows/discovery-to-build/steps/screen-inventory-gate.md +70 -0
  157. package/pp-core/workflows/discovery-to-build/templates/discovery-references.md +54 -0
  158. package/pp-core/workflows/discovery-to-build/templates/pp-handoff.md +184 -0
  159. package/pp-core/workflows/discovery-to-build.md +21 -238
  160. package/pp-core/workflows/discuss-phase/modes/advisor.md +1 -1
  161. package/pp-core/workflows/discuss-phase/modes/auto.md +1 -1
  162. package/pp-core/workflows/discuss-phase/modes/chain.md +1 -1
  163. package/pp-core/workflows/discuss-phase-assumptions.md +1 -1
  164. package/pp-core/workflows/discuss-phase.md +9 -2
  165. package/pp-core/workflows/do.md +4 -4
  166. package/pp-core/workflows/docs-update.md +1 -1
  167. package/pp-core/workflows/edit-phase.md +1 -1
  168. package/pp-core/workflows/eval-review.md +1 -1
  169. package/pp-core/workflows/execute-phase/steps/checkpoint-handling.md +45 -0
  170. package/pp-core/workflows/execute-phase/steps/client-context-gate.md +1 -1
  171. package/pp-core/workflows/execute-phase/steps/codebase-drift-gate.md +1 -1
  172. package/pp-core/workflows/execute-phase/steps/cross-ai-delegation.md +90 -0
  173. package/pp-core/workflows/execute-phase/steps/install-run.md +29 -0
  174. package/pp-core/workflows/execute-phase/steps/phase-close-rebaseline.md +23 -0
  175. package/pp-core/workflows/execute-phase/steps/post-merge-gate.md +76 -77
  176. package/pp-core/workflows/execute-phase/steps/serial-group-dispatch.md +72 -0
  177. package/pp-core/workflows/execute-phase/steps/vault-fetch.md +5 -5
  178. package/pp-core/workflows/execute-phase/steps/vault-load-gate.md +19 -112
  179. package/pp-core/workflows/execute-phase.md +102 -216
  180. package/pp-core/workflows/execute-plan.md +6 -2
  181. package/pp-core/workflows/explore.md +1 -1
  182. package/pp-core/workflows/extract-learnings.md +1 -1
  183. package/pp-core/workflows/forensics.md +4 -4
  184. package/pp-core/workflows/graduation.md +1 -1
  185. package/pp-core/workflows/health.md +1 -1
  186. package/pp-core/workflows/help/modes/brief.md +1 -0
  187. package/pp-core/workflows/help/modes/default.md +2 -1
  188. package/pp-core/workflows/help/modes/full.md +12 -5
  189. package/pp-core/workflows/import.md +1 -1
  190. package/pp-core/workflows/ingest-docs.md +1 -1
  191. package/pp-core/workflows/init.md +18 -2
  192. package/pp-core/workflows/insert-phase.md +1 -1
  193. package/pp-core/workflows/list-workspaces.md +1 -1
  194. package/pp-core/workflows/manager.md +3 -3
  195. package/pp-core/workflows/map-codebase.md +2 -2
  196. package/pp-core/workflows/milestone-summary.md +4 -4
  197. package/pp-core/workflows/mvp-phase.md +1 -1
  198. package/pp-core/workflows/new-milestone.md +7 -8
  199. package/pp-core/workflows/new-project.md +6 -1
  200. package/pp-core/workflows/new-workspace.md +1 -1
  201. package/pp-core/workflows/next.md +2 -2
  202. package/pp-core/workflows/note.md +2 -2
  203. package/pp-core/workflows/pause-work.md +1 -1
  204. package/pp-core/workflows/plan-milestone-gaps.md +1 -1
  205. package/pp-core/workflows/plan-phase/steps/chunked-planning.md +111 -0
  206. package/pp-core/workflows/plan-phase/steps/phase-tier.md +34 -0
  207. package/pp-core/workflows/plan-phase.md +43 -167
  208. package/pp-core/workflows/plan-review-convergence.md +2 -2
  209. package/pp-core/workflows/plant-seed.md +1 -1
  210. package/pp-core/workflows/pr-branch.md +1 -1
  211. package/pp-core/workflows/profile-user.md +1 -1
  212. package/pp-core/workflows/progress.md +3 -3
  213. package/pp-core/workflows/quick.md +3 -1
  214. package/pp-core/workflows/remove-phase.md +1 -1
  215. package/pp-core/workflows/remove-workspace.md +1 -1
  216. package/pp-core/workflows/resume-project.md +6 -12
  217. package/pp-core/workflows/review.md +1 -1
  218. package/pp-core/workflows/scan.md +1 -1
  219. package/pp-core/workflows/secure-phase.md +1 -1
  220. package/pp-core/workflows/settings-advanced.md +1 -1
  221. package/pp-core/workflows/settings-integrations.md +1 -1
  222. package/pp-core/workflows/settings.md +1 -1
  223. package/pp-core/workflows/ship.md +25 -8
  224. package/pp-core/workflows/sketch-wrap-up.md +1 -1
  225. package/pp-core/workflows/sketch.md +2 -1
  226. package/pp-core/workflows/spec-phase.md +1 -1
  227. package/pp-core/workflows/spike-wrap-up.md +1 -1
  228. package/pp-core/workflows/spike.md +1 -1
  229. package/pp-core/workflows/stats.md +2 -2
  230. package/pp-core/workflows/thread.md +1 -1
  231. package/pp-core/workflows/transition.md +5 -9
  232. package/pp-core/workflows/ui-phase.md +23 -2
  233. package/pp-core/workflows/ui-review.md +1 -1
  234. package/pp-core/workflows/ultraplan-phase.md +2 -2
  235. package/pp-core/workflows/validate-phase.md +1 -1
  236. package/pp-core/workflows/vault-write-back.md +1 -1
  237. package/pp-core/workflows/verify-phase.md +29 -26
  238. package/pp-core/workflows/verify-work.md +14 -10
  239. package/scripts/base64-scan.sh +7 -41
  240. package/scripts/changeset/cli.cjs +25 -5
  241. package/scripts/dev-env.cjs +462 -0
  242. package/scripts/lib/allowlist-ratchet.cjs +103 -1
  243. package/scripts/lib/size-budget.cjs +311 -0
  244. package/scripts/lint-neutrality-names.cjs +694 -0
  245. package/scripts/lint-org-coordinates.allowlist.json +35 -0
  246. package/scripts/lint-org-coordinates.cjs +426 -0
  247. package/scripts/lint-skill-deps.cjs +48 -9
  248. package/scripts/lint-test-file-count.cjs +17 -2
  249. package/scripts/prompt-injection-scan.sh +41 -20
  250. package/scripts/sync-manifest-versions.cjs +41 -7
  251. package/scripts/sync-runtime-launcher.cjs +106 -1
  252. package/scripts/update-size-baseline.cjs +274 -30
  253. package/scripts/workflow-size.cjs +61 -9
  254. package/commands/pp-build-handoff.md +0 -46
  255. package/pp-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +0 -94
@@ -19,10 +19,10 @@ const configLoaderMod = require("./config-loader.cjs");
19
19
  const { loadConfig } = configLoaderMod;
20
20
  // eslint-disable-next-line @typescript-eslint/no-require-imports
21
21
  const phaseIdMod = require("./phase-id.cjs");
22
- const { escapeRegex } = phaseIdMod;
22
+ const { escapeRegex, normalizePhaseName } = phaseIdMod;
23
23
  // eslint-disable-next-line @typescript-eslint/no-require-imports
24
24
  const roadmapParserMod = require("./roadmap-parser.cjs");
25
- const { getMilestoneInfo, getMilestonePhaseFilter, extractCurrentMilestone } = roadmapParserMod;
25
+ const { getDeclaredMilestoneInfo, getMilestonePhaseFilter, extractCurrentMilestone, getClosedMilestonePhaseDirFilter } = roadmapParserMod;
26
26
  const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
27
27
  // eslint-disable-next-line @typescript-eslint/no-require-imports
28
28
  const planningWorkspace = require("./planning-workspace.cjs");
@@ -33,6 +33,9 @@ const frontmatter = require("./frontmatter.cjs");
33
33
  const { extractFrontmatter, reconstructFrontmatter } = frontmatter;
34
34
  // eslint-disable-next-line @typescript-eslint/no-require-imports
35
35
  const scanPhasePlans = require("./plan-scan.cjs");
36
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
37
+ const phaseLifecycleMod = require("./phase-lifecycle.cjs");
38
+ const { deriveProgressFromRoadmap } = phaseLifecycleMod;
36
39
  const state_document_cjs_1 = require("./state-document.cjs");
37
40
  const STATE_PROGRESS_RESYNC_FIELDS = new Set([
38
41
  'Progress',
@@ -73,8 +76,13 @@ function cmdStateLoad(cwd, raw) {
73
76
  const configExists = node_fs_1.default.existsSync(node_path_1.default.join(planDir, 'config.json'));
74
77
  const roadmapExists = node_fs_1.default.existsSync(node_path_1.default.join(planDir, 'ROADMAP.md'));
75
78
  const stateExists = stateRaw.length > 0;
79
+ // Pack 6 D-07: the emitted config is display-safe for its remote coordinate
80
+ // key (config.cjs's projection); `config` itself stays raw. Lazily required
81
+ // so this module's load graph is unchanged.
82
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
83
+ const { redactConfigCoordinates } = require('./config.cjs');
76
84
  const result = {
77
- config,
85
+ config: redactConfigCoordinates('', config),
78
86
  state_raw: stateRaw,
79
87
  state_exists: stateExists,
80
88
  roadmap_exists: roadmapExists,
@@ -172,20 +180,26 @@ function cmdStatePatch(cwd, patches, raw) {
172
180
  const results = { updated: [], failed: [] };
173
181
  const shouldResync = shouldResyncStateProgress(Object.keys(patches));
174
182
  // Use atomic read-modify-write to prevent lost updates from concurrent agents
175
- readModifyWriteStateMd(statePath, (content) => {
183
+ const fmReport = readModifyWriteStateMd(statePath, (content) => {
184
+ // Patch the BODY only, as cmdStateUpdate does. The plain-line pattern is
185
+ // case-insensitive, so on the whole file `Status` matched the frontmatter
186
+ // `status:` line first; the re-derivation then restored it from the untouched
187
+ // body, and the verb reported a write it never made.
188
+ let body = stripFrontmatter(content);
189
+ const head = content.slice(0, content.length - body.length);
176
190
  for (const [field, value] of Object.entries(patches)) {
177
- const result = (0, state_document_cjs_1.stateReplaceField)(content, field, value);
191
+ const result = (0, state_document_cjs_1.stateReplaceField)(body, field, value);
178
192
  if (result) {
179
- content = result;
193
+ body = result;
180
194
  results.updated.push(field);
181
195
  }
182
196
  else {
183
197
  results.failed.push(field);
184
198
  }
185
199
  }
186
- return content;
200
+ return head + body;
187
201
  }, cwd, { resync: shouldResync });
188
- output(results, raw, results.updated.length > 0 ? 'true' : 'false');
202
+ output({ ...results, frontmatter_updated: fmReport.frontmatter_updated }, raw, results.updated.length > 0 ? 'true' : 'false');
189
203
  }
190
204
  catch {
191
205
  error('STATE.md not found');
@@ -208,7 +222,7 @@ function cmdStateUpdate(cwd, field, value) {
208
222
  const shouldResync = shouldResyncStateProgress([field]);
209
223
  // Preserve curated progress for body-only updates, but allow fields that
210
224
  // directly project into progress.* frontmatter to rebuild after mutation.
211
- readModifyWriteStateMd(statePath, (content) => {
225
+ const fmReport = readModifyWriteStateMd(statePath, (content) => {
212
226
  const body = stripFrontmatter(content);
213
227
  const result = (0, state_document_cjs_1.stateReplaceField)(body, field, value);
214
228
  if (result) {
@@ -222,10 +236,10 @@ function cmdStateUpdate(cwd, field, value) {
222
236
  return content;
223
237
  }, cwd, { resync: shouldResync });
224
238
  if (updated) {
225
- output({ updated: true }, false, undefined);
239
+ output({ updated: true, frontmatter_updated: fmReport.frontmatter_updated }, false, undefined);
226
240
  }
227
241
  else {
228
- output({ updated: false, reason: `Field "${field}" not found in STATE.md` }, false, undefined);
242
+ output({ updated: false, reason: `Field "${field}" not found in STATE.md`, frontmatter_updated: fmReport.frontmatter_updated }, false, undefined);
229
243
  }
230
244
  }
231
245
  catch {
@@ -349,7 +363,7 @@ function cmdStateAdvancePlan(cwd, raw) {
349
363
  }
350
364
  const today = clock_cjs_1.realClock.today();
351
365
  let result = null;
352
- readModifyWriteStateMd(statePath, (content) => {
366
+ const fmReport = readModifyWriteStateMd(statePath, (content) => {
353
367
  // Try legacy separate fields first, then compound "Plan: X of Y" format
354
368
  const legacyPlan = (0, state_document_cjs_1.stateExtractField)(content, 'Current Plan');
355
369
  const legacyTotal = (0, state_document_cjs_1.stateExtractField)(content, 'Total Plans in Phase');
@@ -410,15 +424,121 @@ function cmdStateAdvancePlan(cwd, raw) {
410
424
  return content;
411
425
  }, cwd);
412
426
  if (!result || result['error']) {
413
- output({ error: 'Cannot parse Current Plan or Total Plans in Phase from STATE.md' }, raw, undefined);
427
+ output({ error: 'Cannot parse Current Plan or Total Plans in Phase from STATE.md', frontmatter_updated: fmReport.frontmatter_updated }, raw, undefined);
414
428
  return;
415
429
  }
430
+ const advanceResult = { ...result, frontmatter_updated: fmReport.frontmatter_updated };
416
431
  if (result['advanced'] === false) {
417
- output(result, raw, 'false');
432
+ output(advanceResult, raw, 'false');
418
433
  }
419
434
  else {
420
- output(result, raw, 'true');
435
+ output(advanceResult, raw, 'true');
436
+ }
437
+ }
438
+ // ─── Section placeholders (261008-mwk follow-up) ─────────────────────────────
439
+ // A markdown table's header row and separator row, each matched within its own
440
+ // line — the one owner for the writer (record-metric) and the reader
441
+ // (state-snapshot's Decisions Made table). The separator class is `[-|: \t]`,
442
+ // never `[-|\s]`: a class that admits `\n` ran past the blank line under an
443
+ // empty table, so the "table body" began at the NEXT section. record-metric then
444
+ // wrote its row into that section (and, with `None yet` there, deleted the
445
+ // section's heading), and state-snapshot read the next section's table rows as
446
+ // decisions.
447
+ const TABLE_SEPARATOR_ROW = String.raw `\|[-|: \t]+`;
448
+ const TABLE_HEAD_ROWS = String.raw `\n\|[^\n]+\n${TABLE_SEPARATOR_ROW}\n`;
449
+ const TABLE_SEPARATOR_LINE_RE = new RegExp(String.raw `^${TABLE_SEPARATOR_ROW}\s*$`);
450
+ // ─── record-metric's table (261008-mwk follow-up 2) ──────────────────────────
451
+ // The canonical per-plan metrics table: the STATE.md template carries it under
452
+ // `**By Plan:**`, and record-metric's section-absent scaffold writes it.
453
+ const METRICS_TABLE_HEADER = '| Plan | Duration | Tasks | Files |';
454
+ const METRICS_TABLE_SEPARATOR = '|------|----------|-------|-------|';
455
+ function tableCells(line) {
456
+ return line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map((c) => c.trim());
457
+ }
458
+ // One row in the table's OWN column shape, derived from its header: a column is
459
+ // filled only when its header names a value record-metric holds (Phase, Plan,
460
+ // Duration, Tasks, Files); any other column gets `-`. When only one of Phase/Plan
461
+ // exists it carries the `Phase N PM` identifier (the upstream row convention).
462
+ function metricRowFor(header, m) {
463
+ const names = header.map((h) => h.toLowerCase());
464
+ const id = `Phase ${m.phase} P${m.plan}`;
465
+ const hasPhase = names.includes('phase');
466
+ const hasPlan = names.includes('plan');
467
+ const cells = names.map((n) => {
468
+ if (n === 'phase')
469
+ return hasPlan ? m.phase : id;
470
+ if (n === 'plan')
471
+ return hasPhase ? m.plan : id;
472
+ if (n === 'duration')
473
+ return m.duration;
474
+ if (n === 'tasks')
475
+ return `${m.tasks || '-'} tasks`;
476
+ if (n === 'files')
477
+ return `${m.files || '-'} files`;
478
+ return '-';
479
+ });
480
+ return `| ${cells.join(' | ')} |`;
481
+ }
482
+ // Finds record-metric's table: the first table under `## Performance Metrics`
483
+ // whose header has a Duration column. Its body is the run of `|` rows (and any
484
+ // placeholder lines) directly under the separator — never prose that follows.
485
+ // When the section has no such table, `insertAt` is where the By Plan table is
486
+ // created: right after the section's last table, or under the heading if the
487
+ // section has none (D-9 revised).
488
+ function locateMetricsTable(content) {
489
+ const lines = content.split('\n');
490
+ const start = lines.findIndex((l) => /^#{2,3}\s*Performance Metrics\s*$/i.test(l.trimEnd()));
491
+ if (start === -1)
492
+ return { kind: 'no-section' };
493
+ const level = lines[start].match(/^#+/)[0].length;
494
+ let end = lines.length;
495
+ for (let i = start + 1; i < lines.length; i++) {
496
+ const h = lines[i].match(/^(#{1,6})\s/);
497
+ if (h && h[1].length <= level) {
498
+ end = i;
499
+ break;
500
+ }
421
501
  }
502
+ let insertAt = start + 1;
503
+ for (let i = start + 1; i + 1 < end; i++) {
504
+ if (!lines[i].trimStart().startsWith('|') || !TABLE_SEPARATOR_LINE_RE.test(lines[i + 1].trim()))
505
+ continue;
506
+ const header = tableCells(lines[i]);
507
+ let j = i + 2;
508
+ while (j < end && (lines[j].trimStart().startsWith('|') || (lines[j].trim() !== '' && isPlaceholderLine(lines[j]))))
509
+ j++;
510
+ if (header.some((h) => h.toLowerCase() === 'duration'))
511
+ return { kind: 'table', lines, header, bodyStart: i + 2, bodyEnd: j };
512
+ insertAt = j;
513
+ i = j - 1;
514
+ }
515
+ return { kind: 'no-table', lines, insertAt };
516
+ }
517
+ // The one owner of "this line is a template placeholder, not an entry". A line
518
+ // counts only when the WHOLE line (an optional `- ` / `* ` list marker aside) is
519
+ // `None`, `None yet` or `No decisions yet`, with an optional trailing period.
520
+ // Before this owner, add-blocker, add-decision, add-roadmap-evolution and
521
+ // record-metric each matched those words anywhere in the section: `None yet.`
522
+ // became a leftover `yet.` line, `- None yet` left a lone `-`, an existing entry
523
+ // such as `- Nonexistent endpoint returns 500` was rewritten to `- xistent …`,
524
+ // and record-metric dropped every row of a table in which any row mentioned
525
+ // `None yet`.
526
+ const SECTION_PLACEHOLDER_RE = /^\s*(?:[-*]\s+)?(?:none(?:\s+yet)?|no decisions yet)\.?\s*$/i;
527
+ function isPlaceholderLine(line) {
528
+ return SECTION_PLACEHOLDER_RE.test(line);
529
+ }
530
+ // A table row is a placeholder when at least one cell is a placeholder and every
531
+ // other cell is empty or `-` (e.g. `| None yet | | | |`).
532
+ function isPlaceholderTableRow(line) {
533
+ const t = line.trim();
534
+ if (!t.startsWith('|'))
535
+ return false;
536
+ const cells = t.replace(/^\|/, '').replace(/\|$/, '').split('|').map((c) => c.trim());
537
+ return cells.some(isPlaceholderLine) && cells.every((c) => c === '' || c === '-' || isPlaceholderLine(c));
538
+ }
539
+ // Remove placeholder lines whole; every other line is kept byte for byte.
540
+ function stripPlaceholderLines(body) {
541
+ return body.split('\n').filter((line) => !isPlaceholderLine(line) && !isPlaceholderTableRow(line)).join('\n');
422
542
  }
423
543
  function cmdStateRecordMetric(cwd, options, raw) {
424
544
  const statePath = planningPaths(cwd).state;
@@ -431,44 +551,61 @@ function cmdStateRecordMetric(cwd, options, raw) {
431
551
  output({ error: 'phase, plan, and duration required' }, raw, undefined);
432
552
  return;
433
553
  }
434
- let _recorded = false;
554
+ let recorded = false;
435
555
  let created = false;
436
- readModifyWriteStateMd(statePath, (content) => {
437
- // Find Performance Metrics section and its table
438
- const metricsPattern = /(##\s*Performance Metrics[\s\S]*?\n\|[^\n]+\n\|[-|\s]+\n)([\s\S]*?)(?=\n##|\n$|$)/i;
439
- const metricsMatch = content.match(metricsPattern);
440
- const newRow = `| Phase ${phase} P${plan} | ${duration} | ${tasks || '-'} tasks | ${files || '-'} files |`;
441
- if (metricsMatch) {
442
- let tableBody = metricsMatch[2].trimEnd();
443
- if (tableBody.trim() === '' || tableBody.includes('None yet')) {
444
- tableBody = newRow;
445
- }
446
- else {
447
- tableBody = tableBody + '\n' + newRow;
448
- }
449
- _recorded = true;
450
- return content.replace(metricsPattern, (_match, header) => `${header}${tableBody}\n`);
451
- }
452
- // Section absent — DWIM: auto-create canonical ## Performance Metrics scaffold,
453
- // then append the row. Matches state begin-phase / advance-plan DWIM behavior.
556
+ let tableCreated = false;
557
+ const fmReport = readModifyWriteStateMd(statePath, (content) => {
558
+ const place = locateMetricsTable(content);
559
+ const metric = { phase, plan, duration, tasks, files };
560
+ if (place.kind === 'table') {
561
+ // 261008-mwk follow-up 2: the row goes INSIDE the table, in the table's own
562
+ // column shape; placeholder rows in the body are dropped, every other row kept.
563
+ const body = place.lines.slice(place.bodyStart, place.bodyEnd).filter((l) => !isPlaceholderLine(l) && !isPlaceholderTableRow(l));
564
+ const next = [
565
+ ...place.lines.slice(0, place.bodyStart),
566
+ ...body,
567
+ metricRowFor(place.header, metric),
568
+ ...place.lines.slice(place.bodyEnd),
569
+ ];
570
+ recorded = true;
571
+ return next.join('\n');
572
+ }
573
+ if (place.kind === 'no-table') {
574
+ // D-9 revised: a Performance Metrics section with no Duration table (e.g. a
575
+ // STATE.md from the template before it carried `**By Plan:**`) gets the same
576
+ // By Plan table the template and the section-absent scaffold carry, placed after
577
+ // the section's last table (or under the heading) — never after prose, never
578
+ // in another table's shape.
579
+ const block = ['', '**By Plan:**', '', METRICS_TABLE_HEADER, METRICS_TABLE_SEPARATOR, metricRowFor(tableCells(METRICS_TABLE_HEADER), metric)];
580
+ const following = place.lines[place.insertAt];
581
+ if (following !== undefined && following.trim() !== '')
582
+ block.push('');
583
+ recorded = true;
584
+ tableCreated = true;
585
+ return [...place.lines.slice(0, place.insertAt), ...block, ...place.lines.slice(place.insertAt)].join('\n');
586
+ }
587
+ // Section absent — DWIM: auto-create the canonical ## Performance Metrics
588
+ // scaffold, then append the row. Matches state begin-phase / advance-plan DWIM behavior.
454
589
  const scaffold = [
455
590
  '',
456
591
  '## Performance Metrics',
457
592
  '',
458
- '| Phase | Plan | Duration | Notes |',
459
- '|-------|------|----------|-------|',
460
- newRow,
593
+ METRICS_TABLE_HEADER,
594
+ METRICS_TABLE_SEPARATOR,
595
+ metricRowFor(tableCells(METRICS_TABLE_HEADER), metric),
461
596
  '',
462
597
  ].join('\n');
463
- _recorded = true;
598
+ recorded = true;
464
599
  created = true;
465
600
  return content.trimEnd() + '\n' + scaffold;
466
601
  }, cwd);
467
- // Auto-create fallback guarantees recorded === true; no else branch needed.
468
- const result = { recorded: true, phase, plan, duration };
602
+ const result = { recorded, phase, plan, duration };
603
+ if (tableCreated)
604
+ result['table_created'] = true;
469
605
  if (created)
470
606
  result['created'] = true;
471
- output(result, raw, 'true');
607
+ result['frontmatter_updated'] = fmReport.frontmatter_updated;
608
+ output(result, raw, recorded ? 'true' : 'false');
472
609
  }
473
610
  function cmdStateUpdateProgress(cwd, raw) {
474
611
  const statePath = planningPaths(cwd).state;
@@ -476,20 +613,23 @@ function cmdStateUpdateProgress(cwd, raw) {
476
613
  output({ error: 'STATE.md not found' }, raw, undefined);
477
614
  return;
478
615
  }
479
- // Count summaries across current milestone phases only (outside lock — read-only)
616
+ // Count summaries across current milestone phases only (outside lock — read-only).
617
+ // 261007-ixt D-9: from the same milestone scan as the frontmatter block, so a closed
618
+ // milestone's leftover directories are not counted here either. When every directory
619
+ // belongs to a closed milestone, the disk says nothing about the current one and the
620
+ // bar is left as it is.
480
621
  const phasesDir = planningPaths(cwd).phases;
481
622
  let totalPlans = 0;
482
623
  let totalSummaries = 0;
483
624
  if (node_fs_1.default.existsSync(phasesDir)) {
484
- const isDirInMilestone = getMilestonePhaseFilter(cwd);
485
- const phaseDirs = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true })
486
- .filter(e => e.isDirectory()).map(e => e.name)
487
- .filter(isDirInMilestone);
488
- for (const dir of phaseDirs) {
489
- const { planCount, summaryCount } = scanPhasePlans(node_path_1.default.join(phasesDir, dir));
490
- totalPlans += planCount;
491
- totalSummaries += summaryCount;
625
+ _diskScanCache.delete(cwd);
626
+ const scan = scanMilestoneDisk(cwd);
627
+ if (scan === CLOSED_MILESTONE_ONLY) {
628
+ output({ updated: false, reason: 'No phase directory belongs to the current milestone', frontmatter_updated: [] }, raw, 'false');
629
+ return;
492
630
  }
631
+ totalPlans = scan ? scan.totalPlans : 0;
632
+ totalSummaries = scan ? scan.completedPlans : 0;
493
633
  }
494
634
  const percent = totalPlans > 0 ? Math.min(100, Math.round(totalSummaries / totalPlans * 100)) : 0;
495
635
  const barWidth = 10;
@@ -499,25 +639,31 @@ function cmdStateUpdateProgress(cwd, raw) {
499
639
  let updated = false;
500
640
  const _totalPlans = totalPlans;
501
641
  const _totalSummaries = totalSummaries;
502
- readModifyWriteStateMd(statePath, (content) => {
642
+ const fmReport = readModifyWriteStateMd(statePath, (content) => {
643
+ // 261007-ixt: `Progress:` is a BODY field. On the whole file the case-insensitive
644
+ // multi-line plain pattern matched the YAML `progress:` key first and replaced it
645
+ // with the bar — deleting the block's first key (`total_phases`) — while the body
646
+ // was untouched and the result said `updated: true`. Edit the body only.
647
+ const body = stripFrontmatter(content);
648
+ const head = content.slice(0, content.length - body.length);
503
649
  // Try **Progress:** bold format first, then plain Progress: format
504
650
  const boldProgressPattern = /(\*\*Progress:\*\*\s*).*/i;
505
- const plainProgressPattern = /^(Progress:\s*).*/im;
506
- if (boldProgressPattern.test(content)) {
651
+ const plainProgressPattern = /^(Progress:[ \t]*).*/im;
652
+ if (boldProgressPattern.test(body)) {
507
653
  updated = true;
508
- return content.replace(boldProgressPattern, (_match, prefix) => `${prefix}${progressStr}`);
654
+ return head + body.replace(boldProgressPattern, (_match, prefix) => `${prefix}${progressStr}`);
509
655
  }
510
- else if (plainProgressPattern.test(content)) {
656
+ else if (plainProgressPattern.test(body)) {
511
657
  updated = true;
512
- return content.replace(plainProgressPattern, (_match, prefix) => `${prefix}${progressStr}`);
658
+ return head + body.replace(plainProgressPattern, (_match, prefix) => `${prefix}${progressStr}`);
513
659
  }
514
660
  return content;
515
661
  }, cwd);
516
662
  if (updated) {
517
- output({ updated: true, percent, completed: _totalSummaries, total: _totalPlans, bar: progressStr }, raw, progressStr);
663
+ output({ updated: true, percent, completed: _totalSummaries, total: _totalPlans, bar: progressStr, frontmatter_updated: fmReport.frontmatter_updated }, raw, progressStr);
518
664
  }
519
665
  else {
520
- output({ updated: false, reason: 'Progress field not found in STATE.md' }, raw, 'false');
666
+ output({ updated: false, reason: 'Progress field not found in STATE.md', frontmatter_updated: fmReport.frontmatter_updated }, raw, 'false');
521
667
  }
522
668
  }
523
669
  function cmdStateAddDecision(cwd, options, raw) {
@@ -544,14 +690,14 @@ function cmdStateAddDecision(cwd, options, raw) {
544
690
  const entry = `- [Phase ${phase || '?'}]: ${summaryText}${rationaleText ? ` — ${rationaleText}` : ''}`;
545
691
  let _added = false;
546
692
  let created = false;
547
- readModifyWriteStateMd(statePath, (content) => {
693
+ const fmReport = readModifyWriteStateMd(statePath, (content) => {
548
694
  // Find Decisions section (various heading patterns)
549
695
  const sectionPattern = /(###?\s*(?:Decisions|Decisions Made|Accumulated.*Decisions)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
550
696
  const match = content.match(sectionPattern);
551
697
  if (match) {
552
698
  let sectionBody = match[2];
553
699
  // Remove placeholders
554
- sectionBody = sectionBody.replace(/None yet\.?\s*\n?/gi, '').replace(/No decisions yet\.?\s*\n?/gi, '');
700
+ sectionBody = stripPlaceholderLines(sectionBody);
555
701
  sectionBody = sectionBody.trimEnd() + '\n' + entry + '\n';
556
702
  _added = true;
557
703
  return content.replace(sectionPattern, (_match, header) => `${header}${sectionBody}`);
@@ -573,6 +719,7 @@ function cmdStateAddDecision(cwd, options, raw) {
573
719
  const result = { added: true, decision: entry };
574
720
  if (created)
575
721
  result['created'] = true;
722
+ result['frontmatter_updated'] = fmReport.frontmatter_updated;
576
723
  output(result, raw, 'true');
577
724
  }
578
725
  function cmdStateAddBlocker(cwd, text, raw) {
@@ -597,12 +744,12 @@ function cmdStateAddBlocker(cwd, text, raw) {
597
744
  const entry = `- ${blockerText}`;
598
745
  let _added = false;
599
746
  let created = false;
600
- readModifyWriteStateMd(statePath, (content) => {
747
+ const fmReport = readModifyWriteStateMd(statePath, (content) => {
601
748
  const sectionPattern = /(###?\s*(?:Blockers|Blockers\/Concerns|Concerns)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
602
749
  const match = content.match(sectionPattern);
603
750
  if (match) {
604
751
  let sectionBody = match[2];
605
- sectionBody = sectionBody.replace(/None\.?\s*\n?/gi, '').replace(/None yet\.?\s*\n?/gi, '');
752
+ sectionBody = stripPlaceholderLines(sectionBody);
606
753
  sectionBody = sectionBody.trimEnd() + '\n' + entry + '\n';
607
754
  _added = true;
608
755
  return content.replace(sectionPattern, (_match, header) => `${header}${sectionBody}`);
@@ -623,6 +770,7 @@ function cmdStateAddBlocker(cwd, text, raw) {
623
770
  const result = { added: true, blocker: blockerText };
624
771
  if (created)
625
772
  result['created'] = true;
773
+ result['frontmatter_updated'] = fmReport.frontmatter_updated;
626
774
  output(result, raw, 'true');
627
775
  }
628
776
  function cmdStateAddRoadmapEvolution(cwd, options, raw) {
@@ -666,7 +814,7 @@ function cmdStateAddRoadmapEvolution(cwd, options, raw) {
666
814
  // Section boundaries mirror the sibling handlers (add-decision/add-blocker):
667
815
  // a trailing CR on a CRLF STATE.md is absorbed by the lazy body and trimmed,
668
816
  // so following sections are preserved without data loss (see the CRLF test).
669
- readModifyWriteStateMd(statePath, (content) => {
817
+ const fmReport = readModifyWriteStateMd(statePath, (content) => {
670
818
  const accPattern = /(##\s*Accumulated Context\s*\n)([\s\S]*?)(?=\n##[^#]|$)/i;
671
819
  const accMatch = content.match(accPattern);
672
820
  if (accMatch) {
@@ -683,7 +831,7 @@ function cmdStateAddRoadmapEvolution(cwd, options, raw) {
683
831
  duplicate = true;
684
832
  return content;
685
833
  }
686
- subBody = subBody.replace(/None yet\.?\s*\n?/gi, '');
834
+ subBody = stripPlaceholderLines(subBody);
687
835
  subBody = subBody.trimEnd() + '\n' + entry + '\n';
688
836
  const newAccBody = accBody.replace(subPattern, (_m, header) => `${header}${subBody}`);
689
837
  return content.replace(accPattern, () => `${accHeader}${newAccBody}`);
@@ -710,7 +858,7 @@ function cmdStateAddRoadmapEvolution(cwd, options, raw) {
710
858
  return content.trimEnd() + '\n' + scaffold;
711
859
  }, cwd);
712
860
  if (duplicate) {
713
- output({ added: false, reason: 'duplicate', entry }, raw, 'false');
861
+ output({ added: false, reason: 'duplicate', entry, frontmatter_updated: fmReport.frontmatter_updated }, raw, 'false');
714
862
  return;
715
863
  }
716
864
  const result = { added: true, entry };
@@ -718,6 +866,7 @@ function cmdStateAddRoadmapEvolution(cwd, options, raw) {
718
866
  result['created'] = true;
719
867
  if (subsectionCreated)
720
868
  result['subsection_created'] = true;
869
+ result['frontmatter_updated'] = fmReport.frontmatter_updated;
721
870
  output(result, raw, 'true');
722
871
  }
723
872
  function cmdStateResolveBlocker(cwd, text, raw) {
@@ -731,7 +880,7 @@ function cmdStateResolveBlocker(cwd, text, raw) {
731
880
  return;
732
881
  }
733
882
  let resolved = false;
734
- readModifyWriteStateMd(statePath, (content) => {
883
+ const fmReport = readModifyWriteStateMd(statePath, (content) => {
735
884
  const sectionPattern = /(###?\s*(?:Blockers|Blockers\/Concerns|Concerns)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
736
885
  const match = content.match(sectionPattern);
737
886
  if (match) {
@@ -753,10 +902,10 @@ function cmdStateResolveBlocker(cwd, text, raw) {
753
902
  return content;
754
903
  }, cwd);
755
904
  if (resolved) {
756
- output({ resolved: true, blocker: text }, raw, 'true');
905
+ output({ resolved: true, blocker: text, frontmatter_updated: fmReport.frontmatter_updated }, raw, 'true');
757
906
  }
758
907
  else {
759
- output({ resolved: false, reason: 'Blockers section not found in STATE.md' }, raw, 'false');
908
+ output({ resolved: false, reason: 'Blockers section not found in STATE.md', frontmatter_updated: fmReport.frontmatter_updated }, raw, 'false');
760
909
  }
761
910
  }
762
911
  function cmdStateRecordSession(cwd, options, raw) {
@@ -768,7 +917,7 @@ function cmdStateRecordSession(cwd, options, raw) {
768
917
  const now = clock_cjs_1.realClock.nowIso();
769
918
  const updated = [];
770
919
  let sessionCreated = false;
771
- readModifyWriteStateMd(statePath, (content) => {
920
+ const fmReport = readModifyWriteStateMd(statePath, (content) => {
772
921
  // Update Last session / Last Date
773
922
  let result = (0, state_document_cjs_1.stateReplaceField)(content, 'Last session', now);
774
923
  if (result) {
@@ -920,15 +1069,16 @@ function cmdStateRecordSession(cwd, options, raw) {
920
1069
  updated.push('Resume File');
921
1070
  }
922
1071
  return content;
923
- }, cwd);
1072
+ }, cwd, { stoppedAtSetByCaller: !!options.stopped_at });
924
1073
  if (updated.length > 0) {
925
1074
  const result = { recorded: true, updated };
926
1075
  if (sessionCreated)
927
1076
  result['created'] = true;
1077
+ result['frontmatter_updated'] = fmReport.frontmatter_updated;
928
1078
  output(result, raw, 'true');
929
1079
  }
930
1080
  else {
931
- output({ recorded: false, reason: 'No session fields found in STATE.md' }, raw, 'false');
1081
+ output({ recorded: false, reason: 'No session fields found in STATE.md', frontmatter_updated: fmReport.frontmatter_updated }, raw, 'false');
932
1082
  }
933
1083
  }
934
1084
  /**
@@ -941,6 +1091,28 @@ function cmdStateRecordSession(cwd, options, raw) {
941
1091
  * excludes `## Session Continuity Archive` (preserving the #2444 scoping).
942
1092
  * Returns the match whose group 1 is the section body, or null.
943
1093
  */
1094
+ /**
1095
+ * Replace ONE Session-block field's value on its own line (13-REVIEW WR-05).
1096
+ * `stateReplaceField`'s patterns use `\s*` after the colon, which also matches
1097
+ * a newline: with an empty `Stopped at:` it overwrote the NEXT line
1098
+ * (`Resume file: …`), and the following reset then found no `Resume file:` at
1099
+ * all. Here the gap is `[ \t]*` and the value `.*`, so a match never leaves
1100
+ * its line; an empty value gains one space. Bold (`**Field:**`) first, then the
1101
+ * plain line-start form — the same two forms, in the same order, the shared
1102
+ * helper reads. `fieldName` is a fixed literal at every call site.
1103
+ */
1104
+ function replaceSessionLine(section, fieldName, value) {
1105
+ const escaped = escapeRegex(fieldName);
1106
+ for (const pattern of [
1107
+ new RegExp(`(\\*\\*${escaped}:\\*\\*)([ \\t]*)(.*)`, 'i'),
1108
+ new RegExp(`^(${escaped}:)([ \\t]*)(.*)$`, 'im'),
1109
+ ]) {
1110
+ if (pattern.test(section)) {
1111
+ return section.replace(pattern, (_m, label, gap) => `${label}${gap || ' '}${value}`);
1112
+ }
1113
+ }
1114
+ return null;
1115
+ }
944
1116
  function matchSessionSection(body) {
945
1117
  return body.match(/(?:^|\n)##[ \t]*Session[ \t]*\n([\s\S]*?)(?=\n##|$)/i)
946
1118
  || body.match(/(?:^|\n)##[ \t]*Session Continuity[ \t]*\n([\s\S]*?)(?=\n##|$)/i);
@@ -948,7 +1120,15 @@ function matchSessionSection(body) {
948
1120
  function parseProsePhaseField(value) {
949
1121
  if (!value)
950
1122
  return { phase: null, name: null };
951
- const phaseMatch = value.match(/\b(\d+[A-Z]?(?:\.\d+)*)\b/i);
1123
+ // 261007-ixt D-2: a phase id is read only from the START of the value (optionally
1124
+ // after the word `Phase`) — the forms the engine writes (`11 (name) — EXECUTING`,
1125
+ // `08 — COMPLETE`). The unanchored search took the `1` out of `Milestone v1.1
1126
+ // complete`, the line `milestone complete` itself writes, and rewound the pointer
1127
+ // ten phases. A version string is not a phase id; anything that does not start
1128
+ // with one is prose, so the name is refused too and the stored values stand.
1129
+ const phaseMatch = value.match(/^\s*(?:phase\s+)?(\d+[A-Z]?(?:\.\d+)*)\b/i);
1130
+ if (!phaseMatch)
1131
+ return { phase: null, name: null };
952
1132
  const parenName = value.match(/\(([^)]+)\)/);
953
1133
  const dashName = value.match(/—\s*([^(\n]+?)(?:\s*\(|$)/);
954
1134
  const rawName = parenName?.[1] ?? dashName?.[1] ?? null;
@@ -1018,7 +1198,7 @@ function cmdStateSnapshot(cwd, raw) {
1018
1198
  const progressPercent = progressRaw ? parseInt(progressRaw.replace('%', ''), 10) : null;
1019
1199
  // Extract decisions table
1020
1200
  const decisions = [];
1021
- const decisionsMatch = body.match(/##\s*Decisions Made[\s\S]*?\n\|[^\n]+\n\|[-|\s]+\n([\s\S]*?)(?=\n##|\n$|$)/i);
1201
+ const decisionsMatch = body.match(new RegExp(String.raw `##\s*Decisions Made[\s\S]*?${TABLE_HEAD_ROWS}([\s\S]*?)(?=\n##|\n$|$)`, 'i'));
1022
1202
  if (decisionsMatch) {
1023
1203
  const tableBody = decisionsMatch[1];
1024
1204
  const rows = tableBody.trim().split('\n').filter(r => r.includes('|'));
@@ -1071,6 +1251,12 @@ function cmdStateSnapshot(cwd, raw) {
1071
1251
  if (resumeFileMatch)
1072
1252
  session.resume_file = resumeFileMatch[1].trim();
1073
1253
  }
1254
+ // 261007-rly D-10: the stored stopped_at is the record (the writers hold it,
1255
+ // state json reports it); the Session line beneath it may be stale. Same
1256
+ // frontmatter-first rule as the scalars above (#3265).
1257
+ const storedStoppedAt = fmScalar('stopped_at');
1258
+ if (storedStoppedAt)
1259
+ session.stopped_at = storedStoppedAt;
1074
1260
  const result = {
1075
1261
  current_phase: currentPhase,
1076
1262
  current_phase_name: currentPhaseName,
@@ -1089,21 +1275,231 @@ function cmdStateSnapshot(cwd, raw) {
1089
1275
  output(result, raw, undefined);
1090
1276
  }
1091
1277
  // ─── State Frontmatter Sync ──────────────────────────────────────────────────
1278
+ /**
1279
+ * The roadmap side of reconcileCompletedPhases (261007-ixt D-1): how many phases
1280
+ * the ROADMAP progress table marks `Complete`, or null when no table speaks.
1281
+ * Current-milestone scope first; the whole file minus `<details>` only when the
1282
+ * scope is silent — that fallback can only RAISE the count, which the min() then
1283
+ * discards. A detected progress table that marks no row Complete yields 0, not
1284
+ * null: a table that lists the phases and grants none must not be read as silence,
1285
+ * or the first Built phase of a milestone would be counted Complete.
1286
+ */
1287
+ function roadmapCompletedPhases(roadmapRaw, roadmapScope) {
1288
+ // The phases the current milestone's scope names. A progress table may also carry a
1289
+ // shipped milestone's rows (the template's milestone-grouped layout keeps them, with a
1290
+ // Milestone column): those rows grant nothing to this milestone.
1291
+ const scopedIds = new Set();
1292
+ const headingPattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)\s*:/gi;
1293
+ let hm;
1294
+ while ((hm = headingPattern.exec(roadmapScope)) !== null) {
1295
+ if (/\d/.test(hm[1]))
1296
+ scopedIds.add(normalizePhaseName(hm[1]));
1297
+ }
1298
+ const fromText = (text) => {
1299
+ const table = countCompleteProgressRows(text, scopedIds);
1300
+ if (table !== null)
1301
+ return table;
1302
+ const derived = deriveProgressFromRoadmap(text);
1303
+ if (derived.completedPhases !== null)
1304
+ return derived.completedPhases;
1305
+ return derived.totalPhases !== null ? 0 : null;
1306
+ };
1307
+ const scoped = fromText(roadmapScope);
1308
+ if (scoped !== null)
1309
+ return scoped;
1310
+ return fromText(extractCurrentMilestone(roadmapRaw));
1311
+ }
1312
+ /**
1313
+ * Column-aware read of a ROADMAP progress table: the header row names the `Phase` and
1314
+ * `Status` columns, so a table with a Milestone column (pp-core/templates/roadmap.md's
1315
+ * milestone-grouped layout) is read as well as the four-column one. Counts the data
1316
+ * rows whose Status cell is `Complete`, among the rows whose phase is in `phaseIds`
1317
+ * (all rows when `phaseIds` is empty). Returns null when no such table is present, or
1318
+ * when the table lists none of `phaseIds` — a table that says nothing about this
1319
+ * milestone's phases is silence, not a grant of zero.
1320
+ */
1321
+ function countCompleteProgressRows(text, phaseIds) {
1322
+ const cellsOf = (line) => {
1323
+ const t = line.trim();
1324
+ if (!t.startsWith('|'))
1325
+ return null;
1326
+ return t.replace(/^\|/, '').replace(/\|$/, '').split('|').map((c) => c.trim());
1327
+ };
1328
+ const lines = text.split(/\r?\n/);
1329
+ for (let i = 0; i + 1 < lines.length; i++) {
1330
+ const header = cellsOf(lines[i]);
1331
+ if (!header)
1332
+ continue;
1333
+ const phaseCol = header.findIndex((c) => /^phase$/i.test(c));
1334
+ const statusCol = header.findIndex((c) => /^status$/i.test(c));
1335
+ const separator = cellsOf(lines[i + 1]);
1336
+ if (phaseCol === -1 || statusCol === -1 || !separator || !separator.every((c) => /^:?-+:?$/.test(c)))
1337
+ continue;
1338
+ let listed = 0;
1339
+ let complete = 0;
1340
+ for (let j = i + 2; j < lines.length; j++) {
1341
+ const cells = cellsOf(lines[j]);
1342
+ if (!cells)
1343
+ break;
1344
+ const id = cells[phaseCol] ?? '';
1345
+ if (!/^\d/.test(id))
1346
+ continue;
1347
+ if (phaseIds.size > 0 && !phaseIds.has(normalizePhaseName(id)))
1348
+ continue;
1349
+ listed++;
1350
+ if (/^complete$/i.test(cells[statusCol] ?? ''))
1351
+ complete++;
1352
+ }
1353
+ return listed > 0 ? complete : null;
1354
+ }
1355
+ return null;
1356
+ }
1357
+ /**
1358
+ * Read the milestone-scoped ROADMAP once and return the two counts the progress
1359
+ * block takes from it: the digit-bearing phase headings (single source of truth
1360
+ * for total_phases, #549 — numeric 01/05.1 and project codes PROJ-42, never the
1361
+ * pure-word `## Phase Overview:`) and the progress table's Complete count (D-1).
1362
+ * Consumed by buildStateFrontmatter and cmdStateSync so both report one percent.
1363
+ */
1364
+ function readRoadmapCounts(cwd, roadmapOverride) {
1365
+ let phaseHeadingCount = 0;
1366
+ let completedPhases = null;
1367
+ try {
1368
+ const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
1369
+ const roadmapRaw = typeof roadmapOverride === 'string' ? roadmapOverride : (0, shell_command_projection_cjs_1.platformReadSync)(roadmapPath);
1370
+ if (roadmapRaw !== null) {
1371
+ const roadmapScope = extractCurrentMilestone(roadmapRaw, cwd);
1372
+ const phaseHeadingPattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)\s*:/gi;
1373
+ let m;
1374
+ while ((m = phaseHeadingPattern.exec(roadmapScope)) !== null) {
1375
+ if (/\d/.test(m[1]))
1376
+ phaseHeadingCount++;
1377
+ }
1378
+ completedPhases = roadmapCompletedPhases(roadmapRaw, roadmapScope);
1379
+ }
1380
+ }
1381
+ catch { /* fall through: callers use the disk counts */ }
1382
+ return { phaseHeadingCount, completedPhases };
1383
+ }
1384
+ /**
1385
+ * The current milestone's progress as the disk and the ROADMAP report it — the
1386
+ * ONE scan both progress surfaces read (the frontmatter block in
1387
+ * buildStateFrontmatter and the body `Progress:` bar in cmdStateSync), so they
1388
+ * cannot count different directories (261007-ixt D-1; before this, state sync
1389
+ * scanned every directory under phases/, prior milestones included, while the
1390
+ * frontmatter scanned only the milestone's). Returns null when there is no phases
1391
+ * directory or the scan fails. Raw counts only: each consumer reconciles
1392
+ * completed phases through reconcileCompletedPhases.
1393
+ *
1394
+ * Cached per cwd per process (#1967) unless a ROADMAP override is given — that is
1395
+ * the text a transaction is ABOUT to write (phase complete flips the row to
1396
+ * Complete in the same file set as STATE.md), so its counts must neither come
1397
+ * from nor go into the cache.
1398
+ */
1399
+ /**
1400
+ * scanMilestoneDisk's answer when every candidate directory belongs to a CLOSED
1401
+ * milestone (261007-ixt D-9): the disk holds nothing about the current milestone, so
1402
+ * no consumer derives anything from it — distinct from `null` (no phases directory,
1403
+ * or the scan failed), which keeps each consumer's long-standing fallback.
1404
+ */
1405
+ const CLOSED_MILESTONE_ONLY = 'closed-milestone-only';
1406
+ function scanMilestoneDisk(cwd, roadmapOverride) {
1407
+ try {
1408
+ const phasesDir = planningPaths(cwd).phases;
1409
+ if (!node_fs_1.default.existsSync(phasesDir))
1410
+ return null;
1411
+ const useCache = typeof roadmapOverride !== 'string';
1412
+ const cached = useCache ? _diskScanCache.get(cwd) : undefined;
1413
+ if (cached)
1414
+ return cached;
1415
+ const isDirInMilestone = getMilestonePhaseFilter(cwd);
1416
+ // 261007-ixt D-9: a directory a closed milestone's archived ROADMAP names (and the
1417
+ // current scope does not) is that milestone's, never the current one's.
1418
+ const isDirInClosedMilestone = getClosedMilestonePhaseDirFilter(cwd);
1419
+ const inMilestoneDirs = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true })
1420
+ .filter(e => e.isDirectory()).map(e => e.name)
1421
+ .filter(isDirInMilestone);
1422
+ const allMatchingDirs = inMilestoneDirs.filter(dir => !isDirInClosedMilestone(dir));
1423
+ // Every candidate belonged to a closed milestone: the disk holds nothing about the
1424
+ // current one, so nothing is derived and the stored block stands (the #905 fallback
1425
+ // on write, the same fallback in cmdStateJson on read). Counting zero instead would
1426
+ // overwrite a true closed-milestone record with 0/0.
1427
+ if (inMilestoneDirs.length > 0 && allMatchingDirs.length === 0)
1428
+ return CLOSED_MILESTONE_ONLY;
1429
+ // Bug #2445: when stale phase dirs from a prior milestone remain in
1430
+ // .planning/phases/ alongside new dirs with the same phase number,
1431
+ // de-duplicate by normalized phase number keeping the most recently
1432
+ // modified dir. This prevents double-counting (e.g. two "Phase 1" dirs).
1433
+ const seenPhaseNums = new Map(); // normalizedNum -> dirName
1434
+ for (const dir of allMatchingDirs) {
1435
+ const m = dir.match(/^0*(\d+[A-Za-z]?(?:\.\d+)*)/);
1436
+ const key = m ? m[1].toLowerCase() : dir;
1437
+ if (!seenPhaseNums.has(key)) {
1438
+ seenPhaseNums.set(key, dir);
1439
+ }
1440
+ else {
1441
+ // Keep the dir that is newer on disk (more likely current milestone)
1442
+ try {
1443
+ const existing = node_path_1.default.join(phasesDir, seenPhaseNums.get(key));
1444
+ const candidate = node_path_1.default.join(phasesDir, dir);
1445
+ if (node_fs_1.default.statSync(candidate).mtimeMs > node_fs_1.default.statSync(existing).mtimeMs) {
1446
+ seenPhaseNums.set(key, dir);
1447
+ }
1448
+ }
1449
+ catch { /* keep existing on stat error */ }
1450
+ }
1451
+ }
1452
+ const phaseDirs = [...seenPhaseNums.values()];
1453
+ let diskTotalPlans = 0;
1454
+ let diskTotalSummaries = 0;
1455
+ let diskCompletedPhases = 0;
1456
+ for (const dir of phaseDirs) {
1457
+ const { planCount, summaryCount, completed } = scanPhasePlans(node_path_1.default.join(phasesDir, dir));
1458
+ diskTotalPlans += planCount;
1459
+ diskTotalSummaries += summaryCount;
1460
+ if (completed)
1461
+ diskCompletedPhases++;
1462
+ }
1463
+ // ONE read of the ROADMAP feeds both counts it contributes: the phase
1464
+ // headings (total_phases, #549) and the progress table's Complete rows (the
1465
+ // cap on completed_phases, D-1). When the ROADMAP is unreadable both fall
1466
+ // through: phaseDirs.length and the disk count.
1467
+ const roadmap = readRoadmapCounts(cwd, roadmapOverride);
1468
+ const scan = {
1469
+ totalPhases: roadmap.phaseHeadingCount > 0
1470
+ ? Math.max(phaseDirs.length, roadmap.phaseHeadingCount)
1471
+ : phaseDirs.length,
1472
+ diskCompletedPhases,
1473
+ roadmapCompletedPhases: roadmap.completedPhases,
1474
+ totalPlans: diskTotalPlans,
1475
+ completedPlans: diskTotalSummaries,
1476
+ };
1477
+ if (useCache)
1478
+ _diskScanCache.set(cwd, scan);
1479
+ return scan;
1480
+ }
1481
+ catch {
1482
+ return null;
1483
+ }
1484
+ }
1092
1485
  /**
1093
1486
  * Extract machine-readable fields from STATE.md markdown body and build
1094
1487
  * a YAML frontmatter object. Allows hooks and scripts to read state
1095
1488
  * reliably via `state json` instead of fragile regex parsing.
1096
1489
  */
1097
- function buildStateFrontmatter(bodyContent, cwd) {
1098
- const prosePhase = parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(bodyContent, 'Phase'));
1490
+ function buildStateFrontmatter(bodyContent, cwd, roadmapOverride) {
1491
+ // 261007-ixt D-2: the three positional source fields are read from ## Current
1492
+ // Position first (extractPositionalField), so history elsewhere in the body
1493
+ // cannot stand in for the current position.
1494
+ const prosePhase = parseProsePhaseField((0, state_document_cjs_1.extractPositionalField)(bodyContent, 'Phase'));
1099
1495
  const currentPhase = (0, state_document_cjs_1.stateExtractField)(bodyContent, 'Current Phase') ?? prosePhase.phase;
1100
1496
  const currentPhaseName = (0, state_document_cjs_1.stateExtractField)(bodyContent, 'Current Phase Name') ?? prosePhase.name;
1101
1497
  const currentPlan = (0, state_document_cjs_1.stateExtractField)(bodyContent, 'Current Plan');
1102
1498
  const totalPhasesRaw = (0, state_document_cjs_1.stateExtractField)(bodyContent, 'Total Phases');
1103
1499
  const totalPlansRaw = (0, state_document_cjs_1.stateExtractField)(bodyContent, 'Total Plans in Phase');
1104
- const status = (0, state_document_cjs_1.stateExtractField)(bodyContent, 'Status');
1500
+ const status = (0, state_document_cjs_1.extractPositionalField)(bodyContent, 'Status');
1105
1501
  const progressRaw = (0, state_document_cjs_1.stateExtractField)(bodyContent, 'Progress');
1106
- const rawLastActivity = (0, state_document_cjs_1.stateExtractField)(bodyContent, 'Last Activity') ?? (0, state_document_cjs_1.stateExtractField)(bodyContent, 'Last activity');
1502
+ const rawLastActivity = (0, state_document_cjs_1.extractPositionalField)(bodyContent, 'Last Activity', 'Last activity');
1107
1503
  const proseLastActivity = parseProseLastActivityField(rawLastActivity);
1108
1504
  const lastActivity = proseLastActivity.date ?? rawLastActivity;
1109
1505
  const lastActivityDesc = (0, state_document_cjs_1.stateExtractField)(bodyContent, 'Last Activity Description') ?? proseLastActivity.description;
@@ -1121,9 +1517,14 @@ function buildStateFrontmatter(bodyContent, cwd) {
1121
1517
  let milestoneName = null;
1122
1518
  if (cwd) {
1123
1519
  try {
1124
- const info = getMilestoneInfo(cwd);
1125
- milestone = info.version;
1126
- milestoneName = info.name;
1520
+ // 261007-ixt W4: a stored record carries only a DECLARED milestone. When nothing
1521
+ // declares one, no milestone key is derived (an absent key is no claim), rather
1522
+ // than the first version the ROADMAP happens to mention.
1523
+ const info = getDeclaredMilestoneInfo(cwd);
1524
+ if (info) {
1525
+ milestone = info.version;
1526
+ milestoneName = info.name;
1527
+ }
1127
1528
  }
1128
1529
  catch { /* intentionally empty */ }
1129
1530
  }
@@ -1131,93 +1532,23 @@ function buildStateFrontmatter(bodyContent, cwd) {
1131
1532
  let completedPhases = null;
1132
1533
  let totalPlans = totalPlansRaw ? parseInt(totalPlansRaw, 10) : null;
1133
1534
  let completedPlans = null;
1535
+ // 261007-ixt D-9: every phase directory belongs to a closed milestone → derive no
1536
+ // progress block at all (not even from body `Total Phases:` / `Progress:` fields), so
1537
+ // syncStateFrontmatter keeps the stored one rather than a partial or zeroed block.
1538
+ let closedMilestoneOnly = false;
1134
1539
  if (cwd) {
1135
- try {
1136
- const phasesDir = planningPaths(cwd).phases;
1137
- if (node_fs_1.default.existsSync(phasesDir)) {
1138
- // Use cached disk scan when available — avoids N+1 readdirSync calls
1139
- // on repeated buildStateFrontmatter invocations within the same process (#1967)
1140
- let cached = _diskScanCache.get(cwd);
1141
- if (!cached) {
1142
- const isDirInMilestone = getMilestonePhaseFilter(cwd);
1143
- const allMatchingDirs = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true })
1144
- .filter(e => e.isDirectory()).map(e => e.name)
1145
- .filter(isDirInMilestone);
1146
- // Bug #2445: when stale phase dirs from a prior milestone remain in
1147
- // .planning/phases/ alongside new dirs with the same phase number,
1148
- // de-duplicate by normalized phase number keeping the most recently
1149
- // modified dir. This prevents double-counting (e.g. two "Phase 1" dirs).
1150
- const seenPhaseNums = new Map(); // normalizedNum -> dirName
1151
- for (const dir of allMatchingDirs) {
1152
- const m = dir.match(/^0*(\d+[A-Za-z]?(?:\.\d+)*)/);
1153
- const key = m ? m[1].toLowerCase() : dir;
1154
- if (!seenPhaseNums.has(key)) {
1155
- seenPhaseNums.set(key, dir);
1156
- }
1157
- else {
1158
- // Keep the dir that is newer on disk (more likely current milestone)
1159
- try {
1160
- const existing = node_path_1.default.join(phasesDir, seenPhaseNums.get(key));
1161
- const candidate = node_path_1.default.join(phasesDir, dir);
1162
- if (node_fs_1.default.statSync(candidate).mtimeMs > node_fs_1.default.statSync(existing).mtimeMs) {
1163
- seenPhaseNums.set(key, dir);
1164
- }
1165
- }
1166
- catch { /* keep existing on stat error */ }
1167
- }
1168
- }
1169
- const phaseDirs = [...seenPhaseNums.values()];
1170
- let diskTotalPlans = 0;
1171
- let diskTotalSummaries = 0;
1172
- let diskCompletedPhases = 0;
1173
- for (const dir of phaseDirs) {
1174
- const phaseDir = node_path_1.default.join(phasesDir, dir);
1175
- const { planCount, summaryCount, completed } = scanPhasePlans(phaseDir);
1176
- diskTotalPlans += planCount;
1177
- diskTotalSummaries += summaryCount;
1178
- if (completed)
1179
- diskCompletedPhases++;
1180
- }
1181
- // Count phase headings from ROADMAP using a digit-containing pattern
1182
- // that matches both numeric phases (01, 05.1) and project-code phases
1183
- // (PROJ-42, CK-05) but excludes pure-word section headers like
1184
- // `## Phase Overview:` or `## Phase Details:` — single source of
1185
- // truth for total_phases (#549).
1186
- let roadmapPhaseCount = 0;
1187
- try {
1188
- const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
1189
- const roadmapRaw = (0, shell_command_projection_cjs_1.platformReadSync)(roadmapPath);
1190
- if (roadmapRaw !== null) {
1191
- const roadmapScope = extractCurrentMilestone(roadmapRaw, cwd);
1192
- const phaseHeadingPattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)\s*:/gi;
1193
- let m;
1194
- while ((m = phaseHeadingPattern.exec(roadmapScope)) !== null) {
1195
- // Only count tokens that contain at least one digit — excludes
1196
- // pure-word section headings (Overview, Details) while keeping
1197
- // numeric phases (01, 05.1) and project-code IDs (PROJ-42).
1198
- if (/\d/.test(m[1]))
1199
- roadmapPhaseCount++;
1200
- }
1201
- }
1202
- }
1203
- catch { /* fall through: phaseDirs.length used as sole count */ }
1204
- cached = {
1205
- totalPhases: roadmapPhaseCount > 0
1206
- ? Math.max(phaseDirs.length, roadmapPhaseCount)
1207
- : phaseDirs.length,
1208
- completedPhases: diskCompletedPhases,
1209
- totalPlans: diskTotalPlans,
1210
- completedPlans: diskTotalSummaries,
1211
- };
1212
- _diskScanCache.set(cwd, cached);
1213
- }
1214
- totalPhases = cached.totalPhases;
1215
- completedPhases = cached.completedPhases;
1216
- totalPlans = cached.totalPlans;
1217
- completedPlans = cached.completedPlans;
1218
- }
1540
+ const scan = scanMilestoneDisk(cwd, roadmapOverride);
1541
+ if (scan === CLOSED_MILESTONE_ONLY) {
1542
+ closedMilestoneOnly = true;
1543
+ }
1544
+ else if (scan) {
1545
+ totalPhases = scan.totalPhases;
1546
+ // 261007-ixt D-1: the disk scan counts BUILT phases; the ROADMAP table says
1547
+ // which are COMPLETE. The record claims no more than the table grants.
1548
+ completedPhases = (0, state_document_cjs_1.reconcileCompletedPhases)(scan.diskCompletedPhases, scan.roadmapCompletedPhases);
1549
+ totalPlans = scan.totalPlans;
1550
+ completedPlans = scan.completedPlans;
1219
1551
  }
1220
- catch { /* intentionally empty */ }
1221
1552
  }
1222
1553
  // Derive percent from disk counts when available (ground truth).
1223
1554
  // Uses min(plan_fraction, phase_fraction) via computeProgressPercent so that
@@ -1253,6 +1584,13 @@ function buildStateFrontmatter(bodyContent, cwd) {
1253
1584
  if (lastActivityDesc)
1254
1585
  fm['last_activity_desc'] = lastActivityDesc;
1255
1586
  const progress = {};
1587
+ if (closedMilestoneOnly) {
1588
+ totalPhases = null;
1589
+ completedPhases = null;
1590
+ totalPlans = null;
1591
+ completedPlans = null;
1592
+ progressPercent = null;
1593
+ }
1256
1594
  if (totalPhases !== null)
1257
1595
  progress['total_phases'] = totalPhases;
1258
1596
  if (completedPhases !== null)
@@ -1280,12 +1618,23 @@ function stripFrontmatter(content) {
1280
1618
  }
1281
1619
  return result;
1282
1620
  }
1283
- function syncStateFrontmatter(content, cwd) {
1621
+ /**
1622
+ * @param roadmapOverride ROADMAP text to count from instead of the file on disk —
1623
+ * for a caller that writes ROADMAP.md and STATE.md as one file set (phase
1624
+ * complete), so the frontmatter reflects the roadmap the transaction commits.
1625
+ */
1626
+ function syncStateFrontmatter(content, cwd, roadmapOverride, activityFieldSetByCaller, stoppedAtFieldSetByCaller) {
1284
1627
  // Read existing frontmatter BEFORE stripping — it may contain values
1285
1628
  // that the body no longer has (e.g., Status field removed by an agent).
1286
1629
  const existingFm = extractFrontmatter(content);
1287
1630
  const body = stripFrontmatter(content);
1288
- const derivedFm = buildStateFrontmatter(body, cwd);
1631
+ // 261007-ixt D-10: a body `Last activity:` line that lags the frontmatter must not move
1632
+ // the record's date backwards — on every writer path, guarded or not. The hold is for
1633
+ // a DERIVED date only: when the calling verb itself set the `Last activity` field
1634
+ // (`state update "Last activity" …`), that explicit value flows to the frontmatter so
1635
+ // the body and the frontmatter agree.
1636
+ const built = buildStateFrontmatter(body, cwd, roadmapOverride);
1637
+ const derivedFm = activityFieldSetByCaller ? built : (0, state_document_cjs_1.holdNewerActivityDate)(existingFm, built);
1289
1638
  // Preserve existing frontmatter status when body-derived status is 'unknown'.
1290
1639
  // This prevents a missing Status: field in the body from overwriting a
1291
1640
  // previously valid status (e.g., 'executing' → 'unknown').
@@ -1328,6 +1677,31 @@ function syncStateFrontmatter(content, cwd) {
1328
1677
  if (!derivedFm['stopped_at'] && existingFm['stopped_at']) {
1329
1678
  derivedFm['stopped_at'] = existingFm['stopped_at'];
1330
1679
  }
1680
+ // 261007-rly D-7: THE stopped_at hold, one owner for every writer
1681
+ // (writeStateMd, readModifyWriteStateMd, phase complete's transaction). A stored
1682
+ // stopped_at is an operator-authored record; a write that did not change the
1683
+ // Session-scoped `Stopped at:` line must not replace it with whatever that line
1684
+ // says — measured on the v1.1 close, where the stale "Phase 12 context gathered"
1685
+ // replaced the handover note (todos/done/milestone-complete-writes-a-poor-
1686
+ // milestone-record.md). This is the #1230 delta heuristic, which used to live
1687
+ // only in readModifyWriteStateMd: when THIS write changed the line
1688
+ // (`state record-session --stopped-at`, `milestone complete --stopped-at`) the
1689
+ // caller passes true and the new value flows. Undefined holds — fail-safe for a
1690
+ // future caller that forgets the flag. The absent-derived #905 fallback above
1691
+ // stays as it was; the two agree whenever both apply.
1692
+ if (!stoppedAtFieldSetByCaller &&
1693
+ typeof existingFm['stopped_at'] === 'string' &&
1694
+ existingFm['stopped_at'].length > 0 &&
1695
+ derivedFm['stopped_at'] !== existingFm['stopped_at']) {
1696
+ derivedFm['stopped_at'] = existingFm['stopped_at'];
1697
+ }
1698
+ // 261007-ixt W4: when nothing declares a current milestone, the writer derives
1699
+ // no milestone — the stored one (and its name) stands, as for the keys above.
1700
+ if (!derivedFm['milestone'] && existingFm['milestone']) {
1701
+ derivedFm['milestone'] = existingFm['milestone'];
1702
+ if (existingFm['milestone_name'])
1703
+ derivedFm['milestone_name'] = existingFm['milestone_name'];
1704
+ }
1331
1705
  if (!derivedFm['paused_at'] && existingFm['paused_at']) {
1332
1706
  derivedFm['paused_at'] = existingFm['paused_at'];
1333
1707
  }
@@ -1488,16 +1862,47 @@ function withStateLock(statePath, fn) {
1488
1862
  * @param clock
1489
1863
  * Optional clock seam; defaults to realClock. Passed through to acquireStateLock.
1490
1864
  */
1491
- function writeStateMd(statePath, content, cwd, clock) {
1865
+ /** The positional `Last activity` source field — the one D-10's hold compares. */
1866
+ function lastActivityField(body) {
1867
+ return (0, state_document_cjs_1.extractPositionalField)(body, 'Last Activity', 'Last activity');
1868
+ }
1869
+ /**
1870
+ * The Session-scoped `Stopped At` source field — the one the stopped_at hold
1871
+ * compares (261007-rly D-7). Exactly the scope buildStateFrontmatter derives
1872
+ * `stopped_at` from (#2444/#1101): the `## Session` / `## Session Continuity`
1873
+ * section, else the whole body.
1874
+ */
1875
+ function sessionStoppedAtField(body) {
1876
+ const sessionMatch = matchSessionSection(body);
1877
+ const scope = sessionMatch ? sessionMatch[1] : body;
1878
+ return (0, state_document_cjs_1.stateExtractField)(scope, 'Stopped At') || (0, state_document_cjs_1.stateExtractField)(scope, 'Stopped at');
1879
+ }
1880
+ function writeStateMd(statePath, content, cwd, clock, options) {
1492
1881
  // Invalidate disk scan cache before computing new frontmatter — the write
1493
1882
  // may create new PLAN/SUMMARY files that buildStateFrontmatter must see.
1494
1883
  // Safe for any calling pattern, not just short-lived CLI processes (#1967).
1495
1884
  if (cwd)
1496
1885
  _diskScanCache.delete(cwd);
1497
- const synced = syncStateFrontmatter(content, cwd);
1498
1886
  const lockPath = acquireStateLock(statePath, clock);
1499
1887
  try {
1888
+ // 261007-ixt D-3: diff against the file ON DISK, not the caller's `content` —
1889
+ // a caller may have edited the frontmatter text itself (a case-insensitive
1890
+ // `Status:` replace lands on the YAML `status:` line), and a file that did not
1891
+ // exist (health --repair) has no frontmatter to compare. The report describes
1892
+ // what this write changed on disk.
1893
+ const onDisk = (0, shell_command_projection_cjs_1.platformReadSync)(statePath);
1894
+ const before = onDisk === null ? {} : extractFrontmatter(onDisk);
1895
+ // D-10: did the caller set the Last activity field (vs. the file on disk)?
1896
+ const activityFieldSetByCaller = onDisk !== null &&
1897
+ lastActivityField(stripFrontmatter(onDisk)) !== lastActivityField(stripFrontmatter(content));
1898
+ // 261007-rly D-7: did the caller change the Session-scoped Stopped at line?
1899
+ // An explicit value (milestone complete --stopped-at) flows even when the
1900
+ // Session line already read the same text.
1901
+ const stoppedAtFieldSetByCaller = options?.stoppedAtSetByCaller === true || (onDisk !== null &&
1902
+ sessionStoppedAtField(stripFrontmatter(onDisk)) !== sessionStoppedAtField(stripFrontmatter(content)));
1903
+ const synced = syncStateFrontmatter(content, cwd, undefined, activityFieldSetByCaller, stoppedAtFieldSetByCaller);
1500
1904
  (0, shell_command_projection_cjs_1.platformWriteSync)(statePath, synced);
1905
+ return { written: true, frontmatter_updated: (0, state_document_cjs_1.diffFrontmatterFields)(before, extractFrontmatter(synced)) };
1501
1906
  }
1502
1907
  finally {
1503
1908
  releaseStateLock(lockPath);
@@ -1543,14 +1948,31 @@ function readModifyWriteStateMd(statePath, transformFn, cwd, options, clock) {
1543
1948
  // key in the frontmatter block cannot shadow the body field we are tracking.
1544
1949
  const preBody = stripFrontmatter(content);
1545
1950
  const preFmSnapshot = extractFrontmatter(content);
1546
- const preBodyStatus = (0, state_document_cjs_1.stateExtractField)(preBody, 'Status');
1547
- // Bug #1230 / Change B: scope stopped_at delta to the ## Session section,
1548
- // mirroring buildStateFrontmatter's sessionBodyScope logic (line ~1172).
1549
- // A stale "Stopped at:" in a non-Session section (e.g. Session Continuity
1550
- // Archive prose) must not interfere with the delta comparison.
1551
- const preSessionMatch = matchSessionSection(preBody);
1552
- const preSessionScope = preSessionMatch ? preSessionMatch[1] : preBody;
1553
- const preBodyStoppedAt = (0, state_document_cjs_1.stateExtractField)(preSessionScope, 'Stopped At') || (0, state_document_cjs_1.stateExtractField)(preSessionScope, 'Stopped at');
1951
+ // 261007-ixt D-2: the delta unit reads each source field exactly as the
1952
+ // derivation does (extractPositionalField), so "this write did not change the
1953
+ // field the key derives from" is judged on the same line the key comes from.
1954
+ const preBodyStatus = (0, state_document_cjs_1.extractPositionalField)(preBody, 'Status');
1955
+ // Bug #1230 / Change B: the stopped_at delta is scoped to the ## Session
1956
+ // section, read through the same owner the derivation uses (261007-rly D-7).
1957
+ const preBodyStoppedAt = sessionStoppedAtField(preBody);
1958
+ // 2026-09-20: the SAME #1230 delta contract, extended to the position-derived
1959
+ // fields. #1230 applied it to `status` and `stopped_at` only; `current_phase`,
1960
+ // `current_phase_name` and `last_activity_desc` are ALSO re-derived from the
1961
+ // body by syncStateFrontmatter and were left without it. Measured consequence:
1962
+ // on a STATE.md whose ## Current Position had drifted behind its frontmatter,
1963
+ // a write that never touched that section still propagated the stale body into
1964
+ // frontmatter — observed moving `current_phase` from 10 to 09 and restoring a
1965
+ // two-phase-old `last_activity_desc`, while the command exited 0. Snapshot the
1966
+ // whole section: these three fields all derive from it, so one scope is the
1967
+ // honest delta unit.
1968
+ // Delta unit is the SOURCE FIELD each frontmatter key derives from, exactly as
1969
+ // #1230 used stateExtractField(preBody, 'Status') — not the enclosing section.
1970
+ // A section-scoped delta is too coarse: these verbs legitimately rewrite the
1971
+ // `Status:` line inside ## Current Position, which would disable the guard for
1972
+ // every other field in it. (Measured: the first attempt at this fix did
1973
+ // precisely that and the clobber still reproduced.)
1974
+ const preBodyPhaseField = (0, state_document_cjs_1.extractPositionalField)(preBody, 'Phase');
1975
+ const preBodyActivityField = (0, state_document_cjs_1.extractPositionalField)(preBody, 'Last Activity', 'Last activity');
1554
1976
  const modified = transformFn(content);
1555
1977
  // Bug #948: no-op guard — if the transform produced no change, do NOT write
1556
1978
  // the file. An unconditional write would bump `last_updated`, reset
@@ -1560,9 +1982,13 @@ function readModifyWriteStateMd(statePath, transformFn, cwd, options, clock) {
1560
1982
  // content already returns the mutated string, and callers that detect a
1561
1983
  // no-op explicitly return the original content unchanged.
1562
1984
  if (modified === content) {
1563
- return;
1985
+ return { written: false, frontmatter_updated: [] };
1564
1986
  }
1565
- let synced = syncStateFrontmatter(modified, cwd);
1987
+ // D-10: the transform set the Last activity field iff the field differs.
1988
+ // 261007-rly D-7: likewise the Session-scoped Stopped at line — the hold for
1989
+ // stopped_at lives in syncStateFrontmatter now, keyed on this delta.
1990
+ let synced = syncStateFrontmatter(modified, cwd, undefined, lastActivityField(stripFrontmatter(modified)) !== preBodyActivityField, options?.stoppedAtSetByCaller === true ||
1991
+ sessionStoppedAtField(stripFrontmatter(modified)) !== preBodyStoppedAt);
1566
1992
  // Compute postFm once and apply BOTH the progress-restore (when !resync)
1567
1993
  // AND the status/stopped_at preservation (#1230) before reconstructing.
1568
1994
  // This avoids double-wrapping the frontmatter block.
@@ -1571,12 +1997,9 @@ function readModifyWriteStateMd(statePath, transformFn, cwd, options, clock) {
1571
1997
  // Use `modified` (not `synced`): syncStateFrontmatter only rewrites the frontmatter block, so the body is identical in both — and we need the body the transform produced.
1572
1998
  // Strip frontmatter so the YAML status key cannot shadow the body field.
1573
1999
  const postBody = stripFrontmatter(modified);
1574
- const postBodyStatus = (0, state_document_cjs_1.stateExtractField)(postBody, 'Status');
1575
- // Bug #1230 / Change B: scope stopped_at delta to the ## Session section,
1576
- // consistent with the pre-transform snapshot above and buildStateFrontmatter.
1577
- const postSessionMatch = matchSessionSection(postBody);
1578
- const postSessionScope = postSessionMatch ? postSessionMatch[1] : postBody;
1579
- const postBodyStoppedAt = (0, state_document_cjs_1.stateExtractField)(postSessionScope, 'Stopped At') || (0, state_document_cjs_1.stateExtractField)(postSessionScope, 'Stopped at');
2000
+ const postBodyStatus = (0, state_document_cjs_1.extractPositionalField)(postBody, 'Status');
2001
+ const postBodyPhaseField = (0, state_document_cjs_1.extractPositionalField)(postBody, 'Phase');
2002
+ const postBodyActivityField = (0, state_document_cjs_1.extractPositionalField)(postBody, 'Last Activity', 'Last activity');
1580
2003
  let mutated = false;
1581
2004
  const postFm = extractFrontmatter(synced);
1582
2005
  if (needsProgressRestore) {
@@ -1601,13 +2024,33 @@ function readModifyWriteStateMd(statePath, transformFn, cwd, options, clock) {
1601
2024
  postFm['status'] = preFmSnapshot['status'];
1602
2025
  mutated = true;
1603
2026
  }
1604
- // Bug #1230: same delta heuristic for stopped_at.
1605
- if (postBodyStoppedAt === preBodyStoppedAt &&
1606
- typeof preFmSnapshot['stopped_at'] === 'string' &&
1607
- preFmSnapshot['stopped_at'].length > 0 &&
1608
- postFm['stopped_at'] !== preFmSnapshot['stopped_at']) {
1609
- postFm['stopped_at'] = preFmSnapshot['stopped_at'];
1610
- mutated = true;
2027
+ // Bug #1230's stopped_at copy of this heuristic moved into syncStateFrontmatter
2028
+ // (261007-rly D-7), where writeStateMd reaches it too; it is passed the same
2029
+ // Session-scoped delta above.
2030
+ // 2026-09-20: position-derived fields get the same treatment as status and
2031
+ // stopped_at above. When THIS write did not change the ## Current Position
2032
+ // section, a frontmatter value that was set deliberately must not be reverted
2033
+ // to whatever a stale section happens to derive. Conservative by construction:
2034
+ // it only ever RESTORES a pre-existing frontmatter value, and only when the
2035
+ // body source it derives from is byte-identical before and after.
2036
+ const preserveFm = (key) => {
2037
+ const prev = preFmSnapshot[key];
2038
+ if (typeof prev === 'string' && prev.length > 0 && postFm[key] !== prev) {
2039
+ postFm[key] = prev;
2040
+ mutated = true;
2041
+ }
2042
+ };
2043
+ // `current_phase` / `current_phase_name` derive from the body's `Phase:` field
2044
+ // (parseProsePhaseField). If this write did not change that field, a
2045
+ // deliberately-set frontmatter value must survive.
2046
+ if (postBodyPhaseField === preBodyPhaseField) {
2047
+ preserveFm('current_phase');
2048
+ preserveFm('current_phase_name');
2049
+ }
2050
+ // `last_activity_desc` derives from the `Last activity:` field
2051
+ // (parseProseLastActivityField().description).
2052
+ if (postBodyActivityField === preBodyActivityField) {
2053
+ preserveFm('last_activity_desc');
1611
2054
  }
1612
2055
  if (mutated) {
1613
2056
  const yamlStr = reconstructFrontmatter(postFm);
@@ -1615,6 +2058,9 @@ function readModifyWriteStateMd(statePath, transformFn, cwd, options, clock) {
1615
2058
  synced = `---\n${yamlStr}\n---\n\n${body}`;
1616
2059
  }
1617
2060
  (0, shell_command_projection_cjs_1.platformWriteSync)(statePath, synced);
2061
+ // 261007-ixt D-3: diffed AFTER the #1230/25c9760 guards and the resync:false
2062
+ // restore, so the report describes the frontmatter that hit the disk.
2063
+ return { written: true, frontmatter_updated: (0, state_document_cjs_1.diffFrontmatterFields)(preFmSnapshot, extractFrontmatter(synced)) };
1618
2064
  }
1619
2065
  finally {
1620
2066
  releaseStateLock(lockPath);
@@ -1632,14 +2078,26 @@ function cmdStateJson(cwd, raw) {
1632
2078
  // Always rebuild from body + disk so progress counters reflect current state.
1633
2079
  // Returning cached frontmatter directly causes stale percent/completed_plans
1634
2080
  // when SUMMARY files were added after the last STATE.md write (#1589).
1635
- const built = buildStateFrontmatter(body, cwd);
2081
+ // 261007-ixt W3a: the same date hold the writer applies, so read and write agree.
2082
+ const built = (0, state_document_cjs_1.holdNewerActivityDate)(existingFm, buildStateFrontmatter(body, cwd));
1636
2083
  // Preserve frontmatter-only fields that cannot be recovered from the body.
1637
- if (existingFm && existingFm['stopped_at'] && !built['stopped_at']) {
2084
+ // 261007-rly D-7: a stored stopped_at wins over the Session line whenever it
2085
+ // exists — the writer holds it there (syncStateFrontmatter), so the read agrees.
2086
+ if (existingFm && typeof existingFm['stopped_at'] === 'string' && existingFm['stopped_at'].length > 0) {
2087
+ built['stopped_at'] = existingFm['stopped_at'];
2088
+ }
2089
+ else if (existingFm && existingFm['stopped_at'] && !built['stopped_at']) {
1638
2090
  built['stopped_at'] = existingFm['stopped_at'];
1639
2091
  }
1640
2092
  if (existingFm && existingFm['paused_at'] && !built['paused_at']) {
1641
2093
  built['paused_at'] = existingFm['paused_at'];
1642
2094
  }
2095
+ // 261007-ixt W4: the same milestone fallback syncStateFrontmatter applies.
2096
+ if (existingFm && !built['milestone'] && existingFm['milestone']) {
2097
+ built['milestone'] = existingFm['milestone'];
2098
+ if (existingFm['milestone_name'])
2099
+ built['milestone_name'] = existingFm['milestone_name'];
2100
+ }
1643
2101
  // Preserve existing status when body-derived status is 'unknown' (same logic as syncStateFrontmatter).
1644
2102
  if (built['status'] === 'unknown' && existingFm && existingFm['status'] && existingFm['status'] !== 'unknown') {
1645
2103
  built['status'] = existingFm['status'];
@@ -1661,6 +2119,12 @@ function cmdStateJson(cwd, raw) {
1661
2119
  if (existingFm && (0, state_document_cjs_1.shouldPreserveExistingProgress)(existingFm['progress'], built['progress'])) {
1662
2120
  built['progress'] = (0, state_document_cjs_1.normalizeProgressNumbers)(existingFm['progress']);
1663
2121
  }
2122
+ // 261007-ixt D-9: when the disk derives no progress block (e.g. every phase dir
2123
+ // belongs to a closed milestone), report the stored one — the same #905 fallback
2124
+ // syncStateFrontmatter applies on write, so the read and the write agree.
2125
+ if (existingFm && !built['progress'] && existingFm['progress']) {
2126
+ built['progress'] = (0, state_document_cjs_1.normalizeProgressNumbers)(existingFm['progress']);
2127
+ }
1664
2128
  output(built, raw, JSON.stringify(built, null, 2));
1665
2129
  }
1666
2130
  /**
@@ -1677,7 +2141,7 @@ function cmdStateBeginPhase(cwd, phaseNumber, phaseName, planCount, raw) {
1677
2141
  }
1678
2142
  const today = clock_cjs_1.realClock.today();
1679
2143
  const updated = [];
1680
- readModifyWriteStateMd(statePath, (content) => {
2144
+ const fmReport = readModifyWriteStateMd(statePath, (content) => {
1681
2145
  // Bug #1255: all body-field replacements must operate on the body only
1682
2146
  // (frontmatter stripped), not on the full content. When the full content is
1683
2147
  // passed to stateReplaceField the YAML `status: planning` key matches the
@@ -1848,7 +2312,7 @@ function cmdStateBeginPhase(cwd, phaseNumber, phaseName, planCount, raw) {
1848
2312
  }
1849
2313
  return reassemble(body);
1850
2314
  }, cwd);
1851
- output({ updated, phase: phaseNumber, phase_name: phaseName || null, plan_count: planCount || null }, raw, updated.length > 0 ? 'true' : 'false');
2315
+ output({ updated, phase: phaseNumber, phase_name: phaseName || null, plan_count: planCount || null, frontmatter_updated: fmReport.frontmatter_updated }, raw, updated.length > 0 ? 'true' : 'false');
1852
2316
  }
1853
2317
  /**
1854
2318
  * Write a WAITING.json signal file when GSD hits a decision point.
@@ -1899,26 +2363,66 @@ function cmdSignalResume(cwd, raw) {
1899
2363
  // ─── Gate Functions (STATE.md consistency enforcement) ────────────────────────
1900
2364
  /**
1901
2365
  * Update the ## Performance Metrics section in STATE.md content.
1902
- * Increments Velocity totals and upserts a By Phase table row.
2366
+ * Recomputes the Velocity total and upserts a By Phase table row.
1903
2367
  * Returns modified content string.
2368
+ *
2369
+ * `Total plans completed` is a COUNT, NOT AN ACCUMULATOR (D-08-40,
2370
+ * 08-VERIFICATION.md SC4): the number of executed plans — `*-SUMMARY.md` files —
2371
+ * across the CURRENT MILESTONE's phase directories, recomputed on every call.
2372
+ *
2373
+ * It used to be `prevTotal + summaryCount`, read back out of the STATE text.
2374
+ * That is wrong because this function is not called once per phase:
2375
+ * `execute-phase.md` § update_roadmap runs `phase complete` on EVERY execute
2376
+ * run, including the gap-closure re-runs a phase performs when verification
2377
+ * returns findings — this workstream's own phase 8 did two (plans 08-10 and
2378
+ * 08-11). MEASURED 2026-09-19 on a clean fixture, re-completing an
2379
+ * already-complete phase carried the total 2 → 4 → 6 while ROADMAP.md stayed
2380
+ * byte-identical: a silently corrupted stored record, the CLAUDE.md arm.
2381
+ *
2382
+ * The walk is the SAME milestone-scoped one `cmdStateUpdateProgress` already
2383
+ * reports as `completed` (`scanMilestoneDisk` — the milestone filter, the #2445
2384
+ * de-dup and the 261007-ixt D-9 closed-milestone exclusion), so STATE's counters cannot disagree — D-08-14's
2385
+ * spirit. A missing phases directory counts 0. `summaryCount` stays a parameter:
2386
+ * the By Phase row below is per-phase and still uses it, and that upsert was
2387
+ * already idempotent — only the Velocity total accumulated.
1904
2388
  */
1905
2389
  function updatePerformanceMetricsSection(content, cwd, phaseNum, planCount, summaryCount) {
1906
- // Update Velocity: Total plans completed
1907
- const totalMatch = content.match(/Total plans completed:\s*(\d+|\[N\])/);
1908
- const prevTotal = totalMatch && totalMatch[1] !== '[N]' ? parseInt(totalMatch[1], 10) : 0;
1909
- const newTotal = prevTotal + summaryCount;
1910
- content = content.replace(/Total plans completed:\s*(\d+|\[N\])/, `Total plans completed: ${newTotal}`);
2390
+ // Update Velocity: Total plans completed — the milestone's disk truth.
2391
+ // 261007-ixt D-9: the walk IS scanMilestoneDisk now (the one update-progress and the
2392
+ // frontmatter block read), so a closed milestone's leftover directories are excluded
2393
+ // here too. When every directory belongs to a closed milestone the disk says nothing
2394
+ // about the current one and the stored total is left as it is.
2395
+ let newTotal = 0;
2396
+ const phasesDir = planningPaths(cwd).phases;
2397
+ if (node_fs_1.default.existsSync(phasesDir)) {
2398
+ _diskScanCache.delete(cwd);
2399
+ const scan = scanMilestoneDisk(cwd);
2400
+ newTotal = scan === CLOSED_MILESTONE_ONLY ? null : (scan ? scan.completedPlans : 0);
2401
+ }
2402
+ if (newTotal !== null) {
2403
+ content = content.replace(/Total plans completed:\s*(\d+|\[N\])/, `Total plans completed: ${newTotal}`);
2404
+ }
1911
2405
  // Update By Phase table — upsert row for this phase
1912
2406
  const byPhaseMatch = content.match(byPhaseTablePattern);
1913
2407
  if (byPhaseMatch) {
1914
2408
  let tableBody = byPhaseMatch[2].trim();
1915
- const phaseRowPattern = new RegExp(`^\\|\\s*${escapeRegex(String(phaseNum))}\\s*\\|.*$`, 'm');
1916
- const newRow = `| ${phaseNum} | ${summaryCount} | - | - |`;
1917
- if (phaseRowPattern.test(tableBody)) {
2409
+ // The row for this phase is found by phase IDENTITY, not by the literal argument:
2410
+ // `phase complete 4` must update a row spelled `04` (and vice versa), never append
2411
+ // a second row for the same phase. The row keeps its own id spelling.
2412
+ const wanted = normalizePhaseName(String(phaseNum));
2413
+ const lines = tableBody.split('\n');
2414
+ const rowIdx = lines.findIndex((line) => {
2415
+ const cell = /^\|\s*([^|]*?)\s*\|/.exec(line);
2416
+ return cell !== null && /^[A-Za-z0-9.-]+$/.test(cell[1]) && normalizePhaseName(cell[1]) === wanted;
2417
+ });
2418
+ if (rowIdx !== -1) {
1918
2419
  // Update existing row
1919
- tableBody = tableBody.replace(phaseRowPattern, newRow);
2420
+ const rowId = /^\|\s*([^|]*?)\s*\|/.exec(lines[rowIdx])[1];
2421
+ lines[rowIdx] = `| ${rowId} | ${summaryCount} | - | - |`;
2422
+ tableBody = lines.join('\n');
1920
2423
  }
1921
2424
  else {
2425
+ const newRow = `| ${phaseNum} | ${summaryCount} | - | - |`;
1922
2426
  // Remove placeholder row and add new row
1923
2427
  tableBody = tableBody.replace(/^\|\s*-\s*\|\s*-\s*\|\s*-\s*\|\s*-\s*\|$/m, '').trim();
1924
2428
  tableBody = tableBody ? tableBody + '\n' + newRow : newRow;
@@ -1945,7 +2449,7 @@ function cmdStatePlannedPhase(cwd, phaseNumber, planCount, raw) {
1945
2449
  // milestone-wide progress.* frontmatter from a half-planned disk snapshot —
1946
2450
  // doing so tramples curated/known-good counters. Route through the body-only
1947
2451
  // write contract (resync:false), the same guard state.update uses. (#500 RC1)
1948
- readModifyWriteStateMd(statePath, (content) => {
2452
+ const fmReport = readModifyWriteStateMd(statePath, (content) => {
1949
2453
  // Bug #1257: all body-field replacements must operate on the body only
1950
2454
  // (frontmatter stripped), not on the full content. When the full content is
1951
2455
  // passed to stateReplaceFieldIfTemplate the YAML `status: planning` key matches
@@ -1989,13 +2493,31 @@ function cmdStatePlannedPhase(cwd, phaseNumber, planCount, raw) {
1989
2493
  }
1990
2494
  }
1991
2495
  // Update Current Position section
2496
+ // The Current Position write MUST be accounted for. Until 2026-09-20 it was
2497
+ // not: this was the only one of the four `updated`-reporting state verbs
2498
+ // that called updateCurrentPositionFields without pushing to `updated`
2499
+ // (cmdStateCompletePhase pushes 'Current Position'; cmdStateBeginPhase
2500
+ // pushes 'Current focus'; cmdStateRecordSession tracks its own fields).
2501
+ //
2502
+ // WHY THAT MATTERED, measured: the transform still changed the body, so the
2503
+ // no-op guard (#948) did not fire, the file WAS written, and
2504
+ // syncStateFrontmatter then re-derived frontmatter from the body. On a
2505
+ // STATE.md whose body had drifted behind its frontmatter, that propagated
2506
+ // the STALE body into frontmatter — observed moving `current_phase` from 10
2507
+ // to 09 and restoring a two-phase-old `last_activity_desc` — while this
2508
+ // command exited 0 and reported `updated: []`. Every cheap check said
2509
+ // "success, nothing changed"; only a cp→diff saw it. An unreported write is
2510
+ // the dangerous half, so the report is now complete.
2511
+ const beforePosition = body;
1992
2512
  body = updateCurrentPositionFields(body, {
1993
2513
  status: 'Ready to execute',
1994
2514
  lastActivity: `${today} — Phase ${phaseNumber} planning complete`,
1995
2515
  });
2516
+ if (body !== beforePosition)
2517
+ updated.push('Current Position');
1996
2518
  return reassemble(body);
1997
2519
  }, cwd, { resync: false });
1998
- output({ updated, phase: phaseNumber, plan_count: planCount }, raw, updated.length > 0 ? 'true' : 'false');
2520
+ output({ updated, phase: phaseNumber, plan_count: planCount, frontmatter_updated: fmReport.frontmatter_updated }, raw, updated.length > 0 ? 'true' : 'false');
1999
2521
  }
2000
2522
  /**
2001
2523
  * Bug #2630: reset STATE.md for a new milestone cycle.
@@ -2029,11 +2551,27 @@ function cmdStateMilestoneSwitch(cwd, version, name, raw) {
2029
2551
  const preface = body.trim().length > 0 ? body : '# Project State\n';
2030
2552
  newBody = `${preface.trimEnd()}\n\n## Current Position\n${resetPositionBody}`;
2031
2553
  }
2554
+ // The Session block must move with the milestone. Left alone, its `Stopped at:`
2555
+ // still names the PREVIOUS milestone's stop point while this write drops the
2556
+ // frontmatter `stopped_at`; the next writer (state.planned-phase, measured
2557
+ // 2026-10-09 on vault-assurance) then derives `stopped_at` from that stale
2558
+ // line and records "Phase 12 context gathered" against a milestone whose
2559
+ // first phase is 13. Reset the stop point and the resume pointer here, and
2560
+ // record the same stop point in frontmatter, so body and frontmatter agree.
2561
+ const switchStoppedAt = `Milestone ${version} started`;
2562
+ const sessionMatch = matchSessionSection(newBody);
2563
+ if (sessionMatch && sessionMatch.index !== undefined) {
2564
+ const section = sessionMatch[0];
2565
+ let resetSection = replaceSessionLine(section, 'Stopped At', switchStoppedAt) ?? section;
2566
+ resetSection = replaceSessionLine(resetSection, 'Resume File', 'None') ?? resetSection;
2567
+ newBody = newBody.slice(0, sessionMatch.index) + resetSection + newBody.slice(sessionMatch.index + section.length);
2568
+ }
2032
2569
  const fm = {
2033
2570
  gsd_state_version: existingFm['gsd_state_version'] || '1.0',
2034
2571
  milestone: version,
2035
2572
  milestone_name: resolvedName,
2036
2573
  status: 'planning',
2574
+ stopped_at: switchStoppedAt,
2037
2575
  last_updated: clock_cjs_1.realClock.nowIso(),
2038
2576
  last_activity: today,
2039
2577
  progress: {
@@ -2126,11 +2664,19 @@ function cmdStateSync(cwd, options, raw) {
2126
2664
  const verify = options && options.verify;
2127
2665
  const content = node_fs_1.default.readFileSync(statePath, 'utf-8');
2128
2666
  const changes = [];
2129
- let modified = content;
2667
+ // 261007-ixt: the fields below are BODY fields, so they are read and replaced in
2668
+ // the body only. stateReplaceField's plain pattern is case-insensitive and
2669
+ // multi-line, so on the whole file `Progress` matched the YAML `progress:` key
2670
+ // first: the bar was written into the frontmatter (then discarded by the
2671
+ // resync), the body bar never moved, and `changes` reported an update that did
2672
+ // not land. Measured on a STATE.md with a frontmatter progress block.
2673
+ const syncBody = stripFrontmatter(content);
2674
+ const syncHead = content.slice(0, content.length - syncBody.length);
2675
+ let modified = syncBody;
2130
2676
  const today = clock_cjs_1.realClock.today();
2131
2677
  const phasesDir = planningPaths(cwd).phases;
2132
2678
  if (!node_fs_1.default.existsSync(phasesDir)) {
2133
- output({ synced: true, changes: [], dry_run: !!verify }, raw, undefined);
2679
+ output({ synced: true, changes: [], dry_run: !!verify, frontmatter_updated: [] }, raw, undefined);
2134
2680
  return;
2135
2681
  }
2136
2682
  // Scan all phases
@@ -2142,23 +2688,16 @@ function cmdStateSync(cwd, options, raw) {
2142
2688
  .sort();
2143
2689
  }
2144
2690
  catch {
2145
- output({ synced: true, changes: [], dry_run: !!verify }, raw, undefined);
2691
+ output({ synced: true, changes: [], dry_run: !!verify, frontmatter_updated: [] }, raw, undefined);
2146
2692
  return;
2147
2693
  }
2148
- let totalDiskPlans = 0;
2149
- let totalDiskSummaries = 0;
2150
- let diskCompletedPhases = 0;
2151
2694
  let highestIncompletePhase = null;
2152
2695
  let _highestIncompletePhaseNum = null;
2153
2696
  let highestIncompletePhaseplanCount = 0;
2154
2697
  let _highestIncompletePhaseSummaryCount = 0;
2155
2698
  for (const dir of entries) {
2156
2699
  const dirPath = node_path_1.default.join(phasesDir, dir);
2157
- const { planCount: plans, summaryCount: summaries, completed } = scanPhasePlans(dirPath);
2158
- totalDiskPlans += plans;
2159
- totalDiskSummaries += summaries;
2160
- if (completed)
2161
- diskCompletedPhases++;
2700
+ const { planCount: plans, summaryCount: summaries } = scanPhasePlans(dirPath);
2162
2701
  // Track the highest phase with incomplete plans (or any plans)
2163
2702
  const phaseMatch = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)/i);
2164
2703
  if (phaseMatch && plans > 0) {
@@ -2178,33 +2717,6 @@ function cmdStateSync(cwd, options, raw) {
2178
2717
  }
2179
2718
  }
2180
2719
  }
2181
- // Determine total phases from ROADMAP (may be larger than realized disk dirs).
2182
- // Mirrors the logic in buildStateFrontmatter so both report consistent percents (#3242 Bug B).
2183
- let syncTotalPhases = null;
2184
- try {
2185
- let roadmapPhaseCount = 0;
2186
- const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
2187
- const roadmapRaw = (0, shell_command_projection_cjs_1.platformReadSync)(roadmapPath);
2188
- if (roadmapRaw !== null) {
2189
- const roadmapScope = extractCurrentMilestone(roadmapRaw, cwd);
2190
- const phaseHeadingPattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)\s*:/gi;
2191
- let m;
2192
- while ((m = phaseHeadingPattern.exec(roadmapScope)) !== null) {
2193
- // Only count tokens that contain at least one digit — excludes
2194
- // pure-word section headings (Overview, Details) while keeping
2195
- // numeric phases (01, 05.1) and project-code IDs (PROJ-42).
2196
- if (/\d/.test(m[1]))
2197
- roadmapPhaseCount++;
2198
- }
2199
- }
2200
- if (roadmapPhaseCount > 0) {
2201
- syncTotalPhases = Math.max(entries.length, roadmapPhaseCount);
2202
- }
2203
- else {
2204
- syncTotalPhases = entries.length;
2205
- }
2206
- }
2207
- catch { /* intentionally empty */ }
2208
2720
  // Sync Total Plans in Phase
2209
2721
  if (highestIncompletePhase) {
2210
2722
  const currentPlansField = (0, state_document_cjs_1.stateExtractField)(modified, 'Total Plans in Phase');
@@ -2218,11 +2730,21 @@ function cmdStateSync(cwd, options, raw) {
2218
2730
  // Sync Progress — use shared helper so formula stays in one place (#3242 Bug B).
2219
2731
  // computeProgressPercent applies min(plan_fraction, phase_fraction) so unrealised
2220
2732
  // ROADMAP phases cap the reported percent rather than allowing a false 100%.
2733
+ // 261007-ixt D-1: the body bar reads the SAME milestone scan as the frontmatter
2734
+ // block (scanMilestoneDisk) and takes completed phases from the same owner, so
2735
+ // the two surfaces cannot disagree. Fresh scan: sync re-derives from disk.
2736
+ _diskScanCache.delete(cwd);
2737
+ const syncScanRaw = scanMilestoneDisk(cwd);
2738
+ const syncScan = syncScanRaw === CLOSED_MILESTONE_ONLY ? null : syncScanRaw;
2221
2739
  const percent = (() => {
2222
- const p = (0, state_document_cjs_1.computeProgressPercent)(totalDiskSummaries, totalDiskPlans, diskCompletedPhases, syncTotalPhases);
2740
+ const p = syncScan
2741
+ ? (0, state_document_cjs_1.computeProgressPercent)(syncScan.completedPlans, syncScan.totalPlans, (0, state_document_cjs_1.reconcileCompletedPhases)(syncScan.diskCompletedPhases, syncScan.roadmapCompletedPhases), syncScan.totalPhases)
2742
+ : null;
2223
2743
  return p !== null ? p : 0;
2224
2744
  })();
2225
- const currentProgress = (0, state_document_cjs_1.stateExtractField)(modified, 'Progress');
2745
+ // D-9: nothing on disk is the current milestone's → the bar is left as it is rather
2746
+ // than written as 0%.
2747
+ const currentProgress = syncScanRaw === CLOSED_MILESTONE_ONLY ? null : (0, state_document_cjs_1.stateExtractField)(modified, 'Progress');
2226
2748
  if (currentProgress) {
2227
2749
  const currentPercent = parseInt(currentProgress.replace(/[^\d]/g, ''), 10);
2228
2750
  if (currentPercent !== percent) {
@@ -2246,13 +2768,15 @@ function cmdStateSync(cwd, options, raw) {
2246
2768
  modified = result;
2247
2769
  }
2248
2770
  if (verify) {
2249
- output({ synced: false, changes, dry_run: true }, raw, undefined);
2771
+ output({ synced: false, changes, dry_run: true, frontmatter_updated: [] }, raw, undefined);
2250
2772
  return;
2251
2773
  }
2252
- if (changes.length > 0 || modified !== content) {
2253
- writeStateMd(statePath, modified, cwd);
2774
+ const nextContent = syncHead + modified;
2775
+ let frontmatterUpdated = [];
2776
+ if (changes.length > 0 || nextContent !== content) {
2777
+ frontmatterUpdated = writeStateMd(statePath, nextContent, cwd).frontmatter_updated;
2254
2778
  }
2255
- output({ synced: true, changes, dry_run: false }, raw, undefined);
2779
+ output({ synced: true, changes, dry_run: false, frontmatter_updated: frontmatterUpdated }, raw, undefined);
2256
2780
  }
2257
2781
  /**
2258
2782
  * Prune old entries from STATE.md sections that grow unboundedly (#1970).
@@ -2277,7 +2801,7 @@ function cmdStatePrune(cwd, options, raw) {
2277
2801
  const currentPhase = parseInt(currentPhaseRaw, 10) || 0;
2278
2802
  const cutoff = currentPhase - keepRecent;
2279
2803
  if (cutoff <= 0) {
2280
- emit({ pruned: false, reason: `Only ${currentPhase} phases — nothing to prune with --keep-recent ${keepRecent}` }, raw, 'false');
2804
+ emit({ pruned: false, reason: `Only ${currentPhase} phases — nothing to prune with --keep-recent ${keepRecent}`, frontmatter_updated: [] }, raw, 'false');
2281
2805
  return;
2282
2806
  }
2283
2807
  const archivePath = node_path_1.default.join(node_path_1.default.dirname(statePath), 'STATE-ARCHIVE.md');
@@ -2396,10 +2920,11 @@ function cmdStatePrune(cwd, options, raw) {
2396
2920
  sections: result.archivedSections.map(s => ({ section: s.section, entries_would_archive: s.count })),
2397
2921
  total_would_archive: totalPruned,
2398
2922
  note: totalPruned > 0 ? 'Run without --dry-run to actually prune' : 'Nothing to prune',
2923
+ frontmatter_updated: [],
2399
2924
  }, raw, totalPruned > 0 ? 'true' : 'false');
2400
2925
  return;
2401
2926
  }
2402
- readModifyWriteStateMd(statePath, (content) => {
2927
+ const fmReport = readModifyWriteStateMd(statePath, (content) => {
2403
2928
  const result = prunePass(content);
2404
2929
  archived.push(...result.archivedSections);
2405
2930
  return result.newContent;
@@ -2425,6 +2950,7 @@ function cmdStatePrune(cwd, options, raw) {
2425
2950
  sections: archived.map(s => ({ section: s.section, entries_archived: s.count })),
2426
2951
  total_archived: totalPruned,
2427
2952
  archive_file: totalPruned > 0 ? 'STATE-ARCHIVE.md' : null,
2953
+ frontmatter_updated: fmReport.frontmatter_updated,
2428
2954
  }, raw, totalPruned > 0 ? 'true' : 'false');
2429
2955
  }
2430
2956
  /**
@@ -2467,12 +2993,12 @@ function cmdStateCompletePhase(cwd, raw, overridePhase) {
2467
2993
  const existingCurrentPhaseMatch = String(existingCurrentPhaseRaw).match(/(\d+[A-Z]?(?:\.\d+)*)/i);
2468
2994
  const existingCurrentPhase = existingCurrentPhaseMatch ? existingCurrentPhaseMatch[1] : null;
2469
2995
  if (existingCurrentPhase && existingCurrentPhase !== resolvedPhase) {
2470
- output({ updated: [], phase: resolvedPhase, idempotent: true, note: 'phase already superseded; no-op' }, raw, 'false');
2996
+ output({ updated: [], phase: resolvedPhase, idempotent: true, note: 'phase already superseded; no-op', frontmatter_updated: [] }, raw, 'false');
2471
2997
  return;
2472
2998
  }
2473
2999
  const today = clock_cjs_1.realClock.today();
2474
3000
  const updated = [];
2475
- readModifyWriteStateMd(statePath, (content) => {
3001
+ const fmReport = readModifyWriteStateMd(statePath, (content) => {
2476
3002
  const currentPhase = resolvedPhase;
2477
3003
  // Bug #1255: operate on body only so the YAML frontmatter `status:` key
2478
3004
  // cannot shadow the body Status field (pipe-table or inline).
@@ -2548,7 +3074,7 @@ function cmdStateCompletePhase(cwd, raw, overridePhase) {
2548
3074
  }
2549
3075
  return reassemble(body);
2550
3076
  }, cwd);
2551
- output({ updated, phase: resolvedPhase }, raw, updated.length > 0 ? 'true' : 'false');
3077
+ output({ updated, phase: resolvedPhase, frontmatter_updated: fmReport.frontmatter_updated }, raw, updated.length > 0 ? 'true' : 'false');
2552
3078
  }
2553
3079
  module.exports = {
2554
3080
  stateExtractField: state_document_cjs_1.stateExtractField,
@@ -2559,6 +3085,7 @@ module.exports = {
2559
3085
  writeStateMd,
2560
3086
  readModifyWriteStateMd,
2561
3087
  syncStateFrontmatter,
3088
+ matchSessionSection,
2562
3089
  withStateLock,
2563
3090
  updatePerformanceMetricsSection,
2564
3091
  cmdStateLoad,