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.
- package/dist/pi-gsd-hooks.js +1533 -0
- package/dist/pi-gsd-tools.js +53 -52
- package/package.json +3 -5
- package/.gsd/extensions/pi-gsd-hooks.ts +0 -973
- package/src/cli.ts +0 -644
- package/src/commands/base.ts +0 -67
- package/src/commands/commit.ts +0 -22
- package/src/commands/config.ts +0 -71
- package/src/commands/frontmatter.ts +0 -51
- package/src/commands/index.ts +0 -76
- package/src/commands/init.ts +0 -43
- package/src/commands/milestone.ts +0 -37
- package/src/commands/phase.ts +0 -92
- package/src/commands/progress.ts +0 -71
- package/src/commands/roadmap.ts +0 -40
- package/src/commands/scaffold.ts +0 -19
- package/src/commands/state.ts +0 -102
- package/src/commands/template.ts +0 -52
- package/src/commands/verify.ts +0 -70
- package/src/commands/workstream.ts +0 -98
- package/src/commands/wxp.ts +0 -65
- package/src/lib/commands.ts +0 -1040
- package/src/lib/config.ts +0 -385
- package/src/lib/core.ts +0 -1167
- package/src/lib/frontmatter.ts +0 -462
- package/src/lib/init.ts +0 -517
- package/src/lib/milestone.ts +0 -290
- package/src/lib/model-profiles.ts +0 -272
- package/src/lib/phase.ts +0 -1012
- package/src/lib/profile-output.ts +0 -237
- package/src/lib/profile-pipeline.ts +0 -556
- package/src/lib/roadmap.ts +0 -378
- package/src/lib/schemas.ts +0 -290
- package/src/lib/security.ts +0 -176
- package/src/lib/state.ts +0 -1175
- package/src/lib/template.ts +0 -246
- package/src/lib/uat.ts +0 -289
- package/src/lib/verify.ts +0 -879
- package/src/lib/workstream.ts +0 -524
- package/src/output.ts +0 -45
- package/src/schemas/pi-gsd-settings.schema.json +0 -80
- package/src/schemas/wxp.xsd +0 -619
- package/src/schemas/wxp.zod.ts +0 -318
- package/src/wxp/__tests__/arguments.test.ts +0 -86
- package/src/wxp/__tests__/conditions.test.ts +0 -106
- package/src/wxp/__tests__/executor.test.ts +0 -95
- package/src/wxp/__tests__/helpers.ts +0 -26
- package/src/wxp/__tests__/integration.test.ts +0 -166
- package/src/wxp/__tests__/new-features.test.ts +0 -222
- package/src/wxp/__tests__/parser.test.ts +0 -159
- package/src/wxp/__tests__/paste.test.ts +0 -66
- package/src/wxp/__tests__/schema.test.ts +0 -120
- package/src/wxp/__tests__/security.test.ts +0 -87
- package/src/wxp/__tests__/shell.test.ts +0 -85
- package/src/wxp/__tests__/string-ops.test.ts +0 -25
- package/src/wxp/__tests__/variables.test.ts +0 -65
- package/src/wxp/arguments.ts +0 -89
- package/src/wxp/conditions.ts +0 -78
- package/src/wxp/executor.ts +0 -191
- package/src/wxp/index.ts +0 -191
- package/src/wxp/parser.ts +0 -198
- package/src/wxp/paste.ts +0 -51
- package/src/wxp/security.ts +0 -102
- package/src/wxp/shell.ts +0 -81
- package/src/wxp/string-ops.ts +0 -44
- package/src/wxp/variables.ts +0 -109
package/src/lib/commands.ts
DELETED
|
@@ -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
|
-
}
|