@codedology/code-forge 0.7.7 → 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.
Files changed (69) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/README.md +68 -2
  3. package/docs/README.md +2 -1
  4. package/docs/reference/blocks.json +390 -1
  5. package/docs/reference/config.md +26 -8
  6. package/package.json +1 -1
  7. package/schema/code-forge.schema.json +81 -4
  8. package/skill/SKILL.md +6 -6
  9. package/skill/references/code.md +45 -7
  10. package/skill/references/ledger.md +8 -4
  11. package/skill/references/proof.md +44 -3
  12. package/skill/references/review.md +25 -5
  13. package/skill/references/security.md +1 -1
  14. package/skill/scripts/forge +1 -1
  15. package/skill/templates/coder-brief.md +19 -2
  16. package/src/autopilot/binnacle.mjs +21 -3
  17. package/src/cli/block.mjs +427 -41
  18. package/src/cli/context.mjs +175 -0
  19. package/src/cli/gates.mjs +125 -15
  20. package/src/cli/ledger.mjs +46 -2
  21. package/src/cli/proof.mjs +62 -16
  22. package/src/cli/report.mjs +7 -4
  23. package/src/cli/review-file.mjs +324 -18
  24. package/src/cli/status.mjs +212 -0
  25. package/src/config/validate.mjs +45 -11
  26. package/src/context/pack.mjs +319 -0
  27. package/src/doctor/index.mjs +5 -4
  28. package/src/doctor/local.mjs +91 -3
  29. package/src/engines/sentinel.mjs +27 -6
  30. package/src/gates/detect.mjs +21 -8
  31. package/src/gates/expand.mjs +202 -0
  32. package/src/gates/owned-count.mjs +66 -0
  33. package/src/gates/run.mjs +49 -12
  34. package/src/install/wizard/answers.mjs +34 -5
  35. package/src/install/wizard/flags.mjs +8 -1
  36. package/src/install/wizard/project-file.mjs +2 -0
  37. package/src/install/wizard/run.mjs +6 -0
  38. package/src/install/wizard/steps.mjs +24 -4
  39. package/src/install/wizard/summary.mjs +13 -5
  40. package/src/ledger/export.mjs +1 -1
  41. package/src/ledger/outside-review.mjs +164 -0
  42. package/src/ledger/report.mjs +17 -5
  43. package/src/ledger/write.mjs +7 -1
  44. package/src/proof/export.mjs +93 -10
  45. package/src/proof/lock.mjs +28 -5
  46. package/src/proof/worktree.mjs +482 -0
  47. package/src/review/answers.mjs +445 -0
  48. package/src/review/deletion.mjs +106 -0
  49. package/src/review/engine.mjs +36 -2
  50. package/src/review/fixloop.mjs +25 -15
  51. package/src/review/gate-check.mjs +384 -83
  52. package/src/review/open-findings.mjs +59 -0
  53. package/src/review/reopen.mjs +258 -0
  54. package/src/review/stop-exits.mjs +15 -0
  55. package/src/state/block-end.mjs +290 -0
  56. package/src/state/block.mjs +103 -39
  57. package/src/state/brief-status.mjs +131 -0
  58. package/src/state/config-snapshot.mjs +51 -0
  59. package/src/state/registry.mjs +35 -4
  60. package/src/state/run.mjs +10 -1
  61. package/src/util/breadcrumbs.mjs +1 -0
  62. package/src/util/forbidden.mjs +46 -1
  63. package/src/util/gate-token.mjs +8 -0
  64. package/src/util/tmp.mjs +16 -4
  65. package/src/worker/engine.mjs +230 -24
  66. package/src/worker/loop.mjs +29 -8
  67. package/src/worker/queue.mjs +296 -15
  68. package/src/worker/review-state.mjs +346 -51
  69. package/src/worker/ticket.mjs +46 -6
@@ -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
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * The deletion note (block B74, issue #8): a packet for a pure deletion (the file is gone from the
3
+ * reviewed tree, its diff is all `-` lines) gets a `## deleted file` section before the diff. It
4
+ * says the file is deleted and lists the tree paths that still name the file's basename, so the
5
+ * reviewer checks for dangling references.
6
+ *
7
+ * The reviewed tree is the working tree the review engine diffs (`reviewFile`'s `repoRoot`: the
8
+ * block's worktree, or the main tree), never a commit: the paths come from ONE
9
+ * `git grep -l -z --untracked -F -e <basename> -- .` run with that tree as cwd (argv only, the
10
+ * review git env `reviewGitEnv`: no inherited `GIT_*` variable, `GIT_CONFIG_NOSYSTEM=1`,
11
+ * `GIT_CONFIG_GLOBAL` the null device, `GIT_LITERAL_PATHSPECS=1`). `--untracked` adds the files a
12
+ * block created and has not committed yet (never ignored ones). The list is sorted, never names the
13
+ * deleted path itself or a secret-like path (`isSecretLike`), and stops at {@link MAX_REFERENCES}
14
+ * paths; a path with a control character (it could break the packet layout) is never printed, only
15
+ * counted with the paths past the cap (`(<n> more not listed)`). No match ⇒ `no references found`.
16
+ * A basename shorter than {@link MIN_BASENAME} characters is not grepped, nor is a deleted path
17
+ * with a control character (it is printed JSON-escaped), and a grep that fails (or times out) is
18
+ * not trusted: all say `references not checked`. The note is a hint, never an exemption: a
19
+ * deletion is reviewed and needs a signed approval like any other change (a grep cannot prove that
20
+ * nothing depends on a file).
21
+ */
22
+
23
+ import path from 'node:path';
24
+ import { exec } from '../util/exec.mjs';
25
+ import { isSecretLike, reviewGitEnv } from './packet.mjs';
26
+
27
+ /** At most this many referencing paths are listed. */
28
+ export const MAX_REFERENCES = 20;
29
+
30
+ /** A basename shorter than this matches too much to be a useful hint: it is not grepped. */
31
+ export const MIN_BASENAME = 3;
32
+
33
+ export const NO_REFERENCES = 'no references found';
34
+ export const NOT_CHECKED = 'references not checked';
35
+
36
+ const GREP_TIMEOUT_MS = 30000;
37
+
38
+ const CONTROL = /[\x00-\x1f\x7f]/;
39
+
40
+ /** @param {string} p @returns {boolean} whether `p` has a control character. */
41
+ const hasControl = (p) => CONTROL.test(p);
42
+
43
+ /**
44
+ * @typedef {object} References
45
+ * @property {'found' | 'none' | 'not_checked'} status
46
+ * @property {string[]} paths - sorted, at most {@link MAX_REFERENCES}.
47
+ * @property {number} more - matching paths not listed: past the cap, or with a control character.
48
+ */
49
+
50
+ /** @type {References} */
51
+ const NOT_CHECKED_REFS = Object.freeze({ status: 'not_checked', paths: [], more: 0 });
52
+
53
+ /**
54
+ * The paths of the reviewed tree (`repoRoot`, the grep's cwd) that still name `file`'s basename.
55
+ * @param {{repoRoot: string, file: string, run?: typeof exec}} opts - `run`: the child runner
56
+ * (default `exec`; a test seam).
57
+ * @returns {Promise<References>}
58
+ */
59
+ export async function findReferences({ repoRoot, file, run = exec }) {
60
+ const base = path.posix.basename(file);
61
+ if (base.length < MIN_BASENAME || hasControl(file)) return { ...NOT_CHECKED_REFS, paths: [] };
62
+ let res;
63
+ try {
64
+ res = await run(['git', 'grep', '-l', '-z', '--untracked', '-F', '-e', base, '--', '.'], { cwd: repoRoot, env: reviewGitEnv(), timeoutMs: GREP_TIMEOUT_MS, okExitCodes: [0, 1] });
65
+ } catch {
66
+ return { ...NOT_CHECKED_REFS, paths: [] };
67
+ }
68
+ if (res?.result !== 'ok' || (res.code !== 0 && res.code !== 1) || typeof res.stdout !== 'string') return { ...NOT_CHECKED_REFS, paths: [] };
69
+ const matched = [...new Set(res.stdout.split('\0').filter((p) => p.length > 0 && p !== file && !isSecretLike(p)))];
70
+ const unsafe = matched.filter(hasControl).length;
71
+ const clean = matched.filter((p) => !hasControl(p)).sort();
72
+ if (clean.length === 0 && unsafe === 0) return { status: 'none', paths: [], more: 0 };
73
+ return { status: 'found', paths: clean.slice(0, MAX_REFERENCES), more: Math.max(0, clean.length - MAX_REFERENCES) + unsafe };
74
+ }
75
+
76
+ /**
77
+ * The `## deleted file` section (heading included) for a deleted `file`. A name with a control
78
+ * character is printed JSON-escaped, never raw.
79
+ * @param {string} file @param {References} refs @returns {string}
80
+ */
81
+ export function renderDeletionNote(file, refs) {
82
+ const shown = hasControl(file) ? JSON.stringify(file) : file;
83
+ const out = ['## deleted file', `this file is deleted: ${shown}`, `check that nothing still depends on it. tree paths that still name ${JSON.stringify(path.posix.basename(file))}:`];
84
+ if (refs.status === 'not_checked') out.push(NOT_CHECKED);
85
+ else if (refs.status === 'none') out.push(NO_REFERENCES);
86
+ else {
87
+ out.push(...refs.paths.map((p) => `- ${p}`));
88
+ if (refs.more > 0) out.push(`(${refs.more} more not listed)`);
89
+ }
90
+ return out.join('\n');
91
+ }
92
+
93
+ /**
94
+ * The deletion note for `file`, its references read in `repoRoot` (the reviewed tree). Never
95
+ * rejects: anything that fails is `references not checked`.
96
+ * @param {{repoRoot: string, file: string, run?: typeof exec}} opts @returns {Promise<string>}
97
+ */
98
+ export async function deletionNote(opts) {
99
+ let refs;
100
+ try {
101
+ refs = await findReferences(opts);
102
+ } catch {
103
+ refs = NOT_CHECKED_REFS;
104
+ }
105
+ return renderDeletionNote(opts.file, refs);
106
+ }
@@ -40,12 +40,15 @@
40
40
  * (`moved.mjs`, B56). The peers' diffs are read once per review (`input.peerDiffs`, or a
41
41
  * reader built from `input.peers`). It is extra text: every hunk still goes to the reviewer,
42
42
  * and it counts toward the budget in the same measure.
43
+ * 9. a pure deletion (the file is gone from the reviewed tree) gets a `## deleted file` note in
44
+ * every packet — reviewers and judge — naming up to 20 tree paths that still name its basename
45
+ * (`deletion.mjs`, B74). It is a hint, never an exemption: the deletion still needs approval.
43
46
  * Approval (until B12b's triage lands) = the final answer (the judge's in dual/consensus mode, else
44
47
  * the single reviewer's) passed the guard, says `passed: true`, and carries no `critical` or
45
48
  * `warning` finding.
46
49
  */
47
50
 
48
- import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
51
+ import { lstatSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
49
52
  import { randomBytes } from 'node:crypto';
50
53
  import path from 'node:path';
51
54
  import { resolveLevel } from '../config/known-ids.mjs';
@@ -55,6 +58,7 @@ import { isMigrationPath, isPolicyPath } from '../decide/lane-facts.mjs';
55
58
  import { tierFor } from '../proof/tiers.mjs';
56
59
  import { assembleJudgePacket, assemblePacket, attachFiles, budgetFor, diffOnlyTokens, readFileDiff } from './packet.mjs';
57
60
  import { movedText, movesFor, peerDiffReader } from './moved.mjs';
61
+ import { deletionNote, renderDeletionNote } from './deletion.mjs';
58
62
  import { isMarkdownFile, packSections, sectionFindings, sectionGroups } from './sections.mjs';
59
63
  import { FINDING_SCHEMA, minTokensOut, validateReview } from './validate-review.mjs';
60
64
  import { spawnWithTimeoutRetry } from './session-retry.mjs';
@@ -182,6 +186,8 @@ export function rulesRisk({ file, plusCount, cfg }) {
182
186
  * @property {(row: Record<string, any>) => Promise<unknown>} [writeRow] - ledger rows
183
187
  * (`review.plan`, `review.unavailable`, `review.schema_invalid`, `review.schema_fallback`); a
184
188
  * failed write never fails the review (a failed `review.schema_fallback` write means no try).
189
+ * @property {(opts: {repoRoot: string, file: string}) => Promise<string>} [deletionNote] - B74: the
190
+ * deletion note reader (default `deletionNote`, `deletion.mjs`); a throw gives `references not checked`.
185
191
  * @property {import('./schema-fallback.mjs').SchemaHistory} [schemaHistory] - B55: the packet's
186
192
  * signed schema-miss history (the worker's); absent ⇒ no second-level try.
187
193
  */
@@ -233,8 +239,22 @@ export async function reviewFile(input, deps) {
233
239
  } catch {
234
240
  moves = null; // no hint; the review goes on
235
241
  }
242
+ // B74: a pure deletion gets the `## deleted file` note in every packet, read once; it is extra
243
+ // text like the moved code, counted in the same measure. "Deleted" is explicit: the diff is not
244
+ // empty (checked above), the reader found no content (`readFileDiff` gives null ONLY for a path
245
+ // not on disk: a binary file is decoded text, an unreadable one throws) AND the path is absent
246
+ // from the reviewed tree now. The references are read in that same tree (`repoRoot`).
247
+ let deleted = '';
248
+ if (diff.content === null && isAbsent(repoRoot, diff.file)) {
249
+ const note = deps.deletionNote ?? deletionNote;
250
+ try {
251
+ deleted = await note({ repoRoot, file: diff.file });
252
+ } catch {
253
+ deleted = renderDeletionNote(diff.file, { status: 'not_checked', paths: [], more: 0 }); // the review goes on
254
+ }
255
+ }
236
256
  /** @param {ReadonlyArray<number> | null} keys - a Markdown section's group keys; null ⇒ the whole file. */
237
- const movedOf = (keys) => movedText(moves, diff.content, keys);
257
+ const movedOf = (keys) => [deleted, movedText(moves, diff.content, keys)].filter((t) => typeof t === 'string' && t.length > 0).join('\n');
238
258
  const min = minTokensOut(cfg);
239
259
  // B55: never in consensus mode — its providers (two reviewers, the judge) are the check itself
240
260
  const schemaFallback = plan.mode !== 'consensus';
@@ -466,6 +486,20 @@ async function runOne(spec, text, packet, { deps, workDir, min, cfg, schemaFallb
466
486
  return { ...meta, status: 'ok', reason: null, review: verdict.review };
467
487
  }
468
488
 
489
+ /**
490
+ * B74: whether `file` is absent from the tree at `repoRoot` (lstat says ENOENT or ENOTDIR).
491
+ * @param {string} repoRoot @param {string} file @returns {boolean}
492
+ */
493
+ function isAbsent(repoRoot, file) {
494
+ try {
495
+ lstatSync(path.join(repoRoot, file));
496
+ return false;
497
+ } catch (err) {
498
+ const code = /** @type {NodeJS.ErrnoException} */ (err).code;
499
+ return code === 'ENOENT' || code === 'ENOTDIR';
500
+ }
501
+ }
502
+
469
503
  /**
470
504
  * Two token counts added; a missing one (not a number) adds nothing, both missing ⇒ null.
471
505
  * @param {unknown} a @param {unknown} b @returns {number | null}