Files
claude-code/docs/superpowers/plans/2026-06-13-workflow-tui-ultracode.md
claude-code-best 58ee6419b1 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>
2026-06-14 18:13:49 +08:00

2023 lines
70 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Workflow 集成层重写 + `/workflows` 面板 + `/ultracode` skill 实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 在引擎包地基上全量重写 `src/workflow/` 集成层Service 门面 + 单一深度 `claude-code` 后端 + 进度 bus/reducer交付 `/workflows` 双栏扁平面板与 `/ultracode` 知识 skill。
**Architecture:** `WorkflowService` 单例持有共享 `WorkflowPorts`(含 `agentAdapterRegistry`——引擎 hooks 已优先用它);`claudeCodeBackend` 是唯一 `AgentAdapter`,深度从活会话解析 provider/model/agentType/tools进度走 `progressBus`(多订阅)→ `progressStore` reducer`agentId` 精确关联,修旧 LIFO 竞态);面板 `useSyncExternalStore` 订阅 store。引擎唯一微调`agent_started`/`agent_done``agentId`
**Tech Stack:** TypeScript strict、Bun`bun:test`、Zod、React/Ink`@anthropic/ink`)、`useSyncExternalStore`
**Spec:** `docs/superpowers/specs/2026-06-13-workflow-tui-ultracode-design.md`
---
## 关键外部接口(已核实,计划代码据此编写)
- `WorkflowPorts``packages/workflow-engine/src/ports.ts``{ agentRunner, agentAdapterRegistry?, progressEmitter, taskRegistrar, journalStore, permissionGate, logger, hostFactory }`。**hooks 已优先用 `agentAdapterRegistry`**`engine/hooks.ts:87-94`),省略则回退 `agentRunner`
- `AgentAdapter``agentAdapter.ts``{ id, capabilities: {structuredOutput, tools?, stream?}, run(params, ctx: {host, signal, runId}), initialize?(), dispose?() }``AgentAdapterRegistry``register/default/route/resolve/has/get/initializeAll/disposeAll`
- `runWorkflow({script, args?, runId, workflowName?, ports, host, signal, cwd, budgetTotal, resume?, scriptChanged?})``WorkflowRunResult`
- `createWorkflowTool(ports)``WorkflowToolDescriptor``call(input, context, canUseTool, parentMessage, onProgress?) → {data:{output}}`)。
- `parseScript``createFileJournalStore(dir)``resolveNamedWorkflow(dir, name)``listNamedWorkflows(dir)``createHostHandle/unwrapHostHandle``WORKFLOW_DIR_NAME='.claude/workflows'``WORKFLOW_RUNS_DIR='.claude/workflow-runs'`
- 核心:`runAgent({agentDefinition, promptMessages, toolUseContext, canUseTool, isAsync, querySource, availableTools, override:{agentId, model?}})`async generator`assembleToolPool(permissionContext, mcpTools)``src/tools.ts``finalizeAgentTool(messages, agentId, {prompt, resolvedAgentModel, isBuiltInAgent, startTime, agentType, isAsync})``.content`/`.usage.output_tokens`/`.totalTokens``isBuiltInAgent``BuiltInAgentDefinition``AgentDefinition``loadAgentsDir`)。
- `LocalWorkflowTask``registerLocalWorkflowTask(setAppState, {description, workflowName, workflowFile, summary?, toolUseId?, agentId?, abortController?}) → taskId``completeWorkflowTask/failWorkflowTask/killWorkflowTask(taskId, setAppState)`
- `buildTool(def)``src/Tool.ts``Tool.call(args, context, canUseTool, parentMessage, onProgress?)`
- local-jsx 命令:`{ type:'local-jsx', name, description, isEnabled?, load: () => Promise<{call}> }``call: (onDone, context: ToolUseContext & LocalJSXCommandContext, args) => Promise<ReactNode>`
- 注册点(**保留导出名/路径即零改动**`src/tools.ts:152``require('./workflow/wiring.js').createWorkflowToolCore()`)、`src/commands.ts:95``require('./commands/workflows/index.js')` 默认导出)、`src/commands.ts:480``require('./workflow/namedWorkflowCommands.js').getWorkflowCommands`)、`src/constants/tools.ts:35``WORKFLOW_TOOL_NAME`)、`src/tasks.ts:9``src/components/permissions/PermissionRequest.tsx:48,51`
## 文件结构
**引擎包改动M1**
- Modify `packages/workflow-engine/src/types.ts``agent_started`/`agent_done``agentId`
- Modify `packages/workflow-engine/src/engine/context.ts``SharedResources``agentIdSeq`
- Modify `packages/workflow-engine/src/engine/hooks.ts` — 盖戳 `agentId`
- Test `packages/workflow-engine/src/__tests__/agentId.test.ts`
**src/workflow 集成层M2M5**
- Create `src/workflow/progress/bus.ts` — 类型化发布/订阅。
- Create `src/workflow/progress/store.ts``RunProgress`/`AgentProgress` 类型 + reducer按 agentId
- Create `src/workflow/backends/claudeCodeBackend.ts``AgentAdapter` + 体系解析 helpers。
- Create `src/workflow/registry.ts` — 建 `AgentAdapterRegistry`(单 adapter
- Create `src/workflow/ports.ts` — 组装 `WorkflowPorts`(含 `agentAdapterRegistry`、taskRegistrar bindings
- Create `src/workflow/service.ts``WorkflowService` 单例。
- Rewrite `src/workflow/wiring.ts`(保留 `createWorkflowToolCore` 导出)。
- Delete `src/workflow/adapter.ts``src/workflow/progressStore.ts`
- Keep `src/workflow/hostHandle.ts``namedWorkflowCommands.ts``WorkflowPermissionRequest.tsx`
**面板M6**
- Create `src/workflow/panel/WorkflowList.tsx``WorkflowDetail.tsx``useWorkflowKeyboard.ts``WorkflowsPanel.tsx`
- Rewrite `src/commands/workflows/index.ts`local-jsx
- Modify `src/components/tasks/BackgroundTasksDialog.tsx` — 去 `WorkflowDetailDialog`
- Delete `src/components/tasks/WorkflowDetailDialog.tsx`
**skill + 文档M7M8**
- Create `src/skills/bundled/ultracode/SKILL.md`
- Update `docs/features/workflow-scripts.md`
---
## Phase M1引擎进度事件加 `agentId`
### Task 1`ProgressEvent` 加 `agentId` 字段
**Files:**
- Modify: `packages/workflow-engine/src/types.ts:69-76`
- [ ] **Step 1改 `agent_started`/`agent_done` 变体加 `agentId: number`**
`types.ts` 中的:
```ts
| { type: 'agent_started'; runId: string; label?: string; phase?: string }
| {
type: 'agent_done'
runId: string
label?: string
phase?: string
result: AgentRunResult
}
```
替换为:
```ts
| {
type: 'agent_started'
runId: string
agentId: number
label?: string
phase?: string
}
| {
type: 'agent_done'
runId: string
agentId: number
label?: string
phase?: string
result: AgentRunResult
}
```
- [ ] **Step 2类型检查**
Run: `cd packages/workflow-engine && bunx tsc --noEmit 2>&1 | head`
Expected: 报错指向 `engine/hooks.ts``emit({ type: 'agent_started'/'agent_done', ... })``agentId`预期Task 3 修复)。
### Task 2`SharedResources` 加 `agentIdSeq`
**Files:**
- Modify: `packages/workflow-engine/src/engine/context.ts:10-15, 32-41`
- [ ] **Step 1类型加字段 + 初始化**
`SharedResources` 类型:
```ts
export type SharedResources = {
semaphore: Semaphore
budget: Budget
agentCountBox: { value: number }
depth: number
}
```
替换为:
```ts
export type SharedResources = {
semaphore: Semaphore
budget: Budget
agentCountBox: { value: number }
/** agent() 调用的递增序号,盖戳 agent_started/agent_done 供进度精确关联。子 workflow 共享。 */
agentIdSeq: { value: number }
depth: number
}
```
`createSharedResources`
```ts
return {
semaphore: new Semaphore(maxConcurrency()),
budget: new Budget(budgetTotal),
agentCountBox: { value: 0 },
depth: 0,
}
```
替换为:
```ts
return {
semaphore: new Semaphore(maxConcurrency()),
budget: new Budget(budgetTotal),
agentCountBox: { value: 0 },
agentIdSeq: { value: 0 },
depth: 0,
}
```
### Task 3hooks 盖戳 `agentId`
**Files:**
- Modify: `packages/workflow-engine/src/engine/hooks.ts:21-31, 45-108`
- [ ] **Step 1`HookProgressInit` 的 agent 变体加 `agentId`**
把:
```ts
type HookProgressInit =
| { type: 'phase_started'; phase: string }
| { type: 'phase_done'; phase: string }
| { type: 'agent_started'; label?: string; phase?: string }
| {
type: 'agent_done'
label?: string
phase?: string
result: AgentRunResult
}
| { type: 'log'; message: string }
```
替换为:
```ts
type HookProgressInit =
| { type: 'phase_started'; phase: string }
| { type: 'phase_done'; phase: string }
| { type: 'agent_started'; agentId: number; label?: string; phase?: string }
| {
type: 'agent_done'
agentId: number
label?: string
phase?: string
result: AgentRunResult
}
| { type: 'log'; message: string }
```
- [ ] **Step 2`agent()` 内分配并盖戳 `agentId`**
`agent` 函数体中(`budget.assertCanSpend()` 之后、`const params` 之前)插入 id 分配,并给三处 `emit``agentId`。当前:
```ts
r.budget.assertCanSpend()
const params: AgentRunParams = { prompt, ...opts }
const key = agentCallKey(prompt, params)
const label = opts.label as string | undefined
const phase =
(opts.phase as string | undefined) ?? ctx.currentPhase ?? undefined
// journal 命中 → 直接返回缓存
if (!ctx.journalInvalidated && ctx.journalIndex < ctx.journal.length) {
const entry = ctx.journal[ctx.journalIndex]!
if (entry.key === key) {
ctx.journalIndex++
emit({ type: 'agent_done', label, phase, result: entry.result })
return resultToOutput(entry.result)
}
```
替换为:
```ts
r.budget.assertCanSpend()
// 每次 agent() 调用分配唯一 id含 journal 命中),盖戳 started/done 供 reducer 精确关联
const agentId = r.agentIdSeq.value++
const params: AgentRunParams = { prompt, ...opts }
const key = agentCallKey(prompt, params)
const label = opts.label as string | undefined
const phase =
(opts.phase as string | undefined) ?? ctx.currentPhase ?? undefined
// journal 命中 → 直接返回缓存
if (!ctx.journalInvalidated && ctx.journalIndex < ctx.journal.length) {
const entry = ctx.journal[ctx.journalIndex]!
if (entry.key === key) {
ctx.journalIndex++
emit({ type: 'agent_done', agentId, label, phase, result: entry.result })
return resultToOutput(entry.result)
}
```
把 live 分支两处 emit
```ts
ctx.resources.agentCountBox.value++
emit({ type: 'agent_started', label, phase })
```
替换为:
```ts
ctx.resources.agentCountBox.value++
emit({ type: 'agent_started', agentId, label, phase })
```
把:
```ts
emit({ type: 'agent_done', label, phase, result })
```
替换为:
```ts
emit({ type: 'agent_done', agentId, label, phase, result })
```
- [ ] **Step 3类型检查 + 全包测试**
Run: `cd packages/workflow-engine && bunx tsc --noEmit && bun test 2>&1 | tail -5`
Expected: 类型零错误;现有测试仍 PASS既有 hooks 测试不校验 agentId
- [ ] **Step 4写 agentId 配对回归测试**
Create `packages/workflow-engine/src/__tests__/agentId.test.ts`:
```ts
import { expect, test } from 'bun:test'
import { createEngineContext } from '../engine/context.js'
import { makeHooks } from '../engine/hooks.js'
import { createBufferingEmitter } from '../progress/events.js'
import { createHostHandle, type WorkflowPorts } from '../ports.js'
import type { AgentRunParams, AgentRunResult } from '../types.js'
function build(results: Map<string, AgentRunResult>) {
const { emitter, events } = createBufferingEmitter()
const ports: WorkflowPorts = {
agentRunner: {
runAgentToResult: async (p: AgentRunParams) => results.get(p.prompt) ?? { kind: 'dead' },
},
progressEmitter: emitter,
taskRegistrar: {
register: () => ({ runId: 'r', signal: new AbortController().signal }),
complete: () => {}, fail: () => {}, kill: () => {}, pendingAction: () => null,
},
journalStore: { read: async () => [], append: async () => {}, truncate: async () => {} },
permissionGate: { isAborted: () => false },
logger: { debug: () => {}, event: () => {} },
hostFactory: () => ({
handle: createHostHandle(null),
signal: new AbortController().signal, cwd: '/tmp', budgetTotal: null,
}),
}
const ctx = createEngineContext({
ports, host: createHostHandle(null), signal: new AbortController().signal,
runId: 'r', workflowName: 'w', cwd: '/tmp', budgetTotal: null,
})
return { ctx, events, hooks: makeHooks(ctx, async () => null) }
}
test('并发 agent 各自拿到唯一 agentIdstarted/done 配对', async () => {
const ok = (out: string): AgentRunResult => ({ kind: 'ok', output: out, usage: { outputTokens: 1 } })
const { ctx, events, hooks } = build(new Map([['a', ok('1')], ['b', ok('2')]]))
// 并发跑两个 agent
await hooks.parallel([() => hooks.agent('a'), () => hooks.agent('b')])
const started = events.filter(e => e.type === 'agent_started')
const done = events.filter(e => e.type === 'agent_done')
expect(started).toHaveLength(2)
expect(done).toHaveLength(2)
// 每个 started 都有数值 agentId
const ids = started.map(e => (e as { agentId: number }).agentId)
expect(new Set(ids).size).toBe(2) // 唯一
// 每个 done 的 agentId 都能在 started 里找到
for (const d of done as Array<{ agentId: number }>) {
expect(ids).toContain(d.agentId)
}
// 计数与序号推进
expect(ctx.resources.agentIdSeq.value).toBe(2)
})
test('agentId 单调递增', async () => {
const ok = (out: string): AgentRunResult => ({ kind: 'ok', output: out, usage: { outputTokens: 1 } })
const { events, hooks } = build(new Map([['a', ok('1')], ['b', ok('2')], ['c', ok('3')]]))
await hooks.agent('a'); await hooks.agent('b'); await hooks.agent('c')
const ids = events
.filter(e => e.type === 'agent_started')
.map(e => (e as { agentId: number }).agentId)
expect(ids).toEqual([0, 1, 2])
})
```
- [ ] **Step 5运行测试**
Run: `cd packages/workflow-engine && bun test src/__tests__/agentId.test.ts`
Expected: 2 PASS。
- [ ] **Step 6提交**
```bash
git add packages/workflow-engine/src/types.ts packages/workflow-engine/src/engine/context.ts packages/workflow-engine/src/engine/hooks.ts packages/workflow-engine/src/__tests__/agentId.test.ts
git commit -m "feat(workflow-engine): stamp agentId on agent_started/agent_done for exact progress correlation"
```
---
## Phase M2进度 bus + store
### Task 4进度事件总线 `progress/bus.ts`
**Files:**
- Create: `src/workflow/progress/bus.ts`
- Test: `src/workflow/__tests__/progressBus.test.ts`
- [ ] **Step 1写失败测试**
Create `src/workflow/__tests__/progressBus.test.ts`:
```ts
import { expect, test, mock } from 'bun:test'
import { createProgressBus } from '../progress/bus.js'
test('emit 广播给所有订阅者', () => {
const bus = createProgressBus()
const a = mock(() => {})
const b = mock(() => {})
bus.subscribe(a)
bus.subscribe(b)
const ev = { type: 'log' as const, runId: 'r', message: 'hi' }
bus.emit(ev)
expect(a).toHaveBeenCalledTimes(1)
expect(b).toHaveBeenCalledWith(ev)
})
test('subscribe 返回取消订阅', () => {
const bus = createProgressBus()
const fn = mock(() => {})
const unsub = bus.subscribe(fn)
unsub()
bus.emit({ type: 'log', runId: 'r', message: 'x' })
expect(fn).not.toHaveBeenCalled()
})
```
- [ ] **Step 2运行确认失败**
Run: `bun test src/workflow/__tests__/progressBus.test.ts`
Expected: FAIL模块不存在
- [ ] **Step 3实现 `bus.ts`**
Create `src/workflow/progress/bus.ts`:
```ts
import type { ProgressEvent } from '@claude-code-best/workflow-engine'
/** 类型化进度事件总线。引擎 progressEmitter.emit → 广播给所有订阅者store / 遥测)。 */
export type ProgressBus = {
emit(event: ProgressEvent): void
subscribe(listener: (event: ProgressEvent) => void): () => void
}
export function createProgressBus(): ProgressBus {
const listeners = new Set<(event: ProgressEvent) => void>()
return {
emit(event) {
for (const fn of listeners) fn(event)
},
subscribe(listener) {
listeners.add(listener)
return () => listeners.delete(listener)
},
}
}
```
- [ ] **Step 4运行测试**
Run: `bun test src/workflow/__tests__/progressBus.test.ts`
Expected: 2 PASS。
- [ ] **Step 5提交**
```bash
git add src/workflow/progress/bus.ts src/workflow/__tests__/progressBus.test.ts
git commit -m "feat(workflow): add typed progress event bus"
```
### Task 5进度 reducer `progress/store.ts`(按 agentId 关联)
**Files:**
- Create: `src/workflow/progress/store.ts`
- Test: `src/workflow/__tests__/progressStore.test.ts`
- [ ] **Step 1写失败测试含并发 agentId 关联回归)**
Create `src/workflow/__tests__/progressStore.test.ts`:
```ts
import { expect, test } from 'bun:test'
import { createProgressBus, type ProgressBus } from '../progress/bus.js'
import { createProgressStoreFromBus } from '../progress/store.js'
import type { ProgressEvent, AgentRunResult } from '@claude-code-best/workflow-engine'
const ok = (o: string): AgentRunResult => ({ kind: 'ok', output: o, usage: { outputTokens: 1 } })
function newStore() {
const bus: ProgressBus = createProgressBus()
return { bus, store: createProgressStoreFromBus(bus) }
}
function ev(e: Omit<ProgressEvent, never>): ProgressEvent {
return e
}
test('run_started 建条目phase_started/done 更新 phases', () => {
const { bus, store } = newStore()
bus.emit(ev({ type: 'run_started', runId: 'r1', workflowName: 'w', meta: null }))
bus.emit(ev({ type: 'phase_started', runId: 'r1', phase: 'A' }))
bus.emit(ev({ type: 'phase_started', runId: 'r1', phase: 'B' }))
bus.emit(ev({ type: 'phase_done', runId: 'r1', phase: 'A' }))
const r = store.get('r1')!
expect(r.phases.map(p => [p.title, p.status])).toEqual([['A', 'done'], ['B', 'running']])
expect(r.currentPhase).toBe('B')
})
test('并发 agent_done 按 agentId 精确关联(回归旧 LIFO 竞态)', () => {
const { bus, store } = newStore()
bus.emit(ev({ type: 'run_started', runId: 'r1', workflowName: 'w', meta: null }))
// 两个并发 agentstarted 顺序 0,1但 done 顺序 1,0颠倒
bus.emit(ev({ type: 'agent_started', runId: 'r1', agentId: 0, label: 'a', phase: 'A' }))
bus.emit(ev({ type: 'agent_started', runId: 'r1', agentId: 1, label: 'b', phase: 'A' }))
bus.emit(ev({ type: 'agent_done', runId: 'r1', agentId: 1, label: 'b', phase: 'A', result: ok('b-out') }))
bus.emit(ev({ type: 'agent_done', runId: 'r1', agentId: 0, label: 'a', phase: 'A', result: ok('a-out') }))
const agents = store.get('r1')!.agents
// 各自按 id 落位,不串
expect(agents.find(x => x.id === 0)?.status).toBe('done')
expect(agents.find(x => x.id === 1)?.status).toBe('done')
expect(agents.find(x => x.id === 0)?.label).toBe('a')
expect(agents.find(x => x.id === 1)?.label).toBe('b')
})
test('journal 命中(仅 agent_done 无 started按 id 补建 done 条目', () => {
const { bus, store } = newStore()
bus.emit(ev({ type: 'run_started', runId: 'r1', workflowName: 'w', meta: null }))
bus.emit(ev({ type: 'agent_done', runId: 'r1', agentId: 7, label: 'c', phase: 'A', result: ok('c') }))
const a = store.get('r1')!.agents.find(x => x.id === 7)!
expect(a.status).toBe('done')
})
test('run_done 终态 + list 排序 + subscribe 通知', () => {
const { bus, store } = newStore()
let calls = 0
store.subscribe(() => calls++)
bus.emit(ev({ type: 'run_started', runId: 'r1', workflowName: 'w', meta: null }))
bus.emit(ev({ type: 'run_done', runId: 'r1', status: 'completed', returnValue: 42 }))
const r = store.get('r1')!
expect(r.status).toBe('completed')
expect(r.returnValue).toBe(42)
expect(store.list().map(x => x.runId)).toEqual(['r1'])
expect(calls).toBeGreaterThanOrEqual(2)
})
```
- [ ] **Step 2运行确认失败**
Run: `bun test src/workflow/__tests__/progressStore.test.ts`
Expected: FAIL`../progress/store.js` 无导出)。
- [ ] **Step 3实现 `store.ts`**
Create `src/workflow/progress/store.ts`:
```ts
import type { ProgressEvent } from '@claude-code-best/workflow-engine'
import type { ProgressBus } from './bus.js'
export type AgentProgress = {
/** 引擎盖戳的唯一 id精确关联 started/done修旧 LIFO 竞态)。 */
id: number
label?: string
phase?: string
status: 'running' | 'done'
resultKind?: string
}
export type RunProgress = {
runId: string
workflowName: string
status: 'running' | 'completed' | 'failed' | 'killed'
phases: Array<{ title: string; status: 'running' | 'done' }>
currentPhase: string | null
agents: AgentProgress[]
agentCount: number
returnValue?: unknown
error?: string
updatedAt: number
}
export type ProgressStore = {
apply(event: ProgressEvent): void
list(): RunProgress[]
get(runId: string): RunProgress | undefined
/** 供 useSyncExternalStore返回稳定引用无变更时同一数组。 */
subscribe(listener: () => void): () => void
getSnapshot(): RunProgress[]
}
/** 从 bus 构造 reactive store订阅 bus归约事件通知 React 订阅者。 */
export function createProgressStoreFromBus(bus: ProgressBus): ProgressStore {
const byId = new Map<string, RunProgress>()
let snapshot: RunProgress[] = []
const listeners = new Set<() => void>()
const notify = (): void => {
snapshot = [...byId.values()].sort((a, b) => b.updatedAt - a.updatedAt)
for (const fn of listeners) fn()
}
const ensure = (runId: string, workflowName: string): RunProgress => {
let p = byId.get(runId)
if (!p) {
p = {
runId, workflowName, status: 'running', phases: [], currentPhase: null,
agents: [], agentCount: 0, updatedAt: Date.now(),
}
byId.set(runId, p)
}
return p
}
const apply = (event: ProgressEvent): void => {
const runId = event.runId
const p = ensure(runId, 'workflowName' in event ? event.workflowName : 'workflow')
p.updatedAt = Date.now()
switch (event.type) {
case 'run_started':
p.workflowName = event.workflowName
p.status = 'running'
break
case 'phase_started':
if (!p.phases.some(ph => ph.title === event.phase)) {
p.phases.push({ title: event.phase, status: 'running' })
}
p.currentPhase = event.phase
break
case 'phase_done':
for (const ph of p.phases) if (ph.title === event.phase) ph.status = 'done'
if (p.currentPhase === event.phase) p.currentPhase = null
break
case 'agent_started': {
// 按 id upsert幂等
let a = p.agents.find(x => x.id === event.agentId)
if (!a) {
a = { id: event.agentId, label: event.label, phase: event.phase, status: 'running' }
p.agents.push(a)
p.agentCount++
} else {
a.status = 'running'; a.label = event.label; a.phase = event.phase
}
break
}
case 'agent_done': {
// 按 id 精确落位;无 startedjournal 命中)则补建 done 条目
let a = p.agents.find(x => x.id === event.agentId)
if (!a) {
a = { id: event.agentId, label: event.label, phase: event.phase, status: 'done' }
p.agents.push(a)
} else {
a.status = 'done'; a.resultKind = event.result.kind
}
break
}
case 'log':
break
case 'run_done':
p.status = event.status
if (event.returnValue !== undefined) p.returnValue = event.returnValue
if (event.error !== undefined) p.error = event.error
break
}
notify()
}
bus.subscribe(apply)
return {
apply,
list: () => snapshot,
get: id => byId.get(id),
subscribe: fn => {
listeners.add(fn)
return () => listeners.delete(fn)
},
getSnapshot: () => snapshot,
}
}
```
- [ ] **Step 4运行测试**
Run: `bun test src/workflow/__tests__/progressStore.test.ts`
Expected: 4 PASS。
- [ ] **Step 5提交**
```bash
git add src/workflow/progress/store.ts src/workflow/__tests__/progressStore.test.ts
git commit -m "feat(workflow): progress store keyed by agentId (fixes concurrent correlation race)"
```
---
## Phase M3后端 + Registry + ports
### Task 6深度后端 `backends/claudeCodeBackend.ts`
**Files:**
- Create: `src/workflow/backends/claudeCodeBackend.ts`
- Test: `src/workflow/__tests__/claudeCodeBackend.test.ts`
> 说明:把旧 `adapter.ts` 的 `runWorkflowSubAgent` 逻辑抽成 `AgentAdapter`,并加 agentType→真实注册表、model→映射解析。
- [ ] **Step 1写失败测试mock `runAgent`/`assembleToolPool`/`finalizeAgentTool`**
Create `src/workflow/__tests__/claudeCodeBackend.test.ts`:
```ts
import { expect, test, mock } from 'bun:test'
// mock 底层依赖(不 mock 被测业务模块)
mock.module('@claude-code-best/builtin-tools/tools/AgentTool/runAgent.js', () => ({
runAgent: async function* () {
yield { type: 'assistant', message: { content: [{ type: 'text', text: 'agent-text' }] } }
},
}))
mock.module('@claude-code-best/builtin-tools/tools/AgentTool/agentToolUtils.js', () => ({
finalizeAgentTool: () => ({
content: [{ type: 'text', text: 'agent-text' }],
usage: { output_tokens: 42 },
totalTokens: 42,
}),
isBuiltInAgent: () => true,
}))
mock.module('@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js', () => ({
isBuiltInAgent: () => true,
}))
mock.module('../tools.js', () => ({
assembleToolPool: () => ({ tools: [] }),
}))
mock.module('../utils/messages.js', () => ({
createUserMessage: (o: { content: string }) => ({ role: 'user', content: o.content }),
extractTextContent: (_c: unknown, sep: string) => 'agent-text',
}))
mock.module('../utils/uuid.js', () => ({ createAgentId: () => 'agent-1' }))
mock.module('../services/analytics/index.js', () => ({ logEvent: () => {} }))
mock.module('../utils/debug.js', () => ({ logForDebugging: () => {} }))
import { claudeCodeBackend } from '../backends/claudeCodeBackend.js'
import { makeHostHandle } from '../hostHandle.js'
function ctx() {
return { host: makeHostHandle({
toolUseContext: {
options: { agentDefinitions: { activeAgents: [] }, querySource: 'workflow', mainLoopModel: 'm' },
getAppState: () => ({ toolPermissionContext: { mode: 'acceptEdits', alwaysAllowRules: {} }, mcp: { tools: [] } }),
} as never,
canUseTool: (() => Promise.resolve({ behavior: 'allow' })) as never,
parentMessage: undefined,
}), signal: new AbortController().signal, runId: 'r1' }
}
test('文本 agent → ok + token 计量', async () => {
const res = await claudeCodeBackend.run({ prompt: 'do it' }, ctx())
expect(res.kind).toBe('ok')
if (res.kind === 'ok') {
expect(res.output).toBe('agent-text')
expect(res.usage.outputTokens).toBe(42)
}
})
test('runAgent 抛错 → dead', async () => {
mock.module('@claude-code-best/builtin-tools/tools/AgentTool/runAgent.js', () => ({
runAgent: async function* () { throw new Error('boom') },
}))
const res = await claudeCodeBackend.run({ prompt: 'fail' }, ctx())
expect(res.kind).toBe('dead')
})
test('id 与 capabilities 形状', () => {
expect(claudeCodeBackend.id).toBe('claude-code')
expect(claudeCodeBackend.capabilities.structuredOutput).toBe(true)
})
```
- [ ] **Step 2运行确认失败**
Run: `bun test src/workflow/__tests__/claudeCodeBackend.test.ts`
Expected: FAIL模块不存在
- [ ] **Step 3实现 `claudeCodeBackend.ts`**
Create `src/workflow/backends/claudeCodeBackend.ts`:
```ts
import {
type AgentAdapter,
type AgentAdapterContext,
type AgentRunParams,
type AgentRunResult,
} from '@claude-code-best/workflow-engine'
import { assembleToolPool } from '../../tools.js'
import { finalizeAgentTool } from '@claude-code-best/builtin-tools/tools/AgentTool/agentToolUtils.js'
import { runAgent } from '@claude-code-best/builtin-tools/tools/AgentTool/runAgent.js'
import {
isBuiltInAgent,
type AgentDefinition,
type BuiltInAgentDefinition,
} from '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js'
import { createUserMessage, extractTextContent } from '../../utils/messages.js'
import { createAgentId } from '../../utils/uuid.js'
import { logForDebugging } from '../../utils/debug.js'
import { logEvent } from '../../services/analytics/index.js'
import type { Message } from '../../types/message.js'
import type { ToolUseContext } from '../../Tool.js'
import { readHostBundle } from '../hostHandle.js'
/** workflow 子 agent 的兜底定义agentType 未命中真实注册表时用)。 */
const WORKFLOW_AGENT: BuiltInAgentDefinition = {
agentType: 'workflow-worker',
whenToUse: 'workflow 脚本内 agent() 钩子派发的子任务',
tools: ['*'],
source: 'built-in',
baseDir: 'built-in',
getSystemPrompt: () =>
'You are a workflow sub-agent. Complete the task concisely; your final text is the return value relayed to the workflow.',
}
/** agentType → 真实 agent 注册表activeAgents 命中即用,否则兜底)。 */
function resolveAgentDefinition(
agentType: string | undefined,
toolUseContext: ToolUseContext,
): AgentDefinition {
if (!agentType) return WORKFLOW_AGENT
const found = toolUseContext.options.agentDefinitions.activeAgents.find(
a => a.agentType === agentType,
)
return found ?? WORKFLOW_AGENT
}
/** model 别名 → 当前 provider 实际 model id。v1 直传(保留映射扩展点)。 */
function mapWorkflowModel(model: string | undefined): string | undefined {
return model
}
/** 从 agent 最终消息中提取 StructuredOutput 产出的 JSON 对象;失败返回 null。 */
function extractStructuredOutput(
content: Array<{ type: string; text?: string }>,
): unknown | null {
for (const block of content) {
if (block.type === 'text' && block.text) {
const trimmed = block.text.trim()
const start = trimmed.indexOf('{')
const end = trimmed.lastIndexOf('}')
if (start >= 0 && end > start) {
try {
return JSON.parse(trimmed.slice(start, end + 1))
} catch {
// 继续
}
}
}
}
return null
}
/** 深度集成后端:从活会话解析 agent/model/tools委托核心 runAgent。 */
export const claudeCodeBackend: AgentAdapter = {
id: 'claude-code',
capabilities: { structuredOutput: true, tools: true },
async run(params: AgentRunParams, ctx: AgentAdapterContext): Promise<AgentRunResult> {
const { toolUseContext, canUseTool } = readHostBundle(ctx.host)
const appState = toolUseContext.getAppState()
const agentDef = resolveAgentDefinition(params.agentType, toolUseContext)
const model = mapWorkflowModel(params.model)
const agentId = createAgentId()
const workerPermissionContext = {
...appState.toolPermissionContext,
mode: agentDef.permissionMode ?? 'acceptEdits',
}
const workerTools = assembleToolPool(workerPermissionContext, appState.mcp.tools)
const promptText = params.schema
? `${params.prompt}\n\nYou MUST return your final answer by calling the StructuredOutput tool with a value matching this JSON Schema:\n${JSON.stringify(params.schema)}`
: params.prompt
const promptMessages = [createUserMessage({ content: promptText })]
const messages: Message[] = []
const startTime = Date.now()
try {
for await (const msg of runAgent({
agentDefinition: agentDef,
promptMessages,
toolUseContext,
canUseTool,
isAsync: true,
querySource: toolUseContext.options.querySource ?? 'workflow',
availableTools: workerTools,
override: { agentId, ...(model ? { model: model as never } : {}) },
...(params.maxTokens ? { maxTokens: params.maxTokens as never } : {}),
})) {
messages.push(msg as Message)
}
} catch (e) {
logForDebugging(`workflow sub-agent error: ${(e as Error).message}`)
return { kind: 'dead' }
}
const finalized = finalizeAgentTool(messages, agentId, {
prompt: params.prompt,
resolvedAgentModel: toolUseContext.options.mainLoopModel,
isBuiltInAgent: isBuiltInAgent(agentDef),
startTime,
agentType: agentDef.agentType,
isAsync: true,
})
const outputTokens = finalized.usage?.output_tokens ?? finalized.totalTokens ?? 0
logEvent('tengu_workflow_agent', {
agentType: agentDef.agentType, ok: true, outputTokens,
})
if (params.schema) {
const structured = extractStructuredOutput(finalized.content)
if (structured === null) return { kind: 'dead' }
return { kind: 'ok', output: structured as object, usage: { outputTokens } }
}
const text = extractTextContent(finalized.content, '\n')
return { kind: 'ok', output: text, usage: { outputTokens } }
},
}
```
- [ ] **Step 4运行测试**
Run: `bun test src/workflow/__tests__/claudeCodeBackend.test.ts`
Expected: 3 PASS。
- [ ] **Step 5提交**
```bash
git add src/workflow/backends/claudeCodeBackend.ts src/workflow/__tests__/claudeCodeBackend.test.ts
git commit -m "feat(workflow): claude-code AgentAdapter (deep AppState/provider/agent resolution)"
```
### Task 7Registry + ports 组装
**Files:**
- Create: `src/workflow/registry.ts`
- Create: `src/workflow/ports.ts`
- Test: `src/workflow/__tests__/ports.test.ts`
- [ ] **Step 1写失败测试**
Create `src/workflow/__tests__/ports.test.ts`:
```ts
import { expect, test } from 'bun:test'
import { buildRegistry } from '../registry.js'
import { createWorkflowPorts } from '../ports.js'
import { createProgressBus } from '../progress/bus.js'
import { createProgressStoreFromBus } from '../progress/store.js'
test('buildRegistry 注册 claude-code 为默认且 resolve 命中', () => {
const reg = buildRegistry()
expect(reg.has('claude-code')).toBe(true)
expect(reg.resolve({ prompt: 'x' }).id).toBe('claude-code')
expect(reg.resolve({ prompt: 'x', agentType: 'whatever' }).id).toBe('claude-code')
})
test('createWorkflowPorts 组装完整端口(含 agentAdapterRegistry 与 progressEmitter→bus', () => {
const bus = createProgressBus()
const store = createProgressStoreFromBus(bus)
const ports = createWorkflowPorts({ bus, store })
expect(ports.agentAdapterRegistry).toBeDefined()
expect(ports.agentAdapterRegistry!.resolve({ prompt: 'x' }).id).toBe('claude-code')
expect(typeof ports.taskRegistrar.register).toBe('function')
expect(typeof ports.hostFactory).toBe('function')
})
```
- [ ] **Step 2运行确认失败**
Run: `bun test src/workflow/__tests__/ports.test.ts`
Expected: FAIL模块不存在
- [ ] **Step 3实现 `registry.ts`**
Create `src/workflow/registry.ts`:
```ts
import { type AgentAdapterRegistry } from '@claude-code-best/workflow-engine'
import { claudeCodeBackend } from './backends/claudeCodeBackend.js'
/**
* 构建多后端 registry。v1depth B只注册单一 claude-code adapter 为默认,
* 不预填路由规则——扩第二个 provider adapter 时再补 .route(...)。
*/
export function buildRegistry(): AgentAdapterRegistry {
const reg = new AgentAdapterRegistry()
reg.register(claudeCodeBackend).default('claude-code')
return reg
}
```
> 注:`AgentAdapterRegistry` 是 class引擎导出`new` 可用。
- [ ] **Step 4实现 `ports.ts`**
Create `src/workflow/ports.ts`:
```ts
import {
createFileJournalStore,
type ProgressEvent,
type WorkflowPorts,
} from '@claude-code-best/workflow-engine'
import { getCwd } from '../utils/cwd.js'
import { logForDebugging } from '../utils/debug.js'
import { getProjectRoot } from '../bootstrap/state.js'
import { logEvent } from '../services/analytics/index.js'
import {
registerLocalWorkflowTask,
completeWorkflowTask,
failWorkflowTask,
killWorkflowTask,
} from '../tasks/LocalWorkflowTask/LocalWorkflowTask.js'
import { makeHostHandle, readHostBundle, type WorkflowHostBundle } from './hostHandle.js'
import { buildRegistry } from './registry.js'
import type { ProgressBus } from './progress/bus.js'
import type { ProgressStore } from './progress/store.js'
import type { SetAppState } from '../Task.js'
type RunBinding = {
runId: string
taskId: string
setAppState: SetAppState
abortController: AbortController
workflowName: string
}
/** 每次工具调用从 toolUseContext 构造 WorkflowHostContext。 */
function makeHostFactory(): WorkflowPorts['hostFactory'] {
return ({ context, canUseTool, parentMessage }) => {
const ctx = context as WorkflowHostBundle['toolUseContext']
return {
handle: makeHostHandle({
toolUseContext: ctx,
canUseTool: canUseTool as WorkflowHostBundle['canUseTool'],
parentMessage: parentMessage as WorkflowHostBundle['parentMessage'],
agentId: ctx.agentId,
}),
cwd: getCwd(),
budgetTotal: null, // turn 级预算注入点(未来从 settings 读)
toolUseId: ctx.toolUseId,
}
}
}
/**
* 组装完整 WorkflowPorts。bus/store 由调用方传入service 单例共享)。
* taskRegistrar 维护 runId → RunBinding 供 kill 路由。
*/
export function createWorkflowPorts(opts: {
bus: ProgressBus
store: ProgressStore
}): WorkflowPorts {
const bindings = new Map<string, RunBinding>()
const runsDir = `${getProjectRoot()}/.claude/workflow-runs`
const registry = buildRegistry()
// 遥测订阅(独立于 store
opts.bus.subscribe((e: ProgressEvent) => {
if (e.type === 'run_done') {
logEvent('tengu_workflow_done', { status: e.status, runId: e.runId })
}
})
return {
hostFactory: makeHostFactory(),
agentAdapterRegistry: registry,
progressEmitter: {
emit(event) {
opts.bus.emit(event) // → store reducer + 遥测
},
},
taskRegistrar: {
register(regOpts, host) {
const bundle = readHostBundle(host)
const setAppState =
bundle.toolUseContext.setAppStateForTasks ?? bundle.toolUseContext.setAppState
const abortController = new AbortController()
const taskId = registerLocalWorkflowTask(setAppState, {
description: regOpts.summary ?? regOpts.workflowName,
workflowName: regOpts.workflowName,
workflowFile: regOpts.workflowFile ?? '',
summary: regOpts.summary,
...(regOpts.toolUseId ? { toolUseId: regOpts.toolUseId } : {}),
abortController,
})
const runId = regOpts.runId ?? taskId
bindings.set(runId, {
runId, taskId, setAppState, abortController, workflowName: regOpts.workflowName,
})
logEvent('tengu_workflow_started', { runId })
return { runId, signal: abortController.signal }
},
complete(runId, summary) {
const b = bindings.get(runId)
if (!b) return
completeWorkflowTask(b.taskId, b.setAppState)
logForDebugging(`workflow ${runId} completed: ${summary ?? ''}`)
},
fail(runId, error) {
const b = bindings.get(runId)
if (!b) return
failWorkflowTask(b.taskId, b.setAppState)
logForDebugging(`workflow ${runId} failed: ${error}`)
},
kill(runId) {
const b = bindings.get(runId)
if (!b) return
killWorkflowTask(b.taskId, b.setAppState) // abort controller 内置
},
pendingAction() {
return null // v1skip/retry 不接线seam 保留)
},
},
journalStore: createFileJournalStore(runsDir),
permissionGate: {
// 引擎用 ctx.signalregister 返回的 AbortController判 abort
isAborted: () => false,
},
logger: {
debug: msg => logForDebugging(msg),
event: name => logForDebugging(`workflow event: ${name}`),
},
}
}
```
- [ ] **Step 5运行测试**
Run: `bun test src/workflow/__tests__/ports.test.ts`
Expected: 2 PASS。
- [ ] **Step 6提交**
```bash
git add src/workflow/registry.ts src/workflow/ports.ts src/workflow/__tests__/ports.test.ts
git commit -m "feat(workflow): AgentAdapterRegistry + WorkflowPorts assembly"
```
---
## Phase M4Service 门面
### Task 8`WorkflowService` 单例
**Files:**
- Create: `src/workflow/service.ts`
- Test: `src/workflow/__tests__/service.test.ts`
- [ ] **Step 1写失败测试mock 端口,无 LLM**
Create `src/workflow/__tests__/service.test.ts`:
```ts
import { expect, test } from 'bun:test'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
// service 用真实 portsregistry/bus/store+ mock taskRegistrar不触发 LLMregistry adapter 被 mock
mock.module('../backends/claudeCodeBackend.js', () => ({
claudeCodeBackend: {
id: 'claude-code',
capabilities: { structuredOutput: true },
async run() {
return { kind: 'ok', output: 'mock-out', usage: { outputTokens: 1 } }
},
},
}))
mock.module('../utils/cwd.js', () => ({ getCwd: () => '/tmp' }))
mock.module('../bootstrap/state.js', () => ({ getProjectRoot: () => '/tmp' }))
mock.module('../services/analytics/index.js', () => ({ logEvent: () => {} }))
mock.module('../utils/debug.js', () => ({ logForDebugging: () => {} }))
mock.module('../tasks/LocalWorkflowTask/LocalWorkflowTask.js', () => ({
registerLocalWorkflowTask: () => 'task-1',
completeWorkflowTask: () => {}, failWorkflowTask: () => {}, killWorkflowTask: () => {},
}))
mock.module('../tools.js', () => ({ assembleToolPool: () => ({ tools: [] }) }))
import { getWorkflowService } from '../service.js'
function tmpRuns() {
return mkdtemp(join(tmpdir(), 'wf-svc-'))
}
test('launch → completedstore 出现该 runkill 走 taskRegistrar', async () => {
const dir = await tmpRuns()
try {
process.env.WORKFLOW_RUNS_DIR = dir
const svc = getWorkflowService()
const { runId } = await svc.launch(
{ script: `return agent('compute')` },
{ /* toolUseContext stub */ } as never,
(() => Promise.resolve({ behavior: 'allow' })) as never,
)
// 等待 detached run
await new Promise(r => setTimeout(r, 60))
const r = svc.getRun(runId)
expect(r).toBeDefined()
expect(['completed', 'running']).toContain(r!.status)
} finally {
await rm(dir, { recursive: true, force: true })
delete process.env.WORKFLOW_RUNS_DIR
}
})
test('listNamed 委托 namedWorkflows空目录→[]', async () => {
const svc = getWorkflowService()
const names = await svc.listNamed(join(tmpdir(), 'wf-nope-' + Math.random()))
expect(names).toEqual([])
})
test('subscribe 返回取消订阅', () => {
const svc = getWorkflowService()
let n = 0
const unsub = svc.subscribe(() => n++)
unsub()
expect(typeof unsub).toBe('function')
expect(n).toBe(0)
})
```
> 注:`mock` 需在顶部导入:把 `import { expect, test, mock } from 'bun:test'`(首行)。`launch` 的第三参为 canUseTool。
- [ ] **Step 2运行确认失败**
Run: `bun test src/workflow/__tests__/service.test.ts`
Expected: FAIL`../service.js` 不存在)。
- [ ] **Step 3实现 `service.ts`**
Create `src/workflow/service.ts`:
```ts
import {
createFileJournalStore,
createHostHandle,
parseScript,
runWorkflow,
type WorkflowHostContext,
type WorkflowInput,
type WorkflowPorts,
WORKFLOW_DIR_NAME,
resolveNamedWorkflow,
listNamedWorkflows,
} from '@claude-code-best/workflow-engine'
import { getCwd } from '../utils/cwd.js'
import { logForDebugging } from '../utils/debug.js'
import { getProjectRoot } from '../bootstrap/state.js'
import { logEvent } from '../services/analytics/index.js'
import { makeHostHandle, type WorkflowHostBundle } from './hostHandle.js'
import { createProgressBus } from './progress/bus.js'
import { createProgressStoreFromBus, type ProgressStore } from './progress/store.js'
import { createWorkflowPorts } from './ports.js'
import type { CanUseToolFn } from '../hooks/useCanUseTool.js'
import type { ToolUseContext } from '../Tool.js'
import type { RunProgress } from './progress/store.js'
export type WorkflowService = {
/** 共享端口(工具描述符用)。 */
ports: WorkflowPorts
/** 面板/工具启动 workflow解析脚本 → register → detached runWorkflow。 */
launch(
input: Pick<WorkflowInput, 'script' | 'name' | 'scriptPath' | 'args' | 'description' | 'resumeFromRunId' | 'title'>,
toolUseContext: ToolUseContext,
canUseTool: CanUseToolFn,
): Promise<{ runId: string }>
kill(runId: string): void
listRuns(): RunProgress[]
getRun(runId: string): RunProgress | undefined
subscribe(listener: () => void): () => void
listNamed(workflowDir?: string): Promise<string[]>
}
let cached: WorkflowService | null = null
/** 进程单例。工具与面板共享同一 ports/registry/store。 */
export function getWorkflowService(): WorkflowService {
if (cached) return cached
const bus = createProgressBus()
const store: ProgressStore = createProgressStoreFromBus(bus)
const ports = createWorkflowPorts({ bus, store })
cached = makeService(ports, store)
return cached
}
/** 测试用:注入 ports。 */
export function makeService(ports: WorkflowPorts, store: ProgressStore): WorkflowService {
const runsDir = () =>
process.env.WORKFLOW_RUNS_DIR ?? `${getProjectRoot()}/.claude/workflow-runs`
const buildHost = (
toolUseContext: ToolUseContext,
canUseTool: CanUseToolFn,
): WorkflowHostContext => ({
handle: makeHostHandle({
toolUseContext,
canUseTool,
parentMessage: undefined,
agentId: toolUseContext.agentId,
} as WorkflowHostBundle),
cwd: getCwd(),
budgetTotal: null,
toolUseId: toolUseContext.toolUseId,
})
async function resolveSource(input: {
script?: string; name?: string; scriptPath?: string
}): Promise<{ script: string; workflowFile?: string; workflowName: string }> {
if (input.script) return { script: input.script, workflowName: input.name ?? 'workflow' }
if (input.scriptPath) {
const { readFile } = await import('node:fs/promises')
return {
script: await readFile(input.scriptPath, 'utf-8'),
workflowFile: input.scriptPath,
workflowName: input.name ?? 'workflow',
}
}
if (input.name) {
const found = await resolveNamedWorkflow(join(getCwd(), WORKFLOW_DIR_NAME), input.name)
if (!found) throw new Error(`命名 workflow "${input.name}" 未找到(查找 ${WORKFLOW_DIR_NAME}/`)
return { script: found.content, workflowFile: found.path, workflowName: input.name }
}
throw new Error('必须提供 script、name 或 scriptPath 之一')
}
return {
ports,
async launch(input, toolUseContext, canUseTool) {
const { script, workflowFile, workflowName } = await resolveSource(input)
try {
parseScript(script) // 快速校验,失败抛
} catch (e) {
throw new Error(`脚本校验失败:${(e as Error).message}`)
}
const host = buildHost(toolUseContext, canUseTool)
const { runId, signal } = ports.taskRegistrar.register(
{
workflowName,
...(workflowFile ? { workflowFile } : {}),
...(input.description ? { summary: input.description } : {}),
...(host.toolUseId ? { toolUseId: host.toolUseId } : {}),
...(input.resumeFromRunId ? { runId: input.resumeFromRunId } : {}),
},
host.handle,
)
// detached 执行
void runWorkflow({
script,
...(input.args !== undefined ? { args: input.args } : {}),
runId,
workflowName,
ports,
host: host.handle,
signal,
cwd: host.cwd,
budgetTotal: host.budgetTotal,
...(input.resumeFromRunId ? { resume: true } : {}),
}).then(result => {
if (result.status === 'completed') ports.taskRegistrar.complete(runId)
else if (result.status === 'failed') ports.taskRegistrar.fail(runId, result.error ?? 'failed')
else ports.taskRegistrar.kill(runId)
}).catch(e => ports.taskRegistrar.fail(runId, (e as Error).message))
logForDebugging(`workflow launched: ${runId} (${workflowName})`)
return { runId }
},
kill(runId) {
ports.taskRegistrar.kill(runId)
},
listRuns: () => store.list(),
getRun: id => store.get(id),
subscribe: fn => store.subscribe(fn),
async listNamed(workflowDir) {
return listNamedWorkflows(workflowDir ?? join(getCwd(), WORKFLOW_DIR_NAME))
},
}
}
// 兼容:旧 ports.ts 用 createFileJournalStore已由 ports.ts 内部用;此处保留导入以备测试覆盖)
export { createHostHandle, createFileJournalStore }
export type { WorkflowInput }
```
> 注:`createFileJournalStore`/`createHostHandle` 在 service 里未直接用ports.ts 用re-export 仅防 lint 误报未用导入;若 `bunx tsc` 报未使用,删除该行 re-export。
- [ ] **Step 4运行测试**
Run: `bun test src/workflow/__tests__/service.test.ts`
Expected: 3 PASS。若 `launch` 测试因 mock 路径不匹配而 fail检查 `mock.module` 的 specifier 与 `service.ts` 实际 import 路径一致。
- [ ] **Step 5提交**
```bash
git add src/workflow/service.ts src/workflow/__tests__/service.test.ts
git commit -m "feat(workflow): WorkflowService facade (launch/kill/subscribe/listNamed)"
```
---
## Phase M5工具 wiring + 去 WorkflowDetailDialog
### Task 9重写 `wiring.ts`(走 service
**Files:**
- Rewrite: `src/workflow/wiring.ts`
- [ ] **Step 1整体替换 `wiring.ts`**
Replace entire `src/workflow/wiring.ts` with:
```ts
import {
createWorkflowTool,
type WorkflowToolDescriptor,
} from '@claude-code-best/workflow-engine'
import { buildTool, type Tool } from '../Tool.js'
import { getWorkflowService } from './service.js'
/**
* 把引擎自包含描述符适配为 buildTool 兼容的 Tool。
* 描述符统一走 service 单例(共享 ports/registry/store
*/
function buildWorkflowTool(): Tool {
const { ports } = getWorkflowService()
const descriptor: WorkflowToolDescriptor = createWorkflowTool(ports)
return buildTool({
name: descriptor.name,
maxResultSizeChars: 50_000,
inputSchema: descriptor.inputSchema,
isEnabled: () => descriptor.isEnabled(),
isReadOnly: input => descriptor.isReadOnly(input),
isConcurrencySafe: () => true,
async description() {
return descriptor.description()
},
async prompt() {
return descriptor.prompt()
},
async call(input, context, canUseTool, parentMessage, onProgress) {
const result = await descriptor.call(
input, context, canUseTool, parentMessage, onProgress,
)
return { data: result.data }
},
renderToolUseMessage: input => descriptor.renderToolUseMessage(input),
mapToolResultToToolResultBlockParam: (data, toolUseId) =>
descriptor.mapToolResultToToolResultBlockParam(data, toolUseId),
})
}
// 单例tools.ts 注册与 PermissionRequest 引用需为同一实例switch 按引用匹配)。
let cached: Tool | null = null
export function createWorkflowToolCore(): Tool {
if (!cached) cached = buildWorkflowTool()
return cached
}
```
- [ ] **Step 2删除旧 `adapter.ts` 与 `progressStore.ts`**
```bash
git rm src/workflow/adapter.ts src/workflow/progressStore.ts
```
> 校验无残留引用:`grep -rn "workflow/adapter\|workflow/progressStore" src` 应仅命中本计划新增的 progress/ 目录(`progress/store.ts` 路径不同,不算)。若命中旧路径引用,改为新模块。
- [ ] **Step 3类型检查 + lint**
Run: `bunx tsc --noEmit 2>&1 | grep -E "workflow|error" | head`
Expected: 零错误(`createWorkflowToolCore`/`createWorkflowAdapter` 旧引用已清除——`wiring.ts` 不再 import adapter
- [ ] **Step 4提交**
```bash
git add src/workflow/wiring.ts
git commit -m "refactor(workflow): wiring via WorkflowService singleton; drop legacy adapter/progressStore"
```
### Task 10`BackgroundTasksDialog` 去 `WorkflowDetailDialog`
**Files:**
- Modify: `src/components/tasks/BackgroundTasksDialog.tsx:110-112, 443-463`
- [ ] **Step 1读当前 local_workflow 渲染分支**
Run: `sed -n '108,120p;440,465p' src/components/tasks/BackgroundTasksDialog.tsx`
确认 line 110-112 的 `WorkflowDetailDialog` 条件导入、line 443-463 的 `case 'local_workflow'` 渲染 `<WorkflowDetailDialog .../>`
- [ ] **Step 2移除 `WorkflowDetailDialog` 导入**
把(约 110-112 行):
```ts
const WorkflowDetailDialog = feature('WORKFLOW_SCRIPTS')
? (require('./WorkflowDetailDialog.js') as typeof import('./WorkflowDetailDialog.js')).WorkflowDetailDialog
: null;
```
替换为:
```ts
// WorkflowDetailDialog 已移除workflow 详情改由 /workflows 面板展示。
```
- [ ] **Step 3`case 'local_workflow'` 改为内联摘要 + /workflows 提示**
把(约 443 行起的)`case 'local_workflow':` 分支中渲染 `<WorkflowDetailDialog .../>` 的部分,替换为内联摘要(具体 JSX 视 Step 1 读到的实际结构而定,保留外层容器与 `key`)。示例替换(若原结构为 `return <WorkflowDetailDialog workflow={task} ... />`
```tsx
case 'local_workflow':
if (!task) return null;
return (
<Box key={`workflow-${task.id}`} flexDirection="column" paddingX={1}>
<Text bold>{task.workflowName}</Text>
<Text color="subtle">
{task.status} · {task.summary ?? task.description}
</Text>
<Text color="subtle"> /workflows agent </Text>
</Box>
);
```
> 注:`Box`/`Text` 已在该文件顶部从 `@anthropic/ink` 导入(确认存在;若无则补 `import { Box, Text } from '@anthropic/ink'`)。
- [ ] **Step 4删除 `WorkflowDetailDialog.tsx`**
```bash
git rm src/components/tasks/WorkflowDetailDialog.tsx
```
- [ ] **Step 5校验无残留引用**
Run: `grep -rn "WorkflowDetailDialog" src`
Expected: 无输出(或仅注释)。
- [ ] **Step 6类型检查 + 测试**
Run: `bunx tsc --noEmit 2>&1 | grep -iE "backgroundtasks|workflow" | head`
Expected: 零错误。
- [ ] **Step 7提交**
```bash
git add src/components/tasks/BackgroundTasksDialog.tsx
git commit -m "refactor(tasks): drop WorkflowDetailDialog; workflow detail now in /workflows panel"
```
- [ ] **Step 8里程碑 M5 全量 precheck**
Run: `bun run precheck`
Expected: typecheck + lint fix + test 全绿。
```bash
git commit --allow-empty -m "chore(workflow): M5 integration switch — precheck green"
```
---
## Phase M6`/workflows` 双栏面板
### Task 11`WorkflowList`(左栏)
**Files:**
- Create: `src/workflow/panel/WorkflowList.tsx`
- [ ] **Step 1实现左栏扁平列表**
Create `src/workflow/panel/WorkflowList.tsx`:
```tsx
import React from 'react'
import { Box, Text } from '@anthropic/ink'
import type { RunProgress } from '../progress/store.js'
const STATUS_DOT: Record<RunProgress['status'], string> = {
running: '●', completed: '✓', failed: '✗', killed: '■',
}
type Props = {
runs: RunProgress[]
named: string[]
selected: number
}
/** 左栏:扁平 workflow 列表(状态点+名+当前 phase+计数)+ NAMED 区。 */
export function WorkflowList({ runs, named, selected }: Props): React.ReactNode {
const rows = runs
return (
<Box flexDirection="column">
{rows.length === 0 ? (
<Text color="subtle">No active runs.</Text>
) : (
rows.map((r, i) => (
<Box key={r.runId}>
<Text color={i === selected ? 'claude' : undefined}>
{i === selected ? '▸ ' : ' '}
</Text>
<Text color={r.status === 'running' ? 'warning' : r.status === 'failed' ? 'error' : r.status === 'completed' ? 'success' : 'subtle'}>
{STATUS_DOT[r.status]}
</Text>
<Text> {r.workflowName.padEnd(20).slice(0, 20)}</Text>
<Text color="subtle">
{' '}
{r.currentPhase ?? (r.status === 'completed' ? 'done' : r.status)}{' '}
{r.agents.length}/{r.agentCount}
</Text>
</Box>
))
)}
{named.length > 0 && (
<Box flexDirection="column" marginTop={1}>
<Text color="subtle">Named:</Text>
<Text color="subtle">{' ' + named.join(' · ')}</Text>
</Box>
)}
</Box>
)
}
```
### Task 12`WorkflowDetail`(右栏)
**Files:**
- Create: `src/workflow/panel/WorkflowDetail.tsx`
- [ ] **Step 1实现右栏 phase 横条 + 扁平 agent 列表**
Create `src/workflow/panel/WorkflowDetail.tsx`:
```tsx
import React from 'react'
import { Box, Text } from '@anthropic/ink'
import type { AgentProgress, RunProgress } from '../progress/store.js'
function phaseMark(status: 'running' | 'done'): string {
return status === 'done' ? '✓' : '●'
}
function agentMark(a: AgentProgress): string {
if (a.status === 'done') return a.resultKind === 'ok' ? '✓' : a.resultKind === 'dead' ? '✗' : '✓'
return '●'
}
type Props = { run: RunProgress | undefined }
/** 右栏:聚焦 workflow 的 phase 横条 + 扁平 agent 列表。 */
export function WorkflowDetail({ run }: Props): React.ReactNode {
if (!run) {
return (
<Text color="subtle"> workflow n workflow</Text>
)
}
return (
<Box flexDirection="column">
<Box>
<Text bold>{run.workflowName}</Text>
<Text color={run.status === 'running' ? 'warning' : 'subtle'}>
{' ' + (run.status === 'running' ? '● running' : run.status)}
</Text>
</Box>
{run.phases.length > 0 && (
<Box marginTop={1}>
<Text color="subtle">Phases </Text>
<Text>
{run.phases.map(p => `${phaseMark(p.status)}${p.title}`).join(' ')}
</Text>
</Box>
)}
{run.agents.length > 0 && (
<Box flexDirection="column" marginTop={1}>
{run.agents.map(a => (
<Box key={a.id}>
<Text>{agentMark(a)} </Text>
<Text>{(a.label ?? `agent-${a.id}`).padEnd(16).slice(0, 16)}</Text>
<Text color="subtle"> {a.phase ?? ''}</Text>
</Box>
))}
</Box>
)}
{run.status !== 'running' && run.returnValue != null && (
<Box marginTop={1}>
<Text color="subtle"> {String(run.returnValue).slice(0, 80)}</Text>
</Box>
)}
{run.error && (
<Box marginTop={1}>
<Text color="error">{run.error}</Text>
</Box>
)}
</Box>
)
}
```
### Task 13键位 hook `useWorkflowKeyboard`
**Files:**
- Create: `src/workflow/panel/useWorkflowKeyboard.ts`
- [ ] **Step 1实现键位j/k/r/x/n/q**
Create `src/workflow/panel/useWorkflowKeyboard.ts`:
```ts
import { useEffect } from 'react'
import type { useInput } from '@anthropic/ink'
type Actions = {
move: (delta: number) => void
resume: () => void
kill: () => void
newNamed: () => void
quit: () => void
}
/** 绑定 j/k/r/x/n/q/esc。input/useInput 由 @anthropic/ink 提供。 */
export function useWorkflowKeyboard(
input: ReturnType<typeof useInput>,
actions: Actions,
): void {
useEffect(() => {
const handler = (key: string): void => {
switch (key) {
case 'j': actions.move(1); break
case 'k': actions.move(-1); break
case 'r': actions.resume(); break
case 'x': actions.kill(); break
case 'n': actions.newNamed(); break
case 'q':
case 'escape': actions.quit(); break
}
}
const off = input(handler)
return () => { off?.() }
}, [input, actions])
}
```
> 注:`@anthropic/ink` 的 `useInput` 签名以仓库实际为准;若它是 hook 形式(`useInput((input, key) => {...})`),改为在 `WorkflowsPanel` 内直接 `useInput` 并把 `actions` 内联(见 Task 14 备选)。本 hook 适用于"返回注册函数"形态。
### Task 14`WorkflowsPanel` + local-jsx 命令
**Files:**
- Create: `src/workflow/panel/WorkflowsPanel.tsx`
- Rewrite: `src/commands/workflows/index.ts`
- Test: `src/workflow/__tests__/WorkflowsPanel.test.tsx`
- [ ] **Step 1实现面板useSyncExternalStore 订阅 service**
Create `src/workflow/panel/WorkflowsPanel.tsx`:
```tsx
import React, { useState, useSyncExternalStore } from 'react'
import { Box, Text, useInput } from '@anthropic/ink'
import type { LocalJSXCommandOnDone } from '../../types/command.js'
import type { ToolUseContext } from '../../Tool.js'
import { getWorkflowService } from '../service.js'
import { WorkflowList } from './WorkflowList.js'
import { WorkflowDetail } from './WorkflowDetail.js'
type Ctx = ToolUseContext & { /* LocalJSXCommandContext 扩展,按需 */ }
export function WorkflowsPanel({
onDone,
context,
args,
}: {
onDone: LocalJSXCommandOnDone
context: Ctx
args: string
}): React.ReactNode {
const svc = getWorkflowService()
const runs = useSyncExternalStore(svc.subscribe, () => svc.listRuns(), () => [])
const [named, setNamed] = useState<string[]>([])
const [selected, setSelected] = useState(0)
// 初次加载命名 workflow 列表
if (named.length === 0 && runs.length === 0) {
void svc.listNamed().then(setNamed).catch(() => {})
}
const focused = runs[Math.min(selected, Math.max(0, runs.length - 1))]
useInput((input, key) => {
if (input === 'j') setSelected(s => Math.min(runs.length - 1, s + 1))
else if (input === 'k') setSelected(s => Math.max(0, s - 1))
else if (input === 'x' && focused) svc.kill(focused.runId)
else if (input === 'r' && focused) {
// resume用当前会话上下文重跑读 journal
void svc.launch({ resumeFromRunId: focused.runId, name: focused.workflowName }, context, context.options.canUseTool ?? (() => Promise.resolve({ behavior: 'allow' })) as never)
} else if (input === 'n') {
// 简化:提示用户输入命名 workflow完整选择器留作后续
onDone('Tip: 用 /<name> 启动命名 workflow或通过 Workflow 工具带 name 参数。')
} else if (input === 'q' || key.escape) {
onDone()
}
})
return (
<Box flexDirection="column" borderStyle="round" borderColor="claude" paddingX={1}>
<Box justifyContent="space-between">
<Text bold>Workflows</Text>
<Text color="subtle">{runs.filter(r => r.status === 'running').length} running · {runs.filter(r => r.status !== 'running').length} done</Text>
</Box>
<Box flexDirection="row" marginTop={1}>
<Box width="40%"><WorkflowList runs={runs} named={named} selected={Math.min(selected, Math.max(0, runs.length - 1))} /></Box>
<Box width="60%"><WorkflowDetail run={focused} /></Box>
</Box>
<Box marginTop={1}>
<Text color="subtle">j/k run · r resume · x kill · n new · q quit</Text>
</Box>
</Box>
)
}
```
> 注:`context.options.canUseTool` 字段名以实际 `ToolUseContext` 为准;若不同,改用面板自带的会话权限解析(与 `useCanUseTool` 一致)。`borderStyle="round"` 等 prop 以 `@anthropic/ink` 支持为准。
- [ ] **Step 2重写命令为 local-jsx**
Replace entire `src/commands/workflows/index.ts`:
```ts
import type { Command } from '../../types/command.js'
const workflows = {
type: 'local-jsx',
name: 'workflows',
description: 'Workflow 监控面板:实时 run/phase/agent 进度,键盘控制',
isEnabled: undefined,
load: () => import('../../workflow/panel/WorkflowsPanel.js'),
} satisfies Command
export default workflows
```
> 注:`load` 返回的模块须有 `call``LocalJSXCommandModule`)。若 `WorkflowsPanel` 导出的是组件而非 `{call}`,补一个 `panelCall.ts`
Create `src/workflow/panel/panelCall.ts`:
```ts
import React from 'react'
import { WorkflowsPanel } from './WorkflowsPanel.js'
import type { LocalJSXCommandCall } from '../../../types/command.js'
export const call: LocalJSXCommandCall = async (onDone, context, args) =>
React.createElement(WorkflowsPanel, { onDone, context, args })
```
并把命令 `load` 改为 `() => import('../../workflow/panel/panelCall.js')`
- [ ] **Step 3写面板测试**
Create `src/workflow/__tests__/WorkflowsPanel.test.tsx`:
```tsx
import { expect, test } from 'bun:test'
import React from 'react'
import { render } from 'ink-testing-library'
// 注:若 ink-testing-library 不可用,改用 @anthropic/ink 的 test 工具或快照 store 状态
// 直接测纯函数:聚焦选择逻辑
function focusAt(runs: { runId: string }[], selected: number) {
return runs[Math.min(selected, Math.max(0, runs.length - 1))]
}
test('focus clamp 到有效区间', () => {
const runs = [{ runId: 'a' }, { runId: 'b' }]
expect(focusAt(runs, 5)?.runId).toBe('b')
expect(focusAt(runs, -3)?.runId).toBe('a')
expect(focusAt(runs, 0)?.runId).toBe('a')
})
```
> ink 组件交互测试受 `@anthropic/ink` test harness 可用性约束;至少覆盖选择/夹紧纯逻辑。若仓库已有 ink-testing-library 依赖,补 `render(<WorkflowsPanel .../>)` 快照测试。
- [ ] **Step 4类型检查 + 运行**
Run: `bunx tsc --noEmit 2>&1 | grep -iE "panel|workflows" | head`
Expected: 零错误。
Run: `bun test src/workflow/__tests__/WorkflowsPanel.test.tsx`
Expected: PASS。
- [ ] **Step 5里程碑 M6 precheck**
Run: `bun run precheck`
Expected: 全绿。
- [ ] **Step 6提交**
```bash
git add src/workflow/panel/ src/commands/workflows/index.ts src/workflow/__tests__/WorkflowsPanel.test.tsx
git commit -m "feat(workflow): /workflows dual-pane monitoring + control panel (local-jsx)"
```
---
## Phase M7`/ultracode` skill
### Task 15`SKILL.md` playbook
**Files:**
- Create: `src/skills/bundled/ultracode/SKILL.md`
- [ ] **Step 1写 skill 内容**
Create `src/skills/bundled/ultracode/SKILL.md`:
```markdown
---
name: ultracode
description: 进入多 agent workflow 编排模式——何时用 workflow、编排原语、质量模式、确定性约束、后端路由、resume/budget、文件与命令。调用即把这套工作法注入上下文。
user-invocable: true
---
# UltraCode — 多 agent workflow 编排工作法
## 何时用 Workflow 工具
用,当任务满足任一:
- 可**分解/并行**(多文件、多维度、可独立推进的子任务)。
- 需要**多视角置信**(如审查:先生成再对抗式验证)。
- **规模超单上下文**(大迁移、广度审计)。
- 需要 **resume / 可审计**journal 重放、确定性回放)。
**不要用**:琐碎单文件改、单次问答、一次 Read 能解决的事——直接做。
## 编排原语(脚本内可用)
- `agent(prompt, opts?)` — 派发一个子 agent返回其最终文本或 schema 对象)。
- `parallel([()=>…])` — 并发跑,单项抛错 → `null`,其余保留。**无 barrier**。
- `pipeline(items, stage1, stage2, …)` — 每个 item 链式过各 stageitem 间无 barrierstage 间顺序)。
- `phase(title)` — 标记阶段(进度面板按此展示)。
- `log(msg)` — 进度日志。
- `workflow(name|{scriptPath}, args?)` — 嵌套一层子 workflow仅允许一层
## 确定性约束(关键)
脚本内**禁用** `Date.now()` / `Math.random()` / 无参 `new Date()`(破坏 resume
时间戳/随机种子经 `args` 传入。`export const meta = {...}` 必须是**纯字面量**。
## 质量模式(每种给最小片段)
- **Adversarial verify**`parallel([()=>agent(claim), ()=>agent(refute)])`,多数 refute 即弃。
- **Loop-until-dry**`while (fresh.length) { found = await parallel(...); fresh = dedup(found) }`
- **Multi-modal sweep**:多个 agent 各用不同搜索角度。
- **Judge panel**N 个独立方案 → 评分 → 取胜者嫁接他者亮点。
- **Completeness critic**:末尾一个 agent 问"还缺什么"。
## 后端路由
`AgentAdapterRegistry` 按 model/agentType 路由。v1 默认 `claude-code` 后端(深度读会话 provider/model/agent 体系)。`agent({model:'claude-haiku-*', agentType:'Explore'})` 走真实注册表。
## resume / budget
- `resumeFromRunId: '<id>'` — 重放 journal已完成 agent() 秒回。
- `budget.total` — token 硬顶(默认无限);`budget.spent()/remaining()` 读。
## 文件与命令
- 脚本目录:`.claude/workflows/<name>.ts|js|mjs` → 自动成 `/<name>` 命令。
- run 记录:`.claude/workflow-runs/<runId>/journal.jsonl`
- 监控面板:`/workflows`(双栏:左 run 列表,右 phase+agentj/k/r/x/n/q
- 工具:`Workflow`input: `script`/`name`/`scriptPath`/`args`/`resumeFromRunId`)。
```
- [ ] **Step 2验证被发现为 `/ultracode`**
Run: `FEATURE_WORKFLOW_SCRIPTS=1 bun run dev` 然后 REPL 输入 `/ultracode`(或单测 `getSkillDirCommands` 含 ultracode。最小校验
Run: `grep -rn "ultracode" src/skills/bundled/`
Expected: 命中 SKILL.md。
- [ ] **Step 3提交**
```bash
git add src/skills/bundled/ultracode/SKILL.md
git commit -m "feat(workflow): /ultracode knowledge skill (orchestration playbook)"
```
---
## Phase M8文档
### Task 16更新 workflow-scripts 文档
**Files:**
- Modify: `docs/features/workflow-scripts.md`
- [ ] **Step 1补面板与 skill 说明**
`docs/features/workflow-scripts.md` 末尾追加:
```markdown
## 监控面板:`/workflows`
`/workflows` 打开双栏监控面板:左栏扁平 workflow 列表(状态点+名+当前 phase+agent 计数),右栏聚焦 workflow 的 phase 横条 + 扁平 agent 列表。键位:`j/k` 选 run、`r` resume、`x` kill、`n` 新建、`q` 退出。进度按引擎 `agentId` 精确关联。
## `/ultracode` skill
`/ultracode` 注入多 agent workflow 编排工作法何时用、原语、质量模式、确定性约束、路由、resume/budget。纯知识零运行时副作用。
```
- [ ] **Step 2提交**
```bash
git add docs/features/workflow-scripts.md
git commit -m "docs(workflow): document /workflows panel and /ultracode skill"
```
---
## 收尾
- [ ] **最终全量 precheck**
Run: `bun run precheck`
Expected: typecheck + lint fix + test 全绿。
- [ ] **(可选)端到端冒烟**
Run: `FEATURE_WORKFLOW_SCRIPTS=1 bun run dev`REPL 内:
1. `/ultracode` → 注入 playbook。
2. 通过 Workflow 工具 `name: <某命名 workflow>` 启动。
3. `/workflows` → 看到该 runj/k 选中,右栏显示 phase/agent 实时刷新。
4. `x` kill → run 变 killed。
---
## 自查(写作后)
- **Spec 覆盖**:①引擎 agentIdTask 1-3②bus+store4-5③深度后端6④registry+ports7⑤service8⑥wiring+去 DetailDialog9-10⑦面板11-14⑧ultracode15⑨文档16— 全覆盖。
- **注册点零改动**tools.ts/commands.ts/constants/tasks/PermissionRequest 保留导出名即兼容(已在 Task 9 校验无残留旧引用)。
- **类型一致性**`agentId: number` 贯穿 types→hooks→store`WorkflowService`/`ProgressStore` 方法名一致;`claudeCodeBackend.id='claude-code'` 与 registry default 一致。
- **已知 TODO非占位是边界**`useInput` 签名以 `@anthropic/ink` 实际为准Task 13/14 已给备选 `panelCall.ts` 与内联 `useInput` 两套);`context.options.canUseTool` 字段名待确认Task 14 已注明回退)。