@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
@@ -44,6 +44,11 @@ export const IMMUTABLE_KEYS = Object.freeze({
44
44
  // B64b: the block-end hook runs a command on the owner's machine at every block end, so only
45
45
  // the owner sets it, before the run starts
46
46
  'hooks.on_block_end': 'it runs a command on the owner\'s machine at every block end; fixed by the owner before the run starts',
47
+ // B87: the runner argv proves the coder's own work, so only the owner sets it, before the run starts
48
+ 'proof.runners': 'the commands that prove a block are fixed by the owner before the run starts',
49
+ // B93: the coder sandbox profile widens what the coder may write, so only the owner sets it,
50
+ // before the run starts (a coder can never own `.code-forge.yml`)
51
+ 'coder.sandbox': 'the coder sandbox widens what the coder may write; fixed by the owner before the run starts',
47
52
  });
48
53
 
49
54
  /** @param {unknown} v @returns {boolean} what `JSON.stringify` drops from an object. */
@@ -235,3 +240,37 @@ export function blockEndHook(record) {
235
240
  if (!Number.isInteger(timeoutS) || timeoutS < HOOK_TIMEOUT_MIN_S || timeoutS > HOOK_TIMEOUT_MAX_S) return null;
236
241
  return { argv: [...argv], timeoutS };
237
242
  }
243
+
244
+ /**
245
+ * B87: `proof.runners` of the run-start config snapshot (never the live config), as a deep copy
246
+ * (entries and argv arrays), or null when the key is absent or the record is from before
247
+ * snapshots. `proof/runners.mjs` checks each entry again before it runs one (fail closed).
248
+ * @param {Record<string, any>} record
249
+ * @returns {unknown[] | null}
250
+ * @throws {StateError} `config-snapshot` when the snapshot in force does not match its hash;
251
+ * `runner-invalid` when the stored value is not a list, or is an empty one.
252
+ */
253
+ export function proofRunners(record) {
254
+ const runners = configInForce(record)?.proof?.runners;
255
+ if (runners === undefined || runners === null) return null;
256
+ if (!Array.isArray(runners)) throw new StateError('runner-invalid', `run ${record.run_id}: proof.runners in the run snapshot is not a list`);
257
+ // `validate` refuses an empty list (minItems 1); a record that holds one anyway is refused too
258
+ if (runners.length === 0) throw new StateError('runner-invalid', `run ${record.run_id}: proof.runners in the run snapshot is an empty list`);
259
+ // a deep copy: a caller that changes an entry or an argv array never changes the snapshot
260
+ return structuredClone(runners);
261
+ }
262
+
263
+ /**
264
+ * B93: `coder.sandbox` of the run-start config snapshot (never the live config). A record from
265
+ * before snapshots has no run-start value: `{snapshot: false}`. Otherwise `{snapshot: true,
266
+ * sandbox}`, where `sandbox` is a deep copy of the value, or null when the snapshot has no key.
267
+ * @param {Record<string, any>} record
268
+ * @returns {{snapshot: false} | {snapshot: true, sandbox: Record<string, any> | null}}
269
+ * @throws {StateError} `config-snapshot` when the snapshot in force does not match its hash.
270
+ */
271
+ export function coderSandbox(record) {
272
+ const config = configInForce(record);
273
+ if (config === null) return { snapshot: false };
274
+ const sandbox = config.coder?.sandbox;
275
+ return { snapshot: true, sandbox: sandbox === undefined || sandbox === null ? null : structuredClone(sandbox) };
276
+ }
package/src/state/run.mjs CHANGED
@@ -269,6 +269,10 @@ export async function reloadRun({ runId, config, readPending = () => [], effecti
269
269
  if (config.context?.require_coder_tests === true) frozen.push('context.require_coder_tests');
270
270
  // B64b: and never had a block-end hook
271
271
  if (config.hooks?.on_block_end !== undefined) frozen.push('hooks.on_block_end');
272
+ // B87: and never had a proof runner
273
+ if (config.proof?.runners !== undefined) frozen.push('proof.runners');
274
+ // B93: and never had a coder sandbox profile
275
+ if (config.coder?.sandbox !== undefined) frozen.push('coder.sandbox');
272
276
  }
273
277
  if (frozen.length > 0) {
274
278
  const why = frozen.map((key) => `${key} cannot change mid-run (${IMMUTABLE_KEYS[/** @type {keyof typeof IMMUTABLE_KEYS} */ (key)]})`);
@@ -276,6 +276,7 @@ export const FORBIDDEN = Object.freeze(
276
276
  * They are NOT part of `FORBIDDEN` (whose rendered count other blocks pin); `mergeForbidden()`
277
277
  * always appends them, so every list built for a coder carries them.
278
278
  * - `code-forge block waive` — waiving a review finding is human-only;
279
+ * - `code-forge block reopen` (B72) — restarting a stopped or lost file's review is human-only;
279
280
  * - `code-forge autopilot` (any subcommand, B45) — the owner's grant to a delegate;
280
281
  * - `--no-require-reviews` — the human's way out of `block close`'s per-file review check;
281
282
  * - `--remove-worktree` (B62a) — `block close` removing the block's worktree, the orchestrator's
@@ -291,7 +292,16 @@ export const CODER_ONLY_FORBIDDEN = Object.freeze(
291
292
  ['code-forge', 'block', 'waive'],
292
293
  ['forge', 'block', 'waive'],
293
294
  ],
294
- description: 'Waive a review finding (forge block waive) — human-only, run by the orchestrator after the human said so (§4.9)',
295
+ description: 'Waive a review finding (forge block waive) — human-only, typed by the human in their own terminal (§4.9, B80)',
296
+ },
297
+ {
298
+ id: 'code-forge-block-reopen-from-coder',
299
+ kind: 'anyPrefix',
300
+ prefixes: [
301
+ ['code-forge', 'block', 'reopen'],
302
+ ['forge', 'block', 'reopen'],
303
+ ],
304
+ description: 'Reopen the review of a stopped or lost file (forge block reopen) — human-only, typed by the human in their own terminal (B72, B80)',
295
305
  },
296
306
  {
297
307
  id: 'code-forge-autopilot-from-coder',
package/src/util/tmp.mjs CHANGED
@@ -130,18 +130,30 @@ export function tmpBase(root) {
130
130
  }
131
131
 
132
132
  /**
133
- * Create (if needed) the run root `<base>/<runId>/`, record this process as its owner, and
134
- * return its real absolute path.
133
+ * The run root's path `<base>/<runId>`, without creating anything (B69: doctor looks for old
134
+ * review-state files there). The same run id check as {@link runRoot}.
135
135
  * @param {string} runId
136
136
  * @param {{root?: string}} [opts]
137
137
  * @returns {string}
138
+ * @throws {TypeError} a bad run id, or a `root` that is not absolute.
138
139
  */
139
- export function runRoot(runId, opts = {}) {
140
+ export function runRootPath(runId, opts = {}) {
140
141
  if (typeof runId !== 'string' || !RUN_ID.test(runId)) {
141
142
  throw new TypeError('tmp: runId must be one path segment of letters, digits, ".", "_" or "-"');
142
143
  }
144
+ return path.join(tmpBase(opts.root), runId);
145
+ }
146
+
147
+ /**
148
+ * Create (if needed) the run root `<base>/<runId>/`, record this process as its owner, and
149
+ * return its real absolute path.
150
+ * @param {string} runId
151
+ * @param {{root?: string}} [opts]
152
+ * @returns {string}
153
+ */
154
+ export function runRoot(runId, opts = {}) {
155
+ const dir = runRootPath(runId, opts);
143
156
  const base = tmpBase(opts.root);
144
- const dir = path.join(base, runId);
145
157
  // A parallel sweep that claimed a dead root of this name renames it away (see the claim rule)
146
158
  // between our mkdir and our write: the directory is then "missing" or the write fails with
147
159
  // ENOENT, and the retry creates a fresh directory in its place. A tombstone itself can never be
@@ -17,6 +17,13 @@
17
17
  * - `stopped` (`review_cap`, `l3_patch_exhausted`, `split_required`) ⇒ no session: the result
18
18
  * repeats the stop — only the human moves on (§4.11 rule 5). A `split_required` result (round 1
19
19
  * or a recheck stop) carries `tokens_in` and `budget` (B54).
20
+ * A state that does not load is refused with no session: `review-state-tampered` (a wrong file or
21
+ * anchor) or `review-state-lost` (the file is gone and its anchor has no `state_body`). A file
22
+ * that is gone but whose anchor carries the signed body is `restored` (B68): saved as a new seq,
23
+ * one signed `review.state_restored {block, file, seq, from_seq}` row, then the round runs as if
24
+ * it had loaded. B69: the state file lives under the code-forge home (`runs/<run>/review-state/`),
25
+ * not the temp root; a valid file still at the old temp path is moved there on its first load
26
+ * (status `ok`, no restored row).
20
27
  * The round's outcome: `approved: true` ONLY when the loop is `complete` AND its signed
21
28
  * `review.approved` row (keyed `run, block, file, content_hash` = the ticket's hash) was written;
22
29
  * else `findings` = the open fix list, `next` = what the orchestrator does (`fix` at `level`,
@@ -65,6 +72,20 @@
65
72
  * ticket is done); `review-file` on the same content is a fresh attempt every time (`retry: true`).
66
73
  * The tree is passed as `contentRoot`: `repoRoot` stays the main tree, and the fix-loop state
67
74
  * stays under the run root.
75
+ * B72: a verified `review.reopened {by: human}` row of this run for the file whose `at_seq` is the
76
+ * file's latest anchor seq (`reopenRowFor`, `review/reopen.mjs`) restarts a `stopped` file whose
77
+ * content changed since the stop, or a `lost` one: a new state (round 1, a FULL review with no
78
+ * `reviewed_content`, the level from the run record), saved at `at_seq + 1`, so the row works
79
+ * once; no `review.state_restored` row then. The block's L3 rung stays spent (it is read from the
80
+ * anchors). A row that does not verify, of another run, not `by: human`, at another seq, or a
81
+ * stopped file on the stop's own content changes nothing: the stop (or the lost refusal) repeats.
82
+ * Every stop records the ticket's content hash as the state's `stop_content_hash` (signed with
83
+ * the state), the content `block reopen` and this check compare against.
84
+ * B71: a ticket with `kind: 'answers'` (`review-file --answers`) runs no fix-loop round: after the
85
+ * same run-record, tree and mismatch checks it goes to `reviewAnswers` (`review/answers.mjs`) —
86
+ * one fresh L2 session that judges whether the answering file resolves the named open findings of
87
+ * the source, with signed `review.answered` rows for the resolved ones; the source's fix-loop
88
+ * state is neither read for a round nor saved.
68
89
  */
69
90
 
70
91
  import { realpathSync, rmSync } from 'node:fs';
@@ -75,9 +96,11 @@ import { planAndRecord, tierOf } from '../review/budget.mjs';
75
96
  import { budgetFor } from '../review/packet.mjs';
76
97
  import { ContextError, reviewDigest } from '../context/pack.mjs';
77
98
  import { reviewFile, rulesRisk } from '../review/engine.mjs';
99
+ import { reviewAnswers } from '../review/answers.mjs';
78
100
  import { peerDiffReader } from '../review/moved.mjs';
79
101
  import { computeFileSet, ownsFile } from '../gates/scope.mjs';
80
102
  import { extraRoundsFor, newFileState, reopenForExtraRound, runRound } from '../review/fixloop.mjs';
103
+ import { reopenRowFor } from '../review/reopen.mjs';
81
104
  import { readRun } from '../state/run.mjs';
82
105
  import { StateError } from '../state/paths.mjs';
83
106
  import { blockTree, worktreeRecord } from '../proof/worktree.mjs';
@@ -85,8 +108,9 @@ import { ledgerSchemaHistory } from '../review/schema-fallback.mjs';
85
108
  import { verifyRow } from '../state/signer.mjs';
86
109
  import { redact } from '../util/redact.mjs';
87
110
  import { keyedLock } from '../util/locks.mjs';
88
- import { blockAnchors, blockRungUsed, loadState, saveState } from './review-state.mjs';
89
- import { assertTicketId, WorkerError } from './ticket.mjs';
111
+ import { blockAnchors, blockRungUsed, loadState, RESTORED_EVENT, saveState } from './review-state.mjs';
112
+ import { assertAnswers } from './queue.mjs';
113
+ import { assertTicketId, contentHash, readRegularFileNoFollow, WorkerError } from './ticket.mjs';
90
114
 
91
115
  /**
92
116
  * @typedef {object} ReviewContext
@@ -94,7 +118,8 @@ import { assertTicketId, WorkerError } from './ticket.mjs';
94
118
  * @property {string} [contentRoot] - B62b: the tree the ticket's content is read in (the block's
95
119
  * worktree); `repoRoot` when absent.
96
120
  * @property {string} runId
97
- * @property {string} runRootDir - the run's temp root (packets and session dirs live under it).
121
+ * @property {string} runRootDir - the run's temp root (packets and session dirs live under it; the
122
+ * review state no longer does — B69 — it is only read there once, to move an old file).
98
123
  * @property {Record<string, any>} cfg - the project config.
99
124
  * @property {string | null} jevKey - resolved once by the worker through B2's chain; never print.
100
125
  * @property {(opts: Record<string, any>) => Promise<Record<string, any>>} spawn - one isolated
@@ -116,6 +141,7 @@ import { assertTicketId, WorkerError } from './ticket.mjs';
116
141
  * @property {string} engine - `adaptive` or `consensus`.
117
142
  * @property {Array<Record<string, any>>} sessions - one summary per session run.
118
143
  * @property {string} [reason] - why it is not `reviewed` (stub-guard reason, `needs_file-refused`, …).
144
+ * @property {string} [message] - a refusal's human-readable explanation (`review-state-lost`).
119
145
  * @property {Array<Record<string, any>>} [findings] - the open fix list (`reviewed` / `stopped`).
120
146
  * @property {number} [round] - the fix-loop round this ticket ran (patch_check rounds not counted).
121
147
  * @property {string} [kind] - `full`, `recheck` or `patch_check`.
@@ -338,6 +364,14 @@ export async function reviewTicket(ticket, ctx) {
338
364
  }
339
365
  /** @type {ReviewContext} */
340
366
  const tctx = { ...ctx, contentRoot };
367
+ if (ticket.kind === 'answers') {
368
+ // B71: an answers ticket is no review round: one answers session, the fix-loop state untouched
369
+ try {
370
+ return await reviewAnswers(ticket, tctx, { entry, workDir, io: { readFile: readRegularFileNoFollow, loadState, assertAnswers } });
371
+ } finally {
372
+ rmSync(workDir, { recursive: true, force: true });
373
+ }
374
+ }
341
375
  const factsExcerpt = factsFor(entry, ctx.cfg);
342
376
  const peerDiffs = blockPeerDiffs(contentRoot, { base: entry.base, owned: entry.owned, ownedKinds: entry.ownedKinds, file: ticket.file });
343
377
  try {
@@ -413,6 +447,9 @@ export function blockPeerDiffs(repoRoot, { base, owned, ownedKinds, file }) {
413
447
  */
414
448
  export const blockLockKey = (runId, block) => `block:${runId}\0${block}`;
415
449
 
450
+ /** B68: the `review-state-lost` refusal's message (the file is gone and the ledger has no body). */
451
+ export const LOST_MESSAGE = 'the review state file is gone and the ledger cannot rebuild it (its anchor carries no state_body); the file is not reviewed again from round 1 — the owner can run `code-forge block reopen`';
452
+
416
453
  /**
417
454
  * Whether the block's one L3 rung is taken: a verified anchor says so (`blockRungUsed`), OR another
418
455
  * file of the block has a saved state whose round decided `next: patch` — that round took the rung,
@@ -429,11 +466,12 @@ export function rungTaken(where) {
429
466
  for (const file of siblings) {
430
467
  let other;
431
468
  try {
432
- other = loadState({ ...where, file });
469
+ other = loadState({ ...where, file, migrate: false }); // B69: a sibling's old file is read, never moved here
433
470
  } catch {
434
471
  return { status: 'ok', used: true };
435
472
  }
436
- if (other.status !== 'ok' || other.state?.next?.action === 'patch') return { status: 'ok', used: true };
473
+ // a sibling that would be restored (B68) counts by its rebuilt state; lost or tampered fails closed
474
+ if ((other.status !== 'ok' && other.status !== 'restored') || other.state?.next?.action === 'patch') return { status: 'ok', used: true };
437
475
  }
438
476
  return rung;
439
477
  }
@@ -488,11 +526,34 @@ async function fixLoopRound(ticket, ctx, { base, level, owned, ownedKinds, block
488
526
  await recordBlockBudget(ticket, ctx, { base, owned, ownedKinds });
489
527
  }
490
528
  const where = { runRootDir: ctx.runRootDir, runId: ctx.runId, block: ticket.block, file: ticket.file, key, rows };
491
- const loaded = loadState(where);
529
+ const loaded = loadState({ ...where, contentRoot: ctx.contentRoot ?? ctx.repoRoot });
492
530
  const rung = rungTaken(where);
493
531
  if (loaded.status === 'tampered' || rung.status === 'tampered') return { outcome: /** @type {ReviewOutcome} */ ({ status: 'refused', reason: 'review-state-tampered', approved: false, engine: 'adaptive', sessions: [] }) };
532
+ // B72: the human's `block reopen` row for this file's latest anchor seq restarts a stopped
533
+ // (on changed content) or lost file: a new state, saved at that seq + 1 — the row works once
534
+ // (`rows`: this run's rows read above under the block lock; `key`: the run key from opts)
535
+ const reopenAt = {
536
+ runId: ctx.runId,
537
+ block: ticket.block,
538
+ file: ticket.file,
539
+ key,
540
+ status: loaded.status,
541
+ state: loaded.state,
542
+ seq: loaded.seq,
543
+ contentHash: ticket.content_hash,
544
+ };
545
+ const reopened = reopenRowFor(rows, reopenAt) !== null;
546
+ if (loaded.status === 'lost' && !reopened) return { outcome: /** @type {ReviewOutcome} */ ({ status: 'refused', reason: 'review-state-lost', message: LOST_MESSAGE, approved: false, engine: 'adaptive', sessions: [] }) };
547
+ let seq = loaded.seq;
548
+ if (loaded.status === 'restored' && loaded.state !== null && !reopened) {
549
+ // B68: the lost file is rebuilt from the anchor's signed body; saved as a new seq, then recorded
550
+ const writeRow = /** @type {(row: Record<string, any>) => Promise<unknown>} */ (ctx.writeRow);
551
+ await saveState({ ...where, state: loaded.state, seq, writeRow, contentRoot: ctx.contentRoot ?? ctx.repoRoot });
552
+ seq += 1;
553
+ await writeRow({ event: RESTORED_EVENT, block: ticket.block, file: ticket.file, seq, from_seq: loaded.seq });
554
+ }
494
555
  const rungUsed = rung.used;
495
- let state = loaded.state;
556
+ let state = reopened ? null : loaded.state;
496
557
  if (state === null || state.status === 'complete') state = newFileState({ file: ticket.file, level, l3RungUsed: rungUsed });
497
558
  state.l3_rung_used = state.l3_rung_used || rungUsed;
498
559
  // B47: an autopilot extra round raises the file's cap; a file stopped at the old cap reopens
@@ -511,7 +572,7 @@ async function fixLoopRound(ticket, ctx, { base, level, owned, ownedKinds, block
511
572
  state.l3_rung_used = true; // the orchestrator ran the block's one L3 patch before this ticket
512
573
  }
513
574
  const fixed = state;
514
- const round = () => playRound(ticket, ctx, { state: fixed, kind, where, seq: loaded.seq, base, blockKindOf, workDir, factsExcerpt, rulesDigest, extraRounds, peerDiffs });
575
+ const round = () => playRound(ticket, ctx, { state: fixed, kind, where, seq, base, blockKindOf, workDir, factsExcerpt, rulesDigest, extraRounds, peerDiffs });
515
576
  // a round that could take the rung keeps the block lock until its state is saved
516
577
  if (mayTakeRung(fixed, kind, ctx.cfg, rungUsed)) return { outcome: await round() };
517
578
  return { round };
@@ -606,7 +667,12 @@ async function playRound(ticket, ctx, { state, kind, where, seq, base, blockKind
606
667
  state.pending_kind = kind;
607
668
  }
608
669
  }
609
- await saveState({ ...where, state, seq, writeRow: /** @type {(row: Record<string, any>) => Promise<unknown>} */ (ctx.writeRow) });
670
+ // B72: a stop (any reason) records the content it reviewed — the ticket's — in the signed state,
671
+ // so `block reopen` and the worker know which content this stop saw (`stopContentHash`)
672
+ const stamped = /** @type {Record<string, any>} */ (state);
673
+ if (state.status === 'stopped') stamped.stop_content_hash = ticket.content_hash;
674
+ else delete stamped.stop_content_hash;
675
+ await saveState({ ...where, state, seq, writeRow: /** @type {(row: Record<string, any>) => Promise<unknown>} */ (ctx.writeRow), contentRoot: ctx.contentRoot ?? ctx.repoRoot });
610
676
 
611
677
  const eng = /** @type {Record<string, any> | null} */ (engineOutcome);
612
678
  const head = { ...loopFields(state, kind), engine: eng?.engine ?? 'adaptive', sessions: eng?.sessions ?? rechecks };
@@ -39,7 +39,7 @@ import path from 'node:path';
39
39
  import { fileURLToPath } from 'node:url';
40
40
  import { expiryChecks } from '../autopilot/limits.mjs';
41
41
  import { loadProjectConfig } from '../config/load.mjs';
42
- import { createDefaultKeyStore, resolveKey } from '../keys/store.mjs';
42
+ import { cacheHoursFrom, createDefaultKeyStore, resolveKey } from '../keys/store.mjs';
43
43
  import { appendRow, readAllRows } from '../ledger/write.mjs';
44
44
  import { spawnSession } from '../session/spawn.mjs';
45
45
  import { snapshotFor } from '../state/config-snapshot.mjs';
@@ -47,12 +47,13 @@ import { readRun, reattachWorker } from '../state/run.mjs';
47
47
  import { loadKey, signRow } from '../state/signer.mjs';
48
48
  import { logWarning } from '../util/error-log.mjs';
49
49
  import { keyedLock } from '../util/locks.mjs';
50
- import { writeSafe } from '../util/redact.mjs';
50
+ import { redact, writeSafe } from '../util/redact.mjs';
51
51
  import { runRoot, setRunRoot } from '../util/tmp.mjs';
52
52
  import { openFindings } from '../review/gate-check.mjs';
53
+ import { countLines, RouteError, routeTicket } from '../review/route.mjs';
53
54
  import { canonicalPath, contentRootFor, reviewTicket } from './engine.mjs';
54
55
  import { announceWorker, beatWorker, HEARTBEAT_MS, liveWorker, pendingTickets, readTicket, retractWorker, writeResult } from './queue.mjs';
55
- import { contentHash, DELETED_HASH, repoRootOf, WorkerError } from './ticket.mjs';
56
+ import { DELETED_HASH, hashBytes, readRegularFileNoFollow, repoRootOf, WorkerError } from './ticket.mjs';
56
57
 
57
58
  /** Name of the key the worker resolves for System 1 (`keys.jev` / `system1.key` reference). */
58
59
  export const JEV_KEY_NAME = 'jev';
@@ -136,16 +137,37 @@ export function sessionEnv(env, secrets) {
136
137
  /**
137
138
  * Resolve the Jev key through B2's chain (env → store → 1Password). A missing key is not an error:
138
139
  * System 1 then falls back (`system1.fallback`, B3). The value is registered for redaction by B2.
140
+ * A refresh from 1Password is cached for the config's `keys.cache_hours` (B84: the run's snapshot;
141
+ * a snapshot from before the key has none ⇒ 8 h).
139
142
  * @param {Record<string, any>} cfg
140
- * @param {{store: import('../keys/store.mjs').KeyStore, env: NodeJS.ProcessEnv, opRead?: any}} deps
143
+ * @param {{store: import('../keys/store.mjs').KeyStore, env: NodeJS.ProcessEnv, opRead?: any, now?: number}} deps
141
144
  * @returns {Promise<string | null>}
142
145
  */
143
- export async function resolveJevKey(cfg, { store, env, opRead }) {
146
+ export async function resolveJevKey(cfg, { store, env, opRead, now }) {
144
147
  const ref = cfg?.system1?.key ?? cfg?.keys?.[JEV_KEY_NAME];
145
- const res = await resolveKey(JEV_KEY_NAME, { store, env, ref: typeof ref === 'string' ? ref : undefined, ...(opRead ? { opRead } : {}) });
148
+ const res = await resolveKey(JEV_KEY_NAME, {
149
+ store,
150
+ env,
151
+ ref: typeof ref === 'string' ? ref : undefined,
152
+ cacheHours: cacheHoursFrom(cfg).hours,
153
+ ...(opRead ? { opRead } : {}),
154
+ ...(now !== undefined ? { now } : {}),
155
+ });
146
156
  return res.value;
147
157
  }
148
158
 
159
+ /**
160
+ * B90: an unexpected failure inside a named worker step (today: `routing`). The ticket still ends
161
+ * `worker-error`; its result's `detail` says `<step>: <original error text>` (redacted).
162
+ */
163
+ class StepError extends Error {
164
+ /** @param {string} step @param {unknown} cause */
165
+ constructor(step, cause) {
166
+ super(`${step}: ${cause instanceof Error ? `${cause.name}: ${cause.message}` : String(cause)}`, { cause });
167
+ this.step = step;
168
+ }
169
+ }
170
+
149
171
  /** The fix-loop fields of an engine outcome the result carries (B12c, §4.11). */
150
172
  const LOOP_FIELDS = Object.freeze(['round', 'kind', 'level', 'next', 'trigger', 'stopped', 'late']);
151
173
 
@@ -282,14 +304,51 @@ export async function createWorker(opts, deps = {}) {
282
304
  }
283
305
 
284
306
  /**
285
- * One ticket through the engine hook with `tcfg` (the ticket's config snapshot): the unsigned result.
307
+ * One ticket through the engine hook with the ticket's config snapshot, routed by size (B90): the unsigned result.
308
+ * @param {string} id - the ticket id (B90: a retried ticket never writes its route rows twice).
286
309
  * @param {import('./queue.mjs').Ticket} ticket @param {Record<string, any>} base
287
- * @param {Record<string, any>} tcfg
310
+ * @param {Record<string, any>} snapshotCfg - the ticket's config snapshot, before B90 routing.
288
311
  * @param {string} contentRoot - B62b: the tree the content was hashed in, and is reviewed in.
312
+ * @param {{lines: number, deleted: boolean}} size - B90: the line count of the hashed bytes.
289
313
  * @returns {Promise<Record<string, any>>}
290
314
  */
291
- async function reviewWith(ticket, base, tcfg, contentRoot) {
292
- const timeoutS = Number.isInteger(tcfg?.review?.session_timeout_s) && tcfg.review.session_timeout_s > 0 ? tcfg.review.session_timeout_s : DEFAULT_SESSION_TIMEOUT_S;
315
+ async function reviewWith(id, ticket, base, snapshotCfg, contentRoot, size) {
316
+ const baseTimeoutS = Number.isInteger(snapshotCfg?.review?.session_timeout_s) && snapshotCfg.review.session_timeout_s > 0 ? snapshotCfg.review.session_timeout_s : DEFAULT_SESSION_TIMEOUT_S;
317
+ // B90: `review.routes` picks the model, second level and timeout of every session of this
318
+ // ticket from its size, once, before the first session. No route ⇒ the snapshot object itself.
319
+ // A route list that cannot be used is `unavailable` — never a review on a model it did not name.
320
+ let routed;
321
+ try {
322
+ routed = routeTicket(snapshotCfg, { lines: size.lines, deleted: size.deleted, timeoutS: baseTimeoutS });
323
+ } catch (err) {
324
+ // only a route list that cannot be used is `route-invalid`; anything else is a real failure
325
+ // and goes to runOne (`worker-error`), named as a routing-step failure with its own text
326
+ if (!(err instanceof RouteError)) throw new StepError('routing', err);
327
+ return { ...base, status: 'unavailable', reason: 'route-invalid', approved: false };
328
+ }
329
+ const tcfg = routed.cfg;
330
+ const timeoutS = routed.timeoutS;
331
+ // the signed `review.route` row comes before the first session; a row that cannot be written
332
+ // is `unavailable` (a routed review the ledger cannot explain never runs). One row per routed
333
+ // level per ticket: a retried ticket (same id) skips the levels whose row the ledger already has.
334
+ // `runId` is the run this ticket belongs to (reviewOne refuses `ticket.run !== runId` as
335
+ // other-run), and a restarted worker serves the same run id (startWorker: `opts.runId`), so the
336
+ // check holds across workers of that run.
337
+ if (routed.rows.length > 0) {
338
+ try {
339
+ const have = new Set(
340
+ (await readRows())
341
+ .filter((r) => r?.event === 'review.route' && r.run === runId && r.ticket === id)
342
+ .map((r) => r.level),
343
+ );
344
+ for (const row of routed.rows) {
345
+ if (have.has(row.level)) continue;
346
+ await ledger({ ...row, ticket: id, block: ticket.block, file: ticket.file, content_hash: ticket.content_hash });
347
+ }
348
+ } catch {
349
+ return { ...base, status: 'unavailable', reason: 'route-row', approved: false };
350
+ }
351
+ }
293
352
  /** @type {import('./engine.mjs').ReviewContext} */
294
353
  const ctx = {
295
354
  repoRoot,
@@ -318,6 +377,7 @@ export async function createWorker(opts, deps = {}) {
318
377
  approved: outcome.approved === true,
319
378
  engine: outcome.engine,
320
379
  ...(typeof outcome.reason === 'string' ? { reason: outcome.reason } : {}),
380
+ ...(typeof outcome.message === 'string' ? { message: outcome.message } : {}),
321
381
  ...(Array.isArray(outcome.findings) ? { findings: outcome.findings } : {}),
322
382
  ...packetSize(outcome),
323
383
  ...loopResult(outcome),
@@ -381,22 +441,27 @@ export async function createWorker(opts, deps = {}) {
381
441
  if (canonicalPath(hashedIn) !== canonicalPath(contentRoot)) return { ...base, status: 'unavailable', reason: 'worktree-mismatch', approved: false };
382
442
  /** @type {string} */
383
443
  let current;
444
+ /** @type {Buffer | null} */
445
+ let bytes;
384
446
  try {
385
- current = contentHash(contentRoot, ticket.file);
447
+ // B90: one read — the bytes hashed are the bytes whose lines route the review
448
+ bytes = readRegularFileNoFollow(contentRoot, ticket.file);
449
+ current = hashBytes(bytes);
386
450
  } catch (err) {
387
451
  // the path no longer passes the on-disk check, or the file cannot be read
388
452
  const reason = err instanceof WorkerError ? err.code : 'unreadable';
389
453
  return { ...base, status: 'refused', reason, approved: false };
390
454
  }
391
455
  if (current === DELETED_HASH && ticket.content_hash !== DELETED_HASH) return { ...base, status: 'stale', reason: 'missing-file', approved: false };
392
- if (current !== ticket.content_hash) return { ...base, status: 'stale', approved: false };
456
+ // B71: an answers ticket names why (its source moved); a review ticket keeps no reason
457
+ if (current !== ticket.content_hash) return { ...base, status: 'stale', ...(ticket.kind === 'answers' ? { reason: 'source-changed' } : {}), approved: false };
393
458
  let ticketCfg;
394
459
  try {
395
460
  ticketCfg = await configFor(id);
396
461
  } catch {
397
462
  return { ...base, status: 'unavailable', reason: 'config-snapshot', approved: false };
398
463
  }
399
- return reviewWith(ticket, base, ticketCfg, contentRoot);
464
+ return reviewWith(id, ticket, base, ticketCfg, contentRoot, { lines: countLines(bytes), deleted: bytes === null });
400
465
  }
401
466
 
402
467
  /**
@@ -443,9 +508,11 @@ export async function createWorker(opts, deps = {}) {
443
508
  async function runOne(id) {
444
509
  try {
445
510
  return (await processTicket(id)) !== null ? 1 : 0;
446
- } catch {
511
+ } catch (err) {
512
+ // B90: a failure a step names (StepError) keeps its step and original text, redacted
513
+ const detail = err instanceof StepError ? { detail: redact(err.message) } : {};
447
514
  try {
448
- await complete(id, { event: 'review.result', status: 'unavailable', reason: 'worker-error', approved: false });
515
+ await complete(id, { event: 'review.result', status: 'unavailable', reason: 'worker-error', ...detail, approved: false });
449
516
  return 1;
450
517
  } catch {
451
518
  poisoned.add(id);