diff --git a/src/resources/extensions/gsd/auto-worktree-sync.ts b/src/resources/extensions/gsd/auto-worktree-sync.ts index 643576098..8fab45fc3 100644 --- a/src/resources/extensions/gsd/auto-worktree-sync.ts +++ b/src/resources/extensions/gsd/auto-worktree-sync.ts @@ -93,6 +93,11 @@ export function syncStateToProjectRoot( { force: true }, ); + // 3. metrics.json — session cost/token tracking (#2313). + // Without this, metrics accumulated in the worktree are invisible from the + // project root and never appear in the dashboard or skill-health reports. + safeCopy(join(wtGsd, "metrics.json"), join(prGsd, "metrics.json"), { force: true }); + // 4. Runtime records — unit dispatch state used by selfHealRuntimeRecords(). // Without this, a crash during a unit leaves the runtime record only in the // worktree. If the next session resolves basePath before worktree re-entry, diff --git a/src/resources/extensions/gsd/auto-worktree.ts b/src/resources/extensions/gsd/auto-worktree.ts index 784d11276..95e1daba3 100644 --- a/src/resources/extensions/gsd/auto-worktree.ts +++ b/src/resources/extensions/gsd/auto-worktree.ts @@ -162,6 +162,7 @@ export function syncGsdStateToWorktree( "OVERRIDES.md", "QUEUE.md", "completed-units.json", + "metrics.json", ]; for (const f of rootFiles) { const src = join(mainGsd, f); @@ -325,8 +326,9 @@ export function syncWorktreeStateBack( // ── 1. Sync root-level .gsd/ files back ────────────────────────────── // The worktree is authoritative — complete-milestone updates REQUIREMENTS, // PROJECT, etc. These must overwrite main's copies so they survive teardown. - // Also includes QUEUE.md and completed-units.json which are written during - // milestone closeout and lost on teardown without explicit sync (#1787). + // Also includes QUEUE.md, completed-units.json, and metrics.json which are + // written during milestone closeout and lost on teardown without explicit sync + // (#1787, #2313). const rootFiles = [ "DECISIONS.md", "REQUIREMENTS.md", @@ -335,6 +337,7 @@ export function syncWorktreeStateBack( "OVERRIDES.md", "QUEUE.md", "completed-units.json", + "metrics.json", ]; for (const f of rootFiles) { const src = join(wtGsd, f); diff --git a/src/resources/extensions/gsd/auto/phases.ts b/src/resources/extensions/gsd/auto/phases.ts index 945c4e1a0..0b4e276ad 100644 --- a/src/resources/extensions/gsd/auto/phases.ts +++ b/src/resources/extensions/gsd/auto/phases.ts @@ -28,6 +28,7 @@ import { gsdRoot } from "../paths.js"; import { atomicWriteSync } from "../atomic-write.js"; import { PROJECT_FILES } from "../detection.js"; import { join } from "node:path"; +import { existsSync, cpSync } from "node:fs"; // ─── generateMilestoneReport ────────────────────────────────────────────────── @@ -263,9 +264,17 @@ export async function runPreDispatch( // Reset completed-units tracking for the new milestone — stale entries // from the previous milestone cause the dispatch loop to skip units // that haven't actually been completed in the new milestone's context. + // Archive the old completed-units.json instead of wiping it (#2313). s.completedUnits = []; try { const completedKeysPath = join(gsdRoot(s.basePath), "completed-units.json"); + if (existsSync(completedKeysPath) && s.currentMilestoneId) { + const archivePath = join( + gsdRoot(s.basePath), + `completed-units-${s.currentMilestoneId}.json`, + ); + cpSync(completedKeysPath, archivePath); + } atomicWriteSync(completedKeysPath, JSON.stringify([], null, 2)); } catch { /* non-fatal */ } diff --git a/src/resources/extensions/gsd/tests/completed-units-metrics-sync.test.ts b/src/resources/extensions/gsd/tests/completed-units-metrics-sync.test.ts new file mode 100644 index 000000000..e2bfc550f --- /dev/null +++ b/src/resources/extensions/gsd/tests/completed-units-metrics-sync.test.ts @@ -0,0 +1,113 @@ +/** + * completed-units-metrics-sync.test.ts — Regression tests for #2313. + * + * 1. completed-units.json should be archived (not wiped) on milestone transition + * 2. metrics.json should be in the worktree → project root sync file list + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync, mkdtempSync, mkdirSync, writeFileSync, existsSync, cpSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +// ─── Bug 1: completed-units.json should be archived, not wiped ───────────── + +const phasesSrcPath = join(import.meta.dirname, "..", "auto", "phases.ts"); +const phasesSrc = readFileSync(phasesSrcPath, "utf-8"); + +test("#2313: completed-units.json should not be blindly wiped to [] on milestone transition", () => { + // The milestone transition block should NOT write an empty array to completed-units.json + // without first archiving the existing data. Look for the archive/rename pattern. + const transitionIdx = phasesSrc.indexOf("Milestone transition"); + assert.ok(transitionIdx !== -1, "Milestone transition section exists"); + + // Find the completed-units handling block + const completedUnitsIdx = phasesSrc.indexOf("completed-units", transitionIdx); + assert.ok(completedUnitsIdx !== -1, "completed-units handling exists in transition"); + + // Get a window around the completed-units handling + const windowStart = Math.max(0, completedUnitsIdx - 200); + const windowEnd = Math.min(phasesSrc.length, completedUnitsIdx + 500); + const window = phasesSrc.slice(windowStart, windowEnd); + + // Should archive/rename the old file before resetting + const hasArchive = window.includes("archive") || + window.includes("rename") || + window.includes("cpSync") || + window.includes("safeCopy") || + window.includes("completed-units-"); + + assert.ok( + hasArchive, + "completed-units.json should be archived before reset during milestone transition", + ); +}); + +// ─── Bug 2: metrics.json should be in the sync file lists ────────────────── + +test("#2313: syncStateToProjectRoot should sync metrics.json", () => { + const syncSrcPath = join(import.meta.dirname, "..", "auto-worktree-sync.ts"); + const syncSrc = readFileSync(syncSrcPath, "utf-8"); + + // syncStateToProjectRoot should copy metrics.json from worktree to project root + assert.ok( + syncSrc.includes("metrics.json"), + "auto-worktree-sync.ts should reference metrics.json for sync", + ); +}); + +test("#2313: syncWorktreeStateBack should include metrics.json in root files list", () => { + const autoWorktreeSrcPath = join(import.meta.dirname, "..", "auto-worktree.ts"); + const autoWorktreeSrc = readFileSync(autoWorktreeSrcPath, "utf-8"); + + // Find the rootFiles array in syncWorktreeStateBack + const syncBackIdx = autoWorktreeSrc.indexOf("syncWorktreeStateBack"); + assert.ok(syncBackIdx !== -1, "syncWorktreeStateBack exists"); + + const rootFilesIdx = autoWorktreeSrc.indexOf("rootFiles", syncBackIdx); + assert.ok(rootFilesIdx !== -1, "rootFiles list exists in syncWorktreeStateBack"); + + // Get the rootFiles array content + const arrayStart = autoWorktreeSrc.indexOf("[", rootFilesIdx); + const arrayEnd = autoWorktreeSrc.indexOf("]", arrayStart); + const rootFilesBlock = autoWorktreeSrc.slice(arrayStart, arrayEnd); + + assert.ok( + rootFilesBlock.includes("metrics.json"), + "metrics.json should be in syncWorktreeStateBack rootFiles list", + ); +}); + +// ─── Functional test: completed-units archive ──────────────────────────────── + +test("#2313: functional — completed-units archive creates milestone-specific file", () => { + const tmpBase = mkdtempSync(join(tmpdir(), "gsd-completed-units-")); + const gsdDir = join(tmpBase, ".gsd"); + mkdirSync(gsdDir, { recursive: true }); + + // Simulate existing completed-units.json with data + const existing = [ + { type: "task", id: "T01" }, + { type: "slice", id: "S01" }, + ]; + const completedKeysPath = join(gsdDir, "completed-units.json"); + writeFileSync(completedKeysPath, JSON.stringify(existing, null, 2)); + + // Simulate the archive behavior: copy to milestone-specific file + const milestoneId = "M001"; + const archivePath = join(gsdDir, `completed-units-${milestoneId}.json`); + cpSync(completedKeysPath, archivePath); + + // Reset the main file + writeFileSync(completedKeysPath, JSON.stringify([], null, 2)); + + // Verify archive exists with original data + assert.ok(existsSync(archivePath), "archive file should exist"); + const archived = JSON.parse(readFileSync(archivePath, "utf-8")); + assert.deepEqual(archived, existing, "archived data should match original"); + + // Verify main file is reset + const current = JSON.parse(readFileSync(completedKeysPath, "utf-8")); + assert.deepEqual(current, [], "current completed-units should be empty after transition"); +});