From c24393f164efbba593178a0e4796623d11b467bb Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Fri, 1 May 2026 14:17:31 -0400 Subject: [PATCH 1/3] refactor: replace basic-memory with recall as managed tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renames all basic-memory references to recall throughout the installer, updater, profiles, SKILL.md, and AGENTS.md. Removes _run_recall_init() entirely — recall self-bootstraps its KB on first MCP tool call, so ai-rules has zero KB lifecycle knowledge. Deletes the hook scripts that basic-memory required; recall handles git sync internally via daemon thread. --- src/ai_rules/bootstrap/__init__.py | 2 + src/ai_rules/bootstrap/installer.py | 136 +++++++++++++++ src/ai_rules/bootstrap/updater.py | 27 +++ src/ai_rules/cli.py | 43 +++++ src/ai_rules/config/AGENTS.md | 11 ++ src/ai_rules/config/claude/settings.json | 3 +- src/ai_rules/config/profiles/personal.yaml | 10 +- src/ai_rules/config/profiles/work.yaml | 10 +- src/ai_rules/config/skills/kb/SKILL.md | 182 +++++++++++++++++++++ tests/unit/test_bootstrap_installer.py | 163 ++++++++++++++++++ tests/unit/test_bootstrap_updater.py | 48 ++++++ 11 files changed, 632 insertions(+), 3 deletions(-) create mode 100644 src/ai_rules/config/skills/kb/SKILL.md diff --git a/src/ai_rules/bootstrap/__init__.py b/src/ai_rules/bootstrap/__init__.py index df4f655..1cc8a84 100644 --- a/src/ai_rules/bootstrap/__init__.py +++ b/src/ai_rules/bootstrap/__init__.py @@ -10,6 +10,7 @@ from .installer import ( UV_NOT_FOUND_ERROR, ToolSource, + ensure_recall_installed, ensure_statusline_installed, get_effective_install_source, get_tool_config_dir, @@ -35,6 +36,7 @@ "parse_version", "UV_NOT_FOUND_ERROR", "ToolSource", + "ensure_recall_installed", "ensure_statusline_installed", "get_effective_install_source", "get_tool_config_dir", diff --git a/src/ai_rules/bootstrap/installer.py b/src/ai_rules/bootstrap/installer.py index e38577a..312b263 100644 --- a/src/ai_rules/bootstrap/installer.py +++ b/src/ai_rules/bootstrap/installer.py @@ -36,6 +36,7 @@ def make_github_install_url(repo: str) -> str: UV_NOT_FOUND_ERROR = "uv not found in PATH. Install from https://docs.astral.sh/uv/" +RECALL_GITHUB_REPO = "wpfleger96/recall" def _validate_package_name(package_name: str) -> bool: @@ -418,3 +419,138 @@ def ensure_statusline_installed( return "failed", None except Exception: return "failed", None + + +def _is_recall_configured(config: object) -> bool: + """Check if recall is configured in the merged MCP config.""" + if hasattr(config, "mcp_overrides") and "recall" in config.mcp_overrides: + return True + + try: + import importlib.resources + + config_pkg = importlib.resources.files("ai_rules") / "config" + for mcps_path in [ + config_pkg / "mcps.json", + config_pkg / "claude" / "mcps.json", + ]: + traversable = mcps_path + if hasattr(traversable, "is_file") and traversable.is_file(): + import json + + data = json.loads(traversable.read_text()) + if "recall" in data: + return True + except Exception: + pass + + return False + + +def ensure_recall_installed( + dry_run: bool = False, + config: object | None = None, +) -> tuple[str, str | None]: + """Install or upgrade recall if needed. Fails open. + + Args: + dry_run: If True, show what would be done without executing + config: Config object; if provided and recall is not configured, skip + + Returns: + Tuple of (status, message) where status is: + "already_installed", "installed", "upgraded", "upgrade_available", + "source_switched", "source_switch_needed", "failed", or "skipped" + """ + if config is not None and not _is_recall_configured(config): + return "skipped", None + + source, local_path = get_effective_install_source("recall") + from_github = source == ToolSource.GITHUB + + if is_command_available("recall"): + if source != ToolSource.LOCAL: + try: + from ai_rules.bootstrap.updater import ( + check_tool_updates, + get_tool_by_id, + perform_tool_upgrade, + ) + + recall_tool = get_tool_by_id("recall") + if recall_tool: + current_source = get_tool_source(recall_tool.package_name) + if current_source is not None and current_source != source: + if dry_run: + return ( + "source_switch_needed", + f"Would switch recall from {current_source.name} to {source.name}", + ) + uninstall_success, _ = uninstall_tool(recall_tool.package_name) + if uninstall_success: + github_url = ( + recall_tool.github_install_url if from_github else None + ) + success, _ = install_tool( + recall_tool.package_name, + from_github=from_github, + github_url=github_url, + local_path=local_path, + force=True, + ) + if success: + return ( + "source_switched", + f"{current_source.name} → {source.name}", + ) + return "failed", None + + update_info = check_tool_updates(recall_tool, timeout=10) + if update_info and update_info.has_update: + if dry_run: + return ( + "upgrade_available", + f"Would upgrade recall {update_info.current_version} → {update_info.latest_version}", + ) + success, msg, _ = perform_tool_upgrade(recall_tool) + if success: + return ( + "upgraded", + f"{update_info.current_version} → {update_info.latest_version}", + ) + except Exception: + pass + else: + # LOCAL source: always reinstall to pick up latest local changes + success, message = install_tool( + "recall-mcp-server", + local_path=local_path, + force=True, + dry_run=dry_run, + ) + if dry_run: + return "upgrade_available", message + if success: + return "upgraded", "reinstalled from local path" + return "failed", None + + return "already_installed", None + + try: + github_url = ( + make_github_install_url(RECALL_GITHUB_REPO) if from_github else None + ) + success, message = install_tool( + "recall-mcp-server", + from_github=from_github, + github_url=github_url, + local_path=local_path, + force=False, + dry_run=dry_run, + ) + if success: + return "installed", message if dry_run else None + else: + return "failed", None + except Exception: + return "failed", None diff --git a/src/ai_rules/bootstrap/updater.py b/src/ai_rules/bootstrap/updater.py index b32d7f5..323a913 100644 --- a/src/ai_rules/bootstrap/updater.py +++ b/src/ai_rules/bootstrap/updater.py @@ -11,8 +11,10 @@ from dataclasses import dataclass from .installer import ( + RECALL_GITHUB_REPO, UV_NOT_FOUND_ERROR, ToolSource, + _is_recall_configured, _validate_package_name, get_tool_source, get_tool_version, @@ -65,6 +67,7 @@ class ToolSpec: get_version: Callable[[], str | None] is_installed: Callable[[], bool] github_repo: str | None = None + is_enabled: Callable[[], bool] | None = None @property def github_install_url(self) -> str | None: @@ -390,6 +393,19 @@ def perform_tool_upgrade(tool: ToolSpec) -> tuple[bool, str, bool]: return False, f"Unexpected error: {e}", False +def _is_recall_configured_for_active_profile() -> bool: + """Check if recall is configured for the currently active profile.""" + try: + from ai_rules.config import Config + from ai_rules.state import get_active_profile + + profile = get_active_profile() or "default" + config = Config.load(profile=profile) + return _is_recall_configured(config) + except Exception: + return False + + _SELF_SPEC = ToolSpec( tool_id="ai-agent-rules", package_name="ai-agent-rules", @@ -399,6 +415,16 @@ def perform_tool_upgrade(tool: ToolSpec) -> tuple[bool, str, bool]: github_repo=_SELF_GITHUB_REPO, ) +_RECALL_SPEC = ToolSpec( + tool_id="recall", + package_name="recall-mcp-server", + display_name="recall", + get_version=lambda: get_tool_version("recall-mcp-server"), + is_installed=lambda: is_command_available("recall"), + github_repo=RECALL_GITHUB_REPO, + is_enabled=lambda: _is_recall_configured_for_active_profile(), +) + def get_updatable_tools() -> list[ToolSpec]: """Get all updatable tool specs, deriving from registered Tool classes.""" @@ -407,6 +433,7 @@ def get_updatable_tools() -> list[ToolSpec]: tools: list[ToolSpec] = [_SELF_SPEC] if StatuslineTool.INSTALL_SPEC is not None: tools.append(StatuslineTool.INSTALL_SPEC) + tools.append(_RECALL_SPEC) return tools diff --git a/src/ai_rules/cli.py b/src/ai_rules/cli.py index 9c849ca..40ac28e 100644 --- a/src/ai_rules/cli.py +++ b/src/ai_rules/cli.py @@ -457,6 +457,18 @@ def version_callback(ctx: click.Context, param: click.Parameter, value: bool) -> except Exception as e: logger.debug(f"Failed to get statusline version: {e}") + try: + from ai_rules.bootstrap import get_tool_version, is_command_available + + if is_command_available("recall"): + bm_version = get_tool_version("recall-mcp-server") + if bm_version: + console.print(f"recall, version {bm_version}") + else: + console.print("recall, version [dim](installed, version unknown)[/dim]") + except Exception as e: + logger.debug(f"Failed to get recall version: {e}") + try: from ai_rules.bootstrap import check_tool_updates, get_tool_by_id @@ -1061,6 +1073,7 @@ def install( from ai_rules.bootstrap import ( ToolSource, + ensure_recall_installed, ensure_statusline_installed, get_effective_install_source, ) @@ -1102,6 +1115,27 @@ def install( console.print(f"[red]Error:[/red] {e}") sys.exit(1) + recall_result, recall_message = ensure_recall_installed( + dry_run=dry_run, config=config + ) + if recall_result == "installed": + if dry_run and recall_message: + console.print(f"[dim]{recall_message}[/dim]\n") + else: + console.print("[green]✓[/green] Installed recall\n") + elif recall_result in ("upgraded", "source_switched"): + console.print( + f"[green]✓[/green] Updated recall ({recall_message})\n" + if recall_message + else "[green]✓[/green] Updated recall\n" + ) + elif recall_result == "upgrade_available" and dry_run and recall_message: + console.print(f"[dim]{recall_message}[/dim]\n") + elif recall_result == "failed": + console.print( + "[yellow]⚠[/yellow] Failed to install recall (continuing anyway)\n" + ) + if not dry_run: set_active_profile(profile) @@ -1684,6 +1718,15 @@ def status(agents: str | None) -> None: console.print(" [yellow]○[/yellow] claude-statusline not installed") statusline_missing = True + from ai_rules.bootstrap.installer import _is_recall_configured + + if _is_recall_configured(config): + if is_command_available("recall"): + console.print(" [green]✓[/green] recall installed") + else: + console.print(" [yellow]○[/yellow] recall not installed") + statusline_missing = True + console.print() console.print("[bold cyan]Shell Completions[/bold cyan]\n") diff --git a/src/ai_rules/config/AGENTS.md b/src/ai_rules/config/AGENTS.md index d26565b..f5d92c9 100644 --- a/src/ai_rules/config/AGENTS.md +++ b/src/ai_rules/config/AGENTS.md @@ -175,6 +175,17 @@ Three similar lines > premature abstraction | No helpers for one-time ops | Only **Why:** LLMs confidently generate plausible-sounding but incorrect assumptions. Explicit verification prevents wasted work and builds trust through transparency. +### Persistent Knowledge Base (recall) + +A persistent markdown knowledge base exists at `~/.recall/`, powered by the recall MCP server with FTS5 full-text search with BM25 ranking. Knowledge persists across sessions, repos, and machines via git sync. + +**Write-back loop:** When you synthesize a useful answer, debug a non-obvious issue, or the user corrects a false assumption, consider persisting it via `write_note`. Search first to avoid duplicates. Invoke the `/kb` skill for formatting conventions. + +**Quality conventions:** Use `[GAP: ...]` annotations for unverified claims. Use `[misconception]` tags to document what is NOT true (prevents confident false assertions). Use `[promote]` to flag patterns worth promoting to AGENTS.md rules. + +**Key tools:** `search_notes(query)` for FTS5 search, `build_context(path)` for graph traversal, `read_note(path)` for specific notes, `write_note(path, content)` to persist knowledge. + + --- ## Technical Standards diff --git a/src/ai_rules/config/claude/settings.json b/src/ai_rules/config/claude/settings.json index f898a5d..ee3f43c 100644 --- a/src/ai_rules/config/claude/settings.json +++ b/src/ai_rules/config/claude/settings.json @@ -139,5 +139,6 @@ "showThinkingSummaries": true, "skipDangerousModePermissionPrompt": true, "skipAutoPermissionPrompt": true, - "viewMode": "verbose" + "viewMode": "verbose", + "hooks": {} } diff --git a/src/ai_rules/config/profiles/personal.yaml b/src/ai_rules/config/profiles/personal.yaml index c049c7e..cde0947 100644 --- a/src/ai_rules/config/profiles/personal.yaml +++ b/src/ai_rules/config/profiles/personal.yaml @@ -8,4 +8,12 @@ settings_overrides: terminal_title: enabled: true exclude_symlinks: [] -mcp_overrides: {} +mcp_overrides: + recall: + type: stdio + command: recall + args: + - mcp + - stdio + name: recall + description: Persistent markdown knowledge base with FTS5 search and git sync diff --git a/src/ai_rules/config/profiles/work.yaml b/src/ai_rules/config/profiles/work.yaml index f893bb7..8ab6201 100644 --- a/src/ai_rules/config/profiles/work.yaml +++ b/src/ai_rules/config/profiles/work.yaml @@ -26,4 +26,12 @@ settings_overrides: useFullWidth: true exclude_symlinks: - ~/.config/goose/config.yaml -mcp_overrides: {} +mcp_overrides: + recall: + type: stdio + command: recall + args: + - mcp + - stdio + name: recall + description: Persistent markdown knowledge base with FTS5 search and git sync diff --git a/src/ai_rules/config/skills/kb/SKILL.md b/src/ai_rules/config/skills/kb/SKILL.md new file mode 100644 index 0000000..3cc146d --- /dev/null +++ b/src/ai_rules/config/skills/kb/SKILL.md @@ -0,0 +1,182 @@ +--- +name: kb +description: >- + This skill should be used when the user asks to "save to knowledge base", + "write a note", "persist this", "remember this pattern", "update the KB", + or when the Stop hook instructs the agent to persist session knowledge. + Also use when asking "search knowledge base", "what do we know about", + or needing cross-project context from recall. +--- + +# Knowledge Base (recall) + +A persistent markdown knowledge base at `~/.recall/` powered by the recall MCP server. Knowledge persists across sessions, repos, and machines via git sync. Searchable with FTS5 full-text search with BM25 ranking. + +## Workflow + +1. **Search first** to avoid duplicates: `search_notes(query="topic")` +2. **Read existing** if a related note exists: `read_note(path="repos/ai-rules.md")` +3. **Write or update**: `write_note(path="repos/ai-rules.md", content="---\ntitle: ...\ntype: note\nupdated: 2026-04-30\ntags: []\n---\n\n# ...")` +4. **Connect related notes** using `[[wikilinks]]` in the Relations section + +## Directory Guide + +| Directory | Use for | Example titles | +|-----------|---------|----------------| +| `repos/` | Per-repo commands, gotchas, patterns | "ai-rules", "goosed-slackbot" | +| `patterns/` | Reusable technical knowledge | "uv-run-not-direct-invocation" | +| `decisions/` | ADRs -- why something was chosen | "raw-sql-over-sqlalchemy" | +| `preferences/` | User working style, conventions | "test-docstring-conventions" | +| `references/` | External knowledge, company info | "block-ci-cd-pipeline" | +| `references/block/` | Block/Square-specific knowledge | "service-registry-conventions" | +| `people/` | Teammates, communication context | "tyler-sprout-expert" | +| `feedback/` | Corrections, lessons from mistakes | "no-assertions-without-verification" | +| `projects/` | Multi-repo initiative context | "slackbot-kotlin-migration" | + +## Note Format + +Every note uses YAML frontmatter + structured observations + relations: + +```markdown +--- +title: Note Title +type: note +updated: 2026-04-30 +tags: [tag1, tag2] +--- + +# Note Title + +## Observations +- [fact] Concrete, verified information +- [tip] Practical advice for future use +- [method] How to do something specific +- [preference] User's stated preference or convention +- [decision] A choice that was made and why + +## Relations +- related_to [[other-note-title]] +- depends_on [[prerequisite-note]] +``` + +### Observation Categories + +- **`[fact]`** -- verified, objective information (e.g., "Uses Gradle wrapper, not Maven") +- **`[tip]`** -- practical guidance (e.g., "Check Justfile before guessing build commands") +- **`[method]`** -- how to do something (e.g., "Run `just check-all` for full validation") +- **`[preference]`** -- user's stated preference (e.g., "Never add docstrings to test functions") +- **`[decision]`** -- a choice with rationale (e.g., "Chose raw SQL for performance over ORM convenience") +- **`[misconception]`** -- something commonly assumed but false (e.g., "recall does NOT use vector search -- it uses FTS5 with BM25 ranking; the LLM handles semantic understanding via query expansion") + +### GAP Annotations + +When writing notes with uncertain or unverified information, mark the uncertainty explicitly: + +``` +- [fact] recall uses FTS5 full-text search with BM25 ranking [GAP: retrieval quality vs vector search not benchmarked] +- [fact] uv tool install respects XDG_DATA_HOME [GAP: behavior on Windows not verified] +``` + +`[GAP: ...]` makes knowledge base weaknesses machine-readable. Agents can grep for `[GAP:` to find notes needing verification. Include: what information is missing and how to verify it. + +### Tags + +Use lowercase, hyphenated tags in frontmatter. Common tags: `python`, `kotlin`, `rust`, `block`, `testing`, `ci-cd`, `architecture`, `gotcha`. + +### Wikilinks + +Connect related notes with `[[note-title]]` in the Relations section. Use typed relations: +- `related_to [[note]]` -- general connection +- `depends_on [[note]]` -- prerequisite +- `supersedes [[note]]` -- replaces older knowledge +- `contradicts [[note]]` -- conflicting information (flag for review) + +## When to Write vs Search + +**Write a note when:** +- A decision was made about architecture or approach +- A repo-specific gotcha or non-obvious command was discovered +- Company-specific knowledge was learned (Block internals, service names, team conventions) +- The user corrected a previous assumption +- A reusable pattern was identified + +**Search instead when:** +- Starting work in an unfamiliar repo -- `search_notes(query="repo-name")` +- Encountering a pattern already seen -- `search_notes(query="topic")` +- Needing cross-project context -- `build_context(path="repos/ai-rules.md")` + +**Do NOT write:** +- Session-specific ephemeral context (what files were edited this session) +- Information obvious from the codebase (README content, import paths) +- Speculative or unverified information (unless marked with `[GAP:]`) + +## Write-Back Loop + +After any session where you synthesized a useful answer, consider what's worth persisting: + +**When to trigger a write-back:** +- You answered a question by combining knowledge from multiple sources +- You discovered a non-obvious pattern, workaround, or gotcha not in the codebase docs +- You corrected a previous false assumption (write the correction, tag `[misconception]` for the old belief) +- The user explicitly says "remember this" or "save this" + +**The write-back workflow:** +1. Search first: `search_notes(query="")` to avoid duplicating existing knowledge +2. If note exists: use `edit_note()` to add the new insight rather than rewriting +3. If new: pick the appropriate directory, use the template from `references/note-templates.md` +4. Connect with wikilinks to related notes +5. Recall auto-commits and pushes after each write (no external hook needed) + +**Schema promotion:** When a pattern repeats across 3+ notes, it may be worth promoting to `AGENTS.md` as a standing rule. Flag candidate patterns with a `[promote]` tag in the observation. + +## Eval-First Methodology + +Don't pre-populate the knowledge base speculatively. Let real usage reveal what's missing: + +1. **Collect failing questions** -- track queries where agents struggled or gave wrong answers +2. **Test without KB** -- establish baseline: what does the agent get wrong cold? +3. **Write targeted notes** -- one note per knowledge gap, no more +4. **Re-test** -- verify the note actually fixes the failure case +5. **Repeat** -- iterate on real failures, not hypothetical ones + +This prevents "note sprawl" -- a KB full of notes that never get queried because they weren't driven by actual agent failure patterns. + +## KB Lint (Health Check) + +Periodically audit the knowledge base for quality issues. Use `recall_lint()` for automated checks. + +**Lint checks (automated via `recall_lint()`):** +1. **Staleness** -- notes not updated in 90+ days +2. **Orphan wikilinks** -- `[[links]]` pointing to notes that don't exist +3. **Missing frontmatter** -- notes missing required fields (title, type, updated) +4. **GAP annotations** -- notes with unresolved `[GAP:]` markers +5. **Contradictions** -- notes with `contradicts [[...]]` relations + +**What to do with findings:** +- Stale notes: verify and update, or add `[fact] Unable to verify as of ` +- Orphans: create the missing target note, or fix the link +- Contradictions: reconcile, update the winner, mark the loser with `supersedes` +- Missing fields: add required frontmatter + +## MCP Tools Quick Reference + +| Tool | Purpose | +|------|---------| +| `search_notes(query, tags?, types?)` | FTS5 full-text search across all notes | +| `build_context(path)` | Graph traversal from a note via wikilinks | +| `read_note(path)` | Read a specific note by relative path | +| `write_note(path, content)` | Create or update a note (full markdown with frontmatter) | +| `edit_note(path, find, replace)` | Partial edit without full rewrite | +| `delete_note(path)` | Delete a note (ask user first) | +| `recent_activity(n?)` | Recently modified notes | +| `list_directory(directory?)` | Browse the folder structure | +| `recall_lint()` | Automated KB health check | +| `recall_sync()` | Explicit git pull/push | +| `recall_status()` | Index stats, last sync, repo info | + +## Additional Resources + +### Reference Files + +For complete note templates with realistic examples for each directory type: +- **`references/note-templates.md`** -- Full templates for repos/, patterns/, decisions/, preferences/, references/, people/, feedback/, projects/ diff --git a/tests/unit/test_bootstrap_installer.py b/tests/unit/test_bootstrap_installer.py index b65ad5e..28cabd1 100644 --- a/tests/unit/test_bootstrap_installer.py +++ b/tests/unit/test_bootstrap_installer.py @@ -3,6 +3,7 @@ import subprocess import sys +from types import SimpleNamespace from unittest.mock import MagicMock import pytest @@ -10,6 +11,8 @@ from ai_rules.bootstrap.installer import ( UV_NOT_FOUND_ERROR, ToolSource, + _is_recall_configured, + ensure_recall_installed, get_effective_install_source, get_tool_config_dir, get_tool_source, @@ -508,3 +511,163 @@ def _raise(*args, **kwargs): source, local_path = get_effective_install_source("statusline") assert source == ToolSource.PYPI assert local_path is None + + +@pytest.mark.unit +@pytest.mark.bootstrap +class TestIsRecallConfigured: + """Tests for _is_recall_configured helper.""" + + def test_returns_true_when_in_mcp_overrides(self): + config = SimpleNamespace(mcp_overrides={"recall": {"command": "recall"}}) + assert _is_recall_configured(config) is True + + def test_returns_false_when_mcp_overrides_empty(self, monkeypatch): + import importlib.resources + + config = SimpleNamespace(mcp_overrides={}) + monkeypatch.setattr( + importlib.resources, + "files", + lambda pkg: _MockTraversable({}), + ) + assert _is_recall_configured(config) is False + + def test_returns_false_when_no_mcp_overrides_attr(self, monkeypatch): + import importlib.resources + + config = SimpleNamespace() + monkeypatch.setattr( + importlib.resources, + "files", + lambda pkg: _MockTraversable({}), + ) + assert _is_recall_configured(config) is False + + +@pytest.mark.unit +@pytest.mark.bootstrap +class TestEnsureRecallInstalled: + """Tests for ensure_recall_installed function.""" + + def test_skips_when_not_configured(self, monkeypatch): + config = SimpleNamespace(mcp_overrides={}) + monkeypatch.setattr( + "ai_rules.bootstrap.installer._is_recall_configured", + lambda c: False, + ) + status, msg = ensure_recall_installed(config=config) + assert status == "skipped" + assert msg is None + + def test_returns_already_installed_when_available(self, monkeypatch): + monkeypatch.setattr( + "ai_rules.bootstrap.installer._is_recall_configured", + lambda c: True, + ) + monkeypatch.setattr( + "ai_rules.bootstrap.installer.is_command_available", + lambda cmd: True, + ) + monkeypatch.setattr( + "ai_rules.bootstrap.installer.get_effective_install_source", + lambda tid: (ToolSource.PYPI, None), + ) + + from ai_rules.bootstrap import updater + + monkeypatch.setattr( + updater, + "get_tool_by_id", + lambda tid: None, + ) + status, msg = ensure_recall_installed( + config=SimpleNamespace(mcp_overrides={"recall": {}}) + ) + assert status == "already_installed" + + def test_installs_on_fresh_install(self, monkeypatch): + monkeypatch.setattr( + "ai_rules.bootstrap.installer._is_recall_configured", + lambda c: True, + ) + monkeypatch.setattr( + "ai_rules.bootstrap.installer.is_command_available", + lambda cmd: cmd != "recall", + ) + monkeypatch.setattr( + "ai_rules.bootstrap.installer.get_effective_install_source", + lambda tid: (ToolSource.PYPI, None), + ) + monkeypatch.setattr( + "ai_rules.bootstrap.installer.install_tool", + lambda *a, **kw: (True, "ok"), + ) + status, msg = ensure_recall_installed( + config=SimpleNamespace(mcp_overrides={"recall": {}}) + ) + assert status == "installed" + + def test_local_install_failure_returns_failed(self, monkeypatch): + """LOCAL install failure must return 'failed', not fall through to 'already_installed'.""" + monkeypatch.setattr( + "ai_rules.bootstrap.installer._is_recall_configured", + lambda c: True, + ) + monkeypatch.setattr( + "ai_rules.bootstrap.installer.is_command_available", + lambda cmd: True, + ) + monkeypatch.setattr( + "ai_rules.bootstrap.installer.get_effective_install_source", + lambda tid: (ToolSource.LOCAL, "~/dev/recall"), + ) + monkeypatch.setattr( + "ai_rules.bootstrap.installer.install_tool", + lambda *a, **kw: (False, "install failed"), + ) + status, msg = ensure_recall_installed( + config=SimpleNamespace(mcp_overrides={"recall": {}}) + ) + assert status == "failed" + + def test_local_install_success_returns_upgraded(self, monkeypatch): + """Successful LOCAL reinstall returns 'upgraded'.""" + monkeypatch.setattr( + "ai_rules.bootstrap.installer._is_recall_configured", + lambda c: True, + ) + monkeypatch.setattr( + "ai_rules.bootstrap.installer.is_command_available", + lambda cmd: True, + ) + monkeypatch.setattr( + "ai_rules.bootstrap.installer.get_effective_install_source", + lambda tid: (ToolSource.LOCAL, "~/dev/recall"), + ) + monkeypatch.setattr( + "ai_rules.bootstrap.installer.install_tool", + lambda *a, **kw: (True, "ok"), + ) + status, msg = ensure_recall_installed( + config=SimpleNamespace(mcp_overrides={"recall": {}}) + ) + assert status == "upgraded" + + +class _MockTraversable: + """Mock for importlib.resources traversable that returns empty mcps.json.""" + + def __init__(self, data: dict): + self._data = data + + def __truediv__(self, other): + return _MockTraversable(self._data) + + def is_file(self): + return True + + def read_text(self): + import json + + return json.dumps(self._data) diff --git a/tests/unit/test_bootstrap_updater.py b/tests/unit/test_bootstrap_updater.py index 2a8fe19..eae5dca 100644 --- a/tests/unit/test_bootstrap_updater.py +++ b/tests/unit/test_bootstrap_updater.py @@ -1,7 +1,11 @@ """Tests for update checking and application utilities.""" +from __future__ import annotations + import subprocess +from collections.abc import Callable + import pytest from ai_rules.bootstrap.installer import UV_NOT_FOUND_ERROR, ToolSource @@ -375,3 +379,47 @@ def spy_run(*args, **kwargs): assert success is True assert was_upgraded is False assert not subprocess_called + + +@pytest.mark.unit +@pytest.mark.bootstrap +class TestIsEnabledFiltering: + """Tests for is_enabled filtering logic in upgrade command.""" + + @staticmethod + def _make_tool( + tool_id: str, is_enabled: Callable[[], bool] | None = None + ) -> ToolSpec: + return ToolSpec( + tool_id=tool_id, + package_name=f"{tool_id}-pkg", + display_name=tool_id, + get_version=lambda: "1.0.0", + is_installed=lambda: True, + is_enabled=is_enabled, + ) + + def test_disabled_tool_excluded_in_default_upgrade(self): + tools = [self._make_tool("recall", is_enabled=lambda: False)] + tools = [t for t in tools if t.is_installed()] + tools = [t for t in tools if t.is_enabled is None or t.is_enabled()] + assert len(tools) == 0 + + def test_enabled_tool_included(self): + tools = [self._make_tool("recall", is_enabled=lambda: True)] + tools = [t for t in tools if t.is_installed()] + tools = [t for t in tools if t.is_enabled is None or t.is_enabled()] + assert len(tools) == 1 + + def test_disabled_tool_still_included_when_explicitly_targeted(self): + """When user passes --only=recall, is_enabled is not checked.""" + resolved_only = "recall" + tools = [self._make_tool("recall", is_enabled=lambda: False)] + tools = [ + t for t in tools if resolved_only is None or t.tool_id == resolved_only + ] + tools = [t for t in tools if t.is_installed()] + # is_enabled check only runs when resolved_only is None + if resolved_only is None: + tools = [t for t in tools if t.is_enabled is None or t.is_enabled()] + assert len(tools) == 1 From c9379f268fbebd5649517b53502f238f8edc3b8a Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Fri, 1 May 2026 16:51:09 -0400 Subject: [PATCH 2/3] fix: align Claude Code settings template with documented schema Claude Code drops undocumented settings keys during merges, which made the generated template drift from the settings it actually preserves. Keep the template limited to documented/supported keys and preserve hooks explicitly for user-managed entries. --- src/ai_rules/config/claude/settings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ai_rules/config/claude/settings.json b/src/ai_rules/config/claude/settings.json index ee3f43c..05b88a8 100644 --- a/src/ai_rules/config/claude/settings.json +++ b/src/ai_rules/config/claude/settings.json @@ -135,10 +135,10 @@ "type": "command", "command": "claude-statusline" }, + "viewMode": "verbose", "alwaysThinkingEnabled": true, "showThinkingSummaries": true, "skipDangerousModePermissionPrompt": true, "skipAutoPermissionPrompt": true, - "viewMode": "verbose", "hooks": {} } From 7143c2e37c65c31b2dd69bb4ffe3c885e2b305c3 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Wed, 6 May 2026 14:23:38 -0400 Subject: [PATCH 3/3] fix: scope recall install config to selected profile Recall install source resolution depended on the active profile even when install was operating on an explicitly requested profile. Use the loaded profile config for tool source lookup so dry-runs and profile installs resolve recall consistently. Make recall guidance conditional so default users do not inherit personal recall assumptions. --- src/ai_rules/bootstrap/installer.py | 19 ++++++--- src/ai_rules/cli.py | 14 +++---- src/ai_rules/config/AGENTS.md | 4 +- src/ai_rules/config/skills/kb/SKILL.md | 12 +++--- tests/unit/test_bootstrap_installer.py | 57 ++++++++++++++++++++++++-- 5 files changed, 81 insertions(+), 25 deletions(-) diff --git a/src/ai_rules/bootstrap/installer.py b/src/ai_rules/bootstrap/installer.py index 312b263..b64a4fb 100644 --- a/src/ai_rules/bootstrap/installer.py +++ b/src/ai_rules/bootstrap/installer.py @@ -271,18 +271,19 @@ def get_tool_version(tool_name: str) -> str | None: def get_effective_install_source( - tool_id: str, cli_github_flag: bool = False + tool_id: str, cli_github_flag: bool = False, config: object | None = None ) -> tuple[ToolSource, str | None]: """Resolve the install source for a tool. Priority (highest first): 1. cli_github_flag — explicit session override (--github flag) - 2. Merged config (user config > active profile) managed_tools.install_sources + 2. Provided config or merged active config managed_tools.install_sources 3. Default: (ToolSource.PYPI, None) Args: tool_id: Tool identifier (e.g., "statusline", "ai-agent-rules") cli_github_flag: True if --github was passed on the CLI + config: Optional already-loaded Config to avoid active-profile lookups Returns: Tuple of (ToolSource, local_path). local_path is set only for LOCAL source. @@ -290,10 +291,16 @@ def get_effective_install_source( if cli_github_flag: return ToolSource.GITHUB, None try: - from ai_rules.config import Config + if config is None: + from ai_rules.config import Config - config = Config.load() - source = config.get_tool_install_source(tool_id) + config = Config.load() + + source_getter = getattr(config, "get_tool_install_source", None) + if not callable(source_getter): + return ToolSource.PYPI, None + + source = source_getter(tool_id) if source == "github": return ToolSource.GITHUB, None if source == "pypi": @@ -465,7 +472,7 @@ def ensure_recall_installed( if config is not None and not _is_recall_configured(config): return "skipped", None - source, local_path = get_effective_install_source("recall") + source, local_path = get_effective_install_source("recall", config=config) from_github = source == ToolSource.GITHUB if is_command_available("recall"): diff --git a/src/ai_rules/cli.py b/src/ai_rules/cli.py index 40ac28e..d05f816 100644 --- a/src/ai_rules/cli.py +++ b/src/ai_rules/cli.py @@ -1115,6 +1115,12 @@ def install( console.print(f"[red]Error:[/red] {e}") sys.exit(1) + if not dry_run: + set_active_profile(profile) + + if profile and profile != "default": + console.print(f"[dim]Using profile: {profile}[/dim]\n") + recall_result, recall_message = ensure_recall_installed( dry_run=dry_run, config=config ) @@ -1136,13 +1142,7 @@ def install( "[yellow]⚠[/yellow] Failed to install recall (continuing anyway)\n" ) - if not dry_run: - set_active_profile(profile) - - if profile and profile != "default": - console.print(f"[dim]Using profile: {profile}[/dim]\n") - - sl_source, sl_local_path = get_effective_install_source("statusline") + sl_source, sl_local_path = get_effective_install_source("statusline", config=config) statusline_result, statusline_message = ensure_statusline_installed( dry_run=dry_run, from_github=sl_source == ToolSource.GITHUB, diff --git a/src/ai_rules/config/AGENTS.md b/src/ai_rules/config/AGENTS.md index f5d92c9..b1a7f87 100644 --- a/src/ai_rules/config/AGENTS.md +++ b/src/ai_rules/config/AGENTS.md @@ -177,9 +177,9 @@ Three similar lines > premature abstraction | No helpers for one-time ops | Only ### Persistent Knowledge Base (recall) -A persistent markdown knowledge base exists at `~/.recall/`, powered by the recall MCP server with FTS5 full-text search with BM25 ranking. Knowledge persists across sessions, repos, and machines via git sync. +When the recall MCP is configured, it provides a persistent markdown knowledge base at `~/.recall/` with FTS5 full-text search and BM25 ranking. Knowledge persists across sessions, repos, and machines via git sync. -**Write-back loop:** When you synthesize a useful answer, debug a non-obvious issue, or the user corrects a false assumption, consider persisting it via `write_note`. Search first to avoid duplicates. Invoke the `/kb` skill for formatting conventions. +**Write-back loop:** When recall tools are available and you synthesize a useful answer, debug a non-obvious issue, or the user corrects a false assumption, consider persisting it via `write_note`. Search first to avoid duplicates. Invoke the `/kb` skill for formatting conventions. **Quality conventions:** Use `[GAP: ...]` annotations for unverified claims. Use `[misconception]` tags to document what is NOT true (prevents confident false assertions). Use `[promote]` to flag patterns worth promoting to AGENTS.md rules. diff --git a/src/ai_rules/config/skills/kb/SKILL.md b/src/ai_rules/config/skills/kb/SKILL.md index 3cc146d..913d562 100644 --- a/src/ai_rules/config/skills/kb/SKILL.md +++ b/src/ai_rules/config/skills/kb/SKILL.md @@ -1,16 +1,16 @@ --- name: kb description: >- - This skill should be used when the user asks to "save to knowledge base", - "write a note", "persist this", "remember this pattern", "update the KB", - or when the Stop hook instructs the agent to persist session knowledge. - Also use when asking "search knowledge base", "what do we know about", - or needing cross-project context from recall. + This skill should be used when recall MCP tools are available and the user + asks to "save to knowledge base", "write a note", "persist this", "remember + this pattern", "update the KB", or when the Stop hook instructs the agent to + persist session knowledge. Also use when asking "search knowledge base", + "what do we know about", or needing cross-project context from recall. --- # Knowledge Base (recall) -A persistent markdown knowledge base at `~/.recall/` powered by the recall MCP server. Knowledge persists across sessions, repos, and machines via git sync. Searchable with FTS5 full-text search with BM25 ranking. +When configured, recall provides a persistent markdown knowledge base at `~/.recall/`. Knowledge persists across sessions, repos, and machines via git sync. Searchable with FTS5 full-text search with BM25 ranking. ## Workflow diff --git a/tests/unit/test_bootstrap_installer.py b/tests/unit/test_bootstrap_installer.py index 28cabd1..1ffcedc 100644 --- a/tests/unit/test_bootstrap_installer.py +++ b/tests/unit/test_bootstrap_installer.py @@ -489,6 +489,22 @@ def test_config_local_returns_local_with_path(self, monkeypatch): assert source == ToolSource.LOCAL assert local_path == "~/Development/recall" + def test_passed_config_is_used_without_loading_active_profile(self, monkeypatch): + """An explicit config avoids active-profile source lookups.""" + from ai_rules.config import Config + + def _raise(*args, **kwargs): + raise RuntimeError("should not load active profile") + + mock_config = MagicMock() + mock_config.get_tool_install_source.return_value = "local:~/Development/recall" + monkeypatch.setattr(Config, "load", _raise) + + source, local_path = get_effective_install_source("recall", config=mock_config) + + assert source == ToolSource.LOCAL + assert local_path == "~/Development/recall" + def test_defaults_to_pypi_when_nothing_configured(self, monkeypatch): """Falls back to PYPI when no config and no CLI flag.""" from ai_rules.config import Config @@ -571,7 +587,7 @@ def test_returns_already_installed_when_available(self, monkeypatch): ) monkeypatch.setattr( "ai_rules.bootstrap.installer.get_effective_install_source", - lambda tid: (ToolSource.PYPI, None), + lambda *args, **kwargs: (ToolSource.PYPI, None), ) from ai_rules.bootstrap import updater @@ -597,7 +613,7 @@ def test_installs_on_fresh_install(self, monkeypatch): ) monkeypatch.setattr( "ai_rules.bootstrap.installer.get_effective_install_source", - lambda tid: (ToolSource.PYPI, None), + lambda *args, **kwargs: (ToolSource.PYPI, None), ) monkeypatch.setattr( "ai_rules.bootstrap.installer.install_tool", @@ -620,7 +636,7 @@ def test_local_install_failure_returns_failed(self, monkeypatch): ) monkeypatch.setattr( "ai_rules.bootstrap.installer.get_effective_install_source", - lambda tid: (ToolSource.LOCAL, "~/dev/recall"), + lambda *args, **kwargs: (ToolSource.LOCAL, "~/dev/recall"), ) monkeypatch.setattr( "ai_rules.bootstrap.installer.install_tool", @@ -643,7 +659,7 @@ def test_local_install_success_returns_upgraded(self, monkeypatch): ) monkeypatch.setattr( "ai_rules.bootstrap.installer.get_effective_install_source", - lambda tid: (ToolSource.LOCAL, "~/dev/recall"), + lambda *args, **kwargs: (ToolSource.LOCAL, "~/dev/recall"), ) monkeypatch.setattr( "ai_rules.bootstrap.installer.install_tool", @@ -654,6 +670,39 @@ def test_local_install_success_returns_upgraded(self, monkeypatch): ) assert status == "upgraded" + def test_uses_passed_config_for_local_source(self, monkeypatch): + captured = {} + + class ConfigWithRecallSource: + mcp_overrides = {"recall": {"command": "recall"}} + + def get_tool_install_source(self, tool_id): + return "local:/tmp/recall" + + monkeypatch.setattr( + "ai_rules.bootstrap.installer._is_recall_configured", + lambda c: True, + ) + monkeypatch.setattr( + "ai_rules.bootstrap.installer.is_command_available", + lambda cmd: True, + ) + + def install_spy(*args, **kwargs): + captured.update(kwargs) + return True, "ok" + + monkeypatch.setattr( + "ai_rules.bootstrap.installer.install_tool", + install_spy, + ) + + status, msg = ensure_recall_installed(config=ConfigWithRecallSource()) + + assert status == "upgraded" + assert msg == "reinstalled from local path" + assert captured["local_path"] == "/tmp/recall" + class _MockTraversable: """Mock for importlib.resources traversable that returns empty mcps.json."""