feat(sf): auto-mode is autonomous — escalations auto-accept by default

Auto is autonomous, so the escalating-task dispatch rule shouldn't halt
the loop. Default: accept the agent's recommendation, record the choice
with `auto-mode: ...` rationale, and let the next dispatch cycle pick up
the carry-forward override. Users can review or override via
`/sf escalate list --all` later.

Set `phases.escalation_auto_accept: false` to keep gsd-2's pause-and-ask
behavior (loop halts until the user runs `/sf escalate resolve`).

- types.ts: add escalation_auto_accept (default true)
- preferences-validation.ts: allowlist + warn on unknown phase keys
- auto-dispatch.ts: rename rule to "auto-accept-or-pause"; on auto-accept
  resolve via resolveEscalation("accept", ...) and return action:"skip"
  so the next cycle re-reads state cleanly
- PREFERENCES.md: surface the toggle with the autonomy rationale
- tests/escalation-auto-accept.test.ts: 4 cases — default accept, explicit
  true, explicit false (preserves pause), non-escalating phase no-op

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mikael Hugo 2026-05-02 21:36:15 +02:00
parent 0f0aee5bf0
commit 3895ae2cd3
5 changed files with 223 additions and 9 deletions

View file

@ -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:

View file

@ -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)) {

View file

@ -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).

View file

@ -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);
});
});

View file

@ -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 {