@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.
- package/CHANGELOG.md +37 -0
- package/README.md +13 -0
- package/docs/README.md +1 -0
- package/docs/reference/blocks.json +96 -1
- package/docs/reference/config.md +2 -0
- package/package.json +1 -1
- package/schema/code-forge.schema.json +7 -0
- package/skill/SKILL.md +1 -0
- package/skill/references/adapters/claude-code.md +14 -0
- package/skill/references/autopilot.md +48 -0
- package/skill/references/code.md +1 -1
- package/skill/references/decisions.md +2 -0
- package/skill/references/harden.md +1 -1
- package/skill/references/plan.md +2 -0
- package/skill/references/review.md +2 -2
- package/skill/scripts/forge +1 -1
- package/src/autopilot/actions.mjs +491 -0
- package/src/autopilot/binnacle.mjs +494 -0
- package/src/autopilot/delegate.mjs +422 -0
- package/src/autopilot/grant.mjs +354 -0
- package/src/autopilot/limits.mjs +847 -0
- package/src/autopilot/scopes.mjs +95 -0
- package/src/cli/author.mjs +1 -2
- package/src/cli/autopilot.mjs +500 -0
- package/src/cli/block.mjs +40 -8
- package/src/cli/facts.mjs +1 -2
- package/src/cli/jev.mjs +1 -2
- package/src/cli/logs.mjs +4 -142
- package/src/cli/plan.mjs +1 -2
- package/src/cli/run.mjs +18 -60
- package/src/cli/s2.mjs +1 -2
- package/src/cli/spawn.mjs +1 -2
- package/src/config/closed-book.mjs +20 -3
- package/src/config/load.mjs +15 -0
- package/src/doctor/probes.mjs +34 -11
- package/src/engines/builders/claude.mjs +3 -3
- package/src/engines/builders/codex.mjs +6 -5
- package/src/engines/builders/grok.mjs +3 -3
- package/src/engines/builders/validate-params.mjs +3 -3
- package/src/ledger/report.mjs +2 -0
- package/src/review/fixloop.mjs +69 -5
- package/src/review/gate-check.mjs +156 -6
- package/src/session/spawn.mjs +68 -8
- package/src/state/run.mjs +8 -1
- package/src/util/breadcrumbs.mjs +1 -0
- package/src/util/forbidden.mjs +10 -0
- package/src/util/gh.mjs +175 -0
- package/src/worker/engine.mjs +8 -4
- package/src/worker/loop.mjs +42 -0
package/src/doctor/probes.mjs
CHANGED
|
@@ -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
|
-
|
|
200
|
-
|
|
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(
|
|
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(
|
|
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:
|
|
214
|
-
...(
|
|
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(
|
|
218
|
-
else if (res.status === 'ok') rows.push(row(
|
|
219
|
-
else if (res.status === 'invalid-output') rows.push(row(
|
|
220
|
-
else rows.push(row(
|
|
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"`.
|
package/src/ledger/report.mjs
CHANGED
package/src/review/fixloop.mjs
CHANGED
|
@@ -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 {
|
|
118
|
-
|
|
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:
|
|
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
|
-
|
|
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))
|
|
85
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|