@codedology/code-forge 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 (184) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/LICENSE +21 -0
  3. package/README.md +127 -0
  4. package/bin/code-forge.mjs +145 -0
  5. package/docs/README.md +37 -0
  6. package/docs/reference/blocks.json +288 -0
  7. package/docs/reference/config.md +162 -0
  8. package/examples/first-use/README.md +11 -0
  9. package/examples/first-use/clamp.acceptance.yml +8 -0
  10. package/examples/first-use/clamp.brief.md +16 -0
  11. package/examples/first-use/coder/src/math.mjs +31 -0
  12. package/examples/first-use/coder/test/clamp.test.mjs +26 -0
  13. package/examples/node-lib/.env.example +3 -0
  14. package/examples/node-lib/.gitattributes +3 -0
  15. package/examples/node-lib/.github/workflows/test.yml +11 -0
  16. package/examples/node-lib/CHANGELOG.md +13 -0
  17. package/examples/node-lib/README.md +17 -0
  18. package/examples/node-lib/gitignore +3 -0
  19. package/examples/node-lib/package.json +19 -0
  20. package/examples/node-lib/src/list.mjs +23 -0
  21. package/examples/node-lib/src/math.mjs +21 -0
  22. package/examples/node-lib/src/strings.mjs +34 -0
  23. package/examples/node-lib/test/list.test.mjs +12 -0
  24. package/examples/node-lib/test/math.test.mjs +13 -0
  25. package/examples/node-lib/test/package.test.mjs +18 -0
  26. package/examples/node-lib/test/strings.test.mjs +28 -0
  27. package/package.json +52 -0
  28. package/schema/code-forge.schema.json +331 -0
  29. package/schema/facts.schema.json +28 -0
  30. package/scripts/first-use.mjs +517 -0
  31. package/scripts/gen-config-doc.mjs +241 -0
  32. package/skill/SKILL.md +79 -0
  33. package/skill/references/adapters/claude-code.md +19 -0
  34. package/skill/references/adapters/solo.md +34 -0
  35. package/skill/references/adapters/subprocess.md +15 -0
  36. package/skill/references/code.md +64 -0
  37. package/skill/references/continuity.md +31 -0
  38. package/skill/references/decisions.md +50 -0
  39. package/skill/references/degraded.md +40 -0
  40. package/skill/references/harden.md +26 -0
  41. package/skill/references/ledger.md +23 -0
  42. package/skill/references/plan.md +43 -0
  43. package/skill/references/proof.md +34 -0
  44. package/skill/references/review.md +42 -0
  45. package/skill/references/rules-core.md +72 -0
  46. package/skill/references/security.md +29 -0
  47. package/skill/scripts/forge +40 -0
  48. package/skill/templates/block-record.md +19 -0
  49. package/skill/templates/facts-sheet.md +22 -0
  50. package/skill/templates/plan.md +53 -0
  51. package/src/cli/author.mjs +87 -0
  52. package/src/cli/block.mjs +262 -0
  53. package/src/cli/doctor.mjs +74 -0
  54. package/src/cli/facts.mjs +124 -0
  55. package/src/cli/gates.mjs +280 -0
  56. package/src/cli/help.mjs +48 -0
  57. package/src/cli/init.mjs +14 -0
  58. package/src/cli/jev.mjs +175 -0
  59. package/src/cli/keys.mjs +170 -0
  60. package/src/cli/ledger.mjs +160 -0
  61. package/src/cli/list.mjs +68 -0
  62. package/src/cli/models.mjs +131 -0
  63. package/src/cli/plan.mjs +70 -0
  64. package/src/cli/proof.mjs +316 -0
  65. package/src/cli/remove.mjs +76 -0
  66. package/src/cli/report.mjs +73 -0
  67. package/src/cli/resolve.mjs +79 -0
  68. package/src/cli/review-file.mjs +152 -0
  69. package/src/cli/run.mjs +159 -0
  70. package/src/cli/s2.mjs +93 -0
  71. package/src/cli/spawn.mjs +104 -0
  72. package/src/cli/upgrade.mjs +103 -0
  73. package/src/cli/validate.mjs +108 -0
  74. package/src/cli/version.mjs +18 -0
  75. package/src/cli/worker.mjs +76 -0
  76. package/src/config/defaults/anthropic.mjs +18 -0
  77. package/src/config/defaults/index.mjs +24 -0
  78. package/src/config/defaults/openai.mjs +16 -0
  79. package/src/config/defaults/xai.mjs +18 -0
  80. package/src/config/known-ids.mjs +171 -0
  81. package/src/config/load.mjs +135 -0
  82. package/src/config/migrate.mjs +46 -0
  83. package/src/config/refresh.mjs +276 -0
  84. package/src/config/schema-compile.mjs +211 -0
  85. package/src/config/schema-paths.mjs +84 -0
  86. package/src/config/secret-patterns.mjs +85 -0
  87. package/src/config/validate.mjs +694 -0
  88. package/src/decide/author-loop.mjs +90 -0
  89. package/src/decide/escalation.mjs +262 -0
  90. package/src/decide/fallback-rules.mjs +172 -0
  91. package/src/decide/jev-client.mjs +216 -0
  92. package/src/decide/questions.mjs +161 -0
  93. package/src/decide/thresholds.mjs +199 -0
  94. package/src/doctor/index.mjs +105 -0
  95. package/src/doctor/local.mjs +162 -0
  96. package/src/doctor/probes.mjs +358 -0
  97. package/src/doctor/rows.mjs +51 -0
  98. package/src/doctor/worker-probe.mjs +125 -0
  99. package/src/engines/alias-map.mjs +78 -0
  100. package/src/engines/builders/claude.mjs +224 -0
  101. package/src/engines/builders/codex.mjs +242 -0
  102. package/src/engines/builders/grok.mjs +132 -0
  103. package/src/engines/builders/index.mjs +35 -0
  104. package/src/engines/builders/validate-params.mjs +34 -0
  105. package/src/engines/codex-home.mjs +230 -0
  106. package/src/engines/isolation.mjs +84 -0
  107. package/src/engines/probe.mjs +116 -0
  108. package/src/engines/provider-cli.mjs +24 -0
  109. package/src/engines/render-rules.mjs +41 -0
  110. package/src/engines/sentinel.mjs +133 -0
  111. package/src/engines/usage-parse.mjs +149 -0
  112. package/src/gates/acceptance.mjs +95 -0
  113. package/src/gates/detect.mjs +187 -0
  114. package/src/gates/invariants.mjs +43 -0
  115. package/src/gates/run.mjs +72 -0
  116. package/src/gates/safe-edit.mjs +266 -0
  117. package/src/gates/scope.mjs +154 -0
  118. package/src/gates/secret-scan.mjs +79 -0
  119. package/src/gates/transcript-grep.mjs +27 -0
  120. package/src/install/agent-env.mjs +76 -0
  121. package/src/install/detect.mjs +121 -0
  122. package/src/install/harnesses.mjs +131 -0
  123. package/src/install/link.mjs +463 -0
  124. package/src/install/wizard/answers.mjs +389 -0
  125. package/src/install/wizard/flags.mjs +248 -0
  126. package/src/install/wizard/project-file.mjs +128 -0
  127. package/src/install/wizard/run.mjs +366 -0
  128. package/src/install/wizard/steps.mjs +201 -0
  129. package/src/keys/backends/constants.mjs +7 -0
  130. package/src/keys/backends/file.mjs +99 -0
  131. package/src/keys/backends/keychain.mjs +81 -0
  132. package/src/keys/backends/security-cli.mjs +105 -0
  133. package/src/keys/onepassword.mjs +46 -0
  134. package/src/keys/store.mjs +347 -0
  135. package/src/ledger/calibration.mjs +109 -0
  136. package/src/ledger/export.mjs +52 -0
  137. package/src/ledger/outcome.mjs +397 -0
  138. package/src/ledger/paths.mjs +30 -0
  139. package/src/ledger/prices.mjs +49 -0
  140. package/src/ledger/report.mjs +250 -0
  141. package/src/ledger/write.mjs +279 -0
  142. package/src/proof/export.mjs +358 -0
  143. package/src/proof/lock.mjs +138 -0
  144. package/src/proof/red-green.mjs +519 -0
  145. package/src/proof/red-parse.mjs +103 -0
  146. package/src/proof/tiers.mjs +62 -0
  147. package/src/review/budget.mjs +241 -0
  148. package/src/review/context.mjs +156 -0
  149. package/src/review/engine.mjs +264 -0
  150. package/src/review/finding.schema.json +47 -0
  151. package/src/review/fixloop.mjs +467 -0
  152. package/src/review/gate-check.mjs +249 -0
  153. package/src/review/lenses/A.md +22 -0
  154. package/src/review/lenses/B.md +23 -0
  155. package/src/review/lenses/full.md +23 -0
  156. package/src/review/lenses/judge.md +21 -0
  157. package/src/review/lenses/quick.md +21 -0
  158. package/src/review/lenses/recheck.md +23 -0
  159. package/src/review/packet.mjs +355 -0
  160. package/src/review/triage.mjs +149 -0
  161. package/src/review/validate-review.mjs +70 -0
  162. package/src/session/author.mjs +228 -0
  163. package/src/session/facts.mjs +855 -0
  164. package/src/session/plan-check.mjs +294 -0
  165. package/src/session/s2.mjs +164 -0
  166. package/src/session/spawn.mjs +610 -0
  167. package/src/state/block.mjs +289 -0
  168. package/src/state/cli-args.mjs +67 -0
  169. package/src/state/paths.mjs +92 -0
  170. package/src/state/registry.mjs +189 -0
  171. package/src/state/run.mjs +266 -0
  172. package/src/state/signer.mjs +132 -0
  173. package/src/util/exec.mjs +315 -0
  174. package/src/util/forbidden.mjs +787 -0
  175. package/src/util/git.mjs +381 -0
  176. package/src/util/log.mjs +100 -0
  177. package/src/util/reaper.mjs +300 -0
  178. package/src/util/redact.mjs +212 -0
  179. package/src/util/tmp.mjs +409 -0
  180. package/src/worker/engine.mjs +338 -0
  181. package/src/worker/loop.mjs +392 -0
  182. package/src/worker/queue.mjs +186 -0
  183. package/src/worker/review-state.mjs +145 -0
  184. package/src/worker/ticket.mjs +195 -0
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Worker start/stop and signer rows (plan §2.3, §4.8, §8.6; block B13b).
3
+ *
4
+ * The probe makes a throwaway run in a throwaway git repo (the project's `.code-forge.yml` copied
5
+ * in): B8 `startRun` generates the run key, B8 `signer` signs a row and verifies it (and refuses
6
+ * a byte-flipped copy), B11 `launchWorker` starts `code-forge worker` detached and waits for its
7
+ * queue announcement, then the worker is stopped (SIGTERM, SIGKILL of its group after 5 s). The
8
+ * run's record, key, lock and temp root are removed afterwards: doctor leaves no run behind.
9
+ *
10
+ * The `signer` row is printed on every full run: `signer: same-user boundary only` (§8.6) — the
11
+ * MAC is tamper evidence within ordinary tool use, never a boundary against a same-user process.
12
+ */
13
+
14
+ import { copyFileSync, mkdirSync, readdirSync, rmSync } from 'node:fs';
15
+ import path from 'node:path';
16
+ import { runsDir } from '../state/paths.mjs';
17
+ import { startRun } from '../state/run.mjs';
18
+ import { loadKey, signRow, verifyRow } from '../state/signer.mjs';
19
+ import { exec } from '../util/exec.mjs';
20
+ import { isAlive, registerPid, unregisterPid } from '../util/reaper.mjs';
21
+ import { pidsDir, tmpBase } from '../util/tmp.mjs';
22
+ import { launchWorker } from '../worker/loop.mjs';
23
+ import { liveWorker } from '../worker/queue.mjs';
24
+ import { gitChildEnv } from '../worker/ticket.mjs';
25
+ import { row } from './rows.mjs';
26
+
27
+ /** @typedef {import('./rows.mjs').Row} Row */
28
+
29
+ export const SIGNER_ROW = Object.freeze(row('signer', 'INFO', 'signer', 'same-user boundary only'));
30
+
31
+ const STOP_WAIT_MS = 5000;
32
+
33
+ /** @param {() => boolean} cond @param {number} ms */
34
+ async function waitFor(cond, ms) {
35
+ const until = Date.now() + ms;
36
+ while (Date.now() < until) {
37
+ if (cond()) return true;
38
+ await new Promise((r) => setTimeout(r, 50));
39
+ }
40
+ return cond();
41
+ }
42
+
43
+ /**
44
+ * SIGTERM the worker, then SIGKILL its group when it is still alive after `STOP_WAIT_MS`.
45
+ * @param {number} pid @returns {Promise<boolean>} true when it stopped on SIGTERM.
46
+ */
47
+ async function stopWorker(pid) {
48
+ try {
49
+ process.kill(pid, 'SIGTERM');
50
+ } catch {
51
+ return true; // already gone
52
+ }
53
+ if (await waitFor(() => !isAlive(pid), STOP_WAIT_MS)) return true;
54
+ try {
55
+ process.kill(-pid, 'SIGKILL');
56
+ } catch {
57
+ // gone meanwhile
58
+ }
59
+ await waitFor(() => !isAlive(pid), 2000);
60
+ return false;
61
+ }
62
+
63
+ /** @param {string} runId remove the probe run's record, key, lock and anything else it left in the runs dir. */
64
+ function removeRun(runId) {
65
+ let names = [];
66
+ try {
67
+ names = readdirSync(runsDir());
68
+ } catch {
69
+ return;
70
+ }
71
+ for (const name of names) if (name === `${runId}.json` || name === `${runId}.key` || name === `${runId}.lock` || name.startsWith(`${runId}.json.`)) rmSync(path.join(runsDir(), name), { recursive: true, force: true });
72
+ }
73
+
74
+ /**
75
+ * @param {{cfg: Record<string, any>, configFile: string, workDir: string, runRootDir: string, env: NodeJS.ProcessEnv, runId: string}} ctx
76
+ * @returns {Promise<Row[]>} the `signer key` row, the `worker` row and the `signer` line.
77
+ */
78
+ export async function probeWorkerAndSigner(ctx) {
79
+ const { runId, env } = ctx;
80
+ const repo = path.join(ctx.workDir, 'worker-repo');
81
+ const rows = [];
82
+ /** @type {number | null} */
83
+ let pid = null;
84
+ try {
85
+ mkdirSync(repo, { recursive: true, mode: 0o700 });
86
+ const init = await exec(['git', 'init', '-q'], { cwd: repo, env: gitChildEnv(env), timeoutMs: 20000 });
87
+ if (init.result !== 'ok') throw Object.assign(new Error('git init failed'), { code: 'git-init' });
88
+ copyFileSync(ctx.configFile, path.join(repo, '.code-forge.yml'));
89
+ await startRun({ workspace: repo, project: 'doctor-probe', config: ctx.cfg, runId, writeRow: async () => {} });
90
+
91
+ const key = await loadKey(runId);
92
+ const signed = signRow({ event: 'doctor.probe', run: runId }, key);
93
+ const flipped = { ...signed, event: 'doctor.probf' };
94
+ const signerOk = verifyRow(signed, key).ok && verifyRow(flipped, key).reason === 'mismatch';
95
+ rows.push(row('signer-key', signerOk ? 'OK' : 'FAIL', 'signer key', signerOk ? 'generated; a signed row verifies, a changed one is refused' : 'sign/verify round trip failed'));
96
+
97
+ const started = await launchWorker({ runId, workspace: repo, env });
98
+ pid = started.pid;
99
+ registerPid(pidsDir(ctx.runRootDir), pid, 'code-forge worker'); // a killed doctor's worker is reaped by the next sweep
100
+ const announced = liveWorker(started.repoRoot)?.pid === pid;
101
+ const clean = await stopWorker(pid);
102
+ unregisterPid(pidsDir(ctx.runRootDir), pid);
103
+ const workerPid = pid;
104
+ pid = null;
105
+ const gone = liveWorker(started.repoRoot) === null;
106
+ rows.push(announced && clean && gone
107
+ ? row('worker', 'OK', 'worker', `pid ${workerPid} started, announced itself and stopped on SIGTERM`)
108
+ : row('worker', 'FAIL', 'worker', `pid ${workerPid} start/stop incomplete (announced=${announced}, stopped on SIGTERM=${clean}, retracted=${gone})`));
109
+ } catch (err) {
110
+ if (!rows.some((r) => r.id === 'signer-key')) rows.push(row('signer-key', 'FAIL', 'signer key', `run key could not be generated (${err?.code ?? err?.name ?? 'error'})`));
111
+ rows.push(row('worker', 'FAIL', 'worker', `could not start (${err?.code ?? err?.name ?? 'error'})`));
112
+ } finally {
113
+ if (pid !== null) await stopWorker(pid);
114
+ removeRun(runId);
115
+ const cfgRoot = typeof ctx.cfg?.tmp?.root === 'string' ? ctx.cfg.tmp.root : undefined;
116
+ try {
117
+ rmSync(path.join(tmpBase(cfgRoot), runId), { recursive: true, force: true }); // the worker's own run root
118
+ } catch {
119
+ // tmp.root invalid: the tmp row reports it
120
+ }
121
+ rmSync(repo, { recursive: true, force: true });
122
+ }
123
+ rows.push(SIGNER_ROW);
124
+ return rows;
125
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * The Claude Agent-tool alias map (plan §5.2): under `engine: harness` (no Solo, R2 default) the
3
+ * orchestrator calls `Agent(subagent_type: general-purpose, model: <alias>, prompt: <pointer>)` —
4
+ * the Agent tool takes a FAMILY ALIAS (`sonnet|opus|haiku|fable`, per the pinned Claude `--model`
5
+ * help text: "Provide an alias for the latest model (e.g. 'fable', 'opus', or 'sonnet')"), never a
6
+ * full model id, and it has no `effort` parameter at all. So this module does two things: (1) maps
7
+ * a configured model id to its family alias by prefix, and (2) always reports
8
+ * `effort_effective: null` for an Agent-tool dispatch, so the ledger and `doctor` can show that an
9
+ * effort setting silently had no effect there (plan §5.2: "the ledger records `effort_effective:
10
+ * null` and `doctor` prints it" — note the plan's own field name is snake_case; fix round 1
11
+ * corrected this module's key from `effortEffective` to match it exactly).
12
+ *
13
+ * **Fix round 1 (isolated per-file review):**
14
+ * - The prefix check now requires a WORD boundary: `model === prefix` or `model.startsWith(prefix
15
+ * + '-')`. A bare `model.startsWith(prefix)` accepted typos like `claude-opusx` or
16
+ * `claude-sonnetfoo`, which goes against this module's own stated rule ("refuse rather than
17
+ * guess when an id is mistyped").
18
+ * - `toAgentToolDispatch` destructures with a default so `undefined`/`null` params produce the
19
+ * same clear `TypeError` `resolveClaudeAlias` gives for a bad `model`, instead of a generic
20
+ * property-access crash before that check ever runs.
21
+ * - The dispatch object's key is now `effort_effective` (was `effortEffective`).
22
+ */
23
+
24
+ /**
25
+ * Model-id prefix -> Agent-tool alias, matched longest-prefix-first so a future 2-word family name
26
+ * cannot be shadowed by a shorter one that happens to be a prefix of it.
27
+ * @type {ReadonlyArray<[string, string]>}
28
+ */
29
+ const FAMILY_ALIASES = Object.freeze(
30
+ /** @type {[string, string][]} */ ([
31
+ ['claude-haiku', 'haiku'],
32
+ ['claude-sonnet', 'sonnet'],
33
+ ['claude-opus', 'opus'],
34
+ ['claude-fable', 'fable'],
35
+ ]).sort((a, b) => b[0].length - a[0].length),
36
+ );
37
+
38
+ /**
39
+ * @param {string} model
40
+ * @param {string} prefix
41
+ * @returns {boolean} true only for an EXACT family match or `prefix` followed by `-` — never a
42
+ * bare substring prefix (fix round 1, MINOR: refuses `claude-opusx`, `claude-sonnetfoo`, …).
43
+ */
44
+ function matchesFamily(model, prefix) {
45
+ return model === prefix || model.startsWith(`${prefix}-`);
46
+ }
47
+
48
+ /**
49
+ * @param {string} model - a resolved Claude model id, e.g. `"claude-opus-5-5"`.
50
+ * @returns {string} the Agent-tool alias for `model`'s family.
51
+ * @throws {Error} when `model` matches no known family prefix — refusing rather than guessing
52
+ * keeps a typo'd or brand-new id from silently dispatching to the wrong family.
53
+ */
54
+ export function resolveClaudeAlias(model) {
55
+ if (typeof model !== 'string' || model.length === 0) {
56
+ throw new TypeError('resolveClaudeAlias: model must be a non-empty string');
57
+ }
58
+ const match = FAMILY_ALIASES.find(([prefix]) => matchesFamily(model, prefix));
59
+ if (!match) {
60
+ throw new Error(`resolveClaudeAlias: no known family alias for model "${model}"`);
61
+ }
62
+ return match[1];
63
+ }
64
+
65
+ /** @typedef {{subagentType: "general-purpose", model: string, effort_effective: null}} AgentToolDispatch */
66
+
67
+ /**
68
+ * @param {{model?: string, effort?: string}} [params] - `effort` is accepted (a caller may pass the
69
+ * resolved level's effort through unconditionally) but is never reflected in the Agent-tool
70
+ * call — see the module doc. Defaults to `{}` so a missing/`undefined` `params` still reaches
71
+ * {@link resolveClaudeAlias}'s own clear `model` error, instead of crashing on `params.model`
72
+ * here first.
73
+ * @returns {AgentToolDispatch}
74
+ * @throws {Error} propagated from {@link resolveClaudeAlias} for an unknown/missing family.
75
+ */
76
+ export function toAgentToolDispatch({ model } = {}) {
77
+ return { subagentType: 'general-purpose', model: resolveClaudeAlias(/** @type {string} */ (model)), effort_effective: null };
78
+ }
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Claude Code 2.1.282 argv builder (plan §5.2, pinned against `test/fixtures/help/claude-2.1.282.txt`
3
+ * — B0's byte copy of `claude --help` on this Mac, 2026-09-24). Pure: no I/O, no CLI spawning
4
+ * (`src/util/exec.mjs` is the one place that spawns; B9's `spawn.mjs` calls it with this argv).
5
+ *
6
+ * Six roles collapse to two shapes: `coder` (writes files, cwd = the project tree) and everything
7
+ * else — `reviewer`/`judge`/`s2`/`author`/`facts` (closed-book, cwd = an empty temp dir with the
8
+ * packet). `facts` is the one role Claude treats specially even within the closed-book shape
9
+ * (§0.6.7, §8.4): `--tools ""` becomes `--tools "Bash"`, and `--disallowedTools` + `--restricted`
10
+ * are added so the facts delegate can run read-only shell commands but nothing that writes.
11
+ *
12
+ * **Return contract (fix round 3, root ruling — corrects plan §5.2's "<brief pointer>" for the
13
+ * closed-book shape):** `{cli, role, argv, cwd, stdinFile?, forbiddenRendered?}`.
14
+ * - `coder`: the brief is a POINTER — the coder has tools and opens it itself. argv ends with a
15
+ * literal `--` then `promptPath`, so no variadic flag (`--disallowedTools <tools...>`) can ever
16
+ * swallow it. No `stdinFile`.
17
+ * - closed-book (`reviewer`/`judge`/`s2`/`author`/`facts`): a role built with `--tools ""` cannot
18
+ * open a path, so a pointer would leave the model reviewing nothing. The argv carries NO prompt
19
+ * positional (and no `--`); `stdinFile === promptPath` names the packet file whose CONTENT the
20
+ * spawner (B9) must pipe to the child's stdin. `claude -p` with no prompt argument reads the
21
+ * prompt from stdin (`--input-format` default "text", help fixture line "--input-format <format>
22
+ * Input format (only works with --print)"); the root verified the stdin form against the real
23
+ * `claude -p --safe-mode --tools ""` on this Mac (2026-09-24). `facts` has `Bash` and could
24
+ * read the file, but it takes the same stdin path so every closed-book role has ONE delivery
25
+ * mechanism.
26
+ *
27
+ * **Doctor probe item for B13 (no network in B4's tests):** `claude: closed-book stdin delivery` —
28
+ * spawn this module's `reviewer` argv with `stdinFile` piped in, the packet containing a unique
29
+ * canary token and the instruction "reply with the canary"; PASS when the JSON `result` contains
30
+ * the canary, FAIL (`claude: closed-book packet not delivered`) otherwise.
31
+ *
32
+ * **Forbidden-list rendering is a PARAMETER** (`renderedForbidden`, default
33
+ * `renderForClaude(FORBIDDEN)` from B8's landed `src/util/forbidden.mjs`): this module only
34
+ * flattens whatever render it is given, so it never hard-codes a rule-string shape (seam C17).
35
+ * Both `coder` and `facts` refuse an empty render (0 usable rules) — a renderer regression must
36
+ * be a thrown error, never a silently weaker deny list.
37
+ *
38
+ * `--fallback-model <model>` takes exactly ONE model id in the pinned help. Only the first
39
+ * same-provider (`anthropic`) fallback whose model DIFFERS from the main `--model` is used —
40
+ * Claude Code refuses to start when the two are equal.
41
+ */
42
+
43
+ import { FORBIDDEN, mergeForbidden, renderForClaude } from '../../util/forbidden.mjs';
44
+ import { flattenRuleStrings, hasAnyRenderedRule } from '../render-rules.mjs';
45
+ import { assertBaseParams } from './validate-params.mjs';
46
+
47
+ export const CLI = 'claude';
48
+
49
+ /**
50
+ * @typedef {{id: string, rules: ReadonlyArray<string>, enforced: boolean}} RenderedForbiddenEntry
51
+ */
52
+
53
+ /**
54
+ * @typedef {{provider: string, model: string, effort?: string}} FallbackEntry
55
+ */
56
+
57
+ /**
58
+ * @typedef {object} ClaudeBuildParams
59
+ * @property {"coder"|"reviewer"|"judge"|"s2"|"author"|"facts"} role
60
+ * @property {string} model - the resolved model id (already picked by `resolveLevel`, B1).
61
+ * @property {string} [effort]
62
+ * @property {string} promptPath - the brief file (coder: passed as a pointer) or the packet file
63
+ * (closed-book: returned as `stdinFile`, its content is piped to stdin by the spawner).
64
+ * @property {string} cwd - the project tree (coder) or an empty isolated temp dir (closed-book).
65
+ * @property {ReadonlyArray<RenderedForbiddenEntry>} [renderedForbidden] - used by the coder role
66
+ * (default `renderForClaude(mergeForbidden())`, B4.2) and the `facts` role (default
67
+ * `renderForClaude(FORBIDDEN)`, unchanged).
68
+ * @property {ReadonlyArray<FallbackEntry>} [fallback] - coder role only; see the module doc.
69
+ * @property {number} [maxBudgetUsd] - must be a finite number > 0 when given.
70
+ * @property {Record<string, any>} [schema] - compiled JSON Schema object; closed-book roles only,
71
+ * sent inline (`--json-schema`, help text example is an inline JSON string, not a file path).
72
+ * @property {string} [systemPromptText] - the lens preamble; closed-book roles only.
73
+ */
74
+
75
+ /**
76
+ * @typedef {object} BuiltCoder
77
+ * @property {"claude"} cli
78
+ * @property {"coder"} role
79
+ * @property {string[]} argv - ends with `--`, promptPath.
80
+ * @property {string} cwd
81
+ * @property {ReadonlyArray<RenderedForbiddenEntry>} forbiddenRendered
82
+ * @property {undefined} [stdinFile] - never set: the coder opens its brief itself.
83
+ */
84
+
85
+ /**
86
+ * @typedef {object} BuiltClosedBook
87
+ * @property {"claude"} cli
88
+ * @property {"reviewer"|"judge"|"s2"|"author"|"facts"} role
89
+ * @property {string[]} argv - no prompt positional.
90
+ * @property {string} cwd
91
+ * @property {string} stdinFile - the packet file the spawner pipes to stdin.
92
+ * @property {ReadonlyArray<RenderedForbiddenEntry>} [forbiddenRendered] - present only for `facts`.
93
+ */
94
+
95
+ /**
96
+ * Extra `--disallowedTools` rules the `facts` role adds on top of the forbidden list (plan §5.2
97
+ * "Facts role only"): the two literal examples the plan names (`Bash(rm*)`, `Bash(git push*)`)
98
+ * plus the handful of other ordinary write verbs a read-only facts delegate never needs.
99
+ * @type {ReadonlyArray<string>}
100
+ */
101
+ export const FACTS_EXTRA_WRITE_VERB_RULES = Object.freeze([
102
+ 'Bash(rm*)',
103
+ 'Bash(git push*)',
104
+ 'Bash(git commit*)',
105
+ 'Bash(git merge*)',
106
+ 'Bash(mv*)',
107
+ 'Bash(cp*)',
108
+ ]);
109
+
110
+ /**
111
+ * @param {unknown} fallback
112
+ * @throws {TypeError} unless `fallback` is `undefined` or an array of objects whose `provider` and
113
+ * `model` are non-empty strings. The message names the index only, never the entry's values.
114
+ */
115
+ function assertValidFallback(fallback) {
116
+ if (fallback === undefined) return;
117
+ if (!Array.isArray(fallback)) {
118
+ throw new TypeError('buildClaudeArgv: fallback must be an array when given');
119
+ }
120
+ fallback.forEach((entry, index) => {
121
+ const valid =
122
+ entry !== null &&
123
+ typeof entry === 'object' &&
124
+ typeof entry.provider === 'string' &&
125
+ entry.provider.length > 0 &&
126
+ typeof entry.model === 'string' &&
127
+ entry.model.length > 0;
128
+ if (!valid) {
129
+ throw new TypeError(`buildClaudeArgv: fallback[${index}] must be an object with non-empty string provider and model`);
130
+ }
131
+ });
132
+ }
133
+
134
+ /**
135
+ * @param {ReadonlyArray<FallbackEntry> | undefined} fallback
136
+ * @param {string} provider
137
+ * @param {string} model - the main `--model`; a fallback equal to it is skipped.
138
+ * @returns {string | undefined} the FIRST same-provider fallback model id that differs from `model`.
139
+ */
140
+ function firstSameProviderFallbackId(fallback, provider, model) {
141
+ return (fallback ?? []).find((f) => f.provider === provider && f.model !== model)?.model;
142
+ }
143
+
144
+ /**
145
+ * @param {unknown} maxBudgetUsd
146
+ * @param {string} builderName
147
+ * @throws {TypeError} unless `maxBudgetUsd` is `undefined` or a finite number > 0.
148
+ */
149
+ function assertValidBudget(maxBudgetUsd, builderName) {
150
+ if (maxBudgetUsd !== undefined && !(typeof maxBudgetUsd === 'number' && Number.isFinite(maxBudgetUsd) && maxBudgetUsd > 0)) {
151
+ throw new TypeError(`${builderName}: maxBudgetUsd must be a finite number > 0 when given, got ${JSON.stringify(maxBudgetUsd)}`);
152
+ }
153
+ }
154
+
155
+ /**
156
+ * @param {ClaudeBuildParams} params
157
+ * @returns {BuiltCoder}
158
+ */
159
+ function buildCoderArgv(params) {
160
+ // B4.2: a coder's deny list is `mergeForbidden()` — FORBIDDEN plus the coder-only entries
161
+ // (`code-forge block waive`, `--no-require-reviews`). A `contains` entry (`--no-require-reviews`)
162
+ // renders 0 rules (no pinned CLI has a match-anywhere rule), so it stays with the pre-spawn
163
+ // `isForbidden` check and the transcript grep, as before.
164
+ const { model, effort, promptPath, cwd, fallback, maxBudgetUsd, renderedForbidden = renderForClaude(mergeForbidden()) } = params;
165
+ assertValidBudget(maxBudgetUsd, 'buildClaudeArgv');
166
+ assertValidFallback(fallback);
167
+ if (!hasAnyRenderedRule(renderedForbidden)) {
168
+ throw new Error('buildClaudeArgv: coder role requires a non-empty forbidden-list render (got 0 usable rules)');
169
+ }
170
+ const argv = ['claude', '-p', '--model', model];
171
+ if (effort) argv.push('--effort', effort);
172
+ argv.push('--permission-mode', 'bypassPermissions');
173
+ argv.push('--output-format', 'json');
174
+ argv.push('--no-session-persistence');
175
+ if (maxBudgetUsd !== undefined) argv.push('--max-budget-usd', String(maxBudgetUsd));
176
+ argv.push('--disallowedTools', ...flattenRuleStrings(renderedForbidden));
177
+ const sameProviderId = firstSameProviderFallbackId(fallback, 'anthropic', model);
178
+ if (sameProviderId !== undefined) argv.push('--fallback-model', sameProviderId);
179
+ argv.push('--', promptPath);
180
+ return { cli: CLI, role: 'coder', argv, cwd, forbiddenRendered: renderedForbidden };
181
+ }
182
+
183
+ /**
184
+ * @param {ClaudeBuildParams} params
185
+ * @returns {BuiltClosedBook}
186
+ */
187
+ function buildClosedBookArgv(params) {
188
+ const { model, effort, promptPath, cwd, schema, systemPromptText, maxBudgetUsd, renderedForbidden = renderForClaude(FORBIDDEN) } = params;
189
+ assertValidBudget(maxBudgetUsd, 'buildClaudeArgv');
190
+ // The dispatch in `buildClaudeArgv` never reaches this branch with role 'coder' — a runtime
191
+ // invariant the parameter type alone cannot prove, so the narrowing is explicit.
192
+ const role = /** @type {"reviewer"|"judge"|"s2"|"author"|"facts"} */ (params.role);
193
+ const isFacts = role === 'facts';
194
+ if (isFacts && !hasAnyRenderedRule(renderedForbidden)) {
195
+ throw new Error('buildClaudeArgv: facts role requires a non-empty forbidden-list render (got 0 usable rules)');
196
+ }
197
+ const argv = ['claude', '-p', '--model', model];
198
+ if (effort) argv.push('--effort', effort);
199
+ argv.push('--safe-mode');
200
+ argv.push('--tools', isFacts ? 'Bash' : '');
201
+ argv.push('--strict-mcp-config');
202
+ argv.push('--no-session-persistence');
203
+ if (systemPromptText !== undefined) argv.push('--system-prompt', systemPromptText);
204
+ argv.push('--output-format', 'json');
205
+ if (schema !== undefined) argv.push('--json-schema', JSON.stringify(schema));
206
+ if (maxBudgetUsd !== undefined) argv.push('--max-budget-usd', String(maxBudgetUsd));
207
+ argv.push('--permission-mode', 'dontAsk');
208
+ if (!isFacts) {
209
+ return { cli: CLI, role, argv, cwd, stdinFile: promptPath };
210
+ }
211
+ argv.push('--disallowedTools', ...flattenRuleStrings(renderedForbidden), ...FACTS_EXTRA_WRITE_VERB_RULES);
212
+ // `--restricted` is boolean and last: it also terminates the variadic list above.
213
+ argv.push('--restricted');
214
+ return { cli: CLI, role, argv, cwd, stdinFile: promptPath, forbiddenRendered: renderedForbidden };
215
+ }
216
+
217
+ /**
218
+ * @param {ClaudeBuildParams} params
219
+ * @returns {BuiltCoder | BuiltClosedBook}
220
+ */
221
+ export function buildClaudeArgv(params) {
222
+ assertBaseParams('buildClaudeArgv', params);
223
+ return params.role === 'coder' ? buildCoderArgv(params) : buildClosedBookArgv(params);
224
+ }
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Codex 0.155.1 argv builder (plan §5.2, pinned against `test/fixtures/help/codex-0.155.1.txt`).
3
+ * No spawning; the coder role writes its per-session rules file (see "Execpolicy rules" below). NOT fully deterministic: when `outPath` is omitted, a default is drawn from
4
+ * `os.tmpdir()` + `randomBytes` — pass `outPath` for a reproducible argv (every snapshot test does).
5
+ *
6
+ * **Return contract (fix round 3):** `{cli, role, argv, cwd, outPath, stdinFile?, ...}`.
7
+ * - `outPath` is ALWAYS returned — the effective `-o` target, worked out once per call, so the
8
+ * caller reads Codex's last message from it and deletes it afterwards (the builder never
9
+ * creates the file; Codex does).
10
+ * - `coder`: the brief is a POINTER (the last argv token is `promptPath`); the coder has tools.
11
+ * - closed-book (`reviewer`/`judge`/`s2`/`author`/`facts`): the last argv token is the literal
12
+ * `-`, which the pinned help (`codex exec [OPTIONS] [PROMPT]`: "If not provided as an argument
13
+ * (or if `-` is used), instructions are read from stdin") defines as "read the prompt from
14
+ * stdin". `stdinFile === promptPath` names the packet file whose CONTENT the spawner (B9)
15
+ * pipes to stdin — a closed-book reviewer must receive the packet itself, never a path it may
16
+ * not be able to open. The explicit `-` form was chosen over omitting the positional because
17
+ * it is documented and does not depend on stdin-is-piped detection.
18
+ *
19
+ * **Doctor probe item for B13 (no network in B4's tests):** `codex: closed-book stdin delivery` —
20
+ * run this module's `reviewer` argv with `stdinFile` piped in, the packet carrying a unique canary
21
+ * and "reply with the canary"; PASS when the file at `outPath` contains the canary, FAIL
22
+ * (`codex: closed-book packet not delivered`) otherwise.
23
+ *
24
+ * Unlike Claude, the flag table gives Codex only TWO command shapes: coder, and "reviewer / judge
25
+ * / S2 / author / facts" — there is no Codex-specific "facts role only" carve-out anywhere in the
26
+ * plan's flag table, so `facts` here is simply routed through the same closed-book branch as the
27
+ * other four roles.
28
+ *
29
+ * **Execpolicy rules (B4.1, §0.6.3, [A19]).** Codex 0.155.1 `exec` has no flag and no `-c` key
30
+ * naming a rules file; it loads the "user" rules from `$CODEX_HOME/rules/*.rules` (`--ignore-rules`
31
+ * help: "Do not load user or project execpolicy `.rules` files"). So the coder build creates a
32
+ * per-session Codex home under the run temp root (`../codex-home.mjs`, `codexHome` param or
33
+ * `defaultCodexHome()`), writes `rules/code-forge.rules` there (execpolicy Starlark, one
34
+ * `prefix_rule(..., decision="forbidden")` per `renderForCodex` pattern) and returns
35
+ * `env: {CODEX_HOME}` — the spawner MUST merge `env` into the child's environment. The home is
36
+ * never the user's real `~/.codex` (a `codexHome` there throws). This is the one builder that
37
+ * writes a file: the rules must exist before Codex starts, and the builder is the one place that
38
+ * holds the render.
39
+ *
40
+ * **Fix round 1 (isolated per-file review):**
41
+ * - Unknown `role` now throws (was silently routed to closed-book) — `assertBaseParams`.
42
+ * - `cwd` is now required and validated, same as `model`/`promptPath` — `assertBaseParams`.
43
+ * - A coder-role render with 0 usable patterns now throws (see `claude.mjs`'s identical guard).
44
+ * - The default `-o`/output-last-message path no longer lands INSIDE `cwd`: for the coder that
45
+ * put a stray file in the project tree (picked up by later diffs); for a closed-book role it
46
+ * silently broke the "isolation dir lists exactly 1 file" invariant. It now defaults to a path
47
+ * under `os.tmpdir()`, keyed by role and a random suffix, always outside `cwd`.
48
+ * - `effort` is now checked against the same four values the alias map / Claude `--effort` help
49
+ * text documents (`low|medium|high|xhigh|max` minus `xhigh`/`max`, which Codex's own
50
+ * `model_reasoning_effort` TOML values don't claim — kept to the portable subset
51
+ * `minimal|low|medium|high`) before being written into a `-c` config override; Codex parses
52
+ * that value as TOML, so an unchecked string could smuggle a quote or a nested key.
53
+ */
54
+
55
+ import os from 'node:os';
56
+ import path from 'node:path';
57
+ import { randomBytes } from 'node:crypto';
58
+ import { mergeForbidden, renderForCodex } from '../../util/forbidden.mjs';
59
+ import { CODEX_RULES_FILE_NAME, defaultCodexHome, isInside, prepareCodexHome } from '../codex-home.mjs';
60
+ import { hasAnyRenderedRule } from '../render-rules.mjs';
61
+ import { assertBaseParams } from './validate-params.mjs';
62
+
63
+ export const CLI = 'codex';
64
+
65
+ /** The rules file Codex loads from `<CODEX_HOME>/rules/` (B4.1). */
66
+ export const RULES_FILE_NAME = CODEX_RULES_FILE_NAME;
67
+
68
+ /** `-c` overrides that keep `$TMPDIR` and `/tmp` out of the coder's writable roots (B4.1). */
69
+ export const SANDBOX_TMP_EXCLUSIONS = Object.freeze([
70
+ '-c',
71
+ 'sandbox_workspace_write.exclude_tmpdir_env_var=true',
72
+ '-c',
73
+ 'sandbox_workspace_write.exclude_slash_tmp=true',
74
+ ]);
75
+
76
+ /**
77
+ * The coder's approval policy (B4.2). Codex 0.155.1 rejects `-s workspace-write` together with
78
+ * `--approve-for-me` (clap: "the argument '--sandbox <SANDBOX_MODE>' cannot be used with
79
+ * '--approve-for-me'"), so the coder drops `--approve-for-me` and keeps the sandbox. `codex exec`
80
+ * has no `-a/--ask-for-approval` flag (that one is on the interactive `codex` only), so the
81
+ * policy is set through the `approval_policy` config key, whose valid values the 0.155.1 config
82
+ * loader lists as `untrusted`, `on-failure`, `on-request`, `granular`, `never`. `never` = "Never
83
+ * ask for user approval. Execution failures are immediately returned to the model" (`codex
84
+ * --help`): a sandbox-denied command simply fails, nothing escalates out of the sandbox.
85
+ */
86
+ export const CODER_APPROVAL_POLICY = Object.freeze(['-c', 'approval_policy="never"']);
87
+
88
+ /** The `model_reasoning_effort` values Codex's TOML config is willing to receive from this builder. */
89
+ export const VALID_EFFORTS = Object.freeze(['minimal', 'low', 'medium', 'high']);
90
+
91
+ /**
92
+ * @typedef {{id: string, patterns: string[][], decision: "forbidden", enforced: boolean, description: string}} RenderedCodexEntry
93
+ */
94
+
95
+ /**
96
+ * @typedef {object} CodexBuildParams
97
+ * @property {"coder"|"reviewer"|"judge"|"s2"|"author"|"facts"} role
98
+ * @property {string} model
99
+ * @property {string} [effort] - one of {@link VALID_EFFORTS} when given.
100
+ * @property {string} promptPath - the brief file (coder: passed as a pointer) or the packet file
101
+ * (closed-book: returned as `stdinFile`, piped to stdin by the spawner).
102
+ * @property {string} cwd - the project tree (coder) or an empty isolated temp dir (closed-book).
103
+ * @property {string} [outPath] - `-o` target; defaults to a fresh path under `os.tmpdir()`, NEVER
104
+ * inside `cwd` (fix round 1).
105
+ * @property {string} [schemaPath] - closed-book only: a FILE PATH (`--output-schema <FILE>` takes
106
+ * a path, unlike Claude/Grok's inline `--json-schema` string — see `test/fixtures/help/
107
+ * codex-0.155.1.txt`).
108
+ * @property {ReadonlyArray<RenderedCodexEntry>} [renderedForbidden] - coder role only; defaults to
109
+ * `renderForCodex(mergeForbidden())` (B4.2: the coder-only entries too; a `contains` entry
110
+ * renders 0 patterns, so it is left to the pre-spawn check and the transcript grep).
111
+ * @property {string} [codexHome] - coder role only: the per-session Codex home to create (absolute,
112
+ * must not exist as the real `~/.codex` or inside it); defaults to `defaultCodexHome()` under the
113
+ * run temp root.
114
+ */
115
+
116
+ /**
117
+ * @param {string} role
118
+ * @returns {string} a fresh path under `os.tmpdir()`, never inside the caller's `cwd`.
119
+ */
120
+ function defaultOutPath(role) {
121
+ return path.join(os.tmpdir(), `code-forge-${role}-output-${randomBytes(6).toString('hex')}.json`);
122
+ }
123
+
124
+ /**
125
+ * @param {unknown} outPath
126
+ * @param {string} role
127
+ * @returns {string} the effective `-o` target: the caller's `outPath`, or one fresh default.
128
+ * @throws {TypeError} unless `outPath` is `undefined` or a non-empty string.
129
+ */
130
+ function effectiveOutPath(outPath, role) {
131
+ if (outPath === undefined) return defaultOutPath(role);
132
+ if (typeof outPath !== 'string' || outPath.length === 0) {
133
+ throw new TypeError('buildCodexArgv: outPath must be a non-empty string when given');
134
+ }
135
+ return outPath;
136
+ }
137
+
138
+ /**
139
+ * @param {string | undefined} effort
140
+ * @throws {TypeError} unless `effort` is `undefined` or one of {@link VALID_EFFORTS}.
141
+ */
142
+ function assertValidEffort(effort) {
143
+ if (effort !== undefined && !VALID_EFFORTS.includes(effort)) {
144
+ throw new TypeError(`buildCodexArgv: effort must be one of ${VALID_EFFORTS.join(', ')} when given, got ${JSON.stringify(effort)}`);
145
+ }
146
+ }
147
+
148
+ /**
149
+ * The Codex coder argv itself — pure, no I/O, no rules file (B4.2). `buildCoderArgv` uses it, and
150
+ * the doctor's flags probe builds it with placeholder values for its static conflict check.
151
+ * @param {{model: string, effort?: string, cwd: string, outPath: string, promptPath: string}} p
152
+ * @returns {string[]}
153
+ */
154
+ export function coderFlagArgv({ model, effort, cwd, outPath, promptPath }) {
155
+ const argv = ['codex', 'exec', '-m', model];
156
+ if (effort) argv.push('-c', `model_reasoning_effort=${effort}`);
157
+ argv.push('-s', 'workspace-write');
158
+ // INVARIANT (B4.1 fix round 1): the session CODEX_HOME lives under the run temp root, so the
159
+ // sandbox must not make $TMPDIR or /tmp writable — the project cwd is then the only writable
160
+ // root, and a home inside the cwd is refused below. See `../codex-home.mjs`.
161
+ argv.push(...SANDBOX_TMP_EXCLUSIONS);
162
+ // B4.2: NO `--approve-for-me` (Codex 0.155.1 refuses it next to `-s`); see CODER_APPROVAL_POLICY.
163
+ argv.push(...CODER_APPROVAL_POLICY);
164
+ argv.push('-C', cwd);
165
+ argv.push('--json');
166
+ argv.push('-o', outPath);
167
+ argv.push(promptPath);
168
+ return argv;
169
+ }
170
+
171
+ /**
172
+ * @param {CodexBuildParams} params
173
+ * @returns {{cli: "codex", role: "coder", argv: string[], cwd: string, outPath: string, env: {CODEX_HOME: string}, forbiddenRendered: ReadonlyArray<RenderedCodexEntry>, rulesFile: {fileName: string, path: string, content: string, count: number}}}
174
+ */
175
+ function buildCoderArgv(params) {
176
+ const { model, effort, promptPath, cwd, renderedForbidden = renderForCodex(mergeForbidden()) } = params;
177
+ assertValidEffort(effort);
178
+ const outPath = effectiveOutPath(params.outPath, 'coder');
179
+ if (!hasAnyRenderedRule(renderedForbidden)) {
180
+ throw new Error('buildCodexArgv: coder role requires a non-empty forbidden-list render (got 0 usable patterns)');
181
+ }
182
+ const argv = coderFlagArgv({ model, effort, cwd, outPath, promptPath });
183
+ const codexHome = params.codexHome ?? defaultCodexHome();
184
+ // Fix round 4: a non-absolute home is refused here, before any I/O, and the containment check
185
+ // below then runs EVERY time (no `isAbsolute` short-circuit that could skip it).
186
+ if (typeof codexHome !== 'string' || !path.isAbsolute(codexHome)) {
187
+ throw new TypeError('buildCodexArgv: codexHome must be an absolute path');
188
+ }
189
+ // `isInside` realpaths BOTH sides through their nearest existing ancestor (fix round 3): a
190
+ // `/var/...` cwd and a `/private/var/...` home are the same tree on macOS, and a symlinked cwd
191
+ // is compared by its target, so a string-different alias cannot slip a home into the cwd.
192
+ if (isInside(codexHome, cwd)) {
193
+ throw new Error('buildCodexArgv: the session CODEX_HOME must not be inside the coder cwd (the sandbox can write there)');
194
+ }
195
+ const home = prepareCodexHome(codexHome, renderedForbidden);
196
+ return {
197
+ cli: CLI,
198
+ role: 'coder',
199
+ argv,
200
+ cwd,
201
+ outPath,
202
+ env: { CODEX_HOME: home.codexHome },
203
+ forbiddenRendered: renderedForbidden,
204
+ rulesFile: { fileName: RULES_FILE_NAME, path: home.rulesPath, content: home.content, count: home.count },
205
+ };
206
+ }
207
+
208
+ /**
209
+ * @param {CodexBuildParams} params
210
+ * @returns {{cli: "codex", role: "reviewer"|"judge"|"s2"|"author"|"facts", argv: string[], cwd: string, outPath: string, stdinFile: string}}
211
+ */
212
+ function buildClosedBookArgv(params) {
213
+ const { model, effort, promptPath, cwd, schemaPath } = params;
214
+ assertValidEffort(effort);
215
+ // See `builders/claude.mjs`'s identical cast: the dispatch below never reaches this branch
216
+ // with role 'coder', but that is a runtime invariant, not provable from the parameter type.
217
+ const role = /** @type {"reviewer"|"judge"|"s2"|"author"|"facts"} */ (params.role);
218
+ const outPath = effectiveOutPath(params.outPath, role);
219
+ const argv = ['codex', 'exec', '-m', model];
220
+ if (effort) argv.push('-c', `model_reasoning_effort=${effort}`);
221
+ argv.push('-s', 'read-only');
222
+ argv.push('--ephemeral');
223
+ argv.push('--ignore-rules');
224
+ argv.push('--ignore-user-config');
225
+ argv.push('-C', cwd);
226
+ argv.push('--skip-git-repo-check');
227
+ if (schemaPath !== undefined) argv.push('--output-schema', schemaPath);
228
+ argv.push('-o', outPath);
229
+ argv.push('--json');
230
+ // `-` = "read the prompt from stdin" (pinned help); the packet CONTENT arrives there.
231
+ argv.push('-');
232
+ return { cli: CLI, role, argv, cwd, outPath, stdinFile: promptPath };
233
+ }
234
+
235
+ /**
236
+ * @param {CodexBuildParams} params
237
+ * @returns {ReturnType<typeof buildCoderArgv> | ReturnType<typeof buildClosedBookArgv>}
238
+ */
239
+ export function buildCodexArgv(params) {
240
+ assertBaseParams('buildCodexArgv', params);
241
+ return params.role === 'coder' ? buildCoderArgv(params) : buildClosedBookArgv(params);
242
+ }