2026-04-15 14:54:20 +02:00
/ * *
* Auto - mode Dispatch Table — declarative phase → unit mapping .
*
* Each rule maps a SF state to the unit type , unit ID , and prompt builder
* that should be dispatched . Rules are evaluated in order ; the first match wins .
*
* This replaces the 130 - line if - else chain in dispatchNextUnit with a
* data structure that is inspectable , testable per - rule , and extensible
* without modifying orchestration code .
* /
import { existsSync , mkdirSync , readFileSync , writeFileSync } from "node:fs" ;
import { join } from "node:path" ;
import {
2026-04-29 12:42:31 +02:00
buildCompleteMilestonePrompt ,
buildCompleteSlicePrompt ,
buildDiscussMilestonePrompt ,
buildExecuteTaskPrompt ,
buildGateEvaluatePrompt ,
buildParallelResearchSlicesPrompt ,
buildPlanMilestonePrompt ,
buildPlanSlicePrompt ,
buildReactiveExecutePrompt ,
buildReassessRoadmapPrompt ,
buildReplanSlicePrompt ,
buildResearchMilestonePrompt ,
buildResearchSlicePrompt ,
buildRewriteDocsPrompt ,
buildRunUatPrompt ,
buildValidateMilestonePrompt ,
checkNeedsReassessment ,
checkNeedsRunUat ,
2026-04-15 14:54:20 +02:00
} from "./auto-prompts.js" ;
2026-04-29 12:42:31 +02:00
import { hasImplementationArtifacts } from "./auto-recovery.js" ;
2026-04-29 20:33:06 +02:00
import {
getExecuteTaskInstructionConflict ,
skipExecuteTaskForInstructionConflict ,
} from "./execution-instruction-guard.js" ;
2026-04-29 12:42:31 +02:00
import {
extractUatType ,
loadActiveOverrides ,
loadFile ,
parseDeferredRequirements ,
resolveAllOverrides ,
} from "./files.js" ;
import { getMilestonePipelineVariant } from "./milestone-scope-classifier.js" ;
import { parseRoadmap } from "./parsers-legacy.js" ;
import {
buildMilestoneFileName ,
relSliceFile ,
resolveMilestoneFile ,
resolveMilestonePath ,
resolveSliceFile ,
resolveTaskFile ,
sfRoot ,
} from "./paths.js" ;
import type { SFPreferences } from "./preferences.js" ;
2026-04-15 14:54:20 +02:00
import { resolveModelWithFallbacksForUnit } from "./preferences-models.js" ;
2026-04-29 12:42:31 +02:00
import {
getMilestone ,
getMilestoneSlices ,
getPendingGates ,
getSliceTasks ,
isDbAvailable ,
markAllGatesOmitted ,
} from "./sf-db.js" ;
import type { SFState } from "./types.js" ;
2026-04-15 14:54:20 +02:00
import { selectReactiveDispatchBatch } from "./uok/execution-graph.js" ;
2026-04-29 12:42:31 +02:00
import { resolveUokFlags } from "./uok/flags.js" ;
2026-04-25 06:34:49 +02:00
import { EXECUTION_ENTRY_PHASES } from "./uok/plan-v2.js" ;
2026-04-29 12:42:31 +02:00
import { extractVerdict , isAcceptableUatVerdict } from "./verdict-parser.js" ;
import { logError , logWarning } from "./workflow-logger.js" ;
2026-04-15 14:54:20 +02:00
2026-04-30 19:10:38 +02:00
const MAX_PARALLEL_RESEARCH_SLICES = 8 ;
2026-04-15 14:54:20 +02:00
// ─── Types ────────────────────────────────────────────────────────────────
export type DispatchAction =
2026-04-29 12:42:31 +02:00
| {
action : "dispatch" ;
unitType : string ;
unitId : string ;
prompt : string ;
pauseAfterDispatch? : boolean ;
/** Name of the matched dispatch rule from the unified registry (journal provenance). */
matchedRule? : string ;
}
| {
action : "stop" ;
reason : string ;
level : "info" | "warning" | "error" ;
matchedRule? : string ;
}
| { action : "skip" ; matchedRule? : string } ;
2026-04-15 14:54:20 +02:00
export interface DispatchContext {
2026-04-29 12:42:31 +02:00
basePath : string ;
mid : string ;
midTitle : string ;
state : SFState ;
prefs : SFPreferences | undefined ;
session? : import ( "./auto/session.js" ) . AutoSession ;
/** Cached pipeline variant for this dispatch cycle — set once by resolveDispatch. */
pipelineVariant? : string | null ;
2026-04-15 14:54:20 +02:00
}
export interface DispatchRule {
2026-04-29 12:42:31 +02:00
/** Human-readable name for debugging and test identification */
name : string ;
/** Return a DispatchAction if this rule matches, null to fall through */
match : ( ctx : DispatchContext ) = > Promise < DispatchAction | null > ;
2026-04-15 14:54:20 +02:00
}
function missingSliceStop ( mid : string , phase : string ) : DispatchAction {
2026-04-29 12:42:31 +02:00
return {
action : "stop" ,
reason : ` ${ mid } : phase " ${ phase } " has no active slice — run /sf doctor. ` ,
level : "error" ,
} ;
2026-04-15 14:54:20 +02:00
}
2026-04-28 11:52:42 +02:00
export function formatTaskCompleteFailurePrompt ( reason : string ) : string {
2026-04-29 12:42:31 +02:00
return ` sf_task_complete failed: ${ reason } . Try the call again, or investigate the write path. ` ;
2026-04-28 11:52:42 +02:00
}
function prependTaskCompleteFailurePrompt (
2026-04-29 12:42:31 +02:00
session : DispatchContext [ "session" ] | undefined ,
unitId : string ,
prompt : string ,
2026-04-28 11:52:42 +02:00
) : string {
2026-04-29 12:42:31 +02:00
const reason = session ? . pendingTaskCompleteFailures ? . get ( unitId ) ;
if ( ! reason ) return prompt ;
return ` ${ formatTaskCompleteFailurePrompt ( reason ) } \ n \ n ${ prompt } ` ;
2026-04-28 11:52:42 +02:00
}
2026-04-25 06:34:49 +02:00
function isMilestonePlanRepairState ( state : SFState ) : boolean {
2026-04-29 12:42:31 +02:00
if ( state . phase !== "planning" || state . activeSlice ) return false ;
return /roadmap is incomplete|weighted vision alignment meeting/i . test (
state . nextAction ? ? "" ,
) ;
2026-04-25 06:34:49 +02:00
}
2026-04-15 14:54:20 +02:00
/ * *
* Check for milestone slices missing SUMMARY files .
* Returns array of missing slice IDs , or empty array if all present or DB unavailable .
*
* Excludes skipped slices ( intentionally summary - less ) and legacy - complete
* slices whose DB status is authoritative even without on - disk SUMMARY ( # 3620 ) .
* /
function findMissingSummaries ( basePath : string , mid : string ) : string [ ] {
2026-04-29 12:42:31 +02:00
if ( ! isDbAvailable ( ) ) return [ ] ;
const slices = getMilestoneSlices ( mid ) ;
// Skipped slices never produce SUMMARYs; legacy-complete slices may lack them
const CLOSED_STATUSES = new Set ( [ "skipped" , "complete" , "done" ] ) ;
return slices
. filter ( ( s ) = > ! CLOSED_STATUSES . has ( s . status ) )
. filter ( ( s ) = > {
const summaryPath = resolveSliceFile ( basePath , mid , s . id , "SUMMARY" ) ;
return ! summaryPath || ! existsSync ( summaryPath ) ;
} )
. map ( ( s ) = > s . id ) ;
2026-04-15 14:54:20 +02:00
}
// ─── Rewrite Circuit Breaker ──────────────────────────────────────────────
const MAX_REWRITE_ATTEMPTS = 3 ;
// ─── Disk-persisted rewrite attempt counter ──────────────────────────────────
// The counter must survive session restarts (crash recovery, pause/resume,
// step-mode). Storing it on the in-memory session object caused the circuit
// breaker to never trip — see https://github.com/singularity-forge/sf-run/issues/2203
function rewriteCountPath ( basePath : string ) : string {
2026-04-29 12:42:31 +02:00
return join ( sfRoot ( basePath ) , "runtime" , "rewrite-count.json" ) ;
2026-04-15 14:54:20 +02:00
}
export function getRewriteCount ( basePath : string ) : number {
2026-04-29 12:42:31 +02:00
try {
const data = JSON . parse ( readFileSync ( rewriteCountPath ( basePath ) , "utf-8" ) ) ;
return typeof data . count === "number" ? data.count : 0 ;
} catch {
return 0 ;
}
2026-04-15 14:54:20 +02:00
}
export function setRewriteCount ( basePath : string , count : number ) : void {
2026-04-29 12:42:31 +02:00
const filePath = rewriteCountPath ( basePath ) ;
mkdirSync ( join ( sfRoot ( basePath ) , "runtime" ) , { recursive : true } ) ;
writeFileSync (
filePath ,
JSON . stringify ( { count , updatedAt : new Date ( ) . toISOString ( ) } ) + "\n" ,
) ;
2026-04-15 14:54:20 +02:00
}
// ─── Run-UAT dispatch counter (per-slice) ────────────────────────────────
// Caps run-uat dispatches to prevent infinite replay when verification
// commands fail before writing a verdict (#3624).
const MAX_UAT_ATTEMPTS = 3 ;
function uatCountPath ( basePath : string , mid : string , sid : string ) : string {
2026-04-29 12:42:31 +02:00
return join ( sfRoot ( basePath ) , "runtime" , ` uat-count- ${ mid } - ${ sid } .json ` ) ;
2026-04-15 14:54:20 +02:00
}
2026-04-29 12:42:31 +02:00
export function getUatCount (
basePath : string ,
mid : string ,
sid : string ,
) : number {
try {
const data = JSON . parse (
readFileSync ( uatCountPath ( basePath , mid , sid ) , "utf-8" ) ,
) ;
return typeof data . count === "number" ? data.count : 0 ;
} catch {
return 0 ;
}
2026-04-15 14:54:20 +02:00
}
2026-04-29 12:42:31 +02:00
export function incrementUatCount (
basePath : string ,
mid : string ,
sid : string ,
) : number {
const count = getUatCount ( basePath , mid , sid ) + 1 ;
const filePath = uatCountPath ( basePath , mid , sid ) ;
mkdirSync ( join ( sfRoot ( basePath ) , "runtime" ) , { recursive : true } ) ;
writeFileSync (
filePath ,
JSON . stringify ( { count , updatedAt : new Date ( ) . toISOString ( ) } ) + "\n" ,
) ;
return count ;
2026-04-15 14:54:20 +02:00
}
// ─── Helpers ─────────────────────────────────────────────────────────────
/ * *
* Returns true when the verification_operational value indicates that no
* operational verification is needed . Covers common phrasings the planning
* agent may use : "None" , "None required" , "N/A" , "Not applicable" , etc .
*
* @see https : //github.com/singularity-forge/sf-run/issues/2931
* /
export function isVerificationNotApplicable ( value : string ) : boolean {
2026-04-29 12:42:31 +02:00
const v = ( value ? ? "" )
. toLowerCase ( )
. trim ( )
. replace ( /[.\s]+$/ , "" ) ;
if ( ! v || v === "none" ) return true ;
return /^(?:none(?:[\s._\u2014-]+[\s\S]*)?|n\/?a|not[\s._-]+(?:applicable|required|needed|provided)|no[\s._-]+operational[\s\S]*)$/i . test (
v ,
) ;
2026-04-15 14:54:20 +02:00
}
2026-04-30 06:31:19 +02:00
export function extractValidationAttentionPlan (
validationContent : string ,
) : string | null {
const explicit = validationContent . match (
/^## Remediation Plan\s*\n([\s\S]*?)(?=\n## |\s*$)/m ,
) ;
if ( explicit ? . [ 1 ] ? . trim ( ) ) return explicit [ 1 ] . trim ( ) ;
const followUp = validationContent . match (
/^## Follow[- ]Up Items[^\n]*\n([\s\S]*?)(?=\n## |\s*$)/im ,
) ;
if ( followUp ? . [ 1 ] ? . trim ( ) ) return followUp [ 1 ] . trim ( ) ;
const tracking = validationContent . match (
/^\*\*Tracking issues:\*\*\s*\n([\s\S]*?)(?=\n## |\n\*\*|\s*$)/m ,
) ;
if ( tracking ? . [ 1 ] ? . trim ( ) ) return tracking [ 1 ] . trim ( ) ;
return null ;
}
function validationAttentionMarkerPath ( basePath : string , mid : string ) : string {
return join (
sfRoot ( basePath ) ,
"runtime" ,
"validation-attention" ,
` ${ mid } .json ` ,
) ;
}
2026-04-30 08:41:49 +02:00
function parseValidationRemediationRound ( content : string ) : number | null {
const match = content . match ( /^remediation_round:\s*(\d+)\s*$/m ) ;
if ( ! match ) return null ;
const round = Number . parseInt ( match [ 1 ] ! , 10 ) ;
return Number . isFinite ( round ) ? round : null ;
}
interface ValidationAttentionMarker {
milestoneId? : string ;
createdAt? : string ;
source? : string ;
remediationRound? : number | null ;
revalidationRound? : number ;
revalidationRequestedAt? : string ;
}
function readValidationAttentionMarker (
basePath : string ,
mid : string ,
) : ValidationAttentionMarker | null {
const markerPath = validationAttentionMarkerPath ( basePath , mid ) ;
if ( ! existsSync ( markerPath ) ) return null ;
try {
const parsed = JSON . parse ( readFileSync ( markerPath , "utf-8" ) ) as unknown ;
if ( ! parsed || typeof parsed !== "object" ) return null ;
return parsed as ValidationAttentionMarker ;
} catch {
return null ;
}
}
function writeValidationAttentionMarker (
basePath : string ,
mid : string ,
marker : ValidationAttentionMarker ,
) : void {
mkdirSync ( join ( sfRoot ( basePath ) , "runtime" , "validation-attention" ) , {
recursive : true ,
} ) ;
writeFileSync (
validationAttentionMarkerPath ( basePath , mid ) ,
JSON . stringify ( marker , null , 2 ) + "\n" ,
"utf-8" ,
) ;
}
2026-04-30 08:08:10 +02:00
function validationAttentionRuntimePath ( basePath : string , mid : string ) : string {
return join (
sfRoot ( basePath ) ,
"runtime" ,
"units" ,
` rewrite-docs- ${ mid } -validation-attention.json ` ,
) ;
}
function hasActiveValidationAttentionMarker (
basePath : string ,
mid : string ,
) : boolean {
const markerPath = validationAttentionMarkerPath ( basePath , mid ) ;
if ( ! existsSync ( markerPath ) ) return false ;
if ( existsSync ( validationAttentionRuntimePath ( basePath , mid ) ) ) return true ;
logWarning (
"dispatch" ,
` ignoring stale validation attention marker for ${ mid } : remediation unit was never recorded ` ,
) ;
return false ;
}
2026-04-30 08:41:49 +02:00
function shouldDispatchValidationAttentionRevalidation (
basePath : string ,
mid : string ,
validationContent : string ,
) : boolean {
if ( ! hasActiveValidationAttentionMarker ( basePath , mid ) ) return false ;
const marker = readValidationAttentionMarker ( basePath , mid ) ;
if ( marker ? . milestoneId && marker . milestoneId !== mid ) return false ;
const currentRound = parseValidationRemediationRound ( validationContent ) ;
if ( currentRound === null ) return false ;
const originalRound =
typeof marker ? . remediationRound === "number" ? marker . remediationRound : - 1 ;
if ( currentRound <= originalRound ) return false ;
if ( marker ? . revalidationRound === currentRound ) return false ;
writeValidationAttentionMarker ( basePath , mid , {
. . . marker ,
milestoneId : mid ,
revalidationRound : currentRound ,
revalidationRequestedAt : new Date ( ) . toISOString ( ) ,
} ) ;
return true ;
}
2026-04-30 06:31:19 +02:00
function buildValidationAttentionRemediationPrompt (
mid : string ,
midTitle : string ,
basePath : string ,
validationContent : string ,
attentionPlan : string ,
) : string {
const validationRel = ` .sf/milestones/ ${ mid } / ${ mid } -VALIDATION.md ` ;
const escapedValidation = validationContent . replace ( /```/g , "``\\`" ) ;
const escapedPlan = attentionPlan . replace ( /```/g , "``\\`" ) ;
return ` You are executing SF auto-mode.
# # UNIT : Resolve Validation Attention for $ { mid } ( "${midTitle}" )
SF validation returned \ ` needs-attention \` . Automatic milestone completion is blocked until the findings are addressed or explicitly deferred and validation is run again.
# # Working Directory
Your working directory is \ ` ${ basePath } \` . All file reads and writes MUST operate relative to this directory.
# # Actionable Attention Plan
\ ` \` \` md
$ { escapedPlan }
\ ` \` \`
# # Current Validation Artifact
\ ` \` \` md
$ { escapedValidation }
\ ` \` \`
# # Required Work
1 . Apply the attention plan to the relevant SF tracking artifacts and project docs . Prefer narrow edits to roadmap , context , requirements , slice summaries , UAT notes , and validation evidence . Only edit product code when the finding is a real implementation defect .
2 . Preserve historical records , but make the current milestone state internally consistent .
3 . If a finding cannot be completed in this environment , explicitly defer it with the concrete reason , required environment , and follow - up owner / artifact .
4 . Do not mark validation as pass yourself .
5 . After applying the remediation , edit \ ` ${ validationRel } \` frontmatter to set \` verdict: needs-remediation \` and increment \` remediation_round \` by 1. Leave the body intact or add a short note that the attention plan was applied. This forces SF to run a fresh validate-milestone unit next.
When done , say : "Validation attention remediated; ready for revalidation." ` ;
}
2026-04-15 14:54:20 +02:00
// ─── Rules ────────────────────────────────────────────────────────────────
export const DISPATCH_RULES : DispatchRule [ ] = [
2026-04-29 12:42:31 +02:00
{
name : "rewrite-docs (override gate)" ,
match : async ( { mid , midTitle , state , basePath , session : _session } ) = > {
const pendingOverrides = await loadActiveOverrides ( basePath ) ;
if ( pendingOverrides . length === 0 ) return null ;
const count = getRewriteCount ( basePath ) ;
if ( count >= MAX_REWRITE_ATTEMPTS ) {
await resolveAllOverrides ( basePath ) ;
setRewriteCount ( basePath , 0 ) ;
return null ;
}
setRewriteCount ( basePath , count + 1 ) ;
const unitId = state . activeSlice ? ` ${ mid } / ${ state . activeSlice . id } ` : mid ;
return {
action : "dispatch" ,
unitType : "rewrite-docs" ,
unitId ,
prompt : await buildRewriteDocsPrompt (
mid ,
midTitle ,
state . activeSlice ,
basePath ,
pendingOverrides ,
) ,
} ;
} ,
} ,
{
name : "initial-roadmap-meeting (first dispatch)" ,
match : async ( { state , mid , midTitle : _midTitle , basePath } ) = > {
// 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 ;
// 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
return {
action : "dispatch" ,
unitType : "roadmap-meeting" ,
unitId : mid ,
prompt :
"You are facilitating the **initial roadmap meeting** for milestone " +
mid +
".\n\n" +
2026-04-30 06:31:19 +02:00
"You are running in SF auto-mode. Do not call `ask_user_questions`, " +
"do not wait for a human reply, and do not end with open questions. " +
"Use existing project artifacts as the user's durable input. If `" +
mid +
"-CONTEXT.md` contains roadmap/alignment decisions, treat them as approved.\n\n" +
"Before any detailed planning, establish:\n" +
2026-04-29 12:42:31 +02:00
"1. **What done looks like** — the milestone definition of success\n" +
"2. **Rough scope** — what slices (vertical increments) make up this milestone\n" +
"3. **Key risks** — what could go wrong or cause re-planning\n" +
"4. **First slice** — which slice should go first (lowest risk)\n\n" +
2026-04-30 06:31:19 +02:00
"The roadmap must include a `## Vision Alignment Meeting` section with " +
"these `###` subsections: Trigger, Product Manager, User Advocate, " +
"Customer Panel, Business, Researcher, Delivery Lead, Partner, Combatant, " +
"Architect, Moderator, Weighted Synthesis, Confidence By Area, and " +
"Recommended Route. Set Recommended Route to `planning` unless you found " +
"a concrete reason to route back to `researching` or `discussing`.\n\n" +
"If the artifacts leave harmless ambiguity, choose the conservative option, " +
"record it in the roadmap assumptions, and continue. Block only for a concrete " +
"safety issue such as missing credentials, destructive action, or an impossible " +
"contract.\n\n" +
"Then write the roadmap artifact at `.sf/milestones/" +
2026-04-29 12:42:31 +02:00
mid +
2026-04-30 06:31:19 +02:00
"/" +
mid +
"-ROADMAP.md` with the agreed slices.\n" +
2026-04-29 12:42:31 +02:00
"Do NOT write detailed plans — that's for later after the roadmap is aligned.\n\n" +
"## Session Context\n" +
"- Working directory: `" +
basePath +
"`\n" +
2026-04-30 06:31:19 +02:00
"- Project goals/description: See `.sf/PROJECT.md` if it exists\n" +
"- Milestone context: See `.sf/milestones/" +
mid +
"/" +
mid +
"-CONTEXT.md` if it exists\n" +
"- Requirements and decisions: See `.sf/REQUIREMENTS.md` and `.sf/DECISIONS.md` if they exist" ,
2026-04-29 12:42:31 +02:00
} ;
} ,
} ,
{
name : "summarizing → complete-slice" ,
match : async ( { state , mid , midTitle , basePath } ) = > {
if ( state . phase !== "summarizing" ) return null ;
if ( ! state . activeSlice ) return missingSliceStop ( mid , state . phase ) ;
const sid = state . activeSlice ! . id ;
const sTitle = state . activeSlice ! . title ;
return {
action : "dispatch" ,
unitType : "complete-slice" ,
unitId : ` ${ mid } / ${ sid } ` ,
prompt : await buildCompleteSlicePrompt (
mid ,
midTitle ,
sid ,
sTitle ,
basePath ,
) ,
} ;
} ,
} ,
{
name : "run-uat (post-completion)" ,
match : async ( { state , mid , basePath , prefs } ) = > {
const needsRunUat = await checkNeedsRunUat ( basePath , mid , state , prefs ) ;
if ( ! needsRunUat ) return null ;
const { sliceId , uatType } = needsRunUat ;
// Cap run-uat dispatch attempts to prevent infinite replay (#3624)
const attempts = incrementUatCount ( basePath , mid , sliceId ) ;
if ( attempts > MAX_UAT_ATTEMPTS ) {
return {
action : "stop" as const ,
reason : ` run-uat for ${ mid } / ${ sliceId } has been dispatched ${ attempts - 1 } times without producing a verdict. Verification commands may be broken — fix the UAT spec or manually write an ASSESSMENT verdict. ` ,
level : "warning" as const ,
} ;
}
const uatFile = resolveSliceFile ( basePath , mid , sliceId , "UAT" ) ! ;
const uatContent = await loadFile ( uatFile ) ;
return {
action : "dispatch" ,
unitType : "run-uat" ,
unitId : ` ${ mid } / ${ sliceId } ` ,
prompt : await buildRunUatPrompt (
mid ,
sliceId ,
relSliceFile ( basePath , mid , sliceId , "UAT" ) ,
uatContent ? ? "" ,
basePath ,
) ,
pauseAfterDispatch :
! process . env . SF_HEADLESS &&
uatType !== "artifact-driven" &&
uatType !== "browser-executable" &&
uatType !== "runtime-executable" ,
} ;
} ,
} ,
{
name : "uat-verdict-gate (non-PASS blocks progression)" ,
match : async ( { mid , basePath , prefs } ) = > {
// Only applies when UAT dispatch is enabled
if ( ! prefs ? . uat_dispatch ) return null ;
const _roadmapFile = resolveMilestoneFile ( basePath , mid , "ROADMAP" ) ;
// DB-first: get completed slices from DB
let completedSliceIds : string [ ] ;
if ( isDbAvailable ( ) ) {
completedSliceIds = getMilestoneSlices ( mid )
. filter ( ( s ) = > s . status === "complete" )
. map ( ( s ) = > s . id ) ;
} else {
return null ;
}
const uatChecks = await Promise . all (
completedSliceIds . map ( async ( sliceId ) = > {
const resultFile = resolveSliceFile ( basePath , mid , sliceId , "UAT" ) ;
if ( ! resultFile ) return null ;
const content = await loadFile ( resultFile ) ;
if ( ! content ) return null ;
return {
sliceId ,
verdict : extractVerdict ( content ) ,
uatType : extractUatType ( content ) ,
} ;
} ) ,
) ;
for ( const check of uatChecks ) {
if ( ! check ) continue ;
if (
check . verdict &&
! isAcceptableUatVerdict ( check . verdict , check . uatType )
) {
return {
action : "stop" as const ,
reason : ` UAT verdict for ${ check . sliceId } is " ${ check . verdict } " — blocking progression until resolved. \ nReview the UAT result and update the verdict to PASS, or re-run /sf auto after fixing. ` ,
level : "warning" as const ,
} ;
}
}
return null ;
} ,
} ,
{
name : "reassess-roadmap (post-completion)" ,
match : async ( { state , mid , midTitle , basePath , prefs } ) = > {
if ( prefs ? . phases ? . skip_reassess ) return null ;
// Default reassess_after_slice to false per ADR-003 §4 — most reassess
// units conclude "roadmap is fine" and burn a session for no change.
// The plan-slice prompt now carries a reassessment preamble so the
// next slice's planner does JIT roadmap verification at zero extra
// cost. Opt-in via explicit `reassess_after_slice: true` (e.g.
// burn-max profile) when you want the dedicated reassess session.
const reassessEnabled = prefs ? . phases ? . reassess_after_slice ? ? false ;
if ( ! reassessEnabled ) return null ;
const needsReassess = await checkNeedsReassessment (
basePath ,
mid ,
state ,
prefs ,
) ;
if ( ! needsReassess ) return null ;
return {
action : "dispatch" ,
unitType : "reassess-roadmap" ,
unitId : ` ${ mid } / ${ needsReassess . sliceId } ` ,
prompt : await buildReassessRoadmapPrompt (
mid ,
midTitle ,
needsReassess . sliceId ,
basePath ,
) ,
} ;
} ,
} ,
{
name : "needs-discussion → discuss-milestone" ,
match : async ( { state , mid , midTitle , basePath } ) = > {
if ( state . phase !== "needs-discussion" ) return null ;
return {
action : "dispatch" ,
unitType : "discuss-milestone" ,
unitId : mid ,
prompt : await buildDiscussMilestonePrompt ( mid , midTitle , basePath ) ,
} ;
} ,
} ,
{
// #4671 — Recovery for execution-entry phases with missing CONTEXT.md.
// Once deriveStateFromDb returns an execution-entry phase the pre-planning
// guard no longer fires. The plan-v2 gate detects missing context but can
// only block — it cannot redispatch. Without this rule the milestone is
// stuck until `sf doctor heal`. Fire BEFORE execution-entry phase rules.
name : "execution-entry phase (no context) → discuss-milestone" ,
match : async ( { state , mid , midTitle , basePath } ) = > {
if ( ! EXECUTION_ENTRY_PHASES . has ( state . phase ) ) return null ;
const contextFile = resolveMilestoneFile ( basePath , mid , "CONTEXT" ) ;
const contextContent = contextFile ? await loadFile ( contextFile ) : null ;
const hasContext = ! ! ( contextContent && contextContent . trim ( ) . length > 0 ) ;
if ( hasContext ) return null ;
return {
action : "dispatch" ,
unitType : "discuss-milestone" ,
unitId : mid ,
prompt : await buildDiscussMilestonePrompt ( mid , midTitle , basePath ) ,
} ;
} ,
} ,
{
name : "pre-planning (no context) → discuss-milestone" ,
match : async ( { state , mid , midTitle , basePath } ) = > {
if ( state . phase !== "pre-planning" ) return null ;
const contextFile = resolveMilestoneFile ( basePath , mid , "CONTEXT" ) ;
const hasContext = ! ! ( contextFile && ( await loadFile ( contextFile ) ) ) ;
if ( hasContext ) return null ; // fall through to next rule
return {
action : "dispatch" ,
unitType : "discuss-milestone" ,
unitId : mid ,
prompt : await buildDiscussMilestonePrompt ( mid , midTitle , basePath ) ,
} ;
} ,
} ,
{
name : "pre-planning (no research) → research-milestone" ,
match : async ( {
state ,
mid ,
midTitle ,
basePath ,
prefs ,
pipelineVariant ,
} ) = > {
if ( state . phase !== "pre-planning" ) 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
if ( pipelineVariant === "trivial" ) return null ;
const researchFile = resolveMilestoneFile ( basePath , mid , "RESEARCH" ) ;
if ( researchFile ) return null ; // has research, fall through
return {
action : "dispatch" ,
unitType : "research-milestone" ,
unitId : mid ,
prompt : await buildResearchMilestonePrompt ( mid , midTitle , basePath ) ,
} ;
} ,
} ,
{
name : "pre-planning (has research) → plan-milestone" ,
match : async ( { state , mid , midTitle , basePath } ) = > {
if ( state . phase !== "pre-planning" ) return null ;
return {
action : "dispatch" ,
unitType : "plan-milestone" ,
unitId : mid ,
prompt : await buildPlanMilestonePrompt ( mid , midTitle , basePath ) ,
} ;
} ,
} ,
{
name : "planning (roadmap incomplete) → plan-milestone" ,
match : async ( { state , mid , midTitle , basePath } ) = > {
if ( ! isMilestonePlanRepairState ( state ) ) return null ;
return {
action : "dispatch" ,
unitType : "plan-milestone" ,
unitId : mid ,
prompt : await buildPlanMilestonePrompt ( mid , midTitle , basePath ) ,
} ;
} ,
} ,
{
// Keep this rule before the single-slice research rule so the multi-slice
// path wins whenever 2+ slices are ready.
name : "planning (multiple slices need research) → parallel-research-slices" ,
match : async ( {
state ,
mid ,
midTitle ,
basePath ,
prefs ,
pipelineVariant ,
} ) = > {
if ( state . phase !== "planning" ) return null ;
if ( prefs ? . phases ? . skip_research || prefs ? . phases ? . skip_slice_research )
return null ;
// #4781 phase 2: trivial-scope milestones skip dedicated slice research
if ( pipelineVariant === "trivial" ) return null ;
// Load roadmap to find all slices
const roadmapFile = resolveMilestoneFile ( basePath , mid , "ROADMAP" ) ;
const roadmapContent = roadmapFile ? await loadFile ( roadmapFile ) : null ;
if ( ! roadmapContent ) return null ;
const roadmap = parseRoadmap ( roadmapContent ) ;
// Find slices that need research (no RESEARCH file, dependencies done)
const milestoneResearchFile = resolveMilestoneFile (
basePath ,
mid ,
"RESEARCH" ,
) ;
const researchReadySlices : Array < { id : string ; title : string } > = [ ] ;
// Pre-compute which slices have SUMMARY files to avoid O(N× M) existsSync calls
const slicesWithSummary = new Set (
roadmap . slices
. filter ( ( s ) = > ! ! resolveSliceFile ( basePath , mid , s . id , "SUMMARY" ) )
. map ( ( s ) = > s . id ) ,
) ;
for ( const slice of roadmap . slices ) {
if ( slice . done ) continue ;
// Skip S01 when milestone research exists
if ( milestoneResearchFile && slice . id === "S01" ) continue ;
// Skip if already has research
if ( resolveSliceFile ( basePath , mid , slice . id , "RESEARCH" ) ) continue ;
// Skip if dependencies aren't done (check for SUMMARY files)
const depsComplete = ( slice . depends ? ? [ ] ) . every ( ( depId ) = >
slicesWithSummary . has ( depId ) ,
) ;
if ( ! depsComplete ) continue ;
researchReadySlices . push ( { id : slice.id , title : slice.title } ) ;
}
// Only dispatch parallel if 2+ slices are ready
if ( researchReadySlices . length < 2 ) return null ;
2026-04-30 19:10:38 +02:00
if ( researchReadySlices . length > MAX_PARALLEL_RESEARCH_SLICES )
return null ;
2026-04-29 12:42:31 +02:00
// #4414: If a previous parallel-research attempt escalated to a blocker
// placeholder, skip this rule and fall through to per-slice research
// (or other rules) rather than re-dispatching the same failing unit.
const parallelBlocker = resolveMilestoneFile (
basePath ,
mid ,
"PARALLEL-BLOCKER" ,
) ;
if ( parallelBlocker ) return null ;
return {
action : "dispatch" ,
unitType : "research-slice" ,
unitId : ` ${ mid } /parallel-research ` ,
prompt : await buildParallelResearchSlicesPrompt (
mid ,
midTitle ,
researchReadySlices ,
basePath ,
resolveModelWithFallbacksForUnit ( "subagent" ) ? . primary ,
) ,
} ;
} ,
} ,
{
name : "planning (no research, not S01) → research-slice" ,
match : async ( {
state ,
mid ,
midTitle ,
basePath ,
prefs ,
pipelineVariant ,
} ) = > {
if ( state . phase !== "planning" ) return null ;
// Phase skip: skip research when preference or profile says so
if ( prefs ? . phases ? . skip_research || prefs ? . phases ? . skip_slice_research )
return null ;
// #4781 phase 2: trivial-scope milestones skip dedicated slice research
if ( pipelineVariant === "trivial" ) return null ;
if ( ! state . activeSlice ) return missingSliceStop ( mid , state . phase ) ;
const sid = state . activeSlice ! . id ;
const sTitle = state . activeSlice ! . title ;
const researchFile = resolveSliceFile ( basePath , mid , sid , "RESEARCH" ) ;
if ( researchFile ) return null ; // has research, fall through
// Skip slice research for S01 when milestone research already exists —
// the milestone research already covers the same ground for the first slice.
const milestoneResearchFile = resolveMilestoneFile (
basePath ,
mid ,
"RESEARCH" ,
) ;
if ( milestoneResearchFile && sid === "S01" ) return null ; // fall through to plan-slice
return {
action : "dispatch" ,
unitType : "research-slice" ,
unitId : ` ${ mid } / ${ sid } ` ,
prompt : await buildResearchSlicePrompt (
mid ,
midTitle ,
sid ,
sTitle ,
basePath ,
) ,
} ;
} ,
} ,
{
name : "planning → plan-slice" ,
match : async ( { state , mid , midTitle , basePath } ) = > {
if ( state . phase !== "planning" ) return null ;
if ( ! state . activeSlice ) return missingSliceStop ( mid , state . phase ) ;
const sid = state . activeSlice ! . id ;
const sTitle = state . activeSlice ! . title ;
return {
action : "dispatch" ,
unitType : "plan-slice" ,
unitId : ` ${ mid } / ${ sid } ` ,
prompt : await buildPlanSlicePrompt (
mid ,
midTitle ,
sid ,
sTitle ,
basePath ,
) ,
} ;
} ,
} ,
{
name : "evaluating-gates → gate-evaluate" ,
match : async ( { state , mid , midTitle , basePath , prefs } ) = > {
if ( state . phase !== "evaluating-gates" ) return null ;
if ( ! state . activeSlice ) return missingSliceStop ( mid , state . phase ) ;
const sid = state . activeSlice . id ;
const sTitle = state . activeSlice . title ;
// Gate evaluation is opt-in via preferences
const gateConfig = prefs ? . gate_evaluation ;
if ( ! gateConfig ? . enabled ) {
markAllGatesOmitted ( mid , sid ) ;
return { action : "skip" } ;
}
const pending = getPendingGates ( mid , sid , "slice" ) ;
if ( pending . length === 0 ) return { action : "skip" } ;
return {
action : "dispatch" ,
unitType : "gate-evaluate" ,
unitId : ` ${ mid } / ${ sid } /gates+ ${ pending . map ( ( g ) = > g . gate_id ) . join ( "," ) } ` ,
prompt : await buildGateEvaluatePrompt (
mid ,
midTitle ,
sid ,
sTitle ,
basePath ,
resolveModelWithFallbacksForUnit ( "subagent" ) ? . primary ,
) ,
} ;
} ,
} ,
{
name : "replanning-slice → replan-slice" ,
match : async ( { state , mid , midTitle , basePath } ) = > {
if ( state . phase !== "replanning-slice" ) return null ;
if ( ! state . activeSlice ) return missingSliceStop ( mid , state . phase ) ;
const sid = state . activeSlice ! . id ;
const sTitle = state . activeSlice ! . title ;
return {
action : "dispatch" ,
unitType : "replan-slice" ,
unitId : ` ${ mid } / ${ sid } ` ,
prompt : await buildReplanSlicePrompt (
mid ,
midTitle ,
sid ,
sTitle ,
basePath ,
) ,
} ;
} ,
} ,
{
name : "executing → reactive-execute (parallel dispatch)" ,
match : async ( { state , mid , midTitle , basePath , prefs } ) = > {
if ( state . phase !== "executing" || ! state . activeTask ) return null ;
if ( ! state . activeSlice ) return null ; // fall through
// Only activate when reactive_execution is explicitly enabled
const reactiveConfig = prefs ? . reactive_execution ;
if ( ! reactiveConfig ? . enabled ) return null ;
const sid = state . activeSlice . id ;
const sTitle = state . activeSlice . title ;
const maxParallel = reactiveConfig . max_parallel ? ? 2 ;
const subagentModel =
reactiveConfig . subagent_model ? ?
resolveModelWithFallbacksForUnit ( "subagent" ) ? . primary ;
// Dry-run mode: max_parallel=1 means graph is derived and logged but
// execution remains sequential
if ( maxParallel <= 1 ) return null ;
try {
const {
loadSliceTaskIO ,
deriveTaskGraph ,
isGraphAmbiguous ,
getReadyTasks ,
chooseNonConflictingSubset ,
graphMetrics ,
saveReactiveState ,
} = await import ( "./reactive-graph.js" ) ;
const taskIO = await loadSliceTaskIO ( basePath , mid , sid ) ;
if ( taskIO . length < 2 ) return null ; // single task, no point
const graph = deriveTaskGraph ( taskIO ) ;
// Ambiguous graph → fall through to sequential
if ( isGraphAmbiguous ( graph ) ) return null ;
const completed = new Set ( graph . filter ( ( n ) = > n . done ) . map ( ( n ) = > n . id ) ) ;
const readyIds = getReadyTasks ( graph , completed , new Set ( ) ) ;
// Only activate reactive dispatch when >1 task is ready
if ( readyIds . length <= 1 ) return null ;
const uokFlags = resolveUokFlags ( prefs ) ;
const selected = uokFlags . executionGraph
? selectReactiveDispatchBatch ( {
graph ,
readyIds ,
maxParallel ,
inFlightOutputs : new Set ( ) ,
} ) . selected
: chooseNonConflictingSubset ( readyIds , graph , maxParallel , new Set ( ) ) ;
if ( selected . length <= 1 ) return null ;
// Log graph metrics for observability
const metrics = graphMetrics ( graph ) ;
process . stderr . write (
` sf-reactive: ${ mid } / ${ sid } graph — tasks: ${ metrics . taskCount } edges: ${ metrics . edgeCount } ` +
` ready: ${ metrics . readySetSize } dispatching: ${ selected . length } ambiguous: ${ metrics . ambiguous } \ n ` ,
) ;
// Persist dispatched batch so verification and recovery can check
// exactly which tasks were sent.
saveReactiveState ( basePath , mid , sid , {
sliceId : sid ,
completed : [ . . . completed ] ,
dispatched : selected ,
graphSnapshot : metrics ,
updatedAt : new Date ( ) . toISOString ( ) ,
} ) ;
// Encode selected task IDs in unitId for artifact verification.
// Format: M001/S01/reactive+T02,T03
const batchSuffix = selected . join ( "," ) ;
return {
action : "dispatch" ,
unitType : "reactive-execute" ,
unitId : ` ${ mid } / ${ sid } /reactive+ ${ batchSuffix } ` ,
prompt : await buildReactiveExecutePrompt (
mid ,
midTitle ,
sid ,
sTitle ,
selected ,
basePath ,
subagentModel ,
) ,
} ;
} catch ( err ) {
// Non-fatal — fall through to sequential execution
logError ( "dispatch" , "reactive graph derivation failed" , {
error : ( err as Error ) . message ,
} ) ;
return null ;
}
} ,
} ,
{
name : "executing → execute-task (recover missing task plan → plan-slice)" ,
match : async ( { state , mid , midTitle , basePath } ) = > {
if ( state . phase !== "executing" || ! state . activeTask ) return null ;
if ( ! state . activeSlice ) return missingSliceStop ( mid , state . phase ) ;
const sid = state . activeSlice ! . id ;
const sTitle = state . activeSlice ! . title ;
const tid = state . activeTask . id ;
// Guard: if the slice plan exists but the individual task plan files are
// missing, the planner created S##-PLAN.md with task entries but never
// wrote the tasks/ directory files. Dispatch plan-slice to regenerate
// them rather than hard-stopping — fixes the infinite-loop described in
// issue #909.
const taskPlanPath = resolveTaskFile ( basePath , mid , sid , tid , "PLAN" ) ;
if ( ! taskPlanPath || ! existsSync ( taskPlanPath ) ) {
return {
action : "dispatch" ,
unitType : "plan-slice" ,
unitId : ` ${ mid } / ${ sid } ` ,
prompt : await buildPlanSlicePrompt (
mid ,
midTitle ,
sid ,
sTitle ,
basePath ,
) ,
} ;
}
return null ;
} ,
} ,
{
name : "executing → prior-task verification all-fail guard" ,
match : async ( { state , mid } ) = > {
if ( state . phase !== "executing" || ! state . activeTask ) return null ;
if ( ! state . activeSlice ) return null ;
if ( ! isDbAvailable ( ) ) return null ;
const sid = state . activeSlice . id ;
const tid = state . activeTask . id ;
const sliceTasks = getSliceTasks ( mid , sid ) ;
const sortedTasks = sliceTasks . sort (
( a , b ) = >
( a . sequence ? ? 0 ) - ( b . sequence ? ? 0 ) || a . id . localeCompare ( b . id ) ,
) ;
const currentIdx = sortedTasks . findIndex ( ( t ) = > t . id === tid ) ;
if ( currentIdx > 0 ) {
const priorTask = sortedTasks [ currentIdx - 1 ] ;
if ( priorTask ? . verification_status === "all_fail" ) {
return {
action : "stop" ,
reason : ` Task ${ priorTask . id } in slice ${ sid } had all verification checks fail — stopping before dispatching ${ tid } . Fix verification in the prior task or re-run it. ` ,
level : "error" ,
} ;
}
}
return null ;
} ,
} ,
{
name : "executing → execute-task" ,
match : async ( { state , mid , basePath , session } ) = > {
if ( state . phase !== "executing" || ! state . activeTask ) return null ;
if ( ! state . activeSlice ) return missingSliceStop ( mid , state . phase ) ;
const sid = state . activeSlice ! . id ;
const sTitle = state . activeSlice ! . title ;
const tid = state . activeTask . id ;
const tTitle = state . activeTask . title ;
const unitId = ` ${ mid } / ${ sid } / ${ tid } ` ;
2026-04-29 20:25:39 +02:00
const instructionConflict = getExecuteTaskInstructionConflict (
basePath ,
mid ,
sid ,
tid ,
tTitle ,
) ;
if ( instructionConflict ) {
2026-04-29 20:33:06 +02:00
if ( isDbAvailable ( ) ) {
await skipExecuteTaskForInstructionConflict (
basePath ,
mid ,
sid ,
tid ,
instructionConflict . reason ,
) ;
logWarning ( "dispatch" , instructionConflict . reason ) ;
return { action : "skip" } ;
}
2026-04-29 20:25:39 +02:00
return {
action : "stop" ,
reason : instructionConflict.reason ,
level : "error" ,
} ;
}
2026-04-29 12:42:31 +02:00
const prompt = await buildExecuteTaskPrompt (
mid ,
sid ,
sTitle ,
tid ,
tTitle ,
basePath ,
) ;
return {
action : "dispatch" ,
unitType : "execute-task" ,
unitId ,
prompt : prependTaskCompleteFailurePrompt ( session , unitId , prompt ) ,
} ;
} ,
} ,
{
name : "validating-milestone → validate-milestone" ,
match : async ( {
state ,
mid ,
midTitle ,
basePath ,
prefs ,
pipelineVariant ,
} ) = > {
if ( state . phase !== "validating-milestone" ) return null ;
// Safety guard (#1368): verify all roadmap slices have SUMMARY files before
// allowing milestone validation.
const missingSlices = findMissingSummaries ( basePath , mid ) ;
if ( missingSlices . length > 0 ) {
return {
action : "stop" ,
reason : ` Cannot validate milestone ${ mid } : slices ${ missingSlices . join ( ", " ) } are missing SUMMARY files. These slices may have been skipped. ` ,
level : "error" ,
} ;
}
// Skip preference or trivial-scope pipeline variant: write a minimal pass-through VALIDATION file
const trivialVariant = pipelineVariant === "trivial" ;
const skipSource = trivialVariant
? "trivial-scope pipeline variant (#4781)"
: "`skip_milestone_validation` preference" ;
if ( prefs ? . phases ? . skip_milestone_validation || trivialVariant ) {
const mDir = resolveMilestonePath ( basePath , mid ) ;
if ( mDir ) {
if ( ! existsSync ( mDir ) ) mkdirSync ( mDir , { recursive : true } ) ;
const validationPath = join (
mDir ,
buildMilestoneFileName ( mid , "VALIDATION" ) ,
) ;
const content = [
"---" ,
"verdict: pass" ,
"remediation_round: 0" ,
"---" ,
"" ,
"# Milestone Validation (skipped)" ,
"" ,
` Milestone validation was skipped via ${ skipSource } . ` ,
] . join ( "\n" ) ;
writeFileSync ( validationPath , content , "utf-8" ) ;
}
return { action : "skip" } ;
}
return {
action : "dispatch" ,
unitType : "validate-milestone" ,
unitId : mid ,
prompt : await buildValidateMilestonePrompt ( mid , midTitle , basePath ) ,
} ;
} ,
} ,
{
name : "completing-milestone → complete-milestone" ,
match : async ( { state , mid , midTitle , basePath } ) = > {
if ( state . phase !== "completing-milestone" ) return null ;
2026-04-29 21:17:00 +02:00
// Safety guard (#2675): completion is only automatic after a pass verdict.
// Non-pass terminal verdicts are still terminal for validation loops, but
// they are not a license to close the milestone.
2026-04-29 12:42:31 +02:00
const validationFile = resolveMilestoneFile ( basePath , mid , "VALIDATION" ) ;
if ( validationFile ) {
const validationContent = await loadFile ( validationFile ) ;
if ( validationContent ) {
const verdict = extractVerdict ( validationContent ) ;
2026-04-29 21:17:00 +02:00
if ( verdict && verdict !== "pass" ) {
2026-04-30 06:31:19 +02:00
if ( verdict === "needs-attention" ) {
const attentionPlan =
extractValidationAttentionPlan ( validationContent ) ;
2026-04-30 08:08:10 +02:00
if (
attentionPlan &&
! hasActiveValidationAttentionMarker ( basePath , mid )
) {
2026-04-30 06:31:19 +02:00
try {
2026-04-30 08:41:49 +02:00
writeValidationAttentionMarker ( basePath , mid , {
milestoneId : mid ,
createdAt : new Date ( ) . toISOString ( ) ,
source : validationFile ,
remediationRound :
parseValidationRemediationRound ( validationContent ) ,
} ) ;
2026-04-30 06:31:19 +02:00
} catch ( err ) {
logWarning (
"dispatch" ,
` failed to persist validation attention marker: ${ err instanceof Error ? err.message : String ( err ) } ` ,
) ;
}
return {
action : "dispatch" ,
unitType : "rewrite-docs" ,
unitId : ` ${ mid } /validation-attention ` ,
prompt : buildValidationAttentionRemediationPrompt (
mid ,
midTitle ,
basePath ,
validationContent ,
attentionPlan ,
) ,
} ;
}
2026-04-30 08:41:49 +02:00
if (
shouldDispatchValidationAttentionRevalidation (
basePath ,
mid ,
validationContent ,
)
) {
return {
action : "dispatch" ,
unitType : "validate-milestone" ,
unitId : mid ,
prompt : await buildValidateMilestonePrompt (
mid ,
midTitle ,
basePath ,
) ,
} ;
}
2026-04-30 06:31:19 +02:00
}
2026-04-29 12:42:31 +02:00
return {
action : "stop" ,
2026-04-29 21:17:00 +02:00
reason : ` Cannot complete milestone ${ mid } : VALIDATION verdict is " ${ verdict } ". Only verdict "pass" may enter automatic milestone completion. Address or explicitly defer the findings and re-run validation. ` ,
2026-04-29 12:42:31 +02:00
level : "warning" ,
} ;
}
}
}
// Safety guard (#1368): verify all roadmap slices have SUMMARY files.
const missingSlices = findMissingSummaries ( basePath , mid ) ;
if ( missingSlices . length > 0 ) {
return {
action : "stop" ,
reason : ` Cannot complete milestone ${ mid } : slices ${ missingSlices . join ( ", " ) } are missing SUMMARY files. Run /sf doctor to diagnose. ` ,
level : "error" ,
} ;
}
// Safety guard (#1703): verify the milestone produced implementation
// artifacts (non-.sf/ files). A milestone with only plan files and
// zero implementation code should not be marked complete.
const artifactCheck = hasImplementationArtifacts ( basePath ) ;
if ( artifactCheck === "absent" ) {
return {
action : "stop" ,
reason : ` Cannot complete milestone ${ mid } : no implementation files found outside .sf/. The milestone has only plan files — actual code changes are required. ` ,
level : "error" ,
} ;
}
if ( artifactCheck === "unknown" ) {
logWarning (
"dispatch" ,
` Implementation artifact check inconclusive for ${ mid } — proceeding (git context unavailable) ` ,
) ;
}
// Verification class compliance: if operational verification was planned,
// ensure the validation output documents it before allowing completion.
try {
if ( isDbAvailable ( ) ) {
const milestone = getMilestone ( mid ) ;
if (
milestone ? . verification_operational &&
! isVerificationNotApplicable ( milestone . verification_operational )
) {
const validationPath = resolveMilestoneFile (
basePath ,
mid ,
"VALIDATION" ,
) ;
if ( validationPath ) {
const validationContent = await loadFile ( validationPath ) ;
if ( validationContent ) {
// Allow completion when validation was intentionally skipped by
// preference/budget profile (#3399, #3344).
const skippedByPreference =
/skip(?:ped)?[\s-]+(?:by|per|due to)\s+(?:preference|budget|profile)/i . test (
validationContent ,
) ;
// Accept either the structured template format (table with MET/N/A/SATISFIED)
// or prose evidence patterns the validation agent may emit.
const structuredMatch =
validationContent . includes ( "Operational" ) &&
( validationContent . includes ( "MET" ) ||
validationContent . includes ( "N/A" ) ||
validationContent . includes ( "SATISFIED" ) ) ;
const proseMatch =
/[Oo]perational[\s\S]{0,500}?(?:✅|pass|verified|confirmed|met|complete|true|yes|addressed|covered|satisfied|partially|n\/a|not[\s-]+applicable)/i . test (
validationContent ,
) ;
const hasOperationalCheck =
skippedByPreference || structuredMatch || proseMatch ;
if ( ! hasOperationalCheck ) {
return {
action : "stop" as const ,
reason : ` Milestone ${ mid } has planned operational verification (" ${ milestone . verification_operational . substring ( 0 , 100 ) } ") but the validation output does not address it. Re-run validation with verification class awareness, or update the validation to document operational compliance. ` ,
level : "warning" as const ,
} ;
}
}
}
}
}
} catch ( err ) {
/* fall through — don't block on DB errors */
logWarning (
"dispatch" ,
` verification class check failed: ${ err instanceof Error ? err.message : String ( err ) } ` ,
) ;
}
// P5-A: Advisory check for deferred requirements targeting this milestone
try {
const deferred = parseDeferredRequirements ( basePath ) ;
const unaddressed = deferred . filter ( ( r ) = > r . deferredTo === mid ) ;
if ( unaddressed . length > 0 ) {
const ids = unaddressed . map ( ( r ) = > r . id ) . join ( ", " ) ;
logWarning (
"dispatch" ,
` Milestone ${ mid } has ${ unaddressed . length } deferred requirement(s) ( ${ ids } ) that were not validated. Review before completing. ` ,
) ;
}
} catch {
// Non-fatal advisory
}
return {
action : "dispatch" ,
unitType : "complete-milestone" ,
unitId : mid ,
prompt : await buildCompleteMilestonePrompt ( mid , midTitle , basePath ) ,
} ;
} ,
} ,
{
name : "complete → stop" ,
match : async ( { state } ) = > {
if ( state . phase !== "complete" ) return null ;
return {
action : "stop" ,
reason : "All milestones complete." ,
level : "info" ,
} ;
} ,
} ,
2026-04-15 14:54:20 +02:00
] ;
2026-04-19 08:33:29 +02:00
import { getRegistry , hasRegistry } from "./rule-registry.js" ;
2026-04-15 14:54:20 +02:00
// ─── Resolver ─────────────────────────────────────────────────────────────
/ * *
* Evaluate dispatch rules in order . Returns the first matching action ,
* or a "stop" action if no rule matches ( unhandled phase ) .
*
* Delegates to the RuleRegistry when initialized ; falls back to inline
* loop over DISPATCH_RULES for backward compatibility ( tests that import
* resolveDispatch directly without registry initialization ) .
* /
export async function resolveDispatch (
2026-04-29 12:42:31 +02:00
ctx : DispatchContext ,
2026-04-15 14:54:20 +02:00
) : Promise < DispatchAction > {
2026-04-29 12:42:31 +02:00
// Fetch pipeline variant once per dispatch cycle so rules can read ctx.pipelineVariant
// without triggering redundant DB queries + heuristic evaluations.
if ( ctx . pipelineVariant === undefined ) {
ctx . pipelineVariant = await getMilestonePipelineVariant ( ctx . mid ) ;
}
// Delegate to registry when available. Callers that run outside auto-mode
// (e.g. `sf headless query`, `sf headless status`) never initialize the
// registry — falling through to inline rules is the intended behavior,
// not an error, so we silent-probe instead of warning on every call.
if ( hasRegistry ( ) ) {
try {
return await getRegistry ( ) . evaluateDispatch ( ctx ) ;
} catch ( err ) {
// Genuine registry evaluation failure (rule threw, etc.) — log so we
// surface real bugs, then fall back.
logWarning (
"dispatch" ,
` registry dispatch failed, falling back to inline rules: ${ err instanceof Error ? err.message : String ( err ) } ` ,
) ;
}
}
for ( const rule of DISPATCH_RULES ) {
const result = await rule . match ( ctx ) ;
if ( result ) {
if ( result . action !== "skip" ) result . matchedRule = rule . name ;
return result ;
}
}
// No rule matched — unhandled phase.
// Use level "warning" so the loop pauses (resumable) instead of hard-stopping.
// Hard-stop here was causing premature termination for transient phase gaps
// (e.g. after reassessment modifies the roadmap and state needs re-derivation).
return {
action : "stop" ,
reason : ` Unhandled phase " ${ ctx . state . phase } " — run /sf doctor to diagnose. ` ,
level : "warning" ,
matchedRule : "<no-match>" ,
} ;
2026-04-15 14:54:20 +02:00
}
/** Exposed for testing — returns the rule names in evaluation order. */
export function getDispatchRuleNames ( ) : string [ ] {
2026-04-29 12:42:31 +02:00
if ( hasRegistry ( ) ) {
return getRegistry ( )
. listRules ( )
. filter ( ( rule ) = > rule . when === "dispatch" )
. map ( ( rule ) = > rule . name ) ;
}
return DISPATCH_RULES . map ( ( r ) = > r . name ) ;
2026-04-15 14:54:20 +02:00
}