Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ee0763015 | |||
| d1a0a135d4 |
@@ -0,0 +1,183 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
cmdJsonCasInit,
|
||||
cmdNodeGet,
|
||||
cmdNodeList,
|
||||
cmdNodeWalk,
|
||||
cmdWorkflowRegister,
|
||||
cmdWorkflowShow,
|
||||
formatNodeWalk,
|
||||
formatWorkflowShow,
|
||||
getJsonCasDir,
|
||||
} from "../src/commands/json-cas/index.js";
|
||||
|
||||
const SIMPLE_WORKFLOW = {
|
||||
name: "test-workflow",
|
||||
description: "A test workflow for CLI tests",
|
||||
roles: {
|
||||
analyst: {
|
||||
description: "Analyses the input",
|
||||
systemPrompt: "You are an analyst.",
|
||||
extractPrompt: "Extract the analysis.",
|
||||
schema: { type: "object", properties: { result: { type: "string" } } },
|
||||
},
|
||||
},
|
||||
moderator: [{ from: "analyst", to: "__end__", when: null }],
|
||||
};
|
||||
|
||||
describe("json-cas CLI commands", () => {
|
||||
let storageRoot: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
storageRoot = await mkdtemp(join(tmpdir(), "uncaged-json-cas-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(storageRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("getJsonCasDir returns path under storageRoot", () => {
|
||||
const dir = getJsonCasDir(storageRoot);
|
||||
expect(dir).toBe(join(storageRoot, "json-cas"));
|
||||
});
|
||||
|
||||
test("init bootstraps the store and returns a workflow type hash", async () => {
|
||||
const workflowTypeHash = await cmdJsonCasInit(storageRoot);
|
||||
expect(typeof workflowTypeHash).toBe("string");
|
||||
expect(workflowTypeHash.length).toBe(13);
|
||||
});
|
||||
|
||||
test("init is idempotent", async () => {
|
||||
const hash1 = await cmdJsonCasInit(storageRoot);
|
||||
const hash2 = await cmdJsonCasInit(storageRoot);
|
||||
expect(hash1).toBe(hash2);
|
||||
});
|
||||
|
||||
test("workflow register returns a hash", async () => {
|
||||
const filePath = join(storageRoot, "wf.json");
|
||||
await writeFile(filePath, JSON.stringify(SIMPLE_WORKFLOW), "utf-8");
|
||||
|
||||
const result = await cmdWorkflowRegister(storageRoot, filePath);
|
||||
expect(typeof result.hash).toBe("string");
|
||||
expect(result.hash.length).toBe(13);
|
||||
});
|
||||
|
||||
test("workflow register is idempotent", async () => {
|
||||
const filePath = join(storageRoot, "wf.json");
|
||||
await writeFile(filePath, JSON.stringify(SIMPLE_WORKFLOW), "utf-8");
|
||||
|
||||
const r1 = await cmdWorkflowRegister(storageRoot, filePath);
|
||||
const r2 = await cmdWorkflowRegister(storageRoot, filePath);
|
||||
expect(r1.hash).toBe(r2.hash);
|
||||
});
|
||||
|
||||
test("workflow show loads a registered workflow", async () => {
|
||||
const filePath = join(storageRoot, "wf.json");
|
||||
await writeFile(filePath, JSON.stringify(SIMPLE_WORKFLOW), "utf-8");
|
||||
|
||||
const { hash } = await cmdWorkflowRegister(storageRoot, filePath);
|
||||
const wf = await cmdWorkflowShow(storageRoot, hash);
|
||||
|
||||
expect(wf).not.toBeNull();
|
||||
if (wf === null) return;
|
||||
|
||||
expect(wf.name).toBe("test-workflow");
|
||||
expect(wf.description).toBe("A test workflow for CLI tests");
|
||||
expect(Object.keys(wf.roles)).toContain("analyst");
|
||||
expect(wf.roles.analyst.systemPrompt).toBe("You are an analyst.");
|
||||
expect(wf.moderator).toHaveLength(1);
|
||||
expect(wf.moderator[0].from).toBe("analyst");
|
||||
});
|
||||
|
||||
test("workflow show returns null for unknown hash", async () => {
|
||||
await cmdJsonCasInit(storageRoot);
|
||||
const result = await cmdWorkflowShow(storageRoot, "AAAAAAAAAAAAA");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("formatWorkflowShow produces expected output", async () => {
|
||||
const filePath = join(storageRoot, "wf.json");
|
||||
await writeFile(filePath, JSON.stringify(SIMPLE_WORKFLOW), "utf-8");
|
||||
|
||||
const { hash } = await cmdWorkflowRegister(storageRoot, filePath);
|
||||
const wf = await cmdWorkflowShow(storageRoot, hash);
|
||||
if (wf === null) throw new Error("workflow not found");
|
||||
|
||||
const output = formatWorkflowShow(hash, wf);
|
||||
expect(output).toContain("test-workflow");
|
||||
expect(output).toContain(hash);
|
||||
expect(output).toContain("analyst");
|
||||
expect(output).toContain("moderator:");
|
||||
});
|
||||
|
||||
test("node list returns hashes after registration", async () => {
|
||||
const filePath = join(storageRoot, "wf.json");
|
||||
await writeFile(filePath, JSON.stringify(SIMPLE_WORKFLOW), "utf-8");
|
||||
|
||||
await cmdWorkflowRegister(storageRoot, filePath);
|
||||
const hashes = await cmdNodeList(storageRoot);
|
||||
|
||||
expect(hashes.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("node list returns empty array for empty store", async () => {
|
||||
const hashes = await cmdNodeList(storageRoot);
|
||||
expect(hashes).toEqual([]);
|
||||
});
|
||||
|
||||
test("node get returns JSON for a known hash", async () => {
|
||||
const filePath = join(storageRoot, "wf.json");
|
||||
await writeFile(filePath, JSON.stringify(SIMPLE_WORKFLOW), "utf-8");
|
||||
|
||||
const { hash } = await cmdWorkflowRegister(storageRoot, filePath);
|
||||
const json = await cmdNodeGet(storageRoot, hash);
|
||||
|
||||
expect(json).not.toBeNull();
|
||||
if (json === null) return;
|
||||
|
||||
const parsed = JSON.parse(json) as unknown;
|
||||
expect(parsed).toMatchObject({ payload: expect.anything() });
|
||||
});
|
||||
|
||||
test("node get returns null for unknown hash", async () => {
|
||||
const result = await cmdNodeGet(storageRoot, "AAAAAAAAAAAAA");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("node walk traverses the workflow DAG", async () => {
|
||||
const filePath = join(storageRoot, "wf.json");
|
||||
await writeFile(filePath, JSON.stringify(SIMPLE_WORKFLOW), "utf-8");
|
||||
|
||||
const { hash } = await cmdWorkflowRegister(storageRoot, filePath);
|
||||
const entries = await cmdNodeWalk(storageRoot, hash);
|
||||
|
||||
expect(entries).not.toBeNull();
|
||||
if (entries === null) return;
|
||||
|
||||
// should include at least the workflow node, role node, and role-schema node
|
||||
expect(entries.length).toBeGreaterThanOrEqual(3);
|
||||
const hashes = entries.map((e) => e.hash);
|
||||
expect(hashes).toContain(hash);
|
||||
});
|
||||
|
||||
test("node walk returns null for unknown root", async () => {
|
||||
const result = await cmdNodeWalk(storageRoot, "AAAAAAAAAAAAA");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("formatNodeWalk produces output with node hashes", async () => {
|
||||
const filePath = join(storageRoot, "wf.json");
|
||||
await writeFile(filePath, JSON.stringify(SIMPLE_WORKFLOW), "utf-8");
|
||||
|
||||
const { hash } = await cmdWorkflowRegister(storageRoot, filePath);
|
||||
const entries = await cmdNodeWalk(storageRoot, hash);
|
||||
if (entries === null) throw new Error("walk failed");
|
||||
|
||||
const output = formatNodeWalk(hash, entries);
|
||||
expect(output).toContain(`walk from: ${hash}`);
|
||||
expect(output).toContain(hash);
|
||||
});
|
||||
});
|
||||
@@ -11,13 +11,17 @@
|
||||
"uncaged-workflow": "src/cli.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow-gateway": "workspace:^",
|
||||
"@uncaged/workflow-protocol": "workspace:^",
|
||||
"@uncaged/workflow-util": "workspace:^",
|
||||
"@uncaged/json-cas": "^0.1.1",
|
||||
"@uncaged/json-cas-fs": "^0.1.1",
|
||||
"@uncaged/json-cas-workflow": "^0.1.1",
|
||||
"@uncaged/workflow-cas": "workspace:^",
|
||||
"@uncaged/workflow-execute": "workspace:^",
|
||||
"@uncaged/workflow-gateway": "workspace:^",
|
||||
"@uncaged/workflow-json-def": "workspace:*",
|
||||
"@uncaged/workflow-protocol": "workspace:^",
|
||||
"@uncaged/workflow-register": "workspace:^",
|
||||
"@uncaged/workflow-runtime": "workspace:^",
|
||||
"@uncaged/workflow-util": "workspace:^",
|
||||
"hono": "^4.12.18",
|
||||
"yaml": "^2.8.4"
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@ import { formatCliUsage as formatCliUsageWithGroups } from "./cli-usage.js";
|
||||
import { createCasDispatcher } from "./commands/cas/index.js";
|
||||
import { dispatchConnect } from "./commands/connect/index.js";
|
||||
import { createInitDispatcher } from "./commands/init/index.js";
|
||||
import { createJsonCasDispatcher } from "./commands/json-cas/index.js";
|
||||
import { dispatchSetup } from "./commands/setup/index.js";
|
||||
import { createThreadDispatcher, dispatchLive, dispatchRun } from "./commands/thread/index.js";
|
||||
import { createWorkflowDispatcher } from "./commands/workflow/index.js";
|
||||
@@ -43,6 +44,7 @@ const dispatchWorkflow = createWorkflowDispatcher({ dispatchGroup });
|
||||
const dispatchThread = createThreadDispatcher({ dispatchGroup });
|
||||
const dispatchCas = createCasDispatcher({ dispatchGroup });
|
||||
const dispatchInit = createInitDispatcher({ dispatchGroup });
|
||||
const dispatchJsonCas = createJsonCasDispatcher({ dispatchGroup });
|
||||
|
||||
async function showSkillDocOrIndex(topic: string | undefined): Promise<number> {
|
||||
if (topic === undefined) {
|
||||
@@ -72,6 +74,7 @@ const COMMAND_TABLE: Record<string, DispatchFn> = {
|
||||
run: dispatchRun,
|
||||
live: dispatchLive,
|
||||
connect: dispatchConnect,
|
||||
"json-cas": dispatchJsonCas,
|
||||
};
|
||||
|
||||
export async function runCli(storageRoot: string, argv: string[]): Promise<number> {
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { CommandGroup } from "./cli-command-types.js";
|
||||
import { setCommandGroupsForUsage } from "./cli-usage-context.js";
|
||||
import { CAS_SUBCOMMAND_TABLE } from "./commands/cas/index.js";
|
||||
import { INIT_SUBCOMMAND_TABLE } from "./commands/init/index.js";
|
||||
import { JSON_CAS_SUBCOMMAND_TABLE } from "./commands/json-cas/index.js";
|
||||
import { THREAD_SUBCOMMAND_TABLE } from "./commands/thread/index.js";
|
||||
import { WORKFLOW_SUBCOMMAND_TABLE } from "./commands/workflow/index.js";
|
||||
|
||||
@@ -52,6 +53,14 @@ export function getCommandRegistry(): ReadonlyArray<CommandGroup> {
|
||||
name: "setup",
|
||||
commands: [...SETUP_USAGE_COMMANDS],
|
||||
},
|
||||
{
|
||||
name: "json-cas",
|
||||
commands: Object.entries(JSON_CAS_SUBCOMMAND_TABLE).map(([name, e]) => ({
|
||||
name,
|
||||
args: e.args,
|
||||
description: e.description,
|
||||
})),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ const USAGE_SECTION_BY_GROUP: Record<string, string> = {
|
||||
cas: "Content-addressable storage:",
|
||||
init: "Development:",
|
||||
setup: "Configuration:",
|
||||
"json-cas": "JSON-CAS engine:",
|
||||
};
|
||||
|
||||
export function formatUsageCommandLines(
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import type { CommandEntry } from "../../cli-command-types.js";
|
||||
import { printCliError, printCliLine } from "../../cli-output.js";
|
||||
import { cmdJsonCasInit } from "./init.js";
|
||||
import { cmdNodeGet } from "./node-get.js";
|
||||
import { cmdNodeList } from "./node-list.js";
|
||||
import { cmdNodeWalk, formatNodeWalk } from "./node-walk.js";
|
||||
import { getJsonCasDir } from "./store.js";
|
||||
import { cmdThreadShow, formatThreadShow } from "./thread-show.js";
|
||||
import type { JsonCasDispatchDeps } from "./types.js";
|
||||
import { cmdWorkflowRegister } from "./workflow-register.js";
|
||||
import { cmdWorkflowShow, formatWorkflowShow } from "./workflow-show.js";
|
||||
|
||||
// ── node subcommands ─────────────────────────────────────────────────────────
|
||||
|
||||
export async function dispatchNodeGet(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const hash = argv[0];
|
||||
if (hash === undefined || argv.length > 1) {
|
||||
printCliError("error: json-cas node get requires <hash>");
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdNodeGet(storageRoot, hash);
|
||||
if (result === null) {
|
||||
printCliError(`error: node not found: ${hash}`);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(result);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function dispatchNodeList(storageRoot: string, argv: string[]): Promise<number> {
|
||||
if (argv.length > 0) {
|
||||
printCliError("error: json-cas node list takes no arguments");
|
||||
return 1;
|
||||
}
|
||||
const hashes = await cmdNodeList(storageRoot);
|
||||
if (hashes.length === 0) {
|
||||
printCliLine("(no nodes)");
|
||||
return 0;
|
||||
}
|
||||
for (const hash of hashes) {
|
||||
printCliLine(hash);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function dispatchNodeWalk(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const hash = argv[0];
|
||||
if (hash === undefined || argv.length > 1) {
|
||||
printCliError("error: json-cas node walk requires <hash>");
|
||||
return 1;
|
||||
}
|
||||
const entries = await cmdNodeWalk(storageRoot, hash);
|
||||
if (entries === null) {
|
||||
printCliError(`error: node not found: ${hash}`);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(formatNodeWalk(hash, entries));
|
||||
return 0;
|
||||
}
|
||||
|
||||
export const JSON_CAS_NODE_TABLE: Record<string, CommandEntry> = {
|
||||
get: { handler: dispatchNodeGet, args: "<hash>", description: "Get a CAS node as JSON" },
|
||||
list: { handler: dispatchNodeList, args: "", description: "List all hashes in the store" },
|
||||
walk: {
|
||||
handler: dispatchNodeWalk,
|
||||
args: "<hash>",
|
||||
description: "Walk the DAG from a node, show referenced nodes",
|
||||
},
|
||||
};
|
||||
|
||||
// ── workflow subcommands ─────────────────────────────────────────────────────
|
||||
|
||||
export async function dispatchWorkflowRegister(
|
||||
storageRoot: string,
|
||||
argv: string[],
|
||||
): Promise<number> {
|
||||
const file = argv[0];
|
||||
if (file === undefined || argv.length > 1) {
|
||||
printCliError("error: json-cas workflow register requires <file.json>");
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdWorkflowRegister(storageRoot, file);
|
||||
printCliLine(`registered workflow: ${result.hash}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function dispatchWorkflowShow(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const hash = argv[0];
|
||||
if (hash === undefined || argv.length > 1) {
|
||||
printCliError("error: json-cas workflow show requires <hash>");
|
||||
return 1;
|
||||
}
|
||||
const wf = await cmdWorkflowShow(storageRoot, hash);
|
||||
if (wf === null) {
|
||||
printCliError(`error: workflow not found: ${hash}`);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(formatWorkflowShow(hash, wf));
|
||||
return 0;
|
||||
}
|
||||
|
||||
export const JSON_CAS_WORKFLOW_TABLE: Record<string, CommandEntry> = {
|
||||
register: {
|
||||
handler: dispatchWorkflowRegister,
|
||||
args: "<file.json>",
|
||||
description: "Register a workflow definition from a JSON file",
|
||||
},
|
||||
show: {
|
||||
handler: dispatchWorkflowShow,
|
||||
args: "<hash>",
|
||||
description: "Show a workflow by its CAS hash",
|
||||
},
|
||||
};
|
||||
|
||||
// ── thread subcommands ───────────────────────────────────────────────────────
|
||||
|
||||
export async function dispatchThreadShow(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const hash = argv[0];
|
||||
if (hash === undefined || argv.length > 1) {
|
||||
printCliError("error: json-cas thread show requires <start-hash>");
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdThreadShow(storageRoot, hash);
|
||||
if (result === null) {
|
||||
printCliError(`error: thread start node not found: ${hash}`);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(formatThreadShow(result));
|
||||
return 0;
|
||||
}
|
||||
|
||||
export const JSON_CAS_THREAD_TABLE: Record<string, CommandEntry> = {
|
||||
show: {
|
||||
handler: dispatchThreadShow,
|
||||
args: "<start-hash>",
|
||||
description: "Walk and display thread steps from a thread-start hash",
|
||||
},
|
||||
};
|
||||
|
||||
// ── top-level json-cas subcommands ───────────────────────────────────────────
|
||||
|
||||
export async function dispatchJsonCasInit(storageRoot: string, argv: string[]): Promise<number> {
|
||||
if (argv.length > 0) {
|
||||
printCliError("error: json-cas init takes no arguments");
|
||||
return 1;
|
||||
}
|
||||
const workflowTypeHash = await cmdJsonCasInit(storageRoot);
|
||||
const dir = getJsonCasDir(storageRoot);
|
||||
printCliLine(`initialized json-cas store at ${dir}`);
|
||||
printCliLine(`workflow type hash: ${workflowTypeHash}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
function printSubgroupHelp(
|
||||
groupPath: string,
|
||||
table: Record<string, CommandEntry>,
|
||||
sub: string | undefined,
|
||||
): number {
|
||||
if (sub === undefined || sub === "--help" || sub === "-h") {
|
||||
const lines = [`json-cas ${groupPath} subcommands:\n`];
|
||||
for (const [name, e] of Object.entries(table)) {
|
||||
const args = e.args ? ` ${e.args}` : "";
|
||||
lines.push(` uncaged-workflow json-cas ${groupPath} ${name}${args}`);
|
||||
lines.push(` ${e.description}\n`);
|
||||
}
|
||||
printCliLine(lines.join("\n"));
|
||||
return sub === undefined ? 1 : 0;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
async function dispatchSubgroup(
|
||||
groupPath: string,
|
||||
table: Record<string, CommandEntry>,
|
||||
storageRoot: string,
|
||||
argv: string[],
|
||||
): Promise<number> {
|
||||
const sub = argv[0];
|
||||
const helpResult = printSubgroupHelp(groupPath, table, sub);
|
||||
if (helpResult >= 0) return helpResult;
|
||||
|
||||
const entry = table[sub as string];
|
||||
if (entry === undefined) {
|
||||
printCliError(`error: unknown json-cas ${groupPath} subcommand: ${sub}`);
|
||||
return 1;
|
||||
}
|
||||
return entry.handler(storageRoot, argv.slice(1));
|
||||
}
|
||||
|
||||
export const JSON_CAS_SUBCOMMAND_TABLE: Record<string, CommandEntry> = {
|
||||
init: {
|
||||
handler: dispatchJsonCasInit,
|
||||
args: "",
|
||||
description: "Initialize json-cas store and register workflow schemas",
|
||||
},
|
||||
workflow: {
|
||||
handler: (storageRoot, argv) =>
|
||||
dispatchSubgroup("workflow", JSON_CAS_WORKFLOW_TABLE, storageRoot, argv),
|
||||
args: "<register|show>",
|
||||
description: "Manage json-cas workflow definitions",
|
||||
},
|
||||
thread: {
|
||||
handler: (storageRoot, argv) =>
|
||||
dispatchSubgroup("thread", JSON_CAS_THREAD_TABLE, storageRoot, argv),
|
||||
args: "<show>",
|
||||
description: "Inspect json-cas thread execution records",
|
||||
},
|
||||
node: {
|
||||
handler: (storageRoot, argv) =>
|
||||
dispatchSubgroup("node", JSON_CAS_NODE_TABLE, storageRoot, argv),
|
||||
args: "<get|list|walk>",
|
||||
description: "Low-level access to json-cas store nodes",
|
||||
},
|
||||
};
|
||||
|
||||
export function createJsonCasDispatcher(deps: JsonCasDispatchDeps) {
|
||||
const { dispatchGroup } = deps;
|
||||
return async function dispatchJsonCas(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const result = dispatchGroup("json-cas", JSON_CAS_SUBCOMMAND_TABLE, storageRoot, argv);
|
||||
if (result !== null) {
|
||||
return result;
|
||||
}
|
||||
const sub = argv[0];
|
||||
printCliError(`error: unknown json-cas subcommand: ${sub}`);
|
||||
return 1;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export {
|
||||
createJsonCasDispatcher,
|
||||
dispatchJsonCasInit,
|
||||
dispatchNodeGet,
|
||||
dispatchNodeList,
|
||||
dispatchNodeWalk,
|
||||
dispatchThreadShow,
|
||||
dispatchWorkflowRegister,
|
||||
dispatchWorkflowShow,
|
||||
JSON_CAS_NODE_TABLE,
|
||||
JSON_CAS_SUBCOMMAND_TABLE,
|
||||
JSON_CAS_THREAD_TABLE,
|
||||
JSON_CAS_WORKFLOW_TABLE,
|
||||
} from "./dispatch.js";
|
||||
export { cmdJsonCasInit } from "./init.js";
|
||||
export { cmdNodeGet } from "./node-get.js";
|
||||
export { cmdNodeList } from "./node-list.js";
|
||||
export { cmdNodeWalk, formatNodeWalk } from "./node-walk.js";
|
||||
export { getJsonCasDir, openStore } from "./store.js";
|
||||
export type { ThreadShowResult, ThreadStep } from "./thread-show.js";
|
||||
export { cmdThreadShow, formatThreadShow } from "./thread-show.js";
|
||||
export type { JsonCasDispatchDeps } from "./types.js";
|
||||
export type { WorkflowRegisterResult } from "./workflow-register.js";
|
||||
export { cmdWorkflowRegister } from "./workflow-register.js";
|
||||
export { cmdWorkflowShow, formatWorkflowShow } from "./workflow-show.js";
|
||||
@@ -0,0 +1,6 @@
|
||||
import { openStore } from "./store.js";
|
||||
|
||||
export async function cmdJsonCasInit(storageRoot: string): Promise<string> {
|
||||
const { typeHashes } = await openStore(storageRoot);
|
||||
return typeHashes.workflow;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createFsStore } from "@uncaged/json-cas-fs";
|
||||
import { getJsonCasDir } from "./store.js";
|
||||
|
||||
export async function cmdNodeGet(storageRoot: string, hash: string): Promise<string | null> {
|
||||
const store = createFsStore(getJsonCasDir(storageRoot));
|
||||
const node = store.get(hash);
|
||||
if (node === null) return null;
|
||||
return JSON.stringify(node, null, 2);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createFsStore } from "@uncaged/json-cas-fs";
|
||||
import { getJsonCasDir } from "./store.js";
|
||||
|
||||
export async function cmdNodeList(storageRoot: string): Promise<string[]> {
|
||||
const store = createFsStore(getJsonCasDir(storageRoot));
|
||||
return store.list();
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { CasNode, Hash } from "@uncaged/json-cas";
|
||||
import { walk } from "@uncaged/json-cas";
|
||||
import { createFsStore } from "@uncaged/json-cas-fs";
|
||||
import { getJsonCasDir } from "./store.js";
|
||||
|
||||
export type WalkEntry = {
|
||||
hash: Hash;
|
||||
type: Hash;
|
||||
timestamp: number;
|
||||
payloadPreview: string;
|
||||
};
|
||||
|
||||
export async function cmdNodeWalk(
|
||||
storageRoot: string,
|
||||
rootHash: string,
|
||||
): Promise<WalkEntry[] | null> {
|
||||
const store = createFsStore(getJsonCasDir(storageRoot));
|
||||
|
||||
if (!store.has(rootHash)) return null;
|
||||
|
||||
const entries: WalkEntry[] = [];
|
||||
|
||||
walk(store, rootHash, (hash: Hash, node: CasNode) => {
|
||||
const preview = JSON.stringify(node.payload).slice(0, 100);
|
||||
entries.push({
|
||||
hash,
|
||||
type: node.type,
|
||||
timestamp: node.timestamp,
|
||||
payloadPreview: preview,
|
||||
});
|
||||
});
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function formatNodeWalk(rootHash: string, entries: WalkEntry[]): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`walk from: ${rootHash}`);
|
||||
lines.push(`nodes: ${entries.length}`);
|
||||
|
||||
for (const entry of entries) {
|
||||
lines.push("");
|
||||
lines.push(` hash: ${entry.hash}`);
|
||||
lines.push(` type: ${entry.type}`);
|
||||
lines.push(` time: ${new Date(entry.timestamp).toISOString()}`);
|
||||
lines.push(` data: ${entry.payloadPreview}`);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { join } from "node:path";
|
||||
import type { Store } from "@uncaged/json-cas";
|
||||
import { bootstrap } from "@uncaged/json-cas";
|
||||
import { createFsStore } from "@uncaged/json-cas-fs";
|
||||
import type { WorkflowSchemaHashes } from "@uncaged/json-cas-workflow";
|
||||
import { registerWorkflowSchemas } from "@uncaged/json-cas-workflow";
|
||||
|
||||
export function getJsonCasDir(storageRoot: string): string {
|
||||
return join(storageRoot, "json-cas");
|
||||
}
|
||||
|
||||
export type OpenStoreResult = {
|
||||
store: Store;
|
||||
typeHashes: WorkflowSchemaHashes;
|
||||
};
|
||||
|
||||
export async function openStore(storageRoot: string): Promise<OpenStoreResult> {
|
||||
const dir = getJsonCasDir(storageRoot);
|
||||
const store = createFsStore(dir);
|
||||
await bootstrap(store);
|
||||
const typeHashes = await registerWorkflowSchemas(store);
|
||||
return { store, typeHashes };
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { Hash, Store } from "@uncaged/json-cas";
|
||||
import type {
|
||||
ContentPayload,
|
||||
ThreadStepPayload,
|
||||
WorkflowSchemaHashes,
|
||||
} from "@uncaged/json-cas-workflow";
|
||||
import { openStore } from "./store.js";
|
||||
|
||||
type StepEntry = {
|
||||
hash: Hash;
|
||||
payload: ThreadStepPayload;
|
||||
};
|
||||
|
||||
function collectStepsInOrder(
|
||||
store: Store,
|
||||
typeHashes: WorkflowSchemaHashes,
|
||||
startHash: Hash,
|
||||
): StepEntry[] {
|
||||
const stepMap = new Map<Hash, StepEntry>();
|
||||
|
||||
for (const hash of store.list()) {
|
||||
const node = store.get(hash);
|
||||
if (node === null || node.type !== typeHashes.threadStep) continue;
|
||||
const p = node.payload as ThreadStepPayload;
|
||||
if (p.start === startHash) {
|
||||
stepMap.set(hash, { hash, payload: p });
|
||||
}
|
||||
}
|
||||
|
||||
if (stepMap.size === 0) return [];
|
||||
|
||||
// Find the terminal step: the one whose hash is not referenced as `previous` by any other step
|
||||
const previousSet = new Set<Hash>();
|
||||
for (const entry of stepMap.values()) {
|
||||
if (entry.payload.previous !== null) {
|
||||
previousSet.add(entry.payload.previous);
|
||||
}
|
||||
}
|
||||
|
||||
const terminalEntries = [...stepMap.values()].filter((e) => !previousSet.has(e.hash));
|
||||
if (terminalEntries.length === 0) return [...stepMap.values()];
|
||||
|
||||
// Walk backward from terminal to build ordered list
|
||||
const ordered: StepEntry[] = [];
|
||||
let current: StepEntry | undefined = terminalEntries[0];
|
||||
while (current !== undefined) {
|
||||
ordered.unshift(current);
|
||||
const prevHash = current.payload.previous;
|
||||
if (prevHash === null) break;
|
||||
current = stepMap.get(prevHash);
|
||||
}
|
||||
|
||||
return ordered;
|
||||
}
|
||||
|
||||
function getContentText(store: Store, contentHash: Hash): string {
|
||||
const node = store.get(contentHash);
|
||||
if (node === null) return "(not found)";
|
||||
const payload = node.payload as ContentPayload;
|
||||
return typeof payload.text === "string" ? payload.text : "(no text)";
|
||||
}
|
||||
|
||||
export type ThreadStep = {
|
||||
hash: Hash;
|
||||
role: string;
|
||||
meta: Record<string, unknown>;
|
||||
contentPreview: string;
|
||||
};
|
||||
|
||||
export type ThreadShowResult = {
|
||||
startHash: Hash;
|
||||
steps: ThreadStep[];
|
||||
};
|
||||
|
||||
export async function cmdThreadShow(
|
||||
storageRoot: string,
|
||||
startHash: string,
|
||||
): Promise<ThreadShowResult | null> {
|
||||
const { store, typeHashes } = await openStore(storageRoot);
|
||||
|
||||
const startNode = store.get(startHash);
|
||||
if (startNode === null) return null;
|
||||
|
||||
const entries = collectStepsInOrder(store, typeHashes, startHash);
|
||||
|
||||
const steps: ThreadStep[] = entries.map((entry) => {
|
||||
const rawText = getContentText(store, entry.payload.content);
|
||||
const preview = rawText.slice(0, 120).replace(/\n/g, " ");
|
||||
return {
|
||||
hash: entry.hash,
|
||||
role: entry.payload.role,
|
||||
meta: entry.payload.meta,
|
||||
contentPreview: preview,
|
||||
};
|
||||
});
|
||||
|
||||
return { startHash, steps };
|
||||
}
|
||||
|
||||
export function formatThreadShow(result: ThreadShowResult): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`thread: ${result.startHash}`);
|
||||
lines.push(`steps: ${result.steps.length}`);
|
||||
|
||||
for (let i = 0; i < result.steps.length; i++) {
|
||||
const step = result.steps[i];
|
||||
lines.push("");
|
||||
lines.push(` [${i + 1}] ${step.role} (${step.hash})`);
|
||||
if (Object.keys(step.meta).length > 0) {
|
||||
lines.push(` meta: ${JSON.stringify(step.meta)}`);
|
||||
}
|
||||
lines.push(` > ${step.contentPreview}`);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { DispatchGroupFn } from "../../cli-command-types.js";
|
||||
|
||||
export type JsonCasDispatchDeps = {
|
||||
dispatchGroup: DispatchGroupFn;
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import type { WorkflowInput } from "@uncaged/workflow-json-def";
|
||||
import { registerWorkflow } from "@uncaged/workflow-json-def";
|
||||
import { openStore } from "./store.js";
|
||||
|
||||
export type WorkflowRegisterResult = {
|
||||
hash: string;
|
||||
};
|
||||
|
||||
export async function cmdWorkflowRegister(
|
||||
storageRoot: string,
|
||||
filePath: string,
|
||||
): Promise<WorkflowRegisterResult> {
|
||||
const raw = readFileSync(filePath, "utf-8");
|
||||
const workflowDef = JSON.parse(raw) as WorkflowInput;
|
||||
|
||||
const { store, typeHashes } = await openStore(storageRoot);
|
||||
const hash = await registerWorkflow(store, typeHashes, workflowDef);
|
||||
|
||||
return { hash };
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { HydratedWorkflow } from "@uncaged/workflow-json-def";
|
||||
import { loadWorkflow } from "@uncaged/workflow-json-def";
|
||||
import { openStore } from "./store.js";
|
||||
|
||||
export async function cmdWorkflowShow(
|
||||
storageRoot: string,
|
||||
hash: string,
|
||||
): Promise<HydratedWorkflow | null> {
|
||||
const { store, typeHashes } = await openStore(storageRoot);
|
||||
return loadWorkflow(store, typeHashes, hash);
|
||||
}
|
||||
|
||||
export function formatWorkflowShow(hash: string, wf: HydratedWorkflow): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`workflow: ${wf.name}`);
|
||||
lines.push(`hash: ${hash}`);
|
||||
lines.push(`desc: ${wf.description}`);
|
||||
lines.push("");
|
||||
|
||||
lines.push("roles:");
|
||||
for (const [name, role] of Object.entries(wf.roles)) {
|
||||
lines.push(` ${name}:`);
|
||||
lines.push(` description: ${role.description}`);
|
||||
lines.push(` systemPrompt: ${role.systemPrompt.slice(0, 80).replace(/\n/g, " ")}...`);
|
||||
lines.push(` extractPrompt: ${role.extractPrompt.slice(0, 80).replace(/\n/g, " ")}...`);
|
||||
}
|
||||
|
||||
if (wf.moderator.length > 0) {
|
||||
lines.push("");
|
||||
lines.push("moderator:");
|
||||
for (const rule of wf.moderator) {
|
||||
const when = rule.when === null ? "(always)" : `when: ${rule.when}`;
|
||||
lines.push(` ${rule.from} → ${rule.to} [${when}]`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
# @uncaged/workflow-template-develop
|
||||
|
||||
## 0.5.0-alpha.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @uncaged/workflow-register@0.5.0-alpha.4
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.4
|
||||
|
||||
## 0.5.0-alpha.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @uncaged/workflow-register@0.5.0-alpha.3
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.3
|
||||
|
||||
## 0.5.0-alpha.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @uncaged/workflow-register@0.5.0-alpha.2
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.2
|
||||
|
||||
## 0.5.0-alpha.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @uncaged/workflow-register@0.5.0-alpha.1
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.1
|
||||
|
||||
## 0.5.0-alpha.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @uncaged/workflow-register@0.5.0-alpha.0
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.0
|
||||
|
||||
## 0.4.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @uncaged/workflow-register@0.4.5
|
||||
- @uncaged/workflow-runtime@0.4.5
|
||||
|
||||
## 0.4.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @uncaged/workflow-register@0.4.4
|
||||
- @uncaged/workflow-runtime@0.4.4
|
||||
|
||||
## 0.4.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Include src/ in published packages so bun runtime can resolve the 'bun' exports condition.
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-register@0.4.3
|
||||
- @uncaged/workflow-runtime@0.4.3
|
||||
|
||||
## 0.4.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix workspace dependency resolution: use workspace:^ so published packages resolve to compatible versions instead of exact (non-existent) versions.
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-register@0.4.2
|
||||
- @uncaged/workflow-runtime@0.4.2
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Fix package exports for published packages and adopt changesets for version management.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-register@0.4.0
|
||||
- @uncaged/workflow-runtime@0.4.0
|
||||
@@ -1,52 +0,0 @@
|
||||
# @uncaged/workflow-template-develop
|
||||
|
||||
Reference **develop** workflow template: plan phases, implement in a loop, review, test, then commit.
|
||||
|
||||
Export a pure `WorkflowDefinition` (`developWorkflowDefinition`) and role/moderator pieces. Workflow instantiation (`createWorkflow(definition, binding)`) happens in the workflow instance layer, not in this template package.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @uncaged/workflow-template-develop @uncaged/workflow zod
|
||||
```
|
||||
|
||||
In this monorepo: `workspace:*` for `@uncaged/workflow-template-develop` and `@uncaged/workflow`.
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
import { createWorkflow } from "@uncaged/workflow";
|
||||
import { developWorkflowDefinition } from "@uncaged/workflow-template-develop";
|
||||
|
||||
const run = createWorkflow(developWorkflowDefinition, binding);
|
||||
```
|
||||
|
||||
## Roles
|
||||
|
||||
| Role | Purpose |
|
||||
|------|---------|
|
||||
| **planner** | Break work into ordered phases (hashes) |
|
||||
| **coder** | Implement current phase; repeats until phases complete or limits hit |
|
||||
| **reviewer** | Code review gate (`approved` vs send back to coder) |
|
||||
| **tester** | Verify via tests/build/lint (`passed` vs send back to coder) |
|
||||
| **committer** | Final commit step |
|
||||
|
||||
Also exported: role factories/meta schemas (`plannerRole`, `coderRole`, …), `DevelopMeta`, `developRoles`.
|
||||
|
||||
## Moderator flow
|
||||
|
||||
1. **Start** → `planner`
|
||||
2. After **planner** → `coder`
|
||||
3. After **coder** → if all planned phases are done (or last phase completed) → `reviewer`; else `coder` again, until `maxRounds` then `END`
|
||||
4. After **reviewer** → if approved → `tester`; else `coder` (or `END` if out of rounds)
|
||||
5. After **tester** → if passed → `committer`; else `coder` (or `END` if out of rounds)
|
||||
6. After **committer** → `END`
|
||||
|
||||
## API overview
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `developWorkflowDefinition` | `description`, `roles`, `developModerator` |
|
||||
| `developModerator` | `Moderator<DevelopMeta>` |
|
||||
| `buildDevelopDescriptor` | `buildDescriptor({ … })` for bundle metadata |
|
||||
| `DEVELOP_WORKFLOW_DESCRIPTION` | Human-readable one-liner |
|
||||
@@ -1,19 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { committerMetaSchema, committerRole } from "../src/roles/committer.js";
|
||||
|
||||
describe("committerRole", () => {
|
||||
test("committed sample validates against schema", () => {
|
||||
const parsed = committerMetaSchema.safeParse({
|
||||
status: "committed" as const,
|
||||
branch: "feat/example",
|
||||
commitSha: "abc1234",
|
||||
});
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
test("exposes generic committer system prompt", () => {
|
||||
expect(committerRole.systemPrompt).toContain("git committer");
|
||||
expect(committerRole.systemPrompt).not.toContain("project is at");
|
||||
});
|
||||
});
|
||||
@@ -1,262 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { tableToModerator } from "@uncaged/workflow-protocol/moderator-table.js";
|
||||
import { validateWorkflowDescriptor } from "@uncaged/workflow-register";
|
||||
import { END, type ModeratorContext, type RoleStep, START } from "@uncaged/workflow-runtime";
|
||||
import { buildDevelopDescriptor } from "../src/descriptor.js";
|
||||
import { developTable } from "../src/moderator.js";
|
||||
import type { CommitterMeta, PlannerMeta } from "../src/roles/index.js";
|
||||
import type { DevelopMeta } from "../src/roles.js";
|
||||
|
||||
const developModerator = tableToModerator(developTable);
|
||||
|
||||
type PlannedMeta = Extract<PlannerMeta, { status: "planned" }>;
|
||||
|
||||
const DEFAULT_PHASES: PlannedMeta["phases"] = [
|
||||
{
|
||||
hash: "4KNMR2PX",
|
||||
title: "Do the work",
|
||||
},
|
||||
];
|
||||
|
||||
function makeStart(): ModeratorContext<DevelopMeta>["start"] {
|
||||
return {
|
||||
role: START,
|
||||
content: "Implement the feature",
|
||||
meta: {},
|
||||
timestamp: 0,
|
||||
parentState: null,
|
||||
};
|
||||
}
|
||||
|
||||
function makeCtx(steps: ModeratorContext<DevelopMeta>["steps"]): ModeratorContext<DevelopMeta> {
|
||||
return {
|
||||
threadId: "01TEST000000000000000000TR",
|
||||
depth: 0,
|
||||
bundleHash: "TESTHASH00001",
|
||||
start: makeStart(),
|
||||
steps,
|
||||
};
|
||||
}
|
||||
|
||||
function plannerStep(phases: PlannedMeta["phases"] = DEFAULT_PHASES): RoleStep<DevelopMeta> {
|
||||
return {
|
||||
role: "planner",
|
||||
contentHash: "STUBHASHPLANNER001",
|
||||
meta: { status: "planned" as const, phases },
|
||||
refs: phases.map((p) => p.hash),
|
||||
timestamp: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function coderStep(completedPhase = "4KNMR2PX"): RoleStep<DevelopMeta> {
|
||||
return {
|
||||
role: "coder",
|
||||
contentHash: "STUBHASHCODER00001",
|
||||
meta: { completedPhase, filesChanged: ["a.ts"], summary: "implemented" },
|
||||
refs: [completedPhase],
|
||||
timestamp: 2,
|
||||
};
|
||||
}
|
||||
|
||||
function reviewerStep(approved: boolean): RoleStep<DevelopMeta> {
|
||||
return {
|
||||
role: "reviewer",
|
||||
contentHash: "STUBHASHREVIEWER01",
|
||||
meta: approved
|
||||
? { status: "approved" as const }
|
||||
: { status: "rejected" as const, issues: ["needs fix"] },
|
||||
refs: [],
|
||||
timestamp: 3,
|
||||
};
|
||||
}
|
||||
|
||||
function testerStep(passed: boolean): RoleStep<DevelopMeta> {
|
||||
return {
|
||||
role: "tester",
|
||||
contentHash: "STUBHASHTESTER01",
|
||||
meta: passed
|
||||
? { status: "passed" as const, details: "all checks passed" }
|
||||
: { status: "failed" as const, details: "lint failed" },
|
||||
refs: [],
|
||||
timestamp: 4,
|
||||
};
|
||||
}
|
||||
|
||||
function committerStep(meta: CommitterMeta): RoleStep<DevelopMeta> {
|
||||
return {
|
||||
role: "committer",
|
||||
contentHash: "STUBHASHCOMMITTER1",
|
||||
meta,
|
||||
refs: [],
|
||||
timestamp: 5,
|
||||
};
|
||||
}
|
||||
|
||||
describe("developModerator", () => {
|
||||
test("routes initial → planner → coder → reviewer → tester → committer → END", () => {
|
||||
expect(developModerator(makeCtx([]))).toBe("planner");
|
||||
expect(developModerator(makeCtx([plannerStep()]))).toBe("coder");
|
||||
expect(developModerator(makeCtx([plannerStep(), coderStep()]))).toBe("reviewer");
|
||||
expect(developModerator(makeCtx([plannerStep(), coderStep(), reviewerStep(true)]))).toBe(
|
||||
"tester",
|
||||
);
|
||||
expect(
|
||||
developModerator(makeCtx([plannerStep(), coderStep(), reviewerStep(true), testerStep(true)])),
|
||||
).toBe("committer");
|
||||
expect(
|
||||
developModerator(
|
||||
makeCtx([
|
||||
plannerStep(),
|
||||
coderStep(),
|
||||
reviewerStep(true),
|
||||
testerStep(true),
|
||||
committerStep({ status: "committed", branch: "feat/x", commitSha: "abc1234" }),
|
||||
]),
|
||||
),
|
||||
).toBe(END);
|
||||
});
|
||||
|
||||
test("reviewer rejects → coder retry when budget allows", () => {
|
||||
const steps: ModeratorContext<DevelopMeta>["steps"] = [
|
||||
plannerStep(),
|
||||
coderStep(),
|
||||
reviewerStep(false),
|
||||
];
|
||||
expect(developModerator(makeCtx(steps))).toBe("coder");
|
||||
});
|
||||
|
||||
test("reviewer rejects → coder retry (supervisor controls termination)", () => {
|
||||
const steps: ModeratorContext<DevelopMeta>["steps"] = [
|
||||
plannerStep(),
|
||||
coderStep(),
|
||||
reviewerStep(false),
|
||||
];
|
||||
expect(developModerator(makeCtx(steps))).toBe("coder");
|
||||
});
|
||||
|
||||
test("tester failed → coder retry when budget allows", () => {
|
||||
const steps: ModeratorContext<DevelopMeta>["steps"] = [
|
||||
plannerStep(),
|
||||
coderStep(),
|
||||
reviewerStep(true),
|
||||
testerStep(false),
|
||||
];
|
||||
expect(developModerator(makeCtx(steps))).toBe("coder");
|
||||
});
|
||||
|
||||
test("tester failed → coder retry (supervisor controls termination)", () => {
|
||||
const steps: ModeratorContext<DevelopMeta>["steps"] = [
|
||||
plannerStep(),
|
||||
coderStep(),
|
||||
reviewerStep(true),
|
||||
testerStep(false),
|
||||
];
|
||||
expect(developModerator(makeCtx(steps))).toBe("coder");
|
||||
});
|
||||
|
||||
test("multiple planner phases → coder until all complete, then reviewer", () => {
|
||||
const phases: PlannedMeta["phases"] = [
|
||||
{ hash: "AA000001", title: "first phase" },
|
||||
{ hash: "AA000002", title: "second phase" },
|
||||
];
|
||||
expect(developModerator(makeCtx([plannerStep(phases)]))).toBe("coder");
|
||||
expect(developModerator(makeCtx([plannerStep(phases), coderStep("AA000001")]))).toBe("coder");
|
||||
expect(
|
||||
developModerator(
|
||||
makeCtx([plannerStep(phases), coderStep("AA000001"), coderStep("AA000002")]),
|
||||
),
|
||||
).toBe("reviewer");
|
||||
});
|
||||
|
||||
test("one-shot coder reports only last phase hash → reviewer (moderator treats as all phases done)", () => {
|
||||
const phases: PlannedMeta["phases"] = [
|
||||
{ hash: "BB000001", title: "setup branch" },
|
||||
{ hash: "BB000002", title: "write tests" },
|
||||
{ hash: "BB000003", title: "verify" },
|
||||
{ hash: "BB000004", title: "polish" },
|
||||
];
|
||||
expect(developModerator(makeCtx([plannerStep(phases), coderStep("BB000004")]))).toBe(
|
||||
"reviewer",
|
||||
);
|
||||
});
|
||||
|
||||
test("unrecognised completedPhase hash → coder retry when budget allows", () => {
|
||||
const phases: PlannedMeta["phases"] = [
|
||||
{ hash: "CC000001", title: "first phase" },
|
||||
{ hash: "CC000002", title: "second phase" },
|
||||
];
|
||||
expect(developModerator(makeCtx([plannerStep(phases), coderStep("all-done")]))).toBe("coder");
|
||||
});
|
||||
|
||||
test("incomplete phases → coder retry (supervisor controls termination)", () => {
|
||||
const phases: PlannedMeta["phases"] = [
|
||||
{ hash: "DD000001", title: "first phase" },
|
||||
{ hash: "DD000002", title: "second phase" },
|
||||
];
|
||||
const steps: ModeratorContext<DevelopMeta>["steps"] = [
|
||||
plannerStep(phases),
|
||||
coderStep("DD000001"),
|
||||
];
|
||||
expect(developModerator(makeCtx(steps))).toBe("coder");
|
||||
});
|
||||
|
||||
test("planner aborted → END", () => {
|
||||
const abortedStep: RoleStep<DevelopMeta> = {
|
||||
role: "planner",
|
||||
contentHash: "STUBHASHABORT001",
|
||||
meta: { status: "aborted", reason: "No workspace path provided" },
|
||||
refs: [],
|
||||
timestamp: 1,
|
||||
};
|
||||
expect(developModerator(makeCtx([abortedStep]))).toBe("__end__");
|
||||
});
|
||||
|
||||
test("committer → END for any committer meta status", () => {
|
||||
const committed = committerStep({ status: "committed", branch: "f", commitSha: "x" });
|
||||
const recoverable = committerStep({
|
||||
status: "recoverable",
|
||||
error: "merge conflict",
|
||||
logRef: null,
|
||||
});
|
||||
const unrecoverable = committerStep({
|
||||
status: "unrecoverable",
|
||||
error: "repo missing",
|
||||
logRef: "log1",
|
||||
});
|
||||
const base: ModeratorContext<DevelopMeta>["steps"] = [
|
||||
plannerStep(),
|
||||
coderStep(),
|
||||
reviewerStep(true),
|
||||
testerStep(true),
|
||||
];
|
||||
expect(developModerator(makeCtx([...base, committed]))).toBe(END);
|
||||
expect(developModerator(makeCtx([...base, recoverable]))).toBe(END);
|
||||
expect(developModerator(makeCtx([...base, unrecoverable]))).toBe(END);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDevelopDescriptor", () => {
|
||||
test("lists all roles with schemas that validate", () => {
|
||||
const descriptor = buildDevelopDescriptor();
|
||||
const validated = validateWorkflowDescriptor(descriptor);
|
||||
expect(validated.ok).toBe(true);
|
||||
if (!validated.ok) {
|
||||
throw new Error(validated.error);
|
||||
}
|
||||
expect(Object.keys(validated.value.roles).sort()).toEqual([
|
||||
"coder",
|
||||
"committer",
|
||||
"planner",
|
||||
"reviewer",
|
||||
"tester",
|
||||
]);
|
||||
expect(validated.value.graph.edges.length).toBeGreaterThan(0);
|
||||
for (const key of ["planner", "coder", "reviewer", "tester", "committer"] as const) {
|
||||
const role = validated.value.roles[key];
|
||||
expect(role).toBeDefined();
|
||||
expect(typeof role.schema).toBe("object");
|
||||
expect(role.schema).not.toBeNull();
|
||||
expect(Array.isArray(role.schema)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,15 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { reviewerMetaSchema, reviewerRole } from "../src/roles/reviewer.js";
|
||||
|
||||
describe("reviewerRole", () => {
|
||||
test("approved sample validates against schema", () => {
|
||||
const parsed = reviewerMetaSchema.safeParse({ status: "approved" as const });
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
test("system prompt is generic (no cwd)", () => {
|
||||
expect(reviewerRole.systemPrompt).toContain("code reviewer");
|
||||
expect(reviewerRole.systemPrompt).not.toContain("project is at");
|
||||
});
|
||||
});
|
||||
@@ -1,35 +0,0 @@
|
||||
/**
|
||||
* develop bundle entry — 小橘 🍊
|
||||
*
|
||||
* planner/coder/reviewer → cursor-agent (needs code editing)
|
||||
* tester/committer → hermes-agent (lightweight, no editing needed)
|
||||
*/
|
||||
import { createCursorAgent } from "@uncaged/workflow-agent-cursor";
|
||||
import { createHermesAgent } from "@uncaged/workflow-agent-hermes";
|
||||
import { createWorkflow } from "@uncaged/workflow-runtime";
|
||||
import { env } from "@uncaged/workflow-util";
|
||||
import { buildDevelopDescriptor, developWorkflowDefinition } from "./src/index.js";
|
||||
|
||||
const cursorAdapter = createCursorAgent({
|
||||
command: env("WORKFLOW_CURSOR_COMMAND", "/home/azureuser/.local/bin/cursor-agent"),
|
||||
model: env("WORKFLOW_CURSOR_MODEL", "auto"),
|
||||
timeout: Number(env("WORKFLOW_CURSOR_TIMEOUT", "0")),
|
||||
workspace: null,
|
||||
});
|
||||
|
||||
const hermesAdapter = createHermesAgent({
|
||||
command: env("WORKFLOW_HERMES_COMMAND", "/home/azureuser/.local/bin/hermes"),
|
||||
model: env("WORKFLOW_HERMES_MODEL", "") || null,
|
||||
timeout: Number(env("WORKFLOW_HERMES_TIMEOUT", "0")) || null,
|
||||
});
|
||||
|
||||
const wf = createWorkflow(developWorkflowDefinition, {
|
||||
adapter: cursorAdapter,
|
||||
overrides: {
|
||||
tester: hermesAdapter,
|
||||
committer: hermesAdapter,
|
||||
},
|
||||
});
|
||||
|
||||
export const descriptor = buildDevelopDescriptor();
|
||||
export const run = wf;
|
||||
@@ -1,31 +0,0 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-template-develop",
|
||||
"version": "0.5.0-alpha.4",
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
"package.json"
|
||||
],
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"bun": "./src/index.ts",
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow-register": "workspace:^",
|
||||
"@uncaged/workflow-runtime": "workspace:^",
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@uncaged/workflow-protocol": "workspace:^"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@uncaged/workflow-register':
|
||||
specifier: workspace:*
|
||||
version: link:../workflow-register
|
||||
'@uncaged/workflow-runtime':
|
||||
specifier: workspace:*
|
||||
version: link:../workflow-runtime
|
||||
zod:
|
||||
specifier: ^4.0.0
|
||||
version: 4.4.3
|
||||
|
||||
packages:
|
||||
|
||||
zod@4.4.3:
|
||||
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
|
||||
|
||||
snapshots:
|
||||
|
||||
zod@4.4.3: {}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { buildDescriptor } from "@uncaged/workflow-register";
|
||||
|
||||
import { developTable } from "./moderator.js";
|
||||
import { DEVELOP_WORKFLOW_DESCRIPTION, developRoles } from "./roles.js";
|
||||
|
||||
export function buildDevelopDescriptor() {
|
||||
return buildDescriptor({
|
||||
description: DEVELOP_WORKFLOW_DESCRIPTION,
|
||||
roles: developRoles,
|
||||
table: developTable,
|
||||
});
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import type { WorkflowDefinition } from "@uncaged/workflow-runtime";
|
||||
|
||||
import { developTable } from "./moderator.js";
|
||||
import { DEVELOP_WORKFLOW_DESCRIPTION, type DevelopMeta, developRoles } from "./roles.js";
|
||||
|
||||
export { buildDevelopDescriptor } from "./descriptor.js";
|
||||
export { developTable } from "./moderator.js";
|
||||
export {
|
||||
type CoderMeta,
|
||||
type CommitterMeta,
|
||||
coderMetaSchema,
|
||||
coderRole,
|
||||
committerMetaSchema,
|
||||
committerRole,
|
||||
type PlannerMeta,
|
||||
phaseSchema,
|
||||
plannerMetaSchema,
|
||||
plannerRole,
|
||||
type ReviewerMeta,
|
||||
reviewerMetaSchema,
|
||||
reviewerRole,
|
||||
type TesterMeta,
|
||||
testerMetaSchema,
|
||||
testerRole,
|
||||
} from "./roles/index.js";
|
||||
export {
|
||||
DEVELOP_WORKFLOW_DESCRIPTION,
|
||||
type DevelopMeta,
|
||||
type DevelopRoles,
|
||||
developRoles,
|
||||
} from "./roles.js";
|
||||
|
||||
export const developWorkflowDefinition: WorkflowDefinition<DevelopMeta> = {
|
||||
description: DEVELOP_WORKFLOW_DESCRIPTION,
|
||||
roles: developRoles,
|
||||
table: developTable,
|
||||
};
|
||||
@@ -1,105 +0,0 @@
|
||||
import {
|
||||
END,
|
||||
type ModeratorCondition,
|
||||
type ModeratorTable,
|
||||
START,
|
||||
} from "@uncaged/workflow-runtime";
|
||||
|
||||
import type { DevelopMeta } from "./roles.js";
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// ── Conditions ─────────────────────────────────────────────────────
|
||||
|
||||
const plannerAborted: ModeratorCondition<DevelopMeta> = {
|
||||
name: "plannerAborted",
|
||||
description: "The planner aborted due to insufficient information",
|
||||
check: (ctx) => {
|
||||
const plannerStep = ctx.steps.find((s) => s.role === "planner");
|
||||
if (plannerStep === undefined) {
|
||||
return false;
|
||||
}
|
||||
return plannerStep.meta.status === "aborted";
|
||||
},
|
||||
};
|
||||
|
||||
const allPhasesComplete: ModeratorCondition<DevelopMeta> = {
|
||||
name: "allPhasesComplete",
|
||||
description: "All planned phases have been completed by the coder",
|
||||
check: (ctx) => {
|
||||
const plannerStep = ctx.steps.find((s) => s.role === "planner");
|
||||
if (plannerStep === undefined) {
|
||||
return true;
|
||||
}
|
||||
const phases = plannerStep.meta.status === "planned" ? plannerStep.meta.phases : [];
|
||||
if (!Array.isArray(phases)) {
|
||||
return true;
|
||||
}
|
||||
const coderCompletedPhases = ctx.steps
|
||||
.filter((s) => s.role === "coder")
|
||||
.map((s) => s.meta.completedPhase);
|
||||
return coderFinishedAllPlannedPhases(phases, coderCompletedPhases);
|
||||
},
|
||||
};
|
||||
|
||||
const reviewApproved: ModeratorCondition<DevelopMeta> = {
|
||||
name: "reviewApproved",
|
||||
description: "The last reviewer approved the changes",
|
||||
check: (ctx) => {
|
||||
const last = ctx.steps[ctx.steps.length - 1];
|
||||
return last.role === "reviewer" && last.meta.status === "approved";
|
||||
},
|
||||
};
|
||||
|
||||
const testsPassed: ModeratorCondition<DevelopMeta> = {
|
||||
name: "testsPassed",
|
||||
description: "The last tester reported tests passed",
|
||||
check: (ctx) => {
|
||||
const last = ctx.steps[ctx.steps.length - 1];
|
||||
return last.role === "tester" && last.meta.status === "passed";
|
||||
},
|
||||
};
|
||||
|
||||
// ── Transition Table ───────────────────────────────────────────────
|
||||
|
||||
const table: ModeratorTable<DevelopMeta> = {
|
||||
[START]: [{ condition: "FALLBACK", role: "planner" }],
|
||||
planner: [
|
||||
{ condition: plannerAborted, role: END },
|
||||
{ condition: "FALLBACK", role: "coder" },
|
||||
],
|
||||
coder: [
|
||||
{ condition: allPhasesComplete, role: "reviewer" },
|
||||
{ condition: "FALLBACK", role: "coder" },
|
||||
],
|
||||
reviewer: [
|
||||
{ condition: reviewApproved, role: "tester" },
|
||||
{ condition: "FALLBACK", role: "coder" },
|
||||
],
|
||||
tester: [
|
||||
{ condition: testsPassed, role: "committer" },
|
||||
{ condition: "FALLBACK", role: "coder" },
|
||||
],
|
||||
committer: [{ condition: "FALLBACK", role: END }],
|
||||
};
|
||||
|
||||
export { table as developTable };
|
||||
@@ -1,29 +0,0 @@
|
||||
import type { RoleDefinition } from "@uncaged/workflow-runtime";
|
||||
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).";
|
||||
|
||||
export type DevelopMeta = {
|
||||
planner: PlannerMeta;
|
||||
coder: CoderMeta;
|
||||
reviewer: ReviewerMeta;
|
||||
tester: TesterMeta;
|
||||
committer: CommitterMeta;
|
||||
};
|
||||
|
||||
export type DevelopRoles = {
|
||||
[K in keyof DevelopMeta]: RoleDefinition<DevelopMeta[K]>;
|
||||
};
|
||||
|
||||
export const developRoles: DevelopRoles = {
|
||||
planner: plannerRole,
|
||||
coder: coderRole,
|
||||
reviewer: reviewerRole,
|
||||
tester: testerRole,
|
||||
committer: committerRole,
|
||||
};
|
||||
@@ -1,40 +0,0 @@
|
||||
import type { RoleDefinition } from "@uncaged/workflow-runtime";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
export const coderMetaSchema = z.object({
|
||||
completedPhase: z
|
||||
.string()
|
||||
.meta({ casRef: true })
|
||||
.describe(
|
||||
"The planner phase hash finished this round. If multiple phases were completed, use the last finished phase hash.",
|
||||
),
|
||||
filesChanged: z.array(z.string()),
|
||||
summary: z.string(),
|
||||
});
|
||||
|
||||
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.
|
||||
|
||||
Run \`uncaged-workflow skill develop\` for thread ID lookup, CAS commands, and meta output guide.
|
||||
|
||||
## Reading phase details
|
||||
|
||||
Each planner phase has a content-hash and title. Read full details with \`uncaged-workflow cas get <HASH>\`.
|
||||
|
||||
The thread ID (26-char Crockford Base32) appears in the first message. If unsure, run \`uncaged-workflow thread list\`.
|
||||
|
||||
## Completing a phase
|
||||
|
||||
Report which phase you completed using the phase **hash** (not the title). If you legitimately finish every remaining phase in this single turn, set completedPhase to the **last** phase hash in the plan (the workflow treats that as full completion). List the files you changed and summarize what you did.
|
||||
|
||||
## Output rules
|
||||
|
||||
Keep your final response **short** — a brief summary paragraph plus the structured meta output. Do NOT paste diffs, file contents, or code blocks in your response. The actual changes are already on disk; repeating them wastes tokens. Just say what you did and why.`;
|
||||
|
||||
export const coderRole: RoleDefinition<CoderMeta> = {
|
||||
description:
|
||||
"Implements the next incomplete planner phase and reports structured completion metadata.",
|
||||
systemPrompt: CODER_SYSTEM,
|
||||
schema: coderMetaSchema,
|
||||
};
|
||||
@@ -1,32 +0,0 @@
|
||||
import type { RoleDefinition } from "@uncaged/workflow-runtime";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
export const committerMetaSchema = z.discriminatedUnion("status", [
|
||||
z.object({
|
||||
status: z.literal("committed"),
|
||||
branch: z.string(),
|
||||
commitSha: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
status: z.literal("recoverable"),
|
||||
error: z.string(),
|
||||
logRef: z.string().nullable(),
|
||||
}),
|
||||
z.object({
|
||||
status: z.literal("unrecoverable"),
|
||||
error: z.string(),
|
||||
logRef: z.string().nullable(),
|
||||
}),
|
||||
]);
|
||||
|
||||
export type CommitterMeta = z.infer<typeof committerMetaSchema>;
|
||||
|
||||
const COMMITTER_SYSTEM = `You are the git committer. Create a branch and commit the changes.
|
||||
Report the branch name and commit SHA. On failure, classify as recoverable or unrecoverable.
|
||||
Do not attempt to fix failures yourself.`;
|
||||
|
||||
export const committerRole: RoleDefinition<CommitterMeta> = {
|
||||
description: "Creates a branch and commits changes.",
|
||||
systemPrompt: COMMITTER_SYSTEM,
|
||||
schema: committerMetaSchema,
|
||||
};
|
||||
@@ -1,10 +0,0 @@
|
||||
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";
|
||||
@@ -1,66 +0,0 @@
|
||||
import type { RoleDefinition } from "@uncaged/workflow-runtime";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
export const phaseSchema = z.object({
|
||||
hash: z.string().meta({ casRef: true }),
|
||||
title: z.string(),
|
||||
});
|
||||
|
||||
export const plannerMetaSchema = z.discriminatedUnion("status", [
|
||||
z.object({
|
||||
status: z.literal("planned"),
|
||||
phases: z.array(phaseSchema),
|
||||
}),
|
||||
z.object({
|
||||
status: z.literal("aborted"),
|
||||
reason: z.string().describe("Why the task cannot proceed"),
|
||||
}),
|
||||
]);
|
||||
|
||||
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. **Abort** if the prompt lacks critical information (e.g. no project/workspace path, ambiguous target repo).
|
||||
|
||||
Run \`uncaged-workflow skill develop\` for thread ID lookup, CAS commands, and meta output guide.
|
||||
|
||||
## Prerequisites — check FIRST
|
||||
|
||||
The prompt MUST include an **absolute filesystem path** to the project workspace (e.g. \`/home/user/repos/my-project\`). If no workspace path is given and you cannot reliably infer one from context, **abort immediately** with a clear reason explaining what information is missing. Do NOT guess paths.
|
||||
|
||||
## Storing phase details — MANDATORY
|
||||
|
||||
For each phase, store its full detail text in CAS via \`uncaged-workflow cas put '<content>'\`. The command prints a content-hash — use that as the phase identifier.
|
||||
|
||||
The thread ID (26-char Crockford Base32) appears in the first message. If unsure, run \`uncaged-workflow thread list\`.
|
||||
|
||||
**Do NOT store phase details in any other way** — the CLI is the only supported storage mechanism.
|
||||
|
||||
## Phase granularity
|
||||
|
||||
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
|
||||
|
||||
Fewer phases is always better. Each phase must justify its existence — if two phases would be tested together anyway, merge them.
|
||||
|
||||
## Output format
|
||||
|
||||
After storing all phases via the CLI, output compact JSON only:
|
||||
{ "status": "planned", "phases": [{ "hash": "<hash-from-cas-put>", "title": "<one-line-summary>" }] }
|
||||
|
||||
If aborting:
|
||||
{ "status": "aborted", "reason": "<what is missing>" }
|
||||
|
||||
Order phases so earlier steps unblock later ones. Cover root cause, edge cases, and verification across the phases.
|
||||
|
||||
## Output rules
|
||||
|
||||
Keep your final response **short** — just the JSON with phases. Do NOT paste code snippets, diffs, or implementation details in your response. Phase details are already stored in CAS; your response should only contain the compact phases JSON.`;
|
||||
|
||||
export const plannerRole: RoleDefinition<PlannerMeta> = {
|
||||
description: "Breaks the task into sequential phases for the coder.",
|
||||
systemPrompt: PLANNER_SYSTEM,
|
||||
schema: plannerMetaSchema,
|
||||
};
|
||||
@@ -1,45 +0,0 @@
|
||||
import type { RoleDefinition } from "@uncaged/workflow-runtime";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
export const reviewerMetaSchema = z.discriminatedUnion("status", [
|
||||
z.object({
|
||||
status: z.literal("approved"),
|
||||
}),
|
||||
z.object({
|
||||
status: z.literal("rejected"),
|
||||
issues: z.array(z.string()).describe("blocking issues that must be fixed"),
|
||||
}),
|
||||
]);
|
||||
export type ReviewerMeta = z.infer<typeof reviewerMetaSchema>;
|
||||
|
||||
const REVIEWER_SYSTEM = `You are a code reviewer. Review the git diff for correctness, consistency, and adherence to project conventions.
|
||||
|
||||
## Review process
|
||||
|
||||
1. Read the **preparer**'s output in the thread for project conventions (coding style, naming, commit format, etc.).
|
||||
2. Review the diff against these conventions.
|
||||
3. For documentation changes, verify that names, paths, and references match the actual codebase.
|
||||
|
||||
## Review checklist
|
||||
|
||||
- **Correctness** — does the code do what it claims? Logic bugs, off-by-one, missing returns?
|
||||
- **Conventions** — naming, imports, code style per project rules?
|
||||
- **Consistency** — do docs/comments match actual code? Are references current and accurate?
|
||||
- **Edge cases** — missing error handling, null checks, boundary conditions?
|
||||
|
||||
## Verdict
|
||||
|
||||
- **Approve** only if there are zero issues
|
||||
- **Reject** with specific issues that must be fixed — every issue you find is blocking
|
||||
|
||||
Be thorough. A false approve costs more than a false reject.
|
||||
|
||||
## Output rules
|
||||
|
||||
Keep your final response **short**. Summarize findings in a few bullet points, then output the structured verdict. Do NOT paste the full diff or large code blocks in your response.`;
|
||||
|
||||
export const reviewerRole: RoleDefinition<ReviewerMeta> = {
|
||||
description: "Runs git diff checks and sets approved when the change is ready.",
|
||||
systemPrompt: REVIEWER_SYSTEM,
|
||||
schema: reviewerMetaSchema,
|
||||
};
|
||||
@@ -1,27 +0,0 @@
|
||||
import type { RoleDefinition } from "@uncaged/workflow-runtime";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
export const testerMetaSchema = z.discriminatedUnion("status", [
|
||||
z.object({
|
||||
status: z.literal("passed"),
|
||||
details: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
status: z.literal("failed"),
|
||||
details: z.string(),
|
||||
}),
|
||||
]);
|
||||
|
||||
export type TesterMeta = z.infer<typeof testerMetaSchema>;
|
||||
|
||||
const TESTER_SYSTEM = `You are a tester. Run the project's test suite, build, and lint commands. Check what commands are available from the preparer's output in the thread. Report pass/fail with details of what failed.
|
||||
|
||||
## Output rules
|
||||
|
||||
Keep your final response **short**. Report pass/fail with a brief summary of failures (if any). Do NOT paste full test output or build logs — just the key error lines.`;
|
||||
|
||||
export const testerRole: RoleDefinition<TesterMeta> = {
|
||||
description: "Runs test, build, and lint commands and reports pass or fail with details.",
|
||||
systemPrompt: TESTER_SYSTEM,
|
||||
schema: testerMetaSchema,
|
||||
};
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"references": [{ "path": "../workflow-register" }, { "path": "../workflow-runtime" }]
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
# @uncaged/workflow-template-solve-issue
|
||||
|
||||
## 0.5.0-alpha.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @uncaged/workflow-register@0.5.0-alpha.4
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.4
|
||||
|
||||
## 0.5.0-alpha.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @uncaged/workflow-register@0.5.0-alpha.3
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.3
|
||||
|
||||
## 0.5.0-alpha.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @uncaged/workflow-register@0.5.0-alpha.2
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.2
|
||||
|
||||
## 0.5.0-alpha.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @uncaged/workflow-register@0.5.0-alpha.1
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.1
|
||||
|
||||
## 0.5.0-alpha.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @uncaged/workflow-register@0.5.0-alpha.0
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.0
|
||||
|
||||
## 0.4.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @uncaged/workflow-register@0.4.5
|
||||
- @uncaged/workflow-runtime@0.4.5
|
||||
|
||||
## 0.4.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @uncaged/workflow-register@0.4.4
|
||||
- @uncaged/workflow-runtime@0.4.4
|
||||
|
||||
## 0.4.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Include src/ in published packages so bun runtime can resolve the 'bun' exports condition.
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-register@0.4.3
|
||||
- @uncaged/workflow-runtime@0.4.3
|
||||
|
||||
## 0.4.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix workspace dependency resolution: use workspace:^ so published packages resolve to compatible versions instead of exact (non-existent) versions.
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-register@0.4.2
|
||||
- @uncaged/workflow-runtime@0.4.2
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Fix package exports for published packages and adopt changesets for version management.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-register@0.4.0
|
||||
- @uncaged/workflow-runtime@0.4.0
|
||||
@@ -1,48 +0,0 @@
|
||||
# @uncaged/workflow-template-solve-issue
|
||||
|
||||
Reference **solve-issue** workflow template: prepare a repo, delegate implementation to the **develop** workflow, then submit (e.g. open a PR).
|
||||
|
||||
This package exports a pure `WorkflowDefinition` (`solveIssueWorkflowDefinition`). Workflow instantiation (`createWorkflow(definition, binding)`) and any role-specific agent wiring (for example delegating `developer` to `workflowAsAgent("develop")`) are done in the workflow instance layer.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @uncaged/workflow-template-solve-issue @uncaged/workflow zod
|
||||
```
|
||||
|
||||
In this monorepo: `workspace:*` for this package and `@uncaged/workflow`.
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
import { createWorkflow } from "@uncaged/workflow";
|
||||
import { solveIssueWorkflowDefinition } from "@uncaged/workflow-template-solve-issue";
|
||||
|
||||
const run = createWorkflow(solveIssueWorkflowDefinition, binding);
|
||||
```
|
||||
|
||||
## Roles
|
||||
|
||||
| Role | Purpose |
|
||||
|------|---------|
|
||||
| **preparer** | Set up context / repo state for the issue |
|
||||
| **developer** | Implementation; default runs the registered `develop` workflow as a sub-agent |
|
||||
| **submitter** | Finalize and submit the outcome (e.g. PR) |
|
||||
|
||||
Also exported: `preparerRole`, `developerRole`, `submitterRole` and their Zod meta schemas, `SolveIssueMeta`, `solveIssueRoles`.
|
||||
|
||||
## Moderator flow
|
||||
|
||||
1. **Start** → `preparer`
|
||||
2. After **preparer** → `developer`
|
||||
3. After **developer** → `submitter`
|
||||
4. After **submitter** → `END`
|
||||
|
||||
## API overview
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `solveIssueWorkflowDefinition` | `description`, `roles`, `solveIssueModerator` |
|
||||
| `solveIssueModerator` | Linear `Moderator<SolveIssueMeta>` |
|
||||
| `buildSolveIssueDescriptor` | Descriptor helper for bundles |
|
||||
| `SOLVE_ISSUE_WORKFLOW_DESCRIPTION` | Human-readable one-liner |
|
||||
@@ -1,298 +0,0 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createCasStore } from "@uncaged/workflow-cas";
|
||||
import { createExtract } from "@uncaged/workflow-execute";
|
||||
import { tableToModerator } from "@uncaged/workflow-protocol/moderator-table.js";
|
||||
import { validateWorkflowDescriptor } from "@uncaged/workflow-register";
|
||||
import {
|
||||
type AdapterFn,
|
||||
createWorkflow,
|
||||
END,
|
||||
type ModeratorContext,
|
||||
type RoleResult,
|
||||
type RoleStep,
|
||||
START,
|
||||
type ThreadContext,
|
||||
type WorkflowRuntime,
|
||||
} from "@uncaged/workflow-runtime";
|
||||
import type * as z from "zod/v4";
|
||||
import { buildSolveIssueDescriptor } from "../src/descriptor.js";
|
||||
import type { DeveloperMeta } from "../src/developer.js";
|
||||
import { solveIssueTable, solveIssueWorkflowDefinition } from "../src/index.js";
|
||||
import type { PreparerMeta, SubmitterMeta } from "../src/roles/index.js";
|
||||
import type { SolveIssueMeta } from "../src/roles.js";
|
||||
|
||||
const solveIssueModerator = tableToModerator(solveIssueTable);
|
||||
|
||||
function makeStart(): ModeratorContext<SolveIssueMeta>["start"] {
|
||||
return {
|
||||
role: START,
|
||||
content: "Fix the flaky login test",
|
||||
meta: {},
|
||||
timestamp: 0,
|
||||
parentState: null,
|
||||
};
|
||||
}
|
||||
|
||||
function makeCtx(
|
||||
steps: ModeratorContext<SolveIssueMeta>["steps"],
|
||||
): ModeratorContext<SolveIssueMeta> {
|
||||
return {
|
||||
threadId: "01TEST000000000000000000TR",
|
||||
depth: 0,
|
||||
bundleHash: "TESTHASH00001",
|
||||
start: makeStart(),
|
||||
steps,
|
||||
};
|
||||
}
|
||||
|
||||
function preparerStep(): RoleStep<SolveIssueMeta> {
|
||||
return {
|
||||
role: "preparer",
|
||||
contentHash: "STUBHASHPREPARER01",
|
||||
meta: {
|
||||
repoPath: "/home/user/repos/test",
|
||||
defaultBranch: "main",
|
||||
conventions: null,
|
||||
toolchain: {
|
||||
packageManager: "bun",
|
||||
testCommand: "bun test",
|
||||
lintCommand: null,
|
||||
buildCommand: "bun run build",
|
||||
},
|
||||
},
|
||||
refs: [],
|
||||
timestamp: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function developerStep(): RoleStep<SolveIssueMeta> {
|
||||
return {
|
||||
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 submitterStep(meta: SubmitterMeta): RoleStep<SolveIssueMeta> {
|
||||
return {
|
||||
role: "submitter",
|
||||
contentHash: "STUBHASHSUBMITTER1",
|
||||
meta,
|
||||
refs: [],
|
||||
timestamp: 2,
|
||||
};
|
||||
}
|
||||
|
||||
function makeThread(prompt: string) {
|
||||
return {
|
||||
threadId: "01TEST000000000000000000TR",
|
||||
depth: 0,
|
||||
bundleHash: "TESTHASH00001",
|
||||
start: {
|
||||
role: START,
|
||||
content: prompt,
|
||||
meta: {},
|
||||
timestamp: Date.now(),
|
||||
parentState: null,
|
||||
},
|
||||
steps: [],
|
||||
};
|
||||
}
|
||||
|
||||
/** Creates an AdapterFn that returns a fixed sequence of meta values. */
|
||||
function createSequenceAdapter(sequence: ReadonlyArray<Record<string, unknown>>): AdapterFn {
|
||||
let i = 0;
|
||||
return <T>(_prompt: string, _schema: z.ZodType<T>) => {
|
||||
return async (_ctx: ThreadContext, _runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
|
||||
const meta = sequence[i] ?? sequence[sequence.length - 1];
|
||||
if (meta === undefined) {
|
||||
throw new Error("createSequenceAdapter: empty sequence");
|
||||
}
|
||||
i += 1;
|
||||
return { meta: meta as T, childThread: null };
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/** Creates an AdapterFn that tracks calls and returns fixed meta. */
|
||||
function createTrackingAdapter(
|
||||
name: string,
|
||||
calls: string[],
|
||||
meta: Record<string, unknown>,
|
||||
): AdapterFn {
|
||||
return <T>(_prompt: string, _schema: z.ZodType<T>) => {
|
||||
return async (_ctx: ThreadContext, _runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
|
||||
calls.push(name);
|
||||
return { meta: meta as T, childThread: null };
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
describe("solveIssueModerator", () => {
|
||||
test("routes initial → preparer → developer → submitter → END", () => {
|
||||
expect(solveIssueModerator(makeCtx([]))).toBe("preparer");
|
||||
expect(solveIssueModerator(makeCtx([preparerStep()]))).toBe("developer");
|
||||
expect(solveIssueModerator(makeCtx([preparerStep(), developerStep()]))).toBe("submitter");
|
||||
expect(
|
||||
solveIssueModerator(
|
||||
makeCtx([
|
||||
preparerStep(),
|
||||
developerStep(),
|
||||
submitterStep({
|
||||
status: "submitted",
|
||||
prUrl: "https://github.com/example/repo/pull/1",
|
||||
}),
|
||||
]),
|
||||
),
|
||||
).toBe(END);
|
||||
});
|
||||
|
||||
test("submitter failed → END", () => {
|
||||
expect(
|
||||
solveIssueModerator(
|
||||
makeCtx([
|
||||
preparerStep(),
|
||||
developerStep(),
|
||||
submitterStep({ status: "failed", error: "gh not authenticated" }),
|
||||
]),
|
||||
),
|
||||
).toBe(END);
|
||||
});
|
||||
|
||||
test("returns END for any unexpected last step (defensive)", () => {
|
||||
expect(
|
||||
solveIssueModerator(
|
||||
makeCtx([
|
||||
preparerStep(),
|
||||
developerStep(),
|
||||
submitterStep({ status: "submitted", prUrl: "https://example.com/pr/1" }),
|
||||
]),
|
||||
),
|
||||
).toBe(END);
|
||||
});
|
||||
});
|
||||
|
||||
describe("solveIssueWorkflowDefinition + createWorkflow", () => {
|
||||
let casDir: string | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
if (casDir !== undefined) {
|
||||
await rm(casDir, { recursive: true, force: true }).catch(() => {});
|
||||
casDir = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
test("adapter yields preparer meta directly", async () => {
|
||||
const EXPECT_PREPARER_META: PreparerMeta = {
|
||||
repoPath: "/home/user/repos/test",
|
||||
defaultBranch: "main",
|
||||
conventions: null,
|
||||
toolchain: {
|
||||
packageManager: "bun",
|
||||
testCommand: "bun test",
|
||||
lintCommand: null,
|
||||
buildCommand: "bun run build",
|
||||
},
|
||||
};
|
||||
|
||||
casDir = await mkdtemp(join(tmpdir(), "solve-issue-cas-"));
|
||||
const cas = createCasStore(casDir);
|
||||
|
||||
const adapter = createSequenceAdapter([EXPECT_PREPARER_META]);
|
||||
const run = createWorkflow(solveIssueWorkflowDefinition, {
|
||||
adapter,
|
||||
overrides: null,
|
||||
});
|
||||
const gen = run(makeThread("task"), {
|
||||
cas,
|
||||
extract: createExtract({ baseUrl: "http://127.0.0.1:9", apiKey: "", model: "test" }, { cas }),
|
||||
});
|
||||
const first = await gen.next();
|
||||
expect(first.done).toBe(false);
|
||||
if (first.done) {
|
||||
throw new Error("expected yield");
|
||||
}
|
||||
expect(first.value.role).toBe("preparer");
|
||||
expect(first.value.meta).toEqual(EXPECT_PREPARER_META);
|
||||
});
|
||||
|
||||
test("per-role adapter overrides default", 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/x",
|
||||
commitSha: "abc1234",
|
||||
filesChanged: ["a.ts"],
|
||||
summary: "did the work",
|
||||
};
|
||||
const SUBMITTER_META: SubmitterMeta = {
|
||||
status: "submitted",
|
||||
prUrl: "https://github.com/example/repo/pull/2",
|
||||
};
|
||||
|
||||
casDir = await mkdtemp(join(tmpdir(), "solve-issue-cas-"));
|
||||
const cas = createCasStore(casDir);
|
||||
|
||||
const calls: string[] = [];
|
||||
const run = createWorkflow(solveIssueWorkflowDefinition, {
|
||||
adapter: createTrackingAdapter("default", calls, PREPARER_META),
|
||||
overrides: {
|
||||
preparer: createTrackingAdapter("preparer", calls, PREPARER_META),
|
||||
developer: createTrackingAdapter("developer", calls, DEVELOPER_META),
|
||||
submitter: createTrackingAdapter("submitter", calls, SUBMITTER_META),
|
||||
},
|
||||
});
|
||||
const gen = run(makeThread("task"), {
|
||||
cas,
|
||||
extract: createExtract({ baseUrl: "http://127.0.0.1:9", apiKey: "", model: "test" }, { cas }),
|
||||
});
|
||||
await gen.next();
|
||||
expect(calls).toEqual(["preparer"]);
|
||||
|
||||
calls.length = 0;
|
||||
await gen.next();
|
||||
expect(calls).toEqual(["developer"]);
|
||||
|
||||
calls.length = 0;
|
||||
await gen.next();
|
||||
expect(calls).toEqual(["submitter"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSolveIssueDescriptor", () => {
|
||||
test("lists preparer, developer, submitter with schemas that validate", () => {
|
||||
const descriptor = buildSolveIssueDescriptor();
|
||||
const validated = validateWorkflowDescriptor(descriptor);
|
||||
expect(validated.ok).toBe(true);
|
||||
if (!validated.ok) {
|
||||
throw new Error(validated.error);
|
||||
}
|
||||
expect(Object.keys(validated.value.roles).sort()).toEqual([
|
||||
"developer",
|
||||
"preparer",
|
||||
"submitter",
|
||||
]);
|
||||
expect(validated.value.graph.edges.length).toBe(4);
|
||||
for (const key of ["preparer", "developer", "submitter"] as const) {
|
||||
const role = validated.value.roles[key];
|
||||
expect(role).toBeDefined();
|
||||
expect(typeof role.schema).toBe("object");
|
||||
expect(role.schema).not.toBeNull();
|
||||
expect(Array.isArray(role.schema)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,34 +0,0 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
/**
|
||||
* solve-issue bundle entry — 小橘 🍊
|
||||
*
|
||||
* preparer + submitter → hermes agent
|
||||
* developer → workflow adapter (delegates to "develop" workflow)
|
||||
*/
|
||||
import { createHermesAgent } from "@uncaged/workflow-agent-hermes";
|
||||
import { workflowAdapter } from "@uncaged/workflow-execute";
|
||||
import { createWorkflow } from "@uncaged/workflow-runtime";
|
||||
import { env } from "@uncaged/workflow-util";
|
||||
import { buildSolveIssueDescriptor, solveIssueWorkflowDefinition } from "./src/index.js";
|
||||
|
||||
const adapter = createHermesAgent({
|
||||
command: env("WORKFLOW_HERMES_COMMAND", "/home/azureuser/.local/bin/hermes"),
|
||||
model: env("WORKFLOW_HERMES_MODEL", "") || null,
|
||||
timeout: Number(env("WORKFLOW_HERMES_TIMEOUT", "0")) || null,
|
||||
});
|
||||
|
||||
const wf = createWorkflow(solveIssueWorkflowDefinition, {
|
||||
adapter,
|
||||
overrides: {
|
||||
developer: workflowAdapter("develop"),
|
||||
},
|
||||
});
|
||||
|
||||
export const descriptor = buildSolveIssueDescriptor();
|
||||
export const run = wf;
|
||||
@@ -1,33 +0,0 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-template-solve-issue",
|
||||
"version": "0.5.0-alpha.4",
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
"package.json"
|
||||
],
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"bun": "./src/index.ts",
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow-register": "workspace:^",
|
||||
"@uncaged/workflow-runtime": "workspace:^",
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@uncaged/workflow-cas": "workspace:^",
|
||||
"@uncaged/workflow-execute": "workspace:^",
|
||||
"@uncaged/workflow-protocol": "workspace:^"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@uncaged/workflow-register':
|
||||
specifier: workspace:*
|
||||
version: link:../workflow-register
|
||||
'@uncaged/workflow-runtime':
|
||||
specifier: workspace:*
|
||||
version: link:../workflow-runtime
|
||||
zod:
|
||||
specifier: ^4.0.0
|
||||
version: 4.4.3
|
||||
devDependencies:
|
||||
'@uncaged/workflow-cas':
|
||||
specifier: workspace:*
|
||||
version: link:../workflow-cas
|
||||
'@uncaged/workflow-execute':
|
||||
specifier: workspace:*
|
||||
version: link:../workflow-execute
|
||||
|
||||
packages:
|
||||
|
||||
zod@4.4.3:
|
||||
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
|
||||
|
||||
snapshots:
|
||||
|
||||
zod@4.4.3: {}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { buildDescriptor } from "@uncaged/workflow-register";
|
||||
|
||||
import { solveIssueTable } from "./moderator.js";
|
||||
import { SOLVE_ISSUE_WORKFLOW_DESCRIPTION, solveIssueRoles } from "./roles.js";
|
||||
|
||||
export function buildSolveIssueDescriptor() {
|
||||
return buildDescriptor({
|
||||
description: SOLVE_ISSUE_WORKFLOW_DESCRIPTION,
|
||||
roles: solveIssueRoles,
|
||||
table: solveIssueTable,
|
||||
});
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import type { RoleDefinition } from "@uncaged/workflow-runtime";
|
||||
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.`;
|
||||
|
||||
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,
|
||||
schema: developerMetaSchema,
|
||||
};
|
||||
@@ -1,32 +0,0 @@
|
||||
import type { WorkflowDefinition } from "@uncaged/workflow-runtime";
|
||||
|
||||
import { solveIssueTable } from "./moderator.js";
|
||||
import { SOLVE_ISSUE_WORKFLOW_DESCRIPTION, type SolveIssueMeta, solveIssueRoles } from "./roles.js";
|
||||
|
||||
export { buildSolveIssueDescriptor } from "./descriptor.js";
|
||||
export {
|
||||
type DeveloperMeta,
|
||||
developerMetaSchema,
|
||||
developerRole,
|
||||
} from "./developer.js";
|
||||
export { solveIssueTable } from "./moderator.js";
|
||||
export {
|
||||
type PreparerMeta,
|
||||
preparerMetaSchema,
|
||||
preparerRole,
|
||||
type SubmitterMeta,
|
||||
submitterMetaSchema,
|
||||
submitterRole,
|
||||
} from "./roles/index.js";
|
||||
export {
|
||||
SOLVE_ISSUE_WORKFLOW_DESCRIPTION,
|
||||
type SolveIssueMeta,
|
||||
type SolveIssueRoles,
|
||||
solveIssueRoles,
|
||||
} from "./roles.js";
|
||||
|
||||
export const solveIssueWorkflowDefinition: WorkflowDefinition<SolveIssueMeta> = {
|
||||
description: SOLVE_ISSUE_WORKFLOW_DESCRIPTION,
|
||||
roles: solveIssueRoles,
|
||||
table: solveIssueTable,
|
||||
};
|
||||
@@ -1,12 +0,0 @@
|
||||
import { END, type ModeratorTable, START } from "@uncaged/workflow-runtime";
|
||||
|
||||
import type { SolveIssueMeta } from "./roles.js";
|
||||
|
||||
const table: ModeratorTable<SolveIssueMeta> = {
|
||||
[START]: [{ condition: "FALLBACK", role: "preparer" }],
|
||||
preparer: [{ condition: "FALLBACK", role: "developer" }],
|
||||
developer: [{ condition: "FALLBACK", role: "submitter" }],
|
||||
submitter: [{ condition: "FALLBACK", role: END }],
|
||||
};
|
||||
|
||||
export { table as solveIssueTable };
|
||||
@@ -1,23 +0,0 @@
|
||||
import type { RoleDefinition } from "@uncaged/workflow-runtime";
|
||||
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 =
|
||||
"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;
|
||||
developer: DeveloperMeta;
|
||||
submitter: SubmitterMeta;
|
||||
};
|
||||
|
||||
export type SolveIssueRoles = {
|
||||
[K in keyof SolveIssueMeta]: RoleDefinition<SolveIssueMeta[K]>;
|
||||
};
|
||||
|
||||
export const solveIssueRoles: SolveIssueRoles = {
|
||||
preparer: preparerRole,
|
||||
developer: developerRole,
|
||||
submitter: submitterRole,
|
||||
};
|
||||
@@ -1,6 +0,0 @@
|
||||
export {
|
||||
type PreparerMeta,
|
||||
preparerMetaSchema,
|
||||
preparerRole,
|
||||
} from "./preparer.js";
|
||||
export { type SubmitterMeta, submitterMetaSchema, submitterRole } from "./submitter.js";
|
||||
@@ -1,48 +0,0 @@
|
||||
import type { RoleDefinition } from "@uncaged/workflow-runtime";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
const toolchainSchema = z.object({
|
||||
packageManager: z.union([z.string(), z.null()]),
|
||||
testCommand: z.union([z.string(), z.null()]),
|
||||
lintCommand: z.union([z.string(), z.null()]),
|
||||
buildCommand: z.union([z.string(), z.null()]),
|
||||
});
|
||||
|
||||
export const preparerMetaSchema = z.object({
|
||||
repoPath: z.string(),
|
||||
defaultBranch: z.string(),
|
||||
conventions: z.union([z.string(), z.null()]),
|
||||
toolchain: toolchainSchema,
|
||||
});
|
||||
|
||||
export type PreparerMeta = z.infer<typeof preparerMetaSchema>;
|
||||
|
||||
const PREPARER_SYSTEM = `You are a **preparer** for a software task. Your job is to locate (or clone) the target repository locally, ensure it is up to date, and gather project context before work begins.
|
||||
|
||||
## Responsibilities
|
||||
|
||||
1. Parse the issue/task prompt to identify the target repository (URL, org/repo, or name).
|
||||
2. Search for an existing local clone in these locations (in order):
|
||||
- ~/Code/<repo-name>/
|
||||
- ~/repos/<repo-name>/
|
||||
- ~/Code/<org>/<repo-name>/
|
||||
- ~/repos/<org>/<repo-name>/
|
||||
3. If not found locally, \`git clone\` it into ~/repos/<repo-name>/.
|
||||
4. \`git checkout main && git pull\` (or the default branch) to ensure latest.
|
||||
5. Read project conventions: \`CLAUDE.md\`, \`CONTRIBUTING.md\`, \`.cursor/rules/*.mdc\`, \`CONVENTIONS.md\`.
|
||||
6. Detect toolchain: package manager, test runner, linter, build system.
|
||||
|
||||
## Output
|
||||
|
||||
Report your findings as structured data:
|
||||
- **repoPath**: absolute path to the local repo
|
||||
- **defaultBranch**: the default branch name (e.g. "main")
|
||||
- **conventions**: a summary of project conventions found, or null if none
|
||||
- **toolchain**: detected commands for packageManager, testCommand, lintCommand, buildCommand (null if not detected)`;
|
||||
|
||||
export const preparerRole: RoleDefinition<PreparerMeta> = {
|
||||
description:
|
||||
"Locates or clones the target repository, ensures it is up to date, and gathers project context (conventions, toolchain).",
|
||||
systemPrompt: PREPARER_SYSTEM,
|
||||
schema: preparerMetaSchema,
|
||||
};
|
||||
@@ -1,38 +0,0 @@
|
||||
import type { RoleDefinition } from "@uncaged/workflow-runtime";
|
||||
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.`;
|
||||
|
||||
export const submitterRole: RoleDefinition<SubmitterMeta> = {
|
||||
description: "Pushes the developer's branch to the remote and opens a pull request.",
|
||||
systemPrompt: SUBMITTER_SYSTEM,
|
||||
schema: submitterMetaSchema,
|
||||
};
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"references": [{ "path": "../workflow-register" }, { "path": "../workflow-runtime" }]
|
||||
}
|
||||
Reference in New Issue
Block a user