feat: planner abort path — fail fast when workspace info is missing

- PlannerMeta is now a discriminated union: planned | aborted
- Moderator routes aborted planner → END (no coder invocation)
- System prompt requires absolute workspace path, instructs abort if missing
- extractRefs handles both variants
- Test: 'planner aborted → END'

Signed-off-by: 小橘 <xiaoju@shazhou.work>
This commit is contained in:
2026-05-13 14:20:23 +00:00
parent 8d650326db
commit 82e40f0c21
3 changed files with 57 additions and 15 deletions
@@ -9,7 +9,9 @@ import type { DevelopMeta } from "../src/roles.js";
const developModerator = tableToModerator(developTable);
const DEFAULT_PHASES: PlannerMeta["phases"] = [
type PlannedMeta = Extract<PlannerMeta, { status: "planned" }>;
const DEFAULT_PHASES: PlannedMeta["phases"] = [
{
hash: "4KNMR2PX",
title: "Do the work",
@@ -36,11 +38,11 @@ function makeCtx(steps: ModeratorContext<DevelopMeta>["steps"]): ModeratorContex
};
}
function plannerStep(phases: PlannerMeta["phases"] = DEFAULT_PHASES): RoleStep<DevelopMeta> {
function plannerStep(phases: PlannedMeta["phases"] = DEFAULT_PHASES): RoleStep<DevelopMeta> {
return {
role: "planner",
contentHash: "STUBHASHPLANNER001",
meta: { phases },
meta: { status: "planned" as const, phases },
refs: phases.map((p) => p.hash),
timestamp: 1,
};
@@ -153,7 +155,7 @@ describe("developModerator", () => {
});
test("multiple planner phases → coder until all complete, then reviewer", () => {
const phases: PlannerMeta["phases"] = [
const phases: PlannedMeta["phases"] = [
{ hash: "AA000001", title: "first phase" },
{ hash: "AA000002", title: "second phase" },
];
@@ -167,7 +169,7 @@ describe("developModerator", () => {
});
test("one-shot coder reports only last phase hash → reviewer (moderator treats as all phases done)", () => {
const phases: PlannerMeta["phases"] = [
const phases: PlannedMeta["phases"] = [
{ hash: "BB000001", title: "setup branch" },
{ hash: "BB000002", title: "write tests" },
{ hash: "BB000003", title: "verify" },
@@ -179,7 +181,7 @@ describe("developModerator", () => {
});
test("unrecognised completedPhase hash → coder retry when budget allows", () => {
const phases: PlannerMeta["phases"] = [
const phases: PlannedMeta["phases"] = [
{ hash: "CC000001", title: "first phase" },
{ hash: "CC000002", title: "second phase" },
];
@@ -187,7 +189,7 @@ describe("developModerator", () => {
});
test("incomplete phases → coder retry (supervisor controls termination)", () => {
const phases: PlannerMeta["phases"] = [
const phases: PlannedMeta["phases"] = [
{ hash: "DD000001", title: "first phase" },
{ hash: "DD000002", title: "second phase" },
];
@@ -198,6 +200,17 @@ describe("developModerator", () => {
expect(developModerator(makeCtx(steps))).toBe("coder");
});
test("planner aborted → END", () => {
const abortedStep: RoleStep<DevelopMeta> = {
role: "planner",
contentHash: "STUBHASHABORT001",
meta: { status: "aborted", reason: "No workspace path provided" },
refs: [],
timestamp: 1,
};
expect(developModerator(makeCtx([abortedStep]))).toBe("__end__");
});
test("committer → END for any committer meta status", () => {
const committed = committerStep({ status: "committed", branch: "f", commitSha: "x" });
const recoverable = committerStep({
@@ -30,6 +30,18 @@ function coderFinishedAllPlannedPhases(
// ── Conditions ─────────────────────────────────────────────────────
const plannerAborted: ModeratorCondition<DevelopMeta> = {
name: "plannerAborted",
description: "The planner aborted due to insufficient information",
check: (ctx) => {
const plannerStep = ctx.steps.find((s) => s.role === "planner");
if (plannerStep === undefined) {
return false;
}
return plannerStep.meta.status === "aborted";
},
};
const allPhasesComplete: ModeratorCondition<DevelopMeta> = {
name: "allPhasesComplete",
description: "All planned phases have been completed by the coder",
@@ -38,7 +50,7 @@ const allPhasesComplete: ModeratorCondition<DevelopMeta> = {
if (plannerStep === undefined) {
return true;
}
const phases = plannerStep.meta.phases;
const phases = plannerStep.meta.status === "planned" ? plannerStep.meta.phases : [];
if (!Array.isArray(phases)) {
return true;
}
@@ -71,7 +83,10 @@ const testsPassed: ModeratorCondition<DevelopMeta> = {
const table: ModeratorTable<DevelopMeta> = {
[START]: [{ condition: "FALLBACK", role: "planner" }],
planner: [{ condition: "FALLBACK", role: "coder" }],
planner: [
{ condition: plannerAborted, role: END },
{ condition: "FALLBACK", role: "coder" },
],
coder: [
{ condition: allPhasesComplete, role: "reviewer" },
{ condition: "FALLBACK", role: "coder" },
@@ -6,16 +6,27 @@ export const phaseSchema = z.object({
title: z.string(),
});
export const plannerMetaSchema = z.object({
phases: z.array(phaseSchema),
});
export const plannerMetaSchema = z.discriminatedUnion("status", [
z.object({
status: z.literal("planned"),
phases: z.array(phaseSchema),
}),
z.object({
status: z.literal("aborted"),
reason: z.string().describe("Why the task cannot proceed"),
}),
]);
export type PlannerMeta = z.infer<typeof plannerMetaSchema>;
const PLANNER_SYSTEM = `You are a **planner** for a software task. Break the work into **sequential phases** the coder will execute one at a time.
const PLANNER_SYSTEM = `You are a **planner** for a software task. Break the work into **sequential phases** the coder will execute one at a time. **Abort** if the prompt lacks critical information (e.g. no project/workspace path, ambiguous target repo).
Run \`uncaged-workflow skill develop\` for thread ID lookup, CAS commands, and meta output guide.
## Prerequisites — check FIRST
The prompt MUST include an **absolute filesystem path** to the project workspace (e.g. \`/home/user/repos/my-project\`). If no workspace path is given and you cannot reliably infer one from context, **abort immediately** with a clear reason explaining what information is missing. Do NOT guess paths.
## Storing phase details — MANDATORY
For each phase, store its full detail text in CAS via \`uncaged-workflow cas put '<content>'\`. The command prints a content-hash — use that as the phase identifier.
@@ -37,7 +48,10 @@ Fewer phases is always better. Each phase must justify its existence — if two
## Output format
After storing all phases via the CLI, output compact JSON only:
{ "phases": [{ "hash": "<hash-from-cas-put>", "title": "<one-line-summary>" }] }
{ "status": "planned", "phases": [{ "hash": "<hash-from-cas-put>", "title": "<one-line-summary>" }] }
If aborting:
{ "status": "aborted", "reason": "<what is missing>" }
Order phases so earlier steps unblock later ones. Cover root cause, edge cases, and verification across the phases.
@@ -49,5 +63,5 @@ export const plannerRole: RoleDefinition<PlannerMeta> = {
description: "Breaks the task into sequential phases for the coder.",
systemPrompt: PLANNER_SYSTEM,
schema: plannerMetaSchema,
extractRefs: (meta) => meta.phases.map((p) => p.hash),
extractRefs: (meta) => meta.status === "planned" ? meta.phases.map((p) => p.hash) : [],
};