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.
- package/dist/pi-gsd-hooks.js +1532 -0
- 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/core.ts
DELETED
|
@@ -1,1167 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* core.ts - Shared utilities, path helpers, config loading, git helpers.
|
|
3
|
-
*
|
|
4
|
-
* Ported from lib/core.cjs. All functions preserve their original signatures.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import { execFileSync, spawnSync } from "child_process";
|
|
8
|
-
import fs from "fs";
|
|
9
|
-
import os from "os";
|
|
10
|
-
import path from "path";
|
|
11
|
-
import { MODEL_PROFILES } from "./model-profiles.js";
|
|
12
|
-
|
|
13
|
-
export { MODEL_PROFILES };
|
|
14
|
-
|
|
15
|
-
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
16
|
-
|
|
17
|
-
export type PhaseNamingMode = "sequential" | "custom";
|
|
18
|
-
export type ModelProfile = "quality" | "balanced" | "budget" | "inherit";
|
|
19
|
-
export type ResolveModelIds = false | true | "omit";
|
|
20
|
-
|
|
21
|
-
export interface GSDConfig {
|
|
22
|
-
model_profile: ModelProfile;
|
|
23
|
-
commit_docs: boolean;
|
|
24
|
-
search_gitignored: boolean;
|
|
25
|
-
branching_strategy: "none" | "phase" | "milestone" | "workstream";
|
|
26
|
-
phase_branch_template: string;
|
|
27
|
-
milestone_branch_template: string;
|
|
28
|
-
quick_branch_template: string | null;
|
|
29
|
-
research: boolean;
|
|
30
|
-
plan_checker: boolean;
|
|
31
|
-
verifier: boolean;
|
|
32
|
-
nyquist_validation: boolean;
|
|
33
|
-
parallelization: boolean;
|
|
34
|
-
brave_search: boolean;
|
|
35
|
-
firecrawl: boolean;
|
|
36
|
-
exa_search: boolean;
|
|
37
|
-
text_mode: boolean;
|
|
38
|
-
sub_repos: string[];
|
|
39
|
-
resolve_model_ids: ResolveModelIds;
|
|
40
|
-
context_window: number;
|
|
41
|
-
phase_naming: PhaseNamingMode;
|
|
42
|
-
model_overrides: Record<string, string> | null;
|
|
43
|
-
agent_skills: Record<string, unknown>;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export interface PlanningPaths {
|
|
47
|
-
planning: string;
|
|
48
|
-
state: string;
|
|
49
|
-
roadmap: string;
|
|
50
|
-
project: string;
|
|
51
|
-
config: string;
|
|
52
|
-
phases: string;
|
|
53
|
-
requirements: string;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export interface PhaseSearchResult {
|
|
57
|
-
found: true;
|
|
58
|
-
directory: string;
|
|
59
|
-
phase_number: string;
|
|
60
|
-
phase_name: string | null;
|
|
61
|
-
phase_slug: string | null;
|
|
62
|
-
plans: string[];
|
|
63
|
-
summaries: string[];
|
|
64
|
-
incomplete_plans: string[];
|
|
65
|
-
has_research: boolean;
|
|
66
|
-
has_context: boolean;
|
|
67
|
-
has_verification: boolean;
|
|
68
|
-
has_reviews: boolean;
|
|
69
|
-
archived?: string;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export interface ArchivedPhaseEntry {
|
|
73
|
-
name: string;
|
|
74
|
-
milestone: string;
|
|
75
|
-
basePath: string;
|
|
76
|
-
fullPath: string;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
export interface MilestoneInfo {
|
|
80
|
-
version: string;
|
|
81
|
-
name: string;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
export interface RoadmapPhaseResult {
|
|
85
|
-
found: true;
|
|
86
|
-
phase_number: string;
|
|
87
|
-
phase_name: string;
|
|
88
|
-
goal: string | null;
|
|
89
|
-
section: string;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
export interface GitResult {
|
|
93
|
-
exitCode: number;
|
|
94
|
-
stdout: string;
|
|
95
|
-
stderr: string;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
export interface PhaseFileStats {
|
|
99
|
-
plans: string[];
|
|
100
|
-
summaries: string[];
|
|
101
|
-
hasResearch: boolean;
|
|
102
|
-
hasContext: boolean;
|
|
103
|
-
hasVerification: boolean;
|
|
104
|
-
hasReviews: boolean;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
export interface AgentsInstallStatus {
|
|
108
|
-
agents_installed: boolean;
|
|
109
|
-
missing_agents: string[];
|
|
110
|
-
installed_agents: string[];
|
|
111
|
-
agents_dir: string;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
export interface ReapOptions {
|
|
115
|
-
maxAgeMs?: number;
|
|
116
|
-
dirsOnly?: boolean;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
// ─── Model alias map ──────────────────────────────────────────────────────────
|
|
120
|
-
|
|
121
|
-
export const MODEL_ALIAS_MAP: Record<string, string> = {
|
|
122
|
-
opus: "claude-opus-4-6",
|
|
123
|
-
sonnet: "claude-sonnet-4-6",
|
|
124
|
-
haiku: "claude-haiku-4-5",
|
|
125
|
-
};
|
|
126
|
-
|
|
127
|
-
// ─── Path helpers ─────────────────────────────────────────────────────────────
|
|
128
|
-
|
|
129
|
-
export function toPosixPath(p: string): string {
|
|
130
|
-
return p.split(path.sep).join("/");
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
export function detectSubRepos(cwd: string): string[] {
|
|
134
|
-
const results: string[] = [];
|
|
135
|
-
try {
|
|
136
|
-
const entries = fs.readdirSync(cwd, { withFileTypes: true });
|
|
137
|
-
for (const entry of entries) {
|
|
138
|
-
if (!entry.isDirectory()) continue;
|
|
139
|
-
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
|
|
140
|
-
const gitPath = path.join(cwd, entry.name, ".git");
|
|
141
|
-
try {
|
|
142
|
-
if (fs.existsSync(gitPath)) results.push(entry.name);
|
|
143
|
-
} catch {
|
|
144
|
-
/* ok */
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
} catch {
|
|
148
|
-
/* ok */
|
|
149
|
-
}
|
|
150
|
-
return results.sort();
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
export function findProjectRoot(startDir: string): string {
|
|
154
|
-
const resolved = path.resolve(startDir);
|
|
155
|
-
const root = path.parse(resolved).root;
|
|
156
|
-
const homedir = os.homedir();
|
|
157
|
-
|
|
158
|
-
const ownPlanning = path.join(resolved, ".planning");
|
|
159
|
-
if (fs.existsSync(ownPlanning) && fs.statSync(ownPlanning).isDirectory()) {
|
|
160
|
-
return startDir;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
function isInsideGitRepo(candidateParent: string): boolean {
|
|
164
|
-
let d = resolved;
|
|
165
|
-
while (d !== root) {
|
|
166
|
-
if (fs.existsSync(path.join(d, ".git"))) return true;
|
|
167
|
-
if (d === candidateParent) break;
|
|
168
|
-
d = path.dirname(d);
|
|
169
|
-
}
|
|
170
|
-
return false;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
let dir = resolved;
|
|
174
|
-
while (dir !== root) {
|
|
175
|
-
const parent = path.dirname(dir);
|
|
176
|
-
if (parent === dir) break;
|
|
177
|
-
if (parent === homedir) break;
|
|
178
|
-
|
|
179
|
-
const parentPlanning = path.join(parent, ".planning");
|
|
180
|
-
if (
|
|
181
|
-
fs.existsSync(parentPlanning) &&
|
|
182
|
-
fs.statSync(parentPlanning).isDirectory()
|
|
183
|
-
) {
|
|
184
|
-
const configPath = path.join(parentPlanning, "config.json");
|
|
185
|
-
try {
|
|
186
|
-
const cfg = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
|
187
|
-
const subRepos: string[] =
|
|
188
|
-
cfg.sub_repos || cfg.planning?.sub_repos || [];
|
|
189
|
-
if (Array.isArray(subRepos) && subRepos.length > 0) {
|
|
190
|
-
const relPath = path.relative(parent, resolved);
|
|
191
|
-
const topSegment = relPath.split(path.sep)[0];
|
|
192
|
-
if (subRepos.includes(topSegment)) return parent;
|
|
193
|
-
}
|
|
194
|
-
if (cfg.multiRepo === true && isInsideGitRepo(parent)) return parent;
|
|
195
|
-
} catch {
|
|
196
|
-
/* fall through to heuristic */
|
|
197
|
-
}
|
|
198
|
-
if (isInsideGitRepo(parent)) return parent;
|
|
199
|
-
}
|
|
200
|
-
dir = parent;
|
|
201
|
-
}
|
|
202
|
-
return startDir;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
// ─── Output helpers ───────────────────────────────────────────────────────────
|
|
206
|
-
|
|
207
|
-
export function reapStaleTempFiles(
|
|
208
|
-
prefix = "gsd-",
|
|
209
|
-
{ maxAgeMs = 5 * 60 * 1000, dirsOnly = false }: ReapOptions = {},
|
|
210
|
-
): void {
|
|
211
|
-
try {
|
|
212
|
-
const tmpDir = os.tmpdir();
|
|
213
|
-
const now = Date.now();
|
|
214
|
-
for (const entry of fs.readdirSync(tmpDir)) {
|
|
215
|
-
if (!entry.startsWith(prefix)) continue;
|
|
216
|
-
const fullPath = path.join(tmpDir, entry);
|
|
217
|
-
try {
|
|
218
|
-
const stat = fs.statSync(fullPath);
|
|
219
|
-
if (now - stat.mtimeMs > maxAgeMs) {
|
|
220
|
-
if (stat.isDirectory()) {
|
|
221
|
-
fs.rmSync(fullPath, { recursive: true, force: true });
|
|
222
|
-
} else if (!dirsOnly) {
|
|
223
|
-
fs.unlinkSync(fullPath);
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
} catch {
|
|
227
|
-
/* ok */
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
} catch {
|
|
231
|
-
/* non-critical */
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
export function output(result: unknown, raw = false, rawValue?: string): void {
|
|
236
|
-
let data: string;
|
|
237
|
-
if (raw && rawValue !== undefined) {
|
|
238
|
-
data = String(rawValue);
|
|
239
|
-
} else {
|
|
240
|
-
const json = JSON.stringify(result, null, 2);
|
|
241
|
-
if (json.length > 50000) {
|
|
242
|
-
reapStaleTempFiles();
|
|
243
|
-
const tmpPath = path.join(os.tmpdir(), `gsd-${Date.now()}.json`);
|
|
244
|
-
fs.writeFileSync(tmpPath, json, "utf-8");
|
|
245
|
-
data = "@file:" + tmpPath;
|
|
246
|
-
} else {
|
|
247
|
-
data = json;
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
fs.writeSync(1, data);
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
export function gsdError(message: string): never {
|
|
254
|
-
fs.writeSync(2, "Error: " + message + "\n");
|
|
255
|
-
process.exit(1);
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
// ─── File & Config utilities ──────────────────────────────────────────────────
|
|
259
|
-
|
|
260
|
-
export function safeReadFile(filePath: string): string | null {
|
|
261
|
-
try {
|
|
262
|
-
return fs.readFileSync(filePath, "utf-8");
|
|
263
|
-
} catch {
|
|
264
|
-
return null;
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
export function loadConfig(cwd: string): GSDConfig {
|
|
269
|
-
const configPath = path.join(cwd, ".planning", "config.json");
|
|
270
|
-
const defaults: GSDConfig = {
|
|
271
|
-
model_profile: "balanced",
|
|
272
|
-
commit_docs: true,
|
|
273
|
-
search_gitignored: false,
|
|
274
|
-
branching_strategy: "none",
|
|
275
|
-
phase_branch_template: "gsd/phase-{phase}-{slug}",
|
|
276
|
-
milestone_branch_template: "gsd/{milestone}-{slug}",
|
|
277
|
-
quick_branch_template: null,
|
|
278
|
-
research: true,
|
|
279
|
-
plan_checker: true,
|
|
280
|
-
verifier: true,
|
|
281
|
-
nyquist_validation: true,
|
|
282
|
-
parallelization: true,
|
|
283
|
-
brave_search: false,
|
|
284
|
-
firecrawl: false,
|
|
285
|
-
exa_search: false,
|
|
286
|
-
text_mode: false,
|
|
287
|
-
sub_repos: [],
|
|
288
|
-
resolve_model_ids: false,
|
|
289
|
-
context_window: 200000,
|
|
290
|
-
phase_naming: "sequential",
|
|
291
|
-
model_overrides: null,
|
|
292
|
-
agent_skills: {},
|
|
293
|
-
};
|
|
294
|
-
|
|
295
|
-
try {
|
|
296
|
-
const raw = fs.readFileSync(configPath, "utf-8");
|
|
297
|
-
const parsed: Record<string, unknown> = JSON.parse(raw) as Record<string, unknown>;
|
|
298
|
-
|
|
299
|
-
// Migrate deprecated "depth" → "granularity"
|
|
300
|
-
if ("depth" in parsed && !("granularity" in parsed)) {
|
|
301
|
-
const map: Record<string, string> = {
|
|
302
|
-
quick: "coarse",
|
|
303
|
-
standard: "standard",
|
|
304
|
-
comprehensive: "fine",
|
|
305
|
-
};
|
|
306
|
-
parsed.granularity = map[parsed.depth as string] || parsed.depth;
|
|
307
|
-
delete parsed.depth;
|
|
308
|
-
try {
|
|
309
|
-
fs.writeFileSync(configPath, JSON.stringify(parsed, null, 2), "utf-8");
|
|
310
|
-
} catch {
|
|
311
|
-
/* ok */
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
let configDirty = false;
|
|
316
|
-
|
|
317
|
-
// Migrate legacy "multiRepo: true" → sub_repos array
|
|
318
|
-
if (
|
|
319
|
-
parsed.multiRepo === true &&
|
|
320
|
-
!parsed.sub_repos &&
|
|
321
|
-
!(parsed.planning as Record<string, unknown>)?.sub_repos
|
|
322
|
-
) {
|
|
323
|
-
const detected = detectSubRepos(cwd);
|
|
324
|
-
if (detected.length > 0) {
|
|
325
|
-
parsed.sub_repos = detected;
|
|
326
|
-
if (!parsed.planning) parsed.planning = {};
|
|
327
|
-
(parsed.planning as Record<string, unknown>).commit_docs = false;
|
|
328
|
-
delete parsed.multiRepo;
|
|
329
|
-
configDirty = true;
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
// Keep sub_repos in sync
|
|
334
|
-
const current: string[] =
|
|
335
|
-
(parsed.sub_repos as string[] | undefined) ||
|
|
336
|
-
((parsed.planning as Record<string, unknown>)?.sub_repos as string[] | undefined) || [];
|
|
337
|
-
if (Array.isArray(current) && current.length > 0) {
|
|
338
|
-
const detected = detectSubRepos(cwd);
|
|
339
|
-
if (detected.length > 0) {
|
|
340
|
-
const sorted = [...current].sort();
|
|
341
|
-
if (JSON.stringify(sorted) !== JSON.stringify(detected)) {
|
|
342
|
-
parsed.sub_repos = detected;
|
|
343
|
-
configDirty = true;
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
if (configDirty) {
|
|
349
|
-
try {
|
|
350
|
-
fs.writeFileSync(configPath, JSON.stringify(parsed, null, 2), "utf-8");
|
|
351
|
-
} catch {
|
|
352
|
-
/* ok */
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
function get<T>(
|
|
357
|
-
key: string,
|
|
358
|
-
nested?: { section: string; field: string },
|
|
359
|
-
): T | undefined {
|
|
360
|
-
type Section = Record<string, unknown>;
|
|
361
|
-
if (parsed[key] !== undefined) return parsed[key] as T;
|
|
362
|
-
const section = parsed[nested?.section ?? ""] as Section | undefined;
|
|
363
|
-
if (nested && section?.[nested.field] !== undefined)
|
|
364
|
-
return section[nested.field] as T;
|
|
365
|
-
return undefined;
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
const parallelization = (() => {
|
|
369
|
-
const val = get<boolean | { enabled?: boolean }>("parallelization");
|
|
370
|
-
if (typeof val === "boolean") return val;
|
|
371
|
-
if (typeof val === "object" && val !== null && "enabled" in val)
|
|
372
|
-
return Boolean(val.enabled);
|
|
373
|
-
return defaults.parallelization;
|
|
374
|
-
})();
|
|
375
|
-
|
|
376
|
-
return {
|
|
377
|
-
model_profile: get<typeof defaults.model_profile>("model_profile") ?? defaults.model_profile,
|
|
378
|
-
commit_docs: (() => {
|
|
379
|
-
const explicit = get<boolean>("commit_docs", {
|
|
380
|
-
section: "planning",
|
|
381
|
-
field: "commit_docs",
|
|
382
|
-
});
|
|
383
|
-
if (explicit !== undefined) return Boolean(explicit);
|
|
384
|
-
if (isGitIgnored(cwd, ".planning/")) return false;
|
|
385
|
-
return defaults.commit_docs;
|
|
386
|
-
})(),
|
|
387
|
-
search_gitignored:
|
|
388
|
-
get<boolean>("search_gitignored", {
|
|
389
|
-
section: "planning",
|
|
390
|
-
field: "search_gitignored",
|
|
391
|
-
}) ?? defaults.search_gitignored,
|
|
392
|
-
branching_strategy:
|
|
393
|
-
get<typeof defaults.branching_strategy>("branching_strategy", {
|
|
394
|
-
section: "git",
|
|
395
|
-
field: "branching_strategy",
|
|
396
|
-
}) ?? defaults.branching_strategy,
|
|
397
|
-
phase_branch_template:
|
|
398
|
-
get<string>("phase_branch_template", {
|
|
399
|
-
section: "git",
|
|
400
|
-
field: "phase_branch_template",
|
|
401
|
-
}) ?? defaults.phase_branch_template,
|
|
402
|
-
milestone_branch_template:
|
|
403
|
-
get<string>("milestone_branch_template", {
|
|
404
|
-
section: "git",
|
|
405
|
-
field: "milestone_branch_template",
|
|
406
|
-
}) ?? defaults.milestone_branch_template,
|
|
407
|
-
quick_branch_template:
|
|
408
|
-
get<string | null>("quick_branch_template", {
|
|
409
|
-
section: "git",
|
|
410
|
-
field: "quick_branch_template",
|
|
411
|
-
}) ?? defaults.quick_branch_template,
|
|
412
|
-
research:
|
|
413
|
-
get<boolean>("research", { section: "workflow", field: "research" }) ??
|
|
414
|
-
defaults.research,
|
|
415
|
-
plan_checker:
|
|
416
|
-
get<boolean>("plan_checker", { section: "workflow", field: "plan_check" }) ??
|
|
417
|
-
defaults.plan_checker,
|
|
418
|
-
verifier:
|
|
419
|
-
get<boolean>("verifier", { section: "workflow", field: "verifier" }) ??
|
|
420
|
-
defaults.verifier,
|
|
421
|
-
nyquist_validation:
|
|
422
|
-
get<boolean>("nyquist_validation", {
|
|
423
|
-
section: "workflow",
|
|
424
|
-
field: "nyquist_validation",
|
|
425
|
-
}) ?? defaults.nyquist_validation,
|
|
426
|
-
parallelization,
|
|
427
|
-
brave_search: get<boolean>("brave_search") ?? defaults.brave_search,
|
|
428
|
-
firecrawl: get<boolean>("firecrawl") ?? defaults.firecrawl,
|
|
429
|
-
exa_search: get<boolean>("exa_search") ?? defaults.exa_search,
|
|
430
|
-
text_mode:
|
|
431
|
-
get<boolean>("text_mode", { section: "workflow", field: "text_mode" }) ??
|
|
432
|
-
defaults.text_mode,
|
|
433
|
-
sub_repos:
|
|
434
|
-
get<string[]>("sub_repos", { section: "planning", field: "sub_repos" }) ??
|
|
435
|
-
defaults.sub_repos,
|
|
436
|
-
resolve_model_ids: get<typeof defaults.resolve_model_ids>("resolve_model_ids") ?? defaults.resolve_model_ids,
|
|
437
|
-
context_window: get<number>("context_window") ?? defaults.context_window,
|
|
438
|
-
phase_naming: get<typeof defaults.phase_naming>("phase_naming") ?? defaults.phase_naming,
|
|
439
|
-
model_overrides: (parsed.model_overrides as Record<string, string> | null) ?? null,
|
|
440
|
-
agent_skills: (parsed.agent_skills as Record<string, unknown>) || {},
|
|
441
|
-
};
|
|
442
|
-
} catch {
|
|
443
|
-
return defaults;
|
|
444
|
-
}
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
// ─── Git utilities ─────────────────────────────────────────────────────────────
|
|
448
|
-
|
|
449
|
-
export function isGitIgnored(cwd: string, targetPath: string): boolean {
|
|
450
|
-
try {
|
|
451
|
-
execFileSync(
|
|
452
|
-
"git",
|
|
453
|
-
["check-ignore", "-q", "--no-index", "--", targetPath],
|
|
454
|
-
{ cwd, stdio: "pipe" },
|
|
455
|
-
);
|
|
456
|
-
return true;
|
|
457
|
-
} catch {
|
|
458
|
-
return false;
|
|
459
|
-
}
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
export function execGit(cwd: string, args: string[]): GitResult {
|
|
463
|
-
const result = spawnSync("git", args, {
|
|
464
|
-
cwd,
|
|
465
|
-
stdio: "pipe",
|
|
466
|
-
encoding: "utf-8",
|
|
467
|
-
});
|
|
468
|
-
return {
|
|
469
|
-
exitCode: result.status ?? 1,
|
|
470
|
-
stdout: (result.stdout ?? "").toString().trim(),
|
|
471
|
-
stderr: (result.stderr ?? "").toString().trim(),
|
|
472
|
-
};
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
// ─── Markdown normalization ───────────────────────────────────────────────────
|
|
476
|
-
|
|
477
|
-
export function normalizeMd(content: string): string {
|
|
478
|
-
if (!content || typeof content !== "string") return content;
|
|
479
|
-
let text = content.replace(/\r\n/g, "\n");
|
|
480
|
-
const lines = text.split("\n");
|
|
481
|
-
const result: string[] = [];
|
|
482
|
-
|
|
483
|
-
for (let i = 0; i < lines.length; i++) {
|
|
484
|
-
const line = lines[i];
|
|
485
|
-
const prev = i > 0 ? lines[i - 1] : "";
|
|
486
|
-
const prevTrimmed = prev.trimEnd();
|
|
487
|
-
const trimmed = line.trimEnd();
|
|
488
|
-
|
|
489
|
-
if (
|
|
490
|
-
/^#{1,6}\s/.test(trimmed) &&
|
|
491
|
-
i > 0 &&
|
|
492
|
-
prevTrimmed !== "" &&
|
|
493
|
-
prevTrimmed !== "---"
|
|
494
|
-
)
|
|
495
|
-
result.push("");
|
|
496
|
-
if (
|
|
497
|
-
/^```/.test(trimmed) &&
|
|
498
|
-
i > 0 &&
|
|
499
|
-
prevTrimmed !== "" &&
|
|
500
|
-
!isInsideFencedBlock(lines, i)
|
|
501
|
-
)
|
|
502
|
-
result.push("");
|
|
503
|
-
if (
|
|
504
|
-
/^(\s*[-*+]\s|\s*\d+\.\s)/.test(line) &&
|
|
505
|
-
i > 0 &&
|
|
506
|
-
prevTrimmed !== "" &&
|
|
507
|
-
!/^(\s*[-*+]\s|\s*\d+\.\s)/.test(prev) &&
|
|
508
|
-
prevTrimmed !== "---"
|
|
509
|
-
)
|
|
510
|
-
result.push("");
|
|
511
|
-
|
|
512
|
-
result.push(line);
|
|
513
|
-
|
|
514
|
-
if (
|
|
515
|
-
/^#{1,6}\s/.test(trimmed) &&
|
|
516
|
-
i < lines.length - 1 &&
|
|
517
|
-
lines[i + 1]?.trimEnd() !== ""
|
|
518
|
-
)
|
|
519
|
-
result.push("");
|
|
520
|
-
if (
|
|
521
|
-
/^```\s*$/.test(trimmed) &&
|
|
522
|
-
isClosingFence(lines, i) &&
|
|
523
|
-
i < lines.length - 1 &&
|
|
524
|
-
lines[i + 1]?.trimEnd() !== ""
|
|
525
|
-
)
|
|
526
|
-
result.push("");
|
|
527
|
-
if (/^(\s*[-*+]\s|\s*\d+\.\s)/.test(line) && i < lines.length - 1) {
|
|
528
|
-
const next = lines[i + 1];
|
|
529
|
-
if (
|
|
530
|
-
next !== undefined &&
|
|
531
|
-
next.trimEnd() !== "" &&
|
|
532
|
-
!/^(\s*[-*+]\s|\s*\d+\.\s)/.test(next) &&
|
|
533
|
-
!/^\s/.test(next)
|
|
534
|
-
)
|
|
535
|
-
result.push("");
|
|
536
|
-
}
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
text = result.join("\n");
|
|
540
|
-
text = text.replace(/\n{3,}/g, "\n\n");
|
|
541
|
-
text = text.replace(/\n*$/, "\n");
|
|
542
|
-
return text;
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
function isInsideFencedBlock(lines: string[], i: number): boolean {
|
|
546
|
-
let count = 0;
|
|
547
|
-
for (let j = 0; j < i; j++) if (/^```/.test(lines[j].trimEnd())) count++;
|
|
548
|
-
return count % 2 === 1;
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
function isClosingFence(lines: string[], i: number): boolean {
|
|
552
|
-
let count = 0;
|
|
553
|
-
for (let j = 0; j <= i; j++) if (/^```/.test(lines[j].trimEnd())) count++;
|
|
554
|
-
return count % 2 === 0;
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
// ─── Common path helpers ──────────────────────────────────────────────────────
|
|
558
|
-
|
|
559
|
-
export function resolveWorktreeRoot(cwd: string): string {
|
|
560
|
-
if (fs.existsSync(path.join(cwd, ".planning"))) return cwd;
|
|
561
|
-
const gitDir = execGit(cwd, ["rev-parse", "--git-dir"]);
|
|
562
|
-
const commonDir = execGit(cwd, ["rev-parse", "--git-common-dir"]);
|
|
563
|
-
if (gitDir.exitCode !== 0 || commonDir.exitCode !== 0) return cwd;
|
|
564
|
-
const gitDirResolved = path.resolve(cwd, gitDir.stdout);
|
|
565
|
-
const commonDirResolved = path.resolve(cwd, commonDir.stdout);
|
|
566
|
-
if (gitDirResolved !== commonDirResolved)
|
|
567
|
-
return path.dirname(commonDirResolved);
|
|
568
|
-
return cwd;
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
export function withPlanningLock<T>(cwd: string, fn: () => T): T {
|
|
572
|
-
const lockPath = path.join(planningDir(cwd), ".lock");
|
|
573
|
-
const lockTimeout = 10000;
|
|
574
|
-
const retryDelay = 100;
|
|
575
|
-
const start = Date.now();
|
|
576
|
-
|
|
577
|
-
try {
|
|
578
|
-
fs.mkdirSync(planningDir(cwd), { recursive: true });
|
|
579
|
-
} catch {
|
|
580
|
-
/* ok */
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
while (Date.now() - start < lockTimeout) {
|
|
584
|
-
try {
|
|
585
|
-
fs.writeFileSync(
|
|
586
|
-
lockPath,
|
|
587
|
-
JSON.stringify({
|
|
588
|
-
pid: process.pid,
|
|
589
|
-
cwd,
|
|
590
|
-
acquired: new Date().toISOString(),
|
|
591
|
-
}),
|
|
592
|
-
{ flag: "wx" },
|
|
593
|
-
);
|
|
594
|
-
try {
|
|
595
|
-
return fn();
|
|
596
|
-
} finally {
|
|
597
|
-
try {
|
|
598
|
-
fs.unlinkSync(lockPath);
|
|
599
|
-
} catch {
|
|
600
|
-
/* ok */
|
|
601
|
-
}
|
|
602
|
-
}
|
|
603
|
-
} catch (err: unknown) {
|
|
604
|
-
if ((err as NodeJS.ErrnoException).code === "EEXIST") {
|
|
605
|
-
try {
|
|
606
|
-
const stat = fs.statSync(lockPath);
|
|
607
|
-
if (Date.now() - stat.mtimeMs > 30000) {
|
|
608
|
-
fs.unlinkSync(lockPath);
|
|
609
|
-
continue;
|
|
610
|
-
}
|
|
611
|
-
} catch {
|
|
612
|
-
continue;
|
|
613
|
-
}
|
|
614
|
-
spawnSync("sleep", ["0.1"], { stdio: "ignore" });
|
|
615
|
-
continue;
|
|
616
|
-
}
|
|
617
|
-
throw err;
|
|
618
|
-
}
|
|
619
|
-
}
|
|
620
|
-
try {
|
|
621
|
-
fs.unlinkSync(lockPath);
|
|
622
|
-
} catch {
|
|
623
|
-
/* ok */
|
|
624
|
-
}
|
|
625
|
-
return fn();
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
export function planningDir(cwd: string, ws?: string): string {
|
|
629
|
-
const activeWs = ws ?? process.env["GSD_WORKSTREAM"] ?? null;
|
|
630
|
-
if (!activeWs) return path.join(cwd, ".planning");
|
|
631
|
-
return path.join(cwd, ".planning", "workstreams", activeWs);
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
export function planningRoot(cwd: string): string {
|
|
635
|
-
return path.join(cwd, ".planning");
|
|
636
|
-
}
|
|
637
|
-
|
|
638
|
-
export function planningPaths(cwd: string, ws?: string): PlanningPaths {
|
|
639
|
-
const base = planningDir(cwd, ws);
|
|
640
|
-
const root = path.join(cwd, ".planning");
|
|
641
|
-
return {
|
|
642
|
-
planning: base,
|
|
643
|
-
state: path.join(base, "STATE.md"),
|
|
644
|
-
roadmap: path.join(base, "ROADMAP.md"),
|
|
645
|
-
project: path.join(root, "PROJECT.md"),
|
|
646
|
-
config: path.join(root, "config.json"),
|
|
647
|
-
phases: path.join(base, "phases"),
|
|
648
|
-
requirements: path.join(base, "REQUIREMENTS.md"),
|
|
649
|
-
};
|
|
650
|
-
}
|
|
651
|
-
|
|
652
|
-
// ─── Active Workstream ────────────────────────────────────────────────────────
|
|
653
|
-
|
|
654
|
-
export function getActiveWorkstream(cwd: string): string | null {
|
|
655
|
-
const filePath = path.join(planningRoot(cwd), "active-workstream");
|
|
656
|
-
try {
|
|
657
|
-
const name = fs.readFileSync(filePath, "utf-8").trim();
|
|
658
|
-
if (!name || !/^[a-zA-Z0-9_-]+$/.test(name)) return null;
|
|
659
|
-
if (!fs.existsSync(path.join(planningRoot(cwd), "workstreams", name)))
|
|
660
|
-
return null;
|
|
661
|
-
return name;
|
|
662
|
-
} catch {
|
|
663
|
-
return null;
|
|
664
|
-
}
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
export function setActiveWorkstream(cwd: string, name: string | null): void {
|
|
668
|
-
const filePath = path.join(planningRoot(cwd), "active-workstream");
|
|
669
|
-
if (!name) {
|
|
670
|
-
try {
|
|
671
|
-
fs.unlinkSync(filePath);
|
|
672
|
-
} catch {
|
|
673
|
-
/* ok */
|
|
674
|
-
}
|
|
675
|
-
return;
|
|
676
|
-
}
|
|
677
|
-
if (!/^[a-zA-Z0-9_-]+$/.test(name))
|
|
678
|
-
throw new Error("Invalid workstream name");
|
|
679
|
-
fs.writeFileSync(filePath, name + "\n", "utf-8");
|
|
680
|
-
}
|
|
681
|
-
|
|
682
|
-
// ─── Phase utilities ──────────────────────────────────────────────────────────
|
|
683
|
-
|
|
684
|
-
export function escapeRegex(value: string | number): string {
|
|
685
|
-
return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
686
|
-
}
|
|
687
|
-
|
|
688
|
-
export function normalizePhaseName(phase: string | number): string {
|
|
689
|
-
const str = String(phase);
|
|
690
|
-
const match = str.match(/^(\d+)([A-Z])?((?:\.\d+)*)/i);
|
|
691
|
-
if (match) {
|
|
692
|
-
return (
|
|
693
|
-
match[1].padStart(2, "0") +
|
|
694
|
-
(match[2] ? match[2].toUpperCase() : "") +
|
|
695
|
-
(match[3] || "")
|
|
696
|
-
);
|
|
697
|
-
}
|
|
698
|
-
return str;
|
|
699
|
-
}
|
|
700
|
-
|
|
701
|
-
export function comparePhaseNum(
|
|
702
|
-
a: string | number,
|
|
703
|
-
b: string | number,
|
|
704
|
-
): number {
|
|
705
|
-
const pa = String(a).match(/^(\d+)([A-Z])?((?:\.\d+)*)/i);
|
|
706
|
-
const pb = String(b).match(/^(\d+)([A-Z])?((?:\.\d+)*)/i);
|
|
707
|
-
if (!pa || !pb) return String(a).localeCompare(String(b));
|
|
708
|
-
const intDiff = parseInt(pa[1], 10) - parseInt(pb[1], 10);
|
|
709
|
-
if (intDiff !== 0) return intDiff;
|
|
710
|
-
const la = (pa[2] || "").toUpperCase(),
|
|
711
|
-
lb = (pb[2] || "").toUpperCase();
|
|
712
|
-
if (la !== lb) {
|
|
713
|
-
if (!la) return -1;
|
|
714
|
-
if (!lb) return 1;
|
|
715
|
-
return la < lb ? -1 : 1;
|
|
716
|
-
}
|
|
717
|
-
const aP = pa[3]
|
|
718
|
-
? pa[3]
|
|
719
|
-
.slice(1)
|
|
720
|
-
.split(".")
|
|
721
|
-
.map((p) => parseInt(p, 10))
|
|
722
|
-
: [];
|
|
723
|
-
const bP = pb[3]
|
|
724
|
-
? pb[3]
|
|
725
|
-
.slice(1)
|
|
726
|
-
.split(".")
|
|
727
|
-
.map((p) => parseInt(p, 10))
|
|
728
|
-
: [];
|
|
729
|
-
if (aP.length === 0 && bP.length > 0) return -1;
|
|
730
|
-
if (bP.length === 0 && aP.length > 0) return 1;
|
|
731
|
-
for (let i = 0; i < Math.max(aP.length, bP.length); i++) {
|
|
732
|
-
const av = Number.isFinite(aP[i]) ? aP[i] : 0;
|
|
733
|
-
const bv = Number.isFinite(bP[i]) ? bP[i] : 0;
|
|
734
|
-
if (av !== bv) return av - bv;
|
|
735
|
-
}
|
|
736
|
-
return 0;
|
|
737
|
-
}
|
|
738
|
-
|
|
739
|
-
export function searchPhaseInDir(
|
|
740
|
-
baseDir: string,
|
|
741
|
-
relBase: string,
|
|
742
|
-
normalized: string,
|
|
743
|
-
): PhaseSearchResult | null {
|
|
744
|
-
try {
|
|
745
|
-
const dirs = readSubdirectories(baseDir, true);
|
|
746
|
-
const match = dirs.find(
|
|
747
|
-
(d) =>
|
|
748
|
-
d.startsWith(normalized) ||
|
|
749
|
-
d.toUpperCase().startsWith(normalized.toUpperCase()),
|
|
750
|
-
);
|
|
751
|
-
if (!match) return null;
|
|
752
|
-
|
|
753
|
-
const dirMatch =
|
|
754
|
-
match.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i) ||
|
|
755
|
-
match.match(/^([A-Z][A-Z0-9]*(?:-[A-Z0-9]+)*)-(.+)/i) ||
|
|
756
|
-
([null, match, null] as (string | null)[]);
|
|
757
|
-
const phaseNumber = dirMatch?.[1] ?? normalized;
|
|
758
|
-
const phaseName = dirMatch?.[2] || null;
|
|
759
|
-
const phaseDir = path.join(baseDir, match);
|
|
760
|
-
const {
|
|
761
|
-
plans: unsortedPlans,
|
|
762
|
-
summaries: unsortedSummaries,
|
|
763
|
-
hasResearch,
|
|
764
|
-
hasContext,
|
|
765
|
-
hasVerification,
|
|
766
|
-
hasReviews,
|
|
767
|
-
} = getPhaseFileStats(phaseDir);
|
|
768
|
-
const plans = unsortedPlans.sort();
|
|
769
|
-
const summaries = unsortedSummaries.sort();
|
|
770
|
-
const completedPlanIds = new Set(
|
|
771
|
-
summaries.map((s) =>
|
|
772
|
-
s.replace("-SUMMARY.md", "").replace("SUMMARY.md", ""),
|
|
773
|
-
),
|
|
774
|
-
);
|
|
775
|
-
const incompletePlans = plans.filter(
|
|
776
|
-
(p) =>
|
|
777
|
-
!completedPlanIds.has(p.replace("-PLAN.md", "").replace("PLAN.md", "")),
|
|
778
|
-
);
|
|
779
|
-
|
|
780
|
-
return {
|
|
781
|
-
found: true,
|
|
782
|
-
directory: toPosixPath(path.join(relBase, match)),
|
|
783
|
-
phase_number: phaseNumber,
|
|
784
|
-
phase_name: phaseName,
|
|
785
|
-
phase_slug: phaseName
|
|
786
|
-
? phaseName
|
|
787
|
-
.toLowerCase()
|
|
788
|
-
.replace(/[^a-z0-9]+/g, "-")
|
|
789
|
-
.replace(/^-+|-+$/g, "")
|
|
790
|
-
: null,
|
|
791
|
-
plans,
|
|
792
|
-
summaries,
|
|
793
|
-
incomplete_plans: incompletePlans,
|
|
794
|
-
has_research: hasResearch,
|
|
795
|
-
has_context: hasContext,
|
|
796
|
-
has_verification: hasVerification,
|
|
797
|
-
has_reviews: hasReviews,
|
|
798
|
-
};
|
|
799
|
-
} catch {
|
|
800
|
-
return null;
|
|
801
|
-
}
|
|
802
|
-
}
|
|
803
|
-
|
|
804
|
-
export function findPhaseInternal(
|
|
805
|
-
cwd: string,
|
|
806
|
-
phase: string | null,
|
|
807
|
-
): PhaseSearchResult | null {
|
|
808
|
-
if (!phase) return null;
|
|
809
|
-
const phasesDir = path.join(planningDir(cwd), "phases");
|
|
810
|
-
const normalized = normalizePhaseName(phase);
|
|
811
|
-
const relPhasesDir = toPosixPath(path.relative(cwd, phasesDir));
|
|
812
|
-
const current = searchPhaseInDir(phasesDir, relPhasesDir, normalized);
|
|
813
|
-
if (current) return current;
|
|
814
|
-
|
|
815
|
-
const milestonesDir = path.join(cwd, ".planning", "milestones");
|
|
816
|
-
if (!fs.existsSync(milestonesDir)) return null;
|
|
817
|
-
|
|
818
|
-
try {
|
|
819
|
-
const archiveDirs = fs
|
|
820
|
-
.readdirSync(milestonesDir, { withFileTypes: true })
|
|
821
|
-
.filter((e) => e.isDirectory() && /^v[\d.]+-phases$/.test(e.name))
|
|
822
|
-
.map((e) => e.name)
|
|
823
|
-
.sort()
|
|
824
|
-
.reverse();
|
|
825
|
-
|
|
826
|
-
for (const archiveName of archiveDirs) {
|
|
827
|
-
const version = archiveName.match(/^(v[\d.]+)-phases$/)![1];
|
|
828
|
-
const archivePath = path.join(milestonesDir, archiveName);
|
|
829
|
-
const relBase = ".planning/milestones/" + archiveName;
|
|
830
|
-
const result = searchPhaseInDir(archivePath, relBase, normalized);
|
|
831
|
-
if (result) {
|
|
832
|
-
result.archived = version;
|
|
833
|
-
return result;
|
|
834
|
-
}
|
|
835
|
-
}
|
|
836
|
-
} catch {
|
|
837
|
-
/* ok */
|
|
838
|
-
}
|
|
839
|
-
return null;
|
|
840
|
-
}
|
|
841
|
-
|
|
842
|
-
export function getArchivedPhaseDirs(cwd: string): ArchivedPhaseEntry[] {
|
|
843
|
-
const milestonesDir = path.join(cwd, ".planning", "milestones");
|
|
844
|
-
const results: ArchivedPhaseEntry[] = [];
|
|
845
|
-
if (!fs.existsSync(milestonesDir)) return results;
|
|
846
|
-
try {
|
|
847
|
-
const phaseDirs = fs
|
|
848
|
-
.readdirSync(milestonesDir, { withFileTypes: true })
|
|
849
|
-
.filter((e) => e.isDirectory() && /^v[\d.]+-phases$/.test(e.name))
|
|
850
|
-
.map((e) => e.name)
|
|
851
|
-
.sort()
|
|
852
|
-
.reverse();
|
|
853
|
-
for (const archiveName of phaseDirs) {
|
|
854
|
-
const version = archiveName.match(/^(v[\d.]+)-phases$/)![1];
|
|
855
|
-
const archivePath = path.join(milestonesDir, archiveName);
|
|
856
|
-
for (const dir of readSubdirectories(archivePath, true)) {
|
|
857
|
-
results.push({
|
|
858
|
-
name: dir,
|
|
859
|
-
milestone: version,
|
|
860
|
-
basePath: path.join(".planning", "milestones", archiveName),
|
|
861
|
-
fullPath: path.join(archivePath, dir),
|
|
862
|
-
});
|
|
863
|
-
}
|
|
864
|
-
}
|
|
865
|
-
} catch {
|
|
866
|
-
/* ok */
|
|
867
|
-
}
|
|
868
|
-
return results;
|
|
869
|
-
}
|
|
870
|
-
|
|
871
|
-
// ─── Roadmap milestone scoping ────────────────────────────────────────────────
|
|
872
|
-
|
|
873
|
-
export function stripShippedMilestones(content: string): string {
|
|
874
|
-
return content.replace(/<details>[\s\S]*?<\/details>/gi, "");
|
|
875
|
-
}
|
|
876
|
-
|
|
877
|
-
export function extractCurrentMilestone(content: string, cwd?: string): string {
|
|
878
|
-
if (!cwd) return stripShippedMilestones(content);
|
|
879
|
-
let version: string | null = null;
|
|
880
|
-
try {
|
|
881
|
-
const statePath = path.join(planningDir(cwd), "STATE.md");
|
|
882
|
-
if (fs.existsSync(statePath)) {
|
|
883
|
-
const stateRaw = fs.readFileSync(statePath, "utf-8");
|
|
884
|
-
const m = stateRaw.match(/^milestone:\s*(.+)/m);
|
|
885
|
-
if (m) version = m[1].trim();
|
|
886
|
-
}
|
|
887
|
-
} catch {
|
|
888
|
-
/* ok */
|
|
889
|
-
}
|
|
890
|
-
if (!version) {
|
|
891
|
-
const inProg = content.match(/🚧\s*\*\*v(\d+\.\d+)\s/);
|
|
892
|
-
if (inProg) version = "v" + inProg[1];
|
|
893
|
-
}
|
|
894
|
-
if (!version) return stripShippedMilestones(content);
|
|
895
|
-
|
|
896
|
-
const sectionPattern = new RegExp(
|
|
897
|
-
`(^#{1,3}\\s+.*${escapeRegex(version)}[^\\n]*)`,
|
|
898
|
-
"mi",
|
|
899
|
-
);
|
|
900
|
-
const sectionMatch = content.match(sectionPattern);
|
|
901
|
-
if (!sectionMatch) return stripShippedMilestones(content);
|
|
902
|
-
|
|
903
|
-
const sectionStart = sectionMatch.index!;
|
|
904
|
-
const headingLevel = sectionMatch[1].match(/^(#{1,3})\s/)![1].length;
|
|
905
|
-
const restContent = content.slice(sectionStart + sectionMatch[0].length);
|
|
906
|
-
const nextMatch = restContent.match(
|
|
907
|
-
new RegExp(`^#{1,${headingLevel}}\\s+(?:.*v\\d+\\.\\d+|✅|📋|🚧)`, "mi"),
|
|
908
|
-
);
|
|
909
|
-
const sectionEnd = nextMatch
|
|
910
|
-
? sectionStart + sectionMatch[0].length + nextMatch.index!
|
|
911
|
-
: content.length;
|
|
912
|
-
const preamble = content
|
|
913
|
-
.slice(0, sectionStart)
|
|
914
|
-
.replace(/<details>[\s\S]*?<\/details>/gi, "");
|
|
915
|
-
return preamble + content.slice(sectionStart, sectionEnd);
|
|
916
|
-
}
|
|
917
|
-
|
|
918
|
-
export function replaceInCurrentMilestone(
|
|
919
|
-
content: string,
|
|
920
|
-
pattern: RegExp,
|
|
921
|
-
replacement: string | ((substring: string, ...args: unknown[]) => string),
|
|
922
|
-
): string {
|
|
923
|
-
const lastDetailsClose = content.lastIndexOf("</details>");
|
|
924
|
-
if (lastDetailsClose === -1)
|
|
925
|
-
return content.replace(pattern, replacement as string);
|
|
926
|
-
const offset = lastDetailsClose + "</details>".length;
|
|
927
|
-
const before = content.slice(0, offset);
|
|
928
|
-
const after = content.slice(offset);
|
|
929
|
-
return before + after.replace(pattern, replacement as string);
|
|
930
|
-
}
|
|
931
|
-
|
|
932
|
-
// ─── Roadmap & model utilities ────────────────────────────────────────────────
|
|
933
|
-
|
|
934
|
-
export function getRoadmapPhaseInternal(
|
|
935
|
-
cwd: string,
|
|
936
|
-
phaseNum: string | null,
|
|
937
|
-
): RoadmapPhaseResult | null {
|
|
938
|
-
if (!phaseNum) return null;
|
|
939
|
-
const roadmapPath = path.join(planningDir(cwd), "ROADMAP.md");
|
|
940
|
-
if (!fs.existsSync(roadmapPath)) return null;
|
|
941
|
-
try {
|
|
942
|
-
const content = extractCurrentMilestone(
|
|
943
|
-
fs.readFileSync(roadmapPath, "utf-8"),
|
|
944
|
-
cwd,
|
|
945
|
-
);
|
|
946
|
-
const phasePattern = new RegExp(
|
|
947
|
-
`#{2,4}\\s*Phase\\s+${escapeRegex(phaseNum)}:\\s*([^\\n]+)`,
|
|
948
|
-
"i",
|
|
949
|
-
);
|
|
950
|
-
const headerMatch = content.match(phasePattern);
|
|
951
|
-
if (!headerMatch) return null;
|
|
952
|
-
const phaseName = headerMatch[1].trim();
|
|
953
|
-
const headerIndex = headerMatch.index!;
|
|
954
|
-
const restOfContent = content.slice(headerIndex);
|
|
955
|
-
const nextHeaderMatch = restOfContent.match(/\n#{2,4}\s+Phase\s+[\w]/i);
|
|
956
|
-
const sectionEnd = nextHeaderMatch
|
|
957
|
-
? headerIndex + nextHeaderMatch.index!
|
|
958
|
-
: content.length;
|
|
959
|
-
const section = content.slice(headerIndex, sectionEnd).trim();
|
|
960
|
-
const goalMatch = section.match(
|
|
961
|
-
/\*\*Goal(?:\*\*:|\*?\*?:\*\*)\s*([^\n]+)/i,
|
|
962
|
-
);
|
|
963
|
-
return {
|
|
964
|
-
found: true,
|
|
965
|
-
phase_number: phaseNum.toString(),
|
|
966
|
-
phase_name: phaseName,
|
|
967
|
-
goal: goalMatch ? goalMatch[1].trim() : null,
|
|
968
|
-
section,
|
|
969
|
-
};
|
|
970
|
-
} catch {
|
|
971
|
-
return null;
|
|
972
|
-
}
|
|
973
|
-
}
|
|
974
|
-
|
|
975
|
-
// ─── Agent installation validation ───────────────────────────────────────────
|
|
976
|
-
|
|
977
|
-
export function getAgentsDir(): string {
|
|
978
|
-
// dist/gsd-tools.js lives at repo root → agents/ is at ../../agents relative to __dirname
|
|
979
|
-
return path.join(__dirname, "..", "..", "agents");
|
|
980
|
-
}
|
|
981
|
-
|
|
982
|
-
export function checkAgentsInstalled(): AgentsInstallStatus {
|
|
983
|
-
const agentsDir = getAgentsDir();
|
|
984
|
-
const expectedAgents = Object.keys(MODEL_PROFILES);
|
|
985
|
-
const installed: string[] = [],
|
|
986
|
-
missing: string[] = [];
|
|
987
|
-
if (!fs.existsSync(agentsDir)) {
|
|
988
|
-
return {
|
|
989
|
-
agents_installed: false,
|
|
990
|
-
missing_agents: expectedAgents,
|
|
991
|
-
installed_agents: [],
|
|
992
|
-
agents_dir: agentsDir,
|
|
993
|
-
};
|
|
994
|
-
}
|
|
995
|
-
for (const agent of expectedAgents) {
|
|
996
|
-
if (fs.existsSync(path.join(agentsDir, `${agent}.md`)))
|
|
997
|
-
installed.push(agent);
|
|
998
|
-
else missing.push(agent);
|
|
999
|
-
}
|
|
1000
|
-
return {
|
|
1001
|
-
agents_installed: installed.length > 0 && missing.length === 0,
|
|
1002
|
-
missing_agents: missing,
|
|
1003
|
-
installed_agents: installed,
|
|
1004
|
-
agents_dir: agentsDir,
|
|
1005
|
-
};
|
|
1006
|
-
}
|
|
1007
|
-
|
|
1008
|
-
// ─── Model alias resolution ───────────────────────────────────────────────────
|
|
1009
|
-
|
|
1010
|
-
export function resolveModelInternal(cwd: string, agentType: string): string {
|
|
1011
|
-
const config = loadConfig(cwd);
|
|
1012
|
-
const override = config.model_overrides?.[agentType];
|
|
1013
|
-
if (override) return override;
|
|
1014
|
-
if (config.resolve_model_ids === "omit") return "";
|
|
1015
|
-
const profile = String(config.model_profile || "balanced").toLowerCase();
|
|
1016
|
-
const agentModels = MODEL_PROFILES[agentType];
|
|
1017
|
-
if (!agentModels) return "sonnet";
|
|
1018
|
-
if (profile === "inherit") return "inherit";
|
|
1019
|
-
const alias =
|
|
1020
|
-
agentModels[profile as keyof typeof agentModels] ||
|
|
1021
|
-
agentModels["balanced"] ||
|
|
1022
|
-
"sonnet";
|
|
1023
|
-
if (config.resolve_model_ids) return MODEL_ALIAS_MAP[alias] || alias;
|
|
1024
|
-
return alias;
|
|
1025
|
-
}
|
|
1026
|
-
|
|
1027
|
-
// ─── Summary body helpers ─────────────────────────────────────────────────────
|
|
1028
|
-
|
|
1029
|
-
export function extractOneLinerFromBody(content: string | null): string | null {
|
|
1030
|
-
if (!content) return null;
|
|
1031
|
-
const body = content.replace(/^---\n[\s\S]*?\n---\n*/, "");
|
|
1032
|
-
const match = body.match(/^#[^\n]*\n+\*\*([^*]+)\*\*/m);
|
|
1033
|
-
return match ? match[1].trim() : null;
|
|
1034
|
-
}
|
|
1035
|
-
|
|
1036
|
-
// ─── Misc utilities ───────────────────────────────────────────────────────────
|
|
1037
|
-
|
|
1038
|
-
export function pathExistsInternal(cwd: string, targetPath: string): boolean {
|
|
1039
|
-
const fullPath = path.isAbsolute(targetPath)
|
|
1040
|
-
? targetPath
|
|
1041
|
-
: path.join(cwd, targetPath);
|
|
1042
|
-
try {
|
|
1043
|
-
fs.statSync(fullPath);
|
|
1044
|
-
return true;
|
|
1045
|
-
} catch {
|
|
1046
|
-
return false;
|
|
1047
|
-
}
|
|
1048
|
-
}
|
|
1049
|
-
|
|
1050
|
-
export function generateSlugInternal(text: string | null): string | null {
|
|
1051
|
-
if (!text) return null;
|
|
1052
|
-
return text
|
|
1053
|
-
.toLowerCase()
|
|
1054
|
-
.replace(/[^a-z0-9]+/g, "-")
|
|
1055
|
-
.replace(/^-+|-+$/g, "");
|
|
1056
|
-
}
|
|
1057
|
-
|
|
1058
|
-
export function getMilestoneInfo(cwd: string): MilestoneInfo {
|
|
1059
|
-
try {
|
|
1060
|
-
const roadmap = fs.readFileSync(
|
|
1061
|
-
path.join(planningDir(cwd), "ROADMAP.md"),
|
|
1062
|
-
"utf-8",
|
|
1063
|
-
);
|
|
1064
|
-
const inProgressMatch = roadmap.match(
|
|
1065
|
-
/🚧\s*\*\*v(\d+(?:\.\d+)+)\s+([^*]+)\*\*/,
|
|
1066
|
-
);
|
|
1067
|
-
if (inProgressMatch)
|
|
1068
|
-
return {
|
|
1069
|
-
version: "v" + inProgressMatch[1],
|
|
1070
|
-
name: inProgressMatch[2].trim(),
|
|
1071
|
-
};
|
|
1072
|
-
const cleaned = stripShippedMilestones(roadmap);
|
|
1073
|
-
const headingMatch = cleaned.match(/## .*v(\d+(?:\.\d+)+)[:\s]+([^\n(]+)/);
|
|
1074
|
-
if (headingMatch)
|
|
1075
|
-
return { version: "v" + headingMatch[1], name: headingMatch[2].trim() };
|
|
1076
|
-
const versionMatch = cleaned.match(/v(\d+(?:\.\d+)+)/);
|
|
1077
|
-
return {
|
|
1078
|
-
version: versionMatch ? versionMatch[0] : "v1.0",
|
|
1079
|
-
name: "milestone",
|
|
1080
|
-
};
|
|
1081
|
-
} catch {
|
|
1082
|
-
return { version: "v1.0", name: "milestone" };
|
|
1083
|
-
}
|
|
1084
|
-
}
|
|
1085
|
-
|
|
1086
|
-
export function getMilestonePhaseFilter(
|
|
1087
|
-
cwd: string,
|
|
1088
|
-
): ((dirName: string) => boolean) & { phaseCount: number } {
|
|
1089
|
-
const milestonePhaseNums = new Set<string>();
|
|
1090
|
-
try {
|
|
1091
|
-
const roadmap = extractCurrentMilestone(
|
|
1092
|
-
fs.readFileSync(path.join(planningDir(cwd), "ROADMAP.md"), "utf-8"),
|
|
1093
|
-
cwd,
|
|
1094
|
-
);
|
|
1095
|
-
const phasePattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)\s*:/gi;
|
|
1096
|
-
let m;
|
|
1097
|
-
while ((m = phasePattern.exec(roadmap)) !== null)
|
|
1098
|
-
milestonePhaseNums.add(m[1]);
|
|
1099
|
-
} catch {
|
|
1100
|
-
/* ok */
|
|
1101
|
-
}
|
|
1102
|
-
|
|
1103
|
-
if (milestonePhaseNums.size === 0) {
|
|
1104
|
-
const passAll = (_dirName: string) => true;
|
|
1105
|
-
(passAll as typeof passAll & { phaseCount: number }).phaseCount = 0;
|
|
1106
|
-
return passAll as ((dirName: string) => boolean) & { phaseCount: number };
|
|
1107
|
-
}
|
|
1108
|
-
|
|
1109
|
-
const normalized = new Set(
|
|
1110
|
-
[...milestonePhaseNums].map((n) =>
|
|
1111
|
-
(n.replace(/^0+/, "") || "0").toLowerCase(),
|
|
1112
|
-
),
|
|
1113
|
-
);
|
|
1114
|
-
function isDirInMilestone(dirName: string): boolean {
|
|
1115
|
-
const m = dirName.match(/^0*(\d+[A-Za-z]?(?:\.\d+)*)/);
|
|
1116
|
-
if (m && normalized.has(m[1].toLowerCase())) return true;
|
|
1117
|
-
const cust = dirName.match(/^([A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)*)/);
|
|
1118
|
-
if (cust && normalized.has(cust[1].toLowerCase())) return true;
|
|
1119
|
-
return false;
|
|
1120
|
-
}
|
|
1121
|
-
(
|
|
1122
|
-
isDirInMilestone as typeof isDirInMilestone & { phaseCount: number }
|
|
1123
|
-
).phaseCount = milestonePhaseNums.size;
|
|
1124
|
-
return isDirInMilestone as ((dirName: string) => boolean) & {
|
|
1125
|
-
phaseCount: number;
|
|
1126
|
-
};
|
|
1127
|
-
}
|
|
1128
|
-
|
|
1129
|
-
// ─── Phase file helpers ───────────────────────────────────────────────────────
|
|
1130
|
-
|
|
1131
|
-
export function filterPlanFiles(files: string[]): string[] {
|
|
1132
|
-
return files.filter((f) => f.endsWith("-PLAN.md") || f === "PLAN.md");
|
|
1133
|
-
}
|
|
1134
|
-
|
|
1135
|
-
export function filterSummaryFiles(files: string[]): string[] {
|
|
1136
|
-
return files.filter((f) => f.endsWith("-SUMMARY.md") || f === "SUMMARY.md");
|
|
1137
|
-
}
|
|
1138
|
-
|
|
1139
|
-
export function getPhaseFileStats(phaseDir: string): PhaseFileStats {
|
|
1140
|
-
const files = fs.readdirSync(phaseDir);
|
|
1141
|
-
return {
|
|
1142
|
-
plans: filterPlanFiles(files),
|
|
1143
|
-
summaries: filterSummaryFiles(files),
|
|
1144
|
-
hasResearch: files.some(
|
|
1145
|
-
(f) => f.endsWith("-RESEARCH.md") || f === "RESEARCH.md",
|
|
1146
|
-
),
|
|
1147
|
-
hasContext: files.some(
|
|
1148
|
-
(f) => f.endsWith("-CONTEXT.md") || f === "CONTEXT.md",
|
|
1149
|
-
),
|
|
1150
|
-
hasVerification: files.some(
|
|
1151
|
-
(f) => f.endsWith("-VERIFICATION.md") || f === "VERIFICATION.md",
|
|
1152
|
-
),
|
|
1153
|
-
hasReviews: files.some(
|
|
1154
|
-
(f) => f.endsWith("-REVIEWS.md") || f === "REVIEWS.md",
|
|
1155
|
-
),
|
|
1156
|
-
};
|
|
1157
|
-
}
|
|
1158
|
-
|
|
1159
|
-
export function readSubdirectories(dirPath: string, sort = false): string[] {
|
|
1160
|
-
try {
|
|
1161
|
-
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
1162
|
-
const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
1163
|
-
return sort ? dirs.sort((a, b) => comparePhaseNum(a, b)) : dirs;
|
|
1164
|
-
} catch {
|
|
1165
|
-
return [];
|
|
1166
|
-
}
|
|
1167
|
-
}
|