feat: @uncaged/workflow-template-solve-issue — first workflow template

planner → coder → reviewer → committer flow with retry logic.
- createSolveIssueWorkflow factory (agent-agnostic)
- buildSolveIssueDescriptor with zod@4 JSON Schema
- Moderator: reviewer reject → coder retry, maxRounds → END
- 103 tests pass, biome clean

Closes #13
小橘 <xiaoju@shazhou.work>
This commit is contained in:
2026-05-06 07:04:28 +00:00
parent 78d883ec5d
commit db5cbd49e2
8 changed files with 388 additions and 1 deletions
@@ -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<SolveIssueMeta>["start"] {
return {
role: START,
content: "Fix the flaky login test",
meta: { maxRounds },
timestamp: 0,
};
}
function makeCtx(
maxRounds: number,
steps: ThreadContext<SolveIssueMeta>["steps"],
): ThreadContext<SolveIssueMeta> {
return {
start: makeStart(maxRounds),
steps,
};
}
function plannerStep(): RoleStep<SolveIssueMeta> {
return {
role: "planner",
content: "plan",
meta: { plan: "do work", files: ["a.ts"], approach: "minimal fix" },
timestamp: 1,
};
}
function coderStep(): RoleStep<SolveIssueMeta> {
return {
role: "coder",
content: "code",
meta: { filesChanged: ["a.ts"], summary: "fixed" },
timestamp: 2,
};
}
function reviewerStep(approved: boolean): RoleStep<SolveIssueMeta> {
return {
role: "reviewer",
content: "rev",
meta: { approved },
timestamp: 3,
};
}
function committerStep(): RoleStep<SolveIssueMeta> {
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<SolveIssueMeta>["steps"] = [
plannerStep(),
coderStep(),
reviewerStep(false),
];
expect(solveIssueModerator(makeCtx(20, steps))).toBe("coder");
});
test("reviewer rejects → END when max rounds exhausted", () => {
const steps: ThreadContext<SolveIssueMeta>["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);
}
});
});
@@ -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"
}
}
@@ -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.",
},
},
});
}
@@ -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<SolveIssueMeta>({
roles: createSolveIssueRoles(config),
moderator: solveIssueModerator,
});
}
@@ -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<SolveIssueMeta> = (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;
};
@@ -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<typeof plannerMetaSchema>;
export type CoderMeta = z.infer<typeof coderMetaSchema>;
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<PlannerMeta>;
coder: Role<CoderMeta>;
reviewer: Role<ReviewerMeta>;
committer: Role<CommitterMeta>;
};
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<PlannerMeta> = createRole({
name: "planner",
schema: plannerMetaSchema,
systemPrompt: PLANNER_SYSTEM,
agent: config.agent,
extract: { provider: extract.provider, dryRun: extract.dryRun },
});
const coder: Role<CoderMeta> = createRole({
name: "coder",
schema: coderMetaSchema,
systemPrompt: CODER_SYSTEM,
agent: config.agent,
extract: { provider: extract.provider, dryRun: extract.dryRun },
});
const reviewer: Role<ReviewerMeta> = createReviewerRole(
config.agent,
{ provider: extract.provider, dryRun: extract.dryRun },
reviewerGit,
);
const committer: Role<CommitterMeta> = createCommitterRole(
config.agent,
{ provider: extract.provider, dryRun: extract.dryRun },
committerGit,
);
return { planner, coder, reviewer, committer };
}
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"composite": true
},
"include": ["src/**/*.ts"],
"references": [{ "path": "../workflow" }, { "path": "../workflow-role-llm" }]
}
+2 -1
View File
@@ -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" }
]
}