@codedology/code-forge 0.7.4 → 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,19 @@ 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
+
9
22
  ## [0.7.4] — 2026-10-08
10
23
 
11
24
  ### Added
package/README.md CHANGED
@@ -12,6 +12,14 @@ 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
+
15
23
  - **Code moved between files is no longer read as deleted.** When a block moves code from one
16
24
  file to another, each file's review packet now says where the lines went (or came from), so the
17
25
  reviewer of the old file stops reporting the removal as a lost feature, also on rechecks. Every
@@ -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": 59,
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": {
@@ -810,6 +810,19 @@
810
810
  "B55"
811
811
  ],
812
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"
813
826
  }
814
827
  }
815
828
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codedology/code-forge",
3
- "version": "0.7.4",
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.
@@ -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.4"
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`.
@@ -68,7 +68,7 @@ import { mergeForbidden, scanTranscript } from '../util/forbidden.mjs';
68
68
  import { exec } from '../util/exec.mjs';
69
69
  import { readRun, writeSigned } from '../state/run.mjs';
70
70
  import { StateError } from '../state/paths.mjs';
71
- import { findOverlap } from '../state/registry.mjs';
71
+ import { ownsFile } from '../state/registry.mjs';
72
72
  import { sameStamp, treeStamp } from '../state/block.mjs';
73
73
  import { verifyRow } from '../state/signer.mjs';
74
74
  import { tierFor } from '../proof/tiers.mjs';
@@ -565,15 +565,15 @@ export function repoRelativePath(raw) {
565
565
 
566
566
  /**
567
567
  * The block's file set (§4.1): changed tracked files since `base` (deletions included, hashed
568
- * `DELETED_HASH`) ∪ untracked files, filtered to `owned` (exact paths; a glob owned entry matches through `matchOwned`), each with its content hash.
568
+ * `DELETED_HASH`) ∪ untracked files, filtered to `owned` (`matchOwned`, default the registry's `ownsFile` with `ownedKinds`: an exact entry owns its path and the paths below it unless recorded as a regular file, B57), each with its content hash.
569
569
  * Every path goes through `repoRelativePath`.
570
- * @param {{repoRoot: string, base: string, owned: string[], matchOwned?: (file: string) => boolean, changed?: string[]}} opts
570
+ * @param {{repoRoot: string, base: string, owned: string[], ownedKinds?: Record<string, string>, matchOwned?: (file: string) => boolean, changed?: string[]}} opts
571
571
  * `changed`: the tree's changed files when the caller already listed them ({@link changedFiles}).
572
572
  * @returns {Promise<Array<{file: string, content_hash: string}>>}
573
573
  */
574
- export async function blockFileSet({ repoRoot, base, owned, matchOwned, changed }) {
574
+ export async function blockFileSet({ repoRoot, base, owned, ownedKinds, matchOwned, changed }) {
575
575
  const all = changed ?? (await changedFiles({ repoRoot, base }));
576
- const isOwned = matchOwned ?? ((/** @type {string} */ f) => owned.includes(f));
576
+ const isOwned = matchOwned ?? ((/** @type {string} */ f) => ownsFile(owned, f, ownedKinds));
577
577
  return all.filter((f) => isOwned(f)).map((file) => ({ file, content_hash: currentHash(repoRoot, file) }));
578
578
  }
579
579
 
@@ -597,15 +597,16 @@ export async function changedFiles({ repoRoot, base }) {
597
597
  }
598
598
 
599
599
  /**
600
- * B52: whether `owned` (a block's `owned_files`) covers `file` at the gate: the `findOverlap`
601
- * match `block open` uses (exact paths and globs), and — what the field run needed — an entry with
602
- * no glob character also covers every path below it as a directory (`src/feature` owns
603
- * `src/feature/Fan.swift`). A wider match only ever puts more files under the gate's checks.
604
- * @param {ReadonlyArray<string>} owned @param {string} file @returns {boolean}
600
+ * B52: whether `owned` (a block's `owned_files`) covers `file` at the gate. B57: the registry's one
601
+ * ownership rule (`ownsFile`) — the rule `block open`, the scope gate and the worker use: an entry
602
+ * with no glob character covers its path and every path below it as a directory (`src/feature`
603
+ * owns `src/feature/Fan.swift`, never `src/featureX/B.swift`) unless `kinds` (the block's
604
+ * `owned_kinds`) recorded it as a regular file, a glob what it matches.
605
+ * @param {ReadonlyArray<string>} owned @param {string} file @param {Readonly<Record<string, string>>} [kinds]
606
+ * @returns {boolean}
605
607
  */
606
- export function ownsPath(owned, file) {
607
- if (findOverlap(owned, [file]) !== null) return true;
608
- return owned.some((o) => typeof o === 'string' && o.length > 0 && !/[*?[\]{}]/.test(o) && file.startsWith(`${o.replace(/\/+$/, '')}/`));
608
+ export function ownsPath(owned, file, kinds) {
609
+ return ownsFile(owned, file, kinds);
609
610
  }
610
611
 
611
612
  /**
@@ -614,11 +615,11 @@ export function ownsPath(owned, file) {
614
615
  * given, or — for a workspace in a subdirectory (`wsPrefix`, the workspace relative to the top;
615
616
  * `''` when they are the same) — relative to the workspace. A file outside the workspace is
616
617
  * matched as given only.
617
- * @param {string} wsPrefix @returns {(owned: ReadonlyArray<string>, file: string) => boolean}
618
+ * @param {string} wsPrefix @returns {(owned: ReadonlyArray<string>, file: string, kinds?: Readonly<Record<string, string>>) => boolean}
618
619
  */
619
620
  export function ownsAt(wsPrefix) {
620
621
  const prefix = wsPrefix.replace(/\/+$/, '');
621
- return (owned, file) => ownsPath(owned, file) || (prefix !== '' && prefix !== '.' && file.startsWith(`${prefix}/`) && ownsPath(owned, file.slice(prefix.length + 1)));
622
+ return (owned, file, kinds) => ownsPath(owned, file, kinds) || (prefix !== '' && prefix !== '.' && file.startsWith(`${prefix}/`) && ownsPath(owned, file.slice(prefix.length + 1), kinds));
622
623
  }
623
624
 
624
625
  /** B52: the review rows that name a file the block's worker reviewed. */
@@ -673,8 +674,8 @@ const currentHash = (repoRoot, file) => (isOnDisk(repoRoot, file) ? contentHash(
673
674
  * warns that the check did not run (never a silent pass).
674
675
  * @param {{
675
676
  * top: string, changed: ReadonlyArray<string>, id: string, blocks: Record<string, any>,
676
- * rows: Array<Record<string, any>>, key: Buffer, isMine: (file: string) => boolean, owns?: (owned: ReadonlyArray<string>, file: string) => boolean, stateDir?: string,
677
- * }} opts `blocks`: the run record's `blocks`; `rows`: the run's ledger rows; `owns` (default {@link ownsPath}): how another block's `owned_files` match a path.
677
+ * rows: Array<Record<string, any>>, key: Buffer, isMine: (file: string) => boolean, owns?: (owned: ReadonlyArray<string>, file: string, kinds?: Readonly<Record<string, string>>) => boolean, stateDir?: string,
678
+ * }} opts `blocks`: the run record's `blocks`; `rows`: the run's ledger rows; `owns` (default {@link ownsPath}): how another block's `owned_files` (with its `owned_kinds`) match a path.
678
679
  * @returns {Promise<{unowned: string[], siblings: Array<{file: string, block: string}>} | null>}
679
680
  */
680
681
  export async function unownedChanges({ top, changed, id, blocks, rows, key, isMine, owns = ownsPath, stateDir = '.code-forge' }) {
@@ -692,7 +693,7 @@ export async function unownedChanges({ top, changed, id, blocks, rows, key, isMi
692
693
  return others.some(
693
694
  ([other, b]) =>
694
695
  b.status === 'closed' &&
695
- owns(b.owned_files, file) &&
696
+ owns(b.owned_files, file, b.owned_kinds) &&
696
697
  rows.some((r) => r?.event === 'review.approved' && r.block === other && r.file === file && r.content_hash === hash && verified(r, key)),
697
698
  );
698
699
  };
@@ -712,7 +713,7 @@ export async function unownedChanges({ top, changed, id, blocks, rows, key, isMi
712
713
  const siblings = [];
713
714
  for (const file of changed) {
714
715
  if (state(file) || isMine(file) || (await unchangedSinceOpen(file))) continue;
715
- const sibling = others.find(([, b]) => b.status === 'open' && owns(b.owned_files, file));
716
+ const sibling = others.find(([, b]) => b.status === 'open' && owns(b.owned_files, file, b.owned_kinds));
716
717
  if (sibling) siblings.push({ file, block: sibling[0] });
717
718
  else if (!approvedClosed(file)) unowned.push(file);
718
719
  }
@@ -29,6 +29,7 @@
29
29
  import { existsSync, readFileSync } from 'node:fs';
30
30
  import path from 'node:path';
31
31
  import { assertOwned, findOverlap, ownsFile } from '../state/registry.mjs';
32
+ import { ownedKinds } from '../state/owned-kinds.mjs';
32
33
  import { extractClaims, parseSheet, sha256 } from './facts.mjs';
33
34
  import { findSection, nearMissWarning, REQUIRED_SECTIONS } from './plan-sections.mjs';
34
35
 
@@ -300,6 +301,9 @@ export function recordedLanes(rows, planName) {
300
301
  * `run reload` is then backed by a VERIFIED `<bin> run reload`.
301
302
  * @property {Array<Record<string, any>>} [decisions] - the project's ledger rows (the recorded lanes);
302
303
  * none given ⇒ no block has a recorded lane.
304
+ * @property {string} [root] - B57: the project root the owned paths are relative to; an exact owned
305
+ * path that is a regular file there owns only itself in the overlap and seam checks (as at
306
+ * `block open`). None given ⇒ every exact owned path is directory-like (fail closed).
303
307
  */
304
308
 
305
309
  /**
@@ -383,12 +387,23 @@ export function checkPlan(text, opts = {}) {
383
387
  else if (b.lines > limits.blockLines) errors.push(`block ${b.id}: lines forecast ${b.lines} exceeds budget.block_lines ${limits.blockLines} — split it`);
384
388
  }
385
389
 
386
- // disjoint owned files per wave
390
+ // disjoint owned files per wave (B57: an exact path owns the paths below it unless it is a file)
391
+ // (paths are looked at only once they pass `assertOwned`: never `..` or absolute)
392
+ /** @param {PlanBlock} b @returns {Record<string, string>} */
393
+ const kindsFor = (b) => {
394
+ if (opts.root === undefined) return {};
395
+ try {
396
+ return ownedKinds(opts.root, assertOwned(b.owned));
397
+ } catch {
398
+ return {};
399
+ }
400
+ };
401
+ const kindsOf = new Map(blocks.map((b) => [b.id, kindsFor(b)]));
387
402
  for (let i = 0; i < blocks.length; i += 1) {
388
403
  for (let j = i + 1; j < blocks.length; j += 1) {
389
404
  const [a, b] = [blocks[i], blocks[j]];
390
405
  if (a.wave !== b.wave) continue;
391
- const hit = findOverlap(a.owned, b.owned);
406
+ const hit = findOverlap(a.owned, b.owned, { kindsA: kindsOf.get(a.id), kindsB: kindsOf.get(b.id) });
392
407
  if (hit) errors.push(`blocks ${a.id} and ${b.id} (wave ${a.wave}) overlap: ${hit[0]} / ${hit[1]}`);
393
408
  }
394
409
  }
@@ -398,7 +413,7 @@ export function checkPlan(text, opts = {}) {
398
413
  for (const b of blocks) {
399
414
  const deps = closure(blocks, b.id);
400
415
  for (const imported of b.imports) {
401
- const owner = blocks.find((o) => o.id !== b.id && ownsFile(o.owned, imported));
416
+ const owner = blocks.find((o) => o.id !== b.id && ownsFile(o.owned, imported, kindsOf.get(o.id)));
402
417
  if (owner && !deps.has(owner.id)) errors.push(`block ${b.id} imports ${imported} owned by ${owner.id}, which is not in its depends_on`);
403
418
  }
404
419
  }
@@ -17,9 +17,10 @@ import os from 'node:os';
17
17
  import path from 'node:path';
18
18
  import { BLOCK_KINDS, blockKind, dispatchLevel } from '../decide/escalation.mjs';
19
19
  import { exec } from '../util/exec.mjs';
20
+ import { ownedKinds, pathKind } from './owned-kinds.mjs';
20
21
  import { isAncestorArgv, revParseArgv } from '../util/git.mjs';
21
22
  import { StateError } from './paths.mjs';
22
- import { assertOwned, findOverlap, scopeGate } from './registry.mjs';
23
+ import { assertOwned, findOverlap, isExactEntry, ownsFile, scopeGate } from './registry.mjs';
23
24
  import { checkWorkerPin, readRun, saveRun, withRunLock, writeSigned } from './run.mjs';
24
25
  import { verifyRow, loadKey } from './signer.mjs';
25
26
 
@@ -289,13 +290,16 @@ export async function openBlock(opts) {
289
290
 
290
291
  const block = await mutateRun(runId, null, writeRow, async (record) => {
291
292
  if (record.blocks[id]?.status === 'open') throw new StateError('already-open', `block ${id} is already open`);
293
+ // B57: what each exact owned entry is on disk, under the lock (as `block claim`) — a regular
294
+ // file owns only itself, here and in every later check
295
+ const kinds = ownedKinds(record.workspace, owned);
292
296
  for (const [otherId, other] of Object.entries(activeBlocks(record))) {
293
- const pair = findOverlap(owned, other.owned_files);
297
+ const pair = findOverlap(owned, other.owned_files, { kindsA: kinds, kindsB: other.owned_kinds });
294
298
  if (pair) throw new StateError('overlap', `block ${id} owns ${pair[0]}, which overlaps ${pair[1]} owned by open block ${otherId}`);
295
299
  }
296
300
  const baseSha = await resolveCommit(opts.base ?? 'HEAD', record.workspace);
297
301
  if (baseSha !== stampedBase || record.workspace !== workspace) throw new StateError('base-moved', `the base moved while block ${id} was being opened; run block open again`);
298
- const entry = { block: id, base_sha: baseSha, owned_files: [...owned], level: dispatched.level, kind, attempt, opened_at: now.toISOString(), acceptance, lines_forecast: lines, status: 'open', tree_at_open: treeOpen };
302
+ const entry = { block: id, base_sha: baseSha, owned_files: [...owned], owned_kinds: { ...kinds }, level: dispatched.level, kind, attempt, opened_at: now.toISOString(), acceptance, lines_forecast: lines, status: 'open', tree_at_open: treeOpen };
299
303
  record.blocks[id] = entry;
300
304
  const floor = dispatched.trigger === 'docs_floor' ? { requested_level: level, trigger: 'docs_floor' } : {};
301
305
  return {
@@ -318,7 +322,8 @@ export async function attemptBlock({ runId, id, writeRow }) {
318
322
  * `block rebase` — move the base to `head` only when the old base is its ancestor and no commit
319
323
  * in between touched a file this block owns (that is a conflict ⇒ stop). Rename detection is OFF
320
324
  * (`--no-renames`): a rename lists both its old and its new path, so moving an owned file away
321
- * is a conflict too. Never silent.
325
+ * is a conflict too. Never silent. Owned means `ownsFile` (B57: a directory entry owns the files
326
+ * below it).
322
327
  * @param {{runId: string, id: string, head?: string, writeRow: WriteRow}} opts
323
328
  * @returns {Promise<{old_base: string, new_base: string}>}
324
329
  */
@@ -329,7 +334,7 @@ export async function rebaseBlock({ runId, id, head = 'HEAD', writeRow }) {
329
334
  const ancestor = await git(isAncestorArgv(block.base_sha, newBase), cwd, [0, 1]);
330
335
  if (ancestor.code !== 0) throw new StateError('base-not-ancestor', `block ${id}: base ${block.base_sha.slice(0, 12)} is not an ancestor of ${head}`);
331
336
  const diff = await git(['git', 'diff', '--no-renames', '--name-only', block.base_sha, newBase], cwd);
332
- const conflicts = diff.stdout.split('\n').filter((file) => file.length > 0 && findOverlap(block.owned_files, [file]) !== null);
337
+ const conflicts = diff.stdout.split('\n').filter((file) => file.length > 0 && ownsFile(block.owned_files, file, block.owned_kinds));
333
338
  if (conflicts.length > 0) throw new StateError('owned-conflict', `block ${id}: commits since its base changed owned files: ${conflicts.join(', ')}`);
334
339
  const moved = { old_base: block.base_sha, new_base: newBase };
335
340
  block.base_sha = newBase;
@@ -339,18 +344,25 @@ export async function rebaseBlock({ runId, id, head = 'HEAD', writeRow }) {
339
344
 
340
345
  /**
341
346
  * `block claim` — assign one EXACT path (an orphan, or any unowned path) to `id`; refused for a
342
- * glob, and when another open block owns it.
347
+ * glob, and when another open block owns it — or anything it would own: the claimed path becomes an
348
+ * owned entry, so it is checked with `findOverlap` (B57: an entry owns the paths below it too,
349
+ * unless it is a regular file now — its kind is recorded in `owned_kinds` as at `block open`).
343
350
  * @param {{runId: string, id: string, file: string, writeRow: WriteRow}} opts
344
351
  */
345
352
  export async function claimPath({ runId, id, file, writeRow }) {
346
353
  assertOwned([file]);
347
- if (/[*?{]/.test(file)) throw new StateError('bad-owned', `block claim takes one exact path, not a glob (${JSON.stringify(file)})`);
354
+ if (!isExactEntry(file)) throw new StateError('bad-owned', `block claim takes one exact path, not a glob (${JSON.stringify(file)})`);
348
355
  await mutateRun(runId, id, writeRow, (record, block) => {
356
+ const kinds = { [file]: pathKind(record.workspace, file) };
349
357
  for (const [otherId, other] of Object.entries(activeBlocks(record))) {
350
- if (otherId !== id && findOverlap([file], other.owned_files)) throw new StateError('overlap', `${file} is owned by open block ${otherId}`);
358
+ const pair = otherId === id ? null : findOverlap([file], other.owned_files, { kindsA: kinds, kindsB: other.owned_kinds });
359
+ if (pair) throw new StateError('overlap', `${file} overlaps ${pair[1]} owned by open block ${otherId}`);
351
360
  }
352
- if (!block.owned_files.includes(file)) block.owned_files.push(file);
353
- record.orphans = record.orphans.filter((/** @type {string} */ o) => o !== file);
361
+ if (!block.owned_files.includes(file)) {
362
+ block.owned_files.push(file);
363
+ block.owned_kinds = { ...block.owned_kinds, ...kinds };
364
+ }
365
+ record.orphans = record.orphans.filter((/** @type {string} */ o) => !ownsFile([file], o, kinds));
354
366
  return { result: undefined, rows: [{ event: 'block.claim', block: id, path: file }] };
355
367
  });
356
368
  }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * B57: what each EXACT owned entry is on disk when a block is opened or a path claimed — the
3
+ * answer the registry's ownership rule (`state/registry.mjs`) reads through `owned_kinds`. Only a
4
+ * regular file (`lstat`, so never a symlink, whatever it points at) is `file`: it owns only itself.
5
+ * Everything else stays directory-like (fail closed): `dir`, `absent` (not there yet, or a parent
6
+ * is not a directory), `other` (a symlink, a socket, or a path `lstat` could not read).
7
+ */
8
+
9
+ import { lstatSync } from 'node:fs';
10
+ import path from 'node:path';
11
+ import { isExactEntry } from './registry.mjs';
12
+
13
+ /** @typedef {'file' | 'dir' | 'absent' | 'other'} OwnedKind */
14
+
15
+ /**
16
+ * @param {string} root - the directory the entry is relative to (the run's workspace).
17
+ * @param {string} entry - a repo-relative POSIX path (already through `assertOwned`).
18
+ * @returns {OwnedKind}
19
+ */
20
+ export function pathKind(root, entry) {
21
+ let st;
22
+ try {
23
+ st = lstatSync(path.join(root, ...entry.split('/')));
24
+ } catch (err) {
25
+ const code = /** @type {any} */ (err)?.code;
26
+ return code === 'ENOENT' || code === 'ENOTDIR' ? 'absent' : 'other';
27
+ }
28
+ if (st.isFile()) return 'file';
29
+ if (st.isDirectory()) return 'dir';
30
+ return 'other';
31
+ }
32
+
33
+ /**
34
+ * The kind of every exact entry of `owned` (`isExactEntry` — a glob has no kind), keyed by the
35
+ * entry as written.
36
+ * @param {string} root @param {ReadonlyArray<string>} owned
37
+ * @returns {Record<string, OwnedKind>}
38
+ */
39
+ export function ownedKinds(root, owned) {
40
+ /** @type {Record<string, OwnedKind>} */
41
+ const kinds = Object.create(null); // an entry named `__proto__` is a key like any other
42
+ for (const entry of owned) if (isExactEntry(entry)) kinds[entry] = pathKind(root, entry);
43
+ return kinds;
44
+ }
@@ -18,11 +18,29 @@
18
18
  * `test/fixtures/repos/node/**` are disjoint, `src/**` and `src/cli/*.mjs` overlap, and so do
19
19
  * `src/*.mjs` and `src/cli/*.mjs` (they cannot share a path, but overlap is not ruled out by
20
20
  * the prefix rule — the owner resolves it by naming exact paths).
21
+ *
22
+ * B57 — ONE ownership rule, here, for every caller (block open/claim/rebase, the scope gate, the
23
+ * worker's file set and budget, proof, plan check, the close gate's `ownsPath`): an entry with no
24
+ * glob character (`*`, `?`, `{`) owns that exact path AND every path below it, as a directory
25
+ * would (`src/feature` owns `src/feature/a.swift`, never `src/featureX/a.swift`); a glob entry
26
+ * matches as above and nothing more — a brace entry (`src/{a,b}`) is a glob, so each alternative
27
+ * owns exactly itself, nothing below it. This module is pure and cannot tell a file from a
28
+ * directory, so the caller passes what it saw: `kinds` (a block's `owned_kinds`, recorded at
29
+ * `block open` and `block claim` by `state/owned-kinds.mjs`) maps an exact entry to `file` when it
30
+ * was a regular file — then it owns only itself. Any other kind, or no kind (not there yet, an
31
+ * older block, a plan), stays directory-like: fail closed. So `findOverlap` reports a parent/child
32
+ * pair of directory-like entries (either way round), and a directory-like entry against a glob
33
+ * that matches the entry itself or ANY path below it (decided exactly, `globBelow`: `src/feature`
34
+ * vs `src/**` and vs `src/feature/*.swift` overlap, vs `src/*.mjs` and `src/featureX/*` do not);
35
+ * a `file` entry against a glob overlaps only when the glob matches it (`src/README` vs
36
+ * `src/**\/*.mjs` does not).
21
37
  */
22
38
 
23
39
  import { StateError } from './paths.mjs';
24
40
 
25
41
  const GLOB_CHARS = /[*?]/;
42
+ /** What makes an owned ENTRY a glob (B57: the one definition): `*`, `?`, or a `{a,b}` group. */
43
+ const GLOB_ENTRY_CHARS = /[*?{]/;
26
44
  /** Glob syntax this matcher does NOT implement — refused inside a glob entry only. */
27
45
  const UNSUPPORTED_GLOB_CHARS = /[[\]()!+@]/;
28
46
 
@@ -99,7 +117,7 @@ export function assertOwned(owned) {
99
117
  };
100
118
  if (typeof entry !== 'string' || entry.length === 0) refuse('must be a non-empty string');
101
119
  if (entry.includes('\\')) refuse('backslashes are not allowed (POSIX paths only)');
102
- const isGlob = /[*?{]/.test(entry);
120
+ const isGlob = !isExactEntry(entry);
103
121
  if (isGlob && UNSUPPORTED_GLOB_CHARS.test(entry)) {
104
122
  refuse('a glob may not contain [ ] ( ) ! + @ — character classes and extglobs are not supported; name the files exactly or use only *, ?, ** and {a,b}');
105
123
  }
@@ -123,43 +141,124 @@ function staticSegments(p) {
123
141
  const segmentPrefix = (a, b) => a.length <= b.length && a.every((s, i) => s === b[i]);
124
142
 
125
143
  /**
126
- * @param {string} a @param {string} b - brace-free patterns
144
+ * What a caller saw on disk for each exact owned entry (B57, the module doc); only `file` changes
145
+ * the rule. Keys are the entries' original spellings.
146
+ * @typedef {Readonly<Record<string, string>> | null | undefined} OwnedKinds
147
+ */
148
+
149
+ /**
150
+ * B57: is owned `entry` an EXACT path — no `*`, `?` or `{` (`[ ] ( ) ! + @ }` are literal there)?
151
+ * The one definition `assertOwned`, `block claim`, the ownership rule, proof export and
152
+ * `state/owned-kinds.mjs` share.
153
+ * @param {string} entry @returns {boolean}
154
+ */
155
+ export function isExactEntry(entry) {
156
+ return !GLOB_ENTRY_CHARS.test(entry);
157
+ }
158
+
159
+ /**
160
+ * B57: an exact entry that is not a recorded regular file — it owns its path and everything below
161
+ * it (see the module doc). `kinds` is keyed by the entry as written.
162
+ * @param {string} entry @param {OwnedKinds} kinds @returns {boolean}
163
+ */
164
+ const isTreeEntry = (entry, kinds) => isExactEntry(entry) && !(kinds != null && Object.hasOwn(kinds, entry) && kinds[entry] === 'file');
165
+
166
+ /**
167
+ * B57: an exact entry's path for matching — trailing slashes dropped (`assertOwned` refuses them in
168
+ * the run record; the close gate's callers may still pass one), the same in both functions.
169
+ * @param {string} entry @returns {string}
170
+ */
171
+ const exactPath = (entry) => entry.replace(/\/+$/, '');
172
+
173
+ /** @param {string} root @param {string} file @returns {boolean} `file` is `root` or a path below it. */
174
+ const coversPath = (root, file) => file === root || file.startsWith(`${root}/`);
175
+
176
+ /**
177
+ * B57: can brace-free glob `pattern` match some path strictly below directory `dir` (one or more
178
+ * segments after it)? Decided exactly: `dir`'s segments must match a prefix of the pattern, and
179
+ * any pattern left over (or a `**` reached on the way) can always be met by some name below.
180
+ * @param {string} dir @param {string} pattern
181
+ * @returns {boolean}
182
+ */
183
+ function globBelow(dir, pattern) {
184
+ const f = dir.split('/');
185
+ const p = pattern.split('/');
186
+ for (let i = 0; ; i += 1) {
187
+ if (i < p.length && p[i] === '**') return true;
188
+ if (i === f.length) return i < p.length;
189
+ if (i === p.length || !segmentRegExp(p[i]).test(f[i])) return false;
190
+ }
191
+ }
192
+
193
+ /**
194
+ * One brace alternative of an owned entry: `tree` when the entry is directory-like (it owns the
195
+ * paths below it too). An exact entry is one alternative, its {@link exactPath}; an empty one
196
+ * (`/`) has none.
197
+ * @typedef {{p: string, tree: boolean}} Alternative
198
+ * @param {string} entry @param {OwnedKinds} kinds @returns {Alternative[]}
199
+ */
200
+ function alternatives(entry, kinds) {
201
+ if (!isExactEntry(entry)) return expandBraces(entry).map((p) => ({ p, tree: false }));
202
+ const p = exactPath(entry);
203
+ return p.length === 0 ? [] : [{ p, tree: isTreeEntry(entry, kinds) }];
204
+ }
205
+
206
+ /**
207
+ * @param {Alternative} a @param {Alternative} b
127
208
  * @returns {boolean}
128
209
  */
129
210
  function patternsOverlap(a, b) {
130
- const aGlob = GLOB_CHARS.test(a);
131
- const bGlob = GLOB_CHARS.test(b);
132
- if (!aGlob && !bGlob) return a === b;
133
- if (!aGlob) return globMatch(a, b);
134
- if (!bGlob) return globMatch(b, a);
135
- const sa = staticSegments(a);
136
- const sb = staticSegments(b);
211
+ if (b.tree && !a.tree) return patternsOverlap(b, a);
212
+ if (a.tree) {
213
+ if (b.tree) return coversPath(a.p, b.p) || coversPath(b.p, a.p);
214
+ if (!GLOB_CHARS.test(b.p)) return coversPath(a.p, b.p);
215
+ return globMatch(a.p, b.p) || globBelow(a.p, b.p);
216
+ }
217
+ const aGlob = GLOB_CHARS.test(a.p);
218
+ const bGlob = GLOB_CHARS.test(b.p);
219
+ if (!aGlob && !bGlob) return a.p === b.p;
220
+ if (!aGlob) return globMatch(a.p, b.p);
221
+ if (!bGlob) return globMatch(b.p, a.p);
222
+ const sa = staticSegments(a.p);
223
+ const sb = staticSegments(b.p);
137
224
  return segmentPrefix(sa, sb) || segmentPrefix(sb, sa);
138
225
  }
139
226
 
140
227
  /**
228
+ * Could two owned lists ever own one path? An exact entry is directory-like unless its kind says
229
+ * `file` (B57, the module doc), so `src/feature` and `src/feature/x.swift` overlap either way round.
141
230
  * @param {ReadonlyArray<string>} ownedA @param {ReadonlyArray<string>} ownedB
231
+ * @param {{kindsA?: OwnedKinds, kindsB?: OwnedKinds}} [kinds] - each list's `owned_kinds`.
142
232
  * @returns {[string, string] | null} the first overlapping pair (original spellings), or null.
143
233
  */
144
- export function findOverlap(ownedA, ownedB) {
234
+ export function findOverlap(ownedA, ownedB, { kindsA, kindsB } = {}) {
145
235
  for (const a of ownedA) {
146
236
  for (const b of ownedB) {
147
- if (expandBraces(a).some((x) => expandBraces(b).some((y) => patternsOverlap(x, y)))) return [a, b];
237
+ if (alternatives(a, kindsA).some((x) => alternatives(b, kindsB).some((y) => patternsOverlap(x, y)))) return [a, b];
148
238
  }
149
239
  }
150
240
  return null;
151
241
  }
152
242
 
153
243
  /**
154
- * @param {ReadonlyArray<string>} owned @param {string} file
244
+ * Does `owned` own repo-relative `file`? A directory-like exact entry owns its path and every path
245
+ * below it, a `file` one (per `kinds`) only itself (B57); a glob entry owns what it matches. A
246
+ * non-string or empty entry owns nothing; an exact entry is matched as its {@link exactPath}, as
247
+ * in `findOverlap`.
248
+ * @param {ReadonlyArray<unknown>} owned @param {string} file @param {OwnedKinds} [kinds] - the block's `owned_kinds`.
155
249
  * @returns {boolean}
156
250
  */
157
- export function ownsFile(owned, file) {
158
- return owned.some((entry) => expandBraces(entry).some((p) => p === file || (GLOB_CHARS.test(p) && globMatch(file, p))));
251
+ export function ownsFile(owned, file, kinds) {
252
+ return owned.some((entry) => {
253
+ if (typeof entry !== 'string' || entry.length === 0) return false;
254
+ if (!isExactEntry(entry)) return expandBraces(entry).some((p) => p === file || (GLOB_CHARS.test(p) && globMatch(file, p)));
255
+ const root = exactPath(entry);
256
+ return root.length > 0 && (isTreeEntry(entry, kinds) ? coversPath(root, file) : root === file);
257
+ });
159
258
  }
160
259
 
161
260
  /**
162
- * @typedef {{owned_files: string[], status?: string}} RegistryBlock
261
+ * @typedef {{owned_files: string[], owned_kinds?: Record<string, string>, status?: string}} RegistryBlock
163
262
  * @param {Record<string, RegistryBlock>} blocks - the ACTIVE blocks only (status `open`)
164
263
  * @param {ReadonlyArray<string>} files - the shared tree's changed + untracked files
165
264
  * @returns {{scopes: Record<string, string[]>, orphans: string[]}}
@@ -169,7 +268,7 @@ export function computeScopes(blocks, files) {
169
268
  const scopes = Object.fromEntries(Object.keys(blocks).map((id) => [id, []]));
170
269
  const orphans = [];
171
270
  for (const file of [...new Set(files)].sort()) {
172
- const owners = Object.keys(blocks).filter((id) => ownsFile(blocks[id].owned_files, file));
271
+ const owners = Object.keys(blocks).filter((id) => ownsFile(blocks[id].owned_files, file, blocks[id].owned_kinds));
173
272
  if (owners.length === 0) orphans.push(file);
174
273
  for (const id of owners) scopes[id].push(file);
175
274
  }
@@ -107,16 +107,17 @@ import { assertTicketId } from './ticket.mjs';
107
107
  * block entry without a base `block-base-missing` — a guessed base could let a change escape review.
108
108
  * @param {string} runId @param {string} block
109
109
  * The block's kind (B34) is the recorded `kind` when `block open` stored one, else `blockKind`
110
- * of its owned files.
110
+ * of its owned files. `ownedKinds` is the recorded `owned_kinds` (B57: which exact entries were
111
+ * regular files at `block open`/`claim`), `{}` when there is none (every exact entry directory-like).
111
112
  * With no run record the kind is null: `reviewTicket` takes the ticket file's kind.
112
- * @returns {Promise<{base: string | null, level: string, owned: string[], acceptance?: unknown, kind: string | null}>}
113
+ * @returns {Promise<{base: string | null, level: string, owned: string[], ownedKinds: Record<string, string>, acceptance?: unknown, kind: string | null}>}
113
114
  */
114
115
  export async function blockEntryFor(runId, block) {
115
116
  let record;
116
117
  try {
117
118
  record = await readRun(runId);
118
119
  } catch (err) {
119
- if (/** @type {any} */ (err)?.code === 'no-run') return { base: null, level: 'L2', owned: [], acceptance: undefined, kind: null };
120
+ if (/** @type {any} */ (err)?.code === 'no-run') return { base: null, level: 'L2', owned: [], ownedKinds: {}, acceptance: undefined, kind: null };
120
121
  throw new Error('run-record-unreadable');
121
122
  }
122
123
  if (!record || typeof record !== 'object' || !record.blocks || typeof record.blocks !== 'object') throw new Error('run-record-unreadable');
@@ -125,7 +126,9 @@ export async function blockEntryFor(runId, block) {
125
126
  const sha = entry?.base_sha;
126
127
  if (typeof sha !== 'string' || sha.length === 0) throw new Error('block-base-missing');
127
128
  const owned = Array.isArray(entry.owned_files) ? entry.owned_files.filter((f) => typeof f === 'string') : [];
128
- return { base: sha, level: typeof entry.level === 'string' && /^L[0-3]$/.test(entry.level) ? entry.level : 'L2', owned, acceptance: entry.acceptance, kind: blockKind({ owned, declared: entry.kind }) };
129
+ const kinds = entry.owned_kinds;
130
+ const ownedKinds = kinds && typeof kinds === 'object' && !Array.isArray(kinds) ? kinds : {};
131
+ return { base: sha, level: typeof entry.level === 'string' && /^L[0-3]$/.test(entry.level) ? entry.level : 'L2', owned, ownedKinds, acceptance: entry.acceptance, kind: blockKind({ owned, declared: entry.kind }) };
129
132
  }
130
133
 
131
134
  /** The acceptance line written when the block's clauses cannot be read. */
@@ -210,7 +213,7 @@ export async function reviewTicket(ticket, ctx) {
210
213
  return { status: 'unavailable', reason: err instanceof Error ? err.message : 'run-record-unreadable', approved: false, engine: 'adaptive', sessions: [] };
211
214
  }
212
215
  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 });
216
+ const peerDiffs = blockPeerDiffs(ctx.repoRoot, { base: entry.base, owned: entry.owned, ownedKinds: entry.ownedKinds, file: ticket.file });
214
217
  try {
215
218
  if (!ctx.key) {
216
219
  return await reviewFile(
@@ -218,7 +221,7 @@ export async function reviewTicket(ticket, ctx) {
218
221
  { spawn: ctx.spawn, ...(ctx.writeRow ? { writeRow: ctx.writeRow } : {}) },
219
222
  );
220
223
  }
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 });
224
+ return await fixLoopRound(ticket, ctx, { base: entry.base, level: entry.level, owned: entry.owned, ownedKinds: entry.ownedKinds, blockKindOf: entry.kind, key: ctx.key, workDir, factsExcerpt: acceptanceExcerpt(entry.acceptance, ctx.cfg), peerDiffs });
222
225
  } finally {
223
226
  rmSync(workDir, { recursive: true, force: true });
224
227
  }
@@ -229,13 +232,13 @@ export async function reviewTicket(ticket, ctx) {
229
232
  * untracked), filtered to the block's owned files (`ownsFile`, as the budget row does), minus the
230
233
  * ticket's file. The review names code moved between them (`## moved code`). No base, no owned
231
234
  * 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
235
+ * @param {string} repoRoot @param {{base: string | null, owned: string[], ownedKinds?: Record<string, string>, file: string}} opts
233
236
  * @returns {Promise<string[]>}
234
237
  */
235
- export async function blockPeers(repoRoot, { base, owned, file }) {
238
+ export async function blockPeers(repoRoot, { base, owned, ownedKinds, file }) {
236
239
  if (base === null || !Array.isArray(owned) || owned.length === 0) return [];
237
240
  try {
238
- return (await computeFileSet({ cwd: repoRoot, base })).all.filter((f) => f !== file && ownsFile(owned, f));
241
+ return (await computeFileSet({ cwd: repoRoot, base })).all.filter((f) => f !== file && ownsFile(owned, f, ownedKinds));
239
242
  } catch {
240
243
  return [];
241
244
  }
@@ -246,16 +249,16 @@ export async function blockPeers(repoRoot, { base, owned, file }) {
246
249
  * listed and the diffs read (`peerDiffReader`) at most once, however many packets ask — lazily, so
247
250
  * a round with no packet (a stopped file) runs no git call. Any throw or rejection on the way ⇒
248
251
  * no peer diffs (no hint), memoised like a success.
249
- * @param {string} repoRoot @param {{base: string | null, owned: string[], file: string}} opts
252
+ * @param {string} repoRoot @param {{base: string | null, owned: string[], ownedKinds?: Record<string, string>, file: string}} opts
250
253
  * @returns {() => Promise<import('../review/moved.mjs').PeerDiff[]>}
251
254
  */
252
- export function blockPeerDiffs(repoRoot, { base, owned, file }) {
255
+ export function blockPeerDiffs(repoRoot, { base, owned, ownedKinds, file }) {
253
256
  /** @type {Promise<import('../review/moved.mjs').PeerDiff[]> | null} */
254
257
  let once = null;
255
258
  return () => {
256
259
  once ??= (async () => {
257
260
  try {
258
- return await peerDiffReader({ repoRoot, base, file, peers: await blockPeers(repoRoot, { base, owned, file }) })();
261
+ return await peerDiffReader({ repoRoot, base, file, peers: await blockPeers(repoRoot, { base, owned, ownedKinds, file }) })();
259
262
  } catch {
260
263
  return [];
261
264
  }
@@ -326,10 +329,10 @@ export function mayTakeRung(state, kind, cfg, rungUsed) {
326
329
  * first full round, or any round once the rung is gone) runs outside the block lock, in parallel
327
330
  * with the block's other files.
328
331
  * @param {import('./queue.mjs').Ticket} ticket @param {ReviewContext} ctx
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
332
+ * @param {{base: string | null, level: string, owned: string[], ownedKinds?: Record<string, string>, blockKindOf: string, key: Buffer, workDir: string, factsExcerpt: string, peerDiffs?: () => Promise<import('../review/moved.mjs').PeerDiff[]>}} opts
330
333
  * @returns {Promise<ReviewOutcome>}
331
334
  */
332
- async function fixLoopRound(ticket, ctx, { base, level, owned, blockKindOf, key, workDir, factsExcerpt, peerDiffs }) {
335
+ async function fixLoopRound(ticket, ctx, { base, level, owned, ownedKinds, blockKindOf, key, workDir, factsExcerpt, peerDiffs }) {
333
336
  if (!ctx.readRows || !ctx.writeRow) return { status: 'unavailable', reason: 'no-ledger', approved: false, engine: 'adaptive', sessions: [] };
334
337
  const readRows = ctx.readRows;
335
338
  /** @type {{outcome: ReviewOutcome} | {round: () => Promise<ReviewOutcome>}} */
@@ -343,7 +346,7 @@ async function fixLoopRound(ticket, ctx, { base, level, owned, blockKindOf, key,
343
346
  // Check-then-write under the block lock: no other ticket of this block reads the rows between
344
347
  // this check and the budget row's write, so the row is written once per block.
345
348
  if (!rows.some((r) => r?.event === 'review.budget' && r.block === ticket.block)) {
346
- await recordBlockBudget(ticket, ctx, { base, owned });
349
+ await recordBlockBudget(ticket, ctx, { base, owned, ownedKinds });
347
350
  }
348
351
  const where = { runRootDir: ctx.runRootDir, runId: ctx.runId, block: ticket.block, file: ticket.file, key, rows };
349
352
  const loaded = loadState(where);
@@ -489,15 +492,15 @@ async function playRound(ticket, ctx, { state, kind, where, seq, base, blockKind
489
492
  * not run yet), written as ONE `review.budget` row (+ `review.over_budget` when over). Best effort:
490
493
  * a git or ledger failure is swallowed, the review goes on.
491
494
  * @param {import('./queue.mjs').Ticket} ticket @param {ReviewContext} ctx
492
- * @param {{base: string | null, owned: string[]}} opts
495
+ * @param {{base: string | null, owned: string[], ownedKinds?: Record<string, string>}} opts
493
496
  */
494
- async function recordBlockBudget(ticket, ctx, { base, owned }) {
497
+ async function recordBlockBudget(ticket, ctx, { base, owned, ownedKinds }) {
495
498
  try {
496
499
  /** @type {string[]} */
497
500
  let changed = [];
498
501
  if (base !== null && owned.length > 0) {
499
502
  try {
500
- changed = (await computeFileSet({ cwd: ctx.repoRoot, base })).all.filter((f) => ownsFile(owned, f));
503
+ changed = (await computeFileSet({ cwd: ctx.repoRoot, base })).all.filter((f) => ownsFile(owned, f, ownedKinds));
501
504
  } catch {
502
505
  // no row on a git failure: a ticket-only forecast would be permanent (the row is written
503
506
  // once per block); the block's next keyed ticket retries