@bhargavvc/sdd-cc 1.30.0 → 1.35.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 (242) hide show
  1. package/README.ja-JP.md +144 -110
  2. package/README.ko-KR.md +143 -107
  3. package/README.md +183 -112
  4. package/README.pt-BR.md +90 -52
  5. package/README.zh-CN.md +141 -101
  6. package/agents/sdd-advisor-researcher.md +23 -0
  7. package/agents/sdd-ai-researcher.md +133 -0
  8. package/agents/sdd-code-fixer.md +516 -0
  9. package/agents/sdd-code-reviewer.md +355 -0
  10. package/agents/sdd-codebase-mapper.md +3 -3
  11. package/agents/sdd-debugger.md +17 -5
  12. package/agents/sdd-doc-verifier.md +201 -0
  13. package/agents/sdd-doc-writer.md +602 -0
  14. package/agents/sdd-domain-researcher.md +153 -0
  15. package/agents/sdd-eval-auditor.md +164 -0
  16. package/agents/sdd-eval-planner.md +154 -0
  17. package/agents/sdd-executor.md +87 -4
  18. package/agents/sdd-framework-selector.md +160 -0
  19. package/agents/sdd-intel-updater.md +314 -0
  20. package/agents/sdd-nyquist-auditor.md +1 -1
  21. package/agents/sdd-phase-researcher.md +71 -4
  22. package/agents/sdd-plan-checker.md +100 -6
  23. package/agents/sdd-planner.md +145 -206
  24. package/agents/sdd-project-researcher.md +25 -2
  25. package/agents/sdd-research-synthesizer.md +3 -3
  26. package/agents/sdd-roadmapper.md +6 -6
  27. package/agents/sdd-security-auditor.md +128 -0
  28. package/agents/sdd-ui-auditor.md +43 -3
  29. package/agents/sdd-ui-checker.md +5 -5
  30. package/agents/sdd-ui-researcher.md +27 -4
  31. package/agents/sdd-user-profiler.md +2 -2
  32. package/agents/sdd-verifier.md +142 -22
  33. package/bin/install.js +2151 -551
  34. package/commands/sdd/add-backlog.md +5 -5
  35. package/commands/sdd/add-tests.md +2 -2
  36. package/commands/sdd/ai-integration-phase.md +36 -0
  37. package/commands/sdd/analyze-dependencies.md +34 -0
  38. package/commands/sdd/audit-fix.md +33 -0
  39. package/commands/sdd/autonomous.md +7 -2
  40. package/commands/sdd/cleanup.md +5 -0
  41. package/commands/sdd/code-review-fix.md +52 -0
  42. package/commands/sdd/code-review.md +55 -0
  43. package/commands/sdd/complete-milestone.md +6 -6
  44. package/commands/sdd/debug.md +22 -9
  45. package/commands/sdd/discuss-phase.md +7 -2
  46. package/commands/sdd/do.md +1 -1
  47. package/commands/sdd/docs-update.md +48 -0
  48. package/commands/sdd/eval-review.md +32 -0
  49. package/commands/sdd/execute-phase.md +4 -0
  50. package/commands/sdd/explore.md +27 -0
  51. package/commands/sdd/fast.md +2 -2
  52. package/commands/sdd/from-sdd2.md +45 -0
  53. package/commands/sdd/help.md +2 -0
  54. package/commands/sdd/import.md +36 -0
  55. package/commands/sdd/intel.md +179 -0
  56. package/commands/sdd/join-discord.md +2 -1
  57. package/commands/sdd/manager.md +1 -0
  58. package/commands/sdd/map-codebase.md +3 -3
  59. package/commands/sdd/new-milestone.md +1 -1
  60. package/commands/sdd/new-project.md +5 -1
  61. package/commands/sdd/new-workspace.md +1 -1
  62. package/commands/sdd/next.md +2 -0
  63. package/commands/sdd/plan-milestone-gaps.md +2 -2
  64. package/commands/sdd/plan-phase.md +6 -1
  65. package/commands/sdd/plant-seed.md +1 -1
  66. package/commands/sdd/profile-user.md +1 -1
  67. package/commands/sdd/quick.md +5 -3
  68. package/commands/sdd/reapply-patches.md +230 -42
  69. package/commands/sdd/research-phase.md +3 -3
  70. package/commands/sdd/review-backlog.md +1 -0
  71. package/commands/sdd/review.md +6 -3
  72. package/commands/sdd/scan.md +26 -0
  73. package/commands/sdd/secure-phase.md +35 -0
  74. package/commands/sdd/ship.md +1 -1
  75. package/commands/sdd/thread.md +5 -5
  76. package/commands/sdd/undo.md +34 -0
  77. package/commands/sdd/verify-work.md +1 -1
  78. package/commands/sdd/workstreams.md +17 -11
  79. package/hooks/dist/sdd-check-update.js +33 -8
  80. package/hooks/dist/sdd-context-monitor.js +17 -8
  81. package/hooks/dist/sdd-phase-boundary.sh +27 -0
  82. package/hooks/dist/sdd-prompt-guard.js +1 -0
  83. package/hooks/dist/sdd-read-guard.js +82 -0
  84. package/hooks/dist/sdd-session-state.sh +33 -0
  85. package/hooks/dist/sdd-statusline.js +137 -15
  86. package/hooks/dist/sdd-validate-commit.sh +47 -0
  87. package/hooks/dist/sdd-workflow-guard.js +4 -4
  88. package/hooks/sdd-check-update.js +139 -0
  89. package/hooks/sdd-context-monitor.js +165 -0
  90. package/hooks/sdd-phase-boundary.sh +27 -0
  91. package/hooks/sdd-prompt-guard.js +97 -0
  92. package/hooks/sdd-read-guard.js +82 -0
  93. package/hooks/sdd-session-state.sh +33 -0
  94. package/hooks/sdd-statusline.js +241 -0
  95. package/hooks/sdd-validate-commit.sh +47 -0
  96. package/hooks/sdd-workflow-guard.js +94 -0
  97. package/package.json +3 -3
  98. package/scripts/build-hooks.js +18 -7
  99. package/scripts/prompt-injection-scan.sh +1 -0
  100. package/scripts/rebrand-gsd-to-sdd.sh +221 -220
  101. package/scripts/run-tests.cjs +5 -1
  102. package/scripts/sync-upstream.sh +1 -1
  103. package/sdd/bin/lib/commands.cjs +79 -17
  104. package/sdd/bin/lib/config.cjs +90 -48
  105. package/sdd/bin/lib/core.cjs +452 -87
  106. package/sdd/bin/lib/docs.cjs +267 -0
  107. package/sdd/bin/lib/frontmatter.cjs +381 -336
  108. package/sdd/bin/lib/init.cjs +110 -16
  109. package/sdd/bin/lib/intel.cjs +660 -0
  110. package/sdd/bin/lib/learnings.cjs +378 -0
  111. package/sdd/bin/lib/milestone.cjs +42 -11
  112. package/sdd/bin/lib/model-profiles.cjs +17 -15
  113. package/sdd/bin/lib/phase.cjs +367 -288
  114. package/sdd/bin/lib/profile-output.cjs +106 -10
  115. package/sdd/bin/lib/roadmap.cjs +146 -115
  116. package/sdd/bin/lib/schema-detect.cjs +238 -0
  117. package/sdd/bin/lib/sdd2-import.cjs +511 -0
  118. package/sdd/bin/lib/security.cjs +124 -3
  119. package/sdd/bin/lib/state.cjs +648 -264
  120. package/sdd/bin/lib/template.cjs +8 -4
  121. package/sdd/bin/lib/verify.cjs +209 -28
  122. package/sdd/bin/lib/workstream.cjs +7 -3
  123. package/sdd/bin/sdd-tools.cjs +184 -12
  124. package/sdd/contexts/dev.md +21 -0
  125. package/sdd/contexts/research.md +22 -0
  126. package/sdd/contexts/review.md +22 -0
  127. package/sdd/references/agent-contracts.md +79 -0
  128. package/sdd/references/ai-evals.md +156 -0
  129. package/sdd/references/ai-frameworks.md +186 -0
  130. package/sdd/references/artifact-types.md +113 -0
  131. package/sdd/references/common-bug-patterns.md +114 -0
  132. package/sdd/references/context-budget.md +49 -0
  133. package/sdd/references/continuation-format.md +25 -25
  134. package/sdd/references/domain-probes.md +125 -0
  135. package/sdd/references/few-shot-examples/plan-checker.md +73 -0
  136. package/sdd/references/few-shot-examples/verifier.md +109 -0
  137. package/sdd/references/gate-prompts.md +100 -0
  138. package/sdd/references/gates.md +70 -0
  139. package/sdd/references/git-integration.md +1 -1
  140. package/sdd/references/ios-scaffold.md +123 -0
  141. package/sdd/references/model-profile-resolution.md +2 -0
  142. package/sdd/references/model-profiles.md +24 -18
  143. package/sdd/references/planner-gap-closure.md +62 -0
  144. package/sdd/references/planner-reviews.md +39 -0
  145. package/sdd/references/planner-revision.md +87 -0
  146. package/sdd/references/planning-config.md +252 -0
  147. package/sdd/references/revision-loop.md +97 -0
  148. package/sdd/references/thinking-models-debug.md +44 -0
  149. package/sdd/references/thinking-models-execution.md +50 -0
  150. package/sdd/references/thinking-models-planning.md +62 -0
  151. package/sdd/references/thinking-models-research.md +50 -0
  152. package/sdd/references/thinking-models-verification.md +55 -0
  153. package/sdd/references/thinking-partner.md +96 -0
  154. package/sdd/references/ui-brand.md +4 -4
  155. package/sdd/references/universal-anti-patterns.md +63 -0
  156. package/sdd/references/verification-overrides.md +227 -0
  157. package/sdd/references/workstream-flag.md +56 -3
  158. package/sdd/templates/AI-SPEC.md +246 -0
  159. package/sdd/templates/DEBUG.md +1 -1
  160. package/sdd/templates/SECURITY.md +61 -0
  161. package/sdd/templates/UAT.md +4 -4
  162. package/sdd/templates/VALIDATION.md +4 -4
  163. package/sdd/templates/claude-md.md +32 -9
  164. package/sdd/templates/config.json +4 -0
  165. package/sdd/templates/debug-subagent-prompt.md +1 -1
  166. package/sdd/templates/dev-preferences.md +1 -1
  167. package/sdd/templates/discovery.md +2 -2
  168. package/sdd/templates/phase-prompt.md +1 -1
  169. package/sdd/templates/planner-subagent-prompt.md +3 -3
  170. package/sdd/templates/project.md +1 -1
  171. package/sdd/templates/research.md +1 -1
  172. package/sdd/templates/state.md +2 -2
  173. package/sdd/workflows/add-phase.md +8 -8
  174. package/sdd/workflows/add-tests.md +12 -9
  175. package/sdd/workflows/add-todo.md +5 -3
  176. package/sdd/workflows/ai-integration-phase.md +284 -0
  177. package/sdd/workflows/analyze-dependencies.md +96 -0
  178. package/sdd/workflows/audit-fix.md +157 -0
  179. package/sdd/workflows/audit-milestone.md +11 -11
  180. package/sdd/workflows/audit-uat.md +2 -2
  181. package/sdd/workflows/autonomous.md +195 -27
  182. package/sdd/workflows/check-todos.md +12 -10
  183. package/sdd/workflows/cleanup.md +2 -0
  184. package/sdd/workflows/code-review-fix.md +497 -0
  185. package/sdd/workflows/code-review.md +515 -0
  186. package/sdd/workflows/complete-milestone.md +56 -22
  187. package/sdd/workflows/diagnose-issues.md +10 -3
  188. package/sdd/workflows/discovery-phase.md +5 -3
  189. package/sdd/workflows/discuss-phase-assumptions.md +24 -6
  190. package/sdd/workflows/discuss-phase-power.md +291 -0
  191. package/sdd/workflows/discuss-phase.md +173 -21
  192. package/sdd/workflows/do.md +23 -21
  193. package/sdd/workflows/docs-update.md +1155 -0
  194. package/sdd/workflows/eval-review.md +155 -0
  195. package/sdd/workflows/execute-phase.md +594 -38
  196. package/sdd/workflows/execute-plan.md +67 -96
  197. package/sdd/workflows/explore.md +139 -0
  198. package/sdd/workflows/fast.md +5 -5
  199. package/sdd/workflows/forensics.md +2 -2
  200. package/sdd/workflows/health.md +4 -4
  201. package/sdd/workflows/help.md +122 -119
  202. package/sdd/workflows/import.md +276 -0
  203. package/sdd/workflows/inbox.md +387 -0
  204. package/sdd/workflows/insert-phase.md +7 -7
  205. package/sdd/workflows/list-phase-assumptions.md +4 -4
  206. package/sdd/workflows/list-workspaces.md +2 -2
  207. package/sdd/workflows/manager.md +35 -32
  208. package/sdd/workflows/map-codebase.md +7 -5
  209. package/sdd/workflows/milestone-summary.md +2 -2
  210. package/sdd/workflows/new-milestone.md +17 -9
  211. package/sdd/workflows/new-project.md +50 -25
  212. package/sdd/workflows/new-workspace.md +7 -5
  213. package/sdd/workflows/next.md +67 -11
  214. package/sdd/workflows/note.md +9 -7
  215. package/sdd/workflows/pause-work.md +75 -12
  216. package/sdd/workflows/plan-milestone-gaps.md +8 -8
  217. package/sdd/workflows/plan-phase.md +294 -42
  218. package/sdd/workflows/plant-seed.md +6 -3
  219. package/sdd/workflows/pr-branch.md +42 -14
  220. package/sdd/workflows/profile-user.md +9 -7
  221. package/sdd/workflows/progress.md +45 -45
  222. package/sdd/workflows/quick.md +195 -47
  223. package/sdd/workflows/remove-phase.md +6 -6
  224. package/sdd/workflows/remove-workspace.md +3 -1
  225. package/sdd/workflows/research-phase.md +2 -2
  226. package/sdd/workflows/resume-project.md +12 -12
  227. package/sdd/workflows/review.md +109 -9
  228. package/sdd/workflows/scan.md +102 -0
  229. package/sdd/workflows/secure-phase.md +166 -0
  230. package/sdd/workflows/session-report.md +2 -2
  231. package/sdd/workflows/settings.md +38 -12
  232. package/sdd/workflows/ship.md +21 -9
  233. package/sdd/workflows/stats.md +1 -1
  234. package/sdd/workflows/transition.md +23 -23
  235. package/sdd/workflows/ui-phase.md +15 -7
  236. package/sdd/workflows/ui-review.md +29 -4
  237. package/sdd/workflows/undo.md +314 -0
  238. package/sdd/workflows/update.md +171 -20
  239. package/sdd/workflows/validate-phase.md +6 -4
  240. package/sdd/workflows/verify-phase.md +210 -6
  241. package/sdd/workflows/verify-work.md +83 -9
  242. package/sdd/commands/sdd/workstreams.md +0 -63
@@ -4,7 +4,7 @@
4
4
 
5
5
  const fs = require('fs');
6
6
  const path = require('path');
7
- const { escapeRegex, loadConfig, getMilestoneInfo, getMilestonePhaseFilter, normalizeMd, planningDir, planningPaths, output, error } = require('./core.cjs');
7
+ const { escapeRegex, loadConfig, getMilestoneInfo, getMilestonePhaseFilter, normalizeMd, planningDir, planningPaths, output, error, atomicWriteFileSync } = require('./core.cjs');
8
8
  const { extractFrontmatter, reconstructFrontmatter } = require('./frontmatter.cjs');
9
9
 
10
10
  /** Shorthand — every state command needs this path */
@@ -12,6 +12,16 @@ function getStatePath(cwd) {
12
12
  return planningPaths(cwd).state;
13
13
  }
14
14
 
15
+ // Track all lock files held by this process so they can be removed on exit.
16
+ // process.on('exit') fires even on process.exit(1), unlike try/finally which is
17
+ // skipped when error() calls process.exit(1) inside a locked region (#1916).
18
+ const _heldStateLocks = new Set();
19
+ process.on('exit', () => {
20
+ for (const lockPath of _heldStateLocks) {
21
+ try { require('fs').unlinkSync(lockPath); } catch { /* already gone */ }
22
+ }
23
+ });
24
+
15
25
  // Shared helper: extract a field value from STATE.md content.
16
26
  // Supports both **Field:** bold and plain Field: format.
17
27
  function stateExtractField(content, fieldName) {
@@ -141,29 +151,28 @@ function cmdStatePatch(cwd, patches, raw) {
141
151
 
142
152
  const statePath = planningPaths(cwd).state;
143
153
  try {
144
- let content = fs.readFileSync(statePath, 'utf-8');
145
154
  const results = { updated: [], failed: [] };
146
155
 
147
- for (const [field, value] of Object.entries(patches)) {
148
- const fieldEscaped = escapeRegex(field);
149
- // Try **Field:** bold format first, then plain Field: format
150
- const boldPattern = new RegExp(`(\\*\\*${fieldEscaped}:\\*\\*\\s*)(.*)`, 'i');
151
- const plainPattern = new RegExp(`(^${fieldEscaped}:\\s*)(.*)`, 'im');
152
-
153
- if (boldPattern.test(content)) {
154
- content = content.replace(boldPattern, (_match, prefix) => `${prefix}${value}`);
155
- results.updated.push(field);
156
- } else if (plainPattern.test(content)) {
157
- content = content.replace(plainPattern, (_match, prefix) => `${prefix}${value}`);
158
- results.updated.push(field);
159
- } else {
160
- results.failed.push(field);
156
+ // Use atomic read-modify-write to prevent lost updates from concurrent agents
157
+ readModifyWriteStateMd(statePath, (content) => {
158
+ for (const [field, value] of Object.entries(patches)) {
159
+ const fieldEscaped = escapeRegex(field);
160
+ // Try **Field:** bold format first, then plain Field: format
161
+ const boldPattern = new RegExp(`(\\*\\*${fieldEscaped}:\\*\\*\\s*)(.*)`, 'i');
162
+ const plainPattern = new RegExp(`(^${fieldEscaped}:\\s*)(.*)`, 'im');
163
+
164
+ if (boldPattern.test(content)) {
165
+ content = content.replace(boldPattern, (_match, prefix) => `${prefix}${value}`);
166
+ results.updated.push(field);
167
+ } else if (plainPattern.test(content)) {
168
+ content = content.replace(plainPattern, (_match, prefix) => `${prefix}${value}`);
169
+ results.updated.push(field);
170
+ } else {
171
+ results.failed.push(field);
172
+ }
161
173
  }
162
- }
163
-
164
- if (results.updated.length > 0) {
165
- writeStateMd(statePath, content, cwd);
166
- }
174
+ return content;
175
+ }, cwd);
167
176
 
168
177
  output(results, raw, results.updated.length > 0 ? 'true' : 'false');
169
178
  } catch {
@@ -185,18 +194,22 @@ function cmdStateUpdate(cwd, field, value) {
185
194
 
186
195
  const statePath = planningPaths(cwd).state;
187
196
  try {
188
- let content = fs.readFileSync(statePath, 'utf-8');
189
- const fieldEscaped = escapeRegex(field);
190
- // Try **Field:** bold format first, then plain Field: format
191
- const boldPattern = new RegExp(`(\\*\\*${fieldEscaped}:\\*\\*\\s*)(.*)`, 'i');
192
- const plainPattern = new RegExp(`(^${fieldEscaped}:\\s*)(.*)`, 'im');
193
- if (boldPattern.test(content)) {
194
- content = content.replace(boldPattern, (_match, prefix) => `${prefix}${value}`);
195
- writeStateMd(statePath, content, cwd);
196
- output({ updated: true });
197
- } else if (plainPattern.test(content)) {
198
- content = content.replace(plainPattern, (_match, prefix) => `${prefix}${value}`);
199
- writeStateMd(statePath, content, cwd);
197
+ let updated = false;
198
+ readModifyWriteStateMd(statePath, (content) => {
199
+ const fieldEscaped = escapeRegex(field);
200
+ // Try **Field:** bold format first, then plain Field: format
201
+ const boldPattern = new RegExp(`(\\*\\*${fieldEscaped}:\\*\\*\\s*)(.*)`, 'i');
202
+ const plainPattern = new RegExp(`(^${fieldEscaped}:\\s*)(.*)`, 'im');
203
+ if (boldPattern.test(content)) {
204
+ updated = true;
205
+ return content.replace(boldPattern, (_match, prefix) => `${prefix}${value}`);
206
+ } else if (plainPattern.test(content)) {
207
+ updated = true;
208
+ return content.replace(plainPattern, (_match, prefix) => `${prefix}${value}`);
209
+ }
210
+ return content;
211
+ }, cwd);
212
+ if (updated) {
200
213
  output({ updated: true });
201
214
  } else {
202
215
  output({ updated: false, reason: `Field "${field}" not found in STATE.md` });
@@ -236,6 +249,12 @@ function stateReplaceFieldWithFallback(content, primary, fallback, value) {
236
249
  result = stateReplaceField(content, fallback, value);
237
250
  if (result) return result;
238
251
  }
252
+ // Neither pattern matched — field may have been reformatted or removed.
253
+ // Log diagnostic so template drift is detected early rather than silently swallowed.
254
+ process.stderr.write(
255
+ `[sdd-tools] WARNING: STATE.md field "${primary}"${fallback ? ` (fallback: "${fallback}")` : ''} not found — update skipped. ` +
256
+ `This may indicate STATE.md was externally modified or uses an unexpected format.\n`
257
+ );
239
258
  return content;
240
259
  }
241
260
 
@@ -269,55 +288,67 @@ function cmdStateAdvancePlan(cwd, raw) {
269
288
  const statePath = planningPaths(cwd).state;
270
289
  if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; }
271
290
 
272
- let content = fs.readFileSync(statePath, 'utf-8');
273
291
  const today = new Date().toISOString().split('T')[0];
292
+ let result = null;
293
+
294
+ readModifyWriteStateMd(statePath, (content) => {
295
+ // Try legacy separate fields first, then compound "Plan: X of Y" format
296
+ const legacyPlan = stateExtractField(content, 'Current Plan');
297
+ const legacyTotal = stateExtractField(content, 'Total Plans in Phase');
298
+ const planField = stateExtractField(content, 'Plan');
299
+
300
+ let currentPlan, totalPlans;
301
+ let useCompoundFormat = false;
302
+
303
+ if (legacyPlan && legacyTotal) {
304
+ currentPlan = parseInt(legacyPlan, 10);
305
+ totalPlans = parseInt(legacyTotal, 10);
306
+ } else if (planField) {
307
+ // Compound format: "2 of 6 in current phase" or "2 of 6"
308
+ currentPlan = parseInt(planField, 10);
309
+ const ofMatch = planField.match(/of\s+(\d+)/);
310
+ totalPlans = ofMatch ? parseInt(ofMatch[1], 10) : NaN;
311
+ useCompoundFormat = true;
312
+ }
274
313
 
275
- // Try legacy separate fields first, then compound "Plan: X of Y" format
276
- const legacyPlan = stateExtractField(content, 'Current Plan');
277
- const legacyTotal = stateExtractField(content, 'Total Plans in Phase');
278
- const planField = stateExtractField(content, 'Plan');
279
-
280
- let currentPlan, totalPlans;
281
- let useCompoundFormat = false;
282
-
283
- if (legacyPlan && legacyTotal) {
284
- currentPlan = parseInt(legacyPlan, 10);
285
- totalPlans = parseInt(legacyTotal, 10);
286
- } else if (planField) {
287
- // Compound format: "2 of 6 in current phase" or "2 of 6"
288
- currentPlan = parseInt(planField, 10);
289
- const ofMatch = planField.match(/of\s+(\d+)/);
290
- totalPlans = ofMatch ? parseInt(ofMatch[1], 10) : NaN;
291
- useCompoundFormat = true;
292
- }
314
+ if (isNaN(currentPlan) || isNaN(totalPlans)) {
315
+ result = { error: true };
316
+ return content;
317
+ }
318
+
319
+ if (currentPlan >= totalPlans) {
320
+ content = stateReplaceFieldWithFallback(content, 'Status', null, 'Phase complete — ready for verification');
321
+ content = stateReplaceFieldWithFallback(content, 'Last Activity', 'Last activity', today);
322
+ content = updateCurrentPositionFields(content, { status: 'Phase complete — ready for verification', lastActivity: today });
323
+ result = { advanced: false, reason: 'last_plan', current_plan: currentPlan, total_plans: totalPlans, status: 'ready_for_verification' };
324
+ } else {
325
+ const newPlan = currentPlan + 1;
326
+ let planDisplayValue;
327
+ if (useCompoundFormat) {
328
+ // Preserve compound format: "X of Y in current phase" → replace X only
329
+ planDisplayValue = planField.replace(/^\d+/, String(newPlan));
330
+ content = stateReplaceField(content, 'Plan', planDisplayValue) || content;
331
+ } else {
332
+ planDisplayValue = `${newPlan} of ${totalPlans}`;
333
+ content = stateReplaceField(content, 'Current Plan', String(newPlan)) || content;
334
+ }
335
+ content = stateReplaceFieldWithFallback(content, 'Status', null, 'Ready to execute');
336
+ content = stateReplaceFieldWithFallback(content, 'Last Activity', 'Last activity', today);
337
+ content = updateCurrentPositionFields(content, { status: 'Ready to execute', lastActivity: today, plan: planDisplayValue });
338
+ result = { advanced: true, previous_plan: currentPlan, current_plan: newPlan, total_plans: totalPlans };
339
+ }
340
+ return content;
341
+ }, cwd);
293
342
 
294
- if (isNaN(currentPlan) || isNaN(totalPlans)) {
343
+ if (!result || result.error) {
295
344
  output({ error: 'Cannot parse Current Plan or Total Plans in Phase from STATE.md' }, raw);
296
345
  return;
297
346
  }
298
347
 
299
- if (currentPlan >= totalPlans) {
300
- content = stateReplaceFieldWithFallback(content, 'Status', null, 'Phase complete — ready for verification');
301
- content = stateReplaceFieldWithFallback(content, 'Last Activity', 'Last activity', today);
302
- content = updateCurrentPositionFields(content, { status: 'Phase complete — ready for verification', lastActivity: today });
303
- writeStateMd(statePath, content, cwd);
304
- output({ advanced: false, reason: 'last_plan', current_plan: currentPlan, total_plans: totalPlans, status: 'ready_for_verification' }, raw, 'false');
348
+ if (result.advanced === false) {
349
+ output(result, raw, 'false');
305
350
  } else {
306
- const newPlan = currentPlan + 1;
307
- let planDisplayValue;
308
- if (useCompoundFormat) {
309
- // Preserve compound format: "X of Y in current phase" → replace X only
310
- planDisplayValue = planField.replace(/^\d+/, String(newPlan));
311
- content = stateReplaceField(content, 'Plan', planDisplayValue) || content;
312
- } else {
313
- planDisplayValue = `${newPlan} of ${totalPlans}`;
314
- content = stateReplaceField(content, 'Current Plan', String(newPlan)) || content;
315
- }
316
- content = stateReplaceFieldWithFallback(content, 'Status', null, 'Ready to execute');
317
- content = stateReplaceFieldWithFallback(content, 'Last Activity', 'Last activity', today);
318
- content = updateCurrentPositionFields(content, { status: 'Ready to execute', lastActivity: today, plan: planDisplayValue });
319
- writeStateMd(statePath, content, cwd);
320
- output({ advanced: true, previous_plan: currentPlan, current_plan: newPlan, total_plans: totalPlans }, raw, 'true');
351
+ output(result, raw, 'true');
321
352
  }
322
353
  }
323
354
 
@@ -325,7 +356,6 @@ function cmdStateRecordMetric(cwd, options, raw) {
325
356
  const statePath = planningPaths(cwd).state;
326
357
  if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; }
327
358
 
328
- let content = fs.readFileSync(statePath, 'utf-8');
329
359
  const { phase, plan, duration, tasks, files } = options;
330
360
 
331
361
  if (!phase || !plan || !duration) {
@@ -333,22 +363,29 @@ function cmdStateRecordMetric(cwd, options, raw) {
333
363
  return;
334
364
  }
335
365
 
336
- // Find Performance Metrics section and its table
337
- const metricsPattern = /(##\s*Performance Metrics[\s\S]*?\n\|[^\n]+\n\|[-|\s]+\n)([\s\S]*?)(?=\n##|\n$|$)/i;
338
- const metricsMatch = content.match(metricsPattern);
366
+ let recorded = false;
367
+ readModifyWriteStateMd(statePath, (content) => {
368
+ // Find Performance Metrics section and its table
369
+ const metricsPattern = /(##\s*Performance Metrics[\s\S]*?\n\|[^\n]+\n\|[-|\s]+\n)([\s\S]*?)(?=\n##|\n$|$)/i;
370
+ const metricsMatch = content.match(metricsPattern);
339
371
 
340
- if (metricsMatch) {
341
- let tableBody = metricsMatch[2].trimEnd();
342
- const newRow = `| Phase ${phase} P${plan} | ${duration} | ${tasks || '-'} tasks | ${files || '-'} files |`;
372
+ if (metricsMatch) {
373
+ let tableBody = metricsMatch[2].trimEnd();
374
+ const newRow = `| Phase ${phase} P${plan} | ${duration} | ${tasks || '-'} tasks | ${files || '-'} files |`;
343
375
 
344
- if (tableBody.trim() === '' || tableBody.includes('None yet')) {
345
- tableBody = newRow;
346
- } else {
347
- tableBody = tableBody + '\n' + newRow;
376
+ if (tableBody.trim() === '' || tableBody.includes('None yet')) {
377
+ tableBody = newRow;
378
+ } else {
379
+ tableBody = tableBody + '\n' + newRow;
380
+ }
381
+
382
+ recorded = true;
383
+ return content.replace(metricsPattern, (_match, header) => `${header}${tableBody}\n`);
348
384
  }
385
+ return content;
386
+ }, cwd);
349
387
 
350
- content = content.replace(metricsPattern, (_match, header) => `${header}${tableBody}\n`);
351
- writeStateMd(statePath, content, cwd);
388
+ if (recorded) {
352
389
  output({ recorded: true, phase, plan, duration }, raw, 'true');
353
390
  } else {
354
391
  output({ recorded: false, reason: 'Performance Metrics section not found in STATE.md' }, raw, 'false');
@@ -359,9 +396,7 @@ function cmdStateUpdateProgress(cwd, raw) {
359
396
  const statePath = planningPaths(cwd).state;
360
397
  if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; }
361
398
 
362
- let content = fs.readFileSync(statePath, 'utf-8');
363
-
364
- // Count summaries across current milestone phases only
399
+ // Count summaries across current milestone phases only (outside lock — read-only)
365
400
  const phasesDir = planningPaths(cwd).phases;
366
401
  let totalPlans = 0;
367
402
  let totalSummaries = 0;
@@ -384,17 +419,26 @@ function cmdStateUpdateProgress(cwd, raw) {
384
419
  const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(barWidth - filled);
385
420
  const progressStr = `[${bar}] ${percent}%`;
386
421
 
387
- // Try **Progress:** bold format first, then plain Progress: format
388
- const boldProgressPattern = /(\*\*Progress:\*\*\s*).*/i;
389
- const plainProgressPattern = /^(Progress:\s*).*/im;
390
- if (boldProgressPattern.test(content)) {
391
- content = content.replace(boldProgressPattern, (_match, prefix) => `${prefix}${progressStr}`);
392
- writeStateMd(statePath, content, cwd);
393
- output({ updated: true, percent, completed: totalSummaries, total: totalPlans, bar: progressStr }, raw, progressStr);
394
- } else if (plainProgressPattern.test(content)) {
395
- content = content.replace(plainProgressPattern, (_match, prefix) => `${prefix}${progressStr}`);
396
- writeStateMd(statePath, content, cwd);
397
- output({ updated: true, percent, completed: totalSummaries, total: totalPlans, bar: progressStr }, raw, progressStr);
422
+ let updated = false;
423
+ const _totalPlans = totalPlans;
424
+ const _totalSummaries = totalSummaries;
425
+
426
+ readModifyWriteStateMd(statePath, (content) => {
427
+ // Try **Progress:** bold format first, then plain Progress: format
428
+ const boldProgressPattern = /(\*\*Progress:\*\*\s*).*/i;
429
+ const plainProgressPattern = /^(Progress:\s*).*/im;
430
+ if (boldProgressPattern.test(content)) {
431
+ updated = true;
432
+ return content.replace(boldProgressPattern, (_match, prefix) => `${prefix}${progressStr}`);
433
+ } else if (plainProgressPattern.test(content)) {
434
+ updated = true;
435
+ return content.replace(plainProgressPattern, (_match, prefix) => `${prefix}${progressStr}`);
436
+ }
437
+ return content;
438
+ }, cwd);
439
+
440
+ if (updated) {
441
+ output({ updated: true, percent, completed: _totalSummaries, total: _totalPlans, bar: progressStr }, raw, progressStr);
398
442
  } else {
399
443
  output({ updated: false, reason: 'Progress field not found in STATE.md' }, raw, 'false');
400
444
  }
@@ -418,20 +462,26 @@ function cmdStateAddDecision(cwd, options, raw) {
418
462
 
419
463
  if (!summaryText) { output({ error: 'summary required' }, raw); return; }
420
464
 
421
- let content = fs.readFileSync(statePath, 'utf-8');
422
465
  const entry = `- [Phase ${phase || '?'}]: ${summaryText}${rationaleText ? ` — ${rationaleText}` : ''}`;
466
+ let added = false;
467
+
468
+ readModifyWriteStateMd(statePath, (content) => {
469
+ // Find Decisions section (various heading patterns)
470
+ const sectionPattern = /(###?\s*(?:Decisions|Decisions Made|Accumulated.*Decisions)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
471
+ const match = content.match(sectionPattern);
472
+
473
+ if (match) {
474
+ let sectionBody = match[2];
475
+ // Remove placeholders
476
+ sectionBody = sectionBody.replace(/None yet\.?\s*\n?/gi, '').replace(/No decisions yet\.?\s*\n?/gi, '');
477
+ sectionBody = sectionBody.trimEnd() + '\n' + entry + '\n';
478
+ added = true;
479
+ return content.replace(sectionPattern, (_match, header) => `${header}${sectionBody}`);
480
+ }
481
+ return content;
482
+ }, cwd);
423
483
 
424
- // Find Decisions section (various heading patterns)
425
- const sectionPattern = /(###?\s*(?:Decisions|Decisions Made|Accumulated.*Decisions)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
426
- const match = content.match(sectionPattern);
427
-
428
- if (match) {
429
- let sectionBody = match[2];
430
- // Remove placeholders
431
- sectionBody = sectionBody.replace(/None yet\.?\s*\n?/gi, '').replace(/No decisions yet\.?\s*\n?/gi, '');
432
- sectionBody = sectionBody.trimEnd() + '\n' + entry + '\n';
433
- content = content.replace(sectionPattern, (_match, header) => `${header}${sectionBody}`);
434
- writeStateMd(statePath, content, cwd);
484
+ if (added) {
435
485
  output({ added: true, decision: entry }, raw, 'true');
436
486
  } else {
437
487
  output({ added: false, reason: 'Decisions section not found in STATE.md' }, raw, 'false');
@@ -453,18 +503,24 @@ function cmdStateAddBlocker(cwd, text, raw) {
453
503
 
454
504
  if (!blockerText) { output({ error: 'text required' }, raw); return; }
455
505
 
456
- let content = fs.readFileSync(statePath, 'utf-8');
457
506
  const entry = `- ${blockerText}`;
507
+ let added = false;
508
+
509
+ readModifyWriteStateMd(statePath, (content) => {
510
+ const sectionPattern = /(###?\s*(?:Blockers|Blockers\/Concerns|Concerns)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
511
+ const match = content.match(sectionPattern);
512
+
513
+ if (match) {
514
+ let sectionBody = match[2];
515
+ sectionBody = sectionBody.replace(/None\.?\s*\n?/gi, '').replace(/None yet\.?\s*\n?/gi, '');
516
+ sectionBody = sectionBody.trimEnd() + '\n' + entry + '\n';
517
+ added = true;
518
+ return content.replace(sectionPattern, (_match, header) => `${header}${sectionBody}`);
519
+ }
520
+ return content;
521
+ }, cwd);
458
522
 
459
- const sectionPattern = /(###?\s*(?:Blockers|Blockers\/Concerns|Concerns)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
460
- const match = content.match(sectionPattern);
461
-
462
- if (match) {
463
- let sectionBody = match[2];
464
- sectionBody = sectionBody.replace(/None\.?\s*\n?/gi, '').replace(/None yet\.?\s*\n?/gi, '');
465
- sectionBody = sectionBody.trimEnd() + '\n' + entry + '\n';
466
- content = content.replace(sectionPattern, (_match, header) => `${header}${sectionBody}`);
467
- writeStateMd(statePath, content, cwd);
523
+ if (added) {
468
524
  output({ added: true, blocker: blockerText }, raw, 'true');
469
525
  } else {
470
526
  output({ added: false, reason: 'Blockers section not found in STATE.md' }, raw, 'false');
@@ -476,27 +532,33 @@ function cmdStateResolveBlocker(cwd, text, raw) {
476
532
  if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; }
477
533
  if (!text) { output({ error: 'text required' }, raw); return; }
478
534
 
479
- let content = fs.readFileSync(statePath, 'utf-8');
535
+ let resolved = false;
536
+
537
+ readModifyWriteStateMd(statePath, (content) => {
538
+ const sectionPattern = /(###?\s*(?:Blockers|Blockers\/Concerns|Concerns)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
539
+ const match = content.match(sectionPattern);
540
+
541
+ if (match) {
542
+ const sectionBody = match[2];
543
+ const lines = sectionBody.split('\n');
544
+ const filtered = lines.filter(line => {
545
+ if (!line.startsWith('- ')) return true;
546
+ return !line.toLowerCase().includes(text.toLowerCase());
547
+ });
480
548
 
481
- const sectionPattern = /(###?\s*(?:Blockers|Blockers\/Concerns|Concerns)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
482
- const match = content.match(sectionPattern);
483
-
484
- if (match) {
485
- const sectionBody = match[2];
486
- const lines = sectionBody.split('\n');
487
- const filtered = lines.filter(line => {
488
- if (!line.startsWith('- ')) return true;
489
- return !line.toLowerCase().includes(text.toLowerCase());
490
- });
491
-
492
- let newBody = filtered.join('\n');
493
- // If section is now empty, add placeholder
494
- if (!newBody.trim() || !newBody.includes('- ')) {
495
- newBody = 'None\n';
549
+ let newBody = filtered.join('\n');
550
+ // If section is now empty, add placeholder
551
+ if (!newBody.trim() || !newBody.includes('- ')) {
552
+ newBody = 'None\n';
553
+ }
554
+
555
+ resolved = true;
556
+ return content.replace(sectionPattern, (_match, header) => `${header}${newBody}`);
496
557
  }
558
+ return content;
559
+ }, cwd);
497
560
 
498
- content = content.replace(sectionPattern, (_match, header) => `${header}${newBody}`);
499
- writeStateMd(statePath, content, cwd);
561
+ if (resolved) {
500
562
  output({ resolved: true, blocker: text }, raw, 'true');
501
563
  } else {
502
564
  output({ resolved: false, reason: 'Blockers section not found in STATE.md' }, raw, 'false');
@@ -507,31 +569,33 @@ function cmdStateRecordSession(cwd, options, raw) {
507
569
  const statePath = planningPaths(cwd).state;
508
570
  if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; }
509
571
 
510
- let content = fs.readFileSync(statePath, 'utf-8');
511
572
  const now = new Date().toISOString();
512
573
  const updated = [];
513
574
 
514
- // Update Last session / Last Date
515
- let result = stateReplaceField(content, 'Last session', now);
516
- if (result) { content = result; updated.push('Last session'); }
517
- result = stateReplaceField(content, 'Last Date', now);
518
- if (result) { content = result; updated.push('Last Date'); }
519
-
520
- // Update Stopped at
521
- if (options.stopped_at) {
522
- result = stateReplaceField(content, 'Stopped At', options.stopped_at);
523
- if (!result) result = stateReplaceField(content, 'Stopped at', options.stopped_at);
524
- if (result) { content = result; updated.push('Stopped At'); }
525
- }
575
+ readModifyWriteStateMd(statePath, (content) => {
576
+ // Update Last session / Last Date
577
+ let result = stateReplaceField(content, 'Last session', now);
578
+ if (result) { content = result; updated.push('Last session'); }
579
+ result = stateReplaceField(content, 'Last Date', now);
580
+ if (result) { content = result; updated.push('Last Date'); }
581
+
582
+ // Update Stopped at
583
+ if (options.stopped_at) {
584
+ result = stateReplaceField(content, 'Stopped At', options.stopped_at);
585
+ if (!result) result = stateReplaceField(content, 'Stopped at', options.stopped_at);
586
+ if (result) { content = result; updated.push('Stopped At'); }
587
+ }
588
+
589
+ // Update Resume file
590
+ const resumeFile = options.resume_file || 'None';
591
+ result = stateReplaceField(content, 'Resume File', resumeFile);
592
+ if (!result) result = stateReplaceField(content, 'Resume file', resumeFile);
593
+ if (result) { content = result; updated.push('Resume File'); }
526
594
 
527
- // Update Resume file
528
- const resumeFile = options.resume_file || 'None';
529
- result = stateReplaceField(content, 'Resume File', resumeFile);
530
- if (!result) result = stateReplaceField(content, 'Resume file', resumeFile);
531
- if (result) { content = result; updated.push('Resume File'); }
595
+ return content;
596
+ }, cwd);
532
597
 
533
598
  if (updated.length > 0) {
534
- writeStateMd(statePath, content, cwd);
535
599
  output({ recorded: true, updated }, raw, 'true');
536
600
  } else {
537
601
  output({ recorded: false, reason: 'No session fields found in STATE.md' }, raw, 'false');
@@ -699,8 +763,14 @@ function buildStateFrontmatter(bodyContent, cwd) {
699
763
  } catch { /* intentionally empty */ }
700
764
  }
701
765
 
766
+ // Derive percent from disk counts when available (ground truth).
767
+ // Only falls back to the body Progress: field when no plan files exist on disk
768
+ // (phases directory empty or absent), which means disk has no authoritative data.
769
+ // This prevents a stale body "0%" from overriding the real 100% completion state.
702
770
  let progressPercent = null;
703
- if (progressRaw) {
771
+ if (totalPlans !== null && totalPlans > 0 && completedPlans !== null) {
772
+ progressPercent = Math.min(100, Math.round(completedPlans / totalPlans * 100));
773
+ } else if (progressRaw) {
704
774
  const pctMatch = progressRaw.match(/(\d+)%/);
705
775
  if (pctMatch) progressPercent = parseInt(pctMatch[1], 10);
706
776
  }
@@ -781,55 +851,83 @@ function syncStateFrontmatter(content, cwd) {
781
851
  }
782
852
 
783
853
  /**
784
- * Write STATE.md with synchronized YAML frontmatter.
785
- * All STATE.md writes should use this instead of raw writeFileSync.
786
- * Uses a simple lockfile to prevent parallel agents from overwriting
787
- * each other's changes (race condition with read-modify-write cycle).
854
+ * Acquire a lockfile for STATE.md operations.
855
+ * Returns the lock path for later release.
788
856
  */
789
- function writeStateMd(statePath, content, cwd) {
790
- const synced = syncStateFrontmatter(content, cwd);
857
+ function acquireStateLock(statePath) {
791
858
  const lockPath = statePath + '.lock';
792
859
  const maxRetries = 10;
793
860
  const retryDelay = 200; // ms
794
861
 
795
- // Acquire lock (spin with backoff)
796
862
  for (let i = 0; i < maxRetries; i++) {
797
863
  try {
798
- // O_EXCL fails if file already exists — atomic lock
799
864
  const fd = fs.openSync(lockPath, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY);
800
865
  fs.writeSync(fd, String(process.pid));
801
866
  fs.closeSync(fd);
802
- break;
867
+ // Register for exit-time cleanup so process.exit(1) inside a locked region
868
+ // cannot leave a stale lock file (#1916).
869
+ _heldStateLocks.add(lockPath);
870
+ return lockPath;
803
871
  } catch (err) {
804
872
  if (err.code === 'EEXIST') {
805
- // Check for stale lock (> 10s old)
806
873
  try {
807
874
  const stat = fs.statSync(lockPath);
808
875
  if (Date.now() - stat.mtimeMs > 10000) {
809
876
  fs.unlinkSync(lockPath);
810
- continue; // retry immediately after clearing stale lock
877
+ continue;
811
878
  }
812
879
  } catch { /* lock was released between check — retry */ }
813
880
 
814
881
  if (i === maxRetries - 1) {
815
- // Last resort: write anyway rather than losing data
816
882
  try { fs.unlinkSync(lockPath); } catch {}
817
- break;
883
+ return lockPath;
818
884
  }
819
- // Spin-wait with small jitter
820
885
  const jitter = Math.floor(Math.random() * 50);
821
- const start = Date.now();
822
- while (Date.now() - start < retryDelay + jitter) { /* busy wait */ }
886
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, retryDelay + jitter);
823
887
  continue;
824
888
  }
825
- break; // non-EEXIST error — proceed without lock
889
+ return lockPath; // non-EEXIST error — proceed without lock
826
890
  }
827
891
  }
892
+ return statePath + '.lock';
893
+ }
894
+
895
+ function releaseStateLock(lockPath) {
896
+ _heldStateLocks.delete(lockPath);
897
+ try { fs.unlinkSync(lockPath); } catch { /* lock already gone */ }
898
+ }
899
+
900
+ /**
901
+ * Write STATE.md with synchronized YAML frontmatter.
902
+ * All STATE.md writes should use this instead of raw writeFileSync.
903
+ * Uses a simple lockfile to prevent parallel agents from overwriting
904
+ * each other's changes (race condition with read-modify-write cycle).
905
+ */
906
+ function writeStateMd(statePath, content, cwd) {
907
+ const synced = syncStateFrontmatter(content, cwd);
908
+ const lockPath = acquireStateLock(statePath);
909
+ try {
910
+ atomicWriteFileSync(statePath, normalizeMd(synced), 'utf-8');
911
+ } finally {
912
+ releaseStateLock(lockPath);
913
+ }
914
+ }
828
915
 
916
+ /**
917
+ * Atomic read-modify-write for STATE.md.
918
+ * Holds the lock across the entire read -> transform -> write cycle,
919
+ * preventing the lost-update problem where two agents read the same
920
+ * content and the second write clobbers the first.
921
+ */
922
+ function readModifyWriteStateMd(statePath, transformFn, cwd) {
923
+ const lockPath = acquireStateLock(statePath);
829
924
  try {
830
- fs.writeFileSync(statePath, normalizeMd(synced), 'utf-8');
925
+ const content = fs.existsSync(statePath) ? fs.readFileSync(statePath, 'utf-8') : '';
926
+ const modified = transformFn(content);
927
+ const synced = syncStateFrontmatter(modified, cwd);
928
+ atomicWriteFileSync(statePath, normalizeMd(synced), 'utf-8');
831
929
  } finally {
832
- try { fs.unlinkSync(lockPath); } catch { /* lock already gone */ }
930
+ releaseStateLock(lockPath);
833
931
  }
834
932
  }
835
933
 
@@ -841,16 +939,27 @@ function cmdStateJson(cwd, raw) {
841
939
  }
842
940
 
843
941
  const content = fs.readFileSync(statePath, 'utf-8');
844
- const fm = extractFrontmatter(content);
942
+ const existingFm = extractFrontmatter(content);
943
+ const body = stripFrontmatter(content);
845
944
 
846
- if (!fm || Object.keys(fm).length === 0) {
847
- const body = stripFrontmatter(content);
848
- const built = buildStateFrontmatter(body, cwd);
849
- output(built, raw, JSON.stringify(built, null, 2));
850
- return;
945
+ // Always rebuild from body + disk so progress counters reflect current state.
946
+ // Returning cached frontmatter directly causes stale percent/completed_plans
947
+ // when SUMMARY files were added after the last STATE.md write (#1589).
948
+ const built = buildStateFrontmatter(body, cwd);
949
+
950
+ // Preserve frontmatter-only fields that cannot be recovered from the body.
951
+ if (existingFm && existingFm.stopped_at && !built.stopped_at) {
952
+ built.stopped_at = existingFm.stopped_at;
953
+ }
954
+ if (existingFm && existingFm.paused_at && !built.paused_at) {
955
+ built.paused_at = existingFm.paused_at;
956
+ }
957
+ // Preserve existing status when body-derived status is 'unknown' (same logic as syncStateFrontmatter).
958
+ if (built.status === 'unknown' && existingFm && existingFm.status && existingFm.status !== 'unknown') {
959
+ built.status = existingFm.status;
851
960
  }
852
961
 
853
- output(fm, raw, JSON.stringify(fm, null, 2));
962
+ output(built, raw, JSON.stringify(built, null, 2));
854
963
  }
855
964
 
856
965
  /**
@@ -866,96 +975,95 @@ function cmdStateBeginPhase(cwd, phaseNumber, phaseName, planCount, raw) {
866
975
  return;
867
976
  }
868
977
 
869
- let content = fs.readFileSync(statePath, 'utf-8');
870
978
  const today = new Date().toISOString().split('T')[0];
871
979
  const updated = [];
872
980
 
873
- // Update Status field
874
- const statusValue = `Executing Phase ${phaseNumber}`;
875
- let result = stateReplaceField(content, 'Status', statusValue);
876
- if (result) { content = result; updated.push('Status'); }
877
-
878
- // Update Last Activity
879
- result = stateReplaceField(content, 'Last Activity', today);
880
- if (result) { content = result; updated.push('Last Activity'); }
881
-
882
- // Update Last Activity Description if it exists
883
- const activityDesc = `Phase ${phaseNumber} execution started`;
884
- result = stateReplaceField(content, 'Last Activity Description', activityDesc);
885
- if (result) { content = result; updated.push('Last Activity Description'); }
886
-
887
- // Update Current Phase
888
- result = stateReplaceField(content, 'Current Phase', String(phaseNumber));
889
- if (result) { content = result; updated.push('Current Phase'); }
981
+ readModifyWriteStateMd(statePath, (content) => {
982
+ // Update Status field
983
+ const statusValue = `Executing Phase ${phaseNumber}`;
984
+ let result = stateReplaceField(content, 'Status', statusValue);
985
+ if (result) { content = result; updated.push('Status'); }
986
+
987
+ // Update Last Activity
988
+ result = stateReplaceField(content, 'Last Activity', today);
989
+ if (result) { content = result; updated.push('Last Activity'); }
990
+
991
+ // Update Last Activity Description if it exists
992
+ const activityDesc = `Phase ${phaseNumber} execution started`;
993
+ result = stateReplaceField(content, 'Last Activity Description', activityDesc);
994
+ if (result) { content = result; updated.push('Last Activity Description'); }
995
+
996
+ // Update Current Phase
997
+ result = stateReplaceField(content, 'Current Phase', String(phaseNumber));
998
+ if (result) { content = result; updated.push('Current Phase'); }
999
+
1000
+ // Update Current Phase Name
1001
+ if (phaseName) {
1002
+ result = stateReplaceField(content, 'Current Phase Name', phaseName);
1003
+ if (result) { content = result; updated.push('Current Phase Name'); }
1004
+ }
890
1005
 
891
- // Update Current Phase Name
892
- if (phaseName) {
893
- result = stateReplaceField(content, 'Current Phase Name', phaseName);
894
- if (result) { content = result; updated.push('Current Phase Name'); }
895
- }
1006
+ // Update Current Plan to 1 (starting from the first plan)
1007
+ result = stateReplaceField(content, 'Current Plan', '1');
1008
+ if (result) { content = result; updated.push('Current Plan'); }
896
1009
 
897
- // Update Current Plan to 1 (starting from the first plan)
898
- result = stateReplaceField(content, 'Current Plan', '1');
899
- if (result) { content = result; updated.push('Current Plan'); }
1010
+ // Update Total Plans in Phase
1011
+ if (planCount) {
1012
+ result = stateReplaceField(content, 'Total Plans in Phase', String(planCount));
1013
+ if (result) { content = result; updated.push('Total Plans in Phase'); }
1014
+ }
900
1015
 
901
- // Update Total Plans in Phase
902
- if (planCount) {
903
- result = stateReplaceField(content, 'Total Plans in Phase', String(planCount));
904
- if (result) { content = result; updated.push('Total Plans in Phase'); }
905
- }
1016
+ // Update **Current focus:** body text line (#1104)
1017
+ const focusLabel = phaseName ? `Phase ${phaseNumber} — ${phaseName}` : `Phase ${phaseNumber}`;
1018
+ const focusPattern = /(\*\*Current focus:\*\*\s*).*/i;
1019
+ if (focusPattern.test(content)) {
1020
+ content = content.replace(focusPattern, (_match, prefix) => `${prefix}${focusLabel}`);
1021
+ updated.push('Current focus');
1022
+ }
906
1023
 
907
- // Update **Current focus:** body text line (#1104)
908
- const focusLabel = phaseName ? `Phase ${phaseNumber} ${phaseName}` : `Phase ${phaseNumber}`;
909
- const focusPattern = /(\*\*Current focus:\*\*\s*).*/i;
910
- if (focusPattern.test(content)) {
911
- content = content.replace(focusPattern, (_match, prefix) => `${prefix}${focusLabel}`);
912
- updated.push('Current focus');
913
- }
1024
+ // Update ## Current Position section (#1104, #1365)
1025
+ // Update individual fields within Current Position instead of replacing the
1026
+ // entire section, so that Status, Last activity, and Progress are preserved.
1027
+ const positionPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i;
1028
+ const positionMatch = content.match(positionPattern);
1029
+ if (positionMatch) {
1030
+ const header = positionMatch[1];
1031
+ let posBody = positionMatch[2];
1032
+
1033
+ // Update or insert Phase line
1034
+ const newPhase = `Phase: ${phaseNumber}${phaseName ? ` (${phaseName})` : ''} — EXECUTING`;
1035
+ if (/^Phase:/m.test(posBody)) {
1036
+ posBody = posBody.replace(/^Phase:.*$/m, newPhase);
1037
+ } else {
1038
+ posBody = newPhase + '\n' + posBody;
1039
+ }
914
1040
 
915
- // Update ## Current Position section (#1104, #1365)
916
- // Update individual fields within Current Position instead of replacing the
917
- // entire section, so that Status, Last activity, and Progress are preserved.
918
- const positionPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i;
919
- const positionMatch = content.match(positionPattern);
920
- if (positionMatch) {
921
- const header = positionMatch[1];
922
- let posBody = positionMatch[2];
923
-
924
- // Update or insert Phase line
925
- const newPhase = `Phase: ${phaseNumber}${phaseName ? ` (${phaseName})` : ''} — EXECUTING`;
926
- if (/^Phase:/m.test(posBody)) {
927
- posBody = posBody.replace(/^Phase:.*$/m, newPhase);
928
- } else {
929
- posBody = newPhase + '\n' + posBody;
930
- }
1041
+ // Update or insert Plan line
1042
+ const newPlan = `Plan: 1 of ${planCount || '?'}`;
1043
+ if (/^Plan:/m.test(posBody)) {
1044
+ posBody = posBody.replace(/^Plan:.*$/m, newPlan);
1045
+ } else {
1046
+ posBody = posBody.replace(/^(Phase:.*$)/m, `$1\n${newPlan}`);
1047
+ }
931
1048
 
932
- // Update or insert Plan line
933
- const newPlan = `Plan: 1 of ${planCount || '?'}`;
934
- if (/^Plan:/m.test(posBody)) {
935
- posBody = posBody.replace(/^Plan:.*$/m, newPlan);
936
- } else {
937
- posBody = posBody.replace(/^(Phase:.*$)/m, `$1\n${newPlan}`);
938
- }
1049
+ // Update Status line if present
1050
+ const newStatus = `Status: Executing Phase ${phaseNumber}`;
1051
+ if (/^Status:/m.test(posBody)) {
1052
+ posBody = posBody.replace(/^Status:.*$/m, newStatus);
1053
+ }
939
1054
 
940
- // Update Status line if present
941
- const newStatus = `Status: Executing Phase ${phaseNumber}`;
942
- if (/^Status:/m.test(posBody)) {
943
- posBody = posBody.replace(/^Status:.*$/m, newStatus);
944
- }
1055
+ // Update Last activity line if present
1056
+ const newActivity = `Last activity: ${today} -- Phase ${phaseNumber} execution started`;
1057
+ if (/^Last activity:/im.test(posBody)) {
1058
+ posBody = posBody.replace(/^Last activity:.*$/im, newActivity);
1059
+ }
945
1060
 
946
- // Update Last activity line if present
947
- const newActivity = `Last activity: ${today} -- Phase ${phaseNumber} execution started`;
948
- if (/^Last activity:/im.test(posBody)) {
949
- posBody = posBody.replace(/^Last activity:.*$/im, newActivity);
1061
+ content = content.replace(positionPattern, `${header}${posBody}`);
1062
+ updated.push('Current Position');
950
1063
  }
951
1064
 
952
- content = content.replace(positionPattern, `${header}${posBody}`);
953
- updated.push('Current Position');
954
- }
955
-
956
- if (updated.length > 0) {
957
- writeStateMd(statePath, content, cwd);
958
- }
1065
+ return content;
1066
+ }, cwd);
959
1067
 
960
1068
  output({ updated, phase: phaseNumber, phase_name: phaseName || null, plan_count: planCount || null }, raw, updated.length > 0 ? 'true' : 'false');
961
1069
  }
@@ -1007,11 +1115,284 @@ function cmdSignalResume(cwd, raw) {
1007
1115
  output({ resumed: true, removed }, raw, removed ? 'true' : 'false');
1008
1116
  }
1009
1117
 
1118
+ // ─── Gate Functions (STATE.md consistency enforcement) ────────────────────────
1119
+
1120
+ /**
1121
+ * Update the ## Performance Metrics section in STATE.md content.
1122
+ * Increments Velocity totals and upserts a By Phase table row.
1123
+ * Returns modified content string.
1124
+ */
1125
+ function updatePerformanceMetricsSection(content, cwd, phaseNum, planCount, summaryCount) {
1126
+ // Update Velocity: Total plans completed
1127
+ const totalMatch = content.match(/Total plans completed:\s*(\d+|\[N\])/);
1128
+ const prevTotal = totalMatch && totalMatch[1] !== '[N]' ? parseInt(totalMatch[1], 10) : 0;
1129
+ const newTotal = prevTotal + summaryCount;
1130
+ content = content.replace(
1131
+ /Total plans completed:\s*(\d+|\[N\])/,
1132
+ `Total plans completed: ${newTotal}`
1133
+ );
1134
+
1135
+ // Update By Phase table — upsert row for this phase
1136
+ const byPhaseTablePattern = /(\|\s*Phase\s*\|\s*Plans\s*\|\s*Total\s*\|\s*Avg\/Plan\s*\|[ \t]*\n\|(?:[- :\t]+\|)+[ \t]*\n)((?:[ \t]*\|[^\n]*\n)*)(?=\n|$)/i;
1137
+ const byPhaseMatch = content.match(byPhaseTablePattern);
1138
+ if (byPhaseMatch) {
1139
+ let tableBody = byPhaseMatch[2].trim();
1140
+ const phaseRowPattern = new RegExp(`^\\|\\s*${escapeRegex(String(phaseNum))}\\s*\\|.*$`, 'm');
1141
+ const newRow = `| ${phaseNum} | ${summaryCount} | - | - |`;
1142
+
1143
+ if (phaseRowPattern.test(tableBody)) {
1144
+ // Update existing row
1145
+ tableBody = tableBody.replace(phaseRowPattern, newRow);
1146
+ } else {
1147
+ // Remove placeholder row and add new row
1148
+ tableBody = tableBody.replace(/^\|\s*-\s*\|\s*-\s*\|\s*-\s*\|\s*-\s*\|$/m, '').trim();
1149
+ tableBody = tableBody ? tableBody + '\n' + newRow : newRow;
1150
+ }
1151
+
1152
+ content = content.replace(byPhaseTablePattern, `$1${tableBody}\n`);
1153
+ }
1154
+
1155
+ return content;
1156
+ }
1157
+
1158
+ /**
1159
+ * Gate 3a: Record state after plan-phase completes.
1160
+ * Updates Status to "Ready to execute", Total Plans, Last Activity.
1161
+ */
1162
+ function cmdStatePlannedPhase(cwd, phaseNumber, planCount, raw) {
1163
+ const statePath = planningPaths(cwd).state;
1164
+ if (!fs.existsSync(statePath)) {
1165
+ output({ error: 'STATE.md not found' }, raw);
1166
+ return;
1167
+ }
1168
+
1169
+ let content = fs.readFileSync(statePath, 'utf-8');
1170
+ const today = new Date().toISOString().split('T')[0];
1171
+ const updated = [];
1172
+
1173
+ // Update Status
1174
+ let result = stateReplaceField(content, 'Status', 'Ready to execute');
1175
+ if (result) { content = result; updated.push('Status'); }
1176
+
1177
+ // Update Total Plans in Phase
1178
+ if (planCount !== null && planCount !== undefined) {
1179
+ result = stateReplaceField(content, 'Total Plans in Phase', String(planCount));
1180
+ if (result) { content = result; updated.push('Total Plans in Phase'); }
1181
+ }
1182
+
1183
+ // Update Last Activity
1184
+ result = stateReplaceField(content, 'Last Activity', today);
1185
+ if (result) { content = result; updated.push('Last Activity'); }
1186
+
1187
+ // Update Last Activity Description
1188
+ result = stateReplaceField(content, 'Last Activity Description', `Phase ${phaseNumber} planning complete — ${planCount || '?'} plans ready`);
1189
+ if (result) { content = result; updated.push('Last Activity Description'); }
1190
+
1191
+ // Update Current Position section
1192
+ content = updateCurrentPositionFields(content, {
1193
+ status: 'Ready to execute',
1194
+ lastActivity: `${today} -- Phase ${phaseNumber} planning complete`,
1195
+ });
1196
+
1197
+ if (updated.length > 0) {
1198
+ writeStateMd(statePath, content, cwd);
1199
+ }
1200
+
1201
+ output({ updated, phase: phaseNumber, plan_count: planCount }, raw, updated.length > 0 ? 'true' : 'false');
1202
+ }
1203
+
1204
+ /**
1205
+ * Gate 1: Validate STATE.md against filesystem.
1206
+ * Returns { valid, warnings, drift } JSON.
1207
+ */
1208
+ function cmdStateValidate(cwd, raw) {
1209
+ const statePath = planningPaths(cwd).state;
1210
+ if (!fs.existsSync(statePath)) {
1211
+ output({ error: 'STATE.md not found' }, raw);
1212
+ return;
1213
+ }
1214
+
1215
+ const content = fs.readFileSync(statePath, 'utf-8');
1216
+ const warnings = [];
1217
+ const drift = {};
1218
+
1219
+ const status = stateExtractField(content, 'Status') || '';
1220
+ const currentPhase = stateExtractField(content, 'Current Phase');
1221
+ const totalPlansRaw = stateExtractField(content, 'Total Plans in Phase');
1222
+ const totalPlansInPhase = totalPlansRaw ? parseInt(totalPlansRaw, 10) : null;
1223
+
1224
+ const phasesDir = planningPaths(cwd).phases;
1225
+
1226
+ // Scan disk for current phase
1227
+ if (currentPhase && fs.existsSync(phasesDir)) {
1228
+ const normalized = currentPhase.replace(/\s+of\s+\d+.*/, '').trim();
1229
+ try {
1230
+ const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
1231
+ const phaseDir = entries.find(e => e.isDirectory() && e.name.startsWith(normalized.replace(/^0+/, '').padStart(2, '0')));
1232
+ if (phaseDir) {
1233
+ const phaseDirPath = path.join(phasesDir, phaseDir.name);
1234
+ const files = fs.readdirSync(phaseDirPath);
1235
+ const diskPlans = files.filter(f => f.match(/-PLAN\.md$/i)).length;
1236
+ const diskSummaries = files.filter(f => f.match(/-SUMMARY\.md$/i)).length;
1237
+
1238
+ // Check plan count mismatch
1239
+ if (totalPlansInPhase !== null && diskPlans !== totalPlansInPhase) {
1240
+ warnings.push(`Plan count mismatch: STATE.md says ${totalPlansInPhase} plans, disk has ${diskPlans}`);
1241
+ drift.plan_count = { state: totalPlansInPhase, disk: diskPlans };
1242
+ }
1243
+
1244
+ // Check for VERIFICATION.md
1245
+ const verificationFiles = files.filter(f => f.includes('VERIFICATION') && f.endsWith('.md'));
1246
+ for (const vf of verificationFiles) {
1247
+ try {
1248
+ const vContent = fs.readFileSync(path.join(phaseDirPath, vf), 'utf-8');
1249
+ if (/status:\s*passed/i.test(vContent) && /executing/i.test(status)) {
1250
+ warnings.push(`Status drift: STATE.md says "${status}" but ${vf} shows verification passed — phase may be complete`);
1251
+ drift.verification_status = { state_status: status, verification: 'passed' };
1252
+ }
1253
+ } catch { /* intentionally empty */ }
1254
+ }
1255
+
1256
+ // Check if all plans have summaries but status still says executing
1257
+ if (diskPlans > 0 && diskSummaries >= diskPlans && /executing/i.test(status)) {
1258
+ // Only warn if no verification exists (if verification passed, the above warning covers it)
1259
+ if (verificationFiles.length === 0) {
1260
+ warnings.push(`All ${diskPlans} plans have summaries but status is still "${status}" — phase may be ready for verification`);
1261
+ }
1262
+ }
1263
+ }
1264
+ } catch { /* intentionally empty */ }
1265
+ }
1266
+
1267
+ const valid = warnings.length === 0;
1268
+ output({ valid, warnings, drift }, raw);
1269
+ }
1270
+
1271
+ /**
1272
+ * Gate 2: Sync STATE.md from filesystem ground truth.
1273
+ * Scans phase dirs, reconstructs counters, progress, metrics.
1274
+ * Supports --verify for dry-run mode.
1275
+ */
1276
+ function cmdStateSync(cwd, options, raw) {
1277
+ const statePath = planningPaths(cwd).state;
1278
+ if (!fs.existsSync(statePath)) {
1279
+ output({ error: 'STATE.md not found' }, raw);
1280
+ return;
1281
+ }
1282
+
1283
+ const verify = options && options.verify;
1284
+ const content = fs.readFileSync(statePath, 'utf-8');
1285
+ const changes = [];
1286
+ let modified = content;
1287
+ const today = new Date().toISOString().split('T')[0];
1288
+
1289
+ const phasesDir = planningPaths(cwd).phases;
1290
+ if (!fs.existsSync(phasesDir)) {
1291
+ output({ synced: true, changes: [], dry_run: !!verify }, raw);
1292
+ return;
1293
+ }
1294
+
1295
+ // Scan all phases
1296
+ let entries;
1297
+ try {
1298
+ entries = fs.readdirSync(phasesDir, { withFileTypes: true })
1299
+ .filter(e => e.isDirectory())
1300
+ .map(e => e.name)
1301
+ .sort();
1302
+ } catch {
1303
+ output({ synced: true, changes: [], dry_run: !!verify }, raw);
1304
+ return;
1305
+ }
1306
+
1307
+ let totalDiskPlans = 0;
1308
+ let totalDiskSummaries = 0;
1309
+ let highestIncompletePhase = null;
1310
+ let highestIncompletePhaseNum = null;
1311
+ let highestIncompletePhaseplanCount = 0;
1312
+ let highestIncompletePhaseSummaryCount = 0;
1313
+
1314
+ for (const dir of entries) {
1315
+ const dirPath = path.join(phasesDir, dir);
1316
+ const files = fs.readdirSync(dirPath);
1317
+ const plans = files.filter(f => f.match(/-PLAN\.md$/i)).length;
1318
+ const summaries = files.filter(f => f.match(/-SUMMARY\.md$/i)).length;
1319
+ totalDiskPlans += plans;
1320
+ totalDiskSummaries += summaries;
1321
+
1322
+ // Track the highest phase with incomplete plans (or any plans)
1323
+ const phaseMatch = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)/i);
1324
+ if (phaseMatch && plans > 0) {
1325
+ if (summaries < plans) {
1326
+ // Incomplete phase — this is likely the current one
1327
+ highestIncompletePhase = dir;
1328
+ highestIncompletePhaseNum = phaseMatch[1];
1329
+ highestIncompletePhaseplanCount = plans;
1330
+ highestIncompletePhaseSummaryCount = summaries;
1331
+ } else if (!highestIncompletePhase) {
1332
+ // All complete, track as potential current
1333
+ highestIncompletePhase = dir;
1334
+ highestIncompletePhaseNum = phaseMatch[1];
1335
+ highestIncompletePhaseplanCount = plans;
1336
+ highestIncompletePhaseSummaryCount = summaries;
1337
+ }
1338
+ }
1339
+ }
1340
+
1341
+ // Sync Total Plans in Phase
1342
+ if (highestIncompletePhase) {
1343
+ const currentPlansField = stateExtractField(modified, 'Total Plans in Phase');
1344
+ if (currentPlansField && parseInt(currentPlansField, 10) !== highestIncompletePhaseplanCount) {
1345
+ changes.push(`Total Plans in Phase: ${currentPlansField} -> ${highestIncompletePhaseplanCount}`);
1346
+ const result = stateReplaceField(modified, 'Total Plans in Phase', String(highestIncompletePhaseplanCount));
1347
+ if (result) modified = result;
1348
+ }
1349
+ }
1350
+
1351
+ // Sync Progress
1352
+ const percent = totalDiskPlans > 0 ? Math.min(100, Math.round(totalDiskSummaries / totalDiskPlans * 100)) : 0;
1353
+ const currentProgress = stateExtractField(modified, 'Progress');
1354
+ if (currentProgress) {
1355
+ const currentPercent = parseInt(currentProgress.replace(/[^\d]/g, ''), 10);
1356
+ if (currentPercent !== percent) {
1357
+ const barWidth = 10;
1358
+ const filled = Math.round(percent / 100 * barWidth);
1359
+ const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(barWidth - filled);
1360
+ const progressStr = `[${bar}] ${percent}%`;
1361
+ changes.push(`Progress: ${currentProgress} -> ${progressStr}`);
1362
+ const result = stateReplaceField(modified, 'Progress', progressStr);
1363
+ if (result) modified = result;
1364
+ }
1365
+ }
1366
+
1367
+ // Sync Last Activity
1368
+ const result = stateReplaceField(modified, 'Last Activity', today);
1369
+ if (result) {
1370
+ const oldActivity = stateExtractField(modified, 'Last Activity');
1371
+ if (oldActivity !== today) {
1372
+ changes.push(`Last Activity: ${oldActivity} -> ${today}`);
1373
+ }
1374
+ modified = result;
1375
+ }
1376
+
1377
+ if (verify) {
1378
+ output({ synced: false, changes, dry_run: true }, raw);
1379
+ return;
1380
+ }
1381
+
1382
+ if (changes.length > 0 || modified !== content) {
1383
+ writeStateMd(statePath, modified, cwd);
1384
+ }
1385
+
1386
+ output({ synced: true, changes, dry_run: false }, raw);
1387
+ }
1388
+
1010
1389
  module.exports = {
1011
1390
  stateExtractField,
1012
1391
  stateReplaceField,
1013
1392
  stateReplaceFieldWithFallback,
1014
1393
  writeStateMd,
1394
+ readModifyWriteStateMd,
1395
+ updatePerformanceMetricsSection,
1015
1396
  cmdStateLoad,
1016
1397
  cmdStateGet,
1017
1398
  cmdStatePatch,
@@ -1026,6 +1407,9 @@ module.exports = {
1026
1407
  cmdStateSnapshot,
1027
1408
  cmdStateJson,
1028
1409
  cmdStateBeginPhase,
1410
+ cmdStatePlannedPhase,
1411
+ cmdStateValidate,
1412
+ cmdStateSync,
1029
1413
  cmdSignalWaiting,
1030
1414
  cmdSignalResume,
1031
1415
  };