@codedology/code-forge 0.9.0 → 0.11.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 +93 -0
- package/README.md +49 -9
- package/docs/reference/blocks.json +587 -1
- package/docs/reference/config.md +36 -0
- package/package.json +2 -2
- package/schema/code-forge.schema.json +89 -0
- package/skill/SKILL.md +1 -1
- package/skill/references/adapters/claude-code.md +1 -1
- package/skill/references/adapters/solo.md +2 -2
- package/skill/references/adapters/subprocess.md +12 -2
- package/skill/references/code.md +14 -7
- package/skill/references/ledger.md +2 -2
- package/skill/references/proof.md +77 -2
- package/skill/references/review.md +39 -7
- package/skill/references/security.md +4 -4
- package/skill/scripts/forge +1 -1
- package/skill/templates/coder-brief.md +6 -6
- package/src/autopilot/binnacle.mjs +9 -3
- package/src/cli/block.mjs +262 -31
- package/src/cli/jev.mjs +22 -3
- package/src/cli/keys.mjs +156 -21
- package/src/cli/proof.mjs +35 -7
- package/src/cli/review-file.mjs +17 -11
- package/src/cli/spawn.mjs +44 -3
- package/src/config/validate.mjs +320 -5
- package/src/doctor/index.mjs +2 -1
- package/src/doctor/local.mjs +27 -6
- package/src/doctor/probes.mjs +38 -0
- package/src/engines/builders/codex.mjs +44 -4
- package/src/engines/sandbox-dirs.mjs +257 -0
- package/src/keys/store.mjs +128 -21
- package/src/proof/junit.mjs +530 -0
- package/src/proof/red-green.mjs +57 -9
- package/src/proof/red-parse.mjs +2 -0
- package/src/proof/runners.mjs +472 -0
- package/src/proof/xcresult.mjs +285 -0
- package/src/review/engine.mjs +141 -42
- package/src/review/fixloop.mjs +75 -23
- package/src/review/gate-check.mjs +3 -2
- package/src/review/lenses/A.md +2 -0
- package/src/review/lenses/B.md +2 -0
- package/src/review/lenses/full.md +2 -0
- package/src/review/lenses/quick.md +1 -0
- package/src/review/lenses/recheck.md +2 -0
- package/src/review/packet.mjs +48 -14
- package/src/review/paired.mjs +80 -0
- package/src/review/reopen.mjs +4 -6
- package/src/review/reviewed-lines.mjs +94 -0
- package/src/review/route.mjs +232 -0
- package/src/review/schema-fallback.mjs +77 -22
- package/src/review/session-retry.mjs +44 -9
- package/src/review/test-quality.mjs +201 -0
- package/src/review/verb-forms.mjs +39 -0
- package/src/session/spawn.mjs +39 -2
- package/src/state/block-end.mjs +10 -6
- package/src/state/block.mjs +13 -3
- package/src/state/config-snapshot.mjs +39 -0
- package/src/state/run.mjs +4 -0
- package/src/util/forbidden.mjs +2 -2
- package/src/worker/engine.mjs +43 -15
- package/src/worker/loop.mjs +98 -15
- package/src/worker/ticket.mjs +9 -1
package/src/cli/block.mjs
CHANGED
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
* block claim <id> <path> --run <r>
|
|
11
11
|
* block close <id> --run <r> [--worker-pid <pid>] [--remove-worktree]
|
|
12
12
|
* block stop <id> --run <r> --reason <text>
|
|
13
|
-
* block waive <id> <finding-id> --run <r> --file <path> --reason <text> (human-only, §4.9)
|
|
14
|
-
* block reopen <id> --run <r> --file <path> --reason <text> (human-only, B72)
|
|
13
|
+
* block waive <id> <finding-id> --run <r> --file <path> --reason <text> (human-only, §4.9; asks at a terminal, B80)
|
|
14
|
+
* block reopen <id> --run <r> --file <path> --reason <text> (human-only, B72; asks at a terminal, B80)
|
|
15
15
|
*
|
|
16
16
|
* B64a `--json` (every subcommand): on success stdout is exactly ONE JSON line instead of the
|
|
17
17
|
* text — `{"block", "event", …}` with the facts the text prints (open: level, asked, source,
|
|
@@ -22,7 +22,8 @@
|
|
|
22
22
|
* `--remove-worktree`). On
|
|
23
23
|
* failure the exit code and stderr are unchanged and stdout stays empty, except a close the gate
|
|
24
24
|
* refused (`closeBlock` said no), which also prints `{"block", "ok": false, "reason"}`. A usage
|
|
25
|
-
* error, a thrown refusal, a cancelled `--no-require-reviews`
|
|
25
|
+
* error, a thrown refusal, a cancelled `--no-require-reviews`, `block waive` or `block reopen` (or
|
|
26
|
+
* one with no terminal, B80) and a failed `--remove-worktree`
|
|
26
27
|
* print nothing on stdout. Without `--json` every output is byte-identical to before.
|
|
27
28
|
*
|
|
28
29
|
* B64b block end: a close that closes or stops the block and `block stop` write
|
|
@@ -69,6 +70,21 @@
|
|
|
69
70
|
* FAILED: no review-file ticket id for <files>` (skipped under `--no-require-reviews`, the human's
|
|
70
71
|
* way out). It runs after the review rows and reuses their file set (listed once).
|
|
71
72
|
*
|
|
73
|
+
* B96 (E1): the tool builds the close report. After the review gate passed, the close prints
|
|
74
|
+
* `reviewed <path> ticket <id>` for every file the gate accepted, sorted by path, before the
|
|
75
|
+
* `reviewed_by_waiver` lines and the closed line; the id is the queue's ticket for that file at the
|
|
76
|
+
* hash the gate accepted (`resolveTicket`: the tree of a worktree block is part of it). `--json`
|
|
77
|
+
* and the done file get `reviewed: [{file, ticket, via}]` (`via`: approved, waiver or answered —
|
|
78
|
+
* the gate's own verdict, `review/reviewed-lines.mjs`), only when there is a line. So `--report`
|
|
79
|
+
* needs no ticket line: a missing hand line refuses only for a file the tool has no line for, and a
|
|
80
|
+
* hand line that names another ticket prints `WARN report ticket mismatch <path>` on stderr while
|
|
81
|
+
* the close goes on (D1). A gate-accepted file with no ticket prints `WARN no ticket for <path>`
|
|
82
|
+
* (`WARN bad ticket for <path>` when the ticket is not 24 lowercase hex) and has no line; lines that
|
|
83
|
+
* cannot be built at all give no line and `WARN reviewed lines unavailable: <message>` — then
|
|
84
|
+
* `--report` needs a hand line for every file the gate accepted, as before B96. All four warnings
|
|
85
|
+
* are held and print only when the block closes. A waived or external-review close is unchanged:
|
|
86
|
+
* no line, no key.
|
|
87
|
+
*
|
|
72
88
|
* B58 (issue #11): `review.external.required: true` in the run's config snapshot (the owner set it
|
|
73
89
|
* before `run start`; `run reload` refuses to change it) is the owner's non-interactive way out
|
|
74
90
|
* when an outside reviewer reviews the block: the per-file review checks and the `--report` ticket
|
|
@@ -156,6 +172,22 @@
|
|
|
156
172
|
* <from>[ <stop reason>]; by: human)`; `--json`: `{"block", "event": "reopened", "file", "from",
|
|
157
173
|
* "stop_reason", "at_seq", "from_hash", "to_hash", "ticket_cleared", "by"}`.
|
|
158
174
|
*
|
|
175
|
+
* B80 (D1 = a): `block waive` and `block reopen` need the human at a terminal, through the same
|
|
176
|
+
* confirm as `--no-require-reviews` (`confirmAtTerminal`). Right after the usage checks and the run
|
|
177
|
+
* record read — before the autopilot expiry checks or anything else is written — stdin or stdout
|
|
178
|
+
* not a TTY prints `block <verb> needs the human at a terminal to confirm; nothing was recorded
|
|
179
|
+
* (there is no --yes)` (exit 2). At a terminal, what the verb would refuse is refused before the
|
|
180
|
+
* question: a `--file` or finding id with a control character or of the wrong form (`usage`, exit
|
|
181
|
+
* 2, `checkFileForm` / `checkFindingId` — the verbs' own checks), an unknown block, for reopen a
|
|
182
|
+
* block that is not open or a file it does not own (`reopen-not-owned`), for waive a file the block
|
|
183
|
+
* does not own (`waive-not-owned`) or a finding id no review row of the block names for the file
|
|
184
|
+
* (`waive-unknown-finding`; `proof` is exempt) — exit 1. A "no" or a cancel prints `block <id>
|
|
185
|
+
* <verb>: cancelled — nothing was recorded` (exit 1); a "yes" runs the verb exactly as before (the
|
|
186
|
+
* same signed row). There is no `--yes` (an unknown flag, exit 2). An active autopilot grant
|
|
187
|
+
* changes nothing here — autopilot waives through its own path (`autopilot waive`, `by:
|
|
188
|
+
* autopilot`). The prompt checks only that stdin and stdout are terminals; a pseudo-terminal
|
|
189
|
+
* passes it, so it does not prove a human. The coder deny-list entries stay.
|
|
190
|
+
*
|
|
159
191
|
* `--acceptance` is a YAML/JSON list of `{clause, tests: [test ids…]}`. `block close` runs B8's
|
|
160
192
|
* rows of the gate (worker pin, MACs, orphans); the gate rows owned by later blocks join it
|
|
161
193
|
* through `closeBlock`'s `extraChecks`.
|
|
@@ -174,7 +206,7 @@ import { configInForce, externalReviewer, requireCoderTests } from '../state/con
|
|
|
174
206
|
import { StateError } from '../state/paths.mjs';
|
|
175
207
|
import { readRun, writeSigned } from '../state/run.mjs';
|
|
176
208
|
import { loadKey, verifyRow } from '../state/signer.mjs';
|
|
177
|
-
import { blockFileSet, changedFiles, ownsAt, resultFilesFor, reviewedFiles, reviewGateCheck, unownedChanges, waiveFinding } from '../review/gate-check.mjs';
|
|
209
|
+
import { PROOF_FINDING, blockFileSet, changedFiles, openFindings, ownsAt, resultFilesFor, reviewedFiles, reviewGateCheck, unownedChanges, waiveFinding } from '../review/gate-check.mjs';
|
|
178
210
|
import { checkExpiry } from '../autopilot/grant.mjs';
|
|
179
211
|
import { redact, writeSafe } from '../util/redact.mjs';
|
|
180
212
|
import { blockTree, prepareWorktree, removeWorktree, worktreeSettings } from '../proof/worktree.mjs';
|
|
@@ -183,11 +215,13 @@ import { validateConfig } from '../config/validate.mjs';
|
|
|
183
215
|
import { migrateConfig } from '../config/migrate.mjs';
|
|
184
216
|
import { exec } from '../util/exec.mjs';
|
|
185
217
|
import { contentHash, gitChildEnv } from '../worker/ticket.mjs';
|
|
186
|
-
import { clearDone } from '../worker/queue.mjs';
|
|
218
|
+
import { clearDone, resolveTicket } from '../worker/queue.mjs';
|
|
187
219
|
import { loadState } from '../worker/review-state.mjs';
|
|
188
220
|
import { openBlockEntry, reopenFile } from '../review/reopen.mjs';
|
|
221
|
+
import { checkFileForm, checkFindingId } from '../review/verb-forms.mjs';
|
|
189
222
|
import { runRootPath } from '../util/tmp.mjs';
|
|
190
223
|
import { missingReviewTickets, parseSentinel } from '../engines/sentinel.mjs';
|
|
224
|
+
import { reportMismatches, reviewedLines } from '../review/reviewed-lines.mjs';
|
|
191
225
|
import { blockEndHook, runBlockEndHook, writeDoneFile } from '../state/block-end.mjs';
|
|
192
226
|
import { blockOpenFindings } from '../review/open-findings.mjs';
|
|
193
227
|
import { hookForbidden, scanTranscript } from '../util/forbidden.mjs';
|
|
@@ -263,10 +297,14 @@ const FLAG_VALUES = ['run', 'level', 'acceptance', 'brief', 'attempt', 'base', '
|
|
|
263
297
|
* isTTY?: boolean, stdinIsTTY?: boolean, stdoutIsTTY?: boolean,
|
|
264
298
|
* ui?: {confirm: (o: {message: string, initialValue?: boolean}) => Promise<unknown>, isCancel: (v: unknown) => boolean},
|
|
265
299
|
* removeBlockTree?: typeof removeBlockTree,
|
|
300
|
+
* resolveTicket?: typeof resolveTicket, reviewedLines?: typeof reviewedLines,
|
|
266
301
|
* }} [deps] - `now` (B48): the clock of the autopilot expiry checks. `stdinIsTTY` / `stdoutIsTTY`
|
|
267
302
|
* (default `process.stdin.isTTY` / `process.stdout.isTTY`; `isTTY` sets both) and `ui` (default
|
|
268
|
-
* `@clack/prompts`):
|
|
303
|
+
* `@clack/prompts`): the human's confirmation (B52 `--no-require-reviews`, B80 `block waive` and
|
|
304
|
+
* `block reopen`), which needs both to be TTYs.
|
|
269
305
|
* `removeBlockTree` (B64b, tests only): the `--remove-worktree` step, default the real one.
|
|
306
|
+
* `resolveTicket` / `reviewedLines` (B96, tests only): the queue's ticket rule and the reviewed
|
|
307
|
+
* lines builder of the close, default the real ones.
|
|
270
308
|
* @returns {Promise<number>}
|
|
271
309
|
*/
|
|
272
310
|
export async function runBlock(args, deps = {}) {
|
|
@@ -307,12 +345,26 @@ export async function runBlock(args, deps = {}) {
|
|
|
307
345
|
if (sub === 'reopen' && typeof flags.file !== 'string') throw new StateError('usage', 'block reopen needs --file <path>');
|
|
308
346
|
if (sub === 'waive' && typeof flags.file !== 'string') throw new StateError('usage', 'block waive needs --file <path> (a finding id is scoped to one file)');
|
|
309
347
|
const record = await readRun(runId);
|
|
310
|
-
|
|
348
|
+
// B80: waive and reopen are the human's — asked at a terminal before anything is written (not
|
|
349
|
+
// even the autopilot expiry rows below); no terminal is exit 2, a "no" is exit 1
|
|
350
|
+
if (sub === 'waive' || sub === 'reopen') {
|
|
351
|
+
const asked = await confirmHumanVerb(sub, runId, id, { finding: extra, file: /** @type {string} */ (flags.file) }, record, deps);
|
|
352
|
+
if (asked === 'no-tty') {
|
|
353
|
+
err(`${humanVerbNoTTY(sub)}\n`);
|
|
354
|
+
return 2;
|
|
355
|
+
}
|
|
356
|
+
if (asked === 'cancelled') {
|
|
357
|
+
err(`block ${id} ${sub}: cancelled — nothing was recorded\n`);
|
|
358
|
+
return 1;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
const writeRow =(/** @type {Record<string, any>} */ row) => appendRow(row, { slug: record.project });
|
|
311
362
|
// B64b: the block-end signal — after an end's rows (and outside the run lock) the done file,
|
|
312
363
|
// then the owner's `hooks.on_block_end` from the run-start snapshot. Without the hook key the
|
|
313
364
|
// done file is the only addition: no row, and every output stays exactly as before.
|
|
314
365
|
const hook = blockEndHook(record);
|
|
315
|
-
|
|
366
|
+
// B96: `reviewed` (the close's tool lines) joins the done file only when there is one
|
|
367
|
+
const ended = async (/** @type {'closed' | 'stopped'} */ status, /** @type {string} */ event, /** @type {string | null} */ reason, /** @type {import('../review/reviewed-lines.mjs').ReviewedLine[]} */ reviewed = []) => {
|
|
316
368
|
// ruling (e): `ended` never throws — any failure here is the done-file warning and a null
|
|
317
369
|
// `doneFile`, so the caller's try/finally still reaches the hook
|
|
318
370
|
/** @type {string | null} */
|
|
@@ -326,6 +378,7 @@ export async function runBlock(args, deps = {}) {
|
|
|
326
378
|
reason: typeof reason === 'string' ? redact(reason) : null,
|
|
327
379
|
openFindings: (rows) => blockOpenFindings(record, rows, id),
|
|
328
380
|
err,
|
|
381
|
+
...(reviewed.length > 0 ? { reviewed } : {}),
|
|
329
382
|
});
|
|
330
383
|
} catch (thrown) {
|
|
331
384
|
const code = /** @type {any} */ (thrown)?.code;
|
|
@@ -536,6 +589,9 @@ export async function runBlock(args, deps = {}) {
|
|
|
536
589
|
// B70: a file whose review left only waived findings passes as `reviewed_by_waiver`; its signed
|
|
537
590
|
// `gate.review_waived_all` row joins the close rows (written just before `block.close`, only
|
|
538
591
|
// when the block closes — a refused close writes none)
|
|
592
|
+
// B96: whether the review gate passed — set from the gate's own result (below), so the files
|
|
593
|
+
// it checked are the ones it accepted and the tool can give their reviewed lines
|
|
594
|
+
let reviewsPassed = false;
|
|
539
595
|
const onWaivedAll = (/** @type {import('../review/gate-check.mjs').WaivedAll[]} */ files) => {
|
|
540
596
|
// idempotent: the waived rows are rebuilt from the final list, never appended per call
|
|
541
597
|
// (closeBlock reads closeRows only after every extra check passed)
|
|
@@ -543,7 +599,7 @@ export async function runBlock(args, deps = {}) {
|
|
|
543
599
|
const others = closeRows.filter((r) => r.event !== 'gate.review_waived_all');
|
|
544
600
|
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 } : {}) })));
|
|
545
601
|
};
|
|
546
|
-
const
|
|
602
|
+
const gate = reviewGateCheck(async () => {
|
|
547
603
|
const transcript = await findTranscript(record, id, typeof flags.transcript === 'string' ? flags.transcript : null);
|
|
548
604
|
if (transcript === null) {
|
|
549
605
|
await writeSigned(runId, writeRow, { event: 'gate.transcript_missing', block: id });
|
|
@@ -593,7 +649,63 @@ export async function runBlock(args, deps = {}) {
|
|
|
593
649
|
highPaths,
|
|
594
650
|
};
|
|
595
651
|
}, { onWaivedAll });
|
|
596
|
-
//
|
|
652
|
+
// B96: `reviewsPassed` comes from the gate's own result, not from the onWaivedAll call. Each
|
|
653
|
+
// extra check runs at most once per close (`closeBlock`'s one loop, src/state/block.mjs:562,
|
|
654
|
+
// and `closeBlock` is called once below), so the gate's one pass is its final one.
|
|
655
|
+
const reviews = async () => {
|
|
656
|
+
const result = await gate();
|
|
657
|
+
reviewsPassed = result.ok;
|
|
658
|
+
return result;
|
|
659
|
+
};
|
|
660
|
+
// B96: the stderr warnings of the reviewed lines (`WARN no ticket for <path>`, `WARN bad
|
|
661
|
+
// ticket for <path>`, `WARN reviewed lines unavailable: <message>`, `WARN report ticket
|
|
662
|
+
// mismatch <path>`): kept here and printed only when the block closes, before the reviewed
|
|
663
|
+
// lines — a refused close prints none
|
|
664
|
+
/** @type {string[]} */
|
|
665
|
+
const lineWarnings = [];
|
|
666
|
+
// B96: the tool's reviewed lines — one per file the review gate accepted, with the queue's
|
|
667
|
+
// ticket for that file at the hash the gate accepted (`resolveTicket`, the queue's own rule:
|
|
668
|
+
// the same path check, tree resolution and ticket id); a file whose content moved since the
|
|
669
|
+
// gate hashed it, or whose ticket cannot be given, has no line (fail closed, `WARN no ticket
|
|
670
|
+
// for <path>`), and so has a file whose ticket is not 24 lowercase hex (`WARN bad ticket for
|
|
671
|
+
// <path>`). None when the reviews were waived (the human flag or `review.external`) or the
|
|
672
|
+
// gate has not passed — and that empty answer is never memoised: the lines are built once,
|
|
673
|
+
// from the gate's pass (its file set and hashes, its waivedAll). It never throws: an error
|
|
674
|
+
// gives no lines and one held `WARN reviewed lines unavailable: <message>`, so the done file
|
|
675
|
+
// and the on_block_end hook of a closed block always follow.
|
|
676
|
+
/** @type {Promise<import('../review/reviewed-lines.mjs').ReviewedLine[]> | undefined} */
|
|
677
|
+
let linesOnce;
|
|
678
|
+
const toolLines = async () => {
|
|
679
|
+
if (!reviewsPassed || reviewsWaived || external !== null) return [];
|
|
680
|
+
linesOnce ??= (async () => {
|
|
681
|
+
try {
|
|
682
|
+
const { mainTop, top, inTree, fileSet } = await tree();
|
|
683
|
+
const resolve = deps.resolveTicket ?? resolveTicket;
|
|
684
|
+
const ticketOf = (/** @type {{file: string, content_hash: string}} */ f) => {
|
|
685
|
+
try {
|
|
686
|
+
const t = resolve({ repoRoot: mainTop, run: runId, block: id, file: f.file, tree: inTree ? top : null });
|
|
687
|
+
return t.content_hash === f.content_hash ? t.ticket : null;
|
|
688
|
+
} catch {
|
|
689
|
+
return null;
|
|
690
|
+
}
|
|
691
|
+
};
|
|
692
|
+
return await (deps.reviewedLines ?? reviewedLines)({
|
|
693
|
+
files: fileSet,
|
|
694
|
+
waivedAll,
|
|
695
|
+
ticketOf,
|
|
696
|
+
onNoTicket: (file) => lineWarnings.push(`WARN no ticket for ${file}\n`),
|
|
697
|
+
onBadTicket: (file) => lineWarnings.push(`WARN bad ticket for ${file}\n`),
|
|
698
|
+
});
|
|
699
|
+
} catch (thrown) {
|
|
700
|
+
lineWarnings.push(`WARN reviewed lines unavailable: ${thrown instanceof Error ? thrown.message : String(thrown)}\n`);
|
|
701
|
+
return [];
|
|
702
|
+
}
|
|
703
|
+
})();
|
|
704
|
+
return linesOnce;
|
|
705
|
+
};
|
|
706
|
+
// B36 → B96: the coder's report. The tool gives the reviewed lines, so a missing hand line is
|
|
707
|
+
// a refusal only for a file the tool has no line for; a hand line that names another ticket
|
|
708
|
+
// is a warning and the close goes on (D1: the tool lines are the record)
|
|
597
709
|
const report = async () => {
|
|
598
710
|
if (typeof flags.report !== 'string' || reviewsWaived || external !== null) return { ok: true };
|
|
599
711
|
let text;
|
|
@@ -603,8 +715,12 @@ export async function runBlock(args, deps = {}) {
|
|
|
603
715
|
return { ok: false, reason: 'coder report FAILED: --report cannot be read' };
|
|
604
716
|
}
|
|
605
717
|
const { fileSet } = await tree();
|
|
606
|
-
const
|
|
607
|
-
|
|
718
|
+
const lines = await toolLines();
|
|
719
|
+
const given = new Set(lines.map((l) => l.file));
|
|
720
|
+
const missing = missingReviewTickets(text, fileSet.map((f) => f.file)).filter((f) => !given.has(f));
|
|
721
|
+
if (missing.length > 0) return { ok: false, reason: `coder report FAILED: no review-file ticket id for ${missing.join(', ')}` };
|
|
722
|
+
for (const file of reportMismatches(text, lines)) lineWarnings.push(`WARN report ticket mismatch ${file}\n`);
|
|
723
|
+
return { ok: true };
|
|
608
724
|
};
|
|
609
725
|
/** @type {Array<() => Promise<{ok: boolean, reason?: string}>>} */
|
|
610
726
|
const extraChecks = [reviews, report];
|
|
@@ -634,14 +750,19 @@ export async function runBlock(args, deps = {}) {
|
|
|
634
750
|
const result = await closeBlock({ runId, id, rows, writeRow, livePid: intFlag(flags['worker-pid'], 'worker-pid'), probe, extraChecks, closeRows });
|
|
635
751
|
// B64b: the block ended (closed, or stopped by a worker pin event or ledger.tamper) — its rows
|
|
636
752
|
// are written and the run lock released; the done file is written before the tree is removed
|
|
637
|
-
|
|
753
|
+
// B96: the tool's reviewed lines of a block that closed (also kept in its done file)
|
|
754
|
+
const reviewedJson = result.ok ? await toolLines() : [];
|
|
755
|
+
const end = result.status === 'closed' || result.status === 'stopped' ? await ended(result.status, result.event ?? (result.status === 'stopped' ? 'block.stop' : 'block.close'), result.reason ?? null, reviewedJson) : null;
|
|
638
756
|
// B64b ruling (e): the hook runs exactly once for this end, after everything else the verb
|
|
639
757
|
// does — also when a later step (the output, `--remove-worktree`) throws; it never changes
|
|
640
758
|
// the exit code or stdout (`runHook` never throws)
|
|
641
759
|
try {
|
|
760
|
+
// B96: the reviewed lines (sorted by path) come first, then the reviewed_by_waiver lines
|
|
642
761
|
// B70: each file that passed as reviewed_by_waiver is named before the closed line
|
|
643
762
|
// B71: an answered finding is named as answered; a file with only answered findings has no `by`
|
|
644
763
|
if (result.ok) {
|
|
764
|
+
for (const w of lineWarnings) err(w);
|
|
765
|
+
for (const l of reviewedJson) say(`reviewed ${l.file} ticket ${l.ticket}\n`);
|
|
645
766
|
for (const f of waivedAll) {
|
|
646
767
|
const parts = f.findings.length > 0 ? [`${f.findings.join(', ')}; by ${f.by}`] : [];
|
|
647
768
|
if (f.answered?.length) parts.push(`answered ${f.answered.join(', ')}`);
|
|
@@ -660,6 +781,8 @@ export async function runBlock(args, deps = {}) {
|
|
|
660
781
|
...(end?.json ?? {}),
|
|
661
782
|
// B70: only when a file passed as reviewed_by_waiver (otherwise the output is unchanged)
|
|
662
783
|
...(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 } : {}) })) } : {}),
|
|
784
|
+
// B96: only when the tool gave reviewed lines (a waived or external close is unchanged)
|
|
785
|
+
...(reviewedJson.length > 0 ? { reviewed: reviewedJson } : {}),
|
|
663
786
|
};
|
|
664
787
|
if (!result.ok || !removeTree) {
|
|
665
788
|
if (json && result.ok) emit(closedJson);
|
|
@@ -733,31 +856,139 @@ async function coderTests(reportFlag, id) {
|
|
|
733
856
|
export const NO_REVIEWS_NO_TTY = '--no-require-reviews needs the human at a terminal to confirm; the block stays open (there is no --yes)';
|
|
734
857
|
|
|
735
858
|
/**
|
|
736
|
-
*
|
|
737
|
-
*
|
|
738
|
-
* script, the delegate's tooling), so the human confirms at a terminal and there is NO `--yes`.
|
|
739
|
-
* No terminal — stdin AND stdout must both be TTYs (a piped stdout means nobody may see the
|
|
740
|
-
* question) — ⇒ `usage` (exit 2); an active autopilot grant on the run ⇒ `autopilot-active`
|
|
741
|
-
* (exit 1; the owner stops the grant first) — nothing is written either way. `deps.stdinIsTTY` /
|
|
742
|
-
* `deps.stdoutIsTTY` inject each side; `deps.isTTY` injects both at once.
|
|
743
|
-
* @param {string} runId @param {string} id
|
|
744
|
-
* @param {{isTTY?: boolean, stdinIsTTY?: boolean, stdoutIsTTY?: boolean, ui?: {confirm: (o: {message: string, initialValue?: boolean}) => Promise<unknown>, isCancel: (v: unknown) => boolean}, now?: () => Date}} deps
|
|
745
|
-
* @returns {Promise<'confirmed' | 'cancelled'>}
|
|
746
|
-
* @throws {StateError} `usage`, `autopilot-active`
|
|
859
|
+
* B80: the refusal when `block waive` or `block reopen` has no terminal to ask the human at.
|
|
860
|
+
* @param {'waive' | 'reopen'} verb @returns {string}
|
|
747
861
|
*/
|
|
748
|
-
|
|
862
|
+
export const humanVerbNoTTY = (verb) => `block ${verb} needs the human at a terminal to confirm; nothing was recorded (there is no --yes)`;
|
|
863
|
+
|
|
864
|
+
/**
|
|
865
|
+
* @typedef {{isTTY?: boolean, stdinIsTTY?: boolean, stdoutIsTTY?: boolean, ui?: {confirm: (o: {message: string, initialValue?: boolean}) => Promise<unknown>, isCancel: (v: unknown) => boolean}, now?: () => Date}} ConfirmDeps
|
|
866
|
+
*/
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* The ONE human confirm of the `block` verb (B52 `--no-require-reviews`, B80 `block waive` and
|
|
870
|
+
* `block reopen`): a flag or a verb can be run by any process (the orchestrator, a script, a
|
|
871
|
+
* coder), so the human answers a yes/no question at a terminal and there is NO `--yes`. No
|
|
872
|
+
* terminal — stdin AND stdout must both be TTYs (a piped stdout means nobody may see the
|
|
873
|
+
* question) — is `no-tty`, decided before anything is read or asked. `before` runs between the
|
|
874
|
+
* terminal check and the question (a refusal it throws means the human is never asked).
|
|
875
|
+
* `deps.stdinIsTTY` / `deps.stdoutIsTTY` inject each side, `deps.isTTY` both at once, `deps.ui`
|
|
876
|
+
* the prompt (default `@clack/prompts`). A "no" or a cancel (Ctrl-C) is `cancelled`.
|
|
877
|
+
* @param {ConfirmDeps} deps @param {string} message
|
|
878
|
+
* @param {() => Promise<unknown> | unknown} [before]
|
|
879
|
+
* @returns {Promise<'no-tty' | 'confirmed' | 'cancelled'>}
|
|
880
|
+
*/
|
|
881
|
+
async function confirmAtTerminal(deps, message, before) {
|
|
749
882
|
const stdinTTY = deps.stdinIsTTY ?? deps.isTTY ?? process.stdin.isTTY === true;
|
|
750
883
|
const stdoutTTY = deps.stdoutIsTTY ?? deps.isTTY ?? process.stdout.isTTY === true;
|
|
751
|
-
if (!(stdinTTY && stdoutTTY))
|
|
752
|
-
|
|
753
|
-
if (grant.state === 'active') {
|
|
754
|
-
throw new StateError('autopilot-active', `--no-require-reviews is refused while an autopilot grant is active on run ${runId} (stop it first: code-forge autopilot stop --run ${runId})`);
|
|
755
|
-
}
|
|
884
|
+
if (!(stdinTTY && stdoutTTY)) return 'no-tty';
|
|
885
|
+
if (before) await before();
|
|
756
886
|
const ui = deps.ui ?? /** @type {any} */ (await import('@clack/prompts'));
|
|
757
|
-
const go = await ui.confirm({ message
|
|
887
|
+
const go = await ui.confirm({ message, initialValue: false });
|
|
758
888
|
return ui.isCancel(go) || go !== true ? 'cancelled' : 'confirmed';
|
|
759
889
|
}
|
|
760
890
|
|
|
891
|
+
/**
|
|
892
|
+
* B52: `--no-require-reviews` lifts every per-file review check, so it is the human's alone — the
|
|
893
|
+
* same rule as `autopilot approve` (`confirmAtTerminal`). No terminal ⇒ `usage` (exit 2); an
|
|
894
|
+
* active autopilot grant on the run ⇒ `autopilot-active` (exit 1; the owner stops the grant
|
|
895
|
+
* first) — nothing is written either way.
|
|
896
|
+
* @param {string} runId @param {string} id @param {ConfirmDeps} deps
|
|
897
|
+
* @returns {Promise<'confirmed' | 'cancelled'>}
|
|
898
|
+
* @throws {StateError} `usage`, `autopilot-active`
|
|
899
|
+
*/
|
|
900
|
+
async function confirmNoReviews(runId, id, deps) {
|
|
901
|
+
const asked = await confirmAtTerminal(deps, `Close block ${id} of run ${runId} WITHOUT the per-file review checks? Only the owner may say yes.`, async () => {
|
|
902
|
+
const grant = await checkExpiry(runId, (deps.now ?? (() => new Date()))());
|
|
903
|
+
if (grant.state === 'active') {
|
|
904
|
+
throw new StateError('autopilot-active', `--no-require-reviews is refused while an autopilot grant is active on run ${runId} (stop it first: code-forge autopilot stop --run ${runId})`);
|
|
905
|
+
}
|
|
906
|
+
});
|
|
907
|
+
if (asked === 'no-tty') throw new StateError('usage', NO_REVIEWS_NO_TTY);
|
|
908
|
+
return asked;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/**
|
|
912
|
+
* B80: `block waive` and `block reopen` are the human's alone (D1 = a): the human confirms at a
|
|
913
|
+
* terminal through `confirmAtTerminal`, exactly like `--no-require-reviews`, but an active
|
|
914
|
+
* autopilot grant changes nothing (autopilot waives through its own path, `by: autopilot`). The
|
|
915
|
+
* pre-checks (`reopenPrecheck`, `waivePrecheck`) run first, at a terminal, so the human is never
|
|
916
|
+
* asked about a waive or reopen the verb would refuse that way.
|
|
917
|
+
* @param {'waive' | 'reopen'} verb @param {string} runId @param {string} id
|
|
918
|
+
* @param {{finding?: string, file: string}} what @param {Record<string, any>} record @param {ConfirmDeps} deps
|
|
919
|
+
* @returns {Promise<'no-tty' | 'confirmed' | 'cancelled'>}
|
|
920
|
+
*/
|
|
921
|
+
function confirmHumanVerb(verb, runId, id, what, record, deps) {
|
|
922
|
+
const message =
|
|
923
|
+
verb === 'waive'
|
|
924
|
+
? `Waive finding ${what.finding} in ${what.file} of block ${id} (run ${runId})? Only the owner may say yes.`
|
|
925
|
+
: `Reopen the review of ${what.file} in block ${id} (run ${runId})? Only the owner may say yes.`;
|
|
926
|
+
return confirmAtTerminal(deps, message, async () => {
|
|
927
|
+
if (verb === 'reopen') await reopenPrecheck(record, runId, id, what.file);
|
|
928
|
+
else await waivePrecheck(record, runId, id, /** @type {string} */ (what.finding), what.file);
|
|
929
|
+
});
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
/**
|
|
933
|
+
* Does the block own `file`, by the rule both verbs use (`ownsAt` with the workspace's prefix
|
|
934
|
+
* inside its repo)?
|
|
935
|
+
* @param {Record<string, any>} record @param {Record<string, any>} entry @param {string} file
|
|
936
|
+
* @returns {Promise<boolean>}
|
|
937
|
+
*/
|
|
938
|
+
async function blockOwnsFile(record, entry, file) {
|
|
939
|
+
const real = async (/** @type {string} */ p) => realpath(p).catch(() => path.resolve(p));
|
|
940
|
+
const mainTop = await real(await repoTop(record.workspace));
|
|
941
|
+
const wsPrefix = path.relative(mainTop, await real(record.workspace)).split(path.sep).join('/');
|
|
942
|
+
const owned = Array.isArray(entry.owned_files) ? entry.owned_files : [];
|
|
943
|
+
return ownsAt(wsPrefix)(owned, file, entry.owned_kinds);
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
/**
|
|
947
|
+
* B80: the checks a reopen must pass BEFORE the human is asked, in `reopenFile`'s order: the
|
|
948
|
+
* `--file` form (`checkFileForm`, the reopen rule: `usage`), the block (`openBlockEntry`:
|
|
949
|
+
* `unknown_block`, `reopen-block-not-open`) and ownership (`reopen-not-owned`, the same message).
|
|
950
|
+
* The state checks that read the key and the ledger stay the verb's.
|
|
951
|
+
* @param {Record<string, any>} record @param {string} runId @param {string} id @param {string} file
|
|
952
|
+
* @returns {Promise<void>}
|
|
953
|
+
* @throws {StateError}
|
|
954
|
+
*/
|
|
955
|
+
async function reopenPrecheck(record, runId, id, file) {
|
|
956
|
+
checkFileForm(file, { normalized: true });
|
|
957
|
+
const entry = openBlockEntry(record, runId, id);
|
|
958
|
+
if (!(await blockOwnsFile(record, entry, file))) throw new StateError('reopen-not-owned', `reopen-not-owned: block ${id} does not own ${file}; nothing was recorded`);
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/**
|
|
962
|
+
* B80: the checks a waive must pass BEFORE the human is asked, so the human is never asked about a
|
|
963
|
+
* waive the verb would refuse, in `waiveFinding`'s order: the finding id and `--file` have the
|
|
964
|
+
* forms `waiveFinding` takes (`checkFindingId`, `checkFileForm`: `usage`, the same helpers); the
|
|
965
|
+
* block is in the run (`unknown_block`); the block owns the file
|
|
966
|
+
* (`waive-not-owned`, the reopen's ownership rule through `ownsAt`); and, except for the proof
|
|
967
|
+
* finding (`proof`, which no review row names), a review row of this run and block for the file
|
|
968
|
+
* names the id (`waive-unknown-finding`): `review.triage` / `review.late_finding` `finding`, a
|
|
969
|
+
* `review.result` `open` id (as the gate reads it, `openFindings`) or a `review.cap` `open` id.
|
|
970
|
+
* @param {Record<string, any>} record @param {string} runId @param {string} id
|
|
971
|
+
* @param {string} finding @param {string} file
|
|
972
|
+
* @returns {Promise<void>}
|
|
973
|
+
* @throws {StateError}
|
|
974
|
+
*/
|
|
975
|
+
async function waivePrecheck(record, runId, id, finding, file) {
|
|
976
|
+
checkFindingId(finding);
|
|
977
|
+
checkFileForm(file, { normalized: false });
|
|
978
|
+
const entry = record.blocks?.[id];
|
|
979
|
+
if (!entry) throw new StateError('unknown_block', `block ${id} is not in run ${runId}`);
|
|
980
|
+
if (!(await blockOwnsFile(record, entry, file))) throw new StateError('waive-not-owned', `waive-not-owned: block ${id} does not own ${file}; nothing was recorded`);
|
|
981
|
+
if (finding === PROOF_FINDING) return;
|
|
982
|
+
const named = (await readAllRows(record.project)).some((r) => {
|
|
983
|
+
if (r?.run !== runId || r.block !== id || r.file !== file) return false;
|
|
984
|
+
if (r.event === 'review.triage' || r.event === 'review.late_finding') return r.finding === finding;
|
|
985
|
+
if (r.event === 'review.result') return Array.isArray(r.open) && openFindings(r.open).some((o) => o.id === finding);
|
|
986
|
+
if (r.event === 'review.cap') return Array.isArray(r.open) && r.open.includes(finding);
|
|
987
|
+
return false;
|
|
988
|
+
});
|
|
989
|
+
if (!named) throw new StateError('waive-unknown-finding', `waive-unknown-finding: no review row of block ${id} names finding ${finding} in ${file}; nothing was recorded`);
|
|
990
|
+
}
|
|
991
|
+
|
|
761
992
|
/**
|
|
762
993
|
* B59: the rules lane for a brief-mode block — `fallbackLane` over the facts of its owned entries,
|
|
763
994
|
* the `--lines` estimate and the snapshot's `proof.tiers.high.paths` (none for a record from
|
package/src/cli/jev.mjs
CHANGED
|
@@ -23,11 +23,17 @@
|
|
|
23
23
|
* subfolder thus writes to the project's ledger. An unknown flag is a usage error.
|
|
24
24
|
*
|
|
25
25
|
* Resolves the Jev key through B2's chain (`resolveKey`, earlier batch — the seam rule allows a
|
|
26
|
-
* Wave 1 module to import a block in an earlier batch statically)
|
|
26
|
+
* Wave 1 module to import a block in an earlier batch statically) with the project's
|
|
27
|
+
* `keys.cache_hours` (B84; an invalid value warns once and uses 8 h), asks Jev, runs the answer
|
|
27
28
|
* through `thresholds.decide()`, and appends one `{event: 'decision', ...}` row through B6's
|
|
28
29
|
* writer (also an earlier batch) — the shape `ledger/calibration.mjs` already expects:
|
|
29
30
|
* `decision_id`, `question`, `confidence` (plan §6.3's calibration buckets read exactly these).
|
|
30
31
|
*
|
|
32
|
+
* B85: when no key resolved because the cached one expired and 1Password did not answer, and the
|
|
33
|
+
* reference is `op://…`, the generic "no Jev key resolved" line is replaced by one re-link line
|
|
34
|
+
* naming the expired time and the exact `code-forge keys set jev --op <ref>` command (the
|
|
35
|
+
* 1Password reasons still print first; same text with or without a TTY).
|
|
36
|
+
*
|
|
31
37
|
* Never prints the key: it only ever reaches the `Authorization` header inside `askJev`, and
|
|
32
38
|
* `resolveKey` has already registered it with `redact()` before this module sees it.
|
|
33
39
|
*/
|
|
@@ -40,7 +46,7 @@ import { buildQuestionPayload, isKnownQuestion, isQuestionDisabled } from '../de
|
|
|
40
46
|
import { askJev as realAskJev } from '../decide/jev-client.mjs';
|
|
41
47
|
import { decide } from '../decide/thresholds.mjs';
|
|
42
48
|
import { resolveDispatchQuestionsByRules } from '../decide/fallback-rules.mjs';
|
|
43
|
-
import { createDefaultKeyStore, resolveKey } from '../keys/store.mjs';
|
|
49
|
+
import { cacheHoursFrom, createDefaultKeyStore, isTime, resolveKey, shellWord } from '../keys/store.mjs';
|
|
44
50
|
import { appendRow as realAppendRow } from '../ledger/write.mjs';
|
|
45
51
|
import { writeSafe } from '../util/redact.mjs';
|
|
46
52
|
|
|
@@ -113,6 +119,7 @@ function badRuleFacts(state) {
|
|
|
113
119
|
* @param {object} deps
|
|
114
120
|
* @param {import('../keys/store.mjs').KeyStore} [deps.store]
|
|
115
121
|
* @param {typeof loadConfigFile} [deps.loadConfig]
|
|
122
|
+
* @param {typeof import('../keys/onepassword.mjs').opRead} [deps.opRead] - the 1Password reader (tests inject a fake).
|
|
116
123
|
* @param {typeof realAskJev} [deps.askJev]
|
|
117
124
|
* @param {typeof realAppendRow} [deps.appendRow]
|
|
118
125
|
* @param {{write: (s: string) => unknown}} [deps.stdout]
|
|
@@ -126,6 +133,7 @@ export async function runJev(args, deps = {}) {
|
|
|
126
133
|
const {
|
|
127
134
|
store,
|
|
128
135
|
loadConfig = loadConfigFile,
|
|
136
|
+
opRead,
|
|
129
137
|
askJev: ask = realAskJev,
|
|
130
138
|
appendRow: write = realAppendRow,
|
|
131
139
|
stdout = process.stdout,
|
|
@@ -218,9 +226,14 @@ export async function runJev(args, deps = {}) {
|
|
|
218
226
|
|
|
219
227
|
const keyStore = store ?? (await createDefaultKeyStore(env));
|
|
220
228
|
const keyRef = keyRefFlag.value ?? cfg?.keys?.jev;
|
|
229
|
+
// B84: the project's cache time; an invalid one warns once and falls back to the default
|
|
230
|
+
const cache = cacheHoursFrom(cfg);
|
|
231
|
+
if (cache.problem !== null) {
|
|
232
|
+
err(`WARN jev ask: ${cache.problem}; caching for the default ${cache.hours} h\n`);
|
|
233
|
+
}
|
|
221
234
|
let resolved;
|
|
222
235
|
try {
|
|
223
|
-
resolved = await resolveKey('jev', { store: keyStore, ref: keyRef, env, now: now() });
|
|
236
|
+
resolved = await resolveKey('jev', { store: keyStore, ref: keyRef, env, now: now(), cacheHours: cache.hours, ...(opRead ? { opRead } : {}) });
|
|
224
237
|
} catch (thrown) {
|
|
225
238
|
err(`jev ask: ${thrown?.message ?? String(thrown)}\n`);
|
|
226
239
|
return 2;
|
|
@@ -229,6 +242,12 @@ export async function runJev(args, deps = {}) {
|
|
|
229
242
|
for (const reason of resolved.errors) {
|
|
230
243
|
err(`jev ask: ${reason}\n`);
|
|
231
244
|
}
|
|
245
|
+
// `expired` is the dropped entry's exp in ms; anything that is not a valid time gives the
|
|
246
|
+
// generic line
|
|
247
|
+
if (isTime(resolved.expired) && typeof keyRef === 'string' && keyRef.startsWith('op://')) {
|
|
248
|
+
err(`jev ask: the cached jev key expired at ${new Date(resolved.expired).toISOString()} and 1Password did not answer; re-link: code-forge keys set jev --op ${shellWord(keyRef)}\n`);
|
|
249
|
+
return 1;
|
|
250
|
+
}
|
|
232
251
|
err('jev ask: no Jev key resolved (set one with "code-forge keys set jev")\n');
|
|
233
252
|
return 1;
|
|
234
253
|
}
|