@codedology/code-forge 0.13.0 → 0.14.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 +12 -0
- package/README.md +12 -2
- package/docs/reference/blocks.json +153 -1
- package/docs/reference/config.md +2 -0
- package/package.json +1 -1
- package/schema/code-forge.schema.json +8 -0
- package/skill/references/adapters/claude-code.md +1 -1
- package/skill/references/adapters/solo.md +1 -1
- package/skill/references/code.md +3 -3
- package/skill/references/ledger.md +4 -2
- package/skill/references/proof.md +1 -1
- package/skill/references/review.md +13 -6
- package/skill/references/security.md +5 -3
- package/skill/scripts/forge +1 -1
- package/src/cli/block.mjs +103 -31
- package/src/cli/report.mjs +2 -2
- package/src/cli/review-file.mjs +47 -35
- package/src/ledger/export.mjs +1 -1
- package/src/ledger/report.mjs +9 -3
- package/src/review/accept-hit.mjs +78 -0
- package/src/review/agent-waiver.mjs +201 -0
- package/src/review/gate-check.mjs +33 -24
- package/src/review/reopen.mjs +35 -14
- package/src/state/config-snapshot.mjs +1 -0
- package/src/util/forbidden.mjs +22 -0
- package/src/worker/queue.mjs +18 -2
package/src/cli/block.mjs
CHANGED
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
* block claim <id> <path> --run <r>
|
|
11
11
|
* block close <id> --run <r> [--worker-pid <pid>] [--remove-worktree]
|
|
12
12
|
* block stop <id> --run <r> --reason <text>
|
|
13
|
-
* block waive <id> <finding-id> --run <r> --file <path> --reason <text> (
|
|
14
|
-
* block reopen <id> --run <r> --file <path> --reason <text> (
|
|
13
|
+
* block waive <id> <finding-id> --run <r> --file <path> --reason <text> [--yes] (orchestrator --yes: evidence and a fresh judge)
|
|
14
|
+
* block reopen <id> --run <r> --file <path> --reason <text> [--yes] (B124c: evidence and a fresh judge for the agent path)
|
|
15
15
|
*
|
|
16
16
|
* B64a `--json` (every subcommand): on success stdout is exactly ONE JSON line instead of the
|
|
17
17
|
* text — `{"block", "event", …}` with the facts the text prints (open: level, asked, source,
|
|
@@ -172,18 +172,17 @@
|
|
|
172
172
|
* <from>[ <stop reason>]; by: human)`; `--json`: `{"block", "event": "reopened", "file", "from",
|
|
173
173
|
* "stop_reason", "at_seq", "from_hash", "to_hash", "ticket_cleared", "by"}`.
|
|
174
174
|
*
|
|
175
|
-
* B80
|
|
175
|
+
* B80: without `--yes`, `block waive` and `block reopen` need the human at a terminal, through the same
|
|
176
176
|
* confirm as `--no-require-reviews` (`confirmAtTerminal`). Right after the usage checks and the run
|
|
177
177
|
* record read — before the autopilot expiry checks or anything else is written — stdin or stdout
|
|
178
|
-
* not a TTY prints `
|
|
179
|
-
* (there is no --yes)` (exit 2). At a terminal, what the verb would refuse is refused before the
|
|
178
|
+
* not a TTY prints `humanVerbNoTTY` (exit 2). At a terminal, what the verb would refuse is refused before the
|
|
180
179
|
* question: a `--file` or finding id with a control character or of the wrong form (`usage`, exit
|
|
181
180
|
* 2, `checkFileForm` / `checkFindingId` — the verbs' own checks), an unknown block, for reopen a
|
|
182
181
|
* block that is not open or a file it does not own (`reopen-not-owned`), for waive a file the block
|
|
183
182
|
* does not own (`waive-not-owned`) or a finding id no review row of the block names for the file
|
|
184
183
|
* (`waive-unknown-finding`; `proof` is exempt) — exit 1. A "no" or a cancel prints `block <id>
|
|
185
184
|
* <verb>: cancelled — nothing was recorded` (exit 1); a "yes" runs the verb exactly as before (the
|
|
186
|
-
* same signed row).
|
|
185
|
+
* same signed row). Only `block waive` accepts `--yes` (B124a, evidence and a fresh L3 judge). An active autopilot grant
|
|
187
186
|
* changes nothing here — autopilot waives through its own path (`autopilot waive`, `by:
|
|
188
187
|
* autopilot`). The prompt checks only that stdin and stdout are terminals; a pseudo-terminal
|
|
189
188
|
* passes it, so it does not prove a human. The coder deny-list entries stay.
|
|
@@ -209,6 +208,7 @@ import { StateError } from '../state/paths.mjs';
|
|
|
209
208
|
import { readRun, writeSigned } from '../state/run.mjs';
|
|
210
209
|
import { loadKey, verifyRow } from '../state/signer.mjs';
|
|
211
210
|
import { PROOF_FINDING, blockFileSet, changedFiles, openFindings, ownsAt, resultFilesFor, reviewedFiles, reviewGateCheck, unownedChanges, waiveFinding } from '../review/gate-check.mjs';
|
|
211
|
+
import { agentWaivers, agentWaiversAcked, prepareAckWaivers, prepareUnwaive, waiveAsAgent } from '../review/agent-waiver.mjs';
|
|
212
212
|
import { checkExpiry } from '../autopilot/grant.mjs';
|
|
213
213
|
import { redact, writeSafe } from '../util/redact.mjs';
|
|
214
214
|
import { blockTree, prepareWorktree, removeWorktree, worktreeSettings } from '../proof/worktree.mjs';
|
|
@@ -216,10 +216,10 @@ import { DEFAULT_CONFIG_FILENAME, loadProjectConfig } from '../config/load.mjs';
|
|
|
216
216
|
import { validateConfig } from '../config/validate.mjs';
|
|
217
217
|
import { migrateConfig } from '../config/migrate.mjs';
|
|
218
218
|
import { exec } from '../util/exec.mjs';
|
|
219
|
-
import { contentHash, gitChildEnv } from '../worker/ticket.mjs';
|
|
219
|
+
import { contentHash, gitChildEnv, readRegularFileNoFollow } from '../worker/ticket.mjs';
|
|
220
220
|
import { clearDone, resolveTicket } from '../worker/queue.mjs';
|
|
221
221
|
import { loadState } from '../worker/review-state.mjs';
|
|
222
|
-
import { openBlockEntry, reopenFile } from '../review/reopen.mjs';
|
|
222
|
+
import { agentReopens, openBlockEntry, reopenFile } from '../review/reopen.mjs';
|
|
223
223
|
import { checkFileForm, checkFindingId } from '../review/verb-forms.mjs';
|
|
224
224
|
import { runRootPath } from '../util/tmp.mjs';
|
|
225
225
|
import { missingReviewTickets, parseSentinel } from '../engines/sentinel.mjs';
|
|
@@ -227,15 +227,20 @@ import { reportMismatches, reviewedLines } from '../review/reviewed-lines.mjs';
|
|
|
227
227
|
import { blockEndHook, runBlockEndHook, writeDoneFile } from '../state/block-end.mjs';
|
|
228
228
|
import { blockOpenFindings } from '../review/open-findings.mjs';
|
|
229
229
|
import { hookForbidden, scanTranscript } from '../util/forbidden.mjs';
|
|
230
|
+
import { prepareAcceptHit, writeAcceptedHit } from '../review/accept-hit.mjs';
|
|
230
231
|
|
|
231
232
|
/**
|
|
232
233
|
* B72: `block reopen` — the file's content is read where the block works (its worktree, else the
|
|
233
234
|
* main tree), its state is read (never moved) from the code-forge home or the run's old temp root,
|
|
234
235
|
* the checks, the done marker's removal and then the signed row are `reopenFile`'s.
|
|
235
|
-
* @param {{runId: string, id: string, file: string, reason: string, record: Record<string, any>, writeRow: (row: Record<string, any>) => Promise<unknown
|
|
236
|
+
* @param {{runId: string, id: string, file: string, reason: string, record: Record<string, any>, writeRow: (row: Record<string, any>) => Promise<unknown>, agent?: boolean, judge?: import('../review/agent-waiver.mjs').WaiverJudge}} opts
|
|
236
237
|
* @returns {Promise<{row: Record<string, any>, cleared: boolean}>}
|
|
237
238
|
*/
|
|
238
|
-
async function reopenBlockFile({ runId, id, file, reason, record, writeRow }) {
|
|
239
|
+
async function reopenBlockFile({ runId, id, file, reason, record, writeRow, agent = false, judge }) {
|
|
240
|
+
if (agent) {
|
|
241
|
+
if (!reason.trim()) throw new StateError('usage', 'block reopen needs --reason');
|
|
242
|
+
checkFileForm(file, { normalized: true });
|
|
243
|
+
}
|
|
239
244
|
// the stable block codes come first, before any tree, config, key or ledger read
|
|
240
245
|
const entry = openBlockEntry(record, runId, id);
|
|
241
246
|
const real = async (/** @type {string} */ p) => realpath(p).catch(() => path.resolve(p));
|
|
@@ -271,10 +276,12 @@ async function reopenBlockFile({ runId, id, file, reason, record, writeRow }) {
|
|
|
271
276
|
key,
|
|
272
277
|
writeRow,
|
|
273
278
|
writeSigned,
|
|
279
|
+
...(agent ? { agent: { root: contentRoot, judge } } : {}),
|
|
274
280
|
io: {
|
|
275
281
|
owns: ownsAt(wsPrefix),
|
|
276
282
|
loadState: (where) => loadState({ .../** @type {any} */ (where), runRootDir, contentRoot, migrate: false }),
|
|
277
283
|
contentHash: (f) => contentHash(contentRoot, f),
|
|
284
|
+
readFile: (f) => readRegularFileNoFollow(contentRoot, f),
|
|
278
285
|
// C11 (synchronous): the cached result of the current content's ticket would repeat the stop
|
|
279
286
|
clearDone: (f) => clearDone({ repoRoot: mainTop, run: runId, block: id, file: f, ...tree }).removed,
|
|
280
287
|
},
|
|
@@ -285,11 +292,14 @@ async function reopenBlockFile({ runId, id, file, reason, record, writeRow }) {
|
|
|
285
292
|
const USAGE =
|
|
286
293
|
'usage: code-forge block open <id> --run <r> [--level L<n>] --owned <paths…> --acceptance <file> [--brief <file>] [--attempt <n>] [--base <sha>] [--lines <n>] [--kind code|docs|contract] [--tests <paths…>]\n' +
|
|
287
294
|
' code-forge block attempt|rebase <id> --run <r> · block close <id> --run <r> [--transcript <file>] [--report <file>] [--no-require-reviews] [--remove-worktree] · block claim <id> <path> --run <r> · block stop <id> --run <r> --reason <text>\n' +
|
|
288
|
-
' code-forge block waive <id> <finding-id> --run <r> --file <path> --reason <text
|
|
289
|
-
' code-forge block reopen <id> --run <r> --file <path> --reason <text
|
|
295
|
+
' code-forge block waive <id> <finding-id> --run <r> --file <path> --reason <text> [--yes]\n' +
|
|
296
|
+
' code-forge block reopen <id> --run <r> --file <path> --reason <text> [--yes]\n' +
|
|
297
|
+
' code-forge block accept-hit <id> --run <r> --rule <rule-id> --line <n> --transcript <path> --reason <text>\n' +
|
|
298
|
+
' code-forge block unwaive <id> <finding-id> --run <r> --file <path> --reason <text>\n' +
|
|
299
|
+
' code-forge block ack-waivers <id> --run <r>\n' +
|
|
290
300
|
' every block verb takes --json: one JSON line on stdout ({"block","event",…}) instead of the text\n';
|
|
291
301
|
|
|
292
|
-
const SUBCOMMANDS = ['open', 'attempt', 'rebase', 'claim', 'close', 'stop', 'waive', 'reopen'];
|
|
302
|
+
const SUBCOMMANDS = ['open', 'attempt', 'rebase', 'claim', 'close', 'stop', 'waive', 'reopen', 'accept-hit', 'unwaive', 'ack-waivers'];
|
|
293
303
|
|
|
294
304
|
/** Refuse likely shell quoting slips before any row is written.
|
|
295
305
|
* @param {string} workspace @param {string[]} entries @param {string} label
|
|
@@ -308,7 +318,7 @@ function checkWhitespacePaths(workspace, entries, label) {
|
|
|
308
318
|
}
|
|
309
319
|
}
|
|
310
320
|
}
|
|
311
|
-
const FLAG_VALUES = ['run', 'level', 'acceptance', 'brief', 'attempt', 'base', 'lines', 'head', 'worker-pid', 'reason', 'file', 'transcript', 'kind', 'report'];
|
|
321
|
+
const FLAG_VALUES = ['run', 'level', 'acceptance', 'brief', 'attempt', 'base', 'lines', 'head', 'worker-pid', 'reason', 'file', 'transcript', 'kind', 'report', 'rule', 'line'];
|
|
312
322
|
|
|
313
323
|
/**
|
|
314
324
|
* @param {string[]} args
|
|
@@ -318,6 +328,8 @@ const FLAG_VALUES = ['run', 'level', 'acceptance', 'brief', 'attempt', 'base', '
|
|
|
318
328
|
* ui?: {confirm: (o: {message: string, initialValue?: boolean}) => Promise<unknown>, isCancel: (v: unknown) => boolean},
|
|
319
329
|
* removeBlockTree?: typeof removeBlockTree,
|
|
320
330
|
* resolveTicket?: typeof resolveTicket, reviewedLines?: typeof reviewedLines,
|
|
331
|
+
* waiverJudge?: import('../review/agent-waiver.mjs').WaiverJudge,
|
|
332
|
+
* extraTokens?: string[],
|
|
321
333
|
* }} [deps] - `now` (B48): the clock of the autopilot expiry checks. `stdinIsTTY` / `stdoutIsTTY`
|
|
322
334
|
* (default `process.stdin.isTTY` / `process.stdout.isTTY`; `isTTY` sets both) and `ui` (default
|
|
323
335
|
* `@clack/prompts`): the human's confirmation (B52 `--no-require-reviews`, B80 `block waive` and
|
|
@@ -328,7 +340,7 @@ const FLAG_VALUES = ['run', 'level', 'acceptance', 'brief', 'attempt', 'base', '
|
|
|
328
340
|
* @returns {Promise<number>}
|
|
329
341
|
*/
|
|
330
342
|
export async function runBlock(args, deps = {}) {
|
|
331
|
-
const { stdout = process.stdout, stderr = process.stderr, probe } = deps;
|
|
343
|
+
const { stdout = process.stdout, stderr = process.stderr, probe, extraTokens = [] } = deps;
|
|
332
344
|
const out = (/** @type {string} */ s) => writeSafe(stdout, s);
|
|
333
345
|
const err = (/** @type {string} */ s) => writeSafe(stderr, s);
|
|
334
346
|
const [sub, ...rest] = args;
|
|
@@ -337,7 +349,7 @@ export async function runBlock(args, deps = {}) {
|
|
|
337
349
|
return 2;
|
|
338
350
|
}
|
|
339
351
|
try {
|
|
340
|
-
const { flags, positionals } = parseFlags(rest, { values: FLAG_VALUES, multi: ['owned', 'tests'], booleans: ['no-require-reviews', 'remove-worktree', 'json'] });
|
|
352
|
+
const { flags, positionals } = parseFlags(rest, { values: FLAG_VALUES, multi: ['owned', 'tests'], booleans: ['no-require-reviews', 'remove-worktree', 'json', ...(['waive', 'reopen'].includes(sub) ? ['yes'] : [])] });
|
|
341
353
|
// B64a: --json prints ONE JSON line on success instead of the text lines (`emit`, one object
|
|
342
354
|
// per verb, built where the text is); without it every output is exactly as before (`say` is `out`)
|
|
343
355
|
const json = flags.json === true;
|
|
@@ -364,14 +376,39 @@ export async function runBlock(args, deps = {}) {
|
|
|
364
376
|
if (sub === 'reopen' && positionals.length !== 1) throw new StateError('usage', 'block reopen takes one block id (the file goes in --file)');
|
|
365
377
|
if (sub === 'reopen' && typeof flags.file !== 'string') throw new StateError('usage', 'block reopen needs --file <path>');
|
|
366
378
|
if (sub === 'waive' && typeof flags.file !== 'string') throw new StateError('usage', 'block waive needs --file <path> (a finding id is scoped to one file)');
|
|
379
|
+
if (sub === 'accept-hit') {
|
|
380
|
+
const { record, row } = await prepareAcceptHit({ runId, id, flags, positionals, extraTokens });
|
|
381
|
+
const asked = await confirmHumanVerb(sub, runId, id, { rule: row.rule, line: row.line, file: /** @type {string} */ (flags.transcript) }, record, deps);
|
|
382
|
+
if (asked === 'no-tty') { err(`${humanVerbNoTTY(sub)}\n`); return 2; }
|
|
383
|
+
if (asked === 'cancelled') { err(`block ${id} ${sub}: cancelled — nothing was recorded\n`); return 1; }
|
|
384
|
+
await writeAcceptedHit({ runId, row, writeRow: (signed) => appendRow(signed, { slug: record.project }) });
|
|
385
|
+
say(`block ${id} accepted ${row.rule} transcript line ${row.line} (by: human)\n`);
|
|
386
|
+
if (json) emit({ block: id, event: 'rule_break.accepted', rule: row.rule, line: row.line, reason: row.reason, by: 'human' });
|
|
387
|
+
return 0;
|
|
388
|
+
}
|
|
367
389
|
const record = await readRun(runId);
|
|
390
|
+
if (sub === 'unwaive' || sub === 'ack-waivers') {
|
|
391
|
+
if (positionals.length !== (sub === 'unwaive' ? 2 : 1)) throw new StateError('usage', `block ${sub} takes ${sub === 'unwaive' ? 'one block id and one finding id' : 'one block id'}`);
|
|
392
|
+
const prepared = sub === 'unwaive'
|
|
393
|
+
? { counted: [], row: await prepareUnwaive({ runId, id, finding: extra, file: /** @type {string} */ (flags.file), reason: /** @type {string} */ (flags.reason), record }) }
|
|
394
|
+
: await prepareAckWaivers(record, runId, id);
|
|
395
|
+
const asked = await confirmHumanVerb(sub, runId, id, { finding: extra, file: /** @type {string} */ (flags.file) }, record, deps, () => {
|
|
396
|
+
for (const w of prepared.counted) (json ? err : say)(`block ${id}: agent waiver ${w.file} ${w.finding} reason: ${w.reason}; verdict: ${w.verdict}\n`);
|
|
397
|
+
});
|
|
398
|
+
if (asked === 'no-tty') { err(`${humanVerbNoTTY(sub)}\n`); return 2; }
|
|
399
|
+
if (asked === 'cancelled') { err(`block ${id} ${sub}: cancelled — nothing was recorded\n`); return 1; }
|
|
400
|
+
await writeSigned(runId, (row) => appendRow(row, { slug: record.project }), prepared.row);
|
|
401
|
+
say(sub === 'unwaive' ? `block ${id} unwaived ${extra} in ${flags.file} (by: human)\n` : `block ${id} acknowledged ${prepared.counted.length} agent waiver(s) (by: human)\n`);
|
|
402
|
+
if (json) emit(prepared.row);
|
|
403
|
+
return 0;
|
|
404
|
+
}
|
|
368
405
|
if (sub === 'open') {
|
|
369
406
|
checkWhitespacePaths(record.workspace, /** @type {string[]} */ (flags.owned), 'owned files');
|
|
370
407
|
if (Array.isArray(flags.tests)) checkWhitespacePaths(record.workspace, flags.tests, 'test files');
|
|
371
408
|
}
|
|
372
409
|
// B80: waive and reopen are the human's — asked at a terminal before anything is written (not
|
|
373
410
|
// even the autopilot expiry rows below); no terminal is exit 2, a "no" is exit 1
|
|
374
|
-
if (sub === 'waive' || sub === 'reopen') {
|
|
411
|
+
if ((sub === 'waive' || sub === 'reopen') && flags.yes !== true) {
|
|
375
412
|
const asked = await confirmHumanVerb(sub, runId, id, { finding: extra, file: /** @type {string} */ (flags.file) }, record, deps);
|
|
376
413
|
if (asked === 'no-tty') {
|
|
377
414
|
err(`${humanVerbNoTTY(sub)}\n`);
|
|
@@ -383,6 +420,22 @@ export async function runBlock(args, deps = {}) {
|
|
|
383
420
|
}
|
|
384
421
|
}
|
|
385
422
|
const writeRow =(/** @type {Record<string, any>} */ row) => appendRow(row, { slug: record.project });
|
|
423
|
+
const reopened = async (/** @type {boolean} */ agent) => {
|
|
424
|
+
const file = /** @type {string} */ (flags.file);
|
|
425
|
+
const { row, cleared } = await reopenBlockFile({ runId, id, file, reason: /** @type {string} */ (flags.reason), record, writeRow, agent, judge: deps.waiverJudge });
|
|
426
|
+
const fromText = row.stop_reason == null ? row.from : `${row.from} ${row.stop_reason}`;
|
|
427
|
+
say(`block ${id} reopened ${file} (from ${fromText}; by: ${row.by})\n`);
|
|
428
|
+
if (json) emit({ block: id, event: 'reopened', file, from: row.from, stop_reason: row.stop_reason, at_seq: row.at_seq, from_hash: row.from_hash, to_hash: row.to_hash, ticket_cleared: cleared, by: row.by });
|
|
429
|
+
return 0;
|
|
430
|
+
};
|
|
431
|
+
// Agent refusals precede even the lazy expiry checks: no rows on refusal.
|
|
432
|
+
if (sub === 'reopen' && flags.yes === true) return await reopened(true);
|
|
433
|
+
if (sub === 'waive' && flags.yes === true) {
|
|
434
|
+
await waiveAsAgent({ runId, id, finding: extra, file: /** @type {string} */ (flags.file), reason: /** @type {string} */ (flags.reason), record, writeRow, judge: deps.waiverJudge });
|
|
435
|
+
say(`block ${id} waived ${extra} in ${flags.file} (by: agent)\n`);
|
|
436
|
+
if (json) emit({ block: id, event: 'waived', finding: extra, file: flags.file, by: 'agent' });
|
|
437
|
+
return 0;
|
|
438
|
+
}
|
|
386
439
|
// B64b: the block-end signal — after an end's rows (and outside the run lock) the done file,
|
|
387
440
|
// then the owner's `hooks.on_block_end` from the run-start snapshot. Without the hook key the
|
|
388
441
|
// done file is the only addition: no row, and every output stays exactly as before.
|
|
@@ -534,12 +587,7 @@ export async function runBlock(args, deps = {}) {
|
|
|
534
587
|
return 0;
|
|
535
588
|
}
|
|
536
589
|
if (sub === 'reopen') {
|
|
537
|
-
|
|
538
|
-
const { row, cleared } = await reopenBlockFile({ runId, id, file, reason: /** @type {string} */ (flags.reason), record, writeRow });
|
|
539
|
-
const fromText = row.stop_reason == null ? row.from : `${row.from} ${row.stop_reason}`;
|
|
540
|
-
say(`block ${id} reopened ${file} (from ${fromText}; by: human)\n`);
|
|
541
|
-
if (json) emit({ block: id, event: 'reopened', file, from: row.from, stop_reason: row.stop_reason, at_seq: row.at_seq, from_hash: row.from_hash, to_hash: row.to_hash, ticket_cleared: cleared, by: 'human' });
|
|
542
|
-
return 0;
|
|
590
|
+
return await reopened(false);
|
|
543
591
|
}
|
|
544
592
|
if (sub === 'close') {
|
|
545
593
|
const entry = record.blocks?.[id];
|
|
@@ -610,6 +658,8 @@ export async function runBlock(args, deps = {}) {
|
|
|
610
658
|
const closeRows = external === null ? [] : [{ event: 'gate.reviews_waived', block: id, by: 'config', reviewer: external }];
|
|
611
659
|
/** @type {import('../review/gate-check.mjs').WaivedAll[]} */
|
|
612
660
|
let waivedAll = [];
|
|
661
|
+
/** @type {import('../review/accept-hit.mjs').AcceptedHit[]} */
|
|
662
|
+
let acceptedHits = [];
|
|
613
663
|
// B70: a file whose review left only waived findings passes as `reviewed_by_waiver`; its signed
|
|
614
664
|
// `gate.review_waived_all` row joins the close rows (written just before `block.close`, only
|
|
615
665
|
// when the block closes — a refused close writes none)
|
|
@@ -660,6 +710,7 @@ export async function runBlock(args, deps = {}) {
|
|
|
660
710
|
rows,
|
|
661
711
|
key,
|
|
662
712
|
transcript,
|
|
713
|
+
extraTokens,
|
|
663
714
|
// B52: a change no block of the run owns is refused — `--no-require-reviews` does not lift it
|
|
664
715
|
unowned: scope?.unowned ?? [],
|
|
665
716
|
// B52: an old ledger's review.result rows are judged by their signed result files
|
|
@@ -672,7 +723,7 @@ export async function runBlock(args, deps = {}) {
|
|
|
672
723
|
proofFiles: fileSet,
|
|
673
724
|
highPaths,
|
|
674
725
|
};
|
|
675
|
-
}, { onWaivedAll });
|
|
726
|
+
}, { onWaivedAll, onAcceptedHits: (hits) => { acceptedHits = hits; } });
|
|
676
727
|
// B96: `reviewsPassed` comes from the gate's own result, not from the onWaivedAll call. Each
|
|
677
728
|
// extra check runs at most once per close (`closeBlock`'s one loop, src/state/block.mjs:562,
|
|
678
729
|
// and `closeBlock` is called once below), so the gate's one pass is its final one.
|
|
@@ -748,6 +799,8 @@ export async function runBlock(args, deps = {}) {
|
|
|
748
799
|
};
|
|
749
800
|
/** @type {Array<() => Promise<{ok: boolean, reason?: string}>>} */
|
|
750
801
|
const extraChecks = [reviews, report];
|
|
802
|
+
extraChecks.unshift(async () => agentWaiversAcked(record, rows, id, await loadKey(runId))
|
|
803
|
+
? { ok: true } : { ok: false, reason: 'agent-waivers-not-acked' });
|
|
751
804
|
// B63b: `context.require_coder_tests` in the run's config snapshot (never the live config)
|
|
752
805
|
// needs the coder's green test run — waived reviews do not lift it. It runs FIRST: it only
|
|
753
806
|
// reads the report, so a refusal here leaves no row (not even the human waiver row the review
|
|
@@ -772,6 +825,8 @@ export async function runBlock(args, deps = {}) {
|
|
|
772
825
|
});
|
|
773
826
|
}
|
|
774
827
|
const result = await closeBlock({ runId, id, rows, writeRow, livePid: intFlag(flags['worker-pid'], 'worker-pid'), probe, extraChecks, closeRows });
|
|
828
|
+
const agentRows = agentWaivers(rows, id, await loadKey(runId));
|
|
829
|
+
const reopenedRows = agentReopens(rows.filter((r) => r.run === runId), id, await loadKey(runId));
|
|
775
830
|
// B64b: the block ended (closed, or stopped by a worker pin event or ledger.tamper) — its rows
|
|
776
831
|
// are written and the run lock released; the done file is written before the tree is removed
|
|
777
832
|
// B96: the tool's reviewed lines of a block that closed (also kept in its done file)
|
|
@@ -795,6 +850,9 @@ export async function runBlock(args, deps = {}) {
|
|
|
795
850
|
if (f.answered?.length) parts.push(`answered ${f.answered.join(', ')}`);
|
|
796
851
|
say(`block ${id}: ${f.file} reviewed_by_waiver (${parts.join('; ')})\n`);
|
|
797
852
|
}
|
|
853
|
+
for (const w of agentRows) say(`block ${id}: agent waiver ${w.file} ${w.finding} reason: ${w.reason}; verdict: ${w.verdict}\n`);
|
|
854
|
+
for (const r of reopenedRows) say(`agent reopen ${r.file}: ${r.reason} (verdict: ${r.verdict?.verdict})\n`);
|
|
855
|
+
for (const hit of acceptedHits) say(`accepted rule_break ${hit.rule} transcript line ${hit.line}: ${hit.reason} (by: human)\n`);
|
|
798
856
|
}
|
|
799
857
|
(result.ok ? say : err)(`block ${id} ${result.status}${result.event ? ` (${result.event})` : ''}${result.reason ? `: ${result.reason}` : ''}\n`);
|
|
800
858
|
// B64a: a refused close also names itself on stdout under --json (its stderr is unchanged)
|
|
@@ -810,6 +868,9 @@ export async function runBlock(args, deps = {}) {
|
|
|
810
868
|
...(result.ok && waivedAll.length > 0 ? { reviewed_by_waiver: waivedAll.map((f) => ({ file: f.file, content_hash: f.content_hash, status: f.status, findings: f.findings, by: f.by, ...(f.answered?.length ? { answered: f.answered } : {}) })) } : {}),
|
|
811
869
|
// B96: only when the tool gave reviewed lines (a waived or external close is unchanged)
|
|
812
870
|
...(reviewedJson.length > 0 ? { reviewed: reviewedJson } : {}),
|
|
871
|
+
...(agentRows.length > 0 ? { agent_waivers: agentRows } : {}),
|
|
872
|
+
...(reopenedRows.length > 0 ? { agent_reopens: reopenedRows } : {}),
|
|
873
|
+
...(acceptedHits.length > 0 ? { accepted_hits: acceptedHits } : {}),
|
|
813
874
|
};
|
|
814
875
|
if (!result.ok || !removeTree) {
|
|
815
876
|
if (json && result.ok) emit(closedJson);
|
|
@@ -898,9 +959,12 @@ export const NO_REVIEWS_NO_TTY = '--no-require-reviews needs the human at a term
|
|
|
898
959
|
|
|
899
960
|
/**
|
|
900
961
|
* B80: the refusal when `block waive` or `block reopen` has no terminal to ask the human at.
|
|
901
|
-
* @param {'waive' | 'reopen'} verb @returns {string}
|
|
962
|
+
* @param {'waive' | 'reopen' | 'accept-hit' | 'unwaive' | 'ack-waivers'} verb @returns {string}
|
|
902
963
|
*/
|
|
903
|
-
export const humanVerbNoTTY = (verb) =>
|
|
964
|
+
export const humanVerbNoTTY = (verb) => verb === 'waive'
|
|
965
|
+
? 'block waive needs the human at a terminal to confirm; nothing was recorded (--yes works only for a recorded warning or nit finding, with evidence)'
|
|
966
|
+
: verb === 'reopen' ? 'block reopen needs the human at a terminal to confirm; nothing was recorded (--yes works only with evidence and agreement from a fresh judge)'
|
|
967
|
+
: `block ${verb} needs the human at a terminal to confirm; nothing was recorded (there is no --yes)`;
|
|
904
968
|
|
|
905
969
|
/**
|
|
906
970
|
* @typedef {{isTTY?: boolean, stdinIsTTY?: boolean, stdoutIsTTY?: boolean, ui?: {confirm: (o: {message: string, initialValue?: boolean}) => Promise<unknown>, isCancel: (v: unknown) => boolean}, now?: () => Date}} ConfirmDeps
|
|
@@ -955,18 +1019,26 @@ async function confirmNoReviews(runId, id, deps) {
|
|
|
955
1019
|
* autopilot grant changes nothing (autopilot waives through its own path, `by: autopilot`). The
|
|
956
1020
|
* pre-checks (`reopenPrecheck`, `waivePrecheck`) run first, at a terminal, so the human is never
|
|
957
1021
|
* asked about a waive or reopen the verb would refuse that way.
|
|
958
|
-
* @param {'waive' | 'reopen'} verb @param {string} runId @param {string} id
|
|
959
|
-
* @param {{finding?: string, file: string}} what @param {Record<string, any>} record @param {ConfirmDeps} deps
|
|
1022
|
+
* @param {'waive' | 'reopen' | 'accept-hit' | 'unwaive' | 'ack-waivers'} verb @param {string} runId @param {string} id
|
|
1023
|
+
* @param {{finding?: string, file: string, rule?: string, line?: number}} what @param {Record<string, any>} record @param {ConfirmDeps} deps
|
|
1024
|
+
* @param {() => void} [before]
|
|
960
1025
|
* @returns {Promise<'no-tty' | 'confirmed' | 'cancelled'>}
|
|
961
1026
|
*/
|
|
962
|
-
function confirmHumanVerb(verb, runId, id, what, record, deps) {
|
|
1027
|
+
function confirmHumanVerb(verb, runId, id, what, record, deps, before) {
|
|
963
1028
|
const message =
|
|
964
1029
|
verb === 'waive'
|
|
965
1030
|
? `Waive finding ${what.finding} in ${what.file} of block ${id} (run ${runId})? Only the owner may say yes.`
|
|
966
|
-
:
|
|
1031
|
+
: verb === 'unwaive'
|
|
1032
|
+
? `Undo agent waivers for finding ${what.finding} in ${what.file} of block ${id} (run ${runId})? Only the owner may say yes.`
|
|
1033
|
+
: verb === 'ack-waivers'
|
|
1034
|
+
? `Acknowledge the listed agent waivers of block ${id} (run ${runId})? Only the owner may say yes.`
|
|
1035
|
+
: verb === 'accept-hit'
|
|
1036
|
+
? `Accept false transcript hit ${what.rule} at line ${what.line} in ${what.file} of block ${id} (run ${runId})? Only the owner may say yes.`
|
|
1037
|
+
: `Reopen the review of ${what.file} in block ${id} (run ${runId})? Only the owner may say yes.`;
|
|
967
1038
|
return confirmAtTerminal(deps, message, async () => {
|
|
968
1039
|
if (verb === 'reopen') await reopenPrecheck(record, runId, id, what.file);
|
|
969
|
-
else await waivePrecheck(record, runId, id, /** @type {string} */ (what.finding), what.file);
|
|
1040
|
+
else if (verb === 'waive') await waivePrecheck(record, runId, id, /** @type {string} */ (what.finding), what.file);
|
|
1041
|
+
before?.();
|
|
970
1042
|
});
|
|
971
1043
|
}
|
|
972
1044
|
|
package/src/cli/report.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `code-forge report [--slug <slug>] [--json] [--export <dir>]` (plan §6.2, R4). Terminal output
|
|
3
|
-
* only, plus an optional `--export <dir>` that writes the same
|
|
3
|
+
* only, plus an optional `--export <dir>` that writes the same 15 sections as JSON files. The
|
|
4
4
|
* terminal output ends with the spend per run (B33) and the mode of each run (B59: brief or plan);
|
|
5
5
|
* both are text only, never in `--json` or `--export`. The 14th section, `outside_review` (B66), is
|
|
6
6
|
* printed as text lines under its header (`outside review: none`, or one line per PR).
|
|
@@ -75,7 +75,7 @@ export async function runReport(args, deps = {}) {
|
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
/**
|
|
78
|
-
* B33: the running spend per run (open blocks included), after the
|
|
78
|
+
* B33: the running spend per run (open blocks included), after the 15 sections. Unknown-price
|
|
79
79
|
* sessions are named, never priced.
|
|
80
80
|
* @param {ReturnType<typeof buildSpend>} spend @returns {string}
|
|
81
81
|
*/
|
package/src/cli/review-file.mjs
CHANGED
|
@@ -163,21 +163,15 @@ const FRESH_ATTEMPTS = 3;
|
|
|
163
163
|
/** B75: the ways out a counted result names (human-only; a coder stops and reports). */
|
|
164
164
|
export const FRESH_EXITS = Object.freeze(['block reopen', 'block waive']);
|
|
165
165
|
|
|
166
|
+
/** The automatic retry allow-list excludes refused results, even those --fresh can rerun. */
|
|
167
|
+
const RETRY_STATUSES = new Set(['stale', 'unavailable', 'split_required']);
|
|
168
|
+
|
|
166
169
|
/**
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
* A marker that changed under the claim is restored (link only) and classified again, at most
|
|
171
|
-
* {@link FRESH_ATTEMPTS} times. The queue has no lock: the rename claim is the exclusion, and a
|
|
172
|
-
* crash between the drop and the enqueue leaves only a re-runnable pending ticket. Anything else is refused and the marker kept byte-for-byte:
|
|
173
|
-
* `fresh-needs-reopen` (names `block reopen` and `block waive`) or `fresh-tampered` (names only
|
|
174
|
-
* the owner's `block close --no-require-reviews`; reopen refuses a tampered state).
|
|
175
|
-
* B110: a plain cached round-1 `split_required` uses the same claim, with `retry: true`.
|
|
176
|
-
* @param {{repoRoot: string, run: string, block: string, file: string, where: {tree?: string}, say: (obj: Record<string, any>) => unknown, dropDone?: typeof dropDoneIf, retry?: boolean}} opts
|
|
177
|
-
* @returns {number} the exit code.
|
|
170
|
+
* Classify in place and use the same byte-checked claim for fresh and automatic retries.
|
|
171
|
+
* @param {{repoRoot: string, ticket: string, file: string, dropDone?: typeof dropDoneIf, once?: boolean, statuses?: Set<string>}} opts
|
|
172
|
+
* @returns {{verdict: 'rerun' | 'tampered' | 'counted', outcome: 'dropped' | 'gone' | 'kept'}}
|
|
178
173
|
*/
|
|
179
|
-
function
|
|
180
|
-
const { ticket } = resolveTicket({ repoRoot, run, block, file, tree: where.tree ?? null });
|
|
174
|
+
function dropFreshDone({ repoRoot, ticket, file, dropDone = dropDoneIf, once = false, statuses }) {
|
|
181
175
|
const markerPath = path.join(queueDir(repoRoot), `${ticket}.done`);
|
|
182
176
|
/** @type {'rerun' | 'tampered' | 'counted'} */
|
|
183
177
|
let verdict = 'counted';
|
|
@@ -191,18 +185,17 @@ function freshTicket({ repoRoot, run, block, file, where, say, dropDone = dropDo
|
|
|
191
185
|
// no marker: a normal enqueue; a marker that exists but cannot be read is never dropped
|
|
192
186
|
bytes = /** @type {{code?: unknown}} */ (err)?.code === 'ENOENT' ? null : Buffer.alloc(0);
|
|
193
187
|
}
|
|
194
|
-
if (bytes === null) {
|
|
195
|
-
say(enqueue({ repoRoot, run, block, file, ...where }));
|
|
196
|
-
return 0;
|
|
197
|
-
}
|
|
188
|
+
if (bytes === null) return { verdict: 'counted', outcome: 'gone' };
|
|
198
189
|
// classified IN PLACE: a refusal never moves, renames or touches the marker
|
|
199
190
|
const text = bytes.toString('utf8');
|
|
200
|
-
|
|
191
|
+
const result = resultOfMarker(repoRoot, text);
|
|
192
|
+
verdict = freshVerdict(text, result);
|
|
193
|
+
if (statuses && !statuses.has(result?.status)) verdict = 'counted';
|
|
201
194
|
if (verdict !== 'rerun') break;
|
|
202
195
|
/** @type {unknown} */
|
|
203
196
|
let outcome;
|
|
204
197
|
try {
|
|
205
|
-
outcome = dropDone(repoRoot, ticket, bytes);
|
|
198
|
+
outcome = dropDone(repoRoot, ticket, bytes, once ? { once: true } : {});
|
|
206
199
|
} catch (err) {
|
|
207
200
|
// a named refusal passes through; any other throw is named here, never echoed
|
|
208
201
|
if (err instanceof WorkerError) throw err;
|
|
@@ -212,10 +205,31 @@ function freshTicket({ repoRoot, run, block, file, where, say, dropDone = dropDo
|
|
|
212
205
|
verdict = 'counted';
|
|
213
206
|
continue;
|
|
214
207
|
}
|
|
208
|
+
if (outcome === 'kept') break;
|
|
215
209
|
// an allow-list of outcomes: anything else is refused and nothing is queued
|
|
216
210
|
if (outcome !== 'dropped' && outcome !== 'gone') throw new WorkerError('fresh-claim-unknown', `the claim of the cached result of ${file} gave an unknown outcome; nothing was queued`);
|
|
217
|
-
|
|
218
|
-
|
|
211
|
+
return { verdict, outcome };
|
|
212
|
+
}
|
|
213
|
+
return { verdict, outcome: 'kept' };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* B75: `review-file <path> --block <id> --fresh`. No done marker: a normal enqueue. The marker is
|
|
218
|
+
* read and classified IN PLACE; only a cached result that never counted is then claimed by
|
|
219
|
+
* `dropDoneIf` (rename, then a byte compare with what was classified) and the ticket queued again.
|
|
220
|
+
* A marker that changed under the claim is restored (link only) and classified again, at most
|
|
221
|
+
* {@link FRESH_ATTEMPTS} times. The queue has no lock: the rename claim is the exclusion, and a
|
|
222
|
+
* crash between the drop and the enqueue leaves only a re-runnable pending ticket. Anything else is refused and the marker kept byte-for-byte:
|
|
223
|
+
* `fresh-needs-reopen` (names `block reopen` and `block waive`) or `fresh-tampered` (names only
|
|
224
|
+
* the owner's `block close --no-require-reviews`; reopen refuses a tampered state).
|
|
225
|
+
* B110: a plain cached round-1 `split_required` uses the same claim, with `retry: true`.
|
|
226
|
+
* @param {{repoRoot: string, run: string, block: string, file: string, where: {tree?: string}, say: (obj: Record<string, any>) => unknown, dropDone?: typeof dropDoneIf, retry?: boolean}} opts
|
|
227
|
+
* @returns {number} the exit code.
|
|
228
|
+
*/
|
|
229
|
+
function freshTicket({ repoRoot, run, block, file, where, say, dropDone = dropDoneIf, retry = false }) {
|
|
230
|
+
const { ticket } = resolveTicket({ repoRoot, run, block, file, tree: where.tree ?? null });
|
|
231
|
+
const { verdict, outcome } = dropFreshDone({ repoRoot, ticket, file, dropDone });
|
|
232
|
+
if (outcome === 'dropped' || outcome === 'gone') {
|
|
219
233
|
const queued = enqueue({ repoRoot, run, block, file, ...where });
|
|
220
234
|
say(outcome === 'dropped' ? { ...queued, ...(retry ? { retry: true } : { fresh: true }) } : queued);
|
|
221
235
|
return 0;
|
|
@@ -390,7 +404,7 @@ async function waitBlockScope(repoRoot, cwd, run, block) {
|
|
|
390
404
|
throw new WorkerError('block-scope-unreadable', 'the block scope cannot be read');
|
|
391
405
|
}
|
|
392
406
|
|
|
393
|
-
/**
|
|
407
|
+
/** Read a completion marker without the signing key.
|
|
394
408
|
* @param {string} repoRoot @param {string} ticket @returns {Record<string, any> | null}
|
|
395
409
|
*/
|
|
396
410
|
function blockTicketMarker(repoRoot, ticket) {
|
|
@@ -404,12 +418,12 @@ function blockTicketMarker(repoRoot, ticket) {
|
|
|
404
418
|
}
|
|
405
419
|
|
|
406
420
|
/**
|
|
407
|
-
* A block wait
|
|
421
|
+
* A block wait uses unsigned results only to classify retries; it never reads the signing key.
|
|
408
422
|
* Re-scan current content on every poll. Deliver unseen findings of the newest earlier ticket
|
|
409
423
|
* before rechecking a blind edit; the next join queues the fix. Stopped files stay finished.
|
|
410
|
-
* @param {{repoRoot: string, cwd: string, block: string, run: string, maxMs: number, pollMs: number, say: (obj: Record<string, any>) => unknown}} opts
|
|
424
|
+
* @param {{repoRoot: string, cwd: string, block: string, run: string, maxMs: number, pollMs: number, say: (obj: Record<string, any>) => unknown, dropDone?: typeof dropDoneIf}} opts
|
|
411
425
|
*/
|
|
412
|
-
async function waitBlock({ repoRoot, cwd, block, run, maxMs, pollMs, say }) {
|
|
426
|
+
async function waitBlock({ repoRoot, cwd, block, run, maxMs, pollMs, say, dropDone = dropDoneIf }) {
|
|
413
427
|
const { entry, tree } = await waitBlockScope(repoRoot, cwd, run, block);
|
|
414
428
|
const where = realpathSync(tree) === realpathSync(repoRoot) ? {} : { tree: realpathSync(tree) };
|
|
415
429
|
const deadline = Date.now() + maxMs;
|
|
@@ -441,7 +455,12 @@ async function waitBlock({ repoRoot, cwd, block, run, maxMs, pollMs, say }) {
|
|
|
441
455
|
enqueue(opts);
|
|
442
456
|
present = { ...previous[previous.length - 1], ticket };
|
|
443
457
|
}
|
|
444
|
-
|
|
458
|
+
let marker = present ? blockTicketMarker(repoRoot, ticket) : null;
|
|
459
|
+
if (live && marker && RETRY_STATUSES.has(marker.status)) {
|
|
460
|
+
dropFreshDone({ repoRoot, ticket, file, dropDone, once: true, statuses: RETRY_STATUSES });
|
|
461
|
+
marker = blockTicketMarker(repoRoot, ticket);
|
|
462
|
+
}
|
|
463
|
+
return { file, ticket: present ? ticket : null, status: !present ? 'not_queued' : marker ? 'done' : 'pending' };
|
|
445
464
|
});
|
|
446
465
|
if (downPolls >= 2) {
|
|
447
466
|
say({ block, files, done: false, status: 'worker_down' });
|
|
@@ -478,7 +497,7 @@ export async function runReviewFile(args, deps = {}) {
|
|
|
478
497
|
if (positionals.length > 0 || flags.block !== undefined || flags.wait !== undefined || flags.answers !== undefined || flags.of !== undefined || flags.fresh === true || typeof flags.run !== 'string') {
|
|
479
498
|
throw new WorkerError('usage', '--wait-block takes a block, --run and --max only');
|
|
480
499
|
}
|
|
481
|
-
return await waitBlock({ repoRoot, cwd, block: assertBlockId(flags['wait-block']), run: assertRunId(flags.run), maxMs: flags.max === undefined ? DEFAULT_WAIT_MS : parseMax(flags.max), pollMs: deps.pollMs ?? POLL_MS, say });
|
|
500
|
+
return await waitBlock({ repoRoot, cwd, block: assertBlockId(flags['wait-block']), run: assertRunId(flags.run), maxMs: flags.max === undefined ? DEFAULT_WAIT_MS : parseMax(flags.max), pollMs: deps.pollMs ?? POLL_MS, say, dropDone: deps.dropDone });
|
|
482
501
|
}
|
|
483
502
|
|
|
484
503
|
if (typeof flags.wait === 'string') {
|
|
@@ -524,16 +543,9 @@ export async function runReviewFile(args, deps = {}) {
|
|
|
524
543
|
const where = realpathSync(tree) === realpathSync(repoRoot) ? {} : { tree };
|
|
525
544
|
if (flags.fresh === true) return freshTicket({ repoRoot, run: worker.run, block, file, where, say, ...(deps.dropDone ? { dropDone: deps.dropDone } : {}) });
|
|
526
545
|
const queued = enqueue({ repoRoot, run: worker.run, block, file, ...where });
|
|
527
|
-
if (queued.status === 'done' && doneResult(repoRoot, queued.ticket)?.status
|
|
546
|
+
if (queued.status === 'done' && ['unavailable', 'split_required'].includes(doneResult(repoRoot, queued.ticket)?.status)) {
|
|
528
547
|
return freshTicket({ repoRoot, run: worker.run, block, file, where, say, retry: true, ...(deps.dropDone ? { dropDone: deps.dropDone } : {}) });
|
|
529
548
|
}
|
|
530
|
-
if (queued.status === 'done' && doneResult(repoRoot, queued.ticket)?.status === 'unavailable') {
|
|
531
|
-
// `unavailable` is never approval AND never final: drop the done marker so the worker runs
|
|
532
|
-
// the ticket again (the fix loop re-runs its pending round); the signed result is replaced.
|
|
533
|
-
rmSync(path.join(queueDir(repoRoot), `${queued.ticket}.done`), { force: true });
|
|
534
|
-
say({ ...enqueue({ repoRoot, run: worker.run, block, file, ...where }), retry: true });
|
|
535
|
-
return 0;
|
|
536
|
-
}
|
|
537
549
|
say(queued);
|
|
538
550
|
return 0;
|
|
539
551
|
} catch (err) {
|
package/src/ledger/export.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `report --export <dir>` — writes one JSON file per report section (plan §6.2;
|
|
2
|
+
* `report --export <dir>` — writes one JSON file per report section (plan §6.2; 15 sections since B124b).
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { mkdir, writeFile } from 'node:fs/promises';
|
package/src/ledger/report.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `code-forge report` (plan §6.2, R4): builds the
|
|
2
|
+
* `code-forge report` (plan §6.2, R4): builds the 15 report sections from a run's ledger rows.
|
|
3
3
|
* Pure data, no I/O — terminal rendering + `--export` live in `src/cli/report.mjs`.
|
|
4
4
|
*
|
|
5
5
|
* Shadow rows (`row.source === 'shadow'`) are excluded everywhere here (plan §6.3: shadow-sampled
|
|
@@ -262,8 +262,8 @@ const runStopCounts = (rows) => ({
|
|
|
262
262
|
noEngineStops: rows.filter((r) => r.event === 'run.stop' && r.reason === 'no_engine').length,
|
|
263
263
|
});
|
|
264
264
|
|
|
265
|
-
/** The exact, ordered set of section keys `buildReport` produces (plan §6.2; B66 appended
|
|
266
|
-
*
|
|
265
|
+
/** The exact, ordered set of section keys `buildReport` produces (plan §6.2; B66 appended
|
|
266
|
+
* `outside_review`, then B124b appended `agent_waivers` — append new sections, never reorder) —
|
|
267
267
|
* exported so tests (and `--export`) can assert against ONE source of truth instead of a copy. */
|
|
268
268
|
export const SECTION_NAMES = Object.freeze([
|
|
269
269
|
'cost_per_block',
|
|
@@ -280,6 +280,7 @@ export const SECTION_NAMES = Object.freeze([
|
|
|
280
280
|
'blocks_stopped_at_l3',
|
|
281
281
|
'run_stop_counts',
|
|
282
282
|
'outside_review',
|
|
283
|
+
'agent_waivers',
|
|
283
284
|
]);
|
|
284
285
|
|
|
285
286
|
/**
|
|
@@ -307,6 +308,11 @@ export function buildReport(rows) {
|
|
|
307
308
|
// B66: per PR, the outside review rounds next to the cost of its blocks — the SAME
|
|
308
309
|
// `costPerBlock` entries the `cost_per_block` section shows
|
|
309
310
|
outside_review: (/** @type {any[]} */ r) => buildOutsideReview(r, costs),
|
|
311
|
+
agent_waivers: (/** @type {any[]} */ r) => r.flatMap((row, i) => row.event === 'review.waived' && row.by === 'agent'
|
|
312
|
+
? [{ block: row.block, file: row.file, finding: row.finding, severity: row.severity, reason: row.reason, verdict: row.verdict,
|
|
313
|
+
unwaived: r.slice(i + 1).some((undo) => undo.event === 'review.unwaived' && undo.by === 'human'
|
|
314
|
+
&& undo.run === row.run && undo.block === row.block && undo.file === row.file && undo.finding === row.finding) }]
|
|
315
|
+
: []),
|
|
310
316
|
};
|
|
311
317
|
/** @type {Record<string, any>} */
|
|
312
318
|
const sections = {};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/** Human acceptance of one false transcript hit, bound to its rule and exact line bytes. */
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { readFile } from 'node:fs/promises';
|
|
4
|
+
import { StateError } from '../state/paths.mjs';
|
|
5
|
+
import { readRun, writeSigned } from '../state/run.mjs';
|
|
6
|
+
import { verifyRow } from '../state/signer.mjs';
|
|
7
|
+
import { mergeForbidden, scanTranscript } from '../util/forbidden.mjs';
|
|
8
|
+
|
|
9
|
+
/** @typedef {{rule: string, line: number, reason: string}} AcceptedHit */
|
|
10
|
+
|
|
11
|
+
/** @param {string} text */
|
|
12
|
+
const lineHash = (text) => createHash('sha256').update(text).digest('hex');
|
|
13
|
+
|
|
14
|
+
/** One refusal for every well-formed request that does not name an actual hit. */
|
|
15
|
+
function noSuchHit() {
|
|
16
|
+
return new StateError('accept-hit-no-such-hit', 'accept-hit-no-such-hit: the named transcript hit was not found; nothing was recorded');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Validate forms before reading state, then find the exact pair before the terminal question.
|
|
21
|
+
* The close and this pre-check use the same merged forbidden list.
|
|
22
|
+
* @param {{runId: string, id: string, flags: Record<string, any>, positionals: string[], extraTokens?: string[]}} opts
|
|
23
|
+
*/
|
|
24
|
+
export async function prepareAcceptHit({ runId, id, flags, positionals, extraTokens = [] }) {
|
|
25
|
+
if (positionals.length !== 1) throw new StateError('usage', 'block accept-hit takes one block id');
|
|
26
|
+
for (const [name, value] of Object.entries({ block: id, run: runId, rule: flags.rule, line: flags.line, transcript: flags.transcript, reason: flags.reason })) {
|
|
27
|
+
// eslint-disable-next-line no-control-regex
|
|
28
|
+
if (typeof value === 'string' && /[\x00-\x1f\x7f]/.test(value)) throw new StateError('usage', `${name === 'block' ? 'block id' : `--${name}`} must not contain control characters`);
|
|
29
|
+
}
|
|
30
|
+
for (const name of ['rule', 'transcript', 'reason']) {
|
|
31
|
+
if (typeof flags[name] !== 'string' || flags[name].trim().length === 0) throw new StateError('usage', `--${name} is required`);
|
|
32
|
+
}
|
|
33
|
+
if (typeof flags.line !== 'string' || !/^-?\d+$/.test(flags.line) || !Number.isSafeInteger(Number(flags.line))) throw new StateError('usage', '--line must be an integer');
|
|
34
|
+
const line = Number(flags.line);
|
|
35
|
+
let record;
|
|
36
|
+
let text;
|
|
37
|
+
try {
|
|
38
|
+
record = await readRun(runId);
|
|
39
|
+
if (!record.blocks?.[id]) throw noSuchHit();
|
|
40
|
+
text = await readFile(flags.transcript, 'utf8');
|
|
41
|
+
} catch {
|
|
42
|
+
throw noSuchHit();
|
|
43
|
+
}
|
|
44
|
+
const lines = text.split('\n');
|
|
45
|
+
if (line < 1 || line > lines.length || !scanTranscript(text, mergeForbidden(extraTokens)).some((hit) => hit.id === flags.rule && hit.line === line)) throw noSuchHit();
|
|
46
|
+
return { record, row: { block: id, rule: /** @type {string} */ (flags.rule), line, line_sha256: lineHash(lines[line - 1]), reason: /** @type {string} */ (flags.reason.trim()), by: 'human' } };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Called only after terminal confirmation; exactly one signed row, through the waiver's writer.
|
|
51
|
+
* @param {{runId: string, row: Record<string, any>, writeRow: (row: Record<string, any>) => Promise<unknown>}} opts
|
|
52
|
+
*/
|
|
53
|
+
export async function writeAcceptedHit({ runId, row, writeRow }) {
|
|
54
|
+
await writeSigned(runId, writeRow, { event: 'rule_break.accepted', ...row });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Ledger rule_break rows are deliberately outside this transcript-only filter.
|
|
59
|
+
* Line numbers are for reporting; only rule and exact content bind the acceptance.
|
|
60
|
+
* @param {{block: string, runId: string, rows: Record<string, any>[], key: Buffer, transcript: string | null, extraTokens?: string[]}} opts
|
|
61
|
+
* @returns {{remaining: {id: string, line: number}[], accepted: AcceptedHit[]}}
|
|
62
|
+
*/
|
|
63
|
+
export function acceptedTranscriptHits({ block, runId, rows, key, transcript, extraTokens = [] }) {
|
|
64
|
+
if (typeof transcript !== 'string' || transcript.length === 0) return { remaining: [], accepted: [] };
|
|
65
|
+
const approvals = rows.filter((row) => row.event === 'rule_break.accepted' && row.run === runId && row.block === block && row.by === 'human' && typeof row.reason === 'string' && row.reason.trim().length > 0 && verifyRow(row, key).ok);
|
|
66
|
+
const lines = transcript.split('\n');
|
|
67
|
+
/** @type {{id: string, line: number}[]} */
|
|
68
|
+
const remaining = [];
|
|
69
|
+
/** @type {AcceptedHit[]} */
|
|
70
|
+
const accepted = [];
|
|
71
|
+
for (const hit of scanTranscript(transcript, mergeForbidden(extraTokens))) {
|
|
72
|
+
const hash = lineHash(lines[hit.line - 1]);
|
|
73
|
+
const approval = approvals.find((row) => row.rule === hit.id && row.line_sha256 === hash);
|
|
74
|
+
if (approval) accepted.push({ rule: hit.id, line: hit.line, reason: approval.reason });
|
|
75
|
+
else remaining.push(hit);
|
|
76
|
+
}
|
|
77
|
+
return { remaining, accepted };
|
|
78
|
+
}
|