@codedology/code-forge 0.14.1 → 0.15.1
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 +29 -0
- package/README.md +1 -1
- package/docs/reference/blocks.json +495 -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 +4 -0
- package/skill/references/autopilot.md +2 -0
- package/skill/references/code.md +28 -2
- package/skill/references/review.md +10 -2
- package/skill/references/security.md +2 -0
- package/skill/scripts/forge +1 -1
- package/skill/templates/coder-brief.md +3 -1
- package/src/autopilot/scopes.mjs +2 -1
- package/src/cli/block.mjs +271 -50
- 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 +33 -7
- package/src/review/lenses/recheck.md +3 -0
- package/src/review/packet.mjs +26 -0
- package/src/state/block.mjs +196 -22
- package/src/state/run.mjs +35 -8
- package/src/util/forbidden.mjs +230 -6
- package/src/util/reaper.mjs +3 -3
- package/src/worker/engine.mjs +74 -6
- package/src/worker/loop.mjs +19 -10
- package/src/worker/queue.mjs +38 -8
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 { amendAcceptanceBlock, attemptBlock, claimPath, closeBlock, humanPassBlock, openBlock, prepareAcceptanceAmendment, 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';
|
|
@@ -217,7 +219,7 @@ import { validateConfig } from '../config/validate.mjs';
|
|
|
217
219
|
import { migrateConfig } from '../config/migrate.mjs';
|
|
218
220
|
import { exec } from '../util/exec.mjs';
|
|
219
221
|
import { contentHash, gitChildEnv, readRegularFileNoFollow } from '../worker/ticket.mjs';
|
|
220
|
-
import { clearDone, resolveTicket } from '../worker/queue.mjs';
|
|
222
|
+
import { clearDone, resetAcceptanceWatch, resolveTicket } from '../worker/queue.mjs';
|
|
221
223
|
import { loadState } from '../worker/review-state.mjs';
|
|
222
224
|
import { agentReopens, openBlockEntry, reopenFile } from '../review/reopen.mjs';
|
|
223
225
|
import { checkFileForm, checkFindingId } from '../review/verb-forms.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,32 @@ 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' +
|
|
303
|
+
' code-forge block amend-acceptance <id> --run <r> --acceptance <file> --reason <text>\n' +
|
|
300
304
|
' every block verb takes --json: one JSON line on stdout ({"block","event",…}) instead of the text\n';
|
|
301
305
|
|
|
302
|
-
const SUBCOMMANDS = ['open', 'attempt', 'rebase', 'claim', 'close', 'stop', 'waive', 'reopen', 'accept-hit', 'unwaive', 'ack-waivers'];
|
|
306
|
+
const SUBCOMMANDS = ['open', 'attempt', 'rebase', 'claim', 'close', 'stop', 'waive', 'reopen', 'accept-hit', 'unwaive', 'ack-waivers', 'human-pass', 'amend-acceptance'];
|
|
303
307
|
|
|
304
308
|
/** Refuse likely shell quoting slips before any row is written.
|
|
305
309
|
* @param {string} workspace @param {string[]} entries @param {string} label
|
|
306
310
|
*/
|
|
307
311
|
function checkWhitespacePaths(workspace, entries, label) {
|
|
308
312
|
for (const entry of entries) {
|
|
309
|
-
|
|
313
|
+
let depth = 0;
|
|
314
|
+
let separator = /\s/u.test(entry);
|
|
315
|
+
for (const char of entry) {
|
|
316
|
+
if (char === '{') depth += 1;
|
|
317
|
+
else if (char === '}') depth = Math.max(0, depth - 1);
|
|
318
|
+
else if (char === ',' && depth === 0) separator = true;
|
|
319
|
+
}
|
|
320
|
+
if (!separator) continue;
|
|
310
321
|
let stat;
|
|
311
322
|
try {
|
|
312
323
|
stat = statSync(path.join(workspace, entry));
|
|
@@ -314,11 +325,11 @@ function checkWhitespacePaths(workspace, entries, label) {
|
|
|
314
325
|
// A missing or unreadable path cannot establish a literal whitespace-containing name.
|
|
315
326
|
}
|
|
316
327
|
if (!stat?.isFile() && !stat?.isDirectory()) {
|
|
317
|
-
throw new StateError(
|
|
328
|
+
throw new StateError('usage', `${label} entry ${JSON.stringify(entry)} matches no file; use spaces, not commas`);
|
|
318
329
|
}
|
|
319
330
|
}
|
|
320
331
|
}
|
|
321
|
-
const FLAG_VALUES = ['run', 'level', 'acceptance', 'brief', 'attempt', 'base', 'lines', 'head', 'worker-pid', 'reason', 'file', 'transcript', 'kind', 'report', 'rule', 'line'];
|
|
332
|
+
const FLAG_VALUES = ['run', 'level', 'acceptance', 'brief', 'attempt', 'base', 'lines', 'head', 'worker-pid', 'reason', 'file', 'transcript', 'kind', 'report', 'rule', 'line', 'note', 'proof'];
|
|
322
333
|
|
|
323
334
|
/**
|
|
324
335
|
* @param {string[]} args
|
|
@@ -349,7 +360,7 @@ export async function runBlock(args, deps = {}) {
|
|
|
349
360
|
return 2;
|
|
350
361
|
}
|
|
351
362
|
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'] : [])] });
|
|
363
|
+
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
364
|
// B64a: --json prints ONE JSON line on success instead of the text lines (`emit`, one object
|
|
354
365
|
// per verb, built where the text is); without it every output is exactly as before (`say` is `out`)
|
|
355
366
|
const json = flags.json === true;
|
|
@@ -366,6 +377,9 @@ export async function runBlock(args, deps = {}) {
|
|
|
366
377
|
const [id, extra] = positionals;
|
|
367
378
|
const runId = typeof flags.run === 'string' ? flags.run : undefined;
|
|
368
379
|
if (!id || !runId) throw new StateError('usage', 'a block id and --run are required');
|
|
380
|
+
if (sub === 'amend-acceptance' && (positionals.length !== 1 || typeof flags.acceptance !== 'string' || typeof flags.reason !== 'string' || !flags.reason.trim() || /\p{Cc}/u.test(flags.reason))) {
|
|
381
|
+
throw new StateError('usage', 'amend-acceptance needs one block id, --acceptance and a nonblank --reason without control characters');
|
|
382
|
+
}
|
|
369
383
|
if (sub === 'open' && (typeof flags.acceptance !== 'string' || !Array.isArray(flags.owned) || (flags.level !== undefined && typeof flags.level !== 'string'))) {
|
|
370
384
|
throw new StateError('usage', 'block open needs --level, --owned and --acceptance');
|
|
371
385
|
}
|
|
@@ -377,16 +391,60 @@ export async function runBlock(args, deps = {}) {
|
|
|
377
391
|
if (sub === 'reopen' && typeof flags.file !== 'string') throw new StateError('usage', 'block reopen needs --file <path>');
|
|
378
392
|
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
393
|
if (sub === 'accept-hit') {
|
|
380
|
-
|
|
381
|
-
const
|
|
394
|
+
// prepareAcceptHit (review/accept-hit.mjs) returns every accepted hit as rows (all hits on --line, or only the --rule hit).
|
|
395
|
+
const { record, rows } = await prepareAcceptHit({ runId, id, flags, positionals, extraTokens });
|
|
396
|
+
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
397
|
if (asked === 'no-tty') { err(`${humanVerbNoTTY(sub)}\n`); return 2; }
|
|
383
398
|
if (asked === 'cancelled') { err(`block ${id} ${sub}: cancelled — nothing was recorded\n`); return 1; }
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
399
|
+
for (const row of rows) {
|
|
400
|
+
await writeAcceptedHit({ runId, row, writeRow: (signed) => appendRow(signed, { slug: record.project }) });
|
|
401
|
+
say(`block ${id} accepted ${row.rule} transcript line ${row.line} (by: human)\n`);
|
|
402
|
+
}
|
|
403
|
+
const accepted = rows.map(({ rule, line, reason, by }) => ({ rule, line, reason, by }));
|
|
404
|
+
if (json) emit({ block: id, event: 'rule_break.accepted', ...(flags.rule === undefined ? { accepted_hits: accepted } : accepted[0]) });
|
|
387
405
|
return 0;
|
|
388
406
|
}
|
|
389
407
|
const record = await readRun(runId);
|
|
408
|
+
if (sub === 'amend-acceptance') {
|
|
409
|
+
/** @type {ReturnType<typeof prepareAcceptanceAmendment> | null} */
|
|
410
|
+
let prepared = null;
|
|
411
|
+
const what = { file: '', oldCount: 0, newCount: 0 };
|
|
412
|
+
const noGrant = (/** @type {Record<string, any>} */ current) => {
|
|
413
|
+
if (current.autopilot && grantState(current.autopilot, (deps.now ?? (() => new Date()))()) === 'active') throw new StateError('autopilot-active', 'amend-acceptance is refused while an autopilot grant is active');
|
|
414
|
+
};
|
|
415
|
+
const asked = await confirmHumanVerb(sub, runId, id, what, record, deps, async () => {
|
|
416
|
+
noGrant(record);
|
|
417
|
+
const clauses = parseYAML(await readFile(/** @type {string} */ (flags.acceptance), 'utf8'), { prettyErrors: false });
|
|
418
|
+
prepared = prepareAcceptanceAmendment(record, id, clauses);
|
|
419
|
+
what.oldCount = prepared.oldCount;
|
|
420
|
+
what.newCount = prepared.acceptance.length;
|
|
421
|
+
});
|
|
422
|
+
if (asked === 'no-tty') { err(`${humanVerbNoTTY(sub)}\n`); return 2; }
|
|
423
|
+
if (asked === 'cancelled') { err(`block ${id} ${sub}: cancelled — nothing was recorded\n`); return 1; }
|
|
424
|
+
if (prepared === null) throw new Error('acceptance was not prepared');
|
|
425
|
+
const accepted = /** @type {ReturnType<typeof prepareAcceptanceAmendment>} */ (prepared);
|
|
426
|
+
const row = await amendAcceptanceBlock({ runId, id, acceptance: accepted.acceptance, old_sha256: accepted.old_sha256, reason: /** @type {string} */ (flags.reason), writeRow: (signed) => appendRow(signed, { slug: record.project }), before: noGrant });
|
|
427
|
+
resetAcceptanceWatch(await repoTop(record.workspace), id);
|
|
428
|
+
say(`block ${id} acceptance amended (${row.clauses} clauses; by: human)\n`);
|
|
429
|
+
if (json) emit(row);
|
|
430
|
+
return 0;
|
|
431
|
+
}
|
|
432
|
+
const deciding = sub === 'close' && flags.decide === true;
|
|
433
|
+
if (deciding) {
|
|
434
|
+
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');
|
|
435
|
+
if (flags['no-require-reviews']) throw new StateError('usage', '--decide cannot skip reviews');
|
|
436
|
+
if (!(deps.stdinIsTTY ?? deps.isTTY ?? process.stdin.isTTY === true) || !(deps.stdoutIsTTY ?? deps.isTTY ?? process.stdout.isTTY === true)) {
|
|
437
|
+
err(`${humanVerbNoTTY('close')}\n`); return 2;
|
|
438
|
+
}
|
|
439
|
+
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');
|
|
440
|
+
}
|
|
441
|
+
if (sub === 'human-pass') {
|
|
442
|
+
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>');
|
|
443
|
+
if (typeof flags.proof === 'string' && !existsSync(flags.proof)) throw new StateError('usage', '--proof must name an existing directory or file');
|
|
444
|
+
const asked = await confirmHumanVerb(sub, runId, id, { file: '' }, record, deps);
|
|
445
|
+
if (asked === 'no-tty') { err(`${humanVerbNoTTY(sub)}\n`); return 2; }
|
|
446
|
+
if (asked === 'cancelled') { err(`block ${id} ${sub}: cancelled — nothing was recorded\n`); return 1; }
|
|
447
|
+
}
|
|
390
448
|
if (sub === 'unwaive' || sub === 'ack-waivers') {
|
|
391
449
|
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
450
|
const prepared = sub === 'unwaive'
|
|
@@ -403,8 +461,9 @@ export async function runBlock(args, deps = {}) {
|
|
|
403
461
|
return 0;
|
|
404
462
|
}
|
|
405
463
|
if (sub === 'open') {
|
|
406
|
-
|
|
407
|
-
|
|
464
|
+
const base = record.blocks?.[id]?.worktree?.path ?? record.workspace;
|
|
465
|
+
checkWhitespacePaths(base, /** @type {string[]} */ (flags.owned), 'owned');
|
|
466
|
+
if (Array.isArray(flags.tests)) checkWhitespacePaths(base, flags.tests, 'tests');
|
|
408
467
|
}
|
|
409
468
|
// B80: waive and reopen are the human's — asked at a terminal before anything is written (not
|
|
410
469
|
// even the autopilot expiry rows below); no terminal is exit 2, a "no" is exit 1
|
|
@@ -471,10 +530,22 @@ export async function runBlock(args, deps = {}) {
|
|
|
471
530
|
},
|
|
472
531
|
};
|
|
473
532
|
};
|
|
533
|
+
if (sub === 'human-pass') {
|
|
534
|
+
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()))() });
|
|
535
|
+
const end = await ended('closed', 'block.human_pass', null);
|
|
536
|
+
try {
|
|
537
|
+
say(`block ${id} closed (human check passed)\n`);
|
|
538
|
+
if (json) emit({ ...row, status: 'closed', ...end.json });
|
|
539
|
+
return 0;
|
|
540
|
+
} finally { await end.runHook(); }
|
|
541
|
+
}
|
|
474
542
|
// B48: every block command runs the lazy autopilot checks (grant expiry, expired approvals);
|
|
475
543
|
// a failed check is a warning, never a reason to stop the block command
|
|
544
|
+
const requireHumanClose = sub === 'close' && configInForce(record)?.close?.require_human === true;
|
|
476
545
|
try {
|
|
477
|
-
|
|
546
|
+
if (!deciding && !requireHumanClose) {
|
|
547
|
+
err(approvalCheckText(await expiryChecks(runId, (deps.now ?? (() => new Date()))(), { writeRow })));
|
|
548
|
+
}
|
|
478
549
|
} catch (thrown) {
|
|
479
550
|
err(`autopilot: WARN the expiry check failed (${thrown?.message ?? String(thrown)})\n`);
|
|
480
551
|
}
|
|
@@ -608,9 +679,22 @@ export async function runBlock(args, deps = {}) {
|
|
|
608
679
|
throw thrown;
|
|
609
680
|
});
|
|
610
681
|
// ONE base for the whole close: a worktree block is measured against the commit its tree was
|
|
611
|
-
// made from (
|
|
682
|
+
// made from (kept equal to base_sha, including after a closed worktree rebase)
|
|
612
683
|
const base = wt !== null ? wt.base_sha : entry.base_sha;
|
|
613
684
|
const reviewsWaived = flags['no-require-reviews'] === true;
|
|
685
|
+
let routineGrant = null;
|
|
686
|
+
if (!deciding && !reviewsWaived && requireHumanClose) {
|
|
687
|
+
const active = record.autopilot && grantState(record.autopilot, (deps.now ?? (() => new Date()))()) === 'active';
|
|
688
|
+
if (active) {
|
|
689
|
+
const allowed = await grantFor(runId, 'close:routine', (deps.now ?? (() => new Date()))());
|
|
690
|
+
if (!allowed.ok) throw new StateError('close-not-routine', `close-not-routine: ${allowed.reason}`);
|
|
691
|
+
routineGrant = allowed.grant;
|
|
692
|
+
} else {
|
|
693
|
+
const asked = await confirmAtTerminal(deps, `Close block ${id} of run ${runId}? Only the owner may say yes.`);
|
|
694
|
+
if (asked === 'no-tty') { err(`${humanVerbNoTTY('close')}\n`); return 2; }
|
|
695
|
+
if (asked === 'cancelled') { err(`block ${id} close: cancelled — nothing was recorded\n`); return 1; }
|
|
696
|
+
}
|
|
697
|
+
}
|
|
614
698
|
// B52: the human's way out is confirmed by the human, at a terminal, before anything is written
|
|
615
699
|
if (reviewsWaived) {
|
|
616
700
|
const asked = await confirmNoReviews(runId, id, deps);
|
|
@@ -673,14 +757,16 @@ export async function runBlock(args, deps = {}) {
|
|
|
673
757
|
const others = closeRows.filter((r) => r.event !== 'gate.review_waived_all');
|
|
674
758
|
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
759
|
};
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
760
|
+
let dryRun = deciding || routineGrant !== null;
|
|
761
|
+
let transcriptFile = '';
|
|
762
|
+
const loadGate = async () => {
|
|
763
|
+
const transcript = await findTranscript(record, id, typeof flags.transcript === 'string' ? flags.transcript : null, (file) => { transcriptFile = file; });
|
|
764
|
+
if (transcript === null && !dryRun) {
|
|
679
765
|
await writeSigned(runId, writeRow, { event: 'gate.transcript_missing', block: id });
|
|
680
766
|
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
767
|
}
|
|
682
768
|
// 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' });
|
|
769
|
+
if (reviewsWaived && !dryRun) await writeSigned(runId, writeRow, { event: 'gate.reviews_waived', block: id, by: 'human' });
|
|
684
770
|
// B20: the high paths come first — a base the workspace does not know refuses the close here
|
|
685
771
|
const highPaths = await highPathsAt(record.workspace, base);
|
|
686
772
|
const { top, mainTop, where, inTree, wsPrefix, owns, changed, fileSet, reviewed } = await tree();
|
|
@@ -723,7 +809,8 @@ export async function runBlock(args, deps = {}) {
|
|
|
723
809
|
proofFiles: fileSet,
|
|
724
810
|
highPaths,
|
|
725
811
|
};
|
|
726
|
-
}
|
|
812
|
+
};
|
|
813
|
+
const gate = reviewGateCheck(loadGate, { onWaivedAll, onAcceptedHits: (hits) => { acceptedHits = hits; } });
|
|
727
814
|
// B96: `reviewsPassed` comes from the gate's own result, not from the onWaivedAll call. Each
|
|
728
815
|
// extra check runs at most once per close (`closeBlock`'s one loop, src/state/block.mjs:562,
|
|
729
816
|
// and `closeBlock` is called once below), so the gate's one pass is its final one.
|
|
@@ -799,6 +886,35 @@ export async function runBlock(args, deps = {}) {
|
|
|
799
886
|
};
|
|
800
887
|
/** @type {Array<() => Promise<{ok: boolean, reason?: string}>>} */
|
|
801
888
|
const extraChecks = [reviews, report];
|
|
889
|
+
if (entry.kind === 'light') extraChecks.unshift(async () => {
|
|
890
|
+
const { top, changed, owns } = await tree();
|
|
891
|
+
const files = changed.filter((file) => owns(entry.owned_files, file, entry.owned_kinds));
|
|
892
|
+
let lines = 0;
|
|
893
|
+
for (const file of files) {
|
|
894
|
+
const diff = await readFileDiff({ repoRoot: top, file, base });
|
|
895
|
+
lines += diff.plusCount + diff.minusCount;
|
|
896
|
+
}
|
|
897
|
+
const cfg = configInForce(record);
|
|
898
|
+
return files.length > (cfg?.light?.max_files ?? 5) || lines > (cfg?.light?.max_lines ?? 300)
|
|
899
|
+
? { ok: false, reason: `light-limits ${files.length} files, ${lines} lines` } : { ok: true };
|
|
900
|
+
});
|
|
901
|
+
if (configInForce(record)?.gates?.test != null) {
|
|
902
|
+
const testGate = async () => {
|
|
903
|
+
const green = rows.filter((row) => row.block === id && row.event === 'gate.done' && row.all_ok === true);
|
|
904
|
+
// Only commits after the block base that touch its owned paths belong to this block.
|
|
905
|
+
// Older rows without timestamps remain usable, but a failed history lookup cannot prove freshness.
|
|
906
|
+
const commit = green.length === 0 ? null : await exec(['git', 'log', '-1', '--format=%ct', `${base}..HEAD`, '--', ...entry.owned_files], {
|
|
907
|
+
cwd: wt?.workspace ?? record.workspace, env: gitChildEnv(), timeoutMs: 10000,
|
|
908
|
+
});
|
|
909
|
+
const committed = commit?.result === 'ok' ? Number.parseInt(commit.stdout.trim(), 10) * 1000 : Number.NaN;
|
|
910
|
+
const ok = commit?.result === 'ok' && (commit.stdout.trim() === '' || Number.isFinite(committed)) &&
|
|
911
|
+
green.some((row) => !Number.isFinite(committed) || !Number.isFinite(Date.parse(row.ts)) || Date.parse(row.ts) > committed);
|
|
912
|
+
return ok ? { ok: true } : { ok: false, reason: `no green gates run; run: code-forge gates run --run ${runId} --block ${id}` };
|
|
913
|
+
};
|
|
914
|
+
// Keep human-bypass diagnostics ahead of the test gate; waiving reviews still needs green tests.
|
|
915
|
+
if (reviewsWaived) extraChecks.push(testGate);
|
|
916
|
+
else extraChecks.unshift(testGate);
|
|
917
|
+
}
|
|
802
918
|
extraChecks.unshift(async () => agentWaiversAcked(record, rows, id, await loadKey(runId))
|
|
803
919
|
? { ok: true } : { ok: false, reason: 'agent-waivers-not-acked' });
|
|
804
920
|
// B63b: `context.require_coder_tests` in the run's config snapshot (never the live config)
|
|
@@ -824,7 +940,72 @@ export async function runBlock(args, deps = {}) {
|
|
|
824
940
|
return hit ? { ok: false, reason: `rule_break transcript line ${hit.line}: ${hit.id}` } : { ok: true };
|
|
825
941
|
});
|
|
826
942
|
}
|
|
827
|
-
const
|
|
943
|
+
const closeOptions = { runId, id, rows, writeRow, livePid: intFlag(flags['worker-pid'], 'worker-pid'), probe, extraChecks, closeRows };
|
|
944
|
+
if (deciding) {
|
|
945
|
+
const data = await loadGate();
|
|
946
|
+
const initial = checkBlockReviews(data);
|
|
947
|
+
const descriptions = await resultFilesFor({ repoRoot: (await tree()).mainTop, runId, block: id, rows, includeCurrent: true });
|
|
948
|
+
const claimOf = (/** @type {string} */ file, /** @type {string} */ finding) => rows
|
|
949
|
+
.filter((r) => r.block === id && r.file === file && verifyRow(r, data.key).ok).flatMap((r) => {
|
|
950
|
+
const full = descriptions[r.ticket]?.findings;
|
|
951
|
+
return [...(r.finding === finding ? [r] : []), ...(Array.isArray(r.open) ? r.open : []), ...(Array.isArray(full) ? full : [])]
|
|
952
|
+
.filter((f) => (f?.id === finding || f?.finding === finding) && typeof f.claim === 'string');
|
|
953
|
+
}).at(-1)?.claim;
|
|
954
|
+
/** @type {Array<{label: string, write: (sink: (row: Record<string, any>) => Promise<unknown>) => Promise<unknown>}>} */
|
|
955
|
+
const actions = [];
|
|
956
|
+
const seen = new Set();
|
|
957
|
+
for (const refusal of initial.refusals) {
|
|
958
|
+
if (!['not_approved', 'late_unruled', 'review_cap', 'autopilot_waiver_invalid'].includes(refusal.code) || !refusal.file || !refusal.finding || refusal.finding === PROOF_FINDING) continue;
|
|
959
|
+
const { file, finding } = refusal;
|
|
960
|
+
const pair = `${file}\0${finding}`;
|
|
961
|
+
if (seen.has(pair)) continue;
|
|
962
|
+
seen.add(pair);
|
|
963
|
+
await waivePrecheck(record, runId, id, finding, file);
|
|
964
|
+
const severity = recordedSeverity(rows, { block: id, file, finding, key: data.key }).severity ?? 'unknown';
|
|
965
|
+
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 }) });
|
|
966
|
+
}
|
|
967
|
+
if (!agentWaiversAcked(record, rows, id, data.key)) {
|
|
968
|
+
const ack = await prepareAckWaivers(record, runId, id);
|
|
969
|
+
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) });
|
|
970
|
+
}
|
|
971
|
+
const hits = acceptedTranscriptHits(data).remaining;
|
|
972
|
+
for (const hit of hits) {
|
|
973
|
+
const prepared = await prepareAcceptHit({ runId, id, flags: { rule: hit.id, line: String(hit.line), transcript: transcriptFile, reason: flags.reason }, positionals: [id], extraTokens });
|
|
974
|
+
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 }) });
|
|
975
|
+
}
|
|
976
|
+
const count = rows.length;
|
|
977
|
+
const buffered = async (/** @type {Record<string, any>} */ row) => { rows.push(row); };
|
|
978
|
+
for (const action of actions) await action.write(buffered);
|
|
979
|
+
const preview = await closeBlock({ ...closeOptions, dryRun: true });
|
|
980
|
+
rows.splice(count);
|
|
981
|
+
// Reset anything buffered by report, proof and review checks before the real pass.
|
|
982
|
+
closeRows.splice(0, closeRows.length, ...(external === null ? [] : [{ event: 'gate.reviews_waived', block: id, by: 'config', reviewer: external }]));
|
|
983
|
+
linesOnce = undefined;
|
|
984
|
+
actions.forEach((action, index) => (json ? err : say)(`${index + 1}. ${action.label}\n`));
|
|
985
|
+
if (!preview.ok) { err(`cannot decide here: ${preview.reason}\n`); return 1; }
|
|
986
|
+
const asked = await confirmAtTerminal(deps, `Apply these decisions and close block ${id} of run ${runId}? Only the owner may say yes.`);
|
|
987
|
+
if (asked === 'no-tty') { err(`${humanVerbNoTTY('close')}\n`); return 2; }
|
|
988
|
+
if (asked === 'cancelled') { err(`block ${id} close: cancelled — nothing was recorded\n`); return 1; }
|
|
989
|
+
for (const action of actions) await action.write(writeRow);
|
|
990
|
+
rows.splice(0, rows.length, ...(await readAllRows(record.project)).filter((row) => row.run === runId));
|
|
991
|
+
}
|
|
992
|
+
if (routineGrant !== null) {
|
|
993
|
+
const data = await loadGate();
|
|
994
|
+
const problem = external !== null ? 'external reviews' : routineCloseProblem(data);
|
|
995
|
+
if (problem !== null) { err(`close-not-routine: ${problem}\n`); return 1; }
|
|
996
|
+
const preview = await closeBlock({ ...closeOptions, dryRun: true });
|
|
997
|
+
if (!preview.ok) { err(`close-not-routine: ${preview.reason}\n`); return 1; }
|
|
998
|
+
closeRows.splice(0, closeRows.length, { event: 'autopilot.close', block: id, grant: routineGrant.grant_id });
|
|
999
|
+
linesOnce = undefined;
|
|
1000
|
+
}
|
|
1001
|
+
dryRun = false;
|
|
1002
|
+
// The confirmation can take arbitrarily long: measure current bytes again at real close.
|
|
1003
|
+
if (deciding || routineGrant !== null) {
|
|
1004
|
+
treeOnce = undefined;
|
|
1005
|
+
linesOnce = undefined;
|
|
1006
|
+
lineWarnings.length = 0;
|
|
1007
|
+
}
|
|
1008
|
+
const result = await closeBlock(closeOptions);
|
|
828
1009
|
const agentRows = agentWaivers(rows, id, await loadKey(runId));
|
|
829
1010
|
const reopenedRows = agentReopens(rows.filter((r) => r.run === runId), id, await loadKey(runId));
|
|
830
1011
|
// B64b: the block ended (closed, or stopped by a worker pin event or ledger.tamper) — its rows
|
|
@@ -840,9 +1021,7 @@ export async function runBlock(args, deps = {}) {
|
|
|
840
1021
|
// B70: each file that passed as reviewed_by_waiver is named before the closed line
|
|
841
1022
|
// B71: an answered finding is named as answered; a file with only answered findings has no `by`
|
|
842
1023
|
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
|
-
}
|
|
1024
|
+
await warnMovedBase(record.workspace, entry, id, runId, err);
|
|
846
1025
|
for (const w of lineWarnings) err(w);
|
|
847
1026
|
for (const l of reviewedJson) say(`reviewed ${l.file} ticket ${l.ticket}\n`);
|
|
848
1027
|
for (const f of waivedAll) {
|
|
@@ -854,7 +1033,10 @@ export async function runBlock(args, deps = {}) {
|
|
|
854
1033
|
for (const r of reopenedRows) say(`agent reopen ${r.file}: ${r.reason} (verdict: ${r.verdict?.verdict})\n`);
|
|
855
1034
|
for (const hit of acceptedHits) say(`accepted rule_break ${hit.rule} transcript line ${hit.line}: ${hit.reason} (by: human)\n`);
|
|
856
1035
|
}
|
|
857
|
-
|
|
1036
|
+
if (result.status === 'awaiting_human_check') {
|
|
1037
|
+
for (const check of entry.human_checks) say(`block ${id} awaiting human check: ${check}\n`);
|
|
1038
|
+
say(`next: code-forge block human-pass ${id} --run ${runId} --note "<device check note>" [--proof <dir-or-file>]\n`);
|
|
1039
|
+
} else (result.ok ? say : err)(`block ${id} ${result.status}${result.event ? ` (${result.event})` : ''}${result.reason ? `: ${result.reason}` : ''}\n`);
|
|
858
1040
|
// B64a: a refused close also names itself on stdout under --json (its stderr is unchanged)
|
|
859
1041
|
if (json && !result.ok) emit({ block: id, ok: false, reason: result.reason ?? null, ...(end?.json ?? {}) });
|
|
860
1042
|
const closedJson = {
|
|
@@ -863,6 +1045,7 @@ export async function runBlock(args, deps = {}) {
|
|
|
863
1045
|
status: result.status,
|
|
864
1046
|
reason: result.reason ?? null,
|
|
865
1047
|
close_event: result.event ?? null,
|
|
1048
|
+
...(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
1049
|
...(end?.json ?? {}),
|
|
867
1050
|
// B70: only when a file passed as reviewed_by_waiver (otherwise the output is unchanged)
|
|
868
1051
|
...(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 +1055,7 @@ export async function runBlock(args, deps = {}) {
|
|
|
872
1055
|
...(reopenedRows.length > 0 ? { agent_reopens: reopenedRows } : {}),
|
|
873
1056
|
...(acceptedHits.length > 0 ? { accepted_hits: acceptedHits } : {}),
|
|
874
1057
|
};
|
|
875
|
-
if (!result.ok || !removeTree) {
|
|
1058
|
+
if (!result.ok || !removeTree || result.status === 'awaiting_human_check') {
|
|
876
1059
|
if (json && result.ok) emit(closedJson);
|
|
877
1060
|
return result.ok ? 0 : 1;
|
|
878
1061
|
}
|
|
@@ -896,6 +1079,32 @@ export async function runBlock(args, deps = {}) {
|
|
|
896
1079
|
return 2;
|
|
897
1080
|
}
|
|
898
1081
|
|
|
1082
|
+
/**
|
|
1083
|
+
* Advisory only, after a successful close: missing refs or failed history reads cannot change
|
|
1084
|
+
* the close's exit code. Resolve the recorded branch, regardless of the workspace's checkout.
|
|
1085
|
+
* @param {string} cwd @param {Record<string, any>} block @param {string} id @param {string} runId
|
|
1086
|
+
* @param {(text: string) => unknown} warn
|
|
1087
|
+
*/
|
|
1088
|
+
async function warnMovedBase(cwd, block, id, runId, warn) {
|
|
1089
|
+
if (!block.base_branch) return;
|
|
1090
|
+
try {
|
|
1091
|
+
const opts = { cwd, env: gitChildEnv(), timeoutMs: 10000 };
|
|
1092
|
+
const head = await exec(['git', 'rev-parse', '--verify', '--end-of-options', `refs/heads/${block.base_branch}^{commit}`], opts);
|
|
1093
|
+
if (head.result !== 'ok' || head.stdout.trim() === block.base_sha) return;
|
|
1094
|
+
const blockHead = await exec(['git', 'rev-parse', '--verify', 'HEAD^{commit}'], { ...opts, cwd: block.worktree?.path ?? cwd });
|
|
1095
|
+
if (blockHead.result !== 'ok') return;
|
|
1096
|
+
// A base advanced by this block's own commits is already incorporated. Only exit 1
|
|
1097
|
+
// means "not an ancestor"; errors and timeouts must not produce an advisory.
|
|
1098
|
+
const ancestor = await exec(['git', 'merge-base', '--is-ancestor', head.stdout.trim(), blockHead.stdout.trim()], { ...opts, okExitCodes: [0, 1] });
|
|
1099
|
+
if (ancestor.result !== 'ok' || ancestor.code !== 1) return;
|
|
1100
|
+
const count = await exec(['git', 'rev-list', '--count', `${block.base_sha}..${head.stdout.trim()}`], opts);
|
|
1101
|
+
if (count.result !== 'ok' || !/^\d+$/.test(count.stdout.trim())) return;
|
|
1102
|
+
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`);
|
|
1103
|
+
} catch {
|
|
1104
|
+
// The close already succeeded; this warning is best effort.
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
|
|
899
1108
|
/**
|
|
900
1109
|
* B62a: `block close --remove-worktree`, after the block closed: `git worktree remove <path>` (no
|
|
901
1110
|
* `--force`, the branch is kept), then a signed `worktree.removed {block, path, branch}` or
|
|
@@ -959,7 +1168,7 @@ export const NO_REVIEWS_NO_TTY = '--no-require-reviews needs the human at a term
|
|
|
959
1168
|
|
|
960
1169
|
/**
|
|
961
1170
|
* 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}
|
|
1171
|
+
* @param {'waive' | 'reopen' | 'accept-hit' | 'unwaive' | 'ack-waivers' | 'human-pass' | 'amend-acceptance' | 'close'} verb @returns {string}
|
|
963
1172
|
*/
|
|
964
1173
|
export const humanVerbNoTTY = (verb) => verb === 'waive'
|
|
965
1174
|
? '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)'
|
|
@@ -979,7 +1188,7 @@ export const humanVerbNoTTY = (verb) => verb === 'waive'
|
|
|
979
1188
|
* terminal check and the question (a refusal it throws means the human is never asked).
|
|
980
1189
|
* `deps.stdinIsTTY` / `deps.stdoutIsTTY` inject each side, `deps.isTTY` both at once, `deps.ui`
|
|
981
1190
|
* the prompt (default `@clack/prompts`). A "no" or a cancel (Ctrl-C) is `cancelled`.
|
|
982
|
-
* @param {ConfirmDeps} deps @param {string} message
|
|
1191
|
+
* @param {ConfirmDeps} deps @param {string | (() => string)} message
|
|
983
1192
|
* @param {() => Promise<unknown> | unknown} [before]
|
|
984
1193
|
* @returns {Promise<'no-tty' | 'confirmed' | 'cancelled'>}
|
|
985
1194
|
*/
|
|
@@ -989,7 +1198,7 @@ async function confirmAtTerminal(deps, message, before) {
|
|
|
989
1198
|
if (!(stdinTTY && stdoutTTY)) return 'no-tty';
|
|
990
1199
|
if (before) await before();
|
|
991
1200
|
const ui = deps.ui ?? /** @type {any} */ (await import('@clack/prompts'));
|
|
992
|
-
const go = await ui.confirm({ message, initialValue: false });
|
|
1201
|
+
const go = await ui.confirm({ message: typeof message === 'function' ? message() : message, initialValue: false });
|
|
993
1202
|
return ui.isCancel(go) || go !== true ? 'cancelled' : 'confirmed';
|
|
994
1203
|
}
|
|
995
1204
|
|
|
@@ -1016,29 +1225,38 @@ async function confirmNoReviews(runId, id, deps) {
|
|
|
1016
1225
|
/**
|
|
1017
1226
|
* B80: `block waive` and `block reopen` are the human's alone (D1 = a): the human confirms at a
|
|
1018
1227
|
* terminal through `confirmAtTerminal`, exactly like `--no-require-reviews`, but an active
|
|
1019
|
-
* autopilot grant changes nothing (autopilot waives through its own path,
|
|
1228
|
+
* autopilot grant changes nothing for waive/reopen (autopilot waives through its own path,
|
|
1229
|
+
* `by: autopilot`); human-pass instead refuses an active grant. The
|
|
1020
1230
|
* pre-checks (`reopenPrecheck`, `waivePrecheck`) run first, at a terminal, so the human is never
|
|
1021
1231
|
* 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
|
|
1024
|
-
* @param {() =>
|
|
1232
|
+
* @param {'waive' | 'reopen' | 'accept-hit' | 'unwaive' | 'ack-waivers' | 'human-pass' | 'amend-acceptance'} verb @param {string} runId @param {string} id
|
|
1233
|
+
* @param {{finding?: string, file: string, rule?: string, line?: number, hits?: {rule: string, line: number}[], oldCount?: number, newCount?: number}} what @param {Record<string, any>} record @param {ConfirmDeps} deps
|
|
1234
|
+
* @param {() => Promise<unknown> | unknown} [before]
|
|
1025
1235
|
* @returns {Promise<'no-tty' | 'confirmed' | 'cancelled'>}
|
|
1026
1236
|
*/
|
|
1027
1237
|
function confirmHumanVerb(verb, runId, id, what, record, deps, before) {
|
|
1028
|
-
const message =
|
|
1029
|
-
verb === '
|
|
1238
|
+
const message = () =>
|
|
1239
|
+
verb === 'amend-acceptance'
|
|
1240
|
+
? `Amend acceptance of block ${id} (run ${runId}) from ${what.oldCount} clauses to ${what.newCount} clauses? Only the owner may say yes.`
|
|
1241
|
+
: verb === 'human-pass'
|
|
1242
|
+
? `Confirm all device checks of block ${id} (run ${runId}) passed? Only the owner may say yes.`
|
|
1243
|
+
: verb === 'waive'
|
|
1030
1244
|
? `Waive finding ${what.finding} in ${what.file} of block ${id} (run ${runId})? Only the owner may say yes.`
|
|
1031
1245
|
: verb === 'unwaive'
|
|
1032
1246
|
? `Undo agent waivers for finding ${what.finding} in ${what.file} of block ${id} (run ${runId})? Only the owner may say yes.`
|
|
1033
1247
|
: verb === 'ack-waivers'
|
|
1034
1248
|
? `Acknowledge the listed agent waivers of block ${id} (run ${runId})? Only the owner may say yes.`
|
|
1035
1249
|
: 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.`
|
|
1250
|
+
? `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
1251
|
: `Reopen the review of ${what.file} in block ${id} (run ${runId})? Only the owner may say yes.`;
|
|
1038
1252
|
return confirmAtTerminal(deps, message, async () => {
|
|
1253
|
+
if (verb === 'human-pass') {
|
|
1254
|
+
if (record.blocks?.[id]?.status !== 'awaiting_human_check') throw new StateError('not-awaiting', `block ${id} is not awaiting a human check`);
|
|
1255
|
+
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}`);
|
|
1256
|
+
}
|
|
1039
1257
|
if (verb === 'reopen') await reopenPrecheck(record, runId, id, what.file);
|
|
1040
1258
|
else if (verb === 'waive') await waivePrecheck(record, runId, id, /** @type {string} */ (what.finding), what.file);
|
|
1041
|
-
before?.();
|
|
1259
|
+
await before?.();
|
|
1042
1260
|
});
|
|
1043
1261
|
}
|
|
1044
1262
|
|
|
@@ -1148,10 +1366,11 @@ async function openConfig(workspace) {
|
|
|
1148
1366
|
* The block's coder transcript: `--transcript`, else the run record's `blocks.<id>.transcript`
|
|
1149
1367
|
* (workspace-relative), else `.code-forge/runs/<run>/<id>.log`; null when none exists.
|
|
1150
1368
|
* @param {Record<string, any>} record @param {string} id @param {string | null} given
|
|
1369
|
+
* @param {(file: string) => void} [onFile]
|
|
1151
1370
|
* @returns {Promise<string | null>}
|
|
1152
1371
|
*/
|
|
1153
|
-
async function findTranscript(record, id, given) {
|
|
1154
|
-
if (given !== null)
|
|
1372
|
+
async function findTranscript(record, id, given, onFile) {
|
|
1373
|
+
if (given !== null) { const text = await readFile(given, 'utf8'); onFile?.(given); return text; }
|
|
1155
1374
|
const recorded = record.blocks?.[id]?.transcript;
|
|
1156
1375
|
const candidates = [
|
|
1157
1376
|
...(typeof recorded === 'string' && recorded.length > 0 ? [path.resolve(record.workspace, recorded)] : []),
|
|
@@ -1159,7 +1378,9 @@ async function findTranscript(record, id, given) {
|
|
|
1159
1378
|
];
|
|
1160
1379
|
for (const file of candidates) {
|
|
1161
1380
|
try {
|
|
1162
|
-
|
|
1381
|
+
const text = await readFile(file, 'utf8');
|
|
1382
|
+
onFile?.(file);
|
|
1383
|
+
return text;
|
|
1163
1384
|
} catch (thrown) {
|
|
1164
1385
|
if (/** @type {any} */ (thrown)?.code !== 'ENOENT') throw thrown;
|
|
1165
1386
|
}
|