feat(planner,coder,moderator): integrate CAS for phase tracking
Phase 2 of #23: - Planner schema compact: {hash, title} only, details stored via CAS CLI - Planner prompt instructs agent to shell out `cas put` for each phase - Coder prompt instructs agent to `cas get` for phase details, report hash - Moderator compares hashes instead of names - Removed COMPLETED_PHASE_SENTINELS — hash matching eliminates ambiguity Refs #23
This commit is contained in:
@@ -10,13 +10,17 @@ export const coderMetaSchema = z.object({
|
|||||||
export type CoderMeta = z.infer<typeof coderMetaSchema>;
|
export type CoderMeta = z.infer<typeof coderMetaSchema>;
|
||||||
|
|
||||||
const CODER_SYSTEM = `You are a **coder**. Read the thread for the plan and work on the NEXT incomplete phase only.
|
const CODER_SYSTEM = `You are a **coder**. Read the thread for the plan and work on the NEXT incomplete phase only.
|
||||||
Each planner phase is identified by a hash (8-char Crockford Base32) and a title (one-line summary). Report which phase you completed using the planner's exact phase name. If you legitimately finish every remaining phase in this single turn, set completedPhase to the last phase name in the plan (the workflow treats that as full completion). List the files you changed and summarize what you did.`;
|
Each planner phase is identified by a content-hash and a title. To read a phase's full details (name, description, acceptance criteria), run:
|
||||||
|
|
||||||
|
uncaged-workflow cas get <thread-id> <hash>
|
||||||
|
|
||||||
|
Report which phase you completed using the phase **hash** (not the title). If you legitimately finish every remaining phase in this single turn, set completedPhase to the **last** phase hash in the plan (the workflow treats that as full completion). List the files you changed and summarize what you did.`;
|
||||||
|
|
||||||
export const coderRole: RoleDefinition<CoderMeta> = {
|
export const coderRole: RoleDefinition<CoderMeta> = {
|
||||||
description:
|
description:
|
||||||
"Implements the next incomplete planner phase and reports structured completion metadata.",
|
"Implements the next incomplete planner phase and reports structured completion metadata.",
|
||||||
systemPrompt: CODER_SYSTEM,
|
systemPrompt: CODER_SYSTEM,
|
||||||
extractPrompt:
|
extractPrompt:
|
||||||
"Extract completedPhase: the planner phase name finished this round (exact string from the plan). If multiple phases were finished in one round, use the last finished phase name. Extract filesChanged and a summary of the work.",
|
"Extract completedPhase: the planner phase hash finished this round (exact hash string from the plan). If multiple phases were finished in one round, use the last finished phase hash. Extract filesChanged and a summary of the work.",
|
||||||
schema: coderMetaSchema,
|
schema: coderMetaSchema,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,9 +4,6 @@ import * as z from "zod/v4";
|
|||||||
export const phaseSchema = z.object({
|
export const phaseSchema = z.object({
|
||||||
hash: z.string(),
|
hash: z.string(),
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
name: z.string(),
|
|
||||||
description: z.string(),
|
|
||||||
acceptance: z.string(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const plannerMetaSchema = z.object({
|
export const plannerMetaSchema = z.object({
|
||||||
@@ -17,14 +14,21 @@ 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.
|
||||||
|
|
||||||
Each phase must have: a short **name** (stable identifier), a one-line **title** summarising the phase goal, a **hash** (8-char Crockford Base32 identifier unique within this plan), a **description** of what to do in that phase, and **acceptance** criteria for when that phase is done.
|
For each phase, decide on a name, detailed description, and acceptance criteria. Then store the full detail text in CAS so the coder can retrieve it later:
|
||||||
|
|
||||||
Order phases so earlier steps unblock later ones. Cover root cause, edge cases, and verification across the phases. Do not emit separate file lists or a free-form "approach" field — put that detail inside phase descriptions.`;
|
uncaged-workflow cas put <thread-id> "# <name>\n\nDescription: <description>\n\nAcceptance: <acceptance>"
|
||||||
|
|
||||||
|
The command prints a content-hash to stdout. Use that hash as the phase identifier.
|
||||||
|
|
||||||
|
Your final structured output must contain compact phases only:
|
||||||
|
{ "phases": [{ "hash": "<hash-from-cas-put>", "title": "<one-line-summary>" }] }
|
||||||
|
|
||||||
|
The current thread ID is provided in the thread context. Order phases so earlier steps unblock later ones. Cover root cause, edge cases, and verification across the phases.`;
|
||||||
|
|
||||||
export const plannerRole: RoleDefinition<PlannerMeta> = {
|
export const plannerRole: RoleDefinition<PlannerMeta> = {
|
||||||
description: "Breaks the task into sequential phases for the coder.",
|
description: "Breaks the task into sequential phases for the coder.",
|
||||||
systemPrompt: PLANNER_SYSTEM,
|
systemPrompt: PLANNER_SYSTEM,
|
||||||
extractPrompt:
|
extractPrompt:
|
||||||
"Extract the implementation phases from the agent's analysis. Each phase needs a hash (8-char Crockford Base32 unique within this plan), a title (one-line summary), a name, a description, and acceptance criteria.",
|
"Extract the implementation phases from the agent's output. Each phase has a hash (the CAS content-hash returned by the cas put command) and a title (one-line summary).",
|
||||||
schema: plannerMetaSchema,
|
schema: plannerMetaSchema,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,9 +19,6 @@ const DEFAULT_PHASES: PlannerMeta["phases"] = [
|
|||||||
{
|
{
|
||||||
hash: "4KNMR2PX",
|
hash: "4KNMR2PX",
|
||||||
title: "Do the work",
|
title: "Do the work",
|
||||||
name: "phase-a",
|
|
||||||
description: "Do the work",
|
|
||||||
acceptance: "Done",
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -30,15 +27,12 @@ const EXPECT_PLANNER_META: PlannerMeta = {
|
|||||||
{
|
{
|
||||||
hash: "7BQST3VW",
|
hash: "7BQST3VW",
|
||||||
title: "placeholder phase",
|
title: "placeholder phase",
|
||||||
name: "phase-1",
|
|
||||||
description: "placeholder",
|
|
||||||
acceptance: "placeholder",
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const EXPECT_CODER_META: CoderMeta = {
|
const EXPECT_CODER_META: CoderMeta = {
|
||||||
completedPhase: "phase-1",
|
completedPhase: "7BQST3VW",
|
||||||
filesChanged: [],
|
filesChanged: [],
|
||||||
summary: "",
|
summary: "",
|
||||||
};
|
};
|
||||||
@@ -123,7 +117,7 @@ function plannerStep(phases: PlannerMeta["phases"] = DEFAULT_PHASES): RoleStep<S
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function coderStep(completedPhase = "phase-a"): RoleStep<SolveIssueMeta> {
|
function coderStep(completedPhase = "4KNMR2PX"): RoleStep<SolveIssueMeta> {
|
||||||
return {
|
return {
|
||||||
role: "coder",
|
role: "coder",
|
||||||
content: "code",
|
content: "code",
|
||||||
@@ -196,101 +190,54 @@ describe("solveIssueModerator", () => {
|
|||||||
{
|
{
|
||||||
hash: "AA000001",
|
hash: "AA000001",
|
||||||
title: "first phase",
|
title: "first phase",
|
||||||
name: "p1",
|
|
||||||
description: "first",
|
|
||||||
acceptance: "a1",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
hash: "AA000002",
|
hash: "AA000002",
|
||||||
title: "second phase",
|
title: "second phase",
|
||||||
name: "p2",
|
|
||||||
description: "second",
|
|
||||||
acceptance: "a2",
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
expect(solveIssueModerator(makeCtx(20, [plannerStep(phases)]))).toBe("coder");
|
expect(solveIssueModerator(makeCtx(20, [plannerStep(phases)]))).toBe("coder");
|
||||||
expect(solveIssueModerator(makeCtx(20, [plannerStep(phases), coderStep("p1")]))).toBe("coder");
|
expect(solveIssueModerator(makeCtx(20, [plannerStep(phases), coderStep("AA000001")]))).toBe(
|
||||||
|
"coder",
|
||||||
|
);
|
||||||
expect(
|
expect(
|
||||||
solveIssueModerator(makeCtx(20, [plannerStep(phases), coderStep("p1"), coderStep("p2")])),
|
solveIssueModerator(
|
||||||
|
makeCtx(20, [plannerStep(phases), coderStep("AA000001"), coderStep("AA000002")]),
|
||||||
|
),
|
||||||
).toBe("reviewer");
|
).toBe("reviewer");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("one-shot coder reports only last phase name → reviewer (moderator treats as all phases done)", () => {
|
test("one-shot coder reports only last phase hash → reviewer (moderator treats as all phases done)", () => {
|
||||||
const phases: PlannerMeta["phases"] = [
|
const phases: PlannerMeta["phases"] = [
|
||||||
{
|
{ hash: "BB000001", title: "setup branch" },
|
||||||
hash: "BB000001",
|
{ hash: "BB000002", title: "write tests" },
|
||||||
title: "setup branch",
|
{ hash: "BB000003", title: "verify" },
|
||||||
name: "setup-branch",
|
{ hash: "BB000004", title: "commit and pr" },
|
||||||
description: "branch",
|
|
||||||
acceptance: "branch exists",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
hash: "BB000002",
|
|
||||||
title: "write tests",
|
|
||||||
name: "write-tests",
|
|
||||||
description: "tests",
|
|
||||||
acceptance: "tests pass",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
hash: "BB000003",
|
|
||||||
title: "verify",
|
|
||||||
name: "verify",
|
|
||||||
description: "verify",
|
|
||||||
acceptance: "ok",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
hash: "BB000004",
|
|
||||||
title: "commit and pr",
|
|
||||||
name: "commit-and-pr",
|
|
||||||
description: "pr",
|
|
||||||
acceptance: "pr open",
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
expect(
|
expect(solveIssueModerator(makeCtx(20, [plannerStep(phases), coderStep("BB000004")]))).toBe(
|
||||||
solveIssueModerator(makeCtx(20, [plannerStep(phases), coderStep("commit-and-pr")])),
|
"reviewer",
|
||||||
).toBe("reviewer");
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("completedPhase sentinel when not a planned name → reviewer", () => {
|
test("unrecognised completedPhase hash → coder retry when budget allows", () => {
|
||||||
const phases: PlannerMeta["phases"] = [
|
const phases: PlannerMeta["phases"] = [
|
||||||
{
|
{ hash: "CC000001", title: "first phase" },
|
||||||
hash: "CC000001",
|
{ hash: "CC000002", title: "second phase" },
|
||||||
title: "first phase",
|
|
||||||
name: "p1",
|
|
||||||
description: "first",
|
|
||||||
acceptance: "a1",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
hash: "CC000002",
|
|
||||||
title: "second phase",
|
|
||||||
name: "p2",
|
|
||||||
description: "second",
|
|
||||||
acceptance: "a2",
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
expect(solveIssueModerator(makeCtx(20, [plannerStep(phases), coderStep("all-done")]))).toBe(
|
expect(solveIssueModerator(makeCtx(20, [plannerStep(phases), coderStep("all-done")]))).toBe(
|
||||||
"reviewer",
|
"coder",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("incomplete phases → END when max rounds exhausted", () => {
|
test("incomplete phases → END when max rounds exhausted", () => {
|
||||||
const phases: PlannerMeta["phases"] = [
|
const phases: PlannerMeta["phases"] = [
|
||||||
{
|
{ hash: "DD000001", title: "first phase" },
|
||||||
hash: "DD000001",
|
{ hash: "DD000002", title: "second phase" },
|
||||||
title: "first phase",
|
];
|
||||||
name: "p1",
|
const steps: ModeratorContext<SolveIssueMeta>["steps"] = [
|
||||||
description: "first",
|
plannerStep(phases),
|
||||||
acceptance: "a1",
|
coderStep("DD000001"),
|
||||||
},
|
|
||||||
{
|
|
||||||
hash: "DD000002",
|
|
||||||
title: "second phase",
|
|
||||||
name: "p2",
|
|
||||||
description: "second",
|
|
||||||
acceptance: "a2",
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
const steps: ModeratorContext<SolveIssueMeta>["steps"] = [plannerStep(phases), coderStep("p1")];
|
|
||||||
expect(solveIssueModerator(makeCtx(3, steps))).toBe(END);
|
expect(solveIssueModerator(makeCtx(3, steps))).toBe(END);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,28 +3,23 @@ import { END } from "@uncaged/workflow";
|
|||||||
|
|
||||||
import type { SolveIssueMeta } from "./roles.js";
|
import type { SolveIssueMeta } from "./roles.js";
|
||||||
|
|
||||||
const COMPLETED_PHASE_SENTINELS = new Set(["all-done", "all_done", "complete"]);
|
|
||||||
|
|
||||||
function coderFinishedAllPlannedPhases(
|
function coderFinishedAllPlannedPhases(
|
||||||
phases: ReadonlyArray<{ name: string }>,
|
phases: ReadonlyArray<{ hash: string }>,
|
||||||
coderCompletedPhases: ReadonlyArray<string>,
|
coderCompletedPhases: ReadonlyArray<string>,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (phases.length === 0) {
|
if (phases.length === 0) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const plannedNames = new Set(phases.map((p) => p.name));
|
const plannedHashes = new Set(phases.map((p) => p.hash));
|
||||||
const lastName = phases[phases.length - 1].name;
|
const lastHash = phases[phases.length - 1].hash;
|
||||||
const explicit = new Set(coderCompletedPhases.filter((name) => plannedNames.has(name)));
|
const explicit = new Set(coderCompletedPhases.filter((h) => plannedHashes.has(h)));
|
||||||
if (phases.every((p) => explicit.has(p.name))) {
|
if (phases.every((p) => explicit.has(p.hash))) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
// One-shot runs often report only the final phase; treat that as the full plan done.
|
if (coderCompletedPhases.some((h) => h === lastHash)) {
|
||||||
if (coderCompletedPhases.some((name) => name === lastName)) {
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return coderCompletedPhases.some(
|
return false;
|
||||||
(name) => !plannedNames.has(name) && COMPLETED_PHASE_SENTINELS.has(name),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function nextAfterCoder(
|
function nextAfterCoder(
|
||||||
|
|||||||
Reference in New Issue
Block a user