/** * Post-unit processing for handleAgentEnd — auto-commit, doctor run, * state rebuild, worktree sync, DB dual-write, hooks, triage, and * quick-task dispatch. * * Split into two functions called sequentially by handleAgentEnd with * the verification gate between them: * 1. postUnitPreVerification() — commit, doctor, state rebuild, worktree sync, artifact verification * 2. postUnitPostVerification() — DB dual-write, hooks, triage, quick-tasks * * Extracted from handleAgentEnd() in auto.ts. */ import { detectAbandonMilestone } from "./abandon-detect.js"; import { delay } from "./atomic-write.js"; import { resolveExpectedArtifactPath as resolveArtifactForContent } from "./auto-artifact-paths.js"; import { diagnoseExpectedArtifact, resolveExpectedArtifactPath, verifyExpectedArtifact, writeBlockerPlaceholder, } from "./auto-recovery.js"; import { isDeterministicPolicyError } from "./auto-tool-tracking.js"; import { runSafely } from "./auto-utils.js"; import { syncStateToProjectRoot } from "./auto-worktree.js"; import { invalidateAllCaches } from "./cache.js"; import { hasPendingCaptures, loadPendingCaptures, revertExecutorResolvedCaptures, } from "./captures.js"; import { ensureCodebaseMapFresh } from "./codebase-generator.js"; import { debugLog } from "./debug-logger.js"; import { rebuildState } from "./doctor.js"; import { loadFile, parseSummary, resolveAllOverrides } from "./files.js"; import { buildTaskCommitMessage, createGitService, runTurnGitAction, } from "./git-service.js"; import { renderPlanCheckboxes } from "./markdown-renderer.js"; import { buildTaskFileName, resolveMilestoneFile, resolveSliceFile, resolveSlicePath, resolveTaskFile, resolveTasksDir, } from "./paths.js"; import { checkPostUnitHooks, consumeRetryTrigger, isRetryPending, persistHookState, resolveHookArtifactPath, } from "./post-unit-hooks.js"; import { runPreExecutionChecks } from "./pre-execution-checks.js"; import { loadEffectiveSFPreferences } from "./preferences.js"; import { loadPrompt } from "./prompt-loader.js"; // crossReferenceEvidence available for future use when verification_evidence is stored in DB // import { crossReferenceEvidence, type ClaimedEvidence } from "./safety/evidence-cross-ref.js"; import { validateContent } from "./safety/content-validator.js"; import { clearEvidenceFromDisk, getEvidence, } from "./safety/evidence-collector.js"; import { validateFileChanges, validateStagedFileChanges, } from "./safety/file-change-validator.js"; import { resolveSafetyHarnessConfig } from "./safety/safety-harness.js"; import { recordSelfFeedback } from "./self-feedback.js"; import { consumeSignal } from "./session-status-io.js"; import { _getAdapter, getMilestone, getSlice, getSliceTasks, getTask, isDbAvailable, updateSliceStatus, updateTaskStatus, } from "./sf-db.js"; import { deriveState } from "./state.js"; import { parseUnitId } from "./unit-id.js"; import { closeoutUnit } from "./uok/auto-unit-closeout.js"; import { resolveUokFlags } from "./uok/flags.js"; import { UokGateRunner } from "./uok/gate-runner.js"; import { resolveParitySafeGitAction, writeTurnGitTransaction, } from "./uok/gitops.js"; import { captureGitopsDiff, getParityCommitBlockReason, isParityCommitBlocked, legacyGitopsDecision, } from "./uok/parity-diff-capture.js"; import { isAwaitingUserInput } from "./user-input-boundary.js"; import { writePreExecutionEvidence } from "./verification-evidence.js"; import { logError, logWarning } from "./workflow-logger.js"; import { regenerateIfMissing } from "./workflow-projections.js"; /** Maximum verification retry attempts before escalating to blocker placeholder (#2653). */ const MAX_VERIFICATION_RETRIES = 3; function isCompletedTaskStatus(status) { return status === "complete" || status === "done"; } function taskCompleteFailureForCurrentUnit(s) { if (!s.currentUnit || s.currentUnit.type !== "execute-task") return null; const failure = s.lastTaskCompleteFailure; if (!failure || failure.unitId !== s.currentUnit.id) return null; const { milestone: mid, slice: sid, task: tid, } = parseUnitId(s.currentUnit.id); if (!mid || !sid || !tid) return failure.reason; const dbTask = getTask(mid, sid, tid); if (dbTask && isCompletedTaskStatus(dbTask.status)) { s.pendingTaskCompleteFailures.delete(s.currentUnit.id); s.lastTaskCompleteFailure = null; return null; } return failure.reason; } function clearTaskCompleteFailureForCurrentUnit(s) { if (!s.currentUnit) return; s.pendingTaskCompleteFailures.delete(s.currentUnit.id); if (s.lastTaskCompleteFailure?.unitId === s.currentUnit.id) { s.lastTaskCompleteFailure = null; } } /** Enqueue a sidecar item (hook, triage, or quick-task) for the main loop to * drain via runUnit. Logs the enqueue event and notifies the UI. */ function enqueueSidecar(s, ctx, entry, debugExtra, notification) { s.sidecarQueue.push(entry); debugLog("postUnitPostVerification", { phase: "sidecar-enqueue", kind: entry.kind, unitId: entry.unitId, ...debugExtra, }); if (notification) ctx.ui.notify(notification, "info"); return "continue"; } /** Unit types that only touch `.sf/` internal state files (no code changes). * Auto-commit is skipped for these — their state files are picked up by the * next actual task commit via `smartStage()`. */ const LIFECYCLE_ONLY_UNITS = new Set([ "research-milestone", "discuss-milestone", "discuss-slice", "plan-milestone", "validate-milestone", "research-slice", "plan-slice", "replan-slice", "complete-slice", "run-uat", "reassess-roadmap", "rewrite-docs", ]); import { existsSync, unlinkSync } from "node:fs"; import { join } from "node:path"; import { getAutoSession } from "./auto/session.js"; import { describeNextUnit } from "./auto-dashboard.js"; import { _resetHasChangesCache } from "./native-git-bridge.js"; import { autoCommitCurrentBranch } from "./worktree.js"; import { getErrorMessage } from "./error-utils.js"; /** * Detect summary files written directly to disk without the LLM calling * the completion tool. A "rogue" file is one that exists on disk but has * no corresponding DB row with status "complete". * * This is a safety-net diagnostic (D003). The existing migrateFromMarkdown() * in postUnitPostVerification() eventually ingests rogue files, but explicit * detection provides immediate diagnostics so operators know the prompt failed. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any function hasNonEmptyFields(row, fields) { if (!row) return false; return fields.some((f) => String(row[f] || "").trim().length > 0); } const MILESTONE_PLANNING_FIELDS = [ "title", "vision", "requirement_coverage", "boundary_map_markdown", ]; const SLICE_PLANNING_FIELDS = ["title", "demo", "risk", "depends"]; export function detectRogueFileWrites(unitType, unitId, basePath) { if (!isDbAvailable()) return []; const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId); const rogues = []; if (unitType === "execute-task") { if (!mid || !sid || !tid) return []; const summaryPath = resolveTaskFile(basePath, mid, sid, tid, "SUMMARY"); if (!summaryPath || !existsSync(summaryPath)) return []; const dbRow = getTask(mid, sid, tid); if (!dbRow || dbRow.status !== "complete") { rogues.push({ path: summaryPath, unitType, unitId }); } } else if (unitType === "complete-slice") { if (!mid || !sid) return []; const summaryPath = resolveSliceFile(basePath, mid, sid, "SUMMARY"); if (!summaryPath || !existsSync(summaryPath)) return []; const dbRow = getSlice(mid, sid); if (!dbRow || dbRow.status !== "complete") { // Auto-remediate: SUMMARY exists on disk but DB is stale — sync DB to // match filesystem instead of reporting as rogue (#3633). try { updateSliceStatus(mid, sid, "complete", new Date().toISOString()); } catch { // If DB update fails, fall back to rogue detection so the issue is visible rogues.push({ path: summaryPath, unitType, unitId }); } } } else if (unitType === "plan-milestone") { if (!mid) return []; const roadmapPath = resolveMilestoneFile(basePath, mid, "ROADMAP"); if (!roadmapPath || !existsSync(roadmapPath)) return []; const dbRow = getMilestone(mid); const hasPlanningState = hasNonEmptyFields( dbRow, MILESTONE_PLANNING_FIELDS, ); if (!hasPlanningState) { rogues.push({ path: roadmapPath, unitType, unitId }); } } else if (unitType === "plan-slice" || unitType === "replan-slice") { if (!mid || !sid) return []; const planPath = resolveSliceFile(basePath, mid, sid, "PLAN"); if (!planPath || !existsSync(planPath)) return []; const dbRow = getSlice(mid, sid); const hasPlanningState = hasNonEmptyFields(dbRow, SLICE_PLANNING_FIELDS); if (!hasPlanningState) { rogues.push({ path: planPath, unitType, unitId }); } // Also check for rogue REPLAN.md const replanPath = resolveSliceFile(basePath, mid, sid, "REPLAN"); if (replanPath && existsSync(replanPath) && !hasPlanningState) { rogues.push({ path: replanPath, unitType, unitId }); } } else if (unitType === "reassess-roadmap") { if (!mid || !sid) return []; const assessPath = resolveSliceFile(basePath, mid, sid, "ASSESSMENT"); if (!assessPath || !existsSync(assessPath)) return []; // Assessment file exists on disk — check if DB knows about it via the artifacts table const adapter = _getAdapter(); if (adapter) { const row = adapter .prepare( `SELECT 1 FROM artifacts WHERE path LIKE :pattern AND artifact_type = 'ASSESSMENT' LIMIT 1`, ) .get({ ":pattern": `%${sid}-ASSESSMENT.md` }); if (!row) { rogues.push({ path: assessPath, unitType, unitId }); } } } else if (unitType === "plan-task") { if (!mid || !sid || !tid) return []; const taskPlanPath = resolveTaskFile(basePath, mid, sid, tid, "PLAN"); if (!taskPlanPath || !existsSync(taskPlanPath)) return []; const dbRow = getTask(mid, sid, tid); if (!dbRow) { rogues.push({ path: taskPlanPath, unitType, unitId }); } } return rogues; } export const STEP_COMPLETE_FALLBACK_MESSAGE = "Step complete. Run /clear, then /to continue (or /autonomous to run continuously)."; export function buildStepCompleteMessage(nextState) { if (nextState.phase === "complete") { return "Step complete — milestone finished. Run /status to review, or start the next milestone."; } const next = describeNextUnit(nextState); return ( `Step complete. Next: ${next.label}\n` + `Run /clear, then /to continue (or /autonomous to run continuously).` ); } export const USER_DRIVEN_DEEP_UNITS = new Set([ "discuss-project", "discuss-requirements", "discuss-milestone", "research-decision", ]); export { isAwaitingUserInput } from "./user-input-boundary.js"; export async function autoCommitUnit(basePath, unitType, unitId, ctx) { try { let taskContext; if (unitType === "execute-task") { const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId); if (mid && sid && tid) { const summaryPath = resolveTaskFile(basePath, mid, sid, tid, "SUMMARY"); if (summaryPath) { try { const summaryContent = await loadFile(summaryPath); if (summaryContent) { const summary = parseSummary(summaryContent); let ghIssueNumber; try { const { getTaskIssueNumberForCommit } = await import( "../github-sync/sync.js" ); ghIssueNumber = getTaskIssueNumberForCommit(basePath, mid, sid, tid) ?? undefined; } catch (err) { logWarning( "engine", `GitHub issue lookup failed: ${getErrorMessage(err)}`, ); } taskContext = { taskId: `${sid}/${tid}`, taskTitle: summary.title?.replace(/^T\d+:\s*/, "") || tid, oneLiner: summary.oneLiner || undefined, keyFiles: summary.frontmatter.key_files?.filter( (f) => !f.includes("{{"), ) || undefined, issueNumber: ghIssueNumber, }; } } catch (e) { debugLog("postUnit", { phase: "task-summary-parse", error: String(e), }); } } } } _resetHasChangesCache(); if (LIFECYCLE_ONLY_UNITS.has(unitType)) { return null; } const sessionId = getAutoSession().cmdCtx?.sessionManager?.getSessionId?.() ?? null; const commitMsg = autoCommitCurrentBranch( basePath, unitType, unitId, taskContext, sessionId, ); if (commitMsg) { ctx?.ui.notify(`Committed: ${commitMsg.split("\n")[0]}`, "info"); } return commitMsg; } catch (e) { debugLog("postUnit", { phase: "auto-commit", error: String(e) }); ctx?.ui.notify( `Auto-commit failed: ${String(e).split("\n")[0]}`, "warning", ); return null; } } /** * Pre-verification processing: parallel worker signal check, cache invalidation, * auto-commit, doctor run, state rebuild, worktree sync, artifact verification. * * Returns: * - "dispatched" — a signal caused stop/pause * - "continue" — proceed normally * - "retry" — artifact verification failed, s.pendingVerificationRetry set for loop re-iteration */ export async function postUnitPreVerification(pctx, opts) { const { s, ctx, pi, buildSnapshotOpts: _buildSnapshotOpts, stopAuto, pauseAuto, } = pctx; // ── Parallel worker signal check ── const milestoneLock = process.env.SF_MILESTONE_LOCK; if (milestoneLock) { const signal = consumeSignal(s.basePath, milestoneLock); if (signal) { if (signal.signal === "stop") { await stopAuto(ctx, pi); return "dispatched"; } if (signal.signal === "pause") { await pauseAuto(ctx, pi); return "dispatched"; } } } // Invalidate all caches invalidateAllCaches(); // Small delay to let files settle (skipped for sidecars where latency matters more) if (!opts?.skipSettleDelay) { await delay(100); } const prefs = loadEffectiveSFPreferences()?.preferences; const uokFlags = resolveUokFlags(prefs); // Turn-level git action (commit | snapshot | status-only) if (s.currentUnit) { const unit = s.currentUnit; const configuredTurnAction = uokFlags.gitops ? uokFlags.gitopsTurnAction : "commit"; const traceId = s.currentTraceId ?? `turn:${unit.startedAt}`; const turnId = s.currentTurnId ?? `${unit.type}/${unit.id}/${unit.startedAt}`; const parityGitAction = uokFlags.gitops ? captureGitopsDiff({ basePath: s.basePath, sessionId: traceId, turnId, legacy: legacyGitopsDecision("commit", false), uok: { action: configuredTurnAction, push: uokFlags.gitopsTurnPush, }, }).effectiveAction : configuredTurnAction; const safeTurnGit = resolveParitySafeGitAction({ action: parityGitAction, push: uokFlags.gitopsTurnPush, status: "ok", }); const turnAction = safeTurnGit.action; s.lastGitActionFailure = null; s.lastGitActionStatus = null; try { let taskContext; if (turnAction === "commit" && s.currentUnit.type === "execute-task") { const { milestone: mid, slice: sid, task: tid, } = parseUnitId(s.currentUnit.id); if (mid && sid && tid) { const summaryPath = resolveTaskFile( s.basePath, mid, sid, tid, "SUMMARY", ); if (summaryPath) { try { const summaryContent = await loadFile(summaryPath); if (summaryContent) { const summary = parseSummary(summaryContent); // Look up GitHub issue number for commit linking let ghIssueNumber; try { const { getTaskIssueNumberForCommit } = await import( "../github-sync/sync.js" ); ghIssueNumber = getTaskIssueNumberForCommit(s.basePath, mid, sid, tid) ?? undefined; } catch (err) { // GitHub sync not available — skip logWarning( "engine", `GitHub issue lookup failed: ${getErrorMessage(err)}`, ); } taskContext = { taskId: `${sid}/${tid}`, taskTitle: summary.title?.replace(/^T\d+:\s*/, "") || tid, oneLiner: summary.oneLiner || undefined, keyFiles: summary.frontmatter.key_files?.filter( (f) => !f.includes("{{"), ) || undefined, issueNumber: ghIssueNumber, }; } } catch (e) { debugLog("postUnit", { phase: "task-summary-parse", error: String(e), }); } } } } // Invalidate the nativeHasChanges cache before auto-commit (#1853). // The cache has a 10-second TTL and is keyed by basePath. A stale // `false` result causes autoCommit to skip staging entirely, leaving // code files only in the working tree where they are destroyed by // `git worktree remove --force` during teardown. _resetHasChangesCache(); const skipLifecycleCommit = turnAction === "commit" && LIFECYCLE_ONLY_UNITS.has(s.currentUnit.type); if (skipLifecycleCommit) { debugLog("postUnit", { phase: "git-action-skipped", reason: "lifecycle-only-unit", unitType: s.currentUnit.type, unitId: s.currentUnit.id, }); } else if ( turnAction === "commit" && s.currentUnit.type === "execute-task" ) { // Fix 1 deferral: stage changes now (before verification), commit after // verification passes in postUnitPostVerification. This ensures the git // index captures all file changes before the verification gate, while the // git history object is only created once the unit is confirmed complete. try { const git = createGitService(s.basePath); const staged = git.stageOnly([], taskContext?.keyFiles ?? []); // Last-line-of-defense: check if any .sf/ paths slipped into staging. // Both nativeAddPaths and stageExplicitIncludePaths filter .sf/ paths, but // this catches anything that bypassed those barriers (e.g. manual git add). validateStagedFileChanges(s.basePath); if (staged) { s.stagedPendingCommit = true; s.pendingCommitTaskContext = taskContext ?? null; debugLog("postUnit", { phase: "defer-stage", status: "ok", unitType: s.currentUnit.type, unitId: s.currentUnit.id, }); } else { // Nothing to stage — no pending commit needed debugLog("postUnit", { phase: "defer-stage", status: "nothing-to-stage", unitType: s.currentUnit.type, unitId: s.currentUnit.id, }); } s.lastGitActionStatus = "ok"; } catch (stageErr) { const stageErrMsg = getErrorMessage(stageErr); s.lastGitActionFailure = stageErrMsg; s.lastGitActionStatus = "failed"; debugLog("postUnit", { phase: "defer-stage-error", error: stageErrMsg, }); ctx.ui.notify( `Git stage failed: ${stageErrMsg.split("\n")[0]}`, "warning", ); // Record as self-feedback so future runs can drain it from the // backlog. Empty-pathspec failures are low-severity (the upstream // guard in nativeAddPaths now no-ops; if we still hit this branch // the cause is something else worth flagging at medium). const isEmptyPathspec = /\(none\)|add -- failed|empty pathspec/i.test( stageErrMsg, ); recordSelfFeedback( { kind: isEmptyPathspec ? "git-empty-pathspec" : "git-stage-failure", severity: isEmptyPathspec ? "low" : "medium", summary: `git stage failed during postUnit: ${stageErrMsg.split("\n")[0]}`, evidence: stageErrMsg, source: "detector", }, s.basePath, ); } } else { const gitResult = runTurnGitAction({ basePath: s.basePath, action: turnAction, unitType: s.currentUnit.type, unitId: s.currentUnit.id, taskContext, }); if (uokFlags.gitops) { writeTurnGitTransaction({ basePath: s.basePath, traceId, turnId, unitType: unit.type, unitId: unit.id, stage: "publish", action: turnAction, push: uokFlags.gitopsTurnPush, status: gitResult.status, error: gitResult.error, metadata: { dirty: gitResult.dirty, commitMessage: gitResult.commitMessage, snapshotLabel: gitResult.snapshotLabel, }, }); } if (gitResult.status === "failed") { s.lastGitActionFailure = gitResult.error ?? `git ${turnAction} failed`; s.lastGitActionStatus = "failed"; if (uokFlags.gitops && uokFlags.gates) { const parsed = parseUnitId(unit.id); const gateRunner = new UokGateRunner(); gateRunner.register({ id: "closeout-git-action", type: "closeout", execute: async () => ({ outcome: "fail", failureClass: "git", rationale: `turn git action "${turnAction}" failed`, findings: gitResult.error ?? "unknown git failure", }), }); await gateRunner.run("closeout-git-action", { basePath: s.basePath, traceId, turnId, milestoneId: parsed.milestone ?? undefined, sliceId: parsed.slice ?? undefined, taskId: parsed.task ?? undefined, unitType: unit.type, unitId: unit.id, }); } const failureMsg = `Git ${turnAction} failed: ${(gitResult.error ?? "unknown error").split("\n")[0]}`; if (uokFlags.gitops) { ctx.ui.notify(failureMsg, "error"); await pauseAuto(ctx, pi); return "dispatched"; } ctx.ui.notify(failureMsg, "warning"); debugLog("postUnit", { phase: "git-action-failed-nonblocking", action: turnAction, error: gitResult.error ?? "unknown error", }); } s.lastGitActionStatus = "ok"; if (turnAction === "commit" && gitResult.commitMessage) { ctx.ui.notify( `Committed: ${gitResult.commitMessage.split("\n")[0]}`, "info", ); } else if (turnAction === "snapshot" && gitResult.snapshotLabel) { ctx.ui.notify( `Snapshot recorded: ${gitResult.snapshotLabel}`, "info", ); } } } catch (e) { const message = getErrorMessage(e); s.lastGitActionFailure = message; s.lastGitActionStatus = "failed"; debugLog("postUnit", { phase: "git-action", error: message, action: turnAction, }); ctx.ui.notify( `Git ${turnAction} failed: ${message.split("\n")[0]}`, uokFlags.gitops ? "error" : "warning", ); if (uokFlags.gitops) { await pauseAuto(ctx, pi); return "dispatched"; } } // GitHub sync (non-blocking, opt-in) await runSafely("postUnit", "github-sync", async () => { const { runGitHubSync } = await import("../github-sync/sync.js"); await runGitHubSync(s.basePath, unit.type, unit.id); }); // Prune dead bg-shell processes await runSafely("postUnit", "prune-bg-shell", async () => { const { pruneDeadProcesses } = await import( "../bg-shell/process-manager.js" ); pruneDeadProcesses(); }); // Tear down browser between units to prevent Chrome process accumulation (#1733) await runSafely("postUnit", "browser-teardown", async () => { const { getBrowser } = await import("../browser-tools/state.js"); if (getBrowser()) { const { closeBrowser } = await import("../browser-tools/lifecycle.js"); await closeBrowser(); debugLog("postUnit", { phase: "browser-teardown", status: "closed" }); } }); // Keep the on-disk STATE.md aligned with the live derived state after // ordinary unit completion, before any worktree state is synced back. await runSafely("postUnit", "state-rebuild", async () => { await rebuildState(s.basePath); }); // Sync worktree state back to project root (skipped for lightweight sidecars) if ( !opts?.skipWorktreeSync && s.originalBasePath && s.originalBasePath !== s.basePath ) { await runSafely("postUnit", "worktree-sync", () => { syncStateToProjectRoot( s.basePath, s.originalBasePath, s.currentMilestoneId, ); }); } // Rewrite-docs completion if (s.currentUnit.type === "rewrite-docs") { await runSafely("postUnit", "rewrite-docs-resolve", async () => { // Detect abandon/descope overrides BEFORE resolving them (#3490). // If an override is about abandoning the milestone, park it so the // state engine skips it. Without this, rewrite-docs only edits // markdown but the DB still has the milestone as active. try { const { loadActiveOverrides } = await import("./files.js"); const overrides = await loadActiveOverrides(s.basePath); const decision = detectAbandonMilestone( overrides, s.currentMilestoneId, ); if (decision.shouldPark && s.currentMilestoneId) { const { parkMilestone } = await import("./milestone-actions.js"); const parked = parkMilestone( s.basePath, s.currentMilestoneId, decision.reason, ); if (parked) { ctx.ui.notify( `Milestone ${s.currentMilestoneId} parked: "${decision.reason}"`, "info", ); } else { // Park refused: milestone directory missing, milestone already // completed (SUMMARY present), or PARKED.md already exists. // resolveAllOverrides below will still consume the override — // surface this loudly so the user notices state drift rather // than silently losing the abandon directive. const msg = `Abandon detected for ${s.currentMilestoneId} but park refused (milestone is completed, already parked, or missing). Override will be resolved anyway — verify state is correct.`; logError("engine", msg); ctx.ui.notify(msg, "warning"); } } } catch (err) { logError("engine", `abandon-detect failed: ${err.message}`); ctx.ui.notify( `Abandon detection failed — check logs. Overrides will still be resolved.`, "warning", ); } await resolveAllOverrides(s.basePath); // Reset both disk and in-memory counters. Disk counter is authoritative // (survives restarts); in-memory is kept in sync for the current session. const { setRewriteCount } = await import("./uok/auto-dispatch.js"); setRewriteCount(s.basePath, 0); s.rewriteAttemptCount = 0; ctx.ui.notify("Override(s) resolved — rewrite-docs completed.", "info"); }); } // Reactive state cleanup on slice completion if (s.currentUnit.type === "complete-slice") { await runSafely("postUnit", "reactive-state-cleanup", async () => { const { milestone: mid, slice: sid } = parseUnitId(unit.id); if (mid && sid) { const { clearReactiveState } = await import("./reactive-graph.js"); clearReactiveState(s.basePath, mid, sid); } }); } // #4765 — slice-cadence collapse. When `git.collapse_cadence: "slice"` // is set, squash-merge the slice's commits from the milestone branch // onto main right here, so orphan risk shrinks from milestone-size to // slice-size. Only runs in worktree isolation mode — the feature needs // a milestone branch to squash from. let sliceMergeStopped = false; await runSafely("postUnit", "slice-cadence-merge", async () => { const prefsResult = loadEffectiveSFPreferences(); const prefs = prefsResult?.preferences; const { getCollapseCadence, mergeSliceToMain } = await import( "./slice-cadence.js" ); if (getCollapseCadence(prefs) !== "slice") return; if (prefs?.git?.isolation !== "worktree") return; if (s.isolationDegraded) return; const projectRoot = s.originalBasePath || s.basePath; const { milestone: mid, slice: sid } = parseUnitId(unit.id); if (!mid || !sid) return; // Record the milestone start SHA before the first slice merge, so // resquashMilestoneOnMain has a target at milestone completion. // Resolve main branch dynamically — hard-coding "main" breaks repos // that use "master" or a custom default branch. if (!s.milestoneStartShas.has(mid)) { try { const { nativeDetectMainBranch } = await import( "./native-git-bridge.js" ); const mainBranch = nativeDetectMainBranch(projectRoot); const { execFileSync } = await import("node:child_process"); const sha = execFileSync("git", ["rev-parse", mainBranch], { cwd: projectRoot, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8", }).trim(); if (sha) s.milestoneStartShas.set(mid, sha); } catch (err) { logWarning( "engine", `slice-cadence: failed to record milestone start SHA: ${getErrorMessage(err)}`, ); } } try { const result = mergeSliceToMain(projectRoot, mid, sid); if (result.skipped) { logWarning( "engine", `slice-cadence: merge skipped for ${sid} — ${result.skippedReason}`, ); return; } ctx.ui.notify( `slice-cadence: ${sid} merged to main (${result.durationMs}ms).`, "info", ); } catch (err) { const { MergeConflictError } = await import("./git-service.js"); if (err instanceof MergeConflictError) { ctx.ui.notify( `slice-cadence merge conflict in ${sid}: ${err.conflictedFiles.join(", ")}. ` + `Resolve manually on main and run \`/autonomous\` to resume.`, "error", ); // Stop auto AND signal the outer postUnit flow to exit early. // Without the flag, subsequent hooks (triage, rogue detection, // DB writes) would keep running against a conflicted main // checkout after the loop was already told to stop. const { stopAuto } = await import("./auto.js"); await stopAuto(ctx, undefined, `slice-merge-conflict on ${sid}`); sliceMergeStopped = true; return; } logError("engine", `slice-cadence merge failed for ${sid}`, { error: getErrorMessage(err), }); // Non-conflict failures (dirty main, rev-walk error, etc.) can // leave the checkout in an unexpected state. Stop autonomous mode so // the next slice doesn't dispatch on top of it. const { stopAuto } = await import("./auto.js"); await stopAuto(ctx, undefined, `slice-merge-error on ${sid}`); sliceMergeStopped = true; } }); // Exit early after stopAuto so the rest of post-unit processing // (triage, rogue detection, hook dispatch, DB writes) doesn't run // against a conflicted main checkout. Return "dispatched" to match // the convention used by other stop/pauseAuto paths in this function // (see signal handling earlier: stop/pause also return "dispatched"). if (sliceMergeStopped) return "dispatched"; // Post-triage: execute actionable resolutions if (s.currentUnit.type === "triage-captures") { try { const { executeTriageResolutions } = await import( "./triage-resolution.js" ); const state = await deriveState(s.basePath); const mid = state.activeMilestone?.id ?? ""; const sid = state.activeSlice?.id ?? ""; // executeTriageResolutions handles defer milestone creation even // without an active milestone/slice (the "all milestones complete" // scenario from #1562). inject/replan/quick-task still require mid+sid. const triageResult = executeTriageResolutions(s.basePath, mid, sid); if (triageResult.injected > 0) { ctx.ui.notify( `Triage: injected ${triageResult.injected} task${triageResult.injected === 1 ? "" : "s"} into ${sid} plan.`, "info", ); } if (triageResult.replanned > 0) { ctx.ui.notify( `Triage: replan trigger written for ${sid} — next dispatch will enter replanning.`, "info", ); } if (triageResult.deferredMilestones > 0) { ctx.ui.notify( `Triage: created ${triageResult.deferredMilestones} deferred milestone director${triageResult.deferredMilestones === 1 ? "y" : "ies"}.`, "info", ); } if (triageResult.quickTasks.length > 0) { for (const qt of triageResult.quickTasks) { s.pendingQuickTasks.push(qt); } ctx.ui.notify( `Triage: ${triageResult.quickTasks.length} quick-task${triageResult.quickTasks.length === 1 ? "" : "s"} queued for execution.`, "info", ); } for (const action of triageResult.actions) { logWarning("engine", `triage resolution: ${action}`); } } catch (err) { logError("engine", "triage resolution failed", { error: err.message, }); } } // Rogue file detection — safety net for LLM bypassing completion tools (D003) try { const rogueFiles = detectRogueFileWrites( s.currentUnit.type, s.currentUnit.id, s.basePath, ); for (const rogue of rogueFiles) { logWarning("engine", "rogue file write detected", { path: rogue.path, unitId: rogue.unitId, }); ctx.ui.notify(`Rogue file write detected: ${rogue.path}`, "warning"); } } catch (e) { debugLog("postUnit", { phase: "rogue-detection", error: String(e) }); } // ── Safety harness: post-unit validation ── try { const { loadEffectiveSFPreferences } = await import("./preferences.js"); const prefs = loadEffectiveSFPreferences()?.preferences; const safetyConfig = resolveSafetyHarnessConfig(prefs?.safety_harness); if (safetyConfig.enabled) { const { milestone: sMid, slice: sSid, task: sTid, } = parseUnitId(s.currentUnit.id); // File change validation (execute-task only, after auto-commit) if ( safetyConfig.file_change_validation && s.currentUnit.type === "execute-task" && sMid && sSid && sTid && isDbAvailable() ) { try { const taskRow = getTask(sMid, sSid, sTid); if (taskRow) { const expectedOutput = taskRow.expected_output ?? []; const plannedFiles = taskRow.files ?? []; const audit = validateFileChanges( s.basePath, expectedOutput, plannedFiles, { source: s.stagedPendingCommit ? "staged" : "last-commit", baselineFiles: s.preUnitDirtyFiles, }, ); if (audit && audit.violations.length > 0) { const warnings = audit.violations.filter( (v) => v.severity === "warning", ); for (const v of warnings) { logWarning("safety", `file-change: ${v.file} — ${v.reason}`); } if (warnings.length > 0) { ctx.ui.notify( `Safety: ${warnings.length} unexpected file change(s) outside task plan`, "warning", { kind: "progress", source: "safety", dedupe_key: `safety:file-change:${s.currentUnit.id}`, }, ); } } } } catch (e) { debugLog("postUnit", { phase: "safety-file-change", error: String(e), }); } } // Evidence cross-reference (execute-task only) // Verification evidence is passed via the complete-task tool call and // stored in the SUMMARY.md on disk — not available as structured data // in the DB. The evidence collector tracks actual bash tool calls, so // we can still detect units that claimed success but ran no commands. if ( safetyConfig.evidence_cross_reference && s.currentUnit.type === "execute-task" ) { try { const actual = getEvidence(); const bashCalls = actual.filter((e) => e.kind === "bash"); // If the task is marked complete but zero bash commands were run, // it's suspicious — the LLM may have fabricated results. if (sMid && sSid && sTid && isDbAvailable()) { const taskRow = getTask(sMid, sSid, sTid); if ( taskRow?.status === "complete" && taskRow.verify && bashCalls.length === 0 ) { logWarning( "safety", "task marked complete with verification commands but no bash calls were executed", ); ctx.ui.notify( `Safety: task ${sTid} has verification commands but no bash calls were recorded`, "warning", { kind: "progress", source: "safety", dedupe_key: `safety:evidence:${s.currentUnit.id}`, }, ); } } } catch (e) { debugLog("postUnit", { phase: "safety-evidence-xref", error: String(e), }); } } // Content validation (plan-slice, plan-milestone) if (safetyConfig.content_validation) { try { const artifactPath = resolveArtifactForContent( s.currentUnit.type, s.currentUnit.id, s.basePath, ); const contentViolations = validateContent( s.currentUnit.type, artifactPath, ); for (const v of contentViolations) { logWarning("safety", `content: ${v.reason}`); ctx.ui.notify(`Content validation: ${v.reason}`, "warning", { kind: "progress", source: "safety", dedupe_key: `safety:content:${s.currentUnit.id}:${v.reason}`, }); } } catch (e) { debugLog("postUnit", { phase: "safety-content-validation", error: String(e), }); } } // Clear persisted evidence file now that post-unit processing is complete // (Bug #4385 — prevents stale evidence from affecting retries of same unit ID). if ( safetyConfig.evidence_collection && s.currentUnit.type === "execute-task" && sMid && sSid && sTid ) { try { clearEvidenceFromDisk(s.basePath, sMid, sSid, sTid); } catch (e) { debugLog("postUnit", { phase: "safety-evidence-clear", error: String(e), }); } } } } catch (e) { debugLog("postUnit", { phase: "safety-harness", error: String(e) }); } // Artifact verification let triggerArtifactVerified = false; if (!s.currentUnit.type.startsWith("hook/")) { try { triggerArtifactVerified = verifyExpectedArtifact( s.currentUnit.type, s.currentUnit.id, s.basePath, ); if (triggerArtifactVerified) { invalidateAllCaches(); clearTaskCompleteFailureForCurrentUnit(s); } } catch (e) { debugLog("postUnit", { phase: "artifact-verify", error: String(e) }); } // If verification failed, attempt to regenerate missing projection files // from DB data before giving up (e.g. research-slice produces PLAN from engine). if (!triggerArtifactVerified) { try { const { milestone: mid, slice: sid } = parseUnitId(s.currentUnit.id); if (mid && sid) { const regenerated = regenerateIfMissing( s.basePath, mid, sid, "PLAN", ); if (regenerated) { // Re-check after regeneration triggerArtifactVerified = verifyExpectedArtifact( s.currentUnit.type, s.currentUnit.id, s.basePath, ); if (triggerArtifactVerified) { invalidateAllCaches(); clearTaskCompleteFailureForCurrentUnit(s); } } } } catch (e) { debugLog("postUnit", { phase: "regenerate-projection", error: String(e), }); } } // When artifact verification fails for a unit type that has a known expected // artifact, return "retry" so the caller re-dispatches with failure context // instead of blindly re-dispatching the same unit (#1571). // After MAX_VERIFICATION_RETRIES, escalate to writeBlockerPlaceholder so the // pipeline can advance instead of looping forever (#2653). // // Pre-checks short-circuit retry for known-unrecoverable failures: // - User-input waits in deep setup: pause instead of retrying or writing // placeholders while the agent is waiting for approval. // - Deterministic policy rejection (#4973): structural write-gate failure. // - DB infra failure (#2517): completion tool returned db_unavailable. if ( !triggerArtifactVerified && USER_DRIVEN_DEEP_UNITS.has(s.currentUnit.type) && isAwaitingUserInput(opts?.agentEndMessages) ) { debugLog("postUnit", { phase: "artifact-verify-awaiting-user", unitType: s.currentUnit.type, unitId: s.currentUnit.id, }); ctx.ui.notify( `${s.currentUnit.type} ${s.currentUnit.id} is waiting for your input — pausing autonomous mode instead of retrying the missing artifact.`, "info", ); s.lastToolInvocationError = null; await pauseAuto(ctx, pi); return "dispatched"; } else if (!triggerArtifactVerified && !isDbAvailable()) { // DB infra failure — do NOT retry; the completion tool returned // db_unavailable so the artifact was never written. Retrying would // produce an infinite re-dispatch loop (#2517). debugLog("postUnit", { phase: "artifact-verify-skip-db-unavailable", unitType: s.currentUnit.type, unitId: s.currentUnit.id, }); const dbSkipDiag = diagnoseExpectedArtifact( s.currentUnit.type, s.currentUnit.id, s.basePath, ); ctx.ui.notify( `Artifact missing for ${s.currentUnit.type} ${s.currentUnit.id} — DB unavailable, skipping retry.${dbSkipDiag ? ` Expected: ${dbSkipDiag}` : ""}`, "error", ); } else if ( !triggerArtifactVerified && s.lastToolInvocationError && isDeterministicPolicyError(s.lastToolInvocationError) ) { // Deterministic policy rejection (#4973): structural write-gate failure // that will recur on every retry — write a blocker placeholder to advance pipeline. const retryKey = `${s.currentUnit.type}:${s.currentUnit.id}`; debugLog("postUnit", { phase: "deterministic-policy-error-placeholder", unitType: s.currentUnit.type, unitId: s.currentUnit.id, error: s.lastToolInvocationError, }); const reason = `Deterministic policy rejection for ${s.currentUnit.type} "${s.currentUnit.id}": ${s.lastToolInvocationError}. Retrying cannot resolve this gate — writing blocker placeholder to advance pipeline.`; s.lastToolInvocationError = null; s.pendingVerificationRetry = null; s.verificationRetryCount.delete(retryKey); writeBlockerPlaceholder( s.currentUnit.type, s.currentUnit.id, s.basePath, reason, ); ctx.ui.notify( `${s.currentUnit.type} ${s.currentUnit.id} — deterministic policy rejection, wrote blocker placeholder (no retries) (#4973)`, "warning", ); // Fall through to "continue" — do NOT enter the retry or db-unavailable paths. } else if (!triggerArtifactVerified) { const taskCompleteFailure = taskCompleteFailureForCurrentUnit(s); if (taskCompleteFailure) { const retryMessage = `complete_task failed: ${taskCompleteFailure}. Try the call again, or investigate the write path.`; s.pendingTaskCompleteFailures.set( s.currentUnit.id, taskCompleteFailure, ); s.lastTaskCompleteFailure = null; s.pendingVerificationRetry = null; debugLog("postUnit", { phase: "task-complete-transient-retry", unitType: s.currentUnit.type, unitId: s.currentUnit.id, error: taskCompleteFailure, }); ctx.ui.notify(retryMessage, "warning"); return "retry"; } // #2883/#3595: If the artifact is missing because the tool invocation // failed (malformed JSON) or was skipped (queued user message), retrying // will produce the same failure. Pause autonomous mode instead of looping. if (s.lastToolInvocationError) { const isUserSkip = /queued user message/i.test( s.lastToolInvocationError, ); const errMsg = isUserSkip ? `Tool skipped for ${s.currentUnit.type}: ${s.lastToolInvocationError}. Queued user message interrupted the turn — pausing autonomous mode.` : `Tool invocation failed for ${s.currentUnit.type}: ${s.lastToolInvocationError}. Structured argument generation failed — pausing autonomous mode.`; debugLog("postUnit", { phase: "tool-invocation-error-pause", unitType: s.currentUnit.type, unitId: s.currentUnit.id, error: s.lastToolInvocationError, }); ctx.ui.notify(errMsg, "error"); s.lastToolInvocationError = null; await pauseAuto(ctx, pi); return "dispatched"; } const hasExpectedArtifact = resolveExpectedArtifactPath( s.currentUnit.type, s.currentUnit.id, s.basePath, ) !== null; if (hasExpectedArtifact) { const retryKey = `${s.currentUnit.type}:${s.currentUnit.id}`; const attempt = (s.verificationRetryCount.get(retryKey) ?? 0) + 1; s.verificationRetryCount.set(retryKey, attempt); if (attempt > MAX_VERIFICATION_RETRIES) { // #4175: For complete-milestone, a blocker placeholder is harmful — // the stub SUMMARY has no recovery value (milestone is terminal), // it does not update DB status (so deriveState never advances), // and it fools stopAuto's presence check into merging a milestone // that was never legitimately completed. Pause autonomous mode with a // clear single failure signal and preserve the worktree branch. if (s.currentUnit.type === "complete-milestone") { debugLog("postUnit", { phase: "artifact-verify-pause-complete-milestone", unitType: s.currentUnit.type, unitId: s.currentUnit.id, attempt, maxRetries: MAX_VERIFICATION_RETRIES, }); s.verificationRetryCount.delete(retryKey); s.pendingVerificationRetry = null; ctx.ui.notify( `Milestone ${s.currentUnit.id} verification failed after ${MAX_VERIFICATION_RETRIES} retries — worktree branch preserved. Re-run /autonomous once blockers are resolved.`, "error", ); await pauseAuto(ctx, pi); return "dispatched"; } // Retries exhausted — write a blocker placeholder so the pipeline // can advance past this stuck unit (#2653). debugLog("postUnit", { phase: "artifact-verify-escalate", unitType: s.currentUnit.type, unitId: s.currentUnit.id, attempt, maxRetries: MAX_VERIFICATION_RETRIES, }); const reason = `Artifact verification failed after ${MAX_VERIFICATION_RETRIES} retries for ${s.currentUnit.type} "${s.currentUnit.id}".`; writeBlockerPlaceholder( s.currentUnit.type, s.currentUnit.id, s.basePath, reason, ); ctx.ui.notify( `${s.currentUnit.type} ${s.currentUnit.id} — verification retries exhausted (${MAX_VERIFICATION_RETRIES}), wrote blocker placeholder to advance pipeline`, "warning", ); // Reset retry count and fall through to "continue" so the loop // re-derives state with the placeholder in place. s.verificationRetryCount.delete(retryKey); s.pendingVerificationRetry = null; // Do NOT return "retry" — fall through to "continue" below. } else { s.pendingVerificationRetry = { unitId: s.currentUnit.id, failureContext: `Artifact verification failed: expected artifact for ${s.currentUnit.type} "${s.currentUnit.id}" was not found on disk after unit execution (attempt ${attempt}).`, attempt, }; debugLog("postUnit", { phase: "artifact-verify-retry", unitType: s.currentUnit.type, unitId: s.currentUnit.id, attempt, }); ctx.ui.notify( `Artifact missing for ${s.currentUnit.type} ${s.currentUnit.id} — retrying (attempt ${attempt})`, "warning", ); return "retry"; } } } } else { // Hook unit completed — no additional processing needed } } return "continue"; } /** * Post-verification processing: DB dual-write, post-unit hooks, triage * capture dispatch, quick-task dispatch. * * Sidecar work (hooks, triage, quick-tasks) is enqueued on `s.sidecarQueue` * for the main loop to drain via `runUnit()`. * * Returns: * - "continue" — proceed to sidecar drain / normal dispatch * - "step-wizard" — assisted mode, show wizard instead * - "stopped" — stopAuto was called */ export async function postUnitPostVerification(pctx) { const { s, ctx, pi, buildSnapshotOpts, lockBase: _lockBase, stopAuto: _stopAuto2, pauseAuto, updateProgressWidget: _updateProgressWidget, } = pctx; // ── Deferred commit (Fix 1) ── // If postUnitPreVerification staged files but deferred the commit until after // verification, perform the commit now — verification has passed. if (s.stagedPendingCommit) { s.stagedPendingCommit = false; const deferredTaskContext = s.pendingCommitTaskContext; s.pendingCommitTaskContext = null; if (isParityCommitBlocked()) { const reason = getParityCommitBlockReason(); logWarning("engine", `deferred commit blocked by UOK parity: ${reason}`); ctx.ui.notify(`Deferred commit blocked: ${reason}`, "warning"); return "continue"; } try { const git = createGitService(s.basePath); const commitMessage = deferredTaskContext ? buildTaskCommitMessage(deferredTaskContext) : `feat: task complete (deferred commit)`; const committed = git.commitStaged(commitMessage); if (committed) { ctx.ui.notify(`Committed: ${commitMessage.split("\n")[0]}`, "info"); debugLog("postUnit", { phase: "deferred-commit", status: "ok" }); } } catch (e) { logWarning("engine", `deferred commit failed: ${e.message}`); ctx.ui.notify(`Deferred commit failed: ${e.message}`, "warning"); } } if (s.currentUnit) { try { const codebasePrefs = loadEffectiveSFPreferences()?.preferences?.codebase; const refresh = ensureCodebaseMapFresh( s.basePath, codebasePrefs ? { excludePatterns: codebasePrefs.exclude_patterns, maxFiles: codebasePrefs.max_files, collapseThreshold: codebasePrefs.collapse_threshold, } : undefined, { force: true, ttlMs: 0 }, ); if (refresh.status === "generated" || refresh.status === "updated") { debugLog("postUnit", { phase: "codebase-refresh", unitType: s.currentUnit.type, unitId: s.currentUnit.id, status: refresh.status, fileCount: refresh.fileCount, reason: refresh.reason, }); } } catch (e) { logWarning("engine", `CODEBASE refresh failed: ${e.message}`); } } // ── Scaffold-keeper dispatch (ADR-021 Phase D) ── // After milestone completion, fire-and-forget the scaffold-keeper to // detect editing-drift docs and stage `.proposed` artifacts. Failure // is non-fatal and must never break the auto loop, hence the broad try. if (s.currentUnit?.type === "complete-milestone") { try { const { dispatchScaffoldKeeperFireAndForget } = await import( "./scaffold-keeper.js" ); dispatchScaffoldKeeperFireAndForget(s.basePath, ctx); } catch (e) { debugLog("postUnit", { phase: "scaffold-keeper-dispatch", error: getErrorMessage(e), }); } } // ── Record-promoter dispatch (ADR-021 Phase D) ── // After milestone completion, fire-and-forget the record-promoter to // auto-convert any actionable docs/records/ artifacts into milestone backlog. // This catches records the autonomous run itself produced during the // just-finished milestone. Failure is non-fatal. if (s.currentUnit?.type === "complete-milestone") { try { const { dispatchRecordPromoterFireAndForget } = await import( "./record-promoter.js" ); dispatchRecordPromoterFireAndForget(s.basePath, ctx); } catch (err) { debugLog("postUnit", { phase: "record-promoter-dispatch", error: err.message, }); } } // ── Doc-sync drift check (BUILD_PLAN Tier 2.2) ── // After code-mutating units, check whether ARCHITECTURE.md or other tracked // docs are out of sync with the actual codebase. Advisory — never blocks. if (s.currentUnit) { const { runDocSyncStagingCheck } = await import( "./auto/auto-post-unit-staging.js" ); await runDocSyncStagingCheck(s.basePath, s.currentUnit.type, ctx); } // ── Knowledge compounding (Mechanism 4) ── // After milestone completion, distill high-confidence judgment-log entries // into .sf/KNOWLEDGE.md so the next milestone benefits from them. // Failure is always non-fatal. if (s.currentUnit?.type === "complete-milestone") { const milestoneIdForCompound = parseUnitId(s.currentUnit.id).milestone; if (milestoneIdForCompound) { try { const { compoundLearningsIntoKnowledge } = await import( "./knowledge-compounding.js" ); const result = compoundLearningsIntoKnowledge( s.basePath, milestoneIdForCompound, ); if (result.added > 0) { debugLog("postUnit", { phase: "knowledge-compounding", milestoneId: milestoneIdForCompound, added: result.added, skipped: result.skipped, }); } } catch (err) { debugLog("postUnit", { phase: "knowledge-compounding", error: err.message, }); } } } // ── Post-unit hooks ── if (s.currentUnit && !s.stepMode) { const hookUnit = checkPostUnitHooks( s.currentUnit.type, s.currentUnit.id, s.basePath, ); if (hookUnit) { if (s.currentUnit) { await closeoutUnit( ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id), ); } persistHookState(s.basePath); return enqueueSidecar( s, ctx, { kind: "hook", unitType: hookUnit.unitType, unitId: hookUnit.unitId, prompt: hookUnit.prompt, model: hookUnit.model, }, { hookName: hookUnit.hookName }, ); } // Check if a hook requested a retry of the trigger unit if (isRetryPending()) { const trigger = consumeRetryTrigger(); if (trigger) { ctx.ui.notify( `Hook requested retry of ${trigger.unitType} ${trigger.unitId} — resetting task state.`, "info", ); // ── State reset: undo the completion so deriveState re-derives the unit ── try { const { milestone: mid, slice: sid, task: tid, } = parseUnitId(trigger.unitId); // 1. Reset task status in DB and re-render plan checkboxes if (mid && sid && tid) { try { updateTaskStatus(mid, sid, tid, "pending"); await renderPlanCheckboxes(s.basePath, mid, sid); } catch (dbErr) { // DB unavailable — fail explicitly rather than silently reverting to markdown mutation. // Use 'sf recover' to rebuild DB state from disk if needed. logError( "engine", `retry state-reset failed (DB unavailable): ${dbErr.message}. Run 'sf recover' to reconcile.`, ); } } // 2. Delete SUMMARY.md for the task if (mid && sid && tid) { const tasksDir = resolveTasksDir(s.basePath, mid, sid); if (tasksDir) { const summaryFile = join( tasksDir, buildTaskFileName(tid, "SUMMARY"), ); if (existsSync(summaryFile)) { unlinkSync(summaryFile); } } } // 3. Delete the retry_on artifact (e.g. NEEDS-REWORK.md) if (trigger.retryArtifact) { const retryArtifactPath = resolveHookArtifactPath( s.basePath, trigger.unitId, trigger.retryArtifact, ); if (existsSync(retryArtifactPath)) { unlinkSync(retryArtifactPath); } } // 5. Invalidate caches so deriveState reads fresh disk state invalidateAllCaches(); } catch (e) { debugLog("postUnitPostVerification", { phase: "retry-state-reset", error: String(e), }); } // Fall through to normal dispatch — deriveState will re-derive the unit } } } // ── Fast-path stop detection (#3487) ── // Before waiting for triage, check if any PENDING captures contain explicit // stop/halt language. If so, pause immediately — don't wait for triage. if (s.currentUnit && s.currentUnit.type !== "triage-captures") { try { const pending = loadPendingCaptures(s.basePath); // Match only when the capture text starts with a stop/halt directive word, // or the entire text is short and dominated by such a word. This avoids // false positives on captures like "add a pause button" or "stop the timer // from re-rendering" — those are feature descriptions, not halt directives. const STOP_PATTERN = /^(stop|halt|abort|don'?t continue|pause|cease)\b/i; const stopCapture = pending.find((c) => STOP_PATTERN.test(c.text.trim())); if (stopCapture) { ctx.ui.notify( `Stop directive detected in pending capture ${stopCapture.id}: "${stopCapture.text}" — pausing autonomous mode.`, "warning", ); debugLog("postUnit", { phase: "fast-stop", captureId: stopCapture.id }); await pauseAuto(ctx, pi); return "stopped"; } } catch (e) { debugLog("postUnit", { phase: "fast-stop-error", error: String(e) }); } } // ── Capture protection: revert executor-silenced captures (#3487) ── // Non-triage agents can write **Status:** resolved to CAPTURES.md, bypassing // the triage pipeline. Revert those to pending before the triage check. if (s.currentUnit && s.currentUnit.type !== "triage-captures") { try { const reverted = revertExecutorResolvedCaptures(s.basePath); if (reverted > 0) { debugLog("postUnit", { phase: "capture-protection", reverted }); ctx.ui.notify( `Reverted ${reverted} capture${reverted === 1 ? "" : "s"} silenced by executor — re-queuing for triage.`, "warning", ); } } catch (e) { debugLog("postUnit", { phase: "capture-protection-error", error: String(e), }); } } // ── Pre-execution checks (after plan-slice completes) ── if (s.currentUnit && s.currentUnit.type === "plan-slice") { const currentUnit = s.currentUnit; let preExecPauseNeeded = false; await runSafely( "postUnitPostVerification", "pre-execution-checks", async () => { const prefs = loadEffectiveSFPreferences()?.preferences; const uokFlags = resolveUokFlags(prefs); try { // Check preferences — respect enhanced_verification and enhanced_verification_pre const enhancedEnabled = prefs?.enhanced_verification !== false; // default true const preEnabled = prefs?.enhanced_verification_pre !== false; // default true if (!enhancedEnabled || !preEnabled) { debugLog("postUnitPostVerification", { phase: "pre-execution-checks", skipped: true, reason: "disabled by preferences", }); return; } // Parse the unit ID to get milestone/slice IDs const { milestone: mid, slice: sid } = parseUnitId(currentUnit.id); if (!mid || !sid) { debugLog("postUnitPostVerification", { phase: "pre-execution-checks", skipped: true, reason: "could not parse milestone/slice from unit ID", }); return; } // Get tasks for this slice from DB const tasks = getSliceTasks(mid, sid); if (tasks.length === 0) { debugLog("postUnitPostVerification", { phase: "pre-execution-checks", skipped: true, reason: "no tasks found for slice", }); return; } const strictMode = prefs?.enhanced_verification_strict === true; // Run pre-execution checks const result = await runPreExecutionChecks(tasks, s.basePath); // Log summary to stderr in existing verification output format const emoji = result.status === "pass" ? "✅" : result.status === "warn" ? "⚠️" : "❌"; process.stderr.write( `sf-pre-exec: ${emoji} Pre-execution checks ${result.status} for ${mid}/${sid} (${result.durationMs}ms)\n`, ); // Log individual check results for (const check of result.checks) { const checkEmoji = check.passed ? "✓" : check.blocking ? "✗" : "⚠"; process.stderr.write( `sf-pre-exec: ${checkEmoji} [${check.category}] ${check.target}: ${check.message}\n`, ); } // Write evidence JSON to slice artifacts directory const slicePath = resolveSlicePath(s.basePath, mid, sid); if (slicePath) { writePreExecutionEvidence(result, slicePath, mid, sid); } if (uokFlags.gates) { const failedChecks = result.checks .filter((check) => !check.passed) .map( (check) => `[${check.category}] ${check.target}: ${check.message}`, ); const warnEscalated = result.status === "warn" && strictMode; const blockingFailure = result.status === "fail" || warnEscalated; const gateRunner = new UokGateRunner(); gateRunner.register({ id: "pre-execution-checks", type: "input", execute: async () => ({ outcome: blockingFailure ? "fail" : "pass", failureClass: result.status === "fail" ? "input" : warnEscalated ? "policy" : "none", rationale: blockingFailure ? `pre-execution checks ${result.status}${warnEscalated ? " (strict)" : ""}` : "pre-execution checks passed", findings: failedChecks.join("\n"), }), }); await gateRunner.run("pre-execution-checks", { basePath: s.basePath, traceId: `pre-execution:${currentUnit.id}`, turnId: currentUnit.id, milestoneId: mid, sliceId: sid, unitType: currentUnit.type, unitId: currentUnit.id, }); } // Notify UI if (result.status === "fail") { const blockingCount = result.checks.filter( (c) => !c.passed && c.blocking, ).length; ctx.ui.notify( `Pre-execution checks failed: ${blockingCount} blocking issue${blockingCount === 1 ? "" : "s"} found`, "error", ); preExecPauseNeeded = true; } else if (result.status === "warn") { ctx.ui.notify( `Pre-execution checks passed with warnings`, "warning", ); // Strict mode: treat warnings as blocking if (prefs?.enhanced_verification_strict === true) { preExecPauseNeeded = true; } } debugLog("postUnitPostVerification", { phase: "pre-execution-checks", status: result.status, checkCount: result.checks.length, durationMs: result.durationMs, }); } catch (preExecError) { // Fail-closed: if runPreExecutionChecks throws, pause autonomous mode instead of silently continuing const errorMessage = preExecError instanceof Error ? preExecError.message : String(preExecError); debugLog("postUnitPostVerification", { phase: "pre-execution-checks", error: errorMessage, failClosed: true, }); logError( "engine", `sf-pre-exec: Pre-execution checks threw an error: ${errorMessage}`, ); ctx.ui.notify( `Pre-execution checks error: ${errorMessage} — pausing for human review`, "error", ); if (uokFlags.gates && s.currentUnit) { const { milestone: mid, slice: sid } = parseUnitId( s.currentUnit.id, ); const gateRunner = new UokGateRunner(); gateRunner.register({ id: "pre-execution-checks", type: "input", execute: async () => ({ outcome: "manual-attention", failureClass: "manual-attention", rationale: "pre-execution checks threw before completion", findings: errorMessage, }), }); await gateRunner.run("pre-execution-checks", { basePath: s.basePath, traceId: `pre-execution:${s.currentUnit.id}`, turnId: s.currentUnit.id, milestoneId: mid ?? undefined, sliceId: sid ?? undefined, unitType: s.currentUnit.type, unitId: s.currentUnit.id, }); } preExecPauseNeeded = true; } }, ); // Check for blocking failures after runSafely completes if (preExecPauseNeeded) { debugLog("postUnitPostVerification", { phase: "pre-execution-checks", pausing: true, reason: "blocking failures detected", }); await pauseAuto(ctx, pi); return "stopped"; } } // ── Triage check ── if ( !s.stepMode && s.currentUnit && !s.currentUnit.type.startsWith("hook/") && s.currentUnit.type !== "triage-captures" && s.currentUnit.type !== "quick-task" ) { try { if (hasPendingCaptures(s.basePath)) { const pending = loadPendingCaptures(s.basePath); if (pending.length > 0) { const state = await deriveState(s.basePath); const mid = state.activeMilestone?.id; const sid = state.activeSlice?.id; if (mid && sid) { let currentPlan = ""; let roadmapContext = ""; const planFile = resolveSliceFile(s.basePath, mid, sid, "PLAN"); if (planFile) currentPlan = (await loadFile(planFile)) ?? ""; const roadmapFile = resolveMilestoneFile( s.basePath, mid, "ROADMAP", ); if (roadmapFile) roadmapContext = (await loadFile(roadmapFile)) ?? ""; const capturesList = pending .map( (c) => `- **${c.id}**: "${c.text}" (captured: ${c.timestamp})`, ) .join("\n"); const prompt = loadPrompt("triage-captures", { pendingCaptures: capturesList, currentPlan: currentPlan || "(no active slice plan)", roadmapContext: roadmapContext || "(no active roadmap)", }); if (s.currentUnit) { await closeoutUnit( ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, ); } const triageUnitId = `${mid}/${sid}/triage`; return enqueueSidecar( s, ctx, { kind: "triage", unitType: "triage-captures", unitId: triageUnitId, prompt, }, { pendingCount: pending.length }, `Triaging ${pending.length} pending capture${pending.length === 1 ? "" : "s"}...`, ); } } } } catch (e) { debugLog("postUnit", { phase: "triage-check", error: String(e) }); } } // ── Quick-task dispatch ── if ( !s.stepMode && s.pendingQuickTasks.length > 0 && s.currentUnit && s.currentUnit.type !== "quick-task" ) { try { const capture = s.pendingQuickTasks.shift(); const { buildQuickTaskPrompt } = await import("./triage-resolution.js"); const { markCaptureExecuted } = await import("./captures.js"); const prompt = buildQuickTaskPrompt(capture); if (s.currentUnit) { await closeoutUnit( ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, ); } markCaptureExecuted(s.basePath, capture.id); const qtUnitId = `${s.currentMilestoneId}/${capture.id}`; return enqueueSidecar( s, ctx, { kind: "quick-task", unitType: "quick-task", unitId: qtUnitId, prompt, captureId: capture.id, }, { captureId: capture.id }, `Executing quick-task: ${capture.id} — "${capture.text}"`, ); } catch (e) { debugLog("postUnit", { phase: "quick-task-dispatch", error: String(e) }); } } // Assisted mode → show wizard instead of dispatch. // Without this notify(), /in assisted mode finishes a unit and silently // exits the loop, leaving the user with no hint to /clear and /again. if (s.stepMode) { try { const nextState = await deriveState(s.basePath); ctx.ui.notify(buildStepCompleteMessage(nextState), "info"); } catch (e) { debugLog("postUnit", { phase: "step-wizard-notify", error: String(e) }); ctx.ui.notify(STEP_COMPLETE_FALLBACK_MESSAGE, "info"); } return "step-wizard"; } return "continue"; }