@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 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
@@ -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.
@@ -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
+ }