Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions hindsight-docs/docs-integrations/coding-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) |
Expand Down Expand Up @@ -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
Expand Down
10 changes: 5 additions & 5 deletions hindsight-integrations/coding-agents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) |
Expand Down Expand Up @@ -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
Expand Down
44 changes: 39 additions & 5 deletions hindsight-integrations/coding-agents/src/core/bank.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -149,6 +149,28 @@ function mapLookup(map: Record<string, string>, 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 existing = nearestExistingDir(directory);
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;
}

/**
* Whether memory may run for this directory at all.
*
Expand All @@ -168,16 +190,28 @@ function mapLookup(map: Record<string, string>, 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".
Expand Down
117 changes: 115 additions & 2 deletions hindsight-integrations/coding-agents/src/core/opt-in.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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({});
Expand Down Expand Up @@ -54,6 +57,116 @@ 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");

rmSync(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");

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", () => {
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.
Expand Down
Loading