@codedology/code-forge 0.1.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 (184) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/LICENSE +21 -0
  3. package/README.md +127 -0
  4. package/bin/code-forge.mjs +145 -0
  5. package/docs/README.md +37 -0
  6. package/docs/reference/blocks.json +288 -0
  7. package/docs/reference/config.md +162 -0
  8. package/examples/first-use/README.md +11 -0
  9. package/examples/first-use/clamp.acceptance.yml +8 -0
  10. package/examples/first-use/clamp.brief.md +16 -0
  11. package/examples/first-use/coder/src/math.mjs +31 -0
  12. package/examples/first-use/coder/test/clamp.test.mjs +26 -0
  13. package/examples/node-lib/.env.example +3 -0
  14. package/examples/node-lib/.gitattributes +3 -0
  15. package/examples/node-lib/.github/workflows/test.yml +11 -0
  16. package/examples/node-lib/CHANGELOG.md +13 -0
  17. package/examples/node-lib/README.md +17 -0
  18. package/examples/node-lib/gitignore +3 -0
  19. package/examples/node-lib/package.json +19 -0
  20. package/examples/node-lib/src/list.mjs +23 -0
  21. package/examples/node-lib/src/math.mjs +21 -0
  22. package/examples/node-lib/src/strings.mjs +34 -0
  23. package/examples/node-lib/test/list.test.mjs +12 -0
  24. package/examples/node-lib/test/math.test.mjs +13 -0
  25. package/examples/node-lib/test/package.test.mjs +18 -0
  26. package/examples/node-lib/test/strings.test.mjs +28 -0
  27. package/package.json +52 -0
  28. package/schema/code-forge.schema.json +331 -0
  29. package/schema/facts.schema.json +28 -0
  30. package/scripts/first-use.mjs +517 -0
  31. package/scripts/gen-config-doc.mjs +241 -0
  32. package/skill/SKILL.md +79 -0
  33. package/skill/references/adapters/claude-code.md +19 -0
  34. package/skill/references/adapters/solo.md +34 -0
  35. package/skill/references/adapters/subprocess.md +15 -0
  36. package/skill/references/code.md +64 -0
  37. package/skill/references/continuity.md +31 -0
  38. package/skill/references/decisions.md +50 -0
  39. package/skill/references/degraded.md +40 -0
  40. package/skill/references/harden.md +26 -0
  41. package/skill/references/ledger.md +23 -0
  42. package/skill/references/plan.md +43 -0
  43. package/skill/references/proof.md +34 -0
  44. package/skill/references/review.md +42 -0
  45. package/skill/references/rules-core.md +72 -0
  46. package/skill/references/security.md +29 -0
  47. package/skill/scripts/forge +40 -0
  48. package/skill/templates/block-record.md +19 -0
  49. package/skill/templates/facts-sheet.md +22 -0
  50. package/skill/templates/plan.md +53 -0
  51. package/src/cli/author.mjs +87 -0
  52. package/src/cli/block.mjs +262 -0
  53. package/src/cli/doctor.mjs +74 -0
  54. package/src/cli/facts.mjs +124 -0
  55. package/src/cli/gates.mjs +280 -0
  56. package/src/cli/help.mjs +48 -0
  57. package/src/cli/init.mjs +14 -0
  58. package/src/cli/jev.mjs +175 -0
  59. package/src/cli/keys.mjs +170 -0
  60. package/src/cli/ledger.mjs +160 -0
  61. package/src/cli/list.mjs +68 -0
  62. package/src/cli/models.mjs +131 -0
  63. package/src/cli/plan.mjs +70 -0
  64. package/src/cli/proof.mjs +316 -0
  65. package/src/cli/remove.mjs +76 -0
  66. package/src/cli/report.mjs +73 -0
  67. package/src/cli/resolve.mjs +79 -0
  68. package/src/cli/review-file.mjs +152 -0
  69. package/src/cli/run.mjs +159 -0
  70. package/src/cli/s2.mjs +93 -0
  71. package/src/cli/spawn.mjs +104 -0
  72. package/src/cli/upgrade.mjs +103 -0
  73. package/src/cli/validate.mjs +108 -0
  74. package/src/cli/version.mjs +18 -0
  75. package/src/cli/worker.mjs +76 -0
  76. package/src/config/defaults/anthropic.mjs +18 -0
  77. package/src/config/defaults/index.mjs +24 -0
  78. package/src/config/defaults/openai.mjs +16 -0
  79. package/src/config/defaults/xai.mjs +18 -0
  80. package/src/config/known-ids.mjs +171 -0
  81. package/src/config/load.mjs +135 -0
  82. package/src/config/migrate.mjs +46 -0
  83. package/src/config/refresh.mjs +276 -0
  84. package/src/config/schema-compile.mjs +211 -0
  85. package/src/config/schema-paths.mjs +84 -0
  86. package/src/config/secret-patterns.mjs +85 -0
  87. package/src/config/validate.mjs +694 -0
  88. package/src/decide/author-loop.mjs +90 -0
  89. package/src/decide/escalation.mjs +262 -0
  90. package/src/decide/fallback-rules.mjs +172 -0
  91. package/src/decide/jev-client.mjs +216 -0
  92. package/src/decide/questions.mjs +161 -0
  93. package/src/decide/thresholds.mjs +199 -0
  94. package/src/doctor/index.mjs +105 -0
  95. package/src/doctor/local.mjs +162 -0
  96. package/src/doctor/probes.mjs +358 -0
  97. package/src/doctor/rows.mjs +51 -0
  98. package/src/doctor/worker-probe.mjs +125 -0
  99. package/src/engines/alias-map.mjs +78 -0
  100. package/src/engines/builders/claude.mjs +224 -0
  101. package/src/engines/builders/codex.mjs +242 -0
  102. package/src/engines/builders/grok.mjs +132 -0
  103. package/src/engines/builders/index.mjs +35 -0
  104. package/src/engines/builders/validate-params.mjs +34 -0
  105. package/src/engines/codex-home.mjs +230 -0
  106. package/src/engines/isolation.mjs +84 -0
  107. package/src/engines/probe.mjs +116 -0
  108. package/src/engines/provider-cli.mjs +24 -0
  109. package/src/engines/render-rules.mjs +41 -0
  110. package/src/engines/sentinel.mjs +133 -0
  111. package/src/engines/usage-parse.mjs +149 -0
  112. package/src/gates/acceptance.mjs +95 -0
  113. package/src/gates/detect.mjs +187 -0
  114. package/src/gates/invariants.mjs +43 -0
  115. package/src/gates/run.mjs +72 -0
  116. package/src/gates/safe-edit.mjs +266 -0
  117. package/src/gates/scope.mjs +154 -0
  118. package/src/gates/secret-scan.mjs +79 -0
  119. package/src/gates/transcript-grep.mjs +27 -0
  120. package/src/install/agent-env.mjs +76 -0
  121. package/src/install/detect.mjs +121 -0
  122. package/src/install/harnesses.mjs +131 -0
  123. package/src/install/link.mjs +463 -0
  124. package/src/install/wizard/answers.mjs +389 -0
  125. package/src/install/wizard/flags.mjs +248 -0
  126. package/src/install/wizard/project-file.mjs +128 -0
  127. package/src/install/wizard/run.mjs +366 -0
  128. package/src/install/wizard/steps.mjs +201 -0
  129. package/src/keys/backends/constants.mjs +7 -0
  130. package/src/keys/backends/file.mjs +99 -0
  131. package/src/keys/backends/keychain.mjs +81 -0
  132. package/src/keys/backends/security-cli.mjs +105 -0
  133. package/src/keys/onepassword.mjs +46 -0
  134. package/src/keys/store.mjs +347 -0
  135. package/src/ledger/calibration.mjs +109 -0
  136. package/src/ledger/export.mjs +52 -0
  137. package/src/ledger/outcome.mjs +397 -0
  138. package/src/ledger/paths.mjs +30 -0
  139. package/src/ledger/prices.mjs +49 -0
  140. package/src/ledger/report.mjs +250 -0
  141. package/src/ledger/write.mjs +279 -0
  142. package/src/proof/export.mjs +358 -0
  143. package/src/proof/lock.mjs +138 -0
  144. package/src/proof/red-green.mjs +519 -0
  145. package/src/proof/red-parse.mjs +103 -0
  146. package/src/proof/tiers.mjs +62 -0
  147. package/src/review/budget.mjs +241 -0
  148. package/src/review/context.mjs +156 -0
  149. package/src/review/engine.mjs +264 -0
  150. package/src/review/finding.schema.json +47 -0
  151. package/src/review/fixloop.mjs +467 -0
  152. package/src/review/gate-check.mjs +249 -0
  153. package/src/review/lenses/A.md +22 -0
  154. package/src/review/lenses/B.md +23 -0
  155. package/src/review/lenses/full.md +23 -0
  156. package/src/review/lenses/judge.md +21 -0
  157. package/src/review/lenses/quick.md +21 -0
  158. package/src/review/lenses/recheck.md +23 -0
  159. package/src/review/packet.mjs +355 -0
  160. package/src/review/triage.mjs +149 -0
  161. package/src/review/validate-review.mjs +70 -0
  162. package/src/session/author.mjs +228 -0
  163. package/src/session/facts.mjs +855 -0
  164. package/src/session/plan-check.mjs +294 -0
  165. package/src/session/s2.mjs +164 -0
  166. package/src/session/spawn.mjs +610 -0
  167. package/src/state/block.mjs +289 -0
  168. package/src/state/cli-args.mjs +67 -0
  169. package/src/state/paths.mjs +92 -0
  170. package/src/state/registry.mjs +189 -0
  171. package/src/state/run.mjs +266 -0
  172. package/src/state/signer.mjs +132 -0
  173. package/src/util/exec.mjs +315 -0
  174. package/src/util/forbidden.mjs +787 -0
  175. package/src/util/git.mjs +381 -0
  176. package/src/util/log.mjs +100 -0
  177. package/src/util/reaper.mjs +300 -0
  178. package/src/util/redact.mjs +212 -0
  179. package/src/util/tmp.mjs +409 -0
  180. package/src/worker/engine.mjs +338 -0
  181. package/src/worker/loop.mjs +392 -0
  182. package/src/worker/queue.mjs +186 -0
  183. package/src/worker/review-state.mjs +145 -0
  184. package/src/worker/ticket.mjs +195 -0
@@ -0,0 +1,338 @@
1
+ /**
2
+ * The review-engine hook (plan §4.2–§4.4, §4.11; B11 seam, body by B12a, fix loop wired by B12c).
3
+ *
4
+ * `reviewTicket(ticket, ctx)` is the ONE seam between the worker (queue, keys, signing, pid
5
+ * hygiene — B11) and the review (`src/review/`). The worker signs whatever it returns. The hook
6
+ * looks up the block's base SHA and level in the run record and gives the review a packet
7
+ * directory under the run's temp root (removed afterwards). A run record that exists but cannot be
8
+ * read is `unavailable`, never a review against a guessed base.
9
+ *
10
+ * With the run key in `ctx.key` (the worker always passes it), one ticket = ONE round of B12b's
11
+ * fix loop (`fixloop.mjs`, §4.11) on the file's persisted state (`review-state.mjs`):
12
+ * - no state, or the last cycle `complete` (the file changed after its approval) ⇒ round 1: the
13
+ * B12a engine (`reviewFile`), then triage (`triage.mjs`);
14
+ * - open findings ⇒ the recheck round on the fix hunks only (the diff between the content the
15
+ * last round reviewed and the current content), with the open findings by id;
16
+ * - `next: patch` (the L3 rung at the cap) ⇒ the `patch_check` round; a `retry` re-runs its round;
17
+ * - `stopped` (`review_cap`, `l3_patch_exhausted`, `split_required`) ⇒ no session: the result
18
+ * repeats the stop — only the human moves on (§4.11 rule 5).
19
+ * The round's outcome: `approved: true` ONLY when the loop is `complete` AND its signed
20
+ * `review.approved` row (keyed `run, block, file, content_hash` = the ticket's hash) was written;
21
+ * else `findings` = the open fix list, `next` = what the orchestrator does (`fix` at `level`,
22
+ * `trigger: review_stall | review_rounds`, `patch`), `stopped` = the stop reason.
23
+ * Without `ctx.key` (a direct call) the hook is the engine alone and writes no approval row.
24
+ * The first keyed ticket of a block (no `review.budget` row for it in the run yet) records the
25
+ * block's review budget (§4.10, B19): the forecast over the block's changed owned files (tier from
26
+ * the path floors, before S1), one `review.budget` row per block; a failure there never fails the
27
+ * review.
28
+ * The review NEVER approves an empty or failed session: that is `unavailable` (§4.2 stub guard).
29
+ * A throw inside the round is `unavailable` too, with the state saved as `next: retry` (see
30
+ * `fixLoopRound`): the next ticket re-runs that round, never a fresh round 1.
31
+ */
32
+
33
+ import { rmSync } from 'node:fs';
34
+ import path from 'node:path';
35
+ import { askJev } from '../decide/jev-client.mjs';
36
+ import { planAndRecord, tierOf } from '../review/budget.mjs';
37
+ import { reviewFile, rulesRisk } from '../review/engine.mjs';
38
+ import { computeFileSet, ownsFile } from '../gates/scope.mjs';
39
+ import { newFileState, runRound } from '../review/fixloop.mjs';
40
+ import { readRun } from '../state/run.mjs';
41
+ import { redact } from '../util/redact.mjs';
42
+ import { blockRungUsed, loadState, saveState } from './review-state.mjs';
43
+ import { assertTicketId } from './ticket.mjs';
44
+
45
+ /**
46
+ * @typedef {object} ReviewContext
47
+ * @property {string} repoRoot
48
+ * @property {string} runId
49
+ * @property {string} runRootDir - the run's temp root (packets and session dirs live under it).
50
+ * @property {Record<string, any>} cfg - the project config.
51
+ * @property {string | null} jevKey - resolved once by the worker through B2's chain; never print.
52
+ * @property {(opts: Record<string, any>) => Promise<Record<string, any>>} spawn - one isolated
53
+ * session (`spawnSession` opts minus `runRoot`, `run`, `block`, `timeoutMs`; `cfg` defaults to
54
+ * the worker's and a per-call `cfg` wins).
55
+ * @property {(row: Record<string, any>) => Promise<unknown>} [writeRow] - a signed ledger row
56
+ * (the worker adds `run`, `block`, `file`, `content_hash`).
57
+ * @property {Buffer} [key] - the run's HMAC key: signs the fix-loop state. Absent ⇒ engine only.
58
+ * @property {() => Promise<Array<Record<string, any>>>} [readRows] - the run's ledger rows (the
59
+ * `review.state` anchors the state is checked against); required with `key`.
60
+ * @property {import('../review/triage.mjs').JevAsk} [jev] - S1 with the key bound; default: `askJev`
61
+ * with `jevKey` when one was resolved, else none (§3.5 fallback).
62
+ */
63
+
64
+ /**
65
+ * @typedef {object} ReviewOutcome
66
+ * @property {string} status - `reviewed`, `unavailable`, `no_change`, `split_required`, `refused`.
67
+ * @property {boolean} approved
68
+ * @property {string} engine - `adaptive` or `consensus`.
69
+ * @property {Array<Record<string, any>>} sessions - one summary per session run.
70
+ * @property {string} [reason] - why it is not `reviewed` (stub-guard reason, `needs_file-refused`, …).
71
+ * @property {Array<Record<string, any>>} [findings] - the open fix list (`reviewed` / `stopped`).
72
+ * @property {number} [round] - the fix-loop round this ticket ran (patch_check rounds not counted).
73
+ * @property {string} [kind] - `full`, `recheck` or `patch_check`.
74
+ * @property {string} [level] - the level the file's fixes are coded at now.
75
+ * @property {Record<string, any> | null} [next] - the loop's next step (`fix`, `patch`, `complete`, `stop`, `retry`).
76
+ * @property {string | null} [trigger] - `review_stall`, `review_rounds`, `review_cap`, or null.
77
+ * @property {string} [stopped] - the stop reason (`review_cap`, `l3_patch_exhausted`, …).
78
+ * @property {number} [late] - late findings recorded so far for the file.
79
+ */
80
+
81
+ /**
82
+ * The block's entry in the run record: `null` (⇒ HEAD, level L2) ONLY when no run record exists
83
+ * (`no-run`). Once a record exists, a ticket naming a block it does not list throws
84
+ * `block-unknown`; a record that cannot be read or parsed throws `run-record-unreadable`, and a
85
+ * block entry without a base `block-base-missing` — a guessed base could let a change escape review.
86
+ * @param {string} runId @param {string} block
87
+ * @returns {Promise<{base: string | null, level: string, owned: string[]}>}
88
+ */
89
+ export async function blockEntryFor(runId, block) {
90
+ let record;
91
+ try {
92
+ record = await readRun(runId);
93
+ } catch (err) {
94
+ if (/** @type {any} */ (err)?.code === 'no-run') return { base: null, level: 'L2', owned: [] };
95
+ throw new Error('run-record-unreadable');
96
+ }
97
+ if (!record || typeof record !== 'object' || !record.blocks || typeof record.blocks !== 'object') throw new Error('run-record-unreadable');
98
+ if (typeof block !== 'string' || !Object.hasOwn(record.blocks, block)) throw new Error('block-unknown');
99
+ const entry = record.blocks[block];
100
+ const sha = entry?.base_sha;
101
+ if (typeof sha !== 'string' || sha.length === 0) throw new Error('block-base-missing');
102
+ const owned = Array.isArray(entry.owned_files) ? entry.owned_files.filter((f) => typeof f === 'string') : [];
103
+ return { base: sha, level: typeof entry.level === 'string' && /^L[0-3]$/.test(entry.level) ? entry.level : 'L2', owned };
104
+ }
105
+
106
+ /**
107
+ * The block's base SHA (`blockEntryFor(...).base`).
108
+ * @param {string} runId @param {string} block
109
+ * @returns {Promise<string | null>}
110
+ */
111
+ export async function baseFor(runId, block) {
112
+ return (await blockEntryFor(runId, block)).base;
113
+ }
114
+
115
+ /**
116
+ * The packet directory for a ticket, or null when the id cannot name one. The directory is
117
+ * removed with `rmSync({recursive: true})` afterwards, and tickets are coder-writable, so the id
118
+ * must be the 24-hex form B11 generates AND the resolved path must sit directly under
119
+ * `<runRootDir>/packets` — never the run root itself (`''`), a parent (`../..`) or a nested path.
120
+ * @param {string} runRootDir @param {unknown} id
121
+ * @returns {string | null}
122
+ */
123
+ export function packetDirFor(runRootDir, id) {
124
+ const packets = path.resolve(runRootDir, 'packets');
125
+ let workDir;
126
+ try {
127
+ workDir = path.resolve(packets, assertTicketId(id));
128
+ } catch {
129
+ return null;
130
+ }
131
+ const rel = path.relative(packets, workDir);
132
+ if (rel.length === 0 || rel.includes(path.sep) || rel === '..' || path.isAbsolute(rel) || path.dirname(workDir) !== packets) return null;
133
+ return workDir;
134
+ }
135
+
136
+
137
+ /**
138
+ * @param {import('./queue.mjs').Ticket} ticket
139
+ * @param {ReviewContext} ctx
140
+ * @returns {Promise<ReviewOutcome>}
141
+ */
142
+ export async function reviewTicket(ticket, ctx) {
143
+ const workDir = packetDirFor(ctx.runRootDir, ticket.ticket);
144
+ if (workDir === null) return { status: 'unavailable', reason: 'ticket-id-invalid', approved: false, engine: 'adaptive', sessions: [] };
145
+ let entry;
146
+ try {
147
+ entry = await blockEntryFor(ctx.runId, ticket.block);
148
+ } catch (err) {
149
+ return { status: 'unavailable', reason: err instanceof Error ? err.message : 'run-record-unreadable', approved: false, engine: 'adaptive', sessions: [] };
150
+ }
151
+ try {
152
+ if (!ctx.key) {
153
+ return await reviewFile(
154
+ { repoRoot: ctx.repoRoot, file: ticket.file, base: entry.base, cfg: ctx.cfg, workDir },
155
+ { spawn: ctx.spawn, ...(ctx.writeRow ? { writeRow: ctx.writeRow } : {}) },
156
+ );
157
+ }
158
+ return await fixLoopRound(ticket, ctx, { base: entry.base, level: entry.level, owned: entry.owned, key: ctx.key, workDir });
159
+ } finally {
160
+ rmSync(workDir, { recursive: true, force: true });
161
+ }
162
+ }
163
+
164
+ /**
165
+ * One fix-loop round for the ticket (see the module doc).
166
+ * @param {import('./queue.mjs').Ticket} ticket @param {ReviewContext} ctx
167
+ * @param {{base: string | null, level: string, owned: string[], key: Buffer, workDir: string}} opts
168
+ * @returns {Promise<ReviewOutcome>}
169
+ */
170
+ async function fixLoopRound(ticket, ctx, { base, level, owned, key, workDir }) {
171
+ if (!ctx.readRows || !ctx.writeRow) return { status: 'unavailable', reason: 'no-ledger', approved: false, engine: 'adaptive', sessions: [] };
172
+ let rows;
173
+ try {
174
+ rows = (await ctx.readRows()).filter((r) => r?.run === ctx.runId);
175
+ } catch (err) {
176
+ return { status: 'unavailable', reason: failureReason(err), approved: false, engine: 'adaptive', sessions: [] };
177
+ }
178
+ // Check-then-write is safe here: a run has exactly ONE worker, and it processes its tickets one
179
+ // at a time (`loop.mjs` awaits each `processTicket`), so no second ticket of this block can read
180
+ // the rows between this check and the budget row's write.
181
+ if (!rows.some((r) => r?.event === 'review.budget' && r.block === ticket.block)) {
182
+ await recordBlockBudget(ticket, ctx, { base, owned });
183
+ }
184
+ const where = { runRootDir: ctx.runRootDir, runId: ctx.runId, block: ticket.block, file: ticket.file, key, rows };
185
+ const loaded = loadState(where);
186
+ const rung = blockRungUsed(where);
187
+ if (loaded.status === 'tampered' || rung.status === 'tampered') return { status: 'refused', reason: 'review-state-tampered', approved: false, engine: 'adaptive', sessions: [] };
188
+ const rungUsed = rung.used;
189
+ let state = loaded.state;
190
+ if (state === null || state.status === 'complete') state = newFileState({ file: ticket.file, level, l3RungUsed: rungUsed });
191
+ state.l3_rung_used = state.l3_rung_used || rungUsed;
192
+
193
+ if (state.status === 'stopped') {
194
+ return { ...loopFields(state, null), status: 'stopped', stopped: state.next?.reason ?? 'stopped', approved: false, engine: 'adaptive', sessions: [], findings: state.open };
195
+ }
196
+
197
+ /** @type {'full' | 'recheck' | 'patch_check'} */
198
+ let kind = state.round === 0 ? 'full' : 'recheck';
199
+ if (state.next?.action === 'retry') kind = state.pending_kind ?? kind;
200
+ else if (state.next?.action === 'patch') {
201
+ kind = 'patch_check';
202
+ state.l3_rung_used = true; // the orchestrator ran the block's one L3 patch before this ticket
203
+ }
204
+
205
+ /** @type {Record<string, any> | null} */
206
+ let engineOutcome = null;
207
+ let approvalWritten = false;
208
+ /** @type {Array<Record<string, any>>} */
209
+ const rechecks = [];
210
+ /** @param {Record<string, any>} row */
211
+ const writeRow = async (row) => {
212
+ if (!ctx.writeRow) throw new Error('no-ledger');
213
+ if (row.event === 'review.approved') {
214
+ // the approval names the reviewed bytes: a file that moved since the ticket is not approved
215
+ if (row.content_hash !== ticket.content_hash) throw new Error('hash-moved');
216
+ await ctx.writeRow(row);
217
+ approvalWritten = true;
218
+ return undefined;
219
+ }
220
+ return ctx.writeRow(row);
221
+ };
222
+ const jev = ctx.jev ?? (typeof ctx.jevKey === 'string' && ctx.jevKey.length > 0 ? jevWith(ctx.jevKey) : undefined);
223
+ const before = JSON.stringify(state);
224
+ /** @type {string | null} */
225
+ let crashed = null;
226
+ try {
227
+ await runRound(
228
+ state,
229
+ {
230
+ repoRoot: ctx.repoRoot,
231
+ cfg: ctx.cfg,
232
+ workDir,
233
+ base,
234
+ writeRow,
235
+ ...(jev ? { jev } : {}),
236
+ review: async () => {
237
+ engineOutcome = await reviewFile({ repoRoot: ctx.repoRoot, file: ticket.file, base, cfg: ctx.cfg, workDir }, { spawn: ctx.spawn, writeRow });
238
+ return engineOutcome;
239
+ },
240
+ spawn: async (opts) => {
241
+ const res = await ctx.spawn(opts);
242
+ rechecks.push({ lens: opts?.rowExtra?.lens ?? null, role: opts?.role ?? null, level: opts?.level ?? null, provider: res?.provider ?? null, model: res?.model ?? null });
243
+ return res;
244
+ },
245
+ },
246
+ { kind },
247
+ );
248
+ } catch (err) {
249
+ // A throw mid-round (the engine, a session, S1, `git diff`) is `unavailable`, never an
250
+ // exception the worker turns into `engine-error` with the state UNSAVED. `runRound` stages
251
+ // the round on a draft and commits it only after its gate rows (`review.round`, `review.cap`)
252
+ // are written, so a throw leaves `state` as it was and no `review.round` row exists for the
253
+ // round that threw: the state is saved as `next: retry` with the pending kind — exactly what
254
+ // `fixloop` records for an unavailable session — so its anchor stays the latest and the next
255
+ // ticket re-runs THIS round. (An unsaved state would re-run the earlier rounds' rows too; a
256
+ // state that was already committed is kept as committed, its rows are in the ledger.)
257
+ crashed = failureReason(err);
258
+ if (JSON.stringify(state) === before) {
259
+ state.next = { action: 'retry', reason: crashed };
260
+ state.pending_kind = kind;
261
+ }
262
+ }
263
+ await saveState({ ...where, state, seq: loaded.seq, writeRow: /** @type {(row: Record<string, any>) => Promise<unknown>} */ (ctx.writeRow) });
264
+
265
+ const eng = /** @type {Record<string, any> | null} */ (engineOutcome);
266
+ const head = { ...loopFields(state, kind), engine: eng?.engine ?? 'adaptive', sessions: eng?.sessions ?? rechecks };
267
+ if (crashed !== null) return { ...head, status: 'unavailable', reason: crashed, approved: false };
268
+ const next = state.next;
269
+ // `runRound` moved the state on: re-read its status (not the narrowed pre-round value)
270
+ const status = /** @type {'open' | 'complete' | 'stopped'} */ (state.status);
271
+ if (next?.action === 'retry') {
272
+ if (eng && eng.status !== 'reviewed') return /** @type {ReviewOutcome} */ ({ ...eng, ...loopFields(state, kind), approved: false });
273
+ return { ...head, status: 'unavailable', reason: next.reason ?? 'unavailable', approved: false };
274
+ }
275
+ if (status === 'complete') {
276
+ if (!approvalWritten) return { ...head, status: 'unavailable', reason: 'approval-not-written', approved: false };
277
+ return { ...head, status: 'reviewed', approved: true, findings: [] };
278
+ }
279
+ if (status === 'stopped') return { ...head, status: 'stopped', stopped: next?.reason ?? 'stopped', approved: false, findings: state.open };
280
+ return { ...head, status: 'reviewed', approved: false, reason: 'findings-open', findings: state.open };
281
+ }
282
+
283
+ /**
284
+ * The block's review budget (§4.10, B19): the forecast over the block's changed owned files (the
285
+ * ticket's file always included), each tiered by its path floor (`rulesRisk` with no diff — S1 has
286
+ * not run yet), written as ONE `review.budget` row (+ `review.over_budget` when over). Best effort:
287
+ * a git or ledger failure is swallowed, the review goes on.
288
+ * @param {import('./queue.mjs').Ticket} ticket @param {ReviewContext} ctx
289
+ * @param {{base: string | null, owned: string[]}} opts
290
+ */
291
+ async function recordBlockBudget(ticket, ctx, { base, owned }) {
292
+ try {
293
+ /** @type {string[]} */
294
+ let changed = [];
295
+ if (base !== null && owned.length > 0) {
296
+ try {
297
+ changed = (await computeFileSet({ cwd: ctx.repoRoot, base })).all.filter((f) => ownsFile(owned, f));
298
+ } catch {
299
+ // no row on a git failure: a ticket-only forecast would be permanent (the row is written
300
+ // once per block); the block's next keyed ticket retries
301
+ return;
302
+ }
303
+ }
304
+ const files = [...new Set([...changed, ticket.file])].sort().map((file) => ({ file, tier: tierOf({ risk: rulesRisk({ file, plusCount: 0, cfg: ctx.cfg }) }) }));
305
+ await planAndRecord({ files, cfg: ctx.cfg, block: ticket.block, blockOnly: true, writeRow: /** @type {(row: Record<string, any>) => Promise<unknown>} */ (ctx.writeRow) });
306
+ } catch {
307
+ // the budget row is a report dataset, never a reason to fail the review
308
+ }
309
+ }
310
+
311
+ /**
312
+ * The `unavailable` reason for a throw: the error's code when it has one, else its message
313
+ * through B0 `redact` (never a secret; a message can quote a path, never config values).
314
+ * @param {unknown} err @returns {string}
315
+ */
316
+ function failureReason(err) {
317
+ const code = /** @type {any} */ (err)?.code;
318
+ if (typeof code === 'string' && code.length > 0) return code;
319
+ const text = err instanceof Error ? err.message : String(err);
320
+ const safe = /** @type {string} */ (redact(text));
321
+ return safe.length > 0 ? safe : 'round-failed';
322
+ }
323
+
324
+ /**
325
+ * @param {import('../review/fixloop.mjs').FileState} state @param {string | null} kind
326
+ * @returns {Record<string, any>}
327
+ */
328
+ function loopFields(state, kind) {
329
+ return { round: state.round, ...(kind ? { kind } : {}), level: state.level, next: state.next, trigger: state.next?.trigger ?? null, late: state.late.length };
330
+ }
331
+
332
+ /**
333
+ * S1 through `askJev` with the worker's key bound (the key stays in this process).
334
+ * @param {string} key @returns {import('../review/triage.mjs').JevAsk}
335
+ */
336
+ function jevWith(key) {
337
+ return (req) => askJev({ ...req, key });
338
+ }