gsd-pi 2.31.2-dev.c8d7e03 → 2.32.0-dev.1e39869

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 (39) hide show
  1. package/dist/resources/extensions/gsd/auto-start.ts +4 -2
  2. package/dist/resources/extensions/gsd/commands.ts +19 -0
  3. package/dist/resources/extensions/gsd/dashboard-overlay.ts +28 -0
  4. package/dist/resources/extensions/gsd/doctor-environment.ts +497 -0
  5. package/dist/resources/extensions/gsd/doctor-providers.ts +343 -0
  6. package/dist/resources/extensions/gsd/doctor-types.ts +14 -1
  7. package/dist/resources/extensions/gsd/doctor.ts +6 -0
  8. package/dist/resources/extensions/gsd/health-widget.ts +167 -0
  9. package/dist/resources/extensions/gsd/index.ts +6 -0
  10. package/dist/resources/extensions/gsd/progress-score.ts +273 -0
  11. package/dist/resources/extensions/gsd/tests/doctor-environment.test.ts +314 -0
  12. package/dist/resources/extensions/gsd/tests/doctor-providers.test.ts +298 -0
  13. package/dist/resources/extensions/gsd/tests/export-html-enhancements.test.ts +3 -0
  14. package/dist/resources/extensions/gsd/tests/memory-leak-guards.test.ts +7 -3
  15. package/dist/resources/extensions/gsd/tests/progress-score.test.ts +206 -0
  16. package/dist/resources/extensions/gsd/tests/visualizer-views.test.ts +12 -0
  17. package/dist/resources/extensions/gsd/visualizer-data.ts +60 -2
  18. package/dist/resources/extensions/gsd/visualizer-views.ts +54 -0
  19. package/package.json +1 -1
  20. package/packages/pi-coding-agent/package.json +1 -1
  21. package/pkg/package.json +1 -1
  22. package/src/resources/extensions/gsd/auto-start.ts +4 -2
  23. package/src/resources/extensions/gsd/commands.ts +19 -0
  24. package/src/resources/extensions/gsd/dashboard-overlay.ts +28 -0
  25. package/src/resources/extensions/gsd/doctor-environment.ts +497 -0
  26. package/src/resources/extensions/gsd/doctor-providers.ts +343 -0
  27. package/src/resources/extensions/gsd/doctor-types.ts +14 -1
  28. package/src/resources/extensions/gsd/doctor.ts +6 -0
  29. package/src/resources/extensions/gsd/health-widget.ts +167 -0
  30. package/src/resources/extensions/gsd/index.ts +6 -0
  31. package/src/resources/extensions/gsd/progress-score.ts +273 -0
  32. package/src/resources/extensions/gsd/tests/doctor-environment.test.ts +314 -0
  33. package/src/resources/extensions/gsd/tests/doctor-providers.test.ts +298 -0
  34. package/src/resources/extensions/gsd/tests/export-html-enhancements.test.ts +3 -0
  35. package/src/resources/extensions/gsd/tests/memory-leak-guards.test.ts +7 -3
  36. package/src/resources/extensions/gsd/tests/progress-score.test.ts +206 -0
  37. package/src/resources/extensions/gsd/tests/visualizer-views.test.ts +12 -0
  38. package/src/resources/extensions/gsd/visualizer-data.ts +60 -2
  39. package/src/resources/extensions/gsd/visualizer-views.ts +54 -0
@@ -131,9 +131,11 @@ export async function bootstrapAutoSession(
131
131
  // Initialize GitServiceImpl
132
132
  s.gitService = createGitService(s.basePath);
133
133
 
134
- // Check for crash from previous session (use both old and new lock data)
134
+ // Check for crash from previous session (use both old and new lock data).
135
+ // Skip if the lock PID matches this process — acquireSessionLock() writes
136
+ // to the same auto.lock file before this check, so we'd always false-positive.
135
137
  const crashLock = readCrashLock(base);
136
- if (crashLock) {
138
+ if (crashLock && crashLock.pid !== process.pid) {
137
139
  // We already hold the session lock, so no concurrent session is running.
138
140
  // The crash lock is from a dead process — recover context from it.
139
141
  const recoveredMid = crashLock.unitId.split("/")[0];
@@ -44,6 +44,8 @@ import { handleConfig } from "./commands-config.js";
44
44
  import { handleInspect } from "./commands-inspect.js";
45
45
  import { handleCleanupBranches, handleCleanupSnapshots, handleSkip, handleDryRun } from "./commands-maintenance.js";
46
46
  import { handleDoctor, handleSteer, handleCapture, handleTriage, handleKnowledge, handleRunHook, handleUpdate, handleSkillHealth } from "./commands-handlers.js";
47
+ import { computeProgressScore, formatProgressLine } from "./progress-score.js";
48
+ import { runEnvironmentChecks } from "./doctor-environment.js";
47
49
  import { handleLogs } from "./commands-logs.js";
48
50
  import { handleStart, handleTemplates, getTemplateCompletions } from "./commands-workflow-templates.js";
49
51
 
@@ -1068,6 +1070,11 @@ async function handleSetup(args: string, ctx: ExtensionCommandContext): Promise<
1068
1070
  function formatTextStatus(state: GSDState): string {
1069
1071
  const lines: string[] = ["GSD Status\n"];
1070
1072
 
1073
+ // Progress score — traffic light (#1221)
1074
+ const progressScore = computeProgressScore();
1075
+ lines.push(formatProgressLine(progressScore));
1076
+ lines.push("");
1077
+
1071
1078
  // Phase
1072
1079
  lines.push(`Phase: ${state.phase}`);
1073
1080
 
@@ -1114,5 +1121,17 @@ function formatTextStatus(state: GSDState): string {
1114
1121
  }
1115
1122
  }
1116
1123
 
1124
+ // Environment health (#1221)
1125
+ const envResults = runEnvironmentChecks(projectRoot());
1126
+ const envIssues = envResults.filter(r => r.status !== "ok");
1127
+ if (envIssues.length > 0) {
1128
+ lines.push("");
1129
+ lines.push("Environment:");
1130
+ for (const r of envIssues) {
1131
+ const icon = r.status === "error" ? "✗" : "⚠";
1132
+ lines.push(` ${icon} ${r.message}`);
1133
+ }
1134
+ }
1135
+
1117
1136
  return lines.join("\n");
1118
1137
  }
@@ -23,6 +23,8 @@ import { getActiveWorktreeName } from "./worktree-command.js";
23
23
  import { getWorkerBatches, hasActiveWorkers, type WorkerEntry } from "../subagent/worker-registry.js";
24
24
  import { formatDuration, padRight, joinColumns, centerLine, fitColumns, STATUS_GLYPH, STATUS_COLOR } from "../shared/mod.js";
25
25
  import { estimateTimeRemaining } from "./auto-dashboard.js";
26
+ import { computeProgressScore, formatProgressLine } from "./progress-score.js";
27
+ import { runEnvironmentChecks, type EnvironmentCheckResult } from "./doctor-environment.js";
26
28
 
27
29
  function unitLabel(type: string): string {
28
30
  switch (type) {
@@ -310,6 +312,15 @@ export class GSDDashboardOverlay {
310
312
  elapsedParts = th.fg("dim", `since ${this.dashData.remoteSession!.startedAt.replace("T", " ").slice(0, 19)}`);
311
313
  }
312
314
  lines.push(row(joinColumns(`${title} ${status}${worktreeTag}`, elapsedParts, contentWidth)));
315
+
316
+ // Progress score — traffic light indicator (#1221)
317
+ if (this.dashData.active || this.dashData.paused) {
318
+ const progressScore = computeProgressScore();
319
+ const progressIcon = progressScore.level === "green" ? th.fg("success", "●")
320
+ : progressScore.level === "yellow" ? th.fg("warning", "●")
321
+ : th.fg("error", "●");
322
+ lines.push(row(`${progressIcon} ${th.fg("text", progressScore.summary)}`));
323
+ }
313
324
  lines.push(blank());
314
325
 
315
326
  if (this.dashData.currentUnit) {
@@ -579,6 +590,23 @@ export class GSDDashboardOverlay {
579
590
  }
580
591
  }
581
592
 
593
+ // Environment health section (#1221) — only show issues
594
+ const envResults = runEnvironmentChecks(this.dashData.basePath || process.cwd());
595
+ const envIssues = envResults.filter(r => r.status !== "ok");
596
+ if (envIssues.length > 0) {
597
+ lines.push(blank());
598
+ lines.push(hr());
599
+ lines.push(row(th.fg("text", th.bold("Environment"))));
600
+ lines.push(blank());
601
+ for (const r of envIssues) {
602
+ const icon = r.status === "error" ? th.fg("error", "✗") : th.fg("warning", "⚠");
603
+ lines.push(row(` ${icon} ${th.fg("text", r.message)}`));
604
+ if (r.detail) {
605
+ lines.push(row(th.fg("dim", ` ${r.detail}`)));
606
+ }
607
+ }
608
+ }
609
+
582
610
  lines.push(blank());
583
611
  lines.push(hr());
584
612
  lines.push(centered(th.fg("dim", "↑↓ scroll · g/G top/end · esc close")));
@@ -0,0 +1,497 @@
1
+ /**
2
+ * GSD Doctor — Environment Health Checks (#1221)
3
+ *
4
+ * Deterministic checks for environment readiness that prevent the model
5
+ * from spinning its wheels on missing tools, port conflicts, stale
6
+ * dependencies, and other infrastructure issues.
7
+ *
8
+ * These checks complement the existing git/runtime health checks and
9
+ * integrate into the doctor pipeline via checkEnvironmentHealth().
10
+ */
11
+
12
+ import { existsSync, readFileSync, statSync } from "node:fs";
13
+ import { execSync } from "node:child_process";
14
+ import { join } from "node:path";
15
+
16
+ import type { DoctorIssue, DoctorIssueCode } from "./doctor-types.js";
17
+
18
+ // ── Types ──────────────────────────────────────────────────────────────────
19
+
20
+ export interface EnvironmentCheckResult {
21
+ name: string;
22
+ status: "ok" | "warning" | "error";
23
+ message: string;
24
+ detail?: string;
25
+ }
26
+
27
+ // ── Constants ──────────────────────────────────────────────────────────────
28
+
29
+ /** Default dev server ports to scan for conflicts. */
30
+ const DEFAULT_DEV_PORTS = [3000, 3001, 4000, 5000, 5173, 8000, 8080, 8888];
31
+
32
+ /** Minimum free disk space in bytes (500MB). */
33
+ const MIN_DISK_BYTES = 500 * 1024 * 1024;
34
+
35
+ /** Timeout for external commands (ms). */
36
+ const CMD_TIMEOUT = 5_000;
37
+
38
+ // ── Helpers ────────────────────────────────────────────────────────────────
39
+
40
+ function tryExec(cmd: string, cwd: string): string | null {
41
+ try {
42
+ return execSync(cmd, {
43
+ cwd,
44
+ timeout: CMD_TIMEOUT,
45
+ stdio: ["ignore", "pipe", "pipe"],
46
+ encoding: "utf-8",
47
+ }).trim();
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
53
+ function commandExists(name: string, cwd: string): boolean {
54
+ const whichCmd = process.platform === "win32" ? `where ${name}` : `command -v ${name}`;
55
+ return tryExec(whichCmd, cwd) !== null;
56
+ }
57
+
58
+ // ── Individual Checks ──────────────────────────────────────────────────────
59
+
60
+ /**
61
+ * Check that Node.js version meets the project's engines requirement.
62
+ */
63
+ function checkNodeVersion(basePath: string): EnvironmentCheckResult | null {
64
+ const pkgPath = join(basePath, "package.json");
65
+ if (!existsSync(pkgPath)) return null;
66
+
67
+ try {
68
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
69
+ const required = pkg.engines?.node;
70
+ if (!required) return null;
71
+
72
+ const currentVersion = tryExec("node --version", basePath);
73
+ if (!currentVersion) {
74
+ return { name: "node_version", status: "error", message: "Node.js not found in PATH" };
75
+ }
76
+
77
+ // Parse semver requirement (handles >=X.Y.Z format)
78
+ const reqMatch = required.match(/>=?\s*(\d+)(?:\.(\d+))?/);
79
+ if (!reqMatch) return null;
80
+
81
+ const reqMajor = parseInt(reqMatch[1], 10);
82
+ const reqMinor = parseInt(reqMatch[2] ?? "0", 10);
83
+
84
+ const curMatch = currentVersion.match(/v?(\d+)\.(\d+)/);
85
+ if (!curMatch) return null;
86
+
87
+ const curMajor = parseInt(curMatch[1], 10);
88
+ const curMinor = parseInt(curMatch[2], 10);
89
+
90
+ if (curMajor < reqMajor || (curMajor === reqMajor && curMinor < reqMinor)) {
91
+ return {
92
+ name: "node_version",
93
+ status: "warning",
94
+ message: `Node.js ${currentVersion} does not meet requirement "${required}"`,
95
+ detail: `Current: ${currentVersion}, Required: ${required}`,
96
+ };
97
+ }
98
+
99
+ return { name: "node_version", status: "ok", message: `Node.js ${currentVersion}` };
100
+ } catch {
101
+ return null;
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Check if node_modules exists and is not stale vs the lockfile.
107
+ */
108
+ function checkDependenciesInstalled(basePath: string): EnvironmentCheckResult | null {
109
+ const pkgPath = join(basePath, "package.json");
110
+ if (!existsSync(pkgPath)) return null;
111
+
112
+ const nodeModules = join(basePath, "node_modules");
113
+ if (!existsSync(nodeModules)) {
114
+ return {
115
+ name: "dependencies",
116
+ status: "error",
117
+ message: "node_modules missing — run npm install",
118
+ };
119
+ }
120
+
121
+ // Check if lockfile is newer than node_modules
122
+ const lockfiles = ["package-lock.json", "yarn.lock", "pnpm-lock.yaml"];
123
+ for (const lockfile of lockfiles) {
124
+ const lockPath = join(basePath, lockfile);
125
+ if (!existsSync(lockPath)) continue;
126
+
127
+ try {
128
+ const lockMtime = statSync(lockPath).mtimeMs;
129
+ const nmMtime = statSync(nodeModules).mtimeMs;
130
+
131
+ if (lockMtime > nmMtime) {
132
+ return {
133
+ name: "dependencies",
134
+ status: "warning",
135
+ message: `${lockfile} is newer than node_modules — dependencies may be stale`,
136
+ detail: `Run npm install / yarn / pnpm install to update`,
137
+ };
138
+ }
139
+ } catch {
140
+ // stat failed — skip
141
+ }
142
+ }
143
+
144
+ return { name: "dependencies", status: "ok", message: "Dependencies installed" };
145
+ }
146
+
147
+ /**
148
+ * Check for .env.example files without corresponding .env files.
149
+ */
150
+ function checkEnvFiles(basePath: string): EnvironmentCheckResult | null {
151
+ const examplePath = join(basePath, ".env.example");
152
+ if (!existsSync(examplePath)) return null;
153
+
154
+ const envPath = join(basePath, ".env");
155
+ const envLocalPath = join(basePath, ".env.local");
156
+
157
+ if (!existsSync(envPath) && !existsSync(envLocalPath)) {
158
+ return {
159
+ name: "env_file",
160
+ status: "warning",
161
+ message: ".env.example exists but no .env or .env.local found",
162
+ detail: "Copy .env.example to .env and fill in values",
163
+ };
164
+ }
165
+
166
+ return { name: "env_file", status: "ok", message: "Environment file present" };
167
+ }
168
+
169
+ /**
170
+ * Check for port conflicts on common dev server ports.
171
+ * Only checks ports that appear in package.json scripts.
172
+ */
173
+ function checkPortConflicts(basePath: string): EnvironmentCheckResult[] {
174
+ // Only run on macOS/Linux — lsof is not available on Windows
175
+ if (process.platform === "win32") return [];
176
+
177
+ const results: EnvironmentCheckResult[] = [];
178
+
179
+ // Try to detect ports from package.json scripts
180
+ const portsToCheck = new Set<number>();
181
+ const pkgPath = join(basePath, "package.json");
182
+
183
+ if (existsSync(pkgPath)) {
184
+ try {
185
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
186
+ const scripts = pkg.scripts ?? {};
187
+ const scriptText = Object.values(scripts).join(" ");
188
+
189
+ // Look for --port NNNN, -p NNNN, PORT=NNNN, :NNNN patterns
190
+ const portMatches = scriptText.matchAll(/(?:--port\s+|(?:^|[^a-z])PORT[=:]\s*|-p\s+|:)(\d{4,5})\b/gi);
191
+ for (const m of portMatches) {
192
+ const port = parseInt(m[1], 10);
193
+ if (port >= 1024 && port <= 65535) portsToCheck.add(port);
194
+ }
195
+ } catch {
196
+ // parse failed — use defaults
197
+ }
198
+ }
199
+
200
+ // If no ports found in scripts, check common defaults
201
+ if (portsToCheck.size === 0) {
202
+ for (const p of DEFAULT_DEV_PORTS) portsToCheck.add(p);
203
+ }
204
+
205
+ for (const port of portsToCheck) {
206
+ const result = tryExec(`lsof -i :${port} -sTCP:LISTEN -t`, basePath);
207
+ if (result && result.length > 0) {
208
+ // Get process name
209
+ const nameResult = tryExec(`lsof -i :${port} -sTCP:LISTEN -Fp | head -2`, basePath);
210
+ const processName = nameResult?.match(/p(\d+)\n?c?(.+)?/)?.[2] ?? "unknown";
211
+
212
+ results.push({
213
+ name: "port_conflict",
214
+ status: "warning",
215
+ message: `Port ${port} is already in use by ${processName} (PID ${result.split("\n")[0]})`,
216
+ detail: `Kill the process or use a different port`,
217
+ });
218
+ }
219
+ }
220
+
221
+ return results;
222
+ }
223
+
224
+ /**
225
+ * Check available disk space on the working directory partition.
226
+ */
227
+ function checkDiskSpace(basePath: string): EnvironmentCheckResult | null {
228
+ // Only run on macOS/Linux
229
+ if (process.platform === "win32") return null;
230
+
231
+ const dfOutput = tryExec(`df -k "${basePath}" | tail -1`, basePath);
232
+ if (!dfOutput) return null;
233
+
234
+ try {
235
+ // df output: filesystem blocks used avail capacity mount
236
+ const parts = dfOutput.split(/\s+/);
237
+ const availKB = parseInt(parts[3], 10);
238
+ if (isNaN(availKB)) return null;
239
+
240
+ const availBytes = availKB * 1024;
241
+ const availMB = Math.round(availBytes / (1024 * 1024));
242
+ const availGB = (availBytes / (1024 * 1024 * 1024)).toFixed(1);
243
+
244
+ if (availBytes < MIN_DISK_BYTES) {
245
+ return {
246
+ name: "disk_space",
247
+ status: "error",
248
+ message: `Low disk space: ${availMB}MB free`,
249
+ detail: `Free up space — builds and git operations may fail`,
250
+ };
251
+ }
252
+
253
+ if (availBytes < MIN_DISK_BYTES * 4) {
254
+ return {
255
+ name: "disk_space",
256
+ status: "warning",
257
+ message: `Disk space getting low: ${availGB}GB free`,
258
+ };
259
+ }
260
+
261
+ return { name: "disk_space", status: "ok", message: `${availGB}GB free` };
262
+ } catch {
263
+ return null;
264
+ }
265
+ }
266
+
267
+ /**
268
+ * Check if Docker is available when project has a Dockerfile.
269
+ */
270
+ function checkDocker(basePath: string): EnvironmentCheckResult | null {
271
+ const hasDockerfile = existsSync(join(basePath, "Dockerfile")) ||
272
+ existsSync(join(basePath, "docker-compose.yml")) ||
273
+ existsSync(join(basePath, "docker-compose.yaml")) ||
274
+ existsSync(join(basePath, "compose.yml")) ||
275
+ existsSync(join(basePath, "compose.yaml"));
276
+
277
+ if (!hasDockerfile) return null;
278
+
279
+ if (!commandExists("docker", basePath)) {
280
+ return {
281
+ name: "docker",
282
+ status: "warning",
283
+ message: "Project has Docker files but docker is not installed",
284
+ };
285
+ }
286
+
287
+ const info = tryExec("docker info --format '{{.ServerVersion}}'", basePath);
288
+ if (!info) {
289
+ return {
290
+ name: "docker",
291
+ status: "warning",
292
+ message: "Docker is installed but daemon is not running",
293
+ detail: "Start Docker Desktop or the docker daemon",
294
+ };
295
+ }
296
+
297
+ return { name: "docker", status: "ok", message: `Docker ${info}` };
298
+ }
299
+
300
+ /**
301
+ * Check for common project tools that should be available.
302
+ */
303
+ function checkProjectTools(basePath: string): EnvironmentCheckResult[] {
304
+ const results: EnvironmentCheckResult[] = [];
305
+ const pkgPath = join(basePath, "package.json");
306
+
307
+ if (!existsSync(pkgPath)) return results;
308
+
309
+ try {
310
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
311
+ const allDeps = {
312
+ ...(pkg.dependencies ?? {}),
313
+ ...(pkg.devDependencies ?? {}),
314
+ };
315
+
316
+ // Check for package manager
317
+ const packageManager = pkg.packageManager;
318
+ if (packageManager) {
319
+ const managerName = packageManager.split("@")[0];
320
+ if (managerName && managerName !== "npm" && !commandExists(managerName, basePath)) {
321
+ results.push({
322
+ name: "package_manager",
323
+ status: "warning",
324
+ message: `Project requires ${managerName} but it's not installed`,
325
+ detail: `Install with: npm install -g ${managerName}`,
326
+ });
327
+ }
328
+ }
329
+
330
+ // Check for TypeScript if it's a dependency
331
+ if (allDeps["typescript"] && !existsSync(join(basePath, "node_modules", ".bin", "tsc"))) {
332
+ results.push({
333
+ name: "typescript",
334
+ status: "warning",
335
+ message: "TypeScript is a dependency but tsc is not available (run npm install)",
336
+ });
337
+ }
338
+
339
+ // Check for Python if pyproject.toml or requirements.txt exists
340
+ if (existsSync(join(basePath, "pyproject.toml")) || existsSync(join(basePath, "requirements.txt"))) {
341
+ if (!commandExists("python3", basePath) && !commandExists("python", basePath)) {
342
+ results.push({
343
+ name: "python",
344
+ status: "warning",
345
+ message: "Project has Python config but python is not installed",
346
+ });
347
+ }
348
+ }
349
+
350
+ // Check for Rust if Cargo.toml exists
351
+ if (existsSync(join(basePath, "Cargo.toml"))) {
352
+ if (!commandExists("cargo", basePath)) {
353
+ results.push({
354
+ name: "cargo",
355
+ status: "warning",
356
+ message: "Project has Cargo.toml but cargo is not installed",
357
+ });
358
+ }
359
+ }
360
+
361
+ // Check for Go if go.mod exists
362
+ if (existsSync(join(basePath, "go.mod"))) {
363
+ if (!commandExists("go", basePath)) {
364
+ results.push({
365
+ name: "go",
366
+ status: "warning",
367
+ message: "Project has go.mod but go is not installed",
368
+ });
369
+ }
370
+ }
371
+ } catch {
372
+ // parse failed — skip
373
+ }
374
+
375
+ return results;
376
+ }
377
+
378
+ /**
379
+ * Check git remote reachability.
380
+ */
381
+ function checkGitRemote(basePath: string): EnvironmentCheckResult | null {
382
+ // Only check if it's a git repo with a remote
383
+ const remote = tryExec("git remote get-url origin", basePath);
384
+ if (!remote) return null;
385
+
386
+ // Quick connectivity check with short timeout
387
+ const result = tryExec("git ls-remote --exit-code -h origin HEAD", basePath);
388
+ if (result === null) {
389
+ return {
390
+ name: "git_remote",
391
+ status: "warning",
392
+ message: "Git remote 'origin' is unreachable",
393
+ detail: `Remote: ${remote}`,
394
+ };
395
+ }
396
+
397
+ return { name: "git_remote", status: "ok", message: "Git remote reachable" };
398
+ }
399
+
400
+ // ── Public API ─────────────────────────────────────────────────────────────
401
+
402
+ /**
403
+ * Run all environment health checks. Returns structured results for
404
+ * integration with the doctor pipeline.
405
+ */
406
+ export function runEnvironmentChecks(basePath: string): EnvironmentCheckResult[] {
407
+ const results: EnvironmentCheckResult[] = [];
408
+
409
+ const nodeCheck = checkNodeVersion(basePath);
410
+ if (nodeCheck) results.push(nodeCheck);
411
+
412
+ const depsCheck = checkDependenciesInstalled(basePath);
413
+ if (depsCheck) results.push(depsCheck);
414
+
415
+ const envCheck = checkEnvFiles(basePath);
416
+ if (envCheck) results.push(envCheck);
417
+
418
+ results.push(...checkPortConflicts(basePath));
419
+
420
+ const diskCheck = checkDiskSpace(basePath);
421
+ if (diskCheck) results.push(diskCheck);
422
+
423
+ const dockerCheck = checkDocker(basePath);
424
+ if (dockerCheck) results.push(dockerCheck);
425
+
426
+ results.push(...checkProjectTools(basePath));
427
+
428
+ // Git remote check can be slow — only run on explicit doctor invocation
429
+ // (not on pre-dispatch gate)
430
+
431
+ return results;
432
+ }
433
+
434
+ /**
435
+ * Run environment checks with git remote check included.
436
+ * Use this for explicit /gsd doctor invocations, not pre-dispatch gates.
437
+ */
438
+ export function runFullEnvironmentChecks(basePath: string): EnvironmentCheckResult[] {
439
+ const results = runEnvironmentChecks(basePath);
440
+
441
+ const remoteCheck = checkGitRemote(basePath);
442
+ if (remoteCheck) results.push(remoteCheck);
443
+
444
+ return results;
445
+ }
446
+
447
+ /**
448
+ * Convert environment check results to DoctorIssue format for the doctor pipeline.
449
+ */
450
+ export function environmentResultsToDoctorIssues(results: EnvironmentCheckResult[]): DoctorIssue[] {
451
+ return results
452
+ .filter(r => r.status !== "ok")
453
+ .map(r => ({
454
+ severity: r.status === "error" ? "error" as const : "warning" as const,
455
+ code: `env_${r.name}` as DoctorIssueCode,
456
+ scope: "project" as const,
457
+ unitId: "environment",
458
+ message: r.detail ? `${r.message} — ${r.detail}` : r.message,
459
+ fixable: false,
460
+ }));
461
+ }
462
+
463
+ /**
464
+ * Integration point for the doctor pipeline. Runs environment checks
465
+ * and appends issues to the provided array.
466
+ */
467
+ export async function checkEnvironmentHealth(
468
+ basePath: string,
469
+ issues: DoctorIssue[],
470
+ options?: { includeRemote?: boolean },
471
+ ): Promise<void> {
472
+ const results = options?.includeRemote
473
+ ? runFullEnvironmentChecks(basePath)
474
+ : runEnvironmentChecks(basePath);
475
+
476
+ issues.push(...environmentResultsToDoctorIssues(results));
477
+ }
478
+
479
+ /**
480
+ * Format environment check results for display.
481
+ */
482
+ export function formatEnvironmentReport(results: EnvironmentCheckResult[]): string {
483
+ if (results.length === 0) return "No environment checks applicable.";
484
+
485
+ const lines: string[] = [];
486
+ lines.push("Environment Health:");
487
+
488
+ for (const r of results) {
489
+ const icon = r.status === "ok" ? "\u2705" : r.status === "warning" ? "\u26A0\uFE0F" : "\uD83D\uDED1";
490
+ lines.push(` ${icon} ${r.message}`);
491
+ if (r.detail && r.status !== "ok") {
492
+ lines.push(` ${r.detail}`);
493
+ }
494
+ }
495
+
496
+ return lines.join("\n");
497
+ }