diff --git a/src/resources/extensions/gsd/state.ts b/src/resources/extensions/gsd/state.ts index c50bd9320..662545093 100644 --- a/src/resources/extensions/gsd/state.ts +++ b/src/resources/extensions/gsd/state.ts @@ -27,11 +27,13 @@ import { resolveSliceFile, resolveTaskFile, resolveGsdRootFile, + gsdRoot, } from './paths.js'; import { getActiveSliceBranch } from './worktree.js'; import { milestoneIdSort, findMilestoneIds } from './guided-flow.js'; +import { nativeBatchParseGsdFiles, type BatchParsedFile } from './native-parser-bridge.js'; -import { join } from 'path'; +import { join, resolve } from 'path'; // ─── Query Functions ─────────────────────────────────────────────────────── @@ -77,10 +79,79 @@ export async function getActiveMilestoneId(basePath: string): Promise { const milestoneIds = findMilestoneIds(basePath); - const requirements = parseRequirementCounts(await loadFile(resolveGsdRootFile(basePath, "REQUIREMENTS"))); + + // ── Batch-parse file cache ────────────────────────────────────────────── + // When the native Rust parser is available, read every .md file under .gsd/ + // in one call and build an in-memory content map keyed by absolute path. + // This eliminates O(N) individual fs.readFile calls during traversal. + const fileContentCache = new Map(); + const gsdDir = gsdRoot(basePath); + + const batchFiles = nativeBatchParseGsdFiles(gsdDir); + if (batchFiles) { + for (const f of batchFiles) { + // Reconstruct the full file content from parsed components so downstream + // parsers (parseRoadmap, parseSummary, etc.) receive the same input they + // expect from loadFile(). Files with frontmatter get it re-serialized; + // files without get just the body. + const absPath = resolve(gsdDir, f.path); + const hasMetadata = Object.keys(f.metadata).length > 0; + if (hasMetadata) { + // Re-serialize frontmatter as simple YAML key: value lines + const fmLines: string[] = ['---']; + for (const [key, value] of Object.entries(f.metadata)) { + if (Array.isArray(value)) { + if (value.length === 0) { + fmLines.push(`${key}: []`); + } else if (typeof value[0] === 'object' && value[0] !== null) { + fmLines.push(`${key}:`); + for (const obj of value) { + const entries = Object.entries(obj as Record); + if (entries.length > 0) { + fmLines.push(` - ${entries[0][0]}: ${entries[0][1]}`); + for (let i = 1; i < entries.length; i++) { + fmLines.push(` ${entries[i][0]}: ${entries[i][1]}`); + } + } + } + } else { + fmLines.push(`${key}:`); + for (const item of value) { + fmLines.push(` - ${item}`); + } + } + } else { + fmLines.push(`${key}: ${value}`); + } + } + fmLines.push('---'); + fileContentCache.set(absPath, fmLines.join('\n') + '\n\n' + f.body); + } else { + fileContentCache.set(absPath, f.body); + } + } + } + + /** + * Load file content from batch cache first, falling back to disk read. + * Resolves the path to absolute before cache lookup. + */ + async function cachedLoadFile(path: string): Promise { + const abs = resolve(path); + const cached = fileContentCache.get(abs); + if (cached !== undefined) return cached; + return loadFile(path); + } + + const requirements = parseRequirementCounts(await cachedLoadFile(resolveGsdRootFile(basePath, "REQUIREMENTS"))); if (milestoneIds.length === 0) { return { @@ -99,25 +170,31 @@ export async function deriveState(basePath: string): Promise { }; } - // Pre-compute the set of complete milestone IDs for dependency checking. - // This allows forward references (M002 depending on M003) to resolve correctly. + // ── Single-pass milestone scan ────────────────────────────────────────── + // Parse each milestone's roadmap once, caching results. First pass determines + // completeness for dependency resolution; second pass builds the registry. + // With the batch cache, all file reads hit memory instead of disk. + + // Phase 1: Build roadmap cache and completeness set + const roadmapCache = new Map(); const completeMilestoneIds = new Set(); + for (const mid of milestoneIds) { const rf = resolveMilestoneFile(basePath, mid, "ROADMAP"); - const rc = rf ? await loadFile(rf) : null; + const rc = rf ? await cachedLoadFile(rf) : null; if (!rc) { - // No roadmap — milestone is complete if it has a summary const sf = resolveMilestoneFile(basePath, mid, "SUMMARY"); if (sf) completeMilestoneIds.add(mid); continue; } const rmap = parseRoadmap(rc); + roadmapCache.set(mid, rmap); if (!isMilestoneComplete(rmap)) continue; const sf = resolveMilestoneFile(basePath, mid, "SUMMARY"); if (sf) completeMilestoneIds.add(mid); } - // Build the registry and locate the active milestone in a single pass. + // Phase 2: Build registry using cached roadmaps (no re-parsing or re-reading) const registry: MilestoneRegistryEntry[] = []; let activeMilestone: ActiveRef | null = null; let activeRoadmap: Roadmap | null = null; @@ -125,13 +202,13 @@ export async function deriveState(basePath: string): Promise { let activeMilestoneHasDraft = false; for (const mid of milestoneIds) { - const roadmapFile = resolveMilestoneFile(basePath, mid, "ROADMAP"); - const content = roadmapFile ? await loadFile(roadmapFile) : null; - if (!content) { + const roadmap = roadmapCache.get(mid) ?? null; + + if (!roadmap) { // No roadmap — check if a summary exists (completed milestone without roadmap) const summaryFile = resolveMilestoneFile(basePath, mid, "SUMMARY"); if (summaryFile) { - const summaryContent = await loadFile(summaryFile); + const summaryContent = await cachedLoadFile(summaryFile); const summaryTitle = summaryContent ? (parseSummary(summaryContent).title || mid) : mid; @@ -157,7 +234,6 @@ export async function deriveState(basePath: string): Promise { continue; } - const roadmap = parseRoadmap(content); const title = roadmap.title.replace(/^M\d+(?:-[a-z0-9]{6})?[^:]*:\s*/, ''); const complete = isMilestoneComplete(roadmap); @@ -176,7 +252,7 @@ export async function deriveState(basePath: string): Promise { } else if (!activeMilestoneFound) { // Check milestone-level dependencies before promoting to active const contextFile = resolveMilestoneFile(basePath, mid, "CONTEXT"); - const contextContent = contextFile ? await loadFile(contextFile) : null; + const contextContent = contextFile ? await cachedLoadFile(contextFile) : null; const deps = parseContextDependsOn(contextContent); const depsUnmet = deps.some(dep => !completeMilestoneIds.has(dep)); if (depsUnmet) { @@ -190,7 +266,7 @@ export async function deriveState(basePath: string): Promise { } } else { const contextFile2 = resolveMilestoneFile(basePath, mid, "CONTEXT"); - const contextContent2 = contextFile2 ? await loadFile(contextFile2) : null; + const contextContent2 = contextFile2 ? await cachedLoadFile(contextFile2) : null; const deps2 = parseContextDependsOn(contextContent2); registry.push({ id: mid, title, status: 'pending', ...(deps2.length > 0 ? { dependsOn: deps2 } : {}) }); } @@ -330,7 +406,7 @@ export async function deriveState(basePath: string): Promise { // Check if the slice has a plan const planFile = resolveSliceFile(basePath, activeMilestone.id, activeSlice.id, "PLAN"); - const slicePlanContent = planFile ? await loadFile(planFile) : null; + const slicePlanContent = planFile ? await cachedLoadFile(planFile) : null; if (!slicePlanContent) { return { @@ -392,7 +468,7 @@ export async function deriveState(basePath: string): Promise { for (const ct of completedTasks) { const summaryFile = resolveTaskFile(basePath, activeMilestone.id, activeSlice.id, ct.id, "SUMMARY"); if (!summaryFile) continue; - const summaryContent = await loadFile(summaryFile); + const summaryContent = await cachedLoadFile(summaryFile); if (!summaryContent) continue; const summary = parseSummary(summaryContent); if (summary.frontmatter.blocker_discovered) { @@ -432,8 +508,8 @@ export async function deriveState(basePath: string): Promise { const sDir = resolveSlicePath(basePath, activeMilestone.id, activeSlice.id); const continueFile = sDir ? resolveSliceFile(basePath, activeMilestone.id, activeSlice.id, "CONTINUE") : null; // Also check legacy continue.md - const hasInterrupted = !!(continueFile && await loadFile(continueFile)) || - !!(sDir && await loadFile(join(sDir, "continue.md"))); + const hasInterrupted = !!(continueFile && await cachedLoadFile(continueFile)) || + !!(sDir && await cachedLoadFile(join(sDir, "continue.md"))); return { activeMilestone,