minimal-agent 0.1.9 → 0.3.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 (97) hide show
  1. package/README.md +383 -122
  2. package/package.json +19 -12
  3. package/plugins/HOW-TO-WRITE-A-PLUGIN.md +186 -0
  4. package/plugins/ralph-wiggum/commands/ralph-loop.md +6 -16
  5. package/plugins/ralph-wiggum/plugin.js +205 -0
  6. package/plugins/ralph-wiggum/src/goalState.js +260 -0
  7. package/plugins/ralph-wiggum/src/sentinels.js +21 -0
  8. package/plugins/ralph-wiggum/src/stopHookRunner.js +104 -0
  9. package/plugins/ralph-wiggum/src/verificationGate.js +202 -0
  10. package/plugins/workflow-runner/.claude-plugin/plugin.json +5 -0
  11. package/plugins/workflow-runner/commands/workflow.md +15 -0
  12. package/plugins/workflow-runner/commands/workflows.md +8 -0
  13. package/plugins/workflow-runner/plugin.js +36 -0
  14. package/plugins/workflow-runner/src/expressions.js +369 -0
  15. package/plugins/workflow-runner/src/index.js +174 -0
  16. package/plugins/workflow-runner/src/loader.js +183 -0
  17. package/plugins/workflow-runner/src/runner.js +290 -0
  18. package/plugins/workflow-runner/src/stepExecutors/assert.js +28 -0
  19. package/plugins/workflow-runner/src/stepExecutors/llm.js +44 -0
  20. package/plugins/workflow-runner/src/stepExecutors/skill.js +103 -0
  21. package/plugins/workflow-runner/src/stepExecutors/tool.js +35 -0
  22. package/plugins/workflow-runner/src/types.js +59 -0
  23. package/plugins/workflow-runner/src/workflowState.js +46 -0
  24. package/skills/image-gen-openrouter/SKILL.md +121 -0
  25. package/skills/subtitle-srt/SKILL.md +134 -0
  26. package/skills/tts-zh/SKILL.md +137 -0
  27. package/skills/video-compose/SKILL.md +139 -0
  28. package/src/bootstrap/cwdArg.js +22 -0
  29. package/src/bootstrap/workingDir.js +31 -0
  30. package/src/cli/configWizard.js +272 -0
  31. package/src/cli/print.js +192 -0
  32. package/src/config/configFile.js +78 -0
  33. package/src/config.js +118 -0
  34. package/src/context/compact.js +357 -0
  35. package/src/context/microCompactLite.js +151 -0
  36. package/src/context/persistContext.js +109 -0
  37. package/src/context/reactiveCompact.js +121 -0
  38. package/src/context/sessionPath.js +58 -0
  39. package/src/context/snipCompact.js +112 -0
  40. package/src/context/tokenCounter.js +66 -0
  41. package/src/llm/client.js +182 -0
  42. package/src/loop.js +230 -0
  43. package/src/main.js +116 -0
  44. package/src/plugin-sdk.js +24 -0
  45. package/src/plugins/commandRouter.js +169 -0
  46. package/src/plugins/hookEngine.js +258 -0
  47. package/src/plugins/pluginApi.js +23 -0
  48. package/src/plugins/pluginLoader.js +71 -0
  49. package/src/plugins/pluginRunner.js +65 -0
  50. package/src/plugins/transcript.js +171 -0
  51. package/src/prompts/projectInstructions.js +48 -0
  52. package/src/prompts/skillList.js +126 -0
  53. package/src/prompts/system.js +155 -0
  54. package/src/session/runTurn.js +41 -0
  55. package/src/session/sessionState.js +19 -0
  56. package/src/tools/bash/bash.js +352 -0
  57. package/src/tools/bash/semantics.js +85 -0
  58. package/src/tools/bash/warnings.js +98 -0
  59. package/src/tools/edit/edit.js +253 -0
  60. package/src/tools/edit/multi-edit.js +155 -0
  61. package/src/tools/glob/glob.js +97 -0
  62. package/src/tools/grep/grep.js +185 -0
  63. package/src/tools/grep/rgPath.js +173 -0
  64. package/src/tools/index.js +94 -0
  65. package/src/tools/read/read.js +209 -0
  66. package/src/tools/shared/fileState.js +61 -0
  67. package/src/tools/shared/fileUtils.js +281 -0
  68. package/src/tools/shared/schemas.js +16 -0
  69. package/src/tools/types.js +21 -0
  70. package/src/tools/webbrowser/browser.js +55 -0
  71. package/src/tools/webbrowser/webbrowser.js +194 -0
  72. package/src/tools/webfetch/preapproved.js +267 -0
  73. package/src/tools/webfetch/webfetch.js +317 -0
  74. package/src/tools/websearch/websearch.js +161 -0
  75. package/src/tools/write/write.js +125 -0
  76. package/src/types/turndown.d.ts +23 -0
  77. package/src/types.js +16 -0
  78. package/src/ui/App.js +37 -0
  79. package/src/ui/InputBox.js +240 -0
  80. package/src/ui/MessageList.js +28 -0
  81. package/src/ui/Root.js +70 -0
  82. package/src/ui/StatusLine.js +41 -0
  83. package/src/ui/ToolStatus.js +11 -0
  84. package/src/ui/hooks/useChat.js +234 -0
  85. package/src/ui/hooks/usePasteHandler.js +137 -0
  86. package/src/ui/hooks/useTextBuffer.js +55 -0
  87. package/src/ui/hooks/useTokenUsage.js +30 -0
  88. package/src/ui/textBuffer.js +217 -0
  89. package/src/utils/packageRoot.js +37 -0
  90. package/src/utils/resourcePaths.js +49 -0
  91. package/src/utils/zodToJson.js +29 -0
  92. package/workflows/book-review-short.yaml +99 -0
  93. package/workflows/e2e-write-greet.yaml +27 -0
  94. package/workflows/schema.json +74 -0
  95. package/workflows/youtube-shorts.yaml +171 -0
  96. package/dist/main.js +0 -5936
  97. package/plugins/ralph-wiggum/scripts/setup-ralph-loop.sh +0 -203
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "minimal-agent",
3
- "version": "0.1.9",
4
- "description": "最小化 Agent 系统 —— 单对话 + 9 工具 + 自动压缩 + OpenAI 兼容 + Ink TUI(学习/教学用)",
3
+ "version": "0.3.0",
4
+ "description": "最小化 Agent 系统 —— 10 工具 + 插件系统 + workflow DSL + 自动压缩 + OpenAI 兼容 + Ink TUI;NodeNext + tsc 原地编译,dev 用 Bun .ts、install 用 Node .js(学习/教学用)",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "author": "Bill Wang <leiwang0359@gmail.com>",
7
7
  "repository": {
@@ -27,33 +27,41 @@
27
27
  ],
28
28
  "type": "module",
29
29
  "bin": {
30
- "minimal-agent": "dist/main.js"
30
+ "minimal-agent": "src/main.js"
31
31
  },
32
- "main": "dist/main.js",
33
32
  "engines": {
34
33
  "node": ">=20"
35
34
  },
36
35
  "files": [
37
- "dist",
38
- "vendor/ripgrep",
39
- "skills",
40
- "plugins",
36
+ "src/**/*.js",
37
+ "src/**/*.d.ts",
38
+ "plugins/**/*.js",
39
+ "plugins/**/*.d.ts",
40
+ "plugins/**/.claude-plugin/**",
41
+ "plugins/**/commands/**",
42
+ "plugins/**/hooks/**",
43
+ "plugins/HOW-TO-WRITE-A-PLUGIN.md",
44
+ "skills/**",
45
+ "workflows/**",
46
+ "vendor/ripgrep/**",
41
47
  "README.md",
42
48
  "LICENSE"
43
49
  ],
44
50
  "scripts": {
45
51
  "start": "bun run src/main.tsx",
46
52
  "dev": "bun --watch src/main.tsx",
47
- "build": "tsup",
53
+ "build": "tsc --build",
54
+ "clean": "bun scripts/clean-build.ts",
48
55
  "test": "bun test",
49
- "typecheck": "tsc --noEmit",
50
- "prepublishOnly": "npm run build"
56
+ "typecheck": "tsc --noEmit -p tsconfig.typecheck.json",
57
+ "prepublishOnly": "bun run clean && bun run build"
51
58
  },
52
59
  "dependencies": {
53
60
  "fast-glob": "^3.3.2",
54
61
  "ink": "^5.0.1",
55
62
  "react": "^18.3.1",
56
63
  "turndown": "^7.2.4",
64
+ "yaml": "^2.9.0",
57
65
  "zod": "^3.23.8",
58
66
  "zod-to-json-schema": "^3.25.2"
59
67
  },
@@ -61,7 +69,6 @@
61
69
  "@types/node": "^20.14.0",
62
70
  "@types/react": "^18.3.0",
63
71
  "bun-types": "^1.3.13",
64
- "tsup": "^8.5.1",
65
72
  "typescript": "^5.5.0"
66
73
  }
67
74
  }
@@ -0,0 +1,186 @@
1
+ # How to Write a minimal-agent Plugin
2
+
3
+ minimal-agent 的插件系统遵循 **drop-in** 原则:把目录扔进 `plugins/`,命令立即可用,**`src/` 一行不改**。
4
+
5
+ ## 两种契约
6
+
7
+ ### 1. 纯声明式(Anthropic 兼容,零 TS)
8
+
9
+ 最小可用插件 = 一个 frontmatter 模板。框架替你拼 prompt 喂给 LLM。
10
+
11
+ ```
12
+ plugins/hello-bot/
13
+ ├── .claude-plugin/
14
+ │ └── plugin.json
15
+ └── commands/
16
+ └── hi.md
17
+ ```
18
+
19
+ **`.claude-plugin/plugin.json`**
20
+
21
+ ```json
22
+ {
23
+ "name": "hello-bot",
24
+ "version": "0.1.0",
25
+ "description": "Greets the user"
26
+ }
27
+ ```
28
+
29
+ **`commands/hi.md`**
30
+
31
+ ```markdown
32
+ ---
33
+ description: "Greet the user"
34
+ argument-hint: "<name>"
35
+ ---
36
+
37
+ 你好 $ARGUMENTS!欢迎使用 minimal-agent。
38
+ ```
39
+
40
+ **效果**:用户输入 `/hi 世界` → LLM 收到 `你好 世界!欢迎使用 minimal-agent。\n\n用户参数: 世界`。
41
+
42
+ 支持的占位符:
43
+ - `$ARGUMENTS` / `${ARGUMENTS}` —— 用户在命令名后的全部文本
44
+ - `${CLAUDE_PLUGIN_ROOT}` —— 该插件目录的绝对路径(用于引用插件资源)
45
+
46
+ ### 2. 声明式 + `plugin.ts`(富插件)
47
+
48
+ 当模板不够(要跑循环 / 做 TS 校验 / 启动子进程 / 状态机),加一个 `plugin.ts`:
49
+
50
+ ```
51
+ plugins/echo-bot/
52
+ ├── .claude-plugin/plugin.json
53
+ ├── commands/echo.md # 仍要存在!frontmatter 用于 listAvailableCommands
54
+ └── plugin.ts # PluginApi default export
55
+ ```
56
+
57
+ **`plugin.ts`**
58
+
59
+ ```typescript
60
+ import {
61
+ runQuery,
62
+ type PluginApi,
63
+ type LoopEvent,
64
+ } from '../../src/plugin-sdk.ts';
65
+
66
+ const api: PluginApi = {
67
+ async *runCommand(commandName, args, ctx): AsyncGenerator<LoopEvent> {
68
+ if (commandName === 'echo') {
69
+ // 直接 yield 自定义事件
70
+ yield { type: 'text', delta: `你输入了:${args}` };
71
+ yield { type: 'turn_done' };
72
+ return;
73
+ }
74
+
75
+ // 或者改写 prompt 后丢给 LLM
76
+ yield* runQuery(`请把以下内容重复 3 遍:${args}`, {
77
+ provider: ctx.provider,
78
+ history: ctx.history,
79
+ signal: ctx.signal,
80
+ });
81
+ },
82
+ };
83
+
84
+ export default api;
85
+ ```
86
+
87
+ **框架行为**:命中 `/echo abc` 时,`pluginRunner` 优先调用 `runCommand('echo', 'abc', ctx)`,不再走声明式 fallback。你完全接管事件流。
88
+
89
+ ## `plugin-sdk.ts` 暴露的全部 API
90
+
91
+ **唯一稳定 import 路径**:`from '../../src/plugin-sdk.ts'`(相对你的 `plugin.ts`)。
92
+
93
+ | 名称 | 类型 | 用途 |
94
+ |------|------|------|
95
+ | `runQuery(input, opts)` | function | 标准 T-A-O-R 循环,AsyncGenerator |
96
+ | `chat(messages, provider, opts)` | function | 单次非流式 LLM 调用 |
97
+ | `executeTool(name, argsJson, ctx)` | function | 框架级工具调用入口 |
98
+ | `getToolByName(name)` | function | 取 Tool 定义 |
99
+ | `ALL_TOOLS` | const | 所有内置工具数组 |
100
+ | `getWorkingDir()` | function | 当前锁定的工作目录 |
101
+ | `getResourceSearchPaths(name, importMetaUrl)` | function | 双源资源路径(cwd + packageRoot) |
102
+ | `triggerHook(event, payload)` | function | 触发框架级 hook |
103
+ | `createSessionState()` | function | 新建 SessionState |
104
+ | `Message` / `Provider` / `LoopEvent` / `LlmStreamEvent` | type | 协议类型 |
105
+ | `Tool` / `ToolCall` / `ToolResult` | type | 工具协议类型 |
106
+ | `PluginApi` / `PluginContext` | type | 你的 plugin.ts 契约 |
107
+ | `ResourceName` / `HookEventName` / `HookDecision` / `SessionState` | type | 杂项 |
108
+
109
+ **不要**绕开 `plugin-sdk.ts` 直接 import `src/loop.ts` / `src/llm/client.ts` 等内部路径 —— 那些路径不是稳定 API,框架重构时会破坏你的插件。
110
+
111
+ ## `PluginContext`
112
+
113
+ `runCommand` 收到的 ctx:
114
+
115
+ ```typescript
116
+ interface PluginContext {
117
+ provider: Provider; // 当前 LLM provider 配置
118
+ history: Message[]; // 对话历史(你可以读 / 修改 / 替换)
119
+ signal?: AbortSignal; // 用户 ESC / Ctrl+C 时触发
120
+ sessionState?: SessionState; // 可选共享 session 状态
121
+ maxTurns?: number; // 工具循环最大轮数(runQuery 透传用)
122
+ }
123
+ ```
124
+
125
+ ## 事件 (`LoopEvent`)
126
+
127
+ 你的 `runCommand` 必须 yield `LoopEvent`,UI 据此渲染。常用的有:
128
+
129
+ | type | 字段 | 用途 |
130
+ |------|------|------|
131
+ | `text` | `delta: string` | 流式文本片段 |
132
+ | `assistant_message` | (无) | 一次完整 assistant 消息已 push 进 history |
133
+ | `tool_start` | `toolName`, `argsPreview` | 工具开始执行 |
134
+ | `tool_end` | (无) | 工具结束 |
135
+ | `turn_done` | (无) | 整轮交互结束 |
136
+ | `interrupted` | (无) | 用户中断 |
137
+ | `error` | `error: string` | 红色错误提示 |
138
+ | `plugin_progress` | `pluginId`, `current`, `max?`, `message?` | 循环进度条(替代旧的 plugin_start / plugin_iteration) |
139
+
140
+ 通常做法:复杂插件直接 `yield* runQuery(...)` 透传所有事件,自己额外 yield `plugin_progress` 做进度展示。
141
+
142
+ ## 工作目录与持久化
143
+
144
+ - 状态写到 `getWorkingDir()/.minimal-agent-<your-plugin-id>/` 下,避免污染主 context 目录
145
+ - `/new` 命令会自动清这些 `.minimal-agent*` 目录,你不用管 cleanup
146
+ - 跨平台:Windows 没 bash,hooks/*.sh 静默跳过;其它 .ts 代码完全平台无关
147
+
148
+ ## 命令默认值
149
+
150
+ 框架**不再**注入命令默认参数(旧版 `COMMAND_DEFAULTS` 已删除)。如果你的命令需要默认值(如 `--max-iterations 50`),自己在 `plugin.ts` 里解析 args 时兜底。这样默认值的语义属于插件契约,不属于框架。
151
+
152
+ ## 测试
153
+
154
+ - 把测试放在 `plugins/<id>/test/*.test.ts`
155
+ - `bunfig.toml` 的 `[test].root = ["./test", "./plugins"]` 让 `bun test` 自动发现
156
+ - 测试里 mock LLM / 工具时,用 `mock.module('../../../src/loop.ts', ...)` 等绝对到 src 的相对路径
157
+
158
+ ## 参考实现
159
+
160
+ - **声明式 + plugin.ts 简单例**:`plugins/workflow-runner/plugin.ts`(~25 行接管 `/workflow` 与 `/workflows`)
161
+ - **重型循环例**:`plugins/ralph-wiggum/plugin.ts`(~270 行 do-while loop + FSM + sentinel + verification + stop-hook)
162
+
163
+ ## Transcript 与 Hook:当前状态
164
+
165
+ 框架提供了两套基础设施 —— 都**就绪、有测试、可独立调用**,但**主循环 `src/loop.ts` 不自动触发**:
166
+
167
+ | 模块 | 暴露 | 当前主循环触发? |
168
+ |------|------|------|
169
+ | `src/plugins/transcript.ts` | `initTranscript / appendUserMessage / appendAssistantMessage / appendToolResult` | 否 |
170
+ | `src/plugins/hookEngine.ts` | `loadHooks / triggerHook('UserPromptSubmit' \| 'PreToolUse' \| 'PostToolUse' \| 'SessionStart' \| 'SessionEnd', payload)` | 否 |
171
+
172
+ **插件如何使用**:
173
+ - 想写自己的 JSONL transcript → 在 `runCommand` 内 `import { initTranscript, appendUserMessage } from '../../src/plugin-sdk.ts'` 自己调
174
+ - 想响应 hook 事件 → 在 `runCommand` 内 `triggerHook('PreToolUse', { tool_name, ... })` 自己调;ralph-wiggum 走的就是这条路(vendored stop-hook 执行器在 `plugins/ralph-wiggum/src/stopHookRunner.ts`)
175
+
176
+ **为什么不在主循环自动触发**:当前没有 Anthropic 风格 `hooks/*.sh` 的真实用例驱动;自动触发会改主循环热路径但不解决实际问题。等真有跨插件的全局 hook 需求时再接也不迟。届时只需在 `src/loop.ts` 的 user/assistant/tool 三处 push 点加 `appendXxx` 与 `triggerHook` 调用即可,**不影响插件契约**。
177
+
178
+ ## 不支持的 Anthropic 字段
179
+
180
+ minimal-agent 的插件契约是 Anthropic 上游的**子集**。当前未实现的字段:
181
+
182
+ - `mcp-servers/` —— MCP 服务器配置
183
+ - `agents/` —— 子 agent 定义
184
+ - `skills/` 在插件目录下 —— minimal-agent 的 skill 系统是独立顶层目录
185
+
186
+ 未实现字段在 plugin.json 中存在不会报错,但被忽略。
@@ -10,7 +10,7 @@ hide-from-slash-command-tool: "true"
10
10
  <!--
11
11
  minimal-agent 注意:以下 ```! ``` 块是 Claude Code 专有的 shell-on-command
12
12
  语法,minimal-agent **不会执行**它。GoalState 的初始化(goal.md / phase.md
13
- / completion.md 等)由 src/plugins/pluginRunner.ts 在进循环前自动完成,
13
+ / completion.md 等)由本插件的 plugin.ts 在进循环前自动完成,
14
14
  不需要 setup 脚本。该块保留在这里只是为了与原版 Claude Code 兼容。
15
15
  -->
16
16
 
@@ -26,22 +26,12 @@ CRITICAL RULE: If a completion promise is set, you may ONLY output it when the s
26
26
 
27
27
  ---
28
28
 
29
- ## Goal State 初始化(首次迭代必做)
29
+ ## 本插件管理的状态文件
30
30
 
31
- 在开始工作之前,请先执行以下步骤:
32
-
33
- 1. **确认目标文件**:检查 `.minimal-agent/goal.md` 是否已包含用户的原始目标。如未包含,将用户目标写入。
34
- 2. **生成完成判据**:在 `.minimal-agent/completion.md` 中写入 JSON 格式的客观验证条件(如果尚未存在或内容不完整):
35
- ```json
36
- [
37
- { "type": "shell", "command": "你的验证命令" },
38
- { "type": "file_exists", "file": "期望存在的文件路径" },
39
- { "type": "file_contains", "file": "文件路径", "pattern": "正则表达式" }
40
- ]
41
- ```
42
- 3. **设置阶段**:确认 `.minimal-agent/phase.md` 内容为 `plan`。
43
-
44
- > 以上文件由 GoalState 系统自动管理,每轮迭代开始时会自动注入到你的上下文头部。你只需要在第一轮确保它们的内容正确。
31
+ `.minimal-agent-ralph-wiggum/` 目录下的所有文件(goal.md / phase.md / completion.md /
32
+ progress.md / learnings.md / decisions.md)**由本插件自动创建并维护**,你不需要主动
33
+ 读写它们。每轮迭代开始时,插件会把当前目标、阶段、最近进度、关键决策、教训自动拼接到
34
+ 你的 prompt 头部(freshContext),你**专注按头部信息行动**即可。
45
35
 
46
36
  ## 阶段工作流
47
37
 
@@ -0,0 +1,205 @@
1
+ /**
2
+ * ============================================================
3
+ * plugins/ralph-wiggum/plugin.ts —— ralph-wiggum 真·插件入口
4
+ * ------------------------------------------------------------
5
+ * 把原 src/plugins/pluginRunner.ts 的 do-while 循环驱动整段搬来。
6
+ * 对外通过 PluginApi.runCommand 暴露 /ralph-loop。
7
+ *
8
+ * 循环契约:
9
+ * - 每轮把 history 重置成进入循环前的快照(fresh context)
10
+ * - 用 GoalState.composeContext() 拼 PLAN/BUILD/VERIFY/HEAL 阶段信息
11
+ * - 跑 runQuery
12
+ * - 检测 <promise>DONE</promise> → runVerification → 通过则退出
13
+ * - 检测 <PROMISE>NEED_REPLAN</PROMISE> → forceSetPhase(PLAN)
14
+ * - executeStopHook 是咨询式:block 才把 reason 注入下一轮,pass 不退出
15
+ *
16
+ * 终止(独占):sentinel + verify 通过 / 达 max-iterations / abort / 安全天花板
17
+ *
18
+ * Windows 上 hooks/stop-hook.sh 不可用,但循环不依赖 hook,功能完整,
19
+ * 只是 hook 的咨询通道失效。
20
+ * ============================================================
21
+ */
22
+ import { fileURLToPath } from 'node:url';
23
+ import { dirname } from 'node:path';
24
+ import { runQuery, getWorkingDir, } from '../../src/plugin-sdk.js';
25
+ import { GoalState, Phase } from './src/goalState.js';
26
+ import { parseVerifyArgs, runVerification } from './src/verificationGate.js';
27
+ import { hasCompleteSentinel, hasNeedReplanSentinel, } from './src/sentinels.js';
28
+ import { executeStopHook } from './src/stopHookRunner.js';
29
+ const PLUGIN_NAME = 'ralph-wiggum';
30
+ const DEFAULT_MAX_ITERATIONS = 50;
31
+ const SAFETY_CEILING = 200;
32
+ const PLUGIN_ROOT = dirname(fileURLToPath(import.meta.url));
33
+ function extractMaxIterations(args) {
34
+ const match = args.match(/--max-iterations\s+(\d+)/i);
35
+ return match ? parseInt(match[1], 10) : undefined;
36
+ }
37
+ async function* runRalphLoop(args, ctx) {
38
+ const { provider, history, signal } = ctx;
39
+ const maxIter = Math.min(extractMaxIterations(args) ?? DEFAULT_MAX_ITERATIONS, SAFETY_CEILING);
40
+ // 没有 args(用户只敲 /ralph-loop)→ 留个最小 goal placeholder,避免 GoalState.init 拒空串
41
+ const userGoal = args.trim() || '(未提供目标)';
42
+ yield {
43
+ type: 'plugin_progress',
44
+ pluginId: PLUGIN_NAME,
45
+ current: 0,
46
+ max: maxIter,
47
+ message: 'Ralph Wiggum copy-task loop 启动',
48
+ };
49
+ const checks = parseVerifyArgs(args);
50
+ // sessionTag = 插件名 → 多插件可并发不打架,/new 也能扫到清掉
51
+ const goalState = new GoalState(getWorkingDir(), PLUGIN_NAME);
52
+ await goalState.reset();
53
+ await goalState.init(userGoal, checks);
54
+ await goalState.appendProgress(`=== Loop 启动 === 目标: ${userGoal.slice(0, 120)}...`);
55
+ // 进循环前快照 history —— 每轮 runQuery 前用它重置,保证 fresh context
56
+ const baseHistory = history.slice();
57
+ let iterationCount = 0;
58
+ let consecutiveFailures = 0;
59
+ let currentInput = userGoal;
60
+ let finalAssistantMsg = null;
61
+ try {
62
+ do {
63
+ iterationCount++;
64
+ if (iterationCount > maxIter) {
65
+ await goalState.forceSetPhase(Phase.DONE, `达到迭代上限 ${maxIter}`);
66
+ await goalState.appendLearning(`[迭代上限] 循环在 ${iterationCount - 1} 轮后强制终止,可能目标过大或陷入死循环`);
67
+ yield {
68
+ type: 'error',
69
+ error: `Loop 已达迭代上限 ${maxIter},自动停止`,
70
+ };
71
+ return;
72
+ }
73
+ if (signal?.aborted) {
74
+ yield { type: 'interrupted' };
75
+ return;
76
+ }
77
+ yield {
78
+ type: 'plugin_progress',
79
+ pluginId: PLUGIN_NAME,
80
+ current: iterationCount,
81
+ max: maxIter,
82
+ };
83
+ // fresh context:清空 history,重置为入循环前的快照
84
+ history.length = 0;
85
+ history.push(...baseHistory);
86
+ const freshContext = goalState.composeContext(iterationCount);
87
+ const enhancedInput = `${freshContext}\n\n${currentInput}`;
88
+ yield* runQuery(enhancedInput, {
89
+ provider,
90
+ history,
91
+ signal,
92
+ maxTurns: ctx.maxTurns,
93
+ sessionState: ctx.sessionState,
94
+ });
95
+ if (signal?.aborted) {
96
+ yield { type: 'interrupted' };
97
+ return;
98
+ }
99
+ // 从本轮 history 抓最后一个 assistant 消息(runQuery 已 push 进去)
100
+ const lastAssistantIdx = (() => {
101
+ for (let i = history.length - 1; i >= 0; i--) {
102
+ if (history[i].role === 'assistant')
103
+ return i;
104
+ }
105
+ return -1;
106
+ })();
107
+ finalAssistantMsg =
108
+ lastAssistantIdx >= 0 ? history[lastAssistantIdx] : null;
109
+ const lastAssistantText = finalAssistantMsg
110
+ ? typeof finalAssistantMsg.content === 'string'
111
+ ? finalAssistantMsg.content
112
+ : JSON.stringify(finalAssistantMsg.content)
113
+ : '';
114
+ if (hasCompleteSentinel(lastAssistantText)) {
115
+ // 哨兵:可能来自任意阶段(包括 iter 1 的 PLAN),用 force 跳到 VERIFY
116
+ await goalState.forceSetPhase(Phase.VERIFY, '检测到完成哨兵,进入验证');
117
+ await goalState.appendProgress(`迭代 ${iterationCount}: 检测到完成哨兵,运行验证门...`);
118
+ if (checks.length > 0) {
119
+ const vResult = await runVerification(checks);
120
+ if (!vResult.passed) {
121
+ consecutiveFailures++;
122
+ await goalState.appendLearning(`[迭代 ${iterationCount}] 声称完成但验证未通过: ${vResult.summary}`);
123
+ yield {
124
+ type: 'error',
125
+ error: `⚠️ 验证未通过: ${vResult.summary}。继续尝试...`,
126
+ };
127
+ if (consecutiveFailures >= 3) {
128
+ await goalState.forceSetPhase(Phase.HEAL, `连续 ${consecutiveFailures} 次验证失败`);
129
+ }
130
+ else {
131
+ await goalState.setPhase(Phase.BUILD, '验证未通过,返回构建');
132
+ }
133
+ continue;
134
+ }
135
+ await goalState.appendProgress(`✅ 验证通过: ${vResult.summary}`);
136
+ }
137
+ await goalState.setPhase(Phase.DONE, 'goal complete & verified');
138
+ yield {
139
+ type: 'plugin_iteration',
140
+ pluginName: PLUGIN_NAME,
141
+ current: iterationCount,
142
+ max: maxIter,
143
+ };
144
+ return;
145
+ }
146
+ if (hasNeedReplanSentinel(lastAssistantText)) {
147
+ await goalState.forceSetPhase(Phase.PLAN, 'agent 请求重新规划');
148
+ await goalState.appendLearning('[NEED_REPLAN] Agent 认为当前方案不可行,需要重新规划');
149
+ await goalState.appendProgress('Agent 请求 NEED_REPLAN,回 PLAN 阶段');
150
+ consecutiveFailures = 0;
151
+ continue;
152
+ }
153
+ // PLAN 阶段跑过一轮还没哨兵,认为规划已完成,自动推进 BUILD
154
+ if (goalState.currentPhase === Phase.PLAN && iterationCount >= 2) {
155
+ await goalState.setPhase(Phase.BUILD, '规划阶段已完成,进入构建');
156
+ }
157
+ // Stop-hook 咨询式调用:block 才把 reason 注入下一轮 prompt
158
+ // pass / 报错都不再终止循环;终止由 sentinel/maxIter/abort/NEED_REPLAN 独占
159
+ const hookResult = await executeStopHook(PLUGIN_ROOT, lastAssistantText);
160
+ if (hookResult.decision === 'block' && hookResult.reason) {
161
+ currentInput = hookResult.reason;
162
+ consecutiveFailures = 0;
163
+ await goalState.recordDecision({
164
+ iteration: iterationCount,
165
+ phase: goalState.currentPhase,
166
+ summary: 'stop-hook 反馈',
167
+ }, ['继续循环', '终止'], '继续循环', hookResult.reason.slice(0, 200));
168
+ if (hookResult.systemMessage) {
169
+ baseHistory.push({
170
+ role: 'user',
171
+ content: `[Plugin Stop Hook] ${hookResult.systemMessage}`,
172
+ });
173
+ }
174
+ await goalState.appendProgress(`迭代 ${iterationCount}: Stop hook block,注入反馈继续`);
175
+ }
176
+ else {
177
+ await goalState.appendProgress(`迭代 ${iterationCount}: 无哨兵 / hook pass,继续下一轮`);
178
+ }
179
+ } while (true);
180
+ }
181
+ finally {
182
+ // 收尾:把循环里临时累积的 history 还原成 baseHistory + 最后一轮 assistant
183
+ // 这样 TUI 上看到的就是"一次问答",而不是 N 轮重复
184
+ history.length = 0;
185
+ history.push(...baseHistory);
186
+ if (finalAssistantMsg) {
187
+ history.push(finalAssistantMsg);
188
+ }
189
+ await goalState.cleanup();
190
+ }
191
+ }
192
+ const api = {
193
+ async *runCommand(commandName, args, ctx) {
194
+ if (commandName === 'ralph-loop') {
195
+ yield* runRalphLoop(args, ctx);
196
+ return;
197
+ }
198
+ // 其它命令(help / cancel-ralph)→ 不接管,让框架走声明式 fallback
199
+ yield {
200
+ type: 'error',
201
+ error: `ralph-wiggum: 命令 /${commandName} 未由 plugin.ts 接管`,
202
+ };
203
+ },
204
+ };
205
+ export default api;