diff --git a/package.json b/package.json index 6466aa0bd..c48214378 100644 --- a/package.json +++ b/package.json @@ -53,10 +53,10 @@ "copy-resources": "node scripts/copy-resources.cjs", "copy-themes": "node scripts/copy-themes.cjs", "copy-export-html": "node scripts/copy-export-html.cjs", - "test:unit": "node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --test src/resources/extensions/gsd/tests/*.test.ts src/resources/extensions/gsd/tests/*.test.mjs src/tests/*.test.ts", + "test:unit": "node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --experimental-test-isolation=process --test src/resources/extensions/gsd/tests/*.test.ts src/resources/extensions/gsd/tests/*.test.mjs src/tests/*.test.ts", "test:marketplace": "GSD_TEST_CLONE_MARKETPLACES=1 node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --test src/resources/extensions/gsd/tests/claude-import-tui.test.ts src/resources/extensions/gsd/tests/plugin-importer-live.test.ts src/tests/marketplace-discovery.test.ts", - "test:coverage": "c8 --reporter=text --reporter=lcov --exclude='src/resources/extensions/gsd/tests/**' --exclude='src/tests/**' --exclude='scripts/**' --exclude='native/**' --exclude='node_modules/**' --check-coverage --statements=50 --lines=50 --branches=20 --functions=20 node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --test src/resources/extensions/gsd/tests/*.test.ts src/resources/extensions/gsd/tests/*.test.mjs src/tests/*.test.ts", - "test:integration": "node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --test src/resources/extensions/gsd/tests/*integration*.test.ts src/tests/integration/*.test.ts", + "test:coverage": "c8 --reporter=text --reporter=lcov --exclude='src/resources/extensions/gsd/tests/**' --exclude='src/tests/**' --exclude='scripts/**' --exclude='native/**' --exclude='node_modules/**' --check-coverage --statements=50 --lines=50 --branches=20 --functions=20 node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --experimental-test-isolation=process --test src/resources/extensions/gsd/tests/*.test.ts src/resources/extensions/gsd/tests/*.test.mjs src/tests/*.test.ts", + "test:integration": "node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --experimental-test-isolation=process --test src/resources/extensions/gsd/tests/*integration*.test.ts src/tests/integration/*.test.ts", "test": "npm run test:unit && npm run test:integration", "test:smoke": "node --experimental-strip-types tests/smoke/run.ts", "test:fixtures": "node --experimental-strip-types tests/fixtures/run.ts", diff --git a/src/resources/extensions/gsd/auto-direct-dispatch.ts b/src/resources/extensions/gsd/auto-direct-dispatch.ts index bddd5801c..ab89687be 100644 --- a/src/resources/extensions/gsd/auto-direct-dispatch.ts +++ b/src/resources/extensions/gsd/auto-direct-dispatch.ts @@ -11,6 +11,7 @@ import type { import { deriveState } from "./state.js"; import { loadFile } from "./files.js"; import { isDbAvailable, getMilestoneSlices } from "./gsd-db.js"; +import { parseRoadmap } from "./parsers-legacy.js"; import { resolveMilestoneFile, resolveSliceFile, relSliceFile, } from "./paths.js"; @@ -152,13 +153,20 @@ export async function dispatchDirectPhase( case "reassess": case "reassess-roadmap": { - // DB primary path — get completed slices + // DB primary path — get completed slices, fall back to file parsing when DB has no data let completedSliceIds: string[] = []; if (isDbAvailable()) { completedSliceIds = getMilestoneSlices(mid).filter(s => s.status === "complete").map(s => s.id); - } else { - ctx.ui.notify("Cannot dispatch reassess-roadmap: DB unavailable.", "warning"); - return; + } + if (completedSliceIds.length === 0) { + // File-based fallback: parse roadmap checkboxes + const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP"); + if (roadmapPath) { + const roadmapContent = await loadFile(roadmapPath); + if (roadmapContent) { + completedSliceIds = parseRoadmap(roadmapContent).slices.filter(s => s.done).map(s => s.id); + } + } } if (completedSliceIds.length === 0) { ctx.ui.notify("Cannot dispatch reassess-roadmap: no completed slices.", "warning"); @@ -180,9 +188,16 @@ export async function dispatchDirectPhase( let uatCompletedSliceIds: string[] = []; if (isDbAvailable()) { uatCompletedSliceIds = getMilestoneSlices(mid).filter(s => s.status === "complete").map(s => s.id); - } else { - ctx.ui.notify("Cannot dispatch run-uat: DB unavailable.", "warning"); - return; + } + if (uatCompletedSliceIds.length === 0) { + // File-based fallback: parse roadmap checkboxes + const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP"); + if (roadmapPath) { + const roadmapContent = await loadFile(roadmapPath); + if (roadmapContent) { + uatCompletedSliceIds = parseRoadmap(roadmapContent).slices.filter(s => s.done).map(s => s.id); + } + } } if (uatCompletedSliceIds.length === 0) { ctx.ui.notify("Cannot dispatch run-uat: no completed slices.", "warning"); diff --git a/src/resources/extensions/gsd/auto-prompts.ts b/src/resources/extensions/gsd/auto-prompts.ts index 587484b4b..e0017d786 100644 --- a/src/resources/extensions/gsd/auto-prompts.ts +++ b/src/resources/extensions/gsd/auto-prompts.ts @@ -16,6 +16,7 @@ import { resolveGsdRootFile, relGsdRootFile, resolveRuntimeFile, } from "./paths.js"; import { resolveSkillDiscoveryMode, resolveInlineLevel, loadEffectiveGSDPreferences, resolveAllSkillReferences } from "./preferences.js"; +import { parseRoadmap } from "./parsers-legacy.js"; import type { GSDState, InlineLevel } from "./types.js"; import type { GSDPreferences } from "./preferences.js"; import { getLoadedSkills, type Skill } from "@gsd/pi-coding-agent"; @@ -183,14 +184,30 @@ export async function inlineDependencySummaries( const { isDbAvailable, getSlice } = await import("./gsd-db.js"); if (isDbAvailable()) { const slice = getSlice(mid, sid); - if (!slice || slice.depends.length === 0) return "- (no dependencies)"; - depends = slice.depends as string[]; + if (slice) { + if (slice.depends.length === 0) return "- (no dependencies)"; + depends = slice.depends as string[]; + } + // If slice not found in DB, fall through to file-based parsing } } catch { /* fall through */ } - // If DB didn't provide depends, we can't determine them without parsers + // If DB didn't provide depends, fall back to roadmap parsing if (!depends) { - return "- (no dependencies)"; + const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP"); + if (roadmapPath) { + const roadmapContent = await loadFile(roadmapPath); + if (roadmapContent) { + const parsed = parseRoadmap(roadmapContent); + const slice = parsed.slices.find(s => s.id === sid); + if (slice && slice.depends.length > 0) { + depends = slice.depends; + } + } + } + if (!depends) { + return "- (no dependencies)"; + } } const sections: string[] = []; @@ -684,29 +701,44 @@ export async function getDependencyTaskSummaryPaths( export async function checkNeedsReassessment( base: string, mid: string, state: GSDState, ): Promise<{ sliceId: string } | null> { - // DB primary path - let completedSliceIds: string[] = []; - let hasIncomplete = false; + // DB primary path — fall through to file-based when DB has no data for this milestone try { const { isDbAvailable, getMilestoneSlices } = await import("./gsd-db.js"); if (isDbAvailable()) { const slices = getMilestoneSlices(mid); - completedSliceIds = slices.filter(s => s.status === "complete").map(s => s.id); - hasIncomplete = slices.some(s => s.status !== "complete"); - if (completedSliceIds.length === 0 || !hasIncomplete) return null; - const lastCompleted = completedSliceIds[completedSliceIds.length - 1]; - const assessmentFile = resolveSliceFile(base, mid, lastCompleted, "ASSESSMENT"); - const hasAssessment = !!(assessmentFile && await loadFile(assessmentFile)); - if (hasAssessment) return null; - const summaryFile = resolveSliceFile(base, mid, lastCompleted, "SUMMARY"); - const hasSummary = !!(summaryFile && await loadFile(summaryFile)); - if (!hasSummary) return null; - return { sliceId: lastCompleted }; + if (slices.length > 0) { + const completedSliceIds = slices.filter(s => s.status === "complete").map(s => s.id); + const hasIncomplete = slices.some(s => s.status !== "complete"); + if (completedSliceIds.length === 0 || !hasIncomplete) return null; + const lastCompleted = completedSliceIds[completedSliceIds.length - 1]; + const assessmentFile = resolveSliceFile(base, mid, lastCompleted, "ASSESSMENT"); + const hasAssessment = !!(assessmentFile && await loadFile(assessmentFile)); + if (hasAssessment) return null; + const summaryFile = resolveSliceFile(base, mid, lastCompleted, "SUMMARY"); + const hasSummary = !!(summaryFile && await loadFile(summaryFile)); + if (!hasSummary) return null; + return { sliceId: lastCompleted }; + } } } catch { /* fall through */ } - // DB unavailable — cannot determine assessment needs - return null; + // File-based fallback using roadmap checkboxes + const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP"); + if (!roadmapPath) return null; + const roadmapContent = await loadFile(roadmapPath); + if (!roadmapContent) return null; + const parsed = parseRoadmap(roadmapContent); + const fileCompletedIds = parsed.slices.filter(s => s.done).map(s => s.id); + const fileHasIncomplete = parsed.slices.some(s => !s.done); + if (fileCompletedIds.length === 0 || !fileHasIncomplete) return null; + const lastDone = fileCompletedIds[fileCompletedIds.length - 1]; + const assessFile = resolveSliceFile(base, mid, lastDone, "ASSESSMENT"); + const hasAssess = !!(assessFile && await loadFile(assessFile)); + if (hasAssess) return null; + const summFile = resolveSliceFile(base, mid, lastDone, "SUMMARY"); + const hasSumm = !!(summFile && await loadFile(summFile)); + if (!hasSumm) return null; + return { sliceId: lastDone }; } /** @@ -723,34 +755,57 @@ export async function checkNeedsReassessment( export async function checkNeedsRunUat( base: string, mid: string, state: GSDState, prefs: GSDPreferences | undefined, ): Promise<{ sliceId: string; uatType: UatType } | null> { - // DB primary path + // DB primary path — fall through to file-based when DB has no data for this milestone try { const { isDbAvailable, getMilestoneSlices } = await import("./gsd-db.js"); if (isDbAvailable()) { const slices = getMilestoneSlices(mid); - const completedSlices = slices.filter(s => s.status === "complete"); - const incompleteSlices = slices.filter(s => s.status !== "complete"); - if (completedSlices.length === 0) return null; - if (incompleteSlices.length === 0) return null; - if (!prefs?.uat_dispatch) return null; - const lastCompleted = completedSlices[completedSlices.length - 1]; - const sid = lastCompleted.id; - const uatFile = resolveSliceFile(base, mid, sid, "UAT"); - if (!uatFile) return null; - const uatContent = await loadFile(uatFile); - if (!uatContent) return null; - const uatResultFile = resolveSliceFile(base, mid, sid, "UAT-RESULT"); - if (uatResultFile) { - const hasResult = !!(await loadFile(uatResultFile)); - if (hasResult) return null; + if (slices.length > 0) { + const completedSlices = slices.filter(s => s.status === "complete"); + const incompleteSlices = slices.filter(s => s.status !== "complete"); + if (completedSlices.length === 0) return null; + if (incompleteSlices.length === 0) return null; + if (!prefs?.uat_dispatch) return null; + const lastCompleted = completedSlices[completedSlices.length - 1]; + const sid = lastCompleted.id; + const uatFile = resolveSliceFile(base, mid, sid, "UAT"); + if (!uatFile) return null; + const uatContent = await loadFile(uatFile); + if (!uatContent) return null; + const uatResultFile = resolveSliceFile(base, mid, sid, "UAT-RESULT"); + if (uatResultFile) { + const hasResult = !!(await loadFile(uatResultFile)); + if (hasResult) return null; + } + const uatType = extractUatType(uatContent) ?? "artifact-driven"; + return { sliceId: sid, uatType }; } - const uatType = extractUatType(uatContent) ?? "artifact-driven"; - return { sliceId: sid, uatType }; } } catch { /* fall through */ } - // DB unavailable — cannot determine UAT needs - return null; + // File-based fallback using roadmap checkboxes + if (!prefs?.uat_dispatch) return null; + const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP"); + if (!roadmapPath) return null; + const roadmapContent = await loadFile(roadmapPath); + if (!roadmapContent) return null; + const parsed = parseRoadmap(roadmapContent); + const completedFileSlices = parsed.slices.filter(s => s.done); + const incompleteFileSlices = parsed.slices.filter(s => !s.done); + if (completedFileSlices.length === 0 || incompleteFileSlices.length === 0) return null; + const lastCompletedFile = completedFileSlices[completedFileSlices.length - 1]; + const uatSid = lastCompletedFile.id; + const uatFileFb = resolveSliceFile(base, mid, uatSid, "UAT"); + if (!uatFileFb) return null; + const uatContentFb = await loadFile(uatFileFb); + if (!uatContentFb) return null; + const uatResultFb = resolveSliceFile(base, mid, uatSid, "UAT-RESULT"); + if (uatResultFb) { + const hasResultFb = !!(await loadFile(uatResultFb)); + if (hasResultFb) return null; + } + const uatTypeFb = extractUatType(uatContentFb) ?? "artifact-driven"; + return { sliceId: uatSid, uatType: uatTypeFb }; } // ─── Prompt Builders ────────────────────────────────────────────────────── @@ -1207,7 +1262,13 @@ export async function buildCompleteMilestonePrompt( sliceIds = getMilestoneSlices(mid).map(s => s.id); } } catch { /* fall through */ } - // If DB didn't provide slice IDs, sliceIds stays empty — no summaries to inline + // File-based fallback: parse roadmap for slice IDs when DB has no data + if (sliceIds.length === 0 && roadmapPath) { + const roadmapContent = await loadFile(roadmapPath); + if (roadmapContent) { + sliceIds = parseRoadmap(roadmapContent).slices.map(s => s.id); + } + } const seenSlices = new Set(); for (const sid of sliceIds) { if (seenSlices.has(sid)) continue; @@ -1267,7 +1328,13 @@ export async function buildValidateMilestonePrompt( valSliceIds = getMilestoneSlices(mid).map(s => s.id); } } catch { /* fall through */ } - // If DB didn't provide slice IDs, valSliceIds stays empty + // File-based fallback: parse roadmap for slice IDs when DB has no data + if (valSliceIds.length === 0 && roadmapPath) { + const roadmapContent = await loadFile(roadmapPath); + if (roadmapContent) { + valSliceIds = parseRoadmap(roadmapContent).slices.map(s => s.id); + } + } const seenValSlices = new Set(); for (const sid of valSliceIds) { if (seenValSlices.has(sid)) continue; diff --git a/src/resources/extensions/gsd/auto-worktree.ts b/src/resources/extensions/gsd/auto-worktree.ts index 4641e02f6..cfd4a241e 100644 --- a/src/resources/extensions/gsd/auto-worktree.ts +++ b/src/resources/extensions/gsd/auto-worktree.ts @@ -1006,7 +1006,14 @@ export function mergeMilestoneToMain( .filter(s => s.status === "complete") .map(s => ({ id: s.id, title: s.title })); } - // When DB unavailable, completedSlices stays empty — commit message will omit slice details + // Fallback: parse roadmap content when DB is unavailable + if (completedSlices.length === 0 && roadmapContent) { + const sliceRe = /- \[x\] \*\*(\w+):\s*(.+?)\*\*/gi; + let m: RegExpExecArray | null; + while ((m = sliceRe.exec(roadmapContent)) !== null) { + completedSlices.push({ id: m[1], title: m[2] }); + } + } // 3. chdir to original base const previousCwd = process.cwd(); @@ -1037,8 +1044,14 @@ export function mergeMilestoneToMain( // 6. Build rich commit message const dbMilestone = getMilestone(milestoneId); - const milestoneTitle = - (dbMilestone?.title ?? "").replace(/^M\d+:\s*/, "").trim() || milestoneId; + let milestoneTitle = + (dbMilestone?.title ?? "").replace(/^M\d+:\s*/, "").trim(); + // Fallback: parse title from roadmap content header (e.g. "# M020: Backend foundation") + if (!milestoneTitle && roadmapContent) { + const titleMatch = roadmapContent.match(new RegExp(`^#\\s+${milestoneId}:\\s*(.+)`, "m")); + if (titleMatch) milestoneTitle = titleMatch[1].trim(); + } + milestoneTitle = milestoneTitle || milestoneId; const subject = `feat(${milestoneId}): ${milestoneTitle}`; let body = ""; if (completedSlices.length > 0) { diff --git a/src/resources/extensions/gsd/dispatch-guard.ts b/src/resources/extensions/gsd/dispatch-guard.ts index 78a061185..33d0687e4 100644 --- a/src/resources/extensions/gsd/dispatch-guard.ts +++ b/src/resources/extensions/gsd/dispatch-guard.ts @@ -3,6 +3,8 @@ import { resolveMilestoneFile } from "./paths.js"; import { findMilestoneIds } from "./guided-flow.js"; import { isDbAvailable, getMilestoneSlices } from "./gsd-db.js"; +import { parseRoadmap } from "./parsers-legacy.js"; +import { readFileSync } from "node:fs"; const SLICE_DISPATCH_TYPES = new Set([ "research-slice", @@ -34,18 +36,34 @@ export function getPriorSliceCompletionBlocker( if (resolveMilestoneFile(base, mid, "PARKED")) continue; if (resolveMilestoneFile(base, mid, "SUMMARY")) continue; - // Normalised slice list from DB + // Normalised slice list from DB or file fallback type NormSlice = { id: string; done: boolean; depends: string[] }; + let slices: NormSlice[] | null = null; - if (!isDbAvailable()) continue; - - const rows = getMilestoneSlices(mid); - if (rows.length === 0) continue; - const slices: NormSlice[] = rows.map((r) => ({ - id: r.id, - done: r.status === "complete", - depends: r.depends ?? [], - })); + if (isDbAvailable()) { + const rows = getMilestoneSlices(mid); + if (rows.length > 0) { + slices = rows.map((r) => ({ + id: r.id, + done: r.status === "complete", + depends: r.depends ?? [], + })); + } + } + if (!slices) { + // File-based fallback: parse roadmap checkboxes + const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP"); + if (!roadmapPath) continue; + let roadmapContent: string; + try { roadmapContent = readFileSync(roadmapPath, "utf-8"); } catch { continue; } + const parsed = parseRoadmap(roadmapContent); + if (parsed.slices.length === 0) continue; + slices = parsed.slices.map((s) => ({ + id: s.id, + done: s.done, + depends: s.depends ?? [], + })); + } if (mid !== targetMid) { const incomplete = slices.find((slice) => !slice.done); diff --git a/src/resources/extensions/gsd/markdown-renderer.ts b/src/resources/extensions/gsd/markdown-renderer.ts index 567882335..551ce010c 100644 --- a/src/resources/extensions/gsd/markdown-renderer.ts +++ b/src/resources/extensions/gsd/markdown-renderer.ts @@ -933,12 +933,14 @@ export async function repairStaleRenders(basePath: string): Promise { for (const entry of staleEntries) { if (repairedPaths.has(entry.path)) continue; + // Normalize path separators for cross-platform regex matching + const normPath = entry.path.replace(/\\/g, "/"); try { // Determine repair action from the reason if (entry.reason.includes("in roadmap")) { // Roadmap checkbox mismatch — extract milestone ID from path - const milestoneMatch = entry.path.match(/milestones\/([^/]+)\//); + const milestoneMatch = normPath.match(/milestones\/([^/]+)\//); if (milestoneMatch) { const ok = await renderRoadmapCheckboxes(basePath, milestoneMatch[1]); if (ok) { @@ -948,7 +950,7 @@ export async function repairStaleRenders(basePath: string): Promise { } } else if (entry.reason.includes("in plan")) { // Plan checkbox mismatch — extract milestone + slice IDs from path - const pathMatch = entry.path.match(/milestones\/([^/]+)\/slices\/([^/]+)\//); + const pathMatch = normPath.match(/milestones\/([^/]+)\/slices\/([^/]+)\//); if (pathMatch) { const ok = await renderPlanCheckboxes(basePath, pathMatch[1], pathMatch[2]); if (ok) { @@ -958,7 +960,7 @@ export async function repairStaleRenders(basePath: string): Promise { } } else if (entry.reason.includes("SUMMARY.md missing") && entry.reason.match(/^T\d+/)) { // Missing task summary — extract IDs from path - const pathMatch = entry.path.match(/milestones\/([^/]+)\/slices\/([^/]+)\/tasks\//); + const pathMatch = normPath.match(/milestones\/([^/]+)\/slices\/([^/]+)\/tasks\//); const taskMatch = entry.reason.match(/^(T\d+)/); if (pathMatch && taskMatch) { const ok = await renderTaskSummary(basePath, pathMatch[1], pathMatch[2], taskMatch[1]); @@ -969,7 +971,7 @@ export async function repairStaleRenders(basePath: string): Promise { } } else if (entry.reason.includes("SUMMARY.md missing") && entry.reason.match(/^S\d+/)) { // Missing slice summary — extract IDs from path - const pathMatch = entry.path.match(/milestones\/([^/]+)\/slices\/([^/]+)\//); + const pathMatch = normPath.match(/milestones\/([^/]+)\/slices\/([^/]+)\//); if (pathMatch) { const ok = await renderSliceSummary(basePath, pathMatch[1], pathMatch[2]); if (ok) { @@ -979,7 +981,7 @@ export async function repairStaleRenders(basePath: string): Promise { } } else if (entry.reason.includes("UAT.md missing")) { // Missing slice UAT — renderSliceSummary handles both SUMMARY + UAT - const pathMatch = entry.path.match(/milestones\/([^/]+)\/slices\/([^/]+)\//); + const pathMatch = normPath.match(/milestones\/([^/]+)\/slices\/([^/]+)\//); if (pathMatch) { const ok = await renderSliceSummary(basePath, pathMatch[1], pathMatch[2]); if (ok) { diff --git a/src/resources/extensions/gsd/reactive-graph.ts b/src/resources/extensions/gsd/reactive-graph.ts index c36ca29f9..eb76999f6 100644 --- a/src/resources/extensions/gsd/reactive-graph.ts +++ b/src/resources/extensions/gsd/reactive-graph.ts @@ -12,6 +12,7 @@ import type { TaskIO, DerivedTaskNode, ReactiveExecutionState } from "./types.js"; import { loadFile, parseTaskPlanIO } from "./files.js"; import { isDbAvailable, getSliceTasks } from "./gsd-db.js"; +import { parsePlan } from "./parsers-legacy.js"; import { resolveTasksDir, resolveTaskFiles } from "./paths.js"; import { join } from "node:path"; import { loadJsonFileOrNull, saveJsonFile } from "./json-persistence.js"; @@ -205,8 +206,17 @@ export async function loadSliceTaskIO( } catch { /* fall through */ } if (!taskEntries) { - // DB unavailable — cannot determine task graph - return []; + // File-based fallback: parse slice plan for task entries + const parsed = parsePlan(planContent); + if (parsed.tasks.length > 0) { + taskEntries = parsed.tasks.map(t => ({ + id: t.id, + title: t.title, + done: t.done, + })); + } else { + return []; + } } const tDir = resolveTasksDir(basePath, mid, sid); diff --git a/src/resources/extensions/gsd/skill-health.ts b/src/resources/extensions/gsd/skill-health.ts index e08ce3352..778bba7a3 100644 --- a/src/resources/extensions/gsd/skill-health.ts +++ b/src/resources/extensions/gsd/skill-health.ts @@ -283,7 +283,8 @@ export function computeStaleAvoidList( staleDays?: number, ): string[] { const ledger = loadLedgerFromDisk(basePath); - const units = (ledger?.units ?? []).filter(u => u.skills && u.skills.length > 0); + if (!ledger) return []; + const units = ledger.units.filter(u => u.skills && u.skills.length > 0); const stale = detectStaleSkills(units, staleDays ?? DEFAULT_STALE_DAYS); const avoidSet = new Set(currentAvoidList); diff --git a/src/resources/extensions/gsd/tests/auto-stash-merge.test.ts b/src/resources/extensions/gsd/tests/auto-stash-merge.test.ts index 403caf396..2602d307e 100644 --- a/src/resources/extensions/gsd/tests/auto-stash-merge.test.ts +++ b/src/resources/extensions/gsd/tests/auto-stash-merge.test.ts @@ -83,7 +83,7 @@ test("#2151 bug 1: auto-stash unblocks merge when unrelated files are dirty", () const readmeContent = readFileSync(join(repo, "README.md"), "utf-8"); assert.equal(readmeContent, "# modified locally\n", "stash popped — dirty file restored after merge"); } finally { - rmSync(repo, { recursive: true, force: true }); + rmSync(repo, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } }); @@ -116,6 +116,6 @@ test("#2151 bug 2: nativeMergeSquash returns dirty filenames", async () => { ); } finally { run("git checkout -- . 2>/dev/null || true", repo); - rmSync(repo, { recursive: true, force: true }); + rmSync(repo, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } }); diff --git a/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts b/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts index 0a24524df..0661e394f 100644 --- a/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +++ b/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts @@ -490,7 +490,7 @@ async function main(): Promise { // The milestone code should be on main. assertTrue(existsSync(join(repo, "e2e.ts")), "#2151: e2e.ts merged to main"); const content = readFileSync(join(repo, "e2e.ts"), "utf-8"); - assertEq(content, "export const e2e = true;\n", "#2151: merged content is from milestone branch"); + assertEq(content.replace(/\r\n/g, "\n"), "export const e2e = true;\n", "#2151: merged content is from milestone branch"); } // ─── Test 12: Throw on unanchored code changes after empty commit (#1792) ─ diff --git a/src/resources/extensions/gsd/visualizer-data.ts b/src/resources/extensions/gsd/visualizer-data.ts index cac910392..203d8d90e 100644 --- a/src/resources/extensions/gsd/visualizer-data.ts +++ b/src/resources/extensions/gsd/visualizer-data.ts @@ -5,6 +5,7 @@ import { join } from 'node:path'; import { deriveState } from './state.js'; import { parseSummary, loadFile } from './files.js'; import { isDbAvailable, getMilestoneSlices, getSliceTasks } from './gsd-db.js'; +import { parseRoadmap, parsePlan } from './parsers-legacy.js'; import { findMilestoneIds } from './milestone-ids.js'; import { resolveMilestoneFile, resolveSliceFile, resolveGsdRootFile, gsdRoot } from './paths.js'; import { @@ -798,14 +799,21 @@ export async function loadVisualizerData(basePath: string): Promise ({ id: s.id, done: s.status === 'complete', title: s.title, risk: s.risk || 'medium', depends: s.depends, demo: s.demo })); - } else { - normSlices = []; + const dbSlices = getMilestoneSlices(mid); + if (dbSlices.length > 0) { + normSlices = dbSlices.map(s => ({ id: s.id, done: s.status === 'complete', title: s.title, risk: s.risk || 'medium', depends: s.depends, demo: s.demo })); + } } + if (!normSlices && roadmapContent) { + // File-based fallback: parse roadmap for slice entries + const parsed = parseRoadmap(roadmapContent); + normSlices = parsed.slices.map(s => ({ id: s.id, done: s.done, title: s.title, risk: s.risk || 'medium', depends: s.depends, demo: '' })); + } + if (!normSlices) normSlices = []; for (const s of normSlices) { const isActiveSlice = @@ -815,16 +823,40 @@ export async function loadVisualizerData(basePath: string): Promise 0) { + usedDbTasks = true; + for (const t of dbTasks) { + tasks.push({ + id: t.id, + title: t.title, + done: t.status === 'complete' || t.status === 'done', + active: state.activeTask?.id === t.id, + estimate: t.estimate || undefined, + }); + } + } + } + if (!usedDbTasks) { + // File-based fallback: parse slice plan for task entries + const slicePlanFile = resolveSliceFile(basePath, mid, s.id, 'PLAN'); + if (slicePlanFile) { + const planContent = readFileCached(slicePlanFile); + if (planContent) { + const parsed = parsePlan(planContent); + for (const t of parsed.tasks) { + tasks.push({ + id: t.id, + title: t.title, + done: t.done, + active: state.activeTask?.id === t.id, + estimate: t.estimate || undefined, + }); + } + } } } } diff --git a/src/resources/extensions/gsd/workspace-index.ts b/src/resources/extensions/gsd/workspace-index.ts index 8627c7845..8b270662b 100644 --- a/src/resources/extensions/gsd/workspace-index.ts +++ b/src/resources/extensions/gsd/workspace-index.ts @@ -2,6 +2,7 @@ import { join } from "node:path"; import { loadFile } from "./files.js"; import { isDbAvailable, getMilestoneSlices, getSliceTasks } from "./gsd-db.js"; +import { parseRoadmap, parsePlan } from "./parsers-legacy.js"; import { resolveMilestoneFile, resolveSliceFile, @@ -79,21 +80,40 @@ async function indexSlice(basePath: string, milestoneId: string, sliceId: string const tasks: WorkspaceTaskTarget[] = []; let title = fallbackTitle; - // Prefer DB for task data + // Prefer DB for task data, fall back to file parsing when DB has no data + let usedDb = false; if (isDbAvailable()) { const dbTasks = getSliceTasks(milestoneId, sliceId); - for (const task of dbTasks) { - title = fallbackTitle; // title comes from slice-level data, not plan - tasks.push({ - id: task.id, - title: task.title, - done: task.status === "complete" || task.status === "done", - planPath: resolveTaskFile(basePath, milestoneId, sliceId, task.id, "PLAN") ?? undefined, - summaryPath: resolveTaskFile(basePath, milestoneId, sliceId, task.id, "SUMMARY") ?? undefined, - }); + if (dbTasks.length > 0) { + usedDb = true; + for (const task of dbTasks) { + title = fallbackTitle; // title comes from slice-level data, not plan + tasks.push({ + id: task.id, + title: task.title, + done: task.status === "complete" || task.status === "done", + planPath: resolveTaskFile(basePath, milestoneId, sliceId, task.id, "PLAN") ?? undefined, + summaryPath: resolveTaskFile(basePath, milestoneId, sliceId, task.id, "SUMMARY") ?? undefined, + }); + } + } + } + if (!usedDb && planPath) { + // File-based fallback: parse slice plan for task entries + const planContent = await loadFile(planPath); + if (planContent) { + const parsed = parsePlan(planContent); + for (const task of parsed.tasks) { + tasks.push({ + id: task.id, + title: task.title, + done: task.done, + planPath: resolveTaskFile(basePath, milestoneId, sliceId, task.id, "PLAN") ?? undefined, + summaryPath: resolveTaskFile(basePath, milestoneId, sliceId, task.id, "SUMMARY") ?? undefined, + }); + } } } - // When DB unavailable, tasks stays empty return { id: sliceId, @@ -125,23 +145,34 @@ export async function indexWorkspace(basePath: string, opts: IndexWorkspaceOptio const slices: WorkspaceSliceTarget[] = []; if (roadmapPath || isDbAvailable()) { - // Normalize slices from DB + // Normalize slices from DB, fall back to file-based parsing when DB has no data type NormSlice = { id: string; done: boolean; title: string; risk: string; depends: string[]; demo: string }; - let normSlices: NormSlice[]; + let normSlices: NormSlice[] | null = null; if (isDbAvailable()) { - normSlices = getMilestoneSlices(milestoneId).map(s => ({ id: s.id, done: s.status === "complete", title: s.title, risk: s.risk || "medium", depends: s.depends, demo: s.demo })); + const dbSlices = getMilestoneSlices(milestoneId); + if (dbSlices.length > 0) { + normSlices = dbSlices.map(s => ({ id: s.id, done: s.status === "complete", title: s.title, risk: s.risk || "medium", depends: s.depends, demo: s.demo })); + } // Get title from roadmap header if (roadmapPath) { const roadmapContent = await loadFile(roadmapPath); if (roadmapContent) title = titleFromRoadmapHeader(roadmapContent, milestoneId); } - } else { - normSlices = []; } + if (!normSlices && roadmapPath) { + // File-based fallback: parse roadmap for slice entries + const roadmapContent = await loadFile(roadmapPath); + if (roadmapContent) { + title = titleFromRoadmapHeader(roadmapContent, milestoneId); + const parsed = parseRoadmap(roadmapContent); + normSlices = parsed.slices.map(s => ({ id: s.id, done: s.done, title: s.title, risk: s.risk || "medium", depends: s.depends, demo: s.demo || "" })); + } + } + if (!normSlices) normSlices = []; - if (normSlices!.length > 0) { + if (normSlices.length > 0) { const sliceResults = await Promise.all( - normSlices!.map(async (slice) => { + normSlices.map(async (slice) => { return indexSlice(basePath, milestoneId, slice.id, slice.title, slice.done, { risk: slice.risk as RiskLevel, depends: slice.depends, demo: slice.demo }); }), ); diff --git a/src/tests/web-switch-project.test.ts b/src/tests/web-switch-project.test.ts index eae701fd0..df9bc6b8b 100644 --- a/src/tests/web-switch-project.test.ts +++ b/src/tests/web-switch-project.test.ts @@ -5,7 +5,7 @@ import { existsSync, statSync, } from "node:fs"; import { tmpdir, homedir } from "node:os"; -import { join, resolve } from "node:path"; +import { join, resolve, isAbsolute } from "node:path"; // --------------------------------------------------------------------------- // Test the core validation + persistence logic used by /api/switch-root @@ -162,7 +162,7 @@ describe("switch-root: path validation", () => { // Create a relative path that's valid from cwd const result = validateSwitchRoot(rootA); assert.ok(result.ok); - assert.ok(result.devRoot!.startsWith("/"), "Should be absolute path"); + assert.ok(isAbsolute(result.devRoot!), "Should be absolute path"); }); });