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:
parent
7afefc73ac
commit
e6677797ba
3 changed files with 49 additions and 224 deletions
|
|
@ -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(" │ ")}`];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue