@cat-factory/executor-harness 1.98.0 → 1.102.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -277,19 +277,33 @@ export const MCP_TOOL_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
277
277
  * reached the container by any other route is held to the rule too.
278
278
  */
279
279
  export function isAllowedMcpHttpUrl(raw) {
280
- const match = /^(https?):\/\/([^/?#]*)/i.exec(raw);
281
- if (!match)
280
+ // ASCII control characters and the space are refused ANYWHERE rather than canonicalised: the
281
+ // WHATWG parser trims leading/trailing C0-and-space and removes tab, LF and CR from anywhere, so
282
+ // a url carrying one parses to something other than what it reads as, and this url is written
283
+ // VERBATIM into the CLI's MCP config below.
284
+ for (let i = 0; i < raw.length; i += 1)
285
+ if (raw.charCodeAt(i) <= 0x20)
286
+ return false;
287
+ let parsed;
288
+ try {
289
+ parsed = new URL(raw);
290
+ }
291
+ catch {
292
+ // silent-catch-ok: an unparseable url is exactly what this predicate refuses.
293
+ return false;
294
+ }
295
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
282
296
  return false;
283
- if (match[1].toLowerCase() === 'https')
297
+ if (parsed.protocol === 'https:')
284
298
  return true;
285
- // Plain http from here: the host must be loopback. Strip userinfo FIRST and from the LAST `@`,
286
- // or `http://127.0.0.1@evil.example` reads as loopback while the request goes to evil.example.
287
- const authority = match[2];
288
- const hostPort = authority.slice(authority.lastIndexOf('@') + 1);
289
- const closingBracket = hostPort.indexOf(']');
290
- const host = (hostPort.startsWith('[') && closingBracket !== -1
291
- ? hostPort.slice(1, closingBracket) // IPv6 literal, e.g. [::1]:8080
292
- : (hostPort.split(':')[0] ?? '')).toLowerCase();
299
+ // Plain http from here: the host must be loopback. `new URL` rather than a hand-written parse
300
+ // because it is the parser the CLI resolves the request with, so the host ruled on is the host
301
+ // the credential header travels to. It strips userinfo from the LAST `@` (or
302
+ // `http://127.0.0.1@evil.example` reads as loopback while the request goes to evil.example), and
303
+ // it terminates the authority at a backslash as well as at `/?#` (or
304
+ // `http://evil.example\@127.0.0.1` reads as loopback the same way).
305
+ const hostname = parsed.hostname;
306
+ const host = hostname.startsWith('[') ? hostname.slice(1, -1) : hostname;
293
307
  return host === 'localhost' || host === '::1' || /^127\.\d+\.\d+\.\d+$/.test(host);
294
308
  }
295
309
  /** A string→string record, dropping any non-string entry. Undefined when nothing survives. */
package/dist/agent.js CHANGED
@@ -9,7 +9,8 @@ import { captureRedactedOutput, redactSecrets, registerKnownSecrets } from './re
9
9
  import { cloneRepo, commitAll, conflictDiff, fetchPullRequestHead, fetchReferenceBranches, headCommit, mergeBranch, prepareExistingCheckout, pushBranch, unmergedPaths, } from './git.js';
10
10
  import { inferVcsProvider, openPullRequest } from './vcs-api.js';
11
11
  import { applyPrDescription } from './pr-description.js';
12
- import { makeDirClaimer, noChangesReason, runCodingAgent, runMultiRepoCoding, } from './coding-agent.js';
12
+ import { makeDirClaimer } from './checkout-dir.js';
13
+ import { noChangesReason, runCodingAgent, runMultiRepoCoding } from './coding-agent.js';
13
14
  import { validationFailureMessage } from './validation-checks.js';
14
15
  import { prepopulateDependencies, withDependencyNote } from './dependency-install.js';
15
16
  import { agentCapabilities, mergeEffort } from './agent-shared.js';
@@ -0,0 +1,33 @@
1
+ import type { RepoSpec } from './job.js';
2
+ /** Sanitise an owner/name into a safe single path segment for a sibling checkout directory. */
3
+ export declare function safeDirSegment(value: string): string;
4
+ /**
5
+ * A short deterministic digest of the EXACT `owner` / `name` pair, before sanitisation. FNV-1a
6
+ * over `owner\0name`; the NUL separator makes it a digest of the PAIR rather than of a
7
+ * concatenation, so `('a', 'bc')` and `('ab', 'c')` cannot share one. Hand-rolled rather than
8
+ * taken from `node:crypto` because the backend needs the identical function and runs it in
9
+ * workerd as well as on Node.
10
+ *
11
+ * MUST stay byte-identical to the backend's `checkoutDirDigest`
12
+ * (`@cat-factory/server`, `agents/harnessContract.ts`); see {@link makeDirClaimer}.
13
+ */
14
+ export declare function checkoutDirDigest(owner: string, name: string): string;
15
+ /**
16
+ * A sibling-directory allocator for a multi-repo run: returns the checkout directory name for a
17
+ * repo under the workspace root. A pure function of the pair (`owner__name__digest`), which is
18
+ * what lets this and the backend compute it independently with no shared ordering or state. Kept
19
+ * as a factory so the coding + read-only explore fan-outs share ONE scheme, and it MUST stay
20
+ * byte-identical to the backend's `siblingCheckoutDir` / `renderMultiRepoWorkspaceSection` in
21
+ * `@cat-factory/server`, which names this exact directory in the agent's prompt: the two are
22
+ * computed independently, so a divergent rule would point the agent at a directory that does not
23
+ * exist.
24
+ *
25
+ * The readable `owner__name` prefix does not identify a repo on its own, which is why the digest
26
+ * is there. {@link safeDirSegment} folds a whole class of characters onto `-`, so a GitLab
27
+ * namespace path `grp/sub` and a group literally named `grp-sub` sanitise alike; and the `__`
28
+ * join is ambiguous once a segment may contain `_`, which GitHub owners cannot but GitLab
29
+ * namespace paths can, so `('a__b', 'c')` and `('a', 'b__c')` both read as `a__b__c`. Either
30
+ * collision puts two legs on one directory, and the second one's clone then fails against a
31
+ * directory the first already filled, killing the run in the clone phase naming neither repo.
32
+ */
33
+ export declare function makeDirClaimer(): (repo: Pick<RepoSpec, 'name' | 'owner'>) => string;
@@ -0,0 +1,56 @@
1
+ // ---------------------------------------------------------------------------
2
+ // The HARNESS half of the sibling-checkout-directory contract.
3
+ //
4
+ // The harness CREATES these directories; `@cat-factory/server`'s `agents/harnessContract.ts`
5
+ // NAMES them in the agent's prompt. The image builds from this `src/` plus typescript and may
6
+ // depend on no workspace package, so the two halves are computed INDEPENDENTLY and pinned against
7
+ // each other by `test/harness-contract.conformity.test.ts`. Extracted out of `coding-agent.ts` so
8
+ // the pairing sits in one small module per side rather than buried in the agent runner: the whole
9
+ // point of the pairing is that a reader can see both halves at once.
10
+ // ---------------------------------------------------------------------------
11
+ /** Sanitise an owner/name into a safe single path segment for a sibling checkout directory. */
12
+ export function safeDirSegment(value) {
13
+ return value.replace(/[^A-Za-z0-9._-]/g, '-') || '_';
14
+ }
15
+ /**
16
+ * A short deterministic digest of the EXACT `owner` / `name` pair, before sanitisation. FNV-1a
17
+ * over `owner\0name`; the NUL separator makes it a digest of the PAIR rather than of a
18
+ * concatenation, so `('a', 'bc')` and `('ab', 'c')` cannot share one. Hand-rolled rather than
19
+ * taken from `node:crypto` because the backend needs the identical function and runs it in
20
+ * workerd as well as on Node.
21
+ *
22
+ * MUST stay byte-identical to the backend's `checkoutDirDigest`
23
+ * (`@cat-factory/server`, `agents/harnessContract.ts`); see {@link makeDirClaimer}.
24
+ */
25
+ export function checkoutDirDigest(owner, name) {
26
+ const input = `${owner}\u0000${name}`;
27
+ let hash = 0x811c9dc5;
28
+ for (let i = 0; i < input.length; i += 1) {
29
+ hash ^= input.charCodeAt(i);
30
+ // The FNV prime (16777619) as shifts, with `>>> 0` folding the result back to uint32 every
31
+ // step so the arithmetic never drifts into float range and diverges between engines.
32
+ hash = (hash + ((hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24))) >>> 0;
33
+ }
34
+ return hash.toString(36).padStart(7, '0');
35
+ }
36
+ /**
37
+ * A sibling-directory allocator for a multi-repo run: returns the checkout directory name for a
38
+ * repo under the workspace root. A pure function of the pair (`owner__name__digest`), which is
39
+ * what lets this and the backend compute it independently with no shared ordering or state. Kept
40
+ * as a factory so the coding + read-only explore fan-outs share ONE scheme, and it MUST stay
41
+ * byte-identical to the backend's `siblingCheckoutDir` / `renderMultiRepoWorkspaceSection` in
42
+ * `@cat-factory/server`, which names this exact directory in the agent's prompt: the two are
43
+ * computed independently, so a divergent rule would point the agent at a directory that does not
44
+ * exist.
45
+ *
46
+ * The readable `owner__name` prefix does not identify a repo on its own, which is why the digest
47
+ * is there. {@link safeDirSegment} folds a whole class of characters onto `-`, so a GitLab
48
+ * namespace path `grp/sub` and a group literally named `grp-sub` sanitise alike; and the `__`
49
+ * join is ambiguous once a segment may contain `_`, which GitHub owners cannot but GitLab
50
+ * namespace paths can, so `('a__b', 'c')` and `('a', 'b__c')` both read as `a__b__c`. Either
51
+ * collision puts two legs on one directory, and the second one's clone then fails against a
52
+ * directory the first already filled, killing the run in the clone phase naming neither repo.
53
+ */
54
+ export function makeDirClaimer() {
55
+ return (repo) => `${safeDirSegment(repo.owner)}__${safeDirSegment(repo.name)}__${checkoutDirDigest(repo.owner, repo.name)}`;
56
+ }
@@ -219,19 +219,6 @@ export declare function runRalphValidation(repoDir: string, cwd: string, validat
219
219
  iteration?: number;
220
220
  headSha?: string;
221
221
  }>;
222
- /** Sanitise an owner/name into a safe single path segment for a sibling checkout directory. */
223
- export declare function safeDirSegment(value: string): string;
224
- /**
225
- * A sibling-directory allocator for a multi-repo run: returns the checkout directory name for a
226
- * repo under the workspace root. Deterministic (`owner__name`) and collision-free by construction
227
- * — the checkout set is deduped by `owner/name` upstream and GitHub owners contain no `_`, so the
228
- * `owner__name` join is unique per repo without a stateful collision dance. Kept as a factory so
229
- * the coding + read-only explore fan-outs share ONE scheme, and it MUST stay byte-identical to the
230
- * backend's `siblingCheckoutDir` / `renderMultiRepoWorkspaceSection` in `@cat-factory/server`
231
- * (jobBody.ts), which names this exact directory in the agent's prompt — the two are computed
232
- * independently, so a divergent rule would point the agent at a directory that does not exist.
233
- */
234
- export declare function makeDirClaimer(): (repo: Pick<RepoSpec, 'name' | 'owner'>) => string;
235
222
  /**
236
223
  * Multi-repo coding (service-connections phase 3): clone the primary repo AND every connected
237
224
  * peer repo as SIBLING checkouts under one workspace root, run the agent ONCE with its cwd at
@@ -1,6 +1,7 @@
1
1
  import { mkdir } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { runCapturedCommand } from './captured-command.js';
4
+ import { makeDirClaimer } from './checkout-dir.js';
4
5
  import { branchAheadOfBase, changedFilesSinceBase, branchHasCommitsSince, cloneExistingBranch, cloneRepo, commitTrackedEdits, createBranch, excludeFromGit, fetchReferenceBranches, headCommit, listUntrackedFiles, prepareExistingCheckout, pushBranch, refreshFromBaseIfClean, remoteBranchExists, } from './git.js';
5
6
  import { openPullRequest } from './vcs-api.js';
6
7
  import { FOLLOW_UPS_FILENAME, FollowUpTailer } from './follow-ups.js';
@@ -676,23 +677,6 @@ export async function runRalphValidation(repoDir, cwd, validation, logger, opts)
676
677
  ...(headSha ? { headSha } : {}),
677
678
  };
678
679
  }
679
- /** Sanitise an owner/name into a safe single path segment for a sibling checkout directory. */
680
- export function safeDirSegment(value) {
681
- return value.replace(/[^A-Za-z0-9._-]/g, '-') || '_';
682
- }
683
- /**
684
- * A sibling-directory allocator for a multi-repo run: returns the checkout directory name for a
685
- * repo under the workspace root. Deterministic (`owner__name`) and collision-free by construction
686
- * — the checkout set is deduped by `owner/name` upstream and GitHub owners contain no `_`, so the
687
- * `owner__name` join is unique per repo without a stateful collision dance. Kept as a factory so
688
- * the coding + read-only explore fan-outs share ONE scheme, and it MUST stay byte-identical to the
689
- * backend's `siblingCheckoutDir` / `renderMultiRepoWorkspaceSection` in `@cat-factory/server`
690
- * (jobBody.ts), which names this exact directory in the agent's prompt — the two are computed
691
- * independently, so a divergent rule would point the agent at a directory that does not exist.
692
- */
693
- export function makeDirClaimer() {
694
- return (repo) => `${safeDirSegment(repo.owner)}__${safeDirSegment(repo.name)}`;
695
- }
696
680
  /**
697
681
  * Multi-repo coding (service-connections phase 3): clone the primary repo AND every connected
698
682
  * peer repo as SIBLING checkouts under one workspace root, run the agent ONCE with its cwd at
@@ -711,8 +695,9 @@ export async function runMultiRepoCoding(job, opts = {}) {
711
695
  const peers = job.peerRepos ?? [];
712
696
  const references = job.referenceRepos ?? [];
713
697
  const primaryWorkBranch = job.pushBranch ?? job.newBranch ?? job.branch;
714
- // Assign the sibling directory per repo via the shared deterministic allocator (`owner__name`,
715
- // matching the backend prompt's `siblingCheckoutDir`), shared with the read-only explore fan-out.
698
+ // Assign the sibling directory per repo via the shared deterministic allocator
699
+ // (`owner__name__digest`, matching the backend prompt's `siblingCheckoutDir`), shared with the
700
+ // read-only explore fan-out.
716
701
  const claimDir = makeDirClaimer();
717
702
  const legs = [
718
703
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/executor-harness",
3
- "version": "1.98.0",
3
+ "version": "1.102.0",
4
4
  "description": "Container payload: a thin TypeScript wrapper that runs the Pi coding agent against a cloned repo and opens a PR. Runs in the Cloudflare Container (and, in local native mode, as a host process); carries no secrets.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -30,9 +30,9 @@
30
30
  "hono": "^4.13.0",
31
31
  "typescript": "7.0.2",
32
32
  "vitest": "^4.1.10",
33
- "@cat-factory/kernel": "0.267.0",
34
- "@cat-factory/server": "0.247.0",
35
- "@cat-factory/spend": "0.15.35"
33
+ "@cat-factory/kernel": "0.273.0",
34
+ "@cat-factory/server": "0.253.0",
35
+ "@cat-factory/spend": "0.15.41"
36
36
  },
37
37
  "scripts": {
38
38
  "build": "tsc -p tsconfig.json",
@@ -382,19 +382,28 @@ export const MCP_TOOL_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/
382
382
  * reached the container by any other route is held to the rule too.
383
383
  */
384
384
  export function isAllowedMcpHttpUrl(raw: string): boolean {
385
- const match = /^(https?):\/\/([^/?#]*)/i.exec(raw)
386
- if (!match) return false
387
- if (match[1]!.toLowerCase() === 'https') return true
388
- // Plain http from here: the host must be loopback. Strip userinfo FIRST and from the LAST `@`,
389
- // or `http://127.0.0.1@evil.example` reads as loopback while the request goes to evil.example.
390
- const authority = match[2]!
391
- const hostPort = authority.slice(authority.lastIndexOf('@') + 1)
392
- const closingBracket = hostPort.indexOf(']')
393
- const host = (
394
- hostPort.startsWith('[') && closingBracket !== -1
395
- ? hostPort.slice(1, closingBracket) // IPv6 literal, e.g. [::1]:8080
396
- : (hostPort.split(':')[0] ?? '')
397
- ).toLowerCase()
385
+ // ASCII control characters and the space are refused ANYWHERE rather than canonicalised: the
386
+ // WHATWG parser trims leading/trailing C0-and-space and removes tab, LF and CR from anywhere, so
387
+ // a url carrying one parses to something other than what it reads as, and this url is written
388
+ // VERBATIM into the CLI's MCP config below.
389
+ for (let i = 0; i < raw.length; i += 1) if (raw.charCodeAt(i) <= 0x20) return false
390
+ let parsed: URL
391
+ try {
392
+ parsed = new URL(raw)
393
+ } catch {
394
+ // silent-catch-ok: an unparseable url is exactly what this predicate refuses.
395
+ return false
396
+ }
397
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false
398
+ if (parsed.protocol === 'https:') return true
399
+ // Plain http from here: the host must be loopback. `new URL` rather than a hand-written parse
400
+ // because it is the parser the CLI resolves the request with, so the host ruled on is the host
401
+ // the credential header travels to. It strips userinfo from the LAST `@` (or
402
+ // `http://127.0.0.1@evil.example` reads as loopback while the request goes to evil.example), and
403
+ // it terminates the authority at a backslash as well as at `/?#` (or
404
+ // `http://evil.example\@127.0.0.1` reads as loopback the same way).
405
+ const hostname = parsed.hostname
406
+ const host = hostname.startsWith('[') ? hostname.slice(1, -1) : hostname
398
407
  return host === 'localhost' || host === '::1' || /^127\.\d+\.\d+\.\d+$/.test(host)
399
408
  }
400
409
 
package/src/agent.ts CHANGED
@@ -29,12 +29,8 @@ import {
29
29
  import { inferVcsProvider, openPullRequest } from './vcs-api.js'
30
30
  import type { PiRunStats, RunDiagnostics } from './pi-reduction.js'
31
31
  import { applyPrDescription } from './pr-description.js'
32
- import {
33
- makeDirClaimer,
34
- noChangesReason,
35
- runCodingAgent,
36
- runMultiRepoCoding,
37
- } from './coding-agent.js'
32
+ import { makeDirClaimer } from './checkout-dir.js'
33
+ import { noChangesReason, runCodingAgent, runMultiRepoCoding } from './coding-agent.js'
38
34
  import { validationFailureMessage } from './validation-checks.js'
39
35
  import { prepopulateDependencies, withDependencyNote } from './dependency-install.js'
40
36
  import { agentCapabilities, mergeEffort } from './agent-shared.js'
@@ -0,0 +1,62 @@
1
+ import type { RepoSpec } from './job.js'
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // The HARNESS half of the sibling-checkout-directory contract.
5
+ //
6
+ // The harness CREATES these directories; `@cat-factory/server`'s `agents/harnessContract.ts`
7
+ // NAMES them in the agent's prompt. The image builds from this `src/` plus typescript and may
8
+ // depend on no workspace package, so the two halves are computed INDEPENDENTLY and pinned against
9
+ // each other by `test/harness-contract.conformity.test.ts`. Extracted out of `coding-agent.ts` so
10
+ // the pairing sits in one small module per side rather than buried in the agent runner: the whole
11
+ // point of the pairing is that a reader can see both halves at once.
12
+ // ---------------------------------------------------------------------------
13
+
14
+ /** Sanitise an owner/name into a safe single path segment for a sibling checkout directory. */
15
+ export function safeDirSegment(value: string): string {
16
+ return value.replace(/[^A-Za-z0-9._-]/g, '-') || '_'
17
+ }
18
+
19
+ /**
20
+ * A short deterministic digest of the EXACT `owner` / `name` pair, before sanitisation. FNV-1a
21
+ * over `owner\0name`; the NUL separator makes it a digest of the PAIR rather than of a
22
+ * concatenation, so `('a', 'bc')` and `('ab', 'c')` cannot share one. Hand-rolled rather than
23
+ * taken from `node:crypto` because the backend needs the identical function and runs it in
24
+ * workerd as well as on Node.
25
+ *
26
+ * MUST stay byte-identical to the backend's `checkoutDirDigest`
27
+ * (`@cat-factory/server`, `agents/harnessContract.ts`); see {@link makeDirClaimer}.
28
+ */
29
+ export function checkoutDirDigest(owner: string, name: string): string {
30
+ const input = `${owner}\u0000${name}`
31
+ let hash = 0x811c9dc5
32
+ for (let i = 0; i < input.length; i += 1) {
33
+ hash ^= input.charCodeAt(i)
34
+ // The FNV prime (16777619) as shifts, with `>>> 0` folding the result back to uint32 every
35
+ // step so the arithmetic never drifts into float range and diverges between engines.
36
+ hash = (hash + ((hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24))) >>> 0
37
+ }
38
+ return hash.toString(36).padStart(7, '0')
39
+ }
40
+
41
+ /**
42
+ * A sibling-directory allocator for a multi-repo run: returns the checkout directory name for a
43
+ * repo under the workspace root. A pure function of the pair (`owner__name__digest`), which is
44
+ * what lets this and the backend compute it independently with no shared ordering or state. Kept
45
+ * as a factory so the coding + read-only explore fan-outs share ONE scheme, and it MUST stay
46
+ * byte-identical to the backend's `siblingCheckoutDir` / `renderMultiRepoWorkspaceSection` in
47
+ * `@cat-factory/server`, which names this exact directory in the agent's prompt: the two are
48
+ * computed independently, so a divergent rule would point the agent at a directory that does not
49
+ * exist.
50
+ *
51
+ * The readable `owner__name` prefix does not identify a repo on its own, which is why the digest
52
+ * is there. {@link safeDirSegment} folds a whole class of characters onto `-`, so a GitLab
53
+ * namespace path `grp/sub` and a group literally named `grp-sub` sanitise alike; and the `__`
54
+ * join is ambiguous once a segment may contain `_`, which GitHub owners cannot but GitLab
55
+ * namespace paths can, so `('a__b', 'c')` and `('a', 'b__c')` both read as `a__b__c`. Either
56
+ * collision puts two legs on one directory, and the second one's clone then fails against a
57
+ * directory the first already filled, killing the run in the clone phase naming neither repo.
58
+ */
59
+ export function makeDirClaimer(): (repo: Pick<RepoSpec, 'name' | 'owner'>) => string {
60
+ return (repo) =>
61
+ `${safeDirSegment(repo.owner)}__${safeDirSegment(repo.name)}__${checkoutDirDigest(repo.owner, repo.name)}`
62
+ }
@@ -1,6 +1,7 @@
1
1
  import { mkdir } from 'node:fs/promises'
2
2
  import { join } from 'node:path'
3
3
  import { runCapturedCommand } from './captured-command.js'
4
+ import { makeDirClaimer } from './checkout-dir.js'
4
5
  import type {
5
6
  AgentJob,
6
7
  AgentResult,
@@ -1018,25 +1019,6 @@ export async function runRalphValidation(
1018
1019
  }
1019
1020
  }
1020
1021
 
1021
- /** Sanitise an owner/name into a safe single path segment for a sibling checkout directory. */
1022
- export function safeDirSegment(value: string): string {
1023
- return value.replace(/[^A-Za-z0-9._-]/g, '-') || '_'
1024
- }
1025
-
1026
- /**
1027
- * A sibling-directory allocator for a multi-repo run: returns the checkout directory name for a
1028
- * repo under the workspace root. Deterministic (`owner__name`) and collision-free by construction
1029
- * — the checkout set is deduped by `owner/name` upstream and GitHub owners contain no `_`, so the
1030
- * `owner__name` join is unique per repo without a stateful collision dance. Kept as a factory so
1031
- * the coding + read-only explore fan-outs share ONE scheme, and it MUST stay byte-identical to the
1032
- * backend's `siblingCheckoutDir` / `renderMultiRepoWorkspaceSection` in `@cat-factory/server`
1033
- * (jobBody.ts), which names this exact directory in the agent's prompt — the two are computed
1034
- * independently, so a divergent rule would point the agent at a directory that does not exist.
1035
- */
1036
- export function makeDirClaimer(): (repo: Pick<RepoSpec, 'name' | 'owner'>) => string {
1037
- return (repo) => `${safeDirSegment(repo.owner)}__${safeDirSegment(repo.name)}`
1038
- }
1039
-
1040
1022
  /** One repository participating in a multi-repo run: where to clone it + what to do after. */
1041
1023
  interface RepoLeg {
1042
1024
  repo: RepoSpec
@@ -1086,8 +1068,9 @@ export async function runMultiRepoCoding(
1086
1068
  const references: ReferenceRepoSpec[] = job.referenceRepos ?? []
1087
1069
  const primaryWorkBranch = job.pushBranch ?? job.newBranch ?? job.branch
1088
1070
 
1089
- // Assign the sibling directory per repo via the shared deterministic allocator (`owner__name`,
1090
- // matching the backend prompt's `siblingCheckoutDir`), shared with the read-only explore fan-out.
1071
+ // Assign the sibling directory per repo via the shared deterministic allocator
1072
+ // (`owner__name__digest`, matching the backend prompt's `siblingCheckoutDir`), shared with the
1073
+ // read-only explore fan-out.
1091
1074
  const claimDir = makeDirClaimer()
1092
1075
  const legs: RepoLeg[] = [
1093
1076
  {