gsd-opencode 1.22.1 → 1.30.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 (156) 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 +118 -2
  5. package/agents/gsd-executor.md +24 -4
  6. package/agents/gsd-integration-checker.md +0 -2
  7. package/agents/gsd-nyquist-auditor.md +0 -2
  8. package/agents/gsd-phase-researcher.md +150 -5
  9. package/agents/gsd-plan-checker.md +70 -5
  10. package/agents/gsd-planner.md +49 -4
  11. package/agents/gsd-project-researcher.md +28 -3
  12. package/agents/gsd-research-synthesizer.md +0 -2
  13. package/agents/gsd-roadmapper.md +29 -2
  14. package/agents/gsd-ui-auditor.md +445 -0
  15. package/agents/gsd-ui-checker.md +305 -0
  16. package/agents/gsd-ui-researcher.md +368 -0
  17. package/agents/gsd-user-profiler.md +173 -0
  18. package/agents/gsd-verifier.md +123 -4
  19. package/commands/gsd/gsd-add-backlog.md +76 -0
  20. package/commands/gsd/gsd-audit-uat.md +24 -0
  21. package/commands/gsd/gsd-autonomous.md +41 -0
  22. package/commands/gsd/gsd-debug.md +5 -0
  23. package/commands/gsd/gsd-discuss-phase.md +10 -36
  24. package/commands/gsd/gsd-do.md +30 -0
  25. package/commands/gsd/gsd-execute-phase.md +20 -2
  26. package/commands/gsd/gsd-fast.md +30 -0
  27. package/commands/gsd/gsd-forensics.md +56 -0
  28. package/commands/gsd/gsd-list-workspaces.md +19 -0
  29. package/commands/gsd/gsd-manager.md +39 -0
  30. package/commands/gsd/gsd-milestone-summary.md +51 -0
  31. package/commands/gsd/gsd-new-workspace.md +44 -0
  32. package/commands/gsd/gsd-next.md +24 -0
  33. package/commands/gsd/gsd-note.md +34 -0
  34. package/commands/gsd/gsd-plan-phase.md +3 -1
  35. package/commands/gsd/gsd-plant-seed.md +28 -0
  36. package/commands/gsd/gsd-pr-branch.md +25 -0
  37. package/commands/gsd/gsd-profile-user.md +46 -0
  38. package/commands/gsd/gsd-quick.md +4 -2
  39. package/commands/gsd/gsd-reapply-patches.md +9 -8
  40. package/commands/gsd/gsd-remove-workspace.md +26 -0
  41. package/commands/gsd/gsd-research-phase.md +5 -0
  42. package/commands/gsd/gsd-review-backlog.md +61 -0
  43. package/commands/gsd/gsd-review.md +37 -0
  44. package/commands/gsd/gsd-session-report.md +19 -0
  45. package/commands/gsd/gsd-set-profile.md +24 -23
  46. package/commands/gsd/gsd-ship.md +23 -0
  47. package/commands/gsd/gsd-stats.md +18 -0
  48. package/commands/gsd/gsd-thread.md +127 -0
  49. package/commands/gsd/gsd-ui-phase.md +34 -0
  50. package/commands/gsd/gsd-ui-review.md +32 -0
  51. package/commands/gsd/gsd-workstreams.md +66 -0
  52. package/get-shit-done/bin/gsd-tools.cjs +410 -84
  53. package/get-shit-done/bin/lib/commands.cjs +429 -18
  54. package/get-shit-done/bin/lib/config.cjs +318 -45
  55. package/get-shit-done/bin/lib/core.cjs +822 -84
  56. package/get-shit-done/bin/lib/frontmatter.cjs +78 -41
  57. package/get-shit-done/bin/lib/init.cjs +836 -104
  58. package/get-shit-done/bin/lib/milestone.cjs +44 -33
  59. package/get-shit-done/bin/lib/model-profiles.cjs +68 -0
  60. package/get-shit-done/bin/lib/phase.cjs +293 -306
  61. package/get-shit-done/bin/lib/profile-output.cjs +952 -0
  62. package/get-shit-done/bin/lib/profile-pipeline.cjs +539 -0
  63. package/get-shit-done/bin/lib/roadmap.cjs +55 -24
  64. package/get-shit-done/bin/lib/security.cjs +382 -0
  65. package/get-shit-done/bin/lib/state.cjs +363 -53
  66. package/get-shit-done/bin/lib/template.cjs +2 -2
  67. package/get-shit-done/bin/lib/uat.cjs +282 -0
  68. package/get-shit-done/bin/lib/verify.cjs +104 -36
  69. package/get-shit-done/bin/lib/workstream.cjs +491 -0
  70. package/get-shit-done/references/checkpoints.md +12 -10
  71. package/get-shit-done/references/decimal-phase-calculation.md +2 -3
  72. package/get-shit-done/references/git-integration.md +47 -0
  73. package/get-shit-done/references/model-profile-resolution.md +2 -0
  74. package/get-shit-done/references/model-profiles.md +62 -16
  75. package/get-shit-done/references/phase-argument-parsing.md +2 -2
  76. package/get-shit-done/references/planning-config.md +3 -1
  77. package/get-shit-done/references/user-profiling.md +681 -0
  78. package/get-shit-done/references/workstream-flag.md +58 -0
  79. package/get-shit-done/templates/UAT.md +21 -3
  80. package/get-shit-done/templates/UI-SPEC.md +100 -0
  81. package/get-shit-done/templates/claude-md.md +122 -0
  82. package/get-shit-done/templates/config.json +10 -3
  83. package/get-shit-done/templates/context.md +61 -6
  84. package/get-shit-done/templates/dev-preferences.md +21 -0
  85. package/get-shit-done/templates/discussion-log.md +63 -0
  86. package/get-shit-done/templates/phase-prompt.md +46 -5
  87. package/get-shit-done/templates/project.md +2 -0
  88. package/get-shit-done/templates/state.md +2 -2
  89. package/get-shit-done/templates/user-profile.md +146 -0
  90. package/get-shit-done/workflows/add-phase.md +2 -2
  91. package/get-shit-done/workflows/add-tests.md +4 -4
  92. package/get-shit-done/workflows/add-todo.md +3 -3
  93. package/get-shit-done/workflows/audit-milestone.md +13 -5
  94. package/get-shit-done/workflows/audit-uat.md +109 -0
  95. package/get-shit-done/workflows/autonomous.md +891 -0
  96. package/get-shit-done/workflows/check-todos.md +2 -2
  97. package/get-shit-done/workflows/cleanup.md +4 -4
  98. package/get-shit-done/workflows/complete-milestone.md +9 -6
  99. package/get-shit-done/workflows/diagnose-issues.md +15 -3
  100. package/get-shit-done/workflows/discovery-phase.md +3 -3
  101. package/get-shit-done/workflows/discuss-phase-assumptions.md +653 -0
  102. package/get-shit-done/workflows/discuss-phase.md +411 -38
  103. package/get-shit-done/workflows/do.md +104 -0
  104. package/get-shit-done/workflows/execute-phase.md +405 -18
  105. package/get-shit-done/workflows/execute-plan.md +77 -12
  106. package/get-shit-done/workflows/fast.md +105 -0
  107. package/get-shit-done/workflows/forensics.md +265 -0
  108. package/get-shit-done/workflows/health.md +28 -6
  109. package/get-shit-done/workflows/help.md +124 -7
  110. package/get-shit-done/workflows/insert-phase.md +2 -2
  111. package/get-shit-done/workflows/list-phase-assumptions.md +2 -2
  112. package/get-shit-done/workflows/list-workspaces.md +56 -0
  113. package/get-shit-done/workflows/manager.md +362 -0
  114. package/get-shit-done/workflows/map-codebase.md +74 -13
  115. package/get-shit-done/workflows/milestone-summary.md +223 -0
  116. package/get-shit-done/workflows/new-milestone.md +120 -18
  117. package/get-shit-done/workflows/new-project.md +178 -39
  118. package/get-shit-done/workflows/new-workspace.md +237 -0
  119. package/get-shit-done/workflows/next.md +97 -0
  120. package/get-shit-done/workflows/node-repair.md +92 -0
  121. package/get-shit-done/workflows/note.md +156 -0
  122. package/get-shit-done/workflows/pause-work.md +62 -8
  123. package/get-shit-done/workflows/plan-milestone-gaps.md +4 -5
  124. package/get-shit-done/workflows/plan-phase.md +332 -33
  125. package/get-shit-done/workflows/plant-seed.md +169 -0
  126. package/get-shit-done/workflows/pr-branch.md +129 -0
  127. package/get-shit-done/workflows/profile-user.md +450 -0
  128. package/get-shit-done/workflows/progress.md +145 -20
  129. package/get-shit-done/workflows/quick.md +205 -49
  130. package/get-shit-done/workflows/remove-phase.md +2 -2
  131. package/get-shit-done/workflows/remove-workspace.md +90 -0
  132. package/get-shit-done/workflows/research-phase.md +11 -3
  133. package/get-shit-done/workflows/resume-project.md +35 -16
  134. package/get-shit-done/workflows/review.md +228 -0
  135. package/get-shit-done/workflows/session-report.md +146 -0
  136. package/get-shit-done/workflows/set-profile.md +2 -2
  137. package/get-shit-done/workflows/settings.md +79 -10
  138. package/get-shit-done/workflows/ship.md +228 -0
  139. package/get-shit-done/workflows/stats.md +60 -0
  140. package/get-shit-done/workflows/transition.md +147 -20
  141. package/get-shit-done/workflows/ui-phase.md +302 -0
  142. package/get-shit-done/workflows/ui-review.md +165 -0
  143. package/get-shit-done/workflows/update.md +108 -25
  144. package/get-shit-done/workflows/validate-phase.md +15 -8
  145. package/get-shit-done/workflows/verify-phase.md +16 -5
  146. package/get-shit-done/workflows/verify-work.md +72 -18
  147. package/package.json +1 -1
  148. package/skills/gsd-audit-milestone/SKILL.md +29 -0
  149. package/skills/gsd-cleanup/SKILL.md +19 -0
  150. package/skills/gsd-complete-milestone/SKILL.md +131 -0
  151. package/skills/gsd-discuss-phase/SKILL.md +54 -0
  152. package/skills/gsd-execute-phase/SKILL.md +49 -0
  153. package/skills/gsd-plan-phase/SKILL.md +37 -0
  154. package/skills/gsd-ui-phase/SKILL.md +24 -0
  155. package/skills/gsd-ui-review/SKILL.md +24 -0
  156. 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,22 +115,37 @@ 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
144
  let content = fs.readFileSync(statePath, 'utf-8');
125
145
  const results = { updated: [], failed: [] };
126
146
 
127
147
  for (const [field, value] of Object.entries(patches)) {
128
- const fieldEscaped = field.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
148
+ const fieldEscaped = escapeRegex(field);
129
149
  // Try **Field:** bold format first, then plain Field: format
130
150
  const boldPattern = new RegExp(`(\\*\\*${fieldEscaped}:\\*\\*\\s*)(.*)`, 'i');
131
151
  const plainPattern = new RegExp(`(^${fieldEscaped}:\\s*)(.*)`, 'im');
@@ -156,10 +176,17 @@ function cmdStateUpdate(cwd, field, value) {
156
176
  error('field and value required for state update');
157
177
  }
158
178
 
159
- const statePath = path.join(cwd, '.planning', 'STATE.md');
179
+ // Validate field name to prevent regex injection via crafted field names
180
+ const { validateFieldName } = require('./security.cjs');
181
+ const fieldCheck = validateFieldName(field);
182
+ if (!fieldCheck.valid) {
183
+ error(`state update: ${fieldCheck.error}`);
184
+ }
185
+
186
+ const statePath = planningPaths(cwd).state;
160
187
  try {
161
188
  let content = fs.readFileSync(statePath, 'utf-8');
162
- const fieldEscaped = field.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
189
+ const fieldEscaped = escapeRegex(field);
163
190
  // Try **Field:** bold format first, then plain Field: format
164
191
  const boldPattern = new RegExp(`(\\*\\*${fieldEscaped}:\\*\\*\\s*)(.*)`, 'i');
165
192
  const plainPattern = new RegExp(`(^${fieldEscaped}:\\s*)(.*)`, 'im');
@@ -180,21 +207,10 @@ function cmdStateUpdate(cwd, field, value) {
180
207
  }
181
208
 
182
209
  // ─── 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
- }
210
+ // stateExtractField is defined above (shared helper) — do not duplicate.
195
211
 
196
212
  function stateReplaceField(content, fieldName, newValue) {
197
- const escaped = fieldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
213
+ const escaped = escapeRegex(fieldName);
198
214
  // Try **Field:** bold format first, then plain Field: format
199
215
  const boldPattern = new RegExp(`(\\*\\*${escaped}:\\*\\*\\s*)(.*)`, 'i');
200
216
  if (boldPattern.test(content)) {
@@ -207,37 +223,106 @@ function stateReplaceField(content, fieldName, newValue) {
207
223
  return null;
208
224
  }
209
225
 
226
+ /**
227
+ * Replace a STATE.md field with fallback field name support.
228
+ * Tries `primary` first, then `fallback` (if provided), returns content unchanged
229
+ * if neither matches. This consolidates the replaceWithFallback pattern that was
230
+ * previously duplicated inline across phase.cjs, milestone.cjs, and state.cjs.
231
+ */
232
+ function stateReplaceFieldWithFallback(content, primary, fallback, value) {
233
+ let result = stateReplaceField(content, primary, value);
234
+ if (result) return result;
235
+ if (fallback) {
236
+ result = stateReplaceField(content, fallback, value);
237
+ if (result) return result;
238
+ }
239
+ return content;
240
+ }
241
+
242
+ /**
243
+ * Update fields within the ## Current Position section of STATE.md.
244
+ * This keeps the Current Position body in sync with the bold frontmatter fields.
245
+ * Only updates fields that already exist in the section; does not add new lines.
246
+ * Fixes #1365: advance-plan could not update Status/Last activity after begin-phase.
247
+ */
248
+ function updateCurrentPositionFields(content, fields) {
249
+ const posPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i;
250
+ const posMatch = content.match(posPattern);
251
+ if (!posMatch) return content;
252
+
253
+ let posBody = posMatch[2];
254
+
255
+ if (fields.status && /^Status:/m.test(posBody)) {
256
+ posBody = posBody.replace(/^Status:.*$/m, `Status: ${fields.status}`);
257
+ }
258
+ if (fields.lastActivity && /^Last activity:/im.test(posBody)) {
259
+ posBody = posBody.replace(/^Last activity:.*$/im, `Last activity: ${fields.lastActivity}`);
260
+ }
261
+ if (fields.plan && /^Plan:/m.test(posBody)) {
262
+ posBody = posBody.replace(/^Plan:.*$/m, `Plan: ${fields.plan}`);
263
+ }
264
+
265
+ return content.replace(posPattern, `${posMatch[1]}${posBody}`);
266
+ }
267
+
210
268
  function cmdStateAdvancePlan(cwd, raw) {
211
- const statePath = path.join(cwd, '.planning', 'STATE.md');
269
+ const statePath = planningPaths(cwd).state;
212
270
  if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; }
213
271
 
214
272
  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
273
  const today = new Date().toISOString().split('T')[0];
218
274
 
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
+ }
293
+
219
294
  if (isNaN(currentPlan) || isNaN(totalPlans)) {
220
295
  output({ error: 'Cannot parse Current Plan or Total Plans in Phase from STATE.md' }, raw);
221
296
  return;
222
297
  }
223
298
 
224
299
  if (currentPlan >= totalPlans) {
225
- content = stateReplaceField(content, 'Status', 'Phase complete — ready for verification') || content;
226
- content = stateReplaceField(content, 'Last Activity', today) || content;
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 });
227
303
  writeStateMd(statePath, content, cwd);
228
304
  output({ advanced: false, reason: 'last_plan', current_plan: currentPlan, total_plans: totalPlans, status: 'ready_for_verification' }, raw, 'false');
229
305
  } else {
230
306
  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;
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 });
234
319
  writeStateMd(statePath, content, cwd);
235
320
  output({ advanced: true, previous_plan: currentPlan, current_plan: newPlan, total_plans: totalPlans }, raw, 'true');
236
321
  }
237
322
  }
238
323
 
239
324
  function cmdStateRecordMetric(cwd, options, raw) {
240
- const statePath = path.join(cwd, '.planning', 'STATE.md');
325
+ const statePath = planningPaths(cwd).state;
241
326
  if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; }
242
327
 
243
328
  let content = fs.readFileSync(statePath, 'utf-8');
@@ -271,19 +356,21 @@ function cmdStateRecordMetric(cwd, options, raw) {
271
356
  }
272
357
 
273
358
  function cmdStateUpdateProgress(cwd, raw) {
274
- const statePath = path.join(cwd, '.planning', 'STATE.md');
359
+ const statePath = planningPaths(cwd).state;
275
360
  if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; }
276
361
 
277
362
  let content = fs.readFileSync(statePath, 'utf-8');
278
363
 
279
- // Count summaries across all phases
280
- const phasesDir = path.join(cwd, '.planning', 'phases');
364
+ // Count summaries across current milestone phases only
365
+ const phasesDir = planningPaths(cwd).phases;
281
366
  let totalPlans = 0;
282
367
  let totalSummaries = 0;
283
368
 
284
369
  if (fs.existsSync(phasesDir)) {
370
+ const isDirInMilestone = getMilestonePhaseFilter(cwd);
285
371
  const phaseDirs = fs.readdirSync(phasesDir, { withFileTypes: true })
286
- .filter(e => e.isDirectory()).map(e => e.name);
372
+ .filter(e => e.isDirectory()).map(e => e.name)
373
+ .filter(isDirInMilestone);
287
374
  for (const dir of phaseDirs) {
288
375
  const files = fs.readdirSync(path.join(phasesDir, dir));
289
376
  totalPlans += files.filter(f => f.match(/-PLAN\.md$/i)).length;
@@ -314,7 +401,7 @@ function cmdStateUpdateProgress(cwd, raw) {
314
401
  }
315
402
 
316
403
  function cmdStateAddDecision(cwd, options, raw) {
317
- const statePath = path.join(cwd, '.planning', 'STATE.md');
404
+ const statePath = planningPaths(cwd).state;
318
405
  if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; }
319
406
 
320
407
  const { phase, summary, summary_file, rationale, rationale_file } = options;
@@ -352,7 +439,7 @@ function cmdStateAddDecision(cwd, options, raw) {
352
439
  }
353
440
 
354
441
  function cmdStateAddBlocker(cwd, text, raw) {
355
- const statePath = path.join(cwd, '.planning', 'STATE.md');
442
+ const statePath = planningPaths(cwd).state;
356
443
  if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; }
357
444
  const blockerOptions = typeof text === 'object' && text !== null ? text : { text };
358
445
  let blockerText = null;
@@ -385,7 +472,7 @@ function cmdStateAddBlocker(cwd, text, raw) {
385
472
  }
386
473
 
387
474
  function cmdStateResolveBlocker(cwd, text, raw) {
388
- const statePath = path.join(cwd, '.planning', 'STATE.md');
475
+ const statePath = planningPaths(cwd).state;
389
476
  if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; }
390
477
  if (!text) { output({ error: 'text required' }, raw); return; }
391
478
 
@@ -417,7 +504,7 @@ function cmdStateResolveBlocker(cwd, text, raw) {
417
504
  }
418
505
 
419
506
  function cmdStateRecordSession(cwd, options, raw) {
420
- const statePath = path.join(cwd, '.planning', 'STATE.md');
507
+ const statePath = planningPaths(cwd).state;
421
508
  if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; }
422
509
 
423
510
  let content = fs.readFileSync(statePath, 'utf-8');
@@ -452,7 +539,7 @@ function cmdStateRecordSession(cwd, options, raw) {
452
539
  }
453
540
 
454
541
  function cmdStateSnapshot(cwd, raw) {
455
- const statePath = path.join(cwd, '.planning', 'STATE.md');
542
+ const statePath = planningPaths(cwd).state;
456
543
 
457
544
  if (!fs.existsSync(statePath)) {
458
545
  output({ error: 'STATE.md not found' }, raw);
@@ -574,7 +661,7 @@ function buildStateFrontmatter(bodyContent, cwd) {
574
661
  const info = getMilestoneInfo(cwd);
575
662
  milestone = info.version;
576
663
  milestoneName = info.name;
577
- } catch {}
664
+ } catch { /* intentionally empty */ }
578
665
  }
579
666
 
580
667
  let totalPhases = totalPhasesRaw ? parseInt(totalPhasesRaw, 10) : null;
@@ -584,7 +671,7 @@ function buildStateFrontmatter(bodyContent, cwd) {
584
671
 
585
672
  if (cwd) {
586
673
  try {
587
- const phasesDir = path.join(cwd, '.planning', 'phases');
674
+ const phasesDir = planningPaths(cwd).phases;
588
675
  if (fs.existsSync(phasesDir)) {
589
676
  const isDirInMilestone = getMilestonePhaseFilter(cwd);
590
677
  const phaseDirs = fs.readdirSync(phasesDir, { withFileTypes: true })
@@ -609,7 +696,7 @@ function buildStateFrontmatter(bodyContent, cwd) {
609
696
  totalPlans = diskTotalPlans;
610
697
  completedPlans = diskTotalSummaries;
611
698
  }
612
- } catch {}
699
+ } catch { /* intentionally empty */ }
613
700
  }
614
701
 
615
702
  let progressPercent = null;
@@ -662,27 +749,92 @@ function buildStateFrontmatter(bodyContent, cwd) {
662
749
  }
663
750
 
664
751
  function stripFrontmatter(content) {
665
- return content.replace(/^---\n[\s\S]*?\n---\n*/, '');
752
+ // Strip ALL frontmatter blocks at the start of the file.
753
+ // Handles CRLF line endings and multiple stacked blocks (corruption recovery).
754
+ // Greedy: keeps stripping ---...--- blocks separated by optional whitespace.
755
+ let result = content;
756
+ // eslint-disable-next-line no-constant-condition
757
+ while (true) {
758
+ const stripped = result.replace(/^\s*---\r?\n[\s\S]*?\r?\n---\s*/, '');
759
+ if (stripped === result) break;
760
+ result = stripped;
761
+ }
762
+ return result;
666
763
  }
667
764
 
668
765
  function syncStateFrontmatter(content, cwd) {
766
+ // read existing frontmatter BEFORE stripping — it may contain values
767
+ // that the body no longer has (e.g., Status field removed by an agent).
768
+ const existingFm = extractFrontmatter(content);
669
769
  const body = stripFrontmatter(content);
670
- const fm = buildStateFrontmatter(body, cwd);
671
- const yamlStr = reconstructFrontmatter(fm);
770
+ const derivedFm = buildStateFrontmatter(body, cwd);
771
+
772
+ // Preserve existing frontmatter status when body-derived status is 'unknown'.
773
+ // This prevents a missing Status: field in the body from overwriting a
774
+ // previously valid status (e.g., 'executing' → 'unknown').
775
+ if (derivedFm.status === 'unknown' && existingFm.status && existingFm.status !== 'unknown') {
776
+ derivedFm.status = existingFm.status;
777
+ }
778
+
779
+ const yamlStr = reconstructFrontmatter(derivedFm);
672
780
  return `---\n${yamlStr}\n---\n\n${body}`;
673
781
  }
674
782
 
675
783
  /**
676
784
  * write STATE.md with synchronized YAML frontmatter.
677
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).
678
788
  */
679
789
  function writeStateMd(statePath, content, cwd) {
680
790
  const synced = syncStateFrontmatter(content, cwd);
681
- fs.writeFileSync(statePath, synced, 'utf-8');
791
+ const lockPath = statePath + '.lock';
792
+ const maxRetries = 10;
793
+ const retryDelay = 200; // ms
794
+
795
+ // Acquire lock (spin with backoff)
796
+ for (let i = 0; i < maxRetries; i++) {
797
+ try {
798
+ // O_EXCL fails if file already exists — atomic lock
799
+ const fd = fs.openSync(lockPath, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY);
800
+ fs.writeSync(fd, String(process.pid));
801
+ fs.closeSync(fd);
802
+ break;
803
+ } catch (err) {
804
+ if (err.code === 'EEXIST') {
805
+ // Check for stale lock (> 10s old)
806
+ try {
807
+ const stat = fs.statSync(lockPath);
808
+ if (Date.now() - stat.mtimeMs > 10000) {
809
+ fs.unlinkSync(lockPath);
810
+ continue; // retry immediately after clearing stale lock
811
+ }
812
+ } catch { /* lock was released between check — retry */ }
813
+
814
+ if (i === maxRetries - 1) {
815
+ // Last resort: write anyway rather than losing data
816
+ try { fs.unlinkSync(lockPath); } catch {}
817
+ break;
818
+ }
819
+ // Spin-wait with small jitter
820
+ const jitter = Math.floor(Math.random() * 50);
821
+ const start = Date.now();
822
+ while (Date.now() - start < retryDelay + jitter) { /* busy wait */ }
823
+ continue;
824
+ }
825
+ break; // non-EEXIST error — proceed without lock
826
+ }
827
+ }
828
+
829
+ try {
830
+ fs.writeFileSync(statePath, normalizeMd(synced), 'utf-8');
831
+ } finally {
832
+ try { fs.unlinkSync(lockPath); } catch { /* lock already gone */ }
833
+ }
682
834
  }
683
835
 
684
836
  function cmdStateJson(cwd, raw) {
685
- const statePath = path.join(cwd, '.planning', 'STATE.md');
837
+ const statePath = planningPaths(cwd).state;
686
838
  if (!fs.existsSync(statePath)) {
687
839
  output({ error: 'STATE.md not found' }, raw, 'STATE.md not found');
688
840
  return;
@@ -701,9 +853,164 @@ function cmdStateJson(cwd, raw) {
701
853
  output(fm, raw, JSON.stringify(fm, null, 2));
702
854
  }
703
855
 
856
+ /**
857
+ * Update STATE.md when a new phase begins execution.
858
+ * Updates body text fields (Current focus, Status, Last Activity, Current Position)
859
+ * and synchronizes frontmatter via writeStateMd.
860
+ * Fixes: #1102 (plan counts), #1103 (status/last_activity), #1104 (body text).
861
+ */
862
+ function cmdStateBeginPhase(cwd, phaseNumber, phaseName, planCount, raw) {
863
+ const statePath = planningPaths(cwd).state;
864
+ if (!fs.existsSync(statePath)) {
865
+ output({ error: 'STATE.md not found' }, raw);
866
+ return;
867
+ }
868
+
869
+ let content = fs.readFileSync(statePath, 'utf-8');
870
+ const today = new Date().toISOString().split('T')[0];
871
+ const updated = [];
872
+
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'); }
890
+
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
+ }
896
+
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'); }
900
+
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
+ }
906
+
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
+ }
914
+
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
+ }
931
+
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
+ }
939
+
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
+ }
945
+
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);
950
+ }
951
+
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
+ }
959
+
960
+ output({ updated, phase: phaseNumber, phase_name: phaseName || null, plan_count: planCount || null }, raw, updated.length > 0 ? 'true' : 'false');
961
+ }
962
+
963
+ /**
964
+ * write a WAITING.json signal file when GSD hits a decision point.
965
+ * External watchers (fswatch, polling, orchestrators) can detect this.
966
+ * File is written to .planning/WAITING.json (or .gsd/WAITING.json if .gsd exists).
967
+ * Fixes #1034.
968
+ */
969
+ function cmdSignalWaiting(cwd, type, question, options, phase, raw) {
970
+ const gsdDir = fs.existsSync(path.join(cwd, '.gsd')) ? path.join(cwd, '.gsd') : planningDir(cwd);
971
+ const waitingPath = path.join(gsdDir, 'WAITING.json');
972
+
973
+ const signal = {
974
+ status: 'waiting',
975
+ type: type || 'decision_point',
976
+ question: question || null,
977
+ options: options ? options.split('|').map(o => o.trim()) : [],
978
+ since: new Date().toISOString(),
979
+ phase: phase || null,
980
+ };
981
+
982
+ try {
983
+ fs.mkdirSync(gsdDir, { recursive: true });
984
+ fs.writeFileSync(waitingPath, JSON.stringify(signal, null, 2), 'utf-8');
985
+ output({ signaled: true, path: waitingPath }, raw, 'true');
986
+ } catch (e) {
987
+ output({ signaled: false, error: e.message }, raw, 'false');
988
+ }
989
+ }
990
+
991
+ /**
992
+ * Remove the WAITING.json signal file when user answers and agent resumes.
993
+ */
994
+ function cmdSignalResume(cwd, raw) {
995
+ const paths = [
996
+ path.join(cwd, '.gsd', 'WAITING.json'),
997
+ path.join(planningDir(cwd), 'WAITING.json'),
998
+ ];
999
+
1000
+ let removed = false;
1001
+ for (const p of paths) {
1002
+ if (fs.existsSync(p)) {
1003
+ try { fs.unlinkSync(p); removed = true; } catch {}
1004
+ }
1005
+ }
1006
+
1007
+ output({ resumed: true, removed }, raw, removed ? 'true' : 'false');
1008
+ }
1009
+
704
1010
  module.exports = {
705
1011
  stateExtractField,
706
1012
  stateReplaceField,
1013
+ stateReplaceFieldWithFallback,
707
1014
  writeStateMd,
708
1015
  cmdStateLoad,
709
1016
  cmdStateGet,
@@ -718,4 +1025,7 @@ module.exports = {
718
1025
  cmdStateRecordSession,
719
1026
  cmdStateSnapshot,
720
1027
  cmdStateJson,
1028
+ cmdStateBeginPhase,
1029
+ cmdSignalWaiting,
1030
+ cmdSignalResume,
721
1031
  };
@@ -4,7 +4,7 @@
4
4
 
5
5
  const fs = require('fs');
6
6
  const path = require('path');
7
- const { normalizePhaseName, findPhaseInternal, generateSlugInternal, toPosixPath, output, error } = require('./core.cjs');
7
+ const { normalizePhaseName, findPhaseInternal, generateSlugInternal, normalizeMd, toPosixPath, output, error } = require('./core.cjs');
8
8
  const { reconstructFrontmatter } = require('./frontmatter.cjs');
9
9
 
10
10
  function cmdTemplateSelect(cwd, planPath, raw) {
@@ -214,7 +214,7 @@ function cmdTemplateFill(cwd, templateType, options, raw) {
214
214
  return;
215
215
  }
216
216
 
217
- fs.writeFileSync(outPath, fullContent, 'utf-8');
217
+ fs.writeFileSync(outPath, normalizeMd(fullContent), 'utf-8');
218
218
  const relPath = toPosixPath(path.relative(cwd, outPath));
219
219
  output({ created: true, path: relPath, template: templateType }, raw, relPath);
220
220
  }