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 new file mode 100644 index 0000000..c054188 --- /dev/null +++ b/packages/workflow-template-solve-issue/__tests__/solve-issue-template.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, test } from "bun:test"; +import { + END, + type RoleStep, + START, + type ThreadContext, + validateWorkflowDescriptor, +} from "@uncaged/workflow"; + +import { buildSolveIssueDescriptor } from "../src/descriptor.js"; +import { solveIssueModerator } from "../src/moderator.js"; +import { createSolveIssueRoles, type SolveIssueMeta } from "../src/roles.js"; + +function makeStart(maxRounds: number): ThreadContext["start"] { + return { + role: START, + content: "Fix the flaky login test", + meta: { maxRounds }, + timestamp: 0, + }; +} + +function makeCtx( + maxRounds: number, + steps: ThreadContext["steps"], +): ThreadContext { + return { + start: makeStart(maxRounds), + steps, + }; +} + +function plannerStep(): RoleStep { + return { + role: "planner", + content: "plan", + meta: { plan: "do work", files: ["a.ts"], approach: "minimal fix" }, + timestamp: 1, + }; +} + +function coderStep(): RoleStep { + return { + role: "coder", + content: "code", + meta: { filesChanged: ["a.ts"], summary: "fixed" }, + timestamp: 2, + }; +} + +function reviewerStep(approved: boolean): RoleStep { + return { + role: "reviewer", + content: "rev", + meta: { approved }, + timestamp: 3, + }; +} + +function committerStep(): RoleStep { + return { + role: "committer", + content: "commit", + meta: { committed: true }, + timestamp: 4, + }; +} + +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( + "committer", + ); + expect( + solveIssueModerator( + makeCtx(20, [plannerStep(), coderStep(), reviewerStep(true), committerStep()]), + ), + ).toBe(END); + }); + + test("reviewer rejects → coder retry when budget allows", () => { + const steps: ThreadContext["steps"] = [ + plannerStep(), + coderStep(), + reviewerStep(false), + ]; + expect(solveIssueModerator(makeCtx(20, steps))).toBe("coder"); + }); + + test("reviewer rejects → END when max rounds exhausted", () => { + const steps: ThreadContext["steps"] = [ + plannerStep(), + coderStep(), + reviewerStep(false), + ]; + expect(solveIssueModerator(makeCtx(4, steps))).toBe(END); + }); +}); + +describe("createSolveIssueRoles", () => { + test("returns all four role callables", async () => { + const agent = async () => '{"plan":"x","files":[],"approach":"y"}'; + const roles = createSolveIssueRoles({ + agent, + workdir: "/tmp/repo", + extract: null, + }); + + expect(typeof roles.planner).toBe("function"); + expect(typeof roles.coder).toBe("function"); + expect(typeof roles.reviewer).toBe("function"); + expect(typeof roles.committer).toBe("function"); + + const ctx = makeCtx(10, []); + const plannerOut = await roles.planner(ctx); + expect(plannerOut.meta.plan).toBe(""); + expect(Array.isArray(plannerOut.meta.files)).toBe(true); + }); +}); + +describe("buildSolveIssueDescriptor", () => { + test("lists all roles with schemas that validate", () => { + const descriptor = buildSolveIssueDescriptor(); + const validated = validateWorkflowDescriptor(descriptor); + expect(validated.ok).toBe(true); + if (!validated.ok) { + throw new Error(validated.error); + } + expect(Object.keys(validated.value.roles).sort()).toEqual([ + "coder", + "committer", + "planner", + "reviewer", + ]); + for (const key of ["planner", "coder", "reviewer", "committer"] as const) { + const role = validated.value.roles[key]; + expect(role).toBeDefined(); + expect(typeof role.schema).toBe("object"); + expect(role.schema).not.toBeNull(); + expect(Array.isArray(role.schema)).toBe(false); + } + }); +}); diff --git a/packages/workflow-template-solve-issue/package.json b/packages/workflow-template-solve-issue/package.json new file mode 100644 index 0000000..5c0815a --- /dev/null +++ b/packages/workflow-template-solve-issue/package.json @@ -0,0 +1,19 @@ +{ + "name": "@uncaged/workflow-template-solve-issue", + "version": "0.1.0", + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts", + "scripts": { + "build": "echo 'TODO'", + "test": "bun test" + }, + "dependencies": { + "@uncaged/workflow": "workspace:*", + "@uncaged/workflow-agent-cursor": "workspace:*", + "@uncaged/workflow-role-committer": "workspace:*", + "@uncaged/workflow-role-llm": "workspace:*", + "@uncaged/workflow-role-reviewer": "workspace:*", + "zod": "^4.0.0" + } +} diff --git a/packages/workflow-template-solve-issue/src/descriptor.ts b/packages/workflow-template-solve-issue/src/descriptor.ts new file mode 100644 index 0000000..f15b659 --- /dev/null +++ b/packages/workflow-template-solve-issue/src/descriptor.ts @@ -0,0 +1,34 @@ +import { committerMetaSchema } from "@uncaged/workflow-role-committer"; +import { buildDescriptorFromRoles } from "@uncaged/workflow-role-llm"; +import { reviewerMetaSchema } from "@uncaged/workflow-role-reviewer"; + +import { coderMetaSchema, plannerMetaSchema } from "./roles.js"; + +export function buildSolveIssueDescriptor() { + return buildDescriptorFromRoles({ + description: + "Plan, implement, review, and commit changes to resolve an issue end-to-end (planner → coder → reviewer → committer).", + roles: { + planner: { + name: "planner", + schema: plannerMetaSchema, + description: "Analyzes the issue and proposes plan, files, and approach.", + }, + coder: { + name: "coder", + schema: coderMetaSchema, + description: "Implements the planner output and summarizes touched files.", + }, + reviewer: { + name: "reviewer", + schema: reviewerMetaSchema, + description: "Runs git diff checks and sets approved when the change is ready.", + }, + committer: { + name: "committer", + schema: committerMetaSchema, + description: "Creates branch, commits, and pushes when review passes.", + }, + }, + }); +} diff --git a/packages/workflow-template-solve-issue/src/index.ts b/packages/workflow-template-solve-issue/src/index.ts new file mode 100644 index 0000000..61b0894 --- /dev/null +++ b/packages/workflow-template-solve-issue/src/index.ts @@ -0,0 +1,29 @@ +import { createRoleModerator, type WorkflowFn } from "@uncaged/workflow"; + +import { solveIssueModerator } from "./moderator.js"; +import { createSolveIssueRoles, type SolveIssueMeta, type SolveIssueRolesConfig } from "./roles.js"; + +export { type CursorAgentConfig, createCursorAgent } from "@uncaged/workflow-agent-cursor"; +export { buildSolveIssueDescriptor } from "./descriptor.js"; +export { solveIssueModerator } from "./moderator.js"; +export { + type CoderMeta, + coderMetaSchema, + createSolveIssueRoles, + type PlannerMeta, + plannerMetaSchema, + type SolveIssueMeta, + type SolveIssueRoles, + type SolveIssueRolesConfig, +} from "./roles.js"; + +/** + * Factory for a {@link WorkflowFn}: supply an agent and repo paths at runtime, then pass the result + * to the bundle `run` export pattern (`createRoleModerator` is already applied). + */ +export function createSolveIssueRun(config: SolveIssueRolesConfig): WorkflowFn { + return createRoleModerator({ + roles: createSolveIssueRoles(config), + moderator: solveIssueModerator, + }); +} diff --git a/packages/workflow-template-solve-issue/src/moderator.ts b/packages/workflow-template-solve-issue/src/moderator.ts new file mode 100644 index 0000000..10e3561 --- /dev/null +++ b/packages/workflow-template-solve-issue/src/moderator.ts @@ -0,0 +1,38 @@ +import type { Moderator } from "@uncaged/workflow"; +import { END } from "@uncaged/workflow"; + +import type { SolveIssueMeta } from "./roles.js"; + +export const solveIssueModerator: Moderator = (ctx) => { + const maxRounds = ctx.start.meta.maxRounds; + + if (ctx.steps.length === 0) { + return "planner"; + } + + const last = ctx.steps[ctx.steps.length - 1]; + + if (last.role === "planner") { + return "coder"; + } + + if (last.role === "coder") { + return "reviewer"; + } + + if (last.role === "reviewer") { + if (last.meta.approved === true) { + return "committer"; + } + if (ctx.steps.length < maxRounds - 1) { + return "coder"; + } + return END; + } + + if (last.role === "committer") { + return END; + } + + return END; +}; diff --git a/packages/workflow-template-solve-issue/src/roles.ts b/packages/workflow-template-solve-issue/src/roles.ts new file mode 100644 index 0000000..99e872d --- /dev/null +++ b/packages/workflow-template-solve-issue/src/roles.ts @@ -0,0 +1,110 @@ +import type { AgentFn, Role } from "@uncaged/workflow"; +import { type CommitterMeta, createCommitterRole } from "@uncaged/workflow-role-committer"; +import { createRole, type LlmProvider } from "@uncaged/workflow-role-llm"; +import { createReviewerRole, type ReviewerMeta } from "@uncaged/workflow-role-reviewer"; +import * as z from "zod/v4"; + +const DRY_RUN_PROVIDER: LlmProvider = { + baseUrl: "http://127.0.0.1:9", + apiKey: "", + model: "template-dry-run", +}; + +const PLANNER_SYSTEM = `You are a **planner** for a software task. Analyze the issue, list relevant files, and produce a clear step-by-step approach. + +Focus on: root cause, edge cases, and how the implementation will be verified. Output enough detail for a coding agent to implement without guessing.`; + +const CODER_SYSTEM = `You are a **coder**. The previous step produced a plan: read the thread and implement that plan in the repository. + +Make focused changes, follow project conventions, and explain what you changed.`; + +export const plannerMetaSchema = z.object({ + plan: z.string(), + files: z.array(z.string()), + approach: z.string(), +}); + +export const coderMetaSchema = z.object({ + filesChanged: z.array(z.string()), + summary: z.string(), +}); + +export type PlannerMeta = z.infer; + +export type CoderMeta = z.infer; + +export type SolveIssueMeta = { + planner: PlannerMeta; + coder: CoderMeta; + reviewer: ReviewerMeta; + committer: CommitterMeta; +}; + +/** Wiring for workflow-role LLM structured extraction. Use null for schema-default dry runs (tests / stubs). */ +export type SolveIssueRolesConfig = { + agent: AgentFn; + workdir: string; + extract: { provider: LlmProvider; dryRun: boolean | null } | null; +}; + +function resolveExtract(config: SolveIssueRolesConfig): { + provider: LlmProvider; + dryRun: boolean | null; +} { + if (config.extract === null) { + return { provider: DRY_RUN_PROVIDER, dryRun: true }; + } + return config.extract; +} + +export type SolveIssueRoles = { + planner: Role; + coder: Role; + reviewer: Role; + committer: Role; +}; + +export function createSolveIssueRoles(config: SolveIssueRolesConfig): SolveIssueRoles { + const extract = resolveExtract(config); + const reviewerGit = { + cwd: config.workdir, + conventionsPath: null, + extraChecks: [], + threadId: null, + }; + const committerGit = { + cwd: config.workdir, + remote: "origin", + threadId: null, + }; + + const planner: Role = createRole({ + name: "planner", + schema: plannerMetaSchema, + systemPrompt: PLANNER_SYSTEM, + agent: config.agent, + extract: { provider: extract.provider, dryRun: extract.dryRun }, + }); + + const coder: Role = createRole({ + name: "coder", + schema: coderMetaSchema, + systemPrompt: CODER_SYSTEM, + agent: config.agent, + extract: { provider: extract.provider, dryRun: extract.dryRun }, + }); + + const reviewer: Role = createReviewerRole( + config.agent, + { provider: extract.provider, dryRun: extract.dryRun }, + reviewerGit, + ); + + const committer: Role = createCommitterRole( + config.agent, + { provider: extract.provider, dryRun: extract.dryRun }, + committerGit, + ); + + return { planner, coder, reviewer, committer }; +} diff --git a/packages/workflow-template-solve-issue/tsconfig.json b/packages/workflow-template-solve-issue/tsconfig.json new file mode 100644 index 0000000..b270640 --- /dev/null +++ b/packages/workflow-template-solve-issue/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "composite": true + }, + "include": ["src/**/*.ts"], + "references": [{ "path": "../workflow" }, { "path": "../workflow-role-llm" }] +} diff --git a/tsconfig.json b/tsconfig.json index 5488c73..d7ea444 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -22,6 +22,7 @@ { "path": "packages/workflow-role-reviewer" }, { "path": "packages/workflow-agent-cursor" }, { "path": "packages/workflow-agent-hermes" }, - { "path": "packages/cli-workflow" } + { "path": "packages/cli-workflow" }, + { "path": "packages/workflow-template-solve-issue" } ] }