pi-gsd 2.0.1 → 2.0.3

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 (66) hide show
  1. package/dist/pi-gsd-hooks.js +1533 -0
  2. package/dist/pi-gsd-tools.js +53 -52
  3. package/package.json +3 -5
  4. package/.gsd/extensions/pi-gsd-hooks.ts +0 -973
  5. package/src/cli.ts +0 -644
  6. package/src/commands/base.ts +0 -67
  7. package/src/commands/commit.ts +0 -22
  8. package/src/commands/config.ts +0 -71
  9. package/src/commands/frontmatter.ts +0 -51
  10. package/src/commands/index.ts +0 -76
  11. package/src/commands/init.ts +0 -43
  12. package/src/commands/milestone.ts +0 -37
  13. package/src/commands/phase.ts +0 -92
  14. package/src/commands/progress.ts +0 -71
  15. package/src/commands/roadmap.ts +0 -40
  16. package/src/commands/scaffold.ts +0 -19
  17. package/src/commands/state.ts +0 -102
  18. package/src/commands/template.ts +0 -52
  19. package/src/commands/verify.ts +0 -70
  20. package/src/commands/workstream.ts +0 -98
  21. package/src/commands/wxp.ts +0 -65
  22. package/src/lib/commands.ts +0 -1040
  23. package/src/lib/config.ts +0 -385
  24. package/src/lib/core.ts +0 -1167
  25. package/src/lib/frontmatter.ts +0 -462
  26. package/src/lib/init.ts +0 -517
  27. package/src/lib/milestone.ts +0 -290
  28. package/src/lib/model-profiles.ts +0 -272
  29. package/src/lib/phase.ts +0 -1012
  30. package/src/lib/profile-output.ts +0 -237
  31. package/src/lib/profile-pipeline.ts +0 -556
  32. package/src/lib/roadmap.ts +0 -378
  33. package/src/lib/schemas.ts +0 -290
  34. package/src/lib/security.ts +0 -176
  35. package/src/lib/state.ts +0 -1175
  36. package/src/lib/template.ts +0 -246
  37. package/src/lib/uat.ts +0 -289
  38. package/src/lib/verify.ts +0 -879
  39. package/src/lib/workstream.ts +0 -524
  40. package/src/output.ts +0 -45
  41. package/src/schemas/pi-gsd-settings.schema.json +0 -80
  42. package/src/schemas/wxp.xsd +0 -619
  43. package/src/schemas/wxp.zod.ts +0 -318
  44. package/src/wxp/__tests__/arguments.test.ts +0 -86
  45. package/src/wxp/__tests__/conditions.test.ts +0 -106
  46. package/src/wxp/__tests__/executor.test.ts +0 -95
  47. package/src/wxp/__tests__/helpers.ts +0 -26
  48. package/src/wxp/__tests__/integration.test.ts +0 -166
  49. package/src/wxp/__tests__/new-features.test.ts +0 -222
  50. package/src/wxp/__tests__/parser.test.ts +0 -159
  51. package/src/wxp/__tests__/paste.test.ts +0 -66
  52. package/src/wxp/__tests__/schema.test.ts +0 -120
  53. package/src/wxp/__tests__/security.test.ts +0 -87
  54. package/src/wxp/__tests__/shell.test.ts +0 -85
  55. package/src/wxp/__tests__/string-ops.test.ts +0 -25
  56. package/src/wxp/__tests__/variables.test.ts +0 -65
  57. package/src/wxp/arguments.ts +0 -89
  58. package/src/wxp/conditions.ts +0 -78
  59. package/src/wxp/executor.ts +0 -191
  60. package/src/wxp/index.ts +0 -191
  61. package/src/wxp/parser.ts +0 -198
  62. package/src/wxp/paste.ts +0 -51
  63. package/src/wxp/security.ts +0 -102
  64. package/src/wxp/shell.ts +0 -81
  65. package/src/wxp/string-ops.ts +0 -44
  66. package/src/wxp/variables.ts +0 -109
@@ -1,1040 +0,0 @@
1
- /**
2
- * commands.ts - Standalone utility commands.
3
- *
4
- * Ported from lib/commands.cjs. All command signatures preserved.
5
- */
6
-
7
- import { execSync } from "child_process";
8
- import fs from "fs";
9
- import path from "path";
10
- import {
11
- comparePhaseNum,
12
- execGit,
13
- extractCurrentMilestone,
14
- extractOneLinerFromBody,
15
- findPhaseInternal,
16
- findProjectRoot,
17
- generateSlugInternal,
18
- getArchivedPhaseDirs,
19
- getMilestoneInfo,
20
- getMilestonePhaseFilter,
21
- getRoadmapPhaseInternal,
22
- gsdError,
23
- isGitIgnored,
24
- loadConfig,
25
- normalizePhaseName,
26
- output,
27
- planningDir,
28
- planningPaths,
29
- resolveModelInternal,
30
- safeReadFile,
31
- toPosixPath,
32
- } from "./core.js";
33
- import { extractFrontmatter, asStr, asArr, asObj } from "./frontmatter.js";
34
- import { MODEL_PROFILES } from "./model-profiles.js";
35
- import { sanitizeForPrompt } from "./security.js";
36
-
37
- // ─── Utility commands ─────────────────────────────────────────────────────────
38
-
39
- export function cmdGenerateSlug(text: string | undefined, raw: boolean): void {
40
- if (!text) gsdError("text required for slug generation");
41
- const slug = text!
42
- .toLowerCase()
43
- .replace(/[^a-z0-9]+/g, "-")
44
- .replace(/^-+|-+$/g, "");
45
- output({ slug }, raw, slug);
46
- }
47
-
48
- export function cmdCurrentTimestamp(
49
- format: string | undefined,
50
- raw: boolean,
51
- ): void {
52
- const now = new Date();
53
- let result: string;
54
- if (format === "date") result = now.toISOString().split("T")[0];
55
- else if (format === "filename")
56
- result = now.toISOString().replace(/:/g, "-").replace(/\..+/, "");
57
- else result = now.toISOString();
58
- output({ timestamp: result }, raw, result);
59
- }
60
-
61
- export function cmdListTodos(
62
- cwd: string,
63
- area: string | undefined,
64
- raw: boolean,
65
- ): void {
66
- const pendingDir = path.join(planningDir(cwd), "todos", "pending");
67
- let count = 0;
68
- const todos: unknown[] = [];
69
- try {
70
- const files = fs.readdirSync(pendingDir).filter((f) => f.endsWith(".md"));
71
- for (const file of files) {
72
- try {
73
- const content = fs.readFileSync(path.join(pendingDir, file), "utf-8");
74
- const createdMatch = content.match(/^created:\s*(.+)$/m),
75
- titleMatch = content.match(/^title:\s*(.+)$/m),
76
- areaMatch = content.match(/^area:\s*(.+)$/m);
77
- const todoArea = areaMatch ? areaMatch[1].trim() : "general";
78
- if (area && todoArea !== area) continue;
79
- count++;
80
- todos.push({
81
- file,
82
- created: createdMatch ? createdMatch[1].trim() : "unknown",
83
- title: titleMatch ? titleMatch[1].trim() : "Untitled",
84
- area: todoArea,
85
- path: toPosixPath(path.relative(cwd, path.join(pendingDir, file))),
86
- });
87
- } catch {
88
- /* ok */
89
- }
90
- }
91
- } catch {
92
- /* ok */
93
- }
94
- output({ count, todos }, raw, count.toString());
95
- }
96
-
97
- export function cmdVerifyPathExists(
98
- cwd: string,
99
- targetPath: string | undefined,
100
- raw: boolean,
101
- ): void {
102
- if (!targetPath) gsdError("path required for verification");
103
- if (targetPath!.includes("\0")) gsdError("path contains null bytes");
104
- const fullPath = path.isAbsolute(targetPath!)
105
- ? targetPath!
106
- : path.join(cwd, targetPath!);
107
- try {
108
- const stats = fs.statSync(fullPath);
109
- output(
110
- {
111
- exists: true,
112
- type: stats.isDirectory()
113
- ? "directory"
114
- : stats.isFile()
115
- ? "file"
116
- : "other",
117
- },
118
- raw,
119
- "true",
120
- );
121
- } catch {
122
- output({ exists: false, type: null }, raw, "false");
123
- }
124
- }
125
-
126
- export function cmdHistoryDigest(cwd: string, raw: boolean): void {
127
- const phasesDir = planningPaths(cwd).phases;
128
- /** Internal phase entry - Sets serialised to arrays in the output step */
129
- interface PhaseEntry {
130
- name: string;
131
- provides: Set<string>;
132
- affects: Set<string>;
133
- patterns: Set<string>;
134
- }
135
- const digest: {
136
- phases: Record<string, PhaseEntry>;
137
- decisions: Array<{ phase: string; decision: string }>;
138
- tech_stack: Set<string>;
139
- } = {
140
- phases: {},
141
- decisions: [],
142
- tech_stack: new Set<string>(),
143
- };
144
- const allPhaseDirs: Array<{
145
- name: string;
146
- fullPath: string;
147
- milestone: string | null;
148
- }> = [];
149
- for (const a of getArchivedPhaseDirs(cwd))
150
- allPhaseDirs.push({
151
- name: a.name,
152
- fullPath: a.fullPath,
153
- milestone: a.milestone,
154
- });
155
- if (fs.existsSync(phasesDir)) {
156
- try {
157
- for (const dir of fs
158
- .readdirSync(phasesDir, { withFileTypes: true })
159
- .filter((e) => e.isDirectory())
160
- .map((e) => e.name)
161
- .sort()) {
162
- allPhaseDirs.push({
163
- name: dir,
164
- fullPath: path.join(phasesDir, dir),
165
- milestone: null,
166
- });
167
- }
168
- } catch {
169
- /* ok */
170
- }
171
- }
172
- if (allPhaseDirs.length === 0) {
173
- output({ phases: {}, decisions: [], tech_stack: [] }, raw);
174
- return;
175
- }
176
- try {
177
- for (const { name: dir, fullPath: dirPath } of allPhaseDirs) {
178
- const summaries = fs
179
- .readdirSync(dirPath)
180
- .filter((f) => f.endsWith("-SUMMARY.md") || f === "SUMMARY.md");
181
- for (const summary of summaries) {
182
- try {
183
- const content = fs.readFileSync(path.join(dirPath, summary), "utf-8");
184
- const fm = extractFrontmatter(content);
185
- const phaseNum = asStr(fm.phase) ?? dir.split("-")[0];
186
- if (!digest.phases[phaseNum])
187
- digest.phases[phaseNum] = {
188
- name: asStr(fm.name) ?? dir.split("-").slice(1).join(" ") ?? "Unknown",
189
- provides: new Set(),
190
- affects: new Set(),
191
- patterns: new Set(),
192
- };
193
- const depGraph = asObj(fm["dependency-graph"]);
194
- if (depGraph?.provides)
195
- asArr(depGraph.provides)?.forEach((p) =>
196
- digest.phases[phaseNum].provides.add(asStr(p) ?? String(p)),
197
- );
198
- else if (fm.provides)
199
- asArr(fm.provides)?.forEach((p) =>
200
- digest.phases[phaseNum].provides.add(asStr(p) ?? String(p)),
201
- );
202
- if (depGraph?.affects)
203
- asArr(depGraph.affects)?.forEach((a) =>
204
- digest.phases[phaseNum].affects.add(asStr(a) ?? String(a)),
205
- );
206
- if (fm["patterns-established"])
207
- asArr(fm["patterns-established"])?.forEach((p) =>
208
- digest.phases[phaseNum].patterns.add(asStr(p) ?? String(p)),
209
- );
210
- if (fm["key-decisions"])
211
- asArr(fm["key-decisions"])?.forEach((d) =>
212
- digest.decisions.push({ phase: phaseNum, decision: asStr(d) ?? String(d) }),
213
- );
214
- const techStack = asObj(fm["tech-stack"]);
215
- if (techStack?.added)
216
- asArr(techStack.added)?.forEach((t) => {
217
- const s = asStr(t);
218
- if (s) digest.tech_stack.add(s);
219
- else { const o = asObj(t); if (o) digest.tech_stack.add(asStr(o.name) ?? ""); }
220
- });
221
- } catch {
222
- /* ok */
223
- }
224
- }
225
- }
226
- // Serialise Sets to arrays for JSON output
227
- const serialised = {
228
- phases: Object.fromEntries(
229
- Object.entries(digest.phases).map(([p, v]) => [
230
- p,
231
- {
232
- name: v.name,
233
- provides: [...v.provides],
234
- affects: [...v.affects],
235
- patterns: [...v.patterns],
236
- },
237
- ]),
238
- ),
239
- decisions: digest.decisions,
240
- tech_stack: [...digest.tech_stack],
241
- };
242
- output(serialised, raw);
243
- } catch (e) {
244
- gsdError("Failed to generate history digest: " + (e as Error).message);
245
- }
246
- }
247
-
248
- export function cmdResolveModel(
249
- cwd: string,
250
- agentType: string | undefined,
251
- raw: boolean,
252
- ): void {
253
- if (!agentType) gsdError("agent-type required");
254
- const config = loadConfig(cwd);
255
- const model = resolveModelInternal(cwd, agentType!);
256
- const agentModels = MODEL_PROFILES[agentType!];
257
- output(
258
- agentModels
259
- ? { model, profile: config.model_profile }
260
- : { model, profile: config.model_profile, unknown_agent: true },
261
- raw,
262
- model,
263
- );
264
- }
265
-
266
- export function cmdCommit(
267
- cwd: string,
268
- message: string | undefined,
269
- files: string[],
270
- raw: boolean,
271
- amend = false,
272
- noVerify = false,
273
- ): void {
274
- if (!message && !amend) gsdError("commit message required");
275
- let msg = message;
276
- if (msg) msg = sanitizeForPrompt(msg);
277
- const config = loadConfig(cwd);
278
- if (!config.commit_docs) {
279
- output(
280
- { committed: false, hash: null, reason: "skipped_commit_docs_false" },
281
- raw,
282
- "skipped",
283
- );
284
- return;
285
- }
286
- if (isGitIgnored(cwd, ".planning")) {
287
- output(
288
- { committed: false, hash: null, reason: "skipped_gitignored" },
289
- raw,
290
- "skipped",
291
- );
292
- return;
293
- }
294
- // Branch strategy
295
- if (config.branching_strategy && config.branching_strategy !== "none") {
296
- let branchName: string | null = null;
297
- if (config.branching_strategy === "phase") {
298
- const phaseMatch = (files || []).join(" ").match(/(\d+)-/);
299
- if (phaseMatch) {
300
- const phaseInfo = findPhaseInternal(cwd, phaseMatch[1]);
301
- if (phaseInfo)
302
- branchName = config.phase_branch_template
303
- .replace("{phase}", phaseInfo.phase_number)
304
- .replace("{slug}", phaseInfo.phase_slug || "phase");
305
- }
306
- } else if (config.branching_strategy === "milestone") {
307
- const milestoneInfo = getMilestoneInfo(cwd);
308
- if (milestoneInfo?.version)
309
- branchName = config.milestone_branch_template
310
- .replace("{milestone}", milestoneInfo.version)
311
- .replace(
312
- "{slug}",
313
- generateSlugInternal(milestoneInfo.name) || "milestone",
314
- );
315
- }
316
- if (branchName) {
317
- const currentBranch = execGit(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]);
318
- if (
319
- currentBranch.exitCode === 0 &&
320
- currentBranch.stdout.trim() !== branchName
321
- ) {
322
- const create = execGit(cwd, ["checkout", "-b", branchName]);
323
- if (create.exitCode !== 0) execGit(cwd, ["checkout", branchName]);
324
- }
325
- }
326
- }
327
- const filesToStage = files && files.length > 0 ? files : [".planning/"];
328
- for (const file of filesToStage) {
329
- const fullPath = path.join(cwd, file);
330
- if (!fs.existsSync(fullPath))
331
- execGit(cwd, ["rm", "--cached", "--ignore-unmatch", file]);
332
- else execGit(cwd, ["add", file]);
333
- }
334
- const commitArgs = amend
335
- ? ["commit", "--amend", "--no-edit"]
336
- : ["commit", "-m", msg!];
337
- if (noVerify) commitArgs.push("--no-verify");
338
- const commitResult = execGit(cwd, commitArgs);
339
- if (commitResult.exitCode !== 0) {
340
- if (
341
- commitResult.stdout.includes("nothing to commit") ||
342
- commitResult.stderr.includes("nothing to commit")
343
- ) {
344
- output(
345
- { committed: false, hash: null, reason: "nothing_to_commit" },
346
- raw,
347
- "nothing",
348
- );
349
- return;
350
- }
351
- output(
352
- {
353
- committed: false,
354
- hash: null,
355
- reason: "nothing_to_commit",
356
- error: commitResult.stderr,
357
- },
358
- raw,
359
- "nothing",
360
- );
361
- return;
362
- }
363
- const hashResult = execGit(cwd, ["rev-parse", "--short", "HEAD"]);
364
- const hash = hashResult.exitCode === 0 ? hashResult.stdout : null;
365
- output(
366
- { committed: true, hash, reason: "committed" },
367
- raw,
368
- hash || "committed",
369
- );
370
- }
371
-
372
- export function cmdCommitToSubrepo(
373
- cwd: string,
374
- message: string | undefined,
375
- files: string[],
376
- raw: boolean,
377
- ): void {
378
- if (!message) gsdError("commit message required");
379
- const config = loadConfig(cwd);
380
- const subRepos = config.sub_repos;
381
- if (!subRepos || subRepos.length === 0)
382
- gsdError("no sub_repos configured in .planning/config.json");
383
- if (!files || files.length === 0)
384
- gsdError("--files required for commit-to-subrepo");
385
- const grouped: Record<string, string[]> = {},
386
- unmatched: string[] = [];
387
- for (const file of files) {
388
- const match = subRepos.find((repo) => file.startsWith(repo + "/"));
389
- if (match) {
390
- if (!grouped[match]) grouped[match] = [];
391
- grouped[match].push(file);
392
- } else unmatched.push(file);
393
- }
394
- if (unmatched.length > 0)
395
- process.stderr.write(
396
- `Warning: ${unmatched.length} file(s) did not match any sub-repo prefix: ${unmatched.join(", ")}\n`,
397
- );
398
- const repos: Record<string, unknown> = {};
399
- for (const [repo, repoFiles] of Object.entries(grouped)) {
400
- const repoCwd = path.join(cwd, repo);
401
- for (const file of repoFiles)
402
- execGit(repoCwd, ["add", file.slice(repo.length + 1)]);
403
- const commitResult = execGit(repoCwd, ["commit", "-m", message!]);
404
- if (commitResult.exitCode !== 0) {
405
- repos[repo] = {
406
- committed: false,
407
- hash: null,
408
- files: repoFiles,
409
- reason: commitResult.stdout.includes("nothing to commit")
410
- ? "nothing_to_commit"
411
- : "error",
412
- error: commitResult.stderr,
413
- };
414
- continue;
415
- }
416
- const hashResult = execGit(repoCwd, ["rev-parse", "--short", "HEAD"]);
417
- repos[repo] = {
418
- committed: true,
419
- hash: hashResult.exitCode === 0 ? hashResult.stdout : null,
420
- files: repoFiles,
421
- };
422
- }
423
- output(
424
- {
425
- committed: Object.values(repos).some(
426
- (r) => (r as { committed: boolean }).committed,
427
- ),
428
- repos,
429
- unmatched: unmatched.length > 0 ? unmatched : undefined,
430
- },
431
- raw,
432
- Object.entries(repos)
433
- .map(([r, v]) => `${r}:${(v as { hash?: string }).hash || "skip"}`)
434
- .join(" "),
435
- );
436
- }
437
-
438
- export function cmdSummaryExtract(
439
- cwd: string,
440
- summaryPath: string | undefined,
441
- fields: string[] | null,
442
- raw: boolean,
443
- ): void {
444
- if (!summaryPath) gsdError("summary-path required for summary-extract");
445
- const fullPath = path.join(cwd, summaryPath!);
446
- if (!fs.existsSync(fullPath)) {
447
- output({ error: "File not found", path: summaryPath }, raw);
448
- return;
449
- }
450
- const content = fs.readFileSync(fullPath, "utf-8"),
451
- fm = extractFrontmatter(content);
452
- const parseDecisions = (list: import("./frontmatter.js").YamlValue[] | undefined) =>
453
- (list || []).map((d) => {
454
- const ds = asStr(d) ?? String(d);
455
- const idx = ds.indexOf(":");
456
- return idx > 0
457
- ? {
458
- summary: ds.substring(0, idx).trim(),
459
- rationale: ds.substring(idx + 1).trim(),
460
- }
461
- : { summary: ds, rationale: null };
462
- });
463
- const fullResult = {
464
- path: summaryPath,
465
- one_liner: asStr(fm["one-liner"]) ?? extractOneLinerFromBody(content) ?? null,
466
- key_files: asArr(fm["key-files"]) ?? [],
467
- tech_added: asArr(asObj(fm["tech-stack"])?.added) ?? [],
468
- patterns: asArr(fm["patterns-established"]) ?? [],
469
- decisions: parseDecisions(asArr(fm["key-decisions"])),
470
- requirements_completed: asArr(fm["requirements-completed"]) ?? [],
471
- };
472
- if (fields && fields.length > 0) {
473
- const filtered: Record<string, unknown> = { path: summaryPath };
474
- for (const field of fields)
475
- if ((fullResult as Record<string, unknown>)[field] !== undefined)
476
- filtered[field] = (fullResult as Record<string, unknown>)[field];
477
- output(filtered, raw);
478
- return;
479
- }
480
- output(fullResult, raw);
481
- }
482
-
483
- export async function cmdWebsearch(
484
- query: string | undefined,
485
- options: { limit?: number; freshness?: string | null },
486
- raw: boolean,
487
- ): Promise<void> {
488
- const apiKey = process.env["BRAVE_API_KEY"];
489
- if (!apiKey) {
490
- output({ available: false, reason: "BRAVE_API_KEY not set" }, raw, "");
491
- return;
492
- }
493
- if (!query) {
494
- output({ available: false, error: "Query required" }, raw, "");
495
- return;
496
- }
497
- const params = new URLSearchParams({
498
- q: query,
499
- count: String(options.limit || 10),
500
- country: "us",
501
- search_lang: "en",
502
- text_decorations: "false",
503
- });
504
- if (options.freshness) params.set("freshness", options.freshness);
505
- try {
506
- const response = await fetch(
507
- `https://api.search.brave.com/res/v1/web/search?${params}`,
508
- {
509
- headers: { Accept: "application/json", "X-Subscription-Token": apiKey },
510
- },
511
- );
512
- if (!response.ok) {
513
- output(
514
- { available: false, error: `API error: ${response.status}` },
515
- raw,
516
- "",
517
- );
518
- return;
519
- }
520
- const data = (await response.json()) as {
521
- web?: {
522
- results?: Array<{
523
- title: string;
524
- url: string;
525
- description: string;
526
- age?: string;
527
- }>;
528
- };
529
- };
530
- const results = (data.web?.results || []).map((r) => ({
531
- title: r.title,
532
- url: r.url,
533
- description: r.description,
534
- age: r.age || null,
535
- }));
536
- output(
537
- { available: true, query, count: results.length, results },
538
- raw,
539
- results.map((r) => `${r.title}\n${r.url}\n${r.description}`).join("\n\n"),
540
- );
541
- } catch (err) {
542
- output({ available: false, error: (err as Error).message }, raw, "");
543
- }
544
- }
545
-
546
- export function cmdProgressRender(
547
- cwd: string,
548
- format: string,
549
- raw: boolean,
550
- ): void {
551
- const phasesDir = planningPaths(cwd).phases,
552
- roadmapPath = planningPaths(cwd).roadmap;
553
- const milestone = getMilestoneInfo(cwd);
554
- const phases: Array<{
555
- number: string;
556
- name: string;
557
- plans: number;
558
- summaries: number;
559
- status: string;
560
- }> = [];
561
- let totalPlans = 0,
562
- totalSummaries = 0;
563
- try {
564
- const entries = fs
565
- .readdirSync(phasesDir, { withFileTypes: true })
566
- .filter((e) => e.isDirectory())
567
- .map((e) => e.name)
568
- .sort((a, b) => comparePhaseNum(a, b));
569
- for (const dir of entries) {
570
- const dm = dir.match(/^(\d+(?:\.\d+)*)-?(.*)/);
571
- const phaseNum = dm ? dm[1] : dir,
572
- phaseName = dm && dm[2] ? dm[2].replace(/-/g, " ") : "";
573
- const phaseFiles = fs.readdirSync(path.join(phasesDir, dir));
574
- const plans = phaseFiles.filter(
575
- (f) => f.endsWith("-PLAN.md") || f === "PLAN.md",
576
- ).length;
577
- const summaries = phaseFiles.filter(
578
- (f) => f.endsWith("-SUMMARY.md") || f === "SUMMARY.md",
579
- ).length;
580
- totalPlans += plans;
581
- totalSummaries += summaries;
582
- let status: string;
583
- if (plans === 0) status = "Pending";
584
- else if (summaries >= plans) status = "Complete";
585
- else if (summaries > 0) status = "In Progress";
586
- else status = "Planned";
587
- phases.push({
588
- number: phaseNum,
589
- name: phaseName,
590
- plans,
591
- summaries,
592
- status,
593
- });
594
- }
595
- } catch {
596
- /* ok */
597
- }
598
- const percent =
599
- totalPlans > 0
600
- ? Math.min(100, Math.round((totalSummaries / totalPlans) * 100))
601
- : 0;
602
- if (format === "table") {
603
- const barWidth = 10,
604
- filled = Math.round((percent / 100) * barWidth);
605
- const bar = "█".repeat(filled) + "░".repeat(barWidth - filled);
606
- let out = `# ${milestone.version} ${milestone.name}\n\n**Progress:** [${bar}] ${totalSummaries}/${totalPlans} plans (${percent}%)\n\n| Phase | Name | Plans | Status |\n|-------|------|-------|--------|\n`;
607
- for (const p of phases)
608
- out += `| ${p.number} | ${p.name} | ${p.summaries}/${p.plans} | ${p.status} |\n`;
609
- output({ rendered: out }, raw, out);
610
- } else if (format === "bar") {
611
- const barWidth = 20,
612
- filled = Math.round((percent / 100) * barWidth);
613
- const bar = "█".repeat(filled) + "░".repeat(barWidth - filled);
614
- const text = `[${bar}] ${totalSummaries}/${totalPlans} plans (${percent}%)`;
615
- output(
616
- { bar: text, percent, completed: totalSummaries, total: totalPlans },
617
- raw,
618
- text,
619
- );
620
- } else {
621
- output(
622
- {
623
- milestone_version: milestone.version,
624
- milestone_name: milestone.name,
625
- phases,
626
- total_plans: totalPlans,
627
- total_summaries: totalSummaries,
628
- percent,
629
- },
630
- raw,
631
- );
632
- }
633
- }
634
-
635
- export function cmdTodoComplete(
636
- cwd: string,
637
- filename: string | undefined,
638
- raw: boolean,
639
- ): void {
640
- if (!filename) gsdError("filename required for todo complete");
641
- const pendingDir = path.join(planningDir(cwd), "todos", "pending");
642
- const completedDir = path.join(planningDir(cwd), "todos", "completed");
643
- const sourcePath = path.join(pendingDir, filename!);
644
- if (!fs.existsSync(sourcePath)) gsdError(`Todo not found: ${filename}`);
645
- fs.mkdirSync(completedDir, { recursive: true });
646
- let content = fs.readFileSync(sourcePath, "utf-8");
647
- const today = new Date().toISOString().split("T")[0];
648
- content = `completed: ${today}\n` + content;
649
- fs.writeFileSync(path.join(completedDir, filename!), content, "utf-8");
650
- fs.unlinkSync(sourcePath);
651
- output({ completed: true, file: filename, date: today }, raw, "completed");
652
- }
653
-
654
- export function cmdTodoMatchPhase(
655
- cwd: string,
656
- phase: string | undefined,
657
- raw: boolean,
658
- ): void {
659
- if (!phase) gsdError("phase required for todo match-phase");
660
- const pendingDir = path.join(planningDir(cwd), "todos", "pending");
661
- const todos: Array<{
662
- file: string;
663
- title: string;
664
- area: string;
665
- files: string[];
666
- body: string;
667
- }> = [];
668
- try {
669
- for (const file of fs
670
- .readdirSync(pendingDir)
671
- .filter((f) => f.endsWith(".md"))) {
672
- try {
673
- const content = fs.readFileSync(path.join(pendingDir, file), "utf-8");
674
- const titleMatch = content.match(/^title:\s*(.+)$/m),
675
- areaMatch = content.match(/^area:\s*(.+)$/m),
676
- filesMatch = content.match(/^files:\s*(.+)$/m);
677
- const body = content
678
- .replace(/^(title|area|files|created|priority):.*$/gm, "")
679
- .trim();
680
- todos.push({
681
- file,
682
- title: titleMatch ? titleMatch[1].trim() : "Untitled",
683
- area: areaMatch ? areaMatch[1].trim() : "general",
684
- files: filesMatch
685
- ? filesMatch[1]
686
- .trim()
687
- .split(/[,\s]+/)
688
- .filter(Boolean)
689
- : [],
690
- body: body.slice(0, 200),
691
- });
692
- } catch {
693
- /* ok */
694
- }
695
- }
696
- } catch {
697
- /* ok */
698
- }
699
- if (todos.length === 0) {
700
- output({ phase, matches: [], todo_count: 0 }, raw);
701
- return;
702
- }
703
- const phaseInfo2 = getRoadmapPhaseInternal(cwd, phase!);
704
- const phaseText =
705
- `${phaseInfo2?.phase_name ?? ""} ${phaseInfo2?.goal ?? ""} ${phaseInfo2?.section ?? ""}`.toLowerCase();
706
- const stopWords = new Set([
707
- "the",
708
- "and",
709
- "for",
710
- "with",
711
- "from",
712
- "that",
713
- "this",
714
- "will",
715
- "are",
716
- "was",
717
- "has",
718
- "have",
719
- "been",
720
- "not",
721
- "but",
722
- "all",
723
- "can",
724
- "into",
725
- "each",
726
- "when",
727
- "any",
728
- "use",
729
- "new",
730
- ]);
731
- const phaseKeywords = new Set(
732
- phaseText
733
- .split(/[\s\-_/.,;:()[\]{}|]+/)
734
- .map((w) => w.replace(/[^a-z0-9]/g, ""))
735
- .filter((w) => w.length > 2 && !stopWords.has(w)),
736
- );
737
- const phaseInfoDisk = findPhaseInternal(cwd, phase!);
738
- const phasePlans: string[] = [];
739
- if (phaseInfoDisk?.found) {
740
- try {
741
- const phaseDir = path.join(cwd, phaseInfoDisk.directory);
742
- for (const pf of fs
743
- .readdirSync(phaseDir)
744
- .filter((f) => f.endsWith("-PLAN.md"))) {
745
- try {
746
- const planContent = fs.readFileSync(path.join(phaseDir, pf), "utf-8");
747
- const fmFiles = planContent.match(/files_modified:\s*\[([^\]]*)\]/);
748
- if (fmFiles)
749
- phasePlans.push(
750
- ...fmFiles[1]
751
- .split(",")
752
- .map((s) => s.trim().replace(/['"]/g, ""))
753
- .filter(Boolean),
754
- );
755
- } catch {
756
- /* ok */
757
- }
758
- }
759
- } catch {
760
- /* ok */
761
- }
762
- }
763
- const matches: unknown[] = [];
764
- for (const todo of todos) {
765
- let score = 0;
766
- const reasons: string[] = [];
767
- const todoWords = `${todo.title} ${todo.body}`
768
- .toLowerCase()
769
- .split(/[\s\-_/.,;:()[\]{}|]+/)
770
- .map((w) => w.replace(/[^a-z0-9]/g, ""))
771
- .filter((w) => w.length > 2 && !stopWords.has(w));
772
- const matchedKeywords = todoWords.filter((w) => phaseKeywords.has(w));
773
- if (matchedKeywords.length > 0) {
774
- score += Math.min(matchedKeywords.length * 0.2, 0.6);
775
- reasons.push(
776
- `keywords: ${[...new Set(matchedKeywords)].slice(0, 5).join(", ")}`,
777
- );
778
- }
779
- if (
780
- todo.area !== "general" &&
781
- phaseText.includes(todo.area.toLowerCase())
782
- ) {
783
- score += 0.3;
784
- reasons.push(`area: ${todo.area}`);
785
- }
786
- if (todo.files.length > 0 && phasePlans.length > 0) {
787
- const fileOverlap = todo.files.filter((f) =>
788
- phasePlans.some((pf) => pf.includes(f) || f.includes(pf)),
789
- );
790
- if (fileOverlap.length > 0) {
791
- score += 0.4;
792
- reasons.push(`files: ${fileOverlap.slice(0, 3).join(", ")}`);
793
- }
794
- }
795
- if (score > 0)
796
- matches.push({
797
- file: todo.file,
798
- title: todo.title,
799
- area: todo.area,
800
- score: Math.round(score * 100) / 100,
801
- reasons,
802
- });
803
- }
804
- (matches as Array<{ score: number }>).sort((a, b) => b.score - a.score);
805
- output({ phase, matches, todo_count: todos.length }, raw);
806
- }
807
-
808
- export function cmdScaffold(
809
- cwd: string,
810
- type: string | undefined,
811
- options: { phase?: string | null; name?: string | null },
812
- raw: boolean,
813
- ): void {
814
- const { phase, name } = options;
815
- const padded = phase ? normalizePhaseName(phase) : "00";
816
- const today = new Date().toISOString().split("T")[0];
817
- const phaseInfo = phase ? findPhaseInternal(cwd, phase) : null;
818
- const phaseDir = phaseInfo ? path.join(cwd, phaseInfo.directory) : null;
819
- if (phase && !phaseDir && type !== "phase-dir")
820
- gsdError(`Phase ${phase} directory not found`);
821
- let filePath: string, content: string;
822
- switch (type) {
823
- case "context":
824
- filePath = path.join(phaseDir!, `${padded}-CONTEXT.md`);
825
- content = `---\nphase: "${padded}"\nname: "${name || phaseInfo?.phase_name || "Unnamed"}"\ncreated: ${today}\n---\n\n# Phase ${phase}: ${name || phaseInfo?.phase_name || "Unnamed"} - Context\n\n## Decisions\n\n_Decisions will be captured during /gsd-discuss-phase ${phase}_\n\n## Discretion Areas\n\n_Areas where the executor can use judgment_\n\n## Deferred Ideas\n\n_Ideas to consider later_\n`;
826
- break;
827
- case "uat":
828
- filePath = path.join(phaseDir!, `${padded}-UAT.md`);
829
- content = `---\nphase: "${padded}"\nname: "${name || phaseInfo?.phase_name || "Unnamed"}"\ncreated: ${today}\nstatus: pending\n---\n\n# Phase ${phase}: ${name || phaseInfo?.phase_name || "Unnamed"} - User Acceptance Testing\n\n## Test Results\n\n| # | Test | Status | Notes |\n|---|------|--------|-------|\n\n## Summary\n\n_Pending UAT_\n`;
830
- break;
831
- case "verification":
832
- filePath = path.join(phaseDir!, `${padded}-VERIFICATION.md`);
833
- content = `---\nphase: "${padded}"\nname: "${name || phaseInfo?.phase_name || "Unnamed"}"\ncreated: ${today}\nstatus: pending\n---\n\n# Phase ${phase}: ${name || phaseInfo?.phase_name || "Unnamed"} - Verification\n\n## Goal-Backward Verification\n\n**Phase Goal:** [From ROADMAP.md]\n\n## Checks\n\n| # | Requirement | Status | Evidence |\n|---|------------|--------|----------|\n\n## Result\n\n_Pending verification_\n`;
834
- break;
835
- case "phase-dir": {
836
- if (!phase || !name)
837
- gsdError("phase and name required for phase-dir scaffold");
838
- const slug = generateSlugInternal(name!);
839
- const dirName = `${padded}-${slug}`;
840
- const phasesParent = planningPaths(cwd).phases;
841
- fs.mkdirSync(phasesParent, { recursive: true });
842
- const dirPath = path.join(phasesParent, dirName);
843
- fs.mkdirSync(dirPath, { recursive: true });
844
- output(
845
- {
846
- created: true,
847
- directory: toPosixPath(path.relative(cwd, dirPath)),
848
- path: dirPath,
849
- },
850
- raw,
851
- dirPath,
852
- );
853
- return;
854
- }
855
- default:
856
- gsdError(
857
- `Unknown scaffold type: ${type}. Available: context, uat, verification, phase-dir`,
858
- );
859
- return;
860
- }
861
- if (fs.existsSync(filePath)) {
862
- output(
863
- { created: false, reason: "already_exists", path: filePath },
864
- raw,
865
- "exists",
866
- );
867
- return;
868
- }
869
- fs.writeFileSync(filePath, content, "utf-8");
870
- const relPath = toPosixPath(path.relative(cwd, filePath));
871
- output({ created: true, path: relPath }, raw, relPath);
872
- }
873
-
874
- export function cmdStats(cwd: string, format: string, raw: boolean): void {
875
- const phasesDir = planningPaths(cwd).phases,
876
- roadmapPath = planningPaths(cwd).roadmap,
877
- reqPath = planningPaths(cwd).requirements,
878
- statePath = planningPaths(cwd).state;
879
- const milestone = getMilestoneInfo(cwd),
880
- isDirInMilestone = getMilestonePhaseFilter(cwd);
881
- const phasesByNumber = new Map<
882
- string,
883
- {
884
- number: string;
885
- name: string;
886
- plans: number;
887
- summaries: number;
888
- status: string;
889
- }
890
- >();
891
- let totalPlans = 0,
892
- totalSummaries = 0;
893
- try {
894
- const roadmapContent = extractCurrentMilestone(
895
- fs.readFileSync(roadmapPath, "utf-8"),
896
- cwd,
897
- );
898
- const headingPattern =
899
- /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi;
900
- let match: RegExpExecArray | null;
901
- while ((match = headingPattern.exec(roadmapContent)) !== null)
902
- phasesByNumber.set(match[1], {
903
- number: match[1],
904
- name: match[2].replace(/\(INSERTED\)/i, "").trim(),
905
- plans: 0,
906
- summaries: 0,
907
- status: "Not Started",
908
- });
909
- } catch {
910
- /* ok */
911
- }
912
- try {
913
- const dirs = fs
914
- .readdirSync(phasesDir, { withFileTypes: true })
915
- .filter((e) => e.isDirectory())
916
- .map((e) => e.name)
917
- .filter(isDirInMilestone)
918
- .sort((a, b) => comparePhaseNum(a, b));
919
- for (const dir of dirs) {
920
- const dm = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i);
921
- const phaseNum = dm ? dm[1] : dir,
922
- phaseName = dm && dm[2] ? dm[2].replace(/-/g, " ") : "";
923
- const phaseFiles = fs.readdirSync(path.join(phasesDir, dir));
924
- const plans = phaseFiles.filter(
925
- (f) => f.endsWith("-PLAN.md") || f === "PLAN.md",
926
- ).length;
927
- const summaries = phaseFiles.filter(
928
- (f) => f.endsWith("-SUMMARY.md") || f === "SUMMARY.md",
929
- ).length;
930
- totalPlans += plans;
931
- totalSummaries += summaries;
932
- let status: string;
933
- if (plans === 0) status = "Not Started";
934
- else if (summaries >= plans) status = "Complete";
935
- else if (summaries > 0) status = "In Progress";
936
- else status = "Planned";
937
- const existing = phasesByNumber.get(phaseNum);
938
- phasesByNumber.set(phaseNum, {
939
- number: phaseNum,
940
- name: existing?.name || phaseName,
941
- plans,
942
- summaries,
943
- status,
944
- });
945
- }
946
- } catch {
947
- /* ok */
948
- }
949
- const phases = [...phasesByNumber.values()].sort((a, b) =>
950
- comparePhaseNum(a.number, b.number),
951
- );
952
- const completedPhases = phases.filter((p) => p.status === "Complete").length;
953
- const planPercent =
954
- totalPlans > 0
955
- ? Math.min(100, Math.round((totalSummaries / totalPlans) * 100))
956
- : 0;
957
- const percent =
958
- phases.length > 0
959
- ? Math.min(100, Math.round((completedPhases / phases.length) * 100))
960
- : 0;
961
- let requirementsTotal = 0,
962
- requirementsComplete = 0;
963
- try {
964
- if (fs.existsSync(reqPath)) {
965
- const reqContent = fs.readFileSync(reqPath, "utf-8");
966
- requirementsComplete = (reqContent.match(/^- \[x\] \*\*/gm) || []).length;
967
- requirementsTotal =
968
- requirementsComplete +
969
- (reqContent.match(/^- \[ \] \*\*/gm) || []).length;
970
- }
971
- } catch {
972
- /* ok */
973
- }
974
- let lastActivity: string | null = null;
975
- try {
976
- if (fs.existsSync(statePath)) {
977
- const sc = fs.readFileSync(statePath, "utf-8");
978
- lastActivity =
979
- (sc.match(/^last_activity:\s*(.+)$/im) ??
980
- sc.match(/\*\*Last Activity:\*\*\s*(.+)/i) ??
981
- sc.match(/^Last Activity:\s*(.+)$/im))?.[1]?.trim() ?? null;
982
- }
983
- } catch {
984
- /* ok */
985
- }
986
- let gitCommits = 0,
987
- gitFirstCommitDate: string | null = null;
988
- const cc = execGit(cwd, ["rev-list", "--count", "HEAD"]);
989
- if (cc.exitCode === 0) gitCommits = parseInt(cc.stdout, 10) || 0;
990
- const rh = execGit(cwd, ["rev-list", "--max-parents=0", "HEAD"]);
991
- if (rh.exitCode === 0 && rh.stdout) {
992
- const fh = execGit(cwd, [
993
- "show",
994
- "-s",
995
- "--format=%as",
996
- rh.stdout.split("\n")[0].trim(),
997
- ]);
998
- if (fh.exitCode === 0) gitFirstCommitDate = fh.stdout || null;
999
- }
1000
- const result = {
1001
- milestone_version: milestone.version,
1002
- milestone_name: milestone.name,
1003
- phases,
1004
- phases_completed: completedPhases,
1005
- phases_total: phases.length,
1006
- total_plans: totalPlans,
1007
- total_summaries: totalSummaries,
1008
- percent,
1009
- plan_percent: planPercent,
1010
- requirements_total: requirementsTotal,
1011
- requirements_complete: requirementsComplete,
1012
- git_commits: gitCommits,
1013
- git_first_commit_date: gitFirstCommitDate,
1014
- last_activity: lastActivity,
1015
- };
1016
- if (format === "table") {
1017
- const barWidth = 10,
1018
- filled = Math.round((percent / 100) * barWidth);
1019
- const bar = "█".repeat(filled) + "░".repeat(barWidth - filled);
1020
- let out = `# ${milestone.version} ${milestone.name} - Statistics\n\n**Progress:** [${bar}] ${completedPhases}/${phases.length} phases (${percent}%)\n`;
1021
- if (totalPlans > 0)
1022
- out += `**Plans:** ${totalSummaries}/${totalPlans} complete (${planPercent}%)\n`;
1023
- out += `**Phases:** ${completedPhases}/${phases.length} complete\n`;
1024
- if (requirementsTotal > 0)
1025
- out += `**Requirements:** ${requirementsComplete}/${requirementsTotal} complete\n`;
1026
- out +=
1027
- "\n| Phase | Name | Plans | Completed | Status |\n|-------|------|-------|-----------|--------|\n";
1028
- for (const p of phases)
1029
- out += `| ${p.number} | ${p.name} | ${p.plans} | ${p.summaries} | ${p.status} |\n`;
1030
- if (gitCommits > 0) {
1031
- out += `\n**Git:** ${gitCommits} commits`;
1032
- if (gitFirstCommitDate) out += ` (since ${gitFirstCommitDate})`;
1033
- out += "\n";
1034
- }
1035
- if (lastActivity) out += `**Last activity:** ${lastActivity}\n`;
1036
- output({ rendered: out }, raw, out);
1037
- } else {
1038
- output(result, raw);
1039
- }
1040
- }