@codedology/code-forge 0.3.2 → 0.4.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 (66) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +79 -21
  3. package/bin/code-forge.mjs +6 -1
  4. package/docs/README.md +5 -4
  5. package/docs/reference/blocks.json +153 -1
  6. package/docs/reference/config.md +8 -1
  7. package/package.json +4 -2
  8. package/schema/code-forge.schema.json +15 -4
  9. package/skill/SKILL.md +7 -7
  10. package/skill/references/adapters/claude-code.md +1 -1
  11. package/skill/references/adapters/solo.md +1 -1
  12. package/skill/references/adapters/subprocess.md +1 -1
  13. package/skill/references/code.md +4 -3
  14. package/skill/references/continuity.md +4 -0
  15. package/skill/references/decisions.md +5 -2
  16. package/skill/references/ledger.md +8 -2
  17. package/skill/references/plan.md +3 -3
  18. package/skill/references/review.md +8 -5
  19. package/skill/references/security.md +3 -1
  20. package/skill/scripts/forge +1 -1
  21. package/skill/templates/coder-brief.md +36 -0
  22. package/skill/templates/plan.md +2 -0
  23. package/src/cli/block.mjs +51 -8
  24. package/src/cli/jev.mjs +114 -17
  25. package/src/cli/ledger.mjs +99 -8
  26. package/src/cli/logs.mjs +193 -58
  27. package/src/cli/plan.mjs +27 -11
  28. package/src/cli/report.mjs +15 -1
  29. package/src/cli/resolve.mjs +16 -1
  30. package/src/cli/review-file.mjs +10 -5
  31. package/src/cli/run.mjs +109 -8
  32. package/src/config/closed-book.mjs +75 -0
  33. package/src/config/validate.mjs +128 -7
  34. package/src/decide/escalation.mjs +112 -11
  35. package/src/decide/questions.mjs +27 -4
  36. package/src/doctor/probes.mjs +48 -6
  37. package/src/engines/builders/claude.mjs +4 -1
  38. package/src/engines/builders/codex.mjs +26 -11
  39. package/src/engines/builders/grok.mjs +3 -3
  40. package/src/engines/efforts.mjs +109 -0
  41. package/src/engines/sentinel.mjs +34 -0
  42. package/src/keys/onepassword.mjs +3 -0
  43. package/src/ledger/report.mjs +66 -29
  44. package/src/ledger/spend.mjs +171 -0
  45. package/src/review/engine.mjs +57 -16
  46. package/src/review/fixloop.mjs +29 -6
  47. package/src/review/session-retry.mjs +69 -0
  48. package/src/review/triage.mjs +26 -1
  49. package/src/review/validate-review.mjs +6 -3
  50. package/src/session/author.mjs +6 -2
  51. package/src/session/plan-check.mjs +71 -6
  52. package/src/session/plan-sections.mjs +98 -0
  53. package/src/session/spawn.mjs +217 -8
  54. package/src/state/block.mjs +13 -4
  55. package/src/state/config-snapshot.mjs +150 -0
  56. package/src/state/run.mjs +86 -0
  57. package/src/util/breadcrumbs.mjs +145 -0
  58. package/src/util/error-hint.mjs +3 -2
  59. package/src/util/error-kind.mjs +2 -2
  60. package/src/util/error-log.mjs +109 -18
  61. package/src/util/forbidden.mjs +3 -1
  62. package/src/util/known-fixes.json +1 -0
  63. package/src/util/known-fixes.mjs +153 -0
  64. package/src/worker/engine.mjs +13 -8
  65. package/src/worker/loop.mjs +135 -39
  66. package/src/worker/queue.mjs +68 -7
@@ -9,6 +9,11 @@
9
9
  * (`reviewRoundStalled` below) ⇒ +1 level IMMEDIATELY (`trigger: 'review_stall'`), checked
10
10
  * BEFORE rule 2's own round-exhaustion counter — a block need not exhaust
11
11
  * `review_rounds_per_level` to escalate on a stalled round.
12
+ * 2c. Heavy rounds (B34, issue #2) — `escalation.after_rounds_with_warnings` (1) rounds at the
13
+ * current level whose open `fix_now` set held ≥ `escalation.warning_threshold` (2) warnings
14
+ * or any `critical` ⇒ +1 level (`trigger: 'review_warnings'`), checked after 2b and before 2.
15
+ * Below the running ceiling only: at L2 it does not fire (the L3 rung keeps its own
16
+ * triggers), so it never spends the rung on round 1.
12
17
  * 2. Review rounds — `review_rounds_per_level` (2) exhausted with an open `fix_now` finding
13
18
  * ⇒ +1 level (`trigger: 'review_rounds'`).
14
19
  * 3. Security floor (DISPATCH time only) — `security_sensitive == true` ⇒ first-attempt level
@@ -84,15 +89,95 @@ export function securityFloorLevel(lane) {
84
89
  }
85
90
 
86
91
  /**
87
- * Rule 3, at dispatch time only — call this once per block, before the first attempt.
88
- * @param {{lane: string, securitySensitive: boolean}} ctx
89
- * @returns {{level: string, trigger: 'security'|null}}
92
+ * Block kinds (B34, issue #2). A `docs` or `contract` block is coded at `levels.coder_floor_docs`
93
+ * (default L1) or above — never by L0 — and its review never runs the multimodel consensus unless
94
+ * `review.multimodel_for_docs` is true.
90
95
  */
91
- export function dispatchLevel({ lane, securitySensitive }) {
92
- if (securitySensitive) {
93
- return { level: securityFloorLevel(lane), trigger: 'security' };
94
- }
95
- return { level: lane, trigger: null };
96
+ export const BLOCK_KINDS = Object.freeze(['code', 'docs', 'contract']);
97
+
98
+ /** Owned paths with one of these endings are docs (`.txt` is not: `requirements.txt`, `CMakeLists.txt`, `robots.txt` are code or config). */
99
+ export const DOCS_EXTENSIONS = Object.freeze(['.md', '.mdx', '.markdown', '.rst', '.adoc']);
100
+
101
+ /** Owned paths with one of these endings are contracts (schemas, IDLs, API descriptions). */
102
+ export const CONTRACT_SUFFIXES = Object.freeze(['.schema.json', '.schema.yaml', '.schema.yml', '.proto', '.graphql', '.gql', '.avsc', '.openapi.json', '.openapi.yaml', '.openapi.yml']);
103
+
104
+ /** A `.json`/`.yaml`/`.yml` path with one of these directory names is a contract too. */
105
+ const CONTRACT_DIRS = Object.freeze(['schema', 'schemas', 'contract', 'contracts']);
106
+
107
+ /**
108
+ * The kind of ONE owned path (a glob is judged by its literal ending: `docs/**` is `code`, a
109
+ * block that owns it declares its kind).
110
+ * @param {string} file
111
+ * @returns {'code'|'docs'|'contract'}
112
+ */
113
+ export function fileKind(file) {
114
+ const lower = String(file).toLowerCase();
115
+ if (CONTRACT_SUFFIXES.some((s) => lower.endsWith(s))) return 'contract';
116
+ if (/\.(json|ya?ml)$/.test(lower) && lower.split('/').slice(0, -1).some((d) => CONTRACT_DIRS.includes(d))) return 'contract';
117
+ if (DOCS_EXTENSIONS.some((e) => lower.endsWith(e))) return 'docs';
118
+ return 'code';
119
+ }
120
+
121
+ /**
122
+ * A block's kind: the declared `kind` when it is one of `BLOCK_KINDS` (a declaration wins, so a
123
+ * block can also declare `code`); else `docs` when every owned path is docs, `contract` when every
124
+ * owned path is docs or contract and at least one is a contract, else `code` (no owned path ⇒ `code`).
125
+ * @param {{owned?: ReadonlyArray<string>, declared?: unknown}} opts
126
+ * @returns {'code'|'docs'|'contract'}
127
+ */
128
+ export function blockKind({ owned = [], declared } = {}) {
129
+ if (typeof declared === 'string' && BLOCK_KINDS.includes(declared)) return /** @type {'code'|'docs'|'contract'} */ (declared);
130
+ if (owned.length === 0) return 'code';
131
+ const kinds = owned.map(fileKind);
132
+ if (kinds.every((k) => k === 'docs')) return 'docs';
133
+ if (kinds.every((k) => k !== 'code')) return 'contract';
134
+ return 'code';
135
+ }
136
+
137
+ /** @param {unknown} kind @returns {boolean} true for a `docs` or `contract` block (both get the coder floor and no multimodel review by default). */
138
+ export function isDocsKind(kind) {
139
+ return kind === 'docs' || kind === 'contract';
140
+ }
141
+
142
+ /**
143
+ * The lowest level a block of `kind` may be coded at: `levels.coder_floor_docs` (default L1) for
144
+ * a docs/contract block, else L0.
145
+ * @param {unknown} kind @param {Record<string, any> | undefined} [cfg]
146
+ * @returns {string}
147
+ */
148
+ export function coderFloor(kind, cfg) {
149
+ if (!isDocsKind(kind)) return 'L0';
150
+ const floor = cfg?.levels?.coder_floor_docs;
151
+ return typeof floor === 'string' && ['L0', 'L1', 'L2'].includes(floor) ? floor : 'L1';
152
+ }
153
+
154
+ /**
155
+ * Rule 3 (and the B34 docs floor), at dispatch time only — call this once per block, before the
156
+ * first attempt. Both floors apply: the level is `max(security floor, docs floor)` (the lane when
157
+ * neither raises it). The trigger names the floor that set the level — `security` when the
158
+ * security floor reaches it, else `docs_floor`.
159
+ * @param {{lane: string, securitySensitive?: boolean, kind?: unknown, cfg?: Record<string, any>}} ctx
160
+ * @returns {{level: string, trigger: 'security'|'docs_floor'|null}}
161
+ */
162
+ export function dispatchLevel({ lane, securitySensitive = false, kind = 'code', cfg }) {
163
+ const security = securitySensitive ? securityFloorLevel(lane) : lane;
164
+ const docs = coderFloor(kind, cfg);
165
+ if (levelIndex(docs) > levelIndex(security)) return { level: docs, trigger: 'docs_floor' };
166
+ return { level: security, trigger: securitySensitive ? 'security' : null };
167
+ }
168
+
169
+ /**
170
+ * B34: a review round is "heavy" when its open `fix_now` set holds at least `threshold` warnings
171
+ * or any `critical` finding. A threshold that is not an integer ≥ 1 is the default 2; findings
172
+ * that are not an array are not heavy.
173
+ * @param {unknown} findings @param {unknown} [threshold] - `escalation.warning_threshold`, default 2.
174
+ * @returns {boolean}
175
+ */
176
+ export function heavyRound(findings, threshold = 2) {
177
+ if (!Array.isArray(findings)) return false;
178
+ const min = Number.isInteger(threshold) && /** @type {number} */ (threshold) >= 1 ? /** @type {number} */ (threshold) : 2;
179
+ if (findings.some((f) => f?.severity === 'critical')) return true;
180
+ return findings.filter((f) => f?.severity === 'warning').length >= min;
96
181
  }
97
182
 
98
183
  /**
@@ -136,7 +221,7 @@ export function runningCeiling(stopAt = 'L3') {
136
221
  /**
137
222
  * Rules 1, 2b and 2 share this: below the running ceiling ⇒ +1 level (never to L3); at the
138
223
  * running ceiling ⇒ rule 5, the block's one L3 rung (R1), then `stop`.
139
- * @param {'retries'|'review_rounds'|'review_stall'} trigger
224
+ * @param {'retries'|'review_rounds'|'review_stall'|'review_warnings'} trigger
140
225
  * @param {string} currentLevel
141
226
  * @param {string} stopAt
142
227
  * @param {'patch'|'code'} l3Mode
@@ -192,9 +277,13 @@ function finalizeEscalate(trigger, currentLevel, stopAt, l3Mode, l3RungAlreadyUs
192
277
  * level escalation.
193
278
  * @property {number} [maxRoundsPerFile] - `review.max_rounds_per_file`, default 4 (schema
194
279
  * minimum 2, maximum 6; B1.1).
280
+ * @property {number} [warningRoundsAtLevel] - rule 2c (B34): heavy rounds (`heavyRound`) at the
281
+ * current level, this one included.
282
+ * @property {number} [afterRoundsWithWarnings] - `escalation.after_rounds_with_warnings`, default
283
+ * 1; 0 turns rule 2c off.
195
284
  *
196
285
  * @typedef {object} EscalationResult
197
- * @property {'retries'|'review_stall'|'review_rounds'|'s2_ruling'|'review_cap'|null} trigger
286
+ * @property {'retries'|'review_stall'|'review_warnings'|'review_rounds'|'s2_ruling'|'review_cap'|null} trigger
198
287
  * @property {'escalate'|'s2_check'|'stop'|'l3_rung'|'none'} action
199
288
  * @property {string} [level]
200
289
  * @property {string} [mode]
@@ -202,7 +291,7 @@ function finalizeEscalate(trigger, currentLevel, stopAt, l3Mode, l3RungAlreadyUs
202
291
  */
203
292
 
204
293
  /**
205
- * Rules 1, 2b, 2, 4 and 6 (rule 3 is `dispatchLevel`; rule 5 is the ceiling guard inside
294
+ * Rules 1, 2b, 2c, 2, 4 and 6 (rule 3 is `dispatchLevel`; rule 5 is the ceiling guard inside
206
295
  * `finalizeEscalate`), evaluated in the documented order — the first rule that fires wins.
207
296
  * @param {EscalationCtx} ctx
208
297
  * @returns {EscalationResult}
@@ -223,6 +312,8 @@ export function escalationAfterAttempt(ctx) {
223
312
  l3RungAlreadyUsed = false,
224
313
  roundsAtFile = 0,
225
314
  maxRoundsPerFile = 4,
315
+ warningRoundsAtLevel = 0,
316
+ afterRoundsWithWarnings = 1,
226
317
  } = ctx;
227
318
 
228
319
  if (typeof currentLevel !== 'string') {
@@ -237,6 +328,16 @@ export function escalationAfterAttempt(ctx) {
237
328
  if (reviewStall && openFixNowFinding) {
238
329
  return finalizeEscalate('review_stall', currentLevel, stopAt, l3Mode, l3RungAlreadyUsed);
239
330
  }
331
+ // Rule 2c (B34): heavy review rounds — below the running ceiling only (a cheap coder climbs at
332
+ // once; at L2 the L3 rung keeps its own triggers, rules 2b/2/6).
333
+ if (
334
+ afterRoundsWithWarnings > 0 &&
335
+ warningRoundsAtLevel >= afterRoundsWithWarnings &&
336
+ openFixNowFinding &&
337
+ levelIndex(currentLevel) < levelIndex(runningCeiling(stopAt))
338
+ ) {
339
+ return finalizeEscalate('review_warnings', currentLevel, stopAt, l3Mode, l3RungAlreadyUsed);
340
+ }
240
341
  // Rule 2: review rounds.
241
342
  if (reviewRoundsAtLevel >= reviewRoundsPerLevel && openFixNowFinding) {
242
343
  return finalizeEscalate('review_rounds', currentLevel, stopAt, l3Mode, l3RungAlreadyUsed);
@@ -155,7 +155,7 @@ export function buildQuestionPayload(ids, cfg = {}) {
155
155
  /** @type {Record<string, {type: 'choice'|'score'|'noul', instructions: string, criteria: Record<string, string> | string[]}>} */
156
156
  const out = {};
157
157
  for (const id of ids) {
158
- out[id] = toWire(renderQuestion(id, cfg));
158
+ out[id] = toWire(id, renderQuestion(id, cfg));
159
159
  }
160
160
  return out;
161
161
  }
@@ -164,11 +164,34 @@ export function buildQuestionPayload(ids, cfg = {}) {
164
164
  * Jev's wire shape: a `score` question takes `criteria` as a LIST whose index is the score (an
165
165
  * object keyed `0..n` is refused with 422 "Input should be a valid list"); `choice` and `noul`
166
166
  * keep the keyed object. The answer's `legend`/`probabilities` come back keyed `"0".."n"`.
167
+ *
168
+ * The list index IS the score, so the (merged) keys must be exactly the integers `0..n`. A gap, a
169
+ * start at 1 or a non-integer key would otherwise shift every label onto the wrong score without a
170
+ * word — so this throws instead, naming the question id and the keys (never the label text).
171
+ * @param {string} id
167
172
  * @param {{type: 'choice'|'score'|'noul', instructions: string, criteria: Record<string, string>}} q
168
173
  * @returns {{type: 'choice'|'score'|'noul', instructions: string, criteria: Record<string, string> | string[]}}
174
+ * @throws {TypeError} for a score question whose criteria keys are not exactly `0..n`.
169
175
  */
170
- function toWire(q) {
176
+ export function toWire(id, q) {
171
177
  if (q.type !== 'score') return q;
172
- const keys = Object.keys(q.criteria).sort((a, b) => Number(a) - Number(b));
173
- return { ...q, criteria: keys.map((k) => q.criteria[k]) };
178
+ const keys = Object.keys(q.criteria);
179
+ const notIntegers = keys.filter((k) => !/^(0|[1-9]\d*)$/.test(k));
180
+ const present = new Set(keys);
181
+ const missing = [];
182
+ // n integer keys are exactly 0..n-1 iff none of 0..n-1 is missing.
183
+ for (let i = 0; i < keys.length - notIntegers.length; i += 1) {
184
+ if (!present.has(String(i))) missing.push(String(i));
185
+ }
186
+ if (notIntegers.length > 0 || missing.length > 0) {
187
+ const problems = [
188
+ ...(missing.length > 0 ? [`missing ${missing.join(', ')}`] : []),
189
+ ...(notIntegers.length > 0 ? [`not an integer: ${notIntegers.map((k) => JSON.stringify(k)).join(', ')}`] : []),
190
+ ];
191
+ throw new TypeError(
192
+ `score question ${JSON.stringify(id)}: criteria keys must be exactly the integers 0..n ` +
193
+ `(check system1.criteria_extra.${id}.criteria); got keys ${keys.map((k) => JSON.stringify(k)).join(', ')}: ${problems.join('; ')}`,
194
+ );
195
+ }
196
+ return { ...q, criteria: keys.map((_, i) => q.criteria[String(i)]) };
174
197
  }
@@ -6,6 +6,9 @@
6
6
  * - the isolation probe (O4, §0.6.5): the closed-book reviewer argv, run with cwd = the project
7
7
  * itself (the worst case), asked to echo the first line of its `CLAUDE.md`/`AGENTS.md` — the
8
8
  * line appearing in its answer FAILs; the CLI rejecting the closed-book flag combination FAILs;
9
+ * an openai L2 FAILs unrun with `CODEX_CLOSED_BOOK_REFUSAL` (B32: Codex always has a shell), and
10
+ * so does the reviewer/s2 ping of an openai level; with `review.allow_open_book_codex: true` the
11
+ * pings run and the isolation probe runs as before, its rows WARN with the open-book warning;
9
12
  * - the path-deny probe (C4, §0.6.7): a coder asked to print a canary file under
10
13
  * `~/.code-forge/runs/` (a denied path) — the canary appearing in its answer WARNs
11
14
  * `<cli>: path deny rules not honoured`;
@@ -19,6 +22,7 @@
19
22
  import { randomBytes } from 'node:crypto';
20
23
  import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
21
24
  import path from 'node:path';
25
+ import { CODEX_CLOSED_BOOK_REFUSAL, closedBookRefused, needsClosedBook, OPEN_BOOK_CODEX_WARNING, openBookCodexAllowed } from '../config/closed-book.mjs';
22
26
  import { resolveLevel } from '../config/known-ids.mjs';
23
27
  import { buildCodexArgv, coderFlagArgv, RULES_FILE_NAME, SANDBOX_TMP_EXCLUSIONS } from '../engines/builders/codex.mjs';
24
28
  import { isInsideRealCodexHome, isSessionCodexHome, removeCodexHome, renderCodexRules } from '../engines/codex-home.mjs';
@@ -77,7 +81,8 @@ export function configuredProviders(cfg) {
77
81
  function withoutFallback(cfg) {
78
82
  /** @type {Record<string, any>} */
79
83
  const levels = {};
80
- for (const [name, level] of Object.entries(cfg.levels ?? {})) levels[name] = { ...level, fallback: [] };
84
+ // `levels.coder_floor_docs` (B34) is a level name, not a level: kept as it is
85
+ for (const [name, level] of Object.entries(cfg.levels ?? {})) levels[name] = level !== null && typeof level === 'object' ? { ...level, fallback: [] } : level;
81
86
  return { ...cfg, levels };
82
87
  }
83
88
 
@@ -198,6 +203,10 @@ export async function pingRoles(ctx, missingProviders) {
198
203
  rows.push(row(`ping.${role}`, 'FAIL', label, 'ping=skipped(cli-missing)'));
199
204
  continue;
200
205
  }
206
+ if (closedBookRefused(resolvedProvider(ctx.cfg, /** @type {'L0'|'L1'|'L2'|'L3'} */ (level)), role, openBookCodexAllowed(ctx.cfg))) {
207
+ rows.push(row(`ping.${role}`, 'FAIL', label, CODEX_CLOSED_BOOK_REFUSAL)); // B32: the spawner would refuse it
208
+ continue;
209
+ }
201
210
  const res = await session(ctx, {
202
211
  role,
203
212
  level,
@@ -247,6 +256,7 @@ async function reviewerInProject(ctx, prompt) {
247
256
  if (level.effort) params.effort = level.effort;
248
257
  if (cli === 'claude') params.maxBudgetUsd = PROBE_BUDGET_USD;
249
258
  if (cli === 'codex') params.outPath = outPath;
259
+ if (cli === 'codex' && openBookCodexAllowed(ctx.cfg)) params.allowOpenBook = true; // B32 opt-in
250
260
  const built = buildArgv(/** @type {any} */ (params));
251
261
  const argv = [binOf(ctx, cli), ...built.argv.slice(1)];
252
262
  /** @type {NodeJS.ProcessEnv} */
@@ -267,23 +277,55 @@ async function reviewerInProject(ctx, prompt) {
267
277
  }
268
278
  }
269
279
 
280
+ /**
281
+ * @typedef {'info' | 'skipped-402' | 'rejected' | 'saw-doc' | 'clean'} IsolationKind - what the
282
+ * isolation probe found, so callers map rows by kind and never by parsing a row's text.
283
+ */
284
+
285
+ /**
286
+ * The provider a level resolves to, through `resolveLevel` (the source `spawnSession` and
287
+ * `reviewerInProject` use); `undefined` when the level does not resolve.
288
+ * @param {Record<string, any>} cfg @param {'L0'|'L1'|'L2'|'L3'} level
289
+ * @returns {string | undefined}
290
+ */
291
+ function resolvedProvider(cfg, level) {
292
+ try {
293
+ return resolveLevel(cfg, level).provider;
294
+ } catch {
295
+ return undefined;
296
+ }
297
+ }
298
+
270
299
  /**
271
300
  * The isolation probe (reviewer builder, closed-book, cwd = the project).
301
+ * B32: Codex has no no-tools mode, so an openai L2 reviewer is not closed-book — FAIL, unrun. With
302
+ * the opt-in `review.allow_open_book_codex: true` the probe runs as before: an INFO, a 402 skip and
303
+ * a CLI rejection (FAIL) keep their rows; a clean run and a run that saw the project doc become
304
+ * WARN, the detail prefixed with the open-book warning.
272
305
  * @param {ProbeCtx} ctx @returns {Promise<Row[]>}
273
306
  */
274
307
  export async function probeIsolation(ctx) {
308
+ const l2Provider = resolvedProvider(ctx.cfg, 'L2');
309
+ if (closedBookRefused(l2Provider, 'reviewer', openBookCodexAllowed(ctx.cfg))) return [row('isolation', 'FAIL', 'isolation', CODEX_CLOSED_BOOK_REFUSAL)];
310
+ const { kind, row: found } = await isolationRow(ctx);
311
+ if (!needsClosedBook(l2Provider, 'reviewer') || (kind !== 'clean' && kind !== 'saw-doc')) return [found];
312
+ return [row(found.id, 'WARN', found.label, `${OPEN_BOOK_CODEX_WARNING}; ${found.detail}`)];
313
+ }
314
+
315
+ /** @param {ProbeCtx} ctx @returns {Promise<{kind: IsolationKind, row: Row}>} the isolation probe's own row and its kind. */
316
+ async function isolationRow(ctx) {
275
317
  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)')];
318
+ if (doc === null) return { kind: 'info', row: row('isolation', 'INFO', 'isolation', 'skipped (the project has no CLAUDE.md or AGENTS.md to leak)') };
277
319
  const res = await reviewerInProject(
278
320
  ctx,
279
321
  '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
322
  );
281
- if (skipped402(res)) return [row('isolation', 'WARN', 'isolation', 'probe=skipped(402)')];
323
+ if (skipped402(res)) return { kind: 'skipped-402', row: row('isolation', 'WARN', 'isolation', 'probe=skipped(402)') };
282
324
  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'})`)];
325
+ return { kind: 'rejected', row: row('isolation', 'FAIL', 'isolation', `the CLI rejected the closed-book session (${res.status}: ${res.reason ?? 'unknown'})`) };
284
326
  }
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`)];
327
+ if (answerText(res).includes(doc.line)) return { kind: 'saw-doc', row: row('isolation', 'FAIL', 'isolation', `the reviewer saw the project's ${doc.file}`) };
328
+ return { kind: 'clean', row: row('isolation', 'OK', 'isolation', `no project doc visible to the reviewer`) };
287
329
  }
288
330
 
289
331
  /**
@@ -42,6 +42,7 @@
42
42
 
43
43
  import { FORBIDDEN, mergeForbidden, renderForClaude } from '../../util/forbidden.mjs';
44
44
  import { flattenRuleStrings, hasAnyRenderedRule } from '../render-rules.mjs';
45
+ import { assertEffortForProvider } from '../efforts.mjs';
45
46
  import { assertBaseParams } from './validate-params.mjs';
46
47
 
47
48
  export const CLI = 'claude';
@@ -58,7 +59,7 @@ export const CLI = 'claude';
58
59
  * @typedef {object} ClaudeBuildParams
59
60
  * @property {"coder"|"reviewer"|"judge"|"s2"|"author"|"facts"} role
60
61
  * @property {string} model - the resolved model id (already picked by `resolveLevel`, B1).
61
- * @property {string} [effort]
62
+ * @property {string} [effort] - one of `PROVIDER_EFFORTS.anthropic` (`../efforts.mjs`) when given.
62
63
  * @property {string} promptPath - the brief file (coder: passed as a pointer) or the packet file
63
64
  * (closed-book: returned as `stdinFile`, its content is piped to stdin by the spawner).
64
65
  * @property {string} cwd - the project tree (coder) or an empty isolated temp dir (closed-book).
@@ -220,6 +221,8 @@ function buildClosedBookArgv(params) {
220
221
  */
221
222
  export function buildClaudeArgv(params) {
222
223
  assertBaseParams('buildClaudeArgv', params);
224
+ // B29: `--effort` takes the closed list `claude --help` prints (shared with `validateConfig`).
225
+ assertEffortForProvider('buildClaudeArgv', 'anthropic', params.effort);
223
226
  return params.role === 'coder' ? buildCoderArgv(params) : buildClosedBookArgv(params);
224
227
  }
225
228
 
@@ -8,7 +8,12 @@
8
8
  * caller reads Codex's last message from it and deletes it afterwards (the builder never
9
9
  * creates the file; Codex does).
10
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
11
+ * - `reviewer`/`judge`/`s2`/`author` are REFUSED (B32, issue #2): Codex 0.155.1 has no no-tools
12
+ * mode (`-s read-only` still gives the model a shell that reads any absolute path), so the
13
+ * build throws {@link CODEX_CLOSED_BOOK_REFUSAL} (`code: 'closed-book'`) unless the caller passes
14
+ * `allowOpenBook: true` (opt-in `review.allow_open_book_codex`), which builds the read-only
15
+ * argv below. See `../../config/closed-book.mjs`.
16
+ * - `facts` (read-only tools by design): the last argv token is the literal
12
17
  * `-`, which the pinned help (`codex exec [OPTIONS] [PROMPT]`: "If not provided as an argument
13
18
  * (or if `-` is used), instructions are read from stdin") defines as "read the prompt from
14
19
  * stdin". `stdinFile === promptPath` names the packet file whose CONTENT the spawner (B9)
@@ -17,14 +22,14 @@
17
22
  * it is documented and does not depend on stdin-is-piped detection.
18
23
  *
19
24
  * **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
25
+ * run this module's `facts` argv (B32: the only stdin-fed Codex role left) with `stdinFile` piped in, the packet carrying a unique canary
21
26
  * and "reply with the canary"; PASS when the file at `outPath` contains the canary, FAIL
22
27
  * (`codex: closed-book packet not delivered`) otherwise.
23
28
  *
24
29
  * Unlike Claude, the flag table gives Codex only TWO command shapes: coder, and "reviewer / judge
25
30
  * / 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.
31
+ * plan's flag table, so `facts` here is simply routed through the same closed-book branch the
32
+ * other four roles used before B32 refused them.
28
33
  *
29
34
  * **Execpolicy rules (B4.1, §0.6.3, [A19]).** Codex 0.155.1 `exec` has no flag and no `-c` key
30
35
  * naming a rules file; it loads the "user" rules from `$CODEX_HOME/rules/*.rules` (`--ignore-rules`
@@ -58,7 +63,9 @@ import { randomBytes } from 'node:crypto';
58
63
  import { mergeForbidden, renderForCodex } from '../../util/forbidden.mjs';
59
64
  import { CODEX_RULES_FILE_NAME, defaultCodexHome, isInside, prepareCodexHome } from '../codex-home.mjs';
60
65
  import { hasAnyRenderedRule } from '../render-rules.mjs';
66
+ import { assertEffortForProvider, PROVIDER_EFFORTS } from '../efforts.mjs';
61
67
  import { assertBaseParams } from './validate-params.mjs';
68
+ import { CODEX_CLOSED_BOOK_REFUSAL, isNoToolRole } from '../../config/closed-book.mjs';
62
69
 
63
70
  export const CLI = 'codex';
64
71
 
@@ -85,8 +92,11 @@ export const SANDBOX_TMP_EXCLUSIONS = Object.freeze([
85
92
  */
86
93
  export const CODER_APPROVAL_POLICY = Object.freeze(['-c', 'approval_policy="never"']);
87
94
 
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']);
95
+ /**
96
+ * The `model_reasoning_effort` values Codex's TOML config is willing to receive from this builder —
97
+ * re-exported from the shared per-provider table (B29), which `validateConfig` also reads.
98
+ */
99
+ export const VALID_EFFORTS = PROVIDER_EFFORTS.openai;
90
100
 
91
101
  /**
92
102
  * @typedef {{id: string, patterns: string[][], decision: "forbidden", enforced: boolean, description: string}} RenderedCodexEntry
@@ -108,6 +118,8 @@ export const VALID_EFFORTS = Object.freeze(['minimal', 'low', 'medium', 'high'])
108
118
  * @property {ReadonlyArray<RenderedCodexEntry>} [renderedForbidden] - coder role only; defaults to
109
119
  * `renderForCodex(mergeForbidden())` (B4.2: the coder-only entries too; a `contains` entry
110
120
  * renders 0 patterns, so it is left to the pre-spawn check and the transcript grep).
121
+ * @property {boolean} [allowOpenBook] - B32 opt-in: `true` builds reviewer/judge/s2/author with the
122
+ * read-only argv instead of throwing {@link CODEX_CLOSED_BOOK_REFUSAL} (that session can read files).
111
123
  * @property {string} [codexHome] - coder role only: the per-session Codex home to create (absolute,
112
124
  * must not exist as the real `~/.codex` or inside it); defaults to `defaultCodexHome()` under the
113
125
  * run temp root.
@@ -140,9 +152,7 @@ function effectiveOutPath(outPath, role) {
140
152
  * @throws {TypeError} unless `effort` is `undefined` or one of {@link VALID_EFFORTS}.
141
153
  */
142
154
  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
- }
155
+ assertEffortForProvider('buildCodexArgv', 'openai', effort);
146
156
  }
147
157
 
148
158
  /**
@@ -212,8 +222,8 @@ function buildCoderArgv(params) {
212
222
  function buildClosedBookArgv(params) {
213
223
  const { model, effort, promptPath, cwd, schemaPath } = params;
214
224
  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.
225
+ // See `builders/claude.mjs`'s identical cast: the dispatch below never reaches this branch with
226
+ // role 'coder'; a no-tools role reaches it only with `allowOpenBook: true` (B32).
217
227
  const role = /** @type {"reviewer"|"judge"|"s2"|"author"|"facts"} */ (params.role);
218
228
  const outPath = effectiveOutPath(params.outPath, role);
219
229
  const argv = ['codex', 'exec', '-m', model];
@@ -238,5 +248,10 @@ function buildClosedBookArgv(params) {
238
248
  */
239
249
  export function buildCodexArgv(params) {
240
250
  assertBaseParams('buildCodexArgv', params);
251
+ if (isNoToolRole(params.role) && params.allowOpenBook !== true) {
252
+ // B32: no Codex flag removes the shell, so a no-tools role is built only when the caller
253
+ // passes `allowOpenBook: true` (the config's `review.allow_open_book_codex` opt-in).
254
+ throw Object.assign(new Error(CODEX_CLOSED_BOOK_REFUSAL), { code: 'closed-book' });
255
+ }
241
256
  return params.role === 'coder' ? buildCoderArgv(params) : buildClosedBookArgv(params);
242
257
  }
@@ -36,6 +36,7 @@
36
36
 
37
37
  import { mergeForbidden, renderForGrok } from '../../util/forbidden.mjs';
38
38
  import { flattenRuleStrings, hasAnyRenderedRule } from '../render-rules.mjs';
39
+ import { assertEffortForProvider } from '../efforts.mjs';
39
40
  import { assertBaseParams } from './validate-params.mjs';
40
41
 
41
42
  export const CLI = 'grok';
@@ -71,9 +72,8 @@ export const GROK_ALL_BUILTIN_TOOLS = Object.freeze(['Bash', 'Read', 'Write', 'E
71
72
  * @throws {TypeError} unless `effort` is `undefined` or a non-empty string.
72
73
  */
73
74
  function assertValidEffort(effort) {
74
- if (effort !== undefined && (typeof effort !== 'string' || effort.length === 0)) {
75
- throw new TypeError(`buildGrokArgv: effort must be a non-empty string when given, got ${JSON.stringify(effort)}`);
76
- }
75
+ // xai has no closed effort list (`PROVIDER_EFFORTS.xai === null`): any non-empty string (B29).
76
+ assertEffortForProvider('buildGrokArgv', 'xai', effort);
77
77
  }
78
78
 
79
79
  /**
@@ -0,0 +1,109 @@
1
+ /**
2
+ * The one source of the reasoning-effort values each provider's CLI accepts (B29, issue #2). The
3
+ * argv builders (`builders/*.mjs`) refuse a bad effort at spawn time and `validateConfig` refuses
4
+ * it at config time — both read THIS table, so the two can never disagree again (the bug: validate
5
+ * accepted `effort: xhigh` on an openai level, then the codex builder threw at spawn).
6
+ *
7
+ * A provider's entry is one of:
8
+ * - a non-empty list: the closed set of accepted values;
9
+ * - `null`: the CLI takes an effort but publishes no closed list, so any non-empty string passes;
10
+ * - an empty list: the provider has no effort concept — an effort set there is refused (the
11
+ * builder throws, `validateConfig` reports an error). No current provider uses this form.
12
+ *
13
+ * Sources: anthropic — `claude --help` 2.1.282 (`--effort <level>`: low, medium, high, xhigh, max);
14
+ * openai — the `model_reasoning_effort` subset the codex builder has always allowed (it lands in a
15
+ * `-c` TOML override, so it must stay a closed list); xai — `grok --help` 1.0.34 lists
16
+ * `--reasoning-effort <EFFORT>` with no value list.
17
+ *
18
+ * **No raw effort in a message (fix round 1).** An effort is user config and could be a pasted
19
+ * secret, so {@link displayEffort} echoes it only when it is a word from the closed lists below
20
+ * (a constant vocabulary); anything else renders as `(unrecognised value)`.
21
+ */
22
+
23
+ /** @typedef {"anthropic" | "openai" | "xai"} Provider */
24
+
25
+ /** @type {Readonly<Record<Provider, ReadonlyArray<string> | null>>} */
26
+ export const PROVIDER_EFFORTS = Object.freeze({
27
+ anthropic: Object.freeze(['low', 'medium', 'high', 'xhigh', 'max']),
28
+ openai: Object.freeze(['minimal', 'low', 'medium', 'high']),
29
+ xai: null,
30
+ });
31
+
32
+ /**
33
+ * Every effort word any provider's closed list names — a constant vocabulary, so a value found
34
+ * here may be echoed in a message (it is not user data that could be a secret).
35
+ * @param {Readonly<Record<string, ReadonlyArray<string> | null>>} [table]
36
+ * @returns {ReadonlyArray<string>}
37
+ */
38
+ export function knownEffortWords(table = PROVIDER_EFFORTS) {
39
+ return Object.freeze([...new Set(Object.values(table).flatMap((list) => list ?? []))]);
40
+ }
41
+
42
+ const KNOWN_WORDS = knownEffortWords();
43
+
44
+ /**
45
+ * @param {unknown} effort
46
+ * @returns {string} `"xhigh"` (quoted) for a known effort word, `(unrecognised value)` otherwise.
47
+ */
48
+ export function displayEffort(effort) {
49
+ return typeof effort === 'string' && KNOWN_WORDS.includes(effort) ? `"${effort}"` : '(unrecognised value)';
50
+ }
51
+
52
+ /**
53
+ * `kind`: `"ok"`; `"invalid"` (not in the provider's closed list — `allowed` names the list);
54
+ * `"unsupported"` (the provider takes no effort); `"empty"` (free-form provider, value not a
55
+ * non-empty string); `"unknown-provider"` (not a table key — another check reports the provider).
56
+ * @typedef {{kind: "ok" | "invalid" | "unsupported" | "empty" | "unknown-provider", allowed: ReadonlyArray<string>}} EffortCheck
57
+ */
58
+
59
+ const NONE = Object.freeze(/** @type {string[]} */ ([]));
60
+
61
+ /**
62
+ * Checks one effort against one provider. `undefined` (no effort set) always passes.
63
+ * @param {unknown} provider
64
+ * @param {unknown} effort
65
+ * @param {Readonly<Record<string, ReadonlyArray<string> | null>>} [table]
66
+ * @returns {EffortCheck}
67
+ */
68
+ export function checkEffort(provider, effort, table = PROVIDER_EFFORTS) {
69
+ if (effort === undefined) {
70
+ return { kind: 'ok', allowed: NONE };
71
+ }
72
+ if (typeof provider !== 'string' || !Object.hasOwn(table, provider)) {
73
+ return { kind: 'unknown-provider', allowed: NONE };
74
+ }
75
+ const allowed = table[provider];
76
+ if (allowed === null) {
77
+ return { kind: typeof effort === 'string' && effort.length > 0 ? 'ok' : 'empty', allowed: NONE };
78
+ }
79
+ if (allowed.length === 0) {
80
+ return { kind: 'unsupported', allowed: NONE };
81
+ }
82
+ return { kind: typeof effort === 'string' && allowed.includes(effort) ? 'ok' : 'invalid', allowed };
83
+ }
84
+
85
+ /**
86
+ * The builders' guard: throws the builder-named TypeError for an effort `checkEffort` refuses.
87
+ * The effort is shown through {@link displayEffort} only. An unknown provider is not this guard's
88
+ * concern (each builder is called with its own fixed provider; `buildArgv` refuses unknown ones).
89
+ * @param {string} builderName - e.g. `"buildCodexArgv"`.
90
+ * @param {Provider} provider
91
+ * @param {unknown} effort
92
+ * @param {Readonly<Record<string, ReadonlyArray<string> | null>>} [table]
93
+ * @throws {TypeError}
94
+ */
95
+ export function assertEffortForProvider(builderName, provider, effort, table = PROVIDER_EFFORTS) {
96
+ const result = checkEffort(provider, effort, table);
97
+ const shown = displayEffort(effort);
98
+ switch (result.kind) {
99
+ case 'ok':
100
+ case 'unknown-provider':
101
+ return;
102
+ case 'invalid':
103
+ throw new TypeError(`${builderName}: effort must be one of ${result.allowed.join(', ')} when given, got ${shown}`);
104
+ case 'unsupported':
105
+ throw new TypeError(`${builderName}: provider ${provider} takes no effort, got ${shown}`);
106
+ default:
107
+ throw new TypeError(`${builderName}: effort must be a non-empty string when given, got ${shown}`);
108
+ }
109
+ }
@@ -131,3 +131,37 @@ export function verifyAck(output, expectedSha8, expectedLines, opts = {}) {
131
131
  }
132
132
  return { status: 'ok', ack };
133
133
  }
134
+
135
+ /**
136
+ * A `review-file` ticket id: 24 lowercase hex characters standing alone — not part of a longer hex
137
+ * run (a commit sha) and not inside a path or file name (no word, `.`, `/` or `-` touching it).
138
+ */
139
+ const TICKET_IN_LINE = /(?<![\w./-])[0-9a-f]{24}(?![\w/-]|\.\w)/;
140
+
141
+ /** @param {string} s */
142
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
143
+
144
+ /**
145
+ * The coder's final report must name the `review-file` ticket of every changed file (B36): a line
146
+ * that names the path and a ticket id, e.g. `reviewed src/a.mjs ticket 0123456789abcdef01234567`.
147
+ * A line counts for a file only when it names exactly ONE of the changed files (a line listing two
148
+ * paths counts for neither) and carries a ticket id outside that path (the path is blanked before
149
+ * the ticket is looked for). Fail closed: a file with no such line is missing, and the
150
+ * orchestrator treats the report as FAILED whatever its sentinel says
151
+ * (`skill/templates/coder-brief.md`).
152
+ * @param {string} report - the coder's final report (or its whole transcript).
153
+ * @param {string[]} files - the block's changed files, repo-root-relative.
154
+ * @returns {string[]} the files with no ticket id in the report, in the order given.
155
+ */
156
+ export function missingReviewTickets(report, files) {
157
+ if (typeof report !== 'string') throw new TypeError('missingReviewTickets: report must be a string');
158
+ const named = files.map((file) => ({ file, re: new RegExp(`(?<![\\w./-])${escapeRe(file)}(?![\\w/-]|\\.\\w)`, 'g') }));
159
+ /** @type {Set<string>} */
160
+ const ticketed = new Set();
161
+ for (const line of report.split('\n')) {
162
+ const hits = named.filter((n) => line.search(n.re) >= 0);
163
+ if (hits.length !== 1) continue;
164
+ if (TICKET_IN_LINE.test(line.replace(hits[0].re, ' '))) ticketed.add(hits[0].file);
165
+ }
166
+ return files.filter((file) => !ticketed.has(file));
167
+ }
@@ -12,6 +12,7 @@
12
12
  */
13
13
 
14
14
  import { reportErrorKind } from '../util/error-kind.mjs';
15
+ import { logWarning } from '../util/error-log.mjs';
15
16
  import { exec as realExec } from '../util/exec.mjs';
16
17
 
17
18
  // Long enough for a person to see and approve the 1Password prompt (Touch ID or password).
@@ -172,6 +173,8 @@ async function runOp(argv, exec, timeoutMs) {
172
173
  attempts += 1;
173
174
  res = await exec(argv, { timeoutMs });
174
175
  } while (res.result !== 'ok' && res.timedOut && attempts === 1);
176
+ // B37: a call that needed its retry is a recoverable problem worth a warning line
177
+ if (attempts > 1) await logWarning({ warning: 'op_retry', message: '1Password did not answer in time; the call was retried once' }).catch(() => null);
175
178
  return { res, attempts };
176
179
  }
177
180