minimal-agent 0.11.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 (181) hide show
  1. package/package.json +5 -1
  2. package/plugins/workflow-runner/plugin.js +27 -15
  3. package/plugins/workflow-runner/plugin.ts +34 -20
  4. package/plugins/workflow-runner/src/agentCommon.js +13 -12
  5. package/plugins/workflow-runner/src/agentCommon.ts +22 -13
  6. package/plugins/workflow-runner/src/expressions.js +29 -8
  7. package/plugins/workflow-runner/src/expressions.ts +28 -10
  8. package/plugins/workflow-runner/src/index.js +50 -20
  9. package/plugins/workflow-runner/src/index.ts +75 -31
  10. package/plugins/workflow-runner/src/multimodal.js +2 -1
  11. package/plugins/workflow-runner/src/multimodal.ts +2 -1
  12. package/plugins/workflow-runner/src/recovery.js +9 -2
  13. package/plugins/workflow-runner/src/recovery.ts +9 -2
  14. package/plugins/workflow-runner/src/runner.js +31 -5
  15. package/plugins/workflow-runner/src/runner.ts +33 -5
  16. package/plugins/workflow-runner/src/stepExecutors/assert.js +2 -1
  17. package/plugins/workflow-runner/src/stepExecutors/assert.ts +3 -1
  18. package/plugins/workflow-runner/src/stepExecutors/skill.js +5 -6
  19. package/plugins/workflow-runner/src/stepExecutors/skill.ts +12 -7
  20. package/plugins/workflow-runner/src/stepExecutors/tool.js +91 -61
  21. package/plugins/workflow-runner/src/stepExecutors/tool.ts +111 -71
  22. package/plugins/workflow-runner/src/stepExecutors/verify.js +6 -3
  23. package/plugins/workflow-runner/src/stepExecutors/verify.ts +7 -3
  24. package/plugins/workflow-runner/src/taskIdentity.js +197 -21
  25. package/plugins/workflow-runner/src/taskIdentity.ts +201 -20
  26. package/plugins/workflow-runner/src/toolRuntime.js +48 -0
  27. package/plugins/workflow-runner/src/toolRuntime.ts +64 -0
  28. package/plugins/workflow-runner/src/types.js +113 -7
  29. package/plugins/workflow-runner/src/types.ts +137 -14
  30. package/plugins/workflow-runner/src/workflowState.js +455 -54
  31. package/plugins/workflow-runner/src/workflowState.ts +508 -63
  32. package/plugins/workflow-runner/test/cli.e2e.test.ts +20 -0
  33. package/plugins/workflow-runner/test/context-files.test.ts +47 -16
  34. package/plugins/workflow-runner/test/e2eNewLayout.test.ts +314 -0
  35. package/plugins/workflow-runner/test/engineRobustness.test.ts +9 -1
  36. package/plugins/workflow-runner/test/expressions.test.ts +42 -0
  37. package/plugins/workflow-runner/test/hostCapability.test.ts +9 -3
  38. package/plugins/workflow-runner/test/illustrate.load.test.ts +2 -2
  39. package/plugins/workflow-runner/test/multimodal.test.ts +61 -10
  40. package/plugins/workflow-runner/test/parseWorkflowArgs.test.ts +19 -0
  41. package/plugins/workflow-runner/test/ppt.load.test.ts +15 -15
  42. package/plugins/workflow-runner/test/ralphLoop.e2e.test.ts +3 -2
  43. package/plugins/workflow-runner/test/research.load.test.ts +328 -0
  44. package/plugins/workflow-runner/test/runnerPreflight.test.ts +68 -11
  45. package/plugins/workflow-runner/test/stateLifecycle.test.ts +60 -12
  46. package/plugins/workflow-runner/test/taskArbitration.test.ts +19 -3
  47. package/plugins/workflow-runner/test/taskLifecycle.test.ts +61 -7
  48. package/plugins/workflow-runner/test/toolRuntimeIntegration.test.ts +168 -0
  49. package/plugins/workflow-runner/test/webppt.load.test.ts +4 -4
  50. package/plugins/workflow-runner/test/word.load.test.ts +9 -9
  51. package/skills/skill-creator/scripts/run_eval.py +3 -2
  52. package/src/cli/print.js +36 -4
  53. package/src/cli/print.ts +42 -4
  54. package/src/cli/streamJson.js +19 -4
  55. package/src/cli/streamJson.ts +17 -4
  56. package/src/config/configDefaults.js +25 -0
  57. package/src/config/configDefaults.ts +33 -0
  58. package/src/config/configFile.js +2 -21
  59. package/src/config/configFile.ts +11 -24
  60. package/src/config/configManagement.js +1 -1
  61. package/src/config/configManagement.ts +2 -2
  62. package/src/config/configService.js +24 -0
  63. package/src/config/configService.ts +23 -0
  64. package/src/context/compact.js +4 -0
  65. package/src/context/compact.ts +4 -0
  66. package/src/context/contextBundle.js +38 -13
  67. package/src/context/contextBundle.ts +47 -13
  68. package/src/harness/stallGuard.js +19 -4
  69. package/src/harness/stallGuard.ts +30 -7
  70. package/src/infra/verification.js +25 -18
  71. package/src/infra/verification.ts +50 -18
  72. package/src/loop.js +95 -11
  73. package/src/loop.ts +125 -16
  74. package/src/main.js +8 -1
  75. package/src/main.tsx +7 -1
  76. package/src/memory/constants.js +10 -0
  77. package/src/memory/constants.ts +13 -0
  78. package/src/memory/episodicMemory.js +274 -2
  79. package/src/memory/episodicMemory.ts +306 -1
  80. package/src/memory/fileStore.js +24 -4
  81. package/src/memory/fileStore.ts +22 -2
  82. package/src/memory/memoryService.js +79 -12
  83. package/src/memory/memoryService.ts +108 -9
  84. package/src/memory/repository.js +415 -85
  85. package/src/memory/repository.ts +437 -86
  86. package/src/memory/turnJournal.js +95 -0
  87. package/src/memory/turnJournal.ts +120 -0
  88. package/src/memory/types.ts +16 -0
  89. package/src/plugins/pluginApi.ts +30 -6
  90. package/src/plugins/pluginRunner.js +23 -8
  91. package/src/plugins/pluginRunner.ts +32 -13
  92. package/src/prompts/policyRegistry.js +55 -1
  93. package/src/prompts/policyRegistry.ts +55 -1
  94. package/src/prompts/promptAssembly.js +39 -7
  95. package/src/prompts/promptAssembly.ts +145 -59
  96. package/src/session/runAgentTurn.js +43 -21
  97. package/src/session/runAgentTurn.ts +68 -26
  98. package/src/session/runTurn.js +11 -46
  99. package/src/session/runTurn.ts +12 -46
  100. package/src/session/runtimeStore.js +149 -0
  101. package/src/session/runtimeStore.ts +197 -0
  102. package/src/session/sessionState.js +5 -0
  103. package/src/session/sessionState.ts +14 -0
  104. package/src/tools/advisor/advisor.js +76 -9
  105. package/src/tools/advisor/advisor.ts +79 -9
  106. package/src/tools/agent/agent.js +143 -14
  107. package/src/tools/agent/agent.ts +149 -14
  108. package/src/tools/agent/agentTaskService.js +60 -0
  109. package/src/tools/agent/agentTaskService.ts +113 -0
  110. package/src/tools/agent/agentTypes.js +5 -0
  111. package/src/tools/agent/agentTypes.ts +5 -0
  112. package/src/tools/bash/bash.js +80 -15
  113. package/src/tools/bash/bash.ts +83 -15
  114. package/src/tools/capabilities.js +5 -0
  115. package/src/tools/capabilities.ts +11 -1
  116. package/src/tools/edit/edit.js +7 -4
  117. package/src/tools/edit/edit.ts +7 -4
  118. package/src/tools/edit/multi-edit.js +4 -2
  119. package/src/tools/edit/multi-edit.ts +4 -2
  120. package/src/tools/glob/glob.js +156 -40
  121. package/src/tools/glob/glob.ts +185 -46
  122. package/src/tools/index.js +35 -17
  123. package/src/tools/index.ts +81 -20
  124. package/src/tools/lsp/lsp.js +165 -22
  125. package/src/tools/lsp/lsp.ts +206 -36
  126. package/src/tools/powershell/powershell.js +134 -27
  127. package/src/tools/powershell/powershell.ts +171 -30
  128. package/src/tools/presentresult/presentresult.js +10 -0
  129. package/src/tools/presentresult/presentresult.ts +10 -0
  130. package/src/tools/read/read.js +249 -29
  131. package/src/tools/read/read.ts +396 -59
  132. package/src/tools/shared/diffSummary.js +44 -0
  133. package/src/tools/shared/diffSummary.ts +63 -0
  134. package/src/tools/shared/fileActivity.js +2 -1
  135. package/src/tools/shared/fileActivity.ts +3 -0
  136. package/src/tools/shared/permanentDeleteGuard.js +57 -0
  137. package/src/tools/shared/permanentDeleteGuard.ts +71 -0
  138. package/src/tools/shared/toolOutputStore.js +97 -0
  139. package/src/tools/shared/toolOutputStore.ts +110 -0
  140. package/src/tools/skill/skill.js +86 -0
  141. package/src/tools/skill/skill.ts +94 -0
  142. package/src/tools/snip/snip.js +67 -0
  143. package/src/tools/snip/snip.ts +80 -0
  144. package/src/tools/tasks/backgroundTaskService.js +79 -28
  145. package/src/tools/tasks/backgroundTaskService.ts +165 -49
  146. package/src/tools/tasks/taskManagement.js +163 -0
  147. package/src/tools/tasks/taskManagement.ts +180 -0
  148. package/src/tools/tasks/taskOutput.js +64 -14
  149. package/src/tools/tasks/taskOutput.ts +102 -23
  150. package/src/tools/tasks/taskStop.js +48 -8
  151. package/src/tools/tasks/taskStop.ts +69 -9
  152. package/src/tools/tasks/taskStore.js +46 -0
  153. package/src/tools/tasks/taskStore.ts +77 -0
  154. package/src/tools/terminal/terminalCapture.js +167 -0
  155. package/src/tools/terminal/terminalCapture.ts +179 -0
  156. package/src/tools/trash/trash.js +233 -0
  157. package/src/tools/trash/trash.ts +283 -0
  158. package/src/tools/webbrowser/browserControl.js +5 -1
  159. package/src/tools/webbrowser/browserControl.ts +7 -1
  160. package/src/tools/webbrowser/webbrowser.js +8 -4
  161. package/src/tools/webbrowser/webbrowser.ts +8 -4
  162. package/src/tools/worktree/sessionWorktree.js +128 -0
  163. package/src/tools/worktree/sessionWorktree.ts +158 -0
  164. package/src/tools/write/write.js +6 -2
  165. package/src/tools/write/write.ts +6 -2
  166. package/src/types.ts +22 -4
  167. package/src/ui/hooks/useChat.js +31 -11
  168. package/src/ui/hooks/useChat.ts +31 -13
  169. package/src/web/protocol.ts +78 -3
  170. package/workflows/adaptive-research.yaml +5 -5
  171. package/workflows/book-review-short.yaml +5 -5
  172. package/workflows/e2e-write-greet.yaml +2 -2
  173. package/workflows/illustrate.yaml +12 -12
  174. package/workflows/plan.yaml +18 -18
  175. package/workflows/ppt.yaml +201 -201
  176. package/workflows/ralph-loop.yaml +3 -1
  177. package/workflows/research.md +449 -0
  178. package/workflows/research.yaml +873 -0
  179. package/workflows/webppt.yaml +47 -47
  180. package/workflows/word.yaml +80 -80
  181. package/workflows/youtube-shorts.yaml +25 -25
@@ -29,10 +29,12 @@
29
29
  */
30
30
  import { createHash } from 'node:crypto';
31
31
  import { mkdir, writeFile, appendFile, rm, rename, readFile, readdir } from 'node:fs/promises';
32
- import { existsSync } from 'node:fs';
33
- import { join } from 'node:path';
34
- import { computeTaskId, extractGoal, workflowStateDir, } from './taskIdentity.js';
32
+ import { existsSync, readdirSync } from 'node:fs';
33
+ import { join, dirname, relative } from 'node:path';
34
+ import { computeTaskId, extractGoal, sanitizeWorkflowDirName, workflowStateDir } from './taskIdentity.js';
35
+ import { safeArtifactPath } from './types.js';
35
36
  const DONE_SUFFIX = '.done';
37
+ const TRASH_SUFFIX = '.trash';
36
38
  const CHECKPOINTS_DIR = 'checkpoints';
37
39
  const SUPPLEMENTS_FILE = 'supplements.json';
38
40
  /**
@@ -44,6 +46,17 @@ export const LOOP_CP_SEP = '@@';
44
46
  export function loopCheckpointName(loopId, iteration, stepId) {
45
47
  return `${loopId}${LOOP_CP_SEP}${iteration}${LOOP_CP_SEP}${stepId}`;
46
48
  }
49
+ /**
50
+ * glob 段(路径中的一节,不含分隔符)转正则源:`*` -> `[^/]*`、`?` -> `[^/]`,
51
+ * 其余字符全部转义(`[` `]` 按字面处理,不支持字符类--声明产物用 `*`/`?` 足够,
52
+ * 少一种语法就少一类转义事故面)。
53
+ */
54
+ function artifactSegToPattern(seg) {
55
+ return seg
56
+ .replace(/[.+^${}()|[\]\\!,$^-]/g, '\\$&')
57
+ .replace(/\*/g, '[^/]*')
58
+ .replace(/\?/g, '[^/]');
59
+ }
47
60
  /** 解析复合 checkpoint 文件名;非复合名(普通顶层步)返回 null。 */
48
61
  function parseLoopCheckpointName(name) {
49
62
  const parts = name.split(LOOP_CP_SEP);
@@ -77,12 +90,23 @@ export function computeDefFingerprint(steps) {
77
90
  .slice(0, 16);
78
91
  }
79
92
  export class WorkflowState {
93
+ /** 任务实例目录(绝对路径),init 阶段确定 session 后设置 = <wfDir>/<sessionId>/ */
80
94
  dir;
95
+ /** workflow 级目录(下含多个 session 实例) */
96
+ wfDir;
81
97
  workingDir;
82
- constructor(workingDir, workflowName) {
98
+ workflowName;
99
+ /** 当前 session 的实例标识(一个 session 同时只跑一个同名工作流,即任务实例) */
100
+ sessionId;
101
+ constructor(workingDir, workflowName, sessionId = 'default') {
83
102
  this.workingDir = workingDir;
84
- // 按 workflow 名隔离(与 taskIdentity.computeTaskId 读的是同一权威路径)
85
- this.dir = workflowStateDir(workingDir, workflowName);
103
+ this.workflowName = workflowName;
104
+ // Phase 3:session 即任务实例——实例目录 = <wfDir>/<sessionId>/
105
+ this.sessionId = sanitizeWorkflowDirName(sessionId);
106
+ // workflow 级目录(与 taskIdentity.computeTaskId 读的是同一权威路径)
107
+ this.wfDir = workflowStateDir(workingDir, workflowName);
108
+ // 实例目录暂空占位,init 中确定后赋值
109
+ this.dir = '';
86
110
  }
87
111
  /**
88
112
  * 初始化工作流状态 —— 核心入口,返回恢复信息。
@@ -106,45 +130,108 @@ export class WorkflowState {
106
130
  arbitrate) {
107
131
  // 1. 计算当前任务的 identity(goal 内容 hash 是唯一权威信号)
108
132
  // 与本次定义的结构指纹(resume 兼容性信号:yaml 结构变了旧断点即作废)
109
- const identity = computeTaskId(name, inputs, this.workingDir);
133
+ // Phase 3:传 sessionId,实例查找限定到当前 session(taskId 降级为记录字段)
134
+ const identity = computeTaskId(name, inputs, this.workingDir, this.sessionId);
110
135
  const fp = computeDefFingerprint(steps);
111
- // 2. 尝试读取活动目录的 current.json
136
+ // 2. 确定本任务的实例目录(Phase 3:session 即实例目录 = <wfDir>/<sessionId>/)
137
+ // resume/completed:沿用当前 session 的实例目录;fresh/new-task:建/重建它
138
+ this.dir = this.resolveTaskDir(identity.hash, identity.isSameTask);
139
+ // 3. 读取活动态 current.json
112
140
  const oldState = await this.readCurrentState();
113
- if (!oldState) {
114
- // 活动目录无状态 —— 可能是「上次成功已归档到 .done」。读归档判定 completed / 不同 goal 新任务。
115
- const archived = await this.readArchivedState();
116
- if (archived && archived.name === name) {
117
- if (archived.taskId === identity.hash) {
118
- // 模式 3: completed —— 同任务此前已成功(报实际存在的声明产物)
119
- return {
120
- mode: 'completed',
121
- artifacts: this.existingArtifacts(archived.artifacts ?? declaredArtifacts),
122
- };
141
+ if (oldState) {
142
+ // ---- 有活动状态 ----
143
+ if (identity.isSameTask) {
144
+ // 同任务 → resume / recovery(下面有公共分支处理)
145
+ }
146
+ else {
147
+ // 不同任务 + 旧任务未完成 → new-task(下面 oldState 非空分支处理)
148
+ }
149
+ }
150
+ else {
151
+ // ---- 活动态为空 ----
152
+ // 先看是否有同 taskId 的归档(同一个任务之前成功完成了)
153
+ const archived = identity.isSameTask
154
+ ? await this.findArchivedStateByTaskId(identity.hash)
155
+ : null;
156
+ if (archived && archived.taskId === identity.hash) {
157
+ // 模式 3: completed —— 同任务此前已成功
158
+ // 把 this.dir 指向归档目录(后续读产物等基于它)
159
+ this.dir = join(this.wfDir, `${this.sessionId}${DONE_SUFFIX}`);
160
+ return {
161
+ mode: 'completed',
162
+ artifacts: await this.existingArtifactsAsync(archived.artifacts ?? declaredArtifacts),
163
+ };
164
+ }
165
+ // 有其他任务实例(不同 goal)→ 先仲裁二审,再决定 new-task / resume
166
+ if (this.hasAnyTaskInstance()) {
167
+ let arbitratedResume = false;
168
+ if (arbitrate &&
169
+ identity.reason === 'hash_diff' &&
170
+ identity.isSameTask === false) {
171
+ const latestActive = await this.findLatestActiveState();
172
+ if (latestActive &&
173
+ latestActive.state.name === name &&
174
+ latestActive.state.status !== 'done' &&
175
+ latestActive.state.defFingerprint === fp) {
176
+ // 临时切换 this.dir 到旧任务目录,以便 scanCheckpoints / readInputs 工作
177
+ const prevDir = this.dir;
178
+ this.dir = latestActive.dir;
179
+ try {
180
+ const prevGoal = extractGoal((await this.readInputs()) ?? {});
181
+ const newGoal = extractGoal(inputs);
182
+ let verdict = 'new';
183
+ try {
184
+ verdict = await arbitrate({
185
+ workflowName: name,
186
+ prevGoal,
187
+ newGoal,
188
+ completedStepIds: await this.scanCheckpoints(steps),
189
+ totalSteps: steps.length,
190
+ artifacts: latestActive.state.artifacts ?? [],
191
+ });
192
+ }
193
+ catch {
194
+ verdict = 'new';
195
+ }
196
+ if (verdict === 'resume') {
197
+ // 先置位再 return:finally 不再把 this.dir 改回新任务目录,
198
+ // 延续旧实例目录。否则后续 readSupplements / recordCheckpoint
199
+ // 全部写到错误位置,resume 的断点续跑静默失效
200
+ // (实测:supplements 读空、checkpoint 写丢、current.json 停在旧值)。
201
+ arbitratedResume = true;
202
+ const info = await this.buildResumeInfo(latestActive.state, steps, selfHealBackoff);
203
+ info.prevGoal = prevGoal;
204
+ info.supplementaryGoal = newGoal;
205
+ return info;
206
+ }
207
+ }
208
+ finally {
209
+ // 仲裁判 new → 恢复 this.dir 为新任务目录(除非 resume 已 return)
210
+ if (!arbitratedResume)
211
+ this.dir = prevDir;
212
+ }
213
+ }
123
214
  }
124
- // 同名 workflow 但 goal 不同 → 全新任务:清旧归档声明产物后 fresh
125
- // (legacy 归档无 artifacts 字段时用本次声明清单兜底——同名 workflow 的声明产物
126
- // 语义稳定,旧任务的产物路径与本次一致的概率极高,残留比误删危害大)
127
- const cleaned = await this.cleanupDeclaredArtifacts(archived.artifacts ?? declaredArtifacts);
215
+ // 模式 2: new-task —— 不同任务,清旧声明产物 + 旧状态后全新开始
216
+ const oldArtifacts = await this.collectAllInstanceArtifacts(name, declaredArtifacts);
217
+ const sweep = await this.archiveThenClean(oldArtifacts.length > 0 ? oldArtifacts : declaredArtifacts, `${Date.now()}-stale`);
218
+ const sweepOrphan = await this.archiveThenClean(declaredArtifacts, `${Date.now()}-orphan-${identity.hash.slice(0, 8)}`);
128
219
  await this.cleanSlate(name, inputs, identity.hash, declaredArtifacts, fp);
129
220
  return {
130
221
  mode: 'new-task',
131
222
  startFrom: 0,
132
- cleanedArtifacts: [
133
- ...cleaned,
134
- ...(await this.cleanupStaleDeclared(declaredArtifacts)),
135
- ],
223
+ cleanedArtifacts: [...sweep.cleaned, ...sweepOrphan.cleaned],
224
+ archivedTo: sweep.archivedTo ?? sweepOrphan.archivedTo,
136
225
  };
137
226
  }
138
- // 模式 1: fresh —— 活动 + 归档都没有,首次运行。
139
- // 声明产物若已存在于磁盘 = 残留孤儿(如 /new 删状态目录后的旧 PLAN.md,
140
- // artifacts 清单随 current.json 一起被删,引擎无从知道它属于谁)。
141
- // 用户约定:全新任务开始时清理残留与中间文件——删除范围**仅限本工作流
142
- // 声明的产物**(与运行自身对它们的覆盖写权限一致,不碰用户其他文件)。
227
+ // 模式 1: fresh —— 完全没有任何任务实例,首次运行
228
+ const sweepF = await this.archiveThenClean(declaredArtifacts, `${Date.now()}-orphan`);
143
229
  await this.cleanSlate(name, inputs, identity.hash, declaredArtifacts, fp);
144
230
  return {
145
231
  mode: 'fresh',
146
232
  startFrom: 0,
147
- cleanedArtifacts: await this.cleanupStaleDeclared(declaredArtifacts),
233
+ cleanedArtifacts: sweepF.cleaned,
234
+ archivedTo: sweepF.archivedTo,
148
235
  };
149
236
  }
150
237
  // 3. 有活动状态(上次中断 / 失败 / 进行中,未归档)—— 用 hash 判同/异任务
@@ -188,22 +275,21 @@ export class WorkflowState {
188
275
  // 模式 2: new-task —— 不同任务,清旧声明产物 + 旧状态后全新开始。
189
276
  // legacy 状态文件无 artifacts 字段时:仅当同名 workflow 用本次声明清单兜底
190
277
  //(不同名 workflow 的产物不是我们的,不能殃及)。
191
- const cleaned = await this.cleanupDeclaredArtifacts(oldState.artifacts ?? (oldState.name === name ? declaredArtifacts : []));
278
+ const sweep = await this.archiveThenClean(oldState.artifacts ?? (oldState.name === name ? declaredArtifacts : []), `${Date.now()}-${oldState.taskId.slice(0, 8)}`);
279
+ const sweepStale = await this.archiveThenClean(declaredArtifacts, `${Date.now()}-orphan-${identity.hash.slice(0, 8)}`);
192
280
  await this.cleanSlate(name, inputs, identity.hash, declaredArtifacts, fp);
193
281
  return {
194
282
  mode: 'new-task',
195
283
  startFrom: 0,
196
- cleanedArtifacts: [
197
- ...cleaned,
198
- ...(await this.cleanupStaleDeclared(declaredArtifacts)),
199
- ],
284
+ cleanedArtifacts: [...sweep.cleaned, ...sweepStale.cleaned],
285
+ archivedTo: sweep.archivedTo ?? sweepStale.archivedTo,
200
286
  };
201
287
  }
202
288
  // 4. 同一任务 —— 检查完成状态(兜底:正常成功会 archive,故活动目录 done 少见)
203
289
  if (oldState.status === 'done') {
204
290
  return {
205
291
  mode: 'completed',
206
- artifacts: this.existingArtifacts(oldState.artifacts ?? declaredArtifacts),
292
+ artifacts: await this.existingArtifactsAsync(oldState.artifacts ?? declaredArtifacts),
207
293
  };
208
294
  }
209
295
  // 4.5 定义结构变更守卫:同任务但 yaml 结构改过(增删步/换 id/调嵌套)——
@@ -213,16 +299,15 @@ export class WorkflowState {
213
299
  // runner 经 defChanged 旗标向用户明示。legacy 状态(无 defFingerprint
214
300
  // 字段)无从验证兼容性,同样按变更处理(一次性升级成本,安全优先)。
215
301
  if (oldState.defFingerprint !== fp) {
216
- const cleaned = await this.cleanupDeclaredArtifacts(oldState.artifacts ?? declaredArtifacts);
302
+ const sweep = await this.archiveThenClean(oldState.artifacts ?? declaredArtifacts, `${Date.now()}-${oldState.taskId.slice(0, 8)}`);
303
+ const sweepStale = await this.archiveThenClean(declaredArtifacts, `${Date.now()}-orphan-${identity.hash.slice(0, 8)}`);
217
304
  await this.cleanSlate(name, inputs, identity.hash, declaredArtifacts, fp);
218
305
  return {
219
306
  mode: 'new-task',
220
307
  startFrom: 0,
221
308
  defChanged: true,
222
- cleanedArtifacts: [
223
- ...cleaned,
224
- ...(await this.cleanupStaleDeclared(declaredArtifacts)),
225
- ],
309
+ cleanedArtifacts: [...sweep.cleaned, ...sweepStale.cleaned],
310
+ archivedTo: sweep.archivedTo ?? sweepStale.archivedTo,
226
311
  };
227
312
  }
228
313
  // 5. 模式 4: resume —— 同一任务未完成,扫描 checkpoint 决定恢复点。
@@ -462,23 +547,337 @@ export class WorkflowState {
462
547
  return result;
463
548
  }
464
549
  // ---- 私有方法:声明式产物生命周期 ----
550
+ /**
551
+ * 把声明产物里的 glob 模式(`research/04-deep-dive-*.md`)展开成 cwd 下实际
552
+ * 匹配的文件相对路径列表;非 glob 路径原样保留。越界路径被过滤(fail-closed)。
553
+ * 支持段内 `*` / `?`(不跨目录分隔符);声明里写 glob 却一个文件都不匹配时
554
+ * 该声明被丢弃(与旧行为一致:不存在的产物不参与清理/报告)。
555
+ */
556
+ async expandDeclaredArtifacts(paths) {
557
+ const out = [];
558
+ for (const p of paths) {
559
+ const safe = safeArtifactPath(this.workingDir, p);
560
+ if (safe === null)
561
+ continue;
562
+ if (!/[*?]/.test(safe)) {
563
+ out.push(safe);
564
+ continue;
565
+ }
566
+ const norm = safe.replace(/\\/g, '/');
567
+ const segs = norm.split('/');
568
+ const fullRe = new RegExp(`^${segs.map(artifactSegToPattern).join('/')}$`);
569
+ out.push(...(await this.walkGlob(this.workingDir, segs, fullRe, '')));
570
+ }
571
+ return out;
572
+ }
573
+ /** 逐段匹配目录树收集文件;dir 读失败(不存在/无权限)返回空。 */
574
+ async walkGlob(absDir, segs, fullRe, relPrefix) {
575
+ const [head, ...rest] = segs;
576
+ if (!head)
577
+ return [];
578
+ let entries;
579
+ try {
580
+ entries = await readdir(absDir, { withFileTypes: true });
581
+ }
582
+ catch {
583
+ return [];
584
+ }
585
+ const headRe = new RegExp(`^${artifactSegToPattern(head)}$`);
586
+ const results = [];
587
+ for (const e of entries) {
588
+ if (!headRe.test(e.name))
589
+ continue;
590
+ const rel = relPrefix ? `${relPrefix}/${e.name}` : e.name;
591
+ if (rest.length === 0) {
592
+ if (e.isFile() && fullRe.test(rel))
593
+ results.push(rel);
594
+ }
595
+ else if (e.isDirectory()) {
596
+ results.push(...(await this.walkGlob(join(absDir, e.name), rest, fullRe, rel)));
597
+ }
598
+ }
599
+ return results;
600
+ }
601
+ /**
602
+ * 清理前把【实际存在】的声明产物移入回收目录 `<状态目录>.trash/<stamp>/`(保留
603
+ * 相对路径结构),返回回收目录(相对 cwd 表示,便于 UI 展示);无可归档文件或
604
+ * 一个都没归档成功时返回 undefined(此时后续 rm 兜底直接删,不能谎报归档位置--
605
+ * 用户会按提示去回收站找文件)。单个 move 失败不阻断(残留交给 rm 兜底)。
606
+ * 回收目录是状态目录的 sibling:cleanSlate/cleanup 只重建 `<name>/`,
607
+ * `.trash/` 跨任务留存,直到用户手动清理(如保留最近 N 次的淘汰策略,暂未实现)。
608
+ */
609
+ async archiveDeclaredArtifacts(existingRels, stamp) {
610
+ if (existingRels.length === 0)
611
+ return undefined;
612
+ const trashRoot = join(this.dir + TRASH_SUFFIX, stamp);
613
+ let archived = 0;
614
+ for (const rel of existingRels) {
615
+ const dest = join(trashRoot, rel);
616
+ try {
617
+ await mkdir(dirname(dest), { recursive: true });
618
+ await rename(join(this.workingDir, rel), dest);
619
+ archived++;
620
+ }
621
+ catch {
622
+ // 单个归档失败不阻断,残留交给后续 rm 兜底
623
+ }
624
+ }
625
+ if (archived === 0)
626
+ return undefined;
627
+ const display = relative(this.workingDir, trashRoot);
628
+ return display.startsWith('..') ? trashRoot : display;
629
+ }
465
630
  /** 删除工作流【声明】的产物文件(相对 cwd),用于 new-task 清理旧任务残留(如旧 PLAN.md)。
466
- * 仅删工作流自己声明的产物、仅 force-rm 文件,scoped 安全。返回清理过的路径列表。 */
631
+ * 仅删工作流自己声明的产物(glob 声明先展开为实际文件)、仅 force-rm 文件,
632
+ * **并强制路径在 cwd 内(防 ../ 跳出删用户家目录)**。
633
+ * 返回真正执行清理的路径列表(展开后的实际文件相对路径;越界路径被过滤掉,fail-closed)。 */
467
634
  async cleanupDeclaredArtifacts(paths) {
468
- for (const p of paths) {
469
- await rm(join(this.workingDir, p), { force: true }).catch(() => { });
635
+ const done = [];
636
+ for (const rel of await this.expandDeclaredArtifacts(paths)) {
637
+ const abs = join(this.workingDir, rel);
638
+ if (!existsSync(abs))
639
+ continue;
640
+ try {
641
+ await rm(abs, { force: true });
642
+ done.push(rel);
643
+ }
644
+ catch {
645
+ // 单个删除失败不影响整体(与原实现 .catch(() => {}) 一致)
646
+ }
470
647
  }
471
- return paths;
648
+ return done;
472
649
  }
473
- /** 从声明产物路径里过滤出 cwd 下【实际存在】的文件(completed 报真实产物用)。 */
474
- existingArtifacts(paths) {
475
- return paths.filter((p) => existsSync(join(this.workingDir, p)));
650
+ /** 从声明产物路径里过滤出 cwd 下【实际存在且路径安全落在 cwd 内】的文件
651
+ * (completed 报真实产物用;glob 声明先展开;越界声明不报告,视为不存在)。 */
652
+ async existingArtifactsAsync(paths) {
653
+ const expanded = await this.expandDeclaredArtifacts(paths);
654
+ return expanded.filter((rel) => existsSync(join(this.workingDir, rel)));
476
655
  }
477
- /** 清理【实际存在】的本次声明产物(fresh/new-task 启动时清残留孤儿),返回真正删掉的路径。 */
478
- async cleanupStaleDeclared(declaredArtifacts) {
479
- return this.cleanupDeclaredArtifacts(this.existingArtifacts(declaredArtifacts));
656
+ /**
657
+ * 「先归档再清理」组合:把【实际存在】的声明产物移入 .trash 回收目录,
658
+ * 归档后残留(rename 失败者)由 cleanup 兜底 rm。返回清理列表与回收目录。
659
+ * fresh/new-task 启动清理统一走这里--直接 rm 旧产物曾在实战中造成不可找回的损失
660
+ * (LLM 仲裁误判 new-task,同主题调研的全部报告被静默删除)。
661
+ */
662
+ async archiveThenClean(paths, stamp) {
663
+ const existing = await this.existingArtifactsAsync(paths);
664
+ const archivedTo = await this.archiveDeclaredArtifacts(existing, stamp);
665
+ const cleaned = await this.cleanupDeclaredArtifacts(paths);
666
+ return { cleaned, archivedTo };
480
667
  }
481
668
  // ---- 公共方法:归档与清理(保持原有签名兼容) ----
669
+ /**
670
+ * 确定当前任务的实例目录路径。
671
+ * Phase 3:实例目录 = <wfDir>/<sessionId>/(session 即任务实例)。
672
+ * isSameTask=true 时若旧布局(wfDir 本身)存在则沿用(兼容升级前状态)。
673
+ */
674
+ resolveTaskDir(_taskId, isSameTask) {
675
+ if (isSameTask) {
676
+ const existing = this.findExistingTaskDir(_taskId);
677
+ if (existing)
678
+ return existing;
679
+ }
680
+ return join(this.wfDir, this.sessionId);
681
+ }
682
+ /**
683
+ * 找当前 session 对应的现存活动实例目录(含旧布局兼容)。
684
+ * 找不到返回 null。
685
+ */
686
+ findExistingTaskDir(_taskId) {
687
+ // Phase 3:当前 session 的实例目录
688
+ const sessionDir = join(this.wfDir, this.sessionId);
689
+ if (existsSync(sessionDir))
690
+ return sessionDir;
691
+ // 旧布局兼容:wfDir 本身就是实例目录
692
+ if (existsSync(join(this.wfDir, 'current.json'))) {
693
+ try {
694
+ const entries = readdirSync(this.wfDir, { withFileTypes: true });
695
+ const hasInstanceSubdir = entries.some((e) => e.isDirectory() && /^[0-9a-f]{16}$/.test(e.name));
696
+ if (!hasInstanceSubdir)
697
+ return this.wfDir;
698
+ }
699
+ catch {
700
+ return this.wfDir;
701
+ }
702
+ }
703
+ return null;
704
+ }
705
+ /**
706
+ * 扫描当前 session 是否有任何任务实例(活动态或 .done 归档态,不包括 .trash)。
707
+ * 用于 isSameTask=false 时区分 fresh(从未跑过)和 new-task(有其他旧任务)。
708
+ */
709
+ hasAnyTaskInstance() {
710
+ if (!existsSync(this.wfDir))
711
+ return false;
712
+ // Phase 3:当前 session 的实例(活动 / .done)
713
+ if (existsSync(join(this.wfDir, this.sessionId)))
714
+ return true;
715
+ if (existsSync(join(this.wfDir, `${this.sessionId}${DONE_SUFFIX}`)))
716
+ return true;
717
+ // 旧布局:wfDir 本身就是实例(current.json 直接在 wfDir 下)
718
+ if (existsSync(join(this.wfDir, 'current.json')))
719
+ return true;
720
+ if (existsSync(join(this.wfDir + DONE_SUFFIX, 'current.json')))
721
+ return true;
722
+ return false;
723
+ }
724
+ /**
725
+ * 扫描 wfDir 下所有 .done 归档,返回第一个(也是最新的,按修改时间排序)的 current.json。
726
+ * 用于 completed 检测的兜底——当 hash 不匹配但 wfDir 下有归档时,判断是否是同名 workflow 的旧归档。
727
+ */
728
+ findLatestArchivedState() {
729
+ return this.findArchivedState(() => true);
730
+ }
731
+ /**
732
+ * 在 wfDir 下找当前 session 对应的归档目录(.done)里的 current.json。
733
+ * 支持新布局(wfDir/<sessionId>.done/)和旧布局(wfDir.done/)。
734
+ */
735
+ findArchivedStateByTaskId(_taskId) {
736
+ return this.findArchivedState((name, isLegacy) => isLegacy || name === `${this.sessionId}${DONE_SUFFIX}`);
737
+ }
738
+ async findArchivedState(predicate) {
739
+ // 旧布局:wfDir.done/current.json
740
+ const legacyDone = this.wfDir + DONE_SUFFIX;
741
+ if (existsSync(join(legacyDone, 'current.json')) && predicate('', true)) {
742
+ try {
743
+ const raw = await readFile(join(legacyDone, 'current.json'), 'utf-8');
744
+ return JSON.parse(raw);
745
+ }
746
+ catch {
747
+ // fall through
748
+ }
749
+ }
750
+ // 新布局:wfDir/<taskId>.done/current.json
751
+ if (!existsSync(this.wfDir))
752
+ return null;
753
+ try {
754
+ const entries = readdirSync(this.wfDir, { withFileTypes: true });
755
+ const doneDirs = entries
756
+ .filter((e) => e.isDirectory() && e.name.endsWith(DONE_SUFFIX))
757
+ .map((e) => e.name)
758
+ .filter((name) => predicate(name, false))
759
+ .sort();
760
+ for (const name of doneDirs) {
761
+ try {
762
+ const raw = await readFile(join(this.wfDir, name, 'current.json'), 'utf-8');
763
+ return JSON.parse(raw);
764
+ }
765
+ catch {
766
+ continue;
767
+ }
768
+ }
769
+ }
770
+ catch {
771
+ // ignore
772
+ }
773
+ return null;
774
+ }
775
+ /**
776
+ * 收集【当前 session】的任务实例(活动态 + .done 归档态)的声明产物路径,
777
+ * 用于 new-task 时归档+清理旧任务的产物。去重后返回。
778
+ * Phase 3:只收集当前 session 的实例产物——不同 session 的产物各归各,
779
+ * new-task 绝不殃及其他 session 的产出。
780
+ */
781
+ async collectAllInstanceArtifacts(workflowName, fallback) {
782
+ const seen = new Set();
783
+ const allArtifacts = [];
784
+ const pushAll = (arr) => {
785
+ for (const a of arr ?? []) {
786
+ if (!seen.has(a)) {
787
+ seen.add(a);
788
+ allArtifacts.push(a);
789
+ }
790
+ }
791
+ };
792
+ // Phase 3:当前 session 的活动实例与 .done 归档
793
+ for (const dirName of [this.sessionId, `${this.sessionId}${DONE_SUFFIX}`]) {
794
+ const dir = join(this.wfDir, dirName);
795
+ if (!existsSync(dir))
796
+ continue;
797
+ try {
798
+ const raw = await readFile(join(dir, 'current.json'), 'utf-8');
799
+ const s = JSON.parse(raw);
800
+ if (s.name === workflowName)
801
+ pushAll(s.artifacts);
802
+ }
803
+ catch {
804
+ // ignore
805
+ }
806
+ }
807
+ // 旧布局兼容:wfDir 本身有 current.json(且无子实例目录)或 wfDir.done/
808
+ if (!existsSync(this.wfDir)) {
809
+ const legacyDone = this.wfDir + DONE_SUFFIX;
810
+ try {
811
+ const raw = await readFile(join(legacyDone, 'current.json'), 'utf-8');
812
+ const s = JSON.parse(raw);
813
+ if (s.name === workflowName)
814
+ pushAll(s.artifacts);
815
+ }
816
+ catch {
817
+ // ignore
818
+ }
819
+ }
820
+ else {
821
+ try {
822
+ const entries = readdirSync(this.wfDir, { withFileTypes: true });
823
+ const hasInstanceSubdir = entries.some((e) => e.isDirectory() && /^[0-9a-f]{16}/.test(e.name));
824
+ if (!hasInstanceSubdir && existsSync(join(this.wfDir, 'current.json'))) {
825
+ try {
826
+ const raw = await readFile(join(this.wfDir, 'current.json'), 'utf-8');
827
+ const s = JSON.parse(raw);
828
+ if (s.name === workflowName)
829
+ pushAll(s.artifacts);
830
+ }
831
+ catch {
832
+ // ignore
833
+ }
834
+ }
835
+ }
836
+ catch {
837
+ // ignore
838
+ }
839
+ }
840
+ return allArtifacts.length > 0 ? allArtifacts : fallback;
841
+ }
842
+ /**
843
+ * 找当前 session 的活动态实例(未完成),返回其目录 + current.json。
844
+ * 用于 isSameTask=false 时的仲裁——拿到旧任务的 state 做二审。
845
+ * 找不到返回 null。
846
+ */
847
+ async findLatestActiveState() {
848
+ // Phase 3:当前 session 的活动实例 <wfDir>/<sessionId>/
849
+ const sessionDir = join(this.wfDir, this.sessionId);
850
+ if (existsSync(sessionDir)) {
851
+ try {
852
+ const raw = await readFile(join(sessionDir, 'current.json'), 'utf-8');
853
+ const state = JSON.parse(raw);
854
+ if (state.status !== 'done') {
855
+ return { dir: sessionDir, state };
856
+ }
857
+ }
858
+ catch {
859
+ // fall through
860
+ }
861
+ }
862
+ // 旧布局兼容:wfDir 本身是实例目录(current.json 直接在其下)
863
+ if (existsSync(join(this.wfDir, 'current.json'))) {
864
+ try {
865
+ const entries = readdirSync(this.wfDir, { withFileTypes: true });
866
+ const hasInstanceSubdir = entries.some((e) => e.isDirectory() && /^[0-9a-f]{16}/.test(e.name));
867
+ if (!hasInstanceSubdir) {
868
+ const raw = await readFile(join(this.wfDir, 'current.json'), 'utf-8');
869
+ const state = JSON.parse(raw);
870
+ if (state.status !== 'done') {
871
+ return { dir: this.wfDir, state };
872
+ }
873
+ }
874
+ }
875
+ catch {
876
+ // fall through
877
+ }
878
+ }
879
+ return null;
880
+ }
482
881
  /**
483
882
  * 归档(成功完成时调用):重命名状态目录为 .done 后缀,保留所有中间产物。
484
883
  * vars.json / progress.md / inputs.json / checkpoints/ 全部保留——供后续对话 Read 回溯。
@@ -498,7 +897,9 @@ export class WorkflowState {
498
897
  // rename 失败时静默(某些文件系统/权限问题),保留原目录
499
898
  }
500
899
  }
501
- /** 强制清理(/new 或新任务 init 时调用):删除状态目录(含 .done 归档) */
900
+ /** 强制清理(/new 或新任务 init 时调用):删除当前任务实例目录(含 .done 归档)。
901
+ * 注意:.trash 回收目录不在这里删——它是产物清理前的归档安全网,
902
+ * 跨任务保留,由用户或后续淘汰策略管理。 */
502
903
  async cleanup() {
503
904
  await rm(this.dir, { recursive: true, force: true }).catch(() => { });
504
905
  await rm(this.dir + DONE_SUFFIX, { recursive: true, force: true }).catch(() => { });