mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-19 06:45:50 +00:00
feat: dynamic-workflow 来了 (#1271)
* feat(workflow): add workflow engine, /workflows panel, /ultracode skill
将 feat/sdk-backend 分支中 workflow 相关的 20 个 commit 压缩为单 commit:
- 工作流引擎核心:phase / agent / parallel / pipeline 编排原语(packages/workflow-engine/)
- /workflows 面板:三区焦点布局(顶部 run tabs + 左侧 phase 侧栏 + 右侧 agent 列表)
- /ultracode skill:多 agent workflow 编排入口
- 进度存储 / journal / notification 系统
- WorkflowService 生命周期管理 + SentryErrorBoundary
- 脚本沙箱:禁用 dynamic import()、JSON args 防御性归一化
- journal 与 named-workflow 路径统一在 projectRoot
- 错误处理:parallel/pipeline hooks 错误日志、failure routing、semaphore abort
- workflow 工具升级为 core 工具 + PascalCase 命名
Co-Authored-By: glm-5.1 <zai-org@claude-code-best.win>
* 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>
* feat(workflow): run 终态落盘 state.json 支持跨重启恢复
终态 RunProgress(含 returnValue/error)此前只在内存 ProgressStore,进程
重启即丢失。本次让其落盘到 .claude/workflow-runs/<runId>/state.json,使
(a) 重启后可按 runId 取 return、(b) /workflows 面板跨重启展示历史 run。
跨进程 resume 明确不在范围。
- persistence.ts: getRunsDir/writeRunState/readRunState/listPersistedRuns
+ attachRunStatePersistence;原子覆盖写(tmp+rename),读容错(缺文件/
损坏/schemaVersion 不符 → null),写 best-effort(IO 失败只 log warn)
- progress/store.ts: 加 hydrate(run) 直接注入磁盘 run(已存在 runId 跳过,
内存优先)
- service.ts: getWorkflowService() 接线 attachRunStatePersistence(bus,
store) 订阅 run_done(completed/failed/killed 三态共用,shutdown-kill
也走同路径,无需额外钩子);WorkflowService 加 getRunAsync(id) 内存
miss→读盘 fallback(不注入内存)+ loadPersistedRuns() 扫盘 hydrate
(persistedLoaded flag 守护幂等)
- panel/WorkflowsPanel.tsx: mount 时调一次 loadPersistedRuns(重 mount
不重复)
- ports.ts: runsDir 改用 getRunsDir() 消除拼接重复
- 测试: persistence.test.ts(11)/runStatePersistence.test.ts(5)/
progressStore(2)/service(5)/WorkflowsPanel(1) 共 24 个新测试;
precheck 5629 pass / 0 fail
设计偏离: 计划原写 monkey-patch getRunsDir 指向 tmpdir,Bun ESM namespace
不可变不可行;改用可选 runsDirProvider 参数(默认 getRunsDir)DI 注入,
加到 attachRunStatePersistence 与 makeService(cwdOverride 之后第 4 参),
与现有 cwdOverride 模式一致。makeService 的 cwdOverride 保持不变,不破坏
inline 持久化特性。
Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
* feat(workflow): 默认并发降为 3 并支持 per-run maxConcurrency 注入
- DEFAULT_MAX_CONCURRENCY=3 替代旧的 min(16, cores-2);MAX_CONCURRENCY_CAP=16 保留为用户输入的绝对上限
- 新增 clampMaxConcurrency() 处理 undefined/<1/>CAP 边界
- WorkflowInput schema 新增 maxConcurrency: number.int().min(1).max(16).optional()
- 引擎层 context/runWorkflow 全链路透传:semaphore 容量来自 per-run 入参
- WorkflowTool prompt 增加指引:fan-out 场景先用 AskUserQuestion 与用户确认并发再启动
- 同步 ultracode skill + audit workflow spec 的并发文字(删 cpu-cores 公式)
- 同步 docs/features/workflow-scripts.md 旧公式
Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
* fix(workflow): 面板 UI 字符串英文化
WorkflowsPanel 中 4 处面向用户的中文(onDone 错误消息、键位提示行)
改为英文;其他面板组件(AgentList/TabsBar)原本已是英文。代码注释
保留中文,与 workflow 模块惯例一致。
Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
* feat(workflow): 中断系统(x 杀单 agent / K 杀整个 workflow,Dialog 二次确认)
- claudeCodeBackend 桥接 ctx.signal → runAgent.override.abortController(修 'x' 无效根因:abort 到不了内部 fetch)
- AbortError 识别为 throw WorkflowAbortedError(不再吞成 dead,workflow 能感知被 kill)
- ports.taskRegistrar 加 registerAgentAbort/unregisterAgentAbort/killAgent;service.killAgent(runId, agentId) 精确中断
- 面板键位:'x' 杀当前 agent(agents 列聚焦时) / 'K' 杀整个 workflow;Dialog 二次确认 + confirm 模式吞导航键防误触
- 新增测试 8 项(backend signal bridge / hooks inject / ports killAgent / service killAgent)
Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
* docs(workflow): ultracode skill 加 model tier 选择指引(haiku/sonnet/opus/best 场景匹配)
补足 agent() 已有 model 参数缺的判断依据:列出 4 个 tier 的成本/延迟量级和典型场景,
明确"无法 articulate 为什么换 tier 就 omit"的 rule of thumb。
Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
* feat(workflow): maxConcurrency≠3 必须先 AskUserQuestion(默认 3 推荐值)
把 fan-out 时才问改成任何 maxConcurrency≠3 都必须问。
唯一例外:用户在当前会话已明确说过并发数("use 6" / "maxConcurrency 9")。
prompt (WorkflowTool.ts) + skill (ultracode.ts) + audit spec 三处同步。
Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
* feat(workflow): agent 失败自动重试一次(dead 或非 abort throw)
- hooks.agent 包装 invokeBackend:第一次 dead 或非 abort throw → 重试一次
- WorkflowAbortedError(kill)不重试——是用户意图
- registry.resolve 配置错(AdapterNotFoundError 等)在 try 外直接上抛,不走重试——
配置问题重试无意义且掩盖 bug
- 重试仍失败:dead 保持 dead;throw 降级 dead(不击穿 workflow,
与 parallel/pipeline null-on-error 契约一致)
- budget 不重复扣:dead 不 addOutputTokens,重试 ok 才扣一次
- 新增 7 项 hooks 层重试测试 + 1 项 service 层降级测试
Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
* fix(workflow): 面板 label 截断保留 #数字 后缀(同 dim 多 finding 可区分)
audit workflow 用 verify:\${dim}#\${findingIdx} 命名 verify agent。
旧逻辑 slice(0, 18) 从右切把 #idx 全吃了——同 dimension 多 finding
肉眼无法区分。新逻辑:含 #数字 后缀时保留后缀,前缀截断 + … 省略号。
例:verify:correctness#0 → verify:correctn…#0
verify:architecture#15 → verify:archite…#15
Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
* feat(workflow): kill 整个 workflow 后立即回主 chat
run_done→store→notifications.ts 的通知路径已有,但 confirmYes 后面板继续
挂着挡住主 chat,用户看不到"已停止"反馈。kill 后调 onDone() 立即退出面板,
让主 chat 的 `Workflow "<name>" was stopped` 通知直接可见。
Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
* fix(workflow): agent dead 带 reason/detail + prompt 加压 StructuredOutput
12 agent audit workflow 8 个 dead,journal 只记 {kind:"dead"} 无信息,
事后无法区分 "agent 没产 StructuredOutput" vs "runAgent 抛错"。
证据指向主因:sonnet 长 tool chain 后忘记调 StructuredOutput,
extractStructuredOutput 返回 null 即降级 dead。
- types.ts: AgentRunResult.dead 加可选 reason/detail 字段
(no-structured-output / runagent-threw / worktree-failed / unknown)
兼容旧 journal(均 optional)。
- claudeCodeBackend.ts: 三处 dead 填 reason + detail;
no-structured-output 把 finalized 文本前 200 字符做 detail,
让日志/面板能立刻看到 agent 最后说了什么。
- claudeCodeBackend.ts: schema 模式 prompt 首尾各放一次
StructuredOutput 强制要求,针对 sonnet 长 tool chain 后忘记收尾。
- hooks.ts: retry 日志带 reason;retry 仍 throw 时降级 dead 也填
reason=runagent-threw + detail。
- types.test.ts: 加 reason JSON 往返 + 旧 journal 兼容测试。
Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
* fix(workflow): schema 模式弃用 StructuredOutput 工具契约,改鲁棒 JSON 文本解析
上一轮 70a2f76 把"agent 长 tool chain 后忘调 StructuredOutput"当作死因,
加 prompt 头尾双强制。但实测跑 5 个 review agent 4 个 dead,detail 全是
"StructuredOutput tool is not available as a deferred tool"——根因是
该工具从未注入 workflow sub-agent 的工具集(assembleToolPool 默认池不含,
只有 stop_hook 路径 execAgentHook.ts 显式 createStructuredOutputTool())。
prompt 反复要求调一个不可达的工具,agent 困扰、长篇辩解、最终没产 JSON。
- claudeCodeBackend.ts:
- extractStructuredOutput 重写:括号栈扫描替代 indexOf/lastIndexOf,
处理嵌套对象、字符串内的括号、转义符;新增 fenced code block
优先路径(```json / ```),多 JSON 块取第一个 parse 成功的;
只返回 plain object(拒 array/number/string/null)。不做语法修复
(尾逗号/单引号/注释)——避免在字符串内误改(如 "http://" 被 // 注释正则吃)。
- schema 模式 prompt 简化:删首尾双 STRUCTURED OUTPUT 强制(600+ token),
改成指示 agent 在最后文本块 emit raw JSON;明确告知"StructuredOutput
is not available in this environment",消除调用幻觉。
- hooks.ts: detail.slice 用 typeof === 'string' 守卫;catch 块用
e instanceof Error ? e.message : String(e)(旧 journal / 第三方 adapter
可能写非 string detail,直接 .slice 会抛 TypeError 击穿日志)。
- claudeCodeBackend.test.ts: +9 测试覆盖 fenced / 嵌套 / 字符串内括号 /
转义引号 / 多块取首 / 类型守卫 / 损坏 JSON。
precheck: 5663 pass / 0 fail。
Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
* docs(effort): 新增 /effort 交互面板设计 spec
设计要点:
- /effort 无参 → 横向 slider 面板(low/medium/high/xhigh/max/ultracode)
- ←/→ 移动光标,Enter 确认,Esc 取消
- ultracode 仅视觉占位,确认后提示走 /ultracode <context>
- env override 时双标记 + 顶部警告
- 模型不支持时面板禁用
- 两阶段交付:先基础面板 commit,再做 ultracode 波纹动画
Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
* docs(effort): 新增 EffortPanel 基础面板实施计划(第一阶段)
按 TDD 分 6 个 task:纯函数状态 → keybinding 注册 → 组件 → 命令挂载 → 分支测试 → precheck。
波纹动画在第二阶段单独 commit。
Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
* docs(effort): plan 补 q/ctrl+c 取消绑定,对齐 spec §5 状态机
verifier 抓到的 gap:spec §5 写明 Esc / Ctrl+C / q 都是取消事件,
但 plan Task 2.3 只绑了 escape。补上 q 和 ctrl+c → effortPanel:cancel。
同时把 Step 2.2 直接写成 6 个 action 版本(home/end),删除迂回表达。
Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
* docs(effort): plan 修订执行前 review 发现的 5 处 gap
- Task 3.3 EffortPanel.tsx 草稿:Faster/Smarter padEnd 语法错乱重写;
useKeybindings import 路径从 @anthropic/ink 修正为 ../../keybindings/useKeybinding.js;
移除冗余 renderSeparatorLine;保留 renderPaddedLine
- Task 5.2 computeConfirmOutcome 改为注入 ApplyFn 模式:
避免 effortPanelState → effort.tsx → EffortPanel 循环依赖;
测试可注入 mockApply,无需 mock settings
- Step 5.3 测试代码对齐注入版签名
Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
* feat(effort): 新增 EffortPanel 纯函数状态模块(PanelPosition + 移动/初始光标)
仅含纯函数与类型,无 React/Ink 依赖,便于单测。
- PANEL_POSITIONS:low → medium → high → xhigh → max → ultracode
- moveLeft/moveRight:边界钳制(low 不再左移、ultracode 不再右移)
- getInitialCursor:env override > displayed level
Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
* feat(keybindings): 注册 EffortPanel context 与 6 个 action
绑定 ←/→/h/l/home/end/enter/escape/q/ctrl+c 到 effortPanel:* action。
与 ModelPicker context 范式一致,避免左右键被全局 keybinding 拦截。
Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
* feat(effort): 实现 EffortPanel 组件主体(渲染 + 键盘交互 + 确认/取消分支)
- 横向 slider 布局:Faster ↔ Smarter 两极,6 档刻度
- useKeybindings 注册 EffortPanel context(←/→/h/l/home/end/enter/escape/q/ctrl+c)
- Enter 在 5 档之一 → 调 executeEffort 写 settings + AppState
- Enter 在 ultracode → 输出引导文案,不写状态
- Esc/q → "Effort unchanged."
- env override 时顶部黄色警告
- computeConfirmOutcome 注入 ApplyFn,便于测试(Task 5 补测试)
Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
* feat(effort): /effort 无参时挂载 EffortPanel 交互面板
- 无参 → <EffortPanelWrapper> 透传 AppState.effortValue
- current/status → 仍显示文本(不变)
- 有参 → 直跳 executeEffort(不变)
- help/-h/--help → 不变
Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
* test(effort): 补 computeConfirmOutcome 分支测试(注入 mockApply)
- ultracode → kind=ultracode-hint,不调 applyFn
- low → kind=apply,message/effortUpdate 来自 applyFn
- applyFn 返回无 effortUpdate 时 outcome.effortUpdate 为 undefined
- CANCEL_MESSAGE / ULTRACODE_HINT 常量
Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
* fix(effort): 测试里 cursor cast 为 EffortValue,避免 PanelPosition 含 ultracode 触发 TS 错误
computeConfirmOutcome 的 ApplyFn 契约要求 EffortValue,但测试 mockApply 接收 PanelPosition。
实际运行时 computeConfirmOutcome 在 ultracode 档位走 hint 分支不会调 applyFn,
cast 安全。precheck 全量通过:5688 tests / 0 fail。
Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
* fix(effort): 面板对齐与配色修复
- 对齐:用 Box width={SEGMENT} + justifyContent="center" 让 ▲ 与档位名严格居中对齐,
替代之前 string padEnd(11) 与 SEGMENT=12 不一致导致的 1 列偏移
- 配色:所有面板文字改用 theme.claude(Claude Orange rgb(215,119,87)),
替代终端默认紫;分隔线/副标签/底栏用 theme.subtle;env 警告用 theme.warning
- 光标档位的档位名也加粗,强化视觉焦点
Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
* fix(effort): 面板文字改紫色,ULTRACODE_HINT 英文化
- 颜色:theme.claude(橙)→ theme.purple_FOR_SUBAGENTS_ONLY(Purple 600, rgb(147,51,234)),
覆盖标题、Faster/Smarter、▲、档位名
- ULTRACODE_HINT:中文 → 英文
"ultracode is not an effort level. Use /ultracode <context> to start a multi-agent workflow."
Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
* fix(effort): 统一用色版——选中 suggestion(蓝),未选中 subtle(灰)
弃用 purple_FOR_SUBAGENTS_ONLY(subagent 专用)。改与项目其他面板一致:
- 选中档位 + ▲:color="suggestion"(Medium blue rgb(87,105,247))+ bold
- 未选中档位 + 空 ▲ 占位:color="subtle"(Light gray rgb(175,175,175))
- 标题 / Faster / Smarter:color="suggestion"
- 分隔线 / 副标签 / 底栏:color="subtle"
Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
* fix(workflow): 终态前补发 phase_done,面板自动退出 running→terminal 转换
runWorkflow:脚本结束时 hook.phase 不会触发最后一个 phase 的 phase_done,
UI 左栏会永远显示 running。三路径(completed/killed/failed)统一在 run_done
之前补发 emitTerminalPhaseDone。
WorkflowsPanel:抽 isRunTerminatedTransition 纯函数判定 running → terminal,
面板 useEffect 检测到转换后自动退出聚焦。
Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
* feat(effort): 波纹动画纯函数 pickChar/computeRippleLine/mergeLayers + 18 测试
Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
* feat(effort): useRippleFrame hook 包装 useAnimationFrame,按需订阅时钟
Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
* feat(effort): EffortPanel 集成波纹背景——cursor 停在 ultracode 时切换波纹模式
仅在 cursor === 'ultracode' 时启用 useRippleFrame,渲染 5 行波纹背景
+ overlay 文字(Faster/Smarter、分隔线、▲、档位名、副标签)。
其余档位保持原 PlainContent 渲染路径不动。
Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
* refactor(effort): 波纹动画从字符密度改为颜色渐变
按原版风格把波纹背景从 INTENSITY_CHARS 密度字符('·∙░▒▓')改为
suggestion 系颜色渐变(transparent → 暗深紫蓝 → suggestion → 高光):
rippleAnimation.ts:
- 删除 pickChar / INTENSITY_CHARS / WAVE_PEAK_CHARS / mergeLayers
- 新增 intensityToColor(intensity) → 'transparent' | '#xxxxxx'
- 新增 computeRippleCells 返回 Cell[](每位置 char+color)
- 新增 applyOverlaysToCells(cells, overlays) 替代 mergeLayers
- 新增 cellsToSegments(cells) 合并相邻同色段(减少 Text 节点)
EffortPanel.tsx:
- RippleContent 用 cells→segments→tokens 渲染
- 空格段用 BaseText backgroundColor 染色块(纯色块视觉)
- 文字段用 Text color 染色(亮色突出)
- tokens 按空格/文字二次拆分,避免混合段渲染歧义
测试: 29 个 rippleAnimation 测试覆盖 intensityToColor 边界、
computeRippleCells 长度/震源/衰减、applyOverlaysToCells 覆盖/截断/
防御式拷贝、cellsToSegments 合并逻辑。
Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
* fix(effort): 波纹参数调优——铺满左侧 + 速度调慢 + 全面板有底色
用户反馈三个问题:
1. "低峰部分没有颜色变化" → intensity ≤ 0.1 返回 transparent 导致波谷
位置看不见。改为永不返回 transparent,最低档 #0a0d1a 作为面板
底色(暗紫黑海洋),波峰在底色上流动。
2. "波浪速度太快" → time 系数 0.012 → 0.004(约 1/3 速)。波峰移动
速度从 34 cell/s 降到 11 cell/s,每帧颜色变化从 45% 降到 36%。
3. "波浪只到中间部分,没覆盖左侧" → falloff 覆盖半径 40 → 90。
震源 x=65,左侧 dist=65 < 90,波纹可达最左端(约 30-50% 覆盖)。
色阶调整:
- 删除 transparent 档,新增 #0a0d1a 作最暗档(底色)
- 最高档从 #8aa0ff(高光)改为 #5769F7(suggestion),避免与
文字 overlay 同色互相吞噬
- 7 档颜色:#0a0d1a → #15182b → #1f2543 → #2a3360 →
#3a4582 → #4a5bb0 → #5769F7
测试:删除 transparent 期望,改为期望具体颜色(#0a0d1a 等)。
新增"覆盖半径扩大"测试验证 dist=65 仍有非最暗颜色。
Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
* fix(effort): 波纹 v3 — 去黑边 + 删中心高频涟漪 + y 轴覆盖快捷键行
用户反馈三个问题:
1. "黑色边感觉不太对" — 最暗档 #0a0d1a (rgb 10,13,26) 太接近纯黑,
远端波谷看起来像硬黑边。改为 #1a1f3a (rgb 26,31,58),紫蓝感
更强而非纯黑。
2. "中心的快速波纹有点奇怪" — 删除震源附近 dist<6 的高频涟漪叠加
(time*0.02,5 倍主波纹频率)。原本想让震源附近"水波感"更强,
实际效果像"快速闪烁"反而突兀。主波纹已经足够,无需叠加。
3. "y 方向覆盖快捷键" — RippleContent 新增 y=2 行渲染快捷键 overlay
("←/→ adjust · Enter confirm · Esc cancel")。PlainContent 路径
保持原 Box marginTop=1 + Text 渲染。
色阶调整(紫蓝感更强):
- #1a1f3a (原 #0a0d1a) — 最暗档
- #1f2543 / #252c55 / #2e3870 / #3a4582 / #4a5bb0 / #5769F7
(中间档略调亮度,保持平滑过渡)
测试:震源点测试更新为"time=0 时波谷最暗,time 推进后扫过波峰变亮",
反映删除高频涟漪后的纯主波纹行为。
Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
* chore(workflow): 工作流相关代码中文文案全部英文化
源码(src/workflow/ + packages/workflow-engine/src/)的中文注释、
用户可见错误消息、字符串字面量;测试文件的标题与注释;同步 6 条
硬编码断言到英文化后的错误消息。
Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
* feat(effort): 波纹 v4 — 平滑波 + 全色环旋转 + 淡入淡出 + 宽度自适应
- 波函数改 (sin+1)/2:消除 max(0,sin) 平直暗带(约 6 行宽)
- 主色相连续旋转(0.03°/ms,12s/圈全色环):蓝→紫→品红→红→橙→黄→绿→青
- 文字 overlay 同步色相旋转(rotateHue 应用到 Faster/▲/档位名/分隔线/副标签)
- 淡入淡出动画:fadeColor/fadeCells + fade 状态机 ~300ms 进出过渡
- 副标签固定 ultracode 段下方,不跟随光标移动
- 顶部/底部各加一行纯波纹行,视觉一致
- 宽度自适应终端列数:窄则 72,宽则铺满(computeSegment/computeRippleSourceX)
- 快捷键改 plain Text,不参与波纹背景渲染
- 新增 18 测试(fadeColor/fadeCells/rotateHue/getHueShiftAtTime)
Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
* refactor: remove CYBER_RISK_MITIGATION_REMINDER from FileReadTool
Co-Authored-By: deepseek-v4-pro <deepseek-ai@claude-code-best.win>
* fix: prevent ReDoS in extractMeta regex by anchoring to splice boundary
Co-Authored-By: deepseek-v4-pro <deepseek-ai@claude-code-best.win>
* chore: 更新脚本
---------
Co-authored-by: glm-5.1 <zai-org@claude-code-best.win>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: deepseek-v4-pro <deepseek-ai@claude-code-best.win>
This commit is contained in:
@@ -62,9 +62,16 @@ export { TeamDeleteTool } from './tools/TeamDeleteTool/TeamDeleteTool.js'
|
||||
export { TerminalCaptureTool } from './tools/TerminalCaptureTool/TerminalCaptureTool.js'
|
||||
export { VerifyPlanExecutionTool } from './tools/VerifyPlanExecutionTool/VerifyPlanExecutionTool.js'
|
||||
export { WebBrowserTool } from './tools/WebBrowserTool/WebBrowserTool.js'
|
||||
export { WorkflowTool } from './tools/WorkflowTool/WorkflowTool.js'
|
||||
export { initBundledWorkflows } from './tools/WorkflowTool/bundled/index.js'
|
||||
export { getWorkflowCommands } from './tools/WorkflowTool/createWorkflowCommand.js'
|
||||
// WorkflowTool 实现已迁移到 @claude-code-best/workflow-engine(独立包,端口适配)。
|
||||
// 注意:本 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'
|
||||
|
||||
// Constants
|
||||
export {
|
||||
|
||||
@@ -52,7 +52,6 @@ import { lazySchema } from 'src/utils/lazySchema.js'
|
||||
import { logError } from 'src/utils/log.js'
|
||||
import { isAutoMemFile } from 'src/utils/memoryFileDetection.js'
|
||||
import { createUserMessage } from 'src/utils/messages.js'
|
||||
import { getCanonicalName, getMainLoopModel } from 'src/utils/model/model.js'
|
||||
import {
|
||||
mapNotebookCellsToToolResult,
|
||||
readNotebook,
|
||||
@@ -409,9 +408,7 @@ export const FileReadTool = buildTool({
|
||||
renderToolResultMessage,
|
||||
// UI.tsx:140 — ALL types render summary chrome only: "Read N lines",
|
||||
// "Read image (42KB)". Never the content itself. The model-facing
|
||||
// serialization (below) sends content + CYBER_RISK_MITIGATION_REMINDER
|
||||
// + line prefixes; UI shows none of it. Nothing to index. Caught by
|
||||
// the render-fidelity test when this initially claimed file.content.
|
||||
// serialization (below) sends content + line prefixes; UI shows none of it.
|
||||
extractSearchText() {
|
||||
return ''
|
||||
},
|
||||
@@ -694,12 +691,7 @@ export const FileReadTool = buildTool({
|
||||
let content: string
|
||||
|
||||
if (data.file.content) {
|
||||
content =
|
||||
memoryFileFreshnessPrefix(data) +
|
||||
formatFileLines(data.file) +
|
||||
(shouldIncludeFileReadMitigation()
|
||||
? CYBER_RISK_MITIGATION_REMINDER
|
||||
: '')
|
||||
content = memoryFileFreshnessPrefix(data) + formatFileLines(data.file)
|
||||
} else {
|
||||
// Determine the appropriate warning message
|
||||
content =
|
||||
@@ -727,17 +719,6 @@ function formatFileLines(file: { content: string; startLine: number }): string {
|
||||
return addLineNumbers(file)
|
||||
}
|
||||
|
||||
export const CYBER_RISK_MITIGATION_REMINDER =
|
||||
'\n\n<system-reminder>\nWhenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.\n</system-reminder>\n'
|
||||
|
||||
// Models where cyber risk mitigation should be skipped
|
||||
const MITIGATION_EXEMPT_MODELS = new Set(['claude-opus-4-6'])
|
||||
|
||||
function shouldIncludeFileReadMitigation(): boolean {
|
||||
const shortName = getCanonicalName(getMainLoopModel())
|
||||
return !MITIGATION_EXEMPT_MODELS.has(shortName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Side-channel from call() to mapToolResultToToolResultBlockParam: mtime
|
||||
* of auto-memory files, keyed by the `data` object identity. Avoids
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { Box, Text, useTheme } from '@anthropic/ink';
|
||||
import { getTheme } from 'src/utils/theme.js';
|
||||
import { env } from 'src/utils/env.js';
|
||||
import { shouldShowAlwaysAllowOptions } from 'src/utils/permissions/permissionsLoader.js';
|
||||
import { logUnaryEvent } from 'src/utils/unaryLogging.js';
|
||||
import { PermissionDialog } from 'src/components/permissions/PermissionDialog.js';
|
||||
import { PermissionPrompt, type PermissionPromptOption } from 'src/components/permissions/PermissionPrompt.js';
|
||||
import type { PermissionRequestProps } from 'src/components/permissions/PermissionRequest.js';
|
||||
import { PermissionRuleExplanation } from 'src/components/permissions/PermissionRuleExplanation.js';
|
||||
|
||||
type OptionValue = 'yes' | 'yes-dont-ask-again' | 'no';
|
||||
|
||||
/**
|
||||
* Permission request UI for the WorkflowTool. Asks the user to confirm
|
||||
* executing a workflow script.
|
||||
* Follows the MonitorPermissionRequest / FallbackPermissionRequest pattern.
|
||||
*/
|
||||
export function WorkflowPermissionRequest({
|
||||
toolUseConfirm,
|
||||
onDone,
|
||||
onReject,
|
||||
workerBadge,
|
||||
}: PermissionRequestProps): React.ReactNode {
|
||||
const [themeName] = useTheme();
|
||||
const theme = getTheme(themeName);
|
||||
|
||||
const input = toolUseConfirm.input as {
|
||||
workflow: string;
|
||||
args?: string;
|
||||
};
|
||||
|
||||
const showAlwaysAllowOptions = useMemo(() => shouldShowAlwaysAllowOptions(), []);
|
||||
|
||||
const options: PermissionPromptOption<OptionValue>[] = useMemo(() => {
|
||||
const opts: PermissionPromptOption<OptionValue>[] = [
|
||||
{
|
||||
label: 'Yes',
|
||||
value: 'yes',
|
||||
feedbackConfig: { type: 'accept' as const },
|
||||
},
|
||||
];
|
||||
if (showAlwaysAllowOptions) {
|
||||
opts.push({
|
||||
label: (
|
||||
<Text>
|
||||
Yes, and don{'\u2019'}t ask again for <Text bold>{toolUseConfirm.tool.name}</Text> commands
|
||||
</Text>
|
||||
),
|
||||
value: 'yes-dont-ask-again',
|
||||
});
|
||||
}
|
||||
opts.push({
|
||||
label: 'No',
|
||||
value: 'no',
|
||||
feedbackConfig: { type: 'reject' as const },
|
||||
});
|
||||
return opts;
|
||||
}, [showAlwaysAllowOptions, toolUseConfirm.tool.name]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(value: OptionValue, feedback?: string) => {
|
||||
switch (value) {
|
||||
case 'yes':
|
||||
logUnaryEvent({
|
||||
completion_type: 'tool_use_single',
|
||||
event: 'accept',
|
||||
metadata: {
|
||||
language_name: 'none',
|
||||
message_id: toolUseConfirm.assistantMessage.message.id ?? '',
|
||||
platform: env.platform,
|
||||
},
|
||||
});
|
||||
toolUseConfirm.onAllow(toolUseConfirm.input, [], feedback);
|
||||
onDone();
|
||||
break;
|
||||
case 'yes-dont-ask-again':
|
||||
logUnaryEvent({
|
||||
completion_type: 'tool_use_single',
|
||||
event: 'accept',
|
||||
metadata: {
|
||||
language_name: 'none',
|
||||
message_id: toolUseConfirm.assistantMessage.message.id ?? '',
|
||||
platform: env.platform,
|
||||
},
|
||||
});
|
||||
toolUseConfirm.onAllow(toolUseConfirm.input, [
|
||||
{
|
||||
type: 'addRules',
|
||||
rules: [{ toolName: toolUseConfirm.tool.name }],
|
||||
behavior: 'allow',
|
||||
destination: 'localSettings',
|
||||
},
|
||||
]);
|
||||
onDone();
|
||||
break;
|
||||
case 'no':
|
||||
logUnaryEvent({
|
||||
completion_type: 'tool_use_single',
|
||||
event: 'reject',
|
||||
metadata: {
|
||||
language_name: 'none',
|
||||
message_id: toolUseConfirm.assistantMessage.message.id ?? '',
|
||||
platform: env.platform,
|
||||
},
|
||||
});
|
||||
toolUseConfirm.onReject(feedback);
|
||||
onReject();
|
||||
onDone();
|
||||
break;
|
||||
}
|
||||
},
|
||||
[toolUseConfirm, onDone, onReject],
|
||||
);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
logUnaryEvent({
|
||||
completion_type: 'tool_use_single',
|
||||
event: 'reject',
|
||||
metadata: {
|
||||
language_name: 'none',
|
||||
message_id: toolUseConfirm.assistantMessage.message.id ?? '',
|
||||
platform: env.platform,
|
||||
},
|
||||
});
|
||||
toolUseConfirm.onReject();
|
||||
onReject();
|
||||
onDone();
|
||||
}, [toolUseConfirm, onDone, onReject]);
|
||||
|
||||
return (
|
||||
<PermissionDialog title="Workflow" workerBadge={workerBadge}>
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Box flexDirection="column">
|
||||
<Text bold color={theme.permission as any}>
|
||||
Execute workflow: {input.workflow}
|
||||
</Text>
|
||||
{input.args && <Text dimColor>Arguments: {input.args}</Text>}
|
||||
</Box>
|
||||
<PermissionRuleExplanation permissionResult={toolUseConfirm.permissionResult} toolType="command" />
|
||||
<PermissionPrompt<OptionValue> options={options} onSelect={handleSelect} onCancel={handleCancel} />
|
||||
</Box>
|
||||
</PermissionDialog>
|
||||
);
|
||||
}
|
||||
@@ -1,432 +0,0 @@
|
||||
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<Input>
|
||||
|
||||
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<string[]> {
|
||||
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<WorkflowRun | null> {
|
||||
try {
|
||||
const parsed = safeParseJSON(
|
||||
await readFile(workflowRunPath(cwd, runId), 'utf-8'),
|
||||
false,
|
||||
) as Partial<WorkflowRun> | 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<void> {
|
||||
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<WorkflowRun[]> {
|
||||
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<WorkflowStep> | 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<WorkflowOutput> {
|
||||
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<WorkflowOutput> {
|
||||
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<WorkflowOutput> {
|
||||
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<WorkflowOutput> {
|
||||
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<WorkflowInput>) {
|
||||
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) }
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -1,104 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
||||
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { WorkflowTool } from '../WorkflowTool'
|
||||
|
||||
let cwd: string
|
||||
let previousCwd: string
|
||||
|
||||
beforeEach(async () => {
|
||||
previousCwd = process.cwd()
|
||||
cwd = join(
|
||||
tmpdir(),
|
||||
`workflow-tool-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
)
|
||||
await mkdir(join(cwd, '.claude', 'workflows'), { recursive: true })
|
||||
process.chdir(cwd)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
process.chdir(previousCwd)
|
||||
await rm(cwd, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('WorkflowTool', () => {
|
||||
test('starts a workflow run and persists step state', async () => {
|
||||
await writeFile(
|
||||
join(cwd, '.claude', 'workflows', 'release.md'),
|
||||
['# Release', '', '- [ ] Run tests', '- [ ] Build package'].join('\n'),
|
||||
)
|
||||
|
||||
const result = await WorkflowTool.call({ workflow: 'release' })
|
||||
|
||||
expect(result.data.output).toContain('Workflow run started')
|
||||
expect(result.data.output).toContain('Run tests')
|
||||
const match = result.data.output.match(/run_id: ([a-f0-9-]+)/)
|
||||
expect(match?.[1]).toBeString()
|
||||
|
||||
const raw = await readFile(
|
||||
join(cwd, '.claude', 'workflow-runs', `${match![1]}.json`),
|
||||
'utf-8',
|
||||
)
|
||||
const run = JSON.parse(raw)
|
||||
expect(run.workflow).toBe('release')
|
||||
expect(run.status).toBe('running')
|
||||
expect(run.steps).toHaveLength(2)
|
||||
expect(run.steps[0].status).toBe('running')
|
||||
expect(run.steps[1].status).toBe('pending')
|
||||
})
|
||||
|
||||
test('advances a workflow run through completion', async () => {
|
||||
await writeFile(
|
||||
join(cwd, '.claude', 'workflows', 'audit.yaml'),
|
||||
[
|
||||
'steps:',
|
||||
' - name: Inspect',
|
||||
' prompt: Inspect the code',
|
||||
' - name: Verify',
|
||||
' prompt: Run focused tests',
|
||||
].join('\n'),
|
||||
)
|
||||
|
||||
const started = await WorkflowTool.call({ workflow: 'audit' })
|
||||
const runId = started.data.output.match(/run_id: ([a-f0-9-]+)/)![1]!
|
||||
|
||||
const next = await WorkflowTool.call({
|
||||
workflow: 'audit',
|
||||
action: 'advance',
|
||||
run_id: runId,
|
||||
})
|
||||
expect(next.data.output).toContain('Next workflow step')
|
||||
expect(next.data.output).toContain('Run focused tests')
|
||||
|
||||
const done = await WorkflowTool.call({
|
||||
workflow: 'audit',
|
||||
action: 'advance',
|
||||
run_id: runId,
|
||||
})
|
||||
expect(done.data.output).toContain('Workflow completed')
|
||||
})
|
||||
|
||||
test('lists and cancels workflow runs', async () => {
|
||||
await writeFile(
|
||||
join(cwd, '.claude', 'workflows', 'cleanup.md'),
|
||||
'- Remove stale files',
|
||||
)
|
||||
|
||||
const started = await WorkflowTool.call({ workflow: 'cleanup' })
|
||||
const runId = started.data.output.match(/run_id: ([a-f0-9-]+)/)![1]!
|
||||
|
||||
const listed = await WorkflowTool.call({
|
||||
workflow: 'cleanup',
|
||||
action: 'list',
|
||||
})
|
||||
expect(listed.data.output).toContain(runId)
|
||||
|
||||
const cancelled = await WorkflowTool.call({
|
||||
workflow: 'cleanup',
|
||||
action: 'cancel',
|
||||
run_id: runId,
|
||||
})
|
||||
expect(cancelled.data.output).toContain('Workflow cancelled')
|
||||
})
|
||||
})
|
||||
@@ -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,3 +0,0 @@
|
||||
export const WORKFLOW_TOOL_NAME = 'workflow'
|
||||
export const WORKFLOW_DIR_NAME = '.claude/workflows'
|
||||
export const WORKFLOW_FILE_EXTENSIONS = ['.yml', '.yaml', '.md']
|
||||
@@ -1,46 +0,0 @@
|
||||
import { readdir } from 'fs/promises'
|
||||
import { join, parse } from 'path'
|
||||
import type { Command } from 'src/types/command.js'
|
||||
import { WORKFLOW_DIR_NAME, WORKFLOW_FILE_EXTENSIONS } from './constants.js'
|
||||
|
||||
/**
|
||||
* Scans .claude/workflows/ directory and creates Command objects for each workflow file.
|
||||
* Each workflow file becomes a slash command (e.g. /workflow-name).
|
||||
*/
|
||||
export async function getWorkflowCommands(cwd: string): Promise<Command[]> {
|
||||
const workflowDir = join(cwd, WORKFLOW_DIR_NAME)
|
||||
let files: string[]
|
||||
try {
|
||||
files = await readdir(workflowDir)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
const workflowFiles = files.filter(f => {
|
||||
const ext = parse(f).ext.toLowerCase()
|
||||
return WORKFLOW_FILE_EXTENSIONS.includes(ext)
|
||||
})
|
||||
|
||||
return workflowFiles.map(file => {
|
||||
const name = parse(file).name
|
||||
return {
|
||||
type: 'prompt' as const,
|
||||
name,
|
||||
description: `Run workflow: ${name}`,
|
||||
kind: 'workflow' as const,
|
||||
source: 'builtin' as const,
|
||||
progressMessage: `Running workflow ${name}...`,
|
||||
contentLength: 0,
|
||||
async getPromptForCommand(args, _context) {
|
||||
const { readFile } = await import('fs/promises')
|
||||
const content = await readFile(join(workflowDir, file), 'utf-8')
|
||||
return [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Execute this workflow:\n\n${content}${args ? `\n\nArguments: ${args}` : ''}`,
|
||||
},
|
||||
]
|
||||
},
|
||||
} satisfies Command
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user