feat(workflow): add preparer role as first step in solve-issue workflow

- New package: @uncaged/workflow-role-preparer with PreparerMeta type,
  schema, system prompt, and extract prompt
- Preparer locates/clones repo, detects toolchain and conventions
- Moderator updated: preparer → planner → coder → reviewer → committer
- solve-issue template re-exports preparer types
- Tests updated for new flow (129 pass, 0 fail)

Fixes #28
This commit is contained in:
2026-05-07 10:12:59 +00:00
parent f80535d742
commit fe829d9ae6
9 changed files with 160 additions and 14 deletions
@@ -0,0 +1,15 @@
{
"name": "@uncaged/workflow-role-preparer",
"version": "0.1.0",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"build": "echo 'TODO'",
"test": "echo no tests"
},
"dependencies": {
"@uncaged/workflow": "workspace:*",
"zod": "^4.0.0"
}
}
@@ -0,0 +1,5 @@
export {
type PreparerMeta,
preparerMetaSchema,
preparerRole,
} from "./preparer.js";
@@ -0,0 +1,50 @@
import type { RoleDefinition } from "@uncaged/workflow";
import * as z from "zod/v4";
const toolchainSchema = z.object({
packageManager: z.union([z.string(), z.null()]),
testCommand: z.union([z.string(), z.null()]),
lintCommand: z.union([z.string(), z.null()]),
buildCommand: z.union([z.string(), z.null()]),
});
export const preparerMetaSchema = z.object({
repoPath: z.string(),
defaultBranch: z.string(),
conventions: z.union([z.string(), z.null()]),
toolchain: toolchainSchema,
});
export type PreparerMeta = z.infer<typeof preparerMetaSchema>;
const PREPARER_SYSTEM = `You are a **preparer** for a software task. Your job is to locate (or clone) the target repository locally, ensure it is up to date, and gather project context before work begins.
## Responsibilities
1. Parse the issue/task prompt to identify the target repository (URL, org/repo, or name).
2. Search for an existing local clone in these locations (in order):
- ~/Code/<repo-name>/
- ~/repos/<repo-name>/
- ~/Code/<org>/<repo-name>/
- ~/repos/<org>/<repo-name>/
3. If not found locally, \`git clone\` it into ~/repos/<repo-name>/.
4. \`git checkout main && git pull\` (or the default branch) to ensure latest.
5. Read project conventions: \`CLAUDE.md\`, \`CONTRIBUTING.md\`, \`.cursor/rules/*.mdc\`, \`CONVENTIONS.md\`.
6. Detect toolchain: package manager, test runner, linter, build system.
## Output
Report your findings as structured data:
- **repoPath**: absolute path to the local repo
- **defaultBranch**: the default branch name (e.g. "main")
- **conventions**: a summary of project conventions found, or null if none
- **toolchain**: detected commands for packageManager, testCommand, lintCommand, buildCommand (null if not detected)`;
export const preparerRole: RoleDefinition<PreparerMeta> = {
description:
"Locates or clones the target repository, ensures it is up to date, and gathers project context (conventions, toolchain).",
systemPrompt: PREPARER_SYSTEM,
extractPrompt:
"Extract repoPath (absolute path), defaultBranch, conventions (summary string or null), and toolchain (packageManager, testCommand, lintCommand, buildCommand — each string or null).",
schema: preparerMetaSchema,
};
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"]
}
@@ -10,6 +10,7 @@ import {
import type { CoderMeta } from "@uncaged/workflow-role-coder"; import type { CoderMeta } from "@uncaged/workflow-role-coder";
import type { PlannerMeta } from "@uncaged/workflow-role-planner"; import type { PlannerMeta } from "@uncaged/workflow-role-planner";
import type { PreparerMeta } from "@uncaged/workflow-role-preparer";
import { buildSolveIssueDescriptor } from "../src/descriptor.js"; import { buildSolveIssueDescriptor } from "../src/descriptor.js";
import { createSolveIssueRun, solveIssueModerator } from "../src/index.js"; import { createSolveIssueRun, solveIssueModerator } from "../src/index.js";
@@ -108,6 +109,25 @@ function makeCtx(
}; };
} }
function preparerStep(): RoleStep<SolveIssueMeta> {
return {
role: "preparer",
content: "prepared",
meta: {
repoPath: "/home/user/repos/test",
defaultBranch: "main",
conventions: null,
toolchain: {
packageManager: "bun",
testCommand: "bun test",
lintCommand: null,
buildCommand: "bun run build",
},
},
timestamp: 0,
};
}
function plannerStep(phases: PlannerMeta["phases"] = DEFAULT_PHASES): RoleStep<SolveIssueMeta> { function plannerStep(phases: PlannerMeta["phases"] = DEFAULT_PHASES): RoleStep<SolveIssueMeta> {
return { return {
role: "planner", role: "planner",
@@ -153,16 +173,17 @@ const stubExtract = createExtract({
}); });
describe("solveIssueModerator", () => { describe("solveIssueModerator", () => {
test("routes planner → coder → reviewer → committer → END", () => { test("routes preparer → planner → coder → reviewer → committer → END", () => {
expect(solveIssueModerator(makeCtx(20, []))).toBe("planner"); expect(solveIssueModerator(makeCtx(20, []))).toBe("preparer");
expect(solveIssueModerator(makeCtx(20, [plannerStep()]))).toBe("coder"); expect(solveIssueModerator(makeCtx(20, [preparerStep()]))).toBe("planner");
expect(solveIssueModerator(makeCtx(20, [plannerStep(), coderStep()]))).toBe("reviewer"); expect(solveIssueModerator(makeCtx(20, [preparerStep(), plannerStep()]))).toBe("coder");
expect(solveIssueModerator(makeCtx(20, [plannerStep(), coderStep(), reviewerStep(true)]))).toBe( expect(solveIssueModerator(makeCtx(20, [preparerStep(), plannerStep(), coderStep()]))).toBe("reviewer");
expect(solveIssueModerator(makeCtx(20, [preparerStep(), plannerStep(), coderStep(), reviewerStep(true)]))).toBe(
"committer", "committer",
); );
expect( expect(
solveIssueModerator( solveIssueModerator(
makeCtx(20, [plannerStep(), coderStep(), reviewerStep(true), committerStep()]), makeCtx(20, [preparerStep(), plannerStep(), coderStep(), reviewerStep(true), committerStep()]),
), ),
).toBe(END); ).toBe(END);
}); });
@@ -250,8 +271,19 @@ describe("createSolveIssueRun", () => {
restoreFetch = null; restoreFetch = null;
}); });
test("structured extraction yields planner meta from mocked chat completions", async () => { test("structured extraction yields preparer then planner meta from mocked chat completions", async () => {
restoreFetch = installMockChatCompletions([EXPECT_PLANNER_META]); const EXPECT_PREPARER_META: PreparerMeta = {
repoPath: "/home/user/repos/test",
defaultBranch: "main",
conventions: null,
toolchain: {
packageManager: "bun",
testCommand: "bun test",
lintCommand: null,
buildCommand: "bun run build",
},
};
restoreFetch = installMockChatCompletions([EXPECT_PREPARER_META, EXPECT_PLANNER_META]);
const run = createSolveIssueRun({ agent: async () => "" }, stubExtract); const run = createSolveIssueRun({ agent: async () => "" }, stubExtract);
const gen = run( const gen = run(
@@ -263,12 +295,26 @@ describe("createSolveIssueRun", () => {
if (first.done) { if (first.done) {
throw new Error("expected yield"); throw new Error("expected yield");
} }
expect(first.value.role).toBe("planner"); expect(first.value.role).toBe("preparer");
expect(first.value.meta).toEqual(EXPECT_PLANNER_META); expect(first.value.meta).toEqual(EXPECT_PREPARER_META);
const second = await gen.next();
expect(second.done).toBe(false);
if (second.done) {
throw new Error("expected yield");
}
expect(second.value.role).toBe("planner");
expect(second.value.meta).toEqual(EXPECT_PLANNER_META);
}); });
test("per-role agent overrides default", async () => { test("per-role agent overrides default", async () => {
restoreFetch = installMockChatCompletions([EXPECT_PLANNER_META, EXPECT_CODER_META]); const PREPARER_META: PreparerMeta = {
repoPath: "/tmp/r",
defaultBranch: "main",
conventions: null,
toolchain: { packageManager: null, testCommand: null, lintCommand: null, buildCommand: null },
};
restoreFetch = installMockChatCompletions([PREPARER_META, EXPECT_PLANNER_META, EXPECT_CODER_META]);
const calls: string[] = []; const calls: string[] = [];
const run = createSolveIssueRun( const run = createSolveIssueRun(
@@ -278,6 +324,10 @@ describe("createSolveIssueRun", () => {
return ""; return "";
}, },
overrides: { overrides: {
preparer: async () => {
calls.push("preparer");
return "";
},
planner: async () => { planner: async () => {
calls.push("planner"); calls.push("planner");
return ""; return "";
@@ -295,6 +345,10 @@ describe("createSolveIssueRun", () => {
{ threadId: "01TEST000000000000000000TR", maxRounds: 20 }, { threadId: "01TEST000000000000000000TR", maxRounds: 20 },
); );
await gen.next(); await gen.next();
expect(calls).toEqual(["preparer"]);
calls.length = 0;
await gen.next();
expect(calls).toEqual(["planner"]); expect(calls).toEqual(["planner"]);
calls.length = 0; calls.length = 0;
@@ -315,9 +369,10 @@ describe("buildSolveIssueDescriptor", () => {
"coder", "coder",
"committer", "committer",
"planner", "planner",
"preparer",
"reviewer", "reviewer",
]); ]);
for (const key of ["planner", "coder", "reviewer", "committer"] as const) { for (const key of ["preparer", "planner", "coder", "reviewer", "committer"] as const) {
const role = validated.value.roles[key]; const role = validated.value.roles[key];
expect(role).toBeDefined(); expect(role).toBeDefined();
expect(typeof role.schema).toBe("object"); expect(typeof role.schema).toBe("object");
@@ -13,6 +13,7 @@
"@uncaged/workflow-role-committer": "workspace:*", "@uncaged/workflow-role-committer": "workspace:*",
"@uncaged/workflow-role-coder": "workspace:*", "@uncaged/workflow-role-coder": "workspace:*",
"@uncaged/workflow-role-planner": "workspace:*", "@uncaged/workflow-role-planner": "workspace:*",
"@uncaged/workflow-role-preparer": "workspace:*",
"@uncaged/workflow-role-reviewer": "workspace:*" "@uncaged/workflow-role-reviewer": "workspace:*"
} }
} }
@@ -25,6 +25,11 @@ export {
plannerMetaSchema, plannerMetaSchema,
plannerRole, plannerRole,
} from "@uncaged/workflow-role-planner"; } from "@uncaged/workflow-role-planner";
export {
type PreparerMeta,
preparerMetaSchema,
preparerRole,
} from "@uncaged/workflow-role-preparer";
export { export {
type ReviewerMeta, type ReviewerMeta,
reviewerMetaSchema, reviewerMetaSchema,
@@ -48,11 +48,15 @@ export const solveIssueModerator: Moderator<SolveIssueMeta> = (ctx) => {
const maxRounds = ctx.start.meta.maxRounds; const maxRounds = ctx.start.meta.maxRounds;
if (ctx.steps.length === 0) { if (ctx.steps.length === 0) {
return "planner"; return "preparer";
} }
const last = ctx.steps[ctx.steps.length - 1]; const last = ctx.steps[ctx.steps.length - 1];
if (last.role === "preparer") {
return "planner";
}
if (last.role === "planner") { if (last.role === "planner") {
return "coder"; return "coder";
} }
@@ -2,12 +2,14 @@ import type { RoleDefinition } from "@uncaged/workflow";
import { type CoderMeta, coderRole } from "@uncaged/workflow-role-coder"; import { type CoderMeta, coderRole } from "@uncaged/workflow-role-coder";
import { type CommitterMeta, committerRole } from "@uncaged/workflow-role-committer"; import { type CommitterMeta, committerRole } from "@uncaged/workflow-role-committer";
import { type PlannerMeta, plannerRole } from "@uncaged/workflow-role-planner"; import { type PlannerMeta, plannerRole } from "@uncaged/workflow-role-planner";
import { type PreparerMeta, preparerRole } from "@uncaged/workflow-role-preparer";
import { type ReviewerMeta, reviewerRole } from "@uncaged/workflow-role-reviewer"; import { type ReviewerMeta, reviewerRole } from "@uncaged/workflow-role-reviewer";
export const SOLVE_ISSUE_WORKFLOW_DESCRIPTION = export const SOLVE_ISSUE_WORKFLOW_DESCRIPTION =
"Phased plan, incremental implementation per phase, review, and commit to resolve an issue end-to-end (planner → coder [repeat per phase] → reviewer → committer)."; "Prepare repo context, plan phases, implement incrementally, review, and commit to resolve an issue end-to-end (preparer → planner → coder [repeat per phase] → reviewer → committer).";
export type SolveIssueMeta = { export type SolveIssueMeta = {
preparer: PreparerMeta;
planner: PlannerMeta; planner: PlannerMeta;
coder: CoderMeta; coder: CoderMeta;
reviewer: ReviewerMeta; reviewer: ReviewerMeta;
@@ -19,6 +21,7 @@ export type SolveIssueRoles = {
}; };
export const solveIssueRoles: SolveIssueRoles = { export const solveIssueRoles: SolveIssueRoles = {
preparer: preparerRole,
planner: plannerRole, planner: plannerRole,
coder: coderRole, coder: coderRole,
reviewer: reviewerRole, reviewer: reviewerRole,