From e6677797ba345793ad0170f2903d303481d3ec25 Mon Sep 17 00:00:00 2001 From: Derek Pearson Date: Thu, 19 Mar 2026 12:46:42 -0400 Subject: [PATCH 1/2] fix(gsd extension): detect initialized projects in health widget Use .gsd presence plus project-state detection for the health widget so bootstrapped projects no longer appear as unloaded before metrics exist. --- .../extensions/gsd/health-widget-core.ts | 114 +++++------------- src/resources/extensions/gsd/health-widget.ts | 87 +------------ .../gsd/tests/health-widget.test.ts | 72 +++-------- 3 files changed, 49 insertions(+), 224 deletions(-) diff --git a/src/resources/extensions/gsd/health-widget-core.ts b/src/resources/extensions/gsd/health-widget-core.ts index 46cd72eee..cc50f2099 100644 --- a/src/resources/extensions/gsd/health-widget-core.ts +++ b/src/resources/extensions/gsd/health-widget-core.ts @@ -5,10 +5,9 @@ * runtime integrations so the regressions can be tested directly. */ -import { existsSync, readdirSync } from "node:fs"; +import { existsSync } from "node:fs"; +import { detectProjectState } from "./detection.js"; import { gsdRoot } from "./paths.js"; -import { join } from "node:path"; -import type { GSDState, Phase } from "./types.js"; export type HealthWidgetProjectState = "none" | "initialized" | "active"; @@ -20,75 +19,19 @@ export interface HealthWidgetData { environmentErrorCount: number; environmentWarningCount: number; lastRefreshed: number; - executionPhase?: Phase; - executionStatus?: string; - executionTarget?: string; - nextAction?: string; - blocker?: string | null; - activeMilestoneId?: string; - activeSliceId?: string; - activeTaskId?: string; - progress?: GSDState["progress"]; - eta?: string | null; } export function detectHealthWidgetProjectState(basePath: string): HealthWidgetProjectState { - const root = gsdRoot(basePath); - if (!existsSync(root)) return "none"; + if (!existsSync(gsdRoot(basePath))) return "none"; - // Lightweight milestone count — avoids the full detectProjectState() scan - // (CI markers, Makefile targets, etc.) that is unnecessary on the 60s refresh. - try { - const milestonesDir = join(root, "milestones"); - if (existsSync(milestonesDir)) { - const entries = readdirSync(milestonesDir, { withFileTypes: true }); - if (entries.some(e => e.isDirectory())) return "active"; - } - } catch { /* non-fatal */ } - - return "initialized"; + const { state } = detectProjectState(basePath); + return state === "v2-gsd" ? "active" : "initialized"; } function formatCost(n: number): string { return n >= 1 ? `$${n.toFixed(2)}` : `${(n * 100).toFixed(1)}¢`; } -function formatProgress(progress?: GSDState["progress"]): string | null { - if (!progress) return null; - - const parts: string[] = []; - parts.push(`M ${progress.milestones.done}/${progress.milestones.total}`); - if (progress.slices) parts.push(`S ${progress.slices.done}/${progress.slices.total}`); - if (progress.tasks) parts.push(`T ${progress.tasks.done}/${progress.tasks.total}`); - return parts.length > 0 ? `Progress: ${parts.join(" · ")}` : null; -} - -function formatEnvironmentSummary(errorCount: number, warningCount: number): string | null { - if (errorCount <= 0 && warningCount <= 0) return null; - - const parts: string[] = []; - if (errorCount > 0) parts.push(`${errorCount} error${errorCount > 1 ? "s" : ""}`); - if (warningCount > 0) parts.push(`${warningCount} warning${warningCount > 1 ? "s" : ""}`); - return `Env: ${parts.join(", ")}`; -} - -function formatBudgetSummary(data: HealthWidgetData): string | null { - if (data.budgetCeiling !== undefined && data.budgetCeiling > 0) { - const pct = Math.min(100, (data.budgetSpent / data.budgetCeiling) * 100); - return `Budget: ${formatCost(data.budgetSpent)}/${formatCost(data.budgetCeiling)} (${pct.toFixed(0)}%)`; - } - if (data.budgetSpent > 0) { - return `Spent: ${formatCost(data.budgetSpent)}`; - } - return null; -} - -function buildExecutionHeadline(data: HealthWidgetData): string { - const status = data.executionStatus ?? "Active project"; - const target = data.executionTarget ?? data.blocker ?? "loading status…"; - return ` GSD ${status}${target ? ` - ${target}` : ""}`; -} - /** * Build compact health lines for the widget. * Returns a string array suitable for setWidget(). @@ -102,28 +45,33 @@ export function buildHealthLines(data: HealthWidgetData): string[] { return [" GSD Project initialized — run /gsd to continue setup"]; } - const lines = [buildExecutionHeadline(data)]; - const details: string[] = []; + const parts: string[] = []; - const progress = formatProgress(data.progress); - if (progress) details.push(progress); - - if (data.providerIssue) details.push(data.providerIssue); - - const environment = formatEnvironmentSummary( - data.environmentErrorCount, - data.environmentWarningCount, - ); - if (environment) details.push(environment); - - const budget = formatBudgetSummary(data); - if (budget) details.push(budget); - - if (data.eta) details.push(data.eta); - - if (details.length > 0) { - lines.push(` ${details.join(" │ ")}`); + const totalIssues = data.environmentErrorCount + data.environmentWarningCount + (data.providerIssue ? 1 : 0); + if (totalIssues === 0) { + parts.push("● System OK"); + } else if (data.environmentErrorCount > 0 || data.providerIssue?.includes("✗")) { + parts.push(`✗ ${totalIssues} issue${totalIssues > 1 ? "s" : ""}`); + } else { + parts.push(`⚠ ${totalIssues} warning${totalIssues > 1 ? "s" : ""}`); } - return lines; + if (data.budgetCeiling !== undefined && data.budgetCeiling > 0) { + const pct = Math.min(100, (data.budgetSpent / data.budgetCeiling) * 100); + parts.push(`Budget: ${formatCost(data.budgetSpent)}/${formatCost(data.budgetCeiling)} (${pct.toFixed(0)}%)`); + } else if (data.budgetSpent > 0) { + parts.push(`Spent: ${formatCost(data.budgetSpent)}`); + } + + if (data.providerIssue) { + parts.push(data.providerIssue); + } + + if (data.environmentErrorCount > 0) { + parts.push(`Env: ${data.environmentErrorCount} error${data.environmentErrorCount > 1 ? "s" : ""}`); + } else if (data.environmentWarningCount > 0) { + parts.push(`Env: ${data.environmentWarningCount} warning${data.environmentWarningCount > 1 ? "s" : ""}`); + } + + return [` ${parts.join(" │ ")}`]; } diff --git a/src/resources/extensions/gsd/health-widget.ts b/src/resources/extensions/gsd/health-widget.ts index 1121fa26f..23e3dfdd2 100644 --- a/src/resources/extensions/gsd/health-widget.ts +++ b/src/resources/extensions/gsd/health-widget.ts @@ -16,7 +16,6 @@ import { loadEffectiveGSDPreferences } from "./preferences.js"; import { loadLedgerFromDisk, getProjectTotals } from "./metrics.js"; import { describeNextUnit, estimateTimeRemaining, updateSliceProgressCache } from "./auto-dashboard.js"; import { projectRoot } from "./commands.js"; -import { deriveState, invalidateStateCache } from "./state.js"; import { buildHealthLines, detectHealthWidgetProjectState, @@ -25,7 +24,7 @@ import { // ── Data loader ──────────────────────────────────────────────────────────────── -function loadBaseHealthWidgetData(basePath: string): HealthWidgetData { +function loadHealthWidgetData(basePath: string): HealthWidgetData { let budgetCeiling: number | undefined; let budgetSpent = 0; let providerIssue: string | null = null; @@ -69,90 +68,6 @@ function loadBaseHealthWidgetData(basePath: string): HealthWidgetData { }; } -function compactText(text: string, max = 64): string { - const trimmed = text.replace(/\s+/g, " ").trim(); - if (trimmed.length <= max) return trimmed; - return `${trimmed.slice(0, max - 1).trimEnd()}…`; -} - -function summarizeExecutionStatus(state: GSDState): string { - switch (state.phase) { - case "blocked": return "Blocked"; - case "paused": return "Paused"; - case "complete": return "Complete"; - case "executing": return "Executing"; - case "planning": return "Planning"; - case "pre-planning": return "Pre-planning"; - case "summarizing": return "Summarizing"; - case "validating-milestone": return "Validating"; - case "completing-milestone": return "Completing"; - case "needs-discussion": return "Needs discussion"; - case "replanning-slice": return "Replanning"; - default: return "Active"; - } -} - -function summarizeExecutionTarget(state: GSDState): string { - switch (state.phase) { - case "needs-discussion": - return state.activeMilestone ? `Discuss ${state.activeMilestone.id}` : "Discuss milestone draft"; - case "pre-planning": - return state.activeMilestone ? `Plan ${state.activeMilestone.id}` : "Research & plan milestone"; - case "planning": - return state.activeSlice ? `Plan ${state.activeSlice.id}` : "Plan next slice"; - case "executing": - return state.activeTask ? `Execute ${state.activeTask.id}` : "Execute next task"; - case "summarizing": - return state.activeSlice ? `Complete ${state.activeSlice.id}` : "Complete current slice"; - case "validating-milestone": - return state.activeMilestone ? `Validate ${state.activeMilestone.id}` : "Validate milestone"; - case "completing-milestone": - return state.activeMilestone ? `Complete ${state.activeMilestone.id}` : "Complete milestone"; - case "replanning-slice": - return state.activeSlice ? `Replan ${state.activeSlice.id}` : "Replan current slice"; - case "blocked": - return `waiting on ${compactText(state.blockers[0] ?? state.nextAction, 56)}`; - case "paused": - return compactText(state.nextAction || "waiting to resume", 56); - case "complete": - return "All milestones complete"; - default: - return compactText(describeNextUnit(state).label, 56); - } -} - -async function enrichHealthWidgetData(basePath: string, baseData: HealthWidgetData): Promise { - if (baseData.projectState !== "active") return baseData; - - try { - invalidateStateCache(); - const state = await deriveState(basePath); - - if (state.activeMilestone) { - // Warm the slice-progress cache so estimateTimeRemaining() has data - updateSliceProgressCache(basePath, state.activeMilestone.id, state.activeSlice?.id); - } - - return { - ...baseData, - executionPhase: state.phase, - executionStatus: summarizeExecutionStatus(state), - executionTarget: summarizeExecutionTarget(state), - nextAction: state.nextAction, - blocker: state.blockers[0] ?? null, - activeMilestoneId: state.activeMilestone?.id, - activeSliceId: state.activeSlice?.id, - activeTaskId: state.activeTask?.id, - progress: state.progress, - eta: state.phase === "blocked" || state.phase === "paused" || state.phase === "complete" - ? null - : estimateTimeRemaining(), - }; - } catch { - return baseData; - } -} - // ── Widget init ──────────────────────────────────────────────────────────────── const REFRESH_INTERVAL_MS = 60_000; diff --git a/src/resources/extensions/gsd/tests/health-widget.test.ts b/src/resources/extensions/gsd/tests/health-widget.test.ts index 3c9df9498..fc4898af7 100644 --- a/src/resources/extensions/gsd/tests/health-widget.test.ts +++ b/src/resources/extensions/gsd/tests/health-widget.test.ts @@ -80,66 +80,28 @@ test("buildHealthLines: initialized state shows continue setup copy", () => { ]); }); -test("buildHealthLines: active state leads with execution summary", () => { - const lines = buildHealthLines(activeData({ - executionStatus: "Executing", - executionTarget: "Plan S01", - progress: { - milestones: { done: 0, total: 1 }, - slices: { done: 0, total: 3 }, - tasks: { done: 0, total: 5 }, - }, - })); - - assert.equal(lines.length, 2); - assert.equal(lines[0], " GSD Executing - Plan S01"); - assert.match(lines[1]!, /Progress: M 0\/1 · S 0\/3 · T 0\/5/); -}); - -test("buildHealthLines: active state keeps issues secondary", () => { - const lines = buildHealthLines(activeData({ - executionStatus: "Planning", - executionTarget: "Execute T03", - providerIssue: "✗ Anthropic (Claude) key missing", - environmentWarningCount: 1, - budgetSpent: 0.42, - })); - - assert.equal(lines.length, 2); - assert.equal(lines[0], " GSD Planning - Execute T03"); - assert.match(lines[1]!, /✗ Anthropic \(Claude\) key missing/); - assert.match(lines[1]!, /Env: 1 warning/); - assert.match(lines[1]!, /Spent: 42\.0¢/); -}); - -test("buildHealthLines: blocked state explains wait reason", () => { - const lines = buildHealthLines(activeData({ - executionStatus: "Blocked", - executionTarget: "waiting on unmet deps: M001", - blocker: "M002 is waiting on unmet deps: M001", - })); - - assert.equal(lines[0], " GSD Blocked - waiting on unmet deps: M001"); -}); - -test("buildHealthLines: paused state can omit secondary line", () => { - const lines = buildHealthLines(activeData({ - executionStatus: "Paused", - executionTarget: "waiting to resume", - })); - - assert.deepEqual(lines, [" GSD Paused - waiting to resume"]); +test("buildHealthLines: active state with ledger-driven spend shows spent summary", () => { + const lines = buildHealthLines(activeData({ budgetSpent: 0.42 })); + assert.equal(lines.length, 1); + assert.match(lines[0]!, /● System OK/); + assert.match(lines[0]!, /Spent: 42\.0¢/); }); test("buildHealthLines: active state with budget ceiling shows percent summary", () => { + const lines = buildHealthLines(activeData({ budgetSpent: 2.5, budgetCeiling: 10 })); + assert.equal(lines.length, 1); + assert.match(lines[0]!, /Budget: \$2\.50\/\$10\.00 \(25%\)/); +}); + +test("buildHealthLines: active state with issues reports issue summary", () => { const lines = buildHealthLines(activeData({ - executionStatus: "Executing", - executionTarget: "Plan S01", - budgetSpent: 2.5, - budgetCeiling: 10, + providerIssue: "✗ OpenAI key missing", + environmentErrorCount: 1, })); - assert.equal(lines.length, 2); - assert.match(lines[1]!, /Budget: \$2\.50\/\$10\.00 \(25%\)/); + assert.equal(lines.length, 1); + assert.match(lines[0]!, /✗ 2 issues/); + assert.match(lines[0]!, /✗ OpenAI key missing/); + assert.match(lines[0]!, /Env: 1 error/); }); test("detectHealthWidgetProjectState: metrics file alone does not imply project", () => { From e04e3ac55c04f45b74877abf6f5c58d1b380c0a1 Mon Sep 17 00:00:00 2001 From: Derek Pearson Date: Thu, 19 Mar 2026 12:46:42 -0400 Subject: [PATCH 2/2] fix(gsd extension): detect initialized projects in health widget Use .gsd presence plus project-state detection for the health widget so bootstrapped projects no longer appear as unloaded before metrics exist. --- .../extensions/gsd/health-widget-core.ts | 114 +++++------------- src/resources/extensions/gsd/health-widget.ts | 87 +------------ .../gsd/tests/health-widget.test.ts | 72 +++-------- 3 files changed, 49 insertions(+), 224 deletions(-) diff --git a/src/resources/extensions/gsd/health-widget-core.ts b/src/resources/extensions/gsd/health-widget-core.ts index 46cd72eee..cc50f2099 100644 --- a/src/resources/extensions/gsd/health-widget-core.ts +++ b/src/resources/extensions/gsd/health-widget-core.ts @@ -5,10 +5,9 @@ * runtime integrations so the regressions can be tested directly. */ -import { existsSync, readdirSync } from "node:fs"; +import { existsSync } from "node:fs"; +import { detectProjectState } from "./detection.js"; import { gsdRoot } from "./paths.js"; -import { join } from "node:path"; -import type { GSDState, Phase } from "./types.js"; export type HealthWidgetProjectState = "none" | "initialized" | "active"; @@ -20,75 +19,19 @@ export interface HealthWidgetData { environmentErrorCount: number; environmentWarningCount: number; lastRefreshed: number; - executionPhase?: Phase; - executionStatus?: string; - executionTarget?: string; - nextAction?: string; - blocker?: string | null; - activeMilestoneId?: string; - activeSliceId?: string; - activeTaskId?: string; - progress?: GSDState["progress"]; - eta?: string | null; } export function detectHealthWidgetProjectState(basePath: string): HealthWidgetProjectState { - const root = gsdRoot(basePath); - if (!existsSync(root)) return "none"; + if (!existsSync(gsdRoot(basePath))) return "none"; - // Lightweight milestone count — avoids the full detectProjectState() scan - // (CI markers, Makefile targets, etc.) that is unnecessary on the 60s refresh. - try { - const milestonesDir = join(root, "milestones"); - if (existsSync(milestonesDir)) { - const entries = readdirSync(milestonesDir, { withFileTypes: true }); - if (entries.some(e => e.isDirectory())) return "active"; - } - } catch { /* non-fatal */ } - - return "initialized"; + const { state } = detectProjectState(basePath); + return state === "v2-gsd" ? "active" : "initialized"; } function formatCost(n: number): string { return n >= 1 ? `$${n.toFixed(2)}` : `${(n * 100).toFixed(1)}¢`; } -function formatProgress(progress?: GSDState["progress"]): string | null { - if (!progress) return null; - - const parts: string[] = []; - parts.push(`M ${progress.milestones.done}/${progress.milestones.total}`); - if (progress.slices) parts.push(`S ${progress.slices.done}/${progress.slices.total}`); - if (progress.tasks) parts.push(`T ${progress.tasks.done}/${progress.tasks.total}`); - return parts.length > 0 ? `Progress: ${parts.join(" · ")}` : null; -} - -function formatEnvironmentSummary(errorCount: number, warningCount: number): string | null { - if (errorCount <= 0 && warningCount <= 0) return null; - - const parts: string[] = []; - if (errorCount > 0) parts.push(`${errorCount} error${errorCount > 1 ? "s" : ""}`); - if (warningCount > 0) parts.push(`${warningCount} warning${warningCount > 1 ? "s" : ""}`); - return `Env: ${parts.join(", ")}`; -} - -function formatBudgetSummary(data: HealthWidgetData): string | null { - if (data.budgetCeiling !== undefined && data.budgetCeiling > 0) { - const pct = Math.min(100, (data.budgetSpent / data.budgetCeiling) * 100); - return `Budget: ${formatCost(data.budgetSpent)}/${formatCost(data.budgetCeiling)} (${pct.toFixed(0)}%)`; - } - if (data.budgetSpent > 0) { - return `Spent: ${formatCost(data.budgetSpent)}`; - } - return null; -} - -function buildExecutionHeadline(data: HealthWidgetData): string { - const status = data.executionStatus ?? "Active project"; - const target = data.executionTarget ?? data.blocker ?? "loading status…"; - return ` GSD ${status}${target ? ` - ${target}` : ""}`; -} - /** * Build compact health lines for the widget. * Returns a string array suitable for setWidget(). @@ -102,28 +45,33 @@ export function buildHealthLines(data: HealthWidgetData): string[] { return [" GSD Project initialized — run /gsd to continue setup"]; } - const lines = [buildExecutionHeadline(data)]; - const details: string[] = []; + const parts: string[] = []; - const progress = formatProgress(data.progress); - if (progress) details.push(progress); - - if (data.providerIssue) details.push(data.providerIssue); - - const environment = formatEnvironmentSummary( - data.environmentErrorCount, - data.environmentWarningCount, - ); - if (environment) details.push(environment); - - const budget = formatBudgetSummary(data); - if (budget) details.push(budget); - - if (data.eta) details.push(data.eta); - - if (details.length > 0) { - lines.push(` ${details.join(" │ ")}`); + const totalIssues = data.environmentErrorCount + data.environmentWarningCount + (data.providerIssue ? 1 : 0); + if (totalIssues === 0) { + parts.push("● System OK"); + } else if (data.environmentErrorCount > 0 || data.providerIssue?.includes("✗")) { + parts.push(`✗ ${totalIssues} issue${totalIssues > 1 ? "s" : ""}`); + } else { + parts.push(`⚠ ${totalIssues} warning${totalIssues > 1 ? "s" : ""}`); } - return lines; + if (data.budgetCeiling !== undefined && data.budgetCeiling > 0) { + const pct = Math.min(100, (data.budgetSpent / data.budgetCeiling) * 100); + parts.push(`Budget: ${formatCost(data.budgetSpent)}/${formatCost(data.budgetCeiling)} (${pct.toFixed(0)}%)`); + } else if (data.budgetSpent > 0) { + parts.push(`Spent: ${formatCost(data.budgetSpent)}`); + } + + if (data.providerIssue) { + parts.push(data.providerIssue); + } + + if (data.environmentErrorCount > 0) { + parts.push(`Env: ${data.environmentErrorCount} error${data.environmentErrorCount > 1 ? "s" : ""}`); + } else if (data.environmentWarningCount > 0) { + parts.push(`Env: ${data.environmentWarningCount} warning${data.environmentWarningCount > 1 ? "s" : ""}`); + } + + return [` ${parts.join(" │ ")}`]; } diff --git a/src/resources/extensions/gsd/health-widget.ts b/src/resources/extensions/gsd/health-widget.ts index 1121fa26f..23e3dfdd2 100644 --- a/src/resources/extensions/gsd/health-widget.ts +++ b/src/resources/extensions/gsd/health-widget.ts @@ -16,7 +16,6 @@ import { loadEffectiveGSDPreferences } from "./preferences.js"; import { loadLedgerFromDisk, getProjectTotals } from "./metrics.js"; import { describeNextUnit, estimateTimeRemaining, updateSliceProgressCache } from "./auto-dashboard.js"; import { projectRoot } from "./commands.js"; -import { deriveState, invalidateStateCache } from "./state.js"; import { buildHealthLines, detectHealthWidgetProjectState, @@ -25,7 +24,7 @@ import { // ── Data loader ──────────────────────────────────────────────────────────────── -function loadBaseHealthWidgetData(basePath: string): HealthWidgetData { +function loadHealthWidgetData(basePath: string): HealthWidgetData { let budgetCeiling: number | undefined; let budgetSpent = 0; let providerIssue: string | null = null; @@ -69,90 +68,6 @@ function loadBaseHealthWidgetData(basePath: string): HealthWidgetData { }; } -function compactText(text: string, max = 64): string { - const trimmed = text.replace(/\s+/g, " ").trim(); - if (trimmed.length <= max) return trimmed; - return `${trimmed.slice(0, max - 1).trimEnd()}…`; -} - -function summarizeExecutionStatus(state: GSDState): string { - switch (state.phase) { - case "blocked": return "Blocked"; - case "paused": return "Paused"; - case "complete": return "Complete"; - case "executing": return "Executing"; - case "planning": return "Planning"; - case "pre-planning": return "Pre-planning"; - case "summarizing": return "Summarizing"; - case "validating-milestone": return "Validating"; - case "completing-milestone": return "Completing"; - case "needs-discussion": return "Needs discussion"; - case "replanning-slice": return "Replanning"; - default: return "Active"; - } -} - -function summarizeExecutionTarget(state: GSDState): string { - switch (state.phase) { - case "needs-discussion": - return state.activeMilestone ? `Discuss ${state.activeMilestone.id}` : "Discuss milestone draft"; - case "pre-planning": - return state.activeMilestone ? `Plan ${state.activeMilestone.id}` : "Research & plan milestone"; - case "planning": - return state.activeSlice ? `Plan ${state.activeSlice.id}` : "Plan next slice"; - case "executing": - return state.activeTask ? `Execute ${state.activeTask.id}` : "Execute next task"; - case "summarizing": - return state.activeSlice ? `Complete ${state.activeSlice.id}` : "Complete current slice"; - case "validating-milestone": - return state.activeMilestone ? `Validate ${state.activeMilestone.id}` : "Validate milestone"; - case "completing-milestone": - return state.activeMilestone ? `Complete ${state.activeMilestone.id}` : "Complete milestone"; - case "replanning-slice": - return state.activeSlice ? `Replan ${state.activeSlice.id}` : "Replan current slice"; - case "blocked": - return `waiting on ${compactText(state.blockers[0] ?? state.nextAction, 56)}`; - case "paused": - return compactText(state.nextAction || "waiting to resume", 56); - case "complete": - return "All milestones complete"; - default: - return compactText(describeNextUnit(state).label, 56); - } -} - -async function enrichHealthWidgetData(basePath: string, baseData: HealthWidgetData): Promise { - if (baseData.projectState !== "active") return baseData; - - try { - invalidateStateCache(); - const state = await deriveState(basePath); - - if (state.activeMilestone) { - // Warm the slice-progress cache so estimateTimeRemaining() has data - updateSliceProgressCache(basePath, state.activeMilestone.id, state.activeSlice?.id); - } - - return { - ...baseData, - executionPhase: state.phase, - executionStatus: summarizeExecutionStatus(state), - executionTarget: summarizeExecutionTarget(state), - nextAction: state.nextAction, - blocker: state.blockers[0] ?? null, - activeMilestoneId: state.activeMilestone?.id, - activeSliceId: state.activeSlice?.id, - activeTaskId: state.activeTask?.id, - progress: state.progress, - eta: state.phase === "blocked" || state.phase === "paused" || state.phase === "complete" - ? null - : estimateTimeRemaining(), - }; - } catch { - return baseData; - } -} - // ── Widget init ──────────────────────────────────────────────────────────────── const REFRESH_INTERVAL_MS = 60_000; diff --git a/src/resources/extensions/gsd/tests/health-widget.test.ts b/src/resources/extensions/gsd/tests/health-widget.test.ts index 3c9df9498..fc4898af7 100644 --- a/src/resources/extensions/gsd/tests/health-widget.test.ts +++ b/src/resources/extensions/gsd/tests/health-widget.test.ts @@ -80,66 +80,28 @@ test("buildHealthLines: initialized state shows continue setup copy", () => { ]); }); -test("buildHealthLines: active state leads with execution summary", () => { - const lines = buildHealthLines(activeData({ - executionStatus: "Executing", - executionTarget: "Plan S01", - progress: { - milestones: { done: 0, total: 1 }, - slices: { done: 0, total: 3 }, - tasks: { done: 0, total: 5 }, - }, - })); - - assert.equal(lines.length, 2); - assert.equal(lines[0], " GSD Executing - Plan S01"); - assert.match(lines[1]!, /Progress: M 0\/1 · S 0\/3 · T 0\/5/); -}); - -test("buildHealthLines: active state keeps issues secondary", () => { - const lines = buildHealthLines(activeData({ - executionStatus: "Planning", - executionTarget: "Execute T03", - providerIssue: "✗ Anthropic (Claude) key missing", - environmentWarningCount: 1, - budgetSpent: 0.42, - })); - - assert.equal(lines.length, 2); - assert.equal(lines[0], " GSD Planning - Execute T03"); - assert.match(lines[1]!, /✗ Anthropic \(Claude\) key missing/); - assert.match(lines[1]!, /Env: 1 warning/); - assert.match(lines[1]!, /Spent: 42\.0¢/); -}); - -test("buildHealthLines: blocked state explains wait reason", () => { - const lines = buildHealthLines(activeData({ - executionStatus: "Blocked", - executionTarget: "waiting on unmet deps: M001", - blocker: "M002 is waiting on unmet deps: M001", - })); - - assert.equal(lines[0], " GSD Blocked - waiting on unmet deps: M001"); -}); - -test("buildHealthLines: paused state can omit secondary line", () => { - const lines = buildHealthLines(activeData({ - executionStatus: "Paused", - executionTarget: "waiting to resume", - })); - - assert.deepEqual(lines, [" GSD Paused - waiting to resume"]); +test("buildHealthLines: active state with ledger-driven spend shows spent summary", () => { + const lines = buildHealthLines(activeData({ budgetSpent: 0.42 })); + assert.equal(lines.length, 1); + assert.match(lines[0]!, /● System OK/); + assert.match(lines[0]!, /Spent: 42\.0¢/); }); test("buildHealthLines: active state with budget ceiling shows percent summary", () => { + const lines = buildHealthLines(activeData({ budgetSpent: 2.5, budgetCeiling: 10 })); + assert.equal(lines.length, 1); + assert.match(lines[0]!, /Budget: \$2\.50\/\$10\.00 \(25%\)/); +}); + +test("buildHealthLines: active state with issues reports issue summary", () => { const lines = buildHealthLines(activeData({ - executionStatus: "Executing", - executionTarget: "Plan S01", - budgetSpent: 2.5, - budgetCeiling: 10, + providerIssue: "✗ OpenAI key missing", + environmentErrorCount: 1, })); - assert.equal(lines.length, 2); - assert.match(lines[1]!, /Budget: \$2\.50\/\$10\.00 \(25%\)/); + assert.equal(lines.length, 1); + assert.match(lines[0]!, /✗ 2 issues/); + assert.match(lines[0]!, /✗ OpenAI key missing/); + assert.match(lines[0]!, /Env: 1 error/); }); test("detectHealthWidgetProjectState: metrics file alone does not imply project", () => {