@codedology/code-forge 0.3.1 → 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 +60 -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/jev-client.mjs +1 -1
- package/src/decide/questions.mjs +39 -3
- 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 +65 -23
- 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 +21 -6
- 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
|
@@ -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
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
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
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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);
|
|
@@ -91,7 +91,7 @@ async function requestIdOf(response) {
|
|
|
91
91
|
* @typedef {object} JevQuestionSpec
|
|
92
92
|
* @property {'choice'|'score'|'noul'} type
|
|
93
93
|
* @property {string} instructions
|
|
94
|
-
* @property {Record<string, string>} criteria
|
|
94
|
+
* @property {Record<string, string> | string[]} criteria - a list (index = score) for `score`
|
|
95
95
|
*
|
|
96
96
|
* @typedef {object} JevSuccess
|
|
97
97
|
* @property {true} ok
|
package/src/decide/questions.mjs
CHANGED
|
@@ -146,16 +146,52 @@ export function renderQuestion(id, cfg = {}) {
|
|
|
146
146
|
* `askJev`'s `questions` argument takes.
|
|
147
147
|
* @param {string[]} ids
|
|
148
148
|
* @param {Record<string, any>} [cfg]
|
|
149
|
-
* @returns {Record<string, {type: 'choice'|'score'|'noul', instructions: string, criteria: Record<string, string>}>}
|
|
149
|
+
* @returns {Record<string, {type: 'choice'|'score'|'noul', instructions: string, criteria: Record<string, string> | string[]}>}
|
|
150
150
|
*/
|
|
151
151
|
export function buildQuestionPayload(ids, cfg = {}) {
|
|
152
152
|
if (!Array.isArray(ids) || ids.length === 0) {
|
|
153
153
|
throw new TypeError('buildQuestionPayload: ids must be a non-empty array');
|
|
154
154
|
}
|
|
155
|
-
/** @type {Record<string, {type: 'choice'|'score'|'noul', instructions: string, criteria: Record<string, string>}>} */
|
|
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] = renderQuestion(id, cfg);
|
|
158
|
+
out[id] = toWire(id, renderQuestion(id, cfg));
|
|
159
159
|
}
|
|
160
160
|
return out;
|
|
161
161
|
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Jev's wire shape: a `score` question takes `criteria` as a LIST whose index is the score (an
|
|
165
|
+
* object keyed `0..n` is refused with 422 "Input should be a valid list"); `choice` and `noul`
|
|
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
|
|
172
|
+
* @param {{type: 'choice'|'score'|'noul', instructions: string, criteria: Record<string, string>}} q
|
|
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`.
|
|
175
|
+
*/
|
|
176
|
+
export function toWire(id, q) {
|
|
177
|
+
if (q.type !== 'score') return q;
|
|
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)]) };
|
|
197
|
+
}
|
package/src/doctor/probes.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
286
|
-
return
|
|
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
|
-
* -
|
|
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 `
|
|
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
|
|
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
|
-
/**
|
|
89
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
75
|
-
|
|
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
|
+
}
|
package/src/engines/sentinel.mjs
CHANGED
|
@@ -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
|
+
}
|