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.
- package/LICENSE +21 -0
- package/README.md +772 -0
- package/agents/pan-debugger.md +1246 -0
- package/agents/pan-document_code.md +965 -0
- package/agents/pan-executor.md +469 -0
- package/agents/pan-integration-checker.md +443 -0
- package/agents/pan-phase-researcher.md +572 -0
- package/agents/pan-plan-checker.md +763 -0
- package/agents/pan-planner.md +1297 -0
- package/agents/pan-project-researcher.md +647 -0
- package/agents/pan-research-synthesizer.md +239 -0
- package/agents/pan-reviewer.md +112 -0
- package/agents/pan-roadmapper.md +642 -0
- package/agents/pan-verifier.md +672 -0
- package/assets/pan-logo-2000-transparent.svg +30 -0
- package/assets/pan-logo-2000.svg +43 -0
- package/assets/terminal.svg +119 -0
- package/bin/install-lib.cjs +616 -0
- package/bin/install.js +1936 -0
- package/commands/pan/add-phase.md +44 -0
- package/commands/pan/assumptions.md +47 -0
- package/commands/pan/audit-deployment.md +378 -0
- package/commands/pan/debug.md +168 -0
- package/commands/pan/discord.md +19 -0
- package/commands/pan/discuss-phase.md +84 -0
- package/commands/pan/exec-phase.md +45 -0
- package/commands/pan/focus-auto.md +323 -0
- package/commands/pan/focus-design.md +816 -0
- package/commands/pan/focus-exec.md +316 -0
- package/commands/pan/focus-plan.md +101 -0
- package/commands/pan/focus-scan.md +272 -0
- package/commands/pan/focus-sync.md +104 -0
- package/commands/pan/health.md +23 -0
- package/commands/pan/help.md +23 -0
- package/commands/pan/insert-phase.md +33 -0
- package/commands/pan/map-codebase.md +72 -0
- package/commands/pan/milestone-audit.md +37 -0
- package/commands/pan/milestone-cleanup.md +19 -0
- package/commands/pan/milestone-done.md +137 -0
- package/commands/pan/milestone-gaps.md +35 -0
- package/commands/pan/milestone-new.md +45 -0
- package/commands/pan/new-project.md +43 -0
- package/commands/pan/patches.md +110 -0
- package/commands/pan/pause.md +39 -0
- package/commands/pan/phase-budget.md +23 -0
- package/commands/pan/phase-tests.md +42 -0
- package/commands/pan/plan-phase.md +46 -0
- package/commands/pan/profile.md +36 -0
- package/commands/pan/progress.md +25 -0
- package/commands/pan/quick.md +42 -0
- package/commands/pan/remove-phase.md +32 -0
- package/commands/pan/research-phase.md +190 -0
- package/commands/pan/resume.md +41 -0
- package/commands/pan/retro.md +33 -0
- package/commands/pan/settings.md +37 -0
- package/commands/pan/todo-add.md +48 -0
- package/commands/pan/todo-check.md +46 -0
- package/commands/pan/update.md +38 -0
- package/commands/pan/verify-phase.md +39 -0
- package/hooks/dist/pan-check-update.js +62 -0
- package/hooks/dist/pan-context-monitor.js +122 -0
- package/hooks/dist/pan-statusline.js +108 -0
- package/package.json +66 -0
- package/pan-wizard-core/bin/lib/codebase.cjs +746 -0
- package/pan-wizard-core/bin/lib/commands.cjs +1435 -0
- package/pan-wizard-core/bin/lib/config.cjs +611 -0
- package/pan-wizard-core/bin/lib/constants.cjs +696 -0
- package/pan-wizard-core/bin/lib/context-budget.cjs +150 -0
- package/pan-wizard-core/bin/lib/core.cjs +650 -0
- package/pan-wizard-core/bin/lib/focus.cjs +900 -0
- package/pan-wizard-core/bin/lib/frontmatter.cjs +442 -0
- package/pan-wizard-core/bin/lib/init.cjs +881 -0
- package/pan-wizard-core/bin/lib/milestone.cjs +276 -0
- package/pan-wizard-core/bin/lib/phase.cjs +1212 -0
- package/pan-wizard-core/bin/lib/roadmap.cjs +470 -0
- package/pan-wizard-core/bin/lib/state.cjs +1029 -0
- package/pan-wizard-core/bin/lib/template.cjs +314 -0
- package/pan-wizard-core/bin/lib/utils.cjs +171 -0
- package/pan-wizard-core/bin/lib/verify.cjs +1808 -0
- package/pan-wizard-core/bin/pan-tools.cjs +773 -0
- package/pan-wizard-core/references/checkpoints.md +776 -0
- package/pan-wizard-core/references/continuation-format.md +249 -0
- package/pan-wizard-core/references/decimal-phase-calculation.md +65 -0
- package/pan-wizard-core/references/git-integration.md +248 -0
- package/pan-wizard-core/references/git-planning-commit.md +38 -0
- package/pan-wizard-core/references/model-profile-resolution.md +34 -0
- package/pan-wizard-core/references/model-profiles.md +111 -0
- package/pan-wizard-core/references/phase-argument-parsing.md +61 -0
- package/pan-wizard-core/references/planning-config.md +196 -0
- package/pan-wizard-core/references/questioning.md +145 -0
- package/pan-wizard-core/references/tdd.md +263 -0
- package/pan-wizard-core/references/ui-brand.md +160 -0
- package/pan-wizard-core/references/verification-patterns.md +612 -0
- package/pan-wizard-core/templates/codebase/architecture.md +283 -0
- package/pan-wizard-core/templates/codebase/best-practices.md +133 -0
- package/pan-wizard-core/templates/codebase/concerns.md +325 -0
- package/pan-wizard-core/templates/codebase/conventions.md +307 -0
- package/pan-wizard-core/templates/codebase/integrations.md +305 -0
- package/pan-wizard-core/templates/codebase/relationships.md +124 -0
- package/pan-wizard-core/templates/codebase/stack.md +199 -0
- package/pan-wizard-core/templates/codebase/structure.md +298 -0
- package/pan-wizard-core/templates/codebase/testing.md +480 -0
- package/pan-wizard-core/templates/config.json +37 -0
- package/pan-wizard-core/templates/context.md +283 -0
- package/pan-wizard-core/templates/continue-here.md +78 -0
- package/pan-wizard-core/templates/debug-subagent-prompt.md +91 -0
- package/pan-wizard-core/templates/debug.md +164 -0
- package/pan-wizard-core/templates/discovery.md +146 -0
- package/pan-wizard-core/templates/milestone-archive.md +123 -0
- package/pan-wizard-core/templates/milestone.md +115 -0
- package/pan-wizard-core/templates/phase-prompt.md +593 -0
- package/pan-wizard-core/templates/planner-subagent-prompt.md +117 -0
- package/pan-wizard-core/templates/project.md +184 -0
- package/pan-wizard-core/templates/requirements.md +231 -0
- package/pan-wizard-core/templates/research-project/architecture.md +204 -0
- package/pan-wizard-core/templates/research-project/features.md +147 -0
- package/pan-wizard-core/templates/research-project/pitfalls.md +200 -0
- package/pan-wizard-core/templates/research-project/stack.md +120 -0
- package/pan-wizard-core/templates/research-project/summary.md +170 -0
- package/pan-wizard-core/templates/research.md +552 -0
- package/pan-wizard-core/templates/retrospective.md +54 -0
- package/pan-wizard-core/templates/roadmap.md +202 -0
- package/pan-wizard-core/templates/standards.md +24 -0
- package/pan-wizard-core/templates/state.md +176 -0
- package/pan-wizard-core/templates/summary-complex.md +59 -0
- package/pan-wizard-core/templates/summary-minimal.md +41 -0
- package/pan-wizard-core/templates/summary-standard.md +49 -0
- package/pan-wizard-core/templates/summary.md +249 -0
- package/pan-wizard-core/templates/uat.md +247 -0
- package/pan-wizard-core/templates/user-setup.md +311 -0
- package/pan-wizard-core/templates/validation.md +76 -0
- package/pan-wizard-core/templates/verification-report.md +322 -0
- package/pan-wizard-core/workflows/add-phase.md +111 -0
- package/pan-wizard-core/workflows/assumptions.md +178 -0
- package/pan-wizard-core/workflows/diagnose-issues.md +219 -0
- package/pan-wizard-core/workflows/discuss-phase.md +542 -0
- package/pan-wizard-core/workflows/exec-phase.md +572 -0
- package/pan-wizard-core/workflows/execute-plan.md +448 -0
- package/pan-wizard-core/workflows/health.md +156 -0
- package/pan-wizard-core/workflows/help.md +431 -0
- package/pan-wizard-core/workflows/insert-phase.md +129 -0
- package/pan-wizard-core/workflows/map-codebase.md +401 -0
- package/pan-wizard-core/workflows/milestone-audit.md +297 -0
- package/pan-wizard-core/workflows/milestone-cleanup.md +152 -0
- package/pan-wizard-core/workflows/milestone-gaps.md +274 -0
- package/pan-wizard-core/workflows/milestone-new.md +382 -0
- package/pan-wizard-core/workflows/new-project.md +1178 -0
- package/pan-wizard-core/workflows/pause.md +122 -0
- package/pan-wizard-core/workflows/phase-tests.md +388 -0
- package/pan-wizard-core/workflows/plan-phase.md +569 -0
- package/pan-wizard-core/workflows/profile.md +115 -0
- package/pan-wizard-core/workflows/progress.md +381 -0
- package/pan-wizard-core/workflows/quick.md +453 -0
- package/pan-wizard-core/workflows/remove-phase.md +154 -0
- package/pan-wizard-core/workflows/research-phase.md +73 -0
- package/pan-wizard-core/workflows/resume-project.md +306 -0
- package/pan-wizard-core/workflows/retro.md +121 -0
- package/pan-wizard-core/workflows/settings.md +213 -0
- package/pan-wizard-core/workflows/todo-add.md +157 -0
- package/pan-wizard-core/workflows/todo-check.md +176 -0
- package/pan-wizard-core/workflows/transition.md +544 -0
- package/pan-wizard-core/workflows/update.md +219 -0
- package/pan-wizard-core/workflows/verify-phase.md +301 -0
- 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
|
+
};
|