mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-17 05:45:51 +00:00
feat(workflow): 复刻 ultracode 手册并修复 worktree/inline/opt-in 三处缺口
围绕 ultracode skill 审查 agent 系统一致性后:
- ultracode.ts: 用系统提示版完整 Workflow 编排手册替换中文精简版
- HIGH#1 isolation:'worktree': claudeCodeBackend.run() 用 createAgentWorktree +
runWithCwdOverride 包裹 runAgent + finally 清理实现真正的 cwd 隔离;slug 用
sha256(runId:agentId) 派生以匹配 cleanupStaleAgentWorktrees 清理正则
(修 runId 为 w+base36 非 UUID 导致的泄漏盲区);worktree.ts 注释同步修正
- HIGH#2 inline 持久化: 新增 persistInlineScript,WorkflowTool + service 两条
inline 路径对称持久化到 .claude/workflow-runs/<runId>/script.js,返回可复用
scriptPath(闭环 inline→编辑→scriptPath 重提迭代循环)
- HIGH#3 opt-in 分工: ultracode/WorkflowTool/effort 注明 session reminder 由
harness 注入,repo 内无 ultracode 信号,保持 feature('WORKFLOW_SCRIPTS') +
isEnabled 两层 gate,不自造注入
- 测试: 新增 persistInline.test.ts;扩展 claudeCodeBackend(isolation 4 用例)/
WorkflowTool(inline)/service(scriptPath)/ultracode(harness)
含配套 workflow engine/panel 完善与 run-state-persistence design doc。
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -62,13 +62,15 @@ export { TerminalCaptureTool } from './tools/TerminalCaptureTool/TerminalCapture
|
||||
export { VerifyPlanExecutionTool } from './tools/VerifyPlanExecutionTool/VerifyPlanExecutionTool.js'
|
||||
export { WebBrowserTool } from './tools/WebBrowserTool/WebBrowserTool.js'
|
||||
// WorkflowTool 实现已迁移到 @claude-code-best/workflow-engine(独立包,端口适配)。
|
||||
// 这里仅 re-export 工厂与常量,保持向后兼容。
|
||||
// 注意:本 commit 移除了 builtin-tools 的 WorkflowTool 值导出和 getWorkflowCommands。
|
||||
// - WorkflowTool 工厂:改由 @claude-code-best/workflow-engine 的 createWorkflowTool 提供
|
||||
// - getWorkflowCommands:已移除,功能迁至 src/workflow/namedWorkflowCommands.ts
|
||||
// 第三方若从本包 import 这两个符号,需切换到新路径。
|
||||
export {
|
||||
createWorkflowTool,
|
||||
WORKFLOW_TOOL_NAME,
|
||||
type WorkflowToolDescriptor,
|
||||
} from '@claude-code-best/workflow-engine'
|
||||
export { initBundledWorkflows } from './tools/WorkflowTool/bundled/index.js'
|
||||
|
||||
// Constants
|
||||
export {
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
// Bundled workflow initialization.
|
||||
// Called by tools.ts when WORKFLOW_SCRIPTS feature flag is enabled.
|
||||
// Sets up any pre-bundled workflow scripts that ship with the CLI.
|
||||
|
||||
/**
|
||||
* Initialize bundled workflows. Called once at startup when the
|
||||
* WORKFLOW_SCRIPTS feature flag is active. This is the hook point
|
||||
* for registering any workflow scripts that are compiled into the
|
||||
* binary (as opposed to user-authored ones in .claude/workflows/).
|
||||
*/
|
||||
export function initBundledWorkflows(): void {
|
||||
// Bundled workflows are registered here at startup.
|
||||
// Currently a no-op — all workflows are user-authored in .claude/workflows/.
|
||||
// This function exists as the extension point for future built-in workflows.
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test } from 'bun:test'
|
||||
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { createWorkflowTool } from '../tool/WorkflowTool.js'
|
||||
@@ -74,6 +74,34 @@ test('call 返回 launch 消息并在后台完成', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('inline script 持久化到 run 目录,返回真实 scriptPath', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-tool-'))
|
||||
try {
|
||||
const { ports } = mockPorts(
|
||||
dir,
|
||||
new Map([['x', { kind: 'ok', output: 'x', usage: { outputTokens: 1 } }]]),
|
||||
)
|
||||
const tool = createWorkflowTool(ports)
|
||||
const res = await tool.call(
|
||||
{ script: `return agent('x')` },
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
)
|
||||
const expectedPath = join(
|
||||
dir,
|
||||
'.claude',
|
||||
'workflow-runs',
|
||||
'run-x',
|
||||
'script.js',
|
||||
)
|
||||
expect(res.data.output).toContain(expectedPath)
|
||||
expect(await readFile(expectedPath, 'utf-8')).toBe(`return agent('x')`)
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('缺少 script/name/scriptPath → 返回错误(不进后台)', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-tool-'))
|
||||
try {
|
||||
|
||||
41
packages/workflow-engine/src/__tests__/persistInline.test.ts
Normal file
41
packages/workflow-engine/src/__tests__/persistInline.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { expect, test } from 'bun:test'
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { persistInlineScript } from '../tool/persistInline.js'
|
||||
|
||||
test('持久化到 <cwd>/.claude/workflow-runs/<runId>/script.js 并返回路径', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-pi-'))
|
||||
try {
|
||||
const path = await persistInlineScript('return 1', 'r1', dir)
|
||||
expect(path).toBe(join(dir, '.claude', 'workflow-runs', 'r1', 'script.js'))
|
||||
expect(await readFile(path, 'utf-8')).toBe('return 1')
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('同 runId 重复写覆盖(mkdir 幂等,不抛错)', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-pi-'))
|
||||
try {
|
||||
await persistInlineScript('first', 'r2', dir)
|
||||
const path = await persistInlineScript('second', 'r2', dir)
|
||||
expect(await readFile(path, 'utf-8')).toBe('second')
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('不同 runId 互不干扰(各自独立子目录)', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-pi-'))
|
||||
try {
|
||||
const p1 = await persistInlineScript('a', 'run-a', dir)
|
||||
const p2 = await persistInlineScript('b', 'run-b', dir)
|
||||
expect(p1).not.toBe(p2)
|
||||
expect(await readFile(p1, 'utf-8')).toBe('a')
|
||||
expect(await readFile(p2, 'utf-8')).toBe('b')
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
@@ -1,6 +1,10 @@
|
||||
// Agent 后端适配器抽象。引擎通过 registry 取 adapter 再调 run,不关心具体实现
|
||||
// (Anthropic SDK / 核心 runAgent / OpenAI / 本地模型 / mock 均为 adapter 的实现)。
|
||||
import type { AgentRunParams, AgentRunResult } from './types.js'
|
||||
import type {
|
||||
AgentProgressUpdate,
|
||||
AgentRunParams,
|
||||
AgentRunResult,
|
||||
} from './types.js'
|
||||
import type { HostHandle } from './ports.js'
|
||||
|
||||
/** adapter 能力声明。引擎/脚本据此降级(如后端不支持 schema 则改文本 + 解析)。 */
|
||||
@@ -21,6 +25,11 @@ export type AgentAdapterContext = {
|
||||
signal: AbortSignal
|
||||
/** 当前 workflow runId(日志/追踪用)。 */
|
||||
runId: string
|
||||
/**
|
||||
* 运行中进度上报(后端循环累计 token/tool 时调用)。可选:独立后端可不实现;
|
||||
* 引擎据此发 agent_progress 事件(闭包带 agentId/runId 关联),面板实时刷新。
|
||||
*/
|
||||
onProgress?: (update: AgentProgressUpdate) => void
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { MAX_ITEMS_PER_CALL, MAX_TOTAL_AGENTS } from '../constants.js'
|
||||
import type {
|
||||
AgentProgressUpdate,
|
||||
AgentRunParams,
|
||||
AgentRunResult,
|
||||
JournalEntry,
|
||||
@@ -29,6 +30,14 @@ type HookProgressInit =
|
||||
phase?: string
|
||||
result: AgentRunResult
|
||||
}
|
||||
| {
|
||||
type: 'agent_progress'
|
||||
agentId: number
|
||||
label?: string
|
||||
phase?: string
|
||||
tokenCount: number
|
||||
toolCount: number
|
||||
}
|
||||
| { type: 'log'; message: string }
|
||||
|
||||
export function makeHooks(
|
||||
@@ -104,11 +113,16 @@ export function makeHooks(
|
||||
ctx.resources.agentCountBox.value++
|
||||
emit({ type: 'agent_started', agentId, label, phase })
|
||||
const registry = ctx.ports.agentAdapterRegistry
|
||||
// onProgress 闭包:后端循环累计 token/tool → 发 agent_progress 事件(带 agentId 关联)
|
||||
const onProgress = (update: AgentProgressUpdate): void => {
|
||||
emit({ type: 'agent_progress', agentId, label, phase, ...update })
|
||||
}
|
||||
const result = registry
|
||||
? await registry.resolve(params).run(params, {
|
||||
host: ctx.host,
|
||||
signal: ctx.signal,
|
||||
runId: ctx.runId,
|
||||
onProgress,
|
||||
})
|
||||
: await ctx.ports.agentRunner.runAgentToResult(params, ctx.host)
|
||||
if (result.kind === 'ok') {
|
||||
|
||||
@@ -21,4 +21,5 @@ export {
|
||||
type WorkflowToolDescriptor,
|
||||
} from './tool/WorkflowTool.js'
|
||||
export { workflowInputSchema, type WorkflowInput } from './tool/schema.js'
|
||||
export { persistInlineScript } from './tool/persistInline.js'
|
||||
export { WORKFLOW_TOOL_NAME } from './tool/constants.js'
|
||||
|
||||
@@ -9,6 +9,7 @@ import { containsPath, sanitizeWorkflowName } from '../engine/paths.js'
|
||||
import type { WorkflowPorts } from '../ports.js'
|
||||
import type { WorkflowRunResult } from '../types.js'
|
||||
import { workflowInputSchema, type WorkflowInput } from './schema.js'
|
||||
import { persistInlineScript } from './persistInline.js'
|
||||
|
||||
/** 自包含工具描述符(核心 wiring 用 buildTool 包装它)。零核心层依赖。 */
|
||||
export type WorkflowToolDescriptor = {
|
||||
@@ -55,6 +56,10 @@ export function createWorkflowTool(
|
||||
return {
|
||||
name: WORKFLOW_TOOL_NAME,
|
||||
inputSchema: workflowInputSchema,
|
||||
// No per-session runtime opt-in gate here: the "ultracode is on for the
|
||||
// session" signal is injected by the harness (claude.ai/client), not held
|
||||
// in any repo state. This tool is compiled in/out via feature('WORKFLOW_SCRIPTS')
|
||||
// in src/tools.ts; beyond that it is always enabled when present.
|
||||
isEnabled: () => true,
|
||||
isReadOnly: () => false,
|
||||
|
||||
@@ -109,6 +114,23 @@ export function createWorkflowTool(
|
||||
host.handle,
|
||||
)
|
||||
|
||||
// inline 入口持久化脚本到 run 目录,返回可复用路径(ultracode skill 承诺的
|
||||
// inline → 持久化 → 编辑 → scriptPath 重提迭代循环)。写盘失败降级为占位符
|
||||
// + warn,不阻断 run(script 已在内存)。
|
||||
if (!workflowFile && input.script) {
|
||||
try {
|
||||
workflowFile = await persistInlineScript(
|
||||
input.script,
|
||||
runId,
|
||||
host.cwd,
|
||||
)
|
||||
} catch (e) {
|
||||
ports.logger.warn?.(
|
||||
`inline script persist failed: ${(e as Error).message}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// detached 执行
|
||||
void runWorkflow({
|
||||
script,
|
||||
|
||||
28
packages/workflow-engine/src/tool/persistInline.ts
Normal file
28
packages/workflow-engine/src/tool/persistInline.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { WORKFLOW_RUNS_DIR } from '../constants.js'
|
||||
|
||||
/**
|
||||
* Persist an inline workflow script to the run directory so the caller can
|
||||
* iterate via `scriptPath` + `resumeFromRunId` without resending the full script
|
||||
* (the round-trip the ultracode skill promises for the inline entry path).
|
||||
*
|
||||
* Mirrors engine/journal.ts: writes directly via node:fs/promises (no port) to
|
||||
* `<cwd>/<WORKFLOW_RUNS_DIR>/<runId>/script.js` — the same directory as
|
||||
* journal.jsonl, so journalStore.truncate(runId) cleans it up alongside the journal.
|
||||
*
|
||||
* Fixed filename `script.js`: parseScript ignores the extension and the runId
|
||||
* already makes the directory unique, so a stable name aids muscle memory.
|
||||
*/
|
||||
export async function persistInlineScript(
|
||||
script: string,
|
||||
runId: string,
|
||||
cwd: string,
|
||||
): Promise<string> {
|
||||
const dir = join(cwd, WORKFLOW_RUNS_DIR, runId)
|
||||
await mkdir(dir, { recursive: true })
|
||||
const filePath = join(dir, 'script.js')
|
||||
await writeFile(filePath, script, 'utf-8')
|
||||
return filePath
|
||||
}
|
||||
@@ -27,9 +27,25 @@ export type AgentRunParams = {
|
||||
phase?: string
|
||||
}
|
||||
|
||||
/** AgentRunner 返回。 */
|
||||
/** agent 运行中进度快照(onProgress 回调载荷;后端循环累计 token/tool)。 */
|
||||
export type AgentProgressUpdate = {
|
||||
tokenCount: number
|
||||
toolCount: number
|
||||
}
|
||||
|
||||
/** AgentRunner 返回。ok 变体携带 model/toolCount 供面板展示(可选,独立后端可不填)。 */
|
||||
export type AgentRunResult =
|
||||
| { kind: 'ok'; output: string | object; usage: { outputTokens: number } }
|
||||
| {
|
||||
kind: 'ok'
|
||||
output: string | object
|
||||
usage: { outputTokens: number }
|
||||
/** 实际解析后的 model id(展示用)。 */
|
||||
model?: string
|
||||
/** agent 运行期间工具调用次数。 */
|
||||
toolCount?: number
|
||||
/** 完成时的 context 总 token 数(展示用;与 agent_progress 实时口径一致)。 */
|
||||
tokenCount?: number
|
||||
}
|
||||
| { kind: 'skipped' }
|
||||
| { kind: 'dead' }
|
||||
|
||||
@@ -66,6 +82,15 @@ export type ProgressEvent =
|
||||
phase?: string
|
||||
result: AgentRunResult
|
||||
}
|
||||
| {
|
||||
type: 'agent_progress'
|
||||
runId: string
|
||||
agentId: number
|
||||
label?: string
|
||||
phase?: string
|
||||
tokenCount: number
|
||||
toolCount: number
|
||||
}
|
||||
| { type: 'log'; runId: string; message: string }
|
||||
| {
|
||||
type: 'run_done'
|
||||
|
||||
Reference in New Issue
Block a user