From 3c85ae981d4526c3e34bae66602f25e3223c3b2f Mon Sep 17 00:00:00 2001 From: Altay Date: Thu, 13 Aug 2026 21:40:46 +0300 Subject: [PATCH 1/3] fix(coding-agents): inherit opt-in across worktrees --- .../docs-integrations/coding-agents.md | 10 +- .../coding-agents/README.md | 10 +- .../coding-agents/src/core/bank.ts | 34 +++++- .../coding-agents/src/core/opt-in.test.ts | 102 +++++++++++++++++- .../sdks/integrations/coding-agents.md | 37 ++++--- 5 files changed, 158 insertions(+), 35 deletions(-) diff --git a/hindsight-docs/docs-integrations/coding-agents.md b/hindsight-docs/docs-integrations/coding-agents.md index cdf3201c2..88b600357 100644 --- a/hindsight-docs/docs-integrations/coding-agents.md +++ b/hindsight-docs/docs-integrations/coding-agents.md @@ -319,7 +319,7 @@ Anything outside those paths is **inert**: no bank is created, nothing is retain and the agent behaves exactly as it would without the plugin. Approving costs nothing else — `optInPaths` says _which projects_, not _which bank_, so an approved repo keeps its usual `coding-agent::{gitProject}` name. Paths are prefixes, so approving `~/work` approves every repo -under it while each still gets its own bank. +under it and its linked worktrees while each still gets its own bank. A `mapPathToBank` entry counts as opted in too, since routing a path to a named bank already declares that project. A bare `bankId` does not: it names a bank rather than a project, so it @@ -353,10 +353,10 @@ hook by Codex...), so one shared config serves several agents side by side: | `bankId` | — | **explicit static bank**; unset ⇒ per-repo dynamic resolution (below) | | `dynamicBankId` | dynamic iff no `bankId` | force dynamic (`true`) or static (`false`) resolution | | `bankIdTemplate` | `"coding-agent::{gitProject}"` | dynamic bank id format; the default makes every agent share one bank per repo | -| `mapPathToBank` | — | absolute path → bank; **longest prefix wins**; overrides everything | +| `mapPathToBank` | — | absolute path → bank; **longest prefix wins**; linked worktrees inherit their main checkout's mapping; overrides everything | | `optInOnly` | `false` | run memory ONLY in opted-in projects — everything else is inert, with no bank created; see Opt-in only | -| `optInPaths` | — | directories opted in, matched as prefixes with `~` expanded; each repo beneath keeps its own dynamic bank | -| `resolveWorktrees` | `true` | `{gitProject}`: linked worktrees share the main repo's bank | +| `optInPaths` | — | directories opted in, matched as prefixes with `~` expanded; each repo beneath and its linked worktrees are approved while keeping their own dynamic bank | +| `resolveWorktrees` | `true` | linked worktrees inherit the main checkout's bank identity, path approval, and mapping | | `retainTags` | — | extra tags on every document written by the integration, e.g. `["project:{gitProject}"]` — see **Recording where a memory came from** below | | `retainMetadata` | — | extra metadata on every document written by the integration, e.g. `{"repo": "{gitProject}"}` | | `disabled` | `false` | hard off-switch (inert plugin/hook — a no-memory baseline) | @@ -428,7 +428,7 @@ the boundary ("everything I clone under `work/client-x` shares memory"). Coding memory is **per repository**. Resolution order for the working directory: 1. `mapPathToBank` — longest matching absolute-path prefix (mapping a repo root covers every - subdirectory; deeper mappings win; overrides even an explicit `bankId`). + subdirectory and linked worktree; deeper mappings win; overrides even an explicit `bankId`). 2. Static — `bankId` set (or `dynamicBankId: false`). 3. Dynamic — `bankIdTemplate` with placeholders: - `{gitProject}` — worktree-aware repo name: `git rev-parse --git-common-dir` resolves every diff --git a/hindsight-integrations/coding-agents/README.md b/hindsight-integrations/coding-agents/README.md index 0df15ca48..4bef5f2ca 100644 --- a/hindsight-integrations/coding-agents/README.md +++ b/hindsight-integrations/coding-agents/README.md @@ -312,7 +312,7 @@ Anything outside those paths is **inert**: no bank is created, nothing is retain and the agent behaves exactly as it would without the plugin. Approving costs nothing else — `optInPaths` says _which projects_, not _which bank_, so an approved repo keeps its usual `coding-agent::{gitProject}` name. Paths are prefixes, so approving `~/work` approves every repo -under it while each still gets its own bank. +under it and its linked worktrees while each still gets its own bank. A `mapPathToBank` entry counts as opted in too, since routing a path to a named bank already declares that project. A bare `bankId` does not: it names a bank rather than a project, so it @@ -346,10 +346,10 @@ hook by Codex...), so one shared config serves several agents side by side: | `bankId` | — | **explicit static bank**; unset ⇒ per-repo dynamic resolution (below) | | `dynamicBankId` | dynamic iff no `bankId` | force dynamic (`true`) or static (`false`) resolution | | `bankIdTemplate` | `"coding-agent::{gitProject}"` | dynamic bank id format; the default makes every agent share one bank per repo | -| `mapPathToBank` | — | absolute path → bank; **longest prefix wins**; overrides everything | +| `mapPathToBank` | — | absolute path → bank; **longest prefix wins**; linked worktrees inherit their main checkout's mapping; overrides everything | | `optInOnly` | `false` | run memory ONLY in opted-in projects — everything else is inert, with no bank created; see [Opt-in only](#opt-in-only) | -| `optInPaths` | — | directories opted in, matched as prefixes with `~` expanded; each repo beneath keeps its own dynamic bank | -| `resolveWorktrees` | `true` | `{gitProject}`: linked worktrees share the main repo's bank | +| `optInPaths` | — | directories opted in, matched as prefixes with `~` expanded; each repo beneath and its linked worktrees are approved while keeping their own dynamic bank | +| `resolveWorktrees` | `true` | linked worktrees inherit the main checkout's bank identity, path approval, and mapping | | `retainTags` | — | extra tags on every document written by the integration, e.g. `["project:{gitProject}"]` — see **Recording where a memory came from** below | | `retainMetadata` | — | extra metadata on every document written by the integration, e.g. `{"repo": "{gitProject}"}` | | `disabled` | `false` | hard off-switch (inert plugin/hook — a no-memory baseline) | @@ -421,7 +421,7 @@ the boundary ("everything I clone under `work/client-x` shares memory"). Coding memory is **per repository**. Resolution order for the working directory: 1. `mapPathToBank` — longest matching absolute-path prefix (mapping a repo root covers every - subdirectory; deeper mappings win; overrides even an explicit `bankId`). + subdirectory and linked worktree; deeper mappings win; overrides even an explicit `bankId`). 2. Static — `bankId` set (or `dynamicBankId: false`). 3. Dynamic — `bankIdTemplate` with placeholders: - `{gitProject}` — worktree-aware repo name: `git rev-parse --git-common-dir` resolves every diff --git a/hindsight-integrations/coding-agents/src/core/bank.ts b/hindsight-integrations/coding-agents/src/core/bank.ts index ccba7e84a..ceeb98faa 100644 --- a/hindsight-integrations/coding-agents/src/core/bank.ts +++ b/hindsight-integrations/coding-agents/src/core/bank.ts @@ -7,7 +7,7 @@ * * Resolution order: * 1. `mapPathToBank` — absolute path -> bank; LONGEST matching prefix wins, so mapping a - * repo root covers every subdirectory (and worktree paths can be pinned individually). + * repo root covers every subdirectory and linked worktree of that repo. * Overrides everything, including an explicit bankId. * 2. static — when `dynamicBankId` is false, or left unset WITH an explicit `bankId` * (the benchmark harness and single-bank setups). @@ -149,6 +149,18 @@ function mapLookup(map: Record, directory: string): string | und return best?.bank; } +/** Current directory first, then its main Git root. Keeping the literal path first preserves an + * explicit worktree-specific mapping while letting an approved checkout carry that approval to + * linked worktrees outside the configured directory tree. */ +function lookupDirectories(config: BankConfig, directory: string): string[] { + const directories = [normalize(directory)]; + if (config.resolveWorktrees ?? true) { + const root = getProjectRootFromGit(directory); + if (root && root !== directories[0]) directories.push(root); + } + return directories; +} + /** * Whether memory may run for this directory at all. * @@ -168,16 +180,28 @@ function mapLookup(map: Record, directory: string): string | und export function isOptedIn(config: BankConfig, directory: string): boolean { if (!config.optInOnly) return true; if (!directory) return false; - const cwd = normalize(directory); - if ((config.optInPaths ?? []).some((dir) => dir && isWithin(cwd, configuredDir(dir)))) + const directories = lookupDirectories(config, directory); + if ( + directories.some((candidate) => + (config.optInPaths ?? []).some( + (configured) => configured && isWithin(candidate, configuredDir(configured)) + ) + ) + ) return true; - return Boolean(config.mapPathToBank && mapLookup(config.mapPathToBank, directory)); + const pathMap = config.mapPathToBank; + return Boolean(pathMap && directories.some((candidate) => mapLookup(pathMap, candidate))); } /** Derive the bank id for a working directory (see module doc for the resolution order). */ export function deriveBankId(config: BankConfig, directory: string, harness = "coding"): string { + const pathMap = config.mapPathToBank; const mapped = - directory && config.mapPathToBank ? mapLookup(config.mapPathToBank, directory) : undefined; + directory && pathMap + ? lookupDirectories(config, directory) + .map((candidate) => mapLookup(pathMap, candidate)) + .find((bank) => bank !== undefined) + : undefined; if (mapped) return mapped; // dynamic by default — but an explicit bankId (without dynamicBankId: true) means "static". diff --git a/hindsight-integrations/coding-agents/src/core/opt-in.test.ts b/hindsight-integrations/coding-agents/src/core/opt-in.test.ts index 01d9334ae..287a51367 100644 --- a/hindsight-integrations/coding-agents/src/core/opt-in.test.ts +++ b/hindsight-integrations/coding-agents/src/core/opt-in.test.ts @@ -1,5 +1,5 @@ import { execFileSync } from "node:child_process"; -import { mkdtempSync, mkdirSync, rmSync } from "node:fs"; +import { mkdtempSync, mkdirSync, realpathSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; @@ -54,6 +54,106 @@ describe("optInOnly", () => { expect(isOptedIn(cfg, other)).toBe(false); }); + it("carries path approval and bank mapping from a checkout to its linked worktree", () => { + const worktree = join(root, "external-worktrees", "client-x"); + execFileSync( + "git", + [ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.invalid", + "commit", + "--allow-empty", + "-qm", + "seed", + ], + { cwd: approved } + ); + mkdirSync(join(root, "external-worktrees"), { recursive: true }); + execFileSync("git", ["worktree", "add", "-q", "-b", "linked", worktree], { cwd: approved }); + + const checkoutParent = join(realpathSync(root), "work"); + const byPath = resolveConfig({ optInOnly: true, optInPaths: [checkoutParent] }); + expect(isOptedIn(byPath, worktree)).toBe(true); + + const byMap = resolveConfig({ + optInOnly: true, + mapPathToBank: { [checkoutParent]: "client-x" }, + }); + expect(isOptedIn(byMap, worktree)).toBe(true); + expect(deriveBankId(byMap, worktree, "codex")).toBe("client-x"); + + const nested = join(worktree, "src", "deep"); + mkdirSync(nested, { recursive: true }); + expect(isOptedIn(byMap, nested)).toBe(true); + expect(deriveBankId(byMap, nested, "codex")).toBe("client-x"); + + const worktreeOverride = resolveConfig({ + optInOnly: true, + mapPathToBank: { + [checkoutParent]: "client-x", + [worktree]: "client-x-experiment", + }, + }); + expect(deriveBankId(worktreeOverride, worktree, "codex")).toBe("client-x-experiment"); + }); + + it("keeps a linked worktree denied when its checkout is outside every approved path", () => { + const worktree = join(root, "external-worktrees", "throwaway"); + execFileSync( + "git", + [ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.invalid", + "commit", + "--allow-empty", + "-qm", + "seed", + ], + { cwd: other } + ); + mkdirSync(join(root, "external-worktrees"), { recursive: true }); + execFileSync("git", ["worktree", "add", "-q", "-b", "linked", worktree], { cwd: other }); + + const cfg = resolveConfig({ + optInOnly: true, + mapPathToBank: { [join(realpathSync(root), "work")]: "client-x" }, + }); + expect(isOptedIn(cfg, worktree)).toBe(false); + }); + + it("does not carry checkout approval to linked worktrees when resolution is disabled", () => { + const worktree = join(root, "external-worktrees", "client-x"); + execFileSync( + "git", + [ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.invalid", + "commit", + "--allow-empty", + "-qm", + "seed", + ], + { cwd: approved } + ); + mkdirSync(join(root, "external-worktrees"), { recursive: true }); + execFileSync("git", ["worktree", "add", "-q", "-b", "linked", worktree], { cwd: approved }); + + const cfg = resolveConfig({ + optInOnly: true, + optInPaths: [join(realpathSync(root), "work")], + mapPathToBank: { [join(realpathSync(root), "work")]: "client-x" }, + resolveWorktrees: false, + }); + expect(isOptedIn(cfg, worktree)).toBe(false); + expect(deriveBankId(cfg, worktree, "codex")).toBe("coding-agent::client-x"); + }); + it("does not let a bare bankId approve anything", () => { // It names a bank, not a project, so it cannot say which work may be remembered. A privacy // switch fails closed. diff --git a/skills/hindsight-docs/references/sdks/integrations/coding-agents.md b/skills/hindsight-docs/references/sdks/integrations/coding-agents.md index 2f2e18cec..c4e914c40 100644 --- a/skills/hindsight-docs/references/sdks/integrations/coding-agents.md +++ b/skills/hindsight-docs/references/sdks/integrations/coding-agents.md @@ -1,6 +1,5 @@ - {/* GENERATED from hindsight-integrations/coding-agents/README.md — edit that file, then run - node hindsight-docs/scripts/sync-coding-agents-doc.mjs */} +node hindsight-docs/scripts/sync-coding-agents-doc.mjs */} Long-term project memory for **coding agents**, backed by [Hindsight](https://vectorize.io/hindsight). One package, several agents: a shared reflect-and-inject core with a thin entry point per agent @@ -34,7 +33,7 @@ local daemon on this machine (see Where memory lives). Scripted installs pass Same command, only the harness name changes. Run after installing the package globally. -#### Claude Code +#### Claude Code ```bash npx @vectorize-io/hindsight-coding-agents install claude-code @@ -42,7 +41,7 @@ npx @vectorize-io/hindsight-coding-agents install claude-code 3 hooks in `~/.claude/settings.json`, MCP via `claude mcp add` (user scope), and the companion skill. -#### Codex CLI +#### Codex CLI ```bash npx @vectorize-io/hindsight-coding-agents install codex @@ -50,7 +49,7 @@ npx @vectorize-io/hindsight-coding-agents install codex 3 hooks in `~/.codex/hooks.json` plus `[mcp_servers]` in `config.toml` (needs `codex_hooks = true`). -#### opencode +#### opencode ```bash npx @vectorize-io/hindsight-coding-agents install opencode @@ -58,7 +57,7 @@ npx @vectorize-io/hindsight-coding-agents install opencode A plugin entry in `~/.config/opencode/opencode.json` — native tools, no MCP needed. -#### Kilo CLI +#### Kilo CLI ```bash npx @vectorize-io/hindsight-coding-agents install kilo @@ -66,7 +65,7 @@ npx @vectorize-io/hindsight-coding-agents install kilo A plugin entry in `~/.config/kilo/kilo.json[c]`. -#### Cursor CLI +#### Cursor CLI ```bash npx @vectorize-io/hindsight-coding-agents install cursor-cli @@ -74,7 +73,7 @@ npx @vectorize-io/hindsight-coding-agents install cursor-cli Hooks in `~/.cursor/hooks.json`, `~/.cursor/mcp.json`, and the companion skill. -#### GitHub Copilot CLI +#### GitHub Copilot CLI ```bash npx @vectorize-io/hindsight-coding-agents install copilot-cli @@ -82,7 +81,7 @@ npx @vectorize-io/hindsight-coding-agents install copilot-cli `~/.copilot/hooks/`, `mcp-config.json`, and the companion skill. -#### Grok Build +#### Grok Build ```bash npx @vectorize-io/hindsight-coding-agents install grok-build @@ -90,7 +89,7 @@ npx @vectorize-io/hindsight-coding-agents install grok-build Native hooks and MCP in `~/.grok/config.toml`, plus the companion skill. -#### Antigravity CLI +#### Antigravity CLI ```bash npx @vectorize-io/hindsight-coding-agents install agy @@ -98,7 +97,7 @@ npx @vectorize-io/hindsight-coding-agents install agy Lifecycle hooks, MCP, and the `Hindsight · ` status line. -#### Devin CLI +#### Devin CLI ```bash npx @vectorize-io/hindsight-coding-agents install devin-cli @@ -106,7 +105,7 @@ npx @vectorize-io/hindsight-coding-agents install devin-cli Hooks in `~/.config/devin/config.json` plus MCP. Needs Node 22.5+ — see below. -#### Cline CLI +#### Cline CLI ```bash npx @vectorize-io/hindsight-coding-agents install cline-cli @@ -114,7 +113,7 @@ npx @vectorize-io/hindsight-coding-agents install cline-cli A native plugin via `cline plugin install`, plus MCP and the companion skill. -#### Prime Agent +#### Prime Agent ```bash npx @vectorize-io/hindsight-coding-agents install prime-agent @@ -314,7 +313,7 @@ Anything outside those paths is **inert**: no bank is created, nothing is retain and the agent behaves exactly as it would without the plugin. Approving costs nothing else — `optInPaths` says _which projects_, not _which bank_, so an approved repo keeps its usual `coding-agent::{gitProject}` name. Paths are prefixes, so approving `~/work` approves every repo -under it while each still gets its own bank. +under it and its linked worktrees while each still gets its own bank. A `mapPathToBank` entry counts as opted in too, since routing a path to a named bank already declares that project. A bare `bankId` does not: it names a bank rather than a project, so it @@ -348,10 +347,10 @@ hook by Codex...), so one shared config serves several agents side by side: | `bankId` | — | **explicit static bank**; unset ⇒ per-repo dynamic resolution (below) | | `dynamicBankId` | dynamic iff no `bankId` | force dynamic (`true`) or static (`false`) resolution | | `bankIdTemplate` | `"coding-agent::{gitProject}"` | dynamic bank id format; the default makes every agent share one bank per repo | -| `mapPathToBank` | — | absolute path → bank; **longest prefix wins**; overrides everything | -| `optInOnly` | `false` | run memory ONLY in opted-in projects — everything else is inert, with no bank created; see Opt-in only | -| `optInPaths` | — | directories opted in, matched as prefixes with `~` expanded; each repo beneath keeps its own dynamic bank | -| `resolveWorktrees` | `true` | `{gitProject}`: linked worktrees share the main repo's bank | +| `mapPathToBank` | — | absolute path → bank; **longest prefix wins**; linked worktrees inherit their main checkout's mapping; overrides everything | +| `optInOnly` | `false` | run memory ONLY in opted-in projects — everything else is inert, with no bank created; see Opt-in only | +| `optInPaths` | — | directories opted in, matched as prefixes with `~` expanded; each repo beneath and its linked worktrees are approved while keeping their own dynamic bank | +| `resolveWorktrees` | `true` | linked worktrees inherit the main checkout's bank identity, path approval, and mapping | | `retainTags` | — | extra tags on every document written by the integration, e.g. `["project:{gitProject}"]` — see **Recording where a memory came from** below | | `retainMetadata` | — | extra metadata on every document written by the integration, e.g. `{"repo": "{gitProject}"}` | | `disabled` | `false` | hard off-switch (inert plugin/hook — a no-memory baseline) | @@ -423,7 +422,7 @@ the boundary ("everything I clone under `work/client-x` shares memory"). Coding memory is **per repository**. Resolution order for the working directory: 1. `mapPathToBank` — longest matching absolute-path prefix (mapping a repo root covers every - subdirectory; deeper mappings win; overrides even an explicit `bankId`). + subdirectory and linked worktree; deeper mappings win; overrides even an explicit `bankId`). 2. Static — `bankId` set (or `dynamicBankId: false`). 3. Dynamic — `bankIdTemplate` with placeholders: - `{gitProject}` — worktree-aware repo name: `git rev-parse --git-common-dir` resolves every From a4db8618e1d1ee3bcdc5829f059e9c3e45a46b1d Mon Sep 17 00:00:00 2001 From: Altay Date: Thu, 13 Aug 2026 21:45:59 +0300 Subject: [PATCH 2/3] fix(coding-agents): resolve removed worktree paths --- hindsight-integrations/coding-agents/src/core/bank.ts | 3 ++- hindsight-integrations/coding-agents/src/core/opt-in.test.ts | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/hindsight-integrations/coding-agents/src/core/bank.ts b/hindsight-integrations/coding-agents/src/core/bank.ts index ceeb98faa..85dc46178 100644 --- a/hindsight-integrations/coding-agents/src/core/bank.ts +++ b/hindsight-integrations/coding-agents/src/core/bank.ts @@ -155,7 +155,8 @@ function mapLookup(map: Record, directory: string): string | und function lookupDirectories(config: BankConfig, directory: string): string[] { const directories = [normalize(directory)]; if (config.resolveWorktrees ?? true) { - const root = getProjectRootFromGit(directory); + const existing = nearestExistingDir(directory); + const root = existing ? getProjectRootFromGit(existing) : null; if (root && root !== directories[0]) directories.push(root); } return directories; diff --git a/hindsight-integrations/coding-agents/src/core/opt-in.test.ts b/hindsight-integrations/coding-agents/src/core/opt-in.test.ts index 287a51367..45cd4ae8d 100644 --- a/hindsight-integrations/coding-agents/src/core/opt-in.test.ts +++ b/hindsight-integrations/coding-agents/src/core/opt-in.test.ts @@ -89,6 +89,10 @@ describe("optInOnly", () => { expect(isOptedIn(byMap, nested)).toBe(true); expect(deriveBankId(byMap, nested, "codex")).toBe("client-x"); + rmSync(nested, { recursive: true }); + expect(isOptedIn(byMap, nested)).toBe(true); + expect(deriveBankId(byMap, nested, "codex")).toBe("client-x"); + const worktreeOverride = resolveConfig({ optInOnly: true, mapPathToBank: { From 6ba361a0563849718392b5d6af286039d7590191 Mon Sep 17 00:00:00 2001 From: Altay Date: Thu, 13 Aug 2026 21:54:10 +0300 Subject: [PATCH 3/3] fix(coding-agents): retain opt-in after worktree removal --- .../coding-agents/src/core/bank.ts | 13 +++++++++++-- .../coding-agents/src/core/opt-in.test.ts | 11 ++++++++++- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/hindsight-integrations/coding-agents/src/core/bank.ts b/hindsight-integrations/coding-agents/src/core/bank.ts index 85dc46178..18add48c2 100644 --- a/hindsight-integrations/coding-agents/src/core/bank.ts +++ b/hindsight-integrations/coding-agents/src/core/bank.ts @@ -156,8 +156,17 @@ function lookupDirectories(config: BankConfig, directory: string): string[] { const directories = [normalize(directory)]; if (config.resolveWorktrees ?? true) { const existing = nearestExistingDir(directory); - const root = existing ? getProjectRootFromGit(existing) : null; - if (root && root !== directories[0]) directories.push(root); + const candidates = [ + existing, + ...(!existsSync(directory) ? PROJECT_ROOT_ENV.map((name) => process.env[name] || "") : []), + ]; + for (const candidate of candidates) { + const root = candidate ? getProjectRootFromGit(candidate) : null; + if (!root) continue; + const normalizedRoot = normalize(root); + if (normalizedRoot !== directories[0]) directories.push(normalizedRoot); + break; + } } return directories; } diff --git a/hindsight-integrations/coding-agents/src/core/opt-in.test.ts b/hindsight-integrations/coding-agents/src/core/opt-in.test.ts index 45cd4ae8d..e4ca95779 100644 --- a/hindsight-integrations/coding-agents/src/core/opt-in.test.ts +++ b/hindsight-integrations/coding-agents/src/core/opt-in.test.ts @@ -25,7 +25,10 @@ describe("optInOnly", () => { } }); - afterEach(() => rmSync(root, { recursive: true, force: true })); + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + delete process.env.CLAUDE_PROJECT_DIR; + }); it("allows everything when it is off — the zero-setup default is unchanged", () => { const cfg = resolveConfig({}); @@ -101,6 +104,12 @@ describe("optInOnly", () => { }, }); expect(deriveBankId(worktreeOverride, worktree, "codex")).toBe("client-x-experiment"); + + execFileSync("git", ["worktree", "remove", "--force", worktree], { cwd: approved }); + process.env.CLAUDE_PROJECT_DIR = approved; + expect(isOptedIn(byPath, worktree)).toBe(true); + expect(isOptedIn(byMap, worktree)).toBe(true); + expect(deriveBankId(byMap, worktree, "claude-code")).toBe("client-x"); }); it("keeps a linked worktree denied when its checkout is outside every approved path", () => {