up-cc 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agents/up-depurador.md +357 -0
- package/agents/up-executor.md +409 -0
- package/agents/up-pesquisador-projeto.md +358 -0
- package/agents/up-planejador.md +390 -0
- package/agents/up-roteirista.md +401 -0
- package/agents/up-sintetizador.md +232 -0
- package/agents/up-verificador.md +357 -0
- package/bin/install.js +709 -0
- package/bin/lib/core.cjs +270 -0
- package/bin/up-tools.cjs +1361 -0
- package/commands/adicionar-fase.md +33 -0
- package/commands/ajuda.md +131 -0
- package/commands/discutir-fase.md +35 -0
- package/commands/executar-fase.md +40 -0
- package/commands/novo-projeto.md +39 -0
- package/commands/pausar.md +33 -0
- package/commands/planejar-fase.md +43 -0
- package/commands/progresso.md +33 -0
- package/commands/rapido.md +40 -0
- package/commands/remover-fase.md +34 -0
- package/commands/retomar.md +35 -0
- package/commands/verificar-trabalho.md +35 -0
- package/package.json +32 -0
- package/references/checkpoints.md +358 -0
- package/references/git-integration.md +208 -0
- package/references/questioning.md +156 -0
- package/references/ui-brand.md +124 -0
- package/templates/config.json +6 -0
- package/templates/continue-here.md +78 -0
- package/templates/project.md +184 -0
- package/templates/requirements.md +129 -0
- package/templates/roadmap.md +131 -0
- package/templates/state.md +130 -0
- package/templates/summary.md +174 -0
- package/workflows/discutir-fase.md +324 -0
- package/workflows/executar-fase.md +277 -0
- package/workflows/executar-plano.md +192 -0
- package/workflows/novo-projeto.md +561 -0
- package/workflows/pausar.md +111 -0
- package/workflows/planejar-fase.md +208 -0
- package/workflows/progresso.md +226 -0
- package/workflows/rapido.md +209 -0
- package/workflows/retomar.md +231 -0
- package/workflows/verificar-trabalho.md +261 -0
package/bin/up-tools.cjs
ADDED
|
@@ -0,0 +1,1361 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* UP Tools — CLI utility for UP workflow operations
|
|
5
|
+
*
|
|
6
|
+
* Simplified version of GSD Tools. Single file (+ core.cjs).
|
|
7
|
+
*
|
|
8
|
+
* Usage: node up-tools.cjs <command> [args] [--raw] [--cwd <path>]
|
|
9
|
+
*
|
|
10
|
+
* Commands:
|
|
11
|
+
* init planejar-fase|executar-fase|novo-projeto|rapido|retomar
|
|
12
|
+
* state load|get|update|advance-plan|update-progress|add-decision|record-session
|
|
13
|
+
* roadmap get-phase|analyze|update-plan-progress
|
|
14
|
+
* phase add|remove|find|complete
|
|
15
|
+
* config get|set
|
|
16
|
+
* requirements mark-complete
|
|
17
|
+
* commit <msg> --files
|
|
18
|
+
* progress [json|table|bar]
|
|
19
|
+
* timestamp [full|date|filename]
|
|
20
|
+
* slug <text>
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const fs = require('fs');
|
|
24
|
+
const path = require('path');
|
|
25
|
+
const {
|
|
26
|
+
output, error, loadConfig, isGitIgnored, execGit,
|
|
27
|
+
escapeRegex, normalizePhaseName, comparePhaseNum,
|
|
28
|
+
findPhaseInternal, getRoadmapPhaseInternal,
|
|
29
|
+
pathExistsInternal, generateSlugInternal, toPosixPath,
|
|
30
|
+
} = require('./lib/core.cjs');
|
|
31
|
+
|
|
32
|
+
// --- State helpers ---
|
|
33
|
+
|
|
34
|
+
function stateExtractField(content, fieldName) {
|
|
35
|
+
const escaped = escapeRegex(fieldName);
|
|
36
|
+
const boldPattern = new RegExp(`\\*\\*${escaped}:\\*\\*\\s*(.+)`, 'i');
|
|
37
|
+
const boldMatch = content.match(boldPattern);
|
|
38
|
+
if (boldMatch) return boldMatch[1].trim();
|
|
39
|
+
const plainPattern = new RegExp(`^${escaped}:\\s*(.+)`, 'im');
|
|
40
|
+
const plainMatch = content.match(plainPattern);
|
|
41
|
+
return plainMatch ? plainMatch[1].trim() : null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function stateReplaceField(content, fieldName, newValue) {
|
|
45
|
+
const escaped = escapeRegex(fieldName);
|
|
46
|
+
const boldPattern = new RegExp(`(\\*\\*${escaped}:\\*\\*\\s*)(.*)`, 'i');
|
|
47
|
+
if (boldPattern.test(content)) {
|
|
48
|
+
return content.replace(boldPattern, (_match, prefix) => `${prefix}${newValue}`);
|
|
49
|
+
}
|
|
50
|
+
const plainPattern = new RegExp(`(^${escaped}:\\s*)(.*)`, 'im');
|
|
51
|
+
if (plainPattern.test(content)) {
|
|
52
|
+
return content.replace(plainPattern, (_match, prefix) => `${prefix}${newValue}`);
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// --- CLI Router ---
|
|
58
|
+
|
|
59
|
+
function main() {
|
|
60
|
+
const args = process.argv.slice(2);
|
|
61
|
+
|
|
62
|
+
// Optional cwd override
|
|
63
|
+
let cwd = process.cwd();
|
|
64
|
+
const cwdEqArg = args.find(arg => arg.startsWith('--cwd='));
|
|
65
|
+
const cwdIdx = args.indexOf('--cwd');
|
|
66
|
+
if (cwdEqArg) {
|
|
67
|
+
const value = cwdEqArg.slice('--cwd='.length).trim();
|
|
68
|
+
if (!value) error('Missing value for --cwd');
|
|
69
|
+
args.splice(args.indexOf(cwdEqArg), 1);
|
|
70
|
+
cwd = path.resolve(value);
|
|
71
|
+
} else if (cwdIdx !== -1) {
|
|
72
|
+
const value = args[cwdIdx + 1];
|
|
73
|
+
if (!value || value.startsWith('--')) error('Missing value for --cwd');
|
|
74
|
+
args.splice(cwdIdx, 2);
|
|
75
|
+
cwd = path.resolve(value);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (!fs.existsSync(cwd) || !fs.statSync(cwd).isDirectory()) {
|
|
79
|
+
error(`Invalid --cwd: ${cwd}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const rawIndex = args.indexOf('--raw');
|
|
83
|
+
const raw = rawIndex !== -1;
|
|
84
|
+
if (rawIndex !== -1) args.splice(rawIndex, 1);
|
|
85
|
+
|
|
86
|
+
const command = args[0];
|
|
87
|
+
|
|
88
|
+
if (!command) {
|
|
89
|
+
error('Usage: up-tools <command> [args] [--raw] [--cwd <path>]\nCommands: init, state, roadmap, phase, config, requirements, commit, progress, timestamp, slug');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
switch (command) {
|
|
93
|
+
// ==================== INIT ====================
|
|
94
|
+
case 'init': {
|
|
95
|
+
const workflow = args[1];
|
|
96
|
+
switch (workflow) {
|
|
97
|
+
case 'planejar-fase':
|
|
98
|
+
cmdInitPlanejarFase(cwd, args[2], raw);
|
|
99
|
+
break;
|
|
100
|
+
case 'executar-fase':
|
|
101
|
+
cmdInitExecutarFase(cwd, args[2], raw);
|
|
102
|
+
break;
|
|
103
|
+
case 'novo-projeto':
|
|
104
|
+
cmdInitNovoProjeto(cwd, raw);
|
|
105
|
+
break;
|
|
106
|
+
case 'rapido':
|
|
107
|
+
cmdInitRapido(cwd, args.slice(2).join(' '), raw);
|
|
108
|
+
break;
|
|
109
|
+
case 'retomar':
|
|
110
|
+
cmdInitRetomar(cwd, raw);
|
|
111
|
+
break;
|
|
112
|
+
default:
|
|
113
|
+
error(`Unknown init workflow: ${workflow}\nAvailable: planejar-fase, executar-fase, novo-projeto, rapido, retomar`);
|
|
114
|
+
}
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ==================== STATE ====================
|
|
119
|
+
case 'state': {
|
|
120
|
+
const sub = args[1];
|
|
121
|
+
if (sub === 'load') {
|
|
122
|
+
cmdStateLoad(cwd, raw);
|
|
123
|
+
} else if (sub === 'get') {
|
|
124
|
+
cmdStateGet(cwd, args[2], raw);
|
|
125
|
+
} else if (sub === 'update') {
|
|
126
|
+
cmdStateUpdate(cwd, args[2], args[3], raw);
|
|
127
|
+
} else if (sub === 'advance-plan') {
|
|
128
|
+
cmdStateAdvancePlan(cwd, raw);
|
|
129
|
+
} else if (sub === 'update-progress') {
|
|
130
|
+
cmdStateUpdateProgress(cwd, raw);
|
|
131
|
+
} else if (sub === 'add-decision') {
|
|
132
|
+
const summaryIdx = args.indexOf('--summary');
|
|
133
|
+
const phaseIdx = args.indexOf('--phase');
|
|
134
|
+
cmdStateAddDecision(cwd, {
|
|
135
|
+
summary: summaryIdx !== -1 ? args[summaryIdx + 1] : null,
|
|
136
|
+
phase: phaseIdx !== -1 ? args[phaseIdx + 1] : null,
|
|
137
|
+
}, raw);
|
|
138
|
+
} else if (sub === 'record-session') {
|
|
139
|
+
const stoppedIdx = args.indexOf('--stopped-at');
|
|
140
|
+
cmdStateRecordSession(cwd, {
|
|
141
|
+
stopped_at: stoppedIdx !== -1 ? args[stoppedIdx + 1] : null,
|
|
142
|
+
}, raw);
|
|
143
|
+
} else {
|
|
144
|
+
error('Unknown state subcommand. Available: load, get, update, advance-plan, update-progress, add-decision, record-session');
|
|
145
|
+
}
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ==================== ROADMAP ====================
|
|
150
|
+
case 'roadmap': {
|
|
151
|
+
const sub = args[1];
|
|
152
|
+
if (sub === 'get-phase') {
|
|
153
|
+
cmdRoadmapGetPhase(cwd, args[2], raw);
|
|
154
|
+
} else if (sub === 'analyze') {
|
|
155
|
+
cmdRoadmapAnalyze(cwd, raw);
|
|
156
|
+
} else if (sub === 'update-plan-progress') {
|
|
157
|
+
cmdRoadmapUpdatePlanProgress(cwd, args[2], raw);
|
|
158
|
+
} else {
|
|
159
|
+
error('Unknown roadmap subcommand. Available: get-phase, analyze, update-plan-progress');
|
|
160
|
+
}
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ==================== PHASE ====================
|
|
165
|
+
case 'phase': {
|
|
166
|
+
const sub = args[1];
|
|
167
|
+
if (sub === 'find') {
|
|
168
|
+
cmdPhaseFind(cwd, args[2], raw);
|
|
169
|
+
} else if (sub === 'add') {
|
|
170
|
+
cmdPhaseAdd(cwd, args.slice(2).join(' '), raw);
|
|
171
|
+
} else if (sub === 'remove') {
|
|
172
|
+
const forceFlag = args.includes('--force');
|
|
173
|
+
cmdPhaseRemove(cwd, args[2], { force: forceFlag }, raw);
|
|
174
|
+
} else if (sub === 'complete') {
|
|
175
|
+
cmdPhaseComplete(cwd, args[2], raw);
|
|
176
|
+
} else {
|
|
177
|
+
error('Unknown phase subcommand. Available: find, add, remove, complete');
|
|
178
|
+
}
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ==================== CONFIG ====================
|
|
183
|
+
case 'config': {
|
|
184
|
+
const sub = args[1];
|
|
185
|
+
if (sub === 'get') {
|
|
186
|
+
cmdConfigGet(cwd, args[2], raw);
|
|
187
|
+
} else if (sub === 'set') {
|
|
188
|
+
cmdConfigSet(cwd, args[2], args[3], raw);
|
|
189
|
+
} else {
|
|
190
|
+
error('Unknown config subcommand. Available: get, set');
|
|
191
|
+
}
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ==================== REQUIREMENTS ====================
|
|
196
|
+
case 'requirements': {
|
|
197
|
+
const sub = args[1];
|
|
198
|
+
if (sub === 'mark-complete') {
|
|
199
|
+
cmdRequirementsMarkComplete(cwd, args.slice(2), raw);
|
|
200
|
+
} else {
|
|
201
|
+
error('Unknown requirements subcommand. Available: mark-complete');
|
|
202
|
+
}
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ==================== COMMIT ====================
|
|
207
|
+
case 'commit': {
|
|
208
|
+
const filesIndex = args.indexOf('--files');
|
|
209
|
+
const endIndex = filesIndex !== -1 ? filesIndex : args.length;
|
|
210
|
+
const messageArgs = args.slice(1, endIndex).filter(a => !a.startsWith('--'));
|
|
211
|
+
const message = messageArgs.join(' ') || undefined;
|
|
212
|
+
const files = filesIndex !== -1 ? args.slice(filesIndex + 1).filter(a => !a.startsWith('--')) : [];
|
|
213
|
+
cmdCommit(cwd, message, files, raw);
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ==================== PROGRESS ====================
|
|
218
|
+
case 'progress': {
|
|
219
|
+
cmdProgress(cwd, args[1] || 'json', raw);
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ==================== TIMESTAMP ====================
|
|
224
|
+
case 'timestamp': {
|
|
225
|
+
cmdTimestamp(args[1] || 'full', raw);
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ==================== SLUG ====================
|
|
230
|
+
case 'slug': {
|
|
231
|
+
cmdSlug(args[1], raw);
|
|
232
|
+
break;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
default:
|
|
236
|
+
error(`Unknown command: ${command}`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// =====================================================================
|
|
241
|
+
// INIT COMMANDS
|
|
242
|
+
// =====================================================================
|
|
243
|
+
|
|
244
|
+
function cmdInitPlanejarFase(cwd, phase, raw) {
|
|
245
|
+
if (!phase) error('phase required for init planejar-fase');
|
|
246
|
+
|
|
247
|
+
const config = loadConfig(cwd);
|
|
248
|
+
const phaseInfo = findPhaseInternal(cwd, phase);
|
|
249
|
+
|
|
250
|
+
const result = {
|
|
251
|
+
phase_dir: phaseInfo?.directory || null,
|
|
252
|
+
phase_number: phaseInfo?.phase_number || null,
|
|
253
|
+
phase_name: phaseInfo?.phase_name || null,
|
|
254
|
+
phase_slug: phaseInfo?.phase_slug || null,
|
|
255
|
+
padded_phase: phaseInfo?.phase_number?.padStart(2, '0') || null,
|
|
256
|
+
|
|
257
|
+
has_research: phaseInfo?.has_research || false,
|
|
258
|
+
has_context: phaseInfo?.has_context || false,
|
|
259
|
+
has_plans: (phaseInfo?.plans?.length || 0) > 0,
|
|
260
|
+
plan_count: phaseInfo?.plans?.length || 0,
|
|
261
|
+
|
|
262
|
+
commit_docs: config.commit_docs,
|
|
263
|
+
|
|
264
|
+
state_path: '.plano/STATE.md',
|
|
265
|
+
roadmap_path: '.plano/ROADMAP.md',
|
|
266
|
+
requirements_path: '.plano/REQUIREMENTS.md',
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
// Find context and research files in phase directory
|
|
270
|
+
if (phaseInfo?.directory) {
|
|
271
|
+
const phaseDirFull = path.join(cwd, phaseInfo.directory);
|
|
272
|
+
try {
|
|
273
|
+
const files = fs.readdirSync(phaseDirFull);
|
|
274
|
+
const contextFile = files.find(f => f.endsWith('-CONTEXT.md') || f === 'CONTEXT.md');
|
|
275
|
+
if (contextFile) {
|
|
276
|
+
result.context_path = toPosixPath(path.join(phaseInfo.directory, contextFile));
|
|
277
|
+
}
|
|
278
|
+
const researchFile = files.find(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md');
|
|
279
|
+
if (researchFile) {
|
|
280
|
+
result.research_path = toPosixPath(path.join(phaseInfo.directory, researchFile));
|
|
281
|
+
}
|
|
282
|
+
} catch {}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
output(result, raw);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function cmdInitExecutarFase(cwd, phase, raw) {
|
|
289
|
+
if (!phase) error('phase required for init executar-fase');
|
|
290
|
+
|
|
291
|
+
const config = loadConfig(cwd);
|
|
292
|
+
const phaseInfo = findPhaseInternal(cwd, phase);
|
|
293
|
+
|
|
294
|
+
const result = {
|
|
295
|
+
commit_docs: config.commit_docs,
|
|
296
|
+
paralelizacao: config.paralelizacao,
|
|
297
|
+
|
|
298
|
+
phase_found: !!phaseInfo,
|
|
299
|
+
phase_dir: phaseInfo?.directory || null,
|
|
300
|
+
phase_number: phaseInfo?.phase_number || null,
|
|
301
|
+
phase_name: phaseInfo?.phase_name || null,
|
|
302
|
+
phase_slug: phaseInfo?.phase_slug || null,
|
|
303
|
+
|
|
304
|
+
plans: phaseInfo?.plans || [],
|
|
305
|
+
incomplete_plans: phaseInfo?.incomplete_plans || [],
|
|
306
|
+
plan_count: phaseInfo?.plans?.length || 0,
|
|
307
|
+
incomplete_count: phaseInfo?.incomplete_plans?.length || 0,
|
|
308
|
+
|
|
309
|
+
state_exists: pathExistsInternal(cwd, '.plano/STATE.md'),
|
|
310
|
+
roadmap_exists: pathExistsInternal(cwd, '.plano/ROADMAP.md'),
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
output(result, raw);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function cmdInitNovoProjeto(cwd, raw) {
|
|
317
|
+
const config = loadConfig(cwd);
|
|
318
|
+
const { execSync } = require('child_process');
|
|
319
|
+
|
|
320
|
+
let hasCode = false;
|
|
321
|
+
try {
|
|
322
|
+
const files = execSync('find . -maxdepth 3 \\( -name "*.ts" -o -name "*.js" -o -name "*.py" -o -name "*.go" -o -name "*.rs" -o -name "*.swift" -o -name "*.java" \\) 2>/dev/null | grep -v node_modules | grep -v .git | head -5', {
|
|
323
|
+
cwd,
|
|
324
|
+
encoding: 'utf-8',
|
|
325
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
326
|
+
});
|
|
327
|
+
hasCode = files.trim().length > 0;
|
|
328
|
+
} catch {}
|
|
329
|
+
|
|
330
|
+
const result = {
|
|
331
|
+
commit_docs: config.commit_docs,
|
|
332
|
+
project_exists: pathExistsInternal(cwd, '.plano/PROJECT.md'),
|
|
333
|
+
planning_exists: pathExistsInternal(cwd, '.plano'),
|
|
334
|
+
has_existing_code: hasCode,
|
|
335
|
+
has_git: pathExistsInternal(cwd, '.git'),
|
|
336
|
+
project_path: '.plano/PROJECT.md',
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
output(result, raw);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function cmdInitRapido(cwd, description, raw) {
|
|
343
|
+
const config = loadConfig(cwd);
|
|
344
|
+
const now = new Date();
|
|
345
|
+
const slug = description ? generateSlugInternal(description)?.substring(0, 40) : null;
|
|
346
|
+
|
|
347
|
+
const quickDir = path.join(cwd, '.plano', 'rapido');
|
|
348
|
+
let nextNum = 1;
|
|
349
|
+
try {
|
|
350
|
+
const existing = fs.readdirSync(quickDir)
|
|
351
|
+
.filter(f => /^\d+-/.test(f))
|
|
352
|
+
.map(f => parseInt(f.split('-')[0], 10))
|
|
353
|
+
.filter(n => !isNaN(n));
|
|
354
|
+
if (existing.length > 0) {
|
|
355
|
+
nextNum = Math.max(...existing) + 1;
|
|
356
|
+
}
|
|
357
|
+
} catch {}
|
|
358
|
+
|
|
359
|
+
const result = {
|
|
360
|
+
commit_docs: config.commit_docs,
|
|
361
|
+
next_num: nextNum,
|
|
362
|
+
slug,
|
|
363
|
+
date: now.toISOString().split('T')[0],
|
|
364
|
+
timestamp: now.toISOString(),
|
|
365
|
+
quick_dir: '.plano/rapido',
|
|
366
|
+
task_dir: slug ? `.plano/rapido/${nextNum}-${slug}` : null,
|
|
367
|
+
roadmap_exists: pathExistsInternal(cwd, '.plano/ROADMAP.md'),
|
|
368
|
+
planning_exists: pathExistsInternal(cwd, '.plano'),
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
output(result, raw);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function cmdInitRetomar(cwd, raw) {
|
|
375
|
+
const config = loadConfig(cwd);
|
|
376
|
+
|
|
377
|
+
const result = {
|
|
378
|
+
state_exists: pathExistsInternal(cwd, '.plano/STATE.md'),
|
|
379
|
+
roadmap_exists: pathExistsInternal(cwd, '.plano/ROADMAP.md'),
|
|
380
|
+
project_exists: pathExistsInternal(cwd, '.plano/PROJECT.md'),
|
|
381
|
+
planning_exists: pathExistsInternal(cwd, '.plano'),
|
|
382
|
+
commit_docs: config.commit_docs,
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
output(result, raw);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// =====================================================================
|
|
389
|
+
// STATE COMMANDS
|
|
390
|
+
// =====================================================================
|
|
391
|
+
|
|
392
|
+
function cmdStateLoad(cwd, raw) {
|
|
393
|
+
const config = loadConfig(cwd);
|
|
394
|
+
const planoDir = path.join(cwd, '.plano');
|
|
395
|
+
|
|
396
|
+
let stateRaw = '';
|
|
397
|
+
try {
|
|
398
|
+
stateRaw = fs.readFileSync(path.join(planoDir, 'STATE.md'), 'utf-8');
|
|
399
|
+
} catch {}
|
|
400
|
+
|
|
401
|
+
const result = {
|
|
402
|
+
config,
|
|
403
|
+
state_raw: stateRaw,
|
|
404
|
+
state_exists: stateRaw.length > 0,
|
|
405
|
+
roadmap_exists: fs.existsSync(path.join(planoDir, 'ROADMAP.md')),
|
|
406
|
+
config_exists: fs.existsSync(path.join(planoDir, 'config.json')),
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
output(result, raw);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function cmdStateGet(cwd, section, raw) {
|
|
413
|
+
const statePath = path.join(cwd, '.plano', 'STATE.md');
|
|
414
|
+
try {
|
|
415
|
+
const content = fs.readFileSync(statePath, 'utf-8');
|
|
416
|
+
|
|
417
|
+
if (!section) {
|
|
418
|
+
output({ content }, raw, content);
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const fieldEscaped = escapeRegex(section);
|
|
423
|
+
|
|
424
|
+
// Check **field:** value (bold format)
|
|
425
|
+
const boldPattern = new RegExp(`\\*\\*${fieldEscaped}:\\*\\*\\s*(.*)`, 'i');
|
|
426
|
+
const boldMatch = content.match(boldPattern);
|
|
427
|
+
if (boldMatch) {
|
|
428
|
+
output({ [section]: boldMatch[1].trim() }, raw, boldMatch[1].trim());
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Check field: value (plain format)
|
|
433
|
+
const plainPattern = new RegExp(`^${fieldEscaped}:\\s*(.*)`, 'im');
|
|
434
|
+
const plainMatch = content.match(plainPattern);
|
|
435
|
+
if (plainMatch) {
|
|
436
|
+
output({ [section]: plainMatch[1].trim() }, raw, plainMatch[1].trim());
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Check ## Section
|
|
441
|
+
const sectionPattern = new RegExp(`##\\s*${fieldEscaped}\\s*\n([\\s\\S]*?)(?=\\n##|$)`, 'i');
|
|
442
|
+
const sectionMatch = content.match(sectionPattern);
|
|
443
|
+
if (sectionMatch) {
|
|
444
|
+
output({ [section]: sectionMatch[1].trim() }, raw, sectionMatch[1].trim());
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
output({ error: `Section or field "${section}" not found` }, raw, '');
|
|
449
|
+
} catch {
|
|
450
|
+
error('STATE.md not found');
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function cmdStateUpdate(cwd, field, value, raw) {
|
|
455
|
+
if (!field || value === undefined) {
|
|
456
|
+
error('field and value required for state update');
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const statePath = path.join(cwd, '.plano', 'STATE.md');
|
|
460
|
+
try {
|
|
461
|
+
let content = fs.readFileSync(statePath, 'utf-8');
|
|
462
|
+
const result = stateReplaceField(content, field, value);
|
|
463
|
+
if (result) {
|
|
464
|
+
fs.writeFileSync(statePath, result, 'utf-8');
|
|
465
|
+
output({ updated: true }, raw);
|
|
466
|
+
} else {
|
|
467
|
+
output({ updated: false, reason: `Field "${field}" not found in STATE.md` }, raw);
|
|
468
|
+
}
|
|
469
|
+
} catch {
|
|
470
|
+
output({ updated: false, reason: 'STATE.md not found' }, raw);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function cmdStateAdvancePlan(cwd, raw) {
|
|
475
|
+
const statePath = path.join(cwd, '.plano', 'STATE.md');
|
|
476
|
+
if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; }
|
|
477
|
+
|
|
478
|
+
let content = fs.readFileSync(statePath, 'utf-8');
|
|
479
|
+
const currentPlan = parseInt(stateExtractField(content, 'Current Plan'), 10);
|
|
480
|
+
const totalPlans = parseInt(stateExtractField(content, 'Total Plans in Phase'), 10);
|
|
481
|
+
const today = new Date().toISOString().split('T')[0];
|
|
482
|
+
|
|
483
|
+
if (isNaN(currentPlan) || isNaN(totalPlans)) {
|
|
484
|
+
output({ error: 'Cannot parse Current Plan or Total Plans in Phase from STATE.md' }, raw);
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
if (currentPlan >= totalPlans) {
|
|
489
|
+
content = stateReplaceField(content, 'Status', 'Phase complete') || content;
|
|
490
|
+
content = stateReplaceField(content, 'Last Activity', today) || content;
|
|
491
|
+
fs.writeFileSync(statePath, content, 'utf-8');
|
|
492
|
+
output({ advanced: false, reason: 'last_plan', current_plan: currentPlan, total_plans: totalPlans }, raw, 'false');
|
|
493
|
+
} else {
|
|
494
|
+
const newPlan = currentPlan + 1;
|
|
495
|
+
content = stateReplaceField(content, 'Current Plan', String(newPlan)) || content;
|
|
496
|
+
content = stateReplaceField(content, 'Status', 'Ready to execute') || content;
|
|
497
|
+
content = stateReplaceField(content, 'Last Activity', today) || content;
|
|
498
|
+
fs.writeFileSync(statePath, content, 'utf-8');
|
|
499
|
+
output({ advanced: true, previous_plan: currentPlan, current_plan: newPlan, total_plans: totalPlans }, raw, 'true');
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function cmdStateUpdateProgress(cwd, raw) {
|
|
504
|
+
const statePath = path.join(cwd, '.plano', 'STATE.md');
|
|
505
|
+
if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; }
|
|
506
|
+
|
|
507
|
+
let content = fs.readFileSync(statePath, 'utf-8');
|
|
508
|
+
|
|
509
|
+
const phasesDir = path.join(cwd, '.plano', 'fases');
|
|
510
|
+
let totalPlans = 0;
|
|
511
|
+
let totalSummaries = 0;
|
|
512
|
+
|
|
513
|
+
if (fs.existsSync(phasesDir)) {
|
|
514
|
+
const phaseDirs = fs.readdirSync(phasesDir, { withFileTypes: true })
|
|
515
|
+
.filter(e => e.isDirectory()).map(e => e.name);
|
|
516
|
+
for (const dir of phaseDirs) {
|
|
517
|
+
const files = fs.readdirSync(path.join(phasesDir, dir));
|
|
518
|
+
totalPlans += files.filter(f => f.match(/-PLAN\.md$/i)).length;
|
|
519
|
+
totalSummaries += files.filter(f => f.match(/-SUMMARY\.md$/i)).length;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const percent = totalPlans > 0 ? Math.min(100, Math.round(totalSummaries / totalPlans * 100)) : 0;
|
|
524
|
+
const barWidth = 10;
|
|
525
|
+
const filled = Math.round(percent / 100 * barWidth);
|
|
526
|
+
const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(barWidth - filled);
|
|
527
|
+
const progressStr = `[${bar}] ${percent}%`;
|
|
528
|
+
|
|
529
|
+
const boldProgressPattern = /(\*\*Progress:\*\*\s*).*/i;
|
|
530
|
+
const plainProgressPattern = /^(Progress:\s*).*/im;
|
|
531
|
+
if (boldProgressPattern.test(content)) {
|
|
532
|
+
content = content.replace(boldProgressPattern, (_match, prefix) => `${prefix}${progressStr}`);
|
|
533
|
+
fs.writeFileSync(statePath, content, 'utf-8');
|
|
534
|
+
output({ updated: true, percent, completed: totalSummaries, total: totalPlans, bar: progressStr }, raw, progressStr);
|
|
535
|
+
} else if (plainProgressPattern.test(content)) {
|
|
536
|
+
content = content.replace(plainProgressPattern, (_match, prefix) => `${prefix}${progressStr}`);
|
|
537
|
+
fs.writeFileSync(statePath, content, 'utf-8');
|
|
538
|
+
output({ updated: true, percent, completed: totalSummaries, total: totalPlans, bar: progressStr }, raw, progressStr);
|
|
539
|
+
} else {
|
|
540
|
+
output({ updated: false, reason: 'Progress field not found in STATE.md' }, raw, 'false');
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function cmdStateAddDecision(cwd, options, raw) {
|
|
545
|
+
const statePath = path.join(cwd, '.plano', 'STATE.md');
|
|
546
|
+
if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; }
|
|
547
|
+
|
|
548
|
+
const { phase, summary } = options;
|
|
549
|
+
if (!summary) { output({ error: 'summary required' }, raw); return; }
|
|
550
|
+
|
|
551
|
+
let content = fs.readFileSync(statePath, 'utf-8');
|
|
552
|
+
const entry = `- [Phase ${phase || '?'}]: ${summary}`;
|
|
553
|
+
|
|
554
|
+
const sectionPattern = /(###?\s*(?:Decisions|Decisions Made|Accumulated.*Decisions)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
|
|
555
|
+
const match = content.match(sectionPattern);
|
|
556
|
+
|
|
557
|
+
if (match) {
|
|
558
|
+
let sectionBody = match[2];
|
|
559
|
+
sectionBody = sectionBody.replace(/None yet\.?\s*\n?/gi, '').replace(/No decisions yet\.?\s*\n?/gi, '');
|
|
560
|
+
sectionBody = sectionBody.trimEnd() + '\n' + entry + '\n';
|
|
561
|
+
content = content.replace(sectionPattern, (_match, header) => `${header}${sectionBody}`);
|
|
562
|
+
fs.writeFileSync(statePath, content, 'utf-8');
|
|
563
|
+
output({ added: true, decision: entry }, raw, 'true');
|
|
564
|
+
} else {
|
|
565
|
+
output({ added: false, reason: 'Decisions section not found in STATE.md' }, raw, 'false');
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function cmdStateRecordSession(cwd, options, raw) {
|
|
570
|
+
const statePath = path.join(cwd, '.plano', 'STATE.md');
|
|
571
|
+
if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; }
|
|
572
|
+
|
|
573
|
+
let content = fs.readFileSync(statePath, 'utf-8');
|
|
574
|
+
const now = new Date().toISOString();
|
|
575
|
+
const updated = [];
|
|
576
|
+
|
|
577
|
+
let result = stateReplaceField(content, 'Last session', now);
|
|
578
|
+
if (result) { content = result; updated.push('Last session'); }
|
|
579
|
+
result = stateReplaceField(content, 'Last Date', now);
|
|
580
|
+
if (result) { content = result; updated.push('Last Date'); }
|
|
581
|
+
|
|
582
|
+
if (options.stopped_at) {
|
|
583
|
+
result = stateReplaceField(content, 'Stopped At', options.stopped_at);
|
|
584
|
+
if (!result) result = stateReplaceField(content, 'Stopped at', options.stopped_at);
|
|
585
|
+
if (result) { content = result; updated.push('Stopped At'); }
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
if (updated.length > 0) {
|
|
589
|
+
fs.writeFileSync(statePath, content, 'utf-8');
|
|
590
|
+
output({ recorded: true, updated }, raw, 'true');
|
|
591
|
+
} else {
|
|
592
|
+
output({ recorded: false, reason: 'No session fields found in STATE.md' }, raw, 'false');
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// =====================================================================
|
|
597
|
+
// ROADMAP COMMANDS
|
|
598
|
+
// =====================================================================
|
|
599
|
+
|
|
600
|
+
function cmdRoadmapGetPhase(cwd, phaseNum, raw) {
|
|
601
|
+
const roadmapPath = path.join(cwd, '.plano', 'ROADMAP.md');
|
|
602
|
+
|
|
603
|
+
if (!fs.existsSync(roadmapPath)) {
|
|
604
|
+
output({ found: false, error: 'ROADMAP.md not found' }, raw, '');
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
try {
|
|
609
|
+
const content = fs.readFileSync(roadmapPath, 'utf-8');
|
|
610
|
+
const escapedPhase = escapeRegex(phaseNum);
|
|
611
|
+
|
|
612
|
+
const phasePattern = new RegExp(`#{2,4}\\s*Phase\\s+${escapedPhase}:\\s*([^\\n]+)`, 'i');
|
|
613
|
+
const headerMatch = content.match(phasePattern);
|
|
614
|
+
|
|
615
|
+
if (!headerMatch) {
|
|
616
|
+
output({ found: false, phase_number: phaseNum }, raw, '');
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const phaseName = headerMatch[1].trim();
|
|
621
|
+
const headerIndex = headerMatch.index;
|
|
622
|
+
const restOfContent = content.slice(headerIndex);
|
|
623
|
+
const nextHeaderMatch = restOfContent.match(/\n#{2,4}\s+Phase\s+\d/i);
|
|
624
|
+
const sectionEnd = nextHeaderMatch ? headerIndex + nextHeaderMatch.index : content.length;
|
|
625
|
+
const section = content.slice(headerIndex, sectionEnd).trim();
|
|
626
|
+
|
|
627
|
+
const goalMatch = section.match(/\*\*Goal:\*\*\s*([^\n]+)/i);
|
|
628
|
+
const goal = goalMatch ? goalMatch[1].trim() : null;
|
|
629
|
+
|
|
630
|
+
output({ found: true, phase_number: phaseNum, phase_name: phaseName, goal, section }, raw, section);
|
|
631
|
+
} catch (e) {
|
|
632
|
+
error('Failed to read ROADMAP.md: ' + e.message);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function cmdRoadmapAnalyze(cwd, raw) {
|
|
637
|
+
const roadmapPath = path.join(cwd, '.plano', 'ROADMAP.md');
|
|
638
|
+
|
|
639
|
+
if (!fs.existsSync(roadmapPath)) {
|
|
640
|
+
output({ error: 'ROADMAP.md not found', phases: [], current_phase: null }, raw);
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const content = fs.readFileSync(roadmapPath, 'utf-8');
|
|
645
|
+
const phasesDir = path.join(cwd, '.plano', 'fases');
|
|
646
|
+
|
|
647
|
+
const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi;
|
|
648
|
+
const phases = [];
|
|
649
|
+
let match;
|
|
650
|
+
|
|
651
|
+
while ((match = phasePattern.exec(content)) !== null) {
|
|
652
|
+
const phaseNum = match[1];
|
|
653
|
+
const phaseName = match[2].replace(/\(INSERTED\)/i, '').trim();
|
|
654
|
+
|
|
655
|
+
const sectionStart = match.index;
|
|
656
|
+
const restOfContent = content.slice(sectionStart);
|
|
657
|
+
const nextHeader = restOfContent.match(/\n#{2,4}\s+Phase\s+\d/i);
|
|
658
|
+
const sectionEnd = nextHeader ? sectionStart + nextHeader.index : content.length;
|
|
659
|
+
const section = content.slice(sectionStart, sectionEnd);
|
|
660
|
+
|
|
661
|
+
const goalMatch = section.match(/\*\*Goal:\*\*\s*([^\n]+)/i);
|
|
662
|
+
const goal = goalMatch ? goalMatch[1].trim() : null;
|
|
663
|
+
|
|
664
|
+
const normalized = normalizePhaseName(phaseNum);
|
|
665
|
+
let diskStatus = 'no_directory';
|
|
666
|
+
let planCount = 0;
|
|
667
|
+
let summaryCount = 0;
|
|
668
|
+
|
|
669
|
+
try {
|
|
670
|
+
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
|
|
671
|
+
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
|
|
672
|
+
const dirMatch = dirs.find(d => d.startsWith(normalized + '-') || d === normalized);
|
|
673
|
+
|
|
674
|
+
if (dirMatch) {
|
|
675
|
+
const phaseFiles = fs.readdirSync(path.join(phasesDir, dirMatch));
|
|
676
|
+
planCount = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').length;
|
|
677
|
+
summaryCount = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md').length;
|
|
678
|
+
|
|
679
|
+
if (summaryCount >= planCount && planCount > 0) diskStatus = 'complete';
|
|
680
|
+
else if (summaryCount > 0) diskStatus = 'partial';
|
|
681
|
+
else if (planCount > 0) diskStatus = 'planned';
|
|
682
|
+
else diskStatus = 'empty';
|
|
683
|
+
}
|
|
684
|
+
} catch {}
|
|
685
|
+
|
|
686
|
+
const checkboxPattern = new RegExp(`-\\s*\\[(x| )\\]\\s*.*Phase\\s+${escapeRegex(phaseNum)}`, 'i');
|
|
687
|
+
const checkboxMatch = content.match(checkboxPattern);
|
|
688
|
+
const roadmapComplete = checkboxMatch ? checkboxMatch[1] === 'x' : false;
|
|
689
|
+
|
|
690
|
+
phases.push({
|
|
691
|
+
number: phaseNum,
|
|
692
|
+
name: phaseName,
|
|
693
|
+
goal,
|
|
694
|
+
plan_count: planCount,
|
|
695
|
+
summary_count: summaryCount,
|
|
696
|
+
disk_status: diskStatus,
|
|
697
|
+
roadmap_complete: roadmapComplete,
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
const currentPhase = phases.find(p => p.disk_status === 'planned' || p.disk_status === 'partial') || null;
|
|
702
|
+
const nextPhase = phases.find(p => p.disk_status === 'empty' || p.disk_status === 'no_directory') || null;
|
|
703
|
+
|
|
704
|
+
const totalPlans = phases.reduce((sum, p) => sum + p.plan_count, 0);
|
|
705
|
+
const totalSummaries = phases.reduce((sum, p) => sum + p.summary_count, 0);
|
|
706
|
+
const completedPhases = phases.filter(p => p.disk_status === 'complete').length;
|
|
707
|
+
|
|
708
|
+
output({
|
|
709
|
+
phases,
|
|
710
|
+
phase_count: phases.length,
|
|
711
|
+
completed_phases: completedPhases,
|
|
712
|
+
total_plans: totalPlans,
|
|
713
|
+
total_summaries: totalSummaries,
|
|
714
|
+
progress_percent: totalPlans > 0 ? Math.min(100, Math.round((totalSummaries / totalPlans) * 100)) : 0,
|
|
715
|
+
current_phase: currentPhase ? currentPhase.number : null,
|
|
716
|
+
next_phase: nextPhase ? nextPhase.number : null,
|
|
717
|
+
}, raw);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
|
|
721
|
+
if (!phaseNum) error('phase number required for roadmap update-plan-progress');
|
|
722
|
+
|
|
723
|
+
const roadmapPath = path.join(cwd, '.plano', 'ROADMAP.md');
|
|
724
|
+
const phaseInfo = findPhaseInternal(cwd, phaseNum);
|
|
725
|
+
if (!phaseInfo) error(`Phase ${phaseNum} not found`);
|
|
726
|
+
|
|
727
|
+
const planCount = phaseInfo.plans.length;
|
|
728
|
+
const summaryCount = phaseInfo.summaries.length;
|
|
729
|
+
|
|
730
|
+
if (planCount === 0) {
|
|
731
|
+
output({ updated: false, reason: 'No plans found', plan_count: 0, summary_count: 0 }, raw, 'no plans');
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
const isComplete = summaryCount >= planCount;
|
|
736
|
+
const status = isComplete ? 'Complete' : summaryCount > 0 ? 'In Progress' : 'Planned';
|
|
737
|
+
const today = new Date().toISOString().split('T')[0];
|
|
738
|
+
|
|
739
|
+
if (!fs.existsSync(roadmapPath)) {
|
|
740
|
+
output({ updated: false, reason: 'ROADMAP.md not found' }, raw, 'no roadmap');
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
let roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
|
|
745
|
+
const phaseEscaped = escapeRegex(phaseNum);
|
|
746
|
+
|
|
747
|
+
// Progress table row
|
|
748
|
+
const tablePattern = new RegExp(
|
|
749
|
+
`(\\|\\s*${phaseEscaped}\\.?\\s[^|]*\\|)[^|]*(\\|)\\s*[^|]*(\\|)\\s*[^|]*(\\|)`,
|
|
750
|
+
'i'
|
|
751
|
+
);
|
|
752
|
+
const dateField = isComplete ? ` ${today} ` : ' ';
|
|
753
|
+
roadmapContent = roadmapContent.replace(
|
|
754
|
+
tablePattern,
|
|
755
|
+
`$1 ${summaryCount}/${planCount} $2 ${status.padEnd(11)}$3${dateField}$4`
|
|
756
|
+
);
|
|
757
|
+
|
|
758
|
+
// Update plan count in phase detail section
|
|
759
|
+
const planCountPattern = new RegExp(
|
|
760
|
+
`(#{2,4}\\s*Phase\\s+${phaseEscaped}[\\s\\S]*?\\*\\*Plans:\\*\\*\\s*)[^\\n]+`,
|
|
761
|
+
'i'
|
|
762
|
+
);
|
|
763
|
+
const planCountText = isComplete
|
|
764
|
+
? `${summaryCount}/${planCount} plans complete`
|
|
765
|
+
: `${summaryCount}/${planCount} plans executed`;
|
|
766
|
+
roadmapContent = roadmapContent.replace(planCountPattern, `$1${planCountText}`);
|
|
767
|
+
|
|
768
|
+
if (isComplete) {
|
|
769
|
+
const checkboxPattern = new RegExp(
|
|
770
|
+
`(-\\s*\\[)[ ](\\]\\s*.*Phase\\s+${phaseEscaped}[:\\s][^\\n]*)`,
|
|
771
|
+
'i'
|
|
772
|
+
);
|
|
773
|
+
roadmapContent = roadmapContent.replace(checkboxPattern, `$1x$2 (completed ${today})`);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
fs.writeFileSync(roadmapPath, roadmapContent, 'utf-8');
|
|
777
|
+
|
|
778
|
+
output({
|
|
779
|
+
updated: true,
|
|
780
|
+
phase: phaseNum,
|
|
781
|
+
plan_count: planCount,
|
|
782
|
+
summary_count: summaryCount,
|
|
783
|
+
status,
|
|
784
|
+
complete: isComplete,
|
|
785
|
+
}, raw, `${summaryCount}/${planCount} ${status}`);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// =====================================================================
|
|
789
|
+
// PHASE COMMANDS
|
|
790
|
+
// =====================================================================
|
|
791
|
+
|
|
792
|
+
function cmdPhaseFind(cwd, phase, raw) {
|
|
793
|
+
if (!phase) error('phase identifier required');
|
|
794
|
+
|
|
795
|
+
const phasesDir = path.join(cwd, '.plano', 'fases');
|
|
796
|
+
const normalized = normalizePhaseName(phase);
|
|
797
|
+
const notFound = { found: false, directory: null, phase_number: null, phase_name: null, plans: [], summaries: [] };
|
|
798
|
+
|
|
799
|
+
try {
|
|
800
|
+
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
|
|
801
|
+
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort((a, b) => comparePhaseNum(a, b));
|
|
802
|
+
const match = dirs.find(d => d.startsWith(normalized));
|
|
803
|
+
if (!match) { output(notFound, raw, ''); return; }
|
|
804
|
+
|
|
805
|
+
const dirMatch = match.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i);
|
|
806
|
+
const phaseNumber = dirMatch ? dirMatch[1] : normalized;
|
|
807
|
+
const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null;
|
|
808
|
+
|
|
809
|
+
const phaseDir = path.join(phasesDir, match);
|
|
810
|
+
const phaseFiles = fs.readdirSync(phaseDir);
|
|
811
|
+
const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').sort();
|
|
812
|
+
const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md').sort();
|
|
813
|
+
|
|
814
|
+
output({
|
|
815
|
+
found: true,
|
|
816
|
+
directory: toPosixPath(path.join('.plano', 'fases', match)),
|
|
817
|
+
phase_number: phaseNumber,
|
|
818
|
+
phase_name: phaseName,
|
|
819
|
+
plans,
|
|
820
|
+
summaries,
|
|
821
|
+
}, raw, toPosixPath(path.join('.plano', 'fases', match)));
|
|
822
|
+
} catch {
|
|
823
|
+
output(notFound, raw, '');
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
function cmdPhaseAdd(cwd, description, raw) {
|
|
828
|
+
if (!description) error('description required for phase add');
|
|
829
|
+
|
|
830
|
+
const roadmapPath = path.join(cwd, '.plano', 'ROADMAP.md');
|
|
831
|
+
if (!fs.existsSync(roadmapPath)) error('ROADMAP.md not found');
|
|
832
|
+
|
|
833
|
+
const content = fs.readFileSync(roadmapPath, 'utf-8');
|
|
834
|
+
const slug = generateSlugInternal(description);
|
|
835
|
+
|
|
836
|
+
const phasePattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*:/gi;
|
|
837
|
+
let maxPhase = 0;
|
|
838
|
+
let m;
|
|
839
|
+
while ((m = phasePattern.exec(content)) !== null) {
|
|
840
|
+
const num = parseInt(m[1], 10);
|
|
841
|
+
if (num > maxPhase) maxPhase = num;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
const newPhaseNum = maxPhase + 1;
|
|
845
|
+
const paddedNum = String(newPhaseNum).padStart(2, '0');
|
|
846
|
+
const dirName = `${paddedNum}-${slug}`;
|
|
847
|
+
const dirPath = path.join(cwd, '.plano', 'fases', dirName);
|
|
848
|
+
|
|
849
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
850
|
+
fs.writeFileSync(path.join(dirPath, '.gitkeep'), '');
|
|
851
|
+
|
|
852
|
+
const phaseEntry = `\n### Phase ${newPhaseNum}: ${description}\n\n**Goal:** [To be planned]\n**Requirements**: TBD\n**Depends on:** Phase ${maxPhase}\n**Plans:** 0 plans\n`;
|
|
853
|
+
|
|
854
|
+
let updatedContent;
|
|
855
|
+
const lastSeparator = content.lastIndexOf('\n---');
|
|
856
|
+
if (lastSeparator > 0) {
|
|
857
|
+
updatedContent = content.slice(0, lastSeparator) + phaseEntry + content.slice(lastSeparator);
|
|
858
|
+
} else {
|
|
859
|
+
updatedContent = content + phaseEntry;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
fs.writeFileSync(roadmapPath, updatedContent, 'utf-8');
|
|
863
|
+
|
|
864
|
+
output({
|
|
865
|
+
phase_number: newPhaseNum,
|
|
866
|
+
padded: paddedNum,
|
|
867
|
+
name: description,
|
|
868
|
+
slug,
|
|
869
|
+
directory: `.plano/fases/${dirName}`,
|
|
870
|
+
}, raw, paddedNum);
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
function cmdPhaseRemove(cwd, targetPhase, options, raw) {
|
|
874
|
+
if (!targetPhase) error('phase number required for phase remove');
|
|
875
|
+
|
|
876
|
+
const roadmapPath = path.join(cwd, '.plano', 'ROADMAP.md');
|
|
877
|
+
const phasesDir = path.join(cwd, '.plano', 'fases');
|
|
878
|
+
const force = options.force || false;
|
|
879
|
+
|
|
880
|
+
if (!fs.existsSync(roadmapPath)) error('ROADMAP.md not found');
|
|
881
|
+
|
|
882
|
+
const normalized = normalizePhaseName(targetPhase);
|
|
883
|
+
const isDecimal = targetPhase.includes('.');
|
|
884
|
+
|
|
885
|
+
let targetDir = null;
|
|
886
|
+
try {
|
|
887
|
+
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
|
|
888
|
+
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort((a, b) => comparePhaseNum(a, b));
|
|
889
|
+
targetDir = dirs.find(d => d.startsWith(normalized + '-') || d === normalized);
|
|
890
|
+
} catch {}
|
|
891
|
+
|
|
892
|
+
// Check for executed work
|
|
893
|
+
if (targetDir && !force) {
|
|
894
|
+
const targetPath = path.join(phasesDir, targetDir);
|
|
895
|
+
const files = fs.readdirSync(targetPath);
|
|
896
|
+
const summaries = files.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
|
|
897
|
+
if (summaries.length > 0) {
|
|
898
|
+
error(`Phase ${targetPhase} has ${summaries.length} executed plan(s). Use --force to remove anyway.`);
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
// Delete target directory
|
|
903
|
+
if (targetDir) {
|
|
904
|
+
fs.rmSync(path.join(phasesDir, targetDir), { recursive: true, force: true });
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// Renumber subsequent phases
|
|
908
|
+
const renamedDirs = [];
|
|
909
|
+
|
|
910
|
+
if (!isDecimal) {
|
|
911
|
+
const removedInt = parseInt(normalized, 10);
|
|
912
|
+
try {
|
|
913
|
+
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
|
|
914
|
+
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort((a, b) => comparePhaseNum(a, b));
|
|
915
|
+
|
|
916
|
+
const toRename = [];
|
|
917
|
+
for (const dir of dirs) {
|
|
918
|
+
const dm = dir.match(/^(\d+)([A-Z])?(?:\.(\d+))?-(.+)$/i);
|
|
919
|
+
if (!dm) continue;
|
|
920
|
+
const dirInt = parseInt(dm[1], 10);
|
|
921
|
+
if (dirInt > removedInt) {
|
|
922
|
+
toRename.push({ dir, oldInt: dirInt, letter: dm[2] ? dm[2].toUpperCase() : '', decimal: dm[3] ? parseInt(dm[3], 10) : null, slug: dm[4] });
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
toRename.sort((a, b) => {
|
|
927
|
+
if (a.oldInt !== b.oldInt) return b.oldInt - a.oldInt;
|
|
928
|
+
return (b.decimal || 0) - (a.decimal || 0);
|
|
929
|
+
});
|
|
930
|
+
|
|
931
|
+
for (const item of toRename) {
|
|
932
|
+
const newInt = item.oldInt - 1;
|
|
933
|
+
const newPadded = String(newInt).padStart(2, '0');
|
|
934
|
+
const letterSuffix = item.letter || '';
|
|
935
|
+
const decimalSuffix = item.decimal !== null ? `.${item.decimal}` : '';
|
|
936
|
+
const newPrefix = `${newPadded}${letterSuffix}${decimalSuffix}`;
|
|
937
|
+
const newDirName = `${newPrefix}-${item.slug}`;
|
|
938
|
+
|
|
939
|
+
fs.renameSync(path.join(phasesDir, item.dir), path.join(phasesDir, newDirName));
|
|
940
|
+
renamedDirs.push({ from: item.dir, to: newDirName });
|
|
941
|
+
}
|
|
942
|
+
} catch {}
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
// Update ROADMAP.md
|
|
946
|
+
let roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
|
|
947
|
+
|
|
948
|
+
const targetEscaped = escapeRegex(targetPhase);
|
|
949
|
+
const sectionPattern = new RegExp(
|
|
950
|
+
`\\n?#{2,4}\\s*Phase\\s+${targetEscaped}\\s*:[\\s\\S]*?(?=\\n#{2,4}\\s+Phase\\s+\\d|$)`,
|
|
951
|
+
'i'
|
|
952
|
+
);
|
|
953
|
+
roadmapContent = roadmapContent.replace(sectionPattern, '');
|
|
954
|
+
|
|
955
|
+
const checkboxPattern = new RegExp(`\\n?-\\s*\\[[ x]\\]\\s*.*Phase\\s+${targetEscaped}[:\\s][^\\n]*`, 'gi');
|
|
956
|
+
roadmapContent = roadmapContent.replace(checkboxPattern, '');
|
|
957
|
+
|
|
958
|
+
if (!isDecimal) {
|
|
959
|
+
const removedInt = parseInt(normalized, 10);
|
|
960
|
+
for (let oldNum = 99; oldNum > removedInt; oldNum--) {
|
|
961
|
+
const newNum = oldNum - 1;
|
|
962
|
+
const oldStr = String(oldNum);
|
|
963
|
+
const newStr = String(newNum);
|
|
964
|
+
|
|
965
|
+
roadmapContent = roadmapContent.replace(
|
|
966
|
+
new RegExp(`(#{2,4}\\s*Phase\\s+)${oldStr}(\\s*:)`, 'gi'),
|
|
967
|
+
`$1${newStr}$2`
|
|
968
|
+
);
|
|
969
|
+
roadmapContent = roadmapContent.replace(
|
|
970
|
+
new RegExp(`(Phase\\s+)${oldStr}([:\\s])`, 'g'),
|
|
971
|
+
`$1${newStr}$2`
|
|
972
|
+
);
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
fs.writeFileSync(roadmapPath, roadmapContent, 'utf-8');
|
|
977
|
+
|
|
978
|
+
// Update STATE.md phase count
|
|
979
|
+
const statePath = path.join(cwd, '.plano', 'STATE.md');
|
|
980
|
+
if (fs.existsSync(statePath)) {
|
|
981
|
+
let stateContent = fs.readFileSync(statePath, 'utf-8');
|
|
982
|
+
const totalPattern = /(\*\*Total Phases:\*\*\s*)(\d+)/;
|
|
983
|
+
const totalMatch = stateContent.match(totalPattern);
|
|
984
|
+
if (totalMatch) {
|
|
985
|
+
const oldTotal = parseInt(totalMatch[2], 10);
|
|
986
|
+
stateContent = stateContent.replace(totalPattern, `$1${oldTotal - 1}`);
|
|
987
|
+
fs.writeFileSync(statePath, stateContent, 'utf-8');
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
output({
|
|
992
|
+
removed: targetPhase,
|
|
993
|
+
directory_deleted: targetDir || null,
|
|
994
|
+
renamed_directories: renamedDirs,
|
|
995
|
+
roadmap_updated: true,
|
|
996
|
+
}, raw);
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
1000
|
+
if (!phaseNum) error('phase number required for phase complete');
|
|
1001
|
+
|
|
1002
|
+
const roadmapPath = path.join(cwd, '.plano', 'ROADMAP.md');
|
|
1003
|
+
const statePath = path.join(cwd, '.plano', 'STATE.md');
|
|
1004
|
+
const phasesDir = path.join(cwd, '.plano', 'fases');
|
|
1005
|
+
const today = new Date().toISOString().split('T')[0];
|
|
1006
|
+
|
|
1007
|
+
const phaseInfo = findPhaseInternal(cwd, phaseNum);
|
|
1008
|
+
if (!phaseInfo) error(`Phase ${phaseNum} not found`);
|
|
1009
|
+
|
|
1010
|
+
const planCount = phaseInfo.plans.length;
|
|
1011
|
+
const summaryCount = phaseInfo.summaries.length;
|
|
1012
|
+
|
|
1013
|
+
// Update ROADMAP.md
|
|
1014
|
+
if (fs.existsSync(roadmapPath)) {
|
|
1015
|
+
let roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
|
|
1016
|
+
const phaseEscaped = escapeRegex(phaseNum);
|
|
1017
|
+
|
|
1018
|
+
const checkboxPattern = new RegExp(
|
|
1019
|
+
`(-\\s*\\[)[ ](\\]\\s*.*Phase\\s+${phaseEscaped}[:\\s][^\\n]*)`,
|
|
1020
|
+
'i'
|
|
1021
|
+
);
|
|
1022
|
+
roadmapContent = roadmapContent.replace(checkboxPattern, `$1x$2 (completed ${today})`);
|
|
1023
|
+
|
|
1024
|
+
const planCountPattern = new RegExp(
|
|
1025
|
+
`(#{2,4}\\s*Phase\\s+${phaseEscaped}[\\s\\S]*?\\*\\*Plans:\\*\\*\\s*)[^\\n]+`,
|
|
1026
|
+
'i'
|
|
1027
|
+
);
|
|
1028
|
+
roadmapContent = roadmapContent.replace(planCountPattern, `$1${summaryCount}/${planCount} plans complete`);
|
|
1029
|
+
|
|
1030
|
+
fs.writeFileSync(roadmapPath, roadmapContent, 'utf-8');
|
|
1031
|
+
|
|
1032
|
+
// Update REQUIREMENTS.md
|
|
1033
|
+
const reqPath = path.join(cwd, '.plano', 'REQUIREMENTS.md');
|
|
1034
|
+
if (fs.existsSync(reqPath)) {
|
|
1035
|
+
const reqMatch = roadmapContent.match(
|
|
1036
|
+
new RegExp(`Phase\\s+${escapeRegex(phaseNum)}[\\s\\S]*?\\*\\*Requirements:\\*\\*\\s*([^\\n]+)`, 'i')
|
|
1037
|
+
);
|
|
1038
|
+
if (reqMatch) {
|
|
1039
|
+
const reqIds = reqMatch[1].replace(/[\[\]]/g, '').split(/[,\s]+/).map(r => r.trim()).filter(Boolean);
|
|
1040
|
+
let reqContent = fs.readFileSync(reqPath, 'utf-8');
|
|
1041
|
+
for (const reqId of reqIds) {
|
|
1042
|
+
const reqEscaped = escapeRegex(reqId);
|
|
1043
|
+
reqContent = reqContent.replace(
|
|
1044
|
+
new RegExp(`(-\\s*\\[)[ ](\\]\\s*\\*\\*${reqEscaped}\\*\\*)`, 'gi'),
|
|
1045
|
+
'$1x$2'
|
|
1046
|
+
);
|
|
1047
|
+
}
|
|
1048
|
+
fs.writeFileSync(reqPath, reqContent, 'utf-8');
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
// Find next phase
|
|
1054
|
+
let nextPhaseNum = null;
|
|
1055
|
+
let nextPhaseName = null;
|
|
1056
|
+
let isLastPhase = true;
|
|
1057
|
+
|
|
1058
|
+
try {
|
|
1059
|
+
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
|
|
1060
|
+
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name)
|
|
1061
|
+
.sort((a, b) => comparePhaseNum(a, b));
|
|
1062
|
+
|
|
1063
|
+
for (const dir of dirs) {
|
|
1064
|
+
const dm = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i);
|
|
1065
|
+
if (dm && comparePhaseNum(dm[1], phaseNum) > 0) {
|
|
1066
|
+
nextPhaseNum = dm[1];
|
|
1067
|
+
nextPhaseName = dm[2] || null;
|
|
1068
|
+
isLastPhase = false;
|
|
1069
|
+
break;
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
} catch {}
|
|
1073
|
+
|
|
1074
|
+
// Fallback to ROADMAP.md for next phase
|
|
1075
|
+
if (isLastPhase && fs.existsSync(roadmapPath)) {
|
|
1076
|
+
try {
|
|
1077
|
+
const roadmapForPhases = fs.readFileSync(roadmapPath, 'utf-8');
|
|
1078
|
+
const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi;
|
|
1079
|
+
let pm;
|
|
1080
|
+
while ((pm = phasePattern.exec(roadmapForPhases)) !== null) {
|
|
1081
|
+
if (comparePhaseNum(pm[1], phaseNum) > 0) {
|
|
1082
|
+
nextPhaseNum = pm[1];
|
|
1083
|
+
nextPhaseName = pm[2].replace(/\(INSERTED\)/i, '').trim();
|
|
1084
|
+
isLastPhase = false;
|
|
1085
|
+
break;
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
} catch {}
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
// Update STATE.md
|
|
1092
|
+
if (fs.existsSync(statePath)) {
|
|
1093
|
+
let stateContent = fs.readFileSync(statePath, 'utf-8');
|
|
1094
|
+
|
|
1095
|
+
stateContent = stateContent.replace(/(\*\*Current Phase:\*\*\s*).*/, `$1${nextPhaseNum || phaseNum}`);
|
|
1096
|
+
if (nextPhaseName) {
|
|
1097
|
+
stateContent = stateContent.replace(/(\*\*Current Phase Name:\*\*\s*).*/, `$1${nextPhaseName.replace(/-/g, ' ')}`);
|
|
1098
|
+
}
|
|
1099
|
+
stateContent = stateContent.replace(/(\*\*Status:\*\*\s*).*/, `$1${isLastPhase ? 'Project complete' : 'Ready to plan'}`);
|
|
1100
|
+
stateContent = stateContent.replace(/(\*\*Current Plan:\*\*\s*).*/, '$1Not started');
|
|
1101
|
+
stateContent = stateContent.replace(/(\*\*Last Activity:\*\*\s*).*/, `$1${today}`);
|
|
1102
|
+
|
|
1103
|
+
fs.writeFileSync(statePath, stateContent, 'utf-8');
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
output({
|
|
1107
|
+
completed_phase: phaseNum,
|
|
1108
|
+
phase_name: phaseInfo.phase_name,
|
|
1109
|
+
plans_executed: `${summaryCount}/${planCount}`,
|
|
1110
|
+
next_phase: nextPhaseNum,
|
|
1111
|
+
next_phase_name: nextPhaseName,
|
|
1112
|
+
is_last_phase: isLastPhase,
|
|
1113
|
+
date: today,
|
|
1114
|
+
roadmap_updated: fs.existsSync(roadmapPath),
|
|
1115
|
+
state_updated: fs.existsSync(statePath),
|
|
1116
|
+
}, raw);
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
// =====================================================================
|
|
1120
|
+
// CONFIG COMMANDS
|
|
1121
|
+
// =====================================================================
|
|
1122
|
+
|
|
1123
|
+
function cmdConfigGet(cwd, keyPath, raw) {
|
|
1124
|
+
const configPath = path.join(cwd, '.plano', 'config.json');
|
|
1125
|
+
|
|
1126
|
+
if (!keyPath) error('Usage: config get <key.path>');
|
|
1127
|
+
|
|
1128
|
+
let config = {};
|
|
1129
|
+
try {
|
|
1130
|
+
if (fs.existsSync(configPath)) {
|
|
1131
|
+
config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
1132
|
+
} else {
|
|
1133
|
+
error('No config.json found at ' + configPath);
|
|
1134
|
+
}
|
|
1135
|
+
} catch (err) {
|
|
1136
|
+
if (err.message.startsWith('No config.json')) throw err;
|
|
1137
|
+
error('Failed to read config.json: ' + err.message);
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
const keys = keyPath.split('.');
|
|
1141
|
+
let current = config;
|
|
1142
|
+
for (const key of keys) {
|
|
1143
|
+
if (current === undefined || current === null || typeof current !== 'object') {
|
|
1144
|
+
error(`Key not found: ${keyPath}`);
|
|
1145
|
+
}
|
|
1146
|
+
current = current[key];
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
if (current === undefined) error(`Key not found: ${keyPath}`);
|
|
1150
|
+
|
|
1151
|
+
output(current, raw, String(current));
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
function cmdConfigSet(cwd, keyPath, value, raw) {
|
|
1155
|
+
const configPath = path.join(cwd, '.plano', 'config.json');
|
|
1156
|
+
|
|
1157
|
+
if (!keyPath) error('Usage: config set <key.path> <value>');
|
|
1158
|
+
|
|
1159
|
+
let parsedValue = value;
|
|
1160
|
+
if (value === 'true') parsedValue = true;
|
|
1161
|
+
else if (value === 'false') parsedValue = false;
|
|
1162
|
+
else if (!isNaN(value) && value !== '') parsedValue = Number(value);
|
|
1163
|
+
|
|
1164
|
+
let config = {};
|
|
1165
|
+
try {
|
|
1166
|
+
if (fs.existsSync(configPath)) {
|
|
1167
|
+
config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
1168
|
+
}
|
|
1169
|
+
} catch (err) {
|
|
1170
|
+
error('Failed to read config.json: ' + err.message);
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
const keys = keyPath.split('.');
|
|
1174
|
+
let current = config;
|
|
1175
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
1176
|
+
const key = keys[i];
|
|
1177
|
+
if (current[key] === undefined || typeof current[key] !== 'object') {
|
|
1178
|
+
current[key] = {};
|
|
1179
|
+
}
|
|
1180
|
+
current = current[key];
|
|
1181
|
+
}
|
|
1182
|
+
current[keys[keys.length - 1]] = parsedValue;
|
|
1183
|
+
|
|
1184
|
+
try {
|
|
1185
|
+
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
1186
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
|
|
1187
|
+
output({ updated: true, key: keyPath, value: parsedValue }, raw, `${keyPath}=${parsedValue}`);
|
|
1188
|
+
} catch (err) {
|
|
1189
|
+
error('Failed to write config.json: ' + err.message);
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
// =====================================================================
|
|
1194
|
+
// REQUIREMENTS COMMANDS
|
|
1195
|
+
// =====================================================================
|
|
1196
|
+
|
|
1197
|
+
function cmdRequirementsMarkComplete(cwd, idsArgs, raw) {
|
|
1198
|
+
const reqPath = path.join(cwd, '.plano', 'REQUIREMENTS.md');
|
|
1199
|
+
if (!fs.existsSync(reqPath)) error('REQUIREMENTS.md not found');
|
|
1200
|
+
|
|
1201
|
+
// Parse IDs from various formats: REQ-01,REQ-02 or REQ-01 REQ-02 or [REQ-01, REQ-02]
|
|
1202
|
+
const idsStr = idsArgs.join(' ').replace(/[\[\]]/g, '');
|
|
1203
|
+
const ids = idsStr.split(/[,\s]+/).map(s => s.trim()).filter(Boolean);
|
|
1204
|
+
|
|
1205
|
+
if (ids.length === 0) error('No requirement IDs provided');
|
|
1206
|
+
|
|
1207
|
+
let content = fs.readFileSync(reqPath, 'utf-8');
|
|
1208
|
+
const updated = [];
|
|
1209
|
+
|
|
1210
|
+
for (const id of ids) {
|
|
1211
|
+
const idEscaped = escapeRegex(id);
|
|
1212
|
+
const checkboxPattern = new RegExp(`(-\\s*\\[)[ ](\\]\\s*\\*\\*${idEscaped}\\*\\*)`, 'gi');
|
|
1213
|
+
if (checkboxPattern.test(content)) {
|
|
1214
|
+
content = content.replace(checkboxPattern, '$1x$2');
|
|
1215
|
+
updated.push(id);
|
|
1216
|
+
}
|
|
1217
|
+
// Also update traceability table
|
|
1218
|
+
const tablePattern = new RegExp(`(\\|\\s*${idEscaped}\\s*\\|[^|]+\\|)\\s*Pending\\s*(\\|)`, 'gi');
|
|
1219
|
+
content = content.replace(tablePattern, '$1 Complete $2');
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
fs.writeFileSync(reqPath, content, 'utf-8');
|
|
1223
|
+
|
|
1224
|
+
output({ marked: updated, count: updated.length }, raw);
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
// =====================================================================
|
|
1228
|
+
// COMMIT COMMAND
|
|
1229
|
+
// =====================================================================
|
|
1230
|
+
|
|
1231
|
+
function cmdCommit(cwd, message, files, raw) {
|
|
1232
|
+
if (!message) error('commit message required');
|
|
1233
|
+
|
|
1234
|
+
const config = loadConfig(cwd);
|
|
1235
|
+
|
|
1236
|
+
if (!config.commit_docs) {
|
|
1237
|
+
output({ committed: false, hash: null, reason: 'skipped_commit_docs_false' }, raw, 'skipped');
|
|
1238
|
+
return;
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
if (isGitIgnored(cwd, '.plano')) {
|
|
1242
|
+
output({ committed: false, hash: null, reason: 'skipped_gitignored' }, raw, 'skipped');
|
|
1243
|
+
return;
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
const filesToStage = files && files.length > 0 ? files : ['.plano/'];
|
|
1247
|
+
for (const file of filesToStage) {
|
|
1248
|
+
execGit(cwd, ['add', file]);
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
const commitResult = execGit(cwd, ['commit', '-m', message]);
|
|
1252
|
+
if (commitResult.exitCode !== 0) {
|
|
1253
|
+
if (commitResult.stdout.includes('nothing to commit') || commitResult.stderr.includes('nothing to commit')) {
|
|
1254
|
+
output({ committed: false, hash: null, reason: 'nothing_to_commit' }, raw, 'nothing');
|
|
1255
|
+
return;
|
|
1256
|
+
}
|
|
1257
|
+
output({ committed: false, hash: null, reason: 'commit_failed', error: commitResult.stderr }, raw, 'failed');
|
|
1258
|
+
return;
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
const hashResult = execGit(cwd, ['rev-parse', '--short', 'HEAD']);
|
|
1262
|
+
const hash = hashResult.exitCode === 0 ? hashResult.stdout : null;
|
|
1263
|
+
output({ committed: true, hash, reason: 'committed' }, raw, hash || 'committed');
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
// =====================================================================
|
|
1267
|
+
// PROGRESS COMMAND
|
|
1268
|
+
// =====================================================================
|
|
1269
|
+
|
|
1270
|
+
function cmdProgress(cwd, format, raw) {
|
|
1271
|
+
const phasesDir = path.join(cwd, '.plano', 'fases');
|
|
1272
|
+
const phases = [];
|
|
1273
|
+
let totalPlans = 0;
|
|
1274
|
+
let totalSummaries = 0;
|
|
1275
|
+
|
|
1276
|
+
try {
|
|
1277
|
+
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
|
|
1278
|
+
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort((a, b) => comparePhaseNum(a, b));
|
|
1279
|
+
|
|
1280
|
+
for (const dir of dirs) {
|
|
1281
|
+
const dm = dir.match(/^(\d+(?:\.\d+)*)-?(.*)/);
|
|
1282
|
+
const phaseNum = dm ? dm[1] : dir;
|
|
1283
|
+
const phaseName = dm && dm[2] ? dm[2].replace(/-/g, ' ') : '';
|
|
1284
|
+
const phaseFiles = fs.readdirSync(path.join(phasesDir, dir));
|
|
1285
|
+
const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').length;
|
|
1286
|
+
const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md').length;
|
|
1287
|
+
|
|
1288
|
+
totalPlans += plans;
|
|
1289
|
+
totalSummaries += summaries;
|
|
1290
|
+
|
|
1291
|
+
let status;
|
|
1292
|
+
if (plans === 0) status = 'Pending';
|
|
1293
|
+
else if (summaries >= plans) status = 'Complete';
|
|
1294
|
+
else if (summaries > 0) status = 'In Progress';
|
|
1295
|
+
else status = 'Planned';
|
|
1296
|
+
|
|
1297
|
+
phases.push({ number: phaseNum, name: phaseName, plans, summaries, status });
|
|
1298
|
+
}
|
|
1299
|
+
} catch {}
|
|
1300
|
+
|
|
1301
|
+
const percent = totalPlans > 0 ? Math.min(100, Math.round((totalSummaries / totalPlans) * 100)) : 0;
|
|
1302
|
+
|
|
1303
|
+
if (format === 'table') {
|
|
1304
|
+
const barWidth = 10;
|
|
1305
|
+
const filled = Math.round((percent / 100) * barWidth);
|
|
1306
|
+
const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(barWidth - filled);
|
|
1307
|
+
let out = `**Progress:** [${bar}] ${totalSummaries}/${totalPlans} plans (${percent}%)\n\n`;
|
|
1308
|
+
out += `| Phase | Name | Plans | Status |\n`;
|
|
1309
|
+
out += `|-------|------|-------|--------|\n`;
|
|
1310
|
+
for (const p of phases) {
|
|
1311
|
+
out += `| ${p.number} | ${p.name} | ${p.summaries}/${p.plans} | ${p.status} |\n`;
|
|
1312
|
+
}
|
|
1313
|
+
output({ rendered: out }, raw, out);
|
|
1314
|
+
} else if (format === 'bar') {
|
|
1315
|
+
const barWidth = 20;
|
|
1316
|
+
const filled = Math.round((percent / 100) * barWidth);
|
|
1317
|
+
const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(barWidth - filled);
|
|
1318
|
+
const text = `[${bar}] ${totalSummaries}/${totalPlans} plans (${percent}%)`;
|
|
1319
|
+
output({ bar: text, percent, completed: totalSummaries, total: totalPlans }, raw, text);
|
|
1320
|
+
} else {
|
|
1321
|
+
output({ phases, total_plans: totalPlans, total_summaries: totalSummaries, percent }, raw);
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
// =====================================================================
|
|
1326
|
+
// TIMESTAMP COMMAND
|
|
1327
|
+
// =====================================================================
|
|
1328
|
+
|
|
1329
|
+
function cmdTimestamp(format, raw) {
|
|
1330
|
+
const now = new Date();
|
|
1331
|
+
let result;
|
|
1332
|
+
|
|
1333
|
+
switch (format) {
|
|
1334
|
+
case 'date':
|
|
1335
|
+
result = now.toISOString().split('T')[0];
|
|
1336
|
+
break;
|
|
1337
|
+
case 'filename':
|
|
1338
|
+
result = now.toISOString().replace(/:/g, '-').replace(/\..+/, '');
|
|
1339
|
+
break;
|
|
1340
|
+
case 'full':
|
|
1341
|
+
default:
|
|
1342
|
+
result = now.toISOString();
|
|
1343
|
+
break;
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
output({ timestamp: result }, raw, result);
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
// =====================================================================
|
|
1350
|
+
// SLUG COMMAND
|
|
1351
|
+
// =====================================================================
|
|
1352
|
+
|
|
1353
|
+
function cmdSlug(text, raw) {
|
|
1354
|
+
if (!text) error('text required for slug generation');
|
|
1355
|
+
|
|
1356
|
+
const slug = text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
1357
|
+
output({ slug }, raw, slug);
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
// --- Run ---
|
|
1361
|
+
main();
|