minimal-agent 0.2.0 → 0.3.1
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 +54 -72
- package/package.json +18 -13
- package/plugins/ralph-wiggum/plugin.js +205 -0
- package/plugins/ralph-wiggum/src/goalState.js +260 -0
- package/plugins/ralph-wiggum/src/{sentinels.ts → sentinels.js} +4 -7
- package/plugins/ralph-wiggum/src/stopHookRunner.js +104 -0
- package/plugins/ralph-wiggum/src/verificationGate.js +202 -0
- package/plugins/workflow-runner/commands/workflow.md +13 -3
- package/plugins/workflow-runner/{plugin.ts → plugin.js} +20 -26
- package/plugins/workflow-runner/src/expressions.js +369 -0
- package/plugins/workflow-runner/src/index.js +216 -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.ts → tool.js} +19 -25
- package/plugins/workflow-runner/src/types.js +59 -0
- package/plugins/workflow-runner/src/{workflowState.ts → workflowState.js} +21 -40
- 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 +197 -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/dist/main.js +0 -5315
- package/plugins/ralph-wiggum/plugin.ts +0 -275
- package/plugins/ralph-wiggum/scripts/setup-ralph-loop.sh +0 -203
- package/plugins/ralph-wiggum/src/goalState.ts +0 -310
- package/plugins/ralph-wiggum/src/stopHookRunner.ts +0 -136
- package/plugins/ralph-wiggum/src/verificationGate.ts +0 -252
- package/plugins/ralph-wiggum/test/goalState.test.ts +0 -410
- package/plugins/ralph-wiggum/test/verificationGate.test.ts +0 -122
- package/plugins/workflow-runner/src/expressions.ts +0 -371
- package/plugins/workflow-runner/src/index.ts +0 -194
- package/plugins/workflow-runner/src/loader.ts +0 -193
- package/plugins/workflow-runner/src/runner.ts +0 -313
- package/plugins/workflow-runner/src/stepExecutors/assert.ts +0 -30
- package/plugins/workflow-runner/src/stepExecutors/llm.ts +0 -54
- package/plugins/workflow-runner/src/stepExecutors/skill.ts +0 -115
- package/plugins/workflow-runner/src/types.ts +0 -183
- package/plugins/workflow-runner/test/cli.e2e.test.ts +0 -114
- package/plugins/workflow-runner/test/e2e.test.ts +0 -268
- package/plugins/workflow-runner/test/expressions.test.ts +0 -140
- package/plugins/workflow-runner/test/fixtures/cli-e2e.yaml +0 -27
- package/plugins/workflow-runner/test/fixtures/hello-workflow.yaml +0 -49
- package/plugins/workflow-runner/test/graceful.test.ts +0 -139
- package/plugins/workflow-runner/test/loader.test.ts +0 -216
- package/plugins/workflow-runner/test/pluginRunner.isolation.test.ts +0 -230
- package/plugins/workflow-runner/test/runner.test.ts +0 -511
|
@@ -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
|
+
}
|
|
@@ -11,14 +11,11 @@
|
|
|
11
11
|
* 哨兵集中在这里方便审计与扩展;正则全部 case-insensitive。
|
|
12
12
|
* ============================================================
|
|
13
13
|
*/
|
|
14
|
-
|
|
15
14
|
const COMPLETE_RE = /<promise>(?:COMPLETE|DONE|GOAL_COMPLETE)<\/promise>/i;
|
|
16
15
|
const NEED_REPLAN_RE = /<PROMISE>NEED_REPLAN<\/PROMISE>/i;
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
return COMPLETE_RE.test(text);
|
|
16
|
+
export function hasCompleteSentinel(text) {
|
|
17
|
+
return COMPLETE_RE.test(text);
|
|
20
18
|
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
return NEED_REPLAN_RE.test(text);
|
|
19
|
+
export function hasNeedReplanSentinel(text) {
|
|
20
|
+
return NEED_REPLAN_RE.test(text);
|
|
24
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
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: 运行 YAML 工作流(DAG
|
|
3
|
-
argument-hint: <name> [--input key=value ...]
|
|
2
|
+
description: 运行 YAML 工作流(DAG 执行器)。位置参数按 inputs 声明顺序映射,或用 --input k=v 显式传参
|
|
3
|
+
argument-hint: <name> [val ...] [--input key=value ...]
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# /workflow
|
|
@@ -10,6 +10,16 @@ argument-hint: <name> [--input key=value ...]
|
|
|
10
10
|
|
|
11
11
|
用法示例:
|
|
12
12
|
|
|
13
|
-
/workflow hello-workflow
|
|
13
|
+
/workflow hello-workflow alice 位置参数:直接传 inputs 声明里第一个 input
|
|
14
|
+
/workflow hello-workflow alice zh 多个位置参数:按 inputs 顺序填
|
|
15
|
+
/workflow hello-workflow.yaml alice .yaml 后缀可省(带也认)
|
|
16
|
+
/workflow hello-workflow --input name=alice --input lang=zh 显式 KV
|
|
17
|
+
/workflow hello-workflow alice --input lang=zh 混用:位置填 name,--input 填 lang
|
|
18
|
+
|
|
19
|
+
规则:
|
|
20
|
+
- 位置参数按 yaml 文件 `inputs:` 数组的**声明顺序**映射(不是字段名顺序)。多 input workflow 推荐 `--input key=value` 显式 KV,不易出错
|
|
21
|
+
- `--input k=v` 显式 KV 优先级高于位置参数;位置参数对应的 input 已被 KV 占位时,位置参数被丢弃并 stderr 打 warning
|
|
22
|
+
- 多余的位置参数(超过 inputs 声明数量)被丢弃并 stderr 打 warning,不影响 workflow 执行
|
|
23
|
+
- 值含空格时用引号:`/workflow xxx "AI 教程 进阶"`
|
|
14
24
|
|
|
15
25
|
输入 /workflows 查看可用工作流列表。
|
|
@@ -12,31 +12,25 @@
|
|
|
12
12
|
* 缓存这次 import,所以 cold start 后 dynamic import 只跑一次。
|
|
13
13
|
* ============================================================
|
|
14
14
|
*/
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
yield {
|
|
36
|
-
type: 'error',
|
|
37
|
-
error: `workflow-runner: 未知命令 /${commandName}`,
|
|
38
|
-
};
|
|
39
|
-
},
|
|
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
|
+
},
|
|
40
35
|
};
|
|
41
|
-
|
|
42
36
|
export default api;
|