refactor: split state.js into state-shared/db/legacy modules
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>
This commit is contained in:
parent
1adc7f119c
commit
0aaf8f2c0e
4 changed files with 1882 additions and 1816 deletions
841
src/resources/extensions/sf/state-db.js
Normal file
841
src/resources/extensions/sf/state-db.js
Normal file
|
|
@ -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 <id> or create a new milestone.`,
|
||||
registry,
|
||||
requirements,
|
||||
progress: { milestones: milestoneProgress },
|
||||
};
|
||||
}
|
||||
if (registry.length === 0) {
|
||||
return {
|
||||
activeMilestone: null,
|
||||
activeSlice: null,
|
||||
activeTask: null,
|
||||
phase: "pre-planning",
|
||||
recentDecisions: [],
|
||||
blockers: [],
|
||||
nextAction: "No milestones found. Run /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.
|
||||
895
src/resources/extensions/sf/state-legacy.js
Normal file
895
src/resources/extensions/sf/state-legacy.js
Normal file
|
|
@ -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 <id> 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} <choice>\` 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
114
src/resources/extensions/sf/state-shared.js
Normal file
114
src/resources/extensions/sf/state-shared.js
Normal file
|
|
@ -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;
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue