feat: environment health checks, progress score, and status integration (#1263)

This commit is contained in:
Jeremy McSpadden 2026-03-18 19:04:14 -05:00 committed by GitHub
parent d4d8d1a81e
commit 652ac385b7
10 changed files with 1367 additions and 4 deletions

View file

@ -17,6 +17,9 @@ tests/*:AKIA_EXAMPLE
tests/*:test-secret-value
tests/*:fake[-_]?(password|secret|token|key)
# Doctor environment tests use dummy localhost DB URLs
src/resources/extensions/gsd/tests/doctor-environment.test.ts:postgres://localhost
# Documentation examples
*.md:AKIA[0-9A-Z]{16}
*.md:sk_(live|test)_

View file

@ -44,6 +44,8 @@ import { handleConfig } from "./commands-config.js";
import { handleInspect } from "./commands-inspect.js";
import { handleCleanupBranches, handleCleanupSnapshots, handleSkip, handleDryRun } from "./commands-maintenance.js";
import { handleDoctor, handleSteer, handleCapture, handleTriage, handleKnowledge, handleRunHook, handleUpdate, handleSkillHealth } from "./commands-handlers.js";
import { computeProgressScore, formatProgressLine } from "./progress-score.js";
import { runEnvironmentChecks } from "./doctor-environment.js";
import { handleLogs } from "./commands-logs.js";
import { handleStart, handleTemplates, getTemplateCompletions } from "./commands-workflow-templates.js";
@ -1068,6 +1070,11 @@ async function handleSetup(args: string, ctx: ExtensionCommandContext): Promise<
function formatTextStatus(state: GSDState): string {
const lines: string[] = ["GSD Status\n"];
// Progress score — traffic light (#1221)
const progressScore = computeProgressScore();
lines.push(formatProgressLine(progressScore));
lines.push("");
// Phase
lines.push(`Phase: ${state.phase}`);
@ -1114,5 +1121,17 @@ function formatTextStatus(state: GSDState): string {
}
}
// Environment health (#1221)
const envResults = runEnvironmentChecks(projectRoot());
const envIssues = envResults.filter(r => r.status !== "ok");
if (envIssues.length > 0) {
lines.push("");
lines.push("Environment:");
for (const r of envIssues) {
const icon = r.status === "error" ? "✗" : "⚠";
lines.push(` ${icon} ${r.message}`);
}
}
return lines.join("\n");
}

View file

@ -23,6 +23,8 @@ import { getActiveWorktreeName } from "./worktree-command.js";
import { getWorkerBatches, hasActiveWorkers, type WorkerEntry } from "../subagent/worker-registry.js";
import { formatDuration, padRight, joinColumns, centerLine, fitColumns, STATUS_GLYPH, STATUS_COLOR } from "../shared/mod.js";
import { estimateTimeRemaining } from "./auto-dashboard.js";
import { computeProgressScore, formatProgressLine } from "./progress-score.js";
import { runEnvironmentChecks, type EnvironmentCheckResult } from "./doctor-environment.js";
function unitLabel(type: string): string {
switch (type) {
@ -310,6 +312,15 @@ export class GSDDashboardOverlay {
elapsedParts = th.fg("dim", `since ${this.dashData.remoteSession!.startedAt.replace("T", " ").slice(0, 19)}`);
}
lines.push(row(joinColumns(`${title} ${status}${worktreeTag}`, elapsedParts, contentWidth)));
// Progress score — traffic light indicator (#1221)
if (this.dashData.active || this.dashData.paused) {
const progressScore = computeProgressScore();
const progressIcon = progressScore.level === "green" ? th.fg("success", "●")
: progressScore.level === "yellow" ? th.fg("warning", "●")
: th.fg("error", "●");
lines.push(row(`${progressIcon} ${th.fg("text", progressScore.summary)}`));
}
lines.push(blank());
if (this.dashData.currentUnit) {
@ -579,6 +590,23 @@ export class GSDDashboardOverlay {
}
}
// Environment health section (#1221) — only show issues
const envResults = runEnvironmentChecks(this.dashData.basePath || process.cwd());
const envIssues = envResults.filter(r => r.status !== "ok");
if (envIssues.length > 0) {
lines.push(blank());
lines.push(hr());
lines.push(row(th.fg("text", th.bold("Environment"))));
lines.push(blank());
for (const r of envIssues) {
const icon = r.status === "error" ? th.fg("error", "✗") : th.fg("warning", "⚠");
lines.push(row(` ${icon} ${th.fg("text", r.message)}`));
if (r.detail) {
lines.push(row(th.fg("dim", ` ${r.detail}`)));
}
}
}
lines.push(blank());
lines.push(hr());
lines.push(centered(th.fg("dim", "↑↓ scroll · g/G top/end · esc close")));

View file

@ -0,0 +1,497 @@
/**
* GSD Doctor Environment Health Checks (#1221)
*
* Deterministic checks for environment readiness that prevent the model
* from spinning its wheels on missing tools, port conflicts, stale
* dependencies, and other infrastructure issues.
*
* These checks complement the existing git/runtime health checks and
* integrate into the doctor pipeline via checkEnvironmentHealth().
*/
import { existsSync, readFileSync, statSync } from "node:fs";
import { execSync } from "node:child_process";
import { join } from "node:path";
import type { DoctorIssue, DoctorIssueCode } from "./doctor-types.js";
// ── Types ──────────────────────────────────────────────────────────────────
export interface EnvironmentCheckResult {
name: string;
status: "ok" | "warning" | "error";
message: string;
detail?: string;
}
// ── Constants ──────────────────────────────────────────────────────────────
/** Default dev server ports to scan for conflicts. */
const DEFAULT_DEV_PORTS = [3000, 3001, 4000, 5000, 5173, 8000, 8080, 8888];
/** Minimum free disk space in bytes (500MB). */
const MIN_DISK_BYTES = 500 * 1024 * 1024;
/** Timeout for external commands (ms). */
const CMD_TIMEOUT = 5_000;
// ── Helpers ────────────────────────────────────────────────────────────────
function tryExec(cmd: string, cwd: string): string | null {
try {
return execSync(cmd, {
cwd,
timeout: CMD_TIMEOUT,
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf-8",
}).trim();
} catch {
return null;
}
}
function commandExists(name: string, cwd: string): boolean {
const whichCmd = process.platform === "win32" ? `where ${name}` : `command -v ${name}`;
return tryExec(whichCmd, cwd) !== null;
}
// ── Individual Checks ──────────────────────────────────────────────────────
/**
* Check that Node.js version meets the project's engines requirement.
*/
function checkNodeVersion(basePath: string): EnvironmentCheckResult | null {
const pkgPath = join(basePath, "package.json");
if (!existsSync(pkgPath)) return null;
try {
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
const required = pkg.engines?.node;
if (!required) return null;
const currentVersion = tryExec("node --version", basePath);
if (!currentVersion) {
return { name: "node_version", status: "error", message: "Node.js not found in PATH" };
}
// Parse semver requirement (handles >=X.Y.Z format)
const reqMatch = required.match(/>=?\s*(\d+)(?:\.(\d+))?/);
if (!reqMatch) return null;
const reqMajor = parseInt(reqMatch[1], 10);
const reqMinor = parseInt(reqMatch[2] ?? "0", 10);
const curMatch = currentVersion.match(/v?(\d+)\.(\d+)/);
if (!curMatch) return null;
const curMajor = parseInt(curMatch[1], 10);
const curMinor = parseInt(curMatch[2], 10);
if (curMajor < reqMajor || (curMajor === reqMajor && curMinor < reqMinor)) {
return {
name: "node_version",
status: "warning",
message: `Node.js ${currentVersion} does not meet requirement "${required}"`,
detail: `Current: ${currentVersion}, Required: ${required}`,
};
}
return { name: "node_version", status: "ok", message: `Node.js ${currentVersion}` };
} catch {
return null;
}
}
/**
* Check if node_modules exists and is not stale vs the lockfile.
*/
function checkDependenciesInstalled(basePath: string): EnvironmentCheckResult | null {
const pkgPath = join(basePath, "package.json");
if (!existsSync(pkgPath)) return null;
const nodeModules = join(basePath, "node_modules");
if (!existsSync(nodeModules)) {
return {
name: "dependencies",
status: "error",
message: "node_modules missing — run npm install",
};
}
// Check if lockfile is newer than node_modules
const lockfiles = ["package-lock.json", "yarn.lock", "pnpm-lock.yaml"];
for (const lockfile of lockfiles) {
const lockPath = join(basePath, lockfile);
if (!existsSync(lockPath)) continue;
try {
const lockMtime = statSync(lockPath).mtimeMs;
const nmMtime = statSync(nodeModules).mtimeMs;
if (lockMtime > nmMtime) {
return {
name: "dependencies",
status: "warning",
message: `${lockfile} is newer than node_modules — dependencies may be stale`,
detail: `Run npm install / yarn / pnpm install to update`,
};
}
} catch {
// stat failed — skip
}
}
return { name: "dependencies", status: "ok", message: "Dependencies installed" };
}
/**
* Check for .env.example files without corresponding .env files.
*/
function checkEnvFiles(basePath: string): EnvironmentCheckResult | null {
const examplePath = join(basePath, ".env.example");
if (!existsSync(examplePath)) return null;
const envPath = join(basePath, ".env");
const envLocalPath = join(basePath, ".env.local");
if (!existsSync(envPath) && !existsSync(envLocalPath)) {
return {
name: "env_file",
status: "warning",
message: ".env.example exists but no .env or .env.local found",
detail: "Copy .env.example to .env and fill in values",
};
}
return { name: "env_file", status: "ok", message: "Environment file present" };
}
/**
* Check for port conflicts on common dev server ports.
* Only checks ports that appear in package.json scripts.
*/
function checkPortConflicts(basePath: string): EnvironmentCheckResult[] {
// Only run on macOS/Linux — lsof is not available on Windows
if (process.platform === "win32") return [];
const results: EnvironmentCheckResult[] = [];
// Try to detect ports from package.json scripts
const portsToCheck = new Set<number>();
const pkgPath = join(basePath, "package.json");
if (existsSync(pkgPath)) {
try {
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
const scripts = pkg.scripts ?? {};
const scriptText = Object.values(scripts).join(" ");
// Look for --port NNNN, -p NNNN, PORT=NNNN, :NNNN patterns
const portMatches = scriptText.matchAll(/(?:--port\s+|(?:^|[^a-z])PORT[=:]\s*|-p\s+|:)(\d{4,5})\b/gi);
for (const m of portMatches) {
const port = parseInt(m[1], 10);
if (port >= 1024 && port <= 65535) portsToCheck.add(port);
}
} catch {
// parse failed — use defaults
}
}
// If no ports found in scripts, check common defaults
if (portsToCheck.size === 0) {
for (const p of DEFAULT_DEV_PORTS) portsToCheck.add(p);
}
for (const port of portsToCheck) {
const result = tryExec(`lsof -i :${port} -sTCP:LISTEN -t`, basePath);
if (result && result.length > 0) {
// Get process name
const nameResult = tryExec(`lsof -i :${port} -sTCP:LISTEN -Fp | head -2`, basePath);
const processName = nameResult?.match(/p(\d+)\n?c?(.+)?/)?.[2] ?? "unknown";
results.push({
name: "port_conflict",
status: "warning",
message: `Port ${port} is already in use by ${processName} (PID ${result.split("\n")[0]})`,
detail: `Kill the process or use a different port`,
});
}
}
return results;
}
/**
* Check available disk space on the working directory partition.
*/
function checkDiskSpace(basePath: string): EnvironmentCheckResult | null {
// Only run on macOS/Linux
if (process.platform === "win32") return null;
const dfOutput = tryExec(`df -k "${basePath}" | tail -1`, basePath);
if (!dfOutput) return null;
try {
// df output: filesystem blocks used avail capacity mount
const parts = dfOutput.split(/\s+/);
const availKB = parseInt(parts[3], 10);
if (isNaN(availKB)) return null;
const availBytes = availKB * 1024;
const availMB = Math.round(availBytes / (1024 * 1024));
const availGB = (availBytes / (1024 * 1024 * 1024)).toFixed(1);
if (availBytes < MIN_DISK_BYTES) {
return {
name: "disk_space",
status: "error",
message: `Low disk space: ${availMB}MB free`,
detail: `Free up space — builds and git operations may fail`,
};
}
if (availBytes < MIN_DISK_BYTES * 4) {
return {
name: "disk_space",
status: "warning",
message: `Disk space getting low: ${availGB}GB free`,
};
}
return { name: "disk_space", status: "ok", message: `${availGB}GB free` };
} catch {
return null;
}
}
/**
* Check if Docker is available when project has a Dockerfile.
*/
function checkDocker(basePath: string): EnvironmentCheckResult | null {
const hasDockerfile = existsSync(join(basePath, "Dockerfile")) ||
existsSync(join(basePath, "docker-compose.yml")) ||
existsSync(join(basePath, "docker-compose.yaml")) ||
existsSync(join(basePath, "compose.yml")) ||
existsSync(join(basePath, "compose.yaml"));
if (!hasDockerfile) return null;
if (!commandExists("docker", basePath)) {
return {
name: "docker",
status: "warning",
message: "Project has Docker files but docker is not installed",
};
}
const info = tryExec("docker info --format '{{.ServerVersion}}'", basePath);
if (!info) {
return {
name: "docker",
status: "warning",
message: "Docker is installed but daemon is not running",
detail: "Start Docker Desktop or the docker daemon",
};
}
return { name: "docker", status: "ok", message: `Docker ${info}` };
}
/**
* Check for common project tools that should be available.
*/
function checkProjectTools(basePath: string): EnvironmentCheckResult[] {
const results: EnvironmentCheckResult[] = [];
const pkgPath = join(basePath, "package.json");
if (!existsSync(pkgPath)) return results;
try {
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
const allDeps = {
...(pkg.dependencies ?? {}),
...(pkg.devDependencies ?? {}),
};
// Check for package manager
const packageManager = pkg.packageManager;
if (packageManager) {
const managerName = packageManager.split("@")[0];
if (managerName && managerName !== "npm" && !commandExists(managerName, basePath)) {
results.push({
name: "package_manager",
status: "warning",
message: `Project requires ${managerName} but it's not installed`,
detail: `Install with: npm install -g ${managerName}`,
});
}
}
// Check for TypeScript if it's a dependency
if (allDeps["typescript"] && !existsSync(join(basePath, "node_modules", ".bin", "tsc"))) {
results.push({
name: "typescript",
status: "warning",
message: "TypeScript is a dependency but tsc is not available (run npm install)",
});
}
// Check for Python if pyproject.toml or requirements.txt exists
if (existsSync(join(basePath, "pyproject.toml")) || existsSync(join(basePath, "requirements.txt"))) {
if (!commandExists("python3", basePath) && !commandExists("python", basePath)) {
results.push({
name: "python",
status: "warning",
message: "Project has Python config but python is not installed",
});
}
}
// Check for Rust if Cargo.toml exists
if (existsSync(join(basePath, "Cargo.toml"))) {
if (!commandExists("cargo", basePath)) {
results.push({
name: "cargo",
status: "warning",
message: "Project has Cargo.toml but cargo is not installed",
});
}
}
// Check for Go if go.mod exists
if (existsSync(join(basePath, "go.mod"))) {
if (!commandExists("go", basePath)) {
results.push({
name: "go",
status: "warning",
message: "Project has go.mod but go is not installed",
});
}
}
} catch {
// parse failed — skip
}
return results;
}
/**
* Check git remote reachability.
*/
function checkGitRemote(basePath: string): EnvironmentCheckResult | null {
// Only check if it's a git repo with a remote
const remote = tryExec("git remote get-url origin", basePath);
if (!remote) return null;
// Quick connectivity check with short timeout
const result = tryExec("git ls-remote --exit-code -h origin HEAD", basePath);
if (result === null) {
return {
name: "git_remote",
status: "warning",
message: "Git remote 'origin' is unreachable",
detail: `Remote: ${remote}`,
};
}
return { name: "git_remote", status: "ok", message: "Git remote reachable" };
}
// ── Public API ─────────────────────────────────────────────────────────────
/**
* Run all environment health checks. Returns structured results for
* integration with the doctor pipeline.
*/
export function runEnvironmentChecks(basePath: string): EnvironmentCheckResult[] {
const results: EnvironmentCheckResult[] = [];
const nodeCheck = checkNodeVersion(basePath);
if (nodeCheck) results.push(nodeCheck);
const depsCheck = checkDependenciesInstalled(basePath);
if (depsCheck) results.push(depsCheck);
const envCheck = checkEnvFiles(basePath);
if (envCheck) results.push(envCheck);
results.push(...checkPortConflicts(basePath));
const diskCheck = checkDiskSpace(basePath);
if (diskCheck) results.push(diskCheck);
const dockerCheck = checkDocker(basePath);
if (dockerCheck) results.push(dockerCheck);
results.push(...checkProjectTools(basePath));
// Git remote check can be slow — only run on explicit doctor invocation
// (not on pre-dispatch gate)
return results;
}
/**
* Run environment checks with git remote check included.
* Use this for explicit /gsd doctor invocations, not pre-dispatch gates.
*/
export function runFullEnvironmentChecks(basePath: string): EnvironmentCheckResult[] {
const results = runEnvironmentChecks(basePath);
const remoteCheck = checkGitRemote(basePath);
if (remoteCheck) results.push(remoteCheck);
return results;
}
/**
* Convert environment check results to DoctorIssue format for the doctor pipeline.
*/
export function environmentResultsToDoctorIssues(results: EnvironmentCheckResult[]): DoctorIssue[] {
return results
.filter(r => r.status !== "ok")
.map(r => ({
severity: r.status === "error" ? "error" as const : "warning" as const,
code: `env_${r.name}` as DoctorIssueCode,
scope: "project" as const,
unitId: "environment",
message: r.detail ? `${r.message}${r.detail}` : r.message,
fixable: false,
}));
}
/**
* Integration point for the doctor pipeline. Runs environment checks
* and appends issues to the provided array.
*/
export async function checkEnvironmentHealth(
basePath: string,
issues: DoctorIssue[],
options?: { includeRemote?: boolean },
): Promise<void> {
const results = options?.includeRemote
? runFullEnvironmentChecks(basePath)
: runEnvironmentChecks(basePath);
issues.push(...environmentResultsToDoctorIssues(results));
}
/**
* Format environment check results for display.
*/
export function formatEnvironmentReport(results: EnvironmentCheckResult[]): string {
if (results.length === 0) return "No environment checks applicable.";
const lines: string[] = [];
lines.push("Environment Health:");
for (const r of results) {
const icon = r.status === "ok" ? "\u2705" : r.status === "warning" ? "\u26A0\uFE0F" : "\uD83D\uDED1";
lines.push(` ${icon} ${r.message}`);
if (r.detail && r.status !== "ok") {
lines.push(` ${r.detail}`);
}
}
return lines.join("\n");
}

View file

@ -32,7 +32,20 @@ export type DoctorIssueCode =
| "gitignore_missing_patterns"
| "unresolvable_dependency"
| "failed_migration"
| "broken_symlink";
| "broken_symlink"
// Environment health checks (#1221)
| "env_node_version"
| "env_dependencies"
| "env_env_file"
| "env_port_conflict"
| "env_disk_space"
| "env_docker"
| "env_package_manager"
| "env_typescript"
| "env_python"
| "env_cargo"
| "env_go"
| "env_git_remote";
/**
* Issue codes that represent expected completion-transition states.

View file

@ -10,12 +10,15 @@ import { loadEffectiveGSDPreferences, type GSDPreferences } from "./preferences.
import type { DoctorIssue, DoctorIssueCode } from "./doctor-types.js";
import { COMPLETION_TRANSITION_CODES } from "./doctor-types.js";
import { checkGitHealth, checkRuntimeHealth } from "./doctor-checks.js";
import { checkEnvironmentHealth } from "./doctor-environment.js";
// ── Re-exports ─────────────────────────────────────────────────────────────
// All public types and functions from extracted modules are re-exported here
// so that existing imports from "./doctor.js" continue to work unchanged.
export type { DoctorSeverity, DoctorIssueCode, DoctorIssue, DoctorReport, DoctorSummary } from "./doctor-types.js";
export { summarizeDoctorIssues, filterDoctorIssues, formatDoctorReport, formatDoctorIssuesForPrompt } from "./doctor-format.js";
export { runEnvironmentChecks, runFullEnvironmentChecks, formatEnvironmentReport, type EnvironmentCheckResult } from "./doctor-environment.js";
export { computeProgressScore, computeProgressScoreWithContext, formatProgressLine, formatProgressReport, type ProgressScore, type ProgressLevel } from "./progress-score.js";
/**
* Characters that are used as delimiters in GSD state management documents
@ -390,6 +393,9 @@ export async function runGSDDoctor(basePath: string, options?: { fix?: boolean;
// Runtime health checks (crash locks, completed-units, hook state, activity logs, STATE.md, gitignore)
await checkRuntimeHealth(basePath, issues, fixesApplied, shouldFix);
// Environment health checks (#1221: missing tools, port conflicts, stale deps, disk space)
await checkEnvironmentHealth(basePath, issues, { includeRemote: !options?.scope });
const milestonesPath = milestonesDir(basePath);
if (!existsSync(milestonesPath)) {
return { ok: issues.every(issue => issue.severity !== "error"), basePath, issues, fixesApplied };

View file

@ -0,0 +1,273 @@
/**
* GSD Progress Score Traffic Light Status Indicator (#1221)
*
* Combines existing health signals into a single at-a-glance status:
* - Green: progressing well
* - Yellow: struggling (retries, warnings)
* - Red: stuck (loops, persistent errors, no activity)
*
* Purely derived no stored state. Reads from doctor-proactive health
* tracking, stuck detection counters, and working-tree activity.
*/
import {
getHealthTrend,
getConsecutiveErrorUnits,
getHealthHistory,
type HealthSnapshot,
} from "./doctor-proactive.js";
// ── Types ──────────────────────────────────────────────────────────────────
export type ProgressLevel = "green" | "yellow" | "red";
export interface ProgressScore {
level: ProgressLevel;
summary: string;
signals: ProgressSignal[];
}
export interface ProgressSignal {
name: string;
level: ProgressLevel;
detail: string;
}
// ── Signal Evaluators ──────────────────────────────────────────────────────
function evaluateHealthTrend(): ProgressSignal {
const trend = getHealthTrend();
switch (trend) {
case "improving":
return { name: "health_trend", level: "green", detail: "Health improving" };
case "stable":
return { name: "health_trend", level: "green", detail: "Health stable" };
case "degrading":
return { name: "health_trend", level: "red", detail: "Health degrading" };
case "unknown":
return { name: "health_trend", level: "green", detail: "Insufficient data" };
}
}
function evaluateErrorStreak(): ProgressSignal {
const streak = getConsecutiveErrorUnits();
if (streak === 0) {
return { name: "error_streak", level: "green", detail: "No consecutive errors" };
}
if (streak <= 2) {
return { name: "error_streak", level: "yellow", detail: `${streak} consecutive error unit(s)` };
}
return { name: "error_streak", level: "red", detail: `${streak} consecutive error units` };
}
function evaluateRecentErrors(): ProgressSignal {
const history = getHealthHistory();
if (history.length === 0) {
return { name: "recent_errors", level: "green", detail: "No health data yet" };
}
const latest = history[history.length - 1]!;
if (latest.errors === 0 && latest.warnings <= 1) {
return { name: "recent_errors", level: "green", detail: `${latest.errors}E/${latest.warnings}W` };
}
if (latest.errors === 0) {
return { name: "recent_errors", level: "yellow", detail: `${latest.warnings} warning(s)` };
}
if (latest.errors <= 2) {
return { name: "recent_errors", level: "yellow", detail: `${latest.errors} error(s), ${latest.warnings} warning(s)` };
}
return { name: "recent_errors", level: "red", detail: `${latest.errors} error(s), ${latest.warnings} warning(s)` };
}
function evaluateArtifactProduction(): ProgressSignal {
const history = getHealthHistory();
if (history.length < 2) {
return { name: "artifact_production", level: "green", detail: "Insufficient data" };
}
const totalFixes = history.reduce((sum, s) => sum + s.fixesApplied, 0);
const recent = history.slice(-3);
const recentFixes = recent.reduce((sum, s) => sum + s.fixesApplied, 0);
// If recent units are all producing fixes but errors aren't decreasing,
// doctor is fighting fires but not making headway
if (recentFixes > 3 && recent.every(s => s.errors > 0)) {
return { name: "artifact_production", level: "yellow", detail: "Doctor applying fixes but errors persist" };
}
return { name: "artifact_production", level: "green", detail: `${totalFixes} total fixes applied` };
}
function evaluateDispatchVelocity(): ProgressSignal {
const history = getHealthHistory();
if (history.length < 3) {
return { name: "dispatch_velocity", level: "green", detail: "Insufficient data" };
}
// Check time between recent snapshots — are units completing at a reasonable rate?
const recent = history.slice(-5);
if (recent.length < 2) {
return { name: "dispatch_velocity", level: "green", detail: "Insufficient data" };
}
const timeDiffs: number[] = [];
for (let i = 1; i < recent.length; i++) {
timeDiffs.push(recent[i]!.timestamp - recent[i - 1]!.timestamp);
}
const avgTimeMs = timeDiffs.reduce((a, b) => a + b, 0) / timeDiffs.length;
const avgTimeMins = Math.round(avgTimeMs / 60_000);
// If average unit time is > 15 minutes, something might be wrong
if (avgTimeMins > 15) {
return { name: "dispatch_velocity", level: "yellow", detail: `Units averaging ${avgTimeMins}min each` };
}
return { name: "dispatch_velocity", level: "green", detail: `Units averaging ${avgTimeMins || "<1"}min each` };
}
// ── Main API ───────────────────────────────────────────────────────────────
/**
* Compute the current progress score by evaluating all available signals.
* Returns a composite score with individual signal details.
*/
export function computeProgressScore(): ProgressScore {
const signals: ProgressSignal[] = [
evaluateHealthTrend(),
evaluateErrorStreak(),
evaluateRecentErrors(),
evaluateArtifactProduction(),
evaluateDispatchVelocity(),
];
// Overall level: worst of all signals
const level = signals.some(s => s.level === "red")
? "red"
: signals.some(s => s.level === "yellow")
? "yellow"
: "green";
// Build summary from the most important signals
const summary = buildSummary(level, signals);
return { level, summary, signals };
}
/**
* Compute progress score with additional context from the current unit.
*/
export function computeProgressScoreWithContext(context: {
currentUnitType?: string;
currentUnitId?: string;
completedUnits?: number;
totalUnits?: number;
retryCount?: number;
maxRetries?: number;
}): ProgressScore {
const base = computeProgressScore();
// Add retry signal if available
if (context.retryCount !== undefined && context.maxRetries !== undefined) {
const retrySignal: ProgressSignal = context.retryCount === 0
? { name: "retry_count", level: "green", detail: "No retries" }
: context.retryCount <= 2
? { name: "retry_count", level: "yellow", detail: `Retry ${context.retryCount}/${context.maxRetries}` }
: { name: "retry_count", level: "red", detail: `Retry ${context.retryCount}/${context.maxRetries} — looping` };
base.signals.push(retrySignal);
// Re-evaluate level
if (retrySignal.level === "red") base.level = "red";
else if (retrySignal.level === "yellow" && base.level === "green") base.level = "yellow";
}
// Build richer summary with context
base.summary = buildSummaryWithContext(base.level, base.signals, context);
return base;
}
// ── Formatting ─────────────────────────────────────────────────────────────
function buildSummary(level: ProgressLevel, signals: ProgressSignal[]): string {
switch (level) {
case "green":
return "Progressing well";
case "yellow": {
const issues = signals.filter(s => s.level === "yellow").map(s => s.detail);
return `Struggling — ${issues[0] ?? "minor issues detected"}`;
}
case "red": {
const issues = signals.filter(s => s.level === "red").map(s => s.detail);
return `Stuck — ${issues[0] ?? "critical issues detected"}`;
}
}
}
function buildSummaryWithContext(
level: ProgressLevel,
signals: ProgressSignal[],
context: {
currentUnitType?: string;
currentUnitId?: string;
completedUnits?: number;
totalUnits?: number;
retryCount?: number;
maxRetries?: number;
},
): string {
const unitLabel = context.currentUnitId
? ` ${context.currentUnitId}`
: "";
const progressLabel = context.completedUnits !== undefined && context.totalUnits !== undefined
? ` (${context.completedUnits} of ${context.totalUnits} done)`
: "";
switch (level) {
case "green":
return `Progressing well —${unitLabel}${progressLabel}`;
case "yellow": {
const issues = signals.filter(s => s.level === "yellow").map(s => s.detail);
const retryInfo = context.retryCount ? `, attempt ${context.retryCount}/${context.maxRetries}` : "";
return `Struggling —${unitLabel}${retryInfo}${progressLabel ? ` ${progressLabel}` : ""}, ${issues[0] ?? "issues detected"}`;
}
case "red": {
const issues = signals.filter(s => s.level === "red").map(s => s.detail);
return `Stuck —${unitLabel}${progressLabel ? ` ${progressLabel}` : ""}, ${issues[0] ?? "critical issues"}`;
}
}
}
/**
* Format progress score as a single-line traffic light for TUI display.
*/
export function formatProgressLine(score: ProgressScore): string {
const icon = score.level === "green" ? "\uD83D\uDFE2"
: score.level === "yellow" ? "\uD83D\uDFE1"
: "\uD83D\uDD34";
return `${icon} ${score.summary}`;
}
/**
* Format a detailed progress report showing all signals.
*/
export function formatProgressReport(score: ProgressScore): string {
const lines: string[] = [];
lines.push(formatProgressLine(score));
lines.push("");
lines.push("Signals:");
for (const signal of score.signals) {
const icon = signal.level === "green" ? "\u2705"
: signal.level === "yellow" ? "\u26A0\uFE0F"
: "\uD83D\uDED1";
lines.push(` ${icon} ${signal.name}: ${signal.detail}`);
}
return lines.join("\n");
}

View file

@ -0,0 +1,314 @@
/**
* doctor-environment.test.ts Tests for environment health checks (#1221).
*
* Tests:
* - Node version detection
* - Dependencies installed check
* - Env file detection
* - Port conflict detection
* - Disk space check
* - Docker detection
* - Project tool detection
* - Doctor issue conversion
* - Report formatting
*/
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { join, dirname } from "node:path";
import { tmpdir } from "node:os";
import {
runEnvironmentChecks,
runFullEnvironmentChecks,
environmentResultsToDoctorIssues,
formatEnvironmentReport,
checkEnvironmentHealth,
type EnvironmentCheckResult,
} from "../doctor-environment.ts";
import { createTestContext } from "./test-helpers.ts";
const { assertEq, assertTrue, assertMatch, report } = createTestContext();
function createProjectDir(files: Record<string, string> = {}): string {
const dir = mkdtempSync(join(tmpdir(), "gsd-env-test-"));
for (const [name, content] of Object.entries(files)) {
const filePath = join(dir, name);
mkdirSync(dirname(filePath), { recursive: true });
writeFileSync(filePath, content);
}
return dir;
}
async function main(): Promise<void> {
const cleanups: string[] = [];
try {
// ── Node Version Check ─────────────────────────────────────────────
console.log("\n=== env: no package.json returns empty ===");
{
const dir = createProjectDir();
cleanups.push(dir);
const results = runEnvironmentChecks(dir);
// No package.json → no node checks
const nodeCheck = results.find(r => r.name === "node_version");
assertEq(nodeCheck, undefined, "no node version check without package.json");
}
console.log("\n=== env: package.json without engines returns no node check ===");
{
const dir = createProjectDir({
"package.json": JSON.stringify({ name: "test", version: "1.0.0" }),
});
cleanups.push(dir);
const results = runEnvironmentChecks(dir);
const nodeCheck = results.find(r => r.name === "node_version");
assertEq(nodeCheck, undefined, "no node version check without engines field");
}
console.log("\n=== env: package.json with engines returns node check ===");
{
const dir = createProjectDir({
"package.json": JSON.stringify({
name: "test",
version: "1.0.0",
engines: { node: ">=18.0.0" },
}),
});
cleanups.push(dir);
const results = runEnvironmentChecks(dir);
const nodeCheck = results.find(r => r.name === "node_version");
assertTrue(nodeCheck !== undefined, "node version check runs with engines field");
// Current node should be >= 18 in CI
assertEq(nodeCheck!.status, "ok", "node version meets requirement");
}
// ── Dependencies Check ─────────────────────────────────────────────
console.log("\n=== env: missing node_modules detected ===");
{
const dir = createProjectDir({
"package.json": JSON.stringify({ name: "test" }),
});
cleanups.push(dir);
const results = runEnvironmentChecks(dir);
const depsCheck = results.find(r => r.name === "dependencies");
assertTrue(depsCheck !== undefined, "dependencies check runs");
assertEq(depsCheck!.status, "error", "missing node_modules is an error");
assertTrue(depsCheck!.message.includes("node_modules missing"), "reports missing node_modules");
}
console.log("\n=== env: existing node_modules detected ===");
{
const dir = createProjectDir({
"package.json": JSON.stringify({ name: "test" }),
});
mkdirSync(join(dir, "node_modules"), { recursive: true });
cleanups.push(dir);
const results = runEnvironmentChecks(dir);
const depsCheck = results.find(r => r.name === "dependencies");
assertTrue(depsCheck !== undefined, "dependencies check runs");
assertEq(depsCheck!.status, "ok", "existing node_modules is ok");
}
// ── Env File Check ─────────────────────────────────────────────────
console.log("\n=== env: .env.example without .env detected ===");
{
const dir = createProjectDir({
".env.example": "DB_URL=xxx\nAPI_KEY=xxx\n",
});
cleanups.push(dir);
const results = runEnvironmentChecks(dir);
const envCheck = results.find(r => r.name === "env_file");
assertTrue(envCheck !== undefined, "env file check runs");
assertEq(envCheck!.status, "warning", "missing .env is a warning");
}
console.log("\n=== env: .env.example with .env is ok ===");
{
const dir = createProjectDir({
".env.example": "DB_URL=xxx\n",
".env": "DB_URL=postgres://localhost/test\n",
});
cleanups.push(dir);
const results = runEnvironmentChecks(dir);
const envCheck = results.find(r => r.name === "env_file");
assertTrue(envCheck !== undefined, "env file check runs");
assertEq(envCheck!.status, "ok", "present .env is ok");
}
console.log("\n=== env: .env.example with .env.local is ok ===");
{
const dir = createProjectDir({
".env.example": "DB_URL=xxx\n",
".env.local": "DB_URL=postgres://localhost/test\n",
});
cleanups.push(dir);
const results = runEnvironmentChecks(dir);
const envCheck = results.find(r => r.name === "env_file");
assertTrue(envCheck !== undefined, "env file check runs");
assertEq(envCheck!.status, "ok", ".env.local counts as present");
}
// ── Disk Space Check ───────────────────────────────────────────────
console.log("\n=== env: disk space check returns result ===");
if (process.platform !== "win32") {
const dir = createProjectDir();
cleanups.push(dir);
const results = runEnvironmentChecks(dir);
const diskCheck = results.find(r => r.name === "disk_space");
assertTrue(diskCheck !== undefined, "disk space check runs on unix");
// Should be ok on dev machines with reasonable disk
assertTrue(diskCheck!.status === "ok" || diskCheck!.status === "warning", "disk check returns valid status");
}
// ── Project Tools Check ────────────────────────────────────────────
console.log("\n=== env: detects missing python when pyproject.toml exists ===");
{
const dir = createProjectDir({
"package.json": JSON.stringify({ name: "test" }),
"pyproject.toml": "[build-system]\nrequires = ['setuptools']\n",
});
mkdirSync(join(dir, "node_modules"), { recursive: true });
cleanups.push(dir);
const results = runEnvironmentChecks(dir);
const pythonCheck = results.find(r => r.name === "python");
// Python is likely installed on CI/dev machines, so just verify the check runs
// without error — the result depends on the system
assertTrue(true, "python check runs without error");
}
console.log("\n=== env: detects Cargo.toml ===");
{
const dir = createProjectDir({
"package.json": JSON.stringify({ name: "test" }),
"Cargo.toml": "[package]\nname = 'test'\n",
});
mkdirSync(join(dir, "node_modules"), { recursive: true });
cleanups.push(dir);
const results = runEnvironmentChecks(dir);
// Just verify it runs without error
assertTrue(true, "cargo check runs without error");
}
// ── Docker Check ───────────────────────────────────────────────────
console.log("\n=== env: no docker check without Dockerfile ===");
{
const dir = createProjectDir({
"package.json": JSON.stringify({ name: "test" }),
});
mkdirSync(join(dir, "node_modules"), { recursive: true });
cleanups.push(dir);
const results = runEnvironmentChecks(dir);
const dockerCheck = results.find(r => r.name === "docker");
assertEq(dockerCheck, undefined, "no docker check without Dockerfile");
}
console.log("\n=== env: docker check with Dockerfile ===");
{
const dir = createProjectDir({
"package.json": JSON.stringify({ name: "test" }),
"Dockerfile": "FROM node:22\n",
});
mkdirSync(join(dir, "node_modules"), { recursive: true });
cleanups.push(dir);
const results = runEnvironmentChecks(dir);
const dockerCheck = results.find(r => r.name === "docker");
// Docker may or may not be installed on the test machine
assertTrue(dockerCheck !== undefined, "docker check runs when Dockerfile present");
}
// ── Doctor Issue Conversion ────────────────────────────────────────
console.log("\n=== env: converts results to doctor issues ===");
{
const results: EnvironmentCheckResult[] = [
{ name: "node_version", status: "ok", message: "Node.js v22.0.0" },
{ name: "dependencies", status: "error", message: "node_modules missing" },
{ name: "env_file", status: "warning", message: ".env missing", detail: "Copy .env.example" },
];
const issues = environmentResultsToDoctorIssues(results);
assertEq(issues.length, 2, "only non-ok results converted");
assertEq(issues[0]!.severity, "error", "error severity preserved");
assertEq(issues[0]!.code, "env_dependencies", "code prefixed with env_");
assertEq(issues[1]!.severity, "warning", "warning severity preserved");
assertTrue(issues[1]!.message.includes("Copy .env.example"), "detail included in message");
}
// ── checkEnvironmentHealth integration ──────────────────────────────
console.log("\n=== env: checkEnvironmentHealth adds issues to array ===");
{
const dir = createProjectDir({
"package.json": JSON.stringify({ name: "test" }),
});
cleanups.push(dir);
const issues: any[] = [];
await checkEnvironmentHealth(dir, issues);
// Should have at least the missing node_modules issue
assertTrue(issues.some(i => i.code === "env_dependencies"), "environment issues added to array");
}
// ── Report Formatting ──────────────────────────────────────────────
console.log("\n=== env: formatEnvironmentReport ===");
{
const results: EnvironmentCheckResult[] = [
{ name: "node_version", status: "ok", message: "Node.js v22.0.0" },
{ name: "dependencies", status: "error", message: "node_modules missing", detail: "Run npm install" },
{ name: "disk_space", status: "ok", message: "50.2GB free" },
];
const report = formatEnvironmentReport(results);
assertTrue(report.includes("Environment Health:"), "has header");
assertTrue(report.includes("Node.js v22.0.0"), "includes ok result");
assertTrue(report.includes("node_modules missing"), "includes error result");
assertTrue(report.includes("Run npm install"), "includes detail for errors");
}
console.log("\n=== env: formatEnvironmentReport empty ===");
{
const report = formatEnvironmentReport([]);
assertEq(report, "No environment checks applicable.", "empty report message");
}
// ── Full environment checks include git remote ─────────────────────
console.log("\n=== env: runFullEnvironmentChecks includes git remote ===");
{
// runFullEnvironmentChecks adds git remote check
// We can't easily test this without a real git repo, but verify it doesn't throw
const dir = createProjectDir();
cleanups.push(dir);
const results = runFullEnvironmentChecks(dir);
// No git repo → no remote check, but should not throw
assertTrue(true, "runFullEnvironmentChecks does not throw on non-git dir");
}
// ── Port Detection from package.json ───────────────────────────────
console.log("\n=== env: port detection from scripts ===");
if (process.platform !== "win32") {
const dir = createProjectDir({
"package.json": JSON.stringify({
name: "test",
scripts: {
dev: "next dev --port 3456",
start: "node server.js",
},
}),
});
mkdirSync(join(dir, "node_modules"), { recursive: true });
cleanups.push(dir);
const results = runEnvironmentChecks(dir);
// Port 3456 is unlikely to be in use, so no conflicts expected
const portConflicts = results.filter(r => r.name === "port_conflict");
// Just verify it ran without error
assertTrue(true, "port check with script-detected ports runs without error");
}
} finally {
for (const dir of cleanups) {
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
}
}
report();
}
main();

View file

@ -7,7 +7,7 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, rmSync, existsSync, readdirSync, readFileSync } from "node:fs";
import { mkdtempSync, rmSync, existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
@ -23,7 +23,11 @@ function createCtx(entries: unknown[]) {
test("clearActivityLogState resets dedup state so identical saves write again", () => {
clearActivityLogState();
const baseDir = mkdtempSync(join(tmpdir(), "gsd-memleak-test-"));
// Pre-resolve baseDir so gsdRoot() returns a stable key across calls.
// On macOS, /tmp is a symlink to /private/tmp — without realpathSync, the
// key changes between the first save (dir doesn't exist, realpathSync throws)
// and subsequent saves (dir exists, realpathSync resolves to /private/tmp/...).
const baseDir = realpathSync(mkdtempSync(join(tmpdir(), "gsd-memleak-test-")));
try {
const entries = [{ role: "assistant", content: "test entry" }];
const ctx = createCtx(entries);
@ -53,7 +57,7 @@ test("clearActivityLogState resets dedup state so identical saves write again",
test("saveActivityLog writes valid JSONL via streaming", () => {
clearActivityLogState();
const baseDir = mkdtempSync(join(tmpdir(), "gsd-memleak-jsonl-"));
const baseDir = realpathSync(mkdtempSync(join(tmpdir(), "gsd-memleak-jsonl-")));
try {
const entries = [
{ type: "message", message: { role: "user", content: "hello" } },

View file

@ -0,0 +1,206 @@
/**
* progress-score.test.ts Tests for progress score / traffic light (#1221).
*
* Tests:
* - Score computation from health signals
* - Signal evaluation (trend, error streak, recent errors)
* - Context-aware scoring (retry counts, unit progress)
* - Formatting (single-line, detailed report)
*/
import {
recordHealthSnapshot,
resetProactiveHealing,
} from "../doctor-proactive.ts";
import {
computeProgressScore,
computeProgressScoreWithContext,
formatProgressLine,
formatProgressReport,
} from "../progress-score.ts";
import { createTestContext } from "./test-helpers.ts";
const { assertEq, assertTrue, assertMatch, report } = createTestContext();
async function main(): Promise<void> {
try {
// ── Base Score: No Data ─────────────────────────────────────────────
console.log("\n=== progress: green with no data ===");
{
resetProactiveHealing();
const score = computeProgressScore();
assertEq(score.level, "green", "green when no data available");
assertTrue(score.summary.includes("Progressing well"), "summary says progressing");
assertTrue(score.signals.length > 0, "has signals");
}
// ── Green: Clean Health Data ────────────────────────────────────────
console.log("\n=== progress: green with clean health ===");
{
resetProactiveHealing();
for (let i = 0; i < 5; i++) {
recordHealthSnapshot(0, 0, 0);
}
const score = computeProgressScore();
assertEq(score.level, "green", "green with all clean snapshots");
}
// ── Yellow: Some Warnings ──────────────────────────────────────────
console.log("\n=== progress: yellow with error streak ===");
{
resetProactiveHealing();
recordHealthSnapshot(1, 2, 0);
recordHealthSnapshot(1, 1, 0);
const score = computeProgressScore();
assertEq(score.level, "yellow", "yellow with consecutive errors");
assertTrue(score.summary.includes("Struggling"), "summary says struggling");
}
// ── Red: Degrading Health ──────────────────────────────────────────
console.log("\n=== progress: red with degrading trend ===");
{
resetProactiveHealing();
// 5 older clean snapshots
for (let i = 0; i < 5; i++) {
recordHealthSnapshot(0, 0, 0);
}
// 5 recent error snapshots — triggers degrading trend
for (let i = 0; i < 5; i++) {
recordHealthSnapshot(3, 5, 0);
}
const score = computeProgressScore();
assertEq(score.level, "red", "red with degrading trend and persistent errors");
assertTrue(score.summary.includes("Stuck"), "summary says stuck");
}
// ── Red: High Error Streak ─────────────────────────────────────────
console.log("\n=== progress: red with high error streak ===");
{
resetProactiveHealing();
for (let i = 0; i < 4; i++) {
recordHealthSnapshot(2, 0, 0);
}
const score = computeProgressScore();
assertEq(score.level, "red", "red with 4 consecutive error units");
}
// ── Context-Aware Scoring ──────────────────────────────────────────
console.log("\n=== progress: context with retries ===");
{
resetProactiveHealing();
for (let i = 0; i < 3; i++) {
recordHealthSnapshot(0, 0, 0);
}
const score = computeProgressScoreWithContext({
currentUnitId: "M001/S01/T03",
completedUnits: 2,
totalUnits: 5,
retryCount: 0,
maxRetries: 5,
});
assertEq(score.level, "green", "green with no retries");
assertTrue(score.summary.includes("M001/S01/T03"), "summary includes unit ID");
assertTrue(score.summary.includes("2 of 5"), "summary includes progress");
}
console.log("\n=== progress: context with high retry count ===");
{
resetProactiveHealing();
for (let i = 0; i < 3; i++) {
recordHealthSnapshot(0, 0, 0);
}
const score = computeProgressScoreWithContext({
currentUnitId: "M001/S01/T03",
retryCount: 4,
maxRetries: 5,
});
assertEq(score.level, "red", "red with high retry count");
assertTrue(score.summary.includes("looping"), "summary mentions looping");
}
console.log("\n=== progress: context with moderate retries ===");
{
resetProactiveHealing();
for (let i = 0; i < 3; i++) {
recordHealthSnapshot(0, 0, 0);
}
const score = computeProgressScoreWithContext({
currentUnitId: "M001/S01/T03",
retryCount: 1,
maxRetries: 5,
});
assertEq(score.level, "yellow", "yellow with 1 retry");
}
// ── Formatting ─────────────────────────────────────────────────────
console.log("\n=== progress: formatProgressLine ===");
{
resetProactiveHealing();
const score = computeProgressScore();
const line = formatProgressLine(score);
assertTrue(line.includes("Progressing well"), "line includes summary");
// Should start with green circle emoji
assertTrue(line.startsWith("\uD83D\uDFE2"), "starts with green circle");
}
console.log("\n=== progress: formatProgressLine yellow ===");
{
resetProactiveHealing();
recordHealthSnapshot(1, 0, 0);
recordHealthSnapshot(1, 0, 0);
const score = computeProgressScore();
const line = formatProgressLine(score);
assertTrue(line.startsWith("\uD83D\uDFE1"), "starts with yellow circle");
}
console.log("\n=== progress: formatProgressReport ===");
{
resetProactiveHealing();
recordHealthSnapshot(0, 1, 0);
const score = computeProgressScore();
const detailed = formatProgressReport(score);
assertTrue(detailed.includes("Signals:"), "report has signals section");
assertTrue(detailed.includes("health_trend"), "report includes trend signal");
assertTrue(detailed.includes("error_streak"), "report includes streak signal");
}
// ── Signal Details ─────────────────────────────────────────────────
console.log("\n=== progress: signal names are consistent ===");
{
resetProactiveHealing();
recordHealthSnapshot(0, 0, 0);
const score = computeProgressScore();
const names = score.signals.map(s => s.name);
assertTrue(names.includes("health_trend"), "has health_trend signal");
assertTrue(names.includes("error_streak"), "has error_streak signal");
assertTrue(names.includes("recent_errors"), "has recent_errors signal");
assertTrue(names.includes("artifact_production"), "has artifact_production signal");
assertTrue(names.includes("dispatch_velocity"), "has dispatch_velocity signal");
}
console.log("\n=== progress: all signals have valid levels ===");
{
resetProactiveHealing();
for (let i = 0; i < 5; i++) {
recordHealthSnapshot(1, 1, 1);
}
const score = computeProgressScore();
for (const signal of score.signals) {
assertTrue(
signal.level === "green" || signal.level === "yellow" || signal.level === "red",
`signal ${signal.name} has valid level: ${signal.level}`,
);
assertTrue(signal.detail.length > 0, `signal ${signal.name} has non-empty detail`);
}
}
} finally {
resetProactiveHealing();
}
report();
}
main();