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
@@ -30,17 +30,13 @@
30
30
 
31
31
  import { createHash } from 'node:crypto';
32
32
  import { mkdir, writeFile, appendFile, rm, rename, readFile, readdir } from 'node:fs/promises';
33
- import { existsSync } from 'node:fs';
34
- import { join } from 'node:path';
35
- import {
36
- computeTaskId,
37
- extractGoal,
38
- workflowStateDir,
39
- type TaskArbitrator,
40
- } from './taskIdentity.js';
41
- import type { StepDef } from './types.js';
33
+ import { existsSync, readdirSync, type Dirent } from 'node:fs';
34
+ import { join, dirname, relative } from 'node:path';
35
+ import { computeTaskId, extractGoal, sanitizeWorkflowDirName, workflowStateDir, type TaskArbitrator } from './taskIdentity.js';
36
+ import { safeArtifactPath, type StepDef } from './types.js';
42
37
 
43
38
  const DONE_SUFFIX = '.done';
39
+ const TRASH_SUFFIX = '.trash';
44
40
  const CHECKPOINTS_DIR = 'checkpoints';
45
41
  const SUPPLEMENTS_FILE = 'supplements.json';
46
42
 
@@ -55,6 +51,18 @@ export function loopCheckpointName(loopId: string, iteration: number, stepId: st
55
51
  return `${loopId}${LOOP_CP_SEP}${iteration}${LOOP_CP_SEP}${stepId}`;
56
52
  }
57
53
 
54
+ /**
55
+ * glob 段(路径中的一节,不含分隔符)转正则源:`*` -> `[^/]*`、`?` -> `[^/]`,
56
+ * 其余字符全部转义(`[` `]` 按字面处理,不支持字符类--声明产物用 `*`/`?` 足够,
57
+ * 少一种语法就少一类转义事故面)。
58
+ */
59
+ function artifactSegToPattern(seg: string): string {
60
+ return seg
61
+ .replace(/[.+^${}()|[\]\\!,$^-]/g, '\\$&')
62
+ .replace(/\*/g, '[^/]*')
63
+ .replace(/\?/g, '[^/]');
64
+ }
65
+
58
66
  /** 解析复合 checkpoint 文件名;非复合名(普通顶层步)返回 null。 */
59
67
  function parseLoopCheckpointName(
60
68
  name: string,
@@ -117,8 +125,20 @@ export interface TaskResumeInfo {
117
125
  mode: TaskResumeMode;
118
126
  /** fresh/new-task/resume: 从第几个 step 索引开始执行 */
119
127
  startFrom?: number;
120
- /** new-task: 被清理的旧产物文件路径列表 */
128
+ /**
129
+ * fresh/new-task: 被 rm 直接删除的残留产物路径(展开 glob 后的实际文件)。
130
+ * 注意:正常路径清理先归档(archivedTo)再 rm 兜底,归档成功的文件已移走、
131
+ * 不在本列表里--本列表非空通常意味着归档失败的兜底删除。
132
+ */
121
133
  cleanedArtifacts?: string[];
134
+ /**
135
+ * fresh/new-task: 清理前旧产物被归档到的目录(相对 cwd,如
136
+ * .minimal-agent-workflow/plan.trash/1712...-ab12cd34/)。清理不再直接
137
+ * 删除而是先移入回收目录--LLM 仲裁误判 new-task 时用户成果可找回
138
+ * (实战事故:同主题重跑被仲裁判新任务,11 个声明产物被直接 rm)。
139
+ * 没有可归档文件或归档全部失败时缺省。
140
+ */
141
+ archivedTo?: string;
122
142
  /**
123
143
  * new-task: 同任务但工作流定义结构已变更(或 legacy 状态无指纹无从验证)
124
144
  * → 旧断点作废、全新开始。runner 据此向用户明示原因(不是静默重头跑)。
@@ -182,13 +202,24 @@ interface CurrentState {
182
202
  }
183
203
 
184
204
  export class WorkflowState {
185
- readonly dir: string;
205
+ /** 任务实例目录(绝对路径),init 阶段确定 session 后设置 = <wfDir>/<sessionId>/ */
206
+ dir: string;
207
+ /** workflow 级目录(下含多个 session 实例) */
208
+ readonly wfDir: string;
186
209
  readonly workingDir: string;
210
+ readonly workflowName: string;
211
+ /** 当前 session 的实例标识(一个 session 同时只跑一个同名工作流,即任务实例) */
212
+ readonly sessionId: string;
187
213
 
188
- constructor(workingDir: string, workflowName: string) {
214
+ constructor(workingDir: string, workflowName: string, sessionId = 'default') {
189
215
  this.workingDir = workingDir;
190
- // 按 workflow 名隔离(与 taskIdentity.computeTaskId 读的是同一权威路径)
191
- this.dir = workflowStateDir(workingDir, workflowName);
216
+ this.workflowName = workflowName;
217
+ // Phase 3:session 即任务实例——实例目录 = <wfDir>/<sessionId>/
218
+ this.sessionId = sanitizeWorkflowDirName(sessionId);
219
+ // workflow 级目录(与 taskIdentity.computeTaskId 读的是同一权威路径)
220
+ this.wfDir = workflowStateDir(workingDir, workflowName);
221
+ // 实例目录暂空占位,init 中确定后赋值
222
+ this.dir = '';
192
223
  }
193
224
 
194
225
  /**
@@ -217,49 +248,129 @@ export class WorkflowState {
217
248
  ): Promise<TaskResumeInfo> {
218
249
  // 1. 计算当前任务的 identity(goal 内容 hash 是唯一权威信号)
219
250
  // 与本次定义的结构指纹(resume 兼容性信号:yaml 结构变了旧断点即作废)
220
- const identity = computeTaskId(name, inputs, this.workingDir);
251
+ // Phase 3:传 sessionId,实例查找限定到当前 session(taskId 降级为记录字段)
252
+ const identity = computeTaskId(name, inputs, this.workingDir, this.sessionId);
221
253
  const fp = computeDefFingerprint(steps);
222
254
 
223
- // 2. 尝试读取活动目录的 current.json
255
+ // 2. 确定本任务的实例目录(Phase 3:session 即实例目录 = <wfDir>/<sessionId>/)
256
+ // resume/completed:沿用当前 session 的实例目录;fresh/new-task:建/重建它
257
+ this.dir = this.resolveTaskDir(identity.hash, identity.isSameTask);
258
+
259
+ // 3. 读取活动态 current.json
224
260
  const oldState = await this.readCurrentState();
225
261
 
226
- if (!oldState) {
227
- // 活动目录无状态 —— 可能是「上次成功已归档到 .done」。读归档判定 completed / 不同 goal 新任务。
228
- const archived = await this.readArchivedState();
229
- if (archived && archived.name === name) {
230
- if (archived.taskId === identity.hash) {
231
- // 模式 3: completed —— 同任务此前已成功(报实际存在的声明产物)
232
- return {
233
- mode: 'completed',
234
- artifacts: this.existingArtifacts(archived.artifacts ?? declaredArtifacts),
235
- };
262
+ if (oldState) {
263
+ // ---- 有活动状态 ----
264
+ if (identity.isSameTask) {
265
+ // 同任务 → resume / recovery(下面有公共分支处理)
266
+ } else {
267
+ // 不同任务 + 旧任务未完成 → new-task(下面 oldState 非空分支处理)
268
+ }
269
+ } else {
270
+ // ---- 活动态为空 ----
271
+ // 先看是否有同 taskId 的归档(同一个任务之前成功完成了)
272
+ const archived = identity.isSameTask
273
+ ? await this.findArchivedStateByTaskId(identity.hash)
274
+ : null;
275
+
276
+ if (archived && archived.taskId === identity.hash) {
277
+ // 模式 3: completed —— 同任务此前已成功
278
+ // 把 this.dir 指向归档目录(后续读产物等基于它)
279
+ this.dir = join(this.wfDir, `${this.sessionId}${DONE_SUFFIX}`);
280
+ return {
281
+ mode: 'completed',
282
+ artifacts: await this.existingArtifactsAsync(
283
+ archived.artifacts ?? declaredArtifacts,
284
+ ),
285
+ };
286
+ }
287
+
288
+ // 有其他任务实例(不同 goal)→ 先仲裁二审,再决定 new-task / resume
289
+ if (this.hasAnyTaskInstance()) {
290
+ let arbitratedResume = false;
291
+ if (
292
+ arbitrate &&
293
+ identity.reason === 'hash_diff' &&
294
+ identity.isSameTask === false
295
+ ) {
296
+ const latestActive = await this.findLatestActiveState();
297
+ if (
298
+ latestActive &&
299
+ latestActive.state.name === name &&
300
+ latestActive.state.status !== 'done' &&
301
+ latestActive.state.defFingerprint === fp
302
+ ) {
303
+ // 临时切换 this.dir 到旧任务目录,以便 scanCheckpoints / readInputs 工作
304
+ const prevDir = this.dir;
305
+ this.dir = latestActive.dir;
306
+ try {
307
+ const prevGoal = extractGoal((await this.readInputs()) ?? {});
308
+ const newGoal = extractGoal(inputs);
309
+ let verdict: 'resume' | 'new' = 'new';
310
+ try {
311
+ verdict = await arbitrate({
312
+ workflowName: name,
313
+ prevGoal,
314
+ newGoal,
315
+ completedStepIds: await this.scanCheckpoints(steps),
316
+ totalSteps: steps.length,
317
+ artifacts: latestActive.state.artifacts ?? [],
318
+ });
319
+ } catch {
320
+ verdict = 'new';
321
+ }
322
+ if (verdict === 'resume') {
323
+ // 先置位再 return:finally 不再把 this.dir 改回新任务目录,
324
+ // 延续旧实例目录。否则后续 readSupplements / recordCheckpoint
325
+ // 全部写到错误位置,resume 的断点续跑静默失效
326
+ // (实测:supplements 读空、checkpoint 写丢、current.json 停在旧值)。
327
+ arbitratedResume = true;
328
+ const info = await this.buildResumeInfo(
329
+ latestActive.state,
330
+ steps,
331
+ selfHealBackoff,
332
+ );
333
+ info.prevGoal = prevGoal;
334
+ info.supplementaryGoal = newGoal;
335
+ return info;
336
+ }
337
+ } finally {
338
+ // 仲裁判 new → 恢复 this.dir 为新任务目录(除非 resume 已 return)
339
+ if (!arbitratedResume) this.dir = prevDir;
340
+ }
341
+ }
236
342
  }
237
- // 同名 workflow 但 goal 不同 → 全新任务:清旧归档声明产物后 fresh
238
- // (legacy 归档无 artifacts 字段时用本次声明清单兜底——同名 workflow 的声明产物
239
- // 语义稳定,旧任务的产物路径与本次一致的概率极高,残留比误删危害大)
240
- const cleaned = await this.cleanupDeclaredArtifacts(
241
- archived.artifacts ?? declaredArtifacts,
343
+
344
+ // 模式 2: new-task —— 不同任务,清旧声明产物 + 旧状态后全新开始
345
+ const oldArtifacts = await this.collectAllInstanceArtifacts(name, declaredArtifacts);
346
+ const sweep = await this.archiveThenClean(
347
+ oldArtifacts.length > 0 ? oldArtifacts : declaredArtifacts,
348
+ `${Date.now()}-stale`,
349
+ );
350
+ const sweepOrphan = await this.archiveThenClean(
351
+ declaredArtifacts,
352
+ `${Date.now()}-orphan-${identity.hash.slice(0, 8)}`,
242
353
  );
243
354
  await this.cleanSlate(name, inputs, identity.hash, declaredArtifacts, fp);
244
355
  return {
245
356
  mode: 'new-task',
246
357
  startFrom: 0,
247
- cleanedArtifacts: [
248
- ...cleaned,
249
- ...(await this.cleanupStaleDeclared(declaredArtifacts)),
250
- ],
358
+ cleanedArtifacts: [...sweep.cleaned, ...sweepOrphan.cleaned],
359
+ archivedTo: sweep.archivedTo ?? sweepOrphan.archivedTo,
251
360
  };
252
361
  }
253
- // 模式 1: fresh —— 活动 + 归档都没有,首次运行。
254
- // 声明产物若已存在于磁盘 = 残留孤儿(如 /new 删状态目录后的旧 PLAN.md,
255
- // artifacts 清单随 current.json 一起被删,引擎无从知道它属于谁)。
256
- // 用户约定:全新任务开始时清理残留与中间文件——删除范围**仅限本工作流
257
- // 声明的产物**(与运行自身对它们的覆盖写权限一致,不碰用户其他文件)。
362
+
363
+ // 模式 1: fresh —— 完全没有任何任务实例,首次运行
364
+ const sweepF = await this.archiveThenClean(
365
+ declaredArtifacts,
366
+ `${Date.now()}-orphan`,
367
+ );
258
368
  await this.cleanSlate(name, inputs, identity.hash, declaredArtifacts, fp);
259
369
  return {
260
370
  mode: 'fresh',
261
371
  startFrom: 0,
262
- cleanedArtifacts: await this.cleanupStaleDeclared(declaredArtifacts),
372
+ cleanedArtifacts: sweepF.cleaned,
373
+ archivedTo: sweepF.archivedTo,
263
374
  };
264
375
  }
265
376
 
@@ -306,17 +417,20 @@ export class WorkflowState {
306
417
  // 模式 2: new-task —— 不同任务,清旧声明产物 + 旧状态后全新开始。
307
418
  // legacy 状态文件无 artifacts 字段时:仅当同名 workflow 用本次声明清单兜底
308
419
  //(不同名 workflow 的产物不是我们的,不能殃及)。
309
- const cleaned = await this.cleanupDeclaredArtifacts(
420
+ const sweep = await this.archiveThenClean(
310
421
  oldState.artifacts ?? (oldState.name === name ? declaredArtifacts : []),
422
+ `${Date.now()}-${oldState.taskId.slice(0, 8)}`,
423
+ );
424
+ const sweepStale = await this.archiveThenClean(
425
+ declaredArtifacts,
426
+ `${Date.now()}-orphan-${identity.hash.slice(0, 8)}`,
311
427
  );
312
428
  await this.cleanSlate(name, inputs, identity.hash, declaredArtifacts, fp);
313
429
  return {
314
430
  mode: 'new-task',
315
431
  startFrom: 0,
316
- cleanedArtifacts: [
317
- ...cleaned,
318
- ...(await this.cleanupStaleDeclared(declaredArtifacts)),
319
- ],
432
+ cleanedArtifacts: [...sweep.cleaned, ...sweepStale.cleaned],
433
+ archivedTo: sweep.archivedTo ?? sweepStale.archivedTo,
320
434
  };
321
435
  }
322
436
 
@@ -324,7 +438,9 @@ export class WorkflowState {
324
438
  if (oldState.status === 'done') {
325
439
  return {
326
440
  mode: 'completed',
327
- artifacts: this.existingArtifacts(oldState.artifacts ?? declaredArtifacts),
441
+ artifacts: await this.existingArtifactsAsync(
442
+ oldState.artifacts ?? declaredArtifacts,
443
+ ),
328
444
  };
329
445
  }
330
446
 
@@ -335,18 +451,21 @@ export class WorkflowState {
335
451
  // runner 经 defChanged 旗标向用户明示。legacy 状态(无 defFingerprint
336
452
  // 字段)无从验证兼容性,同样按变更处理(一次性升级成本,安全优先)。
337
453
  if (oldState.defFingerprint !== fp) {
338
- const cleaned = await this.cleanupDeclaredArtifacts(
454
+ const sweep = await this.archiveThenClean(
339
455
  oldState.artifacts ?? declaredArtifacts,
456
+ `${Date.now()}-${oldState.taskId.slice(0, 8)}`,
457
+ );
458
+ const sweepStale = await this.archiveThenClean(
459
+ declaredArtifacts,
460
+ `${Date.now()}-orphan-${identity.hash.slice(0, 8)}`,
340
461
  );
341
462
  await this.cleanSlate(name, inputs, identity.hash, declaredArtifacts, fp);
342
463
  return {
343
464
  mode: 'new-task',
344
465
  startFrom: 0,
345
466
  defChanged: true,
346
- cleanedArtifacts: [
347
- ...cleaned,
348
- ...(await this.cleanupStaleDeclared(declaredArtifacts)),
349
- ],
467
+ cleanedArtifacts: [...sweep.cleaned, ...sweepStale.cleaned],
468
+ archivedTo: sweep.archivedTo ?? sweepStale.archivedTo,
350
469
  };
351
470
  }
352
471
 
@@ -624,27 +743,351 @@ export class WorkflowState {
624
743
 
625
744
  // ---- 私有方法:声明式产物生命周期 ----
626
745
 
746
+ /**
747
+ * 把声明产物里的 glob 模式(`research/04-deep-dive-*.md`)展开成 cwd 下实际
748
+ * 匹配的文件相对路径列表;非 glob 路径原样保留。越界路径被过滤(fail-closed)。
749
+ * 支持段内 `*` / `?`(不跨目录分隔符);声明里写 glob 却一个文件都不匹配时
750
+ * 该声明被丢弃(与旧行为一致:不存在的产物不参与清理/报告)。
751
+ */
752
+ private async expandDeclaredArtifacts(paths: string[]): Promise<string[]> {
753
+ const out: string[] = [];
754
+ for (const p of paths) {
755
+ const safe = safeArtifactPath(this.workingDir, p);
756
+ if (safe === null) continue;
757
+ if (!/[*?]/.test(safe)) {
758
+ out.push(safe);
759
+ continue;
760
+ }
761
+ const norm = safe.replace(/\\/g, '/');
762
+ const segs = norm.split('/');
763
+ const fullRe = new RegExp(`^${segs.map(artifactSegToPattern).join('/')}$`);
764
+ out.push(...(await this.walkGlob(this.workingDir, segs, fullRe, '')));
765
+ }
766
+ return out;
767
+ }
768
+
769
+ /** 逐段匹配目录树收集文件;dir 读失败(不存在/无权限)返回空。 */
770
+ private async walkGlob(
771
+ absDir: string,
772
+ segs: string[],
773
+ fullRe: RegExp,
774
+ relPrefix: string,
775
+ ): Promise<string[]> {
776
+ const [head, ...rest] = segs;
777
+ if (!head) return [];
778
+ let entries: Dirent[];
779
+ try {
780
+ entries = await readdir(absDir, { withFileTypes: true });
781
+ } catch {
782
+ return [];
783
+ }
784
+ const headRe = new RegExp(`^${artifactSegToPattern(head)}$`);
785
+ const results: string[] = [];
786
+ for (const e of entries) {
787
+ if (!headRe.test(e.name)) continue;
788
+ const rel = relPrefix ? `${relPrefix}/${e.name}` : e.name;
789
+ if (rest.length === 0) {
790
+ if (e.isFile() && fullRe.test(rel)) results.push(rel);
791
+ } else if (e.isDirectory()) {
792
+ results.push(...(await this.walkGlob(join(absDir, e.name), rest, fullRe, rel)));
793
+ }
794
+ }
795
+ return results;
796
+ }
797
+
798
+ /**
799
+ * 清理前把【实际存在】的声明产物移入回收目录 `<状态目录>.trash/<stamp>/`(保留
800
+ * 相对路径结构),返回回收目录(相对 cwd 表示,便于 UI 展示);无可归档文件或
801
+ * 一个都没归档成功时返回 undefined(此时后续 rm 兜底直接删,不能谎报归档位置--
802
+ * 用户会按提示去回收站找文件)。单个 move 失败不阻断(残留交给 rm 兜底)。
803
+ * 回收目录是状态目录的 sibling:cleanSlate/cleanup 只重建 `<name>/`,
804
+ * `.trash/` 跨任务留存,直到用户手动清理(如保留最近 N 次的淘汰策略,暂未实现)。
805
+ */
806
+ private async archiveDeclaredArtifacts(
807
+ existingRels: string[],
808
+ stamp: string,
809
+ ): Promise<string | undefined> {
810
+ if (existingRels.length === 0) return undefined;
811
+ const trashRoot = join(this.dir + TRASH_SUFFIX, stamp);
812
+ let archived = 0;
813
+ for (const rel of existingRels) {
814
+ const dest = join(trashRoot, rel);
815
+ try {
816
+ await mkdir(dirname(dest), { recursive: true });
817
+ await rename(join(this.workingDir, rel), dest);
818
+ archived++;
819
+ } catch {
820
+ // 单个归档失败不阻断,残留交给后续 rm 兜底
821
+ }
822
+ }
823
+ if (archived === 0) return undefined;
824
+ const display = relative(this.workingDir, trashRoot);
825
+ return display.startsWith('..') ? trashRoot : display;
826
+ }
827
+
627
828
  /** 删除工作流【声明】的产物文件(相对 cwd),用于 new-task 清理旧任务残留(如旧 PLAN.md)。
628
- * 仅删工作流自己声明的产物、仅 force-rm 文件,scoped 安全。返回清理过的路径列表。 */
829
+ * 仅删工作流自己声明的产物(glob 声明先展开为实际文件)、仅 force-rm 文件,
830
+ * **并强制路径在 cwd 内(防 ../ 跳出删用户家目录)**。
831
+ * 返回真正执行清理的路径列表(展开后的实际文件相对路径;越界路径被过滤掉,fail-closed)。 */
629
832
  private async cleanupDeclaredArtifacts(paths: string[]): Promise<string[]> {
630
- for (const p of paths) {
631
- await rm(join(this.workingDir, p), { force: true }).catch(() => {});
833
+ const done: string[] = [];
834
+ for (const rel of await this.expandDeclaredArtifacts(paths)) {
835
+ const abs = join(this.workingDir, rel);
836
+ if (!existsSync(abs)) continue;
837
+ try {
838
+ await rm(abs, { force: true });
839
+ done.push(rel);
840
+ } catch {
841
+ // 单个删除失败不影响整体(与原实现 .catch(() => {}) 一致)
842
+ }
632
843
  }
633
- return paths;
844
+ return done;
634
845
  }
635
846
 
636
- /** 从声明产物路径里过滤出 cwd 下【实际存在】的文件(completed 报真实产物用)。 */
637
- private existingArtifacts(paths: string[]): string[] {
638
- return paths.filter((p) => existsSync(join(this.workingDir, p)));
847
+ /** 从声明产物路径里过滤出 cwd 下【实际存在且路径安全落在 cwd 内】的文件
848
+ * (completed 报真实产物用;glob 声明先展开;越界声明不报告,视为不存在)。 */
849
+ private async existingArtifactsAsync(paths: string[]): Promise<string[]> {
850
+ const expanded = await this.expandDeclaredArtifacts(paths);
851
+ return expanded.filter((rel) => existsSync(join(this.workingDir, rel)));
639
852
  }
640
853
 
641
- /** 清理【实际存在】的本次声明产物(fresh/new-task 启动时清残留孤儿),返回真正删掉的路径。 */
642
- private async cleanupStaleDeclared(declaredArtifacts: string[]): Promise<string[]> {
643
- return this.cleanupDeclaredArtifacts(this.existingArtifacts(declaredArtifacts));
854
+ /**
855
+ * 「先归档再清理」组合:把【实际存在】的声明产物移入 .trash 回收目录,
856
+ * 归档后残留(rename 失败者)由 cleanup 兜底 rm。返回清理列表与回收目录。
857
+ * fresh/new-task 启动清理统一走这里--直接 rm 旧产物曾在实战中造成不可找回的损失
858
+ * (LLM 仲裁误判 new-task,同主题调研的全部报告被静默删除)。
859
+ */
860
+ private async archiveThenClean(
861
+ paths: string[],
862
+ stamp: string,
863
+ ): Promise<{ cleaned: string[]; archivedTo?: string }> {
864
+ const existing = await this.existingArtifactsAsync(paths);
865
+ const archivedTo = await this.archiveDeclaredArtifacts(existing, stamp);
866
+ const cleaned = await this.cleanupDeclaredArtifacts(paths);
867
+ return { cleaned, archivedTo };
644
868
  }
645
869
 
646
870
  // ---- 公共方法:归档与清理(保持原有签名兼容) ----
647
871
 
872
+ /**
873
+ * 确定当前任务的实例目录路径。
874
+ * Phase 3:实例目录 = <wfDir>/<sessionId>/(session 即任务实例)。
875
+ * isSameTask=true 时若旧布局(wfDir 本身)存在则沿用(兼容升级前状态)。
876
+ */
877
+ private resolveTaskDir(_taskId: string, isSameTask: boolean): string {
878
+ if (isSameTask) {
879
+ const existing = this.findExistingTaskDir(_taskId);
880
+ if (existing) return existing;
881
+ }
882
+ return join(this.wfDir, this.sessionId);
883
+ }
884
+
885
+ /**
886
+ * 找当前 session 对应的现存活动实例目录(含旧布局兼容)。
887
+ * 找不到返回 null。
888
+ */
889
+ private findExistingTaskDir(_taskId: string): string | null {
890
+ // Phase 3:当前 session 的实例目录
891
+ const sessionDir = join(this.wfDir, this.sessionId);
892
+ if (existsSync(sessionDir)) return sessionDir;
893
+
894
+ // 旧布局兼容:wfDir 本身就是实例目录
895
+ if (existsSync(join(this.wfDir, 'current.json'))) {
896
+ try {
897
+ const entries = readdirSync(this.wfDir, { withFileTypes: true });
898
+ const hasInstanceSubdir = entries.some(
899
+ (e) => e.isDirectory() && /^[0-9a-f]{16}$/.test(e.name),
900
+ );
901
+ if (!hasInstanceSubdir) return this.wfDir;
902
+ } catch {
903
+ return this.wfDir;
904
+ }
905
+ }
906
+ return null;
907
+ }
908
+
909
+ /**
910
+ * 扫描当前 session 是否有任何任务实例(活动态或 .done 归档态,不包括 .trash)。
911
+ * 用于 isSameTask=false 时区分 fresh(从未跑过)和 new-task(有其他旧任务)。
912
+ */
913
+ private hasAnyTaskInstance(): boolean {
914
+ if (!existsSync(this.wfDir)) return false;
915
+ // Phase 3:当前 session 的实例(活动 / .done)
916
+ if (existsSync(join(this.wfDir, this.sessionId))) return true;
917
+ if (existsSync(join(this.wfDir, `${this.sessionId}${DONE_SUFFIX}`))) return true;
918
+ // 旧布局:wfDir 本身就是实例(current.json 直接在 wfDir 下)
919
+ if (existsSync(join(this.wfDir, 'current.json'))) return true;
920
+ if (existsSync(join(this.wfDir + DONE_SUFFIX, 'current.json'))) return true;
921
+ return false;
922
+ }
923
+
924
+ /**
925
+ * 扫描 wfDir 下所有 .done 归档,返回第一个(也是最新的,按修改时间排序)的 current.json。
926
+ * 用于 completed 检测的兜底——当 hash 不匹配但 wfDir 下有归档时,判断是否是同名 workflow 的旧归档。
927
+ */
928
+ private findLatestArchivedState(): Promise<CurrentState | null> {
929
+ return this.findArchivedState(() => true);
930
+ }
931
+
932
+ /**
933
+ * 在 wfDir 下找当前 session 对应的归档目录(.done)里的 current.json。
934
+ * 支持新布局(wfDir/<sessionId>.done/)和旧布局(wfDir.done/)。
935
+ */
936
+ private findArchivedStateByTaskId(_taskId: string): Promise<CurrentState | null> {
937
+ return this.findArchivedState(
938
+ (name, isLegacy) => isLegacy || name === `${this.sessionId}${DONE_SUFFIX}`,
939
+ );
940
+ }
941
+
942
+ private async findArchivedState(
943
+ predicate: (dirName: string, isLegacy: boolean) => boolean,
944
+ ): Promise<CurrentState | null> {
945
+ // 旧布局:wfDir.done/current.json
946
+ const legacyDone = this.wfDir + DONE_SUFFIX;
947
+ if (existsSync(join(legacyDone, 'current.json')) && predicate('', true)) {
948
+ try {
949
+ const raw = await readFile(join(legacyDone, 'current.json'), 'utf-8');
950
+ return JSON.parse(raw) as CurrentState;
951
+ } catch {
952
+ // fall through
953
+ }
954
+ }
955
+
956
+ // 新布局:wfDir/<taskId>.done/current.json
957
+ if (!existsSync(this.wfDir)) return null;
958
+ try {
959
+ const entries = readdirSync(this.wfDir, { withFileTypes: true });
960
+ const doneDirs = entries
961
+ .filter((e) => e.isDirectory() && e.name.endsWith(DONE_SUFFIX))
962
+ .map((e) => e.name)
963
+ .filter((name) => predicate(name, false))
964
+ .sort();
965
+ for (const name of doneDirs) {
966
+ try {
967
+ const raw = await readFile(join(this.wfDir, name, 'current.json'), 'utf-8');
968
+ return JSON.parse(raw) as CurrentState;
969
+ } catch {
970
+ continue;
971
+ }
972
+ }
973
+ } catch {
974
+ // ignore
975
+ }
976
+ return null;
977
+ }
978
+
979
+ /**
980
+ * 收集【当前 session】的任务实例(活动态 + .done 归档态)的声明产物路径,
981
+ * 用于 new-task 时归档+清理旧任务的产物。去重后返回。
982
+ * Phase 3:只收集当前 session 的实例产物——不同 session 的产物各归各,
983
+ * new-task 绝不殃及其他 session 的产出。
984
+ */
985
+ private async collectAllInstanceArtifacts(
986
+ workflowName: string,
987
+ fallback: string[],
988
+ ): Promise<string[]> {
989
+ const seen = new Set<string>();
990
+ const allArtifacts: string[] = [];
991
+ const pushAll = (arr: string[] | undefined) => {
992
+ for (const a of arr ?? []) {
993
+ if (!seen.has(a)) {
994
+ seen.add(a);
995
+ allArtifacts.push(a);
996
+ }
997
+ }
998
+ };
999
+
1000
+ // Phase 3:当前 session 的活动实例与 .done 归档
1001
+ for (const dirName of [this.sessionId, `${this.sessionId}${DONE_SUFFIX}`]) {
1002
+ const dir = join(this.wfDir, dirName);
1003
+ if (!existsSync(dir)) continue;
1004
+ try {
1005
+ const raw = await readFile(join(dir, 'current.json'), 'utf-8');
1006
+ const s = JSON.parse(raw) as CurrentState;
1007
+ if (s.name === workflowName) pushAll(s.artifacts);
1008
+ } catch {
1009
+ // ignore
1010
+ }
1011
+ }
1012
+
1013
+ // 旧布局兼容:wfDir 本身有 current.json(且无子实例目录)或 wfDir.done/
1014
+ if (!existsSync(this.wfDir)) {
1015
+ const legacyDone = this.wfDir + DONE_SUFFIX;
1016
+ try {
1017
+ const raw = await readFile(join(legacyDone, 'current.json'), 'utf-8');
1018
+ const s = JSON.parse(raw) as CurrentState;
1019
+ if (s.name === workflowName) pushAll(s.artifacts);
1020
+ } catch {
1021
+ // ignore
1022
+ }
1023
+ } else {
1024
+ try {
1025
+ const entries = readdirSync(this.wfDir, { withFileTypes: true });
1026
+ const hasInstanceSubdir = entries.some(
1027
+ (e) => e.isDirectory() && /^[0-9a-f]{16}/.test(e.name),
1028
+ );
1029
+ if (!hasInstanceSubdir && existsSync(join(this.wfDir, 'current.json'))) {
1030
+ try {
1031
+ const raw = await readFile(join(this.wfDir, 'current.json'), 'utf-8');
1032
+ const s = JSON.parse(raw) as CurrentState;
1033
+ if (s.name === workflowName) pushAll(s.artifacts);
1034
+ } catch {
1035
+ // ignore
1036
+ }
1037
+ }
1038
+ } catch {
1039
+ // ignore
1040
+ }
1041
+ }
1042
+
1043
+ return allArtifacts.length > 0 ? allArtifacts : fallback;
1044
+ }
1045
+
1046
+ /**
1047
+ * 找当前 session 的活动态实例(未完成),返回其目录 + current.json。
1048
+ * 用于 isSameTask=false 时的仲裁——拿到旧任务的 state 做二审。
1049
+ * 找不到返回 null。
1050
+ */
1051
+ private async findLatestActiveState(): Promise<{
1052
+ dir: string;
1053
+ state: CurrentState;
1054
+ } | null> {
1055
+ // Phase 3:当前 session 的活动实例 <wfDir>/<sessionId>/
1056
+ const sessionDir = join(this.wfDir, this.sessionId);
1057
+ if (existsSync(sessionDir)) {
1058
+ try {
1059
+ const raw = await readFile(join(sessionDir, 'current.json'), 'utf-8');
1060
+ const state = JSON.parse(raw) as CurrentState;
1061
+ if (state.status !== 'done') {
1062
+ return { dir: sessionDir, state };
1063
+ }
1064
+ } catch {
1065
+ // fall through
1066
+ }
1067
+ }
1068
+
1069
+ // 旧布局兼容:wfDir 本身是实例目录(current.json 直接在其下)
1070
+ if (existsSync(join(this.wfDir, 'current.json'))) {
1071
+ try {
1072
+ const entries = readdirSync(this.wfDir, { withFileTypes: true });
1073
+ const hasInstanceSubdir = entries.some(
1074
+ (e) => e.isDirectory() && /^[0-9a-f]{16}/.test(e.name),
1075
+ );
1076
+ if (!hasInstanceSubdir) {
1077
+ const raw = await readFile(join(this.wfDir, 'current.json'), 'utf-8');
1078
+ const state = JSON.parse(raw) as CurrentState;
1079
+ if (state.status !== 'done') {
1080
+ return { dir: this.wfDir, state };
1081
+ }
1082
+ }
1083
+ } catch {
1084
+ // fall through
1085
+ }
1086
+ }
1087
+
1088
+ return null;
1089
+ }
1090
+
648
1091
  /**
649
1092
  * 归档(成功完成时调用):重命名状态目录为 .done 后缀,保留所有中间产物。
650
1093
  * vars.json / progress.md / inputs.json / checkpoints/ 全部保留——供后续对话 Read 回溯。
@@ -663,7 +1106,9 @@ export class WorkflowState {
663
1106
  }
664
1107
  }
665
1108
 
666
- /** 强制清理(/new 或新任务 init 时调用):删除状态目录(含 .done 归档) */
1109
+ /** 强制清理(/new 或新任务 init 时调用):删除当前任务实例目录(含 .done 归档)。
1110
+ * 注意:.trash 回收目录不在这里删——它是产物清理前的归档安全网,
1111
+ * 跨任务保留,由用户或后续淘汰策略管理。 */
667
1112
  async cleanup(): Promise<void> {
668
1113
  await rm(this.dir, { recursive: true, force: true }).catch(() => {});
669
1114
  await rm(this.dir + DONE_SUFFIX, { recursive: true, force: true }).catch(() => {});