From 652ac385b7923e72fd16894b7526060ad7f4fe85 Mon Sep 17 00:00:00 2001 From: Jeremy McSpadden Date: Wed, 18 Mar 2026 19:04:14 -0500 Subject: [PATCH] feat: environment health checks, progress score, and status integration (#1263) --- .secretscanignore | 3 + src/resources/extensions/gsd/commands.ts | 19 + .../extensions/gsd/dashboard-overlay.ts | 28 + .../extensions/gsd/doctor-environment.ts | 497 ++++++++++++++++++ src/resources/extensions/gsd/doctor-types.ts | 15 +- src/resources/extensions/gsd/doctor.ts | 6 + .../extensions/gsd/progress-score.ts | 273 ++++++++++ .../gsd/tests/doctor-environment.test.ts | 314 +++++++++++ .../gsd/tests/memory-leak-guards.test.ts | 10 +- .../gsd/tests/progress-score.test.ts | 206 ++++++++ 10 files changed, 1367 insertions(+), 4 deletions(-) create mode 100644 src/resources/extensions/gsd/doctor-environment.ts create mode 100644 src/resources/extensions/gsd/progress-score.ts create mode 100644 src/resources/extensions/gsd/tests/doctor-environment.test.ts create mode 100644 src/resources/extensions/gsd/tests/progress-score.test.ts diff --git a/.secretscanignore b/.secretscanignore index 94cd201c7..6c08b9a7e 100644 --- a/.secretscanignore +++ b/.secretscanignore @@ -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)_ diff --git a/src/resources/extensions/gsd/commands.ts b/src/resources/extensions/gsd/commands.ts index a89c8ea50..bd8ea6bac 100644 --- a/src/resources/extensions/gsd/commands.ts +++ b/src/resources/extensions/gsd/commands.ts @@ -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"); } diff --git a/src/resources/extensions/gsd/dashboard-overlay.ts b/src/resources/extensions/gsd/dashboard-overlay.ts index e9ba83d08..9c4ca6bdb 100644 --- a/src/resources/extensions/gsd/dashboard-overlay.ts +++ b/src/resources/extensions/gsd/dashboard-overlay.ts @@ -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"))); diff --git a/src/resources/extensions/gsd/doctor-environment.ts b/src/resources/extensions/gsd/doctor-environment.ts new file mode 100644 index 000000000..999fa3d8d --- /dev/null +++ b/src/resources/extensions/gsd/doctor-environment.ts @@ -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(); + 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 { + 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"); +} diff --git a/src/resources/extensions/gsd/doctor-types.ts b/src/resources/extensions/gsd/doctor-types.ts index 6bf6c6954..dd4cafd7a 100644 --- a/src/resources/extensions/gsd/doctor-types.ts +++ b/src/resources/extensions/gsd/doctor-types.ts @@ -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. diff --git a/src/resources/extensions/gsd/doctor.ts b/src/resources/extensions/gsd/doctor.ts index 9fd0e0055..2138a1586 100644 --- a/src/resources/extensions/gsd/doctor.ts +++ b/src/resources/extensions/gsd/doctor.ts @@ -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 }; diff --git a/src/resources/extensions/gsd/progress-score.ts b/src/resources/extensions/gsd/progress-score.ts new file mode 100644 index 000000000..8584763e8 --- /dev/null +++ b/src/resources/extensions/gsd/progress-score.ts @@ -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"); +} diff --git a/src/resources/extensions/gsd/tests/doctor-environment.test.ts b/src/resources/extensions/gsd/tests/doctor-environment.test.ts new file mode 100644 index 000000000..cc7f396a7 --- /dev/null +++ b/src/resources/extensions/gsd/tests/doctor-environment.test.ts @@ -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 { + 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 { + 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(); diff --git a/src/resources/extensions/gsd/tests/memory-leak-guards.test.ts b/src/resources/extensions/gsd/tests/memory-leak-guards.test.ts index 305d1fc50..28fd767ce 100644 --- a/src/resources/extensions/gsd/tests/memory-leak-guards.test.ts +++ b/src/resources/extensions/gsd/tests/memory-leak-guards.test.ts @@ -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" } }, diff --git a/src/resources/extensions/gsd/tests/progress-score.test.ts b/src/resources/extensions/gsd/tests/progress-score.test.ts new file mode 100644 index 000000000..65096c68e --- /dev/null +++ b/src/resources/extensions/gsd/tests/progress-score.test.ts @@ -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 { + 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();