@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.
Files changed (62) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/README.md +49 -9
  3. package/docs/reference/blocks.json +587 -1
  4. package/docs/reference/config.md +36 -0
  5. package/package.json +2 -2
  6. package/schema/code-forge.schema.json +89 -0
  7. package/skill/SKILL.md +1 -1
  8. package/skill/references/adapters/claude-code.md +1 -1
  9. package/skill/references/adapters/solo.md +2 -2
  10. package/skill/references/adapters/subprocess.md +12 -2
  11. package/skill/references/code.md +14 -7
  12. package/skill/references/ledger.md +2 -2
  13. package/skill/references/proof.md +77 -2
  14. package/skill/references/review.md +39 -7
  15. package/skill/references/security.md +4 -4
  16. package/skill/scripts/forge +1 -1
  17. package/skill/templates/coder-brief.md +6 -6
  18. package/src/autopilot/binnacle.mjs +9 -3
  19. package/src/cli/block.mjs +262 -31
  20. package/src/cli/jev.mjs +22 -3
  21. package/src/cli/keys.mjs +156 -21
  22. package/src/cli/proof.mjs +35 -7
  23. package/src/cli/review-file.mjs +17 -11
  24. package/src/cli/spawn.mjs +44 -3
  25. package/src/config/validate.mjs +320 -5
  26. package/src/doctor/index.mjs +2 -1
  27. package/src/doctor/local.mjs +27 -6
  28. package/src/doctor/probes.mjs +38 -0
  29. package/src/engines/builders/codex.mjs +44 -4
  30. package/src/engines/sandbox-dirs.mjs +257 -0
  31. package/src/keys/store.mjs +128 -21
  32. package/src/proof/junit.mjs +530 -0
  33. package/src/proof/red-green.mjs +57 -9
  34. package/src/proof/red-parse.mjs +2 -0
  35. package/src/proof/runners.mjs +472 -0
  36. package/src/proof/xcresult.mjs +285 -0
  37. package/src/review/engine.mjs +141 -42
  38. package/src/review/fixloop.mjs +75 -23
  39. package/src/review/gate-check.mjs +3 -2
  40. package/src/review/lenses/A.md +2 -0
  41. package/src/review/lenses/B.md +2 -0
  42. package/src/review/lenses/full.md +2 -0
  43. package/src/review/lenses/quick.md +1 -0
  44. package/src/review/lenses/recheck.md +2 -0
  45. package/src/review/packet.mjs +48 -14
  46. package/src/review/paired.mjs +80 -0
  47. package/src/review/reopen.mjs +4 -6
  48. package/src/review/reviewed-lines.mjs +94 -0
  49. package/src/review/route.mjs +232 -0
  50. package/src/review/schema-fallback.mjs +77 -22
  51. package/src/review/session-retry.mjs +44 -9
  52. package/src/review/test-quality.mjs +201 -0
  53. package/src/review/verb-forms.mjs +39 -0
  54. package/src/session/spawn.mjs +39 -2
  55. package/src/state/block-end.mjs +10 -6
  56. package/src/state/block.mjs +13 -3
  57. package/src/state/config-snapshot.mjs +39 -0
  58. package/src/state/run.mjs +4 -0
  59. package/src/util/forbidden.mjs +2 -2
  60. package/src/worker/engine.mjs +43 -15
  61. package/src/worker/loop.mjs +98 -15
  62. 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` and a failed `--remove-worktree`
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`): B52's confirmation of `--no-require-reviews`, which needs both to be TTYs.
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
- const writeRow = (/** @type {Record<string, any>} */ row) => appendRow(row, { slug: record.project });
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
- const ended = async (/** @type {'closed' | 'stopped'} */ status, /** @type {string} */ event, /** @type {string | null} */ reason) => {
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 reviews = reviewGateCheck(async () => {
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
- // B36: the coder's report names a review-file ticket for every changed file, or it is FAILED
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 missing = missingReviewTickets(text, fileSet.map((f) => f.file));
607
- return missing.length === 0 ? { ok: true } : { ok: false, reason: `coder report FAILED: no review-file ticket id for ${missing.join(', ')}` };
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
- const end = result.status === 'closed' || result.status === 'stopped' ? await ended(result.status, result.event ?? (result.status === 'stopped' ? 'block.stop' : 'block.close'), result.reason ?? null) : null;
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
- * B52: `--no-require-reviews` lifts every per-file review check, so it is the human's alone — the
737
- * same rule as `autopilot approve`: a flag can be passed by any process (the orchestrator, a
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
- async function confirmNoReviews(runId, id, deps) {
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)) throw new StateError('usage', NO_REVIEWS_NO_TTY);
752
- const grant = await checkExpiry(runId, (deps.now ?? (() => new Date()))());
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: `Close block ${id} of run ${runId} WITHOUT the per-file review checks? Only the owner may say yes.`, initialValue: false });
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), asks Jev, runs the answer
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
  }