@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.
- package/.github/workflows/ci.yml +29 -0
- package/.github/workflows/release.yml +150 -0
- package/README.md +106 -0
- package/package.json +32 -0
- package/src/index.ts +271 -0
- package/tsconfig.json +12 -0
|
@@ -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
|
+
}
|