feat: Phase 3 — workflow JSON definitions in CAS #298

Merged
xiaoju merged 1 commits from feat/294-phase3-workflow-json into main 2026-05-18 02:27:56 +00:00
13 changed files with 1023 additions and 1 deletions
+4
View File
@@ -4,6 +4,10 @@
"workspaces": [
"packages/*"
],
"overrides": {
"@uncaged/json-cas": "file:../json-cas/packages/json-cas",
"@uncaged/json-cas-workflow": "file:../json-cas/packages/json-cas-workflow"
},
"scripts": {
"build": "bunx tsc --build",
"check": "bunx tsc --build && biome check . && bash scripts/lint-log-tags.sh",
@@ -0,0 +1,403 @@
import { describe, expect, test } from "bun:test";
import type { CasNode } from "@uncaged/json-cas";
import { createMemoryStore, refs, validate, walk } from "@uncaged/json-cas";
import { registerWorkflowSchemas } from "@uncaged/json-cas-workflow";
import {
developWorkflow,
END,
loadWorkflow,
registerWorkflow,
START,
solveIssueWorkflow,
} from "../src/index.js";
// ─────────────────────────────────────────────────────────────────────────────
// Step 1: Bootstrap — registerWorkflowSchemas returns all 11 schema hashes
// ─────────────────────────────────────────────────────────────────────────────
describe("Step 1: registerWorkflowSchemas", () => {
test("returns 11 distinct 13-char Crockford Base32 hashes", async () => {
const store = createMemoryStore();
const hashes = await registerWorkflowSchemas(store);
const values = Object.values(hashes);
expect(values).toHaveLength(11);
for (const h of values) {
expect(h).toHaveLength(13);
expect(h).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
}
expect(new Set(values).size).toBe(11);
});
test("is idempotent across multiple calls", async () => {
const store = createMemoryStore();
const first = await registerWorkflowSchemas(store);
const second = await registerWorkflowSchemas(store);
for (const key of Object.keys(first) as (keyof typeof first)[]) {
expect(first[key]).toBe(second[key]);
}
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Step 2: registerWorkflow — stores roles + workflow in CAS
// ─────────────────────────────────────────────────────────────────────────────
describe("Step 2: registerWorkflow", () => {
test("returns a 13-char Crockford Base32 workflow hash", async () => {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
const hash = await registerWorkflow(store, typeHashes, solveIssueWorkflow);
expect(hash).toHaveLength(13);
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
});
test("is idempotent: registering the same workflow twice returns the same hash", async () => {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
const hash1 = await registerWorkflow(store, typeHashes, solveIssueWorkflow);
const hash2 = await registerWorkflow(store, typeHashes, solveIssueWorkflow);
expect(hash1).toBe(hash2);
});
test("workflow node is present in the store after registration", async () => {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
const hash = await registerWorkflow(store, typeHashes, solveIssueWorkflow);
expect(store.get(hash)).not.toBeNull();
});
test("stores role nodes — one per role in the definition", async () => {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
const wfHash = await registerWorkflow(store, typeHashes, solveIssueWorkflow);
const wfNode = store.get(wfHash) as CasNode;
const roles = (wfNode.payload as Record<string, unknown>).roles as Record<string, string>;
expect(Object.keys(roles)).toHaveLength(Object.keys(solveIssueWorkflow.roles).length);
for (const roleHash of Object.values(roles)) {
expect(store.get(roleHash)).not.toBeNull();
}
});
test("stores role-schema nodes — one per role", async () => {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
const wfHash = await registerWorkflow(store, typeHashes, developWorkflow);
const wfNode = store.get(wfHash) as CasNode;
const roles = (wfNode.payload as Record<string, unknown>).roles as Record<string, string>;
for (const roleHash of Object.values(roles)) {
const roleNode = store.get(roleHash) as CasNode;
const schemaHash = (roleNode.payload as Record<string, string>).schema;
expect(store.get(schemaHash)).not.toBeNull();
}
});
test("workflow payload contains correct name and description", async () => {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
const hash = await registerWorkflow(store, typeHashes, developWorkflow);
const node = store.get(hash) as CasNode;
const payload = node.payload as Record<string, unknown>;
expect(payload.name).toBe("develop");
expect(payload.description).toBe(developWorkflow.description);
});
test("workflow payload contains moderator rules array", async () => {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
const hash = await registerWorkflow(store, typeHashes, solveIssueWorkflow);
const node = store.get(hash) as CasNode;
const payload = node.payload as Record<string, unknown>;
expect(Array.isArray(payload.moderator)).toBe(true);
const rules = payload.moderator as Array<{ from: string; to: string; when: string | null }>;
expect(rules.some((r) => r.from === START)).toBe(true);
expect(rules.some((r) => r.to === END)).toBe(true);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Step 3: loadWorkflow — round-trip hydration from CAS
// ─────────────────────────────────────────────────────────────────────────────
describe("Step 3: loadWorkflow", () => {
test("returns null for an unknown hash", async () => {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
expect(loadWorkflow(store, typeHashes, "AAAAAAAAAAAAA")).toBeNull();
});
test("hydrates solve-issue workflow with correct name and description", async () => {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
const hash = await registerWorkflow(store, typeHashes, solveIssueWorkflow);
const result = loadWorkflow(store, typeHashes, hash);
expect(result).not.toBeNull();
expect(result?.name).toBe("solve-issue");
expect(result?.description).toBe(solveIssueWorkflow.description);
});
test("hydrated workflow contains all roles", async () => {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
const hash = await registerWorkflow(store, typeHashes, solveIssueWorkflow);
const result = loadWorkflow(store, typeHashes, hash);
const expectedRoles = Object.keys(solveIssueWorkflow.roles);
expect(Object.keys(result?.roles ?? {})).toEqual(expect.arrayContaining(expectedRoles));
});
test("hydrated role has correct systemPrompt and description", async () => {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
const hash = await registerWorkflow(store, typeHashes, solveIssueWorkflow);
const result = loadWorkflow(store, typeHashes, hash);
const preparer = result?.roles.preparer;
expect(preparer?.description).toBe(solveIssueWorkflow.roles.preparer.description);
expect(preparer?.systemPrompt).toBe(solveIssueWorkflow.roles.preparer.systemPrompt);
expect(preparer?.extractPrompt).toBe(solveIssueWorkflow.roles.preparer.extractPrompt);
});
test("hydrated role includes the JSON Schema", async () => {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
const hash = await registerWorkflow(store, typeHashes, solveIssueWorkflow);
const result = loadWorkflow(store, typeHashes, hash);
const schema = result?.roles.preparer?.schema;
expect(schema).toBeDefined();
expect((schema as Record<string, unknown>)?.type).toBe("object");
});
test("hydrated workflow contains moderator rules matching the definition", async () => {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
const hash = await registerWorkflow(store, typeHashes, developWorkflow);
const result = loadWorkflow(store, typeHashes, hash);
expect(result?.moderator).toHaveLength(developWorkflow.moderator.length);
expect(result?.moderator[0]).toEqual(developWorkflow.moderator[0]);
});
test("develop workflow round-trip has 5 roles", async () => {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
const hash = await registerWorkflow(store, typeHashes, developWorkflow);
const result = loadWorkflow(store, typeHashes, hash);
expect(Object.keys(result?.roles ?? {})).toHaveLength(5);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Step 4: validate() — CAS nodes pass validation against their schemas
// ─────────────────────────────────────────────────────────────────────────────
describe("Step 4: validate", () => {
test("workflow node is valid against its schema", async () => {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
const hash = await registerWorkflow(store, typeHashes, solveIssueWorkflow);
const node = store.get(hash) as CasNode;
expect(validate(store, node)).toBe(true);
});
test("role nodes are valid against their schema", async () => {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
const wfHash = await registerWorkflow(store, typeHashes, solveIssueWorkflow);
const wfNode = store.get(wfHash) as CasNode;
const roles = (wfNode.payload as Record<string, unknown>).roles as Record<string, string>;
for (const roleHash of Object.values(roles)) {
const roleNode = store.get(roleHash) as CasNode;
expect(validate(store, roleNode)).toBe(true);
}
});
test("role-schema nodes are valid against their schema", async () => {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
const wfHash = await registerWorkflow(store, typeHashes, developWorkflow);
const wfNode = store.get(wfHash) as CasNode;
const roles = (wfNode.payload as Record<string, unknown>).roles as Record<string, string>;
for (const roleHash of Object.values(roles)) {
const roleNode = store.get(roleHash) as CasNode;
const schemaHash = (roleNode.payload as Record<string, string>).schema;
const schemaNode = store.get(schemaHash) as CasNode;
expect(validate(store, schemaNode)).toBe(true);
}
});
test("workflow node with wrong type for roles fails validation", async () => {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
const badHash = await store.put(typeHashes.workflow, {
name: "bad",
description: "bad",
roles: "not-an-object",
moderator: [],
});
const node = store.get(badHash) as CasNode;
expect(validate(store, node)).toBe(false);
});
test("role node missing required field fails validation", async () => {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
const badHash = await store.put(typeHashes.role, {
name: "bad",
description: "d",
systemPrompt: "s",
});
const node = store.get(badHash) as CasNode;
expect(validate(store, node)).toBe(false);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Step 5: refs() — extracts cas_ref hashes from workflow and role nodes
// ─────────────────────────────────────────────────────────────────────────────
describe("Step 5: refs", () => {
test("workflow node refs() returns one hash per role", async () => {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
const hash = await registerWorkflow(store, typeHashes, solveIssueWorkflow);
const node = store.get(hash) as CasNode;
const roleCount = Object.keys(solveIssueWorkflow.roles).length;
expect(refs(store, node)).toHaveLength(roleCount);
});
test("role node refs() returns exactly one hash (the schema)", async () => {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
const wfHash = await registerWorkflow(store, typeHashes, solveIssueWorkflow);
const wfNode = store.get(wfHash) as CasNode;
const roles = (wfNode.payload as Record<string, unknown>).roles as Record<string, string>;
const firstRoleHash = Object.values(roles)[0];
const roleNode = store.get(firstRoleHash) as CasNode;
const roleRefs = refs(store, roleNode);
expect(roleRefs).toHaveLength(1);
});
test("role refs() points to the role-schema node", async () => {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
const wfHash = await registerWorkflow(store, typeHashes, solveIssueWorkflow);
const wfNode = store.get(wfHash) as CasNode;
const roles = (wfNode.payload as Record<string, unknown>).roles as Record<string, string>;
const firstRoleHash = Object.values(roles)[0];
const roleNode = store.get(firstRoleHash) as CasNode;
const schemaHash = refs(store, roleNode)[0];
const schemaNode = store.get(schemaHash);
expect(schemaNode).not.toBeNull();
expect(schemaNode?.type).toBe(typeHashes.roleSchema);
});
test("develop workflow node refs() returns one hash per role (5 roles)", async () => {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
const hash = await registerWorkflow(store, typeHashes, developWorkflow);
const node = store.get(hash) as CasNode;
expect(refs(store, node)).toHaveLength(5);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Step 6: walk() — BFS traversal visits workflow, role, and schema nodes
// ─────────────────────────────────────────────────────────────────────────────
describe("Step 6: walk", () => {
test("walk from workflow hash visits workflow + role + schema nodes", async () => {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
const wfHash = await registerWorkflow(store, typeHashes, solveIssueWorkflow);
const visited = new Set<string>();
walk(store, wfHash, (h) => visited.add(h));
// workflow node itself
expect(visited.has(wfHash)).toBe(true);
// all role nodes and their schema nodes should be reachable
const wfNode = store.get(wfHash) as CasNode;
const roles = (wfNode.payload as Record<string, unknown>).roles as Record<string, string>;
for (const roleHash of Object.values(roles)) {
expect(visited.has(roleHash)).toBe(true);
const roleNode = store.get(roleHash) as CasNode;
const schemaHash = refs(store, roleNode)[0];
expect(visited.has(schemaHash)).toBe(true);
}
});
test("walk visits all 5 role nodes for develop workflow", async () => {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
const wfHash = await registerWorkflow(store, typeHashes, developWorkflow);
const visited = new Set<string>();
walk(store, wfHash, (h) => visited.add(h));
const wfNode = store.get(wfHash) as CasNode;
const roles = (wfNode.payload as Record<string, unknown>).roles as Record<string, string>;
expect(Object.values(roles).every((rh) => visited.has(rh))).toBe(true);
});
test("walk total node count = 1 workflow + N roles + N schemas", async () => {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
const wfHash = await registerWorkflow(store, typeHashes, solveIssueWorkflow);
const roleCount = Object.keys(solveIssueWorkflow.roles).length;
const visited = new Set<string>();
walk(store, wfHash, (h) => visited.add(h));
// 1 workflow + roleCount roles + roleCount schemas
expect(visited.size).toBe(1 + roleCount + roleCount);
});
test("walk handles two workflows sharing a schema node — visits it only once", async () => {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
// Register the same workflow twice — second call is idempotent, same hashes
const hash1 = await registerWorkflow(store, typeHashes, solveIssueWorkflow);
const hash2 = await registerWorkflow(store, typeHashes, solveIssueWorkflow);
expect(hash1).toBe(hash2);
const visited = new Set<string>();
walk(store, hash1, (h) => visited.add(h));
// Each node should be counted exactly once despite any shared refs
const roleCount = Object.keys(solveIssueWorkflow.roles).length;
expect(visited.size).toBe(1 + roleCount + roleCount);
});
test("walk with unknown starting hash visits nothing", () => {
const store = createMemoryStore();
const visited: string[] = [];
walk(store, "AAAAAAAAAAAAA", (h) => visited.push(h));
expect(visited).toHaveLength(0);
});
});
+30
View File
@@ -0,0 +1,30 @@
{
"name": "@uncaged/workflow-json-def",
"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/json-cas": "file:../../../json-cas/packages/json-cas",
"@uncaged/json-cas-workflow": "file:../../../json-cas/packages/json-cas-workflow"
},
"devDependencies": {
"typescript": "^5.8.3"
},
"publishConfig": {
"access": "public"
}
}
@@ -0,0 +1,2 @@
export const START = "__start__" as const;
export const END = "__end__" as const;
@@ -0,0 +1,284 @@
import type { WorkflowInput } from "../types.js";
import { END, START } from "./constants.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).";
// ── JSONata conditions ────────────────────────────────────────────────────────
/**
* True when the planner aborted due to insufficient information.
* Translates the plannerAborted TypeScript condition to JSONata.
*/
const PLANNER_ABORTED = "$boolean(steps[role='planner'].meta.status = 'aborted')";
/**
* True when all planned phases have been completed by the coder.
*
* Logic:
* - No planned phases → true (nothing to complete)
* - Last phase hash appears in any coder step's completedPhase → true
* - Every phase hash appears in some coder's completedPhase → true (via count check)
*/
const ALL_PHASES_COMPLETE = [
"(",
" $plannerMeta := steps[role='planner'].meta;",
" $phases := $plannerMeta.status = 'planned' ? $plannerMeta.phases : [];",
" $count($phases) = 0 ? true :",
" (",
" $lastHash := $phases[-1].hash;",
" $completedHashes := steps[role='coder'].meta.completedPhase;",
" $lastHash in $completedHashes or",
" $count($phases[$not(hash in $completedHashes)]) = 0",
" )",
")",
].join(" ");
/** True when the most recent reviewer step reported approved. */
const REVIEW_APPROVED = "steps[-1].meta.status = 'approved'";
/** True when the most recent tester step reported passed. */
const TESTS_PASSED = "steps[-1].meta.status = 'passed'";
// ── Workflow definition ───────────────────────────────────────────────────────
export const developWorkflow: WorkflowInput = {
name: "develop",
description: DEVELOP_WORKFLOW_DESCRIPTION,
roles: {
planner: {
description: "Breaks the task into sequential phases for the coder.",
systemPrompt: `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.`,
extractPrompt:
"Extract the planner result as JSON. Use status='planned' with phases array (hash+title), or status='aborted' with reason.",
schema: {
type: "object",
oneOf: [
{
required: ["status", "phases"],
properties: {
status: { type: "string", enum: ["planned"] },
phases: {
type: "array",
items: {
type: "object",
required: ["hash", "title"],
properties: {
hash: { type: "string" },
title: { type: "string" },
},
},
},
},
},
{
required: ["status", "reason"],
properties: {
status: { type: "string", enum: ["aborted"] },
reason: { type: "string" },
},
},
],
},
},
coder: {
description:
"Implements the next incomplete planner phase and reports structured completion metadata.",
systemPrompt: `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.`,
extractPrompt:
"Extract the coder result as JSON with fields: completedPhase (hash string), filesChanged (array), summary.",
schema: {
type: "object",
required: ["completedPhase", "filesChanged", "summary"],
properties: {
completedPhase: { type: "string" },
filesChanged: { type: "array", items: { type: "string" } },
summary: { type: "string" },
},
},
},
reviewer: {
description: "Runs git diff checks and sets approved when the change is ready.",
systemPrompt: `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.`,
extractPrompt:
"Extract the reviewer verdict as JSON. Use status='approved', or status='rejected' with issues array.",
schema: {
type: "object",
oneOf: [
{
required: ["status"],
properties: {
status: { type: "string", enum: ["approved"] },
},
},
{
required: ["status", "issues"],
properties: {
status: { type: "string", enum: ["rejected"] },
issues: { type: "array", items: { type: "string" } },
},
},
],
},
},
tester: {
description: "Runs test, build, and lint commands and reports pass or fail with details.",
systemPrompt: `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.`,
extractPrompt:
"Extract the tester result as JSON. Use status='passed' or status='failed', both with details string.",
schema: {
type: "object",
oneOf: [
{
required: ["status", "details"],
properties: {
status: { type: "string", enum: ["passed"] },
details: { type: "string" },
},
},
{
required: ["status", "details"],
properties: {
status: { type: "string", enum: ["failed"] },
details: { type: "string" },
},
},
],
},
},
committer: {
description: "Creates a branch and commits changes.",
systemPrompt:
"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.",
extractPrompt:
"Extract the committer result as JSON. Use status='committed' with branch+commitSha, status='recoverable' with error+logRef, or status='unrecoverable' with error+logRef.",
schema: {
type: "object",
oneOf: [
{
required: ["status", "branch", "commitSha"],
properties: {
status: { type: "string", enum: ["committed"] },
branch: { type: "string" },
commitSha: { type: "string" },
},
},
{
required: ["status", "error", "logRef"],
properties: {
status: { type: "string", enum: ["recoverable"] },
error: { type: "string" },
logRef: { anyOf: [{ type: "string" }, { type: "null" }] },
},
},
{
required: ["status", "error", "logRef"],
properties: {
status: { type: "string", enum: ["unrecoverable"] },
error: { type: "string" },
logRef: { anyOf: [{ type: "string" }, { type: "null" }] },
},
},
],
},
},
},
moderator: [
{ from: START, to: "planner", when: null },
{ from: "planner", to: END, when: PLANNER_ABORTED },
{ from: "planner", to: "coder", when: null },
{ from: "coder", to: "reviewer", when: ALL_PHASES_COMPLETE },
{ from: "coder", to: "coder", when: null },
{ from: "reviewer", to: "tester", when: REVIEW_APPROVED },
{ from: "reviewer", to: "coder", when: null },
{ from: "tester", to: "committer", when: TESTS_PASSED },
{ from: "tester", to: "coder", when: null },
{ from: "committer", to: END, when: null },
],
};
@@ -0,0 +1,3 @@
export { END, START } from "./constants.js";
export { DEVELOP_WORKFLOW_DESCRIPTION, developWorkflow } from "./develop.js";
export { SOLVE_ISSUE_WORKFLOW_DESCRIPTION, solveIssueWorkflow } from "./solve-issue.js";
@@ -0,0 +1,128 @@
import type { WorkflowInput } from "../types.js";
import { END, START } from "./constants.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 const solveIssueWorkflow: WorkflowInput = {
name: "solve-issue",
description: SOLVE_ISSUE_WORKFLOW_DESCRIPTION,
roles: {
preparer: {
description:
"Locates or clones the target repository, ensures it is up to date, and gathers project context (conventions, toolchain).",
systemPrompt: `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)`,
extractPrompt:
"Extract the structured repo preparation result as JSON with fields: repoPath, defaultBranch, conventions, toolchain.",
schema: {
type: "object",
required: ["repoPath", "defaultBranch", "conventions", "toolchain"],
properties: {
repoPath: { type: "string" },
defaultBranch: { type: "string" },
conventions: { anyOf: [{ type: "string" }, { type: "null" }] },
toolchain: {
type: "object",
required: ["packageManager", "testCommand", "lintCommand", "buildCommand"],
properties: {
packageManager: { anyOf: [{ type: "string" }, { type: "null" }] },
testCommand: { anyOf: [{ type: "string" }, { type: "null" }] },
lintCommand: { anyOf: [{ type: "string" }, { type: "null" }] },
buildCommand: { anyOf: [{ type: "string" }, { type: "null" }] },
},
},
},
},
},
developer: {
description:
"Delegates the actual implementation to the develop workflow (workflow-as-agent). Produces a summary by traversing the child thread's Merkle DAG.",
systemPrompt: `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.`,
extractPrompt:
"Extract the developer result as JSON with fields: branch, commitSha, filesChanged (array), summary.",
schema: {
type: "object",
required: ["branch", "commitSha", "filesChanged", "summary"],
properties: {
branch: { type: "string" },
commitSha: { type: "string" },
filesChanged: { type: "array", items: { type: "string" } },
summary: { type: "string" },
},
},
},
submitter: {
description: "Pushes the developer's branch to the remote and opens a pull request.",
systemPrompt: `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.`,
extractPrompt:
"Extract the submitter result as JSON. Use status='submitted' with prUrl, or status='failed' with error.",
schema: {
type: "object",
oneOf: [
{
required: ["status", "prUrl"],
properties: {
status: { type: "string", enum: ["submitted"] },
prUrl: { type: "string" },
},
},
{
required: ["status", "error"],
properties: {
status: { type: "string", enum: ["failed"] },
error: { type: "string" },
},
},
],
},
},
},
moderator: [
{ from: START, to: "preparer", when: null },
{ from: "preparer", to: "developer", when: null },
{ from: "developer", to: "submitter", when: null },
{ from: "submitter", to: END, when: null },
],
};
+11
View File
@@ -0,0 +1,11 @@
export {
DEVELOP_WORKFLOW_DESCRIPTION,
developWorkflow,
END,
SOLVE_ISSUE_WORKFLOW_DESCRIPTION,
START,
solveIssueWorkflow,
} from "./definitions/index.js";
export { loadWorkflow } from "./load.js";
export { registerWorkflow } from "./register.js";
export type { HydratedRole, HydratedWorkflow, RoleInput, WorkflowInput } from "./types.js";
+56
View File
@@ -0,0 +1,56 @@
import type { Hash, Store } from "@uncaged/json-cas";
import type {
RolePayload,
WorkflowPayload,
WorkflowSchemaHashes,
} from "@uncaged/json-cas-workflow";
import type { HydratedRole, HydratedWorkflow } from "./types.js";
/**
* Load a workflow from CAS by its hash.
*
* Reads the workflow node, then for each role ref reads the role node and
* its associated role-schema node. Returns a fully hydrated workflow structure.
*
* Returns null if the workflow node cannot be found.
*/
export function loadWorkflow(
store: Store,
_typeHashes: WorkflowSchemaHashes,
workflowHash: Hash,
): HydratedWorkflow | null {
const workflowNode = store.get(workflowHash);
if (workflowNode === null) {
return null;
}
const wf = workflowNode.payload as WorkflowPayload;
const roles: Record<string, HydratedRole> = {};
for (const [roleName, roleHash] of Object.entries(wf.roles)) {
const roleNode = store.get(roleHash);
if (roleNode === null) {
continue;
}
const rolePayload = roleNode.payload as RolePayload;
const schemaNode = store.get(rolePayload.schema);
const schema = schemaNode !== null ? (schemaNode.payload as Record<string, unknown>) : {};
roles[roleName] = {
name: rolePayload.name,
description: rolePayload.description,
systemPrompt: rolePayload.systemPrompt,
extractPrompt: rolePayload.extractPrompt,
schema,
};
}
return {
name: wf.name,
description: wf.description,
roles,
moderator: wf.moderator,
};
}
@@ -0,0 +1,43 @@
import type { Hash, Store } from "@uncaged/json-cas";
import type { WorkflowSchemaHashes } from "@uncaged/json-cas-workflow";
import type { WorkflowInput } from "./types.js";
/**
* Store a workflow definition in CAS.
*
* For each role:
* 1. Store the role's JSON Schema as a role-schema node → schemaHash
* 2. Store the role as a role node referencing schemaHash → roleHash
*
* Then store the workflow node referencing all role hashes and moderator rules.
* Returns the workflow CAS hash.
*/
export async function registerWorkflow(
store: Store,
typeHashes: WorkflowSchemaHashes,
workflowDef: WorkflowInput,
): Promise<Hash> {
const roleHashes: Record<string, Hash> = {};
for (const [roleName, roleInput] of Object.entries(workflowDef.roles)) {
const schemaHash = await store.put(typeHashes.roleSchema, roleInput.schema);
const roleHash = await store.put(typeHashes.role, {
name: roleName,
description: roleInput.description,
systemPrompt: roleInput.systemPrompt,
extractPrompt: roleInput.extractPrompt,
schema: schemaHash,
});
roleHashes[roleName] = roleHash;
}
const workflowHash = await store.put(typeHashes.workflow, {
name: workflowDef.name,
description: workflowDef.description,
roles: roleHashes,
moderator: workflowDef.moderator,
});
return workflowHash;
}
+35
View File
@@ -0,0 +1,35 @@
import type { JSONSchema } from "@uncaged/json-cas";
import type { WorkflowTransition } from "@uncaged/json-cas-workflow";
// ── Input types (high-level workflow definition) ──────────────────────────────
export type RoleInput = {
description: string;
systemPrompt: string;
extractPrompt: string;
schema: JSONSchema;
};
export type WorkflowInput = {
name: string;
description: string;
roles: Record<string, RoleInput>;
moderator: WorkflowTransition[];
};
// ── Output types (hydrated workflow from CAS) ─────────────────────────────────
export type HydratedRole = {
name: string;
description: string;
systemPrompt: string;
extractPrompt: string;
schema: JSONSchema;
};
export type HydratedWorkflow = {
name: string;
description: string;
roles: Record<string, HydratedRole>;
moderator: WorkflowTransition[];
};
+22
View File
@@ -0,0 +1,22 @@
{
"references": [],
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"exactOptionalPropertyTypes": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"composite": true,
"outDir": "dist",
"rootDir": "src",
"types": ["bun-types"]
},
"include": ["src/**/*.ts"]
}
+2 -1
View File
@@ -32,6 +32,7 @@
{ "path": "packages/workflow-agent-react" },
{ "path": "packages/cli-workflow" },
{ "path": "packages/workflow-template-solve-issue" },
{ "path": "packages/workflow-template-develop" }
{ "path": "packages/workflow-template-develop" },
{ "path": "packages/workflow-json-def" }
]
}