Merge pull request #4007 from jeremymcs/refactor/state-derive-god-function

refactor: extract deriveStateFromDb logic into composable helpers
This commit is contained in:
Jeremy McSpadden 2026-04-11 16:11:45 -05:00 committed by GitHub
commit 31e88c99d2
2 changed files with 729 additions and 363 deletions

View file

@ -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<GSDState> {
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<GSDState> {
}
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<GSDState> {
});
}
}
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<string>();
const parkedMilestoneIds = new Set<string>();
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<string>,
parkedMilestoneIds: Set<string>
) {
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<GSDState> {
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<GSDState> {
}
}
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<GSDState> {
}
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<GSDState> {
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<GSDState> {
}
}
// 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<GSDState> {
}
}
// 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<GSDState> {
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<GSDState> {
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 <id> 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 <id> 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<GSDState> {
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<TaskRow[]> {
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<string | null> {
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<boolean> {
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<GSDState> {
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<GSDState> {
};
}
// 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<GSDState> {
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<GSDState> {
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<GSDState> {
}
}
// ── 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<GSDState> {
}
}
// ── 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<GSDState> {
}
}
// ── 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<GSDState> {
};
}
// 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<GSDState> {
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

View file

@ -0,0 +1,436 @@
// GSD Extension — Tests for extracted deriveStateFromDb helper functions
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
//
// 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);
}
});
});