From a2a6ab767c7bf5bd3b30942c5fceb59fbe789898 Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Fri, 15 May 2026 21:15:04 +0200 Subject: [PATCH] fix(auto): replan inactive milestones with DB context --- src/resources/extensions/sf/state-db.js | 69 ++++++++++++++----- .../auto-dispatch-canonical-plan.test.mjs | 50 ++++++++++++++ .../sf/tests/db-driven-runtime-state.test.mjs | 7 +- .../extensions/sf/uok/auto-dispatch.js | 21 ++++++ 4 files changed, 125 insertions(+), 22 deletions(-) diff --git a/src/resources/extensions/sf/state-db.js b/src/resources/extensions/sf/state-db.js index fbb78252a..4a63275a6 100644 --- a/src/resources/extensions/sf/state-db.js +++ b/src/resources/extensions/sf/state-db.js @@ -423,9 +423,7 @@ async function handleAllSlicesDone( // 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", - ) + ? hasRealWorkSlice(activeMilestoneSlices) : true; // fall back to old behaviour if caller didn't pass slices if (!hasRealWork) { // Route into the pre-planning ladder. The dispatcher decides: @@ -440,25 +438,13 @@ async function handleAllSlicesDone( // planner has the purpose it needs. No operator action // required - autonomous mode will pick it up on the next // dispatch tick. - return { + return buildNoRealWorkPrePlanningState( 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 }, - }; + milestoneProgress, + sliceProgress, + ); } const { terminal: validationTerminal, verdict } = await readMilestoneValidationVerdict( @@ -553,6 +539,39 @@ function resolveSliceDependencies(activeMilestoneSlices) { } return { activeSlice: null, activeSliceRow: null }; } + +function hasRealWorkSlice(slices) { + return slices.some((s) => s.status === "complete" || s.status === "done"); +} + +function buildNoRealWorkPrePlanningState( + activeMilestone, + registry, + requirements, + milestoneProgress, + sliceProgress, +) { + 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, cancelled, or otherwise inactive). ` + + `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 }, + }; +} + async function reconcileSliceTasks(basePath, milestoneId, sliceId, planFile) { const tasks = getSliceTasks(milestoneId, sliceId); if (tasks.length === 0 && planFile) { @@ -753,6 +772,18 @@ export async function deriveStateFromDb(basePath) { progress: { milestones: milestoneProgress, slices: sliceProgress }, }; } + if ( + activeMilestoneSlices.length > 0 && + !hasRealWorkSlice(activeMilestoneSlices) + ) { + return buildNoRealWorkPrePlanningState( + activeMilestone, + registry, + requirements, + milestoneProgress, + sliceProgress, + ); + } return { activeMilestone, activeSlice: null, diff --git a/src/resources/extensions/sf/tests/auto-dispatch-canonical-plan.test.mjs b/src/resources/extensions/sf/tests/auto-dispatch-canonical-plan.test.mjs index db01a3064..3a841cfc2 100644 --- a/src/resources/extensions/sf/tests/auto-dispatch-canonical-plan.test.mjs +++ b/src/resources/extensions/sf/tests/auto-dispatch-canonical-plan.test.mjs @@ -154,6 +154,56 @@ describe("resolveDispatch canonical milestone plan", () => { } }); + test("pre_planning_when_db_has_milestone_context_dispatches_plan_milestone", async () => { + const base = makeTempDir("sf-dispatch-db-context-plan-"); + try { + mkdirSync(join(base, ".sf"), { recursive: true }); + openDatabase(join(base, ".sf", "sf.db")); + insertMilestone({ + id: "M325", + title: "DB context replan", + status: "active", + planning: { + vision: "Replan from DB-backed milestone purpose.", + successCriteria: ["Planner receives enough context from DB."], + }, + }); + insertSlice({ + milestoneId: "M325", + id: "S01", + title: "Cancelled stale slice", + status: "cancelled", + goal: "Old slice still records what the milestone was trying to do.", + sequence: 1, + }); + + const result = await resolveDispatch({ + state: { + phase: "pre-planning", + activeMilestone: { id: "M325", title: "DB context replan" }, + activeSlice: null, + activeTask: null, + }, + mid: "M325", + midTitle: "DB context replan", + basePath: base, + prefs: { phases: {} }, + session: {}, + pipelineVariant: "standard", + }); + + expect(result).toMatchObject({ + action: "dispatch", + unitType: "plan-milestone", + unitId: "M325", + prompt: "plan milestone", + }); + } finally { + closeDatabase(); + cleanup(base); + } + }); + test("completing_milestone_when_validation_needs_attention_without_plan_dispatches_remediation", async () => { const base = makeTempDir("sf-dispatch-validation-attention-"); try { diff --git a/src/resources/extensions/sf/tests/db-driven-runtime-state.test.mjs b/src/resources/extensions/sf/tests/db-driven-runtime-state.test.mjs index e89552c8d..48a8ce1d1 100644 --- a/src/resources/extensions/sf/tests/db-driven-runtime-state.test.mjs +++ b/src/resources/extensions/sf/tests/db-driven-runtime-state.test.mjs @@ -134,7 +134,7 @@ test("deriveState_when_generated_projections_are_stale_uses_db_slice_and_task_se assert.equal(state.activeTask?.id, "T02"); }); -test("deriveState_when_all_slices_cancelled_does_not_select_cancelled_slice", async () => { +test("deriveState_when_all_slices_cancelled_replans_without_selecting_cancelled_slice", async () => { const project = mkdtempSync(join(tmpdir(), "sf-db-runtime-state-")); tmpDirs.push(project); mkdirSync(join(project, ".sf", "milestones", "M779", "slices", "S01"), { @@ -156,10 +156,11 @@ test("deriveState_when_all_slices_cancelled_does_not_select_cancelled_slice", as const state = await deriveState(project); - assert.equal(state.phase, "blocked"); + assert.equal(state.phase, "pre-planning"); assert.equal(state.activeMilestone?.id, "M779"); assert.equal(state.activeSlice, null); - assert.deepEqual(state.blockers, ["No slice eligible — check dependency ordering"]); + assert.deepEqual(state.blockers, []); + assert.match(state.nextAction, /pre-planning ladder|cancelled|inactive/i); }); test("deriveState_when_db_has_no_tasks_refuses_runtime_plan_file_import", async () => { diff --git a/src/resources/extensions/sf/uok/auto-dispatch.js b/src/resources/extensions/sf/uok/auto-dispatch.js index 711350daf..64534a306 100644 --- a/src/resources/extensions/sf/uok/auto-dispatch.js +++ b/src/resources/extensions/sf/uok/auto-dispatch.js @@ -127,6 +127,24 @@ function missingSliceStop(mid, phase) { level: "error", }; } + +function hasDbMilestonePlanningContext(mid) { + if (!isDbAvailable()) return false; + const milestone = getMilestone(mid); + if ( + typeof milestone?.vision === "string" && + milestone.vision.trim().length > 0 + ) { + return true; + } + return getMilestoneSlices(mid).some( + (slice) => + (typeof slice.goal === "string" && slice.goal.trim().length > 0) || + (typeof slice.success_criteria === "string" && + slice.success_criteria.trim().length > 0), + ); +} + async function readMilestoneValidationForDispatch(basePath, mid) { if (isDbAvailable()) { const assessment = getMilestoneValidationAssessment(mid); @@ -716,6 +734,7 @@ export const DISPATCH_RULES = [ // Only on first dispatch: when phase is pre-planning AND no roadmap exists yet // This ensures roadmap meeting happens BEFORE discuss/research/plan if (state.phase !== "pre-planning") return null; + if (hasDbMilestonePlanningContext(mid)) return null; // resolveMilestoneFile returns path string if file exists, null if not const roadmapFile = resolveMilestoneFile(basePath, mid, "ROADMAP"); if (roadmapFile && existsSync(roadmapFile)) return null; // roadmap already exists @@ -1005,6 +1024,7 @@ export const DISPATCH_RULES = [ name: "pre-planning (no context) → discuss-milestone", match: async ({ state, mid, midTitle, basePath }) => { if (state.phase !== "pre-planning") return null; + if (hasDbMilestonePlanningContext(mid)) return null; const contextFile = resolveMilestoneFile(basePath, mid, "CONTEXT"); const hasContext = !!(contextFile && (await loadFile(contextFile))); if (hasContext) return null; // fall through to next rule @@ -1027,6 +1047,7 @@ export const DISPATCH_RULES = [ pipelineVariant, }) => { if (state.phase !== "pre-planning") return null; + if (hasDbMilestonePlanningContext(mid)) return null; // Phase skip: skip research when preference or profile says so if (prefs?.phases?.skip_research) return null; // #4781 phase 2: trivial-scope milestones skip dedicated milestone research