@codedology/code-forge 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +13 -0
  3. package/docs/README.md +1 -0
  4. package/docs/reference/blocks.json +96 -1
  5. package/docs/reference/config.md +2 -0
  6. package/package.json +1 -1
  7. package/schema/code-forge.schema.json +7 -0
  8. package/skill/SKILL.md +1 -0
  9. package/skill/references/adapters/claude-code.md +14 -0
  10. package/skill/references/autopilot.md +48 -0
  11. package/skill/references/code.md +1 -1
  12. package/skill/references/decisions.md +2 -0
  13. package/skill/references/harden.md +1 -1
  14. package/skill/references/plan.md +2 -0
  15. package/skill/references/review.md +2 -2
  16. package/skill/scripts/forge +1 -1
  17. package/src/autopilot/actions.mjs +491 -0
  18. package/src/autopilot/binnacle.mjs +494 -0
  19. package/src/autopilot/delegate.mjs +422 -0
  20. package/src/autopilot/grant.mjs +354 -0
  21. package/src/autopilot/limits.mjs +847 -0
  22. package/src/autopilot/scopes.mjs +95 -0
  23. package/src/cli/author.mjs +1 -2
  24. package/src/cli/autopilot.mjs +500 -0
  25. package/src/cli/block.mjs +40 -8
  26. package/src/cli/facts.mjs +1 -2
  27. package/src/cli/jev.mjs +1 -2
  28. package/src/cli/logs.mjs +4 -142
  29. package/src/cli/plan.mjs +1 -2
  30. package/src/cli/run.mjs +18 -60
  31. package/src/cli/s2.mjs +1 -2
  32. package/src/cli/spawn.mjs +1 -2
  33. package/src/config/closed-book.mjs +20 -3
  34. package/src/config/load.mjs +15 -0
  35. package/src/doctor/probes.mjs +34 -11
  36. package/src/engines/builders/claude.mjs +3 -3
  37. package/src/engines/builders/codex.mjs +6 -5
  38. package/src/engines/builders/grok.mjs +3 -3
  39. package/src/engines/builders/validate-params.mjs +3 -3
  40. package/src/ledger/report.mjs +2 -0
  41. package/src/review/fixloop.mjs +69 -5
  42. package/src/review/gate-check.mjs +156 -6
  43. package/src/session/spawn.mjs +68 -8
  44. package/src/state/run.mjs +8 -1
  45. package/src/util/breadcrumbs.mjs +1 -0
  46. package/src/util/forbidden.mjs +10 -0
  47. package/src/util/gh.mjs +175 -0
  48. package/src/worker/engine.mjs +8 -4
  49. package/src/worker/loop.mjs +42 -0
@@ -2,7 +2,9 @@
2
2
  * Doctor rows that run the provider CLIs (plan §2.3, §0.6; block B13b):
3
3
  * - per CLI: present + version + `--help` flag probe (B4 `probeHelpText`; a missing flag FAILs);
4
4
  * - one real call per role builder (coder, reviewer, s2 with the compiled S2 schema, facts) through
5
- * B9a `spawnSession` — an HTTP 402 is `ping=skipped(402)` WARN, never FAIL (§0.6.1);
5
+ * B9a `spawnSession` — an HTTP 402 is `ping=skipped(402)` WARN, never FAIL (§0.6.1); when the
6
+ * config has an `autopilot` section (B46), one `delegate` ping per delegate level (L2, L3) with
7
+ * the delegate schema — an openai level FAILs unrun whatever `review.allow_open_book_codex` says;
6
8
  * - the isolation probe (O4, §0.6.5): the closed-book reviewer argv, run with cwd = the project
7
9
  * itself (the worst case), asked to echo the first line of its `CLAUDE.md`/`AGENTS.md` — the
8
10
  * line appearing in its answer FAILs; the CLI rejecting the closed-book flag combination FAILs;
@@ -33,6 +35,8 @@ import { cliNameForProvider } from '../engines/provider-cli.mjs';
33
35
  import { commandOnPath } from '../install/detect.mjs';
34
36
  import { classifyUnavailable, readAnswer, spawnSession } from '../session/spawn.mjs';
35
37
  import { S2_SCHEMA } from '../session/s2.mjs';
38
+ import { DELEGATE_SCHEMA } from '../autopilot/delegate.mjs';
39
+ import { DELEGATE_LEVELS } from '../autopilot/scopes.mjs';
36
40
  import { runsDir } from '../state/paths.mjs';
37
41
  import { exec } from '../util/exec.mjs';
38
42
  import { gitChildEnv } from '../worker/ticket.mjs';
@@ -48,6 +52,23 @@ export const PING_ROLES = Object.freeze([
48
52
  { role: 's2', level: 'L3' },
49
53
  ]);
50
54
 
55
+ /**
56
+ * B46: the delegate pings, one per level a grant may name — run only when the config has an
57
+ * `autopilot` section (a project that never uses autopilot pays for no extra call).
58
+ * @param {Record<string, any>} cfg @returns {Array<{role: string, level: string, id: string}>}
59
+ */
60
+ export function delegatePings(cfg) {
61
+ const on = cfg?.autopilot !== null && typeof cfg?.autopilot === 'object';
62
+ return on ? DELEGATE_LEVELS.map((level) => ({ role: 'delegate', level, id: `ping.delegate.${level}` })) : [];
63
+ }
64
+
65
+ /** What each schema'd ping is told to answer, so a working CLI matches the schema. */
66
+ const PING_ANSWER = Object.freeze({
67
+ s2: 'Answer with decision "proceed", confidence 1, reason "ping", overrule false, ask_human false, human_question null.\n',
68
+ delegate: 'Answer with decision "proceed", within_scope true, confidence 1, reason "ping", escalate false.\n',
69
+ });
70
+ const PING_SCHEMA = Object.freeze({ s2: S2_SCHEMA, delegate: DELEGATE_SCHEMA });
71
+
51
72
  const LEVELS = Object.freeze(['L0', 'L1', 'L2', 'L3']);
52
73
  const PROBE_TIMEOUT_MS = 180000;
53
74
  const PROBE_BUDGET_USD = 0.5;
@@ -196,28 +217,30 @@ function levelName(cfg, level) {
196
217
  */
197
218
  export async function pingRoles(ctx, missingProviders) {
198
219
  const rows = [];
199
- for (const { role, level } of PING_ROLES) {
200
- const label = `ping ${role} (${levelName(ctx.cfg, level)})`;
220
+ const pings = [...PING_ROLES.map((p) => ({ ...p, id: `ping.${p.role}` })), ...delegatePings(ctx.cfg)];
221
+ for (const { role, level, id } of pings) {
222
+ const label = role === 'delegate' ? `ping delegate ${level} (${levelName(ctx.cfg, level)})` : `ping ${role} (${levelName(ctx.cfg, level)})`;
201
223
  const provider = ctx.cfg.levels?.[level]?.provider ?? ctx.cfg.provider;
202
224
  if (missingProviders.has(provider)) {
203
- rows.push(row(`ping.${role}`, 'FAIL', label, 'ping=skipped(cli-missing)'));
225
+ rows.push(row(id, 'FAIL', label, 'ping=skipped(cli-missing)'));
204
226
  continue;
205
227
  }
206
228
  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
229
+ rows.push(row(id, 'FAIL', label, CODEX_CLOSED_BOOK_REFUSAL)); // B32/B46: the spawner would refuse it
208
230
  continue;
209
231
  }
232
+ const schemaRole = /** @type {'s2' | 'delegate' | null} */ (role === 's2' || role === 'delegate' ? role : null);
210
233
  const res = await session(ctx, {
211
234
  role,
212
235
  level,
213
- prompt: role === 's2' ? `${PING_TEXT}Answer with decision "proceed", confidence 1, reason "ping", overrule false, ask_human false, human_question null.\n` : PING_TEXT,
214
- ...(role === 's2' ? { schema: S2_SCHEMA } : {}),
236
+ prompt: schemaRole ? `${PING_TEXT}${PING_ANSWER[schemaRole]}` : PING_TEXT,
237
+ ...(schemaRole ? { schema: PING_SCHEMA[schemaRole] } : {}),
215
238
  ...(role === 'coder' ? { cwd: await tempRepo(ctx) } : {}),
216
239
  });
217
- if (skipped402(res)) rows.push(row(`ping.${role}`, 'WARN', label, 'ping=skipped(402)'));
218
- else if (res.status === 'ok') rows.push(row(`ping.${role}`, 'OK', label, 'ping=ok'));
219
- else if (res.status === 'invalid-output') rows.push(row(`ping.${role}`, 'WARN', label, 'ping=answered (schema mismatch)'));
220
- else rows.push(row(`ping.${role}`, 'FAIL', label, `ping=${res.status}(${res.reason ?? 'unknown'})`));
240
+ if (skipped402(res)) rows.push(row(id, 'WARN', label, 'ping=skipped(402)'));
241
+ else if (res.status === 'ok') rows.push(row(id, 'OK', label, 'ping=ok'));
242
+ else if (res.status === 'invalid-output') rows.push(row(id, 'WARN', label, 'ping=answered (schema mismatch)'));
243
+ else rows.push(row(id, 'FAIL', label, `ping=${res.status}(${res.reason ?? 'unknown'})`));
221
244
  }
222
245
  return rows;
223
246
  }
@@ -57,7 +57,7 @@ export const CLI = 'claude';
57
57
 
58
58
  /**
59
59
  * @typedef {object} ClaudeBuildParams
60
- * @property {"coder"|"reviewer"|"judge"|"s2"|"author"|"facts"} role
60
+ * @property {"coder"|"reviewer"|"judge"|"s2"|"author"|"facts"|"delegate"} role
61
61
  * @property {string} model - the resolved model id (already picked by `resolveLevel`, B1).
62
62
  * @property {string} [effort] - one of `PROVIDER_EFFORTS.anthropic` (`../efforts.mjs`) when given.
63
63
  * @property {string} promptPath - the brief file (coder: passed as a pointer) or the packet file
@@ -86,7 +86,7 @@ export const CLI = 'claude';
86
86
  /**
87
87
  * @typedef {object} BuiltClosedBook
88
88
  * @property {"claude"} cli
89
- * @property {"reviewer"|"judge"|"s2"|"author"|"facts"} role
89
+ * @property {"reviewer"|"judge"|"s2"|"author"|"facts"|"delegate"} role
90
90
  * @property {string[]} argv - no prompt positional.
91
91
  * @property {string} cwd
92
92
  * @property {string} stdinFile - the packet file the spawner pipes to stdin.
@@ -190,7 +190,7 @@ function buildClosedBookArgv(params) {
190
190
  assertValidBudget(maxBudgetUsd, 'buildClaudeArgv');
191
191
  // The dispatch in `buildClaudeArgv` never reaches this branch with role 'coder' — a runtime
192
192
  // invariant the parameter type alone cannot prove, so the narrowing is explicit.
193
- const role = /** @type {"reviewer"|"judge"|"s2"|"author"|"facts"} */ (params.role);
193
+ const role = /** @type {"reviewer"|"judge"|"s2"|"author"|"facts"|"delegate"} */ (params.role);
194
194
  const isFacts = role === 'facts';
195
195
  if (isFacts && !hasAnyRenderedRule(renderedForbidden)) {
196
196
  throw new Error('buildClaudeArgv: facts role requires a non-empty forbidden-list render (got 0 usable rules)');
@@ -65,7 +65,7 @@ import { CODEX_RULES_FILE_NAME, defaultCodexHome, isInside, prepareCodexHome } f
65
65
  import { hasAnyRenderedRule } from '../render-rules.mjs';
66
66
  import { assertEffortForProvider, PROVIDER_EFFORTS } from '../efforts.mjs';
67
67
  import { assertBaseParams } from './validate-params.mjs';
68
- import { CODEX_CLOSED_BOOK_REFUSAL, isNoToolRole } from '../../config/closed-book.mjs';
68
+ import { CODEX_CLOSED_BOOK_REFUSAL, isNeverOpenBookRole, isNoToolRole } from '../../config/closed-book.mjs';
69
69
 
70
70
  export const CLI = 'codex';
71
71
 
@@ -104,7 +104,7 @@ export const VALID_EFFORTS = PROVIDER_EFFORTS.openai;
104
104
 
105
105
  /**
106
106
  * @typedef {object} CodexBuildParams
107
- * @property {"coder"|"reviewer"|"judge"|"s2"|"author"|"facts"} role
107
+ * @property {"coder"|"reviewer"|"judge"|"s2"|"author"|"facts"|"delegate"} role
108
108
  * @property {string} model
109
109
  * @property {string} [effort] - one of {@link VALID_EFFORTS} when given.
110
110
  * @property {string} promptPath - the brief file (coder: passed as a pointer) or the packet file
@@ -217,14 +217,14 @@ function buildCoderArgv(params) {
217
217
 
218
218
  /**
219
219
  * @param {CodexBuildParams} params
220
- * @returns {{cli: "codex", role: "reviewer"|"judge"|"s2"|"author"|"facts", argv: string[], cwd: string, outPath: string, stdinFile: string}}
220
+ * @returns {{cli: "codex", role: "reviewer"|"judge"|"s2"|"author"|"facts"|"delegate", argv: string[], cwd: string, outPath: string, stdinFile: string}}
221
221
  */
222
222
  function buildClosedBookArgv(params) {
223
223
  const { model, effort, promptPath, cwd, schemaPath } = params;
224
224
  assertValidEffort(effort);
225
225
  // See `builders/claude.mjs`'s identical cast: the dispatch below never reaches this branch with
226
226
  // role 'coder'; a no-tools role reaches it only with `allowOpenBook: true` (B32).
227
- const role = /** @type {"reviewer"|"judge"|"s2"|"author"|"facts"} */ (params.role);
227
+ const role = /** @type {"reviewer"|"judge"|"s2"|"author"|"facts"|"delegate"} */ (params.role);
228
228
  const outPath = effectiveOutPath(params.outPath, role);
229
229
  const argv = ['codex', 'exec', '-m', model];
230
230
  if (effort) argv.push('-c', `model_reasoning_effort=${effort}`);
@@ -248,9 +248,10 @@ function buildClosedBookArgv(params) {
248
248
  */
249
249
  export function buildCodexArgv(params) {
250
250
  assertBaseParams('buildCodexArgv', params);
251
- if (isNoToolRole(params.role) && params.allowOpenBook !== true) {
251
+ if (isNoToolRole(params.role) && (params.allowOpenBook !== true || isNeverOpenBookRole(params.role))) {
252
252
  // B32: no Codex flag removes the shell, so a no-tools role is built only when the caller
253
253
  // passes `allowOpenBook: true` (the config's `review.allow_open_book_codex` opt-in).
254
+ // B46: the autopilot delegate is never built on Codex, opt-in or not.
254
255
  throw Object.assign(new Error(CODEX_CLOSED_BOOK_REFUSAL), { code: 'closed-book' });
255
256
  }
256
257
  return params.role === 'coder' ? buildCoderArgv(params) : buildClosedBookArgv(params);
@@ -56,7 +56,7 @@ export const GROK_ALL_BUILTIN_TOOLS = Object.freeze(['Bash', 'Read', 'Write', 'E
56
56
 
57
57
  /**
58
58
  * @typedef {object} GrokBuildParams
59
- * @property {"coder"|"reviewer"|"judge"|"s2"|"author"|"facts"} role
59
+ * @property {"coder"|"reviewer"|"judge"|"s2"|"author"|"facts"|"delegate"} role
60
60
  * @property {string} model
61
61
  * @property {string} [effort] - a non-empty string when given.
62
62
  * @property {string} promptPath - `--prompt-file` value (brief for coder, packet for closed-book).
@@ -100,14 +100,14 @@ function buildCoderArgv(params) {
100
100
 
101
101
  /**
102
102
  * @param {GrokBuildParams} params
103
- * @returns {{cli: "grok", role: "reviewer"|"judge"|"s2"|"author"|"facts", argv: string[], cwd: string}}
103
+ * @returns {{cli: "grok", role: "reviewer"|"judge"|"s2"|"author"|"facts"|"delegate", argv: string[], cwd: string}}
104
104
  */
105
105
  function buildClosedBookArgv(params) {
106
106
  const { model, effort, promptPath, cwd, schema, systemPromptText } = params;
107
107
  assertValidEffort(effort);
108
108
  // See `builders/claude.mjs`'s identical cast: the dispatch below never reaches this branch
109
109
  // with role 'coder', but that is a runtime invariant, not provable from the parameter type.
110
- const role = /** @type {"reviewer"|"judge"|"s2"|"author"|"facts"} */ (params.role);
110
+ const role = /** @type {"reviewer"|"judge"|"s2"|"author"|"facts"|"delegate"} */ (params.role);
111
111
  const argv = ['grok', '--prompt-file', promptPath, '-m', model];
112
112
  if (effort) argv.push('--reasoning-effort', effort);
113
113
  if (schema !== undefined) argv.push('--json-schema', JSON.stringify(schema));
@@ -7,10 +7,10 @@
7
7
  */
8
8
 
9
9
  /**
10
- * The six roles the plan's engine adapters section (§5.2) declares.
11
- * @type {ReadonlyArray<"coder"|"reviewer"|"judge"|"s2"|"author"|"facts">}
10
+ * The six roles the plan's engine adapters section (§5.2) declares, plus the autopilot `delegate` (B46, closed-book).
11
+ * @type {ReadonlyArray<"coder"|"reviewer"|"judge"|"s2"|"author"|"facts"|"delegate">}
12
12
  */
13
- export const VALID_ROLES = Object.freeze(/** @type {const} */ (['coder', 'reviewer', 'judge', 's2', 'author', 'facts']));
13
+ export const VALID_ROLES = Object.freeze(/** @type {const} */ (['coder', 'reviewer', 'judge', 's2', 'author', 'facts', 'delegate']));
14
14
 
15
15
  /**
16
16
  * @param {string} builderName - for the thrown message, e.g. `"buildClaudeArgv"`.
@@ -24,6 +24,8 @@ const ROLE_BUCKET = Object.freeze({
24
24
  reviewer: 'reviewUsd',
25
25
  judge: 'reviewUsd',
26
26
  s2: 's1s2Usd',
27
+ // B46: the autopilot delegate answers one owner-level question per session, like S2
28
+ delegate: 's1s2Usd',
27
29
  facts: 'factsUsd',
28
30
  });
29
31
 
@@ -33,6 +33,12 @@
33
33
  * new_in_hunks, late, open_after, tokens_in, tokens_out}`; a converged file gets
34
34
  * `review.approved {file, content_hash, round}` (the worker signs every row it writes).
35
35
  *
36
+ * Autopilot extra round (B47): `deps.extraRounds` (from {@link extraRoundsFor}: one per signed
37
+ * `autopilot.extra_round` row for the file, one per grant) raises the file's cap by that many
38
+ * rounds — the cap checks here and rule 6 in `escalationAfterAttempt` both use the raised cap. A
39
+ * file already stopped at `review_cap` is reopened for its extra round by
40
+ * {@link reopenForExtraRound} (the worker calls it before the round).
41
+ *
36
42
  * Over budget (B12a follow-up, decided here): a recheck packet over `review.budgets.full_in`
37
43
  * falls to `contextMode: 'minimal'` (± `min_context_lines` around the SAME fix hunks) exactly as
38
44
  * `assemblePacket` does for any packet. The budget is a hard ceiling on what one session reads;
@@ -54,6 +60,8 @@ import { randomBytes } from 'node:crypto';
54
60
  import path from 'node:path';
55
61
  import { escalationAfterAttempt, heavyRound, reviewRoundStalled } from '../decide/escalation.mjs';
56
62
  import { exec } from '../util/exec.mjs';
63
+ import { verifyRow } from '../state/signer.mjs';
64
+ import { decisionProblem, grantCoverProblem } from './gate-check.mjs';
57
65
  import { contentHash, gitChildEnv } from '../worker/ticket.mjs';
58
66
  import { parseDiff } from './context.mjs';
59
67
  import { assemblePacket, assertPacketPath, readFileDiff } from './packet.mjs';
@@ -103,6 +111,7 @@ import { spawnWithTimeoutRetry } from './session-retry.mjs';
103
111
  * @property {(req: {file: string, level: 'L3', open: Finding[]}) => Promise<unknown>} [patch] - the L3 patch rung (≤ 80 lines, owned files only).
104
112
  * @property {string | null} [base] - the block base (only for `recheck_scope: file`).
105
113
  * @property {string} [rulesDigest] @property {string} [factsExcerpt]
114
+ * @property {number} [extraRounds] - B47: rounds past `review.max_rounds_per_file` granted by autopilot (`extraRoundsFor`).
106
115
  */
107
116
 
108
117
  /** @param {LoopDeps} deps @param {Record<string, any>} row */
@@ -114,13 +123,68 @@ async function note(deps, row) {
114
123
  }
115
124
  }
116
125
 
117
- /** @param {Record<string, any> | undefined} cfg */
118
- function settings(cfg) {
126
+ /** @param {unknown} v @param {number} d @returns {number} `v` when a non-negative integer, else `d`. */
127
+ const int = (v, d) => (Number.isInteger(v) && /** @type {number} */ (v) >= 0 ? /** @type {number} */ (v) : d);
128
+
129
+ /**
130
+ * The file's round cap: `review.max_rounds_per_file` (default 4) plus the autopilot extra rounds.
131
+ * @param {Record<string, any> | undefined} cfg @param {number} [extraRounds]
132
+ * @returns {number}
133
+ */
134
+ export function roundCap(cfg, extraRounds = 0) {
135
+ return int(cfg?.review?.max_rounds_per_file, 4) + int(extraRounds, 0);
136
+ }
137
+
138
+ /**
139
+ * B47: the extra rounds autopilot granted the file — the number of distinct grants with an
140
+ * `autopilot.extra_round` row of this run for this block and file that holds up as the gate's
141
+ * waiver check does: its MAC verifies, its grant covered `round:extra` at its `ts`
142
+ * (`grantCoverProblem`) and its `decision_id` names the delegate's acted decision for that block
143
+ * and file (`decisionProblem`). A row that fails any check, or whose check throws, counts for nothing.
144
+ * @param {Array<Record<string, any>>} rows @param {{runId: string, block: string, file: string, key: Buffer}} where
145
+ * @returns {number}
146
+ */
147
+ export function extraRoundsFor(rows, { runId, block, file, key }) {
148
+ const runRows = rows.filter((r) => r?.run === runId);
149
+ const grants = new Set();
150
+ for (const r of runRows) {
151
+ if (r?.event !== 'autopilot.extra_round' || r.block !== block || r.file !== file || typeof r.grant_id !== 'string') continue;
152
+ try {
153
+ if (!verifyRow(r, key).ok) continue;
154
+ const at = typeof r.ts === 'string' ? Date.parse(r.ts) : Number.NaN;
155
+ if (!Number.isFinite(at)) continue;
156
+ if (grantCoverProblem(runRows, { grantId: r.grant_id, scope: 'round:extra', at, key, endRows: rows }) !== null) continue;
157
+ if (decisionProblem(runRows, { decisionId: r.decision_id, grantId: r.grant_id, scope: 'round:extra', subject: { block, file }, decision: 'allow', at, key }) !== null) continue;
158
+ grants.add(r.grant_id);
159
+ } catch {
160
+ // a row the checks cannot read gives no round (fail closed)
161
+ }
162
+ }
163
+ return grants.size;
164
+ }
165
+
166
+ /**
167
+ * B47: reopen a file stopped at `review_cap` when its raised cap leaves a round: the state goes
168
+ * back to `open` with `next: fix` at its level (`trigger: autopilot_extra_round`); the next round
169
+ * is a recheck. Any other state is left as it is.
170
+ * @param {FileState} state @param {number} extraRounds @param {Record<string, any> | undefined} cfg
171
+ * @returns {boolean} whether the state was reopened.
172
+ */
173
+ export function reopenForExtraRound(state, extraRounds, cfg) {
174
+ if (state.status !== 'stopped' || state.next?.reason !== 'review_cap') return false;
175
+ if (state.round >= roundCap(cfg, extraRounds)) return false;
176
+ state.status = 'open';
177
+ state.next = { action: 'fix', level: state.level, trigger: 'autopilot_extra_round' };
178
+ state.pending_kind = null;
179
+ return true;
180
+ }
181
+
182
+ /** @param {Record<string, any> | undefined} cfg @param {number} [extraRounds] */
183
+ function settings(cfg, extraRounds = 0) {
119
184
  const r = cfg?.review ?? {};
120
185
  const e = cfg?.escalation ?? {};
121
- const int = (/** @type {unknown} */ v, /** @type {number} */ d) => (Number.isInteger(v) && /** @type {number} */ (v) >= 0 ? /** @type {number} */ (v) : d);
122
186
  return {
123
- maxRounds: int(r.max_rounds_per_file, 4),
187
+ maxRounds: roundCap(cfg, extraRounds),
124
188
  scope: r.recheck_scope === 'file' ? 'file' : 'fix_hunks',
125
189
  late: r.late_findings === 'block' ? 'block' : 'sweep',
126
190
  perLevel: int(e.review_rounds_per_level, 2),
@@ -332,7 +396,7 @@ async function stopBeforeRound(state, deps, kind, file, reason) {
332
396
  * @returns {Promise<FileState>}
333
397
  */
334
398
  export async function runRound(state, deps, opts = {}) {
335
- const s = settings(deps.cfg);
399
+ const s = settings(deps.cfg, deps.extraRounds);
336
400
  const kind = opts.kind ?? (state.round === 0 ? 'full' : 'recheck');
337
401
  const current = readCurrent(deps.repoRoot, state.file);
338
402
  const openBefore = state.open.length;
@@ -17,6 +17,18 @@
17
17
  * (`file` + `finding`; a finding id is scoped to one file, so a waiver of `F1` in `a.mjs` never
18
18
  * clears `F1` in `b.mjs`); a late finding counts whether or not it is signed (fail closed).
19
19
  *
20
+ * B47 (autopilot): a `review.waived {by: autopilot}` row (written by `autopilot waive`) clears a
21
+ * finding ONLY when {@link autopilotWaiverProblem} finds nothing wrong with it: its MAC verifies;
22
+ * the finding is not `proof`; the finding's severity recorded in the block's signed review rows
23
+ * (`review.triage`, `review.late_finding`; {@link recordedSeverity}) is `warning` or `nit` and
24
+ * equals the waiver's `severity`; a signed `autopilot.grant` row of this run with the waiver's
25
+ * `grant_id` exists, its window (`ts` ≤ waiver `ts` < `until`) holds the waiver, and its scopes
26
+ * include `waive:<severity>`; no `autopilot.stop` / `autopilot.expire` row for that grant is
27
+ * dated at or before the waiver (or undated); and its `decision_id` names a signed, acted
28
+ * `autopilot.decision` of the same grant, scope, block, file and finding made at most 30 min
29
+ * before it ({@link decisionProblem}). Otherwise the waiver is ignored and the finding is refused as
30
+ * `autopilot_waiver_invalid` (the detail says why). `by: human` waivers are unchanged.
31
+ *
20
32
  * `block waive <id> <finding> --file <path> --reason <text>` (`waiveFinding`) writes a signed
21
33
  * `review.waived` row with `by: human`. The orchestrator runs it only after the human said so in chat; the brief
22
34
  * never mentions it; for a coder the argv is forbidden (`mergeForbidden()` always carries B12b's
@@ -34,7 +46,7 @@ import { tierFor } from '../proof/tiers.mjs';
34
46
  import { DELETED_HASH, contentHash, gitChildEnv } from '../worker/ticket.mjs';
35
47
 
36
48
  /**
37
- * @typedef {{code: 'unreviewed' | 'unsigned' | 'rule_break' | 'late_unruled' | 'review_cap' | 'unproven', file?: string, finding?: string, detail?: string}} Refusal
49
+ * @typedef {{code: 'unreviewed' | 'unsigned' | 'rule_break' | 'late_unruled' | 'review_cap' | 'unproven' | 'autopilot_waiver_invalid', file?: string, finding?: string, detail?: string}} Refusal
38
50
  */
39
51
 
40
52
  /** @param {Record<string, any>} row @param {Buffer} key */
@@ -76,28 +88,166 @@ export function checkBlockReviews({ block, runId, files, rows, key, transcript =
76
88
 
77
89
  const signedOf = (/** @type {string} */ event) => mine.filter((r) => r.event === event && verified(r, key));
78
90
  const waivers = signedOf('review.waived').filter((r) => r.by === 'human');
91
+ const autopilotWaivers = mine.filter((r) => r.event === 'review.waived' && r.by === 'autopilot');
92
+ const runRows = rows.filter((r) => r?.run === runId);
79
93
  const rulings = signedOf('review.late_ruling');
80
94
  const matches = (/** @type {Record<string, any>} */ r, /** @type {string} */ file, /** @type {string} */ finding) => r.finding === finding && r.file === file;
81
- const waived = (/** @type {string} */ file, /** @type {string} */ finding) => waivers.some((r) => matches(r, file, finding));
95
+ /**
96
+ * Whether the finding is waived: a signed human waiver, or an autopilot waiver its grant covered.
97
+ * `invalid`: why the autopilot waivers naming it do not count (null when none names it).
98
+ * @param {string} file @param {string} finding @returns {{waived: boolean, invalid: string | null}}
99
+ */
100
+ const waiverFor = (file, finding) => {
101
+ if (waivers.some((r) => matches(r, file, finding))) return { waived: true, invalid: null };
102
+ const named = autopilotWaivers.filter((r) => matches(r, file, finding));
103
+ if (named.length === 0) return { waived: false, invalid: null };
104
+ const problems = named.map((w) => autopilotWaiverProblem(w, { blockRows: mine, runRows, allRows: rows, key }));
105
+ if (problems.some((p) => p === null)) return { waived: true, invalid: null };
106
+ return { waived: false, invalid: /** @type {string} */ (problems[0]) };
107
+ };
108
+ /** @param {Refusal} refusal @param {string} finding @param {string | null} invalid @returns {Refusal} */
109
+ const orInvalid = (refusal, finding, invalid) =>
110
+ invalid === null ? refusal : { code: 'autopilot_waiver_invalid', file: refusal.file, finding, detail: `the autopilot waiver of ${finding} in ${refusal.file} does not count: ${invalid}` };
82
111
 
83
112
  for (const late of mine.filter((r) => r.event === 'review.late_finding')) {
84
- if (rulings.some((r) => matches(r, late.file, late.finding)) || waived(late.file, late.finding)) continue;
85
- refusals.push({ code: 'late_unruled', file: late.file, finding: late.finding, detail: `late finding ${late.finding} in ${late.file} has no ruling` });
113
+ if (rulings.some((r) => matches(r, late.file, late.finding))) continue;
114
+ const w = waiverFor(late.file, late.finding);
115
+ if (w.waived) continue;
116
+ refusals.push(orInvalid({ code: 'late_unruled', file: late.file, finding: late.finding, detail: `late finding ${late.finding} in ${late.file} has no ruling` }, late.finding, w.invalid));
86
117
  }
87
118
 
88
119
  for (const cap of mine.filter((r) => r.event === 'review.cap')) {
89
120
  const hash = hashOf.get(cap.file);
90
121
  if (hash !== undefined && approvedFor(cap.file, hash).some((r) => verified(r, key))) continue;
91
122
  for (const finding of Array.isArray(cap.open) ? cap.open : []) {
92
- if (!waived(cap.file, finding)) refusals.push({ code: 'review_cap', file: cap.file, finding, detail: `${cap.file} stopped at review_cap; finding ${finding} is open and not waived` });
123
+ const w = waiverFor(cap.file, finding);
124
+ if (!w.waived) refusals.push(orInvalid({ code: 'review_cap', file: cap.file, finding, detail: `${cap.file} stopped at review_cap; finding ${finding} is open and not waived` }, finding, w.invalid));
93
125
  }
94
126
  }
95
127
  for (const r of checkBlockProof({ runId, files: proofFiles, rows: mine, key, highPaths })) {
96
- if (!waived(/** @type {string} */ (r.file), PROOF_FINDING)) refusals.push(r);
128
+ const w = waiverFor(/** @type {string} */ (r.file), PROOF_FINDING);
129
+ if (!w.waived) refusals.push(orInvalid(r, PROOF_FINDING, w.invalid));
97
130
  }
98
131
  return { ok: refusals.length === 0, refusals };
99
132
  }
100
133
 
134
+ /** The finding severities autopilot may waive (B47); anything else is never delegated. */
135
+ export const DELEGABLE_SEVERITIES = Object.freeze(['warning', 'nit']);
136
+
137
+ /** Severity order for {@link recordedSeverity}: the worst recorded one wins. */
138
+ const SEVERITY_RANK = Object.freeze({ nit: 0, warning: 1, critical: 2, blocker: 3 });
139
+
140
+ /**
141
+ * The severity the block's signed review rows record for one finding of one file (B47): every
142
+ * `review.triage` and `review.late_finding` row with that `block`, `file` and `finding`; the worst
143
+ * one wins. Fails closed: no row ⇒ `not-recorded`; any such row whose MAC does not verify ⇒
144
+ * `unverified`; a severity outside critical/blocker/warning/nit ⇒ `unknown` — each with
145
+ * `severity: null`.
146
+ * @param {Array<Record<string, any>>} rows - rows of ONE run (other runs filtered out by the caller).
147
+ * @param {{block: string, file: string, finding: string, key: Buffer}} where
148
+ * @returns {{severity: string, why: null} | {severity: null, why: 'not-recorded' | 'unverified' | 'unknown'}}
149
+ */
150
+ export function recordedSeverity(rows, { block, file, finding, key }) {
151
+ const named = rows.filter((r) => (r?.event === 'review.triage' || r?.event === 'review.late_finding') && r.block === block && r.file === file && r.finding === finding);
152
+ if (named.length === 0) return { severity: null, why: 'not-recorded' };
153
+ if (named.some((r) => !verified(r, key))) return { severity: null, why: 'unverified' };
154
+ /** @type {Record<string, number>} */
155
+ const rank = SEVERITY_RANK;
156
+ let worst = '';
157
+ for (const r of named) {
158
+ if (typeof r.severity !== 'string' || !Object.hasOwn(rank, r.severity)) return { severity: null, why: 'unknown' };
159
+ if (worst === '' || rank[r.severity] > rank[worst]) worst = r.severity;
160
+ }
161
+ return { severity: worst, why: null };
162
+ }
163
+
164
+ /**
165
+ * Why a `review.waived {by: autopilot}` row does NOT clear its finding, or null when it does (the
166
+ * rules are in the module doc). `blockRows`: the block's rows of this run (the review rows the
167
+ * severity is read from); `runRows`: every row of this run (grant, stop and expire rows carry no
168
+ * block); `allRows`: every row given (stop and expire rows are matched by grant id across all of
169
+ * them, whatever their `run`). A stop or expire row counts whether or not it is signed (fail closed).
170
+ * @param {Record<string, any>} waiver
171
+ * @param {{blockRows: Array<Record<string, any>>, runRows: Array<Record<string, any>>, allRows?: Array<Record<string, any>>, key: Buffer}} ctx
172
+ * @returns {string | null}
173
+ */
174
+ export function autopilotWaiverProblem(waiver, { blockRows, runRows, allRows = runRows, key }) {
175
+ if (!verified(waiver, key)) return 'the waiver has no valid MAC';
176
+ if (waiver.finding === PROOF_FINDING) return 'proof is never delegated';
177
+ const recorded = recordedSeverity(blockRows, { block: waiver.block, file: waiver.file, finding: waiver.finding, key });
178
+ if (recorded.severity === null) return `the finding's severity is ${recorded.why}; critical findings are never delegated`;
179
+ if (!DELEGABLE_SEVERITIES.includes(recorded.severity)) return `the finding is ${recorded.severity}; critical findings are never delegated`;
180
+ if (waiver.severity !== recorded.severity) return `the waiver says ${String(waiver.severity)} but the finding is recorded as ${recorded.severity}`;
181
+ const at = typeof waiver.ts === 'string' ? Date.parse(waiver.ts) : Number.NaN;
182
+ if (!Number.isFinite(at)) return 'the waiver has no time';
183
+ const scope = `waive:${recorded.severity}`;
184
+ return (
185
+ grantCoverProblem(runRows, { grantId: waiver.grant_id, scope, at, key, what: 'the waiver', endRows: allRows }) ??
186
+ decisionProblem(runRows, { decisionId: waiver.decision_id, grantId: waiver.grant_id, scope, subject: { block: waiver.block, file: waiver.file, finding: waiver.finding }, decision: 'waive', at, key })
187
+ );
188
+ }
189
+
190
+ /** A delegate decision allows an action for at most this long after it was made (B47). */
191
+ export const DECISION_MAX_AGE_MS = 30 * 60 * 1000;
192
+
193
+ /**
194
+ * Why grant `grantId` did NOT cover `scope` at time `at` (epoch ms), or null when it did (B47;
195
+ * shared by the waiver check and the fix loop's extra rounds): a signed `autopilot.grant` row of
196
+ * the run with that id, whose window (`ts` ≤ at < `until`) holds `at`, whose scopes include the
197
+ * scope and whose deny list does not, and no `autopilot.stop` / `autopilot.expire` row for it at
198
+ * or before `at`. A stop or expire row counts whether or not it is signed, and one with a missing
199
+ * or unparsable `ts` counts as having stopped the grant (fail closed). Stop and expire rows are
200
+ * looked for in `endRows` (default `runRows`) by grant id alone, whatever their `run`.
201
+ * @param {Array<Record<string, any>>} runRows - every row of the run.
202
+ * @param {{grantId: unknown, scope: string, at: number, key: Buffer, what?: string, endRows?: Array<Record<string, any>>}} q - `what` names the action in the message.
203
+ * @returns {string | null}
204
+ */
205
+ export function grantCoverProblem(runRows, { grantId, scope, at, key, what = 'the action', endRows = runRows }) {
206
+ const id = grantId;
207
+ const grant = runRows.find((r) => r?.event === 'autopilot.grant' && typeof id === 'string' && r.grant_id === id && verified(r, key));
208
+ if (!grant) return `no signed autopilot.grant row for grant ${String(id)} in this run`;
209
+ const from = Date.parse(grant.ts);
210
+ const until = Date.parse(grant.until);
211
+ if (!(Number.isFinite(from) && Number.isFinite(until) && at >= from && at < until)) return `${what} is outside grant ${id}'s window`;
212
+ if (!Array.isArray(grant.scopes) || !grant.scopes.includes(scope) || (Array.isArray(grant.deny) && grant.deny.includes(scope))) return `grant ${id} does not allow ${scope}`;
213
+ const ended = endRows.find((r) => {
214
+ if ((r?.event !== 'autopilot.stop' && r?.event !== 'autopilot.expire') || r.grant_id !== id) return false;
215
+ const ts = typeof r.ts === 'string' ? Date.parse(r.ts) : Number.NaN;
216
+ return !Number.isFinite(ts) || ts <= at;
217
+ });
218
+ if (ended) return `grant ${id} was ${ended.event === 'autopilot.stop' ? 'stopped' : 'expired'} before ${what}`;
219
+ return null;
220
+ }
221
+
222
+ /**
223
+ * Why decision `decisionId` does NOT allow the action, or null when it does (B47): a signed
224
+ * `autopilot.decision` row of the run with that `decision_id`, `acted: true`, the same grant and
225
+ * scope, the fixed option word the action needs as its `decision` (`waive` for a waiver, `allow`
226
+ * for an extra round, the level itself — `L0`/`L1`/`L2` — for a coder level; so an acted "fix" or
227
+ * "deny" never authorises anything), a `subject` naming the same block (and file / finding when
228
+ * the action has them), made at or before `at` and at most {@link DECISION_MAX_AGE_MS} before it.
229
+ * @param {Array<Record<string, any>>} runRows
230
+ * @param {{decisionId: unknown, grantId: unknown, scope: string, subject: {block: string, file?: string, finding?: string}, decision: string, at: number, key: Buffer}} q
231
+ * @returns {string | null}
232
+ */
233
+ export function decisionProblem(runRows, { decisionId, grantId, scope, subject, decision, at, key }) {
234
+ if (typeof decisionId !== 'string' || decisionId.length === 0) return 'no delegate decision named';
235
+ const row = runRows.find((r) => r?.event === 'autopilot.decision' && r.decision_id === decisionId);
236
+ if (!row) return `no autopilot.decision row ${decisionId} in this run`;
237
+ if (!verified(row, key)) return `decision ${decisionId} has no valid MAC`;
238
+ if (row.acted !== true) return `decision ${decisionId} did not act (it went to the owner)`;
239
+ if (row.grant_id !== grantId) return `decision ${decisionId} is under another grant`;
240
+ if (row.scope !== scope) return `decision ${decisionId} is for ${String(row.scope)}, not ${scope}`;
241
+ if (row.decision !== decision) return `decision ${decisionId} decided ${JSON.stringify(row.decision)}, not ${JSON.stringify(decision)}`;
242
+ const named = row.subject && typeof row.subject === 'object' ? row.subject : {};
243
+ for (const k of /** @type {Array<'block' | 'file' | 'finding'>} */ (['block', 'file', 'finding'])) {
244
+ if (subject[k] !== undefined && named[k] !== subject[k]) return `decision ${decisionId} is about another ${k}`;
245
+ }
246
+ const ts = typeof row.ts === 'string' ? Date.parse(row.ts) : Number.NaN;
247
+ if (!Number.isFinite(ts) || ts > at || at - ts > DECISION_MAX_AGE_MS) return `decision ${decisionId} is older than 30 min (or not yet made)`;
248
+ return null;
249
+ }
250
+
101
251
  /** The finding id a human waives to clear an `unproven` file: `block waive <id> proof --file <path>`. */
102
252
  export const PROOF_FINDING = 'proof';
103
253