1859 lines
62 KiB
JavaScript
1859 lines
62 KiB
JavaScript
/**
|
||
* Autonomous 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 {
|
||
buildCompleteMilestonePrompt,
|
||
buildCompleteSlicePrompt,
|
||
buildDiscussMilestonePrompt,
|
||
buildDiscussProjectPrompt,
|
||
buildDiscussRequirementsPrompt,
|
||
buildExecuteTaskPrompt,
|
||
buildGateEvaluatePrompt,
|
||
buildParallelResearchSlicesPrompt,
|
||
buildPlanMilestonePrompt,
|
||
buildPlanSlicePrompt,
|
||
buildReactiveExecutePrompt,
|
||
buildReassessRoadmapPrompt,
|
||
buildRefineSlicePrompt,
|
||
buildReplanSlicePrompt,
|
||
buildResearchMilestonePrompt,
|
||
buildResearchProjectPrompt,
|
||
buildResearchSlicePrompt,
|
||
buildRewriteDocsPrompt,
|
||
buildRunUatPrompt,
|
||
buildValidateMilestonePrompt,
|
||
buildWorkflowPreferencesPrompt,
|
||
} from "./auto-prompts.js";
|
||
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";
|
||
import {
|
||
getExecuteTaskInstructionConflict,
|
||
skipExecuteTaskForInstructionConflict,
|
||
} from "./execution-instruction-guard.js";
|
||
import {
|
||
extractUatType,
|
||
loadActiveOverrides,
|
||
loadFile,
|
||
parseDeferredRequirements,
|
||
resolveAllOverrides,
|
||
} from "./files.js";
|
||
import {
|
||
getRelevantMemoriesRanked,
|
||
isDbAvailable as isMemoryDbAvailable,
|
||
} from "./memory-store.js";
|
||
import { getMilestonePipelineVariant } from "./milestone-scope-classifier.js";
|
||
import {
|
||
buildMilestoneFileName,
|
||
relSliceFile,
|
||
resolveMilestoneFile,
|
||
resolveMilestonePath,
|
||
resolveSliceFile,
|
||
resolveTaskFile,
|
||
sfRoot,
|
||
} from "./paths.js";
|
||
import { resolveModelWithFallbacksForUnit } from "./preferences-models.js";
|
||
import {
|
||
buildScheduledPrompt,
|
||
executeProjectScheduleCommand,
|
||
isAutoDispatchScheduleEntry,
|
||
markProjectScheduleDone,
|
||
} from "./schedule/schedule-auto-dispatch.js";
|
||
import { createScheduleStore } from "./schedule/schedule-store.js";
|
||
import {
|
||
getMilestone,
|
||
getMilestoneSlices,
|
||
getMilestoneValidationAssessment,
|
||
getPendingGates,
|
||
getSlice,
|
||
getSliceTasks,
|
||
isDbAvailable,
|
||
markAllGatesOmitted,
|
||
} from "./sf-db.js";
|
||
import { isClosedStatus, isInactiveStatus } from "./status-guards.js";
|
||
import { buildAuditEnvelope, emitUokAuditEvent } from "./uok/audit.js";
|
||
import {
|
||
buildDispatchEnvelope,
|
||
explainDispatch,
|
||
} from "./uok/dispatch-envelope.js";
|
||
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 {
|
||
decideUnitRuntimeDispatch,
|
||
readUnitRuntimeRecord,
|
||
} from "./uok/unit-runtime.js";
|
||
import { extractVerdict, isAcceptableUatVerdict } from "./verdict-parser.js";
|
||
import {
|
||
checkNeedsReassessment,
|
||
checkNeedsRunUat,
|
||
} from "./workflow-helpers.js";
|
||
import { logError, logWarning } from "./workflow-logger.js";
|
||
|
||
const MAX_PARALLEL_RESEARCH_SLICES = 8;
|
||
const PARALLEL_RESEARCH_BLOCKING_PHASES = new Set([
|
||
"blocked",
|
||
"cancelled",
|
||
"failed",
|
||
"recovery",
|
||
"runaway-warning-sent",
|
||
"timeout",
|
||
"timed-out",
|
||
]);
|
||
function missingSliceStop(mid, phase) {
|
||
return {
|
||
action: "stop",
|
||
reason: `${mid}: phase "${phase}" has no active slice — run /sf doctor.`,
|
||
level: "error",
|
||
};
|
||
}
|
||
async function readMilestoneValidationForDispatch(basePath, mid) {
|
||
if (isDbAvailable()) {
|
||
const assessment = getMilestoneValidationAssessment(mid);
|
||
const verdict =
|
||
typeof assessment?.status === "string" && assessment.status.trim()
|
||
? assessment.status.trim().toLowerCase()
|
||
: undefined;
|
||
if (verdict) {
|
||
return {
|
||
verdict,
|
||
content: assessment.full_content ?? "",
|
||
path:
|
||
assessment.path ?? resolveMilestoneFile(basePath, mid, "VALIDATION"),
|
||
};
|
||
}
|
||
return null;
|
||
}
|
||
const validationFile = resolveMilestoneFile(basePath, mid, "VALIDATION");
|
||
if (!validationFile) return null;
|
||
const content = await loadFile(validationFile);
|
||
if (!content) return null;
|
||
return {
|
||
verdict: extractVerdict(content),
|
||
content,
|
||
path: validationFile,
|
||
};
|
||
}
|
||
function canonicalPlanStop(mid, plan) {
|
||
return {
|
||
action: "stop",
|
||
reason: `${mid}: canonical milestone plan unavailable (${plan.source}): ${plan.reason} Run /sf doctor or regenerate structured roadmap state before dispatching autonomous mode work.`,
|
||
level: "error",
|
||
};
|
||
}
|
||
function hasPriorParallelResearchFailure(basePath, mid) {
|
||
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;
|
||
}
|
||
}
|
||
const ROADMAP_COUNT_WORDS = new Map([
|
||
["one", 1],
|
||
["two", 2],
|
||
["three", 3],
|
||
["four", 4],
|
||
["five", 5],
|
||
["six", 6],
|
||
["seven", 7],
|
||
["eight", 8],
|
||
["nine", 9],
|
||
["ten", 10],
|
||
]);
|
||
function parseSliceCountToken(token) {
|
||
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;
|
||
}
|
||
function findRoadmapSliceCountContradiction(roadmapContent, actualSliceCount) {
|
||
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;
|
||
}
|
||
export function formatTaskCompleteFailurePrompt(reason) {
|
||
return `sf_task_complete failed: ${reason}. Try the call again, or investigate the write path.`;
|
||
}
|
||
function prependTaskCompleteFailurePrompt(session, unitId, prompt) {
|
||
const reason = session?.pendingTaskCompleteFailures?.get(unitId);
|
||
if (!reason) return prompt;
|
||
return `${formatTaskCompleteFailurePrompt(reason)}\n\n${prompt}`;
|
||
}
|
||
function isMilestonePlanRepairState(state) {
|
||
if (state.phase !== "planning" || state.activeSlice) return false;
|
||
return /roadmap is incomplete|weighted vision alignment meeting/i.test(
|
||
state.nextAction ?? "",
|
||
);
|
||
}
|
||
/**
|
||
* 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) {
|
||
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);
|
||
}
|
||
// ─── 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) {
|
||
return join(sfRoot(basePath), "runtime", "rewrite-count.json");
|
||
}
|
||
export function getRewriteCount(basePath) {
|
||
try {
|
||
const data = JSON.parse(readFileSync(rewriteCountPath(basePath), "utf-8"));
|
||
return typeof data.count === "number" ? data.count : 0;
|
||
} catch {
|
||
return 0;
|
||
}
|
||
}
|
||
export function setRewriteCount(basePath, count) {
|
||
const filePath = rewriteCountPath(basePath);
|
||
mkdirSync(join(sfRoot(basePath), "runtime"), { recursive: true });
|
||
writeFileSync(
|
||
filePath,
|
||
JSON.stringify({ count, updatedAt: new Date().toISOString() }) + "\n",
|
||
);
|
||
}
|
||
// ─── 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) {
|
||
return join(sfRoot(basePath), "runtime", `uat-count-${mid}-${sid}.json`);
|
||
}
|
||
export function getUatCount(basePath, mid, sid) {
|
||
try {
|
||
const data = JSON.parse(
|
||
readFileSync(uatCountPath(basePath, mid, sid), "utf-8"),
|
||
);
|
||
return typeof data.count === "number" ? data.count : 0;
|
||
} catch {
|
||
return 0;
|
||
}
|
||
}
|
||
export function incrementUatCount(basePath, mid, sid) {
|
||
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;
|
||
}
|
||
// ─── 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) {
|
||
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,
|
||
);
|
||
}
|
||
export function extractValidationAttentionPlan(validationContent) {
|
||
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, mid) {
|
||
return join(
|
||
sfRoot(basePath),
|
||
"runtime",
|
||
"validation-attention",
|
||
`${mid}.json`,
|
||
);
|
||
}
|
||
function parseValidationRemediationRound(content) {
|
||
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;
|
||
}
|
||
function readValidationAttentionMarker(basePath, mid) {
|
||
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;
|
||
}
|
||
}
|
||
function writeValidationAttentionMarker(basePath, mid, marker) {
|
||
mkdirSync(join(sfRoot(basePath), "runtime", "validation-attention"), {
|
||
recursive: true,
|
||
});
|
||
writeFileSync(
|
||
validationAttentionMarkerPath(basePath, mid),
|
||
JSON.stringify(marker, null, 2) + "\n",
|
||
"utf-8",
|
||
);
|
||
}
|
||
function validationAttentionRuntimePath(basePath, mid) {
|
||
return join(
|
||
sfRoot(basePath),
|
||
"runtime",
|
||
"units",
|
||
`rewrite-docs-${mid}-validation-attention.json`,
|
||
);
|
||
}
|
||
function hasActiveValidationAttentionMarker(basePath, mid) {
|
||
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;
|
||
}
|
||
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;
|
||
}
|
||
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, "``\\`");
|
||
return `You are executing SF autonomous 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."`;
|
||
}
|
||
|
||
// ─── Memory-Enhanced Dispatch ─────────────────────────────────────────────
|
||
/**
|
||
* Enhance unit ranking with memory-learned patterns.
|
||
*
|
||
* Purpose: Improve dispatch decisions by boosting units that match learned
|
||
* patterns from previous successful executions. Degrades gracefully if memory
|
||
* unavailable.
|
||
*
|
||
* Consumer: Dispatch rules for unit prioritization.
|
||
*/
|
||
export async function enhanceUnitRankingWithMemory(units, baseScores = {}) {
|
||
if (!isMemoryDbAvailable()) {
|
||
// No memory available, return original ranking
|
||
return units;
|
||
}
|
||
|
||
try {
|
||
const enhanced = [];
|
||
|
||
for (const unit of units) {
|
||
const baseScore = baseScores[unit.id] ?? 0.5;
|
||
let memoryBoost = 0;
|
||
|
||
try {
|
||
// Query memory for patterns matching this unit type.
|
||
const unitType = unit.type || unit.unitType || "unknown";
|
||
const memories = await getRelevantMemoriesRanked(
|
||
`dispatch pattern ${unitType}`,
|
||
3,
|
||
);
|
||
const pattern = memories.find((memory) =>
|
||
["pattern", undefined, null].includes(memory.category),
|
||
);
|
||
|
||
if (pattern) {
|
||
// Boost by highest confidence pattern, scaled down for caution.
|
||
memoryBoost = pattern.confidence * 0.15;
|
||
}
|
||
} catch {
|
||
// Degrade gracefully - memory lookup failure doesn't block dispatch
|
||
}
|
||
|
||
enhanced.push({
|
||
...unit,
|
||
score: baseScore + memoryBoost,
|
||
memoryBoost,
|
||
});
|
||
}
|
||
|
||
// Return sorted by score (highest first)
|
||
return enhanced.sort((a, b) => b.score - a.score);
|
||
} catch {
|
||
// Degrade gracefully - return original units if anything fails
|
||
return units;
|
||
}
|
||
}
|
||
|
||
// ─── Rules ────────────────────────────────────────────────────────────────
|
||
export const DISPATCH_RULES = [
|
||
{
|
||
name: "schedule auto-dispatch",
|
||
match: async ({ basePath }) => {
|
||
try {
|
||
const store = createScheduleStore(basePath);
|
||
const due = store.findDue("project", new Date());
|
||
const autoDispatch = due.filter(isAutoDispatchScheduleEntry);
|
||
if (autoDispatch.length === 0) return null;
|
||
|
||
const entry = autoDispatch[0];
|
||
if (entry.kind === "command") {
|
||
const result = executeProjectScheduleCommand(basePath, entry);
|
||
if (result.ok) {
|
||
return {
|
||
action: "skip",
|
||
reason: `[schedule] executed command ${entry.id}`,
|
||
};
|
||
}
|
||
return {
|
||
action: "stop",
|
||
reason: `[schedule] command ${entry.id} failed: ${result.reason}`,
|
||
level: "warning",
|
||
};
|
||
}
|
||
|
||
markProjectScheduleDone(basePath, entry, {
|
||
result_note: "prompt dispatched",
|
||
});
|
||
return {
|
||
action: "dispatch",
|
||
unitType: "custom-step",
|
||
unitId: `schedule/${entry.id}`,
|
||
prompt: buildScheduledPrompt(entry),
|
||
};
|
||
} catch {
|
||
// Non-fatal: never block dispatch on schedule store errors
|
||
return null;
|
||
}
|
||
},
|
||
},
|
||
{
|
||
// ADR-011 Phase 2 (SF ADR): mid-execution escalation handling.
|
||
// Autonomous mode is autonomous, so by default we accept the agent's
|
||
// recommendation and continue — the user can review/override later via
|
||
// `/sf escalate list --all`. Set `phases.escalation_auto_accept: false`
|
||
// to keep SF'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",
|
||
"autonomous mode: accepted agent recommendation; user can override via /sf escalate",
|
||
"autonomous mode",
|
||
);
|
||
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" +
|
||
"You are running in SF autonomous 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" +
|
||
"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.\nReview 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",
|
||
reason: `${mid}: ${contradiction}. Regenerate structured roadmap state before dispatching autonomous mode work.`,
|
||
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,
|
||
),
|
||
};
|
||
},
|
||
},
|
||
{
|
||
// SF 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 validation = await readMilestoneValidationForDispatch(
|
||
basePath,
|
||
mid,
|
||
);
|
||
if (!validation && isDbAvailable()) {
|
||
return {
|
||
action: "stop",
|
||
reason: `Cannot complete milestone ${mid}: DB has no milestone-validation assessment row. Runtime does not fall back to VALIDATION.md when .sf/sf.db is available; run validate-milestone or sf recover.`,
|
||
level: "error",
|
||
};
|
||
}
|
||
if (validation) {
|
||
const validationContent = validation.content;
|
||
if (validation.verdict) {
|
||
const verdict = validation.verdict;
|
||
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: validation.path,
|
||
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",
|
||
};
|
||
},
|
||
},
|
||
];
|
||
|
||
import { getRegistry, hasRegistry } from "./rule-registry.js";
|
||
|
||
// ─── 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) {
|
||
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.
|
||
}
|
||
}
|
||
// ─── 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).
|
||
*/
|
||
function applyUokRuntimeGuard(ctx, dispatchResult) {
|
||
if (dispatchResult.action !== "dispatch") return dispatchResult;
|
||
const { basePath } = ctx;
|
||
const { unitType, unitId } = dispatchResult;
|
||
if (!unitType || !unitId) return dispatchResult;
|
||
const record = readUnitRuntimeRecord(basePath, unitType, unitId);
|
||
const decision = decideUnitRuntimeDispatch(record);
|
||
if (decision.action === "dispatch" || decision.action === "retry") {
|
||
return dispatchResult;
|
||
}
|
||
if (decision.action === "block") {
|
||
return {
|
||
action: "stop",
|
||
reason: `UOK runtime guard blocked ${unitType} ${unitId}: ${decision.reasonCode} (retry ${decision.retryCount}/${decision.maxRetries})`,
|
||
level: "warning",
|
||
matchedRule: dispatchResult.matchedRule,
|
||
};
|
||
}
|
||
// skip or notify — treat as skip so the loop re-derives state
|
||
return { action: "skip", matchedRule: dispatchResult.matchedRule };
|
||
}
|
||
export async function resolveDispatch(ctx) {
|
||
// 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 autonomous 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 {
|
||
const result = await getRegistry().evaluateDispatch(ctx);
|
||
const guarded = applyUokRuntimeGuard(ctx, result);
|
||
emitDispatchEnvelope(ctx, guarded);
|
||
return guarded;
|
||
} 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;
|
||
const guarded = applyUokRuntimeGuard(ctx, result);
|
||
emitDispatchEnvelope(ctx, guarded);
|
||
return guarded;
|
||
}
|
||
}
|
||
// 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;
|
||
}
|
||
/** Exposed for testing — returns the rule names in evaluation order. */
|
||
export function getDispatchRuleNames() {
|
||
if (hasRegistry()) {
|
||
return getRegistry()
|
||
.listRules()
|
||
.filter((rule) => rule.when === "dispatch")
|
||
.map((rule) => rule.name);
|
||
}
|
||
return DISPATCH_RULES.map((r) => r.name);
|
||
}
|