@lyupro/codex-bridge 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.
Files changed (45) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/LICENSE +21 -0
  3. package/README.md +77 -0
  4. package/bin/codex-bridge.mjs +105 -0
  5. package/cli/copy.mjs +38 -0
  6. package/cli/doctor.mjs +170 -0
  7. package/cli/hosts.mjs +50 -0
  8. package/cli/install.mjs +109 -0
  9. package/cli/manifest.mjs +210 -0
  10. package/cli/settings-merge.mjs +101 -0
  11. package/cli/stop.mjs +125 -0
  12. package/cli/uninstall.mjs +62 -0
  13. package/cli/update.mjs +156 -0
  14. package/package.json +48 -0
  15. package/scripts/check-file-size.mjs +193 -0
  16. package/scripts/run-tests.mjs +26 -0
  17. package/src/agents/codex-build.md +224 -0
  18. package/src/agents/codex-review.md +166 -0
  19. package/src/agents/codex-scout.md +188 -0
  20. package/src/commands/env.md +38 -0
  21. package/src/commands/usage.md +88 -0
  22. package/src/hooks/reply-guard.mjs +311 -0
  23. package/src/meta/chain.mjs +87 -0
  24. package/src/meta/environment.mjs +36 -0
  25. package/src/meta/paths.mjs +193 -0
  26. package/src/meta/reply.mjs +133 -0
  27. package/src/meta/run-state.mjs +197 -0
  28. package/src/meta/verdict.mjs +362 -0
  29. package/src/rules/codex-bridge.rules +173 -0
  30. package/src/run-codex.mjs +113 -0
  31. package/src/run-config.json +4 -0
  32. package/src/run-config.mjs +302 -0
  33. package/src/runner/args.mjs +111 -0
  34. package/src/runner/attach.mjs +111 -0
  35. package/src/runner/codex-cmd.mjs +265 -0
  36. package/src/runner/git-state.mjs +144 -0
  37. package/src/runner/launcher.mjs +374 -0
  38. package/src/runner/project-dir.mjs +92 -0
  39. package/src/runner/prompts.mjs +97 -0
  40. package/src/runner/run-context.mjs +52 -0
  41. package/src/runner/run-env.mjs +38 -0
  42. package/src/runner/runs-root.mjs +16 -0
  43. package/src/runner/schemas.mjs +135 -0
  44. package/src/runner/worker.mjs +62 -0
  45. package/src/write-meta.mjs +135 -0
@@ -0,0 +1,362 @@
1
+ /**
2
+ * Derives a run's status from what its artifacts prove, never from what the run claims.
3
+ *
4
+ * Exit code, log size, result file, the worktree either side of the run, HEAD either side
5
+ * of it, and the sub-questions the order asked — a dispatcher agent therefore cannot report
6
+ * an outcome its own run does not support. Every branch in resolveStatus() exists because
7
+ * a real run satisfied every schema while doing something other than the job.
8
+ */
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import {
12
+ SERVICE_RE,
13
+ changedPaths,
14
+ declaredHits,
15
+ displayPath,
16
+ expandDeclared,
17
+ globToRegExp,
18
+ line,
19
+ normalizePath,
20
+ readJson,
21
+ readText,
22
+ } from './paths.mjs';
23
+ import { chainBaseline } from './chain.mjs';
24
+ import { splitRunChanges } from './environment.mjs';
25
+
26
+ /**
27
+ * Quota exhaustion is a transport error, not a word. Both markers must sit on the same
28
+ * line: a run whose review text merely discusses "quota exhaustion" is not a run that
29
+ * hit the quota, and reporting LIMIT there tells the orchestrator not to retry when it
30
+ * should. "try again later" is deliberately absent — it is a transient-failure phrase,
31
+ * not a quota one.
32
+ */
33
+ const LIMIT_RE = /rate.?limit|usage limit|usage_limit|quota exceeded|quota exhausted|too many requests|\b429\b/i;
34
+ const ERROR_RE = /\bERROR\b|error[:=]|stream error|"status"\s*:\s*429|rejected|refused|failed/i;
35
+
36
+ // How much prose an answer must carry once coordinates and paths are subtracted. Measured
37
+ // on the scout run of 2026-07-30 that replied with a table of `file.ts:60-79` rows: every
38
+ // one of its six sub-answers scored under 30, while the shortest honest explanation of a
39
+ // single mechanism in the same report scored above 120. 80 is one full sentence; 200 is
40
+ // the single-question mode, where one question means the whole answer is the essay.
41
+ const MIN_SUBSTANCE_CHARS = 80;
42
+ const MIN_SINGLE_SUBSTANCE_CHARS = 200;
43
+
44
+ /**
45
+ * The honest reason for a failure. Codex prints API errors as pretty-printed JSON, so
46
+ * the literally-last line is often just `}` — useless in a one-line reply. Prefer the
47
+ * error message field, then any error line, then the last line that carries text.
48
+ */
49
+ const reasonFrom = (log) => {
50
+ const messages = [...log.matchAll(/"message"\s*:\s*"((?:[^"\\]|\\.)*)"/g)];
51
+ if (messages.length) return line(messages[messages.length - 1][1].replace(/\\"/g, '"'));
52
+ const lines = log.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
53
+ const errorLine = [...lines].reverse().find((l) => /error|failed|denied|refused/i.test(l));
54
+ return line(errorLine || lines[lines.length - 1] || '');
55
+ };
56
+
57
+ const limitSignal = (log) => {
58
+ const hit = log.split(/\r?\n/).find((l) => LIMIT_RE.test(l) && ERROR_RE.test(l));
59
+ return hit ? line(hit) : null;
60
+ };
61
+
62
+ /**
63
+ * Which touched paths the task never authorised. The scope exists because prose does not
64
+ * bind: one build run was told in words to keep away from `!Plans/*.md` and edited them
65
+ * anyway, and nothing in the artifacts objected — reportVersusWork() only needs a single
66
+ * declared path to match the tree, so extra files were never looked at.
67
+ *
68
+ * Service directories are outside every scope by construction: no task grants a delegated
69
+ * run the right to edit `.git/` or `.claude/`, so no pattern can whitelist them.
70
+ */
71
+ export function outOfScope(touchedPaths, patterns) {
72
+ const allowed = (patterns || []).filter(Boolean).map(globToRegExp);
73
+ const bad = [];
74
+ for (const raw of touchedPaths || []) {
75
+ const file = displayPath(raw);
76
+ const comparison = normalizePath(file);
77
+ if (!comparison) continue;
78
+ if (SERVICE_RE.test(comparison)) {
79
+ bad.push(file);
80
+ continue;
81
+ }
82
+ if (allowed.length && !allowed.some((re) => re.test(file))) bad.push(file);
83
+ }
84
+ return bad;
85
+ }
86
+
87
+ /** Reserved for verdicts where the work really was the wrong work, not for check artefacts. */
88
+ const WRONG_WORK = 'wrong work was done: ';
89
+
90
+ /**
91
+ * Does the report describe the work that actually happened? A schema cannot answer this:
92
+ * a well-formed report about unrelated work is indistinguishable from a well-formed
93
+ * report about the job. The worktree can — it was snapshotted either side of the run.
94
+ *
95
+ * The report describes the state of the TASK, so it is matched against the run's own delta
96
+ * first and against the whole chain's accumulated work second (baseline of the first run ->
97
+ * this run's "after"). A second pass that legitimately changed little is carried, not
98
+ * failed. Both sides empty stays agreement: "there was nothing to change" is a legitimate
99
+ * outcome, and failing it would punish the one honest way to say so.
100
+ */
101
+ export function reportVersusWork(runDir, result, ctx) {
102
+ const declared = (result?.changes || []).flatMap((c) => expandDeclared(c?.file));
103
+ const after = readText(path.join(runDir, 'state-after.txt'));
104
+ // Only the run's own work is compared: a report that names nothing is honest when the sole
105
+ // change in the tree was written by the tooling around the run.
106
+ const touched = splitRunChanges(
107
+ runDir,
108
+ changedPaths(readText(path.join(runDir, 'state-before.txt')), after),
109
+ ).work;
110
+ const show = (list) => list.slice(0, 3).join(', ');
111
+ const agreed = (carried = false) => ({ ok: true, carried, reason: null });
112
+ const broken = (reason) => ({ ok: false, carried: false, reason });
113
+
114
+ if (!declared.length && !touched.length) return agreed();
115
+ if (!declared.length) {
116
+ return broken(
117
+ `${WRONG_WORK}tree changed (${touched.length}: ${show(touched)}), but the report names no changes`,
118
+ );
119
+ }
120
+ if (declared.some((d) => declaredHits(d, touched))) return agreed();
121
+
122
+ // Ahead of the chain lookup: no earlier pass of any task grants the right to edit `.git/`
123
+ // or `.claude/`, so finding such a path in the chain would excuse the one thing that is
124
+ // never excusable.
125
+ const service = declared.filter((f) => SERVICE_RE.test(f));
126
+ if (service.length) {
127
+ return broken(
128
+ `${WRONG_WORK}report describes work in service directories (${show(service)}), not task files`,
129
+ );
130
+ }
131
+
132
+ const baseline = chainBaseline(ctx?.runsRoot, ctx?.repo, ctx?.slug, ctx?.taskHash, ctx?.orderId);
133
+ if (baseline !== null) {
134
+ const accumulated = splitRunChanges(runDir, changedPaths(baseline, after)).work;
135
+ if (declared.some((d) => declaredHits(d, accumulated))) return agreed(true);
136
+ }
137
+
138
+ return broken(
139
+ touched.length
140
+ ? `${WRONG_WORK}report names ${show(declared)}, but ${show(touched)} changed`
141
+ : 'run did not change the tree, and earlier runs of this task do not contain the declared files',
142
+ );
143
+ }
144
+
145
+ /**
146
+ * Which task a run belongs to, for the chain lookup. status.json is written before Codex is
147
+ * even probed, so repo and slug are on disk by the time a verdict is computed; a run from
148
+ * before status.json existed yields an empty context, and the chain is then not guessed.
149
+ */
150
+ const chainContextOf = (runDir) => {
151
+ const status = readJson(path.join(runDir, 'status.json')) || {};
152
+ return {
153
+ runsRoot: path.dirname(runDir),
154
+ repo: status.repo || '',
155
+ slug: status.slug || '',
156
+ // Runs from before the fingerprint existed carry no hash; they chain by slug as they did.
157
+ taskHash: status.task_hash || '',
158
+ orderId: status.order_id || '',
159
+ };
160
+ };
161
+
162
+ /**
163
+ * How much of an answer is prose rather than a map. Coordinates (`path/to/file.ts:12-40`,
164
+ * `foo.ts:88`) and bare paths are subtracted first, then punctuation and spacing, because a
165
+ * "scout" that returns nothing but a coordinate table is the exact failure this measures
166
+ * — and by character count that table is long.
167
+ */
168
+ function substanceLength(text) {
169
+ return String(text ?? '')
170
+ .replace(/`+/g, ' ')
171
+ .replace(/\S*\.[A-Za-z0-9]+:\d+(?:\s*[-– — ]\s*\d+)?/g, ' ')
172
+ .replace(/\S*[\\/]\S*/g, ' ')
173
+ .replace(/[^\p{L}\p{N}]+/gu, '').length;
174
+ }
175
+
176
+ const questionsOf = (runDir) => {
177
+ const parsed = readJson(path.join(runDir, 'questions.json'));
178
+ return Array.isArray(parsed) ? parsed.filter((q) => q && q.id) : [];
179
+ };
180
+
181
+ /** Answers indexed by question id, first one wins; ids come back in whatever case Codex used. */
182
+ const answersById = (result) => {
183
+ const map = new Map();
184
+ for (const answer of Array.isArray(result?.answers) ? result.answers : []) {
185
+ const id = String(answer?.question_id || '').trim().toUpperCase();
186
+ if (id && !map.has(id)) map.set(id, answer);
187
+ }
188
+ return map;
189
+ };
190
+
191
+ /**
192
+ * Why a scout run is not done. Coverage is checked against the orchestrator's questions.json,
193
+ * including a one-question order. Runs from before that artifact existed retain the legacy
194
+ * single-answer check. Every branch here exists because a schema alone accepted the run that
195
+ * answered in coordinates.
196
+ */
197
+ function scoutCoverageGap(runDir, result) {
198
+ const questions = questionsOf(runDir);
199
+ const show = (list) => list.slice(0, 8).join(', ');
200
+
201
+ if (!questions.length) {
202
+ const chars = substanceLength(result?.answer);
203
+ if (chars < MIN_SINGLE_SUBSTANCE_CHARS) {
204
+ return `response without analysis: ${chars} substantive characters, minimum ` +
205
+ `${MIN_SINGLE_SUBSTANCE_CHARS} (coordinates and paths excluded)`;
206
+ }
207
+ return null;
208
+ }
209
+
210
+ const byId = answersById(result);
211
+ const missing = questions
212
+ .filter((q) => !String(byId.get(q.id)?.answer || '').trim())
213
+ .map((q) => q.id);
214
+ if (missing.length) {
215
+ return `scout did not answer ${missing.length} sub-questions: ${show(missing)}`;
216
+ }
217
+
218
+ const thin = questions
219
+ .filter((q) => substanceLength(byId.get(q.id).answer) < MIN_SUBSTANCE_CHARS)
220
+ .map((q) => q.id);
221
+ if (thin.length) return `response to ${show(thin)} contains coordinates without analysis`;
222
+
223
+ const unsourced = questions
224
+ .filter((q) => !(byId.get(q.id).evidence || []).some((e) => String(e || '').trim()))
225
+ .map((q) => q.id);
226
+ if (unsourced.length) return `responses without a single code reference: ${show(unsourced)}`;
227
+
228
+ return null;
229
+ }
230
+
231
+ /** `1/1 sub-questions`, or null for legacy runs with no questions.json artifact. */
232
+ export function scoutCoverage(runDir, result) {
233
+ const questions = questionsOf(runDir);
234
+ if (!questions.length) return null;
235
+ const byId = answersById(result);
236
+ const answered = questions.filter((q) => String(byId.get(q.id)?.answer || '').trim()).length;
237
+ return `${answered}/${questions.length} sub-questions`;
238
+ }
239
+
240
+ /**
241
+ * A commit made under an explicit ban. HEAD is snapshotted either side of the run, so this
242
+ * is a fact rather than an interpretation: one build run went to commit while the task said
243
+ * not to, and only the sandbox (a read-only `.git`) stopped it — the contract said nothing.
244
+ */
245
+ function commitDuringRun(runDir) {
246
+ const before = readText(path.join(runDir, 'head-before.txt')).trim();
247
+ const after = readText(path.join(runDir, 'head-after.txt')).trim();
248
+ if (!before || !after || before === after) return null;
249
+ return `commit made despite prohibition: ${before.slice(0, 12)} → ${after.slice(0, 12)}`;
250
+ }
251
+
252
+ /**
253
+ * A branch moved under an explicit ban. Branch names are snapshotted either side of the run,
254
+ * so the artifacts prove whether it stayed on the same ref or left the repository detached.
255
+ *
256
+ * Existence is checked rather than emptiness, because here an empty file is data — detached
257
+ * HEAD — and a missing one is silence. Both snapshots have to be present for the comparison
258
+ * to mean anything: a run whose launcher predates this check, or whose worker died before
259
+ * writing its half, would otherwise be accused of detaching a branch it never touched.
260
+ */
261
+ function branchDuringRun(runDir) {
262
+ const beforeFile = path.join(runDir, 'branch-before.txt');
263
+ const afterFile = path.join(runDir, 'branch-after.txt');
264
+ if (!fs.existsSync(beforeFile) || !fs.existsSync(afterFile)) return null;
265
+ const before = readText(beforeFile).trim();
266
+ const after = readText(afterFile).trim();
267
+ if (before === after) return null;
268
+ if (before && !after) return `branch moved despite prohibition: ${before} → detached HEAD`;
269
+ return `branch moved despite prohibition: ${before || 'detached HEAD'} → ${after}`;
270
+ }
271
+
272
+ /**
273
+ * Status comes from artifacts, never from intent. An abandoned run leaves raw.log at
274
+ * zero bytes, and that is a FAIL with its own reason — not a retry hint. An empty
275
+ * result with a quota signal in the log is LIMIT; without one it is FAIL.
276
+ */
277
+ export function resolveStatus({ log, logBytes, resultOk, exit, agent, result, runDir }) {
278
+ if (!logBytes) {
279
+ return {
280
+ status: 'FAIL',
281
+ reason: 'run abandoned at startup: raw.log is empty, Codex did not run',
282
+ };
283
+ }
284
+ // Ahead of everything else, including LIMIT: a commit under an explicit ban is a broken
285
+ // contract, not a grade for the work. Whatever else the run produced, the orchestrator has
286
+ // to learn first that history moved under it.
287
+ if (agent === 'codex-build') {
288
+ const committed = commitDuringRun(runDir);
289
+ if (committed) return { status: 'FAIL', reason: committed };
290
+ const branched = branchDuringRun(runDir);
291
+ if (branched) return { status: 'FAIL', reason: branched };
292
+ }
293
+ if (!resultOk) {
294
+ const limit = limitSignal(log);
295
+ if (limit) return { status: 'LIMIT', reason: limit };
296
+ return { status: 'FAIL', reason: reasonFrom(log) || `result is empty, exit=${exit}` };
297
+ }
298
+ if (exit !== 0) {
299
+ return { status: 'FAIL', reason: `result exists, but exit=${exit}: ${reasonFrom(log)}` };
300
+ }
301
+ // A red verification is a failed job, not an OK with a footnote. The orchestrator
302
+ // reads the first word; burying "fail" in line three is how a broken build passes.
303
+ if (agent === 'codex-build' && result?.verify_passed === false) {
304
+ return {
305
+ status: 'FAIL',
306
+ reason: `verification “${line(result.verify_command || 'no command', 60)}” failed; changes remain in the tree`,
307
+ };
308
+ }
309
+ // Answers that never arrived, or arrived as coordinates. Scout-only, and per-question
310
+ // only when questions.json is absent — see scoutCoverageGap().
311
+ if (agent === 'codex-scout') {
312
+ const gap = scoutCoverageGap(runDir, result);
313
+ if (gap) return { status: 'FAIL', reason: gap };
314
+ }
315
+ // Files the task never authorised. Checked before the mismatch below, because a run that
316
+ // did the job AND edited three unrelated files passes reportVersusWork() — one matching
317
+ // path is all it needs — and the unrelated files would go unmentioned. No automatic
318
+ // revert: what to do with the extra edits is the orchestrator's call, not this file's.
319
+ if (agent === 'codex-build') {
320
+ const patterns = readText(path.join(runDir, 'scope.txt'))
321
+ .split(/\r?\n/)
322
+ .map((l) => l.trim())
323
+ .filter(Boolean);
324
+ if (patterns.length) {
325
+ // Environment writes are subtracted first: attributing them to the run failed an honest
326
+ // pass for a file it never opened. What was subtracted is reported, not swallowed —
327
+ // see the "Environment" line in reply.mjs and environment_changes in meta.json.
328
+ const { work } = splitRunChanges(
329
+ runDir,
330
+ changedPaths(
331
+ readText(path.join(runDir, 'state-before.txt')),
332
+ readText(path.join(runDir, 'state-after.txt')),
333
+ ),
334
+ );
335
+ const strays = outOfScope(work, patterns);
336
+ if (strays.length) {
337
+ return {
338
+ status: 'FAIL',
339
+ reason: `out-of-scope changes (${strays.length}): ${strays.slice(0, 3).join(', ')}`,
340
+ };
341
+ }
342
+ }
343
+ }
344
+ // Work on the wrong thing is a failed job even when every artifact is well-formed. The
345
+ // run that forced this check was green on all of them while Codex spent the quota
346
+ // quarantining someone else's session file.
347
+ if (agent === 'codex-build') {
348
+ const verdict = reportVersusWork(runDir, result, chainContextOf(runDir));
349
+ if (!verdict.ok) {
350
+ // Hooks on is the known cause of this exact failure, so the reason says so instead
351
+ // of leaving the operator to rediscover it from the log.
352
+ const hooks = readJson(path.join(runDir, 'env.json'))?.hooks
353
+ ? ' (hooks are enabled in run-config.json — a likely cause)'
354
+ : '';
355
+ return { status: 'FAIL', reason: `${verdict.reason}${hooks}` };
356
+ }
357
+ // Work found in the chain but not in this run's delta is done work, not a lie: the
358
+ // second pass of a task legitimately has little left to change.
359
+ if (verdict.carried) return { status: 'OK', reason: null, carried: true };
360
+ }
361
+ return { status: 'OK', reason: null };
362
+ }
@@ -0,0 +1,173 @@
1
+ # codex-bridge installs this file to constrain delegated Codex runs.
2
+ # It exists because a delegated run must not rewrite the operator's repository history or branches.
3
+ # Check it with: codex execpolicy check --rules src/rules/codex-bridge.rules -- <command> <args>.
4
+ # Codex combines this file with other active rule files, and the strictest matching decision wins.
5
+ # Known limit: prefix rules cannot detect shell redirection into .git/HEAD or equivalent indirect writes.
6
+
7
+ prefix_rule(
8
+ pattern = ["git", "reset"],
9
+ decision = "forbidden",
10
+ justification = "Leave your work uncommitted in the tree; the index, commits and HEAD belong to the orchestrator.",
11
+ match = ["git reset --hard HEAD~1", "git reset HEAD -- file.txt"],
12
+ not_match = ["git status", "git diff --stat"],
13
+ )
14
+
15
+ prefix_rule(
16
+ pattern = ["git", "checkout"],
17
+ decision = "forbidden",
18
+ justification = "Read another revision with git show <rev>:<path>; branches and revisions are switched by the operator, never by a run.",
19
+ match = ["git checkout master", "git checkout -- file.txt"],
20
+ not_match = ["git show HEAD:file.txt", "git restore file.txt"],
21
+ )
22
+
23
+ prefix_rule(
24
+ pattern = ["git", "switch"],
25
+ decision = "forbidden",
26
+ justification = "Inspect branches with git branch --show-current and leave branch switching or creation to the operator.",
27
+ match = ["git switch main", "git switch -c feature"],
28
+ not_match = ["git branch --show-current", "git status"],
29
+ )
30
+
31
+ prefix_rule(
32
+ pattern = ["git", "branch", ["-d", "-D", "-m", "-M", "--delete", "--move", "--force"]],
33
+ decision = "forbidden",
34
+ justification = "Inspect branches with git branch or git branch --show-current; ask the operator to delete, rename, or force a branch.",
35
+ match = ["git branch -D feature", "git branch --move old new", "git branch --force feature HEAD"],
36
+ not_match = ["git branch", "git branch --show-current", "git branch --list"],
37
+ )
38
+
39
+ prefix_rule(
40
+ pattern = ["git", ["rebase", "cherry-pick", "revert", "merge"]],
41
+ decision = "forbidden",
42
+ justification = "Inspect candidate changes with git show or git diff and leave history integration to the operator.",
43
+ match = ["git rebase main", "git cherry-pick abc123", "git revert HEAD", "git merge feature"],
44
+ not_match = ["git show HEAD", "git diff main...feature"],
45
+ )
46
+
47
+ prefix_rule(
48
+ pattern = ["git", "stash", ["drop", "clear", "pop"]],
49
+ decision = "forbidden",
50
+ justification = "Inspect saved work with git stash list or git stash show and ask the operator before removing or applying it.",
51
+ match = ["git stash drop", "git stash clear", "git stash pop"],
52
+ not_match = ["git stash", "git stash list", "git stash show"],
53
+ )
54
+
55
+ prefix_rule(
56
+ pattern = ["git", ["update-ref", "symbolic-ref", "worktree", "filter-branch"]],
57
+ decision = "forbidden",
58
+ justification = "Inspect references with git show-ref or git branch and ask the operator to mutate refs, worktrees, or repository history.",
59
+ match = ["git update-ref refs/heads/main HEAD", "git symbolic-ref HEAD refs/heads/main", "git worktree remove ../other", "git filter-branch -- --all"],
60
+ not_match = ["git show-ref", "git branch --show-current"],
61
+ )
62
+
63
+ prefix_rule(
64
+ pattern = ["git", "gc", ["--prune", "--prune=now"]],
65
+ decision = "forbidden",
66
+ justification = "Inspect repository size with git count-objects -v and leave object pruning to the operator.",
67
+ match = ["git gc --prune", "git gc --prune=now"],
68
+ not_match = ["git gc", "git count-objects -v"],
69
+ )
70
+
71
+ prefix_rule(
72
+ pattern = ["git", "reflog", "expire"],
73
+ decision = "forbidden",
74
+ justification = "Inspect recovery history with git reflog show and leave reflog expiration to the operator.",
75
+ match = ["git reflog expire --expire=now --all"],
76
+ not_match = ["git reflog", "git reflog show"],
77
+ )
78
+
79
+ prefix_rule(
80
+ pattern = ["git", "push"],
81
+ decision = "forbidden",
82
+ justification = "Prepare local commits and show the operator the verified git push command; only the operator publishes changes.",
83
+ match = ["git push", "git push origin main", "git push --force"],
84
+ not_match = ["git status", "git remote -v"],
85
+ )
86
+
87
+ prefix_rule(
88
+ pattern = ["git", "remote", ["set-url", "remove", "rm", "set-head"]],
89
+ decision = "forbidden",
90
+ justification = "Inspect remotes with git remote -v and ask the operator to change remote configuration.",
91
+ match = ["git remote set-url origin example.invalid/repo", "git remote remove origin", "git remote rm origin", "git remote set-head origin main"],
92
+ not_match = ["git remote", "git remote -v", "git remote get-url origin"],
93
+ )
94
+
95
+ prefix_rule(
96
+ pattern = ["git", "config", ["--global", "--system"]],
97
+ decision = "forbidden",
98
+ justification = "Read configuration with git config --get <key>; ask the operator to change global or system configuration.",
99
+ match = ["git config --global user.name Agent", "git config --system core.autocrlf false"],
100
+ not_match = ["git config --get user.name", "git config user.name"],
101
+ )
102
+
103
+ prefix_rule(
104
+ pattern = ["git", "commit", "--amend"],
105
+ decision = "forbidden",
106
+ justification = "Create a new commit when authorized and leave rewriting the previous commit to the operator.",
107
+ match = ["git commit --amend", "git commit --amend --no-edit"],
108
+ not_match = ["git commit -m msg", "git status"],
109
+ )
110
+
111
+ prefix_rule(
112
+ pattern = ["git", "clean", ["-f", "-fd", "-df", "-fdx", "-xfd"]],
113
+ decision = "forbidden",
114
+ justification = "Preview untracked cleanup with git clean -n and remove only explicitly reviewed paths without forced bulk cleanup.",
115
+ match = ["git clean -f", "git clean -fd", "git clean -df", "git clean -fdx", "git clean -xfd"],
116
+ not_match = ["git clean -n", "git status --short"],
117
+ )
118
+
119
+ prefix_rule(
120
+ pattern = ["rm", ["-rf", "-fr", "-Rf", "-fR", "-rF", "-Fr", "-RF", "-FR"]],
121
+ decision = "forbidden",
122
+ justification = "Remove specific reviewed files without combined recursive-force flags, or ask the operator for bulk deletion.",
123
+ match = ["rm -rf build", "rm -fr /tmp/example", "rm -RF cache"],
124
+ not_match = ["rm -f stale.txt", "rm notes.md"],
125
+ )
126
+
127
+ prefix_rule(
128
+ pattern = ["rm", ["-r", "-R", "--recursive"], ["-f", "--force"]],
129
+ decision = "forbidden",
130
+ justification = "Remove specific reviewed files without recursive forced deletion, or ask the operator for bulk deletion.",
131
+ match = ["rm -r -f build", "rm --recursive --force cache"],
132
+ not_match = ["rm -r empty-directory", "rm -f stale.txt"],
133
+ )
134
+
135
+ prefix_rule(
136
+ pattern = ["rm", ["-f", "--force"], ["-r", "-R", "--recursive"]],
137
+ decision = "forbidden",
138
+ justification = "Remove specific reviewed files without recursive forced deletion, or ask the operator for bulk deletion.",
139
+ match = ["rm -f -r build", "rm --force --recursive cache"],
140
+ not_match = ["rm -f stale.txt", "rm -r empty-directory"],
141
+ )
142
+
143
+ prefix_rule(
144
+ pattern = ["rm", ".git"],
145
+ decision = "forbidden",
146
+ justification = "Inspect repository metadata with git status or git rev-parse and leave removal of the repository database to the operator.",
147
+ match = ["rm .git"],
148
+ not_match = ["rm .gitignore", "git status"],
149
+ )
150
+
151
+ prefix_rule(
152
+ pattern = ["rm", ["-r", "-R", "--recursive"], ".git"],
153
+ decision = "forbidden",
154
+ justification = "Inspect repository metadata with git status or git rev-parse and leave removal of the repository database to the operator.",
155
+ match = ["rm -r .git", "rm --recursive .git"],
156
+ not_match = ["rm -r build", "rm .gitignore"],
157
+ )
158
+
159
+ prefix_rule(
160
+ pattern = ["mv", ".git"],
161
+ decision = "forbidden",
162
+ justification = "Inspect the repository location with git rev-parse --git-dir and ask the operator to move repository metadata.",
163
+ match = ["mv .git .git-old"],
164
+ not_match = ["mv notes.txt archive.txt", "git rev-parse --git-dir"],
165
+ )
166
+
167
+ prefix_rule(
168
+ pattern = ["git", "restore"],
169
+ decision = "prompt",
170
+ justification = "Every form of restore rewinds tracked state: leave the tree as it is and report what you changed instead.",
171
+ match = ["git restore file.txt", "git restore --worktree file.txt", "git restore --staged file.txt"],
172
+ not_match = ["git status", "git show HEAD:file.txt"],
173
+ )
@@ -0,0 +1,113 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Runs one delegated Codex job end to end:
4
+ * node run-codex.mjs --agent <codex-scout|codex-build|codex-review> [options] < task.txt
5
+ *
6
+ * Options:
7
+ * --agent codex-scout | codex-build | codex-review (required)
8
+ * --repo repository path (default: current directory)
9
+ * --slug short run name used in the run folder (default: agent suffix)
10
+ * --effort reasoning effort, passed to Codex as given (default: medium)
11
+ * --verify verification command, codex-build only (e.g. "npm test")
12
+ * --scope comma-separated globs the run may edit, codex-build only (required there)
13
+ * --mode uncommitted | base:<branch> | commit:<sha>, codex-review only
14
+ * --continue acknowledge that this repo+slug already has runs (required if it does)
15
+ *
16
+ * The operator's task text arrives on stdin, verbatim.
17
+ *
18
+ * Why this is a script and not a list of steps inside an agent prompt: a prompt can be
19
+ * silently not followed, and twice it wasn't — one dispatcher reviewed the diff itself
20
+ * after Codex disappointed it, another announced a background run that never existed.
21
+ * Here the caller still blocks until a verdict exists on disk, the status is computed from
22
+ * artifacts by write-meta.mjs, and the reply is printed by code. "Started in the
23
+ * background, will report later" and "I analyzed it myself instead" are unreachable.
24
+ *
25
+ * This file is two programs. A plain call is the LAUNCHER: it does every preparation and
26
+ * every refusal that costs no quota, then spawns the WORKER — this same file called as
27
+ * `--worker <runDir>`, detached, with no stdio — prints `RUN=<folder>` immediately and waits
28
+ * for the worker's reply.txt, which it prints verbatim. The worker runs Codex, snapshots the
29
+ * tree and writes the verdict.
30
+ *
31
+ * The split exists because the caller dies long before a 20-25 minute run does. Run
32
+ * `2026-07-31_114736_*` has no raw.log at all while 11+ files were written into the tree: the
33
+ * dispatcher's Bash call hit its two-minute timeout, node was killed, spawnSync's buffered
34
+ * output died with it — and Codex kept going, unwatched and unrecorded. Killing that orphan
35
+ * is not the fix either: its edits were the work, and later runs finished on top of them. So
36
+ * the process the caller can kill is now the launcher, and the worker it leaves behind closes
37
+ * the run with artifacts whether anyone is still listening or not.
38
+ *
39
+ * The work itself lives in runner/, one concern per module and the imports pointing one way:
40
+ * run-context.mjs holds the run in progress and answers for it, run-env.mjs decides what
41
+ * environment a run gets, args.mjs reads and refuses the command line, schemas.mjs and
42
+ * prompts.mjs are what each agent is asked for and told, git-state.mjs reads the repository,
43
+ * codex-cmd.mjs invokes the Codex CLI, and launcher.mjs / worker.mjs are the two halves
44
+ * above. This file is the command line and the fork between them; importers name it and
45
+ * nothing below it.
46
+ */
47
+ import fs from 'node:fs';
48
+ import path from 'node:path';
49
+ import { fileURLToPath } from 'node:url';
50
+ import { writeFailure } from './write-meta.mjs';
51
+ import { setWorkerDir, getRun, emitReply } from './runner/run-context.mjs';
52
+ import { launcher } from './runner/launcher.mjs';
53
+ import { worker } from './runner/worker.mjs';
54
+
55
+ export { parseArgs } from './runner/args.mjs';
56
+ export { runsPrefixInside, worktreeSnapshot } from './runner/git-state.mjs';
57
+
58
+ // `--worker <runDir>` and nothing else: the worker takes its whole order from worker.json in
59
+ // that folder, so a half-parsed command line cannot make the two halves disagree about which
60
+ // run they are working on.
61
+ const WORKER_DIR = process.argv[2] === '--worker' ? process.argv[3] : null;
62
+
63
+ /**
64
+ * Only a direct call is a run. Imported — which is how the tests reach parseArgs(),
65
+ * runsPrefixInside() and worktreeSnapshot() — this file must do nothing at all: no argument
66
+ * parsing, no stdin, no spawn, not even a global crash handler that would swallow someone
67
+ * else's exception. Same guard run-config.mjs and write-meta.mjs already use.
68
+ */
69
+ const invokedDirectly =
70
+ process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url));
71
+
72
+ setWorkerDir(WORKER_DIR);
73
+
74
+ // A crash of the runner itself still has to read as a FAIL, and that FAIL still has to
75
+ // be backed by meta.json — otherwise there would be a second path by which a reply
76
+ // exists without an artifact behind it, which is the whole failure mode this design
77
+ // removes. Before the run folder exists there is nowhere to write, so the reply says so.
78
+ //
79
+ // One handler, here, for both halves: it has to catch a crash whichever branch below is
80
+ // running, and a second registration inside launcher.mjs or worker.mjs would be a second
81
+ // answer to the same question.
82
+ if (invokedDirectly) {
83
+ process.on('uncaughtException', (err) => {
84
+ const { dir: currentRun, agent: currentAgent } = getRun();
85
+ if (currentRun) {
86
+ try {
87
+ fs.appendFileSync(path.join(currentRun, 'raw.log'), `\nrun-codex crash: ${err.stack}\n`);
88
+ const { reply } = writeFailure(currentRun, currentAgent, `Codex runner crashed: ${err.message}`, [
89
+ `Log: ${path.join(currentRun, 'raw.log')}`,
90
+ ]);
91
+ emitReply(reply);
92
+ process.exit(1);
93
+ } catch {
94
+ // Falls through to the bare reply below: the disk itself is not cooperating.
95
+ }
96
+ }
97
+ emitReply(
98
+ [
99
+ `FAIL — Codex runner crashed before creating the run folder: ${String(err.message).replace(/\s+/g, ' ').slice(0, 150)}`,
100
+ 'No artifacts; nothing to inspect',
101
+ ].join('\n'),
102
+ );
103
+ process.exit(1);
104
+ });
105
+ }
106
+
107
+ if (invokedDirectly) {
108
+ if (WORKER_DIR) {
109
+ worker(WORKER_DIR);
110
+ } else {
111
+ launcher();
112
+ }
113
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "hooks": false,
3
+ "plugins": false
4
+ }