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:
parent
0f0aee5bf0
commit
3895ae2cd3
5 changed files with 223 additions and 9 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
171
src/resources/extensions/sf/tests/escalation-auto-accept.test.ts
Normal file
171
src/resources/extensions/sf/tests/escalation-auto-accept.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue