Coverage for astrocyte/harness/project.py: 94%

34 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-10-29 22:10 +0000

1"""One memory bank per project, derived identically everywhere. 

2 

3The hooks (which receive the session's ``cwd``) and the MCP server (launched 

4with the session's cwd as its own — measured on Claude Code 2.1) must land in 

5the *same* bank, or automatic capture and the agent's own ``memory_retain`` 

6calls never meet. Both call :func:`project_bank` on that directory. 

7 

8Identity prefers the git ``origin`` URL, so every clone and worktree of a repo 

9shares one memory; without a remote it falls back to the repo root path. A 

10directory outside any repo is its own project. 

11""" 

12 

13from __future__ import annotations 

14 

15import hashlib 

16import os 

17import re 

18import subprocess 

19from pathlib import Path 

20 

21_SLUG = re.compile(r"[^a-z0-9._-]+") 

22 

23 

24def _git(cwd: str, *args: str) -> str | None: 

25 try: 

26 out = subprocess.run( 

27 ["git", "-C", cwd, *args], capture_output=True, text=True, timeout=2, check=False 

28 ) 

29 except (OSError, subprocess.SubprocessError): 

30 return None 

31 return out.stdout.strip() or None if out.returncode == 0 else None 

32 

33 

34def _normalise_remote(url: str) -> str: 

35 """``git@github.com:Org/Repo.git`` and ``https://github.com/org/repo`` → ``github.com/org/repo``.""" 

36 u = url.strip().lower() 

37 u = re.sub(r"^[a-z+]+://", "", u) 

38 u = re.sub(r"^[^@/]+@", "", u) # user@ in ssh / https-with-credentials 

39 u = u.replace(":", "/", 1) if "/" not in u.split(":", 1)[0] else u 

40 return u.rstrip("/").removesuffix(".git").rstrip("/") 

41 

42 

43def project_root(cwd: str) -> Path: 

44 root = _git(cwd, "rev-parse", "--show-toplevel") 

45 return Path(root) if root else Path(cwd).resolve() 

46 

47 

48def project_bank(cwd: str | None = None) -> str: 

49 cwd = cwd or os.getcwd() 

50 root = project_root(cwd) 

51 remote = _git(str(root), "config", "--get", "remote.origin.url") 

52 if remote: 

53 identity = _normalise_remote(remote) 

54 name = identity.rsplit("/", 1)[-1] 

55 else: 

56 identity = str(root) 

57 name = root.name 

58 slug = _SLUG.sub("-", name.lower()).strip("-.")[:40] or "project" 

59 digest = hashlib.sha1(identity.encode("utf-8")).hexdigest()[:8] 

60 return f"project:{slug}-{digest}"