singularity-forge/src/resources/extensions/sf/auto-post-unit.js
Mikael Hugo aa6ecce384 refactor: fix all remaining inline error ternaries across 20 files
Used perl regex to replace all patterns of the form
  X instanceof Error ? X.message : String(X)
with getErrorMessage(X) for any variable name.

Added getErrorMessage imports to 6 files that lacked it.
Leaves only 2 intentional .stack || .message variants unchanged.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-11 14:50:01 +02:00

1937 lines
66 KiB
JavaScript

/**
* 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 `<file>.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";
}