diff --git a/src/resources/extensions/gsd/paths.ts b/src/resources/extensions/gsd/paths.ts index c41ee596e..601e7e1d9 100644 --- a/src/resources/extensions/gsd/paths.ts +++ b/src/resources/extensions/gsd/paths.ts @@ -9,9 +9,40 @@ * via prefix matching, so existing projects work without migration. */ -import { readdirSync, existsSync } from "node:fs"; +import { readdirSync, existsSync, Dirent } from "node:fs"; import { join } from "node:path"; +// ─── Directory Listing Cache ────────────────────────────────────────────────── + +const dirEntryCache = new Map(); +const dirListCache = new Map(); + +function cachedReaddirWithTypes(dirPath: string): Dirent[] { + const cached = dirEntryCache.get(dirPath); + if (cached) return cached; + const entries = readdirSync(dirPath, { withFileTypes: true }); + dirEntryCache.set(dirPath, entries); + return entries; +} + +function cachedReaddir(dirPath: string): string[] { + const cached = dirListCache.get(dirPath); + if (cached) return cached; + const entries = readdirSync(dirPath); + dirListCache.set(dirPath, entries); + return entries; +} + +/** + * Clear the directory listing cache. + * Call after milestone transitions, file creation in planning directories, + * or at the start/end of a dispatch cycle. + */ +export function clearPathCache(): void { + dirEntryCache.clear(); + dirListCache.clear(); +} + // ─── Name Builders ───────────────────────────────────────────────────────── /** @@ -58,7 +89,7 @@ export function buildTaskFileName(taskId: string, suffix: string): string { export function resolveDir(parentDir: string, idPrefix: string): string | null { if (!existsSync(parentDir)) return null; try { - const entries = readdirSync(parentDir, { withFileTypes: true }); + const entries = cachedReaddirWithTypes(parentDir); // Exact match first (current convention: bare ID) const exact = entries.find(e => e.isDirectory() && e.name === idPrefix); if (exact) return exact.name; @@ -83,7 +114,7 @@ export function resolveFile(dir: string, idPrefix: string, suffix: string): stri if (!existsSync(dir)) return null; const target = `${idPrefix}-${suffix}.md`.toUpperCase(); try { - const entries = readdirSync(dir); + const entries = cachedReaddir(dir); // Direct match: ID-SUFFIX.md const direct = entries.find(e => e.toUpperCase() === target); if (direct) return direct; @@ -113,7 +144,7 @@ export function resolveTaskFiles(tasksDir: string, suffix: string): string[] { const currentPattern = new RegExp(`^T\\d+-${suffix}\\.md$`, "i"); // Legacy convention: T01-INSTALL-PACKAGES-PLAN.md const legacyPattern = new RegExp(`^T\\d+-.*-${suffix}\\.md$`, "i"); - return readdirSync(tasksDir) + return cachedReaddir(tasksDir) .filter(f => currentPattern.test(f) || legacyPattern.test(f)) .sort(); } catch { diff --git a/src/resources/extensions/gsd/tests/complete-milestone.test.ts b/src/resources/extensions/gsd/tests/complete-milestone.test.ts index cdb286792..0055cb862 100644 --- a/src/resources/extensions/gsd/tests/complete-milestone.test.ts +++ b/src/resources/extensions/gsd/tests/complete-milestone.test.ts @@ -3,6 +3,7 @@ import { join, dirname } from "node:path"; import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; import { createTestContext } from './test-helpers.ts'; +import { clearPathCache } from '../paths.ts'; // loadPrompt reads from ~/.gsd/agent/extensions/gsd/prompts/ (main checkout). // In a worktree the file may not exist there yet, so we resolve prompts @@ -179,6 +180,7 @@ async function main(): Promise { // Now add the summary and verify it transitions to complete writeMilestoneSummary(base, "M001", "# M001 Summary\n\nDone."); + clearPathCache(); const stateAfter = await deriveState(base); assertEq(stateAfter.phase, "complete", "deriveState returns complete after summary exists"); assertEq(stateAfter.registry[0]?.status, "complete", "registry shows complete status"); diff --git a/src/resources/extensions/gsd/tests/draft-promotion.test.ts b/src/resources/extensions/gsd/tests/draft-promotion.test.ts index 0fb7160cd..1037d9809 100644 --- a/src/resources/extensions/gsd/tests/draft-promotion.test.ts +++ b/src/resources/extensions/gsd/tests/draft-promotion.test.ts @@ -3,7 +3,7 @@ import { join } from "node:path"; import { tmpdir } from "node:os"; import { deriveState } from "../state.js"; -import { resolveMilestoneFile } from "../paths.js"; +import { resolveMilestoneFile, clearPathCache } from "../paths.js"; let passed = 0; let failed = 0; @@ -40,6 +40,7 @@ assert( const contextPath = join(gsd, "milestones", "M001", "M001-CONTEXT.md"); writeFileSync(contextPath, "# M001: Full Context\n\nDeep discussion output.\n"); +clearPathCache(); const state2 = await deriveState(tmpBase); assert( state2.phase === "pre-planning", @@ -65,6 +66,7 @@ assert( ); // Step 4: After cleanup, state is still pre-planning (CONTEXT.md exists) +clearPathCache(); const state3 = await deriveState(tmpBase); assert( state3.phase === "pre-planning", diff --git a/src/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts b/src/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts index d5b761075..7e16581eb 100644 --- a/src/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts +++ b/src/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts @@ -23,6 +23,7 @@ import { parseSliceBranch, switchToMain, } from '../worktree.ts'; +import { clearPathCache } from '../paths.ts'; import { createTestContext } from './test-helpers.ts'; // ─── Assertion Helpers ──────────────────────────────────────────────────── @@ -437,6 +438,7 @@ Built the legacy feature successfully. `); run('git add .', base); run('git commit -m add-m001', base); + clearPathCache(); // M001 (seq=1) < M001-abc123 (seq=1) — but M001 has incomplete S02 // Since M001 seq=1 and M002-abc123 seq=2, blocker should reference M001/S02 @@ -459,6 +461,7 @@ Built the legacy feature successfully. `); run('git add .', base); run('git commit -m complete-m001', base); + clearPathCache(); assertEq( getPriorSliceCompletionBlocker(base, 'main', 'plan-slice', 'M002-abc123/S01'), diff --git a/src/resources/extensions/gsd/tests/unit-runtime.test.ts b/src/resources/extensions/gsd/tests/unit-runtime.test.ts index b96439ede..64c7ee49a 100644 --- a/src/resources/extensions/gsd/tests/unit-runtime.test.ts +++ b/src/resources/extensions/gsd/tests/unit-runtime.test.ts @@ -8,6 +8,7 @@ import { readUnitRuntimeRecord, writeUnitRuntimeRecord, } from "../unit-runtime.ts"; +import { clearPathCache } from '../paths.ts'; import { createTestContext } from './test-helpers.ts'; const { assertEq, assertTrue, report } = createTestContext(); @@ -48,6 +49,7 @@ console.log("\n=== execute-task durability inspection ==="); "utf-8", ); writeFileSync(join(base, ".gsd", "STATE.md"), "## Next Action\nExecute T10 for S02: next thing\n", "utf-8"); + clearPathCache(); status = await inspectExecuteTaskDurability(base, "M100/S02/T09"); assertEq(status!.summaryExists, true, "summary found after write"); @@ -128,6 +130,7 @@ console.log("\n=== must-haves: partially mentioned in summary ==="); ); writeFileSync(join(mhBase, ".gsd", "STATE.md"), "## Next Action\nExecute T02 for S02: next thing\n", "utf-8"); + clearPathCache(); const status = await inspectExecuteTaskDurability(mhBase, "M200/S02/T01"); assertTrue(status !== null, "mh-partial: status exists"); assertEq(status!.mustHaveCount, 3, "mh-partial: mustHaveCount is 3"); @@ -155,6 +158,7 @@ console.log("\n=== must-haves: no task plan file ==="); ); writeFileSync(join(mhBase, ".gsd", "STATE.md"), "## Next Action\nExecute T02 for S03: next thing\n", "utf-8"); + clearPathCache(); const status = await inspectExecuteTaskDurability(mhBase, "M200/S03/T01"); assertTrue(status !== null, "mh-noplan: status exists"); assertEq(status!.mustHaveCount, 0, "mh-noplan: mustHaveCount is 0 when no task plan"); @@ -179,6 +183,7 @@ console.log("\n=== must-haves: present but no summary file ==="); ); writeFileSync(join(mhBase, ".gsd", "STATE.md"), "## Next Action\nExecute T01 for S04: build parser\n", "utf-8"); + clearPathCache(); const status = await inspectExecuteTaskDurability(mhBase, "M200/S04/T01"); assertTrue(status !== null, "mh-nosummary: status exists"); assertEq(status!.mustHaveCount, 2, "mh-nosummary: mustHaveCount is 2"); @@ -210,6 +215,7 @@ console.log("\n=== must-haves: substring matching (no backtick tokens) ==="); ); writeFileSync(join(mhBase, ".gsd", "STATE.md"), "## Next Action\nExecute T02 for S05: next thing\n", "utf-8"); + clearPathCache(); const status = await inspectExecuteTaskDurability(mhBase, "M200/S05/T01"); assertTrue(status !== null, "mh-substr: status exists"); assertEq(status!.mustHaveCount, 3, "mh-substr: mustHaveCount is 3");