@lyupro/codex-bridge 0.5.1 → 0.5.2

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 (83) hide show
  1. package/CHANGELOG.md +85 -1
  2. package/README.md +19 -2
  3. package/bin/codex-bridge.mjs +4 -0
  4. package/cli/doctor.mjs +118 -19
  5. package/cli/frontmatter.mjs +137 -0
  6. package/cli/hook.mjs +2 -2
  7. package/cli/install-record.mjs +2 -2
  8. package/cli/install.mjs +14 -2
  9. package/cli/manifest.mjs +23 -22
  10. package/cli/permissions.mjs +2 -2
  11. package/cli/projects.mjs +3 -3
  12. package/cli/prune-plan.mjs +2 -2
  13. package/cli/prune.mjs +1 -1
  14. package/cli/read.mjs +2 -2
  15. package/cli/rules-owners.mjs +2 -2
  16. package/cli/run-lookup.mjs +3 -3
  17. package/cli/runs-inventory.mjs +3 -3
  18. package/cli/settings-merge.mjs +1 -1
  19. package/cli/stop.mjs +5 -5
  20. package/cli/unlock.mjs +7 -7
  21. package/package.json +1 -1
  22. package/scripts/check-file-size.mjs +1 -1
  23. package/src/agents/codex-build.md +265 -231
  24. package/src/agents/codex-review.md +219 -184
  25. package/src/agents/codex-scout.md +232 -200
  26. package/src/{hooks → home/hooks}/guard-tries.mjs +1 -1
  27. package/src/{hooks → home/hooks}/live-runs.mjs +43 -3
  28. package/src/home/hooks/order-gate.mjs +135 -0
  29. package/src/{hooks → home/hooks}/prune-guard.mjs +2 -2
  30. package/src/{hooks → home/hooks}/reply-guard.mjs +117 -97
  31. package/src/home/hooks/reply-parser.mjs +27 -0
  32. package/src/home/hooks/reply-verdicts.mjs +174 -0
  33. package/src/{hooks → home/hooks}/stop-guard.mjs +3 -3
  34. package/src/{hooks → home/hooks}/worktree-lock.mjs +3 -3
  35. package/src/home/lib/dispatcher-order.mjs +36 -0
  36. package/src/{hook-definitions.mjs → home/lib/hook-definitions.mjs} +1 -1
  37. package/src/home/lib/host-refusal.mjs +20 -0
  38. package/src/{json-file.mjs → home/lib/json-file.mjs} +4 -4
  39. package/src/{meta → home/lib/meta}/paths.mjs +20 -1
  40. package/src/{meta → home/lib/meta}/reply.mjs +1 -1
  41. package/src/{meta → home/lib/meta}/verdict.mjs +1 -1
  42. package/src/{no-self-execution.mjs → home/lib/no-self-execution.mjs} +7 -2
  43. package/src/{required-inputs.mjs → home/lib/required-inputs.mjs} +56 -5
  44. package/src/{retention.mjs → home/lib/retention.mjs} +1 -1
  45. package/src/{run-codex.mjs → home/lib/run-codex.mjs} +39 -11
  46. package/src/{run-config.mjs → home/lib/run-config.mjs} +2 -2
  47. package/src/{runner → home/lib/runner}/args.mjs +86 -10
  48. package/src/{runner → home/lib/runner}/attach.mjs +58 -9
  49. package/src/{runner → home/lib/runner}/launcher.mjs +33 -21
  50. package/src/home/lib/runner/order-owner.mjs +28 -0
  51. package/src/{runner → home/lib/runner}/run-env.mjs +1 -1
  52. package/src/home/lib/runner/task-file.mjs +92 -0
  53. package/src/home/lib/runner/task-input.mjs +26 -0
  54. package/src/{runner → home/lib/runner}/worker.mjs +1 -1
  55. package/src/home/lib/shell-unsafe.mjs +26 -0
  56. package/src/{write-meta.mjs → home/lib/write-meta.mjs} +12 -1
  57. package/src/hooks/order-gate.mjs +0 -73
  58. /package/src/{run-config.json → home/config.json} +0 -0
  59. /package/src/{conventions.md → home/conventions.md} +0 -0
  60. /package/src/{cli-names.mjs → home/lib/cli-names.mjs} +0 -0
  61. /package/src/{heartbeat.mjs → home/lib/heartbeat.mjs} +0 -0
  62. /package/src/{meta → home/lib/meta}/chain.mjs +0 -0
  63. /package/src/{meta → home/lib/meta}/deadline.mjs +0 -0
  64. /package/src/{meta → home/lib/meta}/environment.mjs +0 -0
  65. /package/src/{meta → home/lib/meta}/events.mjs +0 -0
  66. /package/src/{meta → home/lib/meta}/outcome.mjs +0 -0
  67. /package/src/{meta → home/lib/meta}/pre-start.mjs +0 -0
  68. /package/src/{meta → home/lib/meta}/reason.mjs +0 -0
  69. /package/src/{meta → home/lib/meta}/run-state.mjs +0 -0
  70. /package/src/{meta → home/lib/meta}/startup.mjs +0 -0
  71. /package/src/{meta → home/lib/meta}/transport.mjs +0 -0
  72. /package/src/{process-identity.mjs → home/lib/process-identity.mjs} +0 -0
  73. /package/src/{runner → home/lib/runner}/codex-cmd.mjs +0 -0
  74. /package/src/{runner → home/lib/runner}/continuation.mjs +0 -0
  75. /package/src/{runner → home/lib/runner}/conventions.mjs +0 -0
  76. /package/src/{runner → home/lib/runner}/git-state.mjs +0 -0
  77. /package/src/{runner → home/lib/runner}/project-dir.mjs +0 -0
  78. /package/src/{runner → home/lib/runner}/prompts.mjs +0 -0
  79. /package/src/{runner → home/lib/runner}/run-context.mjs +0 -0
  80. /package/src/{runner → home/lib/runner}/runs-root.mjs +0 -0
  81. /package/src/{runner → home/lib/runner}/schemas.mjs +0 -0
  82. /package/src/{runner → home/lib/runner}/scope-check.mjs +0 -0
  83. /package/src/{stop-contract.mjs → home/lib/stop-contract.mjs} +0 -0
@@ -4,6 +4,7 @@ import path from 'node:path';
4
4
  import { readJsonFileSync } from '../json-file.mjs';
5
5
  import { IDENTITY_DEAD, IDENTITY_FOREIGN, processAlive, processIdentity } from '../process-identity.mjs';
6
6
  import { exitCodeFor, writeFailure, chainRuns } from '../write-meta.mjs';
7
+ import { conflictingOrderOwner, runsForOrder } from './order-owner.mjs';
7
8
 
8
9
  // The attach call is the only process that waits for a worker it did not spawn. Keeping that
9
10
  // wait here leaves the launcher free to return before a caller's time ceiling kills it.
@@ -11,12 +12,38 @@ const POLL_MS = 500;
11
12
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
12
13
 
13
14
  // The 2026-08-10_220535_plan25-2-install-table-two-roots incident showed that ATTACH alone
14
- // can be mistaken for this pass; state the previous run and the fact that no work was started.
15
- function announceAttach(runDir, startedAt) {
16
- console.log(`ATTACH=${runDir} started=${startedAt}`);
15
+ // can be mistaken for this pass. Distinguish a saved answer from a live run whose verdict is
16
+ // still pending so provenance is truthful while preserving the guarantee that no work restarted.
17
+ function announceSavedReply(runDir, orderId, startedAt) {
18
+ console.log(`ATTACH=${runDir} order-id=${orderId} started=${startedAt}`);
17
19
  console.log(`This is the answer of the previous run started at ${startedAt}; no new work was started.`);
18
20
  }
19
21
 
22
+ function announceLiveAttach(runDir, orderId, startedAt) {
23
+ console.log(`ATTACH=${runDir} order-id=${orderId} started=${startedAt}`);
24
+ console.log(
25
+ `Attached to the run already in progress since ${startedAt}; no new work was started, and this invocation is waiting for its verdict.`,
26
+ );
27
+ }
28
+
29
+ function elapsedSince(startedAt) {
30
+ const elapsedSeconds = Math.max(0, Math.floor((Date.now() - Date.parse(startedAt)) / 1000));
31
+ if (!Number.isFinite(elapsedSeconds)) return 'unknown';
32
+ const hours = Math.floor(elapsedSeconds / 3600);
33
+ const minutes = Math.floor((elapsedSeconds % 3600) / 60);
34
+ const seconds = elapsedSeconds % 60;
35
+ if (hours) return `${hours}h ${minutes}m`;
36
+ if (minutes) return `${minutes}m ${seconds}s`;
37
+ return `${seconds}s`;
38
+ }
39
+
40
+ function announcePending(runDir, orderId, startedAt) {
41
+ console.log(`ATTACH=${runDir} order-id=${orderId} started=${startedAt}`);
42
+ console.log(
43
+ `Run is still in progress; elapsed ${elapsedSince(startedAt)}. Repeat the same command without --no-wait later to wait for its verdict.`,
44
+ );
45
+ }
46
+
20
47
  export const readJsonFile = (file) => {
21
48
  try {
22
49
  return readJsonFileSync(file);
@@ -77,12 +104,24 @@ export async function waitForReply(runDir, workerPid, status = {}) {
77
104
  * reason. `--continue` is the one case that must not attach: it is the orchestrator saying it read
78
105
  * the previous reply and wants another pass.
79
106
  */
80
- export async function attach({ runsRoot, repo, slug, taskHash, orderId, chain, isContinue } = {}) {
107
+ export async function attach({ runsRoot, repo, slug, taskHash, orderId, chain, isContinue, noWait } = {}) {
81
108
  if (isContinue) return null;
82
109
  const runs = chain || chainRuns(runsRoot, repo, slug, taskHash, orderId);
83
- const sameOrder = runs
110
+ const runRecords = runs
84
111
  .map((run) => ({ run, status: readJsonFile(path.join(runsRoot, run, 'status.json')) }))
85
- .filter(({ status }) => status && String(status.order_id ?? '') === String(orderId ?? ''));
112
+ .filter(({ status }) => status);
113
+ const sameOrder = runsForOrder(runRecords, orderId);
114
+
115
+ const owner = conflictingOrderOwner(runRecords, orderId, taskHash);
116
+ if (owner) {
117
+ const ownerDir = path.join(runsRoot, owner.run);
118
+ console.log(
119
+ `Order id collision: ${JSON.stringify(String(orderId ?? ''))} already belongs to run folder ${ownerDir} ` +
120
+ `(slug ${owner.status.slug}, started_at ${owner.status.started_at}) with a different task. ` +
121
+ 'Pass a new --order-id, or pass --continue if this really is another pass of the same order.',
122
+ );
123
+ return 2;
124
+ }
86
125
 
87
126
  // The run an order is currently about is its newest one, and the chain arrives oldest first.
88
127
  // Reading it from the end is the whole point: `--continue` adds a second run under the same
@@ -93,7 +132,7 @@ export async function attach({ runsRoot, repo, slug, taskHash, orderId, chain, i
93
132
  const entry = sameOrder[i];
94
133
  const dir = path.join(runsRoot, entry.run);
95
134
  if (fs.existsSync(path.join(dir, 'reply.txt'))) {
96
- announceAttach(dir, entry.status.started_at);
135
+ announceSavedReply(dir, orderId, entry.status.started_at);
97
136
  console.log(fs.readFileSync(path.join(dir, 'reply.txt'), 'utf8').replace(/\s+$/, ''));
98
137
  return exitCodeFor(readJsonFile(path.join(dir, 'meta.json'))?.status);
99
138
  }
@@ -102,10 +141,20 @@ export async function attach({ runsRoot, repo, slug, taskHash, orderId, chain, i
102
141
  break;
103
142
  }
104
143
  }
105
- if (!candidate) return null;
144
+ if (!candidate) {
145
+ if (!noWait) return null;
146
+ console.log(`No run exists for order id ${JSON.stringify(String(orderId ?? ''))}; --no-wait never starts a new run.`);
147
+ return 4;
148
+ }
106
149
 
107
150
  const runDir = path.join(runsRoot, candidate.run);
108
- announceAttach(runDir, candidate.status.started_at);
151
+ // On 2026-08-13 a killed waiting call became an invented FAIL over a run already finished OK.
152
+ // A distinct call outcome lets the dispatcher inspect disk without mistaking pending for failure.
153
+ if (noWait) {
154
+ announcePending(runDir, orderId, candidate.status.started_at);
155
+ return 4;
156
+ }
157
+ announceLiveAttach(runDir, orderId, candidate.status.started_at);
109
158
  const replyText = await waitForReply(runDir, candidate.status.pid, candidate.status);
110
159
  if (replyText !== null) {
111
160
  console.log(replyText);
@@ -31,6 +31,7 @@ import { attach } from './attach.mjs';
31
31
  import { setRun } from './run-context.mjs';
32
32
  import { loadRunEnv, RUN_ENV } from './run-env.mjs';
33
33
  import { parseArgs, die } from './args.mjs';
34
+ import { settleTaskInput } from './task-input.mjs';
34
35
  import { parseContinuationGrant } from '../required-inputs.mjs';
35
36
  import { continuationRefusal } from './continuation.mjs';
36
37
  import { SCHEMAS } from './schemas.mjs';
@@ -48,7 +49,6 @@ import { validateScope } from './scope-check.mjs';
48
49
  * below is the CLI entry one level up — not this module, which has no command line of its own.
49
50
  */
50
51
  const RUNNER_ENTRY = fileURLToPath(new URL('../run-codex.mjs', import.meta.url));
51
-
52
52
  export const questionsFromFlags = (questionTexts = []) =>
53
53
  questionTexts.map((text, i) => ({ id: `Q${i + 1}`, text }));
54
54
 
@@ -63,7 +63,7 @@ const stamp = () => {
63
63
  * otherwise overwrite each other's task.md, log and result — and a status could then be
64
64
  * computed from another run's artifacts.
65
65
  */
66
- function makeRunDir(base) {
66
+ export function makeRunDir(base) {
67
67
  fs.mkdirSync(path.dirname(base), { recursive: true });
68
68
  for (let n = 2, dir = base; ; n += 1) {
69
69
  try {
@@ -76,19 +76,26 @@ function makeRunDir(base) {
76
76
  }
77
77
  }
78
78
 
79
+ export const runDirPath = (root, slug, runStamp = stamp()) => {
80
+ // `2026-08-13_235525_2026-08-13_plan4-6g-ceiling-scope` duplicated the order date. Only the
81
+ // directory copy is stripped: the slug stored in status.json stays verbatim, because a run
82
+ // with neither order_id nor task_hash is found by its stored slug alone (meta/chain.mjs).
83
+ const stripped = String(slug).replace(/^\d{4}-\d{2}-\d{2}[_-]/, '');
84
+ // An order id that is nothing but a date would otherwise name a folder ending in `_`.
85
+ const directorySlug = stripped || String(slug);
86
+ return path.join(root, `${runStamp}_${directorySlug}`);
87
+ };
88
+
79
89
  /**
80
90
  * Everything a run needs before a single token of someone else's quota is spent, and every
81
91
  * refusal that costs nothing: bad arguments, an open chain without --continue, a busy
82
92
  * worktree, a missing Codex CLI. All of it happens here, in the process the caller is free
83
93
  * to kill — so a killed caller can only ever interrupt a run that was already paid for.
84
94
  */
85
- export async function launcher() {
95
+ export async function launcher(argv = process.argv.slice(2)) {
86
96
  loadRunEnv();
87
-
88
- const opts = parseArgs(process.argv.slice(2));
89
- const taskText = fs.readFileSync(0, 'utf8').trim();
90
- if (!taskText) die('task text on stdin is empty');
91
-
97
+ const opts = parseArgs(argv);
98
+ const taskText = settleTaskInput(opts);
92
99
  const topLevel = git(opts.repo, ['rev-parse', '--show-toplevel']);
93
100
  const isGitRepo = topLevel.status === 0;
94
101
  const repoRoot = isGitRepo ? topLevel.stdout.trim() : opts.repo;
@@ -162,8 +169,9 @@ export async function launcher() {
162
169
  orderId: opts.orderId,
163
170
  chain,
164
171
  isContinue: opts.continue,
172
+ noWait: opts.noWait,
165
173
  });
166
- if (attachedExitCode !== null) process.exit(attachedExitCode);
174
+ if (attachedExitCode !== null) return attachedExitCode;
167
175
 
168
176
  if (startedChain.length && !opts.continue) {
169
177
  const last = startedChain[startedChain.length - 1];
@@ -196,7 +204,7 @@ export async function launcher() {
196
204
  retention = null;
197
205
  }
198
206
 
199
- const runDir = makeRunDir(path.join(projectRunsRoot, `${stamp()}_${opts.slug}`));
207
+ const runDir = makeRunDir(runDirPath(projectRunsRoot, opts.slug));
200
208
  setRun(runDir, opts.agent);
201
209
 
202
210
  // Written before Codex is even probed. From here on a killed runner leaves a folder that
@@ -230,7 +238,7 @@ export async function launcher() {
230
238
 
231
239
  // Printed before anything can go wrong: even a dispatcher that dies mid-run leaves the
232
240
  // orchestrator with a folder to look into.
233
- console.log(`RUN=${runDir}`);
241
+ console.log(`RUN=${runDir} order-id=${opts.orderId}`);
234
242
 
235
243
  // Before requireCodex, so a blocked run costs nothing at all.
236
244
  if (busy) {
@@ -245,7 +253,7 @@ export async function launcher() {
245
253
  true,
246
254
  );
247
255
  console.log(reply);
248
- process.exit(1);
256
+ return 1;
249
257
  }
250
258
 
251
259
  requireCodex(runDir, opts.agent);
@@ -334,7 +342,7 @@ export async function launcher() {
334
342
  true,
335
343
  );
336
344
  console.log(reply);
337
- process.exit(1);
345
+ return 1;
338
346
  }
339
347
  fs.writeFileSync(
340
348
  path.join(runDir, 'worker.json'),
@@ -347,7 +355,7 @@ export async function launcher() {
347
355
  is_git_repo: isGitRepo,
348
356
  launcher_pid: process.pid,
349
357
  // The mode's wall-clock budget, resolved here and never re-read by the worker: a run
350
- // that consulted run-config.json twice could end up honouring two different limits.
358
+ // that consulted config.json twice could end up honouring two different limits.
351
359
  budget_minutes: RUN_ENV?.budgets?.[runMode(opts.agent)],
352
360
  scope_new: opts.scopeNewPatterns,
353
361
  args: codexArgv,
@@ -366,13 +374,17 @@ export async function launcher() {
366
374
  windowsHide: true,
367
375
  cwd: repoRoot,
368
376
  });
369
- worker.on('error', (err) => {
370
- const { reply } = writeFailure(runDir, opts.agent, `run worker process failed to start: ${err.message}`, [
371
- 'Codex was not started; quota was not spent',
372
- ], true);
373
- console.log(reply);
374
- process.exit(1);
377
+ const started = await new Promise((resolve) => {
378
+ worker.once('spawn', () => resolve(true));
379
+ worker.once('error', (err) => {
380
+ const { reply } = writeFailure(runDir, opts.agent, `run worker process failed to start: ${err.message}`, [
381
+ 'Codex was not started; quota was not spent',
382
+ ], true);
383
+ console.log(reply);
384
+ resolve(false);
385
+ });
375
386
  });
387
+ if (!started) return 1;
376
388
  worker.unref();
377
389
  writeStatus(runDir, { pid: worker.pid, runner_pid: worker.pid, process_started_at: null });
378
390
 
@@ -382,5 +394,5 @@ export async function launcher() {
382
394
  console.log(
383
395
  'To get the verdict, repeat the identical command with the same --order-id; it will attach to this run and will not start a second run.',
384
396
  );
385
- process.exit(0);
397
+ return 0;
386
398
  }
@@ -0,0 +1,28 @@
1
+ /** Keeps the runner and producer hook on one definition of which task owns an order id. */
2
+
3
+ const byStartThenName = (left, right) => {
4
+ const leftAt = String(left.status.started_at || '');
5
+ const rightAt = String(right.status.started_at || '');
6
+ if (leftAt !== rightAt) return leftAt < rightAt ? -1 : 1;
7
+ if (String(left.run) === String(right.run)) return 0;
8
+ return String(left.run) < String(right.run) ? -1 : 1;
9
+ };
10
+
11
+ export function runsForOrder(runs, orderId) {
12
+ const wantedOrderId = String(orderId ?? '');
13
+ if (!wantedOrderId) return [];
14
+ return runs
15
+ .filter(({ status }) => String(status?.order_id ?? '') === wantedOrderId)
16
+ .sort(byStartThenName);
17
+ }
18
+
19
+ /**
20
+ * On 2026-08-15 plan42-run3 reused plan42-run2's order id and received run2's verdict.
21
+ * Compare only known fingerprints so runs predating task_hash remain fail-open.
22
+ */
23
+ export function conflictingOrderOwner(runs, orderId, taskHash) {
24
+ const owner = runsForOrder(runs, orderId).at(-1);
25
+ const ownerHash = String(owner?.status.task_hash ?? '').trim().toLowerCase();
26
+ const incomingHash = String(taskHash ?? '').trim().toLowerCase();
27
+ return ownerHash && incomingHash && ownerHash !== incomingHash ? owner : null;
28
+ }
@@ -9,7 +9,7 @@
9
9
  * fixture ran the task and cost 23k.
10
10
  *
11
11
  * Not hardcoded, because "never" is the wrong contract: `node run-config.mjs hooks on`
12
- * turns either of them back on. A broken run-config.json stops the run instead of quietly
12
+ * turns either of them back on. A broken config.json stops the run instead of quietly
13
13
  * falling back — a typo must not decide what environment a run gets.
14
14
  */
15
15
  import { readRunConfig, disableFlags } from '../run-config.mjs';
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Parses the task document without knowing how it reached the runner.
3
+ *
4
+ * The 2026-08-15 permission-rule incident showed that model-written questions and verification
5
+ * commands cannot safely travel through a shell command line. Recognised sections keep that free
6
+ * text in the file while the headingless form preserves every existing task file.
7
+ */
8
+
9
+ const SECTION_HEADING = /^ {0,3}(#{1,6})[ \t]+(.+?)[ \t]*$/;
10
+ const LIST_ITEM = /^\s*(?:[-*]|\d+\.)[ \t]+(.+?)\s*$/;
11
+ const SECTION_NAMES = new Map([
12
+ ['task', 'task'],
13
+ ['questions', 'questions'],
14
+ ['verify', 'verify'],
15
+ ]);
16
+
17
+ /**
18
+ * A heading, its level, and the section it opens when the runner recognises its text.
19
+ *
20
+ * The level is what closes a section. Treating every unrecognised heading as ordinary content
21
+ * made the first task file written against this format unreadable: a `## Constraints` heading
22
+ * after `## Verify` was swallowed into the verification command, and the document was refused
23
+ * for holding more than one command. A sibling or shallower heading ends the section it follows;
24
+ * only a deeper one belongs to it.
25
+ */
26
+ function headingFrom(line) {
27
+ const match = SECTION_HEADING.exec(line);
28
+ if (!match) return undefined;
29
+ const text = match[2].replace(/[ \t]+#+[ \t]*$/, '').trim().toLowerCase();
30
+ return { level: match[1].length, section: SECTION_NAMES.get(text) };
31
+ }
32
+
33
+ function sectionFrom(line) {
34
+ return headingFrom(line)?.section;
35
+ }
36
+
37
+ export function parseTaskDocument(text) {
38
+ const lines = String(text).split(/\r?\n/);
39
+ const hasSections = lines.some((line) => sectionFrom(line) !== undefined);
40
+ if (!hasSections) return { task: String(text).trim(), questions: [], verify: undefined };
41
+
42
+ const taskLines = [];
43
+ const questionLines = [];
44
+ const verifyLines = [];
45
+ let section = 'task';
46
+ let sectionLevel = 0;
47
+ let sawQuestions = false;
48
+ let sawVerify = false;
49
+
50
+ for (const line of lines) {
51
+ const heading = headingFrom(line);
52
+ if (heading?.section !== undefined) {
53
+ section = heading.section;
54
+ sectionLevel = heading.level;
55
+ if (section === 'questions') sawQuestions = true;
56
+ if (section === 'verify') sawVerify = true;
57
+ continue;
58
+ }
59
+ // An unrecognised heading at the section's own level or above ends it; the heading itself is
60
+ // part of the task statement from there on. A deeper heading stays inside the section.
61
+ if (heading && section !== 'task' && heading.level <= sectionLevel) {
62
+ section = 'task';
63
+ sectionLevel = 0;
64
+ }
65
+ if (section === 'questions') questionLines.push(line);
66
+ else if (section === 'verify') verifyLines.push(line);
67
+ else taskLines.push(line);
68
+ }
69
+
70
+ const questions = [];
71
+ for (const line of questionLines) {
72
+ if (!line.trim()) continue;
73
+ const item = LIST_ITEM.exec(line);
74
+ if (!item) {
75
+ throw new Error(`Questions section contains a non-list line: ${JSON.stringify(line.trim())}`);
76
+ }
77
+ questions.push(item[1].trim());
78
+ }
79
+ if (sawQuestions && !questions.length) throw new Error('Questions section is empty');
80
+
81
+ const verifyContent = verifyLines.filter((line) => line.trim());
82
+ if (sawVerify && !verifyContent.length) throw new Error('Verify section is empty');
83
+ if (verifyContent.length > 1) {
84
+ throw new Error('Verify section must contain exactly one non-empty line');
85
+ }
86
+
87
+ return {
88
+ task: taskLines.join('\n').trim(),
89
+ questions,
90
+ verify: sawVerify ? verifyContent[0].trim() : undefined,
91
+ };
92
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Settles the order's task, sub-questions and verification command from the two channels that
3
+ * can carry them, and refuses before a run folder or a paid process can exist.
4
+ *
5
+ * Kept apart from the launcher because it is the one place that has seen both channels: argument
6
+ * parsing runs before the task file has been read, and the file is read without knowing which
7
+ * flags were passed. Deciding it in either of them alone is how a scout order carrying its
8
+ * questions in the file got refused for not passing --question.
9
+ */
10
+ import { die, readTaskDocument } from './args.mjs';
11
+
12
+ export function settleTaskInput(opts) {
13
+ const { task, questions: fileQuestions, verify: fileVerify } = readTaskDocument(opts);
14
+ opts.questions ??= fileQuestions.length ? fileQuestions : undefined;
15
+ opts.verify ??= fileVerify;
16
+ // Still before the run folder exists and before a token of someone else's quota is touched,
17
+ // which is where every refusal of this kind belongs.
18
+ if (opts.agent === 'codex-scout' && !opts.questions?.length) {
19
+ die(
20
+ 'a sub-question is required for codex-scout: put one Markdown list item per sub-question ' +
21
+ 'under a `Questions` heading in the task file, or repeat --question for manual calls. ' +
22
+ 'The runner will not infer questions from the task text; no quota was spent.',
23
+ );
24
+ }
25
+ return task;
26
+ }
@@ -6,7 +6,7 @@
6
6
  * `repo`, `agent`, `args`, `is_git_repo`, `budget_minutes`. Written there: those five plus
7
7
  * `slug`, `launcher_pid` and `scope_new`, which nothing here needs but a run folder read back
8
8
  * months later does. The launcher must write the selected mode's budget into worker.json before
9
- * spawning this half, so the worker never re-reads run-config.json and one run cannot acquire two
9
+ * spawning this half, so the worker never re-reads config.json and one run cannot acquire two
10
10
  * configurations.
11
11
  * Neither side may change the shape alone.
12
12
  */
@@ -0,0 +1,26 @@
1
+ /** Plan_19 had to repair drift between duplicated CLI spellings, so shell-unsafe sequences must never be listed anywhere else. */
2
+
3
+ export const SHELL_UNSAFE_SEQUENCES = Object.freeze([
4
+ '`',
5
+ '$(',
6
+ '${',
7
+ '&&',
8
+ '||',
9
+ '|',
10
+ ';',
11
+ ]);
12
+
13
+ /** Returns the first forbidden sequence in reading order, preferring a compound operator at a tie. */
14
+ export function firstShellUnsafeSequence(value) {
15
+ const text = String(value);
16
+ let first = null;
17
+ let firstIndex = -1;
18
+ for (const sequence of SHELL_UNSAFE_SEQUENCES) {
19
+ const index = text.indexOf(sequence);
20
+ if (index !== -1 && (firstIndex === -1 || index < firstIndex)) {
21
+ first = sequence;
22
+ firstIndex = index;
23
+ }
24
+ }
25
+ return first;
26
+ }
@@ -30,7 +30,18 @@ import { writeStatus } from './meta/run-state.mjs';
30
30
  import { resolveStatus } from './meta/verdict.mjs';
31
31
  import { AGENTS, failReply, limitReply } from './meta/reply.mjs';
32
32
 
33
- const RUNNER_VERSION = readJsonFileSync(new URL('../package.json', import.meta.url)).version;
33
+ function readRunnerVersion() {
34
+ try {
35
+ return readJsonFileSync(new URL('../package.json', import.meta.url)).version;
36
+ } catch (error) {
37
+ // package.json is the one file npm requires outside the literal home image. The installed
38
+ // image has it beside lib/, while the repository has src/home between the module and manifest.
39
+ if (error.code !== 'ENOENT') throw error;
40
+ return readJsonFileSync(new URL('../../../package.json', import.meta.url)).version;
41
+ }
42
+ }
43
+
44
+ const RUNNER_VERSION = readRunnerVersion();
34
45
 
35
46
  export { expandDeclared, globToRegExp, readJson } from './meta/paths.mjs';
36
47
  export { chainBaseline, chainRuns, taskFingerprint } from './meta/chain.mjs';
@@ -1,73 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * PreToolUse gate for the Codex dispatchers (codex-scout / codex-build / codex-review).
4
- *
5
- * The runner rejects a dispatcher immediately when order id (and scope for codex-build) is
6
- * absent, but the old requirement lived only in the prompt read by the dispatcher. The caller
7
- * therefore had no enforced place to provide it and codex-build died before doing work. This
8
- * gate checks the producer's task text while it can still be corrected.
9
- *
10
- * Input is Claude Code's hook JSON on stdin. The last payload is retained for diagnostics so a
11
- * future host schema change can be inspected instead of guessed at. Any uncertain shape passes
12
- * silently because a diagnostic guard must never break an unrelated tool call.
13
- */
14
- import fs from 'node:fs';
15
- import os from 'node:os';
16
- import path from 'node:path';
17
- import { diagnoseInput, missingInputs } from '../required-inputs.mjs';
18
- import { SUBAGENT_TOOLS } from '../hook-definitions.mjs';
19
-
20
- const HOME = os.homedir();
21
- const LOG_DIR = path.join(HOME, '.claude', 'logs');
22
- const GUARDED = new Set(['codex-scout', 'codex-build', 'codex-review']);
23
- /**
24
- * Both spellings of the subagent-launching tool, from the same list the installer builds its
25
- * matcher from. Recognising only the name this host happens to use would make the gate silent
26
- * on every other host, and a gate that is silent for an unknown reason is worse than none.
27
- */
28
- const SUBAGENT_TOOL_NAMES = new Set(SUBAGENT_TOOLS);
29
-
30
- const pass = () => process.exit(0);
31
-
32
- let input;
33
- try {
34
- input = JSON.parse(fs.readFileSync(0, 'utf8'));
35
- } catch {
36
- pass();
37
- }
38
-
39
- try {
40
- fs.mkdirSync(LOG_DIR, { recursive: true });
41
- fs.writeFileSync(path.join(LOG_DIR, 'codex-order-gate.last.json'), `${JSON.stringify(input, null, 2)}\n`);
42
- } catch {
43
- // Diagnostics are a convenience, never a reason to fail the turn.
44
- }
45
-
46
- if (!input || typeof input !== 'object' || Array.isArray(input)) pass();
47
- if (!SUBAGENT_TOOL_NAMES.has(input.tool_name)) pass();
48
-
49
- const toolInput = input.tool_input;
50
- if (!toolInput || typeof toolInput !== 'object' || Array.isArray(toolInput)) pass();
51
- if (!GUARDED.has(toolInput.subagent_type) || typeof toolInput.prompt !== 'string') pass();
52
-
53
- const missing = missingInputs(toolInput.subagent_type, toolInput.prompt);
54
- if (!missing.length) pass();
55
-
56
- const reason = [
57
- 'Order gate denied the Agent call because required dispatcher input(s) are missing or still placeholders.',
58
- 'Write each value in tool_input.prompt using `label: value` before launching the dispatcher:',
59
- ...missing.flatMap((entry) => {
60
- const lines = [`- ${entry.label}: ${entry.explanation} Example: \`${entry.example}\`.`];
61
- const diagnosis = diagnoseInput(toolInput.prompt, entry.label);
62
- if (diagnosis) lines.push(` found \`${diagnosis.line}\`, ${diagnosis.reason}`);
63
- return lines;
64
- }),
65
- ].join('\n');
66
-
67
- process.stdout.write(JSON.stringify({
68
- hookSpecificOutput: {
69
- hookEventName: 'PreToolUse',
70
- permissionDecision: 'deny',
71
- permissionDecisionReason: reason,
72
- },
73
- }));
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes