From 12b32e93127839444d48c65c487f3547126092bc Mon Sep 17 00:00:00 2001 From: gethin Date: Mon, 31 Aug 2026 15:58:35 +0100 Subject: [PATCH] feat(setup): enumerate managed skills from repo checkout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the hardcoded MANAGED_SKILLS tuple with manifest.managed_skills(repo_root), which scans .claude/skills/*/SKILL.md. A skill added to the repo is linked into ~/.claude/skills on the next session's autocheck with no code change; a skill removed from the repo has its user-level link pruned (foreign entries are never touched — only links targeting the repo skills tree). - diff() reports stale repo-targeted links as drift, so autocheck's if-drift guard triggers the pruning apply() - autocheck._mtimes() now stats skills_dir: a manually deleted link busts the fingerprint cache and is relinked next session - enumeration is sorted, so fingerprint() stays deterministic and a repo skill add/remove invalidates the cache for free Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QMxQKgsP9Dw9b5kN53MJfo --- README.md | 6 +- better_memory/setup/autocheck.py | 6 +- better_memory/setup/engine.py | 62 +++++++++++--- better_memory/setup/manifest.py | 29 +++++-- tests/setup/test_autocheck.py | 15 ++++ tests/setup/test_engine_apply.py | 135 ++++++++++++++++++++++++++++--- tests/setup/test_manifest.py | 36 +++++++-- website/architecture.md | 4 +- 8 files changed, 252 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 858a003..cf8d508 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ cd better-memory .\scripts\setup.ps1 ``` -Each script: installs `uv` if it isn't already on PATH (macOS/Linux: the official installer via `curl | sh`; Windows: `winget install --id=astral-sh.uv`, falling back to the `irm https://astral.sh/uv/install.ps1 | iex` installer if winget isn't available), runs `uv sync`, then runs `uv run better-memory setup` — which creates `~/.better-memory/{spool,state,install-backups,knowledge-base/{standards,languages,projects}}`, and installs/repairs the MCP server registration (`~/.claude.json`), all eight managed hooks plus the `BETTER_MEMORY_INJECT_MODE` env var (`~/.claude/settings.json`), the managed CLAUDE.md block, and the three skill symlinks (falling back to Windows directory junctions if symlink privilege is unavailable) — idempotently; anything overwritten is backed up first to `~/.better-memory/install-backups/`. +Each script: installs `uv` if it isn't already on PATH (macOS/Linux: the official installer via `curl | sh`; Windows: `winget install --id=astral-sh.uv`, falling back to the `irm https://astral.sh/uv/install.ps1 | iex` installer if winget isn't available), runs `uv sync`, then runs `uv run better-memory setup` — which creates `~/.better-memory/{spool,state,install-backups,knowledge-base/{standards,languages,projects}}`, and installs/repairs the MCP server registration (`~/.claude.json`), all eight managed hooks plus the `BETTER_MEMORY_INJECT_MODE` env var (`~/.claude/settings.json`), the managed CLAUDE.md block, and a skill symlink for every skill in the repo's `.claude/skills/` (falling back to Windows directory junctions if symlink privilege is unavailable) — idempotently; anything overwritten is backed up first to `~/.better-memory/install-backups/`. **Scripted / CI** — the same steps, called directly (skips the bootstrap script's `uv`-install step, e.g. when `uv` is already provisioned by the image): @@ -88,7 +88,7 @@ That single command: - Registers the MCP server in `~/.claude.json` (creating the file if needed). - Installs eight managed hooks plus the `BETTER_MEMORY_INJECT_MODE` env var into `~/.claude/settings.json`: `session_bootstrap` (SessionStart) opens/reuses the session's background episode and injects curated context — project-scoped and general-scope semantic memories plus distilled reflections (`do` / `dont` / `neutral` buckets) — as `additionalContext`, with only the top `BETTER_MEMORY_BOOTSTRAP_TOP_N` project-scoped items (default 5) rendered in full; `observer` (PostToolUse, `Write|Edit|Bash`) captures tool-call snapshots into the spool; `session_close` + `stop_sweep` (Stop) drive the end-of-session rating sweep and a reminder to record non-obvious observations; `contextual_inject` (UserPromptSubmit every prompt, and PreToolUse unscoped but latched to one firing per session) surfaces memories relevant to the current prompt/tool-input — see [Configuration](website/configuration.md) and [Architecture](website/architecture.md#injection-strategies) for how it scores, floors, and dedups candidates; `commit_checkpoint` (PreToolUse, gated to `Bash(git commit*)`) reminds Claude to record an observation before committing; `pre_compact` (PreCompact) reminds Claude to persist state before context compaction. - Splices the managed CLAUDE.md block (the retrieve/reinforce/synthesize/record protocol) into `~/.claude/CLAUDE.md` between `` / `` markers, leaving the rest of your CLAUDE.md untouched. -- Symlinks (or, without Windows symlink privilege, directory-junctions) the three Claude Code skills — see [Claude Code skills](#claude-code-skills) below. +- Symlinks (or, without Windows symlink privilege, directory-junctions) every Claude Code skill found in the repo's `.claude/skills/`, and prunes links whose repo skill was removed — see [Claude Code skills](#claude-code-skills) below. For the exact JSON shapes it writes — useful if you're hand-editing `~/.claude/settings.json` or `~/.claude.json` — see [docs/hooks-setup.md](docs/hooks-setup.md), which documents each hook's event, matcher, and async/sync-ness as reference material only; `better-memory setup` / `doctor --fix` are the source of truth, not the doc. @@ -239,7 +239,7 @@ Plus `CLAUDE.snippet.md` — paste into your project's `CLAUDE.md` to teach the ### Claude Code skills -Three skills live in `.claude/skills/` and are auto-symlinked into `~/.claude/skills/` by `better-memory setup` (run directly, or via either bootstrap script) so Claude triggers them in any repo — falling back to a Windows directory junction automatically when symlink privilege isn't available: +The skills in `.claude/skills/` (every directory with a `SKILL.md`) are enumerated and auto-symlinked into `~/.claude/skills/` by `better-memory setup` (run directly, or via either bootstrap script) so Claude triggers them in any repo — falling back to a Windows directory junction automatically when symlink privilege isn't available. A skill added to the repo is picked up on the next session's autocheck with no code change; a skill removed from the repo has its user-level link pruned. Currently: - **`better-memory-synthesize`** — walks Claude through the per-episode reflection synthesis loop (`memory.synthesize_next_get_context` → decide → `memory.synthesize_next_apply`). Fires when `memory.start_episode` reports `pending_synthesis.pending > 0` or when the user asks to consolidate. - **`rate-session-memories`** — classifies exposed reflections / semantic memories at session end (`cited` / `shaped` / `ignored` / `misled` / `overlooked`) via `memory.list_session_exposures` + `memory.apply_session_ratings`. Triggered by a Stop-block directive from the `session_close` hook when unrated exposures remain. diff --git a/better_memory/setup/autocheck.py b/better_memory/setup/autocheck.py index f795c01..5223788 100644 --- a/better_memory/setup/autocheck.py +++ b/better_memory/setup/autocheck.py @@ -18,7 +18,11 @@ def _mtimes(paths: engine.TargetPaths) -> dict[str, float]: result = {} - for p in (paths.claude_json, paths.settings_json, paths.claude_md): + # skills_dir: a directory's mtime changes when entries are added or + # removed, so a manually deleted skill link busts the cache and gets + # relinked next session. + for p in (paths.claude_json, paths.settings_json, paths.claude_md, + paths.skills_dir): result[str(p)] = p.stat().st_mtime if p.exists() else 0.0 return result diff --git a/better_memory/setup/engine.py b/better_memory/setup/engine.py index fa623e5..b05d418 100644 --- a/better_memory/setup/engine.py +++ b/better_memory/setup/engine.py @@ -14,8 +14,8 @@ (lines 228-279). The pure merges are generalized to iterate the full ``MANAGED_HOOKS`` table (8 specs, including ``if_filter`` groups) and to merge ``MANAGED_ENV`` alongside hooks; ``install_skill_symlinks`` is -parameterized by target dir + repo root and extended to loop -``MANAGED_SKILLS``. +parameterized by target dir + repo root and extended to loop every skill +``manifest.managed_skills()`` enumerates from the repo checkout. ``_backup``/``_atomic_write`` are duplicated here rather than imported from ``cli/install_hooks`` — that module becomes a thin shim over this engine in @@ -43,9 +43,9 @@ LEGACY_HOOK_MODULES, MANAGED_ENV, MANAGED_HOOKS, - MANAGED_SKILLS, MachineParams, hook_entry, + managed_skills, mcp_server_entry, ) @@ -106,7 +106,8 @@ def _canonical_hook_groups(params: MachineParams) -> dict[str, list[dict]]: def render(params: MachineParams) -> dict: """Build the desired-state dict for every managed surface.""" skills = tuple( - (name, str(_repo_skill_source(params, name))) for name in MANAGED_SKILLS + (name, str(_repo_skill_source(params, name))) + for name in managed_skills(params.repo_root) ) return { @@ -351,7 +352,7 @@ def diff(params: MachineParams, paths: TargetPaths) -> list[str]: f"{paths.claude_md}: legacy unmarked better-memory section outside managed block" ) - for skill_name in MANAGED_SKILLS: + for skill_name in managed_skills(params.repo_root): link = paths.skills_dir / skill_name source = _repo_skill_source(params, skill_name) if not (link.is_symlink() or os.path.isjunction(link)): @@ -364,6 +365,9 @@ def diff(params: MachineParams, paths: TargetPaths) -> list[str]: if not resolved_ok: drifts.append(f"skill {skill_name!r}: link does not resolve to repo skill dir") + for stale in _stale_repo_skill_links(paths, params): + drifts.append(f"skill {stale.name!r}: stale link (source gone from repo)") + return drifts @@ -512,14 +516,42 @@ def _apply_claude_md(paths: TargetPaths, backup_dir: Path, repaired: list[str]) repaired.append(f"{paths.claude_md}: managed block") +def _stale_repo_skill_links(paths: TargetPaths, params: MachineParams) -> list[Path]: + """Links in ``paths.skills_dir`` that better-memory owns but no longer + manages: the link targets ``/.claude/skills/`` yet its name is not + an enumerated skill (the skill was deleted or renamed in the repo). + Foreign entries — real directories, or links targeting anywhere else — + are never included. + """ + if not paths.skills_dir.is_dir(): + return [] + managed = set(managed_skills(params.repo_root)) + skills_root = (Path(params.repo_root) / ".claude" / "skills").resolve() + stale: list[Path] = [] + for entry in paths.skills_dir.iterdir(): + if entry.name in managed: + continue + if not (entry.is_symlink() or os.path.isjunction(entry)): + continue + try: + # realpath, not resolve(): non-strict for dangling links, and it + # follows junctions on Windows without the \\?\ prefix. + target = Path(os.path.realpath(entry)) + except OSError: + continue + if skills_root in target.parents: + stale.append(entry) + return stale + + def install_skills(paths: TargetPaths, params: MachineParams) -> list[str]: - """Symlink each ``MANAGED_SKILLS`` entry from the repo into - ``paths.skills_dir``. + """Symlink each skill ``manifest.managed_skills()`` enumerates from the + repo checkout into ``paths.skills_dir``. Port of ``cli/install_hooks.py``'s ``install_skill_symlinks``, parameterized by target dir (``paths.skills_dir``) and repo root (``params.repo_root``) instead of hardcoded user-scope paths, and - looping the full ``MANAGED_SKILLS`` tuple. + looping every enumerated repo skill. Idempotent: if a link already points to the right target, nothing happens. If a different symlink, file, or directory exists at the @@ -534,7 +566,7 @@ def install_skills(paths: TargetPaths, params: MachineParams) -> list[str]: warnings: list[str] = [] paths.skills_dir.mkdir(parents=True, exist_ok=True) - for skill_name in MANAGED_SKILLS: + for skill_name in managed_skills(params.repo_root): source = _repo_skill_source(params, skill_name) if not source.is_dir(): warnings.append(f"skill {skill_name!r}: source not found at {source}") @@ -579,6 +611,18 @@ def install_skills(paths: TargetPaths, params: MachineParams) -> list[str]: "Developer Mode or run as administrator to install skill symlinks." ) + # Prune links whose repo skill is gone (deleted/renamed in the checkout). + for stale in _stale_repo_skill_links(paths, params): + try: + stale.unlink() + except OSError as exc: + try: + os.rmdir(stale) + except OSError: + warnings.append( + f"stale skill link not removed ({stale.name}): {exc}" + ) + return warnings diff --git a/better_memory/setup/manifest.py b/better_memory/setup/manifest.py index 82276bc..02805f3 100644 --- a/better_memory/setup/manifest.py +++ b/better_memory/setup/manifest.py @@ -2,10 +2,12 @@ Managed surface (spec table, rows 1-12): eight hook entries and one env key in ~/.claude/settings.json, the MCP server entry in ~/.claude.json, the -CLAUDE.md managed block, and three user-scope skills. Row 13 (per-repo -post-commit hook) lives in setup/repo_hook.py. +CLAUDE.md managed block, and the user-scope skills enumerated from the repo +checkout's .claude/skills/. Row 13 (per-repo post-commit hook) lives in +setup/repo_hook.py. -Pure data + rendering helpers; no I/O except detect_machine_params(). +Pure data + rendering helpers; no I/O except detect_machine_params() and +managed_skills() (a read-only directory listing). """ from __future__ import annotations @@ -104,11 +106,22 @@ class HookSpec: MANAGED_ENV: dict[str, str] = {"BETTER_MEMORY_INJECT_MODE": "deferred"} -MANAGED_SKILLS: tuple[str, ...] = ( - "better-memory-synthesize", - "rate-session-memories", - "start-better-memory-ui", -) +def managed_skills(repo_root: str | Path) -> tuple[str, ...]: + """Enumerate the repo's user-scope skills: every directory under + ``/.claude/skills/`` that contains a ``SKILL.md``. + + Sorted for deterministic ``engine.fingerprint()`` output — adding or + removing a skill directory in the repo changes the fingerprint, which + is what lets the per-session autocheck notice and re-apply. A missing + skills directory yields an empty tuple. + """ + skills_root = Path(repo_root) / ".claude" / "skills" + if not skills_root.is_dir(): + return () + return tuple(sorted( + entry.name for entry in skills_root.iterdir() + if entry.is_dir() and (entry / "SKILL.md").is_file() + )) BEGIN_MARKER = "" END_MARKER = "" diff --git a/tests/setup/test_autocheck.py b/tests/setup/test_autocheck.py index ef91187..c2b0221 100644 --- a/tests/setup/test_autocheck.py +++ b/tests/setup/test_autocheck.py @@ -54,6 +54,21 @@ def test_kill_switch(tmp_path, monkeypatch): assert autocheck.maybe_repair(tmp_path / "home", tmp_path) is None +def test_cache_invalidated_when_skills_dir_touched(tmp_path, monkeypatch): + """Adding or removing an entry in the user-level skills dir changes its + mtime, which must bust the fingerprint cache — otherwise a manually + deleted skill link would never be restored.""" + paths, params = _wire(tmp_path, monkeypatch) + autocheck.maybe_repair(tmp_path / "home", tmp_path) + calls = [] + monkeypatch.setattr(engine, "diff", + lambda *a, **k: calls.append(1) or []) + paths.skills_dir.mkdir(parents=True) + (paths.skills_dir / "some-skill").mkdir() + autocheck.maybe_repair(tmp_path / "home", tmp_path) + assert calls == [1] # skills_dir mtime change re-ran the diff + + def test_cache_invalidated_when_config_touched(tmp_path, monkeypatch): paths, params = _wire(tmp_path, monkeypatch) autocheck.maybe_repair(tmp_path / "home", tmp_path) diff --git a/tests/setup/test_engine_apply.py b/tests/setup/test_engine_apply.py index 107f4f3..54453cf 100644 --- a/tests/setup/test_engine_apply.py +++ b/tests/setup/test_engine_apply.py @@ -1,5 +1,6 @@ import json import os +import shutil import subprocess import sys import tempfile @@ -19,10 +20,18 @@ BEGIN_MARKER, CLAUDE_MD_BLOCK, END_MARKER, - MANAGED_SKILLS, MachineParams, ) +# Skill names seeded into the fake repo checkout by _fake_repo_params(). +# With managed_skills() enumerating the repo, the fake tree defines the +# managed set for these tests. +SEEDED_SKILLS = ( + "better-memory-synthesize", + "rate-session-memories", + "start-better-memory-ui", +) + PARAMS = MachineParams( venv_py="/repo/.venv/bin/python", venv_pyw="/repo/.venv/bin/python", home="/home/u/.better-memory", repo_root="/repo", @@ -182,10 +191,10 @@ def test_install_skills_creates_real_symlink_to_repo_skill(tmp_path): def _fake_repo_params(tmp_path: Path) -> MachineParams: - """A fake repo checkout with every MANAGED_SKILLS entry seeded as a real + """A fake repo checkout with every SEEDED_SKILLS entry seeded as a real directory, so install_skills() never warns about a missing source.""" repo = tmp_path / "repo" - for name in MANAGED_SKILLS: + for name in SEEDED_SKILLS: skill_dir = repo / ".claude" / "skills" / name skill_dir.mkdir(parents=True) (skill_dir / "SKILL.md").write_text(f"{name} sentinel\n", encoding="utf-8") @@ -291,9 +300,9 @@ def boom(self, target, target_is_directory=False): # noqa: ARG001 warnings = install_skills(paths, params) - assert len(warnings) == len(MANAGED_SKILLS) + assert len(warnings) == len(SEEDED_SKILLS) assert all("skipped" in w for w in warnings) - for name in MANAGED_SKILLS: + for name in SEEDED_SKILLS: assert not (skills_dir / name).exists() assert skills_dir.is_dir() # dir creation is separate from link creation @@ -328,10 +337,10 @@ def fake_run(cmd, **kwargs): # noqa: ARG001 warnings = install_skills(paths, params) assert warnings == [] - assert len(calls) == len(MANAGED_SKILLS) + assert len(calls) == len(SEEDED_SKILLS) for cmd in calls: assert cmd[:4] == ["cmd", "/c", "mklink", "/J"] - for name in MANAGED_SKILLS: + for name in SEEDED_SKILLS: assert (skills_dir / name).exists() @@ -358,9 +367,9 @@ def boom(self, target, target_is_directory=False): # noqa: ARG001 warnings = install_skills(paths, params) - assert len(warnings) == len(MANAGED_SKILLS) + assert len(warnings) == len(SEEDED_SKILLS) assert all("skipped" in w for w in warnings) - for name in MANAGED_SKILLS: + for name in SEEDED_SKILLS: assert not (skills_dir / name).exists() @@ -390,9 +399,9 @@ def raise_timeout(cmd, **kwargs): # noqa: ARG001 warnings = install_skills(paths, params) - assert len(warnings) == len(MANAGED_SKILLS) + assert len(warnings) == len(SEEDED_SKILLS) assert all("skipped" in w for w in warnings) - for name in MANAGED_SKILLS: + for name in SEEDED_SKILLS: assert not (skills_dir / name).exists() @@ -427,3 +436,107 @@ def test_junction_skill_link_is_recognised_and_skipped(tmp_path): # (see requires_symlinks); only the junction'd skill must be silent. assert not any(LADDER_SKILL in w for w in warnings) assert os.path.isjunction(junction) # untouched, not rmtree'd + + +# ------------------------- dynamic enumeration + stale-link prune + + +def _make_link(link: Path, target: Path) -> None: + """Create a directory link the way this environment allows: a symlink + where privileged, else a junction (mklink /J needs no privilege).""" + link.parent.mkdir(parents=True, exist_ok=True) + try: + link.symlink_to(target, target_is_directory=True) + except OSError: + proc = subprocess.run( + ["cmd", "/c", "mklink", "/J", str(link), str(target)], + capture_output=True, text=True, + ) + if proc.returncode != 0: + pytest.skip(f"cannot create links here: {proc.stderr.strip()}") + + +def _link_exists(link: Path) -> bool: + return os.path.lexists(link) or os.path.isjunction(link) + + +def test_install_skills_picks_up_newly_added_skill_dir(tmp_path): + """A skill directory added to the repo after install is enumerated and + linked on the next run — no hardcoded skill list to update.""" + params = _fake_repo_params(tmp_path) + new_skill = Path(params.repo_root) / ".claude" / "skills" / "zz-brand-new" + new_skill.mkdir(parents=True) + (new_skill / "SKILL.md").write_text("new skill\n", encoding="utf-8") + paths = TargetPaths( + claude_json=tmp_path / ".claude.json", settings_json=tmp_path / "settings.json", + claude_md=tmp_path / "CLAUDE.md", skills_dir=tmp_path / "skills", + ) + + warnings = install_skills(paths, params) + + link = paths.skills_dir / "zz-brand-new" + if any("zz-brand-new" in w for w in warnings): + pytest.skip("environment can create neither symlinks nor junctions") + assert _link_exists(link) + assert Path(os.path.realpath(link)) == new_skill.resolve() + + +def test_install_skills_prunes_stale_repo_targeted_link(tmp_path): + """A link in skills_dir that targets /.claude/skills/ whose + source no longer exists (skill deleted/renamed in repo) is removed.""" + params = _fake_repo_params(tmp_path) + gone = Path(params.repo_root) / ".claude" / "skills" / "old-deleted-skill" + gone.mkdir(parents=True) + paths = TargetPaths( + claude_json=tmp_path / ".claude.json", settings_json=tmp_path / "settings.json", + claude_md=tmp_path / "CLAUDE.md", skills_dir=tmp_path / "skills", + ) + stale = paths.skills_dir / "old-deleted-skill" + _make_link(stale, gone) + shutil.rmtree(gone) # now dangling, repo-targeted + + install_skills(paths, params) + + assert not _link_exists(stale) + + +def test_install_skills_preserves_foreign_entries(tmp_path): + """Entries in skills_dir that are not better-memory's — a user's real + skill directory, or a link pointing outside the repo skills tree — are + never touched by the prune pass.""" + params = _fake_repo_params(tmp_path) + paths = TargetPaths( + claude_json=tmp_path / ".claude.json", settings_json=tmp_path / "settings.json", + claude_md=tmp_path / "CLAUDE.md", skills_dir=tmp_path / "skills", + ) + user_dir = paths.skills_dir / "user-skill" + user_dir.mkdir(parents=True) + (user_dir / "SKILL.md").write_text("user's own skill\n", encoding="utf-8") + elsewhere = tmp_path / "other-tool-skills" / "other-skill" + elsewhere.mkdir(parents=True) + foreign_link = paths.skills_dir / "other-skill" + _make_link(foreign_link, elsewhere) + + install_skills(paths, params) + + assert (user_dir / "SKILL.md").exists() + assert _link_exists(foreign_link) + + +def test_diff_reports_stale_repo_targeted_link(tmp_path): + """diff() must surface a stale repo-targeted link as drift, or the + autocheck's `if drift:` guard would never trigger the pruning apply().""" + params = _fake_repo_params(tmp_path) + gone = Path(params.repo_root) / ".claude" / "skills" / "old-deleted-skill" + gone.mkdir(parents=True) + paths = TargetPaths( + claude_json=tmp_path / ".claude.json", settings_json=tmp_path / "settings.json", + claude_md=tmp_path / "CLAUDE.md", skills_dir=tmp_path / "skills", + ) + stale = paths.skills_dir / "old-deleted-skill" + _make_link(stale, gone) + shutil.rmtree(gone) + + drift = diff(params, paths) + + assert any("skill" in d and "stale link" in d for d in drift) diff --git a/tests/setup/test_manifest.py b/tests/setup/test_manifest.py index 328226e..d6f8a22 100644 --- a/tests/setup/test_manifest.py +++ b/tests/setup/test_manifest.py @@ -1,12 +1,14 @@ """Manifest completeness and rendering tests.""" +from pathlib import Path + from better_memory.setup.manifest import ( CLAUDE_MD_BLOCK, MANAGED_ENV, MANAGED_HOOKS, - MANAGED_SKILLS, MachineParams, detect_machine_params, hook_entry, + managed_skills, mcp_server_entry, ) @@ -61,13 +63,33 @@ def test_mcp_server_entry_shape(): } -def test_managed_env_and_skills(): +def test_managed_env(): assert MANAGED_ENV == {"BETTER_MEMORY_INJECT_MODE": "deferred"} - assert MANAGED_SKILLS == ( - "better-memory-synthesize", - "rate-session-memories", - "start-better-memory-ui", - ) + + +def test_managed_skills_enumerates_repo_skill_dirs(tmp_path): + """Every directory under /.claude/skills/ with a SKILL.md counts; + dirs without SKILL.md and stray files are ignored; result is sorted.""" + skills_root = tmp_path / ".claude" / "skills" + for name in ("zeta-skill", "alpha-skill"): + d = skills_root / name + d.mkdir(parents=True) + (d / "SKILL.md").write_text(f"{name}\n", encoding="utf-8") + (skills_root / "not-a-skill").mkdir() # no SKILL.md + (skills_root / "stray.md").write_text("stray\n", encoding="utf-8") + + assert managed_skills(tmp_path) == ("alpha-skill", "zeta-skill") + + +def test_managed_skills_missing_dir_returns_empty(tmp_path): + assert managed_skills(tmp_path) == () + + +def test_managed_skills_finds_real_repo_skills(): + repo_root = Path(__file__).resolve().parents[2] + found = managed_skills(repo_root) + assert {"better-memory-synthesize", "rate-session-memories", + "start-better-memory-ui"} <= set(found) def test_claude_md_block_is_nonempty_and_unmarkered(): diff --git a/website/architecture.md b/website/architecture.md index 25c128e..a1f1129 100644 --- a/website/architecture.md +++ b/website/architecture.md @@ -62,8 +62,8 @@ See [Configuration](configuration.md) for env vars and [AgentCore setup](agentco better-memory owns its own Claude Code wiring rather than relying on a one-time install script. Three pieces, all under `better_memory/setup/`: -- **`manifest.py`** — the single source of truth: a declarative table of every managed hook (`MANAGED_HOOKS`, 8 entries across 7 modules — `contextual_inject` registers on two events), the managed env key (`MANAGED_ENV`), the three managed skills (`MANAGED_SKILLS`), and the managed CLAUDE.md block text (`CLAUDE_MD_BLOCK`), spliced between `` / `` markers. -- **`engine.py`** — pure `render()` / `diff()` functions plus the I/O half, `apply()`: smart-merges the managed subset of `~/.claude/settings.json` (hooks + env), `~/.claude.json` (MCP server entry), and `~/.claude/CLAUDE.md` (managed block — absorbing any pre-marker legacy unmarked section so the protocol text is never duplicated) without touching the user's other entries. Backs up every file it overwrites to `~/.better-memory/install-backups/` first, retries once if `~/.claude.json` is rewritten concurrently by Claude Code itself, and serializes concurrent `apply()` calls via `~/.better-memory/state/setup-apply.lock` (60s stale timeout). Skill links use real symlinks, falling back to Windows directory junctions when symlink privilege is unavailable. +- **`manifest.py`** — the single source of truth: a declarative table of every managed hook (`MANAGED_HOOKS`, 8 entries across 7 modules — `contextual_inject` registers on two events), the managed env key (`MANAGED_ENV`), the managed skills (`managed_skills()` — enumerated live from the repo checkout's `.claude/skills/`, so a skill added to or removed from the repo is picked up without a code change), and the managed CLAUDE.md block text (`CLAUDE_MD_BLOCK`), spliced between `` / `` markers. +- **`engine.py`** — pure `render()` / `diff()` functions plus the I/O half, `apply()`: smart-merges the managed subset of `~/.claude/settings.json` (hooks + env), `~/.claude.json` (MCP server entry), and `~/.claude/CLAUDE.md` (managed block — absorbing any pre-marker legacy unmarked section so the protocol text is never duplicated) without touching the user's other entries. Backs up every file it overwrites to `~/.better-memory/install-backups/` first, retries once if `~/.claude.json` is rewritten concurrently by Claude Code itself, and serializes concurrent `apply()` calls via `~/.better-memory/state/setup-apply.lock` (60s stale timeout). Skill links use real symlinks, falling back to Windows directory junctions when symlink privilege is unavailable; links whose repo skill no longer exists are pruned (foreign entries in `~/.claude/skills/` are never touched). - **`autocheck.py`** — a near-zero-cost per-session check wired into the `session_bootstrap` hook: it compares a fingerprint of the desired state (plus target-file mtimes, cached in `~/.better-memory/state/wiring_fingerprint.json`) against what's on disk, and only runs the full `diff()`/`apply()` when something changed. It also installs the per-repo `post-commit` hook the first time it sees a git repo without one (see [docs/hooks-setup.md](https://github.com/emp3thy/better-memory/blob/main/docs/hooks-setup.md#post-commit-hook-opt-in-episode-close) for the install rules: honors a custom `core.hooksPath`, chains only after a plain-`sh` script, warns and never overwrites anything else). Disable with `BETTER_MEMORY_WIRING_AUTOCHECK=off`. The CLI wraps the same engine: `better-memory setup` always applies; `better-memory doctor` reports drift read-only (exit 1 if any drift, 0 if clean); `better-memory doctor --fix` applies (always exits 0). `python -m better_memory.cli.install_hooks` is a deprecated shim that now just delegates to `setup`.