ralph-codex 0.1.2 → 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,6 +2,11 @@
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
+
5
10
  ## [0.1.2] - 2026-01-22
6
11
  - Improved README with setup, defaults, Docker guidance, and troubleshooting.
7
12
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ralph-codex",
3
- "version": "0.1.2",
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;
@@ -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
 
@@ -862,6 +890,13 @@ async function selectSuccessCriteria(
862
890
  standardChoices,
863
891
  detectedChoices = []
864
892
  ) {
893
+ if (isTestMode) {
894
+ const safeDefaults =
895
+ defaultCriteria && defaultCriteria.length > 0
896
+ ? defaultCriteria
897
+ : standardChoices;
898
+ return { mode: "manual", criteria: safeDefaults || [] };
899
+ }
865
900
  const autoChoiceValue = "__auto__";
866
901
  const customChoiceValue = "__custom__";
867
902
  const defaults =
@@ -955,6 +990,7 @@ async function selectSuccessCriteria(
955
990
  }
956
991
 
957
992
  async function confirmPlan(tasksFile) {
993
+ if (isTestMode) return true;
958
994
  const content = fs.readFileSync(tasksFile, "utf8");
959
995
  process.stdout.write(`\n${colors.cyan("--- Proposed tasks.md ---")}\n\n`);
960
996
  process.stdout.write(content);
@@ -978,6 +1014,7 @@ async function readRevisionFeedback() {
978
1014
  }
979
1015
 
980
1016
  async function confirmResetState(tasksFilePath, agentPath) {
1017
+ if (isTestMode) return false;
981
1018
  const hasTasks = fs.existsSync(tasksFilePath);
982
1019
  const hasAgent = fs.existsSync(agentPath);
983
1020
  if (!hasTasks && !hasAgent) return false;
@@ -1000,6 +1037,23 @@ function resetState(tasksFilePath, agentPath) {
1000
1037
  }
1001
1038
 
1002
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
+
1003
1057
  const resolvedConfigPath = configPath || path.join(root, "ralph.config.yml");
1004
1058
  const config = loadConfig(resolvedConfigPath);
1005
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
  }
@@ -1144,8 +1144,33 @@ async function main() {
1144
1144
  }
1145
1145
 
1146
1146
  if (hasCompletion(result.output)) {
1147
- completed = true;
1148
- 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
+ );
1149
1174
  }
1150
1175
 
1151
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: