feat(sf): expose escalation via sf_task_complete (PDD)
Closes the agent surface for ADR-011 P2. Task agents can now include
an optional 'escalation' payload on sf_task_complete, gated by
phases.mid_execution_escalation. When the preference is on and the
field is present, the executor builds and writes the artifact, which
flips tasks.escalation_pending or escalation_awaiting_review based
on continueWithDefault. The producer chain from 14efcd773 is now
agent-callable.
PDD spec for this change:
Purpose: give task agents a way to file a mid-execution escalation
through the same tool they already call to record completion. No
new tool surface — escalation rides as an optional field on
sf_task_complete (matches gsd-2's design intent).
Consumer: task agents (execute-task) when they hit ambiguity that
requires user judgment.
Contract:
1. phases.mid_execution_escalation !== true → escalation field
silently ignored, current behavior preserved. Verified.
2. preference on + escalation field → buildEscalationArtifact
validates, writeEscalationArtifact persists, DB flag set,
result text + details report path + status. Verified.
3. continueWithDefault=false → status='pending' (loop pauses).
continueWithDefault=true → status='awaiting-review' (no pause).
4. Escalation write failures are caught — task completion never
blocks on an escalation error (logged via logError).
Failure boundary:
- Validation errors from buildEscalationArtifact propagate as
caught try/catch in the executor → logged → task still completes.
- Preference loader fails → behaves as if preference is off.
- DB write failures fall through; the task is already recorded.
Evidence: smoke test exercises both preference states (on writes
artifact + sets flag; off silently ignores). Typecheck clean.
Existing sf_task_complete callers without an escalation field
see zero change in result shape or behavior.
Non-goals:
- resolveEscalation (apply user's choice → carry forward as
override) — bigger flow, later fire.
- listActionableEscalations / listAllEscalations — for /sf
escalate list, later fire.
- /sf escalate user command (later fire).
Invariants:
- Safety: escalation field is Optional in the schema; no caller
is forced to migrate.
- Liveness: build+write happen synchronously after handleCompleteTask
returns; on success, the next state-derivation cycle picks up
pending=1 and pauses.
Schema additions to preferences-validation.ts:
- mid_execution_escalation, progressive_planning recognized as
valid phases keys (previously typed in PhaseSkipPreferences but
silently stripped by the validator).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e82e878eaa
commit
2bf6c51fde
3 changed files with 113 additions and 1 deletions
|
|
@ -1358,6 +1358,49 @@ export function registerDbTools(pi: ExtensionAPI): void {
|
|||
description: "Whether a plan-invalidating blocker was discovered",
|
||||
}),
|
||||
),
|
||||
// ADR-011 Phase 2: mid-execution escalation — agent flags an ambiguity
|
||||
// for the user. Only honored when phases.mid_execution_escalation=true.
|
||||
escalation: Type.Optional(
|
||||
Type.Object(
|
||||
{
|
||||
question: Type.String({
|
||||
description:
|
||||
"The question the user needs to answer — one clear sentence.",
|
||||
}),
|
||||
options: Type.Array(
|
||||
Type.Object({
|
||||
id: Type.String({
|
||||
description:
|
||||
"Short id (e.g. 'A', 'B') used by /sf escalate resolve.",
|
||||
}),
|
||||
label: Type.String({ description: "One-line label." }),
|
||||
tradeoffs: Type.String({
|
||||
description: "1-2 sentences on the tradeoffs of this option.",
|
||||
}),
|
||||
}),
|
||||
{
|
||||
minItems: 2,
|
||||
maxItems: 4,
|
||||
description: "2–4 options the user can choose between.",
|
||||
},
|
||||
),
|
||||
recommendation: Type.String({
|
||||
description: "Option id the executor recommends.",
|
||||
}),
|
||||
recommendationRationale: Type.String({
|
||||
description: "Why the recommendation — 1–2 sentences.",
|
||||
}),
|
||||
continueWithDefault: Type.Boolean({
|
||||
description:
|
||||
"When true, loop continues (artifact logged for later review). When false, auto-mode pauses until the user resolves via /sf escalate resolve.",
|
||||
}),
|
||||
},
|
||||
{
|
||||
description:
|
||||
"ADR-011 P2: optional escalation payload. Only honored when phases.mid_execution_escalation is true.",
|
||||
},
|
||||
),
|
||||
),
|
||||
verificationEvidence: Type.Optional(
|
||||
Type.Array(
|
||||
Type.Union([
|
||||
|
|
|
|||
|
|
@ -766,6 +766,12 @@ export function validatePreferences(preferences: SFPreferences): {
|
|||
if ((p as any).require_slice_discussion !== undefined)
|
||||
(validatedPhases as any).require_slice_discussion = !!(p as any)
|
||||
.require_slice_discussion;
|
||||
if ((p as any).mid_execution_escalation !== undefined)
|
||||
(validatedPhases as any).mid_execution_escalation = !!(p as any)
|
||||
.mid_execution_escalation;
|
||||
if ((p as any).progressive_planning !== undefined)
|
||||
(validatedPhases as any).progressive_planning = !!(p as any)
|
||||
.progressive_planning;
|
||||
// Warn on unknown phase keys
|
||||
const knownPhaseKeys = new Set([
|
||||
"skip_research",
|
||||
|
|
@ -774,6 +780,8 @@ export function validatePreferences(preferences: SFPreferences): {
|
|||
"skip_milestone_validation",
|
||||
"reassess_after_slice",
|
||||
"require_slice_discussion",
|
||||
"mid_execution_escalation",
|
||||
"progressive_planning",
|
||||
]);
|
||||
for (const key of Object.keys(p)) {
|
||||
if (!knownPhaseKeys.has(key)) {
|
||||
|
|
|
|||
|
|
@ -218,6 +218,18 @@ type VerificationEvidenceInput =
|
|||
}
|
||||
| string;
|
||||
|
||||
/** ADR-011 Phase 2: optional escalation payload on sf_task_complete.
|
||||
* When phases.mid_execution_escalation is enabled, the executor calls
|
||||
* buildEscalationArtifact + writeEscalationArtifact after the task is
|
||||
* recorded, flipping the appropriate task escalation flag. */
|
||||
export interface TaskCompleteEscalationInput {
|
||||
question: string;
|
||||
options: Array<{ id: string; label: string; tradeoffs: string }>;
|
||||
recommendation: string;
|
||||
recommendationRationale: string;
|
||||
continueWithDefault: boolean;
|
||||
}
|
||||
|
||||
export interface TaskCompleteParams {
|
||||
taskId: string;
|
||||
sliceId: string;
|
||||
|
|
@ -231,6 +243,7 @@ export interface TaskCompleteParams {
|
|||
keyDecisions?: string[];
|
||||
blockerDiscovered?: boolean;
|
||||
verificationEvidence?: VerificationEvidenceInput[];
|
||||
escalation?: TaskCompleteEscalationInput;
|
||||
}
|
||||
|
||||
export type CompleteMilestoneExecutorParams = Partial<CompleteMilestoneParams> &
|
||||
|
|
@ -293,11 +306,57 @@ export async function executeTaskComplete(
|
|||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
// ADR-011 P2: optional escalation payload. Only honored when the
|
||||
// phases.mid_execution_escalation preference is true. When false (default),
|
||||
// any escalation field on the params is silently ignored — keeps the
|
||||
// payload backwards-compatible for callers that always send it.
|
||||
let escalationPath: string | undefined;
|
||||
let escalationStatus: "pending" | "awaiting-review" | undefined;
|
||||
if (params.escalation) {
|
||||
try {
|
||||
const { loadEffectiveSFPreferences } = await import(
|
||||
"../preferences.js"
|
||||
);
|
||||
const prefs = loadEffectiveSFPreferences()?.preferences;
|
||||
if (prefs?.phases?.mid_execution_escalation === true) {
|
||||
const { buildEscalationArtifact, writeEscalationArtifact } =
|
||||
await import("../escalation.js");
|
||||
const artifact = buildEscalationArtifact({
|
||||
taskId: result.taskId,
|
||||
sliceId: result.sliceId,
|
||||
milestoneId: result.milestoneId,
|
||||
question: params.escalation.question,
|
||||
options: params.escalation.options,
|
||||
recommendation: params.escalation.recommendation,
|
||||
recommendationRationale:
|
||||
params.escalation.recommendationRationale,
|
||||
continueWithDefault: params.escalation.continueWithDefault,
|
||||
});
|
||||
escalationPath = writeEscalationArtifact(basePath, artifact);
|
||||
escalationStatus = params.escalation.continueWithDefault
|
||||
? "awaiting-review"
|
||||
: "pending";
|
||||
}
|
||||
} catch (err) {
|
||||
// Escalation is additive — never block task completion if it fails.
|
||||
logError(
|
||||
"tool",
|
||||
`sf_task_complete escalation write failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
{ tool: "sf_task_complete", op: "escalation" },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const baseText = `Completed task ${result.taskId} (${result.sliceId}/${result.milestoneId})`;
|
||||
const escalationSuffix = escalationStatus
|
||||
? ` — escalation ${escalationStatus} at ${escalationPath}`
|
||||
: "";
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Completed task ${result.taskId} (${result.sliceId}/${result.milestoneId})`,
|
||||
text: baseText + escalationSuffix,
|
||||
},
|
||||
],
|
||||
details: {
|
||||
|
|
@ -306,6 +365,8 @@ export async function executeTaskComplete(
|
|||
sliceId: result.sliceId,
|
||||
milestoneId: result.milestoneId,
|
||||
summaryPath: result.summaryPath,
|
||||
...(escalationPath ? { escalationArtifactPath: escalationPath } : {}),
|
||||
...(escalationStatus ? { escalationStatus } : {}),
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue