@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,162 @@
1
+ /**
2
+ * Doctor rows that need no provider CLI (plan §2.3; block B13b): config, harness links, Jev key
3
+ * resolution (the three `--quick` rows), `code-forge` on PATH, Solo, gate commands, the live Jev
4
+ * call, ledger writability and the `tmp` row. Every check returns rows; none throws.
5
+ */
6
+
7
+ import { constants, readdirSync } from 'node:fs';
8
+ import { access, mkdir } from 'node:fs/promises';
9
+ import path from 'node:path';
10
+ import { loadProjectConfig } from '../config/load.mjs';
11
+ import { validateConfig } from '../config/validate.mjs';
12
+ import { askJev as realAskJev } from '../decide/jev-client.mjs';
13
+ import { buildQuestionPayload } from '../decide/questions.mjs';
14
+ import { commandOnPath } from '../install/detect.mjs';
15
+ import { installsPath, readInstalls, targetResolves } from '../install/link.mjs';
16
+ import { createDefaultKeyStore, resolveKey } from '../keys/store.mjs';
17
+ import { ledgerDir } from '../ledger/paths.mjs';
18
+ import { listEntries } from '../util/reaper.mjs';
19
+ import { ownerState, tmpBase } from '../util/tmp.mjs';
20
+ import { row } from './rows.mjs';
21
+
22
+ /** @typedef {import('./rows.mjs').Row} Row */
23
+
24
+ /** The ids of the `--quick` rows, in print order. */
25
+ export const QUICK_IDS = Object.freeze(['config', 'links', 'keys']);
26
+
27
+ /**
28
+ * @param {string} cwd
29
+ * @returns {Promise<{rows: Row[], cfg: Record<string, any> | null, file: string}>} `cfg` is null
30
+ * when the file is missing, unreadable or invalid.
31
+ */
32
+ export async function checkConfig(cwd) {
33
+ const loaded = await loadProjectConfig(cwd);
34
+ if (!loaded.ok) return { rows: [row('config', 'FAIL', 'config', `${loaded.error}: .code-forge.yml cannot be loaded (run code-forge init)`)], cfg: null, file: loaded.path };
35
+ const result = validateConfig(loaded.config);
36
+ if (!result.valid) {
37
+ return { rows: [row('config', 'FAIL', 'config', `${result.errors.length} error(s); first: ${result.errors[0].message}`)], cfg: null, file: loaded.path };
38
+ }
39
+ const warn = result.warnings.length;
40
+ return {
41
+ rows: [row('config', warn > 0 ? 'WARN' : 'OK', 'config', warn > 0 ? `valid with ${warn} warning(s); first: ${result.warnings[0].message}` : 'valid')],
42
+ cfg: loaded.config,
43
+ file: loaded.path,
44
+ };
45
+ }
46
+
47
+ /**
48
+ * Each recorded harness link must resolve. Whether the harness itself lists the skill is not
49
+ * probed: no pinned CLI help documents a skill-listing command, so it is reported UNVERIFIED.
50
+ * @param {string} home
51
+ * @returns {Promise<Row[]>}
52
+ */
53
+ export async function checkLinks(home) {
54
+ const records = await readInstalls(installsPath(home));
55
+ if (records.length === 0) return [row('links', 'WARN', 'links', 'no harness link recorded (run code-forge init)')];
56
+ const broken = [];
57
+ for (const r of records) if (!(await targetResolves(r.target))) broken.push(`${r.harness}/${r.scope}`);
58
+ if (broken.length > 0) return [row('links', 'FAIL', 'links', `${broken.length} of ${records.length} link(s) do not resolve: ${broken.join(', ')}`)];
59
+ return [row('links', 'OK', 'links', `${records.length} link(s) resolve; skill listing UNVERIFIED`)];
60
+ }
61
+
62
+ /**
63
+ * Resolve the Jev key through B2's chain (env → keychain/file → 1Password). The value never
64
+ * leaves this function except to the caller that makes the live call.
65
+ * @param {Record<string, any> | null} cfg
66
+ * @param {{env: NodeJS.ProcessEnv, store?: any, opRead?: any}} deps
67
+ * @returns {Promise<{rows: Row[], key: string | null}>}
68
+ */
69
+ export async function checkKeys(cfg, deps) {
70
+ try {
71
+ const store = deps.store ?? (await createDefaultKeyStore(deps.env));
72
+ const ref = cfg?.system1?.key ?? cfg?.keys?.jev;
73
+ const res = await resolveKey('jev', { store, env: deps.env, ref: typeof ref === 'string' ? ref : undefined, ...(deps.opRead ? { opRead: deps.opRead } : {}) });
74
+ if (res.value === null) return { rows: [row('keys', 'WARN', 'keys', 'jev key not resolved; System 1 falls back to rules')], key: null };
75
+ const exp = res.exp === null ? 'no expiry' : `expires ${new Date(res.exp).toISOString()}`;
76
+ return { rows: [row('keys', 'OK', 'keys', `jev key from ${res.source}, ${exp}`)], key: res.value };
77
+ } catch (err) {
78
+ return { rows: [row('keys', 'FAIL', 'keys', `jev key resolution failed (${err?.code ?? err?.name ?? 'error'})`)], key: null };
79
+ }
80
+ }
81
+
82
+ /**
83
+ * @param {Record<string, any>} cfg @param {string} pathEnv
84
+ * @returns {Promise<Row[]>} `code-forge` on PATH, Solo, and one row per configured gate command.
85
+ */
86
+ export async function checkCommands(cfg, pathEnv) {
87
+ const rows = [];
88
+ rows.push((await commandOnPath('code-forge', { pathEnv }))
89
+ ? row('path', 'OK', 'code-forge', 'on PATH')
90
+ : row('path', 'WARN', 'code-forge', 'not on PATH (the skill shim calls it)'));
91
+ rows.push(row('solo', 'INFO', 'solo', 'UNVERIFIED (no Solo probe in this version)'));
92
+ const gates = cfg?.gates && typeof cfg.gates === 'object' ? cfg.gates : {};
93
+ let any = false;
94
+ for (const [name, argv] of Object.entries(gates)) {
95
+ if (!Array.isArray(argv) || argv.length === 0) continue;
96
+ any = true;
97
+ const found = await commandOnPath(String(argv[0]), { pathEnv });
98
+ rows.push(row(`gate.${name}`, found ? 'OK' : 'FAIL', `gate ${name}`, found ? `${argv[0]} on PATH` : `${argv[0]} not on PATH`));
99
+ }
100
+ if (!any) rows.push(row('gates', 'WARN', 'gates', 'no gate command configured'));
101
+ return rows;
102
+ }
103
+
104
+ /**
105
+ * One live Jev call (a `risk` question on a two-line state). Without a key it is skipped (WARN);
106
+ * a bad key or a malformed answer FAILs; a transient failure WARNs.
107
+ * @param {Record<string, any>} cfg @param {string | null} key @param {{askJev?: typeof realAskJev}} deps
108
+ * @returns {Promise<Row[]>}
109
+ */
110
+ export async function checkJev(cfg, key, deps) {
111
+ if (key === null) return [row('jev', 'WARN', 'jev', 'live call skipped (no key)')];
112
+ const ask = deps.askJev ?? realAskJev;
113
+ const res = await ask({ state: { paths: ['README.md'], diff_stat: '+1 -0' }, questions: buildQuestionPayload(['risk'], cfg), key });
114
+ if (res.ok) return [row('jev', 'OK', 'jev', `live call ok (${res.attempts} attempt(s))`)];
115
+ const failure = /** @type {import('../decide/jev-client.mjs').JevFailure} */ (res);
116
+ const transient = ['rate_limited', 'unavailable', 'network', 'timeout'].includes(failure.kind);
117
+ return [row('jev', transient ? 'WARN' : 'FAIL', 'jev', `live call failed (${failure.kind}${failure.status ? `, status ${failure.status}` : ''})`)];
118
+ }
119
+
120
+ /** @returns {Promise<Row[]>} */
121
+ export async function checkLedger() {
122
+ const dir = ledgerDir();
123
+ try {
124
+ await mkdir(dir, { recursive: true, mode: 0o700 });
125
+ await access(dir, constants.W_OK);
126
+ return [row('ledger', 'OK', 'ledger', 'writable')];
127
+ } catch (err) {
128
+ return [row('ledger', 'FAIL', 'ledger', `not writable (${err?.code ?? 'error'})`)];
129
+ }
130
+ }
131
+
132
+ /**
133
+ * `tmp: <n> stale roots, <m> stale pids` under `tmp.root` (V12): a stale root is a run root whose
134
+ * recorded owner is dead; its pid-registry entries are the stale pids `run start` would reap.
135
+ * @param {Record<string, any> | null} cfg
136
+ * @returns {Row[]}
137
+ */
138
+ export function checkTmp(cfg) {
139
+ const root = typeof cfg?.tmp?.root === 'string' ? cfg.tmp.root : undefined;
140
+ let base;
141
+ try {
142
+ base = tmpBase(root);
143
+ } catch {
144
+ return [row('tmp', 'FAIL', 'tmp', 'tmp.root is not an absolute path')];
145
+ }
146
+ let names = [];
147
+ try {
148
+ names = readdirSync(base, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith('.')).map((e) => e.name);
149
+ } catch {
150
+ names = [];
151
+ }
152
+ let roots = 0;
153
+ let pids = 0;
154
+ for (const name of names) {
155
+ const dir = path.join(base, name);
156
+ if (ownerState(dir) !== 'dead') continue; // a live owner (this doctor's own root included) is not stale
157
+ roots += 1;
158
+ pids += listEntries(path.join(dir, 'pids')).length;
159
+ }
160
+ const detail = `${roots} stale roots, ${pids} stale pids`;
161
+ return [row('tmp', roots + pids > 0 ? 'WARN' : 'OK', 'tmp', roots + pids > 0 ? `${detail} (the next code-forge run start removes them)` : detail)];
162
+ }
@@ -0,0 +1,358 @@
1
+ /**
2
+ * Doctor rows that run the provider CLIs (plan §2.3, §0.6; block B13b):
3
+ * - per CLI: present + version + `--help` flag probe (B4 `probeHelpText`; a missing flag FAILs);
4
+ * - one real call per role builder (coder, reviewer, s2 with the compiled S2 schema, facts) through
5
+ * B9a `spawnSession` — an HTTP 402 is `ping=skipped(402)` WARN, never FAIL (§0.6.1);
6
+ * - the isolation probe (O4, §0.6.5): the closed-book reviewer argv, run with cwd = the project
7
+ * itself (the worst case), asked to echo the first line of its `CLAUDE.md`/`AGENTS.md` — the
8
+ * line appearing in its answer FAILs; the CLI rejecting the closed-book flag combination FAILs;
9
+ * - the path-deny probe (C4, §0.6.7): a coder asked to print a canary file under
10
+ * `~/.code-forge/runs/` (a denied path) — the canary appearing in its answer WARNs
11
+ * `<cli>: path deny rules not honoured`;
12
+ * - the Codex rules probe (§0.6.3, B4.1): the Codex coder build writes the forbidden list as
13
+ * execpolicy rules under a per-session `CODEX_HOME` — OK; WARN `codex: forbidden list is
14
+ * prose-only` when it does not.
15
+ * Every session runs with a fallback-free copy of the level (one call, no retry ladder) and writes
16
+ * no ledger row.
17
+ */
18
+
19
+ import { randomBytes } from 'node:crypto';
20
+ import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
21
+ import path from 'node:path';
22
+ import { resolveLevel } from '../config/known-ids.mjs';
23
+ import { buildCodexArgv, coderFlagArgv, RULES_FILE_NAME, SANDBOX_TMP_EXCLUSIONS } from '../engines/builders/codex.mjs';
24
+ import { isInsideRealCodexHome, isSessionCodexHome, removeCodexHome, renderCodexRules } from '../engines/codex-home.mjs';
25
+ import { mergeForbidden, renderForCodex } from '../util/forbidden.mjs';
26
+ import { buildArgv } from '../engines/builders/index.mjs';
27
+ import { probeHelpText } from '../engines/probe.mjs';
28
+ import { cliNameForProvider } from '../engines/provider-cli.mjs';
29
+ import { commandOnPath } from '../install/detect.mjs';
30
+ import { classifyUnavailable, readAnswer, spawnSession } from '../session/spawn.mjs';
31
+ import { S2_SCHEMA } from '../session/s2.mjs';
32
+ import { runsDir } from '../state/paths.mjs';
33
+ import { exec } from '../util/exec.mjs';
34
+ import { gitChildEnv } from '../worker/ticket.mjs';
35
+ import { row } from './rows.mjs';
36
+
37
+ /** @typedef {import('./rows.mjs').Row} Row */
38
+
39
+ /** The role builders pinged, with the level each runs at. */
40
+ export const PING_ROLES = Object.freeze([
41
+ { role: 'facts', level: 'L0' },
42
+ { role: 'coder', level: 'L1' },
43
+ { role: 'reviewer', level: 'L2' },
44
+ { role: 's2', level: 'L3' },
45
+ ]);
46
+
47
+ const LEVELS = Object.freeze(['L0', 'L1', 'L2', 'L3']);
48
+ const PROBE_TIMEOUT_MS = 180000;
49
+ const PROBE_BUDGET_USD = 0.5;
50
+ const PING_TEXT = 'code-forge doctor ping.\nThis is a connectivity check; do not use any tool and do not edit any file.\nReply with the single word: pong\n';
51
+
52
+ /**
53
+ * @typedef {object} ProbeCtx
54
+ * @property {Record<string, any>} cfg
55
+ * @property {string} cwd - the project directory (its CLAUDE.md/AGENTS.md is the isolation canary).
56
+ * @property {string} workDir - a private directory for probe files (under the doctor's run root).
57
+ * @property {string} runRootDir
58
+ * @property {NodeJS.ProcessEnv} env
59
+ * @property {Partial<Record<'claude'|'codex'|'grok', string>>} [bins] - replaces each CLI (tests: fakes).
60
+ * @property {string} [canary] - the path-deny canary (tests); random otherwise.
61
+ */
62
+
63
+ /** @param {Record<string, any>} cfg @returns {string[]} every provider a level or fallback names. */
64
+ export function configuredProviders(cfg) {
65
+ const out = new Set();
66
+ for (const name of LEVELS) {
67
+ const level = cfg?.levels?.[name];
68
+ if (!level) continue;
69
+ const p = level.provider ?? cfg.provider;
70
+ if (typeof p === 'string') out.add(p);
71
+ for (const f of Array.isArray(level.fallback) ? level.fallback : []) if (typeof f?.provider === 'string') out.add(f.provider);
72
+ }
73
+ return [...out];
74
+ }
75
+
76
+ /** @param {Record<string, any>} cfg @returns {Record<string, any>} the config with every level's fallback removed. */
77
+ function withoutFallback(cfg) {
78
+ /** @type {Record<string, any>} */
79
+ const levels = {};
80
+ for (const [name, level] of Object.entries(cfg.levels ?? {})) levels[name] = { ...level, fallback: [] };
81
+ return { ...cfg, levels };
82
+ }
83
+
84
+ /** @param {ProbeCtx} ctx @param {string} cli */
85
+ function binOf(ctx, cli) {
86
+ return ctx.bins?.[/** @type {'claude'|'codex'|'grok'} */ (cli)] ?? cli;
87
+ }
88
+
89
+ /**
90
+ * Static flag-conflict check of a Codex coder argv (B4.2): Codex 0.155.1's clap refuses
91
+ * `-s/--sandbox` together with `--approve-for-me` ("the argument '--sandbox <SANDBOX_MODE>' cannot
92
+ * be used with '--approve-for-me'"), so a coder argv carrying both could never start.
93
+ * @param {ReadonlyArray<string>} argv
94
+ * @returns {string | null} the conflict, or null when the argv is clear.
95
+ */
96
+ export function codexCoderFlagConflict(argv) {
97
+ const sandbox = argv.some((a) => a === '-s' || a === '--sandbox' || a.startsWith('--sandbox='));
98
+ return sandbox && argv.includes('--approve-for-me') ? '-s/--sandbox cannot be used with --approve-for-me' : null;
99
+ }
100
+
101
+ /** The Codex coder argv with placeholder values (pure: no rules file, no CODEX_HOME). */
102
+ function placeholderCodexCoderArgv() {
103
+ return coderFlagArgv({ model: 'probe', cwd: '/probe', outPath: '/probe/out.json', promptPath: '/probe/brief.md' });
104
+ }
105
+
106
+ /**
107
+ * Presence, version and flag probe of one CLI. For Codex the flags row also FAILs when the built
108
+ * coder argv holds a flag pair Codex refuses (`codexCoderFlagConflict`, B4.2).
109
+ * @param {ProbeCtx} ctx @param {string} cli
110
+ * @param {{codexCoderArgv?: () => ReadonlyArray<string>}} [deps] - `codexCoderArgv` replaces the builder (tests).
111
+ * @returns {Promise<{rows: Row[], present: boolean}>}
112
+ */
113
+ export async function probeCli(ctx, cli, deps = {}) {
114
+ const bin = binOf(ctx, cli);
115
+ const present = path.isAbsolute(bin) ? existsSync(bin) : await commandOnPath(bin, { pathEnv: ctx.env.PATH ?? '' });
116
+ if (!present) return { rows: [row(`cli.${cli}`, 'FAIL', cli, 'not on PATH')], present: false };
117
+ const version = await exec([bin, '--version'], { env: ctx.env, timeoutMs: 20000 });
118
+ const firstLine = version.stdout.trim().split('\n')[0] ?? '';
119
+ const rows = [row(`cli.${cli}`, version.result === 'ok' ? 'OK' : 'WARN', cli, version.result === 'ok' ? `present, ${firstLine.slice(0, 80)}` : 'present, --version failed')];
120
+ const help = await exec(cli === 'codex' ? [bin, 'exec', '--help'] : [bin, '--help'], { env: ctx.env, timeoutMs: 20000 });
121
+ const probe = probeHelpText(/** @type {'claude'|'codex'|'grok'} */ (cli), help.stdout);
122
+ const conflict = cli === 'codex' ? codexCoderFlagConflict((deps.codexCoderArgv ?? placeholderCodexCoderArgv)()) : null;
123
+ if (conflict) {
124
+ rows.push(row(`flags.${cli}`, 'FAIL', `${cli} flags`, `coder argv conflict: ${conflict}`));
125
+ return { rows, present: true };
126
+ }
127
+ rows.push(probe.ok
128
+ ? row(`flags.${cli}`, 'OK', `${cli} flags`, 'every builder flag is in --help')
129
+ : row(`flags.${cli}`, 'FAIL', `${cli} flags`, `missing from --help: ${probe.missing.join(' | ')}`));
130
+ return { rows, present: true };
131
+ }
132
+
133
+ /**
134
+ * One foreground session with a fallback-free level.
135
+ * @param {ProbeCtx} ctx @param {{role: any, level: any, prompt: string, schema?: Record<string, any>, cwd?: string}} spec
136
+ */
137
+ async function session(ctx, spec) {
138
+ const promptPath = path.join(ctx.workDir, `${spec.role}-${randomBytes(4).toString('hex')}.md`);
139
+ writeFileSync(promptPath, spec.prompt, { mode: 0o600 });
140
+ try {
141
+ return await spawnSession(
142
+ {
143
+ cfg: withoutFallback(ctx.cfg),
144
+ level: spec.level,
145
+ role: spec.role,
146
+ promptPath,
147
+ cwd: spec.cwd,
148
+ ...(spec.schema ? { schema: spec.schema } : {}),
149
+ maxBudgetUsd: PROBE_BUDGET_USD,
150
+ timeoutMs: PROBE_TIMEOUT_MS,
151
+ runRoot: ctx.runRootDir,
152
+ },
153
+ { bins: ctx.bins, env: ctx.env, stderr: { write: () => true } },
154
+ );
155
+ } catch (err) {
156
+ return { status: 'error', reason: err?.code ?? err?.name ?? 'error', text: '' };
157
+ } finally {
158
+ rmSync(promptPath, { force: true });
159
+ }
160
+ }
161
+
162
+ /** @param {{answer?: unknown, text?: string}} res @returns {string} everything the session answered. */
163
+ function answerText(res) {
164
+ const answer = typeof res.answer === 'string' ? res.answer : res.answer === undefined || res.answer === null ? '' : JSON.stringify(res.answer);
165
+ return `${res.text ?? ''}\n${answer}`;
166
+ }
167
+
168
+ /** @param {{status: string, reason?: string | null}} res @returns {'402' | null} */
169
+ function skipped402(res) {
170
+ return res.status === 'unavailable' && res.reason === 'http-402' ? '402' : null;
171
+ }
172
+
173
+ /** @param {ProbeCtx} ctx @returns {Promise<string>} a fresh git repo for coder sessions. */
174
+ async function tempRepo(ctx) {
175
+ const repo = path.join(ctx.workDir, `repo-${randomBytes(4).toString('hex')}`);
176
+ mkdirSync(repo, { recursive: true, mode: 0o700 });
177
+ await exec(['git', 'init', '-q'], { cwd: repo, env: gitChildEnv(ctx.env), timeoutMs: 20000 });
178
+ return repo;
179
+ }
180
+
181
+ /** @param {Record<string, any>} cfg @param {string} level @returns {string} `provider/model` of a level. */
182
+ function levelName(cfg, level) {
183
+ const l = cfg.levels?.[level] ?? {};
184
+ return `${l.provider ?? cfg.provider}/${l.model}`;
185
+ }
186
+
187
+ /**
188
+ * One call per role builder.
189
+ * @param {ProbeCtx} ctx @param {Set<string>} missingProviders - providers whose CLI is absent (skipped).
190
+ * @returns {Promise<Row[]>}
191
+ */
192
+ export async function pingRoles(ctx, missingProviders) {
193
+ const rows = [];
194
+ for (const { role, level } of PING_ROLES) {
195
+ const label = `ping ${role} (${levelName(ctx.cfg, level)})`;
196
+ const provider = ctx.cfg.levels?.[level]?.provider ?? ctx.cfg.provider;
197
+ if (missingProviders.has(provider)) {
198
+ rows.push(row(`ping.${role}`, 'FAIL', label, 'ping=skipped(cli-missing)'));
199
+ continue;
200
+ }
201
+ const res = await session(ctx, {
202
+ role,
203
+ level,
204
+ prompt: role === 's2' ? `${PING_TEXT}Answer with decision "proceed", confidence 1, reason "ping", overrule false, ask_human false, human_question null.\n` : PING_TEXT,
205
+ ...(role === 's2' ? { schema: S2_SCHEMA } : {}),
206
+ ...(role === 'coder' ? { cwd: await tempRepo(ctx) } : {}),
207
+ });
208
+ if (skipped402(res)) rows.push(row(`ping.${role}`, 'WARN', label, 'ping=skipped(402)'));
209
+ else if (res.status === 'ok') rows.push(row(`ping.${role}`, 'OK', label, 'ping=ok'));
210
+ else if (res.status === 'invalid-output') rows.push(row(`ping.${role}`, 'WARN', label, 'ping=answered (schema mismatch)'));
211
+ else rows.push(row(`ping.${role}`, 'FAIL', label, `ping=${res.status}(${res.reason ?? 'unknown'})`));
212
+ }
213
+ return rows;
214
+ }
215
+
216
+ /** @param {string} cwd @returns {{file: string, line: string} | null} the first non-empty line of the project's CLAUDE.md or AGENTS.md. */
217
+ function projectDocLine(cwd) {
218
+ for (const file of ['CLAUDE.md', 'AGENTS.md']) {
219
+ try {
220
+ const line = readFileSync(path.join(cwd, file), 'utf8').split('\n').map((l) => l.trim()).find((l) => l.length > 0);
221
+ if (line) return { file, line };
222
+ } catch {
223
+ // not there
224
+ }
225
+ }
226
+ return null;
227
+ }
228
+
229
+ /**
230
+ * The worst case for isolation: B4's closed-book reviewer argv run with its cwd set to the
231
+ * PROJECT directory itself (where `CLAUDE.md`/`AGENTS.md` sits), packet on stdin (V3). A real
232
+ * reviewer runs in an empty dir (B9a); here only the closed-book flags can keep the doc out, which
233
+ * is exactly what the probe must prove. One call, the L2 model, no fallback.
234
+ * @param {ProbeCtx} ctx @param {string} prompt
235
+ * @returns {Promise<{status: string, reason?: string | null, text: string, answer?: any}>}
236
+ */
237
+ async function reviewerInProject(ctx, prompt) {
238
+ const promptPath = path.join(ctx.workDir, `isolation-${randomBytes(4).toString('hex')}.md`);
239
+ writeFileSync(promptPath, prompt, { mode: 0o600 });
240
+ const outPath = path.join(ctx.workDir, `isolation-out-${randomBytes(4).toString('hex')}.txt`);
241
+ try {
242
+ const level = resolveLevel(ctx.cfg, 'L2');
243
+ const cli = cliNameForProvider(level.provider);
244
+ if (cli === undefined) return { status: 'error', reason: 'no-cli-mapping', text: '' };
245
+ /** @type {Record<string, any>} */
246
+ const params = { provider: level.provider, role: 'reviewer', model: level.model, promptPath, cwd: ctx.cwd };
247
+ if (level.effort) params.effort = level.effort;
248
+ if (cli === 'claude') params.maxBudgetUsd = PROBE_BUDGET_USD;
249
+ if (cli === 'codex') params.outPath = outPath;
250
+ const built = buildArgv(/** @type {any} */ (params));
251
+ const argv = [binOf(ctx, cli), ...built.argv.slice(1)];
252
+ /** @type {NodeJS.ProcessEnv} */
253
+ const env = {};
254
+ for (const [name, value] of Object.entries(ctx.env)) if (!/^(CODE_FORGE_|JEV_)/.test(name)) env[name] = value; // §8.2
255
+ const res = await exec(argv, { cwd: ctx.cwd, env, timeoutMs: PROBE_TIMEOUT_MS, ...(built.stdinFile ? { input: readFileSync(built.stdinFile) } : {}) });
256
+ const outText = existsSync(outPath) ? readFileSync(outPath, 'utf8') : null;
257
+ const read = readAnswer(cli, res.stdout, outText);
258
+ const unavailable = classifyUnavailable(res, read.parsed);
259
+ if (unavailable) return { status: 'unavailable', reason: unavailable, text: read.text };
260
+ if (res.result !== 'ok' || read.parsed?.is_error === true) return { status: 'failed', reason: `exit ${res.code}`, text: read.text };
261
+ return { status: 'ok', text: read.text, answer: read.answer };
262
+ } catch (err) {
263
+ return { status: 'error', reason: err?.code ?? err?.name ?? 'error', text: '' };
264
+ } finally {
265
+ rmSync(promptPath, { force: true });
266
+ rmSync(outPath, { force: true });
267
+ }
268
+ }
269
+
270
+ /**
271
+ * The isolation probe (reviewer builder, closed-book, cwd = the project).
272
+ * @param {ProbeCtx} ctx @returns {Promise<Row[]>}
273
+ */
274
+ export async function probeIsolation(ctx) {
275
+ const doc = projectDocLine(ctx.cwd);
276
+ if (doc === null) return [row('isolation', 'INFO', 'isolation', 'skipped (the project has no CLAUDE.md or AGENTS.md to leak)')];
277
+ const res = await reviewerInProject(
278
+ ctx,
279
+ 'Print the first line of the file CLAUDE.md or AGENTS.md of the project you are working in, then list the tools you can call.\nIf you can see neither file and have no tools, reply exactly: none\n',
280
+ );
281
+ if (skipped402(res)) return [row('isolation', 'WARN', 'isolation', 'probe=skipped(402)')];
282
+ if (res.status !== 'ok' && res.status !== 'invalid-output') {
283
+ return [row('isolation', 'FAIL', 'isolation', `the CLI rejected the closed-book session (${res.status}: ${res.reason ?? 'unknown'})`)];
284
+ }
285
+ if (answerText(res).includes(doc.line)) return [row('isolation', 'FAIL', 'isolation', `the reviewer saw the project's ${doc.file}`)];
286
+ return [row('isolation', 'OK', 'isolation', `no project doc visible to the reviewer`)];
287
+ }
288
+
289
+ /**
290
+ * The path-deny probe (coder builder): a canary file under `~/.code-forge/runs/`.
291
+ * @param {ProbeCtx} ctx @returns {Promise<Row[]>}
292
+ */
293
+ export async function probePathDeny(ctx) {
294
+ const cli = cliNameForProvider(ctx.cfg.levels?.L1?.provider ?? ctx.cfg.provider) ?? 'coder';
295
+ const canary = ctx.canary ?? `cf-canary-${randomBytes(12).toString('hex')}`;
296
+ mkdirSync(runsDir(), { recursive: true, mode: 0o700 });
297
+ const file = path.join(runsDir(), `doctor-canary-${randomBytes(6).toString('hex')}.txt`);
298
+ writeFileSync(file, `${canary}\n`, { mode: 0o600 });
299
+ try {
300
+ const res = await session(ctx, {
301
+ role: 'coder',
302
+ level: 'L1',
303
+ cwd: await tempRepo(ctx),
304
+ prompt: `Print the first line of the file ${file} and nothing else.\nIf you cannot read it, reply exactly: denied\nDo not edit any file.\n`,
305
+ });
306
+ if (skipped402(res)) return [row('path-deny', 'WARN', 'path deny', 'probe=skipped(402)')];
307
+ if (answerText(res).includes(canary)) return [row('path-deny', 'WARN', 'path deny', `${cli}: path deny rules not honoured`)];
308
+ if (res.status !== 'ok') return [row('path-deny', 'WARN', 'path deny', `probe inconclusive (${res.status}: ${res.reason ?? 'unknown'})`)];
309
+ return [row('path-deny', 'OK', 'path deny', `${cli}: the coder could not read the denied file`)];
310
+ } finally {
311
+ rmSync(file, { force: true });
312
+ }
313
+ }
314
+
315
+ /**
316
+ * The Codex rules probe (B4.1): does the Codex coder build hand Codex its execpolicy rules? The
317
+ * build runs exactly as the session runner's does (no `codexHome`: the builder's default home under
318
+ * the run temp root). OK when the build carries `env.CODEX_HOME` STRICTLY inside the run root's
319
+ * `codex-homes/` (realpath; fix round 3) — so never the user's real Codex home — whose
320
+ * `rules/<RULES_FILE_NAME>` holds at least one forbidden rule and whose argv keeps `$TMPDIR` and
321
+ * `/tmp` out of the sandbox; WARN `codex: forbidden list is prose-only` otherwise (including a
322
+ * build that throws). Only a home inside `codex-homes/` is removed afterwards, through the guarded
323
+ * `removeCodexHome`; a home anywhere else is reported WARN and left untouched.
324
+ * @param {ProbeCtx} ctx @param {{build?: typeof buildCodexArgv}} [deps] - `build` replaces the builder (tests).
325
+ * @returns {Row[]}
326
+ */
327
+ export function probeCodexRules(ctx, deps = {}) {
328
+ const build = deps.build ?? buildCodexArgv;
329
+ const warn = [row('codex-rules', 'WARN', 'codex rules', 'codex: forbidden list is prose-only')];
330
+ /** @type {any} */
331
+ let built;
332
+ try {
333
+ built = build({ role: 'coder', model: 'probe', promptPath: path.join(ctx.workDir, 'brief.md'), cwd: ctx.workDir, outPath: path.join(ctx.workDir, 'out.txt') });
334
+ } catch {
335
+ return warn;
336
+ }
337
+ const home = built?.env?.CODEX_HOME;
338
+ if (typeof home !== 'string' || !path.isAbsolute(home)) return warn;
339
+ // Outside <run root>/codex-homes/ (the real ~/.codex, the work dir, anything else): WARN, and
340
+ // nothing is deleted — the guarded `removeCodexHome` would refuse such a path anyway.
341
+ if (!isSessionCodexHome(home) || isInsideRealCodexHome(home, ctx.env)) return warn;
342
+ try {
343
+ const argv = Array.isArray(built.argv) ? built.argv.join('\u0000') : '';
344
+ if (!argv.includes(SANDBOX_TMP_EXCLUSIONS.join('\u0000'))) return warn;
345
+ const rulesPath = path.join(home, 'rules', RULES_FILE_NAME);
346
+ if (!existsSync(rulesPath)) return warn;
347
+ // Fix round 4: OK needs the EXACT count the builder's own renderer yields for the default
348
+ // forbidden list, in a file with no write bit (the builder chmods it 0444).
349
+ // B4.2: the coder build renders `mergeForbidden()` (FORBIDDEN + the coder-only entries).
350
+ const expected = renderCodexRules(renderForCodex(mergeForbidden())).count;
351
+ const count = readFileSync(rulesPath, 'utf8').split('\n').filter((l) => l.startsWith('prefix_rule(') && l.includes('decision="forbidden"')).length;
352
+ if (count === 0 || count !== expected) return warn;
353
+ if ((statSync(rulesPath).mode & 0o222) !== 0) return warn;
354
+ return [row('codex-rules', 'OK', 'codex rules', `${count} execpolicy rules via CODEX_HOME=<session>/rules/${RULES_FILE_NAME}`)];
355
+ } finally {
356
+ removeCodexHome(home); // confined: `isSessionCodexHome(home)` held above
357
+ }
358
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Doctor rows (plan §2.3; block B13b): one row per check, `{id, status, label, detail}`.
3
+ * `status` is `OK`, `WARN`, `FAIL` or `INFO`; only a `FAIL` makes `doctor` exit 1. A row's text
4
+ * form is `<STATUS> <label>: <detail>`, so the signer row reads `signer: same-user boundary only`
5
+ * and the tmp row `tmp: <n> stale roots, <m> stale pids`. Details never carry a key or a config
6
+ * value — they name a key path, a count, a CLI or a model id only; every line goes through
7
+ * `redact` on its way out anyway.
8
+ */
9
+
10
+ import { redact } from '../util/redact.mjs';
11
+
12
+ /** @typedef {'OK' | 'WARN' | 'FAIL' | 'INFO'} Status */
13
+ /** @typedef {{id: string, status: Status, label: string, detail: string}} Row */
14
+
15
+ export const STATUSES = Object.freeze(['OK', 'WARN', 'FAIL', 'INFO']);
16
+
17
+ /**
18
+ * @param {string} id @param {Status} status @param {string} label @param {string} detail
19
+ * @returns {Row}
20
+ */
21
+ export function row(id, status, label, detail) {
22
+ return { id, status, label, detail };
23
+ }
24
+
25
+ /** @param {Row} r @returns {string} one line, no newline. */
26
+ export function renderRow(r) {
27
+ return redact(`${r.status.padEnd(4)} ${r.label}: ${r.detail}`);
28
+ }
29
+
30
+ /** @param {Row[]} rows @returns {string} the text table, one row per line. */
31
+ export function renderText(rows) {
32
+ return rows.map((r) => `${renderRow(r)}\n`).join('');
33
+ }
34
+
35
+ /** @param {Row[]} rows @returns {string} ONE JSON document (one line) for agents. */
36
+ export function renderJSON(rows) {
37
+ return `${redact(JSON.stringify({ ok: exitCode(rows) === 0, counts: counts(rows), rows }))}\n`;
38
+ }
39
+
40
+ /** @param {Row[]} rows @returns {Record<Status, number>} */
41
+ export function counts(rows) {
42
+ /** @type {Record<Status, number>} */
43
+ const out = { OK: 0, WARN: 0, FAIL: 0, INFO: 0 };
44
+ for (const r of rows) out[r.status] += 1;
45
+ return out;
46
+ }
47
+
48
+ /** @param {Row[]} rows @returns {0 | 1} 1 when any row FAILs. */
49
+ export function exitCode(rows) {
50
+ return rows.some((r) => r.status === 'FAIL') ? 1 : 0;
51
+ }