minimal-agent 0.8.2 → 0.9.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 (55) hide show
  1. package/README.md +4 -5
  2. package/package.json +1 -1
  3. package/plugins/workflow-runner/plugin.js +1 -0
  4. package/plugins/workflow-runner/src/agentCommon.js +82 -47
  5. package/plugins/workflow-runner/src/expressions.js +48 -2
  6. package/plugins/workflow-runner/src/index.js +1 -0
  7. package/plugins/workflow-runner/src/jsonPayload.js +1 -1
  8. package/plugins/workflow-runner/src/loader.js +70 -8
  9. package/plugins/workflow-runner/src/recovery.js +43 -35
  10. package/plugins/workflow-runner/src/runner.js +143 -34
  11. package/plugins/workflow-runner/src/stepExecutors/agent.js +64 -35
  12. package/plugins/workflow-runner/src/stepExecutors/assert.js +14 -2
  13. package/plugins/workflow-runner/src/stepExecutors/llm.js +48 -107
  14. package/plugins/workflow-runner/src/stepExecutors/skill.js +50 -39
  15. package/plugins/workflow-runner/src/stepExecutors/tool.js +20 -7
  16. package/plugins/workflow-runner/src/stepExecutors/verify.js +11 -3
  17. package/plugins/workflow-runner/src/taskIdentity.js +141 -0
  18. package/plugins/workflow-runner/src/types.js +1 -2
  19. package/plugins/workflow-runner/src/workflowState.js +211 -28
  20. package/skills/adversarial-review/SKILL.md +0 -1
  21. package/skills/pptx/scripts/office/__pycache__/soffice.cpython-314.pyc +0 -0
  22. package/src/askBridge.js +10 -0
  23. package/src/context/archive.js +4 -0
  24. package/src/infra/verification.js +25 -13
  25. package/src/llm/client.js +160 -12
  26. package/src/loop.js +15 -12
  27. package/src/plugins/pluginLoader.js +2 -1
  28. package/src/plugins/pluginRunner.js +9 -21
  29. package/src/prompts/pluginList.js +5 -5
  30. package/src/prompts/promptAssembly.js +3 -38
  31. package/src/prompts/skillList.js +1 -1
  32. package/src/prompts/system.js +0 -1
  33. package/src/ui/AskQuestion.js +20 -3
  34. package/src/ui/InputBox.js +70 -3
  35. package/src/ui/hooks/useChat.js +13 -0
  36. package/workflows/adaptive-research.yaml +4 -3
  37. package/workflows/book-review-short.yaml +5 -3
  38. package/workflows/e2e-write-greet.yaml +3 -2
  39. package/workflows/plan.yaml +219 -168
  40. package/workflows/ralph-loop.yaml +169 -0
  41. package/workflows/youtube-shorts.yaml +8 -5
  42. package/plugins/HOW-TO-WRITE-A-PLUGIN.md +0 -186
  43. package/plugins/ralph-wiggum/.claude-plugin/plugin.json +0 -9
  44. package/plugins/ralph-wiggum/README.md +0 -197
  45. package/plugins/ralph-wiggum/commands/cancel-ralph.md +0 -18
  46. package/plugins/ralph-wiggum/commands/help.md +0 -137
  47. package/plugins/ralph-wiggum/commands/ralph-loop.md +0 -85
  48. package/plugins/ralph-wiggum/hooks/hooks.json +0 -15
  49. package/plugins/ralph-wiggum/hooks/stop-hook.sh +0 -191
  50. package/plugins/ralph-wiggum/plugin.js +0 -280
  51. package/plugins/ralph-wiggum/src/goalState.js +0 -258
  52. package/plugins/ralph-wiggum/src/sentinels.js +0 -76
  53. package/plugins/ralph-wiggum/src/stopHookRunner.js +0 -104
  54. package/plugins/ralph-wiggum/src/verificationGate.js +0 -202
  55. package/plugins/workflow-runner/src/miniReAct.js +0 -223
@@ -1,28 +1,24 @@
1
- // NOTE: output_schema 不支持 skill 节点——loader 现已直接拒绝 skill+output_schema 组合
2
- // (见 loader.ts validateSteps,P3 修复:静默 no-op 改为加载期报错)。
3
- // 仅 llm 节点支持 provider-native structured output;vote 通过 runVoter 调 execLlmStep(llm 虚拟步骤)。
4
1
  /**
5
2
  * ============================================================
6
- * plugins/workflow-runner/src/stepExecutors/skill.ts —— skill step 执行
3
+ * plugins/workflow-runner/src/stepExecutors/skill.ts —— skill 步骤执行器
7
4
  * ------------------------------------------------------------
8
- * 把一个具名 skill(skills/<name>/SKILL.md)当作"迷你 ReAct 子循环"
9
- * 跑一次:复用 src/loop.ts::runQuery,让模型在子 history 内 T-A-O-R。
5
+ * v0.9 重构:不再使用独立 subHistory 隔离子循环,而是委托主 TAOR 引擎
6
+ * (runQuery / src/loop.ts)在共享 ctx.history 上执行。
10
7
  *
11
8
  * 实现要点:
12
- * - 双源查找:cwd/skills/<name>/SKILL.md 优先,packageRoot/skills/ fallback;
13
- * 支持按目录名 or 按 frontmatter::name 匹配
14
- * - 剥掉 frontmatter,body 作 system 指令;step.input 插值后作为 user
15
- * - 子 history 与外层 ctx.history 隔离,避免污染主对话
16
- * - 流式转发:runQuery yield 的事件直接 yield 出去(让 UI 看到子循环进度)
17
- * - 收集 text 事件累计为 raw.text,供 capture 绑变量使用
18
- * - maxTurns 可在 step 里覆盖,默认走 runQuery 自带(50)
9
+ * - 双源查找:cwd/skills/<name>/SKILL.md 优先,packageRoot/skills/ fallback
10
+ * - SKILL.md body 作为用户提示的一部分(系统提示词已在 history[0])
11
+ * - 执行结果直接写入共享 ctx.history,上下文自然积累
12
+ * - 流式转发:runQuery yield 的事件直接透传给 UI
13
+ * - maxTurns 可在 step 里覆盖(默认 25)
19
14
  *
20
15
  * capture:{ text: var_name } / { result: var_name }
21
16
  * ============================================================
22
17
  */
23
18
  import { readdir, readFile } from 'node:fs/promises';
24
19
  import { join } from 'node:path';
25
- import { getResourceSearchPaths, runQuery, } from '../../../../src/plugin-sdk.js';
20
+ import { runQuery } from '../../../../src/loop.js';
21
+ import { getResourceSearchPaths } from '../../../../src/plugin-sdk.js';
26
22
  import { interpolate } from '../expressions.js';
27
23
  function stripFrontmatter(content) {
28
24
  return content.replace(/^---\n[\s\S]*?\n---\n?/, '');
@@ -36,8 +32,11 @@ function parseFrontmatterName(content) {
36
32
  return null;
37
33
  return nm[1].trim().replace(/^["']|["']$/g, '');
38
34
  }
39
- async function findSkillFile(skillName) {
35
+ async function findSkillFile(skillName, cwd) {
40
36
  const roots = getResourceSearchPaths('skills', import.meta.url);
37
+ // 优先搜索 cwd/skills(工作目录下的 skills 优先于包内 skills)
38
+ const cwdSkills = join(cwd, 'skills');
39
+ roots.unshift(cwdSkills);
41
40
  for (const root of roots) {
42
41
  let entries;
43
42
  try {
@@ -58,7 +57,7 @@ async function findSkillFile(skillName) {
58
57
  return skillFile;
59
58
  }
60
59
  catch {
61
- // 该子目录无 SKILL.md,跳过
60
+ continue;
62
61
  }
63
62
  }
64
63
  }
@@ -68,43 +67,55 @@ export async function* execSkillStep(step, vars, ctx) {
68
67
  if (!step.skill)
69
68
  throw new Error(`step ${step.id}: 缺少 skill 字段`);
70
69
  const skillName = interpolate(step.skill, vars);
71
- const skillFile = await findSkillFile(skillName);
70
+ const skillFile = await findSkillFile(skillName, ctx.cwd);
72
71
  if (!skillFile) {
73
72
  throw new Error(`step ${step.id}: 未找到 skill "${skillName}"(cwd/skills/ 与 packageRoot/skills/ 双源均未命中)`);
74
73
  }
75
74
  const md = await readFile(skillFile, 'utf8');
76
75
  const body = stripFrontmatter(md).trim();
76
+ // 构造用户提示:skill 指令 + 步骤 input
77
+ // 系统提示词已在 history[0] 中(含完整 skill 列表和描述),
78
+ // 这里不需要再拼 system message——把 SKILL.md body 作为操作指令即可
77
79
  const userInput = step.input
78
80
  ? interpolate(step.input, vars)
79
- : `请按 SKILL "${skillName}" 的指示完成任务。`;
80
- const subHistory = [
81
- {
82
- role: 'system',
83
- content: `下面是要遵循的 SKILL 指示(来源:${skillFile}):\n\n${body}`,
84
- },
85
- ];
86
- let text = '';
87
- for await (const ev of runQuery(userInput, {
81
+ : `请按以下 SKILL "${skillName}" 的指示完成任务。`;
82
+ const fullPrompt = `【执行 Skill:${skillName}】\n\n${body}\n\n---\n\n当前任务:\n${userInput}`;
83
+ // 通用目标锚定:确保 skill 执行时也不偏离原始目标
84
+ const effectivePrompt = ctx.goalAnchor
85
+ ? `${ctx.goalAnchor}\n\n${fullPrompt}`
86
+ : fullPrompt;
87
+ const maxTurns = step.maxTurns ?? 25;
88
+ // 记录 history 长度用于提取本步输出
89
+ const historyLenBefore = ctx.history.length;
90
+ // 委托 TAOR 引擎在共享 history 上执行
91
+ // 注意:需要过滤掉 turn_done 事件(它是 runQuery 内部的终结标记,
92
+ // 不应外泄为 workflow 的终结——workflow 的收口由 index.ts 统一处理)
93
+ for await (const ev of runQuery(effectivePrompt, {
88
94
  provider: ctx.provider,
89
- history: subHistory,
95
+ history: ctx.history,
90
96
  signal: ctx.signal,
91
- maxTurns: step.maxTurns,
97
+ maxTurns,
98
+ chat: ctx.chat,
99
+ executeTool: ctx.executeTool,
100
+ sessionState: ctx.sessionState,
92
101
  })) {
93
- if (ev.type === 'text')
94
- text += ev.delta;
95
- if (ev.type === 'interrupted')
96
- throw new Error('用户中断');
97
- if (ev.type === 'error')
98
- throw new Error(ev.error);
99
- // 子 skill 内部那次 runQuery 的 turn_done 是它自己的终结标记,不是整个 workflow 的终结。
100
- // 不外泄——否则把 turn_done 当终结的消费者(-p --output-format json / gateway broker)
101
- // 会在 workflow 中途(如 critic 步之后、verify 步之前)误判结束、漏掉后续步骤。
102
- // workflow 的对外收口由 index.ts::runWorkflowFromCommand 末尾统一补发。
102
+ // turn_done 是 runQuery 单次调用的终结标记,不是 workflow 终结。
103
+ // 外泄会导致 gateway broker / -p json 模式误判 workflow 中途结束。
103
104
  if (ev.type === 'turn_done')
104
105
  continue;
106
+ // error / interrupted 应该冒泡让 runner 处理
105
107
  yield ev;
106
108
  }
107
- const trimmed = text.trim();
109
+ // 从 history 提取本步最后一条 assistant 消息
110
+ let rawText = '';
111
+ for (let i = ctx.history.length - 1; i >= historyLenBefore; i--) {
112
+ const msg = ctx.history[i];
113
+ if (msg.role === 'assistant' && typeof msg.content === 'string' && msg.content.trim()) {
114
+ rawText = msg.content;
115
+ break;
116
+ }
117
+ }
118
+ const trimmed = rawText.trim();
108
119
  return {
109
120
  raw: { text: trimmed, result: trimmed },
110
121
  preview: trimmed.length > 200 ? `${trimmed.slice(0, 200)}...` : trimmed,
@@ -7,10 +7,12 @@
7
7
  *
8
8
  * v2(P1 TUI 可见):本执行器改为 AsyncGenerator,在调 executeTool 前后
9
9
  * yield 通用 tool_start / tool_end LoopEvent —— 这样 workflow 的 tool 步骤
10
- * 在 TUI 里像普通 ReAct 工具调用一样显示(runningTools spinner + 人话描述),
11
- * 不再"无声"。**不** yield tool_messages_committed:workflow 步骤不进主 history,
12
- * 其 live 态由 useChat 的 submit finally(resetTurnState) 在事件流末尾统一清理;
13
- * tool_end 自身已把该 callId 从 runningTools 里摘除,无需额外 commit 语义。
10
+ * 在 TUI 里像普通 ReAct 工具调用一样显示(runningTools spinner + 人话描述)。
11
+ *
12
+ * v0.9.3(上下文统一修复):tool 步骤执行后把工具调用摘要写入 ctx.history,
13
+ * 让后续 agent/llm/skill 步骤(走 runQuery → LLM)能自然看到前面步骤的工作成果,
14
+ * 不再依赖 VarStack 变量插值作为唯一的信息传递通道。
15
+ * 这实现了"一套上下文"架构:workflow 的每一步都在共享的 TAOR history 中留痕。
14
16
  *
15
17
  * capture 字段(支持点路径):
16
18
  * - `{ content: var_name }` 绑 ToolResult.content(工具回执文本)
@@ -26,7 +28,7 @@ export async function* execToolStep(step, vars, ctx) {
26
28
  throw new Error(`step ${step.id}: 缺少 tool 字段`);
27
29
  const tool = getToolByName(step.tool);
28
30
  if (!tool)
29
- throw new Error(`step ${step.id}: 未知 tool "${step.tool}"`);
31
+ throw new Error(`step ${step.id}: 未知工具 "${step.tool}"`);
30
32
  const args = interpolateDeep(step.args ?? {}, vars);
31
33
  const argsJson = JSON.stringify(args);
32
34
  const toolCallId = `wf_${step.id}`;
@@ -50,6 +52,19 @@ export async function* execToolStep(step, vars, ctx) {
50
52
  throw new Error(result.error);
51
53
  }
52
54
  const content = result.content;
55
+ // ════ v0.9.3:上下文统一 —— 把工具调用写入共享 history ════
56
+ // 后续 agent/llm/skill 步骤走 runQuery() 时,LLM 能在上下文中看到
57
+ // 这步做了什么(与正常 TAOR 循环中 tool_call → tool_result 的记录方式一致)。
58
+ // 只记录精简摘要(不把完整工具回执塞入 history,避免 token 浪费)。
59
+ const summary = content.length > 500
60
+ ? `${content.slice(0, 500)}…(已截断,共 ${content.length} 字符)`
61
+ : content;
62
+ ctx.history.push({
63
+ role: 'assistant',
64
+ content: `[workflow:${step.id}] ✅ ${tool.name} 执行完成:${summary}`,
65
+ timestamp: Date.now(),
66
+ id: crypto.randomUUID(),
67
+ });
53
68
  return {
54
69
  raw: { content, result: content, args },
55
70
  preview: content.length > 200 ? `${content.slice(0, 200)}...` : content,
@@ -57,8 +72,6 @@ export async function* execToolStep(step, vars, ctx) {
57
72
  }
58
73
  /**
59
74
  * 给 tool_start 生成人话描述(LiveArea spinner 行用)。
60
- * 取常见入参键(file_path/command/query/...)拼成 "Write greetings/a/hello.txt"
61
- * 这样的短句;都没命中就回退到工具名本身。
62
75
  */
63
76
  function friendlyToolDesc(toolName, args) {
64
77
  const key = ['file_path', 'command', 'query', 'pattern', 'path', 'url'].find((k) => typeof args[k] === 'string');
@@ -13,12 +13,20 @@
13
13
  import { parseCheckString, runVerification } from '../../../../src/infra/verification.js';
14
14
  import { interpolate } from '../expressions.js';
15
15
  export async function execVerifyStep(step, vars) {
16
- if (!Array.isArray(step.checks) || step.checks.length === 0) {
17
- throw new Error(`step ${step.id}: verify 缺少 checks 数组`);
16
+ // 兼容两种格式:YAML 数组 或逗号分隔字符串
17
+ let rawChecks;
18
+ if (Array.isArray(step.checks) && step.checks.length > 0) {
19
+ rawChecks = step.checks;
20
+ }
21
+ else if (typeof step.checks === 'string' && step.checks.trim() !== '') {
22
+ rawChecks = step.checks.split(',').map((s) => s.trim()).filter(Boolean);
23
+ }
24
+ else {
25
+ throw new Error(`step ${step.id}: verify 缺少 checks(需要数组或逗号分隔字符串)`);
18
26
  }
19
27
  // 解析并插值每条 check 字符串
20
28
  const checks = [];
21
- for (const raw of step.checks) {
29
+ for (const raw of rawChecks) {
22
30
  const interpolated = interpolate(raw, vars);
23
31
  const check = parseCheckString(interpolated);
24
32
  if (!check) {
@@ -0,0 +1,141 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ /**
5
+ * 默认时间窗口:30 分钟内同名 workflow 视为同任务(毫秒)
6
+ */
7
+ const DEFAULT_TIME_WINDOW_MS = 30 * 60 * 1000;
8
+ /**
9
+ * 计算任务身份 hash。
10
+ *
11
+ * 策略(双重信号):
12
+ * 1. 内容 hash:对 goal 做归一化后 SHA256 → 取前 16 位
13
+ * 2. 时间窗口:读取 .minimal-agent-workflow/current.json 的 startedAt,
14
+ * 如果在窗口期内且 workflow 名相同 → 强制视为同一任务(覆盖 hash 差异)
15
+ *
16
+ * 归一化规则:
17
+ * - 转小写
18
+ * - 去除所有标点符号(保留中文、英文、数字、空格)
19
+ * - 去除多余空格(连续空格→单空格,首尾 trim)
20
+ * - 截断到 200 字符(防止超长输入导致不必要的差异)
21
+ */
22
+ export function computeTaskId(workflowName, inputs, workingDir) {
23
+ // 1. 提取 goal 文本(与 buildGoalAnchor 同策略)
24
+ const rawGoal = extractGoal(inputs);
25
+ // 2. 归一化 goal
26
+ const normalizedGoal = normalizeGoal(rawGoal);
27
+ // 3. 计算 contentHash = SHA256(`${workflowName}::${normalizedGoal}`)[:16]
28
+ const contentHash = createHash('sha256')
29
+ .update(`${workflowName}::${normalizedGoal}`)
30
+ .digest('hex')
31
+ .slice(0, 16);
32
+ // 4. 如果没有提供 workingDir,只返回 hash,不做比较
33
+ if (!workingDir) {
34
+ return {
35
+ hash: contentHash,
36
+ isSameTask: false,
37
+ reason: 'no_previous',
38
+ };
39
+ }
40
+ // 5. 尝试读取旧状态文件
41
+ const stateFilePath = join(workingDir, '.minimal-agent-workflow', 'current.json');
42
+ if (!existsSync(stateFilePath)) {
43
+ return {
44
+ hash: contentHash,
45
+ isSameTask: false,
46
+ reason: 'no_previous',
47
+ };
48
+ }
49
+ try {
50
+ const stateContent = readFileSync(stateFilePath, 'utf-8');
51
+ const prevState = JSON.parse(stateContent);
52
+ // a. 不同 workflow → 不同任务
53
+ if (prevState.name && prevState.name !== workflowName) {
54
+ return {
55
+ hash: contentHash,
56
+ isSameTask: false,
57
+ reason: 'different_workflow',
58
+ };
59
+ }
60
+ // b. 检查时间窗口
61
+ if (prevState.startedAt) {
62
+ const startedAt = new Date(prevState.startedAt).getTime();
63
+ const now = Date.now();
64
+ const timeDiff = now - startedAt;
65
+ // 在时间窗口内 → 强制视为同一任务(即使 hash 不同)
66
+ if (timeDiff >= 0 && timeDiff <= DEFAULT_TIME_WINDOW_MS) {
67
+ return {
68
+ hash: contentHash,
69
+ isSameTask: true,
70
+ reason: 'time_window',
71
+ };
72
+ }
73
+ }
74
+ // c. 时间窗口过期但 taskId 与当前 hash 匹配
75
+ if (prevState.taskId === contentHash) {
76
+ return {
77
+ hash: contentHash,
78
+ isSameTask: true,
79
+ reason: 'hash_match',
80
+ };
81
+ }
82
+ // d. 时间窗口过期且 hash 不匹配
83
+ return {
84
+ hash: contentHash,
85
+ isSameTask: false,
86
+ reason: 'expired',
87
+ };
88
+ }
89
+ catch (error) {
90
+ // 解析失败时返回默认结果
91
+ return {
92
+ hash: contentHash,
93
+ isSameTask: false,
94
+ reason: 'no_previous',
95
+ };
96
+ }
97
+ }
98
+ /**
99
+ * 从 inputs 中提取 goal 文本
100
+ *
101
+ * 策略:
102
+ * - 优先 inputs.goal
103
+ * - 其次第一个 length > 2 的 string 值
104
+ * - 最后 JSON.stringify 整个 inputs
105
+ */
106
+ function extractGoal(inputs) {
107
+ // 优先级1:直接使用 inputs.goal
108
+ if (typeof inputs.goal === 'string') {
109
+ return inputs.goal;
110
+ }
111
+ // 优先级2:查找第一个长度 > 2 的字符串值
112
+ for (const value of Object.values(inputs)) {
113
+ if (typeof value === 'string' && value.length > 2) {
114
+ return value;
115
+ }
116
+ }
117
+ // 优先级3:JSON 序列化整个 inputs
118
+ return JSON.stringify(inputs);
119
+ }
120
+ /**
121
+ * 归一化 goal 字符串
122
+ *
123
+ * 规则:
124
+ * - toLowerCase()
125
+ * - 去除所有标点符号(保留中文、英文、数字、空格)
126
+ * - 去除多余空格(连续空格→单空格)
127
+ * - 首尾 trim
128
+ * - 截断到 200 字符
129
+ */
130
+ function normalizeGoal(raw) {
131
+ return (raw
132
+ .toLowerCase()
133
+ // 保留中文、英文、数字、空格,去除其他字符(标点等)
134
+ .replace(/[^\p{L}\p{N}\s]/gu, '')
135
+ // 连续空格替换为单空格
136
+ .replace(/\s+/g, ' ')
137
+ // 首尾去空格
138
+ .trim()
139
+ // 截断到 200 字符
140
+ .slice(0, 200));
141
+ }
@@ -2,8 +2,7 @@
2
2
  * ============================================================
3
3
  * src/workflows/types.ts —— workflow runner 核心类型
4
4
  * ------------------------------------------------------------
5
- * ralph-loop 让 LLM 自规划;workflow 反过来 —— 外层确定性 step 列表
6
- * 驱动执行,LLM 只在 llm: / skill: 节点出场。本文件定义 YAML 解析
5
+ * 外层确定性 step 列表驱动执行,LLM 只在 llm: / skill: / agent: 节点出场。
7
6
  * 后的内存形态(WorkflowDef / StepDef),以及运行时上下文(VarStack /
8
7
  * RunContext)和 UI 事件(WorkflowEvent)。
9
8
  *
@@ -1,56 +1,212 @@
1
1
  /**
2
2
  * ============================================================
3
- * src/workflows/workflowState.ts —— workflow 运行状态持久化
3
+ * plugins/workflow-runner/src/workflowState.ts —— 工作流状态持久化
4
4
  * ------------------------------------------------------------
5
- * 与 GoalState 类似的轻量目录模型,但只用于 debug / 中断恢复(P0 不实现
6
- * 恢复,只写出现场)。目录结构:
5
+ * Phase 1 升级:支持断点续执行、任务身份判定、capture 回放、产物管理。
7
6
  *
7
+ * 核心变更:
8
+ * - init() 不再无条件清空,而是先读旧状态 → 判定任务身份 → 分发 4 种模式
9
+ * - 新增 checkpoints/ 目录记录每步完成状态和 capture 变量
10
+ * - 新增 artifacts/ 目录管理产出物
11
+ * - resume 模式时回放已完成步骤的 capture 到 vars 中
12
+ *
13
+ * 目录结构:
8
14
  * <cwd>/.minimal-agent-workflow/
9
- * ├── current.json # { name, startedAt }
15
+ * ├── current.json # { name, taskId, status, startedAt, lastFailedStep?, lastError? }
10
16
  * ├── inputs.json # 用户传入的 inputs 快照
11
17
  * ├── vars.json # 最后一次成功 step 后的 VarStack.snapshot()
12
- * └── progress.md # 时间戳追加的 step 完成日志
13
- *
14
- * init() 进 workflow 前 reset 一次;cleanup() 在 finally 把文件删掉
15
- * + rmdir(保持 /new 扫描 .minimal-agent-* 子目录的语义一致)。
16
- *
17
- * 与 GoalState 的核心区别:workflow 不需要 PHASE / completion / learnings 文件,
18
- * 因为执行流程是确定性的,没有 LLM 自规划过程。
18
+ * ├── progress.md # 时间戳追加的 step 完成日志
19
+ * ├── checkpoints/ # 每步一个 JSON:{ stepId, completedAt, output, captures, varsSnapshot }
20
+ * └── artifacts/ # 产出物文件(PLAN.md 等)
19
21
  * ============================================================
20
22
  */
21
- import { mkdir, writeFile, appendFile, rm, rename } from 'node:fs/promises';
23
+ import { mkdir, writeFile, appendFile, rm, rename, readFile, readdir } from 'node:fs/promises';
22
24
  import { join } from 'node:path';
25
+ import { computeTaskId } from './taskIdentity.js';
23
26
  const STATE_DIR = '.minimal-agent-workflow';
24
27
  const DONE_SUFFIX = '.done';
28
+ const CHECKPOINTS_DIR = 'checkpoints';
29
+ const ARTIFACTS_DIR = 'artifacts';
25
30
  export class WorkflowState {
26
31
  dir;
27
32
  constructor(workingDir) {
28
33
  this.dir = join(workingDir, STATE_DIR);
29
34
  }
30
- async init(name, inputs) {
31
- // 启动新 workflow 时清理旧状态(含之前的归档)
32
- await rm(this.dir, { recursive: true, force: true }).catch(() => { });
33
- await rm(this.dir + DONE_SUFFIX, { recursive: true, force: true }).catch(() => { });
34
- await mkdir(this.dir, { recursive: true });
35
- await writeFile(join(this.dir, 'current.json'), JSON.stringify({ name, startedAt: new Date().toISOString() }, null, 2), 'utf8');
36
- await writeFile(join(this.dir, 'inputs.json'), JSON.stringify(inputs, null, 2), 'utf8');
37
- await writeFile(join(this.dir, 'progress.md'), `# ${name}\n\n`, 'utf8');
35
+ /**
36
+ * 初始化工作流状态 —— 核心入口,返回恢复信息。
37
+ *
38
+ * 4 种模式:
39
+ * 1. fresh — 首次运行此工作流(无旧状态)
40
+ * 2. new-task — 不同任务(hash 或时间窗口不匹配)→ 清理旧产物后全新开始
41
+ * 3. completed — 同一任务已完成 → 返回产物列表
42
+ * 4. resume — 同一任务未完成 → 扫描 checkpoint 决定恢复点
43
+ */
44
+ async init(name, inputs, steps) {
45
+ // 1. 计算当前任务的 identity
46
+ const workingDir = this.dir.replace(`/${STATE_DIR}`, '') || process.cwd();
47
+ const identity = computeTaskId(name, inputs, workingDir);
48
+ // 2. 尝试读取旧 current.json
49
+ const oldState = await this.readCurrentState();
50
+ if (!oldState) {
51
+ // 模式 1: fresh —— 无旧状态,首次运行
52
+ await this.cleanSlate(name, inputs, identity.hash);
53
+ return { mode: 'fresh', startFrom: 0 };
54
+ }
55
+ // 3. 用 computeTaskId 的 isSameTask 结果判断
56
+ if (!identity.isSameTask) {
57
+ // 模式 2: new-task —— 不同任务,清理旧产物后全新开始
58
+ const artifacts = await this.listArtifacts();
59
+ await this.cleanupArtifacts(artifacts);
60
+ await this.cleanSlate(name, inputs, identity.hash);
61
+ return { mode: 'new-task', cleanedArtifacts: artifacts, startFrom: 0 };
62
+ }
63
+ // 4. 同一任务 —— 检查完成状态
64
+ if (oldState.status === 'done') {
65
+ // 模式 3: completed —— 同一任务已完成
66
+ const artifacts = await this.listArtifacts();
67
+ return { mode: 'completed', artifacts };
68
+ }
69
+ // 5. 模式 4: resume —— 同一任务未完成,扫描 checkpoint 决定恢复点
70
+ const completedSteps = await this.scanCheckpoints(steps);
71
+ const selfHealBackoff = 1; // 默认回退 1 步,runner 可通过 WorkflowDef.self_heal.backoff 覆盖
72
+ const recoveryStart = Math.max(0, completedSteps.length - selfHealBackoff);
73
+ // 加载 capturedVars 用于回放
74
+ const capturedVars = await this.loadAllCaptures(completedSteps);
75
+ return {
76
+ mode: 'resume',
77
+ startFrom: recoveryStart,
78
+ completedStepIds: completedSteps,
79
+ failedStep: oldState.lastFailedStep,
80
+ recoveryStart,
81
+ lastError: oldState.lastError,
82
+ capturedVars,
83
+ };
84
+ }
85
+ // ---- 公共方法:checkpoint / 失败 / 完成 ----
86
+ /** 记录单个步骤成功完成(含 capture 变量和 vars 快照) */
87
+ async recordCheckpoint(stepId, output, captures, varsSnapshot) {
88
+ const cpDir = join(this.dir, CHECKPOINTS_DIR);
89
+ await mkdir(cpDir, { recursive: true }).catch(() => { });
90
+ const data = {
91
+ stepId,
92
+ completedAt: new Date().toISOString(),
93
+ output,
94
+ captures,
95
+ varsSnapshot,
96
+ };
97
+ await writeFile(join(cpDir, `${stepId}.json`), JSON.stringify(data, null, 2), 'utf-8');
38
98
  }
99
+ /** 记录步骤失败:更新 current.json 的 lastFailedStep 和 lastError */
100
+ async recordFailure(stepId, error) {
101
+ const current = await this.readCurrentState();
102
+ if (current) {
103
+ current.lastFailedStep = stepId;
104
+ current.lastError = error;
105
+ current.status = 'failed';
106
+ await this.writeCurrent(current);
107
+ }
108
+ }
109
+ /** 标记整个工作流完成 */
110
+ async markDone() {
111
+ const current = await this.readCurrentState();
112
+ if (current) {
113
+ current.status = 'done';
114
+ await this.writeCurrent(current);
115
+ }
116
+ }
117
+ // ---- 公共方法:进度与变量(保持原有签名兼容) ----
118
+ /** 追加进度日志 */
39
119
  async appendProgress(line) {
40
120
  const ts = new Date().toISOString().replace('T', ' ').slice(0, 19);
41
- await appendFile(join(this.dir, 'progress.md'), `- ${ts} ${line}\n`, 'utf8').catch(() => { });
121
+ await appendFile(join(this.dir, 'progress.md'), `- ${ts} ${line}\n`, 'utf-8').catch(() => { });
42
122
  }
123
+ /** 写入 vars 快照 */
43
124
  async writeVars(snapshot) {
44
- await writeFile(join(this.dir, 'vars.json'), JSON.stringify(snapshot, null, 2), 'utf8').catch(() => { });
125
+ await writeFile(join(this.dir, 'vars.json'), JSON.stringify(snapshot, null, 2), 'utf-8').catch(() => { });
45
126
  }
127
+ /**
128
+ * 扫描 checkpoints/ 目录,返回按 steps 数组顺序排列的已完成 step id 列表。
129
+ * 只返回在 steps[] 中存在的 id(防止 YAML 改动后 orphaned checkpoint 干扰恢复)。
130
+ */
131
+ async scanCheckpoints(steps) {
132
+ const cpDir = join(this.dir, CHECKPOINTS_DIR);
133
+ const validIds = new Set(steps.map((s) => s.id));
134
+ try {
135
+ const files = await readdir(cpDir);
136
+ const ids = [];
137
+ for (const f of files) {
138
+ if (!f.endsWith('.json'))
139
+ continue;
140
+ const stepId = f.replace(/\.json$/, '');
141
+ if (validIds.has(stepId))
142
+ ids.push(stepId);
143
+ }
144
+ // 按 steps 数组中的原始顺序排列(非文件系统顺序)
145
+ return ids.sort((a, b) => {
146
+ const ai = steps.findIndex((s) => s.id === a);
147
+ const bi = steps.findIndex((s) => s.id === b);
148
+ return ai - bi;
149
+ });
150
+ }
151
+ catch {
152
+ return [];
153
+ }
154
+ }
155
+ /** 加载最近一次成功的 vars 快照 */
156
+ async loadLastGoodVars() {
157
+ try {
158
+ const raw = await readFile(join(this.dir, 'vars.json'), 'utf-8');
159
+ return JSON.parse(raw);
160
+ }
161
+ catch {
162
+ return null;
163
+ }
164
+ }
165
+ /** 加载所有已完成步骤的 capture 变量(用于 resume 时回放到 VarStack) */
166
+ async loadAllCaptures(stepIds) {
167
+ const result = {};
168
+ const cpDir = join(this.dir, CHECKPOINTS_DIR);
169
+ for (const sid of stepIds) {
170
+ try {
171
+ const raw = await readFile(join(cpDir, `${sid}.json`), 'utf-8');
172
+ const cp = JSON.parse(raw);
173
+ if (cp.captures && Object.keys(cp.captures).length > 0) {
174
+ result[sid] = cp.captures;
175
+ }
176
+ }
177
+ catch {
178
+ // 单个 checkpoint 读失败不阻断整体
179
+ }
180
+ }
181
+ return result;
182
+ }
183
+ // ---- 公共方法:产物管理 ----
184
+ /** 列出 artifacts/ 目录中的产物文件路径 */
185
+ async listArtifacts() {
186
+ const artDir = join(this.dir, ARTIFACTS_DIR);
187
+ try {
188
+ const files = await readdir(artDir);
189
+ return files.filter((f) => f !== '.gitkeep');
190
+ }
191
+ catch {
192
+ return [];
193
+ }
194
+ }
195
+ /** 清理指定的产物文件 */
196
+ async cleanupArtifacts(paths) {
197
+ const artDir = join(this.dir, ARTIFACTS_DIR);
198
+ for (const p of paths) {
199
+ await rm(join(artDir, p), { force: true }).catch(() => { });
200
+ }
201
+ }
202
+ // ---- 公共方法:归档与清理(保持原有签名兼容) ----
46
203
  /**
47
204
  * 归档(成功完成时调用):重命名状态目录为 .done 后缀,保留所有中间产物。
48
- * vars.json / progress.md / inputs.json 全部保留——供后续对话 Read 回溯。
205
+ * vars.json / progress.md / inputs.json / checkpoints/ 全部保留——供后续对话 Read 回溯。
49
206
  */
50
207
  async archive() {
51
208
  const doneDir = this.dir + DONE_SUFFIX;
52
209
  try {
53
- // 先清理旧的归档(如果存在)
54
210
  await rm(doneDir, { recursive: true, force: true });
55
211
  }
56
212
  catch {
@@ -63,12 +219,39 @@ export class WorkflowState {
63
219
  // rename 失败时静默(某些文件系统/权限问题),保留原目录
64
220
  }
65
221
  }
66
- /**
67
- * 强制清理(/new 或新 workflow init 时调用):删除状态目录(含 .done 归档)。
68
- */
222
+ /** 强制清理(/new 或新任务 init 时调用):删除状态目录(含 .done 归档) */
69
223
  async cleanup() {
70
224
  await rm(this.dir, { recursive: true, force: true }).catch(() => { });
71
- // 同时清理可能的归档目录
72
225
  await rm(this.dir + DONE_SUFFIX, { recursive: true, force: true }).catch(() => { });
73
226
  }
227
+ // ---- 私有方法 ----
228
+ /** 创建全新的空白状态目录(含 checkpoints/ 和 artifacts/ 子目录) */
229
+ async cleanSlate(name, inputs, taskId) {
230
+ await this.cleanup();
231
+ await mkdir(this.dir, { recursive: true });
232
+ await mkdir(join(this.dir, CHECKPOINTS_DIR), { recursive: true });
233
+ await mkdir(join(this.dir, ARTIFACTS_DIR), { recursive: true });
234
+ await this.writeCurrent({
235
+ name,
236
+ taskId,
237
+ status: 'running',
238
+ startedAt: new Date().toISOString(),
239
+ });
240
+ await writeFile(join(this.dir, 'inputs.json'), JSON.stringify(inputs, null, 2), 'utf-8');
241
+ await writeFile(join(this.dir, 'progress.md'), `# ${name}\n\n`, 'utf-8');
242
+ }
243
+ /** 读取 current.json,解析失败或文件不存在返回 null */
244
+ async readCurrentState() {
245
+ try {
246
+ const raw = await readFile(join(this.dir, 'current.json'), 'utf-8');
247
+ return JSON.parse(raw);
248
+ }
249
+ catch {
250
+ return null;
251
+ }
252
+ }
253
+ /** 写入 current.json */
254
+ async writeCurrent(data) {
255
+ await writeFile(join(this.dir, 'current.json'), JSON.stringify(data, null, 2), 'utf-8');
256
+ }
74
257
  }
@@ -1,7 +1,6 @@
1
1
  ---
2
2
  name: adversarial-review
3
3
  description: 质疑一个结论 / 计划 / 既有评审 / 设计方案是否站得住脚——默认怀疑、逼出证据,把发现分成「真问题 / 误报 / 待澄清」。注意:若用户只是想给代码找冗余 / 反模式 / 做常规质量改进,用 /simplify 而非本 skill。
4
- triggers: 对抗审查、反向提问、红队、挑战这个结论、验证某结论或方案是否站得住脚、哪些是误报哪些是真问题、审稿、复核这个判断
5
4
  ---
6
5
 
7
6
  # /adversarial-review —— 对抗式审查(反向提问,默认怀疑)