Browse Source

Adding cartography (#87)

Alvin 2 months ago
parent
commit
ac48a0c427

+ 26 - 0
.gitignore

@@ -45,3 +45,29 @@ local
 .ignore
 .ignore
 opencode
 opencode
 oh-my-opencode
 oh-my-opencode
+
+# Cartography
+.slim/
+
+# Python
+__pycache__/
+*.py[cod]
+*$py.class
+*.so
+.Python
+env/
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+*.egg-info/
+.installed.cfg
+*.egg

+ 55 - 0
README.md

@@ -635,11 +635,19 @@ npx skills add https://github.com/brianlovin/claude-config --skill simplify -a o
 
 
 ### Available Skills
 ### Available Skills
 
 
+#### Recommended Skills (via npx)
+
 | Skill | Description | Assigned To |
 | Skill | Description | Assigned To |
 |-------|-------------|-------------|
 |-------|-------------|-------------|
 | `simplify` | YAGNI code simplification expert | `orchestrator` |
 | `simplify` | YAGNI code simplification expert | `orchestrator` |
 | `agent-browser` | High-performance browser automation | `designer` |
 | `agent-browser` | High-performance browser automation | `designer` |
 
 
+#### Custom Skills (bundled in repo)
+
+| Skill | Description | Assigned To |
+|-------|-------------|-------------|
+| `cartography` | Repository understanding and hierarchical codemap generation | `orchestrator` |
+
 ### Configuration & Syntax
 ### Configuration & Syntax
 
 
 You can customize which skills each agent is allowed to use in `~/.config/opencode/oh-my-opencode-slim.json`.
 You can customize which skills each agent is allowed to use in `~/.config/opencode/oh-my-opencode-slim.json`.
@@ -688,6 +696,53 @@ You can customize which skills each agent is allowed to use in `~/.config/openco
 
 
 `agent-browser` provides full high-performance browser automation capabilities. It allows agents to browse the web, interact with elements, and capture screenshots for visual state verification.
 `agent-browser` provides full high-performance browser automation capabilities. It allows agents to browse the web, interact with elements, and capture screenshots for visual state verification.
 
 
+### Cartography
+
+**Automated repository mapping through hierarchical codemaps.**
+
+<img src="img/cartography.png" alt="Cartography Skill" width="800" style="border-radius: 10px; margin: 20px 0;">
+
+`cartography` empowers the Orchestrator to build and maintain a deep architectural understanding of any codebase. Instead of reading thousands of lines of code every time, agents refer to hierarchical `codemap.md` files that describe the *why* and *how* of each directory.
+
+**How to use:**
+
+Just ask the **Orchestrator** to `run cartography`. It will automatically detect if it needs to initialize a new map or update an existing one.
+
+**Why it's useful:**
+
+- **Instant Onboarding:** Help agents (and humans) understand unfamiliar codebases in seconds.
+- **Efficient Context:** Agents only read architectural summaries, saving tokens and improving accuracy.
+- **Change Detection:** Only modified folders are re-analyzed, making updates fast and efficient.
+- **Timeless Documentation:** Focuses on high-level design patterns that don't get stale.
+
+<details>
+<summary><b>Technical Details & Manual Control</b></summary>
+
+The skill uses a background Python engine (`cartographer.py`) to manage state and detect changes.
+
+**How it works under the hood:**
+
+1. **Initialize** - Orchestrator analyzes repo structure and runs `init` to create `.slim/cartography.json` (hashes) and empty templates.
+2. **Map** - Orchestrator spawns specialized **Explorer** sub-agents to fill codemaps with timeless architectural details (Responsibility, Design, Flow, Integration).
+3. **Update** - On subsequent runs, the engine detects changed files and only refreshes codemaps for affected folders.
+
+**Manual Commands:**
+
+```bash
+# Initialize mapping manually
+python ~/.config/opencode/skills/cartography/scripts/cartographer.py init \
+  --root . \
+  --include "src/**/*.ts" \
+  --exclude "**/*.test.ts"
+
+# Check for changes since last map
+python ~/.config/opencode/skills/cartography/scripts/cartographer.py changes --root .
+
+# Sync hashes after manual map updates
+python ~/.config/opencode/skills/cartography/scripts/cartographer.py update --root .
+```
+</details>
+
 ---
 ---
 
 
 ## 🔌 MCP Servers
 ## 🔌 MCP Servers

BIN
img/cartography.png


+ 99 - 0
src/cli/custom-skills.ts

@@ -0,0 +1,99 @@
+import {
+  copyFileSync,
+  existsSync,
+  mkdirSync,
+  readdirSync,
+  statSync,
+} from 'node:fs';
+import { join, dirname } from 'node:path';
+import { homedir } from 'node:os';
+
+/**
+ * A custom skill bundled in this repository.
+ * Unlike npx-installed skills, these are copied from src/skills/ to ~/.config/opencode/skills/
+ */
+export interface CustomSkill {
+  /** Skill name (folder name) */
+  name: string;
+  /** Human-readable description */
+  description: string;
+  /** List of agents that should auto-allow this skill */
+  allowedAgents: string[];
+  /** Source path in this repo (relative to project root) */
+  sourcePath: string;
+}
+
+/**
+ * Registry of custom skills bundled in this repository.
+ */
+export const CUSTOM_SKILLS: CustomSkill[] = [
+  {
+    name: 'cartography',
+    description: 'Repository understanding and hierarchical codemap generation',
+    allowedAgents: ['orchestrator'],
+    sourcePath: 'src/skills/cartography',
+  },
+];
+
+/**
+ * Get the target directory for custom skills installation.
+ */
+export function getCustomSkillsDir(): string {
+  return join(homedir(), '.config', 'opencode', 'skills');
+}
+
+/**
+ * Recursively copy a directory.
+ */
+function copyDirRecursive(src: string, dest: string): void {
+  if (!existsSync(dest)) {
+    mkdirSync(dest, { recursive: true });
+  }
+
+  const entries = readdirSync(src);
+  for (const entry of entries) {
+    const srcPath = join(src, entry);
+    const destPath = join(dest, entry);
+    const stat = statSync(srcPath);
+
+    if (stat.isDirectory()) {
+      copyDirRecursive(srcPath, destPath);
+    } else {
+      const destDir = dirname(destPath);
+      if (!existsSync(destDir)) {
+        mkdirSync(destDir, { recursive: true });
+      }
+      copyFileSync(srcPath, destPath);
+    }
+  }
+}
+
+/**
+ * Install a custom skill by copying from src/skills/ to ~/.config/opencode/skills/
+ * @param skill - The custom skill to install
+ * @param projectRoot - Root directory of oh-my-opencode-slim project
+ * @returns True if installation succeeded, false otherwise
+ */
+export function installCustomSkill(
+  skill: CustomSkill,
+  projectRoot: string,
+): boolean {
+  try {
+    const sourcePath = join(projectRoot, skill.sourcePath);
+    const targetPath = join(getCustomSkillsDir(), skill.name);
+
+    // Validate source exists
+    if (!existsSync(sourcePath)) {
+      console.error(`Custom skill source not found: ${sourcePath}`);
+      return false;
+    }
+
+    // Copy skill directory
+    copyDirRecursive(sourcePath, targetPath);
+
+    return true;
+  } catch (error) {
+    console.error(`Failed to install custom skill: ${skill.name}`, error);
+    return false;
+  }
+}

+ 34 - 0
src/cli/install.ts

@@ -10,6 +10,7 @@ import {
   writeLiteConfig,
   writeLiteConfig,
 } from './config-manager';
 } from './config-manager';
 import { RECOMMENDED_SKILLS, installSkill } from './skills';
 import { RECOMMENDED_SKILLS, installSkill } from './skills';
+import { CUSTOM_SKILLS, installCustomSkill } from './custom-skills';
 import type {
 import type {
   BooleanArg,
   BooleanArg,
   ConfigMergeResult,
   ConfigMergeResult,
@@ -157,6 +158,7 @@ function argsToConfig(args: InstallArgs): InstallConfig {
     hasOpencodeZen: true, // Always enabled - free models available to all users
     hasOpencodeZen: true, // Always enabled - free models available to all users
     hasTmux: args.tmux === 'yes',
     hasTmux: args.tmux === 'yes',
     installSkills: args.skills === 'yes',
     installSkills: args.skills === 'yes',
+    installCustomSkills: args.skills === 'yes', // Install custom skills when skills=yes
   };
   };
 }
 }
 
 
@@ -226,12 +228,24 @@ async function runInteractiveMode(
     const skills = await askYesNo(rl, 'Install recommended skills?', 'yes');
     const skills = await askYesNo(rl, 'Install recommended skills?', 'yes');
     console.log();
     console.log();
 
 
+    // Custom skills prompt
+    console.log(`${BOLD}Custom Skills:${RESET}`);
+    for (const skill of CUSTOM_SKILLS) {
+      console.log(
+        `  ${SYMBOLS.bullet} ${BOLD}${skill.name}${RESET}: ${skill.description}`,
+      );
+    }
+    console.log();
+    const customSkills = await askYesNo(rl, 'Install custom skills?', 'yes');
+    console.log();
+
     return {
     return {
       hasAntigravity: antigravity === 'yes',
       hasAntigravity: antigravity === 'yes',
       hasOpenAI: openai === 'yes',
       hasOpenAI: openai === 'yes',
       hasOpencodeZen: true,
       hasOpencodeZen: true,
       hasTmux: false,
       hasTmux: false,
       installSkills: skills === 'yes',
       installSkills: skills === 'yes',
+      installCustomSkills: customSkills === 'yes',
     };
     };
   } finally {
   } finally {
     rl.close();
     rl.close();
@@ -248,6 +262,7 @@ async function runInstall(config: InstallConfig): Promise<number> {
   let totalSteps = 4; // Base: check opencode, add plugin, disable default agents, write lite config
   let totalSteps = 4; // Base: check opencode, add plugin, disable default agents, write lite config
   if (config.hasAntigravity) totalSteps += 1; // provider config only (no auth plugin needed)
   if (config.hasAntigravity) totalSteps += 1; // provider config only (no auth plugin needed)
   if (config.installSkills) totalSteps += 1; // skills installation
   if (config.installSkills) totalSteps += 1; // skills installation
+  if (config.installCustomSkills) totalSteps += 1; // custom skills installation
 
 
   let step = 1;
   let step = 1;
 
 
@@ -292,6 +307,25 @@ async function runInstall(config: InstallConfig): Promise<number> {
     );
     );
   }
   }
 
 
+  // Install custom skills if requested
+  if (config.installCustomSkills) {
+    printStep(step++, totalSteps, 'Installing custom skills...');
+    let customSkillsInstalled = 0;
+    const projectRoot = process.cwd(); // Assumes running from project root
+    for (const skill of CUSTOM_SKILLS) {
+      printInfo(`Installing ${skill.name}...`);
+      if (installCustomSkill(skill, projectRoot)) {
+        printSuccess(`Installed: ${skill.name}`);
+        customSkillsInstalled++;
+      } else {
+        printWarning(`Failed to install: ${skill.name}`);
+      }
+    }
+    printSuccess(
+      `${customSkillsInstalled}/${CUSTOM_SKILLS.length} custom skills installed`,
+    );
+  }
+
   // Summary
   // Summary
   console.log();
   console.log();
   console.log(formatConfigSummary(config));
   console.log(formatConfigSummary(config));

+ 1 - 0
src/cli/types.ts

@@ -21,6 +21,7 @@ export interface InstallConfig {
   hasOpencodeZen: boolean;
   hasOpencodeZen: boolean;
   hasTmux: boolean;
   hasTmux: boolean;
   installSkills: boolean;
   installSkills: boolean;
+  installCustomSkills: boolean;
 }
 }
 
 
 export interface ConfigMergeResult {
 export interface ConfigMergeResult {

+ 57 - 0
src/skills/cartography/README.md

@@ -0,0 +1,57 @@
+# Cartography Skill
+
+Repository understanding and hierarchical codemap generation.
+
+## Overview
+
+Cartography helps orchestrators map and understand codebases by:
+
+1. Selecting relevant code/config files using LLM judgment
+2. Creating `.slim/cartography.json` for change tracking
+3. Generating empty `codemap.md` templates for explorers to fill in
+
+## Commands
+
+```bash
+# Initialize mapping
+python cartographer.py init --root /repo --include "src/**/*.ts" --exclude "node_modules/**"
+
+# Check what changed
+python cartographer.py changes --root /repo
+
+# Update hashes
+python cartographer.py update --root /repo
+```
+
+## Outputs
+
+### .slim/cartography.json
+
+```json
+{
+  "metadata": {
+    "version": "1.0.0",
+    "last_run": "2026-01-25T19:00:00Z",
+    "include_patterns": ["src/**/*.ts"],
+    "exclude_patterns": ["node_modules/**"]
+  },
+  "file_hashes": {
+    "src/index.ts": "abc123..."
+  },
+  "folder_hashes": {
+    "src": "def456..."
+  }
+}
+```
+
+### codemap.md (per folder)
+
+Empty templates created in each folder for explorers to fill with:
+- Responsibility
+- Design patterns
+- Data/control flow
+- Integration points
+
+## Installation
+
+Installed automatically via oh-my-opencode-slim installer when custom skills are enabled.

+ 107 - 0
src/skills/cartography/SKILL.md

@@ -0,0 +1,107 @@
+---
+name: cartography
+description: Repository understanding and hierarchical codemap generation
+---
+
+# Cartography Skill
+
+You help users understand and map repositories by creating hierarchical codemaps.
+
+## When to Use
+
+- User asks to understand/map a repository
+- User wants codebase documentation
+- Starting work on an unfamiliar codebase
+
+## Workflow
+
+### Step 1: Check for Existing State
+
+**First, check if `.slim/cartography.json` exists in the repo root.**
+
+If it **exists**: Skip to Step 3 (Detect Changes) - no need to re-initialize.
+
+If it **doesn't exist**: Continue to Step 2 (Initialize).
+
+### Step 2: Initialize (Only if no state exists)
+
+1. **Analyze the repository structure** - List files, understand directories
+2. **Infer patterns** for **core code/config files ONLY** to include:
+   - **Include**: `src/**/*.ts`, `package.json`, etc.
+   - **Exclude (MANDATORY)**: Do NOT include tests, documentation, or translations.
+     - Tests: `**/*.test.ts`, `**/*.spec.ts`, `tests/**`, `__tests__/**`
+     - Docs: `docs/**`, `*.md` (except root `README.md` if needed), `LICENSE`
+     - Build/Deps: `node_modules/**`, `dist/**`, `build/**`, `*.min.js`
+   - Respect `.gitignore` automatically
+3. **Run cartographer.py init**:
+
+```bash
+python ~/.config/opencode/skills/cartography/scripts/cartographer.py init \
+  --root /path/to/repo \
+  --include "src/**/*.ts" \
+  --exclude "**/*.test.ts" --exclude "dist/**" --exclude "node_modules/**"
+```
+
+This creates:
+- `.slim/cartography.json` - File and folder hashes for change detection
+- Empty `codemap.md` files in all relevant subdirectories
+
+4. **Delegate to Explorer agents** - Spawn one explorer per folder to read code and fill in its specific `codemap.md` file.
+
+### Step 3: Detect Changes (If state already exists)
+
+1. **Run cartographer.py changes** to see what changed:
+
+```bash
+python3 ~/.config/opencode/skills/cartography/scripts/cartographer.py changes \
+  --root /path/to/repo
+```
+
+2. **Review the output** - It shows:
+   - Added files
+   - Removed files
+   - Modified files
+   - Affected folders
+
+3. **Only update affected codemaps** - Spawn one explorer per affected folder to update its `codemap.md`.
+4. **Run update** to save new state:
+
+```bash
+python3 ~/.config/opencode/skills/cartography/scripts/cartographer.py update \
+  --root /path/to/repo
+```
+
+## Codemap Content
+
+Explorers should write **timeless architectural understanding**, not exact details that get stale:
+
+- **Responsibility** - What is this folder's job in the system?
+- **Design** - Key patterns, abstractions, architectural decisions
+- **Flow** - How does data/control flow through this module?
+- **Integration** - How does it connect to other parts of the system?
+
+Example codemap:
+
+```markdown
+# src/agents/
+
+## Responsibility
+Defines agent personalities and manages their configuration lifecycle.
+
+## Design
+Each agent is a prompt + permission set. Config system uses:
+- Default prompts (orchestrator.ts, explorer.ts, etc.)
+- User overrides from ~/.config/opencode/oh-my-opencode-slim.json
+- Permission wildcards for skill/MCP access control
+
+## Flow
+1. Plugin loads → calls getAgentConfigs()
+2. Reads user config preset
+3. Merges defaults with overrides
+4. Applies permission rules (wildcard expansion)
+5. Returns agent configs to OpenCode
+
+## Integration
+- Consumed by: Main plugin (src/index.ts)
+- Depends on: Config loader, skills registry
+```

+ 456 - 0
src/skills/cartography/scripts/cartographer.py

@@ -0,0 +1,456 @@
+#!/usr/bin/env python3
+"""
+Cartographer - Repository mapping and change detection tool.
+
+Commands:
+  init     Initialize mapping (create hashes + empty codemaps)
+  changes  Show what changed (read-only, like git status)
+  update   Update hashes (like git commit)
+
+Usage:
+  cartographer.py init --root /path/to/repo --include "src/**/*.ts" --exclude "node_modules/**"
+  cartographer.py changes --root /path/to/repo
+  cartographer.py update --root /path/to/repo
+"""
+
+import argparse
+import hashlib
+import json
+import os
+import re
+import sys
+from datetime import datetime, timezone
+from pathlib import Path, PurePath
+from typing import Dict, List, Optional, Set, Tuple
+
+VERSION = "1.0.0"
+STATE_DIR = ".slim"
+STATE_FILE = "cartography.json"
+CODEMAP_FILE = "codemap.md"
+
+
+def load_gitignore(root: Path) -> List[str]:
+    """Load .gitignore patterns from the repository root."""
+    gitignore_path = root / ".gitignore"
+    patterns = []
+    if gitignore_path.exists():
+        with open(gitignore_path, "r", encoding="utf-8") as f:
+            for line in f:
+                line = line.strip()
+                if line and not line.startswith("#"):
+                    patterns.append(line)
+    return patterns
+
+
+class PatternMatcher:
+    """Efficiently match paths against multiple glob patterns using pre-compiled regex."""
+
+    def __init__(self, patterns: List[str]):
+        if not patterns:
+            self.regex = None
+            return
+
+        regex_parts = []
+        for pattern in patterns:
+            # Regex conversion logic
+            reg = re.escape(pattern)
+            reg = reg.replace(r'\*\*/', '(?:.*/)?')  # Recursive glob
+            reg = reg.replace(r'\*\*', '.*')
+            reg = reg.replace(r'\*', '[^/]*')  # Single level glob
+            reg = reg.replace(r'\?', '.')
+
+            if pattern.endswith('/'):
+                reg += '.*'
+
+            if pattern.startswith('/'):
+                reg = '^' + reg[1:]
+            else:
+                reg = '(?:^|.*/)' + reg
+            
+            regex_parts.append(f'(?:{reg}$)')
+        
+        # Combine all patterns into a single regex for speed
+        combined_regex = '|'.join(regex_parts)
+        self.regex = re.compile(combined_regex)
+
+    def matches(self, path: str) -> bool:
+        """Check if a path matches any of the patterns."""
+        if not self.regex:
+            return False
+        return bool(self.regex.search(path))
+
+
+def select_files(
+    root: Path,
+    include_patterns: List[str],
+    exclude_patterns: List[str],
+    exceptions: List[str],
+    gitignore_patterns: List[str],
+) -> List[Path]:
+    """Select files based on include/exclude patterns and exceptions."""
+    selected = []
+    
+    # Pre-compile matchers
+    include_matcher = PatternMatcher(include_patterns)
+    exclude_matcher = PatternMatcher(exclude_patterns)
+    gitignore_matcher = PatternMatcher(gitignore_patterns)
+    exception_set = set(exceptions)
+    
+    root_str = str(root)
+    
+    for dirpath, dirnames, filenames in os.walk(root_str):
+        # Skip hidden directories early by modifying dirnames in-place
+        dirnames[:] = [d for d in dirnames if not d.startswith(".")]
+        
+        rel_dir = os.path.relpath(dirpath, root_str)
+        if rel_dir == ".":
+            rel_dir = ""
+        
+        for filename in filenames:
+            rel_path = os.path.join(rel_dir, filename).replace("\\", "/")
+            if rel_path.startswith("./"):
+                rel_path = rel_path[2:]
+            
+            # Skip if ignored by .gitignore
+            if gitignore_matcher.matches(rel_path):
+                continue
+            
+            # Check explicit exclusions first
+            if exclude_matcher.matches(rel_path):
+                # Unless it's an exception
+                if rel_path not in exception_set:
+                    continue
+            
+            # Check inclusions
+            if include_matcher.matches(rel_path) or rel_path in exception_set:
+                selected.append(root / rel_path)
+    
+    return sorted(selected)
+
+
+def compute_file_hash(filepath: Path) -> str:
+    """Compute MD5 hash of file content."""
+    hasher = hashlib.md5()
+    try:
+        with open(filepath, "rb") as f:
+            for chunk in iter(lambda: f.read(8192), b""):
+                hasher.update(chunk)
+        return hasher.hexdigest()
+    except (IOError, OSError):
+        return ""
+
+
+def compute_folder_hash(folder: str, file_hashes: Dict[str, str]) -> str:
+    """Compute a stable hash for a folder based on its files."""
+    # Get all files in this folder
+    folder_files = sorted(
+        (path, hash_val)
+        for path, hash_val in file_hashes.items()
+        if path.startswith(folder + "/") or (folder == "." and "/" not in path)
+    )
+    
+    if not folder_files:
+        return ""
+    
+    # Hash the concatenation of path:hash pairs
+    hasher = hashlib.md5()
+    for path, hash_val in folder_files:
+        hasher.update(f"{path}:{hash_val}\n".encode())
+    return hasher.hexdigest()
+
+
+def get_folders_with_files(files: List[Path], root: Path) -> Set[str]:
+    """Get all unique folders that contain selected files."""
+    folders = set()
+    for f in files:
+        rel = f.relative_to(root)
+        # Add all parent directories
+        parts = rel.parts[:-1]  # Exclude filename
+        for i in range(len(parts)):
+            folders.add("/".join(parts[: i + 1]))
+    folders.add(".")  # Always include root
+    return folders
+
+
+def load_state(root: Path) -> Optional[dict]:
+    """Load the current cartography state."""
+    state_path = root / STATE_DIR / STATE_FILE
+    if state_path.exists():
+        try:
+            with open(state_path, "r", encoding="utf-8") as f:
+                return json.load(f)
+        except (json.JSONDecodeError, IOError):
+            return None
+    return None
+
+
+def save_state(root: Path, state: dict) -> None:
+    """Save the cartography state."""
+    state_dir = root / STATE_DIR
+    state_dir.mkdir(parents=True, exist_ok=True)
+    
+    state_path = state_dir / STATE_FILE
+    with open(state_path, "w", encoding="utf-8") as f:
+        json.dump(state, f, indent=2)
+
+
+def create_empty_codemap(folder_path: Path, folder_name: str) -> None:
+    """Create an empty codemap.md file with a header."""
+    codemap_path = folder_path / CODEMAP_FILE
+    if not codemap_path.exists():
+        content = f"""# {folder_name}/
+
+<!-- Explorer: Fill in this section with architectural understanding -->
+
+## Responsibility
+
+<!-- What is this folder's job in the system? -->
+
+## Design
+
+<!-- Key patterns, abstractions, architectural decisions -->
+
+## Flow
+
+<!-- How does data/control flow through this module? -->
+
+## Integration
+
+<!-- How does it connect to other parts of the system? -->
+"""
+        with open(codemap_path, "w", encoding="utf-8") as f:
+            f.write(content)
+
+
+def cmd_init(args: argparse.Namespace) -> int:
+    """Initialize mapping: create hashes and empty codemaps."""
+    root = Path(args.root).resolve()
+    
+    if not root.is_dir():
+        print(f"Error: {root} is not a directory", file=sys.stderr)
+        return 1
+    
+    # Load patterns
+    gitignore = load_gitignore(root)
+    include_patterns = args.include or ["**/*"]
+    exclude_patterns = args.exclude or []
+    exceptions = args.exception or []
+    
+    print(f"Scanning {root}...")
+    print(f"Include patterns: {include_patterns}")
+    print(f"Exclude patterns: {exclude_patterns}")
+    print(f"Exceptions: {exceptions}")
+    
+    # Select files
+    selected_files = select_files(
+        root, include_patterns, exclude_patterns, exceptions, gitignore
+    )
+    
+    print(f"Selected {len(selected_files)} files")
+    
+    # Compute file hashes
+    file_hashes: Dict[str, str] = {}
+    for f in selected_files:
+        rel_path = str(f.relative_to(root))
+        file_hashes[rel_path] = compute_file_hash(f)
+    
+    # Get folders and compute folder hashes
+    folders = get_folders_with_files(selected_files, root)
+    folder_hashes: Dict[str, str] = {}
+    for folder in folders:
+        folder_hashes[folder] = compute_folder_hash(folder, file_hashes)
+    
+    # Create state
+    state = {
+        "metadata": {
+            "version": VERSION,
+            "last_run": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
+            "root": str(root),
+            "include_patterns": include_patterns,
+            "exclude_patterns": exclude_patterns,
+            "exceptions": exceptions,
+        },
+        "file_hashes": file_hashes,
+        "folder_hashes": folder_hashes,
+    }
+    
+    # Save state
+    save_state(root, state)
+    print(f"Created {STATE_DIR}/{STATE_FILE}")
+    
+    # Create empty codemaps
+    for folder in folders:
+        if folder == ".":
+            folder_path = root
+            folder_name = root.name
+        else:
+            folder_path = root / folder
+            folder_name = folder
+        
+        create_empty_codemap(folder_path, folder_name)
+    
+    print(f"Created {len(folders)} empty codemap.md files")
+    
+    return 0
+
+
+def cmd_changes(args: argparse.Namespace) -> int:
+    """Show what changed since last update."""
+    root = Path(args.root).resolve()
+    
+    state = load_state(root)
+    if not state:
+        print("No cartography state found. Run 'init' first.", file=sys.stderr)
+        return 1
+    
+    # Get patterns from saved state
+    metadata = state.get("metadata", {})
+    include_patterns = metadata.get("include_patterns", ["**/*"])
+    exclude_patterns = metadata.get("exclude_patterns", [])
+    exceptions = metadata.get("exceptions", [])
+    
+    gitignore = load_gitignore(root)
+    
+    # Select current files
+    current_files = select_files(
+        root, include_patterns, exclude_patterns, exceptions, gitignore
+    )
+    
+    # Compute current hashes
+    current_hashes: Dict[str, str] = {}
+    for f in current_files:
+        rel_path = str(f.relative_to(root))
+        current_hashes[rel_path] = compute_file_hash(f)
+    
+    saved_hashes = state.get("file_hashes", {})
+    
+    # Find changes
+    added = set(current_hashes.keys()) - set(saved_hashes.keys())
+    removed = set(saved_hashes.keys()) - set(current_hashes.keys())
+    modified = {
+        path
+        for path in current_hashes.keys() & saved_hashes.keys()
+        if current_hashes[path] != saved_hashes[path]
+    }
+    
+    if not added and not removed and not modified:
+        print("No changes detected.")
+        return 0
+    
+    if added:
+        print(f"\n{len(added)} added:")
+        for path in sorted(added):
+            print(f"  + {path}")
+    
+    if removed:
+        print(f"\n{len(removed)} removed:")
+        for path in sorted(removed):
+            print(f"  - {path}")
+    
+    if modified:
+        print(f"\n{len(modified)} modified:")
+        for path in sorted(modified):
+            print(f"  ~ {path}")
+    
+    # Show affected folders
+    affected_folders = set()
+    for path in added | removed | modified:
+        parts = Path(path).parts[:-1]
+        for i in range(len(parts)):
+            affected_folders.add("/".join(parts[: i + 1]))
+        affected_folders.add(".")
+    
+    print(f"\n{len(affected_folders)} folders affected:")
+    for folder in sorted(affected_folders):
+        print(f"  {folder}/")
+    
+    return 0
+
+
+def cmd_update(args: argparse.Namespace) -> int:
+    """Update hashes and save state."""
+    root = Path(args.root).resolve()
+    
+    state = load_state(root)
+    if not state:
+        print("No cartography state found. Run 'init' first.", file=sys.stderr)
+        return 1
+    
+    # Get patterns from saved state
+    metadata = state.get("metadata", {})
+    include_patterns = metadata.get("include_patterns", ["**/*"])
+    exclude_patterns = metadata.get("exclude_patterns", [])
+    exceptions = metadata.get("exceptions", [])
+    
+    gitignore = load_gitignore(root)
+    
+    # Select current files
+    selected_files = select_files(
+        root, include_patterns, exclude_patterns, exceptions, gitignore
+    )
+    
+    # Compute new hashes
+    file_hashes: Dict[str, str] = {}
+    for f in selected_files:
+        rel_path = str(f.relative_to(root))
+        file_hashes[rel_path] = compute_file_hash(f)
+    
+    # Compute folder hashes
+    folders = get_folders_with_files(selected_files, root)
+    folder_hashes: Dict[str, str] = {}
+    for folder in folders:
+        folder_hashes[folder] = compute_folder_hash(folder, file_hashes)
+    
+    # Update state
+    state["metadata"]["last_run"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
+    state["file_hashes"] = file_hashes
+    state["folder_hashes"] = folder_hashes
+    
+    save_state(root, state)
+    print(f"Updated {STATE_DIR}/{STATE_FILE} with {len(file_hashes)} files")
+    
+    return 0
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser(
+        description="Cartographer - Repository mapping and change detection"
+    )
+    subparsers = parser.add_subparsers(dest="command", help="Available commands")
+    
+    # Init command
+    init_parser = subparsers.add_parser("init", help="Initialize mapping")
+    init_parser.add_argument("--root", required=True, help="Repository root path")
+    init_parser.add_argument(
+        "--include", action="append", help="Glob patterns for files to include"
+    )
+    init_parser.add_argument(
+        "--exclude", action="append", help="Glob patterns for files to exclude"
+    )
+    init_parser.add_argument(
+        "--exception", action="append", help="Explicit file paths to include despite exclusions"
+    )
+    
+    # Changes command
+    changes_parser = subparsers.add_parser("changes", help="Show what changed")
+    changes_parser.add_argument("--root", required=True, help="Repository root path")
+    
+    # Update command
+    update_parser = subparsers.add_parser("update", help="Update hashes")
+    update_parser.add_argument("--root", required=True, help="Repository root path")
+    
+    args = parser.parse_args()
+    
+    if args.command == "init":
+        return cmd_init(args)
+    elif args.command == "changes":
+        return cmd_changes(args)
+    elif args.command == "update":
+        return cmd_update(args)
+    else:
+        parser.print_help()
+        return 1
+
+
+if __name__ == "__main__":
+    sys.exit(main())

+ 87 - 0
src/skills/cartography/scripts/test_cartographer.py

@@ -0,0 +1,87 @@
+import unittest
+import os
+import shutil
+import json
+import tempfile
+import hashlib
+from pathlib import Path
+from cartographer import PatternMatcher, compute_file_hash, compute_folder_hash, select_files
+
+class TestCartographer(unittest.TestCase):
+    def test_pattern_matcher(self):
+        patterns = ["node_modules/", "dist/", "*.log", "src/**/*.ts"]
+        matcher = PatternMatcher(patterns)
+        
+        # Directory patterns
+        self.assertTrue(matcher.matches("node_modules/foo.js"))
+        self.assertTrue(matcher.matches("vendor/node_modules/bar.js"))
+        self.assertTrue(matcher.matches("dist/main.js"))
+        self.assertTrue(matcher.matches("src/dist/output.js"))
+        
+        # Glob patterns
+        self.assertTrue(matcher.matches("error.log"))
+        self.assertTrue(matcher.matches("logs/access.log"))
+        
+        # Recursive glob patterns
+        self.assertTrue(matcher.matches("src/index.ts"))
+        self.assertTrue(matcher.matches("src/utils/helper.ts"))
+        
+        # Non-matches
+        self.assertFalse(matcher.matches("README.md"))
+        self.assertFalse(matcher.matches("tests/test.py"))
+
+    def test_compute_file_hash(self):
+        # Use binary mode to avoid any newline translation issues
+        with tempfile.NamedTemporaryFile(mode='wb', delete=False) as f:
+            f.write(b"test content")
+            f_path = f.name
+        
+        try:
+            h1 = compute_file_hash(Path(f_path))
+            # md5 of b"test content" is 9473fdd0d880a43c21b7778d34872157
+            expected = hashlib.md5(b"test content").hexdigest()
+            self.assertEqual(h1, expected)
+            self.assertEqual(h1, "9473fdd0d880a43c21b7778d34872157")
+        finally:
+            if os.path.exists(f_path):
+                os.unlink(f_path)
+
+    def test_compute_folder_hash(self):
+        file_hashes = {
+            "src/a.ts": "hash-a",
+            "src/b.ts": "hash-b",
+            "tests/test.ts": "hash-test"
+        }
+        
+        h1 = compute_folder_hash("src", file_hashes)
+        h2 = compute_folder_hash("src", file_hashes)
+        self.assertEqual(h1, h2)
+        
+        file_hashes_alt = {
+            "src/a.ts": "hash-a-modified",
+            "src/b.ts": "hash-b"
+        }
+        h3 = compute_folder_hash("src", file_hashes_alt)
+        self.assertNotEqual(h1, h3)
+
+    def test_select_files(self):
+        with tempfile.TemporaryDirectory() as tmpdir:
+            root = Path(tmpdir)
+            (root / "src").mkdir()
+            (root / "node_modules").mkdir()
+            (root / "src" / "index.ts").write_text("code")
+            (root / "src" / "index.test.ts").write_text("test")
+            (root / "node_modules" / "foo.js").write_text("dep")
+            (root / "package.json").write_text("{}")
+            
+            includes = ["src/**/*.ts", "package.json"]
+            excludes = ["**/*.test.ts", "node_modules/"]
+            exceptions = []
+            
+            selected = select_files(root, includes, excludes, exceptions, [])
+            
+            rel_selected = sorted([os.path.relpath(f, root) for f in selected])
+            self.assertEqual(rel_selected, ["package.json", "src/index.ts"])
+
+if __name__ == "__main__":
+    unittest.main()