perf: add session-scoped directory listing cache to path resolution

resolveDir(), resolveFile(), and resolveTaskFiles() call readdirSync()
on every invocation with no caching. These are called dozens of times
per dispatch cycle through resolveMilestonePath, resolveSliceFile,
relTaskFile, etc. This adds a module-level Map cache for directory
listings with an exported clearPathCache() function for invalidation
at dispatch cycle boundaries and milestone transitions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lex Christopherson 2026-03-14 13:34:29 -06:00
parent 07609d50b7
commit 1b6fdd6aa6

View file

@ -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<string, Dirent[]>();
const dirListCache = new Map<string, string[]>();
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 {