gsd-pi 2.37.1 → 2.38.0-dev.4d4d14a

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 (222) hide show
  1. package/README.md +1 -1
  2. package/dist/app-paths.js +1 -1
  3. package/dist/cli.js +9 -0
  4. package/dist/extension-discovery.d.ts +5 -3
  5. package/dist/extension-discovery.js +14 -9
  6. package/dist/extension-registry.js +2 -2
  7. package/dist/onboarding.js +1 -0
  8. package/dist/remote-questions-config.js +2 -2
  9. package/dist/resources/extensions/browser-tools/package.json +3 -1
  10. package/dist/resources/extensions/cmux/index.js +55 -1
  11. package/dist/resources/extensions/context7/package.json +1 -1
  12. package/dist/resources/extensions/env-utils.js +29 -0
  13. package/dist/resources/extensions/get-secrets-from-user.js +5 -24
  14. package/dist/resources/extensions/github-sync/cli.js +284 -0
  15. package/dist/resources/extensions/github-sync/index.js +73 -0
  16. package/dist/resources/extensions/github-sync/mapping.js +67 -0
  17. package/dist/resources/extensions/github-sync/sync.js +424 -0
  18. package/dist/resources/extensions/github-sync/templates.js +118 -0
  19. package/dist/resources/extensions/github-sync/types.js +7 -0
  20. package/dist/resources/extensions/google-search/package.json +3 -1
  21. package/dist/resources/extensions/gsd/auto/session.js +6 -23
  22. package/dist/resources/extensions/gsd/auto-dispatch.js +74 -9
  23. package/dist/resources/extensions/gsd/auto-loop.js +149 -170
  24. package/dist/resources/extensions/gsd/auto-post-unit.js +105 -68
  25. package/dist/resources/extensions/gsd/auto-prompts.js +98 -33
  26. package/dist/resources/extensions/gsd/auto-recovery.js +37 -1
  27. package/dist/resources/extensions/gsd/auto-start.js +13 -2
  28. package/dist/resources/extensions/gsd/auto-worktree-sync.js +13 -5
  29. package/dist/resources/extensions/gsd/auto.js +143 -96
  30. package/dist/resources/extensions/gsd/captures.js +9 -1
  31. package/dist/resources/extensions/gsd/commands-extensions.js +3 -2
  32. package/dist/resources/extensions/gsd/commands-handlers.js +16 -3
  33. package/dist/resources/extensions/gsd/commands-prefs-wizard.js +1 -1
  34. package/dist/resources/extensions/gsd/commands.js +22 -2
  35. package/dist/resources/extensions/gsd/context-budget.js +2 -10
  36. package/dist/resources/extensions/gsd/detection.js +1 -2
  37. package/dist/resources/extensions/gsd/docs/preferences-reference.md +0 -2
  38. package/dist/resources/extensions/gsd/doctor-checks.js +82 -0
  39. package/dist/resources/extensions/gsd/doctor-environment.js +78 -0
  40. package/dist/resources/extensions/gsd/doctor-format.js +15 -0
  41. package/dist/resources/extensions/gsd/doctor-providers.js +62 -12
  42. package/dist/resources/extensions/gsd/doctor.js +184 -11
  43. package/dist/resources/extensions/gsd/export.js +1 -1
  44. package/dist/resources/extensions/gsd/files.js +43 -2
  45. package/dist/resources/extensions/gsd/forensics.js +1 -1
  46. package/dist/resources/extensions/gsd/git-service.js +8 -1
  47. package/dist/resources/extensions/gsd/index.js +24 -20
  48. package/dist/resources/extensions/gsd/migrate/parsers.js +1 -1
  49. package/dist/resources/extensions/gsd/observability-validator.js +24 -0
  50. package/dist/resources/extensions/gsd/package.json +1 -1
  51. package/dist/resources/extensions/gsd/preferences-models.js +0 -12
  52. package/dist/resources/extensions/gsd/preferences-types.js +3 -2
  53. package/dist/resources/extensions/gsd/preferences-validation.js +101 -11
  54. package/dist/resources/extensions/gsd/preferences.js +8 -5
  55. package/dist/resources/extensions/gsd/prompts/discuss.md +11 -14
  56. package/dist/resources/extensions/gsd/prompts/execute-task.md +2 -2
  57. package/dist/resources/extensions/gsd/prompts/guided-discuss-milestone.md +11 -12
  58. package/dist/resources/extensions/gsd/prompts/guided-discuss-slice.md +8 -10
  59. package/dist/resources/extensions/gsd/prompts/guided-resume-task.md +1 -1
  60. package/dist/resources/extensions/gsd/prompts/plan-slice.md +2 -1
  61. package/dist/resources/extensions/gsd/prompts/queue.md +4 -8
  62. package/dist/resources/extensions/gsd/prompts/reactive-execute.md +44 -0
  63. package/dist/resources/extensions/gsd/prompts/run-uat.md +25 -10
  64. package/dist/resources/extensions/gsd/prompts/workflow-start.md +2 -2
  65. package/dist/resources/extensions/gsd/reactive-graph.js +227 -0
  66. package/dist/resources/extensions/gsd/repo-identity.js +21 -4
  67. package/dist/resources/extensions/gsd/resource-version.js +2 -1
  68. package/dist/resources/extensions/gsd/state.js +1 -1
  69. package/dist/resources/extensions/gsd/templates/task-plan.md +11 -3
  70. package/dist/resources/extensions/gsd/visualizer-data.js +1 -1
  71. package/dist/resources/extensions/gsd/worktree.js +35 -16
  72. package/dist/resources/extensions/remote-questions/status.js +2 -1
  73. package/dist/resources/extensions/remote-questions/store.js +2 -1
  74. package/dist/resources/extensions/search-the-web/provider.js +2 -1
  75. package/dist/resources/extensions/subagent/index.js +12 -3
  76. package/dist/resources/extensions/subagent/isolation.js +2 -1
  77. package/dist/resources/extensions/ttsr/rule-loader.js +2 -1
  78. package/dist/resources/extensions/universal-config/package.json +1 -1
  79. package/dist/welcome-screen.d.ts +12 -0
  80. package/dist/welcome-screen.js +53 -0
  81. package/package.json +2 -1
  82. package/packages/pi-ai/dist/env-api-keys.js +13 -0
  83. package/packages/pi-ai/dist/env-api-keys.js.map +1 -1
  84. package/packages/pi-ai/dist/models.generated.d.ts +172 -0
  85. package/packages/pi-ai/dist/models.generated.d.ts.map +1 -1
  86. package/packages/pi-ai/dist/models.generated.js +172 -0
  87. package/packages/pi-ai/dist/models.generated.js.map +1 -1
  88. package/packages/pi-ai/dist/providers/anthropic-shared.d.ts +64 -0
  89. package/packages/pi-ai/dist/providers/anthropic-shared.d.ts.map +1 -0
  90. package/packages/pi-ai/dist/providers/anthropic-shared.js +668 -0
  91. package/packages/pi-ai/dist/providers/anthropic-shared.js.map +1 -0
  92. package/packages/pi-ai/dist/providers/anthropic-vertex.d.ts +5 -0
  93. package/packages/pi-ai/dist/providers/anthropic-vertex.d.ts.map +1 -0
  94. package/packages/pi-ai/dist/providers/anthropic-vertex.js +85 -0
  95. package/packages/pi-ai/dist/providers/anthropic-vertex.js.map +1 -0
  96. package/packages/pi-ai/dist/providers/anthropic.d.ts +4 -30
  97. package/packages/pi-ai/dist/providers/anthropic.d.ts.map +1 -1
  98. package/packages/pi-ai/dist/providers/anthropic.js +47 -764
  99. package/packages/pi-ai/dist/providers/anthropic.js.map +1 -1
  100. package/packages/pi-ai/dist/providers/register-builtins.d.ts.map +1 -1
  101. package/packages/pi-ai/dist/providers/register-builtins.js +6 -0
  102. package/packages/pi-ai/dist/providers/register-builtins.js.map +1 -1
  103. package/packages/pi-ai/dist/types.d.ts +2 -2
  104. package/packages/pi-ai/dist/types.d.ts.map +1 -1
  105. package/packages/pi-ai/dist/types.js.map +1 -1
  106. package/packages/pi-ai/dist/utils/oauth/anthropic.js +2 -2
  107. package/packages/pi-ai/dist/utils/oauth/anthropic.js.map +1 -1
  108. package/packages/pi-ai/package.json +1 -0
  109. package/packages/pi-ai/src/env-api-keys.ts +14 -0
  110. package/packages/pi-ai/src/models.generated.ts +172 -0
  111. package/packages/pi-ai/src/providers/anthropic-shared.ts +761 -0
  112. package/packages/pi-ai/src/providers/anthropic-vertex.ts +130 -0
  113. package/packages/pi-ai/src/providers/anthropic.ts +76 -868
  114. package/packages/pi-ai/src/providers/register-builtins.ts +7 -0
  115. package/packages/pi-ai/src/types.ts +2 -0
  116. package/packages/pi-ai/src/utils/oauth/anthropic.ts +2 -2
  117. package/packages/pi-coding-agent/dist/core/model-resolver.d.ts.map +1 -1
  118. package/packages/pi-coding-agent/dist/core/model-resolver.js +1 -0
  119. package/packages/pi-coding-agent/dist/core/model-resolver.js.map +1 -1
  120. package/packages/pi-coding-agent/dist/core/package-manager.d.ts.map +1 -1
  121. package/packages/pi-coding-agent/dist/core/package-manager.js +8 -4
  122. package/packages/pi-coding-agent/dist/core/package-manager.js.map +1 -1
  123. package/packages/pi-coding-agent/package.json +1 -1
  124. package/packages/pi-coding-agent/src/core/model-resolver.ts +1 -0
  125. package/packages/pi-coding-agent/src/core/package-manager.ts +8 -4
  126. package/pkg/package.json +1 -1
  127. package/src/resources/extensions/cmux/index.ts +57 -1
  128. package/src/resources/extensions/env-utils.ts +31 -0
  129. package/src/resources/extensions/get-secrets-from-user.ts +5 -24
  130. package/src/resources/extensions/github-sync/cli.ts +364 -0
  131. package/src/resources/extensions/github-sync/index.ts +93 -0
  132. package/src/resources/extensions/github-sync/mapping.ts +81 -0
  133. package/src/resources/extensions/github-sync/sync.ts +556 -0
  134. package/src/resources/extensions/github-sync/templates.ts +183 -0
  135. package/src/resources/extensions/github-sync/tests/cli.test.ts +20 -0
  136. package/src/resources/extensions/github-sync/tests/commit-linking.test.ts +39 -0
  137. package/src/resources/extensions/github-sync/tests/mapping.test.ts +104 -0
  138. package/src/resources/extensions/github-sync/tests/templates.test.ts +110 -0
  139. package/src/resources/extensions/github-sync/types.ts +47 -0
  140. package/src/resources/extensions/gsd/auto/session.ts +7 -25
  141. package/src/resources/extensions/gsd/auto-dispatch.ts +99 -8
  142. package/src/resources/extensions/gsd/auto-loop.ts +207 -252
  143. package/src/resources/extensions/gsd/auto-post-unit.ts +82 -39
  144. package/src/resources/extensions/gsd/auto-prompts.ts +132 -36
  145. package/src/resources/extensions/gsd/auto-recovery.ts +42 -0
  146. package/src/resources/extensions/gsd/auto-start.ts +18 -2
  147. package/src/resources/extensions/gsd/auto-worktree-sync.ts +15 -4
  148. package/src/resources/extensions/gsd/auto.ts +139 -101
  149. package/src/resources/extensions/gsd/captures.ts +10 -1
  150. package/src/resources/extensions/gsd/commands-extensions.ts +4 -2
  151. package/src/resources/extensions/gsd/commands-handlers.ts +17 -2
  152. package/src/resources/extensions/gsd/commands-prefs-wizard.ts +1 -1
  153. package/src/resources/extensions/gsd/commands.ts +24 -2
  154. package/src/resources/extensions/gsd/context-budget.ts +2 -12
  155. package/src/resources/extensions/gsd/detection.ts +2 -2
  156. package/src/resources/extensions/gsd/docs/preferences-reference.md +0 -2
  157. package/src/resources/extensions/gsd/doctor-checks.ts +75 -0
  158. package/src/resources/extensions/gsd/doctor-environment.ts +82 -1
  159. package/src/resources/extensions/gsd/doctor-format.ts +20 -0
  160. package/src/resources/extensions/gsd/doctor-providers.ts +64 -10
  161. package/src/resources/extensions/gsd/doctor-types.ts +16 -1
  162. package/src/resources/extensions/gsd/doctor.ts +177 -13
  163. package/src/resources/extensions/gsd/export.ts +1 -1
  164. package/src/resources/extensions/gsd/files.ts +47 -2
  165. package/src/resources/extensions/gsd/forensics.ts +1 -1
  166. package/src/resources/extensions/gsd/git-service.ts +13 -1
  167. package/src/resources/extensions/gsd/index.ts +24 -17
  168. package/src/resources/extensions/gsd/migrate/parsers.ts +1 -1
  169. package/src/resources/extensions/gsd/observability-validator.ts +27 -0
  170. package/src/resources/extensions/gsd/preferences-models.ts +0 -12
  171. package/src/resources/extensions/gsd/preferences-types.ts +9 -5
  172. package/src/resources/extensions/gsd/preferences-validation.ts +92 -11
  173. package/src/resources/extensions/gsd/preferences.ts +8 -5
  174. package/src/resources/extensions/gsd/prompts/discuss.md +11 -14
  175. package/src/resources/extensions/gsd/prompts/execute-task.md +2 -2
  176. package/src/resources/extensions/gsd/prompts/guided-discuss-milestone.md +11 -12
  177. package/src/resources/extensions/gsd/prompts/guided-discuss-slice.md +8 -10
  178. package/src/resources/extensions/gsd/prompts/guided-resume-task.md +1 -1
  179. package/src/resources/extensions/gsd/prompts/plan-slice.md +2 -1
  180. package/src/resources/extensions/gsd/prompts/queue.md +4 -8
  181. package/src/resources/extensions/gsd/prompts/reactive-execute.md +44 -0
  182. package/src/resources/extensions/gsd/prompts/run-uat.md +25 -10
  183. package/src/resources/extensions/gsd/prompts/workflow-start.md +2 -2
  184. package/src/resources/extensions/gsd/reactive-graph.ts +289 -0
  185. package/src/resources/extensions/gsd/repo-identity.ts +23 -4
  186. package/src/resources/extensions/gsd/resource-version.ts +3 -1
  187. package/src/resources/extensions/gsd/state.ts +1 -1
  188. package/src/resources/extensions/gsd/templates/task-plan.md +11 -3
  189. package/src/resources/extensions/gsd/tests/agent-end-retry.test.ts +21 -18
  190. package/src/resources/extensions/gsd/tests/auto-loop.test.ts +16 -37
  191. package/src/resources/extensions/gsd/tests/cmux.test.ts +93 -0
  192. package/src/resources/extensions/gsd/tests/doctor-enhancements.test.ts +266 -0
  193. package/src/resources/extensions/gsd/tests/doctor-providers.test.ts +191 -3
  194. package/src/resources/extensions/gsd/tests/plan-quality-validator.test.ts +111 -0
  195. package/src/resources/extensions/gsd/tests/preferences.test.ts +2 -7
  196. package/src/resources/extensions/gsd/tests/prompt-contracts.test.ts +59 -0
  197. package/src/resources/extensions/gsd/tests/reactive-executor.test.ts +511 -0
  198. package/src/resources/extensions/gsd/tests/reactive-graph.test.ts +299 -0
  199. package/src/resources/extensions/gsd/tests/repo-identity-worktree.test.ts +21 -1
  200. package/src/resources/extensions/gsd/tests/run-uat.test.ts +11 -3
  201. package/src/resources/extensions/gsd/tests/worktree.test.ts +47 -0
  202. package/src/resources/extensions/gsd/types.ts +43 -1
  203. package/src/resources/extensions/gsd/visualizer-data.ts +1 -1
  204. package/src/resources/extensions/gsd/worktree.ts +35 -15
  205. package/src/resources/extensions/remote-questions/status.ts +3 -1
  206. package/src/resources/extensions/remote-questions/store.ts +3 -1
  207. package/src/resources/extensions/search-the-web/provider.ts +2 -1
  208. package/src/resources/extensions/subagent/index.ts +12 -3
  209. package/src/resources/extensions/subagent/isolation.ts +3 -1
  210. package/src/resources/extensions/ttsr/rule-loader.ts +3 -1
  211. package/dist/resources/extensions/gsd/prompt-compressor.js +0 -393
  212. package/dist/resources/extensions/gsd/semantic-chunker.js +0 -254
  213. package/dist/resources/extensions/gsd/summary-distiller.js +0 -212
  214. package/src/resources/extensions/gsd/prompt-compressor.ts +0 -508
  215. package/src/resources/extensions/gsd/semantic-chunker.ts +0 -336
  216. package/src/resources/extensions/gsd/summary-distiller.ts +0 -258
  217. package/src/resources/extensions/gsd/tests/context-compression.test.ts +0 -193
  218. package/src/resources/extensions/gsd/tests/prompt-compressor.test.ts +0 -529
  219. package/src/resources/extensions/gsd/tests/semantic-chunker.test.ts +0 -426
  220. package/src/resources/extensions/gsd/tests/summary-distiller.test.ts +0 -323
  221. package/src/resources/extensions/gsd/tests/token-optimization-benchmark.test.ts +0 -1272
  222. package/src/resources/extensions/gsd/tests/token-optimization-prefs.test.ts +0 -164
@@ -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
+ }
@@ -14,6 +14,7 @@
14
14
  import { existsSync } from "node:fs";
15
15
  import { join } from "node:path";
16
16
  import { AuthStorage } from "@gsd/pi-coding-agent";
17
+ import { getEnvApiKey } from "@gsd/pi-ai";
17
18
  import { loadEffectiveGSDPreferences } from "./preferences.js";
18
19
  import { getAuthPath, PROVIDER_REGISTRY, type ProviderCategory } from "./key-manager.js";
19
20
 
@@ -50,12 +51,15 @@ function modelToProviderId(model: string): string | null {
50
51
  const prefix = model.split("/")[0].toLowerCase();
51
52
  // Map known prefixes to registry IDs
52
53
  const prefixMap: Record<string, string> = {
54
+ "anthropic-vertex": "anthropic-vertex",
53
55
  openrouter: "openrouter",
54
56
  groq: "groq",
55
57
  mistral: "mistral",
56
58
  google: "google",
59
+ "google-vertex": "google-vertex",
57
60
  anthropic: "anthropic",
58
61
  openai: "openai",
62
+ "github-copilot": "github-copilot",
59
63
  };
60
64
  if (prefixMap[prefix]) return prefixMap[prefix];
61
65
  }
@@ -86,11 +90,20 @@ function collectConfiguredModelProviders(): Set<string> {
86
90
 
87
91
  const modelEntries = typeof models === "object" ? Object.values(models) : [];
88
92
  for (const entry of modelEntries) {
89
- const modelId = typeof entry === "string" ? entry
90
- : typeof entry === "object" && entry !== null && "model" in entry
91
- ? String((entry as { model: unknown }).model)
92
- : null;
93
- if (modelId) {
93
+ if (typeof entry === "string") {
94
+ const pid = modelToProviderId(entry);
95
+ if (pid) providers.add(pid);
96
+ continue;
97
+ }
98
+
99
+ if (typeof entry === "object" && entry !== null && "model" in entry) {
100
+ const configuredProvider = "provider" in entry ? (entry as { provider?: unknown }).provider : undefined;
101
+ if (typeof configuredProvider === "string" && configuredProvider.trim().length > 0) {
102
+ providers.add(configuredProvider);
103
+ continue;
104
+ }
105
+
106
+ const modelId = String((entry as { model: unknown }).model);
94
107
  const pid = modelToProviderId(modelId);
95
108
  if (pid) providers.add(pid);
96
109
  }
@@ -139,7 +152,15 @@ function resolveKey(providerId: string): KeyLookup {
139
152
  }
140
153
  }
141
154
 
142
- // Check environment variable
155
+ // Check environment variable using the authoritative env var resolution
156
+ // (handles multi-var lookups like ANTHROPIC_OAUTH_TOKEN || ANTHROPIC_API_KEY,
157
+ // COPILOT_GITHUB_TOKEN || GH_TOKEN || GITHUB_TOKEN, Vertex ADC, Bedrock, etc.)
158
+ if (getEnvApiKey(providerId)) {
159
+ return { found: true, source: "env", backedOff: false };
160
+ }
161
+
162
+ // Fall back to PROVIDER_REGISTRY env var for providers not covered by getEnvApiKey
163
+ // (e.g., search providers like Brave, Tavily; tool providers like Jina, Context7)
143
164
  if (info?.envVar && process.env[info.envVar]) {
144
165
  return { found: true, source: "env", backedOff: false };
145
166
  }
@@ -149,24 +170,57 @@ function resolveKey(providerId: string): KeyLookup {
149
170
 
150
171
  // ── Individual check groups ────────────────────────────────────────────────────
151
172
 
173
+ /**
174
+ * Providers that can serve models normally associated with another provider.
175
+ * Key = the provider whose models can be served, Value = alternative providers to check.
176
+ * e.g. GitHub Copilot subscriptions can access Claude and GPT models.
177
+ */
178
+ const PROVIDER_ROUTES: Record<string, string[]> = {
179
+ anthropic: ["github-copilot"],
180
+ openai: ["github-copilot"],
181
+ };
182
+
152
183
  function checkLlmProviders(): ProviderCheckResult[] {
153
184
  const required = collectConfiguredModelProviders();
154
185
  const results: ProviderCheckResult[] = [];
155
186
 
156
187
  for (const providerId of required) {
157
188
  const info = PROVIDER_REGISTRY.find(p => p.id === providerId);
158
- const label = info?.label ?? providerId;
189
+ const label = providerId === "anthropic-vertex"
190
+ ? "Anthropic Vertex"
191
+ : info?.label ?? providerId;
159
192
  const lookup = resolveKey(providerId);
160
193
 
161
194
  if (!lookup.found) {
162
- const envVar = info?.envVar ?? `${providerId.toUpperCase()}_API_KEY`;
195
+ // Check if a cross-provider can serve this provider's models
196
+ const routes = PROVIDER_ROUTES[providerId];
197
+ const routeProvider = routes?.find(routeId => resolveKey(routeId).found);
198
+ if (routeProvider) {
199
+ const routeInfo = PROVIDER_REGISTRY.find(p => p.id === routeProvider);
200
+ const routeLabel = routeInfo?.label ?? routeProvider;
201
+ results.push({
202
+ name: providerId,
203
+ label,
204
+ category: "llm",
205
+ status: "ok",
206
+ message: `${label} — available via ${routeLabel}`,
207
+ required: true,
208
+ });
209
+ continue;
210
+ }
211
+
212
+ const envVar = providerId === "anthropic-vertex"
213
+ ? "ANTHROPIC_VERTEX_PROJECT_ID"
214
+ : info?.envVar ?? `${providerId.toUpperCase()}_API_KEY`;
163
215
  results.push({
164
216
  name: providerId,
165
217
  label,
166
218
  category: "llm",
167
219
  status: "error",
168
- message: `${label} — no API key found`,
169
- detail: info?.hasOAuth
220
+ message: `${label} — not configured`,
221
+ detail: providerId === "anthropic-vertex"
222
+ ? "Set ANTHROPIC_VERTEX_PROJECT_ID and authenticate with Google ADC"
223
+ : info?.hasOAuth
170
224
  ? `Run /gsd keys to authenticate`
171
225
  : `Set ${envVar} or run /gsd keys`,
172
226
  required: true,
@@ -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 {
@@ -1,14 +1,15 @@
1
- import { existsSync, mkdirSync } from "node:fs";
1
+ import { existsSync, mkdirSync, lstatSync, readdirSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
 
4
4
  import { loadFile, parsePlan, parseRoadmap, parseSummary, saveFile, parseTaskPlanMustHaves, countMustHavesMentionedInSummary } from "./files.js";
5
- import { resolveMilestoneFile, resolveMilestonePath, resolveSliceFile, resolveSlicePath, resolveTaskFile, resolveTasksDir, milestonesDir, gsdRoot, relMilestoneFile, relSliceFile, relTaskFile, relSlicePath, relGsdRootFile, resolveGsdRootFile } from "./paths.js";
5
+ import { resolveMilestoneFile, resolveMilestonePath, resolveSliceFile, resolveSlicePath, resolveTaskFile, resolveTasksDir, milestonesDir, gsdRoot, relMilestoneFile, relSliceFile, relTaskFile, relSlicePath, relGsdRootFile, resolveGsdRootFile, relMilestonePath } from "./paths.js";
6
6
  import { deriveState, isMilestoneComplete } from "./state.js";
7
7
  import { invalidateAllCaches } from "./cache.js";
8
8
  import { loadEffectiveGSDPreferences, type GSDPreferences } from "./preferences.js";
9
9
 
10
- import type { DoctorIssue, DoctorIssueCode } from "./doctor-types.js";
10
+ import type { DoctorIssue, DoctorIssueCode, DoctorReport } from "./doctor-types.js";
11
11
  import { COMPLETION_TRANSITION_CODES } from "./doctor-types.js";
12
+ import type { RoadmapSliceEntry } from "./types.js";
12
13
  import { checkGitHealth, checkRuntimeHealth } from "./doctor-checks.js";
13
14
  import { checkEnvironmentHealth } from "./doctor-environment.js";
14
15
  import { runProviderChecks } from "./doctor-providers.js";
@@ -17,7 +18,7 @@ import { runProviderChecks } from "./doctor-providers.js";
17
18
  // All public types and functions from extracted modules are re-exported here
18
19
  // so that existing imports from "./doctor.js" continue to work unchanged.
19
20
  export type { DoctorSeverity, DoctorIssueCode, DoctorIssue, DoctorReport, DoctorSummary } from "./doctor-types.js";
20
- export { summarizeDoctorIssues, filterDoctorIssues, formatDoctorReport, formatDoctorIssuesForPrompt } from "./doctor-format.js";
21
+ export { summarizeDoctorIssues, filterDoctorIssues, formatDoctorReport, formatDoctorIssuesForPrompt, formatDoctorReportJson } from "./doctor-format.js";
21
22
  export { runEnvironmentChecks, runFullEnvironmentChecks, formatEnvironmentReport, type EnvironmentCheckResult } from "./doctor-environment.js";
22
23
  export { computeProgressScore, computeProgressScoreWithContext, formatProgressLine, formatProgressReport, type ProgressScore, type ProgressLevel } from "./progress-score.js";
23
24
 
@@ -350,10 +351,60 @@ export async function selectDoctorScope(basePath: string, requestedScope?: strin
350
351
  return state.registry[0]?.id;
351
352
  }
352
353
 
353
- export async function runGSDDoctor(basePath: string, options?: { fix?: boolean; scope?: string; fixLevel?: "task" | "all"; isolationMode?: "none" | "worktree" | "branch" }): Promise<import("./doctor-types.js").DoctorReport> {
354
+ // ── Helper: circular dependency detection ──────────────────────────────────
355
+ function detectCircularDependencies(slices: RoadmapSliceEntry[]): string[][] {
356
+ const known = new Set(slices.map(s => s.id));
357
+ const adj = new Map<string, string[]>();
358
+ for (const s of slices) adj.set(s.id, s.depends.filter(d => known.has(d)));
359
+ const state = new Map<string, "unvisited" | "visiting" | "done">();
360
+ for (const s of slices) state.set(s.id, "unvisited");
361
+ const cycles: string[][] = [];
362
+ function dfs(id: string, path: string[]): void {
363
+ const st = state.get(id);
364
+ if (st === "done") return;
365
+ if (st === "visiting") { cycles.push([...path.slice(path.indexOf(id)), id]); return; }
366
+ state.set(id, "visiting");
367
+ for (const dep of adj.get(id) ?? []) dfs(dep, [...path, id]);
368
+ state.set(id, "done");
369
+ }
370
+ for (const s of slices) if (state.get(s.id) === "unvisited") dfs(s.id, []);
371
+ return cycles;
372
+ }
373
+
374
+ // ── Helper: doctor run history ──────────────────────────────────────────────
375
+ interface DoctorHistoryEntry { ts: string; ok: boolean; errors: number; warnings: number; fixes: number; codes: string[] }
376
+
377
+ async function appendDoctorHistory(basePath: string, report: DoctorReport): Promise<void> {
378
+ try {
379
+ const historyPath = join(gsdRoot(basePath), "doctor-history.jsonl");
380
+ const entry = JSON.stringify({
381
+ ts: new Date().toISOString(),
382
+ ok: report.ok,
383
+ errors: report.issues.filter(i => i.severity === "error").length,
384
+ warnings: report.issues.filter(i => i.severity === "warning").length,
385
+ fixes: report.fixesApplied.length,
386
+ codes: [...new Set(report.issues.map(i => i.code))],
387
+ } satisfies DoctorHistoryEntry);
388
+ const existing = existsSync(historyPath) ? readFileSync(historyPath, "utf-8") : "";
389
+ await saveFile(historyPath, existing + entry + "\n");
390
+ } catch { /* non-fatal */ }
391
+ }
392
+
393
+ /** Read the last N doctor history entries. Returns most-recent-first. */
394
+ export async function readDoctorHistory(basePath: string, lastN = 50): Promise<DoctorHistoryEntry[]> {
395
+ try {
396
+ const historyPath = join(gsdRoot(basePath), "doctor-history.jsonl");
397
+ if (!existsSync(historyPath)) return [];
398
+ const lines = readFileSync(historyPath, "utf-8").split("\n").filter(l => l.trim());
399
+ return lines.slice(-lastN).reverse().map(l => JSON.parse(l) as DoctorHistoryEntry);
400
+ } catch { return []; }
401
+ }
402
+
403
+ export async function runGSDDoctor(basePath: string, options?: { fix?: boolean; dryRun?: boolean; scope?: string; fixLevel?: "task" | "all"; isolationMode?: "none" | "worktree" | "branch"; includeBuild?: boolean; includeTests?: boolean }): Promise<DoctorReport> {
354
404
  const issues: DoctorIssue[] = [];
355
405
  const fixesApplied: string[] = [];
356
406
  const fix = options?.fix === true;
407
+ const dryRun = options?.dryRun === true;
357
408
  const fixLevel = options?.fixLevel ?? "all";
358
409
 
359
410
  // Issue codes that represent completion state transitions — creating summary
@@ -364,11 +415,18 @@ export async function runGSDDoctor(basePath: string, options?: { fix?: boolean;
364
415
 
365
416
  /** Whether a given issue code should be auto-fixed at the current fixLevel. */
366
417
  const shouldFix = (code: DoctorIssueCode): boolean => {
367
- if (!fix) return false;
418
+ if (!fix || dryRun) return false;
368
419
  if (fixLevel === "task" && COMPLETION_TRANSITION_CODES.has(code)) return false;
369
420
  return true;
370
421
  };
371
422
 
423
+ /** Log a dry-run "would fix" entry when fix=true but dryRun=true. */
424
+ const dryRunCanFix = (code: DoctorIssueCode, message: string): void => {
425
+ if (dryRun && fix && !(fixLevel === "task" && COMPLETION_TRANSITION_CODES.has(code))) {
426
+ fixesApplied.push(`[dry-run] would fix: ${message}`);
427
+ }
428
+ };
429
+
372
430
  const prefs = loadEffectiveGSDPreferences();
373
431
  if (prefs) {
374
432
  const prefIssues = validatePreferenceShape(prefs.preferences);
@@ -385,21 +443,33 @@ export async function runGSDDoctor(basePath: string, options?: { fix?: boolean;
385
443
  }
386
444
  }
387
445
 
388
- // Git health checks (orphaned worktrees, stale branches, corrupt merge state, tracked runtime files)
446
+ // Git health checks timed
447
+ const t0git = Date.now();
389
448
  const isolationMode: "none" | "worktree" | "branch" = options?.isolationMode ??
390
449
  (prefs?.preferences?.git?.isolation === "none" ? "none" :
391
450
  prefs?.preferences?.git?.isolation === "branch" ? "branch" : "worktree");
392
451
  await checkGitHealth(basePath, issues, fixesApplied, shouldFix, isolationMode);
452
+ const gitMs = Date.now() - t0git;
393
453
 
394
- // Runtime health checks (crash locks, completed-units, hook state, activity logs, STATE.md, gitignore)
454
+ // Runtime health checks timed
455
+ const t0runtime = Date.now();
395
456
  await checkRuntimeHealth(basePath, issues, fixesApplied, shouldFix);
457
+ const runtimeMs = Date.now() - t0runtime;
396
458
 
397
- // Environment health checks (#1221: missing tools, port conflicts, stale deps, disk space)
398
- await checkEnvironmentHealth(basePath, issues, { includeRemote: !options?.scope });
459
+ // Environment health checks timed
460
+ const t0env = Date.now();
461
+ await checkEnvironmentHealth(basePath, issues, {
462
+ includeRemote: !options?.scope,
463
+ includeBuild: options?.includeBuild,
464
+ includeTests: options?.includeTests,
465
+ });
466
+ const envMs = Date.now() - t0env;
399
467
 
400
468
  const milestonesPath = milestonesDir(basePath);
401
469
  if (!existsSync(milestonesPath)) {
402
- return { ok: issues.every(issue => issue.severity !== "error"), basePath, issues, fixesApplied };
470
+ const report: DoctorReport = { ok: issues.every(i => i.severity !== "error"), basePath, issues, fixesApplied, timing: { git: gitMs, runtime: runtimeMs, environment: envMs, gsdState: 0 } };
471
+ await appendDoctorHistory(basePath, report);
472
+ return report;
403
473
  }
404
474
 
405
475
  const requirementsPath = resolveGsdRootFile(basePath, "REQUIREMENTS");
@@ -465,6 +535,43 @@ export async function runGSDDoctor(basePath: string, options?: { fix?: boolean;
465
535
  if (!roadmapContent) continue;
466
536
  const roadmap = parseRoadmap(roadmapContent);
467
537
 
538
+ // ── Circular dependency detection ──────────────────────────────────────
539
+ for (const cycle of detectCircularDependencies(roadmap.slices)) {
540
+ issues.push({
541
+ severity: "error",
542
+ code: "circular_slice_dependency",
543
+ scope: "milestone",
544
+ unitId: milestoneId,
545
+ message: `Circular dependency detected: ${cycle.join(" → ")}`,
546
+ file: relMilestoneFile(basePath, milestoneId, "ROADMAP"),
547
+ fixable: false,
548
+ });
549
+ }
550
+
551
+ // ── Orphaned slice directories ─────────────────────────────────────────
552
+ try {
553
+ const slicesDir = join(milestonePath, "slices");
554
+ if (existsSync(slicesDir)) {
555
+ const knownSliceIds = new Set(roadmap.slices.map(s => s.id));
556
+ for (const entry of readdirSync(slicesDir)) {
557
+ try {
558
+ if (!lstatSync(join(slicesDir, entry)).isDirectory()) continue;
559
+ } catch { continue; }
560
+ if (!knownSliceIds.has(entry)) {
561
+ issues.push({
562
+ severity: "warning",
563
+ code: "orphaned_slice_directory",
564
+ scope: "milestone",
565
+ unitId: milestoneId,
566
+ message: `Directory "${entry}" exists in ${milestoneId}/slices/ but is not referenced in the roadmap`,
567
+ file: `${relMilestonePath(basePath, milestoneId)}/slices/${entry}`,
568
+ fixable: false,
569
+ });
570
+ }
571
+ }
572
+ }
573
+ } catch { /* non-fatal */ }
574
+
468
575
  for (const slice of roadmap.slices) {
469
576
  const unitId = `${milestoneId}/${slice.id}`;
470
577
  if (options?.scope && !matchesScope(unitId, options.scope) && options.scope !== milestoneId) continue;
@@ -539,6 +646,33 @@ export async function runGSDDoctor(basePath: string, options?: { fix?: boolean;
539
646
  continue;
540
647
  }
541
648
 
649
+ // ── Duplicate task IDs ───────────────────────────────────────────────
650
+ const taskIdCounts = new Map<string, number>();
651
+ for (const task of plan.tasks) taskIdCounts.set(task.id, (taskIdCounts.get(task.id) ?? 0) + 1);
652
+ for (const [taskId, count] of taskIdCounts) {
653
+ if (count > 1) {
654
+ issues.push({ severity: "error", code: "duplicate_task_id", scope: "slice", unitId,
655
+ message: `Task ID "${taskId}" appears ${count} times in ${slice.id}-PLAN.md — duplicate IDs cause dispatch failures`,
656
+ file: relSliceFile(basePath, milestoneId, slice.id, "PLAN"), fixable: false });
657
+ }
658
+ }
659
+
660
+ // ── Task files on disk not in plan ────────────────────────────────────
661
+ try {
662
+ if (tasksDir) {
663
+ const planTaskIds = new Set(plan.tasks.map(t => t.id));
664
+ for (const f of readdirSync(tasksDir)) {
665
+ if (!f.endsWith("-SUMMARY.md")) continue;
666
+ const diskTaskId = f.replace(/-SUMMARY\.md$/, "");
667
+ if (!planTaskIds.has(diskTaskId)) {
668
+ issues.push({ severity: "info", code: "task_file_not_in_plan", scope: "slice", unitId,
669
+ message: `Task summary "${f}" exists on disk but "${diskTaskId}" is not in ${slice.id}-PLAN.md`,
670
+ file: relTaskFile(basePath, milestoneId, slice.id, diskTaskId, "SUMMARY"), fixable: false });
671
+ }
672
+ }
673
+ }
674
+ } catch { /* non-fatal */ }
675
+
542
676
  let allTasksDone = plan.tasks.length > 0;
543
677
  for (const task of plan.tasks) {
544
678
  const taskUnitId = `${unitId}/${task.id}`;
@@ -555,6 +689,7 @@ export async function runGSDDoctor(basePath: string, options?: { fix?: boolean;
555
689
  file: relTaskFile(basePath, milestoneId, slice.id, task.id, "SUMMARY"),
556
690
  fixable: true,
557
691
  });
692
+ dryRunCanFix("task_done_missing_summary", `create stub summary for ${taskUnitId}`);
558
693
  if (shouldFix("task_done_missing_summary")) {
559
694
  const stubPath = join(
560
695
  basePath, ".gsd", "milestones", milestoneId, "slices", slice.id, "tasks",
@@ -618,6 +753,22 @@ export async function runGSDDoctor(basePath: string, options?: { fix?: boolean;
618
753
  }
619
754
  }
620
755
 
756
+ // ── Future timestamp check ─────────────────────────────────────
757
+ if (task.done && hasSummary && summaryPath) {
758
+ try {
759
+ const rawSummary = await loadFile(summaryPath);
760
+ const m = rawSummary?.match(/^completed_at:\s*(.+)$/m);
761
+ if (m) {
762
+ const ts = new Date(m[1].trim());
763
+ if (!isNaN(ts.getTime()) && ts.getTime() > Date.now() + 24 * 60 * 60 * 1000) {
764
+ issues.push({ severity: "warning", code: "future_timestamp", scope: "task", unitId: taskUnitId,
765
+ message: `Task ${task.id} has completed_at "${m[1].trim()}" which is more than 24h in the future`,
766
+ file: relTaskFile(basePath, milestoneId, slice.id, task.id, "SUMMARY"), fixable: false });
767
+ }
768
+ }
769
+ } catch { /* non-fatal */ }
770
+ }
771
+
621
772
  allTasksDone = allTasksDone && task.done;
622
773
  }
623
774
 
@@ -646,6 +797,13 @@ export async function runGSDDoctor(basePath: string, options?: { fix?: boolean;
646
797
  }
647
798
  }
648
799
 
800
+ // ── Stale REPLAN: exists but all tasks done ────────────────────────
801
+ if (replanPath && allTasksDone) {
802
+ issues.push({ severity: "info", code: "stale_replan_file", scope: "slice", unitId,
803
+ message: `${slice.id} has a REPLAN.md but all tasks are done — REPLAN.md may be stale`,
804
+ file: relSliceFile(basePath, milestoneId, slice.id, "REPLAN"), fixable: false });
805
+ }
806
+
649
807
  const sliceSummaryPath = resolveSliceFile(basePath, milestoneId, slice.id, "SUMMARY");
650
808
  const sliceUatPath = join(slicePath, `${slice.id}-UAT.md`);
651
809
  const hasSliceSummary = !!(sliceSummaryPath && await loadFile(sliceSummaryPath));
@@ -661,6 +819,7 @@ export async function runGSDDoctor(basePath: string, options?: { fix?: boolean;
661
819
  file: relSliceFile(basePath, milestoneId, slice.id, "SUMMARY"),
662
820
  fixable: true,
663
821
  });
822
+ dryRunCanFix("all_tasks_done_missing_slice_summary", `create placeholder summary for ${unitId}`);
664
823
  if (shouldFix("all_tasks_done_missing_slice_summary")) await ensureSliceSummaryStub(basePath, milestoneId, slice.id, fixesApplied);
665
824
  }
666
825
 
@@ -674,6 +833,7 @@ export async function runGSDDoctor(basePath: string, options?: { fix?: boolean;
674
833
  file: `${relSlicePath(basePath, milestoneId, slice.id)}/${slice.id}-UAT.md`,
675
834
  fixable: true,
676
835
  });
836
+ dryRunCanFix("all_tasks_done_missing_slice_uat", `create placeholder UAT for ${unitId}`);
677
837
  if (shouldFix("all_tasks_done_missing_slice_uat")) await ensureSliceUatStub(basePath, milestoneId, slice.id, fixesApplied);
678
838
  }
679
839
 
@@ -687,6 +847,7 @@ export async function runGSDDoctor(basePath: string, options?: { fix?: boolean;
687
847
  file: relMilestoneFile(basePath, milestoneId, "ROADMAP"),
688
848
  fixable: true,
689
849
  });
850
+ dryRunCanFix("all_tasks_done_roadmap_not_checked", `mark ${slice.id} done in roadmap`);
690
851
  if (shouldFix("all_tasks_done_roadmap_not_checked") && (hasSliceSummary || issues.some(issue => issue.code === "all_tasks_done_missing_slice_summary" && issue.unitId === unitId))) {
691
852
  await markSliceDoneInRoadmap(basePath, milestoneId, slice.id, fixesApplied);
692
853
  }
@@ -744,14 +905,17 @@ export async function runGSDDoctor(basePath: string, options?: { fix?: boolean;
744
905
  }
745
906
  }
746
907
 
747
- if (fix && fixesApplied.length > 0) {
908
+ if (fix && !dryRun && fixesApplied.length > 0) {
748
909
  await updateStateFile(basePath, fixesApplied);
749
910
  }
750
911
 
751
- return {
912
+ const report: DoctorReport = {
752
913
  ok: issues.every(issue => issue.severity !== "error"),
753
914
  basePath,
754
915
  issues,
755
916
  fixesApplied,
917
+ timing: { git: gitMs, runtime: runtimeMs, environment: envMs, gsdState: Math.max(0, Date.now() - t0env - envMs) },
756
918
  };
919
+ await appendDoctorHistory(basePath, report);
920
+ return report;
757
921
  }
@@ -11,7 +11,7 @@ import {
11
11
  } from "./metrics.js";
12
12
  import type { UnitMetrics } from "./metrics.js";
13
13
  import { gsdRoot } from "./paths.js";
14
- import { formatDuration, fileLink } from "../shared/mod.js";
14
+ import { formatDuration, fileLink } from "../shared/format-utils.js";
15
15
  import { getErrorMessage } from "./error-utils.js";
16
16
 
17
17
  /**