ralph-codex 0.1.1 → 0.1.3

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/CHANGELOG.md CHANGED
@@ -2,8 +2,16 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.1.3] - 2026-01-22
6
+ - Added comprehensive CLI test suite with vitest, harness, and fixtures.
7
+ - Added GitHub Actions CI for Node 18/20/22.
8
+ - Guarded run completion to require all tasks checked off.
9
+
10
+ ## [0.1.2] - 2026-01-22
11
+ - Improved README with setup, defaults, Docker guidance, and troubleshooting.
12
+
5
13
  ## [0.1.1] - 2026-01-22
6
- - Added shell completion command with bash/zsh/fish support and dynamic suggestions.
14
+ - Added shell completion command (bash/zsh/fish) with dynamic suggestions.
7
15
 
8
16
  ## [0.1.0] - 2026-01-21
9
17
  - Initial release.
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # ralph-codex
2
2
 
3
+ ![ralph-codex](docs/ralph-codex.jpg)
4
+
3
5
  Codex-first Ralph-style planning and run loops.
4
6
 
5
7
  ## What it does
@@ -9,7 +11,14 @@ Codex-first Ralph-style planning and run loops.
9
11
  - Optional Docker mode for reproducible runs.
10
12
  - Colorized Codex output in TTY for easier scanning (disable with `NO_COLOR=1`).
11
13
 
12
- ![Ralph Codex normal workflow](docs/ralph-codex-workflow.png)
14
+ ## How it works
15
+
16
+ - `plan` asks for success criteria, runs Codex, and writes `tasks.md`.
17
+ - `run` executes tasks until `LOOP_COMPLETE`, updating `.ralph/` state and logs.
18
+ - `revise` adds new tasks from feedback without touching existing items.
19
+ - `view` and `reset` help you inspect and reset task status.
20
+
21
+ ![Ralph Codex workflow](docs/ralph-codex-workflow.png)
13
22
 
14
23
  ## Requirements
15
24
 
@@ -17,6 +26,19 @@ Codex-first Ralph-style planning and run loops.
17
26
  - Codex CLI installed and authenticated (`codex` available in PATH)
18
27
  - Docker (optional, only for Docker mode)
19
28
 
29
+ ## Codex CLI setup
30
+
31
+ Install and verify:
32
+
33
+ ```bash
34
+ npm install -g @openai/codex
35
+ codex --help
36
+ ```
37
+
38
+ Authenticate using the Codex CLI and/or create a profile in `~/.codex/config.toml`.
39
+ Follow the auth guide at https://developers.openai.com/codex/auth.
40
+ If you use profiles, pass `--profile` or set `codex.profile` in `ralph.config.yml`.
41
+
20
42
  ## Install
21
43
 
22
44
  ```bash
@@ -36,6 +58,45 @@ ralph-codex view
36
58
  ralph-codex reset
37
59
  ```
38
60
 
61
+ ## Demo (sample output)
62
+
63
+ ```text
64
+ $ ralph-codex plan "Add screenshot flow"
65
+ ... (interactive prompts) ...
66
+ tasks.md written
67
+ $ ralph-codex run
68
+ ... (loop output) ...
69
+ LOOP_COMPLETE
70
+ ```
71
+
72
+ ## Cookbook
73
+
74
+ ### Basic flow
75
+
76
+ ```bash
77
+ ralph-codex init
78
+ ralph-codex plan "Add screenshot flow for /demo" --output tasks.md
79
+ ralph-codex run --max-iterations 10
80
+ ```
81
+
82
+ ### Docker flow
83
+
84
+ ```bash
85
+ ralph-codex docker
86
+ # Ensure docker.codex_install is set in ralph.config.yml
87
+ ralph-codex plan "Add screenshot flow for /demo"
88
+ ralph-codex run
89
+ ```
90
+
91
+ ### Low-touch automation (still interactive)
92
+
93
+ Use a prefilled config and pass flags to minimize prompts. This CLI still expects a TTY.
94
+
95
+ ```bash
96
+ ralph-codex plan "Add screenshot flow" --full-auto --reasoning low
97
+ ralph-codex run --full-auto --reasoning low
98
+ ```
99
+
39
100
  ## Command reference
40
101
 
41
102
  Use `--help` with any command to see its available options.
@@ -174,6 +235,7 @@ run:
174
235
  max_iterations: 15
175
236
  max_iteration_seconds: null
176
237
  max_total_seconds: null
238
+ completion_promise: LOOP_COMPLETE
177
239
  ```
178
240
 
179
241
  Codex settings quick guide:
@@ -187,6 +249,20 @@ Enable `plan.auto_detect_success_criteria` to add detected checks based on repo
187
249
 
188
250
  CLI flags always override config values.
189
251
 
252
+ ## Defaults
253
+
254
+ Defaults are from the template `ralph.config.yml`.
255
+
256
+ | Setting | Default | Details |
257
+ | --- | --- | --- |
258
+ | `plan.tasks_path` | `tasks.md` | Output path for generated tasks. |
259
+ | `plan.auto_detect_success_criteria` | `false` | Detect and suggest checks from the repo. |
260
+ | `run.tasks_path` | `tasks.md` | Input path for tasks during runs. |
261
+ | `run.max_iterations` | `15` | Max loop iterations before stopping. |
262
+ | `run.completion_promise` | `LOOP_COMPLETE` | Completion token printed by the loop. |
263
+ | `docker.enabled` | `false` | Enable Docker execution. |
264
+ | `docker.use_for_plan` | `false` | Run planning inside Docker too. |
265
+
190
266
  ## Docker mode
191
267
 
192
268
  1. Run `ralph-codex docker` to pick a base image.
@@ -194,6 +270,20 @@ CLI flags always override config values.
194
270
  3. Run `ralph-codex plan` and `ralph-codex run` as usual. Enable `docker.use_for_plan`
195
271
  if you want planning to happen inside Docker as well.
196
272
 
273
+ Why Docker (especially with `danger-full-access`):
274
+ - Isolation: lets you grant broad permissions inside the container without exposing your host.
275
+ - Reproducibility: consistent OS/package stack across runs and teammates.
276
+ - Safer cleanup: delete the container/image to reset state.
277
+
278
+ Why `danger-full-access`:
279
+ - Fewer approval prompts for tooling that needs broad filesystem or network access.
280
+ - Works better for complex build/test flows that span many paths.
281
+ - Faster iterations when you trust the environment (best paired with Docker).
282
+
283
+ > **Warning**: Avoid `danger-full-access` on your host unless you fully trust the prompts,
284
+ > scripts, and dependencies. It grants broad access to your machine and can write
285
+ > outside the repo. Prefer Docker when you need this mode.
286
+
197
287
  `Dockerfile.ralph` is generated automatically when Docker is enabled.
198
288
 
199
289
  ## Files created
@@ -207,12 +297,24 @@ CLI flags always override config values.
207
297
  Codex output is colorized when stdout is a TTY. Plan uses spinners and run shows a task
208
298
  progress bar when interactive. Set `NO_COLOR=1` to disable color styling.
209
299
 
300
+ ## Exit codes
301
+
302
+ - `0` success.
303
+ - `1` invalid usage or runtime error.
304
+
210
305
  ## Troubleshooting
211
306
 
212
307
  - `codex: command not found` -> install Codex CLI and ensure it is in PATH.
308
+ - Auth errors (401/403) -> authenticate Codex CLI or select a valid profile.
309
+ - `Missing tasks.md` -> run `ralph-codex plan` first or pass `--tasks`.
213
310
  - Docker errors -> start Docker Desktop/Colima and retry.
214
- - Plan/run fail to read config -> verify `ralph.config.yml` path or pass `--config`.
311
+ - Config read errors -> verify `ralph.config.yml` path or pass `--config`.
312
+
313
+ ## Changelog and license
314
+
315
+ - Changelog: `CHANGELOG.md`
316
+ - License: `LICENSE` (MIT)
215
317
 
216
- ## License
318
+ ## Privacy
217
319
 
218
- MIT
320
+ ralph-codex does not add its own telemetry. It sends prompts and context to the Codex CLI you configure; follow your Codex policies and settings.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ralph-codex",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Codex-first Ralph-style planning and run loops",
5
5
  "repository": {
6
6
  "type": "git",
@@ -25,6 +25,10 @@
25
25
  "node": ">=18"
26
26
  },
27
27
  "packageManager": "npm@10.8.2",
28
+ "scripts": {
29
+ "test": "vitest run",
30
+ "test:watch": "vitest"
31
+ },
28
32
  "dependencies": {
29
33
  "cli-progress": "^3.12.0",
30
34
  "enquirer": "^2.4.1",
@@ -32,6 +36,9 @@
32
36
  "ora": "^5.4.1",
33
37
  "picocolors": "^1.0.0"
34
38
  },
39
+ "devDependencies": {
40
+ "vitest": "^1.6.0"
41
+ },
35
42
  "files": [
36
43
  "bin",
37
44
  "src",
@@ -130,9 +130,9 @@ _ralph_codex_completion_promise_list() {
130
130
 
131
131
  _ralph_codex() {
132
132
  local cur prev cmd
133
- cur="\\${COMP_WORDS[COMP_CWORD]}"
134
- prev="\\${COMP_WORDS[COMP_CWORD-1]}"
135
- cmd="\\${COMP_WORDS[1]}"
133
+ cur="\${COMP_WORDS[COMP_CWORD]}"
134
+ prev="\${COMP_WORDS[COMP_CWORD-1]}"
135
+ cmd="\${COMP_WORDS[1]}"
136
136
 
137
137
  local commands="init plan run revise refine view reset docker completion help"
138
138
  local root_opts="--help -h --version -v"
@@ -174,6 +174,10 @@ _ralph_codex() {
174
174
  COMPREPLY=( $(compgen -W "$tasks" -- "$cur") $(compgen -f -- "$cur") )
175
175
  return 0
176
176
  ;;
177
+ --idea-file)
178
+ COMPREPLY=( $(compgen -f -- "$cur") )
179
+ return 0
180
+ ;;
177
181
  --sandbox)
178
182
  COMPREPLY=( $(compgen -W "read-only workspace-write danger-full-access" -- "$cur") )
179
183
  return 0
@@ -199,7 +203,7 @@ _ralph_codex() {
199
203
  return 0
200
204
  ;;
201
205
  esac
202
- local opts="--output --tasks --max-iterations --config --model -m --profile -p --sandbox --no-sandbox --ask-for-approval --full-auto --reasoning --detect-success-criteria --no-detect-success-criteria --help -h"
206
+ local opts="--output --tasks --idea-file --stdin --max-iterations --config --model -m --profile -p --sandbox --no-sandbox --ask-for-approval --full-auto --reasoning --detect-success-criteria --no-detect-success-criteria --help -h"
203
207
  COMPREPLY=( $(compgen -W "$opts" -- "$cur") )
204
208
  return 0
205
209
  ;;
@@ -525,6 +529,8 @@ _ralph_codex() {
525
529
  _arguments \\
526
530
  '--output[Write tasks to a custom file]:path:_ralph_codex_tasks' \\
527
531
  '--tasks[Write tasks to a custom file]:path:_ralph_codex_tasks' \\
532
+ '--idea-file[Read idea from a markdown file]:file:_files' \\
533
+ '--stdin[Read idea from stdin]' \\
528
534
  '--max-iterations[Max planning iterations]:number:' \\
529
535
  '--config[Path to ralph.config.yml]:file:_ralph_codex_configs' \\
530
536
  '(-m --model)'{-m,--model}'[Codex model]:model:_ralph_codex_models' \\
@@ -745,6 +751,8 @@ complete -c ralph-codex -n '__fish_seen_subcommand_from init' -l no-gitignore -d
745
751
 
746
752
  complete -c ralph-codex -n '__fish_seen_subcommand_from plan' -l output -r -a '(__ralph_codex_tasks)' -d 'Write tasks to a custom file'
747
753
  complete -c ralph-codex -n '__fish_seen_subcommand_from plan' -l tasks -r -a '(__ralph_codex_tasks)' -d 'Write tasks to a custom file'
754
+ complete -c ralph-codex -n '__fish_seen_subcommand_from plan' -l idea-file -r -a '(__fish_complete_path)' -d 'Read idea from a markdown file'
755
+ complete -c ralph-codex -n '__fish_seen_subcommand_from plan' -l stdin -d 'Read idea from stdin'
748
756
  complete -c ralph-codex -n '__fish_seen_subcommand_from plan' -l max-iterations -r -d 'Max planning iterations'
749
757
  complete -c ralph-codex -n '__fish_seen_subcommand_from plan' -l config -r -a '(__ralph_codex_configs)' -d 'Path to ralph.config.yml'
750
758
  complete -c ralph-codex -n '__fish_seen_subcommand_from plan' -s m -l model -r -a '(__ralph_codex_models)' -d 'Codex model'
@@ -8,6 +8,7 @@ const { Confirm } = enquirer;
8
8
 
9
9
  const root = process.cwd();
10
10
  const defaultConfigPath = path.join(root, "ralph.config.yml");
11
+ const isTestMode = process.env.RALPH_TEST_MODE === "1";
11
12
 
12
13
  const argv = process.argv.slice(2);
13
14
  let configPath = null;
@@ -122,12 +123,12 @@ function runCodex(prompt, codexConfig) {
122
123
 
123
124
  function buildPrompt(nodeVersion) {
124
125
  const nodeLine = nodeVersion ? `Node version: ${nodeVersion}` : "Node 20+";
125
- return `Encuentra la mejor imagen base de Docker para este proyecto.
126
- Considera que necesita ejecutar npm scripts, módulos nativos y evitar Alpine.
126
+ return `Find the best Docker base image for this project.
127
+ Consider that it needs to run npm scripts, native modules, and should avoid Alpine.
127
128
  ${nodeLine}
128
129
 
129
- Responde SOLO con una línea en este formato:
130
- BASE_IMAGE: <imagen>`;
130
+ Respond with a single line in this format:
131
+ BASE_IMAGE: <image>`;
131
132
  }
132
133
 
133
134
  async function main() {
@@ -148,13 +149,13 @@ async function main() {
148
149
  process.exit(1);
149
150
  }
150
151
 
151
- const confirm = new Confirm({
152
- name: "confirm",
153
- message: `Set docker.enabled=true and base_image=${baseImage}?`,
154
- initial: true,
155
- });
156
-
157
- const approved = await confirm.run();
152
+ const approved = isTestMode
153
+ ? true
154
+ : await new Confirm({
155
+ name: "confirm",
156
+ message: `Set docker.enabled=true and base_image=${baseImage}?`,
157
+ initial: true,
158
+ }).run();
158
159
  if (!approved) {
159
160
  process.stdout.write("Aborted by user.\n");
160
161
  process.exit(1);
@@ -7,6 +7,7 @@ const { AutoComplete, Confirm, Input, Toggle } = enquirer;
7
7
 
8
8
  const root = process.cwd();
9
9
  const argv = process.argv.slice(2);
10
+ const isTestMode = process.env.RALPH_TEST_MODE === "1";
10
11
 
11
12
  let force = false;
12
13
  let configPath = null;
@@ -42,6 +43,7 @@ const targetPath = configPath
42
43
  : path.join(root, "ralph.config.yml");
43
44
 
44
45
  async function confirmOverwrite() {
46
+ if (isTestMode) return true;
45
47
  const confirm = new Confirm({
46
48
  name: "overwrite",
47
49
  message: `Overwrite existing ${path.relative(root, targetPath)}?`,
@@ -224,6 +226,16 @@ async function promptModelChoice() {
224
226
  }
225
227
 
226
228
  async function collectCodexConfig() {
229
+ if (isTestMode) {
230
+ return {
231
+ model: null,
232
+ profile: null,
233
+ sandbox: null,
234
+ ask_for_approval: null,
235
+ full_auto: false,
236
+ model_reasoning_effort: null,
237
+ };
238
+ }
227
239
  const model = await promptModelChoice();
228
240
  const profile = await promptOptionalInput(
229
241
  "Codex CLI profile (optional; leave blank to use Codex default)",
@@ -359,13 +371,15 @@ async function main() {
359
371
 
360
372
  const content = fs.readFileSync(templatePath, "utf8");
361
373
  const codexConfig = await collectCodexConfig();
362
- const useDocker = await new Toggle({
363
- name: "use_docker",
364
- message: "Use Docker for the loop? (adds a docker section)",
365
- enabled: "Yes",
366
- disabled: "No",
367
- initial: false,
368
- }).run();
374
+ const useDocker = isTestMode
375
+ ? process.env.RALPH_TEST_USE_DOCKER === "1"
376
+ : await new Toggle({
377
+ name: "use_docker",
378
+ message: "Use Docker for the loop? (adds a docker section)",
379
+ enabled: "Yes",
380
+ disabled: "No",
381
+ initial: false,
382
+ }).run();
369
383
 
370
384
  let updated = content;
371
385
  updated = setYamlValue(updated, "model", codexConfig.model);
@@ -10,10 +10,13 @@ const { AutoComplete, Confirm, Editor, Input, MultiSelect } = enquirer;
10
10
 
11
11
  const root = process.cwd();
12
12
  const agentDir = path.join(root, ".ralph");
13
+ const isTestMode = process.env.RALPH_TEST_MODE === "1";
13
14
 
14
15
  const argv = process.argv.slice(2);
15
16
  let maxIterations = "1";
16
17
  let tasksPath = "tasks.md";
18
+ let ideaFile = null;
19
+ let readStdin = false;
17
20
  let noSandbox = false;
18
21
  let sandbox = null;
19
22
  let fullAuto = false;
@@ -27,6 +30,7 @@ let autoDetectSuccessCriteria = null;
27
30
  let reasoningChoice;
28
31
  let showHelp = false;
29
32
  const ideaParts = [];
33
+ let idea = "";
30
34
 
31
35
  for (let i = 0; i < argv.length; i += 1) {
32
36
  const arg = argv[i];
@@ -49,6 +53,20 @@ for (let i = 0; i < argv.length; i += 1) {
49
53
  i += 1;
50
54
  continue;
51
55
  }
56
+ if (arg === "--idea-file") {
57
+ const value = argv[i + 1];
58
+ if (!value || (value.startsWith("-") && value !== "-")) {
59
+ console.error("Missing --idea-file <path>.");
60
+ process.exit(1);
61
+ }
62
+ ideaFile = value;
63
+ i += 1;
64
+ continue;
65
+ }
66
+ if (arg === "--stdin") {
67
+ readStdin = true;
68
+ continue;
69
+ }
52
70
  if (arg === "--no-sandbox") {
53
71
  noSandbox = true;
54
72
  continue;
@@ -109,6 +127,8 @@ function printHelp() {
109
127
  `${colors.yellow("Options:")}\n` +
110
128
  ` ${colors.green("--output <path>")} Write tasks to a custom file (alias of --tasks)\n` +
111
129
  ` ${colors.green("--tasks <path>")} Write tasks to a custom file (default: tasks.md)\n` +
130
+ ` ${colors.green("--idea-file <path>")} Read idea from a markdown file ('-' for stdin)\n` +
131
+ ` ${colors.green("--stdin")} Read idea from stdin (paste then Ctrl-D)\n` +
112
132
  ` ${colors.green("--max-iterations <n>")} Max planning iterations (default: 1)\n` +
113
133
  ` ${colors.green("--config <path>")} Path to ralph.config.yml\n` +
114
134
  ` ${colors.green("--model <name>, -m")} Codex model\n` +
@@ -129,16 +149,24 @@ if (showHelp) {
129
149
  process.exit(0);
130
150
  }
131
151
 
132
- const idea = ideaParts.join(" ").trim();
152
+ const promptPath = path.join(agentDir, "ralph-plan-prompt.md");
133
153
 
134
- if (!idea) {
135
- console.error(
136
- 'Usage: ralph-codex plan "<idea>" [--output <path>] [--tasks <path>] [--max-iterations <n>]',
137
- );
138
- process.exit(1);
154
+ function readIdeaFromStdin() {
155
+ try {
156
+ return fs.readFileSync(0, "utf8");
157
+ } catch (_) {
158
+ return "";
159
+ }
139
160
  }
140
161
 
141
- const promptPath = path.join(agentDir, "ralph-plan-prompt.md");
162
+ function readIdeaFromFile(filePath) {
163
+ const resolved = path.resolve(root, filePath);
164
+ if (!fs.existsSync(resolved)) {
165
+ console.error(`Missing idea file: ${filePath}`);
166
+ process.exit(1);
167
+ }
168
+ return fs.readFileSync(resolved, "utf8");
169
+ }
142
170
  function loadConfig(configFilePath) {
143
171
  if (!configFilePath) return {};
144
172
  if (!fs.existsSync(configFilePath)) return {};
@@ -175,7 +203,7 @@ function resolveDockerConfig(config) {
175
203
  ? dockerConfig.pip_packages
176
204
  : [],
177
205
  useForPlan: Boolean(dockerConfig.use_for_plan),
178
- tty: dockerConfig.tty ?? "auto",
206
+ tty: dockerConfig.tty ?? false,
179
207
  };
180
208
  }
181
209
 
@@ -675,6 +703,7 @@ Context scan (read-only):
675
703
  pyproject.toml, requirements.txt, go.mod, Cargo.toml, pom.xml, build.gradle, Makefile,
676
704
  .nvmrc, Dockerfile, etc. Only inspect files that exist.
677
705
  - Use this context to infer file locations, tooling, and sensible commands.
706
+ - You may use read-only commands like ls, rg, and cat to inspect files.
678
707
 
679
708
  Requirements:
680
709
  - If there are open questions, ask them first and do not write ${tasksPath}.
@@ -693,13 +722,18 @@ Requirements:
693
722
  - Tasks must be atomic, ordered, and verifiable. Include exact file paths,
694
723
  commands to run (if any), and expected outcomes. Avoid vague verbs like "handle" or "improve".
695
724
  - Keep scope minimal: avoid refactors unless required by the idea or to unblock tasks.
725
+ - Use this exact section order and headings in ${tasksPath}:
726
+ 1) # Tasks
727
+ 2) ## Assumptions (only if needed)
728
+ 3) ## Success criteria
729
+ 4) ## Required tools
696
730
  ${successCriteriaBlock}
697
731
  - Include a "Required tools" section using this exact format:
698
732
  - \`- apt: <comma-separated packages or none>\`
699
733
  - \`- npm: <comma-separated packages or none>\`
700
734
  - \`- pip: <comma-separated packages or none>\`
701
735
  - Do not edit any files other than ${tasksPath}.
702
- - Do not run commands, tests, or start dev servers during planning.
736
+ - Do not run write commands, tests, installs, or start dev servers during planning.
703
737
 
704
738
  When done, output exactly: LOOP_COMPLETE
705
739
  `;
@@ -856,6 +890,13 @@ async function selectSuccessCriteria(
856
890
  standardChoices,
857
891
  detectedChoices = []
858
892
  ) {
893
+ if (isTestMode) {
894
+ const safeDefaults =
895
+ defaultCriteria && defaultCriteria.length > 0
896
+ ? defaultCriteria
897
+ : standardChoices;
898
+ return { mode: "manual", criteria: safeDefaults || [] };
899
+ }
859
900
  const autoChoiceValue = "__auto__";
860
901
  const customChoiceValue = "__custom__";
861
902
  const defaults =
@@ -949,6 +990,7 @@ async function selectSuccessCriteria(
949
990
  }
950
991
 
951
992
  async function confirmPlan(tasksFile) {
993
+ if (isTestMode) return true;
952
994
  const content = fs.readFileSync(tasksFile, "utf8");
953
995
  process.stdout.write(`\n${colors.cyan("--- Proposed tasks.md ---")}\n\n`);
954
996
  process.stdout.write(content);
@@ -972,6 +1014,7 @@ async function readRevisionFeedback() {
972
1014
  }
973
1015
 
974
1016
  async function confirmResetState(tasksFilePath, agentPath) {
1017
+ if (isTestMode) return false;
975
1018
  const hasTasks = fs.existsSync(tasksFilePath);
976
1019
  const hasAgent = fs.existsSync(agentPath);
977
1020
  if (!hasTasks && !hasAgent) return false;
@@ -994,6 +1037,23 @@ function resetState(tasksFilePath, agentPath) {
994
1037
  }
995
1038
 
996
1039
  async function main() {
1040
+ const ideaFromArgs = ideaParts.join(" ").trim();
1041
+ if (ideaFile) {
1042
+ idea = ideaFile === "-" ? readIdeaFromStdin() : readIdeaFromFile(ideaFile);
1043
+ } else if (readStdin || (!ideaFromArgs && !process.stdin.isTTY)) {
1044
+ idea = readIdeaFromStdin();
1045
+ } else {
1046
+ idea = ideaFromArgs;
1047
+ }
1048
+
1049
+ idea = String(idea || "").trim();
1050
+ if (!idea) {
1051
+ console.error(
1052
+ 'Usage: ralph-codex plan "<idea>" [--idea-file <path>] [--stdin] [--output <path>] [--tasks <path>] [--max-iterations <n>]',
1053
+ );
1054
+ process.exit(1);
1055
+ }
1056
+
997
1057
  const resolvedConfigPath = configPath || path.join(root, "ralph.config.yml");
998
1058
  const config = loadConfig(resolvedConfigPath);
999
1059
  const codexConfig = config?.codex || {};
@@ -4,6 +4,12 @@ import path from "path";
4
4
  import enquirer from "enquirer";
5
5
  import yaml from "js-yaml";
6
6
  import { colors, createLogStyler, createSpinner } from "../ui/terminal.js";
7
+ import {
8
+ diffCriteria,
9
+ diffTasks,
10
+ parseSuccessCriteria,
11
+ parseTasks,
12
+ } from "../lib/tasks.js";
7
13
 
8
14
  const { AutoComplete, Confirm, Editor, Input } = enquirer;
9
15
 
@@ -223,91 +229,6 @@ async function readFeedback(promptMessage) {
223
229
  return input.run();
224
230
  }
225
231
 
226
- function parseTasks(content) {
227
- const tasks = [];
228
- const lines = content.split(/\r?\n/);
229
- for (const line of lines) {
230
- const match = line.match(/^\s*[-*]\s+\[([ x~])\]\s+(.*)$/);
231
- if (!match) continue;
232
- const statusToken = match[1].toLowerCase();
233
- const status =
234
- statusToken === "x" ? "done" : statusToken === "~" ? "blocked" : "pending";
235
- tasks.push({
236
- status,
237
- text: match[2].trim(),
238
- raw: line.trim(),
239
- });
240
- }
241
- return tasks;
242
- }
243
-
244
- function normalizeTaskText(text) {
245
- return String(text || "")
246
- .toLowerCase()
247
- .replace(/\s+/g, " ")
248
- .trim();
249
- }
250
-
251
- function parseSuccessCriteria(content) {
252
- const lines = content.split(/\r?\n/);
253
- let start = -1;
254
- for (let i = 0; i < lines.length; i += 1) {
255
- if (/^(#+\s*)?success criteria\b/i.test(lines[i].trim())) {
256
- start = i;
257
- break;
258
- }
259
- }
260
- if (start === -1) return [];
261
- const items = [];
262
- for (let i = start + 1; i < lines.length; i += 1) {
263
- const line = lines[i].trim();
264
- if (!line) continue;
265
- if (/^#+\s+/.test(line)) break;
266
- if (line.startsWith("- ")) items.push(line.slice(2).trim());
267
- if (line.startsWith("* ")) items.push(line.slice(2).trim());
268
- }
269
- return items;
270
- }
271
-
272
- function diffTasks(oldTasks, newTasks) {
273
- const oldSet = new Set(oldTasks.map((task) => normalizeTaskText(task.text)));
274
- const newSet = new Set(newTasks.map((task) => normalizeTaskText(task.text)));
275
-
276
- const additions = newTasks.filter(
277
- (task) => !oldSet.has(normalizeTaskText(task.text))
278
- );
279
- const removals = oldTasks.filter(
280
- (task) => !newSet.has(normalizeTaskText(task.text))
281
- );
282
-
283
- const modified = [];
284
- const compareCount = Math.min(oldTasks.length, newTasks.length);
285
- for (let i = 0; i < compareCount; i += 1) {
286
- const before = oldTasks[i];
287
- const after = newTasks[i];
288
- if (
289
- before.status !== after.status ||
290
- normalizeTaskText(before.text) !== normalizeTaskText(after.text)
291
- ) {
292
- modified.push({
293
- index: i + 1,
294
- before,
295
- after,
296
- });
297
- }
298
- }
299
-
300
- return { additions, removals, modified };
301
- }
302
-
303
- function diffCriteria(oldCriteria, newCriteria) {
304
- const oldSet = new Set(oldCriteria);
305
- const newSet = new Set(newCriteria);
306
- const added = newCriteria.filter((item) => !oldSet.has(item));
307
- const removed = oldCriteria.filter((item) => !newSet.has(item));
308
- return { added, removed };
309
- }
310
-
311
232
  function renderChanges(changes) {
312
233
  if (changes.additions.length > 0) {
313
234
  process.stdout.write(`${colors.cyan("Proposed new tasks:")}\n`);
@@ -278,7 +278,7 @@ function resolveDockerConfig(config) {
278
278
  fixAttempts: Number(dockerConfig.fix_attempts || 2),
279
279
  fixUseHost: dockerConfig.fix_use_host !== false,
280
280
  fixLog: dockerConfig.fix_log || ".ralph/docker-build.log",
281
- tty: dockerConfig.tty ?? "auto",
281
+ tty: dockerConfig.tty ?? false,
282
282
  cleanup: dockerConfig.cleanup || "none",
283
283
  };
284
284
  }
@@ -356,6 +356,8 @@ ${result.output}
356
356
  Constraints:
357
357
  - Only edit ${dockerConfig.dockerfile}
358
358
  - Do not change other files
359
+ - Keep the existing base image unless the error requires changing it
360
+ - Do not add new dependencies unless required by the error
359
361
  - Do not ask questions
360
362
  - Output exactly: LOOP_COMPLETE
361
363
  `;
@@ -1142,8 +1144,33 @@ async function main() {
1142
1144
  }
1143
1145
 
1144
1146
  if (hasCompletion(result.output)) {
1145
- completed = true;
1146
- break;
1147
+ const completionProgress = getTaskProgress(tasksFile);
1148
+ if (
1149
+ completionProgress.total > 0 &&
1150
+ completionProgress.completed === completionProgress.total
1151
+ ) {
1152
+ completed = true;
1153
+ break;
1154
+ }
1155
+
1156
+ const pendingCount = Math.max(
1157
+ 0,
1158
+ completionProgress.total -
1159
+ completionProgress.completed -
1160
+ completionProgress.blocked,
1161
+ );
1162
+ const reason =
1163
+ completionProgress.total === 0
1164
+ ? "no tasks detected"
1165
+ : `${pendingCount} pending, ${completionProgress.blocked} blocked`;
1166
+ notes.push(
1167
+ `Completion token received but tasks remain incomplete (${reason}).`,
1168
+ );
1169
+ process.stdout.write(
1170
+ `${colors.yellow(
1171
+ "Completion token received but tasks remain incomplete; continuing.",
1172
+ )}\n`,
1173
+ );
1147
1174
  }
1148
1175
 
1149
1176
  if (iterationTimedOut) {
@@ -2,6 +2,7 @@ import fs from "fs";
2
2
  import path from "path";
3
3
  import yaml from "js-yaml";
4
4
  import { colors } from "../ui/terminal.js";
5
+ import { parseSuccessCriteria, parseTasks } from "../lib/tasks.js";
5
6
 
6
7
  const root = process.cwd();
7
8
  const argv = process.argv.slice(2);
@@ -156,21 +157,6 @@ function formatStatus(status) {
156
157
  return { raw, display: colors.gray(raw) };
157
158
  }
158
159
 
159
- function parseTasks(content) {
160
- const tasks = [];
161
- const lines = content.split(/\r?\n/);
162
- let index = 0;
163
- for (const line of lines) {
164
- const match = line.match(/^\s*[-*]\s+\[([ x~])\]\s+(.*)$/);
165
- if (!match) continue;
166
- index += 1;
167
- const statusToken = match[1].toLowerCase();
168
- const status =
169
- statusToken === "x" ? "done" : statusToken === "~" ? "blocked" : "pending";
170
- tasks.push({ index, status, text: match[2].trim() });
171
- }
172
- return tasks;
173
- }
174
160
 
175
161
  function summarizeTasks(tasks) {
176
162
  const total = tasks.length;
@@ -186,26 +172,6 @@ function filterTasks(tasks, filter) {
186
172
  return tasks.filter((task) => task.status === filter);
187
173
  }
188
174
 
189
- function extractSuccessCriteria(content) {
190
- const lines = content.split(/\r?\n/);
191
- let start = -1;
192
- for (let i = 0; i < lines.length; i += 1) {
193
- if (/^(#+\s*)?success criteria\b/i.test(lines[i].trim())) {
194
- start = i;
195
- break;
196
- }
197
- }
198
- if (start === -1) return [];
199
- const items = [];
200
- for (let i = start + 1; i < lines.length; i += 1) {
201
- const line = lines[i].trim();
202
- if (!line) continue;
203
- if (/^#+\s+/.test(line)) break;
204
- if (line.startsWith("- ")) items.push(line.slice(2).trim());
205
- if (line.startsWith("* ")) items.push(line.slice(2).trim());
206
- }
207
- return items;
208
- }
209
175
 
210
176
  function getLastBlocker(logPath) {
211
177
  if (!fs.existsSync(logPath)) return "";
@@ -339,6 +305,7 @@ function getConfigRows(config) {
339
305
  use_for_plan: false,
340
306
  base_image: "node:20-bullseye",
341
307
  codex_install: "",
308
+ tty: false,
342
309
  },
343
310
  plan: {
344
311
  tasks_path: "tasks.md",
@@ -468,7 +435,7 @@ function renderOnce({ allowMissingTasks }) {
468
435
  const summary = summarizeTasks(allTasks);
469
436
  const filtered = filterTasks(allTasks, only);
470
437
  const sliced = limit > 0 ? filtered.slice(0, limit) : filtered;
471
- const criteria = extractSuccessCriteria(tasksContent);
438
+ const criteria = parseSuccessCriteria(tasksContent);
472
439
 
473
440
  tasksData = {
474
441
  path: resolvedTasksPath,
@@ -0,0 +1,97 @@
1
+ function parseTasks(content) {
2
+ const tasks = [];
3
+ const lines = String(content || "").split(/\r?\n/);
4
+ let index = 0;
5
+
6
+ for (const line of lines) {
7
+ const match = line.match(/^\s*[-*]\s+\[([ x~])\]\s+(.*)$/);
8
+ if (!match) continue;
9
+ index += 1;
10
+ const statusToken = match[1].toLowerCase();
11
+ const status =
12
+ statusToken === "x" ? "done" : statusToken === "~" ? "blocked" : "pending";
13
+ tasks.push({
14
+ index,
15
+ status,
16
+ text: match[2].trim(),
17
+ raw: line.trim(),
18
+ });
19
+ }
20
+
21
+ return tasks;
22
+ }
23
+
24
+ function parseSuccessCriteria(content) {
25
+ const lines = String(content || "").split(/\r?\n/);
26
+ let start = -1;
27
+ for (let i = 0; i < lines.length; i += 1) {
28
+ if (/^(#+\s*)?success criteria\b/i.test(lines[i].trim())) {
29
+ start = i;
30
+ break;
31
+ }
32
+ }
33
+ if (start === -1) return [];
34
+ const items = [];
35
+ for (let i = start + 1; i < lines.length; i += 1) {
36
+ const line = lines[i].trim();
37
+ if (!line) continue;
38
+ if (/^#+\s+/.test(line)) break;
39
+ if (line.startsWith("- ")) items.push(line.slice(2).trim());
40
+ if (line.startsWith("* ")) items.push(line.slice(2).trim());
41
+ }
42
+ return items;
43
+ }
44
+
45
+ function normalizeTaskText(text) {
46
+ return String(text || "")
47
+ .toLowerCase()
48
+ .replace(/\s+/g, " ")
49
+ .trim();
50
+ }
51
+
52
+ function diffTasks(oldTasks, newTasks) {
53
+ const oldSet = new Set(oldTasks.map((task) => normalizeTaskText(task.text)));
54
+ const newSet = new Set(newTasks.map((task) => normalizeTaskText(task.text)));
55
+
56
+ const additions = newTasks.filter(
57
+ (task) => !oldSet.has(normalizeTaskText(task.text))
58
+ );
59
+ const removals = oldTasks.filter(
60
+ (task) => !newSet.has(normalizeTaskText(task.text))
61
+ );
62
+
63
+ const modified = [];
64
+ const compareCount = Math.min(oldTasks.length, newTasks.length);
65
+ for (let i = 0; i < compareCount; i += 1) {
66
+ const before = oldTasks[i];
67
+ const after = newTasks[i];
68
+ if (
69
+ before.status !== after.status ||
70
+ normalizeTaskText(before.text) !== normalizeTaskText(after.text)
71
+ ) {
72
+ modified.push({
73
+ index: i + 1,
74
+ before,
75
+ after,
76
+ });
77
+ }
78
+ }
79
+
80
+ return { additions, removals, modified };
81
+ }
82
+
83
+ function diffCriteria(oldCriteria, newCriteria) {
84
+ const oldSet = new Set(oldCriteria);
85
+ const newSet = new Set(newCriteria);
86
+ const added = newCriteria.filter((item) => !oldSet.has(item));
87
+ const removed = oldCriteria.filter((item) => !newSet.has(item));
88
+ return { added, removed };
89
+ }
90
+
91
+ export {
92
+ diffCriteria,
93
+ diffTasks,
94
+ normalizeTaskText,
95
+ parseSuccessCriteria,
96
+ parseTasks,
97
+ };
@@ -18,7 +18,7 @@ docker:
18
18
  codex_install: npm install -g @openai/codex
19
19
  codex_home: .ralph/codex # Writable codex home inside the repo
20
20
  mount_codex_config: true # Seed codex_home from host ~/.codex if empty
21
- tty: auto # auto | true | false
21
+ tty: false # auto | true | false
22
22
  pass_env:
23
23
  - OPENAI_API_KEY
24
24
  apt_packages: