@codedology/code-forge 0.5.0 → 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 +44 -0
- package/README.md +23 -3
- 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
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Delegated actions (issue #5, plan autopilot §1, block B47): what autopilot may do for the owner
|
|
3
|
+
* while a grant covers it. Each action asks `grantFor(run, scope, now)` (B45) first; a refusal
|
|
4
|
+
* sends the question back to the owner and writes nothing.
|
|
5
|
+
*
|
|
6
|
+
* - {@link waiveForOwner} (`autopilot waive`, scope `waive:<severity>`): a signed
|
|
7
|
+
* `review.waived` row with `by: 'autopilot'`, `grant_id`, `severity` and `issue`. Only for a
|
|
8
|
+
* finding the block's SIGNED review rows record as `warning` or `nit` (`recordedSeverity`, the
|
|
9
|
+
* same function the gate uses — the `--severity` flag is never trusted alone): a finding
|
|
10
|
+
* recorded as critical/blocker, with no recorded severity, or with an unverifiable row is
|
|
11
|
+
* refused ("critical findings are never delegated"); `proof` is never delegable.
|
|
12
|
+
* Each waiver opens ONE tracked issue in the PROJECT's GitHub repo (`gh repo view` in the
|
|
13
|
+
* run's workspace — never this package's repo), deduped by a body marker
|
|
14
|
+
* {@link waiverMarker}: an issue that already holds it gets a comment instead. The body is
|
|
15
|
+
* scrubbed; a secret-shaped text left in it blocks the issue (not the waiver). No `gh`, not
|
|
16
|
+
* signed in, no repo, a failed call → the waiver still stands with `issue: null`, plus one
|
|
17
|
+
* signed `autopilot.issue_failed` row naming why (a code, never gh's output). Never asks.
|
|
18
|
+
* - {@link grantExtraRound} (`autopilot round`, scope `round:extra`): one signed
|
|
19
|
+
* `autopilot.extra_round` row — ONE fix round past the file's cap per grant (the fix loop and
|
|
20
|
+
* the worker read it through `extraRoundsFor`); a second request for the file under the same
|
|
21
|
+
* grant is refused.
|
|
22
|
+
* - {@link chooseCoderLevel} (`autopilot level`, scope `model:choose`): the block's coder level
|
|
23
|
+
* = its recorded Jev lane (`recordedLanes`, the rows `jev ask lane --block` writes), capped at
|
|
24
|
+
* the plan's level + 1 and never above L2 (L3 is never a coding level); one signed
|
|
25
|
+
* `autopilot.level` row. `block open` without `--level` takes the latest such row.
|
|
26
|
+
*
|
|
27
|
+
* The delegate decides (issue #5): every action needs `--decision <id>`, a signed
|
|
28
|
+
* `autopilot.decision` row (B46, `autopilot ask`) with `acted: true`, the same grant and scope,
|
|
29
|
+
* a `subject` naming the same block (+ file + finding for waive, + file for round), made at most
|
|
30
|
+
* 30 min before, whose `decision` is the fixed option word of the action — `waive` (waive),
|
|
31
|
+
* `allow` (round), the level being set `L0`/`L1`/`L2` (level) — so the orchestrator asks with
|
|
32
|
+
* `--options waive,fix`, `--options allow,deny` or `--options L1,L2` (`decisionProblem` in
|
|
33
|
+
* `review/gate-check.mjs`, the check the gate and the fix loop use too). The action row records
|
|
34
|
+
* the `decision_id`.
|
|
35
|
+
*
|
|
36
|
+
* Stop race: every action checks the grant (`grantFor`) and the decision first, then — for the
|
|
37
|
+
* write — takes the run lock, reads the run record again with a FRESH clock and re-checks the
|
|
38
|
+
* grant ({@link liveGrantRefusal}: same grant, run active, not stopped or expired, scope allowed)
|
|
39
|
+
* and the decision, and writes its row inside that lock dated with that fresh time. A grant
|
|
40
|
+
* stopped or expired in between (e.g. while gh ran) means no row; a waiver issue already created
|
|
41
|
+
* then gets one best-effort comment "waiver cancelled: autopilot grant ended".
|
|
42
|
+
*
|
|
43
|
+
* The per-finding lock is in-process only (`keyedLock`); two separate processes waiving the same
|
|
44
|
+
* finding at once rely on the marker search alone (accepted, B47 fix round 1).
|
|
45
|
+
*
|
|
46
|
+
* Tracked issues: the dedupe searches the exact quoted marker with up to 100 results and confirms
|
|
47
|
+
* it in the bodies; a full page without it is `search-failed` (no new issue). Two waives of one
|
|
48
|
+
* finding in this process run one at a time (`keyedLock`), so they open one issue.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
import { readFile } from 'node:fs/promises';
|
|
52
|
+
import path from 'node:path';
|
|
53
|
+
import { appendRow, readAllRows } from '../ledger/write.mjs';
|
|
54
|
+
import { DELEGABLE_SEVERITIES, decisionProblem, PROOF_FINDING, recordedSeverity, repoRelativePath } from '../review/gate-check.mjs';
|
|
55
|
+
import { parsePlan, recordedLanes } from '../session/plan-check.mjs';
|
|
56
|
+
import { StateError } from '../state/paths.mjs';
|
|
57
|
+
import { readRun, withRunLock, writeSigned } from '../state/run.mjs';
|
|
58
|
+
import { loadKey, verifyRow } from '../state/signer.mjs';
|
|
59
|
+
import { exec as realExec } from '../util/exec.mjs';
|
|
60
|
+
import { findReported, findSecret, ghRun, ghWithBodyFile, pathLookup } from '../util/gh.mjs';
|
|
61
|
+
import { keyedLock } from '../util/locks.mjs';
|
|
62
|
+
import { scrub } from './delegate.mjs';
|
|
63
|
+
import { grantFor, grantState } from './grant.mjs';
|
|
64
|
+
import { isFixedDeny } from './scopes.mjs';
|
|
65
|
+
|
|
66
|
+
/** @typedef {(row: Record<string, any>) => Promise<unknown>} WriteRow */
|
|
67
|
+
/**
|
|
68
|
+
* @typedef {object} ActionDeps
|
|
69
|
+
* @property {() => Date} [now] - the clock.
|
|
70
|
+
* @property {WriteRow} [writeRow] - the ledger writer (default: the run's project ledger).
|
|
71
|
+
* @property {() => Promise<Array<Record<string, any>>>} [readRows] - the project's ledger rows (default: `readAllRows`).
|
|
72
|
+
* @property {typeof realExec} [exec] - runs `gh` (tests: a fake).
|
|
73
|
+
* @property {NodeJS.ProcessEnv} [env]
|
|
74
|
+
* @property {(cmd: string) => boolean} [onPath] - whether `cmd` is on PATH.
|
|
75
|
+
*/
|
|
76
|
+
/** @typedef {{ok: true, row: Record<string, any>} | {ok: false, reason: string, message: string}} ActionResult */
|
|
77
|
+
|
|
78
|
+
/** The label a waiver issue asks for (retried without it when the repo has no such label). */
|
|
79
|
+
export const WAIVER_LABEL = 'autopilot-waiver';
|
|
80
|
+
|
|
81
|
+
/** A finding id, as `block waive` accepts it. */
|
|
82
|
+
const FINDING_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/;
|
|
83
|
+
|
|
84
|
+
/** The refusal text for anything above warning. */
|
|
85
|
+
export const NEVER_CRITICAL = 'critical findings are never delegated';
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The body marker an issue for one waived finding carries; the dedupe searches for it.
|
|
89
|
+
* @param {string} runId @param {string} block @param {string} file @param {string} finding
|
|
90
|
+
* @returns {string}
|
|
91
|
+
*/
|
|
92
|
+
export const waiverMarker = (runId, block, file, finding) => `<!-- code-forge-waiver: ${runId}/${block}/${file}/${finding} -->`;
|
|
93
|
+
|
|
94
|
+
/** @param {Record<string, any>} record @param {ActionDeps} deps @returns {WriteRow} */
|
|
95
|
+
const writerFor = (record, deps) => deps.writeRow ?? ((row) => appendRow(row, { slug: record.project }));
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The run's rows (other runs and unparsable rows dropped).
|
|
99
|
+
* @param {Record<string, any>} record @param {ActionDeps} deps @returns {Promise<Array<Record<string, any>>>}
|
|
100
|
+
*/
|
|
101
|
+
async function runRowsOf(record, deps) {
|
|
102
|
+
const all = deps.readRows ? await deps.readRows() : await readAllRows(record.project);
|
|
103
|
+
return all.filter((r) => r?.run === record.run_id);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Sign and write `row`; the signed row is returned.
|
|
108
|
+
* @param {string} runId @param {WriteRow} write @param {Record<string, any>} row
|
|
109
|
+
* @returns {Promise<Record<string, any>>}
|
|
110
|
+
*/
|
|
111
|
+
async function signed(runId, write, row) {
|
|
112
|
+
/** @type {Record<string, any> | null} */
|
|
113
|
+
let out = null;
|
|
114
|
+
await writeSigned(runId, async (r) => {
|
|
115
|
+
out = r;
|
|
116
|
+
return write(r);
|
|
117
|
+
}, row);
|
|
118
|
+
return /** @type {Record<string, any>} */ (out);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** @param {Record<string, any>} record @param {string} block */
|
|
122
|
+
function assertBlock(record, block) {
|
|
123
|
+
if (typeof block !== 'string' || !record.blocks?.[block]) throw new StateError('unknown_block', `block ${block} is not in run ${record.run_id}`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** @param {string} reason @returns {string} */
|
|
127
|
+
const grantRefusal = (reason) => `the grant refuses (${reason}); the question goes to the owner`;
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The waiver issue text (already scrubbed). `marker` ends the body.
|
|
131
|
+
* @param {{runId: string, block: string, file: string, finding: string, severity: string, reason: string, grantId: string, ts: string}} w
|
|
132
|
+
* @returns {{title: string, body: string, comment: string, marker: string}}
|
|
133
|
+
*/
|
|
134
|
+
export function waiverIssueText(w) {
|
|
135
|
+
const marker = waiverMarker(w.runId, w.block, w.file, w.finding);
|
|
136
|
+
const reason = scrub(w.reason).replace(/\r?\n/g, ' ');
|
|
137
|
+
const title = scrub(`code-forge autopilot waived ${w.severity} ${w.finding} in ${w.file} (block ${w.block})`);
|
|
138
|
+
const body = [
|
|
139
|
+
`code-forge autopilot waived a ${w.severity} review finding while the owner was away. It still needs a follow-up.`,
|
|
140
|
+
'',
|
|
141
|
+
'| field | value |',
|
|
142
|
+
'|---|---|',
|
|
143
|
+
`| run | ${w.runId} |`,
|
|
144
|
+
`| block | ${w.block} |`,
|
|
145
|
+
`| file | \`${w.file}\` |`,
|
|
146
|
+
`| finding | ${w.finding} |`,
|
|
147
|
+
`| severity | ${w.severity} |`,
|
|
148
|
+
`| grant | ${w.grantId} |`,
|
|
149
|
+
`| waived at | ${w.ts} |`,
|
|
150
|
+
'',
|
|
151
|
+
`Reason: ${reason}`,
|
|
152
|
+
'',
|
|
153
|
+
marker,
|
|
154
|
+
'',
|
|
155
|
+
].join('\n');
|
|
156
|
+
const comment = `Waived again by code-forge autopilot at ${w.ts} (grant ${w.grantId}). Reason: ${reason}\n\n${marker}\n`;
|
|
157
|
+
return { title, body: scrub(body), comment: scrub(comment), marker };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The only `autopilot.issue_failed` reasons; anything else is written as `gh-failed`. */
|
|
161
|
+
export const ISSUE_FAILURES = Object.freeze(['gh-missing', 'gh-not-logged-in', 'no-project-repo', 'secret-in-body', 'search-failed', 'comment-failed', 'create-failed', 'gh-failed']);
|
|
162
|
+
|
|
163
|
+
/** At most this many search results are read for the dedupe (a full page with no exact match is `search-failed`). */
|
|
164
|
+
export const DEDUPE_LIMIT = 100;
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Open (or comment on) the tracked issue for a waiver. Never throws; never asks. The failure is a
|
|
168
|
+
* fixed code from {@link ISSUE_FAILURES} — never gh's output.
|
|
169
|
+
* @param {{workspace: string, text: ReturnType<typeof waiverIssueText>}} req
|
|
170
|
+
* @param {ActionDeps} deps
|
|
171
|
+
* @returns {Promise<{url: string, action: 'created' | 'commented'} | {url: null, failure: string}>}
|
|
172
|
+
*/
|
|
173
|
+
export async function trackWaiverIssue({ workspace, text }, deps) {
|
|
174
|
+
/** @param {string} code @returns {{url: null, failure: string}} */
|
|
175
|
+
const failed = (code) => ({ url: null, failure: ISSUE_FAILURES.includes(code) ? code : 'gh-failed' });
|
|
176
|
+
try {
|
|
177
|
+
const env = deps.env ?? process.env;
|
|
178
|
+
const exec = deps.exec ?? realExec;
|
|
179
|
+
const onPath = deps.onPath ?? pathLookup(env.PATH ?? '');
|
|
180
|
+
if (!onPath('gh')) return failed('gh-missing');
|
|
181
|
+
if ((await ghRun(exec, ['gh', 'auth', 'status'], env)).result !== 'ok') return failed('gh-not-logged-in');
|
|
182
|
+
const view = await ghRun(exec, ['gh', 'repo', 'view', '--json', 'nameWithOwner'], env, { cwd: workspace });
|
|
183
|
+
if (view.result !== 'ok') return failed('no-project-repo');
|
|
184
|
+
/** @type {string | null} */
|
|
185
|
+
let repo = null;
|
|
186
|
+
try {
|
|
187
|
+
const name = JSON.parse(view.stdout)?.nameWithOwner;
|
|
188
|
+
repo = typeof name === 'string' && /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(name) ? name : null;
|
|
189
|
+
} catch {
|
|
190
|
+
repo = null;
|
|
191
|
+
}
|
|
192
|
+
if (repo === null) return failed('no-project-repo');
|
|
193
|
+
if (findSecret(`${text.title}\n${text.body}\n${text.comment}`) !== null) return failed('secret-in-body');
|
|
194
|
+
// the exact marker, quoted, and up to 100 results; the bodies are checked for the exact marker
|
|
195
|
+
const search = await findReported(exec, env, repo, `"${text.marker}"`, { match: text.marker, limit: DEDUPE_LIMIT });
|
|
196
|
+
if ('why' in search) return failed('search-failed');
|
|
197
|
+
const target = repo;
|
|
198
|
+
if (search.hit !== null) {
|
|
199
|
+
const dup = search.hit;
|
|
200
|
+
const why = await ghWithBodyFile(exec, env, text.comment, (file) => [['gh', 'issue', 'comment', String(dup.number), '--repo', target, '--body-file', file]]);
|
|
201
|
+
return why === null ? { url: dup.url, action: 'commented' } : failed('comment-failed');
|
|
202
|
+
}
|
|
203
|
+
/** @type {string | null} */
|
|
204
|
+
let url = null;
|
|
205
|
+
const why = await ghWithBodyFile(
|
|
206
|
+
exec,
|
|
207
|
+
env,
|
|
208
|
+
text.body,
|
|
209
|
+
(file) => {
|
|
210
|
+
const base = ['gh', 'issue', 'create', '--repo', target, '--title', text.title, '--body-file', file];
|
|
211
|
+
return [[...base, '--label', WAIVER_LABEL], base];
|
|
212
|
+
},
|
|
213
|
+
(stdout) => {
|
|
214
|
+
url = stdout.split('\n').map((l) => l.trim()).filter((l) => /^https:\/\/\S+$/.test(l)).pop() ?? null;
|
|
215
|
+
},
|
|
216
|
+
);
|
|
217
|
+
if (why !== null || url === null) return failed('create-failed');
|
|
218
|
+
return { url, action: 'created' };
|
|
219
|
+
} catch {
|
|
220
|
+
return failed('gh-failed');
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* The delegate-decision check for an action (`decisionProblem` over the run's rows), as a
|
|
226
|
+
* refusal, or null when the decision allows it.
|
|
227
|
+
* @param {Array<Record<string, any>>} rows @param {Parameters<typeof decisionProblem>[1]} q
|
|
228
|
+
* @returns {{ok: false, reason: string, message: string} | null}
|
|
229
|
+
*/
|
|
230
|
+
function decisionRefusal(rows, q) {
|
|
231
|
+
const problem = decisionProblem(rows, q);
|
|
232
|
+
return problem === null ? null : { ok: false, reason: 'no-decision', message: `${problem}; the question goes to the owner (autopilot ask, then --decision <id>)` };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* The grant re-check made INSIDE the run lock (where `grantFor` cannot run: its lazy expiry takes
|
|
237
|
+
* the same lock). Reads the run record as it is now: the run must be active and its grant the same
|
|
238
|
+
* one, still active at `now` (not stopped, not past `until`), allowing `scope`. An expired grant's
|
|
239
|
+
* one expire row is left to the next `grantFor` check.
|
|
240
|
+
* @param {string} runId @param {{grantId: string, scope: string, now: Date}} q
|
|
241
|
+
* @returns {Promise<{ok: false, reason: string, message: string} | null>}
|
|
242
|
+
*/
|
|
243
|
+
export async function liveGrantRefusal(runId, { grantId, scope, now }) {
|
|
244
|
+
const record = await readRun(runId);
|
|
245
|
+
const grant = record.autopilot && typeof record.autopilot === 'object' ? record.autopilot : null;
|
|
246
|
+
/** @type {string | null} */
|
|
247
|
+
let reason = null;
|
|
248
|
+
if (grant === null || grant.grant_id !== grantId) reason = 'no-grant';
|
|
249
|
+
else if (record.status !== 'active') reason = 'run-ended';
|
|
250
|
+
else {
|
|
251
|
+
const state = grantState(grant, now);
|
|
252
|
+
if (state !== 'active') reason = state;
|
|
253
|
+
else if (isFixedDeny(scope) || grant.deny.includes(scope)) reason = 'denied';
|
|
254
|
+
else if (!grant.scopes.includes(scope)) reason = 'not-allowed';
|
|
255
|
+
}
|
|
256
|
+
return reason === null ? null : { ok: false, reason, message: grantRefusal(reason) };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** The comment a created waiver issue gets when its waiver was not written. */
|
|
260
|
+
export const CANCELLED_COMMENT = 'waiver cancelled: autopilot grant ended';
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Best effort: one comment on the waiver issue saying the waiver was cancelled. Never throws.
|
|
264
|
+
* @param {string} url @param {string} marker @param {ActionDeps} deps
|
|
265
|
+
*/
|
|
266
|
+
async function cancelOnIssue(url, marker, deps) {
|
|
267
|
+
try {
|
|
268
|
+
const m = /^https:\/\/github\.com\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)\/issues\/(\d+)$/.exec(url);
|
|
269
|
+
if (!m) return;
|
|
270
|
+
await ghWithBodyFile(deps.exec ?? realExec, deps.env ?? process.env, `${CANCELLED_COMMENT}\n\n${marker}\n`, (file) => [['gh', 'issue', 'comment', m[2], '--repo', m[1], '--body-file', file]]);
|
|
271
|
+
} catch {
|
|
272
|
+
// best effort: the missing waiver row is what the gate reads
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** @param {unknown} id @param {string} sub */
|
|
277
|
+
function needDecision(id, sub) {
|
|
278
|
+
if (typeof id !== 'string' || !/^[A-Za-z0-9._:-]{1,64}$/.test(id)) throw new StateError('usage', `autopilot ${sub} needs --decision <id> (the delegate's acted decision from autopilot ask)`);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* `autopilot waive`: see the module doc.
|
|
283
|
+
* @param {{runId: string, block: string, file: string, finding: string, severity: string, reason: string, decisionId: string}} req
|
|
284
|
+
* @param {ActionDeps} [deps]
|
|
285
|
+
* @returns {Promise<{ok: true, row: Record<string, any>, issue: string | null, issueAction: string | null, issueFailure: string | null, issueRowWritten: boolean} | {ok: false, reason: string, message: string}>}
|
|
286
|
+
* @throws {StateError} `usage` for a malformed request; `unknown_block`, `no-run`.
|
|
287
|
+
*/
|
|
288
|
+
export async function waiveForOwner(req, deps = {}) {
|
|
289
|
+
const clock = deps.now ?? (() => new Date());
|
|
290
|
+
if (!DELEGABLE_SEVERITIES.includes(req.severity)) throw new StateError('usage', `--severity must be warning or nit (${NEVER_CRITICAL})`);
|
|
291
|
+
if (typeof req.finding !== 'string' || !FINDING_PATTERN.test(req.finding)) throw new StateError('usage', 'a finding id is required (letters, digits, . _ : -)');
|
|
292
|
+
if (typeof req.reason !== 'string' || req.reason.trim().length === 0) throw new StateError('usage', 'autopilot waive needs --reason <text>');
|
|
293
|
+
// the reason goes into a public issue: a secret-shaped text left after the scrub refuses the waive
|
|
294
|
+
const reason = scrub(req.reason.trim());
|
|
295
|
+
if (findSecret(reason) !== null) throw new StateError('usage', 'the reason looks like it holds a secret; reword it');
|
|
296
|
+
if (req.finding === PROOF_FINDING) return { ok: false, reason: 'never-delegated', message: 'proof is never delegated; only the owner can waive it (block waive)' };
|
|
297
|
+
needDecision(req.decisionId, 'waive');
|
|
298
|
+
let file;
|
|
299
|
+
try {
|
|
300
|
+
file = repoRelativePath(req.file);
|
|
301
|
+
} catch {
|
|
302
|
+
throw new StateError('usage', '--file must be a repo-relative path');
|
|
303
|
+
}
|
|
304
|
+
const record = await readRun(req.runId);
|
|
305
|
+
assertBlock(record, req.block);
|
|
306
|
+
const key = await loadKey(req.runId);
|
|
307
|
+
const recorded = recordedSeverity(await runRowsOf(record, deps), { block: req.block, file, finding: req.finding, key });
|
|
308
|
+
if (recorded.severity === null) {
|
|
309
|
+
return { ok: false, reason: 'severity-unknown', message: `finding ${req.finding} in ${file} has no verifiable recorded severity (${recorded.why}); ${NEVER_CRITICAL}` };
|
|
310
|
+
}
|
|
311
|
+
if (!DELEGABLE_SEVERITIES.includes(recorded.severity)) {
|
|
312
|
+
return { ok: false, reason: 'critical', message: `finding ${req.finding} in ${file} is recorded as ${recorded.severity}; ${NEVER_CRITICAL}` };
|
|
313
|
+
}
|
|
314
|
+
if (recorded.severity !== req.severity) {
|
|
315
|
+
return { ok: false, reason: 'severity-mismatch', message: `finding ${req.finding} in ${file} is recorded as ${recorded.severity}, not ${req.severity}` };
|
|
316
|
+
}
|
|
317
|
+
const scope = `waive:${recorded.severity}`;
|
|
318
|
+
const now = clock();
|
|
319
|
+
const write = writerFor(record, deps);
|
|
320
|
+
const gate = await grantFor(req.runId, scope, now, { writeRow: deps.writeRow });
|
|
321
|
+
if (!gate.ok) return { ok: false, reason: gate.reason, message: grantRefusal(gate.reason) };
|
|
322
|
+
const grantId = gate.grant.grant_id;
|
|
323
|
+
const subject = { block: req.block, file, finding: req.finding };
|
|
324
|
+
const refused = decisionRefusal(await runRowsOf(record, deps), { decisionId: req.decisionId, grantId, scope, subject, decision: 'waive', at: now.getTime(), key });
|
|
325
|
+
if (refused) return refused;
|
|
326
|
+
const ts = now.toISOString();
|
|
327
|
+
const text = waiverIssueText({ runId: req.runId, block: req.block, file, finding: req.finding, severity: recorded.severity, reason, grantId, ts });
|
|
328
|
+
|
|
329
|
+
// one finding at a time in this process: the dedupe search, the create and the waiver row of
|
|
330
|
+
// two concurrent waives never interleave, so they open one issue
|
|
331
|
+
return keyedLock(`autopilot-waive\0${req.runId}\0${req.block}\0${file}\0${req.finding}`, async () => {
|
|
332
|
+
const issue = await trackWaiverIssue({ workspace: record.workspace, text }, deps);
|
|
333
|
+
// the gh work may have outlived the grant: re-check grant and decision under the run lock with
|
|
334
|
+
// a fresh clock, and write the waiver there, dated then
|
|
335
|
+
/** @type {{ok: true, row: Record<string, any>} | {ok: false, reason: string, message: string}} */
|
|
336
|
+
const done = await withRunLock(req.runId, async () => {
|
|
337
|
+
const fresh = clock();
|
|
338
|
+
const ended = await liveGrantRefusal(req.runId, { grantId, scope, now: fresh });
|
|
339
|
+
if (ended) return ended;
|
|
340
|
+
const stale = decisionRefusal(await runRowsOf(record, deps), { decisionId: req.decisionId, grantId, scope, subject, decision: 'waive', at: fresh.getTime(), key });
|
|
341
|
+
if (stale) return stale;
|
|
342
|
+
const row = await signed(req.runId, write, {
|
|
343
|
+
event: 'review.waived',
|
|
344
|
+
block: req.block,
|
|
345
|
+
file,
|
|
346
|
+
finding: req.finding,
|
|
347
|
+
reason,
|
|
348
|
+
by: 'autopilot',
|
|
349
|
+
grant_id: grantId,
|
|
350
|
+
decision_id: req.decisionId,
|
|
351
|
+
severity: recorded.severity,
|
|
352
|
+
issue: issue.url,
|
|
353
|
+
ts: fresh.toISOString(),
|
|
354
|
+
});
|
|
355
|
+
return { ok: /** @type {const} */ (true), row };
|
|
356
|
+
});
|
|
357
|
+
if (done.ok === false) {
|
|
358
|
+
if (issue.url !== null) await cancelOnIssue(issue.url, text.marker, deps);
|
|
359
|
+
return done;
|
|
360
|
+
}
|
|
361
|
+
const row = /** @type {{row: Record<string, any>}} */ (done).row;
|
|
362
|
+
/** @type {string | null} */
|
|
363
|
+
let failure = null;
|
|
364
|
+
let issueRowWritten = true;
|
|
365
|
+
if (issue.url === null) {
|
|
366
|
+
failure = /** @type {{failure: string}} */ (issue).failure;
|
|
367
|
+
try {
|
|
368
|
+
await signed(req.runId, write, { event: 'autopilot.issue_failed', block: req.block, file, finding: req.finding, grant_id: grantId, reason: failure, ts: row.ts });
|
|
369
|
+
} catch {
|
|
370
|
+
issueRowWritten = false; // the waiver stands; the result says the issue row is missing
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
return { ok: /** @type {const} */ (true), row, issue: issue.url, issueAction: issue.url === null ? null : /** @type {{action: string}} */ (issue).action, issueFailure: failure, issueRowWritten };
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* `autopilot round`: one extra fix round past the file's cap, once per file per grant, on the
|
|
379
|
+
* delegate's acted decision for that block and file.
|
|
380
|
+
* @param {{runId: string, block: string, file: string, decisionId: string}} req @param {ActionDeps} [deps]
|
|
381
|
+
* @returns {Promise<ActionResult>}
|
|
382
|
+
* @throws {StateError} `usage`, `unknown_block`, `no-run`.
|
|
383
|
+
*/
|
|
384
|
+
export async function grantExtraRound(req, deps = {}) {
|
|
385
|
+
const clock = deps.now ?? (() => new Date());
|
|
386
|
+
needDecision(req.decisionId, 'round');
|
|
387
|
+
let file;
|
|
388
|
+
try {
|
|
389
|
+
file = repoRelativePath(req.file);
|
|
390
|
+
} catch {
|
|
391
|
+
throw new StateError('usage', '--file must be a repo-relative path');
|
|
392
|
+
}
|
|
393
|
+
const record = await readRun(req.runId);
|
|
394
|
+
assertBlock(record, req.block);
|
|
395
|
+
const now = clock();
|
|
396
|
+
const gate = await grantFor(req.runId, 'round:extra', now, { writeRow: deps.writeRow });
|
|
397
|
+
if (!gate.ok) return { ok: false, reason: gate.reason, message: grantRefusal(gate.reason) };
|
|
398
|
+
const grantId = gate.grant.grant_id;
|
|
399
|
+
const key = await loadKey(req.runId);
|
|
400
|
+
const write = writerFor(record, deps);
|
|
401
|
+
// check-then-write under the run lock: two requests for one file never both get a round
|
|
402
|
+
return withRunLock(req.runId, async () => {
|
|
403
|
+
const fresh = clock();
|
|
404
|
+
const ended = await liveGrantRefusal(req.runId, { grantId, scope: 'round:extra', now: fresh });
|
|
405
|
+
if (ended) return ended;
|
|
406
|
+
const rows = await runRowsOf(record, deps);
|
|
407
|
+
const refused = decisionRefusal(rows, { decisionId: req.decisionId, grantId, scope: 'round:extra', subject: { block: req.block, file }, decision: 'allow', at: fresh.getTime(), key });
|
|
408
|
+
if (refused) return refused;
|
|
409
|
+
const used = rows.some((r) => r.event === 'autopilot.extra_round' && r.block === req.block && r.file === file && r.grant_id === grantId && verifyRow(r, key).ok);
|
|
410
|
+
if (used) return { ok: false, reason: 'already-used', message: `${file} already had its extra round under grant ${grantId}` };
|
|
411
|
+
const row = await signed(req.runId, write, { event: 'autopilot.extra_round', block: req.block, file, grant_id: grantId, decision_id: req.decisionId, ts: fresh.toISOString() });
|
|
412
|
+
return { ok: true, row };
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** @param {string} level @returns {number} */
|
|
417
|
+
const levelIndex = (level) => Number(level.slice(1));
|
|
418
|
+
|
|
419
|
+
/** The highest level a coder ever runs at (L3 is never a coding level, R1). */
|
|
420
|
+
export const CODER_MAX_LEVEL = 'L2';
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* The coder level for a block: the recorded lane, at most the plan's level + 1, at most L2.
|
|
424
|
+
* @param {{lane: string, planLevel: string}} input - both `L0`–`L3`.
|
|
425
|
+
* @returns {string}
|
|
426
|
+
*/
|
|
427
|
+
export function chooseLevel({ lane, planLevel }) {
|
|
428
|
+
const level = Math.min(levelIndex(lane), levelIndex(planLevel) + 1, levelIndex(CODER_MAX_LEVEL));
|
|
429
|
+
return `L${level}`;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* `autopilot level`: see the module doc. The plan's level comes from the plan file's block table
|
|
434
|
+
* (`parsePlan`); the lane from the project ledger's `decision` rows for `lane` with this block
|
|
435
|
+
* (rows naming another plan file are skipped, as `plan check` does).
|
|
436
|
+
* @param {{runId: string, block: string, plan: string, decisionId: string}} req - `plan`: the plan file (absolute or relative to the process cwd).
|
|
437
|
+
* @param {ActionDeps} [deps]
|
|
438
|
+
* @returns {Promise<ActionResult>}
|
|
439
|
+
* @throws {StateError} `usage` (no plan file, the block not in it, no level for it, no --decision), `no-run`.
|
|
440
|
+
*/
|
|
441
|
+
export async function chooseCoderLevel(req, deps = {}) {
|
|
442
|
+
const clock = deps.now ?? (() => new Date());
|
|
443
|
+
if (typeof req.block !== 'string' || !/^[A-Za-z0-9._-]{1,32}$/.test(req.block)) throw new StateError('usage', 'autopilot level needs --block <id>');
|
|
444
|
+
if (typeof req.plan !== 'string' || req.plan.length === 0) throw new StateError('usage', 'autopilot level needs --plan <file> (the plan whose block table gives the level)');
|
|
445
|
+
needDecision(req.decisionId, 'level');
|
|
446
|
+
let text;
|
|
447
|
+
try {
|
|
448
|
+
text = await readFile(req.plan, 'utf8');
|
|
449
|
+
} catch {
|
|
450
|
+
throw new StateError('usage', `--plan ${req.plan} cannot be read`);
|
|
451
|
+
}
|
|
452
|
+
const entry = parsePlan(text).blocks.find((b) => b.id === req.block);
|
|
453
|
+
if (!entry) throw new StateError('usage', `block ${req.block} is not in the plan's block table`);
|
|
454
|
+
if (entry.level === null) throw new StateError('usage', `block ${req.block} has no level (L0–L3) in the plan`);
|
|
455
|
+
// the block need not be open yet: the level is chosen before `block open` (which then uses it)
|
|
456
|
+
const record = await readRun(req.runId);
|
|
457
|
+
const now = clock();
|
|
458
|
+
const gate = await grantFor(req.runId, 'model:choose', now, { writeRow: deps.writeRow });
|
|
459
|
+
if (!gate.ok) return { ok: false, reason: gate.reason, message: grantRefusal(gate.reason) };
|
|
460
|
+
const all = deps.readRows ? await deps.readRows() : await readAllRows(record.project);
|
|
461
|
+
const key = await loadKey(req.runId);
|
|
462
|
+
const runRows = all.filter((r) => r?.run === req.runId);
|
|
463
|
+
const recorded = recordedLanes(all, path.basename(req.plan)).get(req.block);
|
|
464
|
+
if (!recorded) return { ok: false, reason: 'no-lane', message: `block ${req.block} has no recorded lane — run forge jev ask lane --block ${req.block}; the level goes to the owner` };
|
|
465
|
+
if (!/^L[0-3]$/.test(recorded.lane)) return { ok: false, reason: 'no-lane', message: `block ${req.block}'s recorded lane is ${JSON.stringify(recorded.lane)}, not a level; the level goes to the owner` };
|
|
466
|
+
const level = chooseLevel({ lane: recorded.lane, planLevel: entry.level });
|
|
467
|
+
// the delegate must have chosen exactly this level (its options were the levels, e.g. L1,L2)
|
|
468
|
+
const refused = decisionRefusal(runRows, { decisionId: req.decisionId, grantId: gate.grant.grant_id, scope: 'model:choose', subject: { block: req.block }, decision: level, at: now.getTime(), key });
|
|
469
|
+
if (refused) return refused;
|
|
470
|
+
const grantId = gate.grant.grant_id;
|
|
471
|
+
return withRunLock(req.runId, async () => {
|
|
472
|
+
const fresh = clock();
|
|
473
|
+
const ended = await liveGrantRefusal(req.runId, { grantId, scope: 'model:choose', now: fresh });
|
|
474
|
+
if (ended) return ended;
|
|
475
|
+
const stale = decisionRefusal(await runRowsOf(record, deps), { decisionId: req.decisionId, grantId, scope: 'model:choose', subject: { block: req.block }, decision: level, at: fresh.getTime(), key });
|
|
476
|
+
if (stale) return stale;
|
|
477
|
+
const row = await signed(req.runId, writerFor(record, deps), {
|
|
478
|
+
event: 'autopilot.level',
|
|
479
|
+
block: req.block,
|
|
480
|
+
level,
|
|
481
|
+
lane: recorded.lane,
|
|
482
|
+
lane_source: recorded.source,
|
|
483
|
+
lane_decision_id: recorded.decision_id,
|
|
484
|
+
plan_level: entry.level,
|
|
485
|
+
grant_id: grantId,
|
|
486
|
+
decision_id: req.decisionId,
|
|
487
|
+
ts: fresh.toISOString(),
|
|
488
|
+
});
|
|
489
|
+
return { ok: /** @type {const} */ (true), row };
|
|
490
|
+
});
|
|
491
|
+
}
|