From 31695e89a847b09a4d652290424934f8a71b4b6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=9F=E6=9C=88?= Date: Fri, 22 May 2026 18:38:18 +0800 Subject: [PATCH] feat: add workflow-agent-claude-code package Claude Code CLI adapter for the workflow engine, mirroring workflow-agent-hermes architecture. Spawns `claude -p` with `--output-format json` for structured output parsing. Refs #391 --- .../__tests__/claude-code.test.ts | 58 +++++++ .../__tests__/session-detail.test.ts | 115 +++++++++++++ .../workflow-agent-claude-code/package.json | 33 ++++ .../src/claude-code.ts | 152 ++++++++++++++++++ .../workflow-agent-claude-code/src/cli.ts | 6 + .../workflow-agent-claude-code/src/index.ts | 6 + .../workflow-agent-claude-code/src/schemas.ts | 25 +++ .../src/session-detail.ts | 79 +++++++++ .../workflow-agent-claude-code/src/types.ts | 19 +++ .../workflow-agent-claude-code/tsconfig.json | 6 + 10 files changed, 499 insertions(+) create mode 100644 packages/workflow-agent-claude-code/__tests__/claude-code.test.ts create mode 100644 packages/workflow-agent-claude-code/__tests__/session-detail.test.ts create mode 100644 packages/workflow-agent-claude-code/package.json create mode 100644 packages/workflow-agent-claude-code/src/claude-code.ts create mode 100644 packages/workflow-agent-claude-code/src/cli.ts create mode 100644 packages/workflow-agent-claude-code/src/index.ts create mode 100644 packages/workflow-agent-claude-code/src/schemas.ts create mode 100644 packages/workflow-agent-claude-code/src/session-detail.ts create mode 100644 packages/workflow-agent-claude-code/src/types.ts create mode 100644 packages/workflow-agent-claude-code/tsconfig.json diff --git a/packages/workflow-agent-claude-code/__tests__/claude-code.test.ts b/packages/workflow-agent-claude-code/__tests__/claude-code.test.ts new file mode 100644 index 0000000..6aac7b5 --- /dev/null +++ b/packages/workflow-agent-claude-code/__tests__/claude-code.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from "bun:test"; +import type { AgentContext } from "@uncaged/workflow-agent-kit"; +import { buildClaudeCodePrompt } from "../src/claude-code.js"; + +function makeCtx(overrides: Partial = {}): AgentContext { + return { + workflow: { + roles: { + developer: { + goal: "Write code", + capabilities: ["coding"], + procedure: "1. Read spec\n2. Write code", + output: "List files changed", + meta: null, + }, + }, + conditions: {}, + graph: {}, + }, + role: "developer", + start: { prompt: "Fix the bug", workflowHash: "abc123", threadId: "t1" }, + steps: [], + store: {} as AgentContext["store"], + outputFormatInstruction: "Use YAML frontmatter", + ...overrides, + }; +} + +describe("buildClaudeCodePrompt", () => { + test("assembles outputFormatInstruction + role prompt + task prompt", () => { + const result = buildClaudeCodePrompt(makeCtx()); + expect(result).toMatch(/^Use YAML frontmatter/); + expect(result).toContain("Write code"); + expect(result).toContain("## Task\nFix the bug"); + }); + + test("includes previous steps as history summary", () => { + const ctx = makeCtx({ + steps: [{ role: "planner", output: '{"plan":"do X"}', agent: "hermes" }], + }); + const result = buildClaudeCodePrompt(ctx); + expect(result).toContain("## Previous Steps"); + expect(result).toContain("Step 1: planner"); + expect(result).toContain("do X"); + }); + + test("omits history section when steps array is empty", () => { + const result = buildClaudeCodePrompt(makeCtx({ steps: [] })); + expect(result).not.toContain("## Previous Steps"); + }); + + test("works without outputFormatInstruction", () => { + const result = buildClaudeCodePrompt(makeCtx({ outputFormatInstruction: "" })); + expect(result).not.toMatch(/^\s*\n/); + expect(result).toContain("Write code"); + expect(result).toContain("## Task"); + }); +}); diff --git a/packages/workflow-agent-claude-code/__tests__/session-detail.test.ts b/packages/workflow-agent-claude-code/__tests__/session-detail.test.ts new file mode 100644 index 0000000..80eb809 --- /dev/null +++ b/packages/workflow-agent-claude-code/__tests__/session-detail.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, test } from "bun:test"; +import { createMemoryStore, walk } from "@uncaged/json-cas"; +import { + parseClaudeCodeJsonOutput, + storeClaudeCodeDetail, + storeClaudeCodeRawOutput, +} from "../src/session-detail.js"; +import type { ClaudeCodeParsedResult } from "../src/types.js"; + +describe("parseClaudeCodeJsonOutput", () => { + test("parses valid claude -p --output-format json output", () => { + const stdout = JSON.stringify({ + type: "result", + subtype: "success", + result: "Done fixing bug", + session_id: "75e2167f-abc", + num_turns: 3, + total_cost_usd: 0.08, + duration_ms: 10276, + }); + const parsed = parseClaudeCodeJsonOutput(stdout); + expect(parsed).not.toBeNull(); + expect(parsed!.type).toBe("result"); + expect(parsed!.subtype).toBe("success"); + expect(parsed!.result).toBe("Done fixing bug"); + expect(parsed!.sessionId).toBe("75e2167f-abc"); + expect(parsed!.numTurns).toBe(3); + expect(parsed!.totalCostUsd).toBe(0.08); + expect(parsed!.durationMs).toBe(10276); + }); + + test("parses error_max_turns result", () => { + const stdout = JSON.stringify({ + type: "result", + subtype: "error_max_turns", + result: "Ran out of turns", + session_id: "abc-def", + num_turns: 90, + total_cost_usd: 1.5, + duration_ms: 50000, + }); + const parsed = parseClaudeCodeJsonOutput(stdout); + expect(parsed).not.toBeNull(); + expect(parsed!.subtype).toBe("error_max_turns"); + expect(parsed!.result).toBe("Ran out of turns"); + }); + + test("returns null for non-JSON output", () => { + const parsed = parseClaudeCodeJsonOutput("Some random text\nwithout JSON"); + expect(parsed).toBeNull(); + }); + + test("returns null when session_id is missing", () => { + const stdout = JSON.stringify({ type: "result", result: "hi", subtype: "success" }); + const parsed = parseClaudeCodeJsonOutput(stdout); + expect(parsed).toBeNull(); + }); +}); + +describe("storeClaudeCodeDetail", () => { + test("stores claude-code-detail CAS node and returns output + detailHash", async () => { + const store = createMemoryStore(); + const parsed: ClaudeCodeParsedResult = { + type: "result", + subtype: "success", + result: "The answer", + sessionId: "abc-123", + numTurns: 5, + totalCostUsd: 0.12, + durationMs: 15000, + }; + + const { detailHash, output, sessionId } = await storeClaudeCodeDetail(store, parsed); + expect(detailHash).toHaveLength(13); + expect(output).toBe("The answer"); + expect(sessionId).toBe("abc-123"); + + const node = await store.get(detailHash); + expect(node).not.toBeNull(); + expect(node!.payload.sessionId).toBe("abc-123"); + expect(node!.payload.numTurns).toBe(5); + expect(node!.payload.totalCostUsd).toBe(0.12); + expect(node!.payload.durationMs).toBe(15000); + }); + + test("detail node is walkable from root", async () => { + const store = createMemoryStore(); + const parsed: ClaudeCodeParsedResult = { + type: "result", + subtype: "success", + result: "walkable test", + sessionId: "walk-123", + numTurns: 1, + totalCostUsd: 0.01, + durationMs: 1000, + }; + + const { detailHash } = await storeClaudeCodeDetail(store, parsed); + const visited: string[] = []; + walk(store, detailHash, (hash) => visited.push(hash)); + expect(visited.length).toBeGreaterThan(0); + }); +}); + +describe("storeClaudeCodeRawOutput", () => { + test("stores raw text when JSON parsing fails", async () => { + const store = createMemoryStore(); + const rawText = "Claude produced plain text without JSON"; + const hash = await storeClaudeCodeRawOutput(store, rawText); + expect(hash).toHaveLength(13); + const node = await store.get(hash); + expect(node).not.toBeNull(); + expect(node!.payload.text).toBe(rawText); + }); +}); diff --git a/packages/workflow-agent-claude-code/package.json b/packages/workflow-agent-claude-code/package.json new file mode 100644 index 0000000..5d8d0c2 --- /dev/null +++ b/packages/workflow-agent-claude-code/package.json @@ -0,0 +1,33 @@ +{ + "name": "@uncaged/workflow-agent-claude-code", + "version": "0.1.0", + "files": [ + "src", + "dist", + "package.json" + ], + "type": "module", + "bin": { + "uwf-claude-code": "./src/cli.ts" + }, + "exports": { + ".": { + "bun": "./src/index.ts", + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "test": "bun test" + }, + "dependencies": { + "@uncaged/json-cas": "^0.4.0", + "@uncaged/workflow-agent-kit": "workspace:^" + }, + "devDependencies": { + "typescript": "^5.8.3" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/workflow-agent-claude-code/src/claude-code.ts b/packages/workflow-agent-claude-code/src/claude-code.ts new file mode 100644 index 0000000..d54f5c6 --- /dev/null +++ b/packages/workflow-agent-claude-code/src/claude-code.ts @@ -0,0 +1,152 @@ +import { spawn } from "node:child_process"; +import type { Store } from "@uncaged/json-cas"; + +import { + type AgentContext, + type AgentRunResult, + buildRolePrompt, + createAgent, +} from "@uncaged/workflow-agent-kit"; + +import { + parseClaudeCodeJsonOutput, + storeClaudeCodeDetail, + storeClaudeCodeRawOutput, +} from "./session-detail.js"; + +const CLAUDE_COMMAND = "claude"; +const CLAUDE_MAX_TURNS = 90; + +function buildHistorySummary(steps: AgentContext["steps"]): string { + if (steps.length === 0) { + return ""; + } + + const lines: string[] = ["## Previous Steps"]; + for (let i = 0; i < steps.length; i++) { + const step = steps[i]; + if (step === undefined) { + continue; + } + lines.push(""); + lines.push(`### Step ${i + 1}: ${step.role}`); + lines.push(`Output: ${JSON.stringify(step.output)}`); + lines.push(`Agent: ${step.agent}`); + } + return lines.join("\n"); +} + +/** Assemble system prompt, task, and prior step outputs for Claude Code. */ +export function buildClaudeCodePrompt(ctx: AgentContext): string { + const roleDef = ctx.workflow.roles[ctx.role]; + const rolePrompt = roleDef !== undefined ? buildRolePrompt(roleDef) : ""; + const parts: string[] = []; + if (ctx.outputFormatInstruction !== undefined && ctx.outputFormatInstruction !== "") { + parts.push(ctx.outputFormatInstruction, ""); + } + parts.push(rolePrompt, "", "## Task", ctx.start.prompt); + const historyBlock = buildHistorySummary(ctx.steps); + if (historyBlock !== "") { + parts.push("", historyBlock); + } + return parts.join("\n"); +} + +function spawnClaude(args: string[]): Promise<{ stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + const child = spawn(CLAUDE_COMMAND, args, { + env: process.env, + shell: false, + stdio: ["ignore", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + child.stdout?.on("data", (chunk: Buffer) => { + stdout += chunk.toString(); + }); + child.stderr?.on("data", (chunk: Buffer) => { + stderr += chunk.toString(); + }); + + child.on("error", (cause) => { + const message = cause instanceof Error ? cause.message : String(cause); + reject(new Error(`claude spawn failed: ${message}`)); + }); + + child.on("close", (code) => { + if (code === 0) { + resolve({ stdout, stderr }); + return; + } + const detail = stderr.trim() !== "" ? ` stderr=${stderr.trim()}` : ""; + reject(new Error(`claude exited with code ${code ?? "null"}${detail}`)); + }); + }); +} + +function spawnClaudeRun(prompt: string): Promise<{ stdout: string; stderr: string }> { + return spawnClaude([ + "-p", + prompt, + "--output-format", + "json", + "--dangerously-skip-permissions", + "--max-turns", + String(CLAUDE_MAX_TURNS), + ]); +} + +function spawnClaudeResume( + sessionId: string, + message: string, +): Promise<{ stdout: string; stderr: string }> { + return spawnClaude([ + "-p", + message, + "--resume", + sessionId, + "--output-format", + "json", + "--dangerously-skip-permissions", + "--max-turns", + String(CLAUDE_MAX_TURNS), + ]); +} + +async function processClaudeOutput(stdout: string, store: Store): Promise { + const parsed = parseClaudeCodeJsonOutput(stdout); + + if (parsed !== null) { + const { detailHash, output, sessionId } = await storeClaudeCodeDetail(store, parsed); + return { output, detailHash, sessionId }; + } + + // Non-JSON fallback + const detailHash = await storeClaudeCodeRawOutput(store, stdout); + return { output: stdout, detailHash }; +} + +async function runClaudeCode(ctx: AgentContext): Promise { + const fullPrompt = buildClaudeCodePrompt(ctx); + const { stdout } = await spawnClaudeRun(fullPrompt); + return processClaudeOutput(stdout, ctx.store); +} + +async function continueClaudeCode( + sessionId: string, + message: string, + store: Store, +): Promise { + const { stdout } = await spawnClaudeResume(sessionId, message); + return processClaudeOutput(stdout, store); +} + +/** Agent CLI factory: parses argv, runs Claude Code, extracts output, writes StepNode. */ +export function createClaudeCodeAgent(): () => Promise { + return createAgent({ + name: "claude-code", + run: runClaudeCode, + continue: continueClaudeCode, + }); +} diff --git a/packages/workflow-agent-claude-code/src/cli.ts b/packages/workflow-agent-claude-code/src/cli.ts new file mode 100644 index 0000000..eb28ec5 --- /dev/null +++ b/packages/workflow-agent-claude-code/src/cli.ts @@ -0,0 +1,6 @@ +#!/usr/bin/env bun + +import { createClaudeCodeAgent } from "./claude-code.js"; + +const main = createClaudeCodeAgent(); +void main(); diff --git a/packages/workflow-agent-claude-code/src/index.ts b/packages/workflow-agent-claude-code/src/index.ts new file mode 100644 index 0000000..a5e5aa1 --- /dev/null +++ b/packages/workflow-agent-claude-code/src/index.ts @@ -0,0 +1,6 @@ +export { buildClaudeCodePrompt, createClaudeCodeAgent } from "./claude-code.js"; +export { + parseClaudeCodeJsonOutput, + storeClaudeCodeDetail, + storeClaudeCodeRawOutput, +} from "./session-detail.js"; diff --git a/packages/workflow-agent-claude-code/src/schemas.ts b/packages/workflow-agent-claude-code/src/schemas.ts new file mode 100644 index 0000000..152afc9 --- /dev/null +++ b/packages/workflow-agent-claude-code/src/schemas.ts @@ -0,0 +1,25 @@ +import type { JSONSchema } from "@uncaged/json-cas"; + +export const CLAUDE_CODE_DETAIL_SCHEMA: JSONSchema = { + title: "claude-code-detail", + type: "object", + required: ["sessionId", "numTurns", "totalCostUsd", "durationMs", "subtype"], + properties: { + sessionId: { type: "string" }, + numTurns: { type: "integer" }, + totalCostUsd: { type: "number" }, + durationMs: { type: "integer" }, + subtype: { type: "string" }, + }, + additionalProperties: false, +}; + +export const CLAUDE_CODE_RAW_OUTPUT_SCHEMA: JSONSchema = { + title: "claude-code-raw-output", + type: "object", + required: ["text"], + properties: { + text: { type: "string" }, + }, + additionalProperties: false, +}; diff --git a/packages/workflow-agent-claude-code/src/session-detail.ts b/packages/workflow-agent-claude-code/src/session-detail.ts new file mode 100644 index 0000000..c8ade42 --- /dev/null +++ b/packages/workflow-agent-claude-code/src/session-detail.ts @@ -0,0 +1,79 @@ +import { bootstrap, putSchema, type Store } from "@uncaged/json-cas"; + +import { CLAUDE_CODE_DETAIL_SCHEMA, CLAUDE_CODE_RAW_OUTPUT_SCHEMA } from "./schemas.js"; +import type { ClaudeCodeDetailPayload, ClaudeCodeParsedResult } from "./types.js"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** Parse Claude Code JSON stdout (`claude -p --output-format json`). */ +export function parseClaudeCodeJsonOutput(stdout: string): ClaudeCodeParsedResult | null { + let parsed: unknown; + try { + parsed = JSON.parse(stdout.trim()); + } catch { + return null; + } + + if (!isRecord(parsed)) { + return null; + } + + const sessionId = parsed.session_id; + const result = parsed.result; + const subtype = parsed.subtype; + + if (typeof sessionId !== "string" || typeof result !== "string" || typeof subtype !== "string") { + return null; + } + + return { + type: typeof parsed.type === "string" ? parsed.type : "result", + subtype: subtype as ClaudeCodeParsedResult["subtype"], + result, + sessionId, + numTurns: typeof parsed.num_turns === "number" ? parsed.num_turns : 0, + totalCostUsd: typeof parsed.total_cost_usd === "number" ? parsed.total_cost_usd : 0, + durationMs: typeof parsed.duration_ms === "number" ? parsed.duration_ms : 0, + }; +} + +type ClaudeCodeSchemaHashes = { + detail: string; + rawOutput: string; +}; + +async function registerSchemas(store: Store): Promise { + await bootstrap(store); + const [detail, rawOutput] = await Promise.all([ + putSchema(store, CLAUDE_CODE_DETAIL_SCHEMA), + putSchema(store, CLAUDE_CODE_RAW_OUTPUT_SCHEMA), + ]); + return { detail, rawOutput }; +} + +/** Store parsed Claude Code result as a CAS detail node. */ +export async function storeClaudeCodeDetail( + store: Store, + parsed: ClaudeCodeParsedResult, +): Promise<{ detailHash: string; output: string; sessionId: string }> { + const schemas = await registerSchemas(store); + + const detail: ClaudeCodeDetailPayload = { + sessionId: parsed.sessionId, + numTurns: parsed.numTurns, + totalCostUsd: parsed.totalCostUsd, + durationMs: parsed.durationMs, + subtype: parsed.subtype, + }; + + const detailHash = await store.put(schemas.detail, detail); + return { detailHash, output: parsed.result, sessionId: parsed.sessionId }; +} + +/** Fallback: store raw text output when JSON parsing fails. */ +export async function storeClaudeCodeRawOutput(store: Store, rawOutput: string): Promise { + const schemas = await registerSchemas(store); + return store.put(schemas.rawOutput, { text: rawOutput }); +} diff --git a/packages/workflow-agent-claude-code/src/types.ts b/packages/workflow-agent-claude-code/src/types.ts new file mode 100644 index 0000000..150f12b --- /dev/null +++ b/packages/workflow-agent-claude-code/src/types.ts @@ -0,0 +1,19 @@ +export type ClaudeCodeResultSubtype = "success" | "error_max_turns" | "error_budget"; + +export type ClaudeCodeParsedResult = { + type: string; + subtype: ClaudeCodeResultSubtype; + result: string; + sessionId: string; + numTurns: number; + totalCostUsd: number; + durationMs: number; +}; + +export type ClaudeCodeDetailPayload = { + sessionId: string; + numTurns: number; + totalCostUsd: number; + durationMs: number; + subtype: string; +}; diff --git a/packages/workflow-agent-claude-code/tsconfig.json b/packages/workflow-agent-claude-code/tsconfig.json new file mode 100644 index 0000000..b367d4d --- /dev/null +++ b/packages/workflow-agent-claude-code/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { "rootDir": "src", "outDir": "dist" }, + "include": ["src"], + "references": [{ "path": "../workflow-agent-kit" }] +}