Compare commits

..

7 Commits

Author SHA1 Message Date
xiaomo 61be1c662a feat(cli): help --skill command for agent-consumable docs (#69) 2026-05-07 14:20:06 +00:00
xiaomo 84e8d70da4 Merge pull request 'refactor(cli): group commands by noun-verb pattern' (#67) from refactor/cli-noun-verb-grouping into main 2026-05-07 14:09:46 +00:00
xiaomo 8976f4cf3b fix(cli): move 'remove' from workflow table to deprecation path
Per review nit: 'workflow rm' is canonical, 'workflow remove' now shows
deprecation warning. Consistent with top-level 'remove' → 'workflow rm'.
2026-05-07 14:09:37 +00:00
xiaomo 07730dd24c refactor(cli): group commands by noun-verb pattern (RFC #54)
Phase 1: workflow subcommand group (add/list/show/rm/history/rollback)
Phase 2: thread subcommand group (run/list/show/rm/fork/ps/kill/live/pause/resume)
Phase 3: cas gc + top-level aliases + deprecation warnings for old flat commands

- Follow existing CAS_SUBCOMMAND_TABLE pattern for workflow and thread groups
- Top-level 'run' and 'live' stay as shortcuts (no deprecation)
- Old flat commands print deprecation warning then delegate
- Update usage string to show grouped format
- Update tests to use new grouped syntax
2026-05-07 14:03:35 +00:00
xiaoju 4eff4d2370 Merge pull request 'feat: developer + submitter roles, solve-issue as parent workflow' (#62) from feat/59-solve-issue-refactor into main 2026-05-07 13:51:56 +00:00
xiaoju 1d6da18b18 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
2026-05-07 13:51:37 +00:00
xiaomo c342ff3737 Merge pull request 'feat(cli): live command — real-time thread monitoring' (#57) from feat/37-live-command into main 2026-05-07 13:45:09 +00:00
18 changed files with 697 additions and 352 deletions
@@ -129,7 +129,10 @@ describe("gc cli and garbageCollectCas", () => {
});
const env = { ...process.env, UNCAGED_WORKFLOW_STORAGE_ROOT: storageRoot };
const proc = spawnSync(process.execPath, [cliEntryPath, "gc"], { env, encoding: "utf8" });
const proc = spawnSync(process.execPath, [cliEntryPath, "cas", "gc"], {
env,
encoding: "utf8",
});
expect(proc.status).toBe(0);
expect(String(proc.stdout).trim()).toBe("scanned 1 threads, 2 active refs, deleted 1 entries");
});
@@ -0,0 +1,84 @@
import { describe, expect, test } from "bun:test";
import { runCli } from "../src/cli-dispatch.js";
import { formatSkillDoc } from "../src/cmd-help.js";
const STORAGE_ROOT = "/tmp/help-test-storage";
describe("help command", () => {
test("help returns 0", async () => {
const code = await runCli(STORAGE_ROOT, ["help"]);
expect(code).toBe(0);
});
test("help --skill returns 0", async () => {
const code = await runCli(STORAGE_ROOT, ["help", "--skill"]);
expect(code).toBe(0);
});
});
describe("formatSkillDoc", () => {
const doc = formatSkillDoc();
test("contains title", () => {
expect(doc).toContain("# uncaged-workflow CLI Reference");
});
test("contains all command group headers", () => {
expect(doc).toContain("### workflow");
expect(doc).toContain("### thread");
expect(doc).toContain("### cas");
expect(doc).toContain("### init");
expect(doc).toContain("### Top-level shortcuts");
});
test("contains core concepts", () => {
expect(doc).toContain("## Core Concepts");
expect(doc).toContain("Workflow");
expect(doc).toContain("Bundle");
expect(doc).toContain("Thread");
expect(doc).toContain("CAS");
expect(doc).toContain("Registry");
});
test("mentions all workflow subcommands", () => {
for (const sub of ["add", "list", "show", "rm", "history", "rollback"]) {
expect(doc).toContain(`workflow ${sub}`);
}
});
test("mentions all thread subcommands", () => {
for (const sub of [
"run",
"list",
"show",
"rm",
"fork",
"ps",
"kill",
"live",
"pause",
"resume",
]) {
expect(doc).toContain(`thread ${sub}`);
}
});
test("mentions all cas subcommands", () => {
for (const sub of ["get", "put", "list", "rm", "gc"]) {
expect(doc).toContain(`cas ${sub}`);
}
});
test("contains exit codes section", () => {
expect(doc).toContain("## Exit Codes");
});
test("contains environment variables section", () => {
expect(doc).toContain("## Environment Variables");
expect(doc).toContain("UNCAGED_WORKFLOW_STORAGE_ROOT");
});
test("contains typical workflow section", () => {
expect(doc).toContain("## Typical Workflow");
});
});
@@ -250,13 +250,16 @@ describe("cli thread commands", () => {
test("cli entrypoint dispatches threads / ps (spawn)", () => {
const env = { ...process.env, UNCAGED_WORKFLOW_STORAGE_ROOT: storageRoot };
const threads = spawnSync(process.execPath, [cliEntryPath, "threads"], {
const threads = spawnSync(process.execPath, [cliEntryPath, "thread", "list"], {
env,
encoding: "utf8",
});
expect(threads.status).toBe(0);
const ps = spawnSync(process.execPath, [cliEntryPath, "ps"], { env, encoding: "utf8" });
const ps = spawnSync(process.execPath, [cliEntryPath, "thread", "ps"], {
env,
encoding: "utf8",
});
expect(ps.status).toBe(0);
});
+139 -46
View File
@@ -3,6 +3,7 @@ import { cmdAdd, formatAddSuccess, parseAddArgv } from "./cmd-add.js";
import { cmdCasGet, cmdCasList, cmdCasPut, cmdCasRm } from "./cmd-cas.js";
import { cmdFork, parseForkArgv } from "./cmd-fork.js";
import { cmdGc } from "./cmd-gc.js";
import { formatSkillDoc } from "./cmd-help.js";
import { cmdHistory } from "./cmd-history.js";
import { cmdInitTemplate, cmdInitWorkspace } from "./cmd-init.js";
import { cmdKill } from "./cmd-kill.js";
@@ -23,33 +24,47 @@ import { parseRunArgv } from "./run-argv.js";
export function formatCliUsage(): string {
return [
"Usage:",
" uncaged-workflow add <name> <file.esm.js> [--types <path>]",
" uncaged-workflow list",
" uncaged-workflow show <name>",
" uncaged-workflow remove <name>",
" uncaged-workflow run <name> [--prompt <text>] [--max-rounds N]",
" uncaged-workflow ps",
" uncaged-workflow kill <thread-id>",
" uncaged-workflow live <thread-id> [--debug] [--role <name>]",
" uncaged-workflow live --latest [--debug] [--role <name>]",
" uncaged-workflow history <name>",
" uncaged-workflow rollback <name> [hash]",
" uncaged-workflow pause <thread-id>",
" uncaged-workflow resume <thread-id>",
" uncaged-workflow threads [name]",
" uncaged-workflow thread <id>",
" uncaged-workflow workflow add <name> <file.esm.js> [--types <path>]",
" uncaged-workflow workflow list",
" uncaged-workflow workflow show <name>",
" uncaged-workflow workflow rm <name>",
" uncaged-workflow workflow history <name>",
" uncaged-workflow workflow rollback <name> [hash]",
"",
" uncaged-workflow thread run <name> [--prompt <text>] [--max-rounds N]",
" uncaged-workflow thread list [name]",
" uncaged-workflow thread show <id>",
" uncaged-workflow thread rm <id>",
" uncaged-workflow fork <thread-id> [--from-role <role>]",
" uncaged-workflow gc",
" uncaged-workflow thread ps",
" uncaged-workflow thread kill <thread-id>",
" uncaged-workflow thread live <thread-id> [--debug] [--role <name>]",
" uncaged-workflow thread live --latest [--debug] [--role <name>]",
" uncaged-workflow thread pause <thread-id>",
" uncaged-workflow thread resume <thread-id>",
" uncaged-workflow thread fork <thread-id> [--from-role <role>]",
"",
" uncaged-workflow cas get <thread-id> <hash>",
" uncaged-workflow cas put <thread-id> <content>",
" uncaged-workflow cas list <thread-id>",
" uncaged-workflow cas rm <thread-id> <hash>",
" uncaged-workflow cas gc",
"",
" uncaged-workflow init workspace <name>",
" uncaged-workflow init template <name>",
"",
" uncaged-workflow run <name> [...] (shortcut for thread run)",
" uncaged-workflow live <thread-id> [...] (shortcut for thread live)",
].join("\n");
}
function printDeprecation(oldCmd: string, newCmd: string): void {
printCliWarn(`⚠ "${oldCmd}" is deprecated, use "${newCmd}" instead`);
}
type DispatchFn = (storageRoot: string, argv: string[]) => Promise<number>;
// ── Individual dispatch functions ──────────────────────────────────────
async function dispatchInit(_storageRoot: string, argv: string[]): Promise<number> {
const sub = argv[0];
const name = argv[1];
@@ -266,7 +281,7 @@ async function dispatchResume(storageRoot: string, argv: string[]): Promise<numb
return 0;
}
async function dispatchThreads(storageRoot: string, argv: string[]): Promise<number> {
async function dispatchThreadList(storageRoot: string, argv: string[]): Promise<number> {
const result = await cmdThreads(storageRoot, argv);
if (!result.ok) {
printCliError(result.error);
@@ -278,10 +293,10 @@ async function dispatchThreads(storageRoot: string, argv: string[]): Promise<num
return 0;
}
async function dispatchThread(storageRoot: string, argv: string[]): Promise<number> {
async function dispatchThreadShow(storageRoot: string, argv: string[]): Promise<number> {
const id = argv[0];
if (id === undefined || argv.length > 1) {
printCliError(`${formatCliUsage()}\n\nerror: thread requires <id>`);
printCliError(`${formatCliUsage()}\n\nerror: thread show requires <id>`);
return 1;
}
const result = await cmdThreadShow(storageRoot, id);
@@ -308,14 +323,6 @@ async function dispatchThreadRm(storageRoot: string, argv: string[]): Promise<nu
return 0;
}
async function dispatchThreadBranch(storageRoot: string, rest: string[]): Promise<number> {
const sub = rest[0];
if (sub === "rm") {
return dispatchThreadRm(storageRoot, rest.slice(1));
}
return dispatchThread(storageRoot, rest);
}
async function dispatchGc(storageRoot: string, argv: string[]): Promise<number> {
if (argv.length > 0) {
printCliError(`${formatCliUsage()}\n\nerror: gc takes no arguments`);
@@ -348,6 +355,8 @@ async function dispatchFork(storageRoot: string, argv: string[]): Promise<number
return 0;
}
// ── CAS subcommand table ───────────────────────────────────────────────
async function dispatchCasGet(storageRoot: string, rest: string[]): Promise<number> {
const threadId = rest[0];
const hash = rest[1];
@@ -413,14 +422,12 @@ async function dispatchCasRm(storageRoot: string, rest: string[]): Promise<numbe
return 0;
}
const CAS_SUBCOMMAND_TABLE: Record<
string,
(storageRoot: string, rest: string[]) => Promise<number>
> = {
const CAS_SUBCOMMAND_TABLE: Record<string, DispatchFn> = {
get: dispatchCasGet,
put: dispatchCasPut,
list: dispatchCasList,
rm: dispatchCasRm,
gc: dispatchGc,
};
async function dispatchCas(storageRoot: string, argv: string[]): Promise<number> {
@@ -437,27 +444,105 @@ async function dispatchCas(storageRoot: string, argv: string[]): Promise<number>
return handler(storageRoot, argv.slice(1));
}
type DispatchFn = (storageRoot: string, argv: string[]) => Promise<number>;
// ── Workflow subcommand table (Phase 1) ────────────────────────────────
const COMMAND_TABLE: Record<string, DispatchFn> = {
const WORKFLOW_SUBCOMMAND_TABLE: Record<string, DispatchFn> = {
add: dispatchAdd,
init: dispatchInit,
list: dispatchList,
show: dispatchShow,
remove: dispatchRemove,
rm: dispatchRemove,
history: dispatchHistory,
rollback: dispatchRollback,
};
async function dispatchWorkflow(storageRoot: string, argv: string[]): Promise<number> {
const sub = argv[0];
if (sub === undefined) {
printCliError(`${formatCliUsage()}\n\nerror: unknown workflow subcommand: (none)`);
return 1;
}
const handler = WORKFLOW_SUBCOMMAND_TABLE[sub];
if (handler !== undefined) {
return handler(storageRoot, argv.slice(1));
}
if (sub === "remove") {
printDeprecation("workflow remove", "workflow rm");
return dispatchRemove(storageRoot, argv.slice(1));
}
printCliError(`${formatCliUsage()}\n\nerror: unknown workflow subcommand: ${sub}`);
return 1;
}
// ── Thread subcommand table (Phase 2) ──────────────────────────────────
const THREAD_SUBCOMMAND_TABLE: Record<string, DispatchFn> = {
run: dispatchRun,
list: dispatchThreadList,
show: dispatchThreadShow,
rm: dispatchThreadRm,
fork: dispatchFork,
ps: dispatchPs,
kill: dispatchKill,
live: dispatchLive,
history: dispatchHistory,
rollback: dispatchRollback,
pause: dispatchPause,
resume: dispatchResume,
threads: dispatchThreads,
thread: dispatchThreadBranch,
fork: dispatchFork,
gc: dispatchGc,
};
async function dispatchThread(storageRoot: string, argv: string[]): Promise<number> {
const sub = argv[0];
if (sub === undefined) {
printCliError(`${formatCliUsage()}\n\nerror: unknown thread subcommand: (none)`);
return 1;
}
const handler = THREAD_SUBCOMMAND_TABLE[sub];
if (handler === undefined) {
printCliError(`${formatCliUsage()}\n\nerror: unknown thread subcommand: ${sub}`);
return 1;
}
return handler(storageRoot, argv.slice(1));
}
// ── Help ────────────────────────────────────────────────────────────────
async function dispatchHelp(_storageRoot: string, argv: string[]): Promise<number> {
if (argv.includes("--skill")) {
printCliLine(formatSkillDoc());
} else {
printCliLine(formatCliUsage());
}
return 0;
}
// ── Top-level command table (Phase 3) ──────────────────────────────────
const COMMAND_TABLE: Record<string, DispatchFn> = {
// Grouped commands (primary)
workflow: dispatchWorkflow,
thread: dispatchThread,
cas: dispatchCas,
init: dispatchInit,
help: dispatchHelp,
// Top-level shortcuts (no deprecation)
run: dispatchRun,
live: dispatchLive,
};
// Deprecated flat commands that delegate to grouped commands
const DEPRECATED_ALIASES: Record<string, { newCmd: string; handler: DispatchFn }> = {
add: { newCmd: "workflow add", handler: dispatchAdd },
list: { newCmd: "workflow list", handler: dispatchList },
show: { newCmd: "workflow show", handler: dispatchShow },
remove: { newCmd: "workflow rm", handler: dispatchRemove },
ps: { newCmd: "thread ps", handler: dispatchPs },
kill: { newCmd: "thread kill", handler: dispatchKill },
pause: { newCmd: "thread pause", handler: dispatchPause },
resume: { newCmd: "thread resume", handler: dispatchResume },
threads: { newCmd: "thread list", handler: dispatchThreadList },
fork: { newCmd: "thread fork", handler: dispatchFork },
gc: { newCmd: "cas gc", handler: dispatchGc },
history: { newCmd: "workflow history", handler: dispatchHistory },
rollback: { newCmd: "workflow rollback", handler: dispatchRollback },
};
export async function runCli(storageRoot: string, argv: string[]): Promise<number> {
@@ -471,10 +556,18 @@ export async function runCli(storageRoot: string, argv: string[]): Promise<numbe
return 1;
}
const rest = argv.slice(1);
const dispatch = COMMAND_TABLE[command];
if (dispatch === undefined) {
printCliError(`${formatCliUsage()}\n\nerror: unknown command ${command}`);
return 1;
if (dispatch !== undefined) {
return dispatch(storageRoot, rest);
}
return dispatch(storageRoot, rest);
const deprecated = DEPRECATED_ALIASES[command];
if (deprecated !== undefined) {
printDeprecation(command, deprecated.newCmd);
return deprecated.handler(storageRoot, rest);
}
printCliError(`${formatCliUsage()}\n\nerror: unknown command ${command}`);
return 1;
}
+86
View File
@@ -0,0 +1,86 @@
export function formatSkillDoc(): string {
return `# uncaged-workflow CLI Reference
## Core Concepts
| Concept | Description |
|---------|-------------|
| **Workflow** | A single-file ESM bundle (\`.esm.js\`) that exports \`run\` and \`descriptor\`. Identified by name and XXH64 hash. |
| **Bundle** | The physical \`.esm.js\` file stored in the bundles directory. Immutable once written. |
| **Thread** | A single execution of a workflow, identified by a ULID. Persists state as JSONL files. |
| **CAS** | Content-Addressable Storage. Per-thread key-value store keyed by content hash. |
| **Registry** | \`workflow.yaml\` — maps workflow names to their current and historical bundle hashes. |
## Commands
### workflow
| Command | Args | Description |
|---------|------|-------------|
| \`workflow add\` | \`<name> <file.esm.js> [--types <path>]\` | Register a workflow bundle in the registry |
| \`workflow list\` | (none) | List all registered workflows |
| \`workflow show\` | \`<name>\` | Show details of a registered workflow |
| \`workflow rm\` | \`<name>\` | Remove a workflow from the registry |
| \`workflow history\` | \`<name>\` | Show version history of a workflow |
| \`workflow rollback\` | \`<name> [hash]\` | Rollback a workflow to a previous version |
### thread
| Command | Args | Description |
|---------|------|-------------|
| \`thread run\` | \`<name> [--prompt <text>] [--max-rounds N]\` | Start a new thread executing a workflow |
| \`thread list\` | \`[name]\` | List threads, optionally filtered by workflow name |
| \`thread show\` | \`<id>\` | Show thread details and state |
| \`thread rm\` | \`<id>\` | Remove a thread |
| \`thread fork\` | \`<thread-id> [--from-role <role>]\` | Fork a thread, optionally from a specific role |
| \`thread ps\` | (none) | List running threads |
| \`thread kill\` | \`<thread-id>\` | Kill a running thread |
| \`thread live\` | \`<thread-id> [--debug] [--role <name>]\` or \`--latest [--debug] [--role <name>]\` | Attach to a thread and stream output live |
| \`thread pause\` | \`<thread-id>\` | Pause a running thread |
| \`thread resume\` | \`<thread-id>\` | Resume a paused thread |
### cas
| Command | Args | Description |
|---------|------|-------------|
| \`cas get\` | \`<thread-id> <hash>\` | Retrieve content by hash from a thread's CAS |
| \`cas put\` | \`<thread-id> <content>\` | Store content in a thread's CAS, returns hash |
| \`cas list\` | \`<thread-id>\` | List all CAS entries for a thread |
| \`cas rm\` | \`<thread-id> <hash>\` | Remove a CAS entry |
| \`cas gc\` | (none) | Garbage-collect unreferenced CAS entries |
### init
| Command | Args | Description |
|---------|------|-------------|
| \`init workspace\` | \`<name>\` | Initialize a new workflow workspace |
| \`init template\` | \`<name>\` | Initialize a new workflow template |
### Top-level shortcuts
| Command | Equivalent | Description |
|---------|------------|-------------|
| \`run\` | \`thread run\` | Shortcut to start a thread |
| \`live\` | \`thread live\` | Shortcut to attach to a thread |
## Typical Workflow
1. \`uncaged-workflow workflow add my-wf ./my-wf.esm.js\` — register a workflow
2. \`uncaged-workflow run my-wf --prompt "do the thing"\` — start a thread
3. \`uncaged-workflow live --latest\` — attach and watch output
4. \`uncaged-workflow thread show <thread-id>\` — inspect completed thread
## Exit Codes
| Code | Meaning |
|------|---------|
| 0 | Success |
| 1 | Error |
## Environment Variables
| Variable | Description |
|----------|-------------|
| \`UNCAGED_WORKFLOW_STORAGE_ROOT\` | Override the default storage directory for all workflow data |
`;
}
@@ -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,
} 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 { SubmitterMeta } from "@uncaged/workflow-role-submitter";
import { buildSolveIssueDescriptor } from "../src/descriptor.js";
import type { DeveloperMeta } from "../src/developer.js";
import { createSolveIssueRun, solveIssueModerator } from "../src/index.js";
import type { SolveIssueMeta } from "../src/roles.js";
const DEFAULT_PHASES: PlannerMeta["phases"] = [
{
hash: "4KNMR2PX",
title: "Do the work",
},
];
function jsonResponse(payload: Record<string, unknown>): Response {
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
const EXPECT_PLANNER_META: PlannerMeta = {
phases: [
{
hash: "7BQST3VW",
title: "placeholder phase",
},
],
};
function readToolListFromBody(init: RequestInit | undefined): readonly Record<string, unknown>[] {
if (init === undefined || init.body === undefined || init.body === null) {
return [];
}
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 = {
completedPhase: "7BQST3VW",
filesChanged: [],
summary: "",
};
function singleToolName(tools: readonly Record<string, unknown>[]): string {
if (tools.length === 0) {
return "extract";
}
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 {
const origFetch = globalThis.fetch;
let i = 0;
const mockFetch = async (
input: Parameters<typeof fetch>[0],
_input: Parameters<typeof fetch>[0],
init?: RequestInit,
): Promise<Response> => {
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");
}
i += 1;
void input;
const body = init?.body ? (JSON.parse(String(init.body)) as Record<string, unknown>) : {};
const tools = body.tools;
const firstTool =
Array.isArray(tools) && tools.length > 0 && tools[0] !== null && typeof tools[0] === "object"
? (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" } },
);
const tools = readToolListFromBody(init);
if (tools.length > 1) {
return buildReactModeResponse(args);
}
return buildSingleModeResponse(args, singleToolName(tools));
};
globalThis.fetch = Object.assign(mockFetch, {
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 {
role: "planner",
contentHash: "STUBHASHPLANNER001",
meta: { phases },
refs: phases.map((p) => p.hash),
role: "developer",
contentHash: "STUBHASHDEVELOPER1",
meta: {
branch: "feat/issue-1",
commitSha: "abc1234",
filesChanged: ["src/login.ts"],
summary: "Fixed flaky login test by stabilising async setup.",
},
refs: [],
timestamp: 1,
};
}
function coderStep(completedPhase = "4KNMR2PX"): RoleStep<SolveIssueMeta> {
function submitterStep(meta: SubmitterMeta): RoleStep<SolveIssueMeta> {
return {
role: "coder",
contentHash: "STUBHASHCODER00001",
meta: { completedPhase, filesChanged: ["a.ts"], summary: "fixed" },
refs: [completedPhase],
role: "submitter",
contentHash: "STUBHASHSUBMITTER1",
meta,
refs: [],
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({
baseUrl: "http://127.0.0.1:9",
apiKey: "",
model: "test",
});
const stubLlmProvider = {
baseUrl: "http://127.0.0.1:9",
apiKey: "",
model: "test",
};
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, [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, [preparerStep()]))).toBe("developer");
expect(solveIssueModerator(makeCtx(20, [preparerStep(), developerStep()]))).toBe("submitter");
expect(
solveIssueModerator(
makeCtx(20, [
preparerStep(),
plannerStep(),
coderStep(),
reviewerStep(true),
committerStep(),
developerStep(),
submitterStep({
status: "submitted",
prUrl: "https://github.com/example/repo/pull/1",
}),
]),
),
).toBe(END);
});
test("reviewer rejects → coder retry when budget allows", () => {
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",
);
test("submitter failed → END", () => {
expect(
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)", () => {
const phases: PlannerMeta["phases"] = [
{ hash: "BB000001", title: "setup branch" },
{ hash: "BB000002", title: "write tests" },
{ hash: "BB000003", title: "verify" },
{ hash: "BB000004", title: "commit and pr" },
];
expect(solveIssueModerator(makeCtx(20, [plannerStep(phases), coderStep("BB000004")]))).toBe(
"reviewer",
);
});
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);
test("returns END for any unexpected last step (defensive)", () => {
// A submitter step with a pseudo-unknown future status would still be
// routed to END, since the moderator is a closed switch over known roles.
expect(
solveIssueModerator(
makeCtx(20, [
preparerStep(),
developerStep(),
submitterStep({ status: "submitted", prUrl: "https://example.com/pr/1" }),
]),
),
).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 = {
repoPath: "/home/user/repos/test",
defaultBranch: "main",
@@ -308,12 +247,20 @@ describe("createSolveIssueRun", () => {
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-"));
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(
{ prompt: "task", steps: [] },
{ threadId: "01TEST000000000000000000TR", maxRounds: 20, depth: 0, cas },
@@ -325,14 +272,6 @@ describe("createSolveIssueRun", () => {
}
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 () => {
@@ -342,11 +281,17 @@ describe("createSolveIssueRun", () => {
conventions: null,
toolchain: { packageManager: null, testCommand: null, lintCommand: null, buildCommand: null },
};
restoreFetch = installMockChatCompletions([
PREPARER_META,
EXPECT_PLANNER_META,
EXPECT_CODER_META,
]);
const DEVELOPER_META: DeveloperMeta = {
branch: "feat/x",
commitSha: "abc1234",
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-"));
const cas = createCasStore(casDir);
@@ -363,18 +308,18 @@ describe("createSolveIssueRun", () => {
calls.push("preparer");
return "";
},
planner: async () => {
calls.push("planner");
return "";
developer: async () => {
calls.push("developer");
return "stub-root-hash";
},
coder: async () => {
calls.push("coder");
submitter: async () => {
calls.push("submitter");
return "";
},
},
},
stubExtract,
null,
stubLlmProvider,
);
const gen = run(
{ prompt: "task", steps: [] },
@@ -385,16 +330,65 @@ describe("createSolveIssueRun", () => {
calls.length = 0;
await gen.next();
expect(calls).toEqual(["planner"]);
expect(calls).toEqual(["developer"]);
calls.length = 0;
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", () => {
test("lists all roles with schemas that validate", () => {
test("lists preparer, developer, submitter with schemas that validate", () => {
const descriptor = buildSolveIssueDescriptor();
const validated = validateWorkflowDescriptor(descriptor);
expect(validated.ok).toBe(true);
@@ -402,13 +396,11 @@ describe("buildSolveIssueDescriptor", () => {
throw new Error(validated.error);
}
expect(Object.keys(validated.value.roles).sort()).toEqual([
"coder",
"committer",
"planner",
"developer",
"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];
expect(role).toBeDefined();
expect(typeof role.schema).toBe("object");
@@ -10,10 +10,8 @@
},
"dependencies": {
"@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-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 WorkflowDefinition,
type WorkflowFn,
workflowAsAgent,
} from "@uncaged/workflow";
import { solveIssueModerator } from "./moderator.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 {
type PreparerMeta,
preparerMetaSchema,
preparerRole,
} from "@uncaged/workflow-role-preparer";
export {
type ReviewerMeta,
reviewerMetaSchema,
reviewerRole,
} from "@uncaged/workflow-role-reviewer";
type SubmitterMeta,
submitterMetaSchema,
submitterRole,
} from "@uncaged/workflow-role-submitter";
export { buildSolveIssueDescriptor } from "./descriptor.js";
export {
type DeveloperMeta,
developerMetaSchema,
developerRole,
} from "./developer.js";
export { solveIssueModerator } from "./moderator.js";
export {
SOLVE_ISSUE_WORKFLOW_DESCRIPTION,
@@ -51,10 +41,25 @@ export const solveIssueWorkflowDefinition: WorkflowDefinition<SolveIssueMeta> =
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(
binding: AgentBinding,
extract: ExtractFn,
llmProvider: LlmProvider | null,
): 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 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) => {
const maxRounds = ctx.start.meta.maxRounds;
if (ctx.steps.length === 0) {
return "preparer";
}
@@ -54,31 +11,14 @@ export const solveIssueModerator: Moderator<SolveIssueMeta> = (ctx) => {
const last = ctx.steps[ctx.steps.length - 1];
if (last.role === "preparer") {
return "planner";
return "developer";
}
if (last.role === "planner") {
return "coder";
if (last.role === "developer") {
return "submitter";
}
if (last.role === "coder") {
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";
}
if (last.role === "submitter") {
return END;
}
@@ -1,19 +1,16 @@
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";
import { type SubmitterMeta, submitterRole } from "@uncaged/workflow-role-submitter";
import { type DeveloperMeta, developerRole } from "./developer.js";
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 (preparerdeveloper → submitter).";
export type SolveIssueMeta = {
preparer: PreparerMeta;
planner: PlannerMeta;
coder: CoderMeta;
reviewer: ReviewerMeta;
committer: CommitterMeta;
developer: DeveloperMeta;
submitter: SubmitterMeta;
};
export type SolveIssueRoles = {
@@ -22,8 +19,6 @@ export type SolveIssueRoles = {
export const solveIssueRoles: SolveIssueRoles = {
preparer: preparerRole,
planner: plannerRole,
coder: coderRole,
reviewer: reviewerRole,
committer: committerRole,
developer: developerRole,
submitter: submitterRole,
};
@@ -8,9 +8,7 @@
"include": ["src/**/*.ts"],
"references": [
{ "path": "../workflow" },
{ "path": "../workflow-role-coder" },
{ "path": "../workflow-role-committer" },
{ "path": "../workflow-role-planner" },
{ "path": "../workflow-role-reviewer" }
{ "path": "../workflow-role-preparer" },
{ "path": "../workflow-role-submitter" }
]
}
+2
View File
@@ -22,7 +22,9 @@
{ "path": "packages/workflow-role-committer" },
{ "path": "packages/workflow-role-coder" },
{ "path": "packages/workflow-role-planner" },
{ "path": "packages/workflow-role-preparer" },
{ "path": "packages/workflow-role-reviewer" },
{ "path": "packages/workflow-role-submitter" },
{ "path": "packages/workflow-role-tester" },
{ "path": "packages/workflow-agent-cursor" },
{ "path": "packages/workflow-agent-hermes" },