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.
Files changed (47) hide show
  1. package/dist/cli.js +9 -0
  2. package/dist/extension-discovery.d.ts +5 -3
  3. package/dist/extension-discovery.js +14 -9
  4. package/dist/resources/extensions/browser-tools/package.json +3 -1
  5. package/dist/resources/extensions/cmux/index.js +55 -1
  6. package/dist/resources/extensions/context7/package.json +1 -1
  7. package/dist/resources/extensions/google-search/package.json +3 -1
  8. package/dist/resources/extensions/gsd/auto-loop.js +7 -1
  9. package/dist/resources/extensions/gsd/auto-start.js +6 -1
  10. package/dist/resources/extensions/gsd/auto-worktree-sync.js +11 -4
  11. package/dist/resources/extensions/gsd/captures.js +9 -1
  12. package/dist/resources/extensions/gsd/commands-handlers.js +16 -3
  13. package/dist/resources/extensions/gsd/commands.js +20 -1
  14. package/dist/resources/extensions/gsd/doctor-checks.js +82 -0
  15. package/dist/resources/extensions/gsd/doctor-environment.js +78 -0
  16. package/dist/resources/extensions/gsd/doctor-format.js +15 -0
  17. package/dist/resources/extensions/gsd/doctor.js +184 -11
  18. package/dist/resources/extensions/gsd/package.json +1 -1
  19. package/dist/resources/extensions/gsd/worktree.js +35 -16
  20. package/dist/resources/extensions/subagent/index.js +12 -3
  21. package/dist/resources/extensions/universal-config/package.json +1 -1
  22. package/dist/welcome-screen.d.ts +12 -0
  23. package/dist/welcome-screen.js +53 -0
  24. package/package.json +1 -1
  25. package/packages/pi-coding-agent/dist/core/package-manager.d.ts.map +1 -1
  26. package/packages/pi-coding-agent/dist/core/package-manager.js +8 -4
  27. package/packages/pi-coding-agent/dist/core/package-manager.js.map +1 -1
  28. package/packages/pi-coding-agent/package.json +1 -1
  29. package/packages/pi-coding-agent/src/core/package-manager.ts +8 -4
  30. package/pkg/package.json +1 -1
  31. package/src/resources/extensions/cmux/index.ts +57 -1
  32. package/src/resources/extensions/gsd/auto-loop.ts +13 -1
  33. package/src/resources/extensions/gsd/auto-start.ts +7 -1
  34. package/src/resources/extensions/gsd/auto-worktree-sync.ts +12 -3
  35. package/src/resources/extensions/gsd/captures.ts +10 -1
  36. package/src/resources/extensions/gsd/commands-handlers.ts +17 -2
  37. package/src/resources/extensions/gsd/commands.ts +21 -1
  38. package/src/resources/extensions/gsd/doctor-checks.ts +75 -0
  39. package/src/resources/extensions/gsd/doctor-environment.ts +82 -1
  40. package/src/resources/extensions/gsd/doctor-format.ts +20 -0
  41. package/src/resources/extensions/gsd/doctor-types.ts +16 -1
  42. package/src/resources/extensions/gsd/doctor.ts +177 -13
  43. package/src/resources/extensions/gsd/tests/cmux.test.ts +93 -0
  44. package/src/resources/extensions/gsd/tests/doctor-enhancements.test.ts +266 -0
  45. package/src/resources/extensions/gsd/tests/worktree.test.ts +47 -0
  46. package/src/resources/extensions/gsd/worktree.ts +35 -15
  47. 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), this.config.surfaceId);
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
- return p.endsWith(worktreesSuffix);
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
- const marker = `${pathSep}.gsd${pathSep}worktrees${pathSep}`;
157
- const idx = base.indexOf(marker);
158
- if (idx === -1) return base;
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
- const idx = resolved.indexOf(worktreeMarker);
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
- const parts = trimmed ? trimmed.split(/\s+/) : [];
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 {