@codedology/code-forge 0.3.2 → 0.5.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.
- package/CHANGELOG.md +85 -0
- package/README.md +88 -21
- package/bin/code-forge.mjs +6 -1
- package/docs/README.md +5 -4
- package/docs/reference/blocks.json +236 -1
- package/docs/reference/config.md +13 -1
- package/package.json +6 -2
- package/schema/code-forge.schema.json +26 -4
- package/skill/SKILL.md +8 -7
- package/skill/references/adapters/claude-code.md +5 -1
- package/skill/references/adapters/solo.md +1 -1
- package/skill/references/adapters/subprocess.md +1 -1
- package/skill/references/code.md +4 -3
- package/skill/references/continuity.md +4 -0
- package/skill/references/decisions.md +5 -2
- package/skill/references/ledger.md +10 -2
- package/skill/references/plan.md +12 -3
- package/skill/references/review.md +13 -6
- package/skill/references/security.md +3 -1
- package/skill/scripts/forge +1 -1
- package/skill/templates/coder-brief.md +36 -0
- package/skill/templates/plan.md +2 -0
- package/src/cli/block.mjs +51 -8
- package/src/cli/facts.mjs +16 -1
- package/src/cli/jev.mjs +114 -17
- package/src/cli/ledger.mjs +99 -8
- package/src/cli/logs.mjs +193 -58
- package/src/cli/plan.mjs +27 -11
- package/src/cli/report.mjs +15 -1
- package/src/cli/resolve.mjs +16 -1
- package/src/cli/review-file.mjs +10 -5
- package/src/cli/review.mjs +40 -9
- package/src/cli/run.mjs +109 -8
- package/src/config/closed-book.mjs +75 -0
- package/src/config/validate.mjs +128 -7
- package/src/decide/escalation.mjs +112 -11
- package/src/decide/questions.mjs +27 -4
- package/src/doctor/index.mjs +2 -1
- package/src/doctor/local.mjs +15 -0
- package/src/doctor/probes.mjs +48 -6
- package/src/engines/builders/claude.mjs +4 -1
- package/src/engines/builders/codex.mjs +26 -11
- package/src/engines/builders/grok.mjs +3 -3
- package/src/engines/efforts.mjs +109 -0
- package/src/engines/sentinel.mjs +34 -0
- package/src/keys/onepassword.mjs +3 -0
- package/src/ledger/report.mjs +66 -29
- package/src/ledger/spend.mjs +288 -0
- package/src/review/engine.mjs +57 -16
- package/src/review/fixloop.mjs +29 -6
- package/src/review/session-retry.mjs +69 -0
- package/src/review/triage.mjs +26 -1
- package/src/review/validate-review.mjs +6 -3
- package/src/session/author.mjs +6 -2
- package/src/session/facts.mjs +218 -53
- package/src/session/plan-check.mjs +71 -6
- package/src/session/plan-sections.mjs +98 -0
- package/src/session/spawn.mjs +373 -20
- package/src/state/block.mjs +13 -4
- package/src/state/config-snapshot.mjs +150 -0
- package/src/state/run.mjs +86 -0
- package/src/util/breadcrumbs.mjs +145 -0
- package/src/util/error-hint.mjs +3 -2
- package/src/util/error-kind.mjs +2 -2
- package/src/util/error-log.mjs +109 -18
- package/src/util/forbidden.mjs +3 -1
- package/src/util/known-fixes.json +1 -0
- package/src/util/known-fixes.mjs +153 -0
- package/src/util/locks.mjs +104 -0
- package/src/worker/engine.mjs +122 -40
- package/src/worker/loop.mjs +326 -78
- package/src/worker/queue.mjs +68 -7
package/src/cli/review.mjs
CHANGED
|
@@ -16,7 +16,9 @@
|
|
|
16
16
|
* default clause. A generated file lives in this command's temp root, never in the tree.
|
|
17
17
|
* 4. Flow, every step a `code-forge` child with a `GIT_*`-free env: `run start` (launches the
|
|
18
18
|
* worker) → `block open R-<stamp> --level L2 --base <sha> --owned <files…>` → `review-file`
|
|
19
|
-
* per file → `review-file --wait` per ticket
|
|
19
|
+
* per file (all enqueued first) → one `review-file --wait` per ticket, all together under ONE run
|
|
20
|
+
* deadline (`--max`, default 900 s for the whole review; a ticket pending at the deadline is
|
|
21
|
+
* reported `stopped: timeout`, exit 1), results announced on stderr as they finish → summary in file order →
|
|
20
22
|
* `block stop` (a review never closes a block) → `run end` unless `--keep-run`. The cleanup runs
|
|
21
23
|
* in a `finally`, on SIGINT/SIGTERM and after a timeout, and no worker outlives the command.
|
|
22
24
|
* Each cleanup step is independent (a failed or throwing step is reported on stderr and the
|
|
@@ -42,6 +44,7 @@ import { parseFlags } from '../state/cli-args.mjs';
|
|
|
42
44
|
import { assertRunId, newRunId, StateError } from '../state/paths.mjs';
|
|
43
45
|
import { processStartTime, readRun } from '../state/run.mjs';
|
|
44
46
|
import { exec } from '../util/exec.mjs';
|
|
47
|
+
import { semaphore } from '../util/locks.mjs';
|
|
45
48
|
import { writeSafe } from '../util/redact.mjs';
|
|
46
49
|
import { isAlive, listEntries, readStartTime, UNKNOWN_START_TIME } from '../util/reaper.mjs';
|
|
47
50
|
import { pidsDir, runRoot, setRunRoot, tmpBase } from '../util/tmp.mjs';
|
|
@@ -54,6 +57,8 @@ export const USAGE =
|
|
|
54
57
|
'usage: code-forge review [--base <ref>] [--files <path…>] [--acceptance <file> | --intent "<text>"] [--run <id>] [--max <seconds>] [--json] [--keep-run]\n';
|
|
55
58
|
|
|
56
59
|
export const DEFAULT_MAX_S = 900;
|
|
60
|
+
/** The most `review-file --wait` children that run at once. */
|
|
61
|
+
export const MAX_WAITERS = 8;
|
|
57
62
|
export const DEFAULT_CLAUSE = 'Review for correctness, security and test quality; no intent was stated.';
|
|
58
63
|
const GIT_TIMEOUT_MS = 30_000;
|
|
59
64
|
const STEP_TIMEOUT_MS = 120_000;
|
|
@@ -511,14 +516,40 @@ export async function runReview(args, deps = {}) {
|
|
|
511
516
|
const answer = lastJSON(res.stdout);
|
|
512
517
|
queued.push({ file, ticket: typeof answer?.ticket === 'string' ? answer.ticket : null, answer });
|
|
513
518
|
}
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
519
|
+
// Every file is enqueued above before any wait starts. One run deadline (`--max`, measured from
|
|
520
|
+
// here) covers all the waits, which run together: each `review-file --wait` gets the time still
|
|
521
|
+
// left, and a ticket still pending at the deadline is reported `stopped: timeout`. A result is
|
|
522
|
+
// announced on stderr as its file finishes; the table below keeps the file order.
|
|
523
|
+
// At most MAX_WAITERS `review-file --wait` children at once (a review of many files must not
|
|
524
|
+
// start one Node process per file); a wait that starts late still counts down to the same
|
|
525
|
+
// deadline, and one that starts after it is reported pending without a child.
|
|
526
|
+
const deadline = Date.now() + maxS * 1000;
|
|
527
|
+
const waiters = semaphore(MAX_WAITERS);
|
|
528
|
+
/** @type {Array<FileOutcome | undefined>} */
|
|
529
|
+
const byIndex = new Array(queued.length);
|
|
530
|
+
const finish = (/** @type {number} */ i, /** @type {FileOutcome} */ o) => {
|
|
531
|
+
byIndex[i] = o;
|
|
532
|
+
note(`${o.file} ${label(o)}`);
|
|
533
|
+
};
|
|
534
|
+
const settled = await Promise.allSettled(
|
|
535
|
+
queued.map(async (q, i) => {
|
|
536
|
+
if (q.ticket === null) {
|
|
537
|
+
finish(i, classify(q.file, { status: q.answer?.status ?? 'refused', reason: q.answer?.reason ?? q.answer?.status ?? 'not queued' }));
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
await waiters.run(async () => {
|
|
541
|
+
const left = deadline - Date.now();
|
|
542
|
+
if (left <= 0) {
|
|
543
|
+
finish(i, classify(q.file, { status: 'pending' }));
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
const res = await cf(['review-file', '--wait', q.ticket, '--max', `${Math.ceil(left / 1000)}s`], left + 60_000);
|
|
547
|
+
finish(i, classify(q.file, lastJSON(res.stdout)));
|
|
548
|
+
});
|
|
549
|
+
}),
|
|
550
|
+
);
|
|
551
|
+
for (const s of settled) if (s.status === 'rejected') throw s.reason;
|
|
552
|
+
for (const o of byIndex) outcomes.push(/** @type {FileOutcome} */ (o));
|
|
522
553
|
const allOk = outcomes.every((o) => o.result === 'approved' || o.result === 'unchanged');
|
|
523
554
|
code = allOk ? 0 : 1;
|
|
524
555
|
if (json) out(`${JSON.stringify({ ok: allOk, base: base.sha, base_from: base.from, run: runId, block: blockId, files: outcomes, skipped, totals: totals(outcomes) })}\n`);
|
package/src/cli/run.mjs
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
*
|
|
5
5
|
* run start [--cwd <dir>] [--run <id>] [--engine <e>] [--worker-pid <pid>]
|
|
6
6
|
* run start --reattach --run <id> [--worker-pid <pid>]
|
|
7
|
-
* run status --run <id>
|
|
7
|
+
* run status --run <id> (B33: includes spent_usd / budget_usd / unknown_usd_sessions)
|
|
8
|
+
* run reload --run <id>
|
|
8
9
|
* run end --run <id>
|
|
9
10
|
*
|
|
10
11
|
* B11: from the CLI (the default export), `run start` without `--worker-pid` sweeps dead run
|
|
@@ -12,18 +13,31 @@
|
|
|
12
13
|
* queue and pins it; `run start --reattach` without `--worker-pid` re-pins the live worker the
|
|
13
14
|
* queue announces (`repinWorker`). Called as `runRun(args, deps)` without `deps.startWorker` (the
|
|
14
15
|
* state tests), no worker is launched and none is pinned unless `--worker-pid` is given.
|
|
16
|
+
*
|
|
17
|
+
* B35 `run reload`: re-read and validate the workspace's `.code-forge.yml` and replace the run's
|
|
18
|
+
* config snapshot (`reloadRun`) — no `block stop` / `run end` / `run start` cycle. An invalid
|
|
19
|
+
* config or a change to a key that is fixed for the run is refused with nothing changed. It prints
|
|
20
|
+
* the changed key PATHS (never values); queued review tickets keep the config they were enqueued
|
|
21
|
+
* with, new tickets use the new one, and the worker picks it up on its next ticket (it reads the
|
|
22
|
+
* run record per ticket — no restart).
|
|
15
23
|
*/
|
|
16
24
|
|
|
25
|
+
import { readdirSync } from 'node:fs';
|
|
17
26
|
import path from 'node:path';
|
|
18
|
-
import { loadProjectConfig } from '../config/load.mjs';
|
|
19
|
-
import {
|
|
27
|
+
import { DEFAULT_CONFIG_FILENAME, loadProjectConfig } from '../config/load.mjs';
|
|
28
|
+
import { loadSeenInCache } from '../config/refresh.mjs';
|
|
29
|
+
import { validateConfig } from '../config/validate.mjs';
|
|
30
|
+
import { budgetUsdOf, runSpend } from '../ledger/spend.mjs';
|
|
31
|
+
import { appendRow, readAllRows } from '../ledger/write.mjs';
|
|
20
32
|
import { intFlag, parseFlags } from '../state/cli-args.mjs';
|
|
21
33
|
import { StateError } from '../state/paths.mjs';
|
|
22
|
-
import { endRun, processStartTime, readRun, reattachWorker, startRun, stopPinnedWorker } from '../state/run.mjs';
|
|
34
|
+
import { endRun, processStartTime, readRun, reattachWorker, reloadRun, startRun, stopPinnedWorker } from '../state/run.mjs';
|
|
23
35
|
import { writeSafe } from '../util/redact.mjs';
|
|
24
36
|
import { sweepRoots, tmpBase } from '../util/tmp.mjs';
|
|
25
37
|
import { launchWorker, repinWorker } from '../worker/loop.mjs';
|
|
26
|
-
import {
|
|
38
|
+
import { pendingTickets, queueDir } from '../worker/queue.mjs';
|
|
39
|
+
import { repoRootOf, WorkerError } from '../worker/ticket.mjs';
|
|
40
|
+
import { hasCliOnPath } from './validate.mjs';
|
|
27
41
|
|
|
28
42
|
/**
|
|
29
43
|
* `project.slug` from config, else the workspace directory name as a ledger slug. The configured
|
|
@@ -40,6 +54,25 @@ export function slugFor(cfg, workspace) {
|
|
|
40
54
|
return derived || 'project';
|
|
41
55
|
}
|
|
42
56
|
|
|
57
|
+
/**
|
|
58
|
+
* B33: the run's spend so far (estimated USD from its ledger rows, open blocks included) and its
|
|
59
|
+
* `budget.usd` (read from the workspace config now — the value the next session is checked
|
|
60
|
+
* against; null when unset or the config does not load).
|
|
61
|
+
* @param {Record<string, any>} record
|
|
62
|
+
* @returns {Promise<{spent_usd: number, budget_usd: number | null, unknown_usd_sessions: number}>}
|
|
63
|
+
*/
|
|
64
|
+
async function spendOf(record) {
|
|
65
|
+
const { usd, unknown } = runSpend(await readAllRows(record.project), record.run_id);
|
|
66
|
+
let budget = null;
|
|
67
|
+
try {
|
|
68
|
+
const loaded = await loadProjectConfig(record.workspace);
|
|
69
|
+
budget = loaded.ok ? budgetUsdOf(loaded.config) : null;
|
|
70
|
+
} catch {
|
|
71
|
+
budget = null;
|
|
72
|
+
}
|
|
73
|
+
return { spent_usd: usd, budget_usd: budget, unknown_usd_sessions: unknown };
|
|
74
|
+
}
|
|
75
|
+
|
|
43
76
|
/** @param {string} slug */
|
|
44
77
|
const ledgerWriter = (slug) => (/** @type {Record<string, any>} */ row) => appendRow(row, { slug });
|
|
45
78
|
|
|
@@ -82,7 +115,7 @@ export async function runRun(args, deps = {}) {
|
|
|
82
115
|
const out = (/** @type {string} */ s) => writeSafe(stdout, s);
|
|
83
116
|
const err = (/** @type {string} */ s) => writeSafe(stderr, s);
|
|
84
117
|
const [sub, ...rest] = args;
|
|
85
|
-
if (!['start', 'status', 'end'].includes(sub)) {
|
|
118
|
+
if (!['start', 'status', 'reload', 'end'].includes(sub)) {
|
|
86
119
|
err(USAGE);
|
|
87
120
|
return 2;
|
|
88
121
|
}
|
|
@@ -133,9 +166,14 @@ export async function runRun(args, deps = {}) {
|
|
|
133
166
|
if (sub === 'status' && runId) {
|
|
134
167
|
const record = await readRun(runId);
|
|
135
168
|
const { run_id, status, engine, blocks, orphans } = record;
|
|
136
|
-
|
|
169
|
+
const spend = await spendOf(record);
|
|
170
|
+
out(`${JSON.stringify({ run_id, status, engine, worker_pinned: Boolean(record.worker), blocks, orphans, ...spend }, null, 2)}\n`);
|
|
171
|
+
// on stderr, so stdout stays one JSON document
|
|
172
|
+
const n = spend.unknown_usd_sessions;
|
|
173
|
+
if (n > 0) err(`${n} session${n === 1 ? ' has' : 's have'} no known price; the budget does not count ${n === 1 ? 'it' : 'them'}\n`);
|
|
137
174
|
return 0;
|
|
138
175
|
}
|
|
176
|
+
if (sub === 'reload' && runId) return await reloadVerb(runId, { out, err });
|
|
139
177
|
if (sub === 'end' && runId) {
|
|
140
178
|
const { project } = await readRun(runId);
|
|
141
179
|
const record = await endRun({ runId, writeRow: ledgerWriter(project), stopWorker, probe });
|
|
@@ -150,8 +188,71 @@ export async function runRun(args, deps = {}) {
|
|
|
150
188
|
return 2;
|
|
151
189
|
}
|
|
152
190
|
|
|
191
|
+
/**
|
|
192
|
+
* `run reload --run <id>`: load + validate the workspace config, pin the queue's pending tickets
|
|
193
|
+
* to the old snapshot and store the new one (`reloadRun`). Exit 0 on a reload or no change, 1 on a
|
|
194
|
+
* config that does not load or validate, or on a refused key.
|
|
195
|
+
* @param {string} runId
|
|
196
|
+
* @param {{out: (s: string) => void, err: (s: string) => void}} io
|
|
197
|
+
* @returns {Promise<number>}
|
|
198
|
+
*/
|
|
199
|
+
async function reloadVerb(runId, { out, err }) {
|
|
200
|
+
const { project, workspace, status } = await readRun(runId);
|
|
201
|
+
if (status !== 'active') throw new StateError('run-ended', `run ${runId} has ended`);
|
|
202
|
+
const loaded = await loadProjectConfig(workspace);
|
|
203
|
+
if (!loaded.ok || !loaded.config) {
|
|
204
|
+
// never the parser's message: it can quote a line of the file
|
|
205
|
+
if (loaded.error === 'not-found') err(`run reload: no ${DEFAULT_CONFIG_FILENAME} in the workspace — nothing changed\n`);
|
|
206
|
+
else {
|
|
207
|
+
const line = loaded.error === 'parse-error' ? /\(line (\d+), column \d+\)$/.exec(loaded.message ?? '')?.[1] : undefined;
|
|
208
|
+
err(`run reload: the config could not be parsed (${DEFAULT_CONFIG_FILENAME}${line ? `:${line}` : ''}) — nothing changed\n`);
|
|
209
|
+
}
|
|
210
|
+
return 1;
|
|
211
|
+
}
|
|
212
|
+
const result = validateConfig(loaded.config, { seenInCache: await loadSeenInCache(), hasCliOnPath });
|
|
213
|
+
if (!result.valid) {
|
|
214
|
+
// rule ids and key paths only: a validator message may quote a value
|
|
215
|
+
const lines = result.errors.map((e) => ` [${e.rule}]${typeof e.path === 'string' && e.path.length > 0 ? ` ${e.path}` : ''}\n`);
|
|
216
|
+
err(`run reload: ${DEFAULT_CONFIG_FILENAME} is invalid (${result.errors.length} error${result.errors.length === 1 ? '' : 's'}) — nothing changed; run code-forge validate\n${lines.join('')}`);
|
|
217
|
+
return 1;
|
|
218
|
+
}
|
|
219
|
+
/** @type {string | null} */
|
|
220
|
+
let repoRoot = null;
|
|
221
|
+
try {
|
|
222
|
+
repoRoot = await repoRootOf(workspace);
|
|
223
|
+
} catch (thrown) {
|
|
224
|
+
if (!(thrown instanceof WorkerError && thrown.code === 'no-repo')) throw thrown;
|
|
225
|
+
// no repository: there is no review queue to pin
|
|
226
|
+
}
|
|
227
|
+
const queueRoot = repoRoot;
|
|
228
|
+
const readPending = () => {
|
|
229
|
+
if (queueRoot === null) return [];
|
|
230
|
+
try {
|
|
231
|
+
readdirSync(queueDir(queueRoot));
|
|
232
|
+
} catch (thrown) {
|
|
233
|
+
if (thrown?.code === 'ENOENT') return [];
|
|
234
|
+
throw new StateError('queue-unreadable', 'cannot read the review queue; nothing changed');
|
|
235
|
+
}
|
|
236
|
+
return pendingTickets(queueRoot);
|
|
237
|
+
};
|
|
238
|
+
const reloaded = await reloadRun({ runId, config: loaded.config, readPending, effectiveSlug: slugFor(loaded.config, workspace), writeRow: ledgerWriter(project) });
|
|
239
|
+
if (reloaded.changed !== null && reloaded.changed.length === 0) {
|
|
240
|
+
out(`run ${runId}: no config change\n`);
|
|
241
|
+
return 0;
|
|
242
|
+
}
|
|
243
|
+
if (reloaded.changed === null) {
|
|
244
|
+
out(`run ${runId}: config snapshot recorded (the run predates run reload; no earlier snapshot to compare)\n`);
|
|
245
|
+
} else {
|
|
246
|
+
out(`run ${runId}: config reloaded · ${reloaded.changed.length} key${reloaded.changed.length === 1 ? '' : 's'} changed\n`);
|
|
247
|
+
for (const key of reloaded.changed) out(` ${key}\n`);
|
|
248
|
+
}
|
|
249
|
+
out(`queued review tickets (${reloaded.pinned}) keep the config they were enqueued with; new tickets use the new config\n`);
|
|
250
|
+
if (reloaded.rowError !== null) err(`run reload: WARN the run.reload ledger row could not be written (${reloaded.rowError}); the run record holds the new config\n`);
|
|
251
|
+
return 0;
|
|
252
|
+
}
|
|
253
|
+
|
|
153
254
|
const USAGE =
|
|
154
|
-
'usage: code-forge run start [--cwd <dir>] [--run <id>] [--engine <e>] [--worker-pid <pid>] | start --reattach --run <id> [--worker-pid <pid>] | status --run <id> | end --run <id>\n';
|
|
255
|
+
'usage: code-forge run start [--cwd <dir>] [--run <id>] [--engine <e>] [--worker-pid <pid>] | start --reattach --run <id> [--worker-pid <pid>] | status --run <id> | reload --run <id> | end --run <id>\n';
|
|
155
256
|
|
|
156
257
|
/** @param {string[]} args @returns {Promise<number>} */
|
|
157
258
|
export default async function run(args) {
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which providers may run a closed-book session (B32, issue #2; security.md §4).
|
|
3
|
+
*
|
|
4
|
+
* A closed-book session (reviewer, judge, S2, author) must see the packet and nothing else. Claude
|
|
5
|
+
* runs it with `--tools ""` and Grok with `--deny "*"`: no tools at all. Codex 0.155.1 has no such
|
|
6
|
+
* mode. Its `exec` sandbox choices are `read-only | workspace-write | danger-full-access`, and
|
|
7
|
+
* `read-only` still hands the model a shell that can read any absolute path (the doctor isolation
|
|
8
|
+
* probe FAILs with an openai L2 reviewer). Its help text documents no flag or `-c` key that removes
|
|
9
|
+
* every tool, so the openai provider is refused for those roles until one exists.
|
|
10
|
+
*
|
|
11
|
+
* Coders keep Codex. The facts delegate keeps it too: it gets read-only shell tools by design.
|
|
12
|
+
* The scrub pass (`session/scrub.mjs`) runs as role `reviewer` at L1, so it is refused as well.
|
|
13
|
+
*
|
|
14
|
+
* Opt-in (default off): `review.allow_open_book_codex: true` lets Codex run those roles with its
|
|
15
|
+
* read-only argv; every surface then shows {@link OPEN_BOOK_CODEX_WARNING} instead of refusing.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** The text every refusal prints: the builder, the spawner, `doctor`, `validate`, `resolve`. */
|
|
19
|
+
export const CODEX_CLOSED_BOOK_REFUSAL = 'codex cannot run closed-book yet: it always has a shell; use anthropic or xai for reviewer, judge, S2 and plan author';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The warning every surface prints when `review.allow_open_book_codex: true` lets Codex run those
|
|
23
|
+
* roles anyway, with its `-s read-only` argv (B32 opt-in, default off).
|
|
24
|
+
*/
|
|
25
|
+
export const OPEN_BOOK_CODEX_WARNING = 'codex reviewer is not closed-book: it can read files on this machine (review.allow_open_book_codex)';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {unknown} cfg - a loaded project config.
|
|
29
|
+
* @returns {boolean} true only when `review.allow_open_book_codex` is exactly `true`.
|
|
30
|
+
*/
|
|
31
|
+
export function openBookCodexAllowed(cfg) {
|
|
32
|
+
return /** @type {any} */ (cfg)?.review?.allow_open_book_codex === true;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The provider that cannot run closed-book (its CLI is Codex). */
|
|
36
|
+
export const NO_CLOSED_BOOK_PROVIDER = 'openai';
|
|
37
|
+
|
|
38
|
+
/** Roles that must run with no tools (facts is not one: it has read-only tools by design). */
|
|
39
|
+
export const NO_TOOL_ROLES = Object.freeze(['reviewer', 'judge', 's2', 'author']);
|
|
40
|
+
|
|
41
|
+
/** Levels whose sessions are closed-book: L2 (reviewers), L3 (judge, S2, author). */
|
|
42
|
+
export const CLOSED_BOOK_LEVELS = Object.freeze(['L2', 'L3']);
|
|
43
|
+
|
|
44
|
+
/** @param {unknown} value @returns {string} `value` trimmed and lowercased, `''` for a non-string. */
|
|
45
|
+
function norm(value) {
|
|
46
|
+
return typeof value === 'string' ? value.trim().toLowerCase() : '';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** @param {unknown} provider @returns {boolean} true when `provider` (any case) is the Codex provider. */
|
|
50
|
+
export function isNoClosedBookProvider(provider) {
|
|
51
|
+
return norm(provider) === NO_CLOSED_BOOK_PROVIDER;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** @param {unknown} role @returns {boolean} true when `role` (any case) must run with no tools. */
|
|
55
|
+
export function isNoToolRole(role) {
|
|
56
|
+
return NO_TOOL_ROLES.includes(norm(role));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @param {unknown} provider @param {unknown} role - compared lowercased and trimmed.
|
|
61
|
+
* @returns {boolean} true when `provider` cannot run `role` closed-book.
|
|
62
|
+
*/
|
|
63
|
+
export function needsClosedBook(provider, role) {
|
|
64
|
+
return isNoClosedBookProvider(provider) && isNoToolRole(role);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @param {unknown} provider @param {unknown} role @param {boolean} openBookAllowed - the config's
|
|
69
|
+
* opt-in, read once by the caller with {@link openBookCodexAllowed}.
|
|
70
|
+
* @returns {boolean} true when the session must be refused: `provider` cannot run `role`
|
|
71
|
+
* closed-book and the config has not opted in to open-book Codex.
|
|
72
|
+
*/
|
|
73
|
+
export function closedBookRefused(provider, role, openBookAllowed) {
|
|
74
|
+
return needsClosedBook(provider, role) && openBookAllowed !== true;
|
|
75
|
+
}
|
package/src/config/validate.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* 1. **Schema validation** (Ajv, draft 2020-12, `additionalProperties: false` almost everywhere)
|
|
6
6
|
* catches shape errors: unknown keys, wrong types, missing required fields.
|
|
7
|
-
* 2. **Domain rules** (`DOMAIN_RULES` below,
|
|
7
|
+
* 2. **Domain rules** (`DOMAIN_RULES` below, 16 of them as of B32) catch things no JSON Schema can express:
|
|
8
8
|
* cross-field comparisons (two levels resolving to the same tuple), a value that needs a
|
|
9
9
|
* second config value to be reachable (`second_levels.L2.provider` vs. the effective L2
|
|
10
10
|
* provider) and rules that reach outside the config object entirely (is a CLI on PATH, is a
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* instead of touching `process.env.PATH` themselves, so a unit test never needs a real CLI on
|
|
15
15
|
* PATH to prove either branch.
|
|
16
16
|
*
|
|
17
|
-
* The
|
|
17
|
+
* The 16 rules (B32 added `closed-book-on-openai`, B29 `effort-not-valid-for-provider`; before them: B1's original 14 plus B1.1's `caps-coders-exceeds-cap`, minus B1.2's removal of
|
|
18
18
|
* `proof-tool-absent-for-high-tier` — Q16 answered "cut", so `proof.tiers.high.tool` no longer
|
|
19
19
|
* exists to be absent, plan §10.4 Wave 6), and how the plan's prose bullets map onto them (§1.3's
|
|
20
20
|
* "review.multimodel: true with second_provider empty **or with**
|
|
@@ -34,6 +34,8 @@
|
|
|
34
34
|
* 12 engine-subprocess-no-cli ERROR
|
|
35
35
|
* 13 shadow-rate-range ERROR ("shadow_rate range")
|
|
36
36
|
* 14 caps-coders-exceeds-cap WARN (v1.3/R10/B1.1: caps.coders > 2)
|
|
37
|
+
* 15 effort-not-valid-for-provider ERROR (B29: a level's / fallback's effort vs. its provider)
|
|
38
|
+
* 16 closed-book-on-openai WARN (B32: Codex has no no-tools mode)
|
|
37
39
|
*
|
|
38
40
|
* **B1.2 (v1.3, Q16 cut) removed rule:** `proof-tool-absent-for-high-tier` (formerly #13) — the
|
|
39
41
|
* key it warned about (`proof.tiers.high.tool`) left the schema in the same amendment, along with
|
|
@@ -60,7 +62,9 @@
|
|
|
60
62
|
import { Ajv2020 } from 'ajv/dist/2020.js';
|
|
61
63
|
import schema from '../../schema/code-forge.schema.json' with { type: 'json' };
|
|
62
64
|
import { redact } from '../util/redact.mjs';
|
|
63
|
-
import {
|
|
65
|
+
import { checkEffort, displayEffort } from '../engines/efforts.mjs';
|
|
66
|
+
import { CLOSED_BOOK_LEVELS, CODEX_CLOSED_BOOK_REFUSAL, isNoClosedBookProvider, OPEN_BOOK_CODEX_WARNING, openBookCodexAllowed } from './closed-book.mjs';
|
|
67
|
+
import { isKnownId, resolveLevel } from './known-ids.mjs';
|
|
64
68
|
import { looksLikeSecret, maskSecretTokens, SECRET_MASK, secretTokensIn } from './secret-patterns.mjs';
|
|
65
69
|
|
|
66
70
|
/**
|
|
@@ -637,11 +641,126 @@ function checkCapsCodersExceedsCap(cfg) {
|
|
|
637
641
|
return [];
|
|
638
642
|
}
|
|
639
643
|
|
|
644
|
+
// ── 15. effort-not-valid-for-provider (ERROR, B29) ──────────────────────────
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* A provider name for an effort message: bare (`openai`) only when it is one of the schema's
|
|
648
|
+
* closed provider words, `(unknown provider)` otherwise — never an unchecked config string.
|
|
649
|
+
* @param {unknown} provider
|
|
650
|
+
* @returns {string}
|
|
651
|
+
*/
|
|
652
|
+
function effortProvider(provider) {
|
|
653
|
+
return typeof provider === 'string' && PROVIDER_NAMES.includes(provider) ? provider : '(unknown provider)';
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* One effort issue for the effort at `keyPath` on `provider`, or `null` when it is fine. The
|
|
658
|
+
* builders throw for exactly the efforts `checkEffort` refuses, so this rule moves that spawn-time
|
|
659
|
+
* failure to config time. The effort is shown only through `displayEffort` (a closed-list word,
|
|
660
|
+
* else `(unrecognised value)`), the provider only through `effortProvider`. A non-string effort is
|
|
661
|
+
* left to the schema layer; an unknown provider is skipped (the schema layer reports it).
|
|
662
|
+
* @param {string} keyPath
|
|
663
|
+
* @param {unknown} provider
|
|
664
|
+
* @param {unknown} effort
|
|
665
|
+
* @returns {ValidationIssue | null}
|
|
666
|
+
*/
|
|
667
|
+
function effortIssue(keyPath, provider, effort) {
|
|
668
|
+
if (typeof effort !== 'string') return null;
|
|
669
|
+
const result = checkEffort(provider, effort);
|
|
670
|
+
const who = effortProvider(provider);
|
|
671
|
+
/** @type {string} */
|
|
672
|
+
let message;
|
|
673
|
+
switch (result.kind) {
|
|
674
|
+
case 'invalid':
|
|
675
|
+
message = `${keyPath} ${displayEffort(effort)} is not valid for provider ${who}; use one of: ${result.allowed.join(', ')}`;
|
|
676
|
+
break;
|
|
677
|
+
case 'unsupported':
|
|
678
|
+
message = `${keyPath} is set but provider ${who} takes no effort; remove it`;
|
|
679
|
+
break;
|
|
680
|
+
case 'empty':
|
|
681
|
+
message = `${keyPath} is empty; provider ${who} needs a non-empty effort or none at all`;
|
|
682
|
+
break;
|
|
683
|
+
default: // 'ok', 'unknown-provider'
|
|
684
|
+
return null;
|
|
685
|
+
}
|
|
686
|
+
return { rule: 'effort-not-valid-for-provider', severity: 'error', message };
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* Checks each level's effort against its effective provider, and each fallback entry's effort
|
|
691
|
+
* against that entry's own provider (the spawner hands a fallback step's effort to that provider's
|
|
692
|
+
* builder). Both efforts are read from the SAME raw source the spawner uses — `cfg.levels.<Lx>` as
|
|
693
|
+
* written (`resolveLevel` in `known-ids.mjs` copies `level.effort` and `level.fallback` verbatim,
|
|
694
|
+
* and no loader merges provider defaults into a config, see `load.mjs`), so every value checked
|
|
695
|
+
* here was set in `levels.<Lx>` itself and the message names exactly that key.
|
|
696
|
+
* @param {Record<string, any>} cfg
|
|
697
|
+
*/
|
|
698
|
+
function checkEffortNotValidForProvider(cfg) {
|
|
699
|
+
/** @type {ValidationIssue[]} */
|
|
700
|
+
const issues = [];
|
|
701
|
+
for (const name of LEVEL_NAMES) {
|
|
702
|
+
const level = effectiveLevel(cfg, name);
|
|
703
|
+
if (level) {
|
|
704
|
+
const issue = effortIssue(`levels.${name}.effort`, level.provider, level.effort);
|
|
705
|
+
if (issue) issues.push(issue);
|
|
706
|
+
}
|
|
707
|
+
const rawFallback = cfg?.levels?.[name]?.fallback;
|
|
708
|
+
const fallbackList = Array.isArray(rawFallback) ? rawFallback : [];
|
|
709
|
+
for (const [i, fb] of fallbackList.entries()) {
|
|
710
|
+
if (fb === null || typeof fb !== 'object') continue;
|
|
711
|
+
const issue = effortIssue(`levels.${name}.fallback[${i}].effort`, fb.provider, fb.effort);
|
|
712
|
+
if (issue) issues.push(issue);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
return issues;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// ── 16. closed-book-on-openai (WARN, B32) ──────────────────────────────────
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* L2 (reviewers) and L3 (judge, S2, author) run closed-book, and Codex has no no-tools mode
|
|
722
|
+
* (`closed-book.mjs`): the spawner refuses an openai level there and skips an openai fallback.
|
|
723
|
+
* One warning per place that resolves to openai — the level itself, each of its fallbacks, and
|
|
724
|
+
* the second reviewer's effective L2 provider when multimodel is on. WARN, not ERROR: the
|
|
725
|
+
* `provider: openai` default writes openai on every level, and the coders still run on Codex.
|
|
726
|
+
* With `review.allow_open_book_codex: true` the warnings name the opt-in's cost instead.
|
|
727
|
+
* @param {Record<string, any>} cfg
|
|
728
|
+
*/
|
|
729
|
+
function checkClosedBookOnOpenai(cfg) {
|
|
730
|
+
/** @type {string[]} */
|
|
731
|
+
const where = [];
|
|
732
|
+
for (const name of CLOSED_BOOK_LEVELS) {
|
|
733
|
+
// The level and its fallbacks exactly as the spawner's ladder sees them (fix round 1):
|
|
734
|
+
// `resolveLevel` is what `spawnSession` and `resolve` call. A level it cannot resolve is
|
|
735
|
+
// `level-missing`'s to report; the spawner cannot run it either.
|
|
736
|
+
let resolved;
|
|
737
|
+
try {
|
|
738
|
+
resolved = resolveLevel(cfg, /** @type {"L2"|"L3"} */ (name));
|
|
739
|
+
} catch {
|
|
740
|
+
continue;
|
|
741
|
+
}
|
|
742
|
+
if (isNoClosedBookProvider(resolved.provider)) where.push(`levels.${name}`);
|
|
743
|
+
resolved.fallback.forEach((fb, i) => {
|
|
744
|
+
if (isNoClosedBookProvider(fb?.provider)) where.push(`levels.${name}.fallback[${i}]`);
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
if (cfg?.review?.multimodel === true && isNoClosedBookProvider(effectiveSecondL2Provider(cfg))) {
|
|
748
|
+
where.push('review.second_levels.L2 (else review.second_provider)');
|
|
749
|
+
}
|
|
750
|
+
// B32 opt-in: the same places, but the text says what the opt-in costs instead of "refused".
|
|
751
|
+
const text = openBookCodexAllowed(cfg) ? OPEN_BOOK_CODEX_WARNING : CODEX_CLOSED_BOOK_REFUSAL;
|
|
752
|
+
return where.map((key) => ({
|
|
753
|
+
rule: 'closed-book-on-openai',
|
|
754
|
+
severity: /** @type {const} */ ('warning'),
|
|
755
|
+
message: `${key} resolves to openai — ${text}`,
|
|
756
|
+
}));
|
|
757
|
+
}
|
|
758
|
+
|
|
640
759
|
/**
|
|
641
|
-
* The
|
|
760
|
+
* The 16 domain rules paired with the exact `rule` id string each one emits — the single source
|
|
642
761
|
* of truth both `DOMAIN_RULES` (below, what `validateConfig` runs) and the exported
|
|
643
762
|
* `DOMAIN_RULE_IDS` are built from. `'schema'` (the ajv layer's own issues) is deliberately not
|
|
644
|
-
* one of these
|
|
763
|
+
* one of these 16 (B29 added rule 15, B32 rule 16). (B1.2/v1.3, Q16 cut: `proof-tool-absent-for-high-tier` removed, 15 -> 14.)
|
|
645
764
|
* @type {ReadonlyArray<{fn: (cfg: Record<string, any>, opts?: object) => ValidationIssue[], id: string}>}
|
|
646
765
|
*/
|
|
647
766
|
const RULE_TABLE = Object.freeze([
|
|
@@ -659,12 +778,14 @@ const RULE_TABLE = Object.freeze([
|
|
|
659
778
|
{ fn: checkEngineSubprocessNoCli, id: 'engine-subprocess-no-cli' },
|
|
660
779
|
{ fn: checkShadowRateRange, id: 'shadow-rate-range' },
|
|
661
780
|
{ fn: checkCapsCodersExceedsCap, id: 'caps-coders-exceeds-cap' },
|
|
781
|
+
{ fn: checkEffortNotValidForProvider, id: 'effort-not-valid-for-provider' },
|
|
782
|
+
{ fn: checkClosedBookOnOpenai, id: 'closed-book-on-openai' },
|
|
662
783
|
]);
|
|
663
784
|
|
|
664
|
-
/** The
|
|
785
|
+
/** The 16 domain rules, in the order documented above. */
|
|
665
786
|
const DOMAIN_RULES = Object.freeze(RULE_TABLE.map((entry) => entry.fn));
|
|
666
787
|
|
|
667
|
-
/** The
|
|
788
|
+
/** The 16 rule ids `validateConfig` can emit, in `DOMAIN_RULES` order (see `RULE_TABLE`). */
|
|
668
789
|
export const DOMAIN_RULE_IDS = Object.freeze(RULE_TABLE.map((entry) => entry.id));
|
|
669
790
|
|
|
670
791
|
/**
|