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:
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user