diff --git a/AGENTS.md b/AGENTS.md index 30dfdf0..6c268d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -197,7 +197,7 @@ uv run pytest -m state # State management tests only ## Skills -**Skills:** Explore `config/skills/*/SKILL.md` for available skills (10 total: agents-md, code-reviewer, continue-crash, crossfire, dev-docs, doc-writer, pr-creator, prompt-critique, prompt-engineer, test-writer). +**Skills:** Explore `config/skills/*/SKILL.md` for available skills (11 total: agents-md, code-reviewer, continue-crash, crossfire, dev-docs, doc-writer, kb, pr-creator, prompt-critique, prompt-engineer, test-writer). - **SHARED across agents** - symlinked to `~/.claude/skills/`, `~/.config/goose/skills/`, `~/.config/agents/skills/` (Amp), `~/.agents/skills/` (Codex) - Managed by SharedAgent (displays under "Shared:" in status) - To add a skill: Create subdir in `config/skills/` with `SKILL.md` diff --git a/src/ai_rules/bootstrap/__init__.py b/src/ai_rules/bootstrap/__init__.py index df4f655..34d23e9 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_basic_memory_installed, ensure_statusline_installed, get_effective_install_source, get_tool_config_dir, @@ -35,6 +36,7 @@ "parse_version", "UV_NOT_FOUND_ERROR", "ToolSource", + "ensure_basic_memory_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 88ac9fe..5eea332 100644 --- a/src/ai_rules/bootstrap/installer.py +++ b/src/ai_rules/bootstrap/installer.py @@ -35,6 +35,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/" +BASIC_MEMORY_GITHUB_REPO = "basicmachines-co/basic-memory" def _validate_package_name(package_name: str) -> bool: @@ -395,3 +396,144 @@ def ensure_statusline_installed( return "failed", None except Exception: return "failed", None + + +def _run_basic_memory_setup(verbose: bool = False) -> None: + """Run the idempotent basic-memory setup script (git init, GitHub remote). + + Reads basic_memory config from ~/.ai-rules-config.yaml and passes + as env vars to the setup script. + """ + setup_script = ( + Path(__file__).parent.parent + / "config" + / "claude" + / "hooks" + / "basic-memory-setup.sh" + ) + if not setup_script.exists(): + return + + env = dict(os.environ) + try: + import yaml + + user_config_path = Path.home() / ".ai-rules-config.yaml" + if user_config_path.exists(): + with open(user_config_path) as f: + user_config = yaml.safe_load(f) or {} + bm_config = user_config.get("basic_memory", {}) + if bm_config.get("repo"): + env["BASIC_MEMORY_WIKI_REPO"] = bm_config["repo"] + if bm_config.get("path"): + env["BASIC_MEMORY_HOME"] = str(Path(bm_config["path"]).expanduser()) + except Exception: + pass + + try: + subprocess.run( + ["bash", str(setup_script)], + timeout=60, + capture_output=not verbose, + env=env, + ) + except (subprocess.TimeoutExpired, Exception): + pass + + +def _is_basic_memory_configured(config: object) -> bool: + """Check if basic-memory is configured in the merged MCP config. + + Checks both profile mcp_overrides and the base mcps.json file. + """ + if hasattr(config, "mcp_overrides") and "basic-memory" 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 "basic-memory" in data: + return True + except Exception: + pass + + return False + + +def ensure_basic_memory_installed( + dry_run: bool = False, + from_github: bool = False, + config: object | None = None, +) -> tuple[str, str | None]: + """Install or upgrade basic-memory if needed. Runs setup script after. Fails open. + + Args: + dry_run: If True, show what would be done without executing + from_github: Install from GitHub instead of PyPI + config: Config object; if provided and basic-memory is not configured, skip + + Returns: + Tuple of (status, message) where status is: + "already_installed", "installed", "upgraded", "upgrade_available", "failed", or "skipped" + """ + if config is not None and not _is_basic_memory_configured(config): + return "skipped", None + if is_command_available("basic-memory"): + try: + from ai_rules.bootstrap.updater import ( + check_tool_updates, + get_tool_by_id, + perform_tool_upgrade, + ) + + bm_tool = get_tool_by_id("basic-memory") + if bm_tool: + update_info = check_tool_updates(bm_tool, timeout=10) + if update_info and update_info.has_update: + if dry_run: + return ( + "upgrade_available", + f"Would upgrade basic-memory {update_info.current_version} → {update_info.latest_version}", + ) + success, msg, _ = perform_tool_upgrade(bm_tool) + if success: + if not dry_run: + _run_basic_memory_setup() + return ( + "upgraded", + f"{update_info.current_version} → {update_info.latest_version}", + ) + except Exception: + pass + if not dry_run: + _run_basic_memory_setup() + return "already_installed", None + + try: + success, message = install_tool( + "basic-memory", + from_github=from_github, + github_url=make_github_install_url(BASIC_MEMORY_GITHUB_REPO) + if from_github + else None, + force=False, + dry_run=dry_run, + ) + if success: + if not dry_run: + _run_basic_memory_setup(verbose=True) + 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 f80b232..9f62256 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 ( + BASIC_MEMORY_GITHUB_REPO, UV_NOT_FOUND_ERROR, ToolSource, + _is_basic_memory_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: @@ -387,6 +390,19 @@ def perform_tool_upgrade(tool: ToolSpec) -> tuple[bool, str, bool]: return False, f"Unexpected error: {e}", False +def _is_basic_memory_configured_for_active_profile() -> bool: + """Check if basic-memory 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_basic_memory_configured(config) + except Exception: + return False + + _SELF_SPEC = ToolSpec( tool_id="ai-agent-rules", package_name="ai-agent-rules", @@ -396,6 +412,16 @@ def perform_tool_upgrade(tool: ToolSpec) -> tuple[bool, str, bool]: github_repo=_SELF_GITHUB_REPO, ) +_BASIC_MEMORY_SPEC = ToolSpec( + tool_id="basic-memory", + package_name="basic-memory", + display_name="basic-memory", + get_version=lambda: get_tool_version("basic-memory"), + is_installed=lambda: is_command_available("basic-memory"), + github_repo=BASIC_MEMORY_GITHUB_REPO, + is_enabled=lambda: _is_basic_memory_configured_for_active_profile(), +) + def get_updatable_tools() -> list[ToolSpec]: """Get all updatable tool specs, deriving from registered Tool classes.""" @@ -404,6 +430,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(_BASIC_MEMORY_SPEC) return tools diff --git a/src/ai_rules/cli.py b/src/ai_rules/cli.py index 1b3b721..3ee0bf8 100644 --- a/src/ai_rules/cli.py +++ b/src/ai_rules/cli.py @@ -456,6 +456,20 @@ 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("basic-memory"): + bm_version = get_tool_version("basic-memory") + if bm_version: + console.print(f"basic-memory, version {bm_version}") + else: + console.print( + "basic-memory, version [dim](installed, version unknown)[/dim]" + ) + except Exception as e: + logger.debug(f"Failed to get basic-memory version: {e}") + try: from ai_rules.bootstrap import check_tool_updates, get_tool_by_id @@ -1046,6 +1060,7 @@ def install( from rich.prompt import Confirm from ai_rules.bootstrap import ( + ensure_basic_memory_installed, ensure_statusline_installed, get_effective_install_source, ) @@ -1101,6 +1116,19 @@ def install( console.print(f"[red]Error:[/red] {e}") sys.exit(1) + bm_result, bm_message = ensure_basic_memory_installed( + dry_run=dry_run, config=config + ) + if bm_result == "installed": + if dry_run and bm_message: + console.print(f"[dim]{bm_message}[/dim]\n") + else: + console.print("[green]✓[/green] Installed basic-memory\n") + elif bm_result == "failed": + console.print( + "[yellow]⚠[/yellow] Failed to install basic-memory (continuing anyway)\n" + ) + if not dry_run: set_active_profile(profile) @@ -1660,12 +1688,21 @@ def status(agents: str | None) -> None: console.print("[bold cyan]Optional Tools[/bold cyan]\n") from ai_rules.bootstrap import is_command_available - statusline_missing = False + optional_tools_missing = False if is_command_available("claude-statusline"): console.print(" [green]✓[/green] claude-statusline installed") else: console.print(" [yellow]○[/yellow] claude-statusline not installed") - statusline_missing = True + optional_tools_missing = True + + from ai_rules.bootstrap.installer import _is_basic_memory_configured + + if _is_basic_memory_configured(config): + if is_command_available("basic-memory"): + console.print(" [green]✓[/green] basic-memory installed") + else: + console.print(" [yellow]○[/yellow] basic-memory not installed") + optional_tools_missing = True console.print() @@ -1707,7 +1744,7 @@ def status(agents: str | None) -> None: "[yellow]💡 Run 'ai-agent-rules install' to fix issues[/yellow]" ) sys.exit(1) - elif statusline_missing: + elif optional_tools_missing: console.print("[green]All symlinks are correct![/green]") console.print( "[yellow]💡 Run 'ai-agent-rules install' to install optional tools[/yellow]" @@ -1838,7 +1875,7 @@ def list_agents_cmd() -> None: ) @click.option( "--only", - type=click.Choice(["ai-agent-rules", "ai-rules", "statusline"]), + type=click.Choice(["ai-agent-rules", "ai-rules", "statusline", "basic-memory"]), help="Only upgrade specific tool", ) def upgrade( @@ -1875,6 +1912,8 @@ def upgrade( if resolved_only is None or t.tool_id == resolved_only ] tools = [t for t in all_tools if t.is_installed()] + if resolved_only is None: + tools = [t for t in tools if t.is_enabled is None or t.is_enabled()] missing_tools = [t for t in all_tools if not t.is_installed()] for tool in missing_tools: diff --git a/src/ai_rules/config/AGENTS.md b/src/ai_rules/config/AGENTS.md index e9a7c15..70e07c6 100644 --- a/src/ai_rules/config/AGENTS.md +++ b/src/ai_rules/config/AGENTS.md @@ -133,6 +133,16 @@ 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 (basic-memory) + +A persistent markdown knowledge base exists at `~/basic-memory/`, powered by the basic-memory MCP server with hybrid BM25+vector search. 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 hybrid search, `build_context(url)` for graph traversal, `read_note(identifier)` for specific notes, `write_note(title, directory, content)` to persist knowledge. + --- ## Technical Standards diff --git a/src/ai_rules/config/claude/hooks/basic-memory-post-write.sh b/src/ai_rules/config/claude/hooks/basic-memory-post-write.sh new file mode 100755 index 0000000..9b5ce37 --- /dev/null +++ b/src/ai_rules/config/claude/hooks/basic-memory-post-write.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# Commit + push knowledge base changes after a basic-memory write. +# Called by PostToolUse hook on mcp__basic-memory__* tools. +WIKI_DIR="${BASIC_MEMORY_HOME:-$HOME/basic-memory}" +[ -d "$WIKI_DIR/.git" ] || exit 0 +cd "$WIKI_DIR" +git add -A +git diff --cached --quiet && exit 0 +git commit -m "auto: update knowledge base" +nohup git push >/dev/null 2>&1 & +exit 0 diff --git a/src/ai_rules/config/claude/hooks/basic-memory-runner.sh b/src/ai_rules/config/claude/hooks/basic-memory-runner.sh new file mode 100755 index 0000000..ce1e1de --- /dev/null +++ b/src/ai_rules/config/claude/hooks/basic-memory-runner.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Wrapper: sync pending changes, pull latest, then launch basic-memory MCP server. +# Used as the MCP command in mcps.json for all agents. +export PATH="$HOME/.local/bin:$PATH" +WIKI_DIR="${BASIC_MEMORY_HOME:-$HOME/basic-memory}" +if [ -d "$WIKI_DIR/.git" ]; then + ( + cd "$WIKI_DIR" + git push >/dev/null 2>&1 || true + git pull --rebase --autostash >/dev/null 2>&1 || git rebase --abort >/dev/null 2>&1 + ) & +fi +exec basic-memory mcp "$@" diff --git a/src/ai_rules/config/claude/hooks/basic-memory-setup.sh b/src/ai_rules/config/claude/hooks/basic-memory-setup.sh new file mode 100755 index 0000000..48bb578 --- /dev/null +++ b/src/ai_rules/config/claude/hooks/basic-memory-setup.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# Idempotent setup: ensure ~/basic-memory is a git repo with GitHub remote. +# Safe to run on every `ai-rules install` / `ai-rules upgrade`. +# Handles: fresh install, second machine, repo migration, graceful degradation. +set -euo pipefail + +WIKI_DIR="${BASIC_MEMORY_HOME:-$HOME/basic-memory}" +REPO_NAME="${BASIC_MEMORY_WIKI_REPO:-basic-memory-wiki}" + +resolve_repo() { + local name="$1" + case "$name" in + */*) echo "$name" ;; + *) + local user + user=$(gh api user --jq '.login' 2>/dev/null) || return 1 + [ -n "$user" ] && echo "$user/$name" || return 1 + ;; + esac +} + +check_gh() { + command -v gh >/dev/null 2>&1 || { echo "⚠ gh CLI not found — skipping GitHub remote setup. Knowledge base works locally."; return 1; } + gh auth status >/dev/null 2>&1 || { echo "⚠ gh not authenticated — run 'gh auth login' for cross-machine sync."; return 1; } + return 0 +} + +mkdir -p "$WIKI_DIR" +for dir in repos projects patterns decisions preferences references references/block people feedback; do + mkdir -p "$WIKI_DIR/$dir" + [ -f "$WIKI_DIR/$dir/.gitkeep" ] || touch "$WIKI_DIR/$dir/.gitkeep" +done + +if [ ! -d "$WIKI_DIR/.git" ]; then + cd "$WIKI_DIR" + git init + if ! git config --global user.name >/dev/null 2>&1; then + git config --local user.name "AI Agent" + git config --local user.email "agent@local" + fi + git add -A + git commit -m "feat: initialize knowledge base" --allow-empty +fi + +cd "$WIKI_DIR" + +CURRENT_REMOTE=$(git remote get-url origin 2>/dev/null || echo "") + +if [ -z "$CURRENT_REMOTE" ]; then + check_gh || exit 0 + FULL_REPO=$(resolve_repo "$REPO_NAME") || { echo "⚠ Could not detect GitHub username — skipping remote setup."; exit 0; } + + if gh repo view "$FULL_REPO" >/dev/null 2>&1; then + git remote add origin "git@github.com:$FULL_REPO.git" + git fetch origin + git branch --set-upstream-to=origin/main main 2>/dev/null || true + git pull --rebase --autostash >/dev/null 2>&1 || git rebase --abort >/dev/null 2>&1 + else + gh repo create "$FULL_REPO" --private --description "Persistent LLM knowledge base" + git remote add origin "git@github.com:$FULL_REPO.git" + fi + git push -u origin main 2>/dev/null || true + +else + check_gh || exit 0 + FULL_REPO=$(resolve_repo "$REPO_NAME") || exit 0 + EXPECTED_URL="git@github.com:$FULL_REPO.git" + + if [ "$CURRENT_REMOTE" != "$EXPECTED_URL" ]; then + echo "Migrating knowledge base remote: $CURRENT_REMOTE → $EXPECTED_URL" + + git fetch origin 2>/dev/null || true + git pull --rebase --autostash >/dev/null 2>&1 || git rebase --abort >/dev/null 2>&1 + + if ! gh repo view "$FULL_REPO" >/dev/null 2>&1; then + gh repo create "$FULL_REPO" --private --description "Persistent LLM knowledge base" + fi + + git remote set-url origin "$EXPECTED_URL" + git push -u origin main 2>/dev/null || true + + OLD_REPO=$(echo "$CURRENT_REMOTE" | sed 's|git@github.com:||;s|\.git$||') + echo "✓ Migrated knowledge base to $FULL_REPO" + echo "⚠ Old repo '$OLD_REPO' still exists on GitHub. Delete manually if no longer needed." + exit 0 + fi +fi + +echo "✓ Knowledge base ready at $WIKI_DIR" diff --git a/src/ai_rules/config/claude/settings.json b/src/ai_rules/config/claude/settings.json index 6cac6b9..3ecd1ae 100644 --- a/src/ai_rules/config/claude/settings.json +++ b/src/ai_rules/config/claude/settings.json @@ -138,5 +138,18 @@ "alwaysThinkingEnabled": true, "showThinkingSummaries": true, "skipDangerousModePermissionPrompt": true, - "viewMode": "verbose" + "viewMode": "verbose", + "hooks": { + "PostToolUse": [ + { + "matcher": "mcp__basic-memory__.*", + "hooks": [ + { + "type": "command", + "command": "~/.claude/hooks/basic-memory-post-write.sh" + } + ] + } + ] + } } diff --git a/src/ai_rules/config/profiles/personal.yaml b/src/ai_rules/config/profiles/personal.yaml index c049c7e..f42cd5c 100644 --- a/src/ai_rules/config/profiles/personal.yaml +++ b/src/ai_rules/config/profiles/personal.yaml @@ -8,4 +8,11 @@ settings_overrides: terminal_title: enabled: true exclude_symlinks: [] -mcp_overrides: {} +mcp_overrides: + basic-memory: + type: stdio + command: /bin/bash + args: + - ~/.claude/hooks/basic-memory-runner.sh + name: basic-memory + description: Persistent markdown knowledge base with BM25+vector search and git sync diff --git a/src/ai_rules/config/profiles/work.yaml b/src/ai_rules/config/profiles/work.yaml index 0606f67..9ff0773 100644 --- a/src/ai_rules/config/profiles/work.yaml +++ b/src/ai_rules/config/profiles/work.yaml @@ -26,4 +26,11 @@ settings_overrides: useFullWidth: true exclude_symlinks: - ~/.config/goose/config.yaml -mcp_overrides: {} +mcp_overrides: + basic-memory: + type: stdio + command: /bin/bash + args: + - ~/.claude/hooks/basic-memory-runner.sh + name: basic-memory + description: Persistent markdown knowledge base with BM25+vector 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..ebc9180 --- /dev/null +++ b/src/ai_rules/config/skills/kb/SKILL.md @@ -0,0 +1,184 @@ +--- +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 basic-memory. +--- + +# Knowledge Base (basic-memory) + +A persistent markdown knowledge base at `~/basic-memory/` powered by the basic-memory MCP server. Knowledge persists across sessions, repos, and machines via git sync. Searchable with hybrid BM25 + vector search. + +## Workflow + +1. **Search first** to avoid duplicates: `search_notes(query="topic")` +2. **Read existing** if a related note exists: `read_note(identifier="note-title")` +3. **Write or update**: `write_note(title="...", directory="...", content="...")` +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 +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., "basic-memory does NOT require a running database -- it uses plain markdown files") + +### GAP Annotations + +When writing notes with uncertain or unverified information, mark the uncertainty explicitly: + +``` +- [fact] basic-memory uses hybrid BM25 + vector search [GAP: retrieval quality vs alternatives 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(url="memory://note-title")` + +**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. The `PostToolUse` hook auto-commits and pushes after each `write_note` call + +**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. Run manually or when the KB feels cluttered. + +**Lint checks:** +1. **Stale GAPs** -- notes with `[GAP:]` annotations older than 90 days that haven't been verified +2. **Orphan notes** -- notes with no wikilinks in or out (`list_directory()` + `build_context()`) +3. **Contradictions** -- notes with `contradicts [[...]]` relations that haven't been resolved +4. **Version drift** -- notes referencing specific version numbers that may be outdated +5. **Common misconceptions** -- verify `[misconception]` entries are still accurate + +**Running a lint pass:** +1. `recent_activity(depth=50)` -- find recently modified notes +2. `search_notes(query="[GAP:")` -- find unverified gaps +3. `search_notes(query="contradicts")` -- find conflicting notes +4. `list_directory()` -- browse for orphan directories + +**What to do with findings:** +- Stale `[GAP:]`: verify and update, or add `[fact] Unable to verify as of ` +- Orphans: add wikilinks to connect them, or delete if truly obsolete +- Contradictions: reconcile, update the winner, mark the loser with `supersedes` +- Version drift: update or add `[GAP: version not verified as of ]` + +## MCP Tools Quick Reference + +| Tool | Purpose | +|------|---------| +| `search_notes(query, tags?, note_types?)` | Hybrid search across all notes | +| `build_context(url)` | Graph traversal from a note via wikilinks | +| `read_note(identifier)` | Read a specific note by title or permalink | +| `write_note(title, directory, content)` | Create or update a note | +| `edit_note(identifier, find, replace?)` | Partial edit without full rewrite | +| `delete_note(identifier)` | Delete a note (ask user first) | +| `recent_activity(depth?)` | Recently modified notes | +| `list_directory(dir_name?, depth?)` | Browse the folder structure | + +## 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/src/ai_rules/config/skills/kb/references/note-templates.md b/src/ai_rules/config/skills/kb/references/note-templates.md new file mode 100644 index 0000000..a090242 --- /dev/null +++ b/src/ai_rules/config/skills/kb/references/note-templates.md @@ -0,0 +1,270 @@ +# Note Templates by Directory + +Complete templates for each knowledge base directory. Copy the relevant template and fill in the content. + +## repos/ -- Repository Guide + +```markdown +--- +title: repo-name +type: note +tags: [language, framework, block] +--- + +# repo-name + +## Observations +- [fact] Brief description of what this repo does +- [method] Primary build/test command (e.g., `just test` or `./gradlew testUnit`) +- [fact] Language and framework details +- [tip] Non-obvious gotchas or conventions +- [fact] Key configuration files to know about + +## Common Misconceptions +- [misconception] Something commonly assumed but false about this repo + +## Relations +- related_to [[project-name-if-applicable]] +- related_to [[relevant-pattern]] +``` + +**Example:** +```markdown +--- +title: goosed-slackbot +type: note +tags: [kotlin, slackbot, block, migration] +--- + +# goosed-slackbot + +## Observations +- [fact] Slack bot migrating from Python to Kotlin (kgoose) +- [method] Run tests with `just test` (Justfile wraps Gradle) +- [fact] Has 60+ git worktrees for parallel development +- [tip] Check `Justfile` for available commands before guessing +- [tip] Worktree branch names sanitize `/` to `-` + +## Relations +- related_to [[cash-server]] +- related_to [[slackbot-kotlin-migration]] +- related_to [[kotlin-patterns]] +``` + +## patterns/ -- Reusable Technical Pattern + +```markdown +--- +title: pattern-name +type: note +tags: [language, domain] +--- + +# pattern-name + +## Observations +- [fact] What the pattern is +- [method] How to apply it +- [tip] When to use it vs alternatives +- [fact] Why it matters (consequence of ignoring) + +## Relations +- related_to [[relevant-repo]] +- related_to [[related-pattern]] +``` + +**Example:** +```markdown +--- +title: uv-run-not-direct-invocation +type: note +tags: [python, tooling, gotcha] +--- + +# uv-run-not-direct-invocation + +## Observations +- [fact] Always use `uv run pytest` not bare `pytest` in uv-managed projects +- [fact] Always use `uvx ruff check .` not bare `ruff` +- [method] Check for Justfile first -- `just test` wraps the correct invocation +- [tip] Direct tool invocation bypasses project configuration and venv +- [fact] This is the #1 mistake agents make in Python projects + +## Relations +- related_to [[ai-rules]] +- related_to [[python-tooling]] +``` + +## decisions/ -- Architecture Decision Record + +```markdown +--- +title: decision-name +type: note +tags: [domain, scope] +--- + +# decision-name + +## Observations +- [decision] What was decided and the chosen approach +- [fact] What alternatives were considered +- [fact] Why the chosen approach won (key tradeoff) +- [fact] What was explicitly rejected and why +- [tip] Constraints or assumptions this depends on + +## Relations +- related_to [[relevant-project]] +- supersedes [[older-decision-if-any]] +``` + +**Example:** +```markdown +--- +title: raw-sql-over-sqlalchemy +type: note +tags: [architecture, database, python] +--- + +# raw-sql-over-sqlalchemy + +## Observations +- [decision] Use raw SQL (psycopg3) instead of SQLAlchemy ORM for the data layer +- [fact] ORM adds 3-5ms per query, unacceptable for batch operations +- [fact] SQLAlchemy considered but rejected due to N+1 query risk +- [preference] User prefers explicit SQL over magic ORM behavior +- [tip] This decision holds for read-heavy services; write-heavy may revisit + +## Relations +- related_to [[python-patterns]] +- related_to [[data-layer-project]] +``` + +## preferences/ -- User Working Style + +```markdown +--- +title: preference-name +type: note +tags: [domain] +--- + +# preference-name + +## Observations +- [preference] The specific preference or convention +- [fact] Context for why this matters +- [tip] How to apply this in practice + +## Relations +- related_to [[relevant-pattern]] +``` + +**Example:** +```markdown +--- +title: test-docstring-conventions +type: note +tags: [testing, style, python] +--- + +# test-docstring-conventions + +## Observations +- [preference] Never add docstrings to individual test functions +- [preference] Only the test class itself gets a docstring +- [fact] Test function names should be descriptive enough on their own +- [method] Name tests as `test__` + +## Relations +- related_to [[python-patterns]] +- related_to [[testing-conventions]] +``` + +## references/ -- External Knowledge + +```markdown +--- +title: reference-name +type: note +tags: [domain, source] +--- + +# reference-name + +## Observations +- [fact] Key information from the external source +- [method] How to apply this knowledge +- [tip] Gotchas or non-obvious details + +## Relations +- related_to [[relevant-project-or-pattern]] +``` + +For Block-specific knowledge, use `references/block/` as the directory. + +## people/ -- Teammate Context + +```markdown +--- +title: person-name +type: note +tags: [team, domain] +--- + +# person-name + +## Observations +- [fact] Role and primary responsibilities +- [fact] Domain expertise areas +- [tip] Best way to collaborate or communicate +- [fact] Key projects they own or contribute to + +## Relations +- related_to [[project-they-own]] +``` + +## feedback/ -- Corrections and Lessons + +```markdown +--- +title: feedback-name +type: note +tags: [domain] +--- + +# feedback-name + +## Observations +- [fact] What went wrong or what was corrected +- [fact] Why the incorrect assumption was made +- [method] The correct approach going forward +- [tip] How to avoid this mistake in the future + +## Relations +- related_to [[relevant-pattern-or-repo]] +``` + +## projects/ -- Multi-repo Initiative + +```markdown +--- +title: project-name +type: note +tags: [initiative, status] +--- + +# project-name + +## Observations +- [fact] What the initiative is trying to accomplish +- [fact] Which repos are involved +- [fact] Current status and next steps +- [decision] Key architectural choices made +- [tip] Who to ask about specific aspects + +## Relations +- related_to [[repo-1]] +- related_to [[repo-2]] +- related_to [[key-person]] +``` diff --git a/tests/unit/test_bootstrap_installer.py b/tests/unit/test_bootstrap_installer.py index 53ffca0..14e96c7 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_basic_memory_configured, + ensure_basic_memory_installed, get_effective_install_source, get_tool_config_dir, get_tool_source, @@ -384,3 +387,126 @@ def _raise(*args, **kwargs): monkeypatch.setattr(Config, "load", _raise) assert get_effective_install_source("statusline") is False + + +@pytest.mark.unit +@pytest.mark.bootstrap +class TestIsBasicMemoryConfigured: + """Tests for _is_basic_memory_configured helper.""" + + def test_returns_true_when_in_mcp_overrides(self): + config = SimpleNamespace( + mcp_overrides={"basic-memory": {"command": "/bin/bash"}} + ) + assert _is_basic_memory_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_basic_memory_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_basic_memory_configured(config) is False + + +@pytest.mark.unit +@pytest.mark.bootstrap +class TestEnsureBasicMemoryInstalled: + """Tests for ensure_basic_memory_installed function.""" + + def test_skips_when_not_configured(self, monkeypatch): + config = SimpleNamespace(mcp_overrides={}) + monkeypatch.setattr( + "ai_rules.bootstrap.installer._is_basic_memory_configured", + lambda c: False, + ) + status, msg = ensure_basic_memory_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_basic_memory_configured", + lambda c: True, + ) + monkeypatch.setattr( + "ai_rules.bootstrap.installer.is_command_available", + lambda cmd: True, + ) + monkeypatch.setattr( + "ai_rules.bootstrap.installer._run_basic_memory_setup", + lambda **kw: None, + ) + + from ai_rules.bootstrap import updater + + monkeypatch.setattr( + updater, + "get_tool_by_id", + lambda tid: None, + ) + status, msg = ensure_basic_memory_installed( + config=SimpleNamespace(mcp_overrides={"basic-memory": {}}) + ) + assert status == "already_installed" + + def test_setup_called_on_fresh_install(self, monkeypatch): + setup_calls = [] + monkeypatch.setattr( + "ai_rules.bootstrap.installer._is_basic_memory_configured", + lambda c: True, + ) + monkeypatch.setattr( + "ai_rules.bootstrap.installer.is_command_available", + lambda cmd: cmd != "basic-memory", + ) + monkeypatch.setattr( + "ai_rules.bootstrap.installer._run_basic_memory_setup", + lambda **kw: setup_calls.append(kw), + ) + monkeypatch.setattr( + "ai_rules.bootstrap.installer.install_tool", + lambda *a, **kw: (True, "ok"), + ) + monkeypatch.setattr( + "ai_rules.bootstrap.installer.make_github_install_url", + lambda repo: f"https://github.com/{repo}", + ) + status, msg = ensure_basic_memory_installed( + config=SimpleNamespace(mcp_overrides={"basic-memory": {}}) + ) + assert status == "installed" + assert len(setup_calls) == 1 + assert setup_calls[0].get("verbose") is True + + +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 aa86c62..0728e05 100644 --- a/tests/unit/test_bootstrap_updater.py +++ b/tests/unit/test_bootstrap_updater.py @@ -2,6 +2,8 @@ import subprocess +from collections.abc import Callable + import pytest from ai_rules.bootstrap.installer import UV_NOT_FOUND_ERROR, ToolSource @@ -313,3 +315,53 @@ class Result: assert success is True assert was_upgraded is True + + +@pytest.mark.unit +@pytest.mark.bootstrap +class TestIsEnabledFiltering: + """Tests that is_enabled is respected in upgrade filtering (cli.py pattern).""" + + def _make_tool( + self, tool_id: str, is_enabled: Callable[[], bool] | None = None + ) -> ToolSpec: + return ToolSpec( + tool_id=tool_id, + package_name=tool_id, + display_name=tool_id, + get_version=lambda: "1.0.0", + is_installed=lambda: True, + github_repo="org/repo", + is_enabled=is_enabled, + ) + + def test_disabled_tool_excluded_in_default_upgrade(self): + tools = [self._make_tool("basic-memory", 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_tool_with_no_gate_always_included(self): + tools = [self._make_tool("statusline", is_enabled=None)] + 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_enabled_tool_included(self): + tools = [self._make_tool("basic-memory", 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=basic-memory, is_enabled is not checked.""" + resolved_only = "basic-memory" + tools = [self._make_tool("basic-memory", 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