From dae746b905eb8ba0741d6e24a4b306bd1da94568 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 30 Mar 2026 16:31:57 -0400 Subject: [PATCH] fix(forensics): read completion status from DB instead of legacy file (#3129) (#3234) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The forensics command showed "Completed Keys: 0" because it read from completed-units.json, which is never populated during normal auto-mode completion. Now queries the DB (milestones/slices/tasks tables) for authoritative completion counts, falling back to the legacy file only when DB is unavailable. Also fixes STATE.md showing "Active Milestone" for completed milestones — now shows "Last Completed Milestone" when phase is complete. Co-authored-by: Claude Opus 4.6 --- src/resources/extensions/gsd/forensics.ts | 66 ++++++++++++- src/resources/extensions/gsd/state.ts | 3 +- .../gsd/tests/forensics-db-completion.test.ts | 96 +++++++++++++++++++ src/resources/extensions/gsd/types.ts | 2 + .../extensions/gsd/workflow-projections.ts | 12 ++- 5 files changed, 171 insertions(+), 8 deletions(-) create mode 100644 src/resources/extensions/gsd/tests/forensics-db-completion.test.ts diff --git a/src/resources/extensions/gsd/forensics.ts b/src/resources/extensions/gsd/forensics.ts index dedf2b331..81cc69da2 100644 --- a/src/resources/extensions/gsd/forensics.ts +++ b/src/resources/extensions/gsd/forensics.ts @@ -28,6 +28,8 @@ import { deriveState } from "./state.js"; import { isAutoActive } from "./auto.js"; import { loadPrompt } from "./prompt-loader.js"; import { gsdRoot } from "./paths.js"; +import { isDbAvailable, getAllMilestones, getMilestoneSlices, getSliceTasks } from "./gsd-db.js"; +import { isClosedStatus } from "./status-guards.js"; import { formatDuration } from "../shared/format-utils.js"; import { getAutoWorktreePath } from "./auto-worktree.js"; import { loadEffectiveGSDPreferences, loadGlobalGSDPreferences, getGlobalGSDPreferencesPath } from "./preferences.js"; @@ -85,6 +87,15 @@ interface JournalSummary { fileCount: number; } +interface DbCompletionCounts { + milestones: number; + milestonesTotal: number; + slices: number; + slicesTotal: number; + tasks: number; + tasksTotal: number; +} + interface ForensicReport { gsdVersion: string; timestamp: string; @@ -95,6 +106,7 @@ interface ForensicReport { unitTraces: UnitTrace[]; metrics: MetricsLedger | null; completedKeys: string[]; + dbCompletionCounts: DbCompletionCounts | null; crashLock: LockData | null; doctorIssues: DoctorIssue[]; anomalies: ForensicAnomaly[]; @@ -276,8 +288,9 @@ export async function buildForensicReport(basePath: string): Promise { ? `All milestones complete. ${activeReqs} active requirement${activeReqs === 1 ? '' : 's'} in REQUIREMENTS.md ${activeReqs === 1 ? 'has' : 'have'} not been mapped to a milestone.` : 'All milestones complete.'; return { - activeMilestone: lastEntry ? { id: lastEntry.id, title: lastEntry.title } : null, + activeMilestone: null, + lastCompletedMilestone: lastEntry ? { id: lastEntry.id, title: lastEntry.title } : null, activeSlice: null, activeTask: null, phase: 'complete', recentDecisions: [], blockers: [], diff --git a/src/resources/extensions/gsd/tests/forensics-db-completion.test.ts b/src/resources/extensions/gsd/tests/forensics-db-completion.test.ts new file mode 100644 index 000000000..12fcf0bfc --- /dev/null +++ b/src/resources/extensions/gsd/tests/forensics-db-completion.test.ts @@ -0,0 +1,96 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const gsdDir = join(__dirname, ".."); + +/** + * Tests for #3129: forensics reads DB for completion status instead of legacy file. + * + * The old loadCompletedKeys() reads completed-units.json which is never populated + * during normal auto-mode completion. The DB (milestones/slices/tasks tables) is + * the authoritative source for completion status. + */ +describe("forensics DB completion status (#3129)", () => { + const forensicsSrc = readFileSync(join(gsdDir, "forensics.ts"), "utf-8"); + const stateSrc = readFileSync(join(gsdDir, "state.ts"), "utf-8"); + + // ── Primary fix: forensics queries DB for completion counts ────────── + + it("ForensicReport has dbCompletionCounts field for DB-sourced completion data", () => { + assert.ok( + forensicsSrc.includes("dbCompletionCounts"), + "ForensicReport must include dbCompletionCounts field for DB-sourced completion data", + ); + }); + + it("buildForensicReport queries DB for completed milestones, slices, and tasks", () => { + assert.ok( + forensicsSrc.includes("getDbCompletionCounts"), + "buildForensicReport must call getDbCompletionCounts to query DB completion status", + ); + }); + + it("getDbCompletionCounts checks isDbAvailable before querying", () => { + assert.ok( + forensicsSrc.includes("isDbAvailable"), + "getDbCompletionCounts must check isDbAvailable() before querying the DB", + ); + }); + + it("getDbCompletionCounts queries getAllMilestones for milestone completion", () => { + assert.ok( + forensicsSrc.includes("getAllMilestones"), + "getDbCompletionCounts must use getAllMilestones() to count completed milestones", + ); + }); + + it("completion counting uses isClosedStatus for consistent status checks", () => { + assert.ok( + forensicsSrc.includes("isClosedStatus"), + "forensics must use isClosedStatus() for consistent status checks", + ); + }); + + it("report rendering shows DB completion counts instead of just legacy key count", () => { + assert.ok( + forensicsSrc.includes("milestones complete"), + "report must show '__ milestones complete' from DB data", + ); + assert.ok( + forensicsSrc.includes("slices complete"), + "report must show '__ slices complete' from DB data", + ); + assert.ok( + forensicsSrc.includes("tasks complete"), + "report must show '__ tasks complete' from DB data", + ); + }); + + it("falls back to completed-units.json only when DB is unavailable", () => { + // loadCompletedKeys should still exist as fallback + assert.ok( + forensicsSrc.includes("loadCompletedKeys"), + "loadCompletedKeys must still exist as fallback for non-DB projects", + ); + // But the report should prefer DB counts + assert.ok( + forensicsSrc.includes("dbCompletionCounts"), + "report must prefer dbCompletionCounts over legacy completedKeys", + ); + }); + + // ── Secondary fix: STATE.md label when all milestones complete ─────── + + it("state.ts returns null activeMilestone when all milestones are complete", () => { + // When phase is "complete", activeMilestone should be null, not the last milestone + // The last completed milestone should be in a separate field + assert.ok( + stateSrc.includes("lastCompletedMilestone"), + "GSDState must have lastCompletedMilestone field for the final milestone when phase=complete", + ); + }); +}); diff --git a/src/resources/extensions/gsd/types.ts b/src/resources/extensions/gsd/types.ts index ffecfc75e..dc5250372 100644 --- a/src/resources/extensions/gsd/types.ts +++ b/src/resources/extensions/gsd/types.ts @@ -249,6 +249,8 @@ export interface GSDState { slices?: { done: number; total: number }; tasks?: { done: number; total: number }; }; + /** When phase=complete, holds the last completed milestone (instead of activeMilestone). */ + lastCompletedMilestone?: ActiveRef | null; } // ─── Post-Unit Hook Types ───────────────────────────────────────────────── diff --git a/src/resources/extensions/gsd/workflow-projections.ts b/src/resources/extensions/gsd/workflow-projections.ts index 4affbec8a..aae3adcb9 100644 --- a/src/resources/extensions/gsd/workflow-projections.ts +++ b/src/resources/extensions/gsd/workflow-projections.ts @@ -235,14 +235,18 @@ export function renderStateContent(state: GSDState): string { const lines: string[] = []; lines.push("# GSD State", ""); - const activeMilestone = state.activeMilestone - ? `${state.activeMilestone.id}: ${state.activeMilestone.title}` - : "None"; const activeSlice = state.activeSlice ? `${state.activeSlice.id}: ${state.activeSlice.title}` : "None"; - lines.push(`**Active Milestone:** ${activeMilestone}`); + if (state.phase === 'complete' && state.lastCompletedMilestone) { + lines.push(`**Last Completed Milestone:** ${state.lastCompletedMilestone.id}: ${state.lastCompletedMilestone.title}`); + } else { + const activeMilestone = state.activeMilestone + ? `${state.activeMilestone.id}: ${state.activeMilestone.title}` + : "None"; + lines.push(`**Active Milestone:** ${activeMilestone}`); + } lines.push(`**Active Slice:** ${activeSlice}`); lines.push(`**Phase:** ${state.phase}`); if (state.requirements) {