@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.
- package/CHANGELOG.md +50 -0
- package/README.md +79 -21
- package/bin/code-forge.mjs +6 -1
- package/docs/README.md +5 -4
- package/docs/reference/blocks.json +153 -1
- package/docs/reference/config.md +8 -1
- package/package.json +4 -2
- package/schema/code-forge.schema.json +15 -4
- package/skill/SKILL.md +7 -7
- package/skill/references/adapters/claude-code.md +1 -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 +8 -2
- package/skill/references/plan.md +3 -3
- package/skill/references/review.md +8 -5
- 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/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/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/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 +171 -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/plan-check.mjs +71 -6
- package/src/session/plan-sections.mjs +98 -0
- package/src/session/spawn.mjs +217 -8
- 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/worker/engine.mjs +13 -8
- package/src/worker/loop.mjs +135 -39
- package/src/worker/queue.mjs +68 -7
package/src/cli/resolve.mjs
CHANGED
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
* (plan §5.2, §10.3 B4 acceptance): `{provider, model, effort, fallback, cli}`, via B1's
|
|
4
4
|
* `resolveLevel` (the per-level `provider` override wins over the top-level `provider`, R5) plus
|
|
5
5
|
* this block's own `cliNameForProvider` (the CLI binary `engine: subprocess` would spawn).
|
|
6
|
+
* B32: for L2/L3 (closed-book) an openai level gains `closed_book: "refused"` (and each openai
|
|
7
|
+
* fallback entry the same key), with the refusal text on stderr; the exit code stays 0. With the
|
|
8
|
+
* opt-in `review.allow_open_book_codex: true` the mark is `"open-book (allowed)"` and stderr carries
|
|
9
|
+
* the open-book warning instead.
|
|
6
10
|
*
|
|
7
11
|
* **Fix round 1 (isolated per-file review, all MINOR):**
|
|
8
12
|
* - The result is now written with `redact.writeSafe(process.stdout, ...)` — B0's documented
|
|
@@ -19,6 +23,7 @@
|
|
|
19
23
|
*/
|
|
20
24
|
|
|
21
25
|
import { loadProjectConfig } from '../config/load.mjs';
|
|
26
|
+
import { CLOSED_BOOK_LEVELS, CODEX_CLOSED_BOOK_REFUSAL, isNoClosedBookProvider, OPEN_BOOK_CODEX_WARNING, openBookCodexAllowed } from '../config/closed-book.mjs';
|
|
22
27
|
import { resolveLevel } from '../config/known-ids.mjs';
|
|
23
28
|
import { cliNameForProvider } from '../engines/provider-cli.mjs';
|
|
24
29
|
import { maskSecretTokens } from '../config/secret-patterns.mjs';
|
|
@@ -73,7 +78,17 @@ export default async function resolve(args) {
|
|
|
73
78
|
return 1;
|
|
74
79
|
}
|
|
75
80
|
|
|
76
|
-
|
|
81
|
+
// B32: L2/L3 run closed-book; an openai level there is marked (and the spawner refuses it), an
|
|
82
|
+
// openai fallback entry is marked (and the spawner skips it). Other levels print as before.
|
|
83
|
+
// With `review.allow_open_book_codex: true` the mark is `open-book (allowed)` and the warning
|
|
84
|
+
// names what that costs.
|
|
85
|
+
const closedBook = CLOSED_BOOK_LEVELS.includes(parsed.level);
|
|
86
|
+
const openBook = openBookCodexAllowed(loaded.config);
|
|
87
|
+
const mark = openBook ? 'open-book (allowed)' : 'refused';
|
|
88
|
+
const marked = closedBook && isNoClosedBookProvider(resolved.provider);
|
|
89
|
+
const fallback = closedBook ? resolved.fallback.map((f) => (isNoClosedBookProvider(f.provider) ? { ...f, closed_book: mark } : f)) : resolved.fallback;
|
|
90
|
+
if (marked || fallback.some((f) => 'closed_book' in f)) log.warn(openBook ? OPEN_BOOK_CODEX_WARNING : CODEX_CLOSED_BOOK_REFUSAL);
|
|
91
|
+
const output = { provider: resolved.provider, model: resolved.model, effort: resolved.effort ?? null, fallback, cli, ...(marked ? { closed_book: mark } : {}) };
|
|
77
92
|
writeSafe(process.stdout, `${JSON.stringify(output)}\n`);
|
|
78
93
|
return 0;
|
|
79
94
|
}
|
package/src/cli/review-file.mjs
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* review-file <path> --block <id> [--run <id>] enqueue; prints {ticket, status: queued|done, file}
|
|
5
5
|
* review-file --wait <ticket> [--max <90s>] poll; prints {ticket, status: done, result} or
|
|
6
|
-
* {ticket, status: pending
|
|
6
|
+
* {ticket, status: pending, reason: wait_timeout}
|
|
7
|
+
* when --max elapses (the review is still running)
|
|
7
8
|
*
|
|
8
9
|
* The same content enqueued again after an `unavailable` result is a fresh attempt (`retry: true`),
|
|
9
10
|
* not the old result.
|
|
@@ -16,7 +17,9 @@
|
|
|
16
17
|
* path is given relative to the current directory and stored relative to the REPOSITORY ROOT
|
|
17
18
|
* (V4); an absolute path or a `..` segment is refused with `bad-path`. With no live worker the
|
|
18
19
|
* answer is `worker_down` — never approval — and nothing is enqueued; the coder must then print
|
|
19
|
-
* `===BLOCK <id> FAILED: worker down===`.
|
|
20
|
+
* `===BLOCK <id> FAILED: worker down===`. While waiting, `worker_down` needs the worker to look
|
|
21
|
+
* down on two polls in a row, and a worker whose pid is alive with a fresh heartbeat is never down
|
|
22
|
+
* (`liveWorker`, B30). Output is one JSON line on stdout.
|
|
20
23
|
* Exit codes: 0 queued/done/pending, 1 bad-path or another refusal, 2 usage, 3 worker_down.
|
|
21
24
|
*/
|
|
22
25
|
|
|
@@ -99,18 +102,20 @@ export async function runReviewFile(args, deps = {}) {
|
|
|
99
102
|
const ticket = assertTicketId(flags.wait);
|
|
100
103
|
const maxMs = flags.max === undefined ? DEFAULT_WAIT_MS : parseMax(flags.max);
|
|
101
104
|
const deadline = Date.now() + maxMs;
|
|
105
|
+
let downPolls = 0;
|
|
102
106
|
for (;;) {
|
|
103
107
|
if (isDone(repoRoot, ticket)) {
|
|
104
108
|
const result = doneResult(repoRoot, ticket);
|
|
105
109
|
say({ ticket, status: 'done', result, ...fixList(result) });
|
|
106
110
|
return 0;
|
|
107
111
|
}
|
|
108
|
-
|
|
112
|
+
downPolls = liveWorker(repoRoot) ? 0 : downPolls + 1;
|
|
113
|
+
if (downPolls >= 2 && !isDone(repoRoot, ticket)) {
|
|
109
114
|
say({ ticket, status: 'worker_down' });
|
|
110
115
|
return 3;
|
|
111
116
|
}
|
|
112
|
-
if (Date.now() >= deadline) {
|
|
113
|
-
say({ ticket, status: 'pending' });
|
|
117
|
+
if (Date.now() >= deadline && downPolls === 0) {
|
|
118
|
+
say({ ticket, status: 'pending', reason: 'wait_timeout' });
|
|
114
119
|
return 0;
|
|
115
120
|
}
|
|
116
121
|
await new Promise((resolve) => setTimeout(resolve, deps.pollMs ?? POLL_MS));
|
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
|
/**
|