minimal-agent 0.12.0 → 0.13.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 (74) hide show
  1. package/package.json +3 -2
  2. package/plugins/workflow-runner/src/expressions.js +29 -8
  3. package/plugins/workflow-runner/src/expressions.ts +28 -10
  4. package/plugins/workflow-runner/src/index.js +45 -17
  5. package/plugins/workflow-runner/src/index.ts +44 -18
  6. package/plugins/workflow-runner/src/recovery.js +9 -2
  7. package/plugins/workflow-runner/src/recovery.ts +9 -2
  8. package/plugins/workflow-runner/src/runner.js +31 -5
  9. package/plugins/workflow-runner/src/runner.ts +33 -5
  10. package/plugins/workflow-runner/src/stepExecutors/assert.js +2 -1
  11. package/plugins/workflow-runner/src/stepExecutors/assert.ts +3 -1
  12. package/plugins/workflow-runner/src/stepExecutors/tool.js +29 -10
  13. package/plugins/workflow-runner/src/stepExecutors/tool.ts +29 -11
  14. package/plugins/workflow-runner/src/stepExecutors/verify.js +6 -3
  15. package/plugins/workflow-runner/src/stepExecutors/verify.ts +7 -3
  16. package/plugins/workflow-runner/src/taskIdentity.js +197 -21
  17. package/plugins/workflow-runner/src/taskIdentity.ts +201 -20
  18. package/plugins/workflow-runner/src/types.js +113 -7
  19. package/plugins/workflow-runner/src/types.ts +111 -7
  20. package/plugins/workflow-runner/src/workflowState.js +455 -54
  21. package/plugins/workflow-runner/src/workflowState.ts +508 -63
  22. package/plugins/workflow-runner/test/cli.e2e.test.ts +20 -0
  23. package/plugins/workflow-runner/test/e2eNewLayout.test.ts +314 -0
  24. package/plugins/workflow-runner/test/engineRobustness.test.ts +9 -1
  25. package/plugins/workflow-runner/test/expressions.test.ts +42 -0
  26. package/plugins/workflow-runner/test/illustrate.load.test.ts +2 -2
  27. package/plugins/workflow-runner/test/parseWorkflowArgs.test.ts +19 -0
  28. package/plugins/workflow-runner/test/ppt.load.test.ts +15 -15
  29. package/plugins/workflow-runner/test/ralphLoop.e2e.test.ts +3 -2
  30. package/plugins/workflow-runner/test/research.load.test.ts +328 -0
  31. package/plugins/workflow-runner/test/runnerPreflight.test.ts +68 -11
  32. package/plugins/workflow-runner/test/stateLifecycle.test.ts +60 -12
  33. package/plugins/workflow-runner/test/taskArbitration.test.ts +19 -3
  34. package/plugins/workflow-runner/test/taskLifecycle.test.ts +61 -7
  35. package/plugins/workflow-runner/test/webppt.load.test.ts +4 -4
  36. package/plugins/workflow-runner/test/word.load.test.ts +9 -9
  37. package/skills/skill-creator/scripts/run_eval.py +3 -2
  38. package/src/cli/print.js +17 -0
  39. package/src/cli/print.ts +25 -0
  40. package/src/config/configService.js +24 -0
  41. package/src/config/configService.ts +23 -0
  42. package/src/infra/verification.js +25 -18
  43. package/src/infra/verification.ts +50 -18
  44. package/src/plugins/pluginRunner.js +18 -7
  45. package/src/plugins/pluginRunner.ts +20 -6
  46. package/src/prompts/policyRegistry.js +28 -1
  47. package/src/prompts/policyRegistry.ts +28 -1
  48. package/src/tools/advisor/advisor.js +76 -9
  49. package/src/tools/advisor/advisor.ts +79 -9
  50. package/src/tools/bash/bash.js +51 -6
  51. package/src/tools/bash/bash.ts +56 -7
  52. package/src/tools/powershell/powershell.js +18 -1
  53. package/src/tools/powershell/powershell.ts +18 -2
  54. package/src/tools/read/read.js +9 -4
  55. package/src/tools/read/read.ts +9 -4
  56. package/src/tools/shared/permanentDeleteGuard.js +34 -3
  57. package/src/tools/shared/permanentDeleteGuard.ts +43 -6
  58. package/src/tools/webbrowser/browserControl.js +5 -1
  59. package/src/tools/webbrowser/browserControl.ts +7 -1
  60. package/src/tools/webbrowser/webbrowser.js +8 -4
  61. package/src/tools/webbrowser/webbrowser.ts +8 -4
  62. package/src/types.ts +10 -4
  63. package/workflows/adaptive-research.yaml +5 -5
  64. package/workflows/book-review-short.yaml +5 -5
  65. package/workflows/e2e-write-greet.yaml +2 -2
  66. package/workflows/illustrate.yaml +12 -12
  67. package/workflows/plan.yaml +18 -18
  68. package/workflows/ppt.yaml +201 -201
  69. package/workflows/ralph-loop.yaml +3 -1
  70. package/workflows/research.md +449 -0
  71. package/workflows/research.yaml +873 -0
  72. package/workflows/webppt.yaml +47 -47
  73. package/workflows/word.yaml +80 -80
  74. package/workflows/youtube-shorts.yaml +25 -25
@@ -144,4 +144,24 @@ describe('CLI 端到端 /workflow(拉子进程跑 main.tsx)', () => {
144
144
  },
145
145
  SPAWN_TIMEOUT,
146
146
  );
147
+
148
+ // B7:用户 shell 习惯写 --input brief=A B C(没加引号),shell 拆成 --input brief=A + B + C
149
+ // 三个 token;引擎识别为首参已由 --input 提供、但仍存在散落位置参数,按贪婪语义自动拼回,
150
+ // 不丢 B、C(之前 B/C 被当占位参数映射给后续非贪婪 input,或直接静默丢弃)。
151
+ it(
152
+ 'B7:贪婪首参用 --input 提供但没加引号被拆 → 位置参数自动拼回首参',
153
+ async () => {
154
+ // 注意:命令字符串本身在这里是单条 input,但引擎 tokenize 后是:
155
+ // [/workflow, greedy-e2e, --input, brief=做, 一个, 小玩具]
156
+ // 即 greedy-e2e 之后的内容中,--input brief=做 是首参赋值,「一个」「小玩具」
157
+ // 是被 shell-like 拆分出来的位置参数,B7 修复后必须自动拼回为「做 一个 小玩具」。
158
+ const r = await spawnMain('/workflow greedy-e2e --input brief=做 一个 小玩具', tmp);
159
+ expect(r.exitCode).toBe(0);
160
+ expect(r.stdout).toContain('GREEDY-BRIEF=[做 一个 小玩具]');
161
+ // 修复附带的用户提示:告诉用户下次记得加引号(-p 模式 warning 走 stderr 或 stdout 都算)
162
+ const combined = r.stdout + '\n' + r.stderr;
163
+ expect(combined).toContain('已自动拼回');
164
+ },
165
+ SPAWN_TIMEOUT,
166
+ );
147
167
  });
@@ -0,0 +1,314 @@
1
+ /**
2
+ * 端到端验证 Phase 3:session 化状态目录布局(<wfDir>/<sessionId>/)
3
+ * 在真实 runWorkflow 下的完整行为:fresh → 同 goal completed →
4
+ * 不同 goal new-task + 归档 → 旧布局兼容 → 多 session 并行隔离。
5
+ *
6
+ * 复用 e2e.test.ts 的 mock 模式(mock workingDir.ts + MINIMAL_AGENT_CWD),
7
+ * 用 hello-workflow fixture(无 LLM 依赖,确定性强)。
8
+ */
9
+ import {
10
+ afterEach,
11
+ beforeEach,
12
+ describe,
13
+ expect,
14
+ it,
15
+ mock,
16
+ } from 'bun:test';
17
+ import {
18
+ copyFileSync,
19
+ existsSync,
20
+ mkdirSync,
21
+ mkdtempSync,
22
+ readdirSync,
23
+ readFileSync,
24
+ rmSync,
25
+ writeFileSync,
26
+ } from 'node:fs';
27
+ import { tmpdir } from 'node:os';
28
+ import { join } from 'node:path';
29
+
30
+ import { _resetWorkingDir } from '../../../src/bootstrap/workingDir.js';
31
+ import { _resetPluginCache } from '../../../src/plugins/commandRouter.js';
32
+ import type { Provider } from '../../../src/types.js';
33
+ import type { PluginEvent } from '../../../src/plugins/pluginApi.js';
34
+ import type { WorkflowEvent } from '../src/types.js';
35
+
36
+ type Ev = PluginEvent | WorkflowEvent;
37
+
38
+ const FIXTURE_SRC = join(import.meta.dir, 'fixtures', 'hello-workflow.yaml');
39
+
40
+ const provider: Provider = {
41
+ name: 'test',
42
+ baseURL: '',
43
+ apiKey: '',
44
+ model: 'm',
45
+ contextWindow: 128000,
46
+ };
47
+
48
+ async function collect(gen: AsyncGenerator<Ev, void, void>): Promise<Ev[]> {
49
+ const out: Ev[] = [];
50
+ for await (const ev of gen) out.push(ev);
51
+ return out;
52
+ }
53
+
54
+ const SESSION_ALPHA = 'alpha';
55
+ const SESSION_BETA = 'beta';
56
+
57
+ describe('workflow E2E · session 化新布局(Phase 3)', () => {
58
+ let tmp: string;
59
+
60
+ beforeEach(() => {
61
+ tmp = mkdtempSync(join(tmpdir(), 'wf-e2e-phase3-'));
62
+ mkdirSync(join(tmp, 'workflows'), { recursive: true });
63
+ // 拷贝 fixture 并:① 加 goal input(hash 身份的关键字段,区分任务)
64
+ // ② 补静态路径 artifacts 声明 + 一个写它的 step(new-task 归档逻辑依赖声明产物;
65
+ // 注意 artifacts 不支持 ${var} 插值,必须用固定路径)
66
+ const raw = readFileSync(FIXTURE_SRC, 'utf8');
67
+ const patched = raw
68
+ .replace(
69
+ 'version: "0.1"',
70
+ 'version: "0.1"\n\nartifacts:\n - "greetings/output.txt"',
71
+ )
72
+ .replace(
73
+ 'inputs:\n - name: name',
74
+ 'inputs:\n - name: goal\n type: string\n required: true\n description: 任务身份\n - name: name',
75
+ )
76
+ // 在 verify_files 前插一个写 output.txt 的 step
77
+ .replace(
78
+ ' - id: verify_files',
79
+ ` - id: write_output\n tool: Write\n args:\n file_path: "greetings/output.txt"\n content: "ok"\n\n - id: verify_files`,
80
+ );
81
+ writeFileSync(join(tmp, 'workflows', 'hello-workflow.yaml'), patched);
82
+
83
+ process.env.MINIMAL_AGENT_CWD = tmp;
84
+ _resetWorkingDir();
85
+ _resetPluginCache();
86
+ mock.module('../../../src/bootstrap/workingDir.js', () => ({
87
+ getWorkingDir: () => tmp,
88
+ initWorkingDir: () => tmp,
89
+ _resetWorkingDir: () => {},
90
+ }));
91
+ });
92
+
93
+ afterEach(() => {
94
+ delete process.env.MINIMAL_AGENT_CWD;
95
+ _resetWorkingDir();
96
+ _resetPluginCache();
97
+ rmSync(tmp, { recursive: true, force: true });
98
+ });
99
+
100
+ async function runCmd(
101
+ input: string,
102
+ opts: { sessionId?: string } = {},
103
+ ): Promise<Ev[]> {
104
+ const { runWithPlugins } = await import('../../../src/plugins/pluginRunner.js');
105
+ const runtimeContext: Record<string, unknown> = {};
106
+ if (opts.sessionId) runtimeContext.sessionId = opts.sessionId;
107
+ return collect(runWithPlugins(input, {
108
+ provider,
109
+ history: [],
110
+ runtimeContext: runtimeContext as any,
111
+ }) as AsyncGenerator<Ev>);
112
+ }
113
+
114
+ function wfDir(): string {
115
+ return join(tmp, '.minimal-agent-workflow', 'hello-workflow');
116
+ }
117
+
118
+ function sessionActive(session: string): boolean {
119
+ return existsSync(join(wfDir(), session));
120
+ }
121
+ function sessionDone(session: string): boolean {
122
+ return existsSync(join(wfDir(), `${session}.done`));
123
+ }
124
+ function sessionTrash(session: string): boolean {
125
+ return existsSync(join(wfDir(), `${session}.trash`));
126
+ }
127
+
128
+ it('fresh → 真实 runWorkflow 跑完归档到 <wfDir>/<sessionId>.done/(成功路径)', async () => {
129
+ await runCmd(
130
+ '/workflow hello-workflow --input goal=任务A --input name=alice --input lang=en',
131
+ { sessionId: SESSION_BETA },
132
+ );
133
+
134
+ // 跑完后状态被 archive,活动实例目录被改名 <sessionId>.done/
135
+ expect(sessionActive(SESSION_BETA)).toBe(false);
136
+ expect(sessionDone(SESSION_BETA)).toBe(true);
137
+ // 产物实际落盘到 cwd(不是状态目录里)
138
+ expect(existsSync(join(tmp, 'greetings/alice/hello.txt'))).toBe(true);
139
+ // 状态目录保留了 current.json / inputs.json / progress.md 等(供后续回溯)
140
+ const doneDir = join(wfDir(), `${SESSION_BETA}.done`);
141
+ expect(existsSync(join(doneDir, 'current.json'))).toBe(true);
142
+ });
143
+
144
+ it('未指定 sessionId → 引擎自动生成唯一 session(非 default),多 session 互不覆盖', async () => {
145
+ // 不传 sessionId:宿主未提供有意义 session(runtimeContext.sessionId 缺失),
146
+ // 引擎应自动命名一个唯一 session(YYYYMMDD-HHmmss-xxxxxx),而非固定 'default'
147
+ await runCmd(
148
+ '/workflow hello-workflow --input goal=任务A --input name=alice --input lang=en',
149
+ );
150
+ let doneEntries = readdirSync(wfDir()).filter((n) => n.endsWith('.done'));
151
+ expect(doneEntries.length).toBe(1);
152
+ const autoSession = doneEntries[0].slice(0, -'.done'.length);
153
+ expect(autoSession).not.toBe('default');
154
+ expect(/^\d{8}-\d{6}-[0-9a-f]{6}$/.test(autoSession)).toBe(true);
155
+ // 产物落盘
156
+ expect(existsSync(join(tmp, 'greetings/alice/hello.txt'))).toBe(true);
157
+
158
+ // 再跑一次(不同 goal):引擎自动生成第二个独立 session,各自归档互不覆盖
159
+ await runCmd(
160
+ '/workflow hello-workflow --input goal=任务X --input name=carol --input lang=en',
161
+ );
162
+ doneEntries = readdirSync(wfDir()).filter((n) => n.endsWith('.done'));
163
+ expect(doneEntries.length).toBe(2);
164
+ });
165
+
166
+ it('fresh + 同 goal 再跑 → completed(命中 .done/ 归档,第二次不写产物)', async () => {
167
+ await runCmd(
168
+ '/workflow hello-workflow --input goal=任务A --input name=alice --input lang=en',
169
+ { sessionId: SESSION_BETA },
170
+ );
171
+ expect(sessionDone(SESSION_BETA)).toBe(true);
172
+
173
+ const events = await runCmd(
174
+ '/workflow hello-workflow --input goal=任务A --input name=alice --input lang=en',
175
+ { sessionId: SESSION_BETA },
176
+ );
177
+
178
+ // 第二次是 completed:.done 归档不变(同一任务已完成,不重新归档)
179
+ expect(sessionActive(SESSION_BETA)).toBe(false);
180
+ expect(sessionDone(SESSION_BETA)).toBe(true);
181
+ // 产物保留(completed 不删)
182
+ expect(existsSync(join(tmp, 'greetings/alice/hello.txt'))).toBe(true);
183
+ // 事件序列表明已完成分支被命中
184
+ expect(events.some((e) => e.type === 'workflow_done')).toBe(true);
185
+ });
186
+
187
+ it('goal A 完成 → 改 goal B(同 session 内 new-task):B 产物覆盖,A 产物归档到 .trash/<stamp>/', async () => {
188
+ await runCmd(
189
+ '/workflow hello-workflow --input goal=任务A --input name=alice --input lang=en',
190
+ { sessionId: SESSION_BETA },
191
+ );
192
+ expect(sessionDone(SESSION_BETA)).toBe(true);
193
+ expect(existsSync(join(tmp, 'greetings/output.txt'))).toBe(true);
194
+
195
+ // 改 goal → hash 不同 → new-task(同 session 内,A 被归档后重跑 B)
196
+ await runCmd(
197
+ '/workflow hello-workflow --input goal=任务B --input name=bob --input lang=zh',
198
+ { sessionId: SESSION_BETA },
199
+ );
200
+
201
+ // 最终仍是 .done(B 成功)
202
+ expect(sessionActive(SESSION_BETA)).toBe(false);
203
+ expect(sessionDone(SESSION_BETA)).toBe(true);
204
+ // B 产物存在
205
+ expect(existsSync(join(tmp, 'greetings/bob/hello.txt'))).toBe(true);
206
+ // 声明产物(output.txt)被 goal B 重新生成(write_output step 又跑了一遍)
207
+ expect(existsSync(join(tmp, 'greetings/output.txt'))).toBe(true);
208
+ // A 的旧产物被归档到 .trash 里(goal A 的旧 output.txt)
209
+ const trashDir = join(wfDir(), `${SESSION_BETA}.trash`);
210
+ expect(sessionTrash(SESSION_BETA)).toBe(true);
211
+ const outputInTrash = flattenDir(trashDir).some((f) =>
212
+ f.endsWith('output.txt'),
213
+ );
214
+ expect(outputInTrash).toBe(true);
215
+ });
216
+
217
+ it('同 goal 两个 session → 各自独立完成(Phase 3 核心:多 session 并行隔离)', async () => {
218
+ // session alpha 跑 goal=任务A
219
+ await runCmd(
220
+ '/workflow hello-workflow --input goal=任务A --input name=alice --input lang=en',
221
+ { sessionId: SESSION_ALPHA },
222
+ );
223
+ // session beta 跑 goal=任务A(同 goal,不同 session)
224
+ await runCmd(
225
+ '/workflow hello-workflow --input goal=任务A --input name=alice --input lang=en',
226
+ { sessionId: SESSION_BETA },
227
+ );
228
+
229
+ // 两个 session 都有自己的 .done 归档,互不干扰
230
+ expect(sessionDone(SESSION_ALPHA)).toBe(true);
231
+ expect(sessionDone(SESSION_BETA)).toBe(true);
232
+ // 各自的 current.json 都在
233
+ expect(existsSync(join(wfDir(), `${SESSION_ALPHA}.done`, 'current.json'))).toBe(true);
234
+ expect(existsSync(join(wfDir(), `${SESSION_BETA}.done`, 'current.json'))).toBe(true);
235
+ // 产物(共享 cwd 下)都存在
236
+ expect(existsSync(join(tmp, 'greetings/alice/hello.txt'))).toBe(true);
237
+ expect(existsSync(join(tmp, 'greetings/output.txt'))).toBe(true);
238
+ });
239
+
240
+ it('不同 session 的 new-task 互不影响:A session 跑 goalA,B session 跑 goalB 时 A 完好', async () => {
241
+ // session alpha: goalA 完成
242
+ await runCmd(
243
+ '/workflow hello-workflow --input goal=任务A --input name=alice --input lang=en',
244
+ { sessionId: SESSION_ALPHA },
245
+ );
246
+ expect(sessionDone(SESSION_ALPHA)).toBe(true);
247
+ const alphaBefore = readFileSync(
248
+ join(wfDir(), `${SESSION_ALPHA}.done`, 'current.json'),
249
+ 'utf8',
250
+ );
251
+
252
+ // session beta: 跑 goalB(new-task,不应碰 alpha 的任何东西)
253
+ await runCmd(
254
+ '/workflow hello-workflow --input goal=任务B --input name=bob --input lang=zh',
255
+ { sessionId: SESSION_BETA },
256
+ );
257
+
258
+ // alpha 的 .done 完好无损
259
+ expect(sessionDone(SESSION_ALPHA)).toBe(true);
260
+ const alphaAfter = readFileSync(
261
+ join(wfDir(), `${SESSION_ALPHA}.done`, 'current.json'),
262
+ 'utf8',
263
+ );
264
+ expect(alphaAfter).toBe(alphaBefore);
265
+ // alpha 的产物归档(.trash)也没被 beta session 碰
266
+ // beta 自己的 .done 也有了
267
+ expect(sessionDone(SESSION_BETA)).toBe(true);
268
+ });
269
+
270
+ it('旧布局状态文件(wfDir/current.json)→ 新引擎正确识别 + 处理(兼容)', async () => {
271
+ // 手写旧布局状态(status=running,未完成)
272
+ mkdirSync(wfDir(), { recursive: true });
273
+ writeFileSync(
274
+ join(wfDir(), 'current.json'),
275
+ JSON.stringify({
276
+ name: 'hello-workflow',
277
+ taskId: '1234567890abcdef',
278
+ status: 'running',
279
+ startedAt: '2026-01-01T00:00:00.000Z',
280
+ artifacts: [],
281
+ defFingerprint: 'legacy',
282
+ }),
283
+ );
284
+
285
+ // 跑真实工作流:新引擎应当从旧布局读到状态,根据 hash 走对应分支
286
+ const events = await runCmd(
287
+ '/workflow hello-workflow --input goal=任务C --input name=carol --input lang=en',
288
+ { sessionId: SESSION_BETA },
289
+ );
290
+
291
+ // 核心断言:新引擎成功处理(无 error),最终有归档或活动实例
292
+ expect(events.filter((e) => e.type === 'error').length).toBe(0);
293
+ expect(sessionDone(SESSION_BETA) || sessionActive(SESSION_BETA)).toBe(true);
294
+ // carol 产物落盘
295
+ expect(existsSync(join(tmp, 'greetings/carol/hello.txt'))).toBe(true);
296
+ });
297
+ });
298
+
299
+ /** 递归列出目录下所有文件路径 */
300
+ function flattenDir(dir: string): string[] {
301
+ const out: string[] = [];
302
+ const walk = (d: string) => {
303
+ for (const e of readdirSync(d)) {
304
+ const p = join(d, e);
305
+ const isDir = existsSync(p) && (() => {
306
+ try { readdirSync(p); return true; } catch { return false; }
307
+ })();
308
+ if (isDir) walk(p);
309
+ else out.push(p);
310
+ }
311
+ };
312
+ walk(dir);
313
+ return out;
314
+ }
@@ -20,6 +20,7 @@ import { tmpdir } from 'node:os';
20
20
  import { join } from 'node:path';
21
21
 
22
22
  import { _resetWorkingDir } from '../../../src/bootstrap/workingDir.js';
23
+ import { createAgentRuntimeContext } from '../../../src/plugin-sdk.js';
23
24
  import type { LoopEvent, Provider } from '../../../src/types.js';
24
25
  import { evalExpr, interpolate } from '../src/expressions.js';
25
26
  import { runWorkflow } from '../src/runner.js';
@@ -80,7 +81,14 @@ describe('E3/E2/E1 引擎层(runWorkflow 集成)', () => {
80
81
  initWorkingDir: () => tmp,
81
82
  _resetWorkingDir: () => {},
82
83
  }));
83
- ctx = { provider: fakeProvider, history: [], cwd: tmp, tools: [] };
84
+ ctx = {
85
+ provider: fakeProvider,
86
+ history: [],
87
+ cwd: tmp,
88
+ tools: [],
89
+ // 显式 sessionId:resume(两次 runWorkflow 同 session)场景可预测
90
+ runtimeContext: createAgentRuntimeContext({ surface: 'workflow', profile: 'main', cwd: tmp, sessionId: 'test-session', owner: 'local-owner', interactiveAsk: false }),
91
+ };
84
92
  });
85
93
 
86
94
  afterEach(() => {
@@ -270,6 +270,48 @@ describe('workflows/expressions::fileExists', () => {
270
270
  expect(evalExpr('fileExists(inputs.name + ".txt")', vars)).toBe(true);
271
271
  expect(evalExpr('fileExists("videos/" + inputs.name + ".txt")', vars)).toBe(false);
272
272
  });
273
+
274
+ // 会话进入 session worktree 后,写文件的工具跟着 worktree 走(loop.ts 按
275
+ // sessionState.activeWorktree 重定向 cwd)。done_when / when / over 全靠
276
+ // fileExists,若它仍锚原 workspace,就会在空目录里找产物 —— 恒 false,
277
+ // 工作流被判失败并反复重试,而产物其实好好躺在 worktree 里。
278
+ it('resolveCwd 让 fileExists 跟随 session worktree 而非原 workspace', () => {
279
+ const worktree = mkdtempSync(join(tmpdir(), 'wf-expr-wt-'));
280
+ try {
281
+ // 产物只存在于 worktree,workspace 是空的。
282
+ writeFileSync(join(worktree, 'out.md'), 'artifact');
283
+
284
+ const vars = new VarStack();
285
+ // 未设 resolveCwd:锚 workspace(旧行为)→ 找不到。
286
+ expect(evalExpr('fileExists("out.md")', vars)).toBe(false);
287
+
288
+ // 设了 resolveCwd:锚 worktree → 命中。
289
+ vars.resolveCwd = () => worktree;
290
+ expect(evalExpr('fileExists("out.md")', vars)).toBe(true);
291
+
292
+ // 解析器是函数而非快照:运行途中退出 worktree 后立刻跟着变回去。
293
+ let active: string | undefined = worktree;
294
+ vars.resolveCwd = () => active ?? tmp;
295
+ expect(evalExpr('fileExists("out.md")', vars)).toBe(true);
296
+ active = undefined;
297
+ expect(evalExpr('fileExists("out.md")', vars)).toBe(false);
298
+ } finally {
299
+ rmSync(worktree, { recursive: true, force: true });
300
+ }
301
+ });
302
+
303
+ it('resolveCwd 下越界守卫仍以该 cwd 为界', () => {
304
+ const worktree = mkdtempSync(join(tmpdir(), 'wf-expr-wt2-'));
305
+ try {
306
+ writeFileSync(join(tmp, 'outside.txt'), 'x');
307
+ const vars = new VarStack();
308
+ vars.resolveCwd = () => worktree;
309
+ // 换锚点后,原 workspace 的文件反而成了「界外」,fail-closed 仍生效。
310
+ expect(evalExpr('fileExists("../outside.txt")', vars)).toBe(false);
311
+ } finally {
312
+ rmSync(worktree, { recursive: true, force: true });
313
+ }
314
+ });
273
315
  });
274
316
 
275
317
  describe('workflows/expressions::readJson', () => {
@@ -52,7 +52,7 @@ describe('workflows/illustrate.yaml(网上抓图配图)', () => {
52
52
  expect(inputs.find((i) => i.name === 'topic')?.greedy).toBe(true);
53
53
  expect(inputs.find((i) => i.name === 'count')?.default).toBe(3);
54
54
  expect(inputs.find((i) => i.name === 'min_candidates')?.default).toBe(6);
55
- expect(def.artifacts).toContain('./build/illustrations.json');
55
+ expect(def.artifacts).toContain('out/illustrate/${session}/build/illustrations.json');
56
56
  });
57
57
 
58
58
  it('关键步齐全且顺序正确:三家图搜 → 守门 → 下载 → 打分 → 选优 → 报告', async () => {
@@ -152,7 +152,7 @@ describe('workflows/illustrate.yaml(网上抓图配图)', () => {
152
152
  expect(a).toContain('illustrations.json');
153
153
  expect(String(sel?.done_when)).toContain('illustrations.json');
154
154
  for (const t of ['Glob', 'Read', 'Write']) expect(sel?.allowed_tools).toContain(t);
155
- expect(sel?.artifacts).toContain('./build/illustrations.json');
155
+ expect(sel?.artifacts).toContain('out/illustrate/${session}/build/illustrations.json');
156
156
  });
157
157
 
158
158
  it('用到本轮新增通用件:parseJson 表达式 + Download 工具', async () => {
@@ -61,6 +61,25 @@ describe('parseWorkflowArgs / tokenizeArgs 边界行为冻结', () => {
61
61
  expect(r.inputs).toEqual({ topic: 'AI 教程' });
62
62
  });
63
63
 
64
+ test('引号在 = 后的 KV:value 含空格也完整捕获(回归:曾按空白硬切+引号残留)', () => {
65
+ // 用户 shell 习惯 `--input topic="带空格 的值"` —— 引号在 token 内部(= 之后),
66
+ // 曾被 tokenizeArgs 按空白切分 → 值截断 + 引号残留 + 散落 positional。
67
+ const r = parseWorkflowArgs(
68
+ 'hello --input topic="比特币量化 长线 短线策略" --input depth=standard',
69
+ );
70
+ expect(r.inputs).toEqual({
71
+ topic: '比特币量化 长线 短线策略',
72
+ depth: 'standard',
73
+ });
74
+ expect(r.positional).toEqual([]);
75
+ });
76
+
77
+ test('单引号在 = 后同理支持', () => {
78
+ const r = parseWorkflowArgs("hello --input topic='AI 教程 入门'");
79
+ expect(r.inputs).toEqual({ topic: 'AI 教程 入门' });
80
+ expect(r.positional).toEqual([]);
81
+ });
82
+
64
83
  test('未知 -- flag 跳过其后一个 token(保守解析)', () => {
65
84
  const r = parseWorkflowArgs('hello --foo bar alice');
66
85
  // --foo bar 被吃掉,alice 进 positional
@@ -93,11 +93,11 @@ describe('workflows/ppt.yaml(统一素材摄取·素材驱动)', () => {
93
93
  expect(String(s.title)).toContain('/22');
94
94
  }
95
95
 
96
- // 收尾 echo;artifacts 只列最终交付物、不在被 rm 的 ./build 下
96
+ // 收尾 echo;artifacts 只列最终交付物、不在被 rm 的 out/ppt/${session}/build 下
97
97
  expect(def.steps.find((s) => s.id === 'final_report')?.type).toBe('echo');
98
98
  const declared = def.artifacts ?? [];
99
- expect(declared).toContain('./out/deck.pptx');
100
- for (const a of declared) expect(a.startsWith('./build')).toBe(false);
99
+ expect(declared).toContain('out/ppt/${session}/deck.pptx');
100
+ for (const a of declared) expect(a.startsWith('out/ppt/${session}/build')).toBe(false);
101
101
  });
102
102
 
103
103
  it('stage0_clarify:样式推荐按配色感知、覆盖全部 13 个 VI(不再只露 6 个)', async () => {
@@ -205,7 +205,7 @@ describe('workflows/ppt.yaml(统一素材摄取·素材驱动)', () => {
205
205
 
206
206
  const repair = def.steps.find((s) => s.id === 'stage_draft_coverage_repair');
207
207
  expect(String(repair?.when)).toContain('draft_coverage_report.json');
208
- expect(String(repair?.when)).toContain("readJson('./build/draft_coverage_report.json', 'ok') == false");
208
+ expect(String(repair?.when)).toContain("readJson('out/ppt/${session}/build/draft_coverage_report.json', 'ok') == false");
209
209
  // 用户点名最高优先级问题:fast/best 都要跑,when 不能限死 quality=='best'。
210
210
  expect(String(repair?.when)).not.toContain("quality == 'best'");
211
211
  expect(String(repair?.agent)).toContain('draft_coverage_report.md');
@@ -482,7 +482,7 @@ describe('workflows/ppt.yaml(统一素材摄取·素材驱动)', () => {
482
482
  // ① 摄取后跑确定性规划(在 design 之前,data 已齐)
483
483
  const ids = def.steps.map((s) => s.id);
484
484
  const am = def.steps.find((s) => s.id === 'stage_assemble_material');
485
- expect(String(am?.args?.command)).toContain('plan_source_layout.py --build ./build');
485
+ expect(String(am?.args?.command)).toContain('plan_source_layout.py --build out/ppt/${session}/build');
486
486
  expect(ids.indexOf('stage_assemble_material')).toBeLessThan(ids.indexOf('stage_design_doc'));
487
487
 
488
488
  // ② 设计步「读规划、照它执行」——逐图心算被收成确定性
@@ -516,7 +516,7 @@ describe('workflows/ppt.yaml(统一素材摄取·素材驱动)', () => {
516
516
  expect(ga).toContain('--ensure-page-backgrounds');
517
517
  expect(ga).toContain('image_prompt_rewrite.py');
518
518
  expect(ga).toContain('image_plan_lint.py');
519
- expect(ga).toContain('touch ./build/img_route_ok');
519
+ expect(ga).toContain('touch out/ppt/${session}/build/img_route_ok');
520
520
  // when 放宽:img_plan 存在 或 规划里有待增强图 —— 表达式可被引擎求值(不抛、不畸形)
521
521
  const whenExpr = String(gate?.when);
522
522
  expect(whenExpr).toContain('enhance_entries');
@@ -664,8 +664,8 @@ describe('workflows/ppt.yaml(统一素材摄取·素材驱动)', () => {
664
664
  expect(gen?.agent).toBeUndefined();
665
665
  expect(gen?.tool).toBe('GenerateImage');
666
666
  expect(gen?.tool).not.toBe('Bash');
667
- expect(String(gen?.args?.plan)).toBe('./build/img_plan.json');
668
- expect(String(gen?.args?.assets)).toBe('./build/assets');
667
+ expect(String(gen?.args?.plan)).toBe('out/ppt/${session}/build/img_plan.json');
668
+ expect(String(gen?.args?.assets)).toBe('out/ppt/${session}/build/assets');
669
669
  expect(String(gen?.args?.timeout_ms)).toBe('300000');
670
670
  expect(String(gen?.args?.wall_budget_ms)).toBe('3600000');
671
671
  expect(gen?.onError).toBe('continue');
@@ -673,7 +673,7 @@ describe('workflows/ppt.yaml(统一素材摄取·素材驱动)', () => {
673
673
  const quality = def.steps.find((s) => s.id === 'stage_image_quality_gate');
674
674
  expect(quality?.tool).toBe('GenerateImage');
675
675
  expect(String(quality?.when)).toContain('image_generation_report.json');
676
- expect(String(quality?.args?.retry_degraded_report)).toBe('./build/image_generation_report.json');
676
+ expect(String(quality?.args?.retry_degraded_report)).toBe('out/ppt/${session}/build/image_generation_report.json');
677
677
  expect(String(quality?.title)).toContain('fast一轮');
678
678
  expect(quality?.args?.max_rounds).toBe(1);
679
679
 
@@ -705,7 +705,7 @@ describe('workflows/ppt.yaml(统一素材摄取·素材驱动)', () => {
705
705
  const imageRetry = def.steps.find((s) => s.id === 'stage_generated_image_retry');
706
706
  expect(imageRetry?.tool).toBe('GenerateImage');
707
707
  expect(String(imageRetry?.when)).toBe('false');
708
- expect(String(imageRetry?.args?.retry_degraded_report)).toBe('./build/generated_image_semantic_retry_report.json');
708
+ expect(String(imageRetry?.args?.retry_degraded_report)).toBe('out/ppt/${session}/build/generated_image_semantic_retry_report.json');
709
709
  expect(String(imageRetry?.args?.max_rounds)).toBe('3');
710
710
 
711
711
  const visualGate = def.steps.find((s) => s.id === 'stage_visual_quality_gate');
@@ -753,7 +753,7 @@ describe('workflows/ppt.yaml(统一素材摄取·素材驱动)', () => {
753
753
  // 完全同参双跑、且视觉门达轮数已带残留放行)——style 检查唯一跑点在
754
754
  // stage_visual_quality_gate(fast/best 统一 2 轮修复),导出只验哨兵。
755
755
  expect(String(exp?.args?.command)).not.toContain('deck_visual_style_check.py');
756
- expect(String(exp?.args?.command)).toContain('test -f ./build/visual_quality_ok');
756
+ expect(String(exp?.args?.command)).toContain('test -f out/ppt/${session}/build/visual_quality_ok');
757
757
  expect(exp?.done_when).toBeUndefined();
758
758
 
759
759
  const design = def.steps.find((s) => s.id === 'stage_design_doc');
@@ -829,7 +829,7 @@ describe('workflows/ppt.yaml(统一素材摄取·素材驱动)', () => {
829
829
  expect(String(probe?.args?.command)).toContain('build_probe.json');
830
830
  expect(String(probe?.args?.command)).toContain('build_complete');
831
831
  const batchStep = dos.find((s) => s.id === 'build_batch');
832
- expect(String(batchStep?.when)).toContain("!fileExists('./build/build_complete')");
832
+ expect(String(batchStep?.when)).toContain("!fileExists('out/ppt/${session}/build/build_complete')");
833
833
  expect(batchStep?.maxTurns).toBeLessThanOrEqual(24);
834
834
  expect(batchStep?.max_retries).toBe(1);
835
835
  const batch = String(batchStep?.agent);
@@ -840,7 +840,7 @@ describe('workflows/ppt.yaml(统一素材摄取·素材驱动)', () => {
840
840
  expect(batch).toContain('次顶层'); // 插图图层:文本之下、背景之上
841
841
  expect(batch).toContain('遮挡');
842
842
  const done = dos.find((s) => s.id === 'build_done');
843
- expect(String(done?.when)).toContain("fileExists('./build/build_complete')");
843
+ expect(String(done?.when)).toContain("fileExists('out/ppt/${session}/build/build_complete')");
844
844
  });
845
845
 
846
846
  it('fast 链路清理旧运行哨兵,且说明/报告顺序为先生图后构建', async () => {
@@ -866,12 +866,12 @@ describe('workflows/ppt.yaml(统一素材摄取·素材驱动)', () => {
866
866
  ]) {
867
867
  expect(cmd).toContain(marker);
868
868
  }
869
- expect(cmd).toContain('rm -rf ./build/export ./build/generated-image-reviews');
869
+ expect(cmd).toContain('rm -rf out/ppt/${session}/build/export out/ppt/${session}/build/generated-image-reviews');
870
870
 
871
871
  const cleanup = def.steps.find((s) => s.id === 'cleanup_intermediates');
872
872
  const cleanupCmd = String(cleanup?.args?.command);
873
873
  expect(cleanupCmd).toContain('inputs.debug_keep_build');
874
- expect(cleanupCmd).toContain('rm -rf ./build');
874
+ expect(cleanupCmd).toContain('rm -rf out/ppt/${session}/build');
875
875
  expect(cleanupCmd).toContain('debug_keep_build=true');
876
876
  expect(cleanupCmd).not.toContain('[TEMP]');
877
877
  expect(cleanupCmd).not.toContain('上线前');
@@ -128,11 +128,12 @@ describe('ralph-loop.yaml 端到端(while + fresh_context + 哨兵协议)',
128
128
  expect(p2).not.toContain('我做了规划');
129
129
 
130
130
  // 教训档案恰好一条、不重复(iter1 无 learning 标签时 extract → '' 不得重复累积)
131
- // 注意成功后状态目录被归档为 ralph-loop.done
131
+ // learnings 是 workflow 级共享存档,写在 ralph-loop/ 下(Phase 2 父目录不参与归档改名;
132
+ // 状态实例归档为 ralph-loop/<taskId>.done/)
132
133
  const learningsPath = join(
133
134
  tmp,
134
135
  '.minimal-agent-workflow',
135
- 'ralph-loop.done',
136
+ 'ralph-loop',
136
137
  'learnings.md',
137
138
  );
138
139
  expect(existsSync(learningsPath)).toBe(true);