pan-wizard 2.8.1

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 (164) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +772 -0
  3. package/agents/pan-debugger.md +1246 -0
  4. package/agents/pan-document_code.md +965 -0
  5. package/agents/pan-executor.md +469 -0
  6. package/agents/pan-integration-checker.md +443 -0
  7. package/agents/pan-phase-researcher.md +572 -0
  8. package/agents/pan-plan-checker.md +763 -0
  9. package/agents/pan-planner.md +1297 -0
  10. package/agents/pan-project-researcher.md +647 -0
  11. package/agents/pan-research-synthesizer.md +239 -0
  12. package/agents/pan-reviewer.md +112 -0
  13. package/agents/pan-roadmapper.md +642 -0
  14. package/agents/pan-verifier.md +672 -0
  15. package/assets/pan-logo-2000-transparent.svg +30 -0
  16. package/assets/pan-logo-2000.svg +43 -0
  17. package/assets/terminal.svg +119 -0
  18. package/bin/install-lib.cjs +616 -0
  19. package/bin/install.js +1936 -0
  20. package/commands/pan/add-phase.md +44 -0
  21. package/commands/pan/assumptions.md +47 -0
  22. package/commands/pan/audit-deployment.md +378 -0
  23. package/commands/pan/debug.md +168 -0
  24. package/commands/pan/discord.md +19 -0
  25. package/commands/pan/discuss-phase.md +84 -0
  26. package/commands/pan/exec-phase.md +45 -0
  27. package/commands/pan/focus-auto.md +323 -0
  28. package/commands/pan/focus-design.md +816 -0
  29. package/commands/pan/focus-exec.md +316 -0
  30. package/commands/pan/focus-plan.md +101 -0
  31. package/commands/pan/focus-scan.md +272 -0
  32. package/commands/pan/focus-sync.md +104 -0
  33. package/commands/pan/health.md +23 -0
  34. package/commands/pan/help.md +23 -0
  35. package/commands/pan/insert-phase.md +33 -0
  36. package/commands/pan/map-codebase.md +72 -0
  37. package/commands/pan/milestone-audit.md +37 -0
  38. package/commands/pan/milestone-cleanup.md +19 -0
  39. package/commands/pan/milestone-done.md +137 -0
  40. package/commands/pan/milestone-gaps.md +35 -0
  41. package/commands/pan/milestone-new.md +45 -0
  42. package/commands/pan/new-project.md +43 -0
  43. package/commands/pan/patches.md +110 -0
  44. package/commands/pan/pause.md +39 -0
  45. package/commands/pan/phase-budget.md +23 -0
  46. package/commands/pan/phase-tests.md +42 -0
  47. package/commands/pan/plan-phase.md +46 -0
  48. package/commands/pan/profile.md +36 -0
  49. package/commands/pan/progress.md +25 -0
  50. package/commands/pan/quick.md +42 -0
  51. package/commands/pan/remove-phase.md +32 -0
  52. package/commands/pan/research-phase.md +190 -0
  53. package/commands/pan/resume.md +41 -0
  54. package/commands/pan/retro.md +33 -0
  55. package/commands/pan/settings.md +37 -0
  56. package/commands/pan/todo-add.md +48 -0
  57. package/commands/pan/todo-check.md +46 -0
  58. package/commands/pan/update.md +38 -0
  59. package/commands/pan/verify-phase.md +39 -0
  60. package/hooks/dist/pan-check-update.js +62 -0
  61. package/hooks/dist/pan-context-monitor.js +122 -0
  62. package/hooks/dist/pan-statusline.js +108 -0
  63. package/package.json +66 -0
  64. package/pan-wizard-core/bin/lib/codebase.cjs +746 -0
  65. package/pan-wizard-core/bin/lib/commands.cjs +1435 -0
  66. package/pan-wizard-core/bin/lib/config.cjs +611 -0
  67. package/pan-wizard-core/bin/lib/constants.cjs +696 -0
  68. package/pan-wizard-core/bin/lib/context-budget.cjs +150 -0
  69. package/pan-wizard-core/bin/lib/core.cjs +650 -0
  70. package/pan-wizard-core/bin/lib/focus.cjs +900 -0
  71. package/pan-wizard-core/bin/lib/frontmatter.cjs +442 -0
  72. package/pan-wizard-core/bin/lib/init.cjs +881 -0
  73. package/pan-wizard-core/bin/lib/milestone.cjs +276 -0
  74. package/pan-wizard-core/bin/lib/phase.cjs +1212 -0
  75. package/pan-wizard-core/bin/lib/roadmap.cjs +470 -0
  76. package/pan-wizard-core/bin/lib/state.cjs +1029 -0
  77. package/pan-wizard-core/bin/lib/template.cjs +314 -0
  78. package/pan-wizard-core/bin/lib/utils.cjs +171 -0
  79. package/pan-wizard-core/bin/lib/verify.cjs +1808 -0
  80. package/pan-wizard-core/bin/pan-tools.cjs +773 -0
  81. package/pan-wizard-core/references/checkpoints.md +776 -0
  82. package/pan-wizard-core/references/continuation-format.md +249 -0
  83. package/pan-wizard-core/references/decimal-phase-calculation.md +65 -0
  84. package/pan-wizard-core/references/git-integration.md +248 -0
  85. package/pan-wizard-core/references/git-planning-commit.md +38 -0
  86. package/pan-wizard-core/references/model-profile-resolution.md +34 -0
  87. package/pan-wizard-core/references/model-profiles.md +111 -0
  88. package/pan-wizard-core/references/phase-argument-parsing.md +61 -0
  89. package/pan-wizard-core/references/planning-config.md +196 -0
  90. package/pan-wizard-core/references/questioning.md +145 -0
  91. package/pan-wizard-core/references/tdd.md +263 -0
  92. package/pan-wizard-core/references/ui-brand.md +160 -0
  93. package/pan-wizard-core/references/verification-patterns.md +612 -0
  94. package/pan-wizard-core/templates/codebase/architecture.md +283 -0
  95. package/pan-wizard-core/templates/codebase/best-practices.md +133 -0
  96. package/pan-wizard-core/templates/codebase/concerns.md +325 -0
  97. package/pan-wizard-core/templates/codebase/conventions.md +307 -0
  98. package/pan-wizard-core/templates/codebase/integrations.md +305 -0
  99. package/pan-wizard-core/templates/codebase/relationships.md +124 -0
  100. package/pan-wizard-core/templates/codebase/stack.md +199 -0
  101. package/pan-wizard-core/templates/codebase/structure.md +298 -0
  102. package/pan-wizard-core/templates/codebase/testing.md +480 -0
  103. package/pan-wizard-core/templates/config.json +37 -0
  104. package/pan-wizard-core/templates/context.md +283 -0
  105. package/pan-wizard-core/templates/continue-here.md +78 -0
  106. package/pan-wizard-core/templates/debug-subagent-prompt.md +91 -0
  107. package/pan-wizard-core/templates/debug.md +164 -0
  108. package/pan-wizard-core/templates/discovery.md +146 -0
  109. package/pan-wizard-core/templates/milestone-archive.md +123 -0
  110. package/pan-wizard-core/templates/milestone.md +115 -0
  111. package/pan-wizard-core/templates/phase-prompt.md +593 -0
  112. package/pan-wizard-core/templates/planner-subagent-prompt.md +117 -0
  113. package/pan-wizard-core/templates/project.md +184 -0
  114. package/pan-wizard-core/templates/requirements.md +231 -0
  115. package/pan-wizard-core/templates/research-project/architecture.md +204 -0
  116. package/pan-wizard-core/templates/research-project/features.md +147 -0
  117. package/pan-wizard-core/templates/research-project/pitfalls.md +200 -0
  118. package/pan-wizard-core/templates/research-project/stack.md +120 -0
  119. package/pan-wizard-core/templates/research-project/summary.md +170 -0
  120. package/pan-wizard-core/templates/research.md +552 -0
  121. package/pan-wizard-core/templates/retrospective.md +54 -0
  122. package/pan-wizard-core/templates/roadmap.md +202 -0
  123. package/pan-wizard-core/templates/standards.md +24 -0
  124. package/pan-wizard-core/templates/state.md +176 -0
  125. package/pan-wizard-core/templates/summary-complex.md +59 -0
  126. package/pan-wizard-core/templates/summary-minimal.md +41 -0
  127. package/pan-wizard-core/templates/summary-standard.md +49 -0
  128. package/pan-wizard-core/templates/summary.md +249 -0
  129. package/pan-wizard-core/templates/uat.md +247 -0
  130. package/pan-wizard-core/templates/user-setup.md +311 -0
  131. package/pan-wizard-core/templates/validation.md +76 -0
  132. package/pan-wizard-core/templates/verification-report.md +322 -0
  133. package/pan-wizard-core/workflows/add-phase.md +111 -0
  134. package/pan-wizard-core/workflows/assumptions.md +178 -0
  135. package/pan-wizard-core/workflows/diagnose-issues.md +219 -0
  136. package/pan-wizard-core/workflows/discuss-phase.md +542 -0
  137. package/pan-wizard-core/workflows/exec-phase.md +572 -0
  138. package/pan-wizard-core/workflows/execute-plan.md +448 -0
  139. package/pan-wizard-core/workflows/health.md +156 -0
  140. package/pan-wizard-core/workflows/help.md +431 -0
  141. package/pan-wizard-core/workflows/insert-phase.md +129 -0
  142. package/pan-wizard-core/workflows/map-codebase.md +401 -0
  143. package/pan-wizard-core/workflows/milestone-audit.md +297 -0
  144. package/pan-wizard-core/workflows/milestone-cleanup.md +152 -0
  145. package/pan-wizard-core/workflows/milestone-gaps.md +274 -0
  146. package/pan-wizard-core/workflows/milestone-new.md +382 -0
  147. package/pan-wizard-core/workflows/new-project.md +1178 -0
  148. package/pan-wizard-core/workflows/pause.md +122 -0
  149. package/pan-wizard-core/workflows/phase-tests.md +388 -0
  150. package/pan-wizard-core/workflows/plan-phase.md +569 -0
  151. package/pan-wizard-core/workflows/profile.md +115 -0
  152. package/pan-wizard-core/workflows/progress.md +381 -0
  153. package/pan-wizard-core/workflows/quick.md +453 -0
  154. package/pan-wizard-core/workflows/remove-phase.md +154 -0
  155. package/pan-wizard-core/workflows/research-phase.md +73 -0
  156. package/pan-wizard-core/workflows/resume-project.md +306 -0
  157. package/pan-wizard-core/workflows/retro.md +121 -0
  158. package/pan-wizard-core/workflows/settings.md +213 -0
  159. package/pan-wizard-core/workflows/todo-add.md +157 -0
  160. package/pan-wizard-core/workflows/todo-check.md +176 -0
  161. package/pan-wizard-core/workflows/transition.md +544 -0
  162. package/pan-wizard-core/workflows/update.md +219 -0
  163. package/pan-wizard-core/workflows/verify-phase.md +301 -0
  164. package/scripts/build-hooks.js +43 -0
@@ -0,0 +1,1029 @@
1
+ /**
2
+ * State -- state.md operations and progression engine
3
+ */
4
+
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const { loadConfig, getMilestoneInfo, escapeRegex, safeReadFile, output, error } = require('./core.cjs');
8
+ const { extractFrontmatter, reconstructFrontmatter } = require('./frontmatter.cjs');
9
+ const {
10
+ PLANNING_DIR,
11
+ STATE_FILE,
12
+ ROADMAP_FILE,
13
+ CONFIG_FILE,
14
+ PHASES_DIR,
15
+ isPlanFile,
16
+ isSummaryFile,
17
+ getPlanId,
18
+ getSummaryId,
19
+ FIELD_VALUE_RE,
20
+ PROGRESS_BAR_WIDTH,
21
+ FILLED_BLOCK,
22
+ EMPTY_BLOCK,
23
+ } = require('./constants.cjs');
24
+ const {
25
+ planningPath,
26
+ phasesPath,
27
+ filterPlanFiles,
28
+ filterSummaryFiles,
29
+ fileAccessible,
30
+ } = require('./utils.cjs');
31
+
32
+ /**
33
+ * Load project state including config, state.md content, and file existence flags.
34
+ * @param {string} cwd - Working directory path
35
+ * @param {boolean} raw - If true, output condensed key=value format
36
+ * @returns {void}
37
+ */
38
+ function cmdStateLoad(cwd, raw) {
39
+ const config = loadConfig(cwd);
40
+ const planningDir = planningPath(cwd);
41
+
42
+ let stateRaw = '';
43
+ try {
44
+ stateRaw = fs.readFileSync(path.join(planningDir, STATE_FILE), 'utf-8');
45
+ } catch {
46
+ // state.md may not exist yet in a fresh project -- fall through with empty string
47
+ }
48
+
49
+ const configExists = fileAccessible(path.join(planningDir, CONFIG_FILE));
50
+ const roadmapExists = fileAccessible(path.join(planningDir, ROADMAP_FILE));
51
+ const stateExists = stateRaw.length > 0;
52
+
53
+ const result = {
54
+ config,
55
+ state_raw: stateRaw,
56
+ state_exists: stateExists,
57
+ roadmap_exists: roadmapExists,
58
+ config_exists: configExists,
59
+ };
60
+
61
+ // For --raw, output a condensed key=value format
62
+ if (raw) {
63
+ const lines = [
64
+ `model_profile=${config.model_profile}`,
65
+ `commit_docs=${config.commit_docs}`,
66
+ `branching_strategy=${config.branching_strategy}`,
67
+ `phase_branch_template=${config.phase_branch_template}`,
68
+ `milestone_branch_template=${config.milestone_branch_template}`,
69
+ `parallelization=${config.parallelization}`,
70
+ `research=${config.research}`,
71
+ `plan_checker=${config.plan_checker}`,
72
+ `verifier=${config.verifier}`,
73
+ `config_exists=${configExists}`,
74
+ `roadmap_exists=${roadmapExists}`,
75
+ `state_exists=${stateExists}`,
76
+ ];
77
+ process.stdout.write(lines.join('\n'));
78
+ process.exit(0);
79
+ }
80
+
81
+ output(result);
82
+ }
83
+
84
+ /**
85
+ * Get a specific field or section from state.md.
86
+ * @param {string} cwd - Working directory path
87
+ * @param {string} section - Field name or section heading to retrieve
88
+ * @param {boolean} raw - If true, output raw value instead of JSON
89
+ * @returns {void}
90
+ */
91
+ function cmdStateGet(cwd, section, raw) {
92
+ const statePath = path.join(planningPath(cwd), STATE_FILE);
93
+ try {
94
+ const content = fs.readFileSync(statePath, 'utf-8');
95
+
96
+ if (!section) {
97
+ output({ content }, raw, content);
98
+ return;
99
+ }
100
+
101
+ // Try to find markdown section or field
102
+ const fieldEscaped = escapeRegex(section);
103
+
104
+ // Check for **field:** value
105
+ const fieldPattern = new RegExp(`\\*\\*${fieldEscaped}:\\*\\*\\s*(.*)`, 'i');
106
+ const fieldMatch = content.match(fieldPattern);
107
+ if (fieldMatch) {
108
+ output({ [section]: fieldMatch[1].trim() }, raw, fieldMatch[1].trim());
109
+ return;
110
+ }
111
+
112
+ // Check for ## Section
113
+ const sectionPattern = new RegExp(`##\\s*${fieldEscaped}\\s*\n([\\s\\S]*?)(?=\\n##|$)`, 'i');
114
+ const sectionMatch = content.match(sectionPattern);
115
+ if (sectionMatch) {
116
+ output({ [section]: sectionMatch[1].trim() }, raw, sectionMatch[1].trim());
117
+ return;
118
+ }
119
+
120
+ output({ error: `Section or field "${section}" not found` }, raw, '');
121
+ } catch {
122
+ // state.md does not exist or is unreadable -- report as JSON (consistent with other state commands)
123
+ output({ error: 'state.md not found' }, raw);
124
+ }
125
+ }
126
+
127
+ function readTextArgOrFile(cwd, value, filePath, label) {
128
+ if (!filePath) return value;
129
+
130
+ const resolvedPath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath);
131
+ try {
132
+ return fs.readFileSync(resolvedPath, 'utf-8').trimEnd();
133
+ } catch {
134
+ // File specified by caller does not exist -- throw descriptive error
135
+ throw new Error(`${label} file not found: ${filePath}`);
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Batch-update multiple bold-field values in state.md.
141
+ * @param {string} cwd - Working directory path
142
+ * @param {Object.<string, string>} patches - Map of field names to new values
143
+ * @param {boolean} raw - If true, output raw value instead of JSON
144
+ * @returns {void}
145
+ */
146
+ function cmdStatePatch(cwd, patches, raw) {
147
+ const statePath = path.join(planningPath(cwd), STATE_FILE);
148
+ try {
149
+ let content = fs.readFileSync(statePath, 'utf-8');
150
+ const results = { updated: [], failed: [] };
151
+
152
+ for (const [field, value] of Object.entries(patches)) {
153
+ const fieldEscaped = escapeRegex(field);
154
+ const pattern = new RegExp(`(\\*\\*${fieldEscaped}:\\*\\*\\s*)(.*)`, 'i');
155
+
156
+ if (pattern.test(content)) {
157
+ content = content.replace(pattern, (_match, prefix) => `${prefix}${value}`);
158
+ results.updated.push(field);
159
+ } else {
160
+ results.failed.push(field);
161
+ }
162
+ }
163
+
164
+ if (results.updated.length > 0) {
165
+ writeStateMd(statePath, content, cwd);
166
+ }
167
+
168
+ output(results, raw, results.updated.length > 0 ? 'true' : 'false');
169
+ } catch {
170
+ // state.md does not exist or is unreadable -- report as missing
171
+ error('state.md not found');
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Update a single bold-field value in state.md.
177
+ * @param {string} cwd - Working directory path
178
+ * @param {string} field - Field name to update
179
+ * @param {string} value - New value to set
180
+ * @returns {void}
181
+ */
182
+ function cmdStateUpdate(cwd, field, value) {
183
+ if (!field || value === undefined) {
184
+ error('field and value required for state update');
185
+ }
186
+
187
+ const statePath = path.join(planningPath(cwd), STATE_FILE);
188
+ try {
189
+ let content = fs.readFileSync(statePath, 'utf-8');
190
+ const fieldEscaped = escapeRegex(field);
191
+ const pattern = new RegExp(`(\\*\\*${fieldEscaped}:\\*\\*\\s*)(.*)`, 'i');
192
+ if (pattern.test(content)) {
193
+ content = content.replace(pattern, (_match, prefix) => `${prefix}${value}`);
194
+ writeStateMd(statePath, content, cwd);
195
+ output({ updated: true });
196
+ } else {
197
+ output({ updated: false, reason: `Field "${field}" not found in state.md` });
198
+ }
199
+ } catch {
200
+ // state.md does not exist or is unreadable -- report gracefully
201
+ output({ updated: false, reason: 'state.md not found' });
202
+ }
203
+ }
204
+
205
+ // --- State Progression Engine ------------------------------------------------
206
+
207
+ /**
208
+ * Extract a bold-field value from state.md markdown content.
209
+ * @param {string} content - state.md file content
210
+ * @param {string} fieldName - Name of the **Field:** to extract
211
+ * @returns {string|null} Trimmed field value or null if not found
212
+ */
213
+ function stateExtractField(content, fieldName) {
214
+ const pattern = new RegExp(`\\*\\*${fieldName}:\\*\\*\\s*(.+)`, 'i');
215
+ const match = content.match(pattern);
216
+ return match ? match[1].trim() : null;
217
+ }
218
+
219
+ /**
220
+ * Replace a bold-field value in state.md markdown content.
221
+ * @param {string} content - state.md file content
222
+ * @param {string} fieldName - Name of the **Field:** to replace
223
+ * @param {string} newValue - New value to set
224
+ * @returns {string|null} Updated content or null if field not found
225
+ */
226
+ function stateReplaceField(content, fieldName, newValue) {
227
+ const escaped = escapeRegex(fieldName);
228
+ const pattern = new RegExp(`(\\*\\*${escaped}:\\*\\*\\s*)(.*)`, 'i');
229
+ if (pattern.test(content)) {
230
+ return content.replace(pattern, (_match, prefix) => `${prefix}${newValue}`);
231
+ }
232
+ return null;
233
+ }
234
+
235
+ /**
236
+ * Advance Current Plan counter in state.md or mark phase complete if at last plan.
237
+ * @param {string} cwd - Working directory path
238
+ * @param {boolean} raw - If true, output raw value instead of JSON
239
+ * @returns {void}
240
+ */
241
+ function cmdStateAdvancePlan(cwd, raw) {
242
+ const statePath = path.join(planningPath(cwd), STATE_FILE);
243
+ let content = safeReadFile(statePath);
244
+ if (content === null) { output({ error: 'state.md not found' }, raw); return; }
245
+ const currentPlan = parseInt(stateExtractField(content, 'Current Plan'), 10);
246
+ const totalPlans = parseInt(stateExtractField(content, 'Total Plans in Phase'), 10);
247
+ const today = new Date().toISOString().split('T')[0];
248
+
249
+ if (isNaN(currentPlan) || isNaN(totalPlans)) {
250
+ output({ error: 'Cannot parse Current Plan or Total Plans in Phase from state.md' }, raw);
251
+ return;
252
+ }
253
+
254
+ if (currentPlan >= totalPlans) {
255
+ content = stateReplaceField(content, 'Status', 'Phase complete — ready for verification') || content;
256
+ content = stateReplaceField(content, 'Last Activity', today) || content;
257
+ writeStateMd(statePath, content, cwd);
258
+ output({ advanced: false, reason: 'last_plan', current_plan: currentPlan, total_plans: totalPlans, status: 'ready_for_verification' }, raw, 'false');
259
+ } else {
260
+ const newPlan = currentPlan + 1;
261
+ content = stateReplaceField(content, 'Current Plan', String(newPlan)) || content;
262
+ content = stateReplaceField(content, 'Status', 'Ready to execute') || content;
263
+ content = stateReplaceField(content, 'Last Activity', today) || content;
264
+ writeStateMd(statePath, content, cwd);
265
+ output({ advanced: true, previous_plan: currentPlan, current_plan: newPlan, total_plans: totalPlans }, raw, 'true');
266
+ }
267
+ }
268
+
269
+ /**
270
+ * Append a performance metric row to the Performance Metrics table in state.md.
271
+ * @param {string} cwd - Working directory path
272
+ * @param {Object} options - Metric data (phase, plan, duration, tasks, files)
273
+ * @param {boolean} raw - If true, output raw value instead of JSON
274
+ * @returns {void}
275
+ */
276
+ function cmdStateRecordMetric(cwd, options, raw) {
277
+ const statePath = path.join(planningPath(cwd), STATE_FILE);
278
+ let content = safeReadFile(statePath);
279
+ if (content === null) { output({ error: 'state.md not found' }, raw); return; }
280
+ const { phase, plan, duration, tasks, files } = options;
281
+
282
+ if (!phase || !plan || !duration) {
283
+ output({ error: 'phase, plan, and duration required' }, raw);
284
+ return;
285
+ }
286
+
287
+ // Find Performance Metrics section and its table
288
+ const metricsPattern = /(##\s*Performance Metrics[\s\S]*?\n\|[^\n]+\n\|[-|\s]+\n)([\s\S]*?)(?=\n##|\n$|$)/i;
289
+ const metricsMatch = content.match(metricsPattern);
290
+
291
+ if (metricsMatch) {
292
+ let tableBody = metricsMatch[2].trimEnd();
293
+ const newRow = `| Phase ${phase} P${plan} | ${duration} | ${tasks || '-'} tasks | ${files || '-'} files |`;
294
+
295
+ if (tableBody.trim() === '' || tableBody.includes('None yet')) {
296
+ tableBody = newRow;
297
+ } else {
298
+ tableBody = tableBody + '\n' + newRow;
299
+ }
300
+
301
+ content = content.replace(metricsPattern, (_match, header) => `${header}${tableBody}\n`);
302
+ writeStateMd(statePath, content, cwd);
303
+ output({ recorded: true, phase, plan, duration }, raw, 'true');
304
+ } else {
305
+ output({ recorded: false, reason: 'Performance Metrics section not found in state.md' }, raw, 'false');
306
+ }
307
+ }
308
+
309
+ /**
310
+ * Recalculate and update the progress bar in state.md from SUMMARY counts on disk.
311
+ * @param {string} cwd - Working directory path
312
+ * @param {boolean} raw - If true, output raw value instead of JSON
313
+ * @returns {void}
314
+ */
315
+ function cmdStateUpdateProgress(cwd, raw) {
316
+ const statePath = path.join(planningPath(cwd), STATE_FILE);
317
+ let content = safeReadFile(statePath);
318
+ if (content === null) { output({ error: 'state.md not found' }, raw); return; }
319
+
320
+ // Count summaries across all phases by scanning disk
321
+ const phasesDir = phasesPath(cwd);
322
+ let totalPlans = 0;
323
+ let totalSummaries = 0;
324
+
325
+ try {
326
+ const phaseDirs = fs.readdirSync(phasesDir, { withFileTypes: true })
327
+ .filter(entry => entry.isDirectory()).map(entry => entry.name);
328
+ for (const dir of phaseDirs) {
329
+ const files = fs.readdirSync(path.join(phasesDir, dir));
330
+ totalPlans += files.filter(isPlanFile).length;
331
+ totalSummaries += files.filter(isSummaryFile).length;
332
+ }
333
+ } catch {
334
+ // Phases directory does not exist yet — totals remain 0
335
+ }
336
+
337
+ // Render progress bar from completed/total counts
338
+ const progressStr = calculateProgressBar(totalSummaries, totalPlans);
339
+ const percent = totalPlans > 0 ? Math.min(100, Math.round(totalSummaries / totalPlans * 100)) : 0;
340
+
341
+ const progressPattern = /(\*\*Progress:\*\*\s*).*/i;
342
+ if (progressPattern.test(content)) {
343
+ content = content.replace(progressPattern, (_match, prefix) => `${prefix}${progressStr}`);
344
+ writeStateMd(statePath, content, cwd);
345
+ output({ updated: true, percent, completed: totalSummaries, total: totalPlans, bar: progressStr }, raw, progressStr);
346
+ } else {
347
+ output({ updated: false, reason: 'Progress field not found in state.md' }, raw, 'false');
348
+ }
349
+ }
350
+
351
+ /**
352
+ * Add a decision entry to the Decisions section in state.md.
353
+ * @param {string} cwd - Working directory path
354
+ * @param {Object} options - Decision data (phase, summary, summary_file, rationale, rationale_file)
355
+ * @param {boolean} raw - If true, output raw value instead of JSON
356
+ * @returns {void}
357
+ */
358
+ function cmdStateAddDecision(cwd, options, raw) {
359
+ const statePath = path.join(planningPath(cwd), STATE_FILE);
360
+ let content = safeReadFile(statePath);
361
+ if (content === null) { output({ error: 'state.md not found' }, raw); return; }
362
+
363
+ const { phase, summary, summary_file, rationale, rationale_file } = options;
364
+ let summaryText = null;
365
+ let rationaleText = '';
366
+
367
+ try {
368
+ summaryText = readTextArgOrFile(cwd, summary, summary_file, 'summary');
369
+ rationaleText = readTextArgOrFile(cwd, rationale || '', rationale_file, 'rationale');
370
+ } catch (err) {
371
+ output({ added: false, reason: err.message }, raw, 'false');
372
+ return;
373
+ }
374
+
375
+ if (!summaryText) { output({ error: 'summary required' }, raw); return; }
376
+ const entry = `- [Phase ${phase || '?'}]: ${summaryText}${rationaleText ? ` — ${rationaleText}` : ''}`;
377
+
378
+ // Find Decisions section (various heading patterns)
379
+ const sectionPattern = /(###?\s*(?:Decisions|Decisions Made|Accumulated.*Decisions)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
380
+ const match = content.match(sectionPattern);
381
+
382
+ if (match) {
383
+ let sectionBody = match[2];
384
+ // Remove placeholders
385
+ sectionBody = sectionBody.replace(/None yet\.?\s*\n?/gi, '').replace(/No decisions yet\.?\s*\n?/gi, '');
386
+ sectionBody = sectionBody.trimEnd() + '\n' + entry + '\n';
387
+ content = content.replace(sectionPattern, (_match, header) => `${header}${sectionBody}`);
388
+ writeStateMd(statePath, content, cwd);
389
+ output({ added: true, decision: entry }, raw, 'true');
390
+ } else {
391
+ output({ added: false, reason: 'Decisions section not found in state.md' }, raw, 'false');
392
+ }
393
+ }
394
+
395
+ /**
396
+ * Add a blocker entry to the Blockers section in state.md.
397
+ * @param {string} cwd - Working directory path
398
+ * @param {string|Object} text - Blocker text or object with text/text_file properties
399
+ * @param {boolean} raw - If true, output raw value instead of JSON
400
+ * @returns {void}
401
+ */
402
+ function cmdStateAddBlocker(cwd, text, raw) {
403
+ const statePath = path.join(planningPath(cwd), STATE_FILE);
404
+ let content = safeReadFile(statePath);
405
+ if (content === null) { output({ error: 'state.md not found' }, raw); return; }
406
+ const blockerOptions = typeof text === 'object' && text !== null ? text : { text };
407
+ let blockerText = null;
408
+
409
+ try {
410
+ blockerText = readTextArgOrFile(cwd, blockerOptions.text, blockerOptions.text_file, 'blocker');
411
+ } catch (err) {
412
+ output({ added: false, reason: err.message }, raw, 'false');
413
+ return;
414
+ }
415
+
416
+ if (!blockerText) { output({ error: 'text required' }, raw); return; }
417
+ const entry = `- ${blockerText}`;
418
+
419
+ const sectionPattern = /(###?\s*(?:Blockers|Blockers\/Concerns|Concerns)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
420
+ const match = content.match(sectionPattern);
421
+
422
+ if (match) {
423
+ let sectionBody = match[2];
424
+ sectionBody = sectionBody.replace(/None\.?\s*\n?/gi, '').replace(/None yet\.?\s*\n?/gi, '');
425
+ sectionBody = sectionBody.trimEnd() + '\n' + entry + '\n';
426
+ content = content.replace(sectionPattern, (_match, header) => `${header}${sectionBody}`);
427
+ writeStateMd(statePath, content, cwd);
428
+ output({ added: true, blocker: blockerText }, raw, 'true');
429
+ } else {
430
+ output({ added: false, reason: 'Blockers section not found in state.md' }, raw, 'false');
431
+ }
432
+ }
433
+
434
+ /**
435
+ * Remove a matching blocker entry from the Blockers section in state.md.
436
+ * @param {string} cwd - Working directory path
437
+ * @param {string} text - Text to match against existing blockers (case-insensitive)
438
+ * @param {boolean} raw - If true, output raw value instead of JSON
439
+ * @returns {void}
440
+ */
441
+ function cmdStateResolveBlocker(cwd, text, raw) {
442
+ const statePath = path.join(planningPath(cwd), STATE_FILE);
443
+ if (!text) { output({ error: 'text required' }, raw); return; }
444
+ let content = safeReadFile(statePath);
445
+ if (content === null) { output({ error: 'state.md not found' }, raw); return; }
446
+
447
+ const sectionPattern = /(###?\s*(?:Blockers|Blockers\/Concerns|Concerns)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
448
+ const match = content.match(sectionPattern);
449
+
450
+ if (match) {
451
+ const sectionBody = match[2];
452
+ const lines = sectionBody.split('\n');
453
+ const filtered = lines.filter(line => {
454
+ if (!line.startsWith('- ')) return true;
455
+ return !line.toLowerCase().includes(text.toLowerCase());
456
+ });
457
+
458
+ let newBody = filtered.join('\n');
459
+ // If section is now empty, add placeholder
460
+ if (!newBody.trim() || !newBody.includes('- ')) {
461
+ newBody = 'None\n';
462
+ }
463
+
464
+ content = content.replace(sectionPattern, (_match, header) => `${header}${newBody}`);
465
+ writeStateMd(statePath, content, cwd);
466
+ output({ resolved: true, blocker: text }, raw, 'true');
467
+ } else {
468
+ output({ resolved: false, reason: 'Blockers section not found in state.md' }, raw, 'false');
469
+ }
470
+ }
471
+
472
+ /**
473
+ * Update session tracking fields (last date, stopped at, resume file) in state.md.
474
+ * @param {string} cwd - Working directory path
475
+ * @param {Object} options - Session data (stopped_at, resume_file)
476
+ * @param {boolean} raw - If true, output raw value instead of JSON
477
+ * @returns {void}
478
+ */
479
+ function cmdStateRecordSession(cwd, options, raw) {
480
+ const statePath = path.join(planningPath(cwd), STATE_FILE);
481
+ let content = safeReadFile(statePath);
482
+ if (content === null) { output({ error: 'state.md not found' }, raw); return; }
483
+ const now = new Date().toISOString();
484
+ const updated = [];
485
+
486
+ // Update Last session / Last Date
487
+ let result = stateReplaceField(content, 'Last session', now);
488
+ if (result) { content = result; updated.push('Last session'); }
489
+ result = stateReplaceField(content, 'Last Date', now);
490
+ if (result) { content = result; updated.push('Last Date'); }
491
+
492
+ // Update Stopped at
493
+ if (options.stopped_at) {
494
+ result = stateReplaceField(content, 'Stopped At', options.stopped_at);
495
+ if (!result) result = stateReplaceField(content, 'Stopped at', options.stopped_at);
496
+ if (result) { content = result; updated.push('Stopped At'); }
497
+ }
498
+
499
+ // Update Resume file
500
+ const resumeFile = options.resume_file || 'None';
501
+ result = stateReplaceField(content, 'Resume File', resumeFile);
502
+ if (!result) result = stateReplaceField(content, 'Resume file', resumeFile);
503
+ if (result) { content = result; updated.push('Resume File'); }
504
+
505
+ if (updated.length > 0) {
506
+ writeStateMd(statePath, content, cwd);
507
+ output({ recorded: true, updated }, raw, 'true');
508
+ } else {
509
+ output({ recorded: false, reason: 'No session fields found in state.md' }, raw, 'false');
510
+ }
511
+ }
512
+
513
+ // --- Snapshot Parsers --------------------------------------------------------
514
+
515
+ /**
516
+ * Parse the Decisions Made table from state.md content.
517
+ * Extracts rows from a markdown table under the "## Decisions Made" heading,
518
+ * splitting each row into phase, summary, and rationale cells.
519
+ * @param {string} content - Full state.md content
520
+ * @returns {Array<{phase: string, summary: string, rationale: string}>}
521
+ */
522
+ function parseDecisionsFromState(content) {
523
+ const decisions = [];
524
+ // Match the decisions table body after header row and separator row
525
+ const decisionsMatch = content.match(/##\s*Decisions Made[\s\S]*?\n\|[^\n]+\n\|[-|\s]+\n([\s\S]*?)(?=\n##|\n$|$)/i);
526
+ if (decisionsMatch) {
527
+ const tableBody = decisionsMatch[1];
528
+ const rows = tableBody.trim().split('\n').filter(row => row.includes('|'));
529
+ for (const row of rows) {
530
+ const cells = row.split('|').map(cell => cell.trim()).filter(Boolean);
531
+ if (cells.length >= 3) {
532
+ decisions.push({
533
+ phase: cells[0],
534
+ summary: cells[1],
535
+ rationale: cells[2],
536
+ });
537
+ }
538
+ }
539
+ }
540
+ return decisions;
541
+ }
542
+
543
+ /**
544
+ * Parse the Blockers section from state.md content.
545
+ * Extracts bullet-point items (lines starting with "- ") from under
546
+ * the "## Blockers" heading.
547
+ * @param {string} content - Full state.md content
548
+ * @returns {string[]} Array of blocker text strings
549
+ */
550
+ function parseBlockersFromState(content) {
551
+ const blockers = [];
552
+ const blockersMatch = content.match(/##\s*Blockers\s*\n([\s\S]*?)(?=\n##|$)/i);
553
+ if (blockersMatch) {
554
+ const blockersSection = blockersMatch[1];
555
+ const items = blockersSection.match(/^-\s+(.+)$/gm) || [];
556
+ for (const item of items) {
557
+ blockers.push(item.replace(/^-\s+/, '').trim());
558
+ }
559
+ }
560
+ return blockers;
561
+ }
562
+
563
+ /**
564
+ * Parse the Session section from state.md content.
565
+ * Extracts **Last Date:**, **Stopped At:**, and **Resume File:** bold-field
566
+ * values from under the "## Session" heading.
567
+ * @param {string} content - Full state.md content
568
+ * @returns {{last_date: string|null, stopped_at: string|null, resume_file: string|null}}
569
+ */
570
+ function parseSessionFromState(content) {
571
+ const session = {
572
+ last_date: null,
573
+ stopped_at: null,
574
+ resume_file: null,
575
+ };
576
+
577
+ const sessionMatch = content.match(/##\s*Session\s*\n([\s\S]*?)(?=\n##|$)/i);
578
+ if (sessionMatch) {
579
+ const sessionSection = sessionMatch[1];
580
+ const lastDateMatch = sessionSection.match(/\*\*Last Date:\*\*\s*(.+)/i);
581
+ const stoppedAtMatch = sessionSection.match(/\*\*Stopped At:\*\*\s*(.+)/i);
582
+ const resumeFileMatch = sessionSection.match(/\*\*Resume File:\*\*\s*(.+)/i);
583
+
584
+ if (lastDateMatch) session.last_date = lastDateMatch[1].trim();
585
+ if (stoppedAtMatch) session.stopped_at = stoppedAtMatch[1].trim();
586
+ if (resumeFileMatch) session.resume_file = resumeFileMatch[1].trim();
587
+ }
588
+
589
+ return session;
590
+ }
591
+
592
+ /**
593
+ * Extract a structured snapshot of all state.md fields, decisions, blockers, and session info.
594
+ * Orchestrates the individual parsers to build a complete state snapshot object.
595
+ * @param {string} cwd - Working directory path
596
+ * @param {boolean} raw - If true, output raw value instead of JSON
597
+ * @returns {void}
598
+ */
599
+ function cmdStateSnapshot(cwd, raw) {
600
+ const statePath = path.join(planningPath(cwd), STATE_FILE);
601
+ const content = safeReadFile(statePath);
602
+ if (content === null) { output({ error: 'state.md not found' }, raw); return; }
603
+
604
+ // Reuse shared field extraction
605
+ const fields = extractFieldsFromState(content);
606
+
607
+ // Parse numeric fields
608
+ const totalPhases = fields.totalPhasesRaw ? parseInt(fields.totalPhasesRaw, 10) : null;
609
+ const totalPlansInPhase = fields.totalPlansRaw ? parseInt(fields.totalPlansRaw, 10) : null;
610
+ let progressPercent = null;
611
+ if (fields.progressRaw) {
612
+ const pctMatch = fields.progressRaw.match(/(\d+)%/);
613
+ if (pctMatch) progressPercent = parseInt(pctMatch[1], 10);
614
+ }
615
+
616
+ // Delegate to specialized parsers for complex sections
617
+ const decisions = parseDecisionsFromState(content);
618
+ const blockers = parseBlockersFromState(content);
619
+ const session = parseSessionFromState(content);
620
+
621
+ const result = {
622
+ current_phase: fields.currentPhase,
623
+ current_phase_name: fields.currentPhaseName,
624
+ total_phases: totalPhases,
625
+ current_plan: fields.currentPlan,
626
+ total_plans_in_phase: totalPlansInPhase,
627
+ status: fields.status,
628
+ progress_percent: progressPercent,
629
+ last_activity: fields.lastActivity,
630
+ last_activity_desc: fields.lastActivityDesc,
631
+ decisions,
632
+ blockers,
633
+ paused_at: fields.pausedAt,
634
+ session,
635
+ };
636
+
637
+ output(result, raw);
638
+ }
639
+
640
+ // --- State Frontmatter Sync --------------------------------------------------
641
+
642
+ /**
643
+ * Extract key **Field:** value pairs from state.md markdown body content.
644
+ * Parses fields needed for frontmatter: phase, plan, status, progress, etc.
645
+ * @param {string} bodyContent - state.md body (without frontmatter)
646
+ * @returns {Object} Extracted field values keyed by semantic name
647
+ */
648
+ function extractFieldsFromState(bodyContent) {
649
+ const extractField = (fieldName) => {
650
+ const pattern = new RegExp(`\\*\\*${fieldName}:\\*\\*\\s*(.+)`, 'i');
651
+ const match = bodyContent.match(pattern);
652
+ return match ? match[1].trim() : null;
653
+ };
654
+
655
+ return {
656
+ currentPhase: extractField('Current Phase'),
657
+ currentPhaseName: extractField('Current Phase Name'),
658
+ currentPlan: extractField('Current Plan'),
659
+ totalPhasesRaw: extractField('Total Phases'),
660
+ totalPlansRaw: extractField('Total Plans in Phase'),
661
+ status: extractField('Status'),
662
+ progressRaw: extractField('Progress'),
663
+ lastActivity: extractField('Last Activity'),
664
+ lastActivityDesc: extractField('Last Activity Description'),
665
+ stoppedAt: extractField('Stopped At') || extractField('Stopped at'),
666
+ pausedAt: extractField('Paused At'),
667
+ };
668
+ }
669
+
670
+ /**
671
+ * Scan phase directories on disk to count plans and summaries.
672
+ * Reads each subdirectory under .planning/phases/ and tallies plan/summary files
673
+ * to determine per-phase and overall completion counts.
674
+ * @param {string} cwd - Working directory path
675
+ * @returns {{totalPhases: number|null, completedPhases: number|null, totalPlans: number|null, completedPlans: number|null}}
676
+ */
677
+ function scanPhaseProgress(cwd) {
678
+ let totalPhases = null;
679
+ let completedPhases = null;
680
+ let totalPlans = null;
681
+ let completedPlans = null;
682
+
683
+ try {
684
+ const phasesDir = phasesPath(cwd);
685
+ const phaseDirs = fs.readdirSync(phasesDir, { withFileTypes: true })
686
+ .filter(entry => entry.isDirectory()).map(entry => entry.name);
687
+ let diskTotalPlans = 0;
688
+ let diskTotalSummaries = 0;
689
+ let diskCompletedPhases = 0;
690
+
691
+ // Walk each phase directory and count plan vs summary files
692
+ for (const dir of phaseDirs) {
693
+ const files = fs.readdirSync(path.join(phasesDir, dir));
694
+ const plans = files.filter(isPlanFile).length;
695
+ const summaries = files.filter(isSummaryFile).length;
696
+ diskTotalPlans += plans;
697
+ diskTotalSummaries += summaries;
698
+ // A phase is complete when every plan has a corresponding summary
699
+ if (plans > 0 && summaries >= plans) diskCompletedPhases++;
700
+ }
701
+ totalPhases = phaseDirs.length;
702
+ completedPhases = diskCompletedPhases;
703
+ totalPlans = diskTotalPlans;
704
+ completedPlans = diskTotalSummaries;
705
+ } catch {
706
+ // Phases directory may not exist or be unreadable -- return nulls
707
+ }
708
+
709
+ return { totalPhases, completedPhases, totalPlans, completedPlans };
710
+ }
711
+
712
+ /**
713
+ * Normalize a human-readable status string to a canonical machine-readable value.
714
+ * Maps free-form status text (e.g. "In progress", "Phase complete") to one of:
715
+ * planning, discussing, executing, verifying, paused, completed, unknown.
716
+ *
717
+ * Priority order:
718
+ * 1. paused/stopped (highest -- overrides all)
719
+ * 2. executing/in-progress
720
+ * 3. planning/ready-to-plan
721
+ * 4. discussing
722
+ * 5. verifying
723
+ * 6. completed/done
724
+ * 7. ready-to-execute (falls into executing)
725
+ * 8. unknown (fallback)
726
+ *
727
+ * @param {string|null} status - Raw status string from state.md
728
+ * @param {string|null} pausedAt - Value of **Paused At:** field (presence forces paused)
729
+ * @returns {string} Normalized status string
730
+ */
731
+ function normalizePhaseStatus(status, pausedAt) {
732
+ let normalizedStatus = status || 'unknown';
733
+ const statusLower = (status || '').toLowerCase();
734
+
735
+ // Paused/stopped takes highest priority -- if pausedAt field exists, always paused
736
+ if (statusLower.includes('paused') || statusLower.includes('stopped') || pausedAt) {
737
+ normalizedStatus = 'paused';
738
+ } else if (statusLower.includes('executing') || statusLower.includes('in progress')) {
739
+ normalizedStatus = 'executing';
740
+ } else if (statusLower.includes('planning') || statusLower.includes('ready to plan')) {
741
+ normalizedStatus = 'planning';
742
+ } else if (statusLower.includes('discussing')) {
743
+ normalizedStatus = 'discussing';
744
+ } else if (statusLower.includes('verif')) {
745
+ normalizedStatus = 'verifying';
746
+ } else if (statusLower.includes('complete') || statusLower.includes('done')) {
747
+ normalizedStatus = 'completed';
748
+ } else if (statusLower.includes('ready to execute')) {
749
+ // "Ready to execute" is treated as executing since execution is imminent
750
+ normalizedStatus = 'executing';
751
+ }
752
+
753
+ return normalizedStatus;
754
+ }
755
+
756
+ /**
757
+ * Render a text-based progress bar string from completed and total counts.
758
+ * Uses filled/empty block characters: [##########] 100%
759
+ * @param {number} completed - Number of completed items (summaries)
760
+ * @param {number} total - Total number of items (plans)
761
+ * @returns {string} Formatted progress bar string, e.g. "[#####-----] 50%"
762
+ */
763
+ function calculateProgressBar(completed, total) {
764
+ // Calculate percentage, clamped to 0-100
765
+ const percent = total > 0 ? Math.min(100, Math.round(completed / total * 100)) : 0;
766
+ // Scale percentage to bar width (number of filled blocks)
767
+ const filled = Math.round(percent / 100 * PROGRESS_BAR_WIDTH);
768
+ const bar = FILLED_BLOCK.repeat(filled) + EMPTY_BLOCK.repeat(PROGRESS_BAR_WIDTH - filled);
769
+ return `[${bar}] ${percent}%`;
770
+ }
771
+
772
+ /**
773
+ * Extract machine-readable fields from state.md markdown body and build
774
+ * a YAML frontmatter object. Allows hooks and scripts to read state
775
+ * reliably via `state json` instead of fragile regex parsing.
776
+ *
777
+ * Orchestrates extractFieldsFromState, scanPhaseProgress,
778
+ * normalizePhaseStatus, and calculateProgressBar.
779
+ */
780
+ function buildStateFrontmatter(bodyContent, cwd) {
781
+ const fields = extractFieldsFromState(bodyContent);
782
+
783
+ let milestone = null;
784
+ let milestoneName = null;
785
+ if (cwd) {
786
+ try {
787
+ const info = getMilestoneInfo(cwd);
788
+ milestone = info.version;
789
+ milestoneName = info.name;
790
+ } catch {
791
+ // No milestone configured or milestone file unreadable -- skip milestone fields
792
+ }
793
+ }
794
+
795
+ let totalPhases = fields.totalPhasesRaw ? parseInt(fields.totalPhasesRaw, 10) : null;
796
+ let completedPhases = null;
797
+ let totalPlans = fields.totalPlansRaw ? parseInt(fields.totalPlansRaw, 10) : null;
798
+ let completedPlans = null;
799
+
800
+ // Scan disk for actual plan/summary counts if cwd is available
801
+ if (cwd) {
802
+ const diskProgress = scanPhaseProgress(cwd);
803
+ if (diskProgress.totalPhases !== null) {
804
+ if (totalPhases === null) totalPhases = diskProgress.totalPhases;
805
+ completedPhases = diskProgress.completedPhases;
806
+ totalPlans = diskProgress.totalPlans;
807
+ completedPlans = diskProgress.completedPlans;
808
+ }
809
+ }
810
+
811
+ // Parse percentage from progress bar text (e.g. "[######----] 60%")
812
+ let progressPercent = null;
813
+ if (fields.progressRaw) {
814
+ const pctMatch = fields.progressRaw.match(/(\d+)%/);
815
+ if (pctMatch) progressPercent = parseInt(pctMatch[1], 10);
816
+ }
817
+
818
+ const normalizedStatus = normalizePhaseStatus(fields.status, fields.pausedAt);
819
+
820
+ const frontmatter = { pan_state_version: '1.0' };
821
+
822
+ if (milestone) frontmatter.milestone = milestone;
823
+ if (milestoneName) frontmatter.milestone_name = milestoneName;
824
+ if (fields.currentPhase) frontmatter.current_phase = fields.currentPhase;
825
+ if (fields.currentPhaseName) frontmatter.current_phase_name = fields.currentPhaseName;
826
+ if (fields.currentPlan) frontmatter.current_plan = fields.currentPlan;
827
+ frontmatter.status = normalizedStatus;
828
+ if (fields.stoppedAt) frontmatter.stopped_at = fields.stoppedAt;
829
+ if (fields.pausedAt) frontmatter.paused_at = fields.pausedAt;
830
+ frontmatter.last_updated = new Date().toISOString();
831
+ if (fields.lastActivity) frontmatter.last_activity = fields.lastActivity;
832
+
833
+ const progress = {};
834
+ if (totalPhases !== null) progress.total_phases = totalPhases;
835
+ if (completedPhases !== null) progress.completed_phases = completedPhases;
836
+ if (totalPlans !== null) progress.total_plans = totalPlans;
837
+ if (completedPlans !== null) progress.completed_plans = completedPlans;
838
+ if (progressPercent !== null) progress.percent = progressPercent;
839
+ if (Object.keys(progress).length > 0) frontmatter.progress = progress;
840
+
841
+ return frontmatter;
842
+ }
843
+
844
+ function stripFrontmatter(content) {
845
+ return content.replace(/^---\n[\s\S]*?\n---\n*/, '');
846
+ }
847
+
848
+ function syncStateFrontmatter(content, cwd) {
849
+ const body = stripFrontmatter(content);
850
+ const frontmatter = buildStateFrontmatter(body, cwd);
851
+ const yamlStr = reconstructFrontmatter(frontmatter);
852
+ return `---\n${yamlStr}\n---\n\n${body}`;
853
+ }
854
+
855
+ /**
856
+ * Write state.md with synchronized YAML frontmatter.
857
+ * All state.md writes should use this instead of raw writeFileSync.
858
+ */
859
+ function writeStateMd(statePath, content, cwd) {
860
+ const synced = syncStateFrontmatter(content, cwd);
861
+ try {
862
+ fs.writeFileSync(statePath, synced, 'utf-8');
863
+ } catch (err) {
864
+ throw new Error('Failed to write state.md: ' + err.message);
865
+ }
866
+ }
867
+
868
+ /**
869
+ * Output state.md frontmatter as JSON, building it from body content if missing.
870
+ * @param {string} cwd - Working directory path
871
+ * @param {boolean} raw - If true, output raw value instead of JSON
872
+ * @returns {void}
873
+ */
874
+ function cmdStateJson(cwd, raw) {
875
+ const statePath = path.join(planningPath(cwd), STATE_FILE);
876
+ const content = safeReadFile(statePath);
877
+ if (content === null) {
878
+ output({ error: 'state.md not found' }, raw, 'state.md not found');
879
+ return;
880
+ }
881
+ const frontmatter = extractFrontmatter(content);
882
+
883
+ if (!frontmatter || Object.keys(frontmatter).length === 0) {
884
+ const body = stripFrontmatter(content);
885
+ const built = buildStateFrontmatter(body, cwd);
886
+ output(built, raw, JSON.stringify(built, null, 2));
887
+ return;
888
+ }
889
+
890
+ output(frontmatter, raw, JSON.stringify(frontmatter, null, 2));
891
+ }
892
+
893
+ /**
894
+ * Aggregated project dashboard — single-command project overview.
895
+ * Combines config, state, phase progress, blockers, and last activity.
896
+ * @param {string} cwd - Working directory path
897
+ * @param {boolean} raw - If true, output raw value instead of JSON
898
+ * @returns {void}
899
+ */
900
+ function cmdDashboard(cwd, raw) {
901
+ const planDir = planningPath(cwd);
902
+
903
+ // Load config for project name and version
904
+ const config = loadConfig(cwd);
905
+ let projectName = null;
906
+ let version = null;
907
+ try {
908
+ const pkg = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf-8'));
909
+ projectName = pkg.name || null;
910
+ version = pkg.version || null;
911
+ } catch { /* no package.json */ }
912
+
913
+ // Load state.md
914
+ const statePath = path.join(planDir, STATE_FILE);
915
+ const stateContent = safeReadFile(statePath);
916
+
917
+ let currentPhase = null;
918
+ let currentPhaseName = null;
919
+ let status = null;
920
+ let lastActivity = null;
921
+ let lastActivityDesc = null;
922
+ let blockerCount = 0;
923
+ const activeBlockers = [];
924
+
925
+ if (stateContent) {
926
+ currentPhase = stateExtractField(stateContent, 'Current Phase');
927
+ currentPhaseName = stateExtractField(stateContent, 'Current Phase Name');
928
+ status = stateExtractField(stateContent, 'Status');
929
+ lastActivity = stateExtractField(stateContent, 'Last Activity');
930
+ lastActivityDesc = stateExtractField(stateContent, 'Last Activity Description');
931
+
932
+ // Parse blockers
933
+ const blockersMatch = stateContent.match(/##\s*Blockers\s*\n([\s\S]*?)(?=\n##|$)/i);
934
+ if (blockersMatch) {
935
+ const items = blockersMatch[1].match(/^-\s+(.+)$/gm) || [];
936
+ for (const item of items) {
937
+ const text = item.replace(/^-\s+/, '').trim();
938
+ if (text && !/^none$/i.test(text)) {
939
+ activeBlockers.push(text);
940
+ }
941
+ }
942
+ }
943
+ blockerCount = activeBlockers.length;
944
+ }
945
+
946
+ // Scan phase progress from disk
947
+ const progress = scanPhaseProgress(cwd);
948
+
949
+ // Determine milestone info
950
+ let milestone = null;
951
+ try {
952
+ milestone = getMilestoneInfo(cwd);
953
+ } catch { /* no milestone */ }
954
+
955
+ // Find next phase (phase after current)
956
+ let nextPhase = null;
957
+ if (currentPhase) {
958
+ const phaseNum = parseInt(currentPhase, 10);
959
+ if (!isNaN(phaseNum)) {
960
+ const nextNum = String(phaseNum + 1).padStart(2, '0');
961
+ try {
962
+ const entries = fs.readdirSync(phasesPath(cwd));
963
+ const nextDir = entries.find(e => e.startsWith(nextNum + '-'));
964
+ if (nextDir) {
965
+ nextPhase = { number: nextNum, name: nextDir.replace(/^\d+-/, '') };
966
+ }
967
+ } catch { /* no phases dir */ }
968
+ }
969
+ }
970
+
971
+ const result = {
972
+ project: projectName,
973
+ version,
974
+ milestone: milestone ? { version: milestone.version, name: milestone.name } : null,
975
+ current_phase: currentPhase ? {
976
+ number: currentPhase,
977
+ name: currentPhaseName || null,
978
+ status: status || null,
979
+ } : null,
980
+ progress: {
981
+ phases_completed: progress.completedPhases,
982
+ phases_total: progress.totalPhases,
983
+ plans_total: progress.totalPlans,
984
+ plans_completed: progress.completedPlans,
985
+ },
986
+ blockers: blockerCount,
987
+ blocker_list: activeBlockers.length > 0 ? activeBlockers : undefined,
988
+ last_activity: lastActivity || null,
989
+ last_activity_description: lastActivityDesc || null,
990
+ next_phase: nextPhase,
991
+ };
992
+
993
+ // Raw mode: human-readable summary
994
+ if (raw) {
995
+ const lines = [];
996
+ if (projectName) lines.push(`Project: ${projectName}${version ? ' v' + version : ''}`);
997
+ if (milestone) lines.push(`Milestone: ${milestone.version} ${milestone.name || ''}`);
998
+ if (currentPhase) lines.push(`Current Phase: ${currentPhase}${currentPhaseName ? ' — ' + currentPhaseName : ''} (${status || 'unknown'})`);
999
+ if (progress.totalPhases !== null) lines.push(`Progress: ${progress.completedPhases}/${progress.totalPhases} phases, ${progress.completedPlans}/${progress.totalPlans} plans`);
1000
+ lines.push(`Blockers: ${blockerCount}`);
1001
+ if (lastActivity) lines.push(`Last Activity: ${lastActivity}${lastActivityDesc ? ' — ' + lastActivityDesc : ''}`);
1002
+ if (nextPhase) lines.push(`Next Phase: ${nextPhase.number} — ${nextPhase.name}`);
1003
+ output(result, false, lines.join('\n'));
1004
+ return;
1005
+ }
1006
+
1007
+ output(result, raw);
1008
+ }
1009
+
1010
+ module.exports = {
1011
+ readStateSafe: safeReadFile,
1012
+ stateExtractField,
1013
+ stateReplaceField,
1014
+ writeStateMd,
1015
+ cmdStateLoad,
1016
+ cmdStateGet,
1017
+ cmdStatePatch,
1018
+ cmdStateUpdate,
1019
+ cmdStateAdvancePlan,
1020
+ cmdStateRecordMetric,
1021
+ cmdStateUpdateProgress,
1022
+ cmdStateAddDecision,
1023
+ cmdStateAddBlocker,
1024
+ cmdStateResolveBlocker,
1025
+ cmdStateRecordSession,
1026
+ cmdStateSnapshot,
1027
+ cmdStateJson,
1028
+ cmdDashboard,
1029
+ };