@codedology/code-forge 0.8.0 → 0.9.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.
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * `code-forge doctor` (plan §2.3; block B13b): every row, in print order. `--quick` = the three
3
3
  * rows the skill's preflight needs (config, harness links, Jev key resolution); the full run adds
4
- * the PATH/Solo/gate rows, the live Jev call, ledger, `tmp`, the provider CLI probes (presence,
5
- * version, flags, one call per role builder, isolation, path deny, Codex rules), the worker
6
- * start/stop, the signer key and the `signer: same-user boundary only` line.
4
+ * the PATH/Solo/gate rows, the live Jev call, ledger, `tmp`, `review state` (B69), the provider
5
+ * CLI probes (presence, version, flags, one call per role builder, isolation, path deny, Codex
6
+ * rules), the worker start/stop, the signer key and the `signer: same-user boundary only` line.
7
7
  */
8
8
 
9
9
  import { randomBytes } from 'node:crypto';
@@ -13,7 +13,7 @@ import path from 'node:path';
13
13
  import { cliNameForProvider } from '../engines/provider-cli.mjs';
14
14
  import { newRunId } from '../state/paths.mjs';
15
15
  import { runRoot } from '../util/tmp.mjs';
16
- import { checkCommands, checkConfig, checkJev, checkKeys, checkLedger, checkLinks, checkReviewConcurrency, checkTmp, checkTools } from './local.mjs';
16
+ import { checkCommands, checkConfig, checkJev, checkKeys, checkLedger, checkLinks, checkReviewConcurrency, checkReviewState, checkTmp, checkTools } from './local.mjs';
17
17
  import { configuredProviders, pingRoles, probeCli, probeCodexRules, probeIsolation, probePathDeny } from './probes.mjs';
18
18
  import { row } from './rows.mjs';
19
19
  import { probeWorkerAndSigner, SIGNER_ROW } from './worker-probe.mjs';
@@ -68,6 +68,7 @@ export async function runDoctor(opts = {}, deps = {}) {
68
68
  rows.push(...(await checkTools(toolOpts)));
69
69
  rows.push(...(await checkLedger()));
70
70
  rows.push(...checkTmp(cfg));
71
+ rows.push(...(await checkReviewState(cfg)));
71
72
  rows.push(checkReviewConcurrency(cfg));
72
73
  if (!cfg) {
73
74
  rows.push(row('probes', 'FAIL', 'probes', 'skipped: no valid config'));
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Doctor rows that need no provider CLI (plan §2.3; block B13b): config, harness links, Jev key
3
3
  * resolution (the three `--quick` rows), `code-forge` on PATH, Solo, gate commands, the live Jev
4
- * call, ledger writability and the `tmp` row. Every check returns rows; none throws.
4
+ * call, ledger writability, the `tmp` row and (B69) the `review state` row. Every check returns
5
+ * rows; none throws.
5
6
  */
6
7
 
7
8
  import { constants, readdirSync } from 'node:fs';
@@ -17,10 +18,16 @@ import { missingTools } from '../install/tools.mjs';
17
18
  import { OP_MESSAGES } from '../keys/onepassword.mjs';
18
19
  import { createDefaultKeyStore, resolveKey } from '../keys/store.mjs';
19
20
  import { ledgerDir } from '../ledger/paths.mjs';
21
+ import { readAllRows } from '../ledger/write.mjs';
20
22
  import { DEFAULT_PROVIDER_CONCURRENCY, providerLimit } from '../session/spawn.mjs';
23
+ import { snapshotFor } from '../state/config-snapshot.mjs';
24
+ import { assertRunId, runsDir } from '../state/paths.mjs';
25
+ import { readRun } from '../state/run.mjs';
26
+ import { loadKey } from '../state/signer.mjs';
21
27
  import { listEntries } from '../util/reaper.mjs';
22
- import { ownerState, tmpBase } from '../util/tmp.mjs';
28
+ import { ownerState, runRootPath, tmpBase } from '../util/tmp.mjs';
23
29
  import { parallelTickets } from '../worker/loop.mjs';
30
+ import { missingStateFiles } from '../worker/review-state.mjs';
24
31
  import { row } from './rows.mjs';
25
32
 
26
33
  /** @typedef {import('./rows.mjs').Row} Row */
@@ -192,6 +199,86 @@ export function checkTmp(cfg) {
192
199
  return [row('tmp', roots + pids > 0 ? 'WARN' : 'OK', 'tmp', roots + pids > 0 ? `${detail} (the next code-forge run start removes them)` : detail)];
193
200
  }
194
201
 
202
+ /** B69: the most missing state files the `review state` row names. */
203
+ export const LISTED_MAX = 10;
204
+
205
+ /**
206
+ * B69: one WARN row per ACTIVE run (its record's `status` is `active`, as `run end` and the worker
207
+ * decide it) whose ledger has `review.state` anchors whose latest state file is missing — neither
208
+ * at `<code-forge home>/runs/<run>/review-state/` nor at the old temp-root path. The row counts the
209
+ * files the ledger can rebuild (the latest anchor carries `state_body`) and the lost ones (no body:
210
+ * the next ticket is refused `review-state-lost`; the owner needs `code-forge block reopen`), and
211
+ * names the first {@link LISTED_MAX} blocks and files (then `… and K more`). No row when nothing
212
+ * is missing. When the old temp location cannot be named (`tmp.root` not absolute), nothing is
213
+ * counted and the row says so; when an anchor of the run does not verify, the row says the review
214
+ * state was not checked. Each project's ledger is read once. Never throws: a run whose record, key
215
+ * or ledger cannot be read is skipped. No file content is read or printed.
216
+ * @param {Record<string, any> | null} cfg
217
+ * @returns {Promise<Row[]>}
218
+ */
219
+ export async function checkReviewState(cfg) {
220
+ let names = [];
221
+ try {
222
+ names = readdirSync(runsDir()).filter((n) => n.endsWith('.json'));
223
+ } catch {
224
+ return [];
225
+ }
226
+ /** @type {Row[]} */
227
+ const rows = [];
228
+ /** @type {Map<string, Array<Record<string, any>>>} the ledger rows per project, read once */
229
+ const ledgers = new Map();
230
+ for (const name of names.sort()) {
231
+ const runId = name.slice(0, -'.json'.length);
232
+ try {
233
+ assertRunId(runId);
234
+ const record = await readRun(runId);
235
+ if (record?.status !== 'active' || typeof record.project !== 'string') continue;
236
+ const key = await loadKey(runId);
237
+ let ledger = ledgers.get(record.project);
238
+ if (ledger === undefined) {
239
+ ledger = await readAllRows(record.project);
240
+ ledgers.set(record.project, ledger);
241
+ }
242
+ const atNew = missingStateFiles({ runId, key, rows: ledger });
243
+ if (atNew === null) {
244
+ rows.push(row('review-state', 'WARN', 'review state', `run ${runId}: review.state anchors do not verify; review state not checked`));
245
+ continue;
246
+ }
247
+ let root;
248
+ try {
249
+ root = snapshotFor(record, '')?.config?.tmp?.root;
250
+ } catch {
251
+ root = undefined;
252
+ }
253
+ if (typeof root !== 'string') root = typeof cfg?.tmp?.root === 'string' ? cfg.tmp.root : undefined;
254
+ let runRootDir;
255
+ try {
256
+ runRootDir = runRootPath(runId, root === undefined ? {} : { root });
257
+ } catch {
258
+ runRootDir = undefined;
259
+ }
260
+ if (runRootDir === undefined) {
261
+ // the old place cannot be named: nothing is counted as missing, the row says why
262
+ if (atNew.length > 0) {
263
+ rows.push(row('review-state', 'WARN', 'review state', `run ${runId}: the old temp location could not be checked (tmp.root is not an absolute path); review state files are not counted`));
264
+ }
265
+ continue;
266
+ }
267
+ const missing = missingStateFiles({ runId, key, rows: ledger, runRootDir });
268
+ if (missing === null || missing.length === 0) continue;
269
+ const rebuildable = missing.filter((m) => m.rebuildable).length;
270
+ const lost = missing.length - rebuildable;
271
+ const shown = missing.slice(0, LISTED_MAX).map((m) => `${m.block} ${m.file} (${m.rebuildable ? 'rebuildable' : 'lost'})`).join(', ');
272
+ const more = missing.length > LISTED_MAX ? ` … and ${missing.length - LISTED_MAX} more` : '';
273
+ rows.push(row('review-state', 'WARN', 'review state',
274
+ `run ${runId}: ${missing.length} state file(s) missing — ${rebuildable} rebuildable from the ledger, ${lost} lost (needs code-forge block reopen): ${shown}${more}`));
275
+ } catch {
276
+ // an unreadable record, key or ledger: not this row's business
277
+ }
278
+ }
279
+ return rows;
280
+ }
281
+
195
282
  /**
196
283
  * B44: the `review concurrency` INFO row — how many review tickets run at once and each provider's
197
284
  * session slots, from the effective config (defaults when a key is unset). Never throws.
@@ -53,9 +53,14 @@ function normalizeRow(row) {
53
53
  /**
54
54
  * Claim `<base>.<n>.jsonl` for `n = 1, 2, ...` atomically via `link()` (hardlink the still-full
55
55
  * file under the rotated name, then unlink the original) instead of `rename()`.
56
+ *
57
+ * `hooks.afterLink` is a test seam only: when given, it is awaited with the claimed candidate path
58
+ * right after a successful `link()` and before this call's `unlink()` of the source, so a test can
59
+ * force another racer's unlink into exactly that window. Production callers pass nothing.
56
60
  * @param {string} file @param {string} dir @param {string} base
61
+ * @param {{ afterLink?: (candidate: string) => Promise<void> | void }} [hooks]
57
62
  */
58
- export async function claimRotatedName(file, dir, base) {
63
+ export async function claimRotatedName(file, dir, base, hooks = {}) {
59
64
  let n = 1;
60
65
  for (;;) {
61
66
  const candidate = path.join(dir, `${base}.${n}.jsonl`);
@@ -70,6 +75,7 @@ export async function claimRotatedName(file, dir, base) {
70
75
  if (err.code === 'ENOENT') return; // `file` vanished before we could link it — a racer already rotated it out
71
76
  throw err;
72
77
  }
78
+ if (hooks.afterLink) await hooks.afterLink(candidate);
73
79
  try {
74
80
  await unlink(file);
75
81
  return;
@@ -0,0 +1,445 @@
1
+ /**
2
+ * B71 (issue #9 b): an answers ticket — `review-file <answering file> --block <id> --answers <ids>
3
+ * --of <source>` — asks ONE fresh L2 reviewer session whether the answering file (`by_file`)
4
+ * resolves the open findings `<ids>` of the source file. The worker runs it through the engine
5
+ * seam (`reviewTicket` dispatches here on `kind: 'answers'`); `loop.mjs` has already checked the
6
+ * ticket's run, its tree and that the source still has the ticket's hash.
7
+ *
8
+ * Checks here (the ones that count; `review-file` only pre-checks the same rules, unverified):
9
+ * - both files are owned by the block (`answers-not-owned`) and they are two files
10
+ * (`answers-same-file`);
11
+ * - each file is read ONCE: the answering file's bytes must hash to the ticket's `by_hash` (else
12
+ * `stale`, `answering-file-changed`; absent or unreadable: `answers-file-missing`) and the
13
+ * source's to its `content_hash` (else `stale`, `source-changed`); the packet is built from
14
+ * those same bytes, so the row's `by_hash` is the hash of what the reviewer saw;
15
+ * - every id is open on the source's CURRENT hash by the signed rows and not waived or answered
16
+ * yet — `answerableFindings`, the close's own rules (`answers-not-open`).
17
+ * A refusal runs no session. The packet holds each finding (file, lines, severity, claim, evidence,
18
+ * fix — from the source's signed fix-loop state; only id and severity when the state has none),
19
+ * the source lines around it, and the answering file's diff against the block base (its full
20
+ * content when it is new). The reviewer answers per id `resolved` or `not_resolved` with a reason;
21
+ * a missing, repeated or broken verdict is `not_resolved`, and a session that fails (no result,
22
+ * not `ok`, a non-zero exit, a thrown spawn error: `session-error`) makes the ticket `unavailable`
23
+ * with no row. A packet that cannot be built (B79: a secret-like source or answering file too) is
24
+ * `packet-unreadable`; the whole packet stays within the `full_in` budget, and one whose fixed text
25
+ * alone is over it is refused `packet-too-large`. One that cannot be written to the work dir is
26
+ * `packet-write-failed`. All three are refusals (`unavailable`: never approval, no session, no row). For each resolved id the
27
+ * worker writes one signed `review.answered {block, file: source, content_hash: source hash,
28
+ * finding, by_file, by_hash, ticket}` row (`ctx.writeRow` stamps the block, the source and its
29
+ * hash). The ticket's result is `status: 'answers'`, never an approval: the gate never reads it
30
+ * as a verdict on the source (`neverReviewed`), the answering file still needs its own approval,
31
+ * and the source's fix-loop state is not touched.
32
+ *
33
+ * The worker's file and state readers come in through `io` (the worker passes its own
34
+ * `readRegularFileNoFollow`, `loadState` and `assertAnswers`), so this review-side module imports
35
+ * nothing from `src/worker/`.
36
+ */
37
+
38
+ import { createHash, randomBytes } from 'node:crypto';
39
+ import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
40
+ import path from 'node:path';
41
+ import { ownsFile } from '../state/registry.mjs';
42
+ import { exec } from '../util/exec.mjs';
43
+ import { redact } from '../util/redact.mjs';
44
+ import { ANSWERED_EVENT, ANSWERS_STATUS, answerableFindings, ticketAnswered } from './gate-check.mjs';
45
+ import { fixHunkDiff } from './fixloop.mjs';
46
+ import { assertPacketPath, budgetFor, isSecretLike, plainGitEnv, reviewGitEnv } from './packet.mjs';
47
+ import { spawnWithTimeoutRetry } from './session-retry.mjs';
48
+
49
+ /** The answer the reviewer gives: one verdict per finding id. */
50
+ export const ANSWERS_SCHEMA = Object.freeze({
51
+ type: 'object',
52
+ properties: {
53
+ verdicts: {
54
+ type: 'array',
55
+ items: {
56
+ type: 'object',
57
+ properties: {
58
+ id: { type: 'string' },
59
+ verdict: { type: 'string', enum: ['resolved', 'not_resolved'] },
60
+ reason: { type: 'string' },
61
+ },
62
+ required: ['id', 'verdict', 'reason'],
63
+ additionalProperties: false,
64
+ },
65
+ },
66
+ },
67
+ required: ['verdicts'],
68
+ additionalProperties: false,
69
+ });
70
+
71
+ /** The system prompt of the answers session. */
72
+ export const ANSWERS_LENS =
73
+ 'You are a code reviewer for code-forge, judging whether a change in one file resolves findings a review raised about another file. ' +
74
+ 'You have no tools and no files; everything you may use is in the message. Answer once, with a JSON object that matches the schema.';
75
+
76
+ /** Lines of the source shown before and after each finding. */
77
+ const AROUND = 5;
78
+
79
+ /** A reason longer than this is cut (the reviewer's free text). */
80
+ const REASON_MAX = 500;
81
+
82
+ /** B79: what a part cut at the packet budget ends with. */
83
+ export const TRUNCATED_MARKER = '[... truncated at the packet budget]';
84
+
85
+ /** B79: what a cut part shows when its cap is too small for {@link TRUNCATED_MARKER}. */
86
+ export const SHORT_MARKER = '[cut]';
87
+
88
+ /** B79: the packet's fixed text (headings, labels, fences) alone is over the budget. */
89
+ class PacketTooLarge extends Error {}
90
+
91
+ /**
92
+ * @typedef {object} AnswersOutcome
93
+ * @property {string} status - `answers`, `stale` or `unavailable`.
94
+ * @property {boolean} approved - always false.
95
+ * @property {string} engine
96
+ * @property {string} [reason]
97
+ * @property {string} kind - `answers`.
98
+ * @property {Array<{id: string, severity: unknown, verdict: 'resolved' | 'not_resolved', reason: string}>} [findings]
99
+ * @property {Array<Record<string, any>>} sessions
100
+ */
101
+
102
+ /**
103
+ * The ticket fields an answers ticket carries (`worker/queue.mjs` `enqueueAnswers`).
104
+ * @typedef {object} AnswersTicket
105
+ * @property {string} ticket
106
+ * @property {string} block
107
+ * @property {string} file - the source.
108
+ * @property {string} content_hash - the source's hash.
109
+ * @property {string} [by_file]
110
+ * @property {string} [by_hash]
111
+ * @property {string[]} [answers]
112
+ */
113
+
114
+ /**
115
+ * What the worker's review context gives an answers ticket.
116
+ * @typedef {object} AnswersContext
117
+ * @property {string} repoRoot
118
+ * @property {string | null} [contentRoot] - the block's tree, when it has one.
119
+ * @property {string} runId
120
+ * @property {string} [runRootDir]
121
+ * @property {Buffer | null} [key]
122
+ * @property {Parameters<typeof budgetFor>[0]} cfg
123
+ * @property {Parameters<typeof spawnWithTimeoutRetry>[0]} spawn
124
+ * @property {() => Promise<Array<Record<string, any>>>} [readRows]
125
+ * @property {(row: Record<string, any>) => Promise<unknown>} [writeRow]
126
+ */
127
+
128
+ /**
129
+ * The worker's readers (injected, see the module doc).
130
+ * @typedef {object} AnswersIo
131
+ * @property {(root: string, file: string) => Buffer | null} readFile - no symlinks; null when absent.
132
+ * @property {(opts: Record<string, any>) => {status: string, state?: Record<string, any> | null}} loadState - the signed fix-loop state, read only.
133
+ * @property {(answers: unknown) => string[]} assertAnswers - the queue's id-list rule (sorted, unique); throws when the list is bad.
134
+ */
135
+
136
+ /**
137
+ * @param {string} status @param {string} reason @param {Array<Record<string, any>>} [sessions]
138
+ * @returns {AnswersOutcome}
139
+ */
140
+ const outcome = (status, reason, sessions = []) => ({ status, reason, approved: false, engine: 'adaptive', kind: 'answers', sessions });
141
+
142
+ /**
143
+ * Run one answers ticket.
144
+ * @param {AnswersTicket} ticket - `kind: 'answers'`.
145
+ * @param {AnswersContext} ctx - `contentRoot` is the block's tree.
146
+ * @param {{entry: {base: string | null, owned: string[], ownedKinds?: Record<string, string>}, workDir: string, io: AnswersIo}} opts
147
+ * @returns {Promise<AnswersOutcome>}
148
+ */
149
+ export async function reviewAnswers(ticket, ctx, { entry, workDir, io }) {
150
+ const root = ctx.contentRoot ?? ctx.repoRoot;
151
+ const source = ticket.file;
152
+ const byFile = /** @type {string} */ (ticket.by_file);
153
+ // the id list, checked here too by the queue's own rule: 1 to 10 well-formed ids, none twice
154
+ /** @type {string[]} */
155
+ let ids;
156
+ try {
157
+ ids = io.assertAnswers(ticket.answers);
158
+ } catch {
159
+ return outcome(ANSWERS_STATUS, 'usage');
160
+ }
161
+ if (!Array.isArray(ticket.answers) || ids.length !== ticket.answers.length) return outcome(ANSWERS_STATUS, 'usage');
162
+ if (!ctx.key || !ctx.readRows || !ctx.writeRow) return outcome('unavailable', 'no-ledger');
163
+ if (typeof byFile !== 'string' || path.posix.normalize(byFile) === path.posix.normalize(source)) return outcome(ANSWERS_STATUS, 'answers-same-file');
164
+ if (!ownsFile(entry.owned, source, entry.ownedKinds) || !ownsFile(entry.owned, byFile, entry.ownedKinds)) return outcome(ANSWERS_STATUS, 'answers-not-owned');
165
+ // each file is read ONCE; the hashes and the packet come from these bytes
166
+ /** @type {Buffer | null} */
167
+ let byBytes;
168
+ /** @type {Buffer | null} */
169
+ let srcBytes;
170
+ try {
171
+ byBytes = io.readFile(root, byFile);
172
+ } catch {
173
+ byBytes = null;
174
+ }
175
+ if (byBytes === null) return outcome(ANSWERS_STATUS, 'answers-file-missing');
176
+ const byHash = hashOf(byBytes);
177
+ if (byHash !== ticket.by_hash) return outcome('stale', 'answering-file-changed');
178
+ try {
179
+ srcBytes = io.readFile(root, source);
180
+ } catch {
181
+ srcBytes = null;
182
+ }
183
+ if (srcBytes === null || hashOf(srcBytes) !== ticket.content_hash) return outcome('stale', 'source-changed');
184
+
185
+ let rows;
186
+ try {
187
+ rows = (await ctx.readRows()).filter((r) => r?.run === ctx.runId);
188
+ } catch {
189
+ return outcome('unavailable', 'ledger-unreadable');
190
+ }
191
+ const current = new Map([
192
+ [source, ticket.content_hash],
193
+ [byFile, byHash],
194
+ ]);
195
+ // this ticket's own rows do not close an id: a retry after a partial write finishes the rest
196
+ const open = answerableFindings({ block: ticket.block, runId: ctx.runId, rows, key: ctx.key, file: source, hash: ticket.content_hash, current, ticket: ticket.ticket });
197
+ if (!ids.every((id) => open.has(id))) return outcome(ANSWERS_STATUS, 'answers-not-open');
198
+
199
+ let text;
200
+ try {
201
+ text = await packetText({ ticket, ctx, rows, root, entry, open, io, ids, srcBytes, byBytes, workDir });
202
+ } catch (err) {
203
+ // B79: a packet whose fixed text alone is over the budget is refused like an unreadable one:
204
+ // never approval, no session, no row
205
+ if (err instanceof PacketTooLarge) return outcome('unavailable', 'packet-too-large');
206
+ return outcome('unavailable', 'packet-unreadable');
207
+ }
208
+ const promptPath = path.join(workDir, `answers-${randomBytes(6).toString('hex')}.md`);
209
+ try {
210
+ mkdirSync(workDir, { recursive: true, mode: 0o700 });
211
+ writeFileSync(promptPath, text, { mode: 0o600 });
212
+ } catch {
213
+ return outcome('unavailable', 'packet-write-failed');
214
+ }
215
+
216
+ const note = async (/** @type {Record<string, any>} */ row) => {
217
+ try {
218
+ await ctx.writeRow?.(row);
219
+ } catch {
220
+ // a note is best-effort
221
+ }
222
+ };
223
+ let res;
224
+ try {
225
+ ({ res } = await spawnWithTimeoutRetry(ctx.spawn, { level: 'L2', role: 'reviewer', promptPath, schema: ANSWERS_SCHEMA, systemPromptText: ANSWERS_LENS, rowExtra: { lens: 'answers' } }, note));
226
+ } catch {
227
+ // a spawn or adapter error is no verdict: retry the ticket
228
+ return outcome('unavailable', 'session-error');
229
+ } finally {
230
+ rmSync(promptPath, { force: true });
231
+ }
232
+ const sessions = [{ lens: 'answers', role: 'reviewer', level: 'L2', provider: res?.provider ?? null, model: res?.model ?? null, tokens_in: res?.usage?.tokens_in ?? null, tokens_out: res?.usage?.tokens_out ?? null }];
233
+ // a spawn that threw (`spawnWithTimeoutRetry` gives null) is `session-error`, like a throw above
234
+ if (!res) return outcome('unavailable', 'session-error', sessions);
235
+ if (res.status !== 'ok' || (res.exit_code !== undefined && res.exit_code !== 0)) return outcome('unavailable', typeof res.status === 'string' && res.status !== 'ok' ? res.status : 'exit', sessions);
236
+
237
+ const verdicts = verdictsOf(res.answer, ids);
238
+ /** @type {NonNullable<AnswersOutcome['findings']>} */
239
+ const findings = ids.map((id) => {
240
+ const v = verdicts.get(id) ?? { verdict: /** @type {const} */ ('not_resolved'), reason: 'no valid verdict' };
241
+ return { id, severity: open.get(id) ?? null, verdict: v.verdict, reason: v.reason };
242
+ });
243
+ // every verdict is built before any row is written; an id this ticket already answered (an
244
+ // earlier attempt whose later write failed) gets no second row, so a retry ends with one per id
245
+ const done = ticketAnswered({ block: ticket.block, runId: ctx.runId, rows, key: ctx.key, file: source, current, ticket: ticket.ticket });
246
+ for (const f of findings) {
247
+ if (f.verdict !== 'resolved' || done.has(f.id)) continue;
248
+ try {
249
+ await ctx.writeRow({ event: ANSWERED_EVENT, finding: f.id, by_file: byFile, by_hash: byHash, ticket: ticket.ticket });
250
+ } catch {
251
+ return outcome('unavailable', 'ledger-write-failed', sessions);
252
+ }
253
+ }
254
+ return { status: ANSWERS_STATUS, approved: false, engine: 'adaptive', kind: 'answers', findings, sessions };
255
+ }
256
+
257
+ /**
258
+ * The reviewer's verdict per requested id: only `{id, verdict: 'resolved' | 'not_resolved',
259
+ * reason: string}` entries count; an id given twice, an unknown id or a broken entry gives no
260
+ * verdict for that id (the caller reads it as `not_resolved`).
261
+ * @param {unknown} answer @param {string[]} ids
262
+ * @returns {Map<string, {verdict: 'resolved' | 'not_resolved', reason: string}>}
263
+ */
264
+ export function verdictsOf(answer, ids) {
265
+ /** @type {Map<string, {verdict: 'resolved' | 'not_resolved', reason: string}>} */
266
+ const out = new Map();
267
+ let value = answer;
268
+ if (typeof value === 'string') {
269
+ try {
270
+ value = JSON.parse(value);
271
+ } catch {
272
+ return out;
273
+ }
274
+ }
275
+ const list = value && typeof value === 'object' && Array.isArray(/** @type {any} */ (value).verdicts) ? /** @type {unknown[]} */ (/** @type {any} */ (value).verdicts) : [];
276
+ /** @type {Set<string>} */
277
+ const seen = new Set();
278
+ /** @type {Set<string>} */
279
+ const repeated = new Set();
280
+ for (const v of list) {
281
+ const e = /** @type {Record<string, unknown>} */ (v);
282
+ if (!e || typeof e !== 'object' || typeof e.id !== 'string' || !ids.includes(e.id)) continue;
283
+ if (seen.has(e.id)) repeated.add(e.id);
284
+ seen.add(e.id);
285
+ if ((e.verdict === 'resolved' || e.verdict === 'not_resolved') && typeof e.reason === 'string') out.set(e.id, { verdict: e.verdict, reason: e.reason.slice(0, REASON_MAX) });
286
+ }
287
+ for (const id of repeated) out.delete(id);
288
+ return out;
289
+ }
290
+
291
+ /** The worker's content hash of bytes it read (`worker/ticket.mjs` `contentHash`: sha256, hex). */
292
+ const hashOf = (/** @type {Buffer} */ bytes) => createHash('sha256').update(bytes).digest('hex');
293
+
294
+ /**
295
+ * The answering file's change, built from the bytes that were hashed (never a second read): its
296
+ * diff against its text at the block base (`entry.base`, else HEAD), or its full content when the
297
+ * base has no such file. A secret-like path or a git failure throws (`packet-unreadable`).
298
+ * @param {{root: string, file: string, base: string | null, bytes: Buffer, workDir: string}} opts
299
+ * @returns {Promise<{kind: 'new' | 'tracked', text: string}>}
300
+ */
301
+ async function answeringChange({ root, file, base, bytes, workDir }) {
302
+ const rel = assertPacketPath(root, file);
303
+ if (isSecretLike(rel)) throw new Error('secret-like path');
304
+ const ref = base ?? 'HEAD';
305
+ if (typeof ref !== 'string' || ref.length === 0 || ref.startsWith('-')) throw new Error('the base must be a commit id');
306
+ const git = { cwd: root, timeoutMs: 30000 };
307
+ const listed = await exec(['git', 'ls-tree', '-z', ref, '--', rel], { ...git, env: reviewGitEnv() });
308
+ if (listed.result !== 'ok') throw new Error(`git ls-tree failed (exit ${listed.code})`);
309
+ const current = bytes.toString('utf8');
310
+ if (listed.stdout.length === 0) return { kind: 'new', text: current };
311
+ const shown = await exec(['git', 'show', `${ref}:${rel}`], { ...git, env: plainGitEnv() });
312
+ if (shown.result !== 'ok') throw new Error(`git show failed (exit ${shown.code})`);
313
+ const diff = await fixHunkDiff({ file: rel, previous: shown.stdout, current, workDir });
314
+ return { kind: 'tracked', text: diff.diffText };
315
+ }
316
+
317
+ /**
318
+ * B79: `text` cut to at most `max` UTF-8 bytes, marker included — never more. The bytes are cut on
319
+ * the Buffer and the cut moves back to a character boundary (never half a character); a cut text
320
+ * ends with a newline and {@link TRUNCATED_MARKER}. When `max` is too small for that marker, the
321
+ * part's text is dropped for {@link SHORT_MARKER} (or for nothing, when even that does not fit).
322
+ * A text that fits is returned as it is.
323
+ * @param {string} text @param {number} max @returns {string}
324
+ */
325
+ export function capBytes(text, max) {
326
+ const bytes = Buffer.from(text, 'utf8');
327
+ if (bytes.length <= max) return text;
328
+ const tail = `\n${TRUNCATED_MARKER}`;
329
+ const tailBytes = Buffer.byteLength(tail);
330
+ if (max < tailBytes) return max >= Buffer.byteLength(SHORT_MARKER) ? SHORT_MARKER : '';
331
+ let end = max - tailBytes;
332
+ // a UTF-8 continuation byte (10xxxxxx) never starts a character: step back to the lead byte
333
+ while (end > 0 && (bytes[end] & 0xc0) === 0x80) end -= 1;
334
+ return `${bytes.subarray(0, end).toString('utf8')}${tail}`;
335
+ }
336
+
337
+ /** The free-text fields of a finding the packet shows. */
338
+ const FIELDS = /** @type {const} */ (['claim', 'evidence', 'fix']);
339
+
340
+ /**
341
+ * The packet: the findings, the source lines around each, and the answering file's change — all
342
+ * from the bytes `reviewAnswers` read and hashed. Neither file may be secret-like (both refused the
343
+ * same way: `packet-unreadable`). B79: the WHOLE packet stays within the budget (`full_in` × 4
344
+ * bytes). Its fixed text (headings, labels, fences) is counted first — over the budget alone it is
345
+ * {@link PacketTooLarge}. Of the room left, the findings get half — per finding, each of claim,
346
+ * evidence and fix a fifth of its share and the source excerpt the rest — and the answering file's
347
+ * change what the findings leave. Each part is redacted BEFORE it is cut (a secret is never cut
348
+ * into a prefix redaction misses), and cut by {@link capBytes}: one marker per cut part, never over
349
+ * its cap. The fixed text is redacted before it is measured, so the joined packet is never cut: it
350
+ * ends with the change's closing fence. A joined packet redaction would still change (a secret
351
+ * across a part boundary) is refused, never cut — `packet-unreadable`.
352
+ * @param {{ticket: AnswersTicket, ctx: AnswersContext, rows: Array<Record<string, any>>, root: string, entry: {base: string | null}, open: Map<string, unknown>, io: AnswersIo, ids: string[], srcBytes: Buffer, byBytes: Buffer, workDir: string}} opts
353
+ * @returns {Promise<string>}
354
+ */
355
+ async function packetText({ ticket, ctx, rows, root, entry, open, io, ids, srcBytes, byBytes, workDir }) {
356
+ const source = ticket.file;
357
+ const byFile = /** @type {string} */ (ticket.by_file);
358
+ if (isSecretLike(assertPacketPath(root, source))) throw new Error('secret-like path');
359
+ const details = findingDetails({ ticket, ctx, rows, root, io });
360
+ const lines = srcBytes.toString('utf8').split('\n');
361
+ const diff = await answeringChange({ root, file: byFile, base: entry.base, bytes: byBytes, workDir });
362
+ const limit = budgetFor(ctx.cfg, 'full_in') * 4;
363
+ // each finding: its fixed lines, and its free text (null: not recorded) and source excerpt (null: none)
364
+ const findings = ids.map((id) => {
365
+ const f = details.get(id) ?? null;
366
+ const start = Number.isInteger(f?.line_start) ? /** @type {number} */ (f?.line_start) : null;
367
+ const end = Number.isInteger(f?.line_end) ? /** @type {number} */ (f?.line_end) : start;
368
+ // the fixed text is redacted before it is measured: the joined packet never grows past its count
369
+ const head = [`## Finding ${id}`, '', `- file: ${source}`, `- lines: ${start === null ? 'unknown' : `${start}-${end}`}`, `- severity: ${String(f?.severity ?? open.get(id) ?? 'unknown')}`].map(redact);
370
+ /** @type {Record<typeof FIELDS[number], string | null>} */
371
+ const text = { claim: null, evidence: null, fix: null };
372
+ for (const k of FIELDS) text[k] = typeof f?.[k] === 'string' ? redact(f[k]) : null;
373
+ /** @type {{label: string, text: string} | null} */
374
+ let excerpt = null;
375
+ // a range recorded backwards (start after end) shows no lines, whatever the lines around it
376
+ if (start !== null && end !== null && start <= end && lines.length > 0) {
377
+ const from = Math.max(1, start - AROUND);
378
+ const to = Math.min(lines.length, end + AROUND);
379
+ // a recorded range past the end of the source, or a backwards one, shows no lines
380
+ if (from <= to) excerpt = { label: redact(`Source lines ${from}-${to}:`), text: redact(lines.slice(from - 1, to).map((l, i) => `${from + i}: ${l}`).join('\n')) };
381
+ }
382
+ return { head, text, excerpt };
383
+ });
384
+ const intro = [
385
+ '# Answered findings',
386
+ '',
387
+ `A review of \`${source}\` left the findings below open. The coder says the change in \`${byFile}\` resolves them.`,
388
+ 'For EACH finding id, decide whether that change resolves it: `resolved` only when the change clearly removes the problem the finding names; otherwise `not_resolved`. Give a short reason for each.',
389
+ 'Reply with ONE JSON object: {"verdicts": [{"id": string, "verdict": "resolved" | "not_resolved", "reason": string}]}, one entry per id below.',
390
+ '',
391
+ ].map(redact);
392
+ const changeHead = [`## The change in ${byFile}`, '', diff.kind === 'new' ? '(a new file: its full content)' : `(its diff against the block base)`, '```'].map(redact);
393
+ /**
394
+ * The packet with these part texts (`parts[i]`: finding i's fields and excerpt) and change.
395
+ * @param {Array<{claim: string, evidence: string, fix: string, excerpt: string}>} parts @param {string} change
396
+ */
397
+ const render = (parts, change) => {
398
+ const out = [...intro];
399
+ findings.forEach((f, i) => {
400
+ out.push(...f.head);
401
+ for (const k of FIELDS) out.push(`- ${k}: ${f.text[k] === null ? '(not recorded)' : parts[i][k]}`);
402
+ out.push('');
403
+ if (f.excerpt !== null) out.push(f.excerpt.label, '```', parts[i].excerpt, '```', '');
404
+ });
405
+ out.push(...changeHead, change, '```', '');
406
+ return out.join('\n');
407
+ };
408
+ const fixed = Buffer.byteLength(render(findings.map(() => ({ claim: '', evidence: '', fix: '', excerpt: '' })), ''));
409
+ if (fixed > limit) throw new PacketTooLarge('the packet fixed text is over the budget');
410
+ const share = Math.floor((limit - fixed) / 2 / Math.max(1, findings.length));
411
+ const textCap = Math.floor(share / 5);
412
+ const sourceCap = share - FIELDS.length * textCap;
413
+ const parts = findings.map((f) => ({
414
+ claim: capBytes(f.text.claim ?? '', textCap),
415
+ evidence: capBytes(f.text.evidence ?? '', textCap),
416
+ fix: capBytes(f.text.fix ?? '', textCap),
417
+ excerpt: capBytes(f.excerpt?.text ?? '', sourceCap),
418
+ }));
419
+ // the change gets what the fixed text and the findings leave
420
+ const used = Buffer.byteLength(render(parts, ''));
421
+ const packet = render(parts, capBytes(redact(diff.text), limit - used));
422
+ // every piece is redacted and counted: the packet is never cut here, only refused (fail closed)
423
+ if (Buffer.byteLength(packet) > limit) throw new PacketTooLarge('the packet is over the budget');
424
+ if (redact(packet) !== packet) throw new Error('a secret across a part boundary');
425
+ return packet;
426
+ }
427
+
428
+ /**
429
+ * The open findings' details from the source's signed fix-loop state (read only, never moved or
430
+ * restored here). A state that does not load gives none: the packet then names id and severity.
431
+ * @param {{ticket: AnswersTicket, ctx: AnswersContext, rows: Array<Record<string, any>>, root: string, io: AnswersIo}} opts
432
+ * @returns {Map<string, Record<string, any>>}
433
+ */
434
+ function findingDetails({ ticket, ctx, rows, root, io }) {
435
+ /** @type {Map<string, Record<string, any>>} */
436
+ const out = new Map();
437
+ try {
438
+ const loaded = io.loadState({ runRootDir: ctx.runRootDir, runId: ctx.runId, block: ticket.block, file: ticket.file, key: /** @type {Buffer} */ (ctx.key), rows, contentRoot: root, migrate: false });
439
+ if (loaded.status !== 'ok' && loaded.status !== 'restored') return out;
440
+ for (const f of [...(loaded.state?.open ?? []), ...(loaded.state?.late ?? [])]) if (typeof f?.id === 'string' && !out.has(f.id)) out.set(f.id, f);
441
+ } catch {
442
+ // no details: id and severity only
443
+ }
444
+ return out;
445
+ }