@lnilluv/pi-ralph-loop 0.1.1 → 0.2.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/.github/workflows/release.yml +2 -2
- package/README.md +35 -1
- package/package.json +1 -1
- package/src/index.ts +276 -149
- package/src/shims.d.ts +23 -0
|
@@ -24,10 +24,10 @@ jobs:
|
|
|
24
24
|
with:
|
|
25
25
|
fetch-depth: 0
|
|
26
26
|
|
|
27
|
-
- name: Setup Node.js
|
|
27
|
+
- name: Setup Node.js 24
|
|
28
28
|
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
|
|
29
29
|
with:
|
|
30
|
-
node-version:
|
|
30
|
+
node-version: 24
|
|
31
31
|
registry-url: https://registry.npmjs.org
|
|
32
32
|
|
|
33
33
|
- name: Install dependencies
|
package/README.md
CHANGED
|
@@ -41,6 +41,8 @@ commands:
|
|
|
41
41
|
run: npm run lint
|
|
42
42
|
timeout: 60
|
|
43
43
|
max_iterations: 25
|
|
44
|
+
timeout: 300
|
|
45
|
+
completion_promise: "DONE"
|
|
44
46
|
guardrails:
|
|
45
47
|
block_commands:
|
|
46
48
|
- "rm\\s+-rf\\s+/"
|
|
@@ -51,12 +53,15 @@ guardrails:
|
|
|
51
53
|
---
|
|
52
54
|
You are fixing flaky tests in the auth module.
|
|
53
55
|
|
|
56
|
+
<!-- This comment is stripped before sending to the agent -->
|
|
57
|
+
|
|
54
58
|
Latest test output:
|
|
55
59
|
{{ commands.tests }}
|
|
56
60
|
|
|
57
61
|
Latest lint output:
|
|
58
62
|
{{ commands.lint }}
|
|
59
63
|
|
|
64
|
+
Iteration {{ ralph.iteration }} of {{ ralph.name }}.
|
|
60
65
|
Apply the smallest safe fix and explain why it works.
|
|
61
66
|
```
|
|
62
67
|
|
|
@@ -67,9 +72,21 @@ Apply the smallest safe fix and explain why it works.
|
|
|
67
72
|
| `commands[].run` | string | required | Shell command |
|
|
68
73
|
| `commands[].timeout` | number | `60` | Seconds before kill |
|
|
69
74
|
| `max_iterations` | number | `50` | Stop after N iterations |
|
|
75
|
+
| `timeout` | number | `300` | Per-iteration timeout in seconds; stops the loop if the agent is stuck |
|
|
76
|
+
| `completion_promise` | string | — | Agent signals completion by sending `<promise>DONE</promise>`; loop breaks on match |
|
|
70
77
|
| `guardrails.block_commands` | string[] | `[]` | Regex patterns to block in bash |
|
|
71
78
|
| `guardrails.protected_files` | string[] | `[]` | Glob patterns to block writes |
|
|
72
79
|
|
|
80
|
+
### Placeholders
|
|
81
|
+
|
|
82
|
+
| Placeholder | Description |
|
|
83
|
+
|-------------|-------------|
|
|
84
|
+
| `{{ commands.<name> }}` | Output from the named command |
|
|
85
|
+
| `{{ ralph.iteration }}` | Current 1-based iteration number |
|
|
86
|
+
| `{{ ralph.name }}` | Directory name containing the RALPH.md |
|
|
87
|
+
|
|
88
|
+
HTML comments (`<!-- ... -->`) are stripped from the prompt body after placeholder resolution, so you can annotate your RALPH.md freely.
|
|
89
|
+
|
|
73
90
|
## Commands
|
|
74
91
|
|
|
75
92
|
- `/ralph <path>`: Start the loop from a `RALPH.md` file or directory.
|
|
@@ -79,7 +96,7 @@ Apply the smallest safe fix and explain why it works.
|
|
|
79
96
|
|
|
80
97
|
### Guardrails
|
|
81
98
|
|
|
82
|
-
`guardrails.block_commands` and `guardrails.protected_files` come from RALPH frontmatter. The extension enforces them in the `tool_call` hook. Matching bash commands are blocked, and writes/edits to protected file globs are denied.
|
|
99
|
+
`guardrails.block_commands` and `guardrails.protected_files` come from RALPH frontmatter. The extension enforces them in the `tool_call` hook — but only for sessions created by the loop, so they don't leak into unrelated conversations. Matching bash commands are blocked, and writes/edits to protected file globs are denied.
|
|
83
100
|
|
|
84
101
|
### Cross-iteration memory
|
|
85
102
|
|
|
@@ -89,6 +106,18 @@ After each iteration, the extension stores a short summary with iteration number
|
|
|
89
106
|
|
|
90
107
|
In the `tool_result` hook, bash outputs are scanned for failure patterns. After three or more failures in the same iteration, the extension appends a stop-and-think warning to push root-cause analysis before another retry.
|
|
91
108
|
|
|
109
|
+
### Completion promise
|
|
110
|
+
|
|
111
|
+
When `completion_promise` is set (e.g., `"DONE"`), the loop scans the agent's messages for `<promise>DONE</promise>` after each iteration. If found, the loop stops early — the agent signals it's finished rather than relying solely on `max_iterations`.
|
|
112
|
+
|
|
113
|
+
### Iteration timeout
|
|
114
|
+
|
|
115
|
+
Each iteration has a configurable timeout (default 300 seconds). If the agent is stuck and doesn't become idle within the timeout, the loop stops with a warning. This prevents runaway iterations from running forever.
|
|
116
|
+
|
|
117
|
+
### Input validation
|
|
118
|
+
|
|
119
|
+
The extension validates `RALPH.md` frontmatter before starting and on each re-parse: `max_iterations` must be a positive integer, `timeout` must be positive, `block_commands` regexes must compile, and commands must have non-empty names and run strings with positive timeouts.
|
|
120
|
+
|
|
92
121
|
## Comparison table
|
|
93
122
|
|
|
94
123
|
| Feature | **@lnilluv/pi-ralph-loop** | pi-ralph | pi-ralph-wiggum | ralphi | ralphify |
|
|
@@ -99,8 +128,13 @@ In the `tool_result` hook, bash outputs are scanned for failure patterns. After
|
|
|
99
128
|
| Cross-iteration memory | ✓ | ✗ | ✗ | ✗ | ✗ |
|
|
100
129
|
| Mid-turn steering | ✓ | ✗ | ✗ | ✗ | ✗ |
|
|
101
130
|
| Live prompt editing | ✓ | ✗ | ✗ | ✗ | ✓ |
|
|
131
|
+
| Completion promise | ✓ | ✗ | ✗ | ✗ | ✓ |
|
|
132
|
+
| Iteration timeout | ✓ | ✗ | ✗ | ✗ | ✗ |
|
|
133
|
+
| Session-scoped hooks | ✓ | ✗ | ✗ | ✗ | ✗ |
|
|
134
|
+
| Input validation | ✓ | ✗ | ✗ | ✗ | ✗ |
|
|
102
135
|
| Setup required | RALPH.md | config | RALPH.md | PRD pipeline | RALPH.md |
|
|
103
136
|
|
|
104
137
|
## License
|
|
105
138
|
|
|
106
139
|
MIT
|
|
140
|
+
# CI provenance test
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -4,64 +4,102 @@ import { readFileSync, existsSync } from "node:fs";
|
|
|
4
4
|
import { resolve, join, dirname, basename } from "node:path";
|
|
5
5
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
6
6
|
|
|
7
|
-
// --- Types ---
|
|
8
|
-
|
|
9
7
|
type CommandDef = { name: string; run: string; timeout: number };
|
|
10
|
-
|
|
11
8
|
type Frontmatter = {
|
|
12
9
|
commands: CommandDef[];
|
|
13
10
|
maxIterations: number;
|
|
11
|
+
timeout: number;
|
|
12
|
+
completionPromise?: string;
|
|
14
13
|
guardrails: { blockCommands: string[]; protectedFiles: string[] };
|
|
15
14
|
};
|
|
16
|
-
|
|
17
15
|
type ParsedRalph = { frontmatter: Frontmatter; body: string };
|
|
18
16
|
type CommandOutput = { name: string; output: string };
|
|
19
|
-
|
|
20
17
|
type LoopState = {
|
|
21
18
|
active: boolean;
|
|
22
19
|
ralphPath: string;
|
|
23
20
|
iteration: number;
|
|
24
21
|
maxIterations: number;
|
|
22
|
+
timeout: number;
|
|
23
|
+
completionPromise?: string;
|
|
25
24
|
stopRequested: boolean;
|
|
26
|
-
failCount: number;
|
|
27
25
|
iterationSummaries: Array<{ iteration: number; duration: number }>;
|
|
28
|
-
guardrails: { blockCommands:
|
|
26
|
+
guardrails: { blockCommands: string[]; protectedFiles: string[] };
|
|
27
|
+
loopSessionFile?: string;
|
|
28
|
+
};
|
|
29
|
+
type PersistedLoopState = {
|
|
30
|
+
active: boolean;
|
|
31
|
+
sessionFile?: string;
|
|
32
|
+
iteration?: number;
|
|
33
|
+
maxIterations?: number;
|
|
34
|
+
iterationSummaries?: Array<{ iteration: number; duration: number }>;
|
|
35
|
+
guardrails?: { blockCommands: string[]; protectedFiles: string[] };
|
|
36
|
+
stopRequested?: boolean;
|
|
29
37
|
};
|
|
30
|
-
|
|
31
|
-
// --- Parsing ---
|
|
32
38
|
|
|
33
39
|
function defaultFrontmatter(): Frontmatter {
|
|
34
|
-
return { commands: [], maxIterations: 50, guardrails: { blockCommands: [], protectedFiles: [] } };
|
|
40
|
+
return { commands: [], maxIterations: 50, timeout: 300, guardrails: { blockCommands: [], protectedFiles: [] } };
|
|
35
41
|
}
|
|
36
42
|
|
|
37
43
|
function parseRalphMd(filePath: string): ParsedRalph {
|
|
38
|
-
|
|
39
|
-
|
|
44
|
+
let raw = readFileSync(filePath, "utf8");
|
|
45
|
+
raw = raw.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n");
|
|
46
|
+
const match = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
40
47
|
if (!match) return { frontmatter: defaultFrontmatter(), body: raw };
|
|
41
48
|
|
|
42
|
-
const yaml = parseYaml(match[1]) ?? {}
|
|
49
|
+
const yaml = (parseYaml(match[1]) ?? {}) as Record<string, any>;
|
|
43
50
|
const commands: CommandDef[] = Array.isArray(yaml.commands)
|
|
44
|
-
? yaml.commands.map((c: Record<string,
|
|
45
|
-
name: String(c.name ?? ""),
|
|
46
|
-
run: String(c.run ?? ""),
|
|
47
|
-
timeout: Number(c.timeout ?? 60),
|
|
48
|
-
}))
|
|
51
|
+
? yaml.commands.map((c: Record<string, any>) => ({ name: String(c.name ?? ""), run: String(c.run ?? ""), timeout: Number(c.timeout ?? 60) }))
|
|
49
52
|
: [];
|
|
53
|
+
const guardrails = (yaml.guardrails ?? {}) as Record<string, any>;
|
|
50
54
|
|
|
51
|
-
const guardrails = yaml.guardrails ?? {};
|
|
52
55
|
return {
|
|
53
56
|
frontmatter: {
|
|
54
57
|
commands,
|
|
55
58
|
maxIterations: Number(yaml.max_iterations ?? 50),
|
|
59
|
+
timeout: Number(yaml.timeout ?? 300),
|
|
60
|
+
completionPromise:
|
|
61
|
+
typeof yaml.completion_promise === "string" && yaml.completion_promise.trim() ? yaml.completion_promise : undefined,
|
|
56
62
|
guardrails: {
|
|
57
|
-
blockCommands: Array.isArray(guardrails.block_commands) ? guardrails.block_commands : [],
|
|
58
|
-
protectedFiles: Array.isArray(guardrails.protected_files) ? guardrails.protected_files : [],
|
|
63
|
+
blockCommands: Array.isArray(guardrails.block_commands) ? guardrails.block_commands.map((p: unknown) => String(p)) : [],
|
|
64
|
+
protectedFiles: Array.isArray(guardrails.protected_files) ? guardrails.protected_files.map((p: unknown) => String(p)) : [],
|
|
59
65
|
},
|
|
60
66
|
},
|
|
61
|
-
body: match[2],
|
|
67
|
+
body: match[2] ?? "",
|
|
62
68
|
};
|
|
63
69
|
}
|
|
64
70
|
|
|
71
|
+
function validateFrontmatter(fm: Frontmatter, ctx: any): boolean {
|
|
72
|
+
if (!Number.isFinite(fm.maxIterations) || !Number.isInteger(fm.maxIterations) || fm.maxIterations <= 0) {
|
|
73
|
+
ctx.ui.notify("Invalid max_iterations: must be a positive finite integer", "error");
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
if (!Number.isFinite(fm.timeout) || fm.timeout <= 0) {
|
|
77
|
+
ctx.ui.notify("Invalid timeout: must be a positive finite number", "error");
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
for (const pattern of fm.guardrails.blockCommands) {
|
|
81
|
+
try { new RegExp(pattern); } catch {
|
|
82
|
+
ctx.ui.notify(`Invalid block_commands regex: ${pattern}`, "error");
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
for (const cmd of fm.commands) {
|
|
87
|
+
if (!cmd.name.trim()) {
|
|
88
|
+
ctx.ui.notify("Invalid command: name is required", "error");
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
if (!cmd.run.trim()) {
|
|
92
|
+
ctx.ui.notify(`Invalid command ${cmd.name}: run is required`, "error");
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
if (!Number.isFinite(cmd.timeout) || cmd.timeout <= 0) {
|
|
96
|
+
ctx.ui.notify(`Invalid command ${cmd.name}: timeout must be positive`, "error");
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
|
|
65
103
|
function resolveRalphPath(args: string, cwd: string): string {
|
|
66
104
|
const target = args.trim() || ".";
|
|
67
105
|
const abs = resolve(cwd, target);
|
|
@@ -70,201 +108,290 @@ function resolveRalphPath(args: string, cwd: string): string {
|
|
|
70
108
|
throw new Error(`No RALPH.md found at ${abs}`);
|
|
71
109
|
}
|
|
72
110
|
|
|
73
|
-
function resolvePlaceholders(body: string, outputs: CommandOutput[]): string {
|
|
111
|
+
function resolvePlaceholders(body: string, outputs: CommandOutput[], ralph: { iteration: number; name: string }): string {
|
|
74
112
|
const map = new Map(outputs.map((o) => [o.name, o.output]));
|
|
75
|
-
return body
|
|
113
|
+
return body
|
|
114
|
+
.replace(/\{\{\s*commands\.(\w[\w-]*)\s*\}\}/g, (_, name) => map.get(name) ?? "")
|
|
115
|
+
.replace(/\{\{\s*ralph\.iteration\s*\}\}/g, String(ralph.iteration))
|
|
116
|
+
.replace(/\{\{\s*ralph\.name\s*\}\}/g, ralph.name);
|
|
76
117
|
}
|
|
77
118
|
|
|
78
|
-
function
|
|
79
|
-
return {
|
|
80
|
-
blockCommands: fm.guardrails.blockCommands.map((p) => new RegExp(p)),
|
|
81
|
-
protectedFiles: fm.guardrails.protectedFiles,
|
|
82
|
-
};
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// --- Command execution ---
|
|
86
|
-
|
|
87
|
-
async function runCommands(
|
|
88
|
-
commands: CommandDef[],
|
|
89
|
-
cwd: string,
|
|
90
|
-
pi: ExtensionAPI,
|
|
91
|
-
): Promise<CommandOutput[]> {
|
|
119
|
+
async function runCommands(commands: CommandDef[], pi: ExtensionAPI): Promise<CommandOutput[]> {
|
|
92
120
|
const results: CommandOutput[] = [];
|
|
93
121
|
for (const cmd of commands) {
|
|
94
122
|
try {
|
|
95
123
|
const result = await pi.exec("bash", ["-c", cmd.run], { timeout: cmd.timeout * 1000 });
|
|
96
|
-
results.push(
|
|
97
|
-
name: cmd.name,
|
|
98
|
-
output: (result.stdout + result.stderr).trim()
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
results.push({ name: cmd.name, output: `[
|
|
124
|
+
results.push(result.killed
|
|
125
|
+
? { name: cmd.name, output: `[timed out after ${cmd.timeout}s]` }
|
|
126
|
+
: { name: cmd.name, output: (result.stdout + result.stderr).trim() });
|
|
127
|
+
} catch (err) {
|
|
128
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
129
|
+
results.push({ name: cmd.name, output: `[error: ${message}]` });
|
|
102
130
|
}
|
|
103
131
|
}
|
|
104
132
|
return results;
|
|
105
133
|
}
|
|
106
134
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
}
|
|
135
|
+
function defaultLoopState(): LoopState {
|
|
136
|
+
return { active: false, ralphPath: "", iteration: 0, maxIterations: 50, timeout: 300, completionPromise: undefined, stopRequested: false, iterationSummaries: [], guardrails: { blockCommands: [], protectedFiles: [] }, loopSessionFile: undefined };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function readPersistedLoopState(ctx: any): PersistedLoopState | undefined {
|
|
140
|
+
const entries = ctx.sessionManager.getEntries();
|
|
141
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
142
|
+
const entry = entries[i];
|
|
143
|
+
if (entry.type === "custom" && entry.customType === "ralph-loop-state") {
|
|
144
|
+
return typeof entry.data === "object" && entry.data ? (entry.data as PersistedLoopState) : undefined;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function persistLoopState(pi: ExtensionAPI, data: PersistedLoopState) {
|
|
151
|
+
pi.appendEntry("ralph-loop-state", data);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
let loopState: LoopState = defaultLoopState();
|
|
119
155
|
|
|
120
156
|
export default function (pi: ExtensionAPI) {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
157
|
+
const failCounts = new Map<string, number>();
|
|
158
|
+
const isLoopSession = (ctx: any): boolean => {
|
|
159
|
+
const state = readPersistedLoopState(ctx);
|
|
160
|
+
const sessionFile = ctx.sessionManager.getSessionFile();
|
|
161
|
+
return state?.active === true && state.sessionFile === sessionFile;
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
pi.on("tool_call", async (event: any, ctx: any) => {
|
|
165
|
+
if (!isLoopSession(ctx)) return;
|
|
166
|
+
const persisted = readPersistedLoopState(ctx);
|
|
167
|
+
if (!persisted) return;
|
|
124
168
|
|
|
125
169
|
if (event.toolName === "bash") {
|
|
126
170
|
const cmd = (event.input as { command?: string }).command ?? "";
|
|
127
|
-
for (const pattern of
|
|
128
|
-
|
|
129
|
-
return { block: true, reason: `ralph: blocked (${pattern
|
|
171
|
+
for (const pattern of persisted.guardrails?.blockCommands ?? []) {
|
|
172
|
+
try {
|
|
173
|
+
if (new RegExp(pattern).test(cmd)) return { block: true, reason: `ralph: blocked (${pattern})` };
|
|
174
|
+
} catch {
|
|
175
|
+
// ignore malformed persisted regex
|
|
130
176
|
}
|
|
131
177
|
}
|
|
132
178
|
}
|
|
133
179
|
|
|
134
180
|
if (event.toolName === "write" || event.toolName === "edit") {
|
|
135
181
|
const filePath = (event.input as { path?: string }).path ?? "";
|
|
136
|
-
for (const glob of
|
|
137
|
-
if (minimatch(filePath, glob, { matchBase: true })) {
|
|
138
|
-
return { block: true, reason: `ralph: ${filePath} is protected` };
|
|
139
|
-
}
|
|
182
|
+
for (const glob of persisted.guardrails?.protectedFiles ?? []) {
|
|
183
|
+
if (minimatch(filePath, glob, { matchBase: true })) return { block: true, reason: `ralph: ${filePath} is protected` };
|
|
140
184
|
}
|
|
141
185
|
}
|
|
142
186
|
});
|
|
143
187
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
.map((s) => `- Iteration ${s.iteration}: ${s.duration}s`)
|
|
150
|
-
.join("\n");
|
|
188
|
+
pi.on("before_agent_start", async (event: any, ctx: any) => {
|
|
189
|
+
if (!isLoopSession(ctx)) return;
|
|
190
|
+
const persisted = readPersistedLoopState(ctx);
|
|
191
|
+
const summaries = persisted?.iterationSummaries ?? [];
|
|
192
|
+
if (summaries.length === 0) return;
|
|
151
193
|
|
|
194
|
+
const history = summaries.map((s) => `- Iteration ${s.iteration}: ${s.duration}s`).join("\n");
|
|
152
195
|
return {
|
|
153
196
|
systemPrompt:
|
|
154
197
|
event.systemPrompt +
|
|
155
|
-
`\n\n## Ralph Loop Context\nIteration ${
|
|
198
|
+
`\n\n## Ralph Loop Context\nIteration ${persisted?.iteration ?? 0}/${persisted?.maxIterations ?? 0}\n\nPrevious iterations:\n${history}\n\nDo not repeat completed work. Check git log for recent changes.`,
|
|
156
199
|
};
|
|
157
200
|
});
|
|
158
201
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
202
|
+
pi.on("tool_result", async (event: any, ctx: any) => {
|
|
203
|
+
if (!isLoopSession(ctx) || event.toolName !== "bash") return;
|
|
204
|
+
const output = event.content.map((c: { type: string; text?: string }) => (c.type === "text" ? c.text ?? "" : "")).join("");
|
|
205
|
+
if (!/FAIL|ERROR|error:|failed/i.test(output)) return;
|
|
162
206
|
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
.join("");
|
|
207
|
+
const sessionFile = ctx.sessionManager.getSessionFile();
|
|
208
|
+
if (!sessionFile) return;
|
|
166
209
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
if (loopState.failCount >= 3) {
|
|
210
|
+
const next = (failCounts.get(sessionFile) ?? 0) + 1;
|
|
211
|
+
failCounts.set(sessionFile, next);
|
|
212
|
+
if (next >= 3) {
|
|
172
213
|
return {
|
|
173
214
|
content: [
|
|
174
215
|
...event.content,
|
|
175
|
-
{
|
|
176
|
-
type: "text" as const,
|
|
177
|
-
text: "\n\n⚠️ ralph: 3+ failures this iteration. Stop and describe the root cause before retrying.",
|
|
178
|
-
},
|
|
216
|
+
{ type: "text" as const, text: "\n\n⚠️ ralph: 3+ failures this iteration. Stop and describe the root cause before retrying." },
|
|
179
217
|
],
|
|
180
218
|
};
|
|
181
219
|
}
|
|
182
220
|
});
|
|
183
221
|
|
|
184
|
-
// /ralph command: start the loop
|
|
185
222
|
pi.registerCommand("ralph", {
|
|
186
223
|
description: "Start an autonomous ralph loop from a RALPH.md file",
|
|
187
|
-
handler: async (args, ctx) => {
|
|
224
|
+
handler: async (args: string, ctx: any) => {
|
|
188
225
|
if (loopState.active) {
|
|
189
226
|
ctx.ui.notify("A ralph loop is already running. Use /ralph-stop first.", "warning");
|
|
190
227
|
return;
|
|
191
228
|
}
|
|
192
229
|
|
|
193
|
-
let
|
|
230
|
+
let name: string;
|
|
194
231
|
try {
|
|
195
|
-
ralphPath = resolveRalphPath(args ?? "", ctx.cwd);
|
|
232
|
+
const ralphPath = resolveRalphPath(args ?? "", ctx.cwd);
|
|
233
|
+
const { frontmatter } = parseRalphMd(ralphPath);
|
|
234
|
+
if (!validateFrontmatter(frontmatter, ctx)) return;
|
|
235
|
+
name = basename(dirname(ralphPath));
|
|
236
|
+
loopState = {
|
|
237
|
+
active: true,
|
|
238
|
+
ralphPath,
|
|
239
|
+
iteration: 0,
|
|
240
|
+
maxIterations: frontmatter.maxIterations,
|
|
241
|
+
timeout: frontmatter.timeout,
|
|
242
|
+
completionPromise: frontmatter.completionPromise,
|
|
243
|
+
stopRequested: false,
|
|
244
|
+
iterationSummaries: [],
|
|
245
|
+
guardrails: { blockCommands: frontmatter.guardrails.blockCommands, protectedFiles: frontmatter.guardrails.protectedFiles },
|
|
246
|
+
loopSessionFile: undefined,
|
|
247
|
+
};
|
|
196
248
|
} catch (err) {
|
|
197
249
|
ctx.ui.notify(String(err), "error");
|
|
198
250
|
return;
|
|
199
251
|
}
|
|
200
|
-
|
|
201
|
-
const { frontmatter } = parseRalphMd(ralphPath);
|
|
202
|
-
loopState = {
|
|
203
|
-
active: true,
|
|
204
|
-
ralphPath,
|
|
205
|
-
iteration: 0,
|
|
206
|
-
maxIterations: frontmatter.maxIterations,
|
|
207
|
-
stopRequested: false,
|
|
208
|
-
failCount: 0,
|
|
209
|
-
iterationSummaries: [],
|
|
210
|
-
guardrails: parseGuardrails(frontmatter),
|
|
211
|
-
};
|
|
212
|
-
|
|
213
|
-
const name = basename(dirname(ralphPath));
|
|
214
252
|
ctx.ui.notify(`Ralph loop started: ${name} (max ${loopState.maxIterations} iterations)`, "info");
|
|
215
253
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
254
|
+
try {
|
|
255
|
+
iterationLoop: for (let i = 1; i <= loopState.maxIterations; i++) {
|
|
256
|
+
if (loopState.stopRequested) break;
|
|
257
|
+
const persistedBefore = readPersistedLoopState(ctx);
|
|
258
|
+
if (persistedBefore?.active && persistedBefore.stopRequested) {
|
|
259
|
+
loopState.stopRequested = true;
|
|
260
|
+
ctx.ui.notify("Ralph loop stopping after current iteration…", "info");
|
|
261
|
+
break;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
loopState.iteration = i;
|
|
265
|
+
const iterStart = Date.now();
|
|
266
|
+
const { frontmatter: fm, body: rawBody } = parseRalphMd(loopState.ralphPath);
|
|
267
|
+
if (!validateFrontmatter(fm, ctx)) {
|
|
268
|
+
ctx.ui.notify(`Invalid RALPH.md on iteration ${i}, stopping loop`, "error");
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
loopState.maxIterations = fm.maxIterations;
|
|
273
|
+
loopState.timeout = fm.timeout;
|
|
274
|
+
loopState.completionPromise = fm.completionPromise;
|
|
275
|
+
loopState.guardrails = { blockCommands: fm.guardrails.blockCommands, protectedFiles: fm.guardrails.protectedFiles };
|
|
276
|
+
|
|
277
|
+
const outputs = await runCommands(fm.commands, pi);
|
|
278
|
+
let body = resolvePlaceholders(rawBody, outputs, { iteration: i, name });
|
|
279
|
+
body = body.replace(/<!--[\s\S]*?-->/g, "");
|
|
280
|
+
const prompt = `[ralph: iteration ${i}/${loopState.maxIterations}]\n\n${body}`;
|
|
281
|
+
|
|
282
|
+
const prevPersisted = readPersistedLoopState(ctx);
|
|
283
|
+
if (prevPersisted?.active && prevPersisted.sessionFile === ctx.sessionManager.getSessionFile()) persistLoopState(pi, { ...prevPersisted, active: false });
|
|
284
|
+
ctx.ui.setStatus("ralph", `🔁 ${name}: iteration ${i}/${loopState.maxIterations}`);
|
|
285
|
+
const prevSessionFile = loopState.loopSessionFile;
|
|
286
|
+
const { cancelled } = await ctx.newSession();
|
|
287
|
+
if (cancelled) {
|
|
288
|
+
ctx.ui.notify("Session switch cancelled, stopping loop", "warning");
|
|
289
|
+
break;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
loopState.loopSessionFile = ctx.sessionManager.getSessionFile();
|
|
293
|
+
if (prevSessionFile && prevSessionFile !== loopState.loopSessionFile) failCounts.delete(prevSessionFile);
|
|
294
|
+
if (loopState.loopSessionFile) failCounts.set(loopState.loopSessionFile, 0);
|
|
295
|
+
persistLoopState(pi, {
|
|
296
|
+
active: true,
|
|
297
|
+
sessionFile: loopState.loopSessionFile,
|
|
298
|
+
iteration: loopState.iteration,
|
|
299
|
+
maxIterations: loopState.maxIterations,
|
|
300
|
+
iterationSummaries: loopState.iterationSummaries,
|
|
301
|
+
guardrails: { blockCommands: loopState.guardrails.blockCommands, protectedFiles: loopState.guardrails.protectedFiles },
|
|
302
|
+
stopRequested: false,
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
pi.sendUserMessage(prompt);
|
|
306
|
+
const timeoutMs = fm.timeout * 1000;
|
|
307
|
+
let timedOut = false;
|
|
308
|
+
let idleError: Error | undefined;
|
|
309
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
310
|
+
try {
|
|
311
|
+
await Promise.race([
|
|
312
|
+
ctx.waitForIdle().catch((e: any) => {
|
|
313
|
+
idleError = e instanceof Error ? e : new Error(String(e));
|
|
314
|
+
throw e;
|
|
315
|
+
}),
|
|
316
|
+
new Promise<never>((_, reject) => {
|
|
317
|
+
timer = setTimeout(() => {
|
|
318
|
+
timedOut = true;
|
|
319
|
+
reject(new Error("timeout"));
|
|
320
|
+
}, timeoutMs);
|
|
321
|
+
}),
|
|
322
|
+
]);
|
|
323
|
+
} catch {
|
|
324
|
+
// timedOut is set by timer; idleError means waitForIdle failed
|
|
325
|
+
}
|
|
326
|
+
if (timer) clearTimeout(timer);
|
|
327
|
+
if (timedOut) {
|
|
328
|
+
ctx.ui.notify(`Iteration ${i} timed out after ${fm.timeout}s, stopping loop`, "warning");
|
|
329
|
+
break;
|
|
330
|
+
}
|
|
331
|
+
if (idleError) {
|
|
332
|
+
ctx.ui.notify(`Iteration ${i} agent error: ${idleError.message}, stopping loop`, "error");
|
|
333
|
+
break;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const elapsed = Math.round((Date.now() - iterStart) / 1000);
|
|
337
|
+
loopState.iterationSummaries.push({ iteration: i, duration: elapsed });
|
|
338
|
+
pi.appendEntry("ralph-iteration", { iteration: i, duration: elapsed, ralphPath: loopState.ralphPath });
|
|
339
|
+
|
|
340
|
+
const persistedAfter = readPersistedLoopState(ctx);
|
|
341
|
+
if (persistedAfter?.active && persistedAfter.stopRequested) {
|
|
342
|
+
loopState.stopRequested = true;
|
|
343
|
+
ctx.ui.notify("Ralph loop stopping after current iteration…", "info");
|
|
344
|
+
break;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (fm.completionPromise) {
|
|
348
|
+
const entries = ctx.sessionManager.getEntries();
|
|
349
|
+
for (const entry of entries) {
|
|
350
|
+
if (entry.type === "message" && entry.message?.role === "assistant") {
|
|
351
|
+
const text = entry.message.content?.filter((b: any) => b.type === "text")?.map((b: any) => b.text)?.join("") ?? "";
|
|
352
|
+
const match = text.match(/<promise>([^<]+)<\/promise>/);
|
|
353
|
+
if (match && fm.completionPromise && match[1].trim() === fm.completionPromise.trim()) {
|
|
354
|
+
ctx.ui.notify(`Completion promise matched on iteration ${i}`, "info");
|
|
355
|
+
break iterationLoop;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
ctx.ui.notify(`Iteration ${i} complete (${elapsed}s)`, "info");
|
|
362
|
+
}
|
|
245
363
|
|
|
246
|
-
|
|
364
|
+
const total = loopState.iterationSummaries.reduce((a, s) => a + s.duration, 0);
|
|
365
|
+
ctx.ui.notify(`Ralph loop done: ${loopState.iteration} iterations, ${total}s total`, "info");
|
|
366
|
+
} catch (err) {
|
|
367
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
368
|
+
ctx.ui.notify(`Ralph loop failed: ${message}`, "error");
|
|
369
|
+
} finally {
|
|
370
|
+
failCounts.clear();
|
|
371
|
+
loopState.active = false;
|
|
372
|
+
loopState.stopRequested = false;
|
|
373
|
+
loopState.loopSessionFile = undefined;
|
|
374
|
+
ctx.ui.setStatus("ralph", undefined);
|
|
375
|
+
persistLoopState(pi, { active: false });
|
|
247
376
|
}
|
|
248
|
-
|
|
249
|
-
loopState.active = false;
|
|
250
|
-
ctx.ui.setStatus("ralph", undefined);
|
|
251
|
-
const total = loopState.iterationSummaries.reduce((a, s) => a + s.duration, 0);
|
|
252
|
-
ctx.ui.notify(
|
|
253
|
-
`Ralph loop done: ${loopState.iteration} iterations, ${total}s total`,
|
|
254
|
-
"info",
|
|
255
|
-
);
|
|
256
377
|
},
|
|
257
378
|
});
|
|
258
379
|
|
|
259
|
-
// /ralph-stop command: graceful stop
|
|
260
380
|
pi.registerCommand("ralph-stop", {
|
|
261
381
|
description: "Stop the ralph loop after the current iteration",
|
|
262
|
-
handler: async (_args, ctx) => {
|
|
263
|
-
|
|
264
|
-
|
|
382
|
+
handler: async (_args: string, ctx: any) => {
|
|
383
|
+
const persisted = readPersistedLoopState(ctx);
|
|
384
|
+
if (!persisted?.active) {
|
|
385
|
+
if (!loopState.active) {
|
|
386
|
+
ctx.ui.notify("No active ralph loop", "warning");
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
loopState.stopRequested = true;
|
|
390
|
+
ctx.ui.notify("Ralph loop stopping after current iteration…", "info");
|
|
265
391
|
return;
|
|
266
392
|
}
|
|
267
393
|
loopState.stopRequested = true;
|
|
394
|
+
persistLoopState(pi, { ...persisted, stopRequested: true });
|
|
268
395
|
ctx.ui.notify("Ralph loop stopping after current iteration…", "info");
|
|
269
396
|
},
|
|
270
397
|
});
|
package/src/shims.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
declare module "yaml" {
|
|
2
|
+
export function parse(input: string): any;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
declare module "minimatch" {
|
|
6
|
+
export function minimatch(path: string, pattern: string, options?: any): boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
declare module "node:fs" {
|
|
10
|
+
export function readFileSync(path: string, encoding: string): string;
|
|
11
|
+
export function existsSync(path: string): boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
declare module "node:path" {
|
|
15
|
+
export function resolve(...paths: string[]): string;
|
|
16
|
+
export function join(...paths: string[]): string;
|
|
17
|
+
export function dirname(path: string): string;
|
|
18
|
+
export function basename(path: string): string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
declare module "@mariozechner/pi-coding-agent" {
|
|
22
|
+
export type ExtensionAPI = any;
|
|
23
|
+
}
|