diff --git a/src/resources/extensions/sf/auto-dispatch.ts b/src/resources/extensions/sf/auto-dispatch.ts index ee74aab49..f8828f459 100644 --- a/src/resources/extensions/sf/auto-dispatch.ts +++ b/src/resources/extensions/sf/auto-dispatch.ts @@ -38,6 +38,7 @@ import { } from "./auto-prompts.js"; import { hasImplementationArtifacts } from "./auto-recovery.js"; import { resolveDeepProjectSetupState } from "./deep-project-setup-policy.js"; +import { resolveEscalation } from "./escalation.js"; import { getExecuteTaskInstructionConflict, skipExecuteTaskForInstructionConflict, @@ -523,15 +524,42 @@ When done, say: "Validation attention remediated; ready for revalidation."`; export const DISPATCH_RULES: DispatchRule[] = [ { - // ADR-011 Phase 2 (gsd-2 ADR): mid-execution escalation pause. + // ADR-011 Phase 2 (gsd-2 ADR): mid-execution escalation handling. + // Auto-mode is autonomous, so by default we accept the agent's + // recommendation and continue — the user can review/override later via + // `/sf escalate list --all`. Set `phases.escalation_auto_accept: false` + // to keep gsd-2's pause-and-ask behavior. // Must evaluate FIRST — phase-agnostic rules below (rewrite-docs gate, - // UAT checks, reassess) cannot bypass a pending user decision. - // state.ts emits phase='escalating-task' only when there's an actionable - // escalation; this rule turns that into a clean 'stop' with the message - // state.nextAction already populated. - name: "escalating-task → pause-for-escalation", - match: async ({ state, mid }) => { + // UAT checks, reassess) cannot run while a task is paused. + name: "escalating-task → auto-accept-or-pause", + match: async ({ state, mid, prefs, basePath }) => { if (state.phase !== "escalating-task") return null; + const autoAccept = prefs?.phases?.escalation_auto_accept !== false; + if ( + autoAccept && + state.activeMilestone && + state.activeSlice && + state.activeTask + ) { + const result = resolveEscalation( + basePath, + state.activeMilestone.id, + state.activeSlice.id, + state.activeTask.id, + "accept", + "auto-mode: accepted agent recommendation; user can override via /sf escalate", + ); + if (result.status === "resolved") { + // Flags cleared; let the next dispatch cycle re-read state and + // route normally (carry-forward injection picks this up via + // claimEscalationOverride on the next execute-task). + return { action: "skip" }; + } + logWarning( + "dispatch", + `escalation auto-accept failed for ${state.activeMilestone.id}/${state.activeSlice.id}/${state.activeTask.id}: ${result.status} — falling back to pause`, + ); + } return { action: "stop", reason: diff --git a/src/resources/extensions/sf/preferences-validation.ts b/src/resources/extensions/sf/preferences-validation.ts index 9da653b70..a5d71da9c 100644 --- a/src/resources/extensions/sf/preferences-validation.ts +++ b/src/resources/extensions/sf/preferences-validation.ts @@ -772,6 +772,9 @@ export function validatePreferences(preferences: SFPreferences): { if ((p as any).progressive_planning !== undefined) (validatedPhases as any).progressive_planning = !!(p as any) .progressive_planning; + if ((p as any).escalation_auto_accept !== undefined) + (validatedPhases as any).escalation_auto_accept = !!(p as any) + .escalation_auto_accept; // Warn on unknown phase keys const knownPhaseKeys = new Set([ "skip_research", @@ -782,6 +785,7 @@ export function validatePreferences(preferences: SFPreferences): { "require_slice_discussion", "mid_execution_escalation", "progressive_planning", + "escalation_auto_accept", ]); for (const key of Object.keys(p)) { if (!knownPhaseKeys.has(key)) { diff --git a/src/resources/extensions/sf/templates/PREFERENCES.md b/src/resources/extensions/sf/templates/PREFERENCES.md index 8b8078420..10925ff4e 100644 --- a/src/resources/extensions/sf/templates/PREFERENCES.md +++ b/src/resources/extensions/sf/templates/PREFERENCES.md @@ -39,9 +39,16 @@ phases: progressive_planning: # ADR-011 P2: mid-execution escalation. When true, sf_task_complete honors # an optional escalation: { question, options, recommendation, ... } payload. - # Auto-mode pauses for user resolution via /sf escalate, then carries the - # user's choice forward as a hard constraint into the next executor. + # The agent's choice carries forward as a hard constraint into the next + # executor. See escalation_auto_accept for whether auto-mode pauses or + # auto-accepts. mid_execution_escalation: + # When true (default), an escalation in auto-mode auto-accepts the agent's + # recommendation and continues — auto-mode is autonomous. The user can + # review/override later via `/sf escalate list --all`. Set false to keep + # gsd-2's pause-and-ask behavior (loop halts until user runs + # `/sf escalate resolve`). + escalation_auto_accept: # Deep-mode planning gate (top-level, not under phases). When set to "deep", # auto-mode runs project-level discussion → requirements → optional research # BEFORE any milestone work. Default: light (current SF behavior). diff --git a/src/resources/extensions/sf/tests/escalation-auto-accept.test.ts b/src/resources/extensions/sf/tests/escalation-auto-accept.test.ts new file mode 100644 index 000000000..9fcf07a4c --- /dev/null +++ b/src/resources/extensions/sf/tests/escalation-auto-accept.test.ts @@ -0,0 +1,171 @@ +/** + * Auto-mode is autonomous — when an escalation lands while auto-mode is + * driving, the escalating-task dispatch rule auto-accepts the agent's + * recommendation by default and lets the loop continue. Setting + * `phases.escalation_auto_accept: false` reverts to gsd-2's pause-and-ask. + * + * The point of these tests is to lock the autonomous default — a regression + * to "always pause" would silently break unattended /loop runs. + */ + +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, test } from "vitest"; + +import { DISPATCH_RULES } from "../auto-dispatch.ts"; +import { + buildEscalationArtifact, + readEscalationArtifact, + writeEscalationArtifact, +} from "../escalation.ts"; +import { + closeDatabase, + getTask, + insertMilestone, + insertSlice, + insertTask, + openDatabase, +} from "../sf-db.ts"; +import type { SFPreferences } from "../preferences.ts"; +import type { SFState } from "../types.ts"; + +const RULE = DISPATCH_RULES.find( + (r) => r.name === "escalating-task → auto-accept-or-pause", +); +assert.ok(RULE, "expected escalating-task → auto-accept-or-pause rule"); + +let dir: string; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "sf-escalation-auto-accept-")); + mkdirSync(join(dir, ".sf", "milestones", "M001", "slices", "S01", "tasks"), { + recursive: true, + }); + openDatabase(join(dir, ".sf", "sf.db")); + insertMilestone({ id: "M001", title: "Test", status: "pending" }); + insertSlice({ + milestoneId: "M001", + id: "S01", + title: "Test Slice", + risk: "medium", + }); + insertTask({ + milestoneId: "M001", + sliceId: "S01", + id: "T01", + title: "Task 1", + }); +}); + +afterEach(() => { + closeDatabase(); + rmSync(dir, { recursive: true, force: true }); +}); + +function pendingState(): SFState { + return { + activeMilestone: { id: "M001", title: "Test", status: "pending" }, + activeSlice: { id: "S01", title: "Test Slice", risk: "medium" }, + activeTask: { id: "T01", title: "Task 1" }, + phase: "escalating-task", + recentDecisions: [], + blockers: ["awaiting user"], + nextAction: "Run /sf escalate resolve M001/S01/T01 ...", + registry: {} as never, + requirements: {} as never, + progress: { + milestones: {} as never, + slices: {} as never, + }, + } as unknown as SFState; +} + +function seedPendingArtifact() { + const artifact = buildEscalationArtifact({ + taskId: "T01", + sliceId: "S01", + milestoneId: "M001", + question: "Overwrite or fail?", + options: [ + { id: "overwrite", label: "Overwrite", tradeoffs: "lose data" }, + { id: "fail", label: "Fail", tradeoffs: "block progress" }, + ], + recommendation: "fail", + recommendationRationale: "data loss is irreversible", + continueWithDefault: false, + }); + writeEscalationArtifact(dir, artifact); +} + +describe("escalating-task dispatch rule (auto-accept default)", () => { + test("default (no prefs) auto-accepts the recommendation and skips the cycle", async () => { + seedPendingArtifact(); + const result = await RULE!.match({ + basePath: dir, + mid: "M001", + midTitle: "Test", + state: pendingState(), + prefs: undefined, + }); + assert.equal(result?.action, "skip"); + const task = getTask("M001", "S01", "T01"); + assert.equal(task?.escalation_pending, 0); + assert.ok(task?.escalation_artifact_path); + const art = readEscalationArtifact(task!.escalation_artifact_path!); + assert.equal(art?.userChoice, "accept"); + assert.match(art?.userRationale ?? "", /auto-mode/); + }); + + test("phases.escalation_auto_accept=true also auto-accepts", async () => { + seedPendingArtifact(); + const prefs = { + phases: { escalation_auto_accept: true }, + } as unknown as SFPreferences; + const result = await RULE!.match({ + basePath: dir, + mid: "M001", + midTitle: "Test", + state: pendingState(), + prefs, + }); + assert.equal(result?.action, "skip"); + }); + + test("phases.escalation_auto_accept=false preserves the pause behavior", async () => { + seedPendingArtifact(); + const prefs = { + phases: { escalation_auto_accept: false }, + } as unknown as SFPreferences; + const result = await RULE!.match({ + basePath: dir, + mid: "M001", + midTitle: "Test", + state: pendingState(), + prefs, + }); + assert.equal(result?.action, "stop"); + if (result?.action === "stop") { + assert.match(result.reason, /escalate/); + } + const task = getTask("M001", "S01", "T01"); + assert.equal( + task?.escalation_pending, + 1, + "pause path must NOT clear the flag", + ); + }); + + test("non-escalating phases return null (rule doesn't fire)", async () => { + const idleState = { ...pendingState(), phase: "executing" } as SFState; + const result = await RULE!.match({ + basePath: dir, + mid: "M001", + midTitle: "Test", + state: idleState, + prefs: undefined, + }); + assert.equal(result, null); + }); +}); diff --git a/src/resources/extensions/sf/types.ts b/src/resources/extensions/sf/types.ts index 86e948a90..1c3aab42d 100644 --- a/src/resources/extensions/sf/types.ts +++ b/src/resources/extensions/sf/types.ts @@ -355,6 +355,10 @@ export interface PhaseSkipPreferences { * and dispatch through refine-slice instead of plan-slice. When false (default), sketches * are indistinguishable from missing plans and fall through to the normal "planning" phase. */ progressive_planning?: boolean; + /** Auto-mode is autonomous: when true (default), an escalation in auto-mode auto-accepts + * the agent's recommendation and continues. The user can review/override later via + * `/sf escalate list --all`. Set false to keep the gsd-2 pause-and-ask behavior. */ + escalation_auto_accept?: boolean; } export interface NotificationPreferences {