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.
- package/README.md +383 -122
- package/package.json +19 -12
- package/plugins/HOW-TO-WRITE-A-PLUGIN.md +186 -0
- package/plugins/ralph-wiggum/commands/ralph-loop.md +6 -16
- package/plugins/ralph-wiggum/plugin.js +205 -0
- package/plugins/ralph-wiggum/src/goalState.js +260 -0
- package/plugins/ralph-wiggum/src/sentinels.js +21 -0
- package/plugins/ralph-wiggum/src/stopHookRunner.js +104 -0
- package/plugins/ralph-wiggum/src/verificationGate.js +202 -0
- package/plugins/workflow-runner/.claude-plugin/plugin.json +5 -0
- package/plugins/workflow-runner/commands/workflow.md +15 -0
- package/plugins/workflow-runner/commands/workflows.md +8 -0
- package/plugins/workflow-runner/plugin.js +36 -0
- package/plugins/workflow-runner/src/expressions.js +369 -0
- package/plugins/workflow-runner/src/index.js +174 -0
- package/plugins/workflow-runner/src/loader.js +183 -0
- package/plugins/workflow-runner/src/runner.js +290 -0
- package/plugins/workflow-runner/src/stepExecutors/assert.js +28 -0
- package/plugins/workflow-runner/src/stepExecutors/llm.js +44 -0
- package/plugins/workflow-runner/src/stepExecutors/skill.js +103 -0
- package/plugins/workflow-runner/src/stepExecutors/tool.js +35 -0
- package/plugins/workflow-runner/src/types.js +59 -0
- package/plugins/workflow-runner/src/workflowState.js +46 -0
- package/skills/image-gen-openrouter/SKILL.md +121 -0
- package/skills/subtitle-srt/SKILL.md +134 -0
- package/skills/tts-zh/SKILL.md +137 -0
- package/skills/video-compose/SKILL.md +139 -0
- package/src/bootstrap/cwdArg.js +22 -0
- package/src/bootstrap/workingDir.js +31 -0
- package/src/cli/configWizard.js +272 -0
- package/src/cli/print.js +192 -0
- package/src/config/configFile.js +78 -0
- package/src/config.js +118 -0
- package/src/context/compact.js +357 -0
- package/src/context/microCompactLite.js +151 -0
- package/src/context/persistContext.js +109 -0
- package/src/context/reactiveCompact.js +121 -0
- package/src/context/sessionPath.js +58 -0
- package/src/context/snipCompact.js +112 -0
- package/src/context/tokenCounter.js +66 -0
- package/src/llm/client.js +182 -0
- package/src/loop.js +230 -0
- package/src/main.js +116 -0
- package/src/plugin-sdk.js +24 -0
- package/src/plugins/commandRouter.js +169 -0
- package/src/plugins/hookEngine.js +258 -0
- package/src/plugins/pluginApi.js +23 -0
- package/src/plugins/pluginLoader.js +71 -0
- package/src/plugins/pluginRunner.js +65 -0
- package/src/plugins/transcript.js +171 -0
- package/src/prompts/projectInstructions.js +48 -0
- package/src/prompts/skillList.js +126 -0
- package/src/prompts/system.js +155 -0
- package/src/session/runTurn.js +41 -0
- package/src/session/sessionState.js +19 -0
- package/src/tools/bash/bash.js +352 -0
- package/src/tools/bash/semantics.js +85 -0
- package/src/tools/bash/warnings.js +98 -0
- package/src/tools/edit/edit.js +253 -0
- package/src/tools/edit/multi-edit.js +155 -0
- package/src/tools/glob/glob.js +97 -0
- package/src/tools/grep/grep.js +185 -0
- package/src/tools/grep/rgPath.js +173 -0
- package/src/tools/index.js +94 -0
- package/src/tools/read/read.js +209 -0
- package/src/tools/shared/fileState.js +61 -0
- package/src/tools/shared/fileUtils.js +281 -0
- package/src/tools/shared/schemas.js +16 -0
- package/src/tools/types.js +21 -0
- package/src/tools/webbrowser/browser.js +55 -0
- package/src/tools/webbrowser/webbrowser.js +194 -0
- package/src/tools/webfetch/preapproved.js +267 -0
- package/src/tools/webfetch/webfetch.js +317 -0
- package/src/tools/websearch/websearch.js +161 -0
- package/src/tools/write/write.js +125 -0
- package/src/types/turndown.d.ts +23 -0
- package/src/types.js +16 -0
- package/src/ui/App.js +37 -0
- package/src/ui/InputBox.js +240 -0
- package/src/ui/MessageList.js +28 -0
- package/src/ui/Root.js +70 -0
- package/src/ui/StatusLine.js +41 -0
- package/src/ui/ToolStatus.js +11 -0
- package/src/ui/hooks/useChat.js +234 -0
- package/src/ui/hooks/usePasteHandler.js +137 -0
- package/src/ui/hooks/useTextBuffer.js +55 -0
- package/src/ui/hooks/useTokenUsage.js +30 -0
- package/src/ui/textBuffer.js +217 -0
- package/src/utils/packageRoot.js +37 -0
- package/src/utils/resourcePaths.js +49 -0
- package/src/utils/zodToJson.js +29 -0
- package/workflows/book-review-short.yaml +99 -0
- package/workflows/e2e-write-greet.yaml +27 -0
- package/workflows/schema.json +74 -0
- package/workflows/youtube-shorts.yaml +171 -0
- package/dist/main.js +0 -5936
- package/plugins/ralph-wiggum/scripts/setup-ralph-loop.sh +0 -203
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { mkdir, appendFile, writeFile, unlink, rmdir } from 'node:fs/promises';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
export var Phase;
|
|
5
|
+
(function (Phase) {
|
|
6
|
+
Phase["PLAN"] = "plan";
|
|
7
|
+
Phase["BUILD"] = "build";
|
|
8
|
+
Phase["VERIFY"] = "verify";
|
|
9
|
+
Phase["HEAL"] = "heal";
|
|
10
|
+
Phase["DONE"] = "done";
|
|
11
|
+
})(Phase || (Phase = {}));
|
|
12
|
+
const VALID_PHASES = new Set(Object.values(Phase));
|
|
13
|
+
const PHASE_TRANSITIONS = {
|
|
14
|
+
[Phase.PLAN]: {
|
|
15
|
+
plan_complete: Phase.BUILD,
|
|
16
|
+
stuck: Phase.PLAN,
|
|
17
|
+
},
|
|
18
|
+
[Phase.BUILD]: {
|
|
19
|
+
task_complete: Phase.VERIFY,
|
|
20
|
+
need_replan: Phase.PLAN,
|
|
21
|
+
tests_failing: Phase.HEAL,
|
|
22
|
+
},
|
|
23
|
+
[Phase.VERIFY]: {
|
|
24
|
+
all_pass: Phase.BUILD,
|
|
25
|
+
failures: Phase.HEAL,
|
|
26
|
+
goal_complete: Phase.DONE,
|
|
27
|
+
},
|
|
28
|
+
[Phase.HEAL]: {
|
|
29
|
+
fixed: Phase.VERIFY,
|
|
30
|
+
cannot_fix_locally: Phase.PLAN,
|
|
31
|
+
},
|
|
32
|
+
[Phase.DONE]: {},
|
|
33
|
+
};
|
|
34
|
+
function isLegalTransition(from, to) {
|
|
35
|
+
if (from === to)
|
|
36
|
+
return true;
|
|
37
|
+
const allowed = PHASE_TRANSITIONS[from];
|
|
38
|
+
if (!allowed)
|
|
39
|
+
return false;
|
|
40
|
+
return Object.values(allowed).includes(to);
|
|
41
|
+
}
|
|
42
|
+
const LEARNINGS_TAIL_LINES = 20;
|
|
43
|
+
export class GoalState {
|
|
44
|
+
dir;
|
|
45
|
+
constructor(workspaceDir, sessionTag) {
|
|
46
|
+
const suffix = sessionTag ? `-${sessionTag}` : '';
|
|
47
|
+
this.dir = join(workspaceDir, `.minimal-agent${suffix}`);
|
|
48
|
+
}
|
|
49
|
+
async reset() {
|
|
50
|
+
const files = ['goal.md', 'completion.md', 'phase.md', 'progress.md', 'learnings.md', 'decisions.md'];
|
|
51
|
+
for (const f of files) {
|
|
52
|
+
try {
|
|
53
|
+
await unlink(join(this.dir, f));
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
async init(goal, criteria) {
|
|
60
|
+
await mkdir(this.dir, { recursive: true });
|
|
61
|
+
const files = {
|
|
62
|
+
goal: `# 不可变目标 (IMMUTABLE GOAL)\n\n${goal}\n`,
|
|
63
|
+
completion: JSON.stringify(criteria, null, 2),
|
|
64
|
+
phase: Phase.PLAN,
|
|
65
|
+
progress: '',
|
|
66
|
+
learnings: '',
|
|
67
|
+
decisions: '',
|
|
68
|
+
};
|
|
69
|
+
for (const [name, content] of Object.entries(files)) {
|
|
70
|
+
const path = join(this.dir, `${name}.md`);
|
|
71
|
+
if (!existsSync(path)) {
|
|
72
|
+
await writeFile(path, content);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
get goal() {
|
|
77
|
+
try {
|
|
78
|
+
return readFileSync(join(this.dir, 'goal.md'), 'utf8').trim();
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return '';
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
get completionCriteria() {
|
|
85
|
+
try {
|
|
86
|
+
const raw = readFileSync(join(this.dir, 'completion.md'), 'utf8').trim();
|
|
87
|
+
return JSON.parse(raw);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
get currentPhase() {
|
|
94
|
+
try {
|
|
95
|
+
const raw = readFileSync(join(this.dir, 'phase.md'), 'utf8').trim();
|
|
96
|
+
if (VALID_PHASES.has(raw)) {
|
|
97
|
+
return raw;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
}
|
|
102
|
+
return Phase.PLAN;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* 切换阶段。`reason` 是给人看的日志文本,不参与校验。
|
|
106
|
+
* 校验只看 from→to 是否在 PHASE_TRANSITIONS 表里有路径(任意 event 通向 to 即合法)。
|
|
107
|
+
* 想绕开 FSM 用 forceSetPhase。
|
|
108
|
+
*/
|
|
109
|
+
async setPhase(phase, reason) {
|
|
110
|
+
if (!VALID_PHASES.has(phase)) {
|
|
111
|
+
throw new Error(`Invalid phase: ${phase}`);
|
|
112
|
+
}
|
|
113
|
+
const current = this.currentPhase;
|
|
114
|
+
if (!isLegalTransition(current, phase)) {
|
|
115
|
+
throw new Error(`非法阶段切换: ${current} → ${phase}。该目标阶段不在 PHASE_TRANSITIONS[${current}] 的可达集合内,需要走 forceSetPhase。`);
|
|
116
|
+
}
|
|
117
|
+
await writeFile(join(this.dir, 'phase.md'), phase);
|
|
118
|
+
await this.appendProgress(`PHASE → ${phase}: ${reason}`);
|
|
119
|
+
}
|
|
120
|
+
async forceSetPhase(phase, reason) {
|
|
121
|
+
if (!VALID_PHASES.has(phase)) {
|
|
122
|
+
throw new Error(`Invalid phase: ${phase}`);
|
|
123
|
+
}
|
|
124
|
+
await writeFile(join(this.dir, 'phase.md'), phase);
|
|
125
|
+
await this.appendProgress(`PHASE → ${phase}: ${reason}`);
|
|
126
|
+
}
|
|
127
|
+
async appendProgress(line) {
|
|
128
|
+
const timestamp = new Date().toLocaleTimeString('en-US', { hour12: false });
|
|
129
|
+
await appendFile(join(this.dir, 'progress.md'), `[${timestamp}] ${line}\n`);
|
|
130
|
+
}
|
|
131
|
+
tailProgress(n) {
|
|
132
|
+
try {
|
|
133
|
+
const content = readFileSync(join(this.dir, 'progress.md'), 'utf8');
|
|
134
|
+
const lines = content.trim().split('\n').filter(Boolean);
|
|
135
|
+
return lines.slice(-n).join('\n');
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return '';
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
async appendLearning(lesson) {
|
|
142
|
+
await appendFile(join(this.dir, 'learnings.md'), `- ${lesson}\n`);
|
|
143
|
+
}
|
|
144
|
+
get learnings() {
|
|
145
|
+
try {
|
|
146
|
+
const raw = readFileSync(join(this.dir, 'learnings.md'), 'utf8').trim();
|
|
147
|
+
if (!raw)
|
|
148
|
+
return '';
|
|
149
|
+
const lines = raw.split('\n').filter(Boolean);
|
|
150
|
+
return lines.slice(-LEARNINGS_TAIL_LINES).join('\n');
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return '';
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
async recordDecision(ctx, options, chosen, reasoning) {
|
|
157
|
+
const entry = {
|
|
158
|
+
iteration: ctx.iteration,
|
|
159
|
+
phase: ctx.phase,
|
|
160
|
+
contextSummary: ctx.summary,
|
|
161
|
+
options,
|
|
162
|
+
chosen,
|
|
163
|
+
reasoning,
|
|
164
|
+
};
|
|
165
|
+
const line = JSON.stringify(entry);
|
|
166
|
+
await appendFile(join(this.dir, 'decisions.md'), `${line}\n`);
|
|
167
|
+
}
|
|
168
|
+
findSimilarDecisions(ctx, k = 3) {
|
|
169
|
+
try {
|
|
170
|
+
const content = readFileSync(join(this.dir, 'decisions.md'), 'utf8');
|
|
171
|
+
const lines = content.trim().split('\n').filter(Boolean);
|
|
172
|
+
const entries = [];
|
|
173
|
+
for (const line of lines) {
|
|
174
|
+
try {
|
|
175
|
+
entries.push(JSON.parse(line));
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const scored = entries
|
|
181
|
+
.map((entry) => ({
|
|
182
|
+
entry,
|
|
183
|
+
score: this._similarity(entry.contextSummary, ctx.summary),
|
|
184
|
+
}))
|
|
185
|
+
.sort((a, b) => b.score - a.score);
|
|
186
|
+
return scored.slice(0, k).filter((s) => s.score > 0.3).map((s) => s.entry);
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
return [];
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
summarizeDecisions(maxEntries = 5) {
|
|
193
|
+
try {
|
|
194
|
+
const content = readFileSync(join(this.dir, 'decisions.md'), 'utf8');
|
|
195
|
+
const lines = content.trim().split('\n').filter(Boolean);
|
|
196
|
+
const entries = [];
|
|
197
|
+
for (const line of lines.slice(-maxEntries * 2)) {
|
|
198
|
+
try {
|
|
199
|
+
entries.push(JSON.parse(line));
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return entries
|
|
205
|
+
.slice(-maxEntries)
|
|
206
|
+
.map((e) => `迭代 ${e.iteration}(${e.phase}): 在 [${e.options.join(', ')}] 中选择了 **${e.chosen}**,原因:${e.reasoning.slice(0, 80)}`)
|
|
207
|
+
.join('\n');
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
return '(无决策记录)';
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
composeContext(iteration) {
|
|
214
|
+
const sections = [];
|
|
215
|
+
sections.push(`# 不可变目标 (IMMUTABLE GOAL)\n${this.goal}\n⚠️ 注意:你不能修改或扩大上述目标。如果你认为目标本身有问题,请输出 <PROMISE>NEED_GOAL_REVISION</PROMISE> 并停止。`);
|
|
216
|
+
sections.push(`# 当前阶段: ${this.currentPhase.toUpperCase()}`);
|
|
217
|
+
const lrn = this.learnings;
|
|
218
|
+
if (lrn) {
|
|
219
|
+
sections.push(`# 关键教训(必须遵守,避免重复踩坑)\n${lrn}`);
|
|
220
|
+
}
|
|
221
|
+
const decisions = this.summarizeDecisions(3);
|
|
222
|
+
if (decisions !== '(无决策记录)') {
|
|
223
|
+
sections.push(`# 历史关键决策(请参考,避免重复试错)\n${decisions}`);
|
|
224
|
+
}
|
|
225
|
+
const recentProgress = this.tailProgress(10);
|
|
226
|
+
if (recentProgress) {
|
|
227
|
+
sections.push(`# 最近进度\n${recentProgress}`);
|
|
228
|
+
}
|
|
229
|
+
sections.push(`---\n\n# 本轮任务 (迭代 ${iteration})`);
|
|
230
|
+
return sections.join('\n\n---\n\n');
|
|
231
|
+
}
|
|
232
|
+
async cleanup() {
|
|
233
|
+
const files = ['goal.md', 'completion.md', 'phase.md', 'progress.md', 'learnings.md', 'decisions.md'];
|
|
234
|
+
for (const f of files) {
|
|
235
|
+
try {
|
|
236
|
+
await unlink(join(this.dir, f));
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
try {
|
|
242
|
+
await rmdir(this.dir);
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
_similarity(a, b) {
|
|
248
|
+
if (!a || !b)
|
|
249
|
+
return 0;
|
|
250
|
+
const wordsA = new Set(a.toLowerCase().split(/\s+/));
|
|
251
|
+
const wordsB = new Set(b.toLowerCase().split(/\s+/));
|
|
252
|
+
let intersection = 0;
|
|
253
|
+
for (const word of wordsA) {
|
|
254
|
+
if (wordsB.has(word))
|
|
255
|
+
intersection++;
|
|
256
|
+
}
|
|
257
|
+
const union = new Set([...wordsA, ...wordsB]).size;
|
|
258
|
+
return union > 0 ? intersection / union : 0;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================
|
|
3
|
+
* plugins/ralph-wiggum/src/sentinels.ts —— LLM 输出哨兵正则
|
|
4
|
+
* ------------------------------------------------------------
|
|
5
|
+
* Ralph 通过"输出特定标签"与 driver 交流:
|
|
6
|
+
* <promise>DONE</promise> / <promise>COMPLETE</promise> / <promise>GOAL_COMPLETE</promise>
|
|
7
|
+
* → 表示当前轮 LLM 认为目标完成,driver 进入验证门
|
|
8
|
+
* <PROMISE>NEED_REPLAN</PROMISE>
|
|
9
|
+
* → 表示当前方案不可行,driver 强转回 PLAN 阶段
|
|
10
|
+
*
|
|
11
|
+
* 哨兵集中在这里方便审计与扩展;正则全部 case-insensitive。
|
|
12
|
+
* ============================================================
|
|
13
|
+
*/
|
|
14
|
+
const COMPLETE_RE = /<promise>(?:COMPLETE|DONE|GOAL_COMPLETE)<\/promise>/i;
|
|
15
|
+
const NEED_REPLAN_RE = /<PROMISE>NEED_REPLAN<\/PROMISE>/i;
|
|
16
|
+
export function hasCompleteSentinel(text) {
|
|
17
|
+
return COMPLETE_RE.test(text);
|
|
18
|
+
}
|
|
19
|
+
export function hasNeedReplanSentinel(text) {
|
|
20
|
+
return NEED_REPLAN_RE.test(text);
|
|
21
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================
|
|
3
|
+
* plugins/ralph-wiggum/src/stopHookRunner.ts
|
|
4
|
+
* ------------------------------------------------------------
|
|
5
|
+
* ralph-wiggum 私用的 stop-hook 执行器。读 hooks/hooks.json 里 Stop 事件
|
|
6
|
+
* 的命令清单,stdin 传 transcript 文本,stdout 收 JSON 决策。
|
|
7
|
+
*
|
|
8
|
+
* 在 minimal-agent 里 stop-hook 是"咨询式"信号:
|
|
9
|
+
* - 返回 block → driver 把 reason 注入下一轮 prompt
|
|
10
|
+
* - pass / 错误 / Windows 缺 bash → 不影响循环继续
|
|
11
|
+
*
|
|
12
|
+
* 与框架级 hookEngine 的关系:hookEngine 服务于 UserPromptSubmit /
|
|
13
|
+
* PreToolUse / PostToolUse 等框架事件;ralph 的 Stop 是插件领域内
|
|
14
|
+
* 特有的循环-反馈机制,故而独立执行,不污染框架 hookTable。
|
|
15
|
+
* ============================================================
|
|
16
|
+
*/
|
|
17
|
+
import { readFile } from 'node:fs/promises';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
import { spawn } from 'node:child_process';
|
|
20
|
+
async function loadStopHookConfig(pluginRoot) {
|
|
21
|
+
const hooksJsonPath = join(pluginRoot, 'hooks', 'hooks.json');
|
|
22
|
+
try {
|
|
23
|
+
const raw = await readFile(hooksJsonPath, 'utf8');
|
|
24
|
+
const parsed = JSON.parse(raw);
|
|
25
|
+
const stopEntries = parsed?.hooks?.Stop;
|
|
26
|
+
if (!Array.isArray(stopEntries))
|
|
27
|
+
return [];
|
|
28
|
+
const commands = [];
|
|
29
|
+
for (const entry of stopEntries) {
|
|
30
|
+
const hooks = entry.hooks;
|
|
31
|
+
if (Array.isArray(hooks)) {
|
|
32
|
+
for (const h of hooks) {
|
|
33
|
+
if (h.type === 'command' && h.command) {
|
|
34
|
+
commands.push(h);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return commands;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export async function executeStopHook(pluginRoot, transcriptText) {
|
|
46
|
+
// Windows 上 bash 通常缺失,hook 链路降级为 pass(咨询式信号不影响循环)
|
|
47
|
+
if (process.platform === 'win32') {
|
|
48
|
+
return { decision: 'pass' };
|
|
49
|
+
}
|
|
50
|
+
const hookConfigs = await loadStopHookConfig(pluginRoot);
|
|
51
|
+
for (const hookConfig of hookConfigs) {
|
|
52
|
+
const result = await runSingleStopHook(hookConfig, pluginRoot, transcriptText);
|
|
53
|
+
if (result.decision === 'block') {
|
|
54
|
+
return result;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return { decision: 'pass' };
|
|
58
|
+
}
|
|
59
|
+
function runSingleStopHook(hookConfig, pluginRoot, transcriptText) {
|
|
60
|
+
const resolvedCommand = hookConfig.command.replaceAll('${CLAUDE_PLUGIN_ROOT}', pluginRoot);
|
|
61
|
+
return new Promise((resolve) => {
|
|
62
|
+
const child = spawn('bash', [resolvedCommand], {
|
|
63
|
+
env: {
|
|
64
|
+
...process.env,
|
|
65
|
+
CLAUDE_PLUGIN_ROOT: pluginRoot,
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
let stdout = '';
|
|
69
|
+
child.stdout.on('data', (data) => {
|
|
70
|
+
stdout += data.toString();
|
|
71
|
+
});
|
|
72
|
+
child.on('error', () => {
|
|
73
|
+
resolve({ decision: 'pass' });
|
|
74
|
+
});
|
|
75
|
+
child.on('close', (code) => {
|
|
76
|
+
if (code !== 0) {
|
|
77
|
+
resolve({ decision: 'pass' });
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const trimmed = stdout.trim();
|
|
81
|
+
if (!trimmed) {
|
|
82
|
+
resolve({ decision: 'pass' });
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
const parsed = JSON.parse(trimmed);
|
|
87
|
+
if (parsed.decision === 'block') {
|
|
88
|
+
resolve({
|
|
89
|
+
decision: 'block',
|
|
90
|
+
reason: typeof parsed.reason === 'string' ? parsed.reason : undefined,
|
|
91
|
+
systemMessage: typeof parsed.systemMessage === 'string' ? parsed.systemMessage : undefined,
|
|
92
|
+
});
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
resolve({ decision: 'pass' });
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
resolve({ decision: 'pass' });
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
child.stdin.write(transcriptText);
|
|
102
|
+
child.stdin.end();
|
|
103
|
+
});
|
|
104
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================
|
|
3
|
+
* src/plugins/verificationGate.ts —— ralph-loop 的"完成验证门"
|
|
4
|
+
* ------------------------------------------------------------
|
|
5
|
+
* Agent 输出 <promise>DONE</promise> 哨兵后,pluginRunner 跑这里的
|
|
6
|
+
* checks。全部通过才真正退出循环;任一未通过则回 BUILD 继续干。
|
|
7
|
+
*
|
|
8
|
+
* 支持四种 check:
|
|
9
|
+
* - shell:<cmd> 跨平台 spawn(Windows 走 cmd /c, 其余 bash -c)
|
|
10
|
+
* - file_exists:<path> fs.existsSync
|
|
11
|
+
* - file_contains:<file>:<re> 正则匹配文件内容
|
|
12
|
+
* - test_count:<min> 跑 `bun test`,从 stdout 解析 "N pass"
|
|
13
|
+
*
|
|
14
|
+
* 工作目录隔离:所有相对路径以 getWorkingDir() 为锚,子进程 cwd 也用它,
|
|
15
|
+
* 确保并行 session 不会互相串台。
|
|
16
|
+
* ============================================================
|
|
17
|
+
*/
|
|
18
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
19
|
+
import { spawn } from 'node:child_process';
|
|
20
|
+
function parseVerifyArg(arg) {
|
|
21
|
+
const colonIdx = arg.indexOf(':');
|
|
22
|
+
if (colonIdx < 0)
|
|
23
|
+
return null;
|
|
24
|
+
const type = arg.slice(0, colonIdx).trim().toLowerCase();
|
|
25
|
+
const value = arg.slice(colonIdx + 1).trim();
|
|
26
|
+
switch (type) {
|
|
27
|
+
case 'shell':
|
|
28
|
+
return { type: 'shell', command: value, timeout: 30_000 };
|
|
29
|
+
case 'file_exists':
|
|
30
|
+
return { type: 'file_exists', file: value };
|
|
31
|
+
case 'file_contains': {
|
|
32
|
+
const sep = value.indexOf(':');
|
|
33
|
+
if (sep < 0)
|
|
34
|
+
return null;
|
|
35
|
+
return {
|
|
36
|
+
type: 'file_contains',
|
|
37
|
+
file: value.slice(0, sep),
|
|
38
|
+
pattern: value.slice(sep + 1),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
case 'test_count': {
|
|
42
|
+
const count = parseInt(value, 10);
|
|
43
|
+
if (isNaN(count) || count < 0)
|
|
44
|
+
return null;
|
|
45
|
+
return { type: 'test_count', minCount: count };
|
|
46
|
+
}
|
|
47
|
+
default:
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
export function parseVerifyArgs(args) {
|
|
52
|
+
const checks = [];
|
|
53
|
+
const regex = /--verify\s+("[^"]*"|\S+)/gi;
|
|
54
|
+
let match;
|
|
55
|
+
while ((match = regex.exec(args)) !== null) {
|
|
56
|
+
const raw = match[1].replace(/^"|"$/g, '');
|
|
57
|
+
const check = parseVerifyArg(raw);
|
|
58
|
+
if (check)
|
|
59
|
+
checks.push(check);
|
|
60
|
+
}
|
|
61
|
+
return checks;
|
|
62
|
+
}
|
|
63
|
+
function runShell(command, timeout) {
|
|
64
|
+
return new Promise((resolve) => {
|
|
65
|
+
const isWin = process.platform === 'win32';
|
|
66
|
+
// 不显式传 cwd —— 继承 process.cwd(),main.tsx 已 chdir 到 workingDir,
|
|
67
|
+
// 而测试里直接用 process.cwd(),避免依赖 mock 的 getWorkingDir 造成串台
|
|
68
|
+
const child = isWin
|
|
69
|
+
? spawn('cmd', ['/c', command], { timeout, env: process.env })
|
|
70
|
+
: spawn('bash', ['-c', command], { timeout, env: process.env });
|
|
71
|
+
let stdout = '';
|
|
72
|
+
let stderr = '';
|
|
73
|
+
child.stdout.on('data', (d) => {
|
|
74
|
+
stdout += d.toString();
|
|
75
|
+
});
|
|
76
|
+
child.stderr.on('data', (d) => {
|
|
77
|
+
stderr += d.toString();
|
|
78
|
+
});
|
|
79
|
+
child.on('error', () => {
|
|
80
|
+
resolve({ exitCode: null, stdout, stderr, errored: true });
|
|
81
|
+
});
|
|
82
|
+
child.on('close', (code) => {
|
|
83
|
+
resolve({ exitCode: code, stdout, stderr, errored: false });
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
export async function verifyShell(command, timeout) {
|
|
88
|
+
const r = await runShell(command, timeout);
|
|
89
|
+
if (r.errored) {
|
|
90
|
+
return {
|
|
91
|
+
check: { type: 'shell', command },
|
|
92
|
+
passed: false,
|
|
93
|
+
output: `执行失败`,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
check: { type: 'shell', command },
|
|
98
|
+
passed: r.exitCode === 0,
|
|
99
|
+
output: r.stdout.trim() || r.stderr.trim() || `exit code ${r.exitCode}`,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
export function verifyFileExists(file) {
|
|
103
|
+
const exists = existsSync(file);
|
|
104
|
+
return {
|
|
105
|
+
check: { type: 'file_exists', file },
|
|
106
|
+
passed: exists,
|
|
107
|
+
output: exists ? '文件存在' : `文件不存在: ${file}`,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
export function verifyFileContains(file, pattern) {
|
|
111
|
+
try {
|
|
112
|
+
const content = readFileSync(file, 'utf8');
|
|
113
|
+
const regex = new RegExp(pattern);
|
|
114
|
+
const matched = regex.test(content);
|
|
115
|
+
return {
|
|
116
|
+
check: { type: 'file_contains', file, pattern },
|
|
117
|
+
passed: matched,
|
|
118
|
+
output: matched ? `文件包含 "${pattern}"` : `文件不包含 "${pattern}"`,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return {
|
|
123
|
+
check: { type: 'file_contains', file, pattern },
|
|
124
|
+
passed: false,
|
|
125
|
+
output: `无法读取文件: ${file}`,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
async function verifyTestCount(minCount) {
|
|
130
|
+
const r = await runShell(`bun test`, 60_000);
|
|
131
|
+
// bun test 把进度打在 stderr,最终统计也常在 stderr,所以合并解析
|
|
132
|
+
const combined = `${r.stdout}\n${r.stderr}`;
|
|
133
|
+
if (r.errored) {
|
|
134
|
+
return {
|
|
135
|
+
check: { type: 'test_count', minCount },
|
|
136
|
+
passed: false,
|
|
137
|
+
output: '无法执行 bun test',
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
const matches = [...combined.matchAll(/(\d+)\s+pass/gi)];
|
|
141
|
+
const count = matches.length > 0 ? parseInt(matches[matches.length - 1][1], 10) : 0;
|
|
142
|
+
const passed = count >= minCount;
|
|
143
|
+
return {
|
|
144
|
+
check: { type: 'test_count', minCount },
|
|
145
|
+
passed,
|
|
146
|
+
output: `${count} pass (需要 >=${minCount})`,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
export async function runVerification(checks) {
|
|
150
|
+
if (checks.length === 0) {
|
|
151
|
+
return { passed: true, details: [], summary: '无验证项' };
|
|
152
|
+
}
|
|
153
|
+
const results = [];
|
|
154
|
+
for (const check of checks) {
|
|
155
|
+
let result;
|
|
156
|
+
switch (check.type) {
|
|
157
|
+
case 'shell':
|
|
158
|
+
result = await verifyShell(check.command ?? '', check.timeout ?? 30_000);
|
|
159
|
+
break;
|
|
160
|
+
case 'file_exists':
|
|
161
|
+
result = verifyFileExists(check.file ?? '');
|
|
162
|
+
break;
|
|
163
|
+
case 'file_contains':
|
|
164
|
+
result = verifyFileContains(check.file ?? '', check.pattern ?? '');
|
|
165
|
+
break;
|
|
166
|
+
case 'test_count':
|
|
167
|
+
result = await verifyTestCount(check.minCount ?? 0);
|
|
168
|
+
break;
|
|
169
|
+
default:
|
|
170
|
+
result = {
|
|
171
|
+
check,
|
|
172
|
+
passed: false,
|
|
173
|
+
output: `未知验证类型: ${check.type}`,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
results.push(result);
|
|
177
|
+
}
|
|
178
|
+
const allPassed = results.every((r) => r.passed);
|
|
179
|
+
const failedNames = results.filter((r) => !r.passed).map((r) => formatCheckName(r.check));
|
|
180
|
+
let summary;
|
|
181
|
+
if (allPassed) {
|
|
182
|
+
summary = `✅ 全部通过 (${results.length}/${results.length})`;
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
summary = `❌ 验证未通过: ${failedNames.join(', ')}`;
|
|
186
|
+
}
|
|
187
|
+
return { passed: allPassed, details: results, summary };
|
|
188
|
+
}
|
|
189
|
+
function formatCheckName(check) {
|
|
190
|
+
switch (check.type) {
|
|
191
|
+
case 'shell':
|
|
192
|
+
return `shell(${(check.command ?? '').slice(0, 40)})`;
|
|
193
|
+
case 'file_exists':
|
|
194
|
+
return `exists(${check.file})`;
|
|
195
|
+
case 'file_contains':
|
|
196
|
+
return `contains(${check.file}:${check.pattern})`;
|
|
197
|
+
case 'test_count':
|
|
198
|
+
return `tests(>=${check.minCount})`;
|
|
199
|
+
default:
|
|
200
|
+
return check.type;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: 运行 YAML 工作流(DAG 执行器),用 --input k=v 传参
|
|
3
|
+
argument-hint: <name> [--input key=value ...]
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# /workflow
|
|
7
|
+
|
|
8
|
+
本命令由 workflow-runner 插件接管(plugin.ts.runCommand),不会把 prompt 喂给 LLM。
|
|
9
|
+
直接调用插件内置的 YAML DAG 执行器,按 steps 顺序运行 tool / llm / assert / branch / loop 等节点。
|
|
10
|
+
|
|
11
|
+
用法示例:
|
|
12
|
+
|
|
13
|
+
/workflow hello-workflow --input name=alice --input lang=zh
|
|
14
|
+
|
|
15
|
+
输入 /workflows 查看可用工作流列表。
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================
|
|
3
|
+
* plugins/workflow-runner/plugin.ts
|
|
4
|
+
* ------------------------------------------------------------
|
|
5
|
+
* workflow-runner 插件入口。接管 /workflow 与 /workflows 两个命令的执行:
|
|
6
|
+
* 不走声明式 markdown→LLM 通道,而是直接调插件内置的 YAML DAG 执行器。
|
|
7
|
+
*
|
|
8
|
+
* 执行器与全部依赖都住在本插件目录 ./src/,外部 src/ 一行不改。
|
|
9
|
+
* 插件只通过 src/plugin-sdk.ts 取框架运行时(runQuery / chat / executeTool 等)。
|
|
10
|
+
*
|
|
11
|
+
* 契约:default export 一个 PluginApi 对象。框架 pluginLoader 会按 pluginRoot
|
|
12
|
+
* 缓存这次 import,所以 cold start 后 dynamic import 只跑一次。
|
|
13
|
+
* ============================================================
|
|
14
|
+
*/
|
|
15
|
+
import { runWorkflowFromCommand, runWorkflowsList } from './src/index.js';
|
|
16
|
+
const api = {
|
|
17
|
+
async *runCommand(commandName, args, ctx) {
|
|
18
|
+
if (commandName === 'workflows') {
|
|
19
|
+
yield* runWorkflowsList();
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
if (commandName === 'workflow') {
|
|
23
|
+
yield* runWorkflowFromCommand(args, {
|
|
24
|
+
provider: ctx.provider,
|
|
25
|
+
history: ctx.history,
|
|
26
|
+
signal: ctx.signal,
|
|
27
|
+
});
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
yield {
|
|
31
|
+
type: 'error',
|
|
32
|
+
error: `workflow-runner: 未知命令 /${commandName}`,
|
|
33
|
+
};
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
export default api;
|