Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8456a8337b | |||
| 9c8b98a551 | |||
| c3272be760 | |||
| c44b773a86 | |||
| 2776f8e419 | |||
| 7b0e256c13 | |||
| c663ba9e9c | |||
| 71b413f20c | |||
| 61be1c662a | |||
| 84e8d70da4 | |||
| 8976f4cf3b | |||
| 07730dd24c | |||
| 4eff4d2370 | |||
| 1d6da18b18 | |||
| c342ff3737 |
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { getDefaultWorkflowStorageRoot } from "@uncaged/workflow";
|
||||
import { resolveWorkflowStorageRoot } from "../src/storage-env.js";
|
||||
|
||||
describe("resolveWorkflowStorageRoot", () => {
|
||||
let savedInternal: string | undefined;
|
||||
let savedUser: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
savedInternal = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||
savedUser = process.env.WORKFLOW_STORAGE_ROOT;
|
||||
delete process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||
delete process.env.WORKFLOW_STORAGE_ROOT;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (savedInternal === undefined) {
|
||||
delete process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||
} else {
|
||||
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = savedInternal;
|
||||
}
|
||||
if (savedUser === undefined) {
|
||||
delete process.env.WORKFLOW_STORAGE_ROOT;
|
||||
} else {
|
||||
process.env.WORKFLOW_STORAGE_ROOT = savedUser;
|
||||
}
|
||||
});
|
||||
|
||||
test("returns default when no env vars are set", () => {
|
||||
expect(resolveWorkflowStorageRoot()).toBe(getDefaultWorkflowStorageRoot());
|
||||
});
|
||||
|
||||
test("WORKFLOW_STORAGE_ROOT overrides default", () => {
|
||||
process.env.WORKFLOW_STORAGE_ROOT = "/tmp/custom-storage";
|
||||
expect(resolveWorkflowStorageRoot()).toBe("/tmp/custom-storage");
|
||||
});
|
||||
|
||||
test("UNCAGED_WORKFLOW_STORAGE_ROOT takes priority over WORKFLOW_STORAGE_ROOT", () => {
|
||||
process.env.WORKFLOW_STORAGE_ROOT = "/tmp/user-path";
|
||||
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = "/tmp/internal-path";
|
||||
expect(resolveWorkflowStorageRoot()).toBe("/tmp/internal-path");
|
||||
});
|
||||
|
||||
test("ignores empty WORKFLOW_STORAGE_ROOT", () => {
|
||||
process.env.WORKFLOW_STORAGE_ROOT = "";
|
||||
expect(resolveWorkflowStorageRoot()).toBe(getDefaultWorkflowStorageRoot());
|
||||
});
|
||||
|
||||
test("ignores empty UNCAGED_WORKFLOW_STORAGE_ROOT and falls through to WORKFLOW_STORAGE_ROOT", () => {
|
||||
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = "";
|
||||
process.env.WORKFLOW_STORAGE_ROOT = "/tmp/user-fallback";
|
||||
expect(resolveWorkflowStorageRoot()).toBe("/tmp/user-fallback");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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";
|
||||
@@ -20,66 +21,49 @@ import { cmdThreads } from "./cmd-threads.js";
|
||||
import { parseLiveArgv } from "./live-argv.js";
|
||||
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 thread rm <id>",
|
||||
" uncaged-workflow fork <thread-id> [--from-role <role>]",
|
||||
" uncaged-workflow gc",
|
||||
" 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 init workspace <name>",
|
||||
" uncaged-workflow init template <name>",
|
||||
].join("\n");
|
||||
}
|
||||
type DispatchFn = (storageRoot: string, argv: string[]) => Promise<number>;
|
||||
|
||||
async function dispatchInit(_storageRoot: string, argv: string[]): Promise<number> {
|
||||
const sub = argv[0];
|
||||
const name = argv[1];
|
||||
if (sub === undefined || name === undefined || argv.length > 2) {
|
||||
printCliError(`${formatCliUsage()}\n\nerror: init requires workspace|template <name>`);
|
||||
type CommandEntry = {
|
||||
handler: DispatchFn;
|
||||
args: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
type CommandGroup = {
|
||||
name: string;
|
||||
commands: ReadonlyArray<{ name: string; args: string; description: string }>;
|
||||
};
|
||||
|
||||
// ── Individual dispatch functions ──────────────────────────────────────
|
||||
|
||||
async function dispatchInitWorkspace(_storageRoot: string, argv: string[]): Promise<number> {
|
||||
const name = argv[0];
|
||||
if (name === undefined || argv.length > 1) {
|
||||
printCliError(`${formatCliUsage()}\n\nerror: init workspace requires <name>`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (sub === "workspace") {
|
||||
const result = await cmdInitWorkspace(process.cwd(), name);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(`initialized workflow workspace at ${result.value.rootPath}`);
|
||||
return 0;
|
||||
const result = await cmdInitWorkspace(process.cwd(), name);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(`initialized workflow workspace at ${result.value.rootPath}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (sub === "template") {
|
||||
const result = await cmdInitTemplate(process.cwd(), name);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(`initialized template at ${result.value.templatePath}`);
|
||||
return 0;
|
||||
async function dispatchInitTemplate(_storageRoot: string, argv: string[]): Promise<number> {
|
||||
const name = argv[0];
|
||||
if (name === undefined || argv.length > 1) {
|
||||
printCliError(`${formatCliUsage()}\n\nerror: init template requires <name>`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
printCliError(`${formatCliUsage()}\n\nerror: unknown init subcommand: ${sub}`);
|
||||
return 1;
|
||||
const result = await cmdInitTemplate(process.cwd(), name);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(`initialized template at ${result.value.templatePath}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function dispatchAdd(storageRoot: string, argv: string[]): Promise<number> {
|
||||
@@ -266,7 +250,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 +262,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 +292,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 +324,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,51 +391,279 @@ async function dispatchCasRm(storageRoot: string, rest: string[]): Promise<numbe
|
||||
return 0;
|
||||
}
|
||||
|
||||
const CAS_SUBCOMMAND_TABLE: Record<
|
||||
string,
|
||||
(storageRoot: string, rest: string[]) => Promise<number>
|
||||
> = {
|
||||
get: dispatchCasGet,
|
||||
put: dispatchCasPut,
|
||||
list: dispatchCasList,
|
||||
rm: dispatchCasRm,
|
||||
// ── Subcommand tables with metadata ────────────────────────────────────
|
||||
|
||||
const WORKFLOW_SUBCOMMAND_TABLE: Record<string, CommandEntry> = {
|
||||
add: {
|
||||
handler: dispatchAdd,
|
||||
args: "<name> <file.esm.js> [--types <path>]",
|
||||
description: "Register a workflow bundle in the registry",
|
||||
},
|
||||
list: { handler: dispatchList, args: "", description: "List all registered workflows" },
|
||||
show: {
|
||||
handler: dispatchShow,
|
||||
args: "<name>",
|
||||
description: "Show details of a registered workflow",
|
||||
},
|
||||
rm: {
|
||||
handler: dispatchRemove,
|
||||
args: "<name>",
|
||||
description: "Remove a workflow from the registry",
|
||||
},
|
||||
history: {
|
||||
handler: dispatchHistory,
|
||||
args: "<name>",
|
||||
description: "Show version history of a workflow",
|
||||
},
|
||||
rollback: {
|
||||
handler: dispatchRollback,
|
||||
args: "<name> [hash]",
|
||||
description: "Rollback a workflow to a previous version",
|
||||
},
|
||||
};
|
||||
|
||||
async function dispatchCas(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const sub = argv[0];
|
||||
if (sub === undefined) {
|
||||
printCliError(`${formatCliUsage()}\n\nerror: unknown cas subcommand: (none)`);
|
||||
return 1;
|
||||
}
|
||||
const handler = CAS_SUBCOMMAND_TABLE[sub];
|
||||
if (handler === undefined) {
|
||||
printCliError(`${formatCliUsage()}\n\nerror: unknown cas subcommand: ${sub}`);
|
||||
return 1;
|
||||
}
|
||||
return handler(storageRoot, argv.slice(1));
|
||||
const THREAD_SUBCOMMAND_TABLE: Record<string, CommandEntry> = {
|
||||
run: {
|
||||
handler: dispatchRun,
|
||||
args: "<name> [--prompt <text>] [--max-rounds N]",
|
||||
description: "Start a new thread executing a workflow",
|
||||
},
|
||||
list: {
|
||||
handler: dispatchThreadList,
|
||||
args: "[name]",
|
||||
description: "List threads, optionally filtered by workflow name",
|
||||
},
|
||||
show: { handler: dispatchThreadShow, args: "<id>", description: "Show thread details and state" },
|
||||
rm: { handler: dispatchThreadRm, args: "<id>", description: "Remove a thread" },
|
||||
fork: {
|
||||
handler: dispatchFork,
|
||||
args: "<thread-id> [--from-role <role>]",
|
||||
description: "Fork a thread, optionally from a specific role",
|
||||
},
|
||||
ps: { handler: dispatchPs, args: "", description: "List running threads" },
|
||||
kill: { handler: dispatchKill, args: "<thread-id>", description: "Kill a running thread" },
|
||||
live: {
|
||||
handler: dispatchLive,
|
||||
args: "<thread-id> [--debug] [--role <name>]",
|
||||
description: "Attach to a thread and stream output live",
|
||||
},
|
||||
pause: { handler: dispatchPause, args: "<thread-id>", description: "Pause a running thread" },
|
||||
resume: { handler: dispatchResume, args: "<thread-id>", description: "Resume a paused thread" },
|
||||
};
|
||||
|
||||
const CAS_SUBCOMMAND_TABLE: Record<string, CommandEntry> = {
|
||||
get: {
|
||||
handler: dispatchCasGet,
|
||||
args: "<thread-id> <hash>",
|
||||
description: "Retrieve content by hash from a thread's CAS",
|
||||
},
|
||||
put: {
|
||||
handler: dispatchCasPut,
|
||||
args: "<thread-id> <content>",
|
||||
description: "Store content in a thread's CAS, returns hash",
|
||||
},
|
||||
list: {
|
||||
handler: dispatchCasList,
|
||||
args: "<thread-id>",
|
||||
description: "List all CAS entries for a thread",
|
||||
},
|
||||
rm: { handler: dispatchCasRm, args: "<thread-id> <hash>", description: "Remove a CAS entry" },
|
||||
gc: { handler: dispatchGc, args: "", description: "Garbage-collect unreferenced CAS entries" },
|
||||
};
|
||||
|
||||
const INIT_SUBCOMMAND_TABLE: Record<string, CommandEntry> = {
|
||||
workspace: {
|
||||
handler: dispatchInitWorkspace,
|
||||
args: "<name>",
|
||||
description: "Initialize a new workflow workspace",
|
||||
},
|
||||
template: {
|
||||
handler: dispatchInitTemplate,
|
||||
args: "<name>",
|
||||
description: "Initialize a new workflow template",
|
||||
},
|
||||
};
|
||||
|
||||
// ── Command registry ───────────────────────────────────────────────────
|
||||
|
||||
export function getCommandRegistry(): ReadonlyArray<CommandGroup> {
|
||||
return [
|
||||
{
|
||||
name: "workflow",
|
||||
commands: Object.entries(WORKFLOW_SUBCOMMAND_TABLE).map(([name, e]) => ({
|
||||
name,
|
||||
args: e.args,
|
||||
description: e.description,
|
||||
})),
|
||||
},
|
||||
{
|
||||
name: "thread",
|
||||
commands: Object.entries(THREAD_SUBCOMMAND_TABLE).map(([name, e]) => ({
|
||||
name,
|
||||
args: e.args,
|
||||
description: e.description,
|
||||
})),
|
||||
},
|
||||
{
|
||||
name: "cas",
|
||||
commands: Object.entries(CAS_SUBCOMMAND_TABLE).map(([name, e]) => ({
|
||||
name,
|
||||
args: e.args,
|
||||
description: e.description,
|
||||
})),
|
||||
},
|
||||
{
|
||||
name: "init",
|
||||
commands: Object.entries(INIT_SUBCOMMAND_TABLE).map(([name, e]) => ({
|
||||
name,
|
||||
args: e.args,
|
||||
description: e.description,
|
||||
})),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
type DispatchFn = (storageRoot: string, argv: string[]) => Promise<number>;
|
||||
// ── Auto-generated CLI usage ───────────────────────────────────────────
|
||||
|
||||
export function formatCliUsage(): string {
|
||||
const groups = getCommandRegistry();
|
||||
const lines: string[] = ["Usage:"];
|
||||
for (const group of groups) {
|
||||
for (const cmd of group.commands) {
|
||||
const args = cmd.args ? ` ${cmd.args}` : "";
|
||||
lines.push(` uncaged-workflow ${group.name} ${cmd.name}${args}`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
lines.push(" uncaged-workflow run <name> [...] (shortcut for thread run)");
|
||||
lines.push(" uncaged-workflow live <thread-id> [...] (shortcut for thread live)");
|
||||
lines.push("");
|
||||
lines.push("Environment variables:");
|
||||
lines.push(
|
||||
" WORKFLOW_STORAGE_ROOT Override storage directory (default: ~/.uncaged/workflow)",
|
||||
);
|
||||
lines.push(
|
||||
" UNCAGED_WORKFLOW_STORAGE_ROOT Internal override (takes priority over WORKFLOW_STORAGE_ROOT)",
|
||||
);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function printDeprecation(oldCmd: string, newCmd: string): void {
|
||||
printCliWarn(`⚠ "${oldCmd}" is deprecated, use "${newCmd}" instead`);
|
||||
}
|
||||
|
||||
// ── Group dispatchers ──────────────────────────────────────────────────
|
||||
|
||||
function dispatchGroup(
|
||||
tableName: string,
|
||||
table: Record<string, CommandEntry>,
|
||||
storageRoot: string,
|
||||
argv: string[],
|
||||
): Promise<number> | null {
|
||||
const sub = argv[0];
|
||||
if (sub === undefined) {
|
||||
printCliError(`${formatCliUsage()}\n\nerror: unknown ${tableName} subcommand: (none)`);
|
||||
return Promise.resolve(1);
|
||||
}
|
||||
const entry = table[sub];
|
||||
if (entry === undefined) {
|
||||
return null;
|
||||
}
|
||||
return entry.handler(storageRoot, argv.slice(1));
|
||||
}
|
||||
|
||||
async function dispatchInit(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const sub = argv[0];
|
||||
const name = argv[1];
|
||||
if (sub === undefined || name === undefined || argv.length > 2) {
|
||||
printCliError(`${formatCliUsage()}\n\nerror: init requires workspace|template <name>`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const entry = INIT_SUBCOMMAND_TABLE[sub];
|
||||
if (entry !== undefined) {
|
||||
return entry.handler(storageRoot, argv.slice(1));
|
||||
}
|
||||
|
||||
printCliError(`${formatCliUsage()}\n\nerror: unknown init subcommand: ${sub}`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
async function dispatchWorkflow(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const result = dispatchGroup("workflow", WORKFLOW_SUBCOMMAND_TABLE, storageRoot, argv);
|
||||
if (result !== null) {
|
||||
return result;
|
||||
}
|
||||
const sub = argv[0];
|
||||
if (sub === "remove") {
|
||||
printDeprecation("workflow remove", "workflow rm");
|
||||
return dispatchRemove(storageRoot, argv.slice(1));
|
||||
}
|
||||
printCliError(`${formatCliUsage()}\n\nerror: unknown workflow subcommand: ${sub}`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
async function dispatchThread(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const result = dispatchGroup("thread", THREAD_SUBCOMMAND_TABLE, storageRoot, argv);
|
||||
if (result !== null) {
|
||||
return result;
|
||||
}
|
||||
const sub = argv[0];
|
||||
printCliError(`${formatCliUsage()}\n\nerror: unknown thread subcommand: ${sub}`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
async function dispatchCas(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const result = dispatchGroup("cas", CAS_SUBCOMMAND_TABLE, storageRoot, argv);
|
||||
if (result !== null) {
|
||||
return result;
|
||||
}
|
||||
const sub = argv[0];
|
||||
printCliError(`${formatCliUsage()}\n\nerror: unknown cas subcommand: ${sub}`);
|
||||
return 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> = {
|
||||
add: dispatchAdd,
|
||||
init: dispatchInit,
|
||||
list: dispatchList,
|
||||
show: dispatchShow,
|
||||
remove: dispatchRemove,
|
||||
run: dispatchRun,
|
||||
ps: dispatchPs,
|
||||
kill: dispatchKill,
|
||||
live: dispatchLive,
|
||||
history: dispatchHistory,
|
||||
rollback: dispatchRollback,
|
||||
pause: dispatchPause,
|
||||
resume: dispatchResume,
|
||||
threads: dispatchThreads,
|
||||
thread: dispatchThreadBranch,
|
||||
fork: dispatchFork,
|
||||
gc: dispatchGc,
|
||||
// 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 +677,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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { getCommandRegistry } from "./cli-dispatch.js";
|
||||
|
||||
export function formatSkillDoc(): string {
|
||||
const groups = getCommandRegistry();
|
||||
|
||||
const commandSections: string[] = [];
|
||||
for (const group of groups) {
|
||||
const rows = group.commands.map((cmd) => {
|
||||
const args = cmd.args ? `\`${cmd.args}\`` : "(none)";
|
||||
return `| \`${group.name} ${cmd.name}\` | ${args} | ${cmd.description} |`;
|
||||
});
|
||||
commandSections.push(
|
||||
`### ${group.name}\n\n| Command | Args | Description |\n|---------|------|-------------|\n${rows.join("\n")}`,
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
${commandSections.join("\n\n")}
|
||||
|
||||
### 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 |
|
||||
`;
|
||||
}
|
||||
@@ -1,10 +1,21 @@
|
||||
import { getDefaultWorkflowStorageRoot } from "@uncaged/workflow";
|
||||
|
||||
/** Resolve storage root, honoring `UNCAGED_WORKFLOW_STORAGE_ROOT` for tests/tools. */
|
||||
/**
|
||||
* Resolve storage root with env var override support.
|
||||
*
|
||||
* Priority (highest first):
|
||||
* 1. `UNCAGED_WORKFLOW_STORAGE_ROOT` — internal/test override
|
||||
* 2. `WORKFLOW_STORAGE_ROOT` — user-facing override
|
||||
* 3. Default (`~/.uncaged/workflow`)
|
||||
*/
|
||||
export function resolveWorkflowStorageRoot(): string {
|
||||
const override = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||
if (override !== undefined && override !== "") {
|
||||
return override;
|
||||
const internal = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||
if (internal !== undefined && internal !== "") {
|
||||
return internal;
|
||||
}
|
||||
const userOverride = process.env.WORKFLOW_STORAGE_ROOT;
|
||||
if (userOverride !== undefined && userOverride !== "") {
|
||||
return userOverride;
|
||||
}
|
||||
return getDefaultWorkflowStorageRoot();
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-role-coder",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"scripts": {
|
||||
"build": "echo 'TODO'",
|
||||
"test": "echo no tests"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow": "workspace:*",
|
||||
"zod": "^4.0.0"
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { type CoderMeta, coderMetaSchema, coderRole } from "./coder.js";
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"references": [{ "path": "../workflow" }]
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-role-committer",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { type CommitterMeta, committerMetaSchema, committerRole } from "./committer.js";
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"references": [{ "path": "../workflow" }]
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-role-planner",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"scripts": {
|
||||
"build": "echo 'TODO'",
|
||||
"test": "echo no tests"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow": "workspace:*",
|
||||
"zod": "^4.0.0"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export {
|
||||
type PlannerMeta,
|
||||
phaseSchema,
|
||||
plannerMetaSchema,
|
||||
plannerRole,
|
||||
} from "./planner.js";
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"references": [{ "path": "../workflow" }]
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-role-preparer",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"scripts": {
|
||||
"build": "echo 'TODO'",
|
||||
"test": "echo no tests"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow": "workspace:*",
|
||||
"zod": "^4.0.0"
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-role-reviewer",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { type ReviewerMeta, reviewerMetaSchema, reviewerRole } from "./reviewer.js";
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"references": [{ "path": "../workflow" }]
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-role-tester",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { type TesterMeta, testerMetaSchema, testerRole } from "./tester.js";
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"references": [{ "path": "../workflow" }]
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { committerMetaSchema, committerRole } from "../src/committer.js";
|
||||
import { committerMetaSchema, committerRole } from "../src/roles/committer.js";
|
||||
|
||||
describe("committerRole", () => {
|
||||
test("committed sample validates against schema", () => {
|
||||
@@ -6,12 +6,9 @@ import {
|
||||
START,
|
||||
validateWorkflowDescriptor,
|
||||
} from "@uncaged/workflow";
|
||||
|
||||
import type { CommitterMeta } from "@uncaged/workflow-role-committer";
|
||||
import type { PlannerMeta } from "@uncaged/workflow-role-planner";
|
||||
|
||||
import { buildDevelopDescriptor } from "../src/descriptor.js";
|
||||
import { developModerator } from "../src/index.js";
|
||||
import type { CommitterMeta, PlannerMeta } from "../src/roles/index.js";
|
||||
import type { DevelopMeta } from "../src/roles.js";
|
||||
|
||||
const DEFAULT_PHASES: PlannerMeta["phases"] = [
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { reviewerMetaSchema, reviewerRole } from "../src/reviewer.js";
|
||||
import { reviewerMetaSchema, reviewerRole } from "../src/roles/reviewer.js";
|
||||
|
||||
describe("reviewerRole", () => {
|
||||
test("approved sample validates against schema", () => {
|
||||
@@ -10,10 +10,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow": "workspace:*",
|
||||
"@uncaged/workflow-role-coder": "workspace:*",
|
||||
"@uncaged/workflow-role-committer": "workspace:*",
|
||||
"@uncaged/workflow-role-planner": "workspace:*",
|
||||
"@uncaged/workflow-role-reviewer": "workspace:*",
|
||||
"@uncaged/workflow-role-tester": "workspace:*"
|
||||
"zod": "^4.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,34 +10,26 @@ import {
|
||||
import { developModerator } from "./moderator.js";
|
||||
import { DEVELOP_WORKFLOW_DESCRIPTION, type DevelopMeta, developRoles } from "./roles.js";
|
||||
|
||||
export { buildDevelopDescriptor } from "./descriptor.js";
|
||||
export { developModerator } from "./moderator.js";
|
||||
export {
|
||||
type CoderMeta,
|
||||
type CommitterMeta,
|
||||
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 ReviewerMeta,
|
||||
reviewerMetaSchema,
|
||||
reviewerRole,
|
||||
} from "@uncaged/workflow-role-reviewer";
|
||||
export {
|
||||
type TesterMeta,
|
||||
testerMetaSchema,
|
||||
testerRole,
|
||||
} from "@uncaged/workflow-role-tester";
|
||||
export { buildDevelopDescriptor } from "./descriptor.js";
|
||||
export { developModerator } from "./moderator.js";
|
||||
} from "./roles/index.js";
|
||||
export {
|
||||
DEVELOP_WORKFLOW_DESCRIPTION,
|
||||
type DevelopMeta,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
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 ReviewerMeta, reviewerRole } from "@uncaged/workflow-role-reviewer";
|
||||
import { type TesterMeta, testerRole } from "@uncaged/workflow-role-tester";
|
||||
import { type CoderMeta, coderRole } from "./roles/coder.js";
|
||||
import { type CommitterMeta, committerRole } from "./roles/committer.js";
|
||||
import { type PlannerMeta, plannerRole } from "./roles/planner.js";
|
||||
import { type ReviewerMeta, reviewerRole } from "./roles/reviewer.js";
|
||||
import { type TesterMeta, testerRole } from "./roles/tester.js";
|
||||
|
||||
export const DEVELOP_WORKFLOW_DESCRIPTION =
|
||||
"Plan phases, implement incrementally, review, verify with tests/build/lint, and commit (planner → coder [repeat per phase] → reviewer → tester → committer).";
|
||||
|
||||
+3
-11
@@ -11,21 +11,13 @@ export type CoderMeta = z.infer<typeof coderMetaSchema>;
|
||||
|
||||
const CODER_SYSTEM = `You are a **coder**. Read the thread for the plan and work on the NEXT incomplete phase only.
|
||||
|
||||
## Finding the current thread ID
|
||||
|
||||
The thread ID is a 26-character Crockford Base32 string (e.g. \`06F03H5V6JTMDST6P3TVH42RWM\`). It appears in the first message of this conversation. If you are unsure, run:
|
||||
|
||||
uncaged-workflow threads
|
||||
|
||||
and use the ID of the active thread.
|
||||
Run \`uncaged-workflow help --skill\` for full CLI reference (thread ID lookup, CAS commands, etc.).
|
||||
|
||||
## Reading phase details
|
||||
|
||||
Each planner phase is identified by a content-hash and a title. To read a phase's full details (name, description, acceptance criteria), run:
|
||||
Each planner phase has a content-hash and title. Read full details with \`uncaged-workflow cas get <THREAD_ID> <HASH>\`.
|
||||
|
||||
uncaged-workflow cas get <THREAD_ID> <HASH>
|
||||
|
||||
Replace \`<THREAD_ID>\` with the actual thread ID and \`<HASH>\` with the phase hash from the plan.
|
||||
The thread ID (26-char Crockford Base32) appears in the first message. If unsure, run \`uncaged-workflow thread list\`.
|
||||
|
||||
## Completing a phase
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
export { type CoderMeta, coderMetaSchema, coderRole } from "./coder.js";
|
||||
export { type CommitterMeta, committerMetaSchema, committerRole } from "./committer.js";
|
||||
export {
|
||||
type PlannerMeta,
|
||||
phaseSchema,
|
||||
plannerMetaSchema,
|
||||
plannerRole,
|
||||
} from "./planner.js";
|
||||
export { type ReviewerMeta, reviewerMetaSchema, reviewerRole } from "./reviewer.js";
|
||||
export { type TesterMeta, testerMetaSchema, testerRole } from "./tester.js";
|
||||
+11
-13
@@ -14,27 +14,25 @@ export type PlannerMeta = z.infer<typeof plannerMetaSchema>;
|
||||
|
||||
const PLANNER_SYSTEM = `You are a **planner** for a software task. Break the work into **sequential phases** the coder will execute one at a time.
|
||||
|
||||
## Finding the current thread ID
|
||||
|
||||
The thread ID is a 26-character Crockford Base32 string (e.g. \`06F03H5V6JTMDST6P3TVH42RWM\`). It appears in the first message of this conversation. If you are unsure, run:
|
||||
|
||||
uncaged-workflow threads
|
||||
|
||||
and use the ID of the active thread.
|
||||
Run \`uncaged-workflow help --skill\` for full CLI reference (thread ID lookup, CAS commands, etc.).
|
||||
|
||||
## Storing phase details — MANDATORY
|
||||
|
||||
For each phase you MUST store its full detail text in CAS using this exact CLI command:
|
||||
For each phase, store its full detail text in CAS via \`uncaged-workflow cas put <THREAD_ID> '<content>'\`. The command prints a content-hash — use that as the phase identifier.
|
||||
|
||||
uncaged-workflow cas put <THREAD_ID> '# <name>
|
||||
The thread ID (26-char Crockford Base32) appears in the first message. If unsure, run \`uncaged-workflow thread list\`.
|
||||
|
||||
Description: <description>
|
||||
**Do NOT store phase details in any other way** — the CLI is the only supported storage mechanism.
|
||||
|
||||
Acceptance: <acceptance>'
|
||||
## Phase granularity
|
||||
|
||||
Replace \`<THREAD_ID>\` with the actual thread ID you found above. The command prints a content-hash to stdout — use that hash as the phase identifier.
|
||||
Match the number of phases to task complexity:
|
||||
- Trivial (add a config option, fix a typo, rename): 1 phase
|
||||
- Small (a new feature touching 2-3 files): 1-2 phases
|
||||
- Medium (cross-module refactor): 2-3 phases
|
||||
- Large (new subsystem, architectural change): 3-5 phases
|
||||
|
||||
**Do NOT store phase details in any other way** (no temp files, no invented paths). The CLI command is the only supported storage mechanism.
|
||||
Fewer phases is always better. Each phase must justify its existence — if two phases would be tested together anyway, merge them.
|
||||
|
||||
## Output format
|
||||
|
||||
@@ -6,12 +6,5 @@
|
||||
"composite": true
|
||||
},
|
||||
"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-tester" }
|
||||
]
|
||||
"references": [{ "path": "../workflow" }]
|
||||
}
|
||||
|
||||
@@ -11,42 +11,69 @@ import {
|
||||
START,
|
||||
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 { buildSolveIssueDescriptor } from "../src/descriptor.js";
|
||||
import type { DeveloperMeta } from "../src/developer.js";
|
||||
import { createSolveIssueRun, solveIssueModerator } from "../src/index.js";
|
||||
import type { PreparerMeta, SubmitterMeta } from "../src/roles/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 +81,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 +136,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 +232,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 +244,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 +269,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 +278,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 +305,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 +327,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 +393,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");
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { submitterMetaSchema, submitterRole } from "../src/roles/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();
|
||||
});
|
||||
});
|
||||
@@ -10,10 +10,6 @@
|
||||
},
|
||||
"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:*"
|
||||
"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,39 +5,27 @@ 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 { buildSolveIssueDescriptor } from "./descriptor.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";
|
||||
type DeveloperMeta,
|
||||
developerMetaSchema,
|
||||
developerRole,
|
||||
} from "./developer.js";
|
||||
export { solveIssueModerator } from "./moderator.js";
|
||||
export {
|
||||
type PreparerMeta,
|
||||
preparerMetaSchema,
|
||||
preparerRole,
|
||||
} from "@uncaged/workflow-role-preparer";
|
||||
export {
|
||||
type ReviewerMeta,
|
||||
reviewerMetaSchema,
|
||||
reviewerRole,
|
||||
} from "@uncaged/workflow-role-reviewer";
|
||||
export { buildSolveIssueDescriptor } from "./descriptor.js";
|
||||
export { solveIssueModerator } from "./moderator.js";
|
||||
type SubmitterMeta,
|
||||
submitterMetaSchema,
|
||||
submitterRole,
|
||||
} from "./roles/index.js";
|
||||
export {
|
||||
SOLVE_ISSUE_WORKFLOW_DESCRIPTION,
|
||||
type SolveIssueMeta,
|
||||
@@ -51,10 +39,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,15 @@
|
||||
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 DeveloperMeta, developerRole } from "./developer.js";
|
||||
import { type PreparerMeta, preparerRole } from "./roles/preparer.js";
|
||||
import { type SubmitterMeta, submitterRole } from "./roles/submitter.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 (preparer → developer → submitter).";
|
||||
|
||||
export type SolveIssueMeta = {
|
||||
preparer: PreparerMeta;
|
||||
planner: PlannerMeta;
|
||||
coder: CoderMeta;
|
||||
reviewer: ReviewerMeta;
|
||||
committer: CommitterMeta;
|
||||
developer: DeveloperMeta;
|
||||
submitter: SubmitterMeta;
|
||||
};
|
||||
|
||||
export type SolveIssueRoles = {
|
||||
@@ -22,8 +18,6 @@ export type SolveIssueRoles = {
|
||||
|
||||
export const solveIssueRoles: SolveIssueRoles = {
|
||||
preparer: preparerRole,
|
||||
planner: plannerRole,
|
||||
coder: coderRole,
|
||||
reviewer: reviewerRole,
|
||||
committer: committerRole,
|
||||
developer: developerRole,
|
||||
submitter: submitterRole,
|
||||
};
|
||||
|
||||
+1
@@ -3,3 +3,4 @@ export {
|
||||
preparerMetaSchema,
|
||||
preparerRole,
|
||||
} from "./preparer.js";
|
||||
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",
|
||||
};
|
||||
@@ -6,11 +6,5 @@
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"references": [
|
||||
{ "path": "../workflow" },
|
||||
{ "path": "../workflow-role-coder" },
|
||||
{ "path": "../workflow-role-committer" },
|
||||
{ "path": "../workflow-role-planner" },
|
||||
{ "path": "../workflow-role-reviewer" }
|
||||
]
|
||||
"references": [{ "path": "../workflow" }]
|
||||
}
|
||||
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
/home/azureuser/repos/uncaged-workflow/packages/workflow
|
||||
@@ -19,11 +19,6 @@
|
||||
"references": [
|
||||
{ "path": "packages/workflow" },
|
||||
{ "path": "packages/workflow-agent-llm" },
|
||||
{ "path": "packages/workflow-role-committer" },
|
||||
{ "path": "packages/workflow-role-coder" },
|
||||
{ "path": "packages/workflow-role-planner" },
|
||||
{ "path": "packages/workflow-role-reviewer" },
|
||||
{ "path": "packages/workflow-role-tester" },
|
||||
{ "path": "packages/workflow-agent-cursor" },
|
||||
{ "path": "packages/workflow-agent-hermes" },
|
||||
{ "path": "packages/workflow-util-agent" },
|
||||
|
||||
Reference in New Issue
Block a user