Symptom: dr-repo M003 had all 8 owning requirements (UNI-01..05,
PIL-01..03) marked Status: complete in .sf/REQUIREMENTS.md, but
the milestone row was still active because its only slice was a
post-migration skipped placeholder. After the previous fix routed
all-skipped milestones to pre-planning, SF ran roadmap-meeting +
plan-milestone and wrote 3 new slices on a milestone whose
contract-level work was already done — burned ~4 LLM turns on
plausibly-adjacent but unwanted re-decomposition.
Root cause: deriveStateFromDb's milestone-completion gate consults
only slice statuses (and indirectly the milestone row's own status
field). It never reads REQUIREMENTS.md to check whether the
contract is already satisfied. The slice-based view collapsed the
real signal.
Fix:
- New parseRequirementsByMilestone(content) helper in files.js:
parses REQUIREMENTS.md, groups entries by their `Primary owning
milestone` field, returns Map<id, {complete, incomplete}>.
- handleAllSlicesDone now reads REQUIREMENTS.md before its
slice-based real-work check. If a milestone has at least one
owning requirement and zero of them are incomplete, route to
completing-milestone with nextAction naming the requirement count
(so the operator can see *why* the milestone is being closed
without manually opening REQUIREMENTS.md).
- Best-effort: REQUIREMENTS.md parse failure falls through to the
existing slice-based rule. Missing file likewise — no regression
for projects that don't keep a requirements file.
Resolves sf-mp74hftw-zud6ba filed via the headless feedback CLI.
End-to-end verified by re-running sf headless query on dr-repo
M003: now reports phase=completing-milestone with the right
requirement-count message.
Tests: 5 new cases — all complete + slice skipped → completing,
some active → pre-planning, zero owning requirements falls through,
missing file falls through, all complete + real slice work still
completes. Existing 4 all-skipped-replan cases still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
936 lines
29 KiB
JavaScript
936 lines
29 KiB
JavaScript
// 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,
|
|
parseRequirementsByMilestone,
|
|
parseSummary,
|
|
} from "./files.js";
|
|
import { findMilestoneIds } from "./milestone-ids.js";
|
|
import {
|
|
resolveMilestoneFile,
|
|
resolveSfRootFile,
|
|
resolveSliceFile,
|
|
resolveSlicePath,
|
|
resolveTaskFile,
|
|
resolveTasksDir,
|
|
} from "./paths.js";
|
|
import { loadQueueOrder, sortByQueueOrder } from "./queue-order.js";
|
|
import {
|
|
getAllMilestones,
|
|
getMilestone,
|
|
getMilestoneSlices,
|
|
getMilestoneValidationAssessment,
|
|
getPendingGateCountForTurn,
|
|
getReplanHistory,
|
|
getSlice,
|
|
getSliceTasks,
|
|
isDbAvailable,
|
|
} from "./sf-db.js";
|
|
import {
|
|
canonicalMilestonePrefix,
|
|
extractContextTitle,
|
|
isGhostMilestone,
|
|
isMilestoneComplete,
|
|
loadTerminalSummary,
|
|
readMilestoneValidationVerdict,
|
|
stripMilestonePrefix,
|
|
} from "./state-shared.js";
|
|
import { isClosedStatus, isDeferredStatus } from "./status-guards.js";
|
|
import { logWarning } from "./workflow-logger.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,
|
|
activeMilestoneSlices,
|
|
) {
|
|
// Requirements-aware completion gate (sf-mp74hftw-zud6ba).
|
|
// Before the slice-based "real work" check, consult REQUIREMENTS.md:
|
|
// if every owning requirement for this milestone is closed
|
|
// (complete/done/skipped), the contract is satisfied regardless of
|
|
// slice state. Route to completing-milestone (write SUMMARY) instead
|
|
// of pre-planning, so a milestone whose work was tracked at the
|
|
// requirements layer doesn't get re-decomposed every time its slice
|
|
// state changes shape (e.g. someone skips a stale placeholder).
|
|
try {
|
|
const reqsFile = resolveSfRootFile(basePath, "REQUIREMENTS");
|
|
const reqsContent = reqsFile ? await loadFile(reqsFile) : null;
|
|
if (reqsContent) {
|
|
const reqsByMs = parseRequirementsByMilestone(reqsContent);
|
|
const owning = reqsByMs.get(activeMilestone.id);
|
|
if (owning && owning.incomplete === 0 && owning.complete > 0) {
|
|
return {
|
|
activeMilestone,
|
|
activeSlice: null,
|
|
activeTask: null,
|
|
phase: "completing-milestone",
|
|
recentDecisions: [],
|
|
blockers: [],
|
|
nextAction:
|
|
`All ${owning.complete} requirement(s) owned by ${activeMilestone.id} ` +
|
|
`are marked complete in REQUIREMENTS.md. Write milestone summary ` +
|
|
`(or close via \`sf headless complete-milestone ${activeMilestone.id}\`).`,
|
|
registry,
|
|
requirements,
|
|
progress: { milestones: milestoneProgress, slices: sliceProgress },
|
|
};
|
|
}
|
|
}
|
|
} catch {
|
|
// Best-effort — REQUIREMENTS.md parse failure must not break state derivation.
|
|
}
|
|
// All-slices-done collapses three quite different states (every
|
|
// slice complete, every slice skipped, mix of both) into one
|
|
// "ready to validate" signal. That's wrong when zero slices carry
|
|
// real-work outcomes — typically the migration-placeholder pattern
|
|
// where someone skipped the only slice on a milestone and SF
|
|
// would then ask the operator to validate work that never
|
|
// happened. Route those to reassessment instead so the milestone
|
|
// gets real slices planned.
|
|
const hasRealWork = Array.isArray(activeMilestoneSlices)
|
|
? activeMilestoneSlices.some(
|
|
(s) => s.status === "complete" || s.status === "done",
|
|
)
|
|
: true; // fall back to old behaviour if caller didn't pass slices
|
|
if (!hasRealWork) {
|
|
// Route into the pre-planning ladder. The dispatcher decides:
|
|
//
|
|
// no CONTEXT file -> discuss-milestone
|
|
// has CONTEXT, no RESEARCH -> research-milestone
|
|
// has both -> plan-milestone (which decomposes into real
|
|
// slices alongside the existing skipped ones)
|
|
//
|
|
// The milestone's vision/PDD is already in the DB (set when
|
|
// the milestone was created via `sf new-milestone`), so the
|
|
// planner has the purpose it needs. No operator action
|
|
// required - autonomous mode will pick it up on the next
|
|
// dispatch tick.
|
|
return {
|
|
activeMilestone,
|
|
activeSlice: null,
|
|
activeTask: null,
|
|
phase: "pre-planning",
|
|
recentDecisions: [],
|
|
blockers: [],
|
|
nextAction:
|
|
`Milestone ${activeMilestone.id} has no slice carrying real ` +
|
|
`work (every slice is skipped). Route into the pre-planning ` +
|
|
`ladder so the dispatcher picks discuss/research/plan based ` +
|
|
`on which artifacts are missing. Use ` +
|
|
`\`sf headless complete-milestone ${activeMilestone.id}\` ` +
|
|
`only if you want to intentionally defer the work without ` +
|
|
`planning it.`,
|
|
registry,
|
|
requirements,
|
|
progress: { milestones: milestoneProgress, slices: 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. Run reconcileTaskFromSummary() from ./state-reconcile.js to apply the on-disk SUMMARY into the DB row explicitly.`,
|
|
{ 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,
|
|
activeMilestoneSlices,
|
|
);
|
|
}
|
|
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.
|