2026-05-04 23:27:20 +02:00
/ * *
2026-05-05 15:42:10 +02:00
* Autonomous mode Dispatch Table — declarative phase → unit mapping .
2026-05-04 23:27:20 +02:00
*
* 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 .
* /
2026-05-05 14:46:18 +02:00
2026-05-04 23:27:20 +02:00
import { existsSync , mkdirSync , readFileSync , writeFileSync } from "node:fs" ;
import { join } from "node:path" ;
2026-05-05 14:31:16 +02:00
import {
buildCompleteMilestonePrompt ,
buildCompleteSlicePrompt ,
buildDiscussMilestonePrompt ,
buildDiscussProjectPrompt ,
buildDiscussRequirementsPrompt ,
buildExecuteTaskPrompt ,
buildGateEvaluatePrompt ,
buildParallelResearchSlicesPrompt ,
buildPlanMilestonePrompt ,
buildPlanSlicePrompt ,
buildReactiveExecutePrompt ,
buildReassessRoadmapPrompt ,
buildRefineSlicePrompt ,
buildReplanSlicePrompt ,
buildResearchMilestonePrompt ,
2026-05-05 14:46:18 +02:00
buildResearchProjectPrompt ,
2026-05-05 14:31:16 +02:00
buildResearchSlicePrompt ,
buildRewriteDocsPrompt ,
buildRunUatPrompt ,
buildValidateMilestonePrompt ,
buildWorkflowPreferencesPrompt ,
checkNeedsReassessment ,
checkNeedsRunUat ,
} from "./auto-prompts.js" ;
2026-05-04 23:27:20 +02:00
import { hasImplementationArtifacts } from "./auto-recovery.js" ;
import { getCanonicalMilestonePlan } from "./canonical-milestone-plan.js" ;
import { resolveDeepProjectSetupState } from "./deep-project-setup-policy.js" ;
import { resolveEscalation } from "./escalation.js" ;
2026-05-05 14:31:16 +02:00
import {
getExecuteTaskInstructionConflict ,
skipExecuteTaskForInstructionConflict ,
} from "./execution-instruction-guard.js" ;
import {
extractUatType ,
loadActiveOverrides ,
loadFile ,
parseDeferredRequirements ,
resolveAllOverrides ,
} from "./files.js" ;
2026-05-04 23:27:20 +02:00
import { getMilestonePipelineVariant } from "./milestone-scope-classifier.js" ;
2026-05-05 14:31:16 +02:00
import {
buildMilestoneFileName ,
relSliceFile ,
resolveMilestoneFile ,
resolveMilestonePath ,
resolveSliceFile ,
resolveTaskFile ,
sfRoot ,
} from "./paths.js" ;
2026-05-04 23:27:20 +02:00
import { resolveModelWithFallbacksForUnit } from "./preferences-models.js" ;
2026-05-05 14:46:18 +02:00
import { createScheduleStore } from "./schedule/schedule-store.js" ;
2026-05-05 14:31:16 +02:00
import {
getMilestone ,
getMilestoneSlices ,
getPendingGates ,
getSlice ,
getSliceTasks ,
isDbAvailable ,
markAllGatesOmitted ,
} from "./sf-db.js" ;
2026-05-04 23:27:20 +02:00
import { isClosedStatus , isInactiveStatus } from "./status-guards.js" ;
import { buildAuditEnvelope , emitUokAuditEvent } from "./uok/audit.js" ;
2026-05-05 14:31:16 +02:00
import {
buildDispatchEnvelope ,
explainDispatch ,
} from "./uok/dispatch-envelope.js" ;
2026-05-04 23:27:20 +02:00
import { selectReactiveDispatchBatch } from "./uok/execution-graph.js" ;
import { resolveUokFlags } from "./uok/flags.js" ;
import { UokGateRunner } from "./uok/gate-runner.js" ;
import { hasFinalizedMilestoneContext } from "./uok/plan-v2.js" ;
import { extractVerdict , isAcceptableUatVerdict } from "./verdict-parser.js" ;
import { logError , logWarning } from "./workflow-logger.js" ;
2026-05-05 14:46:18 +02:00
2026-05-04 23:27:20 +02:00
const MAX _PARALLEL _RESEARCH _SLICES = 8 ;
const PARALLEL _RESEARCH _BLOCKING _PHASES = new Set ( [
2026-05-05 14:31:16 +02:00
"blocked" ,
"cancelled" ,
"failed" ,
"recovery" ,
"runaway-warning-sent" ,
"timeout" ,
"timed-out" ,
2026-05-04 23:27:20 +02:00
] ) ;
function missingSliceStop ( mid , phase ) {
2026-05-05 14:31:16 +02:00
return {
action : "stop" ,
reason : ` ${ mid } : phase " ${ phase } " has no active slice — run /sf doctor. ` ,
level : "error" ,
} ;
2026-05-04 23:27:20 +02:00
}
function canonicalPlanStop ( mid , plan ) {
2026-05-05 14:31:16 +02:00
return {
action : "stop" ,
2026-05-05 15:42:10 +02:00
reason : ` ${ mid } : canonical milestone plan unavailable ( ${ plan . source } ): ${ plan . reason } Run /sf doctor or regenerate structured roadmap state before dispatching autonomous mode work. ` ,
2026-05-05 14:31:16 +02:00
level : "error" ,
} ;
2026-05-04 23:27:20 +02:00
}
function hasPriorParallelResearchFailure ( basePath , mid ) {
2026-05-05 14:31:16 +02:00
const blocker = resolveMilestoneFile ( basePath , mid , "PARALLEL-BLOCKER" ) ;
if ( blocker ) return true ;
const runtimeFile = join (
sfRoot ( basePath ) ,
"runtime" ,
"units" ,
` research-slice- ${ mid } -parallel-research.json ` ,
) ;
if ( ! existsSync ( runtimeFile ) ) return false ;
try {
const state = JSON . parse ( readFileSync ( runtimeFile , "utf-8" ) ) ;
const phase = typeof state . phase === "string" ? state . phase : "" ;
if ( PARALLEL _RESEARCH _BLOCKING _PHASES . has ( phase ) ) return true ;
if (
typeof state . recoveryAttempts === "number" &&
state . recoveryAttempts > 0
) {
return true ;
}
return typeof state . lastRecoveryReason === "string" ;
} catch ( err ) {
logWarning (
"dispatch" ,
` Ignoring unreadable parallel-research runtime state for ${ mid } : ${ err instanceof Error ? err . message : String ( err ) } ` ,
) ;
return false ;
}
2026-05-04 23:27:20 +02:00
}
const ROADMAP _COUNT _WORDS = new Map ( [
2026-05-05 14:31:16 +02:00
[ "one" , 1 ] ,
[ "two" , 2 ] ,
[ "three" , 3 ] ,
[ "four" , 4 ] ,
[ "five" , 5 ] ,
[ "six" , 6 ] ,
[ "seven" , 7 ] ,
[ "eight" , 8 ] ,
[ "nine" , 9 ] ,
[ "ten" , 10 ] ,
2026-05-04 23:27:20 +02:00
] ) ;
function parseSliceCountToken ( token ) {
2026-05-05 14:31:16 +02:00
const normalized = token . toLowerCase ( ) ;
const wordCount = ROADMAP _COUNT _WORDS . get ( normalized ) ;
if ( wordCount !== undefined ) return wordCount ;
const numeric = Number . parseInt ( normalized , 10 ) ;
return Number . isFinite ( numeric ) && numeric > 0 ? numeric : null ;
2026-05-04 23:27:20 +02:00
}
function findRoadmapSliceCountContradiction ( roadmapContent , actualSliceCount ) {
2026-05-05 14:31:16 +02:00
const narrative = roadmapContent . split (
/\n##\s+(?:Slice Overview|Slices)\b/i ,
) [ 0 ] ;
const sliceCountPattern =
"(one|two|three|four|five|six|seven|eight|nine|ten|\\d+)" ;
const claimPatterns = [
new RegExp ( ` \\ b ${ sliceCountPattern } \\ s+slices \\ s*: ` , "i" ) ,
new RegExp ( ` \\ b ${ sliceCountPattern } [- \\ s]+slice \\ s+structure \\ b ` , "i" ) ,
new RegExp ( ` \\ btotal: \\ s* ${ sliceCountPattern } \\ s+slices \\ b ` , "i" ) ,
] ;
for ( const pattern of claimPatterns ) {
const matched = narrative . match ( pattern ) ;
const declared = matched ? . [ 1 ] ? parseSliceCountToken ( matched [ 1 ] ) : null ;
if ( declared !== null && declared !== actualSliceCount ) {
return ` roadmap narrative declares ${ declared } slice ${ declared === 1 ? "" : "s" } , but the parsed Slice Overview contains ${ actualSliceCount } ` ;
}
}
return null ;
2026-05-04 23:27:20 +02:00
}
export function formatTaskCompleteFailurePrompt ( reason ) {
2026-05-05 14:31:16 +02:00
return ` sf_task_complete failed: ${ reason } . Try the call again, or investigate the write path. ` ;
2026-05-04 23:27:20 +02:00
}
function prependTaskCompleteFailurePrompt ( session , unitId , prompt ) {
2026-05-05 14:31:16 +02:00
const reason = session ? . pendingTaskCompleteFailures ? . get ( unitId ) ;
if ( ! reason ) return prompt ;
return ` ${ formatTaskCompleteFailurePrompt ( reason ) } \n \n ${ prompt } ` ;
2026-05-04 23:27:20 +02:00
}
function isMilestonePlanRepairState ( state ) {
2026-05-05 14:31:16 +02:00
if ( state . phase !== "planning" || state . activeSlice ) return false ;
return /roadmap is incomplete|weighted vision alignment meeting/i . test (
state . nextAction ? ? "" ,
) ;
2026-05-04 23:27: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 , mid ) {
2026-05-05 14:31:16 +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-05-04 23:27: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 ) {
2026-05-05 14:31:16 +02:00
return join ( sfRoot ( basePath ) , "runtime" , "rewrite-count.json" ) ;
2026-05-04 23:27:20 +02:00
}
export function getRewriteCount ( basePath ) {
2026-05-05 14:31:16 +02:00
try {
const data = JSON . parse ( readFileSync ( rewriteCountPath ( basePath ) , "utf-8" ) ) ;
return typeof data . count === "number" ? data . count : 0 ;
} catch {
return 0 ;
}
2026-05-04 23:27:20 +02:00
}
export function setRewriteCount ( basePath , count ) {
2026-05-05 14:31:16 +02:00
const filePath = rewriteCountPath ( basePath ) ;
mkdirSync ( join ( sfRoot ( basePath ) , "runtime" ) , { recursive : true } ) ;
writeFileSync (
filePath ,
JSON . stringify ( { count , updatedAt : new Date ( ) . toISOString ( ) } ) + "\n" ,
) ;
2026-05-04 23:27: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 , mid , sid ) {
2026-05-05 14:31:16 +02:00
return join ( sfRoot ( basePath ) , "runtime" , ` uat-count- ${ mid } - ${ sid } .json ` ) ;
2026-05-04 23:27:20 +02:00
}
export function getUatCount ( basePath , mid , sid ) {
2026-05-05 14:31:16 +02:00
try {
const data = JSON . parse (
readFileSync ( uatCountPath ( basePath , mid , sid ) , "utf-8" ) ,
) ;
return typeof data . count === "number" ? data . count : 0 ;
} catch {
return 0 ;
}
2026-05-04 23:27:20 +02:00
}
export function incrementUatCount ( basePath , mid , sid ) {
2026-05-05 14:31:16 +02:00
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-05-04 23:27: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 ) {
2026-05-05 14:31:16 +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-05-04 23:27:20 +02:00
}
export function extractValidationAttentionPlan ( validationContent ) {
2026-05-05 14:31:16 +02:00
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 ;
2026-05-04 23:27:20 +02:00
}
function validationAttentionMarkerPath ( basePath , mid ) {
2026-05-05 14:31:16 +02:00
return join (
sfRoot ( basePath ) ,
"runtime" ,
"validation-attention" ,
` ${ mid } .json ` ,
) ;
2026-05-04 23:27:20 +02:00
}
function parseValidationRemediationRound ( content ) {
2026-05-05 14:31:16 +02:00
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 ;
2026-05-04 23:27:20 +02:00
}
function readValidationAttentionMarker ( basePath , mid ) {
2026-05-05 14:31:16 +02:00
const markerPath = validationAttentionMarkerPath ( basePath , mid ) ;
if ( ! existsSync ( markerPath ) ) return null ;
try {
const parsed = JSON . parse ( readFileSync ( markerPath , "utf-8" ) ) ;
if ( ! parsed || typeof parsed !== "object" ) return null ;
return parsed ;
} catch {
return null ;
}
2026-05-04 23:27:20 +02:00
}
function writeValidationAttentionMarker ( basePath , mid , marker ) {
2026-05-05 14:31:16 +02:00
mkdirSync ( join ( sfRoot ( basePath ) , "runtime" , "validation-attention" ) , {
recursive : true ,
} ) ;
writeFileSync (
validationAttentionMarkerPath ( basePath , mid ) ,
JSON . stringify ( marker , null , 2 ) + "\n" ,
"utf-8" ,
) ;
2026-05-04 23:27:20 +02:00
}
function validationAttentionRuntimePath ( basePath , mid ) {
2026-05-05 14:31:16 +02:00
return join (
sfRoot ( basePath ) ,
"runtime" ,
"units" ,
` rewrite-docs- ${ mid } -validation-attention.json ` ,
) ;
2026-05-04 23:27:20 +02:00
}
function hasActiveValidationAttentionMarker ( basePath , mid ) {
2026-05-05 14:31:16 +02:00
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-05-04 23:27:20 +02:00
}
2026-05-05 14:31:16 +02:00
function shouldDispatchValidationAttentionRevalidation (
basePath ,
mid ,
validationContent ,
) {
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-05-04 23:27:20 +02:00
}
2026-05-05 14:31:16 +02:00
function buildValidationAttentionRemediationPrompt (
mid ,
midTitle ,
basePath ,
validationContent ,
attentionPlan ,
) {
const validationRel = ` .sf/milestones/ ${ mid } / ${ mid } -VALIDATION.md ` ;
const escapedValidation = validationContent . replace ( /```/g , "``\\`" ) ;
const escapedPlan = attentionPlan . replace ( /```/g , "``\\`" ) ;
2026-05-05 15:42:10 +02:00
return ` You are executing SF autonomous mode.
2026-05-04 23:27:20 +02:00
# # 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." ` ;
}
// ─── Rules ────────────────────────────────────────────────────────────────
export const DISPATCH _RULES = [
2026-05-05 14:31:16 +02:00
{
name : "schedule (auto_dispatch=true) → notify" ,
match : async ( { state , basePath } ) => {
// Only fire when no active milestone — never pre-empt real work
if ( state . activeMilestone ? . id ) return null ;
2026-05-05 01:34:50 +02:00
2026-05-05 14:31:16 +02:00
try {
const store = createScheduleStore ( basePath ) ;
const due = store . findDue ( "project" , new Date ( ) ) ;
// Find entries that want auto-dispatch
const autoDispatch = due . filter (
( e ) => e . auto _dispatch === true && e . kind === "reminder" ,
) ;
if ( autoDispatch . length === 0 ) return null ;
2026-05-05 01:34:50 +02:00
2026-05-05 14:31:16 +02:00
// Surface the first due entry as a notification stop
const entry = autoDispatch [ 0 ] ;
const msg =
entry . payload ? . message ? ? ` Scheduled reminder ${ entry . id } is due. ` ;
return {
action : "stop" ,
reason : ` [schedule] ${ msg } Mark done: /sf schedule done ${ entry . id } ` ,
level : "info" ,
} ;
} catch {
// Non-fatal: never block dispatch on schedule store errors
return null ;
}
} ,
} ,
{
// ADR-011 Phase 2 (gsd-2 ADR): mid-execution escalation handling.
2026-05-05 15:42:10 +02:00
// Autonomous mode is autonomous, so by default we accept the agent's
2026-05-05 14:31:16 +02:00
// recommendation and continue — the user can review/override later via
// `/sf escalate list --all`. Set `phases.escalation_auto_accept: false`
// to keep gsd-2's pause-and-ask behavior.
// Must evaluate FIRST — phase-agnostic rules below (rewrite-docs gate,
// UAT checks, reassess) cannot run while a task is paused.
name : "escalating-task → auto-accept-or-pause" ,
match : async ( { state , mid , prefs , basePath } ) => {
if ( state . phase !== "escalating-task" ) return null ;
const autoAccept = prefs ? . phases ? . escalation _auto _accept !== false ;
if (
autoAccept &&
state . activeMilestone &&
state . activeSlice &&
state . activeTask
) {
const result = resolveEscalation (
basePath ,
state . activeMilestone . id ,
state . activeSlice . id ,
state . activeTask . id ,
"accept" ,
2026-05-05 15:42:10 +02:00
"autonomous mode: accepted agent recommendation; user can override via /sf escalate" ,
"autonomous mode" ,
2026-05-05 14:31:16 +02:00
) ;
if ( result . status === "resolved" ) {
// Flags cleared; let the next dispatch cycle re-read state and
// route normally (carry-forward injection picks this up via
// claimEscalationOverride on the next execute-task).
return { action : "skip" } ;
}
logWarning (
"dispatch" ,
` escalation auto-accept failed for ${ state . activeMilestone . id } / ${ state . activeSlice . id } / ${ state . activeTask . id } : ${ result . status } — falling back to pause ` ,
) ;
}
return {
action : "stop" ,
reason :
state . nextAction ||
` ${ mid } : task escalation awaits user resolution. Run /sf escalate list to see pending items. ` ,
level : "info" ,
} ;
} ,
} ,
{
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-05-05 15:42:10 +02:00
"You are running in SF autonomous mode. Do not call `ask_user_questions`, " +
2026-05-05 14:31:16 +02:00
"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" +
"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" +
"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/" +
mid +
"/" +
mid +
"-ROADMAP.md` with the agreed slices.\n" +
"Do NOT write detailed plans — that's for later after the roadmap is aligned.\n\n" +
"## Session Context\n" +
"- Working directory: `" +
basePath +
"`\n" +
"- 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" ,
} ;
} ,
} ,
{
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" ,
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" ,
} ;
}
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 ;
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" ,
reason : ` UAT verdict for ${ check . sliceId } is " ${ check . verdict } " — blocking progression until resolved. \n Review the UAT result and update the verdict to PASS, or re-run /sf auto after fixing. ` ,
level : "warning" ,
} ;
}
}
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 ,
) ,
} ;
} ,
} ,
{
// Deep planning mode: the project-level setup gate runs before any
// milestone-level discuss/research/plan when planning_depth === "deep".
// resolveDeepProjectSetupState walks the staged-prerequisite chain
// (workflow-prefs → project → requirements → research-decision auto-
// resolved → project-research) and returns the next pending stage. Each
// stage's prompt writes its expected artifact, the gate flips the next
// time, and the milestone-level rules below take over when status =
// "complete" or planning_depth !== "deep".
name : "deep planning gate → project-level units" ,
match : async ( { state , basePath , prefs } ) => {
if ( prefs ? . planning _depth !== "deep" ) return null ;
if (
state . phase !== "pre-planning" &&
state . phase !== "needs-discussion"
) {
return null ;
}
let gate ;
try {
gate = resolveDeepProjectSetupState ( prefs , basePath ) ;
} catch {
return null ; // helper failure → fall through to legacy rules
}
if ( gate . status === "not-applicable" || gate . status === "complete" ) {
return null ;
}
if ( gate . status === "blocked" ) {
return {
action : "stop" ,
reason : gate . reason ? ? "Deep planning gate is blocked." ,
level : "warning" ,
} ;
}
// status === "pending"
switch ( gate . stage ) {
case "workflow-preferences" :
return {
action : "dispatch" ,
unitType : "workflow-preferences" ,
unitId : "WORKFLOW-PREFERENCES" ,
prompt : await buildWorkflowPreferencesPrompt ( basePath ) ,
} ;
case "project" :
return {
action : "dispatch" ,
unitType : "discuss-project" ,
unitId : "PROJECT" ,
prompt : await buildDiscussProjectPrompt ( basePath ) ,
} ;
case "requirements" :
return {
action : "dispatch" ,
unitType : "discuss-requirements" ,
unitId : "REQUIREMENTS" ,
prompt : await buildDiscussRequirementsPrompt ( basePath ) ,
} ;
case "project-research" :
return {
action : "dispatch" ,
unitType : "research-project" ,
unitId : "RESEARCH-PROJECT" ,
prompt : await buildResearchProjectPrompt ( basePath ) ,
} ;
default :
return null ;
}
} ,
} ,
{
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 ( state . phase !== "executing" && state . phase !== "summarizing" ) {
return null ;
}
if ( hasFinalizedMilestoneContext ( basePath , mid ) ) 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 ) ,
} ;
} ,
} ,
{
name : "planning (roadmap contradiction) → stop" ,
match : async ( { state , mid , basePath } ) => {
if ( state . phase !== "planning" ) return null ;
const canonicalPlan = getCanonicalMilestonePlan ( basePath , mid ) ;
if ( ! canonicalPlan . safe ) return canonicalPlanStop ( mid , canonicalPlan ) ;
if ( canonicalPlan . source === "db" ) return null ;
const roadmapFile = resolveMilestoneFile ( basePath , mid , "ROADMAP" ) ;
const roadmapContent = roadmapFile ? await loadFile ( roadmapFile ) : null ;
if ( ! roadmapContent ) return null ;
const contradiction = findRoadmapSliceCountContradiction (
roadmapContent ,
canonicalPlan . slices . length ,
) ;
if ( ! contradiction ) return null ;
return {
action : "stop" ,
2026-05-05 15:42:10 +02:00
reason : ` ${ mid } : ${ contradiction } . Regenerate structured roadmap state before dispatching autonomous mode work. ` ,
2026-05-05 14:31:16 +02:00
level : "error" ,
} ;
} ,
} ,
{
// 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 ;
const canonicalPlan = getCanonicalMilestonePlan ( basePath , mid ) ;
if ( ! canonicalPlan . safe ) return canonicalPlanStop ( mid , canonicalPlan ) ;
// Find slices that need research (no RESEARCH file, dependencies done)
const milestoneResearchFile = resolveMilestoneFile (
basePath ,
mid ,
"RESEARCH" ,
) ;
const researchReadySlices = [ ] ;
// Pre-compute which slices have SUMMARY files to avoid O(N× M) existsSync calls
const slicesWithSummary = new Set (
canonicalPlan . slices
. filter (
( s ) =>
isClosedStatus ( s . status ) ||
! ! resolveSliceFile ( basePath , mid , s . id , "SUMMARY" ) ,
)
. map ( ( s ) => s . id ) ,
) ;
for ( const slice of canonicalPlan . slices ) {
if ( isInactiveStatus ( slice . status ) ) 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 ;
if ( researchReadySlices . length > MAX _PARALLEL _RESEARCH _SLICES )
return null ;
// #4414: If a previous parallel-research attempt escalated or recovered
// from a runaway, fall through to per-slice research instead of
// re-dispatching the same synthetic unit.
if ( hasPriorParallelResearchFailure ( basePath , mid ) ) 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 ,
) ,
} ;
} ,
} ,
{
// gsd-2 ADR-011 progressive planning: when a slice was created as a sketch
// (slices.is_sketch=1) and the phases.progressive_planning preference is
// enabled, dispatch refine-slice instead of plan-slice. The refine unit
// expands the stored sketch_scope into a full plan using prior slice
// summaries as authoritative context. When the preference is off, sketches
// fall through to the normal plan-slice rule below — a graceful downgrade.
name : "planning (sketch + progressive_planning) → refine-slice" ,
match : async ( { state , mid , midTitle , basePath , prefs } ) => {
if ( state . phase !== "planning" ) return null ;
if ( ! state . activeSlice ) return null ;
if ( prefs ? . phases ? . progressive _planning !== true ) return null ;
const sid = state . activeSlice . id ;
const sTitle = state . activeSlice . title ;
let isSketch = false ;
try {
const sliceRow = getSlice ( mid , sid ) ;
isSketch = sliceRow ? . is _sketch === 1 ;
} catch {
/* DB unavailable or column missing on pre-migration installs — fall through */
return null ;
}
if ( ! isSketch ) return null ;
return {
action : "dispatch" ,
unitType : "refine-slice" ,
unitId : ` ${ mid } / ${ sid } ` ,
prompt : await buildRefineSlicePrompt (
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 ;
const uokFlags = resolveUokFlags ( prefs ) ;
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 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
const errMsg = err . message ;
logError ( "dispatch" , "reactive graph derivation failed" , {
error : errMsg ,
} ) ;
// Persist execution-graph failure to gate audit when gates are enabled
if ( uokFlags . executionGraph && uokFlags . gates ) {
const egRunner = new UokGateRunner ( ) ;
egRunner . register ( {
id : "execution-graph-gate" ,
type : "execution" ,
execute : async ( ) => ( {
outcome : "fail" ,
failureClass : "execution" ,
rationale :
"reactive graph derivation failed — falling back to sequential" ,
findings : errMsg ,
} ) ,
} ) ;
egRunner
. run ( "execution-graph-gate" , {
basePath ,
traceId : ` dispatch: ${ mid } / ${ sid } ` ,
turnId : ` ${ mid } / ${ sid } ` ,
milestoneId : mid ,
sliceId : sid ,
unitType : "reactive-execute" ,
} )
. catch ( ( ) => {
/* gate telemetry must never block dispatch */
} ) ;
}
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 } ` ;
const instructionConflict = getExecuteTaskInstructionConflict (
basePath ,
mid ,
sid ,
tid ,
tTitle ,
) ;
if ( instructionConflict ) {
if ( isDbAvailable ( ) ) {
await skipExecuteTaskForInstructionConflict (
basePath ,
mid ,
sid ,
tid ,
instructionConflict . reason ,
) ;
logWarning ( "dispatch" , instructionConflict . reason ) ;
return { action : "skip" } ;
}
return {
action : "stop" ,
reason : instructionConflict . reason ,
level : "error" ,
} ;
}
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 skipLine = trivialVariant
? "Milestone validation was skipped via trivial-scope pipeline variant (#4781)."
: "Milestone validation was skipped by preference (`skip_milestone_validation`)." ;
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)" ,
"" ,
skipLine ,
] . 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 ;
// 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.
const validationFile = resolveMilestoneFile ( basePath , mid , "VALIDATION" ) ;
if ( validationFile ) {
const validationContent = await loadFile ( validationFile ) ;
if ( validationContent ) {
const verdict = extractVerdict ( validationContent ) ;
if ( verdict && verdict !== "pass" ) {
if ( verdict === "needs-attention" ) {
const attentionPlan =
extractValidationAttentionPlan ( validationContent ) ;
if (
attentionPlan &&
! hasActiveValidationAttentionMarker ( basePath , mid )
) {
try {
writeValidationAttentionMarker ( basePath , mid , {
milestoneId : mid ,
createdAt : new Date ( ) . toISOString ( ) ,
source : validationFile ,
remediationRound :
parseValidationRemediationRound ( validationContent ) ,
} ) ;
} 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 ,
) ,
} ;
}
if (
shouldDispatchValidationAttentionRevalidation (
basePath ,
mid ,
validationContent ,
)
) {
return {
action : "dispatch" ,
unitType : "validate-milestone" ,
unitId : mid ,
prompt : await buildValidateMilestonePrompt (
mid ,
midTitle ,
basePath ,
) ,
} ;
}
}
return {
action : "stop" ,
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. ` ,
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" ,
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" ,
} ;
}
}
}
}
}
} 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-05-04 23:27:20 +02:00
] ;
2026-05-05 14:46:18 +02:00
2026-05-04 23:27:20 +02:00
import { getRegistry , hasRegistry } from "./rule-registry.js" ;
2026-05-05 14:46:18 +02:00
2026-05-04 23:27:20 +02:00
// ─── Dispatch Envelope Emission ───────────────────────────────────────────
/ * *
* Emit a UokDispatchEnvelope as an audit event when audit is enabled .
* Best - effort — failures must never block dispatch .
* /
function emitDispatchEnvelope ( ctx , action ) {
2026-05-05 14:31:16 +02:00
const uokFlags = resolveUokFlags ( ctx . prefs ) ;
if ( ! uokFlags . gates && ! uokFlags . auditEnvelope ) return ;
try {
const envelopeAction =
action . action === "dispatch" ||
action . action === "stop" ||
action . action === "skip"
? action . action
: "dispatch" ;
const unitType = action . action === "dispatch" ? action . unitType : undefined ;
const unitId = action . action === "dispatch" ? action . unitId : undefined ;
const reasonCode =
action . action === "stop"
? "policy"
: action . action === "skip"
? "state"
: "state" ;
const summary =
action . action === "dispatch"
? ` dispatching ${ action . unitType } for ${ action . unitId } `
: action . action === "stop"
? action . reason
: "skipped" ;
const envelope = buildDispatchEnvelope ( {
action : envelopeAction ,
unitType ,
unitId ,
reasonCode ,
summary ,
evidence : {
phase : ctx . state . phase ,
mid : ctx . mid ,
matchedRule : action . action !== "skip" ? action . matchedRule : undefined ,
} ,
} ) ;
emitUokAuditEvent (
ctx . basePath ,
buildAuditEnvelope ( {
traceId : ` dispatch: ${ ctx . mid } : ${ ctx . state . phase } ` ,
turnId : unitId ? ? ctx . mid ,
category : "orchestration" ,
type : "dispatch-envelope" ,
payload : {
envelope ,
explanation : explainDispatch ( envelope ) ,
} ,
} ) ,
) ;
} catch {
// Best-effort — audit writes must never block dispatch.
}
2026-05-04 23:27: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 ( ctx ) {
2026-05-05 14:31:16 +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 ) ;
}
2026-05-05 15:42:10 +02:00
// Delegate to registry when available. Callers that run outside autonomous mode
2026-05-05 14:31:16 +02:00
// (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 {
const result = await getRegistry ( ) . evaluateDispatch ( ctx ) ;
emitDispatchEnvelope ( ctx , result ) ;
return result ;
} 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 ;
emitDispatchEnvelope ( ctx , result ) ;
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).
const unhandled = {
action : "stop" ,
reason : ` Unhandled phase " ${ ctx . state . phase } " — run /sf doctor to diagnose. ` ,
level : "warning" ,
matchedRule : "<no-match>" ,
} ;
emitDispatchEnvelope ( ctx , unhandled ) ;
return unhandled ;
2026-05-04 23:27:20 +02:00
}
/** Exposed for testing — returns the rule names in evaluation order. */
export function getDispatchRuleNames ( ) {
2026-05-05 14:31:16 +02:00
if ( hasRegistry ( ) ) {
return getRegistry ( )
. listRules ( )
. filter ( ( rule ) => rule . when === "dispatch" )
. map ( ( rule ) => rule . name ) ;
}
return DISPATCH _RULES . map ( ( r ) => r . name ) ;
2026-05-04 23:27:20 +02:00
}