gsd-opencode 1.20.3 → 1.22.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 (114) hide show
  1. package/agents/gsd-codebase-mapper.md +9 -1
  2. package/agents/gsd-debugger.md +66 -10
  3. package/agents/gsd-executor.md +36 -16
  4. package/agents/gsd-integration-checker.md +2 -0
  5. package/agents/gsd-nyquist-auditor.md +178 -0
  6. package/agents/gsd-phase-researcher.md +28 -34
  7. package/agents/gsd-plan-checker.md +42 -78
  8. package/agents/gsd-planner.md +139 -24
  9. package/agents/gsd-project-researcher.md +11 -1
  10. package/agents/gsd-research-synthesizer.md +13 -3
  11. package/agents/gsd-roadmapper.md +25 -15
  12. package/agents/gsd-verifier.md +29 -6
  13. package/bin/dm/lib/constants.js +6 -1
  14. package/bin/dm/src/services/file-ops.js +14 -1
  15. package/commands/gsd/gsd-add-phase.md +6 -6
  16. package/commands/gsd/gsd-add-tests.md +41 -0
  17. package/commands/gsd/gsd-add-todo.md +7 -7
  18. package/commands/gsd/gsd-audit-milestone.md +9 -9
  19. package/commands/gsd/gsd-check-profile.md +3 -3
  20. package/commands/gsd/gsd-check-todos.md +7 -7
  21. package/commands/gsd/gsd-cleanup.md +2 -2
  22. package/commands/gsd/gsd-complete-milestone.md +6 -6
  23. package/commands/gsd/gsd-debug.md +11 -7
  24. package/commands/gsd/gsd-discuss-phase.md +26 -19
  25. package/commands/gsd/gsd-execute-phase.md +13 -13
  26. package/commands/gsd/gsd-health.md +7 -7
  27. package/commands/gsd/gsd-help.md +2 -2
  28. package/commands/gsd/gsd-insert-phase.md +6 -6
  29. package/commands/gsd/gsd-join-discord.md +1 -1
  30. package/commands/gsd/gsd-list-phase-assumptions.md +6 -6
  31. package/commands/gsd/gsd-map-codebase.md +8 -8
  32. package/commands/gsd/gsd-new-milestone.md +12 -12
  33. package/commands/gsd/gsd-new-project.md +12 -12
  34. package/commands/gsd/gsd-pause-work.md +6 -6
  35. package/commands/gsd/gsd-plan-milestone-gaps.md +9 -9
  36. package/commands/gsd/gsd-plan-phase.md +14 -13
  37. package/commands/gsd/gsd-progress.md +8 -8
  38. package/commands/gsd/gsd-quick.md +17 -13
  39. package/commands/gsd/gsd-reapply-patches.md +19 -11
  40. package/commands/gsd/gsd-remove-phase.md +7 -7
  41. package/commands/gsd/gsd-research-phase.md +12 -11
  42. package/commands/gsd/gsd-resume-work.md +8 -8
  43. package/commands/gsd/gsd-set-profile.md +6 -6
  44. package/commands/gsd/gsd-settings.md +7 -7
  45. package/commands/gsd/gsd-update.md +5 -5
  46. package/commands/gsd/gsd-validate-phase.md +35 -0
  47. package/commands/gsd/gsd-verify-work.md +11 -11
  48. package/get-shit-done/bin/gsd-oc-commands/allow-read-config.cjs +235 -0
  49. package/get-shit-done/bin/gsd-oc-tools.cjs +11 -5
  50. package/get-shit-done/bin/gsd-tools.cjs +45 -6
  51. package/get-shit-done/bin/lib/commands.cjs +11 -19
  52. package/get-shit-done/bin/lib/config.cjs +8 -1
  53. package/get-shit-done/bin/lib/core.cjs +131 -16
  54. package/get-shit-done/bin/lib/init.cjs +28 -12
  55. package/get-shit-done/bin/lib/milestone.cjs +34 -8
  56. package/get-shit-done/bin/lib/phase.cjs +74 -50
  57. package/get-shit-done/bin/lib/roadmap.cjs +7 -7
  58. package/get-shit-done/bin/lib/state.cjs +294 -63
  59. package/get-shit-done/bin/lib/template.cjs +3 -3
  60. package/get-shit-done/bin/lib/verify.cjs +56 -8
  61. package/get-shit-done/bin/test/allow-read-config.test.cjs +262 -0
  62. package/get-shit-done/references/checkpoints.md +1 -1
  63. package/get-shit-done/references/decimal-phase-calculation.md +6 -6
  64. package/get-shit-done/references/git-integration.md +3 -3
  65. package/get-shit-done/references/git-planning-commit.md +2 -2
  66. package/get-shit-done/references/model-profile-resolution.md +1 -1
  67. package/get-shit-done/references/model-profiles.md +1 -0
  68. package/get-shit-done/references/phase-argument-parsing.md +4 -4
  69. package/get-shit-done/references/planning-config.md +10 -6
  70. package/get-shit-done/references/questioning.md +17 -0
  71. package/get-shit-done/references/verification-patterns.md +1 -1
  72. package/get-shit-done/templates/DEBUG.md +7 -2
  73. package/get-shit-done/templates/VALIDATION.md +18 -46
  74. package/get-shit-done/templates/codebase/structure.md +3 -3
  75. package/get-shit-done/templates/config.json +2 -2
  76. package/get-shit-done/templates/context.md +14 -0
  77. package/get-shit-done/templates/phase-prompt.md +10 -10
  78. package/get-shit-done/templates/retrospective.md +54 -0
  79. package/get-shit-done/templates/roadmap.md +1 -1
  80. package/get-shit-done/workflows/add-phase.md +3 -2
  81. package/get-shit-done/workflows/add-tests.md +351 -0
  82. package/get-shit-done/workflows/add-todo.md +4 -3
  83. package/get-shit-done/workflows/audit-milestone.md +40 -5
  84. package/get-shit-done/workflows/check-todos.md +3 -2
  85. package/get-shit-done/workflows/cleanup.md +1 -1
  86. package/get-shit-done/workflows/complete-milestone.md +69 -5
  87. package/get-shit-done/workflows/diagnose-issues.md +2 -2
  88. package/get-shit-done/workflows/discovery-phase.md +6 -6
  89. package/get-shit-done/workflows/discuss-phase.md +194 -58
  90. package/get-shit-done/workflows/execute-phase.md +29 -23
  91. package/get-shit-done/workflows/execute-plan.md +22 -18
  92. package/get-shit-done/workflows/health.md +5 -2
  93. package/get-shit-done/workflows/help.md +4 -1
  94. package/get-shit-done/workflows/insert-phase.md +3 -2
  95. package/get-shit-done/workflows/map-codebase.md +3 -2
  96. package/get-shit-done/workflows/new-milestone.md +12 -10
  97. package/get-shit-done/workflows/new-project.md +44 -49
  98. package/get-shit-done/workflows/oc-set-profile.md +24 -0
  99. package/get-shit-done/workflows/pause-work.md +2 -2
  100. package/get-shit-done/workflows/plan-milestone-gaps.md +3 -3
  101. package/get-shit-done/workflows/plan-phase.md +155 -73
  102. package/get-shit-done/workflows/progress.md +8 -7
  103. package/get-shit-done/workflows/quick.md +158 -10
  104. package/get-shit-done/workflows/remove-phase.md +5 -4
  105. package/get-shit-done/workflows/research-phase.md +5 -4
  106. package/get-shit-done/workflows/resume-project.md +3 -2
  107. package/get-shit-done/workflows/set-profile.md +3 -2
  108. package/get-shit-done/workflows/settings.md +6 -6
  109. package/get-shit-done/workflows/transition.md +5 -5
  110. package/get-shit-done/workflows/update.md +45 -19
  111. package/get-shit-done/workflows/validate-phase.md +167 -0
  112. package/get-shit-done/workflows/verify-phase.md +10 -9
  113. package/get-shit-done/workflows/verify-work.md +18 -4
  114. package/package.json +1 -1
@@ -4,7 +4,20 @@
4
4
 
5
5
  const fs = require('fs');
6
6
  const path = require('path');
7
- const { loadConfig, output, error } = require('./core.cjs');
7
+ const { escapeRegex, loadConfig, getMilestoneInfo, getMilestonePhaseFilter, output, error } = require('./core.cjs');
8
+ const { extractFrontmatter, reconstructFrontmatter } = require('./frontmatter.cjs');
9
+
10
+ // Shared helper: extract a field value from STATE.md content.
11
+ // Supports both **Field:** bold and plain Field: format.
12
+ function stateExtractField(content, fieldName) {
13
+ const escaped = escapeRegex(fieldName);
14
+ const boldPattern = new RegExp(`\\*\\*${escaped}:\\*\\*\\s*(.+)`, 'i');
15
+ const boldMatch = content.match(boldPattern);
16
+ if (boldMatch) return boldMatch[1].trim();
17
+ const plainPattern = new RegExp(`^${escaped}:\\s*(.+)`, 'im');
18
+ const plainMatch = content.match(plainPattern);
19
+ return plainMatch ? plainMatch[1].trim() : null;
20
+ }
8
21
 
9
22
  function cmdStateLoad(cwd, raw) {
10
23
  const config = loadConfig(cwd);
@@ -64,11 +77,19 @@ function cmdStateGet(cwd, section, raw) {
64
77
  // Try to find markdown section or field
65
78
  const fieldEscaped = section.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
66
79
 
67
- // Check for **field:** value
68
- const fieldPattern = new RegExp(`\\*\\*${fieldEscaped}:\\*\\*\\s*(.*)`, 'i');
69
- const fieldMatch = content.match(fieldPattern);
70
- if (fieldMatch) {
71
- output({ [section]: fieldMatch[1].trim() }, raw, fieldMatch[1].trim());
80
+ // Check for **field:** value (bold format)
81
+ const boldPattern = new RegExp(`\\*\\*${fieldEscaped}:\\*\\*\\s*(.*)`, 'i');
82
+ const boldMatch = content.match(boldPattern);
83
+ if (boldMatch) {
84
+ output({ [section]: boldMatch[1].trim() }, raw, boldMatch[1].trim());
85
+ return;
86
+ }
87
+
88
+ // Check for field: value (plain format)
89
+ const plainPattern = new RegExp(`^${fieldEscaped}:\\s*(.*)`, 'im');
90
+ const plainMatch = content.match(plainPattern);
91
+ if (plainMatch) {
92
+ output({ [section]: plainMatch[1].trim() }, raw, plainMatch[1].trim());
72
93
  return;
73
94
  }
74
95
 
@@ -86,6 +107,17 @@ function cmdStateGet(cwd, section, raw) {
86
107
  }
87
108
  }
88
109
 
110
+ function readTextArgOrFile(cwd, value, filePath, label) {
111
+ if (!filePath) return value;
112
+
113
+ const resolvedPath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath);
114
+ try {
115
+ return fs.readFileSync(resolvedPath, 'utf-8').trimEnd();
116
+ } catch {
117
+ throw new Error(`${label} file not found: ${filePath}`);
118
+ }
119
+ }
120
+
89
121
  function cmdStatePatch(cwd, patches, raw) {
90
122
  const statePath = path.join(cwd, '.planning', 'STATE.md');
91
123
  try {
@@ -94,10 +126,15 @@ function cmdStatePatch(cwd, patches, raw) {
94
126
 
95
127
  for (const [field, value] of Object.entries(patches)) {
96
128
  const fieldEscaped = field.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
97
- const pattern = new RegExp(`(\\*\\*${fieldEscaped}:\\*\\*\\s*)(.*)`, 'i');
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');
98
132
 
99
- if (pattern.test(content)) {
100
- content = content.replace(pattern, `$1${value}`);
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}`);
101
138
  results.updated.push(field);
102
139
  } else {
103
140
  results.failed.push(field);
@@ -105,7 +142,7 @@ function cmdStatePatch(cwd, patches, raw) {
105
142
  }
106
143
 
107
144
  if (results.updated.length > 0) {
108
- fs.writeFileSync(statePath, content, 'utf-8');
145
+ writeStateMd(statePath, content, cwd);
109
146
  }
110
147
 
111
148
  output(results, raw, results.updated.length > 0 ? 'true' : 'false');
@@ -123,10 +160,16 @@ function cmdStateUpdate(cwd, field, value) {
123
160
  try {
124
161
  let content = fs.readFileSync(statePath, 'utf-8');
125
162
  const fieldEscaped = field.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
126
- const pattern = new RegExp(`(\\*\\*${fieldEscaped}:\\*\\*\\s*)(.*)`, 'i');
127
- if (pattern.test(content)) {
128
- content = content.replace(pattern, `$1${value}`);
129
- fs.writeFileSync(statePath, content, 'utf-8');
163
+ // Try **Field:** bold format first, then plain Field: format
164
+ const boldPattern = new RegExp(`(\\*\\*${fieldEscaped}:\\*\\*\\s*)(.*)`, 'i');
165
+ const plainPattern = new RegExp(`(^${fieldEscaped}:\\s*)(.*)`, 'im');
166
+ if (boldPattern.test(content)) {
167
+ content = content.replace(boldPattern, (_match, prefix) => `${prefix}${value}`);
168
+ writeStateMd(statePath, content, cwd);
169
+ output({ updated: true });
170
+ } else if (plainPattern.test(content)) {
171
+ content = content.replace(plainPattern, (_match, prefix) => `${prefix}${value}`);
172
+ writeStateMd(statePath, content, cwd);
130
173
  output({ updated: true });
131
174
  } else {
132
175
  output({ updated: false, reason: `Field "${field}" not found in STATE.md` });
@@ -139,16 +182,27 @@ function cmdStateUpdate(cwd, field, value) {
139
182
  // ─── State Progression Engine ────────────────────────────────────────────────
140
183
 
141
184
  function stateExtractField(content, fieldName) {
142
- const pattern = new RegExp(`\\*\\*${fieldName}:\\*\\*\\s*(.+)`, 'i');
143
- const match = content.match(pattern);
144
- return match ? match[1].trim() : null;
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;
145
194
  }
146
195
 
147
196
  function stateReplaceField(content, fieldName, newValue) {
148
197
  const escaped = fieldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
149
- const pattern = new RegExp(`(\\*\\*${escaped}:\\*\\*\\s*)(.*)`, 'i');
150
- if (pattern.test(content)) {
151
- return content.replace(pattern, `$1${newValue}`);
198
+ // Try **Field:** bold format first, then plain Field: format
199
+ const boldPattern = new RegExp(`(\\*\\*${escaped}:\\*\\*\\s*)(.*)`, 'i');
200
+ if (boldPattern.test(content)) {
201
+ return content.replace(boldPattern, (_match, prefix) => `${prefix}${newValue}`);
202
+ }
203
+ const plainPattern = new RegExp(`(^${escaped}:\\s*)(.*)`, 'im');
204
+ if (plainPattern.test(content)) {
205
+ return content.replace(plainPattern, (_match, prefix) => `${prefix}${newValue}`);
152
206
  }
153
207
  return null;
154
208
  }
@@ -170,14 +224,14 @@ function cmdStateAdvancePlan(cwd, raw) {
170
224
  if (currentPlan >= totalPlans) {
171
225
  content = stateReplaceField(content, 'Status', 'Phase complete — ready for verification') || content;
172
226
  content = stateReplaceField(content, 'Last Activity', today) || content;
173
- fs.writeFileSync(statePath, content, 'utf-8');
227
+ writeStateMd(statePath, content, cwd);
174
228
  output({ advanced: false, reason: 'last_plan', current_plan: currentPlan, total_plans: totalPlans, status: 'ready_for_verification' }, raw, 'false');
175
229
  } else {
176
230
  const newPlan = currentPlan + 1;
177
231
  content = stateReplaceField(content, 'Current Plan', String(newPlan)) || content;
178
232
  content = stateReplaceField(content, 'Status', 'Ready to execute') || content;
179
233
  content = stateReplaceField(content, 'Last Activity', today) || content;
180
- fs.writeFileSync(statePath, content, 'utf-8');
234
+ writeStateMd(statePath, content, cwd);
181
235
  output({ advanced: true, previous_plan: currentPlan, current_plan: newPlan, total_plans: totalPlans }, raw, 'true');
182
236
  }
183
237
  }
@@ -199,7 +253,6 @@ function cmdStateRecordMetric(cwd, options, raw) {
199
253
  const metricsMatch = content.match(metricsPattern);
200
254
 
201
255
  if (metricsMatch) {
202
- const tableHeader = metricsMatch[1];
203
256
  let tableBody = metricsMatch[2].trimEnd();
204
257
  const newRow = `| Phase ${phase} P${plan} | ${duration} | ${tasks || '-'} tasks | ${files || '-'} files |`;
205
258
 
@@ -209,8 +262,8 @@ function cmdStateRecordMetric(cwd, options, raw) {
209
262
  tableBody = tableBody + '\n' + newRow;
210
263
  }
211
264
 
212
- content = content.replace(metricsPattern, `${tableHeader}${tableBody}\n`);
213
- fs.writeFileSync(statePath, content, 'utf-8');
265
+ content = content.replace(metricsPattern, (_match, header) => `${header}${tableBody}\n`);
266
+ writeStateMd(statePath, content, cwd);
214
267
  output({ recorded: true, phase, plan, duration }, raw, 'true');
215
268
  } else {
216
269
  output({ recorded: false, reason: 'Performance Metrics section not found in STATE.md' }, raw, 'false');
@@ -238,16 +291,22 @@ function cmdStateUpdateProgress(cwd, raw) {
238
291
  }
239
292
  }
240
293
 
241
- const percent = totalPlans > 0 ? Math.round(totalSummaries / totalPlans * 100) : 0;
294
+ const percent = totalPlans > 0 ? Math.min(100, Math.round(totalSummaries / totalPlans * 100)) : 0;
242
295
  const barWidth = 10;
243
296
  const filled = Math.round(percent / 100 * barWidth);
244
297
  const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(barWidth - filled);
245
298
  const progressStr = `[${bar}] ${percent}%`;
246
299
 
247
- const progressPattern = /(\*\*Progress:\*\*\s*).*/i;
248
- if (progressPattern.test(content)) {
249
- content = content.replace(progressPattern, `$1${progressStr}`);
250
- fs.writeFileSync(statePath, content, 'utf-8');
300
+ // Try **Progress:** bold format first, then plain Progress: format
301
+ const boldProgressPattern = /(\*\*Progress:\*\*\s*).*/i;
302
+ const plainProgressPattern = /^(Progress:\s*).*/im;
303
+ if (boldProgressPattern.test(content)) {
304
+ content = content.replace(boldProgressPattern, (_match, prefix) => `${prefix}${progressStr}`);
305
+ writeStateMd(statePath, content, cwd);
306
+ output({ updated: true, percent, completed: totalSummaries, total: totalPlans, bar: progressStr }, raw, progressStr);
307
+ } else if (plainProgressPattern.test(content)) {
308
+ content = content.replace(plainProgressPattern, (_match, prefix) => `${prefix}${progressStr}`);
309
+ writeStateMd(statePath, content, cwd);
251
310
  output({ updated: true, percent, completed: totalSummaries, total: totalPlans, bar: progressStr }, raw, progressStr);
252
311
  } else {
253
312
  output({ updated: false, reason: 'Progress field not found in STATE.md' }, raw, 'false');
@@ -258,11 +317,22 @@ function cmdStateAddDecision(cwd, options, raw) {
258
317
  const statePath = path.join(cwd, '.planning', 'STATE.md');
259
318
  if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; }
260
319
 
261
- const { phase, summary, rationale } = options;
262
- if (!summary) { output({ error: 'summary required' }, raw); return; }
320
+ const { phase, summary, summary_file, rationale, rationale_file } = options;
321
+ let summaryText = null;
322
+ let rationaleText = '';
323
+
324
+ try {
325
+ summaryText = readTextArgOrFile(cwd, summary, summary_file, 'summary');
326
+ rationaleText = readTextArgOrFile(cwd, rationale || '', rationale_file, 'rationale');
327
+ } catch (err) {
328
+ output({ added: false, reason: err.message }, raw, 'false');
329
+ return;
330
+ }
331
+
332
+ if (!summaryText) { output({ error: 'summary required' }, raw); return; }
263
333
 
264
334
  let content = fs.readFileSync(statePath, 'utf-8');
265
- const entry = `- [Phase ${phase || '?'}]: ${summary}${rationale ? ` — ${rationale}` : ''}`;
335
+ const entry = `- [Phase ${phase || '?'}]: ${summaryText}${rationaleText ? ` — ${rationaleText}` : ''}`;
266
336
 
267
337
  // Find Decisions section (various heading patterns)
268
338
  const sectionPattern = /(###?\s*(?:Decisions|Decisions Made|Accumulated.*Decisions)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
@@ -273,8 +343,8 @@ function cmdStateAddDecision(cwd, options, raw) {
273
343
  // Remove placeholders
274
344
  sectionBody = sectionBody.replace(/None yet\.?\s*\n?/gi, '').replace(/No decisions yet\.?\s*\n?/gi, '');
275
345
  sectionBody = sectionBody.trimEnd() + '\n' + entry + '\n';
276
- content = content.replace(sectionPattern, `${match[1]}${sectionBody}`);
277
- fs.writeFileSync(statePath, content, 'utf-8');
346
+ content = content.replace(sectionPattern, (_match, header) => `${header}${sectionBody}`);
347
+ writeStateMd(statePath, content, cwd);
278
348
  output({ added: true, decision: entry }, raw, 'true');
279
349
  } else {
280
350
  output({ added: false, reason: 'Decisions section not found in STATE.md' }, raw, 'false');
@@ -284,10 +354,20 @@ function cmdStateAddDecision(cwd, options, raw) {
284
354
  function cmdStateAddBlocker(cwd, text, raw) {
285
355
  const statePath = path.join(cwd, '.planning', 'STATE.md');
286
356
  if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; }
287
- if (!text) { output({ error: 'text required' }, raw); return; }
357
+ const blockerOptions = typeof text === 'object' && text !== null ? text : { text };
358
+ let blockerText = null;
359
+
360
+ try {
361
+ blockerText = readTextArgOrFile(cwd, blockerOptions.text, blockerOptions.text_file, 'blocker');
362
+ } catch (err) {
363
+ output({ added: false, reason: err.message }, raw, 'false');
364
+ return;
365
+ }
366
+
367
+ if (!blockerText) { output({ error: 'text required' }, raw); return; }
288
368
 
289
369
  let content = fs.readFileSync(statePath, 'utf-8');
290
- const entry = `- ${text}`;
370
+ const entry = `- ${blockerText}`;
291
371
 
292
372
  const sectionPattern = /(###?\s*(?:Blockers|Blockers\/Concerns|Concerns)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
293
373
  const match = content.match(sectionPattern);
@@ -296,9 +376,9 @@ function cmdStateAddBlocker(cwd, text, raw) {
296
376
  let sectionBody = match[2];
297
377
  sectionBody = sectionBody.replace(/None\.?\s*\n?/gi, '').replace(/None yet\.?\s*\n?/gi, '');
298
378
  sectionBody = sectionBody.trimEnd() + '\n' + entry + '\n';
299
- content = content.replace(sectionPattern, `${match[1]}${sectionBody}`);
300
- fs.writeFileSync(statePath, content, 'utf-8');
301
- output({ added: true, blocker: text }, raw, 'true');
379
+ content = content.replace(sectionPattern, (_match, header) => `${header}${sectionBody}`);
380
+ writeStateMd(statePath, content, cwd);
381
+ output({ added: true, blocker: blockerText }, raw, 'true');
302
382
  } else {
303
383
  output({ added: false, reason: 'Blockers section not found in STATE.md' }, raw, 'false');
304
384
  }
@@ -328,8 +408,8 @@ function cmdStateResolveBlocker(cwd, text, raw) {
328
408
  newBody = 'None\n';
329
409
  }
330
410
 
331
- content = content.replace(sectionPattern, `${match[1]}${newBody}`);
332
- fs.writeFileSync(statePath, content, 'utf-8');
411
+ content = content.replace(sectionPattern, (_match, header) => `${header}${newBody}`);
412
+ writeStateMd(statePath, content, cwd);
333
413
  output({ resolved: true, blocker: text }, raw, 'true');
334
414
  } else {
335
415
  output({ resolved: false, reason: 'Blockers section not found in STATE.md' }, raw, 'false');
@@ -364,7 +444,7 @@ function cmdStateRecordSession(cwd, options, raw) {
364
444
  if (result) { content = result; updated.push('Resume File'); }
365
445
 
366
446
  if (updated.length > 0) {
367
- fs.writeFileSync(statePath, content, 'utf-8');
447
+ writeStateMd(statePath, content, cwd);
368
448
  output({ recorded: true, updated }, raw, 'true');
369
449
  } else {
370
450
  output({ recorded: false, reason: 'No session fields found in STATE.md' }, raw, 'false');
@@ -381,24 +461,17 @@ function cmdStateSnapshot(cwd, raw) {
381
461
 
382
462
  const content = fs.readFileSync(statePath, 'utf-8');
383
463
 
384
- // Helper to extract **Field:** value patterns
385
- const extractField = (fieldName) => {
386
- const pattern = new RegExp(`\\*\\*${fieldName}:\\*\\*\\s*(.+)`, 'i');
387
- const match = content.match(pattern);
388
- return match ? match[1].trim() : null;
389
- };
390
-
391
464
  // Extract basic fields
392
- const currentPhase = extractField('Current Phase');
393
- const currentPhaseName = extractField('Current Phase Name');
394
- const totalPhasesRaw = extractField('Total Phases');
395
- const currentPlan = extractField('Current Plan');
396
- const totalPlansRaw = extractField('Total Plans in Phase');
397
- const status = extractField('Status');
398
- const progressRaw = extractField('Progress');
399
- const lastActivity = extractField('Last Activity');
400
- const lastActivityDesc = extractField('Last Activity Description');
401
- const pausedAt = extractField('Paused At');
465
+ const currentPhase = stateExtractField(content, 'Current Phase');
466
+ const currentPhaseName = stateExtractField(content, 'Current Phase Name');
467
+ const totalPhasesRaw = stateExtractField(content, 'Total Phases');
468
+ const currentPlan = stateExtractField(content, 'Current Plan');
469
+ const totalPlansRaw = stateExtractField(content, 'Total Plans in Phase');
470
+ const status = stateExtractField(content, 'Status');
471
+ const progressRaw = stateExtractField(content, 'Progress');
472
+ const lastActivity = stateExtractField(content, 'Last Activity');
473
+ const lastActivityDesc = stateExtractField(content, 'Last Activity Description');
474
+ const pausedAt = stateExtractField(content, 'Paused At');
402
475
 
403
476
  // Parse numeric fields
404
477
  const totalPhases = totalPhasesRaw ? parseInt(totalPhasesRaw, 10) : null;
@@ -444,9 +517,12 @@ function cmdStateSnapshot(cwd, raw) {
444
517
  const sessionMatch = content.match(/##\s*Session\s*\n([\s\S]*?)(?=\n##|$)/i);
445
518
  if (sessionMatch) {
446
519
  const sessionSection = sessionMatch[1];
447
- const lastDateMatch = sessionSection.match(/\*\*Last Date:\*\*\s*(.+)/i);
448
- const stoppedAtMatch = sessionSection.match(/\*\*Stopped At:\*\*\s*(.+)/i);
449
- const resumeFileMatch = sessionSection.match(/\*\*Resume File:\*\*\s*(.+)/i);
520
+ const lastDateMatch = sessionSection.match(/\*\*Last Date:\*\*\s*(.+)/i)
521
+ || sessionSection.match(/^Last Date:\s*(.+)/im);
522
+ const stoppedAtMatch = sessionSection.match(/\*\*Stopped At:\*\*\s*(.+)/i)
523
+ || sessionSection.match(/^Stopped At:\s*(.+)/im);
524
+ const resumeFileMatch = sessionSection.match(/\*\*Resume File:\*\*\s*(.+)/i)
525
+ || sessionSection.match(/^Resume File:\s*(.+)/im);
450
526
 
451
527
  if (lastDateMatch) session.last_date = lastDateMatch[1].trim();
452
528
  if (stoppedAtMatch) session.stopped_at = stoppedAtMatch[1].trim();
@@ -472,9 +548,163 @@ function cmdStateSnapshot(cwd, raw) {
472
548
  output(result, raw);
473
549
  }
474
550
 
551
+ // ─── State Frontmatter Sync ──────────────────────────────────────────────────
552
+
553
+ /**
554
+ * Extract machine-readable fields from STATE.md markdown body and build
555
+ * a YAML frontmatter object. Allows hooks and scripts to read state
556
+ * reliably via `state json` instead of fragile regex parsing.
557
+ */
558
+ function buildStateFrontmatter(bodyContent, cwd) {
559
+ const currentPhase = stateExtractField(bodyContent, 'Current Phase');
560
+ const currentPhaseName = stateExtractField(bodyContent, 'Current Phase Name');
561
+ const currentPlan = stateExtractField(bodyContent, 'Current Plan');
562
+ const totalPhasesRaw = stateExtractField(bodyContent, 'Total Phases');
563
+ const totalPlansRaw = stateExtractField(bodyContent, 'Total Plans in Phase');
564
+ const status = stateExtractField(bodyContent, 'Status');
565
+ const progressRaw = stateExtractField(bodyContent, 'Progress');
566
+ const lastActivity = stateExtractField(bodyContent, 'Last Activity');
567
+ const stoppedAt = stateExtractField(bodyContent, 'Stopped At') || stateExtractField(bodyContent, 'Stopped at');
568
+ const pausedAt = stateExtractField(bodyContent, 'Paused At');
569
+
570
+ let milestone = null;
571
+ let milestoneName = null;
572
+ if (cwd) {
573
+ try {
574
+ const info = getMilestoneInfo(cwd);
575
+ milestone = info.version;
576
+ milestoneName = info.name;
577
+ } catch {}
578
+ }
579
+
580
+ let totalPhases = totalPhasesRaw ? parseInt(totalPhasesRaw, 10) : null;
581
+ let completedPhases = null;
582
+ let totalPlans = totalPlansRaw ? parseInt(totalPlansRaw, 10) : null;
583
+ let completedPlans = null;
584
+
585
+ if (cwd) {
586
+ try {
587
+ const phasesDir = path.join(cwd, '.planning', 'phases');
588
+ if (fs.existsSync(phasesDir)) {
589
+ const isDirInMilestone = getMilestonePhaseFilter(cwd);
590
+ const phaseDirs = fs.readdirSync(phasesDir, { withFileTypes: true })
591
+ .filter(e => e.isDirectory()).map(e => e.name)
592
+ .filter(isDirInMilestone);
593
+ let diskTotalPlans = 0;
594
+ let diskTotalSummaries = 0;
595
+ let diskCompletedPhases = 0;
596
+
597
+ for (const dir of phaseDirs) {
598
+ const files = fs.readdirSync(path.join(phasesDir, dir));
599
+ const plans = files.filter(f => f.match(/-PLAN\.md$/i)).length;
600
+ const summaries = files.filter(f => f.match(/-SUMMARY\.md$/i)).length;
601
+ diskTotalPlans += plans;
602
+ diskTotalSummaries += summaries;
603
+ if (plans > 0 && summaries >= plans) diskCompletedPhases++;
604
+ }
605
+ totalPhases = isDirInMilestone.phaseCount > 0
606
+ ? Math.max(phaseDirs.length, isDirInMilestone.phaseCount)
607
+ : phaseDirs.length;
608
+ completedPhases = diskCompletedPhases;
609
+ totalPlans = diskTotalPlans;
610
+ completedPlans = diskTotalSummaries;
611
+ }
612
+ } catch {}
613
+ }
614
+
615
+ let progressPercent = null;
616
+ if (progressRaw) {
617
+ const pctMatch = progressRaw.match(/(\d+)%/);
618
+ if (pctMatch) progressPercent = parseInt(pctMatch[1], 10);
619
+ }
620
+
621
+ // Normalize status to one of: planning, discussing, executing, verifying, paused, completed, unknown
622
+ let normalizedStatus = status || 'unknown';
623
+ const statusLower = (status || '').toLowerCase();
624
+ if (statusLower.includes('paused') || statusLower.includes('stopped') || pausedAt) {
625
+ normalizedStatus = 'paused';
626
+ } else if (statusLower.includes('executing') || statusLower.includes('in progress')) {
627
+ normalizedStatus = 'executing';
628
+ } else if (statusLower.includes('planning') || statusLower.includes('ready to plan')) {
629
+ normalizedStatus = 'planning';
630
+ } else if (statusLower.includes('discussing')) {
631
+ normalizedStatus = 'discussing';
632
+ } else if (statusLower.includes('verif')) {
633
+ normalizedStatus = 'verifying';
634
+ } else if (statusLower.includes('complete') || statusLower.includes('done')) {
635
+ normalizedStatus = 'completed';
636
+ } else if (statusLower.includes('ready to execute')) {
637
+ normalizedStatus = 'executing';
638
+ }
639
+
640
+ const fm = { gsd_state_version: '1.0' };
641
+
642
+ if (milestone) fm.milestone = milestone;
643
+ if (milestoneName) fm.milestone_name = milestoneName;
644
+ if (currentPhase) fm.current_phase = currentPhase;
645
+ if (currentPhaseName) fm.current_phase_name = currentPhaseName;
646
+ if (currentPlan) fm.current_plan = currentPlan;
647
+ fm.status = normalizedStatus;
648
+ if (stoppedAt) fm.stopped_at = stoppedAt;
649
+ if (pausedAt) fm.paused_at = pausedAt;
650
+ fm.last_updated = new Date().toISOString();
651
+ if (lastActivity) fm.last_activity = lastActivity;
652
+
653
+ const progress = {};
654
+ if (totalPhases !== null) progress.total_phases = totalPhases;
655
+ if (completedPhases !== null) progress.completed_phases = completedPhases;
656
+ if (totalPlans !== null) progress.total_plans = totalPlans;
657
+ if (completedPlans !== null) progress.completed_plans = completedPlans;
658
+ if (progressPercent !== null) progress.percent = progressPercent;
659
+ if (Object.keys(progress).length > 0) fm.progress = progress;
660
+
661
+ return fm;
662
+ }
663
+
664
+ function stripFrontmatter(content) {
665
+ return content.replace(/^---\n[\s\S]*?\n---\n*/, '');
666
+ }
667
+
668
+ function syncStateFrontmatter(content, cwd) {
669
+ const body = stripFrontmatter(content);
670
+ const fm = buildStateFrontmatter(body, cwd);
671
+ const yamlStr = reconstructFrontmatter(fm);
672
+ return `---\n${yamlStr}\n---\n\n${body}`;
673
+ }
674
+
675
+ /**
676
+ * write STATE.md with synchronized YAML frontmatter.
677
+ * All STATE.md writes should use this instead of raw writeFileSync.
678
+ */
679
+ function writeStateMd(statePath, content, cwd) {
680
+ const synced = syncStateFrontmatter(content, cwd);
681
+ fs.writeFileSync(statePath, synced, 'utf-8');
682
+ }
683
+
684
+ function cmdStateJson(cwd, raw) {
685
+ const statePath = path.join(cwd, '.planning', 'STATE.md');
686
+ if (!fs.existsSync(statePath)) {
687
+ output({ error: 'STATE.md not found' }, raw, 'STATE.md not found');
688
+ return;
689
+ }
690
+
691
+ const content = fs.readFileSync(statePath, 'utf-8');
692
+ const fm = extractFrontmatter(content);
693
+
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));
698
+ return;
699
+ }
700
+
701
+ output(fm, raw, JSON.stringify(fm, null, 2));
702
+ }
703
+
475
704
  module.exports = {
476
705
  stateExtractField,
477
706
  stateReplaceField,
707
+ writeStateMd,
478
708
  cmdStateLoad,
479
709
  cmdStateGet,
480
710
  cmdStatePatch,
@@ -487,4 +717,5 @@ module.exports = {
487
717
  cmdStateResolveBlocker,
488
718
  cmdStateRecordSession,
489
719
  cmdStateSnapshot,
720
+ cmdStateJson,
490
721
  };
@@ -4,7 +4,7 @@
4
4
 
5
5
  const fs = require('fs');
6
6
  const path = require('path');
7
- const { normalizePhaseName, findPhaseInternal, generateSlugInternal, output, error } = require('./core.cjs');
7
+ const { normalizePhaseName, findPhaseInternal, generateSlugInternal, toPosixPath, output, error } = require('./core.cjs');
8
8
  const { reconstructFrontmatter } = require('./frontmatter.cjs');
9
9
 
10
10
  function cmdTemplateSelect(cwd, planPath, raw) {
@@ -210,12 +210,12 @@ function cmdTemplateFill(cwd, templateType, options, raw) {
210
210
  const outPath = path.join(cwd, phaseInfo.directory, fileName);
211
211
 
212
212
  if (fs.existsSync(outPath)) {
213
- output({ error: 'File already exists', path: path.relative(cwd, outPath) }, raw);
213
+ output({ error: 'File already exists', path: toPosixPath(path.relative(cwd, outPath)) }, raw);
214
214
  return;
215
215
  }
216
216
 
217
217
  fs.writeFileSync(outPath, fullContent, 'utf-8');
218
- const relPath = path.relative(cwd, outPath);
218
+ const relPath = toPosixPath(path.relative(cwd, outPath));
219
219
  output({ created: true, path: relPath, template: templateType }, raw, relPath);
220
220
  }
221
221