gsd-opencode 1.22.1 → 1.33.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 (188) hide show
  1. package/agents/gsd-advisor-researcher.md +112 -0
  2. package/agents/gsd-assumptions-analyzer.md +110 -0
  3. package/agents/gsd-codebase-mapper.md +0 -2
  4. package/agents/gsd-debugger.md +117 -2
  5. package/agents/gsd-doc-verifier.md +207 -0
  6. package/agents/gsd-doc-writer.md +608 -0
  7. package/agents/gsd-executor.md +45 -4
  8. package/agents/gsd-integration-checker.md +0 -2
  9. package/agents/gsd-nyquist-auditor.md +0 -2
  10. package/agents/gsd-phase-researcher.md +191 -5
  11. package/agents/gsd-plan-checker.md +152 -5
  12. package/agents/gsd-planner.md +131 -157
  13. package/agents/gsd-project-researcher.md +28 -3
  14. package/agents/gsd-research-synthesizer.md +0 -2
  15. package/agents/gsd-roadmapper.md +29 -2
  16. package/agents/gsd-security-auditor.md +129 -0
  17. package/agents/gsd-ui-auditor.md +485 -0
  18. package/agents/gsd-ui-checker.md +305 -0
  19. package/agents/gsd-ui-researcher.md +368 -0
  20. package/agents/gsd-user-profiler.md +173 -0
  21. package/agents/gsd-verifier.md +207 -22
  22. package/commands/gsd/gsd-add-backlog.md +76 -0
  23. package/commands/gsd/gsd-analyze-dependencies.md +34 -0
  24. package/commands/gsd/gsd-audit-uat.md +24 -0
  25. package/commands/gsd/gsd-autonomous.md +45 -0
  26. package/commands/gsd/gsd-cleanup.md +5 -0
  27. package/commands/gsd/gsd-debug.md +29 -21
  28. package/commands/gsd/gsd-discuss-phase.md +15 -36
  29. package/commands/gsd/gsd-do.md +30 -0
  30. package/commands/gsd/gsd-docs-update.md +48 -0
  31. package/commands/gsd/gsd-execute-phase.md +24 -2
  32. package/commands/gsd/gsd-fast.md +30 -0
  33. package/commands/gsd/gsd-forensics.md +56 -0
  34. package/commands/gsd/gsd-help.md +2 -0
  35. package/commands/gsd/gsd-join-discord.md +2 -1
  36. package/commands/gsd/gsd-list-workspaces.md +19 -0
  37. package/commands/gsd/gsd-manager.md +40 -0
  38. package/commands/gsd/gsd-milestone-summary.md +51 -0
  39. package/commands/gsd/gsd-new-project.md +4 -0
  40. package/commands/gsd/gsd-new-workspace.md +44 -0
  41. package/commands/gsd/gsd-next.md +24 -0
  42. package/commands/gsd/gsd-note.md +34 -0
  43. package/commands/gsd/gsd-plan-phase.md +8 -1
  44. package/commands/gsd/gsd-plant-seed.md +28 -0
  45. package/commands/gsd/gsd-pr-branch.md +25 -0
  46. package/commands/gsd/gsd-profile-user.md +46 -0
  47. package/commands/gsd/gsd-quick.md +7 -3
  48. package/commands/gsd/gsd-reapply-patches.md +178 -45
  49. package/commands/gsd/gsd-remove-workspace.md +26 -0
  50. package/commands/gsd/gsd-research-phase.md +7 -12
  51. package/commands/gsd/gsd-review-backlog.md +62 -0
  52. package/commands/gsd/gsd-review.md +38 -0
  53. package/commands/gsd/gsd-secure-phase.md +35 -0
  54. package/commands/gsd/gsd-session-report.md +19 -0
  55. package/commands/gsd/gsd-set-profile.md +24 -23
  56. package/commands/gsd/gsd-ship.md +23 -0
  57. package/commands/gsd/gsd-stats.md +18 -0
  58. package/commands/gsd/gsd-thread.md +127 -0
  59. package/commands/gsd/gsd-ui-phase.md +34 -0
  60. package/commands/gsd/gsd-ui-review.md +32 -0
  61. package/commands/gsd/gsd-workstreams.md +71 -0
  62. package/get-shit-done/bin/gsd-tools.cjs +450 -90
  63. package/get-shit-done/bin/lib/commands.cjs +489 -24
  64. package/get-shit-done/bin/lib/config.cjs +329 -48
  65. package/get-shit-done/bin/lib/core.cjs +1143 -102
  66. package/get-shit-done/bin/lib/docs.cjs +267 -0
  67. package/get-shit-done/bin/lib/frontmatter.cjs +125 -43
  68. package/get-shit-done/bin/lib/init.cjs +918 -106
  69. package/get-shit-done/bin/lib/milestone.cjs +65 -33
  70. package/get-shit-done/bin/lib/model-profiles.cjs +70 -0
  71. package/get-shit-done/bin/lib/phase.cjs +434 -404
  72. package/get-shit-done/bin/lib/profile-output.cjs +1048 -0
  73. package/get-shit-done/bin/lib/profile-pipeline.cjs +539 -0
  74. package/get-shit-done/bin/lib/roadmap.cjs +156 -101
  75. package/get-shit-done/bin/lib/schema-detect.cjs +238 -0
  76. package/get-shit-done/bin/lib/security.cjs +384 -0
  77. package/get-shit-done/bin/lib/state.cjs +711 -79
  78. package/get-shit-done/bin/lib/template.cjs +2 -2
  79. package/get-shit-done/bin/lib/uat.cjs +282 -0
  80. package/get-shit-done/bin/lib/verify.cjs +254 -42
  81. package/get-shit-done/bin/lib/workstream.cjs +495 -0
  82. package/get-shit-done/references/agent-contracts.md +79 -0
  83. package/get-shit-done/references/artifact-types.md +113 -0
  84. package/get-shit-done/references/checkpoints.md +12 -10
  85. package/get-shit-done/references/context-budget.md +49 -0
  86. package/get-shit-done/references/continuation-format.md +15 -15
  87. package/get-shit-done/references/decimal-phase-calculation.md +2 -3
  88. package/get-shit-done/references/domain-probes.md +125 -0
  89. package/get-shit-done/references/gate-prompts.md +100 -0
  90. package/get-shit-done/references/git-integration.md +47 -0
  91. package/get-shit-done/references/model-profile-resolution.md +2 -0
  92. package/get-shit-done/references/model-profiles.md +62 -16
  93. package/get-shit-done/references/phase-argument-parsing.md +2 -2
  94. package/get-shit-done/references/planner-gap-closure.md +62 -0
  95. package/get-shit-done/references/planner-reviews.md +39 -0
  96. package/get-shit-done/references/planner-revision.md +87 -0
  97. package/get-shit-done/references/planning-config.md +18 -1
  98. package/get-shit-done/references/revision-loop.md +97 -0
  99. package/get-shit-done/references/ui-brand.md +2 -2
  100. package/get-shit-done/references/universal-anti-patterns.md +58 -0
  101. package/get-shit-done/references/user-profiling.md +681 -0
  102. package/get-shit-done/references/workstream-flag.md +111 -0
  103. package/get-shit-done/templates/SECURITY.md +61 -0
  104. package/get-shit-done/templates/UAT.md +21 -3
  105. package/get-shit-done/templates/UI-SPEC.md +100 -0
  106. package/get-shit-done/templates/VALIDATION.md +3 -3
  107. package/get-shit-done/templates/claude-md.md +145 -0
  108. package/get-shit-done/templates/config.json +14 -3
  109. package/get-shit-done/templates/context.md +61 -6
  110. package/get-shit-done/templates/debug-subagent-prompt.md +2 -6
  111. package/get-shit-done/templates/dev-preferences.md +21 -0
  112. package/get-shit-done/templates/discussion-log.md +63 -0
  113. package/get-shit-done/templates/phase-prompt.md +46 -5
  114. package/get-shit-done/templates/planner-subagent-prompt.md +2 -10
  115. package/get-shit-done/templates/project.md +2 -0
  116. package/get-shit-done/templates/state.md +2 -2
  117. package/get-shit-done/templates/user-profile.md +146 -0
  118. package/get-shit-done/workflows/add-phase.md +4 -4
  119. package/get-shit-done/workflows/add-tests.md +4 -4
  120. package/get-shit-done/workflows/add-todo.md +4 -4
  121. package/get-shit-done/workflows/analyze-dependencies.md +96 -0
  122. package/get-shit-done/workflows/audit-milestone.md +20 -16
  123. package/get-shit-done/workflows/audit-uat.md +109 -0
  124. package/get-shit-done/workflows/autonomous.md +1036 -0
  125. package/get-shit-done/workflows/check-todos.md +4 -4
  126. package/get-shit-done/workflows/cleanup.md +4 -4
  127. package/get-shit-done/workflows/complete-milestone.md +22 -10
  128. package/get-shit-done/workflows/diagnose-issues.md +21 -7
  129. package/get-shit-done/workflows/discovery-phase.md +2 -2
  130. package/get-shit-done/workflows/discuss-phase-assumptions.md +671 -0
  131. package/get-shit-done/workflows/discuss-phase-power.md +291 -0
  132. package/get-shit-done/workflows/discuss-phase.md +558 -47
  133. package/get-shit-done/workflows/do.md +104 -0
  134. package/get-shit-done/workflows/docs-update.md +1093 -0
  135. package/get-shit-done/workflows/execute-phase.md +741 -58
  136. package/get-shit-done/workflows/execute-plan.md +77 -12
  137. package/get-shit-done/workflows/fast.md +105 -0
  138. package/get-shit-done/workflows/forensics.md +265 -0
  139. package/get-shit-done/workflows/health.md +28 -6
  140. package/get-shit-done/workflows/help.md +127 -7
  141. package/get-shit-done/workflows/insert-phase.md +4 -4
  142. package/get-shit-done/workflows/list-phase-assumptions.md +2 -2
  143. package/get-shit-done/workflows/list-workspaces.md +56 -0
  144. package/get-shit-done/workflows/manager.md +363 -0
  145. package/get-shit-done/workflows/map-codebase.md +83 -44
  146. package/get-shit-done/workflows/milestone-summary.md +223 -0
  147. package/get-shit-done/workflows/new-milestone.md +133 -25
  148. package/get-shit-done/workflows/new-project.md +216 -54
  149. package/get-shit-done/workflows/new-workspace.md +237 -0
  150. package/get-shit-done/workflows/next.md +97 -0
  151. package/get-shit-done/workflows/node-repair.md +92 -0
  152. package/get-shit-done/workflows/note.md +156 -0
  153. package/get-shit-done/workflows/pause-work.md +132 -15
  154. package/get-shit-done/workflows/plan-milestone-gaps.md +6 -7
  155. package/get-shit-done/workflows/plan-phase.md +513 -62
  156. package/get-shit-done/workflows/plant-seed.md +169 -0
  157. package/get-shit-done/workflows/pr-branch.md +129 -0
  158. package/get-shit-done/workflows/profile-user.md +450 -0
  159. package/get-shit-done/workflows/progress.md +154 -29
  160. package/get-shit-done/workflows/quick.md +285 -111
  161. package/get-shit-done/workflows/remove-phase.md +2 -2
  162. package/get-shit-done/workflows/remove-workspace.md +90 -0
  163. package/get-shit-done/workflows/research-phase.md +13 -9
  164. package/get-shit-done/workflows/resume-project.md +37 -18
  165. package/get-shit-done/workflows/review.md +281 -0
  166. package/get-shit-done/workflows/secure-phase.md +154 -0
  167. package/get-shit-done/workflows/session-report.md +146 -0
  168. package/get-shit-done/workflows/set-profile.md +2 -2
  169. package/get-shit-done/workflows/settings.md +91 -11
  170. package/get-shit-done/workflows/ship.md +237 -0
  171. package/get-shit-done/workflows/stats.md +60 -0
  172. package/get-shit-done/workflows/transition.md +150 -23
  173. package/get-shit-done/workflows/ui-phase.md +292 -0
  174. package/get-shit-done/workflows/ui-review.md +183 -0
  175. package/get-shit-done/workflows/update.md +262 -30
  176. package/get-shit-done/workflows/validate-phase.md +14 -17
  177. package/get-shit-done/workflows/verify-phase.md +143 -11
  178. package/get-shit-done/workflows/verify-work.md +141 -39
  179. package/package.json +1 -1
  180. package/skills/gsd-audit-milestone/SKILL.md +29 -0
  181. package/skills/gsd-cleanup/SKILL.md +19 -0
  182. package/skills/gsd-complete-milestone/SKILL.md +131 -0
  183. package/skills/gsd-discuss-phase/SKILL.md +54 -0
  184. package/skills/gsd-execute-phase/SKILL.md +49 -0
  185. package/skills/gsd-plan-phase/SKILL.md +37 -0
  186. package/skills/gsd-ui-phase/SKILL.md +24 -0
  187. package/skills/gsd-ui-review/SKILL.md +24 -0
  188. package/skills/gsd-verify-work/SKILL.md +30 -0
@@ -4,9 +4,14 @@
4
4
 
5
5
  const fs = require('fs');
6
6
  const path = require('path');
7
- const { escapeRegex, loadConfig, getMilestoneInfo, getMilestonePhaseFilter, output, error } = require('./core.cjs');
7
+ const { escapeRegex, loadConfig, getMilestoneInfo, getMilestonePhaseFilter, normalizeMd, planningDir, planningPaths, output, error } = require('./core.cjs');
8
8
  const { extractFrontmatter, reconstructFrontmatter } = require('./frontmatter.cjs');
9
9
 
10
+ /** Shorthand — every state command needs this path */
11
+ function getStatePath(cwd) {
12
+ return planningPaths(cwd).state;
13
+ }
14
+
10
15
  // Shared helper: extract a field value from STATE.md content.
11
16
  // Supports both **Field:** bold and plain Field: format.
12
17
  function stateExtractField(content, fieldName) {
@@ -21,15 +26,15 @@ function stateExtractField(content, fieldName) {
21
26
 
22
27
  function cmdStateLoad(cwd, raw) {
23
28
  const config = loadConfig(cwd);
24
- const planningDir = path.join(cwd, '.planning');
29
+ const planDir = planningPaths(cwd).planning;
25
30
 
26
31
  let stateRaw = '';
27
32
  try {
28
- stateRaw = fs.readFileSync(path.join(planningDir, 'STATE.md'), 'utf-8');
29
- } catch {}
33
+ stateRaw = fs.readFileSync(path.join(planDir, 'STATE.md'), 'utf-8');
34
+ } catch { /* intentionally empty */ }
30
35
 
31
- const configExists = fs.existsSync(path.join(planningDir, 'config.json'));
32
- const roadmapExists = fs.existsSync(path.join(planningDir, 'ROADMAP.md'));
36
+ const configExists = fs.existsSync(path.join(planDir, 'config.json'));
37
+ const roadmapExists = fs.existsSync(path.join(planDir, 'ROADMAP.md'));
33
38
  const stateExists = stateRaw.length > 0;
34
39
 
35
40
  const result = {
@@ -65,7 +70,7 @@ function cmdStateLoad(cwd, raw) {
65
70
  }
66
71
 
67
72
  function cmdStateGet(cwd, section, raw) {
68
- const statePath = path.join(cwd, '.planning', 'STATE.md');
73
+ const statePath = planningPaths(cwd).state;
69
74
  try {
70
75
  const content = fs.readFileSync(statePath, 'utf-8');
71
76
 
@@ -75,7 +80,7 @@ function cmdStateGet(cwd, section, raw) {
75
80
  }
76
81
 
77
82
  // Try to find markdown section or field
78
- const fieldEscaped = section.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
83
+ const fieldEscaped = escapeRegex(section);
79
84
 
80
85
  // Check for **field:** value (bold format)
81
86
  const boldPattern = new RegExp(`\\*\\*${fieldEscaped}:\\*\\*\\s*(.*)`, 'i');
@@ -110,40 +115,54 @@ function cmdStateGet(cwd, section, raw) {
110
115
  function readTextArgOrFile(cwd, value, filePath, label) {
111
116
  if (!filePath) return value;
112
117
 
113
- const resolvedPath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath);
118
+ // Path traversal guard: ensure file resolves within project directory
119
+ const { validatePath } = require('./security.cjs');
120
+ const pathCheck = validatePath(filePath, cwd, { allowAbsolute: true });
121
+ if (!pathCheck.safe) {
122
+ throw new Error(`${label} path rejected: ${pathCheck.error}`);
123
+ }
124
+
114
125
  try {
115
- return fs.readFileSync(resolvedPath, 'utf-8').trimEnd();
126
+ return fs.readFileSync(pathCheck.resolved, 'utf-8').trimEnd();
116
127
  } catch {
117
128
  throw new Error(`${label} file not found: ${filePath}`);
118
129
  }
119
130
  }
120
131
 
121
132
  function cmdStatePatch(cwd, patches, raw) {
122
- const statePath = path.join(cwd, '.planning', 'STATE.md');
133
+ // Validate all field names before processing
134
+ const { validateFieldName } = require('./security.cjs');
135
+ for (const field of Object.keys(patches)) {
136
+ const fieldCheck = validateFieldName(field);
137
+ if (!fieldCheck.valid) {
138
+ error(`state patch: ${fieldCheck.error}`);
139
+ }
140
+ }
141
+
142
+ const statePath = planningPaths(cwd).state;
123
143
  try {
124
- let content = fs.readFileSync(statePath, 'utf-8');
125
144
  const results = { updated: [], failed: [] };
126
145
 
127
- for (const [field, value] of Object.entries(patches)) {
128
- const fieldEscaped = field.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
129
- // Try **Field:** bold format first, then plain Field: format
130
- const boldPattern = new RegExp(`(\\*\\*${fieldEscaped}:\\*\\*\\s*)(.*)`, 'i');
131
- const plainPattern = new RegExp(`(^${fieldEscaped}:\\s*)(.*)`, 'im');
132
-
133
- if (boldPattern.test(content)) {
134
- content = content.replace(boldPattern, (_match, prefix) => `${prefix}${value}`);
135
- results.updated.push(field);
136
- } else if (plainPattern.test(content)) {
137
- content = content.replace(plainPattern, (_match, prefix) => `${prefix}${value}`);
138
- results.updated.push(field);
139
- } else {
140
- results.failed.push(field);
146
+ // Use atomic read-modify-write to prevent lost updates from concurrent agents
147
+ readModifyWriteStateMd(statePath, (content) => {
148
+ for (const [field, value] of Object.entries(patches)) {
149
+ const fieldEscaped = escapeRegex(field);
150
+ // Try **Field:** bold format first, then plain Field: format
151
+ const boldPattern = new RegExp(`(\\*\\*${fieldEscaped}:\\*\\*\\s*)(.*)`, 'i');
152
+ const plainPattern = new RegExp(`(^${fieldEscaped}:\\s*)(.*)`, 'im');
153
+
154
+ if (boldPattern.test(content)) {
155
+ content = content.replace(boldPattern, (_match, prefix) => `${prefix}${value}`);
156
+ results.updated.push(field);
157
+ } else if (plainPattern.test(content)) {
158
+ content = content.replace(plainPattern, (_match, prefix) => `${prefix}${value}`);
159
+ results.updated.push(field);
160
+ } else {
161
+ results.failed.push(field);
162
+ }
141
163
  }
142
- }
143
-
144
- if (results.updated.length > 0) {
145
- writeStateMd(statePath, content, cwd);
146
- }
164
+ return content;
165
+ }, cwd);
147
166
 
148
167
  output(results, raw, results.updated.length > 0 ? 'true' : 'false');
149
168
  } catch {
@@ -156,10 +175,17 @@ function cmdStateUpdate(cwd, field, value) {
156
175
  error('field and value required for state update');
157
176
  }
158
177
 
159
- const statePath = path.join(cwd, '.planning', 'STATE.md');
178
+ // Validate field name to prevent regex injection via crafted field names
179
+ const { validateFieldName } = require('./security.cjs');
180
+ const fieldCheck = validateFieldName(field);
181
+ if (!fieldCheck.valid) {
182
+ error(`state update: ${fieldCheck.error}`);
183
+ }
184
+
185
+ const statePath = planningPaths(cwd).state;
160
186
  try {
161
187
  let content = fs.readFileSync(statePath, 'utf-8');
162
- const fieldEscaped = field.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
188
+ const fieldEscaped = escapeRegex(field);
163
189
  // Try **Field:** bold format first, then plain Field: format
164
190
  const boldPattern = new RegExp(`(\\*\\*${fieldEscaped}:\\*\\*\\s*)(.*)`, 'i');
165
191
  const plainPattern = new RegExp(`(^${fieldEscaped}:\\s*)(.*)`, 'im');
@@ -180,21 +206,10 @@ function cmdStateUpdate(cwd, field, value) {
180
206
  }
181
207
 
182
208
  // ─── State Progression Engine ────────────────────────────────────────────────
183
-
184
- function stateExtractField(content, fieldName) {
185
- const escaped = fieldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
186
- // Try **Field:** bold format first
187
- const boldPattern = new RegExp(`\\*\\*${escaped}:\\*\\*\\s*(.+)`, 'i');
188
- const boldMatch = content.match(boldPattern);
189
- if (boldMatch) return boldMatch[1].trim();
190
- // Fall back to plain Field: format
191
- const plainPattern = new RegExp(`^${escaped}:\\s*(.+)`, 'im');
192
- const plainMatch = content.match(plainPattern);
193
- return plainMatch ? plainMatch[1].trim() : null;
194
- }
209
+ // stateExtractField is defined above (shared helper) — do not duplicate.
195
210
 
196
211
  function stateReplaceField(content, fieldName, newValue) {
197
- const escaped = fieldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
212
+ const escaped = escapeRegex(fieldName);
198
213
  // Try **Field:** bold format first, then plain Field: format
199
214
  const boldPattern = new RegExp(`(\\*\\*${escaped}:\\*\\*\\s*)(.*)`, 'i');
200
215
  if (boldPattern.test(content)) {
@@ -207,37 +222,112 @@ function stateReplaceField(content, fieldName, newValue) {
207
222
  return null;
208
223
  }
209
224
 
225
+ /**
226
+ * Replace a STATE.md field with fallback field name support.
227
+ * Tries `primary` first, then `fallback` (if provided), returns content unchanged
228
+ * if neither matches. This consolidates the replaceWithFallback pattern that was
229
+ * previously duplicated inline across phase.cjs, milestone.cjs, and state.cjs.
230
+ */
231
+ function stateReplaceFieldWithFallback(content, primary, fallback, value) {
232
+ let result = stateReplaceField(content, primary, value);
233
+ if (result) return result;
234
+ if (fallback) {
235
+ result = stateReplaceField(content, fallback, value);
236
+ if (result) return result;
237
+ }
238
+ // Neither pattern matched — field may have been reformatted or removed.
239
+ // Log diagnostic so template drift is detected early rather than silently swallowed.
240
+ process.stderr.write(
241
+ `[gsd-tools] WARNING: STATE.md field "${primary}"${fallback ? ` (fallback: "${fallback}")` : ''} not found — update skipped. ` +
242
+ `This may indicate STATE.md was externally modified or uses an unexpected format.\n`
243
+ );
244
+ return content;
245
+ }
246
+
247
+ /**
248
+ * Update fields within the ## Current Position section of STATE.md.
249
+ * This keeps the Current Position body in sync with the bold frontmatter fields.
250
+ * Only updates fields that already exist in the section; does not add new lines.
251
+ * Fixes #1365: advance-plan could not update Status/Last activity after begin-phase.
252
+ */
253
+ function updateCurrentPositionFields(content, fields) {
254
+ const posPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i;
255
+ const posMatch = content.match(posPattern);
256
+ if (!posMatch) return content;
257
+
258
+ let posBody = posMatch[2];
259
+
260
+ if (fields.status && /^Status:/m.test(posBody)) {
261
+ posBody = posBody.replace(/^Status:.*$/m, `Status: ${fields.status}`);
262
+ }
263
+ if (fields.lastActivity && /^Last activity:/im.test(posBody)) {
264
+ posBody = posBody.replace(/^Last activity:.*$/im, `Last activity: ${fields.lastActivity}`);
265
+ }
266
+ if (fields.plan && /^Plan:/m.test(posBody)) {
267
+ posBody = posBody.replace(/^Plan:.*$/m, `Plan: ${fields.plan}`);
268
+ }
269
+
270
+ return content.replace(posPattern, `${posMatch[1]}${posBody}`);
271
+ }
272
+
210
273
  function cmdStateAdvancePlan(cwd, raw) {
211
- const statePath = path.join(cwd, '.planning', 'STATE.md');
274
+ const statePath = planningPaths(cwd).state;
212
275
  if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; }
213
276
 
214
277
  let content = fs.readFileSync(statePath, 'utf-8');
215
- const currentPlan = parseInt(stateExtractField(content, 'Current Plan'), 10);
216
- const totalPlans = parseInt(stateExtractField(content, 'Total Plans in Phase'), 10);
217
278
  const today = new Date().toISOString().split('T')[0];
218
279
 
280
+ // Try legacy separate fields first, then compound "Plan: X of Y" format
281
+ const legacyPlan = stateExtractField(content, 'Current Plan');
282
+ const legacyTotal = stateExtractField(content, 'Total Plans in Phase');
283
+ const planField = stateExtractField(content, 'Plan');
284
+
285
+ let currentPlan, totalPlans;
286
+ let useCompoundFormat = false;
287
+
288
+ if (legacyPlan && legacyTotal) {
289
+ currentPlan = parseInt(legacyPlan, 10);
290
+ totalPlans = parseInt(legacyTotal, 10);
291
+ } else if (planField) {
292
+ // Compound format: "2 of 6 in current phase" or "2 of 6"
293
+ currentPlan = parseInt(planField, 10);
294
+ const ofMatch = planField.match(/of\s+(\d+)/);
295
+ totalPlans = ofMatch ? parseInt(ofMatch[1], 10) : NaN;
296
+ useCompoundFormat = true;
297
+ }
298
+
219
299
  if (isNaN(currentPlan) || isNaN(totalPlans)) {
220
300
  output({ error: 'Cannot parse Current Plan or Total Plans in Phase from STATE.md' }, raw);
221
301
  return;
222
302
  }
223
303
 
224
304
  if (currentPlan >= totalPlans) {
225
- content = stateReplaceField(content, 'Status', 'Phase complete — ready for verification') || content;
226
- content = stateReplaceField(content, 'Last Activity', today) || content;
305
+ content = stateReplaceFieldWithFallback(content, 'Status', null, 'Phase complete — ready for verification');
306
+ content = stateReplaceFieldWithFallback(content, 'Last Activity', 'Last activity', today);
307
+ content = updateCurrentPositionFields(content, { status: 'Phase complete — ready for verification', lastActivity: today });
227
308
  writeStateMd(statePath, content, cwd);
228
309
  output({ advanced: false, reason: 'last_plan', current_plan: currentPlan, total_plans: totalPlans, status: 'ready_for_verification' }, raw, 'false');
229
310
  } else {
230
311
  const newPlan = currentPlan + 1;
231
- content = stateReplaceField(content, 'Current Plan', String(newPlan)) || content;
232
- content = stateReplaceField(content, 'Status', 'Ready to execute') || content;
233
- content = stateReplaceField(content, 'Last Activity', today) || content;
312
+ let planDisplayValue;
313
+ if (useCompoundFormat) {
314
+ // Preserve compound format: "X of Y in current phase" → replace X only
315
+ planDisplayValue = planField.replace(/^\d+/, String(newPlan));
316
+ content = stateReplaceField(content, 'Plan', planDisplayValue) || content;
317
+ } else {
318
+ planDisplayValue = `${newPlan} of ${totalPlans}`;
319
+ content = stateReplaceField(content, 'Current Plan', String(newPlan)) || content;
320
+ }
321
+ content = stateReplaceFieldWithFallback(content, 'Status', null, 'Ready to execute');
322
+ content = stateReplaceFieldWithFallback(content, 'Last Activity', 'Last activity', today);
323
+ content = updateCurrentPositionFields(content, { status: 'Ready to execute', lastActivity: today, plan: planDisplayValue });
234
324
  writeStateMd(statePath, content, cwd);
235
325
  output({ advanced: true, previous_plan: currentPlan, current_plan: newPlan, total_plans: totalPlans }, raw, 'true');
236
326
  }
237
327
  }
238
328
 
239
329
  function cmdStateRecordMetric(cwd, options, raw) {
240
- const statePath = path.join(cwd, '.planning', 'STATE.md');
330
+ const statePath = planningPaths(cwd).state;
241
331
  if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; }
242
332
 
243
333
  let content = fs.readFileSync(statePath, 'utf-8');
@@ -271,19 +361,21 @@ function cmdStateRecordMetric(cwd, options, raw) {
271
361
  }
272
362
 
273
363
  function cmdStateUpdateProgress(cwd, raw) {
274
- const statePath = path.join(cwd, '.planning', 'STATE.md');
364
+ const statePath = planningPaths(cwd).state;
275
365
  if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; }
276
366
 
277
367
  let content = fs.readFileSync(statePath, 'utf-8');
278
368
 
279
- // Count summaries across all phases
280
- const phasesDir = path.join(cwd, '.planning', 'phases');
369
+ // Count summaries across current milestone phases only
370
+ const phasesDir = planningPaths(cwd).phases;
281
371
  let totalPlans = 0;
282
372
  let totalSummaries = 0;
283
373
 
284
374
  if (fs.existsSync(phasesDir)) {
375
+ const isDirInMilestone = getMilestonePhaseFilter(cwd);
285
376
  const phaseDirs = fs.readdirSync(phasesDir, { withFileTypes: true })
286
- .filter(e => e.isDirectory()).map(e => e.name);
377
+ .filter(e => e.isDirectory()).map(e => e.name)
378
+ .filter(isDirInMilestone);
287
379
  for (const dir of phaseDirs) {
288
380
  const files = fs.readdirSync(path.join(phasesDir, dir));
289
381
  totalPlans += files.filter(f => f.match(/-PLAN\.md$/i)).length;
@@ -314,7 +406,7 @@ function cmdStateUpdateProgress(cwd, raw) {
314
406
  }
315
407
 
316
408
  function cmdStateAddDecision(cwd, options, raw) {
317
- const statePath = path.join(cwd, '.planning', 'STATE.md');
409
+ const statePath = planningPaths(cwd).state;
318
410
  if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; }
319
411
 
320
412
  const { phase, summary, summary_file, rationale, rationale_file } = options;
@@ -352,7 +444,7 @@ function cmdStateAddDecision(cwd, options, raw) {
352
444
  }
353
445
 
354
446
  function cmdStateAddBlocker(cwd, text, raw) {
355
- const statePath = path.join(cwd, '.planning', 'STATE.md');
447
+ const statePath = planningPaths(cwd).state;
356
448
  if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; }
357
449
  const blockerOptions = typeof text === 'object' && text !== null ? text : { text };
358
450
  let blockerText = null;
@@ -385,7 +477,7 @@ function cmdStateAddBlocker(cwd, text, raw) {
385
477
  }
386
478
 
387
479
  function cmdStateResolveBlocker(cwd, text, raw) {
388
- const statePath = path.join(cwd, '.planning', 'STATE.md');
480
+ const statePath = planningPaths(cwd).state;
389
481
  if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; }
390
482
  if (!text) { output({ error: 'text required' }, raw); return; }
391
483
 
@@ -417,7 +509,7 @@ function cmdStateResolveBlocker(cwd, text, raw) {
417
509
  }
418
510
 
419
511
  function cmdStateRecordSession(cwd, options, raw) {
420
- const statePath = path.join(cwd, '.planning', 'STATE.md');
512
+ const statePath = planningPaths(cwd).state;
421
513
  if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; }
422
514
 
423
515
  let content = fs.readFileSync(statePath, 'utf-8');
@@ -452,7 +544,7 @@ function cmdStateRecordSession(cwd, options, raw) {
452
544
  }
453
545
 
454
546
  function cmdStateSnapshot(cwd, raw) {
455
- const statePath = path.join(cwd, '.planning', 'STATE.md');
547
+ const statePath = planningPaths(cwd).state;
456
548
 
457
549
  if (!fs.existsSync(statePath)) {
458
550
  output({ error: 'STATE.md not found' }, raw);
@@ -574,7 +666,7 @@ function buildStateFrontmatter(bodyContent, cwd) {
574
666
  const info = getMilestoneInfo(cwd);
575
667
  milestone = info.version;
576
668
  milestoneName = info.name;
577
- } catch {}
669
+ } catch { /* intentionally empty */ }
578
670
  }
579
671
 
580
672
  let totalPhases = totalPhasesRaw ? parseInt(totalPhasesRaw, 10) : null;
@@ -584,7 +676,7 @@ function buildStateFrontmatter(bodyContent, cwd) {
584
676
 
585
677
  if (cwd) {
586
678
  try {
587
- const phasesDir = path.join(cwd, '.planning', 'phases');
679
+ const phasesDir = planningPaths(cwd).phases;
588
680
  if (fs.existsSync(phasesDir)) {
589
681
  const isDirInMilestone = getMilestonePhaseFilter(cwd);
590
682
  const phaseDirs = fs.readdirSync(phasesDir, { withFileTypes: true })
@@ -609,11 +701,17 @@ function buildStateFrontmatter(bodyContent, cwd) {
609
701
  totalPlans = diskTotalPlans;
610
702
  completedPlans = diskTotalSummaries;
611
703
  }
612
- } catch {}
704
+ } catch { /* intentionally empty */ }
613
705
  }
614
706
 
707
+ // Derive percent from disk counts when available (ground truth).
708
+ // Only falls back to the body Progress: field when no plan files exist on disk
709
+ // (phases directory empty or absent), which means disk has no authoritative data.
710
+ // This prevents a stale body "0%" from overriding the real 100% completion state.
615
711
  let progressPercent = null;
616
- if (progressRaw) {
712
+ if (totalPlans !== null && totalPlans > 0 && completedPlans !== null) {
713
+ progressPercent = Math.min(100, Math.round(completedPlans / totalPlans * 100));
714
+ } else if (progressRaw) {
617
715
  const pctMatch = progressRaw.match(/(\d+)%/);
618
716
  if (pctMatch) progressPercent = parseInt(pctMatch[1], 10);
619
717
  }
@@ -662,49 +760,577 @@ function buildStateFrontmatter(bodyContent, cwd) {
662
760
  }
663
761
 
664
762
  function stripFrontmatter(content) {
665
- return content.replace(/^---\n[\s\S]*?\n---\n*/, '');
763
+ // Strip ALL frontmatter blocks at the start of the file.
764
+ // Handles CRLF line endings and multiple stacked blocks (corruption recovery).
765
+ // Greedy: keeps stripping ---...--- blocks separated by optional whitespace.
766
+ let result = content;
767
+ // eslint-disable-next-line no-constant-condition
768
+ while (true) {
769
+ const stripped = result.replace(/^\s*---\r?\n[\s\S]*?\r?\n---\s*/, '');
770
+ if (stripped === result) break;
771
+ result = stripped;
772
+ }
773
+ return result;
666
774
  }
667
775
 
668
776
  function syncStateFrontmatter(content, cwd) {
777
+ // read existing frontmatter BEFORE stripping — it may contain values
778
+ // that the body no longer has (e.g., Status field removed by an agent).
779
+ const existingFm = extractFrontmatter(content);
669
780
  const body = stripFrontmatter(content);
670
- const fm = buildStateFrontmatter(body, cwd);
671
- const yamlStr = reconstructFrontmatter(fm);
781
+ const derivedFm = buildStateFrontmatter(body, cwd);
782
+
783
+ // Preserve existing frontmatter status when body-derived status is 'unknown'.
784
+ // This prevents a missing Status: field in the body from overwriting a
785
+ // previously valid status (e.g., 'executing' → 'unknown').
786
+ if (derivedFm.status === 'unknown' && existingFm.status && existingFm.status !== 'unknown') {
787
+ derivedFm.status = existingFm.status;
788
+ }
789
+
790
+ const yamlStr = reconstructFrontmatter(derivedFm);
672
791
  return `---\n${yamlStr}\n---\n\n${body}`;
673
792
  }
674
793
 
794
+ /**
795
+ * Acquire a lockfile for STATE.md operations.
796
+ * Returns the lock path for later release.
797
+ */
798
+ function acquireStateLock(statePath) {
799
+ const lockPath = statePath + '.lock';
800
+ const maxRetries = 10;
801
+ const retryDelay = 200; // ms
802
+
803
+ for (let i = 0; i < maxRetries; i++) {
804
+ try {
805
+ const fd = fs.openSync(lockPath, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY);
806
+ fs.writeSync(fd, String(process.pid));
807
+ fs.closeSync(fd);
808
+ return lockPath;
809
+ } catch (err) {
810
+ if (err.code === 'EEXIST') {
811
+ try {
812
+ const stat = fs.statSync(lockPath);
813
+ if (Date.now() - stat.mtimeMs > 10000) {
814
+ fs.unlinkSync(lockPath);
815
+ continue;
816
+ }
817
+ } catch { /* lock was released between check — retry */ }
818
+
819
+ if (i === maxRetries - 1) {
820
+ try { fs.unlinkSync(lockPath); } catch {}
821
+ return lockPath;
822
+ }
823
+ const jitter = Math.floor(Math.random() * 50);
824
+ const start = Date.now();
825
+ while (Date.now() - start < retryDelay + jitter) { /* busy wait */ }
826
+ continue;
827
+ }
828
+ return lockPath; // non-EEXIST error — proceed without lock
829
+ }
830
+ }
831
+ return statePath + '.lock';
832
+ }
833
+
834
+ function releaseStateLock(lockPath) {
835
+ try { fs.unlinkSync(lockPath); } catch { /* lock already gone */ }
836
+ }
837
+
675
838
  /**
676
839
  * write STATE.md with synchronized YAML frontmatter.
677
840
  * All STATE.md writes should use this instead of raw writeFileSync.
841
+ * Uses a simple lockfile to prevent parallel agents from overwriting
842
+ * each other's changes (race condition with read-modify-write cycle).
678
843
  */
679
844
  function writeStateMd(statePath, content, cwd) {
680
845
  const synced = syncStateFrontmatter(content, cwd);
681
- fs.writeFileSync(statePath, synced, 'utf-8');
846
+ const lockPath = acquireStateLock(statePath);
847
+ try {
848
+ fs.writeFileSync(statePath, normalizeMd(synced), 'utf-8');
849
+ } finally {
850
+ releaseStateLock(lockPath);
851
+ }
852
+ }
853
+
854
+ /**
855
+ * Atomic read-modify-write for STATE.md.
856
+ * Holds the lock across the entire read -> transform -> write cycle,
857
+ * preventing the lost-update problem where two agents read the same
858
+ * content and the second write clobbers the first.
859
+ */
860
+ function readModifyWriteStateMd(statePath, transformFn, cwd) {
861
+ const lockPath = acquireStateLock(statePath);
862
+ try {
863
+ const content = fs.existsSync(statePath) ? fs.readFileSync(statePath, 'utf-8') : '';
864
+ const modified = transformFn(content);
865
+ const synced = syncStateFrontmatter(modified, cwd);
866
+ fs.writeFileSync(statePath, normalizeMd(synced), 'utf-8');
867
+ } finally {
868
+ releaseStateLock(lockPath);
869
+ }
682
870
  }
683
871
 
684
872
  function cmdStateJson(cwd, raw) {
685
- const statePath = path.join(cwd, '.planning', 'STATE.md');
873
+ const statePath = planningPaths(cwd).state;
686
874
  if (!fs.existsSync(statePath)) {
687
875
  output({ error: 'STATE.md not found' }, raw, 'STATE.md not found');
688
876
  return;
689
877
  }
690
878
 
691
879
  const content = fs.readFileSync(statePath, 'utf-8');
692
- const fm = extractFrontmatter(content);
880
+ const existingFm = extractFrontmatter(content);
881
+ const body = stripFrontmatter(content);
882
+
883
+ // Always rebuild from body + disk so progress counters reflect current state.
884
+ // Returning cached frontmatter directly causes stale percent/completed_plans
885
+ // when SUMMARY files were added after the last STATE.md write (#1589).
886
+ const built = buildStateFrontmatter(body, cwd);
887
+
888
+ // Preserve frontmatter-only fields that cannot be recovered from the body.
889
+ if (existingFm && existingFm.stopped_at && !built.stopped_at) {
890
+ built.stopped_at = existingFm.stopped_at;
891
+ }
892
+ if (existingFm && existingFm.paused_at && !built.paused_at) {
893
+ built.paused_at = existingFm.paused_at;
894
+ }
895
+ // Preserve existing status when body-derived status is 'unknown' (same logic as syncStateFrontmatter).
896
+ if (built.status === 'unknown' && existingFm && existingFm.status && existingFm.status !== 'unknown') {
897
+ built.status = existingFm.status;
898
+ }
899
+
900
+ output(built, raw, JSON.stringify(built, null, 2));
901
+ }
902
+
903
+ /**
904
+ * Update STATE.md when a new phase begins execution.
905
+ * Updates body text fields (Current focus, Status, Last Activity, Current Position)
906
+ * and synchronizes frontmatter via writeStateMd.
907
+ * Fixes: #1102 (plan counts), #1103 (status/last_activity), #1104 (body text).
908
+ */
909
+ function cmdStateBeginPhase(cwd, phaseNumber, phaseName, planCount, raw) {
910
+ const statePath = planningPaths(cwd).state;
911
+ if (!fs.existsSync(statePath)) {
912
+ output({ error: 'STATE.md not found' }, raw);
913
+ return;
914
+ }
915
+
916
+ let content = fs.readFileSync(statePath, 'utf-8');
917
+ const today = new Date().toISOString().split('T')[0];
918
+ const updated = [];
919
+
920
+ // Update Status field
921
+ const statusValue = `Executing Phase ${phaseNumber}`;
922
+ let result = stateReplaceField(content, 'Status', statusValue);
923
+ if (result) { content = result; updated.push('Status'); }
924
+
925
+ // Update Last Activity
926
+ result = stateReplaceField(content, 'Last Activity', today);
927
+ if (result) { content = result; updated.push('Last Activity'); }
928
+
929
+ // Update Last Activity Description if it exists
930
+ const activityDesc = `Phase ${phaseNumber} execution started`;
931
+ result = stateReplaceField(content, 'Last Activity Description', activityDesc);
932
+ if (result) { content = result; updated.push('Last Activity Description'); }
933
+
934
+ // Update Current Phase
935
+ result = stateReplaceField(content, 'Current Phase', String(phaseNumber));
936
+ if (result) { content = result; updated.push('Current Phase'); }
937
+
938
+ // Update Current Phase Name
939
+ if (phaseName) {
940
+ result = stateReplaceField(content, 'Current Phase Name', phaseName);
941
+ if (result) { content = result; updated.push('Current Phase Name'); }
942
+ }
943
+
944
+ // Update Current Plan to 1 (starting from the first plan)
945
+ result = stateReplaceField(content, 'Current Plan', '1');
946
+ if (result) { content = result; updated.push('Current Plan'); }
947
+
948
+ // Update Total Plans in Phase
949
+ if (planCount) {
950
+ result = stateReplaceField(content, 'Total Plans in Phase', String(planCount));
951
+ if (result) { content = result; updated.push('Total Plans in Phase'); }
952
+ }
953
+
954
+ // Update **Current focus:** body text line (#1104)
955
+ const focusLabel = phaseName ? `Phase ${phaseNumber} — ${phaseName}` : `Phase ${phaseNumber}`;
956
+ const focusPattern = /(\*\*Current focus:\*\*\s*).*/i;
957
+ if (focusPattern.test(content)) {
958
+ content = content.replace(focusPattern, (_match, prefix) => `${prefix}${focusLabel}`);
959
+ updated.push('Current focus');
960
+ }
961
+
962
+ // Update ## Current Position section (#1104, #1365)
963
+ // Update individual fields within Current Position instead of replacing the
964
+ // entire section, so that Status, Last activity, and Progress are preserved.
965
+ const positionPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i;
966
+ const positionMatch = content.match(positionPattern);
967
+ if (positionMatch) {
968
+ const header = positionMatch[1];
969
+ let posBody = positionMatch[2];
970
+
971
+ // Update or insert Phase line
972
+ const newPhase = `Phase: ${phaseNumber}${phaseName ? ` (${phaseName})` : ''} — EXECUTING`;
973
+ if (/^Phase:/m.test(posBody)) {
974
+ posBody = posBody.replace(/^Phase:.*$/m, newPhase);
975
+ } else {
976
+ posBody = newPhase + '\n' + posBody;
977
+ }
978
+
979
+ // Update or insert Plan line
980
+ const newPlan = `Plan: 1 of ${planCount || '?'}`;
981
+ if (/^Plan:/m.test(posBody)) {
982
+ posBody = posBody.replace(/^Plan:.*$/m, newPlan);
983
+ } else {
984
+ posBody = posBody.replace(/^(Phase:.*$)/m, `$1\n${newPlan}`);
985
+ }
986
+
987
+ // Update Status line if present
988
+ const newStatus = `Status: Executing Phase ${phaseNumber}`;
989
+ if (/^Status:/m.test(posBody)) {
990
+ posBody = posBody.replace(/^Status:.*$/m, newStatus);
991
+ }
992
+
993
+ // Update Last activity line if present
994
+ const newActivity = `Last activity: ${today} -- Phase ${phaseNumber} execution started`;
995
+ if (/^Last activity:/im.test(posBody)) {
996
+ posBody = posBody.replace(/^Last activity:.*$/im, newActivity);
997
+ }
998
+
999
+ content = content.replace(positionPattern, `${header}${posBody}`);
1000
+ updated.push('Current Position');
1001
+ }
1002
+
1003
+ if (updated.length > 0) {
1004
+ writeStateMd(statePath, content, cwd);
1005
+ }
1006
+
1007
+ output({ updated, phase: phaseNumber, phase_name: phaseName || null, plan_count: planCount || null }, raw, updated.length > 0 ? 'true' : 'false');
1008
+ }
1009
+
1010
+ /**
1011
+ * write a WAITING.json signal file when GSD hits a decision point.
1012
+ * External watchers (fswatch, polling, orchestrators) can detect this.
1013
+ * File is written to .planning/WAITING.json (or .gsd/WAITING.json if .gsd exists).
1014
+ * Fixes #1034.
1015
+ */
1016
+ function cmdSignalWaiting(cwd, type, question, options, phase, raw) {
1017
+ const gsdDir = fs.existsSync(path.join(cwd, '.gsd')) ? path.join(cwd, '.gsd') : planningDir(cwd);
1018
+ const waitingPath = path.join(gsdDir, 'WAITING.json');
1019
+
1020
+ const signal = {
1021
+ status: 'waiting',
1022
+ type: type || 'decision_point',
1023
+ question: question || null,
1024
+ options: options ? options.split('|').map(o => o.trim()) : [],
1025
+ since: new Date().toISOString(),
1026
+ phase: phase || null,
1027
+ };
1028
+
1029
+ try {
1030
+ fs.mkdirSync(gsdDir, { recursive: true });
1031
+ fs.writeFileSync(waitingPath, JSON.stringify(signal, null, 2), 'utf-8');
1032
+ output({ signaled: true, path: waitingPath }, raw, 'true');
1033
+ } catch (e) {
1034
+ output({ signaled: false, error: e.message }, raw, 'false');
1035
+ }
1036
+ }
1037
+
1038
+ /**
1039
+ * Remove the WAITING.json signal file when user answers and agent resumes.
1040
+ */
1041
+ function cmdSignalResume(cwd, raw) {
1042
+ const paths = [
1043
+ path.join(cwd, '.gsd', 'WAITING.json'),
1044
+ path.join(planningDir(cwd), 'WAITING.json'),
1045
+ ];
1046
+
1047
+ let removed = false;
1048
+ for (const p of paths) {
1049
+ if (fs.existsSync(p)) {
1050
+ try { fs.unlinkSync(p); removed = true; } catch {}
1051
+ }
1052
+ }
1053
+
1054
+ output({ resumed: true, removed }, raw, removed ? 'true' : 'false');
1055
+ }
1056
+
1057
+ // ─── Gate Functions (STATE.md consistency enforcement) ────────────────────────
1058
+
1059
+ /**
1060
+ * Update the ## Performance Metrics section in STATE.md content.
1061
+ * Increments Velocity totals and upserts a By Phase table row.
1062
+ * Returns modified content string.
1063
+ */
1064
+ function updatePerformanceMetricsSection(content, cwd, phaseNum, planCount, summaryCount) {
1065
+ // Update Velocity: Total plans completed
1066
+ const totalMatch = content.match(/Total plans completed:\s*(\d+|\[N\])/);
1067
+ const prevTotal = totalMatch && totalMatch[1] !== '[N]' ? parseInt(totalMatch[1], 10) : 0;
1068
+ const newTotal = prevTotal + summaryCount;
1069
+ content = content.replace(
1070
+ /Total plans completed:\s*(\d+|\[N\])/,
1071
+ `Total plans completed: ${newTotal}`
1072
+ );
1073
+
1074
+ // Update By Phase table — upsert row for this phase
1075
+ 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;
1076
+ const byPhaseMatch = content.match(byPhaseTablePattern);
1077
+ if (byPhaseMatch) {
1078
+ let tableBody = byPhaseMatch[2].trim();
1079
+ const phaseRowPattern = new RegExp(`^\\|\\s*${escapeRegex(String(phaseNum))}\\s*\\|.*$`, 'm');
1080
+ const newRow = `| ${phaseNum} | ${summaryCount} | - | - |`;
1081
+
1082
+ if (phaseRowPattern.test(tableBody)) {
1083
+ // Update existing row
1084
+ tableBody = tableBody.replace(phaseRowPattern, newRow);
1085
+ } else {
1086
+ // Remove placeholder row and add new row
1087
+ tableBody = tableBody.replace(/^\|\s*-\s*\|\s*-\s*\|\s*-\s*\|\s*-\s*\|$/m, '').trim();
1088
+ tableBody = tableBody ? tableBody + '\n' + newRow : newRow;
1089
+ }
1090
+
1091
+ content = content.replace(byPhaseTablePattern, `$1${tableBody}\n`);
1092
+ }
1093
+
1094
+ return content;
1095
+ }
1096
+
1097
+ /**
1098
+ * Gate 3a: Record state after plan-phase completes.
1099
+ * Updates Status to "Ready to execute", Total Plans, Last Activity.
1100
+ */
1101
+ function cmdStatePlannedPhase(cwd, phaseNumber, planCount, raw) {
1102
+ const statePath = planningPaths(cwd).state;
1103
+ if (!fs.existsSync(statePath)) {
1104
+ output({ error: 'STATE.md not found' }, raw);
1105
+ return;
1106
+ }
1107
+
1108
+ let content = fs.readFileSync(statePath, 'utf-8');
1109
+ const today = new Date().toISOString().split('T')[0];
1110
+ const updated = [];
1111
+
1112
+ // Update Status
1113
+ let result = stateReplaceField(content, 'Status', 'Ready to execute');
1114
+ if (result) { content = result; updated.push('Status'); }
1115
+
1116
+ // Update Total Plans in Phase
1117
+ if (planCount !== null && planCount !== undefined) {
1118
+ result = stateReplaceField(content, 'Total Plans in Phase', String(planCount));
1119
+ if (result) { content = result; updated.push('Total Plans in Phase'); }
1120
+ }
1121
+
1122
+ // Update Last Activity
1123
+ result = stateReplaceField(content, 'Last Activity', today);
1124
+ if (result) { content = result; updated.push('Last Activity'); }
1125
+
1126
+ // Update Last Activity Description
1127
+ result = stateReplaceField(content, 'Last Activity Description', `Phase ${phaseNumber} planning complete — ${planCount || '?'} plans ready`);
1128
+ if (result) { content = result; updated.push('Last Activity Description'); }
1129
+
1130
+ // Update Current Position section
1131
+ content = updateCurrentPositionFields(content, {
1132
+ status: 'Ready to execute',
1133
+ lastActivity: `${today} -- Phase ${phaseNumber} planning complete`,
1134
+ });
1135
+
1136
+ if (updated.length > 0) {
1137
+ writeStateMd(statePath, content, cwd);
1138
+ }
1139
+
1140
+ output({ updated, phase: phaseNumber, plan_count: planCount }, raw, updated.length > 0 ? 'true' : 'false');
1141
+ }
1142
+
1143
+ /**
1144
+ * Gate 1: Validate STATE.md against filesystem.
1145
+ * Returns { valid, warnings, drift } JSON.
1146
+ */
1147
+ function cmdStateValidate(cwd, raw) {
1148
+ const statePath = planningPaths(cwd).state;
1149
+ if (!fs.existsSync(statePath)) {
1150
+ output({ error: 'STATE.md not found' }, raw);
1151
+ return;
1152
+ }
1153
+
1154
+ const content = fs.readFileSync(statePath, 'utf-8');
1155
+ const warnings = [];
1156
+ const drift = {};
1157
+
1158
+ const status = stateExtractField(content, 'Status') || '';
1159
+ const currentPhase = stateExtractField(content, 'Current Phase');
1160
+ const totalPlansRaw = stateExtractField(content, 'Total Plans in Phase');
1161
+ const totalPlansInPhase = totalPlansRaw ? parseInt(totalPlansRaw, 10) : null;
1162
+
1163
+ const phasesDir = planningPaths(cwd).phases;
1164
+
1165
+ // Scan disk for current phase
1166
+ if (currentPhase && fs.existsSync(phasesDir)) {
1167
+ const normalized = currentPhase.replace(/\s+of\s+\d+.*/, '').trim();
1168
+ try {
1169
+ const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
1170
+ const phaseDir = entries.find(e => e.isDirectory() && e.name.startsWith(normalized.replace(/^0+/, '').padStart(2, '0')));
1171
+ if (phaseDir) {
1172
+ const phaseDirPath = path.join(phasesDir, phaseDir.name);
1173
+ const files = fs.readdirSync(phaseDirPath);
1174
+ const diskPlans = files.filter(f => f.match(/-PLAN\.md$/i)).length;
1175
+ const diskSummaries = files.filter(f => f.match(/-SUMMARY\.md$/i)).length;
1176
+
1177
+ // Check plan count mismatch
1178
+ if (totalPlansInPhase !== null && diskPlans !== totalPlansInPhase) {
1179
+ warnings.push(`Plan count mismatch: STATE.md says ${totalPlansInPhase} plans, disk has ${diskPlans}`);
1180
+ drift.plan_count = { state: totalPlansInPhase, disk: diskPlans };
1181
+ }
693
1182
 
694
- if (!fm || Object.keys(fm).length === 0) {
695
- const body = stripFrontmatter(content);
696
- const built = buildStateFrontmatter(body, cwd);
697
- output(built, raw, JSON.stringify(built, null, 2));
1183
+ // Check for VERIFICATION.md
1184
+ const verificationFiles = files.filter(f => f.includes('VERIFICATION') && f.endsWith('.md'));
1185
+ for (const vf of verificationFiles) {
1186
+ try {
1187
+ const vContent = fs.readFileSync(path.join(phaseDirPath, vf), 'utf-8');
1188
+ if (/status:\s*passed/i.test(vContent) && /executing/i.test(status)) {
1189
+ warnings.push(`Status drift: STATE.md says "${status}" but ${vf} shows verification passed — phase may be complete`);
1190
+ drift.verification_status = { state_status: status, verification: 'passed' };
1191
+ }
1192
+ } catch { /* intentionally empty */ }
1193
+ }
1194
+
1195
+ // Check if all plans have summaries but status still says executing
1196
+ if (diskPlans > 0 && diskSummaries >= diskPlans && /executing/i.test(status)) {
1197
+ // Only warn if no verification exists (if verification passed, the above warning covers it)
1198
+ if (verificationFiles.length === 0) {
1199
+ warnings.push(`All ${diskPlans} plans have summaries but status is still "${status}" — phase may be ready for verification`);
1200
+ }
1201
+ }
1202
+ }
1203
+ } catch { /* intentionally empty */ }
1204
+ }
1205
+
1206
+ const valid = warnings.length === 0;
1207
+ output({ valid, warnings, drift }, raw);
1208
+ }
1209
+
1210
+ /**
1211
+ * Gate 2: Sync STATE.md from filesystem ground truth.
1212
+ * Scans phase dirs, reconstructs counters, progress, metrics.
1213
+ * Supports --verify for dry-run mode.
1214
+ */
1215
+ function cmdStateSync(cwd, options, raw) {
1216
+ const statePath = planningPaths(cwd).state;
1217
+ if (!fs.existsSync(statePath)) {
1218
+ output({ error: 'STATE.md not found' }, raw);
698
1219
  return;
699
1220
  }
700
1221
 
701
- output(fm, raw, JSON.stringify(fm, null, 2));
1222
+ const verify = options && options.verify;
1223
+ const content = fs.readFileSync(statePath, 'utf-8');
1224
+ const changes = [];
1225
+ let modified = content;
1226
+ const today = new Date().toISOString().split('T')[0];
1227
+
1228
+ const phasesDir = planningPaths(cwd).phases;
1229
+ if (!fs.existsSync(phasesDir)) {
1230
+ output({ synced: true, changes: [], dry_run: !!verify }, raw);
1231
+ return;
1232
+ }
1233
+
1234
+ // Scan all phases
1235
+ let entries;
1236
+ try {
1237
+ entries = fs.readdirSync(phasesDir, { withFileTypes: true })
1238
+ .filter(e => e.isDirectory())
1239
+ .map(e => e.name)
1240
+ .sort();
1241
+ } catch {
1242
+ output({ synced: true, changes: [], dry_run: !!verify }, raw);
1243
+ return;
1244
+ }
1245
+
1246
+ let totalDiskPlans = 0;
1247
+ let totalDiskSummaries = 0;
1248
+ let highestIncompletePhase = null;
1249
+ let highestIncompletePhaseNum = null;
1250
+ let highestIncompletePhaseplanCount = 0;
1251
+ let highestIncompletePhaseSummaryCount = 0;
1252
+
1253
+ for (const dir of entries) {
1254
+ const dirPath = path.join(phasesDir, dir);
1255
+ const files = fs.readdirSync(dirPath);
1256
+ const plans = files.filter(f => f.match(/-PLAN\.md$/i)).length;
1257
+ const summaries = files.filter(f => f.match(/-SUMMARY\.md$/i)).length;
1258
+ totalDiskPlans += plans;
1259
+ totalDiskSummaries += summaries;
1260
+
1261
+ // Track the highest phase with incomplete plans (or any plans)
1262
+ const phaseMatch = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)/i);
1263
+ if (phaseMatch && plans > 0) {
1264
+ if (summaries < plans) {
1265
+ // Incomplete phase — this is likely the current one
1266
+ highestIncompletePhase = dir;
1267
+ highestIncompletePhaseNum = phaseMatch[1];
1268
+ highestIncompletePhaseplanCount = plans;
1269
+ highestIncompletePhaseSummaryCount = summaries;
1270
+ } else if (!highestIncompletePhase) {
1271
+ // All complete, track as potential current
1272
+ highestIncompletePhase = dir;
1273
+ highestIncompletePhaseNum = phaseMatch[1];
1274
+ highestIncompletePhaseplanCount = plans;
1275
+ highestIncompletePhaseSummaryCount = summaries;
1276
+ }
1277
+ }
1278
+ }
1279
+
1280
+ // Sync Total Plans in Phase
1281
+ if (highestIncompletePhase) {
1282
+ const currentPlansField = stateExtractField(modified, 'Total Plans in Phase');
1283
+ if (currentPlansField && parseInt(currentPlansField, 10) !== highestIncompletePhaseplanCount) {
1284
+ changes.push(`Total Plans in Phase: ${currentPlansField} -> ${highestIncompletePhaseplanCount}`);
1285
+ const result = stateReplaceField(modified, 'Total Plans in Phase', String(highestIncompletePhaseplanCount));
1286
+ if (result) modified = result;
1287
+ }
1288
+ }
1289
+
1290
+ // Sync Progress
1291
+ const percent = totalDiskPlans > 0 ? Math.min(100, Math.round(totalDiskSummaries / totalDiskPlans * 100)) : 0;
1292
+ const currentProgress = stateExtractField(modified, 'Progress');
1293
+ if (currentProgress) {
1294
+ const currentPercent = parseInt(currentProgress.replace(/[^\d]/g, ''), 10);
1295
+ if (currentPercent !== percent) {
1296
+ const barWidth = 10;
1297
+ const filled = Math.round(percent / 100 * barWidth);
1298
+ const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(barWidth - filled);
1299
+ const progressStr = `[${bar}] ${percent}%`;
1300
+ changes.push(`Progress: ${currentProgress} -> ${progressStr}`);
1301
+ const result = stateReplaceField(modified, 'Progress', progressStr);
1302
+ if (result) modified = result;
1303
+ }
1304
+ }
1305
+
1306
+ // Sync Last Activity
1307
+ const result = stateReplaceField(modified, 'Last Activity', today);
1308
+ if (result) {
1309
+ const oldActivity = stateExtractField(modified, 'Last Activity');
1310
+ if (oldActivity !== today) {
1311
+ changes.push(`Last Activity: ${oldActivity} -> ${today}`);
1312
+ }
1313
+ modified = result;
1314
+ }
1315
+
1316
+ if (verify) {
1317
+ output({ synced: false, changes, dry_run: true }, raw);
1318
+ return;
1319
+ }
1320
+
1321
+ if (changes.length > 0 || modified !== content) {
1322
+ writeStateMd(statePath, modified, cwd);
1323
+ }
1324
+
1325
+ output({ synced: true, changes, dry_run: false }, raw);
702
1326
  }
703
1327
 
704
1328
  module.exports = {
705
1329
  stateExtractField,
706
1330
  stateReplaceField,
1331
+ stateReplaceFieldWithFallback,
707
1332
  writeStateMd,
1333
+ updatePerformanceMetricsSection,
708
1334
  cmdStateLoad,
709
1335
  cmdStateGet,
710
1336
  cmdStatePatch,
@@ -718,4 +1344,10 @@ module.exports = {
718
1344
  cmdStateRecordSession,
719
1345
  cmdStateSnapshot,
720
1346
  cmdStateJson,
1347
+ cmdStateBeginPhase,
1348
+ cmdStatePlannedPhase,
1349
+ cmdStateValidate,
1350
+ cmdStateSync,
1351
+ cmdSignalWaiting,
1352
+ cmdSignalResume,
721
1353
  };