Skip to content
Merged
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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,8 @@ still won't match an English tag).

## Configuration

Optional `.lore.json` in your project root:
Optional `.lore.json` in your project root (hooks and commands find it — or a
`learnings/` store — from any subdirectory, searching up to the repository root):

```json
{
Expand Down
56 changes: 50 additions & 6 deletions plugin/scripts/_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,27 @@


def find_project_dir(data=None):
"""The user's project root: hook stdin `cwd`, else $CLAUDE_PROJECT_DIR, else cwd."""
"""The user's project root, searched upward from where the agent is.

The search starts at the hook stdin `cwd`, else $CLAUDE_PROJECT_DIR, else
the process cwd. Hook `cwd` follows the agent's `cd` (and `claude` may be
launched from a monorepo package), so the store is not necessarily right
there: the nearest directory holding `.lore.json` or a default store wins.
The search never leaves the enclosing git repository -- a stray
`~/learnings` must not answer for an unrelated project -- and outside any
repository only the start itself is checked. Nothing found: the start.
"""
if data and data.get("cwd"):
return Path(data["cwd"])
env = os.environ.get("CLAUDE_PROJECT_DIR")
if env:
return Path(env)
return Path.cwd()
start = Path(data["cwd"])
else:
env = os.environ.get("CLAUDE_PROJECT_DIR")
start = Path(env) if env else Path.cwd()
chain = [start, *start.parents]
top = next((i for i, d in enumerate(chain) if (d / ".git").exists()), 0)
for d in chain[:top + 1]:
if (d / ".lore.json").is_file() or (d / DEFAULTS["storeDir"]).is_dir():
return d
return start


def _valid(key, value):
Expand Down Expand Up @@ -167,6 +181,36 @@ def rel_path(project, path):
return path.as_posix()


def git_dirs(project):
"""`(git_dir, common_dir)` of the checkout at `project`, or (None, None).

The recall log and the edit-time dedupe state live inside `.git`, where
they can never be committed. In a linked worktree (`git worktree add`, how
parallel agents usually run) and in a submodule, `.git` is a FILE --
`gitdir: <path>` -- naming the checkout's private dir, whose `commondir`
points at the repository's shared `.git`. Read from disk rather than via
`git rev-parse`: this runs in hooks, on every prompt and every edit.
"""
dot = Path(project) / ".git"
if dot.is_dir():
return dot, dot
try:
pointer = dot.read_text(encoding="utf-8").strip()
except OSError:
return None, None
if not pointer.startswith("gitdir:"):
return None, None
git_dir = Path(project) / pointer[len("gitdir:"):].strip()
if not git_dir.is_dir():
return None, None
try:
common = git_dir / (git_dir / "commondir").read_text(
encoding="utf-8").strip()
except OSError:
return git_dir, git_dir
return git_dir, (common if common.is_dir() else git_dir)


# --- text tokenizing (shared by recall and the dupe finder) ------------------
# Unicode-aware and diacritic-folding on purpose: `[a-z0-9_]+` split accented
# words ("autenticación" -> "autenticaci" + "n"), which quietly broke recall for
Expand Down
23 changes: 13 additions & 10 deletions plugin/scripts/recall.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,9 @@
from pathlib import Path

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _common import (find_project_dir, fold, is_under, # noqa: E402
iter_store_entries, load_config, norm_rel, ref_exists,
ref_matches, rel_path, store_dirs, words)
from _common import (find_project_dir, fold, git_dirs, # noqa: E402
is_under, iter_store_entries, load_config, norm_rel,
ref_exists, ref_matches, rel_path, store_dirs, words)

# Generic words that would over-match. Domain words are intentionally absent.
# Entries are diacritic-folded (see _common.fold), so accented fillers are
Expand Down Expand Up @@ -267,14 +267,15 @@ def log_recall(project, kind, paths):
actually opened the file) -- `/lore:stats` compares the two, because an
entry that surfaces constantly and is never read is noise, not knowledge.

Best-effort and silent: telemetry must never break the hook. Skipped when
`.git` is not a directory (bare repos, worktrees, no git).
Best-effort and silent: telemetry must never break the hook. Skipped
outside a git checkout. A linked worktree logs to the repository's shared
`.git`, so every worktree feeds the same `/lore:stats` numbers.
"""
try:
git_dir = Path(project) / ".git"
if not git_dir.is_dir():
_git_dir, common = git_dirs(project)
if common is None:
return
log = git_dir / "lore-recall.log"
log = common / "lore-recall.log"
today = datetime.date.today().isoformat()
lines = [f"{today}\t{kind}\t{rel_path(project, p)}\n" for p in paths]
if log.exists() and log.stat().st_size > _LOG_MAX_BYTES:
Expand All @@ -295,8 +296,10 @@ def log_recall(project, kind, paths):


def _seen_file(project):
git_dir = Path(project) / ".git"
return git_dir / "lore-recall-seen.json" if git_dir.is_dir() else None
# The checkout's own git dir (private per worktree), so parallel agents in
# separate worktrees never reset each other's dedupe state.
git_dir, _common = git_dirs(project)
return git_dir / "lore-recall-seen.json" if git_dir else None


def unseen_matches(project, session_id, matches):
Expand Down
16 changes: 9 additions & 7 deletions plugin/scripts/verify_refs.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,9 @@

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _common import (DEFAULTS, config_issues, find_project_dir, # noqa: E402
iter_entries, load_config, norm_rel, personal_store,
ref_exists, ref_matches, rel_path, stale_after_days,
words)
git_dirs, iter_entries, load_config, norm_rel,
personal_store, ref_exists, ref_matches, rel_path,
stale_after_days, words)

_DATE_LINE = re.compile(r"^\d{4}-\d{2}-\d{2}$")

Expand Down Expand Up @@ -387,11 +387,13 @@ def _recall_activity(entries, project):
The `read` events come from the PostToolUse hook, so an install that
predates it (or a target with no Read tool, e.g. Codex) has none -- and then
"never read" would be vacuously true for every entry, which is why `unread`
is None rather than a full list. The log is local, inside `.git`, never
committed. Absent log -> None (no git, or nothing has surfaced yet).
is None rather than a full list. The log is local, inside `.git` (the
shared one, from a linked worktree), never committed. Absent log -> None
(no git, or nothing has surfaced yet).
"""
log = project / ".git" / "lore-recall.log"
if not log.is_file():
_git_dir, common = git_dirs(project)
log = common / "lore-recall.log" if common else None
if log is None or not log.is_file():
return None
surfaced, reads = {}, {}
try:
Expand Down
101 changes: 96 additions & 5 deletions tests/test_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@
import tempfile
import unittest
from pathlib import Path
from unittest import mock

import _util # noqa: F401 (puts plugin/scripts on sys.path)
from _common import (DEFAULTS, _FM_HEAD_CHARS, config_issues, fold,
is_under, iter_entries, iter_store_entries, load_config,
norm_rel, parse_frontmatter, personal_store,
read_frontmatter, ref_exists, ref_matches, rel_path,
stale_after_days, store_dirs, words)
from _common import (DEFAULTS, _FM_HEAD_CHARS, config_issues,
find_project_dir, fold, git_dirs, is_under, iter_entries,
iter_store_entries, load_config, norm_rel,
parse_frontmatter, personal_store, read_frontmatter,
ref_exists, ref_matches, rel_path, stale_after_days,
store_dirs, words)
from _util import write_entry


Expand Down Expand Up @@ -115,6 +117,95 @@ def test_clean_config_and_missing_file(self):
self.assertEqual(config_issues(td), ([], []))


class TestFindProjectDir(unittest.TestCase):
"""Hook `cwd` follows the agent's `cd`, so the store must be searched for."""

def setUp(self):
td = tempfile.TemporaryDirectory()
self.addCleanup(td.cleanup)
self.root = Path(td.name) / "repo"
self.deep = self.root / "src" / "deep"
self.deep.mkdir(parents=True)

def test_store_found_from_a_subdirectory(self):
(self.root / ".git").mkdir()
(self.root / "learnings").mkdir()
self.assertEqual(find_project_dir({"cwd": str(self.deep)}), self.root)

def test_lore_json_marks_the_root(self):
# A custom storeDir is only known from .lore.json, so it is a marker too.
(self.root / ".git").mkdir()
(self.root / ".lore.json").write_text('{"storeDir": "kb"}',
encoding="utf-8")
self.assertEqual(find_project_dir({"cwd": str(self.deep)}), self.root)

def test_search_stops_at_the_repository_root(self):
# A store ABOVE the repo belongs to something else (e.g. ~/learnings).
(self.root / ".git").mkdir()
(self.root.parent / "learnings").mkdir()
self.assertEqual(find_project_dir({"cwd": str(self.deep)}), self.deep)

def test_outside_a_repository_only_the_start_is_checked(self):
(self.root / "learnings").mkdir()
self.assertEqual(find_project_dir({"cwd": str(self.deep)}), self.deep)
self.assertEqual(find_project_dir({"cwd": str(self.root)}), self.root)

def test_env_start_is_searched_too(self):
(self.root / ".git").mkdir()
(self.root / "learnings").mkdir()
with mock.patch.dict(os.environ,
{"CLAUDE_PROJECT_DIR": str(self.deep)}):
self.assertEqual(find_project_dir(), self.root)


class TestGitDirs(unittest.TestCase):
"""Where the local-only state lives: `.git` may be a directory or a file."""

def setUp(self):
td = tempfile.TemporaryDirectory()
self.addCleanup(td.cleanup)
self.base = Path(td.name)

def test_plain_checkout(self):
(self.base / ".git").mkdir()
self.assertEqual(git_dirs(self.base), (self.base / ".git",) * 2)

def test_no_repository(self):
self.assertEqual(git_dirs(self.base), (None, None))

def test_linked_worktree(self):
# `git worktree add`: `.git` is a file naming a private dir, whose
# `commondir` points back at the repository's shared `.git`.
private = self.base / "main" / ".git" / "worktrees" / "wt"
private.mkdir(parents=True)
(private / "commondir").write_text("../..\n", encoding="utf-8")
wt = self.base / "wt"
wt.mkdir()
(wt / ".git").write_text(f"gitdir: {private}\n", encoding="utf-8")
git_dir, common = git_dirs(wt)
self.assertEqual(git_dir, private)
self.assertEqual(common.resolve(),
(self.base / "main" / ".git").resolve())

def test_relative_gitdir_without_commondir(self):
# A submodule: a relative `gitdir:` and no `commondir`.
private = self.base / ".git" / "modules" / "sub"
private.mkdir(parents=True)
sub = self.base / "sub"
sub.mkdir()
(sub / ".git").write_text("gitdir: ../.git/modules/sub\n",
encoding="utf-8")
git_dir, common = git_dirs(sub)
self.assertEqual(git_dir.resolve(), private.resolve())
self.assertEqual(common, git_dir)

def test_bad_pointer_is_no_repository(self):
(self.base / ".git").write_text("garbage", encoding="utf-8")
self.assertEqual(git_dirs(self.base), (None, None))
(self.base / ".git").write_text("gitdir: missing", encoding="utf-8")
self.assertEqual(git_dirs(self.base), (None, None))


class TestTokenizing(unittest.TestCase):
def test_accented_words_stay_whole_and_fold(self):
self.assertEqual(words("autenticación"), ["autenticacion"])
Expand Down
55 changes: 55 additions & 0 deletions tests/test_recall.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import io
import json
import shutil
import subprocess
import sys
import tempfile
import unittest
Expand All @@ -15,6 +16,7 @@


CFG = dict(DEFAULTS)
GIT = shutil.which("git")


class StoreCase(unittest.TestCase):
Expand Down Expand Up @@ -445,6 +447,18 @@ def test_silent_without_match_or_prompt(self):
self.assertEqual(self.run_hook(
{"cwd": str(self.project), "prompt": " "}), "")

def test_store_found_after_cd_into_a_subdirectory(self):
# Hook `cwd` follows the agent's `cd`; recall used to look for the
# store only there and went silent for the rest of the session.
write_entry(self.store, "ci/cache.md", title="Cache key includes lockfile",
tags=("cache", "lockfile"))
(self.project / ".git").mkdir()
deep = self.project / "src" / "deep"
deep.mkdir(parents=True)
ctx = self.context_of(self.run_hook(
{"cwd": str(deep), "prompt": "why is the cache stale?"}))
self.assertIn("learnings/ci/cache.md - Cache key includes lockfile", ctx)

def test_silent_without_store(self):
proj = self.project / "empty"
proj.mkdir()
Expand Down Expand Up @@ -510,5 +524,46 @@ def test_corrupt_dedupe_state_fails_open(self):
self.assertIn("retry loop", self._pretool())


@unittest.skipUnless(GIT, "git not available")
class TestLinkedWorktree(StoreCase):
"""`git worktree add` checkouts -- how parallel agents usually run.

`.git` is a FILE there, and dedupe + telemetry used to require a directory:
the same learning was re-injected on every edit and nothing was logged.
"""

def setUp(self):
super().setUp()
write_entry(self.store, "a/e.md", title="Watch the retry loop",
files=("src/",))
for args in (("init", "-q"), ("add", "."), ("commit", "-q", "-m", "c1")):
self._git(*args)
td = tempfile.TemporaryDirectory()
self.addCleanup(td.cleanup)
self.wt = Path(td.name) / "wt"
self._git("worktree", "add", "-q", str(self.wt))

def _git(self, *args):
subprocess.run([GIT, "-C", str(self.project), "-c", "user.email=t@t",
"-c", "user.name=t", *args],
check=True, capture_output=True)

def _edit(self):
return self.context_of(self.run_hook(
{"cwd": str(self.wt), "session_id": "s1", "tool_name": "Edit",
"tool_input": {"file_path": str(self.wt / "src" / "a.py")}},
["--pretool"]))

def test_edit_time_dedupe(self):
self.assertIn("retry loop", self._edit())
self.assertEqual(self._edit(), "")

def test_recall_log_lands_in_the_shared_git_dir(self):
self._edit()
log = self.project / ".git" / "lore-recall.log"
self.assertTrue(log.is_file(), "nothing was logged from the worktree")
self.assertIn("\tedit\tlearnings/a/e.md", log.read_text(encoding="utf-8"))


if __name__ == "__main__":
unittest.main()
20 changes: 20 additions & 0 deletions tests/test_verify_refs.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,26 @@ def test_none_without_log(self):
with tempfile.TemporaryDirectory() as td:
self.assertIsNone(verify_refs._recall_activity([], Path(td)))

def test_linked_worktree_reads_the_shared_log(self):
# A worktree's `.git` is a file; the hooks log to the shared `.git`.
td = tempfile.TemporaryDirectory()
self.addCleanup(td.cleanup)
shared = Path(td.name) / "main" / ".git"
private = shared / "worktrees" / "wt"
private.mkdir(parents=True)
(private / "commondir").write_text("../..", encoding="utf-8")
(shared / "lore-recall.log").write_text(
"2026-07-17\tprompt\tlearnings/x/a.md\n", encoding="utf-8")
wt = Path(td.name) / "wt"
write_entry(wt / "learnings", "x/a.md", title="A")
(wt / ".git").write_text(f"gitdir: {private}", encoding="utf-8")
activity = verify_refs._recall_activity(
list(iter_entries(wt / "learnings")), wt)
self.assertIsNotNone(activity, "the worktree found no recall log")
top, never, _unread = activity
self.assertEqual(top, [(1, 0, "learnings/x/a.md")])
self.assertEqual(never, 0)


class TestPersonalStoreStats(unittest.TestCase):
def test_personal_entries_counted_and_attributed(self):
Expand Down
Loading