From fe829d9ae63cbd20ab4afd4e87cc6448bd55e89b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Thu, 7 May 2026 10:12:59 +0000 Subject: [PATCH] feat(workflow): add preparer role as first step in solve-issue workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- packages/workflow-role-preparer/package.json | 15 ++++ packages/workflow-role-preparer/src/index.ts | 5 ++ .../workflow-role-preparer/src/preparer.ts | 50 ++++++++++++ packages/workflow-role-preparer/tsconfig.json | 8 ++ .../__tests__/solve-issue-template.test.ts | 79 ++++++++++++++++--- .../package.json | 1 + .../src/index.ts | 5 ++ .../src/moderator.ts | 6 +- .../src/roles.ts | 5 +- 9 files changed, 160 insertions(+), 14 deletions(-) create mode 100644 packages/workflow-role-preparer/package.json create mode 100644 packages/workflow-role-preparer/src/index.ts create mode 100644 packages/workflow-role-preparer/src/preparer.ts create mode 100644 packages/workflow-role-preparer/tsconfig.json diff --git a/packages/workflow-role-preparer/package.json b/packages/workflow-role-preparer/package.json new file mode 100644 index 0000000..61c36f7 --- /dev/null +++ b/packages/workflow-role-preparer/package.json @@ -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" + } +} diff --git a/packages/workflow-role-preparer/src/index.ts b/packages/workflow-role-preparer/src/index.ts new file mode 100644 index 0000000..44f706a --- /dev/null +++ b/packages/workflow-role-preparer/src/index.ts @@ -0,0 +1,5 @@ +export { + type PreparerMeta, + preparerMetaSchema, + preparerRole, +} from "./preparer.js"; diff --git a/packages/workflow-role-preparer/src/preparer.ts b/packages/workflow-role-preparer/src/preparer.ts new file mode 100644 index 0000000..029160c --- /dev/null +++ b/packages/workflow-role-preparer/src/preparer.ts @@ -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; + +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// + - ~/repos// + - ~/Code/// + - ~/repos/// +3. If not found locally, \`git clone\` it into ~/repos//. +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 = { + 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, +}; diff --git a/packages/workflow-role-preparer/tsconfig.json b/packages/workflow-role-preparer/tsconfig.json new file mode 100644 index 0000000..75eba9f --- /dev/null +++ b/packages/workflow-role-preparer/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src"] +} diff --git a/packages/workflow-template-solve-issue/__tests__/solve-issue-template.test.ts b/packages/workflow-template-solve-issue/__tests__/solve-issue-template.test.ts index 0c20ca6..0803910 100644 --- a/packages/workflow-template-solve-issue/__tests__/solve-issue-template.test.ts +++ b/packages/workflow-template-solve-issue/__tests__/solve-issue-template.test.ts @@ -10,6 +10,7 @@ import { import type { CoderMeta } from "@uncaged/workflow-role-coder"; import type { PlannerMeta } from "@uncaged/workflow-role-planner"; +import type { PreparerMeta } from "@uncaged/workflow-role-preparer"; import { buildSolveIssueDescriptor } from "../src/descriptor.js"; import { createSolveIssueRun, solveIssueModerator } from "../src/index.js"; @@ -108,6 +109,25 @@ function makeCtx( }; } +function preparerStep(): RoleStep { + 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 { return { role: "planner", @@ -153,16 +173,17 @@ const stubExtract = createExtract({ }); describe("solveIssueModerator", () => { - test("routes planner → coder → reviewer → committer → END", () => { - expect(solveIssueModerator(makeCtx(20, []))).toBe("planner"); - expect(solveIssueModerator(makeCtx(20, [plannerStep()]))).toBe("coder"); - expect(solveIssueModerator(makeCtx(20, [plannerStep(), coderStep()]))).toBe("reviewer"); - expect(solveIssueModerator(makeCtx(20, [plannerStep(), coderStep(), reviewerStep(true)]))).toBe( + test("routes preparer → planner → coder → reviewer → committer → END", () => { + expect(solveIssueModerator(makeCtx(20, []))).toBe("preparer"); + expect(solveIssueModerator(makeCtx(20, [preparerStep()]))).toBe("planner"); + expect(solveIssueModerator(makeCtx(20, [preparerStep(), plannerStep()]))).toBe("coder"); + expect(solveIssueModerator(makeCtx(20, [preparerStep(), plannerStep(), coderStep()]))).toBe("reviewer"); + expect(solveIssueModerator(makeCtx(20, [preparerStep(), plannerStep(), coderStep(), reviewerStep(true)]))).toBe( "committer", ); expect( solveIssueModerator( - makeCtx(20, [plannerStep(), coderStep(), reviewerStep(true), committerStep()]), + makeCtx(20, [preparerStep(), plannerStep(), coderStep(), reviewerStep(true), committerStep()]), ), ).toBe(END); }); @@ -250,8 +271,19 @@ describe("createSolveIssueRun", () => { restoreFetch = null; }); - test("structured extraction yields planner meta from mocked chat completions", async () => { - restoreFetch = installMockChatCompletions([EXPECT_PLANNER_META]); + test("structured extraction yields preparer then planner meta from mocked chat completions", async () => { + 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 gen = run( @@ -263,12 +295,26 @@ describe("createSolveIssueRun", () => { if (first.done) { throw new Error("expected yield"); } - expect(first.value.role).toBe("planner"); - expect(first.value.meta).toEqual(EXPECT_PLANNER_META); + expect(first.value.role).toBe("preparer"); + 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 () => { - 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 run = createSolveIssueRun( @@ -278,6 +324,10 @@ describe("createSolveIssueRun", () => { return ""; }, overrides: { + preparer: async () => { + calls.push("preparer"); + return ""; + }, planner: async () => { calls.push("planner"); return ""; @@ -295,6 +345,10 @@ describe("createSolveIssueRun", () => { { threadId: "01TEST000000000000000000TR", maxRounds: 20 }, ); await gen.next(); + expect(calls).toEqual(["preparer"]); + + calls.length = 0; + await gen.next(); expect(calls).toEqual(["planner"]); calls.length = 0; @@ -315,9 +369,10 @@ describe("buildSolveIssueDescriptor", () => { "coder", "committer", "planner", + "preparer", "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]; expect(role).toBeDefined(); expect(typeof role.schema).toBe("object"); diff --git a/packages/workflow-template-solve-issue/package.json b/packages/workflow-template-solve-issue/package.json index fc0abfd..b31fdde 100644 --- a/packages/workflow-template-solve-issue/package.json +++ b/packages/workflow-template-solve-issue/package.json @@ -13,6 +13,7 @@ "@uncaged/workflow-role-committer": "workspace:*", "@uncaged/workflow-role-coder": "workspace:*", "@uncaged/workflow-role-planner": "workspace:*", + "@uncaged/workflow-role-preparer": "workspace:*", "@uncaged/workflow-role-reviewer": "workspace:*" } } diff --git a/packages/workflow-template-solve-issue/src/index.ts b/packages/workflow-template-solve-issue/src/index.ts index 1245e4b..640cd18 100644 --- a/packages/workflow-template-solve-issue/src/index.ts +++ b/packages/workflow-template-solve-issue/src/index.ts @@ -25,6 +25,11 @@ export { plannerMetaSchema, plannerRole, } from "@uncaged/workflow-role-planner"; +export { + type PreparerMeta, + preparerMetaSchema, + preparerRole, +} from "@uncaged/workflow-role-preparer"; export { type ReviewerMeta, reviewerMetaSchema, diff --git a/packages/workflow-template-solve-issue/src/moderator.ts b/packages/workflow-template-solve-issue/src/moderator.ts index 39a6d05..ee4c9b3 100644 --- a/packages/workflow-template-solve-issue/src/moderator.ts +++ b/packages/workflow-template-solve-issue/src/moderator.ts @@ -48,11 +48,15 @@ export const solveIssueModerator: Moderator = (ctx) => { const maxRounds = ctx.start.meta.maxRounds; if (ctx.steps.length === 0) { - return "planner"; + return "preparer"; } const last = ctx.steps[ctx.steps.length - 1]; + if (last.role === "preparer") { + return "planner"; + } + if (last.role === "planner") { return "coder"; } diff --git a/packages/workflow-template-solve-issue/src/roles.ts b/packages/workflow-template-solve-issue/src/roles.ts index 6cab9d3..625269a 100644 --- a/packages/workflow-template-solve-issue/src/roles.ts +++ b/packages/workflow-template-solve-issue/src/roles.ts @@ -2,12 +2,14 @@ import type { RoleDefinition } from "@uncaged/workflow"; import { type CoderMeta, coderRole } from "@uncaged/workflow-role-coder"; import { type CommitterMeta, committerRole } from "@uncaged/workflow-role-committer"; 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"; 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 = { + preparer: PreparerMeta; planner: PlannerMeta; coder: CoderMeta; reviewer: ReviewerMeta; @@ -19,6 +21,7 @@ export type SolveIssueRoles = { }; export const solveIssueRoles: SolveIssueRoles = { + preparer: preparerRole, planner: plannerRole, coder: coderRole, reviewer: reviewerRole,