1476 lines
48 KiB
TypeScript
1476 lines
48 KiB
TypeScript
/**
|
||
* Auto-mode Dispatch Table — declarative phase → unit mapping.
|
||
*
|
||
* Each rule maps a SF state to the unit type, unit ID, and prompt builder
|
||
* that should be dispatched. Rules are evaluated in order; the first match wins.
|
||
*
|
||
* This replaces the 130-line if-else chain in dispatchNextUnit with a
|
||
* data structure that is inspectable, testable per-rule, and extensible
|
||
* without modifying orchestration code.
|
||
*/
|
||
|
||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||
import { join } from "node:path";
|
||
import {
|
||
buildCompleteMilestonePrompt,
|
||
buildCompleteSlicePrompt,
|
||
buildDiscussMilestonePrompt,
|
||
buildExecuteTaskPrompt,
|
||
buildGateEvaluatePrompt,
|
||
buildParallelResearchSlicesPrompt,
|
||
buildPlanMilestonePrompt,
|
||
buildPlanSlicePrompt,
|
||
buildReactiveExecutePrompt,
|
||
buildReassessRoadmapPrompt,
|
||
buildReplanSlicePrompt,
|
||
buildResearchMilestonePrompt,
|
||
buildResearchSlicePrompt,
|
||
buildRewriteDocsPrompt,
|
||
buildRunUatPrompt,
|
||
buildValidateMilestonePrompt,
|
||
checkNeedsReassessment,
|
||
checkNeedsRunUat,
|
||
} from "./auto-prompts.js";
|
||
import { hasImplementationArtifacts } from "./auto-recovery.js";
|
||
import {
|
||
getExecuteTaskInstructionConflict,
|
||
skipExecuteTaskForInstructionConflict,
|
||
} from "./execution-instruction-guard.js";
|
||
import {
|
||
extractUatType,
|
||
loadActiveOverrides,
|
||
loadFile,
|
||
parseDeferredRequirements,
|
||
resolveAllOverrides,
|
||
} from "./files.js";
|
||
import { getMilestonePipelineVariant } from "./milestone-scope-classifier.js";
|
||
import { parseRoadmap } from "./parsers-legacy.js";
|
||
import {
|
||
buildMilestoneFileName,
|
||
relSliceFile,
|
||
resolveMilestoneFile,
|
||
resolveMilestonePath,
|
||
resolveSliceFile,
|
||
resolveTaskFile,
|
||
sfRoot,
|
||
} from "./paths.js";
|
||
import type { SFPreferences } from "./preferences.js";
|
||
import { resolveModelWithFallbacksForUnit } from "./preferences-models.js";
|
||
import {
|
||
getMilestone,
|
||
getMilestoneSlices,
|
||
getPendingGates,
|
||
getSliceTasks,
|
||
isDbAvailable,
|
||
markAllGatesOmitted,
|
||
} from "./sf-db.js";
|
||
import type { SFState } from "./types.js";
|
||
import { selectReactiveDispatchBatch } from "./uok/execution-graph.js";
|
||
import { resolveUokFlags } from "./uok/flags.js";
|
||
import { EXECUTION_ENTRY_PHASES } from "./uok/plan-v2.js";
|
||
import { extractVerdict, isAcceptableUatVerdict } from "./verdict-parser.js";
|
||
import { logError, logWarning } from "./workflow-logger.js";
|
||
|
||
const MAX_PARALLEL_RESEARCH_SLICES = 8;
|
||
|
||
// ─── Types ────────────────────────────────────────────────────────────────
|
||
|
||
export type DispatchAction =
|
||
| {
|
||
action: "dispatch";
|
||
unitType: string;
|
||
unitId: string;
|
||
prompt: string;
|
||
pauseAfterDispatch?: boolean;
|
||
/** Name of the matched dispatch rule from the unified registry (journal provenance). */
|
||
matchedRule?: string;
|
||
}
|
||
| {
|
||
action: "stop";
|
||
reason: string;
|
||
level: "info" | "warning" | "error";
|
||
matchedRule?: string;
|
||
}
|
||
| { action: "skip"; matchedRule?: string };
|
||
|
||
export interface DispatchContext {
|
||
basePath: string;
|
||
mid: string;
|
||
midTitle: string;
|
||
state: SFState;
|
||
prefs: SFPreferences | undefined;
|
||
session?: import("./auto/session.js").AutoSession;
|
||
/** Cached pipeline variant for this dispatch cycle — set once by resolveDispatch. */
|
||
pipelineVariant?: string | null;
|
||
}
|
||
|
||
export interface DispatchRule {
|
||
/** Human-readable name for debugging and test identification */
|
||
name: string;
|
||
/** Return a DispatchAction if this rule matches, null to fall through */
|
||
match: (ctx: DispatchContext) => Promise<DispatchAction | null>;
|
||
}
|
||
|
||
function missingSliceStop(mid: string, phase: string): DispatchAction {
|
||
return {
|
||
action: "stop",
|
||
reason: `${mid}: phase "${phase}" has no active slice — run /sf doctor.`,
|
||
level: "error",
|
||
};
|
||
}
|
||
|
||
export function formatTaskCompleteFailurePrompt(reason: string): string {
|
||
return `sf_task_complete failed: ${reason}. Try the call again, or investigate the write path.`;
|
||
}
|
||
|
||
function prependTaskCompleteFailurePrompt(
|
||
session: DispatchContext["session"] | undefined,
|
||
unitId: string,
|
||
prompt: string,
|
||
): string {
|
||
const reason = session?.pendingTaskCompleteFailures?.get(unitId);
|
||
if (!reason) return prompt;
|
||
return `${formatTaskCompleteFailurePrompt(reason)}\n\n${prompt}`;
|
||
}
|
||
|
||
function isMilestonePlanRepairState(state: SFState): boolean {
|
||
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: string, mid: string): string[] {
|
||
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: string): string {
|
||
return join(sfRoot(basePath), "runtime", "rewrite-count.json");
|
||
}
|
||
|
||
export function getRewriteCount(basePath: string): number {
|
||
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: string, count: number): void {
|
||
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: string, mid: string, sid: string): string {
|
||
return join(sfRoot(basePath), "runtime", `uat-count-${mid}-${sid}.json`);
|
||
}
|
||
|
||
export function getUatCount(
|
||
basePath: string,
|
||
mid: string,
|
||
sid: string,
|
||
): number {
|
||
try {
|
||
const data = JSON.parse(
|
||
readFileSync(uatCountPath(basePath, mid, sid), "utf-8"),
|
||
);
|
||
return typeof data.count === "number" ? data.count : 0;
|
||
} catch {
|
||
return 0;
|
||
}
|
||
}
|
||
|
||
export function incrementUatCount(
|
||
basePath: string,
|
||
mid: string,
|
||
sid: string,
|
||
): number {
|
||
const count = getUatCount(basePath, mid, sid) + 1;
|
||
const filePath = uatCountPath(basePath, mid, sid);
|
||
mkdirSync(join(sfRoot(basePath), "runtime"), { recursive: true });
|
||
writeFileSync(
|
||
filePath,
|
||
JSON.stringify({ count, updatedAt: new Date().toISOString() }) + "\n",
|
||
);
|
||
return count;
|
||
}
|
||
|
||
// ─── Helpers ─────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Returns true when the verification_operational value indicates that no
|
||
* operational verification is needed. Covers common phrasings the planning
|
||
* agent may use: "None", "None required", "N/A", "Not applicable", etc.
|
||
*
|
||
* @see https://github.com/singularity-forge/sf-run/issues/2931
|
||
*/
|
||
export function isVerificationNotApplicable(value: string): boolean {
|
||
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: string,
|
||
): string | null {
|
||
const explicit = validationContent.match(
|
||
/^## Remediation Plan\s*\n([\s\S]*?)(?=\n## |\s*$)/m,
|
||
);
|
||
if (explicit?.[1]?.trim()) return explicit[1].trim();
|
||
|
||
const followUp = validationContent.match(
|
||
/^## Follow[- ]Up Items[^\n]*\n([\s\S]*?)(?=\n## |\s*$)/im,
|
||
);
|
||
if (followUp?.[1]?.trim()) return followUp[1].trim();
|
||
|
||
const tracking = validationContent.match(
|
||
/^\*\*Tracking issues:\*\*\s*\n([\s\S]*?)(?=\n## |\n\*\*|\s*$)/m,
|
||
);
|
||
if (tracking?.[1]?.trim()) return tracking[1].trim();
|
||
|
||
return null;
|
||
}
|
||
|
||
function validationAttentionMarkerPath(basePath: string, mid: string): string {
|
||
return join(
|
||
sfRoot(basePath),
|
||
"runtime",
|
||
"validation-attention",
|
||
`${mid}.json`,
|
||
);
|
||
}
|
||
|
||
function parseValidationRemediationRound(content: string): number | null {
|
||
const match = content.match(/^remediation_round:\s*(\d+)\s*$/m);
|
||
if (!match) return null;
|
||
const round = Number.parseInt(match[1]!, 10);
|
||
return Number.isFinite(round) ? round : null;
|
||
}
|
||
|
||
interface ValidationAttentionMarker {
|
||
milestoneId?: string;
|
||
createdAt?: string;
|
||
source?: string;
|
||
remediationRound?: number | null;
|
||
revalidationRound?: number;
|
||
revalidationRequestedAt?: string;
|
||
}
|
||
|
||
function readValidationAttentionMarker(
|
||
basePath: string,
|
||
mid: string,
|
||
): ValidationAttentionMarker | null {
|
||
const markerPath = validationAttentionMarkerPath(basePath, mid);
|
||
if (!existsSync(markerPath)) return null;
|
||
try {
|
||
const parsed = JSON.parse(readFileSync(markerPath, "utf-8")) as unknown;
|
||
if (!parsed || typeof parsed !== "object") return null;
|
||
return parsed as ValidationAttentionMarker;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function writeValidationAttentionMarker(
|
||
basePath: string,
|
||
mid: string,
|
||
marker: ValidationAttentionMarker,
|
||
): void {
|
||
mkdirSync(join(sfRoot(basePath), "runtime", "validation-attention"), {
|
||
recursive: true,
|
||
});
|
||
writeFileSync(
|
||
validationAttentionMarkerPath(basePath, mid),
|
||
JSON.stringify(marker, null, 2) + "\n",
|
||
"utf-8",
|
||
);
|
||
}
|
||
|
||
function validationAttentionRuntimePath(basePath: string, mid: string): string {
|
||
return join(
|
||
sfRoot(basePath),
|
||
"runtime",
|
||
"units",
|
||
`rewrite-docs-${mid}-validation-attention.json`,
|
||
);
|
||
}
|
||
|
||
function hasActiveValidationAttentionMarker(
|
||
basePath: string,
|
||
mid: string,
|
||
): boolean {
|
||
const markerPath = validationAttentionMarkerPath(basePath, mid);
|
||
if (!existsSync(markerPath)) return false;
|
||
if (existsSync(validationAttentionRuntimePath(basePath, mid))) return true;
|
||
logWarning(
|
||
"dispatch",
|
||
`ignoring stale validation attention marker for ${mid}: remediation unit was never recorded`,
|
||
);
|
||
return false;
|
||
}
|
||
|
||
function shouldDispatchValidationAttentionRevalidation(
|
||
basePath: string,
|
||
mid: string,
|
||
validationContent: string,
|
||
): boolean {
|
||
if (!hasActiveValidationAttentionMarker(basePath, mid)) return false;
|
||
const marker = readValidationAttentionMarker(basePath, mid);
|
||
if (marker?.milestoneId && marker.milestoneId !== mid) return false;
|
||
|
||
const currentRound = parseValidationRemediationRound(validationContent);
|
||
if (currentRound === null) return false;
|
||
const originalRound =
|
||
typeof marker?.remediationRound === "number" ? marker.remediationRound : -1;
|
||
if (currentRound <= originalRound) return false;
|
||
if (marker?.revalidationRound === currentRound) return false;
|
||
|
||
writeValidationAttentionMarker(basePath, mid, {
|
||
...marker,
|
||
milestoneId: mid,
|
||
revalidationRound: currentRound,
|
||
revalidationRequestedAt: new Date().toISOString(),
|
||
});
|
||
return true;
|
||
}
|
||
|
||
function buildValidationAttentionRemediationPrompt(
|
||
mid: string,
|
||
midTitle: string,
|
||
basePath: string,
|
||
validationContent: string,
|
||
attentionPlan: string,
|
||
): string {
|
||
const validationRel = `.sf/milestones/${mid}/${mid}-VALIDATION.md`;
|
||
const escapedValidation = validationContent.replace(/```/g, "``\\`");
|
||
const escapedPlan = attentionPlan.replace(/```/g, "``\\`");
|
||
return `You are executing SF auto-mode.
|
||
|
||
## UNIT: Resolve Validation Attention for ${mid} ("${midTitle}")
|
||
|
||
SF validation returned \`needs-attention\`. Automatic milestone completion is blocked until the findings are addressed or explicitly deferred and validation is run again.
|
||
|
||
## Working Directory
|
||
|
||
Your working directory is \`${basePath}\`. All file reads and writes MUST operate relative to this directory.
|
||
|
||
## Actionable Attention Plan
|
||
|
||
\`\`\`md
|
||
${escapedPlan}
|
||
\`\`\`
|
||
|
||
## Current Validation Artifact
|
||
|
||
\`\`\`md
|
||
${escapedValidation}
|
||
\`\`\`
|
||
|
||
## Required Work
|
||
|
||
1. Apply the attention plan to the relevant SF tracking artifacts and project docs. Prefer narrow edits to roadmap, context, requirements, slice summaries, UAT notes, and validation evidence. Only edit product code when the finding is a real implementation defect.
|
||
2. Preserve historical records, but make the current milestone state internally consistent.
|
||
3. If a finding cannot be completed in this environment, explicitly defer it with the concrete reason, required environment, and follow-up owner/artifact.
|
||
4. Do not mark validation as pass yourself.
|
||
5. After applying the remediation, edit \`${validationRel}\` frontmatter to set \`verdict: needs-remediation\` and increment \`remediation_round\` by 1. Leave the body intact or add a short note that the attention plan was applied. This forces SF to run a fresh validate-milestone unit next.
|
||
|
||
When done, say: "Validation attention remediated; ready for revalidation."`;
|
||
}
|
||
|
||
// ─── Rules ────────────────────────────────────────────────────────────────
|
||
|
||
export const DISPATCH_RULES: DispatchRule[] = [
|
||
{
|
||
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 auto-mode. Do not call `ask_user_questions`, " +
|
||
"do not wait for a human reply, and do not end with open questions. " +
|
||
"Use existing project artifacts as the user's durable input. If `" +
|
||
mid +
|
||
"-CONTEXT.md` contains roadmap/alignment decisions, treat them as approved.\n\n" +
|
||
"Before any detailed planning, establish:\n" +
|
||
"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" as const,
|
||
reason: `run-uat for ${mid}/${sliceId} has been dispatched ${attempts - 1} times without producing a verdict. Verification commands may be broken — fix the UAT spec or manually write an ASSESSMENT verdict.`,
|
||
level: "warning" as const,
|
||
};
|
||
}
|
||
const uatFile = resolveSliceFile(basePath, mid, sliceId, "UAT")!;
|
||
const uatContent = await loadFile(uatFile);
|
||
return {
|
||
action: "dispatch",
|
||
unitType: "run-uat",
|
||
unitId: `${mid}/${sliceId}`,
|
||
prompt: await buildRunUatPrompt(
|
||
mid,
|
||
sliceId,
|
||
relSliceFile(basePath, mid, sliceId, "UAT"),
|
||
uatContent ?? "",
|
||
basePath,
|
||
),
|
||
pauseAfterDispatch:
|
||
!process.env.SF_HEADLESS &&
|
||
uatType !== "artifact-driven" &&
|
||
uatType !== "browser-executable" &&
|
||
uatType !== "runtime-executable",
|
||
};
|
||
},
|
||
},
|
||
{
|
||
name: "uat-verdict-gate (non-PASS blocks progression)",
|
||
match: async ({ mid, basePath, prefs }) => {
|
||
// Only applies when UAT dispatch is enabled
|
||
if (!prefs?.uat_dispatch) return null;
|
||
|
||
const _roadmapFile = resolveMilestoneFile(basePath, mid, "ROADMAP");
|
||
|
||
// DB-first: get completed slices from DB
|
||
let completedSliceIds: string[];
|
||
if (isDbAvailable()) {
|
||
completedSliceIds = getMilestoneSlices(mid)
|
||
.filter((s) => s.status === "complete")
|
||
.map((s) => s.id);
|
||
} else {
|
||
return null;
|
||
}
|
||
|
||
const uatChecks = await Promise.all(
|
||
completedSliceIds.map(async (sliceId) => {
|
||
const resultFile = resolveSliceFile(basePath, mid, sliceId, "UAT");
|
||
if (!resultFile) return null;
|
||
const content = await loadFile(resultFile);
|
||
if (!content) return null;
|
||
return {
|
||
sliceId,
|
||
verdict: extractVerdict(content),
|
||
uatType: extractUatType(content),
|
||
};
|
||
}),
|
||
);
|
||
for (const check of uatChecks) {
|
||
if (!check) continue;
|
||
if (
|
||
check.verdict &&
|
||
!isAcceptableUatVerdict(check.verdict, check.uatType)
|
||
) {
|
||
return {
|
||
action: "stop" as const,
|
||
reason: `UAT verdict for ${check.sliceId} is "${check.verdict}" — blocking progression until resolved.\nReview the UAT result and update the verdict to PASS, or re-run /sf auto after fixing.`,
|
||
level: "warning" as const,
|
||
};
|
||
}
|
||
}
|
||
return null;
|
||
},
|
||
},
|
||
{
|
||
name: "reassess-roadmap (post-completion)",
|
||
match: async ({ state, mid, midTitle, basePath, prefs }) => {
|
||
if (prefs?.phases?.skip_reassess) return null;
|
||
// Default reassess_after_slice to false per ADR-003 §4 — most reassess
|
||
// units conclude "roadmap is fine" and burn a session for no change.
|
||
// The plan-slice prompt now carries a reassessment preamble so the
|
||
// next slice's planner does JIT roadmap verification at zero extra
|
||
// cost. Opt-in via explicit `reassess_after_slice: true` (e.g.
|
||
// burn-max profile) when you want the dedicated reassess session.
|
||
const reassessEnabled = prefs?.phases?.reassess_after_slice ?? false;
|
||
if (!reassessEnabled) return null;
|
||
const needsReassess = await checkNeedsReassessment(
|
||
basePath,
|
||
mid,
|
||
state,
|
||
prefs,
|
||
);
|
||
if (!needsReassess) return null;
|
||
return {
|
||
action: "dispatch",
|
||
unitType: "reassess-roadmap",
|
||
unitId: `${mid}/${needsReassess.sliceId}`,
|
||
prompt: await buildReassessRoadmapPrompt(
|
||
mid,
|
||
midTitle,
|
||
needsReassess.sliceId,
|
||
basePath,
|
||
),
|
||
};
|
||
},
|
||
},
|
||
{
|
||
name: "needs-discussion → discuss-milestone",
|
||
match: async ({ state, mid, midTitle, basePath }) => {
|
||
if (state.phase !== "needs-discussion") return null;
|
||
return {
|
||
action: "dispatch",
|
||
unitType: "discuss-milestone",
|
||
unitId: mid,
|
||
prompt: await buildDiscussMilestonePrompt(mid, midTitle, basePath),
|
||
};
|
||
},
|
||
},
|
||
{
|
||
// #4671 — Recovery for execution-entry phases with missing CONTEXT.md.
|
||
// Once deriveStateFromDb returns an execution-entry phase the pre-planning
|
||
// guard no longer fires. The plan-v2 gate detects missing context but can
|
||
// only block — it cannot redispatch. Without this rule the milestone is
|
||
// stuck until `sf doctor heal`. Fire BEFORE execution-entry phase rules.
|
||
name: "execution-entry phase (no context) → discuss-milestone",
|
||
match: async ({ state, mid, midTitle, basePath }) => {
|
||
if (!EXECUTION_ENTRY_PHASES.has(state.phase)) return null;
|
||
const contextFile = resolveMilestoneFile(basePath, mid, "CONTEXT");
|
||
const contextContent = contextFile ? await loadFile(contextFile) : null;
|
||
const hasContext = !!(contextContent && contextContent.trim().length > 0);
|
||
if (hasContext) return null;
|
||
return {
|
||
action: "dispatch",
|
||
unitType: "discuss-milestone",
|
||
unitId: mid,
|
||
prompt: await buildDiscussMilestonePrompt(mid, midTitle, basePath),
|
||
};
|
||
},
|
||
},
|
||
{
|
||
name: "pre-planning (no context) → discuss-milestone",
|
||
match: async ({ state, mid, midTitle, basePath }) => {
|
||
if (state.phase !== "pre-planning") return null;
|
||
const contextFile = resolveMilestoneFile(basePath, mid, "CONTEXT");
|
||
const hasContext = !!(contextFile && (await loadFile(contextFile)));
|
||
if (hasContext) return null; // fall through to next rule
|
||
return {
|
||
action: "dispatch",
|
||
unitType: "discuss-milestone",
|
||
unitId: mid,
|
||
prompt: await buildDiscussMilestonePrompt(mid, midTitle, basePath),
|
||
};
|
||
},
|
||
},
|
||
{
|
||
name: "pre-planning (no research) → research-milestone",
|
||
match: async ({
|
||
state,
|
||
mid,
|
||
midTitle,
|
||
basePath,
|
||
prefs,
|
||
pipelineVariant,
|
||
}) => {
|
||
if (state.phase !== "pre-planning") return null;
|
||
// Phase skip: skip research when preference or profile says so
|
||
if (prefs?.phases?.skip_research) return null;
|
||
// #4781 phase 2: trivial-scope milestones skip dedicated milestone research
|
||
if (pipelineVariant === "trivial") return null;
|
||
const researchFile = resolveMilestoneFile(basePath, mid, "RESEARCH");
|
||
if (researchFile) return null; // has research, fall through
|
||
return {
|
||
action: "dispatch",
|
||
unitType: "research-milestone",
|
||
unitId: mid,
|
||
prompt: await buildResearchMilestonePrompt(mid, midTitle, basePath),
|
||
};
|
||
},
|
||
},
|
||
{
|
||
name: "pre-planning (has research) → plan-milestone",
|
||
match: async ({ state, mid, midTitle, basePath }) => {
|
||
if (state.phase !== "pre-planning") return null;
|
||
return {
|
||
action: "dispatch",
|
||
unitType: "plan-milestone",
|
||
unitId: mid,
|
||
prompt: await buildPlanMilestonePrompt(mid, midTitle, basePath),
|
||
};
|
||
},
|
||
},
|
||
{
|
||
name: "planning (roadmap incomplete) → plan-milestone",
|
||
match: async ({ state, mid, midTitle, basePath }) => {
|
||
if (!isMilestonePlanRepairState(state)) return null;
|
||
return {
|
||
action: "dispatch",
|
||
unitType: "plan-milestone",
|
||
unitId: mid,
|
||
prompt: await buildPlanMilestonePrompt(mid, midTitle, basePath),
|
||
};
|
||
},
|
||
},
|
||
{
|
||
// Keep this rule before the single-slice research rule so the multi-slice
|
||
// path wins whenever 2+ slices are ready.
|
||
name: "planning (multiple slices need research) → parallel-research-slices",
|
||
match: async ({
|
||
state,
|
||
mid,
|
||
midTitle,
|
||
basePath,
|
||
prefs,
|
||
pipelineVariant,
|
||
}) => {
|
||
if (state.phase !== "planning") return null;
|
||
if (prefs?.phases?.skip_research || prefs?.phases?.skip_slice_research)
|
||
return null;
|
||
// #4781 phase 2: trivial-scope milestones skip dedicated slice research
|
||
if (pipelineVariant === "trivial") return null;
|
||
|
||
// Load roadmap to find all slices
|
||
const roadmapFile = resolveMilestoneFile(basePath, mid, "ROADMAP");
|
||
const roadmapContent = roadmapFile ? await loadFile(roadmapFile) : null;
|
||
if (!roadmapContent) return null;
|
||
const roadmap = parseRoadmap(roadmapContent);
|
||
|
||
// Find slices that need research (no RESEARCH file, dependencies done)
|
||
const milestoneResearchFile = resolveMilestoneFile(
|
||
basePath,
|
||
mid,
|
||
"RESEARCH",
|
||
);
|
||
const researchReadySlices: Array<{ id: string; title: string }> = [];
|
||
|
||
// Pre-compute which slices have SUMMARY files to avoid O(N×M) existsSync calls
|
||
const slicesWithSummary = new Set(
|
||
roadmap.slices
|
||
.filter((s) => !!resolveSliceFile(basePath, mid, s.id, "SUMMARY"))
|
||
.map((s) => s.id),
|
||
);
|
||
|
||
for (const slice of roadmap.slices) {
|
||
if (slice.done) continue;
|
||
// Skip S01 when milestone research exists
|
||
if (milestoneResearchFile && slice.id === "S01") continue;
|
||
// Skip if already has research
|
||
if (resolveSliceFile(basePath, mid, slice.id, "RESEARCH")) continue;
|
||
// Skip if dependencies aren't done (check for SUMMARY files)
|
||
const depsComplete = (slice.depends ?? []).every((depId) =>
|
||
slicesWithSummary.has(depId),
|
||
);
|
||
if (!depsComplete) continue;
|
||
|
||
researchReadySlices.push({ id: slice.id, title: slice.title });
|
||
}
|
||
|
||
// Only dispatch parallel if 2+ slices are ready
|
||
if (researchReadySlices.length < 2) return null;
|
||
if (researchReadySlices.length > MAX_PARALLEL_RESEARCH_SLICES)
|
||
return null;
|
||
|
||
// #4414: If a previous parallel-research attempt escalated to a blocker
|
||
// placeholder, skip this rule and fall through to per-slice research
|
||
// (or other rules) rather than re-dispatching the same failing unit.
|
||
const parallelBlocker = resolveMilestoneFile(
|
||
basePath,
|
||
mid,
|
||
"PARALLEL-BLOCKER",
|
||
);
|
||
if (parallelBlocker) return null;
|
||
|
||
return {
|
||
action: "dispatch",
|
||
unitType: "research-slice",
|
||
unitId: `${mid}/parallel-research`,
|
||
prompt: await buildParallelResearchSlicesPrompt(
|
||
mid,
|
||
midTitle,
|
||
researchReadySlices,
|
||
basePath,
|
||
resolveModelWithFallbacksForUnit("subagent")?.primary,
|
||
),
|
||
};
|
||
},
|
||
},
|
||
{
|
||
name: "planning (no research, not S01) → research-slice",
|
||
match: async ({
|
||
state,
|
||
mid,
|
||
midTitle,
|
||
basePath,
|
||
prefs,
|
||
pipelineVariant,
|
||
}) => {
|
||
if (state.phase !== "planning") return null;
|
||
// Phase skip: skip research when preference or profile says so
|
||
if (prefs?.phases?.skip_research || prefs?.phases?.skip_slice_research)
|
||
return null;
|
||
// #4781 phase 2: trivial-scope milestones skip dedicated slice research
|
||
if (pipelineVariant === "trivial") return null;
|
||
if (!state.activeSlice) return missingSliceStop(mid, state.phase);
|
||
const sid = state.activeSlice!.id;
|
||
const sTitle = state.activeSlice!.title;
|
||
const researchFile = resolveSliceFile(basePath, mid, sid, "RESEARCH");
|
||
if (researchFile) return null; // has research, fall through
|
||
// Skip slice research for S01 when milestone research already exists —
|
||
// the milestone research already covers the same ground for the first slice.
|
||
const milestoneResearchFile = resolveMilestoneFile(
|
||
basePath,
|
||
mid,
|
||
"RESEARCH",
|
||
);
|
||
if (milestoneResearchFile && sid === "S01") return null; // fall through to plan-slice
|
||
return {
|
||
action: "dispatch",
|
||
unitType: "research-slice",
|
||
unitId: `${mid}/${sid}`,
|
||
prompt: await buildResearchSlicePrompt(
|
||
mid,
|
||
midTitle,
|
||
sid,
|
||
sTitle,
|
||
basePath,
|
||
),
|
||
};
|
||
},
|
||
},
|
||
{
|
||
name: "planning → plan-slice",
|
||
match: async ({ state, mid, midTitle, basePath }) => {
|
||
if (state.phase !== "planning") return null;
|
||
if (!state.activeSlice) return missingSliceStop(mid, state.phase);
|
||
const sid = state.activeSlice!.id;
|
||
const sTitle = state.activeSlice!.title;
|
||
return {
|
||
action: "dispatch",
|
||
unitType: "plan-slice",
|
||
unitId: `${mid}/${sid}`,
|
||
prompt: await buildPlanSlicePrompt(
|
||
mid,
|
||
midTitle,
|
||
sid,
|
||
sTitle,
|
||
basePath,
|
||
),
|
||
};
|
||
},
|
||
},
|
||
{
|
||
name: "evaluating-gates → gate-evaluate",
|
||
match: async ({ state, mid, midTitle, basePath, prefs }) => {
|
||
if (state.phase !== "evaluating-gates") return null;
|
||
if (!state.activeSlice) return missingSliceStop(mid, state.phase);
|
||
const sid = state.activeSlice.id;
|
||
const sTitle = state.activeSlice.title;
|
||
|
||
// Gate evaluation is opt-in via preferences
|
||
const gateConfig = prefs?.gate_evaluation;
|
||
if (!gateConfig?.enabled) {
|
||
markAllGatesOmitted(mid, sid);
|
||
return { action: "skip" };
|
||
}
|
||
|
||
const pending = getPendingGates(mid, sid, "slice");
|
||
if (pending.length === 0) return { action: "skip" };
|
||
|
||
return {
|
||
action: "dispatch",
|
||
unitType: "gate-evaluate",
|
||
unitId: `${mid}/${sid}/gates+${pending.map((g) => g.gate_id).join(",")}`,
|
||
prompt: await buildGateEvaluatePrompt(
|
||
mid,
|
||
midTitle,
|
||
sid,
|
||
sTitle,
|
||
basePath,
|
||
resolveModelWithFallbacksForUnit("subagent")?.primary,
|
||
),
|
||
};
|
||
},
|
||
},
|
||
{
|
||
name: "replanning-slice → replan-slice",
|
||
match: async ({ state, mid, midTitle, basePath }) => {
|
||
if (state.phase !== "replanning-slice") return null;
|
||
if (!state.activeSlice) return missingSliceStop(mid, state.phase);
|
||
const sid = state.activeSlice!.id;
|
||
const sTitle = state.activeSlice!.title;
|
||
return {
|
||
action: "dispatch",
|
||
unitType: "replan-slice",
|
||
unitId: `${mid}/${sid}`,
|
||
prompt: await buildReplanSlicePrompt(
|
||
mid,
|
||
midTitle,
|
||
sid,
|
||
sTitle,
|
||
basePath,
|
||
),
|
||
};
|
||
},
|
||
},
|
||
{
|
||
name: "executing → reactive-execute (parallel dispatch)",
|
||
match: async ({ state, mid, midTitle, basePath, prefs }) => {
|
||
if (state.phase !== "executing" || !state.activeTask) return null;
|
||
if (!state.activeSlice) return null; // fall through
|
||
|
||
// Only activate when reactive_execution is explicitly enabled
|
||
const reactiveConfig = prefs?.reactive_execution;
|
||
if (!reactiveConfig?.enabled) return null;
|
||
|
||
const sid = state.activeSlice.id;
|
||
const sTitle = state.activeSlice.title;
|
||
const maxParallel = reactiveConfig.max_parallel ?? 2;
|
||
const subagentModel =
|
||
reactiveConfig.subagent_model ??
|
||
resolveModelWithFallbacksForUnit("subagent")?.primary;
|
||
|
||
// Dry-run mode: max_parallel=1 means graph is derived and logged but
|
||
// execution remains sequential
|
||
if (maxParallel <= 1) return null;
|
||
|
||
try {
|
||
const {
|
||
loadSliceTaskIO,
|
||
deriveTaskGraph,
|
||
isGraphAmbiguous,
|
||
getReadyTasks,
|
||
chooseNonConflictingSubset,
|
||
graphMetrics,
|
||
saveReactiveState,
|
||
} = await import("./reactive-graph.js");
|
||
|
||
const taskIO = await loadSliceTaskIO(basePath, mid, sid);
|
||
if (taskIO.length < 2) return null; // single task, no point
|
||
|
||
const graph = deriveTaskGraph(taskIO);
|
||
|
||
// Ambiguous graph → fall through to sequential
|
||
if (isGraphAmbiguous(graph)) return null;
|
||
|
||
const completed = new Set(graph.filter((n) => n.done).map((n) => n.id));
|
||
const readyIds = getReadyTasks(graph, completed, new Set());
|
||
|
||
// Only activate reactive dispatch when >1 task is ready
|
||
if (readyIds.length <= 1) return null;
|
||
|
||
const uokFlags = resolveUokFlags(prefs);
|
||
const selected = uokFlags.executionGraph
|
||
? selectReactiveDispatchBatch({
|
||
graph,
|
||
readyIds,
|
||
maxParallel,
|
||
inFlightOutputs: new Set(),
|
||
}).selected
|
||
: chooseNonConflictingSubset(readyIds, graph, maxParallel, new Set());
|
||
if (selected.length <= 1) return null;
|
||
|
||
// Log graph metrics for observability
|
||
const metrics = graphMetrics(graph);
|
||
process.stderr.write(
|
||
`sf-reactive: ${mid}/${sid} graph — tasks:${metrics.taskCount} edges:${metrics.edgeCount} ` +
|
||
`ready:${metrics.readySetSize} dispatching:${selected.length} ambiguous:${metrics.ambiguous}\n`,
|
||
);
|
||
|
||
// Persist dispatched batch so verification and recovery can check
|
||
// exactly which tasks were sent.
|
||
saveReactiveState(basePath, mid, sid, {
|
||
sliceId: sid,
|
||
completed: [...completed],
|
||
dispatched: selected,
|
||
graphSnapshot: metrics,
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
// Encode selected task IDs in unitId for artifact verification.
|
||
// Format: M001/S01/reactive+T02,T03
|
||
const batchSuffix = selected.join(",");
|
||
|
||
return {
|
||
action: "dispatch",
|
||
unitType: "reactive-execute",
|
||
unitId: `${mid}/${sid}/reactive+${batchSuffix}`,
|
||
prompt: await buildReactiveExecutePrompt(
|
||
mid,
|
||
midTitle,
|
||
sid,
|
||
sTitle,
|
||
selected,
|
||
basePath,
|
||
subagentModel,
|
||
),
|
||
};
|
||
} catch (err) {
|
||
// Non-fatal — fall through to sequential execution
|
||
logError("dispatch", "reactive graph derivation failed", {
|
||
error: (err as Error).message,
|
||
});
|
||
return null;
|
||
}
|
||
},
|
||
},
|
||
{
|
||
name: "executing → execute-task (recover missing task plan → plan-slice)",
|
||
match: async ({ state, mid, midTitle, basePath }) => {
|
||
if (state.phase !== "executing" || !state.activeTask) return null;
|
||
if (!state.activeSlice) return missingSliceStop(mid, state.phase);
|
||
const sid = state.activeSlice!.id;
|
||
const sTitle = state.activeSlice!.title;
|
||
const tid = state.activeTask.id;
|
||
|
||
// Guard: if the slice plan exists but the individual task plan files are
|
||
// missing, the planner created S##-PLAN.md with task entries but never
|
||
// wrote the tasks/ directory files. Dispatch plan-slice to regenerate
|
||
// them rather than hard-stopping — fixes the infinite-loop described in
|
||
// issue #909.
|
||
const taskPlanPath = resolveTaskFile(basePath, mid, sid, tid, "PLAN");
|
||
if (!taskPlanPath || !existsSync(taskPlanPath)) {
|
||
return {
|
||
action: "dispatch",
|
||
unitType: "plan-slice",
|
||
unitId: `${mid}/${sid}`,
|
||
prompt: await buildPlanSlicePrompt(
|
||
mid,
|
||
midTitle,
|
||
sid,
|
||
sTitle,
|
||
basePath,
|
||
),
|
||
};
|
||
}
|
||
|
||
return null;
|
||
},
|
||
},
|
||
{
|
||
name: "executing → prior-task verification all-fail guard",
|
||
match: async ({ state, mid }) => {
|
||
if (state.phase !== "executing" || !state.activeTask) return null;
|
||
if (!state.activeSlice) return null;
|
||
if (!isDbAvailable()) return null;
|
||
const sid = state.activeSlice.id;
|
||
const tid = state.activeTask.id;
|
||
const sliceTasks = getSliceTasks(mid, sid);
|
||
const sortedTasks = sliceTasks.sort(
|
||
(a, b) =>
|
||
(a.sequence ?? 0) - (b.sequence ?? 0) || a.id.localeCompare(b.id),
|
||
);
|
||
const currentIdx = sortedTasks.findIndex((t) => t.id === tid);
|
||
if (currentIdx > 0) {
|
||
const priorTask = sortedTasks[currentIdx - 1];
|
||
if (priorTask?.verification_status === "all_fail") {
|
||
return {
|
||
action: "stop",
|
||
reason: `Task ${priorTask.id} in slice ${sid} had all verification checks fail — stopping before dispatching ${tid}. Fix verification in the prior task or re-run it.`,
|
||
level: "error",
|
||
};
|
||
}
|
||
}
|
||
return null;
|
||
},
|
||
},
|
||
{
|
||
name: "executing → execute-task",
|
||
match: async ({ state, mid, basePath, session }) => {
|
||
if (state.phase !== "executing" || !state.activeTask) return null;
|
||
if (!state.activeSlice) return missingSliceStop(mid, state.phase);
|
||
const sid = state.activeSlice!.id;
|
||
const sTitle = state.activeSlice!.title;
|
||
const tid = state.activeTask.id;
|
||
const tTitle = state.activeTask.title;
|
||
const unitId = `${mid}/${sid}/${tid}`;
|
||
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 skipSource = trivialVariant
|
||
? "trivial-scope pipeline variant (#4781)"
|
||
: "`skip_milestone_validation` preference";
|
||
if (prefs?.phases?.skip_milestone_validation || trivialVariant) {
|
||
const mDir = resolveMilestonePath(basePath, mid);
|
||
if (mDir) {
|
||
if (!existsSync(mDir)) mkdirSync(mDir, { recursive: true });
|
||
const validationPath = join(
|
||
mDir,
|
||
buildMilestoneFileName(mid, "VALIDATION"),
|
||
);
|
||
const content = [
|
||
"---",
|
||
"verdict: pass",
|
||
"remediation_round: 0",
|
||
"---",
|
||
"",
|
||
"# Milestone Validation (skipped)",
|
||
"",
|
||
`Milestone validation was skipped via ${skipSource}.`,
|
||
].join("\n");
|
||
writeFileSync(validationPath, content, "utf-8");
|
||
}
|
||
return { action: "skip" };
|
||
}
|
||
return {
|
||
action: "dispatch",
|
||
unitType: "validate-milestone",
|
||
unitId: mid,
|
||
prompt: await buildValidateMilestonePrompt(mid, midTitle, basePath),
|
||
};
|
||
},
|
||
},
|
||
{
|
||
name: "completing-milestone → complete-milestone",
|
||
match: async ({ state, mid, midTitle, basePath }) => {
|
||
if (state.phase !== "completing-milestone") return null;
|
||
|
||
// 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" as const,
|
||
reason: `Milestone ${mid} has planned operational verification ("${milestone.verification_operational.substring(0, 100)}") but the validation output does not address it. Re-run validation with verification class awareness, or update the validation to document operational compliance.`,
|
||
level: "warning" as const,
|
||
};
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} catch (err) {
|
||
/* fall through — don't block on DB errors */
|
||
logWarning(
|
||
"dispatch",
|
||
`verification class check failed: ${err instanceof Error ? err.message : String(err)}`,
|
||
);
|
||
}
|
||
|
||
// P5-A: Advisory check for deferred requirements targeting this milestone
|
||
try {
|
||
const deferred = parseDeferredRequirements(basePath);
|
||
const unaddressed = deferred.filter((r) => r.deferredTo === mid);
|
||
if (unaddressed.length > 0) {
|
||
const ids = unaddressed.map((r) => r.id).join(", ");
|
||
logWarning(
|
||
"dispatch",
|
||
`Milestone ${mid} has ${unaddressed.length} deferred requirement(s) (${ids}) that were not validated. Review before completing.`,
|
||
);
|
||
}
|
||
} catch {
|
||
// Non-fatal advisory
|
||
}
|
||
|
||
return {
|
||
action: "dispatch",
|
||
unitType: "complete-milestone",
|
||
unitId: mid,
|
||
prompt: await buildCompleteMilestonePrompt(mid, midTitle, basePath),
|
||
};
|
||
},
|
||
},
|
||
{
|
||
name: "complete → stop",
|
||
match: async ({ state }) => {
|
||
if (state.phase !== "complete") return null;
|
||
return {
|
||
action: "stop",
|
||
reason: "All milestones complete.",
|
||
level: "info",
|
||
};
|
||
},
|
||
},
|
||
];
|
||
|
||
import { getRegistry, hasRegistry } from "./rule-registry.js";
|
||
|
||
// ─── 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: DispatchContext,
|
||
): Promise<DispatchAction> {
|
||
// Fetch pipeline variant once per dispatch cycle so rules can read ctx.pipelineVariant
|
||
// without triggering redundant DB queries + heuristic evaluations.
|
||
if (ctx.pipelineVariant === undefined) {
|
||
ctx.pipelineVariant = await getMilestonePipelineVariant(ctx.mid);
|
||
}
|
||
|
||
// Delegate to registry when available. Callers that run outside auto-mode
|
||
// (e.g. `sf headless query`, `sf headless status`) never initialize the
|
||
// registry — falling through to inline rules is the intended behavior,
|
||
// not an error, so we silent-probe instead of warning on every call.
|
||
if (hasRegistry()) {
|
||
try {
|
||
return await getRegistry().evaluateDispatch(ctx);
|
||
} catch (err) {
|
||
// Genuine registry evaluation failure (rule threw, etc.) — log so we
|
||
// surface real bugs, then fall back.
|
||
logWarning(
|
||
"dispatch",
|
||
`registry dispatch failed, falling back to inline rules: ${err instanceof Error ? err.message : String(err)}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
for (const rule of DISPATCH_RULES) {
|
||
const result = await rule.match(ctx);
|
||
if (result) {
|
||
if (result.action !== "skip") result.matchedRule = rule.name;
|
||
return result;
|
||
}
|
||
}
|
||
|
||
// No rule matched — unhandled phase.
|
||
// Use level "warning" so the loop pauses (resumable) instead of hard-stopping.
|
||
// Hard-stop here was causing premature termination for transient phase gaps
|
||
// (e.g. after reassessment modifies the roadmap and state needs re-derivation).
|
||
return {
|
||
action: "stop",
|
||
reason: `Unhandled phase "${ctx.state.phase}" — run /sf doctor to diagnose.`,
|
||
level: "warning",
|
||
matchedRule: "<no-match>",
|
||
};
|
||
}
|
||
|
||
/** Exposed for testing — returns the rule names in evaluation order. */
|
||
export function getDispatchRuleNames(): string[] {
|
||
if (hasRegistry()) {
|
||
return getRegistry()
|
||
.listRules()
|
||
.filter((rule) => rule.when === "dispatch")
|
||
.map((rule) => rule.name);
|
||
}
|
||
return DISPATCH_RULES.map((r) => r.name);
|
||
}
|