@codedology/code-forge 0.14.1 → 0.15.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 +18 -0
- package/README.md +1 -1
- package/docs/reference/blocks.json +366 -1
- package/docs/reference/config.md +7 -0
- package/package.json +1 -1
- package/schema/code-forge.schema.json +22 -0
- package/scripts/first-use.mjs +11 -5
- package/skill/SKILL.md +2 -0
- package/skill/references/autopilot.md +2 -0
- package/skill/references/code.md +25 -1
- package/skill/references/review.md +10 -2
- package/skill/scripts/forge +1 -1
- package/src/autopilot/scopes.mjs +2 -1
- package/src/cli/block.mjs +235 -44
- package/src/cli/gates.mjs +36 -6
- package/src/cli/ledger.mjs +21 -2
- package/src/cli/report.mjs +14 -0
- package/src/cli/review-file.mjs +56 -16
- package/src/cli/run.mjs +44 -7
- package/src/cli/status.mjs +7 -3
- package/src/config/validate.mjs +13 -1
- package/src/gates/acceptance.mjs +7 -3
- package/src/gates/expand.mjs +25 -2
- package/src/gates/run.mjs +38 -8
- package/src/ledger/report.mjs +29 -7
- package/src/review/accept-hit.mjs +22 -4
- package/src/review/engine.mjs +30 -10
- package/src/review/finding.schema.json +2 -1
- package/src/review/fixloop.mjs +79 -14
- package/src/review/gate-check.mjs +22 -3
- package/src/review/lenses/recheck.md +3 -0
- package/src/review/packet.mjs +26 -0
- package/src/state/block.mjs +162 -20
- package/src/state/run.mjs +35 -8
- package/src/util/forbidden.mjs +14 -0
- package/src/util/reaper.mjs +3 -3
- package/src/worker/engine.mjs +60 -4
- package/src/worker/loop.mjs +19 -10
- package/src/worker/queue.mjs +5 -2
package/src/cli/block.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* `block open` before every spawn in every engine; it prints the brief pointer (≤ 200 bytes).
|
|
4
4
|
*
|
|
5
5
|
* block open <id> --run <r> [--level L<n>] --owned <paths…> --acceptance <file>
|
|
6
|
-
* [--brief <file>] [--attempt <n>] [--base <sha>] [--lines <forecast>] [--kind code|docs|contract]
|
|
6
|
+
* [--brief <file>] [--attempt <n>] [--base <sha>] [--lines <forecast>] [--kind code|docs|contract|light]
|
|
7
7
|
* [--tests <paths…>]
|
|
8
8
|
* block attempt <id> --run <r>
|
|
9
9
|
* block rebase <id> --run <r> [--head <ref>]
|
|
@@ -187,12 +187,14 @@
|
|
|
187
187
|
* autopilot`). The prompt checks only that stdin and stdout are terminals; a pseudo-terminal
|
|
188
188
|
* passes it, so it does not prove a human. The coder deny-list entries stay.
|
|
189
189
|
*
|
|
190
|
-
* `--acceptance` is a YAML/JSON list of `{clause, tests: [test ids…]}
|
|
190
|
+
* `--acceptance` is a YAML/JSON list of `{clause, tests: [test ids…]}` or `{clause, kind: human_check}`
|
|
191
|
+
* without tests. Human checks leave a successful close awaiting the human's terminal pass.
|
|
192
|
+
* `block close` runs B8's
|
|
191
193
|
* rows of the gate (worker pin, MACs, orphans); the gate rows owned by later blocks join it
|
|
192
194
|
* through `closeBlock`'s `extraChecks`.
|
|
193
195
|
*/
|
|
194
196
|
|
|
195
|
-
import { statSync } from 'node:fs';
|
|
197
|
+
import { existsSync, statSync } from 'node:fs';
|
|
196
198
|
import { readFile, realpath } from 'node:fs/promises';
|
|
197
199
|
import path from 'node:path';
|
|
198
200
|
import { parse as parseYAML } from 'yaml';
|
|
@@ -200,16 +202,16 @@ import { approvalCheckText, expiryChecks } from '../autopilot/limits.mjs';
|
|
|
200
202
|
import { fallbackLane } from '../decide/fallback-rules.mjs';
|
|
201
203
|
import { laneFacts } from '../decide/lane-facts.mjs';
|
|
202
204
|
import { appendRow, readAllRows } from '../ledger/write.mjs';
|
|
203
|
-
import { attemptBlock, claimPath, closeBlock, openBlock, rebaseBlock, repoTop, stopBlock } from '../state/block.mjs';
|
|
204
|
-
import { lastGate } from '../state/brief-status.mjs';
|
|
205
|
+
import { attemptBlock, claimPath, closeBlock, humanPassBlock, openBlock, rebaseBlock, repoTop, stopBlock } from '../state/block.mjs';
|
|
205
206
|
import { intFlag, parseFlags } from '../state/cli-args.mjs';
|
|
206
207
|
import { configInForce, externalReviewer, requireCoderTests } from '../state/config-snapshot.mjs';
|
|
208
|
+
import { readFileDiff } from '../review/packet.mjs';
|
|
207
209
|
import { StateError } from '../state/paths.mjs';
|
|
208
210
|
import { readRun, writeSigned } from '../state/run.mjs';
|
|
209
211
|
import { loadKey, verifyRow } from '../state/signer.mjs';
|
|
210
|
-
import { PROOF_FINDING, blockFileSet, changedFiles, openFindings, ownsAt, resultFilesFor, reviewedFiles, reviewGateCheck, unownedChanges, waiveFinding } from '../review/gate-check.mjs';
|
|
212
|
+
import { PROOF_FINDING, blockFileSet, changedFiles, checkBlockReviews, openFindings, ownsAt, recordedSeverity, resultFilesFor, reviewedFiles, reviewGateCheck, routineCloseProblem, unownedChanges, waiveFinding } from '../review/gate-check.mjs';
|
|
211
213
|
import { agentWaivers, agentWaiversAcked, prepareAckWaivers, prepareUnwaive, waiveAsAgent } from '../review/agent-waiver.mjs';
|
|
212
|
-
import { checkExpiry } from '../autopilot/grant.mjs';
|
|
214
|
+
import { checkExpiry, grantFor, grantState } from '../autopilot/grant.mjs';
|
|
213
215
|
import { redact, writeSafe } from '../util/redact.mjs';
|
|
214
216
|
import { blockTree, prepareWorktree, removeWorktree, worktreeSettings } from '../proof/worktree.mjs';
|
|
215
217
|
import { DEFAULT_CONFIG_FILENAME, loadProjectConfig } from '../config/load.mjs';
|
|
@@ -227,7 +229,7 @@ import { reportMismatches, reviewedLines } from '../review/reviewed-lines.mjs';
|
|
|
227
229
|
import { blockEndHook, runBlockEndHook, writeDoneFile } from '../state/block-end.mjs';
|
|
228
230
|
import { blockOpenFindings } from '../review/open-findings.mjs';
|
|
229
231
|
import { hookForbidden, scanTranscript } from '../util/forbidden.mjs';
|
|
230
|
-
import { prepareAcceptHit, writeAcceptedHit } from '../review/accept-hit.mjs';
|
|
232
|
+
import { acceptedTranscriptHits, prepareAcceptHit, writeAcceptedHit } from '../review/accept-hit.mjs';
|
|
231
233
|
|
|
232
234
|
/**
|
|
233
235
|
* B72: `block reopen` — the file's content is read where the block works (its worktree, else the
|
|
@@ -290,23 +292,31 @@ async function reopenBlockFile({ runId, id, file, reason, record, writeRow, agen
|
|
|
290
292
|
}
|
|
291
293
|
|
|
292
294
|
const USAGE =
|
|
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' +
|
|
295
|
+
'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|light] [--tests <paths…>]\n' +
|
|
294
296
|
' 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' +
|
|
295
297
|
' code-forge block waive <id> <finding-id> --run <r> --file <path> --reason <text> [--yes]\n' +
|
|
296
298
|
' 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' +
|
|
299
|
+
' code-forge block accept-hit <id> --run <r> [--rule <rule-id>] --line <n> --transcript <path> --reason <text>\n' +
|
|
298
300
|
' code-forge block unwaive <id> <finding-id> --run <r> --file <path> --reason <text>\n' +
|
|
299
301
|
' code-forge block ack-waivers <id> --run <r>\n' +
|
|
302
|
+
' code-forge block human-pass <id> --run <r> --note <text> [--proof <dir-or-file>]\n' +
|
|
300
303
|
' every block verb takes --json: one JSON line on stdout ({"block","event",…}) instead of the text\n';
|
|
301
304
|
|
|
302
|
-
const SUBCOMMANDS = ['open', 'attempt', 'rebase', 'claim', 'close', 'stop', 'waive', 'reopen', 'accept-hit', 'unwaive', 'ack-waivers'];
|
|
305
|
+
const SUBCOMMANDS = ['open', 'attempt', 'rebase', 'claim', 'close', 'stop', 'waive', 'reopen', 'accept-hit', 'unwaive', 'ack-waivers', 'human-pass'];
|
|
303
306
|
|
|
304
307
|
/** Refuse likely shell quoting slips before any row is written.
|
|
305
308
|
* @param {string} workspace @param {string[]} entries @param {string} label
|
|
306
309
|
*/
|
|
307
310
|
function checkWhitespacePaths(workspace, entries, label) {
|
|
308
311
|
for (const entry of entries) {
|
|
309
|
-
|
|
312
|
+
let depth = 0;
|
|
313
|
+
let separator = /\s/u.test(entry);
|
|
314
|
+
for (const char of entry) {
|
|
315
|
+
if (char === '{') depth += 1;
|
|
316
|
+
else if (char === '}') depth = Math.max(0, depth - 1);
|
|
317
|
+
else if (char === ',' && depth === 0) separator = true;
|
|
318
|
+
}
|
|
319
|
+
if (!separator) continue;
|
|
310
320
|
let stat;
|
|
311
321
|
try {
|
|
312
322
|
stat = statSync(path.join(workspace, entry));
|
|
@@ -314,11 +324,11 @@ function checkWhitespacePaths(workspace, entries, label) {
|
|
|
314
324
|
// A missing or unreadable path cannot establish a literal whitespace-containing name.
|
|
315
325
|
}
|
|
316
326
|
if (!stat?.isFile() && !stat?.isDirectory()) {
|
|
317
|
-
throw new StateError(
|
|
327
|
+
throw new StateError('usage', `${label} entry ${JSON.stringify(entry)} matches no file; use spaces, not commas`);
|
|
318
328
|
}
|
|
319
329
|
}
|
|
320
330
|
}
|
|
321
|
-
const FLAG_VALUES = ['run', 'level', 'acceptance', 'brief', 'attempt', 'base', 'lines', 'head', 'worker-pid', 'reason', 'file', 'transcript', 'kind', 'report', 'rule', 'line'];
|
|
331
|
+
const FLAG_VALUES = ['run', 'level', 'acceptance', 'brief', 'attempt', 'base', 'lines', 'head', 'worker-pid', 'reason', 'file', 'transcript', 'kind', 'report', 'rule', 'line', 'note', 'proof'];
|
|
322
332
|
|
|
323
333
|
/**
|
|
324
334
|
* @param {string[]} args
|
|
@@ -349,7 +359,7 @@ export async function runBlock(args, deps = {}) {
|
|
|
349
359
|
return 2;
|
|
350
360
|
}
|
|
351
361
|
try {
|
|
352
|
-
const { flags, positionals } = parseFlags(rest, { values: FLAG_VALUES, multi: ['owned', 'tests'], booleans: ['no-require-reviews', 'remove-worktree', 'json', ...(['waive', 'reopen'].includes(sub) ? ['yes'] : [])] });
|
|
362
|
+
const { flags, positionals } = parseFlags(rest, { values: FLAG_VALUES, multi: ['owned', 'tests'], booleans: ['no-require-reviews', 'remove-worktree', 'json', ...(sub === 'close' ? ['decide'] : []), ...(['waive', 'reopen'].includes(sub) ? ['yes'] : [])] });
|
|
353
363
|
// B64a: --json prints ONE JSON line on success instead of the text lines (`emit`, one object
|
|
354
364
|
// per verb, built where the text is); without it every output is exactly as before (`say` is `out`)
|
|
355
365
|
const json = flags.json === true;
|
|
@@ -377,16 +387,36 @@ export async function runBlock(args, deps = {}) {
|
|
|
377
387
|
if (sub === 'reopen' && typeof flags.file !== 'string') throw new StateError('usage', 'block reopen needs --file <path>');
|
|
378
388
|
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
389
|
if (sub === 'accept-hit') {
|
|
380
|
-
|
|
381
|
-
const
|
|
390
|
+
// prepareAcceptHit (review/accept-hit.mjs) returns every accepted hit as rows (all hits on --line, or only the --rule hit).
|
|
391
|
+
const { record, rows } = await prepareAcceptHit({ runId, id, flags, positionals, extraTokens });
|
|
392
|
+
const asked = await confirmHumanVerb(sub, runId, id, { rule: rows[0].rule, line: rows[0].line, ...(flags.rule === undefined ? { hits: rows } : {}), file: /** @type {string} */ (flags.transcript) }, record, deps);
|
|
382
393
|
if (asked === 'no-tty') { err(`${humanVerbNoTTY(sub)}\n`); return 2; }
|
|
383
394
|
if (asked === 'cancelled') { err(`block ${id} ${sub}: cancelled — nothing was recorded\n`); return 1; }
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
395
|
+
for (const row of rows) {
|
|
396
|
+
await writeAcceptedHit({ runId, row, writeRow: (signed) => appendRow(signed, { slug: record.project }) });
|
|
397
|
+
say(`block ${id} accepted ${row.rule} transcript line ${row.line} (by: human)\n`);
|
|
398
|
+
}
|
|
399
|
+
const accepted = rows.map(({ rule, line, reason, by }) => ({ rule, line, reason, by }));
|
|
400
|
+
if (json) emit({ block: id, event: 'rule_break.accepted', ...(flags.rule === undefined ? { accepted_hits: accepted } : accepted[0]) });
|
|
387
401
|
return 0;
|
|
388
402
|
}
|
|
389
403
|
const record = await readRun(runId);
|
|
404
|
+
const deciding = sub === 'close' && flags.decide === true;
|
|
405
|
+
if (deciding) {
|
|
406
|
+
if (positionals.length !== 1 || typeof flags.reason !== 'string' || !flags.reason.trim() || /[\x00-\x1f\x7f]/.test(flags.reason)) throw new StateError('usage', '--decide needs one block id and a nonblank --reason without control characters');
|
|
407
|
+
if (flags['no-require-reviews']) throw new StateError('usage', '--decide cannot skip reviews');
|
|
408
|
+
if (!(deps.stdinIsTTY ?? deps.isTTY ?? process.stdin.isTTY === true) || !(deps.stdoutIsTTY ?? deps.isTTY ?? process.stdout.isTTY === true)) {
|
|
409
|
+
err(`${humanVerbNoTTY('close')}\n`); return 2;
|
|
410
|
+
}
|
|
411
|
+
if (record.autopilot && grantState(record.autopilot, (deps.now ?? (() => new Date()))()) === 'active') throw new StateError('autopilot-active', 'block close --decide is refused while an autopilot grant is active');
|
|
412
|
+
}
|
|
413
|
+
if (sub === 'human-pass') {
|
|
414
|
+
if (positionals.length !== 1 || typeof flags.note !== 'string' || flags.note.trim().length === 0) throw new StateError('usage', 'block human-pass needs one block id and --note <text>');
|
|
415
|
+
if (typeof flags.proof === 'string' && !existsSync(flags.proof)) throw new StateError('usage', '--proof must name an existing directory or file');
|
|
416
|
+
const asked = await confirmHumanVerb(sub, runId, id, { file: '' }, record, deps);
|
|
417
|
+
if (asked === 'no-tty') { err(`${humanVerbNoTTY(sub)}\n`); return 2; }
|
|
418
|
+
if (asked === 'cancelled') { err(`block ${id} ${sub}: cancelled — nothing was recorded\n`); return 1; }
|
|
419
|
+
}
|
|
390
420
|
if (sub === 'unwaive' || sub === 'ack-waivers') {
|
|
391
421
|
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
422
|
const prepared = sub === 'unwaive'
|
|
@@ -403,8 +433,9 @@ export async function runBlock(args, deps = {}) {
|
|
|
403
433
|
return 0;
|
|
404
434
|
}
|
|
405
435
|
if (sub === 'open') {
|
|
406
|
-
|
|
407
|
-
|
|
436
|
+
const base = record.blocks?.[id]?.worktree?.path ?? record.workspace;
|
|
437
|
+
checkWhitespacePaths(base, /** @type {string[]} */ (flags.owned), 'owned');
|
|
438
|
+
if (Array.isArray(flags.tests)) checkWhitespacePaths(base, flags.tests, 'tests');
|
|
408
439
|
}
|
|
409
440
|
// B80: waive and reopen are the human's — asked at a terminal before anything is written (not
|
|
410
441
|
// even the autopilot expiry rows below); no terminal is exit 2, a "no" is exit 1
|
|
@@ -471,10 +502,22 @@ export async function runBlock(args, deps = {}) {
|
|
|
471
502
|
},
|
|
472
503
|
};
|
|
473
504
|
};
|
|
505
|
+
if (sub === 'human-pass') {
|
|
506
|
+
const row = await humanPassBlock({ runId, id, note: /** @type {string} */ (flags.note), proof: typeof flags.proof === 'string' ? flags.proof : null, writeRow, now: (deps.now ?? (() => new Date()))() });
|
|
507
|
+
const end = await ended('closed', 'block.human_pass', null);
|
|
508
|
+
try {
|
|
509
|
+
say(`block ${id} closed (human check passed)\n`);
|
|
510
|
+
if (json) emit({ ...row, status: 'closed', ...end.json });
|
|
511
|
+
return 0;
|
|
512
|
+
} finally { await end.runHook(); }
|
|
513
|
+
}
|
|
474
514
|
// B48: every block command runs the lazy autopilot checks (grant expiry, expired approvals);
|
|
475
515
|
// a failed check is a warning, never a reason to stop the block command
|
|
516
|
+
const requireHumanClose = sub === 'close' && configInForce(record)?.close?.require_human === true;
|
|
476
517
|
try {
|
|
477
|
-
|
|
518
|
+
if (!deciding && !requireHumanClose) {
|
|
519
|
+
err(approvalCheckText(await expiryChecks(runId, (deps.now ?? (() => new Date()))(), { writeRow })));
|
|
520
|
+
}
|
|
478
521
|
} catch (thrown) {
|
|
479
522
|
err(`autopilot: WARN the expiry check failed (${thrown?.message ?? String(thrown)})\n`);
|
|
480
523
|
}
|
|
@@ -608,9 +651,22 @@ export async function runBlock(args, deps = {}) {
|
|
|
608
651
|
throw thrown;
|
|
609
652
|
});
|
|
610
653
|
// ONE base for the whole close: a worktree block is measured against the commit its tree was
|
|
611
|
-
// made from (
|
|
654
|
+
// made from (kept equal to base_sha, including after a closed worktree rebase)
|
|
612
655
|
const base = wt !== null ? wt.base_sha : entry.base_sha;
|
|
613
656
|
const reviewsWaived = flags['no-require-reviews'] === true;
|
|
657
|
+
let routineGrant = null;
|
|
658
|
+
if (!deciding && !reviewsWaived && requireHumanClose) {
|
|
659
|
+
const active = record.autopilot && grantState(record.autopilot, (deps.now ?? (() => new Date()))()) === 'active';
|
|
660
|
+
if (active) {
|
|
661
|
+
const allowed = await grantFor(runId, 'close:routine', (deps.now ?? (() => new Date()))());
|
|
662
|
+
if (!allowed.ok) throw new StateError('close-not-routine', `close-not-routine: ${allowed.reason}`);
|
|
663
|
+
routineGrant = allowed.grant;
|
|
664
|
+
} else {
|
|
665
|
+
const asked = await confirmAtTerminal(deps, `Close block ${id} of run ${runId}? Only the owner may say yes.`);
|
|
666
|
+
if (asked === 'no-tty') { err(`${humanVerbNoTTY('close')}\n`); return 2; }
|
|
667
|
+
if (asked === 'cancelled') { err(`block ${id} close: cancelled — nothing was recorded\n`); return 1; }
|
|
668
|
+
}
|
|
669
|
+
}
|
|
614
670
|
// B52: the human's way out is confirmed by the human, at a terminal, before anything is written
|
|
615
671
|
if (reviewsWaived) {
|
|
616
672
|
const asked = await confirmNoReviews(runId, id, deps);
|
|
@@ -673,14 +729,16 @@ export async function runBlock(args, deps = {}) {
|
|
|
673
729
|
const others = closeRows.filter((r) => r.event !== 'gate.review_waived_all');
|
|
674
730
|
closeRows.splice(0, closeRows.length, ...others, ...files.map((f) => ({ event: 'gate.review_waived_all', block: id, file: f.file, content_hash: f.content_hash, findings: f.findings, by: f.by, ...(f.answered?.length ? { answered: f.answered } : {}) })));
|
|
675
731
|
};
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
732
|
+
let dryRun = deciding || routineGrant !== null;
|
|
733
|
+
let transcriptFile = '';
|
|
734
|
+
const loadGate = async () => {
|
|
735
|
+
const transcript = await findTranscript(record, id, typeof flags.transcript === 'string' ? flags.transcript : null, (file) => { transcriptFile = file; });
|
|
736
|
+
if (transcript === null && !dryRun) {
|
|
679
737
|
await writeSigned(runId, writeRow, { event: 'gate.transcript_missing', block: id });
|
|
680
738
|
err(`WARN block ${id}: no coder transcript found (--transcript, run record, .code-forge/runs/${runId}/${id}.log); the transcript grep did not run\n`);
|
|
681
739
|
}
|
|
682
740
|
// the human's way out of the per-file review check leaves a signed audit row before the close
|
|
683
|
-
if (reviewsWaived) await writeSigned(runId, writeRow, { event: 'gate.reviews_waived', block: id, by: 'human' });
|
|
741
|
+
if (reviewsWaived && !dryRun) await writeSigned(runId, writeRow, { event: 'gate.reviews_waived', block: id, by: 'human' });
|
|
684
742
|
// B20: the high paths come first — a base the workspace does not know refuses the close here
|
|
685
743
|
const highPaths = await highPathsAt(record.workspace, base);
|
|
686
744
|
const { top, mainTop, where, inTree, wsPrefix, owns, changed, fileSet, reviewed } = await tree();
|
|
@@ -723,7 +781,8 @@ export async function runBlock(args, deps = {}) {
|
|
|
723
781
|
proofFiles: fileSet,
|
|
724
782
|
highPaths,
|
|
725
783
|
};
|
|
726
|
-
}
|
|
784
|
+
};
|
|
785
|
+
const gate = reviewGateCheck(loadGate, { onWaivedAll, onAcceptedHits: (hits) => { acceptedHits = hits; } });
|
|
727
786
|
// B96: `reviewsPassed` comes from the gate's own result, not from the onWaivedAll call. Each
|
|
728
787
|
// extra check runs at most once per close (`closeBlock`'s one loop, src/state/block.mjs:562,
|
|
729
788
|
// and `closeBlock` is called once below), so the gate's one pass is its final one.
|
|
@@ -799,6 +858,35 @@ export async function runBlock(args, deps = {}) {
|
|
|
799
858
|
};
|
|
800
859
|
/** @type {Array<() => Promise<{ok: boolean, reason?: string}>>} */
|
|
801
860
|
const extraChecks = [reviews, report];
|
|
861
|
+
if (entry.kind === 'light') extraChecks.unshift(async () => {
|
|
862
|
+
const { top, changed, owns } = await tree();
|
|
863
|
+
const files = changed.filter((file) => owns(entry.owned_files, file, entry.owned_kinds));
|
|
864
|
+
let lines = 0;
|
|
865
|
+
for (const file of files) {
|
|
866
|
+
const diff = await readFileDiff({ repoRoot: top, file, base });
|
|
867
|
+
lines += diff.plusCount + diff.minusCount;
|
|
868
|
+
}
|
|
869
|
+
const cfg = configInForce(record);
|
|
870
|
+
return files.length > (cfg?.light?.max_files ?? 5) || lines > (cfg?.light?.max_lines ?? 300)
|
|
871
|
+
? { ok: false, reason: `light-limits ${files.length} files, ${lines} lines` } : { ok: true };
|
|
872
|
+
});
|
|
873
|
+
if (configInForce(record)?.gates?.test != null) {
|
|
874
|
+
const testGate = async () => {
|
|
875
|
+
const green = rows.filter((row) => row.block === id && row.event === 'gate.done' && row.all_ok === true);
|
|
876
|
+
// Only commits after the block base that touch its owned paths belong to this block.
|
|
877
|
+
// Older rows without timestamps remain usable, but a failed history lookup cannot prove freshness.
|
|
878
|
+
const commit = green.length === 0 ? null : await exec(['git', 'log', '-1', '--format=%ct', `${base}..HEAD`, '--', ...entry.owned_files], {
|
|
879
|
+
cwd: wt?.workspace ?? record.workspace, env: gitChildEnv(), timeoutMs: 10000,
|
|
880
|
+
});
|
|
881
|
+
const committed = commit?.result === 'ok' ? Number.parseInt(commit.stdout.trim(), 10) * 1000 : Number.NaN;
|
|
882
|
+
const ok = commit?.result === 'ok' && (commit.stdout.trim() === '' || Number.isFinite(committed)) &&
|
|
883
|
+
green.some((row) => !Number.isFinite(committed) || !Number.isFinite(Date.parse(row.ts)) || Date.parse(row.ts) > committed);
|
|
884
|
+
return ok ? { ok: true } : { ok: false, reason: `no green gates run; run: code-forge gates run --run ${runId} --block ${id}` };
|
|
885
|
+
};
|
|
886
|
+
// Keep human-bypass diagnostics ahead of the test gate; waiving reviews still needs green tests.
|
|
887
|
+
if (reviewsWaived) extraChecks.push(testGate);
|
|
888
|
+
else extraChecks.unshift(testGate);
|
|
889
|
+
}
|
|
802
890
|
extraChecks.unshift(async () => agentWaiversAcked(record, rows, id, await loadKey(runId))
|
|
803
891
|
? { ok: true } : { ok: false, reason: 'agent-waivers-not-acked' });
|
|
804
892
|
// B63b: `context.require_coder_tests` in the run's config snapshot (never the live config)
|
|
@@ -824,7 +912,72 @@ export async function runBlock(args, deps = {}) {
|
|
|
824
912
|
return hit ? { ok: false, reason: `rule_break transcript line ${hit.line}: ${hit.id}` } : { ok: true };
|
|
825
913
|
});
|
|
826
914
|
}
|
|
827
|
-
const
|
|
915
|
+
const closeOptions = { runId, id, rows, writeRow, livePid: intFlag(flags['worker-pid'], 'worker-pid'), probe, extraChecks, closeRows };
|
|
916
|
+
if (deciding) {
|
|
917
|
+
const data = await loadGate();
|
|
918
|
+
const initial = checkBlockReviews(data);
|
|
919
|
+
const descriptions = await resultFilesFor({ repoRoot: (await tree()).mainTop, runId, block: id, rows, includeCurrent: true });
|
|
920
|
+
const claimOf = (/** @type {string} */ file, /** @type {string} */ finding) => rows
|
|
921
|
+
.filter((r) => r.block === id && r.file === file && verifyRow(r, data.key).ok).flatMap((r) => {
|
|
922
|
+
const full = descriptions[r.ticket]?.findings;
|
|
923
|
+
return [...(r.finding === finding ? [r] : []), ...(Array.isArray(r.open) ? r.open : []), ...(Array.isArray(full) ? full : [])]
|
|
924
|
+
.filter((f) => (f?.id === finding || f?.finding === finding) && typeof f.claim === 'string');
|
|
925
|
+
}).at(-1)?.claim;
|
|
926
|
+
/** @type {Array<{label: string, write: (sink: (row: Record<string, any>) => Promise<unknown>) => Promise<unknown>}>} */
|
|
927
|
+
const actions = [];
|
|
928
|
+
const seen = new Set();
|
|
929
|
+
for (const refusal of initial.refusals) {
|
|
930
|
+
if (!['not_approved', 'late_unruled', 'review_cap', 'autopilot_waiver_invalid'].includes(refusal.code) || !refusal.file || !refusal.finding || refusal.finding === PROOF_FINDING) continue;
|
|
931
|
+
const { file, finding } = refusal;
|
|
932
|
+
const pair = `${file}\0${finding}`;
|
|
933
|
+
if (seen.has(pair)) continue;
|
|
934
|
+
seen.add(pair);
|
|
935
|
+
await waivePrecheck(record, runId, id, finding, file);
|
|
936
|
+
const severity = recordedSeverity(rows, { block: id, file, finding, key: data.key }).severity ?? 'unknown';
|
|
937
|
+
actions.push({ label: `${file} ${finding} ${severity}: ${claimOf(file, finding) ?? refusal.detail}`, write: (sink) => waiveFinding({ runId, id, file, finding, reason: /** @type {string} */ (flags.reason), writeRow: sink }) });
|
|
938
|
+
}
|
|
939
|
+
if (!agentWaiversAcked(record, rows, id, data.key)) {
|
|
940
|
+
const ack = await prepareAckWaivers(record, runId, id);
|
|
941
|
+
actions.push({ label: ack.counted.map((w) => `${w.file} ${w.finding} ${w.severity}: agent-waivers-not-acked ${claimOf(w.file, w.finding) ?? w.reason}; reason: ${w.reason}; verdict: ${w.verdict}`).join('; '), write: (sink) => writeSigned(runId, sink, ack.row) });
|
|
942
|
+
}
|
|
943
|
+
const hits = acceptedTranscriptHits(data).remaining;
|
|
944
|
+
for (const hit of hits) {
|
|
945
|
+
const prepared = await prepareAcceptHit({ runId, id, flags: { rule: hit.id, line: String(hit.line), transcript: transcriptFile, reason: flags.reason }, positionals: [id], extraTokens });
|
|
946
|
+
for (const row of prepared.rows) actions.push({ label: `${transcriptFile} ${row.rule} severity rule_break line ${row.line}: ${data.transcript?.split('\n')[row.line - 1]}`, write: (sink) => writeAcceptedHit({ runId, row, writeRow: sink }) });
|
|
947
|
+
}
|
|
948
|
+
const count = rows.length;
|
|
949
|
+
const buffered = async (/** @type {Record<string, any>} */ row) => { rows.push(row); };
|
|
950
|
+
for (const action of actions) await action.write(buffered);
|
|
951
|
+
const preview = await closeBlock({ ...closeOptions, dryRun: true });
|
|
952
|
+
rows.splice(count);
|
|
953
|
+
// Reset anything buffered by report, proof and review checks before the real pass.
|
|
954
|
+
closeRows.splice(0, closeRows.length, ...(external === null ? [] : [{ event: 'gate.reviews_waived', block: id, by: 'config', reviewer: external }]));
|
|
955
|
+
linesOnce = undefined;
|
|
956
|
+
actions.forEach((action, index) => (json ? err : say)(`${index + 1}. ${action.label}\n`));
|
|
957
|
+
if (!preview.ok) { err(`cannot decide here: ${preview.reason}\n`); return 1; }
|
|
958
|
+
const asked = await confirmAtTerminal(deps, `Apply these decisions and close block ${id} of run ${runId}? Only the owner may say yes.`);
|
|
959
|
+
if (asked === 'no-tty') { err(`${humanVerbNoTTY('close')}\n`); return 2; }
|
|
960
|
+
if (asked === 'cancelled') { err(`block ${id} close: cancelled — nothing was recorded\n`); return 1; }
|
|
961
|
+
for (const action of actions) await action.write(writeRow);
|
|
962
|
+
rows.splice(0, rows.length, ...(await readAllRows(record.project)).filter((row) => row.run === runId));
|
|
963
|
+
}
|
|
964
|
+
if (routineGrant !== null) {
|
|
965
|
+
const data = await loadGate();
|
|
966
|
+
const problem = external !== null ? 'external reviews' : routineCloseProblem(data);
|
|
967
|
+
if (problem !== null) { err(`close-not-routine: ${problem}\n`); return 1; }
|
|
968
|
+
const preview = await closeBlock({ ...closeOptions, dryRun: true });
|
|
969
|
+
if (!preview.ok) { err(`close-not-routine: ${preview.reason}\n`); return 1; }
|
|
970
|
+
closeRows.splice(0, closeRows.length, { event: 'autopilot.close', block: id, grant: routineGrant.grant_id });
|
|
971
|
+
linesOnce = undefined;
|
|
972
|
+
}
|
|
973
|
+
dryRun = false;
|
|
974
|
+
// The confirmation can take arbitrarily long: measure current bytes again at real close.
|
|
975
|
+
if (deciding || routineGrant !== null) {
|
|
976
|
+
treeOnce = undefined;
|
|
977
|
+
linesOnce = undefined;
|
|
978
|
+
lineWarnings.length = 0;
|
|
979
|
+
}
|
|
980
|
+
const result = await closeBlock(closeOptions);
|
|
828
981
|
const agentRows = agentWaivers(rows, id, await loadKey(runId));
|
|
829
982
|
const reopenedRows = agentReopens(rows.filter((r) => r.run === runId), id, await loadKey(runId));
|
|
830
983
|
// B64b: the block ended (closed, or stopped by a worker pin event or ledger.tamper) — its rows
|
|
@@ -840,9 +993,7 @@ export async function runBlock(args, deps = {}) {
|
|
|
840
993
|
// B70: each file that passed as reviewed_by_waiver is named before the closed line
|
|
841
994
|
// B71: an answered finding is named as answered; a file with only answered findings has no `by`
|
|
842
995
|
if (result.ok) {
|
|
843
|
-
|
|
844
|
-
err(`WARN block ${id}: no green gates run for this block; run gates run --run ${runId} --block ${id} before close\n`);
|
|
845
|
-
}
|
|
996
|
+
await warnMovedBase(record.workspace, entry, id, runId, err);
|
|
846
997
|
for (const w of lineWarnings) err(w);
|
|
847
998
|
for (const l of reviewedJson) say(`reviewed ${l.file} ticket ${l.ticket}\n`);
|
|
848
999
|
for (const f of waivedAll) {
|
|
@@ -854,7 +1005,10 @@ export async function runBlock(args, deps = {}) {
|
|
|
854
1005
|
for (const r of reopenedRows) say(`agent reopen ${r.file}: ${r.reason} (verdict: ${r.verdict?.verdict})\n`);
|
|
855
1006
|
for (const hit of acceptedHits) say(`accepted rule_break ${hit.rule} transcript line ${hit.line}: ${hit.reason} (by: human)\n`);
|
|
856
1007
|
}
|
|
857
|
-
|
|
1008
|
+
if (result.status === 'awaiting_human_check') {
|
|
1009
|
+
for (const check of entry.human_checks) say(`block ${id} awaiting human check: ${check}\n`);
|
|
1010
|
+
say(`next: code-forge block human-pass ${id} --run ${runId} --note "<device check note>" [--proof <dir-or-file>]\n`);
|
|
1011
|
+
} else (result.ok ? say : err)(`block ${id} ${result.status}${result.event ? ` (${result.event})` : ''}${result.reason ? `: ${result.reason}` : ''}\n`);
|
|
858
1012
|
// B64a: a refused close also names itself on stdout under --json (its stderr is unchanged)
|
|
859
1013
|
if (json && !result.ok) emit({ block: id, ok: false, reason: result.reason ?? null, ...(end?.json ?? {}) });
|
|
860
1014
|
const closedJson = {
|
|
@@ -863,6 +1017,7 @@ export async function runBlock(args, deps = {}) {
|
|
|
863
1017
|
status: result.status,
|
|
864
1018
|
reason: result.reason ?? null,
|
|
865
1019
|
close_event: result.event ?? null,
|
|
1020
|
+
...(result.status === 'awaiting_human_check' ? { human_checks: entry.human_checks, next_command: `code-forge block human-pass ${id} --run ${runId} --note "<device check note>"` } : {}),
|
|
866
1021
|
...(end?.json ?? {}),
|
|
867
1022
|
// B70: only when a file passed as reviewed_by_waiver (otherwise the output is unchanged)
|
|
868
1023
|
...(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 } : {}) })) } : {}),
|
|
@@ -872,7 +1027,7 @@ export async function runBlock(args, deps = {}) {
|
|
|
872
1027
|
...(reopenedRows.length > 0 ? { agent_reopens: reopenedRows } : {}),
|
|
873
1028
|
...(acceptedHits.length > 0 ? { accepted_hits: acceptedHits } : {}),
|
|
874
1029
|
};
|
|
875
|
-
if (!result.ok || !removeTree) {
|
|
1030
|
+
if (!result.ok || !removeTree || result.status === 'awaiting_human_check') {
|
|
876
1031
|
if (json && result.ok) emit(closedJson);
|
|
877
1032
|
return result.ok ? 0 : 1;
|
|
878
1033
|
}
|
|
@@ -896,6 +1051,32 @@ export async function runBlock(args, deps = {}) {
|
|
|
896
1051
|
return 2;
|
|
897
1052
|
}
|
|
898
1053
|
|
|
1054
|
+
/**
|
|
1055
|
+
* Advisory only, after a successful close: missing refs or failed history reads cannot change
|
|
1056
|
+
* the close's exit code. Resolve the recorded branch, regardless of the workspace's checkout.
|
|
1057
|
+
* @param {string} cwd @param {Record<string, any>} block @param {string} id @param {string} runId
|
|
1058
|
+
* @param {(text: string) => unknown} warn
|
|
1059
|
+
*/
|
|
1060
|
+
async function warnMovedBase(cwd, block, id, runId, warn) {
|
|
1061
|
+
if (!block.base_branch) return;
|
|
1062
|
+
try {
|
|
1063
|
+
const opts = { cwd, env: gitChildEnv(), timeoutMs: 10000 };
|
|
1064
|
+
const head = await exec(['git', 'rev-parse', '--verify', '--end-of-options', `refs/heads/${block.base_branch}^{commit}`], opts);
|
|
1065
|
+
if (head.result !== 'ok' || head.stdout.trim() === block.base_sha) return;
|
|
1066
|
+
const blockHead = await exec(['git', 'rev-parse', '--verify', 'HEAD^{commit}'], { ...opts, cwd: block.worktree?.path ?? cwd });
|
|
1067
|
+
if (blockHead.result !== 'ok') return;
|
|
1068
|
+
// A base advanced by this block's own commits is already incorporated. Only exit 1
|
|
1069
|
+
// means "not an ancestor"; errors and timeouts must not produce an advisory.
|
|
1070
|
+
const ancestor = await exec(['git', 'merge-base', '--is-ancestor', head.stdout.trim(), blockHead.stdout.trim()], { ...opts, okExitCodes: [0, 1] });
|
|
1071
|
+
if (ancestor.result !== 'ok' || ancestor.code !== 1) return;
|
|
1072
|
+
const count = await exec(['git', 'rev-list', '--count', `${block.base_sha}..${head.stdout.trim()}`], opts);
|
|
1073
|
+
if (count.result !== 'ok' || !/^\d+$/.test(count.stdout.trim())) return;
|
|
1074
|
+
warn(`WARN block ${id}: base moved: ${block.base_branch} is ${count.stdout.trim()} commit(s) ahead of the block base; run: code-forge block rebase ${id} --run ${runId}\n`);
|
|
1075
|
+
} catch {
|
|
1076
|
+
// The close already succeeded; this warning is best effort.
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
|
|
899
1080
|
/**
|
|
900
1081
|
* B62a: `block close --remove-worktree`, after the block closed: `git worktree remove <path>` (no
|
|
901
1082
|
* `--force`, the branch is kept), then a signed `worktree.removed {block, path, branch}` or
|
|
@@ -959,7 +1140,7 @@ export const NO_REVIEWS_NO_TTY = '--no-require-reviews needs the human at a term
|
|
|
959
1140
|
|
|
960
1141
|
/**
|
|
961
1142
|
* B80: the refusal when `block waive` or `block reopen` has no terminal to ask the human at.
|
|
962
|
-
* @param {'waive' | 'reopen' | 'accept-hit' | 'unwaive' | 'ack-waivers'} verb @returns {string}
|
|
1143
|
+
* @param {'waive' | 'reopen' | 'accept-hit' | 'unwaive' | 'ack-waivers' | 'human-pass' | 'close'} verb @returns {string}
|
|
963
1144
|
*/
|
|
964
1145
|
export const humanVerbNoTTY = (verb) => verb === 'waive'
|
|
965
1146
|
? '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)'
|
|
@@ -1016,26 +1197,33 @@ async function confirmNoReviews(runId, id, deps) {
|
|
|
1016
1197
|
/**
|
|
1017
1198
|
* B80: `block waive` and `block reopen` are the human's alone (D1 = a): the human confirms at a
|
|
1018
1199
|
* terminal through `confirmAtTerminal`, exactly like `--no-require-reviews`, but an active
|
|
1019
|
-
* autopilot grant changes nothing (autopilot waives through its own path,
|
|
1200
|
+
* autopilot grant changes nothing for waive/reopen (autopilot waives through its own path,
|
|
1201
|
+
* `by: autopilot`); human-pass instead refuses an active grant. The
|
|
1020
1202
|
* pre-checks (`reopenPrecheck`, `waivePrecheck`) run first, at a terminal, so the human is never
|
|
1021
1203
|
* asked about a waive or reopen the verb would refuse that way.
|
|
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
|
|
1204
|
+
* @param {'waive' | 'reopen' | 'accept-hit' | 'unwaive' | 'ack-waivers' | 'human-pass'} verb @param {string} runId @param {string} id
|
|
1205
|
+
* @param {{finding?: string, file: string, rule?: string, line?: number, hits?: {rule: string, line: number}[]}} what @param {Record<string, any>} record @param {ConfirmDeps} deps
|
|
1024
1206
|
* @param {() => void} [before]
|
|
1025
1207
|
* @returns {Promise<'no-tty' | 'confirmed' | 'cancelled'>}
|
|
1026
1208
|
*/
|
|
1027
1209
|
function confirmHumanVerb(verb, runId, id, what, record, deps, before) {
|
|
1028
1210
|
const message =
|
|
1029
|
-
verb === '
|
|
1211
|
+
verb === 'human-pass'
|
|
1212
|
+
? `Confirm all device checks of block ${id} (run ${runId}) passed? Only the owner may say yes.`
|
|
1213
|
+
: verb === 'waive'
|
|
1030
1214
|
? `Waive finding ${what.finding} in ${what.file} of block ${id} (run ${runId})? Only the owner may say yes.`
|
|
1031
1215
|
: verb === 'unwaive'
|
|
1032
1216
|
? `Undo agent waivers for finding ${what.finding} in ${what.file} of block ${id} (run ${runId})? Only the owner may say yes.`
|
|
1033
1217
|
: verb === 'ack-waivers'
|
|
1034
1218
|
? `Acknowledge the listed agent waivers of block ${id} (run ${runId})? Only the owner may say yes.`
|
|
1035
1219
|
: 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.`
|
|
1220
|
+
? `Accept false transcript ${what.hits ? `hits ${what.hits.map((hit) => `${hit.rule} at line ${hit.line}`).join(', ')}` : `hit ${what.rule} at line ${what.line}`} in ${what.file} of block ${id} (run ${runId})? Only the owner may say yes.`
|
|
1037
1221
|
: `Reopen the review of ${what.file} in block ${id} (run ${runId})? Only the owner may say yes.`;
|
|
1038
1222
|
return confirmAtTerminal(deps, message, async () => {
|
|
1223
|
+
if (verb === 'human-pass') {
|
|
1224
|
+
if (record.blocks?.[id]?.status !== 'awaiting_human_check') throw new StateError('not-awaiting', `block ${id} is not awaiting a human check`);
|
|
1225
|
+
if ((await checkExpiry(runId, (deps.now ?? (() => new Date()))())).state === 'active') throw new StateError('autopilot-active', `block human-pass is refused while an autopilot grant is active on run ${runId}`);
|
|
1226
|
+
}
|
|
1039
1227
|
if (verb === 'reopen') await reopenPrecheck(record, runId, id, what.file);
|
|
1040
1228
|
else if (verb === 'waive') await waivePrecheck(record, runId, id, /** @type {string} */ (what.finding), what.file);
|
|
1041
1229
|
before?.();
|
|
@@ -1148,10 +1336,11 @@ async function openConfig(workspace) {
|
|
|
1148
1336
|
* The block's coder transcript: `--transcript`, else the run record's `blocks.<id>.transcript`
|
|
1149
1337
|
* (workspace-relative), else `.code-forge/runs/<run>/<id>.log`; null when none exists.
|
|
1150
1338
|
* @param {Record<string, any>} record @param {string} id @param {string | null} given
|
|
1339
|
+
* @param {(file: string) => void} [onFile]
|
|
1151
1340
|
* @returns {Promise<string | null>}
|
|
1152
1341
|
*/
|
|
1153
|
-
async function findTranscript(record, id, given) {
|
|
1154
|
-
if (given !== null)
|
|
1342
|
+
async function findTranscript(record, id, given, onFile) {
|
|
1343
|
+
if (given !== null) { const text = await readFile(given, 'utf8'); onFile?.(given); return text; }
|
|
1155
1344
|
const recorded = record.blocks?.[id]?.transcript;
|
|
1156
1345
|
const candidates = [
|
|
1157
1346
|
...(typeof recorded === 'string' && recorded.length > 0 ? [path.resolve(record.workspace, recorded)] : []),
|
|
@@ -1159,7 +1348,9 @@ async function findTranscript(record, id, given) {
|
|
|
1159
1348
|
];
|
|
1160
1349
|
for (const file of candidates) {
|
|
1161
1350
|
try {
|
|
1162
|
-
|
|
1351
|
+
const text = await readFile(file, 'utf8');
|
|
1352
|
+
onFile?.(file);
|
|
1353
|
+
return text;
|
|
1163
1354
|
} catch (thrown) {
|
|
1164
1355
|
if (/** @type {any} */ (thrown)?.code !== 'ENOENT') throw thrown;
|
|
1165
1356
|
}
|
package/src/cli/gates.mjs
CHANGED
|
@@ -39,11 +39,12 @@ import { writeSafe, redactJSON } from '../util/redact.mjs';
|
|
|
39
39
|
import { appendRow } from '../ledger/write.mjs';
|
|
40
40
|
import { loadProjectConfig } from '../config/load.mjs';
|
|
41
41
|
import { detectGates } from '../gates/detect.mjs';
|
|
42
|
-
import { printableGates, scopeFields, thresholdOf } from '../gates/expand.mjs';
|
|
42
|
+
import { printableGates, scopeFields, testScope, thresholdOf } from '../gates/expand.mjs';
|
|
43
43
|
import { runGates } from '../gates/run.mjs';
|
|
44
44
|
import { runGatesGuarded } from '../proof/lock.mjs';
|
|
45
45
|
import { blockTree } from '../proof/worktree.mjs';
|
|
46
46
|
import { readRun } from '../state/run.mjs';
|
|
47
|
+
import { loadKey, signRow } from '../state/signer.mjs';
|
|
47
48
|
import { StateError } from '../state/paths.mjs';
|
|
48
49
|
import { scanFiles } from '../gates/secret-scan.mjs';
|
|
49
50
|
import { checkSafeEdit } from '../gates/safe-edit.mjs';
|
|
@@ -279,7 +280,29 @@ export async function runGatesVerb(args, deps = {}) {
|
|
|
279
280
|
let ran;
|
|
280
281
|
if (runFlag.value !== undefined && blockFlag.value !== undefined) {
|
|
281
282
|
const threshold = thresholdOf(configured?.full_suite_threshold_files);
|
|
282
|
-
|
|
283
|
+
/** @type {typeof gates & {repeatNewTests?: import('../gates/run.mjs').RepeatNewTests, lightTestFiles?: string[]}} */
|
|
284
|
+
let planned = gates;
|
|
285
|
+
if (configured?.repeat_new_tests !== undefined) {
|
|
286
|
+
const record = await readRun(runFlag.value);
|
|
287
|
+
const block = record.blocks?.[blockFlag.value];
|
|
288
|
+
if (block) planned = { ...gates,
|
|
289
|
+
repeatNewTests: { n: configured.repeat_new_tests, base: block.worktree?.base_sha ?? block.base_sha, owned: block.owned_files, kinds: block.owned_kinds, onSkip: (line) => err(`${line}\n`) },
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
// Missing records are diagnosed by the guarded runner, after its lock decision.
|
|
293
|
+
const block = await readRun(runFlag.value).then((record) => record.blocks?.[blockFlag.value]).catch((error) => {
|
|
294
|
+
if (error.code === 'no-run') return null;
|
|
295
|
+
throw error;
|
|
296
|
+
});
|
|
297
|
+
const light = block?.kind === 'light';
|
|
298
|
+
if (light) {
|
|
299
|
+
const scope = testScope({ testFiles: block.test_files, owned: block.owned_files, threshold: Infinity });
|
|
300
|
+
const command = planned.test;
|
|
301
|
+
const argv = Array.isArray(command) ? command : command?.argv;
|
|
302
|
+
if (scope.scope !== 'related' || !argv?.includes('{test_files}')) throw new StateError('light-tests', 'light-tests: block tests and a gates.test {test_files} item are required');
|
|
303
|
+
planned = { ...planned, lightTestFiles: scope.files };
|
|
304
|
+
}
|
|
305
|
+
const guarded = await runGatesGuarded({ runId: runFlag.value, blockId: blockFlag.value, gates: planned, cwd, threshold, changed: light ? false : changed, slug, ...(timeoutMs ? { timeoutMs } : {}) });
|
|
283
306
|
if (guarded.result === 'proof.busy') {
|
|
284
307
|
out({ result: 'proof.busy', holder: /** @type {{holder: string}} */ (guarded).holder });
|
|
285
308
|
return 1;
|
|
@@ -289,24 +312,31 @@ export async function runGatesVerb(args, deps = {}) {
|
|
|
289
312
|
ran = await runGates(gates, { cwd, ...(timeoutMs ? { timeoutMs } : {}) });
|
|
290
313
|
}
|
|
291
314
|
const { results, allOk } = ran;
|
|
315
|
+
const repeats = results.find((r) => r.repeats)?.repeats;
|
|
292
316
|
|
|
293
317
|
const ledgerSlug = slug ?? ran.ledger_slug;
|
|
294
318
|
if (ledgerSlug) {
|
|
319
|
+
// Preserve the existing rows when off; repeat evidence is covered by the run signature.
|
|
320
|
+
const writeGateRow = async (/** @type {Record<string, any>} */ row) => repeats && runFlag.value !== undefined
|
|
321
|
+
? appendRow(signRow({ run: runFlag.value, ...row }, await loadKey(runFlag.value)), { slug: ledgerSlug })
|
|
322
|
+
: appendRow(row, { slug: ledgerSlug });
|
|
295
323
|
// B60: with --run/--block the rows name the run and the block, so the run's binnacle reads them
|
|
296
324
|
const owner = runFlag.value !== undefined ? { run: runFlag.value, block: blockFlag.value, ...inTree } : {};
|
|
297
325
|
for (const result of results) {
|
|
298
326
|
if (!result.skipped && !result.ok) {
|
|
299
|
-
await
|
|
327
|
+
await writeGateRow({ ...owner, event: 'gate.red', gate: result.gate, code: result.code, ...scopeOf(result),
|
|
328
|
+
...(result.reason === 'flaky-test' ? { reason: 'flaky-test' } : {}),
|
|
329
|
+
...(result.repeats ? { repeats: result.repeats } : {}) });
|
|
300
330
|
}
|
|
301
331
|
}
|
|
302
332
|
// B60: one scope per run (every `{test_files}` gate shares it), named on gate.done too;
|
|
303
333
|
// without --run/--block that is `scope: full, reason: no_tests`
|
|
304
334
|
const scoped = results.find((r) => r.scope !== undefined);
|
|
305
|
-
await
|
|
335
|
+
await writeGateRow({ ...owner, event: 'gate.done', stack: gates.stack, all_ok: allOk,
|
|
306
336
|
...(ran.changed_scope ? scopeFields(ran.changed_scope) : scoped ? scopeOf(scoped) : {}),
|
|
307
|
-
...(ran.files ? { files: ran.files } : {})
|
|
337
|
+
...(ran.files ? { files: ran.files } : {}), ...(repeats ? { repeats } : {}) });
|
|
308
338
|
}
|
|
309
|
-
out({ stack: gates.stack, ...inTree, results, allOk });
|
|
339
|
+
out({ stack: gates.stack, ...inTree, results, allOk, ...(repeats ? { repeats } : {}) });
|
|
310
340
|
return allOk ? 0 : 1;
|
|
311
341
|
}
|
|
312
342
|
|