@lnilluv/pi-ralph-loop 0.0.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.
@@ -0,0 +1,29 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches-ignore:
6
+ - main
7
+ - dev
8
+ pull_request:
9
+
10
+ permissions: read-all
11
+
12
+ jobs:
13
+ check:
14
+ runs-on: ubuntu-latest
15
+
16
+ steps:
17
+ - name: Checkout
18
+ uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
19
+
20
+ - name: Setup Node.js 22
21
+ uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
22
+ with:
23
+ node-version: 22
24
+
25
+ - name: Install dependencies
26
+ run: npm ci --ignore-scripts
27
+
28
+ - name: Typecheck
29
+ run: npm run typecheck
@@ -0,0 +1,150 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+ - dev
8
+
9
+ permissions:
10
+ contents: write
11
+ id-token: write
12
+
13
+ concurrency:
14
+ group: release-${{ github.ref }}
15
+ cancel-in-progress: false
16
+
17
+ jobs:
18
+ release:
19
+ runs-on: ubuntu-latest
20
+
21
+ steps:
22
+ - name: Checkout
23
+ uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
24
+ with:
25
+ fetch-depth: 0
26
+
27
+ - name: Setup Node.js 22
28
+ uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
29
+ with:
30
+ node-version: 22
31
+ registry-url: https://registry.npmjs.org
32
+
33
+ - name: Install dependencies
34
+ run: npm ci --ignore-scripts
35
+
36
+ - name: Typecheck
37
+ run: npm run typecheck
38
+
39
+ - name: Determine version bump
40
+ id: bump
41
+ shell: bash
42
+ run: |
43
+ set -euo pipefail
44
+
45
+ last_tag="$(git describe --tags --abbrev=0 2>/dev/null || true)"
46
+ if [ -n "$last_tag" ]; then
47
+ range="${last_tag}..HEAD"
48
+ else
49
+ range="HEAD"
50
+ fi
51
+
52
+ subjects="$(git log --format=%s $range)"
53
+ bodies="$(git log --format=%b $range)"
54
+
55
+ bump=""
56
+ if echo "$subjects" | grep -Eq '^(feat|fix)!:' || echo "$bodies" | grep -q 'BREAKING CHANGE'; then
57
+ bump="major"
58
+ elif echo "$subjects" | grep -Eq '^feat(\(.+\))?:'; then
59
+ bump="minor"
60
+ elif echo "$subjects" | grep -Eq '^fix(\(.+\))?:'; then
61
+ bump="patch"
62
+ fi
63
+
64
+ if [ -z "$bump" ]; then
65
+ echo "should_release=false" >> "$GITHUB_OUTPUT"
66
+ echo "No feat/fix commits found since ${last_tag:-repository start}; skipping release."
67
+ exit 0
68
+ fi
69
+
70
+ branch="${GITHUB_REF_NAME}"
71
+ current_version="$(node -p "require('./package.json').version")"
72
+ new_version="$(node - "$current_version" "$branch" "$bump" <<'NODE'
73
+ const [current, branch, bump] = process.argv.slice(2);
74
+ const match = current.match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/);
75
+ if (!match) throw new Error(`Unsupported version: ${current}`);
76
+
77
+ let major = Number(match[1]);
78
+ let minor = Number(match[2]);
79
+ let patch = Number(match[3]);
80
+ const prerelease = match[4];
81
+
82
+ if (branch === 'dev') {
83
+ if (prerelease && prerelease.startsWith('dev.')) {
84
+ const currentN = Number(prerelease.split('.')[1] ?? '0');
85
+ process.stdout.write(`${major}.${minor}.${patch}-dev.${currentN + 1}`);
86
+ } else {
87
+ if (bump === 'major') { major += 1; minor = 0; patch = 0; }
88
+ else if (bump === 'minor') { minor += 1; patch = 0; }
89
+ else { patch += 1; }
90
+ process.stdout.write(`${major}.${minor}.${patch}-dev.0`);
91
+ }
92
+ } else {
93
+ if (bump === 'major') { major += 1; minor = 0; patch = 0; }
94
+ else if (bump === 'minor') { minor += 1; patch = 0; }
95
+ else { patch += 1; }
96
+ process.stdout.write(`${major}.${minor}.${patch}`);
97
+ }
98
+ NODE
99
+ )"
100
+
101
+ echo "should_release=true" >> "$GITHUB_OUTPUT"
102
+ echo "bump=$bump" >> "$GITHUB_OUTPUT"
103
+ echo "new_version=$new_version" >> "$GITHUB_OUTPUT"
104
+
105
+ - name: Bump version (main)
106
+ if: steps.bump.outputs.should_release == 'true' && github.ref_name == 'main'
107
+ run: npm version ${{ steps.bump.outputs.bump }} --no-git-tag-version
108
+
109
+ - name: Bump version (dev)
110
+ if: steps.bump.outputs.should_release == 'true' && github.ref_name == 'dev'
111
+ run: npm version prerelease --preid dev --no-git-tag-version
112
+
113
+ - name: Capture version tag
114
+ if: steps.bump.outputs.should_release == 'true'
115
+ id: version
116
+ run: |
117
+ VERSION=$(node -p "require('./package.json').version")
118
+ echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
119
+
120
+ - name: Publish (main)
121
+ if: steps.bump.outputs.should_release == 'true' && github.ref_name == 'main'
122
+ run: npx npm@11 publish --access public --provenance
123
+
124
+ - name: Publish (dev prerelease)
125
+ if: steps.bump.outputs.should_release == 'true' && github.ref_name == 'dev'
126
+ run: npx npm@11 publish --access public --provenance --tag dev
127
+
128
+ - name: Commit version bump and tag
129
+ if: steps.bump.outputs.should_release == 'true'
130
+ run: |
131
+ git config user.name "github-actions[bot]"
132
+ git config user.email "github-actions[bot]@users.noreply.github.com"
133
+ VERSION=$(node -p "require('./package.json').version")
134
+ git add package.json package-lock.json
135
+ git commit -m "chore(release): v${VERSION}"
136
+ git tag "v${VERSION}"
137
+ git push
138
+ git push --tags
139
+
140
+ - name: Create GitHub Release (main)
141
+ if: steps.bump.outputs.should_release == 'true' && github.ref_name == 'main'
142
+ run: gh release create "${{ steps.version.outputs.tag }}" --generate-notes
143
+ env:
144
+ GH_TOKEN: ${{ github.token }}
145
+
146
+ - name: Create GitHub Release (dev prerelease)
147
+ if: steps.bump.outputs.should_release == 'true' && github.ref_name == 'dev'
148
+ run: gh release create "${{ steps.version.outputs.tag }}" --prerelease --generate-notes
149
+ env:
150
+ GH_TOKEN: ${{ github.token }}
package/README.md ADDED
@@ -0,0 +1,106 @@
1
+ # pi-ralph
2
+
3
+ Autonomous coding loops for pi with mid-turn supervision.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pi install npm:@lnilluv/pi-ralph-loop
9
+ ```
10
+
11
+ ## Quick start
12
+
13
+ ```md
14
+ # my-task/RALPH.md
15
+ ---
16
+ commands:
17
+ - name: tests
18
+ run: npm test -- --runInBand
19
+ timeout: 60
20
+ ---
21
+ Fix failing tests using this output:
22
+
23
+ {{ commands.tests }}
24
+ ```
25
+
26
+ Run `/ralph my-task` in pi.
27
+
28
+ ## How it works
29
+
30
+ On each iteration, pi-ralph reads `RALPH.md`, runs the configured commands, injects their output into the prompt through `{{ commands.<name> }}` placeholders, starts a fresh session, sends the prompt, and waits for completion. Failed test output appears in the next iteration, which creates a self-healing loop.
31
+
32
+ ## RALPH.md format
33
+
34
+ ```md
35
+ ---
36
+ commands:
37
+ - name: tests
38
+ run: npm test -- --runInBand
39
+ timeout: 90
40
+ - name: lint
41
+ run: npm run lint
42
+ timeout: 60
43
+ max_iterations: 25
44
+ guardrails:
45
+ block_commands:
46
+ - "rm\\s+-rf\\s+/"
47
+ - "git\\s+push"
48
+ protected_files:
49
+ - ".env*"
50
+ - "**/secrets/**"
51
+ ---
52
+ You are fixing flaky tests in the auth module.
53
+
54
+ Latest test output:
55
+ {{ commands.tests }}
56
+
57
+ Latest lint output:
58
+ {{ commands.lint }}
59
+
60
+ Apply the smallest safe fix and explain why it works.
61
+ ```
62
+
63
+ | Field | Type | Default | Description |
64
+ |-------|------|---------|-------------|
65
+ | `commands` | array | `[]` | Commands to run each iteration |
66
+ | `commands[].name` | string | required | Key for `{{ commands.<name> }}` |
67
+ | `commands[].run` | string | required | Shell command |
68
+ | `commands[].timeout` | number | `60` | Seconds before kill |
69
+ | `max_iterations` | number | `50` | Stop after N iterations |
70
+ | `guardrails.block_commands` | string[] | `[]` | Regex patterns to block in bash |
71
+ | `guardrails.protected_files` | string[] | `[]` | Glob patterns to block writes |
72
+
73
+ ## Commands
74
+
75
+ - `/ralph <path>`: Start the loop from a `RALPH.md` file or directory.
76
+ - `/ralph-stop`: Request a graceful stop after the current iteration.
77
+
78
+ ## Pi-only features
79
+
80
+ ### Guardrails
81
+
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.
83
+
84
+ ### Cross-iteration memory
85
+
86
+ After each iteration, the extension stores a short summary with iteration number and duration. In `before_agent_start`, it injects that history into the system prompt so the next run can avoid repeating completed work.
87
+
88
+ ### Mid-turn steering
89
+
90
+ 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
+
92
+ ## Comparison table
93
+
94
+ | Feature | **@lnilluv/pi-ralph-loop** | pi-ralph | pi-ralph-wiggum | ralphi | ralphify |
95
+ |---------|------------------------|----------------------|-----------------|--------|----------|
96
+ | Command output injection | ✓ | ✗ | ✗ | ✗ | ✓ |
97
+ | Fresh-context sessions | ✓ | ✓ | ✗ | ✓ | ✓ |
98
+ | Mid-turn guardrails | ✓ | ✗ | ✗ | ✗ | ✗ |
99
+ | Cross-iteration memory | ✓ | ✗ | ✗ | ✗ | ✗ |
100
+ | Mid-turn steering | ✓ | ✗ | ✗ | ✗ | ✗ |
101
+ | Live prompt editing | ✓ | ✗ | ✗ | ✗ | ✓ |
102
+ | Setup required | RALPH.md | config | RALPH.md | PRD pipeline | RALPH.md |
103
+
104
+ ## License
105
+
106
+ MIT
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@lnilluv/pi-ralph-loop",
3
+ "version": "0.0.1",
4
+ "description": "Pi-native ralph loop — autonomous coding iterations with mid-turn supervision",
5
+ "type": "module",
6
+ "pi": {
7
+ "extensions": [
8
+ "./src/index.ts"
9
+ ]
10
+ },
11
+ "scripts": {
12
+ "typecheck": "tsc --noEmit"
13
+ },
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/lnilluv/pi-ralph-loop.git"
17
+ },
18
+ "dependencies": {
19
+ "yaml": "2.7.1",
20
+ "minimatch": "10.0.1"
21
+ },
22
+ "peerDependencies": {
23
+ "@mariozechner/pi-coding-agent": "*"
24
+ },
25
+ "keywords": [
26
+ "pi-extension",
27
+ "ralph",
28
+ "autonomous",
29
+ "loop"
30
+ ],
31
+ "license": "MIT"
32
+ }
package/src/index.ts ADDED
@@ -0,0 +1,271 @@
1
+ import { parse as parseYaml } from "yaml";
2
+ import { minimatch } from "minimatch";
3
+ import { readFileSync, existsSync } from "node:fs";
4
+ import { resolve, join, dirname, basename } from "node:path";
5
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
6
+
7
+ // --- Types ---
8
+
9
+ type CommandDef = { name: string; run: string; timeout: number };
10
+
11
+ type Frontmatter = {
12
+ commands: CommandDef[];
13
+ maxIterations: number;
14
+ guardrails: { blockCommands: string[]; protectedFiles: string[] };
15
+ };
16
+
17
+ type ParsedRalph = { frontmatter: Frontmatter; body: string };
18
+ type CommandOutput = { name: string; output: string };
19
+
20
+ type LoopState = {
21
+ active: boolean;
22
+ ralphPath: string;
23
+ iteration: number;
24
+ maxIterations: number;
25
+ stopRequested: boolean;
26
+ failCount: number;
27
+ iterationSummaries: Array<{ iteration: number; duration: number }>;
28
+ guardrails: { blockCommands: RegExp[]; protectedFiles: string[] };
29
+ };
30
+
31
+ // --- Parsing ---
32
+
33
+ function defaultFrontmatter(): Frontmatter {
34
+ return { commands: [], maxIterations: 50, guardrails: { blockCommands: [], protectedFiles: [] } };
35
+ }
36
+
37
+ function parseRalphMd(filePath: string): ParsedRalph {
38
+ const raw = readFileSync(filePath, "utf8");
39
+ const match = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
40
+ if (!match) return { frontmatter: defaultFrontmatter(), body: raw };
41
+
42
+ const yaml = parseYaml(match[1]) ?? {};
43
+ const commands: CommandDef[] = Array.isArray(yaml.commands)
44
+ ? yaml.commands.map((c: Record<string, unknown>) => ({
45
+ name: String(c.name ?? ""),
46
+ run: String(c.run ?? ""),
47
+ timeout: Number(c.timeout ?? 60),
48
+ }))
49
+ : [];
50
+
51
+ const guardrails = yaml.guardrails ?? {};
52
+ return {
53
+ frontmatter: {
54
+ commands,
55
+ maxIterations: Number(yaml.max_iterations ?? 50),
56
+ guardrails: {
57
+ blockCommands: Array.isArray(guardrails.block_commands) ? guardrails.block_commands : [],
58
+ protectedFiles: Array.isArray(guardrails.protected_files) ? guardrails.protected_files : [],
59
+ },
60
+ },
61
+ body: match[2],
62
+ };
63
+ }
64
+
65
+ function resolveRalphPath(args: string, cwd: string): string {
66
+ const target = args.trim() || ".";
67
+ const abs = resolve(cwd, target);
68
+ if (existsSync(abs) && abs.endsWith(".md")) return abs;
69
+ if (existsSync(join(abs, "RALPH.md"))) return join(abs, "RALPH.md");
70
+ throw new Error(`No RALPH.md found at ${abs}`);
71
+ }
72
+
73
+ function resolvePlaceholders(body: string, outputs: CommandOutput[]): string {
74
+ const map = new Map(outputs.map((o) => [o.name, o.output]));
75
+ return body.replace(/\{\{\s*commands\.(\w[\w-]*)\s*\}\}/g, (_, name) => map.get(name) ?? "");
76
+ }
77
+
78
+ function parseGuardrails(fm: Frontmatter): LoopState["guardrails"] {
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[]> {
92
+ const results: CommandOutput[] = [];
93
+ for (const cmd of commands) {
94
+ try {
95
+ 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
+ } catch {
101
+ results.push({ name: cmd.name, output: `[timed out after ${cmd.timeout}s]` });
102
+ }
103
+ }
104
+ return results;
105
+ }
106
+
107
+ // --- Extension ---
108
+
109
+ let loopState: LoopState = {
110
+ active: false,
111
+ ralphPath: "",
112
+ iteration: 0,
113
+ maxIterations: 50,
114
+ stopRequested: false,
115
+ failCount: 0,
116
+ iterationSummaries: [],
117
+ guardrails: { blockCommands: [], protectedFiles: [] },
118
+ };
119
+
120
+ export default function (pi: ExtensionAPI) {
121
+ // Guardrails: block dangerous tool calls during loop
122
+ pi.on("tool_call", async (event) => {
123
+ if (!loopState.active) return;
124
+
125
+ if (event.toolName === "bash") {
126
+ const cmd = (event.input as { command?: string }).command ?? "";
127
+ for (const pattern of loopState.guardrails.blockCommands) {
128
+ if (pattern.test(cmd)) {
129
+ return { block: true, reason: `ralph: blocked (${pattern.source})` };
130
+ }
131
+ }
132
+ }
133
+
134
+ if (event.toolName === "write" || event.toolName === "edit") {
135
+ const filePath = (event.input as { path?: string }).path ?? "";
136
+ for (const glob of loopState.guardrails.protectedFiles) {
137
+ if (minimatch(filePath, glob, { matchBase: true })) {
138
+ return { block: true, reason: `ralph: ${filePath} is protected` };
139
+ }
140
+ }
141
+ }
142
+ });
143
+
144
+ // Cross-iteration memory: inject context into system prompt
145
+ pi.on("before_agent_start", async (event) => {
146
+ if (!loopState.active || loopState.iterationSummaries.length === 0) return;
147
+
148
+ const history = loopState.iterationSummaries
149
+ .map((s) => `- Iteration ${s.iteration}: ${s.duration}s`)
150
+ .join("\n");
151
+
152
+ return {
153
+ systemPrompt:
154
+ event.systemPrompt +
155
+ `\n\n## Ralph Loop Context\nIteration ${loopState.iteration}/${loopState.maxIterations}\n\nPrevious iterations:\n${history}\n\nDo not repeat completed work. Check git log for recent changes.`,
156
+ };
157
+ });
158
+
159
+ // Mid-turn steering: warn after repeated failures
160
+ pi.on("tool_result", async (event) => {
161
+ if (!loopState.active || event.toolName !== "bash") return;
162
+
163
+ const output = event.content
164
+ .map((c: { type: string; text?: string }) => (c.type === "text" ? c.text ?? "" : ""))
165
+ .join("");
166
+
167
+ if (/FAIL|ERROR|error:|failed/i.test(output)) {
168
+ loopState.failCount++;
169
+ }
170
+
171
+ if (loopState.failCount >= 3) {
172
+ return {
173
+ content: [
174
+ ...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
+ },
179
+ ],
180
+ };
181
+ }
182
+ });
183
+
184
+ // /ralph command: start the loop
185
+ pi.registerCommand("ralph", {
186
+ description: "Start an autonomous ralph loop from a RALPH.md file",
187
+ handler: async (args, ctx) => {
188
+ if (loopState.active) {
189
+ ctx.ui.notify("A ralph loop is already running. Use /ralph-stop first.", "warning");
190
+ return;
191
+ }
192
+
193
+ let ralphPath: string;
194
+ try {
195
+ ralphPath = resolveRalphPath(args ?? "", ctx.cwd);
196
+ } catch (err) {
197
+ ctx.ui.notify(String(err), "error");
198
+ return;
199
+ }
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
+ ctx.ui.notify(`Ralph loop started: ${name} (max ${loopState.maxIterations} iterations)`, "info");
215
+
216
+ for (let i = 1; i <= loopState.maxIterations; i++) {
217
+ if (loopState.stopRequested) break;
218
+
219
+ loopState.iteration = i;
220
+ loopState.failCount = 0;
221
+ const iterStart = Date.now();
222
+
223
+ // Re-parse every iteration (live editing support)
224
+ const { frontmatter: fm, body } = parseRalphMd(loopState.ralphPath);
225
+ loopState.maxIterations = fm.maxIterations;
226
+ loopState.guardrails = parseGuardrails(fm);
227
+
228
+ // Run commands and resolve placeholders
229
+ const outputs = await runCommands(fm.commands, ctx.cwd, pi);
230
+ const header = `[ralph: iteration ${i}/${loopState.maxIterations}]\n\n`;
231
+ const prompt = header + resolvePlaceholders(body, outputs);
232
+
233
+ // Fresh session
234
+ ctx.ui.setStatus("ralph", `🔁 ${name}: iteration ${i}/${loopState.maxIterations}`);
235
+ await ctx.newSession();
236
+
237
+ // Send prompt and wait for agent to finish
238
+ pi.sendUserMessage(prompt);
239
+ await ctx.waitForIdle();
240
+
241
+ // Record iteration
242
+ const elapsed = Math.round((Date.now() - iterStart) / 1000);
243
+ loopState.iterationSummaries.push({ iteration: i, duration: elapsed });
244
+ pi.appendEntry("ralph-iteration", { iteration: i, duration: elapsed, ralphPath: loopState.ralphPath });
245
+
246
+ ctx.ui.notify(`Iteration ${i} complete (${elapsed}s)`, "info");
247
+ }
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
+ "success",
255
+ );
256
+ },
257
+ });
258
+
259
+ // /ralph-stop command: graceful stop
260
+ pi.registerCommand("ralph-stop", {
261
+ description: "Stop the ralph loop after the current iteration",
262
+ handler: async (_args, ctx) => {
263
+ if (!loopState.active) {
264
+ ctx.ui.notify("No active ralph loop", "warning");
265
+ return;
266
+ }
267
+ loopState.stopRequested = true;
268
+ ctx.ui.notify("Ralph loop stopping after current iteration…", "info");
269
+ },
270
+ });
271
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,12 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ES2022",
5
+ "moduleResolution": "bundler",
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "skipLibCheck": true,
9
+ "noEmit": true
10
+ },
11
+ "include": ["src"]
12
+ }