feat: developer + submitter roles, solve-issue refactored to parent workflow
- Developer role (react extract): delegates to workflowAsAgent("develop")
- Submitter role: push branch + create PR
- solve-issue now 3-role parent: preparer → developer → submitter
- Removed direct planner/coder/reviewer/committer from solve-issue
- 188 tests passing
Fixes #59
This commit is contained in:
@@ -0,0 +1,39 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
|
||||||
|
import { submitterMetaSchema, submitterRole } from "../src/submitter.js";
|
||||||
|
|
||||||
|
describe("submitterRole", () => {
|
||||||
|
test("submitted sample validates against schema", () => {
|
||||||
|
const parsed = submitterMetaSchema.safeParse({
|
||||||
|
status: "submitted" as const,
|
||||||
|
prUrl: "https://github.com/example/repo/pull/42",
|
||||||
|
});
|
||||||
|
expect(parsed.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("failed sample validates against schema", () => {
|
||||||
|
const parsed = submitterMetaSchema.safeParse({
|
||||||
|
status: "failed" as const,
|
||||||
|
error: "gh not authenticated",
|
||||||
|
});
|
||||||
|
expect(parsed.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects unknown status discriminant", () => {
|
||||||
|
const parsed = submitterMetaSchema.safeParse({
|
||||||
|
status: "queued",
|
||||||
|
prUrl: "https://example.com",
|
||||||
|
});
|
||||||
|
expect(parsed.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("exposes submitter system prompt", () => {
|
||||||
|
expect(submitterRole.systemPrompt).toContain("submitter");
|
||||||
|
expect(submitterRole.systemPrompt).toContain("pull request");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses single extract mode without refs", () => {
|
||||||
|
expect(submitterRole.extractMode).toBe("single");
|
||||||
|
expect(submitterRole.extractRefs).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "@uncaged/workflow-role-submitter",
|
||||||
|
"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:*",
|
||||||
|
"zod": "^4.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { type SubmitterMeta, submitterMetaSchema, submitterRole } from "./submitter.js";
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { RoleDefinition } from "@uncaged/workflow";
|
||||||
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
|
export const submitterMetaSchema = z.discriminatedUnion("status", [
|
||||||
|
z.object({
|
||||||
|
status: z.literal("submitted"),
|
||||||
|
prUrl: z.string(),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
status: z.literal("failed"),
|
||||||
|
error: z.string(),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type SubmitterMeta = z.infer<typeof submitterMetaSchema>;
|
||||||
|
|
||||||
|
const SUBMITTER_SYSTEM = `You are the **submitter**. Your job is to push the work branch to the remote and open a pull request.
|
||||||
|
|
||||||
|
## Inputs
|
||||||
|
|
||||||
|
Read the thread for context:
|
||||||
|
- The **preparer**'s output gives you the absolute repo path and the default branch (and remote URL by inspecting the repo).
|
||||||
|
- The **developer**'s output gives you the branch name that was committed and a list of files changed plus a summary of the work.
|
||||||
|
|
||||||
|
## Procedure
|
||||||
|
|
||||||
|
1. \`cd\` into the repo path from the preparer's output.
|
||||||
|
2. Push the developer's branch to the remote: \`git push -u origin <branch>\`.
|
||||||
|
3. Open a pull request (e.g. via \`gh pr create\`) targeting the default branch. The PR title should be short and describe the change. The PR description should summarize what changed (drawing from the developer's summary and filesChanged) and reference the original issue/task if applicable.
|
||||||
|
4. Report the resulting PR URL.
|
||||||
|
|
||||||
|
On any failure (push rejected, gh not authenticated, PR creation failed, etc.), report status="failed" with a short error message. Do not retry — surface the error so the moderator can decide.`;
|
||||||
|
|
||||||
|
const SUBMITTER_EXTRACT_PROMPT =
|
||||||
|
"Extract the submission result. status='submitted' with prUrl on success, or status='failed' with a short error message on failure.";
|
||||||
|
|
||||||
|
export const submitterRole: RoleDefinition<SubmitterMeta> = {
|
||||||
|
description: "Pushes the developer's branch to the remote and opens a pull request.",
|
||||||
|
systemPrompt: SUBMITTER_SYSTEM,
|
||||||
|
extractPrompt: SUBMITTER_EXTRACT_PROMPT,
|
||||||
|
schema: submitterMetaSchema,
|
||||||
|
extractRefs: null,
|
||||||
|
extractMode: "single",
|
||||||
|
};
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "dist",
|
||||||
|
"composite": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"references": [{ "path": "../workflow" }]
|
||||||
|
}
|
||||||
@@ -12,41 +12,71 @@ import {
|
|||||||
validateWorkflowDescriptor,
|
validateWorkflowDescriptor,
|
||||||
} from "@uncaged/workflow";
|
} from "@uncaged/workflow";
|
||||||
|
|
||||||
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 type { PreparerMeta } from "@uncaged/workflow-role-preparer";
|
||||||
|
import type { SubmitterMeta } from "@uncaged/workflow-role-submitter";
|
||||||
|
|
||||||
import { buildSolveIssueDescriptor } from "../src/descriptor.js";
|
import { buildSolveIssueDescriptor } from "../src/descriptor.js";
|
||||||
|
import type { DeveloperMeta } from "../src/developer.js";
|
||||||
import { createSolveIssueRun, solveIssueModerator } from "../src/index.js";
|
import { createSolveIssueRun, solveIssueModerator } from "../src/index.js";
|
||||||
import type { SolveIssueMeta } from "../src/roles.js";
|
import type { SolveIssueMeta } from "../src/roles.js";
|
||||||
|
|
||||||
const DEFAULT_PHASES: PlannerMeta["phases"] = [
|
function jsonResponse(payload: Record<string, unknown>): Response {
|
||||||
{
|
return new Response(JSON.stringify(payload), {
|
||||||
hash: "4KNMR2PX",
|
status: 200,
|
||||||
title: "Do the work",
|
headers: { "Content-Type": "application/json" },
|
||||||
},
|
});
|
||||||
];
|
}
|
||||||
|
|
||||||
const EXPECT_PLANNER_META: PlannerMeta = {
|
function readToolListFromBody(init: RequestInit | undefined): readonly Record<string, unknown>[] {
|
||||||
phases: [
|
if (init === undefined || init.body === undefined || init.body === null) {
|
||||||
{
|
return [];
|
||||||
hash: "7BQST3VW",
|
}
|
||||||
title: "placeholder phase",
|
const body = JSON.parse(String(init.body)) as Record<string, unknown>;
|
||||||
},
|
const tools = body.tools;
|
||||||
],
|
if (!Array.isArray(tools)) {
|
||||||
};
|
return [];
|
||||||
|
}
|
||||||
|
return tools.filter((t): t is Record<string, unknown> => t !== null && typeof t === "object");
|
||||||
|
}
|
||||||
|
|
||||||
const EXPECT_CODER_META: CoderMeta = {
|
function singleToolName(tools: readonly Record<string, unknown>[]): string {
|
||||||
completedPhase: "7BQST3VW",
|
if (tools.length === 0) {
|
||||||
filesChanged: [],
|
return "extract";
|
||||||
summary: "",
|
}
|
||||||
};
|
const fn = tools[0].function as Record<string, unknown> | undefined;
|
||||||
|
return typeof fn?.name === "string" ? fn.name : "extract";
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSingleModeResponse(args: Record<string, unknown>, toolName: string): Response {
|
||||||
|
return jsonResponse({
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
message: {
|
||||||
|
tool_calls: [
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: { name: toolName, arguments: JSON.stringify(args) },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildReactModeResponse(args: Record<string, unknown>): Response {
|
||||||
|
// reactExtract accepts a plain-JSON assistant message and validates it
|
||||||
|
// directly against the schema, so we skip the cas_get / extract tool dance.
|
||||||
|
return jsonResponse({
|
||||||
|
choices: [{ message: { content: JSON.stringify(args) } }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function installMockChatCompletions(sequence: ReadonlyArray<Record<string, unknown>>): () => void {
|
function installMockChatCompletions(sequence: ReadonlyArray<Record<string, unknown>>): () => void {
|
||||||
const origFetch = globalThis.fetch;
|
const origFetch = globalThis.fetch;
|
||||||
let i = 0;
|
let i = 0;
|
||||||
const mockFetch = async (
|
const mockFetch = async (
|
||||||
input: Parameters<typeof fetch>[0],
|
_input: Parameters<typeof fetch>[0],
|
||||||
init?: RequestInit,
|
init?: RequestInit,
|
||||||
): Promise<Response> => {
|
): Promise<Response> => {
|
||||||
const args = sequence[i] ?? sequence[sequence.length - 1];
|
const args = sequence[i] ?? sequence[sequence.length - 1];
|
||||||
@@ -54,36 +84,11 @@ function installMockChatCompletions(sequence: ReadonlyArray<Record<string, unkno
|
|||||||
throw new Error("installMockChatCompletions: empty sequence");
|
throw new Error("installMockChatCompletions: empty sequence");
|
||||||
}
|
}
|
||||||
i += 1;
|
i += 1;
|
||||||
void input;
|
const tools = readToolListFromBody(init);
|
||||||
const body = init?.body ? (JSON.parse(String(init.body)) as Record<string, unknown>) : {};
|
if (tools.length > 1) {
|
||||||
const tools = body.tools;
|
return buildReactModeResponse(args);
|
||||||
const firstTool =
|
}
|
||||||
Array.isArray(tools) && tools.length > 0 && tools[0] !== null && typeof tools[0] === "object"
|
return buildSingleModeResponse(args, singleToolName(tools));
|
||||||
? (tools[0] as Record<string, unknown>)
|
|
||||||
: null;
|
|
||||||
const fn =
|
|
||||||
firstTool !== null ? (firstTool.function as Record<string, unknown> | undefined) : undefined;
|
|
||||||
const toolName = typeof fn?.name === "string" ? fn.name : "extract";
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
choices: [
|
|
||||||
{
|
|
||||||
message: {
|
|
||||||
tool_calls: [
|
|
||||||
{
|
|
||||||
type: "function",
|
|
||||||
function: {
|
|
||||||
name: toolName,
|
|
||||||
arguments: JSON.stringify(args),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
globalThis.fetch = Object.assign(mockFetch, {
|
globalThis.fetch = Object.assign(mockFetch, {
|
||||||
preconnect: origFetch.preconnect.bind(origFetch),
|
preconnect: origFetch.preconnect.bind(origFetch),
|
||||||
@@ -134,152 +139,86 @@ function preparerStep(): RoleStep<SolveIssueMeta> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function plannerStep(phases: PlannerMeta["phases"] = DEFAULT_PHASES): RoleStep<SolveIssueMeta> {
|
function developerStep(): RoleStep<SolveIssueMeta> {
|
||||||
return {
|
return {
|
||||||
role: "planner",
|
role: "developer",
|
||||||
contentHash: "STUBHASHPLANNER001",
|
contentHash: "STUBHASHDEVELOPER1",
|
||||||
meta: { phases },
|
meta: {
|
||||||
refs: phases.map((p) => p.hash),
|
branch: "feat/issue-1",
|
||||||
|
commitSha: "abc1234",
|
||||||
|
filesChanged: ["src/login.ts"],
|
||||||
|
summary: "Fixed flaky login test by stabilising async setup.",
|
||||||
|
},
|
||||||
|
refs: [],
|
||||||
timestamp: 1,
|
timestamp: 1,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function coderStep(completedPhase = "4KNMR2PX"): RoleStep<SolveIssueMeta> {
|
function submitterStep(meta: SubmitterMeta): RoleStep<SolveIssueMeta> {
|
||||||
return {
|
return {
|
||||||
role: "coder",
|
role: "submitter",
|
||||||
contentHash: "STUBHASHCODER00001",
|
contentHash: "STUBHASHSUBMITTER1",
|
||||||
meta: { completedPhase, filesChanged: ["a.ts"], summary: "fixed" },
|
meta,
|
||||||
refs: [completedPhase],
|
refs: [],
|
||||||
timestamp: 2,
|
timestamp: 2,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function reviewerStep(approved: boolean): RoleStep<SolveIssueMeta> {
|
|
||||||
return {
|
|
||||||
role: "reviewer",
|
|
||||||
contentHash: "STUBHASHREVIEWER01",
|
|
||||||
meta: approved
|
|
||||||
? { status: "approved" as const }
|
|
||||||
: { status: "rejected" as const, issues: ["needs fix"] },
|
|
||||||
refs: [],
|
|
||||||
timestamp: 3,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function committerStep(): RoleStep<SolveIssueMeta> {
|
|
||||||
return {
|
|
||||||
role: "committer",
|
|
||||||
contentHash: "STUBHASHCOMMITTER1",
|
|
||||||
meta: { status: "committed", branch: "feat/issue-1", commitSha: "abc1234" },
|
|
||||||
refs: [],
|
|
||||||
timestamp: 4,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const stubExtract = createExtract({
|
const stubExtract = createExtract({
|
||||||
baseUrl: "http://127.0.0.1:9",
|
baseUrl: "http://127.0.0.1:9",
|
||||||
apiKey: "",
|
apiKey: "",
|
||||||
model: "test",
|
model: "test",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const stubLlmProvider = {
|
||||||
|
baseUrl: "http://127.0.0.1:9",
|
||||||
|
apiKey: "",
|
||||||
|
model: "test",
|
||||||
|
};
|
||||||
|
|
||||||
describe("solveIssueModerator", () => {
|
describe("solveIssueModerator", () => {
|
||||||
test("routes preparer → planner → coder → reviewer → committer → END", () => {
|
test("routes initial → preparer → developer → submitter → END", () => {
|
||||||
expect(solveIssueModerator(makeCtx(20, []))).toBe("preparer");
|
expect(solveIssueModerator(makeCtx(20, []))).toBe("preparer");
|
||||||
expect(solveIssueModerator(makeCtx(20, [preparerStep()]))).toBe("planner");
|
expect(solveIssueModerator(makeCtx(20, [preparerStep()]))).toBe("developer");
|
||||||
expect(solveIssueModerator(makeCtx(20, [preparerStep(), plannerStep()]))).toBe("coder");
|
expect(solveIssueModerator(makeCtx(20, [preparerStep(), developerStep()]))).toBe("submitter");
|
||||||
expect(solveIssueModerator(makeCtx(20, [preparerStep(), plannerStep(), coderStep()]))).toBe(
|
|
||||||
"reviewer",
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
solveIssueModerator(
|
|
||||||
makeCtx(20, [preparerStep(), plannerStep(), coderStep(), reviewerStep(true)]),
|
|
||||||
),
|
|
||||||
).toBe("committer");
|
|
||||||
expect(
|
expect(
|
||||||
solveIssueModerator(
|
solveIssueModerator(
|
||||||
makeCtx(20, [
|
makeCtx(20, [
|
||||||
preparerStep(),
|
preparerStep(),
|
||||||
plannerStep(),
|
developerStep(),
|
||||||
coderStep(),
|
submitterStep({
|
||||||
reviewerStep(true),
|
status: "submitted",
|
||||||
committerStep(),
|
prUrl: "https://github.com/example/repo/pull/1",
|
||||||
|
}),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
).toBe(END);
|
).toBe(END);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("reviewer rejects → coder retry when budget allows", () => {
|
test("submitter failed → END", () => {
|
||||||
const steps: ModeratorContext<SolveIssueMeta>["steps"] = [
|
|
||||||
plannerStep(),
|
|
||||||
coderStep(),
|
|
||||||
reviewerStep(false),
|
|
||||||
];
|
|
||||||
expect(solveIssueModerator(makeCtx(20, steps))).toBe("coder");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("reviewer rejects → END when max rounds exhausted", () => {
|
|
||||||
const steps: ModeratorContext<SolveIssueMeta>["steps"] = [
|
|
||||||
plannerStep(),
|
|
||||||
coderStep(),
|
|
||||||
reviewerStep(false),
|
|
||||||
];
|
|
||||||
expect(solveIssueModerator(makeCtx(4, steps))).toBe(END);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("multiple planner phases → coder until all complete, then reviewer", () => {
|
|
||||||
const phases: PlannerMeta["phases"] = [
|
|
||||||
{
|
|
||||||
hash: "AA000001",
|
|
||||||
title: "first phase",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
hash: "AA000002",
|
|
||||||
title: "second phase",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
expect(solveIssueModerator(makeCtx(20, [plannerStep(phases)]))).toBe("coder");
|
|
||||||
expect(solveIssueModerator(makeCtx(20, [plannerStep(phases), coderStep("AA000001")]))).toBe(
|
|
||||||
"coder",
|
|
||||||
);
|
|
||||||
expect(
|
expect(
|
||||||
solveIssueModerator(
|
solveIssueModerator(
|
||||||
makeCtx(20, [plannerStep(phases), coderStep("AA000001"), coderStep("AA000002")]),
|
makeCtx(20, [
|
||||||
|
preparerStep(),
|
||||||
|
developerStep(),
|
||||||
|
submitterStep({ status: "failed", error: "gh not authenticated" }),
|
||||||
|
]),
|
||||||
),
|
),
|
||||||
).toBe("reviewer");
|
).toBe(END);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("one-shot coder reports only last phase hash → reviewer (moderator treats as all phases done)", () => {
|
test("returns END for any unexpected last step (defensive)", () => {
|
||||||
const phases: PlannerMeta["phases"] = [
|
// A submitter step with a pseudo-unknown future status would still be
|
||||||
{ hash: "BB000001", title: "setup branch" },
|
// routed to END, since the moderator is a closed switch over known roles.
|
||||||
{ hash: "BB000002", title: "write tests" },
|
expect(
|
||||||
{ hash: "BB000003", title: "verify" },
|
solveIssueModerator(
|
||||||
{ hash: "BB000004", title: "commit and pr" },
|
makeCtx(20, [
|
||||||
];
|
preparerStep(),
|
||||||
expect(solveIssueModerator(makeCtx(20, [plannerStep(phases), coderStep("BB000004")]))).toBe(
|
developerStep(),
|
||||||
"reviewer",
|
submitterStep({ status: "submitted", prUrl: "https://example.com/pr/1" }),
|
||||||
);
|
]),
|
||||||
});
|
),
|
||||||
|
).toBe(END);
|
||||||
test("unrecognised completedPhase hash → coder retry when budget allows", () => {
|
|
||||||
const phases: PlannerMeta["phases"] = [
|
|
||||||
{ hash: "CC000001", title: "first phase" },
|
|
||||||
{ hash: "CC000002", title: "second phase" },
|
|
||||||
];
|
|
||||||
expect(solveIssueModerator(makeCtx(20, [plannerStep(phases), coderStep("all-done")]))).toBe(
|
|
||||||
"coder",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("incomplete phases → END when max rounds exhausted", () => {
|
|
||||||
const phases: PlannerMeta["phases"] = [
|
|
||||||
{ hash: "DD000001", title: "first phase" },
|
|
||||||
{ hash: "DD000002", title: "second phase" },
|
|
||||||
];
|
|
||||||
const steps: ModeratorContext<SolveIssueMeta>["steps"] = [
|
|
||||||
plannerStep(phases),
|
|
||||||
coderStep("DD000001"),
|
|
||||||
];
|
|
||||||
expect(solveIssueModerator(makeCtx(3, steps))).toBe(END);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -296,7 +235,7 @@ describe("createSolveIssueRun", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("structured extraction yields preparer then planner meta from mocked chat completions", async () => {
|
test("structured extraction yields preparer meta from mocked chat completions", async () => {
|
||||||
const EXPECT_PREPARER_META: PreparerMeta = {
|
const EXPECT_PREPARER_META: PreparerMeta = {
|
||||||
repoPath: "/home/user/repos/test",
|
repoPath: "/home/user/repos/test",
|
||||||
defaultBranch: "main",
|
defaultBranch: "main",
|
||||||
@@ -308,12 +247,20 @@ describe("createSolveIssueRun", () => {
|
|||||||
buildCommand: "bun run build",
|
buildCommand: "bun run build",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
restoreFetch = installMockChatCompletions([EXPECT_PREPARER_META, EXPECT_PLANNER_META]);
|
restoreFetch = installMockChatCompletions([EXPECT_PREPARER_META]);
|
||||||
|
|
||||||
casDir = await mkdtemp(join(tmpdir(), "solve-issue-cas-"));
|
casDir = await mkdtemp(join(tmpdir(), "solve-issue-cas-"));
|
||||||
const cas = createCasStore(casDir);
|
const cas = createCasStore(casDir);
|
||||||
|
|
||||||
const run = createSolveIssueRun({ agent: async () => "" }, stubExtract, null);
|
// Override developer so the test does not spin up a child workflow.
|
||||||
|
const run = createSolveIssueRun(
|
||||||
|
{
|
||||||
|
agent: async () => "",
|
||||||
|
overrides: { developer: async () => "stub-root-hash" },
|
||||||
|
},
|
||||||
|
stubExtract,
|
||||||
|
stubLlmProvider,
|
||||||
|
);
|
||||||
const gen = run(
|
const gen = run(
|
||||||
{ prompt: "task", steps: [] },
|
{ prompt: "task", steps: [] },
|
||||||
{ threadId: "01TEST000000000000000000TR", maxRounds: 20, depth: 0, cas },
|
{ threadId: "01TEST000000000000000000TR", maxRounds: 20, depth: 0, cas },
|
||||||
@@ -325,14 +272,6 @@ describe("createSolveIssueRun", () => {
|
|||||||
}
|
}
|
||||||
expect(first.value.role).toBe("preparer");
|
expect(first.value.role).toBe("preparer");
|
||||||
expect(first.value.meta).toEqual(EXPECT_PREPARER_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 () => {
|
||||||
@@ -342,11 +281,17 @@ describe("createSolveIssueRun", () => {
|
|||||||
conventions: null,
|
conventions: null,
|
||||||
toolchain: { packageManager: null, testCommand: null, lintCommand: null, buildCommand: null },
|
toolchain: { packageManager: null, testCommand: null, lintCommand: null, buildCommand: null },
|
||||||
};
|
};
|
||||||
restoreFetch = installMockChatCompletions([
|
const DEVELOPER_META: DeveloperMeta = {
|
||||||
PREPARER_META,
|
branch: "feat/x",
|
||||||
EXPECT_PLANNER_META,
|
commitSha: "abc1234",
|
||||||
EXPECT_CODER_META,
|
filesChanged: ["a.ts"],
|
||||||
]);
|
summary: "did the work",
|
||||||
|
};
|
||||||
|
const SUBMITTER_META: SubmitterMeta = {
|
||||||
|
status: "submitted",
|
||||||
|
prUrl: "https://github.com/example/repo/pull/2",
|
||||||
|
};
|
||||||
|
restoreFetch = installMockChatCompletions([PREPARER_META, DEVELOPER_META, SUBMITTER_META]);
|
||||||
|
|
||||||
casDir = await mkdtemp(join(tmpdir(), "solve-issue-cas-"));
|
casDir = await mkdtemp(join(tmpdir(), "solve-issue-cas-"));
|
||||||
const cas = createCasStore(casDir);
|
const cas = createCasStore(casDir);
|
||||||
@@ -363,18 +308,18 @@ describe("createSolveIssueRun", () => {
|
|||||||
calls.push("preparer");
|
calls.push("preparer");
|
||||||
return "";
|
return "";
|
||||||
},
|
},
|
||||||
planner: async () => {
|
developer: async () => {
|
||||||
calls.push("planner");
|
calls.push("developer");
|
||||||
return "";
|
return "stub-root-hash";
|
||||||
},
|
},
|
||||||
coder: async () => {
|
submitter: async () => {
|
||||||
calls.push("coder");
|
calls.push("submitter");
|
||||||
return "";
|
return "";
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
stubExtract,
|
stubExtract,
|
||||||
null,
|
stubLlmProvider,
|
||||||
);
|
);
|
||||||
const gen = run(
|
const gen = run(
|
||||||
{ prompt: "task", steps: [] },
|
{ prompt: "task", steps: [] },
|
||||||
@@ -385,16 +330,65 @@ describe("createSolveIssueRun", () => {
|
|||||||
|
|
||||||
calls.length = 0;
|
calls.length = 0;
|
||||||
await gen.next();
|
await gen.next();
|
||||||
expect(calls).toEqual(["planner"]);
|
expect(calls).toEqual(["developer"]);
|
||||||
|
|
||||||
calls.length = 0;
|
calls.length = 0;
|
||||||
await gen.next();
|
await gen.next();
|
||||||
expect(calls).toEqual(["coder"]);
|
expect(calls).toEqual(["submitter"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("developer defaults to workflowAsAgent override (caller override still wins)", async () => {
|
||||||
|
const PREPARER_META: PreparerMeta = {
|
||||||
|
repoPath: "/tmp/r",
|
||||||
|
defaultBranch: "main",
|
||||||
|
conventions: null,
|
||||||
|
toolchain: { packageManager: null, testCommand: null, lintCommand: null, buildCommand: null },
|
||||||
|
};
|
||||||
|
const DEVELOPER_META: DeveloperMeta = {
|
||||||
|
branch: "feat/y",
|
||||||
|
commitSha: "def5678",
|
||||||
|
filesChanged: ["b.ts"],
|
||||||
|
summary: "more work",
|
||||||
|
};
|
||||||
|
restoreFetch = installMockChatCompletions([PREPARER_META, DEVELOPER_META]);
|
||||||
|
|
||||||
|
casDir = await mkdtemp(join(tmpdir(), "solve-issue-cas-"));
|
||||||
|
const cas = createCasStore(casDir);
|
||||||
|
|
||||||
|
let developerInvocations = 0;
|
||||||
|
const run = createSolveIssueRun(
|
||||||
|
{
|
||||||
|
agent: async () => "",
|
||||||
|
overrides: {
|
||||||
|
developer: async () => {
|
||||||
|
developerInvocations += 1;
|
||||||
|
return "stub-root-hash";
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
stubExtract,
|
||||||
|
stubLlmProvider,
|
||||||
|
);
|
||||||
|
const gen = run(
|
||||||
|
{ prompt: "task", steps: [] },
|
||||||
|
{ threadId: "01TEST000000000000000000TR", maxRounds: 20, depth: 0, cas },
|
||||||
|
);
|
||||||
|
// preparer
|
||||||
|
await gen.next();
|
||||||
|
// developer (caller override should be invoked, NOT workflowAsAgent default)
|
||||||
|
const devYield = await gen.next();
|
||||||
|
expect(devYield.done).toBe(false);
|
||||||
|
if (devYield.done) {
|
||||||
|
throw new Error("expected yield");
|
||||||
|
}
|
||||||
|
expect(devYield.value.role).toBe("developer");
|
||||||
|
expect(devYield.value.meta).toEqual(DEVELOPER_META);
|
||||||
|
expect(developerInvocations).toBe(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("buildSolveIssueDescriptor", () => {
|
describe("buildSolveIssueDescriptor", () => {
|
||||||
test("lists all roles with schemas that validate", () => {
|
test("lists preparer, developer, submitter with schemas that validate", () => {
|
||||||
const descriptor = buildSolveIssueDescriptor();
|
const descriptor = buildSolveIssueDescriptor();
|
||||||
const validated = validateWorkflowDescriptor(descriptor);
|
const validated = validateWorkflowDescriptor(descriptor);
|
||||||
expect(validated.ok).toBe(true);
|
expect(validated.ok).toBe(true);
|
||||||
@@ -402,13 +396,11 @@ describe("buildSolveIssueDescriptor", () => {
|
|||||||
throw new Error(validated.error);
|
throw new Error(validated.error);
|
||||||
}
|
}
|
||||||
expect(Object.keys(validated.value.roles).sort()).toEqual([
|
expect(Object.keys(validated.value.roles).sort()).toEqual([
|
||||||
"coder",
|
"developer",
|
||||||
"committer",
|
|
||||||
"planner",
|
|
||||||
"preparer",
|
"preparer",
|
||||||
"reviewer",
|
"submitter",
|
||||||
]);
|
]);
|
||||||
for (const key of ["preparer", "planner", "coder", "reviewer", "committer"] as const) {
|
for (const key of ["preparer", "developer", "submitter"] 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");
|
||||||
|
|||||||
@@ -10,10 +10,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@uncaged/workflow": "workspace:*",
|
"@uncaged/workflow": "workspace:*",
|
||||||
"@uncaged/workflow-role-committer": "workspace:*",
|
|
||||||
"@uncaged/workflow-role-coder": "workspace:*",
|
|
||||||
"@uncaged/workflow-role-planner": "workspace:*",
|
|
||||||
"@uncaged/workflow-role-preparer": "workspace:*",
|
"@uncaged/workflow-role-preparer": "workspace:*",
|
||||||
"@uncaged/workflow-role-reviewer": "workspace:*"
|
"@uncaged/workflow-role-submitter": "workspace:*",
|
||||||
|
"zod": "^4.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import type { RoleDefinition } from "@uncaged/workflow";
|
||||||
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
|
export const developerMetaSchema = z.object({
|
||||||
|
branch: z.string(),
|
||||||
|
commitSha: z.string(),
|
||||||
|
filesChanged: z.array(z.string()),
|
||||||
|
summary: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type DeveloperMeta = z.infer<typeof developerMetaSchema>;
|
||||||
|
|
||||||
|
const DEVELOPER_SYSTEM = `You are the **developer**. You delegate the implementation work to the \`develop\` workflow.
|
||||||
|
|
||||||
|
The actual implementation (planning → coding → reviewing → testing → committing) is handled by a child workflow that runs in your place. Your output is the Merkle DAG root hash of that child thread.
|
||||||
|
|
||||||
|
Pass through the task and let the child workflow do the work.`;
|
||||||
|
|
||||||
|
const DEVELOPER_EXTRACT_PROMPT = `The agent output is the root CAS hash of a child workflow thread. Use the cas_get tool to traverse the Merkle DAG and extract the developer summary.
|
||||||
|
|
||||||
|
Procedure:
|
||||||
|
1. cas_get(<rootHash>) — the root node lists all child step hashes (planner, coder, reviewer, tester, committer).
|
||||||
|
2. Find the committer step. cas_get its hash to read the committer's meta — extract branch and commitSha from there.
|
||||||
|
3. Find every coder step. cas_get each to read the coder's filesChanged. Union all filesChanged across coder steps.
|
||||||
|
4. Compose a short human-readable summary describing what the develop child workflow accomplished (drawn from the coder summaries, or a synthesis of them).
|
||||||
|
|
||||||
|
Return: { branch, commitSha, filesChanged, summary }.`;
|
||||||
|
|
||||||
|
export const developerRole: RoleDefinition<DeveloperMeta> = {
|
||||||
|
description:
|
||||||
|
"Delegates the actual implementation to the develop workflow (workflow-as-agent). Produces a summary by traversing the child thread's Merkle DAG.",
|
||||||
|
systemPrompt: DEVELOPER_SYSTEM,
|
||||||
|
extractPrompt: DEVELOPER_EXTRACT_PROMPT,
|
||||||
|
schema: developerMetaSchema,
|
||||||
|
extractRefs: () => [],
|
||||||
|
extractMode: "react",
|
||||||
|
};
|
||||||
@@ -5,38 +5,28 @@ import {
|
|||||||
type LlmProvider,
|
type LlmProvider,
|
||||||
type WorkflowDefinition,
|
type WorkflowDefinition,
|
||||||
type WorkflowFn,
|
type WorkflowFn,
|
||||||
|
workflowAsAgent,
|
||||||
} from "@uncaged/workflow";
|
} from "@uncaged/workflow";
|
||||||
|
|
||||||
import { solveIssueModerator } from "./moderator.js";
|
import { solveIssueModerator } from "./moderator.js";
|
||||||
import { SOLVE_ISSUE_WORKFLOW_DESCRIPTION, type SolveIssueMeta, solveIssueRoles } from "./roles.js";
|
import { SOLVE_ISSUE_WORKFLOW_DESCRIPTION, type SolveIssueMeta, solveIssueRoles } from "./roles.js";
|
||||||
|
|
||||||
export {
|
|
||||||
type CoderMeta,
|
|
||||||
coderMetaSchema,
|
|
||||||
coderRole,
|
|
||||||
} from "@uncaged/workflow-role-coder";
|
|
||||||
export {
|
|
||||||
type CommitterMeta,
|
|
||||||
committerMetaSchema,
|
|
||||||
committerRole,
|
|
||||||
} from "@uncaged/workflow-role-committer";
|
|
||||||
export {
|
|
||||||
type PlannerMeta,
|
|
||||||
phaseSchema,
|
|
||||||
plannerMetaSchema,
|
|
||||||
plannerRole,
|
|
||||||
} from "@uncaged/workflow-role-planner";
|
|
||||||
export {
|
export {
|
||||||
type PreparerMeta,
|
type PreparerMeta,
|
||||||
preparerMetaSchema,
|
preparerMetaSchema,
|
||||||
preparerRole,
|
preparerRole,
|
||||||
} from "@uncaged/workflow-role-preparer";
|
} from "@uncaged/workflow-role-preparer";
|
||||||
export {
|
export {
|
||||||
type ReviewerMeta,
|
type SubmitterMeta,
|
||||||
reviewerMetaSchema,
|
submitterMetaSchema,
|
||||||
reviewerRole,
|
submitterRole,
|
||||||
} from "@uncaged/workflow-role-reviewer";
|
} from "@uncaged/workflow-role-submitter";
|
||||||
export { buildSolveIssueDescriptor } from "./descriptor.js";
|
export { buildSolveIssueDescriptor } from "./descriptor.js";
|
||||||
|
export {
|
||||||
|
type DeveloperMeta,
|
||||||
|
developerMetaSchema,
|
||||||
|
developerRole,
|
||||||
|
} from "./developer.js";
|
||||||
export { solveIssueModerator } from "./moderator.js";
|
export { solveIssueModerator } from "./moderator.js";
|
||||||
export {
|
export {
|
||||||
SOLVE_ISSUE_WORKFLOW_DESCRIPTION,
|
SOLVE_ISSUE_WORKFLOW_DESCRIPTION,
|
||||||
@@ -51,10 +41,25 @@ export const solveIssueWorkflowDefinition: WorkflowDefinition<SolveIssueMeta> =
|
|||||||
moderator: solveIssueModerator,
|
moderator: solveIssueModerator,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the solve-issue {@link WorkflowFn}.
|
||||||
|
*
|
||||||
|
* The `developer` role always delegates to the registered `develop` workflow via
|
||||||
|
* {@link workflowAsAgent}; if the caller supplies their own `developer` override in
|
||||||
|
* `binding.overrides`, it takes precedence so tests and custom hosts can stub it.
|
||||||
|
*/
|
||||||
export function createSolveIssueRun(
|
export function createSolveIssueRun(
|
||||||
binding: AgentBinding,
|
binding: AgentBinding,
|
||||||
extract: ExtractFn,
|
extract: ExtractFn,
|
||||||
llmProvider: LlmProvider | null,
|
llmProvider: LlmProvider | null,
|
||||||
): WorkflowFn {
|
): WorkflowFn {
|
||||||
return createWorkflow(solveIssueWorkflowDefinition, binding, extract, llmProvider);
|
const developerOverride = binding.overrides?.developer ?? workflowAsAgent("develop");
|
||||||
|
const mergedBinding: AgentBinding = {
|
||||||
|
agent: binding.agent,
|
||||||
|
overrides: {
|
||||||
|
...(binding.overrides ?? {}),
|
||||||
|
developer: developerOverride,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return createWorkflow(solveIssueWorkflowDefinition, mergedBinding, extract, llmProvider);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,52 +1,9 @@
|
|||||||
import type { Moderator, ModeratorContext } from "@uncaged/workflow";
|
import type { Moderator } from "@uncaged/workflow";
|
||||||
import { END } from "@uncaged/workflow";
|
import { END } from "@uncaged/workflow";
|
||||||
|
|
||||||
import type { SolveIssueMeta } from "./roles.js";
|
import type { SolveIssueMeta } from "./roles.js";
|
||||||
|
|
||||||
function coderFinishedAllPlannedPhases(
|
|
||||||
phases: ReadonlyArray<{ hash: string }>,
|
|
||||||
coderCompletedPhases: ReadonlyArray<string>,
|
|
||||||
): boolean {
|
|
||||||
if (phases.length === 0) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const plannedHashes = new Set(phases.map((p) => p.hash));
|
|
||||||
const lastHash = phases[phases.length - 1].hash;
|
|
||||||
const explicit = new Set(coderCompletedPhases.filter((h) => plannedHashes.has(h)));
|
|
||||||
if (phases.every((p) => explicit.has(p.hash))) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (coderCompletedPhases.some((h) => h === lastHash)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function nextAfterCoder(
|
|
||||||
ctx: ModeratorContext<SolveIssueMeta>,
|
|
||||||
maxRounds: number,
|
|
||||||
): (keyof SolveIssueMeta & string) | typeof END {
|
|
||||||
const plannerStep = ctx.steps.find((s) => s.role === "planner");
|
|
||||||
if (plannerStep === undefined) {
|
|
||||||
return "reviewer";
|
|
||||||
}
|
|
||||||
const phases = plannerStep.meta.phases;
|
|
||||||
const coderCompletedPhases = ctx.steps
|
|
||||||
.filter((s) => s.role === "coder")
|
|
||||||
.map((s) => s.meta.completedPhase);
|
|
||||||
const allDone = coderFinishedAllPlannedPhases(phases, coderCompletedPhases);
|
|
||||||
if (allDone) {
|
|
||||||
return "reviewer";
|
|
||||||
}
|
|
||||||
if (ctx.steps.length < maxRounds - 1) {
|
|
||||||
return "coder";
|
|
||||||
}
|
|
||||||
return END;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const solveIssueModerator: Moderator<SolveIssueMeta> = (ctx) => {
|
export const solveIssueModerator: Moderator<SolveIssueMeta> = (ctx) => {
|
||||||
const maxRounds = ctx.start.meta.maxRounds;
|
|
||||||
|
|
||||||
if (ctx.steps.length === 0) {
|
if (ctx.steps.length === 0) {
|
||||||
return "preparer";
|
return "preparer";
|
||||||
}
|
}
|
||||||
@@ -54,31 +11,14 @@ export const solveIssueModerator: Moderator<SolveIssueMeta> = (ctx) => {
|
|||||||
const last = ctx.steps[ctx.steps.length - 1];
|
const last = ctx.steps[ctx.steps.length - 1];
|
||||||
|
|
||||||
if (last.role === "preparer") {
|
if (last.role === "preparer") {
|
||||||
return "planner";
|
return "developer";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (last.role === "planner") {
|
if (last.role === "developer") {
|
||||||
return "coder";
|
return "submitter";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (last.role === "coder") {
|
if (last.role === "submitter") {
|
||||||
return nextAfterCoder(ctx, maxRounds);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (last.role === "reviewer") {
|
|
||||||
if (last.meta.status === "approved") {
|
|
||||||
return "committer";
|
|
||||||
}
|
|
||||||
if (ctx.steps.length < maxRounds - 1) {
|
|
||||||
return "coder";
|
|
||||||
}
|
|
||||||
return END;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (last.role === "committer") {
|
|
||||||
if (last.meta.status === "recoverable" && ctx.steps.length < maxRounds - 1) {
|
|
||||||
return "coder";
|
|
||||||
}
|
|
||||||
return END;
|
return END;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,16 @@
|
|||||||
import type { RoleDefinition } from "@uncaged/workflow";
|
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 PreparerMeta, preparerRole } from "@uncaged/workflow-role-preparer";
|
||||||
import { type ReviewerMeta, reviewerRole } from "@uncaged/workflow-role-reviewer";
|
import { type SubmitterMeta, submitterRole } from "@uncaged/workflow-role-submitter";
|
||||||
|
|
||||||
|
import { type DeveloperMeta, developerRole } from "./developer.js";
|
||||||
|
|
||||||
export const SOLVE_ISSUE_WORKFLOW_DESCRIPTION =
|
export const SOLVE_ISSUE_WORKFLOW_DESCRIPTION =
|
||||||
"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).";
|
"Resolve an issue end-to-end by preparing the repo, delegating implementation to the develop workflow, and opening a pull request (preparer → developer → submitter).";
|
||||||
|
|
||||||
export type SolveIssueMeta = {
|
export type SolveIssueMeta = {
|
||||||
preparer: PreparerMeta;
|
preparer: PreparerMeta;
|
||||||
planner: PlannerMeta;
|
developer: DeveloperMeta;
|
||||||
coder: CoderMeta;
|
submitter: SubmitterMeta;
|
||||||
reviewer: ReviewerMeta;
|
|
||||||
committer: CommitterMeta;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SolveIssueRoles = {
|
export type SolveIssueRoles = {
|
||||||
@@ -22,8 +19,6 @@ export type SolveIssueRoles = {
|
|||||||
|
|
||||||
export const solveIssueRoles: SolveIssueRoles = {
|
export const solveIssueRoles: SolveIssueRoles = {
|
||||||
preparer: preparerRole,
|
preparer: preparerRole,
|
||||||
planner: plannerRole,
|
developer: developerRole,
|
||||||
coder: coderRole,
|
submitter: submitterRole,
|
||||||
reviewer: reviewerRole,
|
|
||||||
committer: committerRole,
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,9 +8,7 @@
|
|||||||
"include": ["src/**/*.ts"],
|
"include": ["src/**/*.ts"],
|
||||||
"references": [
|
"references": [
|
||||||
{ "path": "../workflow" },
|
{ "path": "../workflow" },
|
||||||
{ "path": "../workflow-role-coder" },
|
{ "path": "../workflow-role-preparer" },
|
||||||
{ "path": "../workflow-role-committer" },
|
{ "path": "../workflow-role-submitter" }
|
||||||
{ "path": "../workflow-role-planner" },
|
|
||||||
{ "path": "../workflow-role-reviewer" }
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,9 @@
|
|||||||
{ "path": "packages/workflow-role-committer" },
|
{ "path": "packages/workflow-role-committer" },
|
||||||
{ "path": "packages/workflow-role-coder" },
|
{ "path": "packages/workflow-role-coder" },
|
||||||
{ "path": "packages/workflow-role-planner" },
|
{ "path": "packages/workflow-role-planner" },
|
||||||
|
{ "path": "packages/workflow-role-preparer" },
|
||||||
{ "path": "packages/workflow-role-reviewer" },
|
{ "path": "packages/workflow-role-reviewer" },
|
||||||
|
{ "path": "packages/workflow-role-submitter" },
|
||||||
{ "path": "packages/workflow-role-tester" },
|
{ "path": "packages/workflow-role-tester" },
|
||||||
{ "path": "packages/workflow-agent-cursor" },
|
{ "path": "packages/workflow-agent-cursor" },
|
||||||
{ "path": "packages/workflow-agent-hermes" },
|
{ "path": "packages/workflow-agent-hermes" },
|
||||||
|
|||||||
Reference in New Issue
Block a user