gsd-pi 2.37.1-dev.d3ace49 → 2.38.0-dev.96dc7fb
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/cli.js +9 -0
- package/dist/extension-discovery.d.ts +5 -3
- package/dist/extension-discovery.js +14 -9
- package/dist/resources/extensions/browser-tools/package.json +3 -1
- package/dist/resources/extensions/cmux/index.js +55 -1
- package/dist/resources/extensions/context7/package.json +1 -1
- package/dist/resources/extensions/google-search/package.json +3 -1
- package/dist/resources/extensions/gsd/auto-loop.js +7 -1
- package/dist/resources/extensions/gsd/auto-start.js +6 -1
- package/dist/resources/extensions/gsd/auto-worktree-sync.js +11 -4
- package/dist/resources/extensions/gsd/captures.js +9 -1
- package/dist/resources/extensions/gsd/commands-handlers.js +16 -3
- package/dist/resources/extensions/gsd/commands.js +20 -1
- package/dist/resources/extensions/gsd/doctor-checks.js +82 -0
- package/dist/resources/extensions/gsd/doctor-environment.js +78 -0
- package/dist/resources/extensions/gsd/doctor-format.js +15 -0
- package/dist/resources/extensions/gsd/doctor.js +184 -11
- package/dist/resources/extensions/gsd/package.json +1 -1
- package/dist/resources/extensions/gsd/worktree.js +35 -16
- package/dist/resources/extensions/subagent/index.js +12 -3
- package/dist/resources/extensions/universal-config/package.json +1 -1
- package/dist/welcome-screen.d.ts +12 -0
- package/dist/welcome-screen.js +53 -0
- package/package.json +1 -1
- package/packages/pi-coding-agent/dist/core/package-manager.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/package-manager.js +8 -4
- package/packages/pi-coding-agent/dist/core/package-manager.js.map +1 -1
- package/packages/pi-coding-agent/package.json +1 -1
- package/packages/pi-coding-agent/src/core/package-manager.ts +8 -4
- package/pkg/package.json +1 -1
- package/src/resources/extensions/cmux/index.ts +57 -1
- package/src/resources/extensions/gsd/auto-loop.ts +13 -1
- package/src/resources/extensions/gsd/auto-start.ts +7 -1
- package/src/resources/extensions/gsd/auto-worktree-sync.ts +12 -3
- package/src/resources/extensions/gsd/captures.ts +10 -1
- package/src/resources/extensions/gsd/commands-handlers.ts +17 -2
- package/src/resources/extensions/gsd/commands.ts +21 -1
- package/src/resources/extensions/gsd/doctor-checks.ts +75 -0
- package/src/resources/extensions/gsd/doctor-environment.ts +82 -1
- package/src/resources/extensions/gsd/doctor-format.ts +20 -0
- package/src/resources/extensions/gsd/doctor-types.ts +16 -1
- package/src/resources/extensions/gsd/doctor.ts +177 -13
- package/src/resources/extensions/gsd/tests/cmux.test.ts +93 -0
- package/src/resources/extensions/gsd/tests/doctor-enhancements.test.ts +266 -0
- package/src/resources/extensions/gsd/tests/worktree.test.ts +47 -0
- package/src/resources/extensions/gsd/worktree.ts +35 -15
- package/src/resources/extensions/subagent/index.ts +12 -3
|
@@ -289,10 +289,17 @@ export class CmuxClient {
|
|
|
289
289
|
}
|
|
290
290
|
|
|
291
291
|
async createSplit(direction: "right" | "down" | "left" | "up"): Promise<string | null> {
|
|
292
|
+
return this.createSplitFrom(this.config.surfaceId, direction);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async createSplitFrom(
|
|
296
|
+
sourceSurfaceId: string | undefined,
|
|
297
|
+
direction: "right" | "down" | "left" | "up",
|
|
298
|
+
): Promise<string | null> {
|
|
292
299
|
if (!this.config.splits) return null;
|
|
293
300
|
const before = new Set(await this.listSurfaceIds());
|
|
294
301
|
const args = ["new-split", direction];
|
|
295
|
-
const scopedArgs = this.appendSurface(this.appendWorkspace(args),
|
|
302
|
+
const scopedArgs = this.appendSurface(this.appendWorkspace(args), sourceSurfaceId);
|
|
296
303
|
await this.runAsync(scopedArgs);
|
|
297
304
|
const after = await this.listSurfaceIds();
|
|
298
305
|
for (const id of after) {
|
|
@@ -301,6 +308,55 @@ export class CmuxClient {
|
|
|
301
308
|
return null;
|
|
302
309
|
}
|
|
303
310
|
|
|
311
|
+
/**
|
|
312
|
+
* Create a grid of surfaces for parallel agent execution.
|
|
313
|
+
*
|
|
314
|
+
* Layout strategy (gsd stays in the original surface):
|
|
315
|
+
* 1 agent: [gsd | A]
|
|
316
|
+
* 2 agents: [gsd | A]
|
|
317
|
+
* [ | B]
|
|
318
|
+
* 3 agents: [gsd | A]
|
|
319
|
+
* [ C | B]
|
|
320
|
+
* 4 agents: [gsd | A]
|
|
321
|
+
* [ C | B] (D splits from B downward)
|
|
322
|
+
* [ | D]
|
|
323
|
+
*
|
|
324
|
+
* Returns surface IDs in order, or empty array on failure.
|
|
325
|
+
*/
|
|
326
|
+
async createGridLayout(count: number): Promise<string[]> {
|
|
327
|
+
if (!this.config.splits || count <= 0) return [];
|
|
328
|
+
const surfaces: string[] = [];
|
|
329
|
+
|
|
330
|
+
// First split: create right column from the gsd surface
|
|
331
|
+
const rightCol = await this.createSplitFrom(this.config.surfaceId, "right");
|
|
332
|
+
if (!rightCol) return [];
|
|
333
|
+
surfaces.push(rightCol);
|
|
334
|
+
if (count === 1) return surfaces;
|
|
335
|
+
|
|
336
|
+
// Second split: split right column down → bottom-right
|
|
337
|
+
const bottomRight = await this.createSplitFrom(rightCol, "down");
|
|
338
|
+
if (!bottomRight) return surfaces;
|
|
339
|
+
surfaces.push(bottomRight);
|
|
340
|
+
if (count === 2) return surfaces;
|
|
341
|
+
|
|
342
|
+
// Third split: split gsd surface down → bottom-left
|
|
343
|
+
const bottomLeft = await this.createSplitFrom(this.config.surfaceId, "down");
|
|
344
|
+
if (!bottomLeft) return surfaces;
|
|
345
|
+
surfaces.push(bottomLeft);
|
|
346
|
+
if (count === 3) return surfaces;
|
|
347
|
+
|
|
348
|
+
// Fourth+: split subsequent surfaces down from the last created
|
|
349
|
+
let lastSurface = bottomRight;
|
|
350
|
+
for (let i = 3; i < count; i++) {
|
|
351
|
+
const next = await this.createSplitFrom(lastSurface, "down");
|
|
352
|
+
if (!next) break;
|
|
353
|
+
surfaces.push(next);
|
|
354
|
+
lastSurface = next;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return surfaces;
|
|
358
|
+
}
|
|
359
|
+
|
|
304
360
|
async sendSurface(surfaceId: string, text: string): Promise<boolean> {
|
|
305
361
|
const payload = text.endsWith("\n") ? text : `${text}\n`;
|
|
306
362
|
const stdout = await this.runAsync(["send-surface", "--surface", surfaceId, payload]);
|
|
@@ -787,7 +787,7 @@ export async function autoLoop(
|
|
|
787
787
|
(m: { status: string }) =>
|
|
788
788
|
m.status !== "complete" && m.status !== "parked",
|
|
789
789
|
);
|
|
790
|
-
if (incomplete.length === 0) {
|
|
790
|
+
if (incomplete.length === 0 && state.registry.length > 0) {
|
|
791
791
|
// All milestones complete — merge milestone branch before stopping
|
|
792
792
|
if (s.currentMilestoneId) {
|
|
793
793
|
deps.resolver.mergeAndExit(s.currentMilestoneId, ctx.ui);
|
|
@@ -804,6 +804,18 @@ export async function autoLoop(
|
|
|
804
804
|
"success",
|
|
805
805
|
);
|
|
806
806
|
await deps.stopAuto(ctx, pi, "All milestones complete");
|
|
807
|
+
} else if (incomplete.length === 0 && state.registry.length === 0) {
|
|
808
|
+
// Empty registry — no milestones visible, likely a path resolution bug
|
|
809
|
+
const diag = `basePath=${s.basePath}, phase=${state.phase}`;
|
|
810
|
+
ctx.ui.notify(
|
|
811
|
+
`No milestones visible in current scope. Possible path resolution issue.\n Diagnostic: ${diag}`,
|
|
812
|
+
"error",
|
|
813
|
+
);
|
|
814
|
+
await deps.stopAuto(
|
|
815
|
+
ctx,
|
|
816
|
+
pi,
|
|
817
|
+
`No milestones found — check basePath resolution`,
|
|
818
|
+
);
|
|
807
819
|
} else if (state.phase === "blocked") {
|
|
808
820
|
const blockerMsg = `Blocked: ${state.blockers.join(", ")}`;
|
|
809
821
|
await deps.stopAuto(ctx, pi, blockerMsg);
|
|
@@ -429,10 +429,16 @@ export async function bootstrapAutoSession(
|
|
|
429
429
|
s.originalBasePath = base;
|
|
430
430
|
|
|
431
431
|
const isUnderGsdWorktrees = (p: string): boolean => {
|
|
432
|
+
// Direct layout: /.gsd/worktrees/
|
|
432
433
|
const marker = `${pathSep}.gsd${pathSep}worktrees${pathSep}`;
|
|
433
434
|
if (p.includes(marker)) return true;
|
|
434
435
|
const worktreesSuffix = `${pathSep}.gsd${pathSep}worktrees`;
|
|
435
|
-
|
|
436
|
+
if (p.endsWith(worktreesSuffix)) return true;
|
|
437
|
+
// Symlink-resolved layout: /.gsd/projects/<hash>/worktrees/
|
|
438
|
+
const symlinkRe = new RegExp(
|
|
439
|
+
`\\${pathSep}\\.gsd\\${pathSep}projects\\${pathSep}[a-f0-9]+\\${pathSep}worktrees(?:\\${pathSep}|$)`,
|
|
440
|
+
);
|
|
441
|
+
return symlinkRe.test(p);
|
|
436
442
|
};
|
|
437
443
|
|
|
438
444
|
if (
|
|
@@ -153,9 +153,18 @@ export function checkResourcesStale(
|
|
|
153
153
|
* Returns the corrected base path.
|
|
154
154
|
*/
|
|
155
155
|
export function escapeStaleWorktree(base: string): string {
|
|
156
|
-
|
|
157
|
-
const
|
|
158
|
-
|
|
156
|
+
// Direct layout: /.gsd/worktrees/
|
|
157
|
+
const directMarker = `${pathSep}.gsd${pathSep}worktrees${pathSep}`;
|
|
158
|
+
let idx = base.indexOf(directMarker);
|
|
159
|
+
if (idx === -1) {
|
|
160
|
+
// Symlink-resolved layout: /.gsd/projects/<hash>/worktrees/
|
|
161
|
+
const symlinkRe = new RegExp(
|
|
162
|
+
`\\${pathSep}\\.gsd\\${pathSep}projects\\${pathSep}[a-f0-9]+\\${pathSep}worktrees\\${pathSep}`,
|
|
163
|
+
);
|
|
164
|
+
const match = base.match(symlinkRe);
|
|
165
|
+
if (!match || match.index === undefined) return base;
|
|
166
|
+
idx = match.index;
|
|
167
|
+
}
|
|
159
168
|
|
|
160
169
|
// base is inside .gsd/worktrees/<something> — extract the project root
|
|
161
170
|
const projectRoot = base.slice(0, idx);
|
|
@@ -59,8 +59,17 @@ const VALID_CLASSIFICATIONS: readonly string[] = [
|
|
|
59
59
|
*/
|
|
60
60
|
export function resolveCapturesPath(basePath: string): string {
|
|
61
61
|
const resolved = resolve(basePath);
|
|
62
|
+
// Direct layout: /.gsd/worktrees/
|
|
62
63
|
const worktreeMarker = `${sep}.gsd${sep}worktrees${sep}`;
|
|
63
|
-
|
|
64
|
+
let idx = resolved.indexOf(worktreeMarker);
|
|
65
|
+
if (idx === -1) {
|
|
66
|
+
// Symlink-resolved layout: /.gsd/projects/<hash>/worktrees/
|
|
67
|
+
const symlinkRe = new RegExp(
|
|
68
|
+
`\\${sep}\\.gsd\\${sep}projects\\${sep}[a-f0-9]+\\${sep}worktrees\\${sep}`,
|
|
69
|
+
);
|
|
70
|
+
const match = resolved.match(symlinkRe);
|
|
71
|
+
if (match && match.index !== undefined) idx = match.index;
|
|
72
|
+
}
|
|
64
73
|
if (idx !== -1) {
|
|
65
74
|
// basePath is inside a worktree — resolve to project root
|
|
66
75
|
const projectRoot = resolved.slice(0, idx);
|
|
@@ -15,6 +15,7 @@ import { appendOverride, appendKnowledge } from "./files.js";
|
|
|
15
15
|
import {
|
|
16
16
|
formatDoctorIssuesForPrompt,
|
|
17
17
|
formatDoctorReport,
|
|
18
|
+
formatDoctorReportJson,
|
|
18
19
|
runGSDDoctor,
|
|
19
20
|
selectDoctorScope,
|
|
20
21
|
filterDoctorIssues,
|
|
@@ -43,16 +44,30 @@ export function dispatchDoctorHeal(pi: ExtensionAPI, scope: string | undefined,
|
|
|
43
44
|
|
|
44
45
|
export async function handleDoctor(args: string, ctx: ExtensionCommandContext, pi: ExtensionAPI): Promise<void> {
|
|
45
46
|
const trimmed = args.trim();
|
|
46
|
-
|
|
47
|
+
// Extract flags before positional parsing
|
|
48
|
+
const jsonMode = trimmed.includes("--json");
|
|
49
|
+
const dryRun = trimmed.includes("--dry-run");
|
|
50
|
+
const includeBuild = trimmed.includes("--build");
|
|
51
|
+
const includeTests = trimmed.includes("--test");
|
|
52
|
+
const stripped = trimmed.replace(/--json|--dry-run|--build|--test/g, "").trim();
|
|
53
|
+
const parts = stripped ? stripped.split(/\s+/) : [];
|
|
47
54
|
const mode = parts[0] === "fix" || parts[0] === "heal" || parts[0] === "audit" ? parts[0] : "doctor";
|
|
48
55
|
const requestedScope = mode === "doctor" ? parts[0] : parts[1];
|
|
49
56
|
const scope = await selectDoctorScope(projectRoot(), requestedScope);
|
|
50
57
|
const effectiveScope = mode === "audit" ? requestedScope : scope;
|
|
51
58
|
const report = await runGSDDoctor(projectRoot(), {
|
|
52
|
-
fix: mode === "fix" || mode === "heal",
|
|
59
|
+
fix: mode === "fix" || mode === "heal" || dryRun,
|
|
60
|
+
dryRun,
|
|
53
61
|
scope: effectiveScope,
|
|
62
|
+
includeBuild,
|
|
63
|
+
includeTests,
|
|
54
64
|
});
|
|
55
65
|
|
|
66
|
+
if (jsonMode) {
|
|
67
|
+
ctx.ui.notify(formatDoctorReportJson(report), "info");
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
56
71
|
const reportText = formatDoctorReport(report, {
|
|
57
72
|
scope: effectiveScope,
|
|
58
73
|
includeWarnings: mode === "audit",
|
|
@@ -159,7 +159,7 @@ async function guardRemoteSession(
|
|
|
159
159
|
|
|
160
160
|
export function registerGSDCommand(pi: ExtensionAPI): void {
|
|
161
161
|
pi.registerCommand("gsd", {
|
|
162
|
-
description: "GSD — Get Shit Done: /gsd help|start|templates|next|auto|stop|pause|status|visualize|queue|quick|capture|triage|dispatch|history|undo|skip|export|cleanup|mode|prefs|config|keys|hooks|run-hook|skill-health|doctor|forensics|changelog|migrate|remote|steer|knowledge|new-milestone|parallel|cmux|update",
|
|
162
|
+
description: "GSD — Get Shit Done: /gsd help|start|templates|next|auto|stop|pause|status|widget|visualize|queue|quick|capture|triage|dispatch|history|undo|rate|skip|export|cleanup|mode|prefs|config|keys|hooks|run-hook|skill-health|doctor|logs|forensics|changelog|migrate|remote|steer|knowledge|new-milestone|parallel|cmux|park|unpark|init|setup|inspect|extensions|update",
|
|
163
163
|
getArgumentCompletions: (prefix: string) => {
|
|
164
164
|
const subcommands = [
|
|
165
165
|
{ cmd: "help", desc: "Categorized command reference with descriptions" },
|
|
@@ -210,7 +210,11 @@ export function registerGSDCommand(pi: ExtensionAPI): void {
|
|
|
210
210
|
{ cmd: "templates", desc: "List available workflow templates" },
|
|
211
211
|
{ cmd: "extensions", desc: "Manage extensions (list, enable, disable, info)" },
|
|
212
212
|
];
|
|
213
|
+
const hasTrailingSpace = prefix.endsWith(" ");
|
|
213
214
|
const parts = prefix.trim().split(/\s+/);
|
|
215
|
+
if (hasTrailingSpace && parts.length >= 1) {
|
|
216
|
+
parts.push("");
|
|
217
|
+
}
|
|
214
218
|
|
|
215
219
|
if (parts.length <= 1) {
|
|
216
220
|
return subcommands
|
|
@@ -509,6 +513,10 @@ export function registerGSDCommand(pi: ExtensionAPI): void {
|
|
|
509
513
|
{ cmd: "fix", desc: "Auto-fix detected issues" },
|
|
510
514
|
{ cmd: "heal", desc: "AI-driven deep healing" },
|
|
511
515
|
{ cmd: "audit", desc: "Run health audit without fixing" },
|
|
516
|
+
{ cmd: "--dry-run", desc: "Show what --fix would change without applying" },
|
|
517
|
+
{ cmd: "--json", desc: "Output report as JSON (CI/tooling friendly)" },
|
|
518
|
+
{ cmd: "--build", desc: "Include slow build health check (npm run build)" },
|
|
519
|
+
{ cmd: "--test", desc: "Include slow test health check (npm test)" },
|
|
512
520
|
];
|
|
513
521
|
|
|
514
522
|
if (parts.length <= 2) {
|
|
@@ -536,6 +544,18 @@ export function registerGSDCommand(pi: ExtensionAPI): void {
|
|
|
536
544
|
.map((p) => ({ value: `dispatch ${p.cmd}`, label: p.cmd, description: p.desc }));
|
|
537
545
|
}
|
|
538
546
|
|
|
547
|
+
if (parts[0] === "rate" && parts.length <= 2) {
|
|
548
|
+
const tierPrefix = parts[1] ?? "";
|
|
549
|
+
const tiers = [
|
|
550
|
+
{ cmd: "over", desc: "Model was overqualified for this task" },
|
|
551
|
+
{ cmd: "ok", desc: "Model was appropriate for this task" },
|
|
552
|
+
{ cmd: "under", desc: "Model was underqualified for this task" },
|
|
553
|
+
];
|
|
554
|
+
return tiers
|
|
555
|
+
.filter((t) => t.cmd.startsWith(tierPrefix))
|
|
556
|
+
.map((t) => ({ value: `rate ${t.cmd}`, label: t.cmd, description: t.desc }));
|
|
557
|
+
}
|
|
558
|
+
|
|
539
559
|
return [];
|
|
540
560
|
},
|
|
541
561
|
|
|
@@ -657,6 +657,81 @@ export async function checkRuntimeHealth(
|
|
|
657
657
|
} catch {
|
|
658
658
|
// Non-fatal — external state check failed
|
|
659
659
|
}
|
|
660
|
+
|
|
661
|
+
// ── Metrics ledger integrity ───────────────────────────────────────────
|
|
662
|
+
try {
|
|
663
|
+
const metricsPath = join(root, "metrics.json");
|
|
664
|
+
if (existsSync(metricsPath)) {
|
|
665
|
+
try {
|
|
666
|
+
const raw = readFileSync(metricsPath, "utf-8");
|
|
667
|
+
const ledger = JSON.parse(raw);
|
|
668
|
+
if (ledger.version !== 1 || !Array.isArray(ledger.units)) {
|
|
669
|
+
issues.push({
|
|
670
|
+
severity: "warning",
|
|
671
|
+
code: "metrics_ledger_corrupt",
|
|
672
|
+
scope: "project",
|
|
673
|
+
unitId: "project",
|
|
674
|
+
message: "metrics.json has an unexpected structure (version !== 1 or units is not an array) — metrics data may be unreliable",
|
|
675
|
+
file: ".gsd/metrics.json",
|
|
676
|
+
fixable: false,
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
} catch {
|
|
680
|
+
issues.push({
|
|
681
|
+
severity: "warning",
|
|
682
|
+
code: "metrics_ledger_corrupt",
|
|
683
|
+
scope: "project",
|
|
684
|
+
unitId: "project",
|
|
685
|
+
message: "metrics.json is not valid JSON — metrics data may be corrupt",
|
|
686
|
+
file: ".gsd/metrics.json",
|
|
687
|
+
fixable: false,
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
} catch {
|
|
692
|
+
// Non-fatal — metrics check failed
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// ── Large planning file detection ──────────────────────────────────────
|
|
696
|
+
// Files over 100KB can cause LLM context pressure. Report the worst offenders.
|
|
697
|
+
try {
|
|
698
|
+
const MAX_FILE_BYTES = 100 * 1024; // 100KB
|
|
699
|
+
const milestonesPath = milestonesDir(basePath);
|
|
700
|
+
if (existsSync(milestonesPath)) {
|
|
701
|
+
const largeFiles: Array<{ path: string; sizeKB: number }> = [];
|
|
702
|
+
function scanForLargeFiles(dir: string, depth = 0): void {
|
|
703
|
+
if (depth > 6) return;
|
|
704
|
+
try {
|
|
705
|
+
for (const entry of readdirSync(dir)) {
|
|
706
|
+
const full = join(dir, entry);
|
|
707
|
+
try {
|
|
708
|
+
const s = statSync(full);
|
|
709
|
+
if (s.isDirectory()) { scanForLargeFiles(full, depth + 1); continue; }
|
|
710
|
+
if (entry.endsWith(".md") && s.size > MAX_FILE_BYTES) {
|
|
711
|
+
largeFiles.push({ path: full.replace(basePath + "/", ""), sizeKB: Math.round(s.size / 1024) });
|
|
712
|
+
}
|
|
713
|
+
} catch { /* skip entry */ }
|
|
714
|
+
}
|
|
715
|
+
} catch { /* skip dir */ }
|
|
716
|
+
}
|
|
717
|
+
scanForLargeFiles(milestonesPath);
|
|
718
|
+
if (largeFiles.length > 0) {
|
|
719
|
+
largeFiles.sort((a, b) => b.sizeKB - a.sizeKB);
|
|
720
|
+
const worst = largeFiles[0]!;
|
|
721
|
+
issues.push({
|
|
722
|
+
severity: "warning",
|
|
723
|
+
code: "large_planning_file",
|
|
724
|
+
scope: "project",
|
|
725
|
+
unitId: "project",
|
|
726
|
+
message: `${largeFiles.length} planning file(s) exceed 100KB — largest: ${worst.path} (${worst.sizeKB}KB). Large files cause LLM context pressure.`,
|
|
727
|
+
file: worst.path,
|
|
728
|
+
fixable: false,
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
} catch {
|
|
733
|
+
// Non-fatal — large file scan failed
|
|
734
|
+
}
|
|
660
735
|
}
|
|
661
736
|
|
|
662
737
|
/**
|
|
@@ -407,6 +407,63 @@ function checkGitRemote(basePath: string): EnvironmentCheckResult | null {
|
|
|
407
407
|
return { name: "git_remote", status: "ok", message: "Git remote reachable" };
|
|
408
408
|
}
|
|
409
409
|
|
|
410
|
+
/**
|
|
411
|
+
* Check if the project build passes (opt-in slow check, use --build flag).
|
|
412
|
+
* Runs npm run build and reports failure as env_build.
|
|
413
|
+
*/
|
|
414
|
+
function checkBuildHealth(basePath: string): EnvironmentCheckResult | null {
|
|
415
|
+
const pkgPath = join(basePath, "package.json");
|
|
416
|
+
if (!existsSync(pkgPath)) return null;
|
|
417
|
+
|
|
418
|
+
try {
|
|
419
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
420
|
+
const buildScript = pkg.scripts?.build;
|
|
421
|
+
if (!buildScript) return null;
|
|
422
|
+
|
|
423
|
+
const result = tryExec("npm run build 2>&1", basePath);
|
|
424
|
+
if (result === null) {
|
|
425
|
+
return {
|
|
426
|
+
name: "build",
|
|
427
|
+
status: "error",
|
|
428
|
+
message: "Build failed — npm run build exited non-zero",
|
|
429
|
+
detail: "Fix build errors before dispatching work",
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
return { name: "build", status: "ok", message: "Build passes" };
|
|
433
|
+
} catch {
|
|
434
|
+
return null;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Check if tests pass (opt-in slow check, use --test flag).
|
|
440
|
+
* Runs npm test and reports failures as env_test.
|
|
441
|
+
*/
|
|
442
|
+
function checkTestHealth(basePath: string): EnvironmentCheckResult | null {
|
|
443
|
+
const pkgPath = join(basePath, "package.json");
|
|
444
|
+
if (!existsSync(pkgPath)) return null;
|
|
445
|
+
|
|
446
|
+
try {
|
|
447
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
448
|
+
const testScript = pkg.scripts?.test;
|
|
449
|
+
// Skip if no test script or the default placeholder
|
|
450
|
+
if (!testScript || testScript.includes("no test specified")) return null;
|
|
451
|
+
|
|
452
|
+
const result = tryExec("npm test 2>&1", basePath);
|
|
453
|
+
if (result === null) {
|
|
454
|
+
return {
|
|
455
|
+
name: "test",
|
|
456
|
+
status: "warning",
|
|
457
|
+
message: "Tests failing — npm test exited non-zero",
|
|
458
|
+
detail: "Fix failing tests before shipping",
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
return { name: "test", status: "ok", message: "Tests pass" };
|
|
462
|
+
} catch {
|
|
463
|
+
return null;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
410
467
|
// ── Public API ─────────────────────────────────────────────────────────────
|
|
411
468
|
|
|
412
469
|
/**
|
|
@@ -454,6 +511,26 @@ export function runFullEnvironmentChecks(basePath: string): EnvironmentCheckResu
|
|
|
454
511
|
return results;
|
|
455
512
|
}
|
|
456
513
|
|
|
514
|
+
/**
|
|
515
|
+
* Run slow opt-in checks (build and/or test).
|
|
516
|
+
* These are never run on the pre-dispatch gate — only on explicit /gsd doctor --build/--test.
|
|
517
|
+
*/
|
|
518
|
+
export function runSlowEnvironmentChecks(
|
|
519
|
+
basePath: string,
|
|
520
|
+
options?: { includeBuild?: boolean; includeTests?: boolean },
|
|
521
|
+
): EnvironmentCheckResult[] {
|
|
522
|
+
const results: EnvironmentCheckResult[] = [];
|
|
523
|
+
if (options?.includeBuild) {
|
|
524
|
+
const buildCheck = checkBuildHealth(basePath);
|
|
525
|
+
if (buildCheck) results.push(buildCheck);
|
|
526
|
+
}
|
|
527
|
+
if (options?.includeTests) {
|
|
528
|
+
const testCheck = checkTestHealth(basePath);
|
|
529
|
+
if (testCheck) results.push(testCheck);
|
|
530
|
+
}
|
|
531
|
+
return results;
|
|
532
|
+
}
|
|
533
|
+
|
|
457
534
|
/**
|
|
458
535
|
* Convert environment check results to DoctorIssue format for the doctor pipeline.
|
|
459
536
|
*/
|
|
@@ -477,12 +554,16 @@ export function environmentResultsToDoctorIssues(results: EnvironmentCheckResult
|
|
|
477
554
|
export async function checkEnvironmentHealth(
|
|
478
555
|
basePath: string,
|
|
479
556
|
issues: DoctorIssue[],
|
|
480
|
-
options?: { includeRemote?: boolean },
|
|
557
|
+
options?: { includeRemote?: boolean; includeBuild?: boolean; includeTests?: boolean },
|
|
481
558
|
): Promise<void> {
|
|
482
559
|
const results = options?.includeRemote
|
|
483
560
|
? runFullEnvironmentChecks(basePath)
|
|
484
561
|
: runEnvironmentChecks(basePath);
|
|
485
562
|
|
|
563
|
+
if (options?.includeBuild || options?.includeTests) {
|
|
564
|
+
results.push(...runSlowEnvironmentChecks(basePath, options));
|
|
565
|
+
}
|
|
566
|
+
|
|
486
567
|
issues.push(...environmentResultsToDoctorIssues(results));
|
|
487
568
|
}
|
|
488
569
|
|
|
@@ -76,3 +76,23 @@ export function formatDoctorIssuesForPrompt(issues: DoctorIssue[]): string {
|
|
|
76
76
|
return `- [${prefix}] ${issue.unitId} | ${issue.code} | ${issue.message}${issue.file ? ` | file: ${issue.file}` : ""} | fixable: ${issue.fixable ? "yes" : "no"}`;
|
|
77
77
|
}).join("\n");
|
|
78
78
|
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Serialize a doctor report to JSON — suitable for CI/tooling integration.
|
|
82
|
+
* Usage: /gsd doctor --json
|
|
83
|
+
*/
|
|
84
|
+
export function formatDoctorReportJson(report: DoctorReport): string {
|
|
85
|
+
return JSON.stringify(
|
|
86
|
+
{
|
|
87
|
+
ok: report.ok,
|
|
88
|
+
basePath: report.basePath,
|
|
89
|
+
generatedAt: new Date().toISOString(),
|
|
90
|
+
summary: summarizeDoctorIssues(report.issues),
|
|
91
|
+
issues: report.issues,
|
|
92
|
+
fixesApplied: report.fixesApplied,
|
|
93
|
+
...(report.timing ? { timing: report.timing } : {}),
|
|
94
|
+
},
|
|
95
|
+
null,
|
|
96
|
+
2,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
@@ -53,7 +53,20 @@ export type DoctorIssueCode =
|
|
|
53
53
|
| "stranded_lock_directory"
|
|
54
54
|
// Git / worktree integrity checks
|
|
55
55
|
| "integration_branch_missing"
|
|
56
|
-
| "worktree_directory_orphaned"
|
|
56
|
+
| "worktree_directory_orphaned"
|
|
57
|
+
// GSD state structural checks
|
|
58
|
+
| "circular_slice_dependency"
|
|
59
|
+
| "orphaned_slice_directory"
|
|
60
|
+
| "duplicate_task_id"
|
|
61
|
+
| "task_file_not_in_plan"
|
|
62
|
+
| "stale_replan_file"
|
|
63
|
+
| "future_timestamp"
|
|
64
|
+
// Runtime data integrity
|
|
65
|
+
| "metrics_ledger_corrupt"
|
|
66
|
+
| "large_planning_file"
|
|
67
|
+
// Slow environment checks (opt-in via --build / --test flags)
|
|
68
|
+
| "env_build"
|
|
69
|
+
| "env_test";
|
|
57
70
|
|
|
58
71
|
/**
|
|
59
72
|
* Issue codes that represent expected completion-transition states.
|
|
@@ -83,6 +96,8 @@ export interface DoctorReport {
|
|
|
83
96
|
basePath: string;
|
|
84
97
|
issues: DoctorIssue[];
|
|
85
98
|
fixesApplied: string[];
|
|
99
|
+
/** Per-domain check durations in milliseconds. Present on explicit /gsd doctor runs. */
|
|
100
|
+
timing?: { git: number; runtime: number; environment: number; gsdState: number };
|
|
86
101
|
}
|
|
87
102
|
|
|
88
103
|
export interface DoctorSummary {
|