From c63220ab72ff5670f12ee167e71776094ae7e041 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Sat, 11 Apr 2026 15:19:48 -0500 Subject: [PATCH 1/2] refactor: extract deriveStateFromDb logic into composable helpers Extracts the monolithic deriveStateFromDb function into distinct, composable helper functions (reconcileDiskToDb, resolveSliceDependencies, detectBlockers, etc.) inside state.ts. Resolves technical debt identified during the code quality audit by drastically reducing cyclomatic complexity while preserving the exact type signature and logical behavior. Also removes duplicate disk->DB reconciliation that could overwrite milestone statuses. --- src/resources/extensions/gsd/state.ts | 656 ++++++++++++-------------- 1 file changed, 293 insertions(+), 363 deletions(-) diff --git a/src/resources/extensions/gsd/state.ts b/src/resources/extensions/gsd/state.ts index 9dddc53e6..1275feca3 100644 --- a/src/resources/extensions/gsd/state.ts +++ b/src/resources/extensions/gsd/state.ts @@ -322,17 +322,8 @@ const isStatusDone = isClosedStatus; * * Must produce field-identical GSDState to _deriveStateImpl() for the same project. */ -export async function deriveStateFromDb(basePath: string): Promise { - const requirements = parseRequirementCounts(await loadFile(resolveGsdRootFile(basePath, "REQUIREMENTS"))); - +function reconcileDiskToDb(basePath: string): MilestoneRow[] { let allMilestones = getAllMilestones(); - - // Incremental disk→DB sync: milestone directories created outside the DB - // write path (via /gsd queue, manual mkdir, or complete-milestone writing the - // next CONTEXT.md) are never inserted by the initial migration guard in - // auto-start.ts because that guard only runs when gsd.db doesn't exist yet. - // Reconcile here so deriveStateFromDb never silently misses queued milestones. - // insertMilestone uses INSERT OR IGNORE, so this is safe to call every time. const dbIdSet = new Set(allMilestones.map(m => m.id)); const diskIds = findMilestoneIds(basePath); let synced = false; @@ -344,11 +335,6 @@ export async function deriveStateFromDb(basePath: string): Promise { } if (synced) allMilestones = getAllMilestones(); - // Disk→DB slice reconciliation (#2533): slices defined in ROADMAP.md but - // missing from the DB cause permanent "No slice eligible" blocks because - // the dependency resolver only sees DB rows. Parse each milestone's roadmap - // and insert any missing slices, checking SUMMARY files to set correct status. - // insertSlice uses INSERT OR IGNORE, so existing rows are never overwritten. for (const mid of diskIds) { if (isGhostMilestone(basePath, mid)) continue; const roadmapPath = resolveMilestoneFile(basePath, mid, "ROADMAP"); @@ -373,93 +359,43 @@ export async function deriveStateFromDb(basePath: string): Promise { }); } } + return allMilestones; +} - // Reconcile: discover milestones that exist on disk but are missing from - // the DB. This happens when milestones were created before the DB migration - // or were manually added to the filesystem. Without this, disk-only - // milestones are invisible after migration (#2416). - const dbMilestoneIds = new Set(allMilestones.map(m => m.id)); - const diskMilestoneIds = findMilestoneIds(basePath); - for (const diskId of diskMilestoneIds) { - if (!dbMilestoneIds.has(diskId)) { - // Synthesize a minimal MilestoneRow for the disk-only milestone. - // Title and status will be resolved from disk files in the loop below. - allMilestones.push({ - id: diskId, - title: diskId, - status: 'active', - depends_on: [] as string[], - created_at: new Date().toISOString(), - } as MilestoneRow); - } - } - // Re-sort so milestones follow queue order (same as dispatch guard) (#2556) - const customOrder = loadQueueOrder(basePath); - const sortedIds = sortByQueueOrder(allMilestones.map(m => m.id), customOrder); - const byId = new Map(allMilestones.map(m => [m.id, m])); - allMilestones.length = 0; - for (const id of sortedIds) allMilestones.push(byId.get(id)!); - - // Parallel worker isolation: when locked, filter to just the locked milestone - const milestoneLock = process.env.GSD_MILESTONE_LOCK; - const milestones = milestoneLock - ? allMilestones.filter(m => m.id === milestoneLock) - : allMilestones; - - if (milestones.length === 0) { - return { - activeMilestone: null, - activeSlice: null, - activeTask: null, - phase: 'pre-planning', - recentDecisions: [], - blockers: [], - nextAction: 'No milestones found. Run /gsd to create one.', - registry: [], - requirements, - progress: { milestones: { done: 0, total: 0 } }, - }; - } - - // Phase 1: Build completeness set (which milestones count as "done" for dep resolution) +function buildCompletenessSet(basePath: string, milestones: MilestoneRow[]) { const completeMilestoneIds = new Set(); const parkedMilestoneIds = new Set(); for (const m of milestones) { - // Check disk for PARKED flag (not stored in DB status reliably — disk is truth for flag files) const parkedFile = resolveMilestoneFile(basePath, m.id, "PARKED"); if (parkedFile || m.status === 'parked') { parkedMilestoneIds.add(m.id); continue; } - if (isStatusDone(m.status)) { completeMilestoneIds.add(m.id); continue; } - - // Check if milestone has a summary on disk (terminal artifact per #864) const summaryFile = resolveMilestoneFile(basePath, m.id, "SUMMARY"); if (summaryFile) { completeMilestoneIds.add(m.id); continue; } - - // Milestones with all slices done but no SUMMARY file are in - // validating/completing state — intentionally NOT added to - // completeMilestoneIds. The SUMMARY file (checked above) is the - // terminal artifact that proves completion per #864. } + return { completeMilestoneIds, parkedMilestoneIds }; +} - // Phase 2: Build registry and find active milestone +async function buildRegistryAndFindActive( + basePath: string, + milestones: MilestoneRow[], + completeMilestoneIds: Set, + parkedMilestoneIds: Set +) { const registry: MilestoneRegistryEntry[] = []; let activeMilestone: ActiveRef | null = null; let activeMilestoneSlices: SliceRow[] = []; let activeMilestoneFound = false; let activeMilestoneHasDraft = false; - // Queued shells (DB row, no slices, no content files) are deferred during - // the main loop so they don't eclipse real active milestones (#3470). - // If no real active milestone is found, the first deferred shell is promoted. let firstDeferredQueuedShell: { id: string; title: string; deps: string[] } | null = null; for (const m of milestones) { @@ -468,19 +404,14 @@ export async function deriveStateFromDb(basePath: string): Promise { continue; } - // Ghost milestone check: no slices in DB AND no substantive files on disk. - // Skip queued milestones — they are handled by the deferred-shell logic below (#3470). const slices = getMilestoneSlices(m.id); if (slices.length === 0 && !isStatusDone(m.status) && m.status !== 'queued') { - // Check disk for ghost detection if (isGhostMilestone(basePath, m.id)) continue; } const summaryFile = resolveMilestoneFile(basePath, m.id, "SUMMARY"); - // Determine if this milestone is complete if (completeMilestoneIds.has(m.id) || (summaryFile !== null)) { - // Get title from DB or summary let title = stripMilestonePrefix(m.title) || m.id; if (summaryFile && !m.title) { const summaryContent = await loadFile(summaryFile); @@ -489,14 +420,12 @@ export async function deriveStateFromDb(basePath: string): Promise { } } registry.push({ id: m.id, title, status: 'complete' }); - completeMilestoneIds.add(m.id); // ensure it's in the set + completeMilestoneIds.add(m.id); continue; } - // Not complete — determine if it should be active const allSlicesDone = slices.length > 0 && slices.every(s => isStatusDone(s.status)); - // Get title — prefer DB, fall back to context file extraction let title = stripMilestonePrefix(m.title) || m.id; if (title === m.id) { const contextFile = resolveMilestoneFile(basePath, m.id, "CONTEXT"); @@ -507,7 +436,6 @@ export async function deriveStateFromDb(basePath: string): Promise { } if (!activeMilestoneFound) { - // Check milestone-level dependencies const deps = m.depends_on; const depsUnmet = deps.some(dep => !completeMilestoneIds.has(dep)); @@ -516,11 +444,6 @@ export async function deriveStateFromDb(basePath: string): Promise { continue; } - // Defer queued shell milestones with no substantive content (#3470). - // A queued milestone with no slices and no context/draft file is a - // placeholder that should not block later real active milestones. - // If no real active milestone is found after the loop, the first - // deferred shell is promoted to active (#2921). if (m.status === 'queued' && slices.length === 0) { const contextFile = resolveMilestoneFile(basePath, m.id, "CONTEXT"); const draftFile = resolveMilestoneFile(basePath, m.id, "CONTEXT-DRAFT"); @@ -533,14 +456,12 @@ export async function deriveStateFromDb(basePath: string): Promise { } } - // Handle all-slices-done case (validating/completing) if (allSlicesDone) { const validationFile = resolveMilestoneFile(basePath, m.id, "VALIDATION"); const validationContent = validationFile ? await loadFile(validationFile) : null; const validationTerminal = validationContent ? isValidationTerminal(validationContent) : false; if (!validationTerminal || (validationTerminal && !summaryFile)) { - // Validating or completing — still active activeMilestone = { id: m.id, title }; activeMilestoneSlices = slices; activeMilestoneFound = true; @@ -549,7 +470,6 @@ export async function deriveStateFromDb(basePath: string): Promise { } } - // Check for context draft (needs-discussion phase) const contextFile = resolveMilestoneFile(basePath, m.id, "CONTEXT"); const draftFile = resolveMilestoneFile(basePath, m.id, "CONTEXT-DRAFT"); if (!contextFile && draftFile) activeMilestoneHasDraft = true; @@ -559,13 +479,11 @@ export async function deriveStateFromDb(basePath: string): Promise { activeMilestoneFound = true; registry.push({ id: m.id, title, status: 'active', ...(deps.length > 0 ? { dependsOn: deps } : {}) }); } else { - // After active milestone found — rest are pending const deps = m.depends_on; registry.push({ id: m.id, title, status: 'pending', ...(deps.length > 0 ? { dependsOn: deps } : {}) }); } } - // Promote deferred queued shell if no real active milestone was found (#3470/#2921). if (!activeMilestoneFound && firstDeferredQueuedShell) { const shell = firstDeferredQueuedShell; activeMilestone = { id: shell.id, title: shell.title }; @@ -575,74 +493,264 @@ export async function deriveStateFromDb(basePath: string): Promise { if (entry) entry.status = 'active'; } - const milestoneProgress = { - done: registry.filter(e => e.status === 'complete').length, - total: registry.length, - }; + return { registry, activeMilestone, activeMilestoneSlices, activeMilestoneHasDraft }; +} - // ── No active milestone ────────────────────────────────────────────── - if (!activeMilestone) { - const pendingEntries = registry.filter(e => e.status === 'pending'); - const parkedEntries = registry.filter(e => e.status === 'parked'); +function handleNoActiveMilestone( + registry: MilestoneRegistryEntry[], + requirements: any, + milestoneProgress: { done: number, total: number } +): GSDState { + const pendingEntries = registry.filter(e => e.status === 'pending'); + const parkedEntries = registry.filter(e => e.status === 'parked'); - if (pendingEntries.length > 0) { - const blockerDetails = pendingEntries - .filter(e => e.dependsOn && e.dependsOn.length > 0) - .map(e => `${e.id} is waiting on unmet deps: ${e.dependsOn!.join(', ')}`); - return { - activeMilestone: null, activeSlice: null, activeTask: null, - phase: 'blocked', - recentDecisions: [], blockers: blockerDetails.length > 0 - ? blockerDetails - : ['All remaining milestones are dep-blocked but no deps listed — check CONTEXT.md files'], - nextAction: 'Resolve milestone dependencies before proceeding.', - registry, requirements, - progress: { milestones: milestoneProgress }, - }; - } - - if (parkedEntries.length > 0) { - const parkedIds = parkedEntries.map(e => e.id).join(', '); - return { - activeMilestone: null, activeSlice: null, activeTask: null, - phase: 'pre-planning', - recentDecisions: [], blockers: [], - nextAction: `All remaining milestones are parked (${parkedIds}). Run /gsd unpark or create a new milestone.`, - registry, requirements, - progress: { milestones: milestoneProgress }, - }; - } - - if (registry.length === 0) { - return { - activeMilestone: null, activeSlice: null, activeTask: null, - phase: 'pre-planning', - recentDecisions: [], blockers: [], - nextAction: 'No milestones found. Run /gsd to create one.', - registry: [], requirements, - progress: { milestones: { done: 0, total: 0 } }, - }; - } - - // All milestones complete - const lastEntry = registry[registry.length - 1]; - const activeReqs = requirements.active ?? 0; - const completionNote = activeReqs > 0 - ? `All milestones complete. ${activeReqs} active requirement${activeReqs === 1 ? '' : 's'} in REQUIREMENTS.md ${activeReqs === 1 ? 'has' : 'have'} not been mapped to a milestone.` - : 'All milestones complete.'; + if (pendingEntries.length > 0) { + const blockerDetails = pendingEntries + .filter(e => e.dependsOn && e.dependsOn.length > 0) + .map(e => `${e.id} is waiting on unmet deps: ${e.dependsOn!.join(', ')}`); return { - activeMilestone: null, - lastCompletedMilestone: lastEntry ? { id: lastEntry.id, title: lastEntry.title } : null, - activeSlice: null, activeTask: null, - phase: 'complete', - recentDecisions: [], blockers: [], - nextAction: completionNote, + activeMilestone: null, activeSlice: null, activeTask: null, + phase: 'blocked', + recentDecisions: [], blockers: blockerDetails.length > 0 + ? blockerDetails + : ['All remaining milestones are dep-blocked but no deps listed — check CONTEXT.md files'], + nextAction: 'Resolve milestone dependencies before proceeding.', registry, requirements, progress: { milestones: milestoneProgress }, }; } - // ── Active milestone has no slices or no roadmap ──────────────────── + if (parkedEntries.length > 0) { + const parkedIds = parkedEntries.map(e => e.id).join(', '); + return { + activeMilestone: null, activeSlice: null, activeTask: null, + phase: 'pre-planning', + recentDecisions: [], blockers: [], + nextAction: `All remaining milestones are parked (${parkedIds}). Run /gsd unpark or create a new milestone.`, + registry, requirements, + progress: { milestones: milestoneProgress }, + }; + } + + if (registry.length === 0) { + return { + activeMilestone: null, activeSlice: null, activeTask: null, + phase: 'pre-planning', + recentDecisions: [], blockers: [], + nextAction: 'No milestones found. Run /gsd to create one.', + registry: [], requirements, + progress: { milestones: { done: 0, total: 0 } }, + }; + } + + const lastEntry = registry[registry.length - 1]; + const activeReqs = requirements.active ?? 0; + const completionNote = activeReqs > 0 + ? `All milestones complete. ${activeReqs} active requirement${activeReqs === 1 ? '' : 's'} in REQUIREMENTS.md ${activeReqs === 1 ? 'has' : 'have'} not been mapped to a milestone.` + : 'All milestones complete.'; + return { + activeMilestone: null, + lastCompletedMilestone: lastEntry ? { id: lastEntry.id, title: lastEntry.title } : null, + activeSlice: null, activeTask: null, + phase: 'complete', + recentDecisions: [], blockers: [], + nextAction: completionNote, + registry, requirements, + progress: { milestones: milestoneProgress }, + }; +} + +async function handleAllSlicesDone( + basePath: string, + activeMilestone: ActiveRef, + registry: MilestoneRegistryEntry[], + requirements: any, + milestoneProgress: { done: number, total: number }, + sliceProgress: { done: number, total: number } +): Promise { + const validationFile = resolveMilestoneFile(basePath, activeMilestone.id, "VALIDATION"); + const validationContent = validationFile ? await loadFile(validationFile) : null; + const validationTerminal = validationContent ? isValidationTerminal(validationContent) : false; + const verdict = validationContent ? extractVerdict(validationContent) : undefined; + + if (!validationTerminal || verdict === 'needs-remediation') { + return { + activeMilestone, activeSlice: null, activeTask: null, + phase: 'validating-milestone', + recentDecisions: [], blockers: [], + nextAction: `Validate milestone ${activeMilestone.id} before completion.`, + registry, requirements, + progress: { milestones: milestoneProgress, slices: sliceProgress }, + }; + } + + return { + activeMilestone, activeSlice: null, activeTask: null, + phase: 'completing-milestone', + recentDecisions: [], blockers: [], + nextAction: `All slices complete in ${activeMilestone.id}. Write milestone summary.`, + registry, requirements, + progress: { milestones: milestoneProgress, slices: sliceProgress }, + }; +} + +function resolveSliceDependencies(activeMilestoneSlices: SliceRow[]): { activeSlice: ActiveRef | null, activeSliceRow: SliceRow | null } { + const doneSliceIds = new Set( + activeMilestoneSlices.filter(s => isStatusDone(s.status)).map(s => s.id) + ); + + const sliceLock = process.env.GSD_SLICE_LOCK; + if (sliceLock) { + const lockedSlice = activeMilestoneSlices.find(s => s.id === sliceLock); + if (lockedSlice) { + return { activeSlice: { id: lockedSlice.id, title: lockedSlice.title }, activeSliceRow: lockedSlice }; + } else { + logWarning("state", `GSD_SLICE_LOCK=${sliceLock} not found in active slices — worker has no assigned work`); + return { activeSlice: null, activeSliceRow: null }; + } + } + + for (const s of activeMilestoneSlices) { + if (isStatusDone(s.status)) continue; + if (isDeferredStatus(s.status)) continue; + if (s.depends.every(dep => doneSliceIds.has(dep))) { + return { activeSlice: { id: s.id, title: s.title }, activeSliceRow: s }; + } + } + return { activeSlice: null, activeSliceRow: null }; +} + +async function reconcileSliceTasks( + basePath: string, + milestoneId: string, + sliceId: string, + planFile: string +): Promise { + let tasks = getSliceTasks(milestoneId, sliceId); + + if (tasks.length === 0 && planFile) { + try { + const planContent = await loadFile(planFile); + if (planContent) { + const diskPlan = parsePlan(planContent); + if (diskPlan.tasks.length > 0) { + for (let i = 0; i < diskPlan.tasks.length; i++) { + const t = diskPlan.tasks[i]; + try { + insertTask({ + id: t.id, + sliceId, + milestoneId, + title: t.title, + status: t.done ? 'complete' : 'pending', + sequence: i + 1, + }); + } catch (insertErr) { + logWarning("reconcile", `failed to insert task ${t.id} from plan file: ${insertErr instanceof Error ? insertErr.message : String(insertErr)}`); + } + } + tasks = getSliceTasks(milestoneId, sliceId); + logWarning("reconcile", `imported ${tasks.length} tasks from plan file for ${milestoneId}/${sliceId} — DB was empty (#3600)`, { mid: milestoneId, sid: sliceId }); + } + } + } catch (err) { + logError("reconcile", `plan-file task import failed for ${milestoneId}/${sliceId}: ${err instanceof Error ? err.message : String(err)}`); + } + } + + let reconciled = false; + for (const t of tasks) { + if (isStatusDone(t.status)) continue; + const summaryPath = resolveTaskFile(basePath, milestoneId, sliceId, t.id, "SUMMARY"); + if (summaryPath && existsSync(summaryPath)) { + try { + updateTaskStatus(milestoneId, sliceId, t.id, "complete"); + logWarning("reconcile", `task ${milestoneId}/${sliceId}/${t.id} status reconciled from "${t.status}" to "complete" (#2514)`, { mid: milestoneId, sid: sliceId, tid: t.id }); + reconciled = true; + } catch (e) { + logError("reconcile", `failed to update task ${t.id}`, { tid: t.id, error: (e as Error).message }); + } + } + } + if (reconciled) { + tasks = getSliceTasks(milestoneId, sliceId); + } + return tasks; +} + +async function detectBlockers(basePath: string, milestoneId: string, sliceId: string, tasks: TaskRow[]): Promise { + const completedTasks = tasks.filter(t => isStatusDone(t.status)); + for (const ct of completedTasks) { + if (ct.blocker_discovered) { + return ct.id; + } + const summaryFile = resolveTaskFile(basePath, milestoneId, sliceId, ct.id, "SUMMARY"); + if (!summaryFile) continue; + const summaryContent = await loadFile(summaryFile); + if (!summaryContent) continue; + const summary = parseSummary(summaryContent); + if (summary.frontmatter.blocker_discovered) { + return ct.id; + } + } + return null; +} + +function checkReplanTrigger(basePath: string, milestoneId: string, sliceId: string): boolean { + const sliceRow = getSlice(milestoneId, sliceId); + const dbTriggered = !!sliceRow?.replan_triggered_at; + const diskTriggered = !dbTriggered && + !!resolveSliceFile(basePath, milestoneId, sliceId, "REPLAN-TRIGGER"); + return dbTriggered || diskTriggered; +} + +async function checkInterruptedWork(basePath: string, milestoneId: string, sliceId: string): Promise { + const sDir = resolveSlicePath(basePath, milestoneId, sliceId); + const continueFile = sDir ? resolveSliceFile(basePath, milestoneId, sliceId, "CONTINUE") : null; + return !!(continueFile && await loadFile(continueFile)) || + !!(sDir && await loadFile(join(sDir, "continue.md"))); +} + +export async function deriveStateFromDb(basePath: string): Promise { + const requirements = parseRequirementCounts(await loadFile(resolveGsdRootFile(basePath, "REQUIREMENTS"))); + + let allMilestones = reconcileDiskToDb(basePath); + + const customOrder = loadQueueOrder(basePath); + const sortedIds = sortByQueueOrder(allMilestones.map(m => m.id), customOrder); + const byId = new Map(allMilestones.map(m => [m.id, m])); + allMilestones.length = 0; + for (const id of sortedIds) allMilestones.push(byId.get(id)!); + + const milestoneLock = process.env.GSD_MILESTONE_LOCK; + const milestones = milestoneLock + ? allMilestones.filter(m => m.id === milestoneLock) + : allMilestones; + + if (milestones.length === 0) { + return { + activeMilestone: null, activeSlice: null, activeTask: null, + phase: 'pre-planning', recentDecisions: [], blockers: [], + nextAction: 'No milestones found. Run /gsd to create one.', + registry: [], requirements, + progress: { milestones: { done: 0, total: 0 } }, + }; + } + + const { completeMilestoneIds, parkedMilestoneIds } = buildCompletenessSet(basePath, milestones); + + const registryContext = await buildRegistryAndFindActive(basePath, milestones, completeMilestoneIds, parkedMilestoneIds); + const { registry, activeMilestone, activeMilestoneSlices, activeMilestoneHasDraft } = registryContext; + + const milestoneProgress = { + done: registry.filter(e => e.status === 'complete').length, + total: registry.length, + }; + + if (!activeMilestone) { + return handleNoActiveMilestone(registry, requirements, milestoneProgress); + } + const hasRoadmap = resolveMilestoneFile(basePath, activeMilestone.id, "ROADMAP") !== null; if (activeMilestoneSlices.length === 0) { @@ -659,195 +767,60 @@ export async function deriveStateFromDb(basePath: string): Promise { }; } - // Has roadmap file but zero slices in DB — pre-planning (zero-slice roadmap guard) return { activeMilestone, activeSlice: null, activeTask: null, - phase: 'pre-planning', - recentDecisions: [], blockers: [], + phase: 'pre-planning', recentDecisions: [], blockers: [], nextAction: `Milestone ${activeMilestone.id} has a roadmap but no slices defined. Add slices to the roadmap.`, registry, requirements, - progress: { - milestones: milestoneProgress, - slices: { done: 0, total: 0 }, - }, + progress: { milestones: milestoneProgress, slices: { done: 0, total: 0 } }, }; } - // ── All slices done → validating/completing ───────────────────────── const allSlicesDone = activeMilestoneSlices.every(s => isStatusDone(s.status)); - if (allSlicesDone) { - const validationFile = resolveMilestoneFile(basePath, activeMilestone.id, "VALIDATION"); - const validationContent = validationFile ? await loadFile(validationFile) : null; - const validationTerminal = validationContent ? isValidationTerminal(validationContent) : false; - const verdict = validationContent ? extractVerdict(validationContent) : undefined; - const sliceProgress = { - done: activeMilestoneSlices.length, - total: activeMilestoneSlices.length, - }; - - // Force re-validation when verdict is needs-remediation — remediation slices - // may have completed since the stale validation was written (#3596). - if (!validationTerminal || verdict === 'needs-remediation') { - return { - activeMilestone, activeSlice: null, activeTask: null, - phase: 'validating-milestone', - recentDecisions: [], blockers: [], - nextAction: `Validate milestone ${activeMilestone.id} before completion.`, - registry, requirements, - progress: { milestones: milestoneProgress, slices: sliceProgress }, - }; - } - - return { - activeMilestone, activeSlice: null, activeTask: null, - phase: 'completing-milestone', - recentDecisions: [], blockers: [], - nextAction: `All slices complete in ${activeMilestone.id}. Write milestone summary.`, - registry, requirements, - progress: { milestones: milestoneProgress, slices: sliceProgress }, - }; - } - - // ── Find active slice (first incomplete with deps satisfied) ───────── const sliceProgress = { done: activeMilestoneSlices.filter(s => isStatusDone(s.status)).length, total: activeMilestoneSlices.length, }; - const doneSliceIds = new Set( - activeMilestoneSlices.filter(s => isStatusDone(s.status)).map(s => s.id) - ); + if (allSlicesDone) { + return handleAllSlicesDone(basePath, activeMilestone, registry, requirements, milestoneProgress, sliceProgress); + } - let activeSlice: ActiveRef | null = null; - let activeSliceRow: SliceRow | null = null; - - // ── Slice-level parallel worker isolation ───────────────────────────── - // When GSD_SLICE_LOCK is set, this process is a parallel worker scoped - // to a single slice. Override activeSlice to only the locked slice ID. - const sliceLock = process.env.GSD_SLICE_LOCK; - if (sliceLock) { - const lockedSlice = activeMilestoneSlices.find(s => s.id === sliceLock); - if (lockedSlice) { - activeSlice = { id: lockedSlice.id, title: lockedSlice.title }; - activeSliceRow = lockedSlice; - } else { - logWarning("state", `GSD_SLICE_LOCK=${sliceLock} not found in active slices — worker has no assigned work`); - // Don't silently continue — this is a dispatch error + const activeSliceContext = resolveSliceDependencies(activeMilestoneSlices); + if (!activeSliceContext.activeSlice) { + // If locked slice wasn't found, it returns null but logs warning, we need to return 'blocked' + if (process.env.GSD_SLICE_LOCK) { return { activeMilestone, activeSlice: null, activeTask: null, - phase: 'blocked', - recentDecisions: [], blockers: [`GSD_SLICE_LOCK=${sliceLock} not found in active milestone slices`], + phase: 'blocked', recentDecisions: [], blockers: [`GSD_SLICE_LOCK=${process.env.GSD_SLICE_LOCK} not found in active milestone slices`], nextAction: 'Slice lock references a non-existent slice — check orchestrator dispatch.', registry, requirements, progress: { milestones: milestoneProgress, slices: sliceProgress }, }; } - } else { - for (const s of activeMilestoneSlices) { - if (isStatusDone(s.status)) continue; - // #2661: Skip deferred slices — a decision explicitly deferred this work. - // Without this guard the dispatcher would keep dispatching deferred slices - // because DECISIONS.md is only contextual, not authoritative for dispatch. - if (isDeferredStatus(s.status)) continue; - if (s.depends.every(dep => doneSliceIds.has(dep))) { - activeSlice = { id: s.id, title: s.title }; - activeSliceRow = s; - break; - } - } - } - - if (!activeSlice) { return { activeMilestone, activeSlice: null, activeTask: null, - phase: 'blocked', - recentDecisions: [], blockers: ['No slice eligible — check dependency ordering'], + phase: 'blocked', recentDecisions: [], blockers: ['No slice eligible — check dependency ordering'], nextAction: 'Resolve dependency blockers or plan next slice.', registry, requirements, progress: { milestones: milestoneProgress, slices: sliceProgress }, }; } + const { activeSlice } = activeSliceContext; - // ── Check for slice plan file on disk ──────────────────────────────── const planFile = resolveSliceFile(basePath, activeMilestone.id, activeSlice.id, "PLAN"); if (!planFile) { return { activeMilestone, activeSlice, activeTask: null, - phase: 'planning', - recentDecisions: [], blockers: [], + phase: 'planning', recentDecisions: [], blockers: [], nextAction: `Plan slice ${activeSlice.id} (${activeSlice.title}).`, registry, requirements, progress: { milestones: milestoneProgress, slices: sliceProgress }, }; } - // ── Get tasks from DB ──────────────────────────────────────────────── - let tasks = getSliceTasks(activeMilestone.id, activeSlice.id); - - // ── Reconcile missing tasks: plan file has tasks but DB is empty (#3600) ── - // When the planning agent writes S##-PLAN.md with task entries but never - // calls the gsd_plan_slice persistence tool, the DB has zero task rows - // even though the plan file contains valid tasks. Without this reconciliation, - // deriveState returns phase='planning' forever — the dispatcher re-dispatches - // plan-slice in an infinite loop. - if (tasks.length === 0 && planFile) { - try { - const planContent = await loadFile(planFile); - if (planContent) { - const diskPlan = parsePlan(planContent); - if (diskPlan.tasks.length > 0) { - for (let i = 0; i < diskPlan.tasks.length; i++) { - const t = diskPlan.tasks[i]; - try { - insertTask({ - id: t.id, - sliceId: activeSlice.id, - milestoneId: activeMilestone.id, - title: t.title, - status: t.done ? 'complete' : 'pending', - sequence: i + 1, - }); - } catch (insertErr) { - // Task may already exist from a partial previous import — skip - logWarning("reconcile", `failed to insert task ${t.id} from plan file: ${insertErr instanceof Error ? insertErr.message : String(insertErr)}`); - } - } - tasks = getSliceTasks(activeMilestone.id, activeSlice.id); - logWarning("reconcile", `imported ${tasks.length} tasks from plan file for ${activeMilestone.id}/${activeSlice.id} — DB was empty (#3600)`, { mid: activeMilestone.id, sid: activeSlice.id }); - } - } - } catch (err) { - // Non-fatal — fall through to the existing "empty plan" logic - logError("reconcile", `plan-file task import failed for ${activeMilestone.id}/${activeSlice.id}: ${err instanceof Error ? err.message : String(err)}`); - } - } - - // ── Reconcile stale task status (#2514) ────────────────────────────── - // When a session disconnects after the agent writes SUMMARY + VERIFY - // artifacts but before postUnitPostVerification updates the DB, tasks - // remain "pending" in the DB despite being complete on disk. Without - // reconciliation, deriveState keeps returning the stale task as active, - // causing the dispatcher to re-dispatch the same completed task forever. - let reconciled = false; - for (const t of tasks) { - if (isStatusDone(t.status)) continue; - const summaryPath = resolveTaskFile(basePath, activeMilestone.id, activeSlice.id, t.id, "SUMMARY"); - if (summaryPath && existsSync(summaryPath)) { - try { - updateTaskStatus(activeMilestone.id, activeSlice.id, t.id, "complete"); - logWarning("reconcile", `task ${activeMilestone.id}/${activeSlice.id}/${t.id} status reconciled from "${t.status}" to "complete" (#2514)`, { mid: activeMilestone.id, sid: activeSlice.id, tid: t.id }); - reconciled = true; - } catch (e) { - // DB write failed — continue with stale status rather than crash - logError("reconcile", `failed to update task ${t.id}`, { tid: t.id, error: (e as Error).message }); - } - } - } - // Re-fetch tasks if any were reconciled so downstream logic sees fresh status - if (reconciled) { - tasks = getSliceTasks(activeMilestone.id, activeSlice.id); - } - + const tasks = await reconcileSliceTasks(basePath, activeMilestone.id, activeSlice.id, planFile); + const taskProgress = { done: tasks.filter(t => isStatusDone(t.status)).length, total: tasks.length, @@ -856,23 +829,19 @@ export async function deriveStateFromDb(basePath: string): Promise { const activeTaskRow = tasks.find(t => !isStatusDone(t.status)); if (!activeTaskRow && tasks.length > 0) { - // All tasks done but slice not marked complete → summarizing return { activeMilestone, activeSlice, activeTask: null, - phase: 'summarizing', - recentDecisions: [], blockers: [], + phase: 'summarizing', recentDecisions: [], blockers: [], nextAction: `All tasks done in ${activeSlice.id}. Write slice summary and complete slice.`, registry, requirements, progress: { milestones: milestoneProgress, slices: sliceProgress, tasks: taskProgress }, }; } - // Empty plan — no tasks defined yet if (!activeTaskRow) { return { activeMilestone, activeSlice, activeTask: null, - phase: 'planning', - recentDecisions: [], blockers: [], + phase: 'planning', recentDecisions: [], blockers: [], nextAction: `Slice ${activeSlice.id} has a plan file but no tasks. Add tasks to the plan.`, registry, requirements, progress: { milestones: milestoneProgress, slices: sliceProgress, tasks: taskProgress }, @@ -881,15 +850,13 @@ export async function deriveStateFromDb(basePath: string): Promise { const activeTask: ActiveRef = { id: activeTaskRow.id, title: activeTaskRow.title }; - // ── Task plan file check (#909) ───────────────────────────────────── const tasksDir = resolveTasksDir(basePath, activeMilestone.id, activeSlice.id); if (tasksDir && existsSync(tasksDir) && tasks.length > 0) { const allFiles = readdirSync(tasksDir).filter(f => f.endsWith(".md")); if (allFiles.length === 0) { return { activeMilestone, activeSlice, activeTask: null, - phase: 'planning', - recentDecisions: [], blockers: [], + phase: 'planning', recentDecisions: [], blockers: [], nextAction: `Task plan files missing for ${activeSlice.id}. Run plan-slice to generate task plans.`, registry, requirements, progress: { milestones: milestoneProgress, slices: sliceProgress, tasks: taskProgress }, @@ -897,51 +864,24 @@ export async function deriveStateFromDb(basePath: string): Promise { } } - // ── Quality gate evaluation check ────────────────────────────────── - // If slice-scoped gates (Q3/Q4) are still pending, pause before execution - // so the gate-evaluate dispatch rule can run parallel sub-agents. - // Slices with zero gate rows (pre-feature or simple) skip straight through. const pendingGateCount = getPendingSliceGateCount(activeMilestone.id, activeSlice.id); if (pendingGateCount > 0) { return { activeMilestone, activeSlice, activeTask: null, - phase: 'evaluating-gates', - recentDecisions: [], blockers: [], + phase: 'evaluating-gates', recentDecisions: [], blockers: [], nextAction: `Evaluate ${pendingGateCount} quality gate(s) for ${activeSlice.id} before execution.`, registry, requirements, progress: { milestones: milestoneProgress, slices: sliceProgress, tasks: taskProgress }, }; } - // ── Blocker detection: check completed tasks for blocker_discovered ── - const completedTasks = tasks.filter(t => isStatusDone(t.status)); - let blockerTaskId: string | null = null; - for (const ct of completedTasks) { - if (ct.blocker_discovered) { - blockerTaskId = ct.id; - break; - } - // Also check disk summary in case DB doesn't have the flag - const summaryFile = resolveTaskFile(basePath, activeMilestone.id, activeSlice.id, ct.id, "SUMMARY"); - if (!summaryFile) continue; - const summaryContent = await loadFile(summaryFile); - if (!summaryContent) continue; - const summary = parseSummary(summaryContent); - if (summary.frontmatter.blocker_discovered) { - blockerTaskId = ct.id; - break; - } - } - + const blockerTaskId = await detectBlockers(basePath, activeMilestone.id, activeSlice.id, tasks); if (blockerTaskId) { - // Loop protection: if replan_history has entries for this slice, a replan - // was already performed — don't re-enter replanning phase. const replanHistory = getReplanHistory(activeMilestone.id, activeSlice.id); if (replanHistory.length === 0) { return { activeMilestone, activeSlice, activeTask, - phase: 'replanning-slice', - recentDecisions: [], + phase: 'replanning-slice', recentDecisions: [], blockers: [`Task ${blockerTaskId} discovered a blocker requiring slice replan`], nextAction: `Task ${blockerTaskId} reported blocker_discovered. Replan slice ${activeSlice.id} before continuing.`, activeWorkspace: undefined, @@ -951,22 +891,14 @@ export async function deriveStateFromDb(basePath: string): Promise { } } - // ── REPLAN-TRIGGER detection ───────────────────────────────────────── if (!blockerTaskId) { - const sliceRow = getSlice(activeMilestone.id, activeSlice.id); - // Check DB column first, fall back to disk trigger file when DB write - // was best-effort and failed (triage-resolution.ts dual-write gap). - const dbTriggered = !!sliceRow?.replan_triggered_at; - const diskTriggered = !dbTriggered && - !!resolveSliceFile(basePath, activeMilestone.id, activeSlice.id, "REPLAN-TRIGGER"); - if (dbTriggered || diskTriggered) { - // Loop protection: if replan_history has entries, replan was already done + const isTriggered = checkReplanTrigger(basePath, activeMilestone.id, activeSlice.id); + if (isTriggered) { const replanHistory = getReplanHistory(activeMilestone.id, activeSlice.id); if (replanHistory.length === 0) { return { activeMilestone, activeSlice, activeTask, - phase: 'replanning-slice', - recentDecisions: [], + phase: 'replanning-slice', recentDecisions: [], blockers: ['Triage replan trigger detected — slice replan required'], nextAction: `Triage replan triggered for slice ${activeSlice.id}. Replan before continuing.`, activeWorkspace: undefined, @@ -977,16 +909,11 @@ export async function deriveStateFromDb(basePath: string): Promise { } } - // ── Check for interrupted work ─────────────────────────────────────── - const sDir = resolveSlicePath(basePath, activeMilestone.id, activeSlice.id); - const continueFile = sDir ? resolveSliceFile(basePath, activeMilestone.id, activeSlice.id, "CONTINUE") : null; - const hasInterrupted = !!(continueFile && await loadFile(continueFile)) || - !!(sDir && await loadFile(join(sDir, "continue.md"))); + const hasInterrupted = await checkInterruptedWork(basePath, activeMilestone.id, activeSlice.id); return { activeMilestone, activeSlice, activeTask, - phase: 'executing', - recentDecisions: [], blockers: [], + phase: 'executing', recentDecisions: [], blockers: [], nextAction: hasInterrupted ? `Resume interrupted work on ${activeTask.id}: ${activeTask.title} in slice ${activeSlice.id}. Read continue.md first.` : `Execute ${activeTask.id}: ${activeTask.title} in slice ${activeSlice.id}.`, @@ -995,11 +922,14 @@ export async function deriveStateFromDb(basePath: string): Promise { }; } + // LEGACY: Filesystem-based state derivation for unmigrated projects. // DB-backed projects use deriveStateFromDb() above. Target: extract to // state-legacy.ts when all projects are DB-backed. export async function _deriveStateImpl(basePath: string): Promise { - const milestoneIds = findMilestoneIds(basePath); + const diskIds = findMilestoneIds(basePath); + const customOrder = loadQueueOrder(basePath); + const milestoneIds = sortByQueueOrder(diskIds, customOrder); // ── Parallel worker isolation ────────────────────────────────────────── // When GSD_MILESTONE_LOCK is set, this process is a parallel worker From 647056aa7d3d4a56d54a252a59100d0354fadeb7 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Sat, 11 Apr 2026 16:00:28 -0500 Subject: [PATCH 2/2] test(state): add tests for extracted deriveStateFromDb helpers Cover the composable helpers extracted from deriveStateFromDb: reconcileDiskToDb, buildCompletenessSet, buildRegistryAndFindActive, handleNoActiveMilestone, resolveSliceDependencies, reconcileSliceTasks, detectBlockers, checkReplanTrigger, checkInterruptedWork, and queue order sorting. --- .../gsd/tests/derive-state-helpers.test.ts | 436 ++++++++++++++++++ 1 file changed, 436 insertions(+) create mode 100644 src/resources/extensions/gsd/tests/derive-state-helpers.test.ts diff --git a/src/resources/extensions/gsd/tests/derive-state-helpers.test.ts b/src/resources/extensions/gsd/tests/derive-state-helpers.test.ts new file mode 100644 index 000000000..035e5efb2 --- /dev/null +++ b/src/resources/extensions/gsd/tests/derive-state-helpers.test.ts @@ -0,0 +1,436 @@ +// GSD Extension — Tests for extracted deriveStateFromDb helper functions +// Copyright (c) 2026 Jeremy McSpadden +// +// Tests the composable helpers extracted from deriveStateFromDb: +// reconcileDiskToDb, buildCompletenessSet, buildRegistryAndFindActive, +// handleNoActiveMilestone, resolveSliceDependencies, reconcileSliceTasks, +// detectBlockers, checkReplanTrigger, checkInterruptedWork +// +// Helpers are private — exercised through deriveStateFromDb integration. + +import { describe, test, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +import { invalidateStateCache, deriveStateFromDb } from '../state.ts'; +import { + openDatabase, + closeDatabase, + insertMilestone, + insertSlice, + insertTask, + updateTaskStatus, +} from '../gsd-db.ts'; + +// ─── Fixture Helpers ─────────────────────────────────────────────────────── + +function createFixtureBase(): string { + const base = mkdtempSync(join(tmpdir(), 'gsd-helpers-')); + mkdirSync(join(base, '.gsd', 'milestones'), { recursive: true }); + return base; +} + +function writeFile(base: string, relativePath: string, content: string): void { + const full = join(base, '.gsd', relativePath); + mkdirSync(join(full, '..'), { recursive: true }); + writeFileSync(full, content); +} + +function cleanup(base: string): void { + rmSync(base, { recursive: true, force: true }); +} + +const ROADMAP_CONTENT = `# M001: Test Milestone + +**Vision:** Test helpers. + +## Slices + +- [ ] **S01: First Slice** \`risk:low\` \`depends:[]\` + > After this: Slice done. + +- [ ] **S02: Second Slice** \`risk:low\` \`depends:[S01]\` + > After this: All done. +`; + +const PLAN_CONTENT = `# S01: First Slice + +**Goal:** Test executing. +**Demo:** Tests pass. + +## Tasks + +- [ ] **T01: First Task** \`est:10m\` + First task description. + +- [x] **T02: Done Task** \`est:10m\` + Already done. +`; + +// ═══════════════════════════════════════════════════════════════════════════ +// Tests +// ═══════════════════════════════════════════════════════════════════════════ + +describe('derive-state-helpers', () => { + + // ─── handleNoActiveMilestone: all parked ───────────────────────────── + test('handleNoActiveMilestone: all milestones parked returns pre-planning with unpark hint', async () => { + const base = createFixtureBase(); + try { + writeFile(base, 'milestones/M001/M001-CONTEXT.md', '# M001\n\nContext.'); + writeFile(base, 'milestones/M001/M001-PARKED.md', 'Parked.'); + writeFile(base, 'milestones/M002/M002-CONTEXT.md', '# M002\n\nContext.'); + writeFile(base, 'milestones/M002/M002-PARKED.md', 'Also parked.'); + + openDatabase(':memory:'); + insertMilestone({ id: 'M001', title: 'First', status: 'parked' }); + insertMilestone({ id: 'M002', title: 'Second', status: 'parked' }); + + invalidateStateCache(); + const state = await deriveStateFromDb(base); + + assert.equal(state.phase, 'pre-planning', 'all-parked: phase is pre-planning'); + assert.equal(state.activeMilestone, null, 'all-parked: no active milestone'); + assert.ok(state.nextAction.includes('parked'), 'all-parked: nextAction mentions parked'); + assert.ok(state.nextAction.includes('unpark'), 'all-parked: nextAction hints unpark'); + assert.equal(state.registry.length, 2, 'all-parked: both in registry'); + assert.ok(state.registry.every(e => e.status === 'parked'), 'all-parked: all registry entries parked'); + } finally { + closeDatabase(); + cleanup(base); + } + }); + + // ─── handleNoActiveMilestone: all complete with active requirements ── + test('handleNoActiveMilestone: all complete with unmapped requirements', async () => { + const base = createFixtureBase(); + try { + writeFile(base, 'milestones/M001/M001-SUMMARY.md', '# M001 Summary\n\nDone.'); + writeFile(base, 'REQUIREMENTS.md', `# Requirements\n\n## Active\n\n### R001 — Unmapped\n- Status: active\n- Description: Not mapped.\n`); + + openDatabase(':memory:'); + insertMilestone({ id: 'M001', title: 'First', status: 'complete' }); + + invalidateStateCache(); + const state = await deriveStateFromDb(base); + + assert.equal(state.phase, 'complete', 'complete-reqs: phase is complete'); + assert.ok(state.nextAction.includes('1 active requirement'), 'complete-reqs: nextAction notes unmapped reqs'); + assert.equal(state.requirements?.active, 1, 'complete-reqs: requirements.active = 1'); + } finally { + closeDatabase(); + cleanup(base); + } + }); + + // ─── resolveSliceDependencies: GSD_SLICE_LOCK with missing slice ──── + test('resolveSliceDependencies: GSD_SLICE_LOCK pointing to non-existent slice returns blocked', async () => { + const base = createFixtureBase(); + const origLock = process.env.GSD_SLICE_LOCK; + try { + writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_CONTENT); + writeFile(base, 'milestones/M001/slices/S01/S01-PLAN.md', PLAN_CONTENT); + writeFile(base, 'milestones/M001/slices/S01/tasks/.gitkeep', ''); + writeFile(base, 'milestones/M001/slices/S01/tasks/T01-PLAN.md', '# T01 Plan'); + + openDatabase(':memory:'); + insertMilestone({ id: 'M001', title: 'Test', status: 'active' }); + insertSlice({ id: 'S01', milestoneId: 'M001', title: 'First', status: 'active', risk: 'low', depends: [] }); + insertTask({ id: 'T01', sliceId: 'S01', milestoneId: 'M001', title: 'First Task', status: 'pending' }); + + process.env.GSD_SLICE_LOCK = 'S99'; + + invalidateStateCache(); + const state = await deriveStateFromDb(base); + + assert.equal(state.phase, 'blocked', 'slice-lock-miss: phase is blocked'); + assert.ok(state.blockers.some(b => b.includes('GSD_SLICE_LOCK=S99')), 'slice-lock-miss: blocker mentions lock'); + } finally { + if (origLock !== undefined) process.env.GSD_SLICE_LOCK = origLock; + else delete process.env.GSD_SLICE_LOCK; + closeDatabase(); + cleanup(base); + } + }); + + // ─── resolveSliceDependencies: GSD_SLICE_LOCK with valid slice ────── + test('resolveSliceDependencies: GSD_SLICE_LOCK targeting valid slice bypasses deps', async () => { + const base = createFixtureBase(); + const origLock = process.env.GSD_SLICE_LOCK; + try { + writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_CONTENT); + // S02 depends on S01 but we lock to S02 directly + writeFile(base, 'milestones/M001/slices/S02/S02-PLAN.md', `# S02\n\n**Goal:** Test.\n**Demo:** Pass.\n\n## Tasks\n\n- [ ] **T01: Task** \`est:5m\`\n Do thing.\n`); + writeFile(base, 'milestones/M001/slices/S02/tasks/.gitkeep', ''); + writeFile(base, 'milestones/M001/slices/S02/tasks/T01-PLAN.md', '# T01 Plan'); + + openDatabase(':memory:'); + insertMilestone({ id: 'M001', title: 'Test', status: 'active' }); + insertSlice({ id: 'S01', milestoneId: 'M001', title: 'First', status: 'pending', risk: 'low', depends: [] }); + insertSlice({ id: 'S02', milestoneId: 'M001', title: 'Second', status: 'pending', risk: 'low', depends: ['S01'] }); + insertTask({ id: 'T01', sliceId: 'S02', milestoneId: 'M001', title: 'Task', status: 'pending' }); + + process.env.GSD_SLICE_LOCK = 'S02'; + + invalidateStateCache(); + const state = await deriveStateFromDb(base); + + assert.equal(state.activeSlice?.id, 'S02', 'slice-lock-valid: activeSlice is S02 (locked)'); + assert.equal(state.phase, 'executing', 'slice-lock-valid: phase is executing'); + } finally { + if (origLock !== undefined) process.env.GSD_SLICE_LOCK = origLock; + else delete process.env.GSD_SLICE_LOCK; + closeDatabase(); + cleanup(base); + } + }); + + // ─── reconcileSliceTasks: plan file imports tasks when DB empty ────── + test('reconcileSliceTasks: imports tasks from plan file when DB has zero tasks (#3600)', async () => { + const base = createFixtureBase(); + try { + writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_CONTENT); + writeFile(base, 'milestones/M001/slices/S01/S01-PLAN.md', PLAN_CONTENT); + writeFile(base, 'milestones/M001/slices/S01/tasks/.gitkeep', ''); + writeFile(base, 'milestones/M001/slices/S01/tasks/T01-PLAN.md', '# T01 Plan'); + + openDatabase(':memory:'); + insertMilestone({ id: 'M001', title: 'Test', status: 'active' }); + insertSlice({ id: 'S01', milestoneId: 'M001', title: 'First', status: 'active', risk: 'low', depends: [] }); + insertSlice({ id: 'S02', milestoneId: 'M001', title: 'Second', status: 'pending', risk: 'low', depends: ['S01'] }); + // No tasks inserted — reconcileSliceTasks should import from plan file + + invalidateStateCache(); + const state = await deriveStateFromDb(base); + + // Plan has T01 (pending) and T02 (done) — reconciliation imports both + assert.equal(state.phase, 'executing', 'task-reconcile: phase is executing (tasks imported)'); + assert.equal(state.activeTask?.id, 'T01', 'task-reconcile: activeTask is T01'); + assert.equal(state.progress?.tasks?.total, 2, 'task-reconcile: total tasks = 2'); + assert.equal(state.progress?.tasks?.done, 1, 'task-reconcile: done tasks = 1 (T02 was [x])'); + } finally { + closeDatabase(); + cleanup(base); + } + }); + + // ─── reconcileSliceTasks: stale task reconciled from disk summary ──── + test('reconcileSliceTasks: stale pending task reconciled to complete when disk SUMMARY exists (#2514)', async () => { + const base = createFixtureBase(); + try { + writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_CONTENT); + writeFile(base, 'milestones/M001/slices/S01/S01-PLAN.md', PLAN_CONTENT); + writeFile(base, 'milestones/M001/slices/S01/tasks/.gitkeep', ''); + writeFile(base, 'milestones/M001/slices/S01/tasks/T01-PLAN.md', '# T01 Plan'); + // T01 has a summary on disk but DB still says pending + writeFile(base, 'milestones/M001/slices/S01/tasks/T01-SUMMARY.md', '# T01 Summary\n\nDone on disk.'); + + openDatabase(':memory:'); + insertMilestone({ id: 'M001', title: 'Test', status: 'active' }); + insertSlice({ id: 'S01', milestoneId: 'M001', title: 'First', status: 'active', risk: 'low', depends: [] }); + insertSlice({ id: 'S02', milestoneId: 'M001', title: 'Second', status: 'pending', risk: 'low', depends: ['S01'] }); + insertTask({ id: 'T01', sliceId: 'S01', milestoneId: 'M001', title: 'First Task', status: 'pending' }); + insertTask({ id: 'T02', sliceId: 'S01', milestoneId: 'M001', title: 'Done Task', status: 'complete' }); + + invalidateStateCache(); + const state = await deriveStateFromDb(base); + + // T01 should have been reconciled to complete (SUMMARY exists on disk) + // Both tasks complete → phase should be summarizing + assert.equal(state.phase, 'summarizing', 'stale-task: phase is summarizing (T01 reconciled)'); + assert.equal(state.activeTask, null, 'stale-task: no active task (all done)'); + assert.equal(state.progress?.tasks?.done, 2, 'stale-task: tasks.done = 2'); + } finally { + closeDatabase(); + cleanup(base); + } + }); + + // ─── detectBlockers: blocker_discovered triggers replanning ────────── + test('detectBlockers: task with blocker_discovered triggers replanning-slice', async () => { + const base = createFixtureBase(); + try { + writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_CONTENT); + writeFile(base, 'milestones/M001/slices/S01/S01-PLAN.md', PLAN_CONTENT); + writeFile(base, 'milestones/M001/slices/S01/tasks/.gitkeep', ''); + writeFile(base, 'milestones/M001/slices/S01/tasks/T01-PLAN.md', '# T01 Plan'); + // T02 completed with blocker discovered — written in summary frontmatter + writeFile(base, 'milestones/M001/slices/S01/tasks/T02-SUMMARY.md', + '---\nblocker_discovered: true\n---\n\n# T02 Summary\n\nFound a blocker.'); + + openDatabase(':memory:'); + insertMilestone({ id: 'M001', title: 'Test', status: 'active' }); + insertSlice({ id: 'S01', milestoneId: 'M001', title: 'First', status: 'active', risk: 'low', depends: [] }); + insertSlice({ id: 'S02', milestoneId: 'M001', title: 'Second', status: 'pending', risk: 'low', depends: ['S01'] }); + insertTask({ id: 'T01', sliceId: 'S01', milestoneId: 'M001', title: 'First Task', status: 'pending' }); + insertTask({ id: 'T02', sliceId: 'S01', milestoneId: 'M001', title: 'Done Task', status: 'complete' }); + + invalidateStateCache(); + const state = await deriveStateFromDb(base); + + assert.equal(state.phase, 'replanning-slice', 'blocker: phase is replanning-slice'); + assert.ok(state.blockers.some(b => b.includes('T02')), 'blocker: blockers mention T02'); + } finally { + closeDatabase(); + cleanup(base); + } + }); + + // ─── checkInterruptedWork: continue.md triggers resume hint ───────── + test('checkInterruptedWork: continue.md present triggers resume nextAction', async () => { + const base = createFixtureBase(); + try { + writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_CONTENT); + writeFile(base, 'milestones/M001/slices/S01/S01-PLAN.md', PLAN_CONTENT); + writeFile(base, 'milestones/M001/slices/S01/tasks/.gitkeep', ''); + writeFile(base, 'milestones/M001/slices/S01/tasks/T01-PLAN.md', '# T01 Plan'); + writeFile(base, 'milestones/M001/slices/S01/S01-CONTINUE.md', 'Resume from here.'); + + openDatabase(':memory:'); + insertMilestone({ id: 'M001', title: 'Test', status: 'active' }); + insertSlice({ id: 'S01', milestoneId: 'M001', title: 'First', status: 'active', risk: 'low', depends: [] }); + insertSlice({ id: 'S02', milestoneId: 'M001', title: 'Second', status: 'pending', risk: 'low', depends: ['S01'] }); + insertTask({ id: 'T01', sliceId: 'S01', milestoneId: 'M001', title: 'First Task', status: 'pending' }); + insertTask({ id: 'T02', sliceId: 'S01', milestoneId: 'M001', title: 'Done Task', status: 'complete' }); + + invalidateStateCache(); + const state = await deriveStateFromDb(base); + + assert.equal(state.phase, 'executing', 'continue: phase is still executing'); + assert.ok(state.nextAction.includes('Resume interrupted work'), 'continue: nextAction mentions resume'); + assert.ok(state.nextAction.includes('continue.md'), 'continue: nextAction mentions continue.md'); + } finally { + closeDatabase(); + cleanup(base); + } + }); + + // ─── buildCompletenessSet: SUMMARY-on-disk marks complete ─────────── + test('buildCompletenessSet: milestone with SUMMARY on disk treated as complete', async () => { + const base = createFixtureBase(); + try { + // M001 has summary on disk but DB status is still 'active' + writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_CONTENT); + writeFile(base, 'milestones/M001/M001-SUMMARY.md', '# M001 Summary\n\nDone.'); + // M002 is the real active milestone + writeFile(base, 'milestones/M002/M002-CONTEXT.md', '# M002\n\nActive.'); + + openDatabase(':memory:'); + insertMilestone({ id: 'M001', title: 'First', status: 'active' }); + insertMilestone({ id: 'M002', title: 'Second', status: 'active' }); + + invalidateStateCache(); + const state = await deriveStateFromDb(base); + + // M001 should be complete (summary on disk), M002 should be active + const m1 = state.registry.find(e => e.id === 'M001'); + assert.equal(m1?.status, 'complete', 'summary-disk: M001 marked complete via disk SUMMARY'); + assert.equal(state.activeMilestone?.id, 'M002', 'summary-disk: M002 is active'); + } finally { + closeDatabase(); + cleanup(base); + } + }); + + // ─── reconcileDiskToDb: disk slices synced into DB (#2533) ────────── + test('reconcileDiskToDb: slices in ROADMAP.md but missing from DB are auto-inserted (#2533)', async () => { + const base = createFixtureBase(); + try { + writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_CONTENT); + + openDatabase(':memory:'); + insertMilestone({ id: 'M001', title: 'Test', status: 'active' }); + // No slices inserted — reconcileDiskToDb should insert from roadmap + + invalidateStateCache(); + const state = await deriveStateFromDb(base); + + // Slices should have been reconciled from roadmap, S01 should be the active slice + assert.equal(state.activeMilestone?.id, 'M001', 'slice-reconcile: M001 is active'); + assert.equal(state.activeSlice?.id, 'S01', 'slice-reconcile: S01 reconciled and active'); + assert.ok((state.progress?.slices?.total ?? 0) >= 2, 'slice-reconcile: at least 2 slices reconciled'); + } finally { + closeDatabase(); + cleanup(base); + } + }); + + // ─── Queue order: milestones sorted by custom queue order ─────────── + test('deriveStateFromDb respects custom queue order from QUEUE-ORDER.json', async () => { + const base = createFixtureBase(); + try { + // M003 should come first per queue order, M001 second + const queueOrder = JSON.stringify({ order: ['M003', 'M001', 'M002'], updatedAt: new Date().toISOString() }); + writeFileSync(join(base, '.gsd', 'QUEUE-ORDER.json'), queueOrder); + writeFile(base, 'milestones/M001/M001-CONTEXT.md', '# M001\n\nContext.'); + writeFile(base, 'milestones/M002/M002-CONTEXT.md', '# M002\n\nContext.'); + writeFile(base, 'milestones/M003/M003-CONTEXT.md', '# M003\n\nContext.'); + + openDatabase(':memory:'); + // Insert in natural order — queue ordering should override + insertMilestone({ id: 'M001', title: 'First', status: 'active' }); + insertMilestone({ id: 'M002', title: 'Second', status: 'active' }); + insertMilestone({ id: 'M003', title: 'Third', status: 'active' }); + + invalidateStateCache(); + const state = await deriveStateFromDb(base); + + // M003 should be the active milestone (first in queue) + assert.equal(state.activeMilestone?.id, 'M003', 'queue-order: M003 is active (first in queue)'); + assert.equal(state.registry[0]?.id, 'M003', 'queue-order: registry[0] is M003'); + } finally { + closeDatabase(); + cleanup(base); + } + }); + + // ─── handleAllSlicesDone: needs-remediation re-triggers validation ── + test('handleAllSlicesDone: needs-remediation verdict triggers validating-milestone', async () => { + const base = createFixtureBase(); + try { + const doneRoadmap = `# M001: Remediation Test\n\n**Vision:** Test.\n\n## Slices\n\n- [x] **S01: Done** \`risk:low\` \`depends:[]\`\n > Done.\n`; + writeFile(base, 'milestones/M001/M001-ROADMAP.md', doneRoadmap); + writeFile(base, 'milestones/M001/M001-VALIDATION.md', + '---\nverdict: needs-remediation\nremediation_round: 1\n---\n\n# Validation\nNeeds remediation.'); + + openDatabase(':memory:'); + insertMilestone({ id: 'M001', title: 'Remediation Test', status: 'active' }); + insertSlice({ id: 'S01', milestoneId: 'M001', title: 'Done', status: 'complete', risk: 'low', depends: [] }); + + invalidateStateCache(); + const state = await deriveStateFromDb(base); + + assert.equal(state.phase, 'validating-milestone', 'remediation: phase is validating-milestone'); + assert.equal(state.activeMilestone?.id, 'M001', 'remediation: activeMilestone is M001'); + } finally { + closeDatabase(); + cleanup(base); + } + }); + + // ─── Deferred queued shell: shell milestone deferred, real one promoted ── + test('buildRegistryAndFindActive: queued shell deferred, later real milestone becomes active (#3470)', async () => { + const base = createFixtureBase(); + try { + // M001: queued shell — no content, no slices + mkdirSync(join(base, '.gsd', 'milestones', 'M001'), { recursive: true }); + // M002: real milestone with context + writeFile(base, 'milestones/M002/M002-CONTEXT.md', '# M002: Real\n\nActive milestone.'); + + openDatabase(':memory:'); + insertMilestone({ id: 'M001', title: 'Shell', status: 'queued' }); + insertMilestone({ id: 'M002', title: 'Real', status: 'active' }); + + invalidateStateCache(); + const state = await deriveStateFromDb(base); + + // M002 should be active (M001 queued shell deferred) + assert.equal(state.activeMilestone?.id, 'M002', 'deferred-shell: M002 is active (shell deferred)'); + } finally { + closeDatabase(); + cleanup(base); + } + }); +});