From 0aaf8f2c0e989541c845144e0865b909d0ec7acb Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Mon, 11 May 2026 16:25:20 +0200 Subject: [PATCH] refactor: split state.js into state-shared/db/legacy modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit state.js was a 2012-line monolith combining shared helpers, DB-backed derivation, and legacy filesystem derivation. Split into four files: - state-shared.js (114 lines): helpers used by both DB and legacy paths isGhostMilestone, isSliceComplete, isMilestoneComplete, isValidationTerminal, readMilestoneValidationVerdict, loadTerminalSummary, stripMilestonePrefix, canonicalMilestonePrefix, extractContextTitle - state-db.js (841 lines): deriveStateFromDb() and its exclusive helpers reconcileDiskToDb, buildRegistryAndFindActive, handleNoActiveMilestone, handleAllSlicesDone, resolveSliceDependencies, reconcileSliceTasks, detectBlockers, checkReplanTrigger, checkInterruptedWork - state-legacy.js (895 lines): _deriveStateImpl() — filesystem-only path - state.js (228 lines): thin barrel — invalidateStateCache, getActiveMilestoneId, deriveState, re-exports from sub-modules All 1195 tests pass. No behavior change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/resources/extensions/sf/state-db.js | 841 +++++++++ src/resources/extensions/sf/state-legacy.js | 895 +++++++++ src/resources/extensions/sf/state-shared.js | 114 ++ src/resources/extensions/sf/state.js | 1848 +------------------ 4 files changed, 1882 insertions(+), 1816 deletions(-) create mode 100644 src/resources/extensions/sf/state-db.js create mode 100644 src/resources/extensions/sf/state-legacy.js create mode 100644 src/resources/extensions/sf/state-shared.js diff --git a/src/resources/extensions/sf/state-db.js b/src/resources/extensions/sf/state-db.js new file mode 100644 index 000000000..70e186314 --- /dev/null +++ b/src/resources/extensions/sf/state-db.js @@ -0,0 +1,841 @@ +// SF Extension — DB-backed State Derivation +// All private helpers and the exported deriveStateFromDb() that queries +// the SQLite milestone/slice/task tables directly. + +import { existsSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { loadFile, parseRequirementCounts, parseSummary } from "./files.js"; +import { findMilestoneIds } from "./milestone-ids.js"; +import { resolveMilestoneFile, resolveSfRootFile, resolveSliceFile, resolveSlicePath, resolveTaskFile, resolveTasksDir } from "./paths.js"; +import { + getAllMilestones, + getMilestone, + getMilestoneSlices, + getMilestoneValidationAssessment, + getPendingGateCountForTurn, + getReplanHistory, + getSlice, + getSliceTasks, + isDbAvailable, +} from "./sf-db.js"; +import { isClosedStatus, isDeferredStatus } from "./status-guards.js"; +import { loadQueueOrder, sortByQueueOrder } from "./queue-order.js"; +import { logWarning } from "./workflow-logger.js"; +import { + canonicalMilestonePrefix, + extractContextTitle, + isGhostMilestone, + isMilestoneComplete, + loadTerminalSummary, + readMilestoneValidationVerdict, + stripMilestonePrefix, +} from "./state-shared.js"; + +// ─── DB-backed State Derivation ──────────────────────────────────────────── +// isStatusDone replaced by isClosedStatus from status-guards.ts (single source of truth). +// Alias kept for backward compatibility within this file. +const isStatusDone = isClosedStatus; +/** + * Derive SF state from the milestones/slices/tasks DB tables. + * Non-planning control files (PARKED, CONTINUE, REPLAN, REPLAN-TRIGGER, + * CONTEXT-DRAFT) are still checked on the filesystem since they are not + * hierarchy state. + * Requirements also stay file-based via parseRequirementCounts(). + * + * Must not import rendered roadmap, plan, or summary artifacts into DB-backed + * runtime state. Explicit migration/repair flows own any legacy file import. + */ +function reconcileDiskToDb(basePath) { + const diskIds = findMilestoneIds(basePath); + if (diskIds.length > 0) { + const dbIds = new Set(getAllMilestones().map((m) => m.id)); + const dbPrefixes = new Set( + Array.from(dbIds, (id) => canonicalMilestonePrefix(id)), + ); + const diskOnlyIds = diskIds.filter( + (id) => + !dbIds.has(id) && + !dbPrefixes.has(canonicalMilestonePrefix(id)) && + !isGhostMilestone(basePath, id), + ); + if (diskOnlyIds.length > 0) { + logWarning( + "state", + `DB-backed state ignored ${diskOnlyIds.length} disk-only milestone(s): ${diskOnlyIds.join(", ")}`, + ); + } + } + return getAllMilestones(); +} +function buildCompletenessSet(basePath, milestones) { + const completeMilestoneIds = new Set(); + const parkedMilestoneIds = new Set(); + // DB-authoritative: a milestone is only "complete" when its DB row says so. + // SUMMARY-file presence is NOT a completion signal here — an orphan SUMMARY + // (crashed complete-milestone turn, partial merge, manual edit) must not + // flip derived state to complete and cascade into a false auto-merge (#4179). + for (const m of milestones) { + 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); + } + } + return { completeMilestoneIds, parkedMilestoneIds }; +} +async function buildRegistryAndFindActive( + basePath, + milestones, + completeMilestoneIds, + parkedMilestoneIds, +) { + const registry = []; + let activeMilestone = null; + let activeMilestoneSlices = []; + let activeMilestoneFound = false; + let activeMilestoneHasDraft = false; + let firstDeferredQueuedShell = null; + for (const m of milestones) { + if (parkedMilestoneIds.has(m.id)) { + registry.push({ + id: m.id, + title: stripMilestonePrefix(m.title) || m.id, + status: "parked", + }); + continue; + } + const slices = getMilestoneSlices(m.id); + if ( + slices.length === 0 && + !isStatusDone(m.status) && + m.status !== "queued" + ) { + if (isGhostMilestone(basePath, m.id)) continue; + } + // DB-authoritative completeness (#4179): only trust completeMilestoneIds, + // which is itself derived from DB status. SUMMARY-file presence alone must + // not imply completion. The summary file may still be consulted below as a + // title source for legitimately-complete milestones whose DB row has no title. + if (completeMilestoneIds.has(m.id)) { + let title = stripMilestonePrefix(m.title) || m.id; + if (!m.title) { + const summaryFile = resolveMilestoneFile(basePath, m.id, "SUMMARY"); + if (summaryFile) { + const summaryContent = await loadFile(summaryFile); + if (summaryContent) { + title = parseSummary(summaryContent).title || m.id; + } + } + } + registry.push({ id: m.id, title, status: "complete" }); + continue; + } + const allSlicesDone = + slices.length > 0 && slices.every((s) => isStatusDone(s.status)); + let title = stripMilestonePrefix(m.title) || m.id; + if (title === m.id) { + const contextFile = resolveMilestoneFile(basePath, m.id, "CONTEXT"); + const draftFile = resolveMilestoneFile(basePath, m.id, "CONTEXT-DRAFT"); + const contextContent = contextFile ? await loadFile(contextFile) : null; + const draftContent = + draftFile && !contextContent ? await loadFile(draftFile) : null; + title = extractContextTitle(contextContent || draftContent, m.id); + } + if (!activeMilestoneFound) { + const deps = m.depends_on; + const depsUnmet = deps.some((dep) => !completeMilestoneIds.has(dep)); + if (depsUnmet) { + registry.push({ id: m.id, title, status: "pending", dependsOn: deps }); + continue; + } + if (m.status === "queued" && slices.length === 0) { + const contextFile = resolveMilestoneFile(basePath, m.id, "CONTEXT"); + const draftFile = resolveMilestoneFile(basePath, m.id, "CONTEXT-DRAFT"); + if (!contextFile && !draftFile) { + if (!firstDeferredQueuedShell) { + firstDeferredQueuedShell = { id: m.id, title, deps }; + } + registry.push({ + id: m.id, + title, + status: "pending", + ...(deps.length > 0 ? { dependsOn: deps } : {}), + }); + continue; + } + } + if (allSlicesDone) { + const { terminal: validationTerminal } = + await readMilestoneValidationVerdict(basePath, m.id, loadFile); + // DB-authoritative (#4179): completeness is already decided by + // completeMilestoneIds above. If we reached this branch, the DB says + // the milestone is NOT complete — so any SUMMARY file on disk is an + // orphan (crashed complete-milestone, partial merge, manual edit) and + // must not short-circuit this path. When validation is terminal, fall + // through to the default active-push below so `complete-milestone` can + // re-run idempotently. + if (!validationTerminal) { + activeMilestone = { id: m.id, title }; + activeMilestoneSlices = slices; + activeMilestoneFound = true; + registry.push({ + id: m.id, + title, + status: "active", + ...(deps.length > 0 ? { dependsOn: deps } : {}), + }); + continue; + } + } + const contextFile = resolveMilestoneFile(basePath, m.id, "CONTEXT"); + const draftFile = resolveMilestoneFile(basePath, m.id, "CONTEXT-DRAFT"); + if (!contextFile && draftFile) activeMilestoneHasDraft = true; + activeMilestone = { id: m.id, title }; + activeMilestoneSlices = slices; + activeMilestoneFound = true; + registry.push({ + id: m.id, + title, + status: "active", + ...(deps.length > 0 ? { dependsOn: deps } : {}), + }); + } else { + const deps = m.depends_on; + registry.push({ + id: m.id, + title, + status: "pending", + ...(deps.length > 0 ? { dependsOn: deps } : {}), + }); + } + } + if (!activeMilestoneFound && firstDeferredQueuedShell) { + const shell = firstDeferredQueuedShell; + activeMilestone = { id: shell.id, title: shell.title }; + activeMilestoneSlices = []; + activeMilestoneFound = true; + const entry = registry.find((e) => e.id === shell.id); + if (entry) entry.status = "active"; + } + return { + registry, + activeMilestone, + activeMilestoneSlices, + activeMilestoneHasDraft, + }; +} +function handleNoActiveMilestone(registry, requirements, milestoneProgress) { + 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 /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 /next 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, + activeMilestone, + registry, + requirements, + milestoneProgress, + sliceProgress, +) { + const { terminal: validationTerminal, verdict } = + await readMilestoneValidationVerdict( + basePath, + activeMilestone.id, + loadFile, + ); + 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) { + const doneSliceIds = new Set( + activeMilestoneSlices + .filter((s) => isStatusDone(s.status)) + .map((s) => s.id), + ); + const sliceLock = process.env.SF_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", + `SF_SLICE_LOCK=${sliceLock} not found in active slices — worker has no assigned work`, + ); + return { activeSlice: null, activeSliceRow: null }; + } + } + // First pass: find a slice with ALL dependencies satisfied (strict) + let bestFallback = null; + let bestFallbackSatisfied = -1; + 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 }; + } + // Track the slice with the most satisfied dependencies as fallback + const satisfied = s.depends.filter((dep) => doneSliceIds.has(dep)).length; + if ( + satisfied > bestFallbackSatisfied || + (satisfied === bestFallbackSatisfied && !bestFallback) + ) { + bestFallback = s; + bestFallbackSatisfied = satisfied; + } + } + // Fallback: if no slice has all deps met but there ARE incomplete non-deferred + // slices, pick the one with the most deps satisfied. This prevents hard-blocking + // when dependency metadata is stale (e.g. after reassessment added/removed slices) + // or when deps reference slices from previous milestones. + if (bestFallback) { + const unmet = bestFallback.depends.filter((dep) => !doneSliceIds.has(dep)); + logWarning( + "state", + `No slice has all deps satisfied — falling back to ${bestFallback.id} ` + + `(${bestFallbackSatisfied}/${bestFallback.depends.length} deps met, ` + + `unmet: ${unmet.join(", ")})`, + { mid: activeMilestoneSlices[0]?.milestone_id, sid: bestFallback.id }, + ); + return { + activeSlice: { id: bestFallback.id, title: bestFallback.title }, + activeSliceRow: bestFallback, + }; + } + return { activeSlice: null, activeSliceRow: null }; +} +async function reconcileSliceTasks(basePath, milestoneId, sliceId, planFile) { + const tasks = getSliceTasks(milestoneId, sliceId); + if (tasks.length === 0 && planFile) { + logWarning( + "reconcile", + `slice plan file exists for ${milestoneId}/${sliceId}, but DB has no task rows; refusing runtime import`, + { mid: milestoneId, sid: sliceId }, + ); + } + for (const t of tasks) { + if (isStatusDone(t.status)) continue; + const summaryPath = resolveTaskFile( + basePath, + milestoneId, + sliceId, + t.id, + "SUMMARY", + ); + if (summaryPath && existsSync(summaryPath)) { + logWarning( + "reconcile", + `task ${milestoneId}/${sliceId}/${t.id} has SUMMARY on disk but DB status is "${t.status}"; refusing runtime status import`, + { mid: milestoneId, sid: sliceId, tid: t.id }, + ); + } + } + return tasks; +} +async function detectBlockers(basePath, milestoneId, sliceId, tasks) { + 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, milestoneId, sliceId) { + 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, milestoneId, sliceId) { + 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) { + const requirements = parseRequirementCounts( + await loadFile(resolveSfRootFile(basePath, "REQUIREMENTS")), + ); + const 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.SF_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 /next 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) { + if (!hasRoadmap) { + const phase = activeMilestoneHasDraft + ? "needs-discussion" + : "pre-planning"; + const nextAction = activeMilestoneHasDraft + ? `Discuss draft context for milestone ${activeMilestone.id}.` + : `Plan milestone ${activeMilestone.id}.`; + return { + activeMilestone, + activeSlice: null, + activeTask: null, + phase, + recentDecisions: [], + blockers: [], + nextAction, + registry, + requirements, + progress: { milestones: milestoneProgress }, + }; + } + return { + activeMilestone, + activeSlice: null, + activeTask: null, + 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 }, + }, + }; + } + const allSlicesDone = activeMilestoneSlices.every((s) => + isStatusDone(s.status), + ); + const sliceProgress = { + done: activeMilestoneSlices.filter((s) => isStatusDone(s.status)).length, + total: activeMilestoneSlices.length, + }; + if (allSlicesDone) { + return handleAllSlicesDone( + basePath, + activeMilestone, + registry, + requirements, + milestoneProgress, + sliceProgress, + ); + } + 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.SF_SLICE_LOCK) { + return { + activeMilestone, + activeSlice: null, + activeTask: null, + phase: "blocked", + recentDecisions: [], + blockers: [ + `SF_SLICE_LOCK=${process.env.SF_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 }, + }; + } + return { + activeMilestone, + activeSlice: null, + activeTask: null, + 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; + const planFile = resolveSliceFile( + basePath, + activeMilestone.id, + activeSlice.id, + "PLAN", + ); + const dbTasksBefore = getSliceTasks(activeMilestone.id, activeSlice.id); + if (!planFile && dbTasksBefore.length === 0) { + return { + activeMilestone, + activeSlice, + activeTask: null, + phase: "planning", + recentDecisions: [], + blockers: [], + nextAction: `Plan slice ${activeSlice.id} (${activeSlice.title}).`, + registry, + requirements, + progress: { milestones: milestoneProgress, slices: sliceProgress }, + }; + } + const tasks = planFile + ? await reconcileSliceTasks( + basePath, + activeMilestone.id, + activeSlice.id, + planFile, + ) + : dbTasksBefore; + const taskProgress = { + done: tasks.filter((t) => isStatusDone(t.status)).length, + total: tasks.length, + }; + const activeTaskRow = tasks.find((t) => !isStatusDone(t.status)); + if (!activeTaskRow && tasks.length > 0) { + return { + activeMilestone, + activeSlice, + activeTask: null, + 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, + }, + }; + } + if (!activeTaskRow) { + return { + activeMilestone, + activeSlice, + activeTask: null, + 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, + }, + }; + } + const activeTask = { + id: activeTaskRow.id, + title: activeTaskRow.title, + }; + 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: [], + nextAction: `Task plan files missing for ${activeSlice.id}. Run plan-slice to generate task plans.`, + registry, + requirements, + progress: { + milestones: milestoneProgress, + slices: sliceProgress, + tasks: taskProgress, + }, + }; + } + } + // ── Quality gate evaluation check ────────────────────────────────── + // Pause before execution only when gates owned by the `gate-evaluate` + // turn (Q3/Q4) are still pending. Q8 is also `scope:"slice"` but is + // owned by `complete-slice`, so it must NOT block the evaluating-gates + // phase — otherwise auto-loop stalls forever waiting for a gate that + // this turn never evaluates. See gate-registry.ts for the ownership map. + // Slices with zero gate rows (pre-feature or simple) skip straight through. + const pendingGateCount = getPendingGateCountForTurn( + activeMilestone.id, + activeSlice.id, + "gate-evaluate", + ); + if (pendingGateCount > 0) { + return { + activeMilestone, + activeSlice, + activeTask: null, + 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, + }, + }; + } + const blockerTaskId = await detectBlockers( + basePath, + activeMilestone.id, + activeSlice.id, + tasks, + ); + if (blockerTaskId) { + const replanHistory = getReplanHistory(activeMilestone.id, activeSlice.id); + if (replanHistory.length === 0) { + return { + activeMilestone, + activeSlice, + activeTask, + 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, + registry, + requirements, + progress: { + milestones: milestoneProgress, + slices: sliceProgress, + tasks: taskProgress, + }, + }; + } + } + if (!blockerTaskId) { + 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: [], + blockers: ["Triage replan trigger detected — slice replan required"], + nextAction: `Triage replan triggered for slice ${activeSlice.id}. Replan before continuing.`, + activeWorkspace: undefined, + registry, + requirements, + progress: { + milestones: milestoneProgress, + slices: sliceProgress, + tasks: taskProgress, + }, + }; + } + } + } + const hasInterrupted = await checkInterruptedWork( + basePath, + activeMilestone.id, + activeSlice.id, + ); + return { + activeMilestone, + activeSlice, + activeTask, + 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}.`, + registry, + requirements, + progress: { + milestones: milestoneProgress, + slices: sliceProgress, + tasks: taskProgress, + }, + }; +} +// 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. diff --git a/src/resources/extensions/sf/state-legacy.js b/src/resources/extensions/sf/state-legacy.js new file mode 100644 index 000000000..e41c19a30 --- /dev/null +++ b/src/resources/extensions/sf/state-legacy.js @@ -0,0 +1,895 @@ +// SF Extension — Legacy Filesystem State Derivation +// _deriveStateImpl() parses .sf/ markdown files directly; used only when +// SQLite is unavailable or the project has not been migrated. + +import { existsSync, readdirSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { detectPendingEscalation } from "./escalation.js"; +import { + isValidTaskSummary, + loadFile, + parseContextDependsOn, + parseRequirementCounts, + parseSummary, +} from "./files.js"; +import { findMilestoneIds } from "./milestone-ids.js"; +import { nativeBatchParseSfFiles } from "./native-parser-bridge.js"; +import { parsePlan, parseRoadmap } from "./parsers.js"; +import { + resolveMilestoneFile, + resolveSfRootFile, + resolveSliceFile, + resolveSlicePath, + resolveTaskFile, + resolveTasksDir, + sfRoot, +} from "./paths.js"; +import { getSlicePlanBlockingIssue } from "./plan-quality.js"; +import { loadQueueOrder, sortByQueueOrder } from "./queue-order.js"; +import { getSliceTasks, isDbAvailable } from "./sf-db.js"; +import { logWarning } from "./workflow-logger.js"; +import { + extractContextTitle, + isGhostMilestone, + isMilestoneComplete, + loadTerminalSummary, + readMilestoneValidationVerdict, + stripMilestonePrefix, +} from "./state-shared.js"; + +export async function _deriveStateImpl(basePath) { + const diskIds = findMilestoneIds(basePath); + const customOrder = loadQueueOrder(basePath); + const milestoneIds = sortByQueueOrder(diskIds, customOrder); + // ── Parallel worker isolation ────────────────────────────────────────── + // When SF_MILESTONE_LOCK is set, this process is a parallel worker + // scoped to a single milestone. Filter the milestone list so this worker + // only sees its assigned milestone (all others are treated as if they + // don't exist). This gives each worker complete isolation without + // modifying any other state derivation logic. + const milestoneLock = process.env.SF_MILESTONE_LOCK; + if (milestoneLock && milestoneIds.includes(milestoneLock)) { + milestoneIds.length = 0; + milestoneIds.push(milestoneLock); + } + // ── Batch-parse file cache ────────────────────────────────────────────── + // When the native Rust parser is available, read every .md file under .sf/ + // in one call and build an in-memory content map keyed by absolute path. + // This eliminates O(N) individual fs.readFile calls during traversal. + const fileContentCache = new Map(); + const sfDir = sfRoot(basePath); + // Filesystem fallback: used when deriveStateFromDb() is not available + // (pre-migration projects). The DB-backed path is preferred when available + // — see deriveStateFromDb() above. + const batchFiles = nativeBatchParseSfFiles(sfDir); + if (batchFiles) { + for (const f of batchFiles) { + const absPath = resolve(sfDir, f.path); + fileContentCache.set(absPath, f.rawContent); + } + } + /** + * Load file content from batch cache first, falling back to disk read. + * Resolves the path to absolute before cache lookup. + */ + async function cachedLoadFile(path) { + const abs = resolve(path); + const cached = fileContentCache.get(abs); + if (cached !== undefined) return cached; + return loadFile(path); + } + const requirements = parseRequirementCounts( + await cachedLoadFile(resolveSfRootFile(basePath, "REQUIREMENTS")), + ); + if (milestoneIds.length === 0) { + return { + activeMilestone: null, + activeSlice: null, + activeTask: null, + phase: "pre-planning", + recentDecisions: [], + blockers: [], + nextAction: "No milestones found. Run /next to create one.", + registry: [], + requirements, + progress: { + milestones: { done: 0, total: 0 }, + }, + }; + } + // ── Single-pass milestone scan ────────────────────────────────────────── + // Parse each milestone's roadmap once, caching results. First pass determines + // completeness for dependency resolution; second pass builds the registry. + // With the batch cache, all file reads hit memory instead of disk. + // Phase 1: Build roadmap cache and completeness set + const roadmapCache = new Map(); + const completeMilestoneIds = new Set(); + // Track parked milestone IDs so Phase 2 can check without re-reading disk + const parkedMilestoneIds = new Set(); + for (const mid of milestoneIds) { + // Skip parked milestones — they do NOT count as complete (don't satisfy depends_on) + // But still parse their roadmap for title extraction in Phase 2. + const parkedFile = resolveMilestoneFile(basePath, mid, "PARKED"); + if (parkedFile) { + parkedMilestoneIds.add(mid); + // Cache roadmap for title extraction (but don't add to completeMilestoneIds) + const prf = resolveMilestoneFile(basePath, mid, "ROADMAP"); + const prc = prf ? await cachedLoadFile(prf) : null; + if (prc) roadmapCache.set(mid, parseRoadmap(prc)); + continue; + } + const rf = resolveMilestoneFile(basePath, mid, "ROADMAP"); + const rc = rf ? await cachedLoadFile(rf) : null; + if (!rc) { + const sf = resolveMilestoneFile(basePath, mid, "SUMMARY"); + if (await loadTerminalSummary(sf, cachedLoadFile)) + completeMilestoneIds.add(mid); + continue; + } + const rmap = parseRoadmap(rc); + roadmapCache.set(mid, rmap); + if (!isMilestoneComplete(rmap)) { + // Summary is the terminal artifact — if it exists and is terminal, the milestone is + // complete even when roadmap checkboxes weren't ticked (#864). + const sf = resolveMilestoneFile(basePath, mid, "SUMMARY"); + if (await loadTerminalSummary(sf, cachedLoadFile)) + completeMilestoneIds.add(mid); + continue; + } + const sf = resolveMilestoneFile(basePath, mid, "SUMMARY"); + if (await loadTerminalSummary(sf, cachedLoadFile)) + completeMilestoneIds.add(mid); + } + // Phase 2: Build registry using cached roadmaps (no re-parsing or re-reading) + const registry = []; + let activeMilestone = null; + let activeRoadmap = null; + let activeMilestoneFound = false; + let activeMilestoneHasDraft = false; + for (const mid of milestoneIds) { + // Skip parked milestones — register them as 'parked' and move on + if (parkedMilestoneIds.has(mid)) { + const roadmap = roadmapCache.get(mid) ?? null; + const title = roadmap ? stripMilestonePrefix(roadmap.title) : mid; + registry.push({ id: mid, title, status: "parked" }); + continue; + } + const roadmap = roadmapCache.get(mid) ?? null; + if (!roadmap) { + // No roadmap — check if a terminal summary exists (completed milestone without roadmap) + const summaryFile = resolveMilestoneFile(basePath, mid, "SUMMARY"); + const sc = await loadTerminalSummary(summaryFile, cachedLoadFile); + if (sc) { + const summaryTitle = parseSummary(sc).title || mid; + registry.push({ id: mid, title: summaryTitle, status: "complete" }); + completeMilestoneIds.add(mid); + continue; + } + // Failure summary or unreadable — milestone is not yet done; fall through + // Ghost milestone (only META.json, no CONTEXT/ROADMAP/SUMMARY) — skip entirely + if (isGhostMilestone(basePath, mid)) continue; + // No roadmap and no summary — treat as incomplete/active + if (!activeMilestoneFound) { + // Check for CONTEXT-DRAFT.md to distinguish draft-seeded from blank milestones. + // A draft seed means the milestone has discussion material but no full context yet. + const contextFile = resolveMilestoneFile(basePath, mid, "CONTEXT"); + const draftFile = resolveMilestoneFile(basePath, mid, "CONTEXT-DRAFT"); + if (!contextFile && draftFile) activeMilestoneHasDraft = true; + // Extract title from CONTEXT.md or CONTEXT-DRAFT.md heading before falling back to mid. + const contextContent = contextFile + ? await cachedLoadFile(contextFile) + : null; + const draftContent = + draftFile && !contextContent ? await cachedLoadFile(draftFile) : null; + const title = extractContextTitle(contextContent || draftContent, mid); + // Check milestone-level dependencies before promoting to active. + // Without this, a queued milestone with depends_on in its CONTEXT + // or CONTEXT-DRAFT frontmatter would be promoted to active even when + // its deps are unmet. Fall back to CONTEXT-DRAFT.md when absent (#1724). + const deps = parseContextDependsOn(contextContent ?? draftContent); + const depsUnmet = deps.some((dep) => !completeMilestoneIds.has(dep)); + if (depsUnmet) { + registry.push({ id: mid, title, status: "pending", dependsOn: deps }); + } else { + activeMilestone = { id: mid, title }; + activeMilestoneFound = true; + registry.push({ + id: mid, + title, + status: "active", + ...(deps.length > 0 ? { dependsOn: deps } : {}), + }); + } + } else { + // For milestones after the active one, also try to extract title from context files. + const contextFile = resolveMilestoneFile(basePath, mid, "CONTEXT"); + const draftFile = resolveMilestoneFile(basePath, mid, "CONTEXT-DRAFT"); + const contextContent = contextFile + ? await cachedLoadFile(contextFile) + : null; + const draftContent = + draftFile && !contextContent ? await cachedLoadFile(draftFile) : null; + const title = extractContextTitle(contextContent || draftContent, mid); + registry.push({ id: mid, title, status: "pending" }); + } + continue; + } + const title = stripMilestonePrefix(roadmap.title); + const complete = isMilestoneComplete(roadmap); + if (complete) { + // All slices done — check validation and summary state + const summaryFile = resolveMilestoneFile(basePath, mid, "SUMMARY"); + const { terminal: validationTerminal, verdict } = + await readMilestoneValidationVerdict(basePath, mid, cachedLoadFile); + // needs-remediation is terminal but requires re-validation (#3596) + const needsRevalidation = + !validationTerminal || verdict === "needs-remediation"; + if (await loadTerminalSummary(summaryFile, cachedLoadFile)) { + // Terminal summary → milestone is complete. The summary is the terminal artifact (#864). + registry.push({ id: mid, title, status: "complete" }); + continue; + } + // Failure summary or unreadable — fall through to re-validation / active logic below + if (needsRevalidation && !activeMilestoneFound) { + // No terminal summary and needs (re-)validation → validating-milestone + activeMilestone = { id: mid, title }; + activeRoadmap = roadmap; + activeMilestoneFound = true; + registry.push({ id: mid, title, status: "active" }); + } else if (needsRevalidation && activeMilestoneFound) { + // Needs (re-)validation, but another milestone is already active + registry.push({ id: mid, title, status: "pending" }); + } else if (!activeMilestoneFound) { + // Terminal validation (pass/needs-attention) but no summary → completing-milestone + activeMilestone = { id: mid, title }; + activeRoadmap = roadmap; + activeMilestoneFound = true; + registry.push({ id: mid, title, status: "active" }); + } else { + registry.push({ id: mid, title, status: "complete" }); + } + } else { + // Roadmap slices not all checked — but if a terminal summary exists, the + // milestone is still complete. The summary is the terminal artifact (#864). + const summaryFile = resolveMilestoneFile(basePath, mid, "SUMMARY"); + if (await loadTerminalSummary(summaryFile, cachedLoadFile)) { + registry.push({ id: mid, title, status: "complete" }); + } else if (!activeMilestoneFound) { + // Check milestone-level dependencies before promoting to active. + // Fall back to CONTEXT-DRAFT.md when CONTEXT.md is absent (#1724). + const contextFile = resolveMilestoneFile(basePath, mid, "CONTEXT"); + const draftFile = resolveMilestoneFile(basePath, mid, "CONTEXT-DRAFT"); + const contextContent = contextFile + ? await cachedLoadFile(contextFile) + : null; + const draftContent = + draftFile && !contextContent ? await cachedLoadFile(draftFile) : null; + const deps = parseContextDependsOn(contextContent ?? draftContent); + const depsUnmet = deps.some((dep) => !completeMilestoneIds.has(dep)); + if (depsUnmet) { + registry.push({ id: mid, title, status: "pending", dependsOn: deps }); + // Do NOT set activeMilestoneFound — let the loop continue to the next milestone + } else { + activeMilestone = { id: mid, title }; + activeRoadmap = roadmap; + activeMilestoneFound = true; + registry.push({ + id: mid, + title, + status: "active", + ...(deps.length > 0 ? { dependsOn: deps } : {}), + }); + } + } else { + const contextFile2 = resolveMilestoneFile(basePath, mid, "CONTEXT"); + const draftFileForDeps3 = resolveMilestoneFile( + basePath, + mid, + "CONTEXT-DRAFT", + ); + const contextOrDraftContent3 = contextFile2 + ? await cachedLoadFile(contextFile2) + : draftFileForDeps3 + ? await cachedLoadFile(draftFileForDeps3) + : null; + const deps2 = parseContextDependsOn(contextOrDraftContent3); + registry.push({ + id: mid, + title, + status: "pending", + ...(deps2.length > 0 ? { dependsOn: deps2 } : {}), + }); + } + } + } + const milestoneProgress = { + done: registry.filter((entry) => entry.status === "complete").length, + total: registry.length, + }; + if (!activeMilestone) { + // Check whether any milestones are pending (dep-blocked) or parked + const pendingEntries = registry.filter( + (entry) => entry.status === "pending", + ); + const parkedEntries = registry.filter((entry) => entry.status === "parked"); + if (pendingEntries.length > 0) { + // All incomplete milestones are dep-blocked — no progress possible + const blockerDetails = pendingEntries + .filter((entry) => entry.dependsOn && entry.dependsOn.length > 0) + .map( + (entry) => + `${entry.id} is waiting on unmet deps: ${entry.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) { + // All non-complete milestones are parked — nothing active, but not "all complete" + 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 /unpark or create a new milestone.`, + registry, + requirements, + progress: { + milestones: milestoneProgress, + }, + }; + } + // All real milestones were ghosts (empty registry) → treat as pre-planning + if (registry.length === 0) { + return { + activeMilestone: null, + activeSlice: null, + activeTask: null, + phase: "pre-planning", + recentDecisions: [], + blockers: [], + nextAction: "No milestones found. Run /next 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."; + 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, + }, + }; + } + if (!activeRoadmap) { + // Active milestone exists but has no roadmap yet. + // If a CONTEXT-DRAFT.md seed exists, it needs discussion before planning. + // Otherwise, it's a blank milestone ready for initial planning. + const phase = activeMilestoneHasDraft ? "needs-discussion" : "pre-planning"; + const nextAction = activeMilestoneHasDraft + ? `Discuss draft context for milestone ${activeMilestone.id}.` + : `Plan milestone ${activeMilestone.id}.`; + return { + activeMilestone, + activeSlice: null, + activeTask: null, + phase, + recentDecisions: [], + blockers: [], + nextAction, + registry, + requirements, + progress: { + milestones: milestoneProgress, + }, + }; + } + // ── Zero-slice roadmap guard (#1785) ───────────────────────────────── + // A stub roadmap (placeholder text, no slice definitions) has a truthy + // roadmap object but an empty slices array. Without this check the + // slice-finding loop below finds nothing and returns phase: "blocked". + // An empty slices array means the roadmap still needs slice definitions, + // so the correct phase is pre-planning. + if (activeRoadmap.slices.length === 0) { + return { + activeMilestone, + activeSlice: null, + activeTask: null, + 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 }, + }, + }; + } + // Check if active milestone needs validation or completion (all slices done) + if (isMilestoneComplete(activeRoadmap)) { + const { terminal: validationTerminal, verdict } = + await readMilestoneValidationVerdict( + basePath, + activeMilestone.id, + cachedLoadFile, + ); + const sliceProgress = { + done: activeRoadmap.slices.length, + total: activeRoadmap.slices.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, + }, + }; + } + const sliceProgress = { + done: activeRoadmap.slices.filter((s) => s.done).length, + total: activeRoadmap.slices.length, + }; + // Find the active slice (first incomplete with deps satisfied) + const doneSliceIds = new Set( + activeRoadmap.slices.filter((s) => s.done).map((s) => s.id), + ); + let activeSlice = null; + // ── Slice-level parallel worker isolation ───────────────────────────── + // When SF_SLICE_LOCK is set, override activeSlice to only the locked slice. + const sliceLockLegacy = process.env.SF_SLICE_LOCK; + if (sliceLockLegacy) { + const lockedSlice = activeRoadmap.slices.find( + (s) => s.id === sliceLockLegacy, + ); + if (lockedSlice) { + activeSlice = { id: lockedSlice.id, title: lockedSlice.title }; + } else { + logWarning( + "state", + `SF_SLICE_LOCK=${sliceLockLegacy} not found in active slices — worker has no assigned work`, + ); + return { + activeMilestone, + activeSlice: null, + activeTask: null, + phase: "blocked", + recentDecisions: [], + blockers: [ + `SF_SLICE_LOCK=${sliceLockLegacy} 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 { + let bestFallbackLegacy = null; + let bestFallbackLegacySatisfied = -1; + for (const s of activeRoadmap.slices) { + if (s.done) continue; + if (s.depends.every((dep) => doneSliceIds.has(dep))) { + activeSlice = { id: s.id, title: s.title }; + break; + } + // Track best fallback + const satisfied = s.depends.filter((dep) => doneSliceIds.has(dep)).length; + if (satisfied > bestFallbackLegacySatisfied) { + bestFallbackLegacy = s; + bestFallbackLegacySatisfied = satisfied; + } + } + // Fallback: if no slice has all deps met, pick the one with the most deps satisfied + if (!activeSlice && bestFallbackLegacy) { + const unmet = bestFallbackLegacy.depends.filter( + (dep) => !doneSliceIds.has(dep), + ); + logWarning( + "state", + `No slice has all deps satisfied — falling back to ${bestFallbackLegacy.id} ` + + `(${bestFallbackLegacySatisfied}/${bestFallbackLegacy.depends.length} deps met, ` + + `unmet: ${unmet.join(", ")})`, + ); + activeSlice = { + id: bestFallbackLegacy.id, + title: bestFallbackLegacy.title, + }; + } + } + if (!activeSlice) { + return { + activeMilestone, + activeSlice: null, + activeTask: null, + 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, + }, + }; + } + // Check if the slice has a plan + const planFile = resolveSliceFile( + basePath, + activeMilestone.id, + activeSlice.id, + "PLAN", + ); + const slicePlanContent = planFile ? await cachedLoadFile(planFile) : null; + if (!slicePlanContent) { + return { + activeMilestone, + activeSlice, + activeTask: null, + phase: "planning", + recentDecisions: [], + blockers: [], + nextAction: `Plan slice ${activeSlice.id} (${activeSlice.title}).`, + registry, + requirements, + progress: { + milestones: milestoneProgress, + slices: sliceProgress, + }, + }; + } + const slicePlan = parsePlan(slicePlanContent); + const planQualityIssue = getSlicePlanBlockingIssue(slicePlanContent); + if (planQualityIssue && slicePlan.tasks.length === 0) { + return { + activeMilestone, + activeSlice, + activeTask: null, + phase: "planning", + recentDecisions: [], + blockers: [], + nextAction: `Slice ${activeSlice.id} plan is incomplete (${planQualityIssue}). Re-run plan-slice with partner/combatant/architect review.`, + registry, + requirements, + progress: { + milestones: milestoneProgress, + slices: sliceProgress, + }, + }; + } + // ── Reconcile stale task status for filesystem-based projects (#2514) ── + // Heading-style tasks (### T01:) are always parsed as done=false by + // parsePlan because the heading syntax has no checkbox. When the agent + // writes a SUMMARY file but the plan's heading isn't converted to a + // checkbox, the task appears incomplete forever — causing infinite + // re-dispatch. Reconcile by checking SUMMARY files on disk. + for (const t of slicePlan.tasks) { + if (t.done) continue; + const summaryPath = resolveTaskFile( + basePath, + activeMilestone.id, + activeSlice.id, + t.id, + "SUMMARY", + ); + if (summaryPath && existsSync(summaryPath)) { + // Validate that the summary file has actual content (#sf-moobj36o-6rxy6e) + const summaryContent = readFileSync(summaryPath, "utf-8"); + if (!isValidTaskSummary(summaryContent)) { + logWarning( + "reconcile", + `task ${activeMilestone.id}/${activeSlice.id}/${t.id} has empty/invalid SUMMARY — skipping reconciliation`, + { mid: activeMilestone.id, sid: activeSlice.id, tid: t.id }, + ); + continue; + } + t.done = true; + logWarning( + "reconcile", + `task ${activeMilestone.id}/${activeSlice.id}/${t.id} reconciled via SUMMARY on disk (#2514)`, + { mid: activeMilestone.id, sid: activeSlice.id, tid: t.id }, + ); + } + } + const taskProgress = { + done: slicePlan.tasks.filter((t) => t.done).length, + total: slicePlan.tasks.length, + }; + const activeTaskEntry = slicePlan.tasks.find((t) => !t.done); + if (!activeTaskEntry && slicePlan.tasks.length > 0) { + // All tasks done but slice not marked complete + return { + activeMilestone, + activeSlice, + activeTask: null, + 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, stay in planning phase + if (!activeTaskEntry) { + return { + activeMilestone, + activeSlice, + activeTask: null, + 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, + }, + }; + } + const activeTask = { + id: activeTaskEntry.id, + title: activeTaskEntry.title, + }; + // ── Task plan file check (#909) ────────────────────────────────────── + // The slice plan may reference tasks but per-task plan files may be + // missing — e.g. when the slice plan was pre-created during roadmapping. + // If the tasks dir exists but has literally zero files (empty dir from + // mkdir), fall back to planning so plan-slice generates task plans. + const tasksDir = resolveTasksDir( + basePath, + activeMilestone.id, + activeSlice.id, + ); + if (tasksDir && existsSync(tasksDir) && slicePlan.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: [], + nextAction: `Task plan files missing for ${activeSlice.id}. Run plan-slice to generate task plans.`, + registry, + requirements, + progress: { + milestones: milestoneProgress, + slices: sliceProgress, + tasks: taskProgress, + }, + }; + } + } + // ── Mid-execution escalation (ADR-011 P2 — SF ADR) ──────────────── + // Pause the loop if any task in the active slice has escalation_pending=1 + // and an unresolved escalation artifact. The user must run /escalate + // resolve before autonomous mode will continue. Falls through (returns null + // from detectPendingEscalation) when nothing is paused — no perf cost + // in the common path. + { + const dbTasks = getSliceTasks(activeMilestone.id, activeSlice.id); + const escalatingTaskId = detectPendingEscalation(dbTasks, basePath); + if (escalatingTaskId) { + return { + activeMilestone, + activeSlice, + activeTask: { id: escalatingTaskId, title: "" }, + phase: "escalating-task", + recentDecisions: [], + blockers: [ + `Task ${escalatingTaskId} requires a user decision before the loop can proceed`, + ], + nextAction: `Run \`/escalate show ${escalatingTaskId}\` to review the options, then \`/escalate resolve ${escalatingTaskId} \` to proceed.`, + registry, + requirements, + progress: { + milestones: milestoneProgress, + slices: sliceProgress, + }, + }; + } + } + // ── Blocker detection: scan completed task summaries ────────────────── + // If any completed task has blocker_discovered: true and no REPLAN.md + // exists yet, transition to replanning-slice instead of executing. + const completedTasks = slicePlan.tasks.filter((t) => t.done); + let blockerTaskId = null; + for (const ct of completedTasks) { + const summaryFile = resolveTaskFile( + basePath, + activeMilestone.id, + activeSlice.id, + ct.id, + "SUMMARY", + ); + if (!summaryFile) continue; + const summaryContent = await cachedLoadFile(summaryFile); + if (!summaryContent) continue; + const summary = parseSummary(summaryContent); + if (summary.frontmatter.blocker_discovered) { + blockerTaskId = ct.id; + break; + } + } + if (blockerTaskId) { + // Loop protection: if REPLAN.md already exists, a replan was already + // performed for this slice — skip further replanning and continue executing. + const replanFile = resolveSliceFile( + basePath, + activeMilestone.id, + activeSlice.id, + "REPLAN", + ); + if (!replanFile) { + return { + activeMilestone, + activeSlice, + activeTask, + 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, + registry, + requirements, + progress: { + milestones: milestoneProgress, + slices: sliceProgress, + tasks: taskProgress, + }, + }; + } + // REPLAN.md exists — loop protection: fall through to normal executing + } + // ── REPLAN-TRIGGER detection: triage-initiated replan ────────────────── + // Manual `/triage` writes REPLAN-TRIGGER.md when a capture is classified + // as "replan". Detect it here and transition to replanning-slice so the + // dispatch loop picks it up (instead of silently advancing past it). + if (!blockerTaskId) { + const replanTriggerFile = resolveSliceFile( + basePath, + activeMilestone.id, + activeSlice.id, + "REPLAN-TRIGGER", + ); + if (replanTriggerFile) { + // Same loop protection: if REPLAN.md already exists, a replan was + // already performed — skip further replanning and continue executing. + const replanFile = resolveSliceFile( + basePath, + activeMilestone.id, + activeSlice.id, + "REPLAN", + ); + if (!replanFile) { + return { + activeMilestone, + activeSlice, + activeTask, + 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, + registry, + requirements, + progress: { + milestones: milestoneProgress, + slices: sliceProgress, + tasks: taskProgress, + }, + }; + } + } + } + // Check for interrupted work + const sDir = resolveSlicePath(basePath, activeMilestone.id, activeSlice.id); + const continueFile = sDir + ? resolveSliceFile(basePath, activeMilestone.id, activeSlice.id, "CONTINUE") + : null; + // Also check legacy continue.md + const hasInterrupted = + !!(continueFile && (await cachedLoadFile(continueFile))) || + !!(sDir && (await cachedLoadFile(join(sDir, "continue.md")))); + return { + activeMilestone, + activeSlice, + activeTask, + 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}.`, + registry, + requirements, + progress: { + milestones: milestoneProgress, + slices: sliceProgress, + tasks: taskProgress, + }, + }; +} diff --git a/src/resources/extensions/sf/state-shared.js b/src/resources/extensions/sf/state-shared.js new file mode 100644 index 000000000..727df4a0f --- /dev/null +++ b/src/resources/extensions/sf/state-shared.js @@ -0,0 +1,114 @@ +// SF Extension — State Shared Helpers +// Helpers shared by both the DB-backed (state-db.js) and legacy filesystem +// (state-legacy.js) state derivation paths. No dependency on either. + +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { isTerminalMilestoneSummaryContent } from "./milestone-summary-classifier.js"; +import { resolveMilestoneFile, sfRoot } from "./paths.js"; +import { + getMilestone, + getMilestoneValidationAssessment, + isDbAvailable, +} from "./sf-db.js"; +import { extractVerdict } from "./verdict-parser.js"; + +export function isGhostMilestone(basePath, mid) { + // If the milestone has a DB row, it's usually a known milestone — not a ghost. + // Exception: a "queued" row with no disk artifacts is a phantom from + // new_milestone_id that was never planned (#3645). + if (isDbAvailable()) { + const dbRow = getMilestone(mid); + if (dbRow) { + if (dbRow.status === "queued") { + const hasContent = + resolveMilestoneFile(basePath, mid, "CONTEXT") || + resolveMilestoneFile(basePath, mid, "CONTEXT-DRAFT") || + resolveMilestoneFile(basePath, mid, "ROADMAP") || + resolveMilestoneFile(basePath, mid, "SUMMARY"); + return !hasContent; + } + return false; + } + } + // If a worktree exists for this milestone, it was legitimately created. + const root = sfRoot(basePath); + const wtPath = join(root, "worktrees", mid); + if (existsSync(wtPath)) return false; + // Fall back to content-file check: no substantive files means ghost. + const context = resolveMilestoneFile(basePath, mid, "CONTEXT"); + const draft = resolveMilestoneFile(basePath, mid, "CONTEXT-DRAFT"); + const roadmap = resolveMilestoneFile(basePath, mid, "ROADMAP"); + const summary = resolveMilestoneFile(basePath, mid, "SUMMARY"); + return !context && !draft && !roadmap && !summary; +} +// ─── Query Functions ─────────────────────────────────────────────────────── +/** + * Check if all tasks in a slice plan are done. + */ +export function isSliceComplete(plan) { + return plan.tasks.length > 0 && plan.tasks.every((t) => t.done); +} +/** + * Check if all slices in a roadmap are done. + */ +export function isMilestoneComplete(roadmap) { + return roadmap.slices.length > 0 && roadmap.slices.every((s) => s.done); +} +/** + * Check whether a VALIDATION file's verdict is terminal. + * Any successfully extracted verdict (pass, needs-attention, needs-remediation, + * fail, etc.) means validation completed. Only return false when no verdict + * could be parsed — i.e. extractVerdict() returns undefined (#2769). + */ +export function isValidationTerminal(validationContent) { + return extractVerdict(validationContent) != null; +} +function getDbMilestoneValidationVerdict(milestoneId) { + if (!isDbAvailable()) return undefined; + const assessment = getMilestoneValidationAssessment(milestoneId); + const status = assessment?.status; + return typeof status === "string" && status.trim() + ? status.trim().toLowerCase() + : undefined; +} +export async function readMilestoneValidationVerdict(basePath, milestoneId, load) { + const dbVerdict = getDbMilestoneValidationVerdict(milestoneId); + if (dbVerdict) { + return { terminal: true, verdict: dbVerdict }; + } + if (isDbAvailable()) { + return { terminal: false, verdict: undefined, source: "db-missing" }; + } + const validationFile = resolveMilestoneFile( + basePath, + milestoneId, + "VALIDATION", + ); + const validationContent = validationFile ? await load(validationFile) : null; + return { + terminal: validationContent + ? isValidationTerminal(validationContent) + : false, + verdict: validationContent ? extractVerdict(validationContent) : undefined, + }; +} +export async function loadTerminalSummary(summaryFile, loadFn) { + if (!summaryFile) return null; + const sc = await loadFn(summaryFile); + if (sc == null || !isTerminalMilestoneSummaryContent(sc)) return null; + return sc; +} +export function stripMilestonePrefix(title) { + return title.replace(/^M\d+(?:-[a-z0-9]{6})?[^:]*:\s*/, "") || title; +} +export function canonicalMilestonePrefix(id) { + return id.match(/^([A-Z]\d{3})/)?.[1] ?? id; +} +export function extractContextTitle(content, fallback) { + if (!content) return fallback; + const h1 = content.split("\n").find((line) => line.startsWith("# ")); + if (!h1) return fallback; + // Extract title from "# M005: Platform Foundation & Separation" format + return stripMilestonePrefix(h1.slice(2).trim()) || fallback; +} diff --git a/src/resources/extensions/sf/state.js b/src/resources/extensions/sf/state.js index 55d933bac..9f37e5132 100644 --- a/src/resources/extensions/sf/state.js +++ b/src/resources/extensions/sf/state.js @@ -2,144 +2,46 @@ // DB-primary state derivation with explicit recovery guidance when DB-backed // projects cannot be opened. Legacy filesystem parsing remains available only // for projects that have not yet attempted DB bootstrap in this process. -// Pure TypeScript, zero Pi dependencies. -import { existsSync, readdirSync, readFileSync } from "node:fs"; -import { join, resolve } from "node:path"; + import { debugCount, debugTime } from "./debug-logger.js"; -import { detectPendingEscalation } from "./escalation.js"; -import { - isValidTaskSummary, - loadFile, - parseContextDependsOn, - parseRequirementCounts, - parseSummary, -} from "./files.js"; +import { loadFile } from "./files.js"; import { findMilestoneIds } from "./milestone-ids.js"; -import { isTerminalMilestoneSummaryContent } from "./milestone-summary-classifier.js"; -import { nativeBatchParseSfFiles } from "./native-parser-bridge.js"; -import { parsePlan, parseRoadmap } from "./parsers.js"; -import { - clearPathCache, - resolveMilestoneFile, - resolveSfRootFile, - resolveSliceFile, - resolveSlicePath, - resolveTaskFile, - resolveTasksDir, - sfRoot, -} from "./paths.js"; -import { getSlicePlanBlockingIssue } from "./plan-quality.js"; +import { parseRoadmap } from "./parsers.js"; +import { clearPathCache, resolveMilestoneFile, resolveSfRootFile } from "./paths.js"; import { loadQueueOrder, sortByQueueOrder } from "./queue-order.js"; import { getAllMilestones, - getMilestone, - getMilestoneSlices, - getMilestoneValidationAssessment, - getPendingGateCountForTurn, - getReplanHistory, - getSlice, - getSliceTasks, isDbAvailable, wasDbOpenAttempted, } from "./sf-db.js"; -import { isClosedStatus, isDeferredStatus } from "./status-guards.js"; -import { extractVerdict } from "./verdict-parser.js"; +import { isClosedStatus } from "./status-guards.js"; import { logWarning } from "./workflow-logger.js"; -/** - * A "ghost" milestone directory contains only META.json (and no substantive - * files like CONTEXT, CONTEXT-DRAFT, ROADMAP, or SUMMARY). These appear when - * a milestone is created but never initialised. Treating them as active causes - * autonomous mode to stall or falsely declare completion. - * - * However, a milestone is NOT a ghost if: - * - It has a DB row with a meaningful status (queued, active, etc.) — the DB - * knows about it even if content files haven't been created yet. - * - It has a worktree directory — a worktree proves the milestone was - * legitimately created and is expected to be populated. - * - * Fixes #2921: queued milestones with worktrees were incorrectly classified - * as ghosts, causing autonomous mode to skip them entirely. - */ -export function isGhostMilestone(basePath, mid) { - // If the milestone has a DB row, it's usually a known milestone — not a ghost. - // Exception: a "queued" row with no disk artifacts is a phantom from - // new_milestone_id that was never planned (#3645). - if (isDbAvailable()) { - const dbRow = getMilestone(mid); - if (dbRow) { - if (dbRow.status === "queued") { - const hasContent = - resolveMilestoneFile(basePath, mid, "CONTEXT") || - resolveMilestoneFile(basePath, mid, "CONTEXT-DRAFT") || - resolveMilestoneFile(basePath, mid, "ROADMAP") || - resolveMilestoneFile(basePath, mid, "SUMMARY"); - return !hasContent; - } - return false; - } - } - // If a worktree exists for this milestone, it was legitimately created. - const root = sfRoot(basePath); - const wtPath = join(root, "worktrees", mid); - if (existsSync(wtPath)) return false; - // Fall back to content-file check: no substantive files means ghost. - const context = resolveMilestoneFile(basePath, mid, "CONTEXT"); - const draft = resolveMilestoneFile(basePath, mid, "CONTEXT-DRAFT"); - const roadmap = resolveMilestoneFile(basePath, mid, "ROADMAP"); - const summary = resolveMilestoneFile(basePath, mid, "SUMMARY"); - return !context && !draft && !roadmap && !summary; -} -// ─── Query Functions ─────────────────────────────────────────────────────── -/** - * Check if all tasks in a slice plan are done. - */ -export function isSliceComplete(plan) { - return plan.tasks.length > 0 && plan.tasks.every((t) => t.done); -} -/** - * Check if all slices in a roadmap are done. - */ -export function isMilestoneComplete(roadmap) { - return roadmap.slices.length > 0 && roadmap.slices.every((s) => s.done); -} -/** - * Check whether a VALIDATION file's verdict is terminal. - * Any successfully extracted verdict (pass, needs-attention, needs-remediation, - * fail, etc.) means validation completed. Only return false when no verdict - * could be parsed — i.e. extractVerdict() returns undefined (#2769). - */ -export function isValidationTerminal(validationContent) { - return extractVerdict(validationContent) != null; -} -function getDbMilestoneValidationVerdict(milestoneId) { - if (!isDbAvailable()) return undefined; - const assessment = getMilestoneValidationAssessment(milestoneId); - const status = assessment?.status; - return typeof status === "string" && status.trim() - ? status.trim().toLowerCase() - : undefined; -} -async function readMilestoneValidationVerdict(basePath, milestoneId, load) { - const dbVerdict = getDbMilestoneValidationVerdict(milestoneId); - if (dbVerdict) { - return { terminal: true, verdict: dbVerdict }; - } - if (isDbAvailable()) { - return { terminal: false, verdict: undefined, source: "db-missing" }; - } - const validationFile = resolveMilestoneFile( - basePath, - milestoneId, - "VALIDATION", - ); - const validationContent = validationFile ? await load(validationFile) : null; - return { - terminal: validationContent - ? isValidationTerminal(validationContent) - : false, - verdict: validationContent ? extractVerdict(validationContent) : undefined, - }; -} +// Sub-module imports +import { + extractContextTitle, + isGhostMilestone, + isMilestoneComplete, + isSliceComplete, + isValidationTerminal, + loadTerminalSummary, + readMilestoneValidationVerdict, + stripMilestonePrefix, +} from "./state-shared.js"; +import { deriveStateFromDb } from "./state-db.js"; +import { _deriveStateImpl } from "./state-legacy.js"; +export { + extractContextTitle, + isGhostMilestone, + isMilestoneComplete, + isSliceComplete, + isValidationTerminal, + loadTerminalSummary, + readMilestoneValidationVerdict, + stripMilestonePrefix, + deriveStateFromDb, + _deriveStateImpl, +}; + const CACHE_TTL_MS = 5000; let _stateCache = null; // ── Telemetry counters for derive-path observability ──────────────────────── @@ -195,16 +97,7 @@ function buildDbRecoveryRequiredState() { * * Consumer: getActiveMilestoneId() and _deriveStateImpl() Phase 1 + Phase 2. */ -async function loadTerminalSummary(summaryFile, loadFn) { - if (!summaryFile) return null; - const sc = await loadFn(summaryFile); - if (sc == null || !isTerminalMilestoneSummaryContent(sc)) return null; - return sc; -} -/** - * Invalidate the deriveState() cache. Call this whenever planning files on disk - * may have changed (unit completion, merges, file writes). - */ + export function invalidateStateCache() { _stateCache = null; clearPathCache(); @@ -333,1680 +226,3 @@ export async function deriveState(basePath) { * Strip the "M001: " prefix from a milestone title to get the human-readable name. * Used by both DB and filesystem paths for consistency. */ -function stripMilestonePrefix(title) { - return title.replace(/^M\d+(?:-[a-z0-9]{6})?[^:]*:\s*/, "") || title; -} -function canonicalMilestonePrefix(id) { - return id.match(/^([A-Z]\d{3})/)?.[1] ?? id; -} -function extractContextTitle(content, fallback) { - if (!content) return fallback; - const h1 = content.split("\n").find((line) => line.startsWith("# ")); - if (!h1) return fallback; - // Extract title from "# M005: Platform Foundation & Separation" format - return stripMilestonePrefix(h1.slice(2).trim()) || fallback; -} -// ─── DB-backed State Derivation ──────────────────────────────────────────── -// isStatusDone replaced by isClosedStatus from status-guards.ts (single source of truth). -// Alias kept for backward compatibility within this file. -const isStatusDone = isClosedStatus; -/** - * Derive SF state from the milestones/slices/tasks DB tables. - * Non-planning control files (PARKED, CONTINUE, REPLAN, REPLAN-TRIGGER, - * CONTEXT-DRAFT) are still checked on the filesystem since they are not - * hierarchy state. - * Requirements also stay file-based via parseRequirementCounts(). - * - * Must not import rendered roadmap, plan, or summary artifacts into DB-backed - * runtime state. Explicit migration/repair flows own any legacy file import. - */ -function reconcileDiskToDb(basePath) { - const diskIds = findMilestoneIds(basePath); - if (diskIds.length > 0) { - const dbIds = new Set(getAllMilestones().map((m) => m.id)); - const dbPrefixes = new Set( - Array.from(dbIds, (id) => canonicalMilestonePrefix(id)), - ); - const diskOnlyIds = diskIds.filter( - (id) => - !dbIds.has(id) && - !dbPrefixes.has(canonicalMilestonePrefix(id)) && - !isGhostMilestone(basePath, id), - ); - if (diskOnlyIds.length > 0) { - logWarning( - "state", - `DB-backed state ignored ${diskOnlyIds.length} disk-only milestone(s): ${diskOnlyIds.join(", ")}`, - ); - } - } - return getAllMilestones(); -} -function buildCompletenessSet(basePath, milestones) { - const completeMilestoneIds = new Set(); - const parkedMilestoneIds = new Set(); - // DB-authoritative: a milestone is only "complete" when its DB row says so. - // SUMMARY-file presence is NOT a completion signal here — an orphan SUMMARY - // (crashed complete-milestone turn, partial merge, manual edit) must not - // flip derived state to complete and cascade into a false auto-merge (#4179). - for (const m of milestones) { - 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); - } - } - return { completeMilestoneIds, parkedMilestoneIds }; -} -async function buildRegistryAndFindActive( - basePath, - milestones, - completeMilestoneIds, - parkedMilestoneIds, -) { - const registry = []; - let activeMilestone = null; - let activeMilestoneSlices = []; - let activeMilestoneFound = false; - let activeMilestoneHasDraft = false; - let firstDeferredQueuedShell = null; - for (const m of milestones) { - if (parkedMilestoneIds.has(m.id)) { - registry.push({ - id: m.id, - title: stripMilestonePrefix(m.title) || m.id, - status: "parked", - }); - continue; - } - const slices = getMilestoneSlices(m.id); - if ( - slices.length === 0 && - !isStatusDone(m.status) && - m.status !== "queued" - ) { - if (isGhostMilestone(basePath, m.id)) continue; - } - // DB-authoritative completeness (#4179): only trust completeMilestoneIds, - // which is itself derived from DB status. SUMMARY-file presence alone must - // not imply completion. The summary file may still be consulted below as a - // title source for legitimately-complete milestones whose DB row has no title. - if (completeMilestoneIds.has(m.id)) { - let title = stripMilestonePrefix(m.title) || m.id; - if (!m.title) { - const summaryFile = resolveMilestoneFile(basePath, m.id, "SUMMARY"); - if (summaryFile) { - const summaryContent = await loadFile(summaryFile); - if (summaryContent) { - title = parseSummary(summaryContent).title || m.id; - } - } - } - registry.push({ id: m.id, title, status: "complete" }); - continue; - } - const allSlicesDone = - slices.length > 0 && slices.every((s) => isStatusDone(s.status)); - let title = stripMilestonePrefix(m.title) || m.id; - if (title === m.id) { - const contextFile = resolveMilestoneFile(basePath, m.id, "CONTEXT"); - const draftFile = resolveMilestoneFile(basePath, m.id, "CONTEXT-DRAFT"); - const contextContent = contextFile ? await loadFile(contextFile) : null; - const draftContent = - draftFile && !contextContent ? await loadFile(draftFile) : null; - title = extractContextTitle(contextContent || draftContent, m.id); - } - if (!activeMilestoneFound) { - const deps = m.depends_on; - const depsUnmet = deps.some((dep) => !completeMilestoneIds.has(dep)); - if (depsUnmet) { - registry.push({ id: m.id, title, status: "pending", dependsOn: deps }); - continue; - } - if (m.status === "queued" && slices.length === 0) { - const contextFile = resolveMilestoneFile(basePath, m.id, "CONTEXT"); - const draftFile = resolveMilestoneFile(basePath, m.id, "CONTEXT-DRAFT"); - if (!contextFile && !draftFile) { - if (!firstDeferredQueuedShell) { - firstDeferredQueuedShell = { id: m.id, title, deps }; - } - registry.push({ - id: m.id, - title, - status: "pending", - ...(deps.length > 0 ? { dependsOn: deps } : {}), - }); - continue; - } - } - if (allSlicesDone) { - const { terminal: validationTerminal } = - await readMilestoneValidationVerdict(basePath, m.id, loadFile); - // DB-authoritative (#4179): completeness is already decided by - // completeMilestoneIds above. If we reached this branch, the DB says - // the milestone is NOT complete — so any SUMMARY file on disk is an - // orphan (crashed complete-milestone, partial merge, manual edit) and - // must not short-circuit this path. When validation is terminal, fall - // through to the default active-push below so `complete-milestone` can - // re-run idempotently. - if (!validationTerminal) { - activeMilestone = { id: m.id, title }; - activeMilestoneSlices = slices; - activeMilestoneFound = true; - registry.push({ - id: m.id, - title, - status: "active", - ...(deps.length > 0 ? { dependsOn: deps } : {}), - }); - continue; - } - } - const contextFile = resolveMilestoneFile(basePath, m.id, "CONTEXT"); - const draftFile = resolveMilestoneFile(basePath, m.id, "CONTEXT-DRAFT"); - if (!contextFile && draftFile) activeMilestoneHasDraft = true; - activeMilestone = { id: m.id, title }; - activeMilestoneSlices = slices; - activeMilestoneFound = true; - registry.push({ - id: m.id, - title, - status: "active", - ...(deps.length > 0 ? { dependsOn: deps } : {}), - }); - } else { - const deps = m.depends_on; - registry.push({ - id: m.id, - title, - status: "pending", - ...(deps.length > 0 ? { dependsOn: deps } : {}), - }); - } - } - if (!activeMilestoneFound && firstDeferredQueuedShell) { - const shell = firstDeferredQueuedShell; - activeMilestone = { id: shell.id, title: shell.title }; - activeMilestoneSlices = []; - activeMilestoneFound = true; - const entry = registry.find((e) => e.id === shell.id); - if (entry) entry.status = "active"; - } - return { - registry, - activeMilestone, - activeMilestoneSlices, - activeMilestoneHasDraft, - }; -} -function handleNoActiveMilestone(registry, requirements, milestoneProgress) { - 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 /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 /next 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, - activeMilestone, - registry, - requirements, - milestoneProgress, - sliceProgress, -) { - const { terminal: validationTerminal, verdict } = - await readMilestoneValidationVerdict( - basePath, - activeMilestone.id, - loadFile, - ); - 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) { - const doneSliceIds = new Set( - activeMilestoneSlices - .filter((s) => isStatusDone(s.status)) - .map((s) => s.id), - ); - const sliceLock = process.env.SF_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", - `SF_SLICE_LOCK=${sliceLock} not found in active slices — worker has no assigned work`, - ); - return { activeSlice: null, activeSliceRow: null }; - } - } - // First pass: find a slice with ALL dependencies satisfied (strict) - let bestFallback = null; - let bestFallbackSatisfied = -1; - 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 }; - } - // Track the slice with the most satisfied dependencies as fallback - const satisfied = s.depends.filter((dep) => doneSliceIds.has(dep)).length; - if ( - satisfied > bestFallbackSatisfied || - (satisfied === bestFallbackSatisfied && !bestFallback) - ) { - bestFallback = s; - bestFallbackSatisfied = satisfied; - } - } - // Fallback: if no slice has all deps met but there ARE incomplete non-deferred - // slices, pick the one with the most deps satisfied. This prevents hard-blocking - // when dependency metadata is stale (e.g. after reassessment added/removed slices) - // or when deps reference slices from previous milestones. - if (bestFallback) { - const unmet = bestFallback.depends.filter((dep) => !doneSliceIds.has(dep)); - logWarning( - "state", - `No slice has all deps satisfied — falling back to ${bestFallback.id} ` + - `(${bestFallbackSatisfied}/${bestFallback.depends.length} deps met, ` + - `unmet: ${unmet.join(", ")})`, - { mid: activeMilestoneSlices[0]?.milestone_id, sid: bestFallback.id }, - ); - return { - activeSlice: { id: bestFallback.id, title: bestFallback.title }, - activeSliceRow: bestFallback, - }; - } - return { activeSlice: null, activeSliceRow: null }; -} -async function reconcileSliceTasks(basePath, milestoneId, sliceId, planFile) { - const tasks = getSliceTasks(milestoneId, sliceId); - if (tasks.length === 0 && planFile) { - logWarning( - "reconcile", - `slice plan file exists for ${milestoneId}/${sliceId}, but DB has no task rows; refusing runtime import`, - { mid: milestoneId, sid: sliceId }, - ); - } - for (const t of tasks) { - if (isStatusDone(t.status)) continue; - const summaryPath = resolveTaskFile( - basePath, - milestoneId, - sliceId, - t.id, - "SUMMARY", - ); - if (summaryPath && existsSync(summaryPath)) { - logWarning( - "reconcile", - `task ${milestoneId}/${sliceId}/${t.id} has SUMMARY on disk but DB status is "${t.status}"; refusing runtime status import`, - { mid: milestoneId, sid: sliceId, tid: t.id }, - ); - } - } - return tasks; -} -async function detectBlockers(basePath, milestoneId, sliceId, tasks) { - 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, milestoneId, sliceId) { - 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, milestoneId, sliceId) { - 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) { - const requirements = parseRequirementCounts( - await loadFile(resolveSfRootFile(basePath, "REQUIREMENTS")), - ); - const 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.SF_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 /next 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) { - if (!hasRoadmap) { - const phase = activeMilestoneHasDraft - ? "needs-discussion" - : "pre-planning"; - const nextAction = activeMilestoneHasDraft - ? `Discuss draft context for milestone ${activeMilestone.id}.` - : `Plan milestone ${activeMilestone.id}.`; - return { - activeMilestone, - activeSlice: null, - activeTask: null, - phase, - recentDecisions: [], - blockers: [], - nextAction, - registry, - requirements, - progress: { milestones: milestoneProgress }, - }; - } - return { - activeMilestone, - activeSlice: null, - activeTask: null, - 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 }, - }, - }; - } - const allSlicesDone = activeMilestoneSlices.every((s) => - isStatusDone(s.status), - ); - const sliceProgress = { - done: activeMilestoneSlices.filter((s) => isStatusDone(s.status)).length, - total: activeMilestoneSlices.length, - }; - if (allSlicesDone) { - return handleAllSlicesDone( - basePath, - activeMilestone, - registry, - requirements, - milestoneProgress, - sliceProgress, - ); - } - 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.SF_SLICE_LOCK) { - return { - activeMilestone, - activeSlice: null, - activeTask: null, - phase: "blocked", - recentDecisions: [], - blockers: [ - `SF_SLICE_LOCK=${process.env.SF_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 }, - }; - } - return { - activeMilestone, - activeSlice: null, - activeTask: null, - 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; - const planFile = resolveSliceFile( - basePath, - activeMilestone.id, - activeSlice.id, - "PLAN", - ); - const dbTasksBefore = getSliceTasks(activeMilestone.id, activeSlice.id); - if (!planFile && dbTasksBefore.length === 0) { - return { - activeMilestone, - activeSlice, - activeTask: null, - phase: "planning", - recentDecisions: [], - blockers: [], - nextAction: `Plan slice ${activeSlice.id} (${activeSlice.title}).`, - registry, - requirements, - progress: { milestones: milestoneProgress, slices: sliceProgress }, - }; - } - const tasks = planFile - ? await reconcileSliceTasks( - basePath, - activeMilestone.id, - activeSlice.id, - planFile, - ) - : dbTasksBefore; - const taskProgress = { - done: tasks.filter((t) => isStatusDone(t.status)).length, - total: tasks.length, - }; - const activeTaskRow = tasks.find((t) => !isStatusDone(t.status)); - if (!activeTaskRow && tasks.length > 0) { - return { - activeMilestone, - activeSlice, - activeTask: null, - 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, - }, - }; - } - if (!activeTaskRow) { - return { - activeMilestone, - activeSlice, - activeTask: null, - 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, - }, - }; - } - const activeTask = { - id: activeTaskRow.id, - title: activeTaskRow.title, - }; - 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: [], - nextAction: `Task plan files missing for ${activeSlice.id}. Run plan-slice to generate task plans.`, - registry, - requirements, - progress: { - milestones: milestoneProgress, - slices: sliceProgress, - tasks: taskProgress, - }, - }; - } - } - // ── Quality gate evaluation check ────────────────────────────────── - // Pause before execution only when gates owned by the `gate-evaluate` - // turn (Q3/Q4) are still pending. Q8 is also `scope:"slice"` but is - // owned by `complete-slice`, so it must NOT block the evaluating-gates - // phase — otherwise auto-loop stalls forever waiting for a gate that - // this turn never evaluates. See gate-registry.ts for the ownership map. - // Slices with zero gate rows (pre-feature or simple) skip straight through. - const pendingGateCount = getPendingGateCountForTurn( - activeMilestone.id, - activeSlice.id, - "gate-evaluate", - ); - if (pendingGateCount > 0) { - return { - activeMilestone, - activeSlice, - activeTask: null, - 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, - }, - }; - } - const blockerTaskId = await detectBlockers( - basePath, - activeMilestone.id, - activeSlice.id, - tasks, - ); - if (blockerTaskId) { - const replanHistory = getReplanHistory(activeMilestone.id, activeSlice.id); - if (replanHistory.length === 0) { - return { - activeMilestone, - activeSlice, - activeTask, - 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, - registry, - requirements, - progress: { - milestones: milestoneProgress, - slices: sliceProgress, - tasks: taskProgress, - }, - }; - } - } - if (!blockerTaskId) { - 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: [], - blockers: ["Triage replan trigger detected — slice replan required"], - nextAction: `Triage replan triggered for slice ${activeSlice.id}. Replan before continuing.`, - activeWorkspace: undefined, - registry, - requirements, - progress: { - milestones: milestoneProgress, - slices: sliceProgress, - tasks: taskProgress, - }, - }; - } - } - } - const hasInterrupted = await checkInterruptedWork( - basePath, - activeMilestone.id, - activeSlice.id, - ); - return { - activeMilestone, - activeSlice, - activeTask, - 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}.`, - registry, - requirements, - progress: { - milestones: milestoneProgress, - slices: sliceProgress, - tasks: taskProgress, - }, - }; -} -// 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) { - const diskIds = findMilestoneIds(basePath); - const customOrder = loadQueueOrder(basePath); - const milestoneIds = sortByQueueOrder(diskIds, customOrder); - // ── Parallel worker isolation ────────────────────────────────────────── - // When SF_MILESTONE_LOCK is set, this process is a parallel worker - // scoped to a single milestone. Filter the milestone list so this worker - // only sees its assigned milestone (all others are treated as if they - // don't exist). This gives each worker complete isolation without - // modifying any other state derivation logic. - const milestoneLock = process.env.SF_MILESTONE_LOCK; - if (milestoneLock && milestoneIds.includes(milestoneLock)) { - milestoneIds.length = 0; - milestoneIds.push(milestoneLock); - } - // ── Batch-parse file cache ────────────────────────────────────────────── - // When the native Rust parser is available, read every .md file under .sf/ - // in one call and build an in-memory content map keyed by absolute path. - // This eliminates O(N) individual fs.readFile calls during traversal. - const fileContentCache = new Map(); - const sfDir = sfRoot(basePath); - // Filesystem fallback: used when deriveStateFromDb() is not available - // (pre-migration projects). The DB-backed path is preferred when available - // — see deriveStateFromDb() above. - const batchFiles = nativeBatchParseSfFiles(sfDir); - if (batchFiles) { - for (const f of batchFiles) { - const absPath = resolve(sfDir, f.path); - fileContentCache.set(absPath, f.rawContent); - } - } - /** - * Load file content from batch cache first, falling back to disk read. - * Resolves the path to absolute before cache lookup. - */ - async function cachedLoadFile(path) { - const abs = resolve(path); - const cached = fileContentCache.get(abs); - if (cached !== undefined) return cached; - return loadFile(path); - } - const requirements = parseRequirementCounts( - await cachedLoadFile(resolveSfRootFile(basePath, "REQUIREMENTS")), - ); - if (milestoneIds.length === 0) { - return { - activeMilestone: null, - activeSlice: null, - activeTask: null, - phase: "pre-planning", - recentDecisions: [], - blockers: [], - nextAction: "No milestones found. Run /next to create one.", - registry: [], - requirements, - progress: { - milestones: { done: 0, total: 0 }, - }, - }; - } - // ── Single-pass milestone scan ────────────────────────────────────────── - // Parse each milestone's roadmap once, caching results. First pass determines - // completeness for dependency resolution; second pass builds the registry. - // With the batch cache, all file reads hit memory instead of disk. - // Phase 1: Build roadmap cache and completeness set - const roadmapCache = new Map(); - const completeMilestoneIds = new Set(); - // Track parked milestone IDs so Phase 2 can check without re-reading disk - const parkedMilestoneIds = new Set(); - for (const mid of milestoneIds) { - // Skip parked milestones — they do NOT count as complete (don't satisfy depends_on) - // But still parse their roadmap for title extraction in Phase 2. - const parkedFile = resolveMilestoneFile(basePath, mid, "PARKED"); - if (parkedFile) { - parkedMilestoneIds.add(mid); - // Cache roadmap for title extraction (but don't add to completeMilestoneIds) - const prf = resolveMilestoneFile(basePath, mid, "ROADMAP"); - const prc = prf ? await cachedLoadFile(prf) : null; - if (prc) roadmapCache.set(mid, parseRoadmap(prc)); - continue; - } - const rf = resolveMilestoneFile(basePath, mid, "ROADMAP"); - const rc = rf ? await cachedLoadFile(rf) : null; - if (!rc) { - const sf = resolveMilestoneFile(basePath, mid, "SUMMARY"); - if (await loadTerminalSummary(sf, cachedLoadFile)) - completeMilestoneIds.add(mid); - continue; - } - const rmap = parseRoadmap(rc); - roadmapCache.set(mid, rmap); - if (!isMilestoneComplete(rmap)) { - // Summary is the terminal artifact — if it exists and is terminal, the milestone is - // complete even when roadmap checkboxes weren't ticked (#864). - const sf = resolveMilestoneFile(basePath, mid, "SUMMARY"); - if (await loadTerminalSummary(sf, cachedLoadFile)) - completeMilestoneIds.add(mid); - continue; - } - const sf = resolveMilestoneFile(basePath, mid, "SUMMARY"); - if (await loadTerminalSummary(sf, cachedLoadFile)) - completeMilestoneIds.add(mid); - } - // Phase 2: Build registry using cached roadmaps (no re-parsing or re-reading) - const registry = []; - let activeMilestone = null; - let activeRoadmap = null; - let activeMilestoneFound = false; - let activeMilestoneHasDraft = false; - for (const mid of milestoneIds) { - // Skip parked milestones — register them as 'parked' and move on - if (parkedMilestoneIds.has(mid)) { - const roadmap = roadmapCache.get(mid) ?? null; - const title = roadmap ? stripMilestonePrefix(roadmap.title) : mid; - registry.push({ id: mid, title, status: "parked" }); - continue; - } - const roadmap = roadmapCache.get(mid) ?? null; - if (!roadmap) { - // No roadmap — check if a terminal summary exists (completed milestone without roadmap) - const summaryFile = resolveMilestoneFile(basePath, mid, "SUMMARY"); - const sc = await loadTerminalSummary(summaryFile, cachedLoadFile); - if (sc) { - const summaryTitle = parseSummary(sc).title || mid; - registry.push({ id: mid, title: summaryTitle, status: "complete" }); - completeMilestoneIds.add(mid); - continue; - } - // Failure summary or unreadable — milestone is not yet done; fall through - // Ghost milestone (only META.json, no CONTEXT/ROADMAP/SUMMARY) — skip entirely - if (isGhostMilestone(basePath, mid)) continue; - // No roadmap and no summary — treat as incomplete/active - if (!activeMilestoneFound) { - // Check for CONTEXT-DRAFT.md to distinguish draft-seeded from blank milestones. - // A draft seed means the milestone has discussion material but no full context yet. - const contextFile = resolveMilestoneFile(basePath, mid, "CONTEXT"); - const draftFile = resolveMilestoneFile(basePath, mid, "CONTEXT-DRAFT"); - if (!contextFile && draftFile) activeMilestoneHasDraft = true; - // Extract title from CONTEXT.md or CONTEXT-DRAFT.md heading before falling back to mid. - const contextContent = contextFile - ? await cachedLoadFile(contextFile) - : null; - const draftContent = - draftFile && !contextContent ? await cachedLoadFile(draftFile) : null; - const title = extractContextTitle(contextContent || draftContent, mid); - // Check milestone-level dependencies before promoting to active. - // Without this, a queued milestone with depends_on in its CONTEXT - // or CONTEXT-DRAFT frontmatter would be promoted to active even when - // its deps are unmet. Fall back to CONTEXT-DRAFT.md when absent (#1724). - const deps = parseContextDependsOn(contextContent ?? draftContent); - const depsUnmet = deps.some((dep) => !completeMilestoneIds.has(dep)); - if (depsUnmet) { - registry.push({ id: mid, title, status: "pending", dependsOn: deps }); - } else { - activeMilestone = { id: mid, title }; - activeMilestoneFound = true; - registry.push({ - id: mid, - title, - status: "active", - ...(deps.length > 0 ? { dependsOn: deps } : {}), - }); - } - } else { - // For milestones after the active one, also try to extract title from context files. - const contextFile = resolveMilestoneFile(basePath, mid, "CONTEXT"); - const draftFile = resolveMilestoneFile(basePath, mid, "CONTEXT-DRAFT"); - const contextContent = contextFile - ? await cachedLoadFile(contextFile) - : null; - const draftContent = - draftFile && !contextContent ? await cachedLoadFile(draftFile) : null; - const title = extractContextTitle(contextContent || draftContent, mid); - registry.push({ id: mid, title, status: "pending" }); - } - continue; - } - const title = stripMilestonePrefix(roadmap.title); - const complete = isMilestoneComplete(roadmap); - if (complete) { - // All slices done — check validation and summary state - const summaryFile = resolveMilestoneFile(basePath, mid, "SUMMARY"); - const { terminal: validationTerminal, verdict } = - await readMilestoneValidationVerdict(basePath, mid, cachedLoadFile); - // needs-remediation is terminal but requires re-validation (#3596) - const needsRevalidation = - !validationTerminal || verdict === "needs-remediation"; - if (await loadTerminalSummary(summaryFile, cachedLoadFile)) { - // Terminal summary → milestone is complete. The summary is the terminal artifact (#864). - registry.push({ id: mid, title, status: "complete" }); - continue; - } - // Failure summary or unreadable — fall through to re-validation / active logic below - if (needsRevalidation && !activeMilestoneFound) { - // No terminal summary and needs (re-)validation → validating-milestone - activeMilestone = { id: mid, title }; - activeRoadmap = roadmap; - activeMilestoneFound = true; - registry.push({ id: mid, title, status: "active" }); - } else if (needsRevalidation && activeMilestoneFound) { - // Needs (re-)validation, but another milestone is already active - registry.push({ id: mid, title, status: "pending" }); - } else if (!activeMilestoneFound) { - // Terminal validation (pass/needs-attention) but no summary → completing-milestone - activeMilestone = { id: mid, title }; - activeRoadmap = roadmap; - activeMilestoneFound = true; - registry.push({ id: mid, title, status: "active" }); - } else { - registry.push({ id: mid, title, status: "complete" }); - } - } else { - // Roadmap slices not all checked — but if a terminal summary exists, the - // milestone is still complete. The summary is the terminal artifact (#864). - const summaryFile = resolveMilestoneFile(basePath, mid, "SUMMARY"); - if (await loadTerminalSummary(summaryFile, cachedLoadFile)) { - registry.push({ id: mid, title, status: "complete" }); - } else if (!activeMilestoneFound) { - // Check milestone-level dependencies before promoting to active. - // Fall back to CONTEXT-DRAFT.md when CONTEXT.md is absent (#1724). - const contextFile = resolveMilestoneFile(basePath, mid, "CONTEXT"); - const draftFile = resolveMilestoneFile(basePath, mid, "CONTEXT-DRAFT"); - const contextContent = contextFile - ? await cachedLoadFile(contextFile) - : null; - const draftContent = - draftFile && !contextContent ? await cachedLoadFile(draftFile) : null; - const deps = parseContextDependsOn(contextContent ?? draftContent); - const depsUnmet = deps.some((dep) => !completeMilestoneIds.has(dep)); - if (depsUnmet) { - registry.push({ id: mid, title, status: "pending", dependsOn: deps }); - // Do NOT set activeMilestoneFound — let the loop continue to the next milestone - } else { - activeMilestone = { id: mid, title }; - activeRoadmap = roadmap; - activeMilestoneFound = true; - registry.push({ - id: mid, - title, - status: "active", - ...(deps.length > 0 ? { dependsOn: deps } : {}), - }); - } - } else { - const contextFile2 = resolveMilestoneFile(basePath, mid, "CONTEXT"); - const draftFileForDeps3 = resolveMilestoneFile( - basePath, - mid, - "CONTEXT-DRAFT", - ); - const contextOrDraftContent3 = contextFile2 - ? await cachedLoadFile(contextFile2) - : draftFileForDeps3 - ? await cachedLoadFile(draftFileForDeps3) - : null; - const deps2 = parseContextDependsOn(contextOrDraftContent3); - registry.push({ - id: mid, - title, - status: "pending", - ...(deps2.length > 0 ? { dependsOn: deps2 } : {}), - }); - } - } - } - const milestoneProgress = { - done: registry.filter((entry) => entry.status === "complete").length, - total: registry.length, - }; - if (!activeMilestone) { - // Check whether any milestones are pending (dep-blocked) or parked - const pendingEntries = registry.filter( - (entry) => entry.status === "pending", - ); - const parkedEntries = registry.filter((entry) => entry.status === "parked"); - if (pendingEntries.length > 0) { - // All incomplete milestones are dep-blocked — no progress possible - const blockerDetails = pendingEntries - .filter((entry) => entry.dependsOn && entry.dependsOn.length > 0) - .map( - (entry) => - `${entry.id} is waiting on unmet deps: ${entry.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) { - // All non-complete milestones are parked — nothing active, but not "all complete" - 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 /unpark or create a new milestone.`, - registry, - requirements, - progress: { - milestones: milestoneProgress, - }, - }; - } - // All real milestones were ghosts (empty registry) → treat as pre-planning - if (registry.length === 0) { - return { - activeMilestone: null, - activeSlice: null, - activeTask: null, - phase: "pre-planning", - recentDecisions: [], - blockers: [], - nextAction: "No milestones found. Run /next 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."; - 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, - }, - }; - } - if (!activeRoadmap) { - // Active milestone exists but has no roadmap yet. - // If a CONTEXT-DRAFT.md seed exists, it needs discussion before planning. - // Otherwise, it's a blank milestone ready for initial planning. - const phase = activeMilestoneHasDraft ? "needs-discussion" : "pre-planning"; - const nextAction = activeMilestoneHasDraft - ? `Discuss draft context for milestone ${activeMilestone.id}.` - : `Plan milestone ${activeMilestone.id}.`; - return { - activeMilestone, - activeSlice: null, - activeTask: null, - phase, - recentDecisions: [], - blockers: [], - nextAction, - registry, - requirements, - progress: { - milestones: milestoneProgress, - }, - }; - } - // ── Zero-slice roadmap guard (#1785) ───────────────────────────────── - // A stub roadmap (placeholder text, no slice definitions) has a truthy - // roadmap object but an empty slices array. Without this check the - // slice-finding loop below finds nothing and returns phase: "blocked". - // An empty slices array means the roadmap still needs slice definitions, - // so the correct phase is pre-planning. - if (activeRoadmap.slices.length === 0) { - return { - activeMilestone, - activeSlice: null, - activeTask: null, - 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 }, - }, - }; - } - // Check if active milestone needs validation or completion (all slices done) - if (isMilestoneComplete(activeRoadmap)) { - const { terminal: validationTerminal, verdict } = - await readMilestoneValidationVerdict( - basePath, - activeMilestone.id, - cachedLoadFile, - ); - const sliceProgress = { - done: activeRoadmap.slices.length, - total: activeRoadmap.slices.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, - }, - }; - } - const sliceProgress = { - done: activeRoadmap.slices.filter((s) => s.done).length, - total: activeRoadmap.slices.length, - }; - // Find the active slice (first incomplete with deps satisfied) - const doneSliceIds = new Set( - activeRoadmap.slices.filter((s) => s.done).map((s) => s.id), - ); - let activeSlice = null; - // ── Slice-level parallel worker isolation ───────────────────────────── - // When SF_SLICE_LOCK is set, override activeSlice to only the locked slice. - const sliceLockLegacy = process.env.SF_SLICE_LOCK; - if (sliceLockLegacy) { - const lockedSlice = activeRoadmap.slices.find( - (s) => s.id === sliceLockLegacy, - ); - if (lockedSlice) { - activeSlice = { id: lockedSlice.id, title: lockedSlice.title }; - } else { - logWarning( - "state", - `SF_SLICE_LOCK=${sliceLockLegacy} not found in active slices — worker has no assigned work`, - ); - return { - activeMilestone, - activeSlice: null, - activeTask: null, - phase: "blocked", - recentDecisions: [], - blockers: [ - `SF_SLICE_LOCK=${sliceLockLegacy} 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 { - let bestFallbackLegacy = null; - let bestFallbackLegacySatisfied = -1; - for (const s of activeRoadmap.slices) { - if (s.done) continue; - if (s.depends.every((dep) => doneSliceIds.has(dep))) { - activeSlice = { id: s.id, title: s.title }; - break; - } - // Track best fallback - const satisfied = s.depends.filter((dep) => doneSliceIds.has(dep)).length; - if (satisfied > bestFallbackLegacySatisfied) { - bestFallbackLegacy = s; - bestFallbackLegacySatisfied = satisfied; - } - } - // Fallback: if no slice has all deps met, pick the one with the most deps satisfied - if (!activeSlice && bestFallbackLegacy) { - const unmet = bestFallbackLegacy.depends.filter( - (dep) => !doneSliceIds.has(dep), - ); - logWarning( - "state", - `No slice has all deps satisfied — falling back to ${bestFallbackLegacy.id} ` + - `(${bestFallbackLegacySatisfied}/${bestFallbackLegacy.depends.length} deps met, ` + - `unmet: ${unmet.join(", ")})`, - ); - activeSlice = { - id: bestFallbackLegacy.id, - title: bestFallbackLegacy.title, - }; - } - } - if (!activeSlice) { - return { - activeMilestone, - activeSlice: null, - activeTask: null, - 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, - }, - }; - } - // Check if the slice has a plan - const planFile = resolveSliceFile( - basePath, - activeMilestone.id, - activeSlice.id, - "PLAN", - ); - const slicePlanContent = planFile ? await cachedLoadFile(planFile) : null; - if (!slicePlanContent) { - return { - activeMilestone, - activeSlice, - activeTask: null, - phase: "planning", - recentDecisions: [], - blockers: [], - nextAction: `Plan slice ${activeSlice.id} (${activeSlice.title}).`, - registry, - requirements, - progress: { - milestones: milestoneProgress, - slices: sliceProgress, - }, - }; - } - const slicePlan = parsePlan(slicePlanContent); - const planQualityIssue = getSlicePlanBlockingIssue(slicePlanContent); - if (planQualityIssue && slicePlan.tasks.length === 0) { - return { - activeMilestone, - activeSlice, - activeTask: null, - phase: "planning", - recentDecisions: [], - blockers: [], - nextAction: `Slice ${activeSlice.id} plan is incomplete (${planQualityIssue}). Re-run plan-slice with partner/combatant/architect review.`, - registry, - requirements, - progress: { - milestones: milestoneProgress, - slices: sliceProgress, - }, - }; - } - // ── Reconcile stale task status for filesystem-based projects (#2514) ── - // Heading-style tasks (### T01:) are always parsed as done=false by - // parsePlan because the heading syntax has no checkbox. When the agent - // writes a SUMMARY file but the plan's heading isn't converted to a - // checkbox, the task appears incomplete forever — causing infinite - // re-dispatch. Reconcile by checking SUMMARY files on disk. - for (const t of slicePlan.tasks) { - if (t.done) continue; - const summaryPath = resolveTaskFile( - basePath, - activeMilestone.id, - activeSlice.id, - t.id, - "SUMMARY", - ); - if (summaryPath && existsSync(summaryPath)) { - // Validate that the summary file has actual content (#sf-moobj36o-6rxy6e) - const summaryContent = readFileSync(summaryPath, "utf-8"); - if (!isValidTaskSummary(summaryContent)) { - logWarning( - "reconcile", - `task ${activeMilestone.id}/${activeSlice.id}/${t.id} has empty/invalid SUMMARY — skipping reconciliation`, - { mid: activeMilestone.id, sid: activeSlice.id, tid: t.id }, - ); - continue; - } - t.done = true; - logWarning( - "reconcile", - `task ${activeMilestone.id}/${activeSlice.id}/${t.id} reconciled via SUMMARY on disk (#2514)`, - { mid: activeMilestone.id, sid: activeSlice.id, tid: t.id }, - ); - } - } - const taskProgress = { - done: slicePlan.tasks.filter((t) => t.done).length, - total: slicePlan.tasks.length, - }; - const activeTaskEntry = slicePlan.tasks.find((t) => !t.done); - if (!activeTaskEntry && slicePlan.tasks.length > 0) { - // All tasks done but slice not marked complete - return { - activeMilestone, - activeSlice, - activeTask: null, - 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, stay in planning phase - if (!activeTaskEntry) { - return { - activeMilestone, - activeSlice, - activeTask: null, - 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, - }, - }; - } - const activeTask = { - id: activeTaskEntry.id, - title: activeTaskEntry.title, - }; - // ── Task plan file check (#909) ────────────────────────────────────── - // The slice plan may reference tasks but per-task plan files may be - // missing — e.g. when the slice plan was pre-created during roadmapping. - // If the tasks dir exists but has literally zero files (empty dir from - // mkdir), fall back to planning so plan-slice generates task plans. - const tasksDir = resolveTasksDir( - basePath, - activeMilestone.id, - activeSlice.id, - ); - if (tasksDir && existsSync(tasksDir) && slicePlan.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: [], - nextAction: `Task plan files missing for ${activeSlice.id}. Run plan-slice to generate task plans.`, - registry, - requirements, - progress: { - milestones: milestoneProgress, - slices: sliceProgress, - tasks: taskProgress, - }, - }; - } - } - // ── Mid-execution escalation (ADR-011 P2 — SF ADR) ──────────────── - // Pause the loop if any task in the active slice has escalation_pending=1 - // and an unresolved escalation artifact. The user must run /escalate - // resolve before autonomous mode will continue. Falls through (returns null - // from detectPendingEscalation) when nothing is paused — no perf cost - // in the common path. - { - const dbTasks = getSliceTasks(activeMilestone.id, activeSlice.id); - const escalatingTaskId = detectPendingEscalation(dbTasks, basePath); - if (escalatingTaskId) { - return { - activeMilestone, - activeSlice, - activeTask: { id: escalatingTaskId, title: "" }, - phase: "escalating-task", - recentDecisions: [], - blockers: [ - `Task ${escalatingTaskId} requires a user decision before the loop can proceed`, - ], - nextAction: `Run \`/escalate show ${escalatingTaskId}\` to review the options, then \`/escalate resolve ${escalatingTaskId} \` to proceed.`, - registry, - requirements, - progress: { - milestones: milestoneProgress, - slices: sliceProgress, - }, - }; - } - } - // ── Blocker detection: scan completed task summaries ────────────────── - // If any completed task has blocker_discovered: true and no REPLAN.md - // exists yet, transition to replanning-slice instead of executing. - const completedTasks = slicePlan.tasks.filter((t) => t.done); - let blockerTaskId = null; - for (const ct of completedTasks) { - const summaryFile = resolveTaskFile( - basePath, - activeMilestone.id, - activeSlice.id, - ct.id, - "SUMMARY", - ); - if (!summaryFile) continue; - const summaryContent = await cachedLoadFile(summaryFile); - if (!summaryContent) continue; - const summary = parseSummary(summaryContent); - if (summary.frontmatter.blocker_discovered) { - blockerTaskId = ct.id; - break; - } - } - if (blockerTaskId) { - // Loop protection: if REPLAN.md already exists, a replan was already - // performed for this slice — skip further replanning and continue executing. - const replanFile = resolveSliceFile( - basePath, - activeMilestone.id, - activeSlice.id, - "REPLAN", - ); - if (!replanFile) { - return { - activeMilestone, - activeSlice, - activeTask, - 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, - registry, - requirements, - progress: { - milestones: milestoneProgress, - slices: sliceProgress, - tasks: taskProgress, - }, - }; - } - // REPLAN.md exists — loop protection: fall through to normal executing - } - // ── REPLAN-TRIGGER detection: triage-initiated replan ────────────────── - // Manual `/triage` writes REPLAN-TRIGGER.md when a capture is classified - // as "replan". Detect it here and transition to replanning-slice so the - // dispatch loop picks it up (instead of silently advancing past it). - if (!blockerTaskId) { - const replanTriggerFile = resolveSliceFile( - basePath, - activeMilestone.id, - activeSlice.id, - "REPLAN-TRIGGER", - ); - if (replanTriggerFile) { - // Same loop protection: if REPLAN.md already exists, a replan was - // already performed — skip further replanning and continue executing. - const replanFile = resolveSliceFile( - basePath, - activeMilestone.id, - activeSlice.id, - "REPLAN", - ); - if (!replanFile) { - return { - activeMilestone, - activeSlice, - activeTask, - 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, - registry, - requirements, - progress: { - milestones: milestoneProgress, - slices: sliceProgress, - tasks: taskProgress, - }, - }; - } - } - } - // Check for interrupted work - const sDir = resolveSlicePath(basePath, activeMilestone.id, activeSlice.id); - const continueFile = sDir - ? resolveSliceFile(basePath, activeMilestone.id, activeSlice.id, "CONTINUE") - : null; - // Also check legacy continue.md - const hasInterrupted = - !!(continueFile && (await cachedLoadFile(continueFile))) || - !!(sDir && (await cachedLoadFile(join(sDir, "continue.md")))); - return { - activeMilestone, - activeSlice, - activeTask, - 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}.`, - registry, - requirements, - progress: { - milestones: milestoneProgress, - slices: sliceProgress, - tasks: taskProgress, - }, - }; -}