minimal-agent 0.1.9 → 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 (43) hide show
  1. package/README.md +405 -122
  2. package/dist/main.js +117 -738
  3. package/package.json +4 -2
  4. package/plugins/HOW-TO-WRITE-A-PLUGIN.md +186 -0
  5. package/plugins/ralph-wiggum/commands/ralph-loop.md +6 -16
  6. package/plugins/ralph-wiggum/plugin.ts +275 -0
  7. package/plugins/ralph-wiggum/src/goalState.ts +310 -0
  8. package/plugins/ralph-wiggum/src/sentinels.ts +24 -0
  9. package/plugins/ralph-wiggum/src/stopHookRunner.ts +136 -0
  10. package/plugins/ralph-wiggum/src/verificationGate.ts +252 -0
  11. package/plugins/ralph-wiggum/test/goalState.test.ts +410 -0
  12. package/plugins/ralph-wiggum/test/verificationGate.test.ts +122 -0
  13. package/plugins/workflow-runner/.claude-plugin/plugin.json +5 -0
  14. package/plugins/workflow-runner/commands/workflow.md +15 -0
  15. package/plugins/workflow-runner/commands/workflows.md +8 -0
  16. package/plugins/workflow-runner/plugin.ts +42 -0
  17. package/plugins/workflow-runner/src/expressions.ts +371 -0
  18. package/plugins/workflow-runner/src/index.ts +194 -0
  19. package/plugins/workflow-runner/src/loader.ts +193 -0
  20. package/plugins/workflow-runner/src/runner.ts +313 -0
  21. package/plugins/workflow-runner/src/stepExecutors/assert.ts +30 -0
  22. package/plugins/workflow-runner/src/stepExecutors/llm.ts +54 -0
  23. package/plugins/workflow-runner/src/stepExecutors/skill.ts +115 -0
  24. package/plugins/workflow-runner/src/stepExecutors/tool.ts +41 -0
  25. package/plugins/workflow-runner/src/types.ts +183 -0
  26. package/plugins/workflow-runner/src/workflowState.ts +65 -0
  27. package/plugins/workflow-runner/test/cli.e2e.test.ts +114 -0
  28. package/plugins/workflow-runner/test/e2e.test.ts +268 -0
  29. package/plugins/workflow-runner/test/expressions.test.ts +140 -0
  30. package/plugins/workflow-runner/test/fixtures/cli-e2e.yaml +27 -0
  31. package/plugins/workflow-runner/test/fixtures/hello-workflow.yaml +49 -0
  32. package/plugins/workflow-runner/test/graceful.test.ts +139 -0
  33. package/plugins/workflow-runner/test/loader.test.ts +216 -0
  34. package/plugins/workflow-runner/test/pluginRunner.isolation.test.ts +230 -0
  35. package/plugins/workflow-runner/test/runner.test.ts +511 -0
  36. package/skills/image-gen-openrouter/SKILL.md +121 -0
  37. package/skills/subtitle-srt/SKILL.md +134 -0
  38. package/skills/tts-zh/SKILL.md +137 -0
  39. package/skills/video-compose/SKILL.md +139 -0
  40. package/workflows/book-review-short.yaml +99 -0
  41. package/workflows/e2e-write-greet.yaml +27 -0
  42. package/workflows/schema.json +74 -0
  43. package/workflows/youtube-shorts.yaml +171 -0
@@ -0,0 +1,252 @@
1
+ /**
2
+ * ============================================================
3
+ * src/plugins/verificationGate.ts —— ralph-loop 的"完成验证门"
4
+ * ------------------------------------------------------------
5
+ * Agent 输出 <promise>DONE</promise> 哨兵后,pluginRunner 跑这里的
6
+ * checks。全部通过才真正退出循环;任一未通过则回 BUILD 继续干。
7
+ *
8
+ * 支持四种 check:
9
+ * - shell:<cmd> 跨平台 spawn(Windows 走 cmd /c, 其余 bash -c)
10
+ * - file_exists:<path> fs.existsSync
11
+ * - file_contains:<file>:<re> 正则匹配文件内容
12
+ * - test_count:<min> 跑 `bun test`,从 stdout 解析 "N pass"
13
+ *
14
+ * 工作目录隔离:所有相对路径以 getWorkingDir() 为锚,子进程 cwd 也用它,
15
+ * 确保并行 session 不会互相串台。
16
+ * ============================================================
17
+ */
18
+
19
+ import { existsSync, readFileSync } from 'node:fs';
20
+ import { spawn } from 'node:child_process';
21
+
22
+ export interface Check {
23
+ type: 'shell' | 'file_contains' | 'file_exists' | 'test_count';
24
+ command?: string;
25
+ file?: string;
26
+ pattern?: string;
27
+ minCount?: number;
28
+ timeout?: number;
29
+ }
30
+
31
+ export interface CheckResult {
32
+ check: Check;
33
+ passed: boolean;
34
+ output: string;
35
+ }
36
+
37
+ export interface VerifyResult {
38
+ passed: boolean;
39
+ details: CheckResult[];
40
+ summary: string;
41
+ }
42
+
43
+ function parseVerifyArg(arg: string): Check | null {
44
+ const colonIdx = arg.indexOf(':');
45
+ if (colonIdx < 0) return null;
46
+
47
+ const type = arg.slice(0, colonIdx).trim().toLowerCase();
48
+ const value = arg.slice(colonIdx + 1).trim();
49
+
50
+ switch (type) {
51
+ case 'shell':
52
+ return { type: 'shell', command: value, timeout: 30_000 };
53
+ case 'file_exists':
54
+ return { type: 'file_exists', file: value };
55
+ case 'file_contains': {
56
+ const sep = value.indexOf(':');
57
+ if (sep < 0) return null;
58
+ return {
59
+ type: 'file_contains',
60
+ file: value.slice(0, sep),
61
+ pattern: value.slice(sep + 1),
62
+ };
63
+ }
64
+ case 'test_count': {
65
+ const count = parseInt(value, 10);
66
+ if (isNaN(count) || count < 0) return null;
67
+ return { type: 'test_count', minCount: count };
68
+ }
69
+ default:
70
+ return null;
71
+ }
72
+ }
73
+
74
+ export function parseVerifyArgs(args: string): Check[] {
75
+ const checks: Check[] = [];
76
+ const regex = /--verify\s+("[^"]*"|\S+)/gi;
77
+ let match;
78
+
79
+ while ((match = regex.exec(args)) !== null) {
80
+ const raw = match[1].replace(/^"|"$/g, '');
81
+ const check = parseVerifyArg(raw);
82
+ if (check) checks.push(check);
83
+ }
84
+
85
+ return checks;
86
+ }
87
+
88
+ interface ShellRunResult {
89
+ exitCode: number | null;
90
+ stdout: string;
91
+ stderr: string;
92
+ errored: boolean;
93
+ }
94
+
95
+ function runShell(command: string, timeout: number): Promise<ShellRunResult> {
96
+ return new Promise((resolve) => {
97
+ const isWin = process.platform === 'win32';
98
+ // 不显式传 cwd —— 继承 process.cwd(),main.tsx 已 chdir 到 workingDir,
99
+ // 而测试里直接用 process.cwd(),避免依赖 mock 的 getWorkingDir 造成串台
100
+ const child = isWin
101
+ ? spawn('cmd', ['/c', command], { timeout, env: process.env })
102
+ : spawn('bash', ['-c', command], { timeout, env: process.env });
103
+
104
+ let stdout = '';
105
+ let stderr = '';
106
+
107
+ child.stdout.on('data', (d: Buffer) => {
108
+ stdout += d.toString();
109
+ });
110
+ child.stderr.on('data', (d: Buffer) => {
111
+ stderr += d.toString();
112
+ });
113
+
114
+ child.on('error', () => {
115
+ resolve({ exitCode: null, stdout, stderr, errored: true });
116
+ });
117
+
118
+ child.on('close', (code) => {
119
+ resolve({ exitCode: code, stdout, stderr, errored: false });
120
+ });
121
+ });
122
+ }
123
+
124
+ export async function verifyShell(command: string, timeout: number): Promise<CheckResult> {
125
+ const r = await runShell(command, timeout);
126
+ if (r.errored) {
127
+ return {
128
+ check: { type: 'shell', command },
129
+ passed: false,
130
+ output: `执行失败`,
131
+ };
132
+ }
133
+ return {
134
+ check: { type: 'shell', command },
135
+ passed: r.exitCode === 0,
136
+ output: r.stdout.trim() || r.stderr.trim() || `exit code ${r.exitCode}`,
137
+ };
138
+ }
139
+
140
+ export function verifyFileExists(file: string): CheckResult {
141
+ const exists = existsSync(file);
142
+ return {
143
+ check: { type: 'file_exists', file },
144
+ passed: exists,
145
+ output: exists ? '文件存在' : `文件不存在: ${file}`,
146
+ };
147
+ }
148
+
149
+ export function verifyFileContains(file: string, pattern: string): CheckResult {
150
+ try {
151
+ const content = readFileSync(file, 'utf8');
152
+ const regex = new RegExp(pattern);
153
+ const matched = regex.test(content);
154
+
155
+ return {
156
+ check: { type: 'file_contains', file, pattern },
157
+ passed: matched,
158
+ output: matched ? `文件包含 "${pattern}"` : `文件不包含 "${pattern}"`,
159
+ };
160
+ } catch {
161
+ return {
162
+ check: { type: 'file_contains', file, pattern },
163
+ passed: false,
164
+ output: `无法读取文件: ${file}`,
165
+ };
166
+ }
167
+ }
168
+
169
+ async function verifyTestCount(minCount: number): Promise<CheckResult> {
170
+ const r = await runShell(`bun test`, 60_000);
171
+ // bun test 把进度打在 stderr,最终统计也常在 stderr,所以合并解析
172
+ const combined = `${r.stdout}\n${r.stderr}`;
173
+ if (r.errored) {
174
+ return {
175
+ check: { type: 'test_count', minCount },
176
+ passed: false,
177
+ output: '无法执行 bun test',
178
+ };
179
+ }
180
+
181
+ const matches = [...combined.matchAll(/(\d+)\s+pass/gi)];
182
+ const count = matches.length > 0 ? parseInt(matches[matches.length - 1][1], 10) : 0;
183
+ const passed = count >= minCount;
184
+
185
+ return {
186
+ check: { type: 'test_count', minCount },
187
+ passed,
188
+ output: `${count} pass (需要 >=${minCount})`,
189
+ };
190
+ }
191
+
192
+ export async function runVerification(checks: Check[]): Promise<VerifyResult> {
193
+ if (checks.length === 0) {
194
+ return { passed: true, details: [], summary: '无验证项' };
195
+ }
196
+
197
+ const results: CheckResult[] = [];
198
+
199
+ for (const check of checks) {
200
+ let result: CheckResult;
201
+
202
+ switch (check.type) {
203
+ case 'shell':
204
+ result = await verifyShell(check.command ?? '', check.timeout ?? 30_000);
205
+ break;
206
+ case 'file_exists':
207
+ result = verifyFileExists(check.file ?? '');
208
+ break;
209
+ case 'file_contains':
210
+ result = verifyFileContains(check.file ?? '', check.pattern ?? '');
211
+ break;
212
+ case 'test_count':
213
+ result = await verifyTestCount(check.minCount ?? 0);
214
+ break;
215
+ default:
216
+ result = {
217
+ check,
218
+ passed: false,
219
+ output: `未知验证类型: ${(check as { type: string }).type}`,
220
+ };
221
+ }
222
+
223
+ results.push(result);
224
+ }
225
+
226
+ const allPassed = results.every((r) => r.passed);
227
+ const failedNames = results.filter((r) => !r.passed).map((r) => formatCheckName(r.check));
228
+
229
+ let summary: string;
230
+ if (allPassed) {
231
+ summary = `✅ 全部通过 (${results.length}/${results.length})`;
232
+ } else {
233
+ summary = `❌ 验证未通过: ${failedNames.join(', ')}`;
234
+ }
235
+
236
+ return { passed: allPassed, details: results, summary };
237
+ }
238
+
239
+ function formatCheckName(check: Check): string {
240
+ switch (check.type) {
241
+ case 'shell':
242
+ return `shell(${(check.command ?? '').slice(0, 40)})`;
243
+ case 'file_exists':
244
+ return `exists(${check.file})`;
245
+ case 'file_contains':
246
+ return `contains(${check.file}:${check.pattern})`;
247
+ case 'test_count':
248
+ return `tests(>=${check.minCount})`;
249
+ default:
250
+ return check.type;
251
+ }
252
+ }
@@ -0,0 +1,410 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { mkdirSync, rmSync, existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+ import { GoalState, Phase } from '../src/goalState.ts';
6
+
7
+ function createTempDir(): string {
8
+ const dir = join(tmpdir(), `goalstate-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
9
+ mkdirSync(dir, { recursive: true });
10
+ return dir;
11
+ }
12
+
13
+ function cleanup(dir: string): void {
14
+ try {
15
+ rmSync(dir, { recursive: true });
16
+ } catch {
17
+ }
18
+ }
19
+
20
+ describe('goalState', () => {
21
+ describe('init', () => {
22
+ it('创建所有状态文件', async () => {
23
+ const tmpDir = createTempDir();
24
+ try {
25
+ const gs = new GoalState(tmpDir);
26
+ await gs.init('Build a REST API', []);
27
+
28
+ expect(existsSync(join(tmpDir, '.minimal-agent', 'goal.md'))).toBe(true);
29
+ expect(existsSync(join(tmpDir, '.minimal-agent', 'completion.md'))).toBe(true);
30
+ expect(existsSync(join(tmpDir, '.minimal-agent', 'phase.md'))).toBe(true);
31
+ expect(existsSync(join(tmpDir, '.minimal-agent', 'progress.md'))).toBe(true);
32
+ expect(existsSync(join(tmpDir, '.minimal-agent', 'learnings.md'))).toBe(true);
33
+ expect(existsSync(join(tmpDir, '.minimal-agent', 'decisions.md'))).toBe(true);
34
+ } finally {
35
+ cleanup(tmpDir);
36
+ }
37
+ });
38
+
39
+ it('goal.md 包含目标文本', async () => {
40
+ const tmpDir = createTempDir();
41
+ try {
42
+ const gs = new GoalState(tmpDir);
43
+ await gs.init('Fix auth bug in login module', []);
44
+ expect(gs.goal).toContain('Fix auth bug');
45
+ } finally {
46
+ cleanup(tmpDir);
47
+ }
48
+ });
49
+
50
+ it('初始阶段为 PLAN', async () => {
51
+ const tmpDir = createTempDir();
52
+ try {
53
+ const gs = new GoalState(tmpDir);
54
+ await gs.init('Test goal', []);
55
+ expect(gs.currentPhase).toBe(Phase.PLAN);
56
+ } finally {
57
+ cleanup(tmpDir);
58
+ }
59
+ });
60
+
61
+ it('重复 init 不覆盖已有文件', async () => {
62
+ const tmpDir = createTempDir();
63
+ try {
64
+ const gs = new GoalState(tmpDir);
65
+ await gs.init('First goal', []);
66
+ await gs.init('Second goal', []);
67
+ expect(gs.goal).toContain('First goal');
68
+ } finally {
69
+ cleanup(tmpDir);
70
+ }
71
+ });
72
+ });
73
+
74
+ describe('phase management', () => {
75
+ it('PLAN → BUILD 合法切换', async () => {
76
+ const tmpDir = createTempDir();
77
+ try {
78
+ const gs = new GoalState(tmpDir);
79
+ await gs.init('Test', []);
80
+ await gs.setPhase(Phase.BUILD, 'plan_complete');
81
+ expect(gs.currentPhase).toBe(Phase.BUILD);
82
+ } finally {
83
+ cleanup(tmpDir);
84
+ }
85
+ });
86
+
87
+ it('setPhase 的 reason 是日志文本,不参与 FSM 校验', async () => {
88
+ const tmpDir = createTempDir();
89
+ try {
90
+ const gs = new GoalState(tmpDir);
91
+ await gs.init('Test', []);
92
+ // 用任意中文 reason,合法目标阶段仍应成功
93
+ await gs.setPhase(Phase.BUILD, '随便写的日志文本,不该被当成事件名');
94
+ expect(gs.currentPhase).toBe(Phase.BUILD);
95
+ } finally {
96
+ cleanup(tmpDir);
97
+ }
98
+ });
99
+
100
+ it('非法阶段切换抛出错误(基于目标阶段,与 reason 无关)', async () => {
101
+ const tmpDir = createTempDir();
102
+ try {
103
+ const gs = new GoalState(tmpDir);
104
+ await gs.init('Test', []);
105
+ // PLAN → VERIFY 在 PHASE_TRANSITIONS[PLAN] 的 values 中不存在
106
+ await expect(
107
+ gs.setPhase(Phase.VERIFY, '哪怕写一个看似合法的英文 event 名也不行'),
108
+ ).rejects.toThrow();
109
+ } finally {
110
+ cleanup(tmpDir);
111
+ }
112
+ });
113
+
114
+ it('forceSetPhase 允许任意切换', async () => {
115
+ const tmpDir = createTempDir();
116
+ try {
117
+ const gs = new GoalState(tmpDir);
118
+ await gs.init('Test', []);
119
+ await gs.forceSetPhase(Phase.HEAL, 'manual override');
120
+ expect(gs.currentPhase).toBe(Phase.HEAL);
121
+ } finally {
122
+ cleanup(tmpDir);
123
+ }
124
+ });
125
+ });
126
+
127
+ describe('progress log', () => {
128
+ it('追加进度日志', async () => {
129
+ const tmpDir = createTempDir();
130
+ try {
131
+ const gs = new GoalState(tmpDir);
132
+ await gs.init('Test', []);
133
+ await gs.appendProgress('Iteration 1: started');
134
+ await gs.appendProgress('Iteration 2: done');
135
+
136
+ const tail = gs.tailProgress(10);
137
+ expect(tail).toContain('Iteration 1: started');
138
+ expect(tail).toContain('Iteration 2: done');
139
+ } finally {
140
+ cleanup(tmpDir);
141
+ }
142
+ });
143
+
144
+ it('tailProgress 只返回最近 N 行', async () => {
145
+ const tmpDir = createTempDir();
146
+ try {
147
+ const gs = new GoalState(tmpDir);
148
+ await gs.init('Test', []);
149
+ for (let i = 0; i < 20; i++) {
150
+ await gs.appendProgress(`Line ${i}`);
151
+ }
152
+
153
+ const tail = gs.tailProgress(3);
154
+ const lines = tail.split('\n').filter(Boolean);
155
+ expect(lines.length).toBe(3);
156
+ expect(lines[0]).toContain('Line 17');
157
+ } finally {
158
+ cleanup(tmpDir);
159
+ }
160
+ });
161
+ });
162
+
163
+ describe('learnings', () => {
164
+ it('追加教训', async () => {
165
+ const tmpDir = createTempDir();
166
+ try {
167
+ const gs = new GoalState(tmpDir);
168
+ await gs.init('Test', []);
169
+ await gs.appendLearning('Always backup before modifying settings.py');
170
+ await gs.appendLearning('Use relative imports in routes/');
171
+
172
+ expect(gs.learnings).toContain('backup before modifying settings.py');
173
+ expect(gs.learnings).toContain('relative imports in routes/');
174
+ } finally {
175
+ cleanup(tmpDir);
176
+ }
177
+ });
178
+
179
+ it('空 learnings 返回空字符串', async () => {
180
+ const tmpDir = createTempDir();
181
+ try {
182
+ const gs = new GoalState(tmpDir);
183
+ await gs.init('Test', []);
184
+ expect(gs.learnings).toBe('');
185
+ } finally {
186
+ cleanup(tmpDir);
187
+ }
188
+ });
189
+
190
+ it('learnings 超过 20 行时只返回尾部 20 行(防 context 膨胀)', async () => {
191
+ const tmpDir = createTempDir();
192
+ try {
193
+ const gs = new GoalState(tmpDir);
194
+ await gs.init('Test', []);
195
+ for (let i = 0; i < 50; i++) {
196
+ await gs.appendLearning(`lesson-${i}`);
197
+ }
198
+
199
+ const tail = gs.learnings;
200
+ const lines = tail.split('\n').filter(Boolean);
201
+ expect(lines.length).toBe(20);
202
+ // 最早的应该已经被裁掉
203
+ expect(tail).not.toContain('lesson-0\n');
204
+ expect(tail).not.toContain('lesson-29\n');
205
+ // 最新一条仍在
206
+ expect(tail).toContain('lesson-49');
207
+ } finally {
208
+ cleanup(tmpDir);
209
+ }
210
+ });
211
+ });
212
+
213
+ describe('decision log', () => {
214
+ it('记录决策', async () => {
215
+ const tmpDir = createTempDir();
216
+ try {
217
+ const gs = new GoalState(tmpDir);
218
+ await gs.init('Test', []);
219
+ await gs.recordDecision(
220
+ { iteration: 5, phase: Phase.PLAN, summary: 'Auth implementation approach' },
221
+ ['JWT', 'Session', 'OAuth'],
222
+ 'JWT',
223
+ 'Single service, simpler for frontend',
224
+ );
225
+
226
+ const similar = gs.findSimilarDecisions(
227
+ { iteration: 10, phase: Phase.PLAN, summary: 'Auth implementation approach' },
228
+ );
229
+ expect(similar).toHaveLength(1);
230
+ expect(similar[0].chosen).toBe('JWT');
231
+ } finally {
232
+ cleanup(tmpDir);
233
+ }
234
+ });
235
+
236
+ it('无相似决策时返回空数组', async () => {
237
+ const tmpDir = createTempDir();
238
+ try {
239
+ const gs = new GoalState(tmpDir);
240
+ await gs.init('Test', []);
241
+ const similar = gs.findSimilarDecisions({
242
+ iteration: 1,
243
+ phase: Phase.PLAN,
244
+ summary: 'Database choice',
245
+ });
246
+ expect(similar).toHaveLength(0);
247
+ } finally {
248
+ cleanup(tmpDir);
249
+ }
250
+ });
251
+
252
+ it('summarizeDecisions 返回格式化摘要', async () => {
253
+ const tmpDir = createTempDir();
254
+ try {
255
+ const gs = new GoalState(tmpDir);
256
+ await gs.init('Test', []);
257
+ await gs.recordDecision(
258
+ { iteration: 3, phase: Phase.PLAN, summary: 'DB' },
259
+ ['PostgreSQL', 'MySQL', 'SQLite'],
260
+ 'PostgreSQL',
261
+ 'JSON support needed',
262
+ );
263
+
264
+ const summary = gs.summarizeDecisions(5);
265
+ expect(summary).toContain('PostgreSQL');
266
+ expect(summary).toContain('迭代 3');
267
+ } finally {
268
+ cleanup(tmpDir);
269
+ }
270
+ });
271
+ });
272
+
273
+ describe('composeContext', () => {
274
+ it('组装包含目标 + 阶段 + 本轮任务', async () => {
275
+ const tmpDir = createTempDir();
276
+ try {
277
+ const gs = new GoalState(tmpDir);
278
+ await gs.init('Build user auth module', []);
279
+ const ctx = gs.composeContext(5);
280
+
281
+ expect(ctx).toContain('不可变目标');
282
+ expect(ctx).toContain('Build user auth module');
283
+ expect(ctx).toContain('当前阶段: PLAN');
284
+ expect(ctx).toContain('本轮任务 (迭代 5)');
285
+ } finally {
286
+ cleanup(tmpDir);
287
+ }
288
+ });
289
+
290
+ it('composeContext 包含 learnings(如果有)', async () => {
291
+ const tmpDir = createTempDir();
292
+ try {
293
+ const gs = new GoalState(tmpDir);
294
+ await gs.init('Test', []);
295
+ await gs.appendLearning('Lesson one');
296
+
297
+ const ctx = gs.composeContext(1);
298
+ expect(ctx).toContain('关键教训');
299
+ expect(ctx).toContain('Lesson one');
300
+ } finally {
301
+ cleanup(tmpDir);
302
+ }
303
+ });
304
+
305
+ it('composeContext 不包含空的 learnings 块', async () => {
306
+ const tmpDir = createTempDir();
307
+ try {
308
+ const gs = new GoalState(tmpDir);
309
+ await gs.init('Test', []);
310
+
311
+ const ctx = gs.composeContext(1);
312
+ expect(ctx).not.toContain('关键教训');
313
+ } finally {
314
+ cleanup(tmpDir);
315
+ }
316
+ });
317
+ });
318
+
319
+ describe('cleanup', () => {
320
+ it('清理后文件不存在(await)', async () => {
321
+ const tmpDir = createTempDir();
322
+ try {
323
+ const gs = new GoalState(tmpDir);
324
+ await gs.init('Test', []);
325
+ await gs.cleanup();
326
+
327
+ expect(existsSync(join(tmpDir, '.minimal-agent', 'goal.md'))).toBe(false);
328
+ } finally {
329
+ cleanup(tmpDir);
330
+ }
331
+ });
332
+
333
+ it('清理后空目录也被删除(await)', async () => {
334
+ const tmpDir = createTempDir();
335
+ try {
336
+ const gs = new GoalState(tmpDir);
337
+ await gs.init('Test', []);
338
+ await gs.cleanup();
339
+
340
+ expect(existsSync(join(tmpDir, '.minimal-agent'))).toBe(false);
341
+ } finally {
342
+ cleanup(tmpDir);
343
+ }
344
+ });
345
+ });
346
+
347
+ describe('reset', () => {
348
+ it('reset 后旧目标被清除,新 init 写入新目标', async () => {
349
+ const tmpDir = createTempDir();
350
+ try {
351
+ const gs = new GoalState(tmpDir);
352
+ await gs.init('Old goal', []);
353
+ expect(gs.goal).toContain('Old goal');
354
+
355
+ await gs.reset();
356
+ expect(gs.goal).toBe('');
357
+
358
+ await gs.init('New goal', []);
359
+ expect(gs.goal).toContain('New goal');
360
+ } finally {
361
+ cleanup(tmpDir);
362
+ }
363
+ });
364
+ });
365
+
366
+ describe('sessionTag', () => {
367
+ it('不传 sessionTag 使用默认目录 .minimal-agent', async () => {
368
+ const tmpDir = createTempDir();
369
+ try {
370
+ const gs = new GoalState(tmpDir);
371
+ await gs.init('Test', []);
372
+
373
+ expect(existsSync(join(tmpDir, '.minimal-agent'))).toBe(true);
374
+ expect(existsSync(join(tmpDir, '.minimal-agent-session-a'))).toBe(false);
375
+ } finally {
376
+ cleanup(tmpDir);
377
+ }
378
+ });
379
+
380
+ it('传入 sessionTag 使用带 tag 的目录', async () => {
381
+ const tmpDir = createTempDir();
382
+ try {
383
+ const gs = new GoalState(tmpDir, 'session-a');
384
+ await gs.init('Test', []);
385
+
386
+ expect(existsSync(join(tmpDir, '.minimal-agent-session-a'))).toBe(true);
387
+ expect(existsSync(join(tmpDir, '.minimal-agent-session-a', 'goal.md'))).toBe(true);
388
+ expect(gs.goal).toContain('Test');
389
+ } finally {
390
+ cleanup(tmpDir);
391
+ }
392
+ });
393
+
394
+ it('不同 sessionTag 隔离状态', async () => {
395
+ const tmpDir = createTempDir();
396
+ try {
397
+ const gsA = new GoalState(tmpDir, 'alpha');
398
+ const gsB = new GoalState(tmpDir, 'beta');
399
+
400
+ await gsA.init('Goal Alpha', []);
401
+ await gsB.init('Goal Beta', []);
402
+
403
+ expect(gsA.goal).toContain('Goal Alpha');
404
+ expect(gsB.goal).toContain('Goal Beta');
405
+ } finally {
406
+ cleanup(tmpDir);
407
+ }
408
+ });
409
+ });
410
+ });