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
4 changes: 2 additions & 2 deletions hindsight-docs/docs-integrations/claude-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ A **bank** is an isolated memory store — like a separate "brain." These settin
| `bankIdPrefix` | — | `""` | A string prepended to all bank IDs — both static and dynamic. Useful for namespacing (e.g. `"prod"` or `"staging"`). |
| `agentName` | `HINDSIGHT_AGENT_NAME` | `"claude-code"` | Name used for the `agent` field in dynamic bank ID derivation. |
| `resolveWorktrees` | — | `true` | When deriving the `project` field, resolve git worktrees to the **main repository's basename** so that all worktrees of the same repo share one bank. Set to `false` to use the literal working directory basename instead (each worktree gets its own bank). |
| `directoryBankMap` | — | `{}` | Explicit `{ "/path/to/dir": "bank-id" }` mapping. When the current working directory matches an entry, that bank is used directly overrides both static and dynamic resolution. `bankIdPrefix` still applies on top. |
| `directoryBankMap` | — | `{}` | Explicit absolute-path `{ "/path/to/dir": "bank-id" }` mapping. When the current working directory is an entry or its descendant, that bank is used directly; the nearest configured ancestor wins. This overrides both static and dynamic resolution. `bankIdPrefix` still applies on top. |

#### Worktrees and explicit mapping

Expand All @@ -171,7 +171,7 @@ For full control, use `directoryBankMap` to pin specific directories to specific
}
```

When `cwd` matches one of the keys, that bank is used immediately — no static or dynamic resolution runs. Directories not listed fall through to the normal logic.
When `cwd` is a mapped directory or any directory beneath it, that bank is used immediately — no static or dynamic resolution runs. If mappings are nested, the nearest configured ancestor wins. For example, `/home/me/work/client-a/src` uses `client-a-memories` in the configuration above. Use absolute mapping keys: relative keys retain exact matching but do not apply to descendants. A symlink key applies to descendants reached through that symlink without expanding its lexical boundary into unrelated directories. If the nearest applicable root has conflicting bank IDs, routing falls through rather than depending on map order; a deeper unambiguous mapping can still win. Directories outside every mapped tree fall through to the normal logic.

---

Expand Down
93 changes: 90 additions & 3 deletions hindsight-integrations/claude-code/scripts/lib/bank.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import os
import subprocess
import sys
from dataclasses import dataclass

from .state import read_state, write_state

Expand All @@ -29,6 +30,16 @@
VALID_FIELDS = {"agent", "project", "session", "channel", "user"}


@dataclass(frozen=True)
class _DirectoryBankMapping:
"""Immutable normalized snapshot of one directoryBankMap entry."""

canonical_root: str
lexical_root: str
bank_id: str
is_absolute: bool


def _resolve_project_name(cwd: str, config: dict) -> str:
"""Resolve the project name from the working directory.

Expand Down Expand Up @@ -96,9 +107,85 @@ def derive_bank_id(hook_input: dict, config: dict) -> str:
# to the default bank. normcase is a no-op on POSIX, preserving
# case-sensitive matching there.
normalized_cwd = os.path.normcase(os.path.realpath(cwd))
for dir_path, bank_id in dir_map.items():
if os.path.normcase(os.path.realpath(dir_path)) == normalized_cwd:
return f"{prefix}-{bank_id}" if prefix else bank_id
lexical_cwd = os.path.normcase(os.path.abspath(cwd))

mapping_candidates = [
_DirectoryBankMapping(
canonical_root=os.path.normcase(os.path.realpath(dir_path)),
lexical_root=os.path.normcase(os.path.abspath(dir_path)),
bank_id=bank_id,
is_absolute=os.path.isabs(dir_path),
)
for dir_path, bank_id in dir_map.items()
]
canonical_bank_by_root = {}
conflicting_roots = set()
for candidate in mapping_candidates:
if (
candidate.canonical_root in canonical_bank_by_root
and canonical_bank_by_root[candidate.canonical_root] != candidate.bank_id
):
conflicting_roots.add(candidate.canonical_root)
else:
canonical_bank_by_root[candidate.canonical_root] = candidate.bank_id

matched_bank_id = None
matched_path_length = -1
matched_root_conflicts = False
for candidate in mapping_candidates:
normalized_dir = candidate.canonical_root
lexical_dir = candidate.lexical_root
is_match = normalized_dir == normalized_cwd

if not is_match and candidate.is_absolute:
# A symlinked mapping key may resolve to a much broader tree
# than its configured spelling. Require canonical containment
# for every descendant, plus lexical containment for such keys,
# so nested symlinks cannot escape either boundary. Canonical
# equality above preserves the prior exact-match behavior.
try:
canonical_relative = os.path.relpath(normalized_cwd, normalized_dir)
except ValueError:
# relpath rejects paths on different Windows drives or
# UNC shares. Such a mapping cannot be an ancestor of cwd.
continue
canonical_contains = (
not os.path.isabs(canonical_relative)
and canonical_relative != os.pardir
and not canonical_relative.startswith(os.pardir + os.sep)
)
if lexical_dir != normalized_dir:
try:
lexical_relative = os.path.relpath(lexical_cwd, lexical_dir)
except ValueError:
continue
lexical_contains = (
not os.path.isabs(lexical_relative)
and lexical_relative != os.pardir
and not lexical_relative.startswith(os.pardir + os.sep)
)
is_match = canonical_contains and lexical_contains
else:
is_match = canonical_contains

if is_match and len(normalized_dir) > matched_path_length:
# A nested configured root is longer than its ancestors, so
# ranking every match in the canonical domain makes the nearest
# ancestor win regardless of symlink spelling length.
matched_bank_id = candidate.bank_id
matched_path_length = len(normalized_dir)
matched_root_conflicts = normalized_dir in conflicting_roots

if matched_root_conflicts:
# A conflicted nearest root must not silently fall back to a broader
# mapped bank. Only a strictly deeper unambiguous root can win.
print(
"[Hindsight] Conflicting directoryBankMap entries resolve to the same directory; "
"ignoring the ambiguous mapping",
file=sys.stderr,
)
elif matched_path_length >= 0:
return f"{prefix}-{matched_bank_id}" if prefix else matched_bank_id

if not config.get("dynamicBankId", False):
# Static mode — single bank for everything
Expand Down
202 changes: 197 additions & 5 deletions hindsight-integrations/claude-code/tests/test_bank.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
"""Tests for lib/bank.py — bank ID derivation and mission management."""

import json
import ntpath
import subprocess
from unittest.mock import MagicMock, patch

import pytest

from lib.bank import _resolve_project_name, derive_bank_id, ensure_bank_mission


Expand Down Expand Up @@ -177,6 +177,195 @@ def test_match_with_trailing_slash(self):
result = derive_bank_id(_hook(cwd="/home/user/myproject"), cfg)
assert result == "custom-bank"

def test_descendant_matches_configured_project_root(self):
cfg = _cfg(directoryBankMap={"/home/user/myproject": "custom-bank"})
result = derive_bank_id(_hook(cwd="/home/user/myproject/src/package"), cfg)
assert result == "custom-bank"

def test_nearest_configured_ancestor_wins(self):
cfg = _cfg(
directoryBankMap={
"/home/user/myproject": "project-bank",
"/home/user/myproject/packages/api": "api-bank",
}
)
result = derive_bank_id(_hook(cwd="/home/user/myproject/packages/api/src"), cfg)
assert result == "api-bank"

def test_path_prefix_without_ancestor_boundary_does_not_match(self):
cfg = _cfg(
directoryBankMap={"/home/user/project": "project-bank"},
bankId="fallback",
)
result = derive_bank_id(_hook(cwd="/home/user/project-other/src"), cfg)
assert result == "fallback"

@pytest.mark.skipif(not hasattr(__import__("os"), "symlink"), reason="symlinks not supported")
def test_symlinked_descendant_matches_real_project_root(self, tmp_path):
import os

real_project = tmp_path / "project"
nested_dir = real_project / "src"
nested_dir.mkdir(parents=True)
project_link = tmp_path / "project-link"
os.symlink(real_project, project_link)

cfg = _cfg(directoryBankMap={str(real_project): "project-bank"})
result = derive_bank_id(_hook(cwd=str(project_link / "src")), cfg)
assert result == "project-bank"

@pytest.mark.skipif(not hasattr(__import__("os"), "symlink"), reason="symlinks not supported")
def test_symlinked_mapping_does_not_capture_canonical_sibling(self, tmp_path):
import os

workspace = tmp_path / "workspace"
project_a = workspace / "project-a"
project_b = workspace / "project-b" / "src"
project_a.mkdir(parents=True)
project_b.mkdir(parents=True)
workspace_link = project_a / "workspace-link"
os.symlink(workspace, workspace_link)

cfg = _cfg(directoryBankMap={str(workspace_link): "project-a-bank"}, bankId="fallback")
assert derive_bank_id(_hook(cwd=str(workspace_link / "project-b" / "src")), cfg) == "project-a-bank"
result = derive_bank_id(_hook(cwd=str(project_b)), cfg)
assert result == "fallback"

@pytest.mark.skipif(not hasattr(__import__("os"), "symlink"), reason="symlinks not supported")
def test_nested_symlink_cannot_escape_mapped_tree(self, tmp_path):
import os

project = tmp_path / "project"
outside = tmp_path / "outside" / "src"
project.mkdir()
outside.mkdir(parents=True)
escape_link = project / "escape"
os.symlink(outside.parent, escape_link)

cfg = _cfg(directoryBankMap={str(project): "project-bank"}, bankId="fallback")
assert derive_bank_id(_hook(cwd=str(escape_link / "src")), cfg) == "fallback"

def test_parent_segments_cannot_escape_mapped_tree(self, tmp_path):
project = tmp_path / "project"
sibling = tmp_path / "sibling" / "src"
project.mkdir()
sibling.mkdir(parents=True)

cfg = _cfg(directoryBankMap={str(project): "project-bank"}, bankId="fallback")
escaped_cwd = str(project / ".." / "sibling" / "src")
assert derive_bank_id(_hook(cwd=escaped_cwd), cfg) == "fallback"

@pytest.mark.skipif(not hasattr(__import__("os"), "symlink"), reason="symlinks not supported")
def test_nearest_ancestor_ignores_symlink_spelling_length_and_map_order(self, tmp_path):
import os

canonical_root = tmp_path / "r"
canonical_project = canonical_root / "project"
(canonical_project / "src").mkdir(parents=True)
long_alias = tmp_path / "a-very-long-workspace-alias-that-must-not-win"
os.symlink(canonical_root, long_alias)

entries = [
(str(long_alias), "root-bank"),
(str(canonical_project), "project-bank"),
]
cwd = str(long_alias / "project" / "src")
for ordered_entries in (entries, list(reversed(entries))):
cfg = _cfg(directoryBankMap=dict(ordered_entries))
assert derive_bank_id(_hook(cwd=cwd), cfg) == "project-bank"

def test_relative_mapping_keeps_exact_match_but_not_descendants(self, tmp_path, monkeypatch):
project = tmp_path / "project"
nested_dir = project / "src"
nested_dir.mkdir(parents=True)
monkeypatch.chdir(project)

cfg = _cfg(directoryBankMap={".": "project-bank"}, bankId="fallback")
assert derive_bank_id(_hook(cwd=str(project)), cfg) == "project-bank"
assert derive_bank_id(_hook(cwd=str(nested_dir)), cfg) == "fallback"

@pytest.mark.skipif(not hasattr(__import__("os"), "symlink"), reason="symlinks not supported")
def test_conflicting_canonical_roots_fall_through_regardless_of_order(self, tmp_path, capsys):
import os

project = tmp_path / "project"
project.mkdir()
project_link = tmp_path / "project-link"
os.symlink(project, project_link)

entries = [(str(project), "bank-a"), (str(project_link), "bank-b")]
for ordered_entries in (entries, list(reversed(entries))):
cfg = _cfg(directoryBankMap=dict(ordered_entries), bankId="fallback")
assert derive_bank_id(_hook(cwd=str(project)), cfg) == "fallback"

assert capsys.readouterr().err.count("Conflicting directoryBankMap entries") == 2

@pytest.mark.skipif(not hasattr(__import__("os"), "symlink"), reason="symlinks not supported")
def test_conflicted_nearest_root_blocks_parent_but_not_deeper_mapping(self, tmp_path, capsys):
import os

projects = tmp_path / "projects"
project = projects / "a"
nested = project / "nested"
child = nested / "src"
child.mkdir(parents=True)
project_link = tmp_path / "project-a-link"
os.symlink(project, project_link)

mappings = {
str(projects): "parent-bank",
str(project): "bank-a",
str(project_link): "bank-b",
}
cfg = _cfg(directoryBankMap=mappings, bankId="fallback")
assert derive_bank_id(_hook(cwd=str(project / "src")), cfg) == "fallback"

mappings[str(nested)] = "nested-bank"
cfg = _cfg(directoryBankMap=mappings, bankId="fallback")
assert derive_bank_id(_hook(cwd=str(child)), cfg) == "nested-bank"
assert capsys.readouterr().err.count("Conflicting directoryBankMap entries") == 1

def test_mapping_realpath_is_snapshotted_once_regardless_of_order(self):
entries = [("/alias", "alias-bank"), ("/project", "project-bank")]
for ordered_entries in (entries, list(reversed(entries))):
alias_calls = 0

def changing_realpath(path):
nonlocal alias_calls
if path == "/alias":
alias_calls += 1
return "/elsewhere" if alias_calls == 1 else "/project"
return path

with patch("lib.bank.os.path.realpath", side_effect=changing_realpath):
cfg = _cfg(directoryBankMap=dict(ordered_entries), bankId="fallback")
result = derive_bank_id(_hook(cwd="/project/src"), cfg)
assert result == "project-bank"
assert alias_calls == 1

@pytest.mark.parametrize(
("cwd", "dir_path", "expected"),
[
(r"C:\Project\src", r"c:\project", "windows-bank"),
(r"C:\project-other\src", r"C:\project", "fallback"),
(r"C:\project\src", r"D:\project", "fallback"),
(r"\\server\share\project\src", r"\\server\share", "windows-bank"),
(r"\\server\share\project\src", r"\\SERVER\SHARE\project", "windows-bank"),
(r"\\server\other\project\src", r"\\server\share\project", "fallback"),
],
)
def test_windows_drive_case_and_unc_semantics_with_ntpath(self, cwd, dir_path, expected):
with (
patch("lib.bank.os.path.abspath", side_effect=ntpath.abspath),
patch("lib.bank.os.path.isabs", side_effect=ntpath.isabs),
patch("lib.bank.os.path.normcase", side_effect=ntpath.normcase),
patch("lib.bank.os.path.realpath", side_effect=ntpath.realpath),
patch("lib.bank.os.path.relpath", side_effect=ntpath.relpath),
patch("lib.bank.os.sep", ntpath.sep),
):
cfg = _cfg(directoryBankMap={dir_path: "windows-bank"}, bankId="fallback")
assert derive_bank_id(_hook(cwd=cwd), cfg) == expected

def test_windows_drive_letter_case_insensitive_match(self):
# On Windows, the cwd's drive-letter (and path) case depends on the
# launcher: PowerShell and git-bash hand children an UPPERCASE drive
Expand Down Expand Up @@ -245,10 +434,12 @@ def test_empty_cwd_skips_map(self):
assert result == "fallback"

def test_multiple_entries(self):
cfg = _cfg(directoryBankMap={
"/home/user/project-a": "bank-a",
"/home/user/project-b": "bank-b",
})
cfg = _cfg(
directoryBankMap={
"/home/user/project-a": "bank-a",
"/home/user/project-b": "bank-b",
}
)
assert derive_bank_id(_hook(cwd="/home/user/project-a"), cfg) == "bank-a"
assert derive_bank_id(_hook(cwd="/home/user/project-b"), cfg) == "bank-b"

Expand Down Expand Up @@ -300,6 +491,7 @@ def test_different_banks_each_set_once(self, state_dir):
@pytest.mark.skipif(not hasattr(__import__("os"), "symlink"), reason="symlinks not supported")
def test_directorybankmap_matches_symlinked_cwd(self, tmp_path):
import os

real = os.path.realpath(tmp_path / "proj")
os.makedirs(real)
link = str(tmp_path / "proj-link")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ A **bank** is an isolated memory store — like a separate "brain." These settin
| `bankIdPrefix` | — | `""` | A string prepended to all bank IDs — both static and dynamic. Useful for namespacing (e.g. `"prod"` or `"staging"`). |
| `agentName` | `HINDSIGHT_AGENT_NAME` | `"claude-code"` | Name used for the `agent` field in dynamic bank ID derivation. |
| `resolveWorktrees` | — | `true` | When deriving the `project` field, resolve git worktrees to the **main repository's basename** so that all worktrees of the same repo share one bank. Set to `false` to use the literal working directory basename instead (each worktree gets its own bank). |
| `directoryBankMap` | — | `{}` | Explicit `{ "/path/to/dir": "bank-id" }` mapping. When the current working directory matches an entry, that bank is used directly overrides both static and dynamic resolution. `bankIdPrefix` still applies on top. |
| `directoryBankMap` | — | `{}` | Explicit absolute-path `{ "/path/to/dir": "bank-id" }` mapping. When the current working directory is an entry or its descendant, that bank is used directly; the nearest configured ancestor wins. This overrides both static and dynamic resolution. `bankIdPrefix` still applies on top. |

#### Worktrees and explicit mapping

Expand All @@ -165,7 +165,7 @@ For full control, use `directoryBankMap` to pin specific directories to specific
}
```

When `cwd` matches one of the keys, that bank is used immediately — no static or dynamic resolution runs. Directories not listed fall through to the normal logic.
When `cwd` is a mapped directory or any directory beneath it, that bank is used immediately — no static or dynamic resolution runs. If mappings are nested, the nearest configured ancestor wins. For example, `/home/me/work/client-a/src` uses `client-a-memories` in the configuration above. Use absolute mapping keys: relative keys retain exact matching but do not apply to descendants. A symlink key applies to descendants reached through that symlink without expanding its lexical boundary into unrelated directories. If the nearest applicable root has conflicting bank IDs, routing falls through rather than depending on map order; a deeper unambiguous mapping can still win. Directories outside every mapped tree fall through to the normal logic.

---

Expand Down