@graypark/ralph-codex 0.1.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/LICENSE +21 -0
- package/README.md +176 -0
- package/bin/install.mjs +230 -0
- package/bin/uninstall.mjs +130 -0
- package/commands/cancel-ralph.md +30 -0
- package/commands/ralph-loop.md +73 -0
- package/hooks/hooks.json +16 -0
- package/hooks/stop-hook.mjs +154 -0
- package/lib/paths.mjs +41 -0
- package/lib/state.mjs +45 -0
- package/lib/stop-hook-core.mjs +129 -0
- package/package.json +49 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Viewcommz
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# ralph-codex
|
|
2
|
+
|
|
3
|
+
Ralph Loop for **OpenAI Codex CLI** — self-referential iterative development loops powered by Stop hooks.
|
|
4
|
+
|
|
5
|
+
Ralph Loop is a development methodology where an AI agent works on a task in a continuous loop, seeing its own previous work each iteration, until a completion condition is met. This package brings that capability to Codex CLI with full cross-platform support.
|
|
6
|
+
|
|
7
|
+
## Requirements
|
|
8
|
+
|
|
9
|
+
- **Node.js** 18+
|
|
10
|
+
- **Codex CLI** v0.114+ (experimental hooks engine required)
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
### Option 1: npx (recommended)
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npx @graypark/ralph-codex --global
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
### Option 2: Clone and install
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
git clone https://github.com/Viewcommz/ralph-codex.git
|
|
24
|
+
cd ralph-codex
|
|
25
|
+
node bin/install.mjs --global
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### Options
|
|
29
|
+
|
|
30
|
+
| Flag | Description |
|
|
31
|
+
| ----------- | ------------------------------------------ |
|
|
32
|
+
| `--global` | Install to `~/.codex/` (default) |
|
|
33
|
+
| `--local` | Install to `.codex/` in current project |
|
|
34
|
+
| `--dry-run` | Preview changes without modifying anything |
|
|
35
|
+
| `--force` | Overwrite existing installation |
|
|
36
|
+
|
|
37
|
+
## Usage
|
|
38
|
+
|
|
39
|
+
### Start a Ralph Loop
|
|
40
|
+
|
|
41
|
+
In Codex CLI, use the slash command:
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
/ralph-loop "Build a REST API for todos with CRUD, validation, and tests" --max-iterations 30 --completion-promise "ALL_TESTS_PASS"
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
**Parameters:**
|
|
48
|
+
|
|
49
|
+
| Parameter | Default | Description |
|
|
50
|
+
| --------------------------- | ---------- | --------------------------------------- |
|
|
51
|
+
| `PROMPT` | (required) | Task description |
|
|
52
|
+
| `--max-iterations N` | 20 | Maximum loop iterations (0 = unlimited) |
|
|
53
|
+
| `--completion-promise TEXT` | "TADA" | Phrase that signals task completion |
|
|
54
|
+
|
|
55
|
+
### Cancel a Loop
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
/cancel-ralph
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### How It Works
|
|
62
|
+
|
|
63
|
+
1. You invoke `/ralph-loop` with a task prompt
|
|
64
|
+
2. Codex works on the task normally
|
|
65
|
+
3. When Codex tries to exit, the **Stop hook** intercepts
|
|
66
|
+
4. The hook checks: max iterations reached? Completion promise found?
|
|
67
|
+
5. If not done, the hook **blocks the exit** and feeds the same prompt back
|
|
68
|
+
6. Codex sees its previous work in files and git history
|
|
69
|
+
7. Codex continues iterating until completion
|
|
70
|
+
|
|
71
|
+
### Completion Promise
|
|
72
|
+
|
|
73
|
+
To signal task completion, Codex must output the promise phrase wrapped in XML tags:
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
<promise>ALL_TESTS_PASS</promise>
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
The promise is only valid when the statement is genuinely true. The loop is designed to prevent false exits.
|
|
80
|
+
|
|
81
|
+
## Prompt Writing Tips
|
|
82
|
+
|
|
83
|
+
### 1. Split into Phases
|
|
84
|
+
|
|
85
|
+
```
|
|
86
|
+
/ralph-loop "Phase 1: Set up project scaffold. Phase 2: Implement core logic. Phase 3: Add tests. Output <promise>DONE</promise> when all phases complete." --max-iterations 30
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### 2. Objective Completion Criteria
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
/ralph-loop "Implement the auth module. Done when: all tests pass, no TypeScript errors, coverage > 80%." --completion-promise "AUTH_COMPLETE" --max-iterations 25
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### 3. Always Set an Escape Hatch
|
|
96
|
+
|
|
97
|
+
Always use `--max-iterations` to prevent infinite loops on impossible tasks.
|
|
98
|
+
|
|
99
|
+
### 4. Self-Correction Pattern
|
|
100
|
+
|
|
101
|
+
```
|
|
102
|
+
/ralph-loop "Fix the failing CI pipeline. Run tests, read errors, fix code, repeat." --max-iterations 15 --completion-promise "CI_GREEN"
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Windows Support
|
|
106
|
+
|
|
107
|
+
ralph-codex works natively on Windows without WSL or Git Bash:
|
|
108
|
+
|
|
109
|
+
- All paths use `path.join()` (no hardcoded slashes)
|
|
110
|
+
- The installer copies files instead of symlinks on Windows
|
|
111
|
+
- State files use JSON (no Unix-specific formats)
|
|
112
|
+
- Hooks use `node` as the interpreter (cross-platform)
|
|
113
|
+
|
|
114
|
+
Tested on: Windows 10/11, macOS, Linux (Ubuntu/Debian).
|
|
115
|
+
|
|
116
|
+
## Uninstall
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
npx @graypark/ralph-codex uninstall
|
|
120
|
+
# or
|
|
121
|
+
node bin/uninstall.mjs --global
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
This removes:
|
|
125
|
+
|
|
126
|
+
- Plugin files from `~/.codex/plugins/ralph-codex/`
|
|
127
|
+
- Stop hook entry from `~/.codex/hooks.json`
|
|
128
|
+
- Skill files for `/ralph-loop` and `/cancel-ralph`
|
|
129
|
+
- Any active state file
|
|
130
|
+
|
|
131
|
+
## Architecture
|
|
132
|
+
|
|
133
|
+
```
|
|
134
|
+
ralph-codex/
|
|
135
|
+
├── bin/
|
|
136
|
+
│ ├── install.mjs # Cross-platform installer
|
|
137
|
+
│ └── uninstall.mjs # Clean uninstaller
|
|
138
|
+
├── hooks/
|
|
139
|
+
│ ├── hooks.json # Hook registration (reference)
|
|
140
|
+
│ └── stop-hook.mjs # Stop hook — the core loop engine
|
|
141
|
+
├── commands/
|
|
142
|
+
│ ├── ralph-loop.md # /ralph-loop slash command
|
|
143
|
+
│ └── cancel-ralph.md # /cancel-ralph slash command
|
|
144
|
+
├── lib/
|
|
145
|
+
│ ├── paths.mjs # Cross-platform path utilities
|
|
146
|
+
│ └── state.mjs # Loop state management
|
|
147
|
+
└── package.json
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## How It Compares to Claude Code's Ralph Loop
|
|
151
|
+
|
|
152
|
+
| Feature | Claude Code (official) | ralph-codex (this) |
|
|
153
|
+
| ------------------ | ------------------------------------- | ------------------------ |
|
|
154
|
+
| Runtime | Bash (sh/perl) | Node.js (cross-platform) |
|
|
155
|
+
| State format | Markdown + YAML frontmatter | JSON |
|
|
156
|
+
| Windows support | WSL required | Native |
|
|
157
|
+
| Hook protocol | `{"decision":"block","reason":"..."}` | Same |
|
|
158
|
+
| Transcript parsing | `jq` + `grep` | Native Node.js |
|
|
159
|
+
| Installation | Plugin marketplace | `npx` or manual |
|
|
160
|
+
|
|
161
|
+
## Development
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
# Install dev dependencies
|
|
165
|
+
npm install
|
|
166
|
+
|
|
167
|
+
# Run tests
|
|
168
|
+
npm test
|
|
169
|
+
|
|
170
|
+
# Run tests in watch mode
|
|
171
|
+
npx vitest
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
## License
|
|
175
|
+
|
|
176
|
+
MIT
|
package/bin/install.mjs
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
readFile,
|
|
5
|
+
writeFile,
|
|
6
|
+
mkdir,
|
|
7
|
+
cp,
|
|
8
|
+
readdir,
|
|
9
|
+
access,
|
|
10
|
+
} from "node:fs/promises";
|
|
11
|
+
import { join, dirname, resolve } from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
import {
|
|
14
|
+
isWindows,
|
|
15
|
+
getCodexHome,
|
|
16
|
+
getHooksJsonPath,
|
|
17
|
+
getPluginInstallDir,
|
|
18
|
+
getSkillsDir,
|
|
19
|
+
getLocalCodexDir,
|
|
20
|
+
getLocalPluginDir,
|
|
21
|
+
getLocalHooksJsonPath,
|
|
22
|
+
getLocalSkillsDir,
|
|
23
|
+
} from "../lib/paths.mjs";
|
|
24
|
+
|
|
25
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
26
|
+
const __dirname = dirname(__filename);
|
|
27
|
+
const PROJECT_ROOT = resolve(__dirname, "..");
|
|
28
|
+
|
|
29
|
+
// Parse CLI args
|
|
30
|
+
const args = process.argv.slice(2);
|
|
31
|
+
const dryRun = args.includes("--dry-run");
|
|
32
|
+
const forceFlag = args.includes("--force");
|
|
33
|
+
const localMode = args.includes("--local");
|
|
34
|
+
const showHelp = args.includes("--help") || args.includes("-h");
|
|
35
|
+
const uninstallMode = args.includes("uninstall");
|
|
36
|
+
|
|
37
|
+
if (showHelp) {
|
|
38
|
+
console.log(`ralph-codex installer
|
|
39
|
+
|
|
40
|
+
Usage:
|
|
41
|
+
node bin/install.mjs [options]
|
|
42
|
+
npx ralph-codex install [options]
|
|
43
|
+
|
|
44
|
+
Options:
|
|
45
|
+
--global Install to ~/.codex/ (default)
|
|
46
|
+
--local Install to .codex/ in current project
|
|
47
|
+
--dry-run Show what would be done without making changes
|
|
48
|
+
--force Overwrite existing installation without prompting
|
|
49
|
+
--help, -h Show this help message
|
|
50
|
+
|
|
51
|
+
uninstall Remove ralph-codex from Codex CLI
|
|
52
|
+
`);
|
|
53
|
+
process.exit(0);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (uninstallMode) {
|
|
57
|
+
const { uninstall } = await import("./uninstall.mjs");
|
|
58
|
+
await uninstall({ dryRun, local: localMode });
|
|
59
|
+
process.exit(0);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Determine target paths based on mode
|
|
63
|
+
const codexHome = localMode ? getLocalCodexDir() : getCodexHome();
|
|
64
|
+
const pluginDir = localMode ? getLocalPluginDir() : getPluginInstallDir();
|
|
65
|
+
const hooksJsonPath = localMode ? getLocalHooksJsonPath() : getHooksJsonPath();
|
|
66
|
+
const skillsDir = localMode ? getLocalSkillsDir() : getSkillsDir();
|
|
67
|
+
|
|
68
|
+
const RALPH_HOOK_MARKER = "ralph-codex";
|
|
69
|
+
|
|
70
|
+
function log(icon, msg) {
|
|
71
|
+
console.log(` ${icon} ${msg}`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function fileExists(p) {
|
|
75
|
+
try {
|
|
76
|
+
await access(p);
|
|
77
|
+
return true;
|
|
78
|
+
} catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function copyPluginFiles() {
|
|
84
|
+
const dirs = ["hooks", "commands", "lib"];
|
|
85
|
+
for (const dir of dirs) {
|
|
86
|
+
const src = join(PROJECT_ROOT, dir);
|
|
87
|
+
const dest = join(pluginDir, dir);
|
|
88
|
+
log(">", `Copy ${dir}/ -> ${dest}`);
|
|
89
|
+
if (!dryRun) {
|
|
90
|
+
await mkdir(dest, { recursive: true });
|
|
91
|
+
await cp(src, dest, { recursive: true });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// Copy package.json for version tracking
|
|
95
|
+
const pkgSrc = join(PROJECT_ROOT, "package.json");
|
|
96
|
+
const pkgDest = join(pluginDir, "package.json");
|
|
97
|
+
log(">", `Copy package.json -> ${pkgDest}`);
|
|
98
|
+
if (!dryRun) {
|
|
99
|
+
await cp(pkgSrc, pkgDest);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function mergeHooksJson() {
|
|
104
|
+
const stopHookCommand = `node "${join(pluginDir, "hooks", "stop-hook.mjs")}"`;
|
|
105
|
+
|
|
106
|
+
const newEntry = {
|
|
107
|
+
hooks: [
|
|
108
|
+
{
|
|
109
|
+
type: "command",
|
|
110
|
+
command: stopHookCommand,
|
|
111
|
+
timeout: 30,
|
|
112
|
+
},
|
|
113
|
+
],
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
let existing = { hooks: {} };
|
|
117
|
+
if (await fileExists(hooksJsonPath)) {
|
|
118
|
+
try {
|
|
119
|
+
const raw = await readFile(hooksJsonPath, "utf-8");
|
|
120
|
+
existing = JSON.parse(raw);
|
|
121
|
+
} catch {
|
|
122
|
+
log("!", `Warning: existing hooks.json is malformed, creating fresh`);
|
|
123
|
+
existing = { hooks: {} };
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (!existing.hooks) {
|
|
128
|
+
existing.hooks = {};
|
|
129
|
+
}
|
|
130
|
+
if (!Array.isArray(existing.hooks.Stop)) {
|
|
131
|
+
existing.hooks.Stop = [];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Remove any existing ralph-codex entries
|
|
135
|
+
existing.hooks.Stop = existing.hooks.Stop.filter((entry) => {
|
|
136
|
+
const cmds = entry.hooks || [];
|
|
137
|
+
return !cmds.some(
|
|
138
|
+
(h) => h.command && h.command.includes(RALPH_HOOK_MARKER),
|
|
139
|
+
);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// Add our entry
|
|
143
|
+
existing.hooks.Stop.push(newEntry);
|
|
144
|
+
|
|
145
|
+
log(">", `Merge Stop hook into ${hooksJsonPath}`);
|
|
146
|
+
log(" ", `Command: ${stopHookCommand}`);
|
|
147
|
+
|
|
148
|
+
if (!dryRun) {
|
|
149
|
+
await mkdir(dirname(hooksJsonPath), { recursive: true });
|
|
150
|
+
await writeFile(hooksJsonPath, JSON.stringify(existing, null, 2), "utf-8");
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function installSkills() {
|
|
155
|
+
const skillNames = ["ralph-loop", "cancel-ralph"];
|
|
156
|
+
for (const name of skillNames) {
|
|
157
|
+
const skillDir = join(skillsDir, name);
|
|
158
|
+
const skillMd = join(skillDir, "SKILL.md");
|
|
159
|
+
const commandSrc = join(pluginDir, "commands", `${name}.md`);
|
|
160
|
+
|
|
161
|
+
log(">", `Install skill: ${name} -> ${skillDir}`);
|
|
162
|
+
|
|
163
|
+
if (!dryRun) {
|
|
164
|
+
await mkdir(skillDir, { recursive: true });
|
|
165
|
+
|
|
166
|
+
// Read the command markdown and convert to SKILL.md format
|
|
167
|
+
const content = await readFile(
|
|
168
|
+
join(PROJECT_ROOT, "commands", `${name}.md`),
|
|
169
|
+
"utf-8",
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
// Replace ${RALPH_CODEX_ROOT} placeholder with actual install path
|
|
173
|
+
const resolved = content.replaceAll("${RALPH_CODEX_ROOT}", pluginDir);
|
|
174
|
+
|
|
175
|
+
await writeFile(skillMd, resolved, "utf-8");
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function main() {
|
|
181
|
+
console.log("");
|
|
182
|
+
console.log(`ralph-codex installer${dryRun ? " (DRY RUN)" : ""}`);
|
|
183
|
+
console.log(`Mode: ${localMode ? "local (.codex/)" : "global (~/.codex/)"}`);
|
|
184
|
+
console.log(`Target: ${pluginDir}`);
|
|
185
|
+
console.log("");
|
|
186
|
+
|
|
187
|
+
// Check for existing installation
|
|
188
|
+
if (!forceFlag && (await fileExists(pluginDir))) {
|
|
189
|
+
if (dryRun) {
|
|
190
|
+
log("!", "Existing installation found (would prompt for --force)");
|
|
191
|
+
} else {
|
|
192
|
+
log("!", "Existing installation found. Use --force to overwrite.");
|
|
193
|
+
process.exit(1);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Step 1: Copy plugin files
|
|
198
|
+
console.log("[1/3] Copying plugin files...");
|
|
199
|
+
await copyPluginFiles();
|
|
200
|
+
|
|
201
|
+
// Step 2: Merge hooks.json
|
|
202
|
+
console.log("[2/3] Configuring Stop hook...");
|
|
203
|
+
await mergeHooksJson();
|
|
204
|
+
|
|
205
|
+
// Step 3: Install skills (slash commands)
|
|
206
|
+
console.log("[3/3] Installing skills...");
|
|
207
|
+
await installSkills();
|
|
208
|
+
|
|
209
|
+
console.log("");
|
|
210
|
+
if (dryRun) {
|
|
211
|
+
log("\u2714", "Dry run complete. No files were modified.");
|
|
212
|
+
} else {
|
|
213
|
+
log("\u2714", "ralph-codex installed successfully!");
|
|
214
|
+
console.log("");
|
|
215
|
+
console.log(" Usage in Codex CLI:");
|
|
216
|
+
console.log(
|
|
217
|
+
' /ralph-loop "Build a REST API" --max-iterations 20 --completion-promise "DONE"',
|
|
218
|
+
);
|
|
219
|
+
console.log(" /cancel-ralph");
|
|
220
|
+
console.log("");
|
|
221
|
+
console.log(" To uninstall:");
|
|
222
|
+
console.log(" node bin/uninstall.mjs" + (localMode ? " --local" : ""));
|
|
223
|
+
}
|
|
224
|
+
console.log("");
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
main().catch((err) => {
|
|
228
|
+
console.error(`\u2718 Installation failed: ${err.message}`);
|
|
229
|
+
process.exit(1);
|
|
230
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { readFile, writeFile, rm, access } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
getHooksJsonPath,
|
|
7
|
+
getPluginInstallDir,
|
|
8
|
+
getSkillsDir,
|
|
9
|
+
getLocalHooksJsonPath,
|
|
10
|
+
getLocalPluginDir,
|
|
11
|
+
getLocalSkillsDir,
|
|
12
|
+
} from "../lib/paths.mjs";
|
|
13
|
+
import { getStatePath } from "../lib/state.mjs";
|
|
14
|
+
|
|
15
|
+
const RALPH_HOOK_MARKER = "ralph-codex";
|
|
16
|
+
|
|
17
|
+
function log(icon, msg) {
|
|
18
|
+
console.log(` ${icon} ${msg}`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function fileExists(p) {
|
|
22
|
+
try {
|
|
23
|
+
await access(p);
|
|
24
|
+
return true;
|
|
25
|
+
} catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function uninstall({ dryRun = false, local = false } = {}) {
|
|
31
|
+
const pluginDir = local ? getLocalPluginDir() : getPluginInstallDir();
|
|
32
|
+
const hooksJsonPath = local ? getLocalHooksJsonPath() : getHooksJsonPath();
|
|
33
|
+
const skillsDir = local ? getLocalSkillsDir() : getSkillsDir();
|
|
34
|
+
|
|
35
|
+
console.log("");
|
|
36
|
+
console.log(`ralph-codex uninstaller${dryRun ? " (DRY RUN)" : ""}`);
|
|
37
|
+
console.log("");
|
|
38
|
+
|
|
39
|
+
// 1. Remove ralph-codex entries from hooks.json
|
|
40
|
+
if (await fileExists(hooksJsonPath)) {
|
|
41
|
+
try {
|
|
42
|
+
const raw = await readFile(hooksJsonPath, "utf-8");
|
|
43
|
+
const config = JSON.parse(raw);
|
|
44
|
+
|
|
45
|
+
if (config.hooks && Array.isArray(config.hooks.Stop)) {
|
|
46
|
+
const before = config.hooks.Stop.length;
|
|
47
|
+
config.hooks.Stop = config.hooks.Stop.filter((entry) => {
|
|
48
|
+
const cmds = entry.hooks || [];
|
|
49
|
+
return !cmds.some(
|
|
50
|
+
(h) => h.command && h.command.includes(RALPH_HOOK_MARKER),
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
const removed = before - config.hooks.Stop.length;
|
|
54
|
+
|
|
55
|
+
if (removed > 0) {
|
|
56
|
+
log(">", `Remove ${removed} Stop hook entry from ${hooksJsonPath}`);
|
|
57
|
+
if (!dryRun) {
|
|
58
|
+
await writeFile(
|
|
59
|
+
hooksJsonPath,
|
|
60
|
+
JSON.stringify(config, null, 2),
|
|
61
|
+
"utf-8",
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
} else {
|
|
65
|
+
log("-", "No ralph-codex hooks found in hooks.json");
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
} catch {
|
|
69
|
+
log("!", `Warning: could not parse ${hooksJsonPath}`);
|
|
70
|
+
}
|
|
71
|
+
} else {
|
|
72
|
+
log("-", "No hooks.json found");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 2. Remove plugin directory
|
|
76
|
+
if (await fileExists(pluginDir)) {
|
|
77
|
+
log(">", `Remove plugin directory: ${pluginDir}`);
|
|
78
|
+
if (!dryRun) {
|
|
79
|
+
await rm(pluginDir, { recursive: true, force: true });
|
|
80
|
+
}
|
|
81
|
+
} else {
|
|
82
|
+
log("-", "Plugin directory not found");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// 3. Remove skill directories
|
|
86
|
+
const skillNames = ["ralph-loop", "cancel-ralph"];
|
|
87
|
+
for (const name of skillNames) {
|
|
88
|
+
const skillDir = join(skillsDir, name);
|
|
89
|
+
if (await fileExists(skillDir)) {
|
|
90
|
+
log(">", `Remove skill: ${skillDir}`);
|
|
91
|
+
if (!dryRun) {
|
|
92
|
+
await rm(skillDir, { recursive: true, force: true });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// 4. Remove state file
|
|
98
|
+
const statePath = getStatePath();
|
|
99
|
+
if (await fileExists(statePath)) {
|
|
100
|
+
log(">", `Remove state file: ${statePath}`);
|
|
101
|
+
if (!dryRun) {
|
|
102
|
+
await rm(statePath, { force: true });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
console.log("");
|
|
107
|
+
if (dryRun) {
|
|
108
|
+
log("\u2714", "Dry run complete. No files were modified.");
|
|
109
|
+
} else {
|
|
110
|
+
log("\u2714", "ralph-codex uninstalled successfully.");
|
|
111
|
+
}
|
|
112
|
+
console.log("");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Run directly if not imported
|
|
116
|
+
const isDirectRun =
|
|
117
|
+
process.argv[1] &&
|
|
118
|
+
(process.argv[1].endsWith("uninstall.mjs") ||
|
|
119
|
+
process.argv[1].endsWith("uninstall"));
|
|
120
|
+
|
|
121
|
+
if (isDirectRun) {
|
|
122
|
+
const args = process.argv.slice(2);
|
|
123
|
+
const dryRun = args.includes("--dry-run");
|
|
124
|
+
const local = args.includes("--local");
|
|
125
|
+
|
|
126
|
+
uninstall({ dryRun, local }).catch((err) => {
|
|
127
|
+
console.error(`\u2718 Uninstall failed: ${err.message}`);
|
|
128
|
+
process.exit(1);
|
|
129
|
+
});
|
|
130
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: cancel-ralph
|
|
3
|
+
description: "Cancel the active Ralph Loop"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Cancel Ralph
|
|
7
|
+
|
|
8
|
+
To cancel the Ralph loop:
|
|
9
|
+
|
|
10
|
+
1. Check if the state file exists at `.codex/ralph-loop.state.json`.
|
|
11
|
+
|
|
12
|
+
2. **If NOT found**: Say "No active Ralph loop found."
|
|
13
|
+
|
|
14
|
+
3. **If found**:
|
|
15
|
+
- Read the state file to get `currentIteration`.
|
|
16
|
+
- Set `active` to `false` by running:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
node -e "
|
|
20
|
+
import { readState, writeState } from '${RALPH_CODEX_ROOT}/lib/state.mjs';
|
|
21
|
+
const state = await readState();
|
|
22
|
+
const iter = state.currentIteration;
|
|
23
|
+
state.active = false;
|
|
24
|
+
await writeState(state);
|
|
25
|
+
console.log('Cancelled Ralph loop at iteration ' + iter);
|
|
26
|
+
"
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Alternatively, edit `.codex/ralph-loop.state.json` directly and set `"active": false`.
|
|
30
|
+
- Report: "Cancelled Ralph loop (was at iteration N)."
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ralph-loop
|
|
3
|
+
description: "Start a Ralph Loop — self-referential iterative development loop"
|
|
4
|
+
argument-hint: "PROMPT [--max-iterations N] [--completion-promise TEXT]"
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Ralph Loop Command
|
|
8
|
+
|
|
9
|
+
You are about to start a Ralph Loop. Parse the user's arguments as follows:
|
|
10
|
+
|
|
11
|
+
## Argument Parsing
|
|
12
|
+
|
|
13
|
+
1. Extract `--max-iterations N` (default: 20). Must be a positive integer or 0 (unlimited).
|
|
14
|
+
2. Extract `--completion-promise TEXT` (default: "TADA"). Multi-word values must be quoted.
|
|
15
|
+
3. Everything else is the **prompt** — the task description.
|
|
16
|
+
|
|
17
|
+
## Setup
|
|
18
|
+
|
|
19
|
+
Run this command to initialize the Ralph loop state file:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
node -e "
|
|
23
|
+
import { writeState } from '${RALPH_CODEX_ROOT}/lib/state.mjs';
|
|
24
|
+
await writeState({
|
|
25
|
+
active: true,
|
|
26
|
+
prompt: PROMPT_HERE,
|
|
27
|
+
completionPromise: PROMISE_HERE,
|
|
28
|
+
maxIterations: MAX_HERE,
|
|
29
|
+
currentIteration: 0,
|
|
30
|
+
sessionId: ''
|
|
31
|
+
});
|
|
32
|
+
"
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Replace `PROMPT_HERE`, `PROMISE_HERE`, and `MAX_HERE` with the parsed values. Use proper JSON string escaping for the prompt.
|
|
36
|
+
|
|
37
|
+
Alternatively, create the state file directly at `.codex/ralph-loop.state.json`:
|
|
38
|
+
|
|
39
|
+
```json
|
|
40
|
+
{
|
|
41
|
+
"active": true,
|
|
42
|
+
"prompt": "<user's prompt>",
|
|
43
|
+
"completionPromise": "<promise text>",
|
|
44
|
+
"maxIterations": 20,
|
|
45
|
+
"currentIteration": 0,
|
|
46
|
+
"sessionId": ""
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## After Setup
|
|
51
|
+
|
|
52
|
+
Display this message to the user:
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
Ralph loop activated!
|
|
56
|
+
|
|
57
|
+
Iteration: 1
|
|
58
|
+
Max iterations: <N or "unlimited">
|
|
59
|
+
Completion promise: <promise text>
|
|
60
|
+
|
|
61
|
+
The stop hook will now block session exit and feed the SAME PROMPT back.
|
|
62
|
+
Your previous work persists in files and git history.
|
|
63
|
+
To cancel: /cancel-ralph
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Then immediately begin working on the user's task prompt.
|
|
67
|
+
|
|
68
|
+
## Rules
|
|
69
|
+
|
|
70
|
+
- When a completion promise is set, you may ONLY output `<promise>TEXT</promise>` when the statement is genuinely and completely TRUE.
|
|
71
|
+
- Do NOT output false promises to exit the loop.
|
|
72
|
+
- Each iteration sees your previous work in files. Build on it incrementally.
|
|
73
|
+
- If stuck, document what's blocking and try a different approach.
|
package/hooks/hooks.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"description": "Ralph Loop stop hook for self-referential iterative loops in Codex CLI",
|
|
3
|
+
"hooks": {
|
|
4
|
+
"Stop": [
|
|
5
|
+
{
|
|
6
|
+
"hooks": [
|
|
7
|
+
{
|
|
8
|
+
"type": "command",
|
|
9
|
+
"command": "node \"${RALPH_CODEX_ROOT}/hooks/stop-hook.mjs\"",
|
|
10
|
+
"timeout": 30
|
|
11
|
+
}
|
|
12
|
+
]
|
|
13
|
+
}
|
|
14
|
+
]
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { readState, writeState } from "../lib/state.mjs";
|
|
5
|
+
|
|
6
|
+
async function readStdin() {
|
|
7
|
+
const chunks = [];
|
|
8
|
+
for await (const chunk of process.stdin) {
|
|
9
|
+
chunks.push(chunk);
|
|
10
|
+
}
|
|
11
|
+
return Buffer.concat(chunks).toString("utf-8");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function extractPromise(text, promisePhrase) {
|
|
15
|
+
const regex = new RegExp(
|
|
16
|
+
`<promise>\\s*${escapeRegex(promisePhrase)}\\s*</promise>`,
|
|
17
|
+
"s",
|
|
18
|
+
);
|
|
19
|
+
return regex.test(text);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function escapeRegex(str) {
|
|
23
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function getLastAssistantText(transcriptPath) {
|
|
27
|
+
if (!transcriptPath) return "";
|
|
28
|
+
try {
|
|
29
|
+
const raw = await readFile(transcriptPath, "utf-8");
|
|
30
|
+
const lines = raw.trim().split("\n");
|
|
31
|
+
const assistantLines = lines.filter((line) => {
|
|
32
|
+
try {
|
|
33
|
+
const obj = JSON.parse(line);
|
|
34
|
+
return obj.role === "assistant";
|
|
35
|
+
} catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
const recent = assistantLines.slice(-100);
|
|
40
|
+
for (let i = recent.length - 1; i >= 0; i--) {
|
|
41
|
+
try {
|
|
42
|
+
const obj = JSON.parse(recent[i]);
|
|
43
|
+
const contents = obj.message?.content || obj.content;
|
|
44
|
+
if (Array.isArray(contents)) {
|
|
45
|
+
for (let j = contents.length - 1; j >= 0; j--) {
|
|
46
|
+
if (contents[j].type === "text" && contents[j].text) {
|
|
47
|
+
return contents[j].text;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
} else if (typeof contents === "string") {
|
|
51
|
+
return contents;
|
|
52
|
+
}
|
|
53
|
+
} catch {
|
|
54
|
+
// skip malformed lines
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
} catch {
|
|
58
|
+
// transcript not found or unreadable
|
|
59
|
+
}
|
|
60
|
+
return "";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function main() {
|
|
64
|
+
let hookInput = {};
|
|
65
|
+
try {
|
|
66
|
+
const raw = await readStdin();
|
|
67
|
+
if (raw.trim()) {
|
|
68
|
+
hookInput = JSON.parse(raw);
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
// no stdin or malformed — proceed with empty input
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const state = await readState();
|
|
75
|
+
|
|
76
|
+
// Not active — allow exit silently
|
|
77
|
+
if (!state.active) {
|
|
78
|
+
process.exit(0);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Session isolation: if state has a sessionId, only match that session
|
|
82
|
+
if (
|
|
83
|
+
state.sessionId &&
|
|
84
|
+
hookInput.session_id &&
|
|
85
|
+
state.sessionId !== hookInput.session_id
|
|
86
|
+
) {
|
|
87
|
+
process.exit(0);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Increment iteration
|
|
91
|
+
state.currentIteration += 1;
|
|
92
|
+
|
|
93
|
+
// Check max iterations
|
|
94
|
+
if (state.maxIterations > 0 && state.currentIteration > state.maxIterations) {
|
|
95
|
+
process.stderr.write(
|
|
96
|
+
`Ralph loop: max iterations (${state.maxIterations}) reached.\n`,
|
|
97
|
+
);
|
|
98
|
+
state.active = false;
|
|
99
|
+
await writeState(state);
|
|
100
|
+
process.exit(0);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Check completion promise in transcript
|
|
104
|
+
if (state.completionPromise) {
|
|
105
|
+
const transcriptPath = hookInput.transcript_path || null;
|
|
106
|
+
const lastText =
|
|
107
|
+
hookInput.last_assistant_message ||
|
|
108
|
+
(await getLastAssistantText(transcriptPath));
|
|
109
|
+
|
|
110
|
+
if (lastText && extractPromise(lastText, state.completionPromise)) {
|
|
111
|
+
process.stderr.write(
|
|
112
|
+
`Ralph loop: completion promise "${state.completionPromise}" detected.\n`,
|
|
113
|
+
);
|
|
114
|
+
state.active = false;
|
|
115
|
+
await writeState(state);
|
|
116
|
+
process.exit(0);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Save updated iteration
|
|
121
|
+
await writeState(state);
|
|
122
|
+
|
|
123
|
+
// Build continuation prompt
|
|
124
|
+
const iterInfo =
|
|
125
|
+
state.maxIterations > 0
|
|
126
|
+
? `${state.currentIteration}/${state.maxIterations}`
|
|
127
|
+
: `${state.currentIteration}`;
|
|
128
|
+
|
|
129
|
+
const reason = [
|
|
130
|
+
state.prompt,
|
|
131
|
+
"",
|
|
132
|
+
"---",
|
|
133
|
+
`Ralph Loop iteration ${iterInfo}. Continue working on the task above.`,
|
|
134
|
+
].join("\n");
|
|
135
|
+
|
|
136
|
+
const output = {
|
|
137
|
+
decision: "block",
|
|
138
|
+
reason,
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
if (state.completionPromise) {
|
|
142
|
+
output.systemMessage = `Ralph iteration ${iterInfo} | To stop: output <promise>${state.completionPromise}</promise> (ONLY when TRUE)`;
|
|
143
|
+
} else {
|
|
144
|
+
output.systemMessage = `Ralph iteration ${iterInfo} | No completion promise — loop runs until max iterations`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
process.stdout.write(JSON.stringify(output));
|
|
148
|
+
process.exit(0);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
main().catch((err) => {
|
|
152
|
+
process.stderr.write(`Ralph stop-hook error: ${err.message}\n`);
|
|
153
|
+
process.exit(0);
|
|
154
|
+
});
|
package/lib/paths.mjs
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
export function isWindows() {
|
|
5
|
+
return process.platform === "win32";
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function getCodexHome() {
|
|
9
|
+
if (process.env.CODEX_HOME) {
|
|
10
|
+
return process.env.CODEX_HOME;
|
|
11
|
+
}
|
|
12
|
+
return join(homedir(), ".codex");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function getHooksJsonPath() {
|
|
16
|
+
return join(getCodexHome(), "hooks.json");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function getPluginInstallDir() {
|
|
20
|
+
return join(getCodexHome(), "plugins", "ralph-codex");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function getSkillsDir() {
|
|
24
|
+
return join(getCodexHome(), "skills");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function getLocalCodexDir() {
|
|
28
|
+
return join(process.cwd(), ".codex");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function getLocalPluginDir() {
|
|
32
|
+
return join(getLocalCodexDir(), "plugins", "ralph-codex");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function getLocalHooksJsonPath() {
|
|
36
|
+
return join(getLocalCodexDir(), "hooks.json");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function getLocalSkillsDir() {
|
|
40
|
+
return join(getLocalCodexDir(), "skills");
|
|
41
|
+
}
|
package/lib/state.mjs
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
2
|
+
import { join, dirname } from "node:path";
|
|
3
|
+
|
|
4
|
+
const DEFAULT_STATE = {
|
|
5
|
+
active: false,
|
|
6
|
+
prompt: "",
|
|
7
|
+
completionPromise: "TADA",
|
|
8
|
+
maxIterations: 20,
|
|
9
|
+
currentIteration: 0,
|
|
10
|
+
sessionId: "",
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function getStatePath() {
|
|
14
|
+
return (
|
|
15
|
+
process.env.RALPH_STATE_FILE ||
|
|
16
|
+
join(process.cwd(), ".codex", "ralph-loop.state.json")
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function readState() {
|
|
21
|
+
const statePath = getStatePath();
|
|
22
|
+
try {
|
|
23
|
+
const raw = await readFile(statePath, "utf-8");
|
|
24
|
+
return { ...DEFAULT_STATE, ...JSON.parse(raw) };
|
|
25
|
+
} catch {
|
|
26
|
+
return { ...DEFAULT_STATE };
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function writeState(state) {
|
|
31
|
+
const statePath = getStatePath();
|
|
32
|
+
await mkdir(dirname(statePath), { recursive: true });
|
|
33
|
+
await writeFile(statePath, JSON.stringify(state, null, 2), "utf-8");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function resetState() {
|
|
37
|
+
await writeState({ ...DEFAULT_STATE });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function incrementIteration() {
|
|
41
|
+
const state = await readState();
|
|
42
|
+
state.currentIteration += 1;
|
|
43
|
+
await writeState(state);
|
|
44
|
+
return state;
|
|
45
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
|
|
3
|
+
export function extractPromise(text, promisePhrase) {
|
|
4
|
+
const regex = new RegExp(
|
|
5
|
+
`<promise>\\s*${escapeRegex(promisePhrase)}\\s*</promise>`,
|
|
6
|
+
"s",
|
|
7
|
+
);
|
|
8
|
+
return regex.test(text);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function escapeRegex(str) {
|
|
12
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function getLastAssistantText(transcriptPath) {
|
|
16
|
+
if (!transcriptPath) return "";
|
|
17
|
+
try {
|
|
18
|
+
const raw = await readFile(transcriptPath, "utf-8");
|
|
19
|
+
const lines = raw.trim().split("\n");
|
|
20
|
+
const assistantLines = lines.filter((line) => {
|
|
21
|
+
try {
|
|
22
|
+
const obj = JSON.parse(line);
|
|
23
|
+
return obj.role === "assistant";
|
|
24
|
+
} catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
const recent = assistantLines.slice(-100);
|
|
29
|
+
for (let i = recent.length - 1; i >= 0; i--) {
|
|
30
|
+
try {
|
|
31
|
+
const obj = JSON.parse(recent[i]);
|
|
32
|
+
const contents = obj.message?.content || obj.content;
|
|
33
|
+
if (Array.isArray(contents)) {
|
|
34
|
+
for (let j = contents.length - 1; j >= 0; j--) {
|
|
35
|
+
if (contents[j].type === "text" && contents[j].text) {
|
|
36
|
+
return contents[j].text;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
} else if (typeof contents === "string") {
|
|
40
|
+
return contents;
|
|
41
|
+
}
|
|
42
|
+
} catch {
|
|
43
|
+
// skip malformed lines
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
} catch {
|
|
47
|
+
// transcript not found or unreadable
|
|
48
|
+
}
|
|
49
|
+
return "";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function processStopHook(hookInput, readStateFn, writeStateFn) {
|
|
53
|
+
const stderr = [];
|
|
54
|
+
const state = await readStateFn();
|
|
55
|
+
|
|
56
|
+
// Not active — allow exit
|
|
57
|
+
if (!state.active) {
|
|
58
|
+
return { exitCode: 0, stdout: "", stderr: "" };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Session isolation
|
|
62
|
+
if (
|
|
63
|
+
state.sessionId &&
|
|
64
|
+
hookInput.session_id &&
|
|
65
|
+
state.sessionId !== hookInput.session_id
|
|
66
|
+
) {
|
|
67
|
+
return { exitCode: 0, stdout: "", stderr: "" };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Increment iteration
|
|
71
|
+
state.currentIteration += 1;
|
|
72
|
+
|
|
73
|
+
// Check max iterations
|
|
74
|
+
if (state.maxIterations > 0 && state.currentIteration > state.maxIterations) {
|
|
75
|
+
stderr.push(
|
|
76
|
+
`Ralph loop: max iterations (${state.maxIterations}) reached.\n`,
|
|
77
|
+
);
|
|
78
|
+
state.active = false;
|
|
79
|
+
await writeStateFn(state);
|
|
80
|
+
return { exitCode: 0, stdout: "", stderr: stderr.join("") };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Check completion promise
|
|
84
|
+
if (state.completionPromise) {
|
|
85
|
+
const transcriptPath = hookInput.transcript_path || null;
|
|
86
|
+
const lastText =
|
|
87
|
+
hookInput.last_assistant_message ||
|
|
88
|
+
(await getLastAssistantText(transcriptPath));
|
|
89
|
+
|
|
90
|
+
if (lastText && extractPromise(lastText, state.completionPromise)) {
|
|
91
|
+
stderr.push(
|
|
92
|
+
`Ralph loop: completion promise "${state.completionPromise}" detected.\n`,
|
|
93
|
+
);
|
|
94
|
+
state.active = false;
|
|
95
|
+
await writeStateFn(state);
|
|
96
|
+
return { exitCode: 0, stdout: "", stderr: stderr.join("") };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Save updated iteration
|
|
101
|
+
await writeStateFn(state);
|
|
102
|
+
|
|
103
|
+
// Build continuation prompt
|
|
104
|
+
const iterInfo =
|
|
105
|
+
state.maxIterations > 0
|
|
106
|
+
? `${state.currentIteration}/${state.maxIterations}`
|
|
107
|
+
: `${state.currentIteration}`;
|
|
108
|
+
|
|
109
|
+
const reason = [
|
|
110
|
+
state.prompt,
|
|
111
|
+
"",
|
|
112
|
+
"---",
|
|
113
|
+
`Ralph Loop iteration ${iterInfo}. Continue working on the task above.`,
|
|
114
|
+
].join("\n");
|
|
115
|
+
|
|
116
|
+
const output = { decision: "block", reason };
|
|
117
|
+
|
|
118
|
+
if (state.completionPromise) {
|
|
119
|
+
output.systemMessage = `Ralph iteration ${iterInfo} | To stop: output <promise>${state.completionPromise}</promise> (ONLY when TRUE)`;
|
|
120
|
+
} else {
|
|
121
|
+
output.systemMessage = `Ralph iteration ${iterInfo} | No completion promise — loop runs until max iterations`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
exitCode: 0,
|
|
126
|
+
stdout: JSON.stringify(output),
|
|
127
|
+
stderr: stderr.join(""),
|
|
128
|
+
};
|
|
129
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@graypark/ralph-codex",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Ralph Loop for OpenAI Codex CLI — self-referential iterative development loops via Stop hooks",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "graypark",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/Viewcommz/ralph-codex"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/Viewcommz/ralph-codex#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/Viewcommz/ralph-codex/issues"
|
|
15
|
+
},
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public"
|
|
18
|
+
},
|
|
19
|
+
"bin": {
|
|
20
|
+
"ralph-codex": "./bin/install.mjs"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"bin/",
|
|
24
|
+
"hooks/",
|
|
25
|
+
"commands/",
|
|
26
|
+
"lib/",
|
|
27
|
+
"README.md",
|
|
28
|
+
"LICENSE"
|
|
29
|
+
],
|
|
30
|
+
"scripts": {
|
|
31
|
+
"test": "vitest run",
|
|
32
|
+
"postinstall": "echo 'Run: npx ralph-codex --help'"
|
|
33
|
+
},
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=18.0.0"
|
|
36
|
+
},
|
|
37
|
+
"keywords": [
|
|
38
|
+
"codex",
|
|
39
|
+
"ralph-loop",
|
|
40
|
+
"autonomous",
|
|
41
|
+
"ai-agent",
|
|
42
|
+
"iterative-development",
|
|
43
|
+
"stop-hook",
|
|
44
|
+
"cross-platform"
|
|
45
|
+
],
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"vitest": "^4.1.0"
|
|
48
|
+
}
|
|
49
|
+
}
|