- check-sf-extension-inventory.mjs: expand parseDirectRegisteredCommands()
scan to include 7 more files (guards/inturn.js, notifications/notify.js,
permissions/index.js, ui/usage-bar.js, commands/legacy/audit.js,
commands/legacy/create-extension.js, commands/legacy/create-slash-command.js)
and filter results by BASE_RUNTIME_COMMAND_NAMES to exclude doc-string false
positives ("name" in create-slash-command.js template text)
- extension-manifest.json: remove 'clear' (subcommand of logs/notifications,
never a top-level pi.registerCommand)
- packages/pi-agent-core/src/db/sf-db.ts: fix 23 noVoidTypeReturn errors
- openDatabase: void → boolean (caller uses return value at line 5625)
- claimEscalationOverride: void → boolean (caller checks at escalation.js:243)
- resolveSelfFeedbackEntry: void → boolean (caller checks at self-feedback.js:387)
- copyWorktreeDb: void → boolean (caller checks at reconcileWorktreeDb)
- compactUokMessages: void → {before,after} (caller returns value at message-bus.js:238)
- insertSessionTurn: void → bigint|null (caller uses id at session-recorder.js:104)
- expireStaleMemories: void → number (caller uses count at auto-start.js:1047)
- deleteMemorySourceRow: void → boolean (caller returns value at memory-source-store.js:107)
- deleteMemoryEmbedding: void → boolean (caller returns value at memory-embeddings.js:328)
- updateBacklogItemStatus: remove dead return expression (callers discard value)
- removeBacklogItem: remove dead return expression (callers discard value)
- updateGateCircuitBreaker: remove dead return {total,avgMs,...} (wrong-type
code accidentally merged from getGateLatencyStats, never reachable)
- markUokMessageRead: remove dead return true/false (callers discard value)
- Auto-fix formatting and organizeImports in ~30 source files (biome --write)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1934 lines
66 KiB
JavaScript
1934 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 { 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";
|
|
|
|
/**
|
|
* 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: ${err instanceof Error ? err.message : String(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 new Promise((r) => setTimeout(r, 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: ${err instanceof Error ? err.message : String(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 =
|
|
stageErr instanceof Error ? stageErr.message : String(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 = e instanceof Error ? e.message : String(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: ${err instanceof Error ? err.message : String(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: err instanceof Error ? err.message : String(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: e instanceof Error ? e.message : String(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";
|
|
}
|