gsd-pi 2.31.2 → 2.32.0-dev.3d7932c

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 (138) hide show
  1. package/README.md +27 -20
  2. package/dist/cli.js +5 -5
  3. package/dist/resource-loader.js +13 -3
  4. package/dist/resources/extensions/gsd/auto-constants.ts +6 -0
  5. package/dist/resources/extensions/gsd/auto-dashboard.ts +23 -27
  6. package/dist/resources/extensions/gsd/auto-direct-dispatch.ts +1 -6
  7. package/dist/resources/extensions/gsd/auto-dispatch.ts +4 -8
  8. package/dist/resources/extensions/gsd/auto-idempotency.ts +3 -2
  9. package/dist/resources/extensions/gsd/auto-observability.ts +2 -4
  10. package/dist/resources/extensions/gsd/auto-post-unit.ts +32 -37
  11. package/dist/resources/extensions/gsd/auto-prompts.ts +84 -78
  12. package/dist/resources/extensions/gsd/auto-recovery.ts +8 -22
  13. package/dist/resources/extensions/gsd/auto-start.ts +16 -12
  14. package/dist/resources/extensions/gsd/auto-stuck-detection.ts +3 -2
  15. package/dist/resources/extensions/gsd/auto-timeout-recovery.ts +2 -1
  16. package/dist/resources/extensions/gsd/auto-timers.ts +3 -2
  17. package/dist/resources/extensions/gsd/auto-verification.ts +6 -6
  18. package/dist/resources/extensions/gsd/auto-worktree.ts +5 -4
  19. package/dist/resources/extensions/gsd/auto.ts +82 -60
  20. package/dist/resources/extensions/gsd/commands-inspect.ts +2 -1
  21. package/dist/resources/extensions/gsd/commands-workflow-templates.ts +5 -6
  22. package/dist/resources/extensions/gsd/commands.ts +19 -0
  23. package/dist/resources/extensions/gsd/complexity-classifier.ts +5 -7
  24. package/dist/resources/extensions/gsd/crash-recovery.ts +15 -2
  25. package/dist/resources/extensions/gsd/dashboard-overlay.ts +28 -0
  26. package/dist/resources/extensions/gsd/dispatch-guard.ts +2 -1
  27. package/dist/resources/extensions/gsd/doctor-environment.ts +497 -0
  28. package/dist/resources/extensions/gsd/doctor-providers.ts +343 -0
  29. package/dist/resources/extensions/gsd/doctor-types.ts +14 -1
  30. package/dist/resources/extensions/gsd/doctor.ts +6 -0
  31. package/dist/resources/extensions/gsd/error-utils.ts +6 -0
  32. package/dist/resources/extensions/gsd/export.ts +2 -1
  33. package/dist/resources/extensions/gsd/git-service.ts +12 -2
  34. package/dist/resources/extensions/gsd/guided-flow-queue.ts +1 -8
  35. package/dist/resources/extensions/gsd/guided-flow.ts +3 -2
  36. package/dist/resources/extensions/gsd/health-widget.ts +167 -0
  37. package/dist/resources/extensions/gsd/index.ts +18 -5
  38. package/dist/resources/extensions/gsd/key-manager.ts +2 -1
  39. package/dist/resources/extensions/gsd/marketplace-discovery.ts +4 -3
  40. package/dist/resources/extensions/gsd/metrics.ts +3 -3
  41. package/dist/resources/extensions/gsd/migrate-external.ts +21 -4
  42. package/dist/resources/extensions/gsd/milestone-ids.ts +2 -1
  43. package/dist/resources/extensions/gsd/native-git-bridge.ts +2 -1
  44. package/dist/resources/extensions/gsd/parallel-merge.ts +2 -1
  45. package/dist/resources/extensions/gsd/parallel-orchestrator.ts +2 -1
  46. package/dist/resources/extensions/gsd/post-unit-hooks.ts +8 -9
  47. package/dist/resources/extensions/gsd/preferences-types.ts +8 -0
  48. package/dist/resources/extensions/gsd/preferences-validation.ts +3 -10
  49. package/dist/resources/extensions/gsd/progress-score.ts +273 -0
  50. package/dist/resources/extensions/gsd/prompts/run-uat.md +1 -42
  51. package/dist/resources/extensions/gsd/quick.ts +61 -8
  52. package/dist/resources/extensions/gsd/repo-identity.ts +22 -1
  53. package/dist/resources/extensions/gsd/session-lock.ts +12 -1
  54. package/dist/resources/extensions/gsd/tests/auto-reentrancy-guard.test.ts +127 -0
  55. package/dist/resources/extensions/gsd/tests/context-compression.test.ts +1 -1
  56. package/dist/resources/extensions/gsd/tests/doctor-environment.test.ts +314 -0
  57. package/dist/resources/extensions/gsd/tests/doctor-providers.test.ts +298 -0
  58. package/dist/resources/extensions/gsd/tests/export-html-enhancements.test.ts +3 -0
  59. package/dist/resources/extensions/gsd/tests/memory-leak-guards.test.ts +7 -3
  60. package/dist/resources/extensions/gsd/tests/progress-score.test.ts +206 -0
  61. package/dist/resources/extensions/gsd/tests/run-uat.test.ts +56 -7
  62. package/dist/resources/extensions/gsd/tests/visualizer-views.test.ts +12 -0
  63. package/dist/resources/extensions/gsd/undo.ts +5 -7
  64. package/dist/resources/extensions/gsd/unit-id.ts +14 -0
  65. package/dist/resources/extensions/gsd/unit-runtime.ts +2 -1
  66. package/dist/resources/extensions/gsd/visualizer-data.ts +60 -2
  67. package/dist/resources/extensions/gsd/visualizer-views.ts +54 -0
  68. package/dist/resources/extensions/gsd/worktree-command.ts +8 -7
  69. package/dist/worktree-cli.d.ts +42 -6
  70. package/dist/worktree-cli.js +88 -48
  71. package/package.json +1 -1
  72. package/packages/pi-coding-agent/package.json +1 -1
  73. package/pkg/package.json +1 -1
  74. package/src/resources/extensions/gsd/auto-constants.ts +6 -0
  75. package/src/resources/extensions/gsd/auto-dashboard.ts +23 -27
  76. package/src/resources/extensions/gsd/auto-direct-dispatch.ts +1 -6
  77. package/src/resources/extensions/gsd/auto-dispatch.ts +4 -8
  78. package/src/resources/extensions/gsd/auto-idempotency.ts +3 -2
  79. package/src/resources/extensions/gsd/auto-observability.ts +2 -4
  80. package/src/resources/extensions/gsd/auto-post-unit.ts +32 -37
  81. package/src/resources/extensions/gsd/auto-prompts.ts +84 -78
  82. package/src/resources/extensions/gsd/auto-recovery.ts +8 -22
  83. package/src/resources/extensions/gsd/auto-start.ts +16 -12
  84. package/src/resources/extensions/gsd/auto-stuck-detection.ts +3 -2
  85. package/src/resources/extensions/gsd/auto-timeout-recovery.ts +2 -1
  86. package/src/resources/extensions/gsd/auto-timers.ts +3 -2
  87. package/src/resources/extensions/gsd/auto-verification.ts +6 -6
  88. package/src/resources/extensions/gsd/auto-worktree.ts +5 -4
  89. package/src/resources/extensions/gsd/auto.ts +82 -60
  90. package/src/resources/extensions/gsd/commands-inspect.ts +2 -1
  91. package/src/resources/extensions/gsd/commands-workflow-templates.ts +5 -6
  92. package/src/resources/extensions/gsd/commands.ts +19 -0
  93. package/src/resources/extensions/gsd/complexity-classifier.ts +5 -7
  94. package/src/resources/extensions/gsd/crash-recovery.ts +15 -2
  95. package/src/resources/extensions/gsd/dashboard-overlay.ts +28 -0
  96. package/src/resources/extensions/gsd/dispatch-guard.ts +2 -1
  97. package/src/resources/extensions/gsd/doctor-environment.ts +497 -0
  98. package/src/resources/extensions/gsd/doctor-providers.ts +343 -0
  99. package/src/resources/extensions/gsd/doctor-types.ts +14 -1
  100. package/src/resources/extensions/gsd/doctor.ts +6 -0
  101. package/src/resources/extensions/gsd/error-utils.ts +6 -0
  102. package/src/resources/extensions/gsd/export.ts +2 -1
  103. package/src/resources/extensions/gsd/git-service.ts +12 -2
  104. package/src/resources/extensions/gsd/guided-flow-queue.ts +1 -8
  105. package/src/resources/extensions/gsd/guided-flow.ts +3 -2
  106. package/src/resources/extensions/gsd/health-widget.ts +167 -0
  107. package/src/resources/extensions/gsd/index.ts +18 -5
  108. package/src/resources/extensions/gsd/key-manager.ts +2 -1
  109. package/src/resources/extensions/gsd/marketplace-discovery.ts +4 -3
  110. package/src/resources/extensions/gsd/metrics.ts +3 -3
  111. package/src/resources/extensions/gsd/migrate-external.ts +21 -4
  112. package/src/resources/extensions/gsd/milestone-ids.ts +2 -1
  113. package/src/resources/extensions/gsd/native-git-bridge.ts +2 -1
  114. package/src/resources/extensions/gsd/parallel-merge.ts +2 -1
  115. package/src/resources/extensions/gsd/parallel-orchestrator.ts +2 -1
  116. package/src/resources/extensions/gsd/post-unit-hooks.ts +8 -9
  117. package/src/resources/extensions/gsd/preferences-types.ts +8 -0
  118. package/src/resources/extensions/gsd/preferences-validation.ts +3 -10
  119. package/src/resources/extensions/gsd/progress-score.ts +273 -0
  120. package/src/resources/extensions/gsd/prompts/run-uat.md +1 -42
  121. package/src/resources/extensions/gsd/quick.ts +61 -8
  122. package/src/resources/extensions/gsd/repo-identity.ts +22 -1
  123. package/src/resources/extensions/gsd/session-lock.ts +12 -1
  124. package/src/resources/extensions/gsd/tests/auto-reentrancy-guard.test.ts +127 -0
  125. package/src/resources/extensions/gsd/tests/context-compression.test.ts +1 -1
  126. package/src/resources/extensions/gsd/tests/doctor-environment.test.ts +314 -0
  127. package/src/resources/extensions/gsd/tests/doctor-providers.test.ts +298 -0
  128. package/src/resources/extensions/gsd/tests/export-html-enhancements.test.ts +3 -0
  129. package/src/resources/extensions/gsd/tests/memory-leak-guards.test.ts +7 -3
  130. package/src/resources/extensions/gsd/tests/progress-score.test.ts +206 -0
  131. package/src/resources/extensions/gsd/tests/run-uat.test.ts +56 -7
  132. package/src/resources/extensions/gsd/tests/visualizer-views.test.ts +12 -0
  133. package/src/resources/extensions/gsd/undo.ts +5 -7
  134. package/src/resources/extensions/gsd/unit-id.ts +14 -0
  135. package/src/resources/extensions/gsd/unit-runtime.ts +2 -1
  136. package/src/resources/extensions/gsd/visualizer-data.ts +60 -2
  137. package/src/resources/extensions/gsd/visualizer-views.ts +54 -0
  138. package/src/resources/extensions/gsd/worktree-command.ts +8 -7
@@ -0,0 +1,343 @@
1
+ /**
2
+ * GSD Doctor — Provider & Integration Health Checks
3
+ *
4
+ * Fast, deterministic checks for external service configuration.
5
+ * Checks key presence in auth.json and environment variables — no HTTP calls,
6
+ * no network I/O, always sub-10ms.
7
+ *
8
+ * Covers:
9
+ * - LLM providers required by the effective model preferences (per phase)
10
+ * - Remote questions channel if configured (Slack/Discord/Telegram token)
11
+ * - Optional search/tool integrations (Brave, Tavily, Jina, Context7)
12
+ */
13
+
14
+ import { existsSync } from "node:fs";
15
+ import { join } from "node:path";
16
+ import { AuthStorage } from "@gsd/pi-coding-agent";
17
+ import { loadEffectiveGSDPreferences } from "./preferences.js";
18
+ import { getAuthPath, PROVIDER_REGISTRY, type ProviderCategory } from "./key-manager.js";
19
+
20
+ // ── Types ──────────────────────────────────────────────────────────────────────
21
+
22
+ export type ProviderCheckStatus = "ok" | "warning" | "error" | "unconfigured";
23
+
24
+ export interface ProviderCheckResult {
25
+ /** Provider id from PROVIDER_REGISTRY (e.g. "anthropic", "slack_bot") */
26
+ name: string;
27
+ /** Human-readable label */
28
+ label: string;
29
+ /** Functional grouping */
30
+ category: ProviderCategory;
31
+ status: ProviderCheckStatus;
32
+ message: string;
33
+ /** Optional extra detail (e.g. which env var to set) */
34
+ detail?: string;
35
+ /** True if this provider is actively required by preferences */
36
+ required: boolean;
37
+ }
38
+
39
+ // ── Model → Provider ID mapping ───────────────────────────────────────────────
40
+
41
+ /**
42
+ * Infer the auth provider ID from a model string.
43
+ * Handles plain model IDs ("claude-sonnet-4-6") and prefixed ones ("openrouter/deepseek").
44
+ */
45
+ function modelToProviderId(model: string): string | null {
46
+ if (!model) return null;
47
+
48
+ // Explicit provider prefix (e.g. "openrouter/deepseek-r1")
49
+ if (model.includes("/")) {
50
+ const prefix = model.split("/")[0].toLowerCase();
51
+ // Map known prefixes to registry IDs
52
+ const prefixMap: Record<string, string> = {
53
+ openrouter: "openrouter",
54
+ groq: "groq",
55
+ mistral: "mistral",
56
+ google: "google",
57
+ anthropic: "anthropic",
58
+ openai: "openai",
59
+ };
60
+ if (prefixMap[prefix]) return prefixMap[prefix];
61
+ }
62
+
63
+ const lower = model.toLowerCase();
64
+ if (lower.startsWith("claude")) return "anthropic";
65
+ if (lower.startsWith("gpt-") || lower.startsWith("o1") || lower.startsWith("o3")) return "openai";
66
+ if (lower.startsWith("gemini")) return "google";
67
+ if (lower.startsWith("llama") || lower.startsWith("mixtral")) return "groq";
68
+ if (lower.startsWith("grok")) return "xai";
69
+ if (lower.startsWith("mistral") || lower.startsWith("codestral")) return "mistral";
70
+
71
+ return null;
72
+ }
73
+
74
+ /** Collect all model strings from effective preferences across all phases. */
75
+ function collectConfiguredModelProviders(): Set<string> {
76
+ const providers = new Set<string>();
77
+
78
+ try {
79
+ const loaded = loadEffectiveGSDPreferences();
80
+ const models = loaded?.preferences?.models;
81
+ if (!models) {
82
+ // Default: Anthropic
83
+ providers.add("anthropic");
84
+ return providers;
85
+ }
86
+
87
+ const modelEntries = typeof models === "object" ? Object.values(models) : [];
88
+ 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) {
94
+ const pid = modelToProviderId(modelId);
95
+ if (pid) providers.add(pid);
96
+ }
97
+ }
98
+ } catch {
99
+ // Preferences not readable — assume Anthropic as default
100
+ providers.add("anthropic");
101
+ }
102
+
103
+ if (providers.size === 0) providers.add("anthropic");
104
+ return providers;
105
+ }
106
+
107
+ // ── Key resolution ─────────────────────────────────────────────────────────────
108
+
109
+ interface KeyLookup {
110
+ found: boolean;
111
+ source: "auth.json" | "env" | "none";
112
+ backedOff: boolean;
113
+ }
114
+
115
+ function resolveKey(providerId: string): KeyLookup {
116
+ const info = PROVIDER_REGISTRY.find(p => p.id === providerId);
117
+
118
+ // Check auth.json
119
+ const authPath = getAuthPath();
120
+ if (existsSync(authPath)) {
121
+ try {
122
+ const auth = AuthStorage.create(authPath);
123
+ const creds = auth.getCredentialsForProvider(providerId);
124
+ if (creds.length > 0) {
125
+ // Filter out empty placeholder keys (from skipped onboarding)
126
+ const hasRealKey = creds.some(c =>
127
+ c.type === "oauth" || (c.type === "api_key" && (c as { key?: string }).key)
128
+ );
129
+ if (hasRealKey) {
130
+ return {
131
+ found: true,
132
+ source: "auth.json",
133
+ backedOff: auth.areAllCredentialsBackedOff(providerId),
134
+ };
135
+ }
136
+ }
137
+ } catch {
138
+ // auth.json malformed — fall through to env check
139
+ }
140
+ }
141
+
142
+ // Check environment variable
143
+ if (info?.envVar && process.env[info.envVar]) {
144
+ return { found: true, source: "env", backedOff: false };
145
+ }
146
+
147
+ return { found: false, source: "none", backedOff: false };
148
+ }
149
+
150
+ // ── Individual check groups ────────────────────────────────────────────────────
151
+
152
+ function checkLlmProviders(): ProviderCheckResult[] {
153
+ const required = collectConfiguredModelProviders();
154
+ const results: ProviderCheckResult[] = [];
155
+
156
+ for (const providerId of required) {
157
+ const info = PROVIDER_REGISTRY.find(p => p.id === providerId);
158
+ const label = info?.label ?? providerId;
159
+ const lookup = resolveKey(providerId);
160
+
161
+ if (!lookup.found) {
162
+ const envVar = info?.envVar ?? `${providerId.toUpperCase()}_API_KEY`;
163
+ results.push({
164
+ name: providerId,
165
+ label,
166
+ category: "llm",
167
+ status: "error",
168
+ message: `${label} — no API key found`,
169
+ detail: info?.hasOAuth
170
+ ? `Run /gsd keys to authenticate`
171
+ : `Set ${envVar} or run /gsd keys`,
172
+ required: true,
173
+ });
174
+ } else if (lookup.backedOff) {
175
+ results.push({
176
+ name: providerId,
177
+ label,
178
+ category: "llm",
179
+ status: "warning",
180
+ message: `${label} — all credentials backed off (rate limited)`,
181
+ detail: `GSD will retry automatically`,
182
+ required: true,
183
+ });
184
+ } else {
185
+ results.push({
186
+ name: providerId,
187
+ label,
188
+ category: "llm",
189
+ status: "ok",
190
+ message: `${label} — key present (${lookup.source})`,
191
+ required: true,
192
+ });
193
+ }
194
+ }
195
+
196
+ return results;
197
+ }
198
+
199
+ function checkRemoteQuestionsProvider(): ProviderCheckResult | null {
200
+ try {
201
+ const loaded = loadEffectiveGSDPreferences();
202
+ const rq = loaded?.preferences?.remote_questions;
203
+ if (!rq) return null;
204
+
205
+ const channel = rq.channel as string | undefined;
206
+ if (!channel) return null;
207
+
208
+ const providerMap: Record<string, string> = {
209
+ slack: "slack_bot",
210
+ discord: "discord_bot",
211
+ telegram: "telegram_bot",
212
+ };
213
+
214
+ const providerId = providerMap[channel.toLowerCase()];
215
+ if (!providerId) return null;
216
+
217
+ const info = PROVIDER_REGISTRY.find(p => p.id === providerId);
218
+ const label = info?.label ?? channel;
219
+ const lookup = resolveKey(providerId);
220
+
221
+ if (!lookup.found) {
222
+ return {
223
+ name: providerId,
224
+ label,
225
+ category: "remote",
226
+ status: "warning",
227
+ message: `${label} — channel configured but token not found`,
228
+ detail: info?.envVar ? `Set ${info.envVar} or run /gsd keys` : `Run /gsd keys to configure`,
229
+ required: true,
230
+ };
231
+ }
232
+
233
+ return {
234
+ name: providerId,
235
+ label,
236
+ category: "remote",
237
+ status: "ok",
238
+ message: `${label} — token present (${lookup.source})`,
239
+ required: true,
240
+ };
241
+ } catch {
242
+ return null;
243
+ }
244
+ }
245
+
246
+ function checkOptionalProviders(): ProviderCheckResult[] {
247
+ const optional = ["brave", "tavily", "jina", "context7"] as const;
248
+ const results: ProviderCheckResult[] = [];
249
+
250
+ for (const providerId of optional) {
251
+ const info = PROVIDER_REGISTRY.find(p => p.id === providerId);
252
+ if (!info) continue;
253
+
254
+ const lookup = resolveKey(providerId);
255
+ results.push({
256
+ name: providerId,
257
+ label: info.label,
258
+ category: info.category as ProviderCategory,
259
+ status: lookup.found ? "ok" : "unconfigured",
260
+ message: lookup.found
261
+ ? `${info.label} — key present (${lookup.source})`
262
+ : `${info.label} — not configured (optional)`,
263
+ detail: !lookup.found && info.envVar ? `Set ${info.envVar} to enable` : undefined,
264
+ required: false,
265
+ });
266
+ }
267
+
268
+ return results;
269
+ }
270
+
271
+ // ── Public API ─────────────────────────────────────────────────────────────────
272
+
273
+ /**
274
+ * Run all provider checks: required LLM keys, remote questions channel, optional tools.
275
+ * Fast (sub-10ms) — reads auth.json and env vars only, no network I/O.
276
+ */
277
+ export function runProviderChecks(): ProviderCheckResult[] {
278
+ const results: ProviderCheckResult[] = [];
279
+
280
+ results.push(...checkLlmProviders());
281
+
282
+ const remoteCheck = checkRemoteQuestionsProvider();
283
+ if (remoteCheck) results.push(remoteCheck);
284
+
285
+ results.push(...checkOptionalProviders());
286
+
287
+ return results;
288
+ }
289
+
290
+ /**
291
+ * Format provider check results as a human-readable report string.
292
+ */
293
+ export function formatProviderReport(results: ProviderCheckResult[]): string {
294
+ if (results.length === 0) return "No provider checks run.";
295
+
296
+ const lines: string[] = [];
297
+
298
+ const groups: Record<string, ProviderCheckResult[]> = {};
299
+ for (const r of results) {
300
+ (groups[r.category] ??= []).push(r);
301
+ }
302
+
303
+ const categoryLabels: Record<string, string> = {
304
+ llm: "LLM Providers",
305
+ remote: "Notifications",
306
+ search: "Search",
307
+ tool: "Tools",
308
+ };
309
+
310
+ for (const [cat, items] of Object.entries(groups)) {
311
+ lines.push(`${categoryLabels[cat] ?? cat}:`);
312
+ for (const item of items) {
313
+ const icon = item.status === "ok" ? "✓"
314
+ : item.status === "warning" ? "⚠"
315
+ : item.status === "error" ? "✗"
316
+ : "·";
317
+ lines.push(` ${icon} ${item.message}`);
318
+ if (item.detail && item.status !== "ok") {
319
+ lines.push(` ${item.detail}`);
320
+ }
321
+ }
322
+ }
323
+
324
+ return lines.join("\n");
325
+ }
326
+
327
+ /**
328
+ * Summarise check results to a compact widget-friendly string.
329
+ * Returns null if all required providers are ok.
330
+ */
331
+ export function summariseProviderIssues(results: ProviderCheckResult[]): string | null {
332
+ const errors = results.filter(r => r.required && r.status === "error");
333
+ const warnings = results.filter(r => r.required && r.status === "warning");
334
+
335
+ if (errors.length === 0 && warnings.length === 0) return null;
336
+
337
+ const parts: string[] = [];
338
+ if (errors.length > 0) parts.push(`✗ ${errors[0].label} key missing`);
339
+ if (warnings.length > 0 && errors.length === 0) parts.push(`⚠ ${warnings[0].label} backed off`);
340
+ if (errors.length + warnings.length > 1) parts.push(`(+${errors.length + warnings.length - 1} more)`);
341
+
342
+ return parts.join(" ");
343
+ }
@@ -32,7 +32,20 @@ export type DoctorIssueCode =
32
32
  | "gitignore_missing_patterns"
33
33
  | "unresolvable_dependency"
34
34
  | "failed_migration"
35
- | "broken_symlink";
35
+ | "broken_symlink"
36
+ // Environment health checks (#1221)
37
+ | "env_node_version"
38
+ | "env_dependencies"
39
+ | "env_env_file"
40
+ | "env_port_conflict"
41
+ | "env_disk_space"
42
+ | "env_docker"
43
+ | "env_package_manager"
44
+ | "env_typescript"
45
+ | "env_python"
46
+ | "env_cargo"
47
+ | "env_go"
48
+ | "env_git_remote";
36
49
 
37
50
  /**
38
51
  * Issue codes that represent expected completion-transition states.
@@ -10,12 +10,15 @@ import { loadEffectiveGSDPreferences, type GSDPreferences } from "./preferences.
10
10
  import type { DoctorIssue, DoctorIssueCode } from "./doctor-types.js";
11
11
  import { COMPLETION_TRANSITION_CODES } from "./doctor-types.js";
12
12
  import { checkGitHealth, checkRuntimeHealth } from "./doctor-checks.js";
13
+ import { checkEnvironmentHealth } from "./doctor-environment.js";
13
14
 
14
15
  // ── Re-exports ─────────────────────────────────────────────────────────────
15
16
  // All public types and functions from extracted modules are re-exported here
16
17
  // so that existing imports from "./doctor.js" continue to work unchanged.
17
18
  export type { DoctorSeverity, DoctorIssueCode, DoctorIssue, DoctorReport, DoctorSummary } from "./doctor-types.js";
18
19
  export { summarizeDoctorIssues, filterDoctorIssues, formatDoctorReport, formatDoctorIssuesForPrompt } from "./doctor-format.js";
20
+ export { runEnvironmentChecks, runFullEnvironmentChecks, formatEnvironmentReport, type EnvironmentCheckResult } from "./doctor-environment.js";
21
+ export { computeProgressScore, computeProgressScoreWithContext, formatProgressLine, formatProgressReport, type ProgressScore, type ProgressLevel } from "./progress-score.js";
19
22
 
20
23
  /**
21
24
  * Characters that are used as delimiters in GSD state management documents
@@ -390,6 +393,9 @@ export async function runGSDDoctor(basePath: string, options?: { fix?: boolean;
390
393
  // Runtime health checks (crash locks, completed-units, hook state, activity logs, STATE.md, gitignore)
391
394
  await checkRuntimeHealth(basePath, issues, fixesApplied, shouldFix);
392
395
 
396
+ // Environment health checks (#1221: missing tools, port conflicts, stale deps, disk space)
397
+ await checkEnvironmentHealth(basePath, issues, { includeRemote: !options?.scope });
398
+
393
399
  const milestonesPath = milestonesDir(basePath);
394
400
  if (!existsSync(milestonesPath)) {
395
401
  return { ok: issues.every(issue => issue.severity !== "error"), basePath, issues, fixesApplied };
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Extract a human-readable message from an unknown caught value.
3
+ */
4
+ export function getErrorMessage(err: unknown): string {
5
+ return err instanceof Error ? err.message : String(err);
6
+ }
@@ -12,6 +12,7 @@ import {
12
12
  import type { UnitMetrics } from "./metrics.js";
13
13
  import { gsdRoot } from "./paths.js";
14
14
  import { formatDuration, fileLink } from "../shared/mod.js";
15
+ import { getErrorMessage } from "./error-utils.js";
15
16
 
16
17
  /**
17
18
  * Open a file in the user's default browser.
@@ -226,7 +227,7 @@ export async function handleExport(args: string, ctx: ExtensionCommandContext, b
226
227
  }
227
228
  } catch (err) {
228
229
  ctx.ui.notify(
229
- `HTML export failed: ${err instanceof Error ? err.message : String(err)}`,
230
+ `HTML export failed: ${getErrorMessage(err)}`,
230
231
  "error",
231
232
  );
232
233
  }
@@ -13,6 +13,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
13
13
  import { join } from "node:path";
14
14
  import { gsdRoot } from "./paths.js";
15
15
  import { GIT_NO_PROMPT_ENV } from "./git-constants.js";
16
+ import { loadEffectiveGSDPreferences } from "./preferences.js";
16
17
 
17
18
  import {
18
19
  detectWorktreeName,
@@ -32,6 +33,7 @@ import {
32
33
  nativeAddPaths,
33
34
  } from "./native-git-bridge.js";
34
35
  import { GSDError, GSD_MERGE_CONFLICT, GSD_GIT_ERROR } from "./errors.js";
36
+ import { getErrorMessage } from "./error-utils.js";
35
37
 
36
38
  // ─── Types ─────────────────────────────────────────────────────────────────
37
39
 
@@ -280,7 +282,7 @@ export function runGit(basePath: string, args: string[], options: { allowFailure
280
282
  }).trim();
281
283
  } catch (error) {
282
284
  if (options.allowFailure) return "";
283
- const message = error instanceof Error ? error.message : String(error);
285
+ const message = getErrorMessage(error);
284
286
  throw new GSDError(GSD_GIT_ERROR, `git ${args.join(" ")} failed in ${basePath}: ${filterGitSvnNoise(message)}`);
285
287
  }
286
288
  }
@@ -532,7 +534,7 @@ export class GitServiceImpl {
532
534
  execSync(command, { cwd: this.basePath, stdio: "pipe", encoding: "utf-8" });
533
535
  return { passed: true, skipped: false, command };
534
536
  } catch (err) {
535
- const msg = err instanceof Error ? err.message : String(err);
537
+ const msg = getErrorMessage(err);
536
538
  return { passed: false, skipped: false, command, error: msg };
537
539
  }
538
540
  }
@@ -541,6 +543,14 @@ export class GitServiceImpl {
541
543
 
542
544
  }
543
545
 
546
+ // ─── Factory ───────────────────────────────────────────────────────────────
547
+
548
+ /** Create a GitServiceImpl with the current effective git preferences. */
549
+ export function createGitService(basePath: string): GitServiceImpl {
550
+ const gitPrefs = loadEffectiveGSDPreferences()?.preferences?.git ?? {};
551
+ return new GitServiceImpl(basePath, gitPrefs);
552
+ }
553
+
544
554
  // ─── Commit Type Inference ─────────────────────────────────────────────────
545
555
 
546
556
  /**
@@ -23,13 +23,6 @@ import { loadEffectiveGSDPreferences } from "./preferences.js";
23
23
  import { loadQueueOrder, sortByQueueOrder, saveQueueOrder } from "./queue-order.js";
24
24
  import { findMilestoneIds, nextMilestoneId } from "./milestone-ids.js";
25
25
 
26
- // ─── Commit Instruction Helper (local copy — avoids circular dep) ───────────
27
-
28
- /** Build commit instruction for queue prompts. .gsd/ is managed externally and always gitignored. */
29
- function buildDocsCommitInstruction(_message: string): string {
30
- return "Do not commit planning artifacts — .gsd/ is managed externally.";
31
- }
32
-
33
26
  // ─── Queue Entry Point ──────────────────────────────────────────────────────
34
27
 
35
28
  /**
@@ -207,7 +200,7 @@ export async function showQueueAdd(
207
200
  preamble,
208
201
  existingMilestonesContext: existingContext,
209
202
  inlinedTemplates: queueInlinedTemplates,
210
- commitInstruction: buildDocsCommitInstruction("docs: queue <milestone list>"),
203
+ commitInstruction: "Do not commit planning artifacts — .gsd/ is managed externally.",
211
204
  });
212
205
 
213
206
  pi.sendMessage(
@@ -44,6 +44,7 @@ export {
44
44
  showQueue, handleQueueReorder, showQueueAdd,
45
45
  buildExistingMilestonesContext,
46
46
  } from "./guided-flow-queue.js";
47
+ import { getErrorMessage } from "./error-utils.js";
47
48
 
48
49
  // ─── Commit Instruction Helpers ──────────────────────────────────────────────
49
50
 
@@ -158,9 +159,9 @@ export function checkAutoStartAfterDiscuss(): boolean {
158
159
 
159
160
  pendingAutoStart = null;
160
161
  startAuto(ctx, pi, basePath, false, { step }).catch((err) => {
161
- ctx.ui.notify(`Auto-start failed: ${err instanceof Error ? err.message : String(err)}`, "error");
162
+ ctx.ui.notify(`Auto-start failed: ${getErrorMessage(err)}`, "error");
162
163
  if (process.env.GSD_DEBUG) console.error('[gsd] auto start error:', err);
163
- debugLog("auto-start-failed", { error: err instanceof Error ? err.message : String(err) });
164
+ debugLog("auto-start-failed", { error: getErrorMessage(err) });
164
165
  });
165
166
  return true;
166
167
  }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * GSD Health Widget — always-on ambient health signal rendered belowEditor.
3
+ *
4
+ * Shows a compact 1-2 line summary: progress score, budget, provider key
5
+ * status, and doctor/environment issue count. Refreshes every 60 seconds.
6
+ * Quiet when everything is healthy; turns amber/red when issues arise.
7
+ *
8
+ * Widget key: "gsd-health", placement: "belowEditor"
9
+ */
10
+
11
+ import type { ExtensionContext } from "@gsd/pi-coding-agent";
12
+ import { runProviderChecks, summariseProviderIssues } from "./doctor-providers.js";
13
+ import { runEnvironmentChecks } from "./doctor-environment.js";
14
+ import { loadEffectiveGSDPreferences } from "./preferences.js";
15
+ import { loadLedgerFromDisk, getProjectTotals } from "./metrics.js";
16
+ import { projectRoot } from "./commands.js";
17
+
18
+ // ── Types ──────────────────────────────────────────────────────────────────────
19
+
20
+ interface HealthWidgetData {
21
+ hasProject: boolean;
22
+ budgetCeiling: number | undefined;
23
+ budgetSpent: number;
24
+ providerIssue: string | null; // compact summary from summariseProviderIssues()
25
+ environmentErrorCount: number;
26
+ environmentWarningCount: number;
27
+ lastRefreshed: number;
28
+ }
29
+
30
+ // ── Data loader ────────────────────────────────────────────────────────────────
31
+
32
+ function loadHealthWidgetData(basePath: string): HealthWidgetData {
33
+ let hasProject = false;
34
+ let budgetCeiling: number | undefined;
35
+ let budgetSpent = 0;
36
+ let providerIssue: string | null = null;
37
+ let environmentErrorCount = 0;
38
+ let environmentWarningCount = 0;
39
+
40
+ try {
41
+ const prefs = loadEffectiveGSDPreferences();
42
+ budgetCeiling = prefs?.preferences?.budget_ceiling;
43
+
44
+ const ledger = loadLedgerFromDisk(basePath);
45
+ if (ledger) {
46
+ hasProject = true;
47
+ const totals = getProjectTotals(ledger.units ?? []);
48
+ budgetSpent = totals.cost;
49
+ }
50
+ } catch { /* non-fatal */ }
51
+
52
+ try {
53
+ const providerResults = runProviderChecks();
54
+ providerIssue = summariseProviderIssues(providerResults);
55
+ } catch { /* non-fatal */ }
56
+
57
+ try {
58
+ const envResults = runEnvironmentChecks(basePath);
59
+ for (const r of envResults) {
60
+ if (r.status === "error") environmentErrorCount++;
61
+ else if (r.status === "warning") environmentWarningCount++;
62
+ }
63
+ } catch { /* non-fatal */ }
64
+
65
+ return {
66
+ hasProject,
67
+ budgetCeiling,
68
+ budgetSpent,
69
+ providerIssue,
70
+ environmentErrorCount,
71
+ environmentWarningCount,
72
+ lastRefreshed: Date.now(),
73
+ };
74
+ }
75
+
76
+ // ── Rendering ──────────────────────────────────────────────────────────────────
77
+
78
+ function formatCost(n: number): string {
79
+ return n >= 1 ? `$${n.toFixed(2)}` : `${(n * 100).toFixed(1)}¢`;
80
+ }
81
+
82
+ /**
83
+ * Build compact health lines for the widget.
84
+ * Returns a string array suitable for setWidget().
85
+ */
86
+ export function buildHealthLines(data: HealthWidgetData): string[] {
87
+ if (!data.hasProject) {
88
+ return [" GSD No project loaded — run /gsd to start"];
89
+ }
90
+
91
+ const parts: string[] = [];
92
+
93
+ // System status signal
94
+ const totalIssues = data.environmentErrorCount + data.environmentWarningCount + (data.providerIssue ? 1 : 0);
95
+ if (totalIssues === 0) {
96
+ parts.push("● System OK");
97
+ } else if (data.environmentErrorCount > 0 || data.providerIssue?.includes("✗")) {
98
+ parts.push(`✗ ${totalIssues} issue${totalIssues > 1 ? "s" : ""}`);
99
+ } else {
100
+ parts.push(`⚠ ${totalIssues} warning${totalIssues > 1 ? "s" : ""}`);
101
+ }
102
+
103
+ // Budget
104
+ if (data.budgetCeiling !== undefined && data.budgetCeiling > 0) {
105
+ const pct = Math.min(100, (data.budgetSpent / data.budgetCeiling) * 100);
106
+ parts.push(`Budget: ${formatCost(data.budgetSpent)}/${formatCost(data.budgetCeiling)} (${pct.toFixed(0)}%)`);
107
+ } else if (data.budgetSpent > 0) {
108
+ parts.push(`Spent: ${formatCost(data.budgetSpent)}`);
109
+ }
110
+
111
+ // Provider issue (if any)
112
+ if (data.providerIssue) {
113
+ parts.push(data.providerIssue);
114
+ }
115
+
116
+ // Environment issues
117
+ if (data.environmentErrorCount > 0) {
118
+ parts.push(`Env: ${data.environmentErrorCount} error${data.environmentErrorCount > 1 ? "s" : ""}`);
119
+ } else if (data.environmentWarningCount > 0) {
120
+ parts.push(`Env: ${data.environmentWarningCount} warning${data.environmentWarningCount > 1 ? "s" : ""}`);
121
+ }
122
+
123
+ return [` ${parts.join(" │ ")}`];
124
+ }
125
+
126
+ // ── Widget init ────────────────────────────────────────────────────────────────
127
+
128
+ const REFRESH_INTERVAL_MS = 60_000;
129
+
130
+ /**
131
+ * Initialize the always-on gsd-health widget (belowEditor).
132
+ * Call once from the extension entry point after context is available.
133
+ */
134
+ export function initHealthWidget(ctx: ExtensionContext): void {
135
+ if (!ctx.hasUI) return;
136
+
137
+ const basePath = projectRoot();
138
+
139
+ // String-array fallback — used in RPC mode (factory is a no-op there)
140
+ const initialData = loadHealthWidgetData(basePath);
141
+ ctx.ui.setWidget("gsd-health", buildHealthLines(initialData), { placement: "belowEditor" });
142
+
143
+ // Factory-based widget for TUI mode — replaces the string-array above
144
+ ctx.ui.setWidget("gsd-health", (_tui, _theme) => {
145
+ let data = initialData;
146
+ let cachedLines: string[] | undefined;
147
+
148
+ const refreshTimer = setInterval(() => {
149
+ try {
150
+ data = loadHealthWidgetData(basePath);
151
+ cachedLines = undefined;
152
+ _tui.requestRender();
153
+ } catch { /* non-fatal */ }
154
+ }, REFRESH_INTERVAL_MS);
155
+
156
+ return {
157
+ render(_width: number): string[] {
158
+ if (!cachedLines) cachedLines = buildHealthLines(data);
159
+ return cachedLines;
160
+ },
161
+ invalidate(): void { cachedLines = undefined; },
162
+ dispose(): void {
163
+ clearInterval(refreshTimer);
164
+ },
165
+ };
166
+ }, { placement: "belowEditor" });
167
+ }