@codedology/code-forge 0.8.0 → 0.10.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 (64) hide show
  1. package/CHANGELOG.md +114 -0
  2. package/README.md +46 -7
  3. package/docs/reference/blocks.json +618 -1
  4. package/docs/reference/config.md +36 -0
  5. package/package.json +1 -1
  6. package/schema/code-forge.schema.json +89 -0
  7. package/skill/references/adapters/claude-code.md +1 -1
  8. package/skill/references/adapters/solo.md +2 -2
  9. package/skill/references/adapters/subprocess.md +12 -2
  10. package/skill/references/code.md +21 -7
  11. package/skill/references/ledger.md +2 -2
  12. package/skill/references/proof.md +77 -2
  13. package/skill/references/review.md +29 -4
  14. package/skill/references/security.md +5 -5
  15. package/skill/scripts/forge +1 -1
  16. package/skill/templates/coder-brief.md +4 -2
  17. package/src/autopilot/binnacle.mjs +9 -3
  18. package/src/cli/block.mjs +300 -33
  19. package/src/cli/jev.mjs +22 -3
  20. package/src/cli/keys.mjs +156 -21
  21. package/src/cli/proof.mjs +35 -7
  22. package/src/cli/review-file.mjs +264 -13
  23. package/src/cli/spawn.mjs +44 -3
  24. package/src/config/validate.mjs +320 -5
  25. package/src/doctor/index.mjs +7 -5
  26. package/src/doctor/local.mjs +116 -8
  27. package/src/doctor/probes.mjs +38 -0
  28. package/src/engines/builders/codex.mjs +44 -4
  29. package/src/engines/sandbox-dirs.mjs +257 -0
  30. package/src/keys/store.mjs +128 -21
  31. package/src/ledger/write.mjs +7 -1
  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/answers.mjs +445 -0
  38. package/src/review/deletion.mjs +106 -0
  39. package/src/review/engine.mjs +117 -31
  40. package/src/review/fixloop.mjs +70 -28
  41. package/src/review/gate-check.mjs +378 -80
  42. package/src/review/lenses/A.md +1 -0
  43. package/src/review/lenses/B.md +1 -0
  44. package/src/review/lenses/full.md +1 -0
  45. package/src/review/lenses/quick.md +1 -0
  46. package/src/review/lenses/recheck.md +1 -0
  47. package/src/review/reopen.mjs +256 -0
  48. package/src/review/route.mjs +232 -0
  49. package/src/review/schema-fallback.mjs +77 -22
  50. package/src/review/session-retry.mjs +44 -9
  51. package/src/review/stop-exits.mjs +15 -0
  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.mjs +13 -3
  56. package/src/state/config-snapshot.mjs +39 -0
  57. package/src/state/run.mjs +4 -0
  58. package/src/util/forbidden.mjs +11 -1
  59. package/src/util/tmp.mjs +16 -4
  60. package/src/worker/engine.mjs +75 -9
  61. package/src/worker/loop.mjs +82 -15
  62. package/src/worker/queue.mjs +265 -10
  63. package/src/worker/review-state.mjs +346 -51
  64. package/src/worker/ticket.mjs +9 -1
package/src/cli/block.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `code-forge block open|attempt|rebase|claim|close|stop` (plan §4.9). The skill calls
2
+ * `code-forge block open|attempt|rebase|claim|close|stop|waive|reopen` (plan §4.9). The skill calls
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>
@@ -10,7 +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)
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)
14
15
  *
15
16
  * B64a `--json` (every subcommand): on success stdout is exactly ONE JSON line instead of the
16
17
  * text — `{"block", "event", …}` with the facts the text prints (open: level, asked, source,
@@ -21,7 +22,8 @@
21
22
  * `--remove-worktree`). On
22
23
  * failure the exit code and stderr are unchanged and stdout stays empty, except a close the gate
23
24
  * refused (`closeBlock` said no), which also prints `{"block", "ok": false, "reason"}`. A usage
24
- * 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`
25
27
  * print nothing on stdout. Without `--json` every output is byte-identical to before.
26
28
  *
27
29
  * B64b block end: a close that closes or stops the block and `block stop` write
@@ -77,6 +79,19 @@
77
79
  * else still refuses — `unowned_change`, `unproven`, `no_changes`, the transcript grep and
78
80
  * `closeBlock`'s own rows. With `--no-require-reviews` the human path runs exactly as without the key.
79
81
  *
82
+ * B70 (issue #9 a): a file with no approval for its current hash whose real review (a signed
83
+ * `review.round` at that hash) left findings that are all waived — `block waive`, or an autopilot
84
+ * waiver its grant covered for a non-critical — passes as `reviewed_by_waiver`, never approved
85
+ * (`checkBlockReviews`). When the block closes, one signed `gate.review_waived_all {file,
86
+ * content_hash, findings, by}` row per such file is written just before `block.close` (after any
87
+ * other close row); a refused close writes none. The close prints `block <id>: <file>
88
+ * reviewed_by_waiver (<ids>; by <who>)` before the closed line, and `--json` adds
89
+ * `reviewed_by_waiver: [{file, content_hash, status, findings, by}]` (only when there is one).
90
+ * B71: a finding a signed `review.answered` row clears (another owned file resolved it, judged by
91
+ * a reviewer) counts like a waived one, listed under `answered` next to `findings` in the close
92
+ * row and the `--json` entry; the line then reads `(<ids>; by <who>; answered <ids>)`, or
93
+ * `(answered <ids>)` when nothing was waived (`by` is then null).
94
+ *
80
95
  * B63b: `context.require_coder_tests: true` in the run's config snapshot (immutable like
81
96
  * `review.external`; the live config is never read for it) adds one check after the report check:
82
97
  * `--report` must be given and readable, its last sentinel for the block must be COMPLETE, and a
@@ -128,6 +143,36 @@
128
143
  * before anything is asked or written. The ledger, the signed result files and the high paths stay
129
144
  * in the main tree. Nothing is checked out, pushed or merged.
130
145
  *
146
+ * B72 (issues #12, #15): `block reopen <id> --run <r> --file <path> --reason <text>` is the
147
+ * human's restart of a file whose review STOPPED or whose state is LOST (`review/reopen.mjs`):
148
+ * coder-forbidden like `block waive`. The done marker of the current content's ticket is removed
149
+ * (the ticket of the block's worktree when it has one), then one signed `review.reopened {block,
150
+ * file, from, stop_reason, at_seq, from_hash, to_hash, reason, by: human}` row is written, so the
151
+ * next `review-file` runs a full round 1. Refused (exit 1, no row): `unknown_block` and
152
+ * `reopen-block-not-open` (checked before anything is read), a `worktree-*` code (the block's tree
153
+ * cannot be read), `reopen-config` (the run's config snapshot or `tmp.root` cannot be read),
154
+ * `reopen-not-owned`, `reopen-tampered`, `reopen-not-stopped`, `reopen-same-content`,
155
+ * `reopen-already-recorded`, `reopen-write-failed`; a missing `--file` or `--reason` is a usage
156
+ * error (exit 2). It prints `block <id> reopened <file> (from
157
+ * <from>[ <stop reason>]; by: human)`; `--json`: `{"block", "event": "reopened", "file", "from",
158
+ * "stop_reason", "at_seq", "from_hash", "to_hash", "ticket_cleared", "by"}`.
159
+ *
160
+ * B80 (D1 = a): `block waive` and `block reopen` need the human at a terminal, through the same
161
+ * confirm as `--no-require-reviews` (`confirmAtTerminal`). Right after the usage checks and the run
162
+ * record read — before the autopilot expiry checks or anything else is written — stdin or stdout
163
+ * not a TTY prints `block <verb> needs the human at a terminal to confirm; nothing was recorded
164
+ * (there is no --yes)` (exit 2). At a terminal, what the verb would refuse is refused before the
165
+ * question: a `--file` or finding id with a control character or of the wrong form (`usage`, exit
166
+ * 2, `checkFileForm` / `checkFindingId` — the verbs' own checks), an unknown block, for reopen a
167
+ * block that is not open or a file it does not own (`reopen-not-owned`), for waive a file the block
168
+ * does not own (`waive-not-owned`) or a finding id no review row of the block names for the file
169
+ * (`waive-unknown-finding`; `proof` is exempt) — exit 1. A "no" or a cancel prints `block <id>
170
+ * <verb>: cancelled — nothing was recorded` (exit 1); a "yes" runs the verb exactly as before (the
171
+ * same signed row). There is no `--yes` (an unknown flag, exit 2). An active autopilot grant
172
+ * changes nothing here — autopilot waives through its own path (`autopilot waive`, `by:
173
+ * autopilot`). The prompt checks only that stdin and stdout are terminals; a pseudo-terminal
174
+ * passes it, so it does not prove a human. The coder deny-list entries stay.
175
+ *
131
176
  * `--acceptance` is a YAML/JSON list of `{clause, tests: [test ids…]}`. `block close` runs B8's
132
177
  * rows of the gate (worker pin, MACs, orphans); the gate rows owned by later blocks join it
133
178
  * through `closeBlock`'s `extraChecks`.
@@ -146,7 +191,7 @@ import { configInForce, externalReviewer, requireCoderTests } from '../state/con
146
191
  import { StateError } from '../state/paths.mjs';
147
192
  import { readRun, writeSigned } from '../state/run.mjs';
148
193
  import { loadKey, verifyRow } from '../state/signer.mjs';
149
- import { blockFileSet, changedFiles, ownsAt, resultFilesFor, reviewedFiles, reviewGateCheck, unownedChanges, waiveFinding } from '../review/gate-check.mjs';
194
+ import { PROOF_FINDING, blockFileSet, changedFiles, openFindings, ownsAt, resultFilesFor, reviewedFiles, reviewGateCheck, unownedChanges, waiveFinding } from '../review/gate-check.mjs';
150
195
  import { checkExpiry } from '../autopilot/grant.mjs';
151
196
  import { redact, writeSafe } from '../util/redact.mjs';
152
197
  import { blockTree, prepareWorktree, removeWorktree, worktreeSettings } from '../proof/worktree.mjs';
@@ -154,19 +199,79 @@ import { DEFAULT_CONFIG_FILENAME, loadProjectConfig } from '../config/load.mjs';
154
199
  import { validateConfig } from '../config/validate.mjs';
155
200
  import { migrateConfig } from '../config/migrate.mjs';
156
201
  import { exec } from '../util/exec.mjs';
157
- import { gitChildEnv } from '../worker/ticket.mjs';
202
+ import { contentHash, gitChildEnv } from '../worker/ticket.mjs';
203
+ import { clearDone } from '../worker/queue.mjs';
204
+ import { loadState } from '../worker/review-state.mjs';
205
+ import { openBlockEntry, reopenFile } from '../review/reopen.mjs';
206
+ import { checkFileForm, checkFindingId } from '../review/verb-forms.mjs';
207
+ import { runRootPath } from '../util/tmp.mjs';
158
208
  import { missingReviewTickets, parseSentinel } from '../engines/sentinel.mjs';
159
209
  import { blockEndHook, runBlockEndHook, writeDoneFile } from '../state/block-end.mjs';
160
210
  import { blockOpenFindings } from '../review/open-findings.mjs';
161
211
  import { hookForbidden, scanTranscript } from '../util/forbidden.mjs';
162
212
 
213
+ /**
214
+ * B72: `block reopen` — the file's content is read where the block works (its worktree, else the
215
+ * main tree), its state is read (never moved) from the code-forge home or the run's old temp root,
216
+ * the checks, the done marker's removal and then the signed row are `reopenFile`'s.
217
+ * @param {{runId: string, id: string, file: string, reason: string, record: Record<string, any>, writeRow: (row: Record<string, any>) => Promise<unknown>}} opts
218
+ * @returns {Promise<{row: Record<string, any>, cleared: boolean}>}
219
+ */
220
+ async function reopenBlockFile({ runId, id, file, reason, record, writeRow }) {
221
+ // the stable block codes come first, before any tree, config, key or ledger read
222
+ const entry = openBlockEntry(record, runId, id);
223
+ const real = async (/** @type {string} */ p) => realpath(p).catch(() => path.resolve(p));
224
+ const mainTop = await real(await repoTop(record.workspace));
225
+ const wt = await blockTree(record.workspace, entry).catch((thrown) => {
226
+ if (typeof thrown?.code === 'string' && thrown.code.startsWith('worktree-')) throw new StateError(thrown.code, `block ${id}: ${thrown.message}, so the file's content cannot be read; nothing was recorded`);
227
+ throw thrown;
228
+ });
229
+ const contentRoot = wt === null ? mainTop : wt.top;
230
+ const wsPrefix = path.relative(mainTop, await real(record.workspace)).split(path.sep).join('/');
231
+ // the state is read where the worker keeps it: an unreadable snapshot or tmp.root is refused,
232
+ // never guessed (a wrong root would read as "no state"); only an absent tmp.root (undefined or
233
+ // null) means the default root, any other non-string is refused (B79)
234
+ /** @type {string} */
235
+ let runRootDir;
236
+ try {
237
+ const root = configInForce(record)?.tmp?.root;
238
+ if (root !== undefined && root !== null && typeof root !== 'string') throw new TypeError('tmp.root is not a string');
239
+ runRootDir = runRootPath(runId, typeof root === 'string' ? { root } : {});
240
+ } catch {
241
+ throw new StateError('reopen-config', `reopen-config: the run's config snapshot or its tmp.root cannot be read, so the review state of ${file} cannot be found; nothing was recorded`);
242
+ }
243
+ const key = await loadKey(runId);
244
+ const rows = await readAllRows(record.project);
245
+ const tree = contentRoot === mainTop ? {} : { tree: contentRoot };
246
+ const { row, cleared } = await reopenFile({
247
+ runId,
248
+ id,
249
+ file,
250
+ reason,
251
+ record,
252
+ rows,
253
+ key,
254
+ writeRow,
255
+ writeSigned,
256
+ io: {
257
+ owns: ownsAt(wsPrefix),
258
+ loadState: (where) => loadState({ .../** @type {any} */ (where), runRootDir, contentRoot, migrate: false }),
259
+ contentHash: (f) => contentHash(contentRoot, f),
260
+ // C11 (synchronous): the cached result of the current content's ticket would repeat the stop
261
+ clearDone: (f) => clearDone({ repoRoot: mainTop, run: runId, block: id, file: f, ...tree }).removed,
262
+ },
263
+ });
264
+ return { row, cleared };
265
+ }
266
+
163
267
  const USAGE =
164
268
  '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' +
165
269
  ' 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' +
166
270
  ' code-forge block waive <id> <finding-id> --run <r> --file <path> --reason <text>\n' +
271
+ ' code-forge block reopen <id> --run <r> --file <path> --reason <text>\n' +
167
272
  ' every block verb takes --json: one JSON line on stdout ({"block","event",…}) instead of the text\n';
168
273
 
169
- const SUBCOMMANDS = ['open', 'attempt', 'rebase', 'claim', 'close', 'stop', 'waive'];
274
+ const SUBCOMMANDS = ['open', 'attempt', 'rebase', 'claim', 'close', 'stop', 'waive', 'reopen'];
170
275
  const FLAG_VALUES = ['run', 'level', 'acceptance', 'brief', 'attempt', 'base', 'lines', 'head', 'worker-pid', 'reason', 'file', 'transcript', 'kind', 'report'];
171
276
 
172
277
  /**
@@ -178,7 +283,8 @@ const FLAG_VALUES = ['run', 'level', 'acceptance', 'brief', 'attempt', 'base', '
178
283
  * removeBlockTree?: typeof removeBlockTree,
179
284
  * }} [deps] - `now` (B48): the clock of the autopilot expiry checks. `stdinIsTTY` / `stdoutIsTTY`
180
285
  * (default `process.stdin.isTTY` / `process.stdout.isTTY`; `isTTY` sets both) and `ui` (default
181
- * `@clack/prompts`): B52's confirmation of `--no-require-reviews`, which needs both to be TTYs.
286
+ * `@clack/prompts`): the human's confirmation (B52 `--no-require-reviews`, B80 `block waive` and
287
+ * `block reopen`), which needs both to be TTYs.
182
288
  * `removeBlockTree` (B64b, tests only): the `--remove-worktree` step, default the real one.
183
289
  * @returns {Promise<number>}
184
290
  */
@@ -214,11 +320,26 @@ export async function runBlock(args, deps = {}) {
214
320
  }
215
321
  if (sub !== 'close' && flags['remove-worktree'] === true) throw new StateError('usage', '--remove-worktree is a block close flag');
216
322
  if (sub === 'claim' && (!extra || positionals.length !== 2)) throw new StateError('usage', 'block claim needs exactly one path');
217
- if ((sub === 'stop' || sub === 'waive') && typeof flags.reason !== 'string') throw new StateError('usage', `block ${sub} needs --reason`);
323
+ if ((sub === 'stop' || sub === 'waive' || sub === 'reopen') && typeof flags.reason !== 'string') throw new StateError('usage', `block ${sub} needs --reason`);
218
324
  if (sub === 'waive' && (!extra || positionals.length !== 2)) throw new StateError('usage', 'block waive needs exactly one finding id');
325
+ if (sub === 'reopen' && positionals.length !== 1) throw new StateError('usage', 'block reopen takes one block id (the file goes in --file)');
326
+ if (sub === 'reopen' && typeof flags.file !== 'string') throw new StateError('usage', 'block reopen needs --file <path>');
219
327
  if (sub === 'waive' && typeof flags.file !== 'string') throw new StateError('usage', 'block waive needs --file <path> (a finding id is scoped to one file)');
220
328
  const record = await readRun(runId);
221
- const writeRow = (/** @type {Record<string, any>} */ row) => appendRow(row, { slug: record.project });
329
+ // B80: waive and reopen are the human's — asked at a terminal before anything is written (not
330
+ // even the autopilot expiry rows below); no terminal is exit 2, a "no" is exit 1
331
+ if (sub === 'waive' || sub === 'reopen') {
332
+ const asked = await confirmHumanVerb(sub, runId, id, { finding: extra, file: /** @type {string} */ (flags.file) }, record, deps);
333
+ if (asked === 'no-tty') {
334
+ err(`${humanVerbNoTTY(sub)}\n`);
335
+ return 2;
336
+ }
337
+ if (asked === 'cancelled') {
338
+ err(`block ${id} ${sub}: cancelled — nothing was recorded\n`);
339
+ return 1;
340
+ }
341
+ }
342
+ const writeRow =(/** @type {Record<string, any>} */ row) => appendRow(row, { slug: record.project });
222
343
  // B64b: the block-end signal — after an end's rows (and outside the run lock) the done file,
223
344
  // then the owner's `hooks.on_block_end` from the run-start snapshot. Without the hook key the
224
345
  // done file is the only addition: no row, and every output stays exactly as before.
@@ -367,6 +488,14 @@ export async function runBlock(args, deps = {}) {
367
488
  if (json) emit({ block: id, event: 'waived', finding: extra, file: flags.file, by: 'human' });
368
489
  return 0;
369
490
  }
491
+ if (sub === 'reopen') {
492
+ const file = /** @type {string} */ (flags.file);
493
+ const { row, cleared } = await reopenBlockFile({ runId, id, file, reason: /** @type {string} */ (flags.reason), record, writeRow });
494
+ const fromText = row.stop_reason == null ? row.from : `${row.from} ${row.stop_reason}`;
495
+ say(`block ${id} reopened ${file} (from ${fromText}; by: human)\n`);
496
+ if (json) emit({ block: id, event: 'reopened', file, from: row.from, stop_reason: row.stop_reason, at_seq: row.at_seq, from_hash: row.from_hash, to_hash: row.to_hash, ticket_cleared: cleared, by: 'human' });
497
+ return 0;
498
+ }
370
499
  if (sub === 'close') {
371
500
  const entry = record.blocks?.[id];
372
501
  if (!entry) throw new StateError('unknown_block', `block ${id} is not in run ${runId}`);
@@ -430,6 +559,22 @@ export async function runBlock(args, deps = {}) {
430
559
  const fileSet = await blockFileSet({ repoRoot: top, base, owned: entry.owned_files, matchOwned: isMine, changed: [...new Set([...changed, ...reviewed])].sort() });
431
560
  return { top, mainTop, where, inTree, wsPrefix, owns, changed, fileSet, reviewed };
432
561
  })());
562
+ // B58: the config waiver row is written by `closeBlock` under the run lock, just before
563
+ // `block.close` and only when every other check passed — never by a refused close
564
+ /** @type {Record<string, any>[]} */
565
+ const closeRows = external === null ? [] : [{ event: 'gate.reviews_waived', block: id, by: 'config', reviewer: external }];
566
+ /** @type {import('../review/gate-check.mjs').WaivedAll[]} */
567
+ let waivedAll = [];
568
+ // B70: a file whose review left only waived findings passes as `reviewed_by_waiver`; its signed
569
+ // `gate.review_waived_all` row joins the close rows (written just before `block.close`, only
570
+ // when the block closes — a refused close writes none)
571
+ const onWaivedAll = (/** @type {import('../review/gate-check.mjs').WaivedAll[]} */ files) => {
572
+ // idempotent: the waived rows are rebuilt from the final list, never appended per call
573
+ // (closeBlock reads closeRows only after every extra check passed)
574
+ waivedAll = files;
575
+ const others = closeRows.filter((r) => r.event !== 'gate.review_waived_all');
576
+ 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 } : {}) })));
577
+ };
433
578
  const reviews = reviewGateCheck(async () => {
434
579
  const transcript = await findTranscript(record, id, typeof flags.transcript === 'string' ? flags.transcript : null);
435
580
  if (transcript === null) {
@@ -479,7 +624,7 @@ export async function runBlock(args, deps = {}) {
479
624
  proofFiles: fileSet,
480
625
  highPaths,
481
626
  };
482
- });
627
+ }, { onWaivedAll });
483
628
  // B36: the coder's report names a review-file ticket for every changed file, or it is FAILED
484
629
  const report = async () => {
485
630
  if (typeof flags.report !== 'string' || reviewsWaived || external !== null) return { ok: true };
@@ -493,10 +638,6 @@ export async function runBlock(args, deps = {}) {
493
638
  const missing = missingReviewTickets(text, fileSet.map((f) => f.file));
494
639
  return missing.length === 0 ? { ok: true } : { ok: false, reason: `coder report FAILED: no review-file ticket id for ${missing.join(', ')}` };
495
640
  };
496
- // B58: the config waiver row is written by `closeBlock` under the run lock, just before
497
- // `block.close` and only when every other check passed — never by a refused close
498
- /** @type {Record<string, any>[]} */
499
- const closeRows = external === null ? [] : [{ event: 'gate.reviews_waived', block: id, by: 'config', reviewer: external }];
500
641
  /** @type {Array<() => Promise<{ok: boolean, reason?: string}>>} */
501
642
  const extraChecks = [reviews, report];
502
643
  // B63b: `context.require_coder_tests` in the run's config snapshot (never the live config)
@@ -530,10 +671,28 @@ export async function runBlock(args, deps = {}) {
530
671
  // does — also when a later step (the output, `--remove-worktree`) throws; it never changes
531
672
  // the exit code or stdout (`runHook` never throws)
532
673
  try {
674
+ // B70: each file that passed as reviewed_by_waiver is named before the closed line
675
+ // B71: an answered finding is named as answered; a file with only answered findings has no `by`
676
+ if (result.ok) {
677
+ for (const f of waivedAll) {
678
+ const parts = f.findings.length > 0 ? [`${f.findings.join(', ')}; by ${f.by}`] : [];
679
+ if (f.answered?.length) parts.push(`answered ${f.answered.join(', ')}`);
680
+ say(`block ${id}: ${f.file} reviewed_by_waiver (${parts.join('; ')})\n`);
681
+ }
682
+ }
533
683
  (result.ok ? say : err)(`block ${id} ${result.status}${result.event ? ` (${result.event})` : ''}${result.reason ? `: ${result.reason}` : ''}\n`);
534
684
  // B64a: a refused close also names itself on stdout under --json (its stderr is unchanged)
535
685
  if (json && !result.ok) emit({ block: id, ok: false, reason: result.reason ?? null, ...(end?.json ?? {}) });
536
- const closedJson = { block: id, event: result.status, status: result.status, reason: result.reason ?? null, close_event: result.event ?? null, ...(end?.json ?? {}) };
686
+ const closedJson = {
687
+ block: id,
688
+ event: result.status,
689
+ status: result.status,
690
+ reason: result.reason ?? null,
691
+ close_event: result.event ?? null,
692
+ ...(end?.json ?? {}),
693
+ // B70: only when a file passed as reviewed_by_waiver (otherwise the output is unchanged)
694
+ ...(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 } : {}) })) } : {}),
695
+ };
537
696
  if (!result.ok || !removeTree) {
538
697
  if (json && result.ok) emit(closedJson);
539
698
  return result.ok ? 0 : 1;
@@ -606,31 +765,139 @@ async function coderTests(reportFlag, id) {
606
765
  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)';
607
766
 
608
767
  /**
609
- * B52: `--no-require-reviews` lifts every per-file review check, so it is the human's alone — the
610
- * same rule as `autopilot approve`: a flag can be passed by any process (the orchestrator, a
611
- * script, the delegate's tooling), so the human confirms at a terminal and there is NO `--yes`.
612
- * No terminal — stdin AND stdout must both be TTYs (a piped stdout means nobody may see the
613
- * question) — ⇒ `usage` (exit 2); an active autopilot grant on the run ⇒ `autopilot-active`
614
- * (exit 1; the owner stops the grant first) — nothing is written either way. `deps.stdinIsTTY` /
615
- * `deps.stdoutIsTTY` inject each side; `deps.isTTY` injects both at once.
616
- * @param {string} runId @param {string} id
617
- * @param {{isTTY?: boolean, stdinIsTTY?: boolean, stdoutIsTTY?: boolean, ui?: {confirm: (o: {message: string, initialValue?: boolean}) => Promise<unknown>, isCancel: (v: unknown) => boolean}, now?: () => Date}} deps
618
- * @returns {Promise<'confirmed' | 'cancelled'>}
619
- * @throws {StateError} `usage`, `autopilot-active`
768
+ * B80: the refusal when `block waive` or `block reopen` has no terminal to ask the human at.
769
+ * @param {'waive' | 'reopen'} verb @returns {string}
620
770
  */
621
- async function confirmNoReviews(runId, id, deps) {
771
+ export const humanVerbNoTTY = (verb) => `block ${verb} needs the human at a terminal to confirm; nothing was recorded (there is no --yes)`;
772
+
773
+ /**
774
+ * @typedef {{isTTY?: boolean, stdinIsTTY?: boolean, stdoutIsTTY?: boolean, ui?: {confirm: (o: {message: string, initialValue?: boolean}) => Promise<unknown>, isCancel: (v: unknown) => boolean}, now?: () => Date}} ConfirmDeps
775
+ */
776
+
777
+ /**
778
+ * The ONE human confirm of the `block` verb (B52 `--no-require-reviews`, B80 `block waive` and
779
+ * `block reopen`): a flag or a verb can be run by any process (the orchestrator, a script, a
780
+ * coder), so the human answers a yes/no question at a terminal and there is NO `--yes`. No
781
+ * terminal — stdin AND stdout must both be TTYs (a piped stdout means nobody may see the
782
+ * question) — is `no-tty`, decided before anything is read or asked. `before` runs between the
783
+ * terminal check and the question (a refusal it throws means the human is never asked).
784
+ * `deps.stdinIsTTY` / `deps.stdoutIsTTY` inject each side, `deps.isTTY` both at once, `deps.ui`
785
+ * the prompt (default `@clack/prompts`). A "no" or a cancel (Ctrl-C) is `cancelled`.
786
+ * @param {ConfirmDeps} deps @param {string} message
787
+ * @param {() => Promise<unknown> | unknown} [before]
788
+ * @returns {Promise<'no-tty' | 'confirmed' | 'cancelled'>}
789
+ */
790
+ async function confirmAtTerminal(deps, message, before) {
622
791
  const stdinTTY = deps.stdinIsTTY ?? deps.isTTY ?? process.stdin.isTTY === true;
623
792
  const stdoutTTY = deps.stdoutIsTTY ?? deps.isTTY ?? process.stdout.isTTY === true;
624
- if (!(stdinTTY && stdoutTTY)) throw new StateError('usage', NO_REVIEWS_NO_TTY);
625
- const grant = await checkExpiry(runId, (deps.now ?? (() => new Date()))());
626
- if (grant.state === 'active') {
627
- 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})`);
628
- }
793
+ if (!(stdinTTY && stdoutTTY)) return 'no-tty';
794
+ if (before) await before();
629
795
  const ui = deps.ui ?? /** @type {any} */ (await import('@clack/prompts'));
630
- 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 });
796
+ const go = await ui.confirm({ message, initialValue: false });
631
797
  return ui.isCancel(go) || go !== true ? 'cancelled' : 'confirmed';
632
798
  }
633
799
 
800
+ /**
801
+ * B52: `--no-require-reviews` lifts every per-file review check, so it is the human's alone — the
802
+ * same rule as `autopilot approve` (`confirmAtTerminal`). No terminal ⇒ `usage` (exit 2); an
803
+ * active autopilot grant on the run ⇒ `autopilot-active` (exit 1; the owner stops the grant
804
+ * first) — nothing is written either way.
805
+ * @param {string} runId @param {string} id @param {ConfirmDeps} deps
806
+ * @returns {Promise<'confirmed' | 'cancelled'>}
807
+ * @throws {StateError} `usage`, `autopilot-active`
808
+ */
809
+ async function confirmNoReviews(runId, id, deps) {
810
+ const asked = await confirmAtTerminal(deps, `Close block ${id} of run ${runId} WITHOUT the per-file review checks? Only the owner may say yes.`, async () => {
811
+ const grant = await checkExpiry(runId, (deps.now ?? (() => new Date()))());
812
+ if (grant.state === 'active') {
813
+ 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})`);
814
+ }
815
+ });
816
+ if (asked === 'no-tty') throw new StateError('usage', NO_REVIEWS_NO_TTY);
817
+ return asked;
818
+ }
819
+
820
+ /**
821
+ * B80: `block waive` and `block reopen` are the human's alone (D1 = a): the human confirms at a
822
+ * terminal through `confirmAtTerminal`, exactly like `--no-require-reviews`, but an active
823
+ * autopilot grant changes nothing (autopilot waives through its own path, `by: autopilot`). The
824
+ * pre-checks (`reopenPrecheck`, `waivePrecheck`) run first, at a terminal, so the human is never
825
+ * asked about a waive or reopen the verb would refuse that way.
826
+ * @param {'waive' | 'reopen'} verb @param {string} runId @param {string} id
827
+ * @param {{finding?: string, file: string}} what @param {Record<string, any>} record @param {ConfirmDeps} deps
828
+ * @returns {Promise<'no-tty' | 'confirmed' | 'cancelled'>}
829
+ */
830
+ function confirmHumanVerb(verb, runId, id, what, record, deps) {
831
+ const message =
832
+ verb === 'waive'
833
+ ? `Waive finding ${what.finding} in ${what.file} of block ${id} (run ${runId})? Only the owner may say yes.`
834
+ : `Reopen the review of ${what.file} in block ${id} (run ${runId})? Only the owner may say yes.`;
835
+ return confirmAtTerminal(deps, message, async () => {
836
+ if (verb === 'reopen') await reopenPrecheck(record, runId, id, what.file);
837
+ else await waivePrecheck(record, runId, id, /** @type {string} */ (what.finding), what.file);
838
+ });
839
+ }
840
+
841
+ /**
842
+ * Does the block own `file`, by the rule both verbs use (`ownsAt` with the workspace's prefix
843
+ * inside its repo)?
844
+ * @param {Record<string, any>} record @param {Record<string, any>} entry @param {string} file
845
+ * @returns {Promise<boolean>}
846
+ */
847
+ async function blockOwnsFile(record, entry, file) {
848
+ const real = async (/** @type {string} */ p) => realpath(p).catch(() => path.resolve(p));
849
+ const mainTop = await real(await repoTop(record.workspace));
850
+ const wsPrefix = path.relative(mainTop, await real(record.workspace)).split(path.sep).join('/');
851
+ const owned = Array.isArray(entry.owned_files) ? entry.owned_files : [];
852
+ return ownsAt(wsPrefix)(owned, file, entry.owned_kinds);
853
+ }
854
+
855
+ /**
856
+ * B80: the checks a reopen must pass BEFORE the human is asked, in `reopenFile`'s order: the
857
+ * `--file` form (`checkFileForm`, the reopen rule: `usage`), the block (`openBlockEntry`:
858
+ * `unknown_block`, `reopen-block-not-open`) and ownership (`reopen-not-owned`, the same message).
859
+ * The state checks that read the key and the ledger stay the verb's.
860
+ * @param {Record<string, any>} record @param {string} runId @param {string} id @param {string} file
861
+ * @returns {Promise<void>}
862
+ * @throws {StateError}
863
+ */
864
+ async function reopenPrecheck(record, runId, id, file) {
865
+ checkFileForm(file, { normalized: true });
866
+ const entry = openBlockEntry(record, runId, id);
867
+ if (!(await blockOwnsFile(record, entry, file))) throw new StateError('reopen-not-owned', `reopen-not-owned: block ${id} does not own ${file}; nothing was recorded`);
868
+ }
869
+
870
+ /**
871
+ * B80: the checks a waive must pass BEFORE the human is asked, so the human is never asked about a
872
+ * waive the verb would refuse, in `waiveFinding`'s order: the finding id and `--file` have the
873
+ * forms `waiveFinding` takes (`checkFindingId`, `checkFileForm`: `usage`, the same helpers); the
874
+ * block is in the run (`unknown_block`); the block owns the file
875
+ * (`waive-not-owned`, the reopen's ownership rule through `ownsAt`); and, except for the proof
876
+ * finding (`proof`, which no review row names), a review row of this run and block for the file
877
+ * names the id (`waive-unknown-finding`): `review.triage` / `review.late_finding` `finding`, a
878
+ * `review.result` `open` id (as the gate reads it, `openFindings`) or a `review.cap` `open` id.
879
+ * @param {Record<string, any>} record @param {string} runId @param {string} id
880
+ * @param {string} finding @param {string} file
881
+ * @returns {Promise<void>}
882
+ * @throws {StateError}
883
+ */
884
+ async function waivePrecheck(record, runId, id, finding, file) {
885
+ checkFindingId(finding);
886
+ checkFileForm(file, { normalized: false });
887
+ const entry = record.blocks?.[id];
888
+ if (!entry) throw new StateError('unknown_block', `block ${id} is not in run ${runId}`);
889
+ if (!(await blockOwnsFile(record, entry, file))) throw new StateError('waive-not-owned', `waive-not-owned: block ${id} does not own ${file}; nothing was recorded`);
890
+ if (finding === PROOF_FINDING) return;
891
+ const named = (await readAllRows(record.project)).some((r) => {
892
+ if (r?.run !== runId || r.block !== id || r.file !== file) return false;
893
+ if (r.event === 'review.triage' || r.event === 'review.late_finding') return r.finding === finding;
894
+ if (r.event === 'review.result') return Array.isArray(r.open) && openFindings(r.open).some((o) => o.id === finding);
895
+ if (r.event === 'review.cap') return Array.isArray(r.open) && r.open.includes(finding);
896
+ return false;
897
+ });
898
+ 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`);
899
+ }
900
+
634
901
  /**
635
902
  * B59: the rules lane for a brief-mode block — `fallbackLane` over the facts of its owned entries,
636
903
  * 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
  }