minimal-agent 0.1.8 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +405 -122
  2. package/dist/main.js +423 -941
  3. package/package.json +5 -2
  4. package/plugins/HOW-TO-WRITE-A-PLUGIN.md +186 -0
  5. package/plugins/ralph-wiggum/.claude-plugin/plugin.json +9 -0
  6. package/plugins/ralph-wiggum/README.md +179 -0
  7. package/plugins/ralph-wiggum/commands/cancel-ralph.md +18 -0
  8. package/plugins/ralph-wiggum/commands/help.md +126 -0
  9. package/plugins/ralph-wiggum/commands/ralph-loop.md +59 -0
  10. package/plugins/ralph-wiggum/hooks/hooks.json +15 -0
  11. package/plugins/ralph-wiggum/hooks/stop-hook.sh +191 -0
  12. package/plugins/ralph-wiggum/plugin.ts +275 -0
  13. package/plugins/ralph-wiggum/scripts/setup-ralph-loop.sh +203 -0
  14. package/plugins/ralph-wiggum/src/goalState.ts +310 -0
  15. package/plugins/ralph-wiggum/src/sentinels.ts +24 -0
  16. package/plugins/ralph-wiggum/src/stopHookRunner.ts +136 -0
  17. package/plugins/ralph-wiggum/src/verificationGate.ts +252 -0
  18. package/plugins/ralph-wiggum/test/goalState.test.ts +410 -0
  19. package/plugins/ralph-wiggum/test/verificationGate.test.ts +122 -0
  20. package/plugins/workflow-runner/.claude-plugin/plugin.json +5 -0
  21. package/plugins/workflow-runner/commands/workflow.md +15 -0
  22. package/plugins/workflow-runner/commands/workflows.md +8 -0
  23. package/plugins/workflow-runner/plugin.ts +42 -0
  24. package/plugins/workflow-runner/src/expressions.ts +371 -0
  25. package/plugins/workflow-runner/src/index.ts +194 -0
  26. package/plugins/workflow-runner/src/loader.ts +193 -0
  27. package/plugins/workflow-runner/src/runner.ts +313 -0
  28. package/plugins/workflow-runner/src/stepExecutors/assert.ts +30 -0
  29. package/plugins/workflow-runner/src/stepExecutors/llm.ts +54 -0
  30. package/plugins/workflow-runner/src/stepExecutors/skill.ts +115 -0
  31. package/plugins/workflow-runner/src/stepExecutors/tool.ts +41 -0
  32. package/plugins/workflow-runner/src/types.ts +183 -0
  33. package/plugins/workflow-runner/src/workflowState.ts +65 -0
  34. package/plugins/workflow-runner/test/cli.e2e.test.ts +114 -0
  35. package/plugins/workflow-runner/test/e2e.test.ts +268 -0
  36. package/plugins/workflow-runner/test/expressions.test.ts +140 -0
  37. package/plugins/workflow-runner/test/fixtures/cli-e2e.yaml +27 -0
  38. package/plugins/workflow-runner/test/fixtures/hello-workflow.yaml +49 -0
  39. package/plugins/workflow-runner/test/graceful.test.ts +139 -0
  40. package/plugins/workflow-runner/test/loader.test.ts +216 -0
  41. package/plugins/workflow-runner/test/pluginRunner.isolation.test.ts +230 -0
  42. package/plugins/workflow-runner/test/runner.test.ts +511 -0
  43. package/skills/config/SKILL.md +27 -1
  44. package/skills/image-gen-openrouter/SKILL.md +121 -0
  45. package/skills/subtitle-srt/SKILL.md +134 -0
  46. package/skills/tts-zh/SKILL.md +137 -0
  47. package/skills/video-compose/SKILL.md +139 -0
  48. package/workflows/book-review-short.yaml +99 -0
  49. package/workflows/e2e-write-greet.yaml +27 -0
  50. package/workflows/schema.json +74 -0
  51. package/workflows/youtube-shorts.yaml +171 -0
@@ -0,0 +1,194 @@
1
+ /**
2
+ * ============================================================
3
+ * src/workflows/index.ts —— /workflow 与 /workflows 命令的胶水层
4
+ * ------------------------------------------------------------
5
+ * 作为 pluginRunner 与 runner 之间的桥:
6
+ * - runWorkflowFromCommand:解析 /workflow <name> --input k=v args 调度
7
+ * - runWorkflowsList:处理 /workflows 列表展示(yield "text" 事件让 UI 显示)
8
+ *
9
+ * R1 红线:本文件只在 pluginRunner 命中 workflow 模式时才被 import。
10
+ * 不污染默认 T-A-O-R 路径。
11
+ * ============================================================
12
+ */
13
+
14
+ import { findWorkflowByName, listWorkflows } from './loader.ts';
15
+ import { runWorkflow } from './runner.ts';
16
+ import type { InputDef, RunContext, WorkflowEvent } from './types.ts';
17
+ import type { LoopEvent, Message, Provider } from '../../../src/plugin-sdk.ts';
18
+
19
+ export interface RunWorkflowFromCommandOptions {
20
+ provider: Provider;
21
+ history: Message[];
22
+ signal?: AbortSignal;
23
+ }
24
+
25
+ /**
26
+ * 把 "/workflow <name> --input k=v --input k2=v2" 拆成 name + inputs。
27
+ * 简单解析器(不依赖第三方):
28
+ * - 第一个非 flag token = name
29
+ * - --input k=v 重复
30
+ * - 不识别其他 flag(P0 不报错,仅 console.warn)
31
+ */
32
+ export function parseWorkflowArgs(rawArgs: string): {
33
+ name: string | null;
34
+ inputs: Record<string, string>;
35
+ } {
36
+ const tokens = tokenizeArgs(rawArgs);
37
+ const inputs: Record<string, string> = {};
38
+ let name: string | null = null;
39
+ for (let i = 0; i < tokens.length; i++) {
40
+ const t = tokens[i];
41
+ if (t === '--input') {
42
+ const kv = tokens[i + 1];
43
+ if (!kv) break;
44
+ const eq = kv.indexOf('=');
45
+ if (eq > 0) {
46
+ inputs[kv.slice(0, eq)] = kv.slice(eq + 1);
47
+ }
48
+ i++;
49
+ continue;
50
+ }
51
+ if (t.startsWith('--')) {
52
+ // 未知 flag:跳过其后一个 token(保守地认为它带值)
53
+ i++;
54
+ continue;
55
+ }
56
+ if (name === null) {
57
+ name = t;
58
+ }
59
+ }
60
+ return { name, inputs };
61
+ }
62
+
63
+ /** 简单 token 化:双引号包裹的整体当一个 token;其它按空白切。 */
64
+ function tokenizeArgs(s: string): string[] {
65
+ const out: string[] = [];
66
+ let i = 0;
67
+ while (i < s.length) {
68
+ while (i < s.length && /\s/.test(s[i])) i++;
69
+ if (i >= s.length) break;
70
+ if (s[i] === '"' || s[i] === "'") {
71
+ const q = s[i];
72
+ i++;
73
+ let acc = '';
74
+ while (i < s.length && s[i] !== q) {
75
+ acc += s[i++];
76
+ }
77
+ if (i < s.length) i++;
78
+ out.push(acc);
79
+ } else {
80
+ let acc = '';
81
+ while (i < s.length && !/\s/.test(s[i])) {
82
+ acc += s[i++];
83
+ }
84
+ out.push(acc);
85
+ }
86
+ }
87
+ return out;
88
+ }
89
+
90
+ /**
91
+ * 把字符串形态的 input 值按 InputDef.type 转成合适的 JS 类型 + 校验。
92
+ * 校验失败抛 Error,pluginRunner 会把它转成 error event。
93
+ */
94
+ function coerceInputs(
95
+ raw: Record<string, string>,
96
+ defs: InputDef[] | undefined,
97
+ ): Record<string, unknown> {
98
+ const out: Record<string, unknown> = {};
99
+ if (!defs) return { ...raw };
100
+
101
+ for (const def of defs) {
102
+ const rawVal = raw[def.name];
103
+ if (rawVal === undefined) {
104
+ if (def.required) {
105
+ throw new Error(`缺少必填 input: ${def.name}`);
106
+ }
107
+ if (def.default !== undefined) {
108
+ out[def.name] = def.default;
109
+ }
110
+ continue;
111
+ }
112
+ if (def.type === 'number') {
113
+ const n = parseFloat(rawVal);
114
+ if (Number.isNaN(n)) throw new Error(`input ${def.name} 必须是数字,收到 "${rawVal}"`);
115
+ out[def.name] = n;
116
+ } else if (def.type === 'enum') {
117
+ if (!def.values?.includes(rawVal)) {
118
+ throw new Error(
119
+ `input ${def.name} 必须是 [${def.values?.join(', ')}] 之一,收到 "${rawVal}"`,
120
+ );
121
+ }
122
+ out[def.name] = rawVal;
123
+ } else {
124
+ out[def.name] = rawVal;
125
+ }
126
+ }
127
+ // 未声明的 input 也透传(让 yaml 里可以引用 ad-hoc 变量)
128
+ for (const k of Object.keys(raw)) {
129
+ if (!(k in out)) out[k] = raw[k];
130
+ }
131
+ return out;
132
+ }
133
+
134
+ /** /workflow <name> [--input k=v] —— 主入口,由 pluginRunner 调用。 */
135
+ export async function* runWorkflowFromCommand(
136
+ rawArgs: string,
137
+ opts: RunWorkflowFromCommandOptions,
138
+ ): AsyncGenerator<WorkflowEvent | LoopEvent, void, void> {
139
+ const { name, inputs: rawInputs } = parseWorkflowArgs(rawArgs);
140
+ if (!name) {
141
+ yield {
142
+ type: 'error',
143
+ error:
144
+ '/workflow 用法: /workflow <name> [--input key=val ...]\n输入 /workflows 查看可用工作流。',
145
+ };
146
+ return;
147
+ }
148
+ const def = await findWorkflowByName(name);
149
+ if (!def) {
150
+ yield {
151
+ type: 'error',
152
+ error: `未找到 workflow "${name}"。输入 /workflows 查看可用列表。`,
153
+ };
154
+ return;
155
+ }
156
+
157
+ let coerced: Record<string, unknown>;
158
+ try {
159
+ coerced = coerceInputs(rawInputs, def.inputs);
160
+ } catch (e) {
161
+ yield { type: 'error', error: (e as Error).message };
162
+ return;
163
+ }
164
+
165
+ const ctx: RunContext = {
166
+ provider: opts.provider,
167
+ signal: opts.signal,
168
+ history: opts.history,
169
+ };
170
+
171
+ yield* runWorkflow(def, coerced, ctx);
172
+ }
173
+
174
+ /** /workflows —— 列出可用 workflow。yield 一条 text 事件让 UI 显示。 */
175
+ export async function* runWorkflowsList(): AsyncGenerator<LoopEvent, void, void> {
176
+ const list = await listWorkflows();
177
+ if (list.length === 0) {
178
+ yield {
179
+ type: 'text',
180
+ delta:
181
+ '当前没有可用工作流。在 cwd/workflows/ 下放 *.yaml 即可(参考 workflows/schema.json 或 docs/workflow-design.md)。\n',
182
+ };
183
+ yield { type: 'turn_done' };
184
+ return;
185
+ }
186
+ const lines = ['可用工作流:'];
187
+ for (const w of list) {
188
+ lines.push(` /workflow ${w.name} — ${w.description.split('\n')[0].trim()}`);
189
+ }
190
+ lines.push('');
191
+ lines.push('运行示例: /workflow <name> --input key=val');
192
+ yield { type: 'text', delta: lines.join('\n') + '\n' };
193
+ yield { type: 'turn_done' };
194
+ }
@@ -0,0 +1,193 @@
1
+ /**
2
+ * ============================================================
3
+ * src/workflows/loader.ts —— workflow yaml 双源加载 + 最小校验
4
+ * ------------------------------------------------------------
5
+ * - 扫描 cwd/workflows/ 与 packageRoot/workflows/(cwd 优先,复用
6
+ * getResourceSearchPaths 契约)
7
+ * - 一个坏 yaml 不应拖累其它工作流(catch 单文件错误,继续扫描)
8
+ * - 目录不存在 / 为空 → 静默返回 [](默认 T-A-O-R 不受影响)
9
+ *
10
+ * P0 不引 AJV;下面手写 ≤80 行的最小校验器,只挡死硬性错误:
11
+ * - name / description / steps 必填且类型正确
12
+ * - steps 非空
13
+ * - 每个 step 必须有且只有一个动作字段(tool / skill / llm / type)
14
+ *
15
+ * P1 切 AJV 做完整 schema。
16
+ * ============================================================
17
+ */
18
+
19
+ import { readFile, readdir } from 'node:fs/promises';
20
+ import { join } from 'node:path';
21
+
22
+ import { parse as parseYamlLib } from 'yaml';
23
+
24
+ import { getResourceSearchPaths } from '../../../src/plugin-sdk.ts';
25
+ import type { StepDef, WorkflowDef } from './types.ts';
26
+
27
+ export class WorkflowLoadError extends Error {
28
+ constructor(message: string, public readonly file?: string) {
29
+ super(message);
30
+ this.name = 'WorkflowLoadError';
31
+ }
32
+ }
33
+
34
+ /**
35
+ * 扫描双源目录,返回所有 *.yaml / *.yml 解析后的 workflow。
36
+ * 永远不抛;坏文件 console.warn 并跳过。
37
+ */
38
+ export async function listWorkflows(): Promise<WorkflowDef[]> {
39
+ const out: WorkflowDef[] = [];
40
+ const seen = new Set<string>();
41
+ const roots = getResourceSearchPaths('workflows', import.meta.url);
42
+
43
+ for (const root of roots) {
44
+ let entries: string[];
45
+ try {
46
+ entries = await readdir(root);
47
+ } catch {
48
+ continue;
49
+ }
50
+ for (const f of entries) {
51
+ if (!f.endsWith('.yaml') && !f.endsWith('.yml')) continue;
52
+ const full = join(root, f);
53
+ try {
54
+ const def = await parseWorkflowFile(full);
55
+ if (!seen.has(def.name)) {
56
+ seen.add(def.name);
57
+ out.push(def);
58
+ }
59
+ } catch (e) {
60
+ // eslint-disable-next-line no-console
61
+ console.warn(`[workflows] 忽略损坏的 yaml ${full}: ${(e as Error).message}`);
62
+ }
63
+ }
64
+ }
65
+ return out;
66
+ }
67
+
68
+ /** 按 name 查找。找不到返回 null —— 调用方决定如何提示用户。 */
69
+ export async function findWorkflowByName(name: string): Promise<WorkflowDef | null> {
70
+ const all = await listWorkflows();
71
+ return all.find((w) => w.name === name) ?? null;
72
+ }
73
+
74
+ /** 读单个 yaml 文件 → 解析 → 校验。失败抛 WorkflowLoadError。 */
75
+ export async function parseWorkflowFile(file: string): Promise<WorkflowDef> {
76
+ let raw: string;
77
+ try {
78
+ raw = await readFile(file, 'utf8');
79
+ } catch (e) {
80
+ throw new WorkflowLoadError(`无法读取 ${file}: ${(e as Error).message}`, file);
81
+ }
82
+ let data: unknown;
83
+ try {
84
+ data = parseYamlLib(raw);
85
+ } catch (e) {
86
+ throw new WorkflowLoadError(`yaml 语法错误: ${(e as Error).message}`, file);
87
+ }
88
+ if (!data || typeof data !== 'object') {
89
+ throw new WorkflowLoadError('yaml 根节点必须是对象', file);
90
+ }
91
+ validate(data as Record<string, unknown>, file);
92
+ const def = data as WorkflowDef;
93
+ def.__source = file;
94
+ return def;
95
+ }
96
+
97
+ // ---------------- 手写最小校验 ----------------
98
+
99
+ function validate(obj: Record<string, unknown>, file: string): void {
100
+ if (typeof obj.name !== 'string' || obj.name.length === 0) {
101
+ throw new WorkflowLoadError("缺少必填字段 'name'(string)", file);
102
+ }
103
+ if (typeof obj.description !== 'string' || obj.description.length === 0) {
104
+ throw new WorkflowLoadError("缺少必填字段 'description'(string)", file);
105
+ }
106
+ if (!Array.isArray(obj.steps) || obj.steps.length === 0) {
107
+ throw new WorkflowLoadError("缺少必填字段 'steps' 或为空数组", file);
108
+ }
109
+ validateSteps(obj.steps as unknown[], file, '');
110
+ if (obj.inputs !== undefined) {
111
+ if (!Array.isArray(obj.inputs)) {
112
+ throw new WorkflowLoadError("'inputs' 必须是数组", file);
113
+ }
114
+ for (const [i, item] of (obj.inputs as unknown[]).entries()) {
115
+ if (!item || typeof item !== 'object') {
116
+ throw new WorkflowLoadError(`inputs[${i}] 必须是对象`, file);
117
+ }
118
+ const r = item as Record<string, unknown>;
119
+ if (typeof r.name !== 'string' || r.name.length === 0) {
120
+ throw new WorkflowLoadError(`inputs[${i}].name 必填`, file);
121
+ }
122
+ if (r.type !== undefined && r.type !== 'string' && r.type !== 'number' && r.type !== 'enum') {
123
+ throw new WorkflowLoadError(`inputs[${i}].type 必须是 string/number/enum`, file);
124
+ }
125
+ if (r.type === 'enum' && (!Array.isArray(r.values) || r.values.length === 0)) {
126
+ throw new WorkflowLoadError(`inputs[${i}].values 必填(type=enum 时)`, file);
127
+ }
128
+ }
129
+ }
130
+ }
131
+
132
+ function validateSteps(steps: unknown[], file: string, pathPrefix: string): void {
133
+ const seenIds = new Set<string>();
134
+ for (const [i, item] of steps.entries()) {
135
+ const p = pathPrefix ? `${pathPrefix}.steps[${i}]` : `steps[${i}]`;
136
+ if (!item || typeof item !== 'object') {
137
+ throw new WorkflowLoadError(`${p} 必须是对象`, file);
138
+ }
139
+ const s = item as Record<string, unknown>;
140
+ if (typeof s.id !== 'string' || s.id.length === 0) {
141
+ throw new WorkflowLoadError(`${p}.id 必填`, file);
142
+ }
143
+ // id 唯一仅在同级 steps 数组内强制
144
+ if (seenIds.has(s.id)) {
145
+ throw new WorkflowLoadError(`${p}.id "${s.id}" 在当前 steps 列表中重复`, file);
146
+ }
147
+ seenIds.add(s.id);
148
+
149
+ // 动作字段恰好一个
150
+ const actionFields: (keyof StepDef)[] = ['tool', 'skill', 'llm', 'type'];
151
+ const present = actionFields.filter((k) => s[k] !== undefined);
152
+ if (present.length === 0) {
153
+ throw new WorkflowLoadError(
154
+ `${p} 必须有以下字段之一: tool / skill / llm / type`,
155
+ file,
156
+ );
157
+ }
158
+ if (present.length > 1) {
159
+ throw new WorkflowLoadError(
160
+ `${p} 同时设置了 ${present.join(', ')};只允许其中一个`,
161
+ file,
162
+ );
163
+ }
164
+
165
+ // 控制流字段一致性
166
+ if (s.type === 'branch' && s.condition === undefined) {
167
+ throw new WorkflowLoadError(`${p} type=branch 必须带 'condition'`, file);
168
+ }
169
+ if (s.type === 'loop') {
170
+ if (s.over === undefined) throw new WorkflowLoadError(`${p} type=loop 必须带 'over'`, file);
171
+ if (typeof s.as !== 'string' || s.as.length === 0) {
172
+ throw new WorkflowLoadError(`${p} type=loop 必须带 'as'`, file);
173
+ }
174
+ if (!Array.isArray(s.do)) {
175
+ throw new WorkflowLoadError(`${p} type=loop 必须带 'do' 数组`, file);
176
+ }
177
+ validateSteps(s.do as unknown[], file, `${p}.do`);
178
+ }
179
+ if (s.type === 'assert' && s.condition === undefined) {
180
+ throw new WorkflowLoadError(`${p} type=assert 必须带 'condition'`, file);
181
+ }
182
+ if (s.type === 'branch') {
183
+ if (s.then !== undefined && !Array.isArray(s.then)) {
184
+ throw new WorkflowLoadError(`${p}.then 必须是数组`, file);
185
+ }
186
+ if (s.else !== undefined && !Array.isArray(s.else)) {
187
+ throw new WorkflowLoadError(`${p}.else 必须是数组`, file);
188
+ }
189
+ if (Array.isArray(s.then)) validateSteps(s.then as unknown[], file, `${p}.then`);
190
+ if (Array.isArray(s.else)) validateSteps(s.else as unknown[], file, `${p}.else`);
191
+ }
192
+ }
193
+ }
@@ -0,0 +1,313 @@
1
+ /**
2
+ * ============================================================
3
+ * plugins/workflow-runner/src/runner.ts —— workflow 顺序执行器
4
+ * ------------------------------------------------------------
5
+ * AsyncGenerator,按 yaml.steps[] 顺序执行:
6
+ * - when: 不满足 → yield workflow_step_skipped
7
+ * - 命中动作字段 → 调对应 step executor
8
+ * - capture: 把 result.raw 上的字段绑到 VarStack
9
+ * - onError: halt(默认)→ yield error 后 return;continue → 记录后跳过
10
+ * - 信号 abort → yield interrupted 后 return
11
+ *
12
+ * 支持的 step kind(loader 已校验):
13
+ * - tool / llm / assert —— 原子动作(execXxxStep 返回 StepResult)
14
+ * - skill —— 迷你 ReAct 子循环(透传子事件 + 累计 text)
15
+ * - branch / loop / pause —— 控制流,直接在 runSteps 内处理(不走 executor)
16
+ *
17
+ * 控制流嵌套通过 runSteps 递归实现:branch.then/else / loop.do 子数组
18
+ * 是 StepDef[],调用方语义与顶层 def.steps 一致;变量栈用 VarStack.push/pop
19
+ * 在 loop 迭代间隔离 `${as}` / `${as}_idx`。
20
+ * ============================================================
21
+ */
22
+
23
+ import { getWorkingDir, type LoopEvent } from '../../../src/plugin-sdk.ts';
24
+ import { evalExpr, interpolate } from './expressions.ts';
25
+ import { execAssertStep } from './stepExecutors/assert.ts';
26
+ import { execLlmStep } from './stepExecutors/llm.ts';
27
+ import { execSkillStep } from './stepExecutors/skill.ts';
28
+ import { execToolStep } from './stepExecutors/tool.ts';
29
+ import type {
30
+ RunContext,
31
+ StepDef,
32
+ StepKind,
33
+ StepResult,
34
+ WorkflowDef,
35
+ WorkflowEvent,
36
+ } from './types.ts';
37
+ import { VarStack } from './types.ts';
38
+ import { WorkflowState } from './workflowState.ts';
39
+
40
+ function stepKind(step: StepDef): StepKind {
41
+ if (step.type) return step.type;
42
+ if (step.tool) return 'tool';
43
+ if (step.skill) return 'skill';
44
+ if (step.llm) return 'llm';
45
+ return 'assert'; // 不会到这里(loader 已校验),兜底
46
+ }
47
+
48
+ function readPath(root: Record<string, unknown>, path: string): unknown {
49
+ const parts = path.split('.');
50
+ let cur: unknown = root;
51
+ for (const p of parts) {
52
+ if (cur == null || typeof cur !== 'object') return undefined;
53
+ cur = (cur as Record<string, unknown>)[p];
54
+ }
55
+ return cur;
56
+ }
57
+
58
+ function bindCapture(
59
+ captureMap: Record<string, string>,
60
+ result: StepResult,
61
+ vars: VarStack,
62
+ ): void {
63
+ for (const [resultField, varName] of Object.entries(captureMap)) {
64
+ const value = resultField.includes('.')
65
+ ? readPath(result.raw, resultField)
66
+ : result.raw[resultField];
67
+ vars.set(varName, value);
68
+ }
69
+ }
70
+
71
+ /**
72
+ * 分发"动作"型 step(tool / llm / skill / assert)到对应 executor。
73
+ * 控制流 step(branch / loop / pause)由 runSteps 直接处理,不走这里。
74
+ */
75
+ async function* dispatchActionStep(
76
+ step: StepDef,
77
+ vars: VarStack,
78
+ ctx: RunContext,
79
+ ): AsyncGenerator<LoopEvent, StepResult, void> {
80
+ if (step.type === 'assert') return execAssertStep(step, vars);
81
+ if (step.tool) return await execToolStep(step, vars, ctx);
82
+ if (step.llm) return await execLlmStep(step, vars, ctx);
83
+ if (step.skill) return yield* execSkillStep(step, vars, ctx);
84
+ throw new Error(`step ${step.id}: 无可执行字段(tool/llm/skill/assert)`);
85
+ }
86
+
87
+ /**
88
+ * 递归执行 steps[]。返回 true 表示该 block 已 halt(上游应中止)。
89
+ *
90
+ * yields 混合事件:WorkflowEvent + LoopEvent(skill 子循环转发 / error / interrupted)。
91
+ * index/total 字段相对**当前 block**,嵌套 block 自有计数(branch.then 内 3 步就报 total=3)。
92
+ */
93
+ async function* runSteps(
94
+ steps: StepDef[],
95
+ vars: VarStack,
96
+ ctx: RunContext,
97
+ state: WorkflowState,
98
+ ): AsyncGenerator<WorkflowEvent | LoopEvent, boolean, void> {
99
+ for (let i = 0; i < steps.length; i++) {
100
+ const step = steps[i];
101
+ if (ctx.signal?.aborted) {
102
+ yield { type: 'interrupted' };
103
+ return true;
104
+ }
105
+
106
+ // when 条件
107
+ if (step.when) {
108
+ let pass = false;
109
+ try {
110
+ pass = Boolean(evalExpr(interpolate(step.when, vars), vars));
111
+ } catch (e) {
112
+ const errMsg = `when 表达式错误: ${(e as Error).message}`;
113
+ yield {
114
+ type: 'workflow_step_end',
115
+ id: step.id,
116
+ ok: false,
117
+ error: errMsg,
118
+ };
119
+ yield {
120
+ type: 'error',
121
+ error: `Workflow halted at step "${step.id}": ${errMsg}`,
122
+ };
123
+ return true;
124
+ }
125
+ if (!pass) {
126
+ yield {
127
+ type: 'workflow_step_skipped',
128
+ id: step.id,
129
+ reason: 'when=false',
130
+ };
131
+ await state.appendProgress(`- ${step.id} skipped (when=false)`);
132
+ continue;
133
+ }
134
+ }
135
+
136
+ const kind = stepKind(step);
137
+ yield {
138
+ type: 'workflow_step_start',
139
+ id: step.id,
140
+ kind,
141
+ index: i,
142
+ total: steps.length,
143
+ };
144
+
145
+ // ---------- 控制流:branch ----------
146
+ if (step.type === 'branch') {
147
+ let cond = false;
148
+ try {
149
+ cond = Boolean(evalExpr(interpolate(step.condition ?? 'false', vars), vars));
150
+ } catch (e) {
151
+ const errMsg = `branch 条件错误: ${(e as Error).message}`;
152
+ await state.appendProgress(`✗ ${step.id}: ${errMsg}`);
153
+ yield { type: 'workflow_step_end', id: step.id, ok: false, error: errMsg };
154
+ yield { type: 'error', error: `Workflow halted at step "${step.id}": ${errMsg}` };
155
+ return true;
156
+ }
157
+ const branch = cond ? step.then : step.else;
158
+ if (Array.isArray(branch) && branch.length > 0) {
159
+ const childHalted = yield* runSteps(branch, vars, ctx, state);
160
+ if (childHalted) return true;
161
+ }
162
+ await state.appendProgress(`✓ ${step.id} (branch=${cond})`);
163
+ await state.writeVars(vars.snapshot());
164
+ yield {
165
+ type: 'workflow_step_end',
166
+ id: step.id,
167
+ ok: true,
168
+ output: `branch=${cond}`,
169
+ };
170
+ continue;
171
+ }
172
+
173
+ // ---------- 控制流:loop ----------
174
+ if (step.type === 'loop') {
175
+ let arr: unknown;
176
+ try {
177
+ arr = evalExpr(interpolate(step.over ?? '[]', vars), vars);
178
+ } catch (e) {
179
+ const errMsg = `loop over 表达式错误: ${(e as Error).message}`;
180
+ await state.appendProgress(`✗ ${step.id}: ${errMsg}`);
181
+ yield { type: 'workflow_step_end', id: step.id, ok: false, error: errMsg };
182
+ yield { type: 'error', error: `Workflow halted at step "${step.id}": ${errMsg}` };
183
+ return true;
184
+ }
185
+ if (!Array.isArray(arr)) {
186
+ const errMsg = `loop over 必须求值为数组,得到 ${typeof arr}`;
187
+ await state.appendProgress(`✗ ${step.id}: ${errMsg}`);
188
+ yield { type: 'workflow_step_end', id: step.id, ok: false, error: errMsg };
189
+ yield { type: 'error', error: `Workflow halted at step "${step.id}": ${errMsg}` };
190
+ return true;
191
+ }
192
+ const asName = step.as ?? 'item';
193
+ let iterCount = 0;
194
+ for (let idx = 0; idx < arr.length; idx++) {
195
+ if (ctx.signal?.aborted) {
196
+ yield { type: 'interrupted' };
197
+ return true;
198
+ }
199
+ vars.push();
200
+ vars.set(asName, arr[idx]);
201
+ vars.set(`${asName}_idx`, idx);
202
+ const childHalted = yield* runSteps(step.do ?? [], vars, ctx, state);
203
+ vars.pop();
204
+ if (childHalted) return true;
205
+ iterCount++;
206
+ }
207
+ await state.appendProgress(`✓ ${step.id} (loop ×${iterCount})`);
208
+ await state.writeVars(vars.snapshot());
209
+ yield {
210
+ type: 'workflow_step_end',
211
+ id: step.id,
212
+ ok: true,
213
+ output: `loop ${iterCount} 次`,
214
+ };
215
+ continue;
216
+ }
217
+
218
+ // ---------- 控制流:pause ----------
219
+ //
220
+ // 非交互模式(CLI -p / 测试)下无法真的暂停等待人工,直接当成 skipped。
221
+ // 交互模式(TUI)需要 UI 层接住 step_skipped 把 reason 渲染出来 —— P0 已
222
+ // 走通这条 UI 路径。step.prompt 用于把"暂停理由"传给上层呈现。
223
+ if (step.type === 'pause') {
224
+ const msg = step.prompt
225
+ ? interpolate(step.prompt, vars)
226
+ : 'pause(非交互模式直接跳过)';
227
+ await state.appendProgress(`- ${step.id} pause: ${msg}`);
228
+ yield {
229
+ type: 'workflow_step_skipped',
230
+ id: step.id,
231
+ reason: `pause: ${msg}`,
232
+ };
233
+ yield {
234
+ type: 'workflow_step_end',
235
+ id: step.id,
236
+ ok: true,
237
+ output: 'paused (skipped in non-interactive mode)',
238
+ };
239
+ continue;
240
+ }
241
+
242
+ // ---------- 普通动作 step:tool / llm / skill / assert ----------
243
+ try {
244
+ const result = yield* dispatchActionStep(step, vars, ctx);
245
+ if (step.capture) bindCapture(step.capture, result, vars);
246
+ await state.appendProgress(`✓ ${step.id} (${kind})`);
247
+ await state.writeVars(vars.snapshot());
248
+ yield {
249
+ type: 'workflow_step_end',
250
+ id: step.id,
251
+ ok: true,
252
+ output: result.preview,
253
+ };
254
+ } catch (e) {
255
+ const errMsg = (e as Error).message ?? String(e);
256
+ await state.appendProgress(`✗ ${step.id}: ${errMsg}`);
257
+ if (step.onError === 'continue') {
258
+ yield {
259
+ type: 'workflow_step_end',
260
+ id: step.id,
261
+ ok: false,
262
+ error: errMsg,
263
+ };
264
+ continue;
265
+ }
266
+ yield {
267
+ type: 'workflow_step_end',
268
+ id: step.id,
269
+ ok: false,
270
+ error: errMsg,
271
+ };
272
+ yield {
273
+ type: 'error',
274
+ error: `Workflow halted at step "${step.id}": ${errMsg}`,
275
+ };
276
+ return true;
277
+ }
278
+ }
279
+ return false;
280
+ }
281
+
282
+ /**
283
+ * 运行一个 workflow。
284
+ *
285
+ * yields 混合事件:本地 WorkflowEvent(workflow_* 系列,UI 静默忽略不渲染)
286
+ * + 通用 LoopEvent(error / interrupted 等,UI 识别并渲染)。
287
+ * 经 plugin.ts -> pluginRunner 透传给 UI;workflow_* 由 PluginEvent 开放契约承载。
288
+ */
289
+ export async function* runWorkflow(
290
+ def: WorkflowDef,
291
+ inputs: Record<string, unknown>,
292
+ ctx: RunContext,
293
+ ): AsyncGenerator<WorkflowEvent | LoopEvent, void, void> {
294
+ const vars = new VarStack();
295
+ vars.setGlobal('inputs', inputs);
296
+
297
+ const state = new WorkflowState(getWorkingDir());
298
+ await state.init(def.name, inputs);
299
+
300
+ yield {
301
+ type: 'workflow_start',
302
+ name: def.name,
303
+ totalSteps: def.steps.length,
304
+ };
305
+
306
+ try {
307
+ const halted = yield* runSteps(def.steps, vars, ctx, state);
308
+ if (halted) return;
309
+ yield { type: 'workflow_done', name: def.name, vars: vars.snapshot() };
310
+ } finally {
311
+ await state.cleanup();
312
+ }
313
+ }