import { randomUUID } from 'crypto' import { mkdir, readdir, readFile, writeFile } from 'fs/promises' import { join, parse } from 'path' import { z } from 'zod/v4' import type { ToolResultBlockParam } from 'src/Tool.js' import { buildTool } from 'src/Tool.js' import { truncate } from 'src/utils/format.js' import { safeParseJSON } from 'src/utils/json.js' import { WORKFLOW_DIR_NAME, WORKFLOW_FILE_EXTENSIONS, WORKFLOW_TOOL_NAME, } from './constants.js' const WORKFLOW_RUNS_DIR = '.claude/workflow-runs' const inputSchema = z.object({ workflow: z.string().describe('Name of the workflow to execute'), args: z.string().optional().describe('Arguments to pass to the workflow'), action: z .enum(['start', 'status', 'advance', 'cancel', 'list']) .optional() .describe('Workflow action. Defaults to start.'), run_id: z .string() .optional() .describe('Workflow run id for status, advance, or cancel.'), }) type Input = typeof inputSchema type WorkflowInput = z.infer type WorkflowStepStatus = 'pending' | 'running' | 'completed' | 'cancelled' type WorkflowStep = { name: string prompt: string status: WorkflowStepStatus startedAt?: number completedAt?: number } type WorkflowRun = { runId: string workflow: string args?: string status: 'running' | 'completed' | 'cancelled' createdAt: number updatedAt: number currentStepIndex: number steps: WorkflowStep[] } type WorkflowOutput = { output: string } async function findWorkflowFile( workflowDir: string, workflow: string, ): Promise<{ path: string; content: string } | null> { for (const ext of WORKFLOW_FILE_EXTENSIONS) { const path = join(workflowDir, `${workflow}${ext}`) try { return { path, content: await readFile(path, 'utf-8') } } catch { // try next } } return null } async function listAvailableWorkflows(workflowDir: string): Promise { try { const files = await readdir(workflowDir) return files .filter(f => WORKFLOW_FILE_EXTENSIONS.includes(parse(f).ext.toLowerCase())) .map(f => parse(f).name) .sort() } catch { return [] } } function workflowRunPath(cwd: string, runId: string): string { return join(cwd, WORKFLOW_RUNS_DIR, `${runId}.json`) } async function readWorkflowRun( cwd: string, runId: string, ): Promise { try { const parsed = safeParseJSON( await readFile(workflowRunPath(cwd, runId), 'utf-8'), false, ) as Partial | null if ( !parsed || typeof parsed.runId !== 'string' || typeof parsed.workflow !== 'string' || !Array.isArray(parsed.steps) ) { return null } return parsed as WorkflowRun } catch { return null } } async function writeWorkflowRun(cwd: string, run: WorkflowRun): Promise { await mkdir(join(cwd, WORKFLOW_RUNS_DIR), { recursive: true }) await writeFile( workflowRunPath(cwd, run.runId), JSON.stringify(run, null, 2) + '\n', 'utf-8', ) } async function listWorkflowRuns(cwd: string): Promise { let files: string[] try { files = await readdir(join(cwd, WORKFLOW_RUNS_DIR)) } catch { return [] } const runs = await Promise.all( files .filter(f => f.endsWith('.json')) .map(f => readWorkflowRun(cwd, f.slice(0, -'.json'.length))), ) return runs .filter((run): run is WorkflowRun => run !== null) .sort((a, b) => b.updatedAt - a.updatedAt) } function parseMarkdownSteps(content: string): WorkflowStep[] { const steps: WorkflowStep[] = [] for (const rawLine of content.split('\n')) { const line = rawLine.trim() const taskMatch = line.match(/^[-*]\s+\[[ xX]\]\s+(.+)$/) const bulletMatch = line.match(/^[-*]\s+(.+)$/) const numberedMatch = line.match(/^\d+[.)]\s+(.+)$/) const text = taskMatch?.[1] ?? bulletMatch?.[1] ?? numberedMatch?.[1] if (!text) continue steps.push({ name: text.slice(0, 80), prompt: text, status: 'pending' }) } return steps } function parseYamlSteps(content: string): WorkflowStep[] { const steps: WorkflowStep[] = [] let current: Partial | null = null const flush = () => { if (!current) return const prompt = current.prompt ?? current.name if (current.name && prompt) { steps.push({ name: current.name, prompt, status: 'pending', }) } current = null } for (const rawLine of content.split('\n')) { const line = rawLine.trim() const stepText = line.match(/^-\s+(.+)$/)?.[1] if (stepText) { flush() const inlineName = stepText.match(/^name:\s*(.+)$/)?.[1] current = { name: inlineName ?? stepText, prompt: inlineName ? undefined : stepText, } continue } const name = line.match(/^name:\s*(.+)$/)?.[1] if (name) { if (!current) current = {} current.name = name continue } const prompt = line.match(/^(prompt|run|command):\s*(.+)$/)?.[2] if (prompt) { if (!current) current = {} current.prompt = prompt } } flush() return steps } function parseWorkflowSteps(filePath: string, content: string): WorkflowStep[] { const ext = parse(filePath).ext.toLowerCase() const steps = ext === '.md' ? parseMarkdownSteps(content) : parseYamlSteps(content) if (steps.length > 0) { return steps } return [ { name: 'Execute workflow', prompt: content.trim(), status: 'pending', }, ] } function formatStep(step: WorkflowStep, index: number): string { return `Step ${index + 1}: ${step.name}\n${step.prompt}` } function formatRunStatus(run: WorkflowRun): string { const lines = [ `Workflow run: ${run.runId}`, `Workflow: ${run.workflow}`, `Status: ${run.status}`, `Current step: ${run.steps[run.currentStepIndex]?.name ?? 'none'}`, `Steps: ${run.steps.length}`, ] for (let i = 0; i < run.steps.length; i += 1) { const step = run.steps[i]! lines.push(` ${i + 1}. [${step.status}] ${step.name}`) } return lines.join('\n') } async function startWorkflow( input: WorkflowInput, cwd: string, ): Promise { const workflowDir = join(cwd, WORKFLOW_DIR_NAME) const found = await findWorkflowFile(workflowDir, input.workflow) if (!found) { const available = await listAvailableWorkflows(workflowDir) const hint = available.length > 0 ? `\nAvailable workflows: ${available.join(', ')}` : `\nNo workflows found in ${WORKFLOW_DIR_NAME}/. Create .md or .yaml files there.` return { output: `Error: Workflow "${input.workflow}" not found.${hint}` } } const steps = parseWorkflowSteps(found.path, found.content) const now = Date.now() steps[0] = { ...steps[0]!, status: 'running', startedAt: now } const run: WorkflowRun = { runId: randomUUID(), workflow: input.workflow, ...(input.args ? { args: input.args } : {}), status: 'running', createdAt: now, updatedAt: now, currentStepIndex: 0, steps, } await writeWorkflowRun(cwd, run) const argsSection = input.args ? `\n\nArguments:\n${input.args}` : '' return { output: [ `Workflow run started`, `run_id: ${run.runId}`, `workflow: ${run.workflow}`, '', formatStep(steps[0]!, 0), argsSection, '', `When this step is complete, call Workflow with action="advance" and run_id="${run.runId}".`, ].join('\n'), } } async function getRunOrError( cwd: string, runId: string | undefined, ): Promise<{ run?: WorkflowRun; output?: string }> { if (!runId) return { output: 'Error: run_id is required for this action.' } const run = await readWorkflowRun(cwd, runId) if (!run) return { output: `Error: Workflow run "${runId}" not found.` } return { run } } async function advanceWorkflow( cwd: string, runId: string | undefined, ): Promise { const found = await getRunOrError(cwd, runId) if (!found.run) return { output: found.output! } const run = found.run const now = Date.now() const current = run.steps[run.currentStepIndex] if (current && current.status === 'running') { current.status = 'completed' current.completedAt = now } const nextIndex = run.currentStepIndex + 1 if (nextIndex >= run.steps.length) { run.status = 'completed' run.updatedAt = now await writeWorkflowRun(cwd, run) return { output: `Workflow completed\nrun_id: ${run.runId}` } } run.currentStepIndex = nextIndex run.steps[nextIndex] = { ...run.steps[nextIndex]!, status: 'running', startedAt: now, } run.updatedAt = now await writeWorkflowRun(cwd, run) return { output: [ `Next workflow step`, `run_id: ${run.runId}`, '', formatStep(run.steps[nextIndex]!, nextIndex), '', `When this step is complete, call Workflow with action="advance" and run_id="${run.runId}".`, ].join('\n'), } } async function cancelWorkflow( cwd: string, runId: string | undefined, ): Promise { const found = await getRunOrError(cwd, runId) if (!found.run) return { output: found.output! } const run = found.run const now = Date.now() run.status = 'cancelled' run.updatedAt = now for (const step of run.steps) { if (step.status === 'pending' || step.status === 'running') { step.status = 'cancelled' } } await writeWorkflowRun(cwd, run) return { output: `Workflow cancelled\nrun_id: ${run.runId}` } } async function listWorkflowRunsForOutput(cwd: string): Promise { const runs = await listWorkflowRuns(cwd) if (runs.length === 0) return { output: 'No workflow runs recorded.' } return { output: runs .slice(0, 20) .map( run => `${run.runId} | ${run.workflow} | ${run.status} | step=${run.steps[run.currentStepIndex]?.name ?? 'none'} | updated=${new Date(run.updatedAt).toLocaleString()}`, ) .join('\n'), } } export const WorkflowTool = buildTool({ name: WORKFLOW_TOOL_NAME, searchHint: 'execute user-defined workflow scripts', maxResultSizeChars: 50_000, strict: true, inputSchema, async description() { return 'Execute and track a user-defined workflow from .claude/workflows/' }, async prompt() { return `Use the Workflow tool to run user-defined workflows located in .claude/workflows/. Workflows may be Markdown checklists/lists or YAML files with steps. Actions: - start (default): create a persisted workflow run and return the first step to execute - advance: mark the current step complete and return the next step - status: inspect a workflow run by run_id - cancel: cancel a workflow run - list: list recent workflow runs Workflow run state is persisted in .claude/workflow-runs/.` }, userFacingName() { return 'Workflow' }, isReadOnly(input) { return input.action === 'status' || input.action === 'list' }, isEnabled() { return true }, renderToolUseMessage(input: Partial) { const name = input.workflow ?? 'unknown' const action = input.action ?? 'start' return input.args ? `Workflow: ${action} ${name} ${input.args}` : `Workflow: ${action} ${name}` }, mapToolResultToToolResultBlockParam( content: WorkflowOutput, toolUseID: string, ): ToolResultBlockParam { return { tool_use_id: toolUseID, type: 'tool_result', content: truncate(content.output, 50_000), } }, async call(input: WorkflowInput) { const cwd = process.cwd() const action = input.action ?? 'start' switch (action) { case 'start': return { data: await startWorkflow(input, cwd) } case 'status': { const found = await getRunOrError(cwd, input.run_id) return { data: { output: found.run ? formatRunStatus(found.run) : found.output!, }, } } case 'advance': return { data: await advanceWorkflow(cwd, input.run_id) } case 'cancel': return { data: await cancelWorkflow(cwd, input.run_id) } case 'list': return { data: await listWorkflowRunsForOutput(cwd) } } }, })