fix(forensics): read completion status from DB instead of legacy file (#3129) (#3234)

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 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher 2026-03-30 16:31:57 -04:00 committed by GitHub
parent dff73009c8
commit dae746b905
5 changed files with 171 additions and 8 deletions

View file

@ -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<ForensicRep
// 3. Load metrics
const metrics = loadLedgerFromDisk(basePath);
// 4. Load completed keys
// 4. Load completed keys (legacy) and DB completion counts
const completedKeys = loadCompletedKeys(basePath);
const dbCompletionCounts = getDbCompletionCounts();
// 5. Check crash lock
const crashLock = readCrashLock(basePath);
@ -336,6 +349,7 @@ export async function buildForensicReport(basePath: string): Promise<ForensicRep
unitTraces,
metrics,
completedKeys,
dbCompletionCounts,
crashLock,
doctorIssues,
anomalies,
@ -586,6 +600,44 @@ function loadCompletedKeys(basePath: string): string[] {
return [];
}
// ─── DB Completion Counts ────────────────────────────────────────────────────
function getDbCompletionCounts(): DbCompletionCounts | null {
if (!isDbAvailable()) return null;
const milestones = getAllMilestones();
let completedMilestones = 0;
let totalSlices = 0;
let completedSlices = 0;
let totalTasks = 0;
let completedTasks = 0;
for (const m of milestones) {
if (isClosedStatus(m.status)) completedMilestones++;
const slices = getMilestoneSlices(m.id);
for (const s of slices) {
totalSlices++;
if (isClosedStatus(s.status)) completedSlices++;
const tasks = getSliceTasks(m.id, s.id);
for (const t of tasks) {
totalTasks++;
if (isClosedStatus(t.status)) completedTasks++;
}
}
}
return {
milestones: completedMilestones,
milestonesTotal: milestones.length,
slices: completedSlices,
slicesTotal: totalSlices,
tasks: completedTasks,
tasksTotal: totalTasks,
};
}
// ─── Anomaly Detectors ───────────────────────────────────────────────────────
function detectStuckLoops(units: UnitMetrics[], anomalies: ForensicAnomaly[]): void {
@ -1072,8 +1124,16 @@ function formatReportForPrompt(report: ForensicReport): string {
sections.push("");
}
// Completed keys count
sections.push(`### Completed Keys: ${report.completedKeys.length}`);
// Completion status — prefer DB counts, fall back to legacy completed-units.json
if (report.dbCompletionCounts) {
const c = report.dbCompletionCounts;
sections.push(`### Completion Status (from DB)`);
sections.push(`- ${c.milestones}/${c.milestonesTotal} milestones complete`);
sections.push(`- ${c.slices}/${c.slicesTotal} slices complete`);
sections.push(`- ${c.tasks}/${c.tasksTotal} tasks complete`);
} else {
sections.push(`### Completed Keys: ${report.completedKeys.length}`);
}
sections.push(`### GSD Version: ${report.gsdVersion}`);
sections.push(`### Active Milestone: ${report.activeMilestone ?? "none"}`);
sections.push(`### Active Slice: ${report.activeSlice ?? "none"}`);

View file

@ -557,7 +557,8 @@ export async function deriveStateFromDb(basePath: string): Promise<GSDState> {
? `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: [],

View file

@ -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",
);
});
});

View file

@ -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 ─────────────────────────────────────────────────

View file

@ -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) {