@codedology/code-forge 0.7.3 → 0.7.5

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,30 @@ waits on a second real consumer (plan §10.4, Q17).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.7.5] — 2026-10-08
10
+
11
+ ### Fixed
12
+
13
+ - An owned entry with no glob character now owns every path below it everywhere, not only at `block close`: `block open`
14
+ and the plan check refuse a directory that overlaps a file or directory of another block (parent or child, either way
15
+ round; `src/feature` vs `src/featureX` stay disjoint), and `block claim`, `block rebase`, the scope gate, proof and
16
+ the worker's file set and budget count the files inside an owned directory (B57, issue #2). An entry that is a regular
17
+ file at `block open` or `block claim` owns only itself (recorded as `owned_kinds`); one not there yet stays
18
+ directory-like; a brace entry stays a glob. A refused `block claim` names the entry it overlaps
19
+ (`<path> overlaps <entry> owned by open block <id>`), and `proof export` never copies or removes an owned directory
20
+ itself, only the files in it.
21
+
22
+ ## [0.7.4] — 2026-10-08
23
+
24
+ ### Added
25
+
26
+ - Review: code moved between two changed files of the same block is named in a ## moved code packet section
27
+ (deterministic line matching against the block's other changed owned files, same base: >= 3 significant lines or >=
28
+ 120 significant characters; at most 12 ranges; a Markdown section packet lists only the moves in that section) in
29
+ round 1, rechecks, patch checks and the judge packet, so the source file's reviewer no longer reports the removal as a
30
+ deleted feature; every hunk and the reviewed_hunks check are unchanged, and the section counts toward the packet
31
+ budget (issue #2).
32
+
9
33
  ## [0.7.3] — 2026-10-08
10
34
 
11
35
  ### Added
package/README.md CHANGED
@@ -12,6 +12,21 @@ for cross-model, parallel, evidence-gated feature delivery — on Claude Code, C
12
12
 
13
13
  Next release:
14
14
 
15
+ - **An owned directory owns the files inside it, everywhere.** A block that owns `src/feature`
16
+ now owns `src/feature/a.swift` for `block open`, `block claim`, `block rebase`, the scope gate,
17
+ proof and the review worker, not only for `block close`. `block open` refuses a second block
18
+ that owns a file or folder inside it (or a folder around it), so two coders can no longer edit
19
+ the same files at once. A path that is already a file when the block opens owns only itself.
20
+
21
+ In 0.7.4:
22
+
23
+ - **Code moved between files is no longer read as deleted.** When a block moves code from one
24
+ file to another, each file's review packet now says where the lines went (or came from), so the
25
+ reviewer of the old file stops reporting the removal as a lost feature, also on rechecks. Every
26
+ hunk is still reviewed.
27
+
28
+ In 0.7.3:
29
+
15
30
  - **A model that keeps failing the answer format gets a stand-in.** When a reviewer's answer
16
31
  fails the schema twice on the same packet and you set `review.second_levels` for that level, the
17
32
  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": 60,
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,35 @@
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"
813
+ },
814
+ "B57": {
815
+ "title": "One ownership rule (issue #2): an owned entry with no glob character owns its path and every path below it (src/feature owns src/feature/a.swift, never src/featureX/a.swift) unless block open or block claim saw a regular file there (owned_kinds: then it owns only itself); globs, brace entries included, own what they match; findOverlap reports file/dir and dir/dir parent-child overlaps either way and a directory-like entry against a glob matching it or any path below it; the rule lives in src/state/registry.mjs and every caller uses it (block open, claim, rebase, scope gate, close gate, proof, worker file set and budget, plan check)",
816
+ "wave": 12,
817
+ "level": "L2",
818
+ "status": "landed",
819
+ "owned": [
820
+ "src/state/owned-kinds.mjs"
821
+ ],
822
+ "depends_on": [
823
+ "B56"
824
+ ],
825
+ "note": "root-added 2026-10-08; found while fixing GitHub issue #2 (B52); also amends src/state/{registry,block}.mjs, src/review/gate-check.mjs, src/cli/{block,proof,plan}.mjs, src/proof/{export,tiers}.mjs, src/worker/engine.mjs, src/session/plan-check.mjs, tests, docs/how-it-works.md, skill/references/code.md"
797
826
  }
798
827
  }
799
828
  }
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.5",
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",
@@ -10,7 +10,7 @@ The coder then forecasts **cases** (tests) and **lines**. Compute the floor your
10
10
 
11
11
  ## §3 Dispatch — what the brief contains
12
12
 
13
- `forge block open <id> --run <r> --level L<n> --owned <paths…> --acceptance <file> --brief <file> [--attempt <n>] [--base <sha>] [--kind code|docs|contract] --lines <n>` records the base sha, the owned files (overlap with another active block ⇒ refused), the block kind (declared, else detected — `decisions.md` §5), the level (a docs/contract block below `levels.coder_floor_docs`, default L1, is raised to it and the CLI prints the floor), the clauses with their test ids and the forecast, writes the ledger `dispatch` row and prints the pointer `BRIEF <path> lines=<n> sha=<sha8> <<<EOM>>>` (≤ 200 B). Call it before every spawn, in every engine. A docs block that owns a glob (`docs/**`) must declare `--kind docs`: a glob is judged by its literal ending, so undeclared it counts as `code`. The brief file carries, in this order:
13
+ `forge block open <id> --run <r> --level L<n> --owned <paths…> --acceptance <file> --brief <file> [--attempt <n>] [--base <sha>] [--kind code|docs|contract] --lines <n>` records the base sha, the owned files (overlap with another active block ⇒ refused; an owned entry with no glob character — `*`, `?`, `{` — owns that path and every path below it, so `src/feature` owns `src/feature/a.swift` but not `src/featureX/a.swift`, and `src/feature` overlaps `src/feature/x.swift` or `src` in another block — unless the entry is a regular file in the workspace at `block open` (or `block claim`): then it owns only itself, recorded as `owned_kinds`, and every later check reads that answer; a glob owns what it matches, and a brace entry `src/{a,b}` is a glob, so `src/a` owns nothing below it; no trailing `/`), the block kind (declared, else detected — `decisions.md` §5), the level (a docs/contract block below `levels.coder_floor_docs`, default L1, is raised to it and the CLI prints the floor), the clauses with their test ids and the forecast, writes the ledger `dispatch` row and prints the pointer `BRIEF <path> lines=<n> sha=<sha8> <<<EOM>>>` (≤ 200 B). Call it before every spawn, in every engine. A docs block that owns a glob (`docs/**`) must declare `--kind docs`: a glob is judged by its literal ending, so undeclared it counts as `code`. The brief file carries, in this order:
14
14
 
15
15
  1. `id`, `title`, the level, `owned_files` ("edit ONLY these"), the acceptance clauses with test ids, `test_command`.
16
16
  2. The reply contract: `ACK <sha8> lines=<n>` before any tool call; the facts diff; the forecast.
@@ -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.5"
14
14
  set -eu
15
15
 
16
16
  self="$0"
package/src/cli/block.mjs CHANGED
@@ -218,7 +218,7 @@ export async function runBlock(args, deps = {}) {
218
218
  const real = async (/** @type {string} */ p) => realpath(p).catch(() => path.resolve(p));
219
219
  const wsPrefix = path.relative(await real(top), await real(record.workspace)).split(path.sep).join('/');
220
220
  const owns = ownsAt(wsPrefix);
221
- const isMine = (/** @type {string} */ f) => owns(entry.owned_files, f);
221
+ const isMine = (/** @type {string} */ f) => owns(entry.owned_files, f, entry.owned_kinds);
222
222
  const changed = await changedFiles({ repoRoot: top, base: entry.base_sha });
223
223
  // the file set is the diff since base ∪ every owned file with review rows of this block —
224
224
  // work the diff cannot see (committed before `block open`, done in another worktree) is
@@ -238,7 +238,7 @@ export async function runBlock(args, deps = {}) {
238
238
  // B20: the high paths come first — a base the workspace does not know refuses the close here
239
239
  const highPaths = await highPathsAt(record.workspace, entry.base_sha);
240
240
  const { top, wsPrefix, owns, changed, fileSet, reviewed } = await tree();
241
- const isMine = (/** @type {string} */ f) => owns(entry.owned_files, f);
241
+ const isMine = (/** @type {string} */ f) => owns(entry.owned_files, f, entry.owned_kinds);
242
242
  const key = await loadKey(runId);
243
243
  // B52: an empty diff with review rows is never silent — name the likely cause
244
244
  if (reviewed.length > 0 && !changed.some(isMine)) {
package/src/cli/plan.mjs CHANGED
@@ -71,7 +71,7 @@ export async function runPlanVerb(args, deps = {}) {
71
71
  // package.json fails only the plans that depend on it
72
72
  let result;
73
73
  try {
74
- result = checkPlan(text, { planPath, factsPath, factsText, cfg, decisions, projectBins: () => readProjectBins(root) });
74
+ result = checkPlan(text, { planPath, factsPath, factsText, cfg, decisions, root, projectBins: () => readProjectBins(root) });
75
75
  } catch (err) {
76
76
  if (!(err instanceof FactsError)) throw err;
77
77
  writeSafe(stderr, `plan: ${err.message}\n`);
package/src/cli/proof.mjs CHANGED
@@ -36,7 +36,7 @@ import { acquireProofLock, lockHolder, releaseProofLock } from '../proof/lock.mj
36
36
  import { MECHANISMS, readJournal, restoreJournal, runRedGreen } from '../proof/red-green.mjs';
37
37
  import { tierFor } from '../proof/tiers.mjs';
38
38
  import { blockFileSet, repoRelativePath } from '../review/gate-check.mjs';
39
- import { findOverlap } from '../state/registry.mjs';
39
+ import { ownsFile } from '../state/registry.mjs';
40
40
 
41
41
  const USAGE =
42
42
  'usage: code-forge proof tier --file <path> --risk <0-3> [--security] [--cwd <dir>]\n' +
@@ -126,6 +126,7 @@ async function exportAndRecord({ runId, record, blockId, block, config, writeRow
126
126
  blockId,
127
127
  baseSha: block.base_sha,
128
128
  owned: block.owned_files,
129
+ ownedKinds: block.owned_kinds,
129
130
  linkDirs: config?.proof?.export?.link_dirs ?? DEFAULT_LINK_DIRS,
130
131
  copyUntracked: config?.proof?.export?.copy_untracked ?? DEFAULT_COPY_UNTRACKED,
131
132
  });
@@ -243,7 +244,7 @@ export async function runProof(args, deps = {}) {
243
244
  repoRoot: record.workspace,
244
245
  base: block.base_sha,
245
246
  owned: block.owned_files,
246
- matchOwned: (f) => findOverlap(block.owned_files, [f]) !== null,
247
+ matchOwned: (f) => ownsFile(block.owned_files, f, block.owned_kinds),
247
248
  });
248
249
  const sources = mechanism === 'assertion-deletion' ? [] : changed.map((c) => c.file).filter((f) => f !== file && !TEST_PATH.test(f));
249
250
  if (mechanism === 'revert' && sources.length === 0) {
@@ -28,7 +28,7 @@ import { copyFile, lstat, mkdir, mkdtemp, readlink, realpath, rm, stat, symlink
28
28
  import path from 'node:path';
29
29
  import { runGates } from '../gates/run.mjs';
30
30
  import { StateError } from '../state/paths.mjs';
31
- import { ownsFile } from '../state/registry.mjs';
31
+ import { isExactEntry, ownsFile } from '../state/registry.mjs';
32
32
  import { writeSigned } from '../state/run.mjs';
33
33
  import { exec } from '../util/exec.mjs';
34
34
  import { revParse } from '../util/git.mjs';
@@ -148,8 +148,16 @@ async function exportIgnoredBlobs(entries, base, cwd) {
148
148
 
149
149
  /**
150
150
  * Build (or rebuild) the export of one block.
151
+ *
152
+ * Owned paths follow the registry's one rule (B57): `ownedKinds` is the block's `owned_kinds` from
153
+ * the run record — every caller passes it; left out it is `{}`, so every exact entry is
154
+ * directory-like (fail closed: it owns the paths below it). A path's kind may differ between base
155
+ * and the tree: a base FILE where the tree has a directory leaves the export before the files below
156
+ * it are copied, and a base DIRECTORY where the tree has a file leaves with every base blob in it;
157
+ * each base blob that leaves is listed in `removed`. A directory is never removed whole because
158
+ * it is owned or missing: only the blobs below it, one by one.
151
159
  * @param {{
152
- * cwd: string, blockId: string, baseSha: string, owned: ReadonlyArray<string>,
160
+ * cwd: string, blockId: string, baseSha: string, owned: ReadonlyArray<string>, ownedKinds?: Readonly<Record<string, string>>,
153
161
  * linkDirs?: ReadonlyArray<string>, copyUntracked?: ReadonlyArray<string>, exportRoot?: string,
154
162
  * }} opts
155
163
  * @returns {Promise<{dir: string, base: string, restored: string[], untracked: string[], owned: string[], removed: string[], linked: string[]}>}
@@ -206,21 +214,54 @@ export async function buildExport(opts) {
206
214
 
207
215
  // (d) owned files at current content; owned base files deleted in the tree leave the export.
208
216
  const listed = (await git(['ls-files', '-z', '--cached', '--others', '--exclude-standard'], cwd)).split('\0').filter(Boolean);
209
- const exact = owned.filter((o) => !/[*?{]/.test(o));
210
- const candidates = [...new Set([...listed, ...exact, ...baseBlobs])].filter((f) => ownsFile(owned, f)).sort();
217
+ const exact = owned.filter((o) => isExactEntry(o));
218
+ const ownedKinds = opts.ownedKinds ?? {};
219
+ const candidates = [...new Set([...listed, ...exact, ...baseBlobs])].filter((f) => ownsFile(owned, f, ownedKinds)).sort();
220
+ const candidateSet = new Set(candidates);
211
221
  const ownedCopied = [];
212
- const removed = [];
222
+ /** @type {Set<string>} */
223
+ const removedSet = new Set();
224
+ /** A base blob that is not in the tree at its path any more leaves the export: listed. */
225
+ const markRemoved = (/** @type {string} */ rel) => {
226
+ if (baseBlobs.has(rel)) removedSet.add(rel);
227
+ };
228
+ /** B57: an export entry above `rel` that is not a directory (a base file the tree turned into a directory) leaves. */
229
+ const clearParents = async (/** @type {string} */ rel) => {
230
+ const segments = rel.split('/');
231
+ for (let n = 1; n < segments.length; n += 1) {
232
+ const parent = segments.slice(0, n).join('/');
233
+ const at = await lstatOrNull(path.join(dir, parent));
234
+ if (at === null) return;
235
+ if (!at.isDirectory()) {
236
+ await rm(path.join(dir, parent), { force: true });
237
+ markRemoved(parent);
238
+ return;
239
+ }
240
+ }
241
+ };
213
242
  for (const rel of candidates) {
214
243
  assertRelPath(rel, 'owned files');
215
244
  const src = path.join(cwd, rel);
216
245
  const dest = path.join(dir, rel);
217
246
  const st = await lstatOrNull(src);
218
- await rm(dest, { recursive: true, force: true });
219
- if (st === null) {
220
- if (baseBlobs.has(rel)) removed.push(rel);
247
+ const at = await lstatOrNull(dest);
248
+ // B57: nothing to copy — a path missing from the tree, or a directory (an exact owned entry may
249
+ // name one; its files are candidates of their own). A stale non-directory export entry there
250
+ // leaves; an export directory is never removed whole — its owned blobs leave one by one.
251
+ if (st === null || st.isDirectory()) {
252
+ if (at !== null && !at.isDirectory()) await rm(dest, { force: true });
253
+ markRemoved(rel);
221
254
  continue;
222
255
  }
256
+ if (at?.isDirectory()) {
257
+ // a base directory the tree turned into a file: every base blob in it is gone from the tree
258
+ for (const blob of baseBlobs) if (blob.startsWith(`${rel}/`) && !candidateSet.has(blob)) markRemoved(blob);
259
+ await rm(dest, { recursive: true, force: true });
260
+ } else if (at !== null) {
261
+ await rm(dest, { force: true });
262
+ }
223
263
  if (!st.isFile() && !st.isSymbolicLink()) continue;
264
+ await clearParents(rel);
224
265
  await mkdir(path.dirname(dest), { recursive: true });
225
266
  if (st.isSymbolicLink()) await symlink(await readlink(src), dest);
226
267
  else await copyFile(src, dest);
@@ -240,7 +281,7 @@ export async function buildExport(opts) {
240
281
  linked.push(rel);
241
282
  }
242
283
 
243
- return { dir, base, restored: [...restored].sort(), untracked: untracked.sort(), owned: ownedCopied, removed, linked: linked.sort() };
284
+ return { dir, base, restored: [...restored].sort(), untracked: untracked.sort(), owned: ownedCopied, removed: [...removedSet].sort(), linked: linked.sort() };
244
285
  } finally {
245
286
  await rm(scratch, { recursive: true, force: true });
246
287
  }
@@ -23,7 +23,8 @@ export const HIGH_RISK = 2;
23
23
  /**
24
24
  * @param {{file: string, risk: number, securitySensitive?: boolean, highPaths?: ReadonlyArray<string>}} input
25
25
  * `risk` is the S1 `risk` answer (0–3); `highPaths` is `proof.tiers.high.paths` (exact paths or
26
- * globs in the registry's syntax: `*`, `?`, `**`, `{a,b}`).
26
+ * globs in the registry's syntax: `*`, `?`, `**`, `{a,b}`; an exact path also covers the paths
27
+ * below it, B57).
27
28
  * @returns {{tier: Tier, reason: TierReason}} the FIRST reason that applies, in the order
28
29
  * security, risk, path — every one of them yields `high`, so the order only picks the label.
29
30
  * @throws {TypeError} on a non-string file, a risk outside 0–3, or a non-array `highPaths`.
@@ -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;