@codedology/code-forge 0.7.3 → 0.7.4

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.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,17 @@ waits on a second real consumer (plan §10.4, Q17).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.7.4] — 2026-10-08
10
+
11
+ ### Added
12
+
13
+ - Review: code moved between two changed files of the same block is named in a ## moved code packet section
14
+ (deterministic line matching against the block's other changed owned files, same base: >= 3 significant lines or >=
15
+ 120 significant characters; at most 12 ranges; a Markdown section packet lists only the moves in that section) in
16
+ round 1, rechecks, patch checks and the judge packet, so the source file's reviewer no longer reports the removal as a
17
+ deleted feature; every hunk and the reviewed_hunks check are unchanged, and the section counts toward the packet
18
+ budget (issue #2).
19
+
9
20
  ## [0.7.3] — 2026-10-08
10
21
 
11
22
  ### Added
package/README.md CHANGED
@@ -12,6 +12,13 @@ for cross-model, parallel, evidence-gated feature delivery — on Claude Code, C
12
12
 
13
13
  Next release:
14
14
 
15
+ - **Code moved between files is no longer read as deleted.** When a block moves code from one
16
+ file to another, each file's review packet now says where the lines went (or came from), so the
17
+ reviewer of the old file stops reporting the removal as a lost feature, also on rechecks. Every
18
+ hunk is still reviewed.
19
+
20
+ In 0.7.3:
21
+
15
22
  - **A model that keeps failing the answer format gets a stand-in.** When a reviewer's answer
16
23
  fails the schema twice on the same packet and you set `review.second_levels` for that level, the
17
24
  worker retries that packet once with the second model, and the ledger records it. Each failed
@@ -4,7 +4,7 @@
4
4
  "plans/code-forge-plan-v1.3.md §10.4",
5
5
  "plans/NIGHT-LOG.md"
6
6
  ],
7
- "block_count": 58,
7
+ "block_count": 59,
8
8
  "baseline_ids": ["B0", "B1", "B2", "B3", "B4", "B5", "B6", "B7", "B8"],
9
9
  "baseline_note": "Wave 0-1 blocks, landed before v1.3; depends_on may name them, but they have no entry under blocks.",
10
10
  "blocks": {
@@ -794,6 +794,22 @@
794
794
  "B54"
795
795
  ],
796
796
  "note": "root-added 2026-10-08; GitHub issue #2 note of 2026-10-06; also amends src/review/{engine,fixloop,validate-review}.mjs, src/worker/engine.mjs, src/session/spawn.mjs (stderr tail moved to util), src/autopilot/binnacle.mjs, test fakes, docs/how-it-works.md, skill/references/review.md"
797
+ },
798
+ "B56": {
799
+ "title": "Moved-code hint (issue #2): code moved between changed files of the same block is detected by deterministic line matching (>= 3 significant lines or >= 120 significant characters; peers in the base diffed against it, deleted peers included) and named in a ## moved code packet section on both files, in round 1, rechecks, patch checks, the judge packet and per Markdown section; extra text only (every hunk and reviewed_hunks unchanged); counted in the packet budget with one measure; unsafe peer names never read or printed; peer diffs read once per ticket with a 2 MiB per-peer cap",
800
+ "wave": 12,
801
+ "level": "L2",
802
+ "status": "landed",
803
+ "owned": [
804
+ "src/review/moved.mjs",
805
+ "test/review/moved.test.mjs",
806
+ "test/review/moved-wiring.test.mjs",
807
+ "test/worker/moved-peers-guard.test.mjs"
808
+ ],
809
+ "depends_on": [
810
+ "B55"
811
+ ],
812
+ "note": "root-added 2026-10-08; GitHub issue #2 note of 2026-10-06 (stale critical from a cross-file move); also amends src/review/{packet,engine,fixloop,sections}.mjs, src/worker/engine.mjs, docs/how-it-works.md, skill/references/review.md"
797
813
  }
798
814
  }
799
815
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codedology/code-forge",
3
- "version": "0.7.3",
3
+ "version": "0.7.4",
4
4
  "description": "Plan/harden/code/review orchestration CLI and Agent Skill for cross-model, parallel, evidence-gated feature delivery.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -15,7 +15,7 @@ The topology is decided in one place (`planSessions`). Consensus (§6) runs only
15
15
 
16
16
  ## §3 Isolation and the packet
17
17
 
18
- Every reviewer, recheck, judge and S2 session runs with cwd = a fresh empty temp dir under the run's temp root and the packet **on stdin** — a no-tools session never opens a path and the packet never enters argv. Packet order is fixed so provider prompt caching applies: lens, project rules digest (≤ 60 lines), facts-sheet excerpt for the symbols in the diff, context, diff. Context: a file at or under `review.context.whole_file_max_lines` travels whole; above it each hunk carries ± `review.context.hunk_context_lines` lines; over `review.budgets.full_in` the context shrinks to ± `review.context.min_context_lines`, then the digest is trimmed, never the diff; a diff alone over budget ⇒ `split_required`, with `tokens_in` and `budget` in the result, its ledger `review.result` row and the `review-file --wait` answer (`stop: split_required`) — except a Markdown file (`.md`, `.markdown`), which is reviewed **section by section**: the diff is cut at its headings (`#`..`######`, not inside fenced code), consecutive sections share a packet while it fits, every section packet runs the file's whole plan with its own stub guard, the file is approved only when every section is, and findings are merged with ids `S<section>.<id>`; a recheck over budget is split the same way, each open finding listed in its own section's packet. A section that alone is over budget keeps the file `split_required` (with that `section`), and so does a deleted Markdown file or one with no heading (fail-safe: one section); nothing is ever truncated. The closed-book rule is in every lens: judge only what is in the packet; a file you cannot see goes in `needs_file`, and the worker attaches it (read-only, trimmed) for **one** more round.
18
+ Every reviewer, recheck, judge and S2 session runs with cwd = a fresh empty temp dir under the run's temp root and the packet **on stdin** — a no-tools session never opens a path and the packet never enters argv. Packet order is fixed so provider prompt caching applies: lens, project rules digest (≤ 60 lines), facts-sheet excerpt for the symbols in the diff, context, diff. Context: a file at or under `review.context.whole_file_max_lines` travels whole; above it each hunk carries ± `review.context.hunk_context_lines` lines; over `review.budgets.full_in` the context shrinks to ± `review.context.min_context_lines`, then the digest is trimmed, never the diff; a diff alone over budget ⇒ `split_required`, with `tokens_in` and `budget` in the result, its ledger `review.result` row and the `review-file --wait` answer (`stop: split_required`) — except a Markdown file (`.md`, `.markdown`), which is reviewed **section by section**: the diff is cut at its headings (`#`..`######`, not inside fenced code), consecutive sections share a packet while it fits, every section packet runs the file's whole plan with its own stub guard, the file is approved only when every section is, and findings are merged with ids `S<section>.<id>`; a recheck over budget is split the same way, each open finding listed in its own section's packet. A section that alone is over budget keeps the file `split_required` (with that `section`), and so does a deleted Markdown file or one with no heading (fail-safe: one section); nothing is ever truncated. Moved code (B56): code moved between two changed files of the same block is named in a `## moved code` section before the diff (`- base lines a-b removed here were added in <file> lines c-d in this block.` / `- lines c-d were moved here from <file> (its base lines a-b) in this block.`), in round 1, rechecks, patch checks and the judge's packet. Detection is deterministic line matching against the block's other changed owned files diffed against the same base: lines trimmed with whitespace collapsed, lines with fewer than 4 letters or digits ignored, a moved stretch needs ≥ 3 such lines or ≥ 120 such characters; at most 12 ranges are listed; a Markdown section packet lists only the moves under its own headings (none ⇒ no section); the peers are listed and read once per review (one `git ls-tree <base>`, one `git diff <base>` over the peers that exist in the base — a peer deleted in the block, staged or not, is all removed lines — one `git check-ignore` and one `git diff --no-index` per new peer; `a/`/`b/` prefixes forced, `core.quotePath=false`; a peer name with a control, bidi or separator character, a quote or a backslash is skipped; a peer whose diff is over 2 MiB is skipped, never buffered past that); a removed block cut right above a heading belongs to the section holding its removed hunk; a move inside one file is not detected. It is extra text counted in the packet budget: every hunk stays, `reviewed_hunks` is unchanged, and a removal listed there is not a defect by itself — only stale references or imports are. The closed-book rule is in every lens: judge only what is in the packet; a file you cannot see goes in `needs_file`, and the worker attaches it (read-only, trimmed) for **one** more round.
19
19
 
20
20
  ## §4 The stub guard — what counts as a review (R11)
21
21
 
@@ -10,7 +10,7 @@
10
10
  # copied install with no package next to it); `test/skill/shim.test.mjs` asserts the pin
11
11
  # equals package.json's version.
12
12
  # The shim holds no logic of its own: every verb, flag and message is the CLI's.
13
- PINNED_VERSION="0.7.3"
13
+ PINNED_VERSION="0.7.4"
14
14
  set -eu
15
15
 
16
16
  self="$0"
@@ -33,7 +33,13 @@
33
33
  * reviewed (the file is then `unavailable`). The file is approved only when EVERY section is;
34
34
  * the findings of all sections are merged with ids `S<section>.<id>`, every session summary
35
35
  * names its `section`, and `sections` lists them. A section that alone is still over the
36
- * budget keeps the file `split_required` (plus `section`: its heading) — never truncated.
36
+ * budget keeps the file `split_required` (plus `section`: its heading) — never truncated;
37
+ * 8. code moved between this file and another changed file of the block (`input.peers`, both
38
+ * diffed against the block base) is named in a `## moved code` section of every packet —
39
+ * reviewers and judge; a Markdown section packet lists only the moves in that section
40
+ * (`moved.mjs`, B56). The peers' diffs are read once per review (`input.peerDiffs`, or a
41
+ * reader built from `input.peers`). It is extra text: every hunk still goes to the reviewer,
42
+ * and it counts toward the budget in the same measure.
37
43
  * Approval (until B12b's triage lands) = the final answer (the judge's in dual/consensus mode, else
38
44
  * the single reviewer's) passed the guard, says `passed: true`, and carries no `critical` or
39
45
  * `warning` finding.
@@ -47,6 +53,7 @@ import { blockKind, isDocsKind } from '../decide/escalation.mjs';
47
53
  import { fallbackRisk } from '../decide/fallback-rules.mjs';
48
54
  import { tierFor } from '../proof/tiers.mjs';
49
55
  import { assembleJudgePacket, assemblePacket, attachFiles, budgetFor, diffOnlyTokens, readFileDiff } from './packet.mjs';
56
+ import { movedText, movesFor, peerDiffReader } from './moved.mjs';
50
57
  import { isMarkdownFile, packSections, sectionFindings, sectionGroups } from './sections.mjs';
51
58
  import { FINDING_SCHEMA, minTokensOut, validateReview } from './validate-review.mjs';
52
59
  import { spawnWithTimeoutRetry } from './session-retry.mjs';
@@ -160,6 +167,10 @@ export function rulesRisk({ file, plusCount, cfg }) {
160
167
  * @property {string} [kind] - the block kind (`code`, `docs`, `contract`; B34); absent ⇒ `blockKind` of the reviewed file.
161
168
  * @property {string} [rulesDigest] @property {string} [factsExcerpt]
162
169
  * @property {string} workDir - where packet files are written (under the run's temp root).
170
+ * @property {ReadonlyArray<string>} [peers] - B56: the block's OTHER changed files (repo-relative);
171
+ * code moved between this file and one of them is named in every packet (`## moved code`).
172
+ * @property {() => Promise<import('./moved.mjs').PeerDiff[]>} [peerDiffs] - B56: a memoised reader
173
+ * of those files' diffs (`peerDiffReader`), shared by the caller across the round; wins over `peers`.
163
174
  */
164
175
 
165
176
  /**
@@ -212,6 +223,17 @@ export async function reviewFile(input, deps) {
212
223
  if (plan.refused) return { status: 'refused', reason: plan.refused, approved: false, ...head, sessions: [] };
213
224
  if (diff.diffText.trim().length === 0) return { status: 'no_change', approved: false, ...head, sessions: [] };
214
225
  mkdirSync(workDir, { recursive: true, mode: 0o700 });
226
+ // B56: the file's moves, read once; every packet and every measure of a part uses `movedOf`
227
+ /** @type {import('./moved.mjs').Moves | null} */
228
+ let moves = null;
229
+ try {
230
+ const peerDiffs = input.peerDiffs ?? peerDiffReader({ repoRoot, base: input.base, file: diff.file, peers: input.peers });
231
+ moves = await movesFor({ diff, peerDiffs });
232
+ } catch {
233
+ moves = null; // no hint; the review goes on
234
+ }
235
+ /** @param {ReadonlyArray<number> | null} keys - a Markdown section's group keys; null ⇒ the whole file. */
236
+ const movedOf = (keys) => movedText(moves, diff.content, keys);
215
237
  const min = minTokensOut(cfg);
216
238
  // B55: never in consensus mode — its providers (two reviewers, the judge) are the check itself
217
239
  const schemaFallback = plan.mode !== 'consensus';
@@ -237,16 +259,17 @@ export async function reviewFile(input, deps) {
237
259
 
238
260
  const reviewers = plan.sessions.filter((s) => s.role === 'reviewer');
239
261
  const judgeSpec = plan.sessions.find((s) => s.role === 'judge');
240
- /** @param {import('./packet.mjs').FileDiff} part */
241
- const packetsFor = (part) => reviewers.map((spec) => assemblePacket({ diff: part, lens: spec.lens, rulesDigest: input.rulesDigest, factsExcerpt: input.factsExcerpt, cfg }));
262
+ /** @param {import('./packet.mjs').FileDiff} part @param {ReadonlyArray<number> | null} [keys] */
263
+ const packetsFor = (part, keys = null) => reviewers.map((spec) => assemblePacket({ diff: part, lens: spec.lens, rulesDigest: input.rulesDigest, factsExcerpt: input.factsExcerpt, cfg, moved: movedOf(keys) }));
242
264
 
243
265
  /**
244
266
  * The plan on one packet set (the whole file, or one Markdown section): reviewers, then the judge.
245
267
  * @param {import('./packet.mjs').FileDiff} part @param {Array<Record<string, any>>} packets
246
268
  * @param {Record<string, any>} [rowExtra] - added to the `review.plan` row (`section`).
269
+ * @param {ReadonlyArray<number> | null} [keys] - the section's group keys (its moved-code hint).
247
270
  * @returns {Promise<{status: 'reviewed' | 'unavailable', reason?: string, approved: boolean, summary?: string, findings?: Array<Record<string, any>>, sessions: Array<Record<string, any>>}>}
248
271
  */
249
- const reviewPart = async (part, packets, rowExtra = {}) => {
272
+ const reviewPart = async (part, packets, rowExtra = {}, keys = null) => {
250
273
  await note(deps, { event: 'review.plan', depth_unconstrained: plan.depth, depth_chosen: plan.depth, degrade_step: 0, risk, context_mode: packets[0]?.contextMode ?? null, forecast_tokens: packets.reduce((n, p) => n + p.tokensIn, 0), ...rowExtra });
251
274
  const done = await Promise.all(reviewers.map((spec, i) => run(spec, /** @type {any} */ (packets[i]))));
252
275
  const sessions = done.map(publicSummary);
@@ -258,7 +281,7 @@ export async function reviewFile(input, deps) {
258
281
  let final = done[0];
259
282
  if (judgeSpec) {
260
283
  const bySlot = Object.fromEntries(done.map((s) => [s.slot, s.review]));
261
- const packet = assembleJudgePacket({ diff: part, reports: { A: bySlot.A, B: bySlot.B }, cfg });
284
+ const packet = assembleJudgePacket({ diff: part, reports: { A: bySlot.A, B: bySlot.B }, cfg, moved: movedOf(keys) });
262
285
  final = await runOne(judgeSpec, packet.text, packet, { deps, workDir, min, cfg, schemaFallback });
263
286
  sessions.push(publicSummary(final));
264
287
  if (final.status !== 'ok') {
@@ -281,18 +304,25 @@ export async function reviewFile(input, deps) {
281
304
  if (!isMarkdownFile(diff.file)) return { status: 'split_required', approved: false, ...head, tokens_in: split.tokensIn, budget: split.budget, sessions: [] };
282
305
 
283
306
  // B54: a Markdown file over the budget is reviewed section by section
284
- /** @param {import('./packet.mjs').FileDiff} part @returns {number} the diff-only packet tokens, largest over the lenses. */
285
- const measure = (part) => Math.max(...reviewers.map((spec) => diffOnlyTokens({ diff: part, lens: spec.lens })));
307
+ /**
308
+ * @param {import('./packet.mjs').FileDiff} part @param {ReadonlyArray<{key: number}> | null} [groups] - null ⇒ the whole file.
309
+ * @returns {number} the diff-only packet tokens, largest over the lenses.
310
+ */
311
+ const measure = (part, groups = null) => {
312
+ const moved = movedOf(groups === null ? null : groups.map((g) => g.key));
313
+ return Math.max(...reviewers.map((spec) => diffOnlyTokens({ diff: part, lens: spec.lens, moved })));
314
+ };
286
315
  const packed = packSections({ diff, budget: split.budget, measure });
287
316
  /**
288
317
  * A `split_required` outcome, its size always numeric (measured here when the packet lacks it).
289
318
  * @param {import('./packet.mjs').FileDiff} part @param {{tokensIn?: unknown, budget?: unknown}} over @param {string | null} section
319
+ * @param {ReadonlyArray<{key: number}> | null} [groups] - the part's heading groups (its moved-code hint); null ⇒ the whole file.
290
320
  */
291
- const splitOutcome = (part, over, section) => ({
321
+ const splitOutcome = (part, over, section, groups = null) => ({
292
322
  status: 'split_required',
293
323
  approved: false,
294
324
  ...head,
295
- tokens_in: Number.isFinite(over.tokensIn) ? over.tokensIn : measure(part),
325
+ tokens_in: Number.isFinite(over.tokensIn) ? over.tokensIn : measure(part, groups),
296
326
  budget: Number.isFinite(over.budget) ? over.budget : split.budget,
297
327
  ...(section ? { section } : {}),
298
328
  sessions: [],
@@ -300,7 +330,7 @@ export async function reviewFile(input, deps) {
300
330
  if (packed.status !== 'ok') {
301
331
  // a named section is measured on its own diff, never the whole file's
302
332
  const group = packed.section ? sectionGroups(diff)?.find((g) => g.heading === packed.section) : undefined;
303
- return splitOutcome(group ? group.diff : diff, packed, packed.section);
333
+ return splitOutcome(group ? group.diff : diff, packed, packed.section, group ? [group] : null);
304
334
  }
305
335
  // never approve with no section: no packet ⇒ no session ⇒ split_required at the whole size
306
336
  if (packed.sections.length === 0) return splitOutcome(diff, split, null);
@@ -308,10 +338,10 @@ export async function reviewFile(input, deps) {
308
338
  // one measure, so with the real packer none is ever not `ok`: this is a DEFENSIVE guard (a test
309
339
  // reaches it through a mocked packer) — one that is not `ok` stops the file as split_required,
310
340
  // sized on that section's own diff
311
- const sectionPackets = packed.sections.map((section) => packetsFor(section.diff));
341
+ const sectionPackets = packed.sections.map((section) => packetsFor(section.diff, section.keys));
312
342
  for (const [i, set] of sectionPackets.entries()) {
313
343
  const over = /** @type {{tokensIn?: unknown, budget?: unknown} | undefined} */ (set.find((p) => p.status !== 'ok'));
314
- if (over) return splitOutcome(packed.sections[i].diff, over, packed.sections[i].headings[0] ?? '(before first heading)');
344
+ if (over) return splitOutcome(packed.sections[i].diff, over, packed.sections[i].headings[0] ?? '(before first heading)', packed.sections[i].keys.map((key) => ({ key })));
315
345
  }
316
346
  const sections = packed.sections.map((s) => ({ index: s.index, headings: s.headings, hunks: s.diff.hunks.length }));
317
347
  /** @type {Array<Record<string, any>>} */
@@ -321,7 +351,7 @@ export async function reviewFile(input, deps) {
321
351
  const summaries = [];
322
352
  let approved = true;
323
353
  for (const [i, section] of packed.sections.entries()) {
324
- const part = await reviewPart(section.diff, sectionPackets[i], { section: section.index, sections: packed.sections.length });
354
+ const part = await reviewPart(section.diff, sectionPackets[i], { section: section.index, sections: packed.sections.length }, section.keys);
325
355
  sessions.push(...part.sessions.map((s) => ({ ...s, section: section.index })));
326
356
  if (part.status !== 'reviewed') return { status: part.status, reason: part.reason, approved: false, ...head, sections, sessions };
327
357
  approved = approved && part.approved;
@@ -59,6 +59,15 @@
59
59
  * findings get ids `S<section>.<id>`; any section session unavailable ⇒ `retry` of the whole
60
60
  * round. A section still over the budget alone ⇒ the terminal stop above (`split_required`).
61
61
  *
62
+ * Moved code (B56): when `deps.peerDiffs` (or `deps.peers`) names the block's other changed files,
63
+ * every recheck and patch_check packet carries the file's `## moved code` section — computed from
64
+ * the file's diff against the block base (`deps.base`), never from the fix hunks, so a stale
65
+ * "feature deleted" finding on code that moved to another file meets the same hint as round 1. A
66
+ * Markdown section packet lists only the moves in its section. Sizing (`recheckTokens`,
67
+ * `packRecheck`) uses the same section text. The peers are read once per deps object (one ticket):
68
+ * the caller's `deps.peerDiffs` reader, else one memoised reader per deps (`recheckPeerDiffs`). Any
69
+ * failure there means no hint, never a failed recheck.
70
+ *
62
71
  * Schema misses (B55): a recheck (or patch_check) session whose answer fails the schema at its
63
72
  * packet's second miss is tried once on `review.second_levels.L2` when configured
64
73
  * (`schema-fallback.mjs`); only when that fails too is the round `unavailable` as below.
@@ -79,6 +88,7 @@ import { decisionProblem, grantCoverProblem } from './gate-check.mjs';
79
88
  import { contentHash, gitChildEnv } from '../worker/ticket.mjs';
80
89
  import { parseDiff } from './context.mjs';
81
90
  import { assemblePacket, assertPacketPath, diffOnlyTokens, readFileDiff } from './packet.mjs';
91
+ import { movedText, movesFor, peerDiffReader } from './moved.mjs';
82
92
  import { isMarkdownFile, packSections, sectionFindings, sectionGroups, sectionOf } from './sections.mjs';
83
93
  import { cameFromJudge, resolvedBand, triageFindings } from './triage.mjs';
84
94
  import { FINDING_SCHEMA, minTokensOut, validateReview } from './validate-review.mjs';
@@ -128,7 +138,9 @@ import { afterSchemaMiss, logSecondLevelMiss } from './schema-fallback.mjs';
128
138
  * @property {(row: Record<string, any>) => Promise<unknown>} [writeRow]
129
139
  * @property {(req: {file: string, round: number, level: string, open: Finding[], l3_patch: boolean}) => Promise<unknown>} [fix] - a fresh coder session at `level`; `l3_patch` = the block's L3 patch is in the tree (§3.6).
130
140
  * @property {(req: {file: string, level: 'L3', open: Finding[]}) => Promise<unknown>} [patch] - the L3 patch rung (≤ 80 lines, owned files only).
131
- * @property {string | null} [base] - the block base (only for `recheck_scope: file`).
141
+ * @property {string | null} [base] - the block base (`recheck_scope: file`, and the B56 moved-code diff).
142
+ * @property {ReadonlyArray<string>} [peers] - B56: the block's other changed files (moved-code hints).
143
+ * @property {() => Promise<import('./moved.mjs').PeerDiff[]>} [peerDiffs] - B56: a memoised reader of their diffs (`peerDiffReader`); wins over `peers`.
132
144
  * @property {string} [rulesDigest] @property {string} [factsExcerpt]
133
145
  * @property {number} [extraRounds] - B47: rounds past `review.max_rounds_per_file` granted by autopilot (`extraRoundsFor`).
134
146
  * @property {import('./schema-fallback.mjs').SchemaHistory} [schemaHistory] - B55: the packet's signed schema-miss history; absent ⇒ no second-level try.
@@ -170,11 +182,14 @@ export function mergePacketInfo(info, packet) {
170
182
  * or hunks that do not parse one to one) ⇒ `split_required` at the whole size, never sectioned;
171
183
  * a finding no packet holds is listed in the LAST packet — every open finding is listed once.
172
184
  * @param {import('./packet.mjs').FileDiff} diff @param {Finding[]} open @param {number} budget
185
+ * @param {import('./moved.mjs').Moves | null} [moves] - the file's moves (B56): each candidate is sized
186
+ * with the `## moved code` text of ITS sections (`movedText`), the whole size with all of them.
173
187
  * @returns {{status: 'ok', sections: Array<import('./sections.mjs').Section & {open: Finding[]}>} | {status: 'split_required', tokensIn: number, budget: number, section: string | null}}
174
188
  */
175
- export function packRecheck(diff, open, budget) {
189
+ export function packRecheck(diff, open, budget, moves = null) {
190
+ const moved = movedText(moves, diff.content, null);
176
191
  const groups = sectionGroups(diff);
177
- if (groups === null || groups.length === 0) return { status: 'split_required', tokensIn: recheckTokens(diff, open), budget, section: null };
192
+ if (groups === null || groups.length === 0) return { status: 'split_required', tokensIn: recheckTokens(diff, open, moved), budget, section: null };
178
193
  /** @type {Map<Finding, number>} */
179
194
  const keyOf = new Map();
180
195
  for (const f of open) {
@@ -183,10 +198,10 @@ export function packRecheck(diff, open, budget) {
183
198
  }
184
199
  /** @param {ReadonlyArray<number>} keys @returns {Finding[]} the open findings of those groups, in order. */
185
200
  const openIn = (keys) => open.filter((f) => keyOf.has(f) && keys.includes(/** @type {number} */ (keyOf.get(f))));
186
- const packed = packSections({ diff, budget, measure: (part, set) => recheckTokens(part, openIn(set.map((g) => g.key))) });
201
+ const packed = packSections({ diff, budget, measure: (part, set) => (set === null ? recheckTokens(part, open, moved) : recheckTokens(part, openIn(set.map((g) => g.key)), movedText(moves, diff.content, set.map((g) => g.key)))) });
187
202
  if (packed.status !== 'ok') return packed;
188
203
  const sections = packed.sections.map((sec) => ({ ...sec, open: openIn(sec.keys) }));
189
- if (sections.length === 0) return { status: 'split_required', tokensIn: recheckTokens(diff, open), budget, section: null };
204
+ if (sections.length === 0) return { status: 'split_required', tokensIn: recheckTokens(diff, open, moved), budget, section: null };
190
205
  // defensive: a finding no packet holds goes to the last packet (it is sized there by the check
191
206
  // every packet passes before any session) — never dropped, never closed unseen
192
207
  const listed = new Set(sections.flatMap((sec) => sec.open));
@@ -332,24 +347,77 @@ const openFindingsText = (open) => `${OPEN_FINDINGS_HEAD}${open.length > 0 ? ope
332
347
  * The size a recheck packet is held to (B54): its diff-only `recheck` packet (the measure behind
333
348
  * `split_required`) plus the open-findings list `buildRecheckPacket` appends. Section packing and
334
349
  * the check before any session use this ONE function.
335
- * @param {import('./packet.mjs').FileDiff} diff @param {ReadonlyArray<Finding>} open @returns {number}
350
+ * @param {import('./packet.mjs').FileDiff} diff @param {ReadonlyArray<Finding>} open
351
+ * @param {string} [moved] - the `## moved code` section the packet carries (B56).
352
+ * @returns {number}
336
353
  */
337
- export function recheckTokens(diff, open) {
338
- return diffOnlyTokens({ diff, lens: 'recheck' }) + Math.ceil(Buffer.byteLength(openFindingsText(open)) / 4);
354
+ export function recheckTokens(diff, open, moved = '') {
355
+ return diffOnlyTokens({ diff, lens: 'recheck', moved }) + Math.ceil(Buffer.byteLength(openFindingsText(open)) / 4);
339
356
  }
340
357
 
341
358
  /**
342
359
  * The recheck packet: the `recheck` lens on the fix hunks (hunk form, never the whole file), then
343
360
  * the open findings by id. Over budget ⇒ `minimal` (see the module doc).
344
- * @param {{diff: import('./packet.mjs').FileDiff, open: Finding[], cfg?: Record<string, any>, contextMode?: 'auto' | 'recheck', rulesDigest?: string, factsExcerpt?: string}} opts
361
+ * @param {{diff: import('./packet.mjs').FileDiff, open: Finding[], cfg?: Record<string, any>, contextMode?: 'auto' | 'recheck', rulesDigest?: string, factsExcerpt?: string, moved?: string}} opts -
362
+ * `moved`: the file's `## moved code` section (B56), '' for none.
345
363
  */
346
- export function buildRecheckPacket({ diff, open, cfg, contextMode = 'recheck', rulesDigest, factsExcerpt }) {
347
- const packet = assemblePacket({ diff, lens: 'recheck', cfg, contextMode, rulesDigest, factsExcerpt });
364
+ export function buildRecheckPacket({ diff, open, cfg, contextMode = 'recheck', rulesDigest, factsExcerpt, moved = '' }) {
365
+ const packet = assemblePacket({ diff, lens: 'recheck', cfg, contextMode, rulesDigest, factsExcerpt, moved });
348
366
  if (packet.status !== 'ok') return packet;
349
367
  const text = `${packet.text}${openFindingsText(open)}`;
350
368
  return { ...packet, text, tokensIn: packet.tokensIn + Math.ceil(Buffer.byteLength(text.slice(packet.text.length)) / 4) };
351
369
  }
352
370
 
371
+ /**
372
+ * B56: the peer readers built from `deps.peers` (no `deps.peerDiffs`): per deps object, one reader
373
+ * per (repoRoot, base, file, peers) key, so A → B → A on one deps reads A's peers once.
374
+ * @type {WeakMap<object, Map<string, () => Promise<import('./moved.mjs').PeerDiff[]>>>}
375
+ */
376
+ const peerReaders = new WeakMap();
377
+
378
+ /**
379
+ * The peers' diffs reader of a recheck: `deps.peerDiffs` when the caller gave one, else ONE
380
+ * memoised `peerDiffReader` per deps object and (repoRoot, base, file, peers) key, so the peers are
381
+ * read once however many rechecks run with the same deps (one ticket), whatever the order.
382
+ * @param {LoopDeps} deps @param {string} base @param {string} rel
383
+ * @returns {() => Promise<import('./moved.mjs').PeerDiff[]>}
384
+ */
385
+ function recheckPeerDiffs(deps, base, rel) {
386
+ if (deps.peerDiffs) return deps.peerDiffs;
387
+ const key = JSON.stringify([deps.repoRoot, base, rel, deps.peers ?? null]);
388
+ let readers = peerReaders.get(deps);
389
+ if (!readers) {
390
+ readers = new Map();
391
+ peerReaders.set(deps, readers);
392
+ }
393
+ const known = readers.get(key);
394
+ if (known) return known;
395
+ const reader = peerDiffReader({ repoRoot: deps.repoRoot, base, file: rel, peers: deps.peers });
396
+ readers.set(key, reader);
397
+ return reader;
398
+ }
399
+
400
+ /**
401
+ * The recheck's moves (B56): from the file's diff against the block base (the one already read
402
+ * for `recheck_scope: file`, else read here), never from the fix hunks; the peers' diffs from the
403
+ * memoised reader (`recheckPeerDiffs`). No base, no peer, or ANY failure (building the reader
404
+ * included) ⇒ null: no hint, the recheck goes on.
405
+ * @param {LoopDeps} deps @param {string} rel @param {import('./packet.mjs').FileDiff | null} baseDiff
406
+ * @returns {Promise<import('./moved.mjs').Moves | null>}
407
+ */
408
+ async function recheckMoves(deps, rel, baseDiff) {
409
+ try {
410
+ if (typeof deps.base !== 'string') return null;
411
+ const peerDiffs = recheckPeerDiffs(deps, deps.base, rel);
412
+ // nothing to read when there is no peer: the base diff is read only for a real peer set
413
+ if ((await peerDiffs()).length === 0) return null;
414
+ const diff = baseDiff ?? (await readFileDiff({ repoRoot: deps.repoRoot, file: rel, base: deps.base }));
415
+ return await movesFor({ diff, peerDiffs });
416
+ } catch {
417
+ return null;
418
+ }
419
+ }
420
+
353
421
  /** @param {Finding} f @param {ReadonlyArray<Hunk>} hunks @returns {boolean} */
354
422
  export function insideHunks(f, hunks) {
355
423
  return hunks.some((h) => {
@@ -568,16 +636,17 @@ export async function runRound(state, deps, opts = {}) {
568
636
  draft.open = [];
569
637
  if (pending.length > 0) {
570
638
  const contextMode = s.scope === 'file' ? 'auto' : 'recheck';
571
- const whole = buildRecheckPacket({ diff, open: pending, cfg: deps.cfg, contextMode, rulesDigest: deps.rulesDigest, factsExcerpt: deps.factsExcerpt });
572
- /** @type {Array<{index: number | null, heading: string | null, diff: import('./packet.mjs').FileDiff, open: Finding[]}>} */
573
- let parts = [{ index: null, heading: null, diff, open: pending }];
639
+ const moves = await recheckMoves(deps, current.rel, s.scope === 'file' ? diff : null);
640
+ const whole = buildRecheckPacket({ diff, open: pending, cfg: deps.cfg, contextMode, rulesDigest: deps.rulesDigest, factsExcerpt: deps.factsExcerpt, moved: movedText(moves, diff.content, null) });
641
+ /** @type {Array<{index: number | null, heading: string | null, diff: import('./packet.mjs').FileDiff, open: Finding[], keys: number[] | null}>} */
642
+ let parts = [{ index: null, heading: null, diff, open: pending, keys: null }];
574
643
  if (whole.status !== 'ok') {
575
644
  // deterministic (the same hunks build the same packet): terminal, never a retry — except
576
645
  // a Markdown file whose packet is `split_required`, rechecked section by section (B54);
577
646
  // any other status stops as it always did
578
- const packed = whole.status === 'split_required' && isMarkdownFile(current.rel) ? packRecheck(diff, pending, whole.budget) : null;
647
+ const packed = whole.status === 'split_required' && isMarkdownFile(current.rel) ? packRecheck(diff, pending, whole.budget, moves) : null;
579
648
  if (!packed || packed.status !== 'ok') return stopBeforeRound(state, deps, kind, current.rel, packed ?? whole);
580
- parts = packed.sections.map((sec) => ({ index: sec.index, heading: sec.headings[0], diff: sec.diff, open: sec.open }));
649
+ parts = packed.sections.map((sec) => ({ index: sec.index, heading: sec.headings[0], diff: sec.diff, open: sec.open, keys: sec.keys }));
581
650
  }
582
651
  // EVERY packet is built and held to the budget BEFORE any session runs: a section over it
583
652
  // stops the round as split_required (with its heading) before anything is spent
@@ -588,10 +657,11 @@ export async function runRound(state, deps, opts = {}) {
588
657
  built.push(whole);
589
658
  continue;
590
659
  }
591
- const packet = buildRecheckPacket({ diff: part.diff, open: part.open, cfg: deps.cfg, contextMode, rulesDigest: deps.rulesDigest, factsExcerpt: deps.factsExcerpt });
660
+ const moved = movedText(moves, diff.content, part.keys);
661
+ const packet = buildRecheckPacket({ diff: part.diff, open: part.open, cfg: deps.cfg, contextMode, rulesDigest: deps.rulesDigest, factsExcerpt: deps.factsExcerpt, moved });
592
662
  // a packet that fails to build stops with ITS status (and size, when it has one)
593
663
  if (packet.status !== 'ok') return stopBeforeRound(state, deps, kind, current.rel, { ...packet, section: part.heading });
594
- const size = recheckTokens(part.diff, part.open);
664
+ const size = recheckTokens(part.diff, part.open, moved);
595
665
  if (size > whole.budget) return stopBeforeRound(state, deps, kind, current.rel, { status: 'split_required', tokensIn: size, budget: whole.budget, section: part.heading });
596
666
  built.push(packet);
597
667
  }
@@ -0,0 +1,399 @@
1
+ /**
2
+ * Moved code across the files of one block (B56, issue #2).
3
+ *
4
+ * Each changed file is reviewed alone, so a block that moves code from file A to file B shows A's
5
+ * reviewer only removed lines — read as "feature deleted", a critical that came back on every
6
+ * recheck. This module finds, deterministically and without any model call, the removed runs of
7
+ * one file that were ADDED in another changed file of the same block (both diffed against the
8
+ * same block base), and the reverse, and renders them as the packet's `## moved code` section.
9
+ * The section is extra text only: every hunk still goes to the reviewer and `reviewed_hunks` is
10
+ * unchanged. A move inside ONE file (removed in one hunk, added in another) is not detected.
11
+ *
12
+ * Detection rule:
13
+ * - a diff line is NORMALISED by trimming it and collapsing every whitespace run to one space;
14
+ * - a line is SIGNIFICANT when its normalised form holds at least {@link MIN_LINE_ALNUM} letters
15
+ * or digits — blank lines, lone braces/brackets/`});`, `*` comment rules are not;
16
+ * - a RUN is a maximal sequence of adjacent `-` (or `+`) lines in one hunk; its significant lines
17
+ * are compared in order, the trivial ones skipped (they never break a match);
18
+ * - a removed run of this file matches an added run of a peer (and an added run of this file a
19
+ * removed run of a peer) where their significant lines are EQUAL one after the other; the longest
20
+ * such stretch from each removed line wins (ties: the first peer in sorted order, then the first
21
+ * line), and it counts as moved when it has at least {@link MIN_MOVED_LINES} significant lines
22
+ * or at least {@link MIN_MOVED_CHARS} significant characters (normalised lengths summed);
23
+ * - line numbers are the BASE numbering for removed lines and the current numbering for added
24
+ * lines; a range runs from the first to the last significant line of the stretch.
25
+ *
26
+ * Peers are read ONCE per review (`peerDiffReader`, memoised): one `git ls-tree <base>` (which
27
+ * peers exist in the base — a peer deleted in the block, staged or not, is diffed against the base
28
+ * with every line removed), one `git diff <base>` over those, one `git check-ignore` and one
29
+ * `git diff --no-index` per new peer on disk.
30
+ * Every peer diff is read with `a/`/`b/` prefixes forced (whatever `diff.noprefix` or
31
+ * `diff.mnemonicPrefix` say) and under the {@link MAX_DIFF_BYTES} cap: the listing over the cap is
32
+ * re-read peer by peer, and a peer over the cap alone is skipped — never buffered past the cap.
33
+ *
34
+ * Markdown section packets (B54) carry only the moves that fall in the section: each move has a
35
+ * position in the CURRENT file — an added stretch: its first line; a removed stretch: the current
36
+ * line just BEFORE the cut (0 at the top), so a block cut right above a heading belongs to the
37
+ * section that holds its removed hunk, exactly as `sectionGroups` cuts the hunks — and a section
38
+ * holds the positions under its headings.
39
+ *
40
+ * The section lists at most {@link MAX_MOVED_ENTRIES} ranges (then one "N more" line), so its size
41
+ * is capped; it counts toward the packet budget like any other section (`packet.mjs`).
42
+ */
43
+
44
+ import { existsSync } from 'node:fs';
45
+ import path from 'node:path';
46
+ import { exec } from '../util/exec.mjs';
47
+ import { assertPacketPath, isSecretLike, plainGitEnv, reviewGitEnv } from './packet.mjs';
48
+ import { markdownHeadings } from './sections.mjs';
49
+
50
+ /** A significant line has at least this many letters or digits once normalised. */
51
+ export const MIN_LINE_ALNUM = 4;
52
+ /** A moved stretch has at least this many significant lines … */
53
+ export const MIN_MOVED_LINES = 3;
54
+ /** … or at least this many significant characters (normalised lengths summed). */
55
+ export const MIN_MOVED_CHARS = 120;
56
+ /** The section lists at most this many ranges. */
57
+ export const MAX_MOVED_ENTRIES = 12;
58
+ /** At most this many peer files are read for one file. */
59
+ export const MAX_PEERS = 50;
60
+ /** A peer (or own) diff larger than this many bytes is not scanned. */
61
+ export const MAX_DIFF_BYTES = 2 * 1024 * 1024;
62
+
63
+ /** At most this many peer positions are tried for one line (a very common line stays cheap). */
64
+ const MAX_CANDIDATES = 200;
65
+ const GIT_TIMEOUT_MS = 30000;
66
+ /**
67
+ * `git diff` for a peer: `a/`/`b/` prefixes forced (the chunks are matched on them), whatever the
68
+ * repository's `diff.noprefix` / `diff.mnemonicPrefix` say; non-ASCII names unquoted
69
+ * (`core.quotePath=false`; a name git still quotes is never a peer — `printable`); no colour, no
70
+ * external diff.
71
+ */
72
+ export const GIT_DIFF = Object.freeze(['git', '-c', 'core.quotePath=false', '-c', 'diff.noprefix=false', '-c', 'diff.mnemonicPrefix=false', 'diff', '--no-color', '--no-ext-diff', '--src-prefix=a/', '--dst-prefix=b/']);
73
+
74
+ /** The `## moved code` section heading. */
75
+ export const MOVED_HEAD = '## moved code';
76
+
77
+ const ALNUM = /[\p{L}\p{N}]/gu;
78
+ /**
79
+ * A path printed in the packet may not carry a control character (it could forge a section or
80
+ * reorder the text): C0, DEL, C1, the line/paragraph separators and the bidi controls.
81
+ */
82
+ const CONTROL = /[\u0000-\u001f\u007f-\u009f\u2028\u2029\u200e\u200f\u202a-\u202e\u2066-\u2069]/;
83
+ /** … nor a character git quotes in a diff header even with `core.quotePath=false` (`"`, `\`). */
84
+ const QUOTED = /["\\]/;
85
+
86
+ /** @param {unknown} p @returns {boolean} whether `p` may be printed and matched as a peer path. */
87
+ const printable = (p) => typeof p === 'string' && !CONTROL.test(p) && !QUOTED.test(p);
88
+
89
+ /** @param {string} line @returns {string} */
90
+ export const normaliseLine = (line) => line.trim().replace(/\s+/g, ' ');
91
+
92
+ /** @param {string} norm @returns {boolean} */
93
+ export const isSignificant = (norm) => (norm.match(ALNUM)?.length ?? 0) >= MIN_LINE_ALNUM;
94
+
95
+ /**
96
+ * @typedef {{line: number, norm: string, at: number}} SigLine - `line`: its number on its own side;
97
+ * `at`: its position in the current file (a removed line: the current line just before the cut, 0 at the top).
98
+ */
99
+ /** @typedef {SigLine[]} Run - the significant lines of one run, in order. */
100
+ /** @typedef {{file: string, diffText: string}} PeerDiff */
101
+
102
+ /**
103
+ * The removed and added runs of one unified diff (one file), significant lines only.
104
+ * @param {string} diffText @returns {{removed: Run[], added: Run[]}}
105
+ */
106
+ export function diffRuns(diffText) {
107
+ /** @type {Run[]} */
108
+ const removed = [];
109
+ /** @type {Run[]} */
110
+ const added = [];
111
+ if (typeof diffText !== 'string' || Buffer.byteLength(diffText) > MAX_DIFF_BYTES) return { removed, added };
112
+ const lines = diffText.split('\n');
113
+ const first = lines.findIndex((l) => l.startsWith('@@'));
114
+ if (first < 0) return { removed, added };
115
+ let oldNo = 0;
116
+ let newNo = 0;
117
+ /** @type {'-' | '+' | null} */
118
+ let open = null;
119
+ /** @type {Run} */
120
+ let run = [];
121
+ const close = () => {
122
+ if (open === '-' && run.length > 0) removed.push(run);
123
+ if (open === '+' && run.length > 0) added.push(run);
124
+ open = null;
125
+ run = [];
126
+ };
127
+ for (const line of lines.slice(first)) {
128
+ const m = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,(\d+))? @@/.exec(line);
129
+ if (m) {
130
+ close();
131
+ oldNo = Number(m[1]);
132
+ // an empty new side names the line BEFORE the change: the cut sits after it
133
+ newNo = m[3] === '0' ? Number(m[2]) + 1 : Number(m[2]);
134
+ continue;
135
+ }
136
+ const sign = line[0];
137
+ if (sign !== '-' && sign !== '+') {
138
+ close();
139
+ // a context line advances both sides; `` advances none
140
+ if (sign === ' ') {
141
+ oldNo += 1;
142
+ newNo += 1;
143
+ }
144
+ continue;
145
+ }
146
+ if (open !== sign) close();
147
+ open = sign;
148
+ const at = newNo - 1;
149
+ const no = sign === '-' ? oldNo++ : newNo++;
150
+ const norm = normaliseLine(line.slice(1));
151
+ if (isSignificant(norm)) run.push({ line: no, norm, at: sign === '-' ? at : no });
152
+ }
153
+ close();
154
+ return { removed, added };
155
+ }
156
+
157
+ /**
158
+ * @typedef {object} MoveMatch
159
+ * @property {number} start - first significant line of the stretch in THIS file (its own side).
160
+ * @property {number} end
161
+ * @property {number} at - its position in the current file (section filtering).
162
+ * @property {string} peer - the other file.
163
+ * @property {number} peerStart
164
+ * @property {number} peerEnd
165
+ */
166
+
167
+ /** @typedef {{out: MoveMatch[], into: MoveMatch[]}} Moves */
168
+
169
+ /**
170
+ * The moved stretches of `own` runs found in the `peers` runs (see the module doc).
171
+ * @param {Run[]} own @param {Array<{file: string, runs: Run[]}>} peers @returns {MoveMatch[]}
172
+ */
173
+ export function matchRuns(own, peers) {
174
+ /** @type {Map<string, Array<{p: number, r: number, i: number}>>} */
175
+ const index = new Map();
176
+ peers.forEach((peer, p) =>
177
+ peer.runs.forEach((run, r) =>
178
+ run.forEach((sig, i) => {
179
+ const at = index.get(sig.norm);
180
+ if (at) at.push({ p, r, i });
181
+ else index.set(sig.norm, [{ p, r, i }]);
182
+ }),
183
+ ),
184
+ );
185
+ /** @type {MoveMatch[]} */
186
+ const out = [];
187
+ for (const run of own) {
188
+ let k = 0;
189
+ while (k < run.length) {
190
+ let best = { len: 0, chars: 0, p: -1, r: -1, i: -1 };
191
+ for (const c of (index.get(run[k].norm) ?? []).slice(0, MAX_CANDIDATES)) {
192
+ const target = peers[c.p].runs[c.r];
193
+ let len = 0;
194
+ let chars = 0;
195
+ while (k + len < run.length && c.i + len < target.length && run[k + len].norm === target[c.i + len].norm) {
196
+ chars += run[k + len].norm.length;
197
+ len += 1;
198
+ }
199
+ if (len > best.len) best = { len, chars, ...c };
200
+ }
201
+ if (best.len > 0 && (best.len >= MIN_MOVED_LINES || best.chars >= MIN_MOVED_CHARS)) {
202
+ const target = peers[best.p].runs[best.r];
203
+ out.push({ start: run[k].line, end: run[k + best.len - 1].line, at: run[k].at, peer: peers[best.p].file, peerStart: target[best.i].line, peerEnd: target[best.i + best.len - 1].line });
204
+ k += best.len;
205
+ } else {
206
+ k += 1;
207
+ }
208
+ }
209
+ }
210
+ return out;
211
+ }
212
+
213
+ /**
214
+ * The moves between one file's diff and its peers' diffs (pure).
215
+ * @param {{diff: {diffText: string}, peers: ReadonlyArray<PeerDiff>}} opts @returns {Moves}
216
+ */
217
+ export function movedCode({ diff, peers }) {
218
+ const others = [...peers]
219
+ .filter((p) => printable(p.file))
220
+ .sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : 0))
221
+ .slice(0, MAX_PEERS)
222
+ .map((p) => ({ file: p.file, runs: diffRuns(p.diffText) }));
223
+ if (others.length === 0) return { out: [], into: [] };
224
+ const own = diffRuns(diff.diffText);
225
+ return {
226
+ out: matchRuns(own.removed, others.map((p) => ({ file: p.file, runs: p.runs.added }))),
227
+ into: matchRuns(own.added, others.map((p) => ({ file: p.file, runs: p.runs.removed }))),
228
+ };
229
+ }
230
+
231
+ /** @param {number} a @param {number} b @returns {string} */
232
+ const range = (a, b) => (a === b ? `${a}` : `${a}-${b}`);
233
+
234
+ /**
235
+ * The `## moved code` section for these moves: '' when there is none.
236
+ * @param {Moves} moves @returns {string}
237
+ */
238
+ export function renderMoved({ out, into }) {
239
+ if (out.length === 0 && into.length === 0) return '';
240
+ const entries = [
241
+ ...out.map((m) => `- base lines ${range(m.start, m.end)} removed here were added in ${m.peer} lines ${range(m.peerStart, m.peerEnd)} in this block.`),
242
+ ...into.map((m) => `- lines ${range(m.start, m.end)} were moved here from ${m.peer} (its base lines ${range(m.peerStart, m.peerEnd)}) in this block.`),
243
+ ];
244
+ const shown = entries.slice(0, MAX_MOVED_ENTRIES);
245
+ if (entries.length > shown.length) shown.push(`- (${entries.length - shown.length} more moved ranges not listed)`);
246
+ const advice = [];
247
+ if (out.length > 0) advice.push('Do not report the removal of moved lines itself as a defect; review only whether references and imports are updated.');
248
+ if (into.length > 0) advice.push('Moved-in lines are reviewed in their new place like any change (imports, references, behaviour).');
249
+ return [MOVED_HEAD, ...shown, ...advice].join('\n');
250
+ }
251
+
252
+ /**
253
+ * The `## moved code` section for one file (pure): '' when nothing moved.
254
+ * @param {{diff: {diffText: string}, peers: ReadonlyArray<PeerDiff>}} opts @returns {string}
255
+ */
256
+ export function movedSection(opts) {
257
+ return renderMoved(movedCode(opts));
258
+ }
259
+
260
+ /** One-entry cache: `markdownHeadings` of the last content asked (packing asks many times). */
261
+ let headingCache = { content: /** @type {string | null | undefined} */ (undefined), keys: /** @type {number[]} */ ([]) };
262
+
263
+ /** @param {string | null} content @returns {number[]} the heading lines, ascending. */
264
+ function headingLines(content) {
265
+ if (headingCache.content !== content) headingCache = { content, keys: [...markdownHeadings(content).keys()].sort((a, b) => a - b) };
266
+ return headingCache.keys;
267
+ }
268
+
269
+ /**
270
+ * The section text a packet carries. `keys` null ⇒ every move (a whole-file packet: the ONE
271
+ * whole-file sentinel); an array ⇒ only the moves whose current-file position falls under one of
272
+ * those heading groups (`SectionGroup.key`: the heading line, 0 = before the first heading) — an
273
+ * empty array, or no move there, ⇒ ''.
274
+ * @param {Moves | null | undefined} moves @param {string | null} content - the CURRENT file.
275
+ * @param {ReadonlyArray<number> | null} [keys]
276
+ * @returns {string}
277
+ */
278
+ export function movedText(moves, content, keys = null) {
279
+ if (!moves) return '';
280
+ if (keys === null) return renderMoved(moves);
281
+ const heads = headingLines(content);
282
+ /** @param {MoveMatch} m */
283
+ const inKeys = (m) => {
284
+ let key = 0;
285
+ for (const h of heads) if (h <= m.at) key = h;
286
+ return keys.includes(key);
287
+ };
288
+ return renderMoved({ out: moves.out.filter(inKeys), into: moves.into.filter(inKeys) });
289
+ }
290
+
291
+ /**
292
+ * The block's other changed files' diffs against `base`, read with as few git calls as possible
293
+ * (see the module doc). A peer that is not a plain repo path, is secret-like, ignored, unchanged,
294
+ * whose diff is over {@link MAX_DIFF_BYTES}, or whose diff cannot be split out of the listing gives
295
+ * nothing. Throws only when `git ls-tree` or `git check-ignore` fails.
296
+ * @param {{repoRoot: string, base: string, file: string, peers: ReadonlyArray<string>}} opts
297
+ * @returns {Promise<PeerDiff[]>}
298
+ */
299
+ export async function readPeerDiffs({ repoRoot, base, file, peers }) {
300
+ if (base.length === 0 || base.startsWith('-')) return [];
301
+ /** @type {string[]} */
302
+ const rels = [];
303
+ for (const p of [...new Set(peers)].sort()) {
304
+ if (!printable(p) || p === file) continue;
305
+ try {
306
+ const rel = assertPacketPath(repoRoot, p);
307
+ if (!isSecretLike(rel)) rels.push(rel);
308
+ } catch {
309
+ // not a plain repo file ⇒ no hint from it
310
+ }
311
+ if (rels.length >= MAX_PEERS) break;
312
+ }
313
+ if (rels.length === 0) return [];
314
+ const env = reviewGitEnv();
315
+ const cap = { maxBufferBytes: MAX_DIFF_BYTES };
316
+ // "in base" (not "in the index"): a peer deleted in the block — `git rm` or gone from the
317
+ // working tree — is still diffed against the base, every line removed
318
+ const listed = await exec(['git', '-c', 'core.quotePath=false', 'ls-tree', '-r', '-z', '--name-only', base, '--', ...rels], { cwd: repoRoot, env, timeoutMs: GIT_TIMEOUT_MS });
319
+ if (listed.result !== 'ok') throw new Error('git ls-tree failed');
320
+ const inBase = new Set(listed.stdout.split('\0').filter((f) => f.length > 0));
321
+ /** @type {PeerDiff[]} */
322
+ const out = [];
323
+ const trackedRels = rels.filter((r) => inBase.has(r));
324
+ if (trackedRels.length > 0) {
325
+ /** @param {string[]} files */
326
+ const listing = (files) => exec([...GIT_DIFF, '--no-renames', base, '--', ...files], { cwd: repoRoot, env, timeoutMs: GIT_TIMEOUT_MS, ...cap });
327
+ /** @param {string} stdout @param {string[]} files - one chunk per file, named by its exact `diff --git a/<f> b/<f>` line (a quoted name matches none) */
328
+ const take = (stdout, files) => {
329
+ const chunks = stdout.split(/^(?=diff --git )/m);
330
+ for (const rel of files) {
331
+ const chunk = chunks.find((c) => c.startsWith(`diff --git a/${rel} b/${rel}\n`));
332
+ if (chunk && chunk.trim().length > 0) out.push({ file: rel, diffText: chunk });
333
+ }
334
+ };
335
+ const res = await listing(trackedRels);
336
+ if (res.result === 'ok') take(res.stdout, trackedRels);
337
+ else if (trackedRels.length > 1) {
338
+ // the listing is over the cap (or failed): peer by peer, one over the cap alone is skipped
339
+ // (a single peer's failed listing is that skip already)
340
+ for (const rel of trackedRels) {
341
+ const one = await listing([rel]);
342
+ if (one.result === 'ok') take(one.stdout, [rel]);
343
+ }
344
+ }
345
+ }
346
+ // absent from the base: a new file, read from disk (`--no-index`) unless git ignores it
347
+ const untracked = rels.filter((r) => !inBase.has(r) && existsSync(path.join(repoRoot, r)));
348
+ if (untracked.length > 0) {
349
+ // `check-ignore` refuses the literal-pathspec magic: plain env (no glob, no leading `:` here);
350
+ // the paths go on stdin, NUL-separated (`-z` needs `--stdin`)
351
+ const ignored = await exec(['git', 'check-ignore', '-z', '--stdin'], { cwd: repoRoot, env: plainGitEnv(), timeoutMs: GIT_TIMEOUT_MS, okExitCodes: [0, 1], input: `${untracked.join('\0')}\0` });
352
+ if (ignored.result !== 'ok') throw new Error('git check-ignore failed');
353
+ const skip = new Set(ignored.stdout.split('\0'));
354
+ for (const rel of untracked.filter((r) => !skip.has(r))) {
355
+ const res = await exec([...GIT_DIFF, '--no-index', '--', '/dev/null', rel], { cwd: repoRoot, env, timeoutMs: GIT_TIMEOUT_MS, okExitCodes: [0, 1], ...cap });
356
+ if (res.result === 'ok' && res.stdout.trim().length > 0) out.push({ file: rel, diffText: res.stdout });
357
+ }
358
+ }
359
+ return out.sort((a, b) => (a.file < b.file ? -1 : 1));
360
+ }
361
+
362
+ /**
363
+ * A memoised reader of the peers' diffs: the listing runs at most ONCE however often it is asked
364
+ * (one per ticket round: round 1, every section, the recheck). No base or no peer ⇒ []; a git
365
+ * failure ⇒ [] (no hint, the review goes on).
366
+ * @param {{repoRoot: string, base: string | null | undefined, file: string, peers?: ReadonlyArray<string>}} opts
367
+ * @returns {() => Promise<PeerDiff[]>}
368
+ */
369
+ export function peerDiffReader({ repoRoot, base, file, peers }) {
370
+ /** @type {Promise<PeerDiff[]> | null} */
371
+ let once = null;
372
+ return () => {
373
+ if (once === null) {
374
+ once =
375
+ typeof base !== 'string' || !Array.isArray(peers) || peers.length === 0
376
+ ? Promise.resolve([])
377
+ : readPeerDiffs({ repoRoot, base, file, peers }).catch(() => []);
378
+ }
379
+ return once;
380
+ };
381
+ }
382
+
383
+ /**
384
+ * This file's moves, from its diff against the base and the peers' diffs. Never throws: any
385
+ * failure ⇒ null (no section).
386
+ * @param {{diff: {diffText: string}, peerDiffs?: () => Promise<PeerDiff[]>}} opts
387
+ * @returns {Promise<Moves | null>}
388
+ */
389
+ export async function movesFor({ diff, peerDiffs }) {
390
+ if (!peerDiffs) return null;
391
+ try {
392
+ const peers = await peerDiffs();
393
+ if (peers.length === 0) return null;
394
+ const moves = movedCode({ diff, peers });
395
+ return moves.out.length + moves.into.length > 0 ? moves : null;
396
+ } catch {
397
+ return null;
398
+ }
399
+ }
@@ -10,6 +10,7 @@
10
10
  * ## project rules the rules digest (≤ 60 lines)
11
11
  * ## facts the facts-sheet excerpt
12
12
  * ## context the current file (whole, or hunk windows — `context.mjs`)
13
+ * ## moved code only when code moved to/from another file of the block (`moved.mjs`, B56)
13
14
  * ## diff `file:`, the `hunks:` list the reviewer must echo, then the diff
14
15
  *
15
16
  * Diff source (§4.1): a file in the index is diffed against the block's base SHA
@@ -20,7 +21,8 @@
20
21
  * bytes / 4. A diff that alone exceeds it ⇒ `split_required` (a Markdown file is then reviewed
21
22
  * section by section — `sections.mjs`, B54). Otherwise over budget ⇒ the context shrinks to
22
23
  * ± `min_context_lines` (`minimal`) FIRST, then the digest is trimmed from the end; the diff is
23
- * never cut.
24
+ * never cut. The `## moved code` section (B56) is part of that measure: it is counted in the diff-only
25
+ * size (`diffOnlyTokens`, `split_required`) and never trimmed.
24
26
  *
25
27
  * Path rule (V4): `file` is repo-root-relative; `./`, `../`, absolute values, symlinks and paths
26
28
  * under `.git/` or `.code-forge/` are refused with `bad-path` before git runs. So is anything git
@@ -54,10 +56,13 @@ const PATHSPEC_MAGIC = /[*?[\]]/;
54
56
  const SECRET_LIKE = [/(^|\/)\.env[^/]*$/i, /\.pem$/i, /\.key$/i, /\.p12$/i, /(^|\/)id_(rsa|ed25519|ecdsa)[^/]*$/i, /(^|\/)credentials[^/]*$/i, /(^|\/)\.code-forge(\/|$)/i];
55
57
 
56
58
  /** @param {string} rel @returns {boolean} */
57
- const isSecretLike = (rel) => SECRET_LIKE.some((re) => re.test(rel));
59
+ export const isSecretLike = (rel) => SECRET_LIKE.some((re) => re.test(rel));
58
60
 
59
61
  /** The env for every child git in this module: B11's clean env plus literal pathspecs. */
60
- const reviewGitEnv = () => ({ ...gitChildEnv(), GIT_LITERAL_PATHSPECS: '1' });
62
+ export const reviewGitEnv = () => ({ ...gitChildEnv(), GIT_LITERAL_PATHSPECS: '1' });
63
+
64
+ /** B11's clean env alone: for `git check-ignore`, which refuses the literal-pathspec magic. */
65
+ export const plainGitEnv = () => gitChildEnv();
61
66
 
62
67
  /** A refusal with a stable `code`: `bad-path`, `bad-lens`, `git`. */
63
68
  export class PacketError extends Error {
@@ -185,49 +190,50 @@ export async function readFileDiff({ repoRoot, file, base }) {
185
190
  * Assemble a reviewer packet (pure).
186
191
  * @param {{
187
192
  * diff: FileDiff, lens: string, rulesDigest?: string, factsExcerpt?: string,
188
- * cfg?: Record<string, any>, contextMode?: 'auto' | 'recheck', budget?: number,
189
- * }} opts
193
+ * cfg?: Record<string, any>, contextMode?: 'auto' | 'recheck', budget?: number, moved?: string,
194
+ * }} opts - `moved`: the file's `## moved code` section (`moved.mjs`), '' for none.
190
195
  * @returns {Packet | {status: 'split_required', tokensIn: number, budget: number}}
191
196
  */
192
- export function assemblePacket({ diff, lens, rulesDigest = '', factsExcerpt = '', cfg, contextMode = 'auto', budget }) {
197
+ export function assemblePacket({ diff, lens, rulesDigest = '', factsExcerpt = '', cfg, contextMode = 'auto', budget, moved = '' }) {
193
198
  const lensText = loadLens(lens);
194
199
  const limit = budget ?? budgetFor(cfg, lens === 'quick' ? 'quick_in' : 'full_in');
195
200
  const hunkHeaders = diff.hunks.map((h) => h.header);
196
201
  const diffSection = diffSectionOf(diff);
197
- const bare = bareTokens(lensText, diffSection);
202
+ const bare = bareTokens(lensText, diffSection, moved);
198
203
  if (bare > limit) return { status: 'split_required', tokensIn: bare, budget: limit };
199
204
 
200
205
  let digest = splitLines(rulesDigest).slice(0, DIGEST_MAX_LINES);
201
206
  let ctx = buildContext({ file: diff.file, content: diff.content, hunks: diff.hunks, cfg, mode: contextMode });
202
- let text = render(lensText, digest, factsExcerpt, ctx.text, diffSection);
207
+ let text = render(lensText, digest, factsExcerpt, ctx.text, diffSection, moved);
203
208
  if (estimateTokens(text) > limit) {
204
209
  ctx = buildContext({ file: diff.file, content: diff.content, hunks: diff.hunks, cfg, mode: 'minimal' });
205
- text = render(lensText, digest, factsExcerpt, ctx.text, diffSection);
210
+ text = render(lensText, digest, factsExcerpt, ctx.text, diffSection, moved);
206
211
  }
207
212
  while (estimateTokens(text) > limit && digest.length > 0) {
208
213
  digest = digest.slice(0, -1);
209
- text = render(lensText, digest, factsExcerpt, ctx.text, diffSection);
214
+ text = render(lensText, digest, factsExcerpt, ctx.text, diffSection, moved);
210
215
  }
211
216
  const tokensIn = estimateTokens(text);
212
217
  return { status: 'ok', text, hunkHeaders, contextMode: ctx.mode, contextLines: ctx.lineCount, digestLines: digest.length, tokensIn, budget: limit, overBudget: tokensIn > limit };
213
218
  }
214
219
 
215
220
  /**
216
- * The tokens of the diff-only packet (lens + diff, no digest, facts or context): what
217
- * `assemblePacket` holds against the budget before it says `split_required`.
218
- * @param {{diff: FileDiff, lens: string}} opts @returns {number}
221
+ * The tokens of the diff-only packet (lens + moved code + diff, no digest, facts or context): what
222
+ * `assemblePacket` holds against the budget before it says `split_required`. Pass the SAME `moved`
223
+ * section the packet gets.
224
+ * @param {{diff: FileDiff, lens: string, moved?: string}} opts @returns {number}
219
225
  */
220
- export function diffOnlyTokens({ diff, lens }) {
221
- return bareTokens(loadLens(lens), diffSectionOf(diff));
226
+ export function diffOnlyTokens({ diff, lens, moved = '' }) {
227
+ return bareTokens(loadLens(lens), diffSectionOf(diff), moved);
222
228
  }
223
229
 
224
230
  /**
225
231
  * The ONE measure behind `split_required` (`assemblePacket`) and section packing (`diffOnlyTokens`,
226
232
  * B54), so the two can never disagree.
227
- * @param {string} lensText @param {string} diffSection @returns {number}
233
+ * @param {string} lensText @param {string} diffSection @param {string} moved @returns {number}
228
234
  */
229
- function bareTokens(lensText, diffSection) {
230
- return estimateTokens(render(lensText, [], '', '', diffSection));
235
+ function bareTokens(lensText, diffSection, moved) {
236
+ return estimateTokens(render(lensText, [], '', '', diffSection, moved));
231
237
  }
232
238
 
233
239
  /** @param {FileDiff} diff @returns {string} the `## diff` section body. */
@@ -237,9 +243,9 @@ function diffSectionOf(diff) {
237
243
 
238
244
  /**
239
245
  * @param {string} lensText @param {string[]} digest @param {string} facts @param {string} context
240
- * @param {string} diffSection
246
+ * @param {string} diffSection @param {string} [moved] - the `## moved code` section (heading included), '' for none.
241
247
  */
242
- function render(lensText, digest, facts, context, diffSection) {
248
+ function render(lensText, digest, facts, context, diffSection, moved = '') {
243
249
  return [
244
250
  '# code-forge review packet',
245
251
  '## lens',
@@ -250,6 +256,7 @@ function render(lensText, digest, facts, context, diffSection) {
250
256
  facts.trim().length > 0 ? facts.trimEnd() : '(none)',
251
257
  '## context',
252
258
  context.length > 0 ? context : '(none)',
259
+ ...(moved.length > 0 ? [moved] : []),
253
260
  '## diff',
254
261
  diffSection,
255
262
  '',
@@ -271,11 +278,12 @@ export async function buildPacket(opts) {
271
278
 
272
279
  /**
273
280
  * The judge's packet (§4.2 step 3, §4.4): the judge lens, the hunk list, the two blind reports,
274
- * and the diff only when `review.judge_sees_diff` is true (R3, default false).
275
- * @param {{diff: FileDiff, reports: {A: unknown, B: unknown}, cfg?: Record<string, any>}} opts
281
+ * and the diff only when `review.judge_sees_diff` is true (R3, default false); the file's
282
+ * `## moved code` section (B56) before the diff when there is one.
283
+ * @param {{diff: FileDiff, reports: {A: unknown, B: unknown}, cfg?: Record<string, any>, moved?: string}} opts
276
284
  * @returns {{status: 'ok', text: string, hunkHeaders: string[], tokensIn: number, budget: number, contextMode: null}}
277
285
  */
278
- export function assembleJudgePacket({ diff, reports, cfg }) {
286
+ export function assembleJudgePacket({ diff, reports, cfg, moved = '' }) {
279
287
  const hunkHeaders = diff.hunks.map((h) => h.header);
280
288
  const seesDiff = cfg?.review?.judge_sees_diff === true;
281
289
  const text = [
@@ -286,6 +294,7 @@ export function assembleJudgePacket({ diff, reports, cfg }) {
286
294
  JSON.stringify(reports.A, null, 1),
287
295
  '## reviewer B',
288
296
  JSON.stringify(reports.B, null, 1),
297
+ ...(moved.length > 0 ? [moved] : []),
289
298
  '## diff',
290
299
  [`file: ${diff.file}`, 'hunks:', ...hunkHeaders.map((h) => `- ${h}`), ...(seesDiff ? ['', diff.diffText.trimEnd()] : [])].join('\n'),
291
300
  '',
@@ -203,14 +203,15 @@ function diffOf(diff, fileHeader, pieces) {
203
203
  * Pack the heading groups into section packets within `budget` (step 3 of the module doc). A
204
204
  * candidate's size is `measure(its diff, its groups)` (a recheck adds the open findings it lists
205
205
  * for those groups). Never `ok` with no section: a diff with no hunk, or one whose hunks do not
206
- * parse one to one, is `split_required` at `measure(diff, [])` with `section: null`.
207
- * @param {{diff: FileDiff, budget: number, measure: (diff: FileDiff, groups: SectionGroup[]) => number}} opts -
208
- * `measure`: the packet tokens of a candidate (the largest over the session lenses).
206
+ * parse one to one, is `split_required` at `measure(diff, null)` with `section: null`.
207
+ * @param {{diff: FileDiff, budget: number, measure: (diff: FileDiff, groups: SectionGroup[] | null) => number}} opts -
208
+ * `measure`: the packet tokens of a candidate (the largest over the session lenses); `groups`
209
+ * null ⇒ the whole file (B56: its whole moved-code section), never an empty section.
209
210
  * @returns {{status: 'ok', sections: Section[]} | {status: 'split_required', tokensIn: number, budget: number, section: string | null}}
210
211
  */
211
212
  export function packSections({ diff, budget, measure }) {
212
213
  const groups = sectionGroups(diff);
213
- if (groups === null || groups.length === 0) return { status: 'split_required', tokensIn: measure(diff, []), budget, section: null };
214
+ if (groups === null || groups.length === 0) return { status: 'split_required', tokensIn: measure(diff, null), budget, section: null };
214
215
  /** @param {SectionGroup[]} set */
215
216
  const size = (set) => measure(merge(diff, set), set);
216
217
  /** @type {Section[]} */
@@ -34,6 +34,9 @@
34
34
  * this run for this file and content hash whose MAC verifies with the run key), so a packet's
35
35
  * second `schema` miss — this round's first try after the earlier ticket's — is tried once on
36
36
  * `review.second_levels.<level>` when one is configured.
37
+ * B56: every round gets ONE memoised reader of the block's OTHER changed files' diffs
38
+ * (`blockPeerDiffs`: listed and read at most once per ticket) so the review can name code moved
39
+ * between them in a `## moved code` packet section (round 1, rechecks and patch check alike).
37
40
  * The review NEVER approves an empty or failed session: that is `unavailable` (§4.2 stub guard).
38
41
  * A throw inside the round is `unavailable` too, with the state saved as `next: retry` (see
39
42
  * `fixLoopRound`): the next ticket re-runs that round, never a fresh round 1.
@@ -46,6 +49,7 @@ import { askJev } from '../decide/jev-client.mjs';
46
49
  import { planAndRecord, tierOf } from '../review/budget.mjs';
47
50
  import { budgetFor } from '../review/packet.mjs';
48
51
  import { reviewFile, rulesRisk } from '../review/engine.mjs';
52
+ import { peerDiffReader } from '../review/moved.mjs';
49
53
  import { computeFileSet, ownsFile } from '../gates/scope.mjs';
50
54
  import { extraRoundsFor, newFileState, reopenForExtraRound, runRound } from '../review/fixloop.mjs';
51
55
  import { readRun } from '../state/run.mjs';
@@ -206,19 +210,60 @@ export async function reviewTicket(ticket, ctx) {
206
210
  return { status: 'unavailable', reason: err instanceof Error ? err.message : 'run-record-unreadable', approved: false, engine: 'adaptive', sessions: [] };
207
211
  }
208
212
  if (entry.kind === null) entry = { ...entry, kind: blockKind({ owned: [ticket.file] }) };
213
+ const peerDiffs = blockPeerDiffs(ctx.repoRoot, { base: entry.base, owned: entry.owned, file: ticket.file });
209
214
  try {
210
215
  if (!ctx.key) {
211
216
  return await reviewFile(
212
- { repoRoot: ctx.repoRoot, file: ticket.file, base: entry.base, cfg: ctx.cfg, kind: entry.kind, workDir, factsExcerpt: acceptanceExcerpt(entry.acceptance, ctx.cfg) },
217
+ { repoRoot: ctx.repoRoot, file: ticket.file, base: entry.base, cfg: ctx.cfg, kind: entry.kind, workDir, factsExcerpt: acceptanceExcerpt(entry.acceptance, ctx.cfg), peerDiffs },
213
218
  { spawn: ctx.spawn, ...(ctx.writeRow ? { writeRow: ctx.writeRow } : {}) },
214
219
  );
215
220
  }
216
- return await fixLoopRound(ticket, ctx, { base: entry.base, level: entry.level, owned: entry.owned, blockKindOf: entry.kind, key: ctx.key, workDir, factsExcerpt: acceptanceExcerpt(entry.acceptance, ctx.cfg) });
221
+ return await fixLoopRound(ticket, ctx, { base: entry.base, level: entry.level, owned: entry.owned, blockKindOf: entry.kind, key: ctx.key, workDir, factsExcerpt: acceptanceExcerpt(entry.acceptance, ctx.cfg), peerDiffs });
217
222
  } finally {
218
223
  rmSync(workDir, { recursive: true, force: true });
219
224
  }
220
225
  }
221
226
 
227
+ /**
228
+ * B56: the block's OTHER changed files — the §4.1 file set since the block base (tracked changes ∪
229
+ * untracked), filtered to the block's owned files (`ownsFile`, as the budget row does), minus the
230
+ * ticket's file. The review names code moved between them (`## moved code`). No base, no owned
231
+ * list, or a git failure ⇒ [] (no hint; the review itself goes on unchanged).
232
+ * @param {string} repoRoot @param {{base: string | null, owned: string[], file: string}} opts
233
+ * @returns {Promise<string[]>}
234
+ */
235
+ export async function blockPeers(repoRoot, { base, owned, file }) {
236
+ if (base === null || !Array.isArray(owned) || owned.length === 0) return [];
237
+ try {
238
+ return (await computeFileSet({ cwd: repoRoot, base })).all.filter((f) => f !== file && ownsFile(owned, f));
239
+ } catch {
240
+ return [];
241
+ }
242
+ }
243
+
244
+ /**
245
+ * B56: ONE memoised reader per ticket of the block's other changed files' diffs: the file set is
246
+ * listed and the diffs read (`peerDiffReader`) at most once, however many packets ask — lazily, so
247
+ * a round with no packet (a stopped file) runs no git call. Any throw or rejection on the way ⇒
248
+ * no peer diffs (no hint), memoised like a success.
249
+ * @param {string} repoRoot @param {{base: string | null, owned: string[], file: string}} opts
250
+ * @returns {() => Promise<import('../review/moved.mjs').PeerDiff[]>}
251
+ */
252
+ export function blockPeerDiffs(repoRoot, { base, owned, file }) {
253
+ /** @type {Promise<import('../review/moved.mjs').PeerDiff[]> | null} */
254
+ let once = null;
255
+ return () => {
256
+ once ??= (async () => {
257
+ try {
258
+ return await peerDiffReader({ repoRoot, base, file, peers: await blockPeers(repoRoot, { base, owned, file }) })();
259
+ } catch {
260
+ return [];
261
+ }
262
+ })();
263
+ return once;
264
+ };
265
+ }
266
+
222
267
  /**
223
268
  * The key of a block's lock (B42): the block's budget row and its L3 rung are taken under it.
224
269
  * It carries the run, so two runs in one process never share a lock.
@@ -281,10 +326,10 @@ export function mayTakeRung(state, kind, cfg, rungUsed) {
281
326
  * first full round, or any round once the rung is gone) runs outside the block lock, in parallel
282
327
  * with the block's other files.
283
328
  * @param {import('./queue.mjs').Ticket} ticket @param {ReviewContext} ctx
284
- * @param {{base: string | null, level: string, owned: string[], blockKindOf: string, key: Buffer, workDir: string, factsExcerpt: string}} opts
329
+ * @param {{base: string | null, level: string, owned: string[], blockKindOf: string, key: Buffer, workDir: string, factsExcerpt: string, peerDiffs?: () => Promise<import('../review/moved.mjs').PeerDiff[]>}} opts
285
330
  * @returns {Promise<ReviewOutcome>}
286
331
  */
287
- async function fixLoopRound(ticket, ctx, { base, level, owned, blockKindOf, key, workDir, factsExcerpt }) {
332
+ async function fixLoopRound(ticket, ctx, { base, level, owned, blockKindOf, key, workDir, factsExcerpt, peerDiffs }) {
288
333
  if (!ctx.readRows || !ctx.writeRow) return { status: 'unavailable', reason: 'no-ledger', approved: false, engine: 'adaptive', sessions: [] };
289
334
  const readRows = ctx.readRows;
290
335
  /** @type {{outcome: ReviewOutcome} | {round: () => Promise<ReviewOutcome>}} */
@@ -324,7 +369,7 @@ async function fixLoopRound(ticket, ctx, { base, level, owned, blockKindOf, key,
324
369
  state.l3_rung_used = true; // the orchestrator ran the block's one L3 patch before this ticket
325
370
  }
326
371
  const fixed = state;
327
- const round = () => playRound(ticket, ctx, { state: fixed, kind, where, seq: loaded.seq, base, blockKindOf, workDir, factsExcerpt, extraRounds });
372
+ const round = () => playRound(ticket, ctx, { state: fixed, kind, where, seq: loaded.seq, base, blockKindOf, workDir, factsExcerpt, extraRounds, peerDiffs });
328
373
  // a round that could take the rung keeps the block lock until its state is saved
329
374
  if (mayTakeRung(fixed, kind, ctx.cfg, rungUsed)) return { outcome: await round() };
330
375
  return { round };
@@ -335,10 +380,10 @@ async function fixLoopRound(ticket, ctx, { base, level, owned, blockKindOf, key,
335
380
  /**
336
381
  * Run the prepared round and save the file's state: the ticket's outcome.
337
382
  * @param {import('./queue.mjs').Ticket} ticket @param {ReviewContext} ctx
338
- * @param {{state: import('../review/fixloop.mjs').FileState, kind: 'full' | 'recheck' | 'patch_check', where: import('./review-state.mjs').Where, seq: number, base: string | null, blockKindOf: string, workDir: string, factsExcerpt: string, extraRounds?: number}} opts
383
+ * @param {{state: import('../review/fixloop.mjs').FileState, kind: 'full' | 'recheck' | 'patch_check', where: import('./review-state.mjs').Where, seq: number, base: string | null, blockKindOf: string, workDir: string, factsExcerpt: string, extraRounds?: number, peerDiffs?: () => Promise<import('../review/moved.mjs').PeerDiff[]>}} opts
339
384
  * @returns {Promise<ReviewOutcome>}
340
385
  */
341
- async function playRound(ticket, ctx, { state, kind, where, seq, base, blockKindOf, workDir, factsExcerpt, extraRounds = 0 }) {
386
+ async function playRound(ticket, ctx, { state, kind, where, seq, base, blockKindOf, workDir, factsExcerpt, extraRounds = 0, peerDiffs }) {
342
387
  /** @type {Record<string, any> | null} */
343
388
  let engineOutcome = null;
344
389
  let approvalWritten = false;
@@ -388,10 +433,11 @@ async function playRound(ticket, ctx, { state, kind, where, seq, base, blockKind
388
433
  writeRow,
389
434
  factsExcerpt,
390
435
  extraRounds,
436
+ ...(peerDiffs ? { peerDiffs } : {}),
391
437
  ...(jev ? { jev } : {}),
392
438
  ...(schemaHistory ? { schemaHistory } : {}),
393
439
  review: async () => {
394
- engineOutcome = await reviewFile({ repoRoot: ctx.repoRoot, file: ticket.file, base, cfg: ctx.cfg, kind: blockKindOf, workDir, factsExcerpt }, { spawn: ctx.spawn, writeRow, ...(schemaHistory ? { schemaHistory } : {}) });
440
+ engineOutcome = await reviewFile({ repoRoot: ctx.repoRoot, file: ticket.file, base, cfg: ctx.cfg, kind: blockKindOf, workDir, factsExcerpt, ...(peerDiffs ? { peerDiffs } : {}) }, { spawn: ctx.spawn, writeRow, ...(schemaHistory ? { schemaHistory } : {}) });
395
441
  return engineOutcome;
396
442
  },
397
443
  spawn: async (opts) => {