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.
This commit is contained in:
Derek Pearson 2026-03-19 12:46:42 -04:00
parent 7afefc73ac
commit e6677797ba
3 changed files with 49 additions and 224 deletions

View file

@ -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(" │ ")}`];
}

View file

@ -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<HealthWidgetData> {
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;

View file

@ -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", () => {