pi-gsd 2.0.1 → 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/dist/pi-gsd-hooks.js +1532 -0
  2. package/package.json +3 -5
  3. package/.gsd/extensions/pi-gsd-hooks.ts +0 -973
  4. package/src/cli.ts +0 -644
  5. package/src/commands/base.ts +0 -67
  6. package/src/commands/commit.ts +0 -22
  7. package/src/commands/config.ts +0 -71
  8. package/src/commands/frontmatter.ts +0 -51
  9. package/src/commands/index.ts +0 -76
  10. package/src/commands/init.ts +0 -43
  11. package/src/commands/milestone.ts +0 -37
  12. package/src/commands/phase.ts +0 -92
  13. package/src/commands/progress.ts +0 -71
  14. package/src/commands/roadmap.ts +0 -40
  15. package/src/commands/scaffold.ts +0 -19
  16. package/src/commands/state.ts +0 -102
  17. package/src/commands/template.ts +0 -52
  18. package/src/commands/verify.ts +0 -70
  19. package/src/commands/workstream.ts +0 -98
  20. package/src/commands/wxp.ts +0 -65
  21. package/src/lib/commands.ts +0 -1040
  22. package/src/lib/config.ts +0 -385
  23. package/src/lib/core.ts +0 -1167
  24. package/src/lib/frontmatter.ts +0 -462
  25. package/src/lib/init.ts +0 -517
  26. package/src/lib/milestone.ts +0 -290
  27. package/src/lib/model-profiles.ts +0 -272
  28. package/src/lib/phase.ts +0 -1012
  29. package/src/lib/profile-output.ts +0 -237
  30. package/src/lib/profile-pipeline.ts +0 -556
  31. package/src/lib/roadmap.ts +0 -378
  32. package/src/lib/schemas.ts +0 -290
  33. package/src/lib/security.ts +0 -176
  34. package/src/lib/state.ts +0 -1175
  35. package/src/lib/template.ts +0 -246
  36. package/src/lib/uat.ts +0 -289
  37. package/src/lib/verify.ts +0 -879
  38. package/src/lib/workstream.ts +0 -524
  39. package/src/output.ts +0 -45
  40. package/src/schemas/pi-gsd-settings.schema.json +0 -80
  41. package/src/schemas/wxp.xsd +0 -619
  42. package/src/schemas/wxp.zod.ts +0 -318
  43. package/src/wxp/__tests__/arguments.test.ts +0 -86
  44. package/src/wxp/__tests__/conditions.test.ts +0 -106
  45. package/src/wxp/__tests__/executor.test.ts +0 -95
  46. package/src/wxp/__tests__/helpers.ts +0 -26
  47. package/src/wxp/__tests__/integration.test.ts +0 -166
  48. package/src/wxp/__tests__/new-features.test.ts +0 -222
  49. package/src/wxp/__tests__/parser.test.ts +0 -159
  50. package/src/wxp/__tests__/paste.test.ts +0 -66
  51. package/src/wxp/__tests__/schema.test.ts +0 -120
  52. package/src/wxp/__tests__/security.test.ts +0 -87
  53. package/src/wxp/__tests__/shell.test.ts +0 -85
  54. package/src/wxp/__tests__/string-ops.test.ts +0 -25
  55. package/src/wxp/__tests__/variables.test.ts +0 -65
  56. package/src/wxp/arguments.ts +0 -89
  57. package/src/wxp/conditions.ts +0 -78
  58. package/src/wxp/executor.ts +0 -191
  59. package/src/wxp/index.ts +0 -191
  60. package/src/wxp/parser.ts +0 -198
  61. package/src/wxp/paste.ts +0 -51
  62. package/src/wxp/security.ts +0 -102
  63. package/src/wxp/shell.ts +0 -81
  64. package/src/wxp/string-ops.ts +0 -44
  65. package/src/wxp/variables.ts +0 -109
package/src/lib/phase.ts DELETED
@@ -1,1012 +0,0 @@
1
- /**
2
- * phase.ts - Phase CRUD, query, and lifecycle operations.
3
- *
4
- * Ported from lib/phase.cjs. All command signatures preserved.
5
- */
6
-
7
- import fs from "fs";
8
- import path from "path";
9
- import {
10
- comparePhaseNum,
11
- escapeRegex,
12
- extractCurrentMilestone,
13
- findPhaseInternal,
14
- generateSlugInternal,
15
- getArchivedPhaseDirs,
16
- getMilestonePhaseFilter,
17
- gsdError,
18
- loadConfig,
19
- normalizePhaseName,
20
- output,
21
- planningDir,
22
- readSubdirectories,
23
- replaceInCurrentMilestone,
24
- toPosixPath,
25
- } from "./core.js";
26
- import { extractFrontmatter, asStr, asArr } from "./frontmatter.js";
27
-
28
- // Shape of a single plan entry built by cmdPhasePlanIndex
29
- interface PhasePlanEntry {
30
- id: string;
31
- wave: number;
32
- autonomous: boolean;
33
- objective: string | null;
34
- files_modified: string[];
35
- task_count: number;
36
- has_summary: boolean;
37
- }
38
-
39
- import {
40
- stateExtractField,
41
- stateReplaceField,
42
- stateReplaceFieldWithFallback,
43
- writeStateMd,
44
- } from "./state.js";
45
-
46
- // ─── cmdPhasesList ────────────────────────────────────────────────────────────
47
-
48
- export function cmdPhasesList(
49
- cwd: string,
50
- options: {
51
- type?: string | null;
52
- phase?: string | null;
53
- includeArchived?: boolean;
54
- },
55
- raw: boolean,
56
- ): void {
57
- const phasesDir = path.join(planningDir(cwd), "phases");
58
- const { type, phase, includeArchived } = options;
59
- if (!fs.existsSync(phasesDir)) {
60
- output(
61
- type ? { files: [], count: 0 } : { directories: [], count: 0 },
62
- raw,
63
- "",
64
- );
65
- return;
66
- }
67
- try {
68
- let dirs = fs
69
- .readdirSync(phasesDir, { withFileTypes: true })
70
- .filter((e) => e.isDirectory())
71
- .map((e) => e.name);
72
- if (includeArchived) {
73
- const archived = getArchivedPhaseDirs(cwd);
74
- for (const a of archived) dirs.push(`${a.name} [${a.milestone}]`);
75
- }
76
- dirs.sort((a, b) => comparePhaseNum(a, b));
77
- if (phase) {
78
- const normalized = normalizePhaseName(phase);
79
- const match = dirs.find((d) => d.startsWith(normalized));
80
- if (!match) {
81
- output(
82
- { files: [], count: 0, phase_dir: null, error: "Phase not found" },
83
- raw,
84
- "",
85
- );
86
- return;
87
- }
88
- dirs = [match];
89
- }
90
- if (type) {
91
- const files: string[] = [];
92
- for (const dir of dirs) {
93
- const dirPath = path.join(phasesDir, dir);
94
- const dirFiles = fs.readdirSync(dirPath);
95
- let filtered: string[];
96
- if (type === "plans")
97
- filtered = dirFiles.filter(
98
- (f) => f.endsWith("-PLAN.md") || f === "PLAN.md",
99
- );
100
- else if (type === "summaries")
101
- filtered = dirFiles.filter(
102
- (f) => f.endsWith("-SUMMARY.md") || f === "SUMMARY.md",
103
- );
104
- else filtered = dirFiles;
105
- files.push(...filtered.sort());
106
- }
107
- output(
108
- {
109
- files,
110
- count: files.length,
111
- phase_dir: phase ? dirs[0]?.replace(/^\d+(?:\.\d+)*-?/, "") : null,
112
- },
113
- raw,
114
- files.join("\n"),
115
- );
116
- return;
117
- }
118
- output({ directories: dirs, count: dirs.length }, raw, dirs.join("\n"));
119
- } catch (e) {
120
- gsdError("Failed to list phases: " + (e as Error).message);
121
- }
122
- }
123
-
124
- // ─── cmdPhaseNextDecimal ──────────────────────────────────────────────────────
125
-
126
- export function cmdPhaseNextDecimal(
127
- cwd: string,
128
- basePhase: string | undefined,
129
- raw: boolean,
130
- ): void {
131
- const phasesDir = path.join(planningDir(cwd), "phases");
132
- const normalized = normalizePhaseName(basePhase ?? "");
133
- if (!fs.existsSync(phasesDir)) {
134
- output(
135
- {
136
- found: false,
137
- base_phase: normalized,
138
- next: `${normalized}.1`,
139
- existing: [],
140
- },
141
- raw,
142
- `${normalized}.1`,
143
- );
144
- return;
145
- }
146
- try {
147
- const dirs = fs
148
- .readdirSync(phasesDir, { withFileTypes: true })
149
- .filter((e) => e.isDirectory())
150
- .map((e) => e.name);
151
- const baseExists = dirs.some(
152
- (d) => d.startsWith(normalized + "-") || d === normalized,
153
- );
154
- const decimalPattern = new RegExp(`^${normalized}\\.(\\d+)`);
155
- const existingDecimals = dirs
156
- .map((d) => {
157
- const m = d.match(decimalPattern);
158
- return m ? `${normalized}.${m[1]}` : null;
159
- })
160
- .filter(Boolean) as string[];
161
- existingDecimals.sort((a, b) => comparePhaseNum(a, b));
162
- const nextDecimal =
163
- existingDecimals.length === 0
164
- ? `${normalized}.1`
165
- : `${normalized}.${parseInt(existingDecimals[existingDecimals.length - 1].split(".")[1], 10) + 1}`;
166
- output(
167
- {
168
- found: baseExists,
169
- base_phase: normalized,
170
- next: nextDecimal,
171
- existing: existingDecimals,
172
- },
173
- raw,
174
- nextDecimal,
175
- );
176
- } catch (e) {
177
- gsdError("Failed to calculate next decimal phase: " + (e as Error).message);
178
- }
179
- }
180
-
181
- // ─── cmdFindPhase ─────────────────────────────────────────────────────────────
182
-
183
- export function cmdFindPhase(
184
- cwd: string,
185
- phase: string | undefined,
186
- raw: boolean,
187
- ): void {
188
- if (!phase) gsdError("phase identifier required");
189
- const phasesDir = path.join(planningDir(cwd), "phases");
190
- const normalized = normalizePhaseName(phase!);
191
- const notFound = {
192
- found: false,
193
- directory: null,
194
- phase_number: null,
195
- phase_name: null,
196
- plans: [],
197
- summaries: [],
198
- };
199
- try {
200
- const dirs = fs
201
- .readdirSync(phasesDir, { withFileTypes: true })
202
- .filter((e) => e.isDirectory())
203
- .map((e) => e.name)
204
- .sort((a, b) => comparePhaseNum(a, b));
205
- const match = dirs.find((d) => d.startsWith(normalized));
206
- if (!match) {
207
- output(notFound, raw, "");
208
- return;
209
- }
210
- const dirMatch = match.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i);
211
- const phaseNumber = dirMatch ? dirMatch[1] : normalized;
212
- const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null;
213
- const phaseDir = path.join(phasesDir, match);
214
- const phaseFiles = fs.readdirSync(phaseDir);
215
- const plans = phaseFiles
216
- .filter((f) => f.endsWith("-PLAN.md") || f === "PLAN.md")
217
- .sort();
218
- const summaries = phaseFiles
219
- .filter((f) => f.endsWith("-SUMMARY.md") || f === "SUMMARY.md")
220
- .sort();
221
- output(
222
- {
223
- found: true,
224
- directory: toPosixPath(
225
- path.join(path.relative(cwd, planningDir(cwd)), "phases", match),
226
- ),
227
- phase_number: phaseNumber,
228
- phase_name: phaseName,
229
- plans,
230
- summaries,
231
- },
232
- raw,
233
- toPosixPath(
234
- path.join(path.relative(cwd, planningDir(cwd)), "phases", match),
235
- ),
236
- );
237
- } catch {
238
- output(notFound, raw, "");
239
- }
240
- }
241
-
242
- // ─── cmdPhasePlanIndex ────────────────────────────────────────────────────────
243
-
244
- export function cmdPhasePlanIndex(
245
- cwd: string,
246
- phase: string | undefined,
247
- raw: boolean,
248
- ): void {
249
- if (!phase) gsdError("phase required for phase-plan-index");
250
- const phasesDir = path.join(planningDir(cwd), "phases");
251
- const normalized = normalizePhaseName(phase!);
252
- let phaseDir: string | null = null;
253
- try {
254
- const dirs = fs
255
- .readdirSync(phasesDir, { withFileTypes: true })
256
- .filter((e) => e.isDirectory())
257
- .map((e) => e.name)
258
- .sort((a, b) => comparePhaseNum(a, b));
259
- const match = dirs.find((d) => d.startsWith(normalized));
260
- if (match) phaseDir = path.join(phasesDir, match);
261
- } catch {
262
- /* ok */
263
- }
264
- if (!phaseDir) {
265
- output(
266
- {
267
- phase: normalized,
268
- error: "Phase not found",
269
- plans: [],
270
- waves: {},
271
- incomplete: [],
272
- has_checkpoints: false,
273
- },
274
- raw,
275
- );
276
- return;
277
- }
278
- const phaseFiles = fs.readdirSync(phaseDir);
279
- const planFiles = phaseFiles
280
- .filter((f) => f.endsWith("-PLAN.md") || f === "PLAN.md")
281
- .sort();
282
- const summaryFiles = phaseFiles.filter(
283
- (f) => f.endsWith("-SUMMARY.md") || f === "SUMMARY.md",
284
- );
285
- const completedPlanIds = new Set(
286
- summaryFiles.map((s) =>
287
- s.replace("-SUMMARY.md", "").replace("SUMMARY.md", ""),
288
- ),
289
- );
290
- const plans: PhasePlanEntry[] = [],
291
- waves: Record<string, string[]> = {},
292
- incomplete: string[] = [];
293
- let hasCheckpoints = false;
294
- for (const planFile of planFiles) {
295
- const planId = planFile.replace("-PLAN.md", "").replace("PLAN.md", "");
296
- const content = fs.readFileSync(path.join(phaseDir, planFile), "utf-8");
297
- const fm = extractFrontmatter(content);
298
- const xmlTasks = content.match(/<task[\s>]/gi) || [],
299
- mdTasks = content.match(/##\s*Task\s*\d+/gi) || [];
300
- const taskCount = xmlTasks.length || mdTasks.length;
301
- const wave = parseInt(String(fm.wave), 10) || 1;
302
- let autonomous = true;
303
- if (fm.autonomous !== undefined)
304
- autonomous = fm.autonomous === "true" || fm.autonomous === true;
305
- if (!autonomous) hasCheckpoints = true;
306
- let filesModified: string[] = [];
307
- const fmFiles = fm["files_modified"] ?? fm["files-modified"];
308
- if (fmFiles) {
309
- const arr = asArr(fmFiles);
310
- filesModified = arr ? arr.map((f) => asStr(f) ?? String(f)) : [asStr(fmFiles) ?? String(fmFiles)];
311
- }
312
- const hasSummary = completedPlanIds.has(planId);
313
- if (!hasSummary) incomplete.push(planId);
314
- plans.push({
315
- id: planId,
316
- wave,
317
- autonomous,
318
- objective:
319
- content.match(/<objective>\s*\n?\s*(.+)/)?.[1]?.trim() ??
320
- asStr(fm.objective) ??
321
- null,
322
- files_modified: filesModified,
323
- task_count: taskCount,
324
- has_summary: hasSummary,
325
- });
326
- const waveKey = String(wave);
327
- if (!waves[waveKey]) waves[waveKey] = [];
328
- waves[waveKey].push(planId);
329
- }
330
- output(
331
- {
332
- phase: normalized,
333
- plans,
334
- waves,
335
- incomplete,
336
- has_checkpoints: hasCheckpoints,
337
- },
338
- raw,
339
- );
340
- }
341
-
342
- // ─── cmdPhaseAdd ─────────────────────────────────────────────────────────────
343
-
344
- export function cmdPhaseAdd(
345
- cwd: string,
346
- description: string | undefined,
347
- raw: boolean,
348
- customId?: string | null,
349
- ): void {
350
- if (!description) gsdError("description required for phase add");
351
- const config = loadConfig(cwd);
352
- const roadmapPath = path.join(planningDir(cwd), "ROADMAP.md");
353
- if (!fs.existsSync(roadmapPath)) gsdError("ROADMAP.md not found");
354
- const rawContent = fs.readFileSync(roadmapPath, "utf-8");
355
- const content = extractCurrentMilestone(rawContent, cwd);
356
- const slug = generateSlugInternal(description!);
357
- let newPhaseId: string | number, dirName: string;
358
- if (customId || config.phase_naming === "custom") {
359
- newPhaseId = customId || slug!.toUpperCase().replace(/-/g, "-");
360
- if (!newPhaseId) gsdError('--id required when phase_naming is "custom"');
361
- dirName = `${newPhaseId}-${slug}`;
362
- } else {
363
- const phasePattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*:/gi;
364
- let maxPhase = 0,
365
- m: RegExpExecArray | null;
366
- while ((m = phasePattern.exec(content)) !== null) {
367
- const n = parseInt(m[1], 10);
368
- if (n > maxPhase) maxPhase = n;
369
- }
370
- newPhaseId = maxPhase + 1;
371
- const paddedNum = String(newPhaseId).padStart(2, "0");
372
- dirName = `${paddedNum}-${slug}`;
373
- }
374
- const dirPath = path.join(planningDir(cwd), "phases", dirName);
375
- fs.mkdirSync(dirPath, { recursive: true });
376
- fs.writeFileSync(path.join(dirPath, ".gitkeep"), "");
377
- const dependsOn =
378
- config.phase_naming === "custom"
379
- ? ""
380
- : `\n**Depends on:** Phase ${typeof newPhaseId === "number" ? newPhaseId - 1 : "TBD"}`;
381
- const phaseEntry = `\n### Phase ${newPhaseId}: ${description}\n\n**Goal:** [To be planned]\n**Requirements**: TBD${dependsOn}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run /gsd-plan-phase ${newPhaseId} to break down)\n`;
382
- const lastSeparator = rawContent.lastIndexOf("\n---");
383
- const updatedContent =
384
- lastSeparator > 0
385
- ? rawContent.slice(0, lastSeparator) +
386
- phaseEntry +
387
- rawContent.slice(lastSeparator)
388
- : rawContent + phaseEntry;
389
- fs.writeFileSync(roadmapPath, updatedContent, "utf-8");
390
- output(
391
- {
392
- phase_number:
393
- typeof newPhaseId === "number" ? newPhaseId : String(newPhaseId),
394
- padded:
395
- typeof newPhaseId === "number"
396
- ? String(newPhaseId).padStart(2, "0")
397
- : String(newPhaseId),
398
- name: description,
399
- slug,
400
- directory: toPosixPath(
401
- path.join(path.relative(cwd, planningDir(cwd)), "phases", dirName),
402
- ),
403
- naming_mode: config.phase_naming,
404
- },
405
- raw,
406
- typeof newPhaseId === "number"
407
- ? String(newPhaseId).padStart(2, "0")
408
- : String(newPhaseId),
409
- );
410
- }
411
-
412
- // ─── cmdPhaseInsert ───────────────────────────────────────────────────────────
413
-
414
- export function cmdPhaseInsert(
415
- cwd: string,
416
- afterPhase: string | undefined,
417
- description: string | undefined,
418
- raw: boolean,
419
- ): void {
420
- if (!afterPhase || !description)
421
- gsdError("after-phase and description required for phase insert");
422
- const roadmapPath = path.join(planningDir(cwd), "ROADMAP.md");
423
- if (!fs.existsSync(roadmapPath)) gsdError("ROADMAP.md not found");
424
- const rawContent = fs.readFileSync(roadmapPath, "utf-8");
425
- const content = extractCurrentMilestone(rawContent, cwd);
426
- const slug = generateSlugInternal(description!);
427
- const normalizedAfter = normalizePhaseName(afterPhase!);
428
- const unpadded = normalizedAfter.replace(/^0+/, "");
429
- const afterPhaseEscaped = unpadded.replace(/\./g, "\\.");
430
- const targetPattern = new RegExp(
431
- `#{2,4}\\s*Phase\\s+0*${afterPhaseEscaped}:`,
432
- "i",
433
- );
434
- if (!targetPattern.test(content))
435
- gsdError(`Phase ${afterPhase} not found in ROADMAP.md`);
436
- const phasesDir = path.join(planningDir(cwd), "phases");
437
- const normalizedBase = normalizePhaseName(afterPhase!);
438
- const existingDecimals: number[] = [];
439
- try {
440
- const dirs = fs
441
- .readdirSync(phasesDir, { withFileTypes: true })
442
- .filter((e) => e.isDirectory())
443
- .map((e) => e.name);
444
- const decimalPattern = new RegExp(`^${normalizedBase}\\.(\\d+)`);
445
- for (const dir of dirs) {
446
- const dm = dir.match(decimalPattern);
447
- if (dm) existingDecimals.push(parseInt(dm[1], 10));
448
- }
449
- } catch {
450
- /* ok */
451
- }
452
- const nextDecimal =
453
- existingDecimals.length === 0 ? 1 : Math.max(...existingDecimals) + 1;
454
- const decimalPhase = `${normalizedBase}.${nextDecimal}`;
455
- const dirName = `${decimalPhase}-${slug}`;
456
- const dirPath = path.join(planningDir(cwd), "phases", dirName);
457
- fs.mkdirSync(dirPath, { recursive: true });
458
- fs.writeFileSync(path.join(dirPath, ".gitkeep"), "");
459
- const phaseEntry = `\n### Phase ${decimalPhase}: ${description} (INSERTED)\n\n**Goal:** [Urgent work - to be planned]\n**Requirements**: TBD\n**Depends on:** Phase ${afterPhase}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run /gsd-plan-phase ${decimalPhase} to break down)\n`;
460
- const headerPattern = new RegExp(
461
- `(#{2,4}\\s*Phase\\s+0*${afterPhaseEscaped}:[^\\n]*\\n)`,
462
- "i",
463
- );
464
- const headerMatch = rawContent.match(headerPattern);
465
- if (!headerMatch) gsdError(`Could not find Phase ${afterPhase} header`);
466
- const headerIdx = rawContent.indexOf(headerMatch![0]);
467
- const afterHeader = rawContent.slice(headerIdx + headerMatch![0].length);
468
- const nextPhaseMatch = afterHeader.match(/\n#{2,4}\s+Phase\s+\d/i);
469
- const insertIdx = nextPhaseMatch
470
- ? headerIdx + headerMatch![0].length + nextPhaseMatch.index!
471
- : rawContent.length;
472
- fs.writeFileSync(
473
- roadmapPath,
474
- rawContent.slice(0, insertIdx) + phaseEntry + rawContent.slice(insertIdx),
475
- "utf-8",
476
- );
477
- output(
478
- {
479
- phase_number: decimalPhase,
480
- after_phase: afterPhase,
481
- name: description,
482
- slug,
483
- directory: toPosixPath(
484
- path.join(path.relative(cwd, planningDir(cwd)), "phases", dirName),
485
- ),
486
- },
487
- raw,
488
- decimalPhase,
489
- );
490
- }
491
-
492
- // ─── cmdPhaseRemove ───────────────────────────────────────────────────────────
493
-
494
- function renameDecimalPhases(
495
- phasesDir: string,
496
- baseInt: string,
497
- removedDecimal: number,
498
- ): {
499
- renamedDirs: Array<{ from: string; to: string }>;
500
- renamedFiles: Array<{ from: string; to: string }>;
501
- } {
502
- const renamedDirs: Array<{ from: string; to: string }> = [],
503
- renamedFiles: Array<{ from: string; to: string }> = [];
504
- const decPattern = new RegExp(`^${baseInt}\\.(\\d+)-(.+)$`);
505
- const toRename = readSubdirectories(phasesDir, true)
506
- .map((dir) => {
507
- const m = dir.match(decPattern);
508
- return m ? { dir, oldDecimal: parseInt(m[1], 10), slug: m[2] } : null;
509
- })
510
- .filter(
511
- (x): x is NonNullable<typeof x> =>
512
- x !== null && x.oldDecimal > removedDecimal,
513
- )
514
- .sort((a, b) => b.oldDecimal - a.oldDecimal);
515
- for (const item of toRename) {
516
- const newDecimal = item.oldDecimal - 1;
517
- const oldPhaseId = `${baseInt}.${item.oldDecimal}`,
518
- newPhaseId = `${baseInt}.${newDecimal}`;
519
- const newDirName = `${baseInt}.${newDecimal}-${item.slug}`;
520
- fs.renameSync(
521
- path.join(phasesDir, item.dir),
522
- path.join(phasesDir, newDirName),
523
- );
524
- renamedDirs.push({ from: item.dir, to: newDirName });
525
- for (const f of fs.readdirSync(path.join(phasesDir, newDirName))) {
526
- if (f.includes(oldPhaseId)) {
527
- const newFileName = f.replace(oldPhaseId, newPhaseId);
528
- fs.renameSync(
529
- path.join(phasesDir, newDirName, f),
530
- path.join(phasesDir, newDirName, newFileName),
531
- );
532
- renamedFiles.push({ from: f, to: newFileName });
533
- }
534
- }
535
- }
536
- return { renamedDirs, renamedFiles };
537
- }
538
-
539
- function renameIntegerPhases(
540
- phasesDir: string,
541
- removedInt: number,
542
- ): {
543
- renamedDirs: Array<{ from: string; to: string }>;
544
- renamedFiles: Array<{ from: string; to: string }>;
545
- } {
546
- const renamedDirs: Array<{ from: string; to: string }> = [],
547
- renamedFiles: Array<{ from: string; to: string }> = [];
548
- const toRename = readSubdirectories(phasesDir, true)
549
- .map((dir) => {
550
- const m = dir.match(/^(\d+)([A-Z])?(?:\.(\d+))?-(.+)$/i);
551
- if (!m) return null;
552
- const dirInt = parseInt(m[1], 10);
553
- return dirInt > removedInt
554
- ? {
555
- dir,
556
- oldInt: dirInt,
557
- letter: m[2] ? m[2].toUpperCase() : "",
558
- decimal: m[3] ? parseInt(m[3], 10) : null,
559
- slug: m[4],
560
- }
561
- : null;
562
- })
563
- .filter((x): x is NonNullable<typeof x> => x !== null)
564
- .sort((a, b) =>
565
- a.oldInt !== b.oldInt
566
- ? b.oldInt - a.oldInt
567
- : (b.decimal || 0) - (a.decimal || 0),
568
- );
569
- for (const item of toRename) {
570
- const newInt = item.oldInt - 1;
571
- const newPadded = String(newInt).padStart(2, "0"),
572
- oldPadded = String(item.oldInt).padStart(2, "0");
573
- const letterSuffix = item.letter || "",
574
- decimalSuffix = item.decimal !== null ? `.${item.decimal}` : "";
575
- const oldPrefix = `${oldPadded}${letterSuffix}${decimalSuffix}`,
576
- newPrefix = `${newPadded}${letterSuffix}${decimalSuffix}`;
577
- const newDirName = `${newPrefix}-${item.slug}`;
578
- fs.renameSync(
579
- path.join(phasesDir, item.dir),
580
- path.join(phasesDir, newDirName),
581
- );
582
- renamedDirs.push({ from: item.dir, to: newDirName });
583
- for (const f of fs.readdirSync(path.join(phasesDir, newDirName))) {
584
- if (f.startsWith(oldPrefix)) {
585
- const newFileName = newPrefix + f.slice(oldPrefix.length);
586
- fs.renameSync(
587
- path.join(phasesDir, newDirName, f),
588
- path.join(phasesDir, newDirName, newFileName),
589
- );
590
- renamedFiles.push({ from: f, to: newFileName });
591
- }
592
- }
593
- }
594
- return { renamedDirs, renamedFiles };
595
- }
596
-
597
- function updateRoadmapAfterPhaseRemoval(
598
- roadmapPath: string,
599
- targetPhase: string,
600
- isDecimal: boolean,
601
- removedInt: number,
602
- ): void {
603
- let content = fs.readFileSync(roadmapPath, "utf-8");
604
- const escaped = escapeRegex(targetPhase);
605
- content = content.replace(
606
- new RegExp(
607
- `\\n?#{2,4}\\s*Phase\\s+${escaped}\\s*:[\\s\\S]*?(?=\\n#{2,4}\\s+Phase\\s+\\d|$)`,
608
- "i",
609
- ),
610
- "",
611
- );
612
- content = content.replace(
613
- new RegExp(
614
- `\\n?-\\s*\\[[ x]\\]\\s*.*Phase\\s+${escaped}[:\\s][^\\n]*`,
615
- "gi",
616
- ),
617
- "",
618
- );
619
- content = content.replace(
620
- new RegExp(`\\n?\\|\\s*${escaped}\\.?\\s[^|]*\\|[^\\n]*`, "gi"),
621
- "",
622
- );
623
- if (!isDecimal) {
624
- for (let oldNum = 99; oldNum > removedInt; oldNum--) {
625
- const newNum = oldNum - 1,
626
- oldStr = String(oldNum),
627
- newStr = String(newNum);
628
- const oldPad = oldStr.padStart(2, "0"),
629
- newPad = newStr.padStart(2, "0");
630
- content = content.replace(
631
- new RegExp(`(#{2,4}\\s*Phase\\s+)${oldStr}(\\s*:)`, "gi"),
632
- `$1${newStr}$2`,
633
- );
634
- content = content.replace(
635
- new RegExp(`(Phase\\s+)${oldStr}([:\\s])`, "g"),
636
- `$1${newStr}$2`,
637
- );
638
- content = content.replace(
639
- new RegExp(`${oldPad}-(\\d{2})`, "g"),
640
- `${newPad}-$1`,
641
- );
642
- content = content.replace(
643
- new RegExp(`(\\|\\s*)${oldStr}\\.\\s`, "g"),
644
- `$1${newStr}. `,
645
- );
646
- content = content.replace(
647
- new RegExp(`(Depends on:\\*\\*\\s*Phase\\s+)${oldStr}\\b`, "gi"),
648
- `$1${newStr}`,
649
- );
650
- }
651
- }
652
- fs.writeFileSync(roadmapPath, content, "utf-8");
653
- }
654
-
655
- export function cmdPhaseRemove(
656
- cwd: string,
657
- targetPhase: string | undefined,
658
- options: { force?: boolean },
659
- raw: boolean,
660
- ): void {
661
- if (!targetPhase) gsdError("phase number required for phase remove");
662
- const roadmapPath = path.join(planningDir(cwd), "ROADMAP.md");
663
- const phasesDir = path.join(planningDir(cwd), "phases");
664
- if (!fs.existsSync(roadmapPath)) gsdError("ROADMAP.md not found");
665
- const normalized = normalizePhaseName(targetPhase!);
666
- const isDecimal = targetPhase!.includes(".");
667
- const force = options.force || false;
668
- const targetDir =
669
- readSubdirectories(phasesDir, true).find(
670
- (d) => d.startsWith(normalized + "-") || d === normalized,
671
- ) || null;
672
- if (targetDir && !force) {
673
- const summaries = fs
674
- .readdirSync(path.join(phasesDir, targetDir))
675
- .filter((f) => f.endsWith("-SUMMARY.md") || f === "SUMMARY.md");
676
- if (summaries.length > 0)
677
- gsdError(
678
- `Phase ${targetPhase} has ${summaries.length} executed plan(s). Use --force to remove anyway.`,
679
- );
680
- }
681
- if (targetDir)
682
- fs.rmSync(path.join(phasesDir, targetDir), {
683
- recursive: true,
684
- force: true,
685
- });
686
- let renamedDirs: Array<{ from: string; to: string }> = [],
687
- renamedFiles: Array<{ from: string; to: string }> = [];
688
- try {
689
- const renamed = isDecimal
690
- ? renameDecimalPhases(
691
- phasesDir,
692
- normalized.split(".")[0],
693
- parseInt(normalized.split(".")[1], 10),
694
- )
695
- : renameIntegerPhases(phasesDir, parseInt(normalized, 10));
696
- renamedDirs = renamed.renamedDirs;
697
- renamedFiles = renamed.renamedFiles;
698
- } catch {
699
- /* ok */
700
- }
701
- updateRoadmapAfterPhaseRemoval(
702
- roadmapPath,
703
- targetPhase!,
704
- isDecimal,
705
- parseInt(normalized, 10),
706
- );
707
- const statePath = path.join(planningDir(cwd), "STATE.md");
708
- if (fs.existsSync(statePath)) {
709
- let stateContent = fs.readFileSync(statePath, "utf-8");
710
- const totalRaw = stateExtractField(stateContent, "Total Phases");
711
- if (totalRaw)
712
- stateContent =
713
- stateReplaceField(
714
- stateContent,
715
- "Total Phases",
716
- String(parseInt(totalRaw, 10) - 1),
717
- ) ?? stateContent;
718
- const ofMatch = stateContent.match(/(\bof\s+)(\d+)(\s*(?:\(|phases?))/i);
719
- if (ofMatch)
720
- stateContent = stateContent.replace(
721
- /(\bof\s+)(\d+)(\s*(?:\(|phases?))/i,
722
- `$1${parseInt(ofMatch[2], 10) - 1}$3`,
723
- );
724
- writeStateMd(statePath, stateContent, cwd);
725
- }
726
- output(
727
- {
728
- removed: targetPhase,
729
- directory_deleted: targetDir,
730
- renamed_directories: renamedDirs,
731
- renamed_files: renamedFiles,
732
- roadmap_updated: true,
733
- state_updated: fs.existsSync(statePath),
734
- },
735
- raw,
736
- );
737
- }
738
-
739
- // ─── cmdPhaseComplete ─────────────────────────────────────────────────────────
740
-
741
- export function cmdPhaseComplete(
742
- cwd: string,
743
- phaseNum: string | undefined,
744
- raw: boolean,
745
- ): void {
746
- if (!phaseNum) gsdError("phase number required for phase complete");
747
- const roadmapPath = path.join(planningDir(cwd), "ROADMAP.md");
748
- const statePath = path.join(planningDir(cwd), "STATE.md");
749
- const phasesDir = path.join(planningDir(cwd), "phases");
750
- const normalized = normalizePhaseName(phaseNum!);
751
- const today = new Date().toISOString().split("T")[0];
752
- const phaseInfo = findPhaseInternal(cwd, phaseNum!);
753
- if (!phaseInfo) gsdError(`Phase ${phaseNum} not found`);
754
- const planCount = phaseInfo!.plans.length,
755
- summaryCount = phaseInfo!.summaries.length;
756
- let requirementsUpdated = false;
757
- const warnings: string[] = [];
758
- try {
759
- const phaseFullDir = path.join(cwd, phaseInfo!.directory);
760
- const phaseFiles = fs.readdirSync(phaseFullDir);
761
- for (const file of phaseFiles.filter(
762
- (f) => f.includes("-UAT") && f.endsWith(".md"),
763
- )) {
764
- const content = fs.readFileSync(path.join(phaseFullDir, file), "utf-8");
765
- if (/result: pending/.test(content))
766
- warnings.push(`${file}: has pending tests`);
767
- if (/result: blocked/.test(content))
768
- warnings.push(`${file}: has blocked tests`);
769
- if (/status: partial/.test(content))
770
- warnings.push(`${file}: testing incomplete (partial)`);
771
- if (/status: diagnosed/.test(content))
772
- warnings.push(`${file}: has diagnosed gaps`);
773
- }
774
- for (const file of phaseFiles.filter(
775
- (f) => f.includes("-VERIFICATION") && f.endsWith(".md"),
776
- )) {
777
- const content = fs.readFileSync(path.join(phaseFullDir, file), "utf-8");
778
- if (/status: human_needed/.test(content))
779
- warnings.push(`${file}: needs human verification`);
780
- if (/status: gaps_found/.test(content))
781
- warnings.push(`${file}: has unresolved gaps`);
782
- }
783
- } catch {
784
- /* ok */
785
- }
786
-
787
- if (fs.existsSync(roadmapPath)) {
788
- let roadmapContent = fs.readFileSync(roadmapPath, "utf-8");
789
- const checkboxPattern = new RegExp(
790
- `(-\\s*\\[)[ ](\\]\\s*.*Phase\\s+${escapeRegex(phaseNum!)}[:\\s][^\\n]*)`,
791
- "i",
792
- );
793
- roadmapContent = replaceInCurrentMilestone(
794
- roadmapContent,
795
- checkboxPattern,
796
- `$1x$2 (completed ${today})`,
797
- );
798
- const phaseEscaped = escapeRegex(phaseNum!);
799
- roadmapContent = roadmapContent.replace(
800
- new RegExp(`^(\\|\\s*${phaseEscaped}\\.?\\s[^|]*(?:\\|[^\\n]*))$`, "im"),
801
- (fullRow) => {
802
- const cells = fullRow.split("|").slice(1, -1);
803
- if (cells.length === 5) {
804
- cells[3] = " Complete ";
805
- cells[4] = ` ${today} `;
806
- } else if (cells.length === 4) {
807
- cells[2] = " Complete ";
808
- cells[3] = ` ${today} `;
809
- }
810
- return "|" + cells.join("|") + "|";
811
- },
812
- );
813
- roadmapContent = replaceInCurrentMilestone(
814
- roadmapContent,
815
- new RegExp(
816
- `(#{2,4}\\s*Phase\\s+${phaseEscaped}[\\s\\S]*?\\*\\*Plans:\\*\\*\\s*)[^\\n]+`,
817
- "i",
818
- ),
819
- `$1${summaryCount}/${planCount} plans complete`,
820
- );
821
- fs.writeFileSync(roadmapPath, roadmapContent, "utf-8");
822
- const reqPath = path.join(planningDir(cwd), "REQUIREMENTS.md");
823
- if (fs.existsSync(reqPath)) {
824
- const currentMilestoneRoadmap = extractCurrentMilestone(
825
- roadmapContent,
826
- cwd,
827
- );
828
- const phaseSectionMatch = currentMilestoneRoadmap.match(
829
- new RegExp(
830
- `(#{2,4}\\s*Phase\\s+${escapeRegex(phaseNum!)}[:\\s][\\s\\S]*?)(?=#{2,4}\\s*Phase\\s+|$)`,
831
- "i",
832
- ),
833
- );
834
- const sectionText = phaseSectionMatch ? phaseSectionMatch[1] : "";
835
- const reqMatch = sectionText.match(/\*\*Requirements:\*\*\s*([^\n]+)/i);
836
- if (reqMatch) {
837
- const reqIds = reqMatch[1]
838
- .replace(/[[\]]/g, "")
839
- .split(/[,\s]+/)
840
- .map((r) => r.trim())
841
- .filter(Boolean);
842
- let reqContent = fs.readFileSync(reqPath, "utf-8");
843
- for (const reqId of reqIds) {
844
- const esc = escapeRegex(reqId);
845
- reqContent = reqContent.replace(
846
- new RegExp(`(-\\s*\\[)[ ](\\]\\s*\\*\\*${esc}\\*\\*)`, "gi"),
847
- "$1x$2",
848
- );
849
- reqContent = reqContent.replace(
850
- new RegExp(
851
- `(\\|\\s*${esc}\\s*\\|[^|]+\\|)\\s*(?:Pending|In Progress)\\s*(\\|)`,
852
- "gi",
853
- ),
854
- "$1 Complete $2",
855
- );
856
- }
857
- fs.writeFileSync(reqPath, reqContent, "utf-8");
858
- requirementsUpdated = true;
859
- }
860
- }
861
- }
862
-
863
- let nextPhaseNum: string | null = null,
864
- nextPhaseName: string | null = null,
865
- isLastPhase = true;
866
- try {
867
- const isDirInMilestone = getMilestonePhaseFilter(cwd);
868
- const dirs = fs
869
- .readdirSync(phasesDir, { withFileTypes: true })
870
- .filter((e) => e.isDirectory())
871
- .map((e) => e.name)
872
- .filter(isDirInMilestone)
873
- .sort((a, b) => comparePhaseNum(a, b));
874
- for (const dir of dirs) {
875
- const dm = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i);
876
- if (dm && comparePhaseNum(dm[1], phaseNum!) > 0) {
877
- nextPhaseNum = dm[1];
878
- nextPhaseName = dm[2] || null;
879
- isLastPhase = false;
880
- break;
881
- }
882
- }
883
- } catch {
884
- /* ok */
885
- }
886
- if (isLastPhase && fs.existsSync(roadmapPath)) {
887
- try {
888
- const roadmapForPhases = extractCurrentMilestone(
889
- fs.readFileSync(roadmapPath, "utf-8"),
890
- cwd,
891
- );
892
- const phasePattern =
893
- /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi;
894
- let pm: RegExpExecArray | null;
895
- while ((pm = phasePattern.exec(roadmapForPhases)) !== null) {
896
- if (comparePhaseNum(pm[1], phaseNum!) > 0) {
897
- nextPhaseNum = pm[1];
898
- nextPhaseName = pm[2]
899
- .replace(/\(INSERTED\)/i, "")
900
- .trim()
901
- .toLowerCase()
902
- .replace(/\s+/g, "-");
903
- isLastPhase = false;
904
- break;
905
- }
906
- }
907
- } catch {
908
- /* ok */
909
- }
910
- }
911
-
912
- if (fs.existsSync(statePath)) {
913
- let stateContent = fs.readFileSync(statePath, "utf-8");
914
- const phaseValue = nextPhaseNum || phaseNum!;
915
- const existingPhaseField =
916
- stateExtractField(stateContent, "Current Phase") ||
917
- stateExtractField(stateContent, "Phase");
918
- let newPhaseValue = String(phaseValue);
919
- if (existingPhaseField) {
920
- const totalMatch = existingPhaseField.match(/of\s+(\d+)/),
921
- nameMatch = existingPhaseField.match(/\(([^)]+)\)/);
922
- if (totalMatch) {
923
- const nameStr = nextPhaseName
924
- ? ` (${nextPhaseName.replace(/-/g, " ")})`
925
- : nameMatch
926
- ? ` (${nameMatch[1]})`
927
- : "";
928
- newPhaseValue = `${phaseValue} of ${totalMatch[1]}${nameStr}`;
929
- }
930
- }
931
- stateContent = stateReplaceFieldWithFallback(
932
- stateContent,
933
- "Current Phase",
934
- "Phase",
935
- newPhaseValue,
936
- );
937
- if (nextPhaseName)
938
- stateContent = stateReplaceFieldWithFallback(
939
- stateContent,
940
- "Current Phase Name",
941
- null,
942
- nextPhaseName.replace(/-/g, " "),
943
- );
944
- stateContent = stateReplaceFieldWithFallback(
945
- stateContent,
946
- "Status",
947
- null,
948
- isLastPhase ? "Milestone complete" : "Ready to plan",
949
- );
950
- stateContent = stateReplaceFieldWithFallback(
951
- stateContent,
952
- "Current Plan",
953
- "Plan",
954
- "Not started",
955
- );
956
- stateContent = stateReplaceFieldWithFallback(
957
- stateContent,
958
- "Last Activity",
959
- "Last activity",
960
- today,
961
- );
962
- stateContent = stateReplaceFieldWithFallback(
963
- stateContent,
964
- "Last Activity Description",
965
- null,
966
- `Phase ${phaseNum} complete${nextPhaseNum ? `, transitioned to Phase ${nextPhaseNum}` : ""}`,
967
- );
968
- const completedRaw = stateExtractField(stateContent, "Completed Phases");
969
- if (completedRaw) {
970
- const newCompleted = parseInt(completedRaw, 10) + 1;
971
- stateContent =
972
- stateReplaceField(
973
- stateContent,
974
- "Completed Phases",
975
- String(newCompleted),
976
- ) ?? stateContent;
977
- const totalRaw = stateExtractField(stateContent, "Total Phases");
978
- if (totalRaw) {
979
- const totalPhases = parseInt(totalRaw, 10);
980
- if (totalPhases > 0) {
981
- const newPercent = Math.round((newCompleted / totalPhases) * 100);
982
- stateContent =
983
- stateReplaceField(stateContent, "Progress", `${newPercent}%`) ??
984
- stateContent;
985
- stateContent = stateContent.replace(
986
- /(percent:\s*)\d+/,
987
- `$1${newPercent}`,
988
- );
989
- }
990
- }
991
- }
992
- writeStateMd(statePath, stateContent, cwd);
993
- }
994
-
995
- output(
996
- {
997
- completed_phase: phaseNum,
998
- phase_name: phaseInfo!.phase_name,
999
- plans_executed: `${summaryCount}/${planCount}`,
1000
- next_phase: nextPhaseNum,
1001
- next_phase_name: nextPhaseName,
1002
- is_last_phase: isLastPhase,
1003
- date: today,
1004
- roadmap_updated: fs.existsSync(roadmapPath),
1005
- state_updated: fs.existsSync(statePath),
1006
- requirements_updated: requirementsUpdated,
1007
- warnings,
1008
- has_warnings: warnings.length > 0,
1009
- },
1010
- raw,
1011
- );
1012
- }