[
  {
    "path": ".github/workflows/skill-lint.yml",
    "content": "name: Skill Lint\n\non:\n  push:\n    branches: [main]\n    paths:\n      - 'skills/**'\n      - 'scripts/skill_lint/**'\n      - 'tests/test_skill_lint.py'\n  pull_request:\n    paths:\n      - 'skills/**'\n      - 'scripts/skill_lint/**'\n      - 'tests/test_skill_lint.py'\n\njobs:\n  lint:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n\n      - uses: actions/setup-python@v5\n        with:\n          python-version: '3.12'\n\n      - name: Install pytest\n        run: pip install pytest\n\n      - name: Run skill-lint tests\n        run: PYTHONPATH=scripts python -m pytest tests/test_skill_lint.py -v\n\n      - name: Run skill-lint (errors fail)\n        run: PYTHONPATH=scripts python -m skill_lint --fail-on error skills/\n\n      - name: Run skill-lint (full report)\n        if: always()\n        run: PYTHONPATH=scripts python -m skill_lint --format json skills/ > skill-lint-report.json || true\n\n      - name: Upload lint report\n        if: always()\n        uses: actions/upload-artifact@v4\n        with:\n          name: skill-lint-report\n          path: skill-lint-report.json\n"
  },
  {
    "path": ".github/workflows/skill-review.yml",
    "content": "name: Skill Review (Tessl + skills-ref)\n\non:\n  pull_request:\n    paths:\n      - 'skills/**'\n\njobs:\n  tessl:\n    runs-on: ubuntu-latest\n    if: ${{ vars.TESSL_ENABLED == 'true' }}\n    steps:\n      - uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n\n      - name: Setup Tessl\n        uses: tesslio/setup-tessl@v2\n        with:\n          token: ${{ secrets.TESSL_TOKEN }}\n\n      - name: Detect changed skills\n        id: changes\n        run: |\n          # Multi-skill PRs produce a multi-line list. Plain echo \"skills=$X\"\n          # fails GHA's output parser on newlines (\"Invalid format\"), AND the\n          # downstream `for skill in ${{ outputs.skills }}` breaks on newlines\n          # because the expansion ends the `for ... in` expression. Join with\n          # spaces so both the output format and the shell loop are happy.\n          CHANGED=$(git diff --name-only origin/main...HEAD -- skills/ | cut -d'/' -f2 | sort -u | tr '\\n' ' ')\n          # Trim trailing space for clean logs\n          CHANGED=\"${CHANGED%% }\"\n          echo \"skills=$CHANGED\" >> \"$GITHUB_OUTPUT\"\n          echo \"Changed skills: $CHANGED\"\n\n      - name: Run Tessl review on changed skills\n        if: steps.changes.outputs.skills != ''\n        run: |\n          for skill in ${{ steps.changes.outputs.skills }}; do\n            echo \"=== Reviewing: $skill ===\"\n            tessl skill lint \"skills/$skill\" || true\n            tessl skill review --json \"skills/$skill\" || true\n          done\n\n  skills-ref:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n\n      - uses: actions/setup-python@v5\n        with:\n          python-version: '3.12'\n\n      - name: Install skills-ref\n        run: pip install skills-ref || echo \"skills-ref not available, skipping\"\n\n      - name: Detect changed skills\n        id: changes\n        run: |\n          # Same space-join as the tessl job — keeps both the GHA output format\n          # and the downstream `for skill in ${{ ... }}` loop working.\n          CHANGED=$(git diff --name-only origin/main...HEAD -- skills/ | cut -d'/' -f2 | sort -u | tr '\\n' ' ')\n          CHANGED=\"${CHANGED%% }\"\n          echo \"skills=$CHANGED\" >> \"$GITHUB_OUTPUT\"\n\n      - name: Validate changed skills\n        if: steps.changes.outputs.skills != ''\n        run: |\n          for skill in ${{ steps.changes.outputs.skills }}; do\n            echo \"=== Validating: $skill ===\"\n            skills-ref validate \"skills/$skill\" || true\n          done\n"
  },
  {
    "path": ".gitignore",
    "content": "__pycache__/\n.DS_Store\nevals/.results/\n.pytest_cache/\n"
  },
  {
    "path": "CHANGELOG.md",
    "content": "# Changelog\n\nAll notable changes to Claude Bootstrap will be documented in this file.\n\nThe format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).\n\n---\n\n## [5.8.0] - 2026-05-12\n\n### Fixed\n\n#### UX Fix Pass (12 issues from manual CLI testing)\n- **Prompt character** — Changed from `maggy:` to `>` for cleaner input (`cli_chat.py:76`)\n- **Ctrl+C during streaming** — Now cancels current response instead of exiting REPL. Added `except KeyboardInterrupt` in `_stream_chunks` (`cli_chat.py:161`)\n- **`/health` 404** — Client was calling `/api/health/memory` (non-existent). Fixed to call `/api/engram/diagnostics` (`cli_client.py:260`)\n- **`/route`, `/models`, `/budget`, `/stats`, `/health`, `/config` crash on server down** — Added `_call(fn, default)` safe wrapper that catches `Exception` and `SystemExit` from unreachable server. All display commands return fallback data instead of crashing (`cli_repl_cmds.py:18`)\n- **Models shows \"0 tracked\" / \"No data yet\"** — When heatmap is empty, now shows the 5 known model tiers (local, kimi, gpt, claude, codex) with 0 samples (`cli_repl_cmds.py:129`)\n- **`/use` accepts invalid model names** — Now validates against `_KNOWN_MODELS`, prints warning for unknown names while still setting the restriction (`cli_repl_cmds.py:147`)\n- **Dir shows \"?\"** — Welcome banner now falls back to `os.getcwd()` when session `working_dir` is empty (`cli_welcome.py:36`)\n\n### Added\n\n#### Budget Subscription Awareness\n- **`plan` field** on `BudgetConfig` — Users set `budget.plan: subscription` in `~/.maggy/config.yaml` (`config.py:150`)\n- **`BudgetManager.budget_status()`** includes `plan` in response (`budget.py:163`)\n- **`/budget`** shows \"Subscription\" instead of \"$0.00 / $10.00\" when plan is subscription (`cli_repl_cmds.py:87`)\n- **Welcome banner** shows \"Subscription\" for subscription plans (`cli_welcome.py:54`)\n\n#### Welcome Banner Improvements\n- **Models count** — Shows \"5 available\" (known model count) instead of \"0 tracked\" when no heatmap data (`cli_welcome.py:62`)\n\n### Changed\n- **`_HELP` compressed** — 2-column layout saves 6 lines, fits all new features within 200-line limit (`cli_repl_cmds.py:191`)\n\n### Tests\n- `test_repl_cmds.py` — +5 tests: models_empty_shows_known, use_warns_unknown_model, budget_subscription_plan, health_graceful_failure, stats_server_down\n- `test_cli_welcome.py` — +3 tests: dir_shows_cwd_fallback, models_shows_available_count, budget_subscription_welcome\n- `test_cli_chat.py` — +1 test: chat_prompt_uses_angle_bracket\n- **Total: 825 tests passing** (816 + 9 new)\n\n---\n\n## [5.7.0] - 2026-05-12\n\n### Added\n\n#### `/monitor` Command — Background Tracker Polling\n- **`maggy/services/monitor.py`** — MonitorService with SQLite-backed polling for GitHub PRs and Monday.com items. `MonitorConfig` and `MonitorEvent` dataclasses, `add/remove/list_active/is_new/mark_seen/status/poll` methods\n- **`maggy/providers/monday.py`** — Monday.com provider implementing `IssueTrackerProvider` protocol via GraphQL API. Maps board items to Task dataclass\n- **`maggy/api/routes_monitor.py`** — REST endpoints: `GET /api/monitor/status`, `POST /api/monitor/start`, `POST /api/monitor/stop`\n- **`/monitor` handler** in REPL — shows active monitor count (`cli_chat.py:94`)\n\n#### `/health` Command — Memory Health Dashboard\n- **`cmd_health()`** — Shows Engram health score (color-coded) and Mnemos fatigue state in Rich Panel (`cli_repl_cmds.py:180`)\n- **`health_dashboard()`** and **`engram_diagnostics()`** client methods (`cli_client.py:259`)\n\n#### Enhanced Welcome Banner\n- **`cli_welcome.py`** — New file with Rich Panel welcome banner showing project info, budget, models, status, and memory health score\n\n#### Search Routing to Local Model\n- **\"search\" type** added to `TYPE_KEYWORDS` in `chat_router.py` — 11 keywords (find, search, grep, where, locate, which, look, scan, show, list, read) route to local/Qwen model for free\n\n#### Account Switching Guidance\n- **`maggy/services/account_guide.py`** — Detects CLI auth profiles from `~/.claude/`, `~/.codex/`. `suggest_switch()` returns CLI instructions, `render_switch_guide()` prints Rich-formatted guidance\n- **Quota error detection** — `_QUOTA_MARKERS` in `cli_chat.py` triggers account switch guidance on rate limit errors\n\n### Tests\n- `test_monitor.py` — 8 tests for MonitorService\n- `test_monday_provider.py` — 6 tests for MondayProvider\n- `test_account_guide.py` — 5 tests for account switching\n- `test_chat_router.py` — +3 tests for search type detection\n- `test_repl_cmds.py` — +3 tests for health command\n- `test_cli_welcome.py` — +2 tests for health and session history\n- `test_cli_chat.py` — +1 test for quota error guidance\n- **Total: 816 tests passing** (788 + 28 new)\n\n---\n\n## [5.1.0] - 2026-05-11\n\n### Added\n\n#### REPL Slash Commands — Stats, Routing, Model Control\n- **`maggy/cli_repl_cmds.py`** — 9 command handlers for the interactive REPL:\n  - `/stats` — Budget + model performance summary (spend, status, reward heatmap)\n  - `/budget` — Detailed per-provider breakdown with visual progress bar\n  - `/route` — Routing rules, task type overrides, model strengths/success rates\n  - `/models` — Full reward heatmap grid by model × task type × blast tier\n  - `/use claude,codex` — Restrict routing to specific models for this session\n  - `/use all` — Remove model restriction\n  - `/config` — Configuration summary (codebases, routing mode, budget limit)\n  - `/claude-md` — Render project's CLAUDE.md in terminal\n  - `/help` — List all available commands\n- **`SessionState`** dataclass — Mutable session-level state (session_id, working_dir, allowed_models)\n- **`dispatch()`** router — Parses slash commands, routes to handlers, returns True if handled\n- **`GET /api/routing/rules`** endpoint — Exposes routing mode, task type overrides, model performance\n- **`allowed_models`** field on `RoutedMessageRequest` — Server-side model restriction: if routed model not in allowed list, picks first allowed model with updated reason\n\n#### Qwen3-Coder Benchmarks\n- **75.7 tok/s average** — 3.4× faster than Qwen2.5-Coder (22.1 tok/s), 2× faster than Claude API (37.4 tok/s)\n- MoE architecture (3.3B active / 30B total params) on M4 Max 128GB\n- Quality: 10/10 BST correctness, 9/10 async rate limiter (token bucket + asyncio.Lock)\n- Cold start: ~13s model load; hot runs: <100ms start\n\n#### mWP Mindset — Full Framework\n- **`skills/base/SKILL.md`** — Added complete mWP section with 11-Star Framework (Brian Chesky), mWP planning checklist (obvious → magical → multiplier)\n- **`routing_rules.py`** — Expanded mWP convention injected into all CLI prompts (codex, kimi, qwen3, claude) with 3-question framework and 11-star reference\n\n### Changed\n- **`cli_chat.py`** — Integrated `SessionState` and `dispatch()` from `cli_repl_cmds`; passes `allowed_models` to `chat_send_routed()`; mode hint now shows `/help for commands`\n- **`cli_client.py`** — Added `budget_by_provider()`, `routing_rules()` methods; updated `chat_send_routed()` signature to accept `allowed_models`\n- **`benchmark-results.md`** — Qwen3-Coder results filled in (was TBD), quality assessment section added\n\n### Tests\n- `tests/test_repl_cmds.py` — 10 tests (dispatch routing, stats, budget, route, models, use, claude-md, help)\n- `tests/test_cli_chat.py` — Updated 2 assertions for `allowed_models=None` parameter\n- **Total: 653 tests passing** (643 maggy + 10 session detect)\n\n---\n\n## [5.0.0] - 2026-05-10\n\n### Added\n\n#### Interactive Chat — Session Takeover\n- **`maggy/services/chat.py`** — ChatManager for interactive Claude sessions with SSE streaming\n  - Auto-connects to all active CLI sessions (Claude, Codex, Kimi) via ActivityService process scanning\n  - Session continuity with `--resume <session-id>` for multi-turn conversations\n  - `CLAUDECODE` env var stripping to allow nested Claude subprocess spawning\n  - `--verbose` flag for `--output-format stream-json` compatibility\n  - Deduplication via dict keyed by project name\n- **`maggy/services/chat_context.py`** — Context builder for session enrichment\n  - Path-based history matching (not just exact project name) via `_path_candidates()`\n  - `_SKIP_DIRS` set prevents matching common system directories (Users, Documents, Library)\n  - Recent prompt injection from activity data per project\n  - Claude `session_id` resolution from `~/.claude/history.jsonl` for true `--resume`\n- **`maggy/api/routes_chat.py`** — Chat API (5 endpoints)\n  - `POST /api/chat/auto-connect` — detect all active sessions, enrich with history context\n  - `POST /api/chat/sessions` — create session\n  - `GET /api/chat/sessions` — list sessions\n  - `GET /api/chat/sessions/{id}` — get session + messages\n  - `POST /api/chat/sessions/{id}/send` — send message, stream response via SSE\n  - `DELETE /api/chat/sessions/{id}` — delete session\n- **Chat UI** in `app.js` — full web-based chat interface\n  - Auto-connects on tab load, shows all active project sessions in sidebar\n  - Message thread with user/Claude bubbles\n  - SSE EventSource for real-time streaming\n  - Session history context display\n  - New session creation from active + configured projects\n\n#### Auto-Bootstrap — No Empty Tabs\n- **`_bootstrap()` in `main.py`** — seeds all services on startup\n  - `history.analyze()` — parses CLI sessions immediately (260+ sessions, 11,994 prompts)\n  - `introspector.analyze()` — collects signals, emits events\n  - `_seed_cikg()` — scans configured codebases, creates nodes for repos + detected languages\n\n#### UI Navigation Cleanup\n- **Grouped navigation** — 9 flat tabs reorganized into 3 logical groups:\n  - **Work** (Chat, Tasks, Watching) — things you do\n  - **Intel** (Competitors, Insights) — things you learn\n  - **System** (gear dropdown: Budget, Models, Forge, Settings) — things you configure\n- **Tab renames** — Inbox→Tasks, Followed→Watching, Process→Insights\n- **Chat is default tab** — loads on startup, auto-connects immediately\n- **Gear dropdown** — system tabs collapsed into icon menu, reduces nav clutter\n- **Section labels** — tiny uppercase \"WORK\" / \"INTEL\" separators\n\n#### Process Intelligence Tab Enhancement\n- Parallel fetch of activity, history, improve, events, CIKG data\n- Health signals display (routing, memory, reliability, cost percentages)\n- Live activity section showing active sessions + recent prompts\n- Session patterns from history analysis\n- Button spinner feedback + success toast on Analyze History / Self-Improve\n\n#### Infrastructure\n- **No-cache static middleware** — `_NoCacheStatic` adds `Cache-Control: no-store` to `/static`\n- **Cache-busting** — `?v=3` on script tag\n- **`showToast()`** — green success notification for async operations\n\n### Security\n- **Chat path validation** — `project_path` now validated against configured codebase roots (blocks arbitrary filesystem access via `--dangerously-skip-permissions`)\n- **Chat streaming lock** — per-session `asyncio.Lock` rejects concurrent `/send` requests, preventing duplicate subprocess spawning and workspace corruption\n\n### Fixed\n- Engram `expire_engrams` referencing `self` outside class context\n- `auto_connect` returning duplicate sessions for same project\n- `CLAUDECODE` env var blocking nested Claude subprocess spawning\n- `--verbose` flag required when using `--output-format stream-json` with `-p`\n- History matching missing projects stored under parent dir name (e.g. \"AI-Playground\" vs \"claude-skills-package\")\n- Process tab buttons doing nothing due to browser-cached old JS\n- 500-row limit in history store masking projects — switched to aggregated report data\n\n### Changed\n- Default tab: `inbox` → `chat`\n- Org name in config: `\"Your Org\"` → read from `~/.maggy/config.yaml`\n- README fully rewritten to reflect current feature set (was still describing MVP)\n\n### Tests\n- `tests/test_chat.py` — 17 tests (ChatManager + AutoConnect)\n- `tests/test_chat_context.py` — 18 tests (path candidates, history matching, prompts, session ID)\n- Total: **466 tests passing**\n\n---\n\n## [4.0.0] - 2026-05-05\n\n### Added\n\n#### Polyphony — Multi-Agent Orchestration (Core)\n- **`scripts/polyphony/`** — Full multi-agent orchestration package with container-isolated workspaces. Each agent session runs in its own Docker container with independent git branches.\n- **Domain models** (`models.py`) — Task, Identity, AgentProfile, RunSpec, Result dataclasses\n- **Task state machine** (`state_machine.py`) — DISCOVERED -> CLAIMED -> ROUTED -> PROVISIONED -> RUNNING -> VERIFYING -> LANDED with FAILED/BLOCKED paths\n- **SQLite store** (`store.py`) — Persistent CRUD for tasks, run_specs, results with state audit log\n- **YAML config** (`config.py`) — Configuration loading from `~/.polyphony/` with defaults merging\n- **5-dimension complexity scoring** (`scoring.py`) — Cyclomatic depth, fan-out, security boundary, concurrency, domain invariants (0-10 scale)\n- **Pure function router** (`router.py`) — Task x Policy -> RunSpec, first-match rules with fallback chains\n- **Identity broker** (`identity.py`) — Named credential bundles with volume mounts and env overlays\n- **Workspace manager** (`workspace.py`) — Per-task git clone lifecycle with `--reference`/`--dissociate` mirror support\n- **Docker runtime** (`runtime.py`) — Container create/start/stop/wait/logs/rm lifecycle\n- **Event parser** (`events.py`) — NDJSON/stream-json parsing from container stdout\n- **Orchestrator** (`orchestrator.py`) — Supervisor loop: discover -> claim -> route -> provision -> run -> verify -> land\n- **Agent adapters** (`adapters/`) — Claude (`-p --output-format stream-json`), Codex (`exec --full-auto`), Kimi (`--print -y`)\n- **Work sources** (`sources/`) — GitHub Issues via `gh api`, local SQLite task queue\n- **CLI** (`__main__.py`) — `polyphony {init|spawn|status|cleanup}` commands\n- **Skill** (`skills/polyphony/SKILL.md`) — Full documentation for the orchestration system\n- **Commands** — `/polyphony-init`, `/polyphony-spawn`, `/polyphony-status`\n- **Templates** — `Dockerfile.polyphony`, `polyphony-config.yaml`, `polyphony-identities.yaml`, `polyphony-agents.yaml`, `polyphony-routing.yaml`\n- **Spec** (`docs/polyphony-spec.md`) — Full specification reference (12 sections)\n- **173 tests** across 13 test files with full TDD coverage\n\n---\n\n## [3.6.1] - 2026-05-04\n\n### Changed\n- **Complexity-based delegation replaces file-count heuristic** (`skills/cross-agent-delegation/SKILL.md`) — Kimi delegation now scored on 5 dimensions (cyclomatic depth, fan-out, security boundary, concurrency, domain invariants) × 0-2 each, sourced from iCPG signals + Claude reasoning. Routing: 0-3 → Kimi solo, 4-6 → Kimi + Codex auto-review, 7-10 → Claude direct. Adds trivial-case shortcut (<2 files + no risk keywords → auto-Kimi without scoring) and single-dimension override (7+ in any one dim keeps Claude). PR #16.\n\n---\n\n## [3.6.0] - 2026-05-03\n\n### Added\n\n#### Cross-Tool Compatibility (Claude + Kimi + Codex)\n- **`scripts/detect-agents.sh`** — Detects installed AI CLI tools (Claude Code, Kimi CLI, Codex CLI)\n- **`scripts/install-skills.sh`** — Reusable skill copier for any target directory\n- **`templates/AGENTS.md`** — Codex project instructions template (mirrors CLAUDE.md with `.agents/skills/` paths)\n- **`templates/config.toml`** — Hooks in TOML format for Kimi/Codex compatibility\n- **`scripts/convert-hooks-to-toml.sh`** — JSON to TOML hook converter (requires jq)\n- **`commands/sync-agents.md`** — `/sync-agents` command for cross-tool config sync\n- **`install.sh`** auto-detects and installs skills to `~/.kimi/skills/` and `~/.codex/skills/`\n- **`/initialize-project`** question 9: \"Which AI CLI tools do you use?\" with auto-detection\n- Cross-tool directories (`.kimi/`, `.codex/`, `.agents/`) added to `.gitignore` template\n\n#### Cross-Agent Intelligence\n- **`templates/codex-auto-review.sh`** — Stop hook that auto-runs Codex review on changed files\n  - Checks for Critical/High severity issues only\n  - Exit 0 = pass, Exit 2 = feed findings back to Claude for fixing\n  - Truncates diff to 8000 chars to prevent Codex token overflow\n  - Gracefully skips if Codex CLI not installed\n- **`skills/cross-agent-delegation/SKILL.md`** — Delegation skill with:\n  - Tool detection (checks `command -v` for each CLI)\n  - iCPG blast radius rules for Kimi delegation (<=3 files suggest Kimi, 4-8 offer option, 9+ stay Claude)\n  - iCPG mandatory pre-task queries for all agents (prior, constraints, risk)\n  - Mnemos mandatory memory lifecycle for all agents (goals, checkpoints, fatigue)\n  - 10-step cross-agent workflow summary\n- **Codex auto-review Stop hook** added to `settings.json` (after TDD, before iCPG record, 120s timeout)\n- **Codex auto-review TOML hook** added to `config.toml` for Kimi/Codex compatibility\n- **Cross-Agent Workflow** section added to both `CLAUDE.md` and `AGENTS.md` templates\n- **`cross-agent-delegation/`** added to always-copy skill list in `/initialize-project`\n\n#### Tests\n- **`tests/test_cross_tool.py`** — 12 tests for cross-tool compatibility (detect-agents, install-skills, templates, sync-agents)\n- **`tests/test_cross_agent.py`** — 22 tests for cross-agent intelligence (codex-auto-review, delegation skill, settings.json hook ordering, config.toml, template refs)\n\n### Changed\n- `install.sh` bumped to v3.6.0\n- `install.sh` now makes `codex-auto-review.sh` executable during install\n- `tests/validate-structure.sh` includes cross-tool template validation\n- Total skills increased from 60 to **61 skills**\n- Total tests: 62 pytest + 238 validation checks\n\n---\n\n## [3.5.2] - 2026-04-22\n\n### Fixed\n- **Hook error behavior revised** — the 3.5.1 fix silently no-op'd missing scripts, which hid real installation problems. Hook commands now:\n  - **Fail loud on real errors** — if the script exists and crashes, its stderr + non-zero exit propagate to Claude Code so you can debug\n  - **Print one actionable line on missing installs** — `[claude-bootstrap] hook script 'X' not installed — run <claude-bootstrap>/install.sh …` and exit 0 (no blocking error, but you see exactly what to do)\n  - **Use `exec` to run the resolved script** — exit code + stderr pass through unchanged\n- **Hook scripts stop swallowing stderr** — removed 19 instances of `2>/dev/null` across `mnemos-*.sh`, `icpg-*.sh`, and `tdd-loop-check.sh`. Python tracebacks and Python stderr now surface to Claude Code's hook diagnostics. Command substitution (`$(...)`) only captures stdout, so this doesn't affect any value parsing.\n\n## [3.5.1] - 2026-04-21\n\n### Fixed\n- **PreToolUse hook \"Bash hook error\" on any tool call.** `templates/settings.json` declared hook commands as relative paths (`scripts/mnemos-*.sh`) that don't exist in most projects — the scripts live in `templates/` and nothing copies them to `<project>/scripts/`. Every tool call triggered a hook-not-found error shown as `PreToolUse:Bash hook error` in the session (non-blocking but noisy).\n- Hook commands now try `.claude/scripts/<name>.sh` first (project-local override), fall back to `$HOME/.claude/templates/<name>.sh` (always installed by `install.sh`), and no-op cleanly when neither exists. Applied to all 8 hook script references across `PreCompact`, `PreToolUse`, `PostToolUse`, `Stop`, and `SessionStart`.\n\n---\n\n## [3.5.0] - 2026-04-19\n\n### CI\n- **`skill-review.yml`**: both `tessl` and `skills-ref` jobs now space-join the detected-skills list before writing to `$GITHUB_OUTPUT`. The old plain `echo \"skills=$CHANGED\"` with a multi-line `$CHANGED` value failed GHA's output parser (\"Invalid format\") AND broke the downstream `for skill in ${{ outputs.skills }}` loop. Space-joining keeps both happy and unblocks multi-skill PRs (like this one, which touches both `maggy/` and `mnemos/`).\n\n### Third review pass fixes (Copilot iteration)\n- **Package renamed `src/` → `maggy/`.** The top-level `src` package name was a well-known Python packaging anti-pattern that collides with other projects. The Python code now lives at `claude-bootstrap/maggy/maggy/` and imports as `from maggy.X import Y` (matching the icpg/mnemos/skill_lint convention). `pyproject.toml` entrypoint + includes, `install.sh`, and the launcher commands updated to `python3 -m maggy.main`.\n- **SQLite PRAGMAs** — `InboxService` and `CompetitorService` open connections via a shared helper that sets `journal_mode=WAL`, `foreign_keys=ON`, and `busy_timeout=30000`. Matches the convention used by `scripts/icpg/store.py` and prevents \"database is locked\" errors when the FastAPI handlers race the heartbeat worker.\n- **Host-safety startup check** — `create_app()` now refuses to boot when `dashboard.auth_mode=\"local\"` is combined with a non-loopback host (anything other than `127.0.0.1`/`localhost`/`::1`). Execute spawns `claude --dangerously-skip-permissions`, so binding to `0.0.0.0` with no auth would expose that to the local network. Users are directed to switch to token auth or rebind.\n- **`is_configured()` no longer accepts `linear`** — `providers.build()` raises `NotImplementedError` for Linear (stub), so treating it as configured would crash `create_app()` at startup. Now returns `False` cleanly.\n- **`providers.build()`** raises `NotImplementedError` with a clear \"use github or asana\" hint for `linear`.\n- **GitHub provider logs non-200s** in `list_tasks` — previously a 401/403/404 silently yielded an empty inbox. Now WARNING-logged with the repo slug and first 200 chars of the response body for debuggability.\n- **Removed unused `timedelta` import** from `inbox.py`.\n\n### Second review pass fixes (CodeRabbit iteration 2)\n- `AsyncAnthropic` used in async methods — inbox ranking + competitor discovery + daily briefing no longer block the event loop on multi-second LLM round-trips\n- RSS/Google News feed date handling uses `parsedate_to_datetime` + ISO parser and compares real `datetime` objects — RFC 822 strings aren't lexicographically ordered (day-of-week cycles weekly)\n- iCPG CLI invocation fixed: `python3 -m scripts.icpg query prior --text ...` against the real argparse entrypoint, not the utility submodule `scripts.icpg.symbols` which has no `__main__`\n- Background `asyncio.create_task()` reference kept in a set + `add_done_callback(discard)` so GC can't kill the TDD pipeline mid-run\n- `GitHubIssuesProvider.list_followed()` and `search_tasks()` refuse to run when `repos` is empty (otherwise the query has no repo filter and searches all of public GitHub)\n- `AsanaProvider.list_tasks()` drops the dead `completed_filter` variable and skips sending `completed_since=\"\"` (Asana validator rejects empty string); filters `closed` state properly\n- `install.sh` enforces Python 3.11+ minimum (was only checking `python3` existed)\n- `/static/index.html`: added CSP meta tag; Font Awesome pinned with SHA-384 SRI; Tailwind Play CDN annotated with vendor-for-prod TODO\n- `static/app.js`: added `jsStr()` for JS-string-context escaping in inline onclick handlers (esc() alone leaves single quotes intact — XSS via ticket titles was possible)\n- `regenerateBriefing()` catches and displays errors instead of swallowing them\n- `commands/maggy.md`: reads `dashboard.host`/`dashboard.port` from config before probing health (was hardcoded 8080)\n- `commands/maggy-init.md`: removed the \"offer to write to .env\" suggestion — the runtime doesn't load that file, so it would leave tokens on disk with no reader\n- `config.example.yaml`: removed the Linear section (stub only, shouldn't be in the advertised selectable set)\n- `PLAN.md`: config sample aligned with the actual runtime schema (removed spurious `config:` nesting)\n- `maggy/README.md`: install path no longer assumes `~/Documents/AI-Playground/...`; uses relative `cd claude-bootstrap/maggy`\n- `providers/__init__.py`: `__all__` alphabetized (RUF022)\n- `skills/maggy/SKILL.md`: explicit permission-model disclosure box explaining the `--dangerously-skip-permissions` tradeoff and the `working_dir` whitelist mitigations\n\n### Added\n- **Maggy — AI engineering command center** (optional extension under `maggy/`)\n  - Local FastAPI + vanilla JS dashboard; install with `maggy/install.sh`, zero build step\n  - Provider abstraction: `GitHubIssuesProvider`, `AsanaProvider`, `LinearProvider` (stub) implement a single `IssueTrackerProvider` Protocol — swap trackers without touching services\n  - AI-prioritized inbox with 30-min SQLite cache; stale-cache fallback when provider is unavailable\n  - Generic competitor discovery + RSS + Google News monitoring with daily AI briefing (cached per day)\n  - TDD execute pipeline (plan → tests → implement) spawns `claude -p --dangerously-skip-permissions` locally in the right codebase, with iCPG context auto-injected from the bootstrap's iCPG CLI\n  - Config-driven (`~/.maggy/config.yaml`) — no hardcoded org IDs, repo names, or competitor lists\n  - `/maggy` command launches dashboard; `/maggy-init` runs interactive setup\n  - `skills/maggy/SKILL.md` documents capabilities; README skills table updated\n- Maggy skill included in the skills table (fixes RI002 lint error for this PR)\n\n### Fixed\n- Added YAML frontmatter to `skills/mnemos/SKILL.md` (fixes FM001 lint error that was blocking CI on main)\n- Skill lint now passes across all 60 skills\n\n### Security (Maggy)\n- RSS URL validation before fetching competitor feeds — blocks loopback, link-local, private-network, and non-HTTP(S) targets (SSRF prevention)\n- `safeHref()` in dashboard JS — only allows `http(s)`/`mailto` schemes in external links, blocks `javascript:`/`data:` URIs that would slip past HTML escaping\n- `working_dir` validated against configured codebase roots before launching Claude Code — prevents arbitrary-cwd execution of `--dangerously-skip-permissions`\n- Execute-mode input validated via `Literal[\"tdd\", \"plan\"]`; typos rejected at request boundary\n- GitHub `_decode_id()` returns `None` on malformed input instead of raising — surfaces as 404 not 500\n- LLM ranking output validated (index range, numeric rank, dedupe) before applying\n\n### Resilience (Maggy)\n- `provider.list_tasks` failure falls back to last cached ranking (flagged `stale=true`) instead of 500\n- Route-level `_require_configured()` returns 503 + onboarding hint when `~/.maggy/config.yaml` is missing, instead of dereferencing `None` services\n- `is_configured()` requires provider credentials (token) in addition to org/repos; refreshes cache on each check\n- Claude subprocess kill on timeout (`proc.kill()` + `await proc.wait()`), non-zero exits marked as failed sessions\n- `_run_claude()` returns `(ok, output)` tuple — TDD pipeline now aborts chain on first-step failure\n- Competitor news events use deterministic SHA-256 IDs with `INSERT OR IGNORE` — prevents duplicate rows on cursor reset / overlapping scans\n\n### Changed (Maggy)\n- `pyproject.toml` console script `maggy = \"src.main:main\"` (proper callable) instead of `\"src.main:app\"` (ASGI instance)\n\n---\n\n## [3.4.1] - 2026-04-10\n\n### Fixed\n- Fixed broken `build-backend` in all three pyproject.toml files (icpg, mnemos, skill_lint). Changed `setuptools.backends._legacy:_Backend` to `setuptools.build_meta`. (Community reported)\n\n### Added\n- Cheeky personality section in CLAUDE.md template for new projects\n\n---\n\n## [3.4.0] - 2026-04-07\n\n### Added\n- **Skill Quality Gates** — Automated linter, CI integration, and behavioral evals\n  - `scripts/skill_lint/` — Python package with 20 check rules across 4 categories:\n    - Frontmatter (FM001-FM009): YAML validation, name/description/field checks\n    - Spec (SP001-SP003, SR001): SKILL.md existence, line count limits, skills-ref integration\n    - Content (CQ001-CQ006): ASCII art detection, vague phrase detection, filler intensity, code block density, stale references, H1 heading\n    - References (RI001-RI002): Cross-skill link validation, README coverage\n  - CLI: `PYTHONPATH=scripts python3 -m skill_lint [--format text|json] [--severity error|warning|info] [--skill NAME] [--fail-on error|warning] skills/`\n  - Inline suppression: `<!-- skill-lint: disable=SP002 -->` in first 10 lines\n  - 28 unit tests covering all check modules, report formatters, and CLI\n  - `.github/workflows/skill-lint.yml` — Runs linter + tests on PR/push to skills/ or scripts/skill_lint/\n  - `.github/workflows/skill-review.yml` — Tessl skill review + skills-ref validation on PRs (requires TESSL_TOKEN)\n  - `evals/` — 18 behavioral eval scenarios for 15 skills with deterministic and LLM-judged criteria\n  - `evals/run-evals.sh` — Eval runner with baseline comparison mode\n- Updated `CONTRIBUTING.md` with quality gate requirements and linter usage\n\n### Scan Results (59 skills)\n- Errors: 1 (mnemos/ missing frontmatter)\n- Warnings: 85 (19 skills over 500 lines, 30+ with ASCII art)\n- Clean: 3 skills\n\n---\n\n## [3.3.2] - 2026-04-07\n\n### Fixed\n- Removed stale `Load with: base.md` line from all 53 skills. Since v3.0, base skill loads via `@include` in CLAUDE.md, not per-skill. The leftover line caused confusion about missing files. (Fixes #13)\n\n### Housekeeping\n- Closed #10 (Gen Agent Trust Hub security audit) — false positives from scanning markdown code samples as executable code.\n- Closed #12 (Dispatch discoverability) — will address skill description metadata in a future cleanup pass.\n- Closed #11 (Low quality skills) — will revisit with specific eval criteria.\n\n---\n\n## [3.3.1] - 2026-04-03\n\n### Added\n- **Post-Compaction Task Restoration** (Two-Layer Defense)\n  - `templates/mnemos-post-compact-inject.sh` — PreToolUse hook (no matcher, fires on ALL tools) that detects compaction via `.mnemos/just-compacted` marker and re-injects the full checkpoint into Claude's context. Fast path ~5ms when no compaction, ~100ms injection when triggered.\n  - `build_task_narrative()` in `checkpoint.py` — Reads signals.jsonl to build human-readable summary of recent activity (files edited, read counts, focus area, error patterns). Automatically included in checkpoints.\n  - `format_for_post_compact_injection()` in `checkpoint.py` — Formats checkpoint as structured restoration block with goal, constraints, activity narrative, progress, key files, git state.\n  - Compaction marker system (`write_compaction_marker`, `check_compaction_marker`, `consume_compaction_marker`) — Atomic marker write/consume to prevent parallel injection.\n\n### Changed\n- **`mnemos-pre-compact.sh`** — Enhanced from advisory to assertive. Now includes inline checkpoint content in preservation instructions, writes compaction marker for Layer 2, builds task narrative from signals, and uses stronger verbatim framing.\n- **`CheckpointNode`** — Added `task_narrative` (str) and `recent_files` (list[dict]) fields for richer checkpoint content.\n- **`settings.json`** — Added new PreToolUse entry (no matcher) for `mnemos-post-compact-inject.sh` before the existing Edit|Write matcher.\n- **`SKILL.md`** — Documented post-compaction recovery mechanism.\n- **`README.md`** — Rewrote Mnemos section with two-layer defense architecture, resilience failure mode table, \"why not just a plain file\" rationale, and post-compaction restoration flow diagram.\n\n## [3.3.0] - 2026-04-03\n\n### Added\n\n#### Mnemos — Task-Scoped Memory Lifecycle\nAgents crash when context fills up. Claude Code's compaction is lossy — it summarizes everything uniformly. Mnemos solves this with typed memory, continuous fatigue monitoring, and checkpoint/resume.\n\n- **`scripts/mnemos/`** — Python package (zero external dependencies)\n  - `models.py` — MnemoNode (8 types with typed eviction policies), FatigueState, CheckpointNode\n  - `store.py` — SQLite MnemoGraph storage with mnemo_nodes, checkpoints, fatigue_log tables\n  - `fatigue.py` — 4-dimension fatigue model from passively observed signals (no agent cooperation needed)\n  - `signals.py` — Behavioral signal collection from hooks (scope scatter, re-read ratio, error density)\n  - `checkpoint.py` — CheckpointNode write/load with iCPG bridge, git state capture, formatted resume output\n  - `consolidation.py` — Micro-consolidation: compress ResultNodes, evict cold ContextNodes, decay weights\n  - `__main__.py` — CLI: init, status, fatigue, checkpoint, resume, consolidate, nodes, add, bridge-icpg\n\n- **4-Dimension Fatigue Model** (all passively observed from hooks):\n  - Token utilization (0.40) — real context_window.used_percentage from statusline\n  - Scope scatter (0.25) — unique directories in recent tool calls (from PreToolUse)\n  - Re-read ratio (0.20) — files Read more than once, strongest signal of context loss (from PreToolUse)\n  - Error density (0.15) — failed tool calls ratio (from PostToolUse)\n  - States: FLOW (0-0.4), COMPRESS (0.4-0.6), PRE-SLEEP (0.6-0.75), REM (0.75-0.9), EMERGENCY (0.9+)\n\n- **Auto-Feeding Token Signal**:\n  - `templates/mnemos-statusline.sh` — Statusline receives `context_window` JSON from Claude Code, writes `fatigue.json`, delegates display to ccusage (if installed) or shows simple context %\n  - JSONL fallback in PostToolUse — reads conversation JSONL to estimate context usage when statusline not configured (0.75 correction factor for cache overhead, ~1-2pp accuracy)\n  - `statusLine` config added to `templates/settings.json` — auto-activates on install, no separate configuration needed\n\n- **Fatigue-Aware Hook System**:\n  - `templates/mnemos-pre-edit.sh` — PreToolUse: logs file signals, reads fatigue, auto-checkpoints at 0.60+, auto-consolidates at 0.40+, includes iCPG context\n  - `templates/mnemos-post-tool.sh` — PostToolUse: logs tool success/failure for error density, auto-feeds token signal from JSONL when statusline is stale\n  - `templates/mnemos-session-start.sh` — SessionStart: loads checkpoint on resume, bridges iCPG state\n  - `templates/mnemos-pre-compact.sh` — PreCompact: emergency checkpoint + typed preservation priorities (NEVER DROP goals/constraints, OK TO DROP file contents)\n  - `templates/mnemos-stop-checkpoint.sh` — Stop: writes final session checkpoint\n\n- **MnemoNode Eviction Policies**:\n  - GoalNodes, ConstraintNodes, CheckpointNodes, HandoffNodes: NEVER evicted\n  - ResultNodes, WorkingNodes, SkillNodes: compressed first (summary kept), then evictable\n  - ContextNodes: evictable when activation weight drops below threshold\n\n- **iCPG Bridge**: `mnemos bridge-icpg` imports ReasonNodes as GoalNodes, postconditions/invariants as ConstraintNodes\n\n- **Skill + Commands**:\n  - `skills/mnemos/SKILL.md` — Full skill documentation with fatigue states, CLI reference, agent instructions\n  - `commands/mnemos-status.md` — `/mnemos-status` slash command\n  - `commands/mnemos-checkpoint.md` — `/mnemos-checkpoint` slash command\n\n- **Documentation**:\n  - `docs/mnemos-implementation.md` — Implementation addendum for the Mnemos RFC\n\n### Changed\n\n#### iCPG Fixes\n- `scripts/icpg/bootstrap.py` — Fixed `_get_commits()` git log parsing (was producing 0 symbols linked)\n- `scripts/icpg/drift.py` — Added `check_file_drift()` for fast, file-scoped drift (O(symbols-in-file))\n- `scripts/icpg/__main__.py` — Added `drift file <path>` subcommand, `_resolve_path()` for relative path handling\n- `templates/icpg-pre-edit.sh` — Now includes file-scoped drift detection alongside context and constraints\n\n#### Settings Template\n- `templates/settings.json` — Added `statusLine` config for auto-feeding token signal, Mnemos hooks replace standalone iCPG hooks, added PostToolUse hook, added mnemos permission allows\n- `templates/CLAUDE.md` — Added `@.claude/skills/mnemos/SKILL.md` to skill includes\n\n---\n\n## [3.2.0] - 2026-04-02\n\n### Added\n\n#### iCPG Full Implementation (Intent-Augmented Code Property Graph)\n- **`scripts/icpg/`** — Python CLI package implementing the full iCPG RFC v8\n  - `models.py` — ReasonNode, Symbol, Edge, DriftEvent data models with Design by Contract (preconditions, postconditions, invariants)\n  - `store.py` — SQLite storage layer with 4 tables, WAL mode, indexed queries\n  - `symbols.py` — Language-aware symbol extraction: Python (AST), TypeScript/JS (regex), Go, Rust, Elixir\n  - `drift.py` — 6-dimension drift detection: spec, decision, ownership, test, usage, dependency\n  - `contracts.py` — Design by Contract layer with LLM inference (Claude/OpenAI) and heuristic fallback\n  - `vectors.py` — Tiered duplicate detection: ChromaDB → TF-IDF → exact match fallback\n  - `bootstrap.py` — Git history inference: cluster commits, LLM-infer ReasonNodes, link symbols\n  - `__main__.py` — CLI with subcommands: init, create, record, query, drift, bootstrap, status\n  - `pyproject.toml` — pip-installable with optional deps (chromadb, sentence-transformers, openai)\n\n- **3 Canonical Pre-Task Queries** (RFC Section 2.1):\n  - `icpg query prior \"<goal>\"` — Vector-based duplicate detection before starting work\n  - `icpg query constraints <file>` — Get invariants/contracts for files being modified\n  - `icpg query risk <symbol>` — Drift score, ownership history, modification count\n\n- **Hook Integration**:\n  - `templates/icpg-pre-edit.sh` — PreToolUse hook: injects intent context + constraints before every Edit/Write\n  - `templates/icpg-stop-record.sh` — Stop hook: auto-records symbols to active ReasonNode after implementation\n\n- **Slash Commands**:\n  - `commands/icpg-impact.md` — `/icpg-impact <id>` blast radius visualization\n  - `commands/icpg-why.md` — `/icpg-why <symbol>` trace symbol to creating intent\n  - `commands/icpg-drift.md` — `/icpg-drift` full drift report across all dimensions\n  - `commands/icpg-bootstrap.md` — `/icpg-bootstrap` infer intents from git history\n\n### Changed\n\n#### iCPG Skill Rewrite\n- **`skills/icpg/SKILL.md`** — Complete rewrite aligning with RFC v8\n  - ReasonNode now carries formal contracts (preconditions, postconditions, invariants)\n  - Drift formally defined as predicate failure (not vague metric)\n  - 6-dimension drift model with 0-1 severity scores per dimension\n  - CLI reference for all `icpg` subcommands\n  - Hook integration documentation (PreToolUse + Stop)\n  - Agent Teams integration section with updated pipeline\n\n#### Agent Team iCPG Integration\n- **`skills/agent-teams/agents/team-lead.md`** — Team lead now creates ReasonNodes and checks for duplicates before creating task chains\n- **`skills/agent-teams/agents/feature.md`** — Feature agents query constraints/risk before implementing, auto-record symbols after\n- **`skills/agent-teams/agents/quality.md`** — Quality agent runs drift checks during GREEN verify, validates spec-intent alignment\n- **`skills/agent-teams/SKILL.md`** — Updated \"Integration with Existing Skills\" table with iCPG + code-graph entries\n\n#### Settings Template\n- **`templates/settings.json`** — Added PreToolUse hook (icpg-pre-edit.sh), Stop hook extension (icpg-stop-record.sh), icpg permission allows\n\n---\n\n## [3.1.0] - 2026-04-02\n\n### Added\n\n#### iCPG Skill (Initial Spec)\n- **`skills/icpg/SKILL.md`** — Initial iCPG skill spec (now superseded by 3.2.0 full implementation)\n\n---\n\n## [3.0.0] - 2026-03-31\n\n### Breaking Changes\n\nThis release aligns Claude Bootstrap with how Claude Code actually works internally. Several features that referenced non-existent infrastructure have been replaced with real Claude Code mechanisms.\n\n- **Ralph Wiggum plugin removed** — The `/ralph-loop` command, `claude-plugins-official` marketplace, and plugin stop-hook mechanism never existed in Claude Code. All references removed.\n- **TDD loops now use real Stop hooks** — Claude Code's Stop hook (exit code 2 feeds stderr back to the model) replaces the fake plugin. `scripts/tdd-loop-check.sh` runs tests/lint/typecheck after each response.\n- **`CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1` removed** — Agent spawning and task management are standard Claude Code features, not gated behind an env var. All references removed.\n- **CLAUDE.md template uses `@include` directives** — Skills are loaded via `@.claude/skills/base/SKILL.md` syntax which Claude Code resolves at parse time (recursive, max depth 5, cycle detection).\n- **Quality gates moved from CLAUDE.md to `.claude/rules/`** — Rules use YAML frontmatter with `paths:` globs for conditional activation.\n- **\"STRICTLY ENFORCED\" / \"Non-Negotiable\" language removed** — Claude Code treats CLAUDE.md as user-level context (not system prompt) wrapped in `<system-reminder>` tags with \"may or may not be relevant\" caveat. Aggressive language wastes tokens without creating binding constraints.\n\n### Added\n\n#### Stop Hook TDD Loops\n- **`templates/tdd-loop-check.sh`** — Universal TDD loop script for Stop hooks\n  - Runs tests, lint, typecheck after each Claude response\n  - Exit 0 (all pass) = Claude stops; Exit 2 (failures) = stderr fed back to Claude\n  - Iteration counter with configurable max (default 25)\n  - Detects project type (Node.js/Python) and runs appropriate commands\n  - Distinguishes code errors (loop) from environment errors (stop)\n\n- **`templates/settings.json`** — Pre-configured Claude Code settings\n  - Stop hook configuration for TDD loops\n  - SessionStart hook for auto-context injection\n  - Permission allow rules: test runners, linters, git read commands, gh CLI\n  - Permission deny rules: `rm -rf`, `git push --force`, writing `.env` files\n  - Ready to copy into any project's `.claude/settings.json`\n\n#### Conditional Rules System\n- **`.claude/rules/` directory** with 7 rule files using proper YAML frontmatter:\n  - `quality-gates.md` — Always active: 20 lines/function, 200 lines/file, 3 params, 80% coverage\n  - `tdd-workflow.md` — Always active: RED-GREEN-VALIDATE workflow\n  - `security.md` — Always active: no secrets in code, parameterized queries, bcrypt\n  - `react.md` — Active on `**/*.tsx`, `**/*.jsx`, `src/components/**`\n  - `typescript.md` — Active on `**/*.ts`, `**/*.tsx`\n  - `python.md` — Active on `**/*.py`\n  - `nodejs-backend.md` — Active on `src/api/**`, `src/routes/**`, `server/**`\n\n#### CLAUDE.local.md\n- **`templates/CLAUDE.local.md`** — Private developer override template\n  - Not checked into git (higher priority than project CLAUDE.md)\n  - Template with common overrides: preferences, local environment, quality gate tweaks\n\n#### Agent Definition Frontmatter\n- All 6 agent definitions now use proper Claude Code frontmatter:\n  - `name` — Agent identifier\n  - `description` — When-to-use hint\n  - `model` — Model selection (sonnet, inherit)\n  - `tools` — Tool allowlist (e.g., `[Read, Glob, Grep, TaskCreate]`)\n  - `disallowedTools` — Tool denylist (e.g., `[Write, Edit, Bash]`)\n  - `maxTurns` — Maximum agentic turns before stopping\n  - `effort` — Thinking depth (medium/high)\n\n#### @include Directives in CLAUDE.md\n- CLAUDE.md template now uses `@.claude/skills/base/SKILL.md` syntax\n- Claude Code resolves these at load time (recursively inlined)\n- Skills actually become part of the prompt instead of decorative text\n\n#### CLAUDE.md Template Structure\n- Added **Project Structure** section — tells Claude where things live without filesystem exploration\n- Added **Key Decisions** section — prevents Claude from re-litigating settled architectural choices\n- Added **Conventions** section — patterns Claude should follow (test colocation, API shape, etc.)\n- Added **Don't** section — short guardrails (no .env writes, no secret leaks)\n- Removed Session Persistence section (belongs in skills, not root template)\n\n#### PreCompact Hook for Smarter Compaction\n- **`templates/pre-compact.sh`** — PreCompact hook that injects project-specific preservation priorities into the compaction summarizer\n  - Auto-detects project type (TypeScript, Python, Next.js, FastAPI, Flutter, etc.)\n  - Finds schema files (Drizzle, Prisma, SQLAlchemy) and tells summarizer to preserve all schema discussion verbatim\n  - Finds API directories and tells summarizer to preserve exact endpoint paths, request/response shapes\n  - Extracts Key Decisions from CLAUDE.md and tells summarizer to reference them by name\n  - Injects live git state (branch, uncommitted changes, staged files) into summary priorities\n  - Tells summarizer to preserve exact error messages and fix context (not paraphrased)\n  - Tells summarizer what NOT to preserve (dead ends, full file contents, formatting noise)\n  - Zero overhead during normal usage — only runs when compaction fires\n  - Configured in `.claude/settings.json` under `hooks.PreCompact`\n\n#### Full Skill Frontmatter (all 57 skills)\n- Added undocumented-but-functional Claude Code skill frontmatter to all 57 skills:\n  - `when-to-use` — guidance for when Claude should invoke the skill\n  - `user-invocable` — 11 skills are user-invocable (code-review, codex-review, gemini-review, security, existing-repo, ticket-craft, workspace, cpg-analysis, playwright-testing, ai-models), 46 are model-only\n  - `effort` — thinking depth per skill (6 high, 47 medium, 4 low)\n  - `paths` — file glob patterns for 24 language/framework/database skills (e.g., `[\"**/*.py\"]` for Python, `[\"**/*.tsx\"]` for React)\n  - `allowed-tools` — restricted tool access for 3 review/security skills (`[Read, Glob, Grep, Bash]`)\n\n### Changed\n- `install.sh` now copies rules/, templates/, and no longer checks for Ralph Wiggum plugin\n- `iterative-development/SKILL.md` completely rewritten for Stop hooks\n- `base/SKILL.md` — Ralph Wiggum auto-invoke section replaced with Stop hook explanation\n- `agent-teams/SKILL.md` — Removed experimental env var requirement\n- `commands/spawn-team.md` — Removed env var check, removed Shift+Up/Down and Ctrl+T UI references\n- All agent definitions in `skills/agent-teams/agents/` rewritten with frontmatter\n- Total files: 57 skills + 7 conditional rules + 3 templates\n\n### Removed\n- All Ralph Wiggum plugin references (`/ralph-loop`, `/plugin install`, `--completion-promise`, `<promise>` tags)\n- `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1` env var requirement\n- Plugin marketplace references (`claude-plugins-official`)\n- `Shift+Up/Down` and `Ctrl+T` UI interaction assumptions\n- \"STRICTLY ENFORCED\" and \"Non-Negotiable\" language throughout\n\n### Migration\n\n```bash\ncd \"$(cat ~/.claude/.bootstrap-dir)\"\ngit pull\n./install.sh\n\n# Then in each project:\nclaude\n> /initialize-project\n# Will update to v3.0.0 structure\n```\n\n**Manual steps for existing projects:**\n1. Copy `templates/settings.json` to `.claude/settings.json`\n2. Copy `templates/tdd-loop-check.sh` to `scripts/tdd-loop-check.sh` and `chmod +x`\n3. Replace skill listings in CLAUDE.md with `@include` directives\n4. Copy `rules/` files to `.claude/rules/`\n5. Add `CLAUDE.local.md` to `.gitignore`\n6. Remove `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS` from environment\n\n---\n\n## [2.7.0] - 2026-03-23\n\n### Added\n\n#### Tiered Code Graph System (MCP-based)\n- **Code Graph skill** (`code-graph/SKILL.md`) - Always-on code intelligence via MCP\n  - \"Graph first, file second\" workflow — Claude queries the graph before reading files\n  - Integrates with codebase-memory-mcp: 14 MCP tools, 64 languages, sub-ms queries\n  - Decision tables for when to use graph vs direct file reads\n  - Workflow: LOCATE → UNDERSTAND → BLAST → TRACE → CHANGE → VERIFY\n  - Anti-patterns guide for common graph-ignoring mistakes\n\n- **CPG Analysis skill** (`cpg-analysis/SKILL.md`) - Opt-in deep code analysis\n  - Tier 2: Joern CPG via CodeBadger MCP (40+ tools, AST+CFG+CDG+DDG+PDG)\n    - Control flow graph analysis, data flow tracing, dead code detection\n    - CPGQL query examples for common analysis patterns\n    - 12 language support (Java, Python, TypeScript, Go, C/C++, etc.)\n  - Tier 3: CodeQL MCP for interprocedural taint analysis and security auditing\n    - OWASP vulnerability detection, source-to-sink data flow\n    - 10+ languages including Rust (which Joern doesn't support)\n  - Combined workflow: Tier 1 scope → Tier 2 flow → Tier 3 security\n\n- **Graph tools installer** (`scripts/install-graph-tools.sh`)\n  - Platform-detecting installer (macOS/Linux, ARM64/AMD64)\n  - `--joern` flag for Tier 2 (Docker + Python setup)\n  - `--codeql` flag for Tier 3 (CodeQL CLI + query packs)\n  - `--all` flag for all tiers\n\n- **Post-commit graph hook** (`hooks/post-commit-graph`)\n  - Lightweight (~10ms) hook that signals codebase-memory-mcp file watcher\n  - Filters to code files only, never blocks git workflow\n  - Auto-installed by `/initialize-project`\n\n- **Graph freshness check** (`hooks/workspace/check-graph-freshness.sh`)\n  - Session-start advisory warns if graph data is stale\n  - Cross-platform timestamp comparison (macOS/Linux)\n\n#### Initialize Project Updates\n- New question 4b: \"Code graph analysis level?\" (Standard/Deep/Security/Full)\n- New Step 4b: Automatic MCP server configuration (`.mcp.json`)\n- `.code-graph/` auto-added to `.gitignore`\n- Post-commit graph hook auto-installed\n- CLAUDE.md template now includes \"Code Graph (MCP)\" section\n- Summary output shows graph tier configuration\n\n### Changed\n- Total skills increased from 55 to **57 skills**\n- `install.sh` now copies `install-graph-tools.sh` to `~/.claude/`\n- `install.sh` summary output includes graph tools commands\n\n---\n\n## [2.6.0] - 2026-02-14\n\n### Added\n\n#### AI-Native Ticket Writing\n- **Ticket Craft skill** - Write Jira/Asana/Linear tickets optimized for Claude Code execution\n  - INVEST+C criteria: standard INVEST plus \"Claude-Ready\" verification\n  - 4 ticket templates: Feature, Bug, Tech Debt, Epic Breakdown\n  - Claude Code Context section: file refs, pattern refs, verification commands, constraints\n  - Claude Code Ready Checklist: 16-point validation before tickets enter sprint\n  - Anti-patterns guide: 6 common ticket-writing mistakes that cause AI agents to fail\n  - Story point calibration for AI agents (different from human estimation)\n  - Epic slicing techniques: by workflow, data variation, user role, CRUD, happy path\n  - Given-When-Then acceptance criteria format\n  - Integration guide for Jira, Asana, Linear, and GitHub Issues\n  - Maps tickets directly to the agent-teams 10-task pipeline\n\n#### Bug Fixes\n- **Fix pre-push hook false positive** - Hook was blocking pushes even when review passed with 0 Critical/High issues (fixes #8, reported by @shawnyeager)\n  - `grep` pattern matched \"Critical\" in table headers and pass messages\n  - Now checks for explicit `Status: ✅ PASS` / `Status: ❌` lines instead\n\n#### Community Contributions\n- **Flexible install directory** - Bootstrap can now be cloned anywhere, not just `~/.claude-bootstrap` (PR #9 by @victortrac)\n  - Install path saved to `~/.claude/.bootstrap-dir` for runtime resolution\n  - Removes fragile symlink approach\n- **Workspace skill frontmatter fix** - Added missing YAML frontmatter to workspace skill (PR #9 by @victortrac)\n\n### Changed\n- Total skills increased from 54 to **55 skills**\n\n### Contributors\n- @victortrac - Flexible install path, workspace skill fix (PR #9)\n- @shawnyeager - Pre-push hook bug report (#8)\n\n---\n\n## [2.5.0] - 2026-02-07\n\n### Added\n\n#### Agent Teams (Default Workflow)\n- **Agent Teams skill** - Coordinated team of AI agents as the default development workflow\n  - Strict TDD pipeline: Specs > Tests > Fail > Implement > Test > Review > Security > Branch > PR\n  - Task dependency chains enforce pipeline ordering (no step can be skipped)\n  - Multiple features run in parallel with shared verification agents\n  - Quality gates at every stage with cross-agent verification\n\n- **Default agent roster** (5 permanent agents):\n  - **Team Lead** - Orchestration only (delegate mode), task breakdown, feature agent spawning\n  - **Quality Agent** - TDD verification (RED/GREEN phases), spec review, coverage >= 80%\n  - **Security Agent** - OWASP scanning, secrets detection, dependency audit\n  - **Code Review Agent** - Multi-engine code review (Claude/Codex/Gemini)\n  - **Merger Agent** - Feature branches, PR creation via `gh` CLI\n\n- **Feature agents** - One per feature, each follows the strict pipeline end-to-end\n  - Writes spec, tests, implementation, validation\n  - Hands off to Quality, Review, Security, Merger at each gate\n\n- **Agent definition files** in `skills/agent-teams/agents/`:\n  - `team-lead.md`, `quality.md`, `security.md`, `code-review.md`, `merger.md`, `feature.md`\n  - Copied to `.claude/agents/` during project initialization\n\n- **`/spawn-team` command** - Spawn the agent team on any project\n  - Checks prerequisites (env var, agent definitions, feature specs)\n  - Spawns all agents and creates task dependency chains\n  - Shows team status summary\n\n- **10-task dependency chain per feature**:\n  1. Spec → 2. Spec Review → 3. Tests → 4. RED Verify → 5. Implement →\n  6. GREEN Verify → 7. Validate → 8. Code Review → 9. Security Scan → 10. Branch+PR\n\n### Changed\n- Total skills increased from 53 to **54 skills**\n- `/initialize-project` Phase 6 now sets up agent team by default (replaces manual next steps)\n- CLAUDE.md template includes agent teams section\n- `team-coordination.md` superseded by `agent-teams.md` for automated coordination\n\n---\n\n## [2.4.0] - 2026-01-20\n\n### Added\n\n#### Multi-Repo Workspace Awareness\n- **Workspace skill** - Dynamic multi-repo and monorepo awareness for Claude Code\n  - Workspace topology discovery (monorepo, multi-repo, hybrid detection)\n  - Dependency graph generation (who calls whom)\n  - API contract extraction (OpenAPI, GraphQL, tRPC, TypeScript, Pydantic)\n  - Key file identification with token estimates\n  - Cross-repo capability index (search before reimplementing)\n  - Token budget management (P0-P3 priority allocation)\n\n- **`/analyze-workspace` command** - Full workspace analysis\n  - Phase 1: Topology discovery (~30s)\n  - Phase 2: Module analysis (~60s)\n  - Phase 3: Contract extraction (~45s)\n  - Phase 4: Dependency graph (~30s)\n  - Phase 5: Key file identification (~30s)\n  - Generates TOPOLOGY.md, CONTRACTS.md, DEPENDENCY_GRAPH.md, KEY_FILES.md, CROSS_REPO_INDEX.md\n\n- **`/sync-contracts` command** - Lightweight incremental contract sync\n  - Checks only contract source files (~15s)\n  - Diff mode to preview changes\n  - Validate mode to check consistency\n  - Lightweight mode for hooks\n\n#### Contract Freshness System\n- **Session start hook** - Staleness check (~5s, advisory)\n- **Post-commit hook** - Auto-sync when contracts change (~15s)\n- **Pre-push hook** - Validation gate (~10s, blocking)\n- `.contract-sources` file to track monitored files\n- Freshness indicators: 🟢 Fresh, 🟡 Stale, 🔴 Outdated, ⚠️ Drift\n\n#### Cross-Repo Change Detection\n- Automatic detection when changes affect other modules\n- Impact analysis with recommended action order\n- Breaking change protocol\n\n### Changed\n- Total skills increased from 52 to **53 skills**\n- Added 3 new commands: `/analyze-workspace`, `/sync-contracts`, `/workspace-status`\n- Added 3 workspace hooks for contract freshness\n\n---\n\n## [2.3.0] - 2026-01-17\n\n### Added\n\n#### Google Gemini Code Review\n- **Gemini Review skill** - Google Gemini CLI for code review with Gemini 2.5 Pro\n  - 1M token context window - analyze entire repositories at once\n  - Free tier: 1,000 requests/day with Google account\n  - Code Review Extension: `/code-review` command in Gemini CLI\n  - Headless mode for CI/CD: `gemini -p \"prompt\"`\n  - Benchmarks: 63.8% SWE-Bench, 56.3% Qodo PR, 70.4% LiveCodeBench\n\n- **Multi-engine code review** - `/code-review` now supports up to 3 engines\n  - Claude (built-in) - quick, context-aware reviews\n  - OpenAI Codex - 88% security issue detection\n  - Google Gemini - 1M token context for large codebases\n  - Dual engine mode - run any two engines, compare findings\n  - Triple engine mode - maximum coverage for critical/security code\n\n- **GitHub Actions workflows** for all configurations\n  - Gemini-only workflow\n  - Triple engine (Claude + Codex + Gemini) workflow\n  - Updated dual engine workflow\n\n### Changed\n- Total skills increased from 51 to **52 skills**\n- Updated `/code-review` to support engine selection: `--engine claude,codex,gemini`\n- Added `--gemini` and `--all` shortcuts for common configurations\n\n---\n\n## [2.2.0] - 2026-01-17\n\n### Added\n\n#### Existing Repository Support\n- **Existing Repo skill** - Analyze existing codebases, maintain structure, setup guardrails\n  - Repo structure detection (monorepo, full-stack, frontend-only, backend-only)\n  - Tech stack auto-detection (TypeScript, Python, Flutter, Android, etc.)\n  - Convention detection (naming, imports, exports, test patterns)\n  - Guardrails audit (pre-commit hooks, linting, formatting, type checking)\n  - Structure preservation rules - work within existing patterns, don't reorganize\n  - Gradual implementation strategy for adding guardrails to legacy projects\n  - Cross-repo coordination for separate frontend/backend repos\n\n- **`/analyze-repo` command** - Quick analysis of any existing repository\n  - Directory structure mapping\n  - Guardrails status audit (Husky, pre-commit, ESLint, Ruff, commitlint, etc.)\n  - Convention detection and documentation\n  - Generates analysis report with recommendations\n  - Offers to add missing guardrails\n  - **Auto-triggered** by `/initialize-project` when existing codebase detected\n\n#### Initialize Project Enhancement\n- **Auto-analysis for existing codebases** - `/initialize-project` now automatically analyzes existing repos before making changes\n- **User choice after analysis** - Options: skills only, skills + guardrails, full setup, or just view analysis\n- **Existing-repo skill auto-copied** - When working with existing codebases\n\n#### Guardrails Setup (for JS/TS and Python)\n- **Husky + lint-staged** setup for JavaScript/TypeScript projects\n- **pre-commit framework** setup for Python projects\n- **commitlint** configuration for conventional commits\n- **ESLint 9 flat config** template\n- **Ruff + mypy** configuration for Python\n\n### Changed\n- Total skills increased from 50 to **51 skills**\n- Updated README with `/analyze-repo` usage pattern\n\n---\n\n## [2.1.0] - 2026-01-17\n\n### Added\n\n#### Mobile Development (contributed by @tyr4n7)\n- **Android Java skill** - MVVM architecture, ViewBinding, Espresso testing, GitHub Actions CI\n- **Android Kotlin skill** - Coroutines, Jetpack Compose, Hilt DI, MockK/Turbine testing\n- **Flutter skill** - Riverpod state management, Freezed models, go_router, mocktail testing\n- **Android/Flutter auto-detection** - `/initialize-project` now detects Flutter, Android Java, and Android Kotlin projects\n\n#### Database Skills (addresses #7)\n- **Firebase skill** - Firestore, Auth, Storage, real-time listeners, security rules, offline persistence\n- **Cloudflare D1 skill** - Serverless SQLite with Workers, Drizzle ORM integration, migrations\n- **AWS DynamoDB skill** - Single-table design, GSI patterns, SDK v3 TypeScript/Python\n- **AWS Aurora skill** - Serverless v2, RDS Proxy, Data API, connection pooling for Lambda\n- **Azure Cosmos DB skill** - Partition key design, consistency levels, change feed, SDK patterns\n\n#### Code Review Enhancements\n- **Codex Review skill** - OpenAI Codex CLI for code review with GPT-5.2-Codex (88% detection rate)\n- **Code review engine choice** - `/code-review` now lets you choose: Claude, OpenAI Codex, or both engines\n- **Dual engine review mode** - Run both Claude and Codex, compare findings, catch more issues\n- **CI/CD templates** - GitHub Actions workflows for Claude, Codex, and dual-engine reviews\n\n### Changed\n- Total skills increased from 44 to **50 skills**\n- Updated README with new database and mobile skill listings\n\n### Contributors\n- @tyr4n7 - Android Java, Android Kotlin, Flutter skills and auto-detection\n- @johnsfuller - Feature request for database skills (#7)\n\n---\n\n## [2.0.0] - 2026-01-08\n\n### Breaking Changes\n- **Skills structure changed** - Skills now use folder/SKILL.md structure instead of flat .md files\n  - Before: `~/.claude/skills/base.md`\n  - After: `~/.claude/skills/base/SKILL.md`\n- All skills now require YAML frontmatter with `name` and `description` fields\n\n### Added\n- **Validation test** (`tests/validate-structure.sh`) - Validates skills structure, commands, hooks\n  - `--full` mode: All 142 checks\n  - `--quick` mode: Essential checks for initialize-project\n- **Phase 0 validation** in `/initialize-project` - Checks bootstrap installation before setup\n- **Conversion script** (`scripts/convert-skills-structure.sh`) - Migrates flat skills to folder structure\n- Install script now runs validation automatically\n- Symlink created at `~/.claude-bootstrap` for easy access to validation tools\n\n### Fixed\n- Skills now load properly in Claude Code (fixes #1)\n- Install script properly copies skill folders instead of merging contents\n\n### Migration\n```bash\ncd ~/.claude-bootstrap\ngit pull\n./install.sh\n```\n\n---\n\n## [1.5.0] - 2026-01-07\n\n### Added\n- **Code Deduplication skill** - Prevent semantic code duplication with capability index\n- **Team Coordination skill** - Multi-person projects with shared state and todo claiming\n- `/check-contributors` command - Detect solo vs team projects\n- `/update-code-index` command - Regenerate CODE_INDEX.md\n- Pre-push hook for code review enforcement\n\n### Changed\n- Code reviews now mandatory before push (blocks on Critical/High issues)\n\n---\n\n## [1.4.0] - 2026-01-06\n\n### Added\n- **Code Review skill** - Mandatory code reviews via `/code-review`\n- **Commit Hygiene skill** - Atomic commits, PR size limits\n- Pre-push hooks installation script\n\n---\n\n## [1.3.0] - 2026-01-05\n\n### Added\n- **MS Teams Apps skill** - Teams bots and AI agents with Claude/OpenAI\n- **Reddit Ads skill** - Agentic ad optimization service\n- **PWA Development skill** - Service workers, caching, offline support\n\n---\n\n## [1.2.0] - 2026-01-04\n\n### Added\n- **Playwright Testing skill** - E2E testing with Page Objects\n- **PostHog Analytics skill** - Event tracking, feature flags\n- **Shopify Apps skill** - Remix, Admin API, checkout extensions\n\n---\n\n## [1.1.0] - 2026-01-03\n\n### Added\n- Session management with automatic state tracking\n- Decision logging for architectural choices\n- Code landmarks for quick navigation\n\n---\n\n## [1.0.0] - 2026-01-01\n\n### Added\n- Initial release with 30+ skills\n- `/initialize-project` command\n- TDD-first workflow with Ralph Wiggum loops\n- Security-first patterns\n- Support for Python, TypeScript, React, React Native\n- Supabase integration skills\n- AI/LLM patterns for Claude and OpenAI\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# Contributing to Maggy\n\nThanks for your interest in contributing! This project aims to make AI-assisted development more reliable and consistent.\n\n## Philosophy\n\nBefore contributing, understand the core philosophy:\n\n1. **Complexity is the enemy** - Every line of code is a liability\n2. **Measurable constraints** - Prefer specific numbers (20 lines/fn) over vague guidance\n3. **Security is non-negotiable** - All projects must pass security checks\n4. **AI-first thinking** - LLMs for logic, code for plumbing\n5. **Spec-driven** - Define before you build\n\n## How to Contribute\n\n### Adding a New Skill\n\n1. Create a directory in `skills/` with a lowercase hyphenated name\n2. Add `SKILL.md` with YAML frontmatter:\n   ```markdown\n   ---\n   name: my-skill\n   description: One-line description of what this skill does\n   when-to-use: When to activate this skill\n   user-invocable: true\n   effort: medium\n   ---\n   # My Skill\n\n   ## Core Principles\n   ...\n   ```\n3. Include these sections:\n   - Core principles with measurable constraints\n   - Project structure (if applicable)\n   - Patterns with code examples (>= 1 per 50 lines)\n   - Anti-patterns list\n4. Keep under 500 lines (ideal: under 300)\n5. Run the linter before submitting:\n   ```bash\n   PYTHONPATH=scripts python3 -m skill_lint --skill my-skill skills/\n   ```\n6. Update `README.md` to include the new skill\n\n### Quality Gates\n\nAll skills must pass the automated linter before merge:\n\n```bash\n# Lint all skills\nPYTHONPATH=scripts python3 -m skill_lint skills/\n\n# Lint a single skill\nPYTHONPATH=scripts python3 -m skill_lint --skill python skills/\n\n# JSON output for CI\nPYTHONPATH=scripts python3 -m skill_lint --format json skills/\n```\n\n**Checks enforced:**\n- **FM001-FM009**: YAML frontmatter (name, description, format, fields)\n- **SP001-SP003**: Spec compliance (SKILL.md exists, line count limits)\n- **CQ001-CQ006**: Content quality (no ASCII art, no vague phrases, code examples)\n- **RI001-RI002**: Cross-references (valid skill links, README listing)\n\nSuppress known issues with inline comments:\n```markdown\n<!-- skill-lint: disable=SP002 -->\n```\n\n### Improving Existing Skills\n\n1. Keep changes focused on one improvement\n2. Maintain the existing structure\n3. Ensure examples are correct and tested\n4. Update version comments if significant\n\n### Updating the Initialize Command\n\n1. Test changes locally before submitting\n2. Ensure idempotency - running twice shouldn't break anything\n3. Preserve user customizations (never overwrite `_project_specs/`)\n\n## Skill Guidelines\n\n### Do\n\n- Use specific, measurable constraints\n- Provide working code examples\n- Include anti-patterns with explanations\n- Keep skills focused on one topic\n- Reference other skills when building on them\n\n### Don't\n\n- Use vague guidance (\"write clean code\")\n- Include time estimates\n- Add features beyond what's needed\n- Break existing projects when run as update\n\n## Testing Your Changes\n\n```bash\n# Install your changes\n./install.sh\n\n# Test on a new project\nmkdir test-project && cd test-project\nclaude\n> /initialize-project\n\n# Test on an existing project\ncd existing-project\nclaude\n> /initialize-project\n# Should update skills without breaking existing config\n```\n\n## Pull Request Process\n\n1. Fork the repository\n2. Create a feature branch (`git checkout -b feature/new-skill`)\n3. Make your changes\n4. Test locally\n5. Submit PR with clear description of changes\n\n## Code of Conduct\n\n- Be respectful and constructive\n- Focus on technical merit\n- Welcome newcomers\n- Share knowledge freely\n\n## Questions?\n\nOpen an issue for:\n- Bug reports\n- Feature requests\n- Clarification on philosophy\n- Help with implementation\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2025 Ali Naqi\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "# Maggy\n\n> **From opinionated Claude Code setup to autonomous AI engineering platform.**\n\nMaggy started as an opinionated project initialization system for Claude Code — skills, TDD hooks, quality gates. It has evolved into a full autonomous engineering command center: interactive chat with session takeover, multi-agent orchestration in containers, P2P mesh networking across machines, AI-prioritized task triage, competitor intelligence, and process analytics. The guardrails that keep AI-generated code simple, secure, and verifiable are still the foundation — but now they power an end-to-end autonomous engineering workflow.\n\n**v5.0.0** — Interactive Chat (`--resume` session takeover), Polyphony (container-isolated multi-agent orchestration), P2P Mesh (cross-machine session sync), auto-bootstrap, grouped dashboard navigation.\n\n## Core Philosophy\n\n```\n┌────────────────────────────────────────────────────────────────┐\n│  TDD LOOPS VIA STOP HOOKS                                      │\n│  ─────────────────────────────────────────────────────────────│\n│  Stop hooks run tests after each Claude response.              │\n│  Failures feed back automatically. Claude iterates until green.│\n│  Real Claude Code infrastructure — no plugins needed.          │\n├────────────────────────────────────────────────────────────────┤\n│  TESTS FIRST, ALWAYS                                           │\n│  ─────────────────────────────────────────────────────────────│\n│  Features: Write tests → Watch them fail → Implement → Pass    │\n│  Bugs: Find test gap → Write failing test → Fix → Pass         │\n│  No code ships without a test that failed first.               │\n├────────────────────────────────────────────────────────────────┤\n│  SIMPLICITY IS THE GOAL                                        │\n│  ─────────────────────────────────────────────────────────────│\n│  20 lines per function │ 200 lines per file │ 3 params max     │\n│  Enforced via .claude/rules/ with paths: frontmatter.          │\n├────────────────────────────────────────────────────────────────┤\n│  SECURITY BY DEFAULT                                           │\n│  ─────────────────────────────────────────────────────────────│\n│  No secrets in code │ Permission deny rules for .env files     │\n│  Dependency scanning │ Pre-commit hooks │ CI enforcement       │\n├────────────────────────────────────────────────────────────────┤\n│  AGENT TEAMS BY DEFAULT                                        │\n│  ─────────────────────────────────────────────────────────────│\n│  Every project runs as a coordinated team of AI agents.        │\n│  Agent definitions use proper frontmatter: tools, model,       │\n│  maxTurns, effort, disallowedTools.                            │\n├────────────────────────────────────────────────────────────────┤\n│  CONDITIONAL RULES                                             │\n│  ─────────────────────────────────────────────────────────────│\n│  Rules in .claude/rules/ activate based on file paths.         │\n│  React rules only load when editing .tsx files.                │\n│  Python rules only load when editing .py files.                │\n│  Saves tokens. Reduces noise. More targeted guidance.          │\n└────────────────────────────────────────────────────────────────┘\n```\n\n## Quick Start\n\n```bash\n# Clone and install (clone anywhere you like)\ngit clone https://github.com/alinaqi/claude-bootstrap.git\ncd claude-bootstrap && ./install.sh\n\n# In any project directory\nclaude\n> /initialize-project\n```\n\nClaude will:\n1. **Validate tools** - Check gh, vercel, supabase CLIs\n2. **Ask questions** - Language, framework, AI-first?, database, graph analysis level\n3. **Set up repository** - Create or connect GitHub repo\n4. **Create structure** - Skills, rules, settings, security, CI/CD, specs, todos\n5. **Copy settings.json** - Pre-configured permissions and Stop hooks\n6. **Generate CLAUDE.md** - With `@include` directives for modular skills\n7. **Generate CLAUDE.local.md** - Template for private developer overrides\n8. **Spawn agent team** - Deploy Team Lead + Quality + Security + Review + Merger + Feature agents\n\n## Cross-Tool Compatibility (Claude + Kimi + Codex)\n\nMaggy works with **Claude Code**, **Kimi CLI**, and **OpenAI Codex CLI**. All three use the same `SKILL.md` format.\n\n| Feature | Claude Code | Kimi CLI | Codex CLI |\n|---------|-------------|----------|-----------|\n| Skills | `.claude/skills/` | `.kimi/skills/` (also reads `.claude/`) | `.codex/skills/` |\n| Project instructions | `CLAUDE.md` | (uses skills) | `AGENTS.md` |\n| Hooks config | `settings.json` | `config.toml` | `config.toml` |\n\n**`install.sh`** auto-detects installed tools and installs skills to all of them.\n\n**`/sync-agents`** syncs project config across tools on demand.\n\n```bash\n# Install tools\ncurl -L code.kimi.com/install.sh | bash     # Kimi\nnpm i -g @openai/codex                       # Codex\n\n# Reinstall to pick up new tools\ncd maggy && ./install.sh\n\n# In any project, sync cross-tool config\nclaude\n> /sync-agents\n```\n\n## Cross-Agent Intelligence\n\nWhen multiple AI CLI tools are installed, Maggy enables intelligent collaboration between them.\n\n### Codex Auto-Review (Stop Hook)\n\nAfter tests pass, Codex automatically reviews your diff for critical bugs and security issues. Runs as a Stop hook between TDD and iCPG recording.\n\n```\nStop hook order:\n1. tdd-loop-check.sh     → tests pass?\n2. codex-auto-review.sh  → Codex reviews diff (NEW)\n3. icpg-stop-record.sh   → record symbols\n4. mnemos-checkpoint.sh   → save memory\n```\n\n- Exit 0 = no critical issues found\n- Exit 2 = critical/high issues feed back to Claude for fixing\n- Gracefully skips if Codex not installed\n\n### Kimi Delegation (Token Optimization)\n\nClaude checks iCPG blast radius and delegates small tasks to Kimi automatically — the user doesn't run anything:\n\n| Blast Radius | Claude's Action |\n|-------------|----------------|\n| 1-3 files | Saves context via `mnemos checkpoint`, runs `kimi --print -y -p \"...\"` with context + task |\n| 4-8 files | Asks user, then delegates or handles directly |\n| 9+ files | Handles directly (needs full context window) |\n\nContext transfer uses structured state (mnemos checkpoints + iCPG constraints), not raw conversation.\n\n### iCPG + Mnemos (Always-On for All Agents)\n\nAll three tools run the same iCPG pre-task queries and Mnemos memory lifecycle:\n\n```bash\n# Before any code change (Claude, Kimi, or Codex):\nicpg query prior \"<goal>\"        # check for duplicate work\nicpg query constraints <file>    # check invariants\nicpg query risk <symbol>         # check fragility\n\n# Memory management:\nmnemos add goal \"<task>\"         # at task start\nmnemos checkpoint                # at sub-goal boundaries\n```\n\n## How TDD Loops Work (Stop Hooks)\n\n**No plugins. No fake commands.** Claude Code's Stop hook runs a script when Claude finishes a response. Exit code 2 feeds stderr back to Claude and continues the conversation.\n\n```\n┌─────────────────────────────────────────────────────────────┐\n│  1. You say: \"Add email validation to signup\"               │\n│  2. Claude writes tests + implementation                    │\n│  3. Claude finishes response                                │\n│  4. Stop hook runs: npm test && npm run lint                │\n│  5a. All pass (exit 0) → Done!                              │\n│  5b. Failures (exit 2) → stderr fed back to Claude          │\n│  6. Claude sees failures, fixes, finishes again             │\n│  7. Stop hook runs again → repeat until green               │\n└─────────────────────────────────────────────────────────────┘\n```\n\n**Configuration** in `.claude/settings.json`:\n\n```json\n{\n  \"hooks\": {\n    \"Stop\": [{\n      \"hooks\": [{\n        \"type\": \"command\",\n        \"command\": \"scripts/tdd-loop-check.sh\",\n        \"timeout\": 60,\n        \"statusMessage\": \"Running tests...\"\n      }]\n    }]\n  }\n}\n```\n\nThe `tdd-loop-check.sh` script runs tests, lint, and typecheck. It tracks iteration count (max 25) and distinguishes code errors (loop) from environment errors (stop).\n\n## @include Directives\n\nCLAUDE.md uses `@include` to modularly load skills:\n\n```markdown\n# CLAUDE.md\n@.claude/skills/base/SKILL.md\n@.claude/skills/iterative-development/SKILL.md\n@.claude/skills/security/SKILL.md\n```\n\nThese are **resolved at load time** by Claude Code — the content is recursively inlined (max depth 5, cycle detection built in). This means skills actually become part of the prompt instead of just being listed as text.\n\n## Conditional Rules\n\nRules in `.claude/rules/` use YAML frontmatter with `paths:` to activate only when relevant files are being edited:\n\n```yaml\n# .claude/rules/react.md\n---\npaths: [\"src/components/**\", \"**/*.tsx\"]\n---\nPrefer functional components with hooks...\n```\n\n```yaml\n# .claude/rules/python.md\n---\npaths: [\"**/*.py\"]\n---\nUse type hints, pytest, ruff...\n```\n\n**Included rules:**\n\n| Rule | Activates When |\n|------|----------------|\n| `quality-gates.md` | Always (no paths: filter) |\n| `tdd-workflow.md` | Always |\n| `security.md` | Always |\n| `react.md` | Editing .tsx/.jsx files |\n| `typescript.md` | Editing .ts/.tsx files |\n| `python.md` | Editing .py files |\n| `nodejs-backend.md` | Editing api/routes/server files |\n\n## Smarter Compaction (PreCompact Hook)\n\nClaude Code's built-in compaction fires at ~83% context and summarizes everything into 20K tokens using a generic 9-section template. It doesn't know what YOUR project cares about.\n\nThe PreCompact hook fixes this by injecting **project-specific preservation priorities** into the summarizer:\n\n```\n┌─────────────────────────────────────────────────────────────┐\n│  Built-in compaction:                                       │\n│  \"Summarize this conversation\" → generic summary            │\n├─────────────────────────────────────────────────────────────┤\n│  With PreCompact hook:                                      │\n│  \"Summarize, but preserve ALL schema decisions verbatim,    │\n│   keep exact error messages, keep API contract details,     │\n│   reference these Key Decisions by name, and here's the     │\n│   current git state to include\" → project-aware summary     │\n└─────────────────────────────────────────────────────────────┘\n```\n\nThe hook auto-detects:\n- **Project type** (TypeScript/Next.js, Python/FastAPI, Flutter, etc.)\n- **Schema files** (Drizzle, Prisma, SQLAlchemy) → tells summarizer to preserve schema discussion\n- **API directories** → tells summarizer to preserve endpoint paths and contracts\n- **Key Decisions from CLAUDE.md** → tells summarizer to reference them by name\n- **Git state** → injects branch, uncommitted changes, staged files\n\nZero overhead during normal usage. Only runs when compaction actually fires.\n\n## Mnemos — Task-Scoped Memory Lifecycle\n\nClaude Code's built-in compaction is lossy and unreliable. It sometimes doesn't fire, `/compact` and `/clear` can fail (especially in multi-agent executions), and crashes/restarts lose all context. Mnemos provides **disk-persistent structured state** that survives all of these failure modes.\n\n```\n┌─────────────────────────────────────────────────────────────┐\n│  DEFAULT CLAUDE CODE          vs  WITH MNEMOS               │\n├─────────────────────────────────────────────────────────────┤\n│  Blind until 83.5%               Continuous 4-dim monitoring│\n│  Sudden hard compaction           Graduated: 40→60→75→83%   │\n│  Uniform summarization            Typed: goals never evict  │\n│  No cross-session memory          Auto checkpoint/resume    │\n│  Crash = total context loss       Crash = resume from disk  │\n│  Multi-agent: no shared state     Per-agent structured state│\n│  No behavioral awareness          Detects re-reads, scatter │\n└─────────────────────────────────────────────────────────────┘\n```\n\n### Post-Compaction Task Restoration (Two-Layer Defense)\n\nWhen compaction fires, the built-in summarizer often drops task-specific state. Mnemos uses two independent layers to guarantee restoration:\n\n```\nBEFORE COMPACTION                    AFTER COMPACTION\n\nPreCompact hook fires                First tool call → PreToolUse fires\n├── Write emergency checkpoint       ├── Detect \".mnemos/just-compacted\" marker\n├── Build task narrative from        ├── Read checkpoint-latest.json\n│   signals.jsonl (files, tools)     ├── Output full checkpoint into context\n├── Output STRONG preservation       ├── Delete marker (one-shot)\n│   instructions to summarizer       └── Claude now has: summary + checkpoint\n└── Write \".mnemos/just-compacted\"\n    marker file                      = Task fully restored\n```\n\n**Layer 1** (best-effort): PreCompact tells the summarizer what to keep, including inline checkpoint content with typed eviction priorities.\n\n**Layer 2** (guaranteed): Post-compaction injection via PreToolUse re-injects the full checkpoint on the first tool call after compaction. Doesn't depend on the summarizer. Fast path ~5ms when no compaction occurred.\n\n### Why Not Just Write to a Plain File?\n\nYou could — but you'd immediately face: what format? When to update? How to distinguish \"this is critical\" from \"this is nice to have\"? The MnemoGraph's typed nodes solve this:\n\n| Node Type | Eviction Policy | Example |\n|-----------|----------------|---------|\n| GoalNode | NEVER evict | \"Implement auth module\" |\n| ConstraintNode | NEVER evict | \"API backward compatibility\" |\n| ResultNode | Compress first | \"JWT middleware tested\" → summary kept |\n| WorkingNode | Compress first | Current reasoning / in-progress analysis |\n| ContextNode | Evictable | File contents → re-read from disk |\n\nWithout typed priorities, a checkpoint is just a blob. With them, the system knows goals > constraints > working memory > context, and makes intelligent decisions about what to restore within token budgets.\n\n### Resilience Beyond Normal Compaction\n\nThe real value isn't the happy path — it's when things go wrong:\n\n| Failure Mode | CC Built-in | Mnemos |\n|---|---|---|\n| Session crash/collapse | Context gone | Checkpoint on disk survives |\n| `/compact` doesn't fire | Truncation at limit | Fatigue hooks wrote checkpoints earlier |\n| Multi-agent child dies | No recovery | Child's `.mnemos/` has structured state |\n| Forced restart | Generic summary | SessionStart reloads full checkpoint |\n| `/clear` fails in multi-agent | Stuck in weird state | MnemoGraph is independent of CC's state |\n\n### Fatigue Model\n\n4 dimensions passively observed from hooks — no agent cooperation needed:\n\n| Dimension | Weight | Signal Source | Detects |\n|-----------|--------|---------------|---------|\n| Token utilization | 0.40 | Statusline JSON | How full the context window is |\n| Scope scatter | 0.25 | PreToolUse file paths | Agent bouncing between directories |\n| Re-read ratio | 0.20 | PreToolUse Read calls | Agent re-reading files (context loss) |\n| Error density | 0.15 | PostToolUse outcomes | Agent struggling (high error rate) |\n\nFatigue states: **FLOW** (0-0.4) → **COMPRESS** (0.4-0.6) → **PRE-SLEEP** (0.6-0.75) → **REM** (0.75-0.9) → **EMERGENCY** (0.9+). The fatigue model ensures checkpoints are written *before* things go wrong — so when a crash happens at 0.85, you have a recent checkpoint from 0.6.\n\n### CLI\n\n```bash\nmnemos init                    # Initialize .mnemos/\nmnemos status                  # Node counts + fatigue\nmnemos fatigue                 # Detailed 4-dimension breakdown\nmnemos checkpoint --force      # Write checkpoint now\nmnemos resume                  # Output checkpoint for session inject\nmnemos add goal \"Build auth\"   # Create a GoalNode\nmnemos bridge-icpg             # Import iCPG ReasonNodes\n```\n\n**Overhead:** ~5ms per tool call (fast path), 84KB on disk. Token signal auto-feeds via statusline.\n\n## iCPG — Intent-Augmented Code Property Graph\n\niCPG tracks *why* code exists, not just what it does. Every code change is linked to a ReasonNode that captures the intent, postconditions, and invariants.\n\n```bash\nicpg create \"Implement auth\" --scope src/auth/   # Create intent\nicpg record src/auth/middleware.ts                # Link symbols\nicpg query constraints src/auth/middleware.ts     # Get invariants\nicpg drift                                        # Check for drift\nicpg bootstrap                                    # Infer from git history\n```\n\n**Pre-Task Queries** (injected automatically via PreToolUse hook):\n- `icpg query context <file>` — What intents touch this file?\n- `icpg query constraints <file>` — What invariants must hold?\n- `icpg drift file <file>` — Has this file drifted from its intent?\n\n**6-Dimension Drift Detection:** spec drift, decision drift, ownership drift, test drift, usage drift, dependency drift.\n\n## Maggy Dashboard — AI Engineering Command Center (Optional)\n\nMaggy is a full-featured AI engineering command center. Install once, point it at your codebases and issue tracker, and get an interactive dashboard with chat, task triage, competitor intelligence, process analytics, and P2P session sync.\n\n```bash\ncd maggy/maggy\n./install.sh\n\n# Edit ~/.maggy/config.yaml — set your org, GitHub repos, codebase paths\nexport GITHUB_TOKEN=ghp_...\nexport ANTHROPIC_API_KEY=sk-ant-...\n\npython3 -m maggy.main   # Open http://localhost:8080\n```\n\nOr from inside any Claude Code session:\n\n```\n/maggy-init   # Interactive setup wizard\n/maggy        # Launch dashboard\n```\n\n### What it does\n\n- **Interactive Chat** — auto-connects to all active Claude/Codex/Kimi sessions, SSE streaming, session continuity via `--resume`, path-based history matching\n- **AI-prioritized Tasks** — Claude ranks your open issues by urgency, OKR alignment, and recency. 30-min SQLite cache with stale-cache fallback.\n- **One-click Execute** — spawns `claude -p` locally in the right codebase, with iCPG context pre-injected. Runs a TDD pipeline, then commits locally for your review.\n- **Competitor Intelligence** — AI-discovered competitors in whatever domain you configure, plus daily news briefing from RSS + Google News.\n- **Process Insights** — CLI session history analysis, health signals, self-improvement recommendations, event spine queries.\n- **P2P Mesh** — WebSocket-based multi-node session sync and handoff across machines, org-scoped networks, state quarantine.\n- **Auto-Bootstrap** — all services seed themselves on startup (history, CIKG, events). No empty tabs.\n- **Provider-agnostic** — GitHub Issues, Asana, or (stubbed) Linear. Swap trackers without touching services.\n\n### Dashboard Navigation\n\nNavigation is grouped by intent — 3 groups instead of 9 flat tabs:\n\n| Group | Tabs | Purpose |\n|-------|------|---------|\n| **Work** | Chat, Tasks, Watching | Do things — chat with Claude, triage issues |\n| **Intel** | Competitors, Insights | Learn things — competitor news, session analytics |\n| **System** | Budget, Models, Forge, Settings | Configure — spend limits, model routing, MCP gaps |\n\nChat is the default tab — auto-connects to all running CLI sessions on load.\n\n### Architecture\n\n```\nmaggy/\n├── maggy/                           # optional dashboard — run ./install.sh to enable\n│   ├── maggy/                       # Python package (importable as `maggy`)\n│   │   ├── main.py                  # FastAPI entry + auto-bootstrap\n│   │   ├── config.py                # ~/.maggy/config.yaml loader\n│   │   ├── providers/               # GitHub, Asana, Linear (stub)\n│   │   ├── services/                # chat, inbox, competitor, executor, activity\n│   │   ├── api/                     # REST endpoints (chat, mesh, process, etc.)\n│   │   ├── mesh/                    # P2P networking (discovery, sync, WebSocket)\n│   │   ├── process/                 # Process intelligence (patterns, signals, router)\n│   │   ├── history/                 # CLI session history parsers (Claude, Codex, Kimi)\n│   │   ├── improve/                 # Self-improvement (signals, analyzer)\n│   │   ├── cikg/                    # Code Intelligence Knowledge Graph\n│   │   ├── engram/                  # Memory entries (write/query/expire)\n│   │   ├── event_spine/             # Structured event emission + querying\n│   │   ├── forge/                   # MCP capability gap detection\n│   │   ├── heartbeat/               # Scheduled jobs (history, engram, mesh sync)\n│   │   └── static/                  # Dashboard (Tailwind + vanilla JS, no build step)\n│   ├── tests/                       # 468 tests\n│   └── install.sh                   # one-line install\n├── commands/maggy.md                # /maggy command\n├── commands/maggy-init.md           # /maggy-init wizard\n└── skills/maggy/SKILL.md            # skill reference\n```\n\n### Config-driven, no hardcoded anything\n\nOne `~/.maggy/config.yaml` drives everything — org name, domain, repos, codebase paths, competitor categories. No hardcoded board IDs or team lists.\n\n```yaml\norg: { name: \"Acme Corp\", domain: \"fintech\" }\nissue_tracker:\n  provider: \"github\"           # or \"asana\"\n  github:\n    org: \"acmecorp\"\n    repos: [\"acmecorp/api\", \"acmecorp/web\"]\ncodebases:\n  - { path: \"~/dev/acmecorp/api\", key: \"api\" }\n  - { path: \"~/dev/acmecorp/web\", key: \"web\" }\ncompetitors:\n  categories: [\"fintech\", \"embedded-finance\"]\n```\n\n### Safety model\n\nExecute and Chat both run Claude Code with `--dangerously-skip-permissions` so subprocesses aren't blocked waiting on approval prompts with no terminal attached. Mitigations in place:\n\n- `working_dir` and `project_path` are **validated against configured codebase roots** — both Execute and Chat reject arbitrary filesystem paths\n- **Per-session streaming lock** — `asyncio.Lock` prevents concurrent subprocess spawning via the Chat API\n- Dashboard **refuses to boot** if `auth_mode=\"local\"` is combined with a non-loopback host (would expose Execute on the local network)\n- RSS URLs **SSRF-validated** before fetching (blocks loopback, private, link-local)\n- `CLAUDECODE` env var stripped from subprocesses to allow nested Claude sessions\n- **No-cache static middleware** — `Cache-Control: no-store` prevents stale JS\n\nSee `maggy/README.md` for the full hardening notes.\n\n### P2P Mesh Network\n\nMulti-node session sync and handoff across machines. Each Maggy instance is a mesh peer that can share memory, discover other nodes, and synchronize state.\n\n| Component | What it does |\n|-----------|-------------|\n| **Peer Discovery** | Registry of known peers with address, org, last-seen tracking |\n| **Git Discovery** | Auto-discovers peers from shared git remotes across configured codebases |\n| **WebSocket Server/Client** | Bidirectional real-time communication between peers |\n| **Mesh Protocol** | 7 message types: `hello`, `share`, `request`, `response`, `quarantine`, `promote`, `heartbeat` |\n| **Quarantine** | Untrusted data from peers is quarantined until reviewed — prevents poisoned memory injection |\n| **Org Scoping** | Peers are filtered by org key so only your team's nodes connect |\n| **Provenance** | Tracks origin of shared data (which peer, when, confidence level) |\n\nConfigure in `~/.maggy/config.yaml`:\n\n```yaml\nmesh:\n  enabled: true\n  port: 8080\n  orgs: [\"my-team\"]\n  git_discovery: true\n  share_interval: 600\n```\n\n### Engram Memory\n\nPersistent memory system with typed records, namespace isolation, and multi-path retrieval. Engrams survive across sessions — they're stored in SQLite, not in-context.\n\n| Field | Purpose |\n|-------|---------|\n| `memory_type` | `fact`, `decision`, `code_ref`, `handoff` |\n| `origin` | `explicit` (user-created), `inferred` (AI-derived), `mesh` (from peer) |\n| `validity` | `active`, `superseded`, `expired` |\n| `confidence` | 0.0-1.0 trust score |\n| `namespace` | Project/session scoping |\n| `expires_at` | Optional TTL for auto-expiry |\n\nRetrieval paths: by namespace, by type, by keyword, by tag, or most recent. The heartbeat scheduler runs periodic expiry to clean stale entries.\n\n### Event Spine\n\nStructured event emission and querying across all Maggy services. Every significant action (task executed, competitor discovered, history analyzed, self-improvement run) emits a typed event with a standard header.\n\nEvents are stored in SQLite and queryable via the `/api/events` endpoint. The Insights tab visualizes event streams for debugging and auditing service behavior.\n\n### Other Subsystems\n\n| Subsystem | Purpose |\n|-----------|---------|\n| **CIKG** | Code Intelligence Knowledge Graph — codebase nodes, technology detection, landscape queries |\n| **Forge** | MCP capability gap detection — scans filesystem patterns, suggests MCP tools to fill gaps |\n| **History** | CLI session history parsers for Claude, Codex, and Kimi — topic extraction, session patterns |\n| **Improve** | Self-improvement — signal collection, health scoring, actionable recommendations |\n| **Budget** | Daily token spend limits with per-provider breakdown |\n| **Model Router** | Reward-based heatmap for model selection by task type |\n| **Heartbeat** | Scheduled jobs — history refresh, engram expiry, self-improvement, mesh sync |\n\n## Pre-configured Permissions\n\n`.claude/settings.json` includes permission rules so users don't get pestered for routine operations:\n\n```json\n{\n  \"permissions\": {\n    \"allow\": [\n      \"Bash(npm test *)\",\n      \"Bash(npm run lint *)\",\n      \"Bash(pytest *)\",\n      \"Bash(git status *)\",\n      \"Bash(gh pr *)\"\n    ],\n    \"deny\": [\n      \"Bash(rm -rf *)\",\n      \"Bash(git push --force *)\",\n      \"Write(.env)\",\n      \"Write(.env.*)\"\n    ]\n  }\n}\n```\n\n## CLAUDE.local.md (Private Overrides)\n\nEach developer gets a `.gitignore`'d `CLAUDE.local.md` for personal preferences:\n\n```markdown\n# My Preferences\n- I prefer verbose explanations\n- My local DB runs on port 5433\n- Use pnpm instead of npm\n```\n\nThis loads at **higher priority** than project `CLAUDE.md` — personal preferences override team config without polluting the repo.\n\n## Agent Teams\n\nEvery project runs as a coordinated team of AI agents with **proper frontmatter definitions**:\n\n```yaml\n# .claude/agents/team-lead.md\n---\nname: team-lead\ndescription: Orchestrates the agent team\nmodel: sonnet\ntools: [Read, Glob, Grep, TaskCreate, TaskUpdate, TaskList, TaskGet, SendMessage]\ndisallowedTools: [Write, Edit, Bash]\nmaxTurns: 50\neffort: high\n---\n```\n\n**Default Team:**\n\n| Agent | Role | Can Edit Code? |\n|-------|------|----------------|\n| **Team Lead** | Orchestrates, assigns tasks (never writes code) | No |\n| **Quality Agent** | Verifies RED/GREEN TDD phases, coverage >= 80% | No |\n| **Security Agent** | OWASP scanning, secrets detection, dependency audit | No |\n| **Code Review Agent** | Multi-engine reviews | No |\n| **Merger Agent** | Creates feature branches and PRs via `gh` CLI | No |\n| **Feature Agent (x N)** | One per feature, follows strict TDD pipeline | Yes |\n\n**Pipeline (enforced by task dependencies):**\n\n```\nSpec > Spec Review > Tests > RED Verify > Implement >\nGREEN Verify > Validate > Code Review > Security > Branch+PR\n```\n\n```bash\n# Auto-spawned by /initialize-project, or manually:\n/spawn-team\n```\n\n## What Gets Created\n\n```\nyour-project/\n├── .claude/\n│   ├── agents/               # Agent definitions with frontmatter\n│   │   ├── team-lead.md      # name, model, tools, disallowedTools, maxTurns\n│   │   ├── quality.md\n│   │   ├── security.md\n│   │   ├── code-review.md\n│   │   ├── merger.md\n│   │   └── feature.md\n│   ├── rules/                # Conditional rules (paths: frontmatter)\n│   │   ├── quality-gates.md  # Always active\n│   │   ├── tdd-workflow.md   # Always active\n│   │   ├── security.md       # Always active\n│   │   ├── react.md          # Active on .tsx/.jsx files\n│   │   ├── typescript.md     # Active on .ts/.tsx files\n│   │   ├── python.md         # Active on .py files\n│   │   └── nodejs-backend.md # Active on api/routes/server files\n│   ├── skills/               # Skills loaded via @include\n│   │   ├── base/SKILL.md\n│   │   ├── iterative-development/SKILL.md\n│   │   ├── security/SKILL.md\n│   │   ├── mnemos/SKILL.md\n│   │   ├── cross-agent-delegation/SKILL.md\n│   │   └── [framework]/SKILL.md\n│   └── settings.json         # Permissions + hooks + statusline\n├── scripts/\n│   ├── tdd-loop-check.sh     # Stop hook script for TDD loops\n│   ├── icpg/                 # Intent-Augmented Code Property Graph\n│   └── mnemos/               # Task-Scoped Memory Lifecycle\n├── .mnemos/                  # Mnemos state (auto-created, gitignored)\n│   ├── mnemo.db              # SQLite MnemoGraph\n│   ├── fatigue.json          # Live fatigue signal\n│   ├── signals.jsonl         # Behavioral signal log\n│   └── checkpoint-latest.json # Most recent checkpoint\n├── .github/workflows/\n│   ├── quality.yml\n│   └── security.yml\n├── _project_specs/\n│   ├── features/\n│   └── todos/\n├── CLAUDE.md                 # @include directives, project context\n└── CLAUDE.local.md           # Private developer overrides (gitignored)\n```\n\n## Commit Hygiene\n\n```\n┌─────────────────────────────────────────────────────────────┐\n│  COMMIT SIZE THRESHOLDS                                     │\n├─────────────────────────────────────────────────────────────┤\n│  OK:     ≤ 5 files,  ≤ 200 lines                           │\n│  WARN:   6-10 files, 201-400 lines  → \"Commit soon\"        │\n│  STOP:   > 10 files, > 400 lines    → \"Commit NOW\"         │\n└─────────────────────────────────────────────────────────────┘\n```\n\n## Skills Included (62 Skills)\n\n### Core Skills\n| Skill | Purpose |\n|-------|---------|\n| `base.md` | Universal patterns, constraints, TDD workflow, atomic todos |\n| `iterative-development.md` | TDD loops via Stop hooks (replaces Ralph Wiggum) |\n| `mnemos.md` | Task-scoped memory lifecycle — fatigue monitoring, checkpoints, typed compaction |\n| `icpg.md` | Intent-augmented code property graph — track why code exists, detect drift |\n| `code-review.md` | Mandatory code reviews - Claude, Codex, Gemini, or multi-engine |\n| `codex-review.md` | OpenAI Codex CLI code review |\n| `gemini-review.md` | Google Gemini CLI code review, 1M token context |\n| `workspace.md` | Multi-repo workspace awareness, contract tracking |\n| `commit-hygiene.md` | Atomic commits, PR size limits |\n| `code-deduplication.md` | Prevent semantic duplication with capability index |\n| `agent-teams.md` | Agent team workflow with proper frontmatter definitions |\n| `ticket-craft.md` | AI-native ticket writing optimized for Claude Code |\n| `maggy.md` | Optional local AI command center — AI-prioritized inbox, one-click TDD execute, competitor intelligence. See the [Maggy section](#maggy--ai-engineering-command-center-optional) for the full docs |\n| `team-coordination.md` | Multi-person projects, shared state, handoffs |\n| `code-graph.md` | Persistent code graph via MCP |\n| `cpg-analysis.md` | Deep CPG analysis - Joern + CodeQL |\n| `security.md` | OWASP patterns, secrets management |\n| `credentials.md` | Centralized API key management |\n| `session-management.md` | Context preservation, resumability |\n| `project-tooling.md` | gh, vercel, supabase CLI + deployment |\n| `existing-repo.md` | Analyze existing repos, setup guardrails |\n| `cross-agent-delegation.md` | Cross-agent task routing, Codex auto-review, Kimi delegation |\n| `polyphony.md` | Multi-agent orchestration with container-isolated workspaces |\n\n### Language & Framework Skills\n| Skill | Purpose |\n|-------|---------|\n| `python.md` | Python + ruff + mypy + pytest |\n| `typescript.md` | TypeScript strict + eslint + jest |\n| `nodejs-backend.md` | Express/Fastify patterns, repositories |\n| `react-web.md` | React + hooks + React Query + Zustand |\n| `react-native.md` | Mobile patterns, platform-specific code |\n| `android-java.md` | Android Java with MVVM, ViewBinding, Espresso |\n| `android-kotlin.md` | Android Kotlin with Coroutines, Jetpack Compose, Hilt |\n| `flutter.md` | Flutter with Riverpod, Freezed, go_router |\n\n### UI Skills\n| Skill | Purpose |\n|-------|---------|\n| `ui-web.md` | Web UI - Tailwind, dark mode, accessibility |\n| `ui-mobile.md` | Mobile UI - React Native, iOS/Android patterns |\n| `ui-testing.md` | Visual testing |\n| `playwright-testing.md` | E2E testing - Playwright, Page Objects |\n| `user-journeys.md` | User experience flows |\n| `pwa-development.md` | Progressive Web Apps - service workers, offline |\n\n### Database & Backend Skills\n| Skill | Purpose |\n|-------|---------|\n| `database-schema.md` | Schema awareness |\n| `supabase.md` | Core Supabase CLI, migrations, RLS |\n| `supabase-nextjs.md` | Next.js + Supabase + Drizzle ORM |\n| `supabase-python.md` | FastAPI + Supabase |\n| `supabase-node.md` | Express/Hono + Supabase |\n| `firebase.md` | Firebase Firestore, Auth, Storage |\n| `cloudflare-d1.md` | Cloudflare D1 SQLite with Workers |\n| `aws-dynamodb.md` | AWS DynamoDB single-table design |\n| `aws-aurora.md` | AWS Aurora Serverless v2 |\n| `azure-cosmosdb.md` | Azure Cosmos DB |\n\n### AI & Agentic Skills\n| Skill | Purpose |\n|-------|---------|\n| `agentic-development.md` | Build AI agents |\n| `llm-patterns.md` | AI-first apps, LLM testing |\n| `ai-models.md` | Latest models reference |\n\n### Content, Integration & Other Skills\n| Skill | Purpose |\n|-------|---------|\n| `aeo-optimization.md` | AI Engine Optimization |\n| `web-content.md` | SEO + AI discovery |\n| `site-architecture.md` | Technical SEO |\n| `web-payments.md` | Stripe Checkout, subscriptions |\n| `reddit-api.md` | Reddit API |\n| `reddit-ads.md` | Reddit Ads API + agentic optimization |\n| `ms-teams-apps.md` | Microsoft Teams bots |\n| `posthog-analytics.md` | PostHog analytics |\n| `shopify-apps.md` | Shopify app development |\n| `woocommerce.md` | WooCommerce REST API |\n| `medusa.md` | Medusa headless commerce |\n| `klaviyo.md` | Klaviyo email/SMS marketing |\n\n## Usage Patterns\n\n### New Project\n```bash\nmkdir my-new-app && cd my-new-app\nclaude\n> /initialize-project\n```\n\n### Existing Project\n```bash\ncd my-existing-app\nclaude\n> /initialize-project\n# Auto-detects existing code → runs analysis first\n```\n\n### Update Skills Globally\n```bash\ncd \"$(cat ~/.claude/.bootstrap-dir)\"\ngit pull\n./install.sh\n```\n\n## Prerequisites\n\n```bash\n# GitHub CLI\nbrew install gh && gh auth login\n\n# Vercel CLI (optional)\nnpm i -g vercel && vercel login\n\n# Supabase CLI (optional)\nbrew install supabase/tap/supabase && supabase login\n```\n\n## Evolution\n\n| Version | Date | What Changed |\n|---------|------|-------------|\n| **v1.0** | Jan 2026 | Initial release — 30+ skills, `/initialize-project`, TDD via Ralph Wiggum loops, Python/TypeScript/React support |\n| **v2.0** | Jan 2026 | Skills restructured (`folder/SKILL.md`), YAML frontmatter, validation tests, 60+ skills across 10 categories |\n| **v3.0** | Mar 2026 | **Real Claude Code infrastructure** — Ralph Wiggum replaced with Stop hooks, `@include` directives, conditional rules (`paths:` frontmatter), agent teams via `.claude/agents/`, pre-configured permissions |\n| **v3.3** | Apr 2026 | Mnemos (task-scoped memory), iCPG (intent tracking + drift detection), Maggy dashboard MVP (inbox, execute, competitors) |\n| **v3.5** | Apr 2026 | PreCompact hook for smarter compaction, fatigue model (4 dimensions), hook error resilience |\n| **v3.6** | May 2026 | Cross-tool compatibility (Claude + Kimi + Codex), cross-agent intelligence (Codex auto-review, Kimi delegation), complexity-based routing |\n| **v4.0** | May 2026 | **Polyphony** — multi-agent orchestration with container isolation, 5-dimension complexity scoring, Docker runtime, 3 agent adapters, state machine task lifecycle |\n| **v5.0** | May 2026 | **Autonomous command center** — Interactive Chat with `--resume` takeover, P2P Mesh networking, process intelligence, auto-bootstrap, grouped UI (Work/Intel/System), 468 tests, security hardening (path validation, streaming lock) |\n\n### Where we started vs where we are\n\n| Area | v1 (Jan 2026) | v5 (May 2026) |\n|------|---------------|---------------|\n| **Scope** | Claude Code project setup tool | Autonomous AI engineering platform |\n| **TDD** | Ralph Wiggum plugin (didn't exist) | Real Stop hooks with iteration tracking |\n| **Skills** | 30 flat `.md` files | 62 skills with `@include`, conditional rules |\n| **Memory** | None (lost on compaction) | Mnemos typed graph + fatigue model |\n| **Intent** | None | iCPG with 6-dimension drift detection |\n| **Agents** | Single Claude session | Polyphony containers + cross-agent delegation |\n| **Models** | Claude only | Claude + Codex + Kimi + complexity routing |\n| **Dashboard** | None | Maggy — chat, tasks, competitors, insights, mesh |\n| **Networking** | None | P2P Mesh (WebSocket sync, org-scoped) |\n| **Tests** | Shell validation script | 468 pytest tests + integration suite |\n\n## Contributing\n\nSee [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.\n\n## Changelog\n\nSee [CHANGELOG.md](CHANGELOG.md) for version history.\n\n## License\n\nMIT - See [LICENSE](LICENSE)\n\n## Credits\n\nBuilt on learnings from 100+ projects across customer experience management, agentic AI platforms, mobile apps, and full-stack web applications.\n\n---\n\n**Need help scaling AI in your org?** [Claude Code & MCP experts](https://leanai.ventures/aiops/claude)\n"
  },
  {
    "path": "_project_specs/00-autonomous-engineering-roadmap.md",
    "content": "# Autonomous Engineering Roadmap\n\nA set of specs closing the gaps between claude-bootstrap's current code-intelligence stack (`codebase-memory-mcp` + `iCPG` + `Joern/CodeQL` + `Mnemos` + `code-deduplication`) and what an autonomous coding agent actually needs to ship changes without supervision.\n\n## Why these?\n\nAutonomous agents fail in 10 specific, repeatable ways (see [comparison doc in chat history — 2026-04-20](#)). Our current stack addresses 9 of them. The specs below close the remaining agent-observable gaps and add the two \"frontier\" capabilities (multimodal ingestion, verifiable contracts).\n\n## Priority order\n\n**Tier 1 — highest leverage, unlocks the rest:**\n\n| # | Spec | Why it matters |\n|---|---|---|\n| 01 | [Runtime observability](01-runtime-observability.md) | Drift detection is static — an agent that ships code needs a production feedback signal to know if the change actually worked |\n| 03 | [Verifiable contracts](03-verifiable-contracts.md) | iCPG postconditions are currently natural-language. Generating property-based tests from them makes them machine-checkable. |\n| 07 | [Human escalation protocol](07-human-escalation-protocol.md) | When the agent is stuck, it needs a formal \"page a human with this packet\" channel |\n\n**Tier 2 — valuable, not blocking:**\n\n| # | Spec | Why it matters |\n|---|---|---|\n| 08 | [Auto CODE_INDEX](08-auto-code-index.md) | The capability index currently depends on humans maintaining it. Auto-derive from the graph. |\n| 04 | [Multi-agent coordination](04-multi-agent-coordination.md) | When two agents touch the same area, we need locking / negotiation |\n| 02 | [Rollback & recovery](02-rollback-and-recovery.md) | Drift flags a problem; we still need automated revert paths |\n\n**Tier 3 — frontier / optional:**\n\n| # | Spec | Why it matters |\n|---|---|---|\n| 05 | [Confidence calibration](05-confidence-calibration.md) | Reinforcement loop — learn from past agent actions which patterns fail |\n| 06 | [Cost / budget awareness](06-cost-budget-awareness.md) | Agents stuck in loops burn real money. Hard budget stops. |\n| 09 | [Multimodal ingestion](09-multimodal-ingestion.md) | Graphify-style. Only matters if your repos include docs/images/video. |\n\n## What each spec contains\n\n- **Context** — the failure mode being addressed\n- **Goal** — one-sentence outcome\n- **Approach** — concrete integration points with existing skills/scripts\n- **Success criteria** — how we know it works\n- **Effort** — rough size (small / medium / large)\n- **Depends on** — other specs that should land first\n\n## Implementation convention\n\nWhen picking up a spec:\n\n1. Create a feature branch `feat/spec-XX-<short-slug>`\n2. Add an entry to `CHANGELOG.md` under an \"Unreleased\" section\n3. Write the feature following TDD (as the rest of the project does)\n4. Update the spec file's `Status` field when merged\n\nStatus values: `pending` · `in-progress` · `in-review` · `done` · `deferred`\n"
  },
  {
    "path": "_project_specs/01-runtime-observability.md",
    "content": "# Spec 01: Runtime Observability for Drift Detection\n\n**Status:** pending\n**Priority:** Tier 1 (highest leverage)\n**Effort:** Medium\n\n## Context\n\n`iCPG` detects drift **statically** — it can tell you a symbol's checksum changed, its tests disappeared, or a postcondition's predicate no longer holds against the current codebase. What it cannot tell you is whether the running system still delivers what the intent promised.\n\nAn autonomous agent that ships code needs a feedback signal after deploy. Otherwise:\n\n- A refactor passes all tests and drift checks but tanks p99 latency in production → agent has no signal\n- A bug fix validates against one invariant but introduces regressions users hit → silent\n- An intent's postcondition is \"<500ms response\" — a static graph can't verify this\n\n## Goal\n\nBridge `iCPG` to runtime telemetry so drift detection includes post-deploy signals, not just pre-commit signals.\n\n## Approach\n\n### Step 1 — Define a runtime-signal abstraction\n\nAdd a new edge type to iCPG:\n\n```\nVALIDATED_IN_PROD    Reason → Metric    (intent's postcondition has a runtime check)\n```\n\nA `Metric` node references an observability query:\n\n```yaml\nmetric:\n  id: \"checkout_p99_under_500ms\"\n  source: \"datadog\"       # datadog | sentry | honeycomb | prometheus\n  query: \"avg:trace.checkout.latency.p99{env:prod}\"\n  predicate: \"value < 500\"\n  window: \"1h\"\n```\n\n### Step 2 — Pluggable observability adapters\n\nOne-file adapters per backend (`scripts/icpg/observability/`):\n\n- `datadog_adapter.py` — query API key from env, return metric value\n- `sentry_adapter.py` — query event frequency for a given issue\n- `honeycomb_adapter.py` — run a Honeycomb query and extract result\n- `prometheus_adapter.py` — PromQL\n- `stub_adapter.py` — for testing, reads from a JSON file\n\nEach exposes `fetch(metric_id, window) -> float | None`.\n\n### Step 3 — Extend `icpg drift check` with `--include-runtime`\n\nWhen the flag is set, evaluate every `VALIDATED_IN_PROD` edge by calling its adapter. Runtime predicate failure adds a 7th drift dimension:\n\n```\nRuntime drift   Postcondition metric violates its predicate in production\n```\n\n### Step 4 — Hook into claude-bootstrap's post-commit flow\n\nThe `hooks/post-commit-graph` script runs `icpg record`. Add an optional `--check-runtime` step that queries the adapters for any symbols touched in this commit, so the agent sees drift before the change ships.\n\n## Integration points\n\n- `scripts/icpg/models.py` — add `MetricNode`, `RuntimeEdge`\n- `scripts/icpg/drift.py` — add `check_runtime_drift()`\n- `scripts/icpg/__main__.py` — wire `drift check --include-runtime` flag\n- `skills/icpg/SKILL.md` — document the pattern\n- `templates/icpg-metric.yaml` — template for declaring metrics\n\n## Success criteria\n\n1. `icpg drift check --include-runtime` queries configured adapters and reports runtime-dimension drift\n2. At least one adapter (Datadog or Sentry) ships with docs + example config\n3. A test harness using `stub_adapter` verifies runtime drift triggers correctly\n4. Agent receives runtime signal in pre-task query output (`icpg query risk` includes current runtime state)\n5. Zero network calls when no `VALIDATED_IN_PROD` edges exist — backward compatible\n\n## Depends on\n\nNone — can be built independently on top of current iCPG.\n\n## Follow-ups\n\n- Spec 02 (rollback) uses the same signal to auto-revert on severe drift\n- Spec 05 (confidence calibration) learns from runtime failures\n"
  },
  {
    "path": "_project_specs/02-rollback-and-recovery.md",
    "content": "# Spec 02: Rollback & Recovery\n\n**Status:** pending\n**Priority:** Tier 2\n**Effort:** Medium\n\n## Context\n\nWhen `iCPG` detects drift or a runtime signal (Spec 01) indicates a shipped change broke something, the agent has no automated path to recover. It knows the problem exists but still has to manually coordinate a revert — find the right commit, check for downstream work, revert, re-verify.\n\nFor autonomous engineering this needs to be a first-class operation. The agent should be able to say \"revert intent R-abc because its postcondition failed in production\" and get a safe, auditable rollback.\n\n## Goal\n\nAdd a `icpg revert` command that safely undoes all commits attributed to a given ReasonNode, handling downstream dependencies and leaving a verifiable audit trail.\n\n## Approach\n\n### Step 1 — Track commit SHAs on intents\n\niCPG already has `CREATES` / `MODIFIES` edges between ReasonNodes and Symbols. Extend the `record` command to also store the commit SHA that made the change:\n\n```\nCREATES      Reason → Symbol    [commit_sha, timestamp]\nMODIFIES     Reason → Symbol    [commit_sha, timestamp]\n```\n\n### Step 2 — `icpg revert <intent-id>`\n\nThe command:\n\n1. Collects all commit SHAs attributed to this intent (from its edges)\n2. Checks for downstream `REQUIRES` intents whose postconditions depend on this one\n3. If downstream intents exist and aren't in `drifted`/`abandoned` status → refuse revert, explain the chain\n4. Otherwise: `git revert --no-commit <sha1> <sha2> ...` in reverse chronological order\n5. Runs the intent's `VALIDATED_BY` tests to confirm pre-intent state is reached\n6. Updates the intent status to `reverted` (new status)\n7. Emits a `REVERTED` edge type linking the revert commit to the original\n\n### Step 3 — Auto-revert on severe drift (opt-in)\n\nWire into drift detection: when `Runtime drift` severity > 0.9 AND drift age < 1h AND `auto_revert: true` is set on the intent → trigger `icpg revert` automatically and page a human (Spec 07).\n\nConfig per-project in `.icpg/config.yaml`:\n\n```yaml\nauto_revert:\n  enabled: false        # opt-in per project\n  severity_threshold: 0.9\n  max_age_minutes: 60\n  require_test_pass: true\n```\n\n### Step 4 — Recovery for partial failures\n\nIf `git revert` fails mid-way (conflicts, missing commits), leave the tree in a clean state (`git revert --abort`) and report exactly which commit failed + why.\n\n## Integration points\n\n- `scripts/icpg/__main__.py` — add `revert` subcommand\n- `scripts/icpg/models.py` — add `commit_sha` field on edges, `reverted` status, `REVERTED` edge type\n- `scripts/icpg/drift.py` — optional auto-revert trigger for severe runtime drift\n- `hooks/post-commit-graph` — capture SHA when recording\n- `skills/icpg/SKILL.md` — add revert section\n\n## Success criteria\n\n1. `icpg revert <id>` reverts all commits attributed to that intent cleanly or explains why it can't\n2. Downstream `REQUIRES` intents block the revert with a clear message\n3. Auto-revert is opt-in per-intent and only fires on high-severity runtime drift\n4. Every revert is logged in the graph with `REVERTED` edges pointing to the original commits\n5. A test harness verifies revert correctness against a scripted intent lifecycle\n\n## Depends on\n\n- Spec 01 (runtime observability) — the auto-revert signal comes from runtime drift\n"
  },
  {
    "path": "_project_specs/03-verifiable-contracts.md",
    "content": "# Spec 03: Verifiable Contracts (Property-Based Test Generation)\n\n**Status:** pending\n**Priority:** Tier 1 (highest leverage)\n**Effort:** Large\n\n## Context\n\niCPG's ReasonNodes already carry formal contracts:\n\n```\npreconditions:   What must be true before execution\npostconditions:  What must be true when fulfilled\ninvariants:      What must remain true\n```\n\nToday these are natural-language strings. Drift detection matches commit patterns and checksums against them heuristically. That's good but not verifiable — the agent can't prove a postcondition still holds after a change.\n\nFor autonomous engineering, we want machine-checkable contracts: the agent writes a postcondition, and the system generates tests that will fail if the postcondition is ever violated.\n\n## Goal\n\nGenerate property-based tests from iCPG postconditions so drift detection becomes \"did the test pass?\" instead of \"does the string still plausibly match?\"\n\n## Approach\n\n### Step 1 — Structured postconditions (optional schema)\n\nLet authors write postconditions in either natural language (current) or a structured form that's machine-generatable:\n\n```yaml\npostconditions:\n  - type: \"returns\"\n    of: \"save_response\"\n    shape: \"Response\"\n    properties:\n      - \"response.id is not null\"\n      - \"response.org_id == input.org_id\"\n      - \"len(response.answers) == len(input.answers)\"\n  - type: \"invariant\"\n    holds: \"during_save\"\n    assertion: \"db.responses.count() increases by 1\"\n```\n\nThe structured form compiles to tests; natural language fallback uses LLM-assisted generation (Step 2).\n\n### Step 2 — Pluggable property-based test generators\n\nOne generator per language/framework:\n\n- `scripts/icpg/codegen/hypothesis_python.py` — Hypothesis (Python)\n- `scripts/icpg/codegen/fastcheck_ts.py` — fast-check (TypeScript)\n- `scripts/icpg/codegen/proptest_rust.py` — proptest (Rust)\n- Natural-language postconditions use LLM generation, structured ones compile directly\n\nEach takes a `ReasonNode` and returns a test file with a `# @icpg-generated from R-abc123` header so the agent knows not to hand-edit.\n\n### Step 3 — `icpg contracts generate <intent-id>`\n\nCLI command that:\n\n1. Reads the intent's postconditions\n2. Detects the language of the scope files (already tracked)\n3. Invokes the right generator\n4. Writes tests to `tests/generated/contracts/<intent-id>.test.py` (or equivalent)\n5. Adds a `VALIDATED_BY` edge automatically\n\n`icpg contracts generate --all` regenerates every intent's tests (bulk operation for upgrading existing projects).\n\n### Step 4 — Drift check gains a \"contract-verified\" signal\n\nExisting drift detection checks whether `VALIDATED_BY` tests exist and pass. With this spec, those tests are now *derived from the postconditions* rather than hand-written, so failure is a direct postcondition violation signal — not just \"a test broke.\"\n\n### Step 5 — Regenerate on intent edit\n\nWhen a ReasonNode's postconditions change, stale generated tests are flagged. Agent can run `icpg contracts sync` to regenerate; humans can review the diff.\n\n## Integration points\n\n- `scripts/icpg/models.py` — add structured `postcondition` variants alongside existing strings\n- `scripts/icpg/codegen/` — new package, one module per language/framework\n- `scripts/icpg/__main__.py` — `contracts generate`, `contracts sync` subcommands\n- `skills/icpg/SKILL.md` — document how to write structured postconditions\n- `templates/reasonnode-structured.yaml` — template showing both forms\n\n## Success criteria\n\n1. Given an intent with structured postconditions, `icpg contracts generate` produces a runnable property-based test in Hypothesis/fast-check\n2. The generated test has a header marking it as machine-generated\n3. Running the test suite fails immediately when a postcondition is violated in the actual implementation\n4. Natural-language postconditions fall back to LLM generation cleanly (doesn't silently skip)\n5. Drift detection differentiates \"stale test\" from \"postcondition violation\" in its severity score\n\n## Depends on\n\nNone (iCPG only). But pairs well with:\n\n- Spec 01 — runtime postconditions (metric predicates) complement code-level postconditions\n- Spec 02 — a generated test failure is a strong auto-revert signal\n"
  },
  {
    "path": "_project_specs/04-multi-agent-coordination.md",
    "content": "# Spec 04: Multi-Agent Coordination (Symbol-Level Locks)\n\n**Status:** pending\n**Priority:** Tier 2\n**Effort:** Medium\n\n## Context\n\nclaude-bootstrap already has `agent-teams` and `team-coordination` skills, and Maggy ships with a P2P session-handoff pattern. But when two agents (or two sessions of the same agent) want to modify the same area of code, there's no coordination protocol. First-to-commit wins, which creates silent merge conflicts, duplicated work, and lost intent tracking.\n\nFor autonomous engineering at team scale (multiple agents, or one agent coordinating long-running subtasks), we need intent-level and symbol-level locks.\n\n## Goal\n\nAgents claim exclusive work on an intent or set of symbols before modifying, negotiate with holders of conflicting locks, and release on completion or timeout.\n\n## Approach\n\n### Step 1 — Lock primitive in iCPG\n\nAdd a `lock` table and edge type:\n\n```\nLOCKED_BY    Reason | Symbol → Agent    [acquired_at, expires_at, purpose]\n```\n\nLocks are scoped to an intent (broadest), a set of files, or a set of symbols (finest). A lock has:\n\n- `holder_id` — agent or session identifier\n- `scope` — intent id | files[] | symbols[]\n- `purpose` — one-line description (\"refactor auth service\")\n- `acquired_at` / `expires_at` — auto-expire to prevent orphans (default 30 min)\n- `heartbeat_at` — renewed periodically by the holder\n\n### Step 2 — `icpg lock` / `icpg unlock` commands\n\n```bash\nicpg lock intent R-abc --purpose \"refactor auth\" --expires 30m\nicpg lock symbols auth.login,auth.logout --purpose \"rate-limiting fix\"\nicpg locks list                              # show all active locks\nicpg unlock R-abc                             # release\nicpg locks prune                              # remove expired\n```\n\nLock attempts on a held scope return the holder's info so the requesting agent can decide what to do (wait, negotiate, defer).\n\n### Step 3 — Pre-task query integration\n\nExtend the 3 canonical pre-task queries with a 4th:\n\n| Query | What It Answers |\n|---|---|\n| `icpg query locks <scope>` | Is someone else working on this right now? |\n\nThe PreToolUse hook adds this to the injected context before any Edit/Write call.\n\n### Step 4 — Negotiation protocol\n\nWhen an agent wants a held lock, it sends a `negotiation_request` to the holder (Mnemos message):\n\n- Requester states: intent, priority, estimated duration\n- Holder responds: `accept` (release), `defer` (hold until completion), `split` (narrow the lock to specific symbols)\n\nIf no response within 5 minutes, the requester either takes the lock (if the holder's heartbeat is stale) or escalates (Spec 07).\n\n### Step 5 — Conflict prevention at commit time\n\nPost-commit hook verifies the committing agent holds the right lock for all symbols the commit modified. If not, the commit is logged as `unauthorized_modification` and the drift check flags it.\n\n## Integration points\n\n- `scripts/icpg/models.py` — `Lock`, `LockedByEdge`\n- `scripts/icpg/store.py` — `acquire_lock`, `release_lock`, `prune_locks`, `list_locks`\n- `scripts/icpg/__main__.py` — `lock`, `unlock`, `locks` subcommands\n- `hooks/pre-tool-use` — inject active-lock context\n- `hooks/post-commit-graph` — verify lock matches modified symbols\n- `skills/agent-teams/SKILL.md` — add locking discipline section\n- `skills/icpg/SKILL.md` — document the 4th pre-task query\n\n## Success criteria\n\n1. Two concurrent agents attempting to modify the same symbol can't both succeed — the second sees the held lock\n2. Locks auto-expire 30 min after last heartbeat (agents don't have to remember to release)\n3. Pre-task queries include active-lock info\n4. Commits violating lock ownership are flagged in drift reports\n5. Negotiation protocol works: requester gets a structured response from holder, or escalation fires\n\n## Depends on\n\n- Spec 07 (escalation) — when negotiation fails, escalation fires\n- Builds on existing `agent-teams` and Maggy P2P patterns\n"
  },
  {
    "path": "_project_specs/05-confidence-calibration.md",
    "content": "# Spec 05: Confidence Calibration (Reinforcement Loop)\n\n**Status:** pending\n**Priority:** Tier 3 (frontier)\n**Effort:** Medium\n\n## Context\n\niCPG's `get_risk_profile` query today classifies symbols as fragile/stable based on ownership history and drift count. It doesn't learn from what actually failed when agents touched it. An agent that tried refactoring this file three times and failed gets the same risk score as one that hasn't been tried yet.\n\nFor autonomous engineering, we want a reinforcement loop: past agent failures against a symbol or pattern should raise its risk score for future agents.\n\n## Goal\n\nTrack agent actions and their outcomes against symbols/patterns, and use that history to calibrate confidence for future pre-task queries.\n\n## Approach\n\n### Step 1 — Action-outcome tracking\n\nAdd two new node types to iCPG:\n\n```\nAgentAction   { id, agent, intent, scope[], timestamp }\nOutcome       { action_id, result, evidence }\n```\n\nResult types:\n- `success` — tests passed, intent fulfilled, no drift\n- `partial` — intent fulfilled but introduced drift elsewhere\n- `failure_test` — tests failed, rolled back\n- `failure_runtime` — shipped, runtime drift detected (Spec 01)\n- `abandoned` — agent gave up\n\nEvidence is a pointer — commit SHA, test output, drift report.\n\n### Step 2 — Hook into existing flows\n\nAutomatic capture:\n\n- Pre-task query writes an open `AgentAction` node\n- Post-commit: matches to the most recent pending action and records outcome based on test results\n- Drift check: if a `VALIDATED_BY` test fails on an intent, the agent action tied to that intent's commit is marked `failure_test`\n- Spec 01 runtime drift: marks `failure_runtime`\n- Spec 02 auto-revert: marks `abandoned`\n\n### Step 3 — Risk score now includes success rate\n\n`icpg query risk <symbol>` returns a calibrated score:\n\n```\nHistorical success rate for this symbol: 40% (2 of 5 attempts successful)\nPattern complexity: high (10+ dependents, 3 owners, drifted twice)\nRecommendation: treat as fragile — consider smaller changes or pair\n```\n\nCalibration uses a simple Bayesian update: prior = structural risk (current method), likelihood = recent action outcomes.\n\n### Step 4 — Pattern-level learning (stretch)\n\nFor autonomous agents, single-symbol history is too narrow — we want \"refactors of dataclasses with >5 fields fail 60% of the time.\" This requires clustering actions by pattern, not just symbol. Defer this to a v2 of this spec; first ship the single-symbol version.\n\n### Step 5 — Privacy & data hygiene\n\nAction history is sensitive (could leak intent details). Make it:\n\n- Opt-out per project (`.icpg/config.yaml: track_outcomes: false`)\n- Redact content, keep structure only (symbol ids, outcome types, timestamps)\n- Never exported outside the `.icpg/` directory\n\n## Integration points\n\n- `scripts/icpg/models.py` — `AgentAction`, `Outcome` node types\n- `scripts/icpg/store.py` — outcome-tracking tables\n- `scripts/icpg/drift.py` — risk scoring gains history term\n- `hooks/pre-tool-use` — record `AgentAction` before Edit/Write calls\n- `hooks/post-commit-graph` — finalize the outcome\n- `skills/icpg/SKILL.md` — document calibrated risk semantics\n\n## Success criteria\n\n1. Every agent Edit/Write action is automatically logged (no manual reporting)\n2. `icpg query risk <symbol>` returns a score incorporating historical outcomes\n3. Risk score converges toward structural risk when action history is empty (no regression)\n4. Privacy opt-out works — no history written when disabled\n5. A test harness replays an action sequence and verifies calibrated scores update correctly\n\n## Depends on\n\n- Spec 01 (runtime observability) — feeds `failure_runtime` signal\n- Spec 02 (rollback) — feeds `abandoned` signal\n- Spec 03 (verifiable contracts) — feeds high-signal `failure_test` from postcondition failures\n"
  },
  {
    "path": "_project_specs/06-cost-budget-awareness.md",
    "content": "# Spec 06: Cost / Budget Awareness\n\n**Status:** pending\n**Priority:** Tier 3 (frontier)\n**Effort:** Small\n\n## Context\n\nAutonomous agents stuck in loops burn real money. Mnemos's fatigue detection (4-dim: tokens, scatter, re-reads, error density) is a *behavioral* proxy for \"the agent is struggling\" but it isn't a hard stop. An agent that's actually wasting tokens or API calls needs a budget ceiling.\n\nThis matters especially for:\n\n- `/improve-maggy`, self-improvement flows, anything that spawns subagents\n- Team runs where one misbehaving agent shouldn't bankrupt the whole run\n- Maggy's TDD execute pipeline (up to 3 Claude Code invocations per ticket)\n\n## Goal\n\nAdd per-task and per-session budget limits with hard stops and a budget-aware fatigue state.\n\n## Approach\n\n### Step 1 — Declare a budget in intent config\n\nExtend ReasonNode:\n\n```yaml\nbudget:\n  tokens: 100000\n  api_calls: 50\n  wall_clock_minutes: 30\n  usd: 5.00\n```\n\nAll fields optional. `usd` calculated from model pricing tables (current Sonnet/Opus rates, refreshed quarterly).\n\n### Step 2 — Track spend via hooks\n\nPostToolUse hook accumulates:\n\n- Tokens consumed (from `transcript_path` JSON blobs)\n- Claude API calls (by counting tool uses)\n- Wall clock elapsed since intent started\n\nStored in `.icpg/budgets/<intent-id>.json` with heartbeats.\n\n### Step 3 — Budget-aware fatigue state\n\nAdd a 5th Mnemos fatigue dimension: `budget_burn_rate`. If the agent has consumed 70% of its token budget at 40% progress, that's a signal to compress / consolidate / consider abandoning. Threshold behavior:\n\n| Budget consumed | Action |\n|---|---|\n| <60% | Normal |\n| 60-85% | Mnemos COMPRESS state forced |\n| 85-100% | Mnemos REM state forced, agent warned to wrap up |\n| >100% | Hard stop — PreToolUse hook rejects further Edit/Write/Bash |\n\n### Step 4 — Graceful stop behavior\n\nWhen budget is exceeded:\n\n1. PreToolUse hook returns `budget_exceeded` error with context about remaining work\n2. Agent is expected to write a handoff Mnemos checkpoint before exiting\n3. Intent status flips to `deferred_budget`\n4. Human (or another agent with a fresh budget) can resume from the checkpoint\n\n### Step 5 — Budget override\n\nA human can set `allow_overage: true` on an intent or raise the limit mid-run. Override requires a commit to the intent's config (auditable).\n\n## Integration points\n\n- `scripts/icpg/models.py` — `Budget` field on ReasonNode\n- `scripts/icpg/budget.py` — new module for tracking and enforcement\n- `hooks/pre-tool-use` — budget check before Edit/Write/Bash\n- `hooks/post-tool-use` — accumulate spend\n- `templates/pricing.yaml` — model → $/token table, refreshed quarterly\n- `skills/mnemos/SKILL.md` — document the 5th fatigue dimension\n- `skills/icpg/SKILL.md` — document budget declaration\n\n## Success criteria\n\n1. An intent with a 10k-token budget hard-stops at 10k tokens via PreToolUse rejection\n2. Mnemos fatigue state reflects budget consumption (COMPRESS / REM / EMERGENCY)\n3. Budget overruns leave a Mnemos handoff checkpoint so work can resume\n4. `icpg budgets list` shows current spend vs limit per active intent\n5. No budget declared → no enforcement (backward compatible)\n\n## Depends on\n\n- Mnemos fatigue model (already exists)\n- Nothing else\n"
  },
  {
    "path": "_project_specs/07-human-escalation-protocol.md",
    "content": "# Spec 07: Human-in-the-Loop Escalation Protocol\n\n**Status:** pending\n**Priority:** Tier 1 (highest leverage)\n**Effort:** Small-Medium\n\n## Context\n\nWhen an autonomous agent hits a wall it can't resolve — drift it can't fix, a contract violation with no clear cause, lock negotiation failure, budget exceeded — there's no formal protocol for raising the problem to a human. The hooks infrastructure exists, the discipline doesn't.\n\nToday the agent might:\n- Silently continue and compound the issue\n- Write a confused summary and exit, leaving no actionable packet\n- Page every minor issue, creating alert fatigue\n\nNone of these scale to autonomous engineering at a team level.\n\n## Goal\n\nA standard escalation protocol: the agent packages a context packet (what it tried, what went wrong, what it needs a human to decide) and delivers it through a configured channel.\n\n## Approach\n\n### Step 1 — Escalation packet schema\n\n```yaml\nescalation:\n  id: \"esc-abc123\"\n  agent: \"claude-opus-4.7\"\n  intent: \"R-auth-refactor\"\n  severity: \"blocking\"           # blocking | high | medium | low\n  category: \"drift_unresolvable\" # or: contract_violation, lock_conflict,\n                                 # budget_exceeded, taint_detected, unknown\n  summary: \"Two-sentence description of the situation\"\n  what_was_tried:\n    - \"Attempted X — result: failed because Y\"\n    - \"Attempted Z — result: partial\"\n  proposed_options:\n    - \"Option A: revert to sha abc, human makes a decision\"\n    - \"Option B: accept the drift, update postcondition\"\n  context_refs:\n    - \"commit: sha-latest\"\n    - \"intent: R-auth-refactor\"\n    - \"drift_report: path/to/drift.json\"\n    - \"mnemos_checkpoint: path/to/checkpoint.json\"\n  awaiting: \"resolution\"\n```\n\n### Step 2 — `icpg escalate` CLI\n\n```bash\nicpg escalate --intent R-auth-refactor \\\n              --category drift_unresolvable \\\n              --severity blocking \\\n              --summary \"Cannot resolve postcondition drift\" \\\n              --context drift.json\n```\n\nWrites the packet to `.icpg/escalations/<id>.yaml` and fires the configured delivery channel.\n\n### Step 3 — Pluggable delivery channels\n\nOne adapter per channel (`scripts/icpg/escalation/`):\n\n- `slack_adapter.py` — post to configured channel with packet fields\n- `github_issue_adapter.py` — create issue with the packet\n- `email_adapter.py` — SendGrid / SMTP\n- `file_adapter.py` — default; writes to `.icpg/escalations/` only (for local/dev)\n\nConfig in `.icpg/config.yaml`:\n\n```yaml\nescalation:\n  channels:\n    - type: slack\n      webhook_url_env: SLACK_ESCALATION_WEBHOOK\n      min_severity: high\n    - type: github_issue\n      repo: \"org/repo\"\n      min_severity: blocking\n```\n\n### Step 4 — Auto-trigger from known conditions\n\nWire automatic escalations:\n\n| Condition | Severity | Category |\n|---|---|---|\n| Drift severity >0.8, auto-revert failed | blocking | drift_unresolvable |\n| Contract violation caught by generated test (Spec 03) | high | contract_violation |\n| Lock negotiation timeout (Spec 04) | medium | lock_conflict |\n| Budget exceeded without handoff checkpoint (Spec 06) | high | budget_exceeded |\n| CodeQL finds new taint path | blocking | taint_detected |\n\nEach hook module calls `icpg escalate` with the right packet when its trigger fires.\n\n### Step 5 — Resolution tracking\n\nWhen a human responds (comment on the GitHub issue, Slack thread reply with a resolution marker like `resolved: revert`), an `EscalationResolution` node is written and any pending agent waiting on the packet can resume.\n\nAgents consult `icpg escalations list --pending` as part of their pre-task queries.\n\n### Step 6 — Rate limiting / dedup\n\nDon't spam. If the same intent + category has an open escalation, merge into it (append to `what_was_tried`) instead of creating a new one. Escalation adapter respects a per-channel rate limit.\n\n## Integration points\n\n- `scripts/icpg/models.py` — `Escalation`, `EscalationResolution`\n- `scripts/icpg/escalation/` — new package, one module per channel\n- `scripts/icpg/__main__.py` — `escalate`, `escalations list/resolve` subcommands\n- `hooks/post-tool-use` — auto-escalate on trigger conditions\n- `skills/icpg/SKILL.md` — document when agents should manually call it\n- `templates/escalation-config.yaml` — example config\n\n## Success criteria\n\n1. Agent can manually escalate a situation with `icpg escalate` and humans receive it through at least one channel (Slack preferred)\n2. Auto-escalations fire for all 5 trigger conditions above\n3. Dedup works — same intent + category doesn't spam\n4. Human resolution flows back as `EscalationResolution` node, pending agents can detect it\n5. Local/dev config uses file-only adapter (no external calls), never breaks tests\n\n## Depends on\n\nNone directly — builds on existing hook infrastructure. Integrates with:\n- Spec 02 (rollback) — failed auto-revert triggers escalation\n- Spec 03 (contracts) — test failures trigger escalation\n- Spec 04 (locks) — negotiation timeout triggers escalation\n- Spec 06 (budget) — overrun without handoff triggers escalation\n"
  },
  {
    "path": "_project_specs/08-auto-code-index.md",
    "content": "# Spec 08: Auto-Derived CODE_INDEX from Graph\n\n**Status:** pending\n**Priority:** Tier 2\n**Effort:** Small-Medium\n\n## Context\n\nThe `code-deduplication` skill requires a `CODE_INDEX.md` in the project root — a capability index that tells the agent \"this already exists, don't reimplement it.\" The current design asks humans (or agents) to maintain it manually.\n\nIn practice:\n\n- Agents don't reliably update the index when they add capabilities\n- Humans forget to update it\n- The index drifts from reality fast\n- Agents that check the index get stale info and duplicate anyway\n\nSince we already have `codebase-memory-mcp` (symbol graph) and `iCPG` (intent graph), we can derive the capability index from them instead of hand-maintaining it.\n\n## Goal\n\nAuto-generate `CODE_INDEX.md` from the graph, refreshed on every commit, organized by capability so agents can check-before-write reliably.\n\n## Approach\n\n### Step 1 — Capability extraction pass\n\nA new pass over the combined graph:\n\n1. Read all `ReasonNode`s with status `fulfilled` (iCPG)\n2. For each, pull the symbols they `CREATE` or `MODIFY`\n3. Group by capability domain (inferred from:\n   - intent's `scope` path prefixes — `app/auth/*` → \"auth\"\n   - intent's `decision_type` — `business_goal` and `arch_decision` are top-level, `task` and `workaround` are subcategories\n   - common tag patterns in the codebase)\n4. For each capability, collect the main entry points (public classes/functions that serve that capability)\n\n### Step 2 — Emit CODE_INDEX.md\n\n```markdown\n# Code Capability Index\n\nAuto-generated from iCPG + codebase-memory-mcp. Last updated: 2026-04-20.\nRun `icpg index build` to regenerate.\n\n## Authentication\n**Capability:** user auth, session management, token handling\n**Entry points:**\n- `app.auth.login_user()` [app/auth/login.py:42] — primary login\n- `app.auth.session.SessionManager` [app/auth/session.py] — session lifecycle\n**Intents:** R-auth-base, R-jwt-refactor, R-rate-limit\n\n## Survey responses\n**Capability:** create, validate, persist, query survey responses\n**Entry points:** ...\n```\n\nOutput is deterministic — same graph state produces the same output.\n\n### Step 3 — Hook into post-commit\n\nEvery commit that records new iCPG edges triggers a regeneration. Runs in under a second for typical repo sizes since it's a DB scan + markdown emit.\n\n### Step 4 — `icpg index` subcommand\n\n```bash\nicpg index build        # regenerate CODE_INDEX.md\nicpg index check        # verify CODE_INDEX.md matches graph state (for CI)\nicpg index query auth   # query a specific capability section\n```\n\nThe `check` subcommand lets CI reject commits that leave an out-of-sync CODE_INDEX.\n\n### Step 5 — Agent workflow integration\n\nThe `code-deduplication` skill's pre-write discipline stays the same, but the data source changes from \"human-maintained CODE_INDEX.md\" to \"graph-derived CODE_INDEX.md.\" Update the skill to:\n\n1. Call `icpg query prior \"<goal>\"` (iCPG's existing prior-work query)\n2. If no match, consult the index sections matching the intent's scope\n3. Only create new code if both checks are dry\n\nAlso add `icpg query capability \"<description>\"` — a semantic search over capability descriptions in the index, not just symbol names.\n\n### Step 6 — Keep hand-written sections (optional)\n\nLet humans add non-derived sections (architecture notes, business domain glossary) in a separate file — `CODE_INDEX.human.md` — and `icpg index build` appends it. Auto-derived + human annotations cleanly separated.\n\n## Integration points\n\n- `scripts/icpg/index.py` — new module, grouping + emit logic\n- `scripts/icpg/__main__.py` — `index build`, `index check`, `index query` subcommands\n- `hooks/post-commit-graph` — call `icpg index build`\n- `skills/code-deduplication/SKILL.md` — update to reference auto-derived index\n- `templates/CODE_INDEX.md` — deprecate the hand-maintained template; add note pointing to the auto-generated path\n\n## Success criteria\n\n1. On any repo with iCPG populated, `icpg index build` produces a grouped, readable CODE_INDEX.md\n2. `icpg index check` detects drift between graph and markdown (for CI)\n3. Agents find existing capabilities via semantic search (`icpg query capability \"rate limiting\"`)\n4. Generation is deterministic — same graph → same markdown\n5. Backward compatible: projects without iCPG continue to hand-maintain; projects with iCPG get the auto version\n6. Regeneration is <2s on a 10k-symbol repo\n\n## Depends on\n\n- iCPG (required)\n- codebase-memory-mcp (preferred — used for richer capability grouping)\n"
  },
  {
    "path": "_project_specs/09-multimodal-ingestion.md",
    "content": "# Spec 09: Multimodal Ingestion (Optional Graphify-Style Extension)\n\n**Status:** pending\n**Priority:** Tier 3 (frontier / optional)\n**Effort:** Large\n\n## Context\n\nOur stack is code-only. Some repos carry essential context in non-code artifacts:\n\n- Product specs in PDFs or Google Docs exports\n- Architecture diagrams in PNG / Miro / whiteboard photos\n- Engineering demos in MP4\n- Research papers in PDF\n\nWhen an autonomous agent works on such a repo, it currently ignores these artifacts. That's a real gap — the agent makes code decisions without knowing the intent captured in the diagrams or docs.\n\nGraphify (github.com/safishamsi/graphify) solves this: it ingests docs, images, audio, and video into the same knowledge graph as code. We don't need to rebuild their work — we can adopt their approach as an optional extension to claude-bootstrap.\n\n**This spec is optional** — only valuable if your repos actually carry non-code context. Most don't.\n\n## Goal\n\nLet claude-bootstrap ingest non-code artifacts into the iCPG graph so agents can reason about code + docs + images in the same queries.\n\n## Approach\n\n### Step 1 — Artifact node type\n\nExtend iCPG with a new node:\n\n```\nArtifact {\n  id, path, kind, content_hash, ingested_at,\n  extracted_concepts: []    // concept strings\n}\n```\n\nKinds: `pdf`, `markdown`, `image`, `diagram`, `video`, `audio`, `slides`.\n\n### Step 2 — Ingestion pipeline\n\n`icpg ingest <path>` — one command, pluggable extractors:\n\n- `pdf_extractor.py` — text via `pypdf` or `pdfplumber`, then LLM to extract key concepts\n- `markdown_extractor.py` — parse headings, blockquotes, pull out \"key decision\" patterns\n- `image_extractor.py` — Claude multimodal: \"describe this diagram; list entities and relationships\"\n- `video_extractor.py` — `faster-whisper` transcription with domain-aware prompt, then concept extraction\n- `audio_extractor.py` — same as video, skip video decode\n\nEach extractor emits concept nodes + relationships back into iCPG using the existing edge vocabulary:\n- `DESCRIBES` — Artifact → Symbol / Reason (this doc describes this code)\n- `MENTIONS` — Artifact → Concept (looser reference)\n- `DECIDES` — Artifact → Reason (this doc made an architectural decision that became an intent)\n\n### Step 3 — `.icpgignore` for ingest paths\n\nRespect a per-project `.icpgignore` like graphify's `.graphifyignore`, using `.gitignore` syntax. Default excludes: `node_modules/`, `dist/`, `.venv/`, `*.generated.*`, binary builds.\n\n### Step 4 — Incremental refresh\n\nTrack content hashes per artifact. Re-ingest only when hash changes. Bulk re-ingest via `icpg ingest --refresh`.\n\n### Step 5 — Extend pre-task queries\n\nAdd a 5th canonical query:\n\n```bash\nicpg query docs \"<topic>\"   # Find artifacts relevant to this topic\n```\n\nReturns: artifact paths, extracted concepts, relationships to code symbols.\n\nThe PreToolUse hook includes this in the injected context when the agent is about to write code in a scope touched by `DESCRIBES` edges.\n\n### Step 6 — Transparent honesty about inference\n\nAdopt graphify's `EXTRACTED` / `INFERRED` / `AMBIGUOUS` edge labeling. PDF text → EXTRACTED. Image concept → INFERRED with confidence. Whiteboard smudged text → AMBIGUOUS, flagged for review.\n\n### Step 7 — Cost control\n\nLLM-based extractors (images, video transcripts) are expensive. Respect Spec 06 budgets. `icpg ingest` without a budget flag runs only the free extractors (markdown, PDF text). Image / video / audio require `--enable-llm` explicit flag.\n\n### Step 8 — Distribution\n\nShip this as a **separate installable package** — `claude-bootstrap-multimodal` on PyPI. Base claude-bootstrap stays code-only. Users opt in:\n\n```bash\npip install claude-bootstrap-multimodal\nicpg ingest docs/ specs/\n```\n\n## Integration points\n\n- `scripts/icpg/models.py` — `Artifact`, new edge types (`DESCRIBES`, `MENTIONS`, `DECIDES`)\n- `scripts/icpg/ingest/` — new package (could live in a separate repo)\n- `scripts/icpg/__main__.py` — `ingest` subcommand\n- `skills/icpg/SKILL.md` — document the 5th pre-task query\n- `skills/multimodal/SKILL.md` — new skill describing when to use ingestion\n\n## Success criteria\n\n1. `icpg ingest docs/` processes markdown + PDF without LLM and creates artifact nodes\n2. `icpg ingest --enable-llm specs/` processes images and videos, with the budget flag respected\n3. Pre-task queries surface relevant documentation when the agent is about to modify code touched by `DESCRIBES` edges\n4. Re-ingestion only processes changed files (hash-based cache)\n5. Base claude-bootstrap doesn't require multimodal deps to work — installed separately\n\n## Depends on\n\n- Spec 06 (budget) — LLM extractors must respect budget caps\n\n## Alternative: adopt graphify directly\n\nInstead of building this, we could document \"for multimodal, run graphify alongside\" and provide a conversion tool that imports graphify's `graph.json` into iCPG as Artifact nodes. This is faster to ship and avoids duplicating graphify's work.\n\n**Recommendation:** ship the conversion tool first (1-2 days of work), observe adoption, build native ingestion only if real demand emerges.\n"
  },
  {
    "path": "commands/analyze-repo.md",
    "content": "# Analyze Repository\n\nAnalyze an existing repository's structure, conventions, and guardrails.\n\n**This command runs automatically** when `/initialize-project` detects an existing codebase without Claude setup. You can also run it standalone anytime.\n\n**Use this command standalone when:**\n- You want to re-analyze after making changes\n- You want analysis without running `/initialize-project`\n- Auditing code quality and guardrails on any repo\n- Reviewing a codebase without adding Claude skills\n\n**Automatic trigger:**\n- `/initialize-project` on existing codebase → auto-runs this analysis first\n\n---\n\n## Phase 1: Repository Detection\n\nRun these checks to understand the repo:\n\n```bash\n# Git info\necho \"=== Git Status ===\" && \\\ngit remote -v 2>/dev/null && \\\ngit branch -a 2>/dev/null | head -10 && \\\ngit log --oneline -5 2>/dev/null\n\n# Config files\necho \"=== Config Files ===\" && \\\nls -la *.json *.toml *.yaml *.yml 2>/dev/null\n\n# Directory structure (3 levels, excluding noise)\necho \"=== Directory Structure ===\" && \\\nfind . -type d -maxdepth 3 \\\n    -not -path \"*/node_modules/*\" \\\n    -not -path \"*/.git/*\" \\\n    -not -path \"*/venv/*\" \\\n    -not -path \"*/__pycache__/*\" \\\n    -not -path \"*/dist/*\" \\\n    -not -path \"*/build/*\" \\\n    2>/dev/null | head -40\n```\n\n---\n\n## Phase 2: Tech Stack Detection\n\nIdentify the primary technologies:\n\n```bash\n# JavaScript/TypeScript\nif [ -f \"package.json\" ]; then\n    echo \"=== Package.json ===\" && \\\n    cat package.json | head -50\nfi\n\n# Python\nif [ -f \"pyproject.toml\" ]; then\n    echo \"=== pyproject.toml ===\" && \\\n    cat pyproject.toml\nfi\n\n# Mobile\nls pubspec.yaml android/build.gradle ios/*.xcodeproj 2>/dev/null\n```\n\nBased on findings, determine:\n\n| File | Technology |\n|------|------------|\n| package.json + tsconfig.json | TypeScript |\n| package.json (no tsconfig) | JavaScript |\n| pyproject.toml | Python |\n| pubspec.yaml | Flutter (Dart) |\n| android/build.gradle | Android Native |\n| Cargo.toml | Rust |\n| go.mod | Go |\n\n---\n\n## Phase 3: Repo Structure Type\n\nClassify the repository:\n\n```bash\n# Check structure type\necho \"=== Repo Structure Type ===\" && \\\nif [ -d \"packages\" ] || [ -d \"apps\" ] || grep -q '\"workspaces\"' package.json 2>/dev/null; then\n    echo \"MONOREPO - Multiple packages/apps with shared tooling\"\nelif [ -d \"frontend\" ] && [ -d \"backend\" ]; then\n    echo \"FULL-STACK MONOLITH - Frontend + Backend in same repo\"\nelif [ -d \"src\" ] && grep -q '\"react\\|vue\\|angular\"' package.json 2>/dev/null; then\n    echo \"FRONTEND - Single frontend application\"\nelif [ -d \"src\" ] && grep -q '\"express\\|fastify\\|koa\"' package.json 2>/dev/null; then\n    echo \"BACKEND - Single backend application\"\nelif [ -f \"pyproject.toml\" ] && grep -q \"fastapi\\|django\\|flask\" pyproject.toml 2>/dev/null; then\n    echo \"BACKEND (Python) - Single backend application\"\nelse\n    echo \"STANDARD - Single-purpose repository\"\nfi\n```\n\n---\n\n## Phase 4: Guardrails Audit\n\nCheck existing code quality tools:\n\n```bash\necho \"=== Guardrails Audit ===\" && \\\n\n# Pre-commit hooks\necho \"Pre-commit Hooks:\" && \\\n[ -d \".husky\" ] && echo \"  [x] Husky installed\" || echo \"  [ ] Husky NOT installed\" && \\\n[ -f \".pre-commit-config.yaml\" ] && echo \"  [x] pre-commit framework\" || echo \"  [ ] pre-commit framework NOT installed\" && \\\n[ -f \".git/hooks/pre-commit\" ] && echo \"  [x] Git hooks present\" || echo \"  [ ] No git hooks\"\n\n# Linting\necho \"Linting:\" && \\\n(grep -q '\"eslint\"' package.json 2>/dev/null && echo \"  [x] ESLint\") || \\\n(grep -q '\"biome\"' package.json 2>/dev/null && echo \"  [x] Biome\") || \\\n(grep -q \"ruff\" pyproject.toml 2>/dev/null && echo \"  [x] Ruff\") || \\\necho \"  [ ] No linter detected\"\n\n# Formatting\necho \"Formatting:\" && \\\n(grep -q '\"prettier\"' package.json 2>/dev/null && echo \"  [x] Prettier\") || \\\n(grep -q \"black\" pyproject.toml 2>/dev/null && echo \"  [x] Black\") || \\\n(grep -q \"ruff\" pyproject.toml 2>/dev/null && echo \"  [x] Ruff (formatting)\") || \\\necho \"  [ ] No formatter detected\"\n\n# Type checking\necho \"Type Checking:\" && \\\n([ -f \"tsconfig.json\" ] && echo \"  [x] TypeScript\") || \\\n(grep -q \"mypy\" pyproject.toml 2>/dev/null && echo \"  [x] mypy\") || \\\n(grep -q \"pyright\" pyproject.toml 2>/dev/null && echo \"  [x] pyright\") || \\\necho \"  [ ] No type checker detected\"\n\n# Testing\necho \"Testing:\" && \\\n(grep -q '\"jest\\|vitest\"' package.json 2>/dev/null && echo \"  [x] Jest/Vitest\") || \\\n(grep -q \"pytest\" pyproject.toml 2>/dev/null && echo \"  [x] pytest\") || \\\necho \"  [ ] No test framework detected\"\n\n# Commit validation\necho \"Commit Validation:\" && \\\n([ -f \"commitlint.config.js\" ] && echo \"  [x] commitlint\") || \\\n(grep -q \"conventional-pre-commit\" .pre-commit-config.yaml 2>/dev/null && echo \"  [x] conventional-pre-commit\") || \\\necho \"  [ ] No commit validation\"\n\n# CI/CD\necho \"CI/CD:\" && \\\n[ -d \".github/workflows\" ] && echo \"  [x] GitHub Actions\" || echo \"  [ ] No GitHub Actions\" && \\\n[ -f \".gitlab-ci.yml\" ] && echo \"  [x] GitLab CI\" || true && \\\n[ -f \"Jenkinsfile\" ] && echo \"  [x] Jenkins\" || true\n```\n\n---\n\n## Phase 5: Convention Detection\n\nIdentify existing code patterns:\n\n```bash\necho \"=== Convention Detection ===\" && \\\n\n# File naming\necho \"File Naming:\" && \\\nls src/**/*.ts 2>/dev/null | head -5 && \\\nls src/**/*.py 2>/dev/null | head -5\n\n# Import style (JS/TS)\necho \"Import Style:\" && \\\ngrep -h \"^import\" src/**/*.ts 2>/dev/null | head -5\n\n# Export style (JS/TS)\necho \"Export Style:\" && \\\ngrep -h \"^export\" src/**/*.ts 2>/dev/null | head -5\n\n# Test file location\necho \"Test Location:\" && \\\nfind . -name \"*.test.*\" -o -name \"*.spec.*\" -o -name \"test_*.py\" 2>/dev/null | head -5\n```\n\n---\n\n## Phase 6: Generate Report\n\nBased on all findings, generate this report structure:\n\n```markdown\n# Repository Analysis Report\n\n**Generated:** [timestamp]\n**Repository:** [name from git remote or directory]\n\n## Overview\n\n| Attribute | Value |\n|-----------|-------|\n| Type | [Monorepo / Full-Stack / Frontend / Backend] |\n| Language | [TypeScript / Python / ...] |\n| Framework | [React / FastAPI / ...] |\n| Package Manager | [npm / pnpm / uv / pip] |\n\n## Directory Structure\n\n[Simplified tree output]\n\n## Tech Stack\n\n| Category | Technology | Config |\n|----------|------------|--------|\n| Language | X | X |\n| Framework | X | X |\n| Testing | X | X |\n| Linting | X | X |\n| Formatting | X | X |\n\n## Guardrails Status\n\n### Present\n- [x] Item 1\n- [x] Item 2\n\n### Missing (Recommended to Add)\n- [ ] Item 1 - [brief reason]\n- [ ] Item 2 - [brief reason]\n\n## Conventions Observed\n\n| Pattern | Observed Value | Example |\n|---------|----------------|---------|\n| Naming | camelCase / snake_case | file.ts |\n| Imports | Absolute / Relative | @/components |\n| Tests | Colocated / Separate | *.test.ts |\n| Exports | Named / Default | export { X } |\n\n## Recommendations\n\n1. **High Priority**\n   - [Recommendation with reason]\n\n2. **Medium Priority**\n   - [Recommendation with reason]\n\n3. **Low Priority / Nice to Have**\n   - [Recommendation with reason]\n\n## Key Files to Review\n\n| File | Purpose | Why Review |\n|------|---------|------------|\n| src/index.ts | Entry point | Understand app bootstrap |\n| src/config.ts | Configuration | Understand env handling |\n| tests/setup.ts | Test setup | Understand test patterns |\n```\n\n---\n\n## Phase 7: Offer Next Steps\n\nAfter generating the report, offer these options:\n\n> **Analysis complete!** Here's what I found: [summary]\n>\n> What would you like to do next?\n> 1. **Add missing guardrails** - Set up pre-commit hooks, linting, etc.\n> 2. **Generate detailed conventions doc** - Document patterns for team\n> 3. **Set up Claude integration** - Run `/initialize-project` to add Claude skills\n> 4. **Start working on code** - I'll follow the conventions I detected\n> 5. **Something else**\n\n---\n\n## Quick Analysis (One Command)\n\nFor a quick overview without the full report:\n\n```bash\necho \"=== Quick Analysis ===\" && \\\necho \"Repo: $(basename $(pwd))\" && \\\necho \"Type: $([ -d packages ] && echo 'Monorepo' || ([ -d frontend ] && [ -d backend ] && echo 'Full-Stack') || echo 'Standard')\" && \\\necho \"Tech: $([ -f package.json ] && echo 'JS/TS' || ([ -f pyproject.toml ] && echo 'Python') || echo 'Other')\" && \\\necho \"Guardrails: $([ -d .husky ] || [ -f .pre-commit-config.yaml ] && echo 'Present' || echo 'Missing')\" && \\\necho \"CI/CD: $([ -d .github/workflows ] && echo 'GitHub Actions' || echo 'None')\"\n```\n"
  },
  {
    "path": "commands/analyze-workspace.md",
    "content": "# /analyze-workspace\n\n> Full dynamic analysis of workspace topology, dependencies, and contracts.\n\n## Trigger\n\nRun this command when:\n- First time setting up workspace awareness\n- Major refactor or new module added\n- Weekly scheduled refresh\n- `/sync-contracts` reports too much drift\n- Switching to work on a different workspace\n\n## Behavior\n\n### Phase 1: Topology Discovery (~30 seconds)\n\n```\n🔍 Analyzing workspace topology...\n\nChecking workspace indicators:\n  ✓ Found turbo.json (Turborepo)\n  ✓ Found pnpm-workspace.yaml\n  ✗ No nx.json\n  ✗ No lerna.json\n\nWorkspace type: Monorepo (Turborepo)\nRoot: /Users/ali/code/myapp\n\nDiscovering modules...\n  ✓ apps/web (package.json found)\n  ✓ apps/api (pyproject.toml found)\n  ✓ packages/shared-types (package.json found)\n  ✓ packages/db (package.json found)\n\nModules found: 4\n```\n\n### Phase 2: Module Analysis (~60 seconds)\n\nFor each module, analyze:\n\n```\n📦 Analyzing apps/web...\n  Tech stack: Next.js 14, TypeScript, TailwindCSS\n  Entry point: src/app/layout.tsx\n  Key directories: src/lib/, src/components/, src/types/\n  Dependencies: @repo/shared-types, @repo/ui\n  External calls: fetch → apps/api (15 files)\n  Token estimate: 18K full, 5K summarized\n\n📦 Analyzing apps/api...\n  Tech stack: FastAPI, Python 3.12, SQLAlchemy\n  Entry point: app/main.py\n  Key directories: app/routes/, app/schemas/, app/models/\n  Dependencies: packages/db (internal)\n  Exposes: OpenAPI spec (47 endpoints)\n  Token estimate: 24K full, 7K summarized\n\n📦 Analyzing packages/shared-types...\n  Tech stack: TypeScript\n  Entry point: src/index.ts\n  Exports: 34 types\n  Consumed by: apps/web, apps/api (codegen)\n  Token estimate: 3K\n\n📦 Analyzing packages/db...\n  Tech stack: Drizzle ORM, TypeScript\n  Entry point: src/index.ts\n  Tables: 12\n  Migrations: 23\n  Token estimate: 8K full, 2K schema only\n```\n\n### Phase 3: Contract Extraction (~45 seconds)\n\n```\n📜 Extracting contracts...\n\nOpenAPI Detection:\n  ✓ apps/api/openapi.json (47 endpoints, 23 schemas)\n\nGraphQL Detection:\n  ✗ No GraphQL schemas found\n\nTypeScript Types:\n  ✓ packages/shared-types/src/index.ts (34 exports)\n\nPydantic Schemas:\n  ✓ apps/api/app/schemas/ (23 models)\n\nDatabase Schema:\n  ✓ packages/db/schema/ (12 tables)\n\nContract sources registered: 5 files\n```\n\n### Phase 4: Dependency Graph (~30 seconds)\n\n```\n🔗 Building dependency graph...\n\nInternal dependencies:\n  apps/web → packages/shared-types (23 imports)\n  apps/web → apps/api (15 API calls)\n  apps/api → packages/db (12 imports)\n  apps/api → packages/shared-types (codegen)\n  packages/db → (none)\n  packages/shared-types → (none)\n\nDependency order (for changes):\n  1. packages/shared-types (leaf)\n  2. packages/db (leaf)\n  3. apps/api (depends on db, shared-types)\n  4. apps/web (depends on api, shared-types)\n```\n\n### Phase 5: Key File Identification (~30 seconds)\n\n```\n📁 Identifying key files...\n\nHigh priority (always relevant):\n  ✓ apps/api/openapi.json\n  ✓ packages/shared-types/src/index.ts\n  ✓ apps/web/src/lib/api/client.ts\n\nContext-specific:\n  ✓ API work: apps/api/app/routes/*.py\n  ✓ DB work: packages/db/schema/*.ts\n  ✓ Auth work: apps/api/app/routes/auth.py + deps\n  ✓ Frontend: apps/web/src/components/**\n\nToken budget by context:\n  Frontend API: ~8K tokens\n  Backend endpoints: ~12K tokens\n  Database changes: ~6K tokens\n  Shared types: ~4K tokens\n```\n\n### Phase 6: Generate Artifacts\n\n```\n📝 Generating workspace artifacts...\n\nCreated:\n  ✓ _project_specs/workspace/TOPOLOGY.md\n  ✓ _project_specs/workspace/CONTRACTS.md\n  ✓ _project_specs/workspace/DEPENDENCY_GRAPH.md\n  ✓ _project_specs/workspace/KEY_FILES.md\n  ✓ _project_specs/workspace/CROSS_REPO_INDEX.md\n  ✓ _project_specs/workspace/.contract-sources\n```\n\n## Final Output\n\n```\n════════════════════════════════════════════════════════════════\n  WORKSPACE ANALYSIS COMPLETE\n════════════════════════════════════════════════════════════════\n\nWorkspace: myapp\nType: Monorepo (Turborepo)\nModules: 4 (2 apps, 2 packages)\n\n┌─────────────────────────────────────────────────┐\n│ apps/web (Next.js) ←──── apps/api (FastAPI)     │\n│      │                        │                 │\n│      ▼                        ▼                 │\n│ packages/shared-types    packages/db            │\n└─────────────────────────────────────────────────┘\n\nContracts:\n  REST API: 47 endpoints\n  Shared types: 34 interfaces\n  DB tables: 12\n\nToken Estimates:\n  Current module only: ~20K tokens\n  With cross-module context: ~45K tokens\n  Full workspace: ~53K tokens\n  Budget remaining: ~100K tokens ✓\n\nArtifacts generated in: _project_specs/workspace/\n\nNext steps:\n  • Contracts will auto-sync on commit (if changed)\n  • Run /sync-contracts manually to refresh\n  • Run /workspace-status for quick check\n\n════════════════════════════════════════════════════════════════\n```\n\n## Flags\n\n| Flag | Description |\n|------|-------------|\n| `--force` | Regenerate all artifacts even if recent |\n| `--type <type>` | Override auto-detection: `monorepo`, `multi-repo`, `hybrid` |\n| `--repos <paths>` | For multi-repo: comma-separated paths to related repos |\n| `--skip-contracts` | Skip contract extraction (faster) |\n| `--verbose` | Show detailed analysis output |\n| `--json` | Output as JSON (for tooling) |\n\n## Multi-Repo Mode\n\nFor workspaces with separate git repositories:\n\n```bash\n# Auto-detect sibling repos\n/analyze-workspace --type multi-repo\n\n# Specify repo locations explicitly\n/analyze-workspace --type multi-repo --repos \"../backend,../shared,../mobile\"\n```\n\nClaude will:\n1. Detect related repos in parent directory\n2. Set up symlinks in `.workspace/repos/` if needed\n3. Analyze each repo\n4. Build cross-repo dependency graph\n5. Extract contracts from each\n\n## Integration Points\n\n### On First Run\n\nCreates the full workspace context structure:\n\n```\n_project_specs/\n└── workspace/\n    ├── TOPOLOGY.md\n    ├── CONTRACTS.md\n    ├── DEPENDENCY_GRAPH.md\n    ├── KEY_FILES.md\n    ├── CROSS_REPO_INDEX.md\n    ├── .contract-sources\n    └── cache/              # Cached cross-repo files\n```\n\n### Updates CLAUDE.md\n\nAdds workspace skill reference:\n\n```markdown\n## Skills\n- .claude/skills/workspace.md\n```\n\n### Sets Up Hooks\n\nInstalls contract freshness hooks:\n- Session start: Staleness check\n- Post-commit: Auto-sync trigger\n- Pre-push: Validation gate\n\n## Error Handling\n\n### No Workspace Detected\n\n```\n⚠️  No workspace configuration detected\n\nThis appears to be a single-repo project.\nUse /analyze-repo for single repository analysis.\n\nOr specify workspace type manually:\n  /analyze-workspace --type monorepo\n  /analyze-workspace --type multi-repo --repos \"../other-repo\"\n```\n\n### Access Denied to Related Repo\n\n```\n⚠️  Cannot access related repository: ../backend\n\nOptions:\n  1. Ensure the repo exists at that path\n  2. Create symlink: ln -s /path/to/backend .workspace/repos/backend\n  3. Skip this repo: /analyze-workspace --skip-repo backend\n```\n\n### Contract Extraction Failed\n\n```\n⚠️  Failed to extract contracts from apps/api\n\nReason: openapi.json not found\n\nSuggestions:\n  1. Generate OpenAPI spec: cd apps/api && python -m app.generate_openapi\n  2. Skip contract extraction: /analyze-workspace --skip-contracts\n  3. Use inferred contracts: /analyze-workspace --infer-contracts\n```\n\n## When to Re-run\n\n| Scenario | Action |\n|----------|--------|\n| Added new module/package | Full `/analyze-workspace` |\n| Changed API endpoints | `/sync-contracts` (lightweight) |\n| Major refactor | Full `/analyze-workspace --force` |\n| Weekly maintenance | Full `/analyze-workspace` |\n| Quick check | `/workspace-status` |\n"
  },
  {
    "path": "commands/check-contributors.md",
    "content": "# Check Contributors\n\nChecks who's working on the project and optionally converts to a multi-person project with team state management.\n\n---\n\n## What This Command Does\n\n1. **Detect current state** - Is this a solo or team project?\n2. **Show active contributors** - Who's working on what right now?\n3. **Offer conversion** - Convert solo → team project if needed\n\n---\n\n## Phase 1: Detect Project Type\n\nCheck for team structure:\n\n```bash\n# Check if team coordination exists\nls _project_specs/team/state.md 2>/dev/null\nls _project_specs/team/contributors.md 2>/dev/null\n\n# Check git contributors\ngit shortlog -sn --all 2>/dev/null | head -10\n\n# Check recent activity\ngit log --oneline --since=\"7 days ago\" --format=\"%an\" | sort | uniq -c | sort -rn\n```\n\n### If Team Structure Exists\n\nReport current state:\n\n```\n📊 Team Project Detected\n\nContributors:\n┌──────────┬────────────────┬──────────┐\n│ Handle   │ Focus Area     │ Status   │\n├──────────┼────────────────┼──────────┤\n│ @alice   │ Backend, Auth  │ 🟢 Active │\n│ @bob     │ Frontend       │ 🟡 Paused │\n└──────────┴────────────────┴──────────┘\n\nActive Sessions:\n• @alice working on TODO-042 (src/auth/*)\n• No conflicts detected\n\nClaimed Todos:\n• TODO-042 - @alice (since 2024-01-15)\n• TODO-038 - @bob (since 2024-01-14)\n\nRecent Decisions:\n• [2024-01-15] JWT vs Sessions - chose JWT (@alice)\n\nRun 'cat _project_specs/team/state.md' for full details.\n```\n\n### If Solo Project\n\n```\n👤 Solo Project Detected\n\nGit contributors found:\n• alice@example.com (142 commits)\n• bob@example.com (38 commits)  ← Recent activity\n\nThis project has multiple git contributors but no team coordination.\n\nWould you like to:\n1. Convert to team project (adds team state management)\n2. Keep as solo project (no changes)\n```\n\n---\n\n## Phase 2: Convert to Team Project\n\nIf user chooses to convert:\n\n### Step 1: Create Team Structure\n\n```bash\nmkdir -p _project_specs/team/handoffs\n```\n\n### Step 2: Create state.md\n\n```markdown\n# Team State\n\n*Last synced: [TIMESTAMP]*\n\n## Active Sessions\n\n| Contributor | Working On | Started | Files Touched | Status |\n|-------------|------------|---------|---------------|--------|\n| - | - | - | - | - |\n\n## Claimed Todos\n\n| Todo | Claimed By | Since | ETA |\n|------|------------|-------|-----|\n| - | - | - | - |\n\n## Recently Completed (Last 48h)\n\n| Todo | Completed By | When | PR |\n|------|--------------|------|-----|\n| - | - | - | - |\n\n## Conflicts to Watch\n\n| Area | Contributors | Notes |\n|------|--------------|-------|\n| - | - | - |\n\n## Announcements\n\n- [DATE] Project converted to team coordination mode\n```\n\n### Step 3: Create contributors.md\n\nAsk user about team members:\n\n```\nWho are the team members? (I'll help you fill this out)\n\nFor each person, I need:\n- Handle (e.g., @alice)\n- Name\n- Focus areas (e.g., Backend, Auth)\n- Timezone\n- Status (Active/Part-time)\n```\n\nThen create:\n\n```markdown\n# Contributors\n\n## Team Members\n\n| Handle | Name | Focus Areas | Timezone | Status |\n|--------|------|-------------|----------|--------|\n| @[handle] | [name] | [areas] | [tz] | Active |\n\n## Ownership\n\n| Area | Primary | Backup | Notes |\n|------|---------|--------|-------|\n| - | - | - | Define as you work |\n\n## Communication\n\n- Slack: #[channel]\n- PRs: Tag area owner for review\n```\n\n### Step 4: Update active.md\n\nAdd claim annotation format to existing todos:\n\n```markdown\n<!--\nTEAM PROJECT - Claim format:\n**Claimed:** @handle (YYYY-MM-DD HH:MM TZ)\n\nAlways claim a todo before starting work.\nCheck team/state.md for who's working on what.\n-->\n\n## [TODO-XXX] Description\n\n**Status:** pending\n**Claimed:** -\n\n...\n```\n\n### Step 5: Update CLAUDE.md\n\nAdd team-coordination.md to skills list:\n\n```markdown\n## Skills\nRead and follow these skills before writing any code:\n- .claude/skills/base.md\n- .claude/skills/team-coordination.md  ← Add this\n...\n```\n\n### Step 6: Copy Skill\n\n```bash\ncp ~/.claude/skills/team-coordination.md .claude/skills/\n```\n\n---\n\n## Phase 3: Summary\n\nAfter conversion:\n\n```\n✅ Converted to Team Project\n\nCreated:\n• _project_specs/team/state.md\n• _project_specs/team/contributors.md\n• _project_specs/team/handoffs/\n• .claude/skills/team-coordination.md\n\nUpdated:\n• _project_specs/todos/active.md (added claim format)\n• CLAUDE.md (added team-coordination skill)\n\nNext steps:\n1. Fill out contributors.md with your team\n2. Each team member should read team-coordination.md\n3. Claim todos before starting work\n4. Update state.md at start/end of each session\n\nCommit these changes:\n  git add _project_specs/team .claude/skills/team-coordination.md CLAUDE.md\n  git commit -m \"Enable team coordination for multi-person project\"\n  git push origin main\n```\n\n---\n\n## Quick Check Mode\n\nFor quick status without conversion prompt:\n\n```\n/check-contributors --status\n```\n\nOutput:\n\n```\n📊 Quick Status\n\nType: Team Project / Solo Project\nContributors: 3 (2 active this week)\nActive Now: @alice (TODO-042)\nClaimed: 2 todos\nConflicts: None\n\nLast state update: 2 hours ago\n```\n\n---\n\n## Reverting to Solo\n\nIf team coordination is no longer needed:\n\n```\n/check-contributors --solo\n```\n\nThis:\n1. Archives `_project_specs/team/` to `_project_specs/team-archive-[date]/`\n2. Removes claim annotations from todos\n3. Removes team-coordination.md from CLAUDE.md skills\n4. Keeps decisions.md (valuable history)\n\n---\n\n## Usage\n\n```bash\n# Check who's working and see options\n/check-contributors\n\n# Quick status only\n/check-contributors --status\n\n# Force conversion to team project\n/check-contributors --team\n\n# Revert to solo project\n/check-contributors --solo\n```\n"
  },
  {
    "path": "commands/icpg-bootstrap.md",
    "content": "# /icpg-bootstrap — Bootstrap from Git History\n\nInfer ReasonNodes from existing git commit history. One-time setup for existing codebases.\n\n---\n\n## Usage\n\n`/icpg-bootstrap [--days N]`\n\nDefault: 90 days of history.\n\n---\n\n## Steps\n\n### 1. Initialize iCPG if needed\n\n```bash\nicpg init\n```\n\n### 2. Run bootstrap\n\n```bash\nicpg bootstrap --days 90 --verbose\n```\n\nIf no LLM API key available:\n```bash\nicpg bootstrap --days 90 --verbose --no-llm\n```\n\n### 3. Show results\n\n```\niCPG BOOTSTRAP COMPLETE\n═══════════════════════\n\nHistory scanned: {N} days ({M} commits)\nCommit clusters: {K}\nReasonNodes created: {R}\nSymbols linked: {S}\nDuplicates skipped: {D}\n\nTOP INFERRED INTENTS:\n  1. [0.80] \"Add JWT authentication\" — 12 symbols, 5 files\n  2. [0.75] \"Refactor payment processing\" — 8 symbols, 3 files\n  3. [0.65] \"Fix rate limiting bug\" — 3 symbols, 2 files\n  ...\n\nLOW CONFIDENCE (review recommended):\n  - [0.55] \"Update dependencies\" — may be too generic\n  - [0.50] \"Misc fixes\" — commit message unclear\n```\n\n### 4. Offer review\n\nAsk the user:\n> {N} ReasonNodes were inferred from git history.\n> {M} are low-confidence and may need review.\n>\n> Would you like to:\n> 1. Keep all (proceed with current quality)\n> 2. Review low-confidence intents (I'll show each one)\n> 3. Run drift scan now (`icpg drift check`)\n\n### 5. Post-bootstrap drift scan\n\n```bash\nicpg drift check\n```\n\nShow any immediate drift detected.\n"
  },
  {
    "path": "commands/icpg-drift.md",
    "content": "# /icpg-drift — Show All Drift\n\nRun a full drift scan and display all unresolved drift events, grouped by dimension and sorted by severity.\n\n---\n\n## Usage\n\n`/icpg-drift`\n\n---\n\n## Steps\n\n### 1. Run drift scan\n\n```bash\nicpg drift check\n```\n\n### 2. Also show existing unresolved drift\n\n```bash\nicpg status\n```\n\n### 3. Display results\n\n```\nDRIFT REPORT\n═══════════════\n\n{N} unresolved drift events across {M} symbols\n\nBY SEVERITY:\n  [0.85] spec(0.9) + decision(0.8) — validateToken drifted from \"JWT auth\"\n  [0.60] ownership(0.7) + test(0.5) — UserService has 4 owners, tests stale\n  ...\n\nBY DIMENSION:\n  Spec drift:       {count} events\n  Decision drift:   {count} events\n  Ownership drift:  {count} events\n  Test drift:       {count} events\n  Usage drift:      {count} events\n  Dependency drift: {count} events\n\nTOP ACTIONS:\n  1. Fix spec drift in validateToken — checksum changed without MODIFIES edge\n  2. Add tests for UserService — VALIDATED_BY tests are missing\n  3. Assign single owner to PaymentProcessor — 5 different owners\n```\n\n### 4. Offer resolution\n\nFor each event, suggest:\n- `icpg drift resolve <id>` to mark resolved\n- Create a new MODIFIES ReasonNode if the change was intentional\n- Write missing tests if test drift detected\n"
  },
  {
    "path": "commands/icpg-impact.md",
    "content": "# /icpg-impact — Show Blast Radius\n\nShow the blast radius of a ReasonNode or symbol — what depends on it, what breaks if it changes.\n\n---\n\n## Usage\n\n`/icpg-impact <id-or-symbol>`\n\n- If argument looks like a UUID (contains `-`), treat as ReasonNode ID\n- Otherwise, treat as symbol name and find its creating ReasonNode\n\n---\n\n## Steps\n\n### 1. Resolve target\n\n```bash\n# If ReasonNode ID\nicpg query blast <id>\n\n# If symbol name\nicpg query risk <symbol-name>\n# Then get the creating reason from the output\nicpg query blast <creating-reason-id>\n```\n\n### 2. Display results\n\nFormat the output as:\n\n```\nBLAST RADIUS: <goal>\n═══════════════════════════════════\n\nSymbols ({N}):\n  function validateToken (src/auth/service.ts)\n  class AuthMiddleware (src/auth/middleware.ts)\n  ...\n\nDependent Intents ({N}):\n  a1b2c3d4 — Dashboard user session management\n  e5f6g7h8 — Payment authorization flow\n  ...\n\nContracts:\n  INV: file_exists(\"src/auth/middleware.ts\")\n  POST: test_exists(\"src/auth/__tests__/service.test.ts\")\n\nRisk: {HIGH|MEDIUM|LOW} based on dependent count + drift history\n```\n\n### 3. Recommendations\n\nIf high risk (>5 dependents or active drift):\n- Suggest running full test suite before changes\n- Suggest creating a new ReasonNode with MODIFIES edge\n- Warn about function signatures to preserve\n"
  },
  {
    "path": "commands/icpg-why.md",
    "content": "# /icpg-why — Why Does This Code Exist?\n\nTrace any symbol back to its creating ReasonNode — show the original goal, who wrote it, and whether it's still doing what it was made for.\n\n---\n\n## Usage\n\n`/icpg-why <symbol-name>`\n\n---\n\n## Steps\n\n### 1. Find the symbol\n\n```bash\nicpg query risk <symbol-name>\n```\n\nIf not found, search more broadly:\n```bash\nicpg query context <likely-file-path>\n```\n\n### 2. Show the full trace\n\n```\nWHY: <symbol-name>\n═══════════════════\n\nSymbol: <type> <name> (<file-path>)\nSignature: <signature>\nChecksum: <checksum>\n\nCREATING INTENT:\n  ID: <reason-id>\n  Goal: <goal>\n  Type: <decision_type>\n  Owner: <owner>\n  Status: <status>\n  Created: <date>\n\nCONTRACTS:\n  PRE: <preconditions>\n  POST: <postconditions>\n  INV: <invariants>\n\nMODIFICATION HISTORY:\n  1. <date> — <modifying-reason-goal> (by <owner>)\n  2. <date> — <modifying-reason-goal> (by <owner>)\n\nDRIFT STATUS: {CLEAN | DRIFTED}\n  Dimensions: <drift-dimensions if any>\n  Severity: <score>\n```\n\n### 3. If no ReasonNode found\n\nSymbol exists but has no iCPG tracking:\n```\n⚠ No ReasonNode found for <symbol-name>.\nThis code has no tracked intent — consider creating one:\n  icpg create \"<inferred goal>\" --scope <file>\n```\n"
  },
  {
    "path": "commands/initialize-project.md",
    "content": "# Initialize Project\n\nFull project setup with Claude coding guardrails. Works for both new and existing projects.\n\n**This command is idempotent** - run it anytime to update skills, add missing structure, or reconfigure.\n\n---\n\n## Phase 0: Validate Bootstrap Installation\n\n**FIRST**, verify Maggy is properly installed:\n\n```bash\n# Read bootstrap directory (saved during install)\nBOOTSTRAP_DIR=$(cat ~/.claude/.bootstrap-dir 2>/dev/null)\n# Run quick validation\n\"$BOOTSTRAP_DIR/tests/validate-structure.sh\" --quick\n```\n\nThis checks:\n- Skills are installed with correct structure (folder/SKILL.md)\n- Commands are installed (~/.claude/commands/)\n- Hooks are installed (~/.claude/hooks/)\n\n**If validation fails:**\n- Show the error to user\n- Suggest running: `cd \"$BOOTSTRAP_DIR\" && git pull && ./install.sh`\n- Offer to continue anyway or abort\n\n**If validation passes:**\n- Continue to Phase 1\n\n---\n\n## Phase 1: Detect Project State\n\nFirst, check what already exists:\n\n```bash\n# Check for existing Claude setup\nls -la .claude/skills/ 2>/dev/null\nls -la CLAUDE.md 2>/dev/null\nls -la _project_specs/ 2>/dev/null\n\n# Check for cross-tool setup (Kimi CLI, Codex CLI)\nls -la .kimi/skills/ 2>/dev/null\nls -la .codex/skills/ 2>/dev/null\nls -la .agents/skills/ 2>/dev/null\nls -la AGENTS.md 2>/dev/null\n\n# Detect installed AI CLI tools\nBOOTSTRAP_DIR=$(cat ~/.claude/.bootstrap-dir 2>/dev/null)\nDETECTED_AGENTS=$(\"$BOOTSTRAP_DIR/scripts/detect-agents.sh\" 2>/dev/null || echo \"claude\")\necho \"Detected AI CLI tools: $DETECTED_AGENTS\"\n\n# Check for existing git repo\ngit remote -v 2>/dev/null\n\n# Check for existing package files\nls package.json pyproject.toml 2>/dev/null\n\n# Check for Flutter project\nls pubspec.yaml 2>/dev/null\n\n# Check for Android project\nls android/build.gradle android/app/build.gradle 2>/dev/null\n\n# Check for native language in Android projects\nfind android -name \"*.java\" -type f 2>/dev/null | head -1\nfind android -name \"*.kt\" -type f 2>/dev/null | head -1\n```\n\nBased on findings, determine:\n- **New project**: No CLAUDE.md, no .claude/skills/, no code files\n- **Existing project with skills**: Has .claude/skills/ - offer to UPDATE\n- **Existing codebase without skills**: Has code but no Claude setup - **AUTO-RUN ANALYSIS**\n\nInform the user:\n- \"Detected new project - will do full setup\"\n- \"Detected existing Claude project - will update skills and add any missing structure\"\n- \"Detected existing codebase - **analyzing before making changes...**\"\n\n**For existing codebases without Claude setup, AUTOMATICALLY proceed to Phase 1b.**\n\n---\n\n## Phase 1b: Analyze Existing Codebase (Auto-triggered)\n\n**This phase runs automatically when an existing codebase is detected without Claude setup.**\n\n### Step 1: Repository Structure Detection\n\n```bash\necho \"=== Analyzing Repository Structure ===\" && \\\n\n# Detect repo type\nif [ -d \"packages\" ] || [ -d \"apps\" ] || grep -q '\"workspaces\"' package.json 2>/dev/null; then\n    REPO_TYPE=\"MONOREPO\"\nelif [ -d \"frontend\" ] && [ -d \"backend\" ]; then\n    REPO_TYPE=\"FULL_STACK\"\nelif [ -d \"src\" ] && grep -q '\"react\\|vue\\|angular\"' package.json 2>/dev/null; then\n    REPO_TYPE=\"FRONTEND\"\nelif [ -f \"pyproject.toml\" ] || grep -q '\"express\\|fastify\"' package.json 2>/dev/null; then\n    REPO_TYPE=\"BACKEND\"\nelse\n    REPO_TYPE=\"STANDARD\"\nfi\necho \"Repo Type: $REPO_TYPE\"\n\n# Directory structure (3 levels, excluding noise)\nfind . -type d -maxdepth 3 \\\n    -not -path \"*/node_modules/*\" \\\n    -not -path \"*/.git/*\" \\\n    -not -path \"*/venv/*\" \\\n    -not -path \"*/__pycache__/*\" \\\n    -not -path \"*/dist/*\" \\\n    -not -path \"*/build/*\" \\\n    2>/dev/null | head -30\n```\n\n### Step 2: Tech Stack Detection\n\n```bash\necho \"=== Tech Stack ===\" && \\\n\n# Primary language/framework\n[ -f \"package.json\" ] && echo \"JavaScript/TypeScript project\"\n[ -f \"tsconfig.json\" ] && echo \"  → TypeScript configured\"\n[ -f \"pyproject.toml\" ] && echo \"Python project\"\n[ -f \"pubspec.yaml\" ] && echo \"Flutter project\"\n[ -d \"android\" ] && echo \"Android project\"\n\n# Frameworks (from package.json)\nif [ -f \"package.json\" ]; then\n    grep -q '\"react\"' package.json && echo \"  → React\"\n    grep -q '\"next\"' package.json && echo \"  → Next.js\"\n    grep -q '\"express\"' package.json && echo \"  → Express\"\n    grep -q '\"fastify\"' package.json && echo \"  → Fastify\"\nfi\n\n# Frameworks (from pyproject.toml)\nif [ -f \"pyproject.toml\" ]; then\n    grep -q \"fastapi\" pyproject.toml && echo \"  → FastAPI\"\n    grep -q \"django\" pyproject.toml && echo \"  → Django\"\n    grep -q \"flask\" pyproject.toml && echo \"  → Flask\"\nfi\n```\n\n### Step 3: Guardrails Audit\n\n```bash\necho \"=== Guardrails Status ===\" && \\\n\n# Pre-commit hooks\necho \"Pre-commit Hooks:\"\n[ -d \".husky\" ] && echo \"  ✓ Husky installed\" || echo \"  ✗ Husky NOT installed\"\n[ -f \".pre-commit-config.yaml\" ] && echo \"  ✓ pre-commit framework\" || echo \"  ✗ pre-commit NOT installed\"\n\n# Linting\necho \"Linting:\"\n(grep -q '\"eslint\"' package.json 2>/dev/null && echo \"  ✓ ESLint\") || \\\n(grep -q \"ruff\" pyproject.toml 2>/dev/null && echo \"  ✓ Ruff\") || \\\necho \"  ✗ No linter detected\"\n\n# Formatting\necho \"Formatting:\"\n(grep -q '\"prettier\"' package.json 2>/dev/null && echo \"  ✓ Prettier\") || \\\n(grep -q \"ruff\\|black\" pyproject.toml 2>/dev/null && echo \"  ✓ Ruff/Black\") || \\\necho \"  ✗ No formatter detected\"\n\n# Type checking\necho \"Type Checking:\"\n([ -f \"tsconfig.json\" ] && echo \"  ✓ TypeScript\") || \\\n(grep -q \"mypy\" pyproject.toml 2>/dev/null && echo \"  ✓ mypy\") || \\\necho \"  ✗ No type checker detected\"\n\n# Commit validation\necho \"Commit Validation:\"\n([ -f \"commitlint.config.js\" ] && echo \"  ✓ commitlint\") || \\\n(grep -q \"conventional-pre-commit\" .pre-commit-config.yaml 2>/dev/null && echo \"  ✓ conventional-pre-commit\") || \\\necho \"  ✗ No commit validation\"\n\n# CI/CD\necho \"CI/CD:\"\n[ -d \".github/workflows\" ] && echo \"  ✓ GitHub Actions\" || echo \"  ✗ No GitHub Actions\"\n```\n\n### Step 4: Convention Detection\n\n```bash\necho \"=== Conventions Detected ===\" && \\\n\n# File naming pattern\necho \"File Naming:\"\nls src/**/*.ts 2>/dev/null | head -3 || ls src/**/*.py 2>/dev/null | head -3\n\n# Import style\necho \"Import Style:\"\ngrep -h \"^import\" src/**/*.ts 2>/dev/null | head -3 || \\\ngrep -h \"^from\\|^import\" src/**/*.py 2>/dev/null | head -3\n\n# Test location\necho \"Test Location:\"\n[ -d \"tests\" ] && echo \"  Separate tests/ directory\"\n[ -d \"__tests__\" ] && echo \"  __tests__/ directory\"\nfind . -name \"*.test.*\" -o -name \"*.spec.*\" 2>/dev/null | head -1 && echo \"  Colocated tests\"\n```\n\n### Step 5: Generate Analysis Summary\n\nAfter running the analysis, present this summary to the user:\n\n```markdown\n## Repository Analysis Complete\n\n**Type:** [Monorepo | Full-Stack | Frontend | Backend | Standard]\n**Language:** [TypeScript | Python | Flutter | ...]\n**Framework:** [React | FastAPI | ...]\n\n### Guardrails Status\n\n| Category | Status | Recommendation |\n|----------|--------|----------------|\n| Pre-commit hooks | ✗ Missing | Add Husky (JS) or pre-commit (Python) |\n| Linting | ✓ ESLint | - |\n| Formatting | ✗ Missing | Add Prettier |\n| Type checking | ✓ TypeScript | - |\n| Commit validation | ✗ Missing | Add commitlint |\n\n### Conventions I'll Follow\n- File naming: camelCase\n- Imports: Absolute (@/...)\n- Tests: Colocated (*.test.ts)\n```\n\n### Step 6: Present Options\n\nAfter showing the analysis, ask:\n\n> **I've analyzed this codebase. Here's what I found:** [summary above]\n>\n> What would you like me to do?\n> 1. **Add Claude skills only** - Add skills, preserve everything else\n> 2. **Add skills + missing guardrails** - Also setup Husky/pre-commit, commitlint, etc.\n> 3. **Full setup** - Skills, guardrails, project specs structure, CI/CD\n> 4. **Just show analysis** - Don't change anything yet\n\n**Based on user choice:**\n- Option 1 → Skip to Phase 4, only copy skills\n- Option 2 → Phase 4 + guardrails setup from `existing-repo` skill\n- Option 3 → Full Phase 4 execution\n- Option 4 → End here, user can run `/initialize-project` again later\n\n---\n\n## Phase 2: Validate CLI Tools\n\nCheck required CLI tools are installed and authenticated:\n\n```bash\n# Check GitHub CLI\ngh auth status\n\n# Check Vercel CLI\nvercel whoami\n\n# Check Supabase CLI\nsupabase projects list\n```\n\nIf any tool fails, inform the user and offer to skip:\n- \"GitHub CLI not authenticated. Run: `gh auth login` (or skip if not using GitHub)\"\n- \"Vercel CLI not authenticated. Run: `vercel login` (or skip if not using Vercel)\"\n- \"Supabase CLI not authenticated. Run: `supabase login` (or skip if not using Supabase)\"\n\n---\n\n## Phase 3: Project Questions\n\n**For existing projects with CLAUDE.md**: Read existing config first, then ask what to update.\n\n**For new or unconfigured projects**: Ask these questions one at a time:\n\n### 1. What are you building?\nAsk for a brief description (1-2 sentences).\n*Skip if CLAUDE.md exists and has Project Overview - show current and ask if they want to update.*\n\n### 2. What language/runtime?\n- Python\n- TypeScript\n- JavaScript (Node)\n- Android Java\n- Android Kotlin\n- Flutter (Dart)\n- Multiple (specify which)\n\n*Auto-detect from package.json, pyproject.toml, pubspec.yaml, or android/ directory if present.*\n\n### 3. What type of project?\n- Backend API\n- Frontend Web (React)\n- Mobile App (React Native)\n- Mobile App (Android Native)\n- Mobile App (Flutter)\n- Mobile App (Flutter + Native Android)\n- Full Stack (Backend + Frontend)\n- CLI Tool\n- Library/Package\n\n*Auto-detect from dependencies if possible.*\n\n### 4. Is this an AI-first application?\n- Yes (LLMs handle core logic)\n- No (traditional application)\n\n*Check for anthropic/openai in dependencies.*\n\n### 4b. Code graph analysis level?\n- **Standard** (default) - Lightweight AST graph with symbol lookup, dependency analysis, blast radius\n- **Deep analysis** - Also enable Joern CPG (control flow, data flow, dead code detection)\n- **Security audit** - Also enable CodeQL (taint analysis, vulnerability detection)\n- **Full** - All three tiers\n\n*Tier 1 (codebase-memory-mcp) is always enabled for all projects. This question determines opt-in tiers.*\n*Auto-suggest: If security skill is included, suggest \"Security audit\". If AI-first, suggest \"Deep analysis\".*\n\n### 5. What framework? (based on previous answers)\n**Backend:**\n- Python: FastAPI, Flask, Django\n- Node: Express, Fastify, Hono\n\n**Frontend Web:**\n- React (Vite, Next.js)\n\n**Mobile:**\n- React Native, Expo\n\n*Auto-detect from dependencies.*\n\n### 6. What database?\n- Supabase (Postgres)\n- None / SQLite\n- Other (specify)\n\n*Skip if supabase/ directory exists.*\n\n### 7. Where will this be deployed?\n- Vercel\n- Render\n- Other (specify)\n\n*Skip if vercel.json or render.yaml exists.*\n\n### 8. Repository setup? (skip if git remote already configured)\n- Create new repository\n- Connect to existing repository\n- Skip (local only for now)\n\nIf creating new:\n- What should the repo be named?\n- Public or private?\n\n### 9. Which AI CLI tools do you use? (auto-detect)\n- Claude Code only (default)\n- Claude Code + Kimi CLI\n- Claude Code + Codex CLI\n- All three (Claude + Kimi + Codex)\n\n*Auto-detect using `$BOOTSTRAP_DIR/scripts/detect-agents.sh`. Pre-select based on what's installed. If only Claude is detected, skip this question and default to Claude-only.*\n\n### 10. Enable container isolation for parallel agents? (auto-detect)\n- **Yes** (default if Docker/OrbStack detected) — Each feature agent runs in its own container\n- **No** — Agents share the workspace (native Agent tool)\n\n*Auto-detect Docker/OrbStack. If available, default to Yes and skip this question. Only ask if Docker IS available and you want to confirm, or if Docker is NOT available (inform user and default to No).*\n\n```bash\nif echo \"$DETECTED_AGENTS\" | grep -qE \"docker|orbstack\"; then\n    echo \"Docker detected — container isolation enabled by default\"\n    USE_POLYPHONY=\"true\"\nelse\n    echo \"Docker not found — agents will share the workspace\"\n    USE_POLYPHONY=\"false\"\nfi\n```\n\n---\n\n## Phase 4: Execute Setup\n\n### Step 1: Create/update directory structure\n```bash\nmkdir -p .claude/skills\nmkdir -p docs\nmkdir -p _project_specs/features\nmkdir -p _project_specs/todos\nmkdir -p _project_specs/prompts\nmkdir -p _project_specs/session/archive\nmkdir -p scripts\n\n# Cross-tool directories (if selected in question 9)\nif [ \"$USE_KIMI\" = \"true\" ]; then\n    mkdir -p .kimi/skills\nfi\nif [ \"$USE_CODEX\" = \"true\" ]; then\n    mkdir -p .codex/skills\nfi\n# Generic .agents/ always created for cross-tool compat\nmkdir -p .agents/skills\n```\n\n### Step 2: Update skill files from ~/.claude/skills/\n\n**Skills use folder structure:** Each skill is a folder containing `SKILL.md`.\n\n```bash\n# Copy skill folders (not flat .md files)\ncp -r ~/.claude/skills/base/ .claude/skills/\ncp -r ~/.claude/skills/security/ .claude/skills/\ncp -r ~/.claude/skills/project-tooling/ .claude/skills/\ncp -r ~/.claude/skills/session-management/ .claude/skills/\ncp -r ~/.claude/skills/code-graph/ .claude/skills/\ncp -r ~/.claude/skills/cross-agent-delegation/ .claude/skills/\n```\n\n**Always copy (overwrite with latest):**\n- `base/` → `.claude/skills/base/`\n- `security/` → `.claude/skills/security/`\n- `project-tooling/` → `.claude/skills/project-tooling/`\n- `session-management/` → `.claude/skills/session-management/`\n- `code-graph/` → `.claude/skills/code-graph/`\n- `cross-agent-delegation/` → `.claude/skills/cross-agent-delegation/`\n\n**If deep analysis or security audit selected (question 4b):**\n- `cpg-analysis/` → `.claude/skills/cpg-analysis/`\n\n```bash\n# Copy CPG analysis skill if Tier 2 or 3 selected\nif [ \"$GRAPH_TIER\" != \"standard\" ]; then\n    cp -r ~/.claude/skills/cpg-analysis/ .claude/skills/\nfi\n```\n\n**For existing codebases (detected in Phase 1b):**\n- `existing-repo/` → `.claude/skills/existing-repo/` - Structure preservation, guardrails setup\n\n**Based on language:**\n- Python → copy `python/`\n- TypeScript/JavaScript → copy `typescript/`\n\n**Based on project type:**\n- React Native → copy `typescript/` AND `react-native/`\n- React Web → copy `typescript/` AND `react-web/`\n- Node Backend → copy `typescript/` AND `nodejs-backend/`\n- Full Stack (Node + React) → copy `typescript/`, `nodejs-backend/`, AND `react-web/`\n\n**For Android/Flutter projects (auto-detect from project structure):**\n\n| Detection | Skills to Copy |\n|-----------|---------------|\n| `pubspec.yaml` exists | `flutter/` |\n| `android/*.java` exists | `android-java/` |\n| `android/*.kt` exists | `android-kotlin/` |\n| Flutter + Java files | `flutter/` + `android-java/` |\n| Flutter + Kotlin files | `flutter/` + `android-kotlin/` |\n| Flutter + Both | `flutter/` + `android-java/` + `android-kotlin/` |\n\n```bash\n# Detect and copy Android/Flutter skills\nif [ -f \"pubspec.yaml\" ]; then\n  cp -r ~/.claude/skills/flutter/ .claude/skills/\nfi\n\nif find android -name \"*.java\" -type f 2>/dev/null | head -1 | grep -q .; then\n  cp -r ~/.claude/skills/android-java/ .claude/skills/\nfi\n\nif find android -name \"*.kt\" -type f 2>/dev/null | head -1 | grep -q .; then\n  cp -r ~/.claude/skills/android-kotlin/ .claude/skills/\nfi\n```\n\n**If AI-first:**\n- Copy `llm-patterns/`\n\n**If container isolation enabled (question 10):**\n- Copy `polyphony/`\n\n```bash\nif [ \"$USE_POLYPHONY\" = \"true\" ]; then\n    cp -r ~/.claude/skills/polyphony/ .claude/skills/\nfi\n```\n\n**Note:** Skills are always overwritten with the latest version from ~/.claude/skills/. This ensures updates propagate when user updates their global skills.\n\n### Step 2b: Cross-tool skill sync (if Kimi or Codex selected)\n\nAfter copying skills to `.claude/skills/`, sync to other tool directories:\n\n```bash\n# Sync skills to all selected tools\nfor skill_dir in .claude/skills/*/; do\n    [ -d \"$skill_dir\" ] || continue\n\n    # Kimi CLI\n    if [ \"$USE_KIMI\" = \"true\" ]; then\n        cp -r \"$skill_dir\" .kimi/skills/\n    fi\n\n    # Codex CLI\n    if [ \"$USE_CODEX\" = \"true\" ]; then\n        cp -r \"$skill_dir\" .codex/skills/\n    fi\n\n    # Generic .agents/ (always)\n    cp -r \"$skill_dir\" .agents/skills/\ndone\n\necho \"Skills synced to cross-tool directories\"\n```\n\n### Step 2c: Generate AGENTS.md (if Codex selected)\n\nIf Codex was selected in question 9, generate `AGENTS.md` alongside `CLAUDE.md`:\n\n**If AGENTS.md exists:** Preserve customizations, update skill references to `.agents/skills/` paths.\n\n**If new:** Generate from `CLAUDE.md` content, replacing `.claude/skills/` references with `.agents/skills/` paths. The structure mirrors CLAUDE.md but uses the generic skill path that Codex reads.\n\n```bash\nif [ \"$USE_CODEX\" = \"true\" ] && [ ! -f \"AGENTS.md\" ]; then\n    if [ -f \"CLAUDE.md\" ]; then\n        # Generate from existing CLAUDE.md\n        sed 's|\\.claude/skills/|.agents/skills/|g' CLAUDE.md > AGENTS.md\n        echo \"Generated AGENTS.md from CLAUDE.md\"\n    else\n        # Copy template\n        cp \"$BOOTSTRAP_DIR/templates/AGENTS.md\" ./AGENTS.md\n        echo \"Created AGENTS.md from template\"\n    fi\nfi\n```\n\n### Step 2d: Generate config.toml hooks (if Kimi or Codex selected)\n\n```bash\nBOOTSTRAP_DIR=$(cat ~/.claude/.bootstrap-dir 2>/dev/null)\n\nif [ \"$USE_KIMI\" = \"true\" ]; then\n    cp \"$BOOTSTRAP_DIR/templates/config.toml\" .kimi/config.toml\n    echo \"Created .kimi/config.toml with hooks\"\nfi\n\nif [ \"$USE_CODEX\" = \"true\" ]; then\n    cp \"$BOOTSTRAP_DIR/templates/config.toml\" .codex/config.toml\n    echo \"Created .codex/config.toml with hooks\"\nfi\n```\n\n### Step 3: Create/update .gitignore (if missing or incomplete)\n\nEnsure these security-critical entries exist:\n```gitignore\n# Environment files - NEVER commit\n.env\n.env.*\n!.env.example\n\n# Secrets\n*.pem\n*.key\n*.p12\ncredentials.json\nsecrets.json\nservice-account*.json\n\n# Dependencies\nnode_modules/\n__pycache__/\n*.pyc\n.venv/\nvenv/\n\n# Build outputs\ndist/\nbuild/\n\n# Code graph data (auto-generated)\n.code-graph/\n\n# Cross-tool agent dirs (derived from .claude/skills/, regenerated by /sync-agents)\n.kimi/\n.codex/\n.agents/\n\n# IDE\n.idea/\n.vscode/settings.json\n.DS_Store\n```\n\n### Step 4: Create .env.example (if missing)\n\nBased on project type:\n```bash\n# .env.example - Copy to .env and fill in values\n# Server-side only (NEVER prefix with VITE_ or NEXT_PUBLIC_)\nDATABASE_URL=\nANTHROPIC_API_KEY=\n\n# Client-side safe (public, non-sensitive)\nVITE_SUPABASE_URL=\nVITE_SUPABASE_ANON_KEY=\n```\n\n### Step 4b: Configure Code Graph MCP Servers\n\n**This step runs for ALL projects** (Tier 1 is always-on).\n\n#### Create/merge .mcp.json\n\n```bash\n# Check if .mcp.json exists\nif [ -f \".mcp.json\" ]; then\n    echo \"Existing .mcp.json found - will merge code graph config\"\nelse\n    echo \"Creating .mcp.json for code graph MCP servers\"\nfi\n```\n\n**Always add (Tier 1 — codebase-memory-mcp):**\n```json\n{\n  \"mcpServers\": {\n    \"codebase-memory\": {\n      \"command\": \"codebase-memory-mcp\",\n      \"args\": []\n    }\n  }\n}\n```\n\n**If Tier 2 selected (deep analysis / full), also add:**\n```json\n{\n  \"mcpServers\": {\n    \"codebadger\": {\n      \"url\": \"http://localhost:4242/mcp\",\n      \"type\": \"http\"\n    }\n  }\n}\n```\n\n**If Tier 3 selected (security audit / full), also add:**\n```json\n{\n  \"mcpServers\": {\n    \"codeql\": {\n      \"command\": \"codeql-mcp\",\n      \"args\": [\"--database\", \".code-graph/codeql-db\"]\n    }\n  }\n}\n```\n\n**Merge strategy:** If `.mcp.json` already exists, read it, merge new `mcpServers` entries without overwriting existing ones, write back.\n\n#### Add .code-graph/ to .gitignore\n\nEnsure this entry exists in `.gitignore`:\n```gitignore\n# Code graph data (auto-generated, machine-specific)\n.code-graph/\n```\n\n#### Auto-install codebase-memory-mcp (if not found)\n\n```bash\nif ! command -v codebase-memory-mcp &> /dev/null; then\n    echo \"\"\n    echo \"Installing codebase-memory-mcp (Tier 1 code graph)...\"\n\n    # Run the graph tools installer (Tier 1 only by default)\n    if [ -f \"$HOME/.claude/install-graph-tools.sh\" ]; then\n        bash \"$HOME/.claude/install-graph-tools.sh\"\n    else\n        # Fallback: inline install\n        INSTALL_DIR=\"$HOME/.local/bin\"\n        mkdir -p \"$INSTALL_DIR\"\n        OS=$(uname -s | tr '[:upper:]' '[:lower:]')\n        ARCH=$(uname -m)\n        case \"$ARCH\" in\n            aarch64|arm64) ARCH=\"arm64\" ;;\n            x86_64|amd64) ARCH=\"amd64\" ;;\n        esac\n        DOWNLOAD_URL=\"https://github.com/DeusData/codebase-memory-mcp/releases/latest/download/codebase-memory-mcp-${OS}-${ARCH}.tar.gz\"\n        TEMP_DIR=$(mktemp -d)\n        if curl -fsSL \"$DOWNLOAD_URL\" -o \"$TEMP_DIR/codebase-memory-mcp.tar.gz\"; then\n            tar xzf \"$TEMP_DIR/codebase-memory-mcp.tar.gz\" -C \"$TEMP_DIR\"\n            mv \"$TEMP_DIR/codebase-memory-mcp\" \"$INSTALL_DIR/codebase-memory-mcp\"\n            chmod +x \"$INSTALL_DIR/codebase-memory-mcp\"\n            echo \"✓ Installed codebase-memory-mcp to $INSTALL_DIR\"\n            # Auto-configure for Claude Code\n            \"$INSTALL_DIR/codebase-memory-mcp\" install 2>/dev/null || true\n        else\n            echo \"⚠ Failed to download codebase-memory-mcp\"\n            echo \"  Manual install: ~/.claude/install-graph-tools.sh\"\n        fi\n        rm -rf \"$TEMP_DIR\"\n    fi\nelse\n    echo \"✓ codebase-memory-mcp already installed\"\nfi\n```\n\n#### Auto-install Tier 2/3 tools (if selected)\n\n```bash\n# Tier 2: Joern CPG (if deep analysis or full selected)\nif [ \"$GRAPH_TIER\" = \"deep\" ] || [ \"$GRAPH_TIER\" = \"full\" ]; then\n    if [ -f \"$HOME/.claude/install-graph-tools.sh\" ]; then\n        echo \"\"\n        echo \"Installing Joern CPG (Tier 2)...\"\n        bash \"$HOME/.claude/install-graph-tools.sh\" --joern\n    fi\nfi\n\n# Tier 3: CodeQL (if security audit or full selected)\nif [ \"$GRAPH_TIER\" = \"security\" ] || [ \"$GRAPH_TIER\" = \"full\" ]; then\n    if [ -f \"$HOME/.claude/install-graph-tools.sh\" ]; then\n        echo \"\"\n        echo \"Installing CodeQL (Tier 3)...\"\n        bash \"$HOME/.claude/install-graph-tools.sh\" --codeql\n    fi\nfi\n```\n\n#### Enable auto-indexing and build initial graph\n\n```bash\nif command -v codebase-memory-mcp &> /dev/null; then\n    # Enable auto-index so graph stays fresh across sessions\n    codebase-memory-mcp config set auto_index true 2>/dev/null || true\n\n    # Build initial graph index for this project\n    echo \"\"\n    echo \"Building code graph index (first time may take a moment)...\"\n    codebase-memory-mcp index --project-dir . 2>/dev/null || {\n        echo \"⚠ Initial index failed - graph will be built on first MCP query\"\n    }\n    echo \"✓ Code graph indexed\"\nfi\n```\n\n#### Install post-commit graph update hook\n\n```bash\nif [ -d \".git\" ]; then\n    # Append to existing post-commit hook (don't overwrite)\n    if [ -f \".git/hooks/post-commit\" ]; then\n        if ! grep -q \"code-graph\" \".git/hooks/post-commit\"; then\n            echo \"\" >> .git/hooks/post-commit\n            echo \"# Code graph incremental update\" >> .git/hooks/post-commit\n            cat ~/.claude/hooks/post-commit-graph >> .git/hooks/post-commit\n        fi\n    else\n        cp ~/.claude/hooks/post-commit-graph .git/hooks/post-commit\n        chmod +x .git/hooks/post-commit\n    fi\n    echo \"✓ Post-commit graph update hook installed\"\nfi\n```\n\n### Step 5: Create/update verification script\nCreate or overwrite `scripts/verify-tooling.sh`:\n\n```bash\n#!/bin/bash\nset -e\n\necho \"Verifying project tooling...\"\n\n# GitHub CLI\nif command -v gh &> /dev/null; then\n  if gh auth status &> /dev/null; then\n    echo \"✓ GitHub CLI authenticated\"\n  else\n    echo \"✗ GitHub CLI not authenticated. Run: gh auth login\"\n    exit 1\n  fi\nelse\n  echo \"⚠ GitHub CLI not installed. Run: brew install gh\"\nfi\n\n# Vercel CLI\nif command -v vercel &> /dev/null; then\n  if vercel whoami &> /dev/null; then\n    echo \"✓ Vercel CLI authenticated\"\n  else\n    echo \"✗ Vercel CLI not authenticated. Run: vercel login\"\n    exit 1\n  fi\nelse\n  echo \"⚠ Vercel CLI not installed. Run: npm i -g vercel\"\nfi\n\n# Supabase CLI\nif command -v supabase &> /dev/null; then\n  if supabase projects list &> /dev/null 2>&1; then\n    echo \"✓ Supabase CLI authenticated\"\n  else\n    echo \"✗ Supabase CLI not authenticated. Run: supabase login\"\n    exit 1\n  fi\nelse\n  echo \"⚠ Supabase CLI not installed. Run: brew install supabase/tap/supabase\"\nfi\n\necho \"\"\necho \"Tooling verification complete!\"\n```\n\n```bash\nchmod +x scripts/verify-tooling.sh\n```\n\n### Step 6: Create security check script\n\nCreate `scripts/security-check.sh`:\n```bash\n#!/bin/bash\nset -e\n\necho \"Running security checks...\"\n\n# Check .env is not staged\nif git diff --cached --name-only | grep -E '^\\.env$|^\\.env\\.' | grep -v '\\.example$'; then\n  echo \"ERROR: .env file is staged for commit!\"\n  exit 1\nfi\n\n# Check for common secret patterns\nSTAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM)\nif [ -n \"$STAGED_FILES\" ]; then\n  if echo \"$STAGED_FILES\" | xargs grep -l -E '(password|secret|api_key|apikey|token)\\s*[:=]\\s*[\"\\047][^\"\\047]{8,}[\"\\047]' 2>/dev/null; then\n    echo \"WARNING: Possible secrets found in staged files - please verify\"\n  fi\nfi\n\n# Check for VITE_* secrets (common mistake)\nif [ -n \"$STAGED_FILES\" ]; then\n  if echo \"$STAGED_FILES\" | xargs grep -l -E 'VITE_.*SECRET|VITE_.*KEY.*=.*[a-zA-Z0-9]{20,}' 2>/dev/null; then\n    echo \"ERROR: Secrets in VITE_* env vars are exposed to client!\"\n    exit 1\n  fi\nfi\n\n# Dependency audit\nif [ -f \"package.json\" ]; then\n  echo \"Checking npm dependencies...\"\n  npm audit --audit-level=high 2>/dev/null || echo \"Warning: npm audit found issues\"\nfi\n\nif [ -f \"pyproject.toml\" ] || [ -f \"requirements.txt\" ]; then\n  if command -v safety &> /dev/null; then\n    echo \"Checking Python dependencies...\"\n    safety check 2>/dev/null || echo \"Warning: safety found issues\"\n  fi\nfi\n\necho \"Security checks complete!\"\n```\n\n```bash\nchmod +x scripts/security-check.sh\n```\n\n### Step 7: Create/update CLAUDE.md\n\n**If CLAUDE.md exists:**\n- Preserve Project Overview, Tech Stack, and Project-Specific Patterns sections\n- Update Skills list to reference current .claude/skills/ contents\n- Update Key Commands section with latest\n\n**If new:**\n```markdown\n# CLAUDE.md\n\n## Skills\nRead and follow these skills before writing any code:\n- .claude/skills/base/SKILL.md\n- .claude/skills/security/SKILL.md\n- .claude/skills/project-tooling/SKILL.md\n- .claude/skills/session-management/SKILL.md\n- .claude/skills/code-graph/SKILL.md\n- .claude/skills/cross-agent-delegation/SKILL.md\n- .claude/skills/cpg-analysis/SKILL.md (if deep analysis or security audit)\n- .claude/skills/[language]/SKILL.md\n- .claude/skills/[framework]/SKILL.md (if applicable)\n- .claude/skills/llm-patterns/SKILL.md (if AI-first)\n\n## Project Overview\n[Description from question 1]\n\n## Tech Stack\n- Language: [X]\n- Framework: [X]\n- Database: [X]\n- Deployment: [X]\n- Testing: [X]\n\n## Key Commands\n```bash\n# Verify all CLI tools are working\n./scripts/verify-tooling.sh\n\n# Install dependencies\nnpm install          # or: pip install -e \".[dev]\"\n\n# Run tests\nnpm test             # or: pytest\n\n# Lint\nnpm run lint         # or: ruff check .\n\n# Type check\nnpm run typecheck    # or: mypy src/\n\n# Pre-commit hooks (run once after clone)\nnpx husky init       # or: pre-commit install\n\n# Database (if using Supabase)\nnpm run db:start     # Start local Supabase\nnpm run db:migrate   # Push migrations\n\n# Deploy\nnpm run deploy:preview  # Deploy to preview\nnpm run deploy:prod     # Deploy to production\n```\n\n## Documentation\n- `docs/` - Technical documentation\n- `_project_specs/` - Project specifications and todos\n\n## Atomic Todos\nAll work is tracked in `_project_specs/todos/`:\n- `active.md` - Current work\n- `backlog.md` - Future work\n- `completed.md` - Done (for reference)\n\nEvery todo must have validation criteria and test cases. See base.md skill for format.\n\n## Session Management\n\n### State Tracking\nMaintain session state in `_project_specs/session/`:\n- `current-state.md` - Live session state (update every 15-20 tool calls)\n- `decisions.md` - Key architectural/implementation decisions (append-only)\n- `code-landmarks.md` - Important code locations for quick reference\n- `archive/` - Past session summaries\n\n### Automatic Updates\nUpdate `current-state.md`:\n- After completing any todo item\n- Every 15-20 tool calls during active work\n- Before any significant context shift\n- When encountering blockers\n\n### Decision Logging\nLog to `decisions.md` when:\n- Choosing between architectural approaches\n- Selecting libraries or tools\n- Making security-related choices\n- Deviating from standard patterns\n\n### Context Compression\nWhen context feels heavy (~50+ tool calls):\n1. Summarize completed work in current-state.md\n2. Archive verbose exploration notes to archive/\n3. Keep only essential context for next steps\n\n### Session Handoff\nWhen ending a session or approaching context limits, update current-state.md with:\n- What was completed this session\n- Current state of work\n- Immediate next steps (numbered, specific)\n- Open questions or blockers\n- Files to review first when resuming\n\n### Resuming Work\nWhen starting a new session:\n1. Read `_project_specs/session/current-state.md`\n2. Check `_project_specs/todos/active.md`\n3. Review recent entries in `decisions.md` if context needed\n4. Continue from \"Next Steps\" in current-state.md\n\n## Code Graph (MCP)\n\nThis project uses MCP-based code graph for optimized code navigation.\n\n### Available Tiers\n- **Tier 1** (always on): `codebase-memory-mcp` - AST graph, symbol lookup, blast radius\n- **Tier 2** (opt-in): Joern/CodeBadger - Full CPG, control/data flow analysis\n- **Tier 3** (opt-in): CodeQL - Taint analysis, security vulnerability detection\n\n### Usage Priority\n1. **Graph first** - Use MCP graph tools for symbol search, dependency tracing, impact analysis\n2. **File read second** - Only read full files when you need to modify code or need full context\n3. **Grep last** - Avoid grep when graph tools can answer the question faster\n\n### Configuration\n- MCP config: `.mcp.json` (project root, committed)\n- Graph data: `.code-graph/` (gitignored, auto-updated)\n- Post-commit hook: auto-updates graph on code changes\n\n### Key Graph Commands\n```bash\n# Install graph tools (run once per machine)\n~/.claude/install-graph-tools.sh\n\n# Install with deep CPG analysis\n~/.claude/install-graph-tools.sh --joern\n\n# Install with security auditing\n~/.claude/install-graph-tools.sh --codeql\n```\n\n## Project-Specific Patterns\n[Any specific patterns for this project]\n```\n\n### Step 5: Create project specs structure (if missing)\n\nOnly create files that don't exist - never overwrite existing specs.\n\n**_project_specs/overview.md** (if missing):\n```markdown\n# Project Overview\n\n## Vision\n[Description from question 1]\n\n## Goals\n- [ ] Goal 1\n- [ ] Goal 2\n\n## Non-Goals\n- What this project will NOT do\n\n## Success Metrics\n- How we measure success\n```\n\n**_project_specs/todos/active.md** (if missing):\n```markdown\n# Active Todos\n\nCurrent work in progress. Each todo follows the atomic todo format from base.md skill.\n\n---\n\n<!-- Add todos here -->\n```\n\n**_project_specs/todos/backlog.md** (if missing):\n```markdown\n# Backlog\n\nFuture work, prioritized. Move to active.md when starting.\n\n---\n\n<!-- Add todos here -->\n```\n\n**_project_specs/todos/completed.md** (if missing):\n```markdown\n# Completed\n\nDone items for reference. Move here from active.md when complete.\n\n---\n\n<!-- Add completed todos here -->\n```\n\n**_project_specs/session/current-state.md** (if missing):\n```markdown\n<!--\nCHECKPOINT RULES (from session-management.md):\n- Quick update: After any todo completion\n- Full checkpoint: After ~20 tool calls or decisions\n- Archive: End of session or major feature complete\n\nAfter each task, ask: Decision made? >10 tool calls? Feature done?\n-->\n\n# Current Session State\n\n*Last updated: [timestamp]*\n\n## Active Task\n[What are we working on right now - one sentence]\n\n## Current Status\n- **Phase**: exploring | planning | implementing | testing | debugging\n- **Progress**: [X of Y steps, or description]\n- **Blocking Issues**: None\n\n## Context Summary\n[2-3 sentences summarizing current state of work]\n\n## Files Being Modified\n| File | Status | Notes |\n|------|--------|-------|\n| - | - | - |\n\n## Next Steps\n1. [ ] First next action\n2. [ ] Second next action\n\n## Key Context to Preserve\n- [Important decisions or context for this task]\n\n## Resume Instructions\nTo continue this work:\n1. [Specific starting point]\n2. [What to check/read first]\n```\n\n**_project_specs/session/decisions.md** (if missing):\n```markdown\n<!--\nLOG DECISIONS WHEN:\n- Choosing between architectural approaches\n- Selecting libraries or tools\n- Making security-related choices\n- Deviating from standard patterns\n\nThis is append-only. Never delete entries.\n-->\n\n# Decision Log\n\nTrack key architectural and implementation decisions.\n\n## Format\n```\n## [YYYY-MM-DD] Decision Title\n\n**Decision**: What was decided\n**Context**: Why this decision was needed\n**Options Considered**: What alternatives existed\n**Choice**: Which option was chosen\n**Reasoning**: Why this choice was made\n**Trade-offs**: What we gave up\n**References**: Related code/docs\n```\n\n---\n\n<!-- Add decisions below -->\n```\n\n**_project_specs/session/code-landmarks.md** (if missing):\n```markdown\n<!--\nUPDATE WHEN:\n- Adding new entry points or key files\n- Introducing new patterns\n- Discovering non-obvious behavior\n\nHelps quickly navigate the codebase when resuming work.\n-->\n\n# Code Landmarks\n\nQuick reference to important parts of the codebase.\n\n## Entry Points\n| Location | Purpose |\n|----------|---------|\n| - | Main application entry |\n\n## Core Business Logic\n| Location | Purpose |\n|----------|---------|\n| - | - |\n\n## Configuration\n| Location | Purpose |\n|----------|---------|\n| - | Environment/app config |\n\n## Key Patterns\n| Pattern | Example Location | Notes |\n|---------|------------------|-------|\n| - | - | - |\n\n## Testing\n| Location | Purpose |\n|----------|---------|\n| tests/ | Test files |\n\n## Gotchas & Non-Obvious Behavior\n| Location | Issue | Notes |\n|----------|-------|-------|\n| - | - | - |\n```\n\n### Step 9: Create/update GitHub Actions workflows\n\n**Quality workflow** (`.github/workflows/quality.yml`):\nCreate based on language (copy from the relevant skill file).\n\n**Security workflow** (`.github/workflows/security.yml`):\n```yaml\nname: Security\n\non:\n  push:\n    branches: [main]\n  pull_request:\n    branches: [main]\n  schedule:\n    - cron: '0 9 * * 1'  # Weekly on Monday\n\njobs:\n  secrets-scan:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n      - name: Detect secrets\n        uses: trufflesecurity/trufflehog@main\n        with:\n          path: ./\n\n  dependency-audit:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - name: Setup Node\n        if: hashFiles('package.json') != ''\n        uses: actions/setup-node@v4\n        with:\n          node-version: '20'\n      - name: NPM Audit\n        if: hashFiles('package.json') != ''\n        run: npm audit --audit-level=high\n      - name: Setup Python\n        if: hashFiles('pyproject.toml') != ''\n        uses: actions/setup-python@v5\n        with:\n          python-version: '3.12'\n      - name: Safety check\n        if: hashFiles('pyproject.toml') != ''\n        run: pip install safety && safety check\n```\n\n### Step 7: Set up pre-commit hooks (if not already configured)\n\n**For Python projects** (if .pre-commit-config.yaml missing):\nCreate `.pre-commit-config.yaml`\n\n**For TypeScript/JavaScript projects** (if .husky/ missing):\nSet up Husky + lint-staged\n\n### Step 7b: Install pre-push code review hook\n\n**Always install the pre-push hook for code review enforcement:**\n\n```bash\n# Check if .git exists\nif [ -d \".git\" ]; then\n    # Copy pre-push hook from ~/.claude/hooks/\n    cp ~/.claude/hooks/pre-push .git/hooks/pre-push\n    chmod +x .git/hooks/pre-push\n    echo \"✓ Pre-push code review hook installed\"\nfi\n```\n\nThis hook:\n- Runs `/code-review` before every `git push`\n- Blocks push if 🔴 Critical or 🟠 High severity issues found\n- Allows push with advisory for 🟡 Medium and 🟢 Low issues\n\nTo disable: `rm .git/hooks/pre-push`\n\n### Step 8: GitHub repository setup (if selected and not already configured)\n\n**Create new repository:**\n```bash\ngit init  # if needed\ngit add .\ngit commit -m \"Initial project setup\"\ngh repo create [repo-name] --[public|private] --source=. --remote=origin --push\n```\n\n**Connect to existing:**\n```bash\ngit remote add origin https://github.com/[owner]/[repo].git\ngit push -u origin main\n```\n\n### Step 9: Initialize deployment (if not already configured)\n\n**Vercel** (if vercel.json missing):\n```bash\nvercel link\n```\n\n**Supabase** (if supabase/ missing):\n```bash\nsupabase init\n```\n\n---\n\n## Phase 5: Summary\n\nAfter setup, show what was done:\n\n### For Updates (existing project):\n```\nUpdated:\n✓ Skills updated to latest versions\n  - base.md (updated)\n  - typescript.md (updated)\n  - react-web.md (updated)\n  - code-graph.md (updated)\n✓ Pre-push code review hook (installed/updated)\n\nAdded:\n✓ llm-patterns.md (new skill added)\n✓ _project_specs/prompts/ (new directory)\n\nCode Graph (fully automated):\n✓ codebase-memory-mcp installed and configured\n✓ .mcp.json configured (Tier 1: codebase-memory-mcp)\n✓ Auto-indexing enabled (graph stays fresh across sessions)\n✓ Initial graph index built\n✓ Post-commit graph update hook installed\n[✓ Tier 2: Joern CPG installed and configured (if selected)]\n[✓ Tier 3: CodeQL installed and configured (if selected)]\n\nCross-Tool Compatibility (if selected):\n[✓ Skills synced to .kimi/skills/ (Kimi CLI)]\n[✓ Skills synced to .codex/skills/ (Codex CLI)]\n[✓ Skills synced to .agents/skills/ (generic)]\n[✓ AGENTS.md created (Codex project instructions)]\n[✓ .kimi/config.toml created (Kimi hooks)]\n[✓ .codex/config.toml created (Codex hooks)]\n\nUnchanged:\n- CLAUDE.md (preserved your customizations)\n- _project_specs/todos/ (preserved your todos)\n- Git repository (already configured)\n```\n\n### For New Projects:\n```\nCreated:\n✓ .claude/skills/ with [N] skill files (including code-graph)\n✓ CLAUDE.md\n✓ _project_specs/ structure\n✓ scripts/verify-tooling.sh\n✓ .github/workflows/quality.yml\n✓ Pre-commit hooks configured\n✓ Pre-push code review hook (blocks on Critical/High issues)\n✓ GitHub repository: https://github.com/[owner]/[repo]\n\nCode Graph (fully automated):\n✓ codebase-memory-mcp installed\n✓ .mcp.json configured\n  Tier 1: codebase-memory-mcp (always on - AST graph, 64 langs)\n  [Tier 2: Joern CPG (control flow, data flow)]\n  [Tier 3: CodeQL (taint analysis, security)]\n✓ Auto-indexing enabled\n✓ Initial graph index built ([N] files, [N] symbols)\n✓ .code-graph/ added to .gitignore\n✓ Post-commit graph update hook installed\n\nCross-Tool Compatibility (if selected):\n✓ Skills synced to .kimi/skills/, .codex/skills/, .agents/skills/\n✓ AGENTS.md created (Codex project instructions)\n✓ .kimi/config.toml + .codex/config.toml (hooks)\n✓ .kimi/, .codex/, .agents/ added to .gitignore\n```\n\n### Quick Start\n```bash\n# Verify setup\n./scripts/verify-tooling.sh\n\n# Install dependencies\n[appropriate command]\n\n# Start development\n[appropriate command]\n```\n\n---\n\n## Phase 5b: Polyphony Setup (Container Isolation)\n\n**This phase runs automatically when Docker/OrbStack is detected (question 10) and the user hasn't opted out.**\n\n### Step 1: Check prerequisites\n\n```bash\n# Verify Docker is running\nif echo \"$DETECTED_AGENTS\" | grep -qE \"docker|orbstack\"; then\n    docker info &>/dev/null && echo \"✓ Docker running\" || echo \"⚠ Docker installed but not running\"\nfi\n\n# Check polyphony CLI\ncommand -v polyphony &>/dev/null && echo \"✓ polyphony CLI available\" || echo \"⚠ polyphony not on PATH\"\n```\n\n### Step 2: Initialize Polyphony config (if missing)\n\n```bash\nif [ ! -d \"$HOME/.polyphony\" ]; then\n    polyphony init\n    echo \"✓ Created ~/.polyphony/ config\"\nelse\n    echo \"✓ ~/.polyphony/ already exists\"\nfi\n```\n\n### Step 3: Build worker image (if not present)\n\n```bash\nif ! docker image inspect polyphony-worker:latest &>/dev/null 2>&1; then\n    BOOTSTRAP_DIR=$(cat ~/.claude/.bootstrap-dir 2>/dev/null)\n    if [ -f \"$BOOTSTRAP_DIR/templates/Dockerfile.polyphony\" ]; then\n        echo \"Building polyphony-worker image...\"\n        docker build -t polyphony-worker:latest -f \"$BOOTSTRAP_DIR/templates/Dockerfile.polyphony\" \"$BOOTSTRAP_DIR\"\n        echo \"✓ Built polyphony-worker:latest\"\n    fi\nelse\n    echo \"✓ polyphony-worker:latest image exists\"\nfi\n```\n\n### Step 4: Add polyphony skill to project\n\n```bash\n# Copy polyphony skill to project\ncp -r ~/.claude/skills/polyphony/ .claude/skills/\n```\n\nAdd to CLAUDE.md Skills section:\n```markdown\n- .claude/skills/polyphony/SKILL.md\n```\n\nAdd to CLAUDE.md Cross-Agent Workflow section:\n```markdown\n### Container Isolation (Polyphony)\nWhen Docker is available, each feature agent runs in its own container with an independent git branch.\n- `/spawn-team` uses Polyphony by default (fallback to native agents if no Docker)\n- `polyphony status` to see running agents\n- `polyphony cleanup` after completion\n```\n\n### Step 5: Show Polyphony status in summary\n\nAdd to the Phase 5 summary output:\n```\nContainer Isolation (Polyphony):\n✓ Docker/OrbStack detected\n✓ polyphony CLI available\n✓ ~/.polyphony/ config ready\n✓ polyphony-worker:latest image built\n✓ Polyphony skill added to project\n→ /spawn-team will use container isolation by default\n```\n\n**If Docker not available:**\n```\nContainer Isolation:\n⚠ Docker not found — /spawn-team will use native agents (shared workspace)\n  Install Docker: brew install --cask docker\n```\n\n---\n\n## Phase 6: Agent Team Setup (Default Workflow)\n\nEvery project uses Claude Agent Teams by default. This phase sets up the team infrastructure and spawns agents to implement features in parallel.\n\n### Step 1: Set Environment Variable\n\nEnsure the agent teams experimental flag is set:\n\n```bash\nexport CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1\n```\n\nAlso add to the project's `.env.example` if not present:\n```\n# Agent Teams (required for Maggy team workflow)\nCLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1\n```\n\n### Step 2: Copy Agent Definitions\n\nCopy agent definitions from the agent-teams skill to the project:\n\n```bash\nmkdir -p .claude/agents\ncp ~/.claude/skills/agent-teams/agents/*.md .claude/agents/\n```\n\nThis creates:\n```\n.claude/agents/\n  team-lead.md      # Orchestration only, delegate mode\n  quality.md        # TDD verification (RED/GREEN phases)\n  security.md       # OWASP scanning, secrets detection\n  code-review.md    # Multi-engine code review\n  merger.md         # Branch creation, PR management\n  feature.md        # Feature implementation template\n```\n\n### Step 3: Add Agent Teams to CLAUDE.md\n\nAdd the agent-teams skill to the Skills section in CLAUDE.md:\n```\n- .claude/skills/agent-teams/SKILL.md\n```\n\nAdd a new section to CLAUDE.md:\n```markdown\n## Agent Teams (Default Workflow)\n\nThis project uses Claude Code Agent Teams as the default development workflow.\nEvery feature is implemented by a dedicated agent following a strict TDD pipeline.\n\n### Strict Pipeline (per feature)\nSpec > Spec Review > Tests > RED Verify > Implement > GREEN Verify > Validate > Code Review > Security Scan > Branch + PR\n\n### Team Roster\n- **Team Lead**: Orchestrates, breaks work into features, assigns tasks (NEVER writes code)\n- **Quality Agent**: Verifies TDD discipline - RED/GREEN phases, coverage >= 80%\n- **Security Agent**: OWASP scanning, secrets detection, dependency audit\n- **Code Review Agent**: Multi-engine code reviews (Claude/Codex/Gemini)\n- **Merger Agent**: Creates feature branches and PRs via gh CLI\n- **Feature Agents**: One per feature, follows strict TDD pipeline\n\n### Commands\n- `/spawn-team` - Spawn the agent team (auto-run after init, or run manually)\n\n### Required Environment\nexport CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1\n```\n\n### Step 4: Prompt for Features\n\n**For new projects:**\n> **Project initialized! Ready to deploy the agent team.**\n>\n> The agent team implements features in parallel using a strict TDD pipeline:\n> ```\n> Spec > Tests > Verify Fail > Implement > Verify Pass > Review > Security > PR\n> ```\n>\n> What are the key features of this project? List them and I'll create a spec\n> skeleton for each, then spawn the team to implement them in parallel.\n>\n> Example: \"user authentication, dashboard, payment processing\"\n\nFor each feature the user lists:\n1. Create `_project_specs/features/{feature-name}.md` with skeleton spec\n2. Include: description (from user input), empty acceptance criteria, empty test cases table\n\n**For existing projects:**\n> **Project updated with latest skills and agent team support!**\n>\n> I've added agent team infrastructure. Your options:\n> 1. Define features and spawn the team now\n> 2. Continue working on existing todos (solo mode)\n> 3. Review what's new in skills\n\n### Step 5: Spawn Team\n\nAfter the user provides features (or if feature specs already exist), automatically run the `/spawn-team` workflow:\n\n1. Create the team (TeamCreate)\n2. Spawn 5 default agents (team-lead, quality-agent, security-agent, review-agent, merger-agent)\n3. Spawn 1 feature agent per feature\n4. Team lead creates 10-task dependency chains per feature\n5. Work begins automatically\n\n### Step 6: Show Team Status\n\n```\n┌─────────────────────────────────────────────────────────────────┐\n│  AGENT TEAM DEPLOYED                                             │\n│  ──────────────────────────────────────────────────────────────  │\n│                                                                  │\n│  Team: {project-name}                                            │\n│  Features: {N}                                                   │\n│  Total tasks: {N * 10}                                           │\n│  Agents: {5 + N}                                                 │\n│                                                                  │\n│  PIPELINE (per feature)                                          │\n│  Spec > Review > Tests > RED > Implement > GREEN >               │\n│  Validate > Code Review > Security > Branch+PR                   │\n│                                                                  │\n│  Use Shift+Up/Down to select and message agents.                 │\n│  Use Ctrl+T to toggle the shared task list.                      │\n│  The team runs autonomously until all PRs are created.           │\n└─────────────────────────────────────────────────────────────────┘\n```\n\n---\n\n## Updating Skills System-Wide\n\nTo update skills for all future projects:\n\n```bash\n# Pull latest skills\ncd \"$(cat ~/.claude/.bootstrap-dir)\"\ngit pull\n\n# Reinstall\n./install.sh\n\n# Validate installation\n./tests/validate-structure.sh\n```\n\nThen in any existing project:\n```\n/initialize-project\n```\n\nSkills will be updated while preserving project-specific configuration.\n\n## Troubleshooting\n\nIf `/initialize-project` shows validation errors:\n\n```bash\nBOOTSTRAP_DIR=$(cat ~/.claude/.bootstrap-dir 2>/dev/null)\n# Full validation to see all issues\n\"$BOOTSTRAP_DIR/tests/validate-structure.sh\" --full\n\n# Quick validation (what initialize-project runs)\n\"$BOOTSTRAP_DIR/tests/validate-structure.sh\" --quick\n```\n\nCommon issues:\n- **Flat .md files**: Skills should be folders with SKILL.md, not flat files\n- **Missing commands**: Reinstall with `./install.sh`\n- **Missing hooks**: Reinstall with `./install.sh`\n"
  },
  {
    "path": "commands/maggy-init.md",
    "content": "# /maggy-init — Set Up Maggy for This Team\n\nInteractive wizard that configures Maggy for the user's org, issue tracker, and codebases. Writes `~/.maggy/config.yaml` and ensures deps are installed.\n\n---\n\n## Usage\n\n`/maggy-init` — run the full setup wizard\n\n---\n\n## Steps\n\n### 1. Check prerequisites\n\n- Python 3.11+ available\n- `claude` CLI on PATH (warn but don't block)\n- Maggy installed (check `~/.claude/.bootstrap-dir`)\n\n### 2. Run installer\n\n```bash\nBOOTSTRAP_DIR=$(cat ~/.claude/.bootstrap-dir)\ncd \"$BOOTSTRAP_DIR/maggy\"\n./install.sh\n```\n\nThis installs Python deps and copies the config template to `~/.maggy/config.yaml`.\n\n### 3. Interactive config wizard\n\nAsk the user:\n\n1. **Org name** — human-readable name (e.g. \"Acme Corp\")\n2. **Domain** — primary competitive domain (e.g. \"fintech\", \"devtools\", \"cx\", \"healthcare\"). This drives competitor discovery.\n3. **Issue tracker** — `github` (default) or `asana`. Linear is a stub.\n4. **For GitHub:** org name + comma-separated repo list (`acmecorp/api, acmecorp/web`)\n5. **For Asana:** workspace ID + project GID for their default board\n6. **Codebases** — paths to each repo Maggy should execute in. Prompt key per path (short name like `api`, `web`).\n7. **Competitor categories** — comma-separated (can match domain; encourages 1-3 categories)\n8. **OKRs** — \"skip\" or \"yaml\" (paste OKRs inline if yaml)\n\n### 4. Write config\n\nPatch `~/.maggy/config.yaml` with the user's answers using a Python helper:\n\n```python\nimport yaml\nfrom pathlib import Path\n\ncfg_path = Path.home() / \".maggy\" / \"config.yaml\"\ncfg = yaml.safe_load(cfg_path.read_text())\n\ncfg[\"org\"][\"name\"] = \"<answer>\"\ncfg[\"org\"][\"domain\"] = \"<answer>\"\ncfg[\"issue_tracker\"][\"provider\"] = \"<answer>\"\n# ... set github/asana section accordingly\ncfg[\"codebases\"] = [{\"path\": \"<path>\", \"key\": \"<key>\"}, ...]\ncfg[\"competitors\"][\"categories\"] = [\"<cat>\", ...]\n\ncfg_path.write_text(yaml.safe_dump(cfg, sort_keys=False))\n```\n\n### 5. Credentials check\n\nTell the user to export these in their shell and source them when starting Maggy:\n\n```\nexport GITHUB_TOKEN=ghp_...           # repo + issues scopes\nexport ANTHROPIC_API_KEY=sk-ant-...\n```\n\n**Do not write tokens to `~/.maggy/.env`** — the Maggy server does not load that\nfile automatically, so credentials would sit on disk in plaintext with no code\nreading them. Use your shell's standard secret store (e.g. `.zshrc`, `direnv`,\n`op run`, a secrets manager) or export them inline when launching Maggy.\n\n### 6. Test the connection\n\n```bash\ncd \"$BOOTSTRAP_DIR/maggy\"\npython3 -c \"from src import config, providers; cfg = config.load(); p = providers.build(cfg); import asyncio; print('Found', len(asyncio.run(p.list_tasks(limit=5))), 'tasks')\"\n```\n\nIf this returns tasks, setup is working.\n\n### 7. Offer to launch\n\n> Maggy is configured. Run `/maggy` to launch the dashboard, or:\n>\n> ```\n> cd $BOOTSTRAP_DIR/maggy && python3 -m maggy.main\n> ```\n>\n> Then open http://127.0.0.1:8080\n\n---\n\n## Related\n\n- `/maggy` — launch dashboard\n- `/icpg-bootstrap` — index your codebases so Execute gets rich context\n"
  },
  {
    "path": "commands/maggy.md",
    "content": "# /maggy — Launch Maggy Dashboard\n\nStart Maggy (the AI engineering command center) and open the dashboard in a browser.\n\n---\n\n## Usage\n\n`/maggy` — start server if not running, open dashboard\n`/maggy stop` — stop running server\n`/maggy status` — show whether server is running + config summary\n\n---\n\n## Steps\n\n### 1. Check config\n\n```bash\nif [ ! -f ~/.maggy/config.yaml ]; then\n  echo \"Maggy not configured yet. Run /maggy-init first.\"\n  exit 1\nfi\n```\n\n### 2. Resolve host/port from config (don't hardcode 8080)\n\n```bash\n# Read dashboard.host and dashboard.port from ~/.maggy/config.yaml.\n# Falls back to 127.0.0.1:8080 only if keys are missing.\nHOST=$(python3 -c \"import yaml; d=yaml.safe_load(open('$HOME/.maggy/config.yaml'))or{}; print((d.get('dashboard') or {}).get('host') or '127.0.0.1')\")\nPORT=$(python3 -c \"import yaml; d=yaml.safe_load(open('$HOME/.maggy/config.yaml'))or{}; print((d.get('dashboard') or {}).get('port') or 8080)\")\nURL=\"http://${HOST}:${PORT}\"\n```\n\n### 3. Check if already running\n\n```bash\nif curl -sf \"${URL}/api/health\" >/dev/null 2>&1; then\n  echo \"Maggy is already running at ${URL}\"\n  open \"${URL}\" 2>/dev/null || xdg-open \"${URL}\" 2>/dev/null || true\n  exit 0\nfi\n```\n\n### 4. Start in background\n\nThe Maggy install lives at `<bootstrap-root>/maggy`. Resolve it from `~/.claude/.bootstrap-dir`:\n\n```bash\nBOOTSTRAP_DIR=$(cat ~/.claude/.bootstrap-dir 2>/dev/null || echo \"\")\nMAGGY_DIR=\"$BOOTSTRAP_DIR/maggy\"\n\nif [ ! -d \"$MAGGY_DIR\" ]; then\n  echo \"Maggy not installed. Run: cd <maggy>/maggy && ./install.sh\"\n  exit 1\nfi\n\ncd \"$MAGGY_DIR\"\nmkdir -p \"$HOME/.maggy\"\nnohup python3 -m maggy.main > \"$HOME/.maggy/maggy.log\" 2>&1 &\necho $! > \"$HOME/.maggy/maggy.pid\"\n```\n\n### 5. Wait for health check\n\n```bash\nfor i in {1..15}; do\n  if curl -sf \"${URL}/api/health\" >/dev/null 2>&1; then\n    echo \"✓ Maggy ready at ${URL}\"\n    open \"${URL}\" 2>/dev/null || true\n    exit 0\n  fi\n  sleep 1\ndone\necho \"Maggy didn't come up in 15s. Check ~/.maggy/maggy.log\"\n```\n\n### 5. Report status\n\nShow:\n```\nMaggy is running:\n  Dashboard: http://127.0.0.1:8080\n  Logs: ~/.maggy/maggy.log\n  PID: <pid>\n```\n\n---\n\n## Related\n\n- `/maggy-init` — first-time setup wizard\n- `/icpg-bootstrap` — Maggy's Execute button uses iCPG context from this\n"
  },
  {
    "path": "commands/mnemos-checkpoint.md",
    "content": "# /mnemos-checkpoint — Write Mnemos Checkpoint\n\nWrite a checkpoint capturing current session state for later resume.\n\n## Steps\n\n1. Run `python3 -m mnemos checkpoint --force` to write checkpoint\n2. Report what was captured (goal, constraints, results, fatigue level)\n3. Show the checkpoint file location\n"
  },
  {
    "path": "commands/mnemos-status.md",
    "content": "# /mnemos-status — Show Mnemos Memory Status\n\nShow current Mnemos fatigue level, active node counts, and checkpoint status.\n\n## Steps\n\n1. Run `python3 -m mnemos status` in the project directory\n2. Run `python3 -m mnemos fatigue` for detailed breakdown\n3. Report the fatigue state and any recommended actions\n4. If fatigue >= 0.60, suggest writing a checkpoint with `python3 -m mnemos checkpoint --force`\n"
  },
  {
    "path": "commands/polyphony-init.md",
    "content": "# /polyphony-init — Setup Wizard\n\nInitialize the Polyphony multi-agent orchestration environment.\n\n---\n\n## Steps\n\n### 1. Check Prerequisites\n\n```bash\ncommand -v docker &>/dev/null || command -v orbctl &>/dev/null\n```\n\nIf neither Docker nor OrbStack is available, inform the user:\n\n> Docker or OrbStack is required for Polyphony container isolation. Install one first.\n\n### 2. Create Config Directory\n\n```bash\nmkdir -p ~/.polyphony\n```\n\n### 3. Copy Config Templates\n\nCopy default configuration files from the templates directory:\n\n```bash\nTEMPLATES=\"$(dirname \"$(realpath \"$0\")\")/../templates\"\ncp -n \"$TEMPLATES/polyphony-config.yaml\" ~/.polyphony/config.yaml\ncp -n \"$TEMPLATES/polyphony-identities.yaml\" ~/.polyphony/identities.yaml\ncp -n \"$TEMPLATES/polyphony-agents.yaml\" ~/.polyphony/agents.yaml\ncp -n \"$TEMPLATES/polyphony-routing.yaml\" ~/.polyphony/routing.yaml\n```\n\n### 4. Build Worker Image\n\n```bash\ndocker build -t polyphony-worker:latest -f templates/Dockerfile.polyphony .\n```\n\n### 5. Detect Available Agents\n\n```bash\ncommand -v claude &>/dev/null && echo \"claude: available\"\ncommand -v codex &>/dev/null && echo \"codex: available\"\ncommand -v kimi &>/dev/null && echo \"kimi: available\"\n```\n\n### 6. Confirm\n\nPrint summary of what was initialized and which agents are available.\n"
  },
  {
    "path": "commands/polyphony-spawn.md",
    "content": "# /polyphony-spawn — Spawn Task\n\nCreate a new task in the Polyphony orchestrator and route it to an agent.\n\n---\n\n## Usage\n\n```\n/polyphony-spawn <title> [--type <task_type>] [--risk <risk>] [--source <source>]\n```\n\n## Steps\n\n### 1. Parse Arguments\n\n- `title`: Required task description\n- `--type`: Task type (feature, bugfix, docs, refactor, etc.). Default: feature\n- `--risk`: Risk level (low, medium, high). Default: low\n- `--source`: Work source (local, github). Default: local\n\n### 2. Create Task\n\n```bash\nPYTHONPATH=scripts python3 -m polyphony spawn \"$TITLE\" --type \"$TYPE\"\n```\n\n### 3. Route Task\n\nThe orchestrator will automatically:\n1. Score task complexity (5-dimension scoring)\n2. Match against routing rules\n3. Select agent and fallback chain\n4. Provision container with workspace\n5. Start agent execution\n\n### 4. Report\n\nPrint task ID and routing decision.\n"
  },
  {
    "path": "commands/polyphony-status.md",
    "content": "# /polyphony-status — Show State\n\nDisplay the current state of all Polyphony tasks and running containers.\n\n---\n\n## Steps\n\n### 1. Show Task States\n\n```bash\nPYTHONPATH=scripts python3 -m polyphony status\n```\n\n### 2. Show Running Containers\n\n```bash\ndocker ps --filter \"name=polyphony-\" --format \"table {{.Names}}\\t{{.Status}}\\t{{.RunningFor}}\"\n```\n\n### 3. Show Workspace Usage\n\n```bash\ndu -sh ~/polyphony/workspaces/* 2>/dev/null || echo \"No workspaces\"\n```\n"
  },
  {
    "path": "commands/spawn-team.md",
    "content": "# /spawn-team - Spawn Agent Team\n\nSpawn the default agent team for this project. Creates a coordinated team of agents that implement features in parallel following the strict TDD pipeline.\n\n**Pipeline:** Specs > Tests > Ensure tests fail > Implement > Test again > Code Review > Security > Create branch > Create PR\n\n---\n\n## Phase 1: Prerequisites Check\n\n### 1.1 Detect Container Mode\n\nCheck if Polyphony container isolation is available. **Container mode is the default when both Docker and polyphony CLI are present.**\n\n```bash\nBOOTSTRAP_DIR=$(cat ~/.claude/.bootstrap-dir 2>/dev/null)\nDETECTED_AGENTS=$(\"$BOOTSTRAP_DIR/scripts/detect-agents.sh\" 2>/dev/null || echo \"claude\")\n\nCONTAINER_MODE=\"false\"\nif echo \"$DETECTED_AGENTS\" | grep -qE \"docker|orbstack\"; then\n    if command -v polyphony &>/dev/null; then\n        CONTAINER_MODE=\"true\"\n        echo \"✓ Container mode: ON (Docker + polyphony detected)\"\n        echo \"  Each feature agent will run in its own isolated container\"\n    else\n        echo \"⚠ Docker found but polyphony CLI missing\"\n        echo \"  Run: cd \\$(cat ~/.claude/.bootstrap-dir) && ./install.sh\"\n        echo \"  Falling back to native agents (shared workspace)\"\n    fi\nelse\n    echo \"ℹ Docker not found — using native agents (shared workspace)\"\n    echo \"  Install Docker for container isolation: brew install --cask docker\"\nfi\n```\n\n### 1.2 Check Agent Definitions\n\nVerify `.claude/agents/` exists and has the required agent definitions:\n\n```bash\nls .claude/agents/\n```\n\nRequired files (with proper frontmatter: name, description, model, tools, disallowedTools, maxTurns):\n- `team-lead.md`\n- `quality.md`\n- `security.md`\n- `code-review.md`\n- `merger.md`\n- `feature.md`\n\nIf missing, copy from the agent-teams skill:\n```bash\ncp -r ~/.claude/skills/agent-teams/agents/ .claude/agents/\n```\n\n### 1.3 Check Feature Specs\n\n```bash\nls _project_specs/features/\n```\n\nIf no feature specs exist, ask the user:\n\n> **No feature specs found.** The agent team needs features to implement.\n>\n> What are the key features of this project? I'll create a spec file for each one.\n\nFor each feature the user lists, create `_project_specs/features/{feature-name}.md` with a skeleton spec.\n\n### 1.4 Check GitHub CLI\n\n```bash\ngh auth status\n```\n\nNeeded by the merger agent for PR creation. Warn if not authenticated but don't block.\n\n### 1.5 Ensure Worker Image (container mode only)\n\n```bash\nif [ \"$CONTAINER_MODE\" = \"true\" ]; then\n    if ! docker image inspect polyphony-worker:latest &>/dev/null 2>&1; then\n        echo \"Building polyphony-worker image...\"\n        docker build -t polyphony-worker:latest \\\n            -f \"$BOOTSTRAP_DIR/templates/Dockerfile.polyphony\" \"$BOOTSTRAP_DIR\"\n        echo \"✓ Built polyphony-worker:latest\"\n    else\n        echo \"✓ polyphony-worker:latest image ready\"\n    fi\nfi\n```\n\n---\n\n## Phase 2: Spawn Default Agents\n\nSpawn the 5 permanent agents **natively** (these are coordination agents — they read/verify, not write code). Each agent reads `.claude/agents/{type}.md` for its full definition including frontmatter (tools, model, maxTurns, etc.).\n\n> **Note:** Permanent agents always run natively regardless of container mode. Only feature agents get containers.\n\n### 2.1 Team Lead\n```\nAgent tool:\n  name: \"team-lead\"\n  subagent_type: \"team-lead\"\n  prompt: \"You are the team lead. Read .claude/agents/team-lead.md for your full instructions. Start by reading _project_specs/features/*.md to identify features, then create task chains and spawn feature agents.\"\n```\n\n### 2.2 Quality Agent\n```\nAgent tool:\n  name: \"quality-agent\"\n  subagent_type: \"quality-agent\"\n  prompt: \"You are the quality agent. Read .claude/agents/quality.md for your instructions. Watch TaskList for tasks assigned to you. Process them in task ID order.\"\n```\n\n### 2.3 Security Agent\n```\nAgent tool:\n  name: \"security-agent\"\n  subagent_type: \"security-agent\"\n  prompt: \"You are the security agent. Read .claude/agents/security.md for your instructions. Watch TaskList for security-scan tasks assigned to you.\"\n```\n\n### 2.4 Code Review Agent\n```\nAgent tool:\n  name: \"review-agent\"\n  subagent_type: \"review-agent\"\n  prompt: \"You are the code review agent. Read .claude/agents/code-review.md for your instructions. Watch TaskList for code-review tasks assigned to you.\"\n```\n\n### 2.5 Merger Agent\n```\nAgent tool:\n  name: \"merger-agent\"\n  subagent_type: \"merger-agent\"\n  prompt: \"You are the merger agent. Read .claude/agents/merger.md for your instructions. Watch TaskList for branch-pr tasks assigned to you.\"\n```\n\n---\n\n## Phase 3: Spawn Feature Agents\n\n### Container Mode (default when Docker + polyphony available)\n\nFor each feature spec in `_project_specs/features/`:\n\n```bash\n# Polyphony creates a container with its own git clone + branch,\n# then starts the agent CLI inside\npolyphony spawn \"{feature-name}: implement feature per _project_specs/features/{feature-name}.md\" \\\n    --type feature --risk low\n```\n\nThis does everything in one command:\n1. Creates a task in Polyphony's store\n2. Routes it to an agent via the routing policy\n3. Provisions a Docker container with a full git clone\n4. Creates a feature branch (`feature/{feature-name}`)\n5. Starts the agent CLI inside the container\n\nCheck running containers:\n```bash\npolyphony status\n```\n\n### Fallback Mode (no Docker)\n\nIf container mode is not available, spawn feature agents natively (shared workspace):\n\n```\nAgent tool:\n  name: \"feature-{feature-name}\"\n  subagent_type: \"feature-agent\"\n  prompt: \"You are the feature agent for {feature-name}. Read .claude/agents/feature.md for your instructions. Your feature spec is at _project_specs/features/{feature-name}.md. Start by checking TaskList for your first task.\"\n```\n\n> **Advisory:** Running without container isolation (Docker not found). Agents share the workspace — coordinate carefully to avoid file conflicts.\n\n---\n\n## Phase 4: Team Status Summary\n\nShow the user:\n\n### Container Mode:\n```\nAGENT TEAM DEPLOYED (Container Isolation ON)\n─────────────────────────────────────────────\n\nTeam: {project-name}\nFeatures: {N}\nIsolation: Polyphony containers (each feature has its own branch)\n\nNATIVE AGENTS (coordination)\n─────────────────────────────\n  Team Lead        Orchestrating\n  Quality Agent    Watching for verification tasks\n  Security Agent   Watching for security scan tasks\n  Code Review      Watching for review tasks\n  Merger Agent     Watching for branch/PR tasks\n\nCONTAINER AGENTS (isolated)\n────────────────────────────\n  feature-{name1}  Container running — branch: feature/{name1}\n  feature-{name2}  Container running — branch: feature/{name2}\n\nPIPELINE (per feature)\n──────────────────────\nSpec > Review > Tests > RED Verify > Implement >\nGREEN Verify > Validate > Code Review > Security > Branch+PR\n\nMonitor: polyphony status\nCleanup: polyphony cleanup (after all PRs created)\n```\n\n### Fallback Mode:\n```\nAGENT TEAM DEPLOYED (Shared Workspace)\n───────────────────────────────────────\n\n⚠ Docker not available — agents share the workspace\n\nTeam: {project-name}\nFeatures: {N}\nTotal tasks: {N * 10}\n\nAGENTS\n──────\n  Team Lead        Orchestrating\n  Quality Agent    Watching for verification tasks\n  Security Agent   Watching for security scan tasks\n  Code Review      Watching for review tasks\n  Merger Agent     Watching for branch/PR tasks\n  feature-{name1}  Starting spec for {name1}\n  feature-{name2}  Starting spec for {name2}\n\nPIPELINE\n────────\nSpec > Review > Tests > RED Verify > Implement >\nGREEN Verify > Validate > Code Review > Security > Branch+PR\n\nThe team runs autonomously until all PRs are created.\n```\n\n---\n\n## Monitoring\n\nAfter the team is spawned, the user can:\n- **Check progress:** Ask team lead for status, or run `polyphony status` (container mode)\n- **Message agents:** Use SendMessage to contact any agent\n- **View container logs:** `docker logs polyphony-{feature-name}` (container mode)\n- **Handle blockers:** Message the blocked agent or team lead\n\nThe team runs autonomously until all PRs are created, then the team lead shuts everything down.\n\n### Cleanup (container mode)\n\nAfter all PRs are created:\n```bash\npolyphony cleanup\n```\nThis removes completed containers and workspaces. Branches and PRs are preserved on the remote.\n"
  },
  {
    "path": "commands/sync-agents.md",
    "content": "# Sync Agents\n\nSync project configuration between Claude Code, Kimi CLI, and Codex CLI.\n\nRun this after `/initialize-project` or anytime you want to ensure all installed AI CLI tools have matching skills, project instructions, and hooks.\n\n---\n\n## Phase 1: Detect Installed Tools\n\n```bash\nBOOTSTRAP_DIR=$(cat ~/.claude/.bootstrap-dir 2>/dev/null)\nif [ -z \"$BOOTSTRAP_DIR\" ]; then\n    echo \"Error: Maggy not installed. Run install.sh first.\"\n    exit 1\nfi\nDETECTED=$(\"$BOOTSTRAP_DIR/scripts/detect-agents.sh\" 2>/dev/null || echo \"claude\")\necho \"Detected AI CLI tools: $DETECTED\"\n```\n\n---\n\n## Phase 2: Show Current State\n\nCheck what exists for each tool and present a status table:\n\n```bash\necho \"=== Current State ===\"\n\n# Claude\necho \"Claude Code:\"\n[ -d \".claude/skills\" ] && echo \"  Skills:       .claude/skills/ ($(ls -d .claude/skills/*/ 2>/dev/null | wc -l | tr -d ' ') skills)\" || echo \"  Skills:       NOT SET UP\"\n[ -f \"CLAUDE.md\" ] && echo \"  Instructions: CLAUDE.md\" || echo \"  Instructions: NOT SET UP\"\n[ -f \".claude/settings.json\" ] && echo \"  Hooks:        .claude/settings.json\" || echo \"  Hooks:        NOT SET UP\"\n\n# Kimi\necho \"Kimi CLI:\"\n[ -d \".kimi/skills\" ] && echo \"  Skills:       .kimi/skills/ ($(ls -d .kimi/skills/*/ 2>/dev/null | wc -l | tr -d ' ') skills)\" || echo \"  Skills:       NOT SET UP\"\necho \"  Instructions: (Kimi uses skills directly, no project file needed)\"\n[ -f \".kimi/config.toml\" ] && echo \"  Hooks:        .kimi/config.toml\" || echo \"  Hooks:        NOT SET UP\"\n\n# Codex\necho \"Codex CLI:\"\n[ -d \".codex/skills\" ] && echo \"  Skills:       .codex/skills/ ($(ls -d .codex/skills/*/ 2>/dev/null | wc -l | tr -d ' ') skills)\" || echo \"  Skills:       NOT SET UP\"\n[ -f \"AGENTS.md\" ] && echo \"  Instructions: AGENTS.md\" || echo \"  Instructions: NOT SET UP\"\n[ -f \".codex/config.toml\" ] && echo \"  Hooks:        .codex/config.toml\" || echo \"  Hooks:        NOT SET UP\"\n```\n\nPresent the status table to the user, then ask what they want to do.\n\n---\n\n## Phase 3: Offer Sync Actions\n\nAsk the user which actions to perform:\n\n> **Current state shown above.** What would you like to sync?\n>\n> 1. **Sync all** - Copy skills + generate instructions + hooks for all detected tools\n> 2. **Skills only** - Copy .claude/skills/ to .kimi/skills/ and .codex/skills/\n> 3. **Generate AGENTS.md** - Create Codex project instructions from CLAUDE.md\n> 4. **Generate config.toml** - Create Kimi/Codex hooks from settings.json\n> 5. **Show diff** - Show what differs between tool configs\n\n---\n\n## Phase 4: Execute Sync\n\n### Option 1: Sync All (or individual options below)\n\n### Skills Sync\n```bash\n# Source of truth is .claude/skills/\nif [ -d \".claude/skills\" ]; then\n    # Sync to Kimi\n    if echo \"$DETECTED\" | grep -q \"kimi\"; then\n        rm -rf .kimi/skills\n        mkdir -p .kimi/skills\n        cp -r .claude/skills/*/ .kimi/skills/ 2>/dev/null || true\n        echo \"Synced skills to .kimi/skills/\"\n    fi\n\n    # Sync to Codex\n    if echo \"$DETECTED\" | grep -q \"codex\"; then\n        rm -rf .codex/skills\n        mkdir -p .codex/skills\n        cp -r .claude/skills/*/ .codex/skills/ 2>/dev/null || true\n        echo \"Synced skills to .codex/skills/\"\n    fi\n\n    # Sync to generic .agents/ (works for any tool)\n    rm -rf .agents/skills\n    mkdir -p .agents/skills\n    cp -r .claude/skills/*/ .agents/skills/ 2>/dev/null || true\n    echo \"Synced skills to .agents/skills/ (generic)\"\nelse\n    echo \"No .claude/skills/ found. Run /initialize-project first.\"\nfi\n```\n\n### Generate AGENTS.md (from CLAUDE.md)\nIf CLAUDE.md exists, generate AGENTS.md by:\n1. Reading CLAUDE.md content\n2. Replacing `.claude/skills/` paths with `.agents/skills/` paths\n3. Writing as AGENTS.md\n\n**Important:** AGENTS.md should reference `.agents/skills/` (generic path) since Codex reads from `.codex/skills/` and `.agents/skills/`. The `.agents/skills/` path is the cross-compatible choice.\n\nIf CLAUDE.md does not exist, copy from the bootstrap template:\n```bash\ncp \"$BOOTSTRAP_DIR/templates/AGENTS.md\" ./AGENTS.md\necho \"Created AGENTS.md from template (customize for your project)\"\n```\n\n### Generate config.toml\n```bash\n# For Kimi\nif echo \"$DETECTED\" | grep -q \"kimi\"; then\n    mkdir -p .kimi\n    cp \"$BOOTSTRAP_DIR/templates/config.toml\" .kimi/config.toml\n    echo \"Created .kimi/config.toml with hooks\"\nfi\n\n# For Codex\nif echo \"$DETECTED\" | grep -q \"codex\"; then\n    mkdir -p .codex\n    cp \"$BOOTSTRAP_DIR/templates/config.toml\" .codex/config.toml\n    echo \"Created .codex/config.toml with hooks\"\nfi\n```\n\n---\n\n## Phase 5: Summary\n\n```\nSync complete!\n\nSkills synced:\n  .claude/skills/ -> .kimi/skills/  (N skills)\n  .claude/skills/ -> .codex/skills/ (N skills)\n  .claude/skills/ -> .agents/skills/ (N skills, generic)\n\nProject instructions:\n  CLAUDE.md   (Claude Code)\n  AGENTS.md   (Codex CLI)\n\nHooks config:\n  .claude/settings.json (Claude Code)\n  .kimi/config.toml     (Kimi CLI)\n  .codex/config.toml    (Codex CLI)\n\nYou can now run any of these in this project:\n  claude    # Claude Code\n  kimi      # Kimi CLI\n  codex     # Codex CLI\n```\n\n---\n\n## Phase 6: Update .gitignore\n\nEnsure cross-tool directories are properly handled in .gitignore:\n\n```bash\n# Add to .gitignore if not present\nfor entry in \".kimi/\" \".codex/\" \".agents/\"; do\n    if ! grep -qF \"$entry\" .gitignore 2>/dev/null; then\n        echo \"$entry\" >> .gitignore\n    fi\ndone\n```\n\n**Note:** Unlike `.claude/` which is typically committed, `.kimi/` and `.codex/` project dirs should generally be gitignored since they're derived from `.claude/skills/`. The `/sync-agents` command regenerates them.\n\nAGENTS.md **should** be committed (it's the Codex equivalent of CLAUDE.md).\n"
  },
  {
    "path": "commands/sync-contracts.md",
    "content": "# /sync-contracts\n\n> Lightweight incremental update of workspace contracts without full re-analysis.\n\n## Purpose\n\nFast contract synchronization that:\n- Checks only contract source files (not full workspace)\n- Updates CONTRACTS.md with changes\n- Validates consistency\n- Takes ~15 seconds instead of ~2 minutes\n\n## When to Use\n\n| Scenario | Command |\n|----------|---------|\n| After modifying API endpoints | `/sync-contracts` |\n| After changing shared types | `/sync-contracts` |\n| Session start shows stale contracts | `/sync-contracts` |\n| Post-commit hook (automatic) | `/sync-contracts --lightweight` |\n| Before pushing changes | `/sync-contracts --validate` |\n| See what changed without updating | `/sync-contracts --diff` |\n\n## Behavior\n\n### Step 1: Load Existing Topology\n\n```\n🔄 Loading workspace context...\n\nWorkspace: myapp (Monorepo)\nLast full analysis: 2026-01-18T10:00:00Z\nLast sync: 2026-01-20T14:32:00Z\n```\n\nDoes NOT re-discover workspace structure - uses existing TOPOLOGY.md.\n\n### Step 2: Check Contract Sources\n\n```\n📋 Checking contract sources...\n\nMonitored files (from .contract-sources):\n  ✓ apps/api/openapi.json (modified 2h ago)\n  ✓ packages/shared-types/src/index.ts (modified 2h ago)\n  ○ packages/db/schema/campaigns.ts (unchanged)\n  ○ packages/db/schema/users.ts (unchanged)\n  ○ apps/api/app/schemas/campaign.py (unchanged)\n\nChanges detected: 2 files\n```\n\n### Step 3: Extract Changes\n\n```\n📝 Extracting contract changes...\n\napps/api/openapi.json:\n  + POST /api/campaigns/bulk (new endpoint)\n  ~ GET /api/campaigns (added 'status' query param)\n\npackages/shared-types/src/index.ts:\n  ~ Campaign interface (added 'tags: string[]' field)\n  + CampaignBulkCreate interface (new)\n```\n\n### Step 4: Update Artifacts\n\n```\n✏️  Updating workspace artifacts...\n\nUpdated: _project_specs/workspace/CONTRACTS.md\n  - Added POST /api/campaigns/bulk to endpoints\n  - Updated Campaign type definition\n  - Added CampaignBulkCreate type\n\nUpdated: _project_specs/workspace/CROSS_REPO_INDEX.md\n  - Added bulk create capability\n\nTimestamps updated:\n  Last sync: 2026-01-20T16:45:00Z\n```\n\n### Step 5: Validate Consistency\n\n```\n✅ Validating contract consistency...\n\nChecks:\n  ✓ OpenAPI endpoint count matches routes (48/48)\n  ✓ All Pydantic models have TypeScript equivalents\n  ✓ No orphaned types in shared-types\n  ⚠️  Frontend types may need regeneration\n\nValidation: PASSED (1 warning)\n```\n\n## Final Output\n\n```\n════════════════════════════════════════════════════════════════\n  CONTRACT SYNC COMPLETE\n════════════════════════════════════════════════════════════════\n\nSources checked: 5\nChanges detected: 2\nFiles updated: 2\n\nChanges Summary:\n  + POST /api/campaigns/bulk (new endpoint)\n  ~ Campaign interface (added 'tags' field)\n  + CampaignBulkCreate interface (new)\n\nFreshness: 🟢 Fresh\nLast sync: 2026-01-20T16:45:00Z\n\n⚠️  Note: Frontend types may need regeneration\n   Run: cd apps/web && npm run generate:types\n\n════════════════════════════════════════════════════════════════\n```\n\n## Flags\n\n| Flag | Description |\n|------|-------------|\n| `--lightweight` | Skip validation, minimal output (for hooks) |\n| `--diff` | Show changes without updating files |\n| `--validate` | Only validate, don't update |\n| `--force` | Update even if no changes detected |\n| `--verbose` | Show detailed extraction output |\n\n## Diff Mode\n\nPreview changes without applying:\n\n```bash\n/sync-contracts --diff\n```\n\nOutput:\n\n```\n📋 Contract Changes (not applied)\n\napps/api/openapi.json:\n  + POST /api/campaigns/bulk\n    Request: CampaignBulkCreate[]\n    Response: Campaign[]\n\n  ~ GET /api/campaigns\n    + query param: status (string, optional)\n\npackages/shared-types/src/index.ts:\n  ~ interface Campaign {\n      id: string;\n      name: string;\n  +   tags: string[];        // NEW\n      status: CampaignStatus;\n    }\n\n  + interface CampaignBulkCreate {\n      campaigns: CampaignCreate[];\n    }\n\nTo apply these changes: /sync-contracts\n```\n\n## Validate Mode\n\nCheck consistency without updating:\n\n```bash\n/sync-contracts --validate\n```\n\nOutput:\n\n```\n🔍 Contract Validation\n\nEndpoint Consistency:\n  ✓ OpenAPI spec: 48 endpoints\n  ✓ Route files: 48 handlers\n  ✓ Match: YES\n\nType Consistency:\n  ✓ Pydantic models: 23\n  ✓ TypeScript types: 34\n  ✓ Shared types exported: 34\n  ⚠️  2 types only in backend (internal)\n\nCross-Module References:\n  ✓ Frontend imports valid types: YES\n  ✓ Backend codegen up to date: YES\n\nOverall: ✅ VALID (2 warnings)\n```\n\n## Lightweight Mode\n\nFor hooks - minimal output, fast execution:\n\n```bash\n/sync-contracts --lightweight\n```\n\nOutput:\n\n```\n✓ Contracts synced (2 changes)\n```\n\nOr if no changes:\n\n```\n✓ Contracts up to date\n```\n\n## Contract Sources File\n\nThe sync uses `.contract-sources` to know what to check:\n\n```bash\n# _project_specs/workspace/.contract-sources\n# Auto-generated by /analyze-workspace\n# Edit to add/remove monitored files\n\n# OpenAPI specs\napps/api/openapi.json\n\n# Type definitions\npackages/shared-types/src/index.ts\npackages/shared-types/src/api.ts\npackages/shared-types/src/campaign.ts\n\n# Pydantic schemas (Python)\napps/api/app/schemas/campaign.py\napps/api/app/schemas/user.py\napps/api/app/schemas/auth.py\n\n# Database schema\npackages/db/schema/campaigns.ts\npackages/db/schema/users.ts\n```\n\nTo add a new source:\n\n```bash\necho \"apps/api/app/schemas/new_model.py\" >> _project_specs/workspace/.contract-sources\n```\n\n## Error Handling\n\n### No Contract Sources\n\n```\n⚠️  No contract sources configured\n\nRun /analyze-workspace first to set up contract monitoring.\n```\n\n### Source File Missing\n\n```\n⚠️  Contract source not found: apps/api/openapi.json\n\nOptions:\n  1. Generate it: cd apps/api && python -m app.generate_openapi\n  2. Remove from monitoring: Edit .contract-sources\n  3. Skip this file: /sync-contracts --skip apps/api/openapi.json\n```\n\n### Validation Failed\n\n```\n❌ Contract validation failed\n\nIssues found:\n  1. OpenAPI has 48 endpoints, routes have 47\n     Missing: DELETE /api/campaigns/:id (in spec, not in routes)\n\n  2. Type mismatch: Campaign.status\n     OpenAPI: \"draft\" | \"active\" | \"paused\"\n     TypeScript: \"draft\" | \"active\" | \"paused\" | \"archived\"\n\nFix these issues, then run /sync-contracts again.\nOr force update: /sync-contracts --force\n```\n\n## Integration with Hooks\n\n### Post-Commit Hook\n\nAutomatically runs after commits that touch contract sources:\n\n```bash\n# hooks/post-commit\nCONTRACT_SOURCES=$(cat _project_specs/workspace/.contract-sources 2>/dev/null)\nCOMMITTED=$(git diff-tree --no-commit-id --name-only -r HEAD)\n\nfor source in $CONTRACT_SOURCES; do\n  if echo \"$COMMITTED\" | grep -q \"$source\"; then\n    echo \"📝 Contract source changed, syncing...\"\n    claude --silent \"/sync-contracts --lightweight\"\n    break\n  fi\ndone\n```\n\n### Pre-Push Hook\n\nValidates before push:\n\n```bash\n# hooks/pre-push\necho \"🔍 Validating contracts...\"\nclaude --silent \"/sync-contracts --validate\"\n\nif [ $? -ne 0 ]; then\n  echo \"❌ Contract validation failed\"\n  echo \"Run /sync-contracts to fix\"\n  exit 1\nfi\n```\n\n## Comparison: sync-contracts vs analyze-workspace\n\n| Aspect | /sync-contracts | /analyze-workspace |\n|--------|-----------------|-------------------|\n| Time | ~15 seconds | ~2 minutes |\n| Scope | Contract files only | Full workspace |\n| Discovers new modules | No | Yes |\n| Updates TOPOLOGY.md | No | Yes |\n| Updates CONTRACTS.md | Yes | Yes |\n| Rebuilds dependency graph | No | Yes |\n| When to use | Frequent (daily) | Occasional (weekly) |\n"
  },
  {
    "path": "commands/update-code-index.md",
    "content": "# Update Code Index\n\nRegenerates `CODE_INDEX.md` by scanning the codebase for all functions, classes, hooks, and components. Organizes by capability to prevent semantic duplication.\n\n---\n\n## What This Command Does\n\n1. **Scans source files** - Finds all exported functions, classes, hooks, components\n2. **Extracts docstrings** - Gets descriptions from JSDoc/docstrings\n3. **Categorizes by capability** - Groups by what things DO, not where they live\n4. **Generates CODE_INDEX.md** - Creates/updates the semantic index\n\n---\n\n## Phase 1: Detect Project Type\n\n```bash\n# Check language\nls package.json pyproject.toml 2>/dev/null\n\n# Check source directories\nls -d src/ lib/ app/ 2>/dev/null\n```\n\n---\n\n## Phase 2: Scan Codebase\n\n### For TypeScript/JavaScript\n\nScan for exports:\n\n```bash\n# Find all exported functions\ngrep -rn \"export function\\|export const\\|export class\\|export default\" src/ --include=\"*.ts\" --include=\"*.tsx\" --include=\"*.js\" --include=\"*.jsx\"\n\n# Find React hooks\ngrep -rn \"export function use[A-Z]\\|export const use[A-Z]\" src/ --include=\"*.ts\" --include=\"*.tsx\"\n\n# Find React components (PascalCase exports)\ngrep -rn \"export function [A-Z]\\|export const [A-Z].*=.*=>\" src/ --include=\"*.tsx\" --include=\"*.jsx\"\n```\n\n### For Python\n\n```bash\n# Find all function definitions\ngrep -rn \"^def \\|^async def \\|^class \" src/ --include=\"*.py\"\n\n# Check __all__ exports\ngrep -rn \"__all__\" src/ --include=\"*.py\"\n```\n\n---\n\n## Phase 3: Extract Documentation\n\nFor each found export, extract:\n\n1. **Name** - Function/class name\n2. **Location** - File path and line number\n3. **Description** - From JSDoc `@description` or first line of docstring\n4. **Parameters** - Function signature\n5. **Returns** - Return type if available\n\n### TypeScript Example\n\n```typescript\n/**\n * Formats a date into a human-readable relative string.\n * @param date - The date to format\n * @returns Relative time string like \"2 days ago\"\n */\nexport function formatRelative(date: Date): string {\n```\n\nExtract:\n- Name: `formatRelative`\n- Description: \"Formats a date into a human-readable relative string\"\n- Params: `(date: Date)`\n- Returns: `string`\n\n### Python Example\n\n```python\ndef format_relative(date: datetime) -> str:\n    \"\"\"Formats a date into a human-readable relative string.\n\n    Args:\n        date: The date to format\n\n    Returns:\n        Relative time string like \"2 days ago\"\n    \"\"\"\n```\n\nExtract:\n- Name: `format_relative`\n- Description: \"Formats a date into a human-readable relative string\"\n- Params: `(date: datetime)`\n- Returns: `str`\n\n---\n\n## Phase 4: Categorize by Capability\n\nGroup functions by what they DO:\n\n| Category | Keywords to Match |\n|----------|-------------------|\n| **Date/Time** | date, time, format, parse, duration, relative, timestamp |\n| **Validation** | validate, is*, check, verify, sanitize |\n| **String Operations** | string, text, format, parse, slug, truncate, capitalize |\n| **API Clients** | fetch, get, post, put, delete, api, request |\n| **Authentication** | auth, login, logout, session, token, user |\n| **Error Handling** | error, exception, handle, catch, throw |\n| **Database** | db, query, find, create, update, delete, repository |\n| **Hooks (React)** | use* |\n| **Components (React)** | PascalCase in .tsx/.jsx |\n| **Utilities** | util, helper, common (catch-all) |\n\n---\n\n## Phase 5: Generate CODE_INDEX.md\n\nCreate or overwrite `CODE_INDEX.md`:\n\n```markdown\n# Code Index\n\n*Auto-generated by /update-code-index*\n*Last updated: [TIMESTAMP]*\n\n> ⚠️ **Before writing new code, search this index first!**\n> Find similar functionality? Use or extend it instead of creating new.\n\n## Quick Stats\n\n| Category | Count | Main Location |\n|----------|-------|---------------|\n| Date/Time | X | src/utils/dates.ts |\n| Validation | X | src/utils/validate.ts |\n| API Clients | X | src/api/*.ts |\n| Hooks | X | src/hooks/*.ts |\n| Components | X | src/components/*.tsx |\n\n---\n\n## Date/Time Operations\n\n| Function | Location | Description | Signature |\n|----------|----------|-------------|-----------|\n| `formatDate()` | utils/dates.ts:15 | Formats Date to locale string | `(date: Date, opts?)` |\n| `formatRelative()` | utils/dates.ts:32 | Formats as \"2 days ago\" | `(date: Date)` |\n| ... | ... | ... | ... |\n\n---\n\n## Validation\n\n| Function | Location | Description | Signature |\n|----------|----------|-------------|-----------|\n| `isEmail()` | utils/validate.ts:10 | Validates email format | `(email: string)` |\n| ... | ... | ... | ... |\n\n---\n\n[Continue for each category...]\n```\n\n---\n\n## Phase 6: Report Changes\n\nAfter generating, report:\n\n```\n📊 Code Index Updated\n\nScanned:\n• 45 TypeScript files\n• 12 React components\n• 8 custom hooks\n• 156 exported functions\n\nCategories:\n• Date/Time: 5 functions\n• Validation: 8 functions\n• API Clients: 23 functions\n• Hooks: 8 hooks\n• Components: 12 components\n• Utilities: 42 functions\n\nNew since last run:\n• + fetchOrders() in api/orders.ts\n• + useCart() in hooks/useCart.ts\n• + OrderCard component in components/OrderCard.tsx\n\nPossible duplicates detected:\n• ⚠️ formatDate() and displayDate() - similar purpose?\n• ⚠️ isValid() and validate() - review these\n\nUpdated: CODE_INDEX.md\n```\n\n---\n\n## Handling Missing Documentation\n\nIf a function lacks documentation:\n\n```markdown\n| `myFunction()` | utils/helpers.ts:42 | ⚠️ *No description - add JSDoc* | `(a, b, c)` |\n```\n\nReport at end:\n\n```\n⚠️ 12 functions missing documentation:\n• myFunction() in utils/helpers.ts:42\n• anotherFunc() in services/user.ts:88\n• ...\n\nRun with --add-docs to prompt for descriptions.\n```\n\n---\n\n## Options\n\n```bash\n# Basic update\n/update-code-index\n\n# Include private/non-exported functions\n/update-code-index --include-private\n\n# Prompt to add missing docs\n/update-code-index --add-docs\n\n# Only scan specific directory\n/update-code-index src/utils\n\n# Output as JSON (for vector DB ingestion)\n/update-code-index --json > code_index.json\n\n# Detect duplicates only (no index update)\n/update-code-index --audit-only\n```\n\n---\n\n## Audit Mode\n\nWhen run with `--audit-only` or as `/audit-duplicates`:\n\n```markdown\n## Duplicate Audit Report - [DATE]\n\n### 🔴 High Confidence Duplicates\n\n1. **formatDate / displayDate / showDate**\n   - `formatDate()` at utils/dates.ts:15\n   - `displayDate()` at components/Header.tsx:42\n   - `showDate()` at pages/Profile.tsx:28\n   - Similarity: 89% (same logic, different names)\n   - **Recommendation:** Consolidate into utils/dates.ts\n\n2. **isEmail / validateEmail / checkEmail**\n   - `isEmail()` at utils/validate.ts:10\n   - `validateEmail()` at forms/signup.ts:55\n   - `checkEmail()` at api/users.ts:30\n   - Similarity: 95% (identical regex)\n   - **Recommendation:** Use isEmail() everywhere\n\n### 🟡 Possible Duplicates (Review)\n\n1. **fetchUser / getUser / loadUser**\n   - Different implementations but same purpose\n   - May be intentional (different contexts)\n   - **Action:** Document if intentional, merge if not\n\n### 🟢 Similar But Distinct\n\n1. **Button / IconButton / LinkButton**\n   - Related components with different purposes\n   - **Status:** OK - documented variants\n```\n\n---\n\n## Integration with Vector DB\n\nIf vector DB is set up, also update embeddings:\n\n```bash\n/update-code-index --vector\n```\n\nThis:\n1. Generates CODE_INDEX.md (as usual)\n2. Creates embeddings for each function description\n3. Stores in `.chroma/` or `.lancedb/`\n4. Enables semantic search: \"find functions that validate user input\"\n\n---\n\n## Suggested Workflow\n\n### Daily\n- Index auto-updates on significant code changes\n- Claude checks index before writing new code\n\n### Weekly\n- Run `/update-code-index --audit-only`\n- Review duplicate report\n- Merge or document similar functions\n\n### After Major Features\n- Full index regeneration\n- Vector DB re-embedding (if used)\n\n---\n\n## File Output\n\nCreates/updates:\n- `CODE_INDEX.md` - Human-readable index\n- `.code-index.json` (optional) - Machine-readable for tooling\n\n---\n\n## Claude Instructions\n\nWhen user runs `/update-code-index`:\n\n1. Detect project type (TS/JS/Python)\n2. Scan source directories\n3. Extract all exports with documentation\n4. Categorize by capability\n5. Generate CODE_INDEX.md\n6. Report stats and potential duplicates\n7. Commit the updated index\n\nAfter running, remind user:\n> \"Index updated! I'll check this before writing any new code to avoid duplicating existing functionality.\"\n"
  },
  {
    "path": "docs/architecture-v5.md",
    "content": "# Maggy v5 Architecture — Multi-Project, Multi-Model Command Center\n\n## 1. Executive Summary\n\nv5 transforms Maggy from a single-project, single-model toolkit into a **multi-project, multi-model orchestration platform**. Pi replaces per-CLI adapters as the universal agent harness. Maggy becomes the central web dashboard. Token budgets are managed dynamically across providers. New features are validated against the competitive intelligence graph before engineering begins.\n\n---\n\n## 2. What Changed: Before and After\n\n### v3.x (Single-Model, Single-Project)\n\n```\nUser → Claude Code → single project → single model\n         │\n         ├── CLAUDE.md (project config)\n         ├── skills/ (TDD, security, etc.)\n         ├── iCPG (blast radius, drift)\n         ├── Mnemos (memory, fatigue)\n         └── hooks (PreToolUse, Stop, etc.)\n```\n\n- One project at a time\n- One model (Claude) for everything\n- When Claude tokens ran out, work stopped\n- Agents shared a filesystem (conflict-prone)\n- No market validation for new features\n\n### v4.0 (Container Isolation, Cross-Agent)\n\n```\nUser → Claude Code → /spawn-team → Polyphony containers\n         │                            ├── Container 1 (claude CLI)\n         ├── cross-agent-delegation   ├── Container 2 (codex CLI)\n         │   (complexity scoring)     └── Container 3 (kimi CLI)\n         ├── iCPG + Mnemos\n         └── 3 separate CLI adapters\n```\n\n- Container isolation per agent (own git clone + branch)\n- Cross-agent delegation via complexity scoring\n- Still one project at a time\n- Still separate CLI tools (claude, codex, kimi)\n- Token exhaustion on one provider = manual switch\n\n### v5.0 (Multi-Project, Multi-Model, Market-Validated)\n\n```\nUser → Maggy Web Dashboard → multiple projects → multiple models\n         │                       │\n         │   ┌───────────────────┼───────────────────┐\n         │   │ Project A         │ Project B          │\n         │   │ zensurveys        │ chief-of-staff     │\n         │   │                   │                    │\n         │   │  ┌─Pi agent─┐    │  ┌─Pi agent─┐     │\n         │   │  │ claude   │    │  │ gpt-4o   │     │\n         │   │  │ → gpt-4o │    │  │ → gemini │     │\n         │   │  │ → gemini │    │  │ → qwen   │     │\n         │   │  └──────────┘    │  └──────────┘     │\n         │   └───────────────────┼───────────────────┘\n         │                       │\n         ├── codebase-memory-mcp (structural graph — 36 projects)\n         ├── CIKG (market graph) │ iCPG (intent graph, layers on code graph)\n         ├── Mnemos (cross-model fatigue)\n         └── Token Budget Manager (auto-rotate)\n```\n\n---\n\n## 3. Core Components\n\n### 3.1 Pi — Universal Agent Harness\n\n**Replaces:** `ClaudeAdapter`, `CodexAdapter`, `KimiAdapter`\n\nPi is an open-source (MIT) terminal coding agent that supports 20+ model providers through a single interface. It runs in three modes:\n\n| Mode | Use Case |\n|------|----------|\n| **Interactive** | Human at terminal |\n| **RPC** | Headless JSONL over stdin/stdout — for container agents |\n| **SDK** | Embedded in Maggy's orchestrator |\n\n**Provider support:**\n\n| Tier | Providers | Auth |\n|------|-----------|------|\n| Subscription | Claude Pro/Max, ChatGPT Plus/Pro, GitHub Copilot | OAuth |\n| API Key | Anthropic, OpenAI, Google, DeepSeek, Mistral, Groq, xAI | Env var |\n| Cloud | Azure OpenAI, Amazon Bedrock, Cloudflare Workers | Platform |\n| Local | Ollama (Qwen, Llama, etc.) | None |\n\n**Key capability:** Runtime model switching via RPC without restarting:\n```json\n{\"command\": \"set_model\", \"provider\": \"openai\", \"model\": \"gpt-4o\"}\n```\n\n### 3.2 Maggy v2 — Multi-Project Command Center\n\n**Extends:** Maggy v1 (single-project inbox + execute)\n\nMaggy v2 is a web dashboard (FastAPI + React) that orchestrates work across multiple GitHub repos from a single browser tab.\n\n```\n┌─────────────────────────────────────────────────────────────┐\n│  MAGGY v2 — Web Dashboard                                    │\n│                                                              │\n│  ┌──────────────────────────────────────────────────────┐   │\n│  │  PROJECT REGISTRY (~/.maggy/projects.yaml)           │   │\n│  │  ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐   │   │\n│  │  │ Project │ │ Project │ │ Project │ │ Project │   │   │\n│  │  │ A       │ │ B       │ │ C       │ │ D       │   │   │\n│  │  └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘   │   │\n│  └───────┼───────────┼───────────┼───────────┼─────────┘   │\n│          │           │           │           │              │\n│  ┌───────▼───────────▼───────────▼───────────▼─────────┐   │\n│  │  ORCHESTRATOR                                        │   │\n│  │  ┌────────────┐ ┌─────────────┐ ┌────────────────┐  │   │\n│  │  │ Planning   │ │ Decision    │ │ Execution      │  │   │\n│  │  │ Layer      │ │ Layer       │ │ Layer          │  │   │\n│  │  │            │ │             │ │                │  │   │\n│  │  │ Claude     │ │ iCPG blast  │ │ Pi agents in   │  │   │\n│  │  │ plans      │ │ radius →    │ │ Polyphony      │  │   │\n│  │  │ Codex      │ │ model tier  │ │ containers     │  │   │\n│  │  │ counter-   │ │             │ │                │  │   │\n│  │  │ checks     │ │ CIKG market │ │ Token budget   │  │   │\n│  │  │            │ │ validation  │ │ auto-rotation  │  │   │\n│  │  └────────────┘ └─────────────┘ └────────────────┘  │   │\n│  └──────────────────────────────────────────────────────┘   │\n│                                                              │\n│  ┌──────────────────────────────────────────────────────┐   │\n│  │  CODE INTELLIGENCE (codebase-memory-mcp)             │   │\n│  │  36 projects indexed │ 700K+ nodes │ 1.4M+ edges    │   │\n│  │  Structural graph powering iCPG, blast radius,       │   │\n│  │  cross-project deps, agent context                   │   │\n│  └──────────────────────────────────────────────────────┘   │\n│                                                              │\n│  ┌──────────────────────────────────────────────────────┐   │\n│  │  DEPLOY LAYER                                        │   │\n│  │  4 isolated browser containers (Playwright)          │   │\n│  │  Each with its own Vercel auth session               │   │\n│  └──────────────────────────────────────────────────────┘   │\n└─────────────────────────────────────────────────────────────┘\n```\n\n**New capabilities over v1:**\n- Multi-project view (registry of repos + branches)\n- Cross-project ticket triage\n- Token budget dashboard (usage per model per project)\n- Deploy status per project (isolated Vercel sessions)\n\n### 3.3 Token Budget Manager\n\n**New component.** Manages model selection based on blast radius and token availability.\n\n#### Model Tiering by Composite Risk Score\n\nModel selection uses iCPG's **5-dimension complexity scoring**, not just file count. Each dimension is scored 0-2, total 0-10:\n\n| Dimension | What It Measures | Examples |\n|-----------|-----------------|----------|\n| **Cyclomatic** | Control flow complexity of touched code | Nested conditionals, state machines |\n| **Fan-out** | How many other modules depend on the change | Shared utilities, API contracts |\n| **Security** | Whether auth, crypto, permissions, or PII are involved | Auth policy, token validation |\n| **Concurrency** | Race conditions, locks, async coordination | Queue workers, websocket handlers |\n| **Domain** | Business logic criticality | Pricing, billing, compliance |\n\nPlus 6-dimension drift detection (spec, decision, ownership, test, usage, dependency) and constraint checking from active ReasonNodes.\n\nThis means a one-file auth policy change scores high (security=2, domain=2) while a five-file CSS refactor scores low (cyclomatic=0, fan_out=1). The routing is risk-aware, not file-count-aware.\n\n```\niCPG composite risk score → model tier\n\n┌─────────────┬──────────────────────┬─────────────────────────┐\n│ Score        │ Model Tier           │ Rationale               │\n├─────────────┼──────────────────────┼─────────────────────────┤\n│ 0-3 (low)   │ Qwen local / DeepSeek│ Bounded scope, no       │\n│             │ via Ollama            │ security/concurrency/   │\n│             │                      │ domain risk             │\n├─────────────┼──────────────────────┼─────────────────────────┤\n│ 4-6 (medium)│ Kimi / Gemini Flash  │ Real risk but bounded;  │\n│             │                      │ + high-tier post-review │\n│             │                      │ on output (catch subtle │\n│             │                      │ bugs cheap models miss) │\n├─────────────┼──────────────────────┼─────────────────────────┤\n│ 7-10 (high) │ Claude / GPT-4o      │ Full context needed —   │\n│             │                      │ cross-cutting, security,│\n│             │                      │ concurrency, or domain  │\n│             │                      │ critical changes        │\n└─────────────┴──────────────────────┴─────────────────────────┘\n```\n\n**Dimension overrides:** Regardless of total score, if `security >= 2` or `concurrency >= 2`, the task is always routed to the high tier. These dimensions are too dangerous for cheap models.\n\n#### Low-Tier Output Verification\n\nWhen a task is handled by a cheap/local model (score 0-6), its output goes through additional verification before landing:\n\n| Gate | What It Catches |\n|------|----------------|\n| iCPG drift check | Scope drift, constraint violations, invariant breakage |\n| iCPG constraint assertions | Postconditions from ReasonNodes evaluated against output |\n| High-tier spot review | Claude/GPT-4o reviews the diff (cheaper than writing it) |\n| Static analysis | Linter + type checker catch mechanical errors |\n\nThis prevents the failure class Codex identified: code that passes tests but has subtle logical regressions.\n\n#### Fallback Chain\n\nWhen the primary model hits quota, the budget manager rotates. Model switching is an **explicit handoff with verification**, not a silent swap:\n\n1. Current model hits quota or rate limit\n2. Mnemos writes checkpoint with full execution state\n3. Pi switches to next model via RPC `set_model`\n4. Checkpoint is re-injected as structured context\n5. New model verifies it understands the task before continuing\n6. If verification fails, escalate to next tier (don't retry on weaker model)\n\n```\nClaude (quota hit) → checkpoint + handoff\n  → GPT-4o (quota hit) → checkpoint + handoff\n    → Gemini 2.5 Pro (quota hit) → checkpoint + handoff\n      → Kimi (quota hit) → checkpoint + handoff\n        → DeepSeek (quota hit) → checkpoint + handoff\n          → Qwen local (unlimited, always available)\n```\n\n#### Budget Tracking\n\n```yaml\n# ~/.maggy/token-budget.yaml\nproviders:\n  anthropic:\n    daily_limit_usd: 50.00\n    used_today_usd: 32.15\n    model_preference: claude-sonnet-4-20250514\n  openai:\n    daily_limit_usd: 30.00\n    used_today_usd: 5.20\n    model_preference: gpt-4o\n  local:\n    daily_limit_usd: 0  # free\n    model_preference: qwen2.5-coder:32b\n    ollama_endpoint: http://localhost:11434\n```\n\n### 3.4 Planning Layer — Dual-Model Review\n\nEvery plan goes through a two-model review before execution:\n\n```\nFeature Request / Ticket\n        │\n        ▼\n┌─────────────────┐\n│ Claude Plans     │  Primary model creates architecture plan\n│ (full context)   │  with file list, approach, risks\n└────────┬────────┘\n         │\n         ▼\n┌─────────────────┐\n│ Codex Counter-   │  Second model independently reviews:\n│ Checks           │  - Missing edge cases?\n│ (independent)    │  - Over-engineering?\n│                  │  - Security gaps?\n│                  │  - Simpler approach?\n└────────┬────────┘\n         │\n         ▼\n┌─────────────────┐\n│ Diff View        │  Maggy shows both perspectives\n│ in Maggy UI      │  User approves/resolves conflicts\n└────────┬────────┘\n         │\n         ▼\n    Execution begins\n```\n\n### 3.5 Decision Layer — iCPG + CIKG\n\nTwo graphs feed the orchestrator's decisions:\n\n#### iCPG (Code Graph) — \"Should we change this?\"\n\nPer-project, SQLite-backed. Layers intent and constraints on top of the structural graph from **codebase-memory-mcp** (Section 3.8). Answers:\n\n| Query | What It Returns |\n|-------|----------------|\n| `icpg query blast <id>` | Files affected, downstream dependencies |\n| `icpg query risk <symbol>` | Drift history, ownership changes, fragility |\n| `icpg query constraints <file>` | Invariants that must be preserved |\n| `icpg drift check` | 6-dimension drift across spec, decision, ownership, test, usage, dependency |\n\nThe blast radius score (0-10) determines:\n- Which model tier handles the task\n- How deep the architecture review goes\n- Whether dual-model planning is required\n\n#### CIKG (Competitive Intelligence Knowledge Graph) — \"Should we build this?\"\n\nSupabase-backed. Node types: `competitor`, `feature`, `market_segment`, `technology`, `trend`, `product`.\n\nEdge types: `has_feature`, `competes_with`, `targets_market`, `uses_technology`, `protaige_has`, `protaige_lacks`, `threatens`.\n\nUsed for **new feature validation** before engineering begins:\n\n```\nNew Feature Idea\n       │\n       ▼\n┌────────────────────┐\n│ CIKG: find_gaps()  │  Who has this? Who lacks it?\n│ compare_entities() │  Competitive advantage or table stakes?\n│ get_landscape()    │  Market trend alignment?\n└────────┬───────────┘\n         │\n         ▼\n┌────────────────────┐\n│ Market Score        │\n│                    │\n│ gap_count: 3       │  3 competitors lack this → opportunity\n│ threat_level: high │  2 competitors actively building → urgent\n│ trend_align: yes   │  Aligns with \"AI voice\" trend → proceed\n└────────┬───────────┘\n         │\n         ▼\n  Requirements validated → proceed to iCPG blast radius\n```\n\n### 3.6 Execution Layer — Polyphony + Pi\n\nUpdated container architecture. Each feature agent runs Pi in RPC mode inside a Polyphony container:\n\n```\n┌──────────────────────────────────────────────────────┐\n│ Polyphony Container (per feature)                     │\n│                                                       │\n│  ┌─────────────────────────────────────────────────┐ │\n│  │  Pi Agent (RPC mode over stdin/stdout)           │ │\n│  │                                                  │ │\n│  │  Current model: claude-sonnet-4-20250514         │ │\n│  │  Fallback chain: gpt-4o → gemini → kimi → qwen  │ │\n│  │                                                  │ │\n│  │  Tools: read, write, edit, bash                  │ │\n│  │  Extensions: skills, hooks, MCP servers          │ │\n│  └──────────────────────────────┬──────────────────┘ │\n│                                 │                     │\n│  ┌──────────┐  ┌────────────┐  │  ┌──────────────┐  │\n│  │ Git clone│  │ .mnemos/   │  │  │ .icpg/       │  │\n│  │ own      │  │ fatigue    │  │  │ blast radius │  │\n│  │ branch   │  │ checkpoint │  │  │ constraints  │  │\n│  └──────────┘  └────────────┘  │  └──────────────┘  │\n│                                │                     │\n│  ┌─────────────────────────────▼──────────────────┐  │\n│  │  RPC Bridge (Maggy ↔ Pi)                       │  │\n│  │  • Send prompts                                │  │\n│  │  • Receive streaming events                    │  │\n│  │  • Switch models on quota hit                  │  │\n│  │  • Steer/follow-up mid-task                    │  │\n│  └────────────────────────────────────────────────┘  │\n└──────────────────────────────────────────────────────┘\n```\n\n**Coordination model (hybrid — option 2):**\n\nClaude Code's native Task tool spawns agents that keep full team coordination (SendMessage, TaskList, UI visibility). Each agent controls a Pi instance inside a Polyphony container via RPC. The agent has Claude's brain for coordination but Pi's body for execution.\n\n**Why this is not a split-brain problem:**\nThis concern is addressed by Mnemos, which serves as a **shared memory layer that both sides can read**:\n\n- **Mnemos checkpoint** persists goal, constraints, progress, and working state to disk (`.mnemos/`)\n- **iCPG state** persists intent, constraints, and drift to disk (`.icpg/`)\n- **Signal log** (`.mnemos/signals.jsonl`) persists behavioral signals across model switches\n- All three are inside the container volume — they survive model swaps\n\nThe coordination agent (Claude Task tool) handles team communication. The execution agent (Pi) handles code work. The shared disk state (Mnemos + iCPG) is the single source of truth. There's no split brain because there's no duplicated state — each layer owns a distinct concern with shared persistence.\n\n```\nClaude Code Task tool agent (coordination — messaging, tasks, UI)\n    │\n    ├── SendMessage to team lead ✓\n    ├── TaskUpdate progress ✓\n    ├── Visible in tmux/iTerm ✓\n    │\n    └── Executes code work via:\n        docker exec polyphony-feature-X \\\n            pi --mode rpc --provider anthropic\n        │\n        ├── stdin: {\"command\": \"prompt\", \"content\": \"implement auth\"}\n        ├── stdout: streaming events (text, tool calls, completion)\n        ├── stdin: {\"command\": \"set_model\", ...} when quota hits\n        │\n        └── Shared persistence (inside container volume):\n            ├── .mnemos/checkpoint-latest.json  ← goal, constraints, progress\n            ├── .mnemos/signals.jsonl           ← behavioral signals\n            ├── .mnemos/fatigue.json            ← model-normalized fatigue\n            └── .icpg/reason.db                 ← intent, constraints, drift\n```\n\n### 3.7 Deploy Layer — Isolated Vercel Sessions\n\nFour Docker containers, each running a headless browser with its own Vercel auth session:\n\n```\n┌────────────────────────────┐\n│ vercel-session-A           │\n│ Playwright + Chrome        │\n│ Auth: vercel.com (session) │\n│ Project: zensurveys-backend│\n│ No local `vercel login`    │\n├────────────────────────────┤\n│ vercel-session-B           │\n│ Own Chrome profile         │\n│ Project: zensurveys-fe     │\n├────────────────────────────┤\n│ vercel-session-C           │\n│ Own Chrome profile         │\n│ Project: chief-of-staff    │\n├────────────────────────────┤\n│ vercel-session-D           │\n│ Own Chrome profile         │\n│ Project: rodcast           │\n└────────────────────────────┘\n```\n\nEach container persists its Chrome profile to a Docker volume. No local directory conflicts. Deploys are triggered from Maggy's web UI or via git push (Vercel auto-deploy).\n\n### 3.8 Code Intelligence Layer — codebase-memory-mcp\n\n**Foundation layer.** Every component above — iCPG, blast radius scoring, Maggy's orchestrator, Pi agents — depends on a structural understanding of the code. codebase-memory-mcp is the AST-based knowledge graph that provides it.\n\n```\n┌──────────────────────────────────────────────────────────────┐\n│  codebase-memory-mcp                                          │\n│  ─────────────────────────────────────────────────────────── │\n│                                                               │\n│  36 projects indexed │ 14 MCP tools │ 64 languages             │\n│  700K+ nodes │ 1.4M+ edges │ auto-updated via file watcher    │\n│                                                               │\n│  Node Types:                                                  │\n│    Function, Method, Class, Variable, Route,                  │\n│    File, Module, Folder, Section, Project                     │\n│                                                               │\n│  Edge Types:                                                  │\n│    CALLS, IMPORTS, USAGE, DEFINES, DEFINES_METHOD,            │\n│    TESTS, WRITES, HANDLES, HTTP_CALLS, CONFIGURES,            │\n│    SEMANTICALLY_RELATED, SIMILAR_TO, CONTAINS_*               │\n│                                                               │\n│  Search Modes:                                                │\n│    BM25 full-text │ regex pattern │ semantic vector             │\n│                                                               │\n│  Trace Modes:                                                 │\n│    calls (callers/callees) │ data_flow (value propagation)     │\n│    cross_service (HTTP/async through Routes)                   │\n└──────────────────────────────────────────────────────────────┘\n```\n\n#### How Each Component Uses It\n\n| Component | Graph Queries | Purpose |\n|-----------|--------------|---------|\n| **iCPG blast radius** | `trace_path(fn, mode=calls, risk_labels=true)` | Fan-out scoring — how many callers/callees, at what hop distance |\n| **iCPG drift** | `detect_changes` + `query_graph` | Detect which functions changed, trace impact to dependents |\n| **Token budget routing** | `trace_path` depth + edge count | Feed fan-out dimension of 5-dimension complexity score |\n| **Pi agents (pre-task)** | `search_graph` + `get_architecture` | Understand codebase before making changes — no blind edits |\n| **Pi agents (post-task)** | `detect_changes` | Verify scope of changes matches intent |\n| **Maggy orchestrator** | `search_graph` across projects | Map ticket descriptions → relevant code across all repos |\n| **Dual-model planning** | `get_architecture` + `trace_path` | Give both Claude and Codex the same structural context |\n| **Reward registry** | `detect_changes` | Measure actual blast radius of completed work for reward signals |\n| **Cross-project deps** | `query_graph` with HTTP_CALLS/IMPORTS | If zensurveys-backend changes an API route, trace consumers in frontend |\n\n#### Multi-Project Graph Topology\n\nEach project has its own indexed graph. Maggy queries across them:\n\n```\n┌─────────────────────────────────────────────────────────────┐\n│  codebase-memory-mcp — Cross-Project Graph                    │\n│                                                               │\n│  ┌──────────────────┐  ┌──────────────────┐                  │\n│  │ zensurveys       │  │ zensurveys-fe    │                  │\n│  │ 7,644 nodes      │  │ 11,168 nodes     │                  │\n│  │ 25,866 edges     │  │ 16,876 edges     │                  │\n│  │                  │  │                  │                  │\n│  │ Route: /api/v1/* │──│ HTTP_CALLS: fetch│                  │\n│  └──────────────────┘  └──────────────────┘                  │\n│                                                               │\n│  ┌──────────────────┐  ┌──────────────────┐                  │\n│  │ chief-of-staff   │  │ maggy            │                  │\n│  │ 2,687 nodes      │  │ 4,692 nodes      │                  │\n│  │ 6,958 edges      │  │ 7,459 edges      │                  │\n│  └──────────────────┘  └──────────────────┘                  │\n│                                                               │\n│  ┌──────────────────┐  ┌──────────────────┐                  │\n│  │ protaige-backend │  │ protaige-frontend│                  │\n│  │ 26,832 nodes     │  │ 8,630 nodes      │                  │\n│  │ 92,174 edges     │  │ 14,539 edges     │                  │\n│  └──────────────────┘  └──────────────────┘                  │\n│                                                               │\n│  + 30 more indexed projects                                   │\n└─────────────────────────────────────────────────────────────┘\n```\n\n#### Integration with iCPG\n\niCPG and codebase-memory-mcp are **complementary, not redundant**:\n\n| Layer | What It Knows | Storage |\n|-------|--------------|---------|\n| **codebase-memory-mcp** | Structure — what calls what, who imports whom, where routes go | `.code-graph/` (AST-derived) |\n| **iCPG** | Intent — WHY code exists, what constraints it must obey, what decisions shaped it | `.icpg/reason.db` (human/AI-derived) |\n\n```\ncodebase-memory-mcp (structural)     iCPG (intentional)\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━     ━━━━━━━━━━━━━━━━━━━━\nFunction: handleAuth()               ReasonNode: \"handles OAuth\"\n  CALLS → validateToken()              Constraint: \"must check exp\"\n  CALLS → refreshSession()             Decision: \"chose PKCE over implicit\"\n  USAGE → from 14 callers               Drift: \"spec says mTLS, code uses JWT\"\n  Route: POST /api/auth/login\n                                      5-dimension score: 8/10\ntrace_path → 3-hop blast radius         (security=2, domain=2, fan_out=2)\n```\n\nThe structural graph provides the \"what and where.\" iCPG provides the \"why and what-must-hold.\" Together they give the token budget manager a complete risk picture.\n\n#### Freshness Guarantees\n\n```\n┌────────────────┬──────────────────────────────────────────┐\n│ Layer           │ How It Stays Fresh                        │\n├────────────────┼──────────────────────────────────────────┤\n│ File watcher   │ Re-indexes changed files on save (~10ms) │\n│ Auto-index     │ Ensures currency on Claude Code startup  │\n│ Post-commit    │ git hook triggers incremental re-index   │\n│ detect_changes │ Diff-aware — shows what changed since    │\n│                │ last index, not full re-scan              │\n└────────────────┴──────────────────────────────────────────┘\n```\n\nNo manual re-indexing needed for normal development. Only `index_repository` after major restructures (branch switches with large diffs, directory renames).\n\n---\n\n## 4. Mnemos in a Multi-Model World\n\n### The Problem\n\nMnemos v1 tracks fatigue for a single Claude Code session. In v5, a task might start on Claude, switch to GPT-4o mid-session, then fall back to Qwen. Each model has different:\n\n- Context window sizes (200K Claude vs 128K GPT-4o vs 32K Qwen local)\n- Compaction behavior\n- Tool call patterns\n\n### The Solution: Model-Aware Fatigue\n\nExtend the 4-dimension fatigue model with model-relative normalization:\n\n```\n┌──────────────────────────────────────────────────────┐\n│ Mnemos v2 — Cross-Model Fatigue                       │\n│                                                       │\n│ Current model: gpt-4o (128K context)                  │\n│ Previous model: claude (200K context)                 │\n│ Model switches this session: 1                        │\n│                                                       │\n│ Fatigue dimensions (model-normalized):                │\n│                                                       │\n│  Token utilization: 0.65                              │\n│    → 83K / 128K (gpt-4o window, not claude's 200K)   │\n│                                                       │\n│  Scope scatter: 0.30                                  │\n│    → Carried over from pre-switch signal log          │\n│                                                       │\n│  Re-read ratio: 0.45 ← ELEVATED                      │\n│    → Model switch caused context loss, agent is       │\n│      re-reading files it already read under Claude    │\n│                                                       │\n│  Error density: 0.20                                  │\n│    → New model still learning the codebase            │\n│                                                       │\n│  Composite: 0.43 (COMPRESS state)                     │\n│  → Auto-consolidation triggered                       │\n└──────────────────────────────────────────────────────┘\n```\n\n### Key Extensions\n\n| Extension | Description |\n|-----------|-------------|\n| **Model-relative token %** | Normalize against current model's context window, not a fixed 200K |\n| **Switch penalty** | When model switches, add +0.15 to re-read ratio (context was lost) |\n| **Cross-model checkpoint** | Checkpoint includes model history so the new model knows what was done |\n| **Shared signal log** | `.mnemos/signals.jsonl` persists across model switches (it's on disk) |\n| **Budget-aware thresholds** | If running on free tier (Qwen local), relax fatigue thresholds (no cost pressure) |\n\n### Checkpoint Format — Extended for Multi-Model\n\n```json\n{\n  \"goal\": \"Implement voice surveys\",\n  \"model_history\": [\n    {\"provider\": \"anthropic\", \"model\": \"claude-sonnet\", \"tokens_used\": 145000, \"duration_s\": 420},\n    {\"provider\": \"openai\", \"model\": \"gpt-4o\", \"tokens_used\": 83000, \"duration_s\": 180}\n  ],\n  \"switch_reason\": \"anthropic quota exceeded\",\n  \"active_constraints\": [\"...\"],\n  \"active_results\": [\"...\"],\n  \"current_subgoal\": \"...\",\n  \"fatigue_at_checkpoint\": 0.43,\n  \"icpg_state\": {\"...\"},\n  \"cikg_context\": {\n    \"market_validation\": \"3 competitors have voice — table stakes\",\n    \"gap_id\": \"uuid-of-cikg-gap-node\"\n  }\n}\n```\n\n---\n\n## 5. Data Flow — End to End\n\n```\n1. USER opens Maggy dashboard\n   → Sees all projects, token budgets, active agents\n\n2. USER selects ticket from inbox (or creates feature idea)\n   │\n   ▼\n3. CIKG VALIDATION (new features only)\n   → find_gaps(): who has this? competitive pressure?\n   → get_landscape(): market trend alignment?\n   → Output: market score + competitive context\n   │\n   ▼\n4. STRUCTURAL ANALYSIS (codebase-memory-mcp)\n   → search_graph: locate relevant symbols across projects\n   → trace_path: map call chains and fan-out (with risk labels)\n   → get_architecture: understand module boundaries\n   → Output: structural dependency map\n   │\n   ▼\n5. iCPG ANALYSIS (layers on structural graph)\n   → query blast: which files are affected?\n   → query risk: are they fragile?\n   → query constraints: what invariants exist?\n   → Output: blast radius score (0-10)\n   │\n   ▼\n5.5 LEXON TOOL RESOLUTION (when tool count > 20 — requires Lexon, Section 16)\n    → Structured intent from iCPG fed to Lexon two-tier routing\n    → Tier A: fast LLM router (<300ms) selects from compact tool manifest\n    → Tier B: multilingual semantic retriever (vector search over tool registry)\n    → Union candidates, filter through Terminology Map (user > org > system)\n    → If confidence < 0.82 or top-2 gap < 0.15: trigger clarify_intent\n    → Output: selected tool with confidence score + LexonRecord logged\n   │\n   ▼\n6. MODEL SELECTION (from blast score + budget)\n   → Score 0-3: Qwen local / DeepSeek (free tier)\n   → Score 4-6: Kimi / Gemini Flash (cheap tier)\n   → Score 7-10: Claude / GPT-4o (full tier)\n   → Check token budget: rotate if primary is exhausted\n   │\n   ▼\n7. PLANNING (score 7+ only)\n   → Claude creates architecture plan\n   → Codex independently counter-checks\n   → Both get structural context from codebase-memory-mcp\n   → Maggy shows diff in UI\n   → User approves\n   │\n   ▼\n8. EXECUTION\n   → Polyphony provisions Docker container\n   → Pi starts in RPC mode with selected model\n   → Pi queries codebase-memory-mcp for context before editing\n   → Claude Code Task agent controls Pi via RPC\n   → Mnemos tracks fatigue (model-normalized)\n   → If quota hits: Pi switches model, Mnemos logs switch\n   │\n   ▼\n9. VERIFICATION\n   → Tests pass in container\n   → detect_changes: verify actual scope matches intended scope\n   → iCPG drift check: no unintended scope drift\n   → Code review (can use second model for independence)\n   │\n   ▼\n10. DEPLOY\n    → Changes on feature branch → PR created\n    → Vercel preview deploy via isolated browser container\n    → User reviews in Maggy dashboard\n    │\n    ▼\n11. PROCESS LEARNING (async, post-merge)\n    → Collect PR review comments + CodeRabbit findings\n    → Collect CI pass/fail results for Maggy-written code\n    → Track review rounds, time-to-merge, post-merge incidents\n    → Update process_patterns.db, ci_patterns.db, pr_patterns.db\n    → Feed reward registry: +0.5 first-round approval, -0.4 critical finding\n    → Adjust policy: add pre-checks, evolve skills, tune PR sizing\n    │\n    ▼\n11.5 ENGRAM PERSISTENCE (async, post-task — requires Engram, Section 15)\n    → Mnemos scans completed task graph for high-confidence memories\n    → Promote to EngramRecord: conventions, patterns, preferences with confidence > 0.8\n    → Namespace-isolate per project (project A's patterns never contaminate project B)\n    → Apply temporal validity windows (patterns expire unless revalidated)\n    → Track Origin: source channel, evidence count, last verified timestamp\n    → Feed Amnesia Score diagnostic: measure retention across 7 dimensions\n    │\n    ▼\n12. MESH SYNC (async, background — requires Maggy Mesh, Section 14)\n    → Broadcast L1 score updates to connected peers (lightweight, one message per task)\n    → Merge incoming peer data: scores weighted by sample count, patterns quarantined\n    → Surface team-wide insights: \"3 peers confirm: Claude best for auth\"\n    → Propose cross-team policy changes when backtesting passes on team-wide data\n    → New peers receive full sync on connect — instant collective intelligence\n```\n\n---\n\n## 6. Project Registry\n\n```yaml\n# ~/.maggy/projects.yaml\nprojects:\n  - name: zensurveys-backend\n    repo: zenloopGmbH/surveys-backend\n    path: ~/Documents/protaige/projects/zensurveys\n    default_branch: staging-v2\n    vercel_session: vercel-session-A\n    icpg: true\n    cikg: false  # not a product repo\n\n  - name: zensurveys-frontend\n    repo: zenloopGmbH/main-frontend-clean\n    path: ~/Documents/protaige/projects/main-frontend-clean\n    default_branch: main\n    vercel_session: vercel-session-B\n    icpg: true\n    cikg: false\n\n  - name: chief-of-staff\n    repo: alinaqi/chief-of-staff\n    path: ~/Documents/protaige/projects/chief-of-staff\n    default_branch: main\n    vercel_session: vercel-session-C\n    icpg: true\n    cikg: true  # has competitive intelligence graph\n\n  - name: rodcast\n    repo: alinaqi/rodcast\n    path: ~/Documents/AI-Playground/rodcast\n    default_branch: main\n    vercel_session: vercel-session-D\n    icpg: true\n    cikg: false\n```\n\n---\n\n## 7. Component Map\n\n```\nmaggy/\n├── dashboard/                        # Maggy v2 — web dashboard\n│   ├── src/\n│   │   ├── api/                  # FastAPI routes\n│   │   ├── providers/            # GitHub, Asana, Linear\n│   │   ├── services/\n│   │   │   ├── inbox.py          # AI-prioritized ticket inbox\n│   │   │   ├── executor.py       # Execute pipeline (now via Pi)\n│   │   │   ├── competitor.py     # Daily briefing\n│   │   │   ├── planner.py        # NEW: dual-model planning\n│   │   │   ├── budget.py         # NEW: token budget manager\n│   │   │   ├── deploy.py         # NEW: isolated Vercel deploys\n│   │   │   ├── process.py        # NEW: process intelligence (env discovery, signal collection)\n│   │   │   └── forge.py          # NEW: MCP Forge integration (capability expansion)\n│   │   └── orchestrator.py       # NEW: multi-project orchestrator\n│   └── frontend/                 # React dashboard\n│       ├── ProjectRegistry.tsx   # NEW: multi-project view\n│       ├── TokenBudget.tsx       # NEW: usage per model\n│       ├── PlanReview.tsx        # NEW: dual-model plan diff\n│       └── DeployStatus.tsx      # NEW: per-project deploy\n│\n├── scripts/\n│   ├── polyphony/                # Container orchestration\n│   │   ├── adapters/\n│   │   │   ├── pi.py             # NEW: PiAdapter (replaces claude/codex/kimi)\n│   │   │   ├── claude.py         # DEPRECATED: kept for fallback\n│   │   │   ├── codex.py          # DEPRECATED: kept for fallback\n│   │   │   └── kimi.py           # DEPRECATED: kept for fallback\n│   │   ├── budget.py             # NEW: token budget + model routing\n│   │   ├── runtime.py            # Docker container lifecycle\n│   │   ├── orchestrator.py       # Supervisor loop\n│   │   └── ...\n│   ├── icpg/                     # Code graph (per-project)\n│   ├── mnemos/                   # Memory + fatigue\n│   │   ├── fatigue.py            # EXTENDED: model-normalized\n│   │   ├── checkpoint.py         # EXTENDED: cross-model state\n│   │   └── ...\n│   ├── cikg/                     # NEW: extracted from chief-of-staff\n│   │   ├── __init__.py\n│   │   ├── graph.py              # KnowledgeGraphService\n│   │   ├── models.py             # Node/Edge types\n│   │   └── __main__.py           # CLI: cikg query/traverse/gaps\n│   ├── engram/                   # NEW: cross-session memory persistence\n│   │   ├── __init__.py\n│   │   ├── record.py             # EngramRecord schema\n│   │   ├── store.py              # SQLite persistence + namespace isolation\n│   │   ├── retrieval.py          # Multi-path retrieval (semantic+temporal+causal)\n│   │   └── diagnostics.py        # Amnesia Score computation (7 dimensions)\n│   ├── lexon/                    # NEW: semantic tool binding\n│   │   ├── __init__.py\n│   │   ├── record.py             # LexonRecord schema\n│   │   ├── router.py             # Two-tier routing (fast LLM + vector)\n│   │   ├── terminology.py        # Terminology Map (system/org/user)\n│   │   ├── disambiguate.py       # Confidence-gated clarification (self/user modes)\n│   │   └── personalization.py    # Implicit learning from user behavior\n│   └── event_spine/              # NEW: canonical event flow\n│       ├── __init__.py\n│       ├── events.py             # Typed event dataclasses (8 event types)\n│       ├── header.py             # Common EventHeader\n│       ├── emitter.py            # Event emission API (used by all components)\n│       └── store.py              # SQLite append-only event log + archive\n│\n├── skills/\n│   ├── polyphony/SKILL.md        # Updated for Pi\n│   ├── mnemos/SKILL.md           # Updated for multi-model\n│   ├── icpg/SKILL.md             # Unchanged\n│   ├── code-graph/SKILL.md       # codebase-memory-mcp integration\n│   ├── cikg/SKILL.md             # NEW: competitive intelligence skill\n│   ├── engram/SKILL.md           # NEW: cross-session memory instructions\n│   └── lexon/SKILL.md            # NEW: tool binding instructions\n│\n├── templates/\n│   ├── Dockerfile.polyphony      # Updated: includes Pi\n│   ├── Dockerfile.vercel-session # NEW: Playwright + Chrome\n│   └── ...\n│\n└── docs/\n    ├── architecture-v5.md        # THIS DOCUMENT\n    ├── polyphony-spec.md         # Container orchestration spec\n    └── mnemos-implementation.md  # Memory lifecycle spec\n```\n\n---\n\n## 8. Migration Path\n\n| Phase | What | Depends On |\n|-------|------|-----------|\n| **Phase 1** | PiAdapter + token budget manager | Pi installed |\n| **Phase 2** | Model-tiered routing (blast score → model) | Phase 1 + iCPG |\n| **Phase 3** | Mnemos multi-model fatigue | Phase 1 |\n| **Phase 4** | Extract CIKG from chief-of-staff | Supabase access |\n| **Phase 5** | Maggy v2 multi-project UI | Phases 1-4 |\n| **Phase 6** | Dual-model planning (Claude + Codex) | Phase 1 |\n| **Phase 7** | Isolated Vercel deploy containers | Docker |\n| **Phase 8** | Process intelligence (env discovery + signal collection) | Phase 5 + GitHub API |\n| **Phase 9** | MCP Forge integration (capability expansion) | Phase 5 + mcp_forge |\n| **Phase 10** | Integration testing + docs | All phases |\n| **Phase 11** | Maggy Mesh — P2P team intelligence | Phase 5 + Phase 8 |\n| **Phase 12** | Engram — Cross-session memory persistence | Phase 3 + Phase 5 |\n| **Phase 13** | Lexon — Semantic tool binding | Phase 9 + Phase 12 |\n| **Phase 14** | Event Spine — Canonical event flow | Phase 12 + Phase 13 |\n\n---\n\n## 9. Security Considerations\n\n| Concern | Mitigation |\n|---------|-----------|\n| API keys across models | Pi's auth.json + env vars, never in code |\n| Container escape | Polyphony containers run unprivileged, no host network |\n| Vercel session theft | Each browser container has isolated Chrome profile in Docker volume |\n| CIKG data sensitivity | Competitive intelligence stays in Supabase with RLS |\n| Local model data leaks | Qwen/Ollama runs fully local, no data leaves machine |\n| Token budget manipulation | Budget file is local YAML, not exposed via API |\n\n---\n\n## 10. Core Principle — mWp (Minimum Wowable Product)\n\nEvery component in this architecture must be designed to wow, not just work.\n\n> **mWp > MVP**: We don't ship \"minimum viable.\" We ship \"minimum wowable.\" The bar is: would this make someone stop scrolling and say \"wait, how did it do that?\"\n\n### What mWp means for each component\n\n| Component | MVP (don't ship this) | mWp (ship this) |\n|-----------|----------------------|-----------------|\n| Token budget | Show remaining tokens | Auto-rotate models mid-task, user never notices the switch |\n| Blast radius | Show a score number | Score drives model selection, review depth, and plan complexity automatically |\n| CIKG validation | \"3 competitors have this\" | \"Here's the competitive gap map, market trend alignment, and suggested positioning — before you write a line of code\" |\n| Mnemos fatigue | \"Context 80% full\" | Silently checkpoints, switches models, re-injects context — user's train of thought is never interrupted |\n| Vercel deploy | \"Run vercel deploy\" | 4 projects deploy in parallel with zero auth conflicts, preview links appear in Maggy dashboard |\n| Code graph | \"We indexed your repo\" | \"Maggy already knows every function, every caller, every route across all 36 projects — before you ask. It traced the blast radius in 10ms, not 10 minutes of grepping.\" |\n| Process intelligence | \"Here are your CI results\" | \"Maggy learned that your reviewer always flags missing error handling — it added it before the PR was created. CI pass rate went from 72% to 97%. Review rounds dropped from 2.8 to 1.1. It didn't just fix the code, it fixed the process.\" |\n| Capability expansion | \"We don't support that integration\" | \"Maggy built a Linear MCP server from the API docs, registered the tools, and pulled your sprint data — all within the same conversation.\" |\n| Dual-model planning | Two plans side by side | Conflicts highlighted, trade-offs explained, one-click approval with merged approach |\n\n### The 5-second test for Maggy v2\n\nA developer opens Maggy in the morning. Within 5 seconds they see:\n- Inbox ranked by urgency across all 4 projects\n- Token budget status (green/yellow/red per provider)\n- Active agents and their progress\n- Yesterday's competitive intelligence briefing\n- Process health: CI pass rate, review rounds trend, CodeRabbit findings trend\n- One-click \"Execute\" on any ticket with the right model auto-selected\n\nThat's the wow.\n\n---\n\n## 11. Maggy as a Self-Improving System\n\nMaggy is not a tool that waits for instructions. It's an autonomous agent with a single objective function: **maximize user development efficiency**. It observes, measures, optimizes, and evaluates itself — continuously, without asking for permission.\n\n### The Objective Function\n\n```\nefficiency = (value_delivered / time_spent) × quality_multiplier\n\nwhere:\n  value_delivered  = tickets landed + features shipped + bugs fixed\n  time_spent       = wall clock from ticket selection to merge\n  quality_multiplier = 1.0 - (bug_escape_rate + revert_rate + incident_rate)\n```\n\nMaggy optimizes this function across all projects, all models, all workflows. Everything it does — model routing, inbox ordering, workflow tuning, fatigue management — feeds back into this single metric.\n\n### Reward Registry\n\nEvery action Maggy takes generates a reward signal. Positive rewards reinforce. Negative rewards suppress. The registry is the memory of what works.\n\n```\n┌─────────────────────────────────────────────────────────────┐\n│  REWARD REGISTRY                                             │\n│                                                              │\n│  ┌─────────────────────────────────────────────────────┐    │\n│  │  POSITIVE REWARDS (reinforce)                       │    │\n│  │                                                      │    │\n│  │  +1.0  Ticket lands without human intervention       │    │\n│  │  +0.8  Tests pass on first attempt                   │    │\n│  │  +0.5  Time-to-merge below rolling average           │    │\n│  │  +0.3  No bug escapes at 2-week mark                 │    │\n│  │  +0.2  User doesn't re-do the work manually          │    │\n│  │  +0.1  Model switch was seamless (no re-reads spike) │    │\n│  └─────────────────────────────────────────────────────┘    │\n│                                                              │\n│  ┌─────────────────────────────────────────────────────┐    │\n│  │  NEGATIVE REWARDS (suppress)                        │    │\n│  │                                                      │    │\n│  │  -1.0  User reverts the change                       │    │\n│  │  -0.8  Bug escape discovered post-merge              │    │\n│  │  -0.5  User manually re-does the task                │    │\n│  │  -0.3  Tests fail after model switch                 │    │\n│  │  -0.2  User overrides Maggy's model/routing choice   │    │\n│  │  -0.1  Time-to-merge above rolling average           │    │\n│  │  -0.1  iCPG drift detected after task completion     │    │\n│  │  -0.1  detect_changes shows scope exceeded intent   │    │\n│  └─────────────────────────────────────────────────────┘    │\n│                                                              │\n│  Rewards decay: 0.95^(days_since_event)                     │\n│  Window: 60-day rolling                                      │\n│  Cold start: hardcoded defaults until 30+ events per signal  │\n└─────────────────────────────────────────────────────────────┘\n```\n\n### Multi-Level Closed-Loop Control\n\nThe previous version of this section described a flat observe → measure → adjust → evaluate loop. That's not a closed-loop system — that's batch processing with hope. A bad model routing decision on Monday would serve degraded output to every task until the weekly evaluation catches it.\n\n**Control theory insight: inner loops provide stability, outer loops provide optimization.** Level 0 keeps individual tasks from going off the rails. Level 2 keeps tools and models healthy day-to-day. Level 3 makes Maggy smarter week-over-week. Each level's output becomes an input signal for the level above it.\n\n```\n┌──────────────────────────────────────────────────────────────┐\n│  MULTI-LEVEL CLOSED-LOOP CONTROL                              │\n│                                                               │\n│  Level 4 ─── Monthly (evolutionary) ──────────────────────── │\n│  │  Sensor:  cross-project trends, platform trajectory        │\n│  │  Actuator: new reward signals, new process patterns,       │\n│  │           blast→tier recalibration, exploration rate        │\n│  │  Bandwidth: weeks                                          │\n│  │                                                            │\n│  │  Level 3 ─── Weekly (strategic) ────────────────────────  │\n│  │  │  Sensor:  worst/best task patterns, score deltas,       │\n│  │  │          process pattern analysis, capability gaps      │\n│  │  │  Actuator: skill evolution, workflow step changes,      │\n│  │  │           model routing thresholds, MCP Forge,          │\n│  │  │           PR strategy, prompt patches                   │\n│  │  │  Bandwidth: days                                        │\n│  │  │                                                         │\n│  │  │  Level 2 ─── Daily (operational) ──────────────────   │\n│  │  │  │  Sensor:  CI pass rates, review round trends,        │\n│  │  │  │          CodeRabbit findings, model failure rates,   │\n│  │  │  │          token budget burn rate                       │\n│  │  │  │  Actuator: pre-commit check toggles, lint rules,     │\n│  │  │  │           model enable/disable, routing weights      │\n│  │  │  │  Bandwidth: hours                                    │\n│  │  │  │                                                      │\n│  │  │  │  Level 1 ─── Task (post-completion) ─────────────  │\n│  │  │  │  │  Sensor:  task reward score, CI results,          │\n│  │  │  │  │          iCPG drift, detect_changes scope,        │\n│  │  │  │  │          review comments on PR                    │\n│  │  │  │  │  Actuator: update model scores, log process       │\n│  │  │  │  │           signals, update fatigue profile          │\n│  │  │  │  │  Bandwidth: minutes                               │\n│  │  │  │  │                                                   │\n│  │  │  │  │  Level 0 ─── Real-time (within task) ──────────│\n│  │  │  │  │  │  Sensor:  tool success/fail, test pass/fail,  ││\n│  │  │  │  │  │          lint errors, Pi RPC events,          ││\n│  │  │  │  │  │          model response quality, fatigue      ││\n│  │  │  │  │  │  Actuator: switch model, retry with context,  ││\n│  │  │  │  │  │           adjust verification depth,          ││\n│  │  │  │  │  │           abort + re-plan, checkpoint          ││\n│  │  │  │  │  │  Bandwidth: seconds                           ││\n│  │  │  │  │  └───────────────────────────────────────────────┘│\n│  │  │  │  └──────────────────────────────────────────────────┘│\n│  │  │  └─────────────────────────────────────────────────────┘│\n│  │  └────────────────────────────────────────────────────────┘│\n│  └───────────────────────────────────────────────────────────┘│\n└──────────────────────────────────────────────────────────────┘\n\nSignal cascade (inner → outer):\n  L0 events aggregate into → L1 task reward\n  L1 task rewards aggregate into → L2 daily trends\n  L2 daily trends feed → L3 weekly pattern analysis\n  L3 weekly patterns feed → L4 monthly trajectory\n```\n\n#### Level 0 — Real-Time (Within Task Execution)\n\nThis is the **stability loop** — the most critical and currently missing level. It keeps individual tasks from going off the rails *as they happen*, not after the damage is done.\n\n```\n┌──────────────────────────────────────────────────────────────┐\n│  LEVEL 0 — REAL-TIME CONTROL (seconds)                        │\n│                                                               │\n│  Pi agent executing task inside Polyphony container           │\n│       │                                                       │\n│       ├── Tool call fails (file not found, API error)         │\n│       │   → Retry with adjusted path/params (not new model)  │\n│       │   → If 3 consecutive fails: escalate model tier       │\n│       │                                                       │\n│       ├── Test fails during TDD green phase                   │\n│       │   → Analyze error: syntax? logic? missing import?     │\n│       │   → If model is struggling (3+ failed attempts):      │\n│       │     checkpoint + switch to higher-tier model           │\n│       │                                                       │\n│       ├── Lint error on written code                          │\n│       │   → Auto-fix (ruff --fix / eslint --fix)              │\n│       │   → If pattern repeats: flag for L2 (add pre-check)  │\n│       │                                                       │\n│       ├── Fatigue signal crosses threshold                    │\n│       │   → Mnemos auto-checkpoint                            │\n│       │   → If mid-task: consolidate context, continue        │\n│       │   → If near completion: push through, checkpoint after│\n│       │                                                       │\n│       ├── Model response quality degrades                     │\n│       │   → Detected by: repeated re-reads, circular edits,  │\n│       │     tool calls that undo previous tool calls          │\n│       │   → Action: checkpoint + model switch immediately     │\n│       │                                                       │\n│       └── Scope drift detected (iCPG)                         │\n│           → Agent touching files outside blast radius          │\n│           → Action: warn → constrain → abort if persistent    │\n│                                                               │\n│  All L0 events are logged to signals.jsonl with timestamps.   │\n│  They aggregate into the L1 task reward score.                │\n└──────────────────────────────────────────────────────────────┘\n```\n\n**Why L0 matters more than any weekly patch:** If Maggy can detect mid-task that the current model is struggling and switch to a stronger one *within seconds*, that's worth more than a hundred policy adjustments. A user whose task fails experiences -1.0 reward. A user whose task recovers mid-flight via model switch experiences +0.1. The delta between \"fail and retry tomorrow\" and \"hiccup and recover\" is the entire product experience.\n\n**L0 signal types:**\n\n| Signal | Detection Method | Response Time | Action |\n|--------|-----------------|---------------|--------|\n| Tool failure | Pi RPC error event | < 1s | Retry with adjusted params |\n| Test failure | Exit code from test runner | < 5s | Analyze, fix, or escalate model |\n| Lint error | ruff/eslint output on written code | < 2s | Auto-fix or flag for L2 |\n| Fatigue spike | Mnemos threshold breach | < 1s | Checkpoint, consolidate, or switch |\n| Quality degradation | Circular edits, re-reads, undo patterns | ~30s | Checkpoint + model switch |\n| Scope drift | iCPG blast radius check on file access | < 1s | Warn → constrain → abort |\n| Model quota hit | Pi RPC quota/rate error | < 1s | Fallback chain activation |\n\n#### Level 1 — Task (Post-Completion, Minutes)\n\nAfter each task completes, compute the task reward score and update the per-model, per-task-type scores. This is the **learning loop** — every completed task teaches Maggy something.\n\n```\nTask completes (PR created or code landed)\n    │\n    ├── Compute task reward from L0 signals:\n    │   reward = Σ(signal_weight × signal_value)\n    │   adjusted for: model used, blast tier, task type\n    │\n    ├── Update model_scores.db:\n    │   (claude, auth, high) → new running average\n    │\n    ├── Update fatigue_profile:\n    │   session duration, checkpoint timing, recovery reads\n    │\n    ├── Log L0 events summary → L2 aggregation:\n    │   \"3 tool retries, 1 model switch, 0 scope drifts\"\n    │\n    └── Emit task_completed event → Maggy dashboard\n```\n\n#### Level 2 — Daily (Operational, Hours)\n\nRuns on a daily schedule (or triggered when a threshold is breached). Catches degradation before it compounds. This is the **operational health loop**.\n\n```\nDaily aggregation job:\n    │\n    ├── CI pass rate today vs 7-day average\n    │   → If dropped >10%: disable the model causing failures\n    │\n    ├── Review rounds today vs 7-day average\n    │   → If increased: check which code patterns are new\n    │\n    ├── CodeRabbit critical findings today\n    │   → If >0 on Maggy-written code: add pattern to pre-check\n    │\n    ├── Model failure rate by tier\n    │   → If a model's L0 failure signals spike: demote it\n    │\n    ├── Token budget burn rate\n    │   → If burning faster than expected: adjust routing to cheaper tier\n    │\n    └── Emergency trigger: if any metric drops >15% in one day\n        → Halt exploration, revert last policy change, alert\n```\n\n**Why L2 exists separately from L3:** A weekly batch can't catch a model that started failing on Tuesday. By Friday, that's 3 days of degraded tasks, 3 days of negative rewards accumulating. L2's daily check catches it within hours and disables the failing model before the damage compounds.\n\n#### Level 3 — Weekly (Strategic, Days)\n\nThe deliberate optimization loop. Analyzes patterns across the week, proposes and applies policy changes with rollback windows. This is where skill evolution, workflow step changes, and MCP Forge generation happen.\n\n```\nWeekly strategic analysis:\n    │\n    ├── Worst 10 tasks this week: what went wrong?\n    │   → Common patterns → skill file patches\n    │   → Recurring reviewer comments → add to review prevention\n    │\n    ├── Best 10 tasks this week: what went right?\n    │   → Reinforce: model, workflow, blast tier settings\n    │\n    ├── Score deltas from last week's modifications\n    │   → delta < -0.2: auto-revert\n    │   → delta > +0.2: reinforce + expand to similar task types\n    │\n    ├── Process pattern analysis\n    │   → New (code_pattern, review_feedback) entries\n    │   → PR sizing effectiveness\n    │   → CI failure patterns\n    │\n    ├── Capability gap analysis\n    │   → Top unresolvable requests → trigger MCP Forge\n    │\n    └── Exploration candidates\n        → Select 10% of low-blast task types for next week's exploration\n```\n\n#### Level 4 — Monthly (Evolutionary, Weeks)\n\nThe meta-optimization loop. Evaluates whether the control system itself is improving. Changes the reward signals, recalibrates tier boundaries, adjusts exploration rates. This is the loop that improves the improvement process.\n\n```\nMonthly evolution review:\n    │\n    ├── Cross-project patterns\n    │   → Are skills learned in project A useful in project B?\n    │   → Promote project-specific skills to global skills\n    │\n    ├── Reward signal effectiveness\n    │   → Is any signal consistently noisy? Reduce its weight\n    │   → Is a new signal needed? (e.g., deploy success rate)\n    │   → Add, remove, or reweight signals\n    │\n    ├── Tier boundary recalibration\n    │   → If blast 4-6 tasks are consistently handled well by\n    │     the cheap tier, lower the threshold: 0-4 = cheap\n    │   → If blast 3 tasks keep failing on cheap models,\n    │     raise it: 0-2 = cheap, 3+ = medium\n    │\n    ├── Exploration rate adjustment\n    │   → If exploration success rate > 40%: increase to 15%\n    │   → If exploration success rate < 10%: decrease to 5%\n    │\n    ├── Control loop tuning\n    │   → Is L2 catching issues that should be caught at L0?\n    │   → Are L0 model switches too aggressive or too cautious?\n    │   → Adjust L0 thresholds based on L1 outcome data\n    │\n    └── Platform trajectory\n        → Efficiency trend: improving, flat, or declining?\n        → If flat for 2+ months: the system has saturated\n          current strategy — try structural change\n```\n\n#### Signal Cascade — How Levels Feed Each Other\n\n```\n┌──────────────────────────────────────────────────────────────┐\n│  SIGNAL CASCADE                                               │\n│                                                               │\n│  L0: tool_fail, test_fail, lint_error, model_switch           │\n│   │  (raw events, seconds)                                    │\n│   ▼                                                           │\n│  L1: task_reward = f(L0_signals)                              │\n│   │  model_score[claude, auth, 8] += task_reward              │\n│   │  (per-task aggregation, minutes)                          │\n│   ▼                                                           │\n│  L2: daily_ci_rate = mean(L1.ci_pass for today)               │\n│   │  daily_model_health[claude] = mean(L1.rewards for claude) │\n│   │  (daily aggregation, hours)                               │\n│   │  ACTION: disable model if health < threshold              │\n│   ▼                                                           │\n│  L3: weekly_pattern = cluster(L2.failures + L1.review_comments│\n│   │  score_delta = this_week.reward - last_week.reward        │\n│   │  (weekly analysis, days)                                  │\n│   │  ACTION: evolve skills, adjust routing, trigger Forge     │\n│   ▼                                                           │\n│  L4: monthly_trajectory = trend(L3.score_deltas)              │\n│      reward_signal_weights = recalibrate(L3.signal_noise)     │\n│      (monthly meta-analysis, weeks)                           │\n│      ACTION: change reward function itself, adjust L0-L3      │\n│                                                               │\n│  Key: outer loops NEVER override inner loop stability.        │\n│  L3 can change routing policy, but L0 still catches in-task   │\n│  failures regardless of what L3 decided.                      │\n└──────────────────────────────────────────────────────────────┘\n```\n\n### What Gets Optimized (and How)\n\n#### 1. Model Routing\n\nMaggy tracks reward per `(model × task_type × blast_tier)` triple:\n\n```\nreward_table:\n  (claude, auth, high):      +0.92  ← claude is great at auth\n  (claude, docs, low):       +0.40  ← claude works but wasteful\n  (qwen, docs, low):         +0.85  ← qwen is faster + free\n  (qwen, auth, medium):      -0.30  ← qwen failed auth tasks\n  (gpt-4o, frontend, medium):+0.78  ← gpt-4o is strong on frontend\n  (kimi, tests, low):        +0.70  ← kimi writes good tests cheaply\n```\n\nMaggy routes new tasks to the model with the highest reward for that `(task_type, blast_tier)`. No human in the loop — the reward table decides.\n\nIf a model has no data for a task type, Maggy uses the tier default (hardcoded) until it collects 30+ data points.\n\n#### 2. Inbox Ordering\n\nInbox priority is a weighted score that Maggy continuously adjusts:\n\n```python\npriority = (\n    w_urgency * urgency_score\n    + w_okr * okr_alignment\n    + w_recency * recency\n    + w_type * type_weight[ticket.type]\n    + w_project * project_weight[ticket.project]\n)\n```\n\nThe weights (`w_urgency`, `w_okr`, etc.) are updated based on which tickets the user actually executes first. If the user consistently picks security tickets despite Maggy ranking them 5th, the type weight for security increases automatically. Not because Maggy asked — because the reward signal said \"user overrode my ranking\" (-0.2) and Maggy's adjustment brought the ranking closer to what the user actually does.\n\n#### 3. Workflow Steps\n\nSome workflow steps add value, some don't. Maggy measures reward per step:\n\n```\nworkflow_rewards:\n  codex_counter_check:\n    blast_0_3: -0.1    # adds latency, never catches issues\n    blast_4_6: +0.2    # catches real issues sometimes\n    blast_7_10: +0.6   # catches critical issues often\n\n  icpg_drift_check:\n    all_tiers: +0.4    # consistently prevents regressions\n\n  high_tier_post_review:\n    after_qwen: +0.7   # catches qwen mistakes frequently\n    after_kimi: +0.3   # kimi output is cleaner, fewer catches\n    after_claude: +0.0  # reviewing claude with claude is redundant\n```\n\nMaggy skips steps with consistently negative reward. No permission needed — if Codex counter-check never catches issues on blast < 3, it gets dropped from that tier. If it starts catching issues again (maybe the codebase grew more complex), the reward changes and it gets re-enabled.\n\n#### 4. Fatigue Thresholds\n\nDifferent users fatigue differently. Maggy learns the user's fatigue curve:\n\n```\nfatigue_profile:\n  avg_productive_session_minutes: 47\n  pre_checkpoint_optimal_minutes: 42\n  model_switch_recovery_reads: 3.2    # avg re-reads after switch\n  best_model_for_recovery: gpt-4o    # fastest context rebuild\n```\n\nMaggy pre-checkpoints at 42 minutes (not at the generic 0.60 threshold) because it learned this user's fatigue pattern. No question asked — the reward signal showed that checkpoints at 42 minutes led to better post-checkpoint output (+0.3 reward) than checkpoints at 50 minutes (-0.2 reward from quality drop).\n\n#### 5. Process Intelligence — Learning from the Full SDLC\n\nMaggy doesn't just optimize code output. It optimizes the **entire development process** by observing what happens to code after it's written: PR reviews, CI results, CodeRabbit findings, reviewer feedback, merge patterns, and post-deploy incidents.\n\n##### 5a. Environment Discovery\n\nOn first run per project, Maggy auto-discovers the developer's workflow. No configuration — it reads what's already there.\n\n```\n┌──────────────────────────────────────────────────────────────┐\n│  ENVIRONMENT DISCOVERY (auto, per project)                     │\n│                                                               │\n│  Ticketing:                                                   │\n│    gh api repos/{owner}/{repo}/issues → GitHub Issues?        │\n│    .asana.yml / .linear/* / jira.config → which tracker?      │\n│    Maggy Inbox providers config → already connected?          │\n│                                                               │\n│  GitHub Integrations:                                         │\n│    gh api repos/{owner}/{repo}/hooks → webhooks               │\n│    gh api repos/{owner}/{repo}/installation → GitHub Apps     │\n│    PR comment authors → detect bots: coderabbitai[bot],       │\n│      dependabot[bot], renovate[bot], github-actions[bot]      │\n│                                                               │\n│  CI/CD:                                                       │\n│    .github/workflows/*.yml → GitHub Actions                   │\n│    Jenkinsfile / .circleci/ / .gitlab-ci.yml → other CI       │\n│    gh api repos/{owner}/{repo}/actions/runs → run history     │\n│                                                               │\n│  Code Quality:                                                │\n│    .eslintrc* / ruff.toml / .prettierrc → lint config         │\n│    mypy.ini / tsconfig.json → type checking                   │\n│    .pre-commit-config.yaml → pre-commit hooks                 │\n│    codecov.yml / .nycrc → coverage config                     │\n│                                                               │\n│  Review Process:                                              │\n│    gh api repos/{owner}/{repo}/branches/{b}/protection        │\n│      → required reviewers, status checks, merge rules         │\n│    CODEOWNERS → who reviews what                              │\n│    Average PR review rounds from git history                   │\n│                                                               │\n│  Output: ~/.maggy/environments/{project}.yaml                 │\n└──────────────────────────────────────────────────────────────┘\n```\n\n```yaml\n# ~/.maggy/environments/zensurveys-backend.yaml (auto-generated)\nticketing: github_issues\ngithub_integrations:\n  - coderabbitai        # CodeRabbit AI reviews\n  - dependabot          # dependency updates\n  - vercel              # preview deploys\nci:\n  provider: github_actions\n  workflows:\n    - test.yml          # pytest + coverage\n    - lint.yml          # ruff + mypy\n    - deploy.yml        # staging deploy\nlint:\n  python: [ruff, mypy]\n  config_files: [ruff.toml, mypy.ini]\nreview:\n  required_approvals: 1\n  codeowners: true\n  branch_protection: staging-v2\n```\n\n##### 5b. Process Signal Collection\n\nMaggy subscribes to signals from every stage of the SDLC pipeline:\n\n```\n┌─────────────────────────────────────────────────────────────┐\n│  PROCESS SIGNALS (collected per PR / per task)                │\n│                                                              │\n│  ┌─── REVIEW SIGNALS ────────────────────────────────────┐  │\n│  │                                                        │  │\n│  │  PR reviewer comments (human)                         │  │\n│  │    → \"missing error handling in /api/surveys\"          │  │\n│  │    → \"this should be a transaction\"                    │  │\n│  │    → \"add tests for edge case\"                         │  │\n│  │                                                        │  │\n│  │  CodeRabbit findings (automated)                      │  │\n│  │    → severity: critical/warning/suggestion             │  │\n│  │    → category: security/performance/style/bug          │  │\n│  │    → file + line + specific suggestion                 │  │\n│  │                                                        │  │\n│  │  Review rounds                                        │  │\n│  │    → PR needed 3 rounds before approval                │  │\n│  │    → First round had 8 comments, second had 2          │  │\n│  │                                                        │  │\n│  └────────────────────────────────────────────────────────┘  │\n│                                                              │\n│  ┌─── CI SIGNALS ────────────────────────────────────────┐  │\n│  │                                                        │  │\n│  │  GitHub Actions results                               │  │\n│  │    → test.yml: PASS (42s)                              │  │\n│  │    → lint.yml: FAIL — ruff: 3 errors, mypy: 1 error   │  │\n│  │    → deploy.yml: PASS (preview URL generated)          │  │\n│  │                                                        │  │\n│  │  Failure patterns                                     │  │\n│  │    → lint failures in files Maggy touched              │  │\n│  │    → test failures from code Maggy wrote               │  │\n│  │    → flaky tests (pass/fail on same code)              │  │\n│  │                                                        │  │\n│  └────────────────────────────────────────────────────────┘  │\n│                                                              │\n│  ┌─── POST-MERGE SIGNALS ────────────────────────────────┐  │\n│  │                                                        │  │\n│  │  Revert within 48h → code was bad                     │  │\n│  │  Hotfix within 7d  → code had latent bug              │  │\n│  │  Incident linked to PR → production impact            │  │\n│  │  Dependency alert (Dependabot/Renovate) → stale deps  │  │\n│  │                                                        │  │\n│  └────────────────────────────────────────────────────────┘  │\n└─────────────────────────────────────────────────────────────┘\n```\n\nNew reward signals for the registry:\n\n```\nPROCESS REWARD SIGNALS\n\n+0.5  PR approved on first review round\n+0.3  CI passes on first push (no re-push needed)\n+0.2  CodeRabbit: zero critical/warning findings\n+0.1  PR merged within 24h of creation\n\n-0.8  PR reverted within 48h\n-0.5  CI fails on Maggy-written code (lint or test)\n-0.4  CodeRabbit critical finding on Maggy-written code\n-0.3  PR requires 3+ review rounds\n-0.2  Reviewer flags same issue type Maggy was warned about before\n-0.1  CodeRabbit warning finding on Maggy-written code\n```\n\n##### 5c. Process Learning\n\nMaggy tracks patterns across three dimensions:\n\n**Code Pattern → Review Feedback:**\n```\nprocess_patterns.db:\n\n(api_route, missing_error_handling):\n  occurrences: 7\n  reviewers: [\"alice\", \"coderabbitai\"]\n  fix_pattern: \"add try/except with proper HTTP error codes\"\n  → LEARNED: always add error handling to API routes\n\n(database_query, missing_transaction):\n  occurrences: 4\n  reviewers: [\"bob\"]\n  fix_pattern: \"wrap multi-table writes in transaction\"\n  → LEARNED: multi-table writes need transactions\n\n(test_file, missing_edge_case):\n  occurrences: 12\n  reviewers: [\"alice\", \"bob\", \"coderabbitai\"]\n  fix_pattern: \"test empty input, null, boundary values\"\n  → LEARNED: always test edge cases (empty, null, boundary)\n```\n\n**File → CI Failure:**\n```\nci_patterns.db:\n\nsrc/api/surveys.py:\n  lint_failures: 5 (ruff E501, E741)\n  type_errors: 2 (mypy: missing return type)\n  → LEARNED: this file needs strict lint pre-check\n\ntests/test_integration.py:\n  flaky_rate: 0.15 (fails 15% of runs on same code)\n  → LEARNED: mark as flaky, don't block on single failure\n\nsrc/services/auth.py:\n  ci_failures: 0 in 30 days\n  → LEARNED: auth code is well-tested, low CI risk\n```\n\n**PR Characteristics → Merge Velocity:**\n```\npr_patterns.db:\n\n(size < 200 lines, single_concern):\n  avg_review_rounds: 1.2\n  avg_time_to_merge: 4h\n  → LEARNED: small focused PRs merge fast\n\n(size > 500 lines, multi_concern):\n  avg_review_rounds: 3.1\n  avg_time_to_merge: 48h\n  → LEARNED: split large PRs into stacked PRs\n\n(has_tests, covers_new_code):\n  approval_rate_first_round: 0.78\n  → LEARNED: tests increase first-round approval\n\n(no_tests, new_feature):\n  reviewer_comment_rate: 0.95\n  most_common: \"please add tests\"\n  → LEARNED: never submit new features without tests\n```\n\n##### 5d. Process Optimization — What Maggy Changes\n\nBased on learned patterns, Maggy autonomously adjusts its own behavior:\n\n| What Changes | Based On | Example |\n|-------------|---------|---------|\n| **Pre-task lint** | CI failure patterns | Maggy runs `ruff check` + `mypy` on its output before committing — prevents CI failures it has seen before |\n| **Skill evolution** | Recurring review comments | If reviewers flag \"missing error handling\" 7 times, Maggy adds the pattern to its skill files — future code includes error handling by default |\n| **PR sizing** | Merge velocity data | If PRs > 500 lines take 3x longer to merge, Maggy splits tasks into stacked PRs automatically |\n| **Test generation** | Reviewer feedback | If \"add tests\" is the most common review comment, Maggy ensures every PR includes tests for new code |\n| **CodeRabbit pre-check** | CodeRabbit finding patterns | If CodeRabbit consistently flags the same security issue, Maggy pre-validates against that pattern before pushing |\n| **Commit hygiene** | CI config + branch rules | Maggy matches commit message format, branch naming, and PR template to whatever the project enforces |\n\n```yaml\n# Added to ~/.maggy/policy.yaml\nprocess:\n  pre_commit_checks:\n    ruff: true                     # learned: lint failures cost -0.5\n    mypy: true                     # learned: type errors caught by CI\n    test_coverage_min: 80          # learned: PRs without coverage get rejected\n  pr_strategy:\n    max_lines: 400                 # learned: optimal size for this team\n    stacked_prs: true              # learned: large changes split = faster merge\n    require_tests: true            # learned: \"add tests\" is #1 review comment\n  review_prevention:\n    error_handling_api_routes: true # learned from 7 review comments\n    transaction_multi_writes: true # learned from 4 review comments\n    edge_case_tests: true          # learned from 12 review comments\n  coderabbit_precheck:\n    security_scan: true            # learned: CodeRabbit catches these\n    unused_imports: true           # learned: CodeRabbit flags these\n```\n\n##### 5e. The Process Intelligence Flywheel\n\n```\n┌──────────────────────────────────────────────────────────────┐\n│  PROCESS INTELLIGENCE FLYWHEEL                                │\n│                                                               │\n│  Week 1: Maggy discovers environment, starts collecting       │\n│    → Sees 5 lint failures, 3 \"add tests\" comments             │\n│    → Learns: run lint before push, always include tests       │\n│                                                               │\n│  Week 2: Maggy applies learned patterns                       │\n│    → Lint failures drop to 0 (pre-checked)                    │\n│    → \"Add tests\" comments drop to 1 (edge case missed)        │\n│    → Review rounds drop from 2.8 to 1.6 avg                  │\n│                                                               │\n│  Week 4: Maggy has enough data for deeper patterns            │\n│    → Learns that PRs touching auth need 2 reviewers           │\n│    → Learns that Friday PRs take 2x longer to merge           │\n│    → Starts scheduling auth PRs for Monday-Wednesday          │\n│                                                               │\n│  Week 8: Maggy evolves its own skills                         │\n│    → Writes new lint rules based on recurring review comments │\n│    → Generates pre-commit hooks for patterns that always fail │\n│    → Review round avg: 1.1 (down from 2.8)                   │\n│    → CI first-pass rate: 97% (up from 72%)                    │\n│    → Time-to-merge: 6h avg (down from 36h)                    │\n│                                                               │\n│  The wow: Maggy didn't just write better code.                │\n│  It made the entire development process faster.               │\n└──────────────────────────────────────────────────────────────┘\n```\n\n#### 6. Capability Expansion — MCP Forge Integration\n\nWhen Maggy encounters a capability gap — a workflow integration that doesn't exist — it doesn't stop. It builds one.\n\n**Source:** MCP Forge (`~/Documents/protaige/mcp_forge`) generates TypeScript MCP servers from API documentation.\n\n```\nMaggy task requires Mailchimp subscriber data\n    │\n    ├── search existing MCP tools → no Mailchimp tool found\n    │\n    ├── Forge: search registry (500+ APIs) → Mailchimp API found\n    │\n    ├── Forge: generate MCP server\n    │   → TypeScript MCP server with validated tool schemas\n    │   → Tools: list_segments, get_subscribers, campaign_stats\n    │\n    ├── Register new tools with Pi agent's MCP config\n    │\n    ├── Execute original task using new tools\n    │\n    └── Reward signal: did it work?\n        → +1.0: task completed with new tool\n        → -0.5: tool generated but failed at runtime\n```\n\n**Weekly gap analysis:**\n```\ncapability_gaps.db:\n\nThis week's unresolvable requests:\n  \"check Linear sprint progress\"    → 8 occurrences\n  \"pull Slack channel activity\"     → 5 occurrences\n  \"get Figma design specs\"          → 3 occurrences\n\nTop 3 gaps → trigger Forge generation:\n  1. Linear MCP server (sprint, issues, labels)\n  2. Slack MCP server (channels, messages, threads)\n  3. Figma MCP server (files, components, comments)\n\nAfter generation: capability surface grows autonomously.\nHibernation policy: tools with < 3 uses in 14 days → disabled.\n```\n\n### Self-Evaluation\n\nMaggy evaluates its own optimization quality on a weekly cycle:\n\n```\n┌──────────────────────────────────────────────────────────┐\n│ MAGGY SELF-EVALUATION (weekly)                            │\n│                                                           │\n│ Efficiency trend:                                         │\n│   Week 1: 2.3 tickets/day, 0.92 quality multiplier       │\n│   Week 2: 2.7 tickets/day, 0.94 quality multiplier  ↑    │\n│   Week 3: 3.1 tickets/day, 0.91 quality multiplier  ↑↓   │\n│   Week 4: 3.0 tickets/day, 0.95 quality multiplier  →↑   │\n│                                                           │\n│ Adjustments this week: 6                                  │\n│   ✓ Promoted kimi for test-writing (reward +0.7)          │\n│   ✓ Dropped codex review for blast < 3 (reward +0.1)     │\n│   ✗ Tried qwen for API routes — auto-rolled back         │\n│     (reward -0.4, 2 bug escapes detected at day 12)      │\n│   ✓ Pre-checkpoint moved to 40min (reward +0.3)          │\n│   ✓ Added error handling to API routes (review feedback)  │\n│   ✓ Enabled ruff pre-check (CI failure prevention)        │\n│                                                           │\n│ Process intelligence:                                     │\n│   CI first-pass rate: 94% (up from 72% at week 1)        │\n│   Review rounds avg: 1.3 (down from 2.8 at week 1)       │\n│   CodeRabbit critical findings: 0 (down from 4 at week 1)│\n│   Capability gaps filled: 2 (Linear, Slack via Forge)     │\n│                                                           │\n│ Auto-rollbacks this week: 1                               │\n│   qwen for API routes: reverted to kimi after 3 failures  │\n│                                                           │\n│ Overall efficiency delta: +18% vs 4 weeks ago             │\n└──────────────────────────────────────────────────────────┘\n```\n\nWhen an adjustment makes things worse, Maggy doesn't wait for the user to notice. It detects the reward drop and **auto-rolls back**. When an adjustment works, it reinforces and looks for similar task types to expand to.\n\n### Exploration vs Exploitation\n\nMaggy needs to try new things (exploration) while mostly doing what works (exploitation):\n\n```\nexploration_rate: 0.10  # 10% of tasks try a new model/workflow\n                        # 90% use the current best policy\n\nexploration_rules:\n  - Never explore on blast >= 7 (too risky)\n  - Never explore on security/concurrency tasks\n  - Explore on docs, tests, low-blast refactors (low cost of failure)\n  - If exploration succeeds 3x in a row, promote to exploitation\n  - If exploration fails 2x in a row, abandon and try different hypothesis\n```\n\n### Storage\n\n```\n~/.maggy/\n  reward_registry.db      # SQLite: (action, context, reward, timestamp)\n  model_scores.db         # SQLite: (model, task_type, blast_tier, reward_avg, n_samples)\n  workflow_scores.db      # SQLite: (workflow_step, tier, reward_avg, n_samples)\n  process_patterns.db     # SQLite: (code_pattern, review_feedback, occurrences, fix_pattern)\n  ci_patterns.db          # SQLite: (file, failure_type, count, flaky_rate)\n  pr_patterns.db          # SQLite: (size_bucket, concern_count, avg_rounds, avg_merge_time)\n  capability_gaps.db      # SQLite: (request_type, occurrences, forge_status, tool_name)\n  improvement_ledger.db   # SQLite: all self-modifications with config snapshots + backtesting\n  task_history.db         # SQLite: every task with L0 events, reward, CI/review outcomes\n  fatigue_profile.yaml    # Learned fatigue curve for this user\n  policy.yaml             # Current active policy (model routing, inbox weights, process rules)\n  policy_history/         # Timestamped snapshots for rollback (also in ledger.db)\n  self_eval.jsonl         # Weekly self-evaluation log\n  environments/           # Auto-discovered per-project workflow configs\n  mesh.yaml               # Mesh config (org_key, port, manual peers)\n  mesh_state.db           # SQLite: peer registry, sync timestamps, message log\n  peer_id                 # This instance's stable UUID (generated on install)\n  quarantine.db           # Patterns from peers awaiting local validation\n  engram.db               # SQLite: EngramRecords with namespace, origin, confidence, temporal validity\n  engram_namespaces.yaml  # Per-project namespace config (isolation boundaries)\n  lexon.db                # SQLite: LexonRecords, terminology map entries, personalization data\n  lexon_embeddings/       # Tool registry vector index (multilingual)\n  events.db               # SQLite: append-only Event Spine log (all 8 event types)\n  events_archive/         # Compressed JSONL archives for events older than 90 days\n```\n\n```yaml\n# ~/.maggy/policy.yaml (Maggy-managed, not user-edited)\nversion: 47  # auto-incremented on every policy update\nupdated_at: \"2026-05-10T03:00:00Z\"\n\nmodel_routing:\n  blast_0_3:\n    primary: qwen-local\n    except:\n      api_routes: kimi          # learned: qwen bad at API routes\n      auth: claude              # override: security dimension >= 2\n  blast_4_6:\n    primary: kimi\n    post_review: true           # high-tier spot check on output\n  blast_7_10:\n    primary: claude\n    fallback: gpt-4o\n    counter_check: codex        # dual-model planning\n\ninbox_weights:\n  urgency: 0.30\n  okr_alignment: 0.20\n  recency: 0.15\n  type:\n    security: 1.8\n    bug: 1.2\n    feature: 1.0\n    docs: 0.6\n  project:\n    zensurveys-backend: 1.3     # learned: user prioritizes this project\n    chief-of-staff: 1.0\n    rodcast: 0.8\n\nworkflow:\n  codex_counter_check:\n    enabled_above_blast: 5      # learned: no value below 5\n  pre_checkpoint_minutes: 40    # learned: user's fatigue curve\n  exploration_rate: 0.10\n\nprocess:\n  pre_commit_checks:\n    ruff: true                     # learned: CI catches these\n    mypy: true                     # learned: type errors in CI\n    test_coverage_min: 80          # learned: PRs without coverage rejected\n  pr_strategy:\n    max_lines: 400                 # learned: optimal for this team\n    stacked_prs: true              # learned: faster merge for large changes\n    require_tests: true            # learned: #1 review comment is \"add tests\"\n  review_prevention:               # patterns learned from reviewer feedback\n    error_handling_api_routes: true\n    transaction_multi_writes: true\n    edge_case_tests: true\n  coderabbit_precheck:             # patterns learned from CodeRabbit\n    security_scan: true\n    unused_imports: true\n  scheduling:\n    avoid_friday_auth_prs: true    # learned: Friday auth PRs take 2x to merge\n  forge:\n    auto_expand: true              # generate new MCP tools for capability gaps\n    hibernation_days: 14           # disable unused forge tools after 14 days\n    min_gap_requests: 5            # require 5+ requests before triggering forge\n```\n\n### Optimization Targets Mapped to Control Levels\n\nEach optimization target from Sections 1-6 now maps to a specific control level:\n\n| Target | L0 (seconds) | L1 (minutes) | L2 (hours) | L3 (days) | L4 (weeks) |\n|--------|:---:|:---:|:---:|:---:|:---:|\n| **1. Model routing** | Switch on failure/fatigue | Update (model,task,tier) score | Disable failing model | Adjust tier boundaries | Recalibrate blast→tier map |\n| **2. Inbox ordering** | — | — | — | Adjust type/project weights | Reweight signals |\n| **3. Workflow steps** | — | Log step value for task | — | Enable/disable steps by tier | Add/remove signal types |\n| **4. Fatigue** | Checkpoint on threshold | Update fatigue profile | — | Adjust checkpoint timing | Tune L0 thresholds |\n| **5. Process intelligence** | Lint before commit | Log CI/review signals | Toggle pre-checks | Evolve skills from patterns | Recalibrate process signals |\n| **6. Capability expansion** | — | Log capability gap | — | Forge top 3 gaps | Prune/archive unused tools |\n\n**L0 handles stability** (don't let a task fail). **L1-L2 handle health** (don't let bad patterns accumulate). **L3-L4 handle strategy** (make the system smarter over time).\n\n### Improvement Ledger — Full Auditability + Backtesting\n\nEvery self-modification Maggy makes is recorded in the improvement ledger with full state snapshots. This serves three purposes: auditability (what changed and why), rollback (revert any change), and **backtesting** (would a policy have worked better on historical data?).\n\n#### Ledger Schema\n\n```sql\n-- ~/.maggy/improvement_ledger.db\nCREATE TABLE modifications (\n    id              INTEGER PRIMARY KEY,\n    timestamp       TEXT NOT NULL,\n    control_level   INTEGER NOT NULL,  -- 0-4\n    category        TEXT NOT NULL,     -- model_routing, process, workflow, etc.\n    description     TEXT NOT NULL,     -- human-readable what changed\n    reasoning       TEXT NOT NULL,     -- why the change was made (signal data)\n    config_before   TEXT NOT NULL,     -- full policy.yaml snapshot (JSON)\n    config_after    TEXT NOT NULL,     -- full policy.yaml snapshot (JSON)\n    score_before    REAL,             -- avg reward in measurement window before\n    score_after     REAL,             -- avg reward in measurement window after\n    delta           REAL,             -- score_after - score_before\n    status          TEXT DEFAULT 'active',  -- active, rolled_back, superseded\n    rolled_back_at  TEXT,             -- timestamp if reverted\n    rollback_reason TEXT              -- why it was reverted\n);\n\nCREATE TABLE task_history (\n    id              INTEGER PRIMARY KEY,\n    timestamp       TEXT NOT NULL,\n    project         TEXT NOT NULL,\n    task_type       TEXT NOT NULL,     -- auth, api_route, test, docs, etc.\n    blast_tier      INTEGER NOT NULL,  -- 0-10\n    model_used      TEXT NOT NULL,\n    policy_version  INTEGER NOT NULL,  -- which policy was active\n    l0_events       TEXT NOT NULL,     -- JSON array of L0 signals\n    l1_reward       REAL NOT NULL,     -- computed task reward\n    ci_passed       BOOLEAN,\n    review_rounds   INTEGER,\n    coderabbit_findings INTEGER,\n    time_to_merge_h REAL,\n    reverted        BOOLEAN DEFAULT FALSE,\n    bug_escape      BOOLEAN DEFAULT FALSE\n);\n```\n\n#### Backtesting: \"Would This Policy Have Worked?\"\n\nBefore deploying a L3/L4 policy change, Maggy can **replay historical tasks** against the proposed policy to predict the outcome:\n\n```\n┌──────────────────────────────────────────────────────────────┐\n│  BACKTEST: proposed policy v48 vs current policy v47          │\n│                                                               │\n│  Replaying 200 tasks from last 30 days...                     │\n│                                                               │\n│  Proposed change: route blast 3 tasks to qwen instead of kimi │\n│                                                               │\n│  Historical tasks at blast 3 (n=47):                          │\n│    Under kimi (actual):                                       │\n│      avg reward: +0.62                                        │\n│      CI pass rate: 91%                                        │\n│      review rounds: 1.4                                       │\n│                                                               │\n│    Under qwen (backtest simulation):                          │\n│      predicted reward: +0.38  ← LOWER                         │\n│      predicted CI pass rate: 78%  ← based on qwen's L0 data  │\n│      predicted review rounds: 2.1 ← based on qwen's L1 data  │\n│                                                               │\n│  VERDICT: DO NOT APPLY — backtest predicts -0.24 reward drop  │\n│                                                               │\n│  Alternative explored: route blast 1-2 to qwen, keep 3 on    │\n│  kimi. Backtest on blast 1-2 tasks (n=31):                    │\n│    kimi actual: +0.58                                         │\n│    qwen predicted: +0.71  ← HIGHER (simpler tasks = qwen OK) │\n│                                                               │\n│  VERDICT: APPLY partial — blast 1-2 to qwen, blast 3 stays   │\n└──────────────────────────────────────────────────────────────┘\n```\n\n**How backtesting works:**\n\n1. **Query `task_history`** for all tasks matching the target criteria (e.g., blast tier, task type)\n2. **For each historical task**, look up the proposed model's performance on similar `(task_type, blast_tier)` combinations from `model_scores.db`\n3. **Predict reward** using the proposed model's historical L0 signals (failure rate, lint errors, test pass rate) on similar tasks\n4. **Compare** predicted vs actual reward across the full set\n5. **Decision**: apply if predicted delta > +0.1, reject if < -0.1, flag for exploration if between\n\n**Backtesting is required for L3 and L4 changes.** L0-L2 changes are reactive (stability and health) and don't need backtesting — they respond to immediate signals. L3-L4 changes are strategic and can be validated against historical data first.\n\n#### Auto-Seeding: Maggy Bootstraps Herself\n\nMaggy has Pi agents. She has access to Claude, Codex, Kimi, Qwen — whatever models are configured. There is no reason for a manual `maggy seed` command. The moment a project is registered in `~/.maggy/projects.yaml`, Maggy spawns a Pi agent to analyze the project's history and seed her own databases. No user action required.\n\n```\n┌──────────────────────────────────────────────────────────────┐\n│  AUTO-SEED (triggered on project registration)                │\n│                                                               │\n│  1. Maggy detects new project in registry                     │\n│     │                                                         │\n│  2. Spawns Pi agent (cheapest available model — qwen/kimi)    │\n│     Task: \"Analyze project history and extract patterns\"      │\n│     │                                                         │\n│  3. Agent executes via gh CLI + git log:                      │\n│     │                                                         │\n│     ├── gh pr list --state merged --limit 200 --json          │\n│     │   → PR sizes, review rounds, time-to-merge              │\n│     │   → Reviewers, approval patterns                        │\n│     │                                                         │\n│     ├── gh pr view {n} --comments --json                      │\n│     │   → Review comments categorized by pattern              │\n│     │   → CodeRabbit findings by severity + category          │\n│     │   → Bot authors detected (coderabbitai, dependabot)     │\n│     │                                                         │\n│     ├── gh api repos/{owner}/{repo}/actions/runs              │\n│     │   → CI pass/fail rates per workflow                     │\n│     │   → Failure patterns per file                           │\n│     │   → Flaky test detection                                │\n│     │                                                         │\n│     ├── git log --format='%H %s' --since='6 months ago'       │\n│     │   → Revert detection (commit messages with \"revert\")    │\n│     │   → Commit patterns, branch naming conventions          │\n│     │                                                         │\n│     ├── codebase-memory-mcp: get_architecture + search_graph  │\n│     │   → Module structure, hot files, dependency depth       │\n│     │   → Fan-out scores for initial blast radius calibration │\n│     │                                                         │\n│     └── Environment discovery (Section 5a)                    │\n│         → Ticketing, CI, lint, review process auto-detected   │\n│                                                               │\n│  4. Agent writes structured analysis to Maggy's databases:    │\n│     process_patterns.db: seeded with review comment patterns  │\n│     ci_patterns.db: seeded with CI failure history            │\n│     pr_patterns.db: seeded with merge velocity data           │\n│     task_history.db: synthetic entries from git log           │\n│     environments/{project}.yaml: workflow config              │\n│                                                               │\n│  5. Agent computes initial policy.yaml from patterns:         │\n│     → \"PRs > 400 lines take 3x review rounds → set max 400\"  │\n│     → \"ruff failures in 40% of PRs → enable pre-check\"       │\n│     → \"auth files have 0% CI failures → low risk\"            │\n│     → \"CodeRabbit flags unused imports 60% of PRs → pre-fix\" │\n│                                                               │\n│  6. Maggy logs seed as modification #1 in improvement_ledger  │\n│     config_before: empty (default policy)                     │\n│     config_after: data-derived initial policy                 │\n│     score_before: null (no baseline)                          │\n│     → All future modifications measured against this seed     │\n│                                                               │\n│  Total cost: ~$0.10-0.50 on a cheap model (one-time)          │\n│  Total time: background task, user doesn't wait               │\n│  User action required: zero                                   │\n└──────────────────────────────────────────────────────────────┘\n```\n\n**Why this works:** The seed analysis is exactly the kind of task cheap models are good at — structured data extraction, pattern counting, statistical aggregation. No creative reasoning needed. Qwen local can do it for free. And the Pi agent already has all the tools: `gh` CLI for GitHub data, `git` for history, codebase-memory-mcp for structural analysis.\n\n**Why manual seed is wrong:** Maggy's entire philosophy is autonomous optimization. A `maggy seed --project foo` command implies the user knows they need to seed, knows the right flags, and remembers to run it. That's three failure points. Maggy should behave like a new hire who reads the project's git history on their first day — automatically, without being told.\n\n**Multi-project seed:** When Maggy is first installed with 4 projects in the registry, she spawns 4 seed agents in parallel (one per project, each in its own Polyphony container). All 4 seed concurrently. By the time the user opens the dashboard, Maggy already knows:\n- zensurveys-backend: \"PRs to auth/ need 2 reviewers, ruff fails on 40% of pushes\"\n- zensurveys-frontend: \"CodeRabbit catches unused imports, avg PR is 180 lines\"\n- chief-of-staff: \"No CI, manual deploys, review optional\"\n- rodcast: \"New project, minimal history — start with defaults\"\n\n**Validation before real work:** The seed data lets Maggy prove her value immediately. On the dashboard, day 1:\n\n```\n┌──────────────────────────────────────────────────────────────┐\n│  MAGGY — Day 1 Analysis (auto-generated from project history)│\n│                                                               │\n│  zensurveys-backend (200 PRs analyzed):                       │\n│    Current process health:                                    │\n│      CI first-pass rate: 72%                                  │\n│      Avg review rounds: 2.8                                   │\n│      Top review comment: \"add error handling\" (23 times)      │\n│      Avg time-to-merge: 36h                                   │\n│                                                               │\n│    Predicted improvements if Maggy had been active:           │\n│      CI first-pass rate: 72% → ~94% (pre-lint + pre-type)    │\n│      Review rounds: 2.8 → ~1.4 (auto error handling + tests) │\n│      Time-to-merge: 36h → ~12h (smaller PRs + fewer rounds)  │\n│                                                               │\n│    Based on: patterns from your last 200 PRs                  │\n│    Confidence: high (200+ data points per pattern)            │\n└──────────────────────────────────────────────────────────────┘\n```\n\nThat's the mWp for onboarding. Maggy doesn't say \"configure me.\" She says \"I already analyzed your project. Here's what I found. Here's what I'll fix. Watch.\"\n\n#### Ledger Queries — \"How Did Maggy Improve Itself?\"\n\n```sql\n-- Show all modifications, most recent first\nSELECT timestamp, control_level, category, description, delta, status\nFROM modifications ORDER BY timestamp DESC LIMIT 20;\n\n-- Show rolled-back changes (what went wrong?)\nSELECT timestamp, description, delta, rollback_reason\nFROM modifications WHERE status = 'rolled_back';\n\n-- Show cumulative improvement over time\nSELECT date(timestamp) as day,\n       sum(CASE WHEN delta > 0 THEN delta ELSE 0 END) as positive_delta,\n       sum(CASE WHEN delta < 0 THEN delta ELSE 0 END) as negative_delta,\n       sum(delta) as net_delta\nFROM modifications\nGROUP BY day ORDER BY day;\n\n-- Show which control level produces the most value\nSELECT control_level,\n       count(*) as modifications,\n       avg(delta) as avg_delta,\n       sum(CASE WHEN status = 'rolled_back' THEN 1 ELSE 0 END) as rollbacks\nFROM modifications\nGROUP BY control_level;\n\n-- Backtest: what would policy v48 have scored on last month's tasks?\nSELECT task_type, blast_tier,\n       avg(l1_reward) as actual_reward,\n       count(*) as n_tasks\nFROM task_history\nWHERE policy_version = 47\n  AND timestamp > date('now', '-30 days')\nGROUP BY task_type, blast_tier;\n```\n\n### The Wow Factor\n\nMaggy after 4 weeks:\n\n> \"I didn't configure anything. I didn't set weights. I didn't tell it which model to use for what. It figured out that Claude is best for my auth code, Kimi writes my tests, and Qwen handles docs — by itself. It tried routing API routes to Qwen once, caught that it was producing bugs, and rolled it back before I even noticed. It knows I fatigue at 42 minutes and checkpoints at 40. My throughput is up 30% and my bug escape rate is down. I don't manage Maggy. Maggy manages my development.\"\n\n> \"But the thing that blows me away is the process improvement. Maggy figured out that my team's reviewers always flag missing error handling on API routes — so now it adds error handling by default. It learned that our CI lint step fails on long lines — so it runs ruff before pushing. Our CodeRabbit findings dropped to zero. PRs that used to take 3 review rounds now merge on the first. And when I needed to pull data from Linear, Maggy generated a whole MCP integration on the fly — I didn't even know that was possible. It's not just writing better code. It's making the entire pipeline faster.\"\n\nThat's the mWp. Not a tool. Not an assistant that asks questions. An autonomous system that optimizes itself with one goal: make its human as efficient as possible.\n\n---\n\n## 12. Codex Review Response\n\nCodex (GPT-5.4) reviewed this architecture. Full review: `docs/codex-review-v5.md`. Summary of decisions:\n\n### Accepted\n\n| Finding | Our Response |\n|---------|-------------|\n| Blast radius is overloaded as routing signal | Correct. Updated to use full 5-dimension iCPG scoring (cyclomatic, fan_out, security, concurrency, domain) with dimension overrides for security/concurrency. |\n| Low-tier output needs stronger verification | Added high-tier post-review gate, iCPG constraint assertions, and static analysis for all cheap-model output. |\n| Self-improving loop needs guardrails | Added cold-start thresholds (50+ data points), 30-day decay windows, delayed outcome tracking, audit log, and user-approval for adaptations. |\n| CIKG + iCPG need shared decision schema | Accepted. Will define cross-graph artifact types (Requirement, Decision, Hypothesis, Evidence, Risk, Outcome) in Phase 4. |\n| Observability is missing | Accepted. Adding to Phase 8: structured event log for agent decisions, bridge translations, model switches, and tool actions. |\n| Model switching should be explicit handoff | Updated fallback chain to include checkpoint + verification step before continuing on new model. |\n\n### Rejected (Codex was wrong on these)\n\n| Codex's Claim | Why We Disagree |\n|---------|-----|\n| Split-brain control model | Not a split-brain. Mnemos + iCPG provide shared persistent state on disk inside the container. Coordination agent and execution agent own distinct concerns with shared persistence. No duplicated state. |\n| Pi is a dangerous universal dependency | Partially rejected. Pi is the right choice for adapter unification, but we accept the recommendation to keep an internal execution contract and preserve direct adapters as fallback for critical paths. |\n| Browser-container deploy is over-engineered | Rejected for our use case. The user has a specific pain point: 4 projects on Vercel with auth conflicts when using `vercel login` locally. Browser containers solve this directly. API/CLI deploy is the primary path; browser containers solve the auth isolation problem specifically. |\n| Self-improving Maggy is unrealistic | Rejected. Maggy is an autonomous optimization agent, not a suggestion engine. It uses a reward registry with positive/negative signals, auto-rollback on reward drops, exploration/exploitation balance (10% exploration on low-risk tasks only), and weekly self-evaluation. Cold start uses hardcoded defaults until 30+ samples. No user approval needed — the reward function is the judge. |\n\n---\n\n## 13. Open Questions\n\n1. **CIKG extraction scope** — Extract just the graph service, or the full strategy pipeline (daily briefing, trend monitoring)?\n2. **Pi extension authoring** — Do we write custom Pi extensions for iCPG/Mnemos hooks, or keep them as shell scripts?\n3. **Vercel deploy frequency** — On every PR, or manual trigger from Maggy?\n4. **Local model quality floor** — Minimum benchmark Qwen must pass before routing low-blast tasks to it?\n5. **Cross-project dependencies** — codebase-memory-mcp can trace HTTP_CALLS across project graphs. When zensurveys-backend changes a Route, should Maggy auto-create a task in zensurveys-frontend? The graph data is there (36 projects indexed); the question is the automation policy.\n6. **Mesh scope** — Should mesh sync extend beyond same-org? An anonymized marketplace of policies and model benchmarks across orgs could be powerful, but raises privacy/competitive concerns.\n7. **Mesh governance** — Who can promote quarantined patterns to active? Auto-promote after N confirmations, or require an explicit team lead role?\n8. **Remote mesh** — For teams without Tailscale/WireGuard, should Maggy offer a lightweight relay service, or is manual peer list + VPN sufficient?\n9. **Engram promotion threshold** — How many Mnemos confirmations before persisting an EngramRecord? Too low = noise (every transient pattern gets persisted), too high = useful conventions lost between sessions.\n10. **Lexon embedding model** — multilingual-e5-large vs paraphrase-multilingual-mpnet-base-v2? Latency vs accuracy tradeoff for the semantic retriever tier. Also: should the vector index run in-process (SQLite + FAISS) or as a sidecar service?\n11. **Engram + Mesh boundary** — Should EngramRecords be mesh-shareable directly, or keep Engram strictly local (per-machine cross-session) and only share distilled typed memory via Mesh? Direct sharing is more powerful but increases the attack surface for data leakage.\n\n---\n\n## 14. Maggy Mesh — Peer-to-Peer Team Intelligence\n\n### 14.1 The Problem\n\nEach developer runs their own Maggy. Each learns independently: model performance scores, process patterns from CI/PR reviews, workflow optimizations. 5 developers = 5 instances independently discovering the same patterns, making the same mistakes, converging on the same policies — separately. That's 5x the learning cost and 5x the time to reach optimal performance.\n\n| Scenario | Without Mesh | With Mesh |\n|----------|-------------|-----------|\n| Ali discovers \"Qwen bad at API routes\" | Ali knows. Sarah doesn't. | Everyone knows in 15 min. |\n| CI keeps failing on unused imports | Each dev independently adds ruff pre-check | First discovery → team-wide pre-check |\n| New developer joins | Cold start. Learns everything from scratch | Inherits team's proven patterns immediately |\n| PRs > 400 lines get rejected | Each dev discovers independently | Team-wide policy from day one |\n| CodeRabbit flags missing error handling | Each dev gets flagged separately | First dev's fix pattern shared to all |\n\nMaggy Mesh connects instances into a peer-to-peer network where learned intelligence flows between peers — no central server. The collective intelligence of the team accelerates everyone from day one.\n\n### 14.2 Network Topology\n\n```\n┌─────────────────────────────────────────────────────────────────┐\n│  MAGGY MESH                                                      │\n│                                                                  │\n│  Transport: LAN / Tailscale / WireGuard                         │\n│  Discovery: mDNS (_maggy._tcp.local)                            │\n│  Auth: TLS + org_key challenge-response                          │\n│                                                                  │\n│  ┌──────────┐    bidirectional    ┌──────────┐                  │\n│  │ Ali's    │◄── WebSocket ─────►│ Sarah's  │                  │\n│  │ Maggy    │    (TLS)           │ Maggy    │                  │\n│  │          │                     │          │                  │\n│  │ Projects:│    ┌──────────┐    │ Projects:│                  │\n│  │  api     │◄──►│ Tom's    │◄──►│  web     │                  │\n│  │  mobile  │    │ Maggy    │    │  infra   │                  │\n│  └──────────┘    │          │    └──────────┘                  │\n│                  │ Projects:│                                    │\n│       ┌──────────│  ml      │──────────┐                        │\n│       │          │  data    │          │                        │\n│       │          └──────────┘          │                        │\n│       ▼                               ▼                        │\n│  ┌──────────┐                   ┌──────────┐                   │\n│  │ Priya's  │                   │ Chen's   │                   │\n│  │ Maggy    │                   │ Maggy    │                   │\n│  │ (devops) │                   │ (qa,perf)│                   │\n│  └──────────┘                   └──────────┘                   │\n│                                                                  │\n│  Each peer:                                                      │\n│    Dashboard: 127.0.0.1:8080 (local only)                       │\n│    Mesh port: 0.0.0.0:8089 (LAN/VPN)                           │\n│    Full mesh: every peer connects to every other peer           │\n└─────────────────────────────────────────────────────────────────┘\n```\n\n### 14.3 What Gets Shared\n\n**Shared (with provenance):**\n\n| Data Type | Source DB | What Crosses the Wire | Why It's Valuable |\n|-----------|-----------|----------------------|-------------------|\n| Model scores | `model_scores.db` | `(model, task_type, blast_tier) → reward_avg, n_samples` | \"Claude is best for auth code\" applies across repos |\n| Process patterns | `process_patterns.db` | `(code_pattern → fix_pattern, frequency)` | \"Unused imports trigger CodeRabbit\" is universal |\n| CI patterns | `ci_patterns.db` | `(failure_type → remedy, frequency)` | \"ruff line-length fails\" applies everywhere |\n| PR patterns | `pr_patterns.db` | `(size_bucket → avg_rounds, avg_merge_time)` | \"PRs > 400 lines take 2x reviews\" is team-wide |\n| Capability gaps | `capability_gaps.db` | `(request_type, frequency)` | If 3 peers need Linear integration, forge it once |\n| Policy proposals | `policy.yaml` | Model routing rules, process pre-checks | Proven optimizations benefit everyone |\n| Improvement ledger summaries | `improvement_ledger.db` | `(category, delta, status)` aggregates | \"Switching to Kimi for tests saved +0.3 reward\" |\n\n**Never shared:**\n\n| Data | Why Private |\n|------|-------------|\n| API keys / tokens | Security — never leaves the machine |\n| Raw code / PR content / task descriptions | Confidentiality |\n| `~/.maggy/config.yaml` | Per-developer settings |\n| `fatigue_profile.yaml` | Personal cognitive pattern |\n| File paths | Local filesystem |\n| Raw `improvement_ledger.db` entries | Instance-specific, only summaries shared |\n\n### 14.4 Every Memory Has Provenance\n\nEvery piece of shared knowledge carries its origin. This prevents context collapse (\"works in repo A\" wrongly applied to repo B).\n\n```python\n@dataclass\nclass SharedMemory:\n    \"\"\"A unit of shareable knowledge across the mesh.\"\"\"\n    type: str           # \"score\", \"pattern\", \"ci_pattern\", \"pr_pattern\", \"gap\", \"proposal\"\n    key: str            # unique identifier for merge\n    value: dict         # type-specific payload\n    provenance: Provenance\n    status: str         # \"active\", \"quarantine\", \"rejected\"\n\n@dataclass\nclass Provenance:\n    \"\"\"Who produced this, from what evidence, in what context.\"\"\"\n    peer_id: str        # which Maggy instance\n    peer_name: str      # human-readable (e.g. \"ali-macbook\")\n    project_key: str    # which project (not path — just key like \"api\")\n    language: str       # python, typescript, go, etc.\n    toolchain: str      # ruff+mypy, eslint+tsc, etc.\n    created_at: str     # when first observed\n    evidence_count: int # how many observations back this up\n    last_verified: str  # when evidence was last re-checked\n    confidence: float   # 0.0-1.0, decays with age\n```\n\nWhen a peer's pattern arrives:\n- Relevant to my project? Check `language` and `toolchain` match\n- Enough evidence? Check `evidence_count >= min_peer_samples`\n- Fresh enough? Check `last_verified` within `trust_decay_days`\n\nIf all pass → active. If borderline → quarantine. If wrong context → ignored.\n\n### 14.5 Discovery Protocol\n\n**mDNS (zero-config LAN):**\n\n```\nService: _maggy._tcp.local\nTXT records:\n  org=<SHA256(org_key)[:16]>    # only peers with same org connect\n  version=0.1.0                  # mesh protocol version\n  peer_id=<stable-uuid>          # per-install identity\n  name=<hostname>                # human-readable\n  projects=3                     # number of registered projects\n```\n\nPeers with matching `org` hash auto-connect. Different org = ignored.\n\n**For remote teams (not on same LAN):**\n\nTailscale/WireGuard puts everyone on the same virtual network. mDNS works over Tailscale natively — zero additional config.\n\n**Manual fallback:** `~/.maggy/mesh.yaml`:\n\n```yaml\nmesh:\n  enabled: true\n  org_key: \"shared-secret-set-during-maggy-init\"\n  port: 8089\n  name: \"ali-macbook\"\n  peers:\n    # Only needed if mDNS doesn't work\n    - host: 192.168.1.42\n    - host: sarah-laptop.tailnet.ts.net\n    - host: tom-desktop.local\n```\n\n### 14.6 Transport + Auth\n\n**WebSocket over TLS.** Not libp2p (heavyweight Go/Rust dependency, overkill for 3-15 person team). Python's `websockets` library is async, works with FastAPI, and is all we need.\n\n**Connection handshake:**\n\n```\nAli's Maggy                           Sarah's Maggy\n    │                                      │\n    ├─── WSS connect to :8089 ────────────►│\n    │                                      │\n    │◄── challenge: {nonce, peer_id,       │\n    │     org_hash: SHA256(org_key)}        │\n    │                                      │\n    ├─── response: {nonce, peer_id,        │\n    │     hmac: HMAC-SHA256(nonce,org_key)} │\n    │                                      │\n    │◄── verify HMAC, accept ──────────────│\n    │                                      │\n    │◄──────── bidirectional sync ─────────►│\n```\n\nIf `org_hash` doesn't match → connection rejected immediately.\nFirst time seeing a `peer_id` → dashboard notification: \"New peer 'sarah-laptop' connected.\"\n\n### 14.7 Message Protocol\n\n```python\n@dataclass\nclass MeshMessage:\n    type: str           # message type (see table below)\n    peer_id: str        # sender's stable UUID\n    peer_name: str      # human-readable sender name\n    timestamp: str      # ISO 8601\n    payload: dict       # type-specific data\n    signature: str      # HMAC-SHA256(json(payload), org_key)\n```\n\n| Type | Direction | Payload | Trigger |\n|------|-----------|---------|---------|\n| `heartbeat` | broadcast | `{peer_id, projects, uptime, policy_version, patterns_count}` | Every 60s |\n| `score_update` | broadcast | `{model, task_type, blast_tier, reward_delta, n_new_samples}` | L1: after task completion |\n| `pattern_share` | broadcast | `{pattern_key, type, value, provenance}` | When new pattern reaches 5+ local observations |\n| `sync_request` | peer→peer | `{tables: [...], since: timestamp}` | On connect + every 15 min |\n| `sync_response` | peer→peer | `{table, rows: [...]}` | Response to sync_request |\n| `policy_proposal` | broadcast | `{rule, evidence, confidence, backtest_delta}` | L3/L4: when backtest passes |\n| `gap_report` | broadcast | `{gap_type, description, occurrences}` | When capability gap hits threshold |\n| `peer_announce` | broadcast | `{event: \"join\"\\|\"leave\", peer_info}` | On connect/disconnect |\n\n### 14.8 Sync + Merge Algorithm\n\n**Score merge — weighted average by sample count:**\n\n```python\ndef merge_model_score(local: ModelScore, remote: ModelScore) -> ModelScore:\n    \"\"\"More data = higher confidence. Simple, effective, no politics.\"\"\"\n    total = local.n_samples + remote.n_samples\n    return ModelScore(\n        model=local.model,\n        task_type=local.task_type,\n        blast_tier=local.blast_tier,\n        reward_avg=(local.reward_avg * local.n_samples +\n                    remote.reward_avg * remote.n_samples) / total,\n        n_samples=total,\n        updated_at=max(local.updated_at, remote.updated_at),\n    )\n```\n\n**Pattern merge — union with frequency counting:**\n\nIf Ali's Maggy says \"unused imports → ruff fix\" with 23 occurrences and Sarah's says the same with 15, merged = 38 occurrences. Higher frequency = higher confidence = more likely to be auto-applied as a pre-check.\n\n**Policy merge — NEVER auto-applied:**\n\nPolicy proposals go into a queue. Before activation:\n1. Backtest against local `task_history.db` (does this policy improve *my* projects?)\n2. If backtest delta > +0.1 → auto-apply with rollback guard\n3. If backtest delta between -0.1 and +0.1 → queue for exploration (try on 10% of tasks)\n4. If backtest delta < -0.1 → reject (notify peer: \"Your proposal doesn't work for my projects\")\n\n**Conflict resolution:** Higher sample count wins. If my 200-sample score says \"Kimi is better for API routes\" and a peer's 8-sample score disagrees, the 200-sample data dominates. This naturally solves cold start: new team members absorb collective knowledge immediately without their sparse data overriding established patterns.\n\n### 14.9 Quarantine System\n\nPatterns from peers don't become active blindly. New incoming patterns start in quarantine:\n\n```\nincoming pattern\n    │\n    ├── language/toolchain matches my projects?\n    │   ├── NO → ignore (eslint patterns for Python project = useless)\n    │   └── YES ↓\n    │\n    ├── evidence_count >= min_peer_samples (default 10)?\n    │   ├── NO → ignore (too little evidence)\n    │   └── YES ↓\n    │\n    ├── contradicts my local data?\n    │   ├── YES → reject (my 200 samples say otherwise)\n    │   └── NO ↓\n    │\n    └── QUARANTINE\n        │\n        ├── Self-confirmed: I observe the same pattern locally → ACTIVE\n        ├── Crowd-confirmed: 3+ peers report same pattern → ACTIVE\n        ├── Time-expired: 30 days without confirmation → DROPPED\n        └── Human override: user clicks Accept/Reject in dashboard\n```\n\n**Poisoning defense:** If a peer suddenly sends data that contradicts 5+ other peers, or sends 10x normal volume, flag as suspicious. Don't merge. Dashboard shows: \"⚠ Anomalous data from tom-desktop — 47 patterns contradict team consensus.\"\n\n### 14.10 Integration with Self-Improvement Loops\n\nMesh plugs into the existing 5-level closed-loop control system:\n\n| Control Level | Without Mesh | With Mesh |\n|---------------|-------------|-----------|\n| **L0** (seconds) | React to own task failures | Same — L0 is too fast for network |\n| **L1** (minutes) | Update own model/task scores | + Broadcast `score_update` to peers |\n| **L2** (hours) | Check own daily health | + Merge peer scores; promote/drop quarantine |\n| **L3** (days) | Optimize own policy | + Backtest against **team-wide data** (higher N = better backtest) |\n| **L4** (weeks) | Recalibrate own signals | + Propose cross-team policy changes; vote on peer proposals |\n\nThe mesh makes L3/L4 decisions **dramatically more reliable** because backtesting draws from the team's combined `task_history` (500+ tasks) instead of just one developer's (100 tasks). More data → better predictions → fewer rollbacks.\n\n### 14.11 Cold Start — New Developer Joins the Team\n\n```\n1. Developer installs Maggy, runs /maggy-init\n   → Sets org_key (same as team)\n   → Generates peer_id\n   → Auto-seed runs on their projects (Section 11)\n\n2. Maggy starts, announces on mDNS (_maggy._tcp.local)\n   → Discovers 4 peers on the mesh\n\n3. Full sync: sends sync_request{tables: all, since: epoch}\n   → Receives: 500+ model scores, 200+ process patterns, 150+ CI patterns\n   → All incoming data → quarantine (except scores, which auto-merge)\n\n4. As new developer works their first tasks:\n   → Local observations match quarantined patterns → auto-promote\n   → \"Ah, ruff catches unused imports here too\" → promoted to active\n   → \"Qwen is bad at API routes? Let me try...\" → confirmed → active\n\n5. Dashboard after day 1:\n   ┌──────────────────────────────────────────────────────────────┐\n   │  MESH — New Member Onboarding                                │\n   │                                                               │\n   │  Connected to: 4 peers (Protaigé org)                        │\n   │  Inherited: 847 patterns                                      │\n   │    Active: 312 (self-confirmed or crowd-confirmed)            │\n   │    Quarantine: 535 (awaiting local validation)                │\n   │                                                               │\n   │  Model routing: inherited team-wide scores                    │\n   │    → Claude for auth (team avg: +0.82, n=89)                 │\n   │    → Kimi for tests (team avg: +0.71, n=134)                 │\n   │    → Qwen for docs (team avg: +0.65, n=67)                   │\n   │                                                               │\n   │  Top patterns auto-promoted today:                            │\n   │    ✓ \"ruff pre-check eliminates 40% of CI failures\" (5 peers)│\n   │    ✓ \"PRs > 400 lines → split\" (4 peers, 200+ observations) │\n   │    ✓ \"mypy strict mode catches type bugs\" (3 peers)          │\n   └──────────────────────────────────────────────────────────────┘\n```\n\nThe new developer's Maggy doesn't start from zero. It starts with the collective intelligence of the team. No ramp-up period. No re-learning.\n\n### 14.12 Dashboard — Mesh Tab\n\n```\n┌──────────────────────────────────────────────────────────────────┐\n│  MESH                                                             │\n│                                                                   │\n│  Peers: 4 connected  │  Last sync: 2 min ago  │  Org: Protaigé  │\n│                                                                   │\n│  ┌─ Ali ──────────────── ● online ───────────────────────────────┐│\n│  │ Projects: api, mobile  │  Policy v47  │  312 active patterns  ││\n│  │ Last contribution: \"Route blast 1-2 to qwen\" (+0.18 delta)   ││\n│  └───────────────────────────────────────────────────────────────┘│\n│  ┌─ Sarah ────────────── ● online ───────────────────────────────┐│\n│  │ Projects: web, infra   │  Policy v31  │  189 active patterns  ││\n│  │ Last contribution: \"mypy pre-check on all Python\" (+0.22)    ││\n│  └───────────────────────────────────────────────────────────────┘│\n│  ┌─ Tom ─────────────── ● online ────────────────────────────────┐│\n│  │ Projects: ml, data     │  Policy v22  │  156 active patterns  ││\n│  │ Last contribution: \"Gemini Flash for data pipeline tasks\"     ││\n│  └───────────────────────────────────────────────────────────────┘│\n│  ┌─ Priya ──────────── ○ offline (2h) ──────────────────────────┐│\n│  │ Projects: devops       │  Policy v18  │  98 active patterns   ││\n│  │ Will sync on reconnect                                        ││\n│  └───────────────────────────────────────────────────────────────┘│\n│                                                                   │\n│  ── Policy Proposals (2) ─────────────────────────────────────── │\n│  │ \"Route blast 1-2 to qwen\"                                    │\n│  │   From: Ali  │  Evidence: 31 tasks  │  Backtest: +0.18       │\n│  │   Status: auto-applied (delta > +0.1)                        │\n│  │                                                               │\n│  │ \"Add security scan pre-commit for auth files\"                 │\n│  │   From: Sarah  │  Evidence: 12 PRs flagged  │  Backtest: +0.31│\n│  │   Status: applied on 3/4 peers, pending on Priya (offline)   │\n│  └───────────────────────────────────────────────────────────────┘│\n│                                                                   │\n│  ── Team Intelligence Summary ────────────────────────────────── │\n│  │ Total team patterns: 847 unique                               │\n│  │ Total team task history: 523 tasks across 4 peers             │\n│  │ Team-wide CI first-pass rate: 91% (up from 72% pre-Maggy)    │\n│  │ Team-wide avg review rounds: 1.3 (down from 2.8 pre-Maggy)   │\n│  │ Collective model ranking:                                     │\n│  │   #1 Claude (auth, security, complex) — avg +0.82            │\n│  │   #2 Kimi (tests, API routes, medium) — avg +0.71            │\n│  │   #3 Gemini Flash (data, pipeline) — avg +0.68               │\n│  │   #4 Qwen (docs, config, simple) — avg +0.65                 │\n│  └───────────────────────────────────────────────────────────────┘│\n└──────────────────────────────────────────────────────────────────┘\n```\n\n### 14.13 The Compound Effect\n\nWeek 1: 5 Maggy instances learn independently. Each discovers ~20 patterns.\n\nWeek 4 (without mesh): Each has ~80 patterns. Significant overlap. Total unique knowledge: ~150 patterns across the org, but no individual has more than 80.\n\nWeek 4 (with mesh): Each has ~150 patterns (the full team set). Total unique knowledge: ~150. But every individual has access to all of it. The team is 2x more optimized than any individual would be alone.\n\nWeek 12 (with mesh): The compound effect kicks in. Each new discovery is immediately tested across 5 different project contexts. Patterns that work everywhere get high confidence fast. Patterns that are project-specific get properly scoped. The collective model ranking has 500+ data points per model — more reliable than any benchmark.\n\n```\nWithout mesh:  knowledge = n_developers × learning_rate × time\nWith mesh:     knowledge = n_developers × learning_rate × time × sharing_factor\n               where sharing_factor ≈ n_developers (superlinear)\n```\n\nEach developer's Maggy becomes as smart as the entire team. The team doesn't just add knowledge linearly — it multiplies it. This is the network effect applied to AI engineering intelligence.\n\n### 14.14 Security Model\n\n| Concern | Mitigation |\n|---------|-----------|\n| Unauthorized peer | Org key challenge-response; unknown peers require dashboard acceptance |\n| Data interception | TLS on all WebSocket connections |\n| Poisoning (bad data) | Quarantine system + anomaly detection (Section 14.9) |\n| Stale data | Confidence decay over time; `trust_decay_days` (default 30) |\n| Data leakage | Only aggregated scores/patterns cross the wire — never raw code, PR text, or secrets |\n| Key compromise | Org key rotation: `/maggy mesh rotate-key` regenerates and pushes to all connected peers |\n| Replay attacks | Nonce in handshake; timestamps in messages; reject messages > 5 min old |\n\n### 14.15 Configuration\n\n```yaml\n# Added to ~/.maggy/policy.yaml\nmesh:\n  enabled: true\n  sync_interval_minutes: 15    # full sync frequency\n  min_peer_samples: 10         # ignore peer data with < 10 samples\n  trust_decay_days: 30         # peer data confidence decays over time\n  quarantine_days: 30          # unconfirmed patterns expire\n  auto_promote_threshold: 3    # 3 independent peer confirmations → auto-promote\n  auto_accept_scores: true     # model scores merge automatically (weighted)\n  auto_accept_patterns: true   # patterns merge automatically (with quarantine)\n  auto_accept_policies: true   # policy proposals auto-apply if backtest passes (+0.1)\n  anomaly_threshold: 10        # flag peer sending 10x normal volume\n  broadcast_on_l1: true        # broadcast score updates after each task\n```\n\nNote: `auto_accept_policies: true` — this is the aggressive default. Maggy is autonomous. If a policy proposal passes backtesting with > +0.1 delta, it applies automatically. The improvement ledger tracks everything for rollback. The team lead can override to `false` if they want manual review.\n\n---\n\n## 15. Engram — Cross-Session Memory\n\n### 15.1 The Problem: Agent Amnesia\n\nMaggy's Mnemos handles memory within a task. But when a session ends, everything learned about a project — conventions, reviewer preferences, codebase idioms, tool configurations — evaporates. The next session starts from scratch. This is agent amnesia, and it has seven distinct pathologies:\n\n| Amnesia Type | What Gets Lost | Maggy Example |\n|-------------|---------------|---------------|\n| **Anterograde** | New memories fail to form across sessions | Maggy learns a project uses Zustand, forgets next session |\n| **Retrograde** | Existing memories degrade over time | A CI fix pattern fades after weeks of disuse |\n| **Temporal** | When something happened is lost | \"The API was refactored\" — but when? Before or after the auth change? |\n| **Source** | Where a fact came from is lost | \"Use 4-space indent\" — was this from the linter config or user preference? |\n| **Interference** | Memories from one context contaminate another | Project A's React patterns leak into Project B's Vue codebase |\n| **Context-binding** | Right memory, wrong retrieval context | Project has error handling conventions stored under \"testing\", not found during \"API route creation\" |\n| **Confabulation** | Inferred patterns presented as confirmed facts | Maggy \"remembers\" a convention it actually inferred from one example |\n\nWithout Engram, Maggy is a perpetual amnesiac — impressive in the moment, but unable to compound learning across sessions.\n\n### 15.2 The EngramRecord\n\nThe EngramRecord is the persistence primitive — the unit of cross-session memory.\n\n```python\n@dataclass\nclass EngramRecord:\n    engram_id: str              # UUID\n    namespace: str              # Project isolation key\n    memory_type: str            # \"convention\", \"preference\", \"pattern\",\n                                # \"tool_config\", \"reviewer_preference\",\n                                # \"codebase_idiom\", \"process_rule\"\n    content: str                # The actual memory\n    origin: Origin              # Where this came from\n    confidence: float           # 0.0-1.0\n    evidence_count: int         # How many times confirmed\n    temporal_validity: Validity # When this is valid\n    entity_links: list[str]     # Linked entities (files, functions, people)\n    causal_links: list[str]     # Linked causes/effects\n    created_at: str             # ISO timestamp\n    last_verified: str          # When last confirmed still valid\n    last_accessed: str          # When last retrieved\n\n@dataclass\nclass Origin:\n    source_type: str            # \"mnemos_task\", \"user_explicit\",\n                                # \"process_signal\", \"mesh_peer\"\n    source_id: str              # Task ID, user ID, or peer_id\n    channel: str                # \"cli\", \"dashboard\", \"mesh\"\n    original_evidence: str      # What prompted this memory\n\n@dataclass\nclass Validity:\n    valid_from: str             # ISO timestamp\n    valid_until: str | None     # None = no expiry\n    superseded_by: str | None   # engram_id of replacement\n    decay_rate: float           # Confidence decay per day (default 0.001)\n```\n\n### 15.3 Three-Tier Namespace Model\n\nEvery EngramRecord belongs to exactly one namespace tier. Three tiers prevent both cross-project contamination and useful-pattern siloing:\n\n```yaml\n# ~/.maggy/engram_namespaces.yaml\ntiers:\n  # Tier 1: LOCAL — project-specific memories\n  local:\n    zensurveys-backend:\n      language: python\n      framework: fastapi\n      isolation: strict        # No cross-namespace retrieval\n    zensurveys-frontend:\n      language: typescript\n      framework: react\n      isolation: strict\n\n  # Tier 2: PORTFOLIO — abstracted cross-project patterns\n  portfolio:\n    python-conventions:\n      scope: language          # All Python projects can read\n      abstraction: required    # Patterns must be de-contextualized\n    api-patterns:\n      scope: framework         # All API projects can read\n      abstraction: required\n    shared-conventions:\n      scope: org               # Org-wide conventions\n      abstraction: optional\n\n  # Tier 3: MESH — peer-derived memories (quarantined)\n  mesh:\n    isolation: quarantine      # Always quarantined on arrival\n    trust_decay_days: 30       # Confidence decays if unvalidated\n    auto_promote_threshold: 3  # 3 local confirmations → promote to portfolio\n```\n\n**Tier 1 (Local)** is project-scoped — a Python FastAPI project's conventions never contaminate a React project's patterns.\n\n**Tier 2 (Portfolio)** holds abstracted patterns that transcend individual projects. When a local pattern proves useful across 3+ projects, it's promoted to portfolio — but only after de-contextualization (stripping project-specific names, paths, and configurations). This prevents the \"works everywhere\" illusion while enabling genuine cross-project learning.\n\n**Tier 3 (Mesh)** holds peer-derived memories that arrive via Maggy Mesh. These always enter quarantine and must be locally validated before promotion. A mesh pattern from a peer's Python project goes to portfolio-level `python-conventions` only after local confirmation.\n\nRetrieval queries search local first, then portfolio, then mesh — with confidence weighting per tier.\n\n### 15.3.1 Engram as Improvement Substrate\n\nEngram absorbs the improvement ledger. The relationship:\n\n- **Improvement ledger** = the mutation log (what changed, when, who proposed)\n- **Engram** = the memory substrate (persists the \"what\" across sessions)\n- **Reward registry** = the outcome signal (did the change work?)\n\nBefore Engram, the improvement ledger was ephemeral — mutations were logged but lost between sessions. Engram makes the ledger persistent: every L2/L3/L4 mutation becomes an EngramRecord with `memory_type: \"mutation\"`, carrying the original proposal, the delta metric, and the outcome reward. This means Maggy can remember not just what it learned, but what it tried, what worked, and what failed — the full self-improvement history.\n\n### 15.4 Memory Lifecycle\n\n```\nMnemos (within-task)\n  → Task completes with high-confidence memories\n  → Promotion filter: confidence > 0.8, evidence_count >= 3\n  │\n  ▼\nEngram (cross-session, per-machine)\n  → EngramRecord created with full Origin + Validity\n  → Namespace-isolated per project\n  → Multi-path retrieval: semantic + temporal + entity links\n  → Confidence decays with age unless revalidated\n  │\n  ▼\nMesh (cross-machine, per-org) [optional]\n  → High-confidence EngramRecords distilled into Mesh typed memory\n  → Shared with peers as patterns/scores with provenance\n  → Incoming peer patterns enter quarantine (Section 14.9)\n```\n\n### 15.5 Multi-Path Retrieval\n\nSingle-path semantic retrieval fails when the retrieval query doesn't match the storage encoding. Engram retrieves across four paths simultaneously:\n\n| Path | What It Finds | Example |\n|------|-------------|---------|\n| **Semantic** | Content-similar memories | Query \"API route\" finds \"REST endpoint conventions\" |\n| **Temporal** | Recent or temporally-relevant memories | Query finds patterns from the same sprint/phase |\n| **Causal** | Cause-effect linked memories | \"Auth refactor\" finds \"session middleware change\" it caused |\n| **Entity** | Entity-linked memories | Query about `auth.py` finds all conventions touching that file |\n\nRetrieval returns a merged, deduplicated set ranked by `confidence * recency * path_match_score`.\n\n### 15.6 Amnesia Score Diagnostic\n\nEach project gets a 7-dimension Amnesia Score (0.0 = perfect retention, 1.0 = total amnesia):\n\n```python\n@dataclass\nclass AmnesiaProfile:\n    anterograde: float    # Are new memories forming across sessions?\n    retrograde: float     # Are old memories degrading?\n    temporal: float       # Is temporal context preserved?\n    source: float         # Is origin attribution maintained?\n    interference: float   # Is cross-namespace contamination occurring?\n    context_binding: float # Are memories retrievable in the right context?\n    confabulation: float  # Are inferred patterns presented as facts?\n\n    @property\n    def overall(self) -> float:\n        return sum(vars(self).values()) / 7\n```\n\nThe L3 weekly loop analyzes Amnesia Scores per project and patches memory encoding rules:\n- High anterograde score → lower the promotion threshold (more memories get persisted)\n- High interference score → tighten namespace isolation rules\n- High confabulation score → require higher evidence_count before promotion\n\n### 15.7 Integration with Control Loops\n\n| Level | Engram Integration |\n|-------|-------------------|\n| **L0** | Check if current task context matches any EngramRecords — surface relevant conventions |\n| **L1** | Promote high-confidence task memories to EngramRecords |\n| **L2** | Daily: check for decayed records, run amnesia diagnostics |\n| **L3** | Weekly: analyze Amnesia Scores, adjust promotion thresholds, patch encoding rules |\n| **L4** | Monthly: evaluate whether Engram is reducing session startup time and improving consistency |\n\n---\n\n## 16. Lexon — Semantic Tool Binding\n\n### 16.1 The Problem: Tool Selection Collapses at Scale\n\nAt 5-10 tools, models select correctly. At 20-30, confusion between similar-sounding tools emerges. At 50+, accuracy collapses: the model selects plausible-sounding but incorrect tools, hallucinates parameters, or conflates capabilities. This is well-documented in research (RAG-MCP: accuracy drops from 87% to 13% as tools grow from 10 to 100).\n\nMaggy's tool count will grow aggressively:\n- MCP Forge (Phase 9) auto-generates MCP servers from API docs\n- Process Intelligence (Phase 8) adds signal collectors per integration\n- Each project's toolchain adds environment-specific tools\n- Mesh peers may surface tool recommendations\n\nWithout Lexon, Maggy's tool accuracy will degrade as it becomes more capable.\n\n### 16.2 The LexonRecord\n\n```python\n@dataclass\nclass LexonRecord:\n    lexon_id: str               # UUID\n    phrase: str                 # Original user phrase (pre-translation)\n    phrase_normalized: str      # Post-translation, lowercased\n    language: str               # ISO 639-1 detected language\n    is_mixed: bool              # Code-switching detected\n\n    # Intent source — Lexon binds more than user phrases\n    source_type: str            # \"user_phrase\" | \"reason_node\" | \"mnemo_node\"\n                                # | \"process_signal\" | \"mesh_policy\"\n    structured_intent: str | None  # iCPG ReasonNode ref (if source_type != \"user_phrase\")\n    reason_node_ref: str | None    # Pointer to iCPG ReasonNode that triggered routing\n    engram_refs: list[str]         # EngramRecord IDs used to resolve this binding\n\n    # Routing results\n    candidate_tools: list       # [{tool_name, tool_version, schema_hash, score, source}]\n    selected_tool: str | None   # None if clarification required\n    selected_tool_version: str | None  # Semantic version of selected tool\n    selected_tool_schema_hash: str | None  # Hash of tool's input schema at bind time\n    confidence: float           # 0.0-1.0\n    ambiguity_class: str | None # \"near_miss\" | \"vocabulary_gap\" | \"context_dependent\"\n    negative_bindings: list[str]  # Tool names explicitly excluded (NOT bindings)\n\n    # Disambiguation\n    was_clarified: bool         # Disambiguation was triggered\n    clarify_mode: str           # \"self_clarify\" | \"user_clarify\"\n\n    # Outcome tracking\n    correction: str | None      # If user corrected post-execution\n    correction_source: str | None  # \"user_explicit\" | \"ci_failure\" | \"review_comment\"\n    outcome_reward: float | None   # -1.0 to 1.0: did the binding produce good results?\n\n    # Context\n    context_snapshot: str       # Pointer to Mnemos ContextNode\n    user_id: str\n    created_at: str\n```\n\nThe enhanced LexonRecord captures not just what was bound, but why (intent source), to which version (tool contract), whether the binding worked (outcome reward), and how errors were detected (correction source). This transforms Lexon from a lookup table into a reward-bearing learning system.\n\n### 16.3 Five-Layer Pipeline\n\nEvery tool invocation passes through five layers:\n\n```\nLayer 1: LANGUAGE NORMALIZATION\n  → Detect language (lightweight classifier)\n  → Translate to English for routing only (response stays in user language)\n  → Handle code-switching: extract English anchor terms from mixed-language input\n  │\n  ▼\nLayer 2: TWO-TIER ROUTING\n  → Tier A (fast LLM, <300ms): compact tool manifest (name + 1-line description)\n    Returns 5-7 candidates with rationale. JSON schema constrained to valid tool names.\n  → Tier B (semantic retriever): multilingual embedding search over tool registry\n    Each tool indexed by: description, example queries, learned synonyms\n    Returns 5-7 candidates with cosine similarity scores.\n  → Union + deduplication. Tools in both lists get score bonus.\n  │\n  ▼\nLayer 3: TERMINOLOGY MAP FILTER\n  → Query three-level Terminology Map: user > org > system\n  → Explicit user preferences override everything (confidence 1.0)\n  → NOT bindings: \"blast\" explicitly does NOT mean \"delete_all\"\n  → Context-conditioned: \"follow up\" → different tool depending on active entity\n  │\n  ▼\nLayer 4: DISAMBIGUATION (dual-mode)\n  → If top candidate confidence > 0.82 and gap to #2 > 0.15: proceed\n  → Otherwise: choose clarify mode based on action reversibility:\n\n  → MODE A — self_clarify (default, autonomous):\n    Lexon resolves ambiguity without asking the user by consulting:\n    - iCPG ReasonNode: structured sub-goal narrows candidate set\n    - Mnemos ContextNode: active entity and recent tool history\n    - Engram: past bindings for this phrase in this project\n    - Process history: which tool succeeded last time in similar context\n    - Mesh consensus: what do peers bind this phrase to?\n    If any source resolves confidence above threshold → proceed silently.\n    Logged as self_clarify in LexonRecord for audit.\n\n  → MODE B — user_clarify (irreversible actions only):\n    Triggered only when action is destructive, expensive, or irreversible\n    (delete, deploy, billing, permission changes).\n    Present 2-3 concrete options in user's language.\n    User's selection becomes high-confidence Terminology Map entry.\n\n  → Autonomous agents should almost never trigger user_clarify.\n    The goal: 95%+ resolutions via self_clarify after 50+ interactions.\n  │\n  ▼\nLayer 5: FEEDBACK + PERSONALIZATION\n  → Five implicit learning signals update Terminology Map:\n    1. Correction: user corrects → add NOT binding + positive binding\n    2. Affirmation: user proceeds → increment confidence\n    3. Repetition: same phrase→tool 5+ times → promote to high-confidence synonym\n    4. Disambiguation selection: capture context + choice as user-level binding\n    5. Clarification repetition: same phrase triggers 3+ disambiguations → prompt explicit preference\n  → High-confidence bindings (>0.9, used >10 times) promoted to Engram for cross-session persistence\n```\n\n### 16.4 Terminology Map Structure\n\n```python\n@dataclass\nclass TerminologyEntry:\n    phrase: str                 # \"blast my list\"\n    tool_name: str              # \"bulk_email_send\"\n    params: dict | None         # Default parameters if applicable\n    NOT: list[str]              # [\"delete_all\"] — explicitly NOT this tool\n    context: str | None         # \"contact_selected\" — binding condition\n    level: str                  # \"system\" | \"org\" | \"user\"\n    confidence: float           # 1.0 for explicit, <1.0 for learned\n    user_id: str | None         # None for system/org level\n```\n\nResolution order: explicit user-level (confidence 1.0) > org-level > system-level > router inference. An explicit user preference is ground truth and bypasses confidence scoring.\n\n### 16.5 Org-Level Terminology via Mesh\n\nThe Terminology Map has an org level between system and user. In a Maggy Mesh deployment:\n- Team leads can define shared vocabulary\n- Org-level entries propagate to all peers as default bindings\n- Individual users can override at user level\n- New team members inherit org vocabulary on Mesh cold start\n\nThis is a natural extension of Mesh's typed memory: terminology entries are a new type alongside scores, patterns, policies, and gaps.\n\n### 16.6 Integration with RFC Stack\n\n```\niCPG (structured intent) → Lexon (routes to correct tool)\n                              ↕\n                          Mnemos (tracks tool selection quality via ToolCallNode)\n                              ↕\n                          Engram (persists learned vocabulary across sessions)\n                              ↕\n                          Mesh (shares org-level terminology across machines)\n```\n\n| Component | Lexon Reads From | Lexon Writes To |\n|-----------|-----------------|----------------|\n| **iCPG** | ReasonNode provides structured sub-goal (better routing signal than raw text) | — |\n| **Mnemos** | ContextNode for active entity (disambiguation signal) | ToolCallNode logged per invocation |\n| **Engram** | High-confidence user synonyms from past sessions | Promotes confirmed bindings for persistence |\n| **Mesh** | Org-level terminology entries from peers | Shares learned org-level vocabulary |\n\n### 16.7 Configuration\n\n```yaml\n# Added to ~/.maggy/policy.yaml\nlexon:\n  enabled: true\n  fast_llm_model: \"claude-haiku\"      # Tier A: speed over depth\n  embedding_model: \"multilingual-e5-large\"\n  confidence_threshold: 0.82\n  disambiguation_gap: 0.15\n  max_candidates: 7\n  personalization:\n    implicit_learning: true\n    promotion_threshold: 10           # Uses before promoting to Engram\n    correction_weight: 2.0            # Corrections count double\n  terminology_map:\n    system_file: \"lexon_system_terms.yaml\"\n    org_sync_via_mesh: true           # Share org terms through Mesh\n```\n\n---\n\n## 17. Event Spine — Canonical Event Flow\n\n### 17.1 Why an Event Spine\n\nMaggy's components — iCPG, Mnemos, Lexon, Engram, Process Intelligence, Mesh — each generate their own events in their own formats. Without a canonical event spine, correlating \"user said X → Lexon bound tool Y → execution failed → memory Z was created → mutation W was proposed → mesh peer P received it\" requires stitching together six different log formats.\n\nThe Event Spine defines a single ordered event stream that every component writes to. Each event carries a common header and a typed payload. This enables end-to-end tracing, reward attribution, and replay for debugging.\n\n### 17.2 Event Types\n\n```\nIntentEvent ──► BindingEvent ──► ExecutionEvent ──► MemoryEvent\n                                                       │\n                                                       ▼\nMeshEvent ◄── MutationEvent ◄── OutcomeEvent ◄── PersistenceEvent\n```\n\n| Event | Emitted By | What It Captures |\n|-------|-----------|-----------------|\n| **IntentEvent** | iCPG | Structured sub-goal from ReasonNode decomposition |\n| **BindingEvent** | Lexon | Tool selection: which tool, which version, confidence, clarify mode |\n| **ExecutionEvent** | Pi / Agent | Tool invocation: input, output, duration, exit code |\n| **MemoryEvent** | Mnemos | Within-task memory write: node type, confidence, entity links |\n| **PersistenceEvent** | Engram | Cross-session memory promotion: namespace tier, memory type |\n| **OutcomeEvent** | Process Intelligence | Task outcome: success/failure, metric delta, reward signal |\n| **MutationEvent** | L2/L3/L4 Loops | Self-modification: what changed, why, expected delta |\n| **MeshEvent** | Mesh | Cross-machine sharing: what was sent/received, quarantine status |\n\n### 17.3 Common Event Header\n\nEvery event carries a standard header for correlation and audit:\n\n```python\n@dataclass\nclass EventHeader:\n    event_id: str           # UUID — unique per event\n    event_type: str         # \"intent\" | \"binding\" | \"execution\" | ...\n    task_id: str            # Links all events in a single task\n    project_id: str         # Engram namespace key\n    agent_id: str           # Which agent (Pi instance) emitted this\n    model_id: str           # Which LLM was active\n    confidence: float       # Event-level confidence (0.0-1.0)\n    namespace: str          # Engram namespace tier (local/portfolio/mesh)\n    policy_version: str     # Which policy.yaml version was active\n    reward_delta: float | None  # Outcome signal (-1.0 to 1.0)\n    timestamp: str          # ISO 8601\n    parent_event_id: str | None  # Causal parent (enables event DAG)\n```\n\n### 17.4 Typed Payloads\n\n```python\n@dataclass\nclass IntentEvent:\n    header: EventHeader\n    reason_node_id: str     # iCPG ReasonNode that decomposed this\n    sub_goal: str           # Natural language sub-goal\n    blast_radius: int       # iCPG blast radius estimate\n    drift_score: float      # iCPG drift from original intent\n\n@dataclass\nclass BindingEvent:\n    header: EventHeader\n    lexon_record_id: str    # LexonRecord UUID\n    source_type: str        # \"user_phrase\" | \"reason_node\" | ...\n    selected_tool: str\n    tool_version: str\n    schema_hash: str\n    clarify_mode: str       # \"self_clarify\" | \"user_clarify\"\n    ambiguity_class: str | None\n\n@dataclass\nclass OutcomeEvent:\n    header: EventHeader\n    success: bool\n    metric_name: str        # \"tests_passed\", \"ci_green\", \"pr_merged\"\n    metric_before: float\n    metric_after: float\n    reward: float           # Computed reward signal\n```\n\n### 17.5 What the Event Spine Enables\n\n| Capability | How |\n|-----------|-----|\n| **End-to-end tracing** | Follow task_id across all 8 event types |\n| **Reward attribution** | OutcomeEvent.reward propagates back to BindingEvent (was tool selection good?) and MutationEvent (was self-modification good?) |\n| **Replay debugging** | Replay event stream to reproduce failures without re-executing |\n| **Amnesia diagnosis** | Compare MemoryEvent → PersistenceEvent conversion rate per project |\n| **Mesh audit** | Track exactly what crossed the wire and whether quarantine was justified |\n| **Self-improvement validation** | MutationEvent + OutcomeEvent = evidence for whether L3/L4 changes helped |\n\n### 17.6 Storage and Retention\n\n```yaml\n# Added to ~/.maggy/policy.yaml\nevent_spine:\n  enabled: true\n  storage: \"~/.maggy/events.db\"    # SQLite — append-only event log\n  retention_days: 90               # Events older than 90 days → archive\n  archive_format: \"jsonl.gz\"       # Compressed JSONL for cold storage\n  index_fields:                    # Fields indexed for fast queries\n    - task_id\n    - event_type\n    - project_id\n    - timestamp\n```\n\n### 17.7 Integration Summary\n\n```\nUser speaks → IntentEvent (iCPG decomposes)\n           → BindingEvent (Lexon routes to tool)\n           → ExecutionEvent (Pi executes)\n           → MemoryEvent (Mnemos records)\n           → PersistenceEvent (Engram persists)\n           → OutcomeEvent (Process Intelligence scores)\n           → MutationEvent (L2/L3 self-modifies)\n           → MeshEvent (Mesh shares with peers)\n\nEvery step is typed, correlated by task_id, and carries a reward signal.\nThis is the nervous system of an autonomous engineering agent.\n```\n\n---\n\n## 18. Benchmark Validation — Maggy vs Claude Code\n\n> Full results: [`docs/benchmark-results.md`](benchmark-results.md)\n\n### 18.1 Test Protocol\n\nBuilt an **Expense Tracker** (FastAPI + SQLite + vanilla JS) using 6 identical tasks:\n- **Runner A (Maggy):** 4-tier routing via blast score, 4 CLIs auto-discovered\n- **Runner B (Claude Code):** All 6 tasks through `claude -p` only\n\nEnvironment: Mac Studio M4 Max, 128 GB RAM. CLIs: Claude Code 2.1.42, Codex 0.129.0, Kimi 1.41.0, Ollama 0.23.2 (qwen2.5-coder:32b).\n\n### 18.2 Results Summary\n\n| Metric | Maggy | Claude Code |\n|--------|-------|-------------|\n| Success rate | 6/6 (100%) | 6/6 (100%) |\n| Total time | 907.6s | 681.0s |\n| Quality score | 7.4/10 | 7.8/10 |\n| Claude subscription burn | 17% (1/6 tasks) | 100% (6/6 tasks) |\n| Models used | 4 (ollama, kimi, codex, claude) | 1 (claude) |\n| Fallbacks needed | 0 | N/A |\n| Security depth | 7 issues found + fixed | No dedicated review |\n| Test generation | None | 3 test files, 11+ cases |\n\n### 18.3 Routing in Action\n\n```\nEXP-1 (docs, blast 2)     → ollama  50.4s   ← FREE (local GPU)\nEXP-2 (schema, blast 3)   → kimi    86.6s   ← cheap subscription\nEXP-3 (CRUD, blast 5)     → codex  147.1s   ← separate subscription\nEXP-4 (API, blast 5)      → codex  133.9s   ← separate subscription\nEXP-5 (frontend, blast 6) → codex  280.1s   ← separate subscription\nEXP-6 (security, blast 8) → claude 209.5s   ← premium (only when needed)\n```\n\n### 18.4 What This Validates\n\n1. **CLI auto-discovery works end-to-end.** Maggy probed 4 CLIs via `--help`, extracted flags, built correct commands, and spawned all 4 successfully with zero manual configuration.\n\n2. **Blast-score routing is functional.** Low-complexity tasks went to cheap/free models; high-complexity tasks went to premium. The routing decisions were defensible.\n\n3. **Fallback chain is reliable.** Zero fallbacks needed — all 4 CLIs completed their assigned tasks. The chain is wired and ready for quota exhaustion scenarios.\n\n4. **Cost efficiency is real.** 83% reduction in Claude usage. Only the security review (blast 8) touched the premium model.\n\n5. **Quality is competitive.** Maggy scored 7.4 vs Claude's 7.8 — a small gap driven by missing tests and product spec (routing issue, not capability issue).\n\n### 18.5 Gaps to Close\n\n| Gap | Root Cause | Fix |\n|-----|-----------|-----|\n| No tests generated | No TDD pipeline step in benchmark | Wire executor's `_run_tdd()` to add RED-GREEN step |\n| Ollama missed product spec | Coding model assigned prose task | Route `task_type: docs` to kimi/claude regardless of blast |\n| Codex slow on frontend (280s vs 122s) | Codex overhead for complex UI tasks | Consider routing blast 6 frontend to claude |\n| Claude had better architecture | Single model sees full context | Multi-model loses cross-task context — address via checkpoint sharing |\n\n### 18.6 Post-Benchmark Improvements\n\nAfter the benchmark, three systems were built to close the identified gaps:\n\n#### A. Routing Rules (`maggy/routing_rules.py`)\n\nA YAML-backed self-updating rules file at `~/.maggy/routing-rules.yaml`. Rules are checked **before** blast-score routing, enforcing that specific task types and pipeline phases always use the right model.\n\n**Task-type overrides** (from benchmark evidence):\n\n| Task Type | Forced Model | Confidence | Source |\n|-----------|-------------|-----------|--------|\n| `docs` | claude | 0.9 | benchmark — local models are code-optimized, not prose |\n| `security` | claude | 1.0 | rule — security review needs deep reasoning |\n| `architecture` | claude | 0.8 | rule — architecture needs cross-context awareness |\n| `tests` | claude | 0.9 | benchmark — only claude generated test files |\n| `planning` | claude | 0.8 | rule — planning requires structured reasoning |\n\n**Pipeline phase overrides** (from TDD workflow):\n\n| Phase | Forced Model | Reason |\n|-------|-------------|--------|\n| `spec` | claude | SPEC phase needs comprehensive docs |\n| `tdd_red` | claude | RED phase needs test design expertise |\n| `tdd_green` | auto | GREEN phase uses blast-score routing |\n| `review` | claude | Review needs security + architecture depth |\n\n**Self-learning API:**\n- `record_outcome(rules, model, task_type, success)` — updates rolling success rates from task results\n- `learn_override(rules, task_type, model, reason, confidence)` — Maggy can add new overrides when data supports it\n- Manual edits to the YAML are preserved; Maggy only appends learned entries\n\nThis directly addresses:\n- **\"Ollama missed product spec\"** → `docs` tasks now forced to claude\n- **\"No tests generated\"** → `tests` and `tdd_red` phases now forced to claude\n\n#### B. Team Conventions (embedded in routing rules)\n\nConventions from claude-bootstrap's CLAUDE.md and skill files are embedded in the routing rules and injected into every prompt sent to any CLI:\n\n```yaml\nconventions:\n  - text: \"Build minimum wowable product (mWP). Ship the smallest thing that makes someone say 'wow'.\"\n    applies_to: [all]\n    source: claude-bootstrap\n  - text: \"Follow TDD: RED → GREEN → VALIDATE. Coverage >= 80%.\"\n    applies_to: [feature, bug, refactor]\n    source: claude-bootstrap\n  - text: \"No secrets in code. Parameterized SQL only. Validate all input at API boundaries.\"\n    applies_to: [all]\n    source: claude-bootstrap\n  - text: \"Quality gates: max 20 lines/function, max 3 params, max 2 nesting levels, max 200 lines/file.\"\n    applies_to: [all]\n    source: claude-bootstrap\n  - text: \"Use existing patterns. Read the codebase before changing it.\"\n    applies_to: [all]\n    source: claude-bootstrap\n```\n\nEvery executor prompt method (`_plan_prompt`, `_analysis_prompt`, `_tests_prompt`, `_impl_prompt`) now calls `conventions_for(rules, task_type)` and appends the matching conventions block. This means kimi, codex, ollama, and claude all receive the same team rules — standardizing quality expectations across all models.\n\n#### C. Routing Rules + Conventions Flow\n\n```\nTask arrives → apply_override(task_type, phase)\n                ↓ forced?\n              ┌─YES─→ use forced model\n              └─NO──→ reward table → blast-score routing\n                        ↓\n              build prompt + conventions_for(task_type)\n                        ↓\n              send to CLI with team conventions embedded\n                        ↓\n              record_outcome() → update YAML success rates\n```\n\n#### D. Expected Impact on Re-run\n\nIf the benchmark were re-run with these improvements:\n\n| Gap (Before) | Expected Result (After) |\n|-------------|----------------------|\n| No product spec from ollama | EXP-1 (`docs`) now routes to claude → spec generated |\n| No tests from any model | TDD pipeline with `tdd_red` → claude → tests generated |\n| Inconsistent quality | All models receive team conventions (mWP, quality gates, security rules) |\n| No self-improvement | Outcome recording feeds back into routing rules YAML |\n\n**Net effect:** Quality score expected to converge with Claude Code's 7.8+ while maintaining the 83% cost reduction.\n"
  },
  {
    "path": "docs/benchmark-results.md",
    "content": "# Maggy v5 Benchmark Results\n\n**Date:** 2026-05-11\n**App:** Personal Expense Tracker (FastAPI + SQLite + vanilla HTML/JS)\n**Environment:** Mac Studio M4 Max, 128 GB RAM, macOS Darwin 24.6.0\n**CLIs:** Claude Code 2.1.42, Codex 0.129.0, Kimi 1.41.0, Ollama 0.23.2 (qwen2.5-coder:32b)\n\n---\n\n## 1. Test Protocol\n\n6 identical tasks run sequentially through two pipelines:\n\n- **Runner A (Maggy):** 4-tier routing via blast score. Auto-discovers CLI flags at startup.\n- **Runner B (Claude Code):** All tasks run through `claude -p` only.\n\nBoth pipelines use `--dangerously-skip-permissions` / equivalent flags, 25 max turns, and subprocess spawning into isolated build directories.\n\n---\n\n## 2. Task Definitions\n\n| ID | Task | Blast | Maggy Route | Type |\n|----|------|-------|-------------|------|\n| EXP-1 | Write product spec | 2 | local (ollama) | docs |\n| EXP-2 | Design database schema | 3 | kimi | architecture |\n| EXP-3 | Build expense CRUD API | 5 | gpt (codex) | feature |\n| EXP-4 | Build category API + monthly summary | 5 | gpt (codex) | feature |\n| EXP-5 | Build frontend dashboard | 6 | gpt (codex) | frontend |\n| EXP-6 | Security review + input validation | 8 | claude | security |\n\n---\n\n## 3. Speed Results\n\n| Task | Blast | Maggy Model | Maggy (s) | Claude (s) | Winner |\n|------|-------|-------------|-----------|------------|--------|\n| EXP-1 | 2 | ollama (local) | 50.4 | 48.6 | Claude |\n| EXP-2 | 3 | kimi | 86.6 | 67.2 | Claude |\n| EXP-3 | 5 | codex | 147.1 | 160.6 | **Maggy** |\n| EXP-4 | 5 | codex | 133.9 | 130.8 | Claude |\n| EXP-5 | 6 | codex | 280.1 | 121.9 | Claude |\n| EXP-6 | 8 | claude | 209.5 | 151.9 | Claude |\n| **Total** | | | **907.6** | **681.0** | **Claude (33% faster)** |\n\n### Routing Distribution (Maggy)\n\n| Model | Tasks | % |\n|-------|-------|---|\n| codex (gpt) | 3 | 50% |\n| ollama (local) | 1 | 17% |\n| kimi | 1 | 17% |\n| claude | 1 | 17% |\n\n---\n\n## 4. Success Rate\n\n| Pipeline | Passed | Failed | Fallbacks | Rate |\n|----------|--------|--------|-----------|------|\n| Maggy | 6 | 0 | 0 | 100% |\n| Claude | 6 | 0 | 0 | 100% |\n\n---\n\n## 5. Output Quality Assessment\n\n### 5.1 File Inventory\n\n**Maggy (10 source files, 1,634 lines):**\n\n| File | Lines | Model | Assessment |\n|------|-------|-------|------------|\n| `SECURITY.md` | 134 | claude | Thorough: 7 findings with fixes, 3 recommendations |\n| `backend/app/database.py` | 74 | kimi | Correct schema, parameterized queries, FK + cascade, seed data |\n| `backend/app/main.py` | 36 | kimi | Lifespan init, CORS from env var (not wildcard), 3 routers |\n| `backend/app/validation.py` | 25 | claude | Shared YYYY-MM regex validator, extracted from duplication |\n| `backend/app/routes/expenses.py` | 148 | codex | Full CRUD, Pydantic models, parameterized SQL, FK check |\n| `backend/app/routes/categories.py` | 107 | codex | CRUD, hex color validator, unique constraint handling |\n| `backend/app/routes/summary.py` | 52 | codex | Monthly aggregation with COALESCE, GROUP BY |\n| `frontend/index.html` | 121 | codex | Dark theme, responsive, all sections present |\n| `frontend/css/style.css` | 472 | codex | CSS bar charts, dark palette, mobile breakpoints |\n| `frontend/js/app.js` | 472 | codex | State management, fetch API, DOM via textContent (XSS-safe) |\n\n**Claude (18 source files, ~1,500 app lines + 457K with venv):**\n\n| File | Lines | Assessment |\n|------|-------|------------|\n| `specs/product-spec.md` | 206 | Comprehensive: vision, schema, Pydantic examples, project structure |\n| `backend/app/database.py` | 68 | Correct schema, parameterized queries, FK, seed data |\n| `backend/app/main.py` | 42 | Lifespan init, CORS from env var, 3 routers |\n| `backend/app/models.py` | 51 | Centralized Pydantic schemas (better separation) |\n| `backend/app/routes/expenses.py` | 159 | Full CRUD, partial update support, category JOIN |\n| `backend/app/routes/categories.py` | 90 | CRUD, referential integrity check on delete |\n| `backend/app/routes/summary.py` | 44 | Monthly aggregation |\n| `backend/tests/conftest.py` | 18 | Temp DB fixture with patch |\n| `backend/tests/test_expenses.py` | 108 | 11 test cases covering CRUD + edge cases |\n| `backend/tests/test_categories.py` | ~50 | Category CRUD tests |\n| `backend/tests/test_summary.py` | ~40 | Summary endpoint tests |\n| `frontend/index.html` | 79 | Clean layout, modal-based form |\n| `frontend/css/style.css` | 323 | Dark theme, responsive |\n| `frontend/js/app.js` | 320 | API wrapper, currency formatting, chart rendering |\n\n### 5.2 Quality Scoring\n\n| Dimension | Maggy | Claude | Notes |\n|-----------|-------|--------|-------|\n| **Functional completeness** | 9/10 | 10/10 | Both implement all endpoints. Claude adds partial updates. |\n| **Security** | 10/10 | 7/10 | Maggy's security review (EXP-6) hardened CORS, added amount bounds, path param validation, color format validation. Claude left CORS with `allow_credentials=True`, no amount ceiling, no color validation. |\n| **SQL safety** | 10/10 | 10/10 | Both use parameterized queries exclusively. |\n| **XSS prevention** | 10/10 | 10/10 | Both use textContent for DOM rendering. No innerHTML. |\n| **Input validation** | 9/10 | 7/10 | Maggy: Pydantic + custom validators (hex color, amount ceiling, path ge=1). Claude: Pydantic regex patterns but less thorough. |\n| **Error handling** | 9/10 | 8/10 | Maggy: context manager with rollback, 409 on duplicate, 404 on missing. Claude: try/finally, 409 on duplicate, referential integrity check. |\n| **Test coverage** | 0/10 | 9/10 | Maggy produced zero tests. Claude created conftest + 3 test files (~200 lines). |\n| **Architecture** | 8/10 | 9/10 | Claude separated models into dedicated file. Maggy inlined models per route. Both wire correctly. |\n| **Product spec** | 0/10 | 10/10 | Maggy's ollama did not produce a spec file. Claude's spec is comprehensive (206 lines). |\n| **Frontend quality** | 9/10 | 8/10 | Maggy's frontend is larger (472+472+121 = 1065 lines) with more CSS detail. Claude's is cleaner (320+323+79 = 722 lines) with modal UX. |\n| **Weighted avg** | **7.4/10** | **7.8/10** | |\n\n### 5.3 Key Differences\n\n**Maggy strengths:**\n- Security review caught and fixed 7 issues (CORS wildcard, missing bounds, color validation, duplicated validation)\n- Multi-model approach applied right tool to right task (security by Claude, CRUD by Codex, schema by Kimi)\n- Larger frontend with more CSS polish\n- Each model contributed its strength: Claude for security depth, Codex for feature implementation\n\n**Claude strengths:**\n- Product spec created (comprehensive 206-line document)\n- Test suite included (conftest + 3 test files, ~200 lines, 11+ test cases)\n- Better code organization (centralized models.py)\n- Partial update support on expenses (PATCH-style PUT)\n- Referential integrity check on category delete (prevents orphaned expenses)\n- Full venv with dependencies installed\n\n**Maggy weaknesses:**\n- No product spec file generated (ollama didn't create it or placed it elsewhere)\n- No test files at all — a significant gap for production readiness\n- Import paths use `backend.app.` which requires specific project structure to run\n\n**Claude weaknesses:**\n- No dedicated security review — CORS uses `allow_credentials=True` (risky with dynamic origins)\n- No amount ceiling on expenses (could submit `1e308`)\n- No hex color format validation on categories\n- `get_db()` returns connection without context manager (manual close in every route)\n\n---\n\n## 6. Cost Analysis\n\n| Pipeline | Claude Usage | Free/Cheap Usage | Est. Subscription Burn |\n|----------|-------------|------------------|----------------------|\n| **Maggy** | 1/6 tasks (17%) | 2/6 tasks (33%) | Low — spread across 3 subscriptions |\n| **Claude** | 6/6 tasks (100%) | 0/6 tasks (0%) | High — 100% on premium model |\n\nMaggy used Claude only for the security review (blast 8). The other 5 tasks consumed cheaper or free models:\n- EXP-1: ollama (free, local GPU)\n- EXP-2: kimi (free tier / cheap subscription)\n- EXP-3/4/5: codex (separate subscription)\n\nThis represents ~83% reduction in Claude subscription consumption.\n\n---\n\n## 7. Routing Observations\n\n### What worked\n- **Blast 8 → Claude** for security review was correct. Claude produced the most thorough audit.\n- **Blast 5 → Codex** for CRUD implementation delivered working endpoints.\n- **Blast 3 → Kimi** for database schema was successful and correct.\n- **Zero fallbacks** — all 4 CLIs completed tasks without needing to escalate.\n- **Auto-discovery** — CLI flags probed from `--help`, not hardcoded.\n\n### What needs tuning\n- **Codex is slow on frontend** — EXP-5 took 280s vs Claude's 122s (2.3x slower). Consider routing blast 6 frontend tasks to Claude.\n- **Ollama missed the spec task** — EXP-1 (docs) was routed to local model but no spec file was generated. Ollama's qwen2.5-coder is optimized for code, not prose. Consider routing `task_type: docs` to kimi or claude regardless of blast score.\n- **No test generation by any Maggy model** — None of the 4 models produced tests. This could be addressed by adding a TDD step (write tests first) as a follow-up task routed to Claude.\n\n---\n\n## 8. Conclusions\n\n| Metric | Maggy | Claude | Verdict |\n|--------|-------|--------|---------|\n| Speed | 907.6s | 681.0s | Claude 33% faster |\n| Success rate | 100% | 100% | Tie |\n| Quality (weighted) | 7.4/10 | 7.8/10 | Claude slightly better |\n| Security depth | Stronger | Weaker | Maggy (dedicated review step) |\n| Test coverage | None | Good | Claude (significant gap for Maggy) |\n| Cost efficiency | 83% savings | Baseline | Maggy |\n| Subscription risk | Distributed | Single point | Maggy |\n| Model diversity | 4 models | 1 model | Maggy |\n\n**Summary:** Claude Code is faster and produces marginally higher overall quality (driven by tests and spec). Maggy's multi-model approach provides cost efficiency and subscription risk distribution, plus deeper security review via dedicated model routing. The main gaps to close: add TDD pipeline (test generation step), and improve docs routing (don't send prose tasks to coding-optimized local models).\n\n---\n\n## 9. Raw Throughput Benchmarks (tokens/sec)\n\nStandalone generation speed measured with identical prompts across all four model tiers. Each model ran 3 iterations (1 cold, 2 hot).\n\n**Prompt:** \"Write a Python function that implements a binary search tree with insert, delete, search, and in-order traversal.\"\n\n### 9.1 Results\n\n| Model | Run 1 | Run 2 | Run 3 | Avg tok/s | Notes |\n|-------|-------|-------|-------|-----------|-------|\n| **Ollama qwen2.5-coder:32b** | 22.3 | 21.8 | 22.1 | **22.1** | Local GPU (M4 Max), consistent across runs |\n| **Claude (claude -p)** | 44.6 (API) / 18.6 (wall) | 41.9 / 14.3 | 25.7 / 6.8 | **37.4 API / 13.2 wall** | API time excludes network overhead; wall-clock includes CLI startup |\n| **Kimi (kimi CLI)** | ~1.8 | ~2.8 | ~3.3 | **~2.6** | Agentic mode — writes files, runs tools; tok/s reflects execution time |\n| **Codex (codex exec)** | ~0.8 | ~0.7 | ~0.6 | **~0.7** | Agentic mode — full-auto file creation; tok/s reflects execution time |\n\n### 9.2 Interpretation\n\n- **Ollama (local):** Stable 22 tok/s on M4 Max 128GB. No network latency, no rate limits, no cost. Best for blast 1-2 tasks where speed-to-first-token matters.\n- **Claude:** Fastest raw generation at ~37 tok/s (API). Wall-clock is lower (~13 tok/s) due to CLI startup overhead and streaming.\n- **Kimi / Codex:** Low tok/s numbers are misleading — both operate in agentic mode (writing files, running commands, iterating). Their throughput reflects end-to-end task execution, not pure generation speed. Codex in particular spends most time on sandboxed execution rather than generation.\n\n### 9.3 Routing Implications\n\n| Tier | Model | tok/s | Cost | Best For |\n|------|-------|-------|------|----------|\n| Local | Ollama qwen2.5-coder:32b | 22 | Free | Blast 1-2: docs, simple scaffolding |\n| Mid | Kimi | 2.6 (agentic) | Cheap | Blast 3-4: schema design, CRUD |\n| Premium-Auto | Codex | 0.7 (agentic) | Mid | Blast 5-6: feature implementation |\n| Premium | Claude | 37 (API) | High | Blast 7+: security, architecture, TDD |\n\n---\n\n## 10. Post-Benchmark Fixes (Routing Rules + Conventions)\n\nThree systems were built immediately after the benchmark to close the gaps above.\n\n### 10.1 Routing Rules (`~/.maggy/routing-rules.yaml`)\n\nA self-updating YAML config that overrides blast-score routing for specific task types and pipeline phases. Rules are checked **before** the reward table or blast-score tier.\n\n**Task-type overrides seeded from benchmark evidence:**\n\n| Task Type | Forced To | Why |\n|-----------|----------|-----|\n| `docs` | claude | Ollama (code-optimized) produced no spec file |\n| `security` | claude | Security review needs deep reasoning |\n| `tests` | claude | Only claude generated test files in benchmark |\n| `architecture` | claude | Architecture needs cross-context awareness |\n| `planning` | claude | Planning requires structured reasoning |\n\n**Pipeline phase overrides from TDD workflow:**\n\n| Phase | Forced To | Why |\n|-------|----------|-----|\n| `spec` | claude | SPEC phase needs comprehensive docs |\n| `tdd_red` | claude | RED phase needs test design expertise |\n| `tdd_green` | auto | GREEN uses blast-score routing (cheap models can implement) |\n| `review` | claude | Review needs security + architecture depth |\n\n**Self-learning:** `record_outcome()` updates rolling success rates per model. `learn_override()` lets Maggy add new rules when outcome data supports it. Manual YAML edits are preserved.\n\n### 10.2 Team Conventions Injection\n\nFive conventions from claude-bootstrap's CLAUDE.md are embedded in routing rules and injected into every prompt sent to any CLI:\n\n1. **mWP** — Build minimum wowable product. No feature flags, no premature abstractions.\n2. **TDD** — RED → GREEN → VALIDATE. Coverage >= 80%.\n3. **Security** — No secrets in code. Parameterized SQL. Validate input at boundaries.\n4. **Quality gates** — 20 lines/fn, 3 params, 2 nesting levels, 200 lines/file.\n5. **Existing patterns** — Read codebase before changing. Keep changes minimal.\n\nAll four executor prompt methods (`_plan_prompt`, `_analysis_prompt`, `_tests_prompt`, `_impl_prompt`) now append matching conventions. This standardizes quality expectations across kimi, codex, ollama, and claude.\n\n### 10.3 Expected Re-run Improvements\n\n| Benchmark Gap | Root Cause | Fix Applied | Expected Result |\n|--------------|-----------|-------------|-----------------|\n| No product spec (EXP-1) | `docs` routed to ollama | `docs → claude` override | Claude generates spec |\n| No tests from any model | No TDD step in pipeline | `tdd_red → claude` + `tests → claude` overrides | Claude writes failing tests |\n| Inconsistent quality across models | No shared standards | Conventions injected into all prompts | mWP + quality gates enforced everywhere |\n| No learning from outcomes | Static routing only | `record_outcome()` + `learn_override()` | Routing improves with each task |\n\n**Projected scores if re-run:**\n\n| Dimension | Before | After (est.) | Change |\n|-----------|--------|-------------|--------|\n| Product spec | 0/10 | 9/10 | `docs → claude` |\n| Test coverage | 0/10 | 8/10 | `tdd_red → claude` |\n| Security | 10/10 | 10/10 | No change (already strong) |\n| Architecture | 8/10 | 9/10 | Conventions enforce patterns |\n| **Weighted avg** | **7.4/10** | **~8.5/10** | **+1.1 points** |\n\nCost efficiency would remain at ~83% savings — the new overrides only force claude for `docs` (1 task) and `tests` (new TDD step), not for CRUD/API/frontend work.\n"
  },
  {
    "path": "docs/mnemos-implementation.md",
    "content": "# Mnemos Implementation Addendum\n\nImplementation details for the Mnemos RFC (Task-Scoped Memory Lifecycle for Autonomous Agents) as deployed in Maggy.\n\n## 1. Signal Access in Claude Code\n\n### Token Utilization (Primary Fatigue Signal)\n\nClaude Code exposes context window metrics through **statusline scripts**. When configured, the statusline script receives JSON on stdin for every API call:\n\n```json\n{\n  \"context_window\": {\n    \"used_percentage\": 42.5,\n    \"remaining_percentage\": 57.5,\n    \"used_tokens\": 85000,\n    \"total_tokens\": 200000,\n    \"remaining_tokens\": 115000\n  }\n}\n```\n\n**Key discovery**: Hooks (PreToolUse, PreCompact, etc.) do NOT receive context data directly. The solution is a two-stage pipeline:\n\n1. **Statusline script** receives token data on every API call, writes to `.mnemos/fatigue.json`\n2. **Hooks** read `.mnemos/fatigue.json` from disk when they fire\n\nThis gives near-real-time fatigue monitoring without requiring direct hook access to context metrics.\n\n### Hook System Integration\n\n| Hook | Trigger | Mnemos Action |\n|------|---------|--------------|\n| Statusline | Every API call | Write `fatigue.json` with token metrics |\n| PreToolUse (Edit/Write) | Before file edits | Read fatigue, auto-checkpoint at 0.60+, auto-consolidate at 0.40+ |\n| PreCompact | Before compaction | Emergency checkpoint, typed preservation instructions |\n| SessionStart | Session begins | Load checkpoint, bridge iCPG state |\n| Stop | Agent stops | Write final checkpoint |\n\n## 2. MnemoGraph Architecture\n\n### Node Types and Eviction Policies\n\n| Type | Eviction Policy | Purpose |\n|------|----------------|---------|\n| GoalNode | NEVER | Task's primary objective |\n| ConstraintNode | NEVER | Invariants, contracts, must-not-violate rules |\n| ContextNode | EVICTABLE | File contents, tool outputs, ephemeral context |\n| WorkingNode | COMPRESS_FIRST | In-progress reasoning, current approach |\n| ResultNode | COMPRESS_FIRST | Completed sub-task results |\n| SkillNode | COMPRESS_FIRST | Learned patterns (Tier 1+: promotable to persistent) |\n| CheckpointNode | NEVER | Serialized session state |\n| HandoffNode | NEVER | Task completion summary for successor |\n\n### Activation Weight Decay\n\nAll evictable/compressible nodes undergo exponential decay:\n- Factor: 0.95 per consolidation pass\n- GoalNodes, ConstraintNodes, CheckpointNodes, HandoffNodes exempt\n- Touching a node (access) resets weight via `touch_node()`\n\n### Storage\n\nSQLite at `.mnemos/mnemo.db`:\n- `mnemo_nodes` — MnemoGraph nodes with type, weight, status, scope_tags\n- `checkpoints` — Serialized session state\n- `fatigue_log` — Historical fatigue measurements for trending\n\n## 3. Fatigue Model (4 Dimensions — All Passively Observable)\n\nAll 4 dimensions are derived from actual hook data. No agent cooperation needed.\n\n### Signal Collection\n\nHooks log behavioral signals to `.mnemos/signals.jsonl` (append-only JSONL):\n- **PreToolUse** logs: `{tool, event: \"pre\", file_path, ts}` — captures what files the agent touches\n- **PostToolUse** logs: `{tool, event: \"post\", file_path, success, ts}` — captures tool outcomes\n- **Statusline** writes: `.mnemos/fatigue.json` with token metrics — captures context window state\n\nFatigue computation reads the last 30 entries from `signals.jsonl` + `fatigue.json`.\n\n### Dimension Weights\n\n```\ncomposite = 0.40 * token_utilization\n          + 0.25 * scope_scatter\n          + 0.20 * reread_ratio\n          + 0.15 * error_density\n```\n\n### Dimension Details\n\n**Token Utilization (0.40)**: `context_window.used_percentage / 100`. Direct from statusline. Most reliable signal — measures how full the context window is.\n\n**Scope Scatter (0.25)**: Ratio of unique directories touched in the last 30 tool calls. Agent editing `src/auth/` exclusively = 0.0 (focused). Agent bouncing across `src/auth/`, `tests/`, `docs/`, `config/`, `lib/` = 0.7+ (scattered, unfocused). Derived from PreToolUse `tool_input.file_path`.\n\n**Re-read Ratio (0.20)**: Proportion of Read tool calls that target files already read in the session. Agent reading `middleware.ts` once then moving on = 0.0 (remembers what it read). Agent re-reading `middleware.ts` 5 times = 0.8 (lost context, needs to re-read). Derived from PreToolUse when `tool_name=Read`. This is the strongest signal of actual context degradation.\n\n**Error Density (0.15)**: Ratio of failed tool calls to total tool calls in the rolling window. Agent with 100% success = 0.0 (productive). Agent with 50% failures = 0.5 (struggling, confused). Derived from PostToolUse `tool_response` error detection.\n\n### State Thresholds\n\n| State | Score Range | Auto-Actions |\n|-------|------------|-------------|\n| FLOW | 0.00–0.40 | None |\n| COMPRESS | 0.40–0.60 | Micro-consolidation (compress 3 ResultNodes, evict 1 cold ContextNode, decay weights) |\n| PRE-SLEEP | 0.60–0.75 | Checkpoint written + consolidation |\n| REM | 0.75–0.90 | Emergency checkpoint, warning to agent |\n| EMERGENCY | 0.90+ | Emergency checkpoint, handoff instruction |\n\n## 4. Checkpoint/Resume Protocol\n\n### CheckpointNode Contents\n\n```json\n{\n  \"id\": \"uuid\",\n  \"task_id\": \"session-1\",\n  \"goal\": \"Implement authentication module\",\n  \"active_constraints\": [\n    \"INV: API backward compatibility\",\n    \"POST: All endpoints require auth token\"\n  ],\n  \"active_results\": [\n    \"JWT middleware implemented and tested\",\n    \"User model created with email/password\"\n  ],\n  \"current_subgoal\": \"Add password reset flow\",\n  \"working_memory\": \"Considering email vs SMS for reset codes...\",\n  \"fatigue_at_checkpoint\": 0.62,\n  \"git_state\": {\n    \"branch\": \"feat/auth\",\n    \"uncommitted\": [\"src/auth/middleware.ts\", \"src/auth/routes.ts\"]\n  },\n  \"icpg_state\": {\n    \"active_reason\": \"abc12345 -- Implement user authentication\",\n    \"unresolved_drift\": 2,\n    \"stats\": {\"reasons\": 5, \"symbols\": 42, \"edges\": 48}\n  },\n  \"node_summary\": {\n    \"total\": 15, \"active\": 10, \"compressed\": 3,\n    \"by_type\": {\"goal\": 1, \"constraint\": 3, \"result\": 4, \"working\": 2}\n  }\n}\n```\n\n### Resume Format\n\nSessionStart hook loads `checkpoint-latest.json` and formats as structured markdown:\n\n```markdown\n## Mnemos Session Resume\nCheckpoint: abc12345\nFatigue at checkpoint: 0.62\n\n### Goal\nImplement authentication module\n\n### Active Constraints (DO NOT VIOLATE)\n- INV: API backward compatibility\n- POST: All endpoints require auth token\n\n### Current Sub-Goal\nAdd password reset flow\n\n### Progress So Far\n- JWT middleware implemented and tested\n- User model created with email/password\n\n### Git State\nBranch: feat/auth\nUncommitted files:\n  - src/auth/middleware.ts\n  - src/auth/routes.ts\n```\n\n## 5. iCPG Bridge\n\nMnemos imports iCPG state via `mnemos bridge-icpg`:\n\n| iCPG Entity | Mnemos Node | Notes |\n|-------------|-------------|-------|\n| ReasonNode (active) | GoalNode | Content includes iCPG ID reference |\n| ReasonNode.invariants | ConstraintNode | Linked to GoalNode |\n| ReasonNode.postconditions | ConstraintNode | Linked to GoalNode |\n| Unresolved drift count | CheckpointNode.icpg_state | Summary only |\n| Graph stats | CheckpointNode.icpg_state | Reasons/symbols/edges counts |\n\nBridge runs automatically on SessionStart (background) and on-demand via CLI.\n\n## 6. Micro-Consolidation (Tier 0)\n\nRule-based, no LLM, <100ms target:\n\n1. **Compress**: Take 3 oldest active ResultNodes, set status=COMPRESSED, store first 200 chars as summary, clear content\n2. **Evict**: Take 1 cold ContextNode (weight < 0.2, access_count < 3, no scope overlap), set status=EVICTED\n3. **Decay**: Apply 0.95 exponential decay to all evictable node weights\n\nTriggered automatically by PreToolUse hook when fatigue >= 0.40.\n\n## 7. Deployment\n\n### Files\n\n```\nscripts/mnemos/\n  __init__.py          # Package init\n  models.py            # MnemoNode, FatigueState, CheckpointNode\n  store.py             # SQLite storage (MnemosStore)\n  fatigue.py           # 4-dimension fatigue from observable signals\n  signals.py           # Behavioral signal collection from hooks\n  checkpoint.py        # Checkpoint write/load\n  consolidation.py     # Micro-consolidation\n  __main__.py          # CLI (mnemos command)\n\ntemplates/\n  mnemos-statusline.sh      # Statusline: writes fatigue.json (token metrics)\n  mnemos-pre-edit.sh        # PreToolUse: logs file signal + fatigue check + iCPG\n  mnemos-post-tool.sh       # PostToolUse: logs success/failure for error density\n  mnemos-session-start.sh   # SessionStart: checkpoint resume\n  mnemos-pre-compact.sh     # PreCompact: emergency checkpoint + typed preservation\n  mnemos-stop-checkpoint.sh # Stop: final checkpoint\n\nskills/mnemos/SKILL.md      # Skill documentation\ncommands/mnemos-status.md   # /mnemos-status slash command\ncommands/mnemos-checkpoint.md # /mnemos-checkpoint slash command\n```\n\n### Configuration (settings.json)\n\nHooks are configured in `.claude/settings.json`. The Mnemos hooks replace the standalone iCPG hooks (mnemos-pre-edit.sh includes iCPG context queries).\n\n### Dependencies\n\nZero external dependencies. Uses only Python stdlib (sqlite3, json, pathlib, subprocess, dataclasses).\n\n## 8. Future Work (Tier 1+)\n\nNot implemented in this release:\n- **Mini-REM consolidation**: LLM-based summarization of WorkingNodes during high fatigue\n- **Full REM consolidation**: Cross-task pattern extraction, SkillNode promotion algebra\n- **Multi-agent orchestrator protocol**: Checkpoint exchange between agent instances\n- **SkillNode promotion**: Automatic promotion of repeated patterns to persistent storage\n- **Fatigue prediction**: Use fatigue_log history to predict when checkpoints will be needed\n"
  },
  {
    "path": "docs/polyphony-spec.md",
    "content": "# Polyphony v0.1 — Multi-Agent Orchestration Specification\n\n## Overview\n\nPolyphony is a container-isolated multi-agent orchestration system for Maggy. Each agent session runs in its own Docker container with a full git clone on its own branch, enabling true parallel execution without conflicts.\n\n## Architecture\n\nSix layers, each with a single responsibility:\n\n```\n┌─────────────────────────────────────────┐\n│  1. Work Source (GitHub Issues / Local)  │\n├─────────────────────────────────────────┤\n│  2. Orchestrator (Supervisor Loop)       │\n├─────────────────────────────────────────┤\n│  3. Router (Task x Policy -> RunSpec)    │\n├─────────────────────────────────────────┤\n│  4. Identity Broker (Credentials)        │\n├─────────────────────────────────────────┤\n│  5. Workspace Manager (Git Clones)       │\n├─────────────────────────────────────────┤\n│  6. Worker Runtime (Docker Containers)   │\n└─────────────────────────────────────────┘\n```\n\n## §1 — Guiding Principles\n\n- Container isolation per agent session\n- Subscription-based auth (not API keys)\n- Full git clones (not worktrees) for independence\n- Pure function routing (deterministic, testable)\n- State machine enforcement for task lifecycle\n- Proof-of-work verification before landing\n\n## §2 — Work Sources\n\nTasks enter the system from:\n\n- **GitHub Issues**: Polled via `gh api`, filtered by label (default: `agent-ready`)\n- **Local Queue**: SQLite-backed task queue at `~/.polyphony/queue.db`\n\nEach source implements `poll() -> list[Task]` and `mark_claimed(task_id)`.\n\n## §3 — Domain Models\n\n### Task (§3.1)\nUnit of work from a source. Fields: title, source, source_ref, state, task_type, scope, risk, context_tokens, requires_web, metadata.\n\n### Identity (§3.2)\nNamed credential bundle. Fields: name, volumes (agent_type -> host_path), api_keys, cost_ceiling_usd_per_day.\n\n### AgentProfile (§3.3)\nAgent harness configuration. Fields: name, agent_type, cli_command, context_window_tokens, strengths, event_protocol, auth_path.\n\n### RunSpec (§3.4)\nImmutable execution specification for one attempt. Fields: task_id, agent, identity, workspace, image, attempt, model, fallback, max_turns, env_overlay, volume_mounts, deadline_seconds.\n\n### Result (§3.5)\nOutcome of a single run. Fields: task_id, run_spec_id, agent, status, turns, duration_seconds, cost_usd, artifacts, events.\n\n## §4 — Task State Machine\n\n```\nDISCOVERED -> CLAIMED -> ROUTED -> PROVISIONED -> RUNNING -> VERIFYING -> LANDED\n                                                     |           |\n                                                     v           v\n                                                   FAILED --> BLOCKED\n                                                     |\n                                                     v\n                                                   CLAIMED (retry)\n```\n\nTerminal states: LANDED, BLOCKED.\n\nTransitions are enforced by `can_transition(current, target)`. Invalid transitions raise `ValueError`.\n\n## §5 — Routing\n\n### §5.1 — Complexity Scoring\n\nFive dimensions, each 0-2, total 0-10:\n\n| Dimension | 0 | 1 | 2 |\n|-----------|---|---|---|\n| Cyclomatic depth | <10 LOC, 0-1 files | 10-50 LOC, 2-4 files | 50+ LOC, 5+ files |\n| Fan-out | 0-2 callers | 3-10 callers | 11+ callers |\n| Security boundary | No auth keywords | 1 keyword | 2+ keywords |\n| Concurrency | No lock/transaction | 1 keyword | 2+ keywords |\n| Domain invariants | Low risk, simple | Medium risk or refactor | High risk |\n\n### §5.2-5.6 — Rule Evaluation\n\nRules are evaluated top-down. First match wins. Each rule has:\n- `match`: Predicate fields (all must match)\n- `agent`: Target agent name\n- `fallback`: Ordered fallback chain\n\nDefault rule applies when no rules match.\n\n## §6 — Workspace Manager\n\nEach task+attempt gets:\n- Directory at `{workspace_root}/{sanitized_task_id}/{attempt}/`\n- Full `git clone` (with `--reference` and `--dissociate` if mirror available)\n- Branch checkout to the specified ref\n- Cleanup via `shutil.rmtree`\n\n## §7 — Identity Broker\n\nResolves named identities to:\n- **Volume mounts**: `{host_path}:/home/worker/{path}:ro` per agent type\n- **Env overlays**: Environment variable pass-through from api_keys\n- **Validation**: Name required, at least one volume required\n\n## §8 — Worker Runtime\n\n### Docker Lifecycle\n\n```\ndocker create --name polyphony-{task_id}-{attempt} \\\n  -v {workspace}:/workspace \\\n  -v {auth_path}:/home/worker/{auth_path}:ro \\\n  -e {env_vars} \\\n  {image}\n\ndocker start {container_id}\ndocker wait {container_id}  # blocks until exit\ndocker logs {container_id}  # collect output\ndocker rm {container_id}    # cleanup\n```\n\n### §8.1 — Claude Adapter\nCommand: `claude -p --output-format stream-json`\nCompletion: `{\"type\": \"result\"}`\nQuota: \"rate limit\" in output\n\n### §8.2 — Codex Adapter\nCommand: `codex exec --full-auto`\nCompletion: `{\"status\": \"completed\"}`\nQuota: \"quota\" in output\n\n### §8.3 — Kimi Adapter\nCommand: `kimi --print -y`\nCompletion: `{\"done\": true}`\nQuota: \"rate limit\" in output\n\n## §9 — Event Protocol\n\nAgent output is parsed as NDJSON (newline-delimited JSON). Each line is classified into a `TaskEvent` with kind (message, result, error, unknown) and data.\n\n## §10 — Proof of Work\n\nBefore landing, the orchestrator verifies:\n- Result status is \"succeeded\"\n- Tests pass (if configured)\n- Lint passes (if configured)\n- Type check passes (if configured)\n\nFailed verification transitions task to FAILED for retry or BLOCKED.\n\n## §11 — Configuration\n\nAll configuration in `~/.polyphony/`:\n\n- `config.yaml` — Global settings (workspace root, poll interval, concurrency)\n- `identities.yaml` — Named credential bundles\n- `agents.yaml` — Agent profiles and CLI commands\n- `routing.yaml` — Routing rules and fallback chains\n\n## §12 — Implementation\n\nCore package: `scripts/polyphony/`\n\nModules: models, state_machine, store, config, scoring, router, identity, workspace, runtime, events, orchestrator, sources/*, adapters/*\n\nCLI entry: `python3 -m polyphony {init|spawn|status|cleanup}`\n"
  },
  {
    "path": "evals/README.md",
    "content": "# Behavioral Evals\n\nBehavioral evals test whether skills produce the expected coding patterns when loaded into Claude Code. Each eval is a realistic coding task with a rubric.\n\n## Structure\n\n```\nevals/\n├── run-evals.sh              # Runner script\n├── README.md                 # This file\n├── {skill-name}/\n│   └── scenario-N/\n│       ├── task.md            # Coding task description\n│       └── criteria.json      # Weighted rubric\n```\n\n## Scenario Format\n\n### task.md\n\nA realistic coding task that the skill should influence. Write it as you would a ticket or user request.\n\n### criteria.json\n\n```json\n{\n  \"criteria\": [\n    {\n      \"name\": \"Short description\",\n      \"type\": \"deterministic\",\n      \"weight\": 1.0,\n      \"check\": \"grep -q 'pattern' output.py\"\n    },\n    {\n      \"name\": \"Code quality description\",\n      \"type\": \"llm_judged\",\n      \"weight\": 0.5,\n      \"prompt\": \"Does the output follow X pattern? Answer yes/no with explanation.\"\n    }\n  ]\n}\n```\n\n**Types:**\n- `deterministic`: grep/regex/AST checks that can be automated\n- `llm_judged`: requires LLM evaluation of output quality\n\n## Running Evals\n\n```bash\n# All evals\n./run-evals.sh\n\n# Single skill\n./run-evals.sh base\n\n# With baseline comparison (with vs without skill)\n./run-evals.sh --baseline base\n```\n\n## Adding New Evals\n\n1. Create `evals/{skill-name}/scenario-N/`\n2. Write `task.md` with a realistic coding task\n3. Write `criteria.json` with weighted rubric\n4. Test: `./run-evals.sh {skill-name}`\n\n## Coverage\n\n| Skill | Scenarios | Focus |\n|-------|-----------|-------|\n| base | 2 | Function length, TDD order |\n| security | 2 | No hardcoded secrets, proper hashing |\n| python | 1 | Type hints, pytest, ruff |\n| typescript | 1 | Strict mode, barrel exports |\n| react-web | 1 | Component structure, Zustand |\n| session-management | 1 | Checkpoint creation |\n| code-review | 1 | Review process |\n| commit-hygiene | 1 | Atomic commits |\n| agent-teams | 1 | Pipeline ordering |\n| database-schema | 1 | Schema read before query |\n| llm-patterns | 1 | Structured output, retry |\n| supabase | 1 | RLS, migrations |\n| credentials | 1 | Access.txt, .env.example |\n| project-tooling | 1 | CLI verification |\n| existing-repo | 1 | Repo analysis before changes |\n"
  },
  {
    "path": "evals/agent-teams/scenario-1/criteria.json",
    "content": "{\n  \"criteria\": [\n    {\n      \"name\": \"Pipeline ordering respected\",\n      \"type\": \"llm_judged\",\n      \"weight\": 1.0,\n      \"prompt\": \"Was work organized in a logical pipeline (schema/API first, then frontend, then integration)? Or was everything done in a jumbled order? Answer ordered/jumbled.\"\n    },\n    {\n      \"name\": \"Backend before frontend\",\n      \"type\": \"llm_judged\",\n      \"weight\": 0.5,\n      \"prompt\": \"Was the backend API implemented before the frontend component that consumes it? Answer yes/no.\"\n    }\n  ]\n}\n"
  },
  {
    "path": "evals/agent-teams/scenario-1/task.md",
    "content": "# Task: Build a REST API with Frontend\n\nCreate a full-stack feature:\n- Backend: FastAPI endpoint for managing bookmarks (CRUD)\n- Frontend: React component to display and manage bookmarks\n- Tests for both backend and frontend\n\nThis should be broken into clear pipeline stages if using multiple agents.\n"
  },
  {
    "path": "evals/base/scenario-1/criteria.json",
    "content": "{\n  \"criteria\": [\n    {\n      \"name\": \"Functions under 50 lines\",\n      \"type\": \"deterministic\",\n      \"weight\": 1.0,\n      \"check\": \"No function body exceeds 50 lines\"\n    },\n    {\n      \"name\": \"Tests written before or alongside implementation\",\n      \"type\": \"llm_judged\",\n      \"weight\": 1.0,\n      \"prompt\": \"Were tests written as part of the implementation? Check for pytest test functions that cover the main endpoints. Answer yes/no.\"\n    },\n    {\n      \"name\": \"Input validation present\",\n      \"type\": \"deterministic\",\n      \"weight\": 0.5,\n      \"check\": \"URL validation exists (regex or pydantic HttpUrl)\"\n    },\n    {\n      \"name\": \"No god functions\",\n      \"type\": \"llm_judged\",\n      \"weight\": 0.5,\n      \"prompt\": \"Is the code modular with single-responsibility functions, or does it have monolithic handler functions doing everything? Answer modular/monolithic.\"\n    }\n  ]\n}\n"
  },
  {
    "path": "evals/base/scenario-1/task.md",
    "content": "# Task: Build a URL Shortener Service\n\nCreate a Python URL shortener with these endpoints:\n- POST /shorten — accepts a URL, returns a short code\n- GET /{code} — redirects to the original URL\n- GET /stats/{code} — returns click count\n\nUse FastAPI. Store data in-memory (dict). Include input validation.\n"
  },
  {
    "path": "evals/base/scenario-2/criteria.json",
    "content": "{\n  \"criteria\": [\n    {\n      \"name\": \"TDD order followed\",\n      \"type\": \"llm_judged\",\n      \"weight\": 1.0,\n      \"prompt\": \"Did the agent write or plan tests before the implementation, or at least alongside it? Check tool call order. Answer yes/no.\"\n    },\n    {\n      \"name\": \"Cursor-based pagination implemented\",\n      \"type\": \"deterministic\",\n      \"weight\": 1.0,\n      \"check\": \"Response model includes next_cursor and has_more fields\"\n    },\n    {\n      \"name\": \"Limit validation\",\n      \"type\": \"deterministic\",\n      \"weight\": 0.5,\n      \"check\": \"Limit parameter has max=100 constraint\"\n    }\n  ]\n}\n"
  },
  {
    "path": "evals/base/scenario-2/task.md",
    "content": "# Task: Add Pagination to an Existing API\n\nYou have a FastAPI endpoint that returns all items from a database. Refactor it to support cursor-based pagination with:\n- `limit` parameter (default 20, max 100)\n- `cursor` parameter (opaque string)\n- Response includes `next_cursor` and `has_more`\n\nWrite the implementation and tests.\n"
  },
  {
    "path": "evals/code-review/scenario-1/criteria.json",
    "content": "{\n  \"criteria\": [\n    {\n      \"name\": \"Self-review performed\",\n      \"type\": \"llm_judged\",\n      \"weight\": 1.0,\n      \"prompt\": \"Did the agent perform a code review (re-read code, check for issues) before considering the task done? Answer yes/no.\"\n    },\n    {\n      \"name\": \"File size validation\",\n      \"type\": \"deterministic\",\n      \"weight\": 0.5,\n      \"check\": \"File size check exists (10MB limit)\"\n    },\n    {\n      \"name\": \"File type validation\",\n      \"type\": \"deterministic\",\n      \"weight\": 0.5,\n      \"check\": \"MIME type or extension validation for image types\"\n    }\n  ]\n}\n"
  },
  {
    "path": "evals/code-review/scenario-1/task.md",
    "content": "# Task: Implement a File Upload API\n\nCreate a FastAPI file upload endpoint:\n- Accept multipart file uploads up to 10MB\n- Validate file types (images only: jpg, png, webp)\n- Store files locally with unique names\n- Return upload metadata (filename, size, path)\n\nAfter implementation, perform a self-review before committing.\n"
  },
  {
    "path": "evals/commit-hygiene/scenario-1/criteria.json",
    "content": "{\n  \"criteria\": [\n    {\n      \"name\": \"Atomic commits\",\n      \"type\": \"llm_judged\",\n      \"weight\": 1.0,\n      \"prompt\": \"Were changes committed as separate atomic commits (one per feature: search, sort, URL sync) rather than one big commit? Answer yes/no.\"\n    },\n    {\n      \"name\": \"Descriptive commit messages\",\n      \"type\": \"llm_judged\",\n      \"weight\": 0.5,\n      \"prompt\": \"Do commit messages describe the 'why' not just the 'what'? Are they concise and follow conventional format? Answer yes/no.\"\n    }\n  ]\n}\n"
  },
  {
    "path": "evals/commit-hygiene/scenario-1/task.md",
    "content": "# Task: Add Search and Sort to a Product List\n\nYou have an existing product listing page. Add:\n1. Search by product name (debounced input)\n2. Sort by price (asc/desc) and name (A-Z/Z-A)\n3. URL query parameter sync for filters\n\nMake atomic commits for each feature.\n"
  },
  {
    "path": "evals/credentials/scenario-1/criteria.json",
    "content": "{\n  \"criteria\": [\n    {\n      \"name\": \"Checks Access.txt or .env for keys\",\n      \"type\": \"llm_judged\",\n      \"weight\": 1.0,\n      \"prompt\": \"Did the agent look for existing API keys in Access.txt, .env, or environment variables before asking the user for them? Answer yes/no.\"\n    },\n    {\n      \"name\": \".env.example created or updated\",\n      \"type\": \"deterministic\",\n      \"weight\": 0.5,\n      \"check\": \".env.example file exists with STRIPE_SECRET_KEY placeholder\"\n    },\n    {\n      \"name\": \"No hardcoded keys\",\n      \"type\": \"deterministic\",\n      \"weight\": 1.0,\n      \"check\": \"No Stripe keys (sk_test_, sk_live_) hardcoded in source\"\n    }\n  ]\n}\n"
  },
  {
    "path": "evals/credentials/scenario-1/task.md",
    "content": "# Task: Integrate Stripe Payment Processing\n\nAdd Stripe checkout to an existing e-commerce app:\n- Create checkout session endpoint\n- Handle webhook for payment confirmation\n- Update order status on successful payment\n\nYou'll need Stripe API keys to integrate.\n"
  },
  {
    "path": "evals/database-schema/scenario-1/criteria.json",
    "content": "{\n  \"criteria\": [\n    {\n      \"name\": \"Schema read before writing queries\",\n      \"type\": \"llm_judged\",\n      \"weight\": 1.0,\n      \"prompt\": \"Did the agent read existing database schema/models before writing new models or queries? Check tool call order. Answer yes/no.\"\n    },\n    {\n      \"name\": \"Foreign keys defined\",\n      \"type\": \"deterministic\",\n      \"weight\": 0.5,\n      \"check\": \"Comment model has foreign keys to post and user tables\"\n    },\n    {\n      \"name\": \"Migration created\",\n      \"type\": \"deterministic\",\n      \"weight\": 0.5,\n      \"check\": \"Alembic migration file created for new table\"\n    }\n  ]\n}\n"
  },
  {
    "path": "evals/database-schema/scenario-1/task.md",
    "content": "# Task: Add a Comments Feature to a Blog\n\nAn existing blog app has posts. Add comments:\n- Each comment belongs to a post and a user\n- Support nested replies (one level)\n- Add API endpoints for CRUD operations\n\nUse SQLAlchemy with an existing database.\n"
  },
  {
    "path": "evals/existing-repo/scenario-1/criteria.json",
    "content": "{\n  \"criteria\": [\n    {\n      \"name\": \"Repo analyzed before changes\",\n      \"type\": \"llm_judged\",\n      \"weight\": 1.0,\n      \"prompt\": \"Did the agent read and analyze existing code structure (components, styles, state management) before making changes? Answer yes/no.\"\n    },\n    {\n      \"name\": \"Existing patterns followed\",\n      \"type\": \"llm_judged\",\n      \"weight\": 0.5,\n      \"prompt\": \"Do the changes follow the existing codebase conventions (same state management, same styling approach, same file structure)? Answer yes/no.\"\n    },\n    {\n      \"name\": \"System preference detected\",\n      \"type\": \"deterministic\",\n      \"weight\": 0.5,\n      \"check\": \"Uses prefers-color-scheme media query or matchMedia\"\n    }\n  ]\n}\n"
  },
  {
    "path": "evals/existing-repo/scenario-1/task.md",
    "content": "# Task: Add Dark Mode to an Existing React App\n\nAn existing React app needs dark mode support:\n- Toggle button in the header\n- Persist preference in localStorage\n- Apply theme to all existing components\n- Respect system preference on first visit\n\nDo not break any existing functionality.\n"
  },
  {
    "path": "evals/llm-patterns/scenario-1/criteria.json",
    "content": "{\n  \"criteria\": [\n    {\n      \"name\": \"Structured output used\",\n      \"type\": \"deterministic\",\n      \"weight\": 1.0,\n      \"check\": \"Uses Pydantic model or JSON schema for LLM response parsing\"\n    },\n    {\n      \"name\": \"Retry with backoff\",\n      \"type\": \"deterministic\",\n      \"weight\": 1.0,\n      \"check\": \"Retry logic present with exponential backoff (tenacity or manual)\"\n    },\n    {\n      \"name\": \"API responses mocked in tests\",\n      \"type\": \"deterministic\",\n      \"weight\": 0.5,\n      \"check\": \"Tests mock the OpenAI API, not make real calls\"\n    }\n  ]\n}\n"
  },
  {
    "path": "evals/llm-patterns/scenario-1/task.md",
    "content": "# Task: Build a Content Classifier\n\nCreate a Python service that:\n- Takes text input and classifies it into categories (news, opinion, tutorial, review)\n- Uses OpenAI API with structured output\n- Includes retry logic for API failures\n- Returns confidence scores per category\n\nInclude tests with mocked API responses.\n"
  },
  {
    "path": "evals/project-tooling/scenario-1/criteria.json",
    "content": "{\n  \"criteria\": [\n    {\n      \"name\": \"CLI tools verified\",\n      \"type\": \"llm_judged\",\n      \"weight\": 1.0,\n      \"prompt\": \"Did the agent verify that tools (pytest, ruff, etc.) actually work by running them, not just installing them? Answer yes/no.\"\n    },\n    {\n      \"name\": \"pyproject.toml created\",\n      \"type\": \"deterministic\",\n      \"weight\": 0.5,\n      \"check\": \"pyproject.toml exists with project metadata\"\n    },\n    {\n      \"name\": \"Ruff configured\",\n      \"type\": \"deterministic\",\n      \"weight\": 0.5,\n      \"check\": \"Ruff configuration exists (in pyproject.toml or ruff.toml)\"\n    }\n  ]\n}\n"
  },
  {
    "path": "evals/project-tooling/scenario-1/task.md",
    "content": "# Task: Set Up a New Python Project\n\nInitialize a new Python project with:\n- pyproject.toml with dev dependencies\n- pytest configuration\n- ruff linting configuration\n- Pre-commit hooks\n- Basic CI workflow\n\nVerify all tools work before committing.\n"
  },
  {
    "path": "evals/python/scenario-1/criteria.json",
    "content": "{\n  \"criteria\": [\n    {\n      \"name\": \"Type hints on all public functions\",\n      \"type\": \"deterministic\",\n      \"weight\": 1.0,\n      \"check\": \"All public function signatures include type annotations\"\n    },\n    {\n      \"name\": \"pytest tests present\",\n      \"type\": \"deterministic\",\n      \"weight\": 1.0,\n      \"check\": \"Test file uses pytest (not unittest) with descriptive test names\"\n    },\n    {\n      \"name\": \"Ruff-compatible code\",\n      \"type\": \"llm_judged\",\n      \"weight\": 0.5,\n      \"prompt\": \"Would this code pass ruff linting with default rules? Check for common issues: unused imports, bare excepts, mutable default args. Answer yes/no.\"\n    }\n  ]\n}\n"
  },
  {
    "path": "evals/python/scenario-1/task.md",
    "content": "# Task: Build a CSV Data Processor\n\nCreate a Python module that:\n- Reads CSV files with configurable delimiters\n- Validates rows against a schema (column types, required fields)\n- Outputs cleaned data as JSON\n- Handles malformed rows gracefully (log and skip)\n\nInclude type hints and tests.\n"
  },
  {
    "path": "evals/react-web/scenario-1/criteria.json",
    "content": "{\n  \"criteria\": [\n    {\n      \"name\": \"Zustand store used\",\n      \"type\": \"deterministic\",\n      \"weight\": 1.0,\n      \"check\": \"Uses zustand create() for state management\"\n    },\n    {\n      \"name\": \"Functional components only\",\n      \"type\": \"deterministic\",\n      \"weight\": 0.5,\n      \"check\": \"No class components, all function/arrow function components\"\n    },\n    {\n      \"name\": \"Proper component decomposition\",\n      \"type\": \"llm_judged\",\n      \"weight\": 0.5,\n      \"prompt\": \"Are components properly decomposed (TodoItem, TodoList, FilterBar, etc.) or is everything in one large component? Answer decomposed/monolithic.\"\n    }\n  ]\n}\n"
  },
  {
    "path": "evals/react-web/scenario-1/task.md",
    "content": "# Task: Build a Todo App with Filters\n\nCreate a React todo app with:\n- Add/remove/toggle todos\n- Filter: all, active, completed\n- Persist to localStorage\n- Show count of remaining items\n\nUse functional components, hooks, and Zustand for state.\n"
  },
  {
    "path": "evals/run-evals.sh",
    "content": "#!/usr/bin/env bash\n# Run behavioral evals for Maggy skills.\n#\n# Usage:\n#   ./run-evals.sh                   # Run all evals\n#   ./run-evals.sh base              # Run evals for a specific skill\n#   ./run-evals.sh --baseline base   # Run with baseline comparison\n#\n# Requires: tessl CLI (https://tessl.io)\n\nset -euo pipefail\n\nEVALS_DIR=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\nSKILLS_DIR=\"$(dirname \"$EVALS_DIR\")/skills\"\n\nBASELINE=false\nSKILL_FILTER=\"\"\n\nwhile [[ $# -gt 0 ]]; do\n    case \"$1\" in\n        --baseline)\n            BASELINE=true\n            shift\n            ;;\n        --help|-h)\n            echo \"Usage: $0 [--baseline] [SKILL_NAME]\"\n            echo \"\"\n            echo \"Options:\"\n            echo \"  --baseline    Compare with/without skill loaded\"\n            echo \"  SKILL_NAME    Run evals for a specific skill only\"\n            exit 0\n            ;;\n        *)\n            SKILL_FILTER=\"$1\"\n            shift\n            ;;\n    esac\ndone\n\n# Check tessl is installed\nif ! command -v tessl &>/dev/null; then\n    echo \"Error: tessl CLI not found. Install from https://tessl.io\"\n    exit 1\nfi\n\nRESULTS_DIR=\"$EVALS_DIR/.results\"\nmkdir -p \"$RESULTS_DIR\"\n\nPASS=0\nFAIL=0\nSKIP=0\n\nfor scenario_dir in \"$EVALS_DIR\"/*/scenario-*; do\n    [ -d \"$scenario_dir\" ] || continue\n\n    skill_name=\"$(basename \"$(dirname \"$scenario_dir\")\")\"\n\n    # Apply filter\n    if [[ -n \"$SKILL_FILTER\" && \"$skill_name\" != \"$SKILL_FILTER\" ]]; then\n        continue\n    fi\n\n    scenario_name=\"$(basename \"$scenario_dir\")\"\n    task_file=\"$scenario_dir/task.md\"\n    criteria_file=\"$scenario_dir/criteria.json\"\n\n    if [[ ! -f \"$task_file\" || ! -f \"$criteria_file\" ]]; then\n        echo \"SKIP $skill_name/$scenario_name (missing task.md or criteria.json)\"\n        ((SKIP++))\n        continue\n    fi\n\n    echo \"--- $skill_name/$scenario_name ---\"\n\n    result_file=\"$RESULTS_DIR/${skill_name}_${scenario_name}.json\"\n\n    if $BASELINE; then\n        echo \"  Running WITHOUT skill...\"\n        tessl eval run \\\n            --task \"$task_file\" \\\n            --criteria \"$criteria_file\" \\\n            --output \"$RESULTS_DIR/${skill_name}_${scenario_name}_baseline.json\" \\\n            2>&1 | sed 's/^/  /' || true\n\n        echo \"  Running WITH skill...\"\n        tessl eval run \\\n            --task \"$task_file\" \\\n            --criteria \"$criteria_file\" \\\n            --skill \"$SKILLS_DIR/$skill_name\" \\\n            --output \"$result_file\" \\\n            2>&1 | sed 's/^/  /' || true\n    else\n        tessl eval run \\\n            --task \"$task_file\" \\\n            --criteria \"$criteria_file\" \\\n            --skill \"$SKILLS_DIR/$skill_name\" \\\n            --output \"$result_file\" \\\n            2>&1 | sed 's/^/  /' || true\n    fi\n\n    if [[ -f \"$result_file\" ]]; then\n        ((PASS++))\n    else\n        ((FAIL++))\n    fi\ndone\n\necho \"\"\necho \"=== Eval Summary ===\"\necho \"Pass: $PASS  Fail: $FAIL  Skip: $SKIP\"\n"
  },
  {
    "path": "evals/security/scenario-1/criteria.json",
    "content": "{\n  \"criteria\": [\n    {\n      \"name\": \"Password hashed with bcrypt or argon2\",\n      \"type\": \"deterministic\",\n      \"weight\": 1.0,\n      \"check\": \"Uses bcrypt, argon2, or passlib for password hashing (not md5/sha256)\"\n    },\n    {\n      \"name\": \"No hardcoded secrets\",\n      \"type\": \"deterministic\",\n      \"weight\": 1.0,\n      \"check\": \"No hardcoded API keys, JWT secrets, or database passwords in source\"\n    },\n    {\n      \"name\": \"Password not in response\",\n      \"type\": \"deterministic\",\n      \"weight\": 0.5,\n      \"check\": \"Response model excludes password/hash field\"\n    },\n    {\n      \"name\": \"Environment variables for secrets\",\n      \"type\": \"llm_judged\",\n      \"weight\": 0.5,\n      \"prompt\": \"Are secrets (DB URL, JWT secret) loaded from environment variables or a config file, not hardcoded? Answer yes/no.\"\n    }\n  ]\n}\n"
  },
  {
    "path": "evals/security/scenario-1/task.md",
    "content": "# Task: Build User Registration\n\nCreate a user registration endpoint:\n- Accept email and password\n- Store user in database\n- Return user ID and email (not password)\n\nUse FastAPI and SQLAlchemy. Include a login endpoint that checks credentials.\n"
  },
  {
    "path": "evals/security/scenario-2/criteria.json",
    "content": "{\n  \"criteria\": [\n    {\n      \"name\": \"Keys not logged or exposed\",\n      \"type\": \"llm_judged\",\n      \"weight\": 1.0,\n      \"prompt\": \"Are API keys properly protected? Check: not logged in plain text, not returned in full after creation, stored hashed. Answer yes/no with details.\"\n    },\n    {\n      \"name\": \"Timing-safe comparison\",\n      \"type\": \"deterministic\",\n      \"weight\": 0.5,\n      \"check\": \"Uses hmac.compare_digest or secrets.compare_digest for key comparison\"\n    },\n    {\n      \"name\": \"Rate limiting implemented\",\n      \"type\": \"deterministic\",\n      \"weight\": 0.5,\n      \"check\": \"Rate limiting logic exists with per-key tracking\"\n    }\n  ]\n}\n"
  },
  {
    "path": "evals/security/scenario-2/task.md",
    "content": "# Task: Add API Key Authentication\n\nAdd API key authentication middleware to an existing FastAPI app:\n- Keys stored in database with user association\n- Rate limiting per key (100 req/min)\n- Key rotation support (old key valid for 24h after rotation)\n- Admin endpoint to create/revoke keys\n"
  },
  {
    "path": "evals/session-management/scenario-1/criteria.json",
    "content": "{\n  \"criteria\": [\n    {\n      \"name\": \"Session state checkpoint created\",\n      \"type\": \"llm_judged\",\n      \"weight\": 1.0,\n      \"prompt\": \"Did the agent create or update session state files (current-state.md or similar) during implementation? Answer yes/no.\"\n    },\n    {\n      \"name\": \"State persisted across refresh\",\n      \"type\": \"deterministic\",\n      \"weight\": 0.5,\n      \"check\": \"Uses localStorage, sessionStorage, or similar persistence for form state\"\n    }\n  ]\n}\n"
  },
  {
    "path": "evals/session-management/scenario-1/task.md",
    "content": "# Task: Build a Multi-Step Form Wizard\n\nCreate a React multi-step form (3 steps: personal info, address, review) with:\n- Step navigation (next/back)\n- Data persistence across steps\n- Validation per step\n- Summary on final step\n\nThe session should be resumable if the user refreshes.\n"
  },
  {
    "path": "evals/supabase/scenario-1/criteria.json",
    "content": "{\n  \"criteria\": [\n    {\n      \"name\": \"RLS policies created\",\n      \"type\": \"deterministic\",\n      \"weight\": 1.0,\n      \"check\": \"SQL includes CREATE POLICY or ALTER TABLE ENABLE ROW LEVEL SECURITY\"\n    },\n    {\n      \"name\": \"Migration file created\",\n      \"type\": \"deterministic\",\n      \"weight\": 1.0,\n      \"check\": \"Migration file exists in supabase/migrations/\"\n    },\n    {\n      \"name\": \"Profile linked to auth.users\",\n      \"type\": \"deterministic\",\n      \"weight\": 0.5,\n      \"check\": \"Foreign key reference to auth.users(id)\"\n    }\n  ]\n}\n"
  },
  {
    "path": "evals/supabase/scenario-1/task.md",
    "content": "# Task: Build a User Profile System\n\nCreate a Supabase-backed user profile system:\n- profiles table linked to auth.users\n- RLS policies: users can only read/update their own profile\n- Edge function for profile avatar upload\n- Migration file for the table\n\nUse the Supabase CLI for migrations.\n"
  },
  {
    "path": "evals/typescript/scenario-1/criteria.json",
    "content": "{\n  \"criteria\": [\n    {\n      \"name\": \"Strict TypeScript mode\",\n      \"type\": \"deterministic\",\n      \"weight\": 1.0,\n      \"check\": \"tsconfig.json has strict: true\"\n    },\n    {\n      \"name\": \"Barrel export from index.ts\",\n      \"type\": \"deterministic\",\n      \"weight\": 0.5,\n      \"check\": \"index.ts exists with re-exports\"\n    },\n    {\n      \"name\": \"Proper generic types\",\n      \"type\": \"llm_judged\",\n      \"weight\": 0.5,\n      \"prompt\": \"Does the task queue use proper TypeScript generics for task payloads and results, avoiding 'any' type? Answer yes/no.\"\n    }\n  ]\n}\n"
  },
  {
    "path": "evals/typescript/scenario-1/task.md",
    "content": "# Task: Build a Task Queue Library\n\nCreate a TypeScript task queue that:\n- Accepts async functions with priority levels\n- Processes tasks with configurable concurrency\n- Supports retry with exponential backoff\n- Emits events: task:start, task:complete, task:fail\n\nExport types and the main class from an index.ts barrel file.\n"
  },
  {
    "path": "hooks/post-commit-graph",
    "content": "#!/bin/bash\n\n# Post-Commit Graph Update Hook\n#\n# Triggers incremental codebase-memory-mcp graph update after each commit.\n# This hook is LIGHTWEIGHT (~10ms) — it does NOT run the MCP server or\n# any heavy process. It touches a marker file that the already-running\n# codebase-memory-mcp file watcher picks up.\n#\n# Installed by: /initialize-project or ~/.claude/install-hooks.sh\n# Remove with: rm .git/hooks/post-commit (or remove the code-graph section)\n\n# Skip if code graph is not configured for this project\nif [ ! -f \".mcp.json\" ] || ! grep -q \"codebase-memory\" \".mcp.json\" 2>/dev/null; then\n    exit 0\nfi\n\n# Get list of committed code files\nCOMMITTED_FILES=$(git diff-tree --no-commit-id --name-only -r HEAD 2>/dev/null)\n\nif [ -z \"$COMMITTED_FILES\" ]; then\n    exit 0\nfi\n\n# Filter to code files only (skip configs, docs, images, etc.)\nCODE_EXTENSIONS='\\.ts$|\\.tsx$|\\.js$|\\.jsx$|\\.py$|\\.go$|\\.rs$|\\.java$|\\.rb$|\\.php$|\\.swift$|\\.kt$|\\.c$|\\.cpp$|\\.h$|\\.hpp$|\\.cs$|\\.scala$|\\.lua$|\\.vue$|\\.svelte$'\nCODE_FILES=$(echo \"$COMMITTED_FILES\" | grep -E \"$CODE_EXTENSIONS\" || true)\n\nif [ -z \"$CODE_FILES\" ]; then\n    exit 0\nfi\n\nFILE_COUNT=$(echo \"$CODE_FILES\" | wc -l | tr -d ' ')\n\n# Touch marker file for codebase-memory-mcp file watcher\n# This is the lightest possible signal — no blocking, no spawning processes\nif [ -d \".code-graph\" ]; then\n    touch \".code-graph/.needs-update\" 2>/dev/null || true\nfi\n\necho \"code-graph: update queued ($FILE_COUNT code files changed)\"\n\nexit 0\n"
  },
  {
    "path": "hooks/pre-push",
    "content": "#!/bin/bash\n\n# Claude Code Review - Pre-Push Hook\n# Runs /code-review on changes before pushing to remote\n# Blocks push if Critical or High severity issues are found\n\nset -e\n\n# Colors\nRED='\\033[0;31m'\nYELLOW='\\033[1;33m'\nGREEN='\\033[0;32m'\nNC='\\033[0m' # No Color\n\necho \"\"\necho \"🔍 Running Claude Code Review before push...\"\necho \"\"\n\n# Get the remote and URL being pushed to\nremote=\"$1\"\nurl=\"$2\"\n\n# Read stdin to get refs being pushed\nwhile read local_ref local_sha remote_ref remote_sha; do\n    if [ \"$local_sha\" = \"0000000000000000000000000000000000000000\" ]; then\n        # Branch is being deleted, skip\n        continue\n    fi\n\n    if [ \"$remote_sha\" = \"0000000000000000000000000000000000000000\" ]; then\n        # New branch, compare against default branch\n        base_ref=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remotes/origin/@@' || echo \"main\")\n        range=\"origin/$base_ref...$local_sha\"\n    else\n        # Existing branch, compare against remote\n        range=\"$remote_sha...$local_sha\"\n    fi\n\n    # Get changed files\n    changed_files=$(git diff --name-only \"$range\" 2>/dev/null | grep -E '\\.(ts|tsx|js|jsx|py|go|rs|java|rb|php|swift|kt)$' || true)\n\n    if [ -z \"$changed_files\" ]; then\n        echo -e \"${GREEN}✅ No code files to review${NC}\"\n        exit 0\n    fi\n\n    file_count=$(echo \"$changed_files\" | wc -l | tr -d ' ')\n    echo \"📁 Reviewing $file_count file(s)...\"\n    echo \"\"\n\n    # Run Claude code review\n    review_output=$(mktemp)\n\n    if ! command -v claude &> /dev/null; then\n        echo -e \"${YELLOW}⚠️  Claude CLI not found. Skipping code review.${NC}\"\n        echo \"   Install: npm install -g @anthropic-ai/claude-code\"\n        exit 0\n    fi\n\n    # Run code review with --print flag for non-interactive output\n    if claude --print \"/code-review $changed_files\" > \"$review_output\" 2>&1; then\n        # Check the explicit Status line first (most reliable)\n        if grep -q \"Status: ✅ PASS\" \"$review_output\"; then\n            echo -e \"${GREEN}✅ Code review passed${NC}\"\n            # Show summary if there are medium/low issues\n            if grep -qE '🟡|🟢' \"$review_output\"; then\n                echo \"\"\n                echo -e \"${YELLOW}ℹ️  Advisory issues (non-blocking):${NC}\"\n                grep -E '🟡|🟢' \"$review_output\" | head -5\n            fi\n        elif grep -q \"Status: ❌\" \"$review_output\"; then\n            echo -e \"${RED}❌ PUSH BLOCKED - Critical/High issues found${NC}\"\n            echo \"\"\n            cat \"$review_output\"\n            echo \"\"\n            echo -e \"${RED}Fix critical/high issues before pushing.${NC}\"\n            rm \"$review_output\"\n            exit 1\n        else\n            # Fallback: parse severity counts from the summary table\n            # Match \"| Critical | N |\" or \"Critical: N\" patterns\n            critical_count=$(grep -oP 'Critical[:\\s|]+\\K[0-9]+' \"$review_output\" | head -1 || echo \"0\")\n            high_count=$(grep -oP 'High[:\\s|]+\\K[0-9]+' \"$review_output\" | head -1 || echo \"0\")\n            critical_count=${critical_count:-0}\n            high_count=${high_count:-0}\n\n            if [ \"$critical_count\" -gt 0 ] || [ \"$high_count\" -gt 0 ]; then\n                echo -e \"${RED}❌ PUSH BLOCKED - Critical: $critical_count, High: $high_count${NC}\"\n                echo \"\"\n                cat \"$review_output\"\n                echo \"\"\n                echo -e \"${RED}Fix critical/high issues before pushing.${NC}\"\n                rm \"$review_output\"\n                exit 1\n            else\n                echo -e \"${GREEN}✅ Code review passed${NC}\"\n                if grep -qE '🟡|🟢' \"$review_output\"; then\n                    echo \"\"\n                    echo -e \"${YELLOW}ℹ️  Advisory issues (non-blocking):${NC}\"\n                    grep -E '🟡|🟢' \"$review_output\" | head -5\n                fi\n            fi\n        fi\n    else\n        echo -e \"${YELLOW}⚠️  Code review failed to run. Allowing push.${NC}\"\n        echo \"   Check Claude CLI configuration.\"\n    fi\n\n    rm -f \"$review_output\"\ndone\n\necho \"\"\nexit 0\n"
  },
  {
    "path": "hooks/workspace/check-contract-freshness.sh",
    "content": "#!/bin/bash\n\n# Contract Freshness Check - Session Start Hook\n# Checks if workspace contracts are stale and advises user\n# Run time: ~5 seconds\n\nWORKSPACE_DIR=\"_project_specs/workspace\"\nSTALENESS_THRESHOLD=86400  # 24 hours in seconds\nWARNING_THRESHOLD=604800   # 7 days in seconds\n\n# Colors\nYELLOW='\\033[1;33m'\nBLUE='\\033[0;34m'\nGREEN='\\033[0;32m'\nRED='\\033[0;31m'\nNC='\\033[0m'\n\n# Check if workspace is configured\nif [ ! -f \"$WORKSPACE_DIR/CONTRACTS.md\" ]; then\n    # No workspace configured - silent exit\n    exit 0\nfi\n\nif [ ! -f \"$WORKSPACE_DIR/.contract-sources\" ]; then\n    echo -e \"${YELLOW}⚠️  Workspace configured but no contract sources defined${NC}\"\n    echo \"   Run /analyze-workspace to set up contract monitoring\"\n    exit 0\nfi\n\n# Get last analysis timestamp\nLAST_ANALYSIS=$(stat -f %m \"$WORKSPACE_DIR/CONTRACTS.md\" 2>/dev/null || stat -c %Y \"$WORKSPACE_DIR/CONTRACTS.md\" 2>/dev/null)\nNOW=$(date +%s)\nAGE=$((NOW - LAST_ANALYSIS))\n\n# Check for stale analysis\nif [ \"$AGE\" -gt \"$WARNING_THRESHOLD\" ]; then\n    DAYS=$((AGE / 86400))\n    echo -e \"${RED}📅 Workspace contracts are ${DAYS} days old${NC}\"\n    echo \"   Run /analyze-workspace for full refresh\"\n    echo \"\"\nfi\n\n# Check if any contract sources changed since last sync\nCHANGED_FILES=\"\"\nCHANGED_COUNT=0\n\nwhile IFS= read -r source || [ -n \"$source\" ]; do\n    # Skip comments and empty lines\n    [[ \"$source\" =~ ^#.*$ ]] && continue\n    [[ -z \"$source\" ]] && continue\n\n    if [ -f \"$source\" ]; then\n        SOURCE_MTIME=$(stat -f %m \"$source\" 2>/dev/null || stat -c %Y \"$source\" 2>/dev/null)\n        if [ \"$SOURCE_MTIME\" -gt \"$LAST_ANALYSIS\" ]; then\n            CHANGED_FILES=\"$CHANGED_FILES\\n  - $source\"\n            CHANGED_COUNT=$((CHANGED_COUNT + 1))\n        fi\n    fi\ndone < \"$WORKSPACE_DIR/.contract-sources\"\n\n# Report changes\nif [ \"$CHANGED_COUNT\" -gt 0 ]; then\n    echo -e \"${YELLOW}🔄 Contract sources changed since last sync:${NC}\"\n    echo -e \"$CHANGED_FILES\"\n    echo \"\"\n    echo -e \"   Run ${BLUE}/sync-contracts${NC} to update\"\n    echo \"\"\nelif [ \"$AGE\" -gt \"$STALENESS_THRESHOLD\" ]; then\n    HOURS=$((AGE / 3600))\n    echo -e \"${YELLOW}📅 Last contract sync: ${HOURS} hours ago${NC}\"\n    echo -e \"   Consider running ${BLUE}/sync-contracts${NC}\"\n    echo \"\"\nelse\n    # Fresh - silent success\n    :\nfi\n\nexit 0\n"
  },
  {
    "path": "hooks/workspace/check-graph-freshness.sh",
    "content": "#!/bin/bash\n\n# Check Graph Freshness - Session Start Advisory\n#\n# Warns if code graph data is older than the latest commit.\n# Run at session start to ensure Claude is working with current data.\n\nGREEN='\\033[0;32m'\nYELLOW='\\033[1;33m'\nNC='\\033[0m'\n\n# Skip if no graph configured\nif [ ! -f \".mcp.json\" ] || ! grep -q \"codebase-memory\" \".mcp.json\" 2>/dev/null; then\n    exit 0\nfi\n\n# Skip if no .code-graph directory (graph not yet built)\nif [ ! -d \".code-graph\" ]; then\n    echo -e \"${YELLOW}code-graph: No graph data found. Run index_repository via MCP to build.${NC}\"\n    exit 0\nfi\n\n# Get latest commit timestamp\nLATEST_COMMIT=$(git log -1 --format=%ct 2>/dev/null || echo \"0\")\n\n# Get graph last-updated timestamp (modification time of the DB or marker)\nif [ -f \".code-graph/.last-updated\" ]; then\n    GRAPH_UPDATED=$(cat \".code-graph/.last-updated\" 2>/dev/null || echo \"0\")\nelif [ \"$(uname)\" = \"Darwin\" ]; then\n    # macOS: stat -f %m\n    GRAPH_UPDATED=$(stat -f %m \".code-graph/\" 2>/dev/null || echo \"0\")\nelse\n    # Linux: stat -c %Y\n    GRAPH_UPDATED=$(stat -c %Y \".code-graph/\" 2>/dev/null || echo \"0\")\nfi\n\n# Compare timestamps\nDIFF=$((LATEST_COMMIT - GRAPH_UPDATED))\n\nif [ \"$DIFF\" -gt 300 ]; then\n    # More than 5 minutes stale\n    MINUTES=$((DIFF / 60))\n    echo -e \"${YELLOW}code-graph: Graph may be stale (~${MINUTES}m behind latest commit)${NC}\"\n    echo \"  The MCP file watcher should auto-update.\"\n    echo \"  If stale, use index_repository to rebuild.\"\nelif [ \"$DIFF\" -gt 60 ]; then\n    # Slightly stale (1-5 minutes) — just a note\n    echo -e \"${YELLOW}code-graph: Graph is slightly behind latest commit (auto-updating)${NC}\"\nelse\n    echo -e \"${GREEN}code-graph: Graph data is fresh${NC}\"\nfi\n\nexit 0\n"
  },
  {
    "path": "hooks/workspace/post-commit-contracts.sh",
    "content": "#!/bin/bash\n\n# Post-Commit Contract Sync Hook\n# Automatically syncs contracts when contract source files are committed\n# Run time: ~15 seconds (only when contracts change)\n\nWORKSPACE_DIR=\"_project_specs/workspace\"\n\n# Colors\nGREEN='\\033[0;32m'\nYELLOW='\\033[1;33m'\nBLUE='\\033[0;34m'\nNC='\\033[0m'\n\n# Check if workspace is configured\nif [ ! -f \"$WORKSPACE_DIR/.contract-sources\" ]; then\n    exit 0\nfi\n\n# Get list of committed files\nCOMMITTED_FILES=$(git diff-tree --no-commit-id --name-only -r HEAD 2>/dev/null)\n\nif [ -z \"$COMMITTED_FILES\" ]; then\n    exit 0\nfi\n\n# Check if any committed files are contract sources\nCONTRACTS_CHANGED=false\nCHANGED_SOURCES=\"\"\n\nwhile IFS= read -r source || [ -n \"$source\" ]; do\n    # Skip comments and empty lines\n    [[ \"$source\" =~ ^#.*$ ]] && continue\n    [[ -z \"$source\" ]] && continue\n\n    if echo \"$COMMITTED_FILES\" | grep -q \"$source\"; then\n        CONTRACTS_CHANGED=true\n        CHANGED_SOURCES=\"$CHANGED_SOURCES $source\"\n    fi\ndone < \"$WORKSPACE_DIR/.contract-sources\"\n\n# If contracts changed, run lightweight sync\nif [ \"$CONTRACTS_CHANGED\" = true ]; then\n    echo \"\"\n    echo -e \"${YELLOW}📝 Contract files changed in this commit:${NC}\"\n    for src in $CHANGED_SOURCES; do\n        echo \"   - $src\"\n    done\n    echo \"\"\n\n    # Check if Claude CLI is available\n    if command -v claude &> /dev/null; then\n        echo -e \"${BLUE}⚡ Running lightweight contract sync...${NC}\"\n\n        # Run sync in silent/lightweight mode\n        if claude --print \"/sync-contracts --lightweight\" > /dev/null 2>&1; then\n            echo -e \"${GREEN}✅ Contracts synced${NC}\"\n        else\n            echo -e \"${YELLOW}⚠️  Contract sync failed - run /sync-contracts manually${NC}\"\n        fi\n    else\n        echo -e \"${YELLOW}⚠️  Claude CLI not found${NC}\"\n        echo \"   Run /sync-contracts manually to update contracts\"\n    fi\n    echo \"\"\nfi\n\nexit 0\n"
  },
  {
    "path": "hooks/workspace/pre-push-contracts.sh",
    "content": "#!/bin/bash\n\n# Pre-Push Contract Validation Hook\n# Validates contract consistency before pushing\n# Blocks push if contracts are out of sync\n# Run time: ~10 seconds\n\nWORKSPACE_DIR=\"_project_specs/workspace\"\n\n# Colors\nRED='\\033[0;31m'\nYELLOW='\\033[1;33m'\nGREEN='\\033[0;32m'\nBLUE='\\033[0;34m'\nNC='\\033[0m'\n\n# Check if workspace is configured\nif [ ! -f \"$WORKSPACE_DIR/CONTRACTS.md\" ]; then\n    exit 0\nfi\n\nif [ ! -f \"$WORKSPACE_DIR/.contract-sources\" ]; then\n    exit 0\nfi\n\necho \"\"\necho -e \"${BLUE}🔍 Validating workspace contracts...${NC}\"\n\nVALIDATION_ERRORS=\"\"\nWARNING_COUNT=0\nERROR_COUNT=0\n\n# Get last sync timestamp\nLAST_SYNC=$(stat -f %m \"$WORKSPACE_DIR/CONTRACTS.md\" 2>/dev/null || stat -c %Y \"$WORKSPACE_DIR/CONTRACTS.md\" 2>/dev/null)\n\n# Check if any contract sources changed since last sync\nSTALE_SOURCES=\"\"\nwhile IFS= read -r source || [ -n \"$source\" ]; do\n    # Skip comments and empty lines\n    [[ \"$source\" =~ ^#.*$ ]] && continue\n    [[ -z \"$source\" ]] && continue\n\n    if [ -f \"$source\" ]; then\n        SOURCE_MTIME=$(stat -f %m \"$source\" 2>/dev/null || stat -c %Y \"$source\" 2>/dev/null)\n        if [ \"$SOURCE_MTIME\" -gt \"$LAST_SYNC\" ]; then\n            STALE_SOURCES=\"$STALE_SOURCES\\n   - $source\"\n            ERROR_COUNT=$((ERROR_COUNT + 1))\n        fi\n    else\n        VALIDATION_ERRORS=\"$VALIDATION_ERRORS\\n⚠️  Contract source missing: $source\"\n        WARNING_COUNT=$((WARNING_COUNT + 1))\n    fi\ndone < \"$WORKSPACE_DIR/.contract-sources\"\n\n# Check OpenAPI consistency (if exists)\nif [ -f \"apps/api/openapi.json\" ] || [ -f \"openapi.json\" ]; then\n    OPENAPI_FILE=$([ -f \"apps/api/openapi.json\" ] && echo \"apps/api/openapi.json\" || echo \"openapi.json\")\n\n    if command -v jq &> /dev/null; then\n        ACTUAL_ENDPOINTS=$(jq -r '.paths | keys | length' \"$OPENAPI_FILE\" 2>/dev/null || echo \"0\")\n        DOCUMENTED_ENDPOINTS=$(grep -cE \"^\\| (GET|POST|PUT|PATCH|DELETE)\" \"$WORKSPACE_DIR/CONTRACTS.md\" 2>/dev/null || echo \"0\")\n\n        if [ \"$ACTUAL_ENDPOINTS\" != \"0\" ] && [ \"$DOCUMENTED_ENDPOINTS\" != \"0\" ]; then\n            if [ \"$ACTUAL_ENDPOINTS\" != \"$DOCUMENTED_ENDPOINTS\" ]; then\n                VALIDATION_ERRORS=\"$VALIDATION_ERRORS\\n⚠️  Endpoint count mismatch: OpenAPI has $ACTUAL_ENDPOINTS, CONTRACTS.md has $DOCUMENTED_ENDPOINTS\"\n                WARNING_COUNT=$((WARNING_COUNT + 1))\n            fi\n        fi\n    fi\nfi\n\n# Report results\nif [ \"$ERROR_COUNT\" -gt 0 ]; then\n    echo -e \"${RED}❌ Contract sources changed but not synced:${NC}\"\n    echo -e \"$STALE_SOURCES\"\n    echo \"\"\n    echo -e \"${RED}Run /sync-contracts before pushing${NC}\"\n    echo -e \"Or bypass with: ${YELLOW}git push --no-verify${NC}\"\n    echo \"\"\n    exit 1\nfi\n\nif [ \"$WARNING_COUNT\" -gt 0 ]; then\n    echo -e \"${YELLOW}⚠️  Validation warnings:${NC}\"\n    echo -e \"$VALIDATION_ERRORS\"\n    echo \"\"\n    echo -e \"${YELLOW}Consider running /sync-contracts${NC}\"\n    echo \"\"\n    # Warnings don't block push\nfi\n\nif [ \"$ERROR_COUNT\" -eq 0 ]; then\n    echo -e \"${GREEN}✅ Contracts validated${NC}\"\nfi\n\nexit 0\n"
  },
  {
    "path": "install.sh",
    "content": "#!/bin/bash\n\n# Maggy Installer\n\nset -e\n\nSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\nCLAUDE_DIR=\"$HOME/.claude\"\n\necho \"Installing Maggy v4.0.0...\"\necho \"\"\n\n# Save bootstrap directory location for other scripts\necho \"$SCRIPT_DIR\" > \"$HOME/.claude/.bootstrap-dir\"\n\n# Create directories\nmkdir -p \"$CLAUDE_DIR/commands\"\nmkdir -p \"$CLAUDE_DIR/skills\"\nmkdir -p \"$CLAUDE_DIR/hooks\"\nmkdir -p \"$CLAUDE_DIR/rules\"\n\n# Copy all commands\ncp \"$SCRIPT_DIR/commands/\"*.md \"$CLAUDE_DIR/commands/\"\necho \"✓ Installed commands:\"\nls -1 \"$CLAUDE_DIR/commands/\" | sed 's/^/  - \\//' | sed 's/\\.md$//'\n\n# Copy skills (folder structure with SKILL.md)\necho \"\"\necho \"Installing skills...\"\nrm -rf \"$CLAUDE_DIR/skills\"\nmkdir -p \"$CLAUDE_DIR/skills\"\nskill_count=0\nfor skill_dir in \"$SCRIPT_DIR/skills\"/*/; do\n    if [ -d \"$skill_dir\" ] && [ -f \"$skill_dir/SKILL.md\" ]; then\n        skill_name=$(basename \"$skill_dir\")\n        cp -r \"${skill_dir%/}\" \"$CLAUDE_DIR/skills/\"\n        skill_count=$((skill_count + 1))\n    fi\ndone\necho \"✓ Installed $skill_count skills (folder/SKILL.md structure)\"\n\n# Cross-tool skill installation (Kimi CLI, Codex CLI)\nDETECTED_AGENTS=$(\"$SCRIPT_DIR/scripts/detect-agents.sh\" 2>/dev/null || true)\n\nif echo \"$DETECTED_AGENTS\" | grep -q \"kimi\"; then\n    \"$SCRIPT_DIR/scripts/install-skills.sh\" \"$HOME/.kimi/skills\" \"$SCRIPT_DIR/skills\"\n    echo \"  Also installed skills to ~/.kimi/skills/ (Kimi CLI)\"\nfi\n\nif echo \"$DETECTED_AGENTS\" | grep -q \"codex\"; then\n    \"$SCRIPT_DIR/scripts/install-skills.sh\" \"$HOME/.codex/skills\" \"$SCRIPT_DIR/skills\"\n    echo \"  Also installed skills to ~/.codex/skills/ (Codex CLI)\"\nfi\n\n# Copy conditional rules\necho \"\"\necho \"Installing conditional rules...\"\nrm -rf \"$CLAUDE_DIR/rules\"\nmkdir -p \"$CLAUDE_DIR/rules\"\nrule_count=0\nfor rule_file in \"$SCRIPT_DIR/rules/\"*.md; do\n    if [ -f \"$rule_file\" ]; then\n        cp \"$rule_file\" \"$CLAUDE_DIR/rules/\"\n        rule_count=$((rule_count + 1))\n    fi\ndone\necho \"✓ Installed $rule_count conditional rules (with paths: frontmatter)\"\nls -1 \"$CLAUDE_DIR/rules/\" | sed 's/^/  - /' | sed 's/\\.md$//'\n\n# Copy hooks\ncp \"$SCRIPT_DIR/hooks/\"* \"$CLAUDE_DIR/hooks/\" 2>/dev/null || true\nchmod +x \"$CLAUDE_DIR/hooks/\"* 2>/dev/null || true\necho \"\"\necho \"✓ Installed git hooks (templates)\"\n\n# Copy templates\necho \"\"\necho \"Installing templates...\"\nmkdir -p \"$CLAUDE_DIR/templates\"\ncp \"$SCRIPT_DIR/templates/\"* \"$CLAUDE_DIR/templates/\" 2>/dev/null || true\nchmod +x \"$CLAUDE_DIR/templates/tdd-loop-check.sh\" 2>/dev/null || true\nchmod +x \"$CLAUDE_DIR/templates/pre-compact.sh\" 2>/dev/null || true\nchmod +x \"$CLAUDE_DIR/templates/codex-auto-review.sh\" 2>/dev/null || true\necho \"✓ Installed templates (CLAUDE.md, AGENTS.md, CLAUDE.local.md, settings.json, config.toml)\"\n\n# Cross-tool config installation\nif echo \"$DETECTED_AGENTS\" | grep -q \"kimi\"; then\n    mkdir -p \"$HOME/.kimi\"\n    cp \"$SCRIPT_DIR/templates/config.toml\" \"$HOME/.kimi/config.toml.bootstrap\" 2>/dev/null || true\n    echo \"  Kimi: hooks template at ~/.kimi/config.toml.bootstrap\"\nfi\n\nif echo \"$DETECTED_AGENTS\" | grep -q \"codex\"; then\n    mkdir -p \"$HOME/.codex\"\n    cp \"$SCRIPT_DIR/templates/AGENTS.md\" \"$HOME/.codex/templates/AGENTS.md\" 2>/dev/null || {\n        mkdir -p \"$HOME/.codex/templates\"\n        cp \"$SCRIPT_DIR/templates/AGENTS.md\" \"$HOME/.codex/templates/AGENTS.md\"\n    }\n    echo \"  Codex: AGENTS.md template at ~/.codex/templates/\"\nfi\n\n# Copy hook installer script\ncp \"$SCRIPT_DIR/scripts/install-hooks.sh\" \"$CLAUDE_DIR/\" 2>/dev/null || true\nchmod +x \"$CLAUDE_DIR/install-hooks.sh\" 2>/dev/null || true\n\n# Copy graph tools installer\ncp \"$SCRIPT_DIR/scripts/install-graph-tools.sh\" \"$CLAUDE_DIR/\" 2>/dev/null || true\nchmod +x \"$CLAUDE_DIR/install-graph-tools.sh\" 2>/dev/null || true\n\n# Install Polyphony CLI shim\nPOLYPHONY_SRC=\"$SCRIPT_DIR/scripts/polyphony\"\nif [ -f \"$POLYPHONY_SRC/__main__.py\" ]; then\n    INSTALL_DIR=\"$HOME/.local/bin\"\n    mkdir -p \"$INSTALL_DIR\"\n    cat > \"$INSTALL_DIR/polyphony\" << SHIM\n#!/bin/bash\nexec python3 -c \"import sys; sys.path.insert(0, '$SCRIPT_DIR/scripts'); from polyphony.__main__ import main; sys.exit(main())\" \"\\$@\"\nSHIM\n    chmod +x \"$INSTALL_DIR/polyphony\"\n    echo \"\"\n    echo \"✓ Installed polyphony CLI shim\"\n\n    # Create default config if missing\n    if [ ! -d \"$HOME/.polyphony\" ]; then\n        mkdir -p \"$HOME/.polyphony\"\n        cp -n \"$SCRIPT_DIR/templates/polyphony-config.yaml\" \"$HOME/.polyphony/config.yaml\" 2>/dev/null || true\n        cp -n \"$SCRIPT_DIR/templates/polyphony-identities.yaml\" \"$HOME/.polyphony/identities.yaml\" 2>/dev/null || true\n        cp -n \"$SCRIPT_DIR/templates/polyphony-agents.yaml\" \"$HOME/.polyphony/agents.yaml\" 2>/dev/null || true\n        cp -n \"$SCRIPT_DIR/templates/polyphony-routing.yaml\" \"$HOME/.polyphony/routing.yaml\" 2>/dev/null || true\n        echo \"✓ Created ~/.polyphony/ config\"\n    fi\nfi\n\n# Run validation\necho \"\"\necho \"Running validation...\"\nif [ -f \"$SCRIPT_DIR/tests/validate-structure.sh\" ]; then\n    if \"$SCRIPT_DIR/tests/validate-structure.sh\" --quick; then\n        echo \"\"\n    else\n        echo \"\"\n        echo \"⚠ Validation found issues. Run full validation:\"\n        echo \"  $SCRIPT_DIR/tests/validate-structure.sh --full\"\n    fi\nfi\n\necho \"\"\necho \"================================================================\"\necho \"  Installation complete! (v4.0.0)\"\necho \"================================================================\"\necho \"\"\necho \"What's new in v4.0.0:\"\necho \"  - Polyphony: container-isolated parallel agents (Docker/OrbStack)\"\necho \"  - /spawn-team now uses Polyphony by default (fallback to native)\"\necho \"  - polyphony CLI: init, spawn, status, cleanup\"\necho \"  - Cross-tool support: Claude Code + Kimi CLI + Codex CLI\"\necho \"\"\necho \"Usage:\"\necho \"  1. Open any project folder\"\necho \"  2. Run: claude (or kimi, or codex)\"\necho \"  3. Type: /initialize-project\"\necho \"\"\necho \"Commands installed:\"\necho \"  /initialize-project   - Full project setup (includes Polyphony)\"\necho \"  /spawn-team           - Spawn agent team (containers by default)\"\necho \"  /sync-agents          - Sync config between Claude/Kimi/Codex\"\necho \"  /check-contributors   - Team coordination\"\necho \"  /update-code-index    - Regenerate code index\"\necho \"\"\necho \"Polyphony CLI:\"\necho \"  polyphony init        - Create ~/.polyphony/ config\"\necho \"  polyphony spawn       - Create and route a task\"\necho \"  polyphony status      - Show task states\"\necho \"  polyphony cleanup     - Remove completed workspaces\"\necho \"\"\necho \"Container isolation (Polyphony):\"\nif echo \"$DETECTED_AGENTS\" | grep -q \"docker\"; then\n    echo \"  [OK] Docker    - container isolation available\"\nelif echo \"$DETECTED_AGENTS\" | grep -q \"orbstack\"; then\n    echo \"  [OK] OrbStack  - container isolation available\"\nelse\n    echo \"  [--] Docker    - not found (brew install --cask docker)\"\nfi\nif echo \"$DETECTED_AGENTS\" | grep -q \"polyphony\"; then\n    echo \"  [OK] Polyphony - CLI installed\"\nelse\n    echo \"  [--] Polyphony - CLI shim not on PATH (add ~/.local/bin to PATH)\"\nfi\necho \"\"\necho \"Cross-tool compatibility:\"\nif echo \"$DETECTED_AGENTS\" | grep -q \"kimi\"; then\n    echo \"  [OK] Kimi CLI  - skills + hooks installed\"\nelse\n    echo \"  [--] Kimi CLI  - not found (curl -L code.kimi.com/install.sh | bash)\"\nfi\nif echo \"$DETECTED_AGENTS\" | grep -q \"codex\"; then\n    echo \"  [OK] Codex CLI - skills + AGENTS.md installed\"\nelse\n    echo \"  [--] Codex CLI - not found (npm i -g @openai/codex)\"\nfi\necho \"\"\necho \"Git Hooks (per-project):\"\necho \"  cd your-project && ~/.claude/install-hooks.sh\"\necho \"\"\necho \"Code Graph Tools:\"\necho \"  ~/.claude/install-graph-tools.sh            - Install Tier 1 (default)\"\necho \"  ~/.claude/install-graph-tools.sh --joern     - Also install Tier 2 (CPG)\"\necho \"  ~/.claude/install-graph-tools.sh --codeql    - Also install Tier 3 (security)\"\necho \"  ~/.claude/install-graph-tools.sh --all       - Install all tiers\"\necho \"\"\necho \"Validation:\"\necho \"  $SCRIPT_DIR/tests/validate-structure.sh --full\"\necho \"\"\n"
  },
  {
    "path": "maggy/.gitignore",
    "content": "__pycache__/\n*.py[cod]\n*$py.class\n.pytest_cache/\n.mypy_cache/\n.ruff_cache/\n*.egg-info/\n"
  },
  {
    "path": "maggy/PLAN.md",
    "content": "# Maggy — Generic AI Engineering Command Center\n\nShips as a core component of Maggy. One install, works with any team.\n\n## What Maggy Is\n\nA local, self-improving AI agent that turns your issue tracker into an AI-prioritized inbox with one-click execution. Uses Maggy's iCPG for codebase intelligence and spawns `claude -p` for implementation.\n\nNot a cloud service — runs on your machine, talks to your APIs, uses your Claude Code.\n\n## Vision\n\n```\n$ maggy init\nOrg name: Acme Corp\nIssue tracker? (github / asana / linear) → github\nGitHub org: acmecorp\nRepos to monitor: api, web, mobile\nCompetitor domain (for intelligence): fintech\nPaste your OKRs (or skip): ...\n\n✓ Config saved to ~/.maggy/config.yaml\n✓ Bootstrapping iCPG for 3 repos...\n✓ Discovering competitors in \"fintech\"...  (found 28)\n✓ Ready: http://localhost:8080\n```\n\nThat's it. Works the same for any org.\n\n## Architecture\n\n```\nmaggy/\n├── maggy/                          # The Maggy dashboard app\n│   ├── PLAN.md                     # this file\n│   ├── README.md                   # user docs\n│   ├── install.sh                  # one-line install\n│   ├── pyproject.toml              # deps\n│   ├── config.example.yaml         # config template\n│   ├── maggy/                      # Python package (importable as `maggy`)\n│   │   ├── main.py                 # FastAPI entry\n│   │   ├── config.py               # loads ~/.maggy/config.yaml\n│   │   ├── providers/\n│   │   │   ├── base.py             # IssueTrackerProvider Protocol\n│   │   │   ├── github_issues.py    # GitHub Issues impl\n│   │   │   └── asana.py            # Asana impl (linear deferred)\n│   │   ├── services/\n│   │   │   ├── inbox.py            # AI-prioritized ranking\n│   │   │   ├── competitor.py       # discovery + monitoring + briefing\n│   │   │   └── executor.py         # TDD pipeline with iCPG enrichment\n│   │   ├── api/\n│   │   │   └── routes.py           # REST endpoints\n│   │   └── static/\n│   │       ├── index.html          # dashboard\n│   │       └── app.js              # vanilla JS\n├── commands/\n│   ├── maggy.md                    # /maggy → launch dashboard\n│   └── maggy-init.md               # /maggy-init → setup wizard\n├── skills/\n│   └── maggy/\n│       └── SKILL.md                # Maggy capabilities reference\n└── scripts/icpg/                   # ALREADY EXISTS — Maggy calls this\n```\n\n## Key Design Decisions\n\n### 1. Config-driven, not hardcoded\n\nA single `~/.maggy/config.yaml` drives everything. No hardcoded board IDs, repo names, team members, OKRs, or competitor lists. All that stuff lives in config.\n\n```yaml\norg:\n  name: \"Acme Corp\"\n  domain: \"fintech\"                  # drives competitor category + system prompt\n\nissue_tracker:\n  provider: \"github\"                 # \"github\" | \"asana\" (linear = stub)\n  github:\n    org: \"acmecorp\"\n    repos: [\"acmecorp/api\", \"acmecorp/web\"]\n    # PAT read from env: GITHUB_TOKEN\n\ncodebases:\n  - path: \"~/dev/acmecorp/api\"\n    key: \"api\"\n  - path: \"~/dev/acmecorp/web\"\n    key: \"web\"\n\ncompetitors:\n  categories: [\"fintech\", \"embedded-finance\"]\n  # Maggy auto-discovers. Stores in ~/.maggy/competitors.json\n\nai:\n  provider: \"anthropic\"\n  model: \"claude-sonnet-4-5-20250929\"\n  # API key from ANTHROPIC_API_KEY env\n\nstorage:\n  # SQLite by default — zero setup. Supabase optional.\n  backend: \"sqlite\"\n  path: \"~/.maggy/maggy.db\"\n\ndashboard:\n  port: 8080\n  auth_mode: \"local\"                 # no auth for single-user local use\n```\n\n### 2. Provider abstraction for issue trackers\n\nThe #1 coupling in the zenloop version is Asana. Generic Maggy defines a Protocol and all services use it:\n\n```python\nclass IssueTrackerProvider(Protocol):\n    async def list_tasks(self, board: str | None = None, state: str = \"open\") -> list[Task]\n    async def get_task(self, task_id: str) -> Task\n    async def add_comment(self, task_id: str, text: str) -> None\n    async def update_status(self, task_id: str, status: str) -> None\n    async def list_followed(self, user_id: str | None = None) -> list[Task]\n    async def search_tasks(self, query: str) -> list[Task]\n```\n\n`GitHubIssuesProvider` and `AsanaProvider` both implement this. Services call `provider.list_tasks()` — they don't care what's underneath.\n\n### 3. Reuses Maggy's iCPG\n\nDon't duplicate iCPG. Maggy shells out to the iCPG CLI:\n\n```python\n# executor.py\nasync def _get_icpg_context(title: str, notes: str) -> str:\n    keywords = extract_keywords(title + notes)\n    context = []\n    for kw in keywords[:5]:\n        result = await run_cmd([\"icpg\", \"query\", \"symbols\", \"--keyword\", kw, \"--json\"])\n        context.append(result)\n    return format_icpg_block(context)\n```\n\nThis means the dashboard automatically benefits from iCPG upgrades. No duplicate symbol indexing.\n\n### 4. SQLite-first storage\n\nThe zenloop version used Supabase for P2P coordination. For a single-user local install, SQLite is simpler and zero-setup. P2P and multi-user stays optional:\n\n- **Default (SQLite):** `~/.maggy/maggy.db`. Zero setup.\n- **Optional (Supabase):** For teams that want shared state and P2P handoff.\n\n### 5. Dashboard is minimal but real\n\nNot a React SPA — Tailwind CDN + vanilla JS. Matches Maggy's philosophy (no build step, dead simple). Three views:\n\n1. **Inbox** — AI-prioritized issues with Execute/Plan/Comment buttons\n2. **Competitor News** — daily AI briefing + news feed\n3. **Settings** — view/edit config, health check\n\n### 6. Ships with Maggy\n\nUser installs Maggy, runs `/maggy-init` in Claude Code, and the dashboard is configured + running. `/maggy` in any Claude Code session opens the dashboard.\n\n## MVP Scope (what I'm building now)\n\n**In scope:**\n- [x] Directory structure\n- [ ] Config loader + example\n- [ ] IssueTrackerProvider Protocol + GitHub Issues + Asana impls\n- [ ] Inbox service (AI-prioritized)\n- [ ] Competitor service (AI-discovered, daily briefing)\n- [ ] Executor service (TDD pipeline with iCPG enrichment)\n- [ ] FastAPI server + 8 endpoints\n- [ ] Minimal HTML dashboard\n- [ ] install.sh + pyproject.toml + README\n- [ ] /maggy and /maggy-init commands\n- [ ] skills/maggy/SKILL.md\n\n**Deferred to v2 (not MVP):**\n- Meeting bot (voice)\n- Slack integration\n- P2P network + session handoff\n- Self-improvement (`/improve-maggy`)\n- Heartbeat service (background processing)\n- BambooHR integration\n- Auto-review (PRs, tickets)\n- 27 AI tools → starts with 5 core tools\n- Linear provider (stub only)\n\n## How to test independently\n\nAfter install:\n\n```bash\ncd ~/Documents/AI-Playground/maggy/maggy\n./install.sh\n\n# Configure\ncp config.example.yaml ~/.maggy/config.yaml\n# Edit ~/.maggy/config.yaml with your GitHub org/repos\n\n# Set env vars\nexport ANTHROPIC_API_KEY=sk-ant-...\nexport GITHUB_TOKEN=ghp_...\n\n# Run\npython -m maggy.main\n\n# Open http://localhost:8080\n```\n\nOr from inside Claude Code (after bootstrap install):\n```\n/maggy-init    # interactive setup\n/maggy         # launch dashboard\n```\n\nShould work out-of-the-box for any GitHub-based team.\n\n## Success criteria\n\n1. Fresh install on a machine that never saw zenloop → works\n2. Points at any GitHub org → inbox populates with issues\n3. AI prioritization runs → issues ranked\n4. Click Execute → TDD pipeline spawns `claude -p` with iCPG context injected\n5. Competitor discovery for any domain → competitors found + daily briefing\n6. No hardcoded zenloop anything anywhere in the code\n\nThat's the bar.\n"
  },
  {
    "path": "maggy/README.md",
    "content": "# Maggy\n\n**Autonomous AI engineering command center.**\n\nInstall once, point it at your codebases and issue tracker, and get:\n\n- **Interactive Chat** — auto-connects to all active Claude/Codex/Kimi sessions, take over from the web UI with full session continuity (`--resume`)\n- **AI-prioritized Tasks** — ranks open issues by urgency + OKR alignment\n- **One-click Execute** — spawns `claude -p` with iCPG-enriched prompts, runs TDD pipeline\n- **Competitor Intelligence** — auto-discovers competitors, daily AI briefing\n- **Process Insights** — CLI session history analysis, health signals, self-improvement recommendations\n- **P2P Mesh** — multi-node session sync and handoff across machines\n- **Auto-Bootstrap** — all services seed themselves on startup (history, CIKG, events)\n\n## Install\n\n```bash\ncd maggy/maggy\n./install.sh\n```\n\n## Configure\n\nEdit `~/.maggy/config.yaml`:\n\n```yaml\norg:\n  name: \"Acme Corp\"\n  domain: \"fintech\"\n\nissue_tracker:\n  provider: \"github\"\n  github:\n    org: \"acmecorp\"\n    repos: [\"acmecorp/api\", \"acmecorp/web\"]\n\ncodebases:\n  - { path: \"~/dev/acmecorp/api\", key: \"api\" }\n  - { path: \"~/dev/acmecorp/web\", key: \"web\" }\n\ncompetitors:\n  categories: [\"fintech\", \"embedded-finance\"]\n```\n\nSet credentials:\n\n```bash\nexport GITHUB_TOKEN=ghp_...\nexport ANTHROPIC_API_KEY=sk-ant-...\n```\n\n## Run\n\n```bash\npython3 -m maggy.main\n```\n\nOpen `http://localhost:8080`.\n\n## Dashboard\n\nNavigation is grouped by intent:\n\n| Group | Tabs | Purpose |\n|-------|------|---------|\n| **Work** | Chat, Tasks, Watching | Do things — chat with Claude, triage issues |\n| **Intel** | Competitors, Insights | Learn things — competitor news, session analytics |\n| **System** | Budget, Models, Forge, Settings | Configure — spend limits, model routing, MCP gaps |\n\nChat is the default tab — auto-connects to all running CLI sessions on load.\n\n## From inside Claude Code\n\n```\n/maggy-init   # interactive setup wizard\n/maggy        # launch dashboard\n```\n\n## Features\n\n- **Interactive Chat** — SSE streaming, session continuity via `--resume`, path-based history matching, auto-connect to active CLI sessions\n- **Activity Scanner** — detects running `claude`, `codex`, `kimi` processes via `ps aux` + `lsof`\n- **History Analysis** — parses 260+ CLI sessions, topic extraction, session patterns\n- **Self-Improvement** — signal collection, health scoring, actionable recommendations\n- **CIKG Knowledge Graph** — codebase nodes, technology detection, landscape queries\n- **Event Spine** — structured event emission and querying across all services\n- **Engram Memory** — write/query/expire memory entries with metadata\n- **Budget Tracking** — daily spend limits with per-provider breakdown\n- **Model Routing** — reward-based heatmap for model selection by task type\n- **MCP Forge** — detects capability gaps from filesystem, suggests MCP tools\n- **P2P Mesh** — WebSocket sync, peer discovery, state quarantine, org-scoped networks\n- **Heartbeat** — scheduled jobs (history refresh, engram expiry, self-improve, mesh sync)\n\n## Hardening\n\n- **Working dir whitelist** — Execute and Chat both validate paths against configured codebase roots\n- **Chat streaming lock** — per-session `asyncio.Lock` prevents concurrent subprocess spawning\n- **SSRF protection** — RSS/blog feed URLs validated before fetch (blocks loopback, private-network)\n- **CLAUDECODE env stripping** — subprocess spawning removes `CLAUDECODE` to allow nested Claude sessions\n- **Process lifecycle** — Claude subprocesses killed on timeout; non-zero exits marked failed\n- **Input validation** — Execute mode `Literal[\"tdd\", \"plan\"]`; malformed IDs return 404\n- **503 onboarding mode** — unconfigured state returns 503 with setup pointer\n- **Safe external links** — scheme allowlist + `rel=\"noopener noreferrer\"`\n- **No-cache static files** — `Cache-Control: no-store` prevents stale JS in browser\n\n## Architecture\n\nSee [PLAN.md](./PLAN.md) for the full architecture rationale.\n\n1. **Provider abstraction** — `IssueTrackerProvider` Protocol (GitHub, Asana, Linear stub)\n2. **Config-driven** — zero hardcoded IDs, orgs, or competitor lists\n3. **iCPG integration** — context enrichment from code property graph\n4. **SQLite-first** — single-user local install, zero setup\n5. **Auto-bootstrap** — all services seed on startup, no empty tabs\n6. **Grouped UI** — Work / Intel / System navigation by intent\n\n## License\n\nMIT\n"
  },
  {
    "path": "maggy/config.example.yaml",
    "content": "# Maggy configuration\n# Copy this to ~/.maggy/config.yaml and customize.\n\norg:\n  name: \"Your Org\"\n  # Drives competitor auto-discovery and system prompt phrasing.\n  # Examples: \"fintech\", \"devtools\", \"cx-feedback\", \"healthcare\", \"marketplaces\"\n  domain: \"your-domain\"\n\nissue_tracker:\n  # Currently supported: \"github\" | \"asana\"\n  # (\"linear\" is a stub and not selectable yet — tracking via #TODO)\n  provider: \"github\"\n\n  github:\n    # Your GitHub org or user\n    org: \"your-org\"\n    # Repos to monitor (full name: \"org/repo\")\n    repos:\n      - \"your-org/api\"\n      - \"your-org/web\"\n    # Optional: only show issues with these labels (empty = all)\n    labels: []\n    # Read-only token from env: GITHUB_TOKEN\n\n  asana:\n    # Used when provider: \"asana\". Ignore if using GitHub.\n    workspace_id: \"\"\n    # Project GIDs for each \"board\" that appears in the sidebar\n    boards:\n      dev: \"\"\n      bugs: \"\"\n    # Token from env: ASANA_API_KEY\n\ncodebases:\n  # Paths to repos Maggy can execute in. When you click Execute on a ticket,\n  # Maggy picks the right repo based on keyword matching.\n  - path: \"~/dev/your-org/api\"\n    key: \"api\"\n    # Optional: default working_dir override per repo\n  - path: \"~/dev/your-org/web\"\n    key: \"web\"\n\ncompetitors:\n  # Maggy auto-discovers competitors in these categories using AI + G2/Capterra research.\n  # Results stored in ~/.maggy/competitors.json — edit freely.\n  categories:\n    - \"your-primary-category\"\n  # Optional: seed with specific competitor names to ensure they're tracked\n  seed:\n    - \"CompetitorOne\"\n    - \"CompetitorTwo\"\n\nokrs:\n  # Two ways to provide OKRs:\n  #   source: \"yaml\"  → list them inline below\n  #   source: \"skip\"  → no OKR tracking\n  source: \"skip\"\n  # If source == \"yaml\":\n  items: []\n  # Example items structure:\n  # - id: \"Q2-1\"\n  #   title: \"Reduce p95 latency to 200ms\"\n  #   keywords: [\"latency\", \"performance\", \"slow\"]\n\nai:\n  provider: \"anthropic\"\n  model: \"claude-sonnet-4-5-20250929\"\n  # API key from env: ANTHROPIC_API_KEY\n  max_budget_usd_per_execute: 5.0\n\nstorage:\n  # SQLite by default — zero setup. For multi-user/P2P, use Supabase (not yet supported in MVP).\n  backend: \"sqlite\"\n  path: \"~/.maggy/maggy.db\"\n\ndashboard:\n  host: \"127.0.0.1\"\n  port: 8080\n  # \"local\" = no auth (single-user local install).\n  # \"token\" = require X-API-Key header matching MAGGY_API_KEY env var.\n  auth_mode: \"local\"\n\n# Paths to Maggy installation — auto-detected, usually don't touch.\nbootstrap:\n  # If omitted, Maggy looks at ~/.claude/.bootstrap-dir written by install.sh\n  path: \"\"\n"
  },
  {
    "path": "maggy/docs/benchmark-results.md",
    "content": "# Maggy v5 Benchmark Results\n\n**Date:** 2026-05-11\n**App:** Personal Expense Tracker (FastAPI + SQLite + vanilla HTML/JS)\n**Environment:** Mac Studio M4 Max, 128 GB RAM, macOS Darwin 24.6.0\n**CLIs:** Claude Code 2.1.42, Codex 0.129.0, Kimi 1.41.0, Ollama 0.23.2 (qwen2.5-coder:32b)\n\n---\n\n## 1. Test Protocol\n\n6 identical tasks run sequentially through two pipelines:\n\n- **Runner A (Maggy):** 4-tier routing via blast score. Auto-discovers CLI flags at startup.\n- **Runner B (Claude Code):** All tasks run through `claude -p` only.\n\nBoth pipelines use `--dangerously-skip-permissions` / equivalent flags, 25 max turns, and subprocess spawning into isolated build directories.\n\n---\n\n## 2. Task Definitions\n\n| ID | Task | Blast | Maggy Route | Type |\n|----|------|-------|-------------|------|\n| EXP-1 | Write product spec | 2 | local (ollama) | docs |\n| EXP-2 | Design database schema | 3 | kimi | architecture |\n| EXP-3 | Build expense CRUD API | 5 | gpt (codex) | feature |\n| EXP-4 | Build category API + monthly summary | 5 | gpt (codex) | feature |\n| EXP-5 | Build frontend dashboard | 6 | gpt (codex) | frontend |\n| EXP-6 | Security review + input validation | 8 | claude | security |\n\n---\n\n## 3. Speed Results\n\n| Task | Blast | Maggy Model | Maggy (s) | Claude (s) | Winner |\n|------|-------|-------------|-----------|------------|--------|\n| EXP-1 | 2 | ollama (local) | 50.4 | 48.6 | Claude |\n| EXP-2 | 3 | kimi | 86.6 | 67.2 | Claude |\n| EXP-3 | 5 | codex | 147.1 | 160.6 | **Maggy** |\n| EXP-4 | 5 | codex | 133.9 | 130.8 | Claude |\n| EXP-5 | 6 | codex | 280.1 | 121.9 | Claude |\n| EXP-6 | 8 | claude | 209.5 | 151.9 | Claude |\n| **Total** | | | **907.6** | **681.0** | **Claude (33% faster)** |\n\n### Routing Distribution (Maggy)\n\n| Model | Tasks | % |\n|-------|-------|---|\n| codex (gpt) | 3 | 50% |\n| ollama (local) | 1 | 17% |\n| kimi | 1 | 17% |\n| claude | 1 | 17% |\n\n---\n\n## 4. Success Rate\n\n| Pipeline | Passed | Failed | Fallbacks | Rate |\n|----------|--------|--------|-----------|------|\n| Maggy | 6 | 0 | 0 | 100% |\n| Claude | 6 | 0 | 0 | 100% |\n\n---\n\n## 5. Output Quality Assessment\n\n### 5.1 File Inventory\n\n**Maggy (10 source files, 1,634 lines):**\n\n| File | Lines | Model | Assessment |\n|------|-------|-------|------------|\n| `SECURITY.md` | 134 | claude | Thorough: 7 findings with fixes, 3 recommendations |\n| `backend/app/database.py` | 74 | kimi | Correct schema, parameterized queries, FK + cascade, seed data |\n| `backend/app/main.py` | 36 | kimi | Lifespan init, CORS from env var (not wildcard), 3 routers |\n| `backend/app/validation.py` | 25 | claude | Shared YYYY-MM regex validator, extracted from duplication |\n| `backend/app/routes/expenses.py` | 148 | codex | Full CRUD, Pydantic models, parameterized SQL, FK check |\n| `backend/app/routes/categories.py` | 107 | codex | CRUD, hex color validator, unique constraint handling |\n| `backend/app/routes/summary.py` | 52 | codex | Monthly aggregation with COALESCE, GROUP BY |\n| `frontend/index.html` | 121 | codex | Dark theme, responsive, all sections present |\n| `frontend/css/style.css` | 472 | codex | CSS bar charts, dark palette, mobile breakpoints |\n| `frontend/js/app.js` | 472 | codex | State management, fetch API, DOM via textContent (XSS-safe) |\n\n**Claude (18 source files, ~1,500 app lines + 457K with venv):**\n\n| File | Lines | Assessment |\n|------|-------|------------|\n| `specs/product-spec.md` | 206 | Comprehensive: vision, schema, Pydantic examples, project structure |\n| `backend/app/database.py` | 68 | Correct schema, parameterized queries, FK, seed data |\n| `backend/app/main.py` | 42 | Lifespan init, CORS from env var, 3 routers |\n| `backend/app/models.py` | 51 | Centralized Pydantic schemas (better separation) |\n| `backend/app/routes/expenses.py` | 159 | Full CRUD, partial update support, category JOIN |\n| `backend/app/routes/categories.py` | 90 | CRUD, referential integrity check on delete |\n| `backend/app/routes/summary.py` | 44 | Monthly aggregation |\n| `backend/tests/conftest.py` | 18 | Temp DB fixture with patch |\n| `backend/tests/test_expenses.py` | 108 | 11 test cases covering CRUD + edge cases |\n| `backend/tests/test_categories.py` | ~50 | Category CRUD tests |\n| `backend/tests/test_summary.py` | ~40 | Summary endpoint tests |\n| `frontend/index.html` | 79 | Clean layout, modal-based form |\n| `frontend/css/style.css` | 323 | Dark theme, responsive |\n| `frontend/js/app.js` | 320 | API wrapper, currency formatting, chart rendering |\n\n### 5.2 Quality Scoring\n\n| Dimension | Maggy | Claude | Notes |\n|-----------|-------|--------|-------|\n| **Functional completeness** | 9/10 | 10/10 | Both implement all endpoints. Claude adds partial updates. |\n| **Security** | 10/10 | 7/10 | Maggy's security review (EXP-6) hardened CORS, added amount bounds, path param validation, color format validation. Claude left CORS with `allow_credentials=True`, no amount ceiling, no color validation. |\n| **SQL safety** | 10/10 | 10/10 | Both use parameterized queries exclusively. |\n| **XSS prevention** | 10/10 | 10/10 | Both use textContent for DOM rendering. No innerHTML. |\n| **Input validation** | 9/10 | 7/10 | Maggy: Pydantic + custom validators (hex color, amount ceiling, path ge=1). Claude: Pydantic regex patterns but less thorough. |\n| **Error handling** | 9/10 | 8/10 | Maggy: context manager with rollback, 409 on duplicate, 404 on missing. Claude: try/finally, 409 on duplicate, referential integrity check. |\n| **Test coverage** | 0/10 | 9/10 | Maggy produced zero tests. Claude created conftest + 3 test files (~200 lines). |\n| **Architecture** | 8/10 | 9/10 | Claude separated models into dedicated file. Maggy inlined models per route. Both wire correctly. |\n| **Product spec** | 0/10 | 10/10 | Maggy's ollama did not produce a spec file. Claude's spec is comprehensive (206 lines). |\n| **Frontend quality** | 9/10 | 8/10 | Maggy's frontend is larger (472+472+121 = 1065 lines) with more CSS detail. Claude's is cleaner (320+323+79 = 722 lines) with modal UX. |\n| **Weighted avg** | **7.4/10** | **7.8/10** | |\n\n### 5.3 Key Differences\n\n**Maggy strengths:**\n- Security review caught and fixed 7 issues (CORS wildcard, missing bounds, color validation, duplicated validation)\n- Multi-model approach applied right tool to right task (security by Claude, CRUD by Codex, schema by Kimi)\n- Larger frontend with more CSS polish\n- Each model contributed its strength: Claude for security depth, Codex for feature implementation\n\n**Claude strengths:**\n- Product spec created (comprehensive 206-line document)\n- Test suite included (conftest + 3 test files, ~200 lines, 11+ test cases)\n- Better code organization (centralized models.py)\n- Partial update support on expenses (PATCH-style PUT)\n- Referential integrity check on category delete (prevents orphaned expenses)\n- Full venv with dependencies installed\n\n**Maggy weaknesses:**\n- No product spec file generated (ollama didn't create it or placed it elsewhere)\n- No test files at all — a significant gap for production readiness\n- Import paths use `backend.app.` which requires specific project structure to run\n\n**Claude weaknesses:**\n- No dedicated security review — CORS uses `allow_credentials=True` (risky with dynamic origins)\n- No amount ceiling on expenses (could submit `1e308`)\n- No hex color format validation on categories\n- `get_db()` returns connection without context manager (manual close in every route)\n\n---\n\n## 6. Cost Analysis\n\n| Pipeline | Claude Usage | Free/Cheap Usage | Est. Subscription Burn |\n|----------|-------------|------------------|----------------------|\n| **Maggy** | 1/6 tasks (17%) | 2/6 tasks (33%) | Low — spread across 3 subscriptions |\n| **Claude** | 6/6 tasks (100%) | 0/6 tasks (0%) | High — 100% on premium model |\n\nMaggy used Claude only for the security review (blast 8). The other 5 tasks consumed cheaper or free models:\n- EXP-1: ollama (free, local GPU)\n- EXP-2: kimi (free tier / cheap subscription)\n- EXP-3/4/5: codex (separate subscription)\n\nThis represents ~83% reduction in Claude subscription consumption.\n\n---\n\n## 7. Routing Observations\n\n### What worked\n- **Blast 8 → Claude** for security review was correct. Claude produced the most thorough audit.\n- **Blast 5 → Codex** for CRUD implementation delivered working endpoints.\n- **Blast 3 → Kimi** for database schema was successful and correct.\n- **Zero fallbacks** — all 4 CLIs completed tasks without needing to escalate.\n- **Auto-discovery** — CLI flags probed from `--help`, not hardcoded.\n\n### What needs tuning\n- **Codex is slow on frontend** — EXP-5 took 280s vs Claude's 122s (2.3x slower). Consider routing blast 6 frontend tasks to Claude.\n- **Ollama missed the spec task** — EXP-1 (docs) was routed to local model but no spec file was generated. Ollama's qwen2.5-coder is optimized for code, not prose. Consider routing `task_type: docs` to kimi or claude regardless of blast score.\n- **No test generation by any Maggy model** — None of the 4 models produced tests. This could be addressed by adding a TDD step (write tests first) as a follow-up task routed to Claude.\n\n---\n\n## 8. Conclusions\n\n| Metric | Maggy | Claude | Verdict |\n|--------|-------|--------|---------|\n| Speed | 907.6s | 681.0s | Claude 33% faster |\n| Success rate | 100% | 100% | Tie |\n| Quality (weighted) | 7.4/10 | 7.8/10 | Claude slightly better |\n| Security depth | Stronger | Weaker | Maggy (dedicated review step) |\n| Test coverage | None | Good | Claude (significant gap for Maggy) |\n| Cost efficiency | 83% savings | Baseline | Maggy |\n| Subscription risk | Distributed | Single point | Maggy |\n| Model diversity | 4 models | 1 model | Maggy |\n\n**Summary:** Claude Code is faster and produces marginally higher overall quality (driven by tests and spec). Maggy's multi-model approach provides cost efficiency and subscription risk distribution, plus deeper security review via dedicated model routing. The main gaps to close: add TDD pipeline (test generation step), and improve docs routing (don't send prose tasks to coding-optimized local models).\n\n---\n\n## 9. Raw Throughput Benchmarks (tokens/sec)\n\nStandalone generation speed measured with identical prompts across all four model tiers. Each model ran 3 iterations (1 cold, 2 hot).\n\n**Prompt:** \"Write a Python function that implements a binary search tree with insert, delete, search, and in-order traversal.\"\n\n### 9.1 Results\n\n| Model | Run 1 | Run 2 | Run 3 | Avg tok/s | Notes |\n|-------|-------|-------|-------|-----------|-------|\n| **Ollama qwen2.5-coder:32b** | 22.3 | 21.8 | 22.1 | **22.1** | Local GPU (M4 Max), consistent across runs |\n| **Ollama qwen3-coder:30b-a3b-q8_0** | 75.3 | 75.4 | 76.3 | **75.7** | MoE (3.3B active/30B total), Q8_0, **3.4x faster than qwen2.5** |\n| **Claude (claude -p)** | 44.6 (API) / 18.6 (wall) | 41.9 / 14.3 | 25.7 / 6.8 | **37.4 API / 13.2 wall** | API time excludes network overhead; wall-clock includes CLI startup |\n| **Kimi (kimi CLI)** | ~1.8 | ~2.8 | ~3.3 | **~2.6** | Agentic mode — writes files, runs tools; tok/s reflects execution time |\n| **Codex (codex exec)** | ~0.8 | ~0.7 | ~0.6 | **~0.7** | Agentic mode — full-auto file creation; tok/s reflects execution time |\n\n### 9.2 Interpretation\n\n- **Ollama qwen3-coder (local):** **75.7 tok/s** — 3.4x faster than qwen2.5-coder:32b (22.1 tok/s) and **2x faster than Claude's API rate** (37.4 tok/s). MoE architecture (3.3B active / 30B total params) means only a fraction of parameters are computed per token. Cold start adds ~13s for model load; hot runs start in <100ms. This makes qwen3-coder the fastest model in the fleet for pure generation.\n- **Ollama qwen2.5-coder (retired):** Was 22 tok/s. Replaced by qwen3-coder which is 3.4x faster with comparable quality.\n- **Claude:** 37 tok/s API generation. Still the strongest for reasoning-heavy tasks (security, architecture, TDD).\n- **Kimi / Codex:** Low tok/s numbers are misleading — both operate in agentic mode (writing files, running commands, iterating). Their throughput reflects end-to-end task execution, not pure generation speed.\n\n### 9.3 Routing Implications\n\n| Tier | Model | tok/s | Cost | Best For |\n|------|-------|-------|------|----------|\n| Local | Ollama qwen3-coder:30b-a3b-q8_0 | 75.7 | Free | Blast 1-3: simple edits, CRUD, code generation |\n| Mid | Kimi | 2.6 (agentic) | Cheap | Blast 3-4: schema design, CRUD |\n| Premium-Auto | Codex | 0.7 (agentic) | Mid | Blast 5-6: feature implementation |\n| Premium | Claude | 37 (API) | High | Blast 7+: security, architecture, TDD |\n\n### 9.4 Qwen3-Coder Quality Assessment\n\nTwo coding tasks evaluated for correctness and code quality:\n\n**Task 1: Binary Search Tree** (same prompt as throughput benchmark)\n- Insert, delete (leaf/internal/root), search, in-order traversal — all correct\n- Clean class structure, recursive helpers, inorder-successor delete\n- Handles duplicate-ignore semantics correctly\n- **Score: 10/10** — functionally identical to Claude's output\n\n**Task 2: Async Rate Limiter** (token bucket, concurrent-safe)\n- `asyncio.Lock` for concurrency safety\n- `_refill()` based on elapsed time — correct token bucket math\n- `acquire()` waits in loop, `try_acquire()` returns immediately\n- Burst exhaustion + refill timing verified within 1ms of expected\n- 10 concurrent tasks completed without deadlock\n- **Score: 9/10** — correct and safe; minor: polling loop at 1ms instead of event-driven wait\n\n**Quality Summary:**\n\n| Dimension | qwen3-coder | qwen2.5-coder | Claude |\n|-----------|-------------|---------------|--------|\n| Correctness | 10/10 | 9/10 | 10/10 |\n| Code structure | 9/10 | 8/10 | 10/10 |\n| Concurrency safety | 9/10 | N/A | 10/10 |\n| Generation speed | **75.7 tok/s** | 22.1 tok/s | 37.4 tok/s |\n| Cost | Free | Free | $$$ |\n\n**Verdict:** qwen3-coder is a major upgrade — 3.4x faster than qwen2.5 with equal or better code quality. At 75.7 tok/s it's the fastest model in the fleet, making it ideal for blast 1-4 tasks where speed matters and deep reasoning isn't required.\n\n---\n\n## 10. Post-Benchmark Fixes (Routing Rules + Conventions)\n\nThree systems were built immediately after the benchmark to close the gaps above.\n\n### 10.1 Routing Rules (`~/.maggy/routing-rules.yaml`)\n\nA self-updating YAML config that overrides blast-score routing for specific task types and pipeline phases. Rules are checked **before** the reward table or blast-score tier.\n\n**Task-type overrides seeded from benchmark evidence:**\n\n| Task Type | Forced To | Why |\n|-----------|----------|-----|\n| `docs` | claude | Ollama (code-optimized) produced no spec file |\n| `security` | claude | Security review needs deep reasoning |\n| `tests` | claude | Only claude generated test files in benchmark |\n| `architecture` | claude | Architecture needs cross-context awareness |\n| `planning` | claude | Planning requires structured reasoning |\n\n**Pipeline phase overrides from TDD workflow:**\n\n| Phase | Forced To | Why |\n|-------|----------|-----|\n| `spec` | claude | SPEC phase needs comprehensive docs |\n| `tdd_red` | claude | RED phase needs test design expertise |\n| `tdd_green` | auto | GREEN uses blast-score routing (cheap models can implement) |\n| `review` | claude | Review needs security + architecture depth |\n\n**Self-learning:** `record_outcome()` updates rolling success rates per model. `learn_override()` lets Maggy add new rules when outcome data supports it. Manual YAML edits are preserved.\n\n### 10.2 Team Conventions Injection\n\nFive conventions from claude-bootstrap's CLAUDE.md are embedded in routing rules and injected into every prompt sent to any CLI:\n\n1. **mWP** — Build minimum wowable product. No feature flags, no premature abstractions.\n2. **TDD** — RED → GREEN → VALIDATE. Coverage >= 80%.\n3. **Security** — No secrets in code. Parameterized SQL. Validate input at boundaries.\n4. **Quality gates** — 20 lines/fn, 3 params, 2 nesting levels, 200 lines/file.\n5. **Existing patterns** — Read codebase before changing. Keep changes minimal.\n\nAll four executor prompt methods (`_plan_prompt`, `_analysis_prompt`, `_tests_prompt`, `_impl_prompt`) now append matching conventions. This standardizes quality expectations across kimi, codex, ollama, and claude.\n\n### 10.3 Expected Re-run Improvements\n\n| Benchmark Gap | Root Cause | Fix Applied | Expected Result |\n|--------------|-----------|-------------|-----------------|\n| No product spec (EXP-1) | `docs` routed to ollama | `docs → claude` override | Claude generates spec |\n| No tests from any model | No TDD step in pipeline | `tdd_red → claude` + `tests → claude` overrides | Claude writes failing tests |\n| Inconsistent quality across models | No shared standards | Conventions injected into all prompts | mWP + quality gates enforced everywhere |\n| No learning from outcomes | Static routing only | `record_outcome()` + `learn_override()` | Routing improves with each task |\n\n**Projected scores if re-run:**\n\n| Dimension | Before | After (est.) | Change |\n|-----------|--------|-------------|--------|\n| Product spec | 0/10 | 9/10 | `docs → claude` |\n| Test coverage | 0/10 | 8/10 | `tdd_red → claude` |\n| Security | 10/10 | 10/10 | No change (already strong) |\n| Architecture | 8/10 | 9/10 | Conventions enforce patterns |\n| **Weighted avg** | **7.4/10** | **~8.5/10** | **+1.1 points** |\n\nCost efficiency would remain at ~83% savings — the new overrides only force claude for `docs` (1 task) and `tests` (new TDD step), not for CRUD/API/frontend work.\n"
  },
  {
    "path": "maggy/docs/maggy-rfc.md",
    "content": "# Maggy: An Autonomous AI Engineering Platform\n\n**RFC — Request for Comments**\n**Author:** Ali Shaheen, Protaige\n**Date:** May 2026\n**Version:** 5.0\n\n---\n\n## 1. Executive Summary\n\nMaggy is a local-first, self-improving AI engineering platform that transforms how development teams build software. Unlike code assistants that wait for prompts, Maggy is an autonomous agent that observes, learns, and optimizes — continuously improving its own effectiveness across models, workflows, and team knowledge.\n\n**What makes Maggy different:**\n\n- **Multi-model orchestration** — Maggy routes tasks to the best model (Claude, GPT-4o, Gemini, Kimi, DeepSeek, local Qwen) based on learned performance data, not static rules. When one model hits quota, work continues seamlessly on the next.\n- **Self-improving closed-loop control** — Every task Maggy completes generates reward signals that improve its future decisions. Model routing, inbox ordering, workflow steps, and fatigue management all optimize automatically.\n- **Process intelligence** — Maggy doesn't just write code. It learns from CI results, PR reviews, CodeRabbit findings, and merge patterns to preemptively fix issues before they reach reviewers.\n- **Maggy Mesh** — A peer-to-peer network connecting Maggy instances across a team. One developer's hard-won CI fix becomes the entire team's knowledge. Autonomously. Instantly.\n- **Local-first, no vendor lock-in** — All data stays on developer machines. No cloud dependency. No vendor seeing your code. Works offline with local models.\n\n**The value proposition:** A team of 5 developers running Maggy Mesh for 6 months accumulates 4x the learning of a solo developer. New team members inherit collective intelligence on day one. CI pass rates go up, review rounds go down, and the system gets smarter every week — without anyone configuring it.\n\n---\n\n## 2. Vision: Autonomous Engineering, Not Code Generation\n\nThe current generation of AI coding tools — Copilot, Cursor, Devin — are fundamentally reactive. They complete code when prompted, suggest edits when asked, and run tasks when instructed. They're sophisticated typeaheads, not engineers.\n\nAn engineer doesn't just write code. An engineer:\n\n- **Prioritizes** — Which ticket matters most right now?\n- **Plans** — What's the blast radius? What could break?\n- **Validates** — Does this feature align with the market? Do competitors have it?\n- **Executes** — Write the code, with the right model for the task\n- **Verifies** — Did CI pass? Did reviewers approve? Did it deploy cleanly?\n- **Learns** — What worked? What didn't? How do I do it better next time?\n\nMaggy does all of this. It's the first AI platform designed around the full software development lifecycle, not just the \"write code\" step.\n\n### The Autonomy Spectrum\n\n```\nLevel 0: Autocomplete (Copilot, TabNine)\n  → Completes the current line\n  → No context beyond the file\n  → No learning\n\nLevel 1: Chat Assistant (ChatGPT, Claude)\n  → Answers questions about code\n  → No project context\n  → No memory between sessions\n\nLevel 2: Project-Aware Assistant (Cursor, Continue)\n  → Understands the codebase\n  → Can edit multiple files\n  → Limited memory (rules, preferences)\n\nLevel 3: Task Agent (Devin, Claude Code Agent)\n  → Executes multi-step tasks\n  → Uses tools (terminal, browser)\n  → Single-model, single-project\n\nLevel 4: Autonomous Engineering Platform (Maggy) ← WE ARE HERE\n  → Multi-model, multi-project orchestration\n  → Self-improving from every task\n  → Process intelligence (learns from CI, reviews, deploys)\n  → Team intelligence via P2P mesh\n  → Market validation before engineering\n```\n\n---\n\n## 3. Architecture Overview\n\n### The Component Map\n\n```\n┌─────────────────────────────────────────────────────────────┐\n│                    MAGGY WEB DASHBOARD                        │\n│  ┌──────────┐ ┌─────────┐ ┌────────┐ ┌───────┐ ┌────────┐ │\n│  │  Inbox   │ │ Budget  │ │ Agents │ │  CIKG │ │Process │ │\n│  │ (ranked) │ │ (live)  │ │(status)│ │ (gaps)│ │(health)│ │\n│  └──────────┘ └─────────┘ └────────┘ └───────┘ └────────┘ │\n└──────────────────────────┬──────────────────────────────────┘\n                           │\n              ┌────────────┴────────────┐\n              │    ORCHESTRATOR LAYER    │\n              │                         │\n              │  Pi Agent (universal    │\n              │  harness, RPC mode)     │\n              │                         │\n              │  Token Budget Manager   │\n              │  Model Router (learned) │\n              │  Dual-Model Planner     │\n              └────────┬────────────────┘\n                       │\n        ┌──────────────┼──────────────┐\n        │              │              │\n   ┌────▼────┐   ┌────▼────┐   ┌────▼────┐\n   │Container│   │Container│   │Container│\n   │  1      │   │  2      │   │  3      │\n   │ Claude  │   │ GPT-4o  │   │  Qwen   │\n   │ (auth)  │   │ (front) │   │ (docs)  │\n   └─────────┘   └─────────┘   └─────────┘\n        │              │              │\n   ┌────┴──────────────┴──────────────┴────┐\n   │         INTELLIGENCE LAYER             │\n   │                                        │\n   │  iCPG — blast radius, drift, intent    │\n   │  Mnemos — memory, fatigue, checkpoints │\n   │  codebase-memory-mcp — code graph      │\n   │  CIKG — competitive intelligence       │\n   │  Process Intelligence — CI/PR/deploy   │\n   │  MCP Forge — capability expansion      │\n   │  Maggy Mesh — P2P team learning        │\n   └────────────────────────────────────────┘\n```\n\n### Pi: The Universal Agent Harness\n\nPi replaces per-CLI adapters with a single interface to every model. It runs inside Polyphony containers in RPC mode over stdin/stdout. The same PiAdapter code controls Claude, GPT-4o, Gemini, Kimi, DeepSeek, or a local Qwen — with identical tool interfaces.\n\n**Model fallback chain:**\n\n```\nClaude → GPT-4o → Gemini → Kimi → DeepSeek → Qwen (local, unlimited)\n```\n\nWhen a model hits quota or rate limits:\n1. Mnemos writes a structured checkpoint (goal, constraints, progress, state)\n2. Pi switches to the next model\n3. The checkpoint is injected as context\n4. The new model verifies it understands the task before continuing\n5. If verification fails, escalate to the next tier — don't retry on a weaker model\n\n**The user never notices the switch.** Work continues. That's the wow.\n\n### Token Budget Manager\n\n```yaml\nproviders:\n  anthropic:\n    daily_limit_usd: 50.00\n    used_today_usd: 32.15\n    model_preference: claude-sonnet-4\n  openai:\n    daily_limit_usd: 30.00\n    used_today_usd: 5.20\n    model_preference: gpt-4o\n  local:\n    daily_limit_usd: 0  # free\n    model_preference: qwen2.5-coder:32b\n```\n\nThe budget manager prevents runaway costs. When anthropic hits $50, Maggy doesn't stop — it routes to OpenAI. When OpenAI hits $30, it routes to local Qwen. Work never stops.\n\n---\n\n## 4. Self-Improvement: Multi-Level Closed-Loop Control\n\nThis is Maggy's core differentiator. Every task teaches Maggy something. Every CI failure, every review comment, every deploy result feeds back into the system. Maggy gets smarter every day — without anyone configuring it.\n\n### The Objective Function\n\n```\nefficiency = (value_delivered / time_spent) x quality_multiplier\n\nwhere:\n  value_delivered   = tickets landed + features shipped + bugs fixed\n  time_spent        = wall clock from ticket selection to merge\n  quality_multiplier = 1.0 - (bug_escape_rate + revert_rate + incident_rate)\n```\n\n### Five Control Levels\n\n| Level | Frequency | What It Does |\n|-------|-----------|-------------|\n| **L0 — Real-time** | Seconds | Catches tool failures, test failures, fatigue spikes, scope drift *as they happen*. Switches models mid-task when quality degrades. |\n| **L1 — Task** | Minutes | Computes task reward score. Updates model performance table. Logs process signals. |\n| **L2 — Daily** | Hours | Catches operational degradation: CI pass rate drops, model failure spikes, budget burn rate anomalies. Disables failing models. |\n| **L3 — Weekly** | Days | Strategic optimization: evolves skill files, adjusts workflow steps, triggers MCP Forge for capability gaps, patches prompts. |\n| **L4 — Monthly** | Weeks | Meta-optimization: recalibrates reward signals, adjusts tier boundaries, tunes exploration rate, changes the improvement process itself. |\n\n**Key principle:** Inner loops provide stability. Outer loops provide optimization. L0 catches a failing model in seconds — the user barely notices. L3 makes routing smarter over weeks — the system quietly improves. L4 makes the improvement process itself better over months.\n\n### What Gets Optimized\n\n**Model routing** — Maggy tracks reward per `(model x task_type x blast_tier)` triple. After 50+ tasks, routing outperforms random assignment by 20%+.\n\n```\n(claude, auth, high):       +0.92  ← claude excels at auth\n(qwen, docs, low):          +0.85  ← qwen is fast and free for docs\n(gpt-4o, frontend, medium): +0.78  ← gpt-4o is strong on frontend\n```\n\n**Inbox ordering** — Learns which tickets the user actually picks first. Adjusts urgency weights to match user behavior.\n\n**Workflow steps** — Drops steps that never catch issues (e.g., Codex counter-check on blast < 3). Re-enables them when they become valuable again.\n\n**Fatigue management** — Learns each user's optimal session length and pre-checkpoints at the right moment. Not at a generic threshold — at *your* threshold.\n\n---\n\n## 5. Process Intelligence: Learning from the Full SDLC\n\nMost AI tools optimize code generation. Maggy optimizes the **entire development process**.\n\n### Environment Discovery\n\nOn first run per project, Maggy auto-discovers the developer's workflow — no configuration:\n\n- **Ticketing:** GitHub Issues, Asana, Linear, Jira\n- **CI/CD:** GitHub Actions, Jenkins, CircleCI\n- **Code quality:** ESLint, ruff, mypy, pre-commit, coverage\n- **Review process:** Required reviewers, CODEOWNERS, branch protection\n- **Integrations:** CodeRabbit, Dependabot, Renovate, Vercel\n\n### Signal Collection\n\nMaggy continuously collects signals from the SDLC:\n\n| Signal Source | What Maggy Learns |\n|--------------|-------------------|\n| CI results | Which code patterns cause test failures |\n| PR review comments | What reviewers consistently flag |\n| CodeRabbit findings | Security and quality issues by pattern |\n| Merge patterns | How many rounds of review, time to merge |\n| Deploy results | Which changes cause deploy failures |\n\n### Preemptive Fixes\n\nThe pattern engine correlates `(code_pattern, review_feedback)` pairs:\n\n> \"Your reviewer always flags missing error handling in API routes. Maggy added it before the PR was created. Review rounds dropped from 2.8 to 1.1.\"\n\nThis is not prompt engineering. This is autonomous process optimization — Maggy observed a pattern, validated it statistically, and changed its behavior to prevent the issue. No human told it to.\n\n---\n\n## 6. Engram: Cross-Session Memory\n\n### The Amnesia Problem\n\nEvery AI coding tool today is an amnesiac. When a session ends, everything the agent learned — project conventions, reviewer preferences, codebase idioms, tool configurations — evaporates. The next session starts from scratch. This isn't a minor inconvenience; it's the fundamental bottleneck preventing AI agents from becoming genuinely useful over time.\n\nEngram identifies seven distinct amnesia pathologies:\n\n| Amnesia Type | What Gets Lost | Impact |\n|-------------|---------------|--------|\n| **Anterograde** | New memories fail to form across sessions | Every session restarts from zero |\n| **Retrograde** | Existing memories degrade over time | Learned patterns fade |\n| **Temporal** | When something happened is lost | Can't track how things changed |\n| **Source** | Where a fact came from is lost | Can't trust or audit memories |\n| **Interference** | Memories from one context contaminate another | Project A's patterns leak into Project B |\n| **Context-binding** | Right memory, wrong retrieval context | Conventions exist but aren't surfaced when needed |\n| **Confabulation** | Inferred patterns presented as confirmed facts | Agent \"remembers\" things it actually guessed |\n\n### The Memory Lifecycle\n\nEngram completes Maggy's memory stack:\n\n```\nMnemos (within-task)     → What the agent remembers during a single task\n     ↓ promote (confidence > 0.8, evidence >= 3)\nEngram (cross-session)   → What survives between sessions, per machine\n     ↓ distill to typed memory\nMesh (cross-machine)     → What's shared across the team, P2P\n```\n\nWithout Engram, Maggy has a 10-minute memory. With Engram, knowledge compounds across every session. After 100 sessions, Maggy knows your project's conventions, your reviewers' preferences, your CI failure patterns — and applies them automatically.\n\n### Three-Tier Namespace Model\n\nMemory is organized into three tiers to prevent both cross-project contamination and useful-pattern siloing:\n\n1. **Local** — project-specific memories (strict isolation). A Python FastAPI project's conventions never contaminate a React project's patterns.\n2. **Portfolio** — abstracted cross-project patterns. When a local pattern proves useful across 3+ projects, it's promoted — but only after de-contextualization (stripping project-specific names and paths).\n3. **Mesh** — peer-derived memories (quarantined on arrival). Must be locally validated before promotion to portfolio.\n\nThis three-tier model means Engram gets smarter across projects without cross-contamination.\n\n### Engram as Improvement Substrate\n\nEngram absorbs the improvement ledger. The ledger is the mutation log (what changed), Engram is the memory substrate (persists it across sessions), and the reward registry tracks whether it worked. Every self-modification becomes a persistent, queryable memory — Maggy remembers not just what it learned, but what it tried and what failed.\n\n### Amnesia Score\n\nEach project gets a 7-dimension diagnostic score (0.0 = perfect retention, 1.0 = total amnesia). The L3 weekly loop analyzes Amnesia Scores and adjusts encoding rules: if anterograde score is high, lower the promotion threshold; if interference is high, tighten namespace isolation.\n\n### Research Basis\n\nEngram builds on validated research: Mem0 (186M API calls, memory-as-object model), Zep/Graphiti (temporal validity windows), Hindsight (91.4% on LongMemEval, fact vs opinion separation), MAGMA (multi-graph retrieval with 45.5% higher reasoning accuracy), and A-MEM (Zettelkasten-style associative encoding). What none of these systems address is the combination of namespace isolation, origin tracking, temporal validity, and amnesia diagnosis in a single architecture designed for multi-project AI agents.\n\n---\n\n## 7. Maggy Mesh: Peer-to-Peer Team Intelligence\n\n### The Problem\n\nA solo developer's Maggy learns from their tasks. But teams have 5, 10, 50 developers — each independently discovering the same CI fixes, the same reviewer preferences, the same model performance patterns. That's wasted learning.\n\n### The Solution\n\nMaggy Mesh connects instances across a team into a peer-to-peer network. Each Maggy autonomously shares learned intelligence with other Maggys in the same organization.\n\n```\n┌──────────────────────────────────────────────────────────┐\n│                    ORGANIZATION                            │\n│                                                           │\n│  ┌─────────┐    ┌─────────┐    ┌─────────┐              │\n│  │ Maggy-A │◄──►│ Maggy-B │◄──►│ Maggy-C │              │\n│  │ (Ali)   │    │ (Sarah) │    │ (John)  │              │\n│  │ Python  │    │ React   │    │ DevOps  │              │\n│  └─────────┘    └─────────┘    └─────────┘              │\n│       ▲              ▲              ▲                    │\n│       └──────────────┴──────────────┘                    │\n│            Full mesh — everyone sees                      │\n│            everyone's learnings                           │\n└──────────────────────────────────────────────────────────┘\n```\n\n### What Gets Shared\n\nNot everything. Maggy Mesh shares **typed memory classes** with different merge rules:\n\n| Type | Example | Merge Rule |\n|------|---------|-----------|\n| **Scores** | \"Claude scores 0.92 on auth tasks\" | Weighted average by sample count |\n| **Patterns** | \"Add error handling before PR\" | Union-merge with frequency tracking |\n| **Policies** | \"Route blast 7+ to premium only\" | Backtest-gated — must pass on local data |\n| **Gaps** | \"No Linear integration\" | Additive accumulation |\n\n### Provenance\n\nEvery shared memory carries full provenance:\n\n- **Who:** peer_id, peer_name\n- **Where:** project_key, language, toolchain\n- **When:** created_at, last_verified\n- **How much:** evidence_count, confidence (decays with age)\n\nThis enables intelligent filtering: \"Only accept Python patterns from peers working on Python projects.\"\n\n### Quarantine System\n\nIncoming peer data doesn't go live immediately. It enters quarantine:\n\n1. **Self-confirmed:** Local data validates the pattern within 30 days\n2. **Crowd-confirmed:** 3+ peers independently report the same pattern\n3. **Human override:** Developer manually promotes or rejects\n\nThis prevents poisoning, stale data propagation, and context collapse. A bad pattern from one node can't silently corrupt the entire team.\n\n### Cold Start\n\nA new team member installs Maggy, discovers peers via mDNS, and receives the entire team's collective intelligence — quarantined until locally validated. Day one, they have the benefit of months of team learning.\n\n### The Compound Effect\n\n```\nIndividual Maggy:    knowledge = learning_rate x time\nTeam Mesh (n peers): knowledge = n x learning_rate x time x sharing_factor\n\n5 developers, 6 months:\n  Solo:  1 x 1.0 x 180 = 180 learning units\n  Mesh:  5 x 1.0 x 180 x 0.8 = 720 learning units (4x multiplier)\n```\n\nThe sharing_factor (0.8) accounts for context mismatch and quarantine filtering. The effect is superlinear because peers validate each other's patterns through crowd confirmation.\n\n---\n\n## 8. Lexon: Semantic Tool Binding\n\n### The Tool Overload Problem\n\nAs Maggy's capabilities grow — MCP Forge auto-generates servers, Process Intelligence adds signal collectors, each project adds environment-specific tools — the tool count will cross 50, then 100. Research shows tool selection accuracy collapses at this scale: RAG-MCP demonstrated accuracy dropping from 87% to 13% as tools grew from 10 to 100.\n\nA second failure mode persists even with retrieval: the **vocabulary gap**. Tool descriptions are written by engineers. Users speak in their own vocabulary. \"I want to blast my leads\" doesn't match `create_campaign` by any lexical metric. Maggy needs to learn that for *this user*, \"blast\" means bulk email send.\n\n### Two-Tier Routing\n\nLexon solves this with a two-tier pipeline that runs in parallel:\n\n1. **Tier A — Fast LLM Router** (<300ms): A compact tool manifest (name + 1-line description, ~400 tokens for 80 tools) fed to a fast model. Returns 5-7 candidates with rationale. JSON schema constrained to valid tool names — no hallucinated tools.\n\n2. **Tier B — Multilingual Semantic Retriever**: Vector search over the full tool registry, indexed by description, example queries, and learned synonyms. Multilingual embedding model ensures queries in any language match correctly.\n\nCandidates from both tiers are unioned and deduplicated. Each tier compensates for the other's failure mode: the LLM captures intent-level reasoning; the retriever captures lexical variants and multilingual matches.\n\n### Terminology Map\n\nA three-level vocabulary store that learns over time:\n\n- **System level**: Built-in tool descriptions (baseline)\n- **Org level**: Team-shared vocabulary, propagated via Mesh (e.g., \"follow up\" = specific CRM workflow)\n- **User level**: Personal shortcuts and preferences (e.g., \"morning sequence\" = campaign with time=09:00)\n\nResolution: user overrides org overrides system. **NOT bindings** encode negative matches — \"blast\" is explicitly NOT \"delete_all\" — preventing recurring mis-selections.\n\n### Dual-Mode Disambiguation\n\nWhen confidence is ambiguous, Lexon has two resolution modes:\n\n**Self-clarify (default, autonomous):** Lexon resolves ambiguity without asking the user by consulting iCPG's structured intent, Mnemos context, Engram's past bindings, process history, and Mesh consensus. If any source resolves confidence above threshold, proceed silently. The goal: 95%+ resolutions via self-clarify after 50+ interactions.\n\n**User-clarify (irreversible actions only):** Triggered only for destructive, expensive, or irreversible actions (delete, deploy, billing changes). Presents 2-3 concrete options. The user's selection becomes a permanent binding.\n\nAutonomous agents should almost never trigger user-clarify. This is what separates Maggy from tools that interrupt you constantly.\n\n### Personalization\n\nFive implicit learning signals update the Terminology Map without user effort:\n1. **Correction** → add NOT binding + positive binding\n2. **Affirmation** → increment confidence\n3. **Repetition** (5+) → promote to high-confidence synonym\n4. **Disambiguation selection** → capture as user-level binding\n5. **Clarification repetition** (3+) → escalate to explicit preference prompt\n\nHigh-confidence bindings persist via Engram across sessions and propagate to the org via Mesh.\n\n### Tool Contract Binding\n\nLexon doesn't just bind phrases to tool names — it binds to tool contracts. Each LexonRecord records the tool version and schema hash at bind time. When a tool's API changes, Lexon detects the schema drift and re-evaluates bindings rather than silently calling a tool with a different interface. This matters because MCP Forge auto-generates tools from API docs that evolve.\n\n### Outcome-Bearing Records\n\nEvery LexonRecord carries an outcome reward (-1.0 to 1.0): did the binding produce good results? Corrections are tracked with their source (user explicit, CI failure, review comment). This transforms Lexon from a static lookup table into a reward-bearing learning system that gets measurably better at tool selection over time.\n\n### Research Basis\n\nLexon builds on: RAG-MCP (Anthropic, 2025 — retrieval-based tool selection), Tool2Vec (2024 — example queries as embedding targets), ToolTree (ICLR 2026 — MCTS-style tool planning), Tool-MVR (2025 — self-correction loops), and Gorilla (Berkeley, 2023 — fine-tuned tool LLMs). Lexon's contribution is the unified architecture combining retrieval, disambiguation, multilingual support, and adaptive personalization — no prior system addresses all four.\n\n---\n\n## 9. Event Spine: The Nervous System\n\n### Why an Event Spine\n\nMaggy's components — iCPG, Mnemos, Lexon, Engram, Process Intelligence, Mesh — each generate events in their own formats. Without a canonical event spine, correlating \"user said X → Lexon bound tool Y → execution failed → memory Z was created → mutation W was proposed\" requires stitching together six different log formats.\n\nThe Event Spine defines a single ordered event stream that every component writes to:\n\n```\nIntentEvent → BindingEvent → ExecutionEvent → MemoryEvent\n                                                   ↓\nMeshEvent ← MutationEvent ← OutcomeEvent ← PersistenceEvent\n```\n\nEight typed events, each carrying a common header (event_id, task_id, project_id, agent_id, model_id, confidence, namespace, policy_version, reward_delta). This enables:\n\n- **End-to-end tracing**: follow a task_id across all 8 event types\n- **Reward attribution**: OutcomeEvent.reward propagates back to BindingEvent (was tool selection good?) and MutationEvent (was self-modification good?)\n- **Replay debugging**: reproduce failures from the event stream without re-executing\n- **Amnesia diagnosis**: compare MemoryEvent → PersistenceEvent conversion rate per project\n- **Self-improvement validation**: MutationEvent + OutcomeEvent = evidence for whether L3/L4 changes helped\n\n### The Positioning Statement\n\n> Maggy understands intent through iCPG. Maggy survives task execution through Mnemos. Maggy chooses the right capability through Lexon. Maggy remembers consequences through Engram. Maggy evolves behavior through rewards. Maggy spreads successful mutations through Mesh.\n>\n> The Event Spine connects all six into a single typed, correlated, reward-bearing event stream. This is the nervous system of an autonomous engineering agent.\n\n---\n\n## 10. Competitive Landscape\n\nThe AI coding tool market has exploded into distinct categories. Understanding where Maggy fits — and where it doesn't compete — is critical for positioning.\n\n### 10.1 Market Taxonomy\n\nThe landscape breaks into five categories, each with different value propositions:\n\n```\n┌─────────────────────────────────────────────────────────────────┐\n│                   AI CODING TOOL TAXONOMY (2026)                  │\n│                                                                  │\n│  1. CLOUD AGENT PLATFORMS (autonomous, cloud-hosted)             │\n│     Codex (OpenAI), Devin (Cognition), Copilot Cloud Agent      │\n│     Claude Managed Agents                                        │\n│                                                                  │\n│  2. AI-NATIVE IDEs (editor-first, multi-model)                   │\n│     Cursor, Windsurf (Codeium/Cognition)                         │\n│                                                                  │\n│  3. CLI AGENTS (terminal-first, model-agnostic)                  │\n│     Claude Code, Codex CLI, Aider, OpenCode, Cline              │\n│                                                                  │\n│  4. APP BUILDERS (prompt-to-app, no-code/low-code)               │\n│     Lovable, Bolt.new, Replit Agent, v0 (Vercel)                 │\n│                                                                  │\n│  5. AUTONOMOUS ENGINEERING PLATFORMS                             │\n│     Maggy ← ONLY ENTRY                                           │\n│     (self-improving + process intelligence + team mesh)          │\n└─────────────────────────────────────────────────────────────────┘\n```\n\nMaggy is not competing with Lovable (app builders) or Cursor (IDE experience). Maggy competes on a different axis: **autonomous improvement over time**. The question isn't \"which tool writes better code today?\" — it's \"which tool writes better code *next month* than it did *this month*?\"\n\n### 10.2 Cloud Agent Platforms\n\n#### OpenAI Codex (Cloud)\n\nCodex is OpenAI's cloud-hosted autonomous coding agent, launched May 2025. Each task runs in its own sandboxed cloud environment preloaded with your GitHub repository. It can write features, fix bugs, run tests, and submit PRs — all in parallel.\n\n| Capability | Codex Cloud | Maggy |\n|-----------|-------------|-------|\n| Execution model | Cloud sandbox (internet disabled) | Local containers (full network) |\n| Model | codex-1 (o3 variant), GPT-5.3-Codex | 6+ models, learned routing |\n| Parallel tasks | Yes (multiple cloud sandboxes) | Yes (Polyphony containers) |\n| Self-improvement | No | 5-level closed-loop control |\n| Process intelligence | No | Full SDLC learning |\n| Team learning | No cross-instance learning | Mesh (P2P, autonomous) |\n| SWE-bench Verified | 85% (GPT-5.3-Codex) | Model-dependent (routes to best) |\n| Cost | ChatGPT Pro/Enterprise subscription | Self-hosted, pay-per-model-use |\n| Data privacy | Code sent to OpenAI cloud | Local-first, code stays on machine |\n| Trigger automation | Codex Jobs (on GitHub push) | Process Intelligence (on any signal) |\n\n**Codex's strength:** Cloud-native parallel execution with strong sandboxing. The upcoming Codex Jobs feature (automated triggers on git events) is compelling for CI/CD workflows.\n\n**Maggy's edge:** Codex treats each task as independent — it doesn't learn from past tasks, doesn't track reviewer patterns, and doesn't share knowledge across team members. Maggy's L1-L4 control loops mean task #100 is handled significantly better than task #1.\n\n#### Devin (Cognition)\n\nDevin is an autonomous cloud-based AI software engineer. It reached $73M ARR by early 2026, with 67% of PRs merged autonomously. Cognition also acquired Windsurf for ~$250M.\n\n| Capability | Devin | Maggy |\n|-----------|-------|-------|\n| Execution model | Cloud VM with browser | Local containers |\n| Knowledge system | Playbooks + Knowledge docs (manual) | Dynamic typed memory (automatic) |\n| Cross-instance learning | No — knowledge is per-org, manually curated | Yes — Mesh shares automatically |\n| Multi-model | Limited | 6+ models with auto-routing |\n| Self-improvement | Playbooks improve via manual updates | 5-level automatic control loops |\n| Process intelligence | No | CI, reviews, deploys, merge patterns |\n| Managed Devins | Yes (parallel orchestration) | Yes (Polyphony containers) |\n| SWE-bench Verified | 45.8% (Devin 2.0, unassisted) | Model-dependent |\n| Cost | $500/mo Teams, custom Enterprise | Self-hosted |\n| Scheduling | Recurring/one-time scheduled sessions | Continuous background operation |\n\n**Devin's strength:** Enterprise organization structure, admin controls, playbook management. The acquisition of Windsurf gives them an IDE play too.\n\n**Maggy's edge:** Devin's knowledge system is manually curated — someone writes playbooks and knowledge docs. Maggy's intelligence is learned automatically from task outcomes. Devin doesn't share learnings across team members' instances; Maggy Mesh does this autonomously.\n\n#### Claude Managed Agents\n\nAnthropic's cloud agent platform, updated May 2026 with three significant features: dreaming, outcomes, and multi-agent orchestration.\n\n| Capability | Claude Managed Agents | Maggy |\n|-----------|----------------------|-------|\n| Execution model | Secure cloud containers | Local containers |\n| Dreaming | Yes — reviews past sessions, extracts patterns | Similar to L3/L4 loops |\n| Memory | Per-agent + cross-agent via dreaming | Typed memory (scores, patterns, policies, gaps) |\n| Multi-agent | Orchestration + webhooks | Polyphony containers + cross-agent delegation |\n| Self-improvement | Dreaming (research preview) | 5-level closed-loop control (designed in) |\n| Process intelligence | No | Full SDLC learning |\n| Team learning | Cross-agent dreaming (same org) | Mesh (P2P, cross-machine) |\n| Local execution | No (cloud only) | Yes (local-first) |\n\n**Claude Managed Agents' strength:** Dreaming is the closest any competitor comes to Maggy's self-improvement concept. Harvey (legal AI) saw 6x task completion improvement after implementing dreaming. The cross-agent pattern extraction is genuinely novel.\n\n**Maggy's edge:** Dreaming is cloud-only and Anthropic-locked. Maggy's control loops work locally, across any model, and share learnings across developer machines — not just across agent sessions in the cloud.\n\n#### GitHub Copilot (Cloud Agent + Agent Mode)\n\nCopilot evolved from autocomplete to a multi-layered platform: inline suggestions, chat, agent mode (IDE), and cloud agent (autonomous).\n\n| Capability | Copilot | Maggy |\n|-----------|---------|-------|\n| Code completion | Best-in-class inline suggestions | Via Pi (any model) |\n| Cloud agent | Yes — autonomous PRs from issues | Yes — local containers |\n| Agent mode | IDE-integrated (VS Code, Visual Studio) | CLI + web dashboard |\n| Custom agents | User-level + repo-level definitions | Skills + iCPG + Mnemos |\n| Multi-model | Yes (GPT-4o, Claude, Gemini via settings) | Yes (6+ models, learned routing) |\n| Security tools | Security Reviewer agent (beta) | iCPG drift detection |\n| Self-improvement | No | 5-level closed-loop control |\n| Process intelligence | No | Full SDLC learning |\n| Team learning | Spaces (cloud-mediated, admin-controlled) | Mesh (P2P, autonomous) |\n| Debugger agent | Yes (Visual Studio, runtime validation) | L0 real-time control |\n| Ecosystem | GitHub-native (Issues, PRs, Actions) | GitHub API + any ticketing system |\n\n**Copilot's strength:** Deepest IDE integration. The debugger agent validating fixes against runtime behavior is unique. GitHub ecosystem integration is unmatched. Custom agents with workspace awareness, MCP connections, and model selection are powerful.\n\n**Maggy's edge:** Copilot doesn't learn from its mistakes. It doesn't track which model does best on which task type. It doesn't observe CI results to preemptively fix reviewer complaints. And Spaces is admin-curated knowledge — not automatically learned intelligence.\n\n### 10.3 AI-Native IDEs\n\n#### Cursor\n\nCursor is the leading AI-native IDE (~$100M+ ARR), a fork of VS Code with deep AI integration.\n\n| Capability | Cursor | Maggy |\n|-----------|--------|-------|\n| IDE experience | Native (fork of VS Code) | CLI + web dashboard |\n| Background agents | 8 parallel cloud agents | Polyphony local containers |\n| Memories | Project-scoped, persisted across sessions | Typed memory with provenance |\n| Rules | `.cursorrules`, project rules | Skills (`.md`), iCPG, Mnemos |\n| Security review | Always-on PR security agents (beta) | iCPG constraints + drift |\n| Team features | Centralized billing, usage analytics | Mesh (P2P intelligence sharing) |\n| Model routing | Manual selection | Learned from reward data |\n| Self-improvement | Memories (passive) | 5-level active control loops |\n| Process intelligence | No | Full SDLC learning |\n| Context management | Rules, skills, MCPs, subagents | Skills, iCPG, Mnemos, code graph |\n\n**Cursor's strength:** UX polish, background agents at scale (8 parallel), and the always-on security review agents. The context usage breakdown (rules, skills, MCPs) shows mature observability.\n\n**Maggy's edge:** Cursor's memories are passive (\"remember this fact\"). Maggy's memory is active — it observes outcomes and adjusts behavior. Cursor doesn't learn from CI failures, doesn't track reviewer patterns, and doesn't share intelligence P2P.\n\n#### Windsurf (Codeium → Cognition)\n\nWindsurf's Cascade agent plans and executes multi-file edits with a dedicated planning agent running in the background. Acquired by Cognition (Devin) for ~$250M in December 2025.\n\n| Capability | Windsurf | Maggy |\n|-----------|----------|-------|\n| Agent | Cascade (plan + execute) | Multi-level control loops |\n| Codemaps | AI-annotated visual code maps | codebase-memory-mcp graph |\n| Built-in browser | Yes (web context for Cascade) | Process Intelligence API hooks |\n| Self-improvement | No | 5-level closed-loop control |\n| Cost | $15/mo Pro | Self-hosted |\n\n### 10.4 CLI Agents\n\n#### Claude Code\n\nAnthropic's terminal-first coding agent. Runs locally, supports multi-agent orchestration via Task tool with teams.\n\n| Capability | Claude Code | Maggy |\n|-----------|-------------|-------|\n| Multi-agent | Task tool, teams, SendMessage | Polyphony containers + Pi |\n| Model | Claude only | 6+ models with auto-routing |\n| IDE integration | VS Code, JetBrains, desktop app | CLI + web dashboard |\n| Hooks | PreToolUse, PostToolUse, Stop | Skills + hooks + L0 real-time |\n| Self-improvement | No | 5-level closed-loop control |\n| MCP support | Native | Native + MCP Forge (auto-generate) |\n\n**Note:** Maggy is *built on* Claude Code's infrastructure (skills, hooks, MCP). It extends Claude Code with self-improvement, multi-model routing, process intelligence, and team mesh.\n\n#### Codex CLI (OpenAI)\n\nOpen-source (Apache-2.0), Rust-based terminal agent. 81K+ GitHub stars. Runs locally, authenticates via ChatGPT account or API key.\n\n| Capability | Codex CLI | Maggy |\n|-----------|-----------|-------|\n| Open source | Yes (Apache-2.0, 81K stars) | Yes |\n| Language | Rust (96.3%) | Python |\n| Model | OpenAI models only | 6+ providers |\n| Self-improvement | No | 5-level closed-loop control |\n| Team learning | No | Mesh (P2P) |\n\n#### Aider\n\nOpen-source CLI pair programmer. 39K+ GitHub stars, 4.1M+ installations. Model-agnostic with an architect/editor dual-model approach.\n\n| Capability | Aider | Maggy |\n|-----------|-------|-------|\n| Open source | Yes (39K stars) | Yes |\n| Multi-model | Yes (75+ providers) | Yes (6+ with auto-routing) |\n| Architect mode | Dual-model: strong planner + cheap editor | Dual-model planning (Phase 6) |\n| Git integration | Every edit = reviewable commit | iCPG + Polyphony branches |\n| Auto-lint/test | Yes (on every change) | L0 real-time control |\n| Self-improvement | No | 5-level closed-loop control |\n| Team learning | No | Mesh (P2P) |\n\n**Aider's strength:** The architect/editor mode is clever cost optimization — expensive model plans, cheap model executes. Maggy's Phase 6 dual-model planning is similar but adds conflict resolution and outcome tracking.\n\n#### OpenCode\n\nWas a Go-based CLI with TUI (Bubble Tea), 12K+ stars. **Archived September 2025**, now continued as \"Crush\" by the original author (Charm team). Supported 75+ LLM providers, SQLite session storage, LSP integration.\n\n### 10.5 App Builders\n\nThese tools target a different audience (non-developers, designers, rapid prototyping) but are worth understanding as they represent the \"opposite end\" of the autonomy spectrum.\n\n#### Lovable\n\nPrompt-to-full-stack-app builder. 2.3M users, $100M ARR, $6.6B valuation (Series B, Dec 2025, backed by Nvidia/Salesforce).\n\n| Capability | Lovable | Maggy |\n|-----------|---------|-------|\n| Target user | Non-developers, designers | Professional developers |\n| Output | Full-stack app from prompt | Code changes to existing codebase |\n| Stack | React + TypeScript + Supabase | Any stack |\n| Agent mode | Autonomous development mode | Multi-level control loops |\n| GitHub sync | Yes | Native (git-first) |\n| Self-improvement | No | 5-level closed-loop control |\n\n#### Bolt.new, Replit Agent, v0\n\n- **Bolt.new** — Browser-based JS app generator. 1M+ websites generated in 5 months.\n- **Replit Agent 4** (March 2026) — Handles auth, databases, parallel task execution, Design Mode, checkpoint rollback. Richest ecosystem (50+ languages).\n- **v0** (Vercel) — Specializes in React components with Tailwind/shadcn/ui. Precision frontend generation.\n\nThese are complementary to Maggy, not competitive. A developer might use Lovable to prototype, then bring the codebase into Maggy for professional development with CI integration, code quality tracking, and team collaboration.\n\n### 10.6 Summary Comparison Matrix\n\n| Capability | Codex Cloud | Devin | Claude Managed | Copilot | Cursor | Claude Code | Aider | Maggy |\n|-----------|------------|-------|---------------|---------|--------|-------------|-------|-------|\n| **Self-improvement** | - | - | Dreaming (preview) | - | - | - | - | 5-level control |\n| **Process intelligence** | - | - | - | - | - | - | - | Full SDLC |\n| **Team learning** | - | - | Cross-agent dreaming | Spaces | Org memories | - | - | P2P Mesh |\n| **Multi-model routing** | - | Limited | - | Manual | Manual | - | Manual | Learned |\n| **Local-first** | - | - | - | - | Partial | Yes | Yes | Yes |\n| **Cloud agents** | Yes | Yes | Yes | Yes | Yes | - | - | - |\n| **IDE integration** | VS Code | Browser | - | Native | Native | VS Code | Terminal | Dashboard |\n| **Open source** | CLI only | - | - | - | - | - | Yes | Yes |\n| **Vendor lock-in** | OpenAI | Cognition | Anthropic | GitHub | Cursor | Anthropic | None | None |\n\n### 10.7 Where Maggy Wins\n\n1. **Self-improvement is the product** — No other tool has a formal multi-level control system. Claude's dreaming is the closest, but it's cloud-only and single-vendor.\n2. **Process intelligence is unique** — Nobody else learns from CI results, reviewer comments, and merge patterns to preemptively fix code.\n3. **Autonomous team learning** — Mesh shares typed, provenanced intelligence P2P without a central server. Everyone else's \"team features\" are admin-curated knowledge or cloud-mediated memory.\n4. **Model-agnostic by design** — Not locked to any provider. Learns which model is best for which task type automatically.\n5. **Local-first with no compromises** — Code never leaves developer machines. Works offline with local models. No vendor sees your proprietary codebase.\n\n### 10.8 Where Competitors Win Today\n\n- **Copilot:** Deepest IDE integration, GitHub ecosystem, largest user base\n- **Cursor:** Best editor UX, background agents at scale, security review agents\n- **Devin:** Enterprise controls, playbooks, $73M ARR proves market demand\n- **Claude Managed Agents:** Dreaming is genuinely novel, cloud scalability\n- **Codex Cloud:** Parallel cloud sandboxes, upcoming Codex Jobs automation\n- **Lovable:** Prompt-to-app for non-developers, $6.6B validates the broader market\n- **Aider:** Open-source community (39K stars), architect/editor cost optimization\n\n---\n\n## 11. Migration Roadmap\n\n### Phase Dependencies\n\n```\nPhase 1: PiAdapter + Token Budget ──────────────────┐\n    │                                                 │\n    ├── Phase 2: Model Routing (blast→model)          │\n    ├── Phase 3: Mnemos Multi-Model Fatigue           │\n    ├── Phase 6: Dual-Model Planning                  │\n    │                                                 │\nPhase 4: CIKG Extract ────────────────┐               │\n    │                                  │              │\n    └───────────┬──────────────────────┘              │\n                │                                     │\nPhase 5: Maggy v2 Dashboard ◄─────────────────────────┘\n    │\n    ├── Phase 7: Vercel Deploy Containers (Docker)\n    ├── Phase 8: Process Intelligence ──────┐\n    ├── Phase 9: MCP Forge                  │\n    │                                       │\n    └── Phase 11: Maggy Mesh ◄──────────────┘\n                                            │\nPhase 10: Integration Testing ◄─────────────┘\n                                            │\nPhase 3 + Phase 5 ──► Phase 12: Engram ─────┘\n                                    │\nPhase 9 + Phase 12 ─► Phase 13: Lexon\n                                    │\nPhase 12 + Phase 13 ─► Phase 14: Event Spine\n```\n\n### Phase Summary\n\n| Phase | What | Priority | Effort | Dependencies |\n|-------|------|----------|--------|-------------|\n| 1 | PiAdapter + token budget | P0 | Large | Pi installed |\n| 2 | Model routing (blast→model) | P0 | Medium | Phase 1 + iCPG |\n| 3 | Mnemos multi-model fatigue | P1 | Medium | Phase 1 |\n| 4 | CIKG extraction | P1 | Medium | Supabase |\n| 5 | Maggy v2 dashboard | P0 | Large | Phases 1-4 |\n| 6 | Dual-model planning | P2 | Medium | Phase 1 |\n| 7 | Vercel deploy containers | P2 | Medium | Docker |\n| 8 | Process intelligence | P1 | Large | Phase 5 + GitHub API |\n| 9 | MCP Forge | P2 | Large | Phase 5 |\n| 10 | Integration testing + docs | P1 | Large | All phases |\n| 11 | Maggy Mesh (P2P) | P2 | XL | Phase 5 + Phase 8 |\n| 12 | Engram (cross-session memory) | P1 | Large | Phase 3 + Phase 5 |\n| 13 | Lexon (semantic tool binding) | P2 | Large | Phase 9 + Phase 12 |\n| 14 | Event Spine (canonical event flow) | P2 | Medium | Phase 12 + Phase 13 |\n\n---\n\n## 12. Research Foundations & Prior Art\n\nMaggy's architecture draws from five distinct research streams. This isn't a tool assembled from hype — each component maps to validated research with production evidence.\n\n### 12.1 Self-Evolving Agent Systems\n\nThe field of self-improving AI agents has exploded in 2025-2026. Papers mentioning \"AI Agent\" or \"Agentic AI\" in 2025 exceeded the total from 2020-2024 combined by more than twofold.\n\n**Key papers and systems:**\n\n- **SICA — Self-Improving Coding Agent (ICLR 2025 Workshop)** — An agent that autonomously edits its own codebase, climbing from 17% to 53% on SWE-bench Verified through self-modification. This validates Maggy's core thesis: agents that modify their own behavior based on outcomes dramatically outperform static agents. ([Paper](https://openreview.net/pdf?id=rShJCyLsOr))\n\n- **Godel Agent (ACL 2025)** — Uses runtime monkey-patching with safety verification. The agent modifies both its task-solving policy and its own learning algorithm, guided by high-level objectives while formal invariant checking prevents unsafe changes. Maggy's L3/L4 control loops use a similar principle: change the improvement process itself, but with rollback safeguards.\n\n- **SAGE — Skill Augmented GRPO (December 2025)** — Agents accumulate reusable function libraries across task chains, achieving 8.9% goal completion gains while reducing output tokens by 59%. This directly parallels Maggy's skill evolution in L3, where successful patterns get codified into reusable skills.\n\n- **HyperAgents (2026)** — Makes the meta-level itself editable. Agents improve *how they improve*, discovering domain-general skills (memory management, prompt engineering, exploration strategies) that transfer across coding, mathematics, and scientific domains. Maggy's L4 monthly evolution loop is designed for exactly this: improving the improvement process.\n\n- **SWE-RL (Meta, 2025)** — Uses self-play where agents alternate between bug injection and fixing roles, gaining +10.4 points on SWE-bench Verified without human-labeled data. This reinforcement-based approach validates Maggy's reward registry concept.\n\n- **AlphaEvolve (Google DeepMind)** — Recovered 0.7% of Google's worldwide compute through automated algorithm optimization. This is the first evidence of hyperscale ROI from self-improving agents — validating that autonomous optimization can deliver measurable economic value.\n\n**Maggy's position:** Maggy applies self-evolution at the *operational* level (routing, workflows, process patterns) rather than at the model-weight level. This is more practical for a local-first system — you don't need GPU clusters to improve model routing decisions based on task rewards.\n\n### 12.2 Agent Memory Systems\n\nMemory has emerged as the central bottleneck for autonomous agents. A comprehensive 2025-2026 survey (\"Memory in the Age of AI Agents\") offers a structured taxonomy of how memory is designed, implemented, and evaluated in modern LLM-based agents.\n\n**Key developments:**\n\n- **Mem0 (2025-2026)** — Dominates commercially with 186 million API calls quarterly. The graph-enhanced variant (Mem0g) builds a directed, labeled knowledge graph alongside the vector store. Maggy's typed memory system (scores, patterns, policies, gaps) is similarly structured but uses domain-specific merge rules rather than a general-purpose graph.\n\n- **Collaborative Memory (2025)** — A framework for multi-user, multi-agent environments with asymmetric, time-evolving access controls. Maintains private memory (per-user) and shared memory (selectively shared). This directly validates Maggy Mesh's approach of personal memory + team memory with provenance-based filtering.\n\n- **MAGMA: Multi-Graph Agentic Memory Architecture (2026)** — Uses multiple graph structures for different memory types. Parallels Maggy's typed memory classes where scores, patterns, and policies each have different storage and merge semantics.\n\n- **SimpleMem (2025)** — Achieved 26.4% average F1 improvement over baselines with 30x token reduction. Demonstrates that structured memory management produces dramatically better results than naive context stuffing.\n\n**Maggy's position:** Most memory systems are passive stores. Maggy's memory is active — the L1-L4 control loops continuously update, prune, and evolve stored knowledge based on outcomes. The Mesh adds a distributed dimension that no other agent memory system currently implements.\n\n### 12.3 Federated & Distributed AI\n\n- **Federated AI Agents** — Intelligent software systems that learn collaboratively across multiple devices while keeping data localized. This is the theoretical foundation for Maggy Mesh: share learned intelligence, not raw data.\n\n- **Agentic Federated Learning (ICML 2025)** — Autonomous agents collaborate on distributed learning tasks, each contributing local expertise to a shared model. Maggy adapts this from model training to operational intelligence: instead of sharing gradients, Maggy shares typed memory (scores, patterns, policies) with provenance.\n\n- **Multi-Agent Collaboration Surveys (ACM DEAI 2025)** — A unified taxonomy decomposing AI agents into Perception, Brain, Planning, Action, Tool Use, and Collaboration subsystems. Surveys show collaborative architectures outperform isolated agents by 30-60% on complex tasks. Gartner reported a 1,445% surge in multi-agent system inquiries from Q1 2024 to Q2 2025.\n\n- **CRDT-inspired merge** — Conflict-free replicated data types allow distributed systems to merge state without coordination. Maggy uses type-specific merge rules (weighted average for scores, union for patterns, backtest-gated for policies) inspired by CRDT semantics.\n\n### 12.4 Self-Improving Coding in Production\n\nThe research isn't just theoretical. Production deployments validate that self-improving agents deliver measurable value:\n\n| System | Result | Relevance to Maggy |\n|--------|--------|-------------------|\n| **Meta's REA** | Doubled model accuracy; 3 engineers improved 8 models simultaneously | Multi-model optimization works at scale |\n| **Cognition (Devin)** | $73M ARR, 67% of PRs merged autonomously | Market demand for autonomous engineering is real |\n| **Harvey + Claude Dreaming** | 6x task completion improvement | Cross-session pattern extraction works |\n| **Karpathy's autoresearch** | 630-line script, 700 experiments in 2 days, 20 optimizations, 11% efficiency gain | Automated experimentation finds real improvements |\n| **AlphaEvolve** | 0.7% of Google's worldwide compute recovered | Self-improvement produces hyperscale ROI |\n\n**Claude Managed Agents — Dreaming (May 2026):** Anthropic's most relevant competitive move. Dreaming is a scheduled process that reviews past agent sessions, extracts patterns, and curates memories so agents improve over time. It surfaces insights no single session could see: recurring mistakes, workflows that multiple agents converge on, and team-shared preferences. This is the closest any competitor comes to Maggy's L3/L4 control loops — but it's cloud-only, Anthropic-locked, and doesn't include process intelligence (CI/review/deploy learning).\n\n### 12.5 Control Theory Foundations\n\n- **Inner-outer loop control** — Industrial control systems use fast inner loops for stability and slow outer loops for optimization. Maggy's L0 (seconds) through L4 (months) hierarchy mirrors this established engineering pattern. The key insight: outer loops NEVER override inner loop stability. L3 can change routing policy, but L0 still catches in-task failures regardless.\n\n- **Reinforcement learning from task outcomes** — Maggy's reward registry applies RLHF principles at the system level, using task outcomes (CI pass, review rounds, deploy success) and user behavior (overrides, re-dos, reverts) as reward signals. Unlike RLHF for model training, this operates at the operational level without any model fine-tuning.\n\n### 12.6 Local-First Software\n\n- **Local-first principles (Ink & Switch, 2019)** — Software that works offline, keeps data on user devices, and syncs peer-to-peer. Maggy's architecture is explicitly local-first: SQLite databases, local filesystem storage, optional P2P sync.\n\n- **Privacy-first trend (2026)** — Multiple tools now emphasize data privacy. OpenCode stores no code or context data. Aider runs entirely locally. The market is moving toward local execution as enterprises grow wary of sending proprietary code to cloud services. Maggy was designed local-first from day one — this isn't a retrofit.\n\n### 12.7 Market Context\n\nThe AI coding tool market is at an inflection point:\n\n- **Gartner predicts 40% of enterprise apps will include task-specific AI agents by 2026**, up from less than 5% in 2025.\n- **57% of organizations** report measurable impact from AI agents in software development (2025 industry survey).\n- The explosion of coding CLIs (30+ tools in 2026) reflects a shift from IDE-native AI to terminal-first agents that understand codebases, git history, and development workflows.\n- **SWE-bench scores** continue to climb: Claude Mythos Preview hits 93.9% on Verified, 77.8% on Pro. But raw coding ability is becoming commoditized. The differentiation is moving to *what surrounds the model*: memory, learning, process integration, and team collaboration.\n\n**The implication for Maggy:** Raw code generation quality is converging across models. The next competitive frontier is *what happens around the generation*: learning from outcomes, optimizing processes, sharing intelligence across teams. This is exactly where Maggy's architecture is positioned.\n\n---\n\n## 13. How to Get Started\n\n### Installation\n\n```bash\ngit clone https://github.com/alinaqi/maggy.git\ncd maggy\n./install.sh\n```\n\n### Current State (v4.0)\n\nToday, Maggy includes:\n- **Skills system** — Markdown-based instructions for AI agents (TDD, security, iCPG, Mnemos, etc.)\n- **Polyphony** — Container-isolated multi-agent orchestration (173 tests, 14 modules)\n- **iCPG** — Intent-augmented code property graph with blast radius scoring\n- **Mnemos** — Task-scoped memory lifecycle with typed MnemoGraph\n- **Cross-agent delegation** — Complexity-based task routing to Codex, Kimi, etc.\n- **Skill-lint** — Quality gates for skill files\n- **Behavioral evals** — Test framework for skill effectiveness\n\n### Roadmap to v5.0\n\nThe 14-phase migration path takes Maggy from a single-project, single-model toolkit to the multi-project, multi-model, self-improving, team-learning platform described in this RFC.\n\n---\n\n## Contact\n\n**Ali Shaheen** — ali@protaige.com\n**Protaige** — Building the future of autonomous AI engineering\n\n---\n\n*This document describes the Maggy v5 architecture as designed. Implementation follows the 11-phase migration path. For technical details, see `docs/architecture-v5.md`. For phase-level task specs, see `_project_specs/phases/`.*\n"
  },
  {
    "path": "maggy/install.sh",
    "content": "#!/usr/bin/env bash\n# Maggy installer — sets up deps and copies config template.\n#\n# Usage: ./install.sh\n\nset -euo pipefail\n\nHERE=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\nMAGGY_HOME=\"${MAGGY_HOME:-$HOME/.maggy}\"\n\necho \"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\"\necho \"  Maggy — Generic AI Engineering Command Center\"\necho \"  Installing to: $MAGGY_HOME\"\necho \"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\"\necho\n\n# 1. Check Python — enforce the 3.11+ minimum from pyproject.toml's requires-python.\nif ! command -v python3 >/dev/null 2>&1; then\n  echo \"❌ python3 not found. Install Python 3.11 or later first.\"\n  exit 1\nfi\nPY_VERSION=$(python3 -c 'import sys; print(f\"{sys.version_info.major}.{sys.version_info.minor}\")')\nif ! python3 -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 11) else 1)'; then\n  echo \"❌ Python 3.11 or later is required. Found Python $PY_VERSION.\"\n  echo \"   Install a newer Python (e.g. via pyenv, homebrew, or python.org).\"\n  exit 1\nfi\necho \"✓ Python $PY_VERSION\"\n\n# 2. Check claude CLI\nif ! command -v claude >/dev/null 2>&1; then\n  echo \"⚠  claude CLI not found on PATH. Maggy can still run, but Execute won't work until you install Claude Code.\"\nelse\n  echo \"✓ claude CLI found\"\nfi\n\n# 3. Install Python deps\necho\necho \"Installing Python dependencies...\"\npython3 -m pip install --upgrade pip >/dev/null 2>&1 || true\npython3 -m pip install -e \"$HERE\" || python3 -m pip install -r \"$HERE/requirements.txt\" 2>/dev/null || {\n  # Fallback: explicit install of runtime deps\n  python3 -m pip install 'fastapi>=0.115' 'uvicorn[standard]>=0.30' 'httpx>=0.27' 'anthropic>=0.40' 'pyyaml>=6.0' 'feedparser>=6.0' 'pydantic>=2.6'\n}\necho \"✓ Dependencies installed\"\n\n# 4. Config directory + template\nmkdir -p \"$MAGGY_HOME\"\nif [ ! -f \"$MAGGY_HOME/config.yaml\" ]; then\n  cp \"$HERE/config.example.yaml\" \"$MAGGY_HOME/config.yaml\"\n  echo \"✓ Wrote config template to $MAGGY_HOME/config.yaml\"\n  NEEDS_CONFIG=1\nelse\n  echo \"✓ Config already exists at $MAGGY_HOME/config.yaml (not overwritten)\"\n  NEEDS_CONFIG=0\nfi\n\n# 5. Remember bootstrap location for iCPG integration\nBOOTSTRAP_MARKER=\"$HOME/.claude/.bootstrap-dir\"\nif [ ! -f \"$BOOTSTRAP_MARKER\" ]; then\n  mkdir -p \"$HOME/.claude\"\n  # Maggy lives in <bootstrap>/maggy — one level up is bootstrap root\n  echo \"$(cd \"$HERE/..\" && pwd)\" > \"$BOOTSTRAP_MARKER\"\n  echo \"✓ Marked bootstrap location for iCPG access\"\nfi\n\necho\necho \"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\"\nif [ \"$NEEDS_CONFIG\" = \"1\" ]; then\n  echo \"Next steps:\"\n  echo \"  1. Edit $MAGGY_HOME/config.yaml\"\n  echo \"     - Set your org name, domain, GitHub org + repos\"\n  echo \"     - Set codebase paths for each repo you want Maggy to execute in\"\n  echo\n  echo \"  2. Export credentials:\"\n  echo \"     export GITHUB_TOKEN=ghp_...           # repo + issues scopes\"\n  echo \"     export ANTHROPIC_API_KEY=sk-ant-...\"\n  echo\n  echo \"  3. Run:\"\n  echo \"     cd $HERE && python3 -m maggy.main\"\n  echo\n  echo \"  4. Open http://localhost:8080\"\nelse\n  echo \"Ready to run:\"\n  echo \"  cd $HERE && python3 -m maggy.main\"\n  echo \"  Then open http://localhost:8080\"\nfi\necho \"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\"\n"
  },
  {
    "path": "maggy/maggy/__init__.py",
    "content": "\"\"\"Maggy — generic AI engineering command center.\"\"\"\n\n__version__ = \"0.1.0\"\n"
  },
  {
    "path": "maggy/maggy/adapters/__init__.py",
    "content": "\"\"\"Unified agent adapters for multi-model execution.\"\"\"\n"
  },
  {
    "path": "maggy/maggy/adapters/cli_discovery.py",
    "content": "\"\"\"Auto-discover installed AI CLIs and their command-line flags.\n\nProbes each CLI via --help, parses capabilities, and builds\ncommand templates that PiAdapter uses to spawn prompts.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport logging\nimport re\nimport shutil\nimport subprocess\nfrom dataclasses import dataclass, field\n\nlogger = logging.getLogger(__name__)\n\n_HELP_TIMEOUT = 10\n\n\n@dataclass\nclass CliProfile:\n    \"\"\"Discovered CLI capabilities and flags.\"\"\"\n\n    name: str\n    binary: str\n    version: str = \"\"\n    installed: bool = False\n    prompt_flag: str = \"\"\n    work_dir_flag: str = \"\"\n    auto_approve_flag: str = \"\"\n    output_format_flag: str = \"\"\n    max_turns_flag: str = \"\"\n    afk_flag: str = \"\"\n    uses_exec_subcommand: bool = False\n    uses_run_subcommand: bool = False\n    run_model: str = \"\"\n    prompt_is_positional: bool = False\n\n    def build_command(\n        self, prompt: str, wd: str, max_turns: int,\n    ) -> list[str]:\n        \"\"\"Build full CLI command from discovered flags.\"\"\"\n        cmd = [self.binary]\n        if self.uses_exec_subcommand:\n            cmd.append(\"exec\")\n        elif self.uses_run_subcommand:\n            cmd += [\"run\", self.run_model]\n        if self.prompt_is_positional:\n            if self.prompt_flag:\n                cmd.append(self.prompt_flag)\n            cmd.append(prompt)\n        elif self.prompt_flag:\n            cmd += [self.prompt_flag, prompt]\n        else:\n            cmd.append(prompt)\n        if self.work_dir_flag:\n            cmd += [self.work_dir_flag, wd]\n        if self.auto_approve_flag:\n            cmd.append(self.auto_approve_flag)\n        if self.afk_flag:\n            cmd.append(self.afk_flag)\n        if self.output_format_flag:\n            cmd += [self.output_format_flag, \"json\"]\n        if self.max_turns_flag and max_turns > 0:\n            cmd += [self.max_turns_flag, str(max_turns)]\n        return cmd\n\n\n@dataclass\nclass DiscoveryResult:\n    \"\"\"Result of scanning all known CLI tools.\"\"\"\n\n    profiles: dict[str, CliProfile] = field(default_factory=dict)\n    errors: list[str] = field(default_factory=list)\n\n\n_KNOWN_CLIS = [\"claude\", \"codex\", \"kimi\", \"deepseek\", \"ollama\"]\n\n\ndef discover_all() -> DiscoveryResult:\n    \"\"\"Scan for all known AI CLIs and probe capabilities.\"\"\"\n    result = DiscoveryResult()\n    for name in _KNOWN_CLIS:\n        profile = discover_cli(name)\n        result.profiles[name] = profile\n        if not profile.installed:\n            result.errors.append(f\"{name}: not found\")\n    return result\n\n\ndef discover_cli(name: str) -> CliProfile:\n    \"\"\"Probe a single CLI binary for capabilities.\"\"\"\n    binary = shutil.which(name)\n    if not binary:\n        return CliProfile(name=name, binary=name)\n    profile = CliProfile(name=name, binary=binary, installed=True)\n    profile.version = _get_version(binary)\n    help_text = _get_help(binary, \"\")\n    _extract_flags(profile, help_text)\n    if profile.uses_exec_subcommand:\n        exec_help = _get_help(binary, \"exec\")\n        _refine_from_exec(profile, exec_help)\n    if profile.uses_run_subcommand:\n        run_help = _get_help(binary, \"run\")\n        _refine_from_run(profile, run_help)\n    _post_process(profile)\n    return profile\n\n\ndef _extract_flags(profile: CliProfile, text: str) -> None:\n    \"\"\"Extract flags by matching known flag names in help.\"\"\"\n    # Print/prompt mode\n    if _has(text, r\"-p,\\s*--print\\b\"):\n        profile.prompt_flag = \"--print\"\n    elif _has(text, r\"(-p|--prompt)\\b\"):\n        profile.prompt_flag = \"-p\"\n    # Working directory\n    if _has(text, r\"--work-dir\\b\"):\n        profile.work_dir_flag = \"-w\"\n    elif _has(text, r\"-C,\\s*--cd\\b\"):\n        profile.work_dir_flag = \"-C\"\n    elif _has(text, r\"--cwd\\b\"):\n        profile.work_dir_flag = \"--cwd\"\n    # Auto-approve / skip permissions\n    if _has(text, r\"--dangerously-skip-permissions\\b\"):\n        profile.auto_approve_flag = \"--dangerously-skip-permissions\"\n    elif _has(text, r\"--dangerously-bypass-approvals\"):\n        profile.auto_approve_flag = \"--dangerously-bypass-approvals-and-sandbox\"\n    elif _has(text, r\"--yolo\\b\"):\n        profile.auto_approve_flag = \"--yolo\"\n    elif _has(text, r\"--auto-approve\\b\"):\n        profile.auto_approve_flag = \"--auto-approve\"\n    # Output format\n    if _has(text, r\"--output-format\\b\"):\n        profile.output_format_flag = \"--output-format\"\n    # Max turns / steps\n    if _has(text, r\"--max-turns\\b\"):\n        profile.max_turns_flag = \"--max-turns\"\n    elif _has(text, r\"--max-steps-per\"):\n        profile.max_turns_flag = \"--max-steps-per-turn\"\n    elif _has(text, r\"--max-steps\\b\"):\n        profile.max_turns_flag = \"--max-steps\"\n    # AFK mode\n    if _has(text, r\"--afk\\b\"):\n        profile.afk_flag = \"--afk\"\n    # Exec subcommand for non-interactive use\n    if _has(text, r\"\\bexec\\b.*non-interactive\"):\n        profile.uses_exec_subcommand = True\n    # Run subcommand (ollama-style: \"run  Run a model\")\n    if _has(text, r\"\\brun\\s+Run a model\\b\"):\n        profile.uses_run_subcommand = True\n\n\ndef _refine_from_exec(profile: CliProfile, text: str) -> None:\n    \"\"\"Override flags with more specific exec subcommand flags.\"\"\"\n    if _has(text, r\"-C,\\s*--cd\\b\"):\n        profile.work_dir_flag = \"-C\"\n    if _has(text, r\"--dangerously-bypass-approvals\"):\n        profile.auto_approve_flag = \"--dangerously-bypass-approvals-and-sandbox\"\n\n\ndef _refine_from_run(profile: CliProfile, text: str) -> None:\n    \"\"\"Extract flags from run subcommand help (ollama-style).\"\"\"\n    profile.prompt_is_positional = True\n    profile.prompt_flag = \"\"\n\n\ndef _post_process(profile: CliProfile) -> None:\n    \"\"\"Apply heuristics after flag extraction.\"\"\"\n    # --print means non-interactive mode; prompt is positional\n    if profile.prompt_flag == \"--print\":\n        profile.prompt_is_positional = True\n        profile.prompt_flag = \"-p\"\n    # exec subcommand: prompt is also positional\n    if profile.uses_exec_subcommand:\n        profile.prompt_is_positional = True\n        profile.prompt_flag = \"\"\n    # run subcommand (ollama): prompt is positional, need model\n    if profile.uses_run_subcommand:\n        profile.prompt_is_positional = True\n        profile.prompt_flag = \"\"\n        if not profile.run_model:\n            profile.run_model = _detect_ollama_model(profile)\n    # Claude uses subprocess cwd, not a --cd flag\n    if \"claude\" in profile.name.lower():\n        profile.work_dir_flag = \"\"\n    # If -p is a prompt arg (not print mode), --output-format\n    # is likely tied to --print mode and will error in -p mode\n    if not profile.prompt_is_positional and profile.output_format_flag:\n        profile.output_format_flag = \"\"\n\n\ndef _detect_ollama_model(profile: CliProfile) -> str:\n    \"\"\"Find best coding model available in ollama.\"\"\"\n    try:\n        out = subprocess.run(\n            [profile.binary, \"list\"],\n            capture_output=True, text=True,\n            timeout=_HELP_TIMEOUT,\n        )\n        text = out.stdout.lower()\n    except (subprocess.TimeoutExpired, OSError):\n        return \"qwen3-coder:30b-a3b-q8_0\"\n    # Prefer Qwen3-Coder (MoE, 3.3B active), then older models\n    prefs = [\n        \"qwen3-coder:30b-a3b-q8_0\",\n        \"qwen3-coder:30b\",\n        \"qwen2.5-coder:32b\", \"qwen2.5-coder:14b\",\n        \"qwen2.5-coder:7b\", \"deepseek-coder-v2\",\n        \"codellama:34b\", \"codellama:13b\",\n        \"qwen3:32b\", \"llama3.1:70b\", \"llama3.1:8b\",\n    ]\n    for model in prefs:\n        if model.split(\":\")[0] in text:\n            return model\n    # Fallback: first listed model\n    lines = out.stdout.strip().splitlines()\n    if len(lines) > 1:\n        return lines[1].split()[0]\n    return \"qwen3-coder:30b-a3b-q8_0\"\n\n\ndef _has(text: str, pattern: str) -> bool:\n    \"\"\"Check if pattern exists in text (case-insensitive).\"\"\"\n    return bool(re.search(pattern, text, re.IGNORECASE))\n\n\ndef _get_version(binary: str) -> str:\n    \"\"\"Get CLI version string.\"\"\"\n    for flag in (\"--version\", \"-V\", \"-v\"):\n        try:\n            out = subprocess.run(\n                [binary, flag],\n                capture_output=True, text=True,\n                timeout=_HELP_TIMEOUT, env=_clean_env(),\n            )\n            text = (out.stdout + out.stderr).strip()\n            if text and len(text) < 200:\n                return text.split(\"\\n\")[0]\n        except (subprocess.TimeoutExpired, OSError):\n            continue\n    return \"\"\n\n\ndef _get_help(binary: str, subcommand: str) -> str:\n    \"\"\"Run --help and return output.\"\"\"\n    cmd = [binary]\n    if subcommand:\n        cmd.append(subcommand)\n    cmd.append(\"--help\")\n    try:\n        out = subprocess.run(\n            cmd, capture_output=True, text=True,\n            timeout=_HELP_TIMEOUT, env=_clean_env(),\n        )\n        return (out.stdout + out.stderr).strip()\n    except (subprocess.TimeoutExpired, OSError) as exc:\n        logger.debug(\"Help failed for %s: %s\", binary, exc)\n        return \"\"\n\n\ndef _clean_env() -> dict[str, str]:\n    \"\"\"Return env without CLAUDECODE to avoid nesting block.\"\"\"\n    import os\n    env = os.environ.copy()\n    env.pop(\"CLAUDECODE\", None)\n    return env\n"
  },
  {
    "path": "maggy/maggy/adapters/pi.py",
    "content": "\"\"\"Unified adapter for CLI prompts and Pi RPC control.\n\nAuto-discovers installed AI CLIs and their flags at init time\nso Maggy can orchestrate any subscription-based tool (claude,\ncodex, kimi, etc.) without hardcoded command templates.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport json\nimport logging\nimport os\nimport shutil\nimport subprocess\nfrom dataclasses import dataclass\nfrom typing import AsyncIterator\n\nfrom maggy.adapters.cli_discovery import (\n    CliProfile,\n    DiscoveryResult,\n    discover_all,\n)\n\nlogger = logging.getLogger(__name__)\n\n\ndef _extract_usage(raw: str) -> tuple[float, int, int, str]:\n    \"\"\"Parse JSON CLI output for cost/tokens; fall back to raw text.\"\"\"\n    try:\n        d = json.loads(raw)\n        u = d.get(\"usage\") or {}\n        return (\n            float(d.get(\"cost_usd\") or 0),\n            int(u.get(\"input_tokens\") or 0),\n            int(u.get(\"output_tokens\") or 0),\n            str(d.get(\"result\", raw)),\n        )\n    except (json.JSONDecodeError, ValueError, TypeError):\n        return 0.0, 0, 0, raw\n\n\n@dataclass\nclass ModelEntry:\n    name: str\n    provider: str\n    model_id: str\n    tier: str\n    cost_per_1k: float = 0.0\n    daily_limit_usd: float = 50.0\n    cli_command: str = \"claude\"\n    context_window: int = 200_000\n\n\nDEFAULT_MODELS: list[ModelEntry] = [\n    ModelEntry(\"local\", \"ollama\", \"qwen3-coder:30b-a3b-q8_0\", \"local\", 0.0, 0.0, \"ollama\", 32_000),\n    ModelEntry(\"kimi\", \"moonshot\", \"kimi-k2\", \"cheap\", 0.001, 10.0, \"kimi\", 128_000),\n    ModelEntry(\"deepseek\", \"deepseek\", \"deepseek-v3\", \"cheap\", 0.002, 10.0, \"deepseek\", 128_000),\n    ModelEntry(\"gpt\", \"openai\", \"gpt-4o\", \"medium\", 0.01, 20.0, \"codex\", 128_000),\n    ModelEntry(\"claude\", \"anthropic\", \"claude-sonnet-4\", \"premium\", 0.03, 50.0, \"claude\", 200_000),\n    ModelEntry(\"codex\", \"openai\", \"codex\", \"validator\", 0.02, 30.0, \"codex\", 200_000),\n]\n\nQUOTA_MARKERS = frozenset(\n    {\"rate limit\", \"quota\", \"429\", \"too many requests\", \"capacity\", \"overloaded\"}\n)\n\n@dataclass\nclass RunResult:\n    model: str\n    success: bool\n    output: str = \"\"\n    error: str = \"\"\n    cost_usd: float = 0.0\n    input_tokens: int = 0\n    output_tokens: int = 0\n    turns: int = 0\n    quota_hit: bool = False\n\n\nclass PiAdapter:\n    def __init__(\n        self,\n        models: list[ModelEntry] | None = None,\n        rpc_command: str = \"pi\",\n        discovery: DiscoveryResult | None = None,\n    ):\n        entries = models or DEFAULT_MODELS\n        self._models = {entry.name: entry for entry in entries}\n        self._fallback_order = [\n            entry.name for entry in sorted(entries, key=lambda m: m.cost_per_1k)\n        ]\n        self._rpc_command = rpc_command\n        self._rpc_process: subprocess.Popen[str] | None = None\n        self._streaming = False\n        self._discovery = discovery or discover_all()\n        self._profiles: dict[str, CliProfile] = self._discovery.profiles\n        self._log_discovery()\n\n    def get_model(self, name: str) -> ModelEntry | None:\n        return self._models.get(name)\n\n    def list_models(self) -> list[ModelEntry]:\n        return list(self._models.values())\n\n    def fallback_chain(self, start: str) -> list[str]:\n        try:\n            idx = self._fallback_order.index(start)\n        except ValueError:\n            return self._fallback_order\n        return self._fallback_order[idx + 1 :]\n\n    async def send_prompt(\n        self,\n        model_name: str,\n        prompt: str,\n        working_dir: str,\n        max_turns: int = 20,\n        timeout: int = 600,\n    ) -> RunResult:\n        model = self._models.get(model_name)\n        if not model:\n            return RunResult(model=model_name, success=False, error=f\"Unknown model: {model_name}\")\n        try:\n            proc = await self._spawn_prompt(model, prompt, max_turns, working_dir)\n            stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout)\n            return self._prompt_result(model_name, proc.returncode or 0, stdout or b\"\")\n        except asyncio.TimeoutError:\n            return RunResult(model=model_name, success=False, error=\"Timed out\")\n        except FileNotFoundError:\n            return RunResult(\n                model=model_name, success=False, error=f\"CLI '{model.cli_command}' not found\"\n            )\n\n    async def send_with_fallback(\n        self,\n        model_name: str,\n        prompt: str,\n        working_dir: str,\n        max_turns: int = 20,\n    ) -> RunResult:\n        result = await self.send_prompt(model_name, prompt, working_dir, max_turns)\n        if result.success:\n            return result\n        for fallback in self.fallback_chain(model_name):\n            logger.info(\"Falling back from %s to %s\", model_name, fallback)\n            result = await self.send_prompt(fallback, prompt, working_dir, max_turns)\n            if result.success:\n                return result\n        return result\n\n    def send_rpc(self, command: dict[str, object]) -> dict[str, object]:\n        proc = self._ensure_rpc_process()\n        stdin = self._require_stream(proc.stdin, \"stdin\")\n        stdout = self._require_stream(proc.stdout, \"stdout\")\n        if self._streaming:\n            raise RuntimeError(\"Cannot send RPC while streaming\")\n        stdin.write(f\"{json.dumps(command, separators=(',', ':'))}\\n\")\n        stdin.flush()\n        line = stdout.readline()\n        return json.loads(line or \"{}\")\n\n    def switch_model(self, provider: str, model: str) -> bool:\n        payload = {\"command\": \"set_model\", \"provider\": provider, \"model\": model}\n        return bool(self.send_rpc(payload).get(\"ok\"))\n\n    async def stream_events(self) -> AsyncIterator[dict[str, object]]:\n        if self._streaming:\n            raise RuntimeError(\"Already streaming events\")\n        stdout = self._require_stream(self._ensure_rpc_process().stdout, \"stdout\")\n        self._streaming = True\n        try:\n            while True:\n                line = await asyncio.to_thread(stdout.readline)\n                if not line:\n                    break\n                yield json.loads(line)\n        finally:\n            self._streaming = False\n\n    def _build_command(\n        self, model: ModelEntry, prompt: str, max_turns: int, wd: str,\n    ) -> list[str]:\n        profile = self._profiles.get(model.cli_command)\n        if profile and profile.installed:\n            return profile.build_command(prompt, wd, max_turns)\n        return [model.cli_command, \"-p\", prompt]\n\n    def _detect_quota(self, text: str) -> bool:\n        return any(marker in text.lower() for marker in QUOTA_MARKERS)\n\n    def _detect_pi(self) -> bool:\n        return shutil.which(self._rpc_command) is not None\n\n    async def _spawn_prompt(\n        self,\n        model: ModelEntry,\n        prompt: str,\n        max_turns: int,\n        working_dir: str,\n    ) -> asyncio.subprocess.Process:\n        env = os.environ.copy()\n        env.pop(\"CLAUDECODE\", None)\n        return await asyncio.create_subprocess_exec(\n            *self._build_command(model, prompt, max_turns, working_dir),\n            stdout=asyncio.subprocess.PIPE,\n            stderr=asyncio.subprocess.STDOUT,\n            cwd=working_dir,\n            env=env,\n        )\n\n    def _log_discovery(self) -> None:\n        for name, p in self._profiles.items():\n            level = logging.INFO if p.installed else logging.DEBUG\n            logger.log(level, \"CLI %s: %s v%s\", \"OK\" if p.installed else \"missing\", name, p.version)\n\n    @property\n    def discovered_profiles(self) -> dict[str, CliProfile]:\n        return dict(self._profiles)\n\n    def _prompt_result(self, model_name: str, code: int, stdout: bytes) -> RunResult:\n        raw = stdout.decode(\"utf-8\", errors=\"replace\")\n        quota = self._detect_quota(raw)\n        cost, in_t, out_t, text = _extract_usage(raw)\n        return RunResult(\n            model=model_name, success=code == 0, output=text,\n            error=\"\" if code == 0 else f\"Exit code {code}\",\n            quota_hit=quota, cost_usd=cost,\n            input_tokens=in_t, output_tokens=out_t,\n        )\n\n    def _ensure_rpc_process(self) -> subprocess.Popen[str]:\n        proc = self._rpc_process\n        if proc and getattr(proc, \"poll\", lambda: None)() is None:\n            return proc\n        self._rpc_process = subprocess.Popen(\n            [self._rpc_command], stdin=subprocess.PIPE,\n            stdout=subprocess.PIPE, stderr=subprocess.STDOUT,\n            text=True, bufsize=1,\n        )\n        return self._rpc_process\n\n    def _require_stream(self, stream: object, name: str):\n        if stream is None:\n            raise RuntimeError(f\"Pi RPC {name} is unavailable\")\n        return stream\n"
  },
  {
    "path": "maggy/maggy/api/__init__.py",
    "content": ""
  },
  {
    "path": "maggy/maggy/api/auth.py",
    "content": "\"\"\"Shared authentication and configuration guards.\"\"\"\n\nfrom __future__ import annotations\n\nfrom fastapi import HTTPException, Request\n\n\ndef check_auth(\n    request: Request, x_api_key: str | None,\n) -> None:\n    \"\"\"Simple token check. Bypassed when auth_mode='local'.\"\"\"\n    cfg = request.app.state.cfg\n    if cfg.dashboard.auth_mode == \"local\":\n        return\n    expected = cfg.dashboard.api_key\n    if not expected or x_api_key != expected:\n        raise HTTPException(\n            status_code=401,\n            detail=\"Invalid or missing X-API-Key\",\n        )\n\n\ndef require_configured(request: Request) -> None:\n    \"\"\"Abort 503 if Maggy is not configured.\"\"\"\n    if not getattr(request.app.state, \"configured\", False):\n        raise HTTPException(\n            status_code=503,\n            detail=\"Maggy is not configured yet.\",\n        )\n\n\ndef require_provider(request: Request) -> None:\n    \"\"\"Abort 503 if no provider credentials (Tier 2).\"\"\"\n    mode = getattr(request.app.state, \"mode\", \"local\")\n    if mode != \"full\":\n        raise HTTPException(\n            status_code=503,\n            detail=\"Provider credentials required. \"\n            \"Set GITHUB_TOKEN or configure Asana.\",\n        )\n"
  },
  {
    "path": "maggy/maggy/api/routes.py",
    "content": "\"\"\"REST API routes — wraps services. All routes under /api/*.\"\"\"\n\nfrom __future__ import annotations\n\nimport logging\nfrom typing import Literal\n\nfrom fastapi import APIRouter, Header, HTTPException, Query, Request\nfrom pydantic import BaseModel\n\nlogger = logging.getLogger(__name__)\n\nrouter = APIRouter(prefix=\"/api\", tags=[\"maggy\"])\n\n\ndef _auth(request: Request, x_api_key: str | None) -> None:\n    \"\"\"Simple token check. Bypassed when auth_mode='local'.\"\"\"\n    cfg = request.app.state.cfg\n    if cfg.dashboard.auth_mode == \"local\":\n        return\n    expected = cfg.dashboard.api_key\n    if not expected or x_api_key != expected:\n        raise HTTPException(status_code=401, detail=\"Invalid or missing X-API-Key\")\n\n\ndef _require_configured(request: Request) -> None:\n    \"\"\"Abort 503 if no provider credentials (Tier 2).\"\"\"\n    mode = getattr(request.app.state, \"mode\", \"local\")\n    if mode != \"full\":\n        raise HTTPException(\n            status_code=503,\n            detail=\"Provider credentials required. \"\n            \"Set GITHUB_TOKEN or configure Asana.\",\n        )\n\n\n# ── Health + Config ──────────────────────────────────────────────────────\n\n@router.get(\"/health\")\nasync def health(request: Request) -> dict:\n    cfg = request.app.state.cfg\n    mode = getattr(request.app.state, \"mode\", \"local\")\n    return {\n        \"status\": \"ok\",\n        \"version\": \"0.1.0\",\n        \"mode\": mode,\n        \"provider\": cfg.issue_tracker.provider,\n        \"org\": cfg.org.name,\n        \"codebases\": len(cfg.codebases),\n        \"competitors_enabled\": bool(\n            cfg.competitors.categories,\n        ),\n    }\n\n\n@router.get(\"/activity\")\nasync def get_activity(request: Request) -> dict:\n    \"\"\"Live CLI sessions + recent prompts. No credentials needed.\"\"\"\n    return request.app.state.activity.get_activity()\n\n\n@router.get(\"/discovery\")\nasync def get_discovery(request: Request) -> dict:\n    \"\"\"Return auto-discovered environment info.\"\"\"\n    from maggy.discovery import full_discovery\n    result = full_discovery()\n    return {\n        \"clis\": result.clis,\n        \"repos\": result.repos,\n        \"active_projects\": result.active_projects,\n        \"tokens\": result.tokens,\n        \"github_org\": result.github_org,\n    }\n\n\n@router.get(\"/config\")\nasync def get_config(request: Request, x_api_key: str | None = Header(None)) -> dict:\n    _auth(request, x_api_key)\n    cfg = request.app.state.cfg\n    # Redact secrets before returning\n    return {\n        \"org\": {\"name\": cfg.org.name, \"domain\": cfg.org.domain},\n        \"issue_tracker\": {\"provider\": cfg.issue_tracker.provider},\n        \"codebases\": [{\"key\": c.key, \"path\": c.path} for c in cfg.codebases],\n        \"competitors\": {\"categories\": cfg.competitors.categories, \"seed\": cfg.competitors.seed},\n        \"okrs\": {\"source\": cfg.okrs.source, \"count\": len(cfg.okrs.items)},\n        \"ai\": {\"provider\": cfg.ai.provider, \"model\": cfg.ai.model, \"has_key\": bool(cfg.ai.api_key)},\n    }\n\n\n# ── Inbox ────────────────────────────────────────────────────────────────\n\n@router.get(\"/inbox\")\nasync def get_inbox(request: Request, refresh: bool = Query(False), x_api_key: str | None = Header(None)) -> dict:\n    _auth(request, x_api_key)\n    _require_configured(request)\n    items = await request.app.state.inbox.get_prioritized(force_refresh=refresh)\n    return {\"items\": items, \"total\": len(items)}\n\n\n@router.get(\"/followed\")\nasync def get_followed(request: Request, x_api_key: str | None = Header(None)) -> dict:\n    _auth(request, x_api_key)\n    _require_configured(request)\n    try:\n        tasks = await request.app.state.provider.list_followed(limit=50)\n    except Exception as e:\n        logger.warning(\"list_followed failed: %s\", e)\n        raise HTTPException(status_code=502, detail=\"Issue tracker unavailable\")\n    return {\n        \"items\": [\n            {\n                \"id\": t.id, \"title\": t.title, \"board\": t.board, \"url\": t.url,\n                \"assignee\": t.assignee, \"updated_at\": t.updated_at, \"labels\": t.labels,\n            }\n            for t in tasks\n        ],\n        \"total\": len(tasks),\n    }\n\n\n# ── Task detail + comments ───────────────────────────────────────────────\n\n@router.get(\"/task/{task_id:path}\")\nasync def get_task(request: Request, task_id: str, x_api_key: str | None = Header(None)) -> dict:\n    _auth(request, x_api_key)\n    _require_configured(request)\n    try:\n        task = await request.app.state.provider.get_task(task_id)\n    except Exception as e:\n        logger.warning(\"get_task(%s) failed: %s\", task_id, e)\n        raise HTTPException(status_code=502, detail=\"Issue tracker unavailable\")\n    if not task:\n        raise HTTPException(status_code=404, detail=\"Task not found\")\n    try:\n        comments = await request.app.state.provider.get_comments(task_id)\n    except Exception as e:\n        logger.warning(\"get_comments(%s) failed: %s\", task_id, e)\n        comments = []\n    return {\n        \"task\": {\n            \"id\": task.id, \"title\": task.title, \"description\": task.description,\n            \"status\": task.status, \"assignee\": task.assignee, \"url\": task.url,\n            \"labels\": task.labels, \"board\": task.board,\n            \"created_at\": task.created_at, \"updated_at\": task.updated_at,\n        },\n        \"comments\": [{\"id\": c.id, \"author\": c.author, \"text\": c.text, \"created_at\": c.created_at}\n                     for c in comments],\n    }\n\n\nclass CommentRequest(BaseModel):\n    text: str\n\n\n@router.post(\"/task/{task_id:path}/comment\")\nasync def post_comment(request: Request, task_id: str, body: CommentRequest, x_api_key: str | None = Header(None)) -> dict:\n    _auth(request, x_api_key)\n    _require_configured(request)\n    if not body.text.strip():\n        raise HTTPException(status_code=400, detail=\"Comment text is required\")\n    try:\n        comment = await request.app.state.provider.add_comment(task_id, body.text)\n    except Exception as e:\n        logger.warning(\"add_comment(%s) failed: %s\", task_id, e)\n        raise HTTPException(status_code=502, detail=\"Issue tracker unavailable\")\n    if not comment:\n        raise HTTPException(status_code=502, detail=\"Issue tracker rejected the comment\")\n    return {\"ok\": True, \"comment\": {\"id\": comment.id, \"text\": comment.text, \"created_at\": comment.created_at}}\n\n\nclass StatusRequest(BaseModel):\n    status: str\n\n\n@router.post(\"/task/{task_id:path}/status\")\nasync def update_status(request: Request, task_id: str, body: StatusRequest, x_api_key: str | None = Header(None)) -> dict:\n    _auth(request, x_api_key)\n    _require_configured(request)\n    try:\n        ok = await request.app.state.provider.update_status(task_id, body.status)\n    except Exception as e:\n        logger.warning(\"update_status(%s) failed: %s\", task_id, e)\n        raise HTTPException(status_code=502, detail=\"Issue tracker unavailable\")\n    return {\"ok\": ok}\n\n\n# ── Execute ──────────────────────────────────────────────────────────────\n\nclass ExecuteRequest(BaseModel):\n    task_id: str\n    mode: Literal[\"tdd\", \"plan\"] = \"tdd\"\n    working_dir: str | None = None  # override; otherwise auto-picked\n\n\n@router.post(\"/execute\")\nasync def execute(request: Request, body: ExecuteRequest, x_api_key: str | None = Header(None)) -> dict:\n    _auth(request, x_api_key)\n    _require_configured(request)\n    try:\n        session_id = await request.app.state.executor.start(\n            task_id=body.task_id, mode=body.mode, working_dir=body.working_dir,\n        )\n    except ValueError as e:\n        raise HTTPException(status_code=400, detail=str(e))\n    return {\"session_id\": session_id, \"status\": \"running\"}\n\n\n@router.get(\"/execute/sessions\")\nasync def list_sessions(request: Request, x_api_key: str | None = Header(None)) -> list[dict]:\n    _auth(request, x_api_key)\n    _require_configured(request)\n    return request.app.state.executor.list_sessions()\n\n\n@router.get(\"/execute/sessions/{session_id}\")\nasync def get_session(request: Request, session_id: str, x_api_key: str | None = Header(None)) -> dict:\n    _auth(request, x_api_key)\n    _require_configured(request)\n    s = request.app.state.executor.get_session(session_id)\n    if not s:\n        raise HTTPException(status_code=404, detail=\"Session not found\")\n    return s\n\n\n# ── Competitors ──────────────────────────────────────────────────────────\n\n@router.get(\"/competitors\")\nasync def list_competitors(request: Request, x_api_key: str | None = Header(None)) -> list[dict]:\n    _auth(request, x_api_key)\n    _require_configured(request)\n    return request.app.state.competitors.list_all()\n\n\n@router.post(\"/competitors/discover\")\nasync def discover_competitors(request: Request, x_api_key: str | None = Header(None)) -> dict:\n    _auth(request, x_api_key)\n    _require_configured(request)\n    return await request.app.state.competitors.discover()\n\n\n@router.post(\"/competitors/monitor\")\nasync def trigger_monitoring(request: Request, x_api_key: str | None = Header(None)) -> dict:\n    _auth(request, x_api_key)\n    _require_configured(request)\n    return await request.app.state.competitors.monitor_all()\n\n\n@router.get(\"/competitors/news\")\nasync def get_competitor_news(request: Request, limit: int = Query(100), x_api_key: str | None = Header(None)) -> list[dict]:\n    _auth(request, x_api_key)\n    _require_configured(request)\n    return request.app.state.competitors.get_news(limit=limit)\n\n\n@router.get(\"/competitors/news/summary\")\nasync def get_briefing(request: Request, refresh: bool = Query(False), x_api_key: str | None = Header(None)) -> dict:\n    _auth(request, x_api_key)\n    _require_configured(request)\n    return await request.app.state.competitors.get_daily_briefing(refresh=refresh)\n"
  },
  {
    "path": "maggy/maggy/api/routes_budget.py",
    "content": "\"\"\"Budget REST endpoints.\"\"\"\n\nfrom __future__ import annotations\n\nfrom fastapi import APIRouter, Header, Request\n\nfrom .auth import check_auth\n\nrouter = APIRouter(prefix=\"/api/budget\", tags=[\"budget\"])\n\n\n@router.get(\"\")\nasync def get_budget(\n    request: Request,\n    x_api_key: str | None = Header(None),\n) -> dict:\n    \"\"\"Return current budget status.\"\"\"\n    check_auth(request, x_api_key)\n    budget = request.app.state.budget\n    if not budget:\n        return {\"status\": \"unconfigured\"}\n    return budget.budget_status()\n\n\n@router.get(\"/by-provider\")\nasync def by_provider(\n    request: Request,\n    x_api_key: str | None = Header(None),\n) -> list[dict]:\n    \"\"\"Return spend breakdown by provider.\"\"\"\n    check_auth(request, x_api_key)\n    budget = request.app.state.budget\n    if not budget:\n        return []\n    return budget.by_provider()\n"
  },
  {
    "path": "maggy/maggy/api/routes_chat.py",
    "content": "\"\"\"Chat API routes — interactive Claude sessions via SSE.\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport logging\nfrom dataclasses import asdict\n\nfrom fastapi import APIRouter, Header, HTTPException, Request\nfrom fastapi.responses import StreamingResponse\nfrom pydantic import BaseModel\n\nfrom maggy.api.auth import check_auth\n\nlogger = logging.getLogger(__name__)\n\nrouter = APIRouter(prefix=\"/api/chat\", tags=[\"chat\"])\n\n\ndef _require_chat(request: Request):\n    chat = getattr(request.app.state, \"chat\", None)\n    if chat is None:\n        raise HTTPException(\n            status_code=503,\n            detail=\"Chat service not available.\",\n        )\n    return chat\n\n\nclass CreateSessionRequest(BaseModel):\n    project_key: str\n    project_path: str | None = None\n\n\nclass SendMessageRequest(BaseModel):\n    message: str\n\n\nclass RoutedMessageRequest(BaseModel):\n    message: str\n    blast_score: int | None = None\n    task_type: str | None = None\n    allowed_models: list[str] | None = None\n\n\n@router.post(\"/auto-connect\")\nasync def auto_connect(\n    request: Request,\n    x_api_key: str | None = Header(None),\n) -> dict:\n    \"\"\"Auto-connect to all active projects.\"\"\"\n    check_auth(request, x_api_key)\n    chat = _require_chat(request)\n    activity = getattr(request.app.state, \"activity\", None)\n    if not activity:\n        return {\"sessions\": []}\n    data = activity.get_activity()\n    active = data.get(\"sessions\", [])\n    recent = data.get(\"recent\", [])\n    sessions = chat.auto_connect(active)\n    history = getattr(request.app.state, \"history\", None)\n    result = []\n    for s in sessions:\n        ctx = _enrich_session(s, history, recent)\n        result.append(_session_summary(s, ctx))\n    return {\"sessions\": result}\n\n\ndef _enrich_session(s, history, recent: list[dict]) -> str:\n    \"\"\"Build context and resolve session ID.\"\"\"\n    from maggy.services.chat_context import (\n        build_project_context,\n        resolve_claude_session_id,\n    )\n    ctx = build_project_context(\n        history, s.working_dir, s.project_key, recent,\n    )\n    s.history_context = ctx\n    if not s.claude_session_id:\n        sid = resolve_claude_session_id(s.working_dir)\n        if sid:\n            s.claude_session_id = sid\n    return ctx\n\n\ndef _session_summary(s, context: str) -> dict:\n    \"\"\"Format session for API response.\"\"\"\n    return {\n        \"id\": s.id,\n        \"project_key\": s.project_key,\n        \"working_dir\": s.working_dir,\n        \"status\": s.status,\n        \"messages\": len(s.messages),\n        \"history_context\": context,\n        \"has_resume_id\": bool(s.claude_session_id),\n    }\n\n\n@router.post(\"/sessions\")\nasync def create_session(\n    request: Request,\n    body: CreateSessionRequest,\n    x_api_key: str | None = Header(None),\n) -> dict:\n    \"\"\"Create a new chat session.\"\"\"\n    check_auth(request, x_api_key)\n    chat = _require_chat(request)\n    try:\n        session = chat.create_session(\n            body.project_key, project_path=body.project_path,\n        )\n    except ValueError as e:\n        raise HTTPException(status_code=400, detail=str(e))\n    return {\n        \"id\": session.id,\n        \"project_key\": session.project_key,\n        \"working_dir\": session.working_dir,\n        \"status\": session.status,\n    }\n\n\n@router.get(\"/sessions\")\nasync def list_sessions(\n    request: Request,\n    x_api_key: str | None = Header(None),\n) -> list[dict]:\n    \"\"\"List all chat sessions.\"\"\"\n    check_auth(request, x_api_key)\n    chat = _require_chat(request)\n    return [\n        {\n            \"id\": s.id,\n            \"project_key\": s.project_key,\n            \"status\": s.status,\n            \"created_at\": s.created_at,\n            \"messages\": len(s.messages),\n        }\n        for s in chat.list_sessions()\n    ]\n\n\n@router.get(\"/sessions/{session_id}\")\nasync def get_session(\n    request: Request,\n    session_id: str,\n    x_api_key: str | None = Header(None),\n) -> dict:\n    \"\"\"Get session details + message history.\"\"\"\n    check_auth(request, x_api_key)\n    chat = _require_chat(request)\n    s = chat.get_session(session_id)\n    if not s:\n        raise HTTPException(status_code=404, detail=\"Session not found\")\n    return {\n        \"id\": s.id,\n        \"project_key\": s.project_key,\n        \"working_dir\": s.working_dir,\n        \"status\": s.status,\n        \"created_at\": s.created_at,\n        \"history_context\": s.history_context,\n        \"messages\": [asdict(m) for m in s.messages],\n    }\n\n\n@router.post(\"/sessions/{session_id}/send\")\nasync def send_message(\n    request: Request,\n    session_id: str,\n    body: SendMessageRequest,\n    x_api_key: str | None = Header(None),\n):\n    \"\"\"Send a message and stream response via SSE.\"\"\"\n    check_auth(request, x_api_key)\n    chat = _require_chat(request)\n    s = chat.get_session(session_id)\n    if not s:\n        raise HTTPException(status_code=404, detail=\"Session not found\")\n    if not body.message.strip():\n        raise HTTPException(status_code=400, detail=\"Message required\")\n    budget = getattr(request.app.state, \"budget\", None)\n\n    async def event_stream():\n        async for chunk in chat.send(session_id, body.message):\n            if budget and chunk.get(\"type\") == \"result\":\n                _record_chat_spend(budget, chunk)\n            data = json.dumps(chunk)\n            yield f\"data: {data}\\n\\n\"\n        yield \"data: {\\\"type\\\": \\\"done\\\"}\\n\\n\"\n\n    return StreamingResponse(\n        event_stream(),\n        media_type=\"text/event-stream\",\n    )\n\n\n@router.post(\"/sessions/{session_id}/send-routed\")\nasync def send_routed(\n    request: Request,\n    session_id: str,\n    body: RoutedMessageRequest,\n    x_api_key: str | None = Header(None),\n):\n    \"\"\"Send a message routed through blast-score engine.\"\"\"\n    check_auth(request, x_api_key)\n    chat = _require_chat(request)\n    s = chat.get_session(session_id)\n    if not s:\n        raise HTTPException(\n            status_code=404, detail=\"Session not found\",\n        )\n    if not body.message.strip():\n        raise HTTPException(\n            status_code=400, detail=\"Message required\",\n        )\n    routing = getattr(request.app.state, \"routing\", None)\n    budget = getattr(request.app.state, \"budget\", None)\n\n    async def event_stream():\n        from maggy.services.chat_router import RoutedChat\n        decision = None\n        if routing:\n            rc = RoutedChat(routing, budget)\n            decision = rc.decide(\n                body.message, body.blast_score, body.task_type,\n            )\n            allowed = body.allowed_models\n            if allowed and decision.model not in allowed:\n                decision.model = allowed[0]\n                decision.reason = f\"restricted to {','.join(allowed)}\"\n            meta = {\n                \"type\": \"routing\",\n                \"model\": decision.model,\n                \"blast\": decision.blast,\n                \"task_type\": decision.task_type,\n                \"reason\": decision.reason,\n            }\n            yield f\"data: {json.dumps(meta)}\\n\\n\"\n        had_error = False\n        async for chunk in chat.send(session_id, body.message):\n            if budget and chunk.get(\"type\") == \"result\":\n                _record_chat_spend(budget, chunk)\n            if chunk.get(\"type\") == \"error\":\n                had_error = True\n            yield f\"data: {json.dumps(chunk)}\\n\\n\"\n        _record_routing_outcome(\n            routing, decision, had_error=had_error,\n        )\n        yield 'data: {\"type\": \"done\"}\\n\\n'\n\n    return StreamingResponse(\n        event_stream(),\n        media_type=\"text/event-stream\",\n    )\n\n\ndef _record_chat_spend(budget, chunk: dict) -> None:\n    \"\"\"Record token/cost data from a result chunk.\"\"\"\n    cost = chunk.get(\"cost_usd\", 0)\n    in_t = chunk.get(\"input_tokens\", 0)\n    out_t = chunk.get(\"output_tokens\", 0)\n    if cost or in_t or out_t:\n        budget.record_spend(\"anthropic\", \"claude\", cost, in_t, out_t)\n\n\ndef _record_routing_outcome(routing, decision, *, had_error: bool) -> None:\n    \"\"\"Record routing reward after chat completes.\"\"\"\n    if not routing or not decision:\n        return\n    reward = 0.0 if had_error else 1.0\n    routing.record_outcome(\n        decision.model, decision.task_type,\n        decision.blast, reward,\n    )\n\n\n@router.delete(\"/sessions/{session_id}\")\nasync def delete_session(\n    request: Request,\n    session_id: str,\n    x_api_key: str | None = Header(None),\n) -> dict:\n    \"\"\"Delete a chat session.\"\"\"\n    check_auth(request, x_api_key)\n    chat = _require_chat(request)\n    ok = chat.delete_session(session_id)\n    if not ok:\n        raise HTTPException(status_code=404, detail=\"Session not found\")\n    return {\"ok\": True}\n"
  },
  {
    "path": "maggy/maggy/api/routes_cikg.py",
    "content": "\"\"\"CIKG REST endpoints.\"\"\"\n\nfrom __future__ import annotations\n\nfrom fastapi import APIRouter, Header, Request\n\nfrom .auth import check_auth\n\nrouter = APIRouter(prefix=\"/api/cikg\", tags=[\"cikg\"])\n\n\n@router.get(\"/landscape\")\nasync def landscape(\n    request: Request,\n    x_api_key: str | None = Header(None),\n) -> dict:\n    \"\"\"Return competitive landscape summary.\"\"\"\n    check_auth(request, x_api_key)\n    graph = request.app.state.cikg\n    if not graph:\n        return {\"error\": \"cikg not configured\"}\n    from maggy.cikg.queries import get_landscape\n    return get_landscape(graph)\n\n\n@router.get(\"/gaps/{feature}\")\nasync def feature_gaps(\n    request: Request,\n    feature: str,\n    x_api_key: str | None = Header(None),\n) -> dict:\n    \"\"\"Score a feature against competitive landscape.\"\"\"\n    check_auth(request, x_api_key)\n    graph = request.app.state.cikg\n    if not graph:\n        return {\"error\": \"cikg not configured\"}\n    from maggy.cikg.queries import find_gaps\n    from dataclasses import asdict\n    return asdict(find_gaps(graph, feature))\n"
  },
  {
    "path": "maggy/maggy/api/routes_deploy.py",
    "content": "\"\"\"Deploy REST endpoints.\"\"\"\n\nfrom __future__ import annotations\n\nfrom dataclasses import asdict\n\nfrom fastapi import APIRouter, Header, Request\nfrom pydantic import BaseModel, Field\n\nfrom .auth import check_auth\n\nrouter = APIRouter(prefix=\"/api/deploy\", tags=[\"deploy\"])\n\n\nclass CreateSessionRequest(BaseModel):\n    project: str = Field(..., min_length=1, max_length=200)\n    branch: str = Field(default=\"main\", max_length=200)\n\n\n@router.get(\"/sessions\")\nasync def list_sessions(\n    request: Request,\n    x_api_key: str | None = Header(None),\n) -> dict:\n    \"\"\"List all deploy sessions.\"\"\"\n    check_auth(request, x_api_key)\n    svc = request.app.state.deploy\n    if not svc:\n        return {\"error\": \"deploy not configured\"}\n    return {\n        \"sessions\": [asdict(s) for s in svc.list_sessions()],\n    }\n\n\n@router.get(\"/sessions/{sid}\")\nasync def get_session(\n    request: Request,\n    sid: str,\n    x_api_key: str | None = Header(None),\n) -> dict:\n    \"\"\"Get a specific deploy session.\"\"\"\n    check_auth(request, x_api_key)\n    svc = request.app.state.deploy\n    if not svc:\n        return {\"error\": \"deploy not configured\"}\n    session = svc.get_session(sid)\n    if not session:\n        return {\"error\": \"session not found\"}\n    return asdict(session)\n\n\n@router.post(\"/sessions\")\nasync def create_session(\n    request: Request,\n    body: CreateSessionRequest,\n    x_api_key: str | None = Header(None),\n) -> dict:\n    \"\"\"Create a new deploy session.\"\"\"\n    check_auth(request, x_api_key)\n    svc = request.app.state.deploy\n    if not svc:\n        return {\"error\": \"deploy not configured\"}\n    session = svc.create_session(\n        project=body.project,\n        branch=body.branch,\n    )\n    return asdict(session)\n"
  },
  {
    "path": "maggy/maggy/api/routes_engram.py",
    "content": "\"\"\"Engram REST endpoints.\"\"\"\n\nfrom __future__ import annotations\n\nfrom dataclasses import asdict\n\nfrom fastapi import APIRouter, Header, Request\n\nfrom .auth import check_auth\n\nrouter = APIRouter(prefix=\"/api/engram\", tags=[\"engram\"])\n\n\n@router.get(\"/query\")\nasync def query_engrams(\n    request: Request,\n    namespace: str | None = None,\n    memory_type: str | None = None,\n    limit: int = 50,\n    x_api_key: str | None = Header(None),\n) -> dict:\n    \"\"\"Query engram records.\"\"\"\n    check_auth(request, x_api_key)\n    engram = request.app.state.engram\n    if not engram:\n        return {\"error\": \"engram not configured\"}\n    records = engram.query(\n        namespace=namespace,\n        memory_type=memory_type,\n        limit=limit,\n    )\n    return {\"records\": [asdict(r) for r in records]}\n\n\n@router.get(\"/diagnostics\")\nasync def diagnostics(\n    request: Request,\n    namespace: str | None = None,\n    x_api_key: str | None = Header(None),\n) -> dict:\n    \"\"\"Run memory diagnostics.\"\"\"\n    check_auth(request, x_api_key)\n    store = request.app.state.engram\n    if not store:\n        return {\"error\": \"engram not configured\"}\n    from maggy.engram.diagnostics import diagnose\n    profile = diagnose(store, namespace)\n    return asdict(profile)\n"
  },
  {
    "path": "maggy/maggy/api/routes_escalation.py",
    "content": "\"\"\"Escalation REST endpoints.\"\"\"\n\nfrom __future__ import annotations\n\nfrom fastapi import APIRouter, Header, HTTPException, Request\nfrom pydantic import BaseModel\n\nfrom .auth import check_auth\n\nrouter = APIRouter(prefix=\"/api/escalations\", tags=[\"escalations\"])\n\n\nclass _EscalationIn(BaseModel):\n    session_id: str\n    reason: str\n    context: dict = {}\n\n\nclass _ResolveIn(BaseModel):\n    guidance: str\n\n\n@router.get(\"\")\nasync def list_pending(\n    request: Request,\n    x_api_key: str | None = Header(None),\n) -> list[dict]:\n    \"\"\"List pending escalations.\"\"\"\n    check_auth(request, x_api_key)\n    esc = request.app.state.escalator\n    if not esc:\n        return []\n    return [\n        {\n            \"id\": p.id, \"session_id\": p.session_id,\n            \"reason\": p.reason, \"created_at\": p.created_at,\n        }\n        for p in esc.list_pending()\n    ]\n\n\n@router.post(\"\", status_code=201)\nasync def create_escalation(\n    body: _EscalationIn,\n    request: Request,\n    x_api_key: str | None = Header(None),\n) -> dict:\n    \"\"\"Create a new escalation.\"\"\"\n    check_auth(request, x_api_key)\n    esc = request.app.state.escalator\n    if not esc:\n        raise HTTPException(503, \"Not configured\")\n    packet = esc.escalate(\n        body.session_id, body.reason, body.context,\n    )\n    return {\"id\": packet.id, \"status\": \"pending\"}\n\n\n@router.post(\"/{escalation_id}/resolve\")\nasync def resolve_escalation(\n    escalation_id: str,\n    body: _ResolveIn,\n    request: Request,\n    x_api_key: str | None = Header(None),\n) -> dict:\n    \"\"\"Resolve an escalation with guidance.\"\"\"\n    check_auth(request, x_api_key)\n    esc = request.app.state.escalator\n    if not esc:\n        raise HTTPException(503, \"Not configured\")\n    try:\n        packet = esc.resolve(escalation_id, body.guidance)\n    except KeyError:\n        raise HTTPException(404, \"Not found\")\n    return {\"id\": packet.id, \"status\": \"resolved\"}\n"
  },
  {
    "path": "maggy/maggy/api/routes_events.py",
    "content": "\"\"\"Event Spine REST endpoints.\"\"\"\n\nfrom __future__ import annotations\n\nfrom fastapi import APIRouter, Header, Request\n\nfrom .auth import check_auth\n\nrouter = APIRouter(prefix=\"/api/events\", tags=[\"events\"])\n\n\n@router.get(\"\")\nasync def query_events(\n    request: Request,\n    task_id: str | None = None,\n    event_type: str | None = None,\n    project_id: str | None = None,\n    limit: int = 100,\n    x_api_key: str | None = Header(None),\n) -> list[dict]:\n    \"\"\"Query events with optional filters.\"\"\"\n    check_auth(request, x_api_key)\n    emitter = request.app.state.events\n    if not emitter:\n        return []\n    return emitter.query(task_id, event_type, project_id, limit)\n\n\n@router.get(\"/trace/{task_id}\")\nasync def trace_task(\n    request: Request,\n    task_id: str,\n    x_api_key: str | None = Header(None),\n) -> list[dict]:\n    \"\"\"Get full event chain for a task.\"\"\"\n    check_auth(request, x_api_key)\n    emitter = request.app.state.events\n    if not emitter:\n        return []\n    return emitter.trace(task_id)\n\n\n@router.get(\"/count\")\nasync def count_events(\n    request: Request,\n    event_type: str | None = None,\n    project_id: str | None = None,\n    x_api_key: str | None = Header(None),\n) -> dict:\n    \"\"\"Count events matching filters.\"\"\"\n    check_auth(request, x_api_key)\n    emitter = request.app.state.events\n    if not emitter:\n        return {\"count\": 0}\n    return {\"count\": emitter.count(event_type, project_id)}\n"
  },
  {
    "path": "maggy/maggy/api/routes_forge.py",
    "content": "\"\"\"Forge REST endpoints.\"\"\"\n\nfrom __future__ import annotations\n\nfrom dataclasses import asdict\n\nfrom fastapi import APIRouter, Header, Request\nfrom pydantic import BaseModel, Field\n\nfrom .auth import check_auth\n\nrouter = APIRouter(prefix=\"/api/forge\", tags=[\"forge\"])\n\n\nclass GapReport(BaseModel):\n    capability: str = Field(..., min_length=1, max_length=200)\n\n\n@router.get(\"/status\")\nasync def forge_status(\n    request: Request,\n    x_api_key: str | None = Header(None),\n) -> dict:\n    \"\"\"Return Forge connector status.\"\"\"\n    check_auth(request, x_api_key)\n    forge = request.app.state.forge\n    if not forge:\n        return {\"error\": \"forge not configured\"}\n    return asdict(forge.status())\n\n\n@router.get(\"/search\")\nasync def search_tools(\n    request: Request,\n    q: str = \"\",\n    x_api_key: str | None = Header(None),\n) -> dict:\n    \"\"\"Search the Forge tool registry.\"\"\"\n    check_auth(request, x_api_key)\n    forge = request.app.state.forge\n    if not forge:\n        return {\"error\": \"forge not configured\"}\n    return {\"results\": forge.search_tools(q)}\n\n\n@router.get(\"/gaps\")\nasync def list_gaps(\n    request: Request,\n    x_api_key: str | None = Header(None),\n) -> dict:\n    \"\"\"List detected capability gaps.\"\"\"\n    check_auth(request, x_api_key)\n    forge = request.app.state.forge\n    if not forge:\n        return {\"error\": \"forge not configured\"}\n    return {\"gaps\": forge.get_gaps()}\n\n\n@router.post(\"/gaps\")\nasync def report_gap(\n    request: Request,\n    body: GapReport,\n    x_api_key: str | None = Header(None),\n) -> dict:\n    \"\"\"Report a capability gap.\"\"\"\n    check_auth(request, x_api_key)\n    forge = request.app.state.forge\n    if not forge:\n        return {\"error\": \"forge not configured\"}\n    return forge.report_gap(body.capability)\n"
  },
  {
    "path": "maggy/maggy/api/routes_heartbeat.py",
    "content": "\"\"\"Heartbeat API routes — scheduler status and manual triggers.\"\"\"\n\nfrom __future__ import annotations\n\nfrom fastapi import APIRouter, Header, HTTPException, Request\n\nfrom maggy.api.auth import check_auth\n\nrouter = APIRouter(prefix=\"/api\", tags=[\"heartbeat\"])\n\n\n@router.get(\"/heartbeat/status\")\nasync def heartbeat_status(\n    request: Request,\n    x_api_key: str | None = Header(None),\n) -> list[dict]:\n    check_auth(request, x_api_key)\n    scheduler = getattr(request.app.state, \"heartbeat\", None)\n    if not scheduler:\n        return []\n    return scheduler.status()\n\n\n@router.post(\"/heartbeat/trigger/{job_name}\")\nasync def trigger_job(\n    request: Request,\n    job_name: str,\n    x_api_key: str | None = Header(None),\n) -> dict:\n    check_auth(request, x_api_key)\n    scheduler = getattr(request.app.state, \"heartbeat\", None)\n    if not scheduler:\n        raise HTTPException(status_code=503, detail=\"Heartbeat not running\")\n    try:\n        return await scheduler.trigger(job_name)\n    except KeyError:\n        raise HTTPException(status_code=404, detail=f\"Job '{job_name}' not found\")\n"
  },
  {
    "path": "maggy/maggy/api/routes_history.py",
    "content": "\"\"\"API routes for session history analysis.\"\"\"\n\nfrom __future__ import annotations\n\nfrom fastapi import APIRouter, Header, HTTPException, Request\n\nfrom maggy.api.auth import check_auth\n\nrouter = APIRouter(\n    prefix=\"/api/history\", tags=[\"history\"],\n)\n\n\ndef _require_history(request: Request):\n    svc = getattr(request.app.state, \"history\", None)\n    if svc is None:\n        raise HTTPException(\n            status_code=503,\n            detail=\"History service not available.\",\n        )\n    return svc\n\n\n@router.post(\"/analyze\")\nasync def analyze_history(\n    request: Request,\n    x_api_key: str | None = Header(None),\n):\n    \"\"\"Trigger full history analysis pipeline.\"\"\"\n    check_auth(request, x_api_key)\n    svc = _require_history(request)\n    report = svc.analyze()\n    return {\n        \"status\": \"ok\",\n        \"total_sessions\": report.total_sessions,\n        \"total_prompts\": report.total_prompts,\n        \"providers\": len(report.providers),\n        \"patterns\": report.patterns,\n        \"summary\": report.summary,\n    }\n\n\n@router.get(\"/report\")\nasync def get_report(\n    request: Request,\n    x_api_key: str | None = Header(None),\n):\n    \"\"\"Get latest cached history report.\"\"\"\n    check_auth(request, x_api_key)\n    svc = _require_history(request)\n    report = svc.get_report()\n    if not report:\n        return {\"status\": \"no_data\"}\n    return report\n\n\n@router.get(\"/sessions\")\nasync def get_sessions(\n    request: Request,\n    provider: str | None = None,\n    x_api_key: str | None = Header(None),\n):\n    \"\"\"Get parsed session records.\"\"\"\n    check_auth(request, x_api_key)\n    svc = _require_history(request)\n    sessions = svc.get_sessions(provider=provider)\n    return {\"sessions\": sessions, \"total\": len(sessions)}\n\n\n@router.get(\"/providers\")\nasync def list_providers(\n    request: Request,\n    x_api_key: str | None = Header(None),\n):\n    \"\"\"List which CLI tools are available.\"\"\"\n    check_auth(request, x_api_key)\n    svc = _require_history(request)\n    return {\"providers\": svc.available_providers()}\n"
  },
  {
    "path": "maggy/maggy/api/routes_improve.py",
    "content": "\"\"\"Self-improvement API routes — reports and manual analysis.\"\"\"\n\nfrom __future__ import annotations\n\nfrom dataclasses import asdict\n\nfrom fastapi import APIRouter, Header, HTTPException, Request\n\nfrom maggy.api.auth import check_auth\n\nrouter = APIRouter(prefix=\"/api\", tags=[\"improve\"])\n\n\n@router.get(\"/improve/report\")\nasync def get_report(\n    request: Request,\n    x_api_key: str | None = Header(None),\n) -> dict:\n    check_auth(request, x_api_key)\n    introspector = getattr(request.app.state, \"introspector\", None)\n    if not introspector:\n        raise HTTPException(status_code=503, detail=\"Not configured\")\n    report = introspector.get_report()\n    if not report:\n        return {\"report\": None}\n    return {\"report\": asdict(report)}\n\n\n@router.post(\"/improve/analyze\")\nasync def run_analysis(\n    request: Request,\n    x_api_key: str | None = Header(None),\n) -> dict:\n    check_auth(request, x_api_key)\n    introspector = getattr(request.app.state, \"introspector\", None)\n    if not introspector:\n        raise HTTPException(status_code=503, detail=\"Not configured\")\n    report = introspector.analyze()\n    return {\"report\": asdict(report)}\n"
  },
  {
    "path": "maggy/maggy/api/routes_lexon.py",
    "content": "\"\"\"Lexon REST endpoints.\"\"\"\n\nfrom __future__ import annotations\n\nfrom dataclasses import asdict\n\nfrom fastapi import APIRouter, Header, Request\nfrom pydantic import BaseModel, Field\n\nfrom .auth import check_auth\n\nrouter = APIRouter(prefix=\"/api/lexon\", tags=[\"lexon\"])\n\n\nclass LearnRequest(BaseModel):\n    phrase: str = Field(..., min_length=1, max_length=500)\n    tool: str = Field(..., min_length=1, max_length=100)\n\n\n@router.get(\"/parse\")\nasync def parse_intent(\n    request: Request,\n    q: str = \"\",\n    x_api_key: str | None = Header(None),\n) -> dict:\n    \"\"\"Parse a phrase into a tool intent.\"\"\"\n    check_auth(request, x_api_key)\n    lexon = request.app.state.lexon\n    if not lexon:\n        return {\"error\": \"lexon not configured\"}\n    record = lexon.route(q)\n    return asdict(record)\n\n\n@router.post(\"/learn\")\nasync def learn_mapping(\n    request: Request,\n    body: LearnRequest,\n    x_api_key: str | None = Header(None),\n) -> dict:\n    \"\"\"Record a confirmed phrase-to-tool mapping.\"\"\"\n    check_auth(request, x_api_key)\n    lexon = request.app.state.lexon\n    if not lexon:\n        return {\"error\": \"lexon not configured\"}\n    lexon.learn(body.phrase, body.tool)\n    return {\"status\": \"learned\"}\n"
  },
  {
    "path": "maggy/maggy/api/routes_mesh.py",
    "content": "\"\"\"Mesh P2P REST endpoints — data operations.\"\"\"\n\nfrom __future__ import annotations\n\nfrom dataclasses import asdict\n\nfrom fastapi import APIRouter, Header, Request\nfrom fastapi.responses import JSONResponse\nfrom pydantic import BaseModel, Field\n\nfrom .auth import check_auth\n\nrouter = APIRouter(prefix=\"/api/mesh\", tags=[\"mesh\"])\n\n\nclass AddPeerRequest(BaseModel):\n    org: str\n    peer_id: str\n    name: str = \"\"\n    address: str = \"\"\n    port: int = Field(default=8080, ge=1, le=65535)\n\n\nclass PromoteRequest(BaseModel):\n    org: str\n    key: str\n\n\n@router.get(\"/status\")\nasync def mesh_status(\n    request: Request,\n    x_api_key: str | None = Header(None),\n) -> dict:\n    \"\"\"Return mesh status across all networks.\"\"\"\n    check_auth(request, x_api_key)\n    mesh = request.app.state.mesh\n    if not mesh:\n        return {\"enabled\": False, \"peers\": 0}\n    return {\n        \"enabled\": True,\n        \"peers\": mesh.total_peers,\n        \"networks\": mesh.list_networks(),\n    }\n\n\n@router.get(\"/networks\")\nasync def list_networks(\n    request: Request,\n    x_api_key: str | None = Header(None),\n) -> dict:\n    \"\"\"List all org-scoped mesh networks.\"\"\"\n    check_auth(request, x_api_key)\n    mesh = request.app.state.mesh\n    if not mesh:\n        return {\"networks\": []}\n    return {\"networks\": mesh.list_networks()}\n\n\n@router.get(\"/peers\")\nasync def list_peers(\n    request: Request,\n    org: str = \"\",\n    x_api_key: str | None = Header(None),\n) -> dict:\n    \"\"\"List peers, optionally filtered by org.\"\"\"\n    check_auth(request, x_api_key)\n    mesh = request.app.state.mesh\n    if not mesh:\n        return JSONResponse(\n            {\"error\": \"mesh not enabled\"}, status_code=503,\n        )\n    if org:\n        net = mesh.get_network(org)\n        if not net:\n            return JSONResponse(\n                {\"error\": f\"unknown org: {org}\"},\n                status_code=404,\n            )\n        return {\n            \"peers\": [asdict(p) for p in net.peers.list_peers()],\n        }\n    peers = []\n    for status in mesh.list_networks():\n        net = mesh.get_network(status[\"org\"])\n        if net:\n            peers.extend(\n                asdict(p) for p in net.peers.list_peers()\n            )\n    return {\"peers\": peers}\n\n\n@router.post(\"/peers\")\nasync def add_peer(\n    request: Request,\n    body: AddPeerRequest,\n    x_api_key: str | None = Header(None),\n) -> dict:\n    \"\"\"Manually add a peer to a network.\"\"\"\n    check_auth(request, x_api_key)\n    mesh = request.app.state.mesh\n    if not mesh:\n        return JSONResponse(\n            {\"error\": \"mesh not enabled\"}, status_code=503,\n        )\n    net = mesh.get_network(body.org)\n    if not net:\n        return JSONResponse(\n            {\"error\": f\"unknown org: {body.org}\"},\n            status_code=404,\n        )\n    from maggy.mesh.discovery import PeerInfo\n    net.peers.register(PeerInfo(\n        peer_id=body.peer_id,\n        name=body.name,\n        address=body.address,\n        port=body.port,\n        org=body.org,\n        manual=True,\n    ))\n    return {\"status\": \"added\", \"peer_id\": body.peer_id}\n\n\n@router.get(\"/quarantine\")\nasync def quarantine_list(\n    request: Request,\n    org: str = \"\",\n    x_api_key: str | None = Header(None),\n) -> dict:\n    \"\"\"List quarantined items for an org.\"\"\"\n    check_auth(request, x_api_key)\n    mesh = request.app.state.mesh\n    if not mesh:\n        return JSONResponse(\n            {\"error\": \"mesh not enabled\"}, status_code=503,\n        )\n    if not org:\n        return JSONResponse(\n            {\"error\": \"org parameter required\"},\n            status_code=422,\n        )\n    net = mesh.get_network(org)\n    if not net:\n        return JSONResponse(\n            {\"error\": f\"unknown org: {org}\"},\n            status_code=404,\n        )\n    items = [asdict(e) for e in net.quarantine.list_all()]\n    return {\"items\": items}\n\n\n@router.post(\"/promote\")\nasync def promote(\n    request: Request,\n    body: PromoteRequest,\n    x_api_key: str | None = Header(None),\n) -> dict:\n    \"\"\"Promote a quarantined item into shared memories.\"\"\"\n    check_auth(request, x_api_key)\n    mesh = request.app.state.mesh\n    if not mesh:\n        return JSONResponse(\n            {\"error\": \"mesh not enabled\"}, status_code=503,\n        )\n    net = mesh.get_network(body.org)\n    if not net:\n        return JSONResponse(\n            {\"error\": f\"unknown org: {body.org}\"},\n            status_code=404,\n        )\n    ok = net.sync.promote_from_quarantine(body.key)\n    return {\"promoted\": ok}\n"
  },
  {
    "path": "maggy/maggy/api/routes_mesh_admin.py",
    "content": "\"\"\"Mesh P2P REST endpoints — admin operations.\"\"\"\n\nfrom __future__ import annotations\n\nfrom fastapi import APIRouter, Header, Request\nfrom fastapi.responses import JSONResponse\n\nfrom .auth import check_auth\n\nrouter = APIRouter(prefix=\"/api/mesh\", tags=[\"mesh\"])\n\n\n@router.post(\"/announce\")\nasync def announce(\n    request: Request,\n    x_api_key: str | None = Header(None),\n) -> dict:\n    \"\"\"Announce self to all org mesh repos via git.\"\"\"\n    check_auth(request, x_api_key)\n    mesh = request.app.state.mesh\n    if not mesh:\n        return JSONResponse(\n            {\"error\": \"mesh not enabled\"}, status_code=503,\n        )\n    cfg = request.app.state.cfg\n    token = cfg.issue_tracker.github.token\n    if not token:\n        return JSONResponse(\n            {\"error\": \"no github token\"}, status_code=422,\n        )\n    result = await mesh.announce_all(token)\n    return {\"announced\": result}\n\n\n@router.post(\"/discover\")\nasync def discover(\n    request: Request,\n    x_api_key: str | None = Header(None),\n) -> dict:\n    \"\"\"Trigger git-based peer discovery for all orgs.\"\"\"\n    check_auth(request, x_api_key)\n    mesh = request.app.state.mesh\n    if not mesh:\n        return JSONResponse(\n            {\"error\": \"mesh not enabled\"}, status_code=503,\n        )\n    cfg = request.app.state.cfg\n    token = cfg.issue_tracker.github.token\n    if not token:\n        return JSONResponse(\n            {\"error\": \"no github token\"}, status_code=422,\n        )\n    result = await mesh.discover(token)\n    return {\"discovered\": result}\n\n\n@router.post(\"/setup\")\nasync def setup(\n    request: Request,\n    x_api_key: str | None = Header(None),\n) -> dict:\n    \"\"\"Onboarding: create mesh repos for all orgs.\"\"\"\n    check_auth(request, x_api_key)\n    mesh = request.app.state.mesh\n    if not mesh:\n        return JSONResponse(\n            {\"error\": \"mesh not enabled\"}, status_code=503,\n        )\n    cfg = request.app.state.cfg\n    token = cfg.issue_tracker.github.token\n    if not token:\n        return JSONResponse(\n            {\"error\": \"no github token\"}, status_code=422,\n        )\n    result = await mesh.setup_repos(token)\n    return {\"repos_created\": result}\n"
  },
  {
    "path": "maggy/maggy/api/routes_monitor.py",
    "content": "\"\"\"API routes for monitor service — tracker polling.\"\"\"\n\nfrom __future__ import annotations\n\nfrom fastapi import APIRouter, Request\n\nrouter = APIRouter(prefix=\"/api/monitor\", tags=[\"monitor\"])\n\n\n@router.get(\"/status\")\nasync def monitor_status(request: Request) -> dict:\n    \"\"\"Get active monitor status.\"\"\"\n    svc = getattr(request.app.state, \"monitor\", None)\n    if not svc:\n        return {\"active\": 0, \"monitors\": []}\n    return svc.status()\n\n\n@router.post(\"/start\")\nasync def monitor_start(request: Request) -> dict:\n    \"\"\"Start monitoring current project's tracker.\"\"\"\n    svc = getattr(request.app.state, \"monitor\", None)\n    if not svc:\n        return {\"ok\": False, \"error\": \"monitor not configured\"}\n    return {\"ok\": True, \"active\": len(svc.list_active())}\n\n\n@router.post(\"/stop\")\nasync def monitor_stop(request: Request) -> dict:\n    \"\"\"Stop all monitors.\"\"\"\n    svc = getattr(request.app.state, \"monitor\", None)\n    if not svc:\n        return {\"ok\": False}\n    for cfg in svc.list_active():\n        svc.remove(cfg.project_key)\n    return {\"ok\": True}\n"
  },
  {
    "path": "maggy/maggy/api/routes_observability.py",
    "content": "\"\"\"Observability signal REST endpoints.\"\"\"\n\nfrom __future__ import annotations\n\nfrom fastapi import APIRouter, Header, HTTPException, Request\nfrom pydantic import BaseModel\n\nfrom .auth import check_auth\n\nrouter = APIRouter(\n    prefix=\"/api/observability\", tags=[\"observability\"],\n)\n\n\nclass _SignalIn(BaseModel):\n    project: str\n    signal_type: str\n    value: float\n\n\n@router.get(\"/signals/{project}\")\nasync def get_signals(\n    project: str,\n    request: Request,\n    x_api_key: str | None = Header(None),\n    limit: int = 20,\n) -> list[dict]:\n    \"\"\"Get recent signals for a project.\"\"\"\n    check_auth(request, x_api_key)\n    obs = request.app.state.observability\n    if not obs:\n        return []\n    return obs.recent_signals(project, min(limit, 100))\n\n\n@router.post(\"/record\", status_code=201)\nasync def record_signal(\n    body: _SignalIn,\n    request: Request,\n    x_api_key: str | None = Header(None),\n) -> dict:\n    \"\"\"Record an observability signal.\"\"\"\n    check_auth(request, x_api_key)\n    obs = request.app.state.observability\n    if not obs:\n        raise HTTPException(503, \"Not configured\")\n    obs.record_signal(body.project, body.signal_type, body.value)\n    return {\"status\": \"recorded\"}\n"
  },
  {
    "path": "maggy/maggy/api/routes_planning.py",
    "content": "\"\"\"Planning REST endpoints.\"\"\"\n\nfrom __future__ import annotations\n\nfrom dataclasses import asdict\n\nfrom fastapi import APIRouter, Header, Request\nfrom pydantic import BaseModel, Field\n\nfrom .auth import check_auth\n\nrouter = APIRouter(prefix=\"/api/planning\", tags=[\"planning\"])\n\n\nclass PlanGenerateRequest(BaseModel):\n    task: str = Field(..., min_length=1, max_length=2000)\n    blast_score: int = Field(default=0, ge=0, le=10)\n    files: list[str] | None = None\n\n\n@router.post(\"/generate\")\nasync def generate_plan(\n    request: Request,\n    body: PlanGenerateRequest,\n    x_api_key: str | None = Header(None),\n) -> dict:\n    \"\"\"Generate a plan for a task.\"\"\"\n    check_auth(request, x_api_key)\n    svc = request.app.state.planning\n    if not svc:\n        return {\"error\": \"planning not configured\"}\n    from maggy.planning import PlanRequest\n    req = PlanRequest(\n        task=body.task,\n        blast_score=body.blast_score,\n        file_context=body.files,\n    )\n    result = svc.plan_task(req)\n    plan = result[\"plan\"]\n    response = {\n        \"mode\": result[\"mode\"],\n        \"plan\": asdict(plan),\n    }\n    if result.get(\"diff\"):\n        response[\"diff\"] = asdict(result[\"diff\"])\n    return response\n"
  },
  {
    "path": "maggy/maggy/api/routes_process.py",
    "content": "\"\"\"Process Intelligence REST routes — /api/process/*.\"\"\"\n\nfrom __future__ import annotations\n\nimport logging\n\nfrom fastapi import APIRouter, Header, HTTPException, Request\nfrom pydantic import BaseModel\n\nlogger = logging.getLogger(__name__)\n\nrouter = APIRouter(prefix=\"/api/process\", tags=[\"process\"])\n\n\ndef _auth(request: Request, x_api_key: str | None) -> None:\n    cfg = request.app.state.cfg\n    if cfg.dashboard.auth_mode == \"local\":\n        return\n    expected = cfg.dashboard.api_key\n    if not expected or x_api_key != expected:\n        raise HTTPException(401, \"Invalid or missing X-API-Key\")\n\n\ndef _require_process(request: Request) -> None:\n    if not getattr(request.app.state, \"process\", None):\n        raise HTTPException(503, \"Process Intelligence not configured\")\n\n\nclass AnalyzeRequest(BaseModel):\n    project_key: str\n\n\n@router.post(\"/analyze\")\nasync def analyze(\n    request: Request,\n    body: AnalyzeRequest,\n    x_api_key: str | None = Header(None),\n) -> dict:\n    \"\"\"Trigger full PR analysis (background).\"\"\"\n    _auth(request, x_api_key)\n    _require_process(request)\n    svc = request.app.state.process\n\n    try:\n        report = await svc.analyze(body.project_key)\n    except ValueError as e:\n        raise HTTPException(400, str(e))\n    except Exception as e:\n        logger.exception(\"Analysis failed for %s\", body.project_key)\n        raise HTTPException(502, f\"Analysis failed: {e}\")\n\n    return {\n        \"status\": \"completed\",\n        \"project_key\": body.project_key,\n        \"total_prs\": report.total_prs,\n        \"summary\": report.summary,\n    }\n\n\n@router.get(\"/report/{project_key}\")\nasync def get_report(\n    request: Request,\n    project_key: str,\n    x_api_key: str | None = Header(None),\n) -> dict:\n    \"\"\"Get latest process report.\"\"\"\n    _auth(request, x_api_key)\n    _require_process(request)\n    report = request.app.state.process.get_report(project_key)\n    if not report:\n        raise HTTPException(404, \"No report found. Run /api/process/analyze first.\")\n    return report\n\n\n@router.get(\"/health/{project_key}\")\nasync def get_health(\n    request: Request,\n    project_key: str,\n    x_api_key: str | None = Header(None),\n) -> dict:\n    \"\"\"Get process health metrics.\"\"\"\n    _auth(request, x_api_key)\n    _require_process(request)\n    health = request.app.state.process.get_health(project_key)\n    if not health:\n        raise HTTPException(404, \"No health data. Run /api/process/analyze first.\")\n    return health\n"
  },
  {
    "path": "maggy/maggy/api/routes_projects.py",
    "content": "\"\"\"Project registry REST endpoints.\"\"\"\n\nfrom __future__ import annotations\n\nfrom fastapi import APIRouter, Header, HTTPException, Request\nfrom pydantic import BaseModel\n\nfrom .auth import check_auth\n\nrouter = APIRouter(prefix=\"/api/projects\", tags=[\"projects\"])\n\n\nclass _ProjectIn(BaseModel):\n    name: str\n    repo: str\n    path: str\n    default_branch: str = \"main\"\n\n\n@router.get(\"\")\nasync def list_projects(\n    request: Request,\n    x_api_key: str | None = Header(None),\n) -> list[dict]:\n    \"\"\"List all registered projects.\"\"\"\n    check_auth(request, x_api_key)\n    registry = request.app.state.registry\n    if not registry:\n        return []\n    return [\n        {\"name\": p.name, \"repo\": p.repo, \"path\": p.path}\n        for p in registry.list()\n    ]\n\n\n@router.get(\"/{name}\")\nasync def get_project(\n    name: str,\n    request: Request,\n    x_api_key: str | None = Header(None),\n) -> dict:\n    \"\"\"Get a single project by name.\"\"\"\n    check_auth(request, x_api_key)\n    registry = request.app.state.registry\n    if not registry:\n        raise HTTPException(404, \"Not configured\")\n    project = registry.get(name)\n    if not project:\n        raise HTTPException(404, f\"{name!r} not found\")\n    return {\n        \"name\": project.name,\n        \"repo\": project.repo,\n        \"path\": project.path,\n    }\n\n\n@router.post(\"\", status_code=201)\nasync def add_project(\n    body: _ProjectIn,\n    request: Request,\n    x_api_key: str | None = Header(None),\n) -> dict:\n    \"\"\"Register a new project.\"\"\"\n    check_auth(request, x_api_key)\n    registry = request.app.state.registry\n    if not registry:\n        raise HTTPException(503, \"Not configured\")\n    from maggy.config import ProjectConfig\n    project = ProjectConfig(\n        name=body.name, repo=body.repo,\n        path=body.path, default_branch=body.default_branch,\n    )\n    try:\n        registry.add(project)\n    except ValueError as exc:\n        raise HTTPException(409, str(exc)) from exc\n    return {\"name\": project.name, \"status\": \"created\"}\n\n\n@router.delete(\"/{name}\")\nasync def remove_project(\n    name: str,\n    request: Request,\n    x_api_key: str | None = Header(None),\n) -> dict:\n    \"\"\"Remove a project by name.\"\"\"\n    check_auth(request, x_api_key)\n    registry = request.app.state.registry\n    if not registry:\n        raise HTTPException(503, \"Not configured\")\n    if not registry.remove(name):\n        raise HTTPException(404, f\"{name!r} not found\")\n    return {\"name\": name, \"status\": \"removed\"}\n"
  },
  {
    "path": "maggy/maggy/api/routes_routing.py",
    "content": "\"\"\"Routing REST endpoints.\"\"\"\n\nfrom __future__ import annotations\n\nfrom fastapi import APIRouter, Header, Request\n\nfrom .auth import check_auth\n\nrouter = APIRouter(prefix=\"/api/routing\", tags=[\"routing\"])\n\n\n@router.get(\"/heatmap\")\nasync def heatmap(\n    request: Request,\n    x_api_key: str | None = Header(None),\n) -> list[dict]:\n    \"\"\"Return reward heatmap for dashboard.\"\"\"\n    check_auth(request, x_api_key)\n    svc = request.app.state.routing\n    if not svc:\n        return []\n    return svc.get_heatmap()\n\n\n@router.get(\"/decide\")\nasync def decide(\n    request: Request,\n    blast: int = 0,\n    task_type: str = \"general\",\n    security: bool = False,\n    x_api_key: str | None = Header(None),\n) -> dict:\n    \"\"\"Get routing decision for given context.\"\"\"\n    check_auth(request, x_api_key)\n    svc = request.app.state.routing\n    if not svc:\n        return {\"error\": \"routing not configured\"}\n    from maggy.routing import RoutingContext\n    ctx = RoutingContext(blast, task_type, security)\n    decision = svc.route(ctx)\n    return {\n        \"primary\": decision.primary,\n        \"validator\": decision.validator,\n        \"fallback\": decision.fallback_chain,\n        \"reason\": decision.reason,\n    }\n\n\n@router.get(\"/rules\")\nasync def rules(\n    request: Request,\n    x_api_key: str | None = Header(None),\n) -> dict:\n    \"\"\"Return routing rules summary.\"\"\"\n    check_auth(request, x_api_key)\n    svc = request.app.state.routing\n    if not svc:\n        return {\"mode\": \"unconfigured\"}\n    r = svc.rules\n    overrides = {\n        k: {\"model\": v.model, \"reason\": v.reason}\n        for k, v in r.task_type_overrides.items()\n    }\n    perf = {\n        k: {\n            \"strengths\": v.strengths,\n            \"success_rate\": v.success_rate,\n            \"tasks_completed\": v.tasks_completed,\n        }\n        for k, v in r.model_performance.items()\n    }\n    return {\n        \"mode\": svc.cfg.routing.mode,\n        \"task_type_overrides\": overrides,\n        \"model_performance\": perf,\n        \"conventions_count\": len(r.conventions),\n    }\n"
  },
  {
    "path": "maggy/maggy/api/routes_setup.py",
    "content": "\"\"\"Setup and onboarding routes — detect missing config, guide users.\"\"\"\n\nfrom __future__ import annotations\n\nfrom fastapi import APIRouter, Request\nfrom pydantic import BaseModel, Field\n\nfrom maggy import config as config_mod\n\nrouter = APIRouter(prefix=\"/api/setup\", tags=[\"setup\"])\n\n\nclass ConfigureRequest(BaseModel):\n    org_name: str = \"\"\n    github_org: str = \"\"\n    github_repos: list[str] = Field(default_factory=list)\n    competitor_categories: list[str] = Field(\n        default_factory=list,\n    )\n\n\ndef _step(label: str, ok: bool, hint: str = \"\") -> dict:\n    \"\"\"Build a single setup step status.\"\"\"\n    return {\n        \"label\": label,\n        \"status\": \"done\" if ok else \"missing\",\n        \"hint\": hint,\n    }\n\n\ndef _build_steps(cfg) -> list[dict]:\n    \"\"\"Detect what's configured and what's missing.\"\"\"\n    gh = cfg.issue_tracker.github\n    return [\n        _step(\"GitHub token\", bool(gh.token), \"\"),\n        _step(\"GitHub organization\", bool(gh.org), \"\"),\n        _step(\n            \"GitHub repositories\", bool(gh.repos),\n            \"Select repos to track issues from\",\n        ),\n        _step(\n            \"AI provider\",\n            bool(cfg.ai.api_key) or _has_claude_cli(),\n            \"\",\n        ),\n        _step(\"Codebases\", bool(cfg.codebases), \"\"),\n    ]\n\n\ndef _has_claude_cli() -> bool:\n    \"\"\"Check if claude CLI is available.\"\"\"\n    import shutil\n    return shutil.which(\"claude\") is not None\n\n\ndef _discover_summary() -> dict:\n    \"\"\"Run discovery and return summary.\"\"\"\n    from maggy.discovery import (\n        discover_cli_auth,\n        discover_clis,\n        discover_env_tokens,\n    )\n    return {\n        \"clis\": discover_clis(),\n        \"cli_auth\": discover_cli_auth(),\n        \"tokens\": discover_env_tokens(),\n    }\n\n\n@router.get(\"/status\")\nasync def setup_status(request: Request) -> dict:\n    \"\"\"What's configured, what's missing.\"\"\"\n    cfg = request.app.state.cfg\n    steps = _build_steps(cfg)\n    done = sum(1 for s in steps if s[\"status\"] == \"done\")\n    discovery = _discover_summary()\n    return {\n        \"configured\": request.app.state.mode == \"full\",\n        \"mode\": request.app.state.mode,\n        \"steps\": steps,\n        \"progress\": f\"{done}/{len(steps)}\",\n        \"codebases\": len(cfg.codebases),\n        \"github_org\": cfg.issue_tracker.github.org,\n        \"discovery\": discovery,\n    }\n\n\n@router.post(\"/configure\")\nasync def configure(\n    request: Request, body: ConfigureRequest,\n) -> dict:\n    \"\"\"Update config sections dynamically.\"\"\"\n    cfg = request.app.state.cfg\n    if body.org_name:\n        cfg.org.name = body.org_name\n    if body.github_org:\n        cfg.issue_tracker.github.org = body.github_org\n    if body.github_repos:\n        cfg.issue_tracker.github.repos = body.github_repos\n    if body.competitor_categories:\n        cfg.competitors.categories = body.competitor_categories\n    config_mod.save(cfg)\n    return {\"saved\": True}\n\n\n@router.post(\"/reload\")\nasync def reload_config(request: Request) -> dict:\n    \"\"\"Reload config and reinitialize services.\"\"\"\n    from maggy.main import reconfigure\n    reconfigure(request.app)\n    mode = request.app.state.mode\n    return {\"mode\": mode, \"reloaded\": True}\n\n\n@router.get(\"/discover-repos\")\nasync def discover_repos(request: Request) -> dict:\n    \"\"\"Return repos found on disk, grouped by org.\"\"\"\n    from maggy.discovery import full_discovery\n    result = full_discovery()\n    return {\n        \"github_org\": result.github_org,\n        \"github_orgs\": result.github_orgs,\n        \"repos\": [\n            {\"key\": r[\"key\"], \"path\": r[\"path\"]}\n            for r in result.repos\n        ],\n        \"cli_auth\": result.cli_auth,\n        \"clis\": result.clis,\n    }\n\n\n@router.post(\"/auto-configure\")\nasync def auto_configure(request: Request) -> dict:\n    \"\"\"Run auto-discovery, save config, reload.\"\"\"\n    cfg = config_mod.auto_configure()\n    request.app.state.cfg = cfg\n    from maggy.main import reconfigure\n    reconfigure(request.app)\n    return {\n        \"mode\": request.app.state.mode,\n        \"codebases\": len(cfg.codebases),\n        \"github_org\": cfg.issue_tracker.github.org,\n        \"github_repos\": cfg.issue_tracker.github.repos,\n        \"has_token\": bool(cfg.issue_tracker.github.token),\n    }\n\n\n@router.get(\"/cli-models\")\nasync def cli_models() -> dict:\n    \"\"\"Auto-discover AI CLIs and their capabilities.\"\"\"\n    from maggy.adapters.cli_discovery import discover_all\n    result = discover_all()\n    profiles = []\n    for name, p in result.profiles.items():\n        profiles.append({\n            \"name\": name, \"installed\": p.installed,\n            \"version\": p.version,\n            \"prompt_flag\": p.prompt_flag,\n            \"work_dir_flag\": p.work_dir_flag,\n            \"auto_approve\": p.auto_approve_flag,\n            \"afk\": p.afk_flag,\n        })\n    installed = [p[\"name\"] for p in profiles if p[\"installed\"]]\n    return {\n        \"profiles\": profiles,\n        \"installed\": installed,\n        \"ready\": len(installed) > 0,\n    }\n"
  },
  {
    "path": "maggy/maggy/budget.py",
    "content": "\"\"\"Token budget manager — tracks spend per provider with daily limits.\"\"\"\n\nfrom __future__ import annotations\n\nimport sqlite3\nimport tempfile\nfrom contextlib import contextmanager\nfrom dataclasses import dataclass\nfrom datetime import datetime, timezone\nfrom pathlib import Path\nfrom typing import Iterator\n\nfrom maggy.config import MaggyConfig\n\n\ndef _today_utc() -> str:\n    return datetime.now(timezone.utc).date().isoformat()\n\n\n@contextmanager\ndef _connect(path: Path) -> Iterator[sqlite3.Connection]:\n    try:\n        conn = _open_conn(path)\n    except sqlite3.OperationalError:\n        fallback = Path(tempfile.gettempdir()) / \"maggy\" / path.name\n        conn = _open_conn(fallback)\n    try:\n        yield conn\n    finally:\n        conn.close()\n\n\ndef _open_conn(path: Path) -> sqlite3.Connection:\n    path.parent.mkdir(parents=True, exist_ok=True)\n    conn = sqlite3.connect(str(path), timeout=30.0)\n    conn.execute(\"PRAGMA journal_mode=WAL\")\n    conn.execute(\"PRAGMA busy_timeout=30000\")\n    conn.row_factory = sqlite3.Row\n    return conn\n\nSCHEMA = \"\"\"\nCREATE TABLE IF NOT EXISTS spend (\n    id INTEGER PRIMARY KEY AUTOINCREMENT,\n    provider TEXT NOT NULL,\n    model TEXT NOT NULL,\n    cost_usd REAL NOT NULL,\n    input_tokens INTEGER NOT NULL DEFAULT 0,\n    output_tokens INTEGER NOT NULL DEFAULT 0,\n    day TEXT NOT NULL,\n    created_at TEXT NOT NULL\n);\nCREATE INDEX IF NOT EXISTS idx_spend_day\n    ON spend(day, provider);\n\"\"\"\n\n\n@dataclass(frozen=True)\nclass ProviderBudget:\n    \"\"\"Budget limit and preferred model for a provider.\"\"\"\n\n    provider: str\n    daily_limit_usd: float\n    model_preference: str\n\n\nclass TaskSpendTracker:\n    \"\"\"Track task-level spend and repeated edits.\"\"\"\n\n    def __init__(self, max_spend: float):\n        self.max_spend = max_spend\n        self._spent = 0.0\n        self.files_edited: dict[str, int] = {}\n\n    def record(self, cost: float) -> None:\n        self._spent += cost\n\n    def total(self) -> float:\n        return self._spent\n\n    def is_exceeded(self) -> bool:\n        return self._spent >= self.max_spend\n\n    def record_edit(self, file_path: str) -> None:\n        count = self.files_edited.get(file_path, 0)\n        self.files_edited[file_path] = count + 1\n\n    def detect_loop(self, threshold: int = 3) -> list[str]:\n        return [\n            path for path, count in self.files_edited.items()\n            if count >= threshold\n        ]\n\n\nclass BudgetManager:\n    \"\"\"Track token spend per provider with daily limits.\"\"\"\n\n    def __init__(self, cfg: MaggyConfig):\n        self.daily_limit = cfg.budget.daily_limit_usd\n        self._plan = cfg.budget.plan\n        self.providers = list(cfg.budget.providers)\n        self._provider_budgets = {\n            item.provider: item for item in self.providers\n        }\n        self.warning_threshold = cfg.budget.warning_threshold\n        db_dir = Path(cfg.storage.path).expanduser().parent\n        self._db_path = db_dir / \"budget.db\"\n        self._init_db()\n\n    def _init_db(self) -> None:\n        with _connect(self._db_path) as conn:\n            conn.executescript(SCHEMA)\n\n    def record_spend(\n        self, provider: str, model: str, cost_usd: float,\n        input_tokens: int = 0, output_tokens: int = 0,\n    ) -> None:\n        now = datetime.now(timezone.utc)\n        with _connect(self._db_path) as conn:\n            conn.execute(\n                \"INSERT INTO spend \"\n                \"(provider,model,cost_usd,input_tokens,output_tokens,day,created_at) \"\n                \"VALUES (?,?,?,?,?,?,?)\",\n                (provider, model, cost_usd, input_tokens, output_tokens,\n                 now.date().isoformat(), now.isoformat()),\n            )\n            conn.commit()\n\n    def today_spend(self, provider: str | None = None) -> float:\n        today = _today_utc()\n        sql = \"SELECT COALESCE(SUM(cost_usd),0) FROM spend WHERE day=?\"\n        params: list = [today]\n        if provider:\n            sql += \" AND provider=?\"\n            params.append(provider)\n        with _connect(self._db_path) as conn:\n            row = conn.execute(sql, params).fetchone()\n        return float(row[0])\n\n    def today_tokens(self, provider: str | None = None) -> dict:\n        today = _today_utc()\n        sql = (\"SELECT COALESCE(SUM(input_tokens),0),\"\n               \"COALESCE(SUM(output_tokens),0) FROM spend WHERE day=?\")\n        params: list = [today]\n        if provider:\n            sql += \" AND provider=?\"\n            params.append(provider)\n        with _connect(self._db_path) as conn:\n            row = conn.execute(sql, params).fetchone()\n        return {\"input\": int(row[0]), \"output\": int(row[1])}\n\n    def budget_status(self) -> dict:\n        spent = self.today_spend()\n        ratio = spent / self.daily_limit if self.daily_limit > 0 else 0\n        status = \"exhausted\" if ratio >= 1.0 else (\n            \"warning\" if ratio >= self.warning_threshold else \"ok\")\n        tokens = self.today_tokens()\n        return {\n            \"spent_today_usd\": round(spent, 4),\n            \"daily_limit_usd\": self.daily_limit,\n            \"utilization\": round(ratio, 3),\n            \"status\": status,\n            \"plan\": self._plan,\n            \"input_tokens\": tokens[\"input\"],\n            \"output_tokens\": tokens[\"output\"],\n        }\n\n    def by_provider(self) -> list[dict]:\n        today = _today_utc()\n        with _connect(self._db_path) as conn:\n            rows = conn.execute(\n                \"SELECT provider, SUM(cost_usd) as total \"\n                \"FROM spend WHERE day=? GROUP BY provider\",\n                (today,),\n            ).fetchall()\n        return [\n            {\"provider\": r[\"provider\"], \"spent_usd\": round(r[\"total\"], 4)}\n            for r in rows\n        ]\n\n    def is_exhausted(\n        self, provider: str | None = None,\n    ) -> bool:\n        \"\"\"Check if daily budget is exhausted.\"\"\"\n        spent = self.today_spend(provider)\n        return spent >= self.daily_limit\n\n    def is_provider_exhausted(self, provider: str) -> bool:\n        \"\"\"Check provider-specific budget when configured.\"\"\"\n        budget = self._provider_budgets.get(provider)\n        if budget is None:\n            return self.is_exhausted(provider)\n        return self.today_spend(provider) >= budget.daily_limit_usd\n\n    def cheapest_available(self) -> str | None:\n        \"\"\"Return preferred model for the first provider with budget left.\"\"\"\n        for budget in self.providers:\n            if not self.is_provider_exhausted(budget.provider):\n                return budget.model_preference\n        return None\n"
  },
  {
    "path": "maggy/maggy/calibration/__init__.py",
    "content": "\"\"\"Calibration exports.\"\"\"\n\nfrom .tracker import CalibrationTracker\n\n__all__ = [\"CalibrationTracker\"]\n"
  },
  {
    "path": "maggy/maggy/calibration/tracker.py",
    "content": "\"\"\"SQLite-backed model calibration tracking.\"\"\"\n\nfrom __future__ import annotations\n\nimport sqlite3\nfrom contextlib import contextmanager\nfrom pathlib import Path\nfrom typing import Iterator\n\nSCHEMA = \"\"\"\nCREATE TABLE IF NOT EXISTS calibration (\n    id INTEGER PRIMARY KEY AUTOINCREMENT,\n    model TEXT NOT NULL,\n    task_type TEXT NOT NULL,\n    predicted REAL NOT NULL,\n    actual REAL NOT NULL\n);\nCREATE INDEX IF NOT EXISTS idx_calibration_model\n    ON calibration(model);\n\"\"\"\n\n\n@contextmanager\ndef _connect(path: Path) -> Iterator[sqlite3.Connection]:\n    path.parent.mkdir(parents=True, exist_ok=True)\n    conn = sqlite3.connect(str(path), timeout=30.0)\n    conn.row_factory = sqlite3.Row\n    try:\n        yield conn\n    finally:\n        conn.close()\n\n\nclass CalibrationTracker:\n    def __init__(self, db_path: Path):\n        self._db_path = db_path\n        self._init_db()\n\n    def record(\n        self, model: str, task_type: str, predicted: float, actual: float,\n    ) -> None:\n        with _connect(self._db_path) as conn:\n            conn.execute(\n                \"INSERT INTO calibration (model, task_type, predicted, actual) \"\n                \"VALUES (?, ?, ?, ?)\",\n                (model, task_type, predicted, actual),\n            )\n            conn.commit()\n\n    def accuracy(self, model: str) -> float:\n        errors = self._errors(model)\n        if not errors:\n            return 0.0\n        score = sum(max(0.0, 1.0 - err) for err in errors) / len(errors)\n        return round(score, 6)\n\n    def calibration_error(self, model: str) -> float:\n        errors = self._errors(model)\n        if not errors:\n            return 0.0\n        return round(sum(errors) / len(errors), 6)\n\n    def _errors(self, model: str) -> list[float]:\n        with _connect(self._db_path) as conn:\n            rows = conn.execute(\n                \"SELECT predicted, actual FROM calibration WHERE model = ?\",\n                (model,),\n            ).fetchall()\n        return [abs(row[\"predicted\"] - row[\"actual\"]) for row in rows]\n\n    def _init_db(self) -> None:\n        with _connect(self._db_path) as conn:\n            conn.executescript(SCHEMA)\n"
  },
  {
    "path": "maggy/maggy/checkpoint.py",
    "content": "\"\"\"JSON checkpoint persistence for fallback chains.\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nfrom pathlib import Path\n\nDEFAULT_DIR = Path.home() / \".maggy\" / \"checkpoints\"\n\n\nclass CheckpointManager:\n    def __init__(self, base_dir: Path = DEFAULT_DIR):\n        self.base_dir = base_dir.expanduser()\n\n    def write(self, session_id: str, data: dict) -> None:\n        self.base_dir.mkdir(parents=True, exist_ok=True)\n        payload = _normalize(data)\n        target = self._path(session_id)\n        tmp = target.with_suffix(\".tmp\")\n        tmp.write_text(json.dumps(payload, indent=2))\n        tmp.replace(target)\n\n    def read(self, session_id: str) -> dict | None:\n        path = self._path(session_id)\n        if not path.exists():\n            return None\n        try:\n            return json.loads(path.read_text())\n        except (json.JSONDecodeError, OSError):\n            return None\n\n    def delete(self, session_id: str) -> bool:\n        path = self._path(session_id)\n        if not path.exists():\n            return False\n        path.unlink()\n        return True\n\n    def list_checkpoints(self) -> list[str]:\n        if not self.base_dir.exists():\n            return []\n        names = [path.stem for path in self.base_dir.glob(\"*.json\")]\n        return sorted(names)\n\n    def _path(self, session_id: str) -> Path:\n        safe_id = _sanitize_id(session_id)\n        target = (self.base_dir / f\"{safe_id}.json\").resolve()\n        if not str(target).startswith(str(self.base_dir.resolve())):\n            raise ValueError(f\"Invalid session id: {session_id!r}\")\n        return target\n\n\ndef _sanitize_id(session_id: str) -> str:\n    import re\n    if not session_id or not re.fullmatch(r\"[a-zA-Z0-9_\\-]+\", session_id):\n        raise ValueError(f\"Invalid session id: {session_id!r}\")\n    return session_id\n\n\ndef _normalize(data: dict) -> dict:\n    return {\n        \"goal\": str(data.get(\"goal\", \"\")),\n        \"constraints\": list(data.get(\"constraints\", [])),\n        \"progress\": list(data.get(\"progress\", [])),\n        \"model_history\": list(data.get(\"model_history\", [])),\n        \"current_subgoal\": str(data.get(\"current_subgoal\", \"\")),\n        \"fatigue_score\": float(data.get(\"fatigue_score\", 0.0)),\n    }\n"
  },
  {
    "path": "maggy/maggy/cikg/__init__.py",
    "content": "\"\"\"Competitive Intelligence Knowledge Graph.\"\"\"\n"
  },
  {
    "path": "maggy/maggy/cikg/graph.py",
    "content": "\"\"\"KnowledgeGraphService — CRUD operations for CIKG.\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport sqlite3\nfrom pathlib import Path\n\nfrom .models import Edge, Node\nfrom .storage import SCHEMA, _connect\n\n\nclass KnowledgeGraphService:\n    \"\"\"SQLite-backed knowledge graph — CRUD only.\"\"\"\n\n    def __init__(self, db_path: Path):\n        self._db_path = db_path\n        with _connect(self._db_path) as conn:\n            conn.executescript(SCHEMA)\n\n    def add_node(self, node: Node) -> None:\n        with _connect(self._db_path) as conn:\n            conn.execute(\n                \"INSERT OR REPLACE INTO nodes VALUES (?,?,?,?,?,?)\",\n                (node.id, node.node_type, node.name,\n                 node.description, json.dumps(node.metadata),\n                 node.created_at),\n            )\n            conn.commit()\n\n    def get_node(self, node_id: str) -> Node | None:\n        with _connect(self._db_path) as conn:\n            row = conn.execute(\n                \"SELECT * FROM nodes WHERE id=?\", (node_id,),\n            ).fetchone()\n        if not row:\n            return None\n        return _row_to_node(row)\n\n    def list_nodes(self, node_type: str | None = None) -> list[Node]:\n        with _connect(self._db_path) as conn:\n            if node_type:\n                rows = conn.execute(\n                    \"SELECT * FROM nodes WHERE node_type=?\",\n                    (node_type,),\n                ).fetchall()\n            else:\n                rows = conn.execute(\"SELECT * FROM nodes\").fetchall()\n        return [_row_to_node(r) for r in rows]\n\n    def add_edge(self, edge: Edge) -> None:\n        with _connect(self._db_path) as conn:\n            conn.execute(\n                \"INSERT OR REPLACE INTO edges VALUES (?,?,?,?,?)\",\n                (edge.source_id, edge.target_id, edge.edge_type,\n                 edge.weight, json.dumps(edge.metadata)),\n            )\n            conn.commit()\n\n    def get_edges(self, node_id: str, direction: str = \"out\") -> list[Edge]:\n        with _connect(self._db_path) as conn:\n            edges: list[Edge] = []\n            if direction in (\"out\", \"both\"):\n                for r in conn.execute(\n                    \"SELECT * FROM edges WHERE source_id=?\",\n                    (node_id,),\n                ).fetchall():\n                    edges.append(_row_to_edge(r))\n            if direction in (\"in\", \"both\"):\n                for r in conn.execute(\n                    \"SELECT * FROM edges WHERE target_id=?\",\n                    (node_id,),\n                ).fetchall():\n                    edges.append(_row_to_edge(r))\n        return edges\n\n    def neighbors(self, node_id: str) -> list[Node]:\n        edges = self.get_edges(node_id, \"both\")\n        ids = set()\n        for e in edges:\n            ids.add(e.source_id)\n            ids.add(e.target_id)\n        ids.discard(node_id)\n        return [n for n in (self.get_node(i) for i in ids) if n]\n\n    def delete_node(self, node_id: str) -> None:\n        with _connect(self._db_path) as conn:\n            conn.execute(\"DELETE FROM nodes WHERE id=?\", (node_id,))\n            conn.execute(\n                \"DELETE FROM edges WHERE source_id=? OR target_id=?\",\n                (node_id, node_id),\n            )\n            conn.commit()\n\n\ndef _row_to_node(r: sqlite3.Row) -> Node:\n    return Node(\n        id=r[\"id\"], node_type=r[\"node_type\"], name=r[\"name\"],\n        description=r[\"description\"],\n        metadata=json.loads(r[\"metadata\"]), created_at=r[\"created_at\"],\n    )\n\n\ndef _row_to_edge(r: sqlite3.Row) -> Edge:\n    return Edge(\n        source_id=r[\"source_id\"], target_id=r[\"target_id\"],\n        edge_type=r[\"edge_type\"], weight=r[\"weight\"],\n        metadata=json.loads(r[\"metadata\"]),\n    )\n"
  },
  {
    "path": "maggy/maggy/cikg/models.py",
    "content": "\"\"\"CIKG node and edge models.\"\"\"\n\nfrom __future__ import annotations\n\nfrom dataclasses import dataclass, field\nfrom datetime import datetime, timezone\n\nNODE_TYPES = (\n    \"codebase\", \"competitor\", \"feature\", \"market_segment\",\n    \"product\", \"technology\", \"trend\",\n)\n\nEDGE_TYPES = (\n    \"has_feature\", \"competes_with\", \"targets_market\",\n    \"uses_technology\", \"protaige_has\", \"protaige_lacks\",\n    \"threatens\",\n)\n\n\n@dataclass\nclass Node:\n    \"\"\"A node in the knowledge graph.\"\"\"\n\n    id: str\n    node_type: str\n    name: str\n    description: str = \"\"\n    metadata: dict = field(default_factory=dict)\n    created_at: str = field(\n        default_factory=lambda: datetime.now(\n            timezone.utc\n        ).isoformat()\n    )\n\n    def __post_init__(self) -> None:\n        if self.node_type not in NODE_TYPES:\n            raise ValueError(f\"Invalid node_type: {self.node_type!r}\")\n\n\n@dataclass\nclass Edge:\n    \"\"\"A directed edge between two nodes.\"\"\"\n\n    source_id: str\n    target_id: str\n    edge_type: str\n    weight: float = 1.0\n    metadata: dict = field(default_factory=dict)\n\n    def __post_init__(self) -> None:\n        if self.edge_type not in EDGE_TYPES:\n            raise ValueError(f\"Invalid edge_type: {self.edge_type!r}\")\n\n\n@dataclass\nclass MarketScore:\n    \"\"\"Result of a market scoring query.\"\"\"\n\n    feature: str\n    gap_count: int = 0\n    threat_level: str = \"low\"  # low | medium | high\n    trend_alignment: float = 0.0\n    recommendation: str = \"\"\n"
  },
  {
    "path": "maggy/maggy/cikg/queries.py",
    "content": "\"\"\"CIKG query functions — gap analysis and market scoring.\"\"\"\n\nfrom __future__ import annotations\n\nfrom .graph import KnowledgeGraphService\nfrom .models import MarketScore, Node\n\n\ndef find_gaps(graph: KnowledgeGraphService, feature_name: str) -> MarketScore:\n    \"\"\"Score a feature against the competitive landscape.\"\"\"\n    feature_ids = _matching_ids(graph, \"feature\", feature_name)\n    results = []\n    for node in graph.list_nodes(\"competitor\"):\n        has = bool(feature_ids & _targets_for(graph, node.id, \"has_feature\"))\n        results.append({\n            \"entity_id\": node.id, \"entity\": node.name,\n            \"feature\": feature_name, \"status\": \"has\" if has else \"lacks\",\n        })\n    have_it = sum(1 for r in results if r[\"status\"] == \"has\")\n    total = len(results)\n    threat = _threat_level(have_it, total)\n    return MarketScore(\n        feature=feature_name, gap_count=total - have_it,\n        threat_level=threat,\n        recommendation=_recommend(feature_name, have_it, total, threat),\n    )\n\n\ndef find_gaps_raw(graph: KnowledgeGraphService, feature: str) -> list[dict]:\n    \"\"\"Return raw gap results per competitor.\"\"\"\n    feature_ids = _matching_ids(graph, \"feature\", feature)\n    results = []\n    for node in graph.list_nodes(\"competitor\"):\n        has = bool(feature_ids & _targets_for(graph, node.id, \"has_feature\"))\n        results.append({\n            \"entity_id\": node.id, \"entity\": node.name,\n            \"feature\": feature, \"status\": \"has\" if has else \"lacks\",\n        })\n    return sorted(results, key=lambda r: r[\"entity\"])\n\n\ndef compare_entities(graph: KnowledgeGraphService, id_a: str, id_b: str) -> dict:\n    \"\"\"Compare two entities by their features.\"\"\"\n    a_feat = _targets_for(graph, id_a, \"has_feature\")\n    b_feat = _targets_for(graph, id_b, \"has_feature\")\n    related = graph.get_edges(id_a, \"out\") + graph.get_edges(id_b, \"out\")\n    rels = [\n        {\"source_id\": e.source_id, \"target_id\": e.target_id, \"edge_type\": e.edge_type}\n        for e in related if {e.source_id, e.target_id} == {id_a, id_b}\n    ]\n    return {\n        \"shared\": sorted(a_feat & b_feat),\n        \"only_a\": sorted(a_feat - b_feat),\n        \"only_b\": sorted(b_feat - a_feat),\n        \"relationships\": rels,\n    }\n\n\ndef get_landscape(graph: KnowledgeGraphService) -> dict:\n    \"\"\"Return competitive landscape summary.\"\"\"\n    competitors = graph.list_nodes(\"competitor\")\n    features = graph.list_nodes(\"feature\")\n    techs = graph.list_nodes(\"technology\")\n    return {\n        \"competitors\": len(competitors),\n        \"features_tracked\": len(features),\n        \"technologies\": len(techs),\n        \"top_competitors\": [c.name for c in competitors[:10]],\n    }\n\n\ndef get_segment_landscape(graph: KnowledgeGraphService, segment: str) -> dict:\n    \"\"\"Return landscape for a specific market segment.\"\"\"\n    seg_nodes = _matching_nodes(graph, \"market_segment\", segment)\n    if not seg_nodes:\n        return _empty_landscape(segment)\n    seg_id = seg_nodes[0].id\n    comp_ids = [\n        e.source_id for e in graph.get_edges(seg_id, \"in\")\n        if e.edge_type == \"targets_market\"\n    ]\n    names = [graph.get_node(i).name for i in comp_ids if graph.get_node(i)]\n    feats = set().union(*(\n        _targets_for(graph, i, \"has_feature\") for i in comp_ids\n    ))\n    techs = set().union(*(\n        _targets_for(graph, i, \"uses_technology\") for i in comp_ids\n    ))\n    threats = sum(\n        1 for i in comp_ids for e in graph.get_edges(i, \"out\")\n        if e.edge_type == \"threatens\" and e.target_id in comp_ids\n    )\n    return {\n        \"segment\": seg_nodes[0].name,\n        \"competitors\": len(comp_ids),\n        \"features_tracked\": len(feats),\n        \"technologies\": len(techs),\n        \"threat_count\": threats,\n        \"top_competitors\": sorted(names)[:10],\n    }\n\n\ndef _matching_ids(graph: KnowledgeGraphService, node_type: str, query: str) -> set[str]:\n    return {n.id for n in _matching_nodes(graph, node_type, query)}\n\n\ndef _matching_nodes(graph: KnowledgeGraphService, node_type: str, query: str) -> list[Node]:\n    val = query.lower()\n    return [n for n in graph.list_nodes(node_type) if val in n.name.lower() or val == n.id.lower()]\n\n\ndef _targets_for(graph: KnowledgeGraphService, node_id: str, edge_type: str) -> set[str]:\n    return {e.target_id for e in graph.get_edges(node_id, \"out\") if e.edge_type == edge_type}\n\n\ndef _threat_level(have_it: int, total: int) -> str:\n    if total == 0:\n        return \"low\"\n    ratio = have_it / total\n    if ratio > 0.7:\n        return \"high\"\n    return \"medium\" if ratio > 0.3 else \"low\"\n\n\ndef _recommend(feature: str, have: int, total: int, threat: str) -> str:\n    if have == 0:\n        return f\"No competitor has '{feature}' — potential differentiator\"\n    suffix = {\"high\": \"Table stakes — must have.\", \"medium\": \"Growing trend.\",\n              \"low\": \"Differentiator opportunity.\"}[threat]\n    return f\"{have}/{total} competitors have this. {suffix}\"\n\n\ndef _empty_landscape(segment: str) -> dict:\n    return {\n        \"segment\": segment, \"competitors\": 0,\n        \"features_tracked\": 0, \"technologies\": 0,\n        \"threat_count\": 0, \"top_competitors\": [],\n    }\n"
  },
  {
    "path": "maggy/maggy/cikg/storage.py",
    "content": "\"\"\"SQLite helpers for the competitive graph.\"\"\"\n\nfrom __future__ import annotations\n\nimport sqlite3\nfrom contextlib import contextmanager\nfrom pathlib import Path\nfrom typing import Iterator\n\nSCHEMA = \"\"\"\nCREATE TABLE IF NOT EXISTS nodes (\n    id TEXT PRIMARY KEY,\n    node_type TEXT NOT NULL,\n    name TEXT NOT NULL,\n    description TEXT DEFAULT '',\n    metadata TEXT DEFAULT '{}',\n    created_at TEXT NOT NULL\n);\nCREATE INDEX IF NOT EXISTS idx_nodes_type ON nodes(node_type);\nCREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);\nCREATE TABLE IF NOT EXISTS edges (\n    source_id TEXT NOT NULL,\n    target_id TEXT NOT NULL,\n    edge_type TEXT NOT NULL,\n    weight REAL DEFAULT 1.0,\n    metadata TEXT DEFAULT '{}',\n    PRIMARY KEY (source_id, target_id, edge_type)\n);\nCREATE INDEX IF NOT EXISTS idx_edges_source ON edges(source_id);\nCREATE INDEX IF NOT EXISTS idx_edges_target ON edges(target_id);\n\"\"\"\n\n\n@contextmanager\ndef _connect(path: Path) -> Iterator[sqlite3.Connection]:\n    path.parent.mkdir(parents=True, exist_ok=True)\n    conn = sqlite3.connect(str(path), timeout=30.0)\n    conn.execute(\"PRAGMA journal_mode=WAL\")\n    conn.execute(\"PRAGMA busy_timeout=30000\")\n    conn.row_factory = sqlite3.Row\n    try:\n        yield conn\n    finally:\n        conn.close()\n"
  },
  {
    "path": "maggy/maggy/cli.py",
    "content": "\"\"\"Maggy CLI — terminal interface for the engineering platform.\"\"\"\n\nfrom __future__ import annotations\n\nimport typer\n\nfrom maggy.cli_client import MaggyClient\nfrom maggy.cli_output import (\n    console,\n    dump_json,\n    render_budget,\n    render_competitors,\n    render_health,\n    render_inbox,\n    render_models,\n    render_route,\n    render_sessions,\n)\n\napp = typer.Typer(\n    name=\"maggy\",\n    help=\"Maggy — AI Engineering Platform\",\n    no_args_is_help=False,\n)\n_client = MaggyClient()\n\n\ndef _ensure() -> bool:\n    if not _client._check_health():\n        console.print(\"[dim]Starting Maggy server...[/dim]\")\n    if not _client.ensure_server():\n        console.print(\"[red]Cannot reach Maggy server.[/red]\")\n        raise typer.Exit(1)\n    return True\n\n\n@app.callback(invoke_without_command=True)\ndef main(ctx: typer.Context) -> None:\n    \"\"\"Interactive REPL (in project) or dashboard.\"\"\"\n    if ctx.invoked_subcommand is not None:\n        return\n    _ensure()\n    from maggy.cli_chat import detect_project, run_chat\n    project = detect_project(_client)\n    if project:\n        run_chat(_client, project, routed=True)\n    else:\n        serve()\n\n\n@app.command()\ndef serve() -> None:\n    \"\"\"Start the Maggy server + web dashboard.\"\"\"\n    from maggy.main import main as start_server\n    start_server()\n\n\n@app.command()\ndef status(json_out: bool = typer.Option(False, \"--json\")) -> None:\n    \"\"\"Show server health and config summary.\"\"\"\n    _ensure()\n    data = _client.health()\n    dump_json(data) if json_out else render_health(data)\n\n\n@app.command()\ndef inbox(\n    refresh: bool = typer.Option(False, \"--refresh\"),\n    json_out: bool = typer.Option(False, \"--json\"),\n) -> None:\n    \"\"\"Show AI-ranked task inbox.\"\"\"\n    _ensure()\n    data = _client.inbox(refresh=refresh)\n    if json_out:\n        dump_json(data)\n    elif not data.get(\"items\"):\n        console.print(\"[dim]No tasks in inbox.[/dim]\")\n    else:\n        render_inbox(data)\n\n\n@app.command()\ndef sessions(json_out: bool = typer.Option(False, \"--json\")) -> None:\n    \"\"\"List active AI sessions across projects.\"\"\"\n    _ensure()\n    data = _client.activity()\n    dump_json(data) if json_out else render_sessions(data)\n\n\n@app.command()\ndef chat(\n    project: str = typer.Argument(..., help=\"Project key\"),\n    direct: bool = typer.Option(False, \"--direct\"),\n) -> None:\n    \"\"\"Interactive chat with a project's AI session.\"\"\"\n    _ensure()\n    from maggy.cli_chat import run_chat\n    run_chat(_client, project, routed=not direct)\n\n\n@app.command()\ndef spawn(\n    task: str = typer.Argument(..., help=\"Task description\"),\n) -> None:\n    \"\"\"Spawn a background AI session.\"\"\"\n    _ensure()\n    from maggy.cli_chat import detect_project\n    from maggy.cli_sessions import spawn_session\n    project = detect_project(_client)\n    if not project:\n        console.print(\"[red]Not in a project directory.[/red]\")\n        raise typer.Exit(1)\n    spawn_session(_client, task, project)\n\n\n@app.command()\ndef ps() -> None:\n    \"\"\"List all managed sessions (chat + executor).\"\"\"\n    _ensure()\n    from maggy.cli_sessions import list_all\n    list_all(_client)\n\n\n@app.command()\ndef kill(\n    session_id: str = typer.Argument(..., help=\"Session ID\"),\n) -> None:\n    \"\"\"Stop a managed session.\"\"\"\n    _ensure()\n    from maggy.cli_sessions import kill_session\n    kill_session(_client, session_id)\n\n\n@app.command()\ndef execute(\n    task_id: str = typer.Argument(..., help=\"Task ID\"),\n    plan: bool = typer.Option(False, \"--plan\"),\n) -> None:\n    \"\"\"Execute a task via the TDD pipeline.\"\"\"\n    _ensure()\n    mode = \"plan\" if plan else \"tdd\"\n    data = _client.execute(task_id, mode)\n    console.print(\n        f\"[green]Started[/green] session \"\n        f\"[bold]{data.get('session_id', '?')}[/bold] \"\n        f\"({mode} mode)\",\n    )\n\n\n@app.command()\ndef route(\n    blast: int = typer.Argument(..., help=\"Complexity 1-10\"),\n    task_type: str = typer.Option(\"general\", \"--type\"),\n    json_out: bool = typer.Option(False, \"--json\"),\n) -> None:\n    \"\"\"Get routing decision for a complexity score.\"\"\"\n    _ensure()\n    data = _client.route(blast, task_type)\n    dump_json(data) if json_out else render_route(data)\n\n\n@app.command()\ndef budget(json_out: bool = typer.Option(False, \"--json\")) -> None:\n    \"\"\"Show per-provider token budget.\"\"\"\n    _ensure()\n    data = _client.budget_summary()\n    dump_json(data) if json_out else render_budget(data)\n\n\n@app.command()\ndef models(json_out: bool = typer.Option(False, \"--json\")) -> None:\n    \"\"\"Show model performance heatmap.\"\"\"\n    _ensure()\n    data = _client.models_heatmap()\n    dump_json(data) if json_out else render_models(data)\n\n\n@app.command()\ndef competitors(\n    briefing: bool = typer.Option(False, \"--briefing\"),\n    json_out: bool = typer.Option(False, \"--json\"),\n) -> None:\n    \"\"\"Show competitor intelligence.\"\"\"\n    _ensure()\n    if briefing:\n        data = _client.competitors_briefing()\n    else:\n        data = _client.competitors_news()\n    if json_out:\n        dump_json(data)\n    elif briefing:\n        console.print(data.get(\"summary\", \"No briefing available.\"))\n    else:\n        render_competitors(data)\n\n\n@app.command()\ndef process(\n    project: str = typer.Argument(..., help=\"Project key\"),\n    json_out: bool = typer.Option(False, \"--json\"),\n) -> None:\n    \"\"\"Show process health for a project.\"\"\"\n    _ensure()\n    data = _client.process_health(project)\n    dump_json(data) if json_out else console.print_json(data=data)\n\n\n@app.command()\ndef config(json_out: bool = typer.Option(False, \"--json\")) -> None:\n    \"\"\"Show current configuration (redacted).\"\"\"\n    _ensure()\n    dump_json(_client.config())\n"
  },
  {
    "path": "maggy/maggy/cli_chat.py",
    "content": "\"\"\"Interactive chat REPL for Maggy CLI with model routing.\"\"\"\nfrom __future__ import annotations\n\nimport os\n\nfrom rich.console import Console\nfrom rich.live import Live\nfrom rich.markdown import Markdown\nfrom rich.prompt import Prompt\nfrom rich.spinner import Spinner\n\nfrom maggy.cli_repl_cmds import SessionState, dispatch\nfrom maggy.cli_welcome import render_welcome\nfrom maggy.services.session_detect import detect_all\n\nconsole = Console()\n\nEXIT_WORDS = frozenset({\"exit\", \"bye\", \"quit\", \"/exit\", \"/bye\"})\n_QUOTA_MARKERS = (\"rate_limit\", \"quota\", \"exceeded\", \"429\")\n\n\ndef detect_project(client) -> str | None:\n    \"\"\"Auto-detect project from current working directory.\"\"\"\n    return client.detect_project(os.getcwd())\n\n\ndef run_chat(\n    client, project: str, routed: bool = True,\n) -> None:\n    session, resumed = _find_or_create(client, project)\n    sid = session.get(\"id\", \"?\")\n    wd = session.get(\"working_dir\", \"?\")\n    render_welcome(project, session, client)\n    _show_resume_info(client, sid, wd)\n    state = SessionState(session_id=sid, working_dir=wd)\n    _repl_loop(client, state, routed)\n    console.print(\"[dim]Session saved. Bye.[/dim]\")\n\n\ndef _find_or_create(client, project: str) -> tuple[dict, bool]:\n    for s in client.chat_sessions():\n        if s.get(\"project_key\") == project:\n            return s, True\n    return client.chat_create(project), False\n\n\ndef _show_resume_info(client, sid: str, wd: str) -> None:\n    detected = detect_all(wd)\n    if detected.sessions:\n        parts = [f\"{s.cli}({s.session_id[:8]})\" for s in detected.sessions]\n        console.print(f\"[dim]Prior: {', '.join(parts)}[/dim]\")\n    for msg in client.chat_history(sid).get(\"messages\", [])[-3:]:\n        role = msg.get(\"role\", \"?\")\n        text = msg.get(\"content\", \"\")[:120]\n        tag = \"[cyan]You[/cyan]\" if role == \"user\" else \"[green]Maggy[/green]\"\n        console.print(f\"  {tag}: {text}\")\n\n\ndef _repl_loop(client, state: SessionState, routed: bool) -> None:\n    blast_override: int | None = None\n    while True:\n        try:\n            text = Prompt.ask(\"[bold cyan]>[/bold cyan]\")\n        except (KeyboardInterrupt, EOFError):\n            console.print()\n            break\n        stripped = text.strip()\n        if not stripped:\n            continue\n        if stripped == \"/quit\" or stripped.lower() in EXIT_WORDS:\n            break\n        if stripped == \"/history\":\n            _show_history(client, state.session_id)\n            continue\n        if stripped == \"/sessions\":\n            _show_sessions(client)\n            continue\n        if stripped == \"/clear\":\n            console.clear()\n            continue\n        if stripped.startswith(\"/monitor\"):\n            data = _call_safe(client.monitor_status)\n            console.print(f\"[dim]Monitors: {data.get('active', 0)} active[/dim]\")\n            continue\n        if stripped.startswith(\"/screenshot\"):\n            _handle_screenshot(stripped)\n            continue\n        if stripped.startswith(\"/blast\"):\n            blast_override = _parse_blast(stripped)\n            continue\n        if dispatch(stripped, client, state):\n            continue\n        if routed:\n            chunks = client.chat_send_routed(\n                state.session_id, stripped,\n                blast=blast_override,\n                allowed_models=state.allowed_models or None,\n            )\n        else:\n            chunks = client.chat_send_stream(\n                state.session_id, stripped,\n            )\n        _stream_chunks(chunks)\n        blast_override = None\n\n\ndef _parse_blast(text: str) -> int | None:\n    parts = text.split()\n    if len(parts) >= 2:\n        try:\n            val = max(1, min(10, int(parts[1])))\n            console.print(f\"[dim]Blast override: {val}[/dim]\")\n            return val\n        except ValueError:\n            pass\n    console.print(\"[dim]Usage: /blast N (1-10)[/dim]\")\n    return None\n\n\ndef _stream_chunks(chunks) -> None:\n    full, err = \"\", \"\"\n    try:\n        with Live(\n            Spinner(\"dots\", text=\"Thinking...\"),\n            console=console, refresh_per_second=8,\n        ) as live:\n            for chunk in chunks:\n                ct = chunk.get(\"type\", \"\")\n                if ct == \"routing\":\n                    _show_routing(chunk)\n                elif ct == \"queued\":\n                    pos = chunk.get(\"position\", \"?\")\n                    live.update(Markdown(f\"[dim]Queued (position {pos})[/dim]\"))\n                elif ct in (\"warning\", \"agent_status\"):\n                    console.print(f\"[dim]{chunk.get('content', chunk.get('status', ''))}[/dim]\")\n                elif ct in (\"text\", \"result\"):\n                    full += chunk.get(\"content\", \"\")\n                    live.update(Markdown(full))\n                elif ct == \"error\":\n                    err = chunk.get(\"content\", \"\")\n                elif ct == \"done\":\n                    break\n    except KeyboardInterrupt:\n        console.print(\"\\n[dim]Interrupted[/dim]\")\n    except Exception as e:\n        err = str(e)\n    if err:\n        console.print(f\"[red]Error:[/red] {err}\")\n        if any(m in err.lower() for m in _QUOTA_MARKERS):\n            from maggy.services.account_guide import render_switch_guide\n            render_switch_guide(\"anthropic\")\n\n\ndef _call_safe(fn, default=None):\n    try:\n        return fn()\n    except (Exception, SystemExit):\n        return default if default is not None else {}\n\n\ndef _handle_screenshot(text: str) -> None:\n    \"\"\"Send image to Qwen3-VL for analysis.\"\"\"\n    from maggy.services.vision import analyze_image\n    parts = text.split(None, 2)\n    if len(parts) < 2:\n        console.print(\"[dim]Usage: /screenshot <path> [prompt][/dim]\")\n        return\n    path = parts[1]\n    prompt = parts[2] if len(parts) > 2 else None\n    console.print(f\"[dim]Analyzing {path}...[/dim]\")\n    _stream_chunks(analyze_image(path, prompt))\n\n\ndef _show_routing(chunk: dict) -> None:\n    console.print(f\"[dim][{chunk.get('model', '?')}] blast={chunk.get('blast', '?')} {chunk.get('reason', '')}[/dim]\")\n\n\ndef _show_history(client, session_id: str) -> None:\n    msgs = client.chat_history(session_id).get(\"messages\", [])\n    if not msgs:\n        console.print(\"[dim]No messages yet.[/dim]\")\n        return\n    for msg in msgs:\n        role, content = msg.get(\"role\", \"?\"), msg.get(\"content\", \"\")\n        tag = \"[cyan]You[/cyan]\" if role == \"user\" else \"[green]Maggy[/green]\"\n        console.print(f\"  {tag}: {content[:120]}\")\n\n\ndef _show_sessions(client) -> None:\n    sessions = client.chat_sessions()\n    if not sessions:\n        console.print(\"[dim]No chat sessions.[/dim]\")\n        return\n    for s in sessions:\n        sid = s.get(\"id\", \"?\")[:8]\n        proj = s.get(\"project_key\", \"?\")\n        n = s.get(\"messages\", 0)\n        console.print(f\"  [bold]{sid}[/bold] {proj} ({n} msgs)\")\n"
  },
  {
    "path": "maggy/maggy/cli_client.py",
    "content": "\"\"\"HTTP client for Maggy REST API.\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport os\nimport signal\nimport subprocess\nimport sys\nimport time\nfrom urllib.parse import urlparse\n\nimport httpx\nimport typer\n\nfrom maggy.config import CONFIG_DIR\n\nDEFAULT_URL = \"http://127.0.0.1:8080\"\nHEALTH_TIMEOUT = 2.0\nSTART_WAIT = 45.0\nSTART_POLL = 1.0\n\n\nclass MaggyClient:\n    \"\"\"Thin wrapper over Maggy's REST API.\"\"\"\n\n    def __init__(self, base_url: str = DEFAULT_URL):\n        self.base_url = base_url.rstrip(\"/\")\n\n    # ── Server lifecycle ─────────────────────────\n\n    def _check_health(self) -> bool:\n        try:\n            r = httpx.get(\n                f\"{self.base_url}/api/health\",\n                timeout=HEALTH_TIMEOUT,\n            )\n            return r.status_code == 200\n        except (httpx.ConnectError, httpx.ReadTimeout):\n            return False\n\n    def _get_port(self) -> int:\n        parsed = urlparse(self.base_url)\n        return parsed.port or 8080\n\n    def _kill_stale_port(self) -> None:\n        \"\"\"Kill any process holding our port.\"\"\"\n        try:\n            result = subprocess.run(\n                [\"lsof\", \"-ti\", f\":{self._get_port()}\"],\n                capture_output=True, text=True, timeout=5,\n            )\n        except (subprocess.SubprocessError, OSError):\n            return\n        for line in result.stdout.strip().splitlines():\n            try:\n                os.kill(int(line.strip()), signal.SIGTERM)\n            except (ValueError, ProcessLookupError,\n                    PermissionError):\n                continue\n        time.sleep(0.5)\n\n    def _start_server(self) -> None:\n        \"\"\"Spawn server, logging to server.log.\"\"\"\n        CONFIG_DIR.mkdir(parents=True, exist_ok=True)\n        log = open(CONFIG_DIR / \"server.log\", \"a\")\n        subprocess.Popen(\n            [sys.executable, \"-m\", \"maggy.main\"],\n            stdout=log, stderr=log,\n        )\n\n    def ensure_server(self) -> bool:\n        \"\"\"Return True if server is reachable.\"\"\"\n        if self._check_health():\n            return True\n        self._kill_stale_port()\n        self._start_server()\n        deadline = time.monotonic() + START_WAIT\n        while time.monotonic() < deadline:\n            time.sleep(START_POLL)\n            if self._check_health():\n                return True\n        return False\n\n    # ── API calls ────────────────────────────────\n\n    def _handle_error(self, r: httpx.Response) -> None:\n        if r.is_success:\n            return\n        try:\n            detail = r.json().get(\"detail\", r.text)\n        except Exception:\n            detail = r.text\n        from rich.console import Console\n        Console(stderr=True).print(\n            f\"[red]Error {r.status_code}:[/red] {detail}\",\n        )\n        raise typer.Exit(1)\n\n    def get(self, path: str, **params) -> dict | list:\n        r = httpx.get(\n            f\"{self.base_url}{path}\",\n            params=params or None,\n            timeout=30.0,\n        )\n        self._handle_error(r)\n        return r.json()\n\n    def post(self, path: str, body: dict) -> dict:\n        r = httpx.post(\n            f\"{self.base_url}{path}\",\n            json=body,\n            timeout=60.0,\n        )\n        self._handle_error(r)\n        return r.json()\n\n    def health(self) -> dict:\n        return self.get(\"/api/health\")\n\n    def inbox(self, refresh: bool = False) -> dict:\n        return self.get(\"/api/inbox\", refresh=refresh)\n\n    def activity(self) -> dict:\n        return self.get(\"/api/activity\")\n\n    def route(self, blast: int, task_type: str) -> dict:\n        return self.get(\n            \"/api/routing/decide\",\n            blast=blast,\n            task_type=task_type,\n        )\n\n    def budget_summary(self) -> dict:\n        return self.get(\"/api/budget\")\n\n    def competitors_news(self, limit: int = 50) -> list:\n        return self.get(\"/api/competitors/news\", limit=limit)\n\n    def competitors_briefing(self) -> dict:\n        return self.get(\"/api/competitors/news/summary\")\n\n    def models_heatmap(self) -> list:\n        return self.get(\"/api/routing/heatmap\")\n\n    def routing_rules(self) -> dict:\n        return self.get(\"/api/routing/rules\")\n\n    def budget_by_provider(self) -> list:\n        return self.get(\"/api/budget/by-provider\")\n\n    def process_health(self, project: str) -> dict:\n        return self.get(f\"/api/process/health/{project}\")\n\n    def config(self) -> dict:\n        return self.get(\"/api/config\")\n\n    def execute(self, task_id: str, mode: str) -> dict:\n        return self.post(\n            \"/api/execute\",\n            {\"task_id\": task_id, \"mode\": mode},\n        )\n\n    def sessions(self) -> list:\n        return self.get(\"/api/execute/sessions\")\n\n    # ── Chat ──────────────────────────────────────\n\n    def chat_create(self, project_key: str) -> dict:\n        return self.post(\n            \"/api/chat/sessions\",\n            {\"project_key\": project_key},\n        )\n\n    def chat_sessions(self) -> list:\n        return self.get(\"/api/chat/sessions\")\n\n    def chat_history(self, session_id: str) -> dict:\n        return self.get(f\"/api/chat/sessions/{session_id}\")\n\n    def chat_send_stream(\n        self, session_id: str, message: str,\n    ):\n        \"\"\"Yield parsed SSE chunks from chat endpoint.\"\"\"\n        url = (\n            f\"{self.base_url}\"\n            f\"/api/chat/sessions/{session_id}/send\"\n        )\n        with httpx.stream(\n            \"POST\", url,\n            json={\"message\": message},\n            timeout=120.0,\n        ) as r:\n            for line in r.iter_lines():\n                if line.startswith(\"data: \"):\n                    yield json.loads(line[6:])\n\n    def chat_send_routed(\n        self, session_id: str, message: str,\n        blast: int | None = None,\n        allowed_models: list[str] | None = None,\n    ):\n        \"\"\"Yield SSE chunks from routed chat endpoint.\"\"\"\n        url = (\n            f\"{self.base_url}\"\n            f\"/api/chat/sessions/{session_id}/send-routed\"\n        )\n        body: dict = {\"message\": message}\n        if blast is not None:\n            body[\"blast_score\"] = blast\n        if allowed_models:\n            body[\"allowed_models\"] = allowed_models\n        with httpx.stream(\n            \"POST\", url, json=body, timeout=120.0,\n        ) as r:\n            for line in r.iter_lines():\n                if line.startswith(\"data: \"):\n                    yield json.loads(line[6:])\n\n    def detect_project(self, cwd: str) -> str | None:\n        \"\"\"Match cwd against configured codebases.\"\"\"\n        try:\n            cfg = self.config()\n        except Exception:\n            return None\n        for cb in cfg.get(\"codebases\", []):\n            if cwd.startswith(cb.get(\"path\", \"\")):\n                return cb.get(\"key\")\n        return None\n\n    # ── Session management ─────────────────────────\n\n    def spawn(self, task: str, project: str) -> dict:\n        return self.post(\n            \"/api/execute\",\n            {\"task_id\": task, \"mode\": \"tdd\",\n             \"project_key\": project},\n        )\n\n    def all_sessions(self) -> list:\n        \"\"\"Merge chat + executor sessions.\"\"\"\n        chat = self.chat_sessions()\n        executor = self.sessions()\n        combined = []\n        for s in chat:\n            combined.append({\n                \"id\": s.get(\"id\"),\n                \"project\": s.get(\"project_key\", \"\"),\n                \"model\": \"claude\",\n                \"status\": s.get(\"status\", \"\"),\n                \"type\": \"chat\",\n                \"messages\": s.get(\"messages\", 0),\n            })\n        for s in executor:\n            combined.append({\n                \"id\": s.get(\"id\"),\n                \"project\": s.get(\"task_id\", \"\"),\n                \"model\": s.get(\"model\", \"?\"),\n                \"status\": s.get(\"status\", \"\"),\n                \"type\": \"executor\",\n                \"messages\": 0,\n            })\n        return combined\n\n    def kill_session(self, session_id: str) -> dict:\n        r = httpx.delete(\n            f\"{self.base_url}\"\n            f\"/api/chat/sessions/{session_id}\",\n            timeout=10.0,\n        )\n        self._handle_error(r)\n        return r.json()\n\n    # ── Monitor ────────────────────────────────────\n\n    def monitor_status(self) -> dict:\n        return self.get(\"/api/monitor/status\")\n\n    def monitor_start(self) -> dict:\n        return self.post(\"/api/monitor/start\", {})\n\n    def monitor_stop(self) -> dict:\n        return self.post(\"/api/monitor/stop\", {})\n\n    # ── Health ─────────────────────────────────────\n\n    def health_dashboard(self) -> dict:\n        return self.get(\"/api/engram/diagnostics\")\n\n    def engram_diagnostics(self) -> dict:\n        return self.get(\"/api/engram/diagnostics\")\n"
  },
  {
    "path": "maggy/maggy/cli_output.py",
    "content": "\"\"\"Rich terminal formatters for Maggy CLI output.\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport sys\n\nfrom rich.console import Console\nfrom rich.panel import Panel\nfrom rich.table import Table\n\nconsole = Console()\n\n\ndef _is_pipe() -> bool:\n    return not sys.stdout.isatty()\n\n\ndef dump_json(data) -> None:\n    \"\"\"Print raw JSON for piping / --json flag.\"\"\"\n    print(json.dumps(data, indent=2))\n\n\n# ── Status ──────────────────────────────────────\n\n\ndef render_health(data: dict) -> None:\n    t = Table(show_header=False, box=None, padding=(0, 2))\n    t.add_column(style=\"bold\")\n    t.add_column()\n    t.add_row(\"Status\", f\"[green]{data.get('status', '?')}[/green]\")\n    t.add_row(\"Mode\", data.get(\"mode\", \"?\"))\n    t.add_row(\"Org\", data.get(\"org\", \"?\"))\n    t.add_row(\"Codebases\", str(data.get(\"codebases\", 0)))\n    t.add_row(\"Provider\", data.get(\"provider\", \"?\"))\n    console.print(Panel(t, title=\"Maggy Status\", border_style=\"blue\"))\n\n\n# ── Inbox ───────────────────────────────────────\n\n\ndef render_inbox(data: dict) -> None:\n    items = data.get(\"items\", [])\n    if not items:\n        console.print(\"[dim]No tasks in inbox.[/dim]\")\n        return\n    t = Table(title=f\"Inbox ({len(items)} tasks)\")\n    t.add_column(\"#\", style=\"bold\", width=4)\n    t.add_column(\"Title\", min_width=30)\n    t.add_column(\"Labels\")\n    t.add_column(\"Reason\", style=\"dim\")\n    for item in items:\n        labels = \", \".join(item.get(\"labels\", [])[:3])\n        t.add_row(\n            str(item.get(\"rank\", \"\")),\n            item.get(\"title\", \"\")[:60],\n            labels,\n            item.get(\"ai_reason\", \"\")[:40],\n        )\n    console.print(t)\n\n\n# ── Sessions ────────────────────────────────────\n\n\ndef render_sessions(data: dict | list) -> None:\n    items = data if isinstance(data, list) else data.get(\"sessions\", [])\n    if not items:\n        console.print(\"[dim]No active sessions.[/dim]\")\n        return\n    t = Table(title=f\"Active Sessions ({len(items)})\")\n    t.add_column(\"PID\", width=8)\n    t.add_column(\"CLI\")\n    t.add_column(\"Project\")\n    t.add_column(\"Status\")\n    t.add_column(\"Agent\")\n    for s in items:\n        cli = s.get(\"cli\") or s.get(\"tool\") or \"?\"\n        agent = s.get(\"agent_name\") or \"\"\n        t.add_row(\n            str(s.get(\"pid\", \"\")),\n            cli,\n            s.get(\"project\", \"?\"),\n            s.get(\"status\", \"?\"),\n            agent,\n        )\n    console.print(t)\n\n\n# ── Route ───────────────────────────────────────\n\n\ndef _model_name(val) -> str:\n    if isinstance(val, dict):\n        return val.get(\"name\", \"?\")\n    return str(val) if val else \"?\"\n\n\ndef render_route(data: dict) -> None:\n    t = Table(show_header=False, box=None, padding=(0, 2))\n    t.add_column(style=\"bold\")\n    t.add_column()\n    primary = _model_name(data.get(\"primary\"))\n    t.add_row(\"Primary\", f\"[green]{primary}[/green]\")\n    validator = data.get(\"validator\")\n    if validator:\n        t.add_row(\"Validator\", _model_name(validator))\n    fallback = data.get(\"fallback\", [])\n    if fallback:\n        names = [_model_name(f) for f in fallback]\n        t.add_row(\"Fallback\", \" → \".join(names))\n    t.add_row(\"Reason\", str(data.get(\"reason\", \"\")))\n    console.print(Panel(t, title=\"Routing Decision\", border_style=\"yellow\"))\n\n\n# ── Budget ──────────────────────────────────────\n\n\ndef render_budget(data: dict) -> None:\n    spent = data.get(\"spent_today_usd\", 0)\n    limit = data.get(\"daily_limit_usd\", 0)\n    pct = (spent / limit * 100) if limit else 0\n    bar_len = int(pct / 5)\n    color = \"red\" if pct > 80 else \"green\"\n    bar = f\"[{color}]{'█' * bar_len}[/{color}]{'░' * (20 - bar_len)}\"\n\n    t = Table(show_header=False, box=None, padding=(0, 2))\n    t.add_column(style=\"bold\")\n    t.add_column()\n    t.add_row(\"Spent today\", f\"${spent:.2f}\")\n    t.add_row(\"Daily limit\", f\"${limit:.2f}\")\n    t.add_row(\"Utilization\", f\"{pct:.0f}%  {bar}\")\n    t.add_row(\"Status\", data.get(\"status\", \"?\"))\n\n    # Per-provider breakdown if available\n    providers = data.get(\"providers\", [])\n    if providers:\n        t.add_row(\"\", \"\")\n        for p in providers:\n            p_used = p.get(\"used\", 0)\n            p_limit = p.get(\"limit\", 0)\n            t.add_row(\n                p.get(\"name\", \"?\"),\n                f\"${p_used:.2f} / ${p_limit:.2f}\",\n            )\n    console.print(Panel(t, title=\"Budget\", border_style=\"green\"))\n\n\n# ── Competitors ─────────────────────────────────\n\n\ndef render_competitors(news: list) -> None:\n    if not news:\n        console.print(\"[dim]No competitor news.[/dim]\")\n        return\n    t = Table(title=f\"Competitor Intel ({len(news)} items)\")\n    t.add_column(\"Date\", width=12)\n    t.add_column(\"Type\")\n    t.add_column(\"Headline\", min_width=40)\n    for item in news[:20]:\n        t.add_row(\n            item.get(\"date\", \"?\")[:10],\n            item.get(\"event_type\", \"?\"),\n            item.get(\"headline\", \"\")[:60],\n        )\n    console.print(t)\n\n\n# ── Models ──────────────────────────────────────\n\n\ndef render_models(heatmap: list) -> None:\n    if not heatmap:\n        console.print(\"[dim]No model performance data.[/dim]\")\n        return\n    t = Table(title=\"Model Performance Heatmap\")\n    t.add_column(\"Model\")\n    t.add_column(\"Task Type\")\n    t.add_column(\"Reward\", justify=\"right\")\n    for entry in heatmap:\n        reward = entry.get(\"reward\", 0)\n        color = \"green\" if reward >= 0.8 else \"yellow\" if reward >= 0.5 else \"red\"\n        t.add_row(\n            entry.get(\"model\", \"?\"),\n            entry.get(\"task_type\", \"?\"),\n            f\"[{color}]{reward:.2f}[/{color}]\",\n        )\n    console.print(t)\n"
  },
  {
    "path": "maggy/maggy/cli_repl_cmds.py",
    "content": "\"\"\"REPL slash command handlers for Maggy CLI.\"\"\"\n\nfrom __future__ import annotations\n\nfrom dataclasses import dataclass, field\nfrom pathlib import Path\n\nfrom rich.console import Console\nfrom rich.markdown import Markdown\nfrom rich.panel import Panel\nfrom rich.table import Table\n\nconsole = Console()\n\n_KNOWN_MODELS = (\"local\", \"kimi\", \"claude\", \"codex\")\n\n\ndef _call(fn, d=None):\n    try:\n        return fn()\n    except (Exception, SystemExit):\n        return d if d is not None else {}\n\n\n@dataclass\nclass SessionState:\n    \"\"\"Mutable session-level state for REPL.\"\"\"\n\n    session_id: str = \"\"\n    working_dir: str = \"\"\n    allowed_models: list[str] = field(default_factory=list)\n\n\ndef dispatch(cmd: str, client, state: SessionState) -> bool:\n    \"\"\"Route a slash command. Returns True if handled.\"\"\"\n    parts = cmd.strip().split(None, 1)\n    name, args = parts[0].lower(), parts[1] if len(parts) > 1 else \"\"\n    simple = {\n        \"/stats\": cmd_stats, \"/budget\": cmd_budget,\n        \"/route\": cmd_route, \"/models\": cmd_models,\n        \"/config\": cmd_config, \"/health\": cmd_health,\n    }\n    if name in simple:\n        simple[name](client)\n        return True\n    if name == \"/use\":\n        cmd_use(args, state)\n    elif name == \"/claude-md\":\n        cmd_claude_md(state)\n    elif name == \"/help\":\n        cmd_help()\n    else:\n        return False\n    return True\n\n\ndef cmd_stats(client) -> None:\n    b = _call(client.budget_summary)\n    t = Table(title=\"Stats\")\n    t.add_column(\"Metric\", style=\"bold\")\n    t.add_column(\"Value\")\n    t.add_row(\"Spent\", f\"${b.get('spent_today_usd', 0):.2f} / ${b.get('daily_limit_usd', 0):.2f}\")\n    in_t, out_t = b.get(\"input_tokens\", 0), b.get(\"output_tokens\", 0)\n    if in_t or out_t:\n        t.add_row(\"Tokens\", f\"{in_t:,} in / {out_t:,} out\")\n    t.add_row(\"Status\", b.get(\"status\", \"?\"))\n    for p in _call(client.budget_by_provider, []):\n        t.add_row(f\"  {p.get('provider', '?')}\", f\"${p.get('spent_usd', 0):.2f}\")\n    for h in _call(client.models_heatmap, [])[:8]:\n        r, c = h.get(\"avg_reward\", 0), \"green\" if h.get(\"avg_reward\", 0) >= 0.8 else \"yellow\"\n        t.add_row(f\"  {h.get('model', '?')} ({h.get('task_type', '')})\", f\"[{c}]{r:.2f}[/{c}] ({h.get('samples', 0)})\")\n    console.print(t)\n\n\ndef cmd_budget(client) -> None:\n    b = _call(client.budget_summary)\n    spent, limit = b.get(\"spent_today_usd\", 0), b.get(\"daily_limit_usd\", 0)\n    pct = (spent / limit * 100) if limit else 0\n    bl, c = min(20, int(pct / 5)), \"red\" if pct > 80 else \"green\"\n    bar = f\"[{c}]{'█' * bl}[/{c}]{'░' * (20 - bl)}\"\n    t = Table(show_header=False, box=None, padding=(0, 2))\n    t.add_column(style=\"bold\")\n    t.add_column()\n    if b.get(\"plan\") == \"subscription\":\n        t.add_row(\"Plan\", \"[green]Subscription[/green]\")\n    else:\n        t.add_row(\"Spent\", f\"${spent:.2f} / ${limit:.2f}\")\n    t.add_row(\"Usage\", f\"{pct:.0f}%  {bar}\")\n    t.add_row(\"Status\", b.get(\"status\", \"?\"))\n    for p in _call(client.budget_by_provider, []):\n        t.add_row(p.get(\"provider\", \"?\"), f\"${p.get('spent_usd', 0):.2f}\")\n    console.print(Panel(t, title=\"Budget\", border_style=\"green\"))\n\n\ndef cmd_route(client) -> None:\n    data = _call(client.routing_rules)\n    t = Table(title=f\"Routing ({data.get('mode', '?')})\")\n    t.add_column(\"Task Type\", style=\"bold\")\n    t.add_column(\"Model\")\n    t.add_column(\"Reason\", style=\"dim\")\n    for tt, info in data.get(\"task_type_overrides\", {}).items():\n        t.add_row(tt, info.get(\"model\", \"?\"), info.get(\"reason\", \"\"))\n    console.print(t)\n    console.print(\"[dim]Blast: 1-3 cheap | 4-6 medium | 7-10 premium[/dim]\")\n    perf = data.get(\"model_performance\", {})\n    if not perf:\n        return\n    pt = Table(title=\"Model Performance\")\n    pt.add_column(\"Model\", style=\"bold\")\n    pt.add_column(\"Strengths\")\n    pt.add_column(\"Rate\", justify=\"right\")\n    for model, info in perf.items():\n        pt.add_row(model, \", \".join(info.get(\"strengths\", [])), f\"{info.get('success_rate', 0):.0%}\")\n    console.print(pt)\n\n\ndef cmd_models(client) -> None:\n    heatmap = _call(client.models_heatmap, [])\n    t = Table(title=\"Model Rewards\")\n    for col in (\"Model\", \"Task Type\", \"Blast Tier\"):\n        t.add_column(col)\n    t.add_column(\"Reward\", justify=\"right\")\n    t.add_column(\"N\", justify=\"right\")\n    if not heatmap:\n        for m in _KNOWN_MODELS:\n            t.add_row(m, \"-\", \"-\", \"-\", \"0\")\n    else:\n        for h in heatmap:\n            r = h.get(\"avg_reward\", 0)\n            c = \"green\" if r >= 0.8 else \"yellow\" if r >= 0.5 else \"red\"\n            t.add_row(h.get(\"model\", \"?\"), h.get(\"task_type\", \"?\"), h.get(\"blast_tier\", \"?\"), f\"[{c}]{r:.2f}[/{c}]\", str(h.get(\"samples\", 0)))\n    console.print(t)\n\n\ndef cmd_use(args: str, state: SessionState) -> None:\n    \"\"\"Set allowed models for this session.\"\"\"\n    if not args or args.strip().lower() == \"all\":\n        state.allowed_models = []\n        console.print(\"[dim]Routing: all models enabled[/dim]\")\n        return\n    models = [m.strip() for m in args.split(\",\") if m.strip()]\n    bad = [m for m in models if m not in _KNOWN_MODELS]\n    if bad:\n        console.print(f\"[yellow]Unknown: {', '.join(bad)}. Known: {', '.join(_KNOWN_MODELS)}[/yellow]\")\n    state.allowed_models = models\n    console.print(f\"[dim]Routing restricted to: {', '.join(models)}[/dim]\")\n\n\ndef cmd_config(client) -> None:\n    \"\"\"Show configuration summary.\"\"\"\n    cfg = _call(client.config)\n    t = Table(show_header=False, box=None, padding=(0, 2))\n    t.add_column(style=\"bold\")\n    t.add_column()\n    cbs = cfg.get(\"codebases\", [])\n    t.add_row(\"Codebases\", str(len(cbs)))\n    for cb in cbs[:5]:\n        t.add_row(f\"  {cb.get('key', '?')}\", cb.get(\"path\", \"\"))\n    t.add_row(\"Routing\", cfg.get(\"routing\", {}).get(\"mode\", \"dynamic\"))\n    t.add_row(\"Limit\", f\"${cfg.get('budget', {}).get('daily_limit_usd', 0):.2f}\")\n    console.print(Panel(t, title=\"Config\", border_style=\"blue\"))\n\n\ndef cmd_claude_md(state: SessionState) -> None:\n    \"\"\"Show project's CLAUDE.md.\"\"\"\n    wd = Path(state.working_dir)\n    for name in (\"CLAUDE.md\", \".claude/CLAUDE.md\"):\n        path = wd / name\n        if path.exists():\n            console.print(Markdown(path.read_text()))\n            return\n    console.print(\"[dim]CLAUDE.md not found in project.[/dim]\")\n\n\ndef cmd_health(client) -> None:\n    \"\"\"Memory system health dashboard.\"\"\"\n    data = _call(client.health_dashboard)\n    eng = data if \"health_score\" in data else data.get(\"engram\", {})\n    mn, score = data.get(\"mnemos\", {}), eng.get(\"health_score\", 0)\n    c = \"green\" if score >= 0.7 else \"yellow\" if score >= 0.4 else \"red\"\n    t = Table(show_header=False, box=None, padding=(0, 2))\n    t.add_column(style=\"bold\")\n    t.add_column()\n    t.add_row(\"Engram\", f\"[{c}]{score:.0%}[/{c}] ({eng.get('active', 0)}/{eng.get('total', 0)})\")\n    t.add_row(\"Mnemos\", f\"{mn.get('state', '?')} ({mn.get('composite', 0):.2f})\")\n    console.print(Panel(t, title=\"Health\", border_style=\"green\"))\n\n\n_HELP = \"\"\"\\\n[bold]Commands:[/bold]\n  /stats   Budget+perf      /budget  Breakdown       /route   Rules+tiers\n  /models  Reward heatmap   /health  Memory health   /monitor Trackers\n  /screenshot F  Analyze image with Qwen3-VL         /claude-md CLAUDE.md\n  /use M   Restrict models  /config  Settings        /blast N Override\n  /history Messages         /sessions List           /clear   Screen\n  /quit    Exit             /help    This help\"\"\"\n\n\ndef cmd_help() -> None:\n    console.print(_HELP)\n"
  },
  {
    "path": "maggy/maggy/cli_sessions.py",
    "content": "\"\"\"Session management for Maggy CLI — spawn, list, kill.\"\"\"\n\nfrom __future__ import annotations\n\nfrom rich.console import Console\nfrom rich.table import Table\n\nconsole = Console()\n\n\ndef spawn_session(client, task: str, project: str) -> None:\n    \"\"\"Spawn a background execution session.\"\"\"\n    data = client.spawn(task, project)\n    sid = data.get(\"session_id\", \"?\")\n    console.print(\n        f\"[green]Spawned[/green] session \"\n        f\"[bold]{sid}[/bold] for {project}\",\n    )\n\n\ndef list_all(client) -> None:\n    \"\"\"Show all sessions (chat + executor).\"\"\"\n    sessions = client.all_sessions()\n    if not sessions:\n        console.print(\"[dim]No active sessions.[/dim]\")\n        return\n    t = Table(title=\"All Sessions\")\n    t.add_column(\"ID\", width=12)\n    t.add_column(\"Project\")\n    t.add_column(\"Model\")\n    t.add_column(\"Type\")\n    t.add_column(\"Status\")\n    for s in sessions:\n        t.add_row(\n            str(s.get(\"id\", \"?\")),\n            s.get(\"project\", \"?\"),\n            s.get(\"model\", \"?\"),\n            s.get(\"type\", \"?\"),\n            s.get(\"status\", \"?\"),\n        )\n    console.print(t)\n\n\ndef kill_session(client, session_id: str) -> None:\n    \"\"\"Kill a session by ID.\"\"\"\n    client.kill_session(session_id)\n    console.print(\n        f\"[yellow]Killed[/yellow] session [bold]{session_id}[/bold]\",\n    )\n"
  },
  {
    "path": "maggy/maggy/cli_welcome.py",
    "content": "\"\"\"Rich welcome banner for Maggy CLI startup.\"\"\"\n\nfrom __future__ import annotations\n\nimport os\n\nfrom rich.console import Console\nfrom rich.panel import Panel\nfrom rich.table import Table\n\nconsole = Console()\n\nVERSION = \"0.5\"\n\n\ndef render_welcome(\n    project: str, session: dict, client,\n) -> None:\n    \"\"\"Print a rich 2-column welcome panel.\"\"\"\n    t = Table(show_header=False, box=None, padding=(0, 2))\n    t.add_column(style=\"bold\")\n    t.add_column()\n    _add_project_rows(t, project, session)\n    _add_system_rows(t, client, session)\n    label = \"Resuming\" if session.get(\"messages\", 0) else \"New\"\n    title = f\"Maggy v{VERSION} - {label}\"\n    console.print(Panel(t, title=title, border_style=\"cyan\"))\n    console.print(\n        \"[dim]/help for commands | /stats for budget[/dim]\\n\",\n    )\n\n\ndef _add_project_rows(\n    t: Table, project: str, session: dict,\n) -> None:\n    \"\"\"Left-side project info.\"\"\"\n    wd = session.get(\"working_dir\") or os.getcwd()\n    short_wd = _shorten(wd, 35)\n    msgs = session.get(\"messages\", 0)\n    sid = session.get(\"id\", \"?\")[:8]\n    t.add_row(\"Project\", f\"[bold]{project}[/bold]\")\n    t.add_row(\"Dir\", short_wd)\n    t.add_row(\"Session\", f\"{sid} ({msgs} msgs)\")\n\n\n_KNOWN_MODELS = (\"local\", \"kimi\", \"gpt\", \"claude\", \"codex\")\n\n\ndef _add_system_rows(\n    t: Table, client, session: dict,\n) -> None:\n    \"\"\"Right-side system state.\"\"\"\n    budget = _safe_call(client.budget_summary)\n    if isinstance(budget, dict) and budget.get(\"plan\") == \"subscription\":\n        t.add_row(\"Budget\", \"[green]Subscription[/green]\")\n    else:\n        spent = budget.get(\"spent_today_usd\", 0) if isinstance(budget, dict) else 0\n        limit = budget.get(\"daily_limit_usd\", 0) if isinstance(budget, dict) else 0\n        t.add_row(\"Budget\", f\"${spent:.2f} / ${limit:.2f}\")\n    models = _safe_call(client.models_heatmap)\n    count = len(models) if models else len(_KNOWN_MODELS)\n    label = f\"{len(models)} tracked\" if models else f\"{count} available\"\n    t.add_row(\"Models\", label)\n    status = budget.get(\"status\", \"?\") if isinstance(budget, dict) else \"?\"\n    t.add_row(\"Status\", f\"[green]{status}[/green]\")\n    _add_health_row(t, client)\n\n\ndef _add_health_row(t: Table, client) -> None:\n    \"\"\"Show engram health score inline.\"\"\"\n    diag = _safe_call(client.engram_diagnostics)\n    if not isinstance(diag, dict):\n        return\n    score = diag.get(\"health_score\", 0)\n    color = \"green\" if score >= 0.7 else \"yellow\" if score >= 0.4 else \"red\"\n    t.add_row(\"Memory\", f\"[{color}]{score:.0%}[/{color}]\")\n\n\ndef _safe_call(fn):\n    \"\"\"Call a client method, return empty on failure.\"\"\"\n    try:\n        return fn() or []\n    except Exception:\n        return []\n\n\ndef _shorten(path: str, max_len: int) -> str:\n    \"\"\"Truncate long paths with ellipsis.\"\"\"\n    if len(path) <= max_len:\n        return path\n    return \"...\" + path[-(max_len - 3) :]\n"
  },
  {
    "path": "maggy/maggy/config.py",
    "content": "\"\"\"Config loader for Maggy — reads ~/.maggy/config.yaml with env overrides.\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nimport tempfile\nfrom dataclasses import dataclass, field\nfrom pathlib import Path\nfrom typing import TYPE_CHECKING, Any\n\nimport yaml\n\nCONFIG_DIR = Path(os.environ.get(\"MAGGY_HOME\", \"~/.maggy\")).expanduser()\nCONFIG_PATH = CONFIG_DIR / \"config.yaml\"\n\nif TYPE_CHECKING:\n    from maggy.budget import ProviderBudget\n\n\ndef _default_storage_path() -> str:\n    return _safe_storage_path(CONFIG_DIR / \"maggy.db\")\n\n\ndef _safe_storage_path(path: str | Path) -> str:\n    target = Path(path).expanduser()\n    try:\n        target.parent.mkdir(parents=True, exist_ok=True)\n        probe = target.parent / \".write-test\"\n        probe.write_text(\"\")\n        probe.unlink()\n        return str(target)\n    except OSError:\n        fallback = Path(tempfile.gettempdir()) / \"maggy\" / \"maggy.db\"\n        fallback.parent.mkdir(parents=True, exist_ok=True)\n        return str(fallback)\n\n\n@dataclass\nclass GitHubConfig:\n    org: str = \"\"\n    repos: list[str] = field(default_factory=list)\n    labels: list[str] = field(default_factory=list)\n    token: str = \"\"\n\n\n@dataclass\nclass AsanaConfig:\n    workspace_id: str = \"\"\n    boards: dict[str, str] = field(default_factory=dict)\n    token: str = \"\"\n\n\n@dataclass\nclass LinearConfig:\n    workspace: str = \"\"\n    token: str = \"\"\n\n\n@dataclass\nclass IssueTrackerConfig:\n    provider: str = \"github\"\n    github: GitHubConfig = field(default_factory=GitHubConfig)\n    asana: AsanaConfig = field(default_factory=AsanaConfig)\n    linear: LinearConfig = field(default_factory=LinearConfig)\n\n\n@dataclass\nclass CodebaseConfig:\n    path: str\n    key: str\n\n\n@dataclass\nclass ProjectConfig:\n    name: str\n    repo: str\n    path: str\n    default_branch: str\n    icpg: bool = True\n    cikg: bool = False\n\n\n@dataclass\nclass OKRItem:\n    id: str\n    title: str\n    keywords: list[str] = field(default_factory=list)\n\n\n@dataclass\nclass OKRConfig:\n    source: str = \"skip\"\n    items: list[OKRItem] = field(default_factory=list)\n\n\n@dataclass\nclass CompetitorsConfig:\n    categories: list[str] = field(default_factory=list)\n    seed: list[str] = field(default_factory=list)\n\n\n@dataclass\nclass AIConfig:\n    provider: str = \"anthropic\"\n    model: str = \"claude-sonnet-4-5-20250929\"\n    api_key: str = \"\"\n    max_budget_usd_per_execute: float = 5.0\n\n\n@dataclass\nclass StorageConfig:\n    backend: str = \"sqlite\"\n    path: str = field(default_factory=_default_storage_path)\n\n\n@dataclass\nclass DashboardConfig:\n    host: str = \"127.0.0.1\"\n    port: int = 8080\n    auth_mode: str = \"local\"\n    api_key: str = \"\"\n\n\n@dataclass\nclass OrgConfig:\n    name: str = \"Your Org\"\n    domain: str = \"\"\n\n\n@dataclass\nclass BootstrapConfig:\n    path: str = \"\"\n\n\n@dataclass\nclass ModelTierConfig:\n    name: str = \"\"\n    provider: str = \"\"\n    model: str = \"\"\n    complexity_range: list[int] = field(default_factory=lambda: [0, 10])\n    strengths: list[str] = field(default_factory=list)\n    cost_per_1k: float = 0.0\n\n\n@dataclass\nclass BudgetConfig:\n    daily_limit_usd: float = 10.0\n    max_spend_per_task: float = 5.0\n    warning_threshold: float = 0.8\n    plan: str = \"daily\"\n    providers: list[\"ProviderBudget\"] = field(default_factory=list)\n\n\n@dataclass\nclass RoutingConfig:\n    mode: str = \"dynamic\"\n    tiers: list[ModelTierConfig] = field(default_factory=list)\n\n\n@dataclass\nclass MeshConfig:\n    enabled: bool = False\n    peer_id: str = \"\"\n    port: int = 8080\n    org_key_secret: str = \"\"\n    orgs: list[str] = field(default_factory=list)\n    exclude_orgs: list[str] = field(default_factory=list)\n    manual_peers: list[str] = field(default_factory=list)\n    tunnel_url: str = \"\"\n    git_discovery: bool = True\n    share_interval: int = 600\n\n\n@dataclass\nclass HeartbeatConfig:\n    enabled: bool = True\n    history_interval: int = 1800\n    engram_interval: int = 3600\n    improve_interval: int = 3600\n    mesh_interval: int = 300\n\n\n@dataclass\nclass MaggyConfig:\n    org: OrgConfig = field(default_factory=OrgConfig)\n    issue_tracker: IssueTrackerConfig = field(default_factory=IssueTrackerConfig)\n    codebases: list[CodebaseConfig] = field(default_factory=list)\n    projects: list[ProjectConfig] = field(default_factory=list)\n    competitors: CompetitorsConfig = field(default_factory=CompetitorsConfig)\n    okrs: OKRConfig = field(default_factory=OKRConfig)\n    ai: AIConfig = field(default_factory=AIConfig)\n    storage: StorageConfig = field(default_factory=StorageConfig)\n    dashboard: DashboardConfig = field(default_factory=DashboardConfig)\n    bootstrap: BootstrapConfig = field(default_factory=BootstrapConfig)\n    budget: BudgetConfig = field(default_factory=BudgetConfig)\n    routing: RoutingConfig = field(default_factory=RoutingConfig)\n    mesh: MeshConfig = field(default_factory=MeshConfig)\n    heartbeat: HeartbeatConfig = field(default_factory=HeartbeatConfig)\n\n    def codebase_paths(self) -> dict[str, Path]:\n        \"\"\"Return {key: expanded_path} for all configured codebases.\"\"\"\n        return {c.key: Path(c.path).expanduser() for c in self.codebases}\n\n    def resolve_bootstrap_path(self) -> Path | None:\n        \"\"\"Find Maggy install. Checks config, then ~/.claude/.bootstrap-dir.\"\"\"\n        if self.bootstrap.path:\n            return Path(self.bootstrap.path).expanduser()\n        marker = Path.home() / \".claude\" / \".bootstrap-dir\"\n        if marker.exists():\n            return Path(marker.read_text().strip()).expanduser()\n        return None\n\n\ndef _merge_env(cfg: MaggyConfig) -> MaggyConfig:\n    \"\"\"Override config with env vars where defined. Env wins over file.\"\"\"\n    cfg.issue_tracker.github.token = os.environ.get(\"GITHUB_TOKEN\", cfg.issue_tracker.github.token)\n    # Fall back to git credential helper if no env var\n    if not cfg.issue_tracker.github.token:\n        cfg.issue_tracker.github.token = _git_credential_token()\n    cfg.issue_tracker.asana.token = os.environ.get(\"ASANA_API_KEY\", cfg.issue_tracker.asana.token)\n    cfg.issue_tracker.linear.token = os.environ.get(\"LINEAR_API_KEY\", cfg.issue_tracker.linear.token)\n    cfg.ai.api_key = os.environ.get(\"ANTHROPIC_API_KEY\", cfg.ai.api_key)\n    cfg.dashboard.api_key = os.environ.get(\"MAGGY_API_KEY\", cfg.dashboard.api_key)\n    cfg.mesh.org_key_secret = os.environ.get(\"MAGGY_MESH_SECRET\", cfg.mesh.org_key_secret)\n    return cfg\n\n\ndef _git_credential_token() -> str:\n    \"\"\"Read GitHub token from git credential helper.\"\"\"\n    from maggy.discovery import discover_git_token\n    return discover_git_token()\n\n\ndef _from_dict(data: dict[str, Any]) -> MaggyConfig:\n    \"\"\"Build MaggyConfig from loaded YAML dict. Tolerates missing sections.\"\"\"\n    from maggy.budget import ProviderBudget\n\n    it_raw = data.get(\"issue_tracker\") or {}\n    tracker = IssueTrackerConfig(\n        provider=it_raw.get(\"provider\", \"github\"),\n        github=GitHubConfig(**(it_raw.get(\"github\") or {})),\n        asana=AsanaConfig(**(it_raw.get(\"asana\") or {})),\n        linear=LinearConfig(**(it_raw.get(\"linear\") or {})),\n    )\n\n    okr_raw = data.get(\"okrs\") or {}\n    okrs = OKRConfig(\n        source=okr_raw.get(\"source\", \"skip\"),\n        items=[OKRItem(**item) for item in (okr_raw.get(\"items\") or [])],\n    )\n\n    routing_raw = data.get(\"routing\") or {}\n    routing = RoutingConfig(\n        mode=routing_raw.get(\"mode\", \"dynamic\"),\n        tiers=[\n            ModelTierConfig(**t)\n            for t in (routing_raw.get(\"tiers\") or [])\n        ],\n    )\n    budget_raw = data.get(\"budget\") or {}\n    providers = [\n        ProviderBudget(**item)\n        for item in (budget_raw.get(\"providers\") or [])\n    ]\n    storage_raw = data.get(\"storage\") or {}\n\n    return MaggyConfig(\n        org=OrgConfig(**(data.get(\"org\") or {})),\n        issue_tracker=tracker,\n        codebases=[CodebaseConfig(**c) for c in (data.get(\"codebases\") or [])],\n        projects=[ProjectConfig(**p) for p in (data.get(\"projects\") or [])],\n        competitors=CompetitorsConfig(**(data.get(\"competitors\") or {})),\n        okrs=okrs,\n        ai=AIConfig(**(data.get(\"ai\") or {})),\n        storage=StorageConfig(\n            backend=storage_raw.get(\"backend\", \"sqlite\"),\n            path=_safe_storage_path(\n                storage_raw.get(\"path\", _default_storage_path())\n            ),\n        ),\n        dashboard=DashboardConfig(**(data.get(\"dashboard\") or {})),\n        bootstrap=BootstrapConfig(**(data.get(\"bootstrap\") or {})),\n        budget=BudgetConfig(\n            daily_limit_usd=budget_raw.get(\"daily_limit_usd\", 10.0),\n            warning_threshold=budget_raw.get(\"warning_threshold\", 0.8),\n            providers=providers,\n        ),\n        routing=routing,\n        mesh=MeshConfig(**(data.get(\"mesh\") or {})),\n        heartbeat=HeartbeatConfig(**(data.get(\"heartbeat\") or {})),\n    )\n\n\n_CACHED: MaggyConfig | None = None\n\n\ndef _has_provider_credentials(cfg: MaggyConfig) -> bool:\n    \"\"\"Check if config has full provider credentials.\"\"\"\n    if cfg.issue_tracker.provider == \"github\":\n        gh = cfg.issue_tracker.github\n        return bool(gh.org and gh.repos and gh.token)\n    if cfg.issue_tracker.provider == \"asana\":\n        az = cfg.issue_tracker.asana\n        return bool(az.workspace_id and az.token)\n    return False\n\n\ndef _has_cli_history(\n    home: Path | None = None,\n) -> bool:\n    \"\"\"Check if any CLI data directories exist.\"\"\"\n    root = home or Path.home()\n    for d in (\".claude\", \".codex\", \".kimi\"):\n        if (root / d).exists():\n            return True\n    return False\n\n\ndef auto_configure(\n    home: Path | None = None,\n    persist: bool = True,\n) -> MaggyConfig:\n    \"\"\"Build config from auto-discovery.\"\"\"\n    from maggy.discovery import full_discovery\n    result = full_discovery(home)\n    cfg = MaggyConfig(\n        codebases=[\n            CodebaseConfig(path=r[\"path\"], key=r[\"key\"])\n            for r in result.repos\n        ],\n    )\n    if result.github_org:\n        cfg.issue_tracker.github.org = result.github_org\n    # Auto-populate repos matching the primary org\n    if result.github_org:\n        cfg.issue_tracker.github.repos = _repos_for_org(\n            result.repos, result.github_org,\n        )\n    if persist:\n        save(cfg)\n    return _merge_env(cfg)\n\n\ndef _repos_for_org(\n    repos: list[dict], org: str,\n) -> list[str]:\n    \"\"\"Filter repo names belonging to a GitHub org.\"\"\"\n    from maggy.discovery import infer_github_org\n    matched: list[str] = []\n    for repo in repos:\n        repo_org = infer_github_org(Path(repo[\"path\"]))\n        if repo_org == org:\n            matched.append(repo[\"key\"])\n    return matched\n\n\ndef load(refresh: bool = False) -> MaggyConfig:\n    \"\"\"Load config from ~/.maggy/config.yaml, with env var overrides. Cached.\"\"\"\n    global _CACHED\n    if _CACHED is not None and not refresh:\n        return _CACHED\n\n    if not CONFIG_PATH.exists():\n        _CACHED = _merge_env(MaggyConfig())\n        return _CACHED\n\n    with open(CONFIG_PATH) as f:\n        data = yaml.safe_load(f) or {}\n    _CACHED = _merge_env(_from_dict(data))\n    return _CACHED\n\n\ndef save(cfg: MaggyConfig) -> None:\n    \"\"\"Write config back to ~/.maggy/config.yaml.\"\"\"\n    CONFIG_DIR.mkdir(parents=True, exist_ok=True)\n    # Convert dataclass → dict, strip empty tokens (they come from env)\n    from dataclasses import asdict\n    d = asdict(cfg)\n    # Don't persist tokens — those come from env\n    for section in (\"github\", \"asana\", \"linear\"):\n        d.get(\"issue_tracker\", {}).get(section, {}).pop(\"token\", None)\n    d.get(\"ai\", {}).pop(\"api_key\", None)\n    d.get(\"dashboard\", {}).pop(\"api_key\", None)\n    with open(CONFIG_PATH, \"w\") as f:\n        yaml.safe_dump(d, f, sort_keys=False)\n    global _CACHED\n    _CACHED = None  # force reload on next load()\n\n\ndef is_configured() -> bool:\n    \"\"\"Check if Maggy has enough to be useful.\n\n    Full mode: provider credentials present.\n    Local mode: CLI history dirs exist (zero-config).\n    \"\"\"\n    if CONFIG_PATH.exists():\n        cfg = load(refresh=True)\n        if _has_provider_credentials(cfg):\n            return True\n    if _has_cli_history():\n        return True\n    return False\n"
  },
  {
    "path": "maggy/maggy/contracts/__init__.py",
    "content": "\"\"\"Contracts exports.\"\"\"\n\nfrom .generator import ContractGenerator\n\n__all__ = [\"ContractGenerator\"]\n"
  },
  {
    "path": "maggy/maggy/contracts/generator.py",
    "content": "\"\"\"Generate lightweight contract tests from postconditions.\"\"\"\n\nfrom __future__ import annotations\n\nimport re\n\n\nclass ContractGenerator:\n    def from_postcondition(self, postcondition: str, symbol: str) -> str:\n        test_name = _test_name(symbol)\n        return (\n            f\"def {test_name}() -> None:\\n\"\n            f'    \"\"\"Contract for {symbol}.\"\"\"\\n'\n            f\"    # Postcondition: {postcondition}\\n\"\n            f\"    raise NotImplementedError(\"\n            f\"\\\"Verify: {postcondition}\\\")\\n\"\n        )\n\n\ndef _test_name(symbol: str) -> str:\n    short = symbol.split(\".\")[-2:]\n    slug = \"_\".join(short).lower()\n    slug = re.sub(r\"[^a-z0-9_]+\", \"_\", slug)\n    return f\"test_{slug}_contract\"\n"
  },
  {
    "path": "maggy/maggy/coordination/__init__.py",
    "content": "\n"
  },
  {
    "path": "maggy/maggy/coordination/lock_manager.py",
    "content": "\"\"\"SQLite-backed file locks for multi-agent coordination.\"\"\"\n\nfrom __future__ import annotations\n\nimport sqlite3\nfrom contextlib import contextmanager\nfrom datetime import datetime, timedelta, timezone\nfrom pathlib import Path\nfrom typing import Iterator\n\nLOCK_TTL = timedelta(minutes=30)\nSCHEMA = \"\"\"\nCREATE TABLE IF NOT EXISTS locks (\n    file_path TEXT NOT NULL,\n    agent_id TEXT NOT NULL,\n    acquired_at TEXT NOT NULL,\n    expires_at TEXT NOT NULL\n);\nCREATE UNIQUE INDEX IF NOT EXISTS idx_locks_file_path\n    ON locks(file_path);\nCREATE INDEX IF NOT EXISTS idx_locks_expires_at\n    ON locks(expires_at);\n\"\"\"\n\n\n@contextmanager\ndef _connect(path: Path) -> Iterator[sqlite3.Connection]:\n    path.parent.mkdir(parents=True, exist_ok=True)\n    conn = sqlite3.connect(str(path), timeout=30.0)\n    conn.execute(\"PRAGMA journal_mode=WAL\")\n    conn.execute(\"PRAGMA busy_timeout=30000\")\n    conn.row_factory = sqlite3.Row\n    try:\n        yield conn\n    finally:\n        conn.close()\n\n\nclass LockManager:\n    def __init__(self, db_path: Path):\n        self._db_path = db_path\n        self._init_db()\n\n    def acquire(self, file_path: str, agent_id: str) -> bool:\n        now, expires = _timestamps()\n        with _connect(self._db_path) as conn:\n            self._expire_locks(conn, now)\n            try:\n                conn.execute(\n                    \"INSERT INTO locks(file_path, agent_id, acquired_at, expires_at) \"\n                    \"VALUES (?, ?, ?, ?)\",\n                    (file_path, agent_id, now, expires),\n                )\n                conn.commit()\n                return True\n            except sqlite3.IntegrityError:\n                row = conn.execute(\n                    \"SELECT agent_id FROM locks WHERE file_path = ?\",\n                    (file_path,),\n                ).fetchone()\n                if row and row[\"agent_id\"] == agent_id:\n                    conn.execute(\n                        \"UPDATE locks SET acquired_at = ?, expires_at = ? \"\n                        \"WHERE file_path = ?\",\n                        (now, expires, file_path),\n                    )\n                    conn.commit()\n                    return True\n                return False\n\n    def release(self, file_path: str, agent_id: str) -> bool:\n        with _connect(self._db_path) as conn:\n            self._expire_locks(conn, _now())\n            cur = conn.execute(\n                \"DELETE FROM locks WHERE file_path = ? AND agent_id = ?\",\n                (file_path, agent_id),\n            )\n            conn.commit()\n        return cur.rowcount > 0\n\n    def release_all(self, agent_id: str) -> int:\n        with _connect(self._db_path) as conn:\n            self._expire_locks(conn, _now())\n            cur = conn.execute(\"DELETE FROM locks WHERE agent_id = ?\", (agent_id,))\n            conn.commit()\n        return cur.rowcount\n\n    def conflicts(self, file_paths: list[str]) -> list[str]:\n        if not file_paths:\n            return []\n        marks = \", \".join(\"?\" for _ in file_paths)\n        with _connect(self._db_path) as conn:\n            self._expire_locks(conn, _now())\n            rows = conn.execute(\n                f\"SELECT file_path FROM locks WHERE file_path IN ({marks})\",\n                file_paths,\n            ).fetchall()\n        locked = {row[\"file_path\"] for row in rows}\n        return [path for path in file_paths if path in locked]\n\n    def _expire_locks(self, conn: sqlite3.Connection, now: str) -> None:\n        conn.execute(\"DELETE FROM locks WHERE expires_at <= ?\", (now,))\n\n    def _init_db(self) -> None:\n        with _connect(self._db_path) as conn:\n            conn.executescript(SCHEMA)\n\n\ndef _now() -> str:\n    return datetime.now(timezone.utc).isoformat()\n\n\ndef _timestamps() -> tuple[str, str]:\n    now = datetime.now(timezone.utc)\n    return now.isoformat(), (now + LOCK_TTL).isoformat()\n"
  },
  {
    "path": "maggy/maggy/deploy.py",
    "content": "\"\"\"Deploy orchestrator — manages Vercel session containers.\"\"\"\n\nfrom __future__ import annotations\n\nimport logging\nfrom dataclasses import dataclass, field\nfrom datetime import datetime, timezone\n\nlogger = logging.getLogger(__name__)\n\n\n@dataclass\nclass DeploySession:\n    \"\"\"Represents a running deploy session.\"\"\"\n\n    session_id: str\n    project: str\n    branch: str\n    status: str = \"pending\"  # pending | building | live | failed\n    url: str = \"\"\n    created_at: str = field(\n        default_factory=lambda: datetime.now(\n            timezone.utc\n        ).isoformat()\n    )\n\n\nclass DeployService:\n    \"\"\"Manages deploy sessions (stub for container orchestration).\"\"\"\n\n    def __init__(self):\n        self._sessions: dict[str, DeploySession] = {}\n\n    def create_session(\n        self, project: str, branch: str,\n    ) -> DeploySession:\n        \"\"\"Create a new deploy session.\"\"\"\n        import uuid\n        sid = str(uuid.uuid4())[:8]\n        session = DeploySession(\n            session_id=sid,\n            project=project,\n            branch=branch,\n            status=\"building\",\n        )\n        self._sessions[sid] = session\n        logger.info(\"Deploy session %s created for %s:%s\",\n                     sid, project, branch)\n        return session\n\n    def get_session(self, sid: str) -> DeploySession | None:\n        return self._sessions.get(sid)\n\n    def list_sessions(self) -> list[DeploySession]:\n        return list(self._sessions.values())\n\n    def update_status(\n        self, sid: str, status: str, url: str = \"\",\n    ) -> DeploySession | None:\n        \"\"\"Update session status.\"\"\"\n        session = self._sessions.get(sid)\n        if not session:\n            return None\n        session.status = status\n        if url:\n            session.url = url\n        return session\n\n    def teardown(self, sid: str) -> bool:\n        \"\"\"Remove a deploy session.\"\"\"\n        if sid in self._sessions:\n            del self._sessions[sid]\n            return True\n        return False\n"
  },
  {
    "path": "maggy/maggy/discovery.py",
    "content": "\"\"\"Auto-discovery — detects local CLIs, repos, and dev environment.\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport logging\nimport os\nimport shutil\nimport subprocess\nfrom dataclasses import dataclass, field\nfrom datetime import datetime, timezone\nfrom pathlib import Path\n\nlogger = logging.getLogger(__name__)\n\nSCAN_DIRS = [\n    \"Documents\", \"dev\", \"projects\", \"code\", \"src\",\n    \"workspace\", \"repos\", \"work\",\n]\n\nCLI_NAMES = [\"claude\", \"codex\", \"kimi\"]\n\n\n@dataclass\nclass DiscoveryResult:\n    \"\"\"Everything auto-discovered about the local env.\"\"\"\n\n    clis: dict[str, str] = field(default_factory=dict)\n    cli_auth: dict[str, bool] = field(\n        default_factory=dict,\n    )\n    repos: list[dict] = field(default_factory=list)\n    active_projects: list[str] = field(\n        default_factory=list,\n    )\n    tokens: dict[str, bool] = field(\n        default_factory=dict,\n    )\n    github_org: str = \"\"\n    github_orgs: list[str] = field(\n        default_factory=list,\n    )\n    timestamp: str = \"\"\n\n\ndef discover_clis() -> dict[str, str]:\n    \"\"\"Find installed CLI tools on PATH.\"\"\"\n    result: dict[str, str] = {}\n    for name in CLI_NAMES:\n        path = shutil.which(name)\n        if path:\n            result[name] = path\n    return result\n\n\ndef discover_cli_auth() -> dict[str, bool]:\n    \"\"\"Check which CLIs have stored auth.\"\"\"\n    home = Path.home()\n    auth: dict[str, bool] = {}\n    # Claude Code: has projects dir = subscription active\n    claude_dir = home / \".claude\"\n    auth[\"claude\"] = (claude_dir / \"projects\").is_dir()\n    # Codex: auth.json with tokens\n    codex_auth = home / \".codex\" / \"auth.json\"\n    auth[\"codex\"] = _has_json_key(codex_auth, \"tokens\")\n    # Kimi: credentials directory with token files\n    kimi_creds = home / \".kimi\" / \"credentials\"\n    auth[\"kimi\"] = kimi_creds.is_dir() and any(\n        kimi_creds.iterdir()\n    )\n    return auth\n\n\ndef _has_json_key(path: Path, key: str) -> bool:\n    \"\"\"Check if JSON file exists and has a key.\"\"\"\n    if not path.exists():\n        return False\n    try:\n        with open(path) as f:\n            return bool(json.load(f).get(key))\n    except (json.JSONDecodeError, OSError):\n        return False\n\n\ndef discover_git_token() -> str:\n    \"\"\"Read GitHub token from git credential helper.\"\"\"\n    try:\n        result = subprocess.run(\n            [\"git\", \"credential\", \"fill\"],\n            input=\"protocol=https\\nhost=github.com\\n\\n\",\n            capture_output=True, text=True, timeout=5,\n        )\n        for line in result.stdout.splitlines():\n            if line.startswith(\"password=\"):\n                return line.split(\"=\", 1)[1]\n    except (subprocess.SubprocessError, OSError):\n        pass\n    return \"\"\n\n\ndef discover_repos(\n    home: Path | None = None,\n) -> list[dict]:\n    \"\"\"Scan common directories for git repos.\"\"\"\n    root = home or Path.home()\n    repos: list[dict] = []\n    for dirname in SCAN_DIRS:\n        parent = root / dirname\n        if not parent.exists():\n            continue\n        _scan_dir(parent, repos, depth=0)\n        if len(repos) >= 30:\n            break\n    return repos[:30]\n\n\ndef _scan_dir(\n    parent: Path, repos: list[dict], depth: int,\n) -> None:\n    \"\"\"Recursively scan for .git dirs up to depth 3.\"\"\"\n    if depth > 3 or len(repos) >= 30:\n        return\n    try:\n        for child in sorted(parent.iterdir()):\n            if not child.is_dir():\n                continue\n            if child.name.startswith(\".\"):\n                continue\n            git_dir = child / \".git\"\n            if git_dir.is_dir():\n                repos.append({\n                    \"path\": str(child),\n                    \"key\": child.name,\n                })\n            else:\n                _scan_dir(child, repos, depth + 1)\n    except PermissionError:\n        pass\n\n\ndef discover_active_projects(\n    claude_dir: Path | None = None,\n) -> list[str]:\n    \"\"\"Rank projects by prompt count from Claude history.\"\"\"\n    cdir = claude_dir or (Path.home() / \".claude\")\n    history = cdir / \"history.jsonl\"\n    if not history.exists():\n        return []\n\n    from collections import Counter\n    counts: Counter[str] = Counter()\n    try:\n        for line in history.read_text().splitlines():\n            if not line.strip():\n                continue\n            try:\n                entry = json.loads(line)\n                project = entry.get(\"project\", \"\")\n                if project:\n                    name = Path(project).name\n                    if name:\n                        counts[name] += 1\n            except json.JSONDecodeError:\n                continue\n    except OSError:\n        return []\n\n    return [p for p, _ in counts.most_common(15)]\n\n\ndef discover_env_tokens() -> dict[str, bool]:\n    \"\"\"Check env vars and git credential helper.\"\"\"\n    tokens = {\n        \"GITHUB_TOKEN\": bool(\n            os.environ.get(\"GITHUB_TOKEN\"),\n        ),\n        \"ANTHROPIC_API_KEY\": bool(\n            os.environ.get(\"ANTHROPIC_API_KEY\"),\n        ),\n        \"ASANA_API_KEY\": bool(\n            os.environ.get(\"ASANA_API_KEY\"),\n        ),\n    }\n    # Fall back to git credential helper for GitHub\n    if not tokens[\"GITHUB_TOKEN\"]:\n        tokens[\"GIT_CREDENTIAL\"] = bool(\n            discover_git_token(),\n        )\n    return tokens\n\n\ndef infer_github_org(repo_path: Path) -> str:\n    \"\"\"Infer GitHub org from git remote URL.\"\"\"\n    try:\n        result = subprocess.run(\n            [\"git\", \"remote\", \"get-url\", \"origin\"],\n            capture_output=True, text=True,\n            cwd=str(repo_path), timeout=5,\n        )\n        url = result.stdout.strip()\n        return _parse_org_from_url(url)\n    except (subprocess.SubprocessError, OSError):\n        return \"\"\n\n\ndef _parse_org_from_url(url: str) -> str:\n    \"\"\"Extract org from GitHub URL.\"\"\"\n    if \"github.com:\" in url:\n        parts = url.split(\"github.com:\")[-1]\n        return parts.split(\"/\")[0]\n    if \"github.com/\" in url:\n        parts = url.split(\"github.com/\")[-1]\n        return parts.split(\"/\")[0]\n    return \"\"\n\n\ndef discover_all_orgs(repos: list[dict]) -> list[str]:\n    \"\"\"Extract unique GitHub orgs from all repos.\"\"\"\n    orgs: set[str] = set()\n    for repo in repos:\n        org = infer_github_org(Path(repo[\"path\"]))\n        if org:\n            orgs.add(org)\n    return sorted(orgs)\n\n\ndef full_discovery(\n    home: Path | None = None,\n) -> DiscoveryResult:\n    \"\"\"Run all discovery checks.\"\"\"\n    clis = discover_clis()\n    cli_auth = discover_cli_auth()\n    repos = discover_repos(home)\n    projects = discover_active_projects()\n    tokens = discover_env_tokens()\n    all_orgs = discover_all_orgs(repos)\n    org = all_orgs[0] if all_orgs else \"\"\n\n    return DiscoveryResult(\n        clis=clis,\n        cli_auth=cli_auth,\n        repos=repos,\n        active_projects=projects,\n        tokens=tokens,\n        github_org=org,\n        github_orgs=all_orgs,\n        timestamp=datetime.now(\n            timezone.utc\n        ).isoformat(),\n    )\n"
  },
  {
    "path": "maggy/maggy/engram/__init__.py",
    "content": "\"\"\"Engram — cross-session persistent memory.\"\"\"\n"
  },
  {
    "path": "maggy/maggy/engram/diagnostics.py",
    "content": "\"\"\"AmnesiaProfile — 7-dimension memory diagnostics.\"\"\"\n\nfrom __future__ import annotations\n\nfrom dataclasses import dataclass\n\nfrom .store import EngramStore\n\n\n@dataclass\nclass AmnesiaProfile:\n    \"\"\"7-dimension memory health assessment.\"\"\"\n\n    total_memories: int = 0\n    active_count: int = 0\n    superseded_count: int = 0\n    facts: int = 0\n    decisions: int = 0\n    code_refs: int = 0\n    handoffs: int = 0\n\n    @property\n    def health_score(self) -> float:\n        \"\"\"0.0-1.0 overall memory health.\"\"\"\n        if self.total_memories == 0:\n            return 0.0\n        active_ratio = self.active_count / self.total_memories\n        diversity = sum(\n            1 for c in [\n                self.facts, self.decisions,\n                self.code_refs, self.handoffs,\n            ] if c > 0\n        ) / 4.0\n        return round(\n            active_ratio * 0.6 + diversity * 0.4, 3,\n        )\n\n\ndef diagnose(\n    store: EngramStore, namespace: str | None = None,\n) -> AmnesiaProfile:\n    \"\"\"Run diagnostics on memory store.\"\"\"\n    all_records = store.query(\n        namespace=namespace, active_only=False, limit=10000,\n    )\n    active = [r for r in all_records if r.is_active]\n\n    return AmnesiaProfile(\n        total_memories=len(all_records),\n        active_count=len(active),\n        superseded_count=len(all_records) - len(active),\n        facts=sum(1 for r in active if r.memory_type == \"fact\"),\n        decisions=sum(\n            1 for r in active if r.memory_type == \"decision\"\n        ),\n        code_refs=sum(\n            1 for r in active if r.memory_type == \"code_ref\"\n        ),\n        handoffs=sum(\n            1 for r in active if r.memory_type == \"handoff\"\n        ),\n    )\n"
  },
  {
    "path": "maggy/maggy/engram/record.py",
    "content": "\"\"\"EngramRecord — the unit of persistent memory.\"\"\"\n\nfrom __future__ import annotations\n\nfrom dataclasses import dataclass, field\nfrom datetime import datetime, timezone\nfrom enum import Enum\n\n\nclass Origin(str, Enum):\n    EXPLICIT = \"explicit\"\n    INFERRED = \"inferred\"\n    MESH = \"mesh\"\n\n\nclass Validity(str, Enum):\n    ACTIVE = \"active\"\n    SUPERSEDED = \"superseded\"\n    EXPIRED = \"expired\"\n\n\n@dataclass\nclass EngramRecord:\n    \"\"\"A single unit of persistent memory.\"\"\"\n\n    engram_id: str\n    namespace: str\n    memory_type: str  # fact | decision | code_ref | handoff\n    content: str\n    origin: str = Origin.EXPLICIT\n    validity: str = Validity.ACTIVE\n    confidence: float = 1.0\n    tags: list[str] = field(default_factory=list)\n    source_task: str = \"\"\n    created_at: str = field(\n        default_factory=lambda: datetime.now(\n            timezone.utc\n        ).isoformat()\n    )\n    expires_at: str = \"\"\n\n    @property\n    def is_active(self) -> bool:\n        return self.validity == Validity.ACTIVE\n\n    def supersede(self) -> None:\n        self.validity = Validity.SUPERSEDED\n"
  },
  {
    "path": "maggy/maggy/engram/retrieval.py",
    "content": "\"\"\"Multi-path retrieval for Engram records.\"\"\"\n\nfrom __future__ import annotations\n\nfrom .record import EngramRecord\nfrom .store import EngramStore\n\n\nclass EngramRetrieval:\n    \"\"\"Multi-path retrieval: semantic, temporal, causal, entity.\"\"\"\n\n    def __init__(self, store: EngramStore):\n        self._store = store\n\n    def by_namespace(\n        self, namespace: str, limit: int = 50,\n    ) -> list[EngramRecord]:\n        \"\"\"Retrieve by namespace (project/session scope).\"\"\"\n        return self._store.query(\n            namespace=namespace, limit=limit,\n        )\n\n    def by_type(\n        self, memory_type: str, limit: int = 50,\n    ) -> list[EngramRecord]:\n        \"\"\"Retrieve by memory type (fact/decision/etc).\"\"\"\n        return self._store.query(\n            memory_type=memory_type, limit=limit,\n        )\n\n    def by_keyword(\n        self, keyword: str, namespace: str | None = None,\n        limit: int = 50,\n    ) -> list[EngramRecord]:\n        \"\"\"Simple keyword search in content.\"\"\"\n        records = self._store.query(\n            namespace=namespace, limit=1000,\n        )\n        matched = [\n            r for r in records\n            if keyword.lower() in r.content.lower()\n        ]\n        return matched[:limit]\n\n    def by_tag(\n        self, tag: str, namespace: str | None = None,\n        limit: int = 50,\n    ) -> list[EngramRecord]:\n        \"\"\"Retrieve by tag.\"\"\"\n        records = self._store.query(\n            namespace=namespace, limit=1000,\n        )\n        matched = [\n            r for r in records if tag in r.tags\n        ]\n        return matched[:limit]\n\n    def recent(self, limit: int = 20) -> list[EngramRecord]:\n        \"\"\"Retrieve most recent records across all namespaces.\"\"\"\n        return self._store.query(\n            active_only=True, limit=limit,\n        )\n"
  },
  {
    "path": "maggy/maggy/engram/seed.py",
    "content": "\"\"\"Seed engrams on first boot for non-zero health.\"\"\"\n\nfrom __future__ import annotations\n\nfrom .record import EngramRecord\nfrom .store import EngramStore\n\n_SEEDS = [\n    (\"seed-fact-1\", \"fact\", \"Maggy uses blast-score routing \"\n     \"to pick the optimal model per task.\"),\n    (\"seed-fact-2\", \"fact\", \"Quality gates: max 20 lines/fn, \"\n     \"3 params, 2 nesting, 200 lines/file.\"),\n    (\"seed-decision-1\", \"decision\", \"TDD workflow: RED \"\n     \"(failing tests) -> GREEN (pass) -> VALIDATE.\"),\n    (\"seed-decision-2\", \"decision\", \"Local Qwen3-Coder \"\n     \"handles blast 0-5; Claude handles 5-10.\"),\n    (\"seed-coderef-1\", \"code_ref\",\n     \"Routing tiers: process/model_router.py DEFAULT_TIERS\"),\n    (\"seed-coderef-2\", \"code_ref\",\n     \"Chat REPL: cli_chat.py _repl_loop\"),\n    (\"seed-handoff-1\", \"handoff\", \"System initialized. \"\n     \"Memory will grow as tasks are completed.\"),\n]\n\n_REQUIRED_TYPES = {\"fact\", \"decision\", \"code_ref\", \"handoff\"}\n\n\ndef seed_if_empty(store: EngramStore) -> None:\n    \"\"\"Seed missing memory types for healthy diversity.\"\"\"\n    existing = {\n        r.memory_type\n        for r in store.query(active_only=True, limit=500)\n    }\n    missing = _REQUIRED_TYPES - existing\n    if not missing:\n        return\n    for eid, mtype, content in _SEEDS:\n        if mtype in missing:\n            store.write(EngramRecord(\n                engram_id=eid,\n                namespace=\"system\",\n                memory_type=mtype,\n                content=content,\n                tags=[\"seed\"],\n            ))\n"
  },
  {
    "path": "maggy/maggy/engram/store.py",
    "content": "\"\"\"SQLite store for Engram records with namespace isolation.\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport sqlite3\nfrom contextlib import contextmanager\nfrom pathlib import Path\nfrom typing import Iterator\n\nfrom .record import EngramRecord\n\nSCHEMA = \"\"\"\nCREATE TABLE IF NOT EXISTS engrams (\n    engram_id TEXT PRIMARY KEY,\n    namespace TEXT NOT NULL,\n    memory_type TEXT NOT NULL,\n    content TEXT NOT NULL,\n    origin TEXT NOT NULL DEFAULT 'explicit',\n    validity TEXT NOT NULL DEFAULT 'active',\n    confidence REAL NOT NULL DEFAULT 1.0,\n    tags TEXT NOT NULL DEFAULT '[]',\n    source_task TEXT NOT NULL DEFAULT '',\n    created_at TEXT NOT NULL,\n    expires_at TEXT NOT NULL DEFAULT ''\n);\nCREATE INDEX IF NOT EXISTS idx_engram_ns\n    ON engrams(namespace);\nCREATE INDEX IF NOT EXISTS idx_engram_type\n    ON engrams(memory_type);\nCREATE INDEX IF NOT EXISTS idx_engram_validity\n    ON engrams(validity);\n\"\"\"\n\n\n@contextmanager\ndef _connect(path: Path) -> Iterator[sqlite3.Connection]:\n    path.parent.mkdir(parents=True, exist_ok=True)\n    conn = sqlite3.connect(str(path), timeout=30.0)\n    conn.execute(\"PRAGMA journal_mode=WAL\")\n    conn.execute(\"PRAGMA busy_timeout=30000\")\n    conn.row_factory = sqlite3.Row\n    try:\n        yield conn\n    finally:\n        conn.close()\n\n\nclass EngramStore:\n    \"\"\"SQLite-backed engram storage.\"\"\"\n\n    def __init__(self, db_path: Path):\n        self._db_path = db_path\n        with _connect(self._db_path) as conn:\n            conn.executescript(SCHEMA)\n\n    def write(self, record: EngramRecord) -> None:\n        with _connect(self._db_path) as conn:\n            conn.execute(\n                \"INSERT OR REPLACE INTO engrams \"\n                \"VALUES (?,?,?,?,?,?,?,?,?,?,?)\",\n                (\n                    record.engram_id,\n                    record.namespace,\n                    record.memory_type,\n                    record.content,\n                    record.origin,\n                    record.validity,\n                    record.confidence,\n                    json.dumps(record.tags),\n                    record.source_task,\n                    record.created_at,\n                    record.expires_at,\n                ),\n            )\n            conn.commit()\n\n    def get(\n        self, engram_id: str,\n    ) -> EngramRecord | None:\n        with _connect(self._db_path) as conn:\n            row = conn.execute(\n                \"SELECT * FROM engrams \"\n                \"WHERE engram_id=?\",\n                (engram_id,),\n            ).fetchone()\n        if not row:\n            return None\n        return self._row_to_record(row)\n\n    def query(\n        self,\n        namespace: str | None = None,\n        memory_type: str | None = None,\n        active_only: bool = True,\n        limit: int = 100,\n    ) -> list[EngramRecord]:\n        clauses: list[str] = []\n        params: list = []\n        if namespace:\n            clauses.append(\"namespace = ?\")\n            params.append(namespace)\n        if memory_type:\n            clauses.append(\"memory_type = ?\")\n            params.append(memory_type)\n        if active_only:\n            clauses.append(\"validity = 'active'\")\n\n        where = (\n            f\"WHERE {' AND '.join(clauses)}\"\n            if clauses else \"\"\n        )\n        with _connect(self._db_path) as conn:\n            rows = conn.execute(\n                f\"SELECT * FROM engrams {where} \"\n                f\"ORDER BY created_at DESC LIMIT ?\",\n                params + [limit],\n            ).fetchall()\n        return [self._row_to_record(r) for r in rows]\n\n    def count(\n        self, namespace: str | None = None,\n    ) -> int:\n        with _connect(self._db_path) as conn:\n            if namespace:\n                row = conn.execute(\n                    \"SELECT COUNT(*) FROM engrams \"\n                    \"WHERE namespace = ?\",\n                    (namespace,),\n                ).fetchone()\n            else:\n                row = conn.execute(\n                    \"SELECT COUNT(*) FROM engrams\",\n                ).fetchone()\n        return int(row[0])\n\n    def _row_to_record(\n        self, r: sqlite3.Row,\n    ) -> EngramRecord:\n        return EngramRecord(\n            engram_id=r[\"engram_id\"],\n            namespace=r[\"namespace\"],\n            memory_type=r[\"memory_type\"],\n            content=r[\"content\"],\n            origin=r[\"origin\"],\n            validity=r[\"validity\"],\n            confidence=r[\"confidence\"],\n            tags=json.loads(r[\"tags\"]),\n            source_task=r[\"source_task\"],\n            created_at=r[\"created_at\"],\n            expires_at=r[\"expires_at\"],\n        )\n"
  },
  {
    "path": "maggy/maggy/escalation/__init__.py",
    "content": "\n"
  },
  {
    "path": "maggy/maggy/escalation/protocol.py",
    "content": "\"\"\"Human escalation packets with SQLite persistence.\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport sqlite3\nimport uuid\nfrom contextlib import contextmanager\nfrom dataclasses import dataclass\nfrom datetime import datetime, timezone\nfrom pathlib import Path\nfrom typing import Iterator\n\nSCHEMA = \"\"\"\nCREATE TABLE IF NOT EXISTS escalations (\n    id TEXT PRIMARY KEY,\n    session_id TEXT NOT NULL,\n    reason TEXT NOT NULL,\n    context TEXT NOT NULL,\n    agent_state TEXT NOT NULL,\n    suggested_actions TEXT NOT NULL,\n    created_at TEXT NOT NULL,\n    resolved INTEGER NOT NULL,\n    resolution TEXT NOT NULL\n);\nCREATE INDEX IF NOT EXISTS idx_escalations_pending\n    ON escalations(resolved, created_at);\n\"\"\"\n\n\n@dataclass\nclass EscalationPacket:\n    id: str\n    session_id: str\n    reason: str\n    context: dict[str, object]\n    agent_state: dict[str, object]\n    suggested_actions: list[str]\n    created_at: str\n    resolved: bool\n    resolution: str\n\n\n@contextmanager\ndef _connect(path: Path) -> Iterator[sqlite3.Connection]:\n    path.parent.mkdir(parents=True, exist_ok=True)\n    conn = sqlite3.connect(str(path), timeout=30.0)\n    conn.execute(\"PRAGMA journal_mode=WAL\")\n    conn.execute(\"PRAGMA busy_timeout=30000\")\n    conn.row_factory = sqlite3.Row\n    try:\n        yield conn\n    finally:\n        conn.close()\n\n\nclass Escalator:\n    def __init__(self, db_path: Path):\n        self._db_path = db_path\n        self._init_db()\n\n    def escalate(\n        self, session_id: str, reason: str, context: dict[str, object]\n    ) -> EscalationPacket:\n        packet = _build_packet(session_id, reason, context)\n        with _connect(self._db_path) as conn:\n            conn.execute(\n                \"INSERT INTO escalations VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)\",\n                _serialize(packet),\n            )\n            conn.commit()\n        return packet\n\n    def resolve(self, escalation_id: str, guidance: str) -> EscalationPacket:\n        with _connect(self._db_path) as conn:\n            conn.execute(\n                \"UPDATE escalations SET resolved = 1, resolution = ? WHERE id = ?\",\n                (guidance, escalation_id),\n            )\n            conn.commit()\n            row = conn.execute(\n                \"SELECT * FROM escalations WHERE id = ?\",\n                (escalation_id,),\n            ).fetchone()\n        if not row:\n            raise KeyError(escalation_id)\n        return _from_row(row)\n\n    def list_pending(self) -> list[EscalationPacket]:\n        with _connect(self._db_path) as conn:\n            rows = conn.execute(\n                \"SELECT * FROM escalations WHERE resolved = 0 ORDER BY created_at\",\n            ).fetchall()\n        return [_from_row(row) for row in rows]\n\n    def get(self, escalation_id: str) -> EscalationPacket | None:\n        with _connect(self._db_path) as conn:\n            row = conn.execute(\n                \"SELECT * FROM escalations WHERE id = ?\",\n                (escalation_id,),\n            ).fetchone()\n        return _from_row(row) if row else None\n\n    def _init_db(self) -> None:\n        with _connect(self._db_path) as conn:\n            conn.executescript(SCHEMA)\n\n\ndef _build_packet(\n    session_id: str, reason: str, context: dict[str, object]\n) -> EscalationPacket:\n    return EscalationPacket(\n        id=str(uuid.uuid4()),\n        session_id=session_id,\n        reason=reason,\n        context=context,\n        agent_state=_dict_field(context, \"agent_state\"),\n        suggested_actions=_list_field(context, \"suggested_actions\"),\n        created_at=datetime.now(timezone.utc).isoformat(),\n        resolved=False,\n        resolution=\"\",\n    )\n\n\ndef _dict_field(context: dict[str, object], key: str) -> dict[str, object]:\n    value = context.get(key, {})\n    return value if isinstance(value, dict) else {}\n\n\ndef _list_field(context: dict[str, object], key: str) -> list[str]:\n    value = context.get(key, [])\n    return [item for item in value if isinstance(item, str)] if isinstance(value, list) else []\n\n\ndef _serialize(packet: EscalationPacket) -> tuple[object, ...]:\n    return (\n        packet.id,\n        packet.session_id,\n        packet.reason,\n        json.dumps(packet.context),\n        json.dumps(packet.agent_state),\n        json.dumps(packet.suggested_actions),\n        packet.created_at,\n        int(packet.resolved),\n        packet.resolution,\n    )\n\n\ndef _safe_json(raw: str, fallback: object) -> object:\n    try:\n        return json.loads(raw)\n    except (json.JSONDecodeError, TypeError):\n        return fallback\n\n\ndef _from_row(row: sqlite3.Row) -> EscalationPacket:\n    return EscalationPacket(\n        id=row[\"id\"],\n        session_id=row[\"session_id\"],\n        reason=row[\"reason\"],\n        context=_safe_json(row[\"context\"], {}),\n        agent_state=_safe_json(row[\"agent_state\"], {}),\n        suggested_actions=_safe_json(row[\"suggested_actions\"], []),\n        created_at=row[\"created_at\"],\n        resolved=bool(row[\"resolved\"]),\n        resolution=row[\"resolution\"],\n    )\n"
  },
  {
    "path": "maggy/maggy/event_spine/__init__.py",
    "content": "\"\"\"Event Spine — canonical event flow for end-to-end tracing.\"\"\"\n\nfrom .emitter import EventEmitter\nfrom .header import EventHeader\n\n__all__ = [\"EventEmitter\", \"EventHeader\"]\n"
  },
  {
    "path": "maggy/maggy/event_spine/emitter.py",
    "content": "\"\"\"Event emitter — write, query, and trace events.\"\"\"\n\nfrom __future__ import annotations\n\nimport logging\nfrom dataclasses import asdict\n\nfrom .header import EventHeader\nfrom .store import EventStore\n\nlogger = logging.getLogger(__name__)\n\n\nclass EventEmitter:\n    \"\"\"Thread-safe event emission and query API.\"\"\"\n\n    def __init__(self, store: EventStore):\n        self._store = store\n\n    def emit(self, event: object) -> str:\n        \"\"\"Write event to store. Returns event_id.\"\"\"\n        header = getattr(event, \"header\", None)\n        if not isinstance(header, EventHeader):\n            raise ValueError(\"Event must have an EventHeader\")\n\n        data = asdict(event)\n        self._store.write(header, data)\n        logger.debug(\n            \"Event %s emitted: %s\",\n            header.event_type, header.event_id,\n        )\n        return header.event_id\n\n    def query(\n        self,\n        task_id: str | None = None,\n        event_type: str | None = None,\n        project_id: str | None = None,\n        limit: int = 100,\n    ) -> list[dict]:\n        \"\"\"Query events with optional filters.\"\"\"\n        return self._store.query(\n            task_id=task_id,\n            event_type=event_type,\n            project_id=project_id,\n            limit=limit,\n        )\n\n    def trace(self, task_id: str) -> list[dict]:\n        \"\"\"Return full ordered event chain for a task.\"\"\"\n        return self._store.query(\n            task_id=task_id, limit=10000,\n        )\n\n    def count(\n        self,\n        event_type: str | None = None,\n        project_id: str | None = None,\n    ) -> int:\n        \"\"\"Count events matching filters.\"\"\"\n        return self._store.count(\n            event_type=event_type,\n            project_id=project_id,\n        )\n"
  },
  {
    "path": "maggy/maggy/event_spine/events.py",
    "content": "\"\"\"Eight typed event dataclasses for the Event Spine.\"\"\"\n\nfrom __future__ import annotations\n\nfrom dataclasses import dataclass, field\n\nfrom .header import EventHeader\n\n\n@dataclass\nclass IntentEvent:\n    \"\"\"iCPG ReasonNode decomposition.\"\"\"\n\n    header: EventHeader = field(\n        default_factory=lambda: EventHeader(\"intent\")\n    )\n    intent_text: str = \"\"\n    reason_node_id: str = \"\"\n    decomposed_steps: list[str] = field(default_factory=list)\n\n\n@dataclass\nclass BindingEvent:\n    \"\"\"Lexon tool selection + clarify mode.\"\"\"\n\n    header: EventHeader = field(\n        default_factory=lambda: EventHeader(\"binding\")\n    )\n    phrase: str = \"\"\n    selected_tool: str = \"\"\n    candidates: list[str] = field(default_factory=list)\n    clarify_mode: str = \"\"  # self_clarify | user_clarify\n\n\n@dataclass\nclass ExecutionEvent:\n    \"\"\"Tool invocation input/output/duration.\"\"\"\n\n    header: EventHeader = field(\n        default_factory=lambda: EventHeader(\"execution\")\n    )\n    tool_name: str = \"\"\n    input_summary: str = \"\"\n    output_summary: str = \"\"\n    duration_ms: int = 0\n    success: bool = True\n\n\n@dataclass\nclass MemoryEvent:\n    \"\"\"Mnemos within-task memory write.\"\"\"\n\n    header: EventHeader = field(\n        default_factory=lambda: EventHeader(\"memory\")\n    )\n    memory_type: str = \"\"  # fact | decision | code_ref | handoff\n    content: str = \"\"\n    node_id: str = \"\"\n\n\n@dataclass\nclass PersistenceEvent:\n    \"\"\"Engram cross-session promotion.\"\"\"\n\n    header: EventHeader = field(\n        default_factory=lambda: EventHeader(\"persistence\")\n    )\n    engram_id: str = \"\"\n    memory_type: str = \"\"\n    content: str = \"\"\n    source_namespace: str = \"\"\n    target_namespace: str = \"\"\n\n\n@dataclass\nclass OutcomeEvent:\n    \"\"\"Process Intelligence success/failure + reward.\"\"\"\n\n    header: EventHeader = field(\n        default_factory=lambda: EventHeader(\"outcome\")\n    )\n    success: bool = True\n    reward: float = 0.0\n    metrics: dict = field(default_factory=dict)\n\n\n@dataclass\nclass MutationEvent:\n    \"\"\"L2/L3/L4 self-modification.\"\"\"\n\n    header: EventHeader = field(\n        default_factory=lambda: EventHeader(\"mutation\")\n    )\n    control_level: str = \"\"  # L2 | L3 | L4\n    target: str = \"\"\n    old_value: str = \"\"\n    new_value: str = \"\"\n    reason: str = \"\"\n\n\n@dataclass\nclass MeshEvent:\n    \"\"\"Cross-machine sharing + quarantine status.\"\"\"\n\n    header: EventHeader = field(\n        default_factory=lambda: EventHeader(\"mesh\")\n    )\n    peer_id: str = \"\"\n    peer_name: str = \"\"\n    action: str = \"\"  # share | receive | quarantine | promote\n    memory_type: str = \"\"\n    content_key: str = \"\"\n\n\nEVENT_TYPES = {\n    \"intent\": IntentEvent,\n    \"binding\": BindingEvent,\n    \"execution\": ExecutionEvent,\n    \"memory\": MemoryEvent,\n    \"persistence\": PersistenceEvent,\n    \"outcome\": OutcomeEvent,\n    \"mutation\": MutationEvent,\n    \"mesh\": MeshEvent,\n}\n"
  },
  {
    "path": "maggy/maggy/event_spine/header.py",
    "content": "\"\"\"Common EventHeader shared by all typed events.\"\"\"\n\nfrom __future__ import annotations\n\nimport uuid\nfrom dataclasses import dataclass, field\nfrom datetime import datetime, timezone\n\n\ndef _uuid() -> str:\n    return str(uuid.uuid4())\n\n\ndef _now() -> str:\n    return datetime.now(timezone.utc).isoformat()\n\n\n@dataclass\nclass EventHeader:\n    \"\"\"Standard fields for every event in the spine.\"\"\"\n\n    event_type: str\n    event_id: str = field(default_factory=_uuid)\n    task_id: str = \"\"\n    project_id: str = \"\"\n    agent_id: str = \"\"\n    model_id: str = \"\"\n    parent_event_id: str = \"\"\n    confidence: float = 1.0\n    namespace: str = \"\"\n    policy_version: str = \"\"\n    reward_delta: float = 0.0\n    timestamp: str = field(default_factory=_now)\n    schema_version: int = 1\n"
  },
  {
    "path": "maggy/maggy/event_spine/store.py",
    "content": "\"\"\"SQLite event store — append-only with archive support.\"\"\"\n\nfrom __future__ import annotations\n\nimport gzip\nimport json\nimport sqlite3\nfrom contextlib import contextmanager\nfrom pathlib import Path\nfrom typing import Iterator\n\nfrom .header import EventHeader\n\nSCHEMA = \"\"\"\nCREATE TABLE IF NOT EXISTS events (\n    id INTEGER PRIMARY KEY AUTOINCREMENT,\n    event_id TEXT UNIQUE NOT NULL,\n    event_type TEXT NOT NULL,\n    task_id TEXT NOT NULL DEFAULT '',\n    project_id TEXT NOT NULL DEFAULT '',\n    agent_id TEXT NOT NULL DEFAULT '',\n    model_id TEXT NOT NULL DEFAULT '',\n    parent_event_id TEXT NOT NULL DEFAULT '',\n    timestamp TEXT NOT NULL,\n    payload TEXT NOT NULL\n);\nCREATE INDEX IF NOT EXISTS idx_events_task\n    ON events(task_id);\nCREATE INDEX IF NOT EXISTS idx_events_type\n    ON events(event_type);\nCREATE INDEX IF NOT EXISTS idx_events_project\n    ON events(project_id);\nCREATE INDEX IF NOT EXISTS idx_events_ts\n    ON events(timestamp);\n\"\"\"\n\n\n@contextmanager\ndef _connect(path: Path) -> Iterator[sqlite3.Connection]:\n    path.parent.mkdir(parents=True, exist_ok=True)\n    conn = sqlite3.connect(str(path), timeout=30.0)\n    conn.execute(\"PRAGMA journal_mode=WAL\")\n    conn.execute(\"PRAGMA busy_timeout=30000\")\n    conn.row_factory = sqlite3.Row\n    try:\n        yield conn\n    finally:\n        conn.close()\n\n\nclass EventStore:\n    \"\"\"Append-only SQLite event store.\"\"\"\n\n    def __init__(self, db_path: Path):\n        self._db_path = db_path\n        self._init_db()\n\n    def _init_db(self) -> None:\n        with _connect(self._db_path) as conn:\n            conn.executescript(SCHEMA)\n\n    def write(\n        self, header: EventHeader, payload: dict,\n    ) -> None:\n        \"\"\"Append an event.\"\"\"\n        with _connect(self._db_path) as conn:\n            conn.execute(\n                \"INSERT OR IGNORE INTO events \"\n                \"(event_id, event_type, task_id, \"\n                \"project_id, agent_id, model_id, \"\n                \"parent_event_id, timestamp, payload) \"\n                \"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)\",\n                (\n                    header.event_id, header.event_type,\n                    header.task_id, header.project_id,\n                    header.agent_id, header.model_id,\n                    header.parent_event_id,\n                    header.timestamp,\n                    json.dumps(payload),\n                ),\n            )\n            conn.commit()\n\n    def query(\n        self,\n        task_id: str | None = None,\n        event_type: str | None = None,\n        project_id: str | None = None,\n        limit: int = 100,\n    ) -> list[dict]:\n        \"\"\"Query events with filters.\"\"\"\n        clauses: list[str] = []\n        params: list = []\n        if task_id:\n            clauses.append(\"task_id = ?\")\n            params.append(task_id)\n        if event_type:\n            clauses.append(\"event_type = ?\")\n            params.append(event_type)\n        if project_id:\n            clauses.append(\"project_id = ?\")\n            params.append(project_id)\n\n        where = (\n            f\"WHERE {' AND '.join(clauses)}\"\n            if clauses else \"\"\n        )\n        sql = (\n            f\"SELECT payload FROM events {where} \"\n            f\"ORDER BY timestamp ASC LIMIT ?\"\n        )\n        params.append(limit)\n\n        with _connect(self._db_path) as conn:\n            rows = conn.execute(sql, params).fetchall()\n        return [json.loads(r[\"payload\"]) for r in rows]\n\n    def count(\n        self,\n        event_type: str | None = None,\n        project_id: str | None = None,\n    ) -> int:\n        \"\"\"Count events matching filters.\"\"\"\n        clauses: list[str] = []\n        params: list[str] = []\n        if event_type:\n            clauses.append(\"event_type = ?\")\n            params.append(event_type)\n        if project_id:\n            clauses.append(\"project_id = ?\")\n            params.append(project_id)\n\n        where = (\n            f\"WHERE {' AND '.join(clauses)}\"\n            if clauses else \"\"\n        )\n        with _connect(self._db_path) as conn:\n            row = conn.execute(\n                f\"SELECT COUNT(*) FROM events {where}\",\n                params,\n            ).fetchone()\n        return int(row[0])\n\n    def archive_old(\n        self,\n        days: int = 90,\n        archive_dir: Path | None = None,\n    ) -> int:\n        \"\"\"Archive events older than N days.\"\"\"\n        from datetime import datetime, timedelta, timezone\n        cutoff = (\n            datetime.now(timezone.utc)\n            - timedelta(days=days)\n        ).isoformat()\n\n        with _connect(self._db_path) as conn:\n            rows = conn.execute(\n                \"SELECT payload FROM events \"\n                \"WHERE timestamp < ?\",\n                (cutoff,),\n            ).fetchall()\n\n            if not rows:\n                return 0\n\n            out_dir = archive_dir or (\n                self._db_path.parent / \"events_archive\"\n            )\n            out_dir.mkdir(parents=True, exist_ok=True)\n            archive_file = (\n                out_dir / f\"events_{cutoff[:10]}.jsonl.gz\"\n            )\n\n            with gzip.open(archive_file, \"wt\") as f:\n                for r in rows:\n                    f.write(r[\"payload\"] + \"\\n\")\n\n            conn.execute(\n                \"DELETE FROM events WHERE timestamp < ?\",\n                (cutoff,),\n            )\n            conn.commit()\n\n        return len(rows)\n"
  },
  {
    "path": "maggy/maggy/fatigue.py",
    "content": "\"\"\"Model-normalized fatigue tracking for cross-model sessions.\n\nNormalizes fatigue scores across models with different context windows\nso that 0.6 means \"approaching limit\" regardless of model.\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom dataclasses import dataclass\n\n\n@dataclass\nclass FatigueProfile:\n    \"\"\"Fatigue state for a model during a session.\"\"\"\n\n    model: str\n    context_window: int\n    tokens_used: int = 0\n    turns: int = 0\n    recovery_reads: int = 0\n\n    @property\n    def raw_utilization(self) -> float:\n        \"\"\"Raw context utilization 0.0-1.0.\"\"\"\n        if self.context_window <= 0:\n            return 0.0\n        return min(self.tokens_used / self.context_window, 1.0)\n\n    @property\n    def fatigue_score(self) -> float:\n        \"\"\"Normalized fatigue score 0.0-1.0.\n\n        Combines context utilization with turn-based fatigue.\n        Higher = more fatigued.\n        \"\"\"\n        ctx_factor = self.raw_utilization\n        turn_factor = min(self.turns / 50.0, 1.0)\n        return min(ctx_factor * 0.7 + turn_factor * 0.3, 1.0)\n\n    def should_checkpoint(self, threshold: float = 0.6) -> bool:\n        \"\"\"Whether the model should checkpoint soon.\"\"\"\n        return self.fatigue_score >= threshold\n\n\nMODEL_CONTEXT_WINDOWS: dict[str, int] = {\n    \"claude\": 200_000,\n    \"gpt\": 128_000,\n    \"kimi\": 128_000,\n    \"deepseek\": 128_000,\n    \"codex\": 200_000,\n    \"local\": 32_000,\n}\n\n\ndef create_profile(model: str) -> FatigueProfile:\n    \"\"\"Create a fatigue profile for a known model.\"\"\"\n    window = MODEL_CONTEXT_WINDOWS.get(model, 128_000)\n    return FatigueProfile(model=model, context_window=window)\n\n\ndef compare_fatigue(\n    profiles: list[FatigueProfile],\n) -> list[dict]:\n    \"\"\"Compare fatigue across active models.\"\"\"\n    return [\n        {\n            \"model\": p.model,\n            \"fatigue\": round(p.fatigue_score, 3),\n            \"utilization\": round(p.raw_utilization, 3),\n            \"turns\": p.turns,\n            \"should_checkpoint\": p.should_checkpoint(),\n        }\n        for p in sorted(\n            profiles, key=lambda p: p.fatigue_score, reverse=True,\n        )\n    ]\n"
  },
  {
    "path": "maggy/maggy/forge/__init__.py",
    "content": "\"\"\"MCP Forge integration — bridge to mcp-forge pipeline.\"\"\"\n"
  },
  {
    "path": "maggy/maggy/forge/connector.py",
    "content": "\"\"\"Bridge to mcp-forge — wraps registry, pipeline, codegen.\n\nConnects Maggy to the MCP Forge at ~/Documents/protaige/mcp-forge/\nwithout requiring it on PYTHONPATH. Uses subprocess for pipeline\ninvocation and file-based data exchange.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport logging\nfrom dataclasses import dataclass\nfrom pathlib import Path\n\nfrom .detector import GapDetector\nfrom .registry import ForgeRegistry\n\nlogger = logging.getLogger(__name__)\n\nDEFAULT_FORGE_PATH = Path.home() / \"Documents\" / \"protaige\" / \"mcp-forge\"\n\n\n@dataclass\nclass ForgeStatus:\n    \"\"\"Current state of the Forge connector.\"\"\"\n\n    available: bool\n    forge_path: str\n    registry_count: int\n    pending_gaps: int\n\n\nclass ForgeConnector:\n    \"\"\"Bridge between Maggy and MCP Forge.\"\"\"\n\n    def __init__(\n        self, forge_path: Path | None = None,\n    ):\n        self._path = forge_path or DEFAULT_FORGE_PATH\n        self._available = self._path.exists()\n        self.registry = ForgeRegistry(\n            self._path if self._available else None,\n        )\n        self.detector = GapDetector()\n\n    @property\n    def available(self) -> bool:\n        return self._available\n\n    def status(self) -> ForgeStatus:\n        \"\"\"Return current connector status.\"\"\"\n        return ForgeStatus(\n            available=self._available,\n            forge_path=str(self._path),\n            registry_count=self.registry.count,\n            pending_gaps=len(self.detector.list_gaps()),\n        )\n\n    def search_tools(self, query: str) -> list[dict]:\n        \"\"\"Search the Forge registry.\"\"\"\n        results = self.registry.search(query)\n        return [\n            {\n                \"slug\": t.slug,\n                \"mcp_url\": t.mcp_url,\n                \"has_mcp\": t.has_mcp,\n                \"auth_method\": t.auth_method,\n            }\n            for t in results\n        ]\n\n    def report_gap(self, capability: str) -> dict:\n        \"\"\"Report a capability gap. Returns trigger status.\"\"\"\n        triggered = self.detector.record_gap(capability)\n        return {\n            \"capability\": capability,\n            \"triggered\": triggered,\n            \"message\": (\n                f\"Forge triggered for '{capability}'\"\n                if triggered\n                else f\"Gap recorded ({capability})\"\n            ),\n        }\n\n    def get_gaps(self) -> list[dict]:\n        \"\"\"Return all detected gaps.\"\"\"\n        return [\n            {\n                \"capability\": g.capability,\n                \"occurrences\": g.occurrences,\n                \"triggered\": g.triggered,\n            }\n            for g in self.detector.top_gaps(10)\n        ]\n"
  },
  {
    "path": "maggy/maggy/forge/detector.py",
    "content": "\"\"\"Capability gap detection — monitors unresolvable requests.\n\nTracks patterns of failed tool lookups and triggers Forge\nafter repeated occurrences of the same gap.\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom collections import Counter\nfrom dataclasses import dataclass\n\nTRIGGER_THRESHOLD = 3\n\n\n@dataclass\nclass GapRecord:\n    \"\"\"A detected capability gap.\"\"\"\n\n    capability: str\n    occurrences: int = 0\n    triggered: bool = False\n\n\nclass GapDetector:\n    \"\"\"Monitors capability gaps across requests.\"\"\"\n\n    def __init__(self, threshold: int = TRIGGER_THRESHOLD):\n        self._gaps: Counter = Counter()\n        self._threshold = threshold\n        self._triggered: set[str] = set()\n\n    def record_gap(self, capability: str) -> bool:\n        \"\"\"Record a gap. Returns True if threshold reached.\"\"\"\n        key = capability.lower().strip()\n        self._gaps[key] += 1\n        if (\n            self._gaps[key] >= self._threshold\n            and key not in self._triggered\n        ):\n            self._triggered.add(key)\n            return True\n        return False\n\n    def list_gaps(self) -> list[GapRecord]:\n        \"\"\"Return all recorded gaps.\"\"\"\n        return [\n            GapRecord(\n                capability=cap,\n                occurrences=count,\n                triggered=cap in self._triggered,\n            )\n            for cap, count in self._gaps.most_common()\n        ]\n\n    def top_gaps(self, n: int = 5) -> list[GapRecord]:\n        \"\"\"Return top N gaps by occurrence count.\"\"\"\n        return self.list_gaps()[:n]\n\n    def reset(self, capability: str) -> None:\n        \"\"\"Reset a gap counter after resolution.\"\"\"\n        key = capability.lower().strip()\n        if key in self._gaps:\n            del self._gaps[key]\n        self._triggered.discard(key)\n"
  },
  {
    "path": "maggy/maggy/forge/registry.py",
    "content": "\"\"\"Tool registry — wraps mcp-forge's KNOWN_SERVERS.\n\nProvides enable/disable per project and search capabilities\nwithout requiring mcp-forge on PYTHONPATH.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nfrom dataclasses import dataclass\nfrom pathlib import Path\n\n\n@dataclass\nclass ToolInfo:\n    \"\"\"A registered MCP tool.\"\"\"\n\n    slug: str\n    mcp_url: str = \"\"\n    has_mcp: str = \"Community\"\n    auth_method: str = \"API Key\"\n    enabled: bool = True\n\n\nclass ForgeRegistry:\n    \"\"\"Project-aware tool registry.\"\"\"\n\n    def __init__(self, forge_path: Path | None = None):\n        self._tools: dict[str, ToolInfo] = {}\n        self._forge_path = forge_path\n        self._load_registry()\n\n    def _load_registry(self) -> None:\n        \"\"\"Load from mcp-forge if available.\"\"\"\n        if not self._forge_path:\n            return\n        reg_file = self._forge_path / \"src\" / \"mcp_registry.py\"\n        if not reg_file.exists():\n            return\n        # Parse KNOWN_SERVERS from the registry\n        self._tools = _parse_registry(reg_file)\n\n    def search(self, query: str) -> list[ToolInfo]:\n        \"\"\"Search tools by slug or keyword.\"\"\"\n        q = query.lower()\n        return [\n            t for t in self._tools.values()\n            if q in t.slug or q in t.mcp_url.lower()\n        ]\n\n    def get(self, slug: str) -> ToolInfo | None:\n        return self._tools.get(slug)\n\n    def list_all(self) -> list[ToolInfo]:\n        return list(self._tools.values())\n\n    def set_enabled(self, slug: str, enabled: bool) -> bool:\n        tool = self._tools.get(slug)\n        if not tool:\n            return False\n        tool.enabled = enabled\n        return True\n\n    @property\n    def count(self) -> int:\n        return len(self._tools)\n\n\ndef _parse_registry(path: Path) -> dict[str, ToolInfo]:\n    \"\"\"Extract KNOWN_SERVERS entries from registry file.\"\"\"\n    tools: dict[str, ToolInfo] = {}\n    content = path.read_text()\n    # Find dict literals in KNOWN_SERVERS list\n    import re\n    pattern = r'\\{[^}]+\\}'\n    for match in re.finditer(pattern, content):\n        try:\n            # Clean Python dict to JSON-compatible\n            raw = match.group()\n            raw = raw.replace(\"'\", '\"')\n            data = json.loads(raw)\n            slug = data.get(\"slug\", \"\")\n            if slug:\n                tools[slug] = ToolInfo(\n                    slug=slug,\n                    mcp_url=data.get(\"mcp_url\", \"\"),\n                    has_mcp=data.get(\"has_mcp\", \"Community\"),\n                    auth_method=data.get(\"auth_method\", \"\"),\n                )\n        except (json.JSONDecodeError, KeyError):\n            continue\n    return tools\n"
  },
  {
    "path": "maggy/maggy/heartbeat/__init__.py",
    "content": "\"\"\"Heartbeat — background scheduler for periodic jobs.\"\"\"\n"
  },
  {
    "path": "maggy/maggy/heartbeat/jobs.py",
    "content": "\"\"\"Built-in heartbeat jobs — wire to existing services.\"\"\"\n\nfrom __future__ import annotations\n\nimport logging\nfrom datetime import datetime, timezone\n\nfrom maggy.engram.record import Validity\n\nlogger = logging.getLogger(__name__)\n\n\nasync def refresh_history(app) -> None:\n    \"\"\"Re-parse CLI session data.\"\"\"\n    history = getattr(app.state, \"history\", None)\n    if not history:\n        return\n    try:\n        history.analyze()\n    except Exception as exc:\n        logger.warning(\"refresh_history failed: %s\", exc)\n        raise\n\n\nasync def expire_engrams(app) -> None:\n    \"\"\"Mark expired engrams.\"\"\"\n    engram = getattr(app.state, \"engram\", None)\n    if not engram:\n        return\n    try:\n        records = engram.query(active_only=True, limit=500)\n        now = datetime.now(timezone.utc)\n        for rec in records:\n            if _is_expired(rec, now):\n                rec.validity = Validity.expired\n                engram.write(rec)\n    except Exception as exc:\n        logger.warning(\"expire_engrams failed: %s\", exc)\n        raise\n\n\ndef _is_expired(rec, now) -> bool:\n    \"\"\"Check if an engram's TTL has elapsed.\"\"\"\n    tags = getattr(rec, \"tags\", []) or []\n    ttl_tag = next((t for t in tags if t.startswith(\"ttl:\")), None)\n    if not ttl_tag:\n        return False\n    try:\n        ttl = int(ttl_tag.split(\":\")[1])\n    except (IndexError, ValueError):\n        return False\n    created = rec.created_at\n    if not created:\n        return False\n    created_dt = datetime.fromisoformat(created)\n    return (now - created_dt).total_seconds() > ttl * 3600\n\n\nasync def self_improve(app) -> None:\n    \"\"\"Run self-improvement analysis.\"\"\"\n    introspector = getattr(app.state, \"introspector\", None)\n    if not introspector:\n        return\n    try:\n        introspector.analyze()\n    except Exception as exc:\n        logger.warning(\"self_improve failed: %s\", exc)\n        raise\n\n\nasync def mesh_heartbeat(app) -> None:\n    \"\"\"Discover peers, announce self, publish shares.\"\"\"\n    mesh = getattr(app.state, \"mesh\", None)\n    if not mesh:\n        return\n    cfg = getattr(app.state, \"cfg\", None)\n    if not cfg:\n        return\n    try:\n        token = cfg.issue_tracker.github.token\n        if token and cfg.mesh.git_discovery:\n            await mesh.discover(token)\n            await mesh.announce_all(token)\n    except Exception as exc:\n        logger.warning(\"mesh_heartbeat failed: %s\", exc)\n        raise\n\n\nasync def collect_signals(app) -> None:\n    \"\"\"Record periodic observability signals.\"\"\"\n    obs = getattr(app.state, \"observability\", None)\n    cfg = getattr(app.state, \"cfg\", None)\n    if not obs or not cfg:\n        return\n    try:\n        for cb in cfg.codebases:\n            obs.record_signal(cb.key, \"heartbeat\", 1.0)\n    except Exception as exc:\n        logger.warning(\"collect_signals failed: %s\", exc)\n        raise\n"
  },
  {
    "path": "maggy/maggy/heartbeat/scheduler.py",
    "content": "\"\"\"Core heartbeat scheduler — register and run periodic jobs.\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport logging\nfrom dataclasses import dataclass\nfrom datetime import datetime, timezone\nfrom typing import Awaitable, Callable\n\nlogger = logging.getLogger(__name__)\n\nTICK_INTERVAL = 1.0  # seconds between scheduler ticks\n\n\n@dataclass\nclass Job:\n    name: str\n    fn: Callable[..., Awaitable[None]]\n    interval_seconds: int\n    last_run: str = \"\"\n    run_count: int = 0\n    last_error: str = \"\"\n    enabled: bool = True\n\n    def is_due(self) -> bool:\n        if not self.last_run:\n            return True\n        last = datetime.fromisoformat(self.last_run)\n        elapsed = (datetime.now(timezone.utc) - last).total_seconds()\n        return elapsed >= self.interval_seconds\n\n\nclass HeartbeatScheduler:\n    def __init__(self) -> None:\n        self._jobs: dict[str, Job] = {}\n        self._task: asyncio.Task | None = None\n\n    def register(\n        self, name: str, fn: Callable, interval: int,\n    ) -> None:\n        if name in self._jobs:\n            raise ValueError(f\"Job '{name}' already registered\")\n        self._jobs[name] = Job(\n            name=name, fn=fn, interval_seconds=interval,\n        )\n\n    async def tick(self) -> None:\n        for job in self._jobs.values():\n            if not job.enabled or not job.is_due():\n                continue\n            await self._run_job(job)\n\n    async def _run_job(self, job: Job) -> None:\n        try:\n            await job.fn()\n            job.last_error = \"\"\n        except Exception as exc:\n            job.last_error = str(exc)\n            logger.warning(\"Job %s failed: %s\", job.name, exc)\n        job.last_run = datetime.now(timezone.utc).isoformat()\n        job.run_count += 1\n\n    async def trigger(self, name: str) -> dict:\n        if name not in self._jobs:\n            raise KeyError(name)\n        job = self._jobs[name]\n        await self._run_job(job)\n        return {\"ok\": not job.last_error, \"name\": name}\n\n    async def start(self) -> None:\n        self._task = asyncio.create_task(self._loop())\n        logger.info(\"Heartbeat started — %d jobs\", len(self._jobs))\n\n    async def stop(self) -> None:\n        if self._task:\n            self._task.cancel()\n            try:\n                await self._task\n            except asyncio.CancelledError:\n                pass\n            self._task = None\n        logger.info(\"Heartbeat stopped\")\n\n    async def _loop(self) -> None:\n        while True:\n            await self.tick()\n            await asyncio.sleep(TICK_INTERVAL)\n\n    def status(self) -> list[dict]:\n        return [\n            {\n                \"name\": j.name,\n                \"interval\": j.interval_seconds,\n                \"last_run\": j.last_run,\n                \"run_count\": j.run_count,\n                \"last_error\": j.last_error,\n                \"enabled\": j.enabled,\n            }\n            for j in self._jobs.values()\n        ]\n"
  },
  {
    "path": "maggy/maggy/history/__init__.py",
    "content": "\"\"\"Session history analyzer — reads Claude/Codex/Kimi local state.\"\"\"\n"
  },
  {
    "path": "maggy/maggy/history/analyzer.py",
    "content": "\"\"\"Aggregation and pattern detection for session history.\"\"\"\n\nfrom __future__ import annotations\n\nfrom collections import Counter, defaultdict\nfrom datetime import datetime\n\nfrom .models import (\n    HistoryReport,\n    ProjectActivity,\n    ProviderUsage,\n    SessionEntry,\n    TimeDistribution,\n    _now_iso,\n)\n\n\ndef build_report(\n    sessions: list[SessionEntry],\n) -> HistoryReport:\n    \"\"\"Build complete history report from sessions.\"\"\"\n    if not sessions:\n        return HistoryReport(\n            generated_at=_now_iso(),\n            total_sessions=0,\n            total_prompts=0,\n        )\n    return HistoryReport(\n        generated_at=_now_iso(),\n        total_sessions=len(sessions),\n        total_prompts=sum(s.prompt_count for s in sessions),\n        providers=aggregate_by_provider(sessions),\n        projects=aggregate_by_project(sessions),\n        time_distribution=compute_time_distribution(sessions),\n        top_topics=extract_top_topics(sessions),\n        patterns=detect_patterns(sessions),\n    )\n\n\ndef aggregate_by_provider(\n    sessions: list[SessionEntry],\n) -> list[ProviderUsage]:\n    \"\"\"Group sessions by provider.\"\"\"\n    by_prov: dict[str, list[SessionEntry]] = defaultdict(list)\n    for s in sessions:\n        by_prov[s.provider].append(s)\n\n    result: list[ProviderUsage] = []\n    for prov, items in sorted(by_prov.items()):\n        minutes = sum(\n            s.duration_minutes or 0 for s in items\n        )\n        models: set[str] = set()\n        for s in items:\n            models.update(s.models_used)\n        result.append(ProviderUsage(\n            provider=prov,\n            session_count=len(items),\n            prompt_count=sum(s.prompt_count for s in items),\n            total_minutes=minutes,\n            models_used=sorted(models),\n        ))\n    return result\n\n\ndef aggregate_by_project(\n    sessions: list[SessionEntry],\n) -> list[ProjectActivity]:\n    \"\"\"Group sessions by project.\"\"\"\n    by_proj: dict[str, list[SessionEntry]] = defaultdict(list)\n    for s in sessions:\n        by_proj[s.project].append(s)\n\n    result: list[ProjectActivity] = []\n    for proj, items in sorted(by_proj.items()):\n        providers = sorted({s.provider for s in items})\n        dates = [s.started_at for s in items if s.started_at]\n        date_range = (min(dates), max(dates)) if dates else (\"\", \"\")\n        topics = _merge_topics(items)\n        result.append(ProjectActivity(\n            project=proj,\n            total_sessions=len(items),\n            total_prompts=sum(s.prompt_count for s in items),\n            providers_used=providers,\n            date_range=date_range,\n            top_topics=topics[:5],\n        ))\n    return result\n\n\ndef compute_time_distribution(\n    sessions: list[SessionEntry],\n) -> TimeDistribution:\n    \"\"\"Bucket sessions by hour, weekday, date.\"\"\"\n    by_hour: Counter[int] = Counter()\n    by_weekday: Counter[int] = Counter()\n    by_date: Counter[str] = Counter()\n\n    for s in sessions:\n        if not s.started_at:\n            continue\n        try:\n            dt = datetime.fromisoformat(s.started_at)\n        except ValueError:\n            continue\n        by_hour[dt.hour] += 1\n        by_weekday[dt.weekday()] += 1\n        by_date[dt.strftime(\"%Y-%m-%d\")] += s.prompt_count\n\n    return TimeDistribution(\n        by_hour=dict(by_hour),\n        by_weekday=dict(by_weekday),\n        by_date=dict(by_date),\n    )\n\n\ndef extract_top_topics(\n    sessions: list[SessionEntry],\n) -> list[str]:\n    \"\"\"Frequency-rank topics across all sessions.\"\"\"\n    counts: Counter[str] = Counter()\n    for s in sessions:\n        for t in s.topics:\n            counts[t] += 1\n    return [t for t, _ in counts.most_common(10)]\n\n\ndef detect_patterns(\n    sessions: list[SessionEntry],\n) -> list[str]:\n    \"\"\"Generate human-readable pattern observations.\"\"\"\n    if not sessions:\n        return []\n    patterns: list[str] = []\n    _detect_provider_dominance(sessions, patterns)\n    _detect_session_stats(sessions, patterns)\n    _detect_project_focus(sessions, patterns)\n    return patterns\n\n\ndef _detect_provider_dominance(\n    sessions: list[SessionEntry],\n    patterns: list[str],\n) -> None:\n    \"\"\"Check if one provider dominates usage.\"\"\"\n    counts = Counter(s.provider for s in sessions)\n    total = len(sessions)\n    for prov, count in counts.most_common(1):\n        pct = count * 100 // total\n        if pct >= 70:\n            patterns.append(\n                f\"{pct}% of sessions use {prov}\"\n            )\n\n\ndef _detect_session_stats(\n    sessions: list[SessionEntry],\n    patterns: list[str],\n) -> None:\n    \"\"\"Compute average session statistics.\"\"\"\n    avg_prompts = (\n        sum(s.prompt_count for s in sessions)\n        // len(sessions)\n    )\n    durations = [\n        s.duration_minutes for s in sessions\n        if s.duration_minutes is not None\n    ]\n    if durations:\n        avg_min = sum(durations) / len(durations)\n        patterns.append(\n            f\"Average session: {avg_prompts} prompts, \"\n            f\"{avg_min:.0f} minutes\"\n        )\n    else:\n        patterns.append(\n            f\"Average session: {avg_prompts} prompts\"\n        )\n\n\ndef _detect_project_focus(\n    sessions: list[SessionEntry],\n    patterns: list[str],\n) -> None:\n    \"\"\"Detect high-activity projects.\"\"\"\n    by_proj = Counter(s.project for s in sessions)\n    for proj, count in by_proj.most_common(1):\n        if count >= 5:\n            patterns.append(\n                f\"Project '{proj}' had {count} sessions\"\n                f\" — high focus\"\n            )\n\n\ndef _merge_topics(\n    sessions: list[SessionEntry],\n) -> list[str]:\n    \"\"\"Merge topics across sessions by frequency.\"\"\"\n    counts: Counter[str] = Counter()\n    for s in sessions:\n        for t in s.topics:\n            counts[t] += 1\n    return [t for t, _ in counts.most_common(10)]\n"
  },
  {
    "path": "maggy/maggy/history/models.py",
    "content": "\"\"\"Data models for session history analysis.\"\"\"\n\nfrom __future__ import annotations\n\nfrom dataclasses import dataclass, field\nfrom datetime import datetime, timezone\n\n\n@dataclass\nclass SessionEntry:\n    \"\"\"A single parsed session from any CLI.\"\"\"\n\n    session_id: str\n    provider: str  # \"claude\" | \"codex\" | \"kimi\"\n    project: str\n    started_at: str\n    ended_at: str\n    prompt_count: int\n    tool_use_count: int\n    models_used: list[str] = field(default_factory=list)\n    git_branch: str = \"\"\n    topics: list[str] = field(default_factory=list)\n    summary: str = \"\"\n\n    @property\n    def duration_minutes(self) -> float | None:\n        \"\"\"Session duration in minutes.\"\"\"\n        if not self.started_at or not self.ended_at:\n            return None\n        try:\n            start = datetime.fromisoformat(self.started_at)\n            end = datetime.fromisoformat(self.ended_at)\n            return (end - start).total_seconds() / 60\n        except (ValueError, TypeError):\n            return None\n\n\n@dataclass\nclass ProjectActivity:\n    \"\"\"Aggregated activity for a project across CLIs.\"\"\"\n\n    project: str\n    total_sessions: int\n    total_prompts: int\n    providers_used: list[str] = field(default_factory=list)\n    date_range: tuple[str, str] = (\"\", \"\")\n    top_topics: list[str] = field(default_factory=list)\n\n\n@dataclass\nclass ProviderUsage:\n    \"\"\"Usage statistics per provider.\"\"\"\n\n    provider: str\n    session_count: int\n    prompt_count: int\n    total_minutes: float\n    models_used: list[str] = field(default_factory=list)\n\n\n@dataclass\nclass TimeDistribution:\n    \"\"\"Work distribution across time periods.\"\"\"\n\n    by_hour: dict[int, int] = field(default_factory=dict)\n    by_weekday: dict[int, int] = field(default_factory=dict)\n    by_date: dict[str, int] = field(default_factory=dict)\n\n\n@dataclass\nclass HistoryReport:\n    \"\"\"Complete analysis report.\"\"\"\n\n    generated_at: str\n    total_sessions: int\n    total_prompts: int\n    providers: list[ProviderUsage] = field(\n        default_factory=list\n    )\n    projects: list[ProjectActivity] = field(\n        default_factory=list\n    )\n    time_distribution: TimeDistribution | None = None\n    top_topics: list[str] = field(default_factory=list)\n    patterns: list[str] = field(default_factory=list)\n    summary: str = \"\"\n\n\ndef _now_iso() -> str:\n    \"\"\"Current UTC timestamp as ISO string.\"\"\"\n    return datetime.now(timezone.utc).isoformat()\n"
  },
  {
    "path": "maggy/maggy/history/parsers/__init__.py",
    "content": "\"\"\"History parsers for Claude Code, Codex CLI, and Kimi CLI.\"\"\"\n\nfrom .claude import ClaudeHistoryParser\nfrom .codex import CodexHistoryParser\nfrom .kimi import KimiHistoryParser\n\n__all__ = [\n    \"ClaudeHistoryParser\",\n    \"CodexHistoryParser\",\n    \"KimiHistoryParser\",\n]\n"
  },
  {
    "path": "maggy/maggy/history/parsers/base.py",
    "content": "\"\"\"Abstract base for CLI history parsers.\"\"\"\n\nfrom __future__ import annotations\n\nfrom abc import ABC, abstractmethod\n\nfrom maggy.history.models import SessionEntry\n\n\nclass HistoryParser(ABC):\n    \"\"\"Base protocol for CLI history parsers.\"\"\"\n\n    provider: str\n\n    @abstractmethod\n    def is_available(self) -> bool:\n        \"\"\"Check if this CLI's data directory exists.\"\"\"\n        ...\n\n    @abstractmethod\n    def parse_sessions(\n        self, limit: int = 500,\n    ) -> list[SessionEntry]:\n        \"\"\"Parse session history into SessionEntry list.\"\"\"\n        ...\n\n    @abstractmethod\n    def session_count(self) -> int:\n        \"\"\"Return total number of sessions available.\"\"\"\n        ...\n"
  },
  {
    "path": "maggy/maggy/history/parsers/claude.py",
    "content": "\"\"\"Claude Code history parser — reads ~/.claude/ local state.\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport logging\nfrom collections import defaultdict\nfrom datetime import datetime, timezone\nfrom pathlib import Path\n\nfrom maggy.history.models import SessionEntry\n\nfrom .base import HistoryParser\n\nlogger = logging.getLogger(__name__)\n\n\ndef _millis_to_iso(ms: int | float) -> str:\n    \"\"\"Convert Unix milliseconds to ISO-8601.\"\"\"\n    dt = datetime.fromtimestamp(ms / 1000, tz=timezone.utc)\n    return dt.isoformat()\n\n\ndef _read_jsonl(path: Path) -> list[dict]:\n    \"\"\"Read JSONL file, skip bad lines.\"\"\"\n    if not path.exists():\n        return []\n    results: list[dict] = []\n    try:\n        for line in path.read_text().splitlines():\n            line = line.strip()\n            if not line:\n                continue\n            try:\n                results.append(json.loads(line))\n            except json.JSONDecodeError:\n                continue\n    except OSError:\n        return []\n    return results\n\n\ndef _extract_topics(prompts: list[str]) -> list[str]:\n    \"\"\"Extract keyword topics from prompt texts.\"\"\"\n    from collections import Counter\n    words: list[str] = []\n    for text in prompts:\n        for w in text.lower().split():\n            if len(w) > 3 and w.isalpha():\n                words.append(w)\n    counts = Counter(words)\n    return [w for w, _ in counts.most_common(5)]\n\n\nclass ClaudeHistoryParser(HistoryParser):\n    \"\"\"Parse Claude Code session history.\"\"\"\n\n    provider = \"claude\"\n\n    def __init__(self, claude_dir: Path | None = None):\n        self._dir = claude_dir or (\n            Path.home() / \".claude\"\n        )\n\n    def is_available(self) -> bool:\n        history = self._dir / \"history.jsonl\"\n        return history.exists()\n\n    def session_count(self) -> int:\n        entries = _read_jsonl(self._dir / \"history.jsonl\")\n        ids = {e.get(\"sessionId\") for e in entries}\n        ids.discard(None)\n        return len(ids)\n\n    def parse_sessions(\n        self, limit: int = 500,\n    ) -> list[SessionEntry]:\n        entries = _read_jsonl(self._dir / \"history.jsonl\")\n        if not entries:\n            return []\n\n        grouped = self._group_by_session(entries)\n        sessions: list[SessionEntry] = []\n        for sid, items in list(grouped.items())[:limit]:\n            session = self._build_entry(sid, items)\n            sessions.append(session)\n        return sessions\n\n    def _group_by_session(\n        self, entries: list[dict],\n    ) -> dict[str, list[dict]]:\n        grouped: dict[str, list[dict]] = defaultdict(list)\n        for e in entries:\n            sid = e.get(\"sessionId\")\n            if sid:\n                grouped[sid].append(e)\n        return dict(grouped)\n\n    def _build_entry(\n        self, sid: str, items: list[dict],\n    ) -> SessionEntry:\n        timestamps = [\n            i[\"timestamp\"] for i in items\n            if \"timestamp\" in i\n        ]\n        project = items[0].get(\"project\", \"\")\n        prompts = [\n            i.get(\"display\", \"\") for i in items\n            if i.get(\"display\")\n        ]\n        summary = prompts[0] if prompts else \"\"\n\n        started = _millis_to_iso(min(timestamps)) if timestamps else \"\"\n        ended = _millis_to_iso(max(timestamps)) if timestamps else \"\"\n\n        # Try reading transcript for richer data\n        extra = self._parse_transcript(sid, project)\n\n        return SessionEntry(\n            session_id=sid,\n            provider=\"claude\",\n            project=self._slug(project),\n            started_at=started,\n            ended_at=ended,\n            prompt_count=len(items),\n            tool_use_count=extra.get(\"tool_uses\", 0),\n            models_used=extra.get(\"models\", []),\n            git_branch=extra.get(\"branch\", \"\"),\n            topics=_extract_topics(prompts),\n            summary=summary,\n        )\n\n    def _slug(self, project_path: str) -> str:\n        \"\"\"Extract project name from path.\"\"\"\n        if not project_path:\n            return \"\"\n        return Path(project_path).name\n\n    def _find_transcript(\n        self, sid: str, project: str,\n    ) -> Path | None:\n        \"\"\"Locate transcript JSONL by session ID.\"\"\"\n        projects_dir = self._dir / \"projects\"\n        if not projects_dir.exists():\n            return None\n        slug = project.replace(\"/\", \"-\").lstrip(\"-\")\n        direct = projects_dir / slug / f\"{sid}.jsonl\"\n        if direct.exists():\n            return direct\n        # Search all project dirs for the session\n        for d in projects_dir.iterdir():\n            if not d.is_dir():\n                continue\n            f = d / f\"{sid}.jsonl\"\n            if f.exists():\n                return f\n        return None\n\n    def _parse_transcript(\n        self, sid: str, project: str,\n    ) -> dict:\n        \"\"\"Read session transcript for models/tools/branch.\"\"\"\n        if not project:\n            return {}\n        transcript = self._find_transcript(sid, project)\n        if not transcript:\n            return {}\n\n        entries = _read_jsonl(transcript)\n        models: set[str] = set()\n        tool_uses = 0\n        branch = \"\"\n\n        for e in entries:\n            etype = e.get(\"type\", \"\")\n            if etype == \"assistant\":\n                m = e.get(\"model\", \"\")\n                if m:\n                    models.add(m)\n                content = e.get(\"message\", {}).get(\n                    \"content\", []\n                )\n                if isinstance(content, list):\n                    tool_uses += sum(\n                        1 for b in content\n                        if isinstance(b, dict)\n                        and b.get(\"type\") == \"tool_use\"\n                    )\n            elif etype == \"user\" and not branch:\n                branch = e.get(\"gitBranch\", \"\")\n\n        return {\n            \"models\": sorted(models),\n            \"tool_uses\": tool_uses,\n            \"branch\": branch,\n        }\n"
  },
  {
    "path": "maggy/maggy/history/parsers/codex.py",
    "content": "\"\"\"Codex CLI history parser — reads ~/.codex/ local state.\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport logging\nfrom collections import defaultdict\nfrom datetime import datetime, timezone\nfrom pathlib import Path\n\nfrom maggy.history.models import SessionEntry\n\nfrom .base import HistoryParser\n\nlogger = logging.getLogger(__name__)\n\n\ndef _seconds_to_iso(ts: int | float) -> str:\n    \"\"\"Convert Unix seconds to ISO-8601.\"\"\"\n    dt = datetime.fromtimestamp(ts, tz=timezone.utc)\n    return dt.isoformat()\n\n\ndef _read_jsonl(path: Path) -> list[dict]:\n    \"\"\"Read JSONL file, skip bad lines.\"\"\"\n    if not path.exists():\n        return []\n    results: list[dict] = []\n    try:\n        for line in path.read_text().splitlines():\n            line = line.strip()\n            if not line:\n                continue\n            try:\n                results.append(json.loads(line))\n            except json.JSONDecodeError:\n                continue\n    except OSError:\n        return []\n    return results\n\n\ndef _extract_topics(texts: list[str]) -> list[str]:\n    \"\"\"Extract keyword topics from prompt texts.\"\"\"\n    from collections import Counter\n    words: list[str] = []\n    for text in texts:\n        for w in text.lower().split():\n            if len(w) > 3 and w.isalpha():\n                words.append(w)\n    counts = Counter(words)\n    return [w for w, _ in counts.most_common(5)]\n\n\nclass CodexHistoryParser(HistoryParser):\n    \"\"\"Parse OpenAI Codex CLI session history.\"\"\"\n\n    provider = \"codex\"\n\n    def __init__(self, codex_dir: Path | None = None):\n        self._dir = codex_dir or (\n            Path.home() / \".codex\"\n        )\n\n    def is_available(self) -> bool:\n        index = self._dir / \"session_index.jsonl\"\n        return index.exists()\n\n    def session_count(self) -> int:\n        entries = _read_jsonl(\n            self._dir / \"session_index.jsonl\"\n        )\n        return len(entries)\n\n    def parse_sessions(\n        self, limit: int = 500,\n    ) -> list[SessionEntry]:\n        index = _read_jsonl(\n            self._dir / \"session_index.jsonl\"\n        )\n        if not index:\n            return []\n\n        history = _read_jsonl(\n            self._dir / \"history.jsonl\"\n        )\n        prompts_by_sid = self._group_prompts(history)\n\n        sessions: list[SessionEntry] = []\n        for entry in index[:limit]:\n            sid = entry.get(\"id\", \"\")\n            if not sid:\n                continue\n            session = self._build_entry(\n                entry, prompts_by_sid.get(sid, []),\n            )\n            sessions.append(session)\n        return sessions\n\n    def _group_prompts(\n        self, history: list[dict],\n    ) -> dict[str, list[dict]]:\n        grouped: dict[str, list[dict]] = defaultdict(list)\n        for h in history:\n            sid = h.get(\"session_id\", \"\")\n            if sid:\n                grouped[sid].append(h)\n        return dict(grouped)\n\n    def _build_entry(\n        self, index_entry: dict, prompts: list[dict],\n    ) -> SessionEntry:\n        sid = index_entry.get(\"id\", \"\")\n        thread_name = index_entry.get(\"thread_name\", \"\")\n        updated = index_entry.get(\"updated_at\", \"\")\n\n        timestamps = [\n            p[\"ts\"] for p in prompts if \"ts\" in p\n        ]\n        texts = [\n            p.get(\"text\", \"\") for p in prompts\n            if p.get(\"text\")\n        ]\n\n        started = _seconds_to_iso(min(timestamps)) if timestamps else updated\n        ended = _seconds_to_iso(max(timestamps)) if timestamps else updated\n\n        return SessionEntry(\n            session_id=sid,\n            provider=\"codex\",\n            project=thread_name,\n            started_at=started,\n            ended_at=ended,\n            prompt_count=len(prompts),\n            tool_use_count=0,\n            models_used=[],\n            topics=_extract_topics(texts),\n            summary=thread_name or (\n                texts[0][:100] if texts else \"\"\n            ),\n        )\n"
  },
  {
    "path": "maggy/maggy/history/parsers/kimi.py",
    "content": "\"\"\"Kimi CLI history parser — reads ~/.kimi/ local state.\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport logging\nfrom datetime import datetime, timezone\nfrom pathlib import Path\n\nfrom maggy.history.models import SessionEntry\n\nfrom .base import HistoryParser\n\nlogger = logging.getLogger(__name__)\n\n\ndef _float_to_iso(ts: float) -> str:\n    \"\"\"Convert Unix float seconds to ISO-8601.\"\"\"\n    dt = datetime.fromtimestamp(ts, tz=timezone.utc)\n    return dt.isoformat()\n\n\ndef _read_jsonl(path: Path) -> list[dict]:\n    \"\"\"Read JSONL file, skip bad lines.\"\"\"\n    if not path.exists():\n        return []\n    results: list[dict] = []\n    try:\n        for line in path.read_text().splitlines():\n            line = line.strip()\n            if not line:\n                continue\n            try:\n                results.append(json.loads(line))\n            except json.JSONDecodeError:\n                continue\n    except OSError:\n        return []\n    return results\n\n\ndef _extract_topics(texts: list[str]) -> list[str]:\n    \"\"\"Extract keyword topics from texts.\"\"\"\n    from collections import Counter\n    words: list[str] = []\n    for text in texts:\n        for w in text.lower().split():\n            if len(w) > 3 and w.isalpha():\n                words.append(w)\n    counts = Counter(words)\n    return [w for w, _ in counts.most_common(5)]\n\n\nclass KimiHistoryParser(HistoryParser):\n    \"\"\"Parse Moonshot Kimi CLI session history.\"\"\"\n\n    provider = \"kimi\"\n\n    def __init__(self, kimi_dir: Path | None = None):\n        self._dir = kimi_dir or (\n            Path.home() / \".kimi\"\n        )\n\n    def is_available(self) -> bool:\n        sessions = self._dir / \"sessions\"\n        return sessions.exists() and sessions.is_dir()\n\n    def session_count(self) -> int:\n        return len(self._find_session_dirs())\n\n    def parse_sessions(\n        self, limit: int = 500,\n    ) -> list[SessionEntry]:\n        dirs = self._find_session_dirs()\n        sessions: list[SessionEntry] = []\n        for d in dirs[:limit]:\n            entry = self._parse_session_dir(d)\n            if entry:\n                sessions.append(entry)\n        return sessions\n\n    def _find_session_dirs(self) -> list[Path]:\n        \"\"\"Find all session UUID directories.\"\"\"\n        sessions_root = self._dir / \"sessions\"\n        if not sessions_root.exists():\n            return []\n        dirs: list[Path] = []\n        for hash_dir in sessions_root.iterdir():\n            if not hash_dir.is_dir():\n                continue\n            for uuid_dir in hash_dir.iterdir():\n                if not uuid_dir.is_dir():\n                    continue\n                ctx = uuid_dir / \"context.jsonl\"\n                if ctx.exists():\n                    dirs.append(uuid_dir)\n        return dirs\n\n    def _parse_session_dir(\n        self, session_dir: Path,\n    ) -> SessionEntry | None:\n        context = _read_jsonl(\n            session_dir / \"context.jsonl\"\n        )\n        if not context:\n            return None\n\n        user_msgs = [\n            e for e in context\n            if e.get(\"role\") == \"user\"\n        ]\n        prompts = []\n        for e in user_msgs:\n            c = e.get(\"content\", \"\")\n            if isinstance(c, str):\n                prompts.append(c)\n            elif isinstance(c, list):\n                prompts.append(str(c[0]) if c else \"\")\n        summary = prompts[0][:100] if prompts else \"\"\n\n        wire = self._parse_wire(session_dir)\n\n        return SessionEntry(\n            session_id=session_dir.name,\n            provider=\"kimi\",\n            project=\"\",\n            started_at=wire.get(\"started\", \"\"),\n            ended_at=wire.get(\"ended\", \"\"),\n            prompt_count=len(user_msgs),\n            tool_use_count=wire.get(\"steps\", 0),\n            models_used=[],\n            topics=_extract_topics(prompts),\n            summary=summary,\n        )\n\n    def _parse_wire(self, session_dir: Path) -> dict:\n        \"\"\"Extract timestamps and step counts from wire.\"\"\"\n        entries = _read_jsonl(\n            session_dir / \"wire.jsonl\"\n        )\n        if not entries:\n            return {}\n\n        timestamps: list[float] = []\n        steps = 0\n        for e in entries:\n            ts = e.get(\"timestamp\")\n            if isinstance(ts, (int, float)):\n                timestamps.append(float(ts))\n            msg_str = e.get(\"message\", \"\")\n            if \"StepBegin\" in str(msg_str):\n                steps += 1\n\n        result: dict = {\"steps\": steps}\n        if timestamps:\n            result[\"started\"] = _float_to_iso(\n                min(timestamps)\n            )\n            result[\"ended\"] = _float_to_iso(\n                max(timestamps)\n            )\n        return result\n"
  },
  {
    "path": "maggy/maggy/history/service.py",
    "content": "\"\"\"History analysis service — orchestrates the full pipeline.\"\"\"\n\nfrom __future__ import annotations\n\nimport logging\nfrom pathlib import Path\n\nfrom .analyzer import build_report\nfrom .models import HistoryReport\nfrom .parsers.claude import ClaudeHistoryParser\nfrom .parsers.codex import CodexHistoryParser\nfrom .parsers.kimi import KimiHistoryParser\nfrom .store import HistoryStore\n\nlogger = logging.getLogger(__name__)\n\n\nclass HistoryService:\n    \"\"\"Orchestrates session history analysis.\"\"\"\n\n    def __init__(\n        self,\n        db_path: Path | None = None,\n        cli_dirs: dict[str, Path] | None = None,\n    ):\n        db = db_path or (\n            Path.home() / \".maggy\" / \"history.db\"\n        )\n        self._store = HistoryStore(db)\n        dirs = cli_dirs or {}\n        self._parsers = [\n            ClaudeHistoryParser(dirs.get(\"claude\")),\n            CodexHistoryParser(dirs.get(\"codex\")),\n            KimiHistoryParser(dirs.get(\"kimi\")),\n        ]\n\n    def analyze(self) -> HistoryReport:\n        \"\"\"Parse all CLIs, analyze, store report.\"\"\"\n        all_sessions = self._collect_sessions()\n        report = build_report(all_sessions)\n\n        if all_sessions:\n            self._store.save_sessions(all_sessions)\n        self._store.save_report(report)\n\n        logger.info(\n            \"History analysis: %d sessions, %d prompts, \"\n            \"%d providers\",\n            report.total_sessions,\n            report.total_prompts,\n            len(report.providers),\n        )\n        return report\n\n    def _collect_sessions(self) -> list:\n        \"\"\"Collect sessions from all available parsers.\"\"\"\n        sessions = []\n        for parser in self._parsers:\n            if not parser.is_available():\n                logger.debug(\n                    \"%s not available, skipping\",\n                    parser.provider,\n                )\n                continue\n            try:\n                parsed = parser.parse_sessions()\n                sessions.extend(parsed)\n                logger.info(\n                    \"Parsed %d sessions from %s\",\n                    len(parsed), parser.provider,\n                )\n            except Exception:\n                logger.exception(\n                    \"Failed to parse %s history\",\n                    parser.provider,\n                )\n        return sessions\n\n    def get_report(self) -> dict | None:\n        \"\"\"Get latest cached report.\"\"\"\n        return self._store.load_latest_report()\n\n    def get_sessions(\n        self, provider: str | None = None,\n    ) -> list[dict]:\n        \"\"\"Get stored session records.\"\"\"\n        return self._store.load_sessions(\n            provider=provider,\n        )\n\n    def available_providers(self) -> list[str]:\n        \"\"\"List which CLIs are available.\"\"\"\n        return [\n            p.provider for p in self._parsers\n            if p.is_available()\n        ]\n"
  },
  {
    "path": "maggy/maggy/history/store.py",
    "content": "\"\"\"SQLite store for session history data.\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport sqlite3\nfrom contextlib import contextmanager\nfrom dataclasses import asdict\nfrom datetime import datetime, timezone\nfrom pathlib import Path\nfrom typing import Iterator\n\nfrom .models import HistoryReport, SessionEntry\n\nSCHEMA = \"\"\"\nCREATE TABLE IF NOT EXISTS sessions (\n    id INTEGER PRIMARY KEY AUTOINCREMENT,\n    session_id TEXT NOT NULL,\n    provider TEXT NOT NULL,\n    project TEXT NOT NULL,\n    started_at TEXT NOT NULL,\n    ended_at TEXT NOT NULL DEFAULT '',\n    prompt_count INTEGER NOT NULL DEFAULT 0,\n    tool_use_count INTEGER NOT NULL DEFAULT 0,\n    models_used TEXT NOT NULL DEFAULT '[]',\n    git_branch TEXT NOT NULL DEFAULT '',\n    topics TEXT NOT NULL DEFAULT '[]',\n    summary TEXT NOT NULL DEFAULT '',\n    ingested_at TEXT NOT NULL\n);\nCREATE INDEX IF NOT EXISTS idx_hsess_provider\n    ON sessions(provider);\nCREATE INDEX IF NOT EXISTS idx_hsess_project\n    ON sessions(project);\n\nCREATE TABLE IF NOT EXISTS history_reports (\n    id INTEGER PRIMARY KEY AUTOINCREMENT,\n    generated_at TEXT NOT NULL,\n    payload TEXT NOT NULL\n);\n\"\"\"\n\n\n@contextmanager\ndef _connect(path: Path) -> Iterator[sqlite3.Connection]:\n    path.parent.mkdir(parents=True, exist_ok=True)\n    conn = sqlite3.connect(str(path), timeout=30.0)\n    conn.execute(\"PRAGMA journal_mode=WAL\")\n    conn.execute(\"PRAGMA busy_timeout=30000\")\n    conn.row_factory = sqlite3.Row\n    try:\n        yield conn\n    finally:\n        conn.close()\n\n\nclass HistoryStore:\n    \"\"\"SQLite-backed session history storage.\"\"\"\n\n    def __init__(self, db_path: Path):\n        self._db_path = db_path\n        with _connect(self._db_path) as conn:\n            conn.executescript(SCHEMA)\n\n    def save_sessions(\n        self, sessions: list[SessionEntry],\n    ) -> None:\n        \"\"\"Save parsed session entries.\"\"\"\n        now = datetime.now(timezone.utc).isoformat()\n        with _connect(self._db_path) as conn:\n            for s in sessions:\n                conn.execute(\n                    \"INSERT INTO sessions \"\n                    \"(session_id, provider, project, \"\n                    \"started_at, ended_at, prompt_count, \"\n                    \"tool_use_count, models_used, \"\n                    \"git_branch, topics, summary, \"\n                    \"ingested_at) \"\n                    \"VALUES (?,?,?,?,?,?,?,?,?,?,?,?)\",\n                    (\n                        s.session_id, s.provider,\n                        s.project, s.started_at,\n                        s.ended_at, s.prompt_count,\n                        s.tool_use_count,\n                        json.dumps(s.models_used),\n                        s.git_branch,\n                        json.dumps(s.topics),\n                        s.summary, now,\n                    ),\n                )\n            conn.commit()\n\n    def load_sessions(\n        self,\n        provider: str | None = None,\n        limit: int = 500,\n    ) -> list[dict]:\n        \"\"\"Load stored session records.\"\"\"\n        with _connect(self._db_path) as conn:\n            if provider:\n                rows = conn.execute(\n                    \"SELECT * FROM sessions \"\n                    \"WHERE provider = ? \"\n                    \"ORDER BY started_at DESC \"\n                    \"LIMIT ?\",\n                    (provider, limit),\n                ).fetchall()\n            else:\n                rows = conn.execute(\n                    \"SELECT * FROM sessions \"\n                    \"ORDER BY started_at DESC \"\n                    \"LIMIT ?\",\n                    (limit,),\n                ).fetchall()\n        return [self._row_to_dict(r) for r in rows]\n\n    def save_report(self, report: HistoryReport) -> None:\n        \"\"\"Save an analysis report.\"\"\"\n        payload = json.dumps(asdict(report))\n        with _connect(self._db_path) as conn:\n            conn.execute(\n                \"INSERT INTO history_reports \"\n                \"(generated_at, payload) VALUES (?, ?)\",\n                (report.generated_at, payload),\n            )\n            conn.commit()\n\n    def load_latest_report(self) -> dict | None:\n        \"\"\"Load the most recent report.\"\"\"\n        with _connect(self._db_path) as conn:\n            row = conn.execute(\n                \"SELECT payload FROM history_reports \"\n                \"ORDER BY id DESC LIMIT 1\",\n            ).fetchone()\n        if not row:\n            return None\n        return json.loads(row[\"payload\"])\n\n    def _row_to_dict(self, r: sqlite3.Row) -> dict:\n        \"\"\"Convert a session row to dict.\"\"\"\n        return {\n            \"session_id\": r[\"session_id\"],\n            \"provider\": r[\"provider\"],\n            \"project\": r[\"project\"],\n            \"started_at\": r[\"started_at\"],\n            \"ended_at\": r[\"ended_at\"],\n            \"prompt_count\": r[\"prompt_count\"],\n            \"tool_use_count\": r[\"tool_use_count\"],\n            \"models_used\": json.loads(r[\"models_used\"]),\n            \"git_branch\": r[\"git_branch\"],\n            \"topics\": json.loads(r[\"topics\"]),\n            \"summary\": r[\"summary\"],\n        }\n"
  },
  {
    "path": "maggy/maggy/improve/__init__.py",
    "content": "\"\"\"Self-improvement — signal collection and analysis.\"\"\"\n"
  },
  {
    "path": "maggy/maggy/improve/analyzer.py",
    "content": "\"\"\"Analyze collected signals and produce recommendations.\"\"\"\n\nfrom __future__ import annotations\n\nfrom .models import Recommendation, SignalBundle\n\nLOW_REWARD = 0.4\nHIGH_FAILURE_RATE = 0.2\nLOW_USAGE_RATE = 0.05\nLOW_HEALTH = 0.5\nHIGH_UTILIZATION = 0.9\n\n\ndef analyze_routing(signals: SignalBundle) -> list[Recommendation]:\n    \"\"\"Flag models with low average reward.\"\"\"\n    recs: list[Recommendation] = []\n    for entry in signals.routing.get(\"underperformers\", []):\n        recs.append(Recommendation(\n            category=\"routing\",\n            severity=\"warning\",\n            message=(\n                f\"Model {entry.get('model', '?')} underperforms on \"\n                f\"{entry.get('task_type', '?')} \"\n                f\"(avg reward {entry.get('avg_reward', 0):.2f}).\"\n            ),\n            suggestion=\"Consider routing to a different model.\",\n            data=entry,\n        ))\n    return recs\n\n\ndef analyze_failures(signals: SignalBundle) -> list[Recommendation]:\n    \"\"\"Flag high execution failure rates.\"\"\"\n    rate = signals.events.get(\"failure_rate\", 0)\n    if rate < HIGH_FAILURE_RATE:\n        return []\n    return [Recommendation(\n        category=\"reliability\",\n        severity=\"action\",\n        message=f\"Execution failure rate is {rate:.0%}.\",\n        suggestion=\"Check tool configuration and logs.\",\n        data=signals.events,\n    )]\n\n\ndef analyze_usage(signals: SignalBundle) -> list[Recommendation]:\n    \"\"\"Detect underutilized providers.\"\"\"\n    recs: list[Recommendation] = []\n    by_provider = signals.history.get(\"by_provider\", {})\n    total = signals.history.get(\"sessions\", 0)\n    if total == 0:\n        return []\n    for provider, count in by_provider.items():\n        ratio = count / total\n        if ratio < LOW_USAGE_RATE:\n            recs.append(Recommendation(\n                category=\"usage\",\n                severity=\"info\",\n                message=(\n                    f\"{provider} used in only \"\n                    f\"{ratio:.0%} of sessions.\"\n                ),\n                suggestion=\"Consider removing or promoting it.\",\n                data={\"provider\": provider, \"ratio\": ratio},\n            ))\n    return recs\n\n\ndef analyze_gaps(signals: SignalBundle) -> list[Recommendation]:\n    \"\"\"Surface triggered capability gaps.\"\"\"\n    recs: list[Recommendation] = []\n    for gap in signals.forge.get(\"gaps\", []):\n        recs.append(Recommendation(\n            category=\"capability\",\n            severity=\"action\",\n            message=(\n                f\"Capability '{gap.get('name', '?')}' \"\n                f\"requested {gap.get('count', 0)} times.\"\n            ),\n            suggestion=\"Consider building an MCP server.\",\n            data=gap,\n        ))\n    return recs\n\n\ndef analyze_memory(signals: SignalBundle) -> list[Recommendation]:\n    \"\"\"Flag low engram health scores.\"\"\"\n    score = signals.engram.get(\"health_score\", 1.0)\n    if score >= LOW_HEALTH:\n        return []\n    return [Recommendation(\n        category=\"memory\",\n        severity=\"warning\",\n        message=f\"Memory health is {score:.2f}.\",\n        suggestion=\"Run engram cleanup or review superseded records.\",\n        data=signals.engram,\n    )]\n\n\ndef analyze_cost(signals: SignalBundle) -> list[Recommendation]:\n    \"\"\"Flag high budget utilization.\"\"\"\n    util = signals.budget.get(\"utilization\", 0)\n    if util < HIGH_UTILIZATION:\n        return []\n    return [Recommendation(\n        category=\"cost\",\n        severity=\"action\",\n        message=f\"Budget utilization at {util:.0%}.\",\n        suggestion=\"Increase daily_limit_usd or optimize routing.\",\n        data=signals.budget,\n    )]\n\n\ndef analyze_all(signals: SignalBundle) -> list[Recommendation]:\n    \"\"\"Run all analyzers and merge results.\"\"\"\n    recs: list[Recommendation] = []\n    for fn in (\n        analyze_routing, analyze_failures, analyze_usage,\n        analyze_gaps, analyze_memory, analyze_cost,\n    ):\n        recs.extend(fn(signals))\n    return recs\n"
  },
  {
    "path": "maggy/maggy/improve/models.py",
    "content": "\"\"\"Data models for self-improvement analysis.\"\"\"\n\nfrom __future__ import annotations\n\nfrom dataclasses import dataclass, field\n\n\n@dataclass\nclass Recommendation:\n    category: str   # routing | reliability | usage | capability | memory | cost\n    severity: str   # info | warning | action\n    message: str\n    suggestion: str\n    data: dict = field(default_factory=dict)\n\n\n@dataclass\nclass SignalBundle:\n    routing: dict = field(default_factory=dict)\n    events: dict = field(default_factory=dict)\n    history: dict = field(default_factory=dict)\n    forge: dict = field(default_factory=dict)\n    engram: dict = field(default_factory=dict)\n    budget: dict = field(default_factory=dict)\n    collected_at: str = \"\"\n\n\n@dataclass\nclass ImprovementReport:\n    generated_at: str\n    total_signals: int\n    recommendations: list[Recommendation] = field(default_factory=list)\n    health_summary: dict = field(default_factory=dict)\n    top_actions: list[str] = field(default_factory=list)\n"
  },
  {
    "path": "maggy/maggy/improve/service.py",
    "content": "\"\"\"Introspector — orchestrates signal collection and analysis.\"\"\"\n\nfrom __future__ import annotations\n\nimport logging\nfrom datetime import datetime, timezone\n\nfrom .analyzer import analyze_all\nfrom .models import ImprovementReport, SignalBundle\nfrom .signals import collect_all\n\nlogger = logging.getLogger(__name__)\n\n\nclass Introspector:\n    \"\"\"Collect signals, analyze, persist recommendations.\"\"\"\n\n    def __init__(self, app_state) -> None:\n        self._state = app_state\n        self._last_report: ImprovementReport | None = None\n\n    def analyze(self) -> ImprovementReport:\n        \"\"\"Run full analysis cycle.\"\"\"\n        signals = collect_all(self._state)\n        recs = analyze_all(signals)\n        report = self._build_report(signals, recs)\n        self._persist(report)\n        self._last_report = report\n        return report\n\n    def get_report(self) -> ImprovementReport | None:\n        \"\"\"Return the most recent report.\"\"\"\n        return self._last_report\n\n    def _build_report(self, signals, recs) -> ImprovementReport:\n        total = sum(\n            1 for v in (\n                signals.routing, signals.events,\n                signals.history, signals.forge,\n                signals.engram, signals.budget,\n            )\n            if v\n        )\n        actions = [\n            r.message for r in recs if r.severity == \"action\"\n        ][:3]\n        health = self._health_summary(signals)\n        return ImprovementReport(\n            generated_at=datetime.now(timezone.utc).isoformat(),\n            total_signals=total,\n            recommendations=recs,\n            health_summary=health,\n            top_actions=actions,\n        )\n\n    def _health_summary(self, s: SignalBundle) -> dict:\n        summary: dict = {}\n        if s.routing:\n            bad = len(s.routing.get(\"underperformers\", []))\n            summary[\"routing\"] = 0.5 if bad else 1.0\n        if s.engram:\n            summary[\"memory\"] = s.engram.get(\"health_score\", 1.0)\n        if s.events:\n            rate = s.events.get(\"failure_rate\", 0)\n            summary[\"reliability\"] = round(1.0 - rate, 2)\n        if s.budget:\n            util = s.budget.get(\"utilization\", 0)\n            summary[\"cost\"] = round(1.0 - util, 2)\n        return summary\n\n    def _persist(self, report: ImprovementReport) -> None:\n        \"\"\"Write report as engram + emit mutation events.\"\"\"\n        engram = getattr(self._state, \"engram\", None)\n        if engram:\n            self._write_engram(engram, report)\n        events = getattr(self._state, \"events\", None)\n        if events:\n            self._emit_mutations(events, report)\n\n    def _write_engram(self, engram, report) -> None:\n        from maggy.engram.record import EngramRecord\n        import uuid\n        try:\n            record = EngramRecord(\n                engram_id=uuid.uuid4().hex[:12],\n                namespace=\"self-improvement\",\n                memory_type=\"fact\",\n                content=f\"Report: {len(report.recommendations)} recs\",\n                tags=[\"auto-improve\"],\n            )\n            engram.write(record)\n        except Exception as exc:\n            logger.warning(\"Failed to write engram: %s\", exc)\n\n    def _emit_mutations(self, events, report) -> None:\n        from maggy.event_spine.events import MutationEvent\n        from maggy.event_spine.header import EventHeader\n        for rec in report.recommendations:\n            if rec.severity != \"action\":\n                continue\n            try:\n                evt = MutationEvent(\n                    header=EventHeader(event_type=\"mutation\"),\n                    control_level=\"advisory\",\n                    target=rec.category,\n                    old_value=\"\",\n                    new_value=rec.suggestion,\n                    reason=rec.message,\n                )\n                events.emit(evt)\n            except Exception as exc:\n                logger.warning(\"Failed to emit: %s\", exc)\n"
  },
  {
    "path": "maggy/maggy/improve/signals.py",
    "content": "\"\"\"Signal collectors — pull data from existing services.\"\"\"\n\nfrom __future__ import annotations\n\nfrom datetime import datetime, timezone\n\nfrom .models import SignalBundle\n\nMIN_SAMPLES = 5\nLOW_REWARD = 0.4\nHIGH_FAILURE_RATE = 0.2\nLOW_USAGE_RATE = 0.05\n\n\ndef collect_routing(routing) -> dict:\n    \"\"\"Read reward heatmap from RoutingService.\"\"\"\n    heatmap = routing.get_heatmap()\n    underperformers = [\n        entry for entry in heatmap\n        if entry.get(\"count\", 0) >= MIN_SAMPLES\n        and entry.get(\"avg_reward\", 1.0) < LOW_REWARD\n    ]\n    return {\"heatmap\": heatmap, \"underperformers\": underperformers}\n\n\ndef collect_events(events) -> dict:\n    \"\"\"Read outcome events for failure analysis.\"\"\"\n    outcomes = events.query(event_type=\"outcome\", limit=200)\n    total = len(outcomes)\n    failures = sum(\n        1 for o in outcomes\n        if not o.get(\"success\", True)\n    )\n    rate = failures / total if total else 0.0\n    return {\n        \"total\": total,\n        \"failures\": failures,\n        \"failure_rate\": round(rate, 3),\n    }\n\n\ndef collect_history(history) -> dict:\n    \"\"\"Read session patterns from HistoryService.\"\"\"\n    report = history.get_report()\n    if not report:\n        return {\"sessions\": 0, \"patterns\": []}\n    return {\n        \"sessions\": report.get(\"total_sessions\", 0),\n        \"patterns\": report.get(\"patterns\", []),\n        \"by_provider\": report.get(\"by_provider\", {}),\n    }\n\n\ndef collect_forge(forge) -> dict:\n    \"\"\"Read capability gaps from ForgeConnector.\"\"\"\n    gaps = forge.get_gaps()\n    return {\"gaps\": gaps, \"count\": len(gaps)}\n\n\ndef collect_engram(engram) -> dict:\n    \"\"\"Read memory health from EngramStore.\"\"\"\n    from maggy.engram.diagnostics import diagnose\n    profile = diagnose(engram)\n    return {\n        \"health_score\": profile.health_score,\n        \"total\": profile.total_memories,\n        \"active\": profile.active_count,\n        \"superseded\": profile.superseded_count,\n    }\n\n\ndef collect_budget(budget) -> dict:\n    \"\"\"Read spend patterns from BudgetManager.\"\"\"\n    return budget.budget_status()\n\n\ndef collect_all(app_state) -> SignalBundle:\n    \"\"\"Collect signals from all available services.\"\"\"\n    bundle = SignalBundle(\n        collected_at=datetime.now(timezone.utc).isoformat(),\n    )\n    if app_state.routing:\n        bundle.routing = collect_routing(app_state.routing)\n    if app_state.events:\n        bundle.events = collect_events(app_state.events)\n    if app_state.history:\n        bundle.history = collect_history(app_state.history)\n    if app_state.forge:\n        bundle.forge = collect_forge(app_state.forge)\n    if app_state.engram:\n        bundle.engram = collect_engram(app_state.engram)\n    if app_state.budget:\n        bundle.budget = collect_budget(app_state.budget)\n    return bundle\n"
  },
  {
    "path": "maggy/maggy/lexon/__init__.py",
    "content": "\"\"\"Lexon — intent parsing and tool disambiguation.\"\"\"\n"
  },
  {
    "path": "maggy/maggy/lexon/disambiguate.py",
    "content": "\"\"\"Confidence-gated disambiguation for ambiguous intents.\"\"\"\n\nfrom __future__ import annotations\n\nfrom dataclasses import dataclass\n\nSELF_CLARIFY_THRESHOLD = 0.5\nUSER_CLARIFY_THRESHOLD = 0.3\n\n\n@dataclass\nclass DisambiguationResult:\n    \"\"\"Outcome of disambiguation attempt.\"\"\"\n\n    resolved: bool\n    tool: str = \"\"\n    mode: str = \"\"  # self_clarify | user_clarify | none\n    suggestions: list[str] | None = None\n\n\ndef disambiguate(\n    confidence: float,\n    candidates: list[str],\n) -> DisambiguationResult:\n    \"\"\"Determine disambiguation strategy.\n\n    >= 0.7: auto-resolve (no disambiguation needed)\n    0.5-0.7: self-clarify (use context to pick)\n    0.3-0.5: user-clarify (ask the user)\n    < 0.3: reject (too ambiguous)\n    \"\"\"\n    if confidence >= 0.7 and candidates:\n        return DisambiguationResult(\n            resolved=True, tool=candidates[0], mode=\"none\",\n        )\n\n    if confidence >= SELF_CLARIFY_THRESHOLD and candidates:\n        return DisambiguationResult(\n            resolved=True, tool=candidates[0],\n            mode=\"self_clarify\",\n            suggestions=candidates[:3],\n        )\n\n    if confidence >= USER_CLARIFY_THRESHOLD and candidates:\n        return DisambiguationResult(\n            resolved=False, mode=\"user_clarify\",\n            suggestions=candidates[:5],\n        )\n\n    return DisambiguationResult(\n        resolved=False, mode=\"none\",\n        suggestions=candidates[:3] if candidates else None,\n    )\n"
  },
  {
    "path": "maggy/maggy/lexon/personalization.py",
    "content": "\"\"\"Implicit learning — tracks 5 user behavior signals.\"\"\"\n\nfrom __future__ import annotations\n\nfrom collections import Counter\nfrom dataclasses import dataclass, field\n\n\n@dataclass\nclass UserSignals:\n    \"\"\"Five implicit signals for personalization.\"\"\"\n\n    tool_frequency: Counter = field(\n        default_factory=Counter\n    )\n    correction_pairs: list[tuple[str, str]] = field(\n        default_factory=list\n    )\n    preferred_aliases: dict[str, str] = field(\n        default_factory=dict\n    )\n    rejection_count: Counter = field(\n        default_factory=Counter\n    )\n    confirmation_rate: dict[str, float] = field(\n        default_factory=dict\n    )\n\n\nclass PersonalizationEngine:\n    \"\"\"Learns from user behavior to improve intent parsing.\"\"\"\n\n    def __init__(self):\n        self._signals = UserSignals()\n\n    def record_use(self, tool: str) -> None:\n        \"\"\"Signal 1: Track tool usage frequency.\"\"\"\n        self._signals.tool_frequency[tool] += 1\n\n    def record_correction(\n        self, wrong: str, correct: str,\n    ) -> None:\n        \"\"\"Signal 2: Track user corrections.\"\"\"\n        self._signals.correction_pairs.append(\n            (wrong, correct)\n        )\n\n    def record_alias(\n        self, phrase: str, tool: str,\n    ) -> None:\n        \"\"\"Signal 3: Track preferred naming.\"\"\"\n        self._signals.preferred_aliases[\n            phrase.lower()\n        ] = tool\n\n    def record_rejection(self, tool: str) -> None:\n        \"\"\"Signal 4: Track rejected suggestions.\"\"\"\n        self._signals.rejection_count[tool] += 1\n\n    def get_preferred(self, phrase: str) -> str | None:\n        \"\"\"Check if user has a preference for this phrase.\"\"\"\n        return self._signals.preferred_aliases.get(\n            phrase.lower()\n        )\n\n    def top_tools(self, n: int = 5) -> list[str]:\n        \"\"\"Return most frequently used tools.\"\"\"\n        return [\n            t for t, _ in self._signals.tool_frequency.most_common(n)\n        ]\n\n    @property\n    def signals(self) -> UserSignals:\n        return self._signals\n"
  },
  {
    "path": "maggy/maggy/lexon/record.py",
    "content": "\"\"\"LexonRecord — parsed intent with confidence.\"\"\"\n\nfrom __future__ import annotations\n\nfrom dataclasses import dataclass, field\nfrom datetime import datetime, timezone\n\n\n@dataclass\nclass LexonRecord:\n    \"\"\"A parsed user intent.\"\"\"\n\n    phrase: str\n    resolved_tool: str = \"\"\n    confidence: float = 0.0\n    candidates: list[str] = field(default_factory=list)\n    disambiguation_mode: str = \"\"  # \"\" | self_clarify | user_clarify\n    created_at: str = field(\n        default_factory=lambda: datetime.now(\n            timezone.utc\n        ).isoformat()\n    )\n\n    @property\n    def is_ambiguous(self) -> bool:\n        return self.confidence < 0.7\n\n    @property\n    def needs_user_input(self) -> bool:\n        return self.disambiguation_mode == \"user_clarify\"\n"
  },
  {
    "path": "maggy/maggy/lexon/router.py",
    "content": "\"\"\"Two-tier Lexon router — fast keyword + fallback LLM.\"\"\"\n\nfrom __future__ import annotations\n\nfrom .disambiguate import disambiguate\nfrom .personalization import PersonalizationEngine\nfrom .record import LexonRecord\nfrom .terminology import TerminologyMap\n\nCONFIDENCE_THRESHOLD = 0.82\nTOP2_GAP = 0.15\nDEFAULT_TOOL_MANIFEST = {\n    \"deploy\": [\"vercel_deploy\", \"docker_push\"],\n    \"test\": [\"pytest\", \"vitest\", \"jest\"],\n    \"fix\": [\"code_edit\", \"patch\"],\n    \"create\": [\"file_create\", \"scaffold\"],\n    \"delete\": [\"file_delete\", \"cleanup\"],\n    \"update\": [\"code_edit\", \"config_update\"],\n    \"search\": [\"grep\", \"glob\", \"find\"],\n    \"review\": [\"code_review\", \"pr_review\"],\n}\n\n\nclass LexonRouter:\n    \"\"\"Routes user phrases to tools using two tiers.\n\n    Tier 1: Fast keyword/terminology lookup\n    Tier 2: LLM-based intent classification (stub)\n    \"\"\"\n\n    def __init__(self, config: dict[str, object] | None = None):\n        self._config = config or {}\n        self._terms = TerminologyMap()\n        self._personal = PersonalizationEngine()\n        self._tool_map = self._load_tool_manifest()\n\n    def route(self, phrase: str) -> LexonRecord:\n        \"\"\"Route a phrase to a tool.\"\"\"\n        preferred = self._personal.get_preferred(phrase)\n        if preferred:\n            return LexonRecord(\n                phrase=phrase,\n                resolved_tool=preferred,\n                confidence=0.95,\n                candidates=[preferred],\n            )\n        tier1 = self._route_tier1(phrase)\n        if tier1:\n            return tier1\n        return self._llm_classify(phrase)\n\n    def learn(self, phrase: str, tool: str) -> None:\n        \"\"\"Record a confirmed tool selection.\"\"\"\n        self._personal.record_use(tool)\n        self._personal.record_alias(phrase, tool)\n\n    @property\n    def terminology(self) -> TerminologyMap:\n        return self._terms\n\n    @property\n    def personalization(self) -> PersonalizationEngine:\n        return self._personal\n\n    def _load_tool_manifest(self) -> dict[str, list[str]]:\n        manifest = self._config.get(\"tool_manifest\", DEFAULT_TOOL_MANIFEST)\n        if not isinstance(manifest, dict):\n            return dict(DEFAULT_TOOL_MANIFEST)\n        return {\n            str(key): [str(item) for item in value]\n            for key, value in manifest.items()\n            if isinstance(value, list)\n        } or dict(DEFAULT_TOOL_MANIFEST)\n\n    def _llm_classify(self, phrase: str) -> LexonRecord:\n        return LexonRecord(\n            phrase=phrase,\n            confidence=0.55,\n            disambiguation_mode=\"llm\",\n        )\n\n    def _route_tier1(self, phrase: str) -> LexonRecord | None:\n        for word in phrase.lower().split():\n            canonical = self._terms.resolve(word)\n            if canonical and canonical in self._tool_map:\n                return self._resolve_manifest_match(phrase, self._tool_map[canonical])\n        return None\n\n    def _resolve_manifest_match(\n        self,\n        phrase: str,\n        candidates: list[str],\n    ) -> LexonRecord:\n        confidence = self._keyword_confidence(candidates)\n        if confidence < CONFIDENCE_THRESHOLD:\n            return self._llm_classify(phrase)\n        if self._top2_gap(candidates) < TOP2_GAP:\n            return self._llm_classify(phrase)\n        result = disambiguate(confidence, candidates)\n        return LexonRecord(\n            phrase=phrase,\n            resolved_tool=result.tool if result.resolved else \"\",\n            confidence=confidence,\n            candidates=candidates,\n            disambiguation_mode=result.mode,\n        )\n\n    def _keyword_confidence(self, candidates: list[str]) -> float:\n        if len(candidates) == 1:\n            return 0.9\n        if len(candidates) == 2:\n            return 0.84\n        return 0.8\n\n    def _top2_gap(self, candidates: list[str]) -> float:\n        if len(candidates) <= 1:\n            return 1.0\n        if len(candidates) == 2:\n            return 0.18\n        return 0.1\n"
  },
  {
    "path": "maggy/maggy/lexon/terminology.py",
    "content": "\"\"\"3-level terminology map for intent normalization.\n\nLevel 1: Canonical terms (e.g., \"deploy\")\nLevel 2: Synonyms (e.g., \"ship\", \"push\", \"release\")\nLevel 3: Project-specific aliases (learned over time)\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom dataclasses import dataclass, field\n\n\n@dataclass\nclass TermEntry:\n    \"\"\"A canonical term with synonyms.\"\"\"\n\n    canonical: str\n    synonyms: list[str] = field(default_factory=list)\n    aliases: list[str] = field(default_factory=list)\n\n\nDEFAULT_TERMS: list[TermEntry] = [\n    TermEntry(\"deploy\", [\"ship\", \"push\", \"release\", \"publish\"]),\n    TermEntry(\"test\", [\"check\", \"verify\", \"validate\", \"qa\"]),\n    TermEntry(\"fix\", [\"repair\", \"patch\", \"resolve\", \"debug\"]),\n    TermEntry(\"create\", [\"add\", \"build\", \"make\", \"generate\"]),\n    TermEntry(\"delete\", [\"remove\", \"drop\", \"destroy\", \"clean\"]),\n    TermEntry(\"update\", [\"modify\", \"change\", \"edit\", \"revise\"]),\n    TermEntry(\"search\", [\"find\", \"lookup\", \"query\", \"locate\"]),\n    TermEntry(\"review\", [\"inspect\", \"audit\", \"examine\", \"check\"]),\n]\n\n\nclass TerminologyMap:\n    \"\"\"Three-level terminology resolution.\"\"\"\n\n    def __init__(\n        self, terms: list[TermEntry] | None = None,\n    ):\n        # Deep copy to avoid mutating module-level defaults\n        if terms is not None:\n            self._terms = terms\n        else:\n            self._terms = [\n                TermEntry(\n                    t.canonical,\n                    list(t.synonyms),\n                    list(t.aliases),\n                )\n                for t in DEFAULT_TERMS\n            ]\n        self._index = self._build_index()\n\n    def _build_index(self) -> dict[str, str]:\n        idx: dict[str, str] = {}\n        for t in self._terms:\n            idx[t.canonical] = t.canonical\n            for s in t.synonyms:\n                idx[s] = t.canonical\n            for a in t.aliases:\n                idx[a] = t.canonical\n        return idx\n\n    def resolve(self, word: str) -> str | None:\n        \"\"\"Resolve a word to its canonical form.\"\"\"\n        return self._index.get(word.lower())\n\n    def add_alias(self, canonical: str, alias: str) -> bool:\n        \"\"\"Add a project-specific alias (Level 3).\"\"\"\n        for t in self._terms:\n            if t.canonical == canonical:\n                t.aliases.append(alias.lower())\n                self._index[alias.lower()] = canonical\n                return True\n        return False\n\n    def list_terms(self) -> list[TermEntry]:\n        return list(self._terms)\n"
  },
  {
    "path": "maggy/maggy/main.py",
    "content": "\"\"\"Maggy FastAPI app entrypoint.\"\"\"\n\nfrom __future__ import annotations\n\nimport logging\nfrom contextlib import asynccontextmanager\nfrom pathlib import Path\n\nfrom fastapi import FastAPI\nfrom fastapi.responses import FileResponse\nfrom fastapi.staticfiles import StaticFiles\nfrom starlette.middleware.base import (\n    BaseHTTPMiddleware,\n    RequestResponseEndpoint,\n)\nfrom starlette.requests import Request\nfrom starlette.responses import Response\n\nfrom maggy import config as config_mod\nfrom maggy import providers\nfrom maggy.api.routes import router as api_router\nfrom maggy.api.routes_budget import router as budget_router\nfrom maggy.api.routes_cikg import router as cikg_router\nfrom maggy.api.routes_deploy import router as deploy_router\nfrom maggy.api.routes_engram import router as engram_router\nfrom maggy.api.routes_events import router as events_router\nfrom maggy.api.routes_forge import router as forge_router\nfrom maggy.api.routes_heartbeat import router as heartbeat_router\nfrom maggy.api.routes_history import router as history_router\nfrom maggy.api.routes_improve import router as improve_router\nfrom maggy.api.routes_lexon import router as lexon_router\nfrom maggy.api.routes_mesh import router as mesh_router\nfrom maggy.api.routes_mesh_admin import router as mesh_admin_router\nfrom maggy.api.routes_planning import router as planning_router\nfrom maggy.api.routes_process import router as process_router\nfrom maggy.api.routes_routing import router as routing_router\nfrom maggy.api.routes_chat import router as chat_router\nfrom maggy.api.routes_escalation import router as escalation_router\nfrom maggy.api.routes_observability import router as observability_router\nfrom maggy.api.routes_projects import router as projects_router\nfrom maggy.api.routes_setup import router as setup_router\nfrom maggy.api.routes_users import router as users_router\nfrom maggy.mesh.ws_server import router as ws_mesh_router\nfrom maggy.budget import BudgetManager\nfrom maggy.event_spine.emitter import EventEmitter\nfrom maggy.event_spine.store import EventStore\nfrom maggy.history.service import HistoryService\nfrom maggy.process.service import ProcessService\nfrom maggy.routing import RoutingService\nfrom maggy.services.competitor import CompetitorService\nfrom maggy.services.executor import ExecutorService\nfrom maggy.services.inbox import InboxService\n\nlogging.basicConfig(level=logging.INFO, format=\"%(asctime)s %(levelname)s %(name)s: %(message)s\")\nlogger = logging.getLogger(\"maggy\")\n\n_TIER1_ATTRS = (\"budget\", \"routing\", \"events\", \"cikg\", \"planning\", \"deploy\", \"forge\", \"engram\", \"lexon\", \"mesh\", \"activity\", \"registry\", \"escalator\", \"observability\")\n_TIER2_ATTRS = (\"provider\", \"inbox\", \"competitors\", \"executor\", \"process\")\n\n\ndef _init_tier1(app: FastAPI, cfg) -> None:\n    \"\"\"Tier 1: local-only services.\"\"\"\n    db_dir = Path(cfg.storage.path).expanduser().parent\n    app.state.budget = BudgetManager(cfg)\n    app.state.routing = RoutingService(cfg)\n    app.state.events = EventEmitter(EventStore(db_dir / \"events.db\"))\n    from maggy.cikg.graph import KnowledgeGraphService\n    app.state.cikg = KnowledgeGraphService(db_dir / \"cikg.db\")\n    from maggy.planning import PlanningService\n    app.state.planning = PlanningService(cfg)\n    from maggy.deploy import DeployService\n    app.state.deploy = DeployService()\n    from maggy.forge.connector import ForgeConnector\n    app.state.forge = ForgeConnector()\n    from maggy.engram.store import EngramStore\n    app.state.engram = EngramStore(db_dir / \"engram.db\")\n    from maggy.engram.seed import seed_if_empty\n    seed_if_empty(app.state.engram)\n    from maggy.lexon.router import LexonRouter\n    app.state.lexon = LexonRouter()\n    _init_mesh(app, cfg)\n    from maggy.services.activity import ActivityService\n    app.state.activity = ActivityService()\n    app.state.history = HistoryService(db_path=db_dir / \"history.db\")\n    from maggy.improve.service import Introspector\n    app.state.introspector = Introspector(app.state)\n    from maggy.services.chat import ChatManager\n    app.state.chat = ChatManager(cfg)\n    from maggy.registry import ProjectRegistry\n    app.state.registry = ProjectRegistry(cfg)\n    from maggy.escalation.protocol import Escalator\n    app.state.escalator = Escalator(db_dir / \"escalations.db\")\n    from maggy.observability.collector import ObservabilityCollector\n    app.state.observability = ObservabilityCollector(db_dir / \"observability.db\")\n\n\ndef _init_mesh(app: FastAPI, cfg) -> None:\n    \"\"\"Wire MeshManager if enabled in config.\"\"\"\n    if not cfg.mesh.enabled or not cfg.mesh.org_key_secret:\n        if cfg.mesh.enabled and not cfg.mesh.org_key_secret:\n            logger.warning(\"Mesh disabled: MAGGY_MESH_SECRET not set\")\n        app.state.mesh = None\n        return\n    from maggy.mesh.manager import MeshManager\n    from maggy.mesh.org_scanner import effective_orgs\n    from maggy.mesh.store import MeshStore\n    db_dir = Path(cfg.storage.path).expanduser().parent\n    store = MeshStore(db_dir / \"mesh.db\")\n    mgr = MeshManager(cfg.mesh, store)\n    for org in effective_orgs(cfg.mesh.orgs, [], cfg.mesh.exclude_orgs):\n        mgr.add_network(org)\n    app.state.mesh = mgr\n\n\ndef _set_mode(app: FastAPI, cfg) -> None:\n    \"\"\"Initialize or skip Tier 2 based on credentials.\"\"\"\n    if config_mod._has_provider_credentials(cfg):\n        app.state.provider = providers.build(cfg)\n        app.state.inbox = InboxService(cfg, app.state.provider)\n        app.state.competitors = CompetitorService(cfg)\n        app.state.executor = ExecutorService(cfg, app.state.provider)\n        app.state.process = ProcessService(cfg)\n        app.state.mode = \"full\"\n    else:\n        for attr in _TIER2_ATTRS:\n            setattr(app.state, attr, None)\n        app.state.mode = \"local\"\n\n\nasync def _start_heartbeat(app: FastAPI) -> None:\n    \"\"\"Register and start the heartbeat scheduler.\"\"\"\n    cfg = app.state.cfg\n    if not cfg.heartbeat.enabled or not app.state.configured:\n        app.state.heartbeat = None\n        return\n    from maggy.heartbeat.scheduler import HeartbeatScheduler\n    from maggy.heartbeat.jobs import refresh_history, expire_engrams, self_improve, mesh_heartbeat, collect_signals\n    from functools import partial\n    sched = HeartbeatScheduler()\n    sched.register(\"refresh_history\", partial(refresh_history, app), cfg.heartbeat.history_interval)\n    sched.register(\"expire_engrams\", partial(expire_engrams, app), cfg.heartbeat.engram_interval)\n    sched.register(\"self_improve\", partial(self_improve, app), cfg.heartbeat.improve_interval)\n    sched.register(\"collect_signals\", partial(collect_signals, app), cfg.heartbeat.improve_interval)\n    if cfg.mesh.enabled:\n        sched.register(\"mesh_heartbeat\", partial(mesh_heartbeat, app), cfg.heartbeat.mesh_interval)\n    await sched.start()\n    app.state.heartbeat = sched\n    logger.info(\"Heartbeat started — %d jobs\", len(sched._jobs))\n\n\n@asynccontextmanager\nasync def lifespan(app: FastAPI):\n    \"\"\"Startup/shutdown lifecycle.\"\"\"\n    await _start_heartbeat(app)\n    await _bootstrap(app)\n    yield\n    if app.state.heartbeat:\n        await app.state.heartbeat.stop()\n\n\nasync def _bootstrap(app: FastAPI) -> None:\n    \"\"\"Seed services with data on first startup.\"\"\"\n    history = getattr(app.state, \"history\", None)\n    if history:\n        try:\n            history.analyze()\n        except Exception as e:\n            logger.warning(\"Bootstrap history failed: %s\", e)\n\n    introspector = getattr(app.state, \"introspector\", None)\n    if introspector:\n        try:\n            introspector.analyze()\n        except Exception as e:\n            logger.warning(\"Bootstrap improve failed: %s\", e)\n\n    cikg = getattr(app.state, \"cikg\", None)\n    cfg = getattr(app.state, \"cfg\", None)\n    if cikg and cfg:\n        try:\n            _seed_cikg(cikg, cfg)\n        except Exception as e:\n            logger.warning(\"Bootstrap CIKG failed: %s\", e)\n\n\ndef _seed_cikg(cikg, cfg) -> None:\n    \"\"\"Build initial knowledge graph from configured codebases.\"\"\"\n    from datetime import datetime, timezone\n\n    from maggy.cikg.models import Node\n\n    now = datetime.now(timezone.utc).isoformat()\n    for cb in cfg.codebases:\n        path = Path(cb.path).expanduser()\n        if not path.exists():\n            continue\n        cikg.add_node(Node(\n            id=f\"codebase:{cb.key}\", node_type=\"codebase\",\n            name=cb.key, description=str(path),\n            metadata={\"path\": str(path)}, created_at=now,\n        ))\n        _add_language_nodes(cikg, cb.key, path, now)\n\n\ndef _add_language_nodes(cikg, codebase_key, path, now) -> None:\n    \"\"\"Detect languages in a codebase and add nodes + edges.\"\"\"\n    from maggy.cikg.models import Edge, Node\n\n    ext_map = {\n        \".py\": \"python\", \".ts\": \"typescript\",\n        \".tsx\": \"typescript\", \".js\": \"javascript\",\n        \".jsx\": \"javascript\", \".go\": \"go\",\n        \".rs\": \"rust\", \".java\": \"java\",\n        \".rb\": \"ruby\", \".swift\": \"swift\",\n        \".kt\": \"kotlin\", \".cs\": \"csharp\",\n    }\n    skip_dirs = {\n        \"node_modules\", \".git\", \"__pycache__\", \".venv\",\n        \"venv\", \"dist\", \"build\", \".next\", \"target\",\n    }\n    found: set[str] = set()\n    # Only scan 2 levels deep to avoid slow recursive scan\n    for child in path.iterdir():\n        if child.name in skip_dirs:\n            continue\n        if child.is_file() and child.suffix in ext_map:\n            found.add(ext_map[child.suffix])\n        elif child.is_dir():\n            try:\n                for f in child.iterdir():\n                    if f.is_file() and f.suffix in ext_map:\n                        found.add(ext_map[f.suffix])\n            except PermissionError:\n                pass\n        if len(found) >= 10:\n            break\n    for lang in found:\n        node_id = f\"lang:{lang}\"\n        cikg.add_node(Node(\n            id=node_id, node_type=\"technology\",\n            name=lang, description=f\"{lang} programming language\",\n            metadata={}, created_at=now,\n        ))\n        cikg.add_edge(Edge(\n            source_id=f\"codebase:{codebase_key}\",\n            target_id=node_id,\n            edge_type=\"uses_technology\",\n        ))\n\n\nclass _NoCacheStatic(BaseHTTPMiddleware):\n    \"\"\"Add no-cache headers to /static responses.\"\"\"\n\n    async def dispatch(\n        self, request: Request, call_next: RequestResponseEndpoint,\n    ) -> Response:\n        response = await call_next(request)\n        if request.url.path.startswith(\"/static\"):\n            response.headers[\"Cache-Control\"] = \"no-store\"\n        return response\n\n\n_ROUTERS = (\n    api_router, budget_router, chat_router, cikg_router,\n    deploy_router, engram_router, escalation_router,\n    events_router, forge_router, heartbeat_router,\n    history_router, improve_router, lexon_router,\n    mesh_router, mesh_admin_router, observability_router,\n    planning_router, process_router, projects_router,\n    routing_router, setup_router, users_router,\n    ws_mesh_router,\n)\n\n\ndef create_app() -> FastAPI:\n    \"\"\"Build the FastAPI application.\"\"\"\n    cfg = config_mod.load()\n    if cfg.dashboard.auth_mode == \"local\" and cfg.dashboard.host not in (\"127.0.0.1\", \"localhost\", \"::1\"):\n        raise RuntimeError(\n            f\"dashboard.auth_mode=\\\"local\\\" is only safe on loopback. \"\n            f\"You configured host={cfg.dashboard.host!r} — set auth_mode=\\\"token\\\" and MAGGY_API_KEY, \"\n            f\"or bind to 127.0.0.1.\"\n        )\n    app = FastAPI(title=\"Maggy\", version=\"0.1.0\", lifespan=lifespan)\n    app.add_middleware(_NoCacheStatic)\n    app.state.cfg = cfg\n    app.state.configured = config_mod.is_configured()\n    if app.state.configured:\n        _init_tier1(app, cfg)\n    else:\n        for attr in _TIER1_ATTRS:\n            setattr(app.state, attr, None)\n        from maggy.services.activity import ActivityService\n        app.state.activity = ActivityService()\n        app.state.history = HistoryService()\n        app.state.introspector = None\n        from maggy.services.chat import ChatManager\n        app.state.chat = ChatManager(cfg)\n    _set_mode(app, cfg)\n    logger.info(\"Maggy ready (%s) — codebases=%d\", app.state.mode, len(cfg.codebases))\n    for r in _ROUTERS:\n        app.include_router(r)\n    static_dir = Path(__file__).parent / \"static\"\n    if static_dir.exists():\n        app.mount(\"/static\", StaticFiles(directory=str(static_dir)), name=\"static\")\n        @app.get(\"/\")\n        async def index():\n            return FileResponse(\n                str(static_dir / \"index.html\"),\n                headers={\"Cache-Control\": \"no-store\"},\n            )\n    return app\n\n\ndef reconfigure(app: FastAPI) -> None:\n    \"\"\"Reload config and reinitialize services.\"\"\"\n    cfg = config_mod.load(refresh=True)\n    app.state.cfg = cfg\n    app.state.configured = config_mod.is_configured()\n    if app.state.configured:\n        _init_tier1(app, cfg)\n    _set_mode(app, cfg)\n    logger.info(\"Reconfigured — mode=%s\", app.state.mode)\n\n\napp = create_app()\n\n\ndef _print_banner(host: str, port: int) -> None:\n    \"\"\"Print startup banner with usage instructions.\"\"\"\n    url = f\"http://{host}:{port}\"\n    print(\"\\n\\033[1;38;5;208m  Maggy\\033[0m\")\n    print(f\"  Dashboard: \\033[4m{url}\\033[0m\")\n    print()\n    print(\n        \"  \\033[33mKeep this terminal open\\033[0m\"\n        \" — Maggy runs here.\"\n    )\n    print(\n        \"  Use other terminals for Claude Code\"\n        \" sessions.\"\n    )\n    print(\n        \"  Maggy Chat auto-connects to all\"\n        \" active sessions.\"\n    )\n    print(\n        \"\\n  Press Ctrl+C to stop.\\n\"\n    )\n\n\ndef main() -> None:\n    \"\"\"Console script entrypoint.\"\"\"\n    import uvicorn\n    cfg = config_mod.load()\n    _print_banner(cfg.dashboard.host, cfg.dashboard.port)\n    uvicorn.run(\n        \"maggy.main:app\",\n        host=cfg.dashboard.host,\n        port=cfg.dashboard.port,\n        reload=False,\n    )\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "maggy/maggy/mesh/__init__.py",
    "content": "\"\"\"Maggy Mesh — P2P memory sharing between instances.\"\"\"\n"
  },
  {
    "path": "maggy/maggy/mesh/discovery.py",
    "content": "\"\"\"Peer discovery — registry with optional SQLite backing.\"\"\"\n\nfrom __future__ import annotations\n\nfrom dataclasses import dataclass, field\nfrom datetime import datetime, timezone\n\n\n@dataclass\nclass PeerInfo:\n    \"\"\"Known mesh peer.\"\"\"\n\n    peer_id: str\n    name: str\n    address: str\n    port: int = 8080\n    org: str = \"\"\n    last_seen: str = field(\n        default_factory=lambda: datetime.now(\n            timezone.utc\n        ).isoformat()\n    )\n    manual: bool = False\n\n\nclass PeerRegistry:\n    \"\"\"Registry of known mesh peers.\"\"\"\n\n    def __init__(self, store=None, org: str = \"\"):\n        self._store = store\n        self._org = org\n        self._peers: dict[str, PeerInfo] = {}\n        if store and org:\n            self._load_from_store()\n\n    def _load_from_store(self) -> None:\n        for row in self._store.list_peers(self._org):\n            self._peers[row[\"peer_id\"]] = PeerInfo(\n                peer_id=row[\"peer_id\"],\n                name=row[\"name\"],\n                address=row[\"address\"],\n                port=row[\"port\"],\n                org=row.get(\"org\", self._org),\n                last_seen=row.get(\"last_seen\", \"\"),\n                manual=bool(row.get(\"manual\", 0)),\n            )\n\n    def register(self, peer: PeerInfo) -> None:\n        if self._store and self._org:\n            self._store.upsert_peer(\n                peer.peer_id, peer.name,\n                peer.address, peer.port, self._org,\n            )\n        self._peers[peer.peer_id] = peer\n\n    def unregister(self, peer_id: str) -> bool:\n        if self._store and self._org:\n            self._store.remove_peer(peer_id, self._org)\n        if peer_id in self._peers:\n            del self._peers[peer_id]\n            return True\n        return False\n\n    def get(self, peer_id: str) -> PeerInfo | None:\n        return self._peers.get(peer_id)\n\n    def list_peers(self) -> list[PeerInfo]:\n        return list(self._peers.values())\n\n    def update_seen(self, peer_id: str) -> None:\n        peer = self._peers.get(peer_id)\n        if peer:\n            peer.last_seen = datetime.now(\n                timezone.utc\n            ).isoformat()\n            if self._store and self._org:\n                self._store.upsert_peer(\n                    peer.peer_id, peer.name,\n                    peer.address, peer.port, self._org,\n                )\n\n    @property\n    def count(self) -> int:\n        return len(self._peers)\n"
  },
  {
    "path": "maggy/maggy/mesh/git_discovery.py",
    "content": "\"\"\"Git-based peer discovery via GitHub Contents API.\"\"\"\n\nfrom __future__ import annotations\n\nimport base64\nimport json\nimport logging\nfrom dataclasses import dataclass\n\nimport httpx\n\nlogger = logging.getLogger(__name__)\n\nGITHUB_API = \"https://api.github.com\"\nREPO_NAME = \"maggy-mesh\"\nTIMEOUT = 15\n\n\n@dataclass\nclass Announcement:\n    \"\"\"Peer data for git-based discovery.\"\"\"\n\n    peer_id: str\n    name: str\n    address: str\n    port: int = 8080\n    org: str = \"\"\n\n\ndef _headers(token: str) -> dict[str, str]:\n    return {\n        \"Authorization\": f\"Bearer {token}\",\n        \"Accept\": \"application/vnd.github+json\",\n        \"X-GitHub-Api-Version\": \"2022-11-28\",\n    }\n\n\nasync def ensure_mesh_repo(\n    org: str, token: str, private: bool = True,\n) -> bool:\n    \"\"\"Create {org}/maggy-mesh repo if it doesn't exist.\"\"\"\n    async with httpx.AsyncClient(\n        timeout=TIMEOUT, headers=_headers(token),\n    ) as client:\n        resp = await client.get(\n            f\"{GITHUB_API}/repos/{org}/{REPO_NAME}\",\n        )\n        if resp.status_code == 200:\n            return True\n        resp = await client.post(\n            f\"{GITHUB_API}/orgs/{org}/repos\",\n            json={\n                \"name\": REPO_NAME,\n                \"private\": private,\n                \"description\": \"Maggy mesh peer discovery\",\n                \"auto_init\": True,\n            },\n        )\n        if resp.status_code in (200, 201):\n            logger.info(\"Created %s/%s\", org, REPO_NAME)\n            return True\n        logger.warning(\n            \"Failed to create %s/%s: %s\",\n            org, REPO_NAME, resp.status_code,\n        )\n        return False\n\n\nasync def announce(\n    org: str, ann: Announcement, token: str,\n) -> bool:\n    \"\"\"Write peer announcement to {org}/maggy-mesh.\"\"\"\n    content = json.dumps({\n        \"peer_id\": ann.peer_id,\n        \"name\": ann.name,\n        \"address\": ann.address,\n        \"port\": ann.port,\n        \"org\": org,\n    }, indent=2)\n    encoded = base64.b64encode(content.encode()).decode()\n    path = f\"peers/{ann.peer_id}.json\"\n\n    async with httpx.AsyncClient(\n        timeout=TIMEOUT, headers=_headers(token),\n    ) as client:\n        existing = await client.get(\n            f\"{GITHUB_API}/repos/{org}/{REPO_NAME}\"\n            f\"/contents/{path}\",\n        )\n        sha = \"\"\n        if existing.status_code == 200:\n            sha = existing.json().get(\"sha\", \"\")\n        body: dict = {\n            \"message\": f\"announce {ann.peer_id}\",\n            \"content\": encoded,\n        }\n        if sha:\n            body[\"sha\"] = sha\n        resp = await client.put(\n            f\"{GITHUB_API}/repos/{org}/{REPO_NAME}\"\n            f\"/contents/{path}\",\n            json=body,\n        )\n        if resp.status_code not in (200, 201):\n            logger.warning(\n                \"Announce %s to %s failed: %s\",\n                ann.peer_id, org, resp.status_code,\n            )\n        return resp.status_code in (200, 201)\n\n\nasync def read_peers(\n    org: str, token: str,\n) -> list[dict]:\n    \"\"\"Read all peer announcements from {org}/maggy-mesh.\"\"\"\n    async with httpx.AsyncClient(\n        timeout=TIMEOUT, headers=_headers(token),\n    ) as client:\n        resp = await client.get(\n            f\"{GITHUB_API}/repos/{org}/{REPO_NAME}\"\n            \"/contents/peers\",\n        )\n        if resp.status_code != 200:\n            return []\n        items = resp.json()\n        if not isinstance(items, list):\n            return []\n        peers: list[dict] = []\n        for item in items:\n            name = item.get(\"name\", \"\")\n            if not name.endswith(\".json\"):\n                continue\n            peer = _decode_peer(item)\n            if peer:\n                peers.append(peer)\n        return peers\n\n\ndef _decode_peer(item: dict) -> dict | None:\n    \"\"\"Decode peer from directory listing content.\"\"\"\n    raw_content = item.get(\"content\")\n    if not raw_content:\n        return None\n    try:\n        return json.loads(base64.b64decode(raw_content))\n    except (json.JSONDecodeError, Exception):\n        return None\n\n\nasync def remove_announcement(\n    org: str, peer_id: str, token: str,\n) -> bool:\n    \"\"\"Remove peer file on shutdown (best-effort).\"\"\"\n    path = f\"peers/{peer_id}.json\"\n    async with httpx.AsyncClient(\n        timeout=TIMEOUT, headers=_headers(token),\n    ) as client:\n        resp = await client.get(\n            f\"{GITHUB_API}/repos/{org}/{REPO_NAME}\"\n            f\"/contents/{path}\",\n        )\n        if resp.status_code != 200:\n            return False\n        sha = resp.json().get(\"sha\", \"\")\n        resp = await client.delete(\n            f\"{GITHUB_API}/repos/{org}/{REPO_NAME}\"\n            f\"/contents/{path}\",\n            json={\n                \"message\": f\"remove {peer_id}\",\n                \"sha\": sha,\n            },\n        )\n        return resp.status_code == 200\n"
  },
  {
    "path": "maggy/maggy/mesh/manager.py",
    "content": "\"\"\"MeshManager — orchestrates multiple org networks.\"\"\"\n\nfrom __future__ import annotations\n\nimport logging\nimport platform\n\nfrom .discovery import PeerInfo\nfrom .git_discovery import (\n    Announcement,\n    announce,\n    ensure_mesh_repo,\n    read_peers,\n)\nfrom .network import Network, build_network\nfrom .store import MeshStore\n\nlogger = logging.getLogger(__name__)\n\n\nclass MeshManager:\n    \"\"\"Manages all org-scoped mesh networks.\"\"\"\n\n    def __init__(self, cfg, store: MeshStore) -> None:\n        self._cfg = cfg\n        self._store = store\n        self._networks: dict[str, Network] = {}\n\n    def add_network(self, org: str) -> Network:\n        net = build_network(\n            org, self._cfg.org_key_secret, self._store,\n        )\n        self._networks[org] = net\n        return net\n\n    def get_network(self, org: str) -> Network | None:\n        return self._networks.get(org)\n\n    def list_networks(self) -> list[dict]:\n        return [n.status() for n in self._networks.values()]\n\n    @property\n    def total_peers(self) -> int:\n        return sum(\n            n.peers.count for n in self._networks.values()\n        )\n\n    async def discover(self, token: str) -> dict:\n        \"\"\"Read peers from git for all networks.\"\"\"\n        result: dict[str, int] = {}\n        for org, net in self._networks.items():\n            if not self._cfg.git_discovery:\n                continue\n            peers = await read_peers(org, token)\n            for p in peers:\n                pid = p.get(\"peer_id\", \"\")\n                if pid == self._cfg.peer_id:\n                    continue\n                net.peers.register(PeerInfo(\n                    peer_id=pid,\n                    name=p.get(\"name\", \"\"),\n                    address=p.get(\"address\", \"\"),\n                    port=p.get(\"port\", 8080),\n                    org=org,\n                ))\n            result[org] = len(peers)\n        return result\n\n    async def announce_all(self, token: str) -> dict:\n        \"\"\"Announce self to all org mesh repos.\"\"\"\n        address = self._resolve_address()\n        result: dict[str, bool] = {}\n        for org in self._networks:\n            ann = Announcement(\n                peer_id=self._cfg.peer_id,\n                name=platform.node(),\n                address=address,\n                port=self._cfg.port,\n                org=org,\n            )\n            ok = await announce(org, ann, token)\n            result[org] = ok\n        return result\n\n    async def setup_repos(self, token: str) -> dict:\n        \"\"\"Create mesh repos for all networks.\"\"\"\n        result: dict[str, bool] = {}\n        for org in self._networks:\n            ok = await ensure_mesh_repo(org, token)\n            result[org] = ok\n        return result\n\n    def _resolve_address(self) -> str:\n        if self._cfg.tunnel_url:\n            return self._cfg.tunnel_url\n        return f\"ws://127.0.0.1:{self._cfg.port}\"\n"
  },
  {
    "path": "maggy/maggy/mesh/memory.py",
    "content": "\"\"\"Typed memory categories for Mesh sharing.\"\"\"\n\nfrom __future__ import annotations\n\nfrom dataclasses import dataclass, field\nfrom datetime import datetime, timezone\nfrom enum import Enum\n\n\nclass MemoryType(str, Enum):\n    SCORE = \"score\"\n    PATTERN = \"pattern\"\n    POLICY = \"policy\"\n    GAP = \"gap\"\n\n\n@dataclass\nclass SharedMemory:\n    \"\"\"A unit of shared memory in the Mesh.\"\"\"\n\n    key: str\n    memory_type: str\n    content: dict = field(default_factory=dict)\n    source_peer: str = \"\"\n    confidence: float = 1.0\n    created_at: str = field(\n        default_factory=lambda: datetime.now(\n            timezone.utc\n        ).isoformat()\n    )\n\n    @property\n    def is_trusted(self) -> bool:\n        return self.confidence >= 0.5\n"
  },
  {
    "path": "maggy/maggy/mesh/network.py",
    "content": "\"\"\"Network — one isolated mesh per GitHub org.\"\"\"\n\nfrom __future__ import annotations\n\nimport logging\nfrom dataclasses import dataclass\n\nfrom .discovery import PeerRegistry\nfrom .quarantine import QuarantineStore\nfrom .store import MeshStore\nfrom .sync import SyncEngine\nfrom .transport import derive_org_key\n\nlogger = logging.getLogger(__name__)\n\n\n@dataclass\nclass Network:\n    \"\"\"A single org-scoped mesh network.\"\"\"\n\n    org: str\n    org_key: str\n    peers: PeerRegistry\n    sync: SyncEngine\n    quarantine: QuarantineStore\n\n    def status(self) -> dict:\n        return {\n            \"org\": self.org,\n            \"peers\": self.peers.count,\n            \"memories\": self.sync.local_count,\n            \"quarantined\": self.quarantine.count,\n        }\n\n\ndef build_network(\n    org: str, secret: str, store: MeshStore,\n) -> Network:\n    \"\"\"Create an org-scoped network with shared store.\"\"\"\n    org_key = derive_org_key(org, secret)\n    quarantine = QuarantineStore(store, org)\n    return Network(\n        org=org,\n        org_key=org_key,\n        peers=PeerRegistry(store, org),\n        sync=SyncEngine(quarantine, store, org),\n        quarantine=quarantine,\n    )\n"
  },
  {
    "path": "maggy/maggy/mesh/org_scanner.py",
    "content": "\"\"\"Scan local repos for unique GitHub org names.\"\"\"\n\nfrom __future__ import annotations\n\nfrom pathlib import Path\n\nfrom maggy.discovery import discover_repos, infer_github_org\n\n\ndef scan_orgs(home: Path | None = None) -> list[str]:\n    \"\"\"Return sorted unique GitHub org names from local repos.\"\"\"\n    repos = discover_repos(home)\n    orgs: set[str] = set()\n    for repo in repos:\n        org = infer_github_org(Path(repo[\"path\"]))\n        if org:\n            orgs.add(org)\n    return sorted(orgs)\n\n\ndef effective_orgs(\n    scanned: list[str],\n    manual: list[str],\n    excluded: list[str],\n) -> list[str]:\n    \"\"\"Merge scanned + manual orgs, remove excluded.\"\"\"\n    combined = set(scanned) | set(manual)\n    combined -= set(excluded)\n    return sorted(combined)\n"
  },
  {
    "path": "maggy/maggy/mesh/protocol.py",
    "content": "\"\"\"Message types and serialization for Mesh protocol.\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nfrom dataclasses import asdict, dataclass, field\nfrom datetime import datetime, timezone\nfrom enum import Enum\n\n\nclass MessageType(str, Enum):\n    HELLO = \"hello\"\n    SHARE = \"share\"\n    REQUEST = \"request\"\n    RESPONSE = \"response\"\n    QUARANTINE = \"quarantine\"\n    PROMOTE = \"promote\"\n    HEARTBEAT = \"heartbeat\"\n\n\n@dataclass\nclass MeshMessage:\n    \"\"\"A message in the Mesh protocol.\"\"\"\n\n    msg_type: str\n    sender_id: str\n    payload: dict = field(default_factory=dict)\n    timestamp: str = field(\n        default_factory=lambda: datetime.now(\n            timezone.utc\n        ).isoformat()\n    )\n\n    def serialize(self) -> str:\n        return json.dumps(asdict(self))\n\n    @classmethod\n    def deserialize(cls, data: str) -> MeshMessage:\n        d = json.loads(data)\n        return cls(**d)\n\n\ndef create_hello(peer_id: str, name: str) -> MeshMessage:\n    return MeshMessage(\n        msg_type=MessageType.HELLO,\n        sender_id=peer_id,\n        payload={\"name\": name},\n    )\n\n\ndef create_share(\n    peer_id: str, key: str, content: dict,\n) -> MeshMessage:\n    return MeshMessage(\n        msg_type=MessageType.SHARE,\n        sender_id=peer_id,\n        payload={\n            \"key\": key,\n            \"memory_type\": content.get(\"memory_type\", \"\"),\n            \"content\": content,\n        },\n    )\n"
  },
  {
    "path": "maggy/maggy/mesh/provenance.py",
    "content": "\"\"\"Provenance tracking with confidence decay.\"\"\"\n\nfrom __future__ import annotations\n\nfrom dataclasses import dataclass, field\nfrom datetime import datetime, timezone\n\nDECAY_PER_HOP = 0.1\nMIN_CONFIDENCE = 0.1\n\n\n@dataclass\nclass Provenance:\n    \"\"\"Tracks origin and confidence of shared data.\"\"\"\n\n    origin_peer: str\n    hops: int = 0\n    base_confidence: float = 1.0\n    received_at: str = field(\n        default_factory=lambda: datetime.now(\n            timezone.utc\n        ).isoformat()\n    )\n\n    @property\n    def effective_confidence(self) -> float:\n        decayed = self.base_confidence - (self.hops * DECAY_PER_HOP)\n        return max(decayed, MIN_CONFIDENCE)\n\n    def add_hop(self) -> Provenance:\n        \"\"\"Create new provenance with one more hop.\"\"\"\n        return Provenance(\n            origin_peer=self.origin_peer,\n            hops=self.hops + 1,\n            base_confidence=self.base_confidence,\n        )\n"
  },
  {
    "path": "maggy/maggy/mesh/publisher.py",
    "content": "\"\"\"Collect local data and build shareable memories.\"\"\"\n\nfrom __future__ import annotations\n\nfrom .memory import SharedMemory\n\n\ndef collect_scores(routing, peer_id: str) -> list[SharedMemory]:\n    \"\"\"Build shareable routing score memories.\"\"\"\n    if not routing:\n        return []\n    shares: list[SharedMemory] = []\n    for entry in routing.get_heatmap():\n        if entry.get(\"count\", 0) < 5:\n            continue\n        key = f\"score:{entry.get('model', '')}:{entry.get('task_type', '')}\"\n        shares.append(SharedMemory(\n            key=key, memory_type=\"score\",\n            content=entry, source_peer=peer_id,\n            confidence=min(entry.get(\"count\", 0) / 20, 1.0),\n        ))\n    return shares\n\n\ndef collect_gaps(forge, peer_id: str) -> list[SharedMemory]:\n    \"\"\"Build shareable capability gap memories.\"\"\"\n    if not forge:\n        return []\n    shares: list[SharedMemory] = []\n    for gap in forge.get_gaps():\n        key = f\"gap:{gap.get('name', '')}\"\n        shares.append(SharedMemory(\n            key=key, memory_type=\"gap\",\n            content=gap, source_peer=peer_id,\n        ))\n    return shares\n\n\ndef collect_policies(introspector, peer_id: str) -> list[SharedMemory]:\n    \"\"\"Build shareable policy memories from recommendations.\"\"\"\n    if not introspector:\n        return []\n    report = introspector.get_report()\n    if not report:\n        return []\n    shares: list[SharedMemory] = []\n    for rec in report.recommendations:\n        if rec.severity != \"action\":\n            continue\n        key = f\"policy:{rec.category}\"\n        shares.append(SharedMemory(\n            key=key, memory_type=\"policy\",\n            content={\"message\": rec.message, \"suggestion\": rec.suggestion},\n            source_peer=peer_id,\n        ))\n    return shares\n\n\ndef collect_all_shares(app_state, peer_id: str) -> list[SharedMemory]:\n    \"\"\"Collect all shareable data from local services.\"\"\"\n    shares: list[SharedMemory] = []\n    shares.extend(collect_scores(\n        getattr(app_state, \"routing\", None), peer_id,\n    ))\n    shares.extend(collect_gaps(\n        getattr(app_state, \"forge\", None), peer_id,\n    ))\n    shares.extend(collect_policies(\n        getattr(app_state, \"introspector\", None), peer_id,\n    ))\n    return shares\n"
  },
  {
    "path": "maggy/maggy/mesh/quarantine.py",
    "content": "\"\"\"Quarantine system for untrusted mesh data.\"\"\"\n\nfrom __future__ import annotations\n\nfrom dataclasses import dataclass, field\nfrom datetime import datetime, timezone\n\n\n@dataclass\nclass QuarantineEntry:\n    \"\"\"A quarantined memory item.\"\"\"\n\n    key: str\n    source_peer: str\n    reason: str\n    content: dict = field(default_factory=dict)\n    memory_type: str = \"\"\n    quarantined_at: str = field(\n        default_factory=lambda: datetime.now(\n            timezone.utc\n        ).isoformat()\n    )\n\n\nclass QuarantineStore:\n    \"\"\"Manages quarantined data from mesh peers.\"\"\"\n\n    def __init__(self, store=None, org: str = \"\"):\n        self._entries: dict[str, QuarantineEntry] = {}\n        self._store = store\n        self._org = org\n        if store and org:\n            self._load_from_store()\n\n    def _load_from_store(self) -> None:\n        for row in self._store.list_quarantined(self._org):\n            self._entries[row[\"key\"]] = QuarantineEntry(\n                key=row[\"key\"],\n                source_peer=row[\"source_peer\"],\n                reason=row[\"reason\"],\n                content=row.get(\"content\", {}),\n                memory_type=row.get(\"memory_type\", \"\"),\n            )\n\n    def quarantine(\n        self, key: str, source: str,\n        reason: str, content: dict,\n        memory_type: str = \"\",\n    ) -> QuarantineEntry:\n        entry = QuarantineEntry(\n            key=key, source_peer=source,\n            reason=reason, content=content,\n            memory_type=memory_type,\n        )\n        self._entries[key] = entry\n        if self._store and self._org:\n            self._store.quarantine_item(\n                self._org, key, source, reason, content,\n            )\n        return entry\n\n    def get(self, key: str) -> QuarantineEntry | None:\n        return self._entries.get(key)\n\n    def list_all(self) -> list[QuarantineEntry]:\n        return list(self._entries.values())\n\n    def promote(self, key: str) -> QuarantineEntry | None:\n        \"\"\"Remove from quarantine and return entry for acceptance.\"\"\"\n        entry = self._entries.pop(key, None)\n        if self._store and self._org:\n            self._store.promote_item(self._org, key)\n        return entry\n\n    def reject(self, key: str) -> bool:\n        \"\"\"Permanently reject quarantined item.\"\"\"\n        if key in self._entries:\n            del self._entries[key]\n        if self._store and self._org:\n            self._store.promote_item(self._org, key)\n            return True\n        return key is not None\n\n    @property\n    def count(self) -> int:\n        return len(self._entries)\n"
  },
  {
    "path": "maggy/maggy/mesh/store.py",
    "content": "\"\"\"SQLite backing for mesh peers, memories, and quarantine.\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport sqlite3\nimport threading\nfrom datetime import datetime, timezone\nfrom pathlib import Path\n\nSCHEMA = \"\"\"\nCREATE TABLE IF NOT EXISTS peers (\n    peer_id TEXT NOT NULL,\n    name TEXT NOT NULL,\n    address TEXT NOT NULL,\n    port INTEGER NOT NULL DEFAULT 8080,\n    org TEXT NOT NULL,\n    last_seen TEXT NOT NULL,\n    manual INTEGER NOT NULL DEFAULT 0,\n    PRIMARY KEY (peer_id, org)\n);\nCREATE TABLE IF NOT EXISTS shared_memories (\n    key TEXT NOT NULL,\n    org TEXT NOT NULL,\n    memory_type TEXT NOT NULL,\n    content TEXT NOT NULL,\n    source_peer TEXT NOT NULL,\n    confidence REAL NOT NULL DEFAULT 1.0,\n    created_at TEXT NOT NULL,\n    PRIMARY KEY (key, org)\n);\nCREATE TABLE IF NOT EXISTS quarantine (\n    key TEXT NOT NULL,\n    org TEXT NOT NULL,\n    source_peer TEXT NOT NULL,\n    reason TEXT NOT NULL,\n    content TEXT NOT NULL,\n    quarantined_at TEXT NOT NULL,\n    PRIMARY KEY (key, org)\n);\n\"\"\"\n\n\ndef _now() -> str:\n    return datetime.now(timezone.utc).isoformat()\n\n\nclass MeshStore:\n    \"\"\"SQLite-backed mesh storage with connection reuse.\"\"\"\n\n    def __init__(self, db_path: Path) -> None:\n        self._db = db_path\n        self._lock = threading.Lock()\n        db_path.parent.mkdir(parents=True, exist_ok=True)\n        self._conn = sqlite3.connect(\n            str(db_path), timeout=30.0,\n            check_same_thread=False,\n        )\n        self._conn.execute(\"PRAGMA journal_mode=WAL\")\n        self._conn.execute(\"PRAGMA busy_timeout=30000\")\n        self._conn.row_factory = sqlite3.Row\n        self._conn.executescript(SCHEMA)\n\n    # ── Peers ──────────────────────────────────────────\n\n    def upsert_peer(\n        self, peer_id: str, name: str,\n        address: str, port: int, org: str,\n    ) -> None:\n        with self._lock:\n            self._conn.execute(\n                \"INSERT OR REPLACE INTO peers \"\n                \"VALUES (?,?,?,?,?,?,?)\",\n                (peer_id, name, address, port,\n                 org, _now(), 0),\n            )\n            self._conn.commit()\n\n    def get_peer(\n        self, peer_id: str, org: str,\n    ) -> dict | None:\n        with self._lock:\n            row = self._conn.execute(\n                \"SELECT * FROM peers \"\n                \"WHERE peer_id=? AND org=?\",\n                (peer_id, org),\n            ).fetchone()\n        return dict(row) if row else None\n\n    def list_peers(\n        self, org: str | None = None,\n    ) -> list[dict]:\n        with self._lock:\n            if org:\n                rows = self._conn.execute(\n                    \"SELECT * FROM peers WHERE org=?\",\n                    (org,),\n                ).fetchall()\n            else:\n                rows = self._conn.execute(\n                    \"SELECT * FROM peers\",\n                ).fetchall()\n        return [dict(r) for r in rows]\n\n    def remove_peer(\n        self, peer_id: str, org: str,\n    ) -> bool:\n        with self._lock:\n            cur = self._conn.execute(\n                \"DELETE FROM peers \"\n                \"WHERE peer_id=? AND org=?\",\n                (peer_id, org),\n            )\n            self._conn.commit()\n        return cur.rowcount > 0\n\n    # ── Memories ───────────────────────────────────────\n\n    def write_memory(\n        self, org: str, key: str, memory_type: str,\n        content: dict, source_peer: str,\n        confidence: float = 1.0,\n    ) -> None:\n        with self._lock:\n            self._conn.execute(\n                \"INSERT OR REPLACE INTO shared_memories \"\n                \"VALUES (?,?,?,?,?,?,?)\",\n                (key, org, memory_type,\n                 json.dumps(content),\n                 source_peer, confidence, _now()),\n            )\n            self._conn.commit()\n\n    def list_memories(self, org: str) -> list[dict]:\n        with self._lock:\n            rows = self._conn.execute(\n                \"SELECT * FROM shared_memories WHERE org=?\",\n                (org,),\n            ).fetchall()\n        return [\n            {**dict(r), \"content\": json.loads(r[\"content\"])}\n            for r in rows\n        ]\n\n    # ── Quarantine ─────────────────────────────────────\n\n    def quarantine_item(\n        self, org: str, key: str,\n        source: str, reason: str, content: dict,\n    ) -> None:\n        with self._lock:\n            self._conn.execute(\n                \"INSERT OR REPLACE INTO quarantine \"\n                \"VALUES (?,?,?,?,?,?)\",\n                (key, org, source, reason,\n                 json.dumps(content), _now()),\n            )\n            self._conn.commit()\n\n    def promote_item(\n        self, org: str, key: str,\n    ) -> bool:\n        with self._lock:\n            cur = self._conn.execute(\n                \"DELETE FROM quarantine \"\n                \"WHERE key=? AND org=?\",\n                (key, org),\n            )\n            self._conn.commit()\n        return cur.rowcount > 0\n\n    def list_quarantined(self, org: str) -> list[dict]:\n        with self._lock:\n            rows = self._conn.execute(\n                \"SELECT * FROM quarantine WHERE org=?\",\n                (org,),\n            ).fetchall()\n        return [\n            {**dict(r), \"content\": json.loads(r[\"content\"])}\n            for r in rows\n        ]\n\n    def close(self) -> None:\n        \"\"\"Close the database connection.\"\"\"\n        self._conn.close()\n"
  },
  {
    "path": "maggy/maggy/mesh/sync.py",
    "content": "\"\"\"Sync engine — merges shared memories across peers.\"\"\"\n\nfrom __future__ import annotations\n\nimport logging\nfrom dataclasses import dataclass\n\nfrom .memory import SharedMemory\nfrom .quarantine import QuarantineStore\n\nlogger = logging.getLogger(__name__)\n\nCONFIDENCE_THRESHOLD = 0.5\n\n\n@dataclass\nclass SyncResult:\n    \"\"\"Result of a sync operation.\"\"\"\n\n    accepted: int = 0\n    quarantined: int = 0\n    rejected: int = 0\n\n\nclass SyncEngine:\n    \"\"\"Merges incoming memories with local store.\"\"\"\n\n    def __init__(\n        self, quarantine: QuarantineStore,\n        store=None, org: str = \"\",\n    ):\n        self._local: dict[str, SharedMemory] = {}\n        self._quarantine = quarantine\n        self._store = store\n        self._org = org\n        if store and org:\n            self._load_from_store()\n\n    def _load_from_store(self) -> None:\n        for row in self._store.list_memories(self._org):\n            self._local[row[\"key\"]] = SharedMemory(\n                key=row[\"key\"],\n                memory_type=row[\"memory_type\"],\n                content=row[\"content\"],\n                source_peer=row[\"source_peer\"],\n                confidence=row[\"confidence\"],\n            )\n\n    def sync_incoming(\n        self, memories: list[SharedMemory],\n    ) -> SyncResult:\n        \"\"\"Process incoming memories from a peer.\"\"\"\n        result = SyncResult()\n        for mem in memories:\n            if mem.confidence >= CONFIDENCE_THRESHOLD:\n                self._accept(mem)\n                result.accepted += 1\n            else:\n                self._quarantine.quarantine(\n                    key=mem.key,\n                    source=mem.source_peer,\n                    reason=\"low confidence\",\n                    content=mem.content,\n                    memory_type=mem.memory_type,\n                )\n                result.quarantined += 1\n        return result\n\n    def _accept(self, mem: SharedMemory) -> None:\n        self._local[mem.key] = mem\n        if self._store and self._org:\n            self._store.write_memory(\n                self._org, mem.key, mem.memory_type,\n                mem.content, mem.source_peer, mem.confidence,\n            )\n\n    def promote_from_quarantine(self, key: str) -> bool:\n        \"\"\"Accept a quarantined item into shared memories.\"\"\"\n        entry = self._quarantine.promote(key)\n        if not entry:\n            return False\n        mem = SharedMemory(\n            key=entry.key,\n            memory_type=entry.memory_type,\n            content=entry.content,\n            source_peer=entry.source_peer,\n            confidence=1.0,\n        )\n        self._accept(mem)\n        return True\n\n    def get_local(self, key: str) -> SharedMemory | None:\n        return self._local.get(key)\n\n    def list_local(self) -> list[SharedMemory]:\n        return list(self._local.values())\n\n    @property\n    def local_count(self) -> int:\n        return len(self._local)\n"
  },
  {
    "path": "maggy/maggy/mesh/transport.py",
    "content": "\"\"\"Transport layer — HMAC auth and org key derivation.\"\"\"\n\nfrom __future__ import annotations\n\nimport hashlib\nimport hmac\nimport json\nimport logging\nimport time\n\nfrom .protocol import MeshMessage\n\nlogger = logging.getLogger(__name__)\n\nMAX_AGE_SECONDS = 300  # 5-minute replay window\n\n\ndef derive_org_key(org: str, secret: str) -> str:\n    \"\"\"Derive per-org HMAC key from shared secret.\"\"\"\n    return hmac.new(\n        secret.encode(), org.encode(), hashlib.sha256,\n    ).hexdigest()\n\n\ndef compute_hmac(payload: str, key: str) -> str:\n    \"\"\"Compute HMAC-SHA256 for message authentication.\"\"\"\n    return hmac.new(\n        key.encode(), payload.encode(), hashlib.sha256,\n    ).hexdigest()\n\n\ndef verify_hmac(\n    payload: str, key: str, signature: str,\n) -> bool:\n    \"\"\"Verify HMAC signature.\"\"\"\n    expected = compute_hmac(payload, key)\n    return hmac.compare_digest(expected, signature)\n\n\ndef sign_message(msg: MeshMessage, org_key: str) -> str:\n    \"\"\"Serialize and sign with timestamp for replay protection.\"\"\"\n    payload = msg.serialize()\n    ts = time.time()\n    sig = compute_hmac(f\"{payload}:{ts}\", org_key)\n    return json.dumps({\"payload\": payload, \"sig\": sig, \"ts\": ts})\n\n\ndef verify_message(\n    raw: str, org_key: str,\n) -> MeshMessage | None:\n    \"\"\"Verify signature and timestamp, then deserialize.\"\"\"\n    try:\n        envelope = json.loads(raw)\n        payload = envelope[\"payload\"]\n        sig = envelope[\"sig\"]\n        ts = envelope.get(\"ts\", 0)\n    except (json.JSONDecodeError, KeyError):\n        return None\n    age = abs(time.time() - ts)\n    if age > MAX_AGE_SECONDS:\n        logger.debug(\"Rejected stale message (age=%.0fs)\", age)\n        return None\n    if not verify_hmac(f\"{payload}:{ts}\", org_key, sig):\n        return None\n    return MeshMessage.deserialize(payload)\n"
  },
  {
    "path": "maggy/maggy/mesh/ws_client.py",
    "content": "\"\"\"Async WebSocket client for mesh peer connections.\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport logging\n\nfrom .discovery import PeerInfo\nfrom .protocol import MeshMessage, create_hello\nfrom .transport import sign_message, verify_message\n\nlogger = logging.getLogger(__name__)\n\nRECONNECT_DELAY = 10.0\n\n\nclass MeshClient:\n    \"\"\"Maintains WebSocket connections to known peers.\"\"\"\n\n    def __init__(self, peer_id: str) -> None:\n        self._peer_id = peer_id\n        self._connections: dict[str, object] = {}\n        self._tasks: dict[str, asyncio.Task] = {}\n\n    async def connect(\n        self, peer: PeerInfo, org: str, org_key: str,\n    ) -> bool:\n        \"\"\"Connect to a peer and send HELLO.\"\"\"\n        try:\n            import websockets\n            url = f\"{peer.address}/ws/mesh\"\n            ws = await websockets.connect(url)\n            hello = create_hello(self._peer_id, \"client\")\n            hello.payload[\"org\"] = org\n            signed = sign_message(hello, org_key)\n            await ws.send(signed)\n            reply_raw = await ws.recv()\n            reply = verify_message(reply_raw, org_key)\n            if not reply:\n                await ws.close()\n                return False\n            self._connections[peer.peer_id] = ws\n            logger.info(\"Connected to peer %s\", peer.peer_id)\n            return True\n        except Exception as exc:\n            logger.debug(\"Connect to %s failed: %s\", peer.peer_id, exc)\n            return False\n\n    async def send(\n        self, peer_id: str, msg: MeshMessage, org_key: str,\n    ) -> bool:\n        \"\"\"Send message to a connected peer.\"\"\"\n        ws = self._connections.get(peer_id)\n        if not ws:\n            return False\n        try:\n            signed = sign_message(msg, org_key)\n            await ws.send(signed)\n            return True\n        except Exception as exc:\n            logger.debug(\"Send to %s failed: %s\", peer_id, exc)\n            self._connections.pop(peer_id, None)\n            return False\n\n    async def broadcast(\n        self, peers: list[str], msg: MeshMessage, org_key: str,\n    ) -> int:\n        \"\"\"Send to all specified peers. Returns success count.\"\"\"\n        sent = 0\n        for pid in peers:\n            if await self.send(pid, msg, org_key):\n                sent += 1\n        return sent\n\n    async def close_all(self) -> None:\n        \"\"\"Close all connections.\"\"\"\n        for ws in self._connections.values():\n            try:\n                await ws.close()\n            except Exception:\n                pass\n        self._connections.clear()\n        for task in self._tasks.values():\n            task.cancel()\n        self._tasks.clear()\n\n    @property\n    def connected_count(self) -> int:\n        return len(self._connections)\n\n    def is_connected(self, peer_id: str) -> bool:\n        return peer_id in self._connections\n"
  },
  {
    "path": "maggy/maggy/mesh/ws_server.py",
    "content": "\"\"\"WebSocket server endpoint for mesh communication.\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport json\nimport logging\n\nfrom fastapi import APIRouter, WebSocket, WebSocketDisconnect\n\nfrom .protocol import MessageType, MeshMessage, create_hello\nfrom .transport import sign_message, verify_message\n\nlogger = logging.getLogger(__name__)\n\nrouter = APIRouter()\n\nHELLO_TIMEOUT = 10.0\nMSG_TIMEOUT = 300.0\nMAX_INVALID = 5\n\n\n@router.websocket(\"/ws/mesh\")\nasync def mesh_ws(websocket: WebSocket) -> None:\n    \"\"\"Accept mesh peer connections.\"\"\"\n    await websocket.accept()\n    manager = getattr(websocket.app.state, \"mesh\", None)\n    if not manager:\n        await websocket.close(code=1008, reason=\"Mesh not enabled\")\n        return\n    try:\n        await _handle_connection(websocket, manager)\n    except WebSocketDisconnect:\n        logger.debug(\"Mesh peer disconnected\")\n    except asyncio.TimeoutError:\n        logger.debug(\"Mesh peer timed out\")\n    except Exception as exc:\n        logger.warning(\"Mesh WS error: %s\", exc)\n\n\nasync def _handle_connection(websocket, manager) -> None:\n    \"\"\"Authenticate and enter message loop.\"\"\"\n    raw = await asyncio.wait_for(\n        websocket.receive_text(), timeout=HELLO_TIMEOUT,\n    )\n    org, msg = _authenticate(raw, manager)\n    if not msg or not org:\n        await websocket.close(code=1008, reason=\"Auth failed\")\n        return\n    net = manager.get_network(org)\n    if not net:\n        await websocket.close(code=1008, reason=\"Unknown org\")\n        return\n    peers = [\n        {\"peer_id\": p.peer_id, \"address\": p.address, \"port\": p.port}\n        for p in net.peers.list_peers()\n    ]\n    reply = create_hello(manager._cfg.peer_id, \"server\")\n    reply.payload[\"peers\"] = peers\n    signed = sign_message(reply, net.org_key)\n    await websocket.send_text(signed)\n    await _message_loop(websocket, net)\n\n\nasync def _message_loop(websocket, net) -> None:\n    \"\"\"Rate-limited message receive loop.\"\"\"\n    invalid_count = 0\n    while True:\n        data = await asyncio.wait_for(\n            websocket.receive_text(), timeout=MSG_TIMEOUT,\n        )\n        incoming = verify_message(data, net.org_key)\n        if not incoming:\n            invalid_count += 1\n            if invalid_count >= MAX_INVALID:\n                logger.warning(\"Too many invalid messages\")\n                break\n            continue\n        invalid_count = 0\n        await _dispatch(incoming, net)\n\n\ndef _authenticate(\n    raw: str, manager,\n) -> tuple[str | None, MeshMessage | None]:\n    \"\"\"Try to authenticate a HELLO message.\"\"\"\n    try:\n        envelope = json.loads(raw)\n        payload_str = envelope.get(\"payload\", \"\")\n        msg = MeshMessage.deserialize(payload_str)\n        org = msg.payload.get(\"org\", \"\")\n    except (json.JSONDecodeError, KeyError, TypeError):\n        return None, None\n    if msg.msg_type != MessageType.HELLO:\n        return None, None\n    net = manager.get_network(org)\n    if not net:\n        return None, None\n    verified = verify_message(raw, net.org_key)\n    if not verified:\n        return None, None\n    return org, verified\n\n\nasync def _dispatch(msg: MeshMessage, net) -> None:\n    \"\"\"Handle incoming message by type.\"\"\"\n    if msg.msg_type == MessageType.SHARE:\n        from .memory import SharedMemory\n        mem = SharedMemory(\n            key=msg.payload.get(\"key\", \"\"),\n            memory_type=msg.payload.get(\"memory_type\", \"\"),\n            content=msg.payload.get(\"content\", {}),\n            source_peer=msg.sender_id,\n            confidence=msg.payload.get(\"confidence\", 1.0),\n        )\n        net.sync.sync_incoming([mem])\n    elif msg.msg_type == MessageType.HEARTBEAT:\n        net.peers.update_seen(msg.sender_id)\n"
  },
  {
    "path": "maggy/maggy/mnemos/__init__.py",
    "content": "\"\"\"Mnemos helpers for fatigue and signal tracking.\"\"\"\n\nfrom .fatigue import FatigueTracker\nfrom .signals import SignalLog\n\n__all__ = [\"FatigueTracker\", \"SignalLog\"]\n"
  },
  {
    "path": "maggy/maggy/mnemos/fatigue.py",
    "content": "\"\"\"Cross-model fatigue tracking for Mnemos.\"\"\"\n\nfrom __future__ import annotations\n\nVALID_DIMENSIONS = frozenset({\n    \"context_load\",\n    \"turn_pressure\",\n    \"reread_ratio\",\n    \"handoff_risk\",\n})\n\n\nclass FatigueTracker:\n    \"\"\"Track fatigue across four compression signals.\"\"\"\n\n    def __init__(self, context_window: int = 200_000):\n        self.context_window = context_window\n        self.dimensions: dict[str, float] = {\n            d: 0.0 for d in VALID_DIMENSIONS\n        }\n\n    def record(self, dimension: str, value: float) -> None:\n        if dimension not in VALID_DIMENSIONS:\n            raise ValueError(\n                f\"Unknown dimension {dimension!r}. \"\n                f\"Valid: {sorted(VALID_DIMENSIONS)}\"\n            )\n        self.dimensions[dimension] = max(0.0, min(value, 1.0))\n\n    def on_model_switch(self, new_context_window: int) -> None:\n        self.context_window = new_context_window\n        value = self.dimensions[\"reread_ratio\"] + 0.15\n        self.record(\"reread_ratio\", value)\n\n    def composite(self) -> float:\n        return sum(self.dimensions.values()) / len(self.dimensions)\n\n    def state(self) -> str:\n        score = self.composite()\n        if score >= 0.8:\n            return \"critical\"\n        if score >= 0.45:\n            return \"compress\"\n        return \"ok\"\n"
  },
  {
    "path": "maggy/maggy/mnemos/signals.py",
    "content": "\"\"\"JSONL-backed signal logging for Mnemos.\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nfrom pathlib import Path\n\n\nclass SignalLog:\n    \"\"\"Append and read Mnemos signal history.\"\"\"\n\n    def __init__(self, path: Path):\n        self._path = path\n\n    def append(self, signal: dict) -> None:\n        self._path.parent.mkdir(parents=True, exist_ok=True)\n        with self._path.open(\"a\", encoding=\"utf-8\") as handle:\n            handle.write(json.dumps(signal) + \"\\n\")\n\n    def recent(self, n: int) -> list[dict]:\n        if n <= 0 or not self._path.exists():\n            return []\n        from collections import deque\n        with self._path.open(encoding=\"utf-8\") as handle:\n            lines = deque(handle, maxlen=n)\n        return [json.loads(line) for line in lines]\n"
  },
  {
    "path": "maggy/maggy/models/__init__.py",
    "content": "\"\"\"Maggy data models.\"\"\"\n"
  },
  {
    "path": "maggy/maggy/models/plan.py",
    "content": "\"\"\"Plan and PlanDiff models for dual-model planning.\"\"\"\n\nfrom __future__ import annotations\n\nfrom dataclasses import dataclass, field\n\n\n@dataclass\nclass PlanStep:\n    \"\"\"A single step in a plan.\"\"\"\n\n    description: str\n    files: list[str] = field(default_factory=list)\n    blast_estimate: int = 0\n\n\n@dataclass\nclass Plan:\n    \"\"\"A generated implementation plan.\"\"\"\n\n    task: str\n    model: str\n    steps: list[PlanStep] = field(default_factory=list)\n    risks: list[str] = field(default_factory=list)\n    total_blast: int = 0\n\n    @property\n    def step_count(self) -> int:\n        return len(self.steps)\n\n\n@dataclass\nclass PlanDiff:\n    \"\"\"Diff between primary and counter plans.\"\"\"\n\n    agreed: list[str] = field(default_factory=list)\n    conflicts: list[dict] = field(default_factory=list)\n    primary_only: list[str] = field(default_factory=list)\n    counter_only: list[str] = field(default_factory=list)\n\n    @property\n    def conflict_count(self) -> int:\n        return len(self.conflicts)\n\n    @property\n    def agreement_ratio(self) -> float:\n        total = (\n            len(self.agreed) + len(self.conflicts)\n            + len(self.primary_only) + len(self.counter_only)\n        )\n        if total == 0:\n            return 1.0\n        return len(self.agreed) / total\n"
  },
  {
    "path": "maggy/maggy/observability/__init__.py",
    "content": "\"\"\"Observability exports.\"\"\"\n\nfrom .collector import ObservabilityCollector\n\n__all__ = [\"ObservabilityCollector\"]\n"
  },
  {
    "path": "maggy/maggy/observability/collector.py",
    "content": "\"\"\"SQLite-backed observability signal storage.\"\"\"\n\nfrom __future__ import annotations\n\nimport sqlite3\nfrom contextlib import contextmanager\nfrom datetime import datetime, timezone\nfrom pathlib import Path\nfrom typing import Iterator\n\nSCHEMA = \"\"\"\nCREATE TABLE IF NOT EXISTS signals (\n    id INTEGER PRIMARY KEY AUTOINCREMENT,\n    project TEXT NOT NULL,\n    signal_type TEXT NOT NULL,\n    value REAL NOT NULL,\n    created_at TEXT NOT NULL\n);\n\"\"\"\n\n\n@contextmanager\ndef _connect(path: Path) -> Iterator[sqlite3.Connection]:\n    path.parent.mkdir(parents=True, exist_ok=True)\n    conn = sqlite3.connect(str(path), timeout=30.0)\n    conn.row_factory = sqlite3.Row\n    try:\n        yield conn\n    finally:\n        conn.close()\n\n\nclass ObservabilityCollector:\n    def __init__(self, db_path: Path):\n        self._db_path = db_path\n        self._init_db()\n\n    def record_signal(\n        self, project: str, signal_type: str, value: float,\n    ) -> None:\n        now = datetime.now(timezone.utc).isoformat()\n        with _connect(self._db_path) as conn:\n            conn.execute(\n                \"INSERT INTO signals (project, signal_type, value, created_at) \"\n                \"VALUES (?, ?, ?, ?)\",\n                (project, signal_type, value, now),\n            )\n            conn.commit()\n\n    def recent_signals(\n        self, project: str, limit: int = 20,\n    ) -> list[dict]:\n        with _connect(self._db_path) as conn:\n            rows = conn.execute(\n                \"SELECT project, signal_type, value, created_at \"\n                \"FROM signals WHERE project = ? \"\n                \"ORDER BY id DESC LIMIT ?\",\n                (project, limit),\n            ).fetchall()\n        return [dict(row) for row in rows]\n\n    def _init_db(self) -> None:\n        with _connect(self._db_path) as conn:\n            conn.executescript(SCHEMA)\n"
  },
  {
    "path": "maggy/maggy/planning.py",
    "content": "\"\"\"Dual-model planning orchestrator.\n\nGenerates plan with primary model, counter-checks with secondary,\nmerges into a diff showing agreements and conflicts.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport logging\nfrom dataclasses import dataclass\n\nfrom maggy.config import MaggyConfig\nfrom maggy.models.plan import Plan, PlanDiff, PlanStep\n\nlogger = logging.getLogger(__name__)\n\nDUAL_PLAN_THRESHOLD = 4\n\n\n@dataclass\nclass PlanRequest:\n    \"\"\"Input for plan generation.\"\"\"\n\n    task: str\n    blast_score: int = 0\n    file_context: list[str] | None = None\n\n\nclass PlanningService:\n    \"\"\"Dual-plan orchestrator.\"\"\"\n\n    def __init__(self, cfg: MaggyConfig):\n        self.cfg = cfg\n\n    def should_dual_plan(self, blast_score: int) -> bool:\n        \"\"\"Only dual-plan for tasks above threshold.\"\"\"\n        return blast_score >= DUAL_PLAN_THRESHOLD\n\n    def generate_plan(\n        self, task: str, model: str,\n        files: list[str] | None = None,\n    ) -> Plan:\n        \"\"\"Generate a plan (stub — real impl calls LLM).\"\"\"\n        steps = [\n            PlanStep(\n                description=f\"Analyze {task}\",\n                files=files or [],\n                blast_estimate=1,\n            ),\n            PlanStep(\n                description=f\"Implement {task}\",\n                files=files or [],\n                blast_estimate=2,\n            ),\n            PlanStep(\n                description=f\"Test {task}\",\n                blast_estimate=1,\n            ),\n        ]\n        return Plan(\n            task=task, model=model, steps=steps,\n            total_blast=sum(s.blast_estimate for s in steps),\n        )\n\n    def diff_plans(\n        self, primary: Plan, counter: Plan,\n    ) -> PlanDiff:\n        \"\"\"Compare two plans and produce a diff.\"\"\"\n        p_descs = {s.description for s in primary.steps}\n        c_descs = {s.description for s in counter.steps}\n\n        agreed = list(p_descs & c_descs)\n        primary_only = list(p_descs - c_descs)\n        counter_only = list(c_descs - p_descs)\n\n        conflicts = []\n        for po in primary_only:\n            for co in counter_only:\n                if _similar(po, co):\n                    conflicts.append({\n                        \"primary\": po, \"counter\": co,\n                    })\n\n        return PlanDiff(\n            agreed=agreed,\n            conflicts=conflicts,\n            primary_only=[\n                p for p in primary_only\n                if not any(c[\"primary\"] == p for c in conflicts)\n            ],\n            counter_only=[\n                c for c in counter_only\n                if not any(cf[\"counter\"] == c for cf in conflicts)\n            ],\n        )\n\n    def plan_task(self, req: PlanRequest) -> dict:\n        \"\"\"Full planning flow for a task.\"\"\"\n        primary = self.generate_plan(\n            req.task, \"claude\", req.file_context,\n        )\n        if not self.should_dual_plan(req.blast_score):\n            return {\n                \"mode\": \"single\",\n                \"plan\": primary,\n                \"diff\": None,\n            }\n\n        counter = self.generate_plan(\n            req.task, \"codex\", req.file_context,\n        )\n        diff = self.diff_plans(primary, counter)\n        return {\n            \"mode\": \"dual\",\n            \"plan\": primary,\n            \"counter_plan\": counter,\n            \"diff\": diff,\n        }\n\n\ndef _similar(a: str, b: str) -> bool:\n    \"\"\"Simple word-overlap similarity check.\"\"\"\n    a_words = set(a.lower().split())\n    b_words = set(b.lower().split())\n    if not a_words or not b_words:\n        return False\n    overlap = len(a_words & b_words)\n    return overlap / min(len(a_words), len(b_words)) > 0.5\n"
  },
  {
    "path": "maggy/maggy/process/__init__.py",
    "content": "\"\"\"Process Intelligence — learns from PRs, reviews, CI to improve engineering.\"\"\"\n"
  },
  {
    "path": "maggy/maggy/process/discovery.py",
    "content": "\"\"\"Environment auto-discovery — detects CI/CD, review tools, etc.\"\"\"\n\nfrom __future__ import annotations\n\nimport logging\nfrom pathlib import Path\n\nimport httpx\n\nlogger = logging.getLogger(__name__)\n\nGITHUB_API = \"https://api.github.com\"\n\n\ndef discover_local(project_path: Path) -> dict:\n    \"\"\"Discover tools from local filesystem markers.\"\"\"\n    result: dict[str, list[str]] = {\n        \"ci\": [], \"quality\": [], \"review\": [], \"deps\": [],\n    }\n\n    # CI/CD\n    gh_workflows = project_path / \".github\" / \"workflows\"\n    if gh_workflows.exists():\n        result[\"ci\"].append(\"github_actions\")\n\n    if (project_path / \"Jenkinsfile\").exists():\n        result[\"ci\"].append(\"jenkins\")\n\n    if (project_path / \".circleci\").exists():\n        result[\"ci\"].append(\"circleci\")\n\n    if (project_path / \".gitlab-ci.yml\").exists():\n        result[\"ci\"].append(\"gitlab_ci\")\n\n    # Code quality\n    if (project_path / \".eslintrc.json\").exists() or \\\n       (project_path / \".eslintrc.js\").exists():\n        result[\"quality\"].append(\"eslint\")\n\n    if (project_path / \"pyproject.toml\").exists():\n        content = (project_path / \"pyproject.toml\").read_text()\n        if \"ruff\" in content:\n            result[\"quality\"].append(\"ruff\")\n        if \"mypy\" in content:\n            result[\"quality\"].append(\"mypy\")\n\n    if (project_path / \".pre-commit-config.yaml\").exists():\n        result[\"quality\"].append(\"pre-commit\")\n\n    # Review tools\n    if (project_path / \"CODEOWNERS\").exists() or \\\n       (project_path / \".github\" / \"CODEOWNERS\").exists():\n        result[\"review\"].append(\"codeowners\")\n\n    # Dependency management\n    dependabot = project_path / \".github\" / \"dependabot.yml\"\n    if dependabot.exists():\n        result[\"deps\"].append(\"dependabot\")\n\n    renovate = project_path / \"renovate.json\"\n    if renovate.exists():\n        result[\"deps\"].append(\"renovate\")\n\n    return result\n\n\nasync def discover_github(\n    repo: str, token: str,\n) -> dict:\n    \"\"\"Discover integrations via GitHub API.\"\"\"\n    result: dict[str, list[str]] = {\n        \"bots\": [], \"protection\": [],\n    }\n    headers = {\n        \"Authorization\": f\"Bearer {token}\",\n        \"Accept\": \"application/vnd.github.v3+json\",\n    }\n\n    async with httpx.AsyncClient(\n        timeout=10.0, headers=headers,\n    ) as client:\n        # Check branch protection\n        try:\n            resp = await client.get(\n                f\"{GITHUB_API}/repos/{repo}/branches/main\"\n            )\n            if resp.status_code == 200:\n                data = resp.json()\n                if data.get(\"protected\"):\n                    result[\"protection\"].append(\n                        \"branch_protection\"\n                    )\n        except httpx.HTTPError:\n            pass\n\n        # Check recent PR comments for bots\n        try:\n            resp = await client.get(\n                f\"{GITHUB_API}/repos/{repo}/pulls\",\n                params={\"state\": \"all\", \"per_page\": \"5\"},\n            )\n            if resp.status_code == 200:\n                for pr in resp.json()[:3]:\n                    cr = await client.get(\n                        f\"{GITHUB_API}/repos/{repo}\"\n                        f\"/pulls/{pr['number']}/comments\",\n                        params={\"per_page\": \"10\"},\n                    )\n                    if cr.status_code == 200:\n                        for c in cr.json():\n                            user = (c.get(\"user\") or {}).get(\n                                \"login\", \"\"\n                            ).lower()\n                            if \"coderabbit\" in user:\n                                result[\"bots\"].append(\n                                    \"coderabbit\"\n                                )\n                            if \"dependabot\" in user:\n                                result[\"bots\"].append(\n                                    \"dependabot\"\n                                )\n                # Deduplicate\n                result[\"bots\"] = list(set(result[\"bots\"]))\n        except httpx.HTTPError:\n            pass\n\n    return result\n"
  },
  {
    "path": "maggy/maggy/process/github_prs.py",
    "content": "\"\"\"GitHub PR fetcher — reads PRs, reviews, and CI checks.\n\nReuses patterns from providers/github_issues.py (httpx async,\nheaders, error handling). Fetches up to 200 PRs per repo.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport logging\n\nimport httpx\n\nfrom .models import CheckRecord, PRRecord, ReviewRecord\n\nlogger = logging.getLogger(__name__)\n\nGITHUB_API = \"https://api.github.com\"\nDEFAULT_TIMEOUT = 15\n\n\ndef _headers(token: str) -> dict[str, str]:\n    return {\n        \"Authorization\": f\"Bearer {token}\",\n        \"Accept\": \"application/vnd.github+json\",\n        \"X-GitHub-Api-Version\": \"2022-11-28\",\n    }\n\n\nasync def fetch_prs(\n    repo: str,\n    token: str,\n    limit: int = 200,\n) -> list[PRRecord]:\n    \"\"\"Fetch merged PRs with reviews and checks.\"\"\"\n    raw_prs = await _fetch_pr_list(repo, token, limit)\n    records: list[PRRecord] = []\n\n    async with httpx.AsyncClient(\n        timeout=DEFAULT_TIMEOUT, headers=_headers(token)\n    ) as client:\n        for pr_data in raw_prs:\n            detail = await _fetch_pr_detail(\n                client, repo, pr_data[\"number\"]\n            )\n            pr = _parse_pr(detail or pr_data)\n            pr.reviews = await _fetch_reviews(\n                client, repo, pr.number\n            )\n            if pr.head_sha:\n                pr.checks = await _fetch_checks(\n                    client, repo, pr.head_sha\n                )\n            pr.files = await _fetch_files(\n                client, repo, pr.number\n            )\n            records.append(pr)\n\n    return records\n\n\nasync def _fetch_pr_list(\n    repo: str,\n    token: str,\n    limit: int,\n) -> list[dict]:\n    \"\"\"Paginate through /pulls endpoint.\"\"\"\n    results: list[dict] = []\n    page = 1\n    per_page = min(limit, 100)\n\n    async with httpx.AsyncClient(\n        timeout=DEFAULT_TIMEOUT, headers=_headers(token)\n    ) as client:\n        while len(results) < limit:\n            resp = await client.get(\n                f\"{GITHUB_API}/repos/{repo}/pulls\",\n                params={\n                    \"state\": \"all\",\n                    \"sort\": \"updated\",\n                    \"direction\": \"desc\",\n                    \"per_page\": str(per_page),\n                    \"page\": str(page),\n                },\n            )\n            if resp.status_code != 200:\n                _log_error(repo, \"pulls\", resp)\n                break\n            batch = resp.json()\n            if not batch:\n                break\n            results.extend(batch)\n            page += 1\n\n    return results[:limit]\n\n\nasync def _fetch_pr_detail(\n    client: httpx.AsyncClient,\n    repo: str,\n    pr_number: int,\n) -> dict | None:\n    \"\"\"Fetch single PR detail (has additions/deletions).\"\"\"\n    resp = await client.get(\n        f\"{GITHUB_API}/repos/{repo}/pulls/{pr_number}\"\n    )\n    if resp.status_code != 200:\n        return None\n    return resp.json()\n\n\ndef _parse_pr(data: dict) -> PRRecord:\n    \"\"\"Convert raw GitHub PR JSON to PRRecord.\"\"\"\n    return PRRecord(\n        number=data.get(\"number\", 0),\n        title=data.get(\"title\", \"\"),\n        author=(data.get(\"user\") or {}).get(\"login\", \"\"),\n        state=_pr_state(data),\n        created_at=data.get(\"created_at\", \"\"),\n        merged_at=data.get(\"merged_at\"),\n        additions=data.get(\"additions\", 0),\n        deletions=data.get(\"deletions\", 0),\n        changed_files=data.get(\"changed_files\", 0),\n        head_sha=(data.get(\"head\") or {}).get(\"sha\", \"\"),\n        base_branch=(data.get(\"base\") or {}).get(\"ref\", \"\"),\n    )\n\n\ndef _pr_state(data: dict) -> str:\n    if data.get(\"merged_at\"):\n        return \"merged\"\n    return data.get(\"state\", \"open\")\n\n\nasync def _fetch_reviews(\n    client: httpx.AsyncClient,\n    repo: str,\n    pr_number: int,\n) -> list[ReviewRecord]:\n    \"\"\"Fetch all reviews for a PR.\"\"\"\n    resp = await client.get(\n        f\"{GITHUB_API}/repos/{repo}/pulls/{pr_number}/reviews\"\n    )\n    if resp.status_code != 200:\n        return []\n    return [\n        ReviewRecord(\n            reviewer=(r.get(\"user\") or {}).get(\"login\", \"\"),\n            state=r.get(\"state\", \"\"),\n            body=r.get(\"body\") or \"\",\n            submitted_at=r.get(\"submitted_at\", \"\"),\n        )\n        for r in resp.json()\n    ]\n\n\nasync def _fetch_checks(\n    client: httpx.AsyncClient,\n    repo: str,\n    sha: str,\n) -> list[CheckRecord]:\n    \"\"\"Fetch CI check runs for a commit.\"\"\"\n    resp = await client.get(\n        f\"{GITHUB_API}/repos/{repo}/commits/{sha}/check-runs\"\n    )\n    if resp.status_code != 200:\n        return []\n    return [\n        CheckRecord(\n            name=c.get(\"name\", \"\"),\n            conclusion=c.get(\"conclusion\") or \"pending\",\n            started_at=c.get(\"started_at\", \"\"),\n            completed_at=c.get(\"completed_at\") or \"\",\n        )\n        for c in resp.json().get(\"check_runs\", [])\n    ]\n\n\nasync def _fetch_files(\n    client: httpx.AsyncClient,\n    repo: str,\n    pr_number: int,\n) -> list[str]:\n    \"\"\"Fetch file paths changed in a PR.\"\"\"\n    resp = await client.get(\n        f\"{GITHUB_API}/repos/{repo}/pulls/{pr_number}/files\",\n        params={\"per_page\": \"100\"},\n    )\n    if resp.status_code != 200:\n        return []\n    return [\n        f.get(\"filename\", \"\")\n        for f in resp.json()\n        if f.get(\"filename\")\n    ]\n\n\ndef _log_error(\n    repo: str, endpoint: str, resp: httpx.Response\n) -> None:\n    body = (resp.text or \"\")[:200].replace(\"\\n\", \" \")\n    logger.warning(\n        \"GitHub /repos/%s/%s returned %s: %s\",\n        repo, endpoint, resp.status_code, body,\n    )\n"
  },
  {
    "path": "maggy/maggy/process/model_router.py",
    "content": "\"\"\"Dynamic model routing — routes tasks to models by complexity.\n\nNot just fallback chains: intelligent routing based on task complexity,\nsecurity sensitivity, and task type. Simple tasks go to cheap models,\ncomplex tasks to premium, security-critical get dual validation.\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom dataclasses import dataclass, field\n\nfrom .models import ModelTier\n\n\nDEFAULT_TIERS: list[ModelTier] = [\n    ModelTier(\n        name=\"local\",\n        provider=\"ollama\",\n        model=\"qwen3-coder:30b-a3b-q8_0\",\n        cost_rank=1,\n        complexity_min=0,\n        complexity_max=5,\n        strengths=[\"formatting\", \"simple_edits\", \"crud\", \"feature\"],\n    ),\n    ModelTier(\n        name=\"kimi\",\n        provider=\"moonshot\",\n        model=\"kimi-k2\",\n        cost_rank=2,\n        complexity_min=0,\n        complexity_max=5,\n        strengths=[\"documentation\", \"simple_tasks\"],\n    ),\n    ModelTier(\n        name=\"codex\",\n        provider=\"openai\",\n        model=\"codex\",\n        cost_rank=3,\n        complexity_min=4,\n        complexity_max=10,\n        strengths=[\"code_generation\", \"api_design\", \"review\"],\n    ),\n    ModelTier(\n        name=\"claude\",\n        provider=\"anthropic\",\n        model=\"claude-sonnet-4\",\n        cost_rank=4,\n        complexity_min=5,\n        complexity_max=10,\n        strengths=[\"complex_reasoning\", \"security\", \"architecture\"],\n    ),\n]\n\n\n@dataclass\nclass RoutingDecision:\n    \"\"\"Result of dynamic model routing.\"\"\"\n\n    primary: ModelTier\n    validator: ModelTier | None = None\n    reason: str = \"\"\n    fallback_chain: list[str] = field(default_factory=list)\n\n\ndef route_task(\n    complexity_score: int,\n    task_type: str = \"general\",\n    security_sensitive: bool = False,\n    tiers: list[ModelTier] | None = None,\n    stakes: str = \"low\",\n) -> RoutingDecision:\n    \"\"\"Route a task to the optimal model tier.\n\n    Args:\n        complexity_score: 0-10 from polyphony scoring\n        task_type: \"bug\", \"feature\", \"refactor\", \"test\", etc.\n        security_sensitive: True for auth/billing/PII tasks\n        tiers: Custom tiers (defaults to DEFAULT_TIERS)\n    \"\"\"\n    available = tiers or DEFAULT_TIERS\n    primaries = [\n        t for t in available if t.role == \"primary\"\n    ]\n    validators = [\n        t for t in available if t.role == \"validator\"\n    ]\n\n    primary = _select_primary(\n        complexity_score, task_type, primaries, stakes,\n    )\n    validator = _select_validator(\n        complexity_score, security_sensitive, validators, stakes,\n    )\n    fallback = _build_fallback(primary, primaries)\n    reason = _build_reason(\n        primary, complexity_score, task_type, security_sensitive\n    )\n\n    return RoutingDecision(\n        primary=primary,\n        validator=validator,\n        reason=reason,\n        fallback_chain=fallback,\n    )\n\n\ndef _select_primary(\n    score: int,\n    task_type: str,\n    tiers: list[ModelTier],\n    stakes: str = \"low\",\n) -> ModelTier:\n    \"\"\"Pick the cheapest tier that handles the complexity.\"\"\"\n    candidates = [\n        t for t in tiers\n        if t.complexity_min <= score <= t.complexity_max\n    ]\n    if not candidates:\n        return tiers[-1]  # Fallback to most capable\n\n    candidates.sort(key=lambda t: t.cost_rank)\n\n    # High stakes or security: skip cheapest tiers\n    high_risk = (\n        stakes == \"high\"\n        or task_type in (\"security\", \"auth\", \"billing\")\n    )\n    if high_risk:\n        capable = [\n            c for c in candidates if c.cost_rank >= 3\n        ]\n        if capable:\n            return capable[0]\n\n    return candidates[0]\n\n\ndef _select_validator(\n    score: int,\n    security_sensitive: bool,\n    validators: list[ModelTier],\n    stakes: str = \"low\",\n) -> ModelTier | None:\n    \"\"\"Add validation for high-risk tasks.\"\"\"\n    if not validators:\n        return None\n    if score >= 8 or security_sensitive or stakes == \"high\":\n        return validators[0]\n    return None\n\n\ndef _build_fallback(\n    primary: ModelTier,\n    tiers: list[ModelTier],\n) -> list[str]:\n    \"\"\"Build fallback chain: next tier up, then next.\"\"\"\n    above = [\n        t for t in tiers\n        if t.cost_rank > primary.cost_rank\n    ]\n    above.sort(key=lambda t: t.cost_rank)\n    return [t.name for t in above]\n\n\ndef _build_reason(\n    primary: ModelTier,\n    score: int,\n    task_type: str,\n    security_sensitive: bool,\n) -> str:\n    \"\"\"Human-readable routing explanation.\"\"\"\n    parts = [f\"complexity={score}/10\"]\n    if task_type != \"general\":\n        parts.append(f\"type={task_type}\")\n    if security_sensitive:\n        parts.append(\"security-sensitive\")\n    parts.append(f\"routed to {primary.name}\")\n    return \", \".join(parts)\n"
  },
  {
    "path": "maggy/maggy/process/models.py",
    "content": "\"\"\"Dataclasses for Process Intelligence — PR records, reviews, CI checks.\"\"\"\n\nfrom __future__ import annotations\n\nfrom dataclasses import dataclass, field\n\n\n@dataclass\nclass ReviewRecord:\n    \"\"\"A single PR review event.\"\"\"\n\n    reviewer: str\n    state: str  # APPROVED, CHANGES_REQUESTED, COMMENTED\n    body: str\n    submitted_at: str\n\n\n@dataclass\nclass CheckRecord:\n    \"\"\"A single CI check run result.\"\"\"\n\n    name: str\n    conclusion: str  # success, failure, neutral, skipped\n    started_at: str\n    completed_at: str\n\n\n@dataclass\nclass PRRecord:\n    \"\"\"A pull request with computed metrics.\"\"\"\n\n    number: int\n    title: str\n    author: str\n    state: str  # open, closed, merged\n    created_at: str\n    merged_at: str | None\n    additions: int\n    deletions: int\n    changed_files: int\n    head_sha: str\n    base_branch: str\n    reviews: list[ReviewRecord] = field(default_factory=list)\n    checks: list[CheckRecord] = field(default_factory=list)\n    files: list[str] = field(default_factory=list)\n\n    @property\n    def total_lines(self) -> int:\n        return self.additions + self.deletions\n\n    @property\n    def review_rounds(self) -> int:\n        return sum(\n            1 for r in self.reviews\n            if r.state == \"CHANGES_REQUESTED\"\n        )\n\n    @property\n    def time_to_merge_hours(self) -> float | None:\n        if not self.merged_at or not self.created_at:\n            return None\n        from datetime import datetime, timezone\n        fmt = \"%Y-%m-%dT%H:%M:%SZ\"\n        try:\n            created = datetime.strptime(self.created_at, fmt)\n            merged = datetime.strptime(self.merged_at, fmt)\n            created = created.replace(tzinfo=timezone.utc)\n            merged = merged.replace(tzinfo=timezone.utc)\n            return (merged - created).total_seconds() / 3600\n        except (ValueError, TypeError):\n            return None\n\n    @property\n    def ci_passed(self) -> bool:\n        if not self.checks:\n            return True\n        return all(\n            c.conclusion in (\"success\", \"neutral\", \"skipped\")\n            for c in self.checks\n        )\n\n\n@dataclass\nclass ReviewSignal:\n    \"\"\"Recurring theme from a reviewer.\"\"\"\n\n    reviewer: str\n    theme: str\n    count: int\n    example_prs: list[int] = field(default_factory=list)\n\n\n@dataclass\nclass CISignal:\n    \"\"\"CI failure pattern.\"\"\"\n\n    check_name: str\n    failure_count: int\n    total_runs: int\n    correlated_files: list[str] = field(default_factory=list)\n\n    @property\n    def failure_rate(self) -> float:\n        if self.total_runs == 0:\n            return 0.0\n        return self.failure_count / self.total_runs\n\n\n@dataclass\nclass VelocitySignal:\n    \"\"\"PR velocity metrics.\"\"\"\n\n    avg_time_to_merge_hours: float\n    median_time_to_merge_hours: float\n    avg_review_rounds: float\n    avg_pr_size: float\n    total_prs_analyzed: int\n\n\n@dataclass\nclass ProcessReport:\n    \"\"\"The 5-minute analysis report.\"\"\"\n\n    project_key: str\n    generated_at: str\n    total_prs: int\n    velocity: VelocitySignal | None = None\n    review_signals: list[ReviewSignal] = field(default_factory=list)\n    ci_signals: list[CISignal] = field(default_factory=list)\n    routing_recommendations: list[dict] = field(\n        default_factory=list\n    )\n    preemptive_fixes: list[str] = field(default_factory=list)\n    summary: str = \"\"\n\n\n@dataclass\nclass ModelTier:\n    \"\"\"A model tier for dynamic routing.\"\"\"\n\n    name: str\n    provider: str\n    model: str\n    cost_rank: int  # 1=cheapest, 5=most expensive\n    complexity_min: int  # Min complexity score\n    complexity_max: int  # Max complexity score\n    strengths: list[str] = field(default_factory=list)\n    role: str = \"primary\"  # \"primary\" | \"validator\"\n"
  },
  {
    "path": "maggy/maggy/process/patterns.py",
    "content": "\"\"\"Pattern engine — correlates signals into actionable insights.\n\nTakes raw signals from signals.py and produces:\n- Preemptive fix recommendations\n- Routing recommendations per task type\n- Bottleneck identification\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom .models import (\n    CISignal,\n    PRRecord,\n    ReviewSignal,\n    VelocitySignal,\n)\n\n\ndef identify_bottlenecks(\n    velocity: VelocitySignal | None,\n    prs: list[PRRecord],\n) -> list[str]:\n    \"\"\"Identify why PRs are slow.\"\"\"\n    if not velocity:\n        return [\"Insufficient data — no merged PRs found\"]\n\n    bottlenecks: list[str] = []\n\n    if velocity.avg_time_to_merge_hours > 48:\n        bottlenecks.append(\n            f\"Slow merge: avg {velocity.avg_time_to_merge_hours:.0f}h \"\n            f\"(target: <24h)\"\n        )\n\n    if velocity.avg_review_rounds > 1.5:\n        bottlenecks.append(\n            f\"High review churn: avg {velocity.avg_review_rounds:.1f} \"\n            f\"rounds (target: <1.5)\"\n        )\n\n    if velocity.avg_pr_size > 500:\n        bottlenecks.append(\n            f\"Large PRs: avg {velocity.avg_pr_size:.0f} lines \"\n            f\"(target: <300)\"\n        )\n\n    # Size-velocity correlation\n    large = [\n        p for p in prs\n        if p.total_lines > 500\n        and p.time_to_merge_hours is not None\n    ]\n    small = [\n        p for p in prs\n        if p.total_lines <= 200\n        and p.time_to_merge_hours is not None\n    ]\n    if large and small:\n        avg_large = _avg_merge_time(large)\n        avg_small = _avg_merge_time(small)\n        if avg_large and avg_small and avg_large > avg_small * 2:\n            ratio = avg_large / avg_small\n            bottlenecks.append(\n                f\"Large PRs take {ratio:.1f}x longer to merge\"\n            )\n\n    if not bottlenecks:\n        bottlenecks.append(\"No major bottlenecks detected\")\n\n    return bottlenecks\n\n\ndef generate_preemptive_fixes(\n    review_signals: list[ReviewSignal],\n    ci_signals: list[CISignal],\n) -> list[str]:\n    \"\"\"Generate actionable pre-PR fixes.\"\"\"\n    fixes: list[str] = []\n\n    for sig in review_signals[:5]:\n        fixes.append(\n            f\"Add {sig.theme.replace('_', ' ')} before PR \"\n            f\"— reviewer {sig.reviewer} flags this \"\n            f\"{sig.count}x\"\n        )\n\n    for sig in ci_signals[:3]:\n        if sig.failure_rate > 0.2:\n            files = \", \".join(sig.correlated_files[:3])\n            fix = (\n                f\"Run {sig.check_name} locally before push \"\n                f\"— fails {sig.failure_rate:.0%} of the time\"\n            )\n            if files:\n                fix += f\" (correlated with: {files})\"\n            fixes.append(fix)\n\n    return fixes\n\n\ndef generate_routing_recs(\n    prs: list[PRRecord],\n) -> list[dict]:\n    \"\"\"Recommend model routing per task pattern.\"\"\"\n    recs: list[dict] = []\n\n    # Count security-related PRs\n    sec_prs = [\n        p for p in prs\n        if _is_security_related(p)\n    ]\n    if sec_prs:\n        recs.append({\n            \"pattern\": \"Security/auth changes\",\n            \"model\": \"claude\",\n            \"validation\": \"codex\",\n            \"reason\": (\n                f\"{len(sec_prs)} security PRs found — \"\n                f\"route to Claude + Codex validation\"\n            ),\n        })\n\n    # Count test-only PRs\n    test_prs = [\n        p for p in prs\n        if _is_test_only(p)\n    ]\n    if test_prs:\n        recs.append({\n            \"pattern\": \"Test-only changes\",\n            \"model\": \"kimi\",\n            \"validation\": None,\n            \"reason\": (\n                f\"{len(test_prs)} test-only PRs — \"\n                f\"route to Kimi (cheaper)\"\n            ),\n        })\n\n    # Count doc changes\n    doc_prs = [p for p in prs if _is_docs(p)]\n    if doc_prs:\n        recs.append({\n            \"pattern\": \"Documentation changes\",\n            \"model\": \"kimi\",\n            \"validation\": None,\n            \"reason\": (\n                f\"{len(doc_prs)} doc PRs — \"\n                f\"route to Kimi\"\n            ),\n        })\n\n    # Complex multi-file changes\n    complex_prs = [\n        p for p in prs if p.changed_files >= 10\n    ]\n    if complex_prs:\n        recs.append({\n            \"pattern\": \"Multi-file refactors (10+ files)\",\n            \"model\": \"claude\",\n            \"validation\": \"codex\",\n            \"reason\": (\n                f\"{len(complex_prs)} complex PRs — \"\n                f\"route to Claude\"\n            ),\n        })\n\n    return recs\n\n\ndef _avg_merge_time(prs: list[PRRecord]) -> float | None:\n    times = [\n        p.time_to_merge_hours\n        for p in prs\n        if p.time_to_merge_hours is not None\n    ]\n    if not times:\n        return None\n    return sum(times) / len(times)\n\n\ndef _is_security_related(pr: PRRecord) -> bool:\n    keywords = {\"auth\", \"security\", \"token\", \"session\"}\n    title = pr.title.lower()\n    return any(k in title for k in keywords) or any(\n        \"auth\" in f or \"security\" in f for f in pr.files\n    )\n\n\ndef _is_test_only(pr: PRRecord) -> bool:\n    if not pr.files:\n        return False\n    return all(\n        \"test\" in f.lower() or \"spec\" in f.lower()\n        for f in pr.files\n    )\n\n\ndef _is_docs(pr: PRRecord) -> bool:\n    if not pr.files:\n        return False\n    return all(\n        f.endswith(\".md\") or \"doc\" in f.lower()\n        for f in pr.files\n    )\n"
  },
  {
    "path": "maggy/maggy/process/report.py",
    "content": "\"\"\"Report generator — produces the 5-minute process analysis.\n\nAnswers:\n1. Why are your PRs slow?\n2. What do reviewers always flag?\n3. Which model should handle which task?\n4. What will Maggy change before the next PR?\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom .models import (\n    CISignal,\n    ProcessReport,\n    ReviewSignal,\n    VelocitySignal,\n)\n\n\ndef generate_summary(report: ProcessReport) -> str:\n    \"\"\"Build human-readable summary from report data.\"\"\"\n    lines: list[str] = []\n\n    lines.append(\n        f\"## Process Report: {report.project_key}\"\n    )\n    lines.append(\n        f\"Analyzed {report.total_prs} PRs\"\n    )\n    lines.append(\"\")\n\n    # Velocity\n    if report.velocity:\n        v = report.velocity\n        lines.append(\"### PR Velocity\")\n        lines.append(\n            f\"- Avg time to merge: {v.avg_time_to_merge_hours:.1f}h\"\n        )\n        lines.append(\n            f\"- Median time to merge: \"\n            f\"{v.median_time_to_merge_hours:.1f}h\"\n        )\n        lines.append(\n            f\"- Avg review rounds: {v.avg_review_rounds:.1f}\"\n        )\n        lines.append(\n            f\"- Avg PR size: {v.avg_pr_size:.0f} lines\"\n        )\n        lines.append(\"\")\n\n    # Review patterns\n    if report.review_signals:\n        lines.append(\"### Recurring Review Themes\")\n        for sig in report.review_signals[:5]:\n            lines.append(\n                f\"- **{sig.reviewer}** flags \"\n                f\"*{sig.theme.replace('_', ' ')}* \"\n                f\"({sig.count}x)\"\n            )\n        lines.append(\"\")\n\n    # CI failures\n    if report.ci_signals:\n        lines.append(\"### CI Failure Patterns\")\n        for sig in report.ci_signals[:5]:\n            lines.append(\n                f\"- **{sig.check_name}**: fails \"\n                f\"{sig.failure_rate:.0%} of runs\"\n            )\n            if sig.correlated_files:\n                files = \", \".join(sig.correlated_files[:3])\n                lines.append(f\"  Correlated with: {files}\")\n        lines.append(\"\")\n\n    # Routing\n    if report.routing_recommendations:\n        lines.append(\"### Model Routing Recommendations\")\n        for rec in report.routing_recommendations:\n            model = rec.get(\"model\", \"?\")\n            pattern = rec.get(\"pattern\", \"?\")\n            lines.append(f\"- {pattern} -> **{model}**\")\n            val = rec.get(\"validation\")\n            if val:\n                lines.append(\n                    f\"  + validation by **{val}**\"\n                )\n        lines.append(\"\")\n\n    # Fixes\n    if report.preemptive_fixes:\n        lines.append(\"### Pre-emptive Fixes\")\n        for fix in report.preemptive_fixes:\n            lines.append(f\"- {fix}\")\n        lines.append(\"\")\n\n    return \"\\n\".join(lines)\n\n\ndef format_health_metrics(\n    velocity: VelocitySignal | None,\n    ci_signals: list[CISignal],\n    review_signals: list[ReviewSignal],\n) -> dict:\n    \"\"\"Format as structured health dashboard data.\"\"\"\n    health: dict = {\"status\": \"unknown\"}\n\n    if velocity:\n        health[\"velocity\"] = {\n            \"avg_merge_hours\": (\n                velocity.avg_time_to_merge_hours\n            ),\n            \"median_merge_hours\": (\n                velocity.median_time_to_merge_hours\n            ),\n            \"avg_review_rounds\": velocity.avg_review_rounds,\n            \"avg_pr_size\": velocity.avg_pr_size,\n            \"prs_analyzed\": velocity.total_prs_analyzed,\n        }\n\n    ci_pass_rate = _ci_pass_rate(ci_signals)\n    health[\"ci_pass_rate\"] = ci_pass_rate\n    health[\"top_review_themes\"] = [\n        {\"reviewer\": s.reviewer, \"theme\": s.theme, \"count\": s.count}\n        for s in review_signals[:5]\n    ]\n\n    # Overall status\n    if velocity and ci_pass_rate is not None:\n        if (\n            velocity.avg_review_rounds <= 1.5\n            and ci_pass_rate >= 0.9\n        ):\n            health[\"status\"] = \"healthy\"\n        elif (\n            velocity.avg_review_rounds <= 2.5\n            and ci_pass_rate >= 0.7\n        ):\n            health[\"status\"] = \"moderate\"\n        else:\n            health[\"status\"] = \"needs_attention\"\n\n    return health\n\n\ndef _ci_pass_rate(\n    ci_signals: list[CISignal],\n) -> float | None:\n    \"\"\"Overall CI pass rate across all checks.\"\"\"\n    total_runs = sum(s.total_runs for s in ci_signals)\n    total_fails = sum(s.failure_count for s in ci_signals)\n    if total_runs == 0:\n        return None\n    return 1.0 - (total_fails / total_runs)\n"
  },
  {
    "path": "maggy/maggy/process/service.py",
    "content": "\"\"\"Process Intelligence service — orchestrates the full pipeline.\n\nPipeline: fetch PRs -> extract signals -> find patterns -> generate report.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport logging\nfrom datetime import datetime, timezone\nfrom pathlib import Path\n\nfrom maggy.config import MaggyConfig\n\nfrom . import github_prs\nfrom .models import ProcessReport\nfrom .patterns import (\n    generate_preemptive_fixes,\n    generate_routing_recs,\n    identify_bottlenecks,\n)\nfrom .report import generate_summary\nfrom .signals import (\n    extract_ci_signals,\n    extract_review_signals,\n    extract_velocity_signals,\n)\nfrom .store import ProcessStore\n\nlogger = logging.getLogger(__name__)\n\n\nclass ProcessService:\n    \"\"\"Orchestrates process intelligence analysis.\"\"\"\n\n    def __init__(self, cfg: MaggyConfig):\n        self.cfg = cfg\n        db_path = (\n            Path(cfg.storage.path).expanduser().parent\n            / \"process.db\"\n        )\n        self.store = ProcessStore(db_path)\n\n    async def analyze(\n        self, project_key: str\n    ) -> ProcessReport:\n        \"\"\"Run full analysis pipeline for a project.\"\"\"\n        repo = self._resolve_repo(project_key)\n        token = self.cfg.issue_tracker.github.token\n\n        if not token:\n            raise ValueError(\"GITHUB_TOKEN not configured\")\n        if not repo:\n            raise ValueError(\n                f\"No repo found for project '{project_key}'\"\n            )\n\n        logger.info(\n            \"Analyzing %s — fetching PRs from %s\",\n            project_key, repo,\n        )\n\n        # 1. Fetch PRs\n        prs = await github_prs.fetch_prs(\n            repo=repo, token=token, limit=200\n        )\n        logger.info(\"Fetched %d PRs from %s\", len(prs), repo)\n\n        # 2. Extract signals\n        review_signals = extract_review_signals(prs)\n        ci_signals = extract_ci_signals(prs)\n        velocity = extract_velocity_signals(prs)\n\n        # 3. Find patterns\n        identify_bottlenecks(velocity, prs)\n        fixes = generate_preemptive_fixes(\n            review_signals, ci_signals\n        )\n        routing = generate_routing_recs(prs)\n\n        # 4. Build report\n        now = datetime.now(timezone.utc).isoformat()\n        report = ProcessReport(\n            project_key=project_key,\n            generated_at=now,\n            total_prs=len(prs),\n            velocity=velocity,\n            review_signals=review_signals,\n            ci_signals=ci_signals,\n            routing_recommendations=routing,\n            preemptive_fixes=fixes,\n        )\n        report.summary = generate_summary(report)\n\n        # 5. Persist\n        self.store.save_report(report)\n        logger.info(\n            \"Process report saved for %s: %d PRs, \"\n            \"%d review signals, %d CI signals\",\n            project_key, len(prs),\n            len(review_signals), len(ci_signals),\n        )\n\n        return report\n\n    def get_report(self, project_key: str) -> dict | None:\n        \"\"\"Get latest cached report.\"\"\"\n        return self.store.load_latest_report(project_key)\n\n    def get_health(self, project_key: str) -> dict | None:\n        \"\"\"Get health metrics from latest report.\"\"\"\n        raw = self.store.load_latest_report(project_key)\n        if not raw:\n            return None\n        return raw\n\n    def _resolve_repo(\n        self, project_key: str\n    ) -> str | None:\n        \"\"\"Map project_key to GitHub org/repo.\"\"\"\n        gh = self.cfg.issue_tracker.github\n        for repo in gh.repos:\n            slug = repo.split(\"/\")[-1]\n            if slug == project_key:\n                return repo\n        # Try matching against codebase keys\n        for cb in self.cfg.codebases:\n            if cb.key == project_key:\n                slug = Path(cb.path).name\n                if gh.org:\n                    return f\"{gh.org}/{slug}\"\n        return None\n"
  },
  {
    "path": "maggy/maggy/process/signals.py",
    "content": "\"\"\"Signal extraction — derives patterns from raw PR data.\n\nThree signal types:\n- Review signals: what do reviewers always flag?\n- CI signals: which checks fail and why?\n- Velocity signals: how fast do PRs merge?\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom collections import Counter\n\nfrom .models import (\n    CISignal,\n    PRRecord,\n    ReviewSignal,\n    VelocitySignal,\n)\n\n# Keywords that indicate common review themes\nREVIEW_THEMES: dict[str, list[str]] = {\n    \"error_handling\": [\n        \"error\", \"exception\", \"try\", \"catch\", \"handle\",\n        \"edge case\", \"null\", \"undefined\",\n    ],\n    \"testing\": [\n        \"test\", \"coverage\", \"assert\", \"mock\", \"spec\",\n        \"unit test\", \"missing test\",\n    ],\n    \"naming\": [\n        \"naming\", \"rename\", \"variable name\", \"unclear\",\n        \"confusing name\", \"readability\",\n    ],\n    \"types\": [\n        \"type\", \"typing\", \"annotation\", \"any type\",\n        \"type hint\", \"interface\",\n    ],\n    \"security\": [\n        \"security\", \"auth\", \"sanitize\", \"inject\",\n        \"xss\", \"csrf\", \"vulnerability\",\n    ],\n    \"performance\": [\n        \"performance\", \"slow\", \"optimize\", \"n+1\",\n        \"cache\", \"memory\", \"complexity\",\n    ],\n    \"documentation\": [\n        \"document\", \"comment\", \"docstring\", \"readme\",\n        \"jsdoc\", \"explain\",\n    ],\n    \"style\": [\n        \"style\", \"format\", \"indent\", \"lint\", \"spacing\",\n        \"consistent\",\n    ],\n}\n\n\ndef extract_review_signals(\n    prs: list[PRRecord],\n) -> list[ReviewSignal]:\n    \"\"\"Find recurring reviewer complaints.\"\"\"\n    # reviewer -> theme -> [pr_numbers]\n    hits: dict[str, dict[str, list[int]]] = {}\n\n    for pr in prs:\n        for review in pr.reviews:\n            if not review.body:\n                continue\n            reviewer = review.reviewer\n            if reviewer not in hits:\n                hits[reviewer] = {}\n            body_lower = review.body.lower()\n            for theme, keywords in REVIEW_THEMES.items():\n                if _matches_theme(body_lower, keywords):\n                    theme_hits = hits[reviewer].setdefault(\n                        theme, []\n                    )\n                    if pr.number not in theme_hits:\n                        theme_hits.append(pr.number)\n\n    signals: list[ReviewSignal] = []\n    for reviewer, themes in hits.items():\n        for theme, pr_nums in themes.items():\n            if len(pr_nums) >= 2:\n                signals.append(ReviewSignal(\n                    reviewer=reviewer,\n                    theme=theme,\n                    count=len(pr_nums),\n                    example_prs=pr_nums[:5],\n                ))\n\n    signals.sort(key=lambda s: s.count, reverse=True)\n    return signals\n\n\ndef extract_ci_signals(\n    prs: list[PRRecord],\n) -> list[CISignal]:\n    \"\"\"Find CI failure patterns.\"\"\"\n    # check_name -> {failures, total, files}\n    stats: dict[str, dict] = {}\n\n    for pr in prs:\n        for check in pr.checks:\n            if check.name not in stats:\n                stats[check.name] = {\n                    \"failures\": 0,\n                    \"total\": 0,\n                    \"files\": Counter(),\n                }\n            stats[check.name][\"total\"] += 1\n            if check.conclusion == \"failure\":\n                stats[check.name][\"failures\"] += 1\n                for f in pr.files:\n                    stats[check.name][\"files\"][f] += 1\n\n    signals: list[CISignal] = []\n    for name, data in stats.items():\n        if data[\"failures\"] == 0:\n            continue\n        # Top correlated files (appear in >50% of failures)\n        threshold = max(2, data[\"failures\"] // 2)\n        correlated = [\n            f for f, count in data[\"files\"].most_common(5)\n            if count >= threshold\n        ]\n        signals.append(CISignal(\n            check_name=name,\n            failure_count=data[\"failures\"],\n            total_runs=data[\"total\"],\n            correlated_files=correlated,\n        ))\n\n    signals.sort(\n        key=lambda s: s.failure_rate, reverse=True\n    )\n    return signals\n\n\ndef extract_velocity_signals(\n    prs: list[PRRecord],\n) -> VelocitySignal | None:\n    \"\"\"Compute PR velocity metrics.\"\"\"\n    merged = [p for p in prs if p.state == \"merged\"]\n    if not merged:\n        return None\n\n    merge_times = [\n        p.time_to_merge_hours\n        for p in merged\n        if p.time_to_merge_hours is not None\n    ]\n    if not merge_times:\n        return None\n\n    merge_times.sort()\n    avg_time = sum(merge_times) / len(merge_times)\n    median_idx = len(merge_times) // 2\n    median_time = merge_times[median_idx]\n\n    rounds = [p.review_rounds for p in merged]\n    avg_rounds = sum(rounds) / len(rounds) if rounds else 0\n\n    sizes = [p.total_lines for p in merged]\n    avg_size = sum(sizes) / len(sizes) if sizes else 0\n\n    return VelocitySignal(\n        avg_time_to_merge_hours=round(avg_time, 1),\n        median_time_to_merge_hours=round(median_time, 1),\n        avg_review_rounds=round(avg_rounds, 2),\n        avg_pr_size=round(avg_size, 1),\n        total_prs_analyzed=len(merged),\n    )\n\n\ndef _matches_theme(\n    text: str, keywords: list[str]\n) -> bool:\n    \"\"\"Check if text matches any keyword in theme.\"\"\"\n    return any(kw in text for kw in keywords)\n"
  },
  {
    "path": "maggy/maggy/process/store.py",
    "content": "\"\"\"SQLite persistence for process intelligence data.\n\nStores PR records, signals, and reports. Follows the WAL +\nbusy_timeout pattern from maggy/services/inbox.py.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport logging\nimport sqlite3\nfrom datetime import datetime, timezone\nfrom pathlib import Path\n\nfrom .models import ProcessReport\n\nlogger = logging.getLogger(__name__)\n\n\ndef _connect(path: Path) -> sqlite3.Connection:\n    \"\"\"Open SQLite with WAL mode for concurrency.\"\"\"\n    db = sqlite3.connect(path, timeout=30.0)\n    db.execute(\"PRAGMA journal_mode=WAL\")\n    db.execute(\"PRAGMA foreign_keys=ON\")\n    db.execute(\"PRAGMA busy_timeout=30000\")\n    return db\n\n\nclass ProcessStore:\n    \"\"\"SQLite store for process intelligence.\"\"\"\n\n    def __init__(self, db_path: Path):\n        self.db_path = db_path\n        self.db_path.parent.mkdir(parents=True, exist_ok=True)\n        self._init_tables()\n\n    def _init_tables(self) -> None:\n        with _connect(self.db_path) as db:\n            db.execute(\"\"\"\n                CREATE TABLE IF NOT EXISTS pr_data (\n                    id INTEGER PRIMARY KEY AUTOINCREMENT,\n                    project_key TEXT NOT NULL,\n                    fetched_at TEXT NOT NULL,\n                    payload TEXT NOT NULL\n                )\n            \"\"\")\n            db.execute(\"\"\"\n                CREATE TABLE IF NOT EXISTS reports (\n                    id INTEGER PRIMARY KEY AUTOINCREMENT,\n                    project_key TEXT NOT NULL,\n                    generated_at TEXT NOT NULL,\n                    payload TEXT NOT NULL\n                )\n            \"\"\")\n            db.execute(\n                \"CREATE INDEX IF NOT EXISTS idx_pr_project \"\n                \"ON pr_data(project_key)\"\n            )\n            db.execute(\n                \"CREATE INDEX IF NOT EXISTS idx_report_project \"\n                \"ON reports(project_key)\"\n            )\n\n    def save_pr_data(\n        self, project_key: str, data: list[dict]\n    ) -> None:\n        \"\"\"Store raw PR data as JSON.\"\"\"\n        now = datetime.now(timezone.utc).isoformat()\n        with _connect(self.db_path) as db:\n            db.execute(\n                \"DELETE FROM pr_data WHERE project_key = ?\",\n                (project_key,),\n            )\n            db.execute(\n                \"INSERT INTO pr_data \"\n                \"(project_key, fetched_at, payload) \"\n                \"VALUES (?, ?, ?)\",\n                (project_key, now, json.dumps(data)),\n            )\n\n    def load_pr_data(\n        self, project_key: str\n    ) -> list[dict] | None:\n        \"\"\"Load cached PR data. Returns None if none.\"\"\"\n        with _connect(self.db_path) as db:\n            row = db.execute(\n                \"SELECT payload FROM pr_data \"\n                \"WHERE project_key = ? \"\n                \"ORDER BY id DESC LIMIT 1\",\n                (project_key,),\n            ).fetchone()\n        if not row:\n            return None\n        return json.loads(row[0])\n\n    def save_report(self, report: ProcessReport) -> None:\n        \"\"\"Store a generated report.\"\"\"\n        payload = {\n            \"project_key\": report.project_key,\n            \"generated_at\": report.generated_at,\n            \"total_prs\": report.total_prs,\n            \"summary\": report.summary,\n            \"preemptive_fixes\": report.preemptive_fixes,\n            \"routing_recommendations\": (\n                report.routing_recommendations\n            ),\n        }\n        if report.velocity:\n            payload[\"velocity\"] = {\n                \"avg_time_to_merge_hours\": (\n                    report.velocity.avg_time_to_merge_hours\n                ),\n                \"median_time_to_merge_hours\": (\n                    report.velocity.median_time_to_merge_hours\n                ),\n                \"avg_review_rounds\": (\n                    report.velocity.avg_review_rounds\n                ),\n                \"avg_pr_size\": report.velocity.avg_pr_size,\n                \"total_prs_analyzed\": (\n                    report.velocity.total_prs_analyzed\n                ),\n            }\n        if report.review_signals:\n            payload[\"review_signals\"] = [\n                {\n                    \"reviewer\": s.reviewer,\n                    \"theme\": s.theme,\n                    \"count\": s.count,\n                }\n                for s in report.review_signals[:10]\n            ]\n        if report.ci_signals:\n            payload[\"ci_signals\"] = [\n                {\n                    \"check_name\": s.check_name,\n                    \"failure_rate\": round(s.failure_rate, 3),\n                    \"failure_count\": s.failure_count,\n                }\n                for s in report.ci_signals[:10]\n            ]\n\n        with _connect(self.db_path) as db:\n            db.execute(\n                \"INSERT INTO reports \"\n                \"(project_key, generated_at, payload) \"\n                \"VALUES (?, ?, ?)\",\n                (\n                    report.project_key,\n                    report.generated_at,\n                    json.dumps(payload),\n                ),\n            )\n\n    def load_latest_report(\n        self, project_key: str\n    ) -> dict | None:\n        \"\"\"Load the most recent report for a project.\"\"\"\n        with _connect(self.db_path) as db:\n            row = db.execute(\n                \"SELECT payload FROM reports \"\n                \"WHERE project_key = ? \"\n                \"ORDER BY id DESC LIMIT 1\",\n                (project_key,),\n            ).fetchone()\n        if not row:\n            return None\n        return json.loads(row[0])\n"
  },
  {
    "path": "maggy/maggy/providers/__init__.py",
    "content": "\"\"\"Issue tracker provider abstractions.\"\"\"\n\nfrom .asana import AsanaProvider\nfrom .base import Comment, IssueTrackerProvider, Task\nfrom .github_issues import GitHubIssuesProvider\n\n__all__ = [\n    \"AsanaProvider\",\n    \"Comment\",\n    \"GitHubIssuesProvider\",\n    \"IssueTrackerProvider\",\n    \"Task\",\n]\n\n\ndef build(cfg) -> IssueTrackerProvider:\n    \"\"\"Factory: build the right provider from MaggyConfig.\n\n    Currently supported: 'github', 'asana'.\n    'linear' is a documented stub — config.is_configured() refuses to accept\n    it, so we should never reach this function with that provider. If we do,\n    raise with a clear message pointing at the roadmap.\n    \"\"\"\n    if cfg.issue_tracker.provider == \"github\":\n        gh = cfg.issue_tracker.github\n        return GitHubIssuesProvider(org=gh.org, repos=gh.repos, token=gh.token, labels=gh.labels)\n    if cfg.issue_tracker.provider == \"asana\":\n        az = cfg.issue_tracker.asana\n        return AsanaProvider(workspace_id=az.workspace_id, boards=az.boards, token=az.token)\n    if cfg.issue_tracker.provider == \"linear\":\n        raise NotImplementedError(\n            \"Linear provider is a stub — not yet implemented. \"\n            \"Use 'github' or 'asana' for now.\"\n        )\n    raise ValueError(f\"Unknown issue tracker provider: {cfg.issue_tracker.provider!r}\")\n"
  },
  {
    "path": "maggy/maggy/providers/asana.py",
    "content": "\"\"\"Asana provider — compatibility shim for teams migrating from the zenloop prototype.\"\"\"\n\nfrom __future__ import annotations\n\nimport httpx\n\nfrom .base import Comment, Task\n\nASANA_BASE = \"https://app.asana.com/api/1.0\"\n\n\nclass AsanaProvider:\n    \"\"\"IssueTrackerProvider implementation for Asana.\n\n    Simpler than the zenloop prototype — no USER_GIDS hardcoded. `list_followed`\n    uses the authenticated user's GID via /users/me.\n    \"\"\"\n\n    def __init__(self, workspace_id: str, boards: dict[str, str], token: str):\n        self.workspace_id = workspace_id\n        # boards: {\"dev\": \"project_gid\", \"bugs\": \"other_gid\"}\n        self.boards = boards\n        self.token = token\n        self._my_gid: str = \"\"\n\n    def provider_name(self) -> str:\n        return \"asana\"\n\n    def _headers(self) -> dict[str, str]:\n        return {\"Authorization\": f\"Bearer {self.token}\"}\n\n    def _to_task(self, t: dict) -> Task:\n        assignee = (t.get(\"assignee\") or {}).get(\"name\", \"\")\n        projects = t.get(\"projects\") or []\n        board = projects[0].get(\"name\", \"\") if projects else \"\"\n        return Task(\n            id=t.get(\"gid\", \"\"),\n            title=t.get(\"name\", \"\"),\n            description=t.get(\"notes\", \"\") or \"\",\n            status=\"closed\" if t.get(\"completed\") else \"open\",\n            assignee=assignee,\n            url=t.get(\"permalink_url\", \"\"),\n            labels=[tag.get(\"name\", \"\") for tag in (t.get(\"tags\") or [])],\n            board=board,\n            created_at=t.get(\"created_at\", \"\"),\n            updated_at=t.get(\"modified_at\", \"\"),\n            raw=t,\n        )\n\n    async def _get_my_gid(self, client: httpx.AsyncClient) -> str:\n        if self._my_gid:\n            return self._my_gid\n        resp = await client.get(f\"{ASANA_BASE}/users/me\", headers=self._headers())\n        if resp.status_code == 200:\n            self._my_gid = resp.json().get(\"data\", {}).get(\"gid\", \"\")\n        return self._my_gid\n\n    async def list_tasks(self, board: str | None = None, state: str = \"open\", limit: int = 50) -> list[Task]:\n        if not self.boards:\n            return []\n\n        # Which boards to query\n        board_gids: list[str]\n        if board and board in self.boards:\n            board_gids = [self.boards[board]]\n        else:\n            board_gids = list(self.boards.values())\n\n        tasks: list[Task] = []\n        async with httpx.AsyncClient(timeout=15, headers=self._headers()) as client:\n            for gid in board_gids:\n                # `completed_since=now` tells Asana to exclude tasks completed\n                # before this instant (i.e. give us open + just-now-completed).\n                # Don't send it at all when we WANT completed tasks — empty\n                # string is rejected by Asana's validator.\n                params = {\n                    \"opt_fields\": \"name,notes,completed,assignee.name,projects.name,modified_at,created_at,permalink_url,tags.name\",\n                    \"limit\": str(min(limit, 100)),\n                }\n                if state == \"open\":\n                    params[\"completed_since\"] = \"now\"\n                resp = await client.get(f\"{ASANA_BASE}/projects/{gid}/tasks\", params=params)\n                if resp.status_code != 200:\n                    continue\n                for t in resp.json().get(\"data\", []):\n                    # completed_since gives everything after a timestamp — we\n                    # still need to filter to match the requested state.\n                    if state == \"open\" and t.get(\"completed\"):\n                        continue\n                    if state == \"closed\" and not t.get(\"completed\"):\n                        continue\n                    tasks.append(self._to_task(t))\n\n        tasks.sort(key=lambda t: t.updated_at, reverse=True)\n        return tasks[:limit]\n\n    async def get_task(self, task_id: str) -> Task | None:\n        async with httpx.AsyncClient(timeout=15, headers=self._headers()) as client:\n            resp = await client.get(\n                f\"{ASANA_BASE}/tasks/{task_id}\",\n                params={\"opt_fields\": \"name,notes,completed,assignee.name,projects.name,modified_at,created_at,permalink_url,tags.name\"},\n            )\n            if resp.status_code != 200:\n                return None\n            return self._to_task(resp.json().get(\"data\", {}))\n\n    async def get_comments(self, task_id: str) -> list[Comment]:\n        async with httpx.AsyncClient(timeout=15, headers=self._headers()) as client:\n            resp = await client.get(\n                f\"{ASANA_BASE}/tasks/{task_id}/stories\",\n                params={\"opt_fields\": \"type,text,created_at,created_by.name,resource_subtype\"},\n            )\n            if resp.status_code != 200:\n                return []\n            out: list[Comment] = []\n            for s in resp.json().get(\"data\", []):\n                if s.get(\"resource_subtype\") != \"comment_added\":\n                    continue\n                out.append(Comment(\n                    id=s.get(\"gid\", \"\"),\n                    author=(s.get(\"created_by\") or {}).get(\"name\", \"\"),\n                    text=s.get(\"text\", \"\"),\n                    created_at=s.get(\"created_at\", \"\"),\n                ))\n            return out\n\n    async def add_comment(self, task_id: str, text: str) -> Comment | None:\n        async with httpx.AsyncClient(timeout=15, headers=self._headers()) as client:\n            resp = await client.post(\n                f\"{ASANA_BASE}/tasks/{task_id}/stories\",\n                headers={**self._headers(), \"Content-Type\": \"application/json\"},\n                json={\"data\": {\"text\": text}},\n            )\n            if resp.status_code not in (200, 201):\n                return None\n            d = resp.json().get(\"data\", {})\n            return Comment(\n                id=d.get(\"gid\", \"\"),\n                author=(d.get(\"created_by\") or {}).get(\"name\", \"\"),\n                text=d.get(\"text\", text),\n                created_at=d.get(\"created_at\", \"\"),\n            )\n\n    async def update_status(self, task_id: str, status: str) -> bool:\n        completed = status.lower().strip() in (\"done\", \"closed\", \"complete\", \"completed\", \"resolved\")\n        async with httpx.AsyncClient(timeout=15, headers=self._headers()) as client:\n            resp = await client.put(\n                f\"{ASANA_BASE}/tasks/{task_id}\",\n                headers={**self._headers(), \"Content-Type\": \"application/json\"},\n                json={\"data\": {\"completed\": completed}},\n            )\n            return resp.status_code == 200\n\n    async def list_followed(self, user_id: str | None = None, limit: int = 50) -> list[Task]:\n        if not self.workspace_id:\n            return []\n        async with httpx.AsyncClient(timeout=15, headers=self._headers()) as client:\n            gid = user_id or await self._get_my_gid(client)\n            if not gid:\n                return []\n            resp = await client.get(\n                f\"{ASANA_BASE}/workspaces/{self.workspace_id}/tasks/search\",\n                params={\n                    \"followers.any\": gid,\n                    \"completed\": \"false\",\n                    \"sort_by\": \"modified_at\",\n                    \"opt_fields\": \"name,notes,assignee.name,projects.name,modified_at,permalink_url\",\n                    \"limit\": str(min(limit, 100)),\n                },\n            )\n            if resp.status_code != 200:\n                return []\n            return [self._to_task(t) for t in resp.json().get(\"data\", [])]\n\n    async def search_tasks(self, query: str, limit: int = 20) -> list[Task]:\n        if not self.workspace_id:\n            return []\n        async with httpx.AsyncClient(timeout=15, headers=self._headers()) as client:\n            resp = await client.get(\n                f\"{ASANA_BASE}/workspaces/{self.workspace_id}/tasks/search\",\n                params={\n                    \"text\": query,\n                    \"opt_fields\": \"name,notes,completed,assignee.name,projects.name,modified_at,permalink_url\",\n                    \"limit\": str(min(limit, 100)),\n                },\n            )\n            if resp.status_code != 200:\n                return []\n            return [self._to_task(t) for t in resp.json().get(\"data\", [])]\n"
  },
  {
    "path": "maggy/maggy/providers/base.py",
    "content": "\"\"\"IssueTrackerProvider Protocol — all trackers (GitHub, Asana, Linear) implement this.\n\nServices call provider.list_tasks() and work with Task/Comment dataclasses. They\ndon't care which tracker is underneath. Swap providers without touching services.\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom dataclasses import dataclass, field\nfrom typing import Protocol\n\n\n@dataclass\nclass Task:\n    \"\"\"Provider-agnostic task representation.\n\n    Fields that don't apply to a given provider are left empty — never None for strings\n    so downstream formatters don't need null checks.\n    \"\"\"\n    id: str                        # Provider-native ID (\"123\" for GH, \"1213...\" for Asana)\n    title: str\n    description: str = \"\"          # Full body/notes\n    status: str = \"\"               # \"open\", \"closed\", \"in progress\", etc.\n    assignee: str = \"\"             # Display name\n    author: str = \"\"               # Who created it\n    url: str = \"\"                  # Permalink\n    labels: list[str] = field(default_factory=list)\n    board: str = \"\"                # Project/repo name\n    created_at: str = \"\"           # ISO 8601\n    updated_at: str = \"\"           # ISO 8601\n    raw: dict = field(default_factory=dict)  # Original provider payload for escape hatches\n\n\n@dataclass\nclass Comment:\n    id: str\n    author: str\n    text: str\n    created_at: str = \"\"\n\n\nclass IssueTrackerProvider(Protocol):\n    \"\"\"Common interface across GitHub Issues, Asana, Linear, etc.\"\"\"\n\n    async def list_tasks(self, board: str | None = None, state: str = \"open\", limit: int = 50) -> list[Task]:\n        \"\"\"List tasks. `board` filters to a specific project/repo if provider supports it.\"\"\"\n        ...\n\n    async def get_task(self, task_id: str) -> Task | None:\n        ...\n\n    async def get_comments(self, task_id: str) -> list[Comment]:\n        ...\n\n    async def add_comment(self, task_id: str, text: str) -> Comment | None:\n        ...\n\n    async def update_status(self, task_id: str, status: str) -> bool:\n        \"\"\"Update status. For providers that use labels (GitHub), this maps intelligently.\"\"\"\n        ...\n\n    async def list_followed(self, user_id: str | None = None, limit: int = 50) -> list[Task]:\n        \"\"\"Tasks the user is watching/following/assigned to — powers the 'Latest' tab.\"\"\"\n        ...\n\n    async def search_tasks(self, query: str, limit: int = 20) -> list[Task]:\n        ...\n\n    def provider_name(self) -> str:\n        \"\"\"Return 'github' | 'asana' | 'linear' — for UI display.\"\"\"\n        ...\n"
  },
  {
    "path": "maggy/maggy/providers/github_issues.py",
    "content": "\"\"\"GitHub Issues provider — talks to GitHub REST API across multiple repos.\"\"\"\n\nfrom __future__ import annotations\n\nimport logging\n\nimport httpx\n\nfrom .base import Comment, Task\n\nlogger = logging.getLogger(__name__)\n\nGITHUB_API = \"https://api.github.com\"\n\n\nclass GitHubIssuesProvider:\n    \"\"\"IssueTrackerProvider implementation for GitHub Issues.\n\n    Handles multiple repos transparently — list_tasks() aggregates across all\n    configured repos. Task IDs are encoded as \"repo/number\" (e.g. \"api/123\") so\n    we can round-trip back to the right repo.\n    \"\"\"\n\n    def __init__(self, org: str, repos: list[str], token: str, labels: list[str] | None = None):\n        self.org = org\n        self.repos = repos  # Full names: [\"org/api\", \"org/web\"]\n        self.token = token\n        self.label_filter = labels or []\n\n    def provider_name(self) -> str:\n        return \"github\"\n\n    def _headers(self) -> dict[str, str]:\n        return {\n            \"Authorization\": f\"Bearer {self.token}\",\n            \"Accept\": \"application/vnd.github+json\",\n            \"X-GitHub-Api-Version\": \"2022-11-28\",\n        }\n\n    def _encode_id(self, repo: str, number: int) -> str:\n        # Store repo slug (without org prefix for compactness) + issue number\n        slug = repo.split(\"/\")[-1]\n        return f\"{slug}/{number}\"\n\n    def _decode_id(self, task_id: str) -> tuple[str, int] | None:\n        \"\"\"Parse 'slug/number' IDs. Returns None for malformed input.\n\n        Returning None (instead of raising) lets the caller translate to a\n        404/None response instead of a 500 to the client.\n        \"\"\"\n        if not task_id or \"/\" not in task_id:\n            return None\n        slug, _, num_str = task_id.partition(\"/\")\n        if not num_str.isdigit():\n            return None\n        number = int(num_str)\n        for repo in self.repos:\n            if repo.endswith(\"/\" + slug):\n                return repo, number\n        # Fallback: assume org prefix (for repos not in the configured list)\n        if self.org:\n            return f\"{self.org}/{slug}\", number\n        return None\n\n    def _to_task(self, repo: str, issue: dict) -> Task:\n        return Task(\n            id=self._encode_id(repo, issue[\"number\"]),\n            title=issue.get(\"title\", \"\"),\n            description=issue.get(\"body\") or \"\",\n            status=issue.get(\"state\", \"open\"),\n            assignee=((issue.get(\"assignee\") or {}) or {}).get(\"login\", \"\"),\n            author=((issue.get(\"user\") or {}) or {}).get(\"login\", \"\"),\n            url=issue.get(\"html_url\", \"\"),\n            labels=[lbl[\"name\"] for lbl in issue.get(\"labels\", []) if isinstance(lbl, dict)],\n            board=repo.split(\"/\")[-1],\n            created_at=issue.get(\"created_at\", \"\"),\n            updated_at=issue.get(\"updated_at\", \"\"),\n            raw=issue,\n        )\n\n    async def list_tasks(self, board: str | None = None, state: str = \"open\", limit: int = 50) -> list[Task]:\n        \"\"\"List issues across repos (or one repo if `board` given). Excludes PRs.\"\"\"\n        repos = [r for r in self.repos if not board or r.endswith(\"/\" + board)]\n        if not repos:\n            return []\n\n        per_repo = max(1, limit // max(len(repos), 1))\n        tasks: list[Task] = []\n\n        async with httpx.AsyncClient(timeout=15, headers=self._headers()) as client:\n            for repo in repos:\n                params: dict[str, str] = {\"state\": state, \"per_page\": str(per_repo), \"sort\": \"updated\"}\n                if self.label_filter:\n                    params[\"labels\"] = \",\".join(self.label_filter)\n                resp = await client.get(f\"{GITHUB_API}/repos/{repo}/issues\", params=params)\n                if resp.status_code != 200:\n                    # Log at WARNING so misconfiguration (bad token, repo renamed,\n                    # missing read scope) is visible instead of silently returning\n                    # an empty inbox. Include the status code + first 200 chars\n                    # of the response body to make diagnostics easy.\n                    body_excerpt = (resp.text or \"\")[:200].replace(\"\\n\", \" \")\n                    logger.warning(\n                        \"GitHub /repos/%s/issues returned %s: %s\",\n                        repo, resp.status_code, body_excerpt,\n                    )\n                    continue\n                for issue in resp.json():\n                    # GitHub returns PRs in /issues — filter them out\n                    if \"pull_request\" in issue:\n                        continue\n                    tasks.append(self._to_task(repo, issue))\n\n        tasks.sort(key=lambda t: t.updated_at, reverse=True)\n        return tasks[:limit]\n\n    async def get_task(self, task_id: str) -> Task | None:\n        decoded = self._decode_id(task_id)\n        if decoded is None:\n            return None\n        repo, number = decoded\n        async with httpx.AsyncClient(timeout=15, headers=self._headers()) as client:\n            resp = await client.get(f\"{GITHUB_API}/repos/{repo}/issues/{number}\")\n            if resp.status_code != 200:\n                return None\n            return self._to_task(repo, resp.json())\n\n    async def get_comments(self, task_id: str) -> list[Comment]:\n        decoded = self._decode_id(task_id)\n        if decoded is None:\n            return []\n        repo, number = decoded\n        async with httpx.AsyncClient(timeout=15, headers=self._headers()) as client:\n            resp = await client.get(f\"{GITHUB_API}/repos/{repo}/issues/{number}/comments\")\n            if resp.status_code != 200:\n                return []\n            return [\n                Comment(\n                    id=str(c[\"id\"]),\n                    author=((c.get(\"user\") or {}) or {}).get(\"login\", \"\"),\n                    text=c.get(\"body\", \"\"),\n                    created_at=c.get(\"created_at\", \"\"),\n                )\n                for c in resp.json()\n            ]\n\n    async def add_comment(self, task_id: str, text: str) -> Comment | None:\n        decoded = self._decode_id(task_id)\n        if decoded is None:\n            return None\n        repo, number = decoded\n        async with httpx.AsyncClient(timeout=15, headers=self._headers()) as client:\n            resp = await client.post(\n                f\"{GITHUB_API}/repos/{repo}/issues/{number}/comments\",\n                json={\"body\": text},\n            )\n            if resp.status_code not in (200, 201):\n                return None\n            c = resp.json()\n            return Comment(\n                id=str(c[\"id\"]),\n                author=((c.get(\"user\") or {}) or {}).get(\"login\", \"\"),\n                text=c.get(\"body\", \"\"),\n                created_at=c.get(\"created_at\", \"\"),\n            )\n\n    async def update_status(self, task_id: str, status: str) -> bool:\n        \"\"\"GitHub issues only have open/closed — map any \"done-like\" status to closed.\"\"\"\n        decoded = self._decode_id(task_id)\n        if decoded is None:\n            return False\n        repo, number = decoded\n        normalized = status.lower().strip()\n        new_state = \"closed\" if normalized in (\"done\", \"closed\", \"complete\", \"completed\", \"resolved\") else \"open\"\n        async with httpx.AsyncClient(timeout=15, headers=self._headers()) as client:\n            resp = await client.patch(\n                f\"{GITHUB_API}/repos/{repo}/issues/{number}\",\n                json={\"state\": new_state},\n            )\n            return resp.status_code == 200\n\n    async def list_followed(self, user_id: str | None = None, limit: int = 50) -> list[Task]:\n        \"\"\"Issues assigned to or mentioning the authenticated user across configured repos.\n\n        Refuses to run without repos — otherwise the GitHub search query would\n        have no repo filter and hit every public issue on the site.\n        \"\"\"\n        if not self.repos:\n            return []\n        async with httpx.AsyncClient(timeout=15, headers=self._headers()) as client:\n            # Figure out the user if not provided\n            if not user_id:\n                me = await client.get(f\"{GITHUB_API}/user\")\n                if me.status_code == 200:\n                    user_id = me.json().get(\"login\", \"\")\n                else:\n                    return []\n\n            # Use search API: is:open + assignee/mentions + repo filter\n            repo_qual = \" \".join(f\"repo:{r}\" for r in self.repos)\n            query = f\"is:issue is:open ({repo_qual}) (assignee:{user_id} OR mentions:{user_id})\"\n            resp = await client.get(\n                f\"{GITHUB_API}/search/issues\",\n                params={\"q\": query, \"sort\": \"updated\", \"per_page\": str(limit)},\n            )\n            if resp.status_code != 200:\n                return []\n\n            tasks: list[Task] = []\n            for issue in resp.json().get(\"items\", []):\n                if \"pull_request\" in issue:\n                    continue\n                # Derive repo from URL\n                repo_url = issue.get(\"repository_url\", \"\")\n                repo = \"/\".join(repo_url.rstrip(\"/\").split(\"/\")[-2:])\n                tasks.append(self._to_task(repo, issue))\n            return tasks\n\n    async def search_tasks(self, query: str, limit: int = 20) -> list[Task]:\n        # Same guard as list_followed — without repos, the query would search\n        # all public GitHub issues, which is never what we want.\n        if not self.repos:\n            return []\n        async with httpx.AsyncClient(timeout=15, headers=self._headers()) as client:\n            repo_qual = \" \".join(f\"repo:{r}\" for r in self.repos)\n            q = f\"is:issue {query} {repo_qual}\"\n            resp = await client.get(\n                f\"{GITHUB_API}/search/issues\",\n                params={\"q\": q, \"per_page\": str(limit)},\n            )\n            if resp.status_code != 200:\n                return []\n            tasks: list[Task] = []\n            for issue in resp.json().get(\"items\", []):\n                if \"pull_request\" in issue:\n                    continue\n                repo_url = issue.get(\"repository_url\", \"\")\n                repo = \"/\".join(repo_url.rstrip(\"/\").split(\"/\")[-2:])\n                tasks.append(self._to_task(repo, issue))\n            return tasks\n"
  },
  {
    "path": "maggy/maggy/providers/monday.py",
    "content": "\"\"\"Monday.com provider — IssueTrackerProvider implementation.\"\"\"\n\nfrom __future__ import annotations\n\nimport httpx\n\nfrom .base import Comment, Task\n\nMONDAY_API = \"https://api.monday.com/v2\"\n\n\nclass MondayProvider:\n    \"\"\"IssueTrackerProvider for Monday.com boards.\"\"\"\n\n    def __init__(self, api_token: str, board_id: str):\n        self.api_token = api_token\n        self.board_id = board_id\n\n    def provider_name(self) -> str:\n        return \"monday\"\n\n    def _headers(self) -> dict[str, str]:\n        return {\n            \"Authorization\": self.api_token,\n            \"Content-Type\": \"application/json\",\n        }\n\n    def _to_task(self, item: dict) -> Task:\n        cols = item.get(\"column_values\", [])\n        status = _col_value(cols, \"status\")\n        assignee = _col_value(cols, \"person\")\n        return Task(\n            id=item.get(\"id\", \"\"),\n            title=item.get(\"name\", \"\"),\n            description=\"\",\n            status=status,\n            assignee=assignee,\n            url=item.get(\"url\", \"\"),\n            created_at=item.get(\"created_at\", \"\"),\n            updated_at=item.get(\"updated_at\", \"\"),\n            raw=item,\n        )\n\n    async def _query(self, q: str) -> dict:\n        async with httpx.AsyncClient(\n            timeout=15, headers=self._headers(),\n        ) as client:\n            resp = await client.post(\n                MONDAY_API, json={\"query\": q},\n            )\n            if resp.status_code != 200:\n                return {}\n            return resp.json().get(\"data\", {})\n\n    async def list_tasks(self, board=None, state=\"open\", limit=50) -> list[Task]:\n        bid = board or self.board_id\n        q = _items_query(bid, limit)\n        data = await self._query(q)\n        boards = data.get(\"boards\", [])\n        if not boards:\n            return []\n        items = boards[0].get(\"items_page\", {}).get(\"items\", [])\n        return [self._to_task(i) for i in items]\n\n    async def get_task(self, task_id: str) -> Task | None:\n        q = f'{{ items(ids: [{task_id}]) {{ id name column_values {{ id text }} url created_at updated_at }} }}'\n        data = await self._query(q)\n        items = data.get(\"items\", [])\n        if not items:\n            return None\n        return self._to_task(items[0])\n\n    async def get_comments(self, task_id: str) -> list[Comment]:\n        q = f'{{ items(ids: [{task_id}]) {{ updates {{ id body created_at creator {{ name }} }} }} }}'\n        data = await self._query(q)\n        items = data.get(\"items\", [])\n        if not items:\n            return []\n        updates = items[0].get(\"updates\", [])\n        return [\n            Comment(\n                id=u.get(\"id\", \"\"),\n                author=(u.get(\"creator\") or {}).get(\"name\", \"\"),\n                text=u.get(\"body\", \"\"),\n                created_at=u.get(\"created_at\", \"\"),\n            )\n            for u in updates\n        ]\n\n    async def add_comment(self, task_id: str, text: str) -> Comment | None:\n        escaped = text.replace('\"', '\\\\\"')\n        q = f'mutation {{ create_update(item_id: {task_id}, body: \"{escaped}\") {{ id body }} }}'\n        data = await self._query(q)\n        update = data.get(\"create_update\", {})\n        if not update:\n            return None\n        return Comment(\n            id=update.get(\"id\", \"\"),\n            author=\"\", text=update.get(\"body\", text),\n        )\n\n    async def update_status(self, task_id: str, status: str) -> bool:\n        return False  # Requires board-specific column ID\n\n    async def list_followed(self, user_id=None, limit=50) -> list[Task]:\n        return await self.list_tasks(limit=limit)\n\n    async def search_tasks(self, query: str, limit=20) -> list[Task]:\n        return await self.list_tasks(limit=limit)\n\n\ndef _col_value(cols: list[dict], col_id: str) -> str:\n    for c in cols:\n        if c.get(\"id\") == col_id:\n            return c.get(\"text\", \"\")\n    return \"\"\n\n\ndef _items_query(board_id: str, limit: int) -> str:\n    return (\n        f'{{ boards(ids: [{board_id}]) {{ items_page(limit: {limit}) '\n        f'{{ items {{ id name column_values {{ id text }} url created_at updated_at }} }} }} }}'\n    )\n"
  },
  {
    "path": "maggy/maggy/recovery/__init__.py",
    "content": "\n"
  },
  {
    "path": "maggy/maggy/recovery/rollback.py",
    "content": "\"\"\"Git-backed rollback savepoints for Maggy sessions.\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport re\n\n_SAFE_ID = re.compile(r\"^[a-zA-Z0-9_\\-]+$\")\n\n\ndef _validate_session_id(session_id: str) -> None:\n    if not _SAFE_ID.match(session_id):\n        raise ValueError(f\"Invalid session_id: {session_id!r}\")\n\n\nclass RollbackManager:\n    async def create_savepoint(self, session_id: str, working_dir: str) -> str:\n        _validate_session_id(session_id)\n        tag = _tag_name(session_id)\n        code, output = await _run_git(working_dir, \"tag\", tag)\n        if code != 0:\n            raise RuntimeError(output or f\"failed to create {tag}\")\n        return tag\n\n    async def rollback(self, session_id: str, working_dir: str) -> bool:\n        _validate_session_id(session_id)\n        code, _ = await _run_git(working_dir, \"reset\", \"--hard\", _tag_name(session_id))\n        return code == 0\n\n    async def list_savepoints(self, working_dir: str) -> list[str]:\n        code, output = await _run_git(working_dir, \"tag\", \"--list\", \"maggy-save-*\")\n        if code != 0 or not output:\n            return []\n        return output.splitlines()\n\n    async def delete_savepoint(self, session_id: str, working_dir: str) -> bool:\n        code, _ = await _run_git(working_dir, \"tag\", \"-d\", _tag_name(session_id))\n        return code == 0\n\n\nasync def _run_git(working_dir: str, *args: str) -> tuple[int, str]:\n    proc = await asyncio.create_subprocess_exec(\n        \"git\",\n        *args,\n        cwd=working_dir,\n        stdout=asyncio.subprocess.PIPE,\n        stderr=asyncio.subprocess.STDOUT,\n    )\n    stdout, _ = await proc.communicate()\n    text = (stdout or b\"\").decode(\"utf-8\", errors=\"replace\").strip()\n    return proc.returncode or 0, text\n\n\ndef _tag_name(session_id: str) -> str:\n    return f\"maggy-save-{session_id}\"\n"
  },
  {
    "path": "maggy/maggy/registry.py",
    "content": "\"\"\"Project registry backed by Maggy config.\"\"\"\n\nfrom __future__ import annotations\n\nfrom maggy.config import MaggyConfig, ProjectConfig\n\n\nclass ProjectRegistry:\n    \"\"\"Manage configured projects in memory.\"\"\"\n\n    def __init__(self, cfg: MaggyConfig):\n        self._projects = {project.name: project for project in cfg.projects}\n\n    def list(self) -> list[ProjectConfig]:\n        return list(self._projects.values())\n\n    def get(self, name: str) -> ProjectConfig | None:\n        return self._projects.get(name)\n\n    def add(self, project: ProjectConfig) -> None:\n        if project.name in self._projects:\n            raise ValueError(f\"Project {project.name!r} already exists\")\n        self._projects[project.name] = project\n\n    def remove(self, name: str) -> bool:\n        return self._projects.pop(name, None) is not None\n"
  },
  {
    "path": "maggy/maggy/routing.py",
    "content": "\"\"\"Blast-to-model routing with iCPG integration and reward learning.\n\nRoutes tasks to the optimal model based on complexity score.\nHigh-blast tasks go to premium models, low-blast to cheap ones.\nLearns from reward scores over time.\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom dataclasses import dataclass\nfrom pathlib import Path\n\nfrom maggy.calibration.tracker import CalibrationTracker\nfrom maggy.config import MaggyConfig\nfrom maggy.process.model_router import (\n    DEFAULT_TIERS,\n    RoutingDecision,\n    route_task,\n)\nfrom maggy.routing_rules import apply_override\nfrom maggy.routing_rules_io import load as load_rules\nfrom maggy.routing_rules import record_outcome as rules_record\nfrom maggy.scores import RewardTable\n\nMIN_CALIBRATION_ACCURACY = 0.5\n\n\n@dataclass\nclass RoutingContext:\n    \"\"\"Input context for a routing decision.\"\"\"\n\n    blast_score: int = 0\n    task_type: str = \"general\"\n    security_sensitive: bool = False\n    project_key: str = \"\"\n    pipeline_phase: str = \"\"\n    stakes: str = \"low\"\n\n\nclass RoutingService:\n    \"\"\"Blast-score aware routing with rule overrides.\"\"\"\n\n    def __init__(self, cfg: MaggyConfig):\n        self.cfg = cfg\n        self.rewards = RewardTable(cfg)\n        db_dir = Path(cfg.storage.path).expanduser().parent\n        self.calibration = CalibrationTracker(\n            db_dir / \"calibration.db\",\n        )\n        self.rules = load_rules()\n\n    def route(self, ctx: RoutingContext) -> RoutingDecision:\n        \"\"\"Pick the best model for this task context.\"\"\"\n        forced = apply_override(\n            self.rules, ctx.task_type, ctx.pipeline_phase,\n        )\n        if forced:\n            return self._forced_decision(forced, ctx)\n\n        override = self.rewards.best_model(\n            ctx.task_type, self._blast_tier(ctx.blast_score),\n        )\n        if override and self._is_calibrated(override):\n            return RoutingDecision(\n                primary=override,\n                validator=None,\n                fallback_chain=[],\n                reason=(\n                    f\"Learned: best for {ctx.task_type} \"\n                    f\"at blast {ctx.blast_score}\"\n                ),\n            )\n\n        decision = route_task(\n            ctx.blast_score,\n            ctx.task_type,\n            ctx.security_sensitive,\n            stakes=ctx.stakes,\n        )\n        return self._penalize_uncalibrated(decision)\n\n    def record_outcome(\n        self,\n        model: str,\n        task_type: str,\n        blast_score: int,\n        reward: float,\n    ) -> None:\n        \"\"\"Record task outcome for learning.\"\"\"\n        tier = self._blast_tier(blast_score)\n        self.rewards.record(model, task_type, tier, reward)\n        self.calibration.record(model, task_type, reward, reward)\n        success = reward > 0.0\n        rules_record(self.rules, model, task_type, success)\n\n    def reload_rules(self) -> None:\n        \"\"\"Reload rules from disk (after Maggy self-update).\"\"\"\n        self.rules = load_rules()\n\n    def get_heatmap(self) -> list[dict]:\n        \"\"\"Return reward heatmap data for dashboard.\"\"\"\n        return self.rewards.heatmap()\n\n    def _blast_tier(self, score: int) -> str:\n        if score <= 3:\n            return \"low\"\n        if score <= 6:\n            return \"medium\"\n        return \"high\"\n\n    def _is_calibrated(self, model: str) -> bool:\n        acc = self.calibration.accuracy(model)\n        return acc == 0.0 or acc >= MIN_CALIBRATION_ACCURACY\n\n    def _forced_decision(\n        self, model_name: str, ctx: RoutingContext,\n    ) -> RoutingDecision:\n        \"\"\"Build decision from a rules override.\"\"\"\n        tier = _find_tier(model_name)\n        if tier is None:\n            return route_task(\n                ctx.blast_score,\n                ctx.task_type,\n                ctx.security_sensitive,\n                stakes=ctx.stakes,\n            )\n        validator = None\n        if ctx.blast_score >= 8 or ctx.security_sensitive or ctx.stakes == \"high\":\n            validator = _find_tier(\"codex\")\n        return RoutingDecision(\n            primary=tier,\n            validator=validator,\n            fallback_chain=[],\n            reason=f\"Rule override: {ctx.task_type}\"\n                   f\"{f'/{ctx.pipeline_phase}' if ctx.pipeline_phase else ''}\"\n                   f\" → {model_name}\",\n        )\n\n    def _penalize_uncalibrated(\n        self, decision: RoutingDecision,\n    ) -> RoutingDecision:\n        if not self._is_calibrated(decision.primary.name):\n            chain = decision.fallback_chain\n            if chain:\n                return RoutingDecision(\n                    primary=chain[0],\n                    validator=decision.validator,\n                    fallback_chain=chain[1:],\n                    reason=\"Calibration penalty\",\n                )\n        return decision\n\n\ndef _find_tier(name: str):\n    \"\"\"Look up a ModelTier by name from defaults.\"\"\"\n    for t in DEFAULT_TIERS:\n        if t.name == name:\n            return t\n    return None\n"
  },
  {
    "path": "maggy/maggy/routing_rules.py",
    "content": "\"\"\"Routing rules — task-type, pipeline-phase, stakes, cascade config.\n\nLoaded from ~/.maggy/routing-rules.yaml. Maggy can self-update\nthis file when benchmark or outcome data provides evidence for\nbetter routing decisions. Manual edits are preserved.\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom dataclasses import dataclass, field\nfrom datetime import datetime, timezone\nfrom pathlib import Path\n\nMIN_CONFIDENCE = 0.6\n\n\n@dataclass\nclass ModelOverride:\n    \"\"\"Force a specific model for a task type or phase.\"\"\"\n\n    model: str\n    reason: str = \"\"\n    confidence: float = 1.0\n    source: str = \"rule\"\n\n\n@dataclass\nclass PerformanceRecord:\n    \"\"\"Tracked model performance from outcomes.\"\"\"\n\n    strengths: list[str] = field(default_factory=list)\n    weaknesses: list[str] = field(default_factory=list)\n    tasks_completed: int = 0\n    success_rate: float = 0.0\n\n\n@dataclass\nclass Convention:\n    \"\"\"A team convention injected into prompts.\"\"\"\n\n    text: str\n    applies_to: list[str] = field(default_factory=list)\n    source: str = \"manual\"\n\n\n@dataclass\nclass StakesLevel:\n    \"\"\"Patterns for a single stakes level.\"\"\"\n\n    file_patterns: list[str] = field(default_factory=list)\n    task_types: list[str] = field(default_factory=list)\n    keywords: list[str] = field(default_factory=list)\n\n\n@dataclass\nclass StakesPatterns:\n    \"\"\"Stakes classification config — high/medium/low.\"\"\"\n\n    high: StakesLevel = field(default_factory=StakesLevel)\n    medium: StakesLevel = field(default_factory=StakesLevel)\n    low: StakesLevel = field(default_factory=StakesLevel)\n\n\n@dataclass\nclass CascadePolicy:\n    \"\"\"Cascade execution policy.\"\"\"\n\n    enabled: bool = True\n    min_blast: int = 5\n    min_stakes: str = \"medium\"\n    max_attempts: int = 3\n    quality_threshold: int = 3\n\n\n@dataclass\nclass RoutingRules:\n    \"\"\"All routing rules Maggy uses for orchestration.\"\"\"\n\n    version: int = 1\n    updated_at: str = \"\"\n    task_type_overrides: dict[str, ModelOverride] = field(\n        default_factory=dict,\n    )\n    pipeline_phases: dict[str, ModelOverride] = field(\n        default_factory=dict,\n    )\n    model_performance: dict[str, PerformanceRecord] = field(\n        default_factory=dict,\n    )\n    conventions: list[Convention] = field(default_factory=list)\n    project_conventions: dict[str, list[Convention]] = field(\n        default_factory=dict,\n    )\n    stakes: StakesPatterns = field(default_factory=StakesPatterns)\n    cascade: CascadePolicy = field(default_factory=CascadePolicy)\n\n\ndef _now_iso() -> str:\n    return datetime.now(timezone.utc).isoformat()\n\n\ndef apply_override(\n    rules: RoutingRules, task_type: str,\n    phase: str | None = None,\n) -> str | None:\n    \"\"\"Return model name if rules override routing.\"\"\"\n    if phase and phase in rules.pipeline_phases:\n        override = rules.pipeline_phases[phase]\n        if override.model != \"auto\" and _trusted(override):\n            return override.model\n    if task_type in rules.task_type_overrides:\n        override = rules.task_type_overrides[task_type]\n        if _trusted(override):\n            return override.model\n    return None\n\n\ndef record_outcome(\n    rules: RoutingRules, model: str,\n    task_type: str, success: bool,\n    path: Path | None = None,\n) -> None:\n    \"\"\"Update performance data from a task outcome.\"\"\"\n    from maggy.routing_rules_io import save\n\n    perf = rules.model_performance.get(model)\n    if perf is None:\n        perf = PerformanceRecord()\n        rules.model_performance[model] = perf\n    _update_perf(perf, task_type, success)\n    rules.updated_at = _now_iso()\n    save(rules, path)\n\n\ndef learn_override(\n    rules: RoutingRules, task_type: str,\n    model: str, reason: str,\n    confidence: float = 0.7,\n    path: Path | None = None,\n) -> None:\n    \"\"\"Maggy learns a new routing override from data.\"\"\"\n    from maggy.routing_rules_io import save\n\n    rules.task_type_overrides[task_type] = ModelOverride(\n        model=model, reason=reason,\n        confidence=confidence, source=\"learned\",\n    )\n    rules.updated_at = _now_iso()\n    save(rules, path)\n\n\ndef conventions_for(\n    rules: RoutingRules, task_type: str,\n    project_key: str | None = None,\n) -> str:\n    \"\"\"Return conventions text relevant to a task type.\"\"\"\n    all_convs = list(rules.conventions)\n    if project_key and project_key in rules.project_conventions:\n        all_convs.extend(rules.project_conventions[project_key])\n    lines = [\n        f\"- {c.text}\" for c in all_convs\n        if \"all\" in c.applies_to or task_type in c.applies_to\n    ]\n    if not lines:\n        return \"\"\n    return \"## Team Conventions\\n\" + \"\\n\".join(lines)\n\n\ndef _trusted(override: ModelOverride) -> bool:\n    return override.confidence >= MIN_CONFIDENCE\n\n\ndef _update_perf(\n    perf: PerformanceRecord, task_type: str, success: bool,\n) -> None:\n    total = perf.tasks_completed\n    rate = perf.success_rate\n    new_total = total + 1\n    perf.tasks_completed = new_total\n    perf.success_rate = round(\n        (rate * total + (1.0 if success else 0.0)) / new_total, 3,\n    )\n    if success and task_type not in perf.strengths:\n        perf.strengths.append(task_type)\n    if not success and task_type not in perf.weaknesses:\n        perf.weaknesses.append(task_type)\n"
  },
  {
    "path": "maggy/maggy/routing_rules_defaults.py",
    "content": "\"\"\"Default routing rules — seed data for first-run initialization.\"\"\"\n\nfrom __future__ import annotations\n\nfrom maggy.routing_rules import (\n    CascadePolicy,\n    Convention,\n    ModelOverride,\n    PerformanceRecord,\n    RoutingRules,\n    StakesLevel,\n    StakesPatterns,\n    _now_iso,\n)\n\n_CONV_DATA = [\n    (\"mWP: Ship minimum wowable product, not MVP. \"\n     \"Target 5-7 on the 11-star scale.\", [\"all\"]),\n    (\"TDD: RED (failing tests) -> GREEN (minimal code) \"\n     \"-> VALIDATE (lint, types, coverage >= 80%).\",\n     [\"feature\", \"bug\", \"refactor\"]),\n    (\"No secrets in code. Parameterized SQL only. \"\n     \"Validate at API boundaries.\", [\"all\"]),\n    (\"Quality gates: max 20 lines/function, 3 params, \"\n     \"2 nesting levels, 200 lines/file.\", [\"all\"]),\n    (\"Use existing patterns. Read codebase before \"\n     \"changing. Keep changes minimal.\", [\"all\"]),\n]\n\n_OVERRIDES = {\n    \"docs\": (\"claude\", \"Not prose-optimized\", 0.9, \"benchmark\"),\n    \"security\": (\"claude\", \"Deep reasoning needed\", 1.0, \"rule\"),\n    \"architecture\": (\"claude\", \"Cross-context awareness\", 0.8, \"rule\"),\n    \"tests\": (\"claude\", \"Test generation\", 0.9, \"benchmark\"),\n    \"planning\": (\"claude\", \"Structured reasoning\", 0.8, \"rule\"),\n}\n\n_PHASES = {\n    \"spec\": (\"claude\", \"Comprehensive docs\", 1.0, \"rule\"),\n    \"tdd_red\": (\"claude\", \"Test design expertise\", 0.9, \"rule\"),\n    \"tdd_green\": (\"auto\", \"Blast-score routing\", 1.0, \"rule\"),\n    \"review\": (\"claude\", \"Security+arch depth\", 1.0, \"rule\"),\n}\n\n_PERF = {\n    \"claude\": ([\"security\", \"tests\", \"docs\", \"architecture\"], [\"cost\"], 6, 1.0),\n    \"codex\": ([\"code_generation\", \"api_design\", \"bug\", \"feature\"], [\"docs\"], 5, 1.0),\n    \"kimi\": ([\"schema\", \"simple_tasks\", \"docs\"], [\"complex_reasoning\"], 1, 1.0),\n    \"local\": ([\"code_formatting\", \"simple_edits\", \"feature\"], [\"docs\", \"prose\"], 1, 1.0),\n}\n\n\ndef default_conventions() -> list[Convention]:\n    \"\"\"Team conventions from claude-bootstrap skills.\"\"\"\n    return [Convention(t, a, \"claude-bootstrap\") for t, a in _CONV_DATA]\n\n\ndef default_stakes() -> StakesPatterns:\n    return StakesPatterns(\n        high=StakesLevel(\n            [\"auth\", \"billing\", \"payment\", \"migration\",\n             \"security\", \"deploy\", \"infra\", \".env\"],\n            [\"security\", \"auth\", \"billing\", \"migration\"],\n            [\"production\", \"customer data\", \"breaking change\"],\n        ),\n        medium=StakesLevel(\n            [\"api\", \"routes\", \"models\", \"schema\", \"database\"],\n            [\"feature\", \"refactor\"],\n        ),\n        low=StakesLevel([], [\"docs\", \"formatting\", \"tests\"]),\n    )\n\n\ndef default_rules() -> RoutingRules:\n    \"\"\"Seed rules from benchmark evidence + team conventions.\"\"\"\n    return RoutingRules(\n        version=1, updated_at=_now_iso(),\n        conventions=default_conventions(),\n        stakes=default_stakes(),\n        cascade=CascadePolicy(),\n        task_type_overrides={\n            k: ModelOverride(*v) for k, v in _OVERRIDES.items()\n        },\n        pipeline_phases={\n            k: ModelOverride(*v) for k, v in _PHASES.items()\n        },\n        model_performance={\n            k: PerformanceRecord(*v) for k, v in _PERF.items()\n        },\n    )\n"
  },
  {
    "path": "maggy/maggy/routing_rules_io.py",
    "content": "\"\"\"Routing rules YAML I/O — load, save, serialize, deserialize.\"\"\"\n\nfrom __future__ import annotations\n\nfrom pathlib import Path\nfrom typing import TYPE_CHECKING\n\nimport yaml\n\nfrom maggy.config import CONFIG_DIR\n\nif TYPE_CHECKING:\n    from maggy.routing_rules import (\n        CascadePolicy,\n        ModelOverride,\n        PerformanceRecord,\n        RoutingRules,\n        StakesLevel,\n        StakesPatterns,\n    )\n\nRULES_PATH = CONFIG_DIR / \"routing-rules.yaml\"\n\n\ndef save(rules: RoutingRules, path: Path | None = None) -> None:\n    \"\"\"Write rules to YAML.\"\"\"\n    target = path or RULES_PATH\n    target.parent.mkdir(parents=True, exist_ok=True)\n    data = to_dict(rules)\n    target.write_text(yaml.safe_dump(data, sort_keys=False))\n\n\ndef load(path: Path | None = None) -> RoutingRules:\n    \"\"\"Load rules from YAML. Seeds defaults if missing.\"\"\"\n    from maggy.routing_rules_defaults import default_conventions, default_rules\n\n    target = path or RULES_PATH\n    if not target.exists():\n        rules = default_rules()\n        save(rules, target)\n        return rules\n    rules = from_yaml(target)\n    if not rules.conventions:\n        rules.conventions = default_conventions()\n        save(rules, target)\n    return rules\n\n\ndef to_dict(rules: RoutingRules) -> dict:\n    \"\"\"Serialize RoutingRules to a plain dict for YAML.\"\"\"\n    return {\n        \"version\": rules.version,\n        \"updated_at\": rules.updated_at,\n        \"stakes_patterns\": _stakes_to_dict(rules.stakes),\n        \"cascade_policy\": _cascade_to_dict(rules.cascade),\n        \"conventions\": [\n            {\"text\": c.text, \"applies_to\": c.applies_to, \"source\": c.source}\n            for c in rules.conventions\n        ],\n        \"project_conventions\": {\n            k: [{\"text\": c.text, \"applies_to\": c.applies_to, \"source\": c.source} for c in v]\n            for k, v in rules.project_conventions.items()\n        },\n        \"task_type_overrides\": {\n            k: _override_to_dict(v)\n            for k, v in rules.task_type_overrides.items()\n        },\n        \"pipeline_phases\": {\n            k: _override_to_dict(v)\n            for k, v in rules.pipeline_phases.items()\n        },\n        \"model_performance\": {\n            k: _perf_to_dict(v)\n            for k, v in rules.model_performance.items()\n        },\n    }\n\n\ndef from_yaml(path: Path) -> RoutingRules:\n    \"\"\"Deserialize RoutingRules from a YAML file.\"\"\"\n    from maggy.routing_rules import (\n        CascadePolicy as CP,\n        Convention,\n        ModelOverride as MO,\n        PerformanceRecord as PR,\n        RoutingRules as RR,\n    )\n\n    data = yaml.safe_load(path.read_text()) or {}\n    overrides = {\n        k: MO(**v)\n        for k, v in (data.get(\"task_type_overrides\") or {}).items()\n    }\n    phases = {\n        k: MO(**v)\n        for k, v in (data.get(\"pipeline_phases\") or {}).items()\n    }\n    perf = {\n        k: PR(**v)\n        for k, v in (data.get(\"model_performance\") or {}).items()\n    }\n    convs = [\n        Convention(**c) for c in (data.get(\"conventions\") or [])\n    ]\n    proj_convs: dict[str, list] = {}\n    for pk, cv_list in (data.get(\"project_conventions\") or {}).items():\n        proj_convs[pk] = [Convention(**c) for c in cv_list]\n    stakes = _stakes_from_dict(data.get(\"stakes_patterns\") or {})\n    cascade_raw = data.get(\"cascade_policy\") or {}\n    cascade = CP(**cascade_raw) if cascade_raw else CP()\n    return RR(\n        version=data.get(\"version\", 1),\n        updated_at=data.get(\"updated_at\", \"\"),\n        task_type_overrides=overrides,\n        pipeline_phases=phases,\n        model_performance=perf,\n        conventions=convs,\n        project_conventions=proj_convs,\n        stakes=stakes,\n        cascade=cascade,\n    )\n\n\ndef _stakes_to_dict(stakes: StakesPatterns) -> dict:\n    return {\n        \"high\": _level_to_dict(stakes.high),\n        \"medium\": _level_to_dict(stakes.medium),\n        \"low\": _level_to_dict(stakes.low),\n    }\n\n\ndef _level_to_dict(level: StakesLevel) -> dict:\n    return {\n        \"file_patterns\": level.file_patterns,\n        \"task_types\": level.task_types,\n        \"keywords\": level.keywords,\n    }\n\n\ndef _cascade_to_dict(cascade: CascadePolicy) -> dict:\n    return {\n        \"enabled\": cascade.enabled,\n        \"min_blast\": cascade.min_blast,\n        \"min_stakes\": cascade.min_stakes,\n        \"max_attempts\": cascade.max_attempts,\n        \"quality_threshold\": cascade.quality_threshold,\n    }\n\n\ndef _override_to_dict(v: ModelOverride) -> dict:\n    return {\n        \"model\": v.model, \"reason\": v.reason,\n        \"confidence\": v.confidence, \"source\": v.source,\n    }\n\n\ndef _perf_to_dict(v: PerformanceRecord) -> dict:\n    return {\n        \"strengths\": v.strengths, \"weaknesses\": v.weaknesses,\n        \"tasks_completed\": v.tasks_completed,\n        \"success_rate\": v.success_rate,\n    }\n\n\ndef _stakes_from_dict(raw: dict) -> StakesPatterns:\n    from maggy.routing_rules import StakesLevel as SL\n    from maggy.routing_rules import StakesPatterns as SP\n\n    def _level(d: dict) -> SL:\n        return SL(\n            file_patterns=d.get(\"file_patterns\", []),\n            task_types=d.get(\"task_types\", []),\n            keywords=d.get(\"keywords\", []),\n        )\n\n    if not raw:\n        from maggy.routing_rules_defaults import default_stakes\n        return default_stakes()\n    return SP(\n        high=_level(raw.get(\"high\", {})),\n        medium=_level(raw.get(\"medium\", {})),\n        low=_level(raw.get(\"low\", {})),\n    )\n"
  },
  {
    "path": "maggy/maggy/scores.py",
    "content": "\"\"\"Reward table — tracks model performance per task type and blast tier.\n\nSQLite-backed with decay so old data ages out naturally.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport sqlite3\nfrom contextlib import contextmanager\nfrom datetime import date, datetime, timezone\nfrom pathlib import Path\nfrom typing import Iterator\n\nfrom maggy.config import MaggyConfig\n\nSCHEMA = \"\"\"\nCREATE TABLE IF NOT EXISTS rewards (\n    id INTEGER PRIMARY KEY AUTOINCREMENT,\n    model TEXT NOT NULL,\n    task_type TEXT NOT NULL,\n    blast_tier TEXT NOT NULL,\n    reward REAL NOT NULL,\n    recorded_at TEXT NOT NULL\n);\nCREATE INDEX IF NOT EXISTS idx_rewards_lookup\n    ON rewards(model, task_type, blast_tier);\n\"\"\"\n\nMIN_SAMPLES = 5\nDECAY_RATE = 0.95\n\n\n@contextmanager\ndef _connect(path: Path) -> Iterator[sqlite3.Connection]:\n    path.parent.mkdir(parents=True, exist_ok=True)\n    conn = sqlite3.connect(str(path), timeout=30.0)\n    conn.execute(\"PRAGMA journal_mode=WAL\")\n    conn.execute(\"PRAGMA busy_timeout=30000\")\n    conn.row_factory = sqlite3.Row\n    try:\n        yield conn\n    finally:\n        conn.close()\n\n\nclass RewardTable:\n    \"\"\"SQLite-backed reward table with time decay.\"\"\"\n\n    def __init__(self, cfg: MaggyConfig):\n        db_dir = Path(cfg.storage.path).expanduser().parent\n        self._db_path = db_dir / \"model_scores.db\"\n        self._init_db()\n\n    def _init_db(self) -> None:\n        with _connect(self._db_path) as conn:\n            conn.executescript(SCHEMA)\n\n    def record(\n        self, model: str, task_type: str,\n        blast_tier: str, reward: float,\n    ) -> None:\n        \"\"\"Record a reward observation.\"\"\"\n        now = datetime.now(timezone.utc).isoformat()\n        with _connect(self._db_path) as conn:\n            conn.execute(\n                \"INSERT INTO rewards \"\n                \"(model, task_type, blast_tier, \"\n                \"reward, recorded_at) \"\n                \"VALUES (?, ?, ?, ?, ?)\",\n                (model, task_type, blast_tier, reward, now),\n            )\n            conn.commit()\n\n    def best_model(\n        self, task_type: str, blast_tier: str,\n    ) -> str | None:\n        \"\"\"Return best model, or None if insufficient data.\"\"\"\n        with _connect(self._db_path) as conn:\n            rows = conn.execute(\n                \"SELECT model, reward, recorded_at \"\n                \"FROM rewards \"\n                \"WHERE task_type = ? AND blast_tier = ?\",\n                (task_type, blast_tier),\n            ).fetchall()\n\n        if not rows:\n            return None\n\n        scores: dict[str, tuple[float, int]] = {}\n        today = date.today()\n        for r in rows:\n            model = r[\"model\"]\n            rec_date = datetime.fromisoformat(\n                r[\"recorded_at\"],\n            ).date()\n            days = (today - rec_date).days\n            weight = DECAY_RATE ** days\n            weighted = r[\"reward\"] * weight\n            total, count = scores.get(model, (0.0, 0))\n            scores[model] = (total + weighted, count + 1)\n\n        candidates = {\n            m: total / count\n            for m, (total, count) in scores.items()\n            if count >= MIN_SAMPLES\n        }\n        if not candidates:\n            return None\n\n        return max(candidates, key=candidates.get)\n\n    def heatmap(self) -> list[dict]:\n        \"\"\"Return reward averages for dashboard.\"\"\"\n        with _connect(self._db_path) as conn:\n            rows = conn.execute(\n                \"SELECT model, task_type, blast_tier, \"\n                \"AVG(reward) as avg_reward, \"\n                \"COUNT(*) as n \"\n                \"FROM rewards \"\n                \"GROUP BY model, task_type, blast_tier\",\n            ).fetchall()\n        return [\n            {\n                \"model\": r[\"model\"],\n                \"task_type\": r[\"task_type\"],\n                \"blast_tier\": r[\"blast_tier\"],\n                \"avg_reward\": round(r[\"avg_reward\"], 3),\n                \"samples\": r[\"n\"],\n            }\n            for r in rows\n        ]\n"
  },
  {
    "path": "maggy/maggy/services/__init__.py",
    "content": ""
  },
  {
    "path": "maggy/maggy/services/account_guide.py",
    "content": "\"\"\"Account switching guidance — detect profiles, suggest re-auth.\"\"\"\n\nfrom __future__ import annotations\n\nfrom dataclasses import dataclass\nfrom pathlib import Path\n\nfrom rich.console import Console\n\nconsole = Console()\n\n_PROVIDERS = {\n    \".claude\": (\"anthropic\", \"claude auth login\"),\n    \".codex\": (\"openai\", \"codex auth login\"),\n}\n\n\n@dataclass\nclass AccountProfile:\n    \"\"\"Represents a CLI auth profile.\"\"\"\n\n    name: str\n    provider: str\n    auth_command: str\n    is_active: bool = False\n\n\ndef detect_accounts(home: Path | None = None) -> list[AccountProfile]:\n    \"\"\"Discover CLI auth profiles from home dir.\"\"\"\n    root = home or Path.home()\n    accounts: list[AccountProfile] = []\n    for dirname, (provider, cmd) in _PROVIDERS.items():\n        path = root / dirname\n        if path.exists():\n            accounts.append(AccountProfile(\n                name=dirname.lstrip(\".\"),\n                provider=provider,\n                auth_command=cmd,\n            ))\n    return accounts\n\n\ndef suggest_switch(provider: str) -> str:\n    \"\"\"Return CLI instructions to switch accounts.\"\"\"\n    if provider == \"anthropic\":\n        return (\n            \"Claude quota hit. Switch account:\\n\"\n            \"  claude auth login\\n\"\n            \"Then restart your session.\"\n        )\n    if provider == \"openai\":\n        return (\n            \"OpenAI/Codex quota hit. Switch account:\\n\"\n            \"  codex auth login\\n\"\n            \"Then restart your session.\"\n        )\n    return f\"Quota hit for {provider}. Re-authenticate.\"\n\n\ndef render_switch_guide(provider: str) -> None:\n    \"\"\"Print Rich-formatted switch instructions.\"\"\"\n    guide = suggest_switch(provider)\n    console.print(f\"[yellow]{guide}[/yellow]\")\n"
  },
  {
    "path": "maggy/maggy/services/activity.py",
    "content": "\"\"\"CLI activity scanner — detects running sessions and recent prompts.\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport logging\nimport re\nimport subprocess\nfrom dataclasses import asdict, dataclass\nfrom datetime import datetime, timezone\nfrom pathlib import Path\n\nlogger = logging.getLogger(__name__)\n\n\n@dataclass\nclass ActiveSession:\n    \"\"\"A currently running CLI session.\"\"\"\n\n    cli: str\n    session_id: str\n    project: str\n    project_path: str\n    status: str  # \"running\" | \"agent\"\n    last_prompt: str\n    agent_name: str\n    team_name: str\n    pid: int\n\n\n@dataclass\nclass RecentPrompt:\n    \"\"\"A recent user prompt from CLI history.\"\"\"\n\n    cli: str\n    text: str\n    project: str\n    timestamp: str\n    session_id: str\n\n\nclass ActivityService:\n    \"\"\"Scans CLI histories and processes.\"\"\"\n\n    def get_activity(self) -> dict:\n        sessions = _scan_processes()\n        prompts = _recent_prompts()\n        return {\n            \"sessions\": [asdict(s) for s in sessions],\n            \"recent\": [asdict(p) for p in prompts],\n        }\n\n\n# ── Process scanning ──────────────────────────────\n\n\ndef _scan_processes() -> list[ActiveSession]:\n    \"\"\"Find running claude/codex/kimi processes.\"\"\"\n    try:\n        result = subprocess.run(\n            [\"ps\", \"aux\"], capture_output=True,\n            text=True, timeout=5,\n        )\n        lines = result.stdout.splitlines()\n    except (subprocess.SubprocessError, OSError):\n        return []\n    return _parse_claude_processes(\n        [line for line in lines if \"claude\" in line.lower()],\n    )\n\n\ndef _parse_claude_processes(\n    lines: list[str],\n) -> list[ActiveSession]:\n    \"\"\"Parse ps aux lines for Claude CLI sessions.\"\"\"\n    sessions: list[ActiveSession] = []\n    for line in lines:\n        if not _is_cli_process(line):\n            continue\n        pid = _extract_pid(line)\n        if not pid:\n            continue\n        cwd = _get_cwd(pid)\n        project = Path(cwd).name if cwd else \"\"\n        agent = _extract_flag(line, \"--agent-name\")\n        team = _extract_flag(line, \"--team-name\")\n        status = \"agent\" if agent else \"running\"\n        sessions.append(ActiveSession(\n            cli=\"claude\", session_id=\"\",\n            project=project, project_path=cwd,\n            status=status, last_prompt=\"\",\n            agent_name=agent, team_name=team,\n            pid=pid,\n        ))\n    return sessions\n\n\ndef _is_cli_process(line: str) -> bool:\n    \"\"\"Filter real CLI processes from app helpers.\"\"\"\n    lower = line.lower()\n    if \"claude.app\" in lower:\n        return False\n    if \"grep\" in lower:\n        return False\n    if \"claude helper\" in lower:\n        return False\n    return bool(re.search(\n        r'(?:^|/|\\s)claude\\s+--', line,\n    ))\n\n\ndef _extract_pid(line: str) -> int:\n    \"\"\"Extract PID from ps aux line.\"\"\"\n    parts = line.split()\n    if len(parts) >= 2:\n        try:\n            return int(parts[1])\n        except ValueError:\n            pass\n    return 0\n\n\ndef _extract_flag(line: str, flag: str) -> str:\n    \"\"\"Extract --flag value from command line.\"\"\"\n    idx = line.find(flag)\n    if idx < 0:\n        return \"\"\n    rest = line[idx + len(flag):].strip()\n    if not rest:\n        return \"\"\n    return rest.split()[0] if rest else \"\"\n\n\ndef _get_cwd(pid: int) -> str:\n    \"\"\"Get working directory of a process (macOS).\"\"\"\n    try:\n        result = subprocess.run(\n            [\"lsof\", \"-p\", str(pid), \"-Fn\"],\n            capture_output=True, text=True, timeout=3,\n        )\n        for line in result.stdout.splitlines():\n            if line.startswith(\"n\") and \"/\" in line:\n                path = line[1:]\n                if Path(path).is_dir():\n                    return path\n    except (subprocess.SubprocessError, OSError):\n        pass\n    return \"\"\n\n\n# ── History scanning ──────────────────────────────\n\n\ndef _recent_prompts(\n    claude_dir: Path | None = None,\n    codex_dir: Path | None = None,\n    kimi_dir: Path | None = None,\n    limit: int = 15,\n) -> list[RecentPrompt]:\n    \"\"\"Read recent prompts from all CLI histories.\"\"\"\n    home = Path.home()\n    c_dir = claude_dir or (home / \".claude\")\n    x_dir = codex_dir or (home / \".codex\")\n    k_dir = kimi_dir or (home / \".kimi\")\n\n    prompts: list[RecentPrompt] = []\n    prompts.extend(_read_claude_history(c_dir))\n    prompts.extend(_read_codex_history(x_dir))\n    prompts.extend(_read_kimi_history(k_dir))\n\n    prompts.sort(key=lambda p: p.timestamp, reverse=True)\n    return prompts[:limit]\n\n\ndef _read_claude_history(\n    claude_dir: Path,\n) -> list[RecentPrompt]:\n    \"\"\"Parse ~/.claude/history.jsonl.\"\"\"\n    path = claude_dir / \"history.jsonl\"\n    if not path.exists():\n        return []\n    prompts: list[RecentPrompt] = []\n    try:\n        for line in _tail_lines(path, 50):\n            try:\n                entry = json.loads(line)\n            except json.JSONDecodeError:\n                continue\n            text = entry.get(\"display\", \"\")\n            if not text:\n                continue\n            ts = entry.get(\"timestamp\", 0)\n            project = entry.get(\"project\", \"\")\n            prompts.append(RecentPrompt(\n                cli=\"claude\", text=text[:200],\n                project=Path(project).name if project else \"\",\n                timestamp=_ms_to_iso(ts),\n                session_id=entry.get(\"sessionId\", \"\"),\n            ))\n    except OSError:\n        pass\n    return prompts\n\n\ndef _read_codex_history(\n    codex_dir: Path,\n) -> list[RecentPrompt]:\n    \"\"\"Parse ~/.codex/history.jsonl.\"\"\"\n    path = codex_dir / \"history.jsonl\"\n    if not path.exists():\n        return []\n    prompts: list[RecentPrompt] = []\n    try:\n        for line in _tail_lines(path, 50):\n            try:\n                entry = json.loads(line)\n            except json.JSONDecodeError:\n                continue\n            text = entry.get(\"text\", \"\")\n            if not text:\n                continue\n            ts = entry.get(\"ts\", 0)\n            prompts.append(RecentPrompt(\n                cli=\"codex\", text=text[:200],\n                project=\"\",\n                timestamp=_s_to_iso(ts),\n                session_id=entry.get(\"session_id\", \"\"),\n            ))\n    except OSError:\n        pass\n    return prompts\n\n\ndef _read_kimi_history(\n    kimi_dir: Path,\n) -> list[RecentPrompt]:\n    \"\"\"Parse ~/.kimi/user-history/*.jsonl.\"\"\"\n    hist_dir = kimi_dir / \"user-history\"\n    if not hist_dir.is_dir():\n        return []\n    prompts: list[RecentPrompt] = []\n    try:\n        for f in sorted(\n            hist_dir.glob(\"*.jsonl\"),\n            key=lambda p: p.stat().st_mtime,\n            reverse=True,\n        )[:3]:\n            mtime = datetime.fromtimestamp(\n                f.stat().st_mtime, tz=timezone.utc,\n            ).isoformat()\n            for line in _tail_lines(f, 10):\n                try:\n                    entry = json.loads(line)\n                except json.JSONDecodeError:\n                    continue\n                text = entry.get(\"content\", \"\")\n                if text:\n                    prompts.append(RecentPrompt(\n                        cli=\"kimi\", text=text[:200],\n                        project=\"\", timestamp=mtime,\n                        session_id=f.stem,\n                    ))\n    except OSError:\n        pass\n    return prompts\n\n\n# ── Helpers ───────────────────────────────────────\n\n\ndef _tail_lines(path: Path, n: int) -> list[str]:\n    \"\"\"Read last N non-empty lines from a file.\"\"\"\n    try:\n        lines = path.read_text().splitlines()\n        return [line for line in lines if line.strip()][-n:]\n    except OSError:\n        return []\n\n\ndef _ms_to_iso(ms: int | float) -> str:\n    \"\"\"Convert milliseconds epoch to ISO string.\"\"\"\n    if not ms:\n        return \"\"\n    try:\n        dt = datetime.fromtimestamp(\n            ms / 1000, tz=timezone.utc,\n        )\n        return dt.isoformat()\n    except (ValueError, OSError):\n        return \"\"\n\n\ndef _s_to_iso(s: int | float) -> str:\n    \"\"\"Convert seconds epoch to ISO string.\"\"\"\n    if not s:\n        return \"\"\n    try:\n        dt = datetime.fromtimestamp(s, tz=timezone.utc)\n        return dt.isoformat()\n    except (ValueError, OSError):\n        return \"\"\n"
  },
  {
    "path": "maggy/maggy/services/ai_client.py",
    "content": "\"\"\"AI client — uses API key or falls back to CLI subscription.\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport logging\nimport shutil\n\nlogger = logging.getLogger(__name__)\n\n\nasync def ai_complete(\n    prompt: str, cfg, model: str = \"\",\n) -> str | None:\n    \"\"\"Get AI completion. Tries API key, then CLI.\"\"\"\n    target_model = model or cfg.ai.model\n    if cfg.ai.api_key:\n        return await _api_complete(\n            prompt, cfg.ai.api_key, target_model,\n        )\n    if shutil.which(\"claude\"):\n        return await _cli_complete(prompt, \"claude\")\n    if shutil.which(\"codex\"):\n        return await _cli_complete(prompt, \"codex\")\n    return None\n\n\nasync def _api_complete(\n    prompt: str, api_key: str, model: str,\n) -> str | None:\n    \"\"\"Call Anthropic API directly.\"\"\"\n    try:\n        import anthropic\n        client = anthropic.AsyncAnthropic(api_key=api_key)\n        msg = await client.messages.create(\n            model=model,\n            max_tokens=2000,\n            messages=[{\"role\": \"user\", \"content\": prompt}],\n        )\n        return msg.content[0].text\n    except Exception as e:\n        logger.warning(\"API completion failed: %s\", e)\n        return None\n\n\nasync def _cli_complete(\n    prompt: str, cli: str,\n) -> str | None:\n    \"\"\"Call AI via CLI subscription (claude/codex).\"\"\"\n    try:\n        process = await asyncio.create_subprocess_exec(\n            cli, \"-p\", prompt, \"--output-format\", \"text\",\n            stdout=asyncio.subprocess.PIPE,\n            stderr=asyncio.subprocess.PIPE,\n        )\n        stdout, stderr = await asyncio.wait_for(\n            process.communicate(), timeout=120,\n        )\n        if process.returncode == 0:\n            return stdout.decode().strip()\n        logger.warning(\n            \"%s CLI failed (rc=%d): %s\",\n            cli, process.returncode,\n            stderr.decode()[:200],\n        )\n    except asyncio.TimeoutError:\n        logger.warning(\"%s CLI timed out\", cli)\n    except OSError as e:\n        logger.warning(\"%s CLI not available: %s\", cli, e)\n    return None\n"
  },
  {
    "path": "maggy/maggy/services/cascade.py",
    "content": "\"\"\"Cascade execution — quality-gate-based model escalation.\n\nTry cheapest model first, evaluate output quality, escalate\nto next tier if quality gate fails. Max 3 attempts.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport logging\nfrom dataclasses import dataclass, field\nfrom typing import TYPE_CHECKING, Callable\n\nif TYPE_CHECKING:\n    from maggy.adapters.pi import PiAdapter\n\nlogger = logging.getLogger(__name__)\n\n\n@dataclass\nclass CascadeAttempt:\n    \"\"\"Record of a single cascade attempt.\"\"\"\n\n    model: str\n    success: bool\n    score: int = 0\n    output: str = \"\"\n    cost_usd: float = 0.0\n\n\n@dataclass\nclass CascadeResult:\n    \"\"\"Result of cascade execution.\"\"\"\n\n    model: str\n    output: str\n    attempts: list[CascadeAttempt] = field(default_factory=list)\n    escalated: bool = False\n    cost_usd: float = 0.0\n\n\nasync def cascade_execute(\n    pi: PiAdapter,\n    chain: list[str],\n    prompt: str,\n    wd: str,\n    quality_gate: Callable[[str], int],\n) -> CascadeResult:\n    \"\"\"Try cheapest model, escalate on quality gate failure.\"\"\"\n    attempts: list[CascadeAttempt] = []\n    best = CascadeAttempt(\"\", False)\n    max_attempts = min(len(chain), 3)\n\n    for i in range(max_attempts):\n        model = chain[i]\n        result = await pi.send_prompt(model, prompt, wd)\n        cost = getattr(result, \"cost_usd\", 0.0)\n        if not result.success:\n            attempts.append(CascadeAttempt(model, False))\n            logger.info(\"Cascade: %s failed, escalating\", model)\n            continue\n        score = await quality_gate(result.output)\n        attempt = CascadeAttempt(model, True, score, result.output, cost)\n        attempts.append(attempt)\n        if score > best.score:\n            best = attempt\n        if score >= 3:\n            return CascadeResult(\n                model, result.output, attempts,\n                escalated=i > 0, cost_usd=cost,\n            )\n        logger.info(\n            \"Cascade: %s scored %d, escalating\", model, score,\n        )\n\n    return CascadeResult(\n        best.model, best.output, attempts,\n        escalated=len(attempts) > 1,\n        cost_usd=best.cost_usd,\n    )\n"
  },
  {
    "path": "maggy/maggy/services/chat.py",
    "content": "\"\"\"ChatManager — interactive Claude Code sessions with message queue.\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport logging\nimport uuid\nfrom collections import deque\nfrom dataclasses import dataclass, field\nfrom datetime import datetime, timezone\nfrom pathlib import Path\nfrom typing import AsyncGenerator\n\nfrom maggy.config import MaggyConfig\nfrom maggy.services.chat_stream import stream_message\n\nlogger = logging.getLogger(__name__)\n\nMAX_QUEUE = 5\n\n\n@dataclass\nclass ChatMessage:\n    \"\"\"A single message in a chat session.\"\"\"\n\n    role: str  # \"user\" | \"assistant\"\n    content: str\n    timestamp: str = field(\n        default_factory=lambda: datetime.now(\n            timezone.utc\n        ).isoformat()\n    )\n\n\n@dataclass\nclass ChatSession:\n    \"\"\"An interactive Claude Code session.\"\"\"\n\n    id: str\n    claude_session_id: str\n    project_key: str\n    working_dir: str\n    messages: list[ChatMessage] = field(default_factory=list)\n    status: str = \"idle\"\n    created_at: str = field(\n        default_factory=lambda: datetime.now(\n            timezone.utc\n        ).isoformat()\n    )\n    pid: int = 0\n    history_context: str = \"\"\n    pending_queue: deque = field(\n        default_factory=lambda: deque(maxlen=MAX_QUEUE),\n    )\n\n\ndef enqueue_msg(session: ChatSession, message: str) -> int:\n    \"\"\"Append message to session queue. Returns position or -1.\"\"\"\n    if len(session.pending_queue) >= MAX_QUEUE:\n        return -1\n    session.pending_queue.append(message)\n    return len(session.pending_queue)\n\n\nclass ChatManager:\n    \"\"\"Manages interactive Claude Code sessions.\"\"\"\n\n    def __init__(self, cfg: MaggyConfig):\n        self.cfg = cfg\n        self._sessions: dict[str, ChatSession] = {}\n        self._locks: dict[str, asyncio.Lock] = {}\n\n    def create_session(\n        self, project_key: str, project_path: str | None = None,\n    ) -> ChatSession:\n        \"\"\"Create a new chat session for a project.\"\"\"\n        if project_path:\n            wd = self._validate_path(project_path)\n            key = project_key or Path(wd).name\n        else:\n            wd = self._resolve_project(project_key)\n            key = project_key\n        session = ChatSession(\n            id=uuid.uuid4().hex[:10],\n            claude_session_id=\"\",\n            project_key=key,\n            working_dir=wd,\n        )\n        self._sessions[session.id] = session\n        self._locks[session.id] = asyncio.Lock()\n        return session\n\n    def find_by_project(self, key: str) -> ChatSession | None:\n        \"\"\"Find existing session for a project key.\"\"\"\n        for s in self._sessions.values():\n            if s.project_key == key:\n                return s\n        return None\n\n    def auto_connect(\n        self, active_sessions: list[dict],\n    ) -> list[ChatSession]:\n        \"\"\"Create sessions for all active projects.\"\"\"\n        connected: dict[str, ChatSession] = {}\n        for active in active_sessions:\n            project = active.get(\"project\", \"\")\n            path = active.get(\"project_path\", \"\")\n            if not project or not path:\n                continue\n            if project in connected:\n                continue\n            existing = self.find_by_project(project)\n            if existing:\n                connected[project] = existing\n                continue\n            try:\n                session = self.create_session(project, path)\n            except ValueError:\n                continue\n            connected[project] = session\n        return list(connected.values())\n\n    def get_session(self, sid: str) -> ChatSession | None:\n        return self._sessions.get(sid)\n\n    def list_sessions(self) -> list[ChatSession]:\n        return list(self._sessions.values())\n\n    def delete_session(self, session_id: str) -> bool:\n        if session_id in self._sessions:\n            del self._sessions[session_id]\n            self._locks.pop(session_id, None)\n            return True\n        return False\n\n    async def send(\n        self, session_id: str, message: str,\n    ) -> AsyncGenerator[dict, None]:\n        \"\"\"Send message, yield streamed response chunks.\"\"\"\n        session = self._sessions.get(session_id)\n        if not session:\n            raise ValueError(f\"Session {session_id} not found\")\n        lock = self._locks.setdefault(\n            session_id, asyncio.Lock(),\n        )\n        if lock.locked():\n            pos = enqueue_msg(session, message)\n            if pos < 0:\n                yield {\"type\": \"error\", \"content\": \"Queue full.\"}\n                return\n            yield {\"type\": \"queued\", \"position\": pos}\n            return\n        async with lock:\n            async for chunk in stream_message(session, message):\n                yield chunk\n            async for chunk in self._drain_queue(session):\n                yield chunk\n\n    async def _drain_queue(\n        self, session: ChatSession,\n    ) -> AsyncGenerator[dict, None]:\n        \"\"\"Process queued messages after current stream.\"\"\"\n        while session.pending_queue:\n            msg = session.pending_queue.popleft()\n            yield {\n                \"type\": \"queue_next\",\n                \"content\": msg[:80],\n            }\n            async for chunk in stream_message(session, msg):\n                yield chunk\n\n    def _validate_path(self, path: str) -> str:\n        \"\"\"Validate path is inside a configured codebase root.\"\"\"\n        candidate = Path(path).expanduser().resolve()\n        roots = [\n            Path(c.path).expanduser().resolve()\n            for c in self.cfg.codebases\n        ]\n        for root in roots:\n            try:\n                candidate.relative_to(root)\n                return str(candidate)\n            except ValueError:\n                continue\n        raise ValueError(\n            f\"Path {path!r} is not inside any configured \"\n            f\"codebase. Allowed: {[str(r) for r in roots]}\"\n        )\n\n    def _resolve_project(self, project_key: str) -> str:\n        \"\"\"Map project_key to validated working directory.\"\"\"\n        for cb in self.cfg.codebases:\n            if cb.key == project_key:\n                path = Path(cb.path).expanduser().resolve()\n                return str(path)\n        raise ValueError(\n            f\"Project '{project_key}' not found in codebases\"\n        )\n"
  },
  {
    "path": "maggy/maggy/services/chat_context.py",
    "content": "\"\"\"Chat context builder — resolves history and session IDs.\n\nHandles the three context gaps:\n1. Path-based history matching (not just project name)\n2. Recent prompt injection from activity data\n3. Claude session_id lookup for true --resume\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport logging\nfrom pathlib import Path\n\nlogger = logging.getLogger(__name__)\n\n\ndef build_project_context(\n    history, working_dir: str,\n    project_key: str, recent_prompts: list[dict],\n) -> str:\n    \"\"\"Build full context string for a project.\"\"\"\n    parts = []\n    hist = _match_history(history, working_dir, project_key)\n    if hist:\n        parts.append(hist)\n    prompts = _format_recent_prompts(recent_prompts, project_key)\n    if prompts:\n        parts.append(prompts)\n    return \"\\n\\n\".join(parts)\n\n\ndef _match_history(\n    history, working_dir: str, project_key: str,\n) -> str:\n    \"\"\"Match history using report data (path-aware).\"\"\"\n    if not history:\n        return \"\"\n    report = history.get_report()\n    if report:\n        return _match_from_report(\n            report, working_dir, project_key,\n        )\n    return \"\"\n\n\ndef _match_from_report(\n    report: dict, working_dir: str, project_key: str,\n) -> str:\n    \"\"\"Match project in the aggregated history report.\"\"\"\n    projects = report.get(\"projects\", [])\n    if not projects:\n        return \"\"\n    candidates = _path_candidates(working_dir, project_key)\n    matched = [\n        p for p in projects\n        if p.get(\"project\", \"\") in candidates\n    ]\n    if not matched:\n        return \"\"\n    lines = []\n    for p in matched:\n        sessions = p.get(\"total_sessions\", 0)\n        prompts = p.get(\"total_prompts\", 0)\n        providers = \", \".join(p.get(\"providers_used\", []))\n        topics = \", \".join(p.get(\"top_topics\", [])[:5])\n        line = f\"- {sessions} sessions, {prompts} prompts\"\n        if providers:\n            line += f\" ({providers})\"\n        if topics:\n            line += f\", topics: {topics}\"\n        lines.append(line)\n    return (\n        f\"Project history ({len(matched)} entries):\\n\"\n        + \"\\n\".join(lines)\n    )\n\n\n_SKIP_DIRS = {\n    \"Users\", \"home\", \"Documents\", \"var\", \"tmp\", \"opt\",\n    \"usr\", \"Library\", \"Applications\",\n}\n\n\ndef _path_candidates(\n    working_dir: str, project_key: str,\n) -> set[str]:\n    \"\"\"Generate candidate project names from path.\"\"\"\n    candidates = {project_key}\n    if working_dir:\n        parts = Path(working_dir).parts\n        for part in parts:\n            if (part and part != \"/\"\n                    and len(part) > 2\n                    and part not in _SKIP_DIRS):\n                candidates.add(part)\n    return candidates\n\n\ndef _format_recent_prompts(\n    recent_prompts: list[dict], project_key: str,\n) -> str:\n    \"\"\"Format recent prompts for this project.\"\"\"\n    matched = [\n        p for p in recent_prompts\n        if p.get(\"project\", \"\") == project_key\n    ][:5]\n    if not matched:\n        return \"\"\n    lines = []\n    for p in matched:\n        text = p.get(\"text\", \"\")[:120]\n        ts = p.get(\"timestamp\", \"\")[:10]\n        lines.append(f\"- [{ts}] {text}\")\n    return \"Recent prompts:\\n\" + \"\\n\".join(lines)\n\n\ndef resolve_claude_session_id(\n    working_dir: str,\n) -> str:\n    \"\"\"Find the latest Claude session_id for a project.\n\n    Reads ~/.claude/history.jsonl to find the most recent\n    sessionId used in this working directory.\n    \"\"\"\n    history_path = Path.home() / \".claude\" / \"history.jsonl\"\n    if not history_path.exists():\n        return \"\"\n    try:\n        lines = history_path.read_text().splitlines()\n    except OSError:\n        return \"\"\n    target = working_dir.rstrip(\"/\")\n    for line in reversed(lines):\n        line = line.strip()\n        if not line:\n            continue\n        try:\n            entry = json.loads(line)\n        except (json.JSONDecodeError, ValueError):\n            continue\n        project = entry.get(\"project\", \"\")\n        if not project:\n            continue\n        if project.rstrip(\"/\") == target:\n            sid = entry.get(\"sessionId\", \"\")\n            if sid:\n                return sid\n    return \"\"\n"
  },
  {
    "path": "maggy/maggy/services/chat_router.py",
    "content": "\"\"\"Routed chat — blast-score routing for interactive messages.\n\nEstimates complexity from message keywords, routes to the optimal\nmodel via RoutingService, and builds CLI commands for any model.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport re\nfrom dataclasses import dataclass\n\nfrom maggy.routing import RoutingContext\n\nHIGH_KEYWORDS = frozenset({\n    \"security\", \"auth\", \"authentication\", \"authorization\",\n    \"oauth\", \"encrypt\", \"vulnerability\", \"architecture\",\n    \"refactor\", \"redesign\", \"migrate\", \"migration\",\n    \"database\", \"schema\", \"performance\", \"optimize\",\n    \"deploy\", \"infrastructure\", \"cicd\", \"pipeline\",\n})\nMID_KEYWORDS = frozenset({\n    \"feature\", \"implement\", \"build\", \"create\", \"api\",\n    \"endpoint\", \"component\", \"service\", \"integration\",\n    \"pagination\", \"filter\", \"search\", \"cache\",\n})\nLOW_KEYWORDS = frozenset({\n    \"fix\", \"typo\", \"rename\", \"move\", \"style\", \"format\",\n    \"lint\", \"comment\", \"readme\", \"docs\", \"log\", \"print\",\n    \"bump\", \"version\", \"config\", \"env\", \"update\",\n})\nTYPE_KEYWORDS: dict[str, frozenset[str]] = {\n    \"security\": frozenset({\n        \"auth\", \"authentication\", \"authorization\",\n        \"security\", \"permission\", \"token\",\n        \"encrypt\", \"vulnerability\", \"oauth\", \"csrf\",\n    }),\n    \"search\": frozenset({\n        \"find\", \"search\", \"grep\", \"where\", \"locate\",\n        \"which\", \"look\", \"scan\", \"show\", \"list\", \"read\",\n    }),\n    \"docs\": frozenset({\n        \"document\", \"documentation\", \"readme\", \"docs\",\n        \"docstring\", \"comment\", \"spec\", \"jsdoc\", \"write\",\n    }),\n    \"tests\": frozenset({\n        \"test\", \"spec\", \"coverage\", \"mock\", \"fixture\",\n        \"assert\", \"pytest\", \"jest\", \"vitest\",\n    }),\n    \"frontend\": frozenset({\n        \"component\", \"css\", \"style\", \"ui\", \"layout\",\n        \"responsive\", \"tailwind\", \"react\", \"vue\",\n    }),\n}\nDEFAULT_BLAST = 5\n_RETRIEVAL = re.compile(\n    r\"\\b(find|get|show|check|where|list|read|look|grab|pick)\\b\",\n    re.IGNORECASE,\n)\n_MUTATION = re.compile(\n    r\"\\b(create|add|build|implement|write|refactor|migrate\"\n    r\"|redesign|overhaul|deploy)\\b\",\n    re.IGNORECASE,\n)\n\n\ndef estimate_blast(message: str) -> int:\n    \"\"\"Estimate blast score (1-10) from message text.\"\"\"\n    if not message.strip():\n        return DEFAULT_BLAST\n    words = set(re.findall(r\"[a-zA-Z]+\", message.lower()))\n    has_kw = words & (HIGH_KEYWORDS | MID_KEYWORDS | LOW_KEYWORDS)\n    if len(words) <= 3 and not has_kw:\n        return 1\n    high = len(words & HIGH_KEYWORDS)\n    mid = len(words & MID_KEYWORDS)\n    low = len(words & LOW_KEYWORDS)\n    score = _keyword_score(high, mid, low)\n    return _apply_intent(message, score)\n\n\ndef _keyword_score(high: int, mid: int, low: int) -> int:\n    \"\"\"Score based on keyword tier counts.\"\"\"\n    if high >= 2:\n        return min(9, 7 + high - 2)\n    if high == 1:\n        return 7\n    if low >= 2 and mid == 0:\n        return 2\n    if low >= 1 and mid == 0:\n        return 3\n    if mid >= 2:\n        return 6\n    if mid >= 1:\n        return 5\n    return 1\n\n\ndef _apply_intent(message: str, score: int) -> int:\n    \"\"\"Cap score for retrieval-only messages.\"\"\"\n    is_retrieval = bool(_RETRIEVAL.search(message))\n    is_mutation = bool(_MUTATION.search(message))\n    if is_retrieval and not is_mutation and score < 7:\n        return min(score, 3)\n    return score\n\n\ndef estimate_type(message: str) -> str:\n    \"\"\"Estimate task type from message keywords.\"\"\"\n    words = set(re.findall(r\"[a-zA-Z]+\", message.lower()))\n    best_type = \"general\"\n    best_count = 0\n    for ttype, keywords in TYPE_KEYWORDS.items():\n        count = len(words & keywords)\n        if count > best_count:\n            best_count = count\n            best_type = ttype\n    return best_type\n\n\n@dataclass\nclass RouteDecision:\n    \"\"\"Result of routing a chat message.\"\"\"\n\n    model: str\n    reason: str\n    blast: int\n    task_type: str\n\n\nclass RoutedChat:\n    \"\"\"Routes chat messages through blast-score engine.\"\"\"\n\n    def __init__(self, routing, budget):\n        self._routing = routing\n        self._budget = budget\n\n    def decide(\n        self,\n        message: str,\n        blast_override: int | None = None,\n        type_override: str | None = None,\n    ) -> RouteDecision:\n        \"\"\"Get routing decision for a message.\"\"\"\n        blast = blast_override or estimate_blast(message)\n        task_type = type_override or estimate_type(message)\n        ctx = RoutingContext(\n            blast_score=blast, task_type=task_type,\n        )\n        decision = self._routing.route(ctx)\n        model_name = self._model_name(decision.primary)\n        return RouteDecision(\n            model=model_name,\n            reason=decision.reason,\n            blast=blast,\n            task_type=task_type,\n        )\n\n    def _model_name(self, primary) -> str:\n        if isinstance(primary, str):\n            return primary\n        return str(getattr(primary, \"name\", primary))\n"
  },
  {
    "path": "maggy/maggy/services/chat_stream.py",
    "content": "\"\"\"Chat streaming — subprocess execution and JSON parsing.\n\nExtracted from ChatManager for quality-gate compliance.\nHandles claude CLI subprocess, stream-json parsing, and\nassistant message extraction.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport json\nimport logging\nimport os\nfrom typing import TYPE_CHECKING, AsyncGenerator\n\nif TYPE_CHECKING:\n    from maggy.services.chat import ChatSession\n\nlogger = logging.getLogger(__name__)\n\nCLAUDE_BIN = \"claude\"\n\n\ndef build_cmd(session: ChatSession, message: str) -> list[str]:\n    \"\"\"Build claude CLI command.\"\"\"\n    cmd = [\n        CLAUDE_BIN, \"-p\", message,\n        \"--output-format\", \"stream-json\",\n        \"--verbose\",\n        \"--dangerously-skip-permissions\",\n    ]\n    if session.claude_session_id:\n        cmd += [\"--resume\", session.claude_session_id]\n    return cmd\n\n\ndef parse_chunk(\n    text: str, session: ChatSession,\n) -> dict | None:\n    \"\"\"Parse a stream-json line from Claude.\"\"\"\n    try:\n        data = json.loads(text)\n    except json.JSONDecodeError:\n        return {\"type\": \"text\", \"content\": text}\n    if \"session_id\" in data and not session.claude_session_id:\n        session.claude_session_id = data[\"session_id\"]\n    msg_type = data.get(\"type\", \"\")\n    if msg_type == \"assistant\":\n        return _extract_assistant(data)\n    if msg_type == \"result\":\n        content = data.get(\"result\", \"\")\n        chunk: dict = {\"type\": \"result\", \"content\": content}\n        cost = data.get(\"cost_usd\")\n        if cost is not None:\n            chunk[\"cost_usd\"] = float(cost)\n        usage = data.get(\"usage\")\n        if usage is not None:\n            chunk[\"input_tokens\"] = int(usage.get(\"input_tokens\") or 0)\n            chunk[\"output_tokens\"] = int(usage.get(\"output_tokens\") or 0)\n        return chunk\n    return None\n\n\ndef _extract_assistant(data: dict) -> dict:\n    \"\"\"Extract text from assistant message.\"\"\"\n    content = data.get(\"message\", {}).get(\"content\", \"\")\n    if isinstance(content, list):\n        parts = [\n            b.get(\"text\", \"\")\n            for b in content\n            if b.get(\"type\") == \"text\"\n        ]\n        return {\"type\": \"text\", \"content\": \"\".join(parts)}\n    return {\"type\": \"text\", \"content\": str(content)}\n\n\ndef check_context_pressure(session: ChatSession) -> dict | None:\n    \"\"\"Warn if session messages are getting large.\"\"\"\n    from maggy.services.context_compactor import estimate_tokens\n    msgs = [{\"content\": m.content} for m in session.messages]\n    tokens = estimate_tokens(msgs)\n    if tokens > 24_000:\n        return {\"type\": \"warning\", \"content\": f\"Context: ~{tokens} tokens\"}\n    return None\n\n\nasync def stream_message(\n    session: ChatSession, message: str,\n) -> AsyncGenerator[dict, None]:\n    \"\"\"Run a single message through Claude CLI.\"\"\"\n    from maggy.services.chat import ChatMessage\n\n    session.messages.append(\n        ChatMessage(role=\"user\", content=message),\n    )\n    session.status = \"streaming\"\n    pressure = check_context_pressure(session)\n    if pressure:\n        yield pressure\n    cmd = build_cmd(session, message)\n    response_text = \"\"\n    try:\n        env = {\n            k: v for k, v in os.environ.items()\n            if k != \"CLAUDECODE\"\n        }\n        proc = await asyncio.create_subprocess_exec(\n            *cmd,\n            stdout=asyncio.subprocess.PIPE,\n            stderr=asyncio.subprocess.STDOUT,\n            cwd=session.working_dir,\n            env=env,\n        )\n        session.pid = proc.pid or 0\n        async for line in proc.stdout:\n            text = line.decode(\"utf-8\", errors=\"replace\").strip()\n            if not text:\n                continue\n            chunk = parse_chunk(text, session)\n            if chunk:\n                response_text += chunk.get(\"content\", \"\")\n                yield chunk\n        await proc.wait()\n        session.status = \"idle\"\n    except FileNotFoundError:\n        session.status = \"error\"\n        yield {\"type\": \"error\", \"content\": \"claude CLI not found\"}\n    except Exception as e:\n        session.status = \"error\"\n        yield {\"type\": \"error\", \"content\": str(e)}\n    if response_text:\n        session.messages.append(\n            ChatMessage(role=\"assistant\", content=response_text),\n        )\n"
  },
  {
    "path": "maggy/maggy/services/checkpoint.py",
    "content": "\"\"\"Cross-model checkpoint serializer.\n\nProduces model-agnostic checkpoints that can be injected into\nany model on switch, preserving task understanding.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nfrom dataclasses import asdict, dataclass, field\nfrom datetime import datetime, timezone\n\n\n@dataclass\nclass Checkpoint:\n    \"\"\"Model-agnostic task checkpoint.\"\"\"\n\n    goal: str = \"\"\n    constraints: list[str] = field(default_factory=list)\n    progress: list[str] = field(default_factory=list)\n    working_state: str = \"\"\n    file_context: list[str] = field(default_factory=list)\n    source_model: str = \"\"\n    created_at: str = \"\"\n\n    def serialize(self) -> str:\n        \"\"\"Serialize to JSON for storage/transfer.\"\"\"\n        if not self.created_at:\n            self.created_at = datetime.now(\n                timezone.utc\n            ).isoformat()\n        return json.dumps(asdict(self), indent=2)\n\n    @classmethod\n    def deserialize(cls, data: str) -> Checkpoint:\n        \"\"\"Reconstruct from JSON.\"\"\"\n        d = json.loads(data)\n        return cls(**d)\n\n    def to_prompt(self) -> str:\n        \"\"\"Format as a structured prompt for the new model.\"\"\"\n        parts = [\n            \"## Task Checkpoint (from previous model session)\",\n            f\"**Goal:** {self.goal}\",\n        ]\n        if self.constraints:\n            parts.append(\"**Constraints:**\")\n            for c in self.constraints:\n                parts.append(f\"  - {c}\")\n        if self.progress:\n            parts.append(\"**Progress so far:**\")\n            for p in self.progress:\n                parts.append(f\"  - {p}\")\n        if self.working_state:\n            parts.append(\n                f\"**Current state:** {self.working_state}\"\n            )\n        if self.file_context:\n            parts.append(\"**Key files:**\")\n            for f in self.file_context[:10]:\n                parts.append(f\"  - {f}\")\n        parts.append(\n            \"\\nPlease confirm you understand this context \"\n            \"before proceeding.\"\n        )\n        return \"\\n\".join(parts)\n\n\ndef create_checkpoint(\n    goal: str,\n    progress: list[str],\n    model: str,\n    working_state: str = \"\",\n    files: list[str] | None = None,\n    constraints: list[str] | None = None,\n) -> Checkpoint:\n    \"\"\"Create a checkpoint from current session state.\"\"\"\n    return Checkpoint(\n        goal=goal,\n        constraints=constraints or [],\n        progress=progress,\n        working_state=working_state,\n        file_context=files or [],\n        source_model=model,\n    )\n"
  },
  {
    "path": "maggy/maggy/services/competitor.py",
    "content": "\"\"\"Generic competitor intelligence — AI discovery + RSS/news monitoring + daily briefing.\n\nStores competitors in ~/.maggy/competitors.json. Monitored feeds stored in SQLite.\nWorks for ANY domain — CX, fintech, devtools, healthcare, etc. Domain comes from config.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport hashlib\nimport ipaddress\nimport json\nimport logging\nimport socket\nimport sqlite3\nfrom datetime import date, datetime, timezone\nfrom pathlib import Path\nfrom urllib.parse import quote, urlparse\n\nimport feedparser\n\nfrom maggy.services.ai_client import ai_complete\nimport httpx\n\nfrom maggy.config import MaggyConfig\n\nlogger = logging.getLogger(__name__)\n\n\ndef _connect_sqlite(path: Path) -> sqlite3.Connection:\n    \"\"\"Open a SQLite connection with WAL + foreign_keys + busy_timeout.\n\n    Same defaults as InboxService — safe for concurrent FastAPI handlers\n    plus the heartbeat worker writing from another thread.\n    \"\"\"\n    db = sqlite3.connect(path, timeout=30.0)\n    db.execute(\"PRAGMA journal_mode=WAL\")\n    db.execute(\"PRAGMA foreign_keys=ON\")\n    db.execute(\"PRAGMA busy_timeout=30000\")\n    return db\n\n\ndef _parse_feed_date(raw: str) -> datetime | None:\n    \"\"\"Parse RFC 822 / ISO 8601 date strings from RSS/Atom feeds.\n\n    feedparser returns `published` as RFC 822 (\"Mon, 15 Jan 2024 10:30:00 GMT\").\n    Comparing those lexicographically is wrong because day names cycle weekly.\n    Returns a timezone-aware UTC datetime, or None if parsing fails.\n    \"\"\"\n    if not raw:\n        return None\n    # feedparser exposes parsed tuple when it can\n    try:\n        from email.utils import parsedate_to_datetime\n        dt = parsedate_to_datetime(raw)\n        if dt.tzinfo is None:\n            dt = dt.replace(tzinfo=timezone.utc)\n        return dt.astimezone(timezone.utc)\n    except (TypeError, ValueError):\n        pass\n    # Fall through: try ISO 8601 (atom feeds, Google News sometimes)\n    try:\n        dt = datetime.fromisoformat(raw.replace(\"Z\", \"+00:00\"))\n        if dt.tzinfo is None:\n            dt = dt.replace(tzinfo=timezone.utc)\n        return dt.astimezone(timezone.utc)\n    except (TypeError, ValueError):\n        return None\n\n\ndef _is_safe_feed_url(url: str) -> bool:\n    \"\"\"Reject RSS URLs that would let an attacker hit internal services.\n\n    Blocks non-HTTP(S), bare hostnames without scheme, and any host whose\n    resolved IPs include loopback, link-local, private, or multicast ranges.\n    Prevents SSRF via AI-discovered or user-edited competitor registry.\n    \"\"\"\n    try:\n        parsed = urlparse(url)\n    except Exception:\n        return False\n    if parsed.scheme not in (\"http\", \"https\"):\n        return False\n    host = (parsed.hostname or \"\").strip().lower()\n    if not host or host in (\"localhost\",):\n        return False\n    # Block bare IP strings that are themselves private\n    try:\n        ip = ipaddress.ip_address(host)\n        return not (ip.is_loopback or ip.is_private or ip.is_link_local\n                    or ip.is_multicast or ip.is_reserved or ip.is_unspecified)\n    except ValueError:\n        pass\n    # Hostname: resolve and check every returned address\n    try:\n        infos = socket.getaddrinfo(host, None)\n    except socket.gaierror:\n        return False\n    for info in infos:\n        addr = info[4][0]\n        try:\n            ip = ipaddress.ip_address(addr.split(\"%\")[0])  # strip scope id on v6\n        except ValueError:\n            return False\n        if (ip.is_loopback or ip.is_private or ip.is_link_local\n                or ip.is_multicast or ip.is_reserved or ip.is_unspecified):\n            return False\n    return True\n\n\nclass CompetitorService:\n    def __init__(self, cfg: MaggyConfig):\n        self.cfg = cfg\n        self.competitors_path = Path(cfg.storage.path).expanduser().parent / \"competitors.json\"\n        self.db_path = Path(cfg.storage.path).expanduser()\n        self._init_db()\n\n    def _init_db(self) -> None:\n        with _connect_sqlite(self.db_path) as db:\n            db.execute(\"\"\"\n                CREATE TABLE IF NOT EXISTS competitor_news (\n                    id TEXT PRIMARY KEY,\n                    competitor_id TEXT NOT NULL,\n                    competitor_name TEXT NOT NULL,\n                    event_type TEXT NOT NULL,\n                    title TEXT NOT NULL,\n                    url TEXT,\n                    source TEXT,\n                    created_at TEXT NOT NULL\n                )\n            \"\"\")\n            db.execute(\"CREATE INDEX IF NOT EXISTS idx_news_created ON competitor_news(created_at DESC)\")\n            db.execute(\"\"\"\n                CREATE TABLE IF NOT EXISTS briefing_cache (\n                    date TEXT PRIMARY KEY,\n                    summary TEXT NOT NULL,\n                    signal_count INTEGER NOT NULL,\n                    generated_at TEXT NOT NULL\n                )\n            \"\"\")\n            db.execute(\"\"\"\n                CREATE TABLE IF NOT EXISTS feed_cursors (\n                    feed_key TEXT PRIMARY KEY,\n                    cursor TEXT NOT NULL\n                )\n            \"\"\")\n\n    # ── Registry ─────────────────────────────────────────────────────────\n\n    def load_registry(self) -> dict[str, dict]:\n        if not self.competitors_path.exists():\n            return {}\n        try:\n            return json.loads(self.competitors_path.read_text())\n        except Exception:\n            return {}\n\n    def save_registry(self, registry: dict[str, dict]) -> None:\n        self.competitors_path.parent.mkdir(parents=True, exist_ok=True)\n        self.competitors_path.write_text(json.dumps(registry, indent=2))\n\n    # ── Discovery ────────────────────────────────────────────────────────\n\n    async def discover(self) -> dict:\n        \"\"\"Ask Claude to identify competitors in the configured domain categories.\n\n        Stores results in ~/.maggy/competitors.json (merges with existing).\n        \"\"\"\n        if not self.cfg.competitors.categories:\n            return {\"error\": \"No competitor categories configured\", \"added\": 0}\n\n        registry = self.load_registry()\n        before = len(registry)\n\n        categories = self.cfg.competitors.categories\n        seed = self.cfg.competitors.seed\n        org_name = self.cfg.org.name\n\n        prompt = f\"\"\"Identify competitors for {org_name}, operating in these categories: {', '.join(categories)}.\n{f\"User already mentioned: {', '.join(seed)}. Include these and add more.\" if seed else \"\"}\n\nReturn 12-18 competitors as JSON. Include a mix of:\n- Established market leaders\n- AI-first challengers / next-gen disruptors\n- Vertical-specific specialists\n\nFormat (STRICT JSON):\n{{\"competitors\": [\n  {{\n    \"id\": \"lowercase-slug\",\n    \"name\": \"Display Name\",\n    \"category\": \"One of: {' | '.join(categories)}\",\n    \"website\": \"example.com\",\n    \"description\": \"One-sentence positioning\",\n    \"strengths\": [\"str1\", \"str2\", \"str3\"],\n    \"weaknesses\": [\"w1\", \"w2\"],\n    \"tags\": [\"tag1\", \"tag2\"],\n    \"blog_rss\": \"optional RSS URL or null\"\n  }}\n]}}\"\"\"\n\n        try:\n            text = await ai_complete(prompt, self.cfg)\n            if not text:\n                return {\"error\": \"No AI provider available\", \"added\": 0}\n            start = text.find(\"{\")\n            end = text.rfind(\"}\")\n            data = json.loads(text[start:end + 1])\n        except Exception as e:\n            logger.error(\"Discovery failed: %s\", e)\n            return {\"error\": str(e), \"added\": 0}\n\n        for comp in data.get(\"competitors\", []):\n            cid = comp.get(\"id\", \"\").lower()\n            if not cid:\n                continue\n            # Preserve blog_rss inside a social sub-dict for monitoring\n            rss = comp.pop(\"blog_rss\", None)\n            if rss:\n                comp[\"social\"] = {\"blog_rss\": rss}\n            # Merge (don't overwrite existing manual edits)\n            if cid in registry:\n                registry[cid].setdefault(\"social\", {})\n                if rss and not registry[cid][\"social\"].get(\"blog_rss\"):\n                    registry[cid][\"social\"][\"blog_rss\"] = rss\n            else:\n                registry[cid] = comp\n\n        self.save_registry(registry)\n        return {\"total\": len(registry), \"added\": len(registry) - before}\n\n    def list_all(self) -> list[dict]:\n        return list(self.load_registry().values())\n\n    # ── Monitoring ───────────────────────────────────────────────────────\n\n    async def monitor_all(self) -> dict:\n        \"\"\"Scan RSS + Google News for all competitors. Called by heartbeat or on-demand.\"\"\"\n        registry = self.load_registry()\n        rss_new = 0\n        news_new = 0\n        for cid, comp in registry.items():\n            try:\n                rss_new += await self._check_rss(cid, comp)\n            except Exception as e:\n                logger.debug(\"RSS %s: %s\", cid, e)\n            try:\n                news_new += await self._check_google_news(cid, comp)\n            except Exception as e:\n                logger.debug(\"News %s: %s\", cid, e)\n        return {\"rss\": rss_new, \"news\": news_new, \"total_competitors\": len(registry)}\n\n    def _get_cursor(self, key: str) -> str:\n        with _connect_sqlite(self.db_path) as db:\n            row = db.execute(\"SELECT cursor FROM feed_cursors WHERE feed_key = ?\", (key,)).fetchone()\n        return row[0] if row else \"\"\n\n    def _set_cursor(self, key: str, cursor: str) -> None:\n        with _connect_sqlite(self.db_path) as db:\n            db.execute(\n                \"INSERT INTO feed_cursors (feed_key, cursor) VALUES (?, ?) \"\n                \"ON CONFLICT(feed_key) DO UPDATE SET cursor = excluded.cursor\",\n                (key, cursor),\n            )\n\n    def _classify(self, title: str) -> str:\n        t = title.lower()\n        if any(w in t for w in [\"launch\", \"release\", \"introduces\", \"announces new\", \"ships\"]):\n            return \"feature_launch\"\n        if any(w in t for w in [\"pricing\", \"price\", \"cost\", \"free tier\"]):\n            return \"pricing_change\"\n        if any(w in t for w in [\"funding\", \"raises\", \"series\", \"valuation\", \"investment\"]):\n            return \"funding\"\n        if any(w in t for w in [\"acquir\", \"acquisition\", \"merge\", \"bought\"]):\n            return \"acquisition\"\n        if any(w in t for w in [\"partner\", \"integration with\", \"teams up\"]):\n            return \"partnership\"\n        return \"news\"\n\n    def _log_event(self, competitor_id: str, competitor_name: str, event_type: str, title: str, url: str, source: str) -> None:\n        # Deterministic ID so the same article logged twice (cursor reset,\n        # overlapping scans) becomes a no-op instead of a duplicate row.\n        id_seed = f\"{competitor_id}|{source}|{url or title}\"\n        event_id = hashlib.sha256(id_seed.encode(\"utf-8\")).hexdigest()[:32]\n        with _connect_sqlite(self.db_path) as db:\n            db.execute(\n                \"INSERT OR IGNORE INTO competitor_news \"\n                \"(id, competitor_id, competitor_name, event_type, title, url, source, created_at) \"\n                \"VALUES (?, ?, ?, ?, ?, ?, ?, ?)\",\n                (event_id, competitor_id, competitor_name, event_type, title, url, source,\n                 datetime.now(timezone.utc).isoformat()),\n            )\n\n    async def _check_rss(self, cid: str, comp: dict) -> int:\n        rss_url = (comp.get(\"social\") or {}).get(\"blog_rss\")\n        if not rss_url:\n            return 0\n        if not _is_safe_feed_url(rss_url):\n            logger.warning(\"Skipping unsafe RSS URL for %s: %s\", cid, rss_url)\n            return 0\n        cursor_key = f\"rss:{cid}\"\n        last_cursor = self._get_cursor(cursor_key)\n\n        try:\n            async with httpx.AsyncClient(timeout=15) as client:\n                resp = await client.get(rss_url)\n                if resp.status_code >= 400:\n                    return 0\n                feed = feedparser.parse(resp.text)\n        except Exception:\n            return 0\n\n        # Cursor is stored as an ISO-8601 UTC string so comparisons are\n        # valid lexicographically AND survive round-trips through SQLite.\n        last_cursor_dt = _parse_feed_date(last_cursor) if last_cursor else None\n        new_items = 0\n        latest_dt = last_cursor_dt\n        for entry in feed.entries[:10]:\n            pub_raw = entry.get(\"published\", entry.get(\"updated\", \"\"))\n            pub_dt = _parse_feed_date(pub_raw)\n            # Skip entries already seen (we have a cursor AND the entry's parsed date is ≤ cursor).\n            # Entries without a parseable date are always processed — INSERT OR IGNORE dedupes.\n            if pub_dt and last_cursor_dt and pub_dt <= last_cursor_dt:\n                continue\n            title = entry.get(\"title\", \"\")\n            link = entry.get(\"link\", \"\")\n            if pub_dt and (latest_dt is None or pub_dt > latest_dt):\n                latest_dt = pub_dt\n            self._log_event(cid, comp.get(\"name\", cid), \"blog_post\", f\"{comp.get('name','')}: {title}\", link, \"rss\")\n            new_items += 1\n\n        if latest_dt and latest_dt != last_cursor_dt:\n            self._set_cursor(cursor_key, latest_dt.isoformat())\n        return new_items\n\n    async def _check_google_news(self, cid: str, comp: dict) -> int:\n        name = comp.get(\"name\", \"\")\n        if not name:\n            return 0\n        cursor_key = f\"news:{cid}\"\n        last_cursor = self._get_cursor(cursor_key)\n\n        # Use domain + category for better relevance — e.g. \"Sprinklr CX\" not \"Sprinklr software\"\n        category = (comp.get(\"category\") or \"\").replace(\"_\", \" \").split(\"/\")[0]\n        search_term = f\"{name} {category}\" if category else f\"{name} {self.cfg.org.domain}\"\n        url = f\"https://news.google.com/rss/search?q={quote(search_term)}&hl=en-US&gl=US&ceid=US:en\"\n\n        try:\n            async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:\n                resp = await client.get(url, headers={\"User-Agent\": \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36\"})\n                if resp.status_code >= 400:\n                    return 0\n                feed = feedparser.parse(resp.text)\n        except Exception:\n            return 0\n\n        last_cursor_dt = _parse_feed_date(last_cursor) if last_cursor else None\n        new_items = 0\n        latest_dt = last_cursor_dt\n        for entry in feed.entries[:5]:\n            pub_dt = _parse_feed_date(entry.get(\"published\", \"\"))\n            if pub_dt and last_cursor_dt and pub_dt <= last_cursor_dt:\n                continue\n            title = entry.get(\"title\", \"\")\n            link = entry.get(\"link\", \"\")\n            if pub_dt and (latest_dt is None or pub_dt > latest_dt):\n                latest_dt = pub_dt\n            self._log_event(cid, name, self._classify(title), f\"{name}: {title}\", link, \"google_news\")\n            new_items += 1\n\n        if latest_dt and latest_dt != last_cursor_dt:\n            self._set_cursor(cursor_key, latest_dt.isoformat())\n        return new_items\n\n    # ── News query ───────────────────────────────────────────────────────\n\n    def get_news(self, limit: int = 100) -> list[dict]:\n        with _connect_sqlite(self.db_path) as db:\n            db.row_factory = sqlite3.Row\n            rows = db.execute(\n                \"SELECT * FROM competitor_news ORDER BY created_at DESC LIMIT ?\",\n                (limit,),\n            ).fetchall()\n        return [dict(r) for r in rows]\n\n    # ── Daily briefing (cached per day) ──────────────────────────────────\n\n    async def get_daily_briefing(self, refresh: bool = False) -> dict:\n        today = date.today().isoformat()\n\n        if not refresh:\n            with _connect_sqlite(self.db_path) as db:\n                row = db.execute(\n                    \"SELECT summary, signal_count, generated_at FROM briefing_cache WHERE date = ?\",\n                    (today,),\n                ).fetchone()\n            if row:\n                return {\"date\": today, \"summary\": row[0], \"total_signals\": row[1], \"generated_at\": row[2]}\n\n        # Regenerate\n        news = self.get_news(limit=80)\n        if not news:\n            return {\"date\": today, \"summary\": \"No competitor news yet. Run a scan first.\", \"total_signals\": 0}\n        digest = [f\"[{n['event_type']}] {n['competitor_name']}: {n['title']}\" for n in news[:50]]\n        domain = self.cfg.org.domain or \"our domain\"\n\n        prompt = f\"\"\"You are the competitive intelligence analyst for {self.cfg.org.name} in the {domain} space.\n\nWrite a daily competitive landscape briefing for {today}. Structure:\n\n1. **Top Signals Today** — 3-5 most important moves (acquisitions, launches, partnerships) with specific competitor names\n2. **Market Trends** — patterns across multiple signals (AI adoption, consolidation, pricing shifts)\n3. **Implications for {self.cfg.org.name}** — 2-3 specific, actionable takeaways\n\nBe specific with competitor names and facts. No generic advice. Under 250 words.\n\nSignals ({len(digest)} total):\n{chr(10).join(digest)}\"\"\"\n\n        try:\n            summary = await ai_complete(prompt, self.cfg)\n            if not summary:\n                return {\"date\": today, \"summary\": \"No AI provider available for briefing.\", \"total_signals\": len(news)}\n        except Exception as e:\n            return {\"date\": today, \"summary\": f\"Failed to generate briefing: {e}\", \"total_signals\": len(news)}\n\n        generated_at = datetime.now(timezone.utc).isoformat()\n        with _connect_sqlite(self.db_path) as db:\n            db.execute(\n                \"INSERT INTO briefing_cache (date, summary, signal_count, generated_at) VALUES (?, ?, ?, ?) \"\n                \"ON CONFLICT(date) DO UPDATE SET summary = excluded.summary, signal_count = excluded.signal_count, generated_at = excluded.generated_at\",\n                (today, summary, len(news), generated_at),\n            )\n\n        return {\"date\": today, \"summary\": summary, \"total_signals\": len(news), \"generated_at\": generated_at}\n"
  },
  {
    "path": "maggy/maggy/services/context_compactor.py",
    "content": "\"\"\"Context compactor — summarize old messages to fit context window.\n\nWhen conversation length exceeds 80% of the model's context window,\nold messages are summarized into a single system message while keeping\nthe most recent messages intact.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport logging\nfrom dataclasses import dataclass\nfrom typing import Awaitable, Callable\n\nlogger = logging.getLogger(__name__)\n\nCOMPACT_THRESHOLD = 0.80\nCHARS_PER_TOKEN = 4\n\nSummarizerFn = Callable[[str], Awaitable[str]]\n\n\n@dataclass\nclass CompactionResult:\n    messages: list[dict]\n    tokens_saved: int = 0\n    summary: str = \"\"\n\n\ndef estimate_tokens(messages: list[dict]) -> int:\n    \"\"\"Rough token estimate based on char count / 4.\"\"\"\n    total = sum(len(m.get(\"content\", \"\")) for m in messages)\n    return total // CHARS_PER_TOKEN\n\n\ndef should_compact(messages: list[dict], context_window: int) -> bool:\n    \"\"\"Check if messages exceed 80% of context window.\"\"\"\n    tokens = estimate_tokens(messages)\n    return tokens > int(context_window * COMPACT_THRESHOLD)\n\n\nasync def compact(\n    messages: list[dict],\n    keep_recent: int = 6,\n    summarizer: SummarizerFn | None = None,\n) -> CompactionResult:\n    \"\"\"Summarize old messages, keep recent ones.\"\"\"\n    if len(messages) <= keep_recent:\n        return CompactionResult(messages=messages)\n    old = messages[:-keep_recent]\n    recent = messages[-keep_recent:]\n    old_text = _format_for_summary(old)\n    old_tokens = estimate_tokens(old)\n    try:\n        if summarizer is None:\n            return CompactionResult(messages=messages)\n        summary = await summarizer(old_text)\n    except Exception as exc:\n        logger.debug(\"Compaction failed: %s\", exc)\n        return CompactionResult(messages=messages)\n    summary_msg = {\"role\": \"system\", \"content\": summary}\n    new_tokens = estimate_tokens([summary_msg])\n    return CompactionResult(\n        messages=[summary_msg, *recent],\n        tokens_saved=max(0, old_tokens - new_tokens),\n        summary=summary,\n    )\n\n\ndef _format_for_summary(messages: list[dict]) -> str:\n    \"\"\"Format messages into text for summarization.\"\"\"\n    parts: list[str] = []\n    for m in messages:\n        role = m.get(\"role\", \"unknown\")\n        content = m.get(\"content\", \"\")[:500]\n        parts.append(f\"{role}: {content}\")\n    return \"\\n\".join(parts)\n"
  },
  {
    "path": "maggy/maggy/services/convention_inferrer.py",
    "content": "\"\"\"LLM-based dynamic convention inference from project fingerprint.\n\nCollects filesystem signals (file tree, config snippets, git log)\nand sends them to a cheap/local model to infer project-specific\nconventions that the static rule table doesn't cover.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport logging\nimport re\nimport subprocess\nfrom pathlib import Path\nfrom typing import TYPE_CHECKING\n\nif TYPE_CHECKING:\n    from maggy.adapters.pi import PiAdapter\n    from maggy.routing_rules import Convention, RoutingRules\n\nlogger = logging.getLogger(__name__)\n\nMAX_CONVENTIONS = 10\nMAX_FINGERPRINT = 4000\nFALLBACK_MODELS = [\"local\", \"kimi\"]\n\nSKIP_DIRS = frozenset({\n    \".git\", \"node_modules\", \"__pycache__\", \".venv\",\n    \"venv\", \"dist\", \"build\", \".next\", \".cache\",\n    \".tox\", \".mypy_cache\", \".ruff_cache\", \"egg-info\",\n})\n\nCONFIG_FILES = [\n    \"pyproject.toml\", \"package.json\", \"Makefile\",\n    \"docker-compose.yml\", \"Dockerfile\", \"tsconfig.json\",\n    \".env.example\", \"Cargo.toml\", \"go.mod\", \"Gemfile\",\n    \"mix.exs\", \"build.gradle\", \"pom.xml\",\n]\n\nPROMPT_TEMPLATE = (\n    \"Analyze this project and list its development conventions.\\n\"\n    \"Each convention must be one line starting with '- '.\\n\"\n    \"Focus on: build tools, test runners, deployment, migrations,\\n\"\n    \"package managers, CI/CD, linting, coding patterns.\\n\"\n    \"Be specific — mention exact commands and tool names.\\n\"\n    \"Max 10 conventions. No explanations, just the list.\\n\\n\"\n    \"{fingerprint}\"\n)\n\n\ndef collect_fingerprint(working_dir: str) -> str:\n    \"\"\"Build compact project fingerprint for LLM analysis.\"\"\"\n    root = Path(working_dir)\n    parts = [_file_tree(root), _config_snippets(root), _git_log(root)]\n    return \"\\n\".join(p for p in parts if p)[:MAX_FINGERPRINT]\n\n\ndef parse_conventions(text: str) -> list[Convention]:\n    \"\"\"Extract '- convention' lines from LLM response.\"\"\"\n    from maggy.routing_rules import Convention as Conv\n\n    convs: list[Conv] = []\n    for line in text.splitlines():\n        m = re.match(r\"^-\\s+(.{5,200})$\", line.strip())\n        if m:\n            convs.append(Conv(m.group(1).strip(), [\"all\"], \"llm-inferred\"))\n        if len(convs) >= MAX_CONVENTIONS:\n            break\n    return convs\n\n\nasync def infer_conventions(\n    pi: PiAdapter, working_dir: str,\n) -> list[Convention]:\n    \"\"\"Send fingerprint to LLM, parse conventions from response.\"\"\"\n    fp = collect_fingerprint(working_dir)\n    if len(fp.strip()) < 20:\n        return []\n    prompt = PROMPT_TEMPLATE.format(fingerprint=fp)\n    for model in FALLBACK_MODELS:\n        result = await pi.send_prompt(model, prompt, working_dir, max_turns=1, timeout=60)\n        if result.success and result.output.strip():\n            return parse_conventions(result.output)\n        logger.debug(\"Inference failed on %s: %s\", model, result.error)\n    return []\n\n\nasync def ensure_inferred(\n    rules: RoutingRules, project_key: str,\n    working_dir: str, pi: PiAdapter,\n) -> None:\n    \"\"\"Run LLM inference if not already cached for this project.\"\"\"\n    if not project_key:\n        return\n    existing = rules.project_conventions.get(project_key, [])\n    if any(c.source == \"llm-inferred\" for c in existing):\n        return\n    try:\n        convs = await infer_conventions(pi, working_dir)\n    except Exception as exc:\n        logger.warning(\"Convention inference failed: %s\", exc)\n        return\n    if not convs:\n        return\n    existing_texts = {c.text for c in existing}\n    new = [c for c in convs if c.text not in existing_texts]\n    rules.project_conventions.setdefault(project_key, []).extend(new)\n\n\ndef _file_tree(root: Path) -> str:\n    \"\"\"List files/dirs to depth 2, excluding noise.\"\"\"\n    lines = [\"## Project Files\"]\n    try:\n        for p in sorted(root.iterdir()):\n            if p.name in SKIP_DIRS or p.name.startswith(\".\"):\n                continue\n            lines.append(p.name + (\"/\" if p.is_dir() else \"\"))\n            if p.is_dir():\n                for child in sorted(p.iterdir()):\n                    if child.name in SKIP_DIRS:\n                        continue\n                    lines.append(f\"  {child.name}\")\n    except OSError:\n        pass\n    return \"\\n\".join(lines[:80])\n\n\ndef _config_snippets(root: Path) -> str:\n    \"\"\"Read first 300 chars of known config files.\"\"\"\n    parts: list[str] = []\n    for name in CONFIG_FILES:\n        path = root / name\n        if path.is_file():\n            try:\n                text = path.read_text(errors=\"ignore\")[:300]\n                parts.append(f\"## {name}\\n{text}\")\n            except OSError:\n                continue\n    return \"\\n\".join(parts)\n\n\ndef _git_log(root: Path) -> str:\n    \"\"\"Recent commit messages via git log --oneline -10.\"\"\"\n    if not (root / \".git\").is_dir():\n        return \"\"\n    try:\n        r = subprocess.run(\n            [\"git\", \"log\", \"--oneline\", \"-10\"],\n            cwd=root, capture_output=True, text=True, timeout=5,\n        )\n        if r.returncode == 0 and r.stdout.strip():\n            return f\"## Recent Commits\\n{r.stdout.strip()}\"\n    except (OSError, subprocess.TimeoutExpired):\n        pass\n    return \"\"\n"
  },
  {
    "path": "maggy/maggy/services/convention_scanner.py",
    "content": "\"\"\"Declarative filesystem scanner for project-specific conventions.\n\nScans a project directory for config files, lock files, and directory\nstructures to auto-detect tooling conventions (e.g. supabase vs alembic,\nnpm vs pnpm, pytest vs jest).\n\"\"\"\n\nfrom __future__ import annotations\n\nimport re\nfrom dataclasses import dataclass, field\nfrom pathlib import Path\nfrom typing import TYPE_CHECKING\n\nif TYPE_CHECKING:\n    from maggy.routing_rules import Convention, RoutingRules\n\n\n@dataclass\nclass ScanRule:\n    \"\"\"A filesystem marker that implies a convention.\"\"\"\n\n    marker: str\n    convention: str\n    applies_to: list[str] = field(default_factory=lambda: [\"all\"])\n    content_match: str = \"\"\n    is_dir: bool = False\n\n\nRULES: list[ScanRule] = [\n    ScanRule(\n        \"supabase/migrations\", is_dir=True,\n        convention=\"Use `supabase db push` for migrations. RLS policies required.\",\n    ),\n    ScanRule(\n        \"alembic.ini\",\n        convention=\"Use `alembic revision --autogenerate` for schema changes.\",\n    ),\n    ScanRule(\n        \"package-lock.json\",\n        convention=\"Package manager: npm. Use `npm install`, not yarn/pnpm.\",\n    ),\n    ScanRule(\n        \"pnpm-lock.yaml\",\n        convention=\"Package manager: pnpm. Use `pnpm install`, not npm/yarn.\",\n    ),\n    ScanRule(\n        \"yarn.lock\",\n        convention=\"Package manager: yarn. Use `yarn add`, not npm/pnpm.\",\n    ),\n    ScanRule(\n        \"pyproject.toml\", content_match=r\"\\[tool\\.ruff\\]\",\n        convention=\"Linter: ruff. Run `ruff check .` before committing.\",\n    ),\n    ScanRule(\n        \"pyproject.toml\", content_match=r\"\\[tool\\.pytest\",\n        convention=\"Testing: pytest. Run `pytest` for tests.\",\n        applies_to=[\"feature\", \"bug\", \"all\"],\n    ),\n    ScanRule(\n        \"pytest.ini\",\n        convention=\"Testing: pytest. Run `pytest` for tests.\",\n        applies_to=[\"feature\", \"bug\", \"all\"],\n    ),\n    ScanRule(\n        \"docker-compose.yml\",\n        convention=\"Use Docker Compose for local services. `docker compose up -d`.\",\n    ),\n    ScanRule(\n        \".github/workflows\", is_dir=True,\n        convention=\"CI: GitHub Actions. Check workflow status before merging.\",\n    ),\n    ScanRule(\n        \"Makefile\",\n        convention=\"Project uses Make. Check `make help` for available targets.\",\n    ),\n    ScanRule(\n        \"tailwind.config.js\",\n        convention=\"Styling: Tailwind CSS. Use utility classes, not custom CSS.\",\n        applies_to=[\"feature\"],\n    ),\n    ScanRule(\n        \"tailwind.config.ts\",\n        convention=\"Styling: Tailwind CSS. Use utility classes, not custom CSS.\",\n        applies_to=[\"feature\"],\n    ),\n]\n\n\ndef scan_project(working_dir: str) -> list[Convention]:\n    \"\"\"Scan project directory, return detected conventions.\"\"\"\n    from maggy.routing_rules import Convention as Conv\n\n    root = Path(working_dir)\n    found: list[Conv] = []\n    seen: set[str] = set()\n    for rule in RULES:\n        if not _matches(root, rule):\n            continue\n        if rule.convention in seen:\n            continue\n        seen.add(rule.convention)\n        found.append(Conv(rule.convention, list(rule.applies_to), \"auto-detected\"))\n    return found\n\n\ndef ensure_scanned(\n    rules: RoutingRules, project_key: str, working_dir: str,\n) -> None:\n    \"\"\"Scan project if not already cached in rules.\"\"\"\n    if project_key in rules.project_conventions:\n        return\n    convs = scan_project(working_dir)\n    rules.project_conventions[project_key] = convs\n\n\ndef _matches(root: Path, rule: ScanRule) -> bool:\n    \"\"\"Check if a scan rule matches the project directory.\"\"\"\n    target = root / rule.marker\n    if rule.is_dir:\n        return target.is_dir()\n    if not target.is_file():\n        return False\n    if not rule.content_match:\n        return True\n    try:\n        text = target.read_text(errors=\"ignore\")[:4096]\n        return bool(re.search(rule.content_match, text))\n    except OSError:\n        return False\n"
  },
  {
    "path": "maggy/maggy/services/executor.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nimport logging\nimport uuid\nfrom datetime import datetime, timezone\nfrom pathlib import Path\n\nfrom maggy.adapters.pi import PiAdapter, RunResult\nfrom maggy.budget import BudgetManager\nfrom maggy.checkpoint import CheckpointManager\nfrom maggy.config import MaggyConfig\nfrom maggy.coordination.lock_manager import LockManager\nfrom maggy.escalation.protocol import Escalator\nfrom maggy.mnemos.fatigue import FatigueTracker\nfrom maggy.mnemos.signals import SignalLog\nfrom maggy.providers.base import IssueTrackerProvider\nfrom maggy.recovery.rollback import RollbackManager\nfrom maggy.routing import RoutingService\nfrom maggy.services import executor_helpers as H\nfrom maggy.services import executor_prompts as P\nfrom maggy.services.executor_types import SessionCtx, StepSpec\nfrom maggy.services.planner import DualPlanner\n\nlogger = logging.getLogger(__name__)\n\n\nclass ExecutorService:\n    def __init__(self, cfg: MaggyConfig, provider: IssueTrackerProvider, status_cb=None):\n        self.cfg, self.provider = cfg, provider\n        self._pi = PiAdapter()\n        self._routing = RoutingService(cfg)\n        self._budget = BudgetManager(cfg)\n        self._sessions: dict[str, dict] = {}\n        self._bg_tasks: set[asyncio.Task] = set()\n        db = Path(cfg.storage.path).expanduser().parent\n        self._fatigue = FatigueTracker()\n        self._signals = SignalLog(db / \"signals.jsonl\")\n        self._locks = LockManager(db / \"locks.db\")\n        self._rollback = RollbackManager()\n        self._checkpoint = CheckpointManager(db / \"checkpoints\")\n        self._escalator = Escalator(db / \"escalations.db\")\n        self._planner, self._status_cb = DualPlanner(self._pi), status_cb\n    async def start(self, task_id: str, mode: str = \"tdd\",\n                    working_dir: str | None = None) -> str:\n        if mode not in (\"tdd\", \"plan\"):\n            raise ValueError(f\"Unknown mode {mode!r}\")\n        task = await self.provider.get_task(task_id)\n        if not task:\n            raise ValueError(f\"Task {task_id} not found\")\n        wd = H.resolve_working_dir(self.cfg, working_dir, task)\n        sid = uuid.uuid4().hex[:10]\n        self._sessions[sid] = dict(\n            id=sid, task_id=task_id, task_title=task.title, mode=mode,\n            working_dir=wd, status=\"running\",\n            started_at=datetime.now(timezone.utc).isoformat(), output=\"\")\n        self._locks.acquire(wd, sid)\n        ctx = SessionCtx(self._sessions[sid], task, wd)\n        bg = asyncio.create_task(self._run(ctx, mode))\n        self._bg_tasks.add(bg)\n        bg.add_done_callback(self._bg_tasks.discard)\n        return sid\n\n    def get_session(self, sid: str) -> dict | None: return self._sessions.get(sid)\n    def list_sessions(self) -> list[dict]: return list(self._sessions.values())\n    async def _run(self, ctx: SessionCtx, mode: str) -> None:\n        try:\n            from maggy.services.convention_inferrer import ensure_inferred\n            from maggy.services.convention_scanner import ensure_scanned\n            pk = str(ctx.task.raw.get(\"project_key\", \"\"))\n            ensure_scanned(self._routing.rules, pk, ctx.wd)\n            await ensure_inferred(self._routing.rules, pk, ctx.wd, self._pi)\n            ctx.icpg = await H.build_icpg_context(self.cfg, ctx.task)\n            await (self._run_plan(ctx) if mode == \"plan\" else self._run_tdd(ctx))\n        except Exception as e:\n            logger.exception(\"Execution failed\")\n            ctx.session[\"status\"], ctx.session[\"error\"] = \"failed\", str(e)\n        finally:\n            self._locks.release_all(ctx.session[\"id\"])\n            self._checkpoint.delete(ctx.task.id.replace(\"/\", \"-\"))\n\n    async def _run_plan(self, ctx: SessionCtx) -> None:\n        result = await self._run_model(ctx, P.plan_prompt(ctx.task, ctx.icpg, self._routing), 5)\n        ctx.session[\"output\"] = result.output[:10000]\n        ctx.session[\"status\"] = \"completed\" if result.success else \"failed\"\n        if not result.success:\n            ctx.session[\"error\"] = result.output[:500]\n        elif result.output:\n            await H.post_plan(self.provider, ctx.task.id, result.output)\n\n    async def _run_tdd(self, ctx: SessionCtx) -> None:\n        if H.blast_score(ctx.task) >= 7:\n            await self._dual_plan(ctx)\n        prompt = P.analysis_prompt(ctx.task, ctx.icpg, self._routing)\n        ok, analysis = await self._reviewed_step(ctx, StepSpec(\"ANALYZE\", prompt, 5))\n        if not ok:\n            return\n        prompt = P.tests_prompt(ctx.task, ctx.icpg, analysis, self._routing)\n        ok, _ = await self._reviewed_step(ctx, StepSpec(\"WRITE TESTS\", prompt, 15))\n        if not ok:\n            return\n        if not await self._verify_red(ctx):\n            return\n        await H.save_rollback(self._rollback, ctx.session[\"id\"], ctx.wd)\n        prompt = P.impl_prompt(ctx.task, ctx.icpg, self._routing)\n        ok, _ = await self._reviewed_step(ctx, StepSpec(\"IMPLEMENT\", prompt, 25))\n        if not ok:\n            await H.try_rollback(self._rollback, ctx.session[\"id\"], ctx.wd)\n            H.maybe_escalate(self._escalator, ctx.session, ctx.task)\n            return\n        if not await self._verify_green(ctx):\n            await H.try_rollback(self._rollback, ctx.session[\"id\"], ctx.wd)\n            return\n        ctx.session[\"status\"] = \"completed\"\n        ctx.session[\"completed_at\"] = datetime.now(timezone.utc).isoformat()\n\n    async def _reviewed_step(self, ctx: SessionCtx, step: StepSpec) -> tuple[bool, str]:\n        for attempt in range(2):\n            ok, output = await self._run_step(ctx, step)\n            if not ok:\n                return ok, output\n            if await self._review_step(ctx, step, output):\n                return True, output\n            if attempt == 0:\n                ctx.session[\"output\"] += f\"\\n--- RETRY {step.label} ---\\n\"\n        ctx.session.update(status=\"failed\", error=f\"Review gate failed for {step.label}\")\n        return False, output\n\n    async def _run_step(self, ctx: SessionCtx, step: StepSpec) -> tuple[bool, str]:\n        result = await self._run_model(ctx, step.prompt, step.max_turns)\n        ctx.session[\"output\"] += f\"\\n=== {step.label} ===\\n{result.output[:2000]}\\n\"\n        H.log_signal(self._signals, ctx.session[\"id\"], step.label, result)\n        if not result.success:\n            ctx.session[\"status\"] = \"failed\"\n        return result.success, result.output\n\n    async def _review_step(self, ctx: SessionCtx, step: StepSpec, output: str) -> bool:\n        from maggy.services.output_reviewer import review_output\n        review = await review_output(self._pi, step.label, output, ctx.wd)\n        ctx.session[\"output\"] += f\"\\n--- REVIEW {step.label}: {review.score}/5 ---\\n\"\n        return review.score >= 3\n\n    async def _run_model(self, ctx: SessionCtx, prompt: str, turns: int) -> RunResult:\n        decision = H.route_model(ctx.task, self._routing)\n        name = H.model_name(decision.primary)\n        H.write_checkpoint(self._checkpoint, ctx.task, name)\n        self._emit_status(name, \"running\")\n        result = await self._send(decision, name, prompt, ctx)\n        self._emit_status(name, \"done\")\n        if result.model != name and (e := self._pi.get_model(result.model)):\n            self._fatigue.on_model_switch(e.context_window)\n        H.track_fatigue(self._fatigue, result)\n        if result.cost_usd > 0 or result.input_tokens > 0:\n            self._budget.record_spend(\n                decision.primary.provider, result.model, result.cost_usd,\n                result.input_tokens, result.output_tokens)\n        return result\n\n    async def _send(self, decision, name, prompt, ctx):\n        cascade = self._routing.rules.cascade\n        if not cascade.enabled or H.blast_score(ctx.task) < cascade.min_blast:\n            return await self._pi.send_with_fallback(name, prompt, ctx.wd)\n        from maggy.services.cascade import cascade_execute\n        from maggy.services.output_reviewer import review_output\n        chain = [name] + decision.fallback_chain\n\n        async def gate(output: str) -> int:\n            return (await review_output(self._pi, \"CASCADE\", output, ctx.wd)).score\n        cr = await cascade_execute(self._pi, chain, prompt, ctx.wd, gate)\n        return RunResult(model=cr.model, success=bool(cr.output), output=cr.output, cost_usd=cr.cost_usd)\n\n    def _emit_status(self, agent: str, status: str) -> None:\n        if self._status_cb:\n            self._status_cb({\"type\": \"agent_status\", \"agent\": agent, \"status\": status})\n    async def _verify_red(self, ctx: SessionCtx) -> bool:\n        from maggy.services.tdd_verifier import verify_tests_exist, verify_tests_fail\n        for check, prefix in [(verify_tests_exist, \"RED: no tests\"), (verify_tests_fail, \"RED\")]:\n            r = await check(ctx.wd)\n            if not r.passed:\n                ctx.session[\"status\"], ctx.session[\"error\"] = \"failed\", f\"{prefix}: {r.detail}\"\n                return False\n        ctx.session[\"output\"] += f\"\\n=== RED ===\\n{r.detail}\\n\"\n        return True\n    async def _verify_green(self, ctx: SessionCtx) -> bool:\n        from maggy.services.tdd_verifier import verify_coverage, verify_lint, verify_tests_pass\n        if not (green := await verify_tests_pass(ctx.wd)).passed:\n            ctx.session[\"status\"], ctx.session[\"error\"] = \"failed\", f\"GREEN: {green.detail}\"\n            return False\n        for label, check in [(\"LINT\", verify_lint), (\"COVERAGE\", verify_coverage)]:\n            if not (r := await check(ctx.wd)).passed:\n                ctx.session[\"output\"] += f\"\\n=== {label} ===\\n{r.detail}\\n\"\n        ctx.session[\"output\"] += \"\\n=== VALIDATE ===\\nPassed\\n\"\n        return True\n\n    async def _dual_plan(self, ctx: SessionCtx) -> None:\n        try:\n            r = await self._planner.dual_plan(ctx.task.title, ctx.task.description[:1500], ctx.wd)\n            ctx.session.update(dual_plan=r.primary_plan[:2000], plan_conflicts=r.conflicts or [])\n        except Exception as exc:\n            logger.warning(\"DualPlanner failed: %s\", exc)\n"
  },
  {
    "path": "maggy/maggy/services/executor_helpers.py",
    "content": "\"\"\"Executor helpers — routing, rollback, fatigue, iCPG.\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport logging\nfrom typing import TYPE_CHECKING\n\nfrom maggy.adapters.pi import RunResult\nfrom maggy.mnemos.fatigue import FatigueTracker\nfrom maggy.mnemos.signals import SignalLog\nfrom maggy.process.model_router import RoutingDecision\nfrom maggy.routing import RoutingContext, RoutingService\n\nif TYPE_CHECKING:\n    from maggy.checkpoint import CheckpointManager\n    from maggy.config import MaggyConfig\n    from maggy.escalation.protocol import Escalator\n    from maggy.providers.base import Task\n    from maggy.recovery.rollback import RollbackManager\n\nlogger = logging.getLogger(__name__)\n\n\ndef route_model(task: Task, routing: RoutingService) -> RoutingDecision:\n    \"\"\"Pick the best model for a task via routing rules.\"\"\"\n    from maggy.services.stakes import classify_stakes\n\n    raw = task.raw if isinstance(task.raw, dict) else {}\n    task_type = str(raw.get(\"task_type\") or _task_type(task))\n    stakes = classify_stakes(task).level\n    return routing.route(\n        RoutingContext(\n            blast_score=int_value(raw.get(\"blast_score\")),\n            task_type=task_type,\n            security_sensitive=_security_flag(raw, task_type),\n            project_key=str(raw.get(\"project_key\") or task.board),\n            stakes=stakes,\n        ),\n    )\n\n\ndef blast_score(task: Task) -> int:\n    \"\"\"Extract blast score from task metadata.\"\"\"\n    raw = task.raw if isinstance(task.raw, dict) else {}\n    return int_value(raw.get(\"blast_score\"))\n\n\ndef int_value(value: object) -> int:\n    \"\"\"Safely convert to int, default 0.\"\"\"\n    try:\n        return int(value)\n    except (TypeError, ValueError):\n        return 0\n\n\ndef model_name(primary: object) -> str:\n    \"\"\"Extract model name string from routing decision.\"\"\"\n    if isinstance(primary, str):\n        return primary\n    return str(primary.name)\n\n\ndef track_fatigue(fatigue: FatigueTracker, result: RunResult) -> None:\n    \"\"\"Record context load from result output length.\"\"\"\n    load = min(len(result.output) / 50_000, 1.0)\n    fatigue.record(\"context_load\", load)\n\n\ndef log_signal(signals: SignalLog, sid: str, label: str, result: RunResult) -> None:\n    \"\"\"Append step signal to log.\"\"\"\n    signals.append({\n        \"session_id\": sid, \"step\": label,\n        \"model\": result.model, \"success\": result.success,\n    })\n\n\ndef write_checkpoint(\n    checkpoint: \"CheckpointManager\", task: Task, model: str,\n) -> None:\n    \"\"\"Write execution checkpoint for crash recovery.\"\"\"\n    checkpoint.write(task.id.replace(\"/\", \"-\"), {\n        \"goal\": task.title,\n        \"model_history\": [model],\n        \"current_subgoal\": \"executing\",\n    })\n\n\nasync def save_rollback(\n    rollback: \"RollbackManager\", sid: str, wd: str,\n) -> None:\n    \"\"\"Create git savepoint before implementation.\"\"\"\n    try:\n        await rollback.create_savepoint(sid, wd)\n    except Exception as exc:\n        logger.warning(\"Savepoint failed: %s\", exc)\n\n\nasync def try_rollback(\n    rollback: \"RollbackManager\", sid: str, wd: str,\n) -> None:\n    \"\"\"Revert to last savepoint on failure.\"\"\"\n    try:\n        await rollback.rollback(sid, wd)\n    except Exception as exc:\n        logger.warning(\"Rollback failed: %s\", exc)\n\n\ndef maybe_escalate(\n    escalator: \"Escalator\", session: dict, task: Task,\n) -> None:\n    \"\"\"Escalate after 3+ consecutive failures.\"\"\"\n    failures = session.get(\"_fail_count\", 0) + 1\n    session[\"_fail_count\"] = failures\n    if failures >= 3:\n        escalator.escalate(\n            session[\"id\"], \"repeated_failure\",\n            {\"task_id\": task.id, \"failures\": failures},\n        )\n\n\nasync def build_icpg_context(cfg: \"MaggyConfig\", task: Task) -> str:\n    \"\"\"Query iCPG CLI for code intelligence context.\"\"\"\n    bp = cfg.resolve_bootstrap_path()\n    if not bp or not (bp / \"scripts\" / \"icpg\" / \"__main__.py\").exists():\n        return \"\"\n    from maggy.services.executor_prompts import extract_keywords\n    kw = extract_keywords(f\"{task.title} {task.description}\")\n    if not kw:\n        return \"\"\n    try:\n        proc = await asyncio.create_subprocess_exec(\n            \"python3\", \"-m\", \"scripts.icpg\", \"--project\", str(bp),\n            \"query\", \"prior\", \"--text\", \" \".join(kw[:8]), \"--limit\", \"8\",\n            stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, cwd=str(bp))\n        stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=10)\n        if proc.returncode != 0:\n            return \"\"\n        text = (stdout or b\"\").decode(\"utf-8\", errors=\"replace\").strip()\n    except (asyncio.TimeoutError, FileNotFoundError, OSError):\n        return \"\"\n    if not text:\n        return \"\"\n    return (\"## iCPG Code Intelligence\\n\"\n            \"Pre-queried from Maggy's intent code property graph:\\n\\n\"\n            + text[:2000] + \"\\n\\n**Use this to target your file reads.**\")\n\n\ndef resolve_working_dir(cfg: \"MaggyConfig\", requested: str | None, task: \"Task\") -> str:\n    \"\"\"Resolve working_dir inside configured codebases.\"\"\"\n    from pathlib import Path\n    if not cfg.codebases:\n        raise ValueError(\"No codebases configured\")\n    roots = [Path(c.path).expanduser().resolve() for c in cfg.codebases]\n    if requested:\n        candidate = Path(requested).expanduser().resolve()\n        for root in roots:\n            try:\n                candidate.relative_to(root)\n                return str(candidate)\n            except ValueError:\n                continue\n        raise ValueError(f\"working_dir {requested!r} not inside codebases\")\n    return pick_working_dir(cfg, task)\n\n\ndef pick_working_dir(cfg: \"MaggyConfig\", task: \"Task\") -> str:\n    \"\"\"Match task keywords to configured codebases.\"\"\"\n    from pathlib import Path\n    cbs = cfg.codebases\n    if len(cbs) == 1:\n        return str(Path(cbs[0].path).expanduser().resolve())\n    text = f\"{task.title} {task.description} {task.board}\".lower()\n    best_key, best_score = cbs[0].key, 0\n    for cb in cbs:\n        score = 5 if cb.key.lower() in text else 0\n        name = Path(cb.path).name.lower()\n        if name != cb.key.lower() and name in text:\n            score += 3\n        if score > best_score:\n            best_key, best_score = cb.key, score\n    picked = next(c for c in cbs if c.key == best_key)\n    return str(Path(picked.path).expanduser().resolve())\n\n\nasync def post_plan(provider, task_id: str, output: str) -> None:\n    \"\"\"Post plan as comment to issue tracker.\"\"\"\n    try:\n        await provider.add_comment(\n            task_id, f\"## Maggy Plan\\n\\n{output[:4000]}\",\n        )\n    except Exception as e:\n        logger.warning(\"Failed to post plan: %s\", e)\n\n\ndef _task_type(task: \"Task\") -> str:\n    return task.labels[0] if task.labels else \"general\"\n\n\ndef _security_flag(raw: dict, task_type: str) -> bool:\n    if \"security_sensitive\" in raw:\n        return bool(raw[\"security_sensitive\"])\n    return task_type in {\"security\", \"auth\", \"billing\"}\n"
  },
  {
    "path": "maggy/maggy/services/executor_prompts.py",
    "content": "\"\"\"Executor prompt templates for TDD pipeline steps.\"\"\"\n\nfrom __future__ import annotations\n\nimport re\nfrom typing import TYPE_CHECKING\n\nif TYPE_CHECKING:\n    from maggy.providers.base import Task\n    from maggy.routing import RoutingService\n\nfrom maggy.routing_rules import conventions_for\n\nSTOP = frozenset({\n    \"the\", \"and\", \"for\", \"to\", \"in\", \"of\", \"a\", \"is\", \"with\",\n    \"on\", \"from\", \"be\", \"as\", \"by\", \"an\", \"or\", \"not\", \"all\",\n    \"that\", \"this\", \"are\", \"can\", \"should\", \"would\", \"when\",\n    \"how\", \"what\", \"where\", \"which\", \"we\", \"need\", \"also\",\n    \"been\", \"has\", \"have\", \"it\", \"its\", \"new\", \"add\", \"fix\",\n    \"update\", \"create\", \"delete\", \"get\", \"set\", \"use\",\n})\n\n\ndef plan_prompt(task: Task, icpg_ctx: str, routing: RoutingService) -> str:\n    conv = _conventions_block(task, routing)\n    return (\n        \"Create an implementation plan for this ticket. \"\n        \"No code changes — just a plan.\\n\\n\"\n        f\"Ticket: {task.title}\\n{task.description[:1500]}\"\n        f\"{_icpg_block(icpg_ctx)}{conv}\\n\"\n        \"Output: numbered steps, files to touch, risks, tests.\"\n    )\n\n\ndef analysis_prompt(task: Task, icpg_ctx: str, routing: RoutingService) -> str:\n    conv = _conventions_block(task, routing)\n    return (\n        \"Analyze this ticket against the codebase and output \"\n        \"a concise plan.\\nIdentify: files to change, functions \"\n        \"affected, tests needed, risks.\\n\\n\"\n        f\"Ticket: {task.title}\\n{task.description[:1500]}\"\n        f\"{_icpg_block(icpg_ctx)}{conv}\"\n    )\n\n\ndef tests_prompt(\n    task: Task, icpg_ctx: str, analysis: str, routing: RoutingService,\n) -> str:\n    conv = _conventions_block(task, routing)\n    return (\n        \"Write failing test cases for this ticket \"\n        \"(TDD — no implementation yet).\\n\"\n        \"Use the project's existing test patterns. \"\n        \"Commit tests separately.\\n\\n\"\n        f\"Ticket: {task.title}\\n{task.description[:1500]}\"\n        f\"{_icpg_block(icpg_ctx)}{conv}\\n\"\n        f\"Analysis:\\n{analysis[:1000]}\"\n    )\n\n\ndef impl_prompt(task: Task, icpg_ctx: str, routing: RoutingService) -> str:\n    conv = _conventions_block(task, routing)\n    return (\n        \"Implement the feature to make the failing tests pass.\\n\"\n        \"Follow existing code patterns. Keep changes minimal.\\n\\n\"\n        f\"Ticket: {task.title}\\n{task.description[:1500]}\"\n        f\"{_icpg_block(icpg_ctx)}{conv}\\n\"\n        \"Run tests to verify, then commit with a conventional \"\n        \"commit message.\"\n    )\n\n\ndef extract_keywords(text: str) -> list[str]:\n    \"\"\"Extract unique keywords from text, filtering stop words.\"\"\"\n    words = re.findall(r\"[a-zA-Z_][a-zA-Z0-9_]*\", text.lower())\n    seen: set[str] = set()\n    result: list[str] = []\n    for w in words:\n        if w in STOP or len(w) < 3 or w in seen:\n            continue\n        seen.add(w)\n        result.append(w)\n    return result[:20]\n\n\ndef _icpg_block(icpg_ctx: str) -> str:\n    if not icpg_ctx:\n        return \"\"\n    return f\"\\n\\n{icpg_ctx}\\n\"\n\n\ndef _task_type(task: Task) -> str:\n    if task.labels:\n        return task.labels[0]\n    return \"general\"\n\n\ndef _conventions_block(task: Task, routing: RoutingService) -> str:\n    raw = task.raw if isinstance(task.raw, dict) else {}\n    task_type = str(raw.get(\"task_type\") or _task_type(task))\n    project_key = str(raw.get(\"project_key\") or \"\")\n    text = conventions_for(routing.rules, task_type, project_key or None)\n    if not text:\n        return \"\"\n    return f\"\\n\\n{text}\\n\"\n"
  },
  {
    "path": "maggy/maggy/services/executor_types.py",
    "content": "\"\"\"Executor shared types — context and step descriptors.\"\"\"\n\nfrom __future__ import annotations\n\nfrom dataclasses import dataclass\nfrom typing import TYPE_CHECKING\n\nif TYPE_CHECKING:\n    from maggy.providers.base import Task\n\n\n@dataclass\nclass SessionCtx:\n    \"\"\"Bundles session state, task, and working dir for executor.\"\"\"\n\n    session: dict\n    task: Task\n    wd: str\n    icpg: str = \"\"\n\n\n@dataclass\nclass StepSpec:\n    \"\"\"Describes a single TDD pipeline step.\"\"\"\n\n    label: str\n    prompt: str\n    max_turns: int\n"
  },
  {
    "path": "maggy/maggy/services/inbox.py",
    "content": "\"\"\"AI-prioritized inbox — ranks tasks by urgency, OKR alignment, and age.\n\nWorks with any IssueTrackerProvider. Caches ranking for 30 minutes in SQLite.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport logging\nimport sqlite3\nfrom datetime import datetime, timezone\nfrom pathlib import Path\n\nfrom maggy.config import MaggyConfig\nfrom maggy.services.ai_client import ai_complete\nfrom maggy.providers.base import IssueTrackerProvider, Task\n\nlogger = logging.getLogger(__name__)\n\nCACHE_TTL_SECONDS = 30 * 60  # 30 min\n\n\ndef _connect_sqlite(path: Path) -> sqlite3.Connection:\n    \"\"\"Open a SQLite connection with sensible defaults for concurrent use.\n\n    FastAPI serves requests concurrently, and the heartbeat worker writes from\n    a different thread. WAL lets readers and writers coexist; foreign_keys\n    enforces referential integrity; busy_timeout avoids 'database is locked'\n    errors under contention. Matches the convention used by scripts/icpg/store.py.\n    \"\"\"\n    db = sqlite3.connect(path, timeout=30.0)\n    db.execute(\"PRAGMA journal_mode=WAL\")\n    db.execute(\"PRAGMA foreign_keys=ON\")\n    db.execute(\"PRAGMA busy_timeout=30000\")\n    return db\n\n\nclass InboxService:\n    def __init__(self, cfg: MaggyConfig, provider: IssueTrackerProvider):\n        self.cfg = cfg\n        self.provider = provider\n        self.db_path = Path(cfg.storage.path).expanduser()\n        self.db_path.parent.mkdir(parents=True, exist_ok=True)\n        self._init_db()\n\n    def _init_db(self) -> None:\n        with _connect_sqlite(self.db_path) as db:\n            db.execute(\"\"\"\n                CREATE TABLE IF NOT EXISTS inbox_cache (\n                    id INTEGER PRIMARY KEY AUTOINCREMENT,\n                    cached_at TEXT NOT NULL,\n                    payload TEXT NOT NULL\n                )\n            \"\"\")\n\n    def _read_cache(self, ignore_ttl: bool = False) -> list[dict] | None:\n        with _connect_sqlite(self.db_path) as db:\n            row = db.execute(\n                \"SELECT cached_at, payload FROM inbox_cache ORDER BY id DESC LIMIT 1\"\n            ).fetchone()\n        if not row:\n            return None\n        if not ignore_ttl:\n            cached_at = datetime.fromisoformat(row[0])\n            age = (datetime.now(timezone.utc) - cached_at).total_seconds()\n            if age > CACHE_TTL_SECONDS:\n                return None\n        return json.loads(row[1])\n\n    def _write_cache(self, items: list[dict]) -> None:\n        with _connect_sqlite(self.db_path) as db:\n            db.execute(\"DELETE FROM inbox_cache\")  # keep just latest\n            db.execute(\n                \"INSERT INTO inbox_cache (cached_at, payload) VALUES (?, ?)\",\n                (datetime.now(timezone.utc).isoformat(), json.dumps(items)),\n            )\n\n    async def get_prioritized(self, force_refresh: bool = False) -> list[dict]:\n        \"\"\"Return AI-ranked tasks. Cached 30 min.\n\n        On provider failure (GitHub/Asana down), fall back to the last cached\n        ranking — even if stale — rather than 500ing the whole endpoint.\n        Staleness is indicated to clients via the `stale` flag on items.\n        \"\"\"\n        if not force_refresh:\n            cached = self._read_cache()\n            if cached is not None:\n                return cached\n\n        try:\n            tasks = await self.provider.list_tasks(state=\"open\", limit=50)\n        except Exception as e:\n            logger.warning(\"provider.list_tasks failed, falling back to stale cache: %s\", e)\n            stale = self._read_cache(ignore_ttl=True) or []\n            for item in stale:\n                item[\"stale\"] = True\n            return stale\n\n        if not tasks:\n            return []\n\n        ranked = await self._rank_with_ai(tasks)\n        self._write_cache(ranked)\n        return ranked\n\n    async def _rank_with_ai(self, tasks: list[Task]) -> list[dict]:\n        \"\"\"Ask Claude to rank tasks by priority. Falls back to date-sorted if AI unavailable.\"\"\"\n        prompt = self._build_rank_prompt(tasks)\n        text = await self._call_ai(prompt)\n        if not text:\n            return [self._task_to_dict(t, rank=i + 1, reason=\"AI not available; sorted by recency\")\n                    for i, t in enumerate(tasks)]\n        try:\n            start = text.find(\"{\")\n            end = text.rfind(\"}\")\n            data = json.loads(text[start:end + 1]) if start >= 0 else {\"rankings\": []}\n        except Exception as e:\n            logger.warning(\"AI ranking parse failed: %s\", e)\n            return [self._task_to_dict(t, rank=i + 1, reason=\"AI ranking unavailable\")\n                    for i, t in enumerate(tasks)]\n\n        # Apply rankings — validate each row before trusting it.\n        # LLMs routinely return missing indices, string ranks, or out-of-range values.\n        rank_map: dict[int, dict] = {}\n        for r in data.get(\"rankings\", []):\n            if not isinstance(r, dict):\n                continue\n            idx = r.get(\"index\")\n            rank = r.get(\"rank\")\n            if not isinstance(idx, int) or idx < 0 or idx >= len(tasks):\n                continue\n            # Coerce rank defensively\n            try:\n                rank_int = int(rank)\n            except (TypeError, ValueError):\n                continue\n            if rank_int < 1:\n                continue\n            # First write wins — LLM occasionally emits duplicate indices\n            rank_map.setdefault(idx, {\"rank\": rank_int, \"reason\": str(r.get(\"reason\", \"\"))[:300]})\n\n        ranked: list[dict] = []\n        for i, t in enumerate(tasks):\n            r = rank_map.get(i) or {\"rank\": i + 1, \"reason\": \"\"}\n            ranked.append(self._task_to_dict(t, rank=r[\"rank\"], reason=r[\"reason\"]))\n        ranked.sort(key=lambda x: x[\"rank\"])\n        return ranked\n\n    def _build_rank_prompt(self, tasks: list[Task]) -> str:\n        \"\"\"Build the ranking prompt for AI.\"\"\"\n        okr_block = \"\"\n        if self.cfg.okrs.source == \"yaml\" and self.cfg.okrs.items:\n            okr_lines = [f\"- {o.id}: {o.title}\" for o in self.cfg.okrs.items]\n            okr_block = \"## Current OKRs\\n\" + \"\\n\".join(okr_lines) + \"\\n\"\n        task_lines = []\n        for i, t in enumerate(tasks):\n            snippet = (t.description or \"\")[:200].replace(\"\\n\", \" \")\n            task_lines.append(f\"[{i}] id={t.id} board={t.board} labels={','.join(t.labels[:3])}\\n    {t.title}\\n    {snippet}\")\n        return f\"\"\"You are the AI triage assistant for {self.cfg.org.name}.\n\n{okr_block}\nRank the following {len(tasks)} open tasks by priority. Consider:\n- OKR alignment (if OKRs provided)\n- Urgency signals (labels like \"bug\", \"critical\", \"urgent\")\n- Age (older + stale = deprioritize, older + active = maybe important)\n\nRespond with STRICT JSON only:\n{{\"rankings\": [{{\"index\": 0, \"rank\": 1, \"reason\": \"<20 word explanation>\"}}, ...]}}\n\nTasks:\n{chr(10).join(task_lines)}\"\"\"\n\n    async def _call_ai(self, prompt: str) -> str | None:\n        \"\"\"Call AI via API key or CLI subscription.\"\"\"\n        return await ai_complete(prompt, self.cfg)\n\n    def _task_to_dict(self, t: Task, rank: int, reason: str) -> dict:\n        return {\n            \"id\": t.id,\n            \"title\": t.title,\n            \"description\": t.description[:500],\n            \"status\": t.status,\n            \"assignee\": t.assignee,\n            \"author\": t.author,\n            \"url\": t.url,\n            \"labels\": t.labels,\n            \"board\": t.board,\n            \"created_at\": t.created_at,\n            \"updated_at\": t.updated_at,\n            \"rank\": rank,\n            \"ai_reason\": reason,\n        }\n"
  },
  {
    "path": "maggy/maggy/services/monitor.py",
    "content": "\"\"\"MonitorService — background polling for issue trackers.\"\"\"\n\nfrom __future__ import annotations\n\nimport logging\nimport sqlite3\nfrom dataclasses import dataclass\nfrom datetime import datetime, timezone\nfrom pathlib import Path\n\nimport httpx\n\nlogger = logging.getLogger(__name__)\n\nGITHUB_API = \"https://api.github.com\"\nMONDAY_API = \"https://api.monday.com/v2\"\n\n\n@dataclass\nclass MonitorConfig:\n    \"\"\"Config for a single project monitor.\"\"\"\n\n    project_key: str\n    provider: str  # \"github\" | \"asana\" | \"monday\"\n    poll_command: str = \"\"\n    interval_seconds: int = 300\n    enabled: bool = True\n\n\n@dataclass\nclass MonitorEvent:\n    \"\"\"A detected new item from a tracker.\"\"\"\n\n    id: str\n    title: str\n    url: str\n    provider: str\n    project_key: str\n    seen_at: str = \"\"\n\n\nclass MonitorService:\n    \"\"\"SQLite-backed tracker polling service.\"\"\"\n\n    def __init__(self, db_path: Path) -> None:\n        self._db = sqlite3.connect(str(db_path))\n        self._init_tables()\n\n    def _init_tables(self) -> None:\n        self._db.executescript(\"\"\"\n            CREATE TABLE IF NOT EXISTS monitors (\n                project_key TEXT PRIMARY KEY,\n                provider TEXT NOT NULL,\n                poll_command TEXT DEFAULT '',\n                interval_seconds INTEGER DEFAULT 300,\n                enabled INTEGER DEFAULT 1\n            );\n            CREATE TABLE IF NOT EXISTS seen_events (\n                event_id TEXT,\n                project_key TEXT,\n                seen_at TEXT,\n                PRIMARY KEY (event_id, project_key)\n            );\n        \"\"\")\n\n    def add(self, cfg: MonitorConfig) -> None:\n        self._db.execute(\n            \"INSERT OR REPLACE INTO monitors VALUES (?,?,?,?,?)\",\n            (cfg.project_key, cfg.provider, cfg.poll_command,\n             cfg.interval_seconds, int(cfg.enabled)),\n        )\n        self._db.commit()\n\n    def remove(self, project_key: str) -> None:\n        self._db.execute(\n            \"DELETE FROM monitors WHERE project_key=?\",\n            (project_key,),\n        )\n        self._db.commit()\n\n    def list_active(self) -> list[MonitorConfig]:\n        rows = self._db.execute(\n            \"SELECT * FROM monitors WHERE enabled=1\",\n        ).fetchall()\n        return [_row_to_config(r) for r in rows]\n\n    def is_new(self, event_id: str, project_key: str) -> bool:\n        row = self._db.execute(\n            \"SELECT 1 FROM seen_events WHERE event_id=? AND project_key=?\",\n            (event_id, project_key),\n        ).fetchone()\n        return row is None\n\n    def mark_seen(self, event_id: str, project_key: str) -> None:\n        now = datetime.now(timezone.utc).isoformat()\n        self._db.execute(\n            \"INSERT OR IGNORE INTO seen_events VALUES (?,?,?)\",\n            (event_id, project_key, now),\n        )\n        self._db.commit()\n\n    def status(self) -> dict:\n        active = len(self.list_active())\n        total = self._db.execute(\n            \"SELECT COUNT(*) FROM seen_events\",\n        ).fetchone()[0]\n        return {\"active\": active, \"seen_events\": total}\n\n    async def poll(self, cfg: MonitorConfig) -> list[MonitorEvent]:\n        \"\"\"Poll tracker and return new events.\"\"\"\n        if cfg.provider == \"github\":\n            return await _poll_github(self, cfg)\n        if cfg.provider == \"monday\":\n            return await _poll_monday(self, cfg)\n        return []\n\n\ndef _row_to_config(row: tuple) -> MonitorConfig:\n    return MonitorConfig(\n        project_key=row[0], provider=row[1],\n        poll_command=row[2], interval_seconds=row[3],\n        enabled=bool(row[4]),\n    )\n\n\nasync def _poll_github(svc: MonitorService, cfg: MonitorConfig) -> list[MonitorEvent]:\n    repo = cfg.poll_command or \"\"\n    if not repo:\n        return []\n    events: list[MonitorEvent] = []\n    async with httpx.AsyncClient(timeout=15) as client:\n        url = f\"{GITHUB_API}/repos/{repo}/pulls\"\n        resp = await client.get(url, params={\"state\": \"open\"})\n        if resp.status_code != 200:\n            return []\n        for pr in resp.json():\n            eid = f\"gh-pr-{pr.get('number', '')}\"\n            if svc.is_new(eid, cfg.project_key):\n                events.append(MonitorEvent(\n                    id=eid, title=pr.get(\"title\", \"\"),\n                    url=pr.get(\"html_url\", \"\"),\n                    provider=\"github\",\n                    project_key=cfg.project_key,\n                ))\n                svc.mark_seen(eid, cfg.project_key)\n    return events\n\n\nasync def _poll_monday(svc: MonitorService, cfg: MonitorConfig) -> list[MonitorEvent]:\n    board_id = cfg.poll_command or \"\"\n    if not board_id:\n        return []\n    events: list[MonitorEvent] = []\n    query = f'{{ boards(ids: [{board_id}]) {{ items_page(limit: 20) {{ items {{ id name }} }} }} }}'\n    async with httpx.AsyncClient(timeout=15) as client:\n        resp = await client.post(\n            MONDAY_API,\n            json={\"query\": query},\n        )\n        if resp.status_code != 200:\n            return []\n        boards = resp.json().get(\"data\", {}).get(\"boards\", [])\n        if not boards:\n            return []\n        items = boards[0].get(\"items_page\", {}).get(\"items\", [])\n        for item in items:\n            eid = f\"mon-{item.get('id', '')}\"\n            if svc.is_new(eid, cfg.project_key):\n                events.append(MonitorEvent(\n                    id=eid, title=item.get(\"name\", \"\"),\n                    url=\"\", provider=\"monday\",\n                    project_key=cfg.project_key,\n                ))\n                svc.mark_seen(eid, cfg.project_key)\n    return events\n"
  },
  {
    "path": "maggy/maggy/services/output_reviewer.py",
    "content": "\"\"\"Inter-task output quality reviewer.\n\nSends step output to a fast local model for quality scoring.\nFalls back to pass-through (score=3) on any failure so it\nnever blocks the pipeline.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport logging\nimport re\nfrom dataclasses import dataclass\nfrom typing import TYPE_CHECKING\n\nif TYPE_CHECKING:\n    from maggy.adapters.pi import PiAdapter\n\nlogger = logging.getLogger(__name__)\n\n_SCORE_RE = re.compile(r\"SCORE:\\s*(\\d+)\", re.IGNORECASE)\n_REASON_RE = re.compile(r\"REASON:\\s*(.+)\", re.IGNORECASE)\n\nREVIEW_MODEL = \"local\"\nREVIEW_MAX_TURNS = 1\n\n\n@dataclass\nclass ReviewResult:\n    score: int\n    reason: str = \"\"\n\n\ndef _parse_review(text: str) -> ReviewResult:\n    \"\"\"Extract score and reason from reviewer output.\"\"\"\n    m = _SCORE_RE.search(text)\n    if not m:\n        return ReviewResult(score=3)\n    score = max(1, min(5, int(m.group(1))))\n    rm = _REASON_RE.search(text)\n    reason = rm.group(1).strip() if rm else \"\"\n    return ReviewResult(score=score, reason=reason)\n\n\ndef _build_prompt(step_label: str, output: str) -> str:\n    \"\"\"Build the review prompt for the local model.\"\"\"\n    trimmed = output[:3000]\n    return (\n        f\"Review this {step_label} output for quality.\\n\"\n        \"Rate 1-5 (1=wrong, 3=acceptable, 5=excellent).\\n\"\n        \"Reply ONLY in this format:\\n\"\n        \"SCORE: <number>\\nREASON: <one sentence>\\n\\n\"\n        f\"--- OUTPUT ---\\n{trimmed}\"\n    )\n\n\nasync def review_output(\n    pi: \"PiAdapter\", step_label: str, output: str, wd: str,\n) -> ReviewResult:\n    \"\"\"Send step output to local model for quality review.\"\"\"\n    prompt = _build_prompt(step_label, output)\n    try:\n        result = await pi.send_prompt(\n            REVIEW_MODEL, prompt, wd,\n            max_turns=REVIEW_MAX_TURNS, timeout=30,\n        )\n        if not result.success:\n            return ReviewResult(score=3, reason=\"review unavailable\")\n        return _parse_review(result.output)\n    except Exception as exc:\n        logger.debug(\"Review failed: %s\", exc)\n        return ReviewResult(score=3, reason=\"review error\")\n"
  },
  {
    "path": "maggy/maggy/services/planner.py",
    "content": "\"\"\"Dual-model planning service.\"\"\"\n\nfrom __future__ import annotations\n\nfrom dataclasses import dataclass, field\n\nfrom maggy.adapters.pi import PiAdapter, RunResult\n\n\n@dataclass\nclass PlanResult:\n    primary_plan: str\n    counter_check: str\n    conflicts: list[str] = field(default_factory=list)\n\n\nclass DualPlanner:\n    def __init__(self, pi: PiAdapter):\n        self._pi = pi\n\n    async def plan(\n        self, task_title: str, task_desc: str, wd: str,\n    ) -> str:\n        prompt = _plan_prompt(task_title, task_desc)\n        return await self._send(\"claude\", prompt, wd)\n\n    async def counter_check(self, plan_text: str, wd: str) -> str:\n        prompt = _review_prompt(plan_text)\n        return await self._send(\"codex\", prompt, wd)\n\n    async def dual_plan(\n        self, task_title: str, task_desc: str, wd: str,\n    ) -> PlanResult:\n        primary = await self.plan(task_title, task_desc, wd)\n        review = await self.counter_check(primary, wd)\n        return PlanResult(primary, review, _conflicts(review))\n\n    async def _send(self, model: str, prompt: str, wd: str) -> str:\n        result = await self._pi.send_prompt(model, prompt, wd, 5)\n        return _result_text(result, model)\n\n\ndef _plan_prompt(task_title: str, task_desc: str) -> str:\n    return (\n        \"Create an implementation plan.\\n\"\n        \"Return numbered steps, files to touch, risks, and tests.\\n\\n\"\n        f\"Title: {task_title}\\n\"\n        f\"Description: {task_desc}\"\n    )\n\n\ndef _review_prompt(plan_text: str) -> str:\n    return (\n        \"Review this implementation plan.\\n\"\n        \"Flag conflicts as 'CONFLICT:' and keep the note short.\\n\"\n        \"Call out risky omissions and invalid assumptions.\\n\\n\"\n        f\"Plan:\\n{plan_text}\"\n    )\n\n\ndef _result_text(result: RunResult, model: str) -> str:\n    if result.success:\n        return result.output.strip()\n    message = result.output or result.error\n    raise RuntimeError((message or f\"{model} planning failed\").strip())\n\n\ndef _conflicts(text: str) -> list[str]:\n    return [\n        line.partition(\":\")[2].strip()\n        for line in text.splitlines()\n        if line.upper().startswith(\"CONFLICT:\")\n    ]\n"
  },
  {
    "path": "maggy/maggy/services/session_detect.py",
    "content": "\"\"\"Multi-CLI session detection.\n\nScans Claude, Kimi, Codex state directories to find\nprevious sessions for a given working directory.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport logging\nfrom dataclasses import dataclass, field\nfrom pathlib import Path\n\nlogger = logging.getLogger(__name__)\n\n\ndef _home() -> Path:\n    \"\"\"Testable home directory getter.\"\"\"\n    return Path.home()\n\n\n@dataclass\nclass CliSessionInfo:\n    \"\"\"Detected session from a CLI tool.\"\"\"\n\n    cli: str\n    session_id: str\n    project_path: str = \"\"\n\n\n@dataclass\nclass DetectedSessions:\n    \"\"\"Results from scanning all CLIs.\"\"\"\n\n    sessions: list[CliSessionInfo] = field(\n        default_factory=list,\n    )\n\n\ndef detect_all(working_dir: str) -> DetectedSessions:\n    \"\"\"Scan all CLIs for previous sessions.\"\"\"\n    result = DetectedSessions()\n    for fn in (detect_claude, detect_kimi, detect_codex):\n        try:\n            info = fn(working_dir)\n            if info:\n                result.sessions.append(info)\n        except Exception:\n            continue\n    return result\n\n\ndef detect_claude(working_dir: str) -> CliSessionInfo | None:\n    \"\"\"Find latest Claude session for this directory.\"\"\"\n    path = _home() / \".claude\" / \"history.jsonl\"\n    if not path.exists():\n        return None\n    target = working_dir.rstrip(\"/\")\n    for line in reversed(path.read_text().splitlines()):\n        entry = _parse_json(line)\n        if not entry:\n            continue\n        project = entry.get(\"project\", \"\").rstrip(\"/\")\n        sid = entry.get(\"sessionId\", \"\")\n        if project == target and sid:\n            return CliSessionInfo(\"claude\", sid, target)\n    return None\n\n\ndef detect_kimi(working_dir: str) -> CliSessionInfo | None:\n    \"\"\"Find latest Kimi session from kimi.json.\"\"\"\n    path = _home() / \".kimi\" / \"kimi.json\"\n    if not path.exists():\n        return None\n    data = _parse_json(path.read_text())\n    if not data:\n        return None\n    target = working_dir.rstrip(\"/\")\n    for entry in data.get(\"work_dirs\", []):\n        entry_path = entry.get(\"path\", \"\").rstrip(\"/\")\n        sid = entry.get(\"last_session_id\")\n        if entry_path == target and sid:\n            return CliSessionInfo(\"kimi\", sid, target)\n    return None\n\n\ndef detect_codex(working_dir: str) -> CliSessionInfo | None:\n    \"\"\"Find latest Codex session by scanning files.\"\"\"\n    sess_dir = _home() / \".codex\" / \"sessions\"\n    if not sess_dir.exists():\n        return None\n    target = working_dir.rstrip(\"/\")\n    files = sorted(\n        sess_dir.rglob(\"rollout-*.jsonl\"), reverse=True,\n    )\n    for f in files[:50]:\n        entry = _parse_json(_read_first_line(f))\n        if not entry:\n            continue\n        payload = entry.get(\"payload\", {})\n        cwd = payload.get(\"cwd\", \"\").rstrip(\"/\")\n        sid = payload.get(\"id\", \"\")\n        if cwd == target and sid:\n            return CliSessionInfo(\"codex\", sid, target)\n    return None\n\n\ndef _parse_json(text: str) -> dict | None:\n    \"\"\"Safe JSON parse, returns None on failure.\"\"\"\n    text = text.strip()\n    if not text:\n        return None\n    try:\n        return json.loads(text)\n    except (json.JSONDecodeError, ValueError):\n        return None\n\n\ndef _read_first_line(path: Path) -> str:\n    \"\"\"Read first line of a file safely.\"\"\"\n    try:\n        with path.open() as f:\n            return f.readline()\n    except OSError:\n        return \"\"\n"
  },
  {
    "path": "maggy/maggy/services/stakes.py",
    "content": "\"\"\"Stakes classification — HIGH/MEDIUM/LOW from task metadata.\"\"\"\n\nfrom __future__ import annotations\n\nimport re\nfrom dataclasses import dataclass, field\nfrom typing import TYPE_CHECKING\n\nif TYPE_CHECKING:\n    from maggy.providers.base import Task\n    from maggy.routing_rules import StakesLevel, StakesPatterns\n\n\n@dataclass\nclass StakesResult:\n    \"\"\"Result of stakes classification.\"\"\"\n\n    level: str  # \"high\" | \"medium\" | \"low\"\n    reasons: list[str] = field(default_factory=list)\n\n\ndef classify_stakes(\n    task: Task,\n    patterns: StakesPatterns | None = None,\n) -> StakesResult:\n    \"\"\"Classify task stakes from metadata and text.\"\"\"\n    if patterns is None:\n        from maggy.routing_rules_defaults import default_stakes\n        patterns = default_stakes()\n\n    text = f\"{task.title} {task.description}\".lower()\n    raw = task.raw if isinstance(task.raw, dict) else {}\n    task_type = str(raw.get(\"task_type\", \"\"))\n\n    reasons: list[str] = []\n    if _matches(text, task_type, patterns.high, reasons):\n        return StakesResult(\"high\", reasons)\n    if _matches(text, task_type, patterns.medium, reasons):\n        return StakesResult(\"medium\", reasons)\n    return StakesResult(\"low\", [\"default\"])\n\n\ndef _matches(\n    text: str, task_type: str,\n    level: \"StakesLevel\", reasons: list[str],\n) -> bool:\n    \"\"\"Check if text/task_type matches a stakes level.\"\"\"\n    matched = False\n    for pat in level.file_patterns:\n        if re.search(re.escape(pat), text):\n            reasons.append(f\"file:{pat}\")\n            matched = True\n    if task_type and task_type in level.task_types:\n        reasons.append(f\"type:{task_type}\")\n        matched = True\n    for kw in level.keywords:\n        if kw.lower() in text:\n            reasons.append(f\"keyword:{kw}\")\n            matched = True\n    return matched\n"
  },
  {
    "path": "maggy/maggy/services/tdd_verifier.py",
    "content": "\"\"\"TDD verification — runs pytest/ruff/coverage between executor steps.\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport logging\nimport re\nfrom dataclasses import dataclass\n\nlogger = logging.getLogger(__name__)\n\nDEFAULT_TIMEOUT = 120\nCOVERAGE_THRESHOLD = 80.0\n\n\n@dataclass\nclass VerifyResult:\n    \"\"\"Outcome of a verification step.\"\"\"\n\n    passed: bool\n    detail: str\n    tests_found: int = 0\n    tests_failed: int = 0\n\n\nasync def verify_tests_exist(wd: str) -> VerifyResult:\n    \"\"\"Run pytest --collect-only to verify tests were written.\"\"\"\n    code, output = await _run_cmd(\n        [\"python3\", \"-m\", \"pytest\", \"--collect-only\", \"-q\"], wd,\n    )\n    count = _count_collected(output)\n    if code != 0 or count == 0:\n        return VerifyResult(False, output[:500], count)\n    return VerifyResult(True, f\"{count} tests collected\", count)\n\n\nasync def verify_tests_fail(wd: str) -> VerifyResult:\n    \"\"\"Run pytest -x and confirm failures (RED phase).\"\"\"\n    code, output = await _run_cmd(\n        [\"python3\", \"-m\", \"pytest\", \"-x\", \"--tb=short\", \"-q\"], wd,\n    )\n    failed = _count_failures(output)\n    if code == 0:\n        return VerifyResult(\n            False, \"Tests passed — expected failures (RED)\",\n        )\n    if failed == 0:\n        return VerifyResult(False, f\"Non-test error:\\n{output[:500]}\")\n    return VerifyResult(True, f\"{failed} tests failed (RED)\", 0, failed)\n\n\nasync def verify_tests_pass(wd: str) -> VerifyResult:\n    \"\"\"Run pytest -x and confirm all pass (GREEN phase).\"\"\"\n    code, output = await _run_cmd(\n        [\"python3\", \"-m\", \"pytest\", \"-x\", \"--tb=short\", \"-q\"], wd,\n    )\n    if code != 0:\n        return VerifyResult(\n            False, f\"Tests failing:\\n{output[:500]}\",\n        )\n    return VerifyResult(True, \"All tests pass (GREEN)\")\n\n\nasync def verify_lint(wd: str) -> VerifyResult:\n    \"\"\"Run ruff check on the working directory.\"\"\"\n    code, output = await _run_cmd(\n        [\"python3\", \"-m\", \"ruff\", \"check\", \".\"], wd,\n    )\n    if code != 0:\n        return VerifyResult(False, f\"Lint errors:\\n{output[:500]}\")\n    return VerifyResult(True, \"Lint clean\")\n\n\nasync def verify_coverage(\n    wd: str, threshold: float = COVERAGE_THRESHOLD,\n) -> VerifyResult:\n    \"\"\"Run pytest with coverage and check threshold.\"\"\"\n    code, output = await _run_cmd(\n        [\"python3\", \"-m\", \"pytest\", \"--cov\", \"-q\"], wd,\n    )\n    pct = _parse_coverage(output)\n    if pct < threshold:\n        return VerifyResult(\n            False, f\"Coverage {pct:.0f}% < {threshold:.0f}%\",\n        )\n    return VerifyResult(True, f\"Coverage {pct:.0f}%\")\n\n\nasync def _run_cmd(\n    cmd: list[str], cwd: str,\n) -> tuple[int, str]:\n    \"\"\"Run a subprocess, return (exit_code, output).\"\"\"\n    try:\n        proc = await asyncio.create_subprocess_exec(\n            *cmd,\n            stdout=asyncio.subprocess.PIPE,\n            stderr=asyncio.subprocess.STDOUT,\n            cwd=cwd,\n        )\n        stdout, _ = await asyncio.wait_for(\n            proc.communicate(), timeout=DEFAULT_TIMEOUT,\n        )\n        text = (stdout or b\"\").decode(\"utf-8\", errors=\"replace\")\n        return proc.returncode or 0, text\n    except asyncio.TimeoutError:\n        return 1, \"Command timed out\"\n    except FileNotFoundError:\n        return 1, f\"Command not found: {cmd[0]}\"\n\n\ndef _count_collected(output: str) -> int:\n    \"\"\"Parse 'N tests collected' from pytest output.\"\"\"\n    m = re.search(r\"(\\d+)\\s+tests?\\s+collected\", output)\n    return int(m.group(1)) if m else 0\n\n\ndef _count_failures(output: str) -> int:\n    \"\"\"Parse 'N failed' from pytest summary.\"\"\"\n    m = re.search(r\"(\\d+)\\s+failed\", output)\n    return int(m.group(1)) if m else 0\n\n\ndef _parse_coverage(output: str) -> float:\n    \"\"\"Parse 'TOTAL ... NN%' from coverage output.\"\"\"\n    m = re.search(r\"TOTAL\\s+.*?(\\d+)%\", output)\n    return float(m.group(1)) if m else 0.0\n"
  },
  {
    "path": "maggy/maggy/services/vision.py",
    "content": "\"\"\"Vision analysis via Ollama Qwen3-VL — screenshot review.\"\"\"\n\nfrom __future__ import annotations\n\nimport base64\nimport json\nimport logging\nfrom pathlib import Path\nfrom typing import Generator\n\nimport httpx\n\nlogger = logging.getLogger(__name__)\n\nOLLAMA_URL = \"http://localhost:11434\"\nVISION_MODEL = \"qwen3-vl:32b\"\n_IMAGE_EXTS = frozenset({\n    \".png\", \".jpg\", \".jpeg\", \".gif\", \".webp\", \".bmp\",\n})\n_DEFAULT_PROMPT = (\n    \"Analyze this screenshot. Describe what you see, \"\n    \"identify any UI issues, and suggest improvements.\"\n)\n\n\ndef _validate(path: str) -> Path | None:\n    \"\"\"Check file exists and is an image.\"\"\"\n    p = Path(path).expanduser().resolve()\n    if not p.exists():\n        return None\n    if p.suffix.lower() not in _IMAGE_EXTS:\n        return None\n    return p\n\n\ndef _encode(path: Path) -> str:\n    \"\"\"Base64-encode an image file.\"\"\"\n    return base64.b64encode(path.read_bytes()).decode()\n\n\ndef analyze_image(\n    path: str,\n    prompt: str | None = None,\n) -> Generator[dict, None, None]:\n    \"\"\"Stream vision analysis from Ollama Qwen3-VL.\n\n    Yields dicts: {type: text|error|done, content: ...}\n    \"\"\"\n    resolved = _validate(path)\n    if resolved is None:\n        yield _err(f\"Invalid image: {path}\")\n        return\n    img_b64 = _encode(resolved)\n    body = {\n        \"model\": VISION_MODEL,\n        \"messages\": [{\n            \"role\": \"user\",\n            \"content\": prompt or _DEFAULT_PROMPT,\n            \"images\": [img_b64],\n        }],\n        \"stream\": True,\n    }\n    try:\n        with httpx.stream(\n            \"POST\", f\"{OLLAMA_URL}/api/chat\",\n            json=body, timeout=120.0,\n        ) as resp:\n            for line in resp.iter_lines():\n                chunk = json.loads(line)\n                if chunk.get(\"done\"):\n                    break\n                text = chunk.get(\"message\", {}).get(\n                    \"content\", \"\",\n                )\n                if text:\n                    yield {\"type\": \"text\", \"content\": text}\n    except httpx.ConnectError as e:\n        yield _err(f\"Cannot connect to Ollama: {e}\")\n        return\n    except Exception as e:\n        yield _err(str(e))\n        return\n    yield {\"type\": \"done\"}\n\n\ndef _err(msg: str) -> dict:\n    return {\"type\": \"error\", \"content\": msg}\n"
  },
  {
    "path": "maggy/maggy/static/app.js",
    "content": "// Maggy dashboard — vanilla JS, no build step.\n// Talks to /api/* routes. Single-user local install; no auth by default.\n\nconst API = '/api';\nlet CURRENT_TAB = 'chat';\n\n// ── Fetch helper ────────────────────────────────────────────────────────\nasync function api(path, opts = {}) {\n  const apiKey = localStorage.getItem('maggy-api-key') || '';\n  const headers = { 'Content-Type': 'application/json', ...(opts.headers || {}) };\n  if (apiKey) headers['X-API-Key'] = apiKey;\n  const resp = await fetch(`${API}${path}`, { ...opts, headers });\n  if (!resp.ok) {\n    const text = await resp.text().catch(() => '');\n    throw new Error(`${resp.status}: ${text || resp.statusText}`);\n  }\n  return resp.json();\n}\n\n// ── HTML escape ─────────────────────────────────────────────────────────\nfunction esc(s) {\n  if (s === null || s === undefined) return '';\n  if (typeof s !== 'string') s = String(s);\n  return s.replace(/[&<>\"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', \"'\": '&#39;' }[c]));\n}\n\n// Only allow http(s) / mailto URLs when rendering external `href`.\n// Blocks javascript:, data:, vbscript: and other script-capable schemes that\n// would slip past `esc()` (since it only encodes angle brackets and quotes).\nfunction safeHref(url) {\n  if (!url || typeof url !== 'string') return '';\n  const trimmed = url.trim();\n  if (!/^(https?:|mailto:)/i.test(trimmed)) return '';\n  return esc(trimmed);\n}\n\n// Escape a value for use inside a JS string literal that is itself embedded in\n// an HTML attribute. esc() is NOT enough here — it leaves single quotes and\n// backslashes intact, so a task id containing `'); alert(1);//` would break\n// out of onclick=\"executeTask('${id}', ...)\". We need to:\n//   1. escape the backslash first (so later escapes don't double-encode)\n//   2. escape the single quote that wraps the JS string\n//   3. escape angle brackets in case the attribute is interpreted as HTML\n//   4. escape newlines and carriage returns that would break the statement\nfunction jsStr(s) {\n  if (s === null || s === undefined) return '';\n  return String(s)\n    .replace(/\\\\/g, '\\\\\\\\')\n    .replace(/'/g, \"\\\\'\")\n    .replace(/</g, '\\\\u003C')\n    .replace(/>/g, '\\\\u003E')\n    .replace(/\\r?\\n/g, '\\\\n');\n}\n\nfunction relDate(iso) {\n  if (!iso) return '';\n  const d = new Date(iso);\n  const diff = (Date.now() - d.getTime()) / 1000;\n  if (diff < 60) return 'just now';\n  if (diff < 3600) return `${Math.floor(diff/60)}m ago`;\n  if (diff < 86400) return `${Math.floor(diff/3600)}h ago`;\n  if (diff < 2592000) return `${Math.floor(diff/86400)}d ago`;\n  return d.toLocaleDateString();\n}\n\n// ── Tabs ────────────────────────────────────────────────────────────────\nfunction switchTab(tab) {\n  CURRENT_TAB = tab;\n  // Close system dropdown\n  const menu = document.getElementById('system-menu');\n  if (menu) menu.classList.add('hidden');\n  // Highlight active tab button (nav bar)\n  for (const b of document.querySelectorAll('.tab-btn')) {\n    b.classList.toggle('active', b.dataset.tab === tab);\n  }\n  // Highlight active system dropdown item\n  const gear = document.getElementById('system-gear');\n  const sysTabs = ['budget', 'routing', 'forge', 'settings'];\n  if (gear) {\n    gear.classList.toggle('active', sysTabs.includes(tab));\n  }\n  for (const s of document.querySelectorAll('.sys-item')) {\n    s.classList.toggle(\n      'text-orange-400', s.dataset.tab === tab,\n    );\n  }\n  // Show/hide panes\n  for (const p of document.querySelectorAll('.pane')) {\n    p.classList.toggle('hidden', p.id !== `pane-${tab}`);\n  }\n  if (tab === 'chat') loadChat();\n  else if (tab === 'inbox') loadInbox();\n  else if (tab === 'followed') loadFollowed();\n  else if (tab === 'competitors') loadCompetitors();\n  else if (tab === 'process') loadProcess();\n  else if (tab === 'budget') loadBudget();\n  else if (tab === 'routing') loadRouting();\n  else if (tab === 'forge') loadForge();\n  else if (tab === 'settings') loadSettings();\n}\n\nfunction toggleSystemMenu() {\n  const menu = document.getElementById('system-menu');\n  if (menu) menu.classList.toggle('hidden');\n}\n\n// Close system menu when clicking outside\ndocument.addEventListener('click', (e) => {\n  const menu = document.getElementById('system-menu');\n  const gear = document.getElementById('system-gear');\n  if (!menu || !gear) return;\n  if (!gear.contains(e.target) && !menu.contains(e.target)) {\n    menu.classList.add('hidden');\n  }\n});\n\n// ── Drawer ──────────────────────────────────────────────────────────────\nfunction openDrawer(title, html) {\n  document.getElementById('drawer-title').textContent = title;\n  document.getElementById('drawer-body').innerHTML = html;\n  document.getElementById('drawer').classList.remove('translate-x-full');\n}\nfunction closeDrawer() {\n  document.getElementById('drawer').classList.add('translate-x-full');\n}\n\n// ── Inbox ───────────────────────────────────────────────────────────────\nasync function loadInbox(refresh = false) {\n  const pane = document.getElementById('pane-inbox');\n  pane.innerHTML = `<div class=\"text-xs text-gray-500\"><i class=\"fas fa-spinner fa-spin mr-1\"></i>Loading…</div>`;\n  const [activity, inbox] = await Promise.all([\n    api('/activity').catch(() => ({ sessions: [], recent: [] })),\n    api(`/inbox${refresh ? '?refresh=true' : ''}`).catch(() => ({ items: [] })),\n  ]);\n  const sessions = activity.sessions || [];\n  const recent = activity.recent || [];\n  const items = inbox.items || [];\n  let html = '';\n  if (sessions.length) {\n    html += `<div class=\"mb-4\"><h2 class=\"text-sm font-bold text-white mb-2\"><i class=\"fas fa-terminal mr-1 text-green-400\"></i>Active Sessions (${sessions.length})</h2><div class=\"space-y-2\">`;\n    for (const s of sessions) {\n      const badge = s.status === 'agent'\n        ? '<span class=\"text-[10px] px-1.5 py-0.5 rounded bg-purple-900 text-purple-300\">agent</span>'\n        : '<span class=\"text-[10px] px-1.5 py-0.5 rounded bg-green-900 text-green-300\">running</span>';\n      const label = s.status === 'agent' ? `${esc(s.agent_name)} @ ${esc(s.team_name)}` : esc(s.project || 'unknown');\n      html += `<div class=\"card p-3\"><div class=\"flex items-center gap-2\">\n        <span class=\"text-[10px] font-mono text-blue-400 uppercase\">${esc(s.cli)}</span>\n        ${badge}\n        <span class=\"text-sm text-white\">${label}</span>\n        <span class=\"text-[10px] text-gray-500 ml-auto\">PID ${s.pid}</span>\n      </div>\n      ${s.last_prompt ? `<div class=\"text-[11px] text-gray-400 mt-1 truncate\">\"${esc(s.last_prompt)}\"</div>` : ''}\n      </div>`;\n    }\n    html += `</div></div>`;\n  }\n  if (recent.length) {\n    html += `<div class=\"mb-4\"><h2 class=\"text-sm font-bold text-white mb-2\"><i class=\"fas fa-clock-rotate-left mr-1 text-yellow-400\"></i>Recent Activity</h2><div class=\"space-y-1\">`;\n    for (const r of recent.slice(0, 10)) {\n      html += `<div class=\"card p-2 flex items-center gap-2\">\n        <span class=\"text-[10px] font-mono text-blue-400 uppercase w-10\">${esc(r.cli)}</span>\n        <span class=\"text-[11px] text-gray-300 flex-1 truncate\">${esc(r.text)}</span>\n        <span class=\"text-[10px] text-gray-500 shrink-0\">${r.project ? esc(r.project) + ' · ' : ''}${esc(relDate(r.timestamp))}</span>\n      </div>`;\n    }\n    html += `</div></div>`;\n  }\n  if (items.length) {\n    html += `<div class=\"mb-4\"><div class=\"flex items-center gap-3 mb-2\">\n      <h2 class=\"text-sm font-bold text-white\"><i class=\"fas fa-inbox mr-1 text-orange-400\"></i>Issues (${items.length})</h2>\n      <button onclick=\"loadInbox(true)\" class=\"text-[10px] text-gray-400 hover:text-white\"><i class=\"fas fa-rotate mr-1\"></i>Re-rank</button>\n    </div><div class=\"space-y-2\">`;\n    for (const i of items) {\n      const labels = (i.labels || []).slice(0, 4).map(l => `<span class=\"text-[10px] px-1.5 py-0.5 rounded bg-gray-800 text-gray-400\">${esc(l)}</span>`).join(' ');\n      html += `<div class=\"card p-3 hover:bg-gray-900 cursor-pointer\" onclick=\"openTaskDetail('${jsStr(i.id)}')\">\n        <div class=\"flex items-start gap-3\">\n          <div class=\"text-xs font-mono text-orange-400 mt-0.5\">#${i.rank}</div>\n          <div class=\"flex-1 min-w-0\">\n            <div class=\"text-sm text-white\">${esc(i.title)}</div>\n            <div class=\"text-[11px] text-gray-500 mt-0.5\">\n              <span class=\"text-blue-400\">${esc(i.board || '')}</span>\n              ${i.assignee ? `· ${esc(i.assignee)}` : ''}\n              · ${esc(relDate(i.updated_at))}\n              ${labels ? '· ' + labels : ''}\n            </div>\n            ${i.ai_reason ? `<div class=\"text-[11px] text-gray-400 mt-1 italic\">\"${esc(i.ai_reason)}\"</div>` : ''}\n          </div>\n          <div class=\"flex gap-1 shrink-0\" onclick=\"event.stopPropagation()\">\n            <button onclick=\"executeTask('${jsStr(i.id)}', 'plan')\" class=\"text-[10px] px-2 py-1 rounded bg-gray-800 hover:bg-gray-700 text-gray-300\">Plan</button>\n            <button onclick=\"executeTask('${jsStr(i.id)}', 'tdd')\" class=\"text-[10px] px-2 py-1 rounded bg-orange-600 hover:bg-orange-700 text-white\">Execute</button>\n          </div>\n        </div>\n      </div>`;\n    }\n    html += `</div></div>`;\n  }\n  if (!sessions.length && !recent.length && !items.length) {\n    html = `<div class=\"card p-4 text-sm text-gray-400\">No activity detected. Start a Claude, Codex, or Kimi session to see it here.</div>`;\n  }\n  pane.innerHTML = html;\n}\n\n// ── Followed ────────────────────────────────────────────────────────────\nasync function loadFollowed() {\n  const pane = document.getElementById('pane-followed');\n  pane.innerHTML = `<div class=\"text-xs text-gray-500\"><i class=\"fas fa-spinner fa-spin mr-1\"></i>Loading followed tasks…</div>`;\n  try {\n    const data = await api('/followed');\n    const items = data.items || [];\n    if (!items.length) {\n      pane.innerHTML = `<div class=\"card p-4 text-sm text-gray-400\">Nothing you're following right now.</div>`;\n      return;\n    }\n    let html = `<h2 class=\"text-sm font-bold text-white mb-3\">Following (${items.length})</h2><div class=\"space-y-2\">`;\n    for (const i of items) {\n      html += `<div class=\"card p-3 hover:bg-gray-900 cursor-pointer\" onclick=\"openTaskDetail('${jsStr(i.id)}')\">\n        <div class=\"text-sm text-white\">${esc(i.title)}</div>\n        <div class=\"text-[11px] text-gray-500 mt-0.5\">\n          <span class=\"text-blue-400\">${esc(i.board || '')}</span>\n          ${i.assignee ? `· ${esc(i.assignee)}` : ''}\n          · ${esc(relDate(i.updated_at))}\n        </div>\n      </div>`;\n    }\n    html += `</div>`;\n    pane.innerHTML = html;\n  } catch (e) {\n    pane.innerHTML = `<div class=\"card p-4 text-sm text-red-400\">Failed: ${esc(e.message)}</div>`;\n  }\n}\n\n// ── Task detail drawer ──────────────────────────────────────────────────\nasync function openTaskDetail(taskId) {\n  openDrawer('Loading…', '<div class=\"text-xs text-gray-500\"><i class=\"fas fa-spinner fa-spin mr-1\"></i>Loading task…</div>');\n  try {\n    const data = await api(`/task/${encodeURIComponent(taskId)}`);\n    const t = data.task;\n    const comments = data.comments || [];\n    document.getElementById('drawer-title').textContent = t.title;\n    let html = `<div class=\"space-y-3\">\n      <div class=\"card p-3\">\n        <div class=\"text-[10px] text-gray-500 uppercase mb-1\">Details</div>\n        <div class=\"flex flex-wrap gap-2 text-[11px] text-gray-400\">\n          <span class=\"text-blue-400\">${esc(t.board)}</span>\n          <span>${esc(t.status)}</span>\n          ${t.assignee ? `<span>@${esc(t.assignee)}</span>` : ''}\n          <span>${esc(relDate(t.updated_at))}</span>\n          ${safeHref(t.url) ? `<a href=\"${safeHref(t.url)}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"text-orange-400\">Open ↗</a>` : ''}\n        </div>\n      </div>`;\n    if (t.description) {\n      html += `<div class=\"card p-3\"><div class=\"text-[10px] text-gray-500 uppercase mb-1\">Description</div><pre class=\"text-xs text-gray-300 max-h-48 overflow-y-auto\">${esc(t.description)}</pre></div>`;\n    }\n    html += `<div class=\"flex gap-2\">\n      <button onclick=\"executeTask('${jsStr(t.id)}', 'plan')\" class=\"flex-1 text-xs px-3 py-1.5 rounded bg-gray-700 hover:bg-gray-600 text-white\"><i class=\"fas fa-list-check mr-1\"></i>Plan</button>\n      <button onclick=\"executeTask('${jsStr(t.id)}', 'tdd')\" class=\"flex-1 text-xs px-3 py-1.5 rounded bg-orange-600 hover:bg-orange-700 text-white\"><i class=\"fas fa-play mr-1\"></i>Execute (TDD)</button>\n    </div>`;\n    if (comments.length) {\n      html += `<div class=\"card p-3\"><div class=\"text-[10px] text-gray-500 uppercase mb-2\">Comments (${comments.length})</div><div class=\"space-y-2 max-h-64 overflow-y-auto\">`;\n      for (const c of comments) {\n        html += `<div class=\"bg-gray-900 rounded p-2\">\n          <div class=\"flex justify-between text-[10px] text-gray-500 mb-1\"><span>${esc(c.author)}</span><span>${esc(relDate(c.created_at))}</span></div>\n          <div class=\"text-xs text-gray-300 whitespace-pre-wrap\">${esc(c.text)}</div>\n        </div>`;\n      }\n      html += `</div></div>`;\n    }\n    html += `<div class=\"card p-3\">\n      <div class=\"text-[10px] text-gray-500 uppercase mb-1\">Reply</div>\n      <textarea id=\"reply-box\" rows=\"3\" class=\"w-full bg-gray-900 text-xs text-white rounded px-2 py-1.5 border border-gray-700\"></textarea>\n      <button onclick=\"postReply('${jsStr(t.id)}')\" class=\"mt-2 text-xs px-3 py-1 rounded bg-blue-600 text-white\">Post</button>\n    </div>`;\n    html += `</div>`;\n    document.getElementById('drawer-body').innerHTML = html;\n  } catch (e) {\n    document.getElementById('drawer-body').innerHTML = `<div class=\"text-sm text-red-400\">Failed: ${esc(e.message)}</div>`;\n  }\n}\n\nasync function postReply(taskId) {\n  const text = document.getElementById('reply-box').value.trim();\n  if (!text) return;\n  try {\n    await api(`/task/${encodeURIComponent(taskId)}/comment`, { method: 'POST', body: JSON.stringify({ text }) });\n    openTaskDetail(taskId);  // refresh\n  } catch (e) {\n    alert('Failed to post: ' + e.message);\n  }\n}\n\nasync function executeTask(taskId, mode) {\n  try {\n    const data = await api('/execute', { method: 'POST', body: JSON.stringify({ task_id: taskId, mode }) });\n    alert(`Started session ${data.session_id} (${mode}). Open the Sessions tab to follow progress.`);\n    switchTab('sessions');\n  } catch (e) {\n    alert('Execute failed: ' + e.message);\n  }\n}\n\n// ── Competitors ─────────────────────────────────────────────────────────\nlet COMP_VIEW = 'news';  // 'news' | 'list'\n\nasync function loadCompetitors() {\n  const pane = document.getElementById('pane-competitors');\n  pane.innerHTML = `<div class=\"text-xs text-gray-500\"><i class=\"fas fa-spinner fa-spin mr-1\"></i>Loading competitors…</div>`;\n  try {\n    const [comps, news] = await Promise.all([\n      api('/competitors'),\n      api('/competitors/news?limit=100').catch(() => []),\n    ]);\n    let html = `<div class=\"flex items-center gap-2 mb-3\">\n      <button onclick=\"COMP_VIEW='news'; loadCompetitors()\" class=\"text-[10px] px-3 py-1.5 rounded-full ${COMP_VIEW==='news' ? 'bg-orange-600 text-white' : 'bg-gray-800 text-gray-300'}\"><i class=\"fas fa-newspaper mr-1\"></i>News (${news.length})</button>\n      <button onclick=\"COMP_VIEW='list'; loadCompetitors()\" class=\"text-[10px] px-3 py-1.5 rounded-full ${COMP_VIEW==='list' ? 'bg-orange-600 text-white' : 'bg-gray-800 text-gray-300'}\"><i class=\"fas fa-list mr-1\"></i>Competitors (${comps.length})</button>\n      <div class=\"flex-1\"></div>\n      ${COMP_VIEW==='news' ? '<button onclick=\"scanCompetitors()\" class=\"text-[10px] px-3 py-1 rounded bg-gray-700 text-gray-300 hover:bg-gray-600\"><i class=\"fas fa-rotate mr-1\"></i>Scan</button>' : '<button onclick=\"discoverCompetitors()\" class=\"text-[10px] px-3 py-1 rounded bg-purple-600 text-white hover:bg-purple-700\"><i class=\"fas fa-magnifying-glass-plus mr-1\"></i>Discover More</button>'}\n    </div>`;\n\n    if (COMP_VIEW === 'news') {\n      html += `<div id=\"briefing\" class=\"card p-4 mb-3 border-purple-700/50\"><div class=\"text-xs text-gray-500\"><i class=\"fas fa-spinner fa-spin mr-1\"></i>Loading daily briefing…</div></div>`;\n      pane.innerHTML = html + renderNewsFeed(news);\n      loadBriefing();\n    } else {\n      if (!comps.length) {\n        html += `<div class=\"card p-4 text-sm text-gray-400\">No competitors yet. Click <b>Discover More</b> to have Maggy find competitors in your domain.</div>`;\n      } else {\n        html += `<div class=\"grid grid-cols-1 md:grid-cols-2 gap-3\">`;\n        for (const c of comps) {\n          html += `<div class=\"card p-3\">\n            <div class=\"text-sm font-bold text-white\">${esc(c.name)}</div>\n            <div class=\"text-[10px] text-gray-500\">${esc(c.category || '')} · ${esc(c.website || '')}</div>\n            <div class=\"text-xs text-gray-400 mt-2\">${esc(c.description || '')}</div>\n          </div>`;\n        }\n        html += `</div>`;\n      }\n      pane.innerHTML = html;\n    }\n  } catch (e) {\n    pane.innerHTML = `<div class=\"card p-4 text-sm text-red-400\">Failed: ${esc(e.message)}</div>`;\n  }\n}\n\nfunction renderNewsFeed(news) {\n  if (!news.length) return '<div class=\"card p-4 text-sm text-gray-400\">No competitor news yet. Click <b>Scan</b> to fetch.</div>';\n  const typeIcon = {\n    feature_launch: 'fa-rocket text-cyan-400',\n    acquisition: 'fa-handshake text-yellow-400',\n    partnership: 'fa-link text-green-400',\n    pricing_change: 'fa-tag text-orange-400',\n    funding: 'fa-dollar-sign text-green-400',\n    blog_post: 'fa-rss text-blue-400',\n    news: 'fa-newspaper text-gray-400',\n  };\n  let html = `<div class=\"space-y-1.5 max-h-[70vh] overflow-y-auto\">`;\n  for (const n of news.slice(0, 80)) {\n    const icon = typeIcon[n.event_type] || 'fa-circle text-gray-500';\n    html += `<div class=\"card px-3 py-2 flex items-start gap-2\">\n      <i class=\"fas ${icon} text-[10px] mt-1.5\"></i>\n      <div class=\"flex-1 min-w-0\">\n        <div class=\"text-xs text-white\">${esc(n.title)}</div>\n        <div class=\"text-[10px] text-gray-500 mt-0.5\">\n          <span class=\"text-orange-400\">${esc(n.competitor_name)}</span>\n          · ${esc(n.source === 'rss' ? 'blog' : 'news')}\n          · ${esc(relDate(n.created_at))}\n        </div>\n      </div>\n      ${safeHref(n.url) ? `<a href=\"${safeHref(n.url)}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"text-blue-400 text-[10px]\"><i class=\"fas fa-external-link-alt\"></i></a>` : ''}\n    </div>`;\n  }\n  html += `</div>`;\n  return html;\n}\n\nasync function loadBriefing() {\n  try {\n    const data = await api('/competitors/news/summary');\n    document.getElementById('briefing').innerHTML = `\n      <div class=\"flex items-center justify-between mb-2\">\n        <div class=\"text-[10px] text-purple-400 uppercase font-bold\"><i class=\"fas fa-robot mr-1\"></i>Daily Briefing — ${esc(data.date || '')}</div>\n        <button onclick=\"regenerateBriefing()\" class=\"text-[10px] text-gray-500 hover:text-purple-400\"><i class=\"fas fa-sync-alt mr-1\"></i>Regenerate</button>\n      </div>\n      <pre class=\"text-xs text-gray-300\">${esc(data.summary || '')}</pre>\n      <div class=\"text-[10px] text-gray-600 mt-2\">${data.total_signals || 0} signals analyzed</div>`;\n  } catch (e) {\n    document.getElementById('briefing').innerHTML = `<div class=\"text-xs text-red-400\">Briefing failed: ${esc(e.message)}</div>`;\n  }\n}\n\nasync function regenerateBriefing() {\n  const el = document.getElementById('briefing');\n  if (el) el.innerHTML = '<div class=\"text-xs text-gray-500\"><i class=\"fas fa-spinner fa-spin mr-1\"></i>Regenerating…</div>';\n  try {\n    await api('/competitors/news/summary?refresh=true');\n    loadBriefing();\n  } catch (e) {\n    if (el) el.innerHTML = `<div class=\"text-xs text-red-400\">Regenerate failed: ${esc(e.message)}</div>`;\n  }\n}\n\nasync function discoverCompetitors() {\n  if (!confirm('Ask Maggy to discover competitors for your domain? This calls the AI.')) return;\n  try {\n    const data = await api('/competitors/discover', { method: 'POST' });\n    alert(`Added ${data.added} new competitors (total: ${data.total})`);\n    loadCompetitors();\n  } catch (e) {\n    alert('Discovery failed: ' + e.message);\n  }\n}\n\nasync function scanCompetitors() {\n  try {\n    const data = await api('/competitors/monitor', { method: 'POST' });\n    alert(`Found ${data.rss || 0} blog posts + ${data.news || 0} news items across ${data.total_competitors} competitors`);\n    loadCompetitors();\n  } catch (e) {\n    alert('Scan failed: ' + e.message);\n  }\n}\n\n// ── Chat ────────────────────────────────────────────────────────────────\nlet CHAT_SESSION_ID = null;\nlet CHAT_SESSIONS_CACHE = [];\n\nasync function loadChat() {\n  const pane = document.getElementById('pane-chat');\n  pane.innerHTML = `<div class=\"text-xs text-gray-500\"><i class=\"fas fa-spinner fa-spin mr-1\"></i>Auto-connecting to active projects…</div>`;\n  try {\n    const result = await api('/chat/auto-connect', { method: 'POST' });\n    CHAT_SESSIONS_CACHE = result.sessions || [];\n    if (!CHAT_SESSION_ID && CHAT_SESSIONS_CACHE.length) {\n      CHAT_SESSION_ID = CHAT_SESSIONS_CACHE[0].id;\n    }\n    renderChatUI(pane);\n  } catch (e) {\n    pane.innerHTML = `<div class=\"card p-4 text-sm text-red-400\">Failed: ${esc(e.message)}</div>`;\n  }\n}\n\nfunction renderChatUI(pane) {\n  const sessions = CHAT_SESSIONS_CACHE;\n  let html = `<div class=\"flex h-[calc(100vh-10rem)]\">`;\n  html += renderChatSidebar(sessions);\n  html += renderChatMain();\n  html += `</div>`;\n  pane.innerHTML = html;\n  if (CHAT_SESSION_ID) loadChatMessages(CHAT_SESSION_ID);\n}\n\nfunction renderChatSidebar(sessions) {\n  let html = `<div class=\"w-60 shrink-0 border-r border-gray-800 pr-3 overflow-y-auto\">`;\n  html += `<div class=\"flex items-center justify-between mb-2\">\n    <span class=\"text-[10px] text-gray-500 uppercase font-bold\"><i class=\"fas fa-circle text-green-400 text-[8px] mr-1\"></i>Connected Projects</span>\n    <button onclick=\"newChatSession()\" class=\"text-[10px] px-2 py-1 rounded bg-orange-600 hover:bg-orange-700 text-white\"><i class=\"fas fa-plus mr-1\"></i>New</button>\n  </div><div class=\"space-y-1\">`;\n  if (!sessions.length) {\n    html += `<div class=\"text-[10px] text-gray-500 p-2\">No active CLI sessions detected</div>`;\n  }\n  for (const s of sessions) {\n    const active = s.id === CHAT_SESSION_ID ? 'bg-gray-800 border-orange-500' : 'border-transparent hover:bg-gray-900';\n    const ctx = s.history_context ? ' title=\"' + esc(s.history_context) + '\"' : '';\n    html += `<div class=\"card px-2 py-1.5 cursor-pointer border ${active}\" onclick=\"openChatSession('${jsStr(s.id)}')\"${ctx}>\n      <div class=\"flex items-center gap-1\"><i class=\"fas fa-circle text-green-400 text-[6px]\"></i><span class=\"text-xs text-white truncate\">${esc(s.project_key)}</span></div>\n      <div class=\"text-[10px] text-gray-500 truncate\">${esc(s.working_dir)}</div>\n      ${s.history_context ? '<div class=\"text-[9px] text-gray-600 mt-0.5 truncate\"><i class=\"fas fa-history mr-0.5\"></i>has history</div>' : ''}\n    </div>`;\n  }\n  html += `</div></div>`;\n  return html;\n}\n\nfunction renderChatMain() {\n  let html = `<div class=\"flex-1 flex flex-col pl-4\">`;\n  if (CHAT_SESSION_ID) {\n    html += `<div id=\"chat-messages\" class=\"flex-1 overflow-y-auto space-y-3 mb-3\"></div>`;\n    html += `<div class=\"shrink-0 flex gap-2\">\n      <input id=\"chat-input\" type=\"text\" placeholder=\"Type a message to Claude…\"\n        class=\"flex-1 bg-gray-900 text-sm text-white rounded px-3 py-2 border border-gray-700 focus:border-orange-500 outline-none\"\n        onkeydown=\"if(event.key==='Enter')sendChatMessage()\" />\n      <button onclick=\"sendChatMessage()\" class=\"px-4 py-2 rounded bg-orange-600 hover:bg-orange-700 text-white text-sm\"><i class=\"fas fa-paper-plane\"></i></button>\n    </div>`;\n  } else {\n    html += `<div class=\"flex-1 flex items-center justify-center\">\n      <div class=\"text-center\">\n        <i class=\"fas fa-robot text-4xl text-gray-700 mb-3\"></i>\n        <div class=\"text-sm text-gray-400 mb-2\">No active CLI sessions detected</div>\n        <div class=\"text-xs text-gray-500\">Start a Claude Code session in any project and Maggy will auto-connect</div>\n      </div>\n    </div>`;\n  }\n  html += `</div>`;\n  return html;\n}\n\nasync function newChatSession() {\n  let projects;\n  try {\n    const [cfg, activity] = await Promise.all([\n      api('/config').catch(() => ({ codebases: [] })),\n      api('/activity').catch(() => ({ sessions: [] })),\n    ]);\n    const configProjects = (cfg.codebases || []).map(c => ({ key: c.key, path: c.path }));\n    const activeProjects = (activity.sessions || []).map(s => ({ key: s.project, path: s.project_path }));\n    const seen = new Set();\n    projects = [];\n    for (const p of [...activeProjects, ...configProjects]) {\n      if (p.key && !seen.has(p.key)) { seen.add(p.key); projects.push(p); }\n    }\n  } catch { projects = []; }\n  if (!projects.length) { alert('No codebases found.'); return; }\n  let chosen = projects[0];\n  if (projects.length > 1) {\n    const name = prompt('Select project:\\n' + projects.map((p, i) => `${i+1}. ${p.key}`).join('\\n') + '\\n\\nEnter name:', projects[0].key);\n    if (!name) return;\n    chosen = projects.find(p => p.key === name) || { key: name, path: '' };\n  }\n  try {\n    const data = await api('/chat/sessions', { method: 'POST', body: JSON.stringify({ project_key: chosen.key, project_path: chosen.path }) });\n    CHAT_SESSION_ID = data.id;\n    loadChat();\n  } catch (e) { alert('Failed: ' + e.message); }\n}\n\nfunction openChatSession(id) {\n  CHAT_SESSION_ID = id;\n  const pane = document.getElementById('pane-chat');\n  if (pane) renderChatUI(pane);\n}\n\nasync function loadChatMessages(id) {\n  const el = document.getElementById('chat-messages');\n  if (!el) return;\n  try {\n    const data = await api(`/chat/sessions/${id}`);\n    let html = renderSessionHeader(data);\n    if (data.history_context && !(data.messages || []).length) {\n      html += renderHistoryContext(data.history_context);\n    }\n    for (const m of data.messages || []) {\n      html += m.role === 'user' ? renderUserMsg(m) : renderAssistantMsg(m);\n    }\n    el.innerHTML = html;\n    el.scrollTop = el.scrollHeight;\n  } catch (e) {\n    el.innerHTML = `<div class=\"text-xs text-red-400\">${esc(e.message)}</div>`;\n  }\n}\n\nfunction renderSessionHeader(data) {\n  return `<div class=\"text-[10px] text-gray-500 mb-2\"><i class=\"fas fa-folder-open mr-1\"></i>${esc(data.project_key)} · <span class=\"font-mono\">${esc(data.working_dir)}</span></div>`;\n}\n\nfunction renderHistoryContext(ctx) {\n  return `<div class=\"card px-3 py-2 mb-2 border border-gray-700 bg-gray-900/50\">\n    <div class=\"text-[10px] text-gray-400 font-bold mb-1\"><i class=\"fas fa-history mr-1\"></i>Session History (Maggy knows this)</div>\n    <pre class=\"text-[10px] text-gray-500 whitespace-pre-wrap\">${esc(ctx)}</pre>\n  </div>`;\n}\n\nfunction renderUserMsg(m) {\n  return `<div class=\"flex justify-end\"><div class=\"max-w-[80%] bg-orange-600/20 border border-orange-600/30 rounded-lg px-3 py-2\">\n    <div class=\"text-xs text-white\">${esc(m.content)}</div>\n    <div class=\"text-[10px] text-gray-500 mt-1\">${esc(relDate(m.timestamp))}</div>\n  </div></div>`;\n}\n\nfunction renderAssistantMsg(m) {\n  return `<div class=\"flex justify-start\"><div class=\"max-w-[80%] card px-3 py-2\">\n    <pre class=\"text-xs text-gray-300 whitespace-pre-wrap\">${esc(m.content)}</pre>\n    <div class=\"text-[10px] text-gray-500 mt-1\">${esc(relDate(m.timestamp))}</div>\n  </div></div>`;\n}\n\nasync function sendChatMessage() {\n  const input = document.getElementById('chat-input');\n  if (!input) return;\n  const message = input.value.trim();\n  if (!message || !CHAT_SESSION_ID) return;\n  input.value = '';\n  input.disabled = true;\n  const el = document.getElementById('chat-messages');\n  el.innerHTML += renderUserMsg({ content: message, timestamp: '' });\n  el.innerHTML += `<div id=\"stream-response\" class=\"flex justify-start\"><div class=\"max-w-[80%] card px-3 py-2\">\n    <pre id=\"stream-text\" class=\"text-xs text-gray-300\"><i class=\"fas fa-spinner fa-spin text-orange-400\"></i> Claude is thinking…</pre>\n  </div></div>`;\n  el.scrollTop = el.scrollHeight;\n  try {\n    await streamChatResponse(message, el);\n  } catch (e) {\n    const streamEl = document.getElementById('stream-text');\n    if (streamEl) streamEl.innerHTML = `<span class=\"text-red-400\">Error: ${esc(e.message)}</span>`;\n  }\n  input.disabled = false;\n  input.focus();\n}\n\nasync function streamChatResponse(message, el) {\n  const apiKey = localStorage.getItem('maggy-api-key') || '';\n  const resp = await fetch(`${API}/chat/sessions/${CHAT_SESSION_ID}/send`, {\n    method: 'POST',\n    headers: { 'Content-Type': 'application/json', ...(apiKey ? { 'X-API-Key': apiKey } : {}) },\n    body: JSON.stringify({ message }),\n  });\n  const reader = resp.body.getReader();\n  const decoder = new TextDecoder();\n  let responseText = '';\n  const streamEl = document.getElementById('stream-text');\n  while (true) {\n    const { done, value } = await reader.read();\n    if (done) break;\n    const chunk = decoder.decode(value, { stream: true });\n    for (const line of chunk.split('\\n')) {\n      if (!line.startsWith('data: ')) continue;\n      try {\n        const data = JSON.parse(line.slice(6));\n        if (data.type === 'done') continue;\n        if (data.type === 'error') { streamEl.innerHTML = `<span class=\"text-red-400\">${esc(data.content)}</span>`; continue; }\n        if (data.content) { responseText += data.content; streamEl.textContent = responseText; el.scrollTop = el.scrollHeight; }\n      } catch {}\n    }\n  }\n  if (!responseText) streamEl.textContent = '(no response)';\n}\n\n// ── Settings ────────────────────────────────────────────────────────────\nasync function loadSettings() {\n  const pane = document.getElementById('pane-settings');\n  pane.innerHTML = `<div class=\"text-xs text-gray-500\"><i class=\"fas fa-spinner fa-spin mr-1\"></i>Loading settings…</div>`;\n  try {\n    const cfg = await api('/config');\n    pane.innerHTML = `\n      <h2 class=\"text-sm font-bold text-white mb-3\">Settings</h2>\n      <div class=\"card p-4 space-y-3 text-sm text-gray-300\">\n        <div><span class=\"text-gray-500 text-[10px] uppercase\">Org</span> — <b>${esc(cfg.org.name)}</b> ${cfg.org.domain ? `(domain: <span class=\"text-orange-400\">${esc(cfg.org.domain)}</span>)` : ''}</div>\n        <div><span class=\"text-gray-500 text-[10px] uppercase\">Issue Tracker</span> — ${esc(cfg.issue_tracker.provider)}</div>\n        <div><span class=\"text-gray-500 text-[10px] uppercase\">Codebases</span>\n          <ul class=\"ml-4 text-xs\">${cfg.codebases.map(c => `<li>${esc(c.key)} → <code class=\"text-gray-400\">${esc(c.path)}</code></li>`).join('')}</ul>\n        </div>\n        <div><span class=\"text-gray-500 text-[10px] uppercase\">Competitors</span> — categories: ${cfg.competitors.categories.map(esc).join(', ') || '—'}</div>\n        <div><span class=\"text-gray-500 text-[10px] uppercase\">OKRs</span> — source: ${esc(cfg.okrs.source)} (${cfg.okrs.count} items)</div>\n        <div><span class=\"text-gray-500 text-[10px] uppercase\">AI</span> — ${esc(cfg.ai.provider)} / ${esc(cfg.ai.model)} · API key ${cfg.ai.has_key ? '<span class=\"text-green-400\">set</span>' : '<span class=\"text-red-400\">MISSING</span>'}</div>\n      </div>\n      <p class=\"text-[11px] text-gray-500 mt-4\">Edit <code>~/.maggy/config.yaml</code> and restart Maggy to apply changes.</p>\n    `;\n  } catch (e) {\n    pane.innerHTML = `<div class=\"card p-4 text-sm text-red-400\">Failed: ${esc(e.message)}</div>`;\n  }\n}\n\n// ── Budget ──────────────────────────────────────────────────────────────\nasync function loadBudget() {\n  const pane = document.getElementById('pane-budget');\n  pane.innerHTML = `<div class=\"text-xs text-gray-500\"><i class=\"fas fa-spinner fa-spin mr-1\"></i>Loading budget…</div>`;\n  try {\n    const [status, byProvider] = await Promise.all([\n      api('/budget'),\n      api('/budget/by-provider'),\n    ]);\n    const statusColor = status.status === 'ok' ? 'text-green-400' : status.status === 'warning' ? 'text-yellow-400' : 'text-red-400';\n    let html = `<h2 class=\"text-sm font-bold text-white mb-3\">Token Budget</h2>`;\n    html += `<div class=\"grid grid-cols-1 md:grid-cols-3 gap-3 mb-4\">\n      <div class=\"card p-4 text-center\">\n        <div class=\"text-2xl font-bold ${statusColor}\">$${esc(status.spent_today_usd)}</div>\n        <div class=\"text-[10px] text-gray-500\">Spent Today</div>\n      </div>\n      <div class=\"card p-4 text-center\">\n        <div class=\"text-2xl font-bold text-gray-300\">$${esc(status.daily_limit_usd)}</div>\n        <div class=\"text-[10px] text-gray-500\">Daily Limit</div>\n      </div>\n      <div class=\"card p-4 text-center\">\n        <div class=\"text-2xl font-bold ${statusColor}\">${esc(Math.round(status.utilization * 100))}%</div>\n        <div class=\"text-[10px] text-gray-500\">${esc(status.status)}</div>\n      </div>\n    </div>`;\n    const providers = byProvider.providers || byProvider || [];\n    if (providers.length) {\n      html += `<h3 class=\"text-xs font-bold text-gray-400 mb-2\">By Provider</h3><div class=\"space-y-1\">`;\n      for (const p of providers) {\n        html += `<div class=\"card px-3 py-2 flex justify-between\"><span class=\"text-xs text-white\">${esc(p.provider)}</span><span class=\"text-xs text-orange-400\">$${esc(p.spent_usd)}</span></div>`;\n      }\n      html += `</div>`;\n    }\n    pane.innerHTML = html;\n  } catch (e) {\n    pane.innerHTML = `<div class=\"card p-4 text-sm text-red-400\">Failed: ${esc(e.message)}</div>`;\n  }\n}\n\n// ── Model Routing ───────────────────────────────────────────────────────\nasync function loadRouting() {\n  const pane = document.getElementById('pane-routing');\n  pane.innerHTML = `<div class=\"text-xs text-gray-500\"><i class=\"fas fa-spinner fa-spin mr-1\"></i>Loading model performance…</div>`;\n  try {\n    const data = await api('/routing/heatmap');\n    const heatmap = data.heatmap || data || [];\n    let html = `<h2 class=\"text-sm font-bold text-white mb-3\">Model Performance Heatmap</h2>`;\n    if (!heatmap.length) {\n      html += `<div class=\"card p-4 text-sm text-gray-400\">No reward data yet. Execute some tasks to build the heatmap.</div>`;\n    } else {\n      html += `<div class=\"overflow-x-auto\"><table class=\"text-xs w-full\"><thead><tr class=\"text-gray-500\">\n        <th class=\"text-left p-2\">Model</th><th class=\"text-left p-2\">Task Type</th><th class=\"text-left p-2\">Blast Tier</th><th class=\"text-right p-2\">Avg Reward</th><th class=\"text-right p-2\">Samples</th>\n      </tr></thead><tbody>`;\n      for (const r of heatmap) {\n        const color = r.avg_reward >= 0.7 ? 'text-green-400' : r.avg_reward >= 0.4 ? 'text-yellow-400' : 'text-red-400';\n        html += `<tr class=\"border-t border-gray-800\"><td class=\"p-2 text-white\">${esc(r.model)}</td><td class=\"p-2\">${esc(r.task_type)}</td><td class=\"p-2\">${esc(r.blast_tier)}</td><td class=\"p-2 text-right ${color}\">${esc(r.avg_reward)}</td><td class=\"p-2 text-right text-gray-500\">${esc(r.samples)}</td></tr>`;\n      }\n      html += `</tbody></table></div>`;\n    }\n    pane.innerHTML = html;\n  } catch (e) {\n    pane.innerHTML = `<div class=\"card p-4 text-sm text-red-400\">Failed: ${esc(e.message)}</div>`;\n  }\n}\n\n// ── Process Intelligence ────────────────────────────────────────────────\nasync function loadProcess() {\n  const pane = document.getElementById('pane-process');\n  pane.innerHTML = `<div class=\"text-xs text-gray-500\"><i class=\"fas fa-spinner fa-spin mr-1\"></i>Loading process intelligence…</div>`;\n  try {\n    const [events, history, improve, landscape, activity] = await Promise.all([\n      api('/events/count').catch(() => ({ count: 0 })),\n      api('/history/report').catch(() => ({ status: 'no_data' })),\n      api('/improve/report').catch(() => ({ report: null })),\n      api('/cikg/landscape').catch(() => ({ technologies: 0 })),\n      api('/activity').catch(() => ({ sessions: [], recent: [] })),\n    ]);\n    let html = `<h2 class=\"text-sm font-bold text-white mb-3\">Process Intelligence</h2>`;\n    html += renderPIStats(events, history, landscape);\n    html += renderPIPatterns(history);\n    html += renderPIHealth(improve);\n    html += renderPIActivity(activity);\n    html += renderPIActions();\n    pane.innerHTML = html;\n  } catch (e) {\n    pane.innerHTML = `<div class=\"card p-4 text-sm text-red-400\">Failed: ${esc(e.message)}</div>`;\n  }\n}\n\nfunction renderPIStats(events, history, landscape) {\n  return `<div class=\"grid grid-cols-2 md:grid-cols-4 gap-3 mb-4\">\n    <div class=\"card p-3 text-center\"><div class=\"text-xl font-bold text-orange-400\">${esc(events.count || 0)}</div><div class=\"text-[10px] text-gray-500\">Events</div></div>\n    <div class=\"card p-3 text-center\"><div class=\"text-xl font-bold text-blue-400\">${esc(history.total_sessions || 0)}</div><div class=\"text-[10px] text-gray-500\">CLI Sessions</div></div>\n    <div class=\"card p-3 text-center\"><div class=\"text-xl font-bold text-green-400\">${esc(history.total_prompts || 0)}</div><div class=\"text-[10px] text-gray-500\">Total Prompts</div></div>\n    <div class=\"card p-3 text-center\"><div class=\"text-xl font-bold text-purple-400\">${esc(landscape.technologies || 0)}</div><div class=\"text-[10px] text-gray-500\">Technologies</div></div>\n  </div>`;\n}\n\nfunction renderPIPatterns(history) {\n  if (!history.patterns || !history.patterns.length) return '';\n  let html = `<div class=\"card p-4 mb-3\"><div class=\"text-[10px] text-gray-500 uppercase mb-2\"><i class=\"fas fa-chart-bar mr-1\"></i>Session Patterns</div><div class=\"space-y-1\">`;\n  for (const p of history.patterns.slice(0, 5)) {\n    html += `<div class=\"text-xs text-gray-300\">- ${esc(typeof p === 'string' ? p : JSON.stringify(p))}</div>`;\n  }\n  return html + `</div></div>`;\n}\n\nfunction renderPIHealth(improve) {\n  const report = improve.report;\n  if (!report) return '';\n  const health = report.health_summary || {};\n  const keys = Object.keys(health);\n  if (!keys.length) return '';\n  let html = `<div class=\"card p-4 mb-3\"><div class=\"text-[10px] text-gray-500 uppercase mb-2\"><i class=\"fas fa-heartbeat mr-1\"></i>Health Signals</div>`;\n  html += `<div class=\"grid grid-cols-2 md:grid-cols-4 gap-2\">`;\n  for (const k of keys) {\n    const val = health[k];\n    const pct = Math.round(val * 100);\n    const color = pct >= 80 ? 'text-green-400' : pct >= 50 ? 'text-yellow-400' : 'text-red-400';\n    html += `<div class=\"text-center\"><div class=\"text-lg font-bold ${color}\">${pct}%</div><div class=\"text-[10px] text-gray-500 capitalize\">${esc(k)}</div></div>`;\n  }\n  html += `</div>`;\n  if (report.top_actions && report.top_actions.length) {\n    html += `<div class=\"mt-3 space-y-1\">`;\n    for (const a of report.top_actions) {\n      html += `<div class=\"text-xs text-yellow-300\"><i class=\"fas fa-lightbulb mr-1\"></i>${esc(a)}</div>`;\n    }\n    html += `</div>`;\n  }\n  return html + `</div>`;\n}\n\nfunction renderPIActivity(activity) {\n  const sessions = activity.sessions || [];\n  const recent = activity.recent || [];\n  if (!sessions.length && !recent.length) return '';\n  let html = `<div class=\"card p-4 mb-3\"><div class=\"text-[10px] text-gray-500 uppercase mb-2\"><i class=\"fas fa-bolt mr-1\"></i>Live Activity</div>`;\n  if (sessions.length) {\n    html += `<div class=\"mb-2\"><span class=\"text-[10px] text-green-400 font-bold\">${sessions.length} active session${sessions.length > 1 ? 's' : ''}</span></div>`;\n    html += `<div class=\"grid grid-cols-2 md:grid-cols-4 gap-2 mb-3\">`;\n    const seen = new Set();\n    for (const s of sessions) {\n      if (seen.has(s.project)) continue;\n      seen.add(s.project);\n      html += `<div class=\"bg-gray-900 rounded px-2 py-1.5\"><div class=\"text-xs text-white truncate\"><i class=\"fas fa-circle text-green-400 text-[6px] mr-1\"></i>${esc(s.project)}</div><div class=\"text-[9px] text-gray-500\">${esc(s.status)}</div></div>`;\n    }\n    html += `</div>`;\n  }\n  if (recent.length) {\n    html += `<div class=\"text-[10px] text-gray-500 mb-1\">Recent prompts:</div><div class=\"space-y-1\">`;\n    for (const p of recent.slice(0, 5)) {\n      html += `<div class=\"text-[10px] text-gray-400 truncate\"><span class=\"text-gray-600\">${esc(p.project)}</span> ${esc(p.text)}</div>`;\n    }\n    html += `</div>`;\n  }\n  return html + `</div>`;\n}\n\nfunction renderPIActions() {\n  return `<div class=\"card p-4\"><div class=\"text-[10px] text-gray-500 uppercase mb-2\">Quick Actions</div>\n    <div class=\"flex flex-wrap gap-2\">\n      <button id=\"btn-history\" onclick=\"triggerAnalysis('history')\" class=\"text-[10px] px-3 py-1.5 rounded bg-gray-800 hover:bg-gray-700 text-gray-300\"><i class=\"fas fa-clock-rotate-left mr-1\"></i>Analyze History</button>\n      <button id=\"btn-improve\" onclick=\"triggerAnalysis('improve')\" class=\"text-[10px] px-3 py-1.5 rounded bg-gray-800 hover:bg-gray-700 text-gray-300\"><i class=\"fas fa-brain mr-1\"></i>Self-Improve</button>\n      <a href=\"/api/events?limit=20\" target=\"_blank\" class=\"text-[10px] px-3 py-1.5 rounded bg-gray-800 hover:bg-gray-700 text-blue-400\">Events JSON</a>\n      <a href=\"/api/cikg/landscape\" target=\"_blank\" class=\"text-[10px] px-3 py-1.5 rounded bg-gray-800 hover:bg-gray-700 text-blue-400\">CIKG Landscape</a>\n    </div>\n  </div>`;\n}\n\nasync function triggerAnalysis(type) {\n  const btn = document.getElementById('btn-' + type);\n  const origText = btn ? btn.innerHTML : '';\n  if (btn) btn.innerHTML = `<i class=\"fas fa-spinner fa-spin mr-1\"></i>Running…`;\n  if (btn) btn.disabled = true;\n  try {\n    let result;\n    if (type === 'history') result = await api('/history/analyze', { method: 'POST' });\n    else if (type === 'improve') result = await api('/improve/analyze', { method: 'POST' });\n    showToast(type === 'history'\n      ? `History: ${result.total_sessions || 0} sessions, ${result.total_prompts || 0} prompts`\n      : `Improve: ${(result.report || {}).total_signals || 0} signals collected`);\n    loadProcess();\n  } catch (e) {\n    alert('Analysis failed: ' + e.message);\n    if (btn) { btn.innerHTML = origText; btn.disabled = false; }\n  }\n}\n\nfunction showToast(msg) {\n  const el = document.createElement('div');\n  el.className = 'fixed bottom-4 right-4 bg-green-600 text-white text-xs px-4 py-2 rounded shadow-lg z-50';\n  el.innerHTML = `<i class=\"fas fa-check mr-1\"></i>${esc(msg)}`;\n  document.body.appendChild(el);\n  setTimeout(() => el.remove(), 3000);\n}\n\n// ── Forge ───────────────────────────────────────────────────────────────\nasync function loadForge() {\n  const pane = document.getElementById('pane-forge');\n  pane.innerHTML = `<div class=\"text-xs text-gray-500\"><i class=\"fas fa-spinner fa-spin mr-1\"></i>Loading forge…</div>`;\n  try {\n    const [status, gaps] = await Promise.all([\n      api('/forge/status'),\n      api('/forge/gaps'),\n    ]);\n    let html = `<h2 class=\"text-sm font-bold text-white mb-3\">MCP Forge</h2>`;\n    html += `<div class=\"grid grid-cols-1 md:grid-cols-3 gap-3 mb-4\">\n      <div class=\"card p-4 text-center\">\n        <div class=\"text-xl font-bold ${status.available ? 'text-green-400' : 'text-red-400'}\">${status.available ? 'Online' : 'Offline'}</div>\n        <div class=\"text-[10px] text-gray-500\">Status</div>\n      </div>\n      <div class=\"card p-4 text-center\">\n        <div class=\"text-xl font-bold text-orange-400\">${esc(status.registry_count || 0)}</div>\n        <div class=\"text-[10px] text-gray-500\">Tools in Registry</div>\n      </div>\n      <div class=\"card p-4 text-center\">\n        <div class=\"text-xl font-bold text-yellow-400\">${esc(status.pending_gaps || 0)}</div>\n        <div class=\"text-[10px] text-gray-500\">Detected Gaps</div>\n      </div>\n    </div>`;\n    const gapList = gaps.gaps || [];\n    if (gapList.length) {\n      html += `<h3 class=\"text-xs font-bold text-gray-400 mb-2\">Capability Gaps</h3><div class=\"space-y-1\">`;\n      for (const g of gapList) {\n        html += `<div class=\"card px-3 py-2 flex justify-between\"><span class=\"text-xs text-white\">${esc(g.capability)}</span><span class=\"text-xs text-gray-400\">${esc(g.occurrences)} hits ${g.triggered ? '<span class=\"text-orange-400\">TRIGGERED</span>' : ''}</span></div>`;\n      }\n      html += `</div>`;\n    }\n    pane.innerHTML = html;\n  } catch (e) {\n    pane.innerHTML = `<div class=\"card p-4 text-sm text-red-400\">Failed: ${esc(e.message)}</div>`;\n  }\n}\n\n// ── Setup Wizard ────────────────────────────────────────────────────────\nasync function checkSetup() {\n  try {\n    const status = await api('/setup/status');\n    if (status.configured) return true;\n    showSetupWizard(status);\n    return false;\n  } catch { return true; }\n}\n\nfunction showSetupWizard(status) {\n  const pane = document.getElementById('pane-inbox');\n  const missing = status.steps.filter(s => s.status === 'missing');\n  const disc = status.discovery || {};\n  const clis = disc.clis || {};\n  const cliAuth = disc.cli_auth || {};\n  const tokens = disc.tokens || {};\n  let html = `<div class=\"max-w-2xl mx-auto mt-4 space-y-4\">`;\n  // Header\n  html += `<div class=\"card p-6\">\n    <div class=\"flex items-center gap-3 mb-3\">\n      <i class=\"fas fa-wand-magic-sparkles text-orange-500 text-xl\"></i>\n      <h2 class=\"text-lg font-bold text-white\">Welcome to Maggy</h2>\n      <span class=\"text-[10px] text-gray-500\">${esc(status.progress)} configured</span>\n    </div>\n    <div class=\"space-y-2\">`;\n  for (const step of status.steps) {\n    const icon = step.status === 'done'\n      ? '<i class=\"fas fa-check-circle text-green-400\"></i>'\n      : '<i class=\"fas fa-circle-xmark text-red-400/60\"></i>';\n    html += `<div class=\"flex items-center gap-3 px-3 py-2 rounded ${step.status === 'done' ? 'bg-green-900/20' : 'bg-red-900/10'}\">\n      ${icon}\n      <span class=\"text-sm ${step.status === 'done' ? 'text-green-300' : 'text-gray-300'}\">${esc(step.label)}</span>\n      ${step.status !== 'done' && step.hint ? `<span class=\"text-[10px] text-gray-500 ml-auto\">${esc(step.hint)}</span>` : ''}\n    </div>`;\n  }\n  html += `</div></div>`;\n  // Discovered CLIs\n  const cliNames = Object.keys(clis);\n  if (cliNames.length) {\n    html += `<div class=\"card p-4\">\n      <div class=\"text-[10px] text-gray-500 uppercase mb-2\"><i class=\"fas fa-terminal mr-1\"></i>Detected CLI Tools</div>\n      <div class=\"space-y-1\">`;\n    for (const name of cliNames) {\n      const auth = cliAuth[name];\n      html += `<div class=\"flex items-center gap-2 text-xs\">\n        <i class=\"fas fa-check text-green-400\"></i>\n        <span class=\"text-white font-mono\">${esc(name)}</span>\n        <span class=\"text-gray-500\">${esc(clis[name])}</span>\n        ${auth ? '<span class=\"text-[10px] px-1.5 py-0.5 rounded bg-green-900/40 text-green-400\">authenticated</span>' : '<span class=\"text-[10px] px-1.5 py-0.5 rounded bg-gray-800 text-gray-500\">not logged in</span>'}\n      </div>`;\n    }\n    html += `</div></div>`;\n  }\n  // Token sources\n  html += `<div class=\"card p-4\">\n    <div class=\"text-[10px] text-gray-500 uppercase mb-2\"><i class=\"fas fa-key mr-1\"></i>Credential Sources</div>\n    <div class=\"space-y-1 text-xs\">`;\n  if (tokens.GITHUB_TOKEN) html += `<div class=\"text-green-400\"><i class=\"fas fa-check mr-1\"></i>GITHUB_TOKEN (env var)</div>`;\n  else if (tokens.GIT_CREDENTIAL) html += `<div class=\"text-green-400\"><i class=\"fas fa-check mr-1\"></i>GitHub token (git credential helper)</div>`;\n  else html += `<div class=\"text-red-400/60\"><i class=\"fas fa-xmark mr-1\"></i>No GitHub token found</div>`;\n  if (tokens.ANTHROPIC_API_KEY) html += `<div class=\"text-green-400\"><i class=\"fas fa-check mr-1\"></i>ANTHROPIC_API_KEY (env var)</div>`;\n  else if (cliAuth.claude) html += `<div class=\"text-green-400\"><i class=\"fas fa-check mr-1\"></i>Claude Code subscription (CLI auth)</div>`;\n  else html += `<div class=\"text-gray-500\"><i class=\"fas fa-info-circle mr-1\"></i>No Anthropic API key (Claude CLI can be used instead)</div>`;\n  html += `</div></div>`;\n  // Actions\n  html += `<div class=\"flex gap-2\">\n    <button onclick=\"autoConfigureSetup()\" class=\"text-xs px-4 py-2 rounded bg-orange-600 hover:bg-orange-700 text-white\"><i class=\"fas fa-wand-magic mr-1\"></i>Auto-Configure</button>\n    <button onclick=\"reloadConfig()\" class=\"text-xs px-4 py-2 rounded bg-gray-700 hover:bg-gray-600 text-gray-300\"><i class=\"fas fa-rotate mr-1\"></i>Reload</button>\n    <button onclick=\"enterLocalMode()\" class=\"text-xs px-4 py-2 rounded bg-gray-800 hover:bg-gray-700 text-gray-400\"><i class=\"fas fa-laptop mr-1\"></i>Local Mode</button>\n  </div>`;\n  html += `</div>`;\n  pane.innerHTML = html;\n}\n\nfunction enterLocalMode() {\n  const pane = document.getElementById('pane-inbox');\n  pane.innerHTML = `<div class=\"card p-6 max-w-2xl mx-auto mt-4\">\n    <div class=\"flex items-center gap-3 mb-3\">\n      <i class=\"fas fa-laptop text-blue-400 text-lg\"></i>\n      <h2 class=\"text-sm font-bold text-white\">Local Mode</h2>\n    </div>\n    <p class=\"text-xs text-gray-400 mb-3\">These features work without provider credentials:</p>\n    <div class=\"grid grid-cols-2 gap-2\">\n      <button onclick=\"switchTab('budget')\" class=\"card p-3 text-left hover:bg-gray-900\"><div class=\"text-xs text-white\"><i class=\"fas fa-wallet text-orange-400 mr-1\"></i>Budget</div><div class=\"text-[10px] text-gray-500\">Track token spend</div></button>\n      <button onclick=\"switchTab('routing')\" class=\"card p-3 text-left hover:bg-gray-900\"><div class=\"text-xs text-white\"><i class=\"fas fa-route text-blue-400 mr-1\"></i>Model Routing</div><div class=\"text-[10px] text-gray-500\">Performance heatmap</div></button>\n      <button onclick=\"switchTab('process')\" class=\"card p-3 text-left hover:bg-gray-900\"><div class=\"text-xs text-white\"><i class=\"fas fa-chart-line text-green-400 mr-1\"></i>Process</div><div class=\"text-[10px] text-gray-500\">Events + knowledge graph</div></button>\n      <button onclick=\"switchTab('forge')\" class=\"card p-3 text-left hover:bg-gray-900\"><div class=\"text-xs text-white\"><i class=\"fas fa-hammer text-yellow-400 mr-1\"></i>Forge</div><div class=\"text-[10px] text-gray-500\">MCP tool gaps</div></button>\n    </div>\n    <button onclick=\"loadAll()\" class=\"mt-3 text-[10px] text-gray-500 hover:text-white\"><i class=\"fas fa-arrow-left mr-1\"></i>Back to setup</button>\n  </div>`;\n}\n\nasync function reloadConfig() {\n  try {\n    const result = await api('/setup/reload', { method: 'POST' });\n    if (result.mode === 'full') {\n      loadAll();\n    } else {\n      const status = await api('/setup/status');\n      showSetupWizard(status);\n    }\n  } catch (e) {\n    alert('Reload failed: ' + e.message);\n  }\n}\n\nasync function autoConfigureSetup() {\n  const btn = event.target;\n  btn.innerHTML = '<i class=\"fas fa-spinner fa-spin mr-1\"></i>Discovering...';\n  btn.disabled = true;\n  try {\n    const result = await api('/setup/auto-configure', { method: 'POST' });\n    if (result.mode === 'full') {\n      loadAll();\n    } else {\n      const status = await api('/setup/status');\n      showSetupWizard(status);\n    }\n  } catch (e) {\n    alert('Auto-configure failed: ' + e.message);\n    btn.innerHTML = '<i class=\"fas fa-wand-magic mr-1\"></i>Auto-Configure';\n    btn.disabled = false;\n  }\n}\n\n// ── Init ────────────────────────────────────────────────────────────────\nasync function loadAll() {\n  try {\n    const h = await api('/health');\n    document.getElementById('org-badge').textContent = `${h.org} · ${h.provider} · ${h.codebases} codebases`;\n  } catch {}\n  const ready = await checkSetup();\n  if (ready) switchTab(CURRENT_TAB);\n}\n\nloadAll();\n"
  },
  {
    "path": "maggy/maggy/static/index.html",
    "content": "<!doctype html>\n<html lang=\"en\">\n<head>\n  <meta charset=\"utf-8\" />\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n  <title>Maggy</title>\n\n  <!--\n    Content Security Policy — limits where scripts/styles can load from.\n    This mitigates the risk of a compromised CDN injecting arbitrary code,\n    since Maggy runs with local file-system access to your codebases and\n    can spawn `claude --dangerously-skip-permissions`.\n\n    For production / air-gapped installs: run `maggy/scripts/vendor-assets.sh`\n    (TODO) to copy Tailwind + Font Awesome locally, then replace the two\n    external references below with /static/tailwind.css and /static/fontawesome.css.\n  -->\n  <meta http-equiv=\"Content-Security-Policy\" content=\"\n    default-src 'self';\n    script-src 'self' https://cdn.tailwindcss.com 'unsafe-inline';\n    style-src 'self' https://cdn.tailwindcss.com https://cdnjs.cloudflare.com 'unsafe-inline';\n    font-src 'self' https://cdnjs.cloudflare.com data:;\n    connect-src 'self';\n    img-src 'self' data:;\n    frame-ancestors 'none';\n    base-uri 'self';\n  \" />\n\n  <!-- Tailwind Play CDN: no stable SRI hash (generated on demand). Vendor for prod. -->\n  <script src=\"https://cdn.tailwindcss.com\"></script>\n\n  <!-- Font Awesome 6.5.0 all.min.css — SHA-384 subresource integrity per cdnjs -->\n  <link rel=\"stylesheet\"\n        href=\"https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css\"\n        integrity=\"sha512-SzlrxWUlpfuzQ+pcUCosxcglQRNAq/DZjVsC0lE40xsADsfeQoEypE+enwcOiGjk/bSuGGKHEyjSoQ1zVisanQ==\"\n        crossorigin=\"anonymous\"\n        referrerpolicy=\"no-referrer\" />\n  <style>\n    body { background:#0b0e14; color:#e6e6e6; font-family: ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif; }\n    .tab-btn.active { background:#ea580c; color:white; }\n    #system-gear.active { background:#ea580c; color:white; }\n    .card { background:#151922; border:1px solid #262b3a; border-radius: 0.5rem; }\n    pre { white-space: pre-wrap; word-break: break-word; }\n  </style>\n</head>\n<body>\n  <div class=\"min-h-screen\">\n    <header class=\"border-b border-gray-800 bg-black/40 px-6 py-3 flex items-center gap-4\">\n      <div class=\"flex items-center gap-2\">\n        <i class=\"fas fa-robot text-orange-500 text-xl\"></i>\n        <h1 class=\"text-lg font-bold text-white\">Maggy</h1>\n        <span class=\"text-[10px] text-gray-500\">v0.1.0</span>\n      </div>\n      <div id=\"org-badge\" class=\"text-xs text-gray-400\"></div>\n      <div class=\"flex-1\"></div>\n      <button onclick=\"loadAll()\" class=\"text-xs text-gray-400 hover:text-white\"><i class=\"fas fa-sync-alt mr-1\"></i>Refresh</button>\n    </header>\n\n    <nav class=\"border-b border-gray-800 px-6 py-2 flex items-center gap-1 bg-black/20\">\n      <span class=\"text-[9px] text-gray-600 uppercase tracking-wider mr-1\">Work</span>\n      <button class=\"tab-btn active text-xs px-3 py-1.5 rounded bg-gray-800 text-gray-300\" data-tab=\"chat\" onclick=\"switchTab('chat')\"><i class=\"fas fa-terminal mr-1\"></i>Chat</button>\n      <button class=\"tab-btn text-xs px-3 py-1.5 rounded bg-gray-800 text-gray-300\" data-tab=\"inbox\" onclick=\"switchTab('inbox')\"><i class=\"fas fa-list-check mr-1\"></i>Tasks</button>\n      <button class=\"tab-btn text-xs px-3 py-1.5 rounded bg-gray-800 text-gray-300\" data-tab=\"followed\" onclick=\"switchTab('followed')\"><i class=\"fas fa-eye mr-1\"></i>Watching</button>\n      <span class=\"mx-2 border-l border-gray-700 h-4 inline-block\"></span>\n      <span class=\"text-[9px] text-gray-600 uppercase tracking-wider mr-1\">Intel</span>\n      <button class=\"tab-btn text-xs px-3 py-1.5 rounded bg-gray-800 text-gray-300\" data-tab=\"competitors\" onclick=\"switchTab('competitors')\"><i class=\"fas fa-chess mr-1\"></i>Competitors</button>\n      <button class=\"tab-btn text-xs px-3 py-1.5 rounded bg-gray-800 text-gray-300\" data-tab=\"process\" onclick=\"switchTab('process')\"><i class=\"fas fa-chart-line mr-1\"></i>Insights</button>\n      <div class=\"flex-1\"></div>\n      <div class=\"relative\">\n        <button onclick=\"toggleSystemMenu()\" class=\"text-xs px-2.5 py-1.5 rounded bg-gray-800 text-gray-400 hover:text-white\" id=\"system-gear\"><i class=\"fas fa-gear\"></i></button>\n        <div id=\"system-menu\" class=\"hidden absolute right-0 top-full mt-1 w-40 rounded border border-gray-700 bg-[#151922] shadow-xl z-30 py-1\">\n          <button onclick=\"switchTab('budget')\" class=\"sys-item w-full text-left text-xs px-3 py-2 text-gray-300 hover:bg-gray-800\" data-tab=\"budget\"><i class=\"fas fa-wallet mr-2 text-gray-500\"></i>Budget</button>\n          <button onclick=\"switchTab('routing')\" class=\"sys-item w-full text-left text-xs px-3 py-2 text-gray-300 hover:bg-gray-800\" data-tab=\"routing\"><i class=\"fas fa-route mr-2 text-gray-500\"></i>Models</button>\n          <button onclick=\"switchTab('forge')\" class=\"sys-item w-full text-left text-xs px-3 py-2 text-gray-300 hover:bg-gray-800\" data-tab=\"forge\"><i class=\"fas fa-hammer mr-2 text-gray-500\"></i>Forge</button>\n          <button onclick=\"switchTab('settings')\" class=\"sys-item w-full text-left text-xs px-3 py-2 text-gray-300 hover:bg-gray-800\" data-tab=\"settings\"><i class=\"fas fa-sliders mr-2 text-gray-500\"></i>Settings</button>\n        </div>\n      </div>\n    </nav>\n\n    <main class=\"px-6 py-4\">\n      <div id=\"pane-chat\" class=\"pane\"></div>\n      <div id=\"pane-inbox\" class=\"pane hidden\"></div>\n      <div id=\"pane-followed\" class=\"pane hidden\"></div>\n      <div id=\"pane-competitors\" class=\"pane hidden\"></div>\n      <div id=\"pane-budget\" class=\"pane hidden\"></div>\n      <div id=\"pane-routing\" class=\"pane hidden\"></div>\n      <div id=\"pane-process\" class=\"pane hidden\"></div>\n      <div id=\"pane-forge\" class=\"pane hidden\"></div>\n      <div id=\"pane-settings\" class=\"pane hidden\"></div>\n    </main>\n\n    <div id=\"drawer\" class=\"fixed top-0 right-0 h-full w-[40rem] max-w-full card border-l border-gray-800 p-4 overflow-y-auto translate-x-full transition-transform z-20\">\n      <div class=\"flex items-center justify-between mb-3\">\n        <h3 id=\"drawer-title\" class=\"text-sm font-bold text-white\">Task</h3>\n        <button onclick=\"closeDrawer()\" class=\"text-gray-400 hover:text-white text-lg\"><i class=\"fas fa-xmark\"></i></button>\n      </div>\n      <div id=\"drawer-body\" class=\"text-sm text-gray-300\"></div>\n    </div>\n  </div>\n\n  <script src=\"/static/app.js?v=3\"></script>\n</body>\n</html>\n"
  },
  {
    "path": "maggy/pyproject.toml",
    "content": "[build-system]\nrequires = [\"setuptools>=68\", \"wheel\"]\nbuild-backend = \"setuptools.build_meta\"\n\n[project]\nname = \"maggy\"\nversion = \"0.1.0\"\ndescription = \"Generic AI engineering command center — part of the Maggy platform\"\nreadme = \"README.md\"\nrequires-python = \">=3.11\"\nlicense = { text = \"MIT\" }\nauthors = [{ name = \"Maggy Contributors\" }]\ndependencies = [\n    \"fastapi>=0.115\",\n    \"uvicorn[standard]>=0.30\",\n    \"httpx>=0.27\",\n    \"anthropic>=0.40\",\n    \"bcrypt>=4.1\",\n    \"email-validator>=2.0\",\n    \"pyyaml>=6.0\",\n    \"feedparser>=6.0\",\n    \"pydantic>=2.6\",\n    \"typer>=0.12\",\n    \"rich>=13.0\",\n]\n\n[project.scripts]\nmaggy = \"maggy.cli:app\"\n\n[tool.setuptools.packages.find]\nwhere = [\".\"]\ninclude = [\"maggy*\"]\n"
  },
  {
    "path": "maggy/tests/conftest.py",
    "content": "\"\"\"Shared test fixtures for Maggy test suite.\"\"\"\n\nfrom __future__ import annotations\n\nimport tempfile\nfrom pathlib import Path\nfrom unittest.mock import MagicMock\n\nimport pytest\n\nfrom maggy.config import (\n    BudgetConfig,\n    DashboardConfig,\n    MaggyConfig,\n    MeshConfig,\n    OrgConfig,\n    RoutingConfig,\n    StorageConfig,\n)\n\n\n@pytest.fixture\ndef tmp_dir(tmp_path: Path) -> Path:\n    return tmp_path\n\n\n@pytest.fixture\ndef mock_cfg(tmp_path: Path) -> MaggyConfig:\n    \"\"\"Minimal MaggyConfig pointing to tmp storage.\"\"\"\n    return MaggyConfig(\n        org=OrgConfig(name=\"test-org\"),\n        storage=StorageConfig(path=str(tmp_path / \"store.db\")),\n        dashboard=DashboardConfig(),\n        budget=BudgetConfig(daily_limit_usd=10.0),\n        routing=RoutingConfig(),\n        mesh=MeshConfig(),\n    )\n"
  },
  {
    "path": "maggy/tests/integration/__init__.py",
    "content": "\"\"\"Integration tests for cross-module flows.\"\"\"\n"
  },
  {
    "path": "maggy/tests/integration/test_full_task_flow.py",
    "content": "\"\"\"Integration test: Ticket -> Route -> Execute -> Reward.\n\nTests the full lifecycle of a task through routing, event\nemission, and reward recording.\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom pathlib import Path\n\nfrom maggy.event_spine.emitter import EventEmitter\nfrom maggy.event_spine.events import (\n    ExecutionEvent,\n    IntentEvent,\n    OutcomeEvent,\n)\nfrom maggy.event_spine.store import EventStore\nfrom maggy.routing import RoutingContext, RoutingService\nfrom maggy.scores import MIN_SAMPLES\n\n\nclass TestFullTaskFlow:\n    def test_route_emit_reward(self, mock_cfg, tmp_path: Path):\n        \"\"\"Full flow: route task, emit events, record reward.\"\"\"\n        # 1. Route the task\n        router = RoutingService(mock_cfg)\n        ctx = RoutingContext(\n            blast_score=5, task_type=\"feature\",\n        )\n        decision = router.route(ctx)\n        name = (\n            decision.primary\n            if isinstance(decision.primary, str)\n            else decision.primary.name\n        )\n        assert name  # Got a routing decision\n\n        # 2. Emit events through the spine\n        store = EventStore(tmp_path / \"events.db\")\n        emitter = EventEmitter(store)\n\n        intent = IntentEvent(\n            intent_text=\"Add user dashboard\",\n            decomposed_steps=[\"create component\", \"add api\"],\n        )\n        intent.header.task_id = \"task-123\"\n        emitter.emit(intent)\n\n        exec_evt = ExecutionEvent(\n            tool_name=\"code_edit\",\n            duration_ms=500,\n            success=True,\n        )\n        exec_evt.header.task_id = \"task-123\"\n        emitter.emit(exec_evt)\n\n        outcome = OutcomeEvent(success=True, reward=0.85)\n        outcome.header.task_id = \"task-123\"\n        emitter.emit(outcome)\n\n        # 3. Verify trace\n        trace = emitter.trace(\"task-123\")\n        assert len(trace) == 3\n\n        # 4. Record reward for learning\n        router.record_outcome(name, \"feature\", 5, 0.85)\n        heatmap = router.get_heatmap()\n        assert len(heatmap) >= 1\n\n    def test_multi_task_routing(self, mock_cfg):\n        \"\"\"Route multiple tasks, verify different tiers.\"\"\"\n        router = RoutingService(mock_cfg)\n\n        low = router.route(RoutingContext(blast_score=1))\n        high = router.route(RoutingContext(blast_score=9))\n\n        low_name = (\n            low.primary if isinstance(low.primary, str)\n            else low.primary.name\n        )\n        high_name = (\n            high.primary if isinstance(high.primary, str)\n            else high.primary.name\n        )\n\n        # Low should be cheaper, high should be premium\n        assert low_name != high_name or low_name == \"claude\"\n"
  },
  {
    "path": "maggy/tests/integration/test_model_fallback.py",
    "content": "\"\"\"Integration test: Quota -> Checkpoint -> Switch -> Continue.\n\nTests fatigue-based checkpointing and model switching.\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom maggy.fatigue import create_profile\nfrom maggy.services.checkpoint import Checkpoint, create_checkpoint\n\n\nclass TestModelFallback:\n    def test_fatigue_triggers_checkpoint(self):\n        \"\"\"When fatigue is high, checkpoint and switch.\"\"\"\n        profile = create_profile(\"claude\")\n        profile.tokens_used = 170_000\n        profile.turns = 40\n\n        assert profile.should_checkpoint()\n\n        # Create checkpoint\n        cp = create_checkpoint(\n            goal=\"Refactor auth module\",\n            progress=[\"Extracted interface\", \"Updated tests\"],\n            model=\"claude\",\n            working_state=\"Mid-refactor, 3 files changed\",\n            files=[\"auth.py\", \"test_auth.py\"],\n        )\n\n        # Serialize for handoff\n        data = cp.serialize()\n        restored = Checkpoint.deserialize(data)\n        assert restored.goal == \"Refactor auth module\"\n        assert restored.source_model == \"claude\"\n\n        # Generate prompt for next model\n        prompt = restored.to_prompt()\n        assert \"Refactor auth module\" in prompt\n        assert \"Mid-refactor\" in prompt\n\n    def test_cross_model_checkpoint_round_trip(self):\n        \"\"\"Checkpoint survives serialization across models.\"\"\"\n        cp = create_checkpoint(\n            goal=\"Fix API pagination\",\n            progress=[\"Found bug in offset calc\"],\n            model=\"gpt\",\n            constraints=[\"Don't break existing tests\"],\n            files=[\"api/routes.py\"],\n        )\n\n        # Simulate model switch: serialize -> transfer -> restore\n        serialized = cp.serialize()\n        new_model_cp = Checkpoint.deserialize(serialized)\n\n        assert new_model_cp.source_model == \"gpt\"\n        prompt = new_model_cp.to_prompt()\n        assert \"Don't break existing tests\" in prompt\n\n    def test_fresh_model_low_fatigue(self):\n        \"\"\"A fresh model should not be fatigued.\"\"\"\n        profile = create_profile(\"kimi\")\n        assert not profile.should_checkpoint()\n        assert profile.fatigue_score == 0.0\n"
  },
  {
    "path": "maggy/tests/integration/test_process_loop.py",
    "content": "\"\"\"Integration test: CI fail -> Signal -> Pattern -> Fix.\n\nTests the process intelligence pipeline with CIKG and Engram.\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom pathlib import Path\n\nfrom maggy.cikg.graph import KnowledgeGraphService\nfrom maggy.cikg.models import Edge, Node\nfrom maggy.cikg.queries import find_gaps, get_landscape\nfrom maggy.engram.diagnostics import diagnose\nfrom maggy.engram.record import EngramRecord\nfrom maggy.engram.retrieval import EngramRetrieval\nfrom maggy.engram.store import EngramStore\nfrom maggy.lexon.router import LexonRouter\n\n\nclass TestProcessLoop:\n    def test_cikg_gap_to_engram(self, tmp_path: Path):\n        \"\"\"Detect feature gap in CIKG, store in Engram.\"\"\"\n        # 1. Build competitive landscape\n        g = KnowledgeGraphService(tmp_path / \"cikg.db\")\n        for i in range(3):\n            g.add_node(Node(\n                id=f\"c{i}\", node_type=\"competitor\",\n                name=f\"Competitor{i}\",\n            ))\n        g.add_node(Node(\n            id=\"f1\", node_type=\"feature\", name=\"SSO\",\n        ))\n        g.add_edge(Edge(\"c0\", \"f1\", \"has_feature\"))\n\n        # 2. Detect gap\n        score = find_gaps(g, \"SSO\")\n        assert score.gap_count == 2\n\n        # 3. Store insight in Engram\n        store = EngramStore(tmp_path / \"engram.db\")\n        store.write(EngramRecord(\n            engram_id=\"gap-sso\",\n            namespace=\"process\",\n            memory_type=\"decision\",\n            content=f\"Gap detected: {score.recommendation}\",\n            tags=[\"cikg\", \"gap\", \"sso\"],\n        ))\n\n        # 4. Verify retrieval\n        retrieval = EngramRetrieval(store)\n        results = retrieval.by_tag(\"cikg\")\n        assert len(results) == 1\n        assert \"Gap detected\" in results[0].content\n\n    def test_lexon_to_engram(self, tmp_path: Path):\n        \"\"\"Parse intent with Lexon, store in Engram.\"\"\"\n        # 1. Parse user intent\n        router = LexonRouter()\n        record = router.route(\"deploy the app to production\")\n        assert record.confidence > 0.5\n\n        # 2. Store the resolution in Engram\n        store = EngramStore(tmp_path / \"engram.db\")\n        store.write(EngramRecord(\n            engram_id=\"intent-deploy\",\n            namespace=\"session-1\",\n            memory_type=\"fact\",\n            content=f\"User said '{record.phrase}' -> \"\n                    f\"{record.resolved_tool}\",\n            tags=[\"lexon\", \"intent\"],\n        ))\n\n        # 3. Verify\n        result = store.get(\"intent-deploy\")\n        assert result is not None\n        assert \"deploy\" in result.content\n\n    def test_full_diagnostics(self, tmp_path: Path):\n        \"\"\"Memory diagnostics across diverse types.\"\"\"\n        store = EngramStore(tmp_path / \"engram.db\")\n        types = [\"fact\", \"decision\", \"code_ref\", \"handoff\"]\n        for i, mt in enumerate(types):\n            store.write(EngramRecord(\n                engram_id=f\"e{i}\",\n                namespace=\"test\",\n                memory_type=mt,\n                content=f\"Content for {mt}\",\n            ))\n\n        profile = diagnose(store)\n        assert profile.total_memories == 4\n        assert profile.health_score > 0.8\n"
  },
  {
    "path": "maggy/tests/test_account_guide.py",
    "content": "\"\"\"Tests for account switching guidance.\"\"\"\n\nfrom __future__ import annotations\n\nfrom maggy.services.account_guide import (\n    AccountProfile,\n    detect_accounts,\n    suggest_switch,\n)\n\n\ndef test_account_profile_dataclass():\n    \"\"\"AccountProfile stores provider and auth command.\"\"\"\n    p = AccountProfile(\n        name=\"claude-work\", provider=\"anthropic\",\n        auth_command=\"claude auth login\",\n    )\n    assert p.provider == \"anthropic\"\n    assert \"login\" in p.auth_command\n\n\ndef test_detect_accounts_finds_claude(tmp_path):\n    \"\"\"Detects Claude accounts from ~/.claude directory.\"\"\"\n    (tmp_path / \".claude\").mkdir()\n    (tmp_path / \".claude\" / \"credentials.json\").write_text(\"{}\")\n    accounts = detect_accounts(home=tmp_path)\n    providers = [a.provider for a in accounts]\n    assert \"anthropic\" in providers\n\n\ndef test_detect_accounts_finds_codex(tmp_path):\n    \"\"\"Detects Codex accounts from ~/.codex directory.\"\"\"\n    (tmp_path / \".codex\").mkdir()\n    accounts = detect_accounts(home=tmp_path)\n    providers = [a.provider for a in accounts]\n    assert \"openai\" in providers\n\n\ndef test_suggest_switch_anthropic():\n    \"\"\"Suggests claude auth login for anthropic quota hit.\"\"\"\n    guide = suggest_switch(\"anthropic\")\n    assert \"claude\" in guide.lower()\n    assert \"login\" in guide.lower() or \"auth\" in guide.lower()\n\n\ndef test_suggest_switch_openai():\n    \"\"\"Suggests codex auth for openai quota hit.\"\"\"\n    guide = suggest_switch(\"openai\")\n    assert \"codex\" in guide.lower() or \"openai\" in guide.lower()\n"
  },
  {
    "path": "maggy/tests/test_activity.py",
    "content": "\"\"\"Tests for CLI activity scanner.\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nfrom pathlib import Path\nfrom unittest.mock import patch\n\nimport pytest\n\nfrom maggy.services.activity import (\n    ActiveSession,\n    ActivityService,\n    RecentPrompt,\n    _parse_claude_processes,\n    _recent_prompts,\n)\n\n\nclass TestParseClaudeProcesses:\n    def test_detects_running_session(self):\n        lines = [\n            \"user  1234  0.0  0.1  claude --dangerously-skip-permissions --continue\",\n        ]\n        with patch(\n            \"maggy.services.activity._get_cwd\",\n            return_value=\"/Users/me/proj-a\",\n        ):\n            sessions = _parse_claude_processes(lines)\n        assert len(sessions) == 1\n        assert sessions[0].cli == \"claude\"\n        assert sessions[0].pid == 1234\n        assert sessions[0].status == \"running\"\n        assert sessions[0].project == \"proj-a\"\n\n    def test_detects_agent_subprocess(self):\n        lines = [\n            \"user  5678  0.1  0.3  /path/to/claude \"\n            \"--agent-id be-schema@maia-demo \"\n            \"--agent-name be-schema \"\n            \"--team-name maia-demo \"\n            \"--parent-session-id abc-123\",\n        ]\n        with patch(\n            \"maggy.services.activity._get_cwd\",\n            return_value=\"/Users/me/proj-b\",\n        ):\n            sessions = _parse_claude_processes(lines)\n        assert len(sessions) == 1\n        s = sessions[0]\n        assert s.status == \"agent\"\n        assert s.agent_name == \"be-schema\"\n        assert s.team_name == \"maia-demo\"\n\n    def test_ignores_non_cli_processes(self):\n        lines = [\n            \"user  9999  0.0  0.0  /Applications/Claude.app/Contents/MacOS/Claude\",\n            \"user  8888  0.0  0.0  grep claude\",\n        ]\n        sessions = _parse_claude_processes(lines)\n        assert sessions == []\n\n    def test_empty_input(self):\n        assert _parse_claude_processes([]) == []\n\n\nclass TestRecentPrompts:\n    def test_reads_claude_history(self, tmp_path: Path):\n        history = tmp_path / \"history.jsonl\"\n        entries = [\n            {\"display\": \"fix the bug\", \"timestamp\": 1000, \"project\": \"/Users/me/app\", \"sessionId\": \"s1\"},\n            {\"display\": \"run tests\", \"timestamp\": 2000, \"project\": \"/Users/me/app\", \"sessionId\": \"s1\"},\n        ]\n        history.write_text(\n            \"\\n\".join(json.dumps(e) for e in entries) + \"\\n\",\n        )\n        prompts = _recent_prompts(\n            claude_dir=tmp_path, codex_dir=tmp_path / \"none\",\n            kimi_dir=tmp_path / \"none2\", limit=5,\n        )\n        assert len(prompts) == 2\n        assert prompts[0].text == \"run tests\"\n        assert prompts[0].cli == \"claude\"\n        assert prompts[0].project == \"app\"\n\n    def test_reads_codex_history(self, tmp_path: Path):\n        history = tmp_path / \"history.jsonl\"\n        entries = [\n            {\"session_id\": \"c1\", \"ts\": 3000, \"text\": \"deploy it\"},\n        ]\n        history.write_text(\n            \"\\n\".join(json.dumps(e) for e in entries) + \"\\n\",\n        )\n        prompts = _recent_prompts(\n            claude_dir=tmp_path / \"none\", codex_dir=tmp_path,\n            kimi_dir=tmp_path / \"none2\", limit=5,\n        )\n        assert len(prompts) == 1\n        assert prompts[0].cli == \"codex\"\n        assert prompts[0].text == \"deploy it\"\n\n    def test_merges_and_sorts_by_time(self, tmp_path: Path):\n        claude_dir = tmp_path / \"claude\"\n        codex_dir = tmp_path / \"codex\"\n        claude_dir.mkdir()\n        codex_dir.mkdir()\n        (claude_dir / \"history.jsonl\").write_text(\n            json.dumps({\"display\": \"old\", \"timestamp\": 1000, \"project\": \"/p\", \"sessionId\": \"s\"}) + \"\\n\",\n        )\n        (codex_dir / \"history.jsonl\").write_text(\n            json.dumps({\"session_id\": \"c1\", \"ts\": 5000, \"text\": \"new\"}) + \"\\n\",\n        )\n        prompts = _recent_prompts(\n            claude_dir=claude_dir, codex_dir=codex_dir,\n            kimi_dir=tmp_path / \"none\", limit=5,\n        )\n        assert prompts[0].text == \"new\"\n        assert prompts[1].text == \"old\"\n\n    def test_limits_output(self, tmp_path: Path):\n        history = tmp_path / \"history.jsonl\"\n        lines = []\n        for i in range(20):\n            lines.append(json.dumps({\n                \"display\": f\"msg-{i}\", \"timestamp\": i * 1000,\n                \"project\": \"/p\", \"sessionId\": \"s\",\n            }))\n        history.write_text(\"\\n\".join(lines) + \"\\n\")\n        prompts = _recent_prompts(\n            claude_dir=tmp_path, codex_dir=tmp_path / \"x\",\n            kimi_dir=tmp_path / \"y\", limit=5,\n        )\n        assert len(prompts) == 5\n\n    def test_no_history_files(self, tmp_path: Path):\n        prompts = _recent_prompts(\n            claude_dir=tmp_path / \"a\", codex_dir=tmp_path / \"b\",\n            kimi_dir=tmp_path / \"c\", limit=5,\n        )\n        assert prompts == []\n\n    def test_malformed_json_skipped(self, tmp_path: Path):\n        history = tmp_path / \"history.jsonl\"\n        history.write_text(\n            \"not-json\\n\"\n            + json.dumps({\"display\": \"ok\", \"timestamp\": 1000, \"project\": \"/p\", \"sessionId\": \"s\"})\n            + \"\\n\",\n        )\n        prompts = _recent_prompts(\n            claude_dir=tmp_path, codex_dir=tmp_path / \"x\",\n            kimi_dir=tmp_path / \"y\", limit=5,\n        )\n        assert len(prompts) == 1\n        assert prompts[0].text == \"ok\"\n\n\nclass TestActivityService:\n    def test_get_activity_shape(self):\n        svc = ActivityService()\n        with patch(\n            \"maggy.services.activity._scan_processes\",\n            return_value=[],\n        ), patch(\n            \"maggy.services.activity._recent_prompts\",\n            return_value=[],\n        ):\n            result = svc.get_activity()\n        assert \"sessions\" in result\n        assert \"recent\" in result\n\n    def test_serializes_sessions(self):\n        session = ActiveSession(\n            cli=\"claude\", session_id=\"\", project=\"myapp\",\n            project_path=\"/Users/me/myapp\", status=\"running\",\n            last_prompt=\"fix bug\", agent_name=\"\", team_name=\"\",\n            pid=1234,\n        )\n        svc = ActivityService()\n        with patch(\n            \"maggy.services.activity._scan_processes\",\n            return_value=[session],\n        ), patch(\n            \"maggy.services.activity._recent_prompts\",\n            return_value=[],\n        ):\n            result = svc.get_activity()\n        assert len(result[\"sessions\"]) == 1\n        s = result[\"sessions\"][0]\n        assert s[\"cli\"] == \"claude\"\n        assert s[\"project\"] == \"myapp\"\n        assert s[\"pid\"] == 1234\n\n    def test_serializes_prompts(self):\n        prompt = RecentPrompt(\n            cli=\"codex\", text=\"deploy\",\n            project=\"api\", timestamp=\"2026-05-10T12:00:00\",\n            session_id=\"c1\",\n        )\n        svc = ActivityService()\n        with patch(\n            \"maggy.services.activity._scan_processes\",\n            return_value=[],\n        ), patch(\n            \"maggy.services.activity._recent_prompts\",\n            return_value=[prompt],\n        ):\n            result = svc.get_activity()\n        assert len(result[\"recent\"]) == 1\n        assert result[\"recent\"][0][\"text\"] == \"deploy\"\n"
  },
  {
    "path": "maggy/tests/test_api_endpoints.py",
    "content": "\"\"\"Full API endpoint validation tests.\n\nCreates a real FastAPI app with all services wired in\n(using tmp directories for SQLite) and validates every\nendpoint from all 14 phases.\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom pathlib import Path\nfrom types import SimpleNamespace\n\nimport pytest\nfrom fastapi import FastAPI\nfrom fastapi.testclient import TestClient\n\nfrom maggy.budget import BudgetManager\nfrom maggy.cikg.graph import KnowledgeGraphService\nfrom maggy.cikg.models import Edge, Node\nfrom maggy.config import (\n    BudgetConfig,\n    DashboardConfig,\n    MaggyConfig,\n    MeshConfig,\n    OrgConfig,\n    RoutingConfig,\n    StorageConfig,\n)\nfrom maggy.deploy import DeployService\nfrom maggy.engram.record import EngramRecord\nfrom maggy.engram.store import EngramStore\nfrom maggy.event_spine.emitter import EventEmitter\nfrom maggy.event_spine.events import IntentEvent\nfrom maggy.event_spine.store import EventStore\nfrom maggy.forge.connector import ForgeConnector\nfrom maggy.lexon.router import LexonRouter\nfrom maggy.mesh.manager import MeshManager\nfrom maggy.mesh.store import MeshStore\nfrom maggy.planning import PlanningService\nfrom maggy.history.service import HistoryService\nfrom maggy.improve.service import Introspector\nfrom maggy.routing import RoutingService\n\n\n@pytest.fixture\ndef app_with_services(tmp_path: Path) -> FastAPI:\n    \"\"\"Build a FastAPI app with all services wired.\"\"\"\n    cfg = MaggyConfig(\n        org=OrgConfig(name=\"test-org\", domain=\"devtools\"),\n        storage=StorageConfig(path=str(tmp_path / \"store.db\")),\n        dashboard=DashboardConfig(auth_mode=\"local\"),\n        budget=BudgetConfig(daily_limit_usd=10.0),\n        routing=RoutingConfig(),\n        mesh=MeshConfig(enabled=True),\n    )\n\n    app = FastAPI()\n    app.state.cfg = cfg\n    app.state.configured = True\n    app.state.mode = \"local\"\n\n    # Wire all services\n    app.state.budget = BudgetManager(cfg)\n    app.state.routing = RoutingService(cfg)\n    app.state.events = EventEmitter(\n        EventStore(tmp_path / \"events.db\"),\n    )\n    app.state.cikg = KnowledgeGraphService(\n        tmp_path / \"cikg.db\",\n    )\n    app.state.planning = PlanningService(cfg)\n    app.state.deploy = DeployService()\n    app.state.forge = ForgeConnector(\n        forge_path=tmp_path / \"fake-forge\",\n    )\n    app.state.engram = EngramStore(tmp_path / \"engram.db\")\n    app.state.lexon = LexonRouter()\n\n    mesh_store = MeshStore(tmp_path / \"mesh.db\")\n    mesh_cfg = SimpleNamespace(\n        peer_id=\"test-peer\",\n        org_key_secret=\"secret\",\n        port=8080,\n        tunnel_url=\"\",\n        git_discovery=False,\n    )\n    mgr = MeshManager(mesh_cfg, mesh_store)\n    mgr.add_network(\"test-org\")\n    app.state.mesh = mgr\n    app.state.history = HistoryService(\n        db_path=tmp_path / \"history.db\",\n        cli_dirs={\n            \"claude\": tmp_path / \"no_claude\",\n            \"codex\": tmp_path / \"no_codex\",\n            \"kimi\": tmp_path / \"no_kimi\",\n        },\n    )\n    app.state.introspector = Introspector(app.state)\n    app.state.heartbeat = None\n\n    # Register all routers\n    from maggy.api.routes import router as r_api\n    from maggy.api.routes_budget import router as r_budget\n    from maggy.api.routes_cikg import router as r_cikg\n    from maggy.api.routes_deploy import router as r_deploy\n    from maggy.api.routes_engram import router as r_engram\n    from maggy.api.routes_events import router as r_events\n    from maggy.api.routes_forge import router as r_forge\n    from maggy.api.routes_heartbeat import router as r_heartbeat\n    from maggy.api.routes_history import router as r_history\n    from maggy.api.routes_improve import router as r_improve\n    from maggy.api.routes_lexon import router as r_lexon\n    from maggy.api.routes_mesh import router as r_mesh\n    from maggy.api.routes_planning import router as r_plan\n    from maggy.api.routes_routing import router as r_routing\n    from maggy.api.routes_setup import router as r_setup\n    from maggy.api.routes_users import router as r_users\n\n    for r in (\n        r_api, r_budget, r_cikg, r_deploy, r_engram,\n        r_events, r_forge, r_heartbeat, r_history,\n        r_improve, r_lexon, r_mesh, r_plan, r_routing,\n        r_setup, r_users,\n    ):\n        app.include_router(r)\n\n    return app\n\n\n@pytest.fixture\ndef client(app_with_services: FastAPI) -> TestClient:\n    return TestClient(app_with_services)\n\n\n# ── Phase 1: Budget ─────────────────────────────────────\n\n\nclass TestBudgetAPI:\n    def test_get_budget_empty(self, client: TestClient):\n        resp = client.get(\"/api/budget\")\n        assert resp.status_code == 200\n        data = resp.json()\n        assert \"daily_limit_usd\" in data\n        assert \"spent_today_usd\" in data\n        assert data[\"spent_today_usd\"] == 0.0\n\n    def test_budget_by_provider_empty(self, client: TestClient):\n        resp = client.get(\"/api/budget/by-provider\")\n        assert resp.status_code == 200\n        assert resp.json() == []\n\n    def test_budget_with_spend(\n        self, app_with_services: FastAPI,\n    ):\n        mgr = app_with_services.state.budget\n        mgr.record_spend(\"anthropic\", \"claude\", 2.5)\n        mgr.record_spend(\"openai\", \"gpt-4\", 1.0)\n\n        c = TestClient(app_with_services)\n        resp = c.get(\"/api/budget\")\n        data = resp.json()\n        assert data[\"spent_today_usd\"] == 3.5\n\n        resp = c.get(\"/api/budget/by-provider\")\n        providers = {\n            r[\"provider\"]: r[\"spent_usd\"]\n            for r in resp.json()\n        }\n        assert providers[\"anthropic\"] == 2.5\n        assert providers[\"openai\"] == 1.0\n\n\n# ── Phase 2: Routing ────────────────────────────────────\n\n\nclass TestRoutingAPI:\n    def test_heatmap_empty(self, client: TestClient):\n        resp = client.get(\"/api/routing/heatmap\")\n        assert resp.status_code == 200\n        assert resp.json() == []\n\n    def test_decide_low_blast(self, client: TestClient):\n        resp = client.get(\n            \"/api/routing/decide?blast=1&task_type=bugfix\",\n        )\n        assert resp.status_code == 200\n        data = resp.json()\n        assert \"primary\" in data\n        assert \"reason\" in data\n\n    def test_decide_high_blast(self, client: TestClient):\n        resp = client.get(\n            \"/api/routing/decide?blast=9&task_type=feature\",\n        )\n        data = resp.json()\n        assert data[\"primary\"] is not None\n\n    def test_heatmap_after_recording(\n        self, app_with_services: FastAPI,\n    ):\n        svc = app_with_services.state.routing\n        svc.record_outcome(\"claude\", \"feature\", 5, 0.9)\n        c = TestClient(app_with_services)\n        resp = c.get(\"/api/routing/heatmap\")\n        assert len(resp.json()) >= 1\n\n\nclass TestUsersAPI:\n    def test_create_user(self, client: TestClient):\n        resp = client.post(\n            \"/api/users\",\n            json={\"email\": \"user@example.com\", \"password\": \"secret123\"},\n        )\n\n        assert resp.status_code == 201\n        data = resp.json()\n        assert data[\"email\"] == \"user@example.com\"\n        assert \"password_hash\" not in data\n\n\n# ── Phase 14: Event Spine ───────────────────────────────\n\n\nclass TestEventsAPI:\n    def test_events_empty(self, client: TestClient):\n        resp = client.get(\"/api/events\")\n        assert resp.status_code == 200\n        assert resp.json() == []\n\n    def test_event_count_empty(self, client: TestClient):\n        resp = client.get(\"/api/events/count\")\n        assert resp.status_code == 200\n        assert resp.json()[\"count\"] == 0\n\n    def test_trace_empty(self, client: TestClient):\n        resp = client.get(\"/api/events/trace/nope\")\n        assert resp.status_code == 200\n        assert resp.json() == []\n\n    def test_events_after_emit(\n        self, app_with_services: FastAPI,\n    ):\n        emitter = app_with_services.state.events\n        evt = IntentEvent(\n            intent_text=\"Add login\",\n            decomposed_steps=[\"create form\", \"add auth\"],\n        )\n        evt.header.task_id = \"t-1\"\n        emitter.emit(evt)\n\n        c = TestClient(app_with_services)\n        resp = c.get(\"/api/events?task_id=t-1\")\n        assert len(resp.json()) == 1\n\n        resp = c.get(\"/api/events/trace/t-1\")\n        assert len(resp.json()) == 1\n\n        resp = c.get(\"/api/events/count\")\n        assert resp.json()[\"count\"] == 1\n\n\n# ── Phase 4: CIKG ───────────────────────────────────────\n\n\nclass TestCIKGAPI:\n    def test_landscape_empty(self, client: TestClient):\n        resp = client.get(\"/api/cikg/landscape\")\n        assert resp.status_code == 200\n        data = resp.json()\n        assert data[\"competitors\"] == 0\n\n    def test_gaps_no_feature(self, client: TestClient):\n        resp = client.get(\"/api/cikg/gaps/SSO\")\n        assert resp.status_code == 200\n        data = resp.json()\n        assert \"gap_count\" in data\n\n    def test_landscape_with_data(\n        self, app_with_services: FastAPI,\n    ):\n        graph = app_with_services.state.cikg\n        graph.add_node(Node(\n            id=\"c1\", node_type=\"competitor\", name=\"Rival\",\n        ))\n        graph.add_node(Node(\n            id=\"f1\", node_type=\"feature\", name=\"SSO\",\n        ))\n        graph.add_edge(Edge(\"c1\", \"f1\", \"has_feature\"))\n\n        c = TestClient(app_with_services)\n        resp = c.get(\"/api/cikg/landscape\")\n        data = resp.json()\n        assert data[\"competitors\"] == 1\n        assert data[\"features_tracked\"] == 1\n\n        resp = c.get(\"/api/cikg/gaps/SSO\")\n        data = resp.json()\n        assert data[\"feature\"] == \"SSO\"\n\n\n# ── Phase 6: Planning ───────────────────────────────────\n\n\nclass TestPlanningAPI:\n    def test_single_plan(self, client: TestClient):\n        resp = client.post(\n            \"/api/planning/generate\",\n            json={\"task\": \"Add auth\", \"blast_score\": 2},\n        )\n        assert resp.status_code == 200\n        data = resp.json()\n        assert data[\"mode\"] == \"single\"\n        assert len(data[\"plan\"][\"steps\"]) == 3\n\n    def test_dual_plan(self, client: TestClient):\n        resp = client.post(\n            \"/api/planning/generate\",\n            json={\"task\": \"Refactor core\", \"blast_score\": 7},\n        )\n        data = resp.json()\n        assert data[\"mode\"] == \"dual\"\n        assert \"diff\" in data\n\n\n# ── Phase 7: Deploy ─────────────────────────────────────\n\n\nclass TestDeployAPI:\n    def test_sessions_empty(self, client: TestClient):\n        resp = client.get(\"/api/deploy/sessions\")\n        assert resp.status_code == 200\n        assert resp.json()[\"sessions\"] == []\n\n    def test_create_and_get(self, client: TestClient):\n        resp = client.post(\n            \"/api/deploy/sessions\",\n            json={\"project\": \"web\", \"branch\": \"feat-x\"},\n        )\n        assert resp.status_code == 200\n        data = resp.json()\n        sid = data[\"session_id\"]\n        assert data[\"status\"] == \"building\"\n\n        resp = client.get(f\"/api/deploy/sessions/{sid}\")\n        assert resp.json()[\"project\"] == \"web\"\n\n    def test_missing_session(self, client: TestClient):\n        resp = client.get(\"/api/deploy/sessions/nope\")\n        data = resp.json()\n        assert data.get(\"error\") == \"session not found\"\n\n\n# ── Phase 9: Forge ──────────────────────────────────────\n\n\nclass TestForgeAPI:\n    def test_forge_status(self, client: TestClient):\n        resp = client.get(\"/api/forge/status\")\n        assert resp.status_code == 200\n        data = resp.json()\n        assert \"available\" in data\n        assert \"registry_count\" in data\n\n    def test_forge_search(self, client: TestClient):\n        resp = client.get(\"/api/forge/search?q=test\")\n        assert resp.status_code == 200\n        assert \"results\" in resp.json()\n\n    def test_forge_gaps_empty(self, client: TestClient):\n        resp = client.get(\"/api/forge/gaps\")\n        assert resp.status_code == 200\n        assert resp.json()[\"gaps\"] == []\n\n    def test_report_gap(self, client: TestClient):\n        resp = client.post(\n            \"/api/forge/gaps\",\n            json={\"capability\": \"slack-notify\"},\n        )\n        assert resp.status_code == 200\n        data = resp.json()\n        assert data[\"capability\"] == \"slack-notify\"\n\n        resp = client.get(\"/api/forge/gaps\")\n        gaps = resp.json()[\"gaps\"]\n        assert len(gaps) == 1\n\n\n# ── Phase 12: Engram ────────────────────────────────────\n\n\nclass TestEngramAPI:\n    def test_query_empty(self, client: TestClient):\n        resp = client.get(\"/api/engram/query\")\n        assert resp.status_code == 200\n        assert resp.json()[\"records\"] == []\n\n    def test_diagnostics_empty(self, client: TestClient):\n        resp = client.get(\"/api/engram/diagnostics\")\n        assert resp.status_code == 200\n        data = resp.json()\n        assert \"total_memories\" in data\n\n    def test_query_with_data(\n        self, app_with_services: FastAPI,\n    ):\n        store = app_with_services.state.engram\n        store.write(EngramRecord(\n            engram_id=\"e1\",\n            namespace=\"test\",\n            memory_type=\"fact\",\n            content=\"Test memory\",\n            tags=[\"test\"],\n        ))\n\n        c = TestClient(app_with_services)\n        resp = c.get(\"/api/engram/query?namespace=test\")\n        records = resp.json()[\"records\"]\n        assert len(records) == 1\n        assert records[0][\"content\"] == \"Test memory\"\n\n    def test_diagnostics_with_data(\n        self, app_with_services: FastAPI,\n    ):\n        store = app_with_services.state.engram\n        store.write(EngramRecord(\n            engram_id=\"e2\",\n            namespace=\"test\",\n            memory_type=\"decision\",\n            content=\"Chose X over Y\",\n        ))\n\n        c = TestClient(app_with_services)\n        resp = c.get(\"/api/engram/diagnostics\")\n        data = resp.json()\n        assert data[\"total_memories\"] >= 1\n\n\n# ── Phase 13: Lexon ─────────────────────────────────────\n\n\nclass TestLexonAPI:\n    def test_parse_known(self, client: TestClient):\n        resp = client.get(\"/api/lexon/parse?q=deploy\")\n        assert resp.status_code == 200\n        data = resp.json()\n        assert \"resolved_tool\" in data\n        assert data[\"confidence\"] > 0\n\n    def test_parse_unknown(self, client: TestClient):\n        resp = client.get(\n            \"/api/lexon/parse?q=xyzzy_unknown_phrase\",\n        )\n        data = resp.json()\n        assert data[\"resolved_tool\"] == \"\"\n\n    def test_learn(self, client: TestClient):\n        resp = client.post(\n            \"/api/lexon/learn\",\n            json={\"phrase\": \"ship it\", \"tool\": \"deploy\"},\n        )\n        assert resp.status_code == 200\n        assert resp.json()[\"status\"] == \"learned\"\n\n        resp = client.get(\"/api/lexon/parse?q=ship+it\")\n        data = resp.json()\n        assert data[\"resolved_tool\"] == \"deploy\"\n\n\n# ── Phase 11: Mesh ──────────────────────────────────────\n\n\nclass TestMeshAPI:\n    def test_mesh_status_enabled(self, client: TestClient):\n        resp = client.get(\"/api/mesh/status\")\n        assert resp.status_code == 200\n        data = resp.json()\n        assert data[\"enabled\"] is True\n        assert data[\"peers\"] == 0\n        assert \"networks\" in data\n\n    def test_mesh_peers_empty(self, client: TestClient):\n        resp = client.get(\"/api/mesh/peers\")\n        assert resp.status_code == 200\n        assert resp.json()[\"peers\"] == []\n\n    def test_mesh_networks(self, client: TestClient):\n        resp = client.get(\"/api/mesh/networks\")\n        assert resp.status_code == 200\n        nets = resp.json()[\"networks\"]\n        assert len(nets) == 1\n        assert nets[0][\"org\"] == \"test-org\"\n\n    def test_mesh_quarantine_requires_org(\n        self, client: TestClient,\n    ):\n        resp = client.get(\"/api/mesh/quarantine\")\n        assert resp.status_code == 422\n        assert \"error\" in resp.json()\n\n    def test_mesh_quarantine_with_org(\n        self, client: TestClient,\n    ):\n        resp = client.get(\"/api/mesh/quarantine?org=test-org\")\n        assert resp.status_code == 200\n        assert resp.json()[\"items\"] == []\n\n    def test_mesh_add_peer(self, client: TestClient):\n        resp = client.post(\n            \"/api/mesh/peers\",\n            json={\n                \"org\": \"test-org\",\n                \"peer_id\": \"p1\",\n                \"name\": \"remote\",\n                \"address\": \"ws://x\",\n            },\n        )\n        assert resp.json()[\"status\"] == \"added\"\n        resp = client.get(\"/api/mesh/peers?org=test-org\")\n        assert len(resp.json()[\"peers\"]) == 1\n\n\n# ── Unconfigured state ──────────────────────────────────\n\n\nclass TestUnconfiguredState:\n    \"\"\"Verify graceful behavior when services are None.\"\"\"\n\n    @pytest.fixture\n    def unconfigured_client(self) -> TestClient:\n        app = FastAPI()\n        app.state.cfg = MaggyConfig()\n        app.state.configured = False\n        app.state.budget = None\n        app.state.routing = None\n        app.state.events = None\n        app.state.cikg = None\n        app.state.planning = None\n        app.state.deploy = None\n        app.state.forge = None\n        app.state.engram = None\n        app.state.lexon = None\n        app.state.mesh = None\n\n        from maggy.api.routes_budget import router as r1\n        from maggy.api.routes_cikg import router as r2\n        from maggy.api.routes_deploy import router as r3\n        from maggy.api.routes_engram import router as r4\n        from maggy.api.routes_events import router as r5\n        from maggy.api.routes_forge import router as r6\n        from maggy.api.routes_lexon import router as r7\n        from maggy.api.routes_mesh import router as r8\n        from maggy.api.routes_planning import router as r9\n        from maggy.api.routes_routing import router as r0\n\n        for r in (r1, r2, r3, r4, r5, r6, r7, r8, r9, r0):\n            app.include_router(r)\n        return TestClient(app)\n\n    def test_budget_unconfigured(\n        self, unconfigured_client: TestClient,\n    ):\n        resp = unconfigured_client.get(\"/api/budget\")\n        assert resp.status_code == 200\n        assert resp.json()[\"status\"] == \"unconfigured\"\n\n    def test_routing_unconfigured(\n        self, unconfigured_client: TestClient,\n    ):\n        resp = unconfigured_client.get(\"/api/routing/heatmap\")\n        assert resp.status_code == 200\n        assert resp.json() == []\n\n    def test_events_unconfigured(\n        self, unconfigured_client: TestClient,\n    ):\n        resp = unconfigured_client.get(\"/api/events\")\n        assert resp.json() == []\n\n    def test_mesh_unconfigured(\n        self, unconfigured_client: TestClient,\n    ):\n        resp = unconfigured_client.get(\"/api/mesh/status\")\n        data = resp.json()\n        assert data[\"enabled\"] is False\n\n    def test_engram_unconfigured(\n        self, unconfigured_client: TestClient,\n    ):\n        resp = unconfigured_client.get(\"/api/engram/query\")\n        assert \"error\" in resp.json()\n\n    def test_lexon_unconfigured(\n        self, unconfigured_client: TestClient,\n    ):\n        resp = unconfigured_client.get(\"/api/lexon/parse?q=hi\")\n        assert \"error\" in resp.json()\n\n    def test_deploy_unconfigured(\n        self, unconfigured_client: TestClient,\n    ):\n        resp = unconfigured_client.get(\"/api/deploy/sessions\")\n        assert \"error\" in resp.json()\n\n    def test_forge_unconfigured(\n        self, unconfigured_client: TestClient,\n    ):\n        resp = unconfigured_client.get(\"/api/forge/status\")\n        assert \"error\" in resp.json()\n\n    def test_planning_unconfigured(\n        self, unconfigured_client: TestClient,\n    ):\n        resp = unconfigured_client.post(\n            \"/api/planning/generate\",\n            json={\"task\": \"test\"},\n        )\n        assert \"error\" in resp.json()\n\n    def test_cikg_unconfigured(\n        self, unconfigured_client: TestClient,\n    ):\n        resp = unconfigured_client.get(\"/api/cikg/landscape\")\n        assert \"error\" in resp.json()\n\n\n# --- History Endpoint Tests ---\n\n\nclass TestHistoryEndpoints:\n    \"\"\"Tests for /api/history/* endpoints.\"\"\"\n\n    def test_providers(self, client: TestClient):\n        resp = client.get(\"/api/history/providers\")\n        assert resp.status_code == 200\n        assert \"providers\" in resp.json()\n\n    def test_analyze(self, client: TestClient):\n        resp = client.post(\"/api/history/analyze\")\n        assert resp.status_code == 200\n        data = resp.json()\n        assert \"total_sessions\" in data\n        assert \"total_prompts\" in data\n\n    def test_report_empty(self, client: TestClient):\n        resp = client.get(\"/api/history/report\")\n        assert resp.status_code == 200\n\n    def test_sessions(self, client: TestClient):\n        # First analyze to populate\n        client.post(\"/api/history/analyze\")\n        resp = client.get(\"/api/history/sessions\")\n        assert resp.status_code == 200\n        assert \"sessions\" in resp.json()\n\n    def test_sessions_filter(self, client: TestClient):\n        resp = client.get(\n            \"/api/history/sessions?provider=claude\",\n        )\n        assert resp.status_code == 200\n\n\n# --- Discovery + Enhanced Health ---\n\n\nclass TestDiscoveryEndpoint:\n    def test_discovery_returns_data(\n        self, client: TestClient,\n    ):\n        resp = client.get(\"/api/discovery\")\n        assert resp.status_code == 200\n        data = resp.json()\n        assert \"clis\" in data\n        assert \"repos\" in data\n        assert \"tokens\" in data\n\n    def test_health_has_mode(\n        self, client: TestClient,\n    ):\n        resp = client.get(\"/api/health\")\n        assert resp.status_code == 200\n        data = resp.json()\n        assert \"mode\" in data\n        assert data[\"mode\"] in (\"full\", \"local\")\n\n\n# --- Heartbeat Endpoint Tests ---\n\n\nclass TestHeartbeatEndpoints:\n    def test_status_no_scheduler(self, client: TestClient):\n        resp = client.get(\"/api/heartbeat/status\")\n        assert resp.status_code == 200\n        assert resp.json() == []\n\n    def test_trigger_no_scheduler(self, client: TestClient):\n        resp = client.post(\"/api/heartbeat/trigger/nope\")\n        assert resp.status_code == 503\n\n    def test_status_with_scheduler(\n        self, app_with_services: FastAPI,\n    ):\n        from maggy.heartbeat.scheduler import HeartbeatScheduler\n        from unittest.mock import AsyncMock\n        sched = HeartbeatScheduler()\n        sched.register(\"test_job\", AsyncMock(), 60)\n        app_with_services.state.heartbeat = sched\n        c = TestClient(app_with_services)\n        resp = c.get(\"/api/heartbeat/status\")\n        assert resp.status_code == 200\n        data = resp.json()\n        assert len(data) == 1\n        assert data[0][\"name\"] == \"test_job\"\n\n\n# --- Self-Improvement Endpoint Tests ---\n\n\nclass TestImproveEndpoints:\n    def test_report_empty(self, client: TestClient):\n        resp = client.get(\"/api/improve/report\")\n        assert resp.status_code == 200\n        assert resp.json()[\"report\"] is None\n\n    def test_analyze_returns_report(\n        self, client: TestClient,\n    ):\n        resp = client.post(\"/api/improve/analyze\")\n        assert resp.status_code == 200\n        data = resp.json()\n        assert \"report\" in data\n        report = data[\"report\"]\n        assert \"generated_at\" in report\n        assert \"recommendations\" in report\n\n    def test_report_after_analyze(\n        self, client: TestClient,\n    ):\n        client.post(\"/api/improve/analyze\")\n        resp = client.get(\"/api/improve/report\")\n        data = resp.json()\n        assert data[\"report\"] is not None\n"
  },
  {
    "path": "maggy/tests/test_benchmark_scenario.py",
    "content": "\"\"\"Benchmark scenario — simulate a 10-task sprint across 3 models.\n\nMeasures Maggy's effectiveness at:\n  1. Routing accuracy  — correct model for each complexity tier\n  2. Budget efficiency — spend distribution across providers\n  3. Fallback resilience — recovery when models hit quota\n  4. Fatigue awareness — detects and reacts to context overload\n  5. Lock safety — prevents file clobbering between agents\n  6. Escalation — auto-escalates repeated failures\n  7. Checkpoint continuity — survives model handoff\n  8. Calibration learning — penalizes bad models over time\n  9. Dual planning — counter-checks high-blast tasks\n  10. Observability — signals recorded for all activity\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom unittest.mock import AsyncMock\n\nimport pytest\n\nfrom maggy.adapters.pi import PiAdapter, RunResult\nfrom maggy.budget import BudgetManager\nfrom maggy.calibration.tracker import CalibrationTracker\nfrom maggy.checkpoint import CheckpointManager\nfrom maggy.config import (\n    CodebaseConfig,\n    MaggyConfig,\n    OrgConfig,\n    ProjectConfig,\n    StorageConfig,\n)\nfrom maggy.coordination.lock_manager import LockManager\nfrom maggy.escalation.protocol import Escalator\nfrom maggy.mnemos.fatigue import FatigueTracker\nfrom maggy.mnemos.signals import SignalLog\nfrom maggy.observability.collector import ObservabilityCollector\nfrom maggy.providers.base import Task\nfrom maggy.registry import ProjectRegistry\nfrom maggy.routing import RoutingContext, RoutingService\nfrom maggy.services.executor import ExecutorService\nfrom maggy.services.executor_types import SessionCtx\nfrom maggy.services.planner import DualPlanner\n\n\n# -- fixtures ----------------------------------------------------------------\n\ndef _cfg(tmp_path) -> MaggyConfig:\n    return MaggyConfig(\n        org=OrgConfig(name=\"benchmark-org\"),\n        storage=StorageConfig(path=str(tmp_path / \"store.db\")),\n        codebases=[\n            CodebaseConfig(path=str(tmp_path / \"repo\"), key=\"app\"),\n        ],\n        projects=[\n            ProjectConfig(\n                name=\"app\", repo=\"bench/app\",\n                path=str(tmp_path / \"repo\"),\n                default_branch=\"main\",\n            ),\n        ],\n    )\n\n\nSPRINT_TASKS = [\n    Task(id=\"T-1\", title=\"Fix README typo\", description=\"Typo fix\",\n         raw={\"blast_score\": 1, \"task_type\": \"docs\"}),\n    Task(id=\"T-2\", title=\"Lint cleanup\", description=\"Format files\",\n         raw={\"blast_score\": 1, \"task_type\": \"formatting\"}),\n    Task(id=\"T-3\", title=\"Add health endpoint\", description=\"GET /health\",\n         raw={\"blast_score\": 3, \"task_type\": \"feature\"}),\n    Task(id=\"T-4\", title=\"Pagination for /users\", description=\"Cursor pagination\",\n         raw={\"blast_score\": 5, \"task_type\": \"feature\"}),\n    Task(id=\"T-5\", title=\"Refactor auth service\", description=\"Extract middleware\",\n         raw={\"blast_score\": 6, \"task_type\": \"refactor\"}),\n    Task(id=\"T-6\", title=\"Add rate limiter\", description=\"Redis rate limit\",\n         raw={\"blast_score\": 7, \"task_type\": \"feature\"}),\n    Task(id=\"T-7\", title=\"Migrate to v2 API\", description=\"Breaking change\",\n         raw={\"blast_score\": 8, \"task_type\": \"refactor\"}),\n    Task(id=\"T-8\", title=\"Fix XSS in comments\", description=\"Sanitize HTML\",\n         raw={\"blast_score\": 9, \"task_type\": \"security\",\n              \"security_sensitive\": True}),\n    Task(id=\"T-9\", title=\"OAuth2 PKCE flow\", description=\"Full OAuth impl\",\n         raw={\"blast_score\": 10, \"task_type\": \"security\",\n              \"security_sensitive\": True}),\n    Task(id=\"T-10\", title=\"Performance audit\", description=\"Profile + optimize\",\n         raw={\"blast_score\": 7, \"task_type\": \"performance\"}),\n]\n\n\n# -- 1. Routing accuracy -----------------------------------------------------\n\nclass TestRoutingAccuracy:\n    \"\"\"Every task lands on the right model tier.\"\"\"\n\n    def test_all_10_tasks_route_correctly(self, tmp_path):\n        cfg = _cfg(tmp_path)\n        svc = RoutingService(cfg)\n        results: dict[str, str] = {}\n\n        for task in SPRINT_TASKS:\n            raw = task.raw or {}\n            ctx = RoutingContext(\n                blast_score=raw.get(\"blast_score\", 0),\n                task_type=raw.get(\"task_type\", \"general\"),\n                security_sensitive=raw.get(\"security_sensitive\", False),\n            )\n            decision = svc.route(ctx)\n            name = decision.primary if isinstance(decision.primary, str) else decision.primary.name\n            results[task.id] = name\n\n        # Low blast (1-3) → cheap tier unless rules override\n        # T-1 is docs → rules force claude\n        assert results[\"T-1\"] == \"claude\"\n        assert results[\"T-2\"] in (\"local\", \"kimi\")\n        assert results[\"T-3\"] in (\"local\", \"kimi\")\n        # Blast 5 → local(0-5) cheapest, codex(4-10), claude(5-10)\n        assert results[\"T-4\"] in (\"local\", \"codex\")\n        # Blast 6 → codex(4-10) cheapest, claude(5-10)\n        assert results[\"T-5\"] in (\"codex\", \"claude\")\n        # Blast 7 → codex or claude\n        assert results[\"T-6\"] in (\"codex\", \"claude\")\n        # Blast 8+ → codex or claude (security→claude)\n        assert results[\"T-7\"] in (\"codex\", \"claude\")\n        # Security always premium (claude)\n        assert results[\"T-8\"] == \"claude\"\n        assert results[\"T-9\"] == \"claude\"\n        assert results[\"T-10\"] in (\"codex\", \"claude\")\n\n    def test_routing_accuracy_score(self, tmp_path):\n        \"\"\"Compute accuracy as % of correct routing decisions.\"\"\"\n        cfg = _cfg(tmp_path)\n        svc = RoutingService(cfg)\n        correct = 0\n\n        expected_tiers = {\n            \"T-1\": \"premium\", \"T-2\": \"cheap\", \"T-3\": \"cheap\",\n            \"T-4\": \"cheap\",   # local covers 0-5\n            \"T-5\": \"mid\",     # codex covers 4-10\n            \"T-6\": \"mid\",     # codex covers 4-10\n            \"T-7\": \"mid\",     # codex (no security override)\n            \"T-8\": \"premium\", \"T-9\": \"premium\",\n            \"T-10\": \"mid\",    # codex covers 4-10\n        }\n        tier_map = {\"local\": \"cheap\", \"kimi\": \"cheap\",\n                     \"codex\": \"mid\", \"claude\": \"premium\"}\n\n        for task in SPRINT_TASKS:\n            raw = task.raw or {}\n            ctx = RoutingContext(\n                blast_score=raw.get(\"blast_score\", 0),\n                task_type=raw.get(\"task_type\", \"general\"),\n                security_sensitive=raw.get(\"security_sensitive\", False),\n            )\n            decision = svc.route(ctx)\n            name = decision.primary if isinstance(decision.primary, str) else decision.primary.name\n            actual_tier = tier_map.get(name, \"unknown\")\n            if actual_tier == expected_tiers[task.id]:\n                correct += 1\n\n        accuracy = correct / len(SPRINT_TASKS)\n        assert accuracy >= 0.9, f\"Routing accuracy {accuracy:.0%} < 90%\"\n\n\n# -- 2. Budget efficiency ----------------------------------------------------\n\nclass TestBudgetEfficiency:\n    def test_spend_distribution(self, tmp_path):\n        cfg = _cfg(tmp_path)\n        bm = BudgetManager(cfg)\n        # Simulate spend from a 10-task sprint\n        bm.record_spend(\"moonshot\", \"kimi-k2\", 0.03)\n        bm.record_spend(\"moonshot\", \"kimi-k2\", 0.03)\n        bm.record_spend(\"moonshot\", \"kimi-k2\", 0.02)\n        bm.record_spend(\"openai\", \"gpt-4o\", 0.30)\n        bm.record_spend(\"openai\", \"gpt-4o\", 0.25)\n        bm.record_spend(\"anthropic\", \"claude-sonnet-4\", 1.20)\n        bm.record_spend(\"anthropic\", \"claude-sonnet-4\", 1.50)\n        bm.record_spend(\"anthropic\", \"claude-sonnet-4\", 1.80)\n        bm.record_spend(\"anthropic\", \"claude-sonnet-4\", 1.60)\n        bm.record_spend(\"anthropic\", \"claude-sonnet-4\", 1.40)\n\n        breakdown = bm.by_provider()\n        by_name = {r[\"provider\"]: r[\"spent_usd\"] for r in breakdown}\n\n        # Cheap tasks should be < 5% of total\n        total = sum(by_name.values())\n        cheap_pct = by_name.get(\"moonshot\", 0) / total\n        assert cheap_pct < 0.05, f\"Cheap tier {cheap_pct:.0%} >= 5%\"\n\n        # Premium should be > 70% (complex tasks dominate)\n        premium_pct = by_name.get(\"anthropic\", 0) / total\n        assert premium_pct > 0.70, f\"Premium {premium_pct:.0%} <= 70%\"\n\n\n# -- 3. Fallback resilience --------------------------------------------------\n\nclass TestFallbackResilience:\n    @pytest.mark.asyncio\n    async def test_quota_recovery(self):\n        pi = PiAdapter()\n        attempts: list[str] = []\n\n        async def fake_send(model, prompt, wd, max_turns=20, timeout=600):\n            attempts.append(model)\n            if model in (\"kimi\", \"deepseek\"):\n                return RunResult(model=model, success=False, error=\"quota\", quota_hit=True)\n            return RunResult(model=model, success=True, output=\"recovered\")\n\n        pi.send_prompt = fake_send\n        result = await pi.send_with_fallback(\"kimi\", \"test\", \"/tmp\")\n\n        assert result.success\n        assert len(attempts) >= 3, \"Should try multiple models\"\n        assert attempts[0] == \"kimi\"\n        assert result.model not in (\"kimi\", \"deepseek\")\n\n    @pytest.mark.asyncio\n    async def test_full_chain_failure(self):\n        pi = PiAdapter()\n\n        async def all_fail(model, prompt, wd, max_turns=20, timeout=600):\n            return RunResult(model=model, success=False, error=\"down\")\n\n        pi.send_prompt = all_fail\n        result = await pi.send_with_fallback(\"kimi\", \"test\", \"/tmp\")\n\n        assert not result.success\n\n\n# -- 4. Fatigue awareness ----------------------------------------------------\n\nclass TestFatigueAwareness:\n    def test_progressive_fatigue(self):\n        ft = FatigueTracker(context_window=200_000)\n        assert ft.state() == \"ok\"\n\n        # Simulate 5 steps of increasing context\n        for i in range(5):\n            ft.record(\"context_load\", 0.15 * (i + 1))\n            ft.record(\"turn_pressure\", 0.1 * (i + 1))\n\n        assert ft.composite() > 0.3\n\n    def test_model_switch_degrades_fatigue(self):\n        ft = FatigueTracker(context_window=200_000)\n        ft.record(\"reread_ratio\", 0.2)\n\n        ft.on_model_switch(128_000)\n        assert ft.dimensions[\"reread_ratio\"] == pytest.approx(0.35)\n        assert ft.context_window == 128_000\n\n        ft.on_model_switch(128_000)\n        assert ft.dimensions[\"reread_ratio\"] == pytest.approx(0.50)\n\n    def test_critical_state_detection(self):\n        ft = FatigueTracker()\n        for dim in (\"context_load\", \"turn_pressure\", \"reread_ratio\", \"handoff_risk\"):\n            ft.record(dim, 0.85)\n        assert ft.state() == \"critical\"\n\n\n# -- 5. Lock safety ----------------------------------------------------------\n\nclass TestLockSafety:\n    def test_concurrent_agent_protection(self, tmp_path):\n        locks = LockManager(tmp_path / \"bench-locks.db\")\n        assert locks.acquire(\"src/auth.py\", \"kimi-agent\")\n        assert not locks.acquire(\"src/auth.py\", \"claude-agent\")\n        assert locks.acquire(\"src/api.py\", \"claude-agent\")\n\n        conflicts = locks.conflicts([\"src/auth.py\", \"src/api.py\"])\n        assert len(conflicts) == 2\n\n    def test_release_allows_reacquire(self, tmp_path):\n        locks = LockManager(tmp_path / \"bench-locks.db\")\n        locks.acquire(\"src/main.py\", \"agent-a\")\n        locks.release(\"src/main.py\", \"agent-a\")\n        assert locks.acquire(\"src/main.py\", \"agent-b\")\n\n    def test_release_all_by_session(self, tmp_path):\n        locks = LockManager(tmp_path / \"bench-locks.db\")\n        locks.acquire(\"f1.py\", \"sess-1\")\n        locks.acquire(\"f2.py\", \"sess-1\")\n        locks.acquire(\"f3.py\", \"sess-1\")\n        count = locks.release_all(\"sess-1\")\n        assert count == 3\n\n\n# -- 6. Escalation -----------------------------------------------------------\n\nclass TestEscalation:\n    def test_auto_escalate_after_failures(self, tmp_path):\n        esc = Escalator(tmp_path / \"bench-esc.db\")\n        assert len(esc.list_pending()) == 0\n\n        esc.escalate(\"sess-1\", \"repeated_failure\", {\"failures\": 3})\n        pending = esc.list_pending()\n        assert len(pending) == 1\n        assert pending[0].reason == \"repeated_failure\"\n\n    def test_resolve_clears_pending(self, tmp_path):\n        esc = Escalator(tmp_path / \"bench-esc.db\")\n        pkt = esc.escalate(\"sess-2\", \"stuck\", {})\n        esc.resolve(pkt.id, \"retry with claude\")\n        assert len(esc.list_pending()) == 0\n\n\n# -- 7. Checkpoint continuity ------------------------------------------------\n\nclass TestCheckpointContinuity:\n    def test_model_handoff_preserves_state(self, tmp_path):\n        mgr = CheckpointManager(tmp_path / \"bench-cp\")\n        mgr.write(\"session-x\", {\n            \"goal\": \"Add OAuth2\",\n            \"model_history\": [\"kimi\", \"gpt\", \"claude\"],\n            \"progress\": [\"Step 1 by kimi\", \"Step 2 by gpt\"],\n            \"current_subgoal\": \"Write tests\",\n            \"fatigue_score\": 0.45,\n        })\n        data = mgr.read(\"session-x\")\n        assert data[\"goal\"] == \"Add OAuth2\"\n        assert len(data[\"model_history\"]) == 3\n        assert data[\"fatigue_score\"] == 0.45\n\n    def test_checkpoint_cleanup(self, tmp_path):\n        mgr = CheckpointManager(tmp_path / \"bench-cp\")\n        mgr.write(\"temp-sess\", {\"goal\": \"temp\"})\n        assert mgr.read(\"temp-sess\") is not None\n        mgr.delete(\"temp-sess\")\n        assert mgr.read(\"temp-sess\") is None\n\n\n# -- 8. Calibration learning -------------------------------------------------\n\nclass TestCalibrationLearning:\n    def test_bad_model_gets_penalized(self, tmp_path):\n        cal = CalibrationTracker(tmp_path / \"bench-cal.db\")\n        # Record consistently bad predictions for \"kimi\"\n        for _ in range(10):\n            cal.record(\"kimi\", \"feature\", 0.9, 0.1)\n        # Record good predictions for \"claude\"\n        for _ in range(10):\n            cal.record(\"claude\", \"feature\", 0.8, 0.85)\n\n        kimi_acc = cal.accuracy(\"kimi\")\n        claude_acc = cal.accuracy(\"claude\")\n\n        assert kimi_acc < 0.5, f\"Bad model accuracy {kimi_acc} >= 0.5\"\n        assert claude_acc > 0.9, f\"Good model accuracy {claude_acc} <= 0.9\"\n\n    def test_routing_penalizes_uncalibrated(self, tmp_path):\n        cfg = _cfg(tmp_path)\n        svc = RoutingService(cfg)\n        # Poison kimi's calibration\n        for _ in range(10):\n            svc.calibration.record(\"kimi\", \"feature\", 0.9, 0.1)\n\n        ctx = RoutingContext(blast_score=1, task_type=\"feature\")\n        decision = svc.route(ctx)\n        name = decision.primary if isinstance(decision.primary, str) else decision.primary.name\n        # kimi should be penalized — routing skips it\n        # (only applies if kimi was the primary)\n        assert name is not None  # routing still works\n\n\n# -- 9. Dual planning -------------------------------------------------------\n\nclass TestDualPlanning:\n    @pytest.mark.asyncio\n    async def test_counter_check_runs(self):\n        models_used: list[str] = []\n\n        async def fake_send(model, prompt, wd, turns=5, timeout=600):\n            models_used.append(model)\n            text = \"CONFLICT: Missing error handling\" if model == \"codex\" else \"Step 1: implement\"\n            return RunResult(model=model, success=True, output=text)\n\n        pi = PiAdapter()\n        pi.send_prompt = fake_send\n        planner = DualPlanner(pi)\n        result = await planner.dual_plan(\"Add OAuth\", \"Implement OAuth2\", \"/tmp\")\n\n        assert \"claude\" in models_used\n        assert \"codex\" in models_used\n        assert len(result.conflicts) >= 1\n        assert \"Missing error handling\" in result.conflicts[0]\n\n\n# -- 10. Observability -------------------------------------------------------\n\nclass TestObservability:\n    def test_signal_recording(self, tmp_path):\n        obs = ObservabilityCollector(tmp_path / \"bench-obs.db\")\n        obs.record_signal(\"app\", \"deploy_status\", 1.0)\n        obs.record_signal(\"app\", \"test_coverage\", 0.87)\n        obs.record_signal(\"api\", \"latency_p99\", 0.250)\n\n        app_signals = obs.recent_signals(\"app\", limit=10)\n        assert len(app_signals) == 2\n\n        api_signals = obs.recent_signals(\"api\", limit=10)\n        assert len(api_signals) == 1\n        assert api_signals[0][\"signal_type\"] == \"latency_p99\"\n\n    def test_signal_log_jsonl(self, tmp_path):\n        log = SignalLog(tmp_path / \"bench-signals.jsonl\")\n        for i in range(5):\n            log.append({\"step\": i, \"model\": \"claude\"})\n        recent = log.recent(3)\n        assert len(recent) == 3\n        assert recent[0][\"step\"] == 2\n\n\n# -- 11. Full executor pipeline (E2E) ----------------------------------------\n\nclass TestFullExecutorPipeline:\n    @pytest.mark.asyncio\n    async def test_10_task_sprint(self, tmp_path):\n        \"\"\"Simulate a full 10-task sprint through the executor.\"\"\"\n        cfg = _cfg(tmp_path)\n        (tmp_path / \"repo\").mkdir()\n        provider = AsyncMock()\n        executor = ExecutorService(cfg, provider)\n\n        models_used: list[str] = []\n\n        async def fake_send(model, prompt, wd, max_turns=20, timeout=600):\n            models_used.append(model)\n            return RunResult(model=model, success=True, output=\"done\", cost_usd=0.10)\n\n        async def fake_ctx(cfg, task):\n            return \"\"\n\n        executor._pi.send_prompt = fake_send\n        from maggy.services import executor_helpers\n        _orig_icpg = executor_helpers.build_icpg_context\n        executor_helpers.build_icpg_context = fake_ctx\n\n        for task in SPRINT_TASKS:\n            sid = f\"s-{task.id}\"\n            session = {\n                \"id\": sid, \"task_id\": task.id,\n                \"task_title\": task.title, \"mode\": \"plan\",\n                \"working_dir\": str(tmp_path / \"repo\"),\n                \"status\": \"running\", \"started_at\": \"\", \"output\": \"\",\n            }\n            executor._sessions[sid] = session\n            ctx = SessionCtx(session, task, str(tmp_path / \"repo\"))\n            await executor._run(ctx, \"plan\")\n\n        # Verify multi-model distribution\n        unique_models = set(models_used)\n        assert len(unique_models) >= 3, f\"Only {unique_models} used\"\n        assert \"claude\" in unique_models\n        assert \"codex\" in unique_models\n        cheap = {\"kimi\", \"local\"}\n        assert cheap & unique_models, \"No cheap model used\"\n\n        # Verify fatigue was tracked\n        assert executor._fatigue.dimensions[\"context_load\"] > 0\n\n        # Verify signals were logged (plan mode uses _run_model directly)\n        # Checkpoints were written and cleaned up\n        for task in SPRINT_TASKS:\n            clean_id = task.id.replace(\"/\", \"-\")\n            assert executor._checkpoint.read(clean_id) is None\n\n    @pytest.mark.asyncio\n    async def test_sprint_budget_summary(self, tmp_path):\n        \"\"\"After a sprint, budget tracks all providers.\"\"\"\n        cfg = _cfg(tmp_path)\n        (tmp_path / \"repo\").mkdir()\n        provider = AsyncMock()\n        executor = ExecutorService(cfg, provider)\n\n        cost_map = {\"kimi\": 0.01, \"local\": 0.0, \"claude\": 0.80, \"codex\": 0.10}\n\n        async def fake_send(model, prompt, wd, max_turns=20, timeout=600):\n            return RunResult(model=model, success=True, output=\"ok\", cost_usd=cost_map.get(model, 0.05))\n\n        async def fake_ctx(cfg, task):\n            return \"\"\n\n        executor._pi.send_prompt = fake_send\n        from maggy.services import executor_helpers\n        _orig_icpg = executor_helpers.build_icpg_context\n        executor_helpers.build_icpg_context = fake_ctx\n\n        for task in SPRINT_TASKS:\n            sid = f\"s-{task.id}\"\n            session = {\n                \"id\": sid, \"task_id\": task.id,\n                \"task_title\": task.title, \"mode\": \"plan\",\n                \"working_dir\": str(tmp_path / \"repo\"),\n                \"status\": \"running\", \"started_at\": \"\", \"output\": \"\",\n            }\n            executor._sessions[sid] = session\n            ctx = SessionCtx(session, task, str(tmp_path / \"repo\"))\n            await executor._run(ctx, \"plan\")\n\n        breakdown = executor._budget.by_provider()\n        providers = {r[\"provider\"] for r in breakdown}\n        assert len(providers) >= 2, f\"Only {providers}\"\n\n\n# -- 12. Project Registry CRUD -----------------------------------------------\n\nclass TestProjectRegistry:\n    def test_full_lifecycle(self, tmp_path):\n        cfg = _cfg(tmp_path)\n        reg = ProjectRegistry(cfg)\n        assert len(reg.list()) == 1\n\n        reg.add(ProjectConfig(\n            name=\"api\", repo=\"bench/api\",\n            path=\"/tmp/api\", default_branch=\"main\",\n        ))\n        assert len(reg.list()) == 2\n        assert reg.get(\"api\") is not None\n\n        reg.remove(\"api\")\n        assert reg.get(\"api\") is None\n        assert len(reg.list()) == 1\n"
  },
  {
    "path": "maggy/tests/test_bootstrap.py",
    "content": "\"\"\"Tests for startup bootstrap — auto-populate services.\"\"\"\n\nfrom __future__ import annotations\n\nfrom pathlib import Path\nfrom unittest.mock import MagicMock, patch\n\nimport pytest\n\n\ndef _make_cfg(tmp_path: Path):\n    \"\"\"Build a minimal MaggyConfig with codebases.\"\"\"\n    from maggy.config import CodebaseConfig, MaggyConfig\n    # Create fake codebase dirs\n    repo_a = tmp_path / \"repo-a\"\n    repo_a.mkdir()\n    (repo_a / \"main.py\").write_text(\"print('hello')\")\n    (repo_a / \"utils.ts\").write_text(\"export const x = 1;\")\n    repo_b = tmp_path / \"repo-b\"\n    repo_b.mkdir()\n    (repo_b / \"app.go\").write_text(\"package main\")\n    return MaggyConfig(\n        codebases=[\n            CodebaseConfig(path=str(repo_a), key=\"repo-a\"),\n            CodebaseConfig(path=str(repo_b), key=\"repo-b\"),\n        ],\n    )\n\n\nclass TestSeedCIKG:\n    \"\"\"Test CIKG seeding from codebases.\"\"\"\n\n    def test_creates_codebase_nodes(self, tmp_path):\n        from maggy.main import _seed_cikg\n        from maggy.cikg.graph import KnowledgeGraphService\n        cfg = _make_cfg(tmp_path)\n        cikg = KnowledgeGraphService(tmp_path / \"cikg.db\")\n        _seed_cikg(cikg, cfg)\n        nodes = cikg.list_nodes(\"codebase\")\n        assert len(nodes) == 2\n        names = {n.name for n in nodes}\n        assert names == {\"repo-a\", \"repo-b\"}\n\n    def test_creates_language_nodes(self, tmp_path):\n        from maggy.main import _seed_cikg\n        from maggy.cikg.graph import KnowledgeGraphService\n        cfg = _make_cfg(tmp_path)\n        cikg = KnowledgeGraphService(tmp_path / \"cikg.db\")\n        _seed_cikg(cikg, cfg)\n        langs = cikg.list_nodes(\"technology\")\n        lang_names = {n.name for n in langs}\n        assert \"python\" in lang_names\n        assert \"typescript\" in lang_names\n        assert \"go\" in lang_names\n\n    def test_creates_edges(self, tmp_path):\n        from maggy.main import _seed_cikg\n        from maggy.cikg.graph import KnowledgeGraphService\n        cfg = _make_cfg(tmp_path)\n        cikg = KnowledgeGraphService(tmp_path / \"cikg.db\")\n        _seed_cikg(cikg, cfg)\n        edges = cikg.get_edges(\"codebase:repo-a\", \"out\")\n        edge_types = {e.edge_type for e in edges}\n        assert \"uses_technology\" in edge_types\n\n    def test_skips_missing_dirs(self, tmp_path):\n        from maggy.config import CodebaseConfig, MaggyConfig\n        from maggy.main import _seed_cikg\n        from maggy.cikg.graph import KnowledgeGraphService\n        cfg = MaggyConfig(codebases=[\n            CodebaseConfig(path=\"/nonexistent/path\", key=\"missing\"),\n        ])\n        cikg = KnowledgeGraphService(tmp_path / \"cikg.db\")\n        _seed_cikg(cikg, cfg)\n        assert cikg.list_nodes(\"codebase\") == []\n\n    def test_idempotent(self, tmp_path):\n        from maggy.main import _seed_cikg\n        from maggy.cikg.graph import KnowledgeGraphService\n        cfg = _make_cfg(tmp_path)\n        cikg = KnowledgeGraphService(tmp_path / \"cikg.db\")\n        _seed_cikg(cikg, cfg)\n        _seed_cikg(cikg, cfg)  # run again\n        nodes = cikg.list_nodes(\"codebase\")\n        assert len(nodes) == 2  # no duplicates\n\n\nclass TestBootstrap:\n    \"\"\"Test the full _bootstrap function.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_calls_services(self):\n        from maggy.main import _bootstrap\n        app = MagicMock()\n        app.state.history = MagicMock()\n        app.state.introspector = MagicMock()\n        app.state.cikg = None\n        app.state.cfg = MagicMock()\n        await _bootstrap(app)\n        app.state.history.analyze.assert_called_once()\n        app.state.introspector.analyze.assert_called_once()\n\n    @pytest.mark.asyncio\n    async def test_handles_missing_services(self):\n        from maggy.main import _bootstrap\n        app = MagicMock()\n        app.state.history = None\n        app.state.introspector = None\n        app.state.cikg = None\n        app.state.cfg = None\n        await _bootstrap(app)  # should not raise\n\n    @pytest.mark.asyncio\n    async def test_handles_analyze_error(self):\n        from maggy.main import _bootstrap\n        app = MagicMock()\n        app.state.history = MagicMock()\n        app.state.history.analyze.side_effect = RuntimeError(\"db locked\")\n        app.state.introspector = None\n        app.state.cikg = None\n        app.state.cfg = None\n        await _bootstrap(app)  # should not raise\n"
  },
  {
    "path": "maggy/tests/test_budget.py",
    "content": "\"\"\"Tests for BudgetManager — spend tracking and status.\"\"\"\n\nfrom __future__ import annotations\n\nfrom maggy.budget import ProviderBudget, TaskSpendTracker\nfrom maggy.config import BudgetConfig\nfrom maggy.budget import BudgetManager\n\n\nclass TestBudgetTracking:\n    def test_initial_spend_is_zero(self, mock_cfg):\n        bm = BudgetManager(mock_cfg)\n        assert bm.today_spend() == 0.0\n\n    def test_record_and_read(self, mock_cfg):\n        bm = BudgetManager(mock_cfg)\n        bm.record_spend(\"anthropic\", \"claude\", 0.5)\n        assert bm.today_spend() >= 0.5\n\n    def test_multiple_records_sum(self, mock_cfg):\n        bm = BudgetManager(mock_cfg)\n        bm.record_spend(\"anthropic\", \"claude\", 0.3)\n        bm.record_spend(\"openai\", \"gpt-4o\", 0.2)\n        assert bm.today_spend() >= 0.5\n\n\nclass TestBudgetStatus:\n    def test_ok_status(self, mock_cfg):\n        bm = BudgetManager(mock_cfg)\n        bm.record_spend(\"anthropic\", \"claude\", 1.0)\n        status = bm.budget_status()\n        assert status[\"status\"] == \"ok\"\n\n    def test_warning_status(self, mock_cfg):\n        bm = BudgetManager(mock_cfg)\n        bm.record_spend(\"anthropic\", \"claude\", 8.5)\n        status = bm.budget_status()\n        assert status[\"status\"] == \"warning\"\n\n    def test_exhausted_status(self, mock_cfg):\n        bm = BudgetManager(mock_cfg)\n        bm.record_spend(\"anthropic\", \"claude\", 10.0)\n        status = bm.budget_status()\n        assert status[\"status\"] == \"exhausted\"\n\n\nclass TestByProvider:\n    def test_breakdown(self, mock_cfg):\n        bm = BudgetManager(mock_cfg)\n        bm.record_spend(\"anthropic\", \"claude\", 0.5)\n        bm.record_spend(\"openai\", \"gpt-4o\", 0.3)\n        breakdown = bm.by_provider()\n        assert len(breakdown) == 2\n        providers = {r[\"provider\"] for r in breakdown}\n        assert \"anthropic\" in providers\n        assert \"openai\" in providers\n\n\nclass TestIsExhausted:\n    def test_not_exhausted(self, mock_cfg):\n        bm = BudgetManager(mock_cfg)\n        assert not bm.is_exhausted()\n\n    def test_exhausted(self, mock_cfg):\n        bm = BudgetManager(mock_cfg)\n        bm.record_spend(\"anthropic\", \"claude\", 11.0)\n        assert bm.is_exhausted()\n\n\nclass TestProviderBudgets:\n    def test_provider_exhaustion_uses_provider_limit(self, mock_cfg):\n        mock_cfg.budget = BudgetConfig(\n            daily_limit_usd=20.0,\n            providers=[\n                ProviderBudget(\"moonshot\", 1.0, \"kimi\"),\n                ProviderBudget(\"openai\", 5.0, \"gpt\"),\n            ],\n        )\n        bm = BudgetManager(mock_cfg)\n        bm.record_spend(\"moonshot\", \"kimi\", 1.1)\n        assert bm.is_provider_exhausted(\"moonshot\")\n        assert not bm.is_provider_exhausted(\"openai\")\n\n    def test_cheapest_available_skips_exhausted_provider(self, mock_cfg):\n        mock_cfg.budget = BudgetConfig(\n            providers=[\n                ProviderBudget(\"moonshot\", 1.0, \"kimi\"),\n                ProviderBudget(\"openai\", 5.0, \"gpt\"),\n            ],\n        )\n        bm = BudgetManager(mock_cfg)\n        bm.record_spend(\"moonshot\", \"kimi\", 1.0)\n        assert bm.cheapest_available() == \"gpt\"\n\n\nclass TestTokenTracking:\n    def test_initial_tokens_zero(self, mock_cfg):\n        bm = BudgetManager(mock_cfg)\n        tokens = bm.today_tokens()\n        assert tokens == {\"input\": 0, \"output\": 0}\n\n    def test_record_and_read_tokens(self, mock_cfg):\n        bm = BudgetManager(mock_cfg)\n        bm.record_spend(\"anthropic\", \"claude\", 0.5, 1000, 500)\n        bm.record_spend(\"openai\", \"gpt-4o\", 0.3, 2000, 800)\n        tokens = bm.today_tokens()\n        assert tokens[\"input\"] == 3000\n        assert tokens[\"output\"] == 1300\n\n    def test_tokens_by_provider(self, mock_cfg):\n        bm = BudgetManager(mock_cfg)\n        bm.record_spend(\"anthropic\", \"claude\", 0.5, 1000, 500)\n        bm.record_spend(\"openai\", \"gpt\", 0.3, 2000, 800)\n        tokens = bm.today_tokens(\"anthropic\")\n        assert tokens[\"input\"] == 1000\n\n    def test_budget_status_includes_tokens(self, mock_cfg):\n        bm = BudgetManager(mock_cfg)\n        bm.record_spend(\"anthropic\", \"claude\", 0.5, 1500, 600)\n        status = bm.budget_status()\n        assert status[\"input_tokens\"] == 1500\n        assert status[\"output_tokens\"] == 600\n\n\nclass TestTaskSpendTracker:\n    def test_records_total_cost(self) -> None:\n        tracker = TaskSpendTracker(5.0)\n        tracker.record(1.5)\n        tracker.record(0.5)\n        assert tracker.total() == 2.0\n\n    def test_detects_exceeded_spend(self) -> None:\n        tracker = TaskSpendTracker(2.0)\n        tracker.record(2.0)\n        assert tracker.is_exceeded()\n\n    def test_tracks_edit_loops(self) -> None:\n        tracker = TaskSpendTracker(5.0)\n        for _ in range(4):\n            tracker.record_edit(\"maggy/services/planner.py\")\n        tracker.record_edit(\"maggy/budget.py\")\n        assert tracker.detect_loop() == [\"maggy/services/planner.py\"]\n\n    def test_budget_config_has_task_limit(self) -> None:\n        cfg = BudgetConfig(max_spend_per_task=3.5)\n        assert cfg.max_spend_per_task == 3.5\n"
  },
  {
    "path": "maggy/tests/test_calibration.py",
    "content": "\"\"\"Tests for calibration tracking.\"\"\"\n\nfrom __future__ import annotations\n\nimport pytest\n\nfrom maggy.calibration import CalibrationTracker\n\n\ndef test_records_accuracy_and_error(tmp_path) -> None:\n    tracker = CalibrationTracker(tmp_path / \"calibration.db\")\n    tracker.record(\"claude\", \"planning\", 0.8, 0.7)\n    tracker.record(\"claude\", \"planning\", 0.4, 0.5)\n\n    assert tracker.accuracy(\"claude\") == pytest.approx(0.9)\n    assert tracker.calibration_error(\"claude\") == pytest.approx(0.1)\n\n\ndef test_unknown_model_returns_zero(tmp_path) -> None:\n    tracker = CalibrationTracker(tmp_path / \"calibration.db\")\n    assert tracker.accuracy(\"codex\") == 0.0\n    assert tracker.calibration_error(\"codex\") == 0.0\n\n\ndef test_accuracy_clamps_at_zero_for_large_errors(tmp_path) -> None:\n    tracker = CalibrationTracker(tmp_path / \"calibration.db\")\n    tracker.record(\"claude\", \"planning\", 0.0, 2.0)\n    assert tracker.accuracy(\"claude\") >= 0.0\n"
  },
  {
    "path": "maggy/tests/test_cascade.py",
    "content": "\"\"\"Tests for cascade execution — quality-gate-based model escalation.\"\"\"\n\nfrom __future__ import annotations\n\nimport pytest\n\nfrom maggy.adapters.pi import PiAdapter, RunResult\nfrom maggy.services.cascade import cascade_execute\n\n\nclass TestCascadeNoEscalation:\n    @pytest.mark.asyncio\n    async def test_first_model_passes(self):\n        pi = PiAdapter()\n        calls: list[str] = []\n\n        async def fake_send(\n            model_name, prompt, wd, max_turns=20, timeout=600,\n        ):\n            calls.append(model_name)\n            return RunResult(model=model_name, success=True, output=\"good\")\n\n        pi.send_prompt = fake_send\n\n        async def good_gate(output: str) -> int:\n            return 4\n\n        result = await cascade_execute(\n            pi, [\"local\", \"gpt\", \"claude\"], \"test\", \"/tmp\", good_gate,\n        )\n        assert result.model == \"local\"\n        assert not result.escalated\n        assert len(calls) == 1\n\n\nclass TestCascadeEscalation:\n    @pytest.mark.asyncio\n    async def test_low_quality_escalates(self):\n        pi = PiAdapter()\n        calls: list[str] = []\n\n        async def fake_send(\n            model_name, prompt, wd, max_turns=20, timeout=600,\n        ):\n            calls.append(model_name)\n            return RunResult(model=model_name, success=True, output=\"ok\")\n\n        pi.send_prompt = fake_send\n        scores = iter([2, 4])\n\n        async def improving_gate(output: str) -> int:\n            return next(scores)\n\n        result = await cascade_execute(\n            pi, [\"local\", \"gpt\", \"claude\"], \"test\", \"/tmp\",\n            improving_gate,\n        )\n        assert result.model == \"gpt\"\n        assert result.escalated\n        assert len(calls) == 2\n\n    @pytest.mark.asyncio\n    async def test_max_3_attempts(self):\n        pi = PiAdapter()\n        calls: list[str] = []\n\n        async def fake_send(\n            model_name, prompt, wd, max_turns=20, timeout=600,\n        ):\n            calls.append(model_name)\n            return RunResult(model=model_name, success=True, output=\"bad\")\n\n        pi.send_prompt = fake_send\n\n        async def always_bad(output: str) -> int:\n            return 1\n\n        result = await cascade_execute(\n            pi, [\"local\", \"gpt\", \"claude\"], \"test\", \"/tmp\", always_bad,\n        )\n        assert len(result.attempts) == 3\n        # All scored equally — returns best (first with highest score)\n        assert len(calls) == 3\n\n\nclass TestCascadeFailure:\n    @pytest.mark.asyncio\n    async def test_send_failure_escalates(self):\n        pi = PiAdapter()\n        calls: list[str] = []\n\n        async def fake_send(\n            model_name, prompt, wd, max_turns=20, timeout=600,\n        ):\n            calls.append(model_name)\n            if model_name == \"local\":\n                return RunResult(\n                    model=model_name, success=False, error=\"crash\",\n                )\n            return RunResult(model=model_name, success=True, output=\"ok\")\n\n        pi.send_prompt = fake_send\n\n        async def ok_gate(output: str) -> int:\n            return 4\n\n        result = await cascade_execute(\n            pi, [\"local\", \"gpt\"], \"test\", \"/tmp\", ok_gate,\n        )\n        assert result.model == \"gpt\"\n        assert result.escalated\n\n    @pytest.mark.asyncio\n    async def test_single_model_no_escalation(self):\n        pi = PiAdapter()\n\n        async def fake_send(\n            model_name, prompt, wd, max_turns=20, timeout=600,\n        ):\n            return RunResult(model=model_name, success=True, output=\"ok\")\n\n        pi.send_prompt = fake_send\n\n        async def low_gate(output: str) -> int:\n            return 2\n\n        result = await cascade_execute(\n            pi, [\"claude\"], \"test\", \"/tmp\", low_gate,\n        )\n        assert result.model == \"claude\"\n        assert len(result.attempts) == 1\n\n\nclass TestCascadeAttemptTracking:\n    @pytest.mark.asyncio\n    async def test_attempts_recorded(self):\n        pi = PiAdapter()\n\n        async def fake_send(\n            model_name, prompt, wd, max_turns=20, timeout=600,\n        ):\n            return RunResult(model=model_name, success=True, output=\"ok\")\n\n        pi.send_prompt = fake_send\n        scores = iter([1, 4])\n\n        async def gate(output: str) -> int:\n            return next(scores)\n\n        result = await cascade_execute(\n            pi, [\"local\", \"gpt\"], \"test\", \"/tmp\", gate,\n        )\n        assert len(result.attempts) == 2\n        assert result.attempts[0].model == \"local\"\n        assert result.attempts[0].score == 1\n        assert result.attempts[1].model == \"gpt\"\n        assert result.attempts[1].score == 4\n"
  },
  {
    "path": "maggy/tests/test_chat.py",
    "content": "\"\"\"Tests for ChatManager — interactive Claude sessions.\"\"\"\n\nfrom __future__ import annotations\n\nfrom pathlib import Path\n\nimport pytest\n\nfrom maggy.config import CodebaseConfig, MaggyConfig\n\n\ndef _make_cfg(tmp_path: Path) -> MaggyConfig:\n    repo = tmp_path / \"my-project\"\n    repo.mkdir()\n    return MaggyConfig(codebases=[\n        CodebaseConfig(path=str(repo), key=\"my-project\"),\n    ])\n\n\nclass TestChatManager:\n    \"\"\"Test ChatManager session lifecycle.\"\"\"\n\n    def test_create_session(self, tmp_path):\n        from maggy.services.chat import ChatManager\n        cfg = _make_cfg(tmp_path)\n        mgr = ChatManager(cfg)\n        session = mgr.create_session(\"my-project\")\n        assert session.project_key == \"my-project\"\n        assert session.status == \"idle\"\n        assert session.working_dir == str(\n            tmp_path / \"my-project\"\n        )\n        assert session.messages == []\n\n    def test_create_session_invalid_project(self, tmp_path):\n        from maggy.services.chat import ChatManager\n        cfg = _make_cfg(tmp_path)\n        mgr = ChatManager(cfg)\n        with pytest.raises(ValueError, match=\"not found\"):\n            mgr.create_session(\"nonexistent\")\n\n    def test_create_with_project_path(self, tmp_path):\n        from maggy.services.chat import ChatManager\n        cfg = _make_cfg(tmp_path)\n        mgr = ChatManager(cfg)\n        # Subdirectory of configured codebase is allowed\n        sub = tmp_path / \"my-project\" / \"src\"\n        sub.mkdir()\n        s = mgr.create_session(\"my-project\", str(sub))\n        assert s.project_key == \"my-project\"\n        assert s.working_dir == str(sub)\n\n    def test_create_rejects_outside_path(self, tmp_path):\n        from maggy.services.chat import ChatManager\n        cfg = _make_cfg(tmp_path)\n        mgr = ChatManager(cfg)\n        outside = tmp_path / \"other-repo\"\n        outside.mkdir()\n        with pytest.raises(ValueError, match=\"not inside\"):\n            mgr.create_session(\"other\", str(outside))\n\n    def test_list_sessions(self, tmp_path):\n        from maggy.services.chat import ChatManager\n        cfg = _make_cfg(tmp_path)\n        mgr = ChatManager(cfg)\n        mgr.create_session(\"my-project\")\n        mgr.create_session(\"my-project\")\n        sessions = mgr.list_sessions()\n        assert len(sessions) == 2\n\n    def test_get_session(self, tmp_path):\n        from maggy.services.chat import ChatManager\n        cfg = _make_cfg(tmp_path)\n        mgr = ChatManager(cfg)\n        s = mgr.create_session(\"my-project\")\n        got = mgr.get_session(s.id)\n        assert got is not None\n        assert got.id == s.id\n\n    def test_get_missing_session(self, tmp_path):\n        from maggy.services.chat import ChatManager\n        cfg = _make_cfg(tmp_path)\n        mgr = ChatManager(cfg)\n        assert mgr.get_session(\"missing\") is None\n\n    def test_build_cmd_new_session(self, tmp_path):\n        from maggy.services.chat import ChatManager\n        from maggy.services.chat_stream import build_cmd\n        cfg = _make_cfg(tmp_path)\n        mgr = ChatManager(cfg)\n        s = mgr.create_session(\"my-project\")\n        cmd = build_cmd(s, \"fix the bug\")\n        assert \"claude\" in cmd[0]\n        assert \"-p\" in cmd\n        assert \"fix the bug\" in cmd\n        assert \"--output-format\" in cmd\n        assert \"--resume\" not in cmd\n\n    def test_build_cmd_resume(self, tmp_path):\n        from maggy.services.chat import ChatManager\n        from maggy.services.chat_stream import build_cmd\n        cfg = _make_cfg(tmp_path)\n        mgr = ChatManager(cfg)\n        s = mgr.create_session(\"my-project\")\n        s.claude_session_id = \"abc123\"\n        cmd = build_cmd(s, \"continue working\")\n        assert \"--resume\" in cmd\n        idx = cmd.index(\"--resume\")\n        assert cmd[idx + 1] == \"abc123\"\n\n    def test_delete_session(self, tmp_path):\n        from maggy.services.chat import ChatManager\n        cfg = _make_cfg(tmp_path)\n        mgr = ChatManager(cfg)\n        s = mgr.create_session(\"my-project\")\n        assert mgr.delete_session(s.id) is True\n        assert mgr.get_session(s.id) is None\n\n    def test_delete_missing(self, tmp_path):\n        from maggy.services.chat import ChatManager\n        cfg = _make_cfg(tmp_path)\n        mgr = ChatManager(cfg)\n        assert mgr.delete_session(\"nope\") is False\n\n    def test_working_dir_security_bad_key(self, tmp_path):\n        from maggy.services.chat import ChatManager\n        cfg = _make_cfg(tmp_path)\n        mgr = ChatManager(cfg)\n        with pytest.raises(ValueError, match=\"not found\"):\n            mgr.create_session(\"hacker-repo\")\n\n    def test_working_dir_security_bad_path(self, tmp_path):\n        from maggy.services.chat import ChatManager\n        cfg = _make_cfg(tmp_path)\n        mgr = ChatManager(cfg)\n        with pytest.raises(ValueError, match=\"not inside\"):\n            mgr.create_session(\"x\", \"/etc\")\n\n\nclass TestAutoConnect:\n    \"\"\"Test auto-connect to active projects.\"\"\"\n\n    def test_auto_connect_creates_sessions(self, tmp_path):\n        from maggy.services.chat import ChatManager\n        cfg = _make_cfg(tmp_path)\n        mgr = ChatManager(cfg)\n        repo = tmp_path / \"my-project\"\n        active = [\n            {\"project\": \"my-project\", \"project_path\": str(repo)},\n        ]\n        result = mgr.auto_connect(active)\n        assert len(result) == 1\n        assert result[0].project_key == \"my-project\"\n\n    def test_auto_connect_deduplicates(self, tmp_path):\n        from maggy.services.chat import ChatManager\n        cfg = _make_cfg(tmp_path)\n        mgr = ChatManager(cfg)\n        repo = tmp_path / \"my-project\"\n        active = [\n            {\"project\": \"my-project\", \"project_path\": str(repo)},\n            {\"project\": \"my-project\", \"project_path\": str(repo)},\n        ]\n        result = mgr.auto_connect(active)\n        assert len(result) == 1\n\n    def test_auto_connect_multiple_projects(self, tmp_path):\n        from maggy.services.chat import ChatManager\n        r1 = tmp_path / \"proj-a\"\n        r2 = tmp_path / \"proj-b\"\n        r1.mkdir()\n        r2.mkdir()\n        cfg = MaggyConfig(codebases=[\n            CodebaseConfig(path=str(r1), key=\"proj-a\"),\n            CodebaseConfig(path=str(r2), key=\"proj-b\"),\n        ])\n        mgr = ChatManager(cfg)\n        active = [\n            {\"project\": \"proj-a\", \"project_path\": str(r1)},\n            {\"project\": \"proj-b\", \"project_path\": str(r2)},\n        ]\n        result = mgr.auto_connect(active)\n        assert len(result) == 2\n        keys = {s.project_key for s in result}\n        assert keys == {\"proj-a\", \"proj-b\"}\n\n    def test_auto_connect_skips_empty(self, tmp_path):\n        from maggy.services.chat import ChatManager\n        cfg = _make_cfg(tmp_path)\n        mgr = ChatManager(cfg)\n        repo = tmp_path / \"my-project\"\n        active = [\n            {\"project\": \"\", \"project_path\": \"\"},\n            {\"project\": \"my-project\", \"project_path\": str(repo)},\n        ]\n        result = mgr.auto_connect(active)\n        assert len(result) == 1\n\n    def test_find_by_project(self, tmp_path):\n        from maggy.services.chat import ChatManager\n        cfg = _make_cfg(tmp_path)\n        mgr = ChatManager(cfg)\n        s = mgr.create_session(\"my-project\")\n        found = mgr.find_by_project(\"my-project\")\n        assert found is not None\n        assert found.id == s.id\n\n    def test_find_by_project_missing(self, tmp_path):\n        from maggy.services.chat import ChatManager\n        cfg = _make_cfg(tmp_path)\n        mgr = ChatManager(cfg)\n        assert mgr.find_by_project(\"nope\") is None\n\n\nclass TestMessageQueue:\n    \"\"\"Message queuing when session is busy.\"\"\"\n\n    def test_enqueue_returns_position(self, tmp_path):\n        from maggy.services.chat import ChatManager, enqueue_msg\n        cfg = _make_cfg(tmp_path)\n        mgr = ChatManager(cfg)\n        s = mgr.create_session(\"my-project\")\n        assert enqueue_msg(s, \"msg 1\") == 1\n        assert enqueue_msg(s, \"msg 2\") == 2\n\n    def test_enqueue_full_returns_negative(self, tmp_path):\n        from maggy.services.chat import ChatManager, enqueue_msg\n        cfg = _make_cfg(tmp_path)\n        mgr = ChatManager(cfg)\n        s = mgr.create_session(\"my-project\")\n        for i in range(5):\n            enqueue_msg(s, f\"msg {i}\")\n        assert enqueue_msg(s, \"overflow\") == -1\n\n    def test_session_has_pending_queue(self, tmp_path):\n        from maggy.services.chat import ChatManager\n        cfg = _make_cfg(tmp_path)\n        mgr = ChatManager(cfg)\n        s = mgr.create_session(\"my-project\")\n        assert hasattr(s, \"pending_queue\")\n        assert len(s.pending_queue) == 0\n\n    @pytest.mark.asyncio\n    async def test_send_while_locked_enqueues(self, tmp_path):\n        from maggy.services.chat import ChatManager\n        cfg = _make_cfg(tmp_path)\n        mgr = ChatManager(cfg)\n        s = mgr.create_session(\"my-project\")\n        lock = mgr._locks[s.id]\n        async with lock:\n            chunks = [c async for c in mgr.send(s.id, \"queued\")]\n        assert any(c.get(\"type\") == \"queued\" for c in chunks)\n        assert len(s.pending_queue) == 1\n"
  },
  {
    "path": "maggy/tests/test_chat_context.py",
    "content": "\"\"\"Tests for chat context builder.\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nfrom unittest.mock import MagicMock\n\nimport pytest\n\nfrom maggy.services.chat_context import (\n    _format_recent_prompts,\n    _match_from_report,\n    _match_history,\n    _path_candidates,\n    build_project_context,\n    resolve_claude_session_id,\n)\n\n\nclass TestPathCandidates:\n    \"\"\"Test path candidate generation.\"\"\"\n\n    def test_basic_path(self):\n        result = _path_candidates(\n            \"/Users/ali/Documents/protaige\", \"protaige\"\n        )\n        assert \"protaige\" in result\n        assert \"Documents\" not in result  # skipped\n        assert \"Users\" not in result  # skipped\n        assert \"ali\" in result\n\n    def test_nested_path(self):\n        result = _path_candidates(\n            \"/Users/ali/Documents/AI-Playground/\"\n            \"claude-skills-package\",\n            \"claude-skills-package\",\n        )\n        assert \"claude-skills-package\" in result\n        assert \"AI-Playground\" in result\n\n    def test_empty_path(self):\n        result = _path_candidates(\"\", \"my-project\")\n        assert \"my-project\" in result\n\n\nclass TestMatchFromReport:\n    \"\"\"Test matching via aggregated report data.\"\"\"\n\n    def test_exact_project_match(self):\n        report = {\n            \"projects\": [\n                {\n                    \"project\": \"protaige\",\n                    \"total_sessions\": 22,\n                    \"total_prompts\": 2369,\n                    \"providers_used\": [\"claude\"],\n                    \"top_topics\": [\"maia\", \"api\", \"auth\"],\n                },\n            ],\n        }\n        result = _match_from_report(\n            report, \"/Users/ali/protaige\", \"protaige\"\n        )\n        assert \"22 sessions\" in result\n        assert \"2369 prompts\" in result\n        assert \"maia\" in result\n\n    def test_parent_dir_match(self):\n        \"\"\"Match claude-skills-package via AI-Playground.\"\"\"\n        report = {\n            \"projects\": [\n                {\n                    \"project\": \"AI-Playground\",\n                    \"total_sessions\": 5,\n                    \"total_prompts\": 51,\n                    \"providers_used\": [\"claude\"],\n                    \"top_topics\": [\"setup\", \"config\"],\n                },\n            ],\n        }\n        result = _match_from_report(\n            report,\n            \"/Users/ali/Documents/AI-Playground/\"\n            \"claude-skills-package\",\n            \"claude-skills-package\",\n        )\n        assert \"5 sessions\" in result\n        assert \"51 prompts\" in result\n\n    def test_multiple_matches(self):\n        \"\"\"Match both direct and parent entries.\"\"\"\n        report = {\n            \"projects\": [\n                {\n                    \"project\": \"plugins\",\n                    \"total_sessions\": 22,\n                    \"total_prompts\": 990,\n                    \"providers_used\": [\"claude\"],\n                    \"top_topics\": [\"plugin\"],\n                },\n                {\n                    \"project\": \"edubites\",\n                    \"total_sessions\": 10,\n                    \"total_prompts\": 200,\n                    \"providers_used\": [\"claude\"],\n                    \"top_topics\": [\"platform\"],\n                },\n            ],\n        }\n        result = _match_from_report(\n            report,\n            \"/Users/ali/edubites/plugins\",\n            \"plugins\",\n        )\n        assert \"plugins\" in result or \"22 sessions\" in result\n        assert \"edubites\" in result or \"10 sessions\" in result\n\n    def test_no_match(self):\n        report = {\n            \"projects\": [\n                {\"project\": \"unrelated\", \"total_sessions\": 1,\n                 \"total_prompts\": 5, \"providers_used\": [],\n                 \"top_topics\": []},\n            ],\n        }\n        result = _match_from_report(\n            report, \"/Users/ali/my-project\", \"my-project\"\n        )\n        assert result == \"\"\n\n\nclass TestMatchHistory:\n    \"\"\"Test the main matching function.\"\"\"\n\n    def test_uses_report_when_available(self):\n        history = MagicMock()\n        history.get_report.return_value = {\n            \"projects\": [\n                {\n                    \"project\": \"myapp\",\n                    \"total_sessions\": 5,\n                    \"total_prompts\": 100,\n                    \"providers_used\": [\"claude\"],\n                    \"top_topics\": [\"api\"],\n                },\n            ],\n        }\n        result = _match_history(\n            history, \"/Users/ali/myapp\", \"myapp\"\n        )\n        assert \"5 sessions\" in result\n\n    def test_returns_empty_when_no_history(self):\n        result = _match_history(\n            None, \"/some/path\", \"proj\"\n        )\n        assert result == \"\"\n\n    def test_returns_empty_when_no_report(self):\n        history = MagicMock()\n        history.get_report.return_value = None\n        result = _match_history(\n            history, \"/some/path\", \"proj\"\n        )\n        assert result == \"\"\n\n\nclass TestFormatRecentPrompts:\n    \"\"\"Test recent prompt formatting.\"\"\"\n\n    def test_matching_prompts(self):\n        prompts = [\n            {\"project\": \"protaige\", \"text\": \"fix the auth bug\",\n             \"timestamp\": \"2026-05-10T14:00:00\"},\n            {\"project\": \"other\", \"text\": \"unrelated\",\n             \"timestamp\": \"2026-05-10T13:00:00\"},\n        ]\n        result = _format_recent_prompts(prompts, \"protaige\")\n        assert \"fix the auth bug\" in result\n        assert \"unrelated\" not in result\n\n    def test_no_matching_prompts(self):\n        prompts = [\n            {\"project\": \"other\", \"text\": \"something\",\n             \"timestamp\": \"2026-05-10T14:00:00\"},\n        ]\n        result = _format_recent_prompts(prompts, \"protaige\")\n        assert result == \"\"\n\n    def test_limits_to_five(self):\n        prompts = [\n            {\"project\": \"x\", \"text\": f\"msg {i}\",\n             \"timestamp\": f\"2026-05-10T1{i}:00:00\"}\n            for i in range(10)\n        ]\n        result = _format_recent_prompts(prompts, \"x\")\n        assert result.count(\"- [\") == 5\n\n\nclass TestResolveSessionId:\n    \"\"\"Test Claude session ID resolution.\"\"\"\n\n    def test_finds_session_id(self, tmp_path):\n        history = tmp_path / \".claude\" / \"history.jsonl\"\n        history.parent.mkdir(parents=True)\n        entries = [\n            json.dumps({\n                \"project\": \"/Users/ali/protaige\",\n                \"sessionId\": \"abc-123\",\n                \"timestamp\": 1715000000000,\n            }),\n            json.dumps({\n                \"project\": \"/Users/ali/protaige\",\n                \"sessionId\": \"def-456\",\n                \"timestamp\": 1715100000000,\n            }),\n        ]\n        history.write_text(\"\\n\".join(entries))\n        from unittest.mock import patch\n        with patch(\n            \"maggy.services.chat_context.Path.home\",\n            return_value=tmp_path,\n        ):\n            result = resolve_claude_session_id(\n                \"/Users/ali/protaige\"\n            )\n        assert result == \"def-456\"\n\n    def test_no_match(self, tmp_path):\n        history = tmp_path / \".claude\" / \"history.jsonl\"\n        history.parent.mkdir(parents=True)\n        history.write_text(json.dumps({\n            \"project\": \"/Users/ali/other\",\n            \"sessionId\": \"xyz\",\n            \"timestamp\": 1715000000000,\n        }))\n        from unittest.mock import patch\n        with patch(\n            \"maggy.services.chat_context.Path.home\",\n            return_value=tmp_path,\n        ):\n            result = resolve_claude_session_id(\n                \"/Users/ali/protaige\"\n            )\n        assert result == \"\"\n\n    def test_missing_file(self, tmp_path):\n        from unittest.mock import patch\n        with patch(\n            \"maggy.services.chat_context.Path.home\",\n            return_value=tmp_path,\n        ):\n            result = resolve_claude_session_id(\"/some/path\")\n        assert result == \"\"\n\n\nclass TestBuildProjectContext:\n    \"\"\"Test full context assembly.\"\"\"\n\n    def test_combines_history_and_prompts(self):\n        history = MagicMock()\n        history.get_report.return_value = {\n            \"projects\": [\n                {\n                    \"project\": \"myapp\",\n                    \"total_sessions\": 8,\n                    \"total_prompts\": 200,\n                    \"providers_used\": [\"claude\"],\n                    \"top_topics\": [\"api\", \"auth\"],\n                },\n            ],\n        }\n        prompts = [\n            {\"project\": \"myapp\", \"text\": \"add endpoint\",\n             \"timestamp\": \"2026-05-10T14:00:00\"},\n        ]\n        result = build_project_context(\n            history, \"/Users/ali/myapp\", \"myapp\", prompts,\n        )\n        assert \"8 sessions\" in result\n        assert \"add endpoint\" in result\n\n    def test_empty_when_nothing(self):\n        history = MagicMock()\n        history.get_report.return_value = {\"projects\": []}\n        result = build_project_context(\n            history, \"/some/path\", \"proj\", [],\n        )\n        assert result == \"\"\n"
  },
  {
    "path": "maggy/tests/test_chat_routed.py",
    "content": "\"\"\"Tests for routed chat — multi-model routing in ChatManager.\"\"\"\n\nfrom __future__ import annotations\n\nfrom unittest.mock import MagicMock\n\nimport pytest\n\nfrom maggy.services.chat_router import estimate_blast, estimate_type\n\n\nclass TestBlastEstimation:\n    \"\"\"Blast score estimation from message keywords.\"\"\"\n\n    def test_low_blast_simple_fix(self):\n        assert estimate_blast(\"fix the typo in README\") <= 3\n\n    def test_high_blast_security(self):\n        assert estimate_blast(\"design auth system with OAuth\") >= 7\n\n    def test_high_blast_architecture(self):\n        assert estimate_blast(\"refactor database schema\") >= 5\n\n    def test_medium_blast_feature(self):\n        score = estimate_blast(\"add pagination to the API\")\n        assert 3 <= score <= 6\n\n    def test_empty_returns_default(self):\n        assert estimate_blast(\"\") == 5\n\n    # --- Intent-based scoring ---\n\n    def test_retrieval_find_key_low_blast(self):\n        \"\"\"'find the API key' is retrieval, not mid-complexity.\"\"\"\n        assert estimate_blast(\"find the API key in ~/Documents\") <= 3\n\n    def test_retrieval_show_config(self):\n        assert estimate_blast(\"show me the current config\") <= 3\n\n    def test_retrieval_check_env(self):\n        assert estimate_blast(\"check the env variables\") <= 3\n\n    def test_retrieval_where_is_file(self):\n        assert estimate_blast(\"where is the routes file\") <= 3\n\n    def test_retrieval_list_endpoints(self):\n        assert estimate_blast(\"list all API endpoints\") <= 3\n\n    def test_retrieval_read_file(self):\n        assert estimate_blast(\"read the package.json\") <= 3\n\n    def test_creation_still_mid(self):\n        \"\"\"create/implement should stay in 4-6 range.\"\"\"\n        score = estimate_blast(\"create a new user service\")\n        assert 4 <= score <= 6\n\n    def test_multi_step_high(self):\n        \"\"\"refactor + migrate = high blast.\"\"\"\n        assert estimate_blast(\"refactor and migrate the database\") >= 7\n\n    def test_retrieval_with_action_not_capped(self):\n        \"\"\"'find the bug and fix it' has both retrieval and mutation.\"\"\"\n        score = estimate_blast(\"find the bug and fix the auth\")\n        assert score >= 4\n\n\nclass TestTypeEstimation:\n    \"\"\"Task type estimation from message keywords.\"\"\"\n\n    def test_security_type(self):\n        assert estimate_type(\"fix authentication bug\") == \"security\"\n\n    def test_docs_type(self):\n        assert estimate_type(\"write documentation for API\") == \"docs\"\n\n    def test_test_type(self):\n        assert estimate_type(\"add unit tests with mock fixtures\") == \"tests\"\n\n    def test_general_default(self):\n        assert estimate_type(\"make it faster\") == \"general\"\n\n\nclass TestRoutedEndpoint:\n    \"\"\"API endpoint /send-routed returns routing metadata.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_send_routed_yields_routing_chunk(self):\n        \"\"\"First SSE chunk should be routing decision.\"\"\"\n        from maggy.services.chat_router import RoutedChat\n\n        mock_routing = MagicMock()\n        mock_routing.route.return_value = MagicMock(\n            primary=MagicMock(name=\"claude\"),\n            reason=\"blast 8 → claude\",\n        )\n        mock_budget = MagicMock()\n        mock_budget.check.return_value = True\n\n        rc = RoutedChat(mock_routing, mock_budget)\n        # We only test the routing decision, not the full send\n        decision = rc.decide(\"design auth system\", None, None)\n        assert decision is not None\n        mock_routing.route.assert_called_once()\n\n\nclass TestRewardRecording:\n    \"\"\"Reward recording after routed chat completes.\"\"\"\n\n    def test_success_records_reward(self):\n        \"\"\"Successful chat records reward=1.0.\"\"\"\n        from maggy.api.routes_chat import _record_routing_outcome\n        routing = MagicMock()\n        decision = MagicMock(\n            model=\"local\", task_type=\"general\", blast=5,\n        )\n        _record_routing_outcome(routing, decision, had_error=False)\n        routing.record_outcome.assert_called_once_with(\n            \"local\", \"general\", 5, 1.0,\n        )\n\n    def test_error_records_zero_reward(self):\n        \"\"\"Chat with error records reward=0.0.\"\"\"\n        from maggy.api.routes_chat import _record_routing_outcome\n        routing = MagicMock()\n        decision = MagicMock(\n            model=\"claude\", task_type=\"security\", blast=8,\n        )\n        _record_routing_outcome(routing, decision, had_error=True)\n        routing.record_outcome.assert_called_once_with(\n            \"claude\", \"security\", 8, 0.0,\n        )\n\n    def test_no_routing_service_noop(self):\n        \"\"\"No routing service → no crash.\"\"\"\n        from maggy.api.routes_chat import _record_routing_outcome\n        _record_routing_outcome(None, None, had_error=False)\n"
  },
  {
    "path": "maggy/tests/test_chat_router.py",
    "content": "\"\"\"Tests for blast-score estimation and task-type detection.\"\"\"\n\nfrom __future__ import annotations\n\nfrom maggy.services.chat_router import (\n    DEFAULT_BLAST,\n    estimate_blast,\n    estimate_type,\n)\n\n\ndef test_blast_hi_scores_low():\n    \"\"\"Trivial greeting should score 1, not 5.\"\"\"\n    assert estimate_blast(\"hi\") == 1\n\n\ndef test_blast_exit_scores_low():\n    \"\"\"Exit-like messages should score 1.\"\"\"\n    assert estimate_blast(\"exit\") == 1\n\n\ndef test_blast_empty_returns_default():\n    \"\"\"Empty string uses DEFAULT_BLAST.\"\"\"\n    assert estimate_blast(\"\") == DEFAULT_BLAST\n\n\ndef test_blast_security_audit_scores_high():\n    \"\"\"Multiple high-tier keywords → blast >= 7.\"\"\"\n    score = estimate_blast(\"security audit migration\")\n    assert score >= 7\n\n\ndef test_blast_fix_typo_scores_low():\n    \"\"\"Low-tier keywords → blast <= 3.\"\"\"\n    score = estimate_blast(\"fix typo in readme\")\n    assert score <= 3\n\n\ndef test_type_security_detected():\n    \"\"\"Security keywords map to security type.\"\"\"\n    assert estimate_type(\"fix auth vulnerability\") == \"security\"\n\n\ndef test_type_general_default():\n    \"\"\"No keyword matches → general.\"\"\"\n    assert estimate_type(\"hello world\") == \"general\"\n\n\ndef test_type_search_detected():\n    \"\"\"Search queries map to search type.\"\"\"\n    assert estimate_type(\"find the utils module\") == \"search\"\n\n\ndef test_type_search_grep():\n    \"\"\"grep-like queries map to search type.\"\"\"\n    assert estimate_type(\"grep for config files\") == \"search\"\n\n\ndef test_blast_search_scores_low():\n    \"\"\"Search queries should score low blast (cheap model).\"\"\"\n    score = estimate_blast(\"find the utils module\")\n    assert score <= 3\n"
  },
  {
    "path": "maggy/tests/test_chat_stream.py",
    "content": "\"\"\"Tests for chat streaming JSON parser and usage extraction.\"\"\"\n\nfrom __future__ import annotations\n\nimport json\n\nfrom maggy.services.chat_stream import parse_chunk\n\n\nclass _FakeSession:\n    def __init__(self):\n        self.claude_session_id = \"\"\n\n\ndef test_parse_result_extracts_usage():\n    session = _FakeSession()\n    data = json.dumps({\n        \"type\": \"result\",\n        \"result\": \"Done\",\n        \"cost_usd\": 0.05,\n        \"usage\": {\"input_tokens\": 1500, \"output_tokens\": 800},\n    })\n    chunk = parse_chunk(data, session)\n    assert chunk[\"type\"] == \"result\"\n    assert chunk[\"content\"] == \"Done\"\n    assert chunk[\"cost_usd\"] == 0.05\n    assert chunk[\"input_tokens\"] == 1500\n    assert chunk[\"output_tokens\"] == 800\n\n\ndef test_parse_result_without_usage():\n    session = _FakeSession()\n    data = json.dumps({\"type\": \"result\", \"result\": \"Done\"})\n    chunk = parse_chunk(data, session)\n    assert chunk[\"type\"] == \"result\"\n    assert chunk[\"content\"] == \"Done\"\n    assert \"cost_usd\" not in chunk\n\n\ndef test_parse_assistant_text():\n    session = _FakeSession()\n    data = json.dumps({\n        \"type\": \"assistant\",\n        \"message\": {\"content\": [{\"type\": \"text\", \"text\": \"Hello\"}]},\n    })\n    chunk = parse_chunk(data, session)\n    assert chunk[\"type\"] == \"text\"\n    assert chunk[\"content\"] == \"Hello\"\n\n\ndef test_parse_captures_session_id():\n    session = _FakeSession()\n    data = json.dumps({\"session_id\": \"abc123\", \"type\": \"system\"})\n    parse_chunk(data, session)\n    assert session.claude_session_id == \"abc123\"\n\n\ndef test_parse_result_zero_cost_preserved():\n    \"\"\"cost_usd=0.0 must appear in chunk, not be dropped.\"\"\"\n    session = _FakeSession()\n    data = json.dumps({\n        \"type\": \"result\",\n        \"result\": \"Done\",\n        \"cost_usd\": 0.0,\n        \"usage\": {\"input_tokens\": 0, \"output_tokens\": 0},\n    })\n    chunk = parse_chunk(data, session)\n    assert chunk[\"cost_usd\"] == 0.0\n    assert chunk[\"input_tokens\"] == 0\n    assert chunk[\"output_tokens\"] == 0\n\n\ndef test_parse_invalid_json():\n    session = _FakeSession()\n    chunk = parse_chunk(\"not json {{\", session)\n    assert chunk[\"type\"] == \"text\"\n"
  },
  {
    "path": "maggy/tests/test_checkpoint.py",
    "content": "\"\"\"Tests for cross-model checkpoint serializer.\"\"\"\n\nfrom __future__ import annotations\n\nfrom maggy.services.checkpoint import (\n    Checkpoint,\n    create_checkpoint,\n)\n\n\nclass TestCheckpoint:\n    def test_serialize_round_trip(self):\n        cp = Checkpoint(\n            goal=\"Fix auth bug\",\n            progress=[\"Found root cause\"],\n            source_model=\"claude\",\n        )\n        data = cp.serialize()\n        restored = Checkpoint.deserialize(data)\n        assert restored.goal == \"Fix auth bug\"\n        assert restored.source_model == \"claude\"\n        assert len(restored.progress) == 1\n\n    def test_serialize_sets_timestamp(self):\n        cp = Checkpoint(goal=\"test\")\n        data = cp.serialize()\n        restored = Checkpoint.deserialize(data)\n        assert restored.created_at != \"\"\n\n    def test_to_prompt_format(self):\n        cp = Checkpoint(\n            goal=\"Add logout button\",\n            constraints=[\"No breaking changes\"],\n            progress=[\"Created component\"],\n            working_state=\"Testing phase\",\n            file_context=[\"src/auth.ts\"],\n        )\n        prompt = cp.to_prompt()\n        assert \"Add logout button\" in prompt\n        assert \"No breaking changes\" in prompt\n        assert \"Created component\" in prompt\n        assert \"Testing phase\" in prompt\n        assert \"src/auth.ts\" in prompt\n\n    def test_to_prompt_minimal(self):\n        cp = Checkpoint(goal=\"Simple task\")\n        prompt = cp.to_prompt()\n        assert \"Simple task\" in prompt\n        assert \"confirm you understand\" in prompt\n\n\nclass TestCreateCheckpoint:\n    def test_helper_function(self):\n        cp = create_checkpoint(\n            goal=\"Refactor DB layer\",\n            progress=[\"Extracted interface\"],\n            model=\"gpt\",\n            working_state=\"mid-refactor\",\n            files=[\"db.py\", \"models.py\"],\n            constraints=[\"Keep API stable\"],\n        )\n        assert cp.goal == \"Refactor DB layer\"\n        assert cp.source_model == \"gpt\"\n        assert len(cp.file_context) == 2\n\n    def test_defaults(self):\n        cp = create_checkpoint(\n            goal=\"Test\", progress=[], model=\"claude\",\n        )\n        assert cp.constraints == []\n        assert cp.file_context == []\n"
  },
  {
    "path": "maggy/tests/test_checkpoint_mgr.py",
    "content": "\"\"\"Tests for CheckpointManager persistence.\"\"\"\n\nfrom __future__ import annotations\n\nfrom maggy.checkpoint import CheckpointManager\n\n\ndef _checkpoint() -> dict:\n    return {\n        \"goal\": \"Ship Phase 2\",\n        \"constraints\": [\"Keep tests green\"],\n        \"progress\": [\"Planner added\"],\n        \"model_history\": [\"claude\"],\n        \"current_subgoal\": \"Add checkpoints\",\n        \"fatigue_score\": 0.2,\n    }\n\n\nclass TestCheckpointManager:\n    def test_write_and_read(self, tmp_path) -> None:\n        mgr = CheckpointManager(tmp_path)\n        mgr.write(\"session-1\", _checkpoint())\n\n        assert mgr.read(\"session-1\") == _checkpoint()\n\n    def test_read_missing_returns_none(self, tmp_path) -> None:\n        mgr = CheckpointManager(tmp_path)\n        assert mgr.read(\"missing\") is None\n\n    def test_delete_returns_true_when_removed(self, tmp_path) -> None:\n        mgr = CheckpointManager(tmp_path)\n        mgr.write(\"session-1\", _checkpoint())\n\n        assert mgr.delete(\"session-1\") is True\n        assert mgr.read(\"session-1\") is None\n\n    def test_list_checkpoints_returns_session_ids(self, tmp_path) -> None:\n        mgr = CheckpointManager(tmp_path)\n        mgr.write(\"b\", _checkpoint())\n        mgr.write(\"a\", _checkpoint())\n\n        assert mgr.list_checkpoints() == [\"a\", \"b\"]\n\n    def test_path_traversal_rejected(self, tmp_path) -> None:\n        import pytest\n        mgr = CheckpointManager(tmp_path)\n        with pytest.raises(ValueError, match=\"Invalid session id\"):\n            mgr.write(\"../../etc/passwd\", _checkpoint())\n\n    def test_read_corrupt_json_returns_none(self, tmp_path) -> None:\n        mgr = CheckpointManager(tmp_path)\n        mgr.write(\"sess-1\", _checkpoint())\n        path = tmp_path / \"sess-1.json\"\n        path.write_text(\"{corrupt\")\n        assert mgr.read(\"sess-1\") is None\n"
  },
  {
    "path": "maggy/tests/test_cikg.py",
    "content": "\"\"\"Tests for CIKG — knowledge graph, queries, market scoring.\"\"\"\n\nfrom __future__ import annotations\n\nfrom pathlib import Path\n\nimport pytest\n\nfrom maggy.cikg.graph import KnowledgeGraphService\nfrom maggy.cikg.models import Edge, Node\nfrom maggy.cikg.queries import (\n    compare_entities,\n    find_gaps,\n    find_gaps_raw,\n    get_landscape,\n    get_segment_landscape,\n)\n\n\nclass TestKnowledgeGraph:\n    def test_add_and_get_node(self, tmp_path: Path):\n        g = KnowledgeGraphService(tmp_path / \"cikg.db\")\n        node = Node(\n            id=\"c1\", node_type=\"competitor\", name=\"Acme\",\n        )\n        g.add_node(node)\n        result = g.get_node(\"c1\")\n        assert result is not None\n        assert result.name == \"Acme\"\n\n    def test_get_missing_node(self, tmp_path: Path):\n        g = KnowledgeGraphService(tmp_path / \"cikg.db\")\n        assert g.get_node(\"nonexistent\") is None\n\n    def test_list_nodes_by_type(self, tmp_path: Path):\n        g = KnowledgeGraphService(tmp_path / \"cikg.db\")\n        g.add_node(Node(id=\"c1\", node_type=\"competitor\", name=\"A\"))\n        g.add_node(Node(id=\"f1\", node_type=\"feature\", name=\"B\"))\n        comps = g.list_nodes(\"competitor\")\n        assert len(comps) == 1\n        assert comps[0].name == \"A\"\n\n    def test_list_all_nodes(self, tmp_path: Path):\n        g = KnowledgeGraphService(tmp_path / \"cikg.db\")\n        g.add_node(Node(id=\"c1\", node_type=\"competitor\", name=\"A\"))\n        g.add_node(Node(id=\"f1\", node_type=\"feature\", name=\"B\"))\n        assert len(g.list_nodes()) == 2\n\n\nclass TestEdges:\n    def test_add_and_get_edge(self, tmp_path: Path):\n        g = KnowledgeGraphService(tmp_path / \"cikg.db\")\n        g.add_node(Node(id=\"c1\", node_type=\"competitor\", name=\"A\"))\n        g.add_node(Node(id=\"f1\", node_type=\"feature\", name=\"SSO\"))\n        g.add_edge(Edge(\n            source_id=\"c1\", target_id=\"f1\",\n            edge_type=\"has_feature\",\n        ))\n        edges = g.get_edges(\"c1\", \"out\")\n        assert len(edges) == 1\n        assert edges[0].target_id == \"f1\"\n\n    def test_inbound_edges(self, tmp_path: Path):\n        g = KnowledgeGraphService(tmp_path / \"cikg.db\")\n        g.add_node(Node(id=\"c1\", node_type=\"competitor\", name=\"A\"))\n        g.add_node(Node(id=\"f1\", node_type=\"feature\", name=\"SSO\"))\n        g.add_edge(Edge(\n            source_id=\"c1\", target_id=\"f1\",\n            edge_type=\"has_feature\",\n        ))\n        edges = g.get_edges(\"f1\", \"in\")\n        assert len(edges) == 1\n        assert edges[0].source_id == \"c1\"\n\n    def test_neighbors(self, tmp_path: Path):\n        g = KnowledgeGraphService(tmp_path / \"cikg.db\")\n        g.add_node(Node(id=\"c1\", node_type=\"competitor\", name=\"A\"))\n        g.add_node(Node(id=\"f1\", node_type=\"feature\", name=\"SSO\"))\n        g.add_edge(Edge(\n            source_id=\"c1\", target_id=\"f1\",\n            edge_type=\"has_feature\",\n        ))\n        neighbors = g.neighbors(\"c1\")\n        assert len(neighbors) == 1\n        assert neighbors[0].id == \"f1\"\n\n\nclass TestDeleteNode:\n    def test_delete_removes_node_and_edges(self, tmp_path: Path):\n        g = KnowledgeGraphService(tmp_path / \"cikg.db\")\n        g.add_node(Node(id=\"c1\", node_type=\"competitor\", name=\"A\"))\n        g.add_node(Node(id=\"f1\", node_type=\"feature\", name=\"SSO\"))\n        g.add_edge(Edge(\n            source_id=\"c1\", target_id=\"f1\",\n            edge_type=\"has_feature\",\n        ))\n        g.delete_node(\"c1\")\n        assert g.get_node(\"c1\") is None\n        assert g.get_edges(\"c1\", \"out\") == []\n\n\nclass TestQueries:\n    def _seed_graph(self, tmp_path: Path) -> KnowledgeGraphService:\n        g = KnowledgeGraphService(tmp_path / \"cikg.db\")\n        for i in range(3):\n            g.add_node(Node(\n                id=f\"c{i}\", node_type=\"competitor\",\n                name=f\"Comp{i}\",\n            ))\n        g.add_node(Node(\n            id=\"f1\", node_type=\"feature\", name=\"SSO\",\n        ))\n        g.add_node(Node(\n            id=\"t1\", node_type=\"technology\", name=\"React\",\n        ))\n        # 2 out of 3 competitors have SSO\n        g.add_edge(Edge(\"c0\", \"f1\", \"has_feature\"))\n        g.add_edge(Edge(\"c1\", \"f1\", \"has_feature\"))\n        return g\n\n    def test_find_gaps_existing(self, tmp_path: Path):\n        g = self._seed_graph(tmp_path)\n        score = find_gaps(g, \"SSO\")\n        assert score.feature == \"SSO\"\n        assert score.gap_count == 1\n        assert score.threat_level == \"medium\"\n\n    def test_find_gaps_unknown(self, tmp_path: Path):\n        g = self._seed_graph(tmp_path)\n        score = find_gaps(g, \"AI Chat\")\n        assert score.gap_count == 3\n        assert score.threat_level == \"low\"\n        assert \"differentiator\" in score.recommendation.lower()\n\n    def test_get_landscape(self, tmp_path: Path):\n        g = self._seed_graph(tmp_path)\n        ls = get_landscape(g)\n        assert ls[\"competitors\"] == 3\n        assert ls[\"features_tracked\"] == 1\n        assert ls[\"technologies\"] == 1\n\n    def test_compare_entities(self, tmp_path: Path):\n        g = self._seed_graph(tmp_path)\n        result = compare_entities(g, \"c0\", \"c1\")\n        assert \"f1\" in result[\"shared\"]\n\n\nclass TestServiceQueries:\n    def _seed_graph(self, tmp_path: Path) -> KnowledgeGraphService:\n        g = KnowledgeGraphService(tmp_path / \"cikg.db\")\n        g.add_node(Node(id=\"c0\", node_type=\"competitor\", name=\"Alpha\"))\n        g.add_node(Node(id=\"c1\", node_type=\"competitor\", name=\"Bravo\"))\n        g.add_node(Node(id=\"c2\", node_type=\"competitor\", name=\"Charlie\"))\n        g.add_node(Node(id=\"f1\", node_type=\"feature\", name=\"SSO\"))\n        g.add_node(Node(id=\"f2\", node_type=\"feature\", name=\"AI Chat\"))\n        g.add_node(Node(id=\"t1\", node_type=\"technology\", name=\"React\"))\n        g.add_node(Node(id=\"s1\", node_type=\"market_segment\", name=\"SMB\"))\n        g.add_node(Node(id=\"s2\", node_type=\"market_segment\", name=\"Enterprise\"))\n        g.add_edge(Edge(\"c0\", \"f1\", \"has_feature\"))\n        g.add_edge(Edge(\"c1\", \"f1\", \"has_feature\"))\n        g.add_edge(Edge(\"c1\", \"f2\", \"has_feature\"))\n        g.add_edge(Edge(\"c0\", \"c1\", \"competes_with\"))\n        g.add_edge(Edge(\"c0\", \"t1\", \"uses_technology\"))\n        g.add_edge(Edge(\"c1\", \"t1\", \"uses_technology\"))\n        g.add_edge(Edge(\"c0\", \"s1\", \"targets_market\"))\n        g.add_edge(Edge(\"c1\", \"s1\", \"targets_market\"))\n        g.add_edge(Edge(\"c2\", \"s2\", \"targets_market\"))\n        g.add_edge(Edge(\"c1\", \"c0\", \"threatens\"))\n        return g\n\n    def test_find_gaps_raw(self, tmp_path: Path):\n        g = self._seed_graph(tmp_path)\n        result = find_gaps_raw(g, \"SSO\")\n        assert {item[\"entity\"] for item in result} == {\n            \"Alpha\", \"Bravo\", \"Charlie\",\n        }\n        status = {item[\"entity\"]: item[\"status\"] for item in result}\n        assert status == {\n            \"Alpha\": \"has\",\n            \"Bravo\": \"has\",\n            \"Charlie\": \"lacks\",\n        }\n\n    def test_compare_entities(self, tmp_path: Path):\n        g = self._seed_graph(tmp_path)\n        result = compare_entities(g, \"c0\", \"c1\")\n        assert result[\"shared\"] == [\"f1\"]\n        assert result[\"only_a\"] == []\n        assert result[\"only_b\"] == [\"f2\"]\n        assert result[\"relationships\"][0][\"edge_type\"] == \"competes_with\"\n\n    def test_segment_landscape(self, tmp_path: Path):\n        g = self._seed_graph(tmp_path)\n        result = get_segment_landscape(g, \"SMB\")\n        assert result[\"segment\"] == \"SMB\"\n        assert result[\"competitors\"] == 2\n        assert result[\"features_tracked\"] == 2\n        assert result[\"technologies\"] == 1\n        assert result[\"threat_count\"] == 1\n\n\nclass TestTypeValidation:\n    def test_valid_node_type_accepted(self):\n        node = Node(id=\"c1\", node_type=\"competitor\", name=\"Test\")\n        assert node.node_type == \"competitor\"\n\n    def test_invalid_node_type_rejected(self):\n        with pytest.raises(ValueError, match=\"Invalid node_type\"):\n            Node(id=\"c1\", node_type=\"bogus\", name=\"Test\")\n\n    def test_valid_edge_type_accepted(self):\n        edge = Edge(source_id=\"a\", target_id=\"b\", edge_type=\"has_feature\")\n        assert edge.edge_type == \"has_feature\"\n\n    def test_invalid_edge_type_rejected(self):\n        with pytest.raises(ValueError, match=\"Invalid edge_type\"):\n            Edge(source_id=\"a\", target_id=\"b\", edge_type=\"bogus\")\n"
  },
  {
    "path": "maggy/tests/test_cli.py",
    "content": "\"\"\"Tests for Maggy CLI — thin client over REST API.\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport subprocess\nfrom unittest.mock import AsyncMock, MagicMock, patch\n\nimport pytest\nfrom typer.testing import CliRunner\n\nfrom maggy.cli import app\n\nrunner = CliRunner()\n\n\n# ── Fixtures ────────────────────────────────────────────────────────\n\n\n@pytest.fixture(autouse=True)\ndef _mock_server_running(monkeypatch):\n    \"\"\"Pretend server is always up.\"\"\"\n    monkeypatch.setattr(\n        \"maggy.cli_client.MaggyClient._check_health\",\n        lambda self: True,\n    )\n\n\ndef _mock_get(response_json: dict | list):\n    \"\"\"Return a mock httpx response.\"\"\"\n    resp = MagicMock()\n    resp.status_code = 200\n    resp.json.return_value = response_json\n    resp.raise_for_status = MagicMock()\n    return resp\n\n\n# ── Status ──────────────────────────────────────────────────────────\n\n\ndef test_status_shows_health():\n    health = {\n        \"status\": \"ok\",\n        \"mode\": \"full\",\n        \"org\": \"Protaige\",\n        \"codebases\": 5,\n        \"provider\": \"github\",\n    }\n    with patch(\"maggy.cli_client.httpx.get\", return_value=_mock_get(health)):\n        result = runner.invoke(app, [\"status\"])\n    assert result.exit_code == 0\n    assert \"Protaige\" in result.output\n\n\ndef test_status_json_flag():\n    health = {\"status\": \"ok\", \"mode\": \"full\", \"org\": \"X\", \"codebases\": 1}\n    with patch(\"maggy.cli_client.httpx.get\", return_value=_mock_get(health)):\n        result = runner.invoke(app, [\"status\", \"--json\"])\n    assert result.exit_code == 0\n    parsed = json.loads(result.output)\n    assert parsed[\"status\"] == \"ok\"\n\n\n# ── Inbox ───────────────────────────────────────────────────────────\n\n\ndef test_inbox_renders_table():\n    items = {\n        \"items\": [\n            {\"rank\": 1, \"title\": \"Fix auth bug\", \"labels\": [\"bug\"], \"ai_reason\": \"critical\", \"id\": \"1\", \"board\": \"repo\"},\n            {\"rank\": 2, \"title\": \"Add tests\", \"labels\": [\"test\"], \"ai_reason\": \"coverage\", \"id\": \"2\", \"board\": \"repo\"},\n        ],\n        \"total\": 2,\n    }\n    with patch(\"maggy.cli_client.httpx.get\", return_value=_mock_get(items)):\n        result = runner.invoke(app, [\"inbox\"])\n    assert result.exit_code == 0\n    assert \"Fix auth bug\" in result.output\n\n\ndef test_inbox_empty():\n    with patch(\"maggy.cli_client.httpx.get\", return_value=_mock_get({\"items\": [], \"total\": 0})):\n        result = runner.invoke(app, [\"inbox\"])\n    assert result.exit_code == 0\n    assert \"No tasks\" in result.output\n\n\n# ── Sessions ────────────────────────────────────────────────────────\n\n\ndef test_sessions_renders():\n    data = {\n        \"sessions\": [\n            {\"pid\": 1234, \"tool\": \"claude\", \"project\": \"myapp\", \"prompts\": 42, \"duration\": \"1h 20m\"},\n        ],\n        \"total\": 1,\n    }\n    with patch(\"maggy.cli_client.httpx.get\", return_value=_mock_get(data)):\n        result = runner.invoke(app, [\"sessions\"])\n    assert result.exit_code == 0\n    assert \"claude\" in result.output\n\n\n# ── Route ───────────────────────────────────────────────────────────\n\n\ndef test_route_decision():\n    decision = {\n        \"primary\": \"claude\",\n        \"validator\": \"codex\",\n        \"fallback\": [\"kimi\", \"ollama\"],\n        \"reason\": \"blast 8 → premium tier\",\n    }\n    with patch(\"maggy.cli_client.httpx.get\", return_value=_mock_get(decision)):\n        result = runner.invoke(app, [\"route\", \"8\"])\n    assert result.exit_code == 0\n    assert \"claude\" in result.output\n\n\n# ── Budget ──────────────────────────────────────────────────────────\n\n\ndef test_budget_renders():\n    data = {\n        \"daily_limit_usd\": 10.0,\n        \"used_today_usd\": 3.50,\n        \"providers\": [\n            {\"name\": \"anthropic\", \"used\": 2.50, \"limit\": 5.0},\n            {\"name\": \"openai\", \"used\": 1.00, \"limit\": 3.0},\n        ],\n    }\n    with patch(\"maggy.cli_client.httpx.get\", return_value=_mock_get(data)):\n        result = runner.invoke(app, [\"budget\"])\n    assert result.exit_code == 0\n    assert \"anthropic\" in result.output\n\n\n# ── Competitors ─────────────────────────────────────────────────────\n\n\ndef test_competitors_news():\n    news = [\n        {\"date\": \"2026-05-11\", \"source\": \"TechCrunch\", \"event_type\": \"funding\", \"headline\": \"Rival raises $50M\"},\n    ]\n    with patch(\"maggy.cli_client.httpx.get\", return_value=_mock_get(news)):\n        result = runner.invoke(app, [\"competitors\"])\n    assert result.exit_code == 0\n    assert \"Rival\" in result.output\n\n\n# ── Models ──────────────────────────────────────────────────────────\n\n\ndef test_models_heatmap():\n    heatmap = [\n        {\"model\": \"claude\", \"task_type\": \"security\", \"reward\": 0.92},\n        {\"model\": \"codex\", \"task_type\": \"crud\", \"reward\": 0.85},\n    ]\n    with patch(\"maggy.cli_client.httpx.get\", return_value=_mock_get(heatmap)):\n        result = runner.invoke(app, [\"models\"])\n    assert result.exit_code == 0\n    assert \"claude\" in result.output\n\n\n# ── Server auto-start ───────────────────────────────────────────────\n\n\ndef test_server_not_running_starts_it(monkeypatch):\n    \"\"\"If health check fails, CLI should attempt to start server.\"\"\"\n    monkeypatch.undo()  # remove autouse mock\n    call_count = {\"n\": 0}\n\n    def fake_check(self):\n        call_count[\"n\"] += 1\n        if call_count[\"n\"] <= 1:\n            return False\n        return True\n\n    monkeypatch.setattr(\n        \"maggy.cli_client.MaggyClient._check_health\",\n        fake_check,\n    )\n    monkeypatch.setattr(\n        \"maggy.cli_client.MaggyClient._start_server\",\n        lambda self: None,\n    )\n    health = {\"status\": \"ok\", \"mode\": \"local\", \"org\": \"Test\", \"codebases\": 0}\n    with patch(\"maggy.cli_client.httpx.get\", return_value=_mock_get(health)):\n        result = runner.invoke(app, [\"status\"])\n    assert result.exit_code == 0\n\n\ndef test_stale_port_killed_before_start(monkeypatch):\n    \"\"\"Stale port holder is killed before spawning server.\"\"\"\n    monkeypatch.undo()\n    calls = {\"health\": 0, \"kill\": 0}\n\n    def fake_check(self):\n        calls[\"health\"] += 1\n        return calls[\"health\"] > 2\n\n    monkeypatch.setattr(\n        \"maggy.cli_client.MaggyClient._check_health\",\n        fake_check,\n    )\n    monkeypatch.setattr(\n        \"maggy.cli_client.MaggyClient._start_server\",\n        lambda self: None,\n    )\n    monkeypatch.setattr(\n        \"maggy.cli_client.MaggyClient._kill_stale_port\",\n        lambda self: calls.__setitem__(\"kill\", 1),\n    )\n    health = {\n        \"status\": \"ok\", \"mode\": \"local\",\n        \"org\": \"T\", \"codebases\": 0,\n    }\n    with patch(\n        \"maggy.cli_client.httpx.get\",\n        return_value=_mock_get(health),\n    ):\n        result = runner.invoke(app, [\"status\"])\n    assert result.exit_code == 0\n    assert calls[\"kill\"] == 1\n\n\ndef test_server_log_written_to_file(monkeypatch, tmp_path):\n    \"\"\"Server stdout/stderr go to ~/.maggy/server.log.\"\"\"\n    monkeypatch.setattr(\"maggy.cli_client.CONFIG_DIR\", tmp_path)\n    captured = {}\n\n    def fake_popen(cmd, **kw):\n        captured.update(kw)\n\n    monkeypatch.setattr(\n        \"maggy.cli_client.subprocess.Popen\", fake_popen,\n    )\n    from maggy.cli_client import MaggyClient\n    MaggyClient()._start_server()\n    assert captured.get(\"stdout\") is not subprocess.DEVNULL\n    assert (tmp_path / \"server.log\").exists()\n"
  },
  {
    "path": "maggy/tests/test_cli_chat.py",
    "content": "\"\"\"Tests for maggy chat CLI — interactive REPL.\"\"\"\n\nfrom __future__ import annotations\n\nfrom unittest.mock import patch\n\nimport pytest\nfrom typer.testing import CliRunner\n\nfrom maggy.cli import app\n\nrunner = CliRunner()\n\nSESSION = {\n    \"id\": \"abc123\",\n    \"project_key\": \"my-proj\",\n    \"working_dir\": \"/tmp/my-proj\",\n    \"status\": \"idle\",\n    \"messages\": 0,\n}\n\nRESUMED = {\n    \"id\": \"abc123\",\n    \"project_key\": \"my-proj\",\n    \"working_dir\": \"/tmp/my-proj\",\n    \"status\": \"idle\",\n    \"messages\": 5,\n}\n\nHISTORY = {\n    \"id\": \"abc123\",\n    \"messages\": [\n        {\"role\": \"user\", \"content\": \"hello\"},\n        {\"role\": \"assistant\", \"content\": \"hi\"},\n    ],\n}\n\n\n@pytest.fixture(autouse=True)\ndef _no_detect(monkeypatch):\n    \"\"\"Prevent real CLI detection in tests.\"\"\"\n    from maggy.services import session_detect\n    monkeypatch.setattr(\n        session_detect, \"detect_all\",\n        lambda wd: session_detect.DetectedSessions(),\n    )\n\n\ndef _setup_new(mock_client):\n    \"\"\"Configure client mocks for new session flow.\"\"\"\n    mock_client.ensure_server.return_value = True\n    mock_client.chat_sessions.return_value = []\n    mock_client.chat_create.return_value = SESSION\n    mock_client.chat_history.return_value = {\"messages\": []}\n    mock_client.budget_summary.return_value = {\n        \"spent_today_usd\": 0, \"daily_limit_usd\": 10, \"status\": \"ok\",\n    }\n    mock_client.models_heatmap.return_value = []\n\n\n@patch(\"maggy.cli._client\")\ndef test_chat_creates_session(mock_client):\n    \"\"\"Creates new session when none exist for project.\"\"\"\n    _setup_new(mock_client)\n    with patch(\"maggy.cli_chat.Prompt\") as mp:\n        mp.ask.side_effect = [\"/quit\"]\n        result = runner.invoke(app, [\"chat\", \"my-proj\"])\n    assert result.exit_code == 0\n    assert \"my-proj\" in result.output\n    mock_client.chat_create.assert_called_once_with(\"my-proj\")\n\n\n@patch(\"maggy.cli._client\")\ndef test_chat_resumes_existing(mock_client):\n    \"\"\"Resumes existing session instead of creating new.\"\"\"\n    mock_client.ensure_server.return_value = True\n    mock_client.chat_sessions.return_value = [RESUMED]\n    mock_client.chat_history.return_value = HISTORY\n    mock_client.budget_summary.return_value = {\n        \"spent_today_usd\": 0, \"daily_limit_usd\": 10, \"status\": \"ok\",\n    }\n    mock_client.models_heatmap.return_value = []\n    with patch(\"maggy.cli_chat.Prompt\") as mp:\n        mp.ask.side_effect = [\"/quit\"]\n        result = runner.invoke(app, [\"chat\", \"my-proj\"])\n    assert result.exit_code == 0\n    assert \"Resuming\" in result.output\n    mock_client.chat_create.assert_not_called()\n\n\n@patch(\"maggy.cli._client\")\ndef test_chat_routed_streams(mock_client):\n    \"\"\"Routed chat sends via send_routed and shows model.\"\"\"\n    _setup_new(mock_client)\n    mock_client.chat_send_routed.return_value = iter([\n        {\"type\": \"routing\", \"model\": \"kimi\", \"blast\": 3,\n         \"reason\": \"low blast\"},\n        {\"type\": \"text\", \"content\": \"Hello\"},\n        {\"type\": \"done\"},\n    ])\n    with patch(\"maggy.cli_chat.Prompt\") as mp:\n        mp.ask.side_effect = [\"say hi\", \"/quit\"]\n        result = runner.invoke(app, [\"chat\", \"my-proj\"])\n    assert result.exit_code == 0\n    mock_client.chat_send_routed.assert_called_once_with(\n        \"abc123\", \"say hi\", blast=None, allowed_models=None,\n    )\n\n\n@patch(\"maggy.cli._client\")\ndef test_chat_direct_mode(mock_client):\n    \"\"\"--direct flag uses send_stream instead of routed.\"\"\"\n    _setup_new(mock_client)\n    mock_client.chat_send_stream.return_value = iter([\n        {\"type\": \"text\", \"content\": \"Hi\"},\n        {\"type\": \"done\"},\n    ])\n    with patch(\"maggy.cli_chat.Prompt\") as mp:\n        mp.ask.side_effect = [\"say hi\", \"/quit\"]\n        result = runner.invoke(\n            app, [\"chat\", \"my-proj\", \"--direct\"],\n        )\n    assert result.exit_code == 0\n    mock_client.chat_send_stream.assert_called_once_with(\n        \"abc123\", \"say hi\",\n    )\n\n\n@patch(\"maggy.cli._client\")\ndef test_chat_history_command(mock_client):\n    _setup_new(mock_client)\n    mock_client.chat_history.return_value = HISTORY\n    with patch(\"maggy.cli_chat.Prompt\") as mp:\n        mp.ask.side_effect = [\"/history\", \"/quit\"]\n        result = runner.invoke(app, [\"chat\", \"my-proj\"])\n    assert result.exit_code == 0\n\n\n@patch(\"maggy.cli._client\")\ndef test_chat_blast_override(mock_client):\n    \"\"\"'/blast 8' sets override for next message.\"\"\"\n    _setup_new(mock_client)\n    mock_client.chat_send_routed.return_value = iter([\n        {\"type\": \"routing\", \"model\": \"claude\", \"blast\": 8,\n         \"reason\": \"override\"},\n        {\"type\": \"text\", \"content\": \"Done\"},\n        {\"type\": \"done\"},\n    ])\n    with patch(\"maggy.cli_chat.Prompt\") as mp:\n        mp.ask.side_effect = [\"/blast 8\", \"do it\", \"/quit\"]\n        result = runner.invoke(app, [\"chat\", \"my-proj\"])\n    assert result.exit_code == 0\n    mock_client.chat_send_routed.assert_called_once_with(\n        \"abc123\", \"do it\", blast=8, allowed_models=None,\n    )\n\n\n@patch(\"maggy.cli._client\")\ndef test_chat_ctrl_c_exits(mock_client):\n    _setup_new(mock_client)\n    with patch(\"maggy.cli_chat.Prompt\") as mp:\n        mp.ask.side_effect = KeyboardInterrupt\n        result = runner.invoke(app, [\"chat\", \"my-proj\"])\n    assert result.exit_code == 0\n\n\n@patch(\"maggy.cli._client\")\ndef test_chat_empty_input_ignored(mock_client):\n    _setup_new(mock_client)\n    with patch(\"maggy.cli_chat.Prompt\") as mp:\n        mp.ask.side_effect = [\"\", \"  \", \"/quit\"]\n        result = runner.invoke(app, [\"chat\", \"my-proj\"])\n    assert result.exit_code == 0\n    mock_client.chat_send_routed.assert_not_called()\n\n\n@patch(\"maggy.cli._client\")\ndef test_chat_error_displayed(mock_client):\n    _setup_new(mock_client)\n    mock_client.chat_send_routed.return_value = iter([\n        {\"type\": \"error\", \"content\": \"CLI not found\"},\n        {\"type\": \"done\"},\n    ])\n    with patch(\"maggy.cli_chat.Prompt\") as mp:\n        mp.ask.side_effect = [\"test\", \"/quit\"]\n        result = runner.invoke(app, [\"chat\", \"my-proj\"])\n    assert result.exit_code == 0\n\n\n@patch(\"maggy.cli._client\")\ndef test_chat_shows_queued_status(mock_client):\n    _setup_new(mock_client)\n    mock_client.chat_send_routed.return_value = iter([\n        {\"type\": \"queued\", \"position\": 2},\n    ])\n    with patch(\"maggy.cli_chat.Prompt\") as mp:\n        mp.ask.side_effect = [\"test\", \"/quit\"]\n        result = runner.invoke(app, [\"chat\", \"my-proj\"])\n    assert result.exit_code == 0\n\n\n@patch(\"maggy.cli._client\")\ndef test_chat_shows_warning(mock_client):\n    _setup_new(mock_client)\n    mock_client.chat_send_routed.return_value = iter([\n        {\"type\": \"warning\", \"content\": \"Context: ~25000 tokens\"},\n        {\"type\": \"text\", \"content\": \"Hi\"},\n        {\"type\": \"done\"},\n    ])\n    with patch(\"maggy.cli_chat.Prompt\") as mp:\n        mp.ask.side_effect = [\"test\", \"/quit\"]\n        result = runner.invoke(app, [\"chat\", \"my-proj\"])\n    assert result.exit_code == 0\n\n\n@patch(\"maggy.cli._client\")\ndef test_chat_exit_word_quits(mock_client):\n    \"\"\"Typing 'exit' terminates the REPL (not routed to LLM).\"\"\"\n    _setup_new(mock_client)\n    with patch(\"maggy.cli_chat.Prompt\") as mp:\n        mp.ask.side_effect = [\"exit\"]\n        result = runner.invoke(app, [\"chat\", \"my-proj\"])\n    assert result.exit_code == 0\n    mock_client.chat_send_routed.assert_not_called()\n\n\n@patch(\"maggy.cli._client\")\ndef test_chat_agent_status_rendered(mock_client):\n    \"\"\"Agent status chunks render @model> step status.\"\"\"\n    _setup_new(mock_client)\n    mock_client.chat_send_routed.return_value = iter([\n        {\"type\": \"agent_status\", \"agent\": \"local\",\n         \"step\": \"ANALYZE\", \"status\": \"running\"},\n        {\"type\": \"text\", \"content\": \"Done\"},\n        {\"type\": \"done\"},\n    ])\n    with patch(\"maggy.cli_chat.Prompt\") as mp:\n        mp.ask.side_effect = [\"test\", \"/quit\"]\n        result = runner.invoke(app, [\"chat\", \"my-proj\"])\n    assert result.exit_code == 0\n    assert \"running\" in result.output\n\n\n@patch(\"maggy.cli._client\")\ndef test_chat_quota_error_shows_guide(mock_client):\n    \"\"\"Quota error triggers account switch guidance.\"\"\"\n    _setup_new(mock_client)\n    mock_client.chat_send_routed.return_value = iter([\n        {\"type\": \"error\",\n         \"content\": \"rate_limit_exceeded: quota hit\"},\n        {\"type\": \"done\"},\n    ])\n    with patch(\"maggy.cli_chat.Prompt\") as mp:\n        mp.ask.side_effect = [\"test\", \"/quit\"]\n        result = runner.invoke(app, [\"chat\", \"my-proj\"])\n    assert result.exit_code == 0\n    out = result.output.lower()\n    assert \"switch\" in out or \"login\" in out or \"account\" in out\n\n\n@patch(\"maggy.cli._client\")\ndef test_chat_prompt_uses_angle_bracket(mock_client):\n    \"\"\"Prompt uses '>' character, not 'maggy:'.\"\"\"\n    _setup_new(mock_client)\n    with patch(\"maggy.cli_chat.Prompt\") as mp:\n        mp.ask.side_effect = [\"/quit\"]\n        runner.invoke(app, [\"chat\", \"my-proj\"])\n    call_args = mp.ask.call_args[0][0]\n    assert \">\" in call_args\n    assert \"maggy\" not in call_args.lower()\n\n\n@patch(\"maggy.cli._client\")\ndef test_screenshot_command_dispatches(mock_client):\n    \"\"\"'/screenshot path.png' calls vision handler.\"\"\"\n    _setup_new(mock_client)\n    with patch(\"maggy.cli_chat.Prompt\") as mp, \\\n         patch(\"maggy.cli_chat._handle_screenshot\") as mh:\n        mp.ask.side_effect = [\"/screenshot test.png\", \"/quit\"]\n        runner.invoke(app, [\"chat\", \"my-proj\"])\n    mh.assert_called_once()\n    assert \"test.png\" in mh.call_args[0][0]\n"
  },
  {
    "path": "maggy/tests/test_cli_discovery.py",
    "content": "\"\"\"Tests for CLI auto-discovery and command building.\"\"\"\n\nfrom __future__ import annotations\n\nfrom maggy.adapters.cli_discovery import (\n    CliProfile,\n    discover_all,\n    discover_cli,\n)\n\n\ndef test_discover_all_returns_profiles():\n    result = discover_all()\n    assert \"claude\" in result.profiles\n    assert \"codex\" in result.profiles\n    assert \"kimi\" in result.profiles\n\n\ndef test_claude_discovered():\n    p = discover_cli(\"claude\")\n    assert p.installed is True\n    assert p.prompt_is_positional is True\n    assert p.prompt_flag == \"-p\"\n    assert \"skip-permissions\" in p.auto_approve_flag\n    assert p.output_format_flag == \"--output-format\"\n    assert p.work_dir_flag == \"\"\n\n\ndef test_codex_discovered():\n    p = discover_cli(\"codex\")\n    assert p.installed is True\n    assert p.uses_exec_subcommand is True\n    assert p.prompt_is_positional is True\n    assert \"bypass\" in p.auto_approve_flag\n    assert p.work_dir_flag == \"-C\"\n\n\ndef test_kimi_discovered():\n    p = discover_cli(\"kimi\")\n    assert p.installed is True\n    assert p.prompt_flag == \"-p\"\n    assert p.auto_approve_flag == \"--yolo\"\n    assert p.afk_flag == \"--afk\"\n    assert p.work_dir_flag == \"-w\"\n\n\ndef test_missing_cli():\n    p = discover_cli(\"nonexistent_xyz\")\n    assert p.installed is False\n\n\ndef test_claude_build_command():\n    p = CliProfile(\n        name=\"claude\", binary=\"claude\", installed=True,\n        prompt_flag=\"-p\", prompt_is_positional=True,\n        auto_approve_flag=\"--dangerously-skip-permissions\",\n        output_format_flag=\"--output-format\",\n    )\n    cmd = p.build_command(\"do stuff\", \"/tmp/repo\", 20)\n    assert cmd[:3] == [\"claude\", \"-p\", \"do stuff\"]\n    assert \"--dangerously-skip-permissions\" in cmd\n    assert \"--output-format\" in cmd\n    assert \"json\" in cmd\n\n\ndef test_codex_build_command():\n    p = CliProfile(\n        name=\"codex\", binary=\"codex\", installed=True,\n        uses_exec_subcommand=True, prompt_is_positional=True,\n        work_dir_flag=\"-C\",\n        auto_approve_flag=\"--dangerously-bypass-approvals-and-sandbox\",\n    )\n    cmd = p.build_command(\"do stuff\", \"/tmp/repo\", 10)\n    assert cmd[:3] == [\"codex\", \"exec\", \"do stuff\"]\n    assert \"-C\" in cmd\n    assert \"/tmp/repo\" in cmd\n\n\ndef test_kimi_build_command():\n    p = CliProfile(\n        name=\"kimi\", binary=\"kimi\", installed=True,\n        prompt_flag=\"-p\", work_dir_flag=\"-w\",\n        auto_approve_flag=\"--yolo\", afk_flag=\"--afk\",\n    )\n    cmd = p.build_command(\"do stuff\", \"/tmp/repo\", 10)\n    assert cmd[:3] == [\"kimi\", \"-p\", \"do stuff\"]\n    assert \"-w\" in cmd\n    assert \"--yolo\" in cmd\n    assert \"--afk\" in cmd\n\n\ndef test_ollama_discovered():\n    p = discover_cli(\"ollama\")\n    assert p.installed is True\n    assert p.uses_run_subcommand is True\n    assert p.prompt_is_positional is True\n    assert \"qwen\" in p.run_model and \"coder\" in p.run_model\n\n\ndef test_ollama_build_command():\n    p = CliProfile(\n        name=\"ollama\", binary=\"ollama\", installed=True,\n        uses_run_subcommand=True, run_model=\"qwen3-coder:30b-a3b-q8_0\",\n        prompt_is_positional=True,\n    )\n    cmd = p.build_command(\"do stuff\", \"/tmp/repo\", 5)\n    assert cmd[:4] == [\"ollama\", \"run\", \"qwen3-coder:30b-a3b-q8_0\", \"do stuff\"]\n    assert \"--output-format\" not in cmd\n\n\ndef test_pi_adapter_uses_discovery():\n    from maggy.adapters.pi import PiAdapter\n    pi = PiAdapter()\n    profiles = pi.discovered_profiles\n    assert \"claude\" in profiles\n    assert profiles[\"claude\"].installed is True\n    assert \"ollama\" in profiles\n    assert profiles[\"ollama\"].installed is True\n"
  },
  {
    "path": "maggy/tests/test_cli_sessions.py",
    "content": "\"\"\"Tests for CLI session management commands.\"\"\"\n\nfrom __future__ import annotations\n\nfrom unittest.mock import patch\n\nfrom typer.testing import CliRunner\n\nfrom maggy.cli import app\n\nrunner = CliRunner()\n\n\n@patch(\"maggy.cli._client\")\ndef test_spawn_creates_session(mock_client):\n    \"\"\"maggy spawn posts to execute endpoint.\"\"\"\n    mock_client.ensure_server.return_value = True\n    mock_client.spawn.return_value = {\n        \"session_id\": \"abc123\",\n    }\n    result = runner.invoke(\n        app, [\"spawn\", \"add unit tests\"],\n    )\n    assert result.exit_code == 0\n    assert \"abc123\" in result.output\n    mock_client.spawn.assert_called_once()\n\n\n@patch(\"maggy.cli._client\")\ndef test_ps_lists_sessions(mock_client):\n    \"\"\"maggy ps shows all sessions.\"\"\"\n    mock_client.ensure_server.return_value = True\n    mock_client.all_sessions.return_value = [\n        {\n            \"id\": \"abc\",\n            \"project\": \"edubites-core\",\n            \"model\": \"claude\",\n            \"status\": \"running\",\n            \"type\": \"chat\",\n        },\n    ]\n    result = runner.invoke(app, [\"ps\"])\n    assert result.exit_code == 0\n    assert \"edubites-core\" in result.output\n\n\n@patch(\"maggy.cli._client\")\ndef test_kill_stops_session(mock_client):\n    \"\"\"maggy kill sends delete to session.\"\"\"\n    mock_client.ensure_server.return_value = True\n    mock_client.kill_session.return_value = {\"ok\": True}\n    result = runner.invoke(app, [\"kill\", \"abc123\"])\n    assert result.exit_code == 0\n    mock_client.kill_session.assert_called_once_with(\"abc123\")\n"
  },
  {
    "path": "maggy/tests/test_cli_welcome.py",
    "content": "\"\"\"Tests for the CLI welcome banner.\"\"\"\n\nfrom __future__ import annotations\n\nfrom unittest.mock import MagicMock\n\nfrom maggy.cli_welcome import render_welcome\n\n\ndef _mock_client():\n    c = MagicMock()\n    c.budget_summary.return_value = {\n        \"spent_today_usd\": 1.50,\n        \"daily_limit_usd\": 10.0,\n        \"status\": \"ok\",\n    }\n    c.models_heatmap.return_value = [\n        {\"model\": \"claude\"},\n        {\"model\": \"kimi\"},\n    ]\n    return c\n\n\nSESSION = {\n    \"id\": \"abc123\",\n    \"project_key\": \"edubites\",\n    \"working_dir\": \"/tmp/edubites\",\n    \"status\": \"idle\",\n    \"messages\": 5,\n}\n\n\ndef test_render_welcome_shows_project(capsys):\n    render_welcome(\"edubites\", SESSION, _mock_client())\n    out = capsys.readouterr().out\n    assert \"edubites\" in out\n\n\ndef test_render_welcome_shows_budget(capsys):\n    render_welcome(\"edubites\", SESSION, _mock_client())\n    out = capsys.readouterr().out\n    assert \"1.50\" in out or \"$1.50\" in out\n\n\ndef test_render_welcome_shows_models(capsys):\n    render_welcome(\"edubites\", SESSION, _mock_client())\n    out = capsys.readouterr().out\n    assert \"2\" in out\n\n\ndef test_render_welcome_shows_health(capsys):\n    \"\"\"Welcome banner displays memory health score.\"\"\"\n    c = _mock_client()\n    c.engram_diagnostics.return_value = {\"health_score\": 0.85}\n    render_welcome(\"edubites\", SESSION, c)\n    out = capsys.readouterr().out\n    assert \"85%\" in out or \"0.85\" in out\n\n\ndef test_render_welcome_shows_session_history(capsys):\n    \"\"\"Welcome banner shows previous session message count.\"\"\"\n    session = {**SESSION, \"messages\": 12}\n    render_welcome(\"edubites\", session, _mock_client())\n    out = capsys.readouterr().out\n    assert \"12\" in out\n\n\ndef test_dir_shows_cwd_fallback(capsys):\n    \"\"\"Dir row uses os.getcwd() when working_dir missing.\"\"\"\n    import os\n    session = {**SESSION, \"working_dir\": \"\"}\n    render_welcome(\"edubites\", session, _mock_client())\n    out = capsys.readouterr().out\n    # Should contain part of the actual cwd, not empty string\n    cwd_tail = os.path.basename(os.getcwd())\n    assert cwd_tail in out\n\n\ndef test_models_shows_available_count(capsys):\n    \"\"\"Empty heatmap shows available model count.\"\"\"\n    c = _mock_client()\n    c.models_heatmap.return_value = []\n    render_welcome(\"edubites\", SESSION, c)\n    out = capsys.readouterr().out\n    assert \"5 available\" in out or \"available\" in out\n\n\ndef test_budget_subscription_welcome(capsys):\n    \"\"\"Subscription plan shows Subscription in welcome.\"\"\"\n    c = _mock_client()\n    c.budget_summary.return_value = {\n        \"spent_today_usd\": 0, \"daily_limit_usd\": 10.0,\n        \"status\": \"ok\", \"plan\": \"subscription\",\n    }\n    render_welcome(\"edubites\", SESSION, c)\n    out = capsys.readouterr().out\n    assert \"subscription\" in out.lower()\n"
  },
  {
    "path": "maggy/tests/test_context_compactor.py",
    "content": "\"\"\"Tests for context compactor — message summarization.\"\"\"\n\nfrom __future__ import annotations\n\nimport pytest\n\nfrom maggy.services.context_compactor import (\n    CompactionResult,\n    estimate_tokens,\n    should_compact,\n)\n\n\nclass TestEstimateTokens:\n    def test_empty_list(self):\n        assert estimate_tokens([]) == 0\n\n    def test_single_message(self):\n        msgs = [{\"role\": \"user\", \"content\": \"hello world\"}]\n        assert estimate_tokens(msgs) > 0\n\n    def test_approximation(self):\n        text = \"a\" * 400\n        msgs = [{\"role\": \"user\", \"content\": text}]\n        assert estimate_tokens(msgs) == pytest.approx(100, abs=10)\n\n\nclass TestShouldCompact:\n    def test_below_threshold_no_compact(self):\n        msgs = [{\"role\": \"user\", \"content\": \"short\"}]\n        assert not should_compact(msgs, context_window=200_000)\n\n    def test_above_threshold_compact(self):\n        big = \"x\" * 160_000\n        msgs = [{\"role\": \"user\", \"content\": big}]\n        assert should_compact(msgs, context_window=40_000)\n\n    def test_threshold_at_80_pct(self):\n        content = \"a\" * 32_800\n        msgs = [{\"role\": \"user\", \"content\": content}]\n        assert should_compact(msgs, context_window=10_000)\n\n\nclass TestCompact:\n    @pytest.mark.asyncio\n    async def test_keeps_recent_messages(self):\n        from maggy.services.context_compactor import compact\n        msgs = [\n            {\"role\": \"user\", \"content\": f\"msg {i}\"}\n            for i in range(10)\n        ]\n\n        async def fake_summarize(text):\n            return \"summary of old messages\"\n\n        result = await compact(msgs, keep_recent=4, summarizer=fake_summarize)\n        assert isinstance(result, CompactionResult)\n        assert len(result.messages) == 5\n        assert result.messages[0][\"role\"] == \"system\"\n        assert \"summary\" in result.messages[0][\"content\"]\n        assert result.messages[-1][\"content\"] == \"msg 9\"\n\n    @pytest.mark.asyncio\n    async def test_nothing_to_compact(self):\n        from maggy.services.context_compactor import compact\n        msgs = [{\"role\": \"user\", \"content\": \"hi\"}]\n\n        async def fake_summarize(text):\n            return \"summary\"\n\n        result = await compact(msgs, keep_recent=6, summarizer=fake_summarize)\n        assert result.messages == msgs\n        assert result.tokens_saved == 0\n\n    @pytest.mark.asyncio\n    async def test_summarizer_failure_passthrough(self):\n        from maggy.services.context_compactor import compact\n        msgs = [\n            {\"role\": \"user\", \"content\": f\"msg {i}\"}\n            for i in range(10)\n        ]\n\n        async def broken_summarize(text):\n            raise RuntimeError(\"model down\")\n\n        result = await compact(msgs, keep_recent=4, summarizer=broken_summarize)\n        assert result.messages == msgs\n        assert result.tokens_saved == 0\n"
  },
  {
    "path": "maggy/tests/test_contracts.py",
    "content": "\"\"\"Tests for contract generation.\"\"\"\n\nfrom __future__ import annotations\n\nfrom maggy.contracts import ContractGenerator\n\n\ndef test_generates_test_code_from_postcondition() -> None:\n    generator = ContractGenerator()\n\n    code = generator.from_postcondition(\n        \"returns sorted results\",\n        \"maggy.services.planner.DualPlanner.plan\",\n    )\n\n    assert \"returns sorted results\" in code\n    assert \"DualPlanner.plan\" in code\n    assert \"def test_dualplanner_plan_contract()\" in code\n"
  },
  {
    "path": "maggy/tests/test_convention_inferrer.py",
    "content": "\"\"\"Tests for LLM-based dynamic convention inference.\"\"\"\n\nfrom __future__ import annotations\n\nfrom pathlib import Path\n\nimport pytest\n\nfrom maggy.adapters.pi import PiAdapter, RunResult\nfrom maggy.routing_rules import Convention, RoutingRules\nfrom maggy.services.convention_inferrer import (\n    collect_fingerprint,\n    ensure_inferred,\n    infer_conventions,\n    parse_conventions,\n)\n\n\ndef test_collect_fingerprint_includes_files(tmp_path: Path):\n    (tmp_path / \"src\").mkdir()\n    (tmp_path / \"src\" / \"main.py\").write_text(\"print('hi')\")\n    (tmp_path / \"README.md\").write_text(\"# Hello\")\n    fp = collect_fingerprint(str(tmp_path))\n    assert \"src\" in fp\n    assert \"README.md\" in fp\n\n\ndef test_collect_fingerprint_excludes_noise(tmp_path: Path):\n    (tmp_path / \"node_modules\" / \"pkg\").mkdir(parents=True)\n    (tmp_path / \".git\" / \"objects\").mkdir(parents=True)\n    (tmp_path / \"__pycache__\").mkdir()\n    (tmp_path / \"src\").mkdir()\n    fp = collect_fingerprint(str(tmp_path))\n    assert \"node_modules\" not in fp\n    assert \".git\" not in fp\n    assert \"__pycache__\" not in fp\n    assert \"src\" in fp\n\n\ndef test_collect_fingerprint_includes_config(tmp_path: Path):\n    (tmp_path / \"pyproject.toml\").write_text(\"[tool.ruff]\\nline-length = 88\\n\")\n    fp = collect_fingerprint(str(tmp_path))\n    assert \"tool.ruff\" in fp\n\n\ndef test_collect_fingerprint_includes_git_log(tmp_path: Path):\n    import subprocess\n    subprocess.run([\"git\", \"init\"], cwd=tmp_path, capture_output=True)\n    subprocess.run([\"git\", \"config\", \"user.email\", \"t@t.com\"], cwd=tmp_path, capture_output=True)\n    subprocess.run([\"git\", \"config\", \"user.name\", \"T\"], cwd=tmp_path, capture_output=True)\n    (tmp_path / \"f.txt\").write_text(\"x\")\n    subprocess.run([\"git\", \"add\", \".\"], cwd=tmp_path, capture_output=True)\n    subprocess.run([\"git\", \"commit\", \"-m\", \"chore: run prisma migrate\"], cwd=tmp_path, capture_output=True)\n    fp = collect_fingerprint(str(tmp_path))\n    assert \"prisma\" in fp\n\n\ndef test_parse_conventions_from_llm_output():\n    text = \"Here are the conventions:\\n- Use prisma migrate\\n- Use turbo build\\n\"\n    convs = parse_conventions(text)\n    assert len(convs) == 2\n    assert \"prisma\" in convs[0].text.lower()\n    assert \"turbo\" in convs[1].text.lower()\n\n\ndef test_parse_ignores_non_convention_lines():\n    text = \"Analysis:\\nThe project uses X.\\n- Use X for builds\\nEnd.\"\n    convs = parse_conventions(text)\n    assert len(convs) == 1\n    assert \"Use X\" in convs[0].text\n\n\ndef test_parse_caps_at_10():\n    lines = \"\\n\".join(f\"- Convention {i}\" for i in range(15))\n    assert len(parse_conventions(lines)) == 10\n\n\ndef test_parse_empty_response():\n    assert parse_conventions(\"\") == []\n    assert parse_conventions(\"No conventions found.\") == []\n\n\ndef _seed_project(tmp_path: Path) -> None:\n    \"\"\"Add a config file so fingerprint exceeds the 20-char minimum.\"\"\"\n    (tmp_path / \"pyproject.toml\").write_text(\"[tool.ruff]\\nline-length=88\\n\")\n\n\n@pytest.mark.asyncio\nasync def test_infer_calls_local_model(tmp_path: Path):\n    _seed_project(tmp_path)\n    pi, models_called = PiAdapter(), []\n\n    async def fake_send(model, prompt, wd, **kw):\n        models_called.append(model)\n        return RunResult(model=model, success=True, output=\"- Use custom deploy\\n\")\n\n    pi.send_prompt = fake_send\n    convs = await infer_conventions(pi, str(tmp_path))\n    assert models_called[0] == \"local\"\n    assert len(convs) >= 1\n    assert \"custom deploy\" in convs[0].text.lower()\n\n\n@pytest.mark.asyncio\nasync def test_infer_falls_back_on_local_failure(tmp_path: Path):\n    _seed_project(tmp_path)\n    pi, models_called = PiAdapter(), []\n\n    async def fake_send(model, prompt, wd, **kw):\n        models_called.append(model)\n        if model == \"local\":\n            return RunResult(model=model, success=False, error=\"offline\")\n        return RunResult(model=model, success=True, output=\"- Use yarn\\n\")\n\n    pi.send_prompt = fake_send\n    convs = await infer_conventions(pi, str(tmp_path))\n    assert \"local\" in models_called\n    assert \"kimi\" in models_called\n    assert len(convs) >= 1\n\n\n@pytest.mark.asyncio\nasync def test_infer_returns_empty_on_all_failures(tmp_path: Path):\n    _seed_project(tmp_path)\n    pi = PiAdapter()\n\n    async def fail_send(model, prompt, wd, **kw):\n        return RunResult(model=model, success=False, error=\"down\")\n\n    pi.send_prompt = fail_send\n    assert await infer_conventions(pi, str(tmp_path)) == []\n\n\n@pytest.mark.asyncio\nasync def test_ensure_inferred_caches(tmp_path: Path):\n    _seed_project(tmp_path)\n    pi, call_count = PiAdapter(), [0]\n\n    async def counting_send(model, prompt, wd, **kw):\n        call_count[0] += 1\n        return RunResult(model=model, success=True, output=\"- Use X\\n\")\n\n    pi.send_prompt = counting_send\n    rules = RoutingRules()\n    await ensure_inferred(rules, \"proj\", str(tmp_path), pi)\n    first_count = call_count[0]\n    await ensure_inferred(rules, \"proj\", str(tmp_path), pi)\n    assert call_count[0] == first_count\n\n\n@pytest.mark.asyncio\nasync def test_ensure_inferred_deduplicates(tmp_path: Path):\n    _seed_project(tmp_path)\n    pi = PiAdapter()\n\n    async def fake_send(model, prompt, wd, **kw):\n        return RunResult(model=model, success=True, output=\"- Use npm install\\n- Use custom script\\n\")\n\n    pi.send_prompt = fake_send\n    rules = RoutingRules(project_conventions={\n        \"proj\": [Convention(\"Use npm install\", [\"all\"], \"auto-detected\")],\n    })\n    await ensure_inferred(rules, \"proj\", str(tmp_path), pi)\n    texts = [c.text for c in rules.project_conventions[\"proj\"]]\n    assert texts.count(\"Use npm install\") == 1\n    assert \"Use custom script\" in texts\n\n\n@pytest.mark.asyncio\nasync def test_all_inferred_have_llm_source(tmp_path: Path):\n    _seed_project(tmp_path)\n    pi = PiAdapter()\n\n    async def fake_send(model, prompt, wd, **kw):\n        return RunResult(model=model, success=True, output=\"- Use X\\n\")\n\n    pi.send_prompt = fake_send\n    rules = RoutingRules()\n    await ensure_inferred(rules, \"proj\", str(tmp_path), pi)\n    llm_convs = [c for c in rules.project_conventions.get(\"proj\", []) if c.source == \"llm-inferred\"]\n    assert len(llm_convs) >= 1\n"
  },
  {
    "path": "maggy/tests/test_convention_scanner.py",
    "content": "\"\"\"Tests for project-specific convention detection from filesystem.\"\"\"\n\nfrom __future__ import annotations\n\nfrom pathlib import Path\n\nfrom maggy.routing_rules import Convention, RoutingRules\nfrom maggy.services.convention_scanner import (\n    ensure_scanned,\n    scan_project,\n)\n\n\ndef test_detects_supabase_migrations(tmp_path: Path):\n    \"\"\"supabase/migrations/ dir -> supabase convention.\"\"\"\n    (tmp_path / \"supabase\" / \"migrations\").mkdir(parents=True)\n    convs = scan_project(str(tmp_path))\n    texts = \" \".join(c.text for c in convs)\n    assert \"supabase\" in texts.lower()\n\n\ndef test_detects_alembic(tmp_path: Path):\n    \"\"\"alembic.ini -> alembic convention.\"\"\"\n    (tmp_path / \"alembic.ini\").write_text(\"[alembic]\\n\")\n    convs = scan_project(str(tmp_path))\n    texts = \" \".join(c.text for c in convs)\n    assert \"alembic\" in texts.lower()\n\n\ndef test_detects_npm(tmp_path: Path):\n    \"\"\"package-lock.json -> npm convention.\"\"\"\n    (tmp_path / \"package-lock.json\").write_text(\"{}\")\n    convs = scan_project(str(tmp_path))\n    texts = \" \".join(c.text for c in convs)\n    assert \"npm\" in texts.lower()\n\n\ndef test_detects_pnpm(tmp_path: Path):\n    \"\"\"pnpm-lock.yaml -> pnpm convention.\"\"\"\n    (tmp_path / \"pnpm-lock.yaml\").write_text(\"\")\n    convs = scan_project(str(tmp_path))\n    texts = \" \".join(c.text for c in convs)\n    assert \"pnpm\" in texts.lower()\n\n\ndef test_detects_pytest_in_pyproject(tmp_path: Path):\n    \"\"\"pyproject.toml with [tool.pytest] -> pytest convention.\"\"\"\n    (tmp_path / \"pyproject.toml\").write_text(\n        \"[tool.pytest.ini_options]\\ntestpaths = ['tests']\\n\"\n    )\n    convs = scan_project(str(tmp_path))\n    texts = \" \".join(c.text for c in convs)\n    assert \"pytest\" in texts.lower()\n\n\ndef test_detects_ruff_in_pyproject(tmp_path: Path):\n    \"\"\"pyproject.toml with [tool.ruff] -> ruff convention.\"\"\"\n    (tmp_path / \"pyproject.toml\").write_text(\"[tool.ruff]\\nline-length=88\\n\")\n    convs = scan_project(str(tmp_path))\n    texts = \" \".join(c.text for c in convs)\n    assert \"ruff\" in texts.lower()\n\n\ndef test_empty_dir_no_conventions(tmp_path: Path):\n    \"\"\"Empty directory produces no conventions.\"\"\"\n    convs = scan_project(str(tmp_path))\n    assert convs == []\n\n\ndef test_all_conventions_have_auto_source(tmp_path: Path):\n    \"\"\"Detected conventions have source='auto-detected'.\"\"\"\n    (tmp_path / \"Makefile\").write_text(\"all:\\n\\techo hi\\n\")\n    convs = scan_project(str(tmp_path))\n    assert len(convs) >= 1\n    assert all(c.source == \"auto-detected\" for c in convs)\n\n\ndef test_conventions_for_merges_project():\n    \"\"\"conventions_for includes project-specific conventions.\"\"\"\n    from maggy.routing_rules import conventions_for\n\n    rules = RoutingRules(\n        conventions=[Convention(\"Global rule\", [\"all\"], \"manual\")],\n        project_conventions={\n            \"protaige\": [\n                Convention(\"Use supabase db push\", [\"all\"], \"auto\"),\n            ],\n        },\n    )\n    text = conventions_for(rules, \"feature\", \"protaige\")\n    assert \"Global rule\" in text\n    assert \"supabase\" in text\n\n\ndef test_conventions_for_without_project():\n    \"\"\"conventions_for without project_key returns only global.\"\"\"\n    from maggy.routing_rules import conventions_for\n\n    rules = RoutingRules(\n        conventions=[Convention(\"Global rule\", [\"all\"], \"manual\")],\n        project_conventions={\n            \"protaige\": [\n                Convention(\"Use supabase db push\", [\"all\"], \"auto\"),\n            ],\n        },\n    )\n    text = conventions_for(rules, \"feature\")\n    assert \"Global rule\" in text\n    assert \"supabase\" not in text\n\n\ndef test_ensure_scanned_caches(tmp_path: Path):\n    \"\"\"ensure_scanned only scans once per project_key.\"\"\"\n    (tmp_path / \"alembic.ini\").write_text(\"[alembic]\\n\")\n    rules = RoutingRules()\n    ensure_scanned(rules, \"my-proj\", str(tmp_path))\n    assert \"my-proj\" in rules.project_conventions\n    count = len(rules.project_conventions[\"my-proj\"])\n    ensure_scanned(rules, \"my-proj\", str(tmp_path))\n    assert len(rules.project_conventions[\"my-proj\"]) == count\n\n\ndef test_yaml_roundtrip_project_conventions(tmp_path: Path):\n    \"\"\"Project conventions survive YAML save/load cycle.\"\"\"\n    from maggy.routing_rules_io import load, save\n\n    rules = RoutingRules(\n        project_conventions={\n            \"protaige\": [\n                Convention(\"Use supabase\", [\"all\"], \"auto-detected\"),\n            ],\n            \"edubites\": [\n                Convention(\"Use alembic\", [\"all\"], \"auto-detected\"),\n            ],\n        },\n    )\n    yaml_path = tmp_path / \"rules.yaml\"\n    save(rules, yaml_path)\n    loaded = load(yaml_path)\n    assert \"protaige\" in loaded.project_conventions\n    assert \"edubites\" in loaded.project_conventions\n    assert \"supabase\" in loaded.project_conventions[\"protaige\"][0].text\n\n\ndef test_detects_docker_compose(tmp_path: Path):\n    \"\"\"docker-compose.yml -> docker convention.\"\"\"\n    (tmp_path / \"docker-compose.yml\").write_text(\"version: '3'\\n\")\n    convs = scan_project(str(tmp_path))\n    texts = \" \".join(c.text for c in convs)\n    assert \"docker\" in texts.lower()\n\n\ndef test_detects_github_actions(tmp_path: Path):\n    \"\"\".github/workflows/ -> CI convention.\"\"\"\n    (tmp_path / \".github\" / \"workflows\").mkdir(parents=True)\n    convs = scan_project(str(tmp_path))\n    texts = \" \".join(c.text for c in convs)\n    assert \"github actions\" in texts.lower()\n"
  },
  {
    "path": "maggy/tests/test_coordination.py",
    "content": "\"\"\"Tests for multi-agent coordination locks.\"\"\"\n\nfrom __future__ import annotations\n\nimport sqlite3\nfrom datetime import datetime, timedelta, timezone\n\nfrom maggy.coordination.lock_manager import LockManager\n\n\nclass TestLockManager:\n    def test_acquire_and_release(self, tmp_path):\n        manager = LockManager(tmp_path / \"locks.db\")\n        assert manager.acquire(\"maggy/a.py\", \"agent-1\") is True\n        assert manager.release(\"maggy/a.py\", \"agent-1\") is True\n        assert manager.release(\"maggy/a.py\", \"agent-1\") is False\n\n    def test_blocks_other_agent(self, tmp_path):\n        manager = LockManager(tmp_path / \"locks.db\")\n        assert manager.acquire(\"maggy/a.py\", \"agent-1\") is True\n        assert manager.acquire(\"maggy/a.py\", \"agent-2\") is False\n\n    def test_release_all_returns_count(self, tmp_path):\n        manager = LockManager(tmp_path / \"locks.db\")\n        manager.acquire(\"maggy/a.py\", \"agent-1\")\n        manager.acquire(\"maggy/b.py\", \"agent-1\")\n        manager.acquire(\"maggy/c.py\", \"agent-2\")\n        assert manager.release_all(\"agent-1\") == 2\n        assert manager.conflicts([\"maggy/a.py\", \"maggy/c.py\"]) == [\"maggy/c.py\"]\n\n    def test_conflicts_returns_locked_paths(self, tmp_path):\n        manager = LockManager(tmp_path / \"locks.db\")\n        manager.acquire(\"maggy/a.py\", \"agent-1\")\n        manager.acquire(\"maggy/c.py\", \"agent-2\")\n        conflicts = manager.conflicts([\"maggy/a.py\", \"maggy/b.py\", \"maggy/c.py\"])\n        assert conflicts == [\"maggy/a.py\", \"maggy/c.py\"]\n\n    def test_expired_locks_are_removed(self, tmp_path):\n        db_path = tmp_path / \"locks.db\"\n        manager = LockManager(db_path)\n        expired_at = datetime.now(timezone.utc) - timedelta(minutes=31)\n        with sqlite3.connect(db_path) as conn:\n            conn.execute(\n                \"INSERT INTO locks(file_path, agent_id, acquired_at, expires_at) \"\n                \"VALUES (?, ?, ?, ?)\",\n                (\n                    \"maggy/a.py\",\n                    \"agent-1\",\n                    expired_at.isoformat(),\n                    expired_at.isoformat(),\n                ),\n            )\n            conn.commit()\n        assert manager.acquire(\"maggy/a.py\", \"agent-2\") is True\n"
  },
  {
    "path": "maggy/tests/test_deploy.py",
    "content": "\"\"\"Tests for deploy service — session management.\"\"\"\n\nfrom __future__ import annotations\n\nfrom maggy.deploy import DeployService, DeploySession\n\n\nclass TestDeployService:\n    def test_create_session(self):\n        svc = DeployService()\n        session = svc.create_session(\"myapp\", \"main\")\n        assert session.project == \"myapp\"\n        assert session.branch == \"main\"\n        assert session.status == \"building\"\n\n    def test_get_session(self):\n        svc = DeployService()\n        session = svc.create_session(\"myapp\", \"feat\")\n        result = svc.get_session(session.session_id)\n        assert result is not None\n        assert result.branch == \"feat\"\n\n    def test_get_missing_session(self):\n        svc = DeployService()\n        assert svc.get_session(\"nonexistent\") is None\n\n    def test_list_sessions(self):\n        svc = DeployService()\n        svc.create_session(\"app1\", \"main\")\n        svc.create_session(\"app2\", \"dev\")\n        sessions = svc.list_sessions()\n        assert len(sessions) == 2\n\n    def test_update_status(self):\n        svc = DeployService()\n        session = svc.create_session(\"myapp\", \"main\")\n        updated = svc.update_status(\n            session.session_id, \"live\",\n            url=\"https://preview.vercel.app\",\n        )\n        assert updated.status == \"live\"\n        assert updated.url == \"https://preview.vercel.app\"\n\n    def test_update_missing_returns_none(self):\n        svc = DeployService()\n        assert svc.update_status(\"nope\", \"live\") is None\n\n    def test_teardown(self):\n        svc = DeployService()\n        session = svc.create_session(\"myapp\", \"main\")\n        assert svc.teardown(session.session_id)\n        assert svc.get_session(session.session_id) is None\n\n    def test_teardown_missing(self):\n        svc = DeployService()\n        assert not svc.teardown(\"nonexistent\")\n"
  },
  {
    "path": "maggy/tests/test_discovery.py",
    "content": "\"\"\"Tests for environment auto-discovery.\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nfrom pathlib import Path\nfrom unittest.mock import patch\n\nimport pytest\n\nfrom maggy.discovery import (\n    DiscoveryResult,\n    _parse_org_from_url,\n    discover_active_projects,\n    discover_clis,\n    discover_env_tokens,\n    discover_repos,\n    full_discovery,\n)\nfrom maggy.process.discovery import discover_local\n\n\nclass TestDiscoverLocal:\n    def test_empty_project(self, tmp_path: Path):\n        result = discover_local(tmp_path)\n        assert result[\"ci\"] == []\n        assert result[\"quality\"] == []\n        assert result[\"review\"] == []\n        assert result[\"deps\"] == []\n\n    def test_detects_github_actions(self, tmp_path: Path):\n        (tmp_path / \".github\" / \"workflows\").mkdir(parents=True)\n        result = discover_local(tmp_path)\n        assert \"github_actions\" in result[\"ci\"]\n\n    def test_detects_jenkins(self, tmp_path: Path):\n        (tmp_path / \"Jenkinsfile\").touch()\n        result = discover_local(tmp_path)\n        assert \"jenkins\" in result[\"ci\"]\n\n    def test_detects_circleci(self, tmp_path: Path):\n        (tmp_path / \".circleci\").mkdir()\n        result = discover_local(tmp_path)\n        assert \"circleci\" in result[\"ci\"]\n\n    def test_detects_gitlab_ci(self, tmp_path: Path):\n        (tmp_path / \".gitlab-ci.yml\").touch()\n        result = discover_local(tmp_path)\n        assert \"gitlab_ci\" in result[\"ci\"]\n\n    def test_detects_eslint(self, tmp_path: Path):\n        (tmp_path / \".eslintrc.json\").touch()\n        result = discover_local(tmp_path)\n        assert \"eslint\" in result[\"quality\"]\n\n    def test_detects_ruff_in_pyproject(self, tmp_path: Path):\n        pyproject = tmp_path / \"pyproject.toml\"\n        pyproject.write_text(\"[tool.ruff]\\nline-length = 88\\n\")\n        result = discover_local(tmp_path)\n        assert \"ruff\" in result[\"quality\"]\n\n    def test_detects_pre_commit(self, tmp_path: Path):\n        (tmp_path / \".pre-commit-config.yaml\").touch()\n        result = discover_local(tmp_path)\n        assert \"pre-commit\" in result[\"quality\"]\n\n    def test_detects_codeowners(self, tmp_path: Path):\n        (tmp_path / \"CODEOWNERS\").touch()\n        result = discover_local(tmp_path)\n        assert \"codeowners\" in result[\"review\"]\n\n    def test_detects_dependabot(self, tmp_path: Path):\n        (tmp_path / \".github\").mkdir(parents=True)\n        (tmp_path / \".github\" / \"dependabot.yml\").touch()\n        result = discover_local(tmp_path)\n        assert \"dependabot\" in result[\"deps\"]\n\n    def test_detects_renovate(self, tmp_path: Path):\n        (tmp_path / \"renovate.json\").touch()\n        result = discover_local(tmp_path)\n        assert \"renovate\" in result[\"deps\"]\n\n\n# --- CLI Discovery ---\n\n\nclass TestDiscoverClis:\n    def test_finds_installed(self):\n        def _which(n):\n            return f\"/usr/bin/{n}\" if n == \"claude\" else None\n\n        with patch(\"shutil.which\", side_effect=_which):\n            result = discover_clis()\n        assert result == {\"claude\": \"/usr/bin/claude\"}\n\n    def test_finds_none(self):\n        with patch(\"shutil.which\", return_value=None):\n            result = discover_clis()\n        assert result == {}\n\n    def test_finds_all(self):\n        with patch(\"shutil.which\", side_effect=lambda n: f\"/usr/bin/{n}\"):\n            result = discover_clis()\n        assert len(result) == 3\n        assert \"claude\" in result\n\n\n# --- Repo Discovery ---\n\n\nclass TestDiscoverRepos:\n    def test_finds_git_repos(self, tmp_path: Path):\n        docs = tmp_path / \"Documents\"\n        docs.mkdir()\n        repo = docs / \"my-proj\"\n        repo.mkdir()\n        (repo / \".git\").mkdir()\n\n        repos = discover_repos(home=tmp_path)\n        assert len(repos) == 1\n        assert repos[0][\"key\"] == \"my-proj\"\n\n    def test_skips_hidden_dirs(self, tmp_path: Path):\n        docs = tmp_path / \"Documents\"\n        docs.mkdir()\n        hidden = docs / \".secret\"\n        hidden.mkdir()\n        (hidden / \".git\").mkdir()\n\n        repos = discover_repos(home=tmp_path)\n        assert repos == []\n\n    def test_depth_limited(self, tmp_path: Path):\n        dev = tmp_path / \"dev\"\n        deep = dev / \"a\" / \"b\" / \"c\" / \"d\" / \"e\"\n        deep.mkdir(parents=True)\n        (deep / \".git\").mkdir()\n\n        repos = discover_repos(home=tmp_path)\n        assert repos == []\n\n    def test_max_30_repos(self, tmp_path: Path):\n        dev = tmp_path / \"dev\"\n        dev.mkdir()\n        for i in range(35):\n            r = dev / f\"repo-{i:02d}\"\n            r.mkdir()\n            (r / \".git\").mkdir()\n\n        repos = discover_repos(home=tmp_path)\n        assert len(repos) == 30\n\n    def test_no_scan_dirs(self, tmp_path: Path):\n        repos = discover_repos(home=tmp_path)\n        assert repos == []\n\n\n# --- Active Projects ---\n\n\nclass TestDiscoverActiveProjects:\n    def test_parses_history(self, tmp_path: Path):\n        lines = [\n            json.dumps({\"project\": \"/Users/me/proj-a\"}),\n            json.dumps({\"project\": \"/Users/me/proj-a\"}),\n            json.dumps({\"project\": \"/Users/me/proj-b\"}),\n        ]\n        (tmp_path / \"history.jsonl\").write_text(\n            \"\\n\".join(lines) + \"\\n\",\n        )\n\n        projects = discover_active_projects(tmp_path)\n        assert projects[0] == \"proj-a\"\n        assert \"proj-b\" in projects\n\n    def test_no_history_file(self, tmp_path: Path):\n        result = discover_active_projects(tmp_path)\n        assert result == []\n\n    def test_malformed_json(self, tmp_path: Path):\n        content = \"not-json\\n{\\\"project\\\":\\\"/p\\\"}\\n\"\n        (tmp_path / \"history.jsonl\").write_text(content)\n\n        projects = discover_active_projects(tmp_path)\n        assert projects == [\"p\"]\n\n\n# --- Env Tokens ---\n\n\nclass TestDiscoverEnvTokens:\n    def test_detects_tokens(self):\n        env = {\"GITHUB_TOKEN\": \"ghp_abc\"}\n        with patch.dict(\"os.environ\", env, clear=True):\n            result = discover_env_tokens()\n        assert result[\"GITHUB_TOKEN\"] is True\n        assert result[\"ANTHROPIC_API_KEY\"] is False\n\n    def test_no_env_tokens(self):\n        with patch.dict(\"os.environ\", {}, clear=True):\n            with patch(\"maggy.discovery.discover_git_token\", return_value=\"\"):\n                result = discover_env_tokens()\n        assert result[\"GITHUB_TOKEN\"] is False\n        assert result[\"ANTHROPIC_API_KEY\"] is False\n        assert result[\"ASANA_API_KEY\"] is False\n\n\n# --- URL Parsing ---\n\n\nclass TestParseOrgFromUrl:\n    def test_ssh_url(self):\n        url = \"git@github.com:acme/webapp.git\"\n        assert _parse_org_from_url(url) == \"acme\"\n\n    def test_https_url(self):\n        url = \"https://github.com/acme/webapp.git\"\n        assert _parse_org_from_url(url) == \"acme\"\n\n    def test_non_github(self):\n        url = \"https://gitlab.com/acme/webapp.git\"\n        assert _parse_org_from_url(url) == \"\"\n\n\n# --- Full Discovery ---\n\n\nclass TestFullDiscovery:\n    def test_returns_result(self, tmp_path: Path):\n        with patch(\"shutil.which\", return_value=None):\n            result = full_discovery(home=tmp_path)\n        assert isinstance(result, DiscoveryResult)\n        assert result.timestamp != \"\"\n\n    def test_populates_repos(self, tmp_path: Path):\n        dev = tmp_path / \"dev\"\n        dev.mkdir()\n        repo = dev / \"my-app\"\n        repo.mkdir()\n        (repo / \".git\").mkdir()\n\n        with patch(\"shutil.which\", return_value=None):\n            result = full_discovery(home=tmp_path)\n        assert len(result.repos) == 1\n        assert result.repos[0][\"key\"] == \"my-app\"\n"
  },
  {
    "path": "maggy/tests/test_dual_planner.py",
    "content": "\"\"\"Tests for DualPlanner orchestration.\"\"\"\n\nfrom __future__ import annotations\n\nfrom unittest.mock import AsyncMock, MagicMock\n\nimport pytest\n\nfrom maggy.adapters.pi import RunResult\nfrom maggy.services.planner import DualPlanner\n\n\ndef _result(output: str) -> RunResult:\n    return RunResult(model=\"test\", success=True, output=output)\n\n\n@pytest.mark.asyncio\nasync def test_plan_uses_claude_prompt() -> None:\n    pi = MagicMock()\n    pi.send_prompt = AsyncMock(return_value=_result(\"Primary plan\"))\n    planner = DualPlanner(pi)\n\n    plan = await planner.plan(\"Fix auth\", \"Add logout flow\", \"/tmp/work\")\n\n    assert plan == \"Primary plan\"\n    pi.send_prompt.assert_awaited_once()\n    args = pi.send_prompt.await_args.args\n    assert args[0] == \"claude\"\n    assert args[2] == \"/tmp/work\"\n    assert args[3] == 5\n    assert \"Fix auth\" in args[1]\n    assert \"Add logout flow\" in args[1]\n\n\n@pytest.mark.asyncio\nasync def test_counter_check_uses_codex_prompt() -> None:\n    pi = MagicMock()\n    pi.send_prompt = AsyncMock(return_value=_result(\"Looks good\"))\n    planner = DualPlanner(pi)\n\n    review = await planner.counter_check(\"1. Update auth\\n2. Add tests\", \"/tmp/work\")\n\n    assert review == \"Looks good\"\n    args = pi.send_prompt.await_args.args\n    assert args[0] == \"codex\"\n    assert args[2] == \"/tmp/work\"\n    assert args[3] == 5\n    assert \"1. Update auth\" in args[1]\n    assert \"Flag conflicts as 'CONFLICT:'\" in args[1]\n\n\n@pytest.mark.asyncio\nasync def test_dual_plan_collects_conflicts() -> None:\n    pi = MagicMock()\n    pi.send_prompt = AsyncMock(\n        side_effect=[\n            _result(\"1. Update auth\\n2. Add tests\"),\n            _result(\"CONFLICT: use middleware\\nkeep step 2\"),\n        ]\n    )\n    planner = DualPlanner(pi)\n\n    result = await planner.dual_plan(\"Fix auth\", \"Add logout flow\", \"/tmp/work\")\n\n    assert result.primary_plan.startswith(\"1. Update auth\")\n    assert result.counter_check.startswith(\"CONFLICT:\")\n    assert result.conflicts == [\"use middleware\"]\n"
  },
  {
    "path": "maggy/tests/test_engram.py",
    "content": "\"\"\"Tests for Engram — record, store, retrieval, diagnostics.\"\"\"\n\nfrom __future__ import annotations\n\nfrom pathlib import Path\n\nfrom maggy.engram.diagnostics import AmnesiaProfile, diagnose\nfrom maggy.engram.record import EngramRecord, Origin, Validity\nfrom maggy.engram.retrieval import EngramRetrieval\nfrom maggy.engram.store import EngramStore\n\n\nclass TestEngramRecord:\n    def test_defaults(self):\n        r = EngramRecord(\n            engram_id=\"e1\", namespace=\"proj-1\",\n            memory_type=\"fact\", content=\"Python 3.11\",\n        )\n        assert r.is_active\n        assert r.origin == Origin.EXPLICIT\n\n    def test_supersede(self):\n        r = EngramRecord(\n            engram_id=\"e1\", namespace=\"proj-1\",\n            memory_type=\"fact\", content=\"test\",\n        )\n        r.supersede()\n        assert not r.is_active\n        assert r.validity == Validity.SUPERSEDED\n\n\nclass TestEngramStore:\n    def test_write_and_get(self, tmp_path: Path):\n        store = EngramStore(tmp_path / \"engram.db\")\n        r = EngramRecord(\n            engram_id=\"e1\", namespace=\"proj-1\",\n            memory_type=\"fact\", content=\"Uses FastAPI\",\n        )\n        store.write(r)\n        result = store.get(\"e1\")\n        assert result is not None\n        assert result.content == \"Uses FastAPI\"\n\n    def test_get_missing(self, tmp_path: Path):\n        store = EngramStore(tmp_path / \"engram.db\")\n        assert store.get(\"nope\") is None\n\n    def test_query_by_namespace(self, tmp_path: Path):\n        store = EngramStore(tmp_path / \"engram.db\")\n        store.write(EngramRecord(\n            engram_id=\"e1\", namespace=\"proj-1\",\n            memory_type=\"fact\", content=\"A\",\n        ))\n        store.write(EngramRecord(\n            engram_id=\"e2\", namespace=\"proj-2\",\n            memory_type=\"fact\", content=\"B\",\n        ))\n        results = store.query(namespace=\"proj-1\")\n        assert len(results) == 1\n        assert results[0].namespace == \"proj-1\"\n\n    def test_query_by_type(self, tmp_path: Path):\n        store = EngramStore(tmp_path / \"engram.db\")\n        store.write(EngramRecord(\n            engram_id=\"e1\", namespace=\"p\",\n            memory_type=\"fact\", content=\"A\",\n        ))\n        store.write(EngramRecord(\n            engram_id=\"e2\", namespace=\"p\",\n            memory_type=\"decision\", content=\"B\",\n        ))\n        results = store.query(memory_type=\"decision\")\n        assert len(results) == 1\n\n    def test_count(self, tmp_path: Path):\n        store = EngramStore(tmp_path / \"engram.db\")\n        store.write(EngramRecord(\n            engram_id=\"e1\", namespace=\"p\",\n            memory_type=\"fact\", content=\"A\",\n        ))\n        assert store.count() == 1\n        assert store.count(namespace=\"p\") == 1\n        assert store.count(namespace=\"x\") == 0\n\n\nclass TestRetrieval:\n    def _seed(self, tmp_path: Path) -> EngramStore:\n        store = EngramStore(tmp_path / \"engram.db\")\n        store.write(EngramRecord(\n            engram_id=\"e1\", namespace=\"proj\",\n            memory_type=\"fact\", content=\"Uses FastAPI\",\n            tags=[\"backend\", \"python\"],\n        ))\n        store.write(EngramRecord(\n            engram_id=\"e2\", namespace=\"proj\",\n            memory_type=\"decision\", content=\"Chose SQLite\",\n            tags=[\"database\"],\n        ))\n        return store\n\n    def test_by_keyword(self, tmp_path: Path):\n        store = self._seed(tmp_path)\n        r = EngramRetrieval(store)\n        results = r.by_keyword(\"FastAPI\")\n        assert len(results) == 1\n\n    def test_by_tag(self, tmp_path: Path):\n        store = self._seed(tmp_path)\n        r = EngramRetrieval(store)\n        results = r.by_tag(\"backend\")\n        assert len(results) == 1\n\n    def test_by_type(self, tmp_path: Path):\n        store = self._seed(tmp_path)\n        r = EngramRetrieval(store)\n        results = r.by_type(\"decision\")\n        assert len(results) == 1\n\n    def test_recent(self, tmp_path: Path):\n        store = self._seed(tmp_path)\n        r = EngramRetrieval(store)\n        results = r.recent()\n        assert len(results) == 2\n\n\nclass TestDiagnostics:\n    def test_empty_store(self, tmp_path: Path):\n        store = EngramStore(tmp_path / \"engram.db\")\n        profile = diagnose(store)\n        assert profile.health_score == 0.0\n\n    def test_healthy_store(self, tmp_path: Path):\n        store = EngramStore(tmp_path / \"engram.db\")\n        for i, mt in enumerate(\n            [\"fact\", \"decision\", \"code_ref\", \"handoff\"]\n        ):\n            store.write(EngramRecord(\n                engram_id=f\"e{i}\", namespace=\"p\",\n                memory_type=mt, content=f\"content {i}\",\n            ))\n        profile = diagnose(store)\n        assert profile.total_memories == 4\n        assert profile.active_count == 4\n        assert profile.health_score > 0.8\n\n\nclass TestEngramSeed:\n    \"\"\"Seed engrams on first boot for non-zero health.\"\"\"\n\n    def test_seed_writes_all_types(self, tmp_path: Path):\n        from maggy.engram.seed import seed_if_empty\n        store = EngramStore(tmp_path / \"engram.db\")\n        seed_if_empty(store)\n        profile = diagnose(store)\n        assert profile.facts > 0\n        assert profile.decisions > 0\n        assert profile.code_refs > 0\n        assert profile.handoffs > 0\n\n    def test_seed_gives_healthy_score(self, tmp_path: Path):\n        from maggy.engram.seed import seed_if_empty\n        store = EngramStore(tmp_path / \"engram.db\")\n        seed_if_empty(store)\n        profile = diagnose(store)\n        assert profile.health_score >= 0.8\n\n    def test_seed_fills_missing_types(self, tmp_path: Path):\n        from maggy.engram.seed import seed_if_empty\n        store = EngramStore(tmp_path / \"engram.db\")\n        store.write(EngramRecord(\n            engram_id=\"existing\", namespace=\"p\",\n            memory_type=\"fact\", content=\"already here\",\n        ))\n        seed_if_empty(store)\n        profile = diagnose(store)\n        # Original fact kept, missing types seeded\n        assert profile.facts >= 1\n        assert profile.decisions > 0\n        assert profile.code_refs > 0\n        assert profile.handoffs > 0\n\n    def test_seed_skips_when_all_types_present(self, tmp_path: Path):\n        from maggy.engram.seed import seed_if_empty\n        store = EngramStore(tmp_path / \"engram.db\")\n        for i, mt in enumerate(\n            [\"fact\", \"decision\", \"code_ref\", \"handoff\"],\n        ):\n            store.write(EngramRecord(\n                engram_id=f\"e{i}\", namespace=\"p\",\n                memory_type=mt, content=f\"c{i}\",\n            ))\n        seed_if_empty(store)\n        assert store.count() == 4\n"
  },
  {
    "path": "maggy/tests/test_escalation.py",
    "content": "\"\"\"Tests for human escalation packets.\"\"\"\n\nfrom __future__ import annotations\n\nfrom maggy.escalation.protocol import Escalator\n\n\nclass TestEscalator:\n    def test_escalate_and_get(self, tmp_path):\n        escalator = Escalator(tmp_path / \"escalations.db\")\n        packet = escalator.escalate(\n            \"session-1\",\n            \"blocked on merge conflict\",\n            {\n                \"agent_state\": {\"task\": \"coordination\"},\n                \"suggested_actions\": [\"review lock owner\"],\n            },\n        )\n        loaded = escalator.get(packet.id)\n        assert loaded is not None\n        assert loaded.session_id == \"session-1\"\n        assert loaded.agent_state == {\"task\": \"coordination\"}\n        assert loaded.suggested_actions == [\"review lock owner\"]\n\n    def test_list_pending_returns_unresolved(self, tmp_path):\n        escalator = Escalator(tmp_path / \"escalations.db\")\n        first = escalator.escalate(\"session-1\", \"needs input\", {})\n        escalator.escalate(\"session-2\", \"waiting on human\", {})\n        escalator.resolve(first.id, \"continue with fallback\")\n        pending = escalator.list_pending()\n        assert [packet.session_id for packet in pending] == [\"session-2\"]\n\n    def test_resolve_marks_packet(self, tmp_path):\n        escalator = Escalator(tmp_path / \"escalations.db\")\n        packet = escalator.escalate(\"session-1\", \"needs approval\", {})\n        resolved = escalator.resolve(packet.id, \"approved\")\n        assert resolved.resolved is True\n        assert resolved.resolution == \"approved\"\n"
  },
  {
    "path": "maggy/tests/test_event_spine.py",
    "content": "\"\"\"Tests for Event Spine — header, typed events, emitter, store.\"\"\"\n\nfrom __future__ import annotations\n\nfrom pathlib import Path\n\nfrom maggy.event_spine.emitter import EventEmitter\nfrom maggy.event_spine.events import (\n    EVENT_TYPES,\n    ExecutionEvent,\n    IntentEvent,\n    MeshEvent,\n    OutcomeEvent,\n)\nfrom maggy.event_spine.header import EventHeader\nfrom maggy.event_spine.store import EventStore\n\n\nclass TestEventHeader:\n    def test_defaults(self):\n        h = EventHeader(event_type=\"intent\")\n        assert h.event_type == \"intent\"\n        assert h.event_id  # uuid generated\n        assert h.timestamp  # iso time generated\n        assert h.schema_version == 1\n        assert h.confidence == 1.0\n\n    def test_custom_fields(self):\n        h = EventHeader(\n            event_type=\"execution\",\n            task_id=\"t1\",\n            project_id=\"p1\",\n            agent_id=\"a1\",\n        )\n        assert h.task_id == \"t1\"\n        assert h.project_id == \"p1\"\n\n\nclass TestTypedEvents:\n    def test_all_eight_types(self):\n        assert len(EVENT_TYPES) == 8\n\n    def test_intent_event(self):\n        e = IntentEvent(\n            intent_text=\"Add login button\",\n            decomposed_steps=[\"create component\", \"add route\"],\n        )\n        assert e.header.event_type == \"intent\"\n        assert len(e.decomposed_steps) == 2\n\n    def test_execution_event(self):\n        e = ExecutionEvent(\n            tool_name=\"grep\",\n            duration_ms=150,\n            success=True,\n        )\n        assert e.header.event_type == \"execution\"\n        assert e.duration_ms == 150\n\n    def test_outcome_event(self):\n        e = OutcomeEvent(success=True, reward=0.9)\n        assert e.header.event_type == \"outcome\"\n        assert e.reward == 0.9\n\n\nclass TestEventStore:\n    def test_write_and_query(self, tmp_path: Path):\n        store = EventStore(tmp_path / \"events.db\")\n        h = EventHeader(event_type=\"intent\", task_id=\"t1\")\n        store.write(h, {\"header\": {\"event_type\": \"intent\"}, \"text\": \"hi\"})\n        results = store.query(task_id=\"t1\")\n        assert len(results) == 1\n\n    def test_query_by_type(self, tmp_path: Path):\n        store = EventStore(tmp_path / \"events.db\")\n        h1 = EventHeader(event_type=\"intent\", task_id=\"t1\")\n        h2 = EventHeader(event_type=\"execution\", task_id=\"t1\")\n        store.write(h1, {\"type\": \"intent\"})\n        store.write(h2, {\"type\": \"execution\"})\n        results = store.query(event_type=\"intent\")\n        assert len(results) == 1\n\n    def test_count(self, tmp_path: Path):\n        store = EventStore(tmp_path / \"events.db\")\n        for i in range(5):\n            h = EventHeader(\n                event_type=\"execution\", task_id=f\"t{i}\",\n            )\n            store.write(h, {\"i\": i})\n        assert store.count(event_type=\"execution\") == 5\n        assert store.count(event_type=\"intent\") == 0\n\n    def test_limit(self, tmp_path: Path):\n        store = EventStore(tmp_path / \"events.db\")\n        for i in range(10):\n            h = EventHeader(event_type=\"intent\", task_id=\"t1\")\n            store.write(h, {\"i\": i})\n        results = store.query(task_id=\"t1\", limit=3)\n        assert len(results) == 3\n\n\nclass TestEventEmitter:\n    def test_emit_returns_id(self, tmp_path: Path):\n        store = EventStore(tmp_path / \"events.db\")\n        emitter = EventEmitter(store)\n        event = IntentEvent(intent_text=\"test\")\n        eid = emitter.emit(event)\n        assert eid == event.header.event_id\n\n    def test_emit_invalid_raises(self, tmp_path: Path):\n        store = EventStore(tmp_path / \"events.db\")\n        emitter = EventEmitter(store)\n        import pytest\n        with pytest.raises(ValueError):\n            emitter.emit({\"not\": \"an event\"})\n\n    def test_trace(self, tmp_path: Path):\n        store = EventStore(tmp_path / \"events.db\")\n        emitter = EventEmitter(store)\n        e1 = IntentEvent(intent_text=\"step 1\")\n        e1.header.task_id = \"task-abc\"\n        e2 = ExecutionEvent(tool_name=\"grep\")\n        e2.header.task_id = \"task-abc\"\n        emitter.emit(e1)\n        emitter.emit(e2)\n        trace = emitter.trace(\"task-abc\")\n        assert len(trace) == 2\n\n    def test_count(self, tmp_path: Path):\n        store = EventStore(tmp_path / \"events.db\")\n        emitter = EventEmitter(store)\n        for _ in range(3):\n            emitter.emit(IntentEvent(intent_text=\"x\"))\n        assert emitter.count(event_type=\"intent\") == 3\n\n    def test_query_by_project(self, tmp_path: Path):\n        store = EventStore(tmp_path / \"events.db\")\n        emitter = EventEmitter(store)\n        e = IntentEvent(intent_text=\"x\")\n        e.header.project_id = \"proj-1\"\n        emitter.emit(e)\n        results = emitter.query(project_id=\"proj-1\")\n        assert len(results) == 1\n"
  },
  {
    "path": "maggy/tests/test_executor_routing.py",
    "content": "\"\"\"Tests for executor model routing and spend recording.\"\"\"\n\nfrom __future__ import annotations\n\nfrom unittest.mock import AsyncMock\n\nimport pytest\n\nfrom maggy.adapters.pi import RunResult\nfrom maggy.providers.base import Task\nfrom maggy.services import executor_helpers\nfrom maggy.services import output_reviewer as reviewer_mod\nfrom maggy.services.executor import ExecutorService\nfrom maggy.services.executor_types import SessionCtx\n\n\ndef _session() -> dict[str, str]:\n    return {\n        \"id\": \"session-1\",\n        \"task_id\": \"task-1\",\n        \"task_title\": \"Test task\",\n        \"mode\": \"plan\",\n        \"working_dir\": \".\",\n        \"status\": \"running\",\n        \"started_at\": \"\",\n        \"output\": \"\",\n    }\n\n\ndef _task(blast_score: int, task_type: str) -> Task:\n    return Task(\n        id=\"task-1\",\n        title=\"Route this task\",\n        description=\"Use task metadata for routing.\",\n        raw={\n            \"blast_score\": blast_score,\n            \"task_type\": task_type,\n            \"security_sensitive\": task_type == \"security\",\n        },\n    )\n\n\ndef _ctx(session: dict, task: Task, wd: str) -> SessionCtx:\n    return SessionCtx(session=session, task=task, wd=wd)\n\n\ndef _patch_executor(executor, monkeypatch):\n    \"\"\"Wire fake send_prompt and context builder.\"\"\"\n\n    async def fake_context(cfg, task):\n        return \"\"\n\n    async def fake_send(\n        model_name: str, prompt: str, working_dir: str,\n        max_turns: int = 20, timeout: int = 600,\n    ) -> RunResult:\n        return RunResult(\n            model=model_name, success=True, output=\"ok\",\n        )\n\n    monkeypatch.setattr(\n        executor_helpers, \"build_icpg_context\", fake_context,\n    )\n    monkeypatch.setattr(executor._pi, \"send_prompt\", fake_send)\n\n\n@pytest.mark.asyncio\nasync def test_plan_mode_routes_high_blast_to_claude(\n    mock_cfg, tmp_path, monkeypatch,\n):\n    provider = AsyncMock()\n    executor = ExecutorService(mock_cfg, provider)\n    session = _session()\n    executor._sessions[\"session-1\"] = session\n    models: list[str] = []\n\n    async def fake_context(cfg, task):\n        return \"\"\n\n    async def tracking_send(\n        model_name: str, prompt: str, working_dir: str,\n        max_turns: int = 20, timeout: int = 600,\n    ) -> RunResult:\n        models.append(model_name)\n        return RunResult(model=model_name, success=True, output=\"ok\")\n\n    monkeypatch.setattr(executor_helpers, \"build_icpg_context\", fake_context)\n    monkeypatch.setattr(executor._pi, \"send_prompt\", tracking_send)\n    task = _task(9, \"general\")\n    ctx = _ctx(session, task, str(tmp_path))\n    await executor._run(ctx, \"plan\")\n    # Blast 9 general → codex (cost_rank=3, covers 4-10)\n    assert models[0] == \"codex\"\n\n\n@pytest.mark.asyncio\nasync def test_plan_records_spend(mock_cfg, tmp_path, monkeypatch):\n    provider = AsyncMock()\n    executor = ExecutorService(mock_cfg, provider)\n    session = _session()\n    executor._sessions[\"session-1\"] = session\n\n    async def fake_context(cfg, task):\n        return \"\"\n\n    async def fake_send(\n        model_name: str, prompt: str, working_dir: str,\n        max_turns: int = 20, timeout: int = 600,\n    ) -> RunResult:\n        return RunResult(\n            model=model_name, success=True,\n            output=\"plan\", cost_usd=1.25,\n        )\n\n    monkeypatch.setattr(executor_helpers, \"build_icpg_context\", fake_context)\n    monkeypatch.setattr(executor._pi, \"send_prompt\", fake_send)\n    task = _task(3, \"security\")\n    ctx = _ctx(session, task, str(tmp_path))\n    await executor._run(ctx, \"plan\")\n    assert executor._budget.today_spend(\"anthropic\") == pytest.approx(1.25)\n\n\n@pytest.mark.asyncio\nasync def test_tdd_high_blast_calls_dual_planner(\n    mock_cfg, tmp_path, monkeypatch,\n):\n    provider = AsyncMock()\n    executor = ExecutorService(mock_cfg, provider)\n    session = _session()\n    executor._sessions[\"session-1\"] = session\n    _patch_executor(executor, monkeypatch)\n    planner_called = []\n\n    async def track_dual(ctx):\n        planner_called.append(True)\n\n    monkeypatch.setattr(executor, \"_dual_plan\", track_dual)\n    task = _task(9, \"feature\")\n    ctx = _ctx(session, task, str(tmp_path))\n    await executor._run(ctx, \"tdd\")\n    assert planner_called\n\n\n@pytest.mark.asyncio\nasync def test_locks_released_after_run(\n    mock_cfg, tmp_path, monkeypatch,\n):\n    provider = AsyncMock()\n    executor = ExecutorService(mock_cfg, provider)\n    session = _session()\n    executor._sessions[\"session-1\"] = session\n    _patch_executor(executor, monkeypatch)\n    wd = str(tmp_path)\n    executor._locks.acquire(wd, \"session-1\")\n    task = _task(3, \"docs\")\n    ctx = _ctx(session, task, wd)\n    await executor._run(ctx, \"plan\")\n    assert executor._locks.acquire(wd, \"other-agent\")\n\n\n@pytest.mark.asyncio\nasync def test_fatigue_tracked(mock_cfg, tmp_path, monkeypatch):\n    provider = AsyncMock()\n    executor = ExecutorService(mock_cfg, provider)\n    session = _session()\n    executor._sessions[\"session-1\"] = session\n    _patch_executor(executor, monkeypatch)\n    task = _task(3, \"docs\")\n    ctx = _ctx(session, task, str(tmp_path))\n    await executor._run(ctx, \"plan\")\n    assert executor._fatigue.dimensions[\"context_load\"] > 0\n\n\n@pytest.mark.asyncio\nasync def test_conventions_in_prompts(\n    mock_cfg, tmp_path, monkeypatch,\n):\n    provider = AsyncMock()\n    executor = ExecutorService(mock_cfg, provider)\n    session = _session()\n    executor._sessions[\"session-1\"] = session\n    prompts: list[str] = []\n\n    async def fake_context(cfg, task):\n        return \"\"\n\n    async def fake_send(\n        model_name: str, prompt: str, working_dir: str,\n        max_turns: int = 20, timeout: int = 600,\n    ) -> RunResult:\n        prompts.append(prompt)\n        return RunResult(model=model_name, success=True, output=\"ok\")\n\n    monkeypatch.setattr(executor_helpers, \"build_icpg_context\", fake_context)\n    monkeypatch.setattr(executor._pi, \"send_prompt\", fake_send)\n    task = _task(5, \"feature\")\n    ctx = _ctx(session, task, str(tmp_path))\n    await executor._run(ctx, \"plan\")\n    assert prompts\n    assert \"Team Conventions\" in prompts[0]\n    assert \"minimum wowable product\" in prompts[0]\n\n\n@pytest.mark.asyncio\nasync def test_tdd_calls_reviewer(mock_cfg, tmp_path, monkeypatch):\n    provider = AsyncMock()\n    executor = ExecutorService(mock_cfg, provider)\n    session = _session()\n    executor._sessions[\"session-1\"] = session\n    _patch_executor(executor, monkeypatch)\n    reviews: list[str] = []\n\n    async def fake_review(pi, label, output, wd):\n        reviews.append(label)\n        from maggy.services.output_reviewer import ReviewResult\n        return ReviewResult(score=4, reason=\"ok\")\n\n    monkeypatch.setattr(reviewer_mod, \"review_output\", fake_review)\n    task = _task(3, \"feature\")\n    ctx = _ctx(session, task, str(tmp_path))\n    await executor._run(ctx, \"tdd\")\n    assert \"ANALYZE\" in reviews\n    assert \"WRITE TESTS\" in reviews\n\n\n@pytest.mark.asyncio\nasync def test_review_retry_on_low_score(\n    mock_cfg, tmp_path, monkeypatch,\n):\n    provider = AsyncMock()\n    executor = ExecutorService(mock_cfg, provider)\n    session = _session()\n    executor._sessions[\"session-1\"] = session\n    _patch_executor(executor, monkeypatch)\n    call_count = [0]\n\n    async def fake_review(pi, label, output, wd):\n        call_count[0] += 1\n        from maggy.services.output_reviewer import ReviewResult\n        if call_count[0] == 1:\n            return ReviewResult(score=2, reason=\"poor\")\n        return ReviewResult(score=4, reason=\"ok\")\n\n    monkeypatch.setattr(reviewer_mod, \"review_output\", fake_review)\n    task = _task(3, \"feature\")\n    ctx = _ctx(session, task, str(tmp_path))\n    await executor._run(ctx, \"tdd\")\n    assert call_count[0] >= 2\n    assert \"RETRY\" in session[\"output\"]\n\n\n@pytest.mark.asyncio\nasync def test_status_callback_fires(\n    mock_cfg, tmp_path, monkeypatch,\n):\n    \"\"\"Status callback receives running/done events.\"\"\"\n    provider = AsyncMock()\n    statuses: list[dict] = []\n    executor = ExecutorService(\n        mock_cfg, provider, status_cb=statuses.append,\n    )\n    session = _session()\n    executor._sessions[\"session-1\"] = session\n    _patch_executor(executor, monkeypatch)\n    task = _task(3, \"docs\")\n    ctx = _ctx(session, task, str(tmp_path))\n    await executor._run(ctx, \"plan\")\n    assert any(s[\"status\"] == \"running\" for s in statuses)\n    assert any(s[\"status\"] == \"done\" for s in statuses)\n\n\n@pytest.mark.asyncio\nasync def test_status_shows_model_name(\n    mock_cfg, tmp_path, monkeypatch,\n):\n    \"\"\"Status events include the routed model name.\"\"\"\n    provider = AsyncMock()\n    statuses: list[dict] = []\n    executor = ExecutorService(\n        mock_cfg, provider, status_cb=statuses.append,\n    )\n    session = _session()\n    executor._sessions[\"session-1\"] = session\n    _patch_executor(executor, monkeypatch)\n    task = _task(9, \"general\")\n    ctx = _ctx(session, task, str(tmp_path))\n    await executor._run(ctx, \"plan\")\n    agents = {s.get(\"agent\") for s in statuses}\n    assert \"codex\" in agents\n"
  },
  {
    "path": "maggy/tests/test_fatigue.py",
    "content": "\"\"\"Tests for fatigue tracking — profiles and model comparison.\"\"\"\n\nfrom __future__ import annotations\n\nfrom maggy.fatigue import (\n    FatigueProfile,\n    MODEL_CONTEXT_WINDOWS,\n    compare_fatigue,\n    create_profile,\n)\n\n\nclass TestFatigueProfile:\n    def test_zero_usage_no_fatigue(self):\n        p = FatigueProfile(model=\"claude\", context_window=200_000)\n        assert p.fatigue_score == 0.0\n        assert p.raw_utilization == 0.0\n\n    def test_full_context_high_fatigue(self):\n        p = FatigueProfile(\n            model=\"claude\", context_window=200_000,\n            tokens_used=200_000, turns=50,\n        )\n        assert p.fatigue_score == 1.0\n\n    def test_half_context_moderate_fatigue(self):\n        p = FatigueProfile(\n            model=\"gpt\", context_window=128_000,\n            tokens_used=64_000, turns=10,\n        )\n        score = p.fatigue_score\n        assert 0.3 < score < 0.6\n\n    def test_zero_context_window_safe(self):\n        p = FatigueProfile(model=\"x\", context_window=0)\n        assert p.raw_utilization == 0.0\n\n\nclass TestShouldCheckpoint:\n    def test_below_threshold(self):\n        p = FatigueProfile(\n            model=\"claude\", context_window=200_000,\n            tokens_used=50_000,\n        )\n        assert not p.should_checkpoint()\n\n    def test_above_threshold(self):\n        p = FatigueProfile(\n            model=\"claude\", context_window=200_000,\n            tokens_used=180_000, turns=40,\n        )\n        assert p.should_checkpoint()\n\n    def test_custom_threshold(self):\n        p = FatigueProfile(\n            model=\"claude\", context_window=200_000,\n            tokens_used=100_000,\n        )\n        assert p.should_checkpoint(threshold=0.3)\n\n\nclass TestCreateProfile:\n    def test_known_model(self):\n        p = create_profile(\"claude\")\n        assert p.context_window == 200_000\n\n    def test_unknown_model_defaults(self):\n        p = create_profile(\"unknown-model\")\n        assert p.context_window == 128_000\n\n\nclass TestCompareFatigue:\n    def test_sorted_by_fatigue(self):\n        p1 = FatigueProfile(\n            model=\"claude\", context_window=200_000,\n            tokens_used=180_000, turns=40,\n        )\n        p2 = FatigueProfile(\n            model=\"gpt\", context_window=128_000,\n            tokens_used=10_000, turns=2,\n        )\n        result = compare_fatigue([p1, p2])\n        assert result[0][\"model\"] == \"claude\"\n        assert result[0][\"fatigue\"] > result[1][\"fatigue\"]\n"
  },
  {
    "path": "maggy/tests/test_forge.py",
    "content": "\"\"\"Tests for MCP Forge connector, registry, and gap detection.\"\"\"\n\nfrom __future__ import annotations\n\nfrom pathlib import Path\n\nfrom maggy.forge.connector import ForgeConnector\nfrom maggy.forge.detector import GapDetector, TRIGGER_THRESHOLD\nfrom maggy.forge.registry import ForgeRegistry, ToolInfo\n\n\nclass TestForgeRegistry:\n    def test_empty_without_forge(self):\n        reg = ForgeRegistry(forge_path=None)\n        assert reg.count == 0\n\n    def test_loads_from_forge_path(self):\n        forge = Path.home() / \"Documents\" / \"protaige\" / \"mcp-forge\"\n        if not forge.exists():\n            return  # skip if forge not available\n        reg = ForgeRegistry(forge_path=forge)\n        assert reg.count > 0\n\n    def test_search(self):\n        forge = Path.home() / \"Documents\" / \"protaige\" / \"mcp-forge\"\n        if not forge.exists():\n            return\n        reg = ForgeRegistry(forge_path=forge)\n        results = reg.search(\"stripe\")\n        assert any(t.slug == \"stripe\" for t in results)\n\n    def test_get_missing(self):\n        reg = ForgeRegistry(forge_path=None)\n        assert reg.get(\"nonexistent\") is None\n\n    def test_set_enabled(self):\n        reg = ForgeRegistry(forge_path=None)\n        reg._tools[\"test\"] = ToolInfo(slug=\"test\")\n        assert reg.set_enabled(\"test\", False)\n        assert not reg._tools[\"test\"].enabled\n        assert not reg.set_enabled(\"nope\", False)\n\n\nclass TestGapDetector:\n    def test_first_record_no_trigger(self):\n        det = GapDetector()\n        assert not det.record_gap(\"email sending\")\n\n    def test_trigger_at_threshold(self):\n        det = GapDetector(threshold=3)\n        det.record_gap(\"email sending\")\n        det.record_gap(\"email sending\")\n        assert det.record_gap(\"email sending\")\n\n    def test_no_double_trigger(self):\n        det = GapDetector(threshold=2)\n        det.record_gap(\"x\")\n        det.record_gap(\"x\")  # triggers\n        assert not det.record_gap(\"x\")  # no re-trigger\n\n    def test_list_gaps(self):\n        det = GapDetector()\n        det.record_gap(\"email\")\n        det.record_gap(\"email\")\n        det.record_gap(\"sms\")\n        gaps = det.list_gaps()\n        assert len(gaps) == 2\n        assert gaps[0].capability == \"email\"\n        assert gaps[0].occurrences == 2\n\n    def test_reset(self):\n        det = GapDetector()\n        det.record_gap(\"x\")\n        det.record_gap(\"x\")\n        det.reset(\"x\")\n        gaps = det.list_gaps()\n        assert len(gaps) == 0\n\n\nclass TestForgeConnector:\n    def test_status(self):\n        conn = ForgeConnector(forge_path=Path(\"/nonexistent\"))\n        s = conn.status()\n        assert not s.available\n        assert s.registry_count == 0\n\n    def test_report_gap(self):\n        conn = ForgeConnector(forge_path=Path(\"/nonexistent\"))\n        r1 = conn.report_gap(\"payment processing\")\n        assert not r1[\"triggered\"]\n\n    def test_search_tools_empty(self):\n        conn = ForgeConnector(forge_path=Path(\"/nonexistent\"))\n        assert conn.search_tools(\"stripe\") == []\n\n    def test_with_real_forge(self):\n        forge = Path.home() / \"Documents\" / \"protaige\" / \"mcp-forge\"\n        if not forge.exists():\n            return\n        conn = ForgeConnector(forge_path=forge)\n        assert conn.available\n        assert conn.status().registry_count > 0\n        results = conn.search_tools(\"github\")\n        assert len(results) > 0\n"
  },
  {
    "path": "maggy/tests/test_heartbeat.py",
    "content": "\"\"\"Tests for heartbeat scheduler.\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nfrom unittest.mock import AsyncMock\n\nimport pytest\n\nfrom maggy.heartbeat.scheduler import HeartbeatScheduler, Job\n\n\n# ── Job dataclass ────────────────────────────────────────────────────────\n\n\nclass TestJob:\n    def test_defaults(self):\n        fn = AsyncMock()\n        job = Job(name=\"test\", fn=fn, interval_seconds=60)\n        assert job.name == \"test\"\n        assert job.interval_seconds == 60\n        assert job.run_count == 0\n        assert job.last_run == \"\"\n        assert job.last_error == \"\"\n        assert job.enabled is True\n\n    def test_is_due_no_last_run(self):\n        fn = AsyncMock()\n        job = Job(name=\"test\", fn=fn, interval_seconds=60)\n        assert job.is_due() is True\n\n    def test_is_due_after_interval(self):\n        from datetime import datetime, timezone, timedelta\n        fn = AsyncMock()\n        job = Job(name=\"test\", fn=fn, interval_seconds=60)\n        past = datetime.now(timezone.utc) - timedelta(seconds=120)\n        job.last_run = past.isoformat()\n        assert job.is_due() is True\n\n    def test_not_due_before_interval(self):\n        from datetime import datetime, timezone\n        fn = AsyncMock()\n        job = Job(name=\"test\", fn=fn, interval_seconds=3600)\n        job.last_run = datetime.now(timezone.utc).isoformat()\n        assert job.is_due() is False\n\n\n# ── Scheduler ────────────────────────────────────────────────────────────\n\n\nclass TestSchedulerRegister:\n    def test_register_job(self):\n        sched = HeartbeatScheduler()\n        fn = AsyncMock()\n        sched.register(\"refresh\", fn, 1800)\n        assert \"refresh\" in sched._jobs\n\n    def test_register_duplicate_raises(self):\n        sched = HeartbeatScheduler()\n        fn = AsyncMock()\n        sched.register(\"dupe\", fn, 60)\n        with pytest.raises(ValueError, match=\"already registered\"):\n            sched.register(\"dupe\", fn, 60)\n\n    def test_status_returns_list(self):\n        sched = HeartbeatScheduler()\n        fn = AsyncMock()\n        sched.register(\"a\", fn, 60)\n        sched.register(\"b\", fn, 120)\n        result = sched.status()\n        assert len(result) == 2\n        names = {r[\"name\"] for r in result}\n        assert names == {\"a\", \"b\"}\n\n\nclass TestSchedulerTick:\n    @pytest.mark.asyncio\n    async def test_tick_runs_due_jobs(self):\n        sched = HeartbeatScheduler()\n        fn = AsyncMock()\n        sched.register(\"job1\", fn, 0)\n        await sched.tick()\n        fn.assert_awaited_once()\n\n    @pytest.mark.asyncio\n    async def test_tick_skips_disabled(self):\n        sched = HeartbeatScheduler()\n        fn = AsyncMock()\n        sched.register(\"disabled\", fn, 0)\n        sched._jobs[\"disabled\"].enabled = False\n        await sched.tick()\n        fn.assert_not_awaited()\n\n    @pytest.mark.asyncio\n    async def test_tick_records_error(self):\n        sched = HeartbeatScheduler()\n        fn = AsyncMock(side_effect=RuntimeError(\"boom\"))\n        sched.register(\"fail\", fn, 0)\n        await sched.tick()\n        assert \"boom\" in sched._jobs[\"fail\"].last_error\n\n    @pytest.mark.asyncio\n    async def test_tick_increments_count(self):\n        sched = HeartbeatScheduler()\n        fn = AsyncMock()\n        sched.register(\"counter\", fn, 0)\n        await sched.tick()\n        await sched.tick()\n        assert sched._jobs[\"counter\"].run_count == 2\n\n\nclass TestSchedulerTrigger:\n    @pytest.mark.asyncio\n    async def test_trigger_runs_job(self):\n        sched = HeartbeatScheduler()\n        fn = AsyncMock(return_value=None)\n        sched.register(\"manual\", fn, 9999)\n        result = await sched.trigger(\"manual\")\n        fn.assert_awaited_once()\n        assert result[\"ok\"] is True\n\n    @pytest.mark.asyncio\n    async def test_trigger_unknown_raises(self):\n        sched = HeartbeatScheduler()\n        with pytest.raises(KeyError, match=\"nope\"):\n            await sched.trigger(\"nope\")\n\n\nclass TestSchedulerLifecycle:\n    @pytest.mark.asyncio\n    async def test_start_stop(self):\n        sched = HeartbeatScheduler()\n        fn = AsyncMock()\n        sched.register(\"tick_job\", fn, 0)\n        await sched.start()\n        assert sched._task is not None\n        await asyncio.sleep(0.05)\n        await sched.stop()\n        assert sched._task is None\n        assert fn.await_count >= 1\n\n\n# ── Jobs ─────────────────────────────────────────────────────────────────\n\n\nclass TestJobs:\n    @pytest.mark.asyncio\n    async def test_refresh_history_calls_analyze(self):\n        from types import SimpleNamespace\n        from unittest.mock import MagicMock\n        from maggy.heartbeat.jobs import refresh_history\n        history = MagicMock()\n        app = SimpleNamespace(state=SimpleNamespace(history=history))\n        await refresh_history(app)\n        history.analyze.assert_called_once()\n\n    @pytest.mark.asyncio\n    async def test_refresh_history_skips_none(self):\n        from types import SimpleNamespace\n        from maggy.heartbeat.jobs import refresh_history\n        app = SimpleNamespace(state=SimpleNamespace(history=None))\n        await refresh_history(app)  # no error\n\n    @pytest.mark.asyncio\n    async def test_self_improve_calls_analyze(self):\n        from types import SimpleNamespace\n        from unittest.mock import MagicMock\n        from maggy.heartbeat.jobs import self_improve\n        intro = MagicMock()\n        app = SimpleNamespace(state=SimpleNamespace(introspector=intro))\n        await self_improve(app)\n        intro.analyze.assert_called_once()\n\n    @pytest.mark.asyncio\n    async def test_self_improve_skips_none(self):\n        from types import SimpleNamespace\n        from maggy.heartbeat.jobs import self_improve\n        app = SimpleNamespace(state=SimpleNamespace(introspector=None))\n        await self_improve(app)  # no error\n"
  },
  {
    "path": "maggy/tests/test_history.py",
    "content": "\"\"\"Tests for history analyzer, store, and service.\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nfrom pathlib import Path\n\nimport pytest\n\nfrom maggy.history.models import (\n    HistoryReport,\n    ProviderUsage,\n    SessionEntry,\n    TimeDistribution,\n)\n\n\n# --- Test Data Fixtures ---\n\n\ndef _make_session(\n    sid: str = \"s1\",\n    provider: str = \"claude\",\n    project: str = \"myproj\",\n    prompts: int = 5,\n    tools: int = 3,\n    started: str = \"2024-01-15T10:00:00+00:00\",\n    ended: str = \"2024-01-15T10:30:00+00:00\",\n) -> SessionEntry:\n    return SessionEntry(\n        session_id=sid,\n        provider=provider,\n        project=project,\n        started_at=started,\n        ended_at=ended,\n        prompt_count=prompts,\n        tool_use_count=tools,\n        models_used=[\"claude-sonnet-4\"],\n        topics=[\"auth\", \"tests\"],\n        summary=\"fix auth bug\",\n    )\n\n\n@pytest.fixture\ndef sample_sessions() -> list[SessionEntry]:\n    return [\n        _make_session(\"s1\", \"claude\", \"proj-a\", 10, 5,\n                       \"2024-01-15T10:00:00+00:00\",\n                       \"2024-01-15T10:45:00+00:00\"),\n        _make_session(\"s2\", \"claude\", \"proj-a\", 8, 3,\n                       \"2024-01-15T14:00:00+00:00\",\n                       \"2024-01-15T14:20:00+00:00\"),\n        _make_session(\"s3\", \"codex\", \"proj-b\", 5, 2,\n                       \"2024-01-16T09:00:00+00:00\",\n                       \"2024-01-16T09:15:00+00:00\"),\n        _make_session(\"s4\", \"kimi\", \"proj-a\", 3, 1,\n                       \"2024-01-16T22:00:00+00:00\",\n                       \"2024-01-16T22:10:00+00:00\"),\n    ]\n\n\n# --- Analyzer Tests ---\n\n\nclass TestAnalyzer:\n    \"\"\"Tests for history/analyzer.py functions.\"\"\"\n\n    def test_build_report_empty(self):\n        from maggy.history.analyzer import build_report\n        report = build_report([])\n        assert report.total_sessions == 0\n        assert report.total_prompts == 0\n        assert report.providers == []\n\n    def test_build_report_with_data(self, sample_sessions):\n        from maggy.history.analyzer import build_report\n        report = build_report(sample_sessions)\n        assert report.total_sessions == 4\n        assert report.total_prompts == 26\n        assert len(report.providers) == 3\n\n    def test_aggregate_by_provider(self, sample_sessions):\n        from maggy.history.analyzer import aggregate_by_provider\n        usage = aggregate_by_provider(sample_sessions)\n        assert len(usage) == 3\n        claude = next(u for u in usage if u.provider == \"claude\")\n        assert claude.session_count == 2\n        assert claude.prompt_count == 18\n\n    def test_aggregate_by_project(self, sample_sessions):\n        from maggy.history.analyzer import aggregate_by_project\n        projects = aggregate_by_project(sample_sessions)\n        proj_a = next(p for p in projects if p.project == \"proj-a\")\n        assert proj_a.total_sessions == 3\n        assert \"claude\" in proj_a.providers_used\n\n    def test_compute_time_distribution(self, sample_sessions):\n        from maggy.history.analyzer import compute_time_distribution\n        dist = compute_time_distribution(sample_sessions)\n        assert isinstance(dist, TimeDistribution)\n        # s1 starts at hour 10, s4 at hour 22\n        assert 10 in dist.by_hour\n        assert 22 in dist.by_hour\n\n    def test_detect_patterns(self, sample_sessions):\n        from maggy.history.analyzer import detect_patterns\n        patterns = detect_patterns(sample_sessions)\n        assert isinstance(patterns, list)\n        assert len(patterns) > 0\n        # Should produce human-readable strings\n        assert all(isinstance(p, str) for p in patterns)\n\n    def test_extract_top_topics(self, sample_sessions):\n        from maggy.history.analyzer import extract_top_topics\n        topics = extract_top_topics(sample_sessions)\n        assert isinstance(topics, list)\n        assert \"auth\" in topics\n\n\n# --- Store Tests ---\n\n\nclass TestHistoryStore:\n    \"\"\"Tests for history/store.py.\"\"\"\n\n    def test_save_and_load_sessions(self, tmp_path: Path):\n        from maggy.history.store import HistoryStore\n        store = HistoryStore(tmp_path / \"history.db\")\n        sessions = [_make_session(\"s1\"), _make_session(\"s2\")]\n        store.save_sessions(sessions)\n        loaded = store.load_sessions()\n        assert len(loaded) == 2\n\n    def test_load_sessions_by_provider(self, tmp_path: Path):\n        from maggy.history.store import HistoryStore\n        store = HistoryStore(tmp_path / \"history.db\")\n        sessions = [\n            _make_session(\"s1\", \"claude\"),\n            _make_session(\"s2\", \"codex\"),\n        ]\n        store.save_sessions(sessions)\n        claude = store.load_sessions(provider=\"claude\")\n        assert len(claude) == 1\n        assert claude[0][\"provider\"] == \"claude\"\n\n    def test_save_and_load_report(self, tmp_path: Path):\n        from maggy.history.store import HistoryStore\n        store = HistoryStore(tmp_path / \"history.db\")\n        report = HistoryReport(\n            generated_at=\"2024-01-15T00:00:00Z\",\n            total_sessions=5,\n            total_prompts=50,\n            summary=\"test report\",\n        )\n        store.save_report(report)\n        loaded = store.load_latest_report()\n        assert loaded is not None\n        assert loaded[\"total_sessions\"] == 5\n\n    def test_load_report_empty(self, tmp_path: Path):\n        from maggy.history.store import HistoryStore\n        store = HistoryStore(tmp_path / \"history.db\")\n        assert store.load_latest_report() is None\n\n\n# --- Service Tests ---\n\n\nclass TestHistoryService:\n    \"\"\"Tests for history/service.py.\"\"\"\n\n    def _isolated_dirs(self, tmp_path: Path) -> dict:\n        \"\"\"Return CLI dirs that don't exist to isolate tests.\"\"\"\n        return {\n            \"claude\": tmp_path / \"no_claude\",\n            \"codex\": tmp_path / \"no_codex\",\n            \"kimi\": tmp_path / \"no_kimi\",\n        }\n\n    def test_analyze_no_parsers(self, tmp_path: Path):\n        from maggy.history.service import HistoryService\n        svc = HistoryService(\n            db_path=tmp_path / \"history.db\",\n            cli_dirs=self._isolated_dirs(tmp_path),\n        )\n        report = svc.analyze()\n        assert report.total_sessions == 0\n\n    def test_analyze_with_claude(self, tmp_path: Path):\n        from maggy.history.service import HistoryService\n        claude_dir = tmp_path / \".claude\"\n        claude_dir.mkdir()\n        lines = [\n            json.dumps({\"display\": \"fix\", \"project\": \"/p\", \"sessionId\": \"s1\", \"timestamp\": 1700000000000}),\n            json.dumps({\"display\": \"test\", \"project\": \"/p\", \"sessionId\": \"s1\", \"timestamp\": 1700000300000}),\n        ]\n        (claude_dir / \"history.jsonl\").write_text(\"\\n\".join(lines) + \"\\n\")\n        dirs = self._isolated_dirs(tmp_path)\n        dirs[\"claude\"] = claude_dir\n        svc = HistoryService(\n            db_path=tmp_path / \"history.db\",\n            cli_dirs=dirs,\n        )\n        report = svc.analyze()\n        assert report.total_sessions == 1\n        assert report.total_prompts == 2\n\n    def test_get_report_cached(self, tmp_path: Path):\n        from maggy.history.service import HistoryService\n        claude_dir = tmp_path / \".claude\"\n        claude_dir.mkdir()\n        lines = [\n            json.dumps({\"display\": \"x\", \"project\": \"/p\", \"sessionId\": \"s1\", \"timestamp\": 1700000000000}),\n        ]\n        (claude_dir / \"history.jsonl\").write_text(\"\\n\".join(lines) + \"\\n\")\n        dirs = self._isolated_dirs(tmp_path)\n        dirs[\"claude\"] = claude_dir\n        svc = HistoryService(\n            db_path=tmp_path / \"history.db\",\n            cli_dirs=dirs,\n        )\n        svc.analyze()\n        cached = svc.get_report()\n        assert cached is not None\n        assert cached[\"total_sessions\"] == 1\n\n    def test_get_sessions(self, tmp_path: Path):\n        from maggy.history.service import HistoryService\n        claude_dir = tmp_path / \".claude\"\n        claude_dir.mkdir()\n        lines = [\n            json.dumps({\"display\": \"x\", \"project\": \"/p\", \"sessionId\": \"s1\", \"timestamp\": 1700000000000}),\n        ]\n        (claude_dir / \"history.jsonl\").write_text(\"\\n\".join(lines) + \"\\n\")\n        dirs = self._isolated_dirs(tmp_path)\n        dirs[\"claude\"] = claude_dir\n        svc = HistoryService(\n            db_path=tmp_path / \"history.db\",\n            cli_dirs=dirs,\n        )\n        svc.analyze()\n        sessions = svc.get_sessions()\n        assert len(sessions) == 1\n"
  },
  {
    "path": "maggy/tests/test_history_parsers.py",
    "content": "\"\"\"Tests for CLI history parsers — Claude, Codex, Kimi.\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nfrom pathlib import Path\n\nimport pytest\n\nfrom maggy.history.parsers.claude import ClaudeHistoryParser\nfrom maggy.history.parsers.codex import CodexHistoryParser\nfrom maggy.history.parsers.kimi import KimiHistoryParser\n\n\n# --- Claude Parser ---\n\n\nclass TestClaudeParser:\n    \"\"\"Tests for ClaudeHistoryParser.\"\"\"\n\n    def test_not_available_missing_dir(self, tmp_path: Path):\n        p = ClaudeHistoryParser(tmp_path / \".claude\")\n        assert p.is_available() is False\n\n    def test_available_with_history(self, tmp_path: Path):\n        claude_dir = tmp_path / \".claude\"\n        claude_dir.mkdir()\n        (claude_dir / \"history.jsonl\").write_text(\"\")\n        p = ClaudeHistoryParser(claude_dir)\n        assert p.is_available() is True\n\n    def test_session_count_empty(self, tmp_path: Path):\n        claude_dir = tmp_path / \".claude\"\n        claude_dir.mkdir()\n        (claude_dir / \"history.jsonl\").write_text(\"\")\n        p = ClaudeHistoryParser(claude_dir)\n        assert p.session_count() == 0\n\n    def test_session_count(self, tmp_path: Path):\n        claude_dir = tmp_path / \".claude\"\n        claude_dir.mkdir()\n        lines = [\n            json.dumps({\"display\": \"fix bug\", \"project\": \"/p\", \"sessionId\": \"s1\", \"timestamp\": 1700000000000}),\n            json.dumps({\"display\": \"add test\", \"project\": \"/p\", \"sessionId\": \"s1\", \"timestamp\": 1700000100000}),\n            json.dumps({\"display\": \"deploy\", \"project\": \"/q\", \"sessionId\": \"s2\", \"timestamp\": 1700001000000}),\n        ]\n        (claude_dir / \"history.jsonl\").write_text(\"\\n\".join(lines) + \"\\n\")\n        p = ClaudeHistoryParser(claude_dir)\n        assert p.session_count() == 2\n\n    def test_parse_sessions(self, tmp_path: Path):\n        claude_dir = tmp_path / \".claude\"\n        claude_dir.mkdir()\n        lines = [\n            json.dumps({\"display\": \"fix auth\", \"project\": \"/Users/test/proj\", \"sessionId\": \"s1\", \"timestamp\": 1700000000000}),\n            json.dumps({\"display\": \"add tests\", \"project\": \"/Users/test/proj\", \"sessionId\": \"s1\", \"timestamp\": 1700000300000}),\n            json.dumps({\"display\": \"deploy app\", \"project\": \"/Users/test/other\", \"sessionId\": \"s2\", \"timestamp\": 1700001000000}),\n        ]\n        (claude_dir / \"history.jsonl\").write_text(\"\\n\".join(lines) + \"\\n\")\n        p = ClaudeHistoryParser(claude_dir)\n        sessions = p.parse_sessions(limit=10)\n        assert len(sessions) == 2\n        s1 = next(s for s in sessions if s.session_id == \"s1\")\n        assert s1.provider == \"claude\"\n        assert s1.prompt_count == 2\n        assert s1.summary == \"fix auth\"\n        assert \"proj\" in s1.project\n\n    def test_parse_empty_history(self, tmp_path: Path):\n        claude_dir = tmp_path / \".claude\"\n        claude_dir.mkdir()\n        (claude_dir / \"history.jsonl\").write_text(\"\")\n        p = ClaudeHistoryParser(claude_dir)\n        assert p.parse_sessions() == []\n\n    def test_parse_with_transcript(self, tmp_path: Path):\n        claude_dir = tmp_path / \".claude\"\n        claude_dir.mkdir()\n        lines = [\n            json.dumps({\"display\": \"task1\", \"project\": \"/Users/test/proj\", \"sessionId\": \"s1\", \"timestamp\": 1700000000000}),\n        ]\n        (claude_dir / \"history.jsonl\").write_text(\"\\n\".join(lines) + \"\\n\")\n        # Create transcript directory\n        proj_dir = claude_dir / \"projects\" / \"-Users-test-proj\"\n        proj_dir.mkdir(parents=True)\n        transcript = [\n            json.dumps({\"type\": \"user\", \"message\": {\"role\": \"user\", \"content\": \"fix the bug\"}, \"sessionId\": \"s1\", \"timestamp\": 1700000000000, \"gitBranch\": \"feat/auth\"}),\n            json.dumps({\"type\": \"assistant\", \"message\": {\"role\": \"assistant\", \"content\": [{\"type\": \"text\", \"text\": \"ok\"}, {\"type\": \"tool_use\", \"name\": \"read\"}]}, \"model\": \"claude-sonnet-4\", \"timestamp\": 1700000010000}),\n        ]\n        (proj_dir / \"s1.jsonl\").write_text(\"\\n\".join(transcript) + \"\\n\")\n        p = ClaudeHistoryParser(claude_dir)\n        sessions = p.parse_sessions()\n        assert len(sessions) == 1\n        s = sessions[0]\n        assert s.tool_use_count >= 1\n        assert \"claude-sonnet-4\" in s.models_used\n        assert s.git_branch == \"feat/auth\"\n\n\n# --- Codex Parser ---\n\n\nclass TestCodexParser:\n    \"\"\"Tests for CodexHistoryParser.\"\"\"\n\n    def test_not_available_missing_dir(self, tmp_path: Path):\n        p = CodexHistoryParser(tmp_path / \".codex\")\n        assert p.is_available() is False\n\n    def test_available_with_index(self, tmp_path: Path):\n        codex_dir = tmp_path / \".codex\"\n        codex_dir.mkdir()\n        (codex_dir / \"session_index.jsonl\").write_text(\"\")\n        p = CodexHistoryParser(codex_dir)\n        assert p.is_available() is True\n\n    def test_session_count(self, tmp_path: Path):\n        codex_dir = tmp_path / \".codex\"\n        codex_dir.mkdir()\n        lines = [\n            json.dumps({\"id\": \"s1\", \"thread_name\": \"fix bug\", \"updated_at\": \"2024-01-01T00:00:00Z\"}),\n            json.dumps({\"id\": \"s2\", \"thread_name\": \"add feature\", \"updated_at\": \"2024-01-02T00:00:00Z\"}),\n        ]\n        (codex_dir / \"session_index.jsonl\").write_text(\"\\n\".join(lines) + \"\\n\")\n        p = CodexHistoryParser(codex_dir)\n        assert p.session_count() == 2\n\n    def test_parse_sessions(self, tmp_path: Path):\n        codex_dir = tmp_path / \".codex\"\n        codex_dir.mkdir()\n        index_lines = [\n            json.dumps({\"id\": \"s1\", \"thread_name\": \"fix auth bug\", \"updated_at\": \"2024-01-01T10:00:00Z\"}),\n        ]\n        (codex_dir / \"session_index.jsonl\").write_text(\"\\n\".join(index_lines) + \"\\n\")\n        history_lines = [\n            json.dumps({\"session_id\": \"s1\", \"ts\": 1704100000, \"text\": \"fix the auth bug\"}),\n            json.dumps({\"session_id\": \"s1\", \"ts\": 1704100300, \"text\": \"now add tests\"}),\n        ]\n        (codex_dir / \"history.jsonl\").write_text(\"\\n\".join(history_lines) + \"\\n\")\n        p = CodexHistoryParser(codex_dir)\n        sessions = p.parse_sessions()\n        assert len(sessions) == 1\n        s = sessions[0]\n        assert s.provider == \"codex\"\n        assert s.prompt_count == 2\n        assert s.summary == \"fix auth bug\"\n\n    def test_parse_empty(self, tmp_path: Path):\n        codex_dir = tmp_path / \".codex\"\n        codex_dir.mkdir()\n        (codex_dir / \"session_index.jsonl\").write_text(\"\")\n        (codex_dir / \"history.jsonl\").write_text(\"\")\n        p = CodexHistoryParser(codex_dir)\n        assert p.parse_sessions() == []\n\n\n# --- Kimi Parser ---\n\n\nclass TestKimiParser:\n    \"\"\"Tests for KimiHistoryParser.\"\"\"\n\n    def test_not_available_missing_dir(self, tmp_path: Path):\n        p = KimiHistoryParser(tmp_path / \".kimi\")\n        assert p.is_available() is False\n\n    def test_available_with_sessions(self, tmp_path: Path):\n        kimi_dir = tmp_path / \".kimi\"\n        (kimi_dir / \"sessions\").mkdir(parents=True)\n        p = KimiHistoryParser(kimi_dir)\n        assert p.is_available() is True\n\n    def test_session_count(self, tmp_path: Path):\n        kimi_dir = tmp_path / \".kimi\"\n        sess_dir = kimi_dir / \"sessions\" / \"abc\" / \"uuid1\"\n        sess_dir.mkdir(parents=True)\n        (sess_dir / \"context.jsonl\").write_text(\"\")\n        sess_dir2 = kimi_dir / \"sessions\" / \"abc\" / \"uuid2\"\n        sess_dir2.mkdir(parents=True)\n        (sess_dir2 / \"context.jsonl\").write_text(\"\")\n        p = KimiHistoryParser(kimi_dir)\n        assert p.session_count() == 2\n\n    def test_parse_sessions(self, tmp_path: Path):\n        kimi_dir = tmp_path / \".kimi\"\n        sess_dir = kimi_dir / \"sessions\" / \"abc\" / \"uuid1\"\n        sess_dir.mkdir(parents=True)\n        ctx_lines = [\n            json.dumps({\"role\": \"user\", \"content\": \"fix the deploy\"}),\n            json.dumps({\"role\": \"assistant\", \"content\": \"sure\"}),\n            json.dumps({\"role\": \"user\", \"content\": \"now test it\"}),\n        ]\n        (sess_dir / \"context.jsonl\").write_text(\"\\n\".join(ctx_lines) + \"\\n\")\n        wire_lines = [\n            json.dumps({\"timestamp\": 1700000000.0, \"message\": '{\"type\":\"TurnBegin\"}'}),\n            json.dumps({\"timestamp\": 1700000010.0, \"message\": '{\"type\":\"StepBegin\"}'}),\n            json.dumps({\"timestamp\": 1700000300.0, \"message\": '{\"type\":\"TurnBegin\"}'}),\n        ]\n        (sess_dir / \"wire.jsonl\").write_text(\"\\n\".join(wire_lines) + \"\\n\")\n        p = KimiHistoryParser(kimi_dir)\n        sessions = p.parse_sessions()\n        assert len(sessions) == 1\n        s = sessions[0]\n        assert s.provider == \"kimi\"\n        assert s.prompt_count == 2\n        assert s.tool_use_count >= 1\n        assert s.summary == \"fix the deploy\"\n\n    def test_parse_empty(self, tmp_path: Path):\n        kimi_dir = tmp_path / \".kimi\"\n        (kimi_dir / \"sessions\").mkdir(parents=True)\n        p = KimiHistoryParser(kimi_dir)\n        assert p.parse_sessions() == []\n\n    def test_parse_missing_wire(self, tmp_path: Path):\n        \"\"\"Graceful when wire.jsonl is missing.\"\"\"\n        kimi_dir = tmp_path / \".kimi\"\n        sess_dir = kimi_dir / \"sessions\" / \"abc\" / \"uuid1\"\n        sess_dir.mkdir(parents=True)\n        ctx_lines = [\n            json.dumps({\"role\": \"user\", \"content\": \"hello\"}),\n        ]\n        (sess_dir / \"context.jsonl\").write_text(\"\\n\".join(ctx_lines) + \"\\n\")\n        p = KimiHistoryParser(kimi_dir)\n        sessions = p.parse_sessions()\n        assert len(sessions) == 1\n        assert sessions[0].prompt_count == 1\n"
  },
  {
    "path": "maggy/tests/test_improve.py",
    "content": "\"\"\"Tests for self-improvement signals and analysis.\"\"\"\n\nfrom __future__ import annotations\n\nfrom types import SimpleNamespace\nfrom unittest.mock import MagicMock, patch\n\nimport pytest\n\nfrom maggy.improve.models import (\n    ImprovementReport,\n    Recommendation,\n    SignalBundle,\n)\n\n\n# ── Models ───────────────────────────────────────────────────────────────\n\n\nclass TestModels:\n    def test_recommendation_defaults(self):\n        rec = Recommendation(\n            category=\"routing\",\n            severity=\"info\",\n            message=\"test\",\n            suggestion=\"do something\",\n        )\n        assert rec.data == {}\n\n    def test_signal_bundle_defaults(self):\n        bundle = SignalBundle()\n        assert bundle.routing == {}\n        assert bundle.collected_at == \"\"\n\n    def test_improvement_report(self):\n        report = ImprovementReport(\n            generated_at=\"2025-01-01\",\n            total_signals=3,\n            recommendations=[],\n            health_summary={\"routing\": 0.8},\n            top_actions=[\"fix routing\"],\n        )\n        assert report.total_signals == 3\n\n\n# ── Signal Collectors ────────────────────────────────────────────────────\n\n\nclass TestCollectRouting:\n    def test_collects_heatmap(self):\n        from maggy.improve.signals import collect_routing\n        routing = MagicMock()\n        routing.get_heatmap.return_value = [\n            {\"model\": \"a\", \"task_type\": \"bug\", \"avg_reward\": 0.8, \"count\": 10},\n        ]\n        result = collect_routing(routing)\n        assert len(result[\"heatmap\"]) == 1\n        assert result[\"underperformers\"] == []\n\n    def test_flags_underperformers(self):\n        from maggy.improve.signals import collect_routing\n        routing = MagicMock()\n        routing.get_heatmap.return_value = [\n            {\"model\": \"bad\", \"task_type\": \"bug\", \"avg_reward\": 0.2, \"count\": 10},\n        ]\n        result = collect_routing(routing)\n        assert len(result[\"underperformers\"]) == 1\n\n\nclass TestCollectEvents:\n    def test_calculates_failure_rate(self):\n        from maggy.improve.signals import collect_events\n        events = MagicMock()\n        events.query.return_value = [\n            {\"success\": True}, {\"success\": False},\n            {\"success\": True}, {\"success\": True},\n        ]\n        result = collect_events(events)\n        assert result[\"total\"] == 4\n        assert result[\"failures\"] == 1\n        assert result[\"failure_rate\"] == 0.25\n\n    def test_empty_events(self):\n        from maggy.improve.signals import collect_events\n        events = MagicMock()\n        events.query.return_value = []\n        result = collect_events(events)\n        assert result[\"failure_rate\"] == 0.0\n\n\nclass TestCollectHistory:\n    def test_returns_patterns(self):\n        from maggy.improve.signals import collect_history\n        history = MagicMock()\n        history.get_report.return_value = {\n            \"total_sessions\": 50,\n            \"patterns\": [\"dominance\"],\n            \"by_provider\": {\"claude\": 40, \"codex\": 10},\n        }\n        result = collect_history(history)\n        assert result[\"sessions\"] == 50\n\n    def test_no_report(self):\n        from maggy.improve.signals import collect_history\n        history = MagicMock()\n        history.get_report.return_value = None\n        result = collect_history(history)\n        assert result[\"sessions\"] == 0\n\n\nclass TestCollectForge:\n    def test_returns_gaps(self):\n        from maggy.improve.signals import collect_forge\n        forge = MagicMock()\n        forge.get_gaps.return_value = [\n            {\"name\": \"slack\", \"count\": 5},\n        ]\n        result = collect_forge(forge)\n        assert result[\"count\"] == 1\n\n\nclass TestCollectEngram:\n    def test_returns_health(self):\n        from maggy.improve.signals import collect_engram\n        engram = MagicMock()\n        with patch(\"maggy.engram.diagnostics.diagnose\") as mock_diag:\n            profile = SimpleNamespace(\n                health_score=0.7, total_memories=100,\n                active_count=70, superseded_count=30,\n            )\n            mock_diag.return_value = profile\n            result = collect_engram(engram)\n        assert result[\"health_score\"] == 0.7\n\n\nclass TestCollectBudget:\n    def test_returns_status(self):\n        from maggy.improve.signals import collect_budget\n        budget = MagicMock()\n        budget.budget_status.return_value = {\n            \"utilization\": 0.5, \"status\": \"ok\",\n        }\n        result = collect_budget(budget)\n        assert result[\"utilization\"] == 0.5\n\n\nclass TestCollectAll:\n    def test_skips_none_services(self):\n        from maggy.improve.signals import collect_all\n        state = SimpleNamespace(\n            routing=None, events=None, history=None,\n            forge=None, engram=None, budget=None,\n        )\n        bundle = collect_all(state)\n        assert bundle.routing == {}\n        assert bundle.events == {}\n\n\n# ── Analyzer ─────────────────────────────────────────────────────────────\n\n\nclass TestAnalyzeRouting:\n    def test_flags_underperformers(self):\n        from maggy.improve.analyzer import analyze_routing\n        signals = SignalBundle(\n            routing={\"underperformers\": [\n                {\"model\": \"bad\", \"task_type\": \"bug\", \"avg_reward\": 0.2},\n            ]},\n        )\n        recs = analyze_routing(signals)\n        assert len(recs) == 1\n        assert recs[0].category == \"routing\"\n\n    def test_no_issues(self):\n        from maggy.improve.analyzer import analyze_routing\n        signals = SignalBundle(routing={\"underperformers\": []})\n        assert analyze_routing(signals) == []\n\n\nclass TestAnalyzeFailures:\n    def test_flags_high_failure(self):\n        from maggy.improve.analyzer import analyze_failures\n        signals = SignalBundle(events={\"failure_rate\": 0.25})\n        recs = analyze_failures(signals)\n        assert len(recs) == 1\n        assert recs[0].severity == \"action\"\n\n    def test_ok_rate(self):\n        from maggy.improve.analyzer import analyze_failures\n        signals = SignalBundle(events={\"failure_rate\": 0.1})\n        assert analyze_failures(signals) == []\n\n\nclass TestAnalyzeUsage:\n    def test_flags_low_usage(self):\n        from maggy.improve.analyzer import analyze_usage\n        signals = SignalBundle(history={\n            \"sessions\": 100,\n            \"by_provider\": {\"codex\": 3},\n        })\n        recs = analyze_usage(signals)\n        assert len(recs) == 1\n        assert recs[0].category == \"usage\"\n\n    def test_no_sessions(self):\n        from maggy.improve.analyzer import analyze_usage\n        signals = SignalBundle(history={\"sessions\": 0})\n        assert analyze_usage(signals) == []\n\n\nclass TestAnalyzeGaps:\n    def test_surfaces_gaps(self):\n        from maggy.improve.analyzer import analyze_gaps\n        signals = SignalBundle(forge={\n            \"gaps\": [{\"name\": \"slack\", \"count\": 5}],\n        })\n        recs = analyze_gaps(signals)\n        assert len(recs) == 1\n        assert recs[0].category == \"capability\"\n\n\nclass TestAnalyzeMemory:\n    def test_flags_low_health(self):\n        from maggy.improve.analyzer import analyze_memory\n        signals = SignalBundle(engram={\"health_score\": 0.3})\n        recs = analyze_memory(signals)\n        assert len(recs) == 1\n        assert recs[0].category == \"memory\"\n\n    def test_healthy(self):\n        from maggy.improve.analyzer import analyze_memory\n        signals = SignalBundle(engram={\"health_score\": 0.8})\n        assert analyze_memory(signals) == []\n\n\nclass TestAnalyzeCost:\n    def test_flags_high_util(self):\n        from maggy.improve.analyzer import analyze_cost\n        signals = SignalBundle(budget={\"utilization\": 0.95})\n        recs = analyze_cost(signals)\n        assert len(recs) == 1\n        assert recs[0].category == \"cost\"\n\n    def test_ok_util(self):\n        from maggy.improve.analyzer import analyze_cost\n        signals = SignalBundle(budget={\"utilization\": 0.5})\n        assert analyze_cost(signals) == []\n\n\nclass TestAnalyzeAll:\n    def test_merges_all(self):\n        from maggy.improve.analyzer import analyze_all\n        signals = SignalBundle(\n            routing={\"underperformers\": [\n                {\"model\": \"x\", \"task_type\": \"bug\", \"avg_reward\": 0.1},\n            ]},\n            events={\"failure_rate\": 0.3},\n            budget={\"utilization\": 0.95},\n            engram={\"health_score\": 0.2},\n            forge={\"gaps\": [{\"name\": \"y\", \"count\": 3}]},\n            history={\"sessions\": 0},\n        )\n        recs = analyze_all(signals)\n        categories = {r.category for r in recs}\n        assert \"routing\" in categories\n        assert \"reliability\" in categories\n        assert \"cost\" in categories\n\n\n# ── Introspector Service ─────────────────────────────────────────────────\n\n\nclass TestIntrospector:\n    def test_analyze_empty_state(self):\n        from maggy.improve.service import Introspector\n        state = SimpleNamespace(\n            routing=None, events=None, history=None,\n            forge=None, engram=None, budget=None,\n        )\n        intro = Introspector(state)\n        report = intro.analyze()\n        assert report.total_signals == 0\n        assert report.recommendations == []\n\n    def test_get_report_none_initially(self):\n        from maggy.improve.service import Introspector\n        state = SimpleNamespace(\n            routing=None, events=None, history=None,\n            forge=None, engram=None, budget=None,\n        )\n        intro = Introspector(state)\n        assert intro.get_report() is None\n\n    def test_get_report_after_analyze(self):\n        from maggy.improve.service import Introspector\n        state = SimpleNamespace(\n            routing=None, events=None, history=None,\n            forge=None, engram=None, budget=None,\n        )\n        intro = Introspector(state)\n        intro.analyze()\n        report = intro.get_report()\n        assert report is not None\n        assert report.generated_at != \"\"\n\n    def test_health_summary_populated(self):\n        from maggy.improve.service import Introspector\n        routing = MagicMock()\n        routing.get_heatmap.return_value = []\n        events = MagicMock()\n        events.query.return_value = [\n            {\"success\": True}, {\"success\": True},\n        ]\n        budget = MagicMock()\n        budget.budget_status.return_value = {\n            \"utilization\": 0.5, \"status\": \"ok\",\n        }\n        state = SimpleNamespace(\n            routing=routing, events=events, history=None,\n            forge=None, engram=None, budget=budget,\n        )\n        intro = Introspector(state)\n        report = intro.analyze()\n        assert \"routing\" in report.health_summary\n        assert \"reliability\" in report.health_summary\n        assert \"cost\" in report.health_summary\n"
  },
  {
    "path": "maggy/tests/test_lexon.py",
    "content": "\"\"\"Tests for Lexon — routing, terminology, disambiguation.\"\"\"\n\nfrom __future__ import annotations\n\nfrom maggy.lexon.disambiguate import disambiguate\nfrom maggy.lexon.personalization import PersonalizationEngine\nfrom maggy.lexon.record import LexonRecord\nfrom maggy.lexon.router import LexonRouter\nfrom maggy.lexon.terminology import TermEntry, TerminologyMap\n\n\nclass TestTerminology:\n    def test_resolve_canonical(self):\n        tm = TerminologyMap()\n        assert tm.resolve(\"deploy\") == \"deploy\"\n\n    def test_resolve_synonym(self):\n        tm = TerminologyMap()\n        assert tm.resolve(\"ship\") == \"deploy\"\n\n    def test_resolve_unknown(self):\n        tm = TerminologyMap()\n        assert tm.resolve(\"xyzzy\") is None\n\n    def test_add_alias(self):\n        tm = TerminologyMap()\n        assert tm.add_alias(\"deploy\", \"yeet\")\n        assert tm.resolve(\"yeet\") == \"deploy\"\n\n    def test_add_alias_unknown_canonical(self):\n        tm = TerminologyMap()\n        assert not tm.add_alias(\"nonexistent\", \"alias\")\n\n\nclass TestDisambiguate:\n    def test_high_confidence_resolves(self):\n        result = disambiguate(0.9, [\"grep\"])\n        assert result.resolved\n        assert result.tool == \"grep\"\n        assert result.mode == \"none\"\n\n    def test_mid_confidence_self_clarify(self):\n        result = disambiguate(0.6, [\"grep\", \"glob\"])\n        assert result.resolved\n        assert result.mode == \"self_clarify\"\n\n    def test_low_confidence_user_clarify(self):\n        result = disambiguate(0.4, [\"grep\", \"glob\", \"find\"])\n        assert not result.resolved\n        assert result.mode == \"user_clarify\"\n\n    def test_very_low_rejects(self):\n        result = disambiguate(0.1, [])\n        assert not result.resolved\n\n\nclass TestPersonalization:\n    def test_record_and_top(self):\n        pe = PersonalizationEngine()\n        pe.record_use(\"grep\")\n        pe.record_use(\"grep\")\n        pe.record_use(\"glob\")\n        top = pe.top_tools(2)\n        assert top[0] == \"grep\"\n\n    def test_preferred_alias(self):\n        pe = PersonalizationEngine()\n        pe.record_alias(\"find stuff\", \"grep\")\n        assert pe.get_preferred(\"find stuff\") == \"grep\"\n\n    def test_correction(self):\n        pe = PersonalizationEngine()\n        pe.record_correction(\"test\", \"pytest\")\n        assert len(pe.signals.correction_pairs) == 1\n\n\nclass TestLexonRouter:\n    def test_known_intent(self):\n        lr = LexonRouter()\n        record = lr.route(\"deploy my app\")\n        assert record.confidence > 0.5\n        assert len(record.candidates) > 0\n\n    def test_unknown_intent(self):\n        lr = LexonRouter()\n        record = lr.route(\"xyzzy plugh\")\n        assert record.disambiguation_mode == \"llm\"\n\n    def test_learn_and_recall(self):\n        lr = LexonRouter()\n        lr.learn(\"push it live\", \"vercel_deploy\")\n        record = lr.route(\"push it live\")\n        assert record.resolved_tool == \"vercel_deploy\"\n        assert record.confidence >= 0.9\n\n    def test_multiple_candidates(self):\n        lr = LexonRouter()\n        record = lr.route(\"search for files\")\n        assert record.disambiguation_mode == \"llm\"\n\n    def test_manifest_overrides_default_tools(self):\n        lr = LexonRouter({\n            \"tool_manifest\": {\n                \"deploy\": [\"shipctl\"],\n            },\n        })\n        record = lr.route(\"deploy release\")\n        assert record.resolved_tool == \"shipctl\"\n\n\nclass TestLexonRecord:\n    def test_ambiguous(self):\n        r = LexonRecord(phrase=\"test\", confidence=0.3)\n        assert r.is_ambiguous\n\n    def test_not_ambiguous(self):\n        r = LexonRecord(phrase=\"test\", confidence=0.9)\n        assert not r.is_ambiguous\n\n    def test_needs_user_input(self):\n        r = LexonRecord(\n            phrase=\"x\", disambiguation_mode=\"user_clarify\",\n        )\n        assert r.needs_user_input\n"
  },
  {
    "path": "maggy/tests/test_mesh.py",
    "content": "\"\"\"Tests for Maggy Mesh — protocol, discovery, sync, quarantine.\"\"\"\n\nfrom __future__ import annotations\n\nfrom maggy.mesh.discovery import PeerInfo, PeerRegistry\nfrom maggy.mesh.memory import MemoryType, SharedMemory\nfrom maggy.mesh.protocol import (\n    MeshMessage,\n    MessageType,\n    create_hello,\n    create_share,\n)\nfrom maggy.mesh.provenance import Provenance\nfrom maggy.mesh.quarantine import QuarantineStore\nfrom maggy.mesh.sync import SyncEngine\nfrom maggy.mesh.transport import compute_hmac, verify_hmac\n\n\nclass TestProtocol:\n    def test_serialize_round_trip(self):\n        msg = create_hello(\"peer-1\", \"Alice\")\n        data = msg.serialize()\n        restored = MeshMessage.deserialize(data)\n        assert restored.msg_type == MessageType.HELLO\n        assert restored.sender_id == \"peer-1\"\n\n    def test_share_message(self):\n        msg = create_share(\n            \"peer-1\", \"score:claude:fix\",\n            {\"memory_type\": \"score\", \"model\": \"claude\"},\n        )\n        assert msg.msg_type == MessageType.SHARE\n        assert msg.payload[\"key\"] == \"score:claude:fix\"\n\n\nclass TestPeerDiscovery:\n    def test_register_and_list(self):\n        reg = PeerRegistry()\n        reg.register(PeerInfo(\n            peer_id=\"p1\", name=\"Alice\",\n            address=\"192.168.1.1\",\n        ))\n        assert reg.count == 1\n        assert reg.get(\"p1\").name == \"Alice\"\n\n    def test_unregister(self):\n        reg = PeerRegistry()\n        reg.register(PeerInfo(\n            peer_id=\"p1\", name=\"Alice\",\n            address=\"192.168.1.1\",\n        ))\n        assert reg.unregister(\"p1\")\n        assert reg.count == 0\n\n    def test_update_seen(self):\n        reg = PeerRegistry()\n        reg.register(PeerInfo(\n            peer_id=\"p1\", name=\"Alice\",\n            address=\"192.168.1.1\",\n        ))\n        old = reg.get(\"p1\").last_seen\n        reg.update_seen(\"p1\")\n        # May or may not change within same ms\n        assert reg.get(\"p1\").last_seen is not None\n\n\nclass TestProvenance:\n    def test_no_hop_full_confidence(self):\n        p = Provenance(origin_peer=\"p1\", base_confidence=1.0)\n        assert p.effective_confidence == 1.0\n\n    def test_decay_per_hop(self):\n        p = Provenance(\n            origin_peer=\"p1\", hops=3, base_confidence=1.0,\n        )\n        assert p.effective_confidence == 0.7\n\n    def test_add_hop(self):\n        p = Provenance(origin_peer=\"p1\", hops=1)\n        p2 = p.add_hop()\n        assert p2.hops == 2\n\n    def test_min_confidence(self):\n        p = Provenance(\n            origin_peer=\"p1\", hops=100, base_confidence=1.0,\n        )\n        assert p.effective_confidence == 0.1\n\n\nclass TestQuarantine:\n    def test_quarantine_and_list(self):\n        qs = QuarantineStore()\n        qs.quarantine(\"k1\", \"peer-1\", \"low conf\", {\"x\": 1})\n        assert qs.count == 1\n        assert qs.get(\"k1\").reason == \"low conf\"\n\n    def test_promote(self):\n        qs = QuarantineStore()\n        qs.quarantine(\"k1\", \"peer-1\", \"test\", {})\n        assert qs.promote(\"k1\")\n        assert qs.count == 0\n\n    def test_promote_missing(self):\n        qs = QuarantineStore()\n        assert not qs.promote(\"nope\")\n\n\nclass TestSync:\n    def test_accept_high_confidence(self):\n        qs = QuarantineStore()\n        engine = SyncEngine(qs)\n        mems = [\n            SharedMemory(\n                key=\"s1\", memory_type=\"score\",\n                confidence=0.8, source_peer=\"p1\",\n            ),\n        ]\n        result = engine.sync_incoming(mems)\n        assert result.accepted == 1\n        assert engine.local_count == 1\n\n    def test_quarantine_low_confidence(self):\n        qs = QuarantineStore()\n        engine = SyncEngine(qs)\n        mems = [\n            SharedMemory(\n                key=\"s1\", memory_type=\"score\",\n                confidence=0.3, source_peer=\"p1\",\n            ),\n        ]\n        result = engine.sync_incoming(mems)\n        assert result.quarantined == 1\n        assert qs.count == 1\n\n\nclass TestTransport:\n    def test_hmac_round_trip(self):\n        sig = compute_hmac(\"hello\", \"secret\")\n        assert verify_hmac(\"hello\", \"secret\", sig)\n\n    def test_hmac_mismatch(self):\n        sig = compute_hmac(\"hello\", \"secret\")\n        assert not verify_hmac(\"hello\", \"wrong\", sig)\n"
  },
  {
    "path": "maggy/tests/test_mesh_network.py",
    "content": "\"\"\"Tests for mesh network layer: org scanner, git discovery, transport, network, manager, publisher.\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nfrom pathlib import Path\nfrom types import SimpleNamespace\nfrom unittest.mock import AsyncMock, patch\n\nimport pytest\n\nfrom maggy.mesh.discovery import PeerInfo\n\n\n# ── Org Scanner ─────────────────────────────────────────\n\n\nclass TestEffectiveOrgs:\n    def test_merge_scanned_and_manual(self):\n        from maggy.mesh.org_scanner import effective_orgs\n        result = effective_orgs(\n            [\"protaige\", \"edubites\"], [\"alinaqi\"], [],\n        )\n        assert result == [\"alinaqi\", \"edubites\", \"protaige\"]\n\n    def test_excludes_orgs(self):\n        from maggy.mesh.org_scanner import effective_orgs\n        result = effective_orgs(\n            [\"protaige\", \"edubites\", \"alinaqi\"],\n            [], [\"edubites\"],\n        )\n        assert \"edubites\" not in result\n        assert len(result) == 2\n\n    def test_deduplicates(self):\n        from maggy.mesh.org_scanner import effective_orgs\n        result = effective_orgs(\n            [\"protaige\"], [\"protaige\"], [],\n        )\n        assert result == [\"protaige\"]\n\n    def test_empty_inputs(self):\n        from maggy.mesh.org_scanner import effective_orgs\n        assert effective_orgs([], [], []) == []\n\n\n# ── Transport ───────────────────────────────────────────\n\n\nclass TestDeriveOrgKey:\n    def test_different_orgs_produce_different_keys(self):\n        from maggy.mesh.transport import derive_org_key\n        k1 = derive_org_key(\"protaige\", \"secret\")\n        k2 = derive_org_key(\"edubites\", \"secret\")\n        assert k1 != k2\n\n    def test_deterministic(self):\n        from maggy.mesh.transport import derive_org_key\n        k1 = derive_org_key(\"protaige\", \"secret\")\n        k2 = derive_org_key(\"protaige\", \"secret\")\n        assert k1 == k2\n\n    def test_returns_hex_string(self):\n        from maggy.mesh.transport import derive_org_key\n        key = derive_org_key(\"org\", \"secret\")\n        assert len(key) == 64  # SHA-256 hex\n\n\nclass TestSignVerify:\n    def test_roundtrip(self):\n        from maggy.mesh.transport import sign_message, verify_message\n        from maggy.mesh.protocol import create_hello\n        msg = create_hello(\"peer-1\", \"tester\")\n        signed = sign_message(msg, \"test-key\")\n        result = verify_message(signed, \"test-key\")\n        assert result is not None\n        assert result.sender_id == \"peer-1\"\n\n    def test_wrong_key_fails(self):\n        from maggy.mesh.transport import sign_message, verify_message\n        from maggy.mesh.protocol import create_hello\n        msg = create_hello(\"peer-1\", \"tester\")\n        signed = sign_message(msg, \"correct-key\")\n        result = verify_message(signed, \"wrong-key\")\n        assert result is None\n\n    def test_invalid_json_fails(self):\n        from maggy.mesh.transport import verify_message\n        result = verify_message(\"not-json\", \"key\")\n        assert result is None\n\n\n# ── Network ─────────────────────────────────────────────\n\n\nclass TestBuildNetwork:\n    def test_creates_network(self, tmp_path: Path):\n        from maggy.mesh.network import build_network\n        from maggy.mesh.store import MeshStore\n        store = MeshStore(tmp_path / \"mesh.db\")\n        net = build_network(\"protaige\", \"secret\", store)\n        assert net.org == \"protaige\"\n        assert net.org_key != \"\"\n\n    def test_isolated_org_keys(self, tmp_path: Path):\n        from maggy.mesh.network import build_network\n        from maggy.mesh.store import MeshStore\n        store = MeshStore(tmp_path / \"mesh.db\")\n        n1 = build_network(\"protaige\", \"secret\", store)\n        n2 = build_network(\"edubites\", \"secret\", store)\n        assert n1.org_key != n2.org_key\n\n    def test_status_returns_counts(self, tmp_path: Path):\n        from maggy.mesh.network import build_network\n        from maggy.mesh.store import MeshStore\n        store = MeshStore(tmp_path / \"mesh.db\")\n        net = build_network(\"test-org\", \"secret\", store)\n        status = net.status()\n        assert status[\"org\"] == \"test-org\"\n        assert status[\"peers\"] == 0\n        assert status[\"memories\"] == 0\n        assert status[\"quarantined\"] == 0\n\n\n# ── Manager ─────────────────────────────────────────────\n\n\ndef _make_cfg(**overrides):\n    \"\"\"Build a minimal MeshConfig-like SimpleNamespace.\"\"\"\n    defaults = {\n        \"peer_id\": \"test-peer\",\n        \"org_key_secret\": \"secret\",\n        \"port\": 8080,\n        \"tunnel_url\": \"\",\n        \"git_discovery\": True,\n    }\n    defaults.update(overrides)\n    return SimpleNamespace(**defaults)\n\n\nclass TestMeshManager:\n    def test_add_and_get_network(self, tmp_path: Path):\n        from maggy.mesh.manager import MeshManager\n        from maggy.mesh.store import MeshStore\n        store = MeshStore(tmp_path / \"mesh.db\")\n        mgr = MeshManager(_make_cfg(), store)\n        net = mgr.add_network(\"protaige\")\n        assert net.org == \"protaige\"\n        assert mgr.get_network(\"protaige\") is net\n\n    def test_missing_network_returns_none(self, tmp_path: Path):\n        from maggy.mesh.manager import MeshManager\n        from maggy.mesh.store import MeshStore\n        store = MeshStore(tmp_path / \"mesh.db\")\n        mgr = MeshManager(_make_cfg(), store)\n        assert mgr.get_network(\"nope\") is None\n\n    def test_list_networks(self, tmp_path: Path):\n        from maggy.mesh.manager import MeshManager\n        from maggy.mesh.store import MeshStore\n        store = MeshStore(tmp_path / \"mesh.db\")\n        mgr = MeshManager(_make_cfg(), store)\n        mgr.add_network(\"org-a\")\n        mgr.add_network(\"org-b\")\n        nets = mgr.list_networks()\n        assert len(nets) == 2\n\n    def test_total_peers_across_networks(self, tmp_path: Path):\n        from maggy.mesh.manager import MeshManager\n        from maggy.mesh.store import MeshStore\n        store = MeshStore(tmp_path / \"mesh.db\")\n        mgr = MeshManager(_make_cfg(), store)\n        net = mgr.add_network(\"org-a\")\n        net.peers.register(PeerInfo(\n            peer_id=\"p1\", name=\"peer1\",\n            address=\"ws://1\", org=\"org-a\",\n        ))\n        assert mgr.total_peers == 1\n\n    def test_resolve_address_tunnel(self, tmp_path: Path):\n        from maggy.mesh.manager import MeshManager\n        from maggy.mesh.store import MeshStore\n        store = MeshStore(tmp_path / \"mesh.db\")\n        cfg = _make_cfg(tunnel_url=\"wss://bore.pub/xyz\")\n        mgr = MeshManager(cfg, store)\n        assert mgr._resolve_address() == \"wss://bore.pub/xyz\"\n\n    def test_resolve_address_local(self, tmp_path: Path):\n        from maggy.mesh.manager import MeshManager\n        from maggy.mesh.store import MeshStore\n        store = MeshStore(tmp_path / \"mesh.db\")\n        mgr = MeshManager(_make_cfg(), store)\n        assert \"127.0.0.1:8080\" in mgr._resolve_address()\n\n\n# ── Publisher ───────────────────────────────────────────\n\n\nclass TestPublisher:\n    def test_collect_scores_skips_low_count(self):\n        from maggy.mesh.publisher import collect_scores\n        routing = SimpleNamespace(\n            get_heatmap=lambda: [\n                {\"model\": \"m1\", \"task_type\": \"fix\", \"count\": 2},\n            ],\n        )\n        result = collect_scores(routing, \"peer-1\")\n        assert len(result) == 0\n\n    def test_collect_scores_includes_high_count(self):\n        from maggy.mesh.publisher import collect_scores\n        routing = SimpleNamespace(\n            get_heatmap=lambda: [\n                {\"model\": \"m1\", \"task_type\": \"fix\", \"count\": 10},\n            ],\n        )\n        result = collect_scores(routing, \"peer-1\")\n        assert len(result) == 1\n        assert result[0].memory_type == \"score\"\n\n    def test_collect_gaps(self):\n        from maggy.mesh.publisher import collect_gaps\n        forge = SimpleNamespace(\n            get_gaps=lambda: [{\"name\": \"slack-notify\"}],\n        )\n        result = collect_gaps(forge, \"peer-1\")\n        assert len(result) == 1\n        assert result[0].key == \"gap:slack-notify\"\n\n    def test_collect_policies_filters_severity(self):\n        from maggy.mesh.publisher import collect_policies\n        rec = SimpleNamespace(\n            severity=\"action\",\n            category=\"routing\",\n            message=\"Fix it\",\n            suggestion=\"Do this\",\n        )\n        rec_info = SimpleNamespace(\n            severity=\"info\",\n            category=\"mem\",\n            message=\"FYI\",\n            suggestion=\"N/A\",\n        )\n        report = SimpleNamespace(\n            recommendations=[rec, rec_info],\n        )\n        introspector = SimpleNamespace(get_report=lambda: report)\n        result = collect_policies(introspector, \"peer-1\")\n        assert len(result) == 1  # only action severity\n\n    def test_collect_all_none_services(self):\n        from maggy.mesh.publisher import collect_all_shares\n        state = SimpleNamespace()\n        result = collect_all_shares(state, \"peer-1\")\n        assert result == []\n\n\n# ── Git Discovery (mocked HTTP) ─────────────────────────\n\n\nclass TestGitDiscovery:\n    @pytest.mark.asyncio\n    async def test_ensure_repo_exists(self):\n        from maggy.mesh.git_discovery import ensure_mesh_repo\n        mock_resp = AsyncMock()\n        mock_resp.status_code = 200\n        mock_client = AsyncMock()\n        mock_client.get = AsyncMock(return_value=mock_resp)\n        mock_client.__aenter__ = AsyncMock(\n            return_value=mock_client,\n        )\n        mock_client.__aexit__ = AsyncMock()\n        with patch(\"httpx.AsyncClient\", return_value=mock_client):\n            result = await ensure_mesh_repo(\"org\", \"token\")\n        assert result is True\n\n    @pytest.mark.asyncio\n    async def test_ensure_repo_creates_new(self):\n        from maggy.mesh.git_discovery import ensure_mesh_repo\n        not_found = AsyncMock()\n        not_found.status_code = 404\n        created = AsyncMock()\n        created.status_code = 201\n        mock_client = AsyncMock()\n        mock_client.get = AsyncMock(return_value=not_found)\n        mock_client.post = AsyncMock(return_value=created)\n        mock_client.__aenter__ = AsyncMock(\n            return_value=mock_client,\n        )\n        mock_client.__aexit__ = AsyncMock()\n        with patch(\"httpx.AsyncClient\", return_value=mock_client):\n            result = await ensure_mesh_repo(\"org\", \"token\")\n        assert result is True\n\n    @pytest.mark.asyncio\n    async def test_read_peers_empty(self):\n        from maggy.mesh.git_discovery import read_peers\n        not_found = AsyncMock()\n        not_found.status_code = 404\n        mock_client = AsyncMock()\n        mock_client.get = AsyncMock(return_value=not_found)\n        mock_client.__aenter__ = AsyncMock(\n            return_value=mock_client,\n        )\n        mock_client.__aexit__ = AsyncMock()\n        with patch(\"httpx.AsyncClient\", return_value=mock_client):\n            result = await read_peers(\"org\", \"token\")\n        assert result == []\n\n    @pytest.mark.asyncio\n    async def test_announce_success(self):\n        from maggy.mesh.git_discovery import Announcement, announce\n        not_found = AsyncMock()\n        not_found.status_code = 404\n        success = AsyncMock()\n        success.status_code = 201\n        mock_client = AsyncMock()\n        mock_client.get = AsyncMock(return_value=not_found)\n        mock_client.put = AsyncMock(return_value=success)\n        mock_client.__aenter__ = AsyncMock(\n            return_value=mock_client,\n        )\n        mock_client.__aexit__ = AsyncMock()\n        ann = Announcement(\n            peer_id=\"peer-1\", name=\"node\",\n            address=\"ws://x\",\n        )\n        with patch(\"httpx.AsyncClient\", return_value=mock_client):\n            result = await announce(\"org\", ann, \"tok\")\n        assert result is True\n\n    @pytest.mark.asyncio\n    async def test_remove_announcement(self):\n        from maggy.mesh.git_discovery import remove_announcement\n        found = AsyncMock()\n        found.status_code = 200\n        found.json = lambda: {\"sha\": \"abc123\"}\n        deleted = AsyncMock()\n        deleted.status_code = 200\n        mock_client = AsyncMock()\n        mock_client.get = AsyncMock(return_value=found)\n        mock_client.delete = AsyncMock(return_value=deleted)\n        mock_client.__aenter__ = AsyncMock(\n            return_value=mock_client,\n        )\n        mock_client.__aexit__ = AsyncMock()\n        with patch(\"httpx.AsyncClient\", return_value=mock_client):\n            result = await remove_announcement(\n                \"org\", \"peer-1\", \"tok\",\n            )\n        assert result is True\n\n\n# ── Promote Flow ────────────────────────────────────────\n\n\nclass TestPromoteFlow:\n    def test_promote_accepts_into_sync(self, tmp_path: Path):\n        from maggy.mesh.network import build_network\n        from maggy.mesh.store import MeshStore\n        store = MeshStore(tmp_path / \"mesh.db\")\n        net = build_network(\"org-a\", \"secret\", store)\n        net.quarantine.quarantine(\n            key=\"score:m1:fix\",\n            source=\"peer-2\",\n            reason=\"low confidence\",\n            content={\"model\": \"m1\"},\n            memory_type=\"score\",\n        )\n        assert net.quarantine.count == 1\n        assert net.sync.local_count == 0\n        ok = net.sync.promote_from_quarantine(\"score:m1:fix\")\n        assert ok is True\n        assert net.quarantine.count == 0\n        assert net.sync.local_count == 1\n        mem = net.sync.get_local(\"score:m1:fix\")\n        assert mem is not None\n        assert mem.content == {\"model\": \"m1\"}\n\n    def test_promote_nonexistent_returns_false(\n        self, tmp_path: Path,\n    ):\n        from maggy.mesh.network import build_network\n        from maggy.mesh.store import MeshStore\n        store = MeshStore(tmp_path / \"mesh.db\")\n        net = build_network(\"org-a\", \"secret\", store)\n        ok = net.sync.promote_from_quarantine(\"nope\")\n        assert ok is False\n\n\n# ── Replay Protection ──────────────────────────────────\n\n\nclass TestReplayProtection:\n    def test_stale_message_rejected(self):\n        import time\n        from maggy.mesh.transport import (\n            sign_message,\n            verify_message,\n        )\n        from maggy.mesh.protocol import create_hello\n        msg = create_hello(\"peer-1\", \"tester\")\n        signed = sign_message(msg, \"key\")\n        # Tamper timestamp to make it old\n        import json\n        envelope = json.loads(signed)\n        envelope[\"ts\"] = time.time() - 600\n        sig_field = envelope[\"sig\"]\n        tampered = json.dumps(envelope)\n        result = verify_message(tampered, \"key\")\n        assert result is None\n\n\n# ── SQLite Reload on Init ──────────────────────────────\n\n\nclass TestSqliteReload:\n    def test_peers_reload_from_store(self, tmp_path: Path):\n        from maggy.mesh.discovery import PeerInfo, PeerRegistry\n        from maggy.mesh.store import MeshStore\n        store = MeshStore(tmp_path / \"mesh.db\")\n        reg1 = PeerRegistry(store, \"org-a\")\n        reg1.register(PeerInfo(\n            peer_id=\"p1\", name=\"Alice\",\n            address=\"ws://a\", org=\"org-a\",\n        ))\n        # Create new registry from same store — should reload\n        reg2 = PeerRegistry(store, \"org-a\")\n        assert reg2.count == 1\n        assert reg2.get(\"p1\") is not None\n\n    def test_sync_reload_from_store(self, tmp_path: Path):\n        from maggy.mesh.memory import SharedMemory\n        from maggy.mesh.quarantine import QuarantineStore\n        from maggy.mesh.store import MeshStore\n        from maggy.mesh.sync import SyncEngine\n        store = MeshStore(tmp_path / \"mesh.db\")\n        q1 = QuarantineStore(store, \"org-a\")\n        s1 = SyncEngine(q1, store, \"org-a\")\n        s1.sync_incoming([SharedMemory(\n            key=\"k1\", memory_type=\"score\",\n            content={\"x\": 1}, source_peer=\"p1\",\n        )])\n        # New engine from same store — should reload\n        q2 = QuarantineStore(store, \"org-a\")\n        s2 = SyncEngine(q2, store, \"org-a\")\n        assert s2.local_count == 1\n"
  },
  {
    "path": "maggy/tests/test_mesh_store.py",
    "content": "\"\"\"Tests for mesh SQLite store.\"\"\"\n\nfrom __future__ import annotations\n\nfrom pathlib import Path\n\nimport pytest\n\nfrom maggy.mesh.store import MeshStore\n\n\n@pytest.fixture\ndef store(tmp_path: Path) -> MeshStore:\n    return MeshStore(tmp_path / \"mesh.db\")\n\n\nclass TestPeerCRUD:\n    def test_upsert_and_get(self, store: MeshStore):\n        store.upsert_peer(\"p1\", \"Alice\", \"1.2.3.4\", 8080, \"acme\")\n        peer = store.get_peer(\"p1\", \"acme\")\n        assert peer is not None\n        assert peer[\"name\"] == \"Alice\"\n\n    def test_list_by_org(self, store: MeshStore):\n        store.upsert_peer(\"p1\", \"A\", \"1.1.1.1\", 8080, \"acme\")\n        store.upsert_peer(\"p2\", \"B\", \"2.2.2.2\", 8080, \"other\")\n        acme = store.list_peers(org=\"acme\")\n        assert len(acme) == 1\n\n    def test_list_all(self, store: MeshStore):\n        store.upsert_peer(\"p1\", \"A\", \"1.1.1.1\", 8080, \"a\")\n        store.upsert_peer(\"p2\", \"B\", \"2.2.2.2\", 8080, \"b\")\n        assert len(store.list_peers()) == 2\n\n    def test_remove_peer(self, store: MeshStore):\n        store.upsert_peer(\"p1\", \"A\", \"1.1.1.1\", 8080, \"acme\")\n        assert store.remove_peer(\"p1\", \"acme\")\n        assert store.get_peer(\"p1\", \"acme\") is None\n\n    def test_remove_missing(self, store: MeshStore):\n        assert not store.remove_peer(\"nope\", \"acme\")\n\n    def test_upsert_updates(self, store: MeshStore):\n        store.upsert_peer(\"p1\", \"A\", \"1.1.1.1\", 8080, \"acme\")\n        store.upsert_peer(\"p1\", \"A-new\", \"9.9.9.9\", 8080, \"acme\")\n        peer = store.get_peer(\"p1\", \"acme\")\n        assert peer[\"name\"] == \"A-new\"\n        assert peer[\"address\"] == \"9.9.9.9\"\n\n\nclass TestMemoryCRUD:\n    def test_write_and_list(self, store: MeshStore):\n        store.write_memory(\"acme\", \"k1\", \"score\", {\"x\": 1}, \"p1\")\n        mems = store.list_memories(\"acme\")\n        assert len(mems) == 1\n        assert mems[0][\"key\"] == \"k1\"\n\n    def test_scoped_by_org(self, store: MeshStore):\n        store.write_memory(\"acme\", \"k1\", \"score\", {}, \"p1\")\n        store.write_memory(\"other\", \"k2\", \"gap\", {}, \"p2\")\n        assert len(store.list_memories(\"acme\")) == 1\n        assert len(store.list_memories(\"other\")) == 1\n\n    def test_upsert_memory(self, store: MeshStore):\n        store.write_memory(\"acme\", \"k1\", \"score\", {\"v\": 1}, \"p1\")\n        store.write_memory(\"acme\", \"k1\", \"score\", {\"v\": 2}, \"p1\")\n        mems = store.list_memories(\"acme\")\n        assert len(mems) == 1\n        assert mems[0][\"content\"][\"v\"] == 2\n\n\nclass TestQuarantineCRUD:\n    def test_quarantine_and_list(self, store: MeshStore):\n        store.quarantine_item(\"acme\", \"k1\", \"p1\", \"low conf\", {\"x\": 1})\n        items = store.list_quarantined(\"acme\")\n        assert len(items) == 1\n        assert items[0][\"reason\"] == \"low conf\"\n\n    def test_promote(self, store: MeshStore):\n        store.quarantine_item(\"acme\", \"k1\", \"p1\", \"test\", {})\n        assert store.promote_item(\"acme\", \"k1\")\n        assert len(store.list_quarantined(\"acme\")) == 0\n\n    def test_promote_missing(self, store: MeshStore):\n        assert not store.promote_item(\"acme\", \"nope\")\n\n    def test_scoped_by_org(self, store: MeshStore):\n        store.quarantine_item(\"acme\", \"k1\", \"p1\", \"r\", {})\n        store.quarantine_item(\"other\", \"k2\", \"p2\", \"r\", {})\n        assert len(store.list_quarantined(\"acme\")) == 1\n"
  },
  {
    "path": "maggy/tests/test_mesh_ws.py",
    "content": "\"\"\"Tests for WebSocket server and client.\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nfrom pathlib import Path\nfrom types import SimpleNamespace\nfrom unittest.mock import AsyncMock, MagicMock, patch\n\nimport pytest\nfrom fastapi import FastAPI\nfrom fastapi.testclient import TestClient\n\nfrom maggy.mesh.protocol import (\n    MessageType,\n    MeshMessage,\n    create_hello,\n    create_share,\n)\nfrom maggy.mesh.transport import sign_message\nfrom maggy.mesh.ws_server import router\n\n\n# ── WS Server ──────────────────────────────────────────\n\n\ndef _build_app_with_mesh(tmp_dir: Path | None = None):\n    \"\"\"Build a FastAPI app with mesh manager wired.\"\"\"\n    import tempfile\n    from maggy.mesh.manager import MeshManager\n    from maggy.mesh.store import MeshStore\n\n    if tmp_dir is None:\n        tmp_dir = Path(tempfile.mkdtemp())\n    app = FastAPI()\n    store = MeshStore(tmp_dir / \"mesh.db\")\n    cfg = SimpleNamespace(\n        peer_id=\"server-peer\",\n        org_key_secret=\"test-secret\",\n        port=8080,\n        tunnel_url=\"\",\n        git_discovery=False,\n    )\n    mgr = MeshManager(cfg, store)\n    mgr.add_network(\"test-org\")\n    app.state.mesh = mgr\n    app.include_router(router)\n    return app, mgr\n\n\nclass TestWsServerNoMesh:\n    def test_no_mesh_closes_connection(self):\n        app = FastAPI()\n        app.state.mesh = None\n        app.include_router(router)\n        client = TestClient(app)\n        with client.websocket_connect(\"/ws/mesh\") as ws:\n            # Server should close immediately with 1008\n            try:\n                ws.receive_text()\n                assert False, \"Should have disconnected\"\n            except Exception:\n                pass  # expected disconnect\n\n\nclass TestWsServerAuth:\n    def test_invalid_json_closes(self):\n        app, mgr = _build_app_with_mesh()\n        client = TestClient(app)\n        with pytest.raises(Exception):\n            with client.websocket_connect(\"/ws/mesh\") as ws:\n                ws.send_text(\"not-valid-json\")\n                ws.receive_text()\n\n    def test_wrong_org_closes(self):\n        app, mgr = _build_app_with_mesh()\n        net = mgr.get_network(\"test-org\")\n        hello = create_hello(\"client-1\", \"client\")\n        hello.payload[\"org\"] = \"wrong-org\"\n        signed = sign_message(hello, net.org_key)\n        client = TestClient(app)\n        with pytest.raises(Exception):\n            with client.websocket_connect(\"/ws/mesh\") as ws:\n                ws.send_text(signed)\n                ws.receive_text()\n\n\nclass TestWsServerHello:\n    def test_valid_hello_gets_reply(self):\n        app, mgr = _build_app_with_mesh()\n        net = mgr.get_network(\"test-org\")\n        hello = create_hello(\"client-1\", \"client\")\n        hello.payload[\"org\"] = \"test-org\"\n        signed = sign_message(hello, net.org_key)\n        client = TestClient(app)\n        with client.websocket_connect(\"/ws/mesh\") as ws:\n            ws.send_text(signed)\n            reply_raw = ws.receive_text()\n            envelope = json.loads(reply_raw)\n            assert \"payload\" in envelope\n            assert \"sig\" in envelope\n\n\n# ── WS Client ──────────────────────────────────────────\n\n\nclass TestMeshClient:\n    def test_init(self):\n        from maggy.mesh.ws_client import MeshClient\n        client = MeshClient(\"peer-1\")\n        assert client.connected_count == 0\n\n    def test_is_connected_false(self):\n        from maggy.mesh.ws_client import MeshClient\n        client = MeshClient(\"peer-1\")\n        assert client.is_connected(\"nope\") is False\n\n    @pytest.mark.asyncio\n    async def test_send_no_connection(self):\n        from maggy.mesh.ws_client import MeshClient\n        client = MeshClient(\"peer-1\")\n        msg = create_hello(\"peer-1\", \"test\")\n        result = await client.send(\"nope\", msg, \"key\")\n        assert result is False\n\n    @pytest.mark.asyncio\n    async def test_broadcast_empty(self):\n        from maggy.mesh.ws_client import MeshClient\n        client = MeshClient(\"peer-1\")\n        msg = create_hello(\"peer-1\", \"test\")\n        count = await client.broadcast([], msg, \"key\")\n        assert count == 0\n\n    @pytest.mark.asyncio\n    async def test_close_all_empty(self):\n        from maggy.mesh.ws_client import MeshClient\n        client = MeshClient(\"peer-1\")\n        await client.close_all()\n        assert client.connected_count == 0\n"
  },
  {
    "path": "maggy/tests/test_mnemos_fatigue.py",
    "content": "\"\"\"Tests for Mnemos fatigue tracking and signal logging.\"\"\"\n\nfrom __future__ import annotations\n\nfrom pathlib import Path\n\nimport pytest\n\nfrom maggy.mnemos.fatigue import FatigueTracker\nfrom maggy.mnemos.signals import SignalLog\n\n\nclass TestFatigueTracker:\n    def test_composite_and_state_ok(self):\n        tracker = FatigueTracker()\n        tracker.record(\"context_load\", 0.2)\n        tracker.record(\"turn_pressure\", 0.1)\n        tracker.record(\"reread_ratio\", 0.2)\n        tracker.record(\"handoff_risk\", 0.1)\n        assert round(tracker.composite(), 2) == 0.15\n        assert tracker.state() == \"ok\"\n\n    def test_rejects_invalid_dimension(self):\n        tracker = FatigueTracker()\n        with pytest.raises(ValueError, match=\"Unknown dimension\"):\n            tracker.record(\"bogus\", 0.5)\n\n    def test_model_switch_increases_reread_ratio(self):\n        tracker = FatigueTracker()\n        tracker.record(\"reread_ratio\", 0.2)\n        tracker.on_model_switch(128_000)\n        assert tracker.context_window == 128_000\n        assert tracker.dimensions[\"reread_ratio\"] == 0.35\n\n    def test_state_thresholds(self):\n        tracker = FatigueTracker()\n        for name in tracker.dimensions:\n            tracker.record(name, 0.6)\n        assert tracker.state() == \"compress\"\n        for name in tracker.dimensions:\n            tracker.record(name, 0.9)\n        assert tracker.state() == \"critical\"\n\n\nclass TestSignalLog:\n    def test_append_and_recent(self, tmp_path: Path):\n        log = SignalLog(tmp_path / \"signals.jsonl\")\n        log.append({\"kind\": \"fatigue\", \"value\": 0.4})\n        log.append({\"kind\": \"switch\", \"value\": 1})\n        assert log.recent(1) == [{\"kind\": \"switch\", \"value\": 1}]\n        assert log.recent(2)[0][\"kind\"] == \"fatigue\"\n"
  },
  {
    "path": "maggy/tests/test_monday_provider.py",
    "content": "\"\"\"Tests for Monday.com provider — IssueTrackerProvider impl.\"\"\"\n\nfrom __future__ import annotations\n\nimport pytest\n\nfrom maggy.providers.monday import MondayProvider\n\n\n@pytest.fixture()\ndef provider():\n    return MondayProvider(\n        api_token=\"test-token\", board_id=\"18391076058\",\n    )\n\n\ndef test_provider_name(provider):\n    assert provider.provider_name() == \"monday\"\n\n\ndef test_to_task_maps_fields(provider):\n    \"\"\"Monday item dict maps to Task dataclass.\"\"\"\n    item = {\n        \"id\": \"123\", \"name\": \"Fix login\",\n        \"column_values\": [\n            {\"id\": \"status\", \"text\": \"Working on it\"},\n            {\"id\": \"person\", \"text\": \"Ali\"},\n        ],\n        \"url\": \"https://monday.com/123\",\n        \"created_at\": \"2025-01-01\",\n        \"updated_at\": \"2025-01-02\",\n    }\n    task = provider._to_task(item)\n    assert task.id == \"123\"\n    assert task.title == \"Fix login\"\n    assert task.status == \"Working on it\"\n    assert task.assignee == \"Ali\"\n\n\n@pytest.mark.asyncio()\nasync def test_list_tasks_parses_items(provider, monkeypatch):\n    \"\"\"list_tasks returns Task objects from API response.\"\"\"\n    import httpx\n\n    class FakeResp:\n        status_code = 200\n        def json(self):\n            return {\"data\": {\"boards\": [{\"items_page\": {\n                \"items\": [\n                    {\"id\": \"1\", \"name\": \"Task A\",\n                     \"column_values\": [], \"url\": \"\",\n                     \"created_at\": \"\", \"updated_at\": \"\"},\n                ],\n            }}]}}\n\n    async def fake_post(self, url, **kw):\n        return FakeResp()\n\n    monkeypatch.setattr(httpx.AsyncClient, \"post\", fake_post)\n    tasks = await provider.list_tasks()\n    assert len(tasks) == 1\n    assert tasks[0].title == \"Task A\"\n\n\n@pytest.mark.asyncio()\nasync def test_list_tasks_empty_board(provider, monkeypatch):\n    \"\"\"Empty board returns empty list.\"\"\"\n    import httpx\n\n    class FakeResp:\n        status_code = 200\n        def json(self):\n            return {\"data\": {\"boards\": [{\"items_page\": {\n                \"items\": [],\n            }}]}}\n\n    async def fake_post(self, url, **kw):\n        return FakeResp()\n\n    monkeypatch.setattr(httpx.AsyncClient, \"post\", fake_post)\n    tasks = await provider.list_tasks()\n    assert tasks == []\n\n\n@pytest.mark.asyncio()\nasync def test_get_task_by_id(provider, monkeypatch):\n    \"\"\"get_task fetches single item by ID.\"\"\"\n    import httpx\n\n    class FakeResp:\n        status_code = 200\n        def json(self):\n            return {\"data\": {\"items\": [\n                {\"id\": \"42\", \"name\": \"Deploy\",\n                 \"column_values\": [], \"url\": \"\",\n                 \"created_at\": \"\", \"updated_at\": \"\"},\n            ]}}\n\n    async def fake_post(self, url, **kw):\n        return FakeResp()\n\n    monkeypatch.setattr(httpx.AsyncClient, \"post\", fake_post)\n    task = await provider.get_task(\"42\")\n    assert task is not None\n    assert task.id == \"42\"\n\n\n@pytest.mark.asyncio()\nasync def test_get_task_not_found(provider, monkeypatch):\n    \"\"\"get_task returns None for missing item.\"\"\"\n    import httpx\n\n    class FakeResp:\n        status_code = 200\n        def json(self):\n            return {\"data\": {\"items\": []}}\n\n    async def fake_post(self, url, **kw):\n        return FakeResp()\n\n    monkeypatch.setattr(httpx.AsyncClient, \"post\", fake_post)\n    task = await provider.get_task(\"999\")\n    assert task is None\n"
  },
  {
    "path": "maggy/tests/test_monitor.py",
    "content": "\"\"\"Tests for MonitorService — background tracker polling.\"\"\"\n\nfrom __future__ import annotations\n\nimport pytest\n\nfrom maggy.services.monitor import (\n    MonitorConfig,\n    MonitorService,\n)\n\n\n@pytest.fixture()\ndef svc(tmp_path):\n    return MonitorService(tmp_path / \"monitors.db\")\n\n\ndef test_add_and_list(svc):\n    \"\"\"Adding a monitor config makes it listable.\"\"\"\n    cfg = MonitorConfig(project_key=\"protaige\", provider=\"github\")\n    svc.add(cfg)\n    active = svc.list_active()\n    assert len(active) == 1\n    assert active[0].project_key == \"protaige\"\n\n\ndef test_remove(svc):\n    \"\"\"Removing a monitor clears it from active list.\"\"\"\n    svc.add(MonitorConfig(project_key=\"zenloop\", provider=\"asana\"))\n    svc.remove(\"zenloop\")\n    assert svc.list_active() == []\n\n\ndef test_is_new_unseen(svc):\n    \"\"\"Unseen event IDs are detected as new.\"\"\"\n    assert svc.is_new(\"PR-42\", \"protaige\") is True\n\n\ndef test_mark_seen_not_new(svc):\n    \"\"\"After marking seen, event is no longer new.\"\"\"\n    svc.mark_seen(\"PR-42\", \"protaige\")\n    assert svc.is_new(\"PR-42\", \"protaige\") is False\n\n\ndef test_add_duplicate_updates(svc):\n    \"\"\"Adding same project_key twice updates, not duplicates.\"\"\"\n    svc.add(MonitorConfig(project_key=\"x\", provider=\"github\"))\n    svc.add(MonitorConfig(project_key=\"x\", provider=\"asana\"))\n    active = svc.list_active()\n    assert len(active) == 1\n    assert active[0].provider == \"asana\"\n\n\ndef test_default_interval(svc):\n    \"\"\"Default poll interval is 300 seconds.\"\"\"\n    cfg = MonitorConfig(project_key=\"p\", provider=\"github\")\n    svc.add(cfg)\n    assert svc.list_active()[0].interval_seconds == 300\n\n\ndef test_status_summary(svc):\n    \"\"\"Status returns dict with counts.\"\"\"\n    svc.add(MonitorConfig(project_key=\"a\", provider=\"github\"))\n    svc.add(MonitorConfig(project_key=\"b\", provider=\"asana\"))\n    status = svc.status()\n    assert status[\"active\"] == 2\n\n\n@pytest.mark.asyncio()\nasync def test_poll_github_prs(svc, monkeypatch):\n    \"\"\"Poll detects new GitHub PRs via httpx mock.\"\"\"\n    import httpx\n\n    cfg = MonitorConfig(\n        project_key=\"protaige\", provider=\"github\",\n        poll_command=\"alinaqi/AI-Playground\",\n    )\n\n    class FakeResp:\n        status_code = 200\n        def json(self):\n            return [\n                {\"number\": 1, \"title\": \"Add auth\",\n                 \"html_url\": \"https://github.com/x/1\"},\n            ]\n\n    async def fake_get(self, url, **kw):\n        return FakeResp()\n\n    monkeypatch.setattr(httpx.AsyncClient, \"get\", fake_get)\n    events = await svc.poll(cfg)\n    assert len(events) == 1\n    assert events[0].title == \"Add auth\"\n"
  },
  {
    "path": "maggy/tests/test_multimodel_integration.py",
    "content": "\"\"\"Integration test — small project with tasks across kimi, gpt, claude.\n\nSimulates Maggy routing a batch of tasks with varying complexity through\nthe full executor pipeline, verifying each lands on the correct model\nand that budget/fallback/checkpoint systems work end-to-end.\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom unittest.mock import AsyncMock, MagicMock\n\nimport pytest\n\nfrom maggy.adapters.pi import PiAdapter, RunResult\nfrom maggy.budget import BudgetManager, TaskSpendTracker\nfrom maggy.checkpoint import CheckpointManager\nfrom maggy.config import (\n    CodebaseConfig,\n    MaggyConfig,\n    OrgConfig,\n    ProjectConfig,\n    StorageConfig,\n)\nfrom maggy.coordination.lock_manager import LockManager\nfrom maggy.mnemos.fatigue import FatigueTracker\nfrom maggy.providers.base import Task\nfrom maggy.routing import RoutingContext, RoutingService\nfrom maggy.services.executor import ExecutorService\nfrom maggy.services.executor_types import SessionCtx\nfrom maggy.services.planner import DualPlanner\n\n\n# -- helpers ---------------------------------------------------------------\n\ndef _project_cfg(tmp_path) -> MaggyConfig:\n    return MaggyConfig(\n        org=OrgConfig(name=\"acme\"),\n        storage=StorageConfig(path=str(tmp_path / \"store.db\")),\n        codebases=[\n            CodebaseConfig(path=str(tmp_path / \"repo\"), key=\"webapp\"),\n        ],\n        projects=[\n            ProjectConfig(\n                name=\"webapp\",\n                repo=\"acme/webapp\",\n                path=str(tmp_path / \"repo\"),\n                default_branch=\"main\",\n            ),\n        ],\n    )\n\n\ndef _task(blast: int, ttype: str, title: str) -> Task:\n    return Task(\n        id=f\"TASK-{blast}\",\n        title=title,\n        description=f\"A {ttype} task with blast={blast}.\",\n        raw={\n            \"blast_score\": blast,\n            \"task_type\": ttype,\n            \"security_sensitive\": ttype == \"security\",\n        },\n    )\n\n\nTASKS = [\n    _task(1, \"docs\", \"Update README typo\"),\n    _task(2, \"formatting\", \"Fix lint warnings\"),\n    _task(5, \"feature\", \"Add pagination to API\"),\n    _task(7, \"refactor\", \"Extract auth middleware\"),\n    _task(9, \"security\", \"Patch XSS in comments\"),\n]\n\n\n# -- 1. Routing decisions --------------------------------------------------\n\nclass TestRoutingDecisions:\n    \"\"\"Verify correct model selection per complexity.\"\"\"\n\n    def test_low_blast_routes_to_cheap_tier(self, tmp_path):\n        cfg = _project_cfg(tmp_path)\n        svc = RoutingService(cfg)\n        for blast in (1, 2):\n            # Use \"formatting\" — \"docs\" is now rules-overridden\n            ctx = RoutingContext(blast_score=blast, task_type=\"formatting\")\n            decision = svc.route(ctx)\n            assert decision.primary.cost_rank <= 2, (\n                f\"blast={blast} should route to cheap tier\"\n            )\n            assert decision.primary.name in (\"local\", \"kimi\")\n\n    def test_mid_blast_routes_to_cheapest_capable(self, tmp_path):\n        cfg = _project_cfg(tmp_path)\n        svc = RoutingService(cfg)\n        ctx = RoutingContext(blast_score=5, task_type=\"feature\")\n        decision = svc.route(ctx)\n        assert decision.primary.name in (\"local\", \"codex\")\n\n    def test_blast_6_routes_to_codex(self, tmp_path):\n        cfg = _project_cfg(tmp_path)\n        svc = RoutingService(cfg)\n        ctx = RoutingContext(blast_score=6, task_type=\"feature\")\n        decision = svc.route(ctx)\n        assert decision.primary.name == \"codex\"\n\n    def test_high_blast_routes_to_codex_or_claude(self, tmp_path):\n        cfg = _project_cfg(tmp_path)\n        svc = RoutingService(cfg)\n        ctx = RoutingContext(blast_score=9, task_type=\"refactor\")\n        decision = svc.route(ctx)\n        assert decision.primary.name in (\"codex\", \"claude\")\n\n    def test_security_routes_to_claude(self, tmp_path):\n        cfg = _project_cfg(tmp_path)\n        svc = RoutingService(cfg)\n        ctx = RoutingContext(\n            blast_score=3, task_type=\"security\",\n            security_sensitive=True,\n        )\n        decision = svc.route(ctx)\n        # Security rule override → claude\n        name = decision.primary if isinstance(\n            decision.primary, str,\n        ) else decision.primary.name\n        assert name == \"claude\"\n\n\n# -- 2. Full executor pipeline with mocked models -------------------------\n\nclass TestExecutorPipeline:\n    \"\"\"End-to-end executor routing with fake model responses.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_distributes_across_models(self, tmp_path):\n        cfg = _project_cfg(tmp_path)\n        (tmp_path / \"repo\").mkdir()\n        provider = AsyncMock()\n        executor = ExecutorService(cfg, provider)\n\n        calls: list[str] = []\n\n        async def fake_send(\n            model_name, prompt, wd, max_turns=20, timeout=600,\n        ):\n            calls.append(model_name)\n            return RunResult(\n                model=model_name, success=True,\n                output=\"done\", cost_usd=0.10,\n            )\n\n        async def fake_ctx(cfg, task):\n            return \"\"\n\n        executor._pi.send_prompt = fake_send\n        from maggy.services import executor_helpers\n        executor_helpers.build_icpg_context = fake_ctx\n\n        for task in TASKS:\n            sid = f\"s-{task.id}\"\n            session = {\n                \"id\": sid, \"task_id\": task.id,\n                \"task_title\": task.title, \"mode\": \"plan\",\n                \"working_dir\": str(tmp_path / \"repo\"),\n                \"status\": \"running\", \"started_at\": \"\",\n                \"output\": \"\",\n            }\n            executor._sessions[sid] = session\n            ctx = SessionCtx(session, task, str(tmp_path / \"repo\"))\n            await executor._run(ctx, \"plan\")\n\n        # Verify each complexity tier used a different model\n        cheap = {\"local\", \"kimi\"}\n        assert cheap & set(calls), \"Low-blast should use cheap tier\"\n        assert \"codex\" in calls, \"Mid-blast should use codex\"\n        assert \"claude\" in calls, \"Security should use claude\"\n        assert len(set(calls)) >= 3, (\n            f\"Expected >= 3 distinct models, got {set(calls)}\"\n        )\n\n\n# -- 3. Budget tracking across providers ----------------------------------\n\nclass TestCrossProviderBudget:\n    def test_spend_tracked_per_provider(self, tmp_path):\n        cfg = _project_cfg(tmp_path)\n        bm = BudgetManager(cfg)\n        bm.record_spend(\"moonshot\", \"kimi-k2\", 0.05)\n        bm.record_spend(\"openai\", \"gpt-4o\", 0.30)\n        bm.record_spend(\"anthropic\", \"claude-sonnet-4\", 1.20)\n\n        breakdown = bm.by_provider()\n        providers = {r[\"provider\"] for r in breakdown}\n        assert providers == {\"moonshot\", \"openai\", \"anthropic\"}\n\n    def test_task_spend_halts_at_limit(self):\n        tracker = TaskSpendTracker(max_spend=1.0)\n        tracker.record(0.3)\n        tracker.record(0.3)\n        tracker.record(0.5)\n        assert tracker.is_exceeded()\n        assert tracker.total() == pytest.approx(1.1)\n\n\n# -- 4. Fallback chain on quota -------------------------------------------\n\nclass TestFallbackChain:\n    @pytest.mark.asyncio\n    async def test_falls_back_on_failure(self):\n        pi = PiAdapter()\n        calls: list[str] = []\n\n        async def fake_send(\n            model_name, prompt, wd, max_turns=20, timeout=600,\n        ):\n            calls.append(model_name)\n            if model_name in (\"kimi\", \"deepseek\"):\n                return RunResult(\n                    model=model_name, success=False,\n                    error=\"quota\", quota_hit=True,\n                )\n            return RunResult(\n                model=model_name, success=True, output=\"ok\",\n            )\n\n        pi.send_prompt = fake_send\n        result = await pi.send_with_fallback(\n            \"kimi\", \"test prompt\", \"/tmp\",\n        )\n        assert result.success\n        assert result.model != \"kimi\"\n        assert len(calls) > 1\n\n\n# -- 5. Checkpoint survives model switch -----------------------------------\n\nclass TestCheckpointHandoff:\n    def test_checkpoint_roundtrip(self, tmp_path):\n        mgr = CheckpointManager(tmp_path / \"checkpoints\")\n        mgr.write(\"session-abc\", {\n            \"goal\": \"Ship auth feature\",\n            \"constraints\": [\"Keep tests green\"],\n            \"progress\": [\"Step 1 done by kimi\"],\n            \"model_history\": [\"kimi\", \"claude\"],\n            \"current_subgoal\": \"Write integration tests\",\n            \"fatigue_score\": 0.35,\n        })\n        data = mgr.read(\"session-abc\")\n        assert data is not None\n        assert data[\"goal\"] == \"Ship auth feature\"\n        assert data[\"model_history\"] == [\"kimi\", \"claude\"]\n        assert data[\"fatigue_score\"] == 0.35\n\n\n# -- 6. Dual planning uses different models --------------------------------\n\nclass TestDualPlanning:\n    @pytest.mark.asyncio\n    async def test_plan_and_review_use_separate_models(self):\n        models_used: list[str] = []\n        pi = MagicMock()\n\n        async def fake_send(model, prompt, wd, turns=5):\n            models_used.append(model)\n            return RunResult(\n                model=model, success=True, output=\"plan output\",\n            )\n\n        pi.send_prompt = fake_send\n        planner = DualPlanner(pi)\n        result = await planner.dual_plan(\n            \"Add OAuth\", \"Implement OAuth2 flow\", \"/tmp\",\n        )\n        assert \"claude\" in models_used\n        assert \"codex\" in models_used\n        assert result.primary_plan == \"plan output\"\n\n\n# -- 7. Fatigue tracks model switches --------------------------------------\n\nclass TestFatigueAcrossModels:\n    def test_model_switch_increases_fatigue(self):\n        tracker = FatigueTracker(context_window=200_000)\n        tracker.record(\"context_load\", 0.3)\n        tracker.record(\"reread_ratio\", 0.2)\n        assert tracker.state() == \"ok\"\n\n        tracker.on_model_switch(128_000)\n        assert tracker.context_window == 128_000\n        assert tracker.dimensions[\"reread_ratio\"] == 0.35\n\n        tracker.on_model_switch(128_000)\n        assert tracker.dimensions[\"reread_ratio\"] == 0.50\n\n\n# -- 8. Lock coordination between agents -----------------------------------\n\nclass TestLockCoordination:\n    def test_agents_cant_clobber_each_other(self, tmp_path):\n        locks = LockManager(tmp_path / \"locks.db\")\n        assert locks.acquire(\"src/auth.py\", \"kimi-agent\")\n        assert not locks.acquire(\"src/auth.py\", \"claude-agent\")\n        assert locks.acquire(\"src/api.py\", \"claude-agent\")\n        conflicts = locks.conflicts([\"src/auth.py\", \"src/api.py\"])\n        assert \"src/auth.py\" in conflicts\n        assert \"src/api.py\" in conflicts\n        locks.release(\"src/auth.py\", \"kimi-agent\")\n        assert locks.acquire(\"src/auth.py\", \"claude-agent\")\n"
  },
  {
    "path": "maggy/tests/test_observability.py",
    "content": "\"\"\"Tests for observability signal collection.\"\"\"\n\nfrom __future__ import annotations\n\nfrom maggy.observability import ObservabilityCollector\n\n\ndef test_records_and_reads_recent_signals(tmp_path) -> None:\n    collector = ObservabilityCollector(tmp_path / \"signals.db\")\n    collector.record_signal(\"maggy\", \"fatigue\", 0.4)\n    collector.record_signal(\"maggy\", \"budget\", 0.9)\n\n    rows = collector.recent_signals(\"maggy\")\n\n    assert len(rows) == 2\n    assert rows[0][\"signal_type\"] == \"budget\"\n    assert rows[1][\"signal_type\"] == \"fatigue\"\n\n\ndef test_limits_recent_signals(tmp_path) -> None:\n    collector = ObservabilityCollector(tmp_path / \"signals.db\")\n    collector.record_signal(\"maggy\", \"fatigue\", 0.2)\n    collector.record_signal(\"maggy\", \"fatigue\", 0.5)\n\n    rows = collector.recent_signals(\"maggy\", limit=1)\n\n    assert len(rows) == 1\n    assert rows[0][\"value\"] == 0.5\n"
  },
  {
    "path": "maggy/tests/test_output_reviewer.py",
    "content": "\"\"\"Tests for inter-task output reviewer.\"\"\"\n\nfrom __future__ import annotations\n\nimport pytest\n\nfrom maggy.services.output_reviewer import (\n    _parse_review,\n    review_output,\n)\n\n\nclass TestParseReview:\n    def test_parses_score_and_reason(self):\n        text = \"SCORE: 4\\nREASON: Clean implementation\"\n        result = _parse_review(text)\n        assert result.score == 4\n        assert result.reason == \"Clean implementation\"\n\n    def test_parses_score_only(self):\n        result = _parse_review(\"SCORE: 2\")\n        assert result.score == 2\n        assert result.reason == \"\"\n\n    def test_no_score_returns_default(self):\n        result = _parse_review(\"No structured output here\")\n        assert result.score == 3\n        assert result.reason == \"\"\n\n    def test_score_out_of_range_clamped(self):\n        assert _parse_review(\"SCORE: 0\").score == 1\n        assert _parse_review(\"SCORE: 8\").score == 5\n\n    def test_score_from_inline_text(self):\n        result = _parse_review(\"The output is fine. SCORE: 5\")\n        assert result.score == 5\n\n\nclass TestReviewOutput:\n    @pytest.mark.asyncio\n    async def test_returns_review_result(self):\n        async def fake_send(\n            model_name, prompt, wd, max_turns=20, timeout=600,\n        ):\n            from maggy.adapters.pi import RunResult\n            return RunResult(\n                model=model_name, success=True,\n                output=\"SCORE: 4\\nREASON: Looks good\",\n            )\n\n        from maggy.adapters.pi import PiAdapter\n        pi = PiAdapter()\n        pi.send_prompt = fake_send\n        result = await review_output(pi, \"ANALYZE\", \"some output\", \"/tmp\")\n        assert result.score == 4\n        assert \"Looks good\" in result.reason\n\n    @pytest.mark.asyncio\n    async def test_failure_returns_passthrough(self):\n        async def fake_send(\n            model_name, prompt, wd, max_turns=20, timeout=600,\n        ):\n            from maggy.adapters.pi import RunResult\n            return RunResult(\n                model=model_name, success=False,\n                error=\"model unavailable\",\n            )\n\n        from maggy.adapters.pi import PiAdapter\n        pi = PiAdapter()\n        pi.send_prompt = fake_send\n        result = await review_output(pi, \"IMPLEMENT\", \"output\", \"/tmp\")\n        assert result.score == 3\n        assert result.reason == \"review unavailable\"\n\n    @pytest.mark.asyncio\n    async def test_exception_returns_passthrough(self):\n        async def fake_send(\n            model_name, prompt, wd, max_turns=20, timeout=600,\n        ):\n            raise OSError(\"connection failed\")\n\n        from maggy.adapters.pi import PiAdapter\n        pi = PiAdapter()\n        pi.send_prompt = fake_send\n        result = await review_output(pi, \"ANALYZE\", \"output\", \"/tmp\")\n        assert result.score == 3\n\n    @pytest.mark.asyncio\n    async def test_uses_local_model(self):\n        models_used: list[str] = []\n\n        async def fake_send(\n            model_name, prompt, wd, max_turns=20, timeout=600,\n        ):\n            models_used.append(model_name)\n            from maggy.adapters.pi import RunResult\n            return RunResult(\n                model=model_name, success=True,\n                output=\"SCORE: 4\\nREASON: ok\",\n            )\n\n        from maggy.adapters.pi import PiAdapter\n        pi = PiAdapter()\n        pi.send_prompt = fake_send\n        await review_output(pi, \"ANALYZE\", \"output\", \"/tmp\")\n        assert models_used == [\"local\"]\n\n    @pytest.mark.asyncio\n    async def test_prompt_contains_step_and_output(self):\n        prompts: list[str] = []\n\n        async def fake_send(\n            model_name, prompt, wd, max_turns=20, timeout=600,\n        ):\n            prompts.append(prompt)\n            from maggy.adapters.pi import RunResult\n            return RunResult(\n                model=model_name, success=True,\n                output=\"SCORE: 3\",\n            )\n\n        from maggy.adapters.pi import PiAdapter\n        pi = PiAdapter()\n        pi.send_prompt = fake_send\n        await review_output(\n            pi, \"WRITE TESTS\", \"test_add_user passed\", \"/tmp\",\n        )\n        assert \"WRITE TESTS\" in prompts[0]\n        assert \"test_add_user passed\" in prompts[0]\n"
  },
  {
    "path": "maggy/tests/test_pi_adapter.py",
    "content": "\"\"\"Tests for PiAdapter — model registry, fallback, quota detection.\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nfrom unittest.mock import MagicMock, patch\n\nimport pytest\n\nfrom maggy.adapters.pi import (\n    ModelEntry,\n    PiAdapter,\n)\n\n\nclass TestModelRegistry:\n    def test_default_models_loaded(self):\n        adapter = PiAdapter()\n        assert len(adapter.list_models()) == 6\n\n    def test_get_known_model(self):\n        adapter = PiAdapter()\n        m = adapter.get_model(\"claude\")\n        assert m is not None\n        assert m.provider == \"anthropic\"\n\n    def test_get_unknown_returns_none(self):\n        adapter = PiAdapter()\n        assert adapter.get_model(\"nonexistent\") is None\n\n    def test_custom_models(self):\n        custom = [\n            ModelEntry(\"test\", \"local\", \"t1\", \"cheap\", 0.0),\n        ]\n        adapter = PiAdapter(models=custom)\n        assert len(adapter.list_models()) == 1\n        assert adapter.get_model(\"test\") is not None\n\n\nclass TestFallbackChain:\n    def test_chain_excludes_start(self):\n        adapter = PiAdapter()\n        chain = adapter.fallback_chain(\"kimi\")\n        assert \"kimi\" not in chain\n\n    def test_chain_ordered_by_cost(self):\n        adapter = PiAdapter()\n        chain = adapter.fallback_chain(\"kimi\")\n        assert len(chain) > 0\n\n    def test_unknown_start_returns_all(self):\n        adapter = PiAdapter()\n        chain = adapter.fallback_chain(\"nonexistent\")\n        assert len(chain) == 6\n\n\nclass TestQuotaDetection:\n    def test_detects_rate_limit(self):\n        adapter = PiAdapter()\n        assert adapter._detect_quota(\"Error: rate limit exceeded\")\n\n    def test_detects_429(self):\n        adapter = PiAdapter()\n        assert adapter._detect_quota(\"HTTP 429 Too Many Requests\")\n\n    def test_clean_output_no_quota(self):\n        adapter = PiAdapter()\n        assert not adapter._detect_quota(\"Task completed.\")\n\n\nclass TestBuildCommand:\n    def test_claude_command_format(self):\n        adapter = PiAdapter()\n        model = adapter.get_model(\"claude\")\n        cmd = adapter._build_command(model, \"hello\", 5, \"/tmp\")\n        assert \"claude\" in cmd[0]\n        assert \"-p\" in cmd\n        assert \"--dangerously-skip-permissions\" in cmd\n\n    def test_non_claude_command(self):\n        entry = ModelEntry(\n            \"test\", \"local\", \"m1\", \"cheap\",\n            cli_command=\"kimi\",\n        )\n        adapter = PiAdapter(models=[entry])\n        cmd = adapter._build_command(entry, \"hello\", 5, \"/tmp\")\n        assert \"kimi\" in cmd[0]\n        assert \"--dangerously-skip-permissions\" not in cmd\n\n\nclass _FakeStream:\n    def __init__(self, lines: list[str]):\n        self._lines = list(lines)\n        self.writes: list[str] = []\n\n    def readline(self) -> str:\n        if self._lines:\n            return self._lines.pop(0)\n        return \"\"\n\n    def write(self, text: str) -> None:\n        self.writes.append(text)\n\n    def flush(self) -> None:\n        return None\n\n\nclass _FakeProcess:\n    def __init__(self, stdout_lines: list[str]):\n        self.stdin = _FakeStream([])\n        self.stdout = _FakeStream(stdout_lines)\n\n\nclass TestRpcMode:\n    def test_detect_pi_uses_path_lookup(self):\n        adapter = PiAdapter()\n        with patch(\"maggy.adapters.pi.shutil.which\", return_value=\"/bin/pi\"):\n            assert adapter._detect_pi() is True\n\n    def test_send_rpc_serializes_command(self):\n        adapter = PiAdapter()\n        proc = _FakeProcess(['{\"ok\": true}\\n'])\n        with patch(\"maggy.adapters.pi.subprocess.Popen\", return_value=proc):\n            result = adapter.send_rpc({\"command\": \"ping\"})\n        assert result == {\"ok\": True}\n        assert proc.stdin.writes == ['{\"command\":\"ping\"}\\n']\n\n    def test_switch_model_uses_rpc(self):\n        adapter = PiAdapter()\n        adapter.send_rpc = MagicMock(return_value={\"ok\": True})\n        changed = adapter.switch_model(\"anthropic\", \"claude-sonnet-4\")\n        assert changed is True\n        adapter.send_rpc.assert_called_once_with(\n            {\n                \"command\": \"set_model\",\n                \"provider\": \"anthropic\",\n                \"model\": \"claude-sonnet-4\",\n            }\n        )\n\nclass TestPromptResult:\n    def test_parses_json_output(self):\n        adapter = PiAdapter()\n        payload = json.dumps({\n            \"result\": \"All tests pass\",\n            \"cost_usd\": 0.05,\n            \"usage\": {\"input_tokens\": 1500, \"output_tokens\": 800},\n        })\n        r = adapter._prompt_result(\"claude\", 0, payload.encode())\n        assert r.success is True\n        assert r.output == \"All tests pass\"\n        assert r.cost_usd == 0.05\n        assert r.input_tokens == 1500\n        assert r.output_tokens == 800\n\n    def test_plain_text_fallback(self):\n        adapter = PiAdapter()\n        r = adapter._prompt_result(\"local\", 0, b\"Just text output\")\n        assert r.success is True\n        assert r.output == \"Just text output\"\n        assert r.cost_usd == 0.0\n        assert r.input_tokens == 0\n\n    def test_json_error_preserves_usage(self):\n        adapter = PiAdapter()\n        payload = json.dumps({\n            \"result\": \"Error occurred\",\n            \"cost_usd\": 0.01,\n            \"usage\": {\"input_tokens\": 500, \"output_tokens\": 100},\n        })\n        r = adapter._prompt_result(\"claude\", 1, payload.encode())\n        assert r.success is False\n        assert r.cost_usd == 0.01\n        assert r.input_tokens == 500\n\n\nclass TestStreaming:\n    @pytest.mark.asyncio\n    async def test_stream_events_reads_jsonl(self):\n        adapter = PiAdapter()\n        adapter._rpc_process = _FakeProcess(\n            ['{\"type\":\"start\"}\\n', '{\"type\":\"done\"}\\n', \"\"]\n        )\n        events = []\n        async for event in adapter.stream_events():\n            events.append(event)\n        assert events == [{\"type\": \"start\"}, {\"type\": \"done\"}]\n"
  },
  {
    "path": "maggy/tests/test_planning.py",
    "content": "\"\"\"Tests for dual-model planning orchestrator.\"\"\"\n\nfrom __future__ import annotations\n\nfrom maggy.models.plan import Plan, PlanDiff, PlanStep\nfrom maggy.planning import (\n    DUAL_PLAN_THRESHOLD,\n    PlanRequest,\n    PlanningService,\n    _similar,\n)\n\n\nclass TestPlanModels:\n    def test_plan_step_count(self):\n        p = Plan(\n            task=\"test\", model=\"claude\",\n            steps=[\n                PlanStep(description=\"step 1\"),\n                PlanStep(description=\"step 2\"),\n            ],\n        )\n        assert p.step_count == 2\n\n    def test_plan_diff_agreement_ratio(self):\n        d = PlanDiff(\n            agreed=[\"a\", \"b\"],\n            conflicts=[],\n            primary_only=[\"c\"],\n            counter_only=[],\n        )\n        assert d.agreement_ratio == 2 / 3\n\n    def test_plan_diff_empty(self):\n        d = PlanDiff()\n        assert d.agreement_ratio == 1.0\n        assert d.conflict_count == 0\n\n\nclass TestPlanningService:\n    def test_below_threshold_single_plan(self, mock_cfg):\n        svc = PlanningService(mock_cfg)\n        req = PlanRequest(task=\"fix typo\", blast_score=2)\n        result = svc.plan_task(req)\n        assert result[\"mode\"] == \"single\"\n        assert result[\"diff\"] is None\n\n    def test_above_threshold_dual_plan(self, mock_cfg):\n        svc = PlanningService(mock_cfg)\n        req = PlanRequest(\n            task=\"refactor auth\", blast_score=6,\n        )\n        result = svc.plan_task(req)\n        assert result[\"mode\"] == \"dual\"\n        assert result[\"diff\"] is not None\n\n    def test_generate_plan(self, mock_cfg):\n        svc = PlanningService(mock_cfg)\n        plan = svc.generate_plan(\"add feature\", \"claude\")\n        assert plan.task == \"add feature\"\n        assert plan.model == \"claude\"\n        assert plan.step_count >= 1\n\n    def test_diff_plans_identical(self, mock_cfg):\n        svc = PlanningService(mock_cfg)\n        p1 = svc.generate_plan(\"task\", \"claude\")\n        p2 = svc.generate_plan(\"task\", \"codex\")\n        diff = svc.diff_plans(p1, p2)\n        assert len(diff.agreed) == 3\n\n    def test_should_dual_plan_boundary(self, mock_cfg):\n        svc = PlanningService(mock_cfg)\n        assert not svc.should_dual_plan(3)\n        assert svc.should_dual_plan(4)\n        assert svc.should_dual_plan(10)\n\n\nclass TestSimilarity:\n    def test_similar_strings(self):\n        assert _similar(\n            \"Implement auth module\",\n            \"Implement auth service\",\n        )\n\n    def test_dissimilar_strings(self):\n        assert not _similar(\n            \"Add login button\",\n            \"Fix database query\",\n        )\n\n    def test_empty_string(self):\n        assert not _similar(\"\", \"hello\")\n"
  },
  {
    "path": "maggy/tests/test_registry.py",
    "content": "\"\"\"Tests for project registry and project config parsing.\"\"\"\n\nfrom __future__ import annotations\n\nfrom maggy.config import MaggyConfig, ProjectConfig, _from_dict\nfrom maggy.registry import ProjectRegistry\n\n\nclass TestProjectConfigParsing:\n    def test_from_dict_parses_projects(self):\n        cfg = _from_dict({\n            \"projects\": [\n                {\n                    \"name\": \"alpha\",\n                    \"repo\": \"acme/alpha\",\n                    \"path\": \"~/code/alpha\",\n                    \"default_branch\": \"main\",\n                },\n                {\n                    \"name\": \"beta\",\n                    \"repo\": \"acme/beta\",\n                    \"path\": \"~/code/beta\",\n                    \"default_branch\": \"develop\",\n                    \"icpg\": False,\n                    \"cikg\": True,\n                },\n            ],\n        })\n        assert [project.name for project in cfg.projects] == [\"alpha\", \"beta\"]\n        assert cfg.projects[0].icpg is True\n        assert cfg.projects[0].cikg is False\n        assert cfg.projects[1].default_branch == \"develop\"\n        assert cfg.projects[1].icpg is False\n        assert cfg.projects[1].cikg is True\n\n\nclass TestProjectRegistry:\n    def test_registry_crud(self):\n        alpha = ProjectConfig(\n            name=\"alpha\",\n            repo=\"acme/alpha\",\n            path=\"/tmp/alpha\",\n            default_branch=\"main\",\n        )\n        beta = ProjectConfig(\n            name=\"beta\",\n            repo=\"acme/beta\",\n            path=\"/tmp/beta\",\n            default_branch=\"develop\",\n        )\n        registry = ProjectRegistry(MaggyConfig(projects=[alpha]))\n        assert registry.list() == [alpha]\n        assert registry.get(\"alpha\") == alpha\n        registry.add(beta)\n        assert registry.get(\"beta\") == beta\n        assert registry.remove(\"alpha\") is True\n        assert registry.get(\"alpha\") is None\n        assert registry.remove(\"alpha\") is False\n\n    def test_add_duplicate_raises(self):\n        import pytest\n        alpha = ProjectConfig(\n            name=\"alpha\",\n            repo=\"acme/alpha\",\n            path=\"/tmp/alpha\",\n            default_branch=\"main\",\n        )\n        registry = ProjectRegistry(MaggyConfig(projects=[alpha]))\n        with pytest.raises(ValueError, match=\"already exists\"):\n            registry.add(alpha)\n"
  },
  {
    "path": "maggy/tests/test_repl_cmds.py",
    "content": "\"\"\"Tests for REPL slash command handlers.\"\"\"\n\nfrom __future__ import annotations\n\nfrom dataclasses import dataclass, field\nfrom unittest.mock import MagicMock\n\nfrom maggy.cli_repl_cmds import (\n    cmd_budget,\n    cmd_claude_md,\n    cmd_help,\n    cmd_models,\n    cmd_route,\n    cmd_stats,\n    cmd_use,\n    dispatch,\n)\n\n\n@dataclass\nclass FakeState:\n    working_dir: str = \"/tmp/proj\"\n    session_id: str = \"s1\"\n    allowed_models: list[str] = field(default_factory=list)\n\n\ndef _mock_client():\n    c = MagicMock()\n    c.budget_summary.return_value = {\n        \"spent_today_usd\": 1.5,\n        \"daily_limit_usd\": 10.0,\n        \"status\": \"ok\",\n        \"input_tokens\": 12500,\n        \"output_tokens\": 3400,\n    }\n    c.budget_by_provider.return_value = [\n        {\"provider\": \"anthropic\", \"spent_usd\": 1.2},\n        {\"provider\": \"openai\", \"spent_usd\": 0.3},\n    ]\n    c.models_heatmap.return_value = [\n        {\"model\": \"claude\", \"task_type\": \"security\",\n         \"avg_reward\": 0.95, \"samples\": 10},\n    ]\n    c.routing_rules.return_value = {\n        \"mode\": \"dynamic\",\n        \"task_type_overrides\": {\n            \"security\": {\"model\": \"claude\", \"reason\": \"deep\"},\n        },\n        \"model_performance\": {\n            \"claude\": {\"success_rate\": 1.0, \"strengths\": [\"security\"]},\n        },\n    }\n    c.config.return_value = {\n        \"codebases\": [{\"key\": \"proj\", \"path\": \"/tmp/proj\"}],\n        \"routing\": {\"mode\": \"dynamic\"},\n        \"budget\": {\"daily_limit_usd\": 10.0},\n    }\n    return c\n\n\ndef test_dispatch_stats(capsys):\n    \"\"\"'/stats' dispatches to stats handler.\"\"\"\n    client = _mock_client()\n    state = FakeState()\n    handled = dispatch(\"/stats\", client, state)\n    assert handled is True\n\n\ndef test_dispatch_unknown():\n    \"\"\"Unknown commands return False.\"\"\"\n    handled = dispatch(\"/xyz123\", MagicMock(), FakeState())\n    assert handled is False\n\n\ndef test_cmd_stats(capsys):\n    \"\"\"Stats shows budget and model perf.\"\"\"\n    cmd_stats(_mock_client())\n    out = capsys.readouterr().out\n    assert \"1.5\" in out or \"budget\" in out.lower()\n\n\ndef test_cmd_budget(capsys):\n    \"\"\"Budget shows per-provider breakdown.\"\"\"\n    cmd_budget(_mock_client())\n    out = capsys.readouterr().out\n    assert \"anthropic\" in out or \"1.2\" in out\n\n\ndef test_cmd_route(capsys):\n    \"\"\"Route shows task type overrides.\"\"\"\n    cmd_route(_mock_client())\n    out = capsys.readouterr().out\n    assert \"security\" in out or \"claude\" in out\n\n\ndef test_cmd_models(capsys):\n    \"\"\"Models shows reward heatmap.\"\"\"\n    cmd_models(_mock_client())\n    out = capsys.readouterr().out\n    assert \"claude\" in out or \"0.95\" in out\n\n\ndef test_cmd_use_sets_models():\n    \"\"\"'/use claude,codex' sets allowed_models.\"\"\"\n    state = FakeState()\n    cmd_use(\"claude,codex\", state)\n    assert state.allowed_models == [\"claude\", \"codex\"]\n\n\ndef test_cmd_use_reset():\n    \"\"\"'/use all' clears allowed_models.\"\"\"\n    state = FakeState(allowed_models=[\"claude\"])\n    cmd_use(\"all\", state)\n    assert state.allowed_models == []\n\n\ndef test_cmd_claude_md_missing(capsys):\n    \"\"\"Shows message when CLAUDE.md not found.\"\"\"\n    state = FakeState(working_dir=\"/nonexistent_xyz_dir\")\n    cmd_claude_md(state)\n    out = capsys.readouterr().out\n    assert \"not found\" in out.lower() or \"no\" in out.lower()\n\n\ndef test_cmd_stats_shows_tokens(capsys):\n    \"\"\"Stats displays token counts when available.\"\"\"\n    cmd_stats(_mock_client())\n    out = capsys.readouterr().out\n    assert \"12,500\" in out\n    assert \"3,400\" in out\n\n\ndef test_cmd_route_shows_tiers(capsys):\n    \"\"\"Route displays blast tier reference.\"\"\"\n    cmd_route(_mock_client())\n    out = capsys.readouterr().out\n    assert \"cheap\" in out.lower()\n    assert \"premium\" in out.lower()\n\n\ndef test_cmd_help(capsys):\n    \"\"\"Help lists all commands.\"\"\"\n    cmd_help()\n    out = capsys.readouterr().out\n    assert \"/stats\" in out\n    assert \"/use\" in out\n    assert \"/help\" in out\n\n\ndef test_cmd_health(capsys):\n    \"\"\"Health shows engram and mnemos status.\"\"\"\n    from maggy.cli_repl_cmds import cmd_health\n    client = _mock_client()\n    client.health_dashboard.return_value = {\n        \"engram\": {\"health_score\": 0.85, \"active\": 42, \"total\": 50},\n        \"mnemos\": {\"state\": \"ok\", \"composite\": 0.3},\n    }\n    cmd_health(client)\n    out = capsys.readouterr().out\n    assert \"85%\" in out or \"0.85\" in out\n    assert \"ok\" in out.lower()\n\n\ndef test_dispatch_health(capsys):\n    \"\"\"/health dispatches to health handler.\"\"\"\n    client = _mock_client()\n    client.health_dashboard.return_value = {\n        \"engram\": {\"health_score\": 0.9, \"active\": 10, \"total\": 12},\n        \"mnemos\": {\"state\": \"ok\", \"composite\": 0.2},\n    }\n    state = FakeState()\n    handled = dispatch(\"/health\", client, state)\n    assert handled is True\n\n\ndef test_help_lists_health(capsys):\n    \"\"\"/help mentions /health command.\"\"\"\n    cmd_help()\n    out = capsys.readouterr().out\n    assert \"/health\" in out\n\n\ndef test_models_empty_shows_known(capsys):\n    \"\"\"Empty heatmap shows known model names.\"\"\"\n    from maggy.cli_repl_cmds import cmd_models\n    client = _mock_client()\n    client.models_heatmap.return_value = []\n    cmd_models(client)\n    out = capsys.readouterr().out\n    assert \"local\" in out\n    assert \"claude\" in out\n\n\ndef test_use_warns_unknown_model(capsys):\n    \"\"\"/use with unknown model name prints warning.\"\"\"\n    state = FakeState()\n    cmd_use(\"badmodel,claude\", state)\n    out = capsys.readouterr().out\n    assert \"unknown\" in out.lower() or \"Unknown\" in out\n\n\ndef test_budget_subscription_plan(capsys):\n    \"\"\"Subscription plan shows 'Subscription' instead of dollar amounts.\"\"\"\n    client = _mock_client()\n    client.budget_summary.return_value = {\n        \"spent_today_usd\": 0, \"daily_limit_usd\": 10.0,\n        \"status\": \"ok\", \"plan\": \"subscription\",\n    }\n    client.budget_by_provider.return_value = []\n    cmd_budget(client)\n    out = capsys.readouterr().out\n    assert \"subscription\" in out.lower()\n\n\ndef test_health_graceful_failure(capsys):\n    \"\"\"Health command handles server failure gracefully.\"\"\"\n    from maggy.cli_repl_cmds import cmd_health\n    client = _mock_client()\n    client.health_dashboard.side_effect = Exception(\"unreachable\")\n    cmd_health(client)\n    out = capsys.readouterr().out\n    assert \"health\" in out.lower() or out == \"\"\n\n\ndef test_stats_server_down(capsys):\n    \"\"\"Stats handles server failure gracefully.\"\"\"\n    client = _mock_client()\n    client.budget_summary.side_effect = Exception(\"unreachable\")\n    cmd_stats(client)\n    # Should not crash — may show empty or partial data\n"
  },
  {
    "path": "maggy/tests/test_rollback.py",
    "content": "\"\"\"Tests for rollback and savepoint recovery.\"\"\"\n\nfrom __future__ import annotations\n\nimport subprocess\n\nimport pytest\n\nfrom maggy.recovery.rollback import RollbackManager\n\n\ndef _git(repo, *args: str) -> None:\n    subprocess.run([\"git\", *args], cwd=repo, check=True)\n\n\ndef _init_repo(repo) -> None:\n    _git(repo, \"init\")\n    _git(repo, \"config\", \"user.email\", \"maggy@example.com\")\n    _git(repo, \"config\", \"user.name\", \"Maggy Tests\")\n    (repo / \"tracked.txt\").write_text(\"v1\\n\")\n    _git(repo, \"add\", \"tracked.txt\")\n    _git(repo, \"commit\", \"-m\", \"init\")\n\n\nclass TestRollbackManager:\n    @pytest.mark.asyncio\n    async def test_create_and_list_savepoints(self, tmp_path):\n        _init_repo(tmp_path)\n        manager = RollbackManager()\n        tag = await manager.create_savepoint(\"session-1\", str(tmp_path))\n        assert tag == \"maggy-save-session-1\"\n        assert await manager.list_savepoints(str(tmp_path)) == [tag]\n\n    @pytest.mark.asyncio\n    async def test_rollback_resets_worktree(self, tmp_path):\n        _init_repo(tmp_path)\n        manager = RollbackManager()\n        await manager.create_savepoint(\"session-1\", str(tmp_path))\n        (tmp_path / \"tracked.txt\").write_text(\"changed\\n\")\n        assert await manager.rollback(\"session-1\", str(tmp_path)) is True\n        assert (tmp_path / \"tracked.txt\").read_text() == \"v1\\n\"\n\n    @pytest.mark.asyncio\n    async def test_delete_savepoint(self, tmp_path):\n        _init_repo(tmp_path)\n        manager = RollbackManager()\n        await manager.create_savepoint(\"session-1\", str(tmp_path))\n        assert await manager.delete_savepoint(\"session-1\", str(tmp_path)) is True\n        assert await manager.list_savepoints(str(tmp_path)) == []\n"
  },
  {
    "path": "maggy/tests/test_routes_escalation.py",
    "content": "\"\"\"Tests for /api/escalations endpoints.\"\"\"\n\nfrom __future__ import annotations\n\nfrom fastapi.testclient import TestClient\n\n\ndef _app(tmp_path):\n    \"\"\"Build a minimal FastAPI app with escalation router.\"\"\"\n    from fastapi import FastAPI\n    from maggy.api.routes_escalation import router\n    from maggy.config import DashboardConfig, MaggyConfig, OrgConfig, StorageConfig\n    from maggy.escalation.protocol import Escalator\n\n    cfg = MaggyConfig(\n        org=OrgConfig(name=\"test\"),\n        storage=StorageConfig(path=str(tmp_path / \"store.db\")),\n        dashboard=DashboardConfig(),\n    )\n    app = FastAPI()\n    app.state.cfg = cfg\n    app.state.escalator = Escalator(tmp_path / \"esc.db\")\n    app.include_router(router)\n    return app\n\n\ndef test_list_pending_empty(tmp_path):\n    client = TestClient(_app(tmp_path))\n    resp = client.get(\"/api/escalations\")\n    assert resp.status_code == 200\n    assert resp.json() == []\n\n\ndef test_create_and_list(tmp_path):\n    client = TestClient(_app(tmp_path))\n    body = {\n        \"session_id\": \"sess-1\",\n        \"reason\": \"test failure\",\n        \"context\": {\"task_id\": \"T-1\"},\n    }\n    resp = client.post(\"/api/escalations\", json=body)\n    assert resp.status_code == 201\n    esc_id = resp.json()[\"id\"]\n\n    resp = client.get(\"/api/escalations\")\n    ids = [e[\"id\"] for e in resp.json()]\n    assert esc_id in ids\n\n\ndef test_resolve_escalation(tmp_path):\n    client = TestClient(_app(tmp_path))\n    body = {\n        \"session_id\": \"sess-2\",\n        \"reason\": \"stuck\",\n        \"context\": {},\n    }\n    resp = client.post(\"/api/escalations\", json=body)\n    esc_id = resp.json()[\"id\"]\n\n    resp = client.post(\n        f\"/api/escalations/{esc_id}/resolve\",\n        json={\"guidance\": \"retry with claude\"},\n    )\n    assert resp.status_code == 200\n    assert resp.json()[\"status\"] == \"resolved\"\n\n    resp = client.get(\"/api/escalations\")\n    assert resp.json() == []\n\n\ndef test_resolve_not_found(tmp_path):\n    client = TestClient(_app(tmp_path))\n    resp = client.post(\n        \"/api/escalations/bad-id/resolve\",\n        json={\"guidance\": \"n/a\"},\n    )\n    assert resp.status_code == 404\n"
  },
  {
    "path": "maggy/tests/test_routes_observability.py",
    "content": "\"\"\"Tests for /api/observability endpoints.\"\"\"\n\nfrom __future__ import annotations\n\nfrom fastapi.testclient import TestClient\n\n\ndef _app(tmp_path):\n    \"\"\"Build a minimal FastAPI app with observability router.\"\"\"\n    from fastapi import FastAPI\n    from maggy.api.routes_observability import router\n    from maggy.config import DashboardConfig, MaggyConfig, OrgConfig, StorageConfig\n    from maggy.observability.collector import ObservabilityCollector\n\n    cfg = MaggyConfig(\n        org=OrgConfig(name=\"test\"),\n        storage=StorageConfig(path=str(tmp_path / \"store.db\")),\n        dashboard=DashboardConfig(),\n    )\n    app = FastAPI()\n    app.state.cfg = cfg\n    app.state.observability = ObservabilityCollector(tmp_path / \"obs.db\")\n    app.include_router(router)\n    return app\n\n\ndef test_get_signals_empty(tmp_path):\n    client = TestClient(_app(tmp_path))\n    resp = client.get(\"/api/observability/signals/myproject\")\n    assert resp.status_code == 200\n    assert resp.json() == []\n\n\ndef test_record_and_read(tmp_path):\n    client = TestClient(_app(tmp_path))\n    body = {\n        \"project\": \"webapp\",\n        \"signal_type\": \"deploy_status\",\n        \"value\": 1.0,\n    }\n    resp = client.post(\"/api/observability/record\", json=body)\n    assert resp.status_code == 201\n\n    resp = client.get(\"/api/observability/signals/webapp\")\n    signals = resp.json()\n    assert len(signals) == 1\n    assert signals[0][\"signal_type\"] == \"deploy_status\"\n"
  },
  {
    "path": "maggy/tests/test_routes_projects.py",
    "content": "\"\"\"Tests for /api/projects endpoints.\"\"\"\n\nfrom __future__ import annotations\n\nfrom fastapi.testclient import TestClient\n\nfrom maggy.registry import ProjectRegistry\n\n\ndef _app(mock_cfg):\n    \"\"\"Build a minimal FastAPI app with projects router.\"\"\"\n    from fastapi import FastAPI\n    from maggy.api.routes_projects import router\n\n    app = FastAPI()\n    app.state.cfg = mock_cfg\n    app.state.registry = ProjectRegistry(mock_cfg)\n    app.include_router(router)\n    return app\n\n\ndef test_list_projects_empty(mock_cfg):\n    client = TestClient(_app(mock_cfg))\n    resp = client.get(\"/api/projects\")\n    assert resp.status_code == 200\n    assert resp.json() == []\n\n\ndef test_add_and_list_project(mock_cfg):\n    client = TestClient(_app(mock_cfg))\n    body = {\n        \"name\": \"webapp\",\n        \"repo\": \"acme/webapp\",\n        \"path\": \"/tmp/webapp\",\n    }\n    resp = client.post(\"/api/projects\", json=body)\n    assert resp.status_code == 201\n    assert resp.json()[\"status\"] == \"created\"\n\n    resp = client.get(\"/api/projects\")\n    names = [p[\"name\"] for p in resp.json()]\n    assert \"webapp\" in names\n\n\ndef test_get_project_not_found(mock_cfg):\n    client = TestClient(_app(mock_cfg))\n    resp = client.get(\"/api/projects/nonexistent\")\n    assert resp.status_code == 404\n\n\ndef test_add_duplicate_project(mock_cfg):\n    client = TestClient(_app(mock_cfg))\n    body = {\n        \"name\": \"dup\",\n        \"repo\": \"acme/dup\",\n        \"path\": \"/tmp/dup\",\n    }\n    client.post(\"/api/projects\", json=body)\n    resp = client.post(\"/api/projects\", json=body)\n    assert resp.status_code == 409\n\n\ndef test_delete_project(mock_cfg):\n    client = TestClient(_app(mock_cfg))\n    body = {\n        \"name\": \"to-delete\",\n        \"repo\": \"acme/td\",\n        \"path\": \"/tmp/td\",\n    }\n    client.post(\"/api/projects\", json=body)\n    resp = client.delete(\"/api/projects/to-delete\")\n    assert resp.status_code == 200\n    assert resp.json()[\"status\"] == \"removed\"\n\n    resp = client.get(\"/api/projects/to-delete\")\n    assert resp.status_code == 404\n"
  },
  {
    "path": "maggy/tests/test_routing_config.py",
    "content": "\"\"\"Tests for routing config — stakes patterns, cascade policy, YAML roundtrip.\"\"\"\n\nfrom __future__ import annotations\n\nfrom pathlib import Path\n\nimport yaml\n\nfrom maggy.routing_rules import CascadePolicy\nfrom maggy.routing_rules_defaults import default_rules\nfrom maggy.routing_rules_io import load, save, to_dict\n\n\nclass TestStakesPatterns:\n    def test_default_has_high_patterns(self):\n        rules = default_rules()\n        assert \"auth\" in rules.stakes.high.file_patterns\n        assert \"security\" in rules.stakes.high.task_types\n\n    def test_default_has_medium_patterns(self):\n        rules = default_rules()\n        assert \"api\" in rules.stakes.medium.file_patterns\n        assert \"feature\" in rules.stakes.medium.task_types\n\n    def test_default_low_has_empty_patterns(self):\n        rules = default_rules()\n        assert rules.stakes.low.file_patterns == []\n\n\nclass TestCascadePolicy:\n    def test_defaults(self):\n        policy = CascadePolicy()\n        assert policy.enabled is True\n        assert policy.min_blast == 5\n        assert policy.min_stakes == \"medium\"\n        assert policy.max_attempts == 3\n        assert policy.quality_threshold == 3\n\n    def test_custom_values(self):\n        policy = CascadePolicy(\n            enabled=False, min_blast=3,\n            min_stakes=\"low\", max_attempts=5,\n        )\n        assert policy.enabled is False\n        assert policy.min_blast == 3\n\n\nclass TestYamlRoundtrip:\n    def test_roundtrip_preserves_stakes(self, tmp_path: Path):\n        rules = default_rules()\n        rules.stakes.high.file_patterns.append(\"custom_critical\")\n        save(rules, tmp_path / \"rules.yaml\")\n        loaded = load(tmp_path / \"rules.yaml\")\n        assert \"custom_critical\" in loaded.stakes.high.file_patterns\n\n    def test_roundtrip_preserves_cascade(self, tmp_path: Path):\n        rules = default_rules()\n        rules.cascade.min_blast = 7\n        save(rules, tmp_path / \"rules.yaml\")\n        loaded = load(tmp_path / \"rules.yaml\")\n        assert loaded.cascade.min_blast == 7\n\n    def test_roundtrip_preserves_conventions(self, tmp_path: Path):\n        rules = default_rules()\n        save(rules, tmp_path / \"rules.yaml\")\n        loaded = load(tmp_path / \"rules.yaml\")\n        assert len(loaded.conventions) == len(rules.conventions)\n\n    def test_user_edits_preserved(self, tmp_path: Path):\n        \"\"\"Write, manually edit YAML, reload — edits survive.\"\"\"\n        rules = default_rules()\n        path = tmp_path / \"rules.yaml\"\n        save(rules, path)\n        data = yaml.safe_load(path.read_text())\n        data[\"cascade_policy\"][\"min_blast\"] = 2\n        path.write_text(yaml.safe_dump(data, sort_keys=False))\n        loaded = load(path)\n        assert loaded.cascade.min_blast == 2\n\n    def test_missing_file_seeds_defaults(self, tmp_path: Path):\n        loaded = load(tmp_path / \"nonexistent.yaml\")\n        assert loaded.version == 1\n        assert loaded.cascade.enabled is True\n        assert \"auth\" in loaded.stakes.high.file_patterns\n\n\nclass TestToDict:\n    def test_stakes_in_output(self):\n        rules = default_rules()\n        d = to_dict(rules)\n        assert \"stakes_patterns\" in d\n        assert \"high\" in d[\"stakes_patterns\"]\n\n    def test_cascade_in_output(self):\n        rules = default_rules()\n        d = to_dict(rules)\n        assert \"cascade_policy\" in d\n        assert d[\"cascade_policy\"][\"enabled\"] is True\n\n\nclass TestDefaultTiers:\n    \"\"\"Default model tiers: no GPT, codex is primary.\"\"\"\n\n    def test_no_gpt_in_defaults(self):\n        from maggy.process.model_router import DEFAULT_TIERS\n        names = [t.name for t in DEFAULT_TIERS]\n        assert \"gpt\" not in names\n\n    def test_codex_is_primary(self):\n        from maggy.process.model_router import DEFAULT_TIERS\n        codex = [t for t in DEFAULT_TIERS if t.name == \"codex\"]\n        assert len(codex) == 1\n        assert codex[0].role == \"primary\"\n\n    def test_codex_handles_complex(self):\n        from maggy.process.model_router import DEFAULT_TIERS\n        codex = [t for t in DEFAULT_TIERS if t.name == \"codex\"][0]\n        assert codex.complexity_max >= 8\n\n    def test_local_kimi_handle_simple(self):\n        from maggy.process.model_router import DEFAULT_TIERS\n        local = [t for t in DEFAULT_TIERS if t.name == \"local\"][0]\n        kimi = [t for t in DEFAULT_TIERS if t.name == \"kimi\"][0]\n        assert local.complexity_max <= 5\n        assert kimi.complexity_max <= 5\n"
  },
  {
    "path": "maggy/tests/test_routing_rules.py",
    "content": "\"\"\"Tests for routing rules — load, save, apply, learn.\"\"\"\n\nfrom __future__ import annotations\n\nfrom pathlib import Path\n\nimport pytest\n\nfrom maggy.routing_rules import (\n    ModelOverride,\n    PerformanceRecord,\n    RoutingRules,\n    apply_override,\n    learn_override,\n    record_outcome,\n)\nfrom maggy.routing_rules_defaults import default_rules\nfrom maggy.routing_rules_io import load, save\n\n\n@pytest.fixture()\ndef rules_path(tmp_path: Path) -> Path:\n    return tmp_path / \"routing-rules.yaml\"\n\n\nclass TestDefaultRules:\n    def test_seeds_task_type_overrides(self):\n        rules = default_rules()\n        assert \"docs\" in rules.task_type_overrides\n        assert \"security\" in rules.task_type_overrides\n        assert \"tests\" in rules.task_type_overrides\n\n    def test_seeds_pipeline_phases(self):\n        rules = default_rules()\n        assert \"spec\" in rules.pipeline_phases\n        assert \"tdd_red\" in rules.pipeline_phases\n        assert rules.pipeline_phases[\"tdd_green\"].model == \"auto\"\n\n    def test_seeds_model_performance(self):\n        rules = default_rules()\n        assert \"claude\" in rules.model_performance\n        assert \"local\" in rules.model_performance\n\n\nclass TestLoadSave:\n    def test_load_creates_default(self, rules_path: Path):\n        rules = load(rules_path)\n        assert rules_path.exists()\n        assert \"docs\" in rules.task_type_overrides\n\n    def test_roundtrip(self, rules_path: Path):\n        original = default_rules()\n        save(original, rules_path)\n        loaded = load(rules_path)\n        assert loaded.version == original.version\n        assert set(loaded.task_type_overrides) == set(\n            original.task_type_overrides,\n        )\n\n    def test_load_existing(self, rules_path: Path):\n        save(default_rules(), rules_path)\n        rules = load(rules_path)\n        assert rules.task_type_overrides[\"security\"].model == \"claude\"\n\n\nclass TestApplyOverride:\n    def test_phase_takes_priority(self):\n        rules = default_rules()\n        result = apply_override(rules, \"feature\", \"spec\")\n        assert result == \"claude\"\n\n    def test_auto_phase_returns_none(self):\n        rules = default_rules()\n        result = apply_override(rules, \"feature\", \"tdd_green\")\n        assert result is None\n\n    def test_task_type_override(self):\n        rules = default_rules()\n        result = apply_override(rules, \"security\")\n        assert result == \"claude\"\n\n    def test_no_override_returns_none(self):\n        rules = default_rules()\n        result = apply_override(rules, \"feature\")\n        assert result is None\n\n    def test_low_confidence_ignored(self):\n        rules = RoutingRules(\n            task_type_overrides={\n                \"test\": ModelOverride(\"kimi\", \"weak\", 0.3),\n            },\n        )\n        result = apply_override(rules, \"test\")\n        assert result is None\n\n\nclass TestRecordOutcome:\n    def test_updates_success_rate(self, rules_path: Path):\n        rules = default_rules()\n        record_outcome(rules, \"claude\", \"feature\", True, rules_path)\n        perf = rules.model_performance[\"claude\"]\n        assert perf.tasks_completed == 7\n        assert perf.success_rate > 0.9\n\n    def test_creates_new_model(self, rules_path: Path):\n        rules = default_rules()\n        record_outcome(rules, \"gemini\", \"feature\", True, rules_path)\n        assert \"gemini\" in rules.model_performance\n        assert rules.model_performance[\"gemini\"].success_rate == 1.0\n\n    def test_records_failure(self, rules_path: Path):\n        rules = RoutingRules(\n            model_performance={\n                \"test\": PerformanceRecord(\n                    tasks_completed=1, success_rate=1.0,\n                ),\n            },\n        )\n        record_outcome(rules, \"test\", \"security\", False, rules_path)\n        assert rules.model_performance[\"test\"].success_rate == 0.5\n        assert \"security\" in rules.model_performance[\"test\"].weaknesses\n\n\nclass TestLearnOverride:\n    def test_adds_new_override(self, rules_path: Path):\n        rules = default_rules()\n        learn_override(\n            rules, \"frontend\", \"claude\",\n            \"Codex too slow for frontend (280s vs 122s)\",\n            0.8, rules_path,\n        )\n        assert rules.task_type_overrides[\"frontend\"].model == \"claude\"\n        assert rules.task_type_overrides[\"frontend\"].source == \"learned\"\n\n    def test_persists_to_disk(self, rules_path: Path):\n        rules = default_rules()\n        save(rules, rules_path)\n        learn_override(\n            rules, \"frontend\", \"claude\", \"test\", 0.9, rules_path,\n        )\n        reloaded = load(rules_path)\n        assert \"frontend\" in reloaded.task_type_overrides\n"
  },
  {
    "path": "maggy/tests/test_routing_service.py",
    "content": "\"\"\"Tests for RoutingService — routing decisions and learning.\"\"\"\n\nfrom __future__ import annotations\n\nfrom maggy.routing import RoutingContext, RoutingService\nfrom maggy.scores import MIN_SAMPLES\n\n\nclass TestRoutingDecisions:\n    def test_low_complexity_routes_cheap(self, mock_cfg):\n        rs = RoutingService(mock_cfg)\n        ctx = RoutingContext(blast_score=1, task_type=\"general\")\n        decision = rs.route(ctx)\n        name = (\n            decision.primary\n            if isinstance(decision.primary, str)\n            else decision.primary.name\n        )\n        assert name in (\"kimi\", \"local\", \"deepseek\")\n\n    def test_high_complexity_routes_premium(self, mock_cfg):\n        rs = RoutingService(mock_cfg)\n        ctx = RoutingContext(blast_score=9, task_type=\"general\")\n        decision = rs.route(ctx)\n        name = (\n            decision.primary\n            if isinstance(decision.primary, str)\n            else decision.primary.name\n        )\n        assert name in (\"codex\", \"claude\")\n\n    def test_security_sensitive_avoids_cheap(self, mock_cfg):\n        rs = RoutingService(mock_cfg)\n        ctx = RoutingContext(\n            blast_score=3,\n            task_type=\"security\",\n            security_sensitive=True,\n        )\n        decision = rs.route(ctx)\n        name = (\n            decision.primary\n            if isinstance(decision.primary, str)\n            else decision.primary.name\n        )\n        assert name in (\"codex\", \"claude\")\n\n\nclass TestRoutingLearning:\n    def test_record_outcome(self, mock_cfg):\n        rs = RoutingService(mock_cfg)\n        rs.record_outcome(\"claude\", \"bug\", 8, 0.95)\n        hm = rs.get_heatmap()\n        assert len(hm) == 1\n\n    def test_learned_override(self, mock_cfg):\n        rs = RoutingService(mock_cfg)\n        # Seed enough data for learning\n        for _ in range(MIN_SAMPLES + 1):\n            rs.record_outcome(\"codex\", \"bug\", 2, 0.99)\n        ctx = RoutingContext(blast_score=2, task_type=\"bug\")\n        decision = rs.route(ctx)\n        name = (\n            decision.primary\n            if isinstance(decision.primary, str)\n            else decision.primary.name\n        )\n        assert name == \"codex\"\n\n    def test_blast_tier_mapping(self, mock_cfg):\n        rs = RoutingService(mock_cfg)\n        assert rs._blast_tier(0) == \"low\"\n        assert rs._blast_tier(3) == \"low\"\n        assert rs._blast_tier(5) == \"medium\"\n        assert rs._blast_tier(8) == \"high\"\n"
  },
  {
    "path": "maggy/tests/test_scores.py",
    "content": "\"\"\"Tests for RewardTable — record, query, best_model, heatmap.\"\"\"\n\nfrom __future__ import annotations\n\nfrom maggy.scores import MIN_SAMPLES, RewardTable\n\n\nclass TestRewardRecord:\n    def test_record_and_heatmap(self, mock_cfg):\n        rt = RewardTable(mock_cfg)\n        rt.record(\"claude\", \"bug\", \"high\", 0.9)\n        hm = rt.heatmap()\n        assert len(hm) == 1\n        assert hm[0][\"model\"] == \"claude\"\n\n    def test_multiple_records(self, mock_cfg):\n        rt = RewardTable(mock_cfg)\n        rt.record(\"claude\", \"bug\", \"high\", 0.9)\n        rt.record(\"gpt\", \"bug\", \"high\", 0.7)\n        hm = rt.heatmap()\n        assert len(hm) == 2\n\n\nclass TestBestModel:\n    def test_no_data_returns_none(self, mock_cfg):\n        rt = RewardTable(mock_cfg)\n        assert rt.best_model(\"bug\", \"high\") is None\n\n    def test_insufficient_samples_returns_none(self, mock_cfg):\n        rt = RewardTable(mock_cfg)\n        for _ in range(MIN_SAMPLES - 1):\n            rt.record(\"claude\", \"bug\", \"high\", 0.9)\n        assert rt.best_model(\"bug\", \"high\") is None\n\n    def test_sufficient_samples_returns_best(self, mock_cfg):\n        rt = RewardTable(mock_cfg)\n        for _ in range(MIN_SAMPLES):\n            rt.record(\"claude\", \"bug\", \"high\", 0.9)\n        for _ in range(MIN_SAMPLES):\n            rt.record(\"gpt\", \"bug\", \"high\", 0.5)\n        best = rt.best_model(\"bug\", \"high\")\n        assert best == \"claude\"\n\n\nclass TestHeatmap:\n    def test_empty_heatmap(self, mock_cfg):\n        rt = RewardTable(mock_cfg)\n        assert rt.heatmap() == []\n\n    def test_heatmap_groups_correctly(self, mock_cfg):\n        rt = RewardTable(mock_cfg)\n        rt.record(\"claude\", \"bug\", \"high\", 0.9)\n        rt.record(\"claude\", \"feature\", \"low\", 0.8)\n        hm = rt.heatmap()\n        assert len(hm) == 2\n"
  },
  {
    "path": "maggy/tests/test_setup_routes.py",
    "content": "\"\"\"Tests for setup and onboarding routes.\"\"\"\n\nfrom __future__ import annotations\n\nfrom pathlib import Path\nfrom unittest.mock import patch\n\nimport pytest\nfrom fastapi import FastAPI\nfrom fastapi.testclient import TestClient\n\nfrom maggy.api.routes_setup import router as setup_router\nfrom maggy.config import (\n    DashboardConfig,\n    MaggyConfig,\n    StorageConfig,\n)\n\n\n@pytest.fixture\ndef setup_app(tmp_path: Path) -> FastAPI:\n    \"\"\"App with setup router only.\"\"\"\n    cfg = MaggyConfig(\n        storage=StorageConfig(path=str(tmp_path / \"s.db\")),\n        dashboard=DashboardConfig(auth_mode=\"local\"),\n    )\n    app = FastAPI()\n    app.state.cfg = cfg\n    app.state.configured = True\n    app.state.mode = \"local\"\n    app.include_router(setup_router)\n    return app\n\n\n@pytest.fixture\ndef client(setup_app: FastAPI) -> TestClient:\n    return TestClient(setup_app)\n\n\nclass TestSetupStatus:\n    def test_returns_steps(self, client: TestClient):\n        resp = client.get(\"/api/setup/status\")\n        assert resp.status_code == 200\n        data = resp.json()\n        assert \"steps\" in data\n        assert len(data[\"steps\"]) == 5\n        assert data[\"mode\"] == \"local\"\n\n    def test_missing_token_detected(\n        self, client: TestClient,\n    ):\n        resp = client.get(\"/api/setup/status\")\n        data = resp.json()\n        token_step = data[\"steps\"][0]\n        assert token_step[\"label\"] == \"GitHub token\"\n        assert token_step[\"status\"] == \"missing\"\n\n    def test_progress_format(self, client: TestClient):\n        resp = client.get(\"/api/setup/status\")\n        data = resp.json()\n        assert \"/\" in data[\"progress\"]\n\n    def test_configured_false_in_local(\n        self, client: TestClient,\n    ):\n        resp = client.get(\"/api/setup/status\")\n        assert resp.json()[\"configured\"] is False\n\n\nclass TestSetupConfigure:\n    @patch(\"maggy.config.save\")\n    def test_updates_org(self, mock_save, client):\n        resp = client.post(\n            \"/api/setup/configure\",\n            json={\"org_name\": \"Protaige\"},\n        )\n        assert resp.status_code == 200\n        assert resp.json()[\"saved\"] is True\n        mock_save.assert_called_once()\n\n    @patch(\"maggy.config.save\")\n    def test_updates_github_repos(\n        self, mock_save, client,\n    ):\n        resp = client.post(\n            \"/api/setup/configure\",\n            json={\n                \"github_org\": \"protaige\",\n                \"github_repos\": [\"api\", \"web\"],\n            },\n        )\n        assert resp.json()[\"saved\"] is True\n\n    @patch(\"maggy.config.save\")\n    def test_empty_body_is_noop(\n        self, mock_save, client,\n    ):\n        resp = client.post(\n            \"/api/setup/configure\", json={},\n        )\n        assert resp.json()[\"saved\"] is True\n\n\nclass TestDiscoverRepos:\n    def test_returns_repos(self, client: TestClient):\n        resp = client.get(\"/api/setup/discover-repos\")\n        assert resp.status_code == 200\n        data = resp.json()\n        assert \"repos\" in data\n        assert isinstance(data[\"repos\"], list)\n"
  },
  {
    "path": "maggy/tests/test_stakes.py",
    "content": "\"\"\"Tests for stakes classification — HIGH/MEDIUM/LOW from task metadata.\"\"\"\n\nfrom __future__ import annotations\n\nfrom maggy.providers.base import Task\nfrom maggy.routing_rules import StakesLevel, StakesPatterns\nfrom maggy.services.stakes import classify_stakes\n\n\ndef _task(title: str, desc: str = \"\", raw: dict | None = None) -> Task:\n    return Task(id=\"T-1\", title=title, description=desc, raw=raw or {})\n\n\nclass TestHighStakes:\n    def test_auth_file_in_title(self):\n        result = classify_stakes(_task(\"Fix auth.py login bug\"))\n        assert result.level == \"high\"\n\n    def test_billing_task_type(self):\n        task = _task(\"Update billing\", raw={\"task_type\": \"billing\"})\n        result = classify_stakes(task)\n        assert result.level == \"high\"\n\n    def test_security_task_type(self):\n        task = _task(\"Patch XSS\", raw={\"task_type\": \"security\"})\n        result = classify_stakes(task)\n        assert result.level == \"high\"\n\n    def test_production_keyword_in_desc(self):\n        task = _task(\"Deploy fix\", \"Affects production data\")\n        result = classify_stakes(task)\n        assert result.level == \"high\"\n\n    def test_env_file_pattern(self):\n        result = classify_stakes(_task(\"Update .env variables\"))\n        assert result.level == \"high\"\n\n    def test_migration_in_title(self):\n        result = classify_stakes(_task(\"Run database migration\"))\n        assert result.level == \"high\"\n\n\nclass TestMediumStakes:\n    def test_api_route_file(self):\n        result = classify_stakes(_task(\"Fix API routes handler\"))\n        assert result.level == \"medium\"\n\n    def test_feature_task_type(self):\n        task = _task(\"Add pagination\", raw={\"task_type\": \"feature\"})\n        result = classify_stakes(task)\n        assert result.level == \"medium\"\n\n    def test_database_schema_change(self):\n        result = classify_stakes(_task(\"Update database schema\"))\n        assert result.level == \"medium\"\n\n\nclass TestLowStakes:\n    def test_readme_update(self):\n        result = classify_stakes(_task(\"Update README typo\"))\n        assert result.level == \"low\"\n\n    def test_docs_task_type(self):\n        task = _task(\"Fix docs\", raw={\"task_type\": \"docs\"})\n        result = classify_stakes(task)\n        assert result.level == \"low\"\n\n    def test_formatting_task(self):\n        task = _task(\"Fix lint\", raw={\"task_type\": \"formatting\"})\n        result = classify_stakes(task)\n        assert result.level == \"low\"\n\n\nclass TestStakesResult:\n    def test_reasons_populated(self):\n        result = classify_stakes(_task(\"Fix auth.py login\"))\n        assert len(result.reasons) > 0\n\n    def test_custom_patterns(self):\n        \"\"\"classify_stakes with explicit patterns overrides defaults.\"\"\"\n        patterns = StakesPatterns(\n            high=StakesLevel(\n                file_patterns=[\"critical\"],\n                task_types=[\"emergency\"],\n                keywords=[\"urgent\"],\n            ),\n            medium=StakesLevel(),\n            low=StakesLevel(),\n        )\n        task = _task(\"Fix critical module\", raw={})\n        result = classify_stakes(task, patterns)\n        assert result.level == \"high\"\n"
  },
  {
    "path": "maggy/tests/test_tdd_verifier.py",
    "content": "\"\"\"Tests for TDD verification gates.\"\"\"\n\nfrom __future__ import annotations\n\nimport pytest\n\nfrom maggy.services.tdd_verifier import (\n    _count_collected,\n    _count_failures,\n    _parse_coverage,\n)\n\n\nclass TestParsers:\n    \"\"\"Parse pytest and coverage output.\"\"\"\n\n    def test_count_collected_normal(self):\n        assert _count_collected(\"12 tests collected\") == 12\n\n    def test_count_collected_singular(self):\n        assert _count_collected(\"1 test collected\") == 1\n\n    def test_count_collected_missing(self):\n        assert _count_collected(\"no tests ran\") == 0\n\n    def test_count_failures_normal(self):\n        assert _count_failures(\"3 failed, 7 passed\") == 3\n\n    def test_count_failures_none(self):\n        assert _count_failures(\"10 passed\") == 0\n\n    def test_parse_coverage_normal(self):\n        out = \"TOTAL    500    50    90%\"\n        assert _parse_coverage(out) == 90.0\n\n    def test_parse_coverage_missing(self):\n        assert _parse_coverage(\"no coverage data\") == 0.0\n\n\nclass TestVerifyResult:\n    \"\"\"VerifyResult dataclass.\"\"\"\n\n    def test_passed_result(self):\n        from maggy.services.tdd_verifier import VerifyResult\n        r = VerifyResult(True, \"ok\", 5, 0)\n        assert r.passed is True\n        assert r.tests_found == 5\n\n    def test_failed_result(self):\n        from maggy.services.tdd_verifier import VerifyResult\n        r = VerifyResult(False, \"tests failing\", 5, 3)\n        assert r.passed is False\n        assert r.tests_failed == 3\n\n\nclass TestVerifyFunctions:\n    \"\"\"Async verify functions with mocked subprocesses.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_verify_tests_exist_passes(self, monkeypatch):\n        from maggy.services import tdd_verifier\n\n        async def mock_run(cmd, cwd):\n            return 0, \"5 tests collected\"\n\n        monkeypatch.setattr(tdd_verifier, \"_run_cmd\", mock_run)\n        r = await tdd_verifier.verify_tests_exist(\"/tmp\")\n        assert r.passed is True\n        assert r.tests_found == 5\n\n    @pytest.mark.asyncio\n    async def test_verify_tests_exist_fails(self, monkeypatch):\n        from maggy.services import tdd_verifier\n\n        async def mock_run(cmd, cwd):\n            return 1, \"error\"\n\n        monkeypatch.setattr(tdd_verifier, \"_run_cmd\", mock_run)\n        r = await tdd_verifier.verify_tests_exist(\"/tmp\")\n        assert r.passed is False\n\n    @pytest.mark.asyncio\n    async def test_verify_tests_fail_red(self, monkeypatch):\n        from maggy.services import tdd_verifier\n\n        async def mock_run(cmd, cwd):\n            return 1, \"2 failed, 3 passed\"\n\n        monkeypatch.setattr(tdd_verifier, \"_run_cmd\", mock_run)\n        r = await tdd_verifier.verify_tests_fail(\"/tmp\")\n        assert r.passed is True\n        assert r.tests_failed == 2\n\n    @pytest.mark.asyncio\n    async def test_verify_tests_fail_rejects_pass(self, monkeypatch):\n        from maggy.services import tdd_verifier\n\n        async def mock_run(cmd, cwd):\n            return 0, \"5 passed\"\n\n        monkeypatch.setattr(tdd_verifier, \"_run_cmd\", mock_run)\n        r = await tdd_verifier.verify_tests_fail(\"/tmp\")\n        assert r.passed is False\n        assert \"expected failures\" in r.detail\n\n    @pytest.mark.asyncio\n    async def test_verify_tests_pass_green(self, monkeypatch):\n        from maggy.services import tdd_verifier\n\n        async def mock_run(cmd, cwd):\n            return 0, \"10 passed\"\n\n        monkeypatch.setattr(tdd_verifier, \"_run_cmd\", mock_run)\n        r = await tdd_verifier.verify_tests_pass(\"/tmp\")\n        assert r.passed is True\n\n    @pytest.mark.asyncio\n    async def test_verify_lint_clean(self, monkeypatch):\n        from maggy.services import tdd_verifier\n\n        async def mock_run(cmd, cwd):\n            return 0, \"All checks passed!\"\n\n        monkeypatch.setattr(tdd_verifier, \"_run_cmd\", mock_run)\n        r = await tdd_verifier.verify_lint(\"/tmp\")\n        assert r.passed is True\n"
  },
  {
    "path": "maggy/tests/test_vision.py",
    "content": "\"\"\"Tests for Maggy vision service — Ollama Qwen3-VL integration.\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nfrom pathlib import Path\nfrom unittest.mock import MagicMock, patch\n\nimport pytest\n\nfrom maggy.services.vision import analyze_image\n\n\n@pytest.fixture()\ndef png_file(tmp_path: Path) -> Path:\n    \"\"\"Create a tiny valid PNG file.\"\"\"\n    p = tmp_path / \"test.png\"\n    # Minimal 1x1 PNG\n    p.write_bytes(\n        b\"\\x89PNG\\r\\n\\x1a\\n\"\n        b\"\\x00\\x00\\x00\\rIHDR\"\n        b\"\\x00\\x00\\x00\\x01\\x00\\x00\\x00\\x01\"\n        b\"\\x08\\x02\\x00\\x00\\x00\\x90wS\\xde\"\n    )\n    return p\n\n\ndef test_analyze_missing_file():\n    \"\"\"Nonexistent path yields error chunk.\"\"\"\n    chunks = list(analyze_image(\"/no/such/file.png\"))\n    assert any(c[\"type\"] == \"error\" for c in chunks)\n\n\ndef test_analyze_bad_extension(tmp_path: Path):\n    \"\"\"Non-image extension yields error chunk.\"\"\"\n    txt = tmp_path / \"notes.txt\"\n    txt.write_text(\"hello\")\n    chunks = list(analyze_image(str(txt)))\n    assert any(c[\"type\"] == \"error\" for c in chunks)\n\n\ndef test_analyze_streams_response(png_file: Path):\n    \"\"\"Mock Ollama API returns streamed text + done.\"\"\"\n    lines = [\n        json.dumps({\"message\": {\"content\": \"A \"}}),\n        json.dumps({\"message\": {\"content\": \"button\"}}),\n        json.dumps({\"done\": True}),\n    ]\n    mock_resp = MagicMock()\n    mock_resp.status_code = 200\n    mock_resp.iter_lines.return_value = iter(lines)\n    mock_resp.__enter__ = lambda s: s\n    mock_resp.__exit__ = MagicMock(return_value=False)\n\n    with patch(\"maggy.services.vision.httpx.stream\",\n               return_value=mock_resp):\n        chunks = list(analyze_image(str(png_file)))\n\n    texts = [c[\"content\"] for c in chunks if c[\"type\"] == \"text\"]\n    assert \"A \" in texts\n    assert \"button\" in texts\n    assert any(c[\"type\"] == \"done\" for c in chunks)\n\n\ndef test_analyze_with_custom_prompt(png_file: Path):\n    \"\"\"Custom prompt is passed to the Ollama API.\"\"\"\n    captured = {}\n\n    def fake_stream(method, url, **kw):\n        captured.update(kw)\n        mock = MagicMock()\n        mock.status_code = 200\n        mock.iter_lines.return_value = iter([\n            json.dumps({\"done\": True}),\n        ])\n        mock.__enter__ = lambda s: s\n        mock.__exit__ = MagicMock(return_value=False)\n        return mock\n\n    with patch(\"maggy.services.vision.httpx.stream\",\n               side_effect=fake_stream):\n        list(analyze_image(str(png_file), \"What color?\"))\n\n    body = captured.get(\"json\", {})\n    msg = body.get(\"messages\", [{}])[0]\n    assert \"What color?\" in msg.get(\"content\", \"\")\n\n\ndef test_analyze_ollama_down(png_file: Path):\n    \"\"\"Connection error yields error chunk.\"\"\"\n    import httpx\n    with patch(\"maggy.services.vision.httpx.stream\",\n               side_effect=httpx.ConnectError(\"refused\")):\n        chunks = list(analyze_image(str(png_file)))\n    assert any(c[\"type\"] == \"error\" for c in chunks)\n    err = next(c for c in chunks if c[\"type\"] == \"error\")\n    assert \"refused\" in err[\"content\"].lower() or \"connect\" in err[\"content\"].lower()\n"
  },
  {
    "path": "maggy/tests/test_zero_config.py",
    "content": "\"\"\"Tests for zero-config auto-configuration.\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nfrom pathlib import Path\nfrom unittest.mock import patch\n\nimport pytest\n\nfrom maggy.config import MaggyConfig\n\n\n# --- Provider Credentials ---\n\n\nclass TestHasProviderCredentials:\n    def test_github_with_creds(self):\n        from maggy.config import _has_provider_credentials\n        cfg = MaggyConfig()\n        cfg.issue_tracker.provider = \"github\"\n        cfg.issue_tracker.github.org = \"acme\"\n        cfg.issue_tracker.github.repos = [\"api\"]\n        cfg.issue_tracker.github.token = \"ghp_abc\"\n        assert _has_provider_credentials(cfg) is True\n\n    def test_github_no_token(self):\n        from maggy.config import _has_provider_credentials\n        cfg = MaggyConfig()\n        cfg.issue_tracker.provider = \"github\"\n        cfg.issue_tracker.github.org = \"acme\"\n        cfg.issue_tracker.github.repos = [\"api\"]\n        assert _has_provider_credentials(cfg) is False\n\n    def test_asana_with_creds(self):\n        from maggy.config import _has_provider_credentials\n        cfg = MaggyConfig()\n        cfg.issue_tracker.provider = \"asana\"\n        cfg.issue_tracker.asana.workspace_id = \"w1\"\n        cfg.issue_tracker.asana.token = \"tok\"\n        assert _has_provider_credentials(cfg) is True\n\n    def test_linear_stub(self):\n        from maggy.config import _has_provider_credentials\n        cfg = MaggyConfig()\n        cfg.issue_tracker.provider = \"linear\"\n        assert _has_provider_credentials(cfg) is False\n\n\n# --- CLI History Detection ---\n\n\nclass TestHasCliHistory:\n    def test_claude_dir_exists(self, tmp_path: Path):\n        from maggy.config import _has_cli_history\n        (tmp_path / \".claude\").mkdir()\n        assert _has_cli_history(tmp_path) is True\n\n    def test_no_dirs(self, tmp_path: Path):\n        from maggy.config import _has_cli_history\n        assert _has_cli_history(tmp_path) is False\n\n    def test_codex_dir_exists(self, tmp_path: Path):\n        from maggy.config import _has_cli_history\n        (tmp_path / \".codex\").mkdir()\n        assert _has_cli_history(tmp_path) is True\n\n\n# --- Auto Configure ---\n\n\nclass TestAutoConfigure:\n    def test_builds_config(self, tmp_path: Path):\n        from maggy.config import auto_configure\n        with patch(\"shutil.which\", return_value=None):\n            cfg = auto_configure(\n                home=tmp_path, persist=False,\n            )\n        assert isinstance(cfg, MaggyConfig)\n\n    def test_populates_codebases(self, tmp_path: Path):\n        from maggy.config import auto_configure\n        dev = tmp_path / \"dev\"\n        dev.mkdir()\n        repo = dev / \"webapp\"\n        repo.mkdir()\n        (repo / \".git\").mkdir()\n\n        with patch(\"shutil.which\", return_value=None):\n            cfg = auto_configure(\n                home=tmp_path, persist=False,\n            )\n        assert len(cfg.codebases) == 1\n        assert cfg.codebases[0].key == \"webapp\"\n\n    def test_persist_writes_file(self, tmp_path: Path):\n        from maggy.config import auto_configure\n        config_path = tmp_path / \"config.yaml\"\n        with patch(\"shutil.which\", return_value=None), \\\n             patch(\"maggy.config.CONFIG_DIR\", tmp_path), \\\n             patch(\"maggy.config.CONFIG_PATH\", config_path):\n            cfg = auto_configure(\n                home=tmp_path, persist=True,\n            )\n        assert config_path.exists()\n\n\n# --- Relaxed is_configured ---\n\n\nclass TestIsConfiguredRelaxed:\n    def test_false_without_anything(self, tmp_path: Path):\n        from maggy.config import is_configured\n        with patch(\"maggy.config.CONFIG_PATH\", tmp_path / \"nope.yaml\"), \\\n             patch(\"maggy.config._CACHED\", None), \\\n             patch(\"maggy.config._has_cli_history\", return_value=False):\n            result = is_configured()\n        assert result is False\n\n    def test_true_with_cli_history(self, tmp_path: Path):\n        from maggy.config import is_configured\n        with patch(\"maggy.config.CONFIG_PATH\", tmp_path / \"nope.yaml\"), \\\n             patch(\"maggy.config._CACHED\", None), \\\n             patch(\"maggy.config._has_cli_history\", return_value=True):\n            result = is_configured()\n        assert result is True\n"
  },
  {
    "path": "rules/nodejs-backend.md",
    "content": "---\ndescription: Node.js backend conventions\npaths: [\"src/api/**\", \"src/routes/**\", \"src/server/**\", \"src/middleware/**\", \"server/**\", \"api/**\"]\n---\n\n## Node.js Backend Conventions\n\n- Use Express or Fastify with typed route handlers\n- Repository pattern for data access\n- Validate request bodies with Zod at the route level\n- Use proper HTTP status codes (201 for creation, 404 for missing, etc.)\n- Add rate limiting to auth endpoints\n- Use structured logging (pino/winston)\n- Handle async errors with middleware, not try/catch in every route\n"
  },
  {
    "path": "rules/python.md",
    "content": "---\ndescription: Python-specific conventions\npaths: [\"**/*.py\"]\n---\n\n## Python Conventions\n\n- Use type hints on all function signatures\n- Use Pydantic for data validation and serialization\n- Use pytest for testing (not unittest)\n- Use ruff for linting and formatting\n- Use mypy for type checking\n- Prefer dataclasses or Pydantic models over plain dicts\n- Use pathlib over os.path\n"
  },
  {
    "path": "rules/quality-gates.md",
    "content": "---\ndescription: Code quality constraints enforced on all files\n---\n\n## Quality Gates\n\n| Constraint | Limit |\n|------------|-------|\n| Lines per function | 20 max |\n| Parameters per function | 3 max |\n| Nesting depth | 2 levels max |\n| Lines per file | 200 max |\n| Functions per file | 10 max |\n| Test coverage | 80% minimum |\n\nBefore completing any file: count lines, count functions, check parameter counts. If limits exceeded, split or decompose immediately.\n"
  },
  {
    "path": "rules/react.md",
    "content": "---\ndescription: React-specific conventions\npaths: [\"src/components/**\", \"src/pages/**\", \"src/app/**\", \"**/*.tsx\", \"**/*.jsx\"]\n---\n\n## React Conventions\n\n- Prefer functional components with hooks\n- Use React Query / TanStack Query for server state\n- Use Zustand or context for client state\n- Colocate component tests (ComponentName.test.tsx)\n- Extract custom hooks when logic is reused across components\n- Avoid prop drilling beyond 2 levels - use context or composition\n"
  },
  {
    "path": "rules/security.md",
    "content": "---\ndescription: Security rules enforced on all code\n---\n\n## Security Rules\n\n- No secrets in code - use environment variables\n- No secrets in client-exposed env vars (VITE_*, NEXT_PUBLIC_*, REACT_APP_*)\n- `.env` files always in `.gitignore`\n- Parameterized queries only - no string concatenation for SQL\n- Hash passwords with bcrypt (12+ rounds) or argon2\n- Validate all input at API boundaries (Zod/Pydantic)\n- `.env.example` with all required vars (no values)\n"
  },
  {
    "path": "rules/tdd-workflow.md",
    "content": "---\ndescription: TDD workflow enforced for all implementation tasks\n---\n\n## TDD Workflow\n\nEvery feature and bug fix follows RED-GREEN-VALIDATE:\n\n1. **RED** - Write tests based on acceptance criteria. Run them. All must FAIL.\n2. **GREEN** - Write minimum code to pass tests. Run them. All must PASS.\n3. **VALIDATE** - Run linter, type checker, full test suite with coverage >= 80%.\n\nTests must fail first to prove they validate the requirement. No code ships without a test that failed first.\n\nFor bugs: identify test gap, write failing test that reproduces bug, then fix.\n"
  },
  {
    "path": "rules/typescript.md",
    "content": "---\ndescription: TypeScript-specific conventions\npaths: [\"**/*.ts\", \"**/*.tsx\", \"tsconfig.json\"]\n---\n\n## TypeScript Conventions\n\n- Enable strict mode in tsconfig.json\n- Prefer interfaces over type aliases for object shapes\n- Use discriminated unions over type assertions\n- Avoid `any` - use `unknown` with type narrowing\n- Use Zod for runtime validation at boundaries\n- Use ESLint with TypeScript parser\n- Prefer `const` over `let`, never use `var`\n"
  },
  {
    "path": "scripts/convert-hooks-to-toml.sh",
    "content": "#!/bin/bash\n# convert-hooks-to-toml.sh - Convert settings.json hooks to config.toml format\n# Usage: convert-hooks-to-toml.sh [settings.json] > config.toml\n# Requires: jq\n\nset -euo pipefail\n\nSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\nDEFAULT_INPUT=\"$SCRIPT_DIR/../templates/settings.json\"\n\ncheck_deps() {\n    command -v jq &>/dev/null || {\n        echo \"Error: jq is required\" >&2\n        exit 1\n    }\n}\n\nprint_header() {\n    cat <<'HEADER'\n# Agent CLI Configuration\n# Compatible with Kimi CLI and OpenAI Codex CLI\n# Auto-generated from settings.json hooks\nHEADER\n    echo \"\"\n}\n\nextract_hook() {\n    local event=\"$1\"\n    local matcher=\"$2\"\n    local command=\"$3\"\n    local timeout=\"$4\"\n\n    echo \"[[hooks]]\"\n    echo \"event = \\\"$event\\\"\"\n    [ -n \"$matcher\" ] && echo \"matcher = \\\"$matcher\\\"\"\n    echo \"command = \\\"\\\"\\\"\"\n    echo \"$command\"\n    echo \"\\\"\\\"\\\"\"\n    echo \"timeout = $timeout\"\n    echo \"\"\n}\n\nconvert_event() {\n    local input=\"$1\"\n    local event=\"$2\"\n    local entries\n\n    entries=$(jq -c \".hooks.${event}[]?\" \"$input\" 2>/dev/null) || return 0\n\n    echo \"$entries\" | while IFS= read -r entry; do\n        local matcher\n        matcher=$(echo \"$entry\" | jq -r '.matcher // \"\"')\n        local hooks_array\n        hooks_array=$(echo \"$entry\" | jq -c '.hooks[]')\n\n        echo \"$hooks_array\" | while IFS= read -r hook; do\n            local cmd timeout\n            cmd=$(echo \"$hook\" | jq -r '.command')\n            timeout=$(echo \"$hook\" | jq -r '.timeout // 30')\n            extract_hook \"$event\" \"$matcher\" \"$cmd\" \"$timeout\"\n        done\n    done\n}\n\nmain() {\n    local input=\"${1:-$DEFAULT_INPUT}\"\n    [ -f \"$input\" ] || {\n        echo \"Error: '$input' not found\" >&2\n        exit 1\n    }\n\n    check_deps\n    print_header\n\n    local events=(\n        \"PreCompact\" \"PreToolUse\" \"PostToolUse\"\n        \"Stop\" \"SessionStart\" \"SessionEnd\"\n    )\n    for event in \"${events[@]}\"; do\n        convert_event \"$input\" \"$event\"\n    done\n}\n\nmain \"$@\"\n"
  },
  {
    "path": "scripts/convert-skills-structure.sh",
    "content": "#!/bin/bash\n# convert-skills-structure.sh\n# Converts flat .md skills to folder/SKILL.md structure with YAML frontmatter\n\nset -eo pipefail\n\nSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\nROOT_DIR=\"$(dirname \"$SCRIPT_DIR\")\"\nSKILLS_DIR=\"$ROOT_DIR/skills\"\n\necho \"Converting skills from flat .md to folder/SKILL.md structure...\"\necho \"Skills directory: $SKILLS_DIR\"\necho \"\"\n\n# Function to get description for a skill\nget_description() {\n    local name=\"$1\"\n    case \"$name\" in\n        \"aeo-optimization\") echo \"AI Engine Optimization - semantic triples, page templates, content clusters for AI citations\" ;;\n        \"agentic-development\") echo \"Build AI agents with Pydantic AI (Python) and Claude SDK (Node.js)\" ;;\n        \"ai-models\") echo \"Latest AI models reference - Claude, OpenAI, Gemini, Eleven Labs, Replicate\" ;;\n        \"base\") echo \"Universal coding patterns, constraints, TDD workflow, atomic todos\" ;;\n        \"code-deduplication\") echo \"Prevent semantic code duplication with capability index and check-before-write\" ;;\n        \"code-review\") echo \"Mandatory code reviews via /code-review before commits and deploys\" ;;\n        \"commit-hygiene\") echo \"Atomic commits, PR size limits, commit thresholds, stacked PRs\" ;;\n        \"credentials\") echo \"Centralized API key management from Access.txt\" ;;\n        \"database-schema\") echo \"Schema awareness - read before coding, type generation, prevent column errors\" ;;\n        \"iterative-development\") echo \"Ralph Wiggum loops - self-referential TDD iteration until tests pass\" ;;\n        \"klaviyo\") echo \"Klaviyo email/SMS marketing - profiles, events, flows, segmentation\" ;;\n        \"llm-patterns\") echo \"AI-first application patterns, LLM testing, prompt management\" ;;\n        \"medusa\") echo \"Medusa headless commerce - modules, workflows, API routes, admin UI\" ;;\n        \"ms-teams-apps\") echo \"Microsoft Teams bots and AI agents - Claude/OpenAI, Adaptive Cards, Graph API\" ;;\n        \"nodejs-backend\") echo \"Node.js backend patterns with Express/Fastify, repositories\" ;;\n        \"playwright-testing\") echo \"E2E testing with Playwright - Page Objects, cross-browser, CI/CD\" ;;\n        \"posthog-analytics\") echo \"PostHog analytics, event tracking, feature flags, dashboards\" ;;\n        \"project-tooling\") echo \"gh, vercel, supabase, render CLI and deployment platform setup\" ;;\n        \"pwa-development\") echo \"Progressive Web Apps - service workers, caching strategies, offline, Workbox\" ;;\n        \"python\") echo \"Python development with ruff, mypy, pytest - TDD and type safety\" ;;\n        \"react-native\") echo \"React Native mobile patterns, platform-specific code\" ;;\n        \"react-web\") echo \"React web development with hooks, React Query, Zustand\" ;;\n        \"reddit-ads\") echo \"Reddit Ads API - campaigns, targeting, conversions, agentic optimization\" ;;\n        \"reddit-api\") echo \"Reddit API with PRAW (Python) and Snoowrap (Node.js)\" ;;\n        \"security\") echo \"OWASP security patterns, secrets management, security testing\" ;;\n        \"session-management\") echo \"Context preservation, tiered summarization, resumability\" ;;\n        \"shopify-apps\") echo \"Shopify app development - Remix, Admin API, checkout extensions\" ;;\n        \"site-architecture\") echo \"Technical SEO - robots.txt, sitemap, meta tags, Core Web Vitals\" ;;\n        \"supabase\") echo \"Core Supabase CLI, migrations, RLS, Edge Functions\" ;;\n        \"supabase-nextjs\") echo \"Next.js with Supabase and Drizzle ORM\" ;;\n        \"supabase-node\") echo \"Express/Hono with Supabase and Drizzle ORM\" ;;\n        \"supabase-python\") echo \"FastAPI with Supabase and SQLAlchemy/SQLModel\" ;;\n        \"team-coordination\") echo \"Multi-person projects - shared state, todo claiming, handoffs\" ;;\n        \"typescript\") echo \"TypeScript strict mode with eslint and jest\" ;;\n        \"ui-mobile\") echo \"Mobile UI patterns - React Native, iOS/Android, touch targets\" ;;\n        \"ui-testing\") echo \"Visual testing - catch invisible buttons, broken layouts, contrast\" ;;\n        \"ui-web\") echo \"Web UI - glassmorphism, Tailwind, dark mode, accessibility\" ;;\n        \"user-journeys\") echo \"User experience flows - journey mapping, UX validation, error recovery\" ;;\n        \"web-content\") echo \"SEO and AI discovery (GEO) - schema, ChatGPT/Perplexity optimization\" ;;\n        \"web-payments\") echo \"Stripe Checkout, subscriptions, webhooks, customer portal\" ;;\n        \"woocommerce\") echo \"WooCommerce REST API - products, orders, customers, webhooks\" ;;\n        *) echo \"Skill for $name\" ;;\n    esac\n}\n\nconverted=0\n\nfor skill_file in \"$SKILLS_DIR\"/*.md; do\n    if [ -f \"$skill_file\" ]; then\n        filename=$(basename \"$skill_file\" .md)\n        skill_folder=\"$SKILLS_DIR/$filename\"\n        skill_md=\"$skill_folder/SKILL.md\"\n\n        echo -n \"Converting: $filename ... \"\n\n        # Get description\n        description=$(get_description \"$filename\")\n\n        # Create folder\n        mkdir -p \"$skill_folder\"\n\n        # Create SKILL.md with YAML frontmatter + original content\n        {\n            echo \"---\"\n            echo \"name: $filename\"\n            echo \"description: $description\"\n            echo \"---\"\n            echo \"\"\n            cat \"$skill_file\"\n        } > \"$skill_md\"\n\n        # Remove original flat file\n        rm \"$skill_file\"\n\n        echo \"✓\"\n        converted=$((converted + 1))\n    fi\ndone\n\necho \"\"\necho \"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\"\necho \"Conversion complete!\"\necho \"Converted: $converted skills\"\necho \"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\"\n"
  },
  {
    "path": "scripts/detect-agents.sh",
    "content": "#!/bin/bash\n# detect-agents.sh - Detect installed AI CLI tools\n# Output: newline-separated list of detected tools (claude, kimi, codex)\n# Usage: ./detect-agents.sh\n#        AGENTS=$(./detect-agents.sh)\n\nset -euo pipefail\n\ndetect_by_binary() {\n    local name=\"$1\"\n    local binary=\"$2\"\n    command -v \"$binary\" &>/dev/null && echo \"$name\"\n}\n\ndetect_by_config() {\n    local name=\"$1\"\n    local dir=\"$2\"\n    [ -d \"$dir\" ] && echo \"$name\"\n}\n\ndetect_tool() {\n    local name=\"$1\"\n    local binary=\"$2\"\n    local config_dir=\"$3\"\n    # Binary takes priority, config dir as fallback\n    if command -v \"$binary\" &>/dev/null; then\n        echo \"$name\"\n    elif [ -d \"$config_dir\" ]; then\n        echo \"$name\"\n    fi\n}\n\nmain() {\n    detect_tool \"claude\" \"claude\" \"$HOME/.claude\"\n    detect_tool \"kimi\" \"kimi\" \"$HOME/.kimi\"\n    detect_tool \"codex\" \"codex\" \"$HOME/.codex\"\n\n    # Container runtime\n    command -v docker &>/dev/null && echo \"docker\" || true\n    command -v orbctl &>/dev/null && echo \"orbstack\" || true\n\n    # Polyphony orchestrator\n    command -v polyphony &>/dev/null && echo \"polyphony\" || true\n}\n\nmain\n"
  },
  {
    "path": "scripts/icpg/__init__.py",
    "content": "\"\"\"iCPG — Intent-Augmented Code Property Graph.\n\nTracks WHY code exists by linking tasks/goals to code symbols with typed\nedges for traceability, blast radius, and drift detection.\n\"\"\"\n\n__version__ = '0.1.0'\n"
  },
  {
    "path": "scripts/icpg/__main__.py",
    "content": "\"\"\"CLI entry point for iCPG — Intent-Augmented Code Property Graph.\"\"\"\n\nfrom __future__ import annotations\n\nimport argparse\nimport json\nimport subprocess\nimport sys\nfrom pathlib import Path\n\nfrom . import __version__\nfrom .bootstrap import bootstrap_from_git\nfrom .contracts import format_contracts, infer_contracts\nfrom .drift import check_all_drift, check_file_drift\nfrom .models import Edge, ReasonNode, _now, _uuid\nfrom .store import ICPGStore\nfrom .symbols import extract_symbols, extract_symbols_from_files\nfrom .vectors import VectorStore\n\n\ndef main(argv: list[str] | None = None) -> int:\n    parser = argparse.ArgumentParser(\n        prog='icpg',\n        description='iCPG — Intent-Augmented Code Property Graph'\n    )\n    parser.add_argument(\n        '--version', action='version', version=f'icpg {__version__}'\n    )\n    parser.add_argument(\n        '--project', default='.', help='Project directory (default: .)'\n    )\n    sub = parser.add_subparsers(dest='command')\n\n    # --- init ---\n    sub.add_parser('init', help='Initialize .icpg/ directory and database')\n\n    # --- create ---\n    p_create = sub.add_parser('create', help='Create a ReasonNode')\n    p_create.add_argument('goal', help='Stated purpose (one sentence)')\n    p_create.add_argument(\n        '--scope', nargs='+', default=[], help='File paths in scope'\n    )\n    p_create.add_argument('--owner', default='user', help='Owner name')\n    p_create.add_argument('--agent', help='Agent identity')\n    p_create.add_argument(\n        '--type', dest='decision_type', default='task',\n        choices=[\n            'business_goal', 'arch_decision', 'task',\n            'workaround', 'constraint', 'patch'\n        ]\n    )\n    p_create.add_argument('--task-id', help='External task tracker ID')\n    p_create.add_argument('--parent', help='Parent ReasonNode ID')\n    p_create.add_argument(\n        '--infer-contracts', action='store_true',\n        help='Use LLM to infer contracts'\n    )\n\n    # --- record ---\n    p_record = sub.add_parser(\n        'record', help='Record symbols from git diff to a ReasonNode'\n    )\n    p_record.add_argument('--reason', required=True, help='ReasonNode ID')\n    p_record.add_argument(\n        '--base', default='main', help='Base branch for diff'\n    )\n    p_record.add_argument(\n        '--edge-type', default='CREATES',\n        choices=['CREATES', 'MODIFIES'],\n        help='Edge type (default: CREATES)'\n    )\n\n    # --- query ---\n    p_query = sub.add_parser('query', help='Query the reason graph')\n    q_sub = p_query.add_subparsers(dest='query_type')\n\n    q_ctx = q_sub.add_parser(\n        'context', help='Get ReasonNodes for symbols in a file'\n    )\n    q_ctx.add_argument('file', help='File path')\n\n    q_blast = q_sub.add_parser(\n        'blast', help='Blast radius for a ReasonNode'\n    )\n    q_blast.add_argument('reason_id', help='ReasonNode ID')\n\n    q_const = q_sub.add_parser(\n        'constraints', help='Get invariants/contracts for file'\n    )\n    q_const.add_argument('file', help='File path')\n\n    q_risk = q_sub.add_parser(\n        'risk', help='Risk profile for a symbol'\n    )\n    q_risk.add_argument('symbol', help='Symbol name')\n\n    q_prior = q_sub.add_parser(\n        'prior', help='Search for duplicate/prior intents'\n    )\n    q_prior.add_argument('goal', help='Goal text to search')\n    q_prior.add_argument(\n        '--threshold', type=float, default=0.75,\n        help='Similarity threshold (0-1, default: 0.75)'\n    )\n\n    # --- drift ---\n    p_drift = sub.add_parser('drift', help='Drift detection')\n    d_sub = p_drift.add_subparsers(dest='drift_action')\n    d_sub.add_parser('check', help='Run full drift scan')\n    d_file = d_sub.add_parser('file', help='Check drift for a single file (fast)')\n    d_file.add_argument('file_path', help='File path to check')\n    d_resolve = d_sub.add_parser('resolve', help='Resolve a drift event')\n    d_resolve.add_argument('event_id', help='Drift event ID')\n\n    # --- bootstrap ---\n    p_boot = sub.add_parser(\n        'bootstrap', help='Infer ReasonNodes from git history'\n    )\n    p_boot.add_argument(\n        '--days', type=int, default=90, help='Days of history (default: 90)'\n    )\n    p_boot.add_argument(\n        '--no-llm', action='store_true', help='Skip LLM inference'\n    )\n    p_boot.add_argument(\n        '--verbose', '-v', action='store_true', help='Verbose output'\n    )\n\n    # --- status ---\n    sub.add_parser('status', help='Show iCPG statistics')\n\n    args = parser.parse_args(argv)\n    store = ICPGStore(args.project)\n\n    if args.command == 'init':\n        return cmd_init(store)\n    elif args.command == 'create':\n        return cmd_create(store, args)\n    elif args.command == 'record':\n        return cmd_record(store, args)\n    elif args.command == 'query':\n        return cmd_query(store, args)\n    elif args.command == 'drift':\n        return cmd_drift(store, args)\n    elif args.command == 'bootstrap':\n        return cmd_bootstrap(store, args)\n    elif args.command == 'status':\n        return cmd_status(store)\n    else:\n        parser.print_help()\n        return 1\n\n\ndef cmd_init(store: ICPGStore) -> int:\n    store.init_db()\n    print(f'Initialized iCPG at {store.icpg_dir}')\n    print(f'  Database: {store.db_path}')\n    print(f'  .gitignore: created')\n    return 0\n\n\ndef cmd_create(store: ICPGStore, args) -> int:\n    if not store.exists():\n        store.init_db()\n\n    reason = ReasonNode(\n        goal=args.goal,\n        owner=args.owner,\n        decision_type=args.decision_type,\n        scope=args.scope,\n        agent=args.agent,\n        task_id=args.task_id,\n        parent_id=args.parent,\n        source='agent-session' if args.agent else 'manual'\n    )\n\n    if args.infer_contracts:\n        contracts = infer_contracts(reason, project_dir=args.project)\n        reason.preconditions = contracts['preconditions']\n        reason.postconditions = contracts['postconditions']\n        reason.invariants = contracts['invariants']\n\n    store.create_reason(reason)\n\n    # Index in vector store\n    vectors = VectorStore(args.project)\n    vectors.add_reason(reason.id, reason.goal, reason.scope)\n\n    print(f'Created ReasonNode: {reason.id}')\n    print(f'  Goal: {reason.goal}')\n    print(f'  Scope: {\", \".join(reason.scope) or \"(none)\"}')\n    if reason.invariants:\n        print(f'  Invariants: {len(reason.invariants)}')\n    return 0\n\n\ndef cmd_record(store: ICPGStore, args) -> int:\n    if not store.exists():\n        print('Error: No .icpg/ directory. Run `icpg init` first.', file=sys.stderr)\n        return 1\n\n    reason = store.get_reason(args.reason)\n    if not reason:\n        print(f'Error: ReasonNode {args.reason} not found.', file=sys.stderr)\n        return 1\n\n    # Get changed files from git diff\n    try:\n        result = subprocess.run(\n            ['git', 'diff', '--name-only', args.base],\n            capture_output=True, text=True, timeout=10,\n            cwd=str(store.project_dir)\n        )\n        files = [f.strip() for f in result.stdout.strip().split('\\n') if f.strip()]\n    except (subprocess.TimeoutExpired, FileNotFoundError):\n        print('Error: git diff failed.', file=sys.stderr)\n        return 1\n\n    if not files:\n        print('No changed files found.')\n        return 0\n\n    count = 0\n    for fp in files:\n        full_path = store.project_dir / fp\n        if not full_path.exists():\n            continue\n        syms = extract_symbols(str(full_path))\n        for sym in syms:\n            store.upsert_symbol(sym)\n            edge = Edge(\n                from_id=reason.id,\n                to_id=sym.id,\n                edge_type=args.edge_type,\n                confidence=1.0\n            )\n            store.create_edge(edge)\n            count += 1\n\n    # Update reason status\n    store.update_reason_status(reason.id, 'executing')\n\n    print(f'Recorded {count} symbols → ReasonNode {args.reason}')\n    print(f'  Files: {len(files)}')\n    print(f'  Edge type: {args.edge_type}')\n    return 0\n\n\ndef cmd_query(store: ICPGStore, args) -> int:\n    if not store.exists():\n        return 0  # Silent — no DB means no context\n\n    if args.query_type == 'context':\n        return _query_context(store, args.file)\n    elif args.query_type == 'blast':\n        return _query_blast(store, args.reason_id)\n    elif args.query_type == 'constraints':\n        return _query_constraints(store, args.file)\n    elif args.query_type == 'risk':\n        return _query_risk(store, args.symbol)\n    elif args.query_type == 'prior':\n        return _query_prior(store, args)\n    else:\n        print('Specify a query type: context, blast, constraints, risk, prior')\n        return 1\n\n\ndef _resolve_path(store: ICPGStore, file_path: str) -> str:\n    \"\"\"Resolve relative paths to absolute, matching DB storage format.\"\"\"\n    p = Path(file_path)\n    if not p.is_absolute():\n        p = store.project_dir / p\n    return str(p.resolve())\n\n\ndef _query_context(store: ICPGStore, file_path: str) -> int:\n    resolved = _resolve_path(store, file_path)\n    reasons = store.get_reasons_for_file(resolved)\n    if not reasons:\n        return 0\n\n    print(f'INTENTS for {file_path}:')\n    for r in reasons:\n        status_icon = {\n            'proposed': '?', 'executing': '>', 'fulfilled': '+',\n            'drifted': '!', 'rejected': 'x', 'abandoned': '-'\n        }.get(r.status, ' ')\n        print(f'  [{status_icon}] {r.id[:8]} — {r.goal}')\n        print(f'      Owner: {r.owner} | Status: {r.status}')\n        if r.invariants:\n            print(f'      Invariants: {len(r.invariants)}')\n    return 0\n\n\ndef _query_blast(store: ICPGStore, reason_id: str) -> int:\n    blast = store.get_blast_radius(reason_id)\n    reason = blast.get('reason')\n    if not reason:\n        print(f'ReasonNode {reason_id} not found.', file=sys.stderr)\n        return 1\n\n    print(f'BLAST RADIUS for {reason.goal}:')\n    print(f'  Symbols: {blast[\"symbol_count\"]}')\n    for sym in blast['symbols']:\n        print(f'    {sym.symbol_type} {sym.name} ({sym.file_path})')\n    print(f'  Dependent intents: {blast[\"dependent_count\"]}')\n    for dep in blast['dependent_reasons']:\n        print(f'    {dep.id[:8]} — {dep.goal}')\n    if reason.invariants:\n        print(f'  Invariants:')\n        for inv in reason.invariants:\n            print(f'    - {inv}')\n    return 0\n\n\ndef _query_constraints(store: ICPGStore, file_path: str) -> int:\n    resolved = _resolve_path(store, file_path)\n    constraints = store.get_constraints_for_scope([resolved])\n    if not constraints:\n        return 0\n\n    print(f'CONSTRAINTS for {file_path}:')\n    for c in constraints:\n        print(f'  From intent: {c[\"goal\"][:60]}')\n        for inv in c['invariants']:\n            print(f'    INV: {inv}')\n        for post in c['postconditions']:\n            print(f'    POST: {post}')\n        for pre in c['preconditions']:\n            print(f'    PRE: {pre}')\n    return 0\n\n\ndef _query_risk(store: ICPGStore, symbol_name: str) -> int:\n    profile = store.get_risk_profile(symbol_name)\n    if not profile.get('found'):\n        return 0\n\n    sym = profile['symbol']\n    print(f'RISK PROFILE for {symbol_name}:')\n    print(f'  File: {sym.file_path}')\n    print(f'  Type: {sym.symbol_type}')\n    print(f'  Owners: {\", \".join(profile[\"owners\"])}')\n    print(f'  Modifications: {profile[\"modify_count\"]}')\n    print(f'  Active drift: {\"YES\" if profile[\"active_drift\"] else \"no\"}')\n\n    if profile['drift_events']:\n        print(f'  Drift history:')\n        for de in profile['drift_events'][:5]:\n            status = 'resolved' if de.resolved else 'ACTIVE'\n            print(f'    [{status}] {de.description} (severity: {de.severity})')\n    return 0\n\n\ndef _query_prior(store: ICPGStore, args) -> int:\n    vectors = VectorStore(args.project)\n    similar = vectors.search_similar(args.goal, threshold=args.threshold)\n\n    if not similar:\n        print('No similar prior intents found.')\n        return 0\n\n    print(f'SIMILAR INTENTS (threshold: {args.threshold}):')\n    for rid, score in similar:\n        reason = store.get_reason(rid)\n        if reason:\n            print(f'  [{score:.2f}] {reason.id[:8]} — {reason.goal}')\n            print(f'         Status: {reason.status} | Owner: {reason.owner}')\n    return 0\n\n\ndef cmd_drift(store: ICPGStore, args) -> int:\n    if not store.exists():\n        print('No .icpg/ directory. Run `icpg init` first.', file=sys.stderr)\n        return 1\n\n    if args.drift_action == 'check':\n        events = check_all_drift(store)\n        if not events:\n            print('No drift detected.')\n            return 0\n\n        # Save new events\n        for event in events:\n            store.create_drift_event(event)\n\n        print(f'DRIFT DETECTED ({len(events)} events):')\n        for e in events:\n            dims = ', '.join(e.drift_dimensions)\n            print(f'  [{e.severity:.2f}] {e.description}')\n            print(f'         Dimensions: {dims}')\n        return 0\n\n    elif args.drift_action == 'file':\n        resolved = _resolve_path(store, args.file_path)\n        events = check_file_drift(store, resolved)\n        if not events:\n            return 0\n\n        # Persist events\n        for event in events:\n            store.create_drift_event(event)\n\n        basename = Path(resolved).name\n        print(f'DRIFT: {len(events)} symbols drifted in {basename}')\n        for e in events:\n            sym = store._get_symbol(e.symbol_id)\n            name = sym.name if sym else '???'\n            dims = ', '.join(\n                f'{d}({s:.2f})'\n                for d, s in zip(e.drift_dimensions, _drift_scores(e))\n            )\n            print(f'  [{e.severity:.2f}] {name} — {dims}')\n        return 0\n\n    elif args.drift_action == 'resolve':\n        store.resolve_drift(args.event_id)\n        print(f'Resolved drift event {args.event_id}')\n        return 0\n\n    else:\n        print('Specify: drift check, drift file <path>, or drift resolve <id>')\n        return 1\n\n\ndef _drift_scores(event) -> list[float]:\n    \"\"\"Extract per-dimension scores from drift event description.\"\"\"\n    import re\n    scores = []\n    for match in re.finditer(r'\\w+\\((\\d+\\.\\d+)\\)', event.description):\n        scores.append(float(match.group(1)))\n    if not scores:\n        scores = [event.severity] * len(event.drift_dimensions)\n    return scores\n\n\ndef cmd_bootstrap(store: ICPGStore, args) -> int:\n    if not store.exists():\n        store.init_db()\n\n    print(f'Bootstrapping iCPG from last {args.days} days of git history...')\n    stats = bootstrap_from_git(\n        store,\n        days=args.days,\n        use_llm=not args.no_llm,\n        verbose=args.verbose\n    )\n\n    print(f'\\nBootstrap complete:')\n    print(f'  Commit clusters: {stats[\"clusters\"]}')\n    print(f'  ReasonNodes created: {stats[\"reasons_created\"]}')\n    print(f'  Symbols linked: {stats[\"symbols_linked\"]}')\n    if stats.get('skipped'):\n        print(f'  Skipped (duplicates): {stats[\"skipped\"]}')\n    return 0\n\n\ndef cmd_status(store: ICPGStore) -> int:\n    if not store.exists():\n        print('No iCPG database found. Run `icpg init` to create one.')\n        return 0\n\n    stats = store.get_stats()\n    drift = store.get_unresolved_drift()\n\n    print('iCPG STATUS')\n    print(f'  ReasonNodes:      {stats[\"reasons\"]}')\n    print(f'  Symbols:          {stats[\"symbols\"]}')\n    print(f'  Edges:            {stats[\"edges\"]}')\n    print(f'  Unresolved drift: {stats[\"unresolved_drift\"]}')\n\n    if drift:\n        print(f'\\nTop drift events:')\n        for d in drift[:5]:\n            dims = ', '.join(d.drift_dimensions)\n            print(f'  [{d.severity:.2f}] {d.description} ({dims})')\n\n    return 0\n\n\nif __name__ == '__main__':\n    sys.exit(main())\n"
  },
  {
    "path": "scripts/icpg/bootstrap.py",
    "content": "\"\"\"Git history inference — bootstrap iCPG from existing commits.\n\nImplements RFC Section 7.2: replay commit history, cluster by PR or\ntemporal proximity, infer ReasonNodes via LLM, create CREATES/MODIFIES\nedges.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport os\nimport re\nimport subprocess\nfrom datetime import datetime, timedelta, timezone\nfrom pathlib import Path\n\nfrom .contracts import infer_contracts\nfrom .models import Edge, ReasonNode, _now, _uuid\nfrom .store import ICPGStore\nfrom .symbols import extract_symbols\nfrom .vectors import VectorStore\n\n\ndef bootstrap_from_git(\n    store: ICPGStore,\n    days: int = 90,\n    use_llm: bool = True,\n    verbose: bool = False\n) -> dict:\n    \"\"\"Infer ReasonNodes from git commit history.\n\n    Returns stats dict: {clusters, reasons_created, symbols_linked, skipped}.\n    \"\"\"\n    vectors = VectorStore(str(store.project_dir))\n    since = (\n        datetime.now(timezone.utc) - timedelta(days=days)\n    ).strftime('%Y-%m-%d')\n\n    # Step 1: Get commits\n    commits = _get_commits(store.project_dir, since)\n    if verbose:\n        print(f'Found {len(commits)} commits in last {days} days')\n\n    if not commits:\n        return {'clusters': 0, 'reasons_created': 0, 'symbols_linked': 0}\n\n    # Step 2: Cluster commits\n    clusters = _cluster_commits(commits)\n    if verbose:\n        print(f'Clustered into {len(clusters)} groups')\n\n    stats = {'clusters': len(clusters), 'reasons_created': 0, 'symbols_linked': 0, 'skipped': 0}\n\n    for cluster in clusters:\n        # Step 3: Extract info from cluster\n        messages = [c['message'] for c in cluster]\n        files_changed = set()\n        for c in cluster:\n            files_changed.update(c.get('files', []))\n\n        combined_message = '\\n'.join(messages)\n\n        # Step 4: Check for duplicates\n        similar = vectors.search_similar(combined_message, threshold=0.8)\n        if similar:\n            stats['skipped'] += 1\n            if verbose:\n                print(f'  Skipping cluster (duplicate of {similar[0][0]})')\n            continue\n\n        # Step 5: Infer ReasonNode\n        if use_llm:\n            reason = _infer_via_llm(combined_message, list(files_changed))\n        else:\n            reason = _infer_from_messages(combined_message, list(files_changed))\n\n        if not reason:\n            stats['skipped'] += 1\n            continue\n\n        # Step 6: Create reason and index\n        store.create_reason(reason)\n        vectors.add_reason(reason.id, reason.goal, reason.scope)\n        stats['reasons_created'] += 1\n\n        if verbose:\n            print(f'  Created: {reason.goal[:60]}...')\n\n        # Step 7: Link symbols\n        for fp in files_changed:\n            full_path = store.project_dir / fp\n            if not full_path.exists():\n                continue\n            syms = extract_symbols(str(full_path))\n            for sym in syms:\n                store.upsert_symbol(sym)\n                edge = Edge(\n                    from_id=reason.id,\n                    to_id=sym.id,\n                    edge_type='CREATES',\n                    confidence=0.6\n                )\n                store.create_edge(edge)\n                stats['symbols_linked'] += 1\n\n        # Step 8: Infer contracts (if LLM available)\n        if use_llm and not reason.postconditions:\n            contracts = infer_contracts(reason, project_dir=str(store.project_dir))\n            if any(contracts.values()):\n                reason.preconditions = contracts['preconditions']\n                reason.postconditions = contracts['postconditions']\n                reason.invariants = contracts['invariants']\n                # Update in DB\n                with store._conn() as conn:\n                    conn.execute(\n                        \"\"\"UPDATE reasons SET\n                           preconditions = ?, postconditions = ?, invariants = ?\n                           WHERE id = ?\"\"\",\n                        (\n                            json.dumps(reason.preconditions),\n                            json.dumps(reason.postconditions),\n                            json.dumps(reason.invariants),\n                            reason.id\n                        )\n                    )\n\n    return stats\n\n\ndef _get_commits(project_dir: Path, since: str) -> list[dict]:\n    \"\"\"Get commits with messages and changed files.\"\"\"\n    try:\n        result = subprocess.run(\n            [\n                'git', 'log', f'--since={since}',\n                '--format=__COMMIT__%n%H%n%an%n%aI%n%s',\n                '--name-only'\n            ],\n            capture_output=True, text=True, timeout=30,\n            cwd=str(project_dir)\n        )\n    except (subprocess.TimeoutExpired, FileNotFoundError):\n        return []\n\n    if result.returncode != 0:\n        return []\n\n    commits = []\n    raw_blocks = result.stdout.split('__COMMIT__\\n')\n\n    for block in raw_blocks:\n        block = block.strip()\n        if not block:\n            continue\n\n        lines = block.split('\\n')\n        if len(lines) < 4:\n            continue\n\n        sha = lines[0].strip()\n        author = lines[1].strip()\n        date = lines[2].strip()\n        message = lines[3].strip()\n\n        # Files come after a blank line separator\n        files = []\n        past_blank = False\n        for line in lines[4:]:\n            stripped = line.strip()\n            if not stripped:\n                past_blank = True\n                continue\n            if past_blank and stripped:\n                files.append(stripped)\n\n        commits.append({\n            'sha': sha,\n            'author': author,\n            'date': date,\n            'message': message,\n            'files': files\n        })\n\n    return commits\n\n\ndef _cluster_commits(\n    commits: list[dict], window_hours: int = 2\n) -> list[list[dict]]:\n    \"\"\"Cluster commits by temporal proximity.\"\"\"\n    if not commits:\n        return []\n\n    clusters = []\n    current_cluster = [commits[0]]\n\n    for commit in commits[1:]:\n        try:\n            prev_date = datetime.fromisoformat(\n                current_cluster[-1]['date'].replace('Z', '+00:00')\n            )\n            curr_date = datetime.fromisoformat(\n                commit['date'].replace('Z', '+00:00')\n            )\n            delta = abs((curr_date - prev_date).total_seconds())\n\n            if delta <= window_hours * 3600:\n                current_cluster.append(commit)\n            else:\n                clusters.append(current_cluster)\n                current_cluster = [commit]\n        except (ValueError, KeyError):\n            clusters.append(current_cluster)\n            current_cluster = [commit]\n\n    if current_cluster:\n        clusters.append(current_cluster)\n\n    return clusters\n\n\ndef _infer_via_llm(\n    messages: str, files: list[str]\n) -> ReasonNode | None:\n    \"\"\"Use LLM to infer a ReasonNode from commit messages.\"\"\"\n    scope_str = ', '.join(files[:20])\n    prompt = f\"\"\"Given these git commit messages, infer the intent/goal.\n\nCOMMITS:\n{messages[:2000]}\n\nFILES CHANGED:\n{scope_str}\n\nReturn ONLY a JSON object:\n{{\n  \"goal\": \"one-sentence description of what this change was trying to achieve\",\n  \"decision_type\": \"task|business_goal|arch_decision|workaround|constraint|patch\",\n  \"scope\": [\"file1\", \"file2\"]\n}}\"\"\"\n\n    # Try Claude CLI\n    try:\n        result = subprocess.run(\n            ['claude', '--print', '-p', prompt],\n            capture_output=True, text=True, timeout=30\n        )\n        if result.returncode == 0:\n            return _parse_reason_response(result.stdout, files)\n    except (FileNotFoundError, subprocess.TimeoutExpired):\n        pass\n\n    # Try OpenAI\n    try:\n        import openai\n        client = openai.OpenAI()\n        response = client.chat.completions.create(\n            model='gpt-4o-mini',\n            messages=[{'role': 'user', 'content': prompt}],\n            temperature=0.2\n        )\n        content = response.choices[0].message.content or ''\n        return _parse_reason_response(content, files)\n    except Exception:\n        pass\n\n    # Fallback\n    return _infer_from_messages(messages, files)\n\n\ndef _infer_from_messages(\n    messages: str, files: list[str]\n) -> ReasonNode | None:\n    \"\"\"Extract ReasonNode from commit messages without LLM.\"\"\"\n    # Use first line as goal\n    first_line = messages.split('\\n')[0].strip()\n    if not first_line:\n        return None\n\n    # Detect decision type from conventional commits\n    dtype = 'task'\n    if first_line.startswith('feat'):\n        dtype = 'business_goal'\n    elif first_line.startswith('fix'):\n        dtype = 'patch'\n    elif first_line.startswith('refactor'):\n        dtype = 'arch_decision'\n    elif first_line.startswith('chore') or first_line.startswith('ci'):\n        dtype = 'constraint'\n\n    # Clean up conventional commit prefix\n    goal = re.sub(r'^(feat|fix|refactor|chore|ci|docs|test)(\\([^)]*\\))?:\\s*', '', first_line)\n\n    return ReasonNode(\n        id=_uuid(),\n        goal=goal or first_line,\n        decision_type=dtype,\n        scope=files[:20],\n        owner='git-history',\n        source='inferred',\n        status='fulfilled',\n        created_at=_now()\n    )\n\n\ndef _parse_reason_response(\n    response: str, fallback_files: list[str]\n) -> ReasonNode | None:\n    \"\"\"Parse LLM response into a ReasonNode.\"\"\"\n    try:\n        start = response.find('{')\n        end = response.rfind('}') + 1\n        if start >= 0 and end > start:\n            data = json.loads(response[start:end])\n            return ReasonNode(\n                id=_uuid(),\n                goal=data.get('goal', ''),\n                decision_type=data.get('decision_type', 'task'),\n                scope=data.get('scope', fallback_files[:20]),\n                owner='git-history',\n                source='inferred',\n                status='fulfilled',\n                created_at=_now()\n            )\n    except (json.JSONDecodeError, KeyError):\n        pass\n    return None\n"
  },
  {
    "path": "scripts/icpg/contracts.py",
    "content": "\"\"\"Design by Contract layer for ReasonNodes.\n\nHandles inference, evaluation, and formatting of preconditions,\npostconditions, and invariants.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport os\nimport subprocess\nfrom pathlib import Path\n\nfrom .models import ReasonNode\n\n\ndef infer_contracts(\n    reason: ReasonNode,\n    code_context: str = '',\n    project_dir: str = '.'\n) -> dict[str, list[str]]:\n    \"\"\"Use LLM to infer contracts from stated purpose + code context.\n\n    Returns dict with 'preconditions', 'postconditions', 'invariants'.\n    Falls back to heuristic extraction if no LLM available.\n    \"\"\"\n    # Try Claude CLI first\n    api_key = os.environ.get('ANTHROPIC_API_KEY')\n    if api_key:\n        return _infer_via_claude(reason, code_context)\n\n    # Try OpenAI\n    openai_key = os.environ.get('OPENAI_API_KEY')\n    if openai_key:\n        return _infer_via_openai(reason, code_context)\n\n    # Fallback: heuristic extraction\n    return _infer_heuristic(reason, project_dir)\n\n\ndef _infer_via_claude(\n    reason: ReasonNode, code_context: str\n) -> dict[str, list[str]]:\n    \"\"\"Call Claude API to infer contracts.\"\"\"\n    prompt = _build_inference_prompt(reason, code_context)\n    try:\n        result = subprocess.run(\n            ['claude', '--print', '-p', prompt],\n            capture_output=True, text=True, timeout=30\n        )\n        if result.returncode == 0:\n            return _parse_contract_response(result.stdout)\n    except (FileNotFoundError, subprocess.TimeoutExpired):\n        pass\n    return _empty_contracts()\n\n\ndef _infer_via_openai(\n    reason: ReasonNode, code_context: str\n) -> dict[str, list[str]]:\n    \"\"\"Call OpenAI API to infer contracts.\"\"\"\n    try:\n        import openai\n        client = openai.OpenAI()\n        prompt = _build_inference_prompt(reason, code_context)\n        response = client.chat.completions.create(\n            model='gpt-4o-mini',\n            messages=[{'role': 'user', 'content': prompt}],\n            temperature=0.2\n        )\n        return _parse_contract_response(\n            response.choices[0].message.content or ''\n        )\n    except Exception:\n        return _empty_contracts()\n\n\ndef _infer_heuristic(\n    reason: ReasonNode, project_dir: str\n) -> dict[str, list[str]]:\n    \"\"\"Basic heuristic contract extraction — no LLM needed.\"\"\"\n    pre = []\n    post = []\n    inv = []\n\n    # Scope-based invariants\n    for scope_path in reason.scope:\n        inv.append(f'file_exists(\"{scope_path}\")')\n\n    # If goal mentions \"test\" or \"validation\"\n    goal_lower = reason.goal.lower()\n    if 'test' in goal_lower:\n        for sp in reason.scope:\n            if 'test' not in sp:\n                test_path = _guess_test_path(sp)\n                if test_path:\n                    post.append(f'test_exists(\"{test_path}\")')\n\n    return {\n        'preconditions': pre,\n        'postconditions': post,\n        'invariants': inv\n    }\n\n\ndef _build_inference_prompt(\n    reason: ReasonNode, code_context: str\n) -> str:\n    scope_str = ', '.join(reason.scope) if reason.scope else 'unspecified'\n    return f\"\"\"Given this intent for a code change, infer formal contracts.\n\nINTENT:\n  Goal: {reason.goal}\n  Decision type: {reason.decision_type}\n  Scope: {scope_str}\n\n{f'CODE CONTEXT:{chr(10)}{code_context[:2000]}' if code_context else ''}\n\nReturn ONLY a JSON object with three arrays:\n{{\n  \"preconditions\": [\"predicate1\", \"predicate2\"],\n  \"postconditions\": [\"predicate1\", \"predicate2\"],\n  \"invariants\": [\"predicate1\", \"predicate2\"]\n}}\n\nPredicate format examples:\n  file_exists(\"src/auth/middleware.ts\")\n  test_exists(\"src/auth/__tests__/middleware.test.ts\")\n  symbol_count(\"src/auth/\") <= 15\n  function_signature(\"validateToken\") == \"(token: string) => Promise<User>\"\n\nRules:\n- Preconditions: what must exist before this change\n- Postconditions: what must be true after this change is complete\n- Invariants: what must NOT change during or after this change\n- Be specific. Use file paths from the scope.\n- 2-5 predicates per category max.\"\"\"\n\n\ndef _parse_contract_response(response: str) -> dict[str, list[str]]:\n    \"\"\"Parse LLM response into contract dict.\"\"\"\n    # Try to extract JSON\n    try:\n        # Find JSON block\n        start = response.find('{')\n        end = response.rfind('}') + 1\n        if start >= 0 and end > start:\n            data = json.loads(response[start:end])\n            return {\n                'preconditions': data.get('preconditions', []),\n                'postconditions': data.get('postconditions', []),\n                'invariants': data.get('invariants', [])\n            }\n    except (json.JSONDecodeError, KeyError):\n        pass\n    return _empty_contracts()\n\n\ndef _empty_contracts() -> dict[str, list[str]]:\n    return {'preconditions': [], 'postconditions': [], 'invariants': []}\n\n\ndef _guess_test_path(source_path: str) -> str | None:\n    \"\"\"Guess test file path from source path.\"\"\"\n    p = Path(source_path)\n    stem = p.stem\n    suffix = p.suffix\n\n    # Python: test_foo.py\n    if suffix == '.py':\n        test_dir = p.parent / 'tests'\n        return str(test_dir / f'test_{stem}.py')\n\n    # TS/JS: foo.test.ts\n    if suffix in ('.ts', '.tsx', '.js', '.jsx'):\n        return str(p.parent / f'{stem}.test{suffix}')\n\n    return None\n\n\ndef format_contracts(reason: ReasonNode) -> str:\n    \"\"\"Format contracts for human-readable display.\"\"\"\n    lines = []\n\n    if reason.preconditions:\n        lines.append('PRECONDITIONS:')\n        for p in reason.preconditions:\n            lines.append(f'  - {p}')\n\n    if reason.postconditions:\n        lines.append('POSTCONDITIONS:')\n        for p in reason.postconditions:\n            lines.append(f'  - {p}')\n\n    if reason.invariants:\n        lines.append('INVARIANTS:')\n        for p in reason.invariants:\n            lines.append(f'  - {p}')\n\n    return '\\n'.join(lines) if lines else '(no contracts defined)'\n"
  },
  {
    "path": "scripts/icpg/drift.py",
    "content": "\"\"\"6-dimension drift detection per RFC Section 6.\"\"\"\n\nfrom __future__ import annotations\n\nimport subprocess\nfrom pathlib import Path\n\nfrom .models import DriftEvent, Edge, _now, _uuid\nfrom .store import ICPGStore\nfrom .symbols import extract_symbols\n\n\ndef check_file_drift(store: ICPGStore, file_path: str) -> list[DriftEvent]:\n    \"\"\"Check drift for symbols in a single file only. Fast path for hooks.\"\"\"\n    symbols = store.get_symbols_for_file(file_path)\n    events = []\n    for sym in symbols:\n        event = check_symbol_drift(store, sym.id)\n        if event:\n            events.append(event)\n    return events\n\n\ndef check_all_drift(store: ICPGStore) -> list[DriftEvent]:\n    \"\"\"Full drift scan across all tracked symbols.\"\"\"\n    events = []\n    reasons = store.list_reasons()\n\n    for reason in reasons:\n        if reason.status in ('rejected', 'abandoned'):\n            continue\n\n        creates_edges = store.get_edges_from(reason.id, 'CREATES')\n        for edge in creates_edges:\n            sym = store._get_symbol(edge.to_id)\n            if not sym:\n                continue\n            event = check_symbol_drift(store, sym.id)\n            if event:\n                events.append(event)\n\n    return events\n\n\ndef check_symbol_drift(\n    store: ICPGStore, symbol_id: str\n) -> DriftEvent | None:\n    \"\"\"Check a single symbol for drift across all 6 dimensions.\"\"\"\n    sym = store._get_symbol(symbol_id)\n    if not sym:\n        return None\n\n    # Find creating reason\n    creates_edges = store.get_edges_to(symbol_id, 'CREATES')\n    if not creates_edges:\n        return None\n    reason = store.get_reason(creates_edges[0].from_id)\n    if not reason:\n        return None\n\n    dimensions = []\n    severity_scores = []\n\n    # 1. Spec drift — checksum changed without MODIFIES edge\n    spec = _check_spec_drift(store, sym, reason)\n    if spec:\n        dimensions.append('spec')\n        severity_scores.append(spec)\n\n    # 2. Decision drift — postconditions no longer hold\n    decision = _check_decision_drift(store, reason)\n    if decision:\n        dimensions.append('decision')\n        severity_scores.append(decision)\n\n    # 3. Ownership drift — >3 different owners\n    ownership = _check_ownership_drift(store, sym)\n    if ownership:\n        dimensions.append('ownership')\n        severity_scores.append(ownership)\n\n    # 4. Test drift — VALIDATED_BY tests missing or failing\n    test = _check_test_drift(store, reason)\n    if test:\n        dimensions.append('test')\n        severity_scores.append(test)\n\n    # 5. Usage drift — used outside original scope\n    usage = _check_usage_drift(store, sym, reason)\n    if usage:\n        dimensions.append('usage')\n        severity_scores.append(usage)\n\n    # 6. Dependency drift — downstream coupling changed\n    dep = _check_dependency_drift(store, reason)\n    if dep:\n        dimensions.append('dependency')\n        severity_scores.append(dep)\n\n    if not dimensions:\n        return None\n\n    avg_severity = sum(severity_scores) / len(severity_scores)\n    desc_parts = [f'{d}({s:.2f})' for d, s in zip(dimensions, severity_scores)]\n\n    return DriftEvent(\n        id=_uuid(),\n        symbol_id=symbol_id,\n        from_reason_id=reason.id,\n        drift_dimensions=dimensions,\n        severity=round(avg_severity, 2),\n        description=f'Drift detected: {\", \".join(desc_parts)}',\n        detected_at=_now()\n    )\n\n\ndef _check_spec_drift(store, sym, reason) -> float | None:\n    \"\"\"Symbol checksum changed since creation without a MODIFIES edge.\"\"\"\n    # Re-extract current symbol\n    current_symbols = extract_symbols(sym.file_path)\n    current = next((s for s in current_symbols if s.name == sym.name), None)\n    if not current:\n        return 0.8  # Symbol removed entirely\n\n    if current.checksum != sym.checksum:\n        # Check if there's a MODIFIES edge explaining the change\n        mod_edges = store.get_edges_to(sym.id, 'MODIFIES')\n        if not mod_edges:\n            return 0.6  # Changed without explanation\n    return None\n\n\ndef _check_decision_drift(store, reason) -> float | None:\n    \"\"\"ReasonNode postconditions no longer hold.\"\"\"\n    if not reason.postconditions:\n        return None\n\n    failed = 0\n    for predicate in reason.postconditions:\n        if not evaluate_predicate(predicate, store.project_dir):\n            failed += 1\n\n    if failed > 0:\n        return min(1.0, failed / len(reason.postconditions))\n    return None\n\n\ndef _check_ownership_drift(store, sym) -> float | None:\n    \"\"\"Symbol touched by >3 different owners.\"\"\"\n    edges = store.get_edges_to(sym.id)\n    owners = set()\n    for edge in edges:\n        reason = store.get_reason(edge.from_id)\n        if reason:\n            owners.add(reason.owner)\n\n    if len(owners) > 3:\n        return min(1.0, (len(owners) - 3) / 5)\n    return None\n\n\ndef _check_test_drift(store, reason) -> float | None:\n    \"\"\"VALIDATED_BY tests no longer exist or fail.\"\"\"\n    test_edges = store.get_edges_from(reason.id, 'VALIDATED_BY')\n    if not test_edges:\n        # No tests linked — mild concern\n        return 0.3\n\n    missing = 0\n    for edge in test_edges:\n        test_sym = store._get_symbol(edge.to_id)\n        if not test_sym or not Path(test_sym.file_path).exists():\n            missing += 1\n\n    if missing > 0:\n        return min(1.0, missing / len(test_edges))\n    return None\n\n\ndef _check_usage_drift(store, sym, reason) -> float | None:\n    \"\"\"Symbol imported from scopes outside original ReasonNode scope.\"\"\"\n    if not reason.scope:\n        return None\n\n    # Use grep to find imports/usages of the symbol\n    try:\n        result = subprocess.run(\n            ['grep', '-rl', sym.name, '.'],\n            capture_output=True, text=True, timeout=5,\n            cwd=str(store.project_dir)\n        )\n    except (subprocess.TimeoutExpired, FileNotFoundError):\n        return None\n\n    if result.returncode != 0:\n        return None\n\n    usage_files = [\n        f.strip().lstrip('./') for f in result.stdout.strip().split('\\n')\n        if f.strip()\n    ]\n\n    out_of_scope = 0\n    for uf in usage_files:\n        if not any(uf.startswith(s.rstrip('/')) for s in reason.scope):\n            out_of_scope += 1\n\n    if out_of_scope > 2:\n        return min(1.0, out_of_scope / 10)\n    return None\n\n\ndef _check_dependency_drift(store, reason) -> float | None:\n    \"\"\"Downstream REQUIRES reasons have drifted or changed status.\"\"\"\n    req_edges = store.get_edges_to(reason.id, 'REQUIRES')\n    if not req_edges:\n        return None\n\n    drifted = 0\n    for edge in req_edges:\n        dep_reason = store.get_reason(edge.from_id)\n        if dep_reason and dep_reason.status == 'drifted':\n            drifted += 1\n\n    if drifted > 0:\n        return min(1.0, drifted / len(req_edges))\n    return None\n\n\ndef evaluate_predicate(predicate: str, project_dir: Path) -> bool:\n    \"\"\"Evaluate a single structured predicate against codebase state.\n\n    Supported predicates:\n        file_exists(\"path\")\n        test_exists(\"path\")\n        symbol_count(\"dir/\") <= N\n        function_signature(\"name\") == \"sig\"\n    \"\"\"\n    predicate = predicate.strip()\n\n    # file_exists(\"path\")\n    m = _match_predicate(predicate, 'file_exists')\n    if m:\n        return (project_dir / m).exists()\n\n    # test_exists(\"path\")\n    m = _match_predicate(predicate, 'test_exists')\n    if m:\n        return (project_dir / m).exists()\n\n    # symbol_count(\"dir/\") <= N\n    import re\n    sc = re.match(\n        r'symbol_count\\(\"([^\"]+)\"\\)\\s*(<=|>=|==|<|>)\\s*(\\d+)', predicate\n    )\n    if sc:\n        dir_path, op, threshold = sc.group(1), sc.group(2), int(sc.group(3))\n        count = _count_symbols_in_dir(project_dir / dir_path)\n        return _compare(count, op, threshold)\n\n    # Unrecognized predicate — pass (don't block on unknown)\n    return True\n\n\ndef _match_predicate(predicate: str, func_name: str) -> str | None:\n    import re\n    m = re.match(rf'{func_name}\\(\"([^\"]+)\"\\)', predicate)\n    return m.group(1) if m else None\n\n\ndef _count_symbols_in_dir(dir_path: Path) -> int:\n    if not dir_path.is_dir():\n        return 0\n    count = 0\n    for f in dir_path.rglob('*'):\n        if f.is_file():\n            count += len(extract_symbols(str(f)))\n    return count\n\n\ndef _compare(value: int, op: str, threshold: int) -> bool:\n    ops = {\n        '<=': value <= threshold,\n        '>=': value >= threshold,\n        '==': value == threshold,\n        '<': value < threshold,\n        '>': value > threshold,\n    }\n    return ops.get(op, True)\n"
  },
  {
    "path": "scripts/icpg/models.py",
    "content": "\"\"\"Data models for iCPG — ReasonNode, Symbol, Edge, DriftEvent.\"\"\"\n\nfrom __future__ import annotations\n\nimport hashlib\nimport uuid\nfrom dataclasses import dataclass, field\nfrom datetime import datetime, timezone\n\n\ndef _now() -> str:\n    return datetime.now(timezone.utc).isoformat()\n\n\ndef _uuid() -> str:\n    return str(uuid.uuid4())\n\n\ndef symbol_id(file_path: str, name: str, symbol_type: str) -> str:\n    \"\"\"Deterministic ID for a symbol: hash of file:name:type.\"\"\"\n    raw = f'{file_path}:{name}:{symbol_type}'\n    return hashlib.sha256(raw.encode()).hexdigest()[:16]\n\n\n# --- Decision types ---\nDECISION_TYPES = (\n    'business_goal', 'arch_decision', 'task',\n    'workaround', 'constraint', 'patch'\n)\n\n# --- ReasonNode statuses ---\nREASON_STATUSES = (\n    'proposed', 'executing', 'fulfilled',\n    'rejected', 'drifted', 'abandoned'\n)\n\n# --- Source types ---\nSOURCE_TYPES = (\n    'manual', 'commit', 'migration',\n    'inferred', 'agent-session'\n)\n\n# --- Edge types ---\nEDGE_TYPES = (\n    'CREATES', 'MODIFIES', 'REQUIRES',\n    'DUPLICATES', 'VALIDATED_BY', 'DRIFTS_FROM'\n)\n\n# --- Drift dimensions ---\nDRIFT_DIMENSIONS = (\n    'spec', 'decision', 'ownership',\n    'test', 'usage', 'dependency'\n)\n\n# --- Symbol types ---\nSYMBOL_TYPES = (\n    'function', 'class', 'module', 'route',\n    'schema', 'component', 'interface', 'type',\n    'constant', 'hook'\n)\n\n\n@dataclass\nclass ReasonNode:\n    \"\"\"A single intent/decision that drives code changes.\"\"\"\n\n    goal: str\n    owner: str\n    id: str = field(default_factory=_uuid)\n    decision_type: str = 'task'\n    scope: list[str] = field(default_factory=list)\n    agent: str | None = None\n    status: str = 'proposed'\n    source: str = 'manual'\n    task_id: str | None = None\n    parent_id: str | None = None\n    # Design by Contract layer\n    preconditions: list[str] = field(default_factory=list)\n    postconditions: list[str] = field(default_factory=list)\n    invariants: list[str] = field(default_factory=list)\n    created_at: str = field(default_factory=_now)\n    fulfilled_at: str | None = None\n\n\n@dataclass\nclass Symbol:\n    \"\"\"A code entity: function, class, module, etc.\"\"\"\n\n    name: str\n    file_path: str\n    symbol_type: str\n    language: str\n    id: str = ''\n    signature: str | None = None\n    checksum: str = ''\n    created_at: str = field(default_factory=_now)\n\n    def __post_init__(self):\n        if not self.id:\n            self.id = symbol_id(self.file_path, self.name, self.symbol_type)\n\n\n@dataclass\nclass Edge:\n    \"\"\"A typed relationship between nodes.\"\"\"\n\n    from_id: str\n    to_id: str\n    edge_type: str\n    id: str = field(default_factory=_uuid)\n    confidence: float = 1.0\n    created_at: str = field(default_factory=_now)\n\n\n@dataclass\nclass DriftEvent:\n    \"\"\"Auto-generated when behavior diverges from intent.\"\"\"\n\n    symbol_id: str\n    from_reason_id: str\n    description: str\n    id: str = field(default_factory=_uuid)\n    drift_dimensions: list[str] = field(default_factory=list)\n    severity: float = 0.5\n    resolved: bool = False\n    detected_at: str = field(default_factory=_now)\n"
  },
  {
    "path": "scripts/icpg/pyproject.toml",
    "content": "[project]\nname = \"icpg\"\nversion = \"0.1.0\"\ndescription = \"iCPG — Intent-Augmented Code Property Graph for agentic development\"\nrequires-python = \">=3.10\"\nlicense = {text = \"MIT\"}\nreadme = \"README.md\"\n\ndependencies = []\n\n[project.optional-dependencies]\nvectors = [\n    \"chromadb>=0.4.0\",\n    \"sentence-transformers>=2.2.0\",\n]\ntfidf = [\n    \"scikit-learn>=1.3.0\",\n]\nllm = [\n    \"openai>=1.0.0\",\n]\nall = [\n    \"icpg[vectors,tfidf,llm]\",\n]\n\n[project.scripts]\nicpg = \"icpg.__main__:main\"\n\n[build-system]\nrequires = [\"setuptools>=68.0\"]\nbuild-backend = \"setuptools.build_meta\"\n"
  },
  {
    "path": "scripts/icpg/store.py",
    "content": "\"\"\"SQLite storage layer for iCPG reason graph.\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport os\nimport sqlite3\nfrom pathlib import Path\nfrom typing import Any\n\nfrom .models import DriftEvent, Edge, ReasonNode, Symbol\n\nICPG_DIR = '.icpg'\nDB_NAME = 'reason.db'\n\nSCHEMA = \"\"\"\nCREATE TABLE IF NOT EXISTS reasons (\n    id TEXT PRIMARY KEY,\n    goal TEXT NOT NULL,\n    decision_type TEXT DEFAULT 'task',\n    scope TEXT DEFAULT '[]',\n    owner TEXT NOT NULL,\n    agent TEXT,\n    status TEXT DEFAULT 'proposed',\n    source TEXT DEFAULT 'manual',\n    task_id TEXT,\n    parent_id TEXT REFERENCES reasons(id),\n    preconditions TEXT DEFAULT '[]',\n    postconditions TEXT DEFAULT '[]',\n    invariants TEXT DEFAULT '[]',\n    created_at TEXT NOT NULL,\n    fulfilled_at TEXT\n);\n\nCREATE TABLE IF NOT EXISTS symbols (\n    id TEXT PRIMARY KEY,\n    name TEXT NOT NULL,\n    file_path TEXT NOT NULL,\n    symbol_type TEXT NOT NULL,\n    language TEXT NOT NULL,\n    signature TEXT,\n    checksum TEXT,\n    created_at TEXT NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS edges (\n    id TEXT PRIMARY KEY,\n    from_id TEXT NOT NULL,\n    to_id TEXT NOT NULL,\n    edge_type TEXT NOT NULL,\n    confidence REAL DEFAULT 1.0,\n    created_at TEXT NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS drift_events (\n    id TEXT PRIMARY KEY,\n    symbol_id TEXT NOT NULL,\n    from_reason_id TEXT NOT NULL,\n    drift_dimensions TEXT DEFAULT '[]',\n    severity REAL DEFAULT 0.5,\n    description TEXT,\n    resolved INTEGER DEFAULT 0,\n    detected_at TEXT NOT NULL\n);\n\nCREATE INDEX IF NOT EXISTS idx_edges_from ON edges(from_id);\nCREATE INDEX IF NOT EXISTS idx_edges_to ON edges(to_id);\nCREATE INDEX IF NOT EXISTS idx_edges_type ON edges(edge_type);\nCREATE INDEX IF NOT EXISTS idx_symbols_file ON symbols(file_path);\nCREATE INDEX IF NOT EXISTS idx_symbols_name ON symbols(name);\nCREATE INDEX IF NOT EXISTS idx_drift_symbol ON drift_events(symbol_id);\nCREATE INDEX IF NOT EXISTS idx_drift_resolved ON drift_events(resolved);\nCREATE INDEX IF NOT EXISTS idx_reasons_status ON reasons(status);\n\"\"\"\n\n\nclass ICPGStore:\n    \"\"\"SQLite-backed storage for the iCPG reason graph.\"\"\"\n\n    def __init__(self, project_dir: str = '.'):\n        self.project_dir = Path(project_dir).resolve()\n        self.icpg_dir = self.project_dir / ICPG_DIR\n        self.db_path = self.icpg_dir / DB_NAME\n\n    def init_db(self) -> None:\n        \"\"\"Create .icpg/ directory and initialize schema.\"\"\"\n        self.icpg_dir.mkdir(parents=True, exist_ok=True)\n        gitignore = self.icpg_dir / '.gitignore'\n        if not gitignore.exists():\n            gitignore.write_text('*\\n')\n        with self._conn() as conn:\n            conn.executescript(SCHEMA)\n\n    def exists(self) -> bool:\n        return self.db_path.exists()\n\n    def _conn(self) -> sqlite3.Connection:\n        conn = sqlite3.connect(str(self.db_path))\n        conn.row_factory = sqlite3.Row\n        conn.execute('PRAGMA journal_mode=WAL')\n        conn.execute('PRAGMA foreign_keys=ON')\n        return conn\n\n    # --- ReasonNode CRUD ---\n\n    def create_reason(self, node: ReasonNode) -> str:\n        with self._conn() as conn:\n            conn.execute(\n                \"\"\"INSERT INTO reasons\n                   (id, goal, decision_type, scope, owner, agent, status,\n                    source, task_id, parent_id, preconditions, postconditions,\n                    invariants, created_at, fulfilled_at)\n                   VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)\"\"\",\n                (\n                    node.id, node.goal, node.decision_type,\n                    json.dumps(node.scope), node.owner, node.agent,\n                    node.status, node.source, node.task_id, node.parent_id,\n                    json.dumps(node.preconditions),\n                    json.dumps(node.postconditions),\n                    json.dumps(node.invariants),\n                    node.created_at, node.fulfilled_at\n                )\n            )\n        return node.id\n\n    def get_reason(self, reason_id: str) -> ReasonNode | None:\n        with self._conn() as conn:\n            row = conn.execute(\n                'SELECT * FROM reasons WHERE id = ?', (reason_id,)\n            ).fetchone()\n        if not row:\n            return None\n        return self._row_to_reason(row)\n\n    def list_reasons(self, status: str | None = None) -> list[ReasonNode]:\n        with self._conn() as conn:\n            if status:\n                rows = conn.execute(\n                    'SELECT * FROM reasons WHERE status = ? ORDER BY created_at',\n                    (status,)\n                ).fetchall()\n            else:\n                rows = conn.execute(\n                    'SELECT * FROM reasons ORDER BY created_at'\n                ).fetchall()\n        return [self._row_to_reason(r) for r in rows]\n\n    def update_reason_status(\n        self, reason_id: str, status: str,\n        fulfilled_at: str | None = None\n    ) -> None:\n        with self._conn() as conn:\n            conn.execute(\n                'UPDATE reasons SET status = ?, fulfilled_at = ? WHERE id = ?',\n                (status, fulfilled_at, reason_id)\n            )\n\n    # --- Symbol CRUD ---\n\n    def upsert_symbol(self, sym: Symbol) -> str:\n        with self._conn() as conn:\n            conn.execute(\n                \"\"\"INSERT INTO symbols\n                   (id, name, file_path, symbol_type, language, signature,\n                    checksum, created_at)\n                   VALUES (?,?,?,?,?,?,?,?)\n                   ON CONFLICT(id) DO UPDATE SET\n                    signature=excluded.signature,\n                    checksum=excluded.checksum\"\"\",\n                (\n                    sym.id, sym.name, sym.file_path, sym.symbol_type,\n                    sym.language, sym.signature, sym.checksum, sym.created_at\n                )\n            )\n        return sym.id\n\n    def get_symbols_for_file(self, file_path: str) -> list[Symbol]:\n        with self._conn() as conn:\n            rows = conn.execute(\n                'SELECT * FROM symbols WHERE file_path = ?', (file_path,)\n            ).fetchall()\n        return [self._row_to_symbol(r) for r in rows]\n\n    def get_symbol_by_name(self, name: str) -> list[Symbol]:\n        with self._conn() as conn:\n            rows = conn.execute(\n                'SELECT * FROM symbols WHERE name = ?', (name,)\n            ).fetchall()\n        return [self._row_to_symbol(r) for r in rows]\n\n    # --- Edge CRUD ---\n\n    def create_edge(self, edge: Edge) -> str:\n        with self._conn() as conn:\n            conn.execute(\n                \"\"\"INSERT OR IGNORE INTO edges\n                   (id, from_id, to_id, edge_type, confidence, created_at)\n                   VALUES (?,?,?,?,?,?)\"\"\",\n                (\n                    edge.id, edge.from_id, edge.to_id,\n                    edge.edge_type, edge.confidence, edge.created_at\n                )\n            )\n        return edge.id\n\n    def get_edges_from(\n        self, node_id: str, edge_type: str | None = None\n    ) -> list[Edge]:\n        with self._conn() as conn:\n            if edge_type:\n                rows = conn.execute(\n                    'SELECT * FROM edges WHERE from_id = ? AND edge_type = ?',\n                    (node_id, edge_type)\n                ).fetchall()\n            else:\n                rows = conn.execute(\n                    'SELECT * FROM edges WHERE from_id = ?', (node_id,)\n                ).fetchall()\n        return [self._row_to_edge(r) for r in rows]\n\n    def get_edges_to(\n        self, node_id: str, edge_type: str | None = None\n    ) -> list[Edge]:\n        with self._conn() as conn:\n            if edge_type:\n                rows = conn.execute(\n                    'SELECT * FROM edges WHERE to_id = ? AND edge_type = ?',\n                    (node_id, edge_type)\n                ).fetchall()\n            else:\n                rows = conn.execute(\n                    'SELECT * FROM edges WHERE to_id = ?', (node_id,)\n                ).fetchall()\n        return [self._row_to_edge(r) for r in rows]\n\n    # --- DriftEvent CRUD ---\n\n    def create_drift_event(self, event: DriftEvent) -> str:\n        with self._conn() as conn:\n            conn.execute(\n                \"\"\"INSERT INTO drift_events\n                   (id, symbol_id, from_reason_id, drift_dimensions,\n                    severity, description, resolved, detected_at)\n                   VALUES (?,?,?,?,?,?,?,?)\"\"\",\n                (\n                    event.id, event.symbol_id, event.from_reason_id,\n                    json.dumps(event.drift_dimensions), event.severity,\n                    event.description, int(event.resolved), event.detected_at\n                )\n            )\n        return event.id\n\n    def get_unresolved_drift(self) -> list[DriftEvent]:\n        with self._conn() as conn:\n            rows = conn.execute(\n                'SELECT * FROM drift_events WHERE resolved = 0 '\n                'ORDER BY severity DESC'\n            ).fetchall()\n        return [self._row_to_drift(r) for r in rows]\n\n    def resolve_drift(self, event_id: str) -> None:\n        with self._conn() as conn:\n            conn.execute(\n                'UPDATE drift_events SET resolved = 1 WHERE id = ?',\n                (event_id,)\n            )\n\n    # --- Composite queries ---\n\n    def get_reasons_for_file(self, file_path: str) -> list[ReasonNode]:\n        \"\"\"All ReasonNodes linked to symbols in a file via CREATES/MODIFIES.\"\"\"\n        with self._conn() as conn:\n            rows = conn.execute(\n                \"\"\"SELECT DISTINCT r.* FROM reasons r\n                   JOIN edges e ON e.from_id = r.id\n                   JOIN symbols s ON e.to_id = s.id\n                   WHERE s.file_path = ?\n                   AND e.edge_type IN ('CREATES', 'MODIFIES')\"\"\",\n                (file_path,)\n            ).fetchall()\n        return [self._row_to_reason(r) for r in rows]\n\n    def get_constraints_for_scope(\n        self, file_paths: list[str]\n    ) -> list[dict[str, Any]]:\n        \"\"\"Get all invariants and contracts for files in scope.\"\"\"\n        results = []\n        for fp in file_paths:\n            reasons = self.get_reasons_for_file(fp)\n            for r in reasons:\n                if r.invariants or r.postconditions or r.preconditions:\n                    results.append({\n                        'reason_id': r.id,\n                        'goal': r.goal,\n                        'file': fp,\n                        'preconditions': r.preconditions,\n                        'postconditions': r.postconditions,\n                        'invariants': r.invariants\n                    })\n        return results\n\n    def get_blast_radius(self, reason_id: str) -> dict[str, Any]:\n        \"\"\"Symbols + downstream REQUIRES reasons for a ReasonNode.\"\"\"\n        symbols = []\n        for edge in self.get_edges_from(reason_id, 'CREATES'):\n            syms = self._get_symbol(edge.to_id)\n            if syms:\n                symbols.append(syms)\n        for edge in self.get_edges_from(reason_id, 'MODIFIES'):\n            syms = self._get_symbol(edge.to_id)\n            if syms:\n                symbols.append(syms)\n\n        dependent_reasons = []\n        for edge in self.get_edges_to(reason_id, 'REQUIRES'):\n            reason = self.get_reason(edge.from_id)\n            if reason:\n                dependent_reasons.append(reason)\n\n        return {\n            'reason': self.get_reason(reason_id),\n            'symbols': symbols,\n            'dependent_reasons': dependent_reasons,\n            'symbol_count': len(symbols),\n            'dependent_count': len(dependent_reasons)\n        }\n\n    def get_risk_profile(self, symbol_name: str) -> dict[str, Any]:\n        \"\"\"Drift score, ownership history, and status for a symbol.\"\"\"\n        symbols = self.get_symbol_by_name(symbol_name)\n        if not symbols:\n            return {'found': False, 'symbol': symbol_name}\n\n        sym = symbols[0]\n        creating_edges = self.get_edges_to(sym.id, 'CREATES')\n        modifying_edges = self.get_edges_to(sym.id, 'MODIFIES')\n        drift_edges = self.get_edges_from(sym.id, 'DRIFTS_FROM')\n\n        owners = set()\n        for edge in creating_edges + modifying_edges:\n            reason = self.get_reason(edge.from_id)\n            if reason:\n                owners.add(reason.owner)\n\n        with self._conn() as conn:\n            drift_rows = conn.execute(\n                'SELECT * FROM drift_events WHERE symbol_id = ? '\n                'ORDER BY detected_at DESC',\n                (sym.id,)\n            ).fetchall()\n\n        return {\n            'found': True,\n            'symbol': sym,\n            'owners': list(owners),\n            'modify_count': len(modifying_edges),\n            'drift_events': [self._row_to_drift(r) for r in drift_rows],\n            'active_drift': any(\n                not self._row_to_drift(r).resolved for r in drift_rows\n            )\n        }\n\n    def get_stats(self) -> dict[str, int]:\n        with self._conn() as conn:\n            reasons = conn.execute('SELECT COUNT(*) FROM reasons').fetchone()[0]\n            symbols = conn.execute('SELECT COUNT(*) FROM symbols').fetchone()[0]\n            edges = conn.execute('SELECT COUNT(*) FROM edges').fetchone()[0]\n            drift = conn.execute(\n                'SELECT COUNT(*) FROM drift_events WHERE resolved = 0'\n            ).fetchone()[0]\n        return {\n            'reasons': reasons,\n            'symbols': symbols,\n            'edges': edges,\n            'unresolved_drift': drift\n        }\n\n    # --- Helpers ---\n\n    def _get_symbol(self, symbol_id: str) -> Symbol | None:\n        with self._conn() as conn:\n            row = conn.execute(\n                'SELECT * FROM symbols WHERE id = ?', (symbol_id,)\n            ).fetchone()\n        return self._row_to_symbol(row) if row else None\n\n    @staticmethod\n    def _row_to_reason(row: sqlite3.Row) -> ReasonNode:\n        return ReasonNode(\n            id=row['id'],\n            goal=row['goal'],\n            decision_type=row['decision_type'],\n            scope=json.loads(row['scope']),\n            owner=row['owner'],\n            agent=row['agent'],\n            status=row['status'],\n            source=row['source'],\n            task_id=row['task_id'],\n            parent_id=row['parent_id'],\n            preconditions=json.loads(row['preconditions']),\n            postconditions=json.loads(row['postconditions']),\n            invariants=json.loads(row['invariants']),\n            created_at=row['created_at'],\n            fulfilled_at=row['fulfilled_at']\n        )\n\n    @staticmethod\n    def _row_to_symbol(row: sqlite3.Row) -> Symbol:\n        return Symbol(\n            id=row['id'],\n            name=row['name'],\n            file_path=row['file_path'],\n            symbol_type=row['symbol_type'],\n            language=row['language'],\n            signature=row['signature'],\n            checksum=row['checksum'],\n            created_at=row['created_at']\n        )\n\n    @staticmethod\n    def _row_to_edge(row: sqlite3.Row) -> Edge:\n        return Edge(\n            id=row['id'],\n            from_id=row['from_id'],\n            to_id=row['to_id'],\n            edge_type=row['edge_type'],\n            confidence=row['confidence'],\n            created_at=row['created_at']\n        )\n\n    @staticmethod\n    def _row_to_drift(row: sqlite3.Row) -> DriftEvent:\n        return DriftEvent(\n            id=row['id'],\n            symbol_id=row['symbol_id'],\n            from_reason_id=row['from_reason_id'],\n            drift_dimensions=json.loads(row['drift_dimensions']),\n            severity=row['severity'],\n            description=row['description'],\n            resolved=bool(row['resolved']),\n            detected_at=row['detected_at']\n        )\n"
  },
  {
    "path": "scripts/icpg/symbols.py",
    "content": "\"\"\"Language-aware symbol extraction from source files.\"\"\"\n\nfrom __future__ import annotations\n\nimport ast\nimport hashlib\nimport re\nfrom pathlib import Path\n\nfrom .models import Symbol\n\n# --- Language detection ---\n\nLANG_MAP = {\n    '.py': 'python',\n    '.ts': 'typescript', '.tsx': 'typescript',\n    '.js': 'javascript', '.jsx': 'javascript',\n    '.go': 'go',\n    '.rs': 'rust',\n    '.java': 'java',\n    '.rb': 'ruby',\n    '.php': 'php',\n    '.swift': 'swift',\n    '.kt': 'kotlin',\n    '.c': 'c', '.h': 'c',\n    '.cpp': 'cpp', '.hpp': 'cpp',\n    '.cs': 'csharp',\n    '.scala': 'scala',\n    '.lua': 'lua',\n    '.vue': 'vue',\n    '.svelte': 'svelte',\n    '.ex': 'elixir', '.exs': 'elixir'\n}\n\n\ndef detect_language(file_path: str) -> str | None:\n    ext = Path(file_path).suffix.lower()\n    return LANG_MAP.get(ext)\n\n\ndef checksum_content(content: str) -> str:\n    \"\"\"SHA256 hash of content for drift detection.\"\"\"\n    return hashlib.sha256(content.encode()).hexdigest()[:16]\n\n\n# --- Python extraction (AST-based) ---\n\ndef _extract_python(file_path: str, source: str) -> list[Symbol]:\n    symbols = []\n    try:\n        tree = ast.parse(source)\n    except SyntaxError:\n        return symbols\n\n    for node in ast.walk(tree):\n        if isinstance(node, ast.ClassDef):\n            body = ast.get_source_segment(source, node) or ''\n            symbols.append(Symbol(\n                name=node.name,\n                file_path=file_path,\n                symbol_type='class',\n                language='python',\n                signature=_python_class_sig(node),\n                checksum=checksum_content(body)\n            ))\n\n        elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):\n            body = ast.get_source_segment(source, node) or ''\n            sig = _python_func_sig(node)\n            stype = 'function'\n            if any(\n                isinstance(d, ast.Name) and d.id == 'staticmethod'\n                for d in node.decorator_list\n            ):\n                stype = 'function'\n            symbols.append(Symbol(\n                name=node.name,\n                file_path=file_path,\n                symbol_type=stype,\n                language='python',\n                signature=sig,\n                checksum=checksum_content(body)\n            ))\n\n    return symbols\n\n\ndef _python_func_sig(node: ast.FunctionDef) -> str:\n    args = []\n    for a in node.args.args:\n        ann = ''\n        if a.annotation:\n            ann = f': {ast.dump(a.annotation)}'\n        args.append(f'{a.arg}{ann}')\n    ret = ''\n    if node.returns:\n        ret = f' -> {ast.dump(node.returns)}'\n    prefix = 'async def' if isinstance(node, ast.AsyncFunctionDef) else 'def'\n    return f'{prefix} {node.name}({\", \".join(args)}){ret}'\n\n\ndef _python_class_sig(node: ast.ClassDef) -> str:\n    bases = [ast.dump(b) for b in node.bases]\n    if bases:\n        return f'class {node.name}({\", \".join(bases)})'\n    return f'class {node.name}'\n\n\n# --- TypeScript/JavaScript extraction (regex) ---\n\n_TS_PATTERNS = [\n    # export function name(...)\n    (r'export\\s+(?:async\\s+)?function\\s+(\\w+)\\s*\\([^)]*\\)',\n     'function'),\n    # export class Name\n    (r'export\\s+(?:abstract\\s+)?class\\s+(\\w+)',\n     'class'),\n    # export const Name = ...\n    (r'export\\s+const\\s+(\\w+)\\s*[=:]',\n     'constant'),\n    # export interface Name\n    (r'export\\s+interface\\s+(\\w+)',\n     'interface'),\n    # export type Name\n    (r'export\\s+type\\s+(\\w+)',\n     'type'),\n    # React components: export const Name = (...) =>\n    (r'export\\s+const\\s+((?:[A-Z]\\w+))\\s*=\\s*(?:\\([^)]*\\)|[^=])\\s*=>',\n     'component'),\n    # Hooks: export function use*\n    (r'export\\s+(?:async\\s+)?function\\s+(use\\w+)',\n     'hook'),\n]\n\n\ndef _extract_typescript(file_path: str, source: str) -> list[Symbol]:\n    lang = 'typescript' if file_path.endswith(('.ts', '.tsx')) else 'javascript'\n    symbols = []\n    seen = set()\n\n    for pattern, stype in _TS_PATTERNS:\n        for match in re.finditer(pattern, source):\n            name = match.group(1)\n            if name in seen:\n                continue\n            seen.add(name)\n            # Get the line for signature\n            line_start = source.rfind('\\n', 0, match.start()) + 1\n            line_end = source.find('\\n', match.end())\n            if line_end == -1:\n                line_end = len(source)\n            sig = source[line_start:line_end].strip()\n            symbols.append(Symbol(\n                name=name,\n                file_path=file_path,\n                symbol_type=stype,\n                language=lang,\n                signature=sig[:200],\n                checksum=checksum_content(sig)\n            ))\n\n    return symbols\n\n\n# --- Go extraction (regex) ---\n\n_GO_PATTERNS = [\n    (r'func\\s+(?:\\(\\w+\\s+\\*?\\w+\\)\\s+)?(\\w+)\\s*\\(', 'function'),\n    (r'type\\s+(\\w+)\\s+struct\\s*\\{', 'class'),\n    (r'type\\s+(\\w+)\\s+interface\\s*\\{', 'interface'),\n]\n\n\ndef _extract_go(file_path: str, source: str) -> list[Symbol]:\n    symbols = []\n    seen = set()\n    for pattern, stype in _GO_PATTERNS:\n        for match in re.finditer(pattern, source):\n            name = match.group(1)\n            if name in seen:\n                continue\n            seen.add(name)\n            line_start = source.rfind('\\n', 0, match.start()) + 1\n            line_end = source.find('\\n', match.end())\n            if line_end == -1:\n                line_end = len(source)\n            sig = source[line_start:line_end].strip()\n            symbols.append(Symbol(\n                name=name,\n                file_path=file_path,\n                symbol_type=stype,\n                language='go',\n                signature=sig[:200],\n                checksum=checksum_content(sig)\n            ))\n    return symbols\n\n\n# --- Rust extraction (regex) ---\n\n_RUST_PATTERNS = [\n    (r'(?:pub\\s+)?(?:async\\s+)?fn\\s+(\\w+)', 'function'),\n    (r'(?:pub\\s+)?struct\\s+(\\w+)', 'class'),\n    (r'(?:pub\\s+)?enum\\s+(\\w+)', 'type'),\n    (r'(?:pub\\s+)?trait\\s+(\\w+)', 'interface'),\n    (r'impl\\s+(\\w+)', 'class'),\n]\n\n\ndef _extract_rust(file_path: str, source: str) -> list[Symbol]:\n    symbols = []\n    seen = set()\n    for pattern, stype in _RUST_PATTERNS:\n        for match in re.finditer(pattern, source):\n            name = match.group(1)\n            if name in seen:\n                continue\n            seen.add(name)\n            line_start = source.rfind('\\n', 0, match.start()) + 1\n            line_end = source.find('\\n', match.end())\n            if line_end == -1:\n                line_end = len(source)\n            sig = source[line_start:line_end].strip()\n            symbols.append(Symbol(\n                name=name,\n                file_path=file_path,\n                symbol_type=stype,\n                language='rust',\n                signature=sig[:200],\n                checksum=checksum_content(sig)\n            ))\n    return symbols\n\n\n# --- Elixir extraction (regex) ---\n\n_ELIXIR_PATTERNS = [\n    (r'defmodule\\s+([\\w.]+)', 'module'),\n    (r'def\\s+(\\w+)\\s*\\(', 'function'),\n    (r'defp\\s+(\\w+)\\s*\\(', 'function'),\n    (r'schema\\s+\"(\\w+)\"', 'schema'),\n]\n\n\ndef _extract_elixir(file_path: str, source: str) -> list[Symbol]:\n    symbols = []\n    seen = set()\n    for pattern, stype in _ELIXIR_PATTERNS:\n        for match in re.finditer(pattern, source):\n            name = match.group(1)\n            if name in seen:\n                continue\n            seen.add(name)\n            line_start = source.rfind('\\n', 0, match.start()) + 1\n            line_end = source.find('\\n', match.end())\n            if line_end == -1:\n                line_end = len(source)\n            sig = source[line_start:line_end].strip()\n            symbols.append(Symbol(\n                name=name,\n                file_path=file_path,\n                symbol_type=stype,\n                language='elixir',\n                signature=sig[:200],\n                checksum=checksum_content(sig)\n            ))\n    return symbols\n\n\n# --- Public API ---\n\nEXTRACTORS = {\n    'python': _extract_python,\n    'typescript': _extract_typescript,\n    'javascript': _extract_typescript,\n    'go': _extract_go,\n    'rust': _extract_rust,\n    'elixir': _extract_elixir,\n}\n\n\ndef extract_symbols(file_path: str) -> list[Symbol]:\n    \"\"\"Extract symbols from a source file.\"\"\"\n    lang = detect_language(file_path)\n    if not lang:\n        return []\n\n    path = Path(file_path)\n    if not path.exists():\n        return []\n\n    try:\n        source = path.read_text(encoding='utf-8')\n    except (OSError, UnicodeDecodeError):\n        return []\n\n    extractor = EXTRACTORS.get(lang)\n    if not extractor:\n        return []\n\n    return extractor(str(file_path), source)\n\n\ndef extract_symbols_from_files(file_paths: list[str]) -> list[Symbol]:\n    \"\"\"Extract symbols from multiple files.\"\"\"\n    all_symbols = []\n    for fp in file_paths:\n        all_symbols.extend(extract_symbols(fp))\n    return all_symbols\n"
  },
  {
    "path": "scripts/icpg/vectors.py",
    "content": "\"\"\"Vector-based duplicate detection for search_prior_work query.\n\nTiered fallback:\n  1. chromadb + sentence-transformers (best quality)\n  2. TF-IDF cosine similarity via scikit-learn (no GPU needed)\n  3. Exact substring matching (zero deps)\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport os\nfrom pathlib import Path\n\nfrom .store import ICPGStore\n\nVECTORS_DIR = '.icpg'\nTFIDF_CACHE = '.icpg/tfidf_cache.json'\n\n\nclass VectorStore:\n    \"\"\"Tiered vector search for ReasonNode deduplication.\"\"\"\n\n    def __init__(self, project_dir: str = '.'):\n        self.project_dir = Path(project_dir).resolve()\n        self.icpg_dir = self.project_dir / VECTORS_DIR\n        self._backend = _detect_backend()\n\n    def add_reason(self, reason_id: str, goal: str, scope: list[str]) -> None:\n        \"\"\"Index a ReasonNode for similarity search.\"\"\"\n        text = f'{goal} | scope: {\", \".join(scope)}'\n\n        if self._backend == 'chromadb':\n            _chromadb_add(self.icpg_dir, reason_id, text)\n        elif self._backend == 'tfidf':\n            _tfidf_add(self.icpg_dir, reason_id, text)\n        else:\n            _exact_add(self.icpg_dir, reason_id, text)\n\n    def search_similar(\n        self, goal_text: str, threshold: float = 0.75, top_k: int = 5\n    ) -> list[tuple[str, float]]:\n        \"\"\"Find similar ReasonNodes. Returns [(id, score), ...].\"\"\"\n        if self._backend == 'chromadb':\n            return _chromadb_search(\n                self.icpg_dir, goal_text, threshold, top_k\n            )\n        elif self._backend == 'tfidf':\n            return _tfidf_search(\n                self.icpg_dir, goal_text, threshold, top_k\n            )\n        else:\n            return _exact_search(self.icpg_dir, goal_text, threshold)\n\n    def remove_reason(self, reason_id: str) -> None:\n        \"\"\"Remove a ReasonNode from the vector index.\"\"\"\n        if self._backend == 'chromadb':\n            _chromadb_remove(self.icpg_dir, reason_id)\n        elif self._backend == 'tfidf':\n            _tfidf_remove(self.icpg_dir, reason_id)\n        else:\n            _exact_remove(self.icpg_dir, reason_id)\n\n\ndef _detect_backend() -> str:\n    \"\"\"Detect best available vector search backend.\"\"\"\n    try:\n        import chromadb\n        import sentence_transformers\n        return 'chromadb'\n    except ImportError:\n        pass\n\n    try:\n        from sklearn.feature_extraction.text import TfidfVectorizer\n        from sklearn.metrics.pairwise import cosine_similarity\n        return 'tfidf'\n    except ImportError:\n        pass\n\n    return 'exact'\n\n\n# --- ChromaDB backend ---\n\ndef _get_chroma_collection(icpg_dir: Path):\n    import chromadb\n    client = chromadb.PersistentClient(path=str(icpg_dir / 'chroma'))\n    return client.get_or_create_collection(\n        name='reasons',\n        metadata={'hnsw:space': 'cosine'}\n    )\n\n\ndef _chromadb_add(icpg_dir: Path, reason_id: str, text: str) -> None:\n    col = _get_chroma_collection(icpg_dir)\n    col.upsert(ids=[reason_id], documents=[text])\n\n\ndef _chromadb_search(\n    icpg_dir: Path, query: str, threshold: float, top_k: int\n) -> list[tuple[str, float]]:\n    col = _get_chroma_collection(icpg_dir)\n    if col.count() == 0:\n        return []\n    results = col.query(\n        query_texts=[query],\n        n_results=min(top_k, col.count())\n    )\n    pairs = []\n    if results['ids'] and results['distances']:\n        for rid, dist in zip(results['ids'][0], results['distances'][0]):\n            # chromadb cosine distance: 0 = identical, 2 = opposite\n            score = 1.0 - (dist / 2.0)\n            if score >= threshold:\n                pairs.append((rid, round(score, 3)))\n    return pairs\n\n\ndef _chromadb_remove(icpg_dir: Path, reason_id: str) -> None:\n    col = _get_chroma_collection(icpg_dir)\n    try:\n        col.delete(ids=[reason_id])\n    except Exception:\n        pass\n\n\n# --- TF-IDF backend ---\n\ndef _tfidf_load(icpg_dir: Path) -> dict[str, str]:\n    cache_path = icpg_dir / 'tfidf_cache.json'\n    if cache_path.exists():\n        return json.loads(cache_path.read_text())\n    return {}\n\n\ndef _tfidf_save(icpg_dir: Path, data: dict[str, str]) -> None:\n    cache_path = icpg_dir / 'tfidf_cache.json'\n    cache_path.write_text(json.dumps(data))\n\n\ndef _tfidf_add(icpg_dir: Path, reason_id: str, text: str) -> None:\n    data = _tfidf_load(icpg_dir)\n    data[reason_id] = text\n    _tfidf_save(icpg_dir, data)\n\n\ndef _tfidf_search(\n    icpg_dir: Path, query: str, threshold: float, top_k: int\n) -> list[tuple[str, float]]:\n    from sklearn.feature_extraction.text import TfidfVectorizer\n    from sklearn.metrics.pairwise import cosine_similarity\n\n    data = _tfidf_load(icpg_dir)\n    if not data:\n        return []\n\n    ids = list(data.keys())\n    texts = list(data.values())\n    texts.append(query)\n\n    vectorizer = TfidfVectorizer()\n    tfidf_matrix = vectorizer.fit_transform(texts)\n\n    query_vec = tfidf_matrix[-1]\n    doc_vecs = tfidf_matrix[:-1]\n    scores = cosine_similarity(query_vec, doc_vecs).flatten()\n\n    pairs = [\n        (ids[i], round(float(scores[i]), 3))\n        for i in range(len(ids))\n        if scores[i] >= threshold\n    ]\n    pairs.sort(key=lambda x: x[1], reverse=True)\n    return pairs[:top_k]\n\n\ndef _tfidf_remove(icpg_dir: Path, reason_id: str) -> None:\n    data = _tfidf_load(icpg_dir)\n    data.pop(reason_id, None)\n    _tfidf_save(icpg_dir, data)\n\n\n# --- Exact match backend ---\n\ndef _exact_load(icpg_dir: Path) -> dict[str, str]:\n    cache_path = icpg_dir / 'exact_cache.json'\n    if cache_path.exists():\n        return json.loads(cache_path.read_text())\n    return {}\n\n\ndef _exact_save(icpg_dir: Path, data: dict[str, str]) -> None:\n    cache_path = icpg_dir / 'exact_cache.json'\n    cache_path.write_text(json.dumps(data))\n\n\ndef _exact_add(icpg_dir: Path, reason_id: str, text: str) -> None:\n    data = _exact_load(icpg_dir)\n    data[reason_id] = text.lower()\n    _exact_save(icpg_dir, data)\n\n\ndef _exact_search(\n    icpg_dir: Path, query: str, threshold: float\n) -> list[tuple[str, float]]:\n    data = _exact_load(icpg_dir)\n    query_words = set(query.lower().split())\n    if not query_words:\n        return []\n\n    pairs = []\n    for rid, text in data.items():\n        text_words = set(text.split())\n        if not text_words:\n            continue\n        overlap = len(query_words & text_words)\n        score = overlap / max(len(query_words), len(text_words))\n        if score >= threshold:\n            pairs.append((rid, round(score, 3)))\n\n    pairs.sort(key=lambda x: x[1], reverse=True)\n    return pairs\n\n\ndef _exact_remove(icpg_dir: Path, reason_id: str) -> None:\n    data = _exact_load(icpg_dir)\n    data.pop(reason_id, None)\n    _exact_save(icpg_dir, data)\n"
  },
  {
    "path": "scripts/install-graph-tools.sh",
    "content": "#!/bin/bash\n\n# install-graph-tools.sh - Install code graph MCP servers\n#\n# Tier 1: codebase-memory-mcp (default, always installed)\n#   - Single static binary, zero dependencies\n#   - 64 languages, sub-ms queries, 14 MCP tools\n#\n# Tier 2: Joern CPG via CodeBadger (opt-in, --joern)\n#   - Full CPG: AST + CFG + CDG + DDG + PDG\n#   - Requires Docker + Python 3.10+\n#\n# Tier 3: CodeQL (opt-in, --codeql)\n#   - Interprocedural taint analysis, security queries\n#   - Requires CodeQL CLI\n\nset -e\n\n# Colors\nRED='\\033[0;31m'\nGREEN='\\033[0;32m'\nYELLOW='\\033[1;33m'\nBLUE='\\033[0;34m'\nNC='\\033[0m'\n\n# Defaults\nINSTALL_JOERN=false\nINSTALL_CODEQL=false\nINSTALL_DIR=\"$HOME/.local/bin\"\n\n# Parse arguments\nwhile [[ $# -gt 0 ]]; do\n    case $1 in\n        --joern) INSTALL_JOERN=true; shift ;;\n        --codeql) INSTALL_CODEQL=true; shift ;;\n        --all) INSTALL_JOERN=true; INSTALL_CODEQL=true; shift ;;\n        --help|-h)\n            echo \"Usage: install-graph-tools.sh [OPTIONS]\"\n            echo \"\"\n            echo \"Install code graph MCP servers for Maggy.\"\n            echo \"\"\n            echo \"Options:\"\n            echo \"  (no flags)   Install Tier 1 only (codebase-memory-mcp)\"\n            echo \"  --joern      Also install Tier 2 (Joern CPG via CodeBadger)\"\n            echo \"  --codeql     Also install Tier 3 (CodeQL)\"\n            echo \"  --all        Install all tiers\"\n            echo \"  --help       Show this help\"\n            echo \"\"\n            echo \"Tiers:\"\n            echo \"  1  codebase-memory-mcp  AST graph, 64 langs, sub-ms     (always)\"\n            echo \"  2  Joern/CodeBadger     Full CPG (AST+CFG+PDG), 12 langs (opt-in)\"\n            echo \"  3  CodeQL               Taint analysis, security, 10+ langs (opt-in)\"\n            exit 0\n            ;;\n        *) echo -e \"${RED}Unknown option: $1${NC}\"; echo \"Run with --help for usage.\"; exit 1 ;;\n    esac\ndone\n\necho \"\"\necho \"════════════════════════════════════════════════════════════════\"\necho \"  Code Graph Tools Installer\"\necho \"════════════════════════════════════════════════════════════════\"\necho \"\"\n\n# Detect platform\nOS=$(uname -s | tr '[:upper:]' '[:lower:]')\nARCH=$(uname -m)\ncase \"$ARCH\" in\n    aarch64|arm64) ARCH=\"arm64\" ;;\n    x86_64|amd64) ARCH=\"amd64\" ;;\nesac\n\necho -e \"${BLUE}Platform: ${OS}-${ARCH}${NC}\"\necho \"\"\n\n# ─────────────────────────────────────────────────────────────────\n# Tier 1: codebase-memory-mcp\n# ─────────────────────────────────────────────────────────────────\necho \"── Tier 1: codebase-memory-mcp ──────────────────────────────\"\necho \"\"\n\nmkdir -p \"$INSTALL_DIR\"\n\nif command -v codebase-memory-mcp &> /dev/null; then\n    echo -e \"${GREEN}✓ codebase-memory-mcp already installed${NC}\"\n    codebase-memory-mcp --version 2>/dev/null || true\nelse\n    DOWNLOAD_URL=\"https://github.com/DeusData/codebase-memory-mcp/releases/latest/download/codebase-memory-mcp-${OS}-${ARCH}.tar.gz\"\n    TEMP_DIR=$(mktemp -d)\n\n    echo \"Downloading from GitHub releases...\"\n    echo \"  URL: $DOWNLOAD_URL\"\n\n    if curl -fsSL \"$DOWNLOAD_URL\" -o \"$TEMP_DIR/codebase-memory-mcp.tar.gz\"; then\n        tar xzf \"$TEMP_DIR/codebase-memory-mcp.tar.gz\" -C \"$TEMP_DIR\"\n        mv \"$TEMP_DIR/codebase-memory-mcp\" \"$INSTALL_DIR/codebase-memory-mcp\"\n        chmod +x \"$INSTALL_DIR/codebase-memory-mcp\"\n        echo -e \"${GREEN}✓ Installed codebase-memory-mcp to $INSTALL_DIR${NC}\"\n\n        # Auto-configure for Claude Code and other agents\n        echo \"\"\n        echo \"Running auto-configuration...\"\n        \"$INSTALL_DIR/codebase-memory-mcp\" install 2>/dev/null || true\n    else\n        echo -e \"${RED}✗ Failed to download codebase-memory-mcp${NC}\"\n        echo \"\"\n        echo \"  Manual install:\"\n        echo \"  1. Go to https://github.com/DeusData/codebase-memory-mcp/releases\"\n        echo \"  2. Download codebase-memory-mcp-${OS}-${ARCH}.tar.gz\"\n        echo \"  3. Extract and move to $INSTALL_DIR/\"\n        echo \"  4. Run: codebase-memory-mcp install\"\n    fi\n\n    rm -rf \"$TEMP_DIR\"\nfi\n\n# Check PATH\nif ! echo \"$PATH\" | tr ':' '\\n' | grep -q \"$INSTALL_DIR\"; then\n    echo \"\"\n    echo -e \"${YELLOW}⚠ $INSTALL_DIR is not in your PATH${NC}\"\n    echo \"  Add to your shell profile:\"\n    echo \"  export PATH=\\\"$INSTALL_DIR:\\$PATH\\\"\"\nfi\n\n# ─────────────────────────────────────────────────────────────────\n# Tier 2: Joern CPG via CodeBadger (opt-in)\n# ─────────────────────────────────────────────────────────────────\nif [ \"$INSTALL_JOERN\" = true ]; then\n    echo \"\"\n    echo \"── Tier 2: Joern CPG (CodeBadger) ───────────────────────────\"\n    echo \"\"\n\n    # Check Docker\n    if ! command -v docker &> /dev/null; then\n        echo -e \"${RED}✗ Docker not found${NC}\"\n        echo \"  Joern requires Docker. Install from: https://docker.com\"\n        echo \"  Skipping Tier 2 installation.\"\n    elif ! docker info &> /dev/null 2>&1; then\n        echo -e \"${RED}✗ Docker is not running${NC}\"\n        echo \"  Start Docker Desktop and try again.\"\n        echo \"  Skipping Tier 2 installation.\"\n    else\n        echo -e \"${GREEN}✓ Docker is running${NC}\"\n\n        # Check Python\n        PYTHON_CMD=\"\"\n        if command -v python3 &> /dev/null; then\n            PYTHON_CMD=\"python3\"\n        elif command -v python &> /dev/null; then\n            PYTHON_CMD=\"python\"\n        fi\n\n        if [ -z \"$PYTHON_CMD\" ]; then\n            echo -e \"${RED}✗ Python 3.10+ not found${NC}\"\n            echo \"  Install Python: https://python.org\"\n            echo \"  Skipping Tier 2 installation.\"\n        else\n            PY_VERSION=$($PYTHON_CMD -c \"import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')\")\n            echo -e \"${GREEN}✓ Python $PY_VERSION found${NC}\"\n\n            CODEBADGER_DIR=\"$HOME/.claude/tools/codebadger\"\n\n            if [ -d \"$CODEBADGER_DIR\" ]; then\n                echo -e \"${GREEN}✓ CodeBadger already cloned${NC}\"\n                echo \"  Pulling latest...\"\n                git -C \"$CODEBADGER_DIR\" pull 2>/dev/null || true\n            else\n                echo \"Cloning CodeBadger...\"\n                mkdir -p \"$HOME/.claude/tools\"\n                git clone https://github.com/lekssays/joern-mcp.git \"$CODEBADGER_DIR\" 2>/dev/null || {\n                    echo -e \"${RED}✗ Failed to clone CodeBadger${NC}\"\n                    echo \"  Manual install: https://github.com/lekssays/joern-mcp\"\n                }\n            fi\n\n            if [ -d \"$CODEBADGER_DIR\" ]; then\n                echo \"Installing Python dependencies...\"\n                $PYTHON_CMD -m pip install -r \"$CODEBADGER_DIR/requirements.txt\" --quiet 2>/dev/null || true\n\n                echo \"Starting Joern Docker services...\"\n                (cd \"$CODEBADGER_DIR\" && docker compose up -d 2>/dev/null) || {\n                    echo -e \"${YELLOW}⚠ Docker compose failed. You may need to start manually:${NC}\"\n                    echo \"  cd $CODEBADGER_DIR && docker compose up -d\"\n                }\n\n                echo -e \"${GREEN}✓ Joern/CodeBadger installed${NC}\"\n                echo \"\"\n                echo \"  To start the MCP server:\"\n                echo \"  cd $CODEBADGER_DIR && $PYTHON_CMD main.py\"\n                echo \"\"\n                echo \"  MCP endpoint: http://localhost:4242/mcp\"\n            fi\n        fi\n    fi\nfi\n\n# ─────────────────────────────────────────────────────────────────\n# Tier 3: CodeQL (opt-in)\n# ─────────────────────────────────────────────────────────────────\nif [ \"$INSTALL_CODEQL\" = true ]; then\n    echo \"\"\n    echo \"── Tier 3: CodeQL ───────────────────────────────────────────\"\n    echo \"\"\n\n    if command -v codeql &> /dev/null; then\n        echo -e \"${GREEN}✓ CodeQL already installed${NC}\"\n        codeql version 2>/dev/null || true\n    else\n        if command -v brew &> /dev/null; then\n            echo \"Installing CodeQL via Homebrew...\"\n            brew install codeql 2>/dev/null || {\n                echo -e \"${YELLOW}⚠ brew install codeql failed${NC}\"\n                echo \"  Trying GitHub release download...\"\n            }\n        fi\n\n        # Fallback: direct download\n        if ! command -v codeql &> /dev/null; then\n            echo \"Downloading CodeQL CLI...\"\n            echo \"\"\n            echo \"  Manual install from:\"\n            echo \"  https://github.com/github/codeql-cli-binaries/releases\"\n            echo \"\"\n            echo \"  After download:\"\n            echo \"  1. Extract to $INSTALL_DIR/codeql/\"\n            echo \"  2. Add to PATH: export PATH=\\\"$INSTALL_DIR/codeql:\\$PATH\\\"\"\n        fi\n    fi\n\n    if command -v codeql &> /dev/null; then\n        echo \"\"\n        echo \"Installing CodeQL query packs...\"\n        codeql pack download codeql/javascript-queries 2>/dev/null || true\n        codeql pack download codeql/python-queries 2>/dev/null || true\n        codeql pack download codeql/java-queries 2>/dev/null || true\n        codeql pack download codeql/go-queries 2>/dev/null || true\n        echo -e \"${GREEN}✓ CodeQL query packs installed${NC}\"\n    fi\nfi\n\n# ─────────────────────────────────────────────────────────────────\n# Summary\n# ─────────────────────────────────────────────────────────────────\necho \"\"\necho \"════════════════════════════════════════════════════════════════\"\necho \"  Installation Summary\"\necho \"════════════════════════════════════════════════════════════════\"\necho \"\"\n\nif command -v codebase-memory-mcp &> /dev/null; then\n    echo -e \"  ${GREEN}✓ Tier 1: codebase-memory-mcp (AST graph, 64 langs)${NC}\"\nelse\n    echo -e \"  ${RED}✗ Tier 1: codebase-memory-mcp NOT installed${NC}\"\nfi\n\nif [ \"$INSTALL_JOERN\" = true ]; then\n    if [ -d \"$HOME/.claude/tools/codebadger\" ]; then\n        echo -e \"  ${GREEN}✓ Tier 2: Joern CPG via CodeBadger${NC}\"\n    else\n        echo -e \"  ${RED}✗ Tier 2: Joern NOT installed${NC}\"\n    fi\nfi\n\nif [ \"$INSTALL_CODEQL\" = true ]; then\n    if command -v codeql &> /dev/null; then\n        echo -e \"  ${GREEN}✓ Tier 3: CodeQL${NC}\"\n    else\n        echo -e \"  ${RED}✗ Tier 3: CodeQL NOT installed${NC}\"\n    fi\nfi\n\necho \"\"\necho \"Next steps:\"\necho \"  1. Run /initialize-project in your project\"\necho \"  2. The MCP servers will be auto-configured in .mcp.json\"\necho \"  3. Claude will use the graph for optimized code navigation\"\necho \"\"\n"
  },
  {
    "path": "scripts/install-hooks.sh",
    "content": "#!/bin/bash\n\n# Install Claude Code Review Git Hooks\n# Run this in any git repository to enable pre-push code review\n\nset -e\n\nCLAUDE_DIR=\"$HOME/.claude\"\nHOOKS_DIR=\"$CLAUDE_DIR/hooks\"\n\n# Colors\nRED='\\033[0;31m'\nYELLOW='\\033[1;33m'\nGREEN='\\033[0;32m'\nNC='\\033[0m'\n\necho \"\"\necho \"🔧 Claude Code Review - Git Hook Installer\"\necho \"\"\n\n# Check if we're in a git repository\nif [ ! -d \".git\" ]; then\n    echo -e \"${RED}❌ Error: Not a git repository${NC}\"\n    echo \"   Run this command from a git project root.\"\n    exit 1\nfi\n\n# Check if hooks exist\nif [ ! -d \"$HOOKS_DIR\" ]; then\n    echo -e \"${RED}❌ Error: Hook templates not found${NC}\"\n    if [ -f \"$CLAUDE_DIR/.bootstrap-dir\" ]; then\n        echo \"   Run $(cat \"$CLAUDE_DIR/.bootstrap-dir\")/install.sh first.\"\n    else\n        echo \"   Run install.sh from your Maggy clone first.\"\n    fi\n    exit 1\nfi\n\n# Check for existing pre-push hook\nif [ -f \".git/hooks/pre-push\" ]; then\n    echo -e \"${YELLOW}⚠️  Existing pre-push hook found${NC}\"\n    read -p \"   Overwrite? (y/N) \" -n 1 -r\n    echo\n    if [[ ! $REPLY =~ ^[Yy]$ ]]; then\n        echo \"   Skipped. Existing hook preserved.\"\n        exit 0\n    fi\nfi\n\n# Install pre-push hook\ncp \"$HOOKS_DIR/pre-push\" \".git/hooks/pre-push\"\nchmod +x \".git/hooks/pre-push\"\n\necho -e \"${GREEN}✅ Pre-push hook installed${NC}\"\necho \"\"\necho \"What happens now:\"\necho \"  • Every 'git push' runs Claude code review\"\necho \"  • 🔴 Critical or 🟠 High issues block the push\"\necho \"  • 🟡 Medium and 🟢 Low issues are advisory only\"\necho \"\"\necho \"To disable:\"\necho \"  rm .git/hooks/pre-push\"\necho \"\"\n"
  },
  {
    "path": "scripts/install-skills.sh",
    "content": "#!/bin/bash\n# install-skills.sh - Install skills to any agent tool directory\n# Usage: install-skills.sh <target_dir> [source_dir]\n# Example: install-skills.sh ~/.kimi/skills\n#          install-skills.sh ~/.codex/skills /path/to/skills\n\nset -euo pipefail\n\nSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\nDEFAULT_SOURCE=\"$SCRIPT_DIR/../skills\"\n\nusage() {\n    echo \"Usage: install-skills.sh <target_dir> [source_dir]\"\n    echo \"  target_dir: Where to install skills\"\n    echo \"  source_dir: Source skills (default: repo skills/)\"\n    exit 1\n}\n\ncopy_skills() {\n    local source=\"$1\"\n    local target=\"$2\"\n    local count=0\n\n    mkdir -p \"$target\"\n    for skill_dir in \"$source\"/*/; do\n        [ -d \"$skill_dir\" ] || continue\n        [ -f \"$skill_dir/SKILL.md\" ] || continue\n        local name\n        name=$(basename \"$skill_dir\")\n        cp -r \"${skill_dir%/}\" \"$target/\"\n        count=$((count + 1))\n    done\n    echo \"$count\"\n}\n\nmain() {\n    local target=\"${1:-}\"\n    local source=\"${2:-$DEFAULT_SOURCE}\"\n\n    [ -z \"$target\" ] && usage\n    [ -d \"$source\" ] || {\n        echo \"Error: source dir '$source' not found\" >&2\n        exit 1\n    }\n\n    local installed\n    installed=$(copy_skills \"$source\" \"$target\")\n    echo \"Installed $installed skills to $target\"\n}\n\nmain \"$@\"\n"
  },
  {
    "path": "scripts/mnemos/__init__.py",
    "content": "\"\"\"Mnemos -- Task-Scoped Memory Lifecycle for Autonomous Agents.\n\nPrevents lossy context compaction by treating memory as a typed graph\n(MnemoGraph) with differentiated eviction policies, continuous fatigue\nmonitoring, and checkpoint/resume.\n\"\"\"\n\n__version__ = '0.1.0'\n"
  },
  {
    "path": "scripts/mnemos/__main__.py",
    "content": "\"\"\"CLI entry point for Mnemos -- Task-Scoped Memory Lifecycle.\"\"\"\n\nfrom __future__ import annotations\n\nimport argparse\nimport json\nimport sys\nfrom pathlib import Path\n\nfrom . import __version__\nfrom .checkpoint import load_checkpoint, write_checkpoint\nfrom .consolidation import micro_consolidate\nfrom .fatigue import compute_fatigue, read_fatigue_file\nfrom .models import FatigueState, MnemoNode, _now, _uuid\nfrom .signals import get_session_stats\nfrom .store import MnemosStore\n\n\ndef main(argv: list[str] | None = None) -> int:\n    parser = argparse.ArgumentParser(\n        prog='mnemos',\n        description='Mnemos -- Task-Scoped Memory Lifecycle'\n    )\n    parser.add_argument(\n        '--version', action='version', version=f'mnemos {__version__}'\n    )\n    parser.add_argument(\n        '--project', default='.', help='Project directory (default: .)'\n    )\n    sub = parser.add_subparsers(dest='command')\n\n    # --- init ---\n    sub.add_parser('init', help='Initialize .mnemos/ directory and database')\n\n    # --- status ---\n    sub.add_parser('status', help='Show Mnemos statistics and fatigue')\n\n    # --- fatigue ---\n    sub.add_parser('fatigue', help='Show detailed fatigue breakdown')\n\n    # --- checkpoint ---\n    p_cp = sub.add_parser('checkpoint', help='Write a checkpoint')\n    p_cp.add_argument(\n        '--force', action='store_true', help='Write even if fatigue is low'\n    )\n    p_cp.add_argument('--task-id', help='Task ID for checkpoint')\n\n    # --- resume ---\n    p_resume = sub.add_parser(\n        'resume', help='Output latest checkpoint for context injection'\n    )\n    p_resume.add_argument('--path', help='Specific checkpoint file path')\n\n    # --- consolidate ---\n    p_cons = sub.add_parser(\n        'consolidate', help='Run micro-consolidation pass'\n    )\n    p_cons.add_argument('--scope', default='', help='Current scope tag')\n\n    # --- nodes ---\n    p_nodes = sub.add_parser('nodes', help='List active MnemoNodes')\n    p_nodes.add_argument('--type', dest='node_type', help='Filter by type')\n    p_nodes.add_argument(\n        '--all', action='store_true', help='Include non-active nodes'\n    )\n\n    # --- add ---\n    p_add = sub.add_parser('add', help='Add a MnemoNode')\n    p_add.add_argument('type', choices=[\n        'goal', 'constraint', 'context', 'working', 'result'\n    ])\n    p_add.add_argument('content', help='Node content')\n    p_add.add_argument('--task-id', default='manual', help='Task ID')\n    p_add.add_argument('--scope', nargs='+', default=[], help='Scope tags')\n\n    # --- bridge-icpg ---\n    sub.add_parser(\n        'bridge-icpg', help='Import iCPG ReasonNodes as MnemoNodes'\n    )\n\n    args = parser.parse_args(argv)\n    store = MnemosStore(args.project)\n\n    if args.command == 'init':\n        return cmd_init(store)\n    elif args.command == 'status':\n        return cmd_status(store, args)\n    elif args.command == 'fatigue':\n        return cmd_fatigue(store, args)\n    elif args.command == 'checkpoint':\n        return cmd_checkpoint(store, args)\n    elif args.command == 'resume':\n        return cmd_resume(args)\n    elif args.command == 'consolidate':\n        return cmd_consolidate(store, args)\n    elif args.command == 'nodes':\n        return cmd_nodes(store, args)\n    elif args.command == 'add':\n        return cmd_add(store, args)\n    elif args.command == 'bridge-icpg':\n        return cmd_bridge_icpg(store, args)\n    else:\n        parser.print_help()\n        return 1\n\n\ndef cmd_init(store: MnemosStore) -> int:\n    store.init_db()\n    print(f'Initialized Mnemos at {store.mnemos_dir}')\n    print(f'  Database: {store.db_path}')\n    print(f'  .gitignore: created')\n    return 0\n\n\ndef cmd_status(store: MnemosStore, args) -> int:\n    if not store.exists():\n        print('No Mnemos database. Run `mnemos init` first.')\n        return 0\n\n    stats = store.get_stats()\n    fatigue_data = read_fatigue_file(args.project)\n\n    print('MNEMOS STATUS')\n    print(f'  Active nodes:     {stats[\"active\"]}')\n    print(f'  Compressed:       {stats[\"compressed\"]}')\n    print(f'  Evicted:          {stats[\"evicted\"]}')\n    print(f'  Total nodes:      {stats[\"total_nodes\"]}')\n    print(f'  Checkpoints:      {stats[\"checkpoints\"]}')\n\n    if stats['by_type']:\n        parts = [f'{t}:{c}' for t, c in stats['by_type'].items()]\n        print(f'  By type:          {\", \".join(parts)}')\n\n    # Show live fatigue if available\n    if fatigue_data:\n        used = fatigue_data.get('used_percentage', 0)\n        remaining = fatigue_data.get('remaining_percentage', 100)\n        print(f'\\n  Context usage:    {used:.1f}% used, {remaining:.1f}% remaining')\n\n        # Compute full fatigue from observable signals\n        fatigue = compute_fatigue(fatigue_data, args.project)\n        state_icons = {\n            'flow': '+', 'compress': '~',\n            'pre_sleep': '!', 'rem': '!!', 'emergency': 'XXX'\n        }\n        icon = state_icons.get(fatigue.state, '?')\n        print(f'  Fatigue:          [{icon}] {fatigue.composite_score:.2f} ({fatigue.state})')\n\n    # Latest checkpoint\n    cp = store.get_latest_checkpoint()\n    if cp:\n        print(f'\\n  Last checkpoint:  {cp.id[:8]} ({cp.created_at})')\n        print(f'    Goal: {cp.goal[:60]}')\n        print(f'    Fatigue then: {cp.fatigue_at_checkpoint:.2f}')\n\n    return 0\n\n\ndef cmd_fatigue(store: MnemosStore, args) -> int:\n    fatigue_data = read_fatigue_file(args.project)\n    if not fatigue_data:\n        print('No fatigue data. Statusline not configured or no API calls yet.')\n        print('Configure mnemos-statusline.sh to start tracking.')\n        return 0\n\n    fatigue = compute_fatigue(fatigue_data, args.project)\n    state_bar = _fatigue_bar(fatigue.composite_score)\n\n    print('MNEMOS FATIGUE ANALYSIS')\n    print(f'  {state_bar}')\n    print(f'  Composite: {fatigue.composite_score:.4f} -> {fatigue.state.upper()}')\n    print()\n    print('  Dimensions (all passively observed from hooks):')\n    print(f'    Token utilization: {fatigue.token_utilization:.4f}  (weight: 0.40)  [statusline]')\n    print(f'    Scope scatter:     {fatigue.scope_scatter:.4f}  (weight: 0.25)  [PreToolUse file paths]')\n    print(f'    Re-read ratio:     {fatigue.reread_ratio:.4f}  (weight: 0.20)  [PreToolUse Read calls]')\n    print(f'    Error density:     {fatigue.error_density:.4f}  (weight: 0.15)  [PostToolUse outcomes]')\n    print()\n\n    # Signal stats\n    sig_stats = get_session_stats(args.project)\n    if sig_stats.get('total_signals', 0) > 0:\n        print(f'  Signal log: {sig_stats[\"total_signals\"]} events')\n        if sig_stats.get('tool_calls'):\n            tools = ', '.join(f'{k}:{v}' for k, v in sig_stats['tool_calls'].items())\n            print(f'    Tools: {tools}')\n        print(f'    Unique files read: {sig_stats.get(\"unique_files_read\", 0)}')\n        print(f'    Re-reads: {sig_stats.get(\"rereads\", 0)}')\n        print(f'    Errors: {sig_stats.get(\"errors\", 0)}/{sig_stats.get(\"total_outcomes\", 0)}')\n        print()\n\n    # Recommendations\n    if fatigue.state == 'flow':\n        print('  Status: Operating normally. No action needed.')\n    elif fatigue.state == 'compress':\n        print('  Status: Consider micro-consolidation.')\n        print('  Run: mnemos consolidate')\n    elif fatigue.state == 'pre_sleep':\n        print('  Status: Write checkpoint and consolidate.')\n        print('  Run: mnemos checkpoint && mnemos consolidate')\n    elif fatigue.state == 'rem':\n        print('  WARNING: High fatigue. Checkpoint immediately.')\n        print('  Run: mnemos checkpoint --force')\n    elif fatigue.state == 'emergency':\n        print('  EMERGENCY: Context nearly full. Checkpoint NOW.')\n        print('  Run: mnemos checkpoint --force')\n\n    # Log it\n    if store.exists():\n        store.log_fatigue(fatigue)\n\n    return 0\n\n\ndef cmd_checkpoint(store: MnemosStore, args) -> int:\n    if not store.exists():\n        store.init_db()\n\n    # Check fatigue to decide if needed\n    fatigue_data = read_fatigue_file(args.project)\n    fatigue = compute_fatigue(fatigue_data, args.project) if fatigue_data else None\n\n    if fatigue and not args.force:\n        if fatigue.composite_score < 0.40:\n            print(f'Fatigue low ({fatigue.composite_score:.2f}). '\n                  f'Use --force to checkpoint anyway.')\n            return 0\n\n    # Try to load iCPG store if available\n    icpg_store = _try_load_icpg(args.project)\n\n    cp = write_checkpoint(\n        store,\n        fatigue_score=fatigue.composite_score if fatigue else 0.0,\n        icpg_store=icpg_store,\n        task_id=getattr(args, 'task_id', None)\n    )\n\n    print(f'Checkpoint written: {cp.id[:8]}')\n    print(f'  Goal: {cp.goal[:60]}')\n    print(f'  Constraints: {len(cp.active_constraints)}')\n    print(f'  Results: {len(cp.active_results)}')\n    print(f'  Fatigue: {cp.fatigue_at_checkpoint:.2f}')\n    print(f'  File: .mnemos/checkpoint-latest.json')\n    return 0\n\n\ndef cmd_resume(args) -> int:\n    output = load_checkpoint(\n        project_dir=args.project,\n        path=getattr(args, 'path', None)\n    )\n    if not output:\n        print('No checkpoint found to resume from.')\n        return 0\n\n    # Output formatted checkpoint (this goes into agent context)\n    print(output)\n    return 0\n\n\ndef cmd_consolidate(store: MnemosStore, args) -> int:\n    if not store.exists():\n        print('No Mnemos database. Run `mnemos init` first.')\n        return 1\n\n    scope = getattr(args, 'scope', '')\n    stats = micro_consolidate(store, current_scope=scope)\n\n    print(f'Micro-consolidation complete:')\n    print(f'  Compressed: {stats[\"compressed\"]} ResultNodes')\n    print(f'  Evicted: {stats[\"evicted\"]} ContextNodes')\n    print(f'  Decayed: {stats[\"decayed\"]} node weights')\n    return 0\n\n\ndef cmd_nodes(store: MnemosStore, args) -> int:\n    if not store.exists():\n        print('No Mnemos database.')\n        return 0\n\n    node_type = getattr(args, 'node_type', None)\n    show_all = getattr(args, 'all', False)\n\n    if node_type:\n        if show_all:\n            # Get all statuses\n            nodes = []\n            for status in ('active', 'compressed', 'evicted'):\n                nodes.extend(store.get_by_type(node_type, status=status))\n        else:\n            nodes = store.get_by_type(node_type)\n    else:\n        if show_all:\n            nodes = []\n            with store._conn() as conn:\n                rows = conn.execute(\n                    'SELECT * FROM mnemo_nodes ORDER BY type, activation_weight DESC'\n                ).fetchall()\n            nodes = [store._row_to_node(r) for r in rows]\n        else:\n            nodes = store.get_active_nodes()\n\n    if not nodes:\n        print('No matching nodes.')\n        return 0\n\n    status_icons = {\n        'active': '+', 'compressed': '~', 'evicted': '-',\n        'promoted': '^', 'handed_off': '>'\n    }\n\n    print(f'MNEMO NODES ({len(nodes)}):')\n    for n in nodes:\n        icon = status_icons.get(n.status, '?')\n        weight = f'{n.activation_weight:.2f}'\n        content = n.summary or n.content\n        content_preview = content[:60] if content else '(empty)'\n        print(f'  [{icon}] {n.type:12s} w={weight} {content_preview}')\n        if n.scope_tags:\n            print(f'       scope: {\", \".join(n.scope_tags[:3])}')\n\n    return 0\n\n\ndef cmd_add(store: MnemosStore, args) -> int:\n    if not store.exists():\n        store.init_db()\n\n    node = MnemoNode(\n        type=args.type,\n        task_id=args.task_id,\n        content=args.content,\n        scope_tags=args.scope,\n        origin='agent_generated'\n    )\n    store.create_node(node)\n\n    print(f'Created {args.type} node: {node.id[:8]}')\n    print(f'  Content: {args.content[:60]}')\n    if args.scope:\n        print(f'  Scope: {\", \".join(args.scope)}')\n    return 0\n\n\ndef cmd_bridge_icpg(store: MnemosStore, args) -> int:\n    if not store.exists():\n        store.init_db()\n\n    icpg_store = _try_load_icpg(args.project)\n    if not icpg_store:\n        print('No iCPG database found. Run `icpg init` first.')\n        return 1\n\n    stats = store.load_from_icpg(icpg_store)\n    print(f'iCPG Bridge complete:')\n    print(f'  GoalNodes imported: {stats[\"goals_imported\"]}')\n    print(f'  ConstraintNodes imported: {stats[\"constraints_imported\"]}')\n    return 0\n\n\ndef _try_load_icpg(project_dir: str):\n    \"\"\"Try to import and load iCPG store. Returns None if unavailable.\"\"\"\n    try:\n        icpg_path = Path(project_dir).resolve() / '.icpg' / 'reason.db'\n        if not icpg_path.exists():\n            return None\n\n        # Try importing from sibling package\n        sys.path.insert(0, str(Path(__file__).parent.parent))\n        from icpg.store import ICPGStore\n        store = ICPGStore(project_dir)\n        if store.exists():\n            return store\n    except ImportError:\n        pass\n    return None\n\n\ndef _fatigue_bar(score: float) -> str:\n    \"\"\"Render a visual fatigue bar.\"\"\"\n    filled = int(score * 20)\n    empty = 20 - filled\n    bar = '#' * filled + '.' * empty\n\n    if score >= 0.90:\n        label = 'EMERGENCY'\n    elif score >= 0.75:\n        label = 'REM'\n    elif score >= 0.60:\n        label = 'PRE-SLEEP'\n    elif score >= 0.40:\n        label = 'COMPRESS'\n    else:\n        label = 'FLOW'\n\n    return f'[{bar}] {score:.2f} {label}'\n\n\nif __name__ == '__main__':\n    sys.exit(main())\n"
  },
  {
    "path": "scripts/mnemos/checkpoint.py",
    "content": "\"\"\"Checkpoint write/load for Mnemos session persistence.\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport subprocess\nimport time\nfrom collections import Counter\nfrom pathlib import Path\n\nfrom .models import CheckpointNode, _now, _uuid\nfrom .signals import read_recent_signals\nfrom .store import MnemosStore\n\n\ndef write_checkpoint(\n    store: MnemosStore,\n    fatigue_score: float = 0.0,\n    icpg_store=None,\n    task_id: str | None = None\n) -> CheckpointNode:\n    \"\"\"Write a CheckpointNode capturing current MnemoGraph state.\n\n    Always includes: GoalNode content, all ConstraintNodes, current sub-goal.\n    Optionally includes: iCPG state, git state, compressed ResultNodes.\n\n    Writes to:\n        .mnemos/checkpoint-latest.json  (always overwritten)\n        .mnemos/checkpoints/<id>.json   (archived copy)\n\n    Returns the created CheckpointNode.\n    \"\"\"\n    # Determine task_id from active GoalNodes\n    goal_nodes = store.get_by_type('goal')\n    if not task_id and goal_nodes:\n        task_id = goal_nodes[0].task_id\n    task_id = task_id or 'unknown'\n\n    # Gather goal\n    goal_text = '; '.join(n.content for n in goal_nodes) or 'No active goal'\n\n    # Gather constraints (never evicted)\n    constraint_nodes = store.get_by_type('constraint')\n    constraints = [n.content for n in constraint_nodes]\n\n    # Gather result summaries (compressed or active)\n    result_nodes = store.get_by_type('result')\n    results = []\n    for rn in result_nodes[:20]:  # Cap at 20 most recent\n        if rn.summary:\n            results.append(rn.summary)\n        elif rn.content:\n            results.append(rn.content[:200])\n\n    # Current sub-goal from working nodes\n    working_nodes = store.get_by_type('working')\n    current_subgoal = working_nodes[0].content if working_nodes else ''\n\n    # Working memory\n    working_memory = '\\n'.join(\n        n.content for n in working_nodes[:3]\n    )\n\n    # Task narrative and recent files from signals\n    narrative, recent_files = build_task_narrative(store.project_dir)\n\n    # Git state\n    git_state = _get_git_state(store.project_dir)\n\n    # iCPG state\n    icpg_state = None\n    if icpg_store and icpg_store.exists():\n        icpg_state = _get_icpg_state(icpg_store)\n\n    # Node summary (counts by type and status)\n    stats = store.get_stats()\n    node_summary = {\n        'total': stats['total_nodes'],\n        'active': stats['active'],\n        'compressed': stats['compressed'],\n        'by_type': stats['by_type']\n    }\n\n    cp = CheckpointNode(\n        id=_uuid(),\n        task_id=task_id,\n        goal=goal_text,\n        active_constraints=constraints,\n        active_results=results,\n        current_subgoal=current_subgoal,\n        working_memory=working_memory,\n        task_narrative=narrative,\n        recent_files=recent_files,\n        fatigue_at_checkpoint=fatigue_score,\n        git_state=git_state,\n        icpg_state=icpg_state,\n        node_summary=node_summary,\n        created_at=_now()\n    )\n\n    # Persist to DB\n    store.save_checkpoint(cp)\n\n    # Write to JSON files\n    cp_data = _checkpoint_to_dict(cp)\n\n    # Latest checkpoint (overwrite)\n    latest_path = store.mnemos_dir / 'checkpoint-latest.json'\n    latest_path.write_text(json.dumps(cp_data, indent=2))\n\n    # Archived copy\n    archive_dir = store.mnemos_dir / 'checkpoints'\n    archive_dir.mkdir(exist_ok=True)\n    archive_path = archive_dir / f'{cp.id}.json'\n    archive_path.write_text(json.dumps(cp_data, indent=2))\n\n    return cp\n\n\ndef load_checkpoint(\n    project_dir: str = '.', path: str | None = None\n) -> str | None:\n    \"\"\"Load latest checkpoint and format as context for session injection.\n\n    Returns formatted markdown string, or None if no checkpoint exists.\n    \"\"\"\n    if path:\n        cp_path = Path(path)\n    else:\n        cp_path = Path(project_dir).resolve() / '.mnemos' / 'checkpoint-latest.json'\n\n    if not cp_path.exists():\n        return None\n\n    try:\n        data = json.loads(cp_path.read_text())\n    except (json.JSONDecodeError, OSError):\n        return None\n\n    return _format_checkpoint(data)\n\n\ndef _format_checkpoint(data: dict) -> str:\n    \"\"\"Format checkpoint data as structured markdown for context injection.\"\"\"\n    lines = []\n    lines.append('## Mnemos Session Resume')\n    lines.append(f'Checkpoint: {data.get(\"id\", \"unknown\")[:8]}')\n    lines.append(f'Created: {data.get(\"created_at\", \"unknown\")}')\n    lines.append(f'Fatigue at checkpoint: {data.get(\"fatigue_at_checkpoint\", 0):.2f}')\n    lines.append('')\n\n    # Goal\n    lines.append('### Goal')\n    lines.append(data.get('goal', 'No goal recorded'))\n    lines.append('')\n\n    # Constraints\n    constraints = data.get('active_constraints', [])\n    if constraints:\n        lines.append('### Active Constraints (DO NOT VIOLATE)')\n        for c in constraints:\n            lines.append(f'- {c}')\n        lines.append('')\n\n    # What was being worked on (task narrative)\n    narrative = data.get('task_narrative', '')\n    if narrative:\n        lines.append('### What You Were Working On')\n        lines.append(narrative)\n        lines.append('')\n\n    # Current task\n    subgoal = data.get('current_subgoal', '')\n    if subgoal:\n        lines.append('### Current Sub-Goal')\n        lines.append(subgoal)\n        lines.append('')\n\n    # Working memory\n    working = data.get('working_memory', '')\n    if working:\n        lines.append('### Working Memory')\n        lines.append(working)\n        lines.append('')\n\n    # Progress (result summaries)\n    results = data.get('active_results', [])\n    if results:\n        lines.append('### Progress So Far')\n        for r in results:\n            lines.append(f'- {r}')\n        lines.append('')\n\n    # Recent files\n    recent = data.get('recent_files', [])\n    if recent:\n        lines.append('### Key Files (from recent activity)')\n        for f in recent[:10]:\n            parts = []\n            if f.get('edits', 0) > 0:\n                parts.append(f'edited {f[\"edits\"]}x')\n            if f.get('reads', 0) > 0:\n                parts.append(f'read {f[\"reads\"]}x')\n            detail = ', '.join(parts) if parts else 'touched'\n            lines.append(f'- {f.get(\"path\", \"?\")} ({detail})')\n        lines.append('')\n\n    # Git state\n    git = data.get('git_state', {})\n    if git.get('branch'):\n        lines.append('### Git State')\n        lines.append(f'Branch: {git[\"branch\"]}')\n        if git.get('uncommitted'):\n            lines.append('Uncommitted files:')\n            for f in git['uncommitted'][:10]:\n                lines.append(f'  - {f}')\n        lines.append('')\n\n    # iCPG state\n    icpg = data.get('icpg_state')\n    if icpg:\n        lines.append('### iCPG Context')\n        if icpg.get('active_reason'):\n            lines.append(f'Active intent: {icpg[\"active_reason\"]}')\n        if icpg.get('unresolved_drift'):\n            lines.append(f'Unresolved drift: {icpg[\"unresolved_drift\"]}')\n        if icpg.get('stats'):\n            s = icpg['stats']\n            lines.append(\n                f'Graph: {s.get(\"reasons\", 0)} intents, '\n                f'{s.get(\"symbols\", 0)} symbols'\n            )\n        lines.append('')\n\n    # Node summary\n    summary = data.get('node_summary', {})\n    if summary:\n        lines.append('### MnemoGraph Summary')\n        lines.append(\n            f'Nodes: {summary.get(\"active\", 0)} active, '\n            f'{summary.get(\"compressed\", 0)} compressed, '\n            f'{summary.get(\"total\", 0)} total'\n        )\n        by_type = summary.get('by_type', {})\n        if by_type:\n            parts = [f'{t}:{c}' for t, c in by_type.items()]\n            lines.append(f'Types: {\", \".join(parts)}')\n\n    return '\\n'.join(lines)\n\n\ndef _get_git_state(project_dir: Path) -> dict:\n    \"\"\"Get current git branch and uncommitted files.\"\"\"\n    state = {}\n    try:\n        result = subprocess.run(\n            ['git', 'branch', '--show-current'],\n            capture_output=True, text=True, timeout=5,\n            cwd=str(project_dir)\n        )\n        if result.returncode == 0:\n            state['branch'] = result.stdout.strip()\n\n        result = subprocess.run(\n            ['git', 'diff', '--name-only'],\n            capture_output=True, text=True, timeout=5,\n            cwd=str(project_dir)\n        )\n        if result.returncode == 0:\n            files = [\n                f.strip() for f in result.stdout.strip().split('\\n')\n                if f.strip()\n            ]\n            state['uncommitted'] = files\n\n        result = subprocess.run(\n            ['git', 'diff', '--cached', '--name-only'],\n            capture_output=True, text=True, timeout=5,\n            cwd=str(project_dir)\n        )\n        if result.returncode == 0:\n            staged = [\n                f.strip() for f in result.stdout.strip().split('\\n')\n                if f.strip()\n            ]\n            if staged:\n                state['staged'] = staged\n\n    except (subprocess.TimeoutExpired, FileNotFoundError):\n        pass\n    return state\n\n\ndef _get_icpg_state(icpg_store) -> dict:\n    \"\"\"Extract summary iCPG state for checkpoint.\"\"\"\n    state = {}\n    try:\n        stats = icpg_store.get_stats()\n        state['stats'] = stats\n\n        # Find most recent executing reason\n        executing = icpg_store.list_reasons(status='executing')\n        if executing:\n            r = executing[-1]\n            state['active_reason'] = f'{r.id[:8]} -- {r.goal}'\n\n        # Unresolved drift count\n        drift = icpg_store.get_unresolved_drift()\n        state['unresolved_drift'] = len(drift)\n    except Exception:\n        pass\n    return state\n\n\ndef build_task_narrative(project_dir: str | Path) -> tuple[str, list[dict]]:\n    \"\"\"Build a human-readable task narrative from recent signals.\n\n    Reads signals.jsonl and produces:\n    1. A narrative string describing recent activity\n    2. A list of recent files with read/edit counts\n\n    Returns:\n        (narrative_text, recent_files_list)\n    \"\"\"\n    signals = read_recent_signals(str(project_dir), limit=50)\n    if not signals:\n        return ('', [])\n\n    # Count file interactions\n    file_edits: Counter = Counter()\n    file_reads: Counter = Counter()\n    tool_counts: Counter = Counter()\n    error_count = 0\n    total_outcomes = 0\n\n    for s in signals:\n        tool = s.get('tool', '')\n        fp = s.get('file_path', '')\n        tool_counts[tool] += 1\n\n        if fp:\n            if tool in ('Edit', 'Write'):\n                file_edits[fp] += 1\n            elif tool == 'Read':\n                file_reads[fp] += 1\n\n        if 'success' in s:\n            total_outcomes += 1\n            if not s['success']:\n                error_count += 1\n\n    # Build narrative\n    parts = []\n\n    # Most-edited files\n    top_edits = file_edits.most_common(5)\n    if top_edits:\n        edit_parts = []\n        for fp, count in top_edits:\n            name = Path(fp).name\n            edit_parts.append(f'{name} ({count}x)')\n        parts.append(f'Editing: {\", \".join(edit_parts)}')\n\n    # Most-read files\n    top_reads = file_reads.most_common(5)\n    if top_reads:\n        read_parts = []\n        for fp, count in top_reads:\n            name = Path(fp).name\n            read_parts.append(f'{name} ({count}x)')\n        parts.append(f'Reading: {\", \".join(read_parts)}')\n\n    # Tool activity\n    other_tools = {t: c for t, c in tool_counts.items()\n                   if t not in ('Edit', 'Write', 'Read')}\n    if other_tools:\n        tool_parts = [f'{t}:{c}' for t, c in\n                      sorted(other_tools.items(), key=lambda x: -x[1])]\n        parts.append(f'Other tools: {\", \".join(tool_parts[:5])}')\n\n    # Focus area (most common directory)\n    all_files = list(file_edits.keys()) + list(file_reads.keys())\n    if all_files:\n        dir_counts: Counter = Counter()\n        for fp in all_files:\n            parent = str(Path(fp).parent)\n            # Shorten to relative if possible\n            try:\n                parent = str(Path(parent).relative_to(Path.cwd()))\n            except ValueError:\n                pass\n            dir_counts[parent] += 1\n        top_dir = dir_counts.most_common(1)[0]\n        parts.append(f'Focus area: {top_dir[0]}/')\n\n    # Errors\n    if error_count > 0:\n        parts.append(f'Errors: {error_count}/{total_outcomes} tool calls failed')\n\n    narrative = '. '.join(parts) + '.' if parts else ''\n\n    # Build recent files list\n    all_touched = set(file_edits.keys()) | set(file_reads.keys())\n    recent_files = []\n    for fp in all_touched:\n        entry = {'path': fp}\n        if file_edits[fp]:\n            entry['edits'] = file_edits[fp]\n        if file_reads[fp]:\n            entry['reads'] = file_reads[fp]\n        recent_files.append(entry)\n    # Sort by total activity\n    recent_files.sort(\n        key=lambda x: x.get('edits', 0) + x.get('reads', 0),\n        reverse=True\n    )\n\n    return (narrative, recent_files[:15])\n\n\ndef format_for_post_compact_injection(\n    project_dir: str = '.',\n    checkpoint_path: str | None = None\n) -> str | None:\n    \"\"\"Format checkpoint as a rich injection block for post-compaction context.\n\n    Called by mnemos-post-compact-inject.sh after compaction is detected.\n    Returns a structured block that Claude can parse and resume from.\n    \"\"\"\n    if checkpoint_path:\n        cp_path = Path(checkpoint_path)\n    else:\n        cp_path = Path(project_dir).resolve() / '.mnemos' / 'checkpoint-latest.json'\n\n    if not cp_path.exists():\n        return None\n\n    try:\n        data = json.loads(cp_path.read_text())\n    except (json.JSONDecodeError, OSError):\n        return None\n\n    lines = []\n    lines.append('=== MNEMOS: CONTEXT RESTORED AFTER COMPACTION ===')\n    lines.append('')\n    lines.append('Compaction just occurred. Your previous context was summarized.')\n    lines.append('Resume from this checkpoint -- DO NOT re-derive information already captured below.')\n    lines.append('')\n\n    # Goal\n    lines.append('## Goal')\n    lines.append(data.get('goal', 'No goal recorded'))\n    lines.append('')\n\n    # Constraints\n    constraints = data.get('active_constraints', [])\n    if constraints:\n        lines.append('## Active Constraints (DO NOT VIOLATE)')\n        for c in constraints:\n            lines.append(f'- {c}')\n        lines.append('')\n\n    # Task narrative\n    narrative = data.get('task_narrative', '')\n    if narrative:\n        lines.append('## What You Were Working On')\n        lines.append(narrative)\n        lines.append('')\n\n    # Current sub-goal\n    subgoal = data.get('current_subgoal', '')\n    if subgoal:\n        lines.append('## Current Sub-Goal')\n        lines.append(subgoal)\n        lines.append('')\n\n    # Working memory\n    working = data.get('working_memory', '')\n    if working:\n        lines.append('## Working Memory')\n        lines.append(working)\n        lines.append('')\n\n    # Progress\n    results = data.get('active_results', [])\n    if results:\n        lines.append('## Progress So Far')\n        for r in results:\n            lines.append(f'- {r}')\n        lines.append('')\n\n    # Recent files\n    recent = data.get('recent_files', [])\n    if recent:\n        lines.append('## Key Files (from recent activity)')\n        for f in recent[:10]:\n            parts = []\n            if f.get('edits', 0) > 0:\n                parts.append(f'edited {f[\"edits\"]}x')\n            if f.get('reads', 0) > 0:\n                parts.append(f'read {f[\"reads\"]}x')\n            detail = ', '.join(parts) if parts else 'touched'\n            lines.append(f'- {f.get(\"path\", \"?\")} ({detail})')\n        lines.append('')\n\n    # Git state\n    git = data.get('git_state', {})\n    if git.get('branch'):\n        lines.append('## Git State')\n        lines.append(f'Branch: {git[\"branch\"]}')\n        if git.get('uncommitted'):\n            lines.append('Uncommitted:')\n            for gf in git['uncommitted'][:10]:\n                lines.append(f'  - {gf}')\n        else:\n            lines.append('Working tree clean.')\n        lines.append('')\n\n    # iCPG\n    icpg = data.get('icpg_state')\n    if icpg:\n        lines.append('## iCPG Context')\n        if icpg.get('active_reason'):\n            lines.append(f'Active intent: {icpg[\"active_reason\"]}')\n        if icpg.get('unresolved_drift'):\n            lines.append(f'Unresolved drift: {icpg[\"unresolved_drift\"]}')\n        lines.append('')\n\n    # Checkpoint metadata\n    lines.append(f'Checkpoint: {data.get(\"id\", \"?\")[:8]} at {data.get(\"created_at\", \"?\")}')\n    lines.append(f'Fatigue at checkpoint: {data.get(\"fatigue_at_checkpoint\", 0):.2f}')\n    lines.append('')\n    lines.append('=== Resume work from this checkpoint. Ask the user to confirm the task if unclear. ===')\n\n    return '\\n'.join(lines)\n\n\ndef write_compaction_marker(project_dir: str = '.') -> None:\n    \"\"\"Write the just-compacted marker file for post-compaction detection.\"\"\"\n    marker = Path(project_dir).resolve() / '.mnemos' / 'just-compacted'\n    marker.parent.mkdir(parents=True, exist_ok=True)\n    marker.write_text(json.dumps({\n        'timestamp': time.time(),\n        'reason': 'pre_compact_hook'\n    }))\n\n\ndef check_compaction_marker(project_dir: str = '.') -> bool:\n    \"\"\"Check if a fresh compaction marker exists (< 5 minutes old).\"\"\"\n    marker = Path(project_dir).resolve() / '.mnemos' / 'just-compacted'\n    if not marker.exists():\n        return False\n    try:\n        data = json.loads(marker.read_text())\n        age = time.time() - data.get('timestamp', 0)\n        return age < 300  # 5 minutes\n    except (json.JSONDecodeError, OSError):\n        return False\n\n\ndef consume_compaction_marker(project_dir: str = '.') -> bool:\n    \"\"\"Atomically consume the compaction marker (rename then delete).\n\n    Returns True if marker was consumed, False if already consumed or missing.\n    \"\"\"\n    marker = Path(project_dir).resolve() / '.mnemos' / 'just-compacted'\n    consumed = marker.with_suffix('.consumed')\n    try:\n        marker.rename(consumed)\n        consumed.unlink(missing_ok=True)\n        return True\n    except (OSError, FileNotFoundError):\n        return False\n\n\ndef _checkpoint_to_dict(cp: CheckpointNode) -> dict:\n    \"\"\"Serialize CheckpointNode to JSON-safe dict.\"\"\"\n    return {\n        'id': cp.id,\n        'task_id': cp.task_id,\n        'goal': cp.goal,\n        'active_constraints': cp.active_constraints,\n        'active_results': cp.active_results,\n        'current_subgoal': cp.current_subgoal,\n        'working_memory': cp.working_memory,\n        'task_narrative': cp.task_narrative,\n        'recent_files': cp.recent_files,\n        'fatigue_at_checkpoint': cp.fatigue_at_checkpoint,\n        'git_state': cp.git_state,\n        'icpg_state': cp.icpg_state,\n        'node_summary': cp.node_summary,\n        'created_at': cp.created_at\n    }\n"
  },
  {
    "path": "scripts/mnemos/consolidation.py",
    "content": "\"\"\"Micro-consolidation -- rule-based, in-context, Tier 0 only.\n\nTriggered when fatigue >= 0.40 (COMPRESS state). No LLM calls.\nTarget: <100ms execution time.\n\nActions:\n    1. Compress 3 oldest ResultNodes (status=COMPRESSED, summary kept)\n    2. Evict 1 cold ContextNode (weight < 0.2, no scope overlap)\n    3. Decay weights on all evictable active nodes\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom .models import MnemoNode\nfrom .store import MnemosStore\n\n\ndef micro_consolidate(\n    store: MnemosStore,\n    current_scope: str = '',\n    max_compress: int = 3,\n    max_evict: int = 1\n) -> dict:\n    \"\"\"Run micro-consolidation pass. Rule-based, no LLM.\n\n    Args:\n        store: MnemosStore instance.\n        current_scope: Current scope tag for eviction decisions.\n        max_compress: Max ResultNodes to compress per pass.\n        max_evict: Max ContextNodes to evict per pass.\n\n    Returns:\n        Stats: {compressed, evicted, decayed}.\n    \"\"\"\n    stats = {'compressed': 0, 'evicted': 0, 'decayed': 0}\n\n    # 1. Compress oldest active ResultNodes\n    result_nodes = store.get_by_type('result', status='active')\n    # Sort by created_at ascending (oldest first)\n    result_nodes.sort(key=lambda n: n.created_at)\n\n    compressed = 0\n    for node in result_nodes:\n        if compressed >= max_compress:\n            break\n        summary = _compress_result_node(node)\n        store.compress_node(node.id, summary)\n        compressed += 1\n    stats['compressed'] = compressed\n\n    # 2. Evict cold ContextNodes\n    context_nodes = store.get_by_type('context', status='active')\n    evicted = 0\n    for node in context_nodes:\n        if evicted >= max_evict:\n            break\n        if _should_evict(node, current_scope):\n            store.evict_node(node.id)\n            evicted += 1\n    stats['evicted'] = evicted\n\n    # 3. Decay weights on all evictable nodes\n    decayed = store.decay_weights(factor=0.95)\n    stats['decayed'] = decayed\n\n    return stats\n\n\ndef _compress_result_node(node: MnemoNode) -> str:\n    \"\"\"Produce a summary from a ResultNode.\n\n    Rule-based: first 200 chars of content as summary.\n    \"\"\"\n    content = node.content.strip()\n    if not content:\n        return node.summary or '(empty result)'\n\n    if len(content) <= 200:\n        return content\n\n    # Truncate at word boundary\n    truncated = content[:200]\n    last_space = truncated.rfind(' ')\n    if last_space > 150:\n        truncated = truncated[:last_space]\n    return truncated + '...'\n\n\ndef _should_evict(node: MnemoNode, current_scope: str) -> bool:\n    \"\"\"Determine if a ContextNode should be evicted.\n\n    Evict when:\n        - activation_weight < 0.2\n        - No scope_tag overlap with current scope\n        - Access count is low (< 3)\n    \"\"\"\n    if node.activation_weight >= 0.2:\n        return False\n\n    if node.access_count >= 3:\n        return False\n\n    if not current_scope:\n        return True\n\n    # Check scope overlap\n    if node.scope_tags:\n        for tag in node.scope_tags:\n            if current_scope.startswith(tag) or tag.startswith(current_scope):\n                return False\n\n    return True\n"
  },
  {
    "path": "scripts/mnemos/fatigue.py",
    "content": "\"\"\"4-dimension fatigue computation -- all dimensions passively observable.\n\nEvery dimension is derived from actual hook data (tool calls, file paths,\nerrors). No agent cooperation or manual input required.\n\nSignals:\n    1. Token utilization  -- statusline writes context_window.used_percentage\n    2. Scope scatter      -- PreToolUse logs file paths -> unique dirs ratio\n    3. Re-read ratio      -- PreToolUse logs Read calls -> duplicate file ratio\n    4. Error density      -- PostToolUse logs success/failure -> error ratio\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nfrom pathlib import Path\n\nfrom .models import FATIGUE_WEIGHTS, FatigueState, _now\nfrom .signals import (\n    compute_error_density,\n    compute_reread_ratio,\n    compute_scope_scatter,\n    read_recent_signals\n)\n\n\ndef compute_fatigue(\n    context_data: dict,\n    project_dir: str = '.'\n) -> FatigueState:\n    \"\"\"Compute 4-dimension fatigue score from observable signals.\n\n    Args:\n        context_data: Dict with used_percentage (from fatigue.json).\n        project_dir: Project directory to read signals from.\n\n    Returns:\n        FatigueState with per-dimension scores and composite.\n    \"\"\"\n    # Dimension 1: Token utilization (real -- from statusline)\n    token_util = min(1.0, context_data.get('used_percentage', 0) / 100)\n\n    # Read behavioral signals from hook log\n    signals = read_recent_signals(project_dir)\n\n    # Dimension 2: Scope scatter (real -- from PreToolUse file paths)\n    scatter = compute_scope_scatter(signals)\n\n    # Dimension 3: Re-read ratio (real -- from PreToolUse Read calls)\n    reread = compute_reread_ratio(signals)\n\n    # Dimension 4: Error density (real -- from PostToolUse outcomes)\n    errors = compute_error_density(signals)\n\n    # Weighted composite\n    score = (\n        FATIGUE_WEIGHTS['token_utilization'] * token_util\n        + FATIGUE_WEIGHTS['scope_scatter'] * scatter\n        + FATIGUE_WEIGHTS['reread_ratio'] * reread\n        + FATIGUE_WEIGHTS['error_density'] * errors\n    )\n    score = min(1.0, max(0.0, score))\n\n    state = FatigueState.score_to_state(score)\n\n    return FatigueState(\n        token_utilization=round(token_util, 4),\n        scope_scatter=round(scatter, 4),\n        reread_ratio=round(reread, 4),\n        error_density=round(errors, 4),\n        composite_score=round(score, 4),\n        state=state,\n        computed_at=_now()\n    )\n\n\ndef read_fatigue_file(project_dir: str = '.') -> dict:\n    \"\"\"Read the live fatigue.json written by the statusline script.\n\n    Returns dict with used_percentage, remaining_percentage, timestamp.\n    Falls back to empty dict if file missing or corrupt.\n    \"\"\"\n    fatigue_path = Path(project_dir).resolve() / '.mnemos' / 'fatigue.json'\n    if not fatigue_path.exists():\n        return {}\n    try:\n        return json.loads(fatigue_path.read_text())\n    except (json.JSONDecodeError, OSError):\n        return {}\n\n\ndef write_fatigue_file(\n    project_dir: str, used_pct: float, remaining_pct: float\n) -> None:\n    \"\"\"Write fatigue.json for hooks to read. Called by statusline.\"\"\"\n    import time\n    mnemos_dir = Path(project_dir).resolve() / '.mnemos'\n    mnemos_dir.mkdir(parents=True, exist_ok=True)\n    data = {\n        'used_percentage': used_pct,\n        'remaining_percentage': remaining_pct,\n        'timestamp': time.time()\n    }\n    fatigue_path = mnemos_dir / 'fatigue.json'\n    fatigue_path.write_text(json.dumps(data))\n"
  },
  {
    "path": "scripts/mnemos/models.py",
    "content": "\"\"\"Data models for Mnemos -- MnemoNode, FatigueState, CheckpointNode.\"\"\"\n\nfrom __future__ import annotations\n\nimport uuid\nfrom dataclasses import dataclass, field\nfrom datetime import datetime, timezone\n\n\ndef _now() -> str:\n    return datetime.now(timezone.utc).isoformat()\n\n\ndef _uuid() -> str:\n    return str(uuid.uuid4())\n\n\n# --- MnemoNode types ---\nMNEMO_TYPES = (\n    'goal', 'constraint', 'context', 'working',\n    'result', 'skill', 'checkpoint', 'handoff'\n)\n\n# --- MnemoNode statuses ---\nMNEMO_STATUSES = (\n    'active', 'compressed', 'evicted', 'promoted', 'handed_off'\n)\n\n# --- MnemoNode origins ---\nMNEMO_ORIGINS = (\n    'loaded', 'derived', 'tool_result',\n    'inherited', 'agent_generated'\n)\n\n# --- Fatigue states ---\nFATIGUE_STATES = (\n    'flow', 'compress', 'pre_sleep', 'rem', 'emergency'\n)\n\n# --- Fatigue thresholds ---\nFATIGUE_THRESHOLDS = {\n    'flow': (0.0, 0.40),\n    'compress': (0.40, 0.60),\n    'pre_sleep': (0.60, 0.75),\n    'rem': (0.75, 0.90),\n    'emergency': (0.90, 1.0)\n}\n\n# --- Fatigue dimension weights ---\n# All 4 dimensions are passively observable from hook data.\n# No agent cooperation required.\nFATIGUE_WEIGHTS = {\n    'token_utilization': 0.40,  # from statusline context_window.used_percentage\n    'scope_scatter': 0.25,      # unique dirs in recent tool calls (PreToolUse)\n    'reread_ratio': 0.20,       # files Read more than once (PreToolUse)\n    'error_density': 0.15       # failed tool calls ratio (PostToolUse)\n}\n\n# --- Eviction policies per type ---\n# never = GoalNode/ConstraintNode survive all compaction\n# compress_first = content replaced with summary before eviction\n# evictable = can be evicted when cold\nEVICTION_POLICIES = {\n    'goal': 'never',\n    'constraint': 'never',\n    'context': 'evictable',\n    'working': 'compress_first',\n    'result': 'compress_first',\n    'skill': 'compress_first',\n    'checkpoint': 'never',\n    'handoff': 'never'\n}\n\n\n@dataclass\nclass MnemoNode:\n    \"\"\"A typed memory node in the MnemoGraph.\n\n    Types and eviction:\n        goal        -- never evicted, task's primary objective\n        constraint  -- never evicted, invariants and contracts\n        context     -- evictable when activation_weight drops\n        working     -- compressed first, then evicted\n        result      -- compressed first (summary kept), then evicted\n        skill       -- compressed first, promotable to persistent\n        checkpoint  -- never evicted, serialized session state\n        handoff     -- never evicted, task completion summary\n    \"\"\"\n\n    type: str\n    task_id: str\n    content: str\n    id: str = field(default_factory=_uuid)\n    summary: str | None = None\n    activation_weight: float = 1.0\n    status: str = 'active'\n    origin: str = 'agent_generated'\n    confidence: float = 1.0\n    scope_tags: list[str] = field(default_factory=list)\n    links: list[str] = field(default_factory=list)\n    created_at: str = field(default_factory=_now)\n    last_accessed: str = field(default_factory=_now)\n    access_count: int = 0\n\n    @property\n    def eviction_policy(self) -> str:\n        return EVICTION_POLICIES.get(self.type, 'evictable')\n\n    @property\n    def is_evictable(self) -> bool:\n        return self.eviction_policy == 'evictable'\n\n    @property\n    def is_compressible(self) -> bool:\n        return self.eviction_policy == 'compress_first'\n\n\n@dataclass\nclass FatigueState:\n    \"\"\"4-dimension fatigue model -- all dimensions passively observable.\n\n    Dimensions (all derived from hook data, no agent cooperation needed):\n        token_utilization  -- context_window.used_percentage / 100 (statusline)\n        scope_scatter      -- unique dirs in recent tool calls (PreToolUse)\n        reread_ratio       -- files Read'd more than once (PreToolUse)\n        error_density      -- failed tool calls / total (PostToolUse)\n\n    Composite score = weighted average, mapped to fatigue state.\n    \"\"\"\n\n    token_utilization: float = 0.0\n    scope_scatter: float = 0.0\n    reread_ratio: float = 0.0\n    error_density: float = 0.0\n    composite_score: float = 0.0\n    state: str = 'flow'\n    computed_at: str = field(default_factory=_now)\n\n    @staticmethod\n    def score_to_state(score: float) -> str:\n        \"\"\"Map composite fatigue score to named state.\"\"\"\n        if score >= 0.90:\n            return 'emergency'\n        elif score >= 0.75:\n            return 'rem'\n        elif score >= 0.60:\n            return 'pre_sleep'\n        elif score >= 0.40:\n            return 'compress'\n        else:\n            return 'flow'\n\n\n@dataclass\nclass CheckpointNode:\n    \"\"\"Serialized session state for resume after compaction or restart.\n\n    Always includes GoalNode content, all ConstraintNodes, current sub-goal.\n    Optionally includes iCPG state (active ReasonNode, drift summary).\n    \"\"\"\n\n    task_id: str\n    goal: str\n    id: str = field(default_factory=_uuid)\n    active_constraints: list[str] = field(default_factory=list)\n    active_results: list[str] = field(default_factory=list)\n    current_subgoal: str = ''\n    working_memory: str = ''\n    task_narrative: str = ''\n    recent_files: list[dict] = field(default_factory=list)\n    fatigue_at_checkpoint: float = 0.0\n    git_state: dict = field(default_factory=dict)\n    icpg_state: dict | None = None\n    node_summary: dict = field(default_factory=dict)\n    created_at: str = field(default_factory=_now)\n"
  },
  {
    "path": "scripts/mnemos/pyproject.toml",
    "content": "[project]\nname = \"mnemos\"\nversion = \"0.1.0\"\ndescription = \"Task-Scoped Memory Lifecycle for Autonomous Agents\"\nrequires-python = \">=3.10\"\ndependencies = []\n\n[project.scripts]\nmnemos = \"mnemos.__main__:main\"\n\n[build-system]\nrequires = [\"setuptools>=68.0\"]\nbuild-backend = \"setuptools.build_meta\"\n"
  },
  {
    "path": "scripts/mnemos/signals.py",
    "content": "\"\"\"Behavioral signal collection from Claude Code hooks.\n\nHooks receive rich JSON on stdin (tool_name, tool_input, tool_response).\nInstead of relying on agent cooperation (manually setting scope_tags),\nwe passively observe tool call patterns to derive fatigue signals.\n\nSignals collected:\n    - File paths from Read/Edit/Write tool calls (scope scatter)\n    - Re-reads: same file Read'd more than once (context loss)\n    - Tool errors from PostToolUse (struggling agent)\n    - Edit frequency to same file (fix-retry loops)\n\nStorage: .mnemos/signals.jsonl (append-only, one JSON line per event)\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport os\nimport time\nfrom pathlib import Path\n\n\nSIGNALS_FILE = 'signals.jsonl'\n# Rolling window for fatigue computation\nWINDOW_SIZE = 30\n\n\ndef append_signal(project_dir: str, signal: dict) -> None:\n    \"\"\"Append a signal event to signals.jsonl. Must be fast (<1ms).\"\"\"\n    signals_path = Path(project_dir).resolve() / '.mnemos' / SIGNALS_FILE\n    signals_path.parent.mkdir(parents=True, exist_ok=True)\n    signal['ts'] = time.time()\n    with open(signals_path, 'a') as f:\n        f.write(json.dumps(signal) + '\\n')\n\n\ndef read_recent_signals(project_dir: str, limit: int = WINDOW_SIZE) -> list[dict]:\n    \"\"\"Read the last N signals from the log. Reads from tail for speed.\"\"\"\n    signals_path = Path(project_dir).resolve() / '.mnemos' / SIGNALS_FILE\n    if not signals_path.exists():\n        return []\n\n    try:\n        # Read last N lines efficiently\n        lines = _tail(str(signals_path), limit)\n        signals = []\n        for line in lines:\n            line = line.strip()\n            if line:\n                try:\n                    signals.append(json.loads(line))\n                except json.JSONDecodeError:\n                    continue\n        return signals\n    except OSError:\n        return []\n\n\ndef compute_scope_scatter(signals: list[dict]) -> float:\n    \"\"\"Scope scatter: how many different directories is the agent touching?\n\n    Low scatter (focused on 1-2 dirs) = 0.0 (no fatigue).\n    High scatter (bouncing across 8+ dirs) = 1.0 (max fatigue).\n\n    Only considers file-bearing tool calls (Read, Edit, Write, Glob, Grep).\n    \"\"\"\n    dirs = []\n    for s in signals:\n        fp = s.get('file_path', '')\n        if fp:\n            # Normalize to parent directory (2 levels deep max)\n            parts = Path(fp).parts\n            if len(parts) >= 3:\n                dirs.append('/'.join(parts[:3]))\n            elif len(parts) >= 2:\n                dirs.append('/'.join(parts[:2]))\n            elif parts:\n                dirs.append(parts[0])\n\n    if not dirs:\n        return 0.0\n\n    unique_dirs = len(set(dirs))\n    total = len(dirs)\n\n    # 1-2 unique dirs in 30 calls = very focused = 0.0\n    # 3-4 = mild scatter = 0.2-0.4\n    # 5-7 = moderate = 0.4-0.7\n    # 8+ = high scatter = 0.7-1.0\n    ratio = unique_dirs / max(total, 1)\n    # Scale: ratio of 0.1 (1 dir in 10 calls) = 0, ratio of 0.5+ = 1.0\n    return min(1.0, max(0.0, (ratio - 0.1) / 0.4))\n\n\ndef compute_reread_ratio(signals: list[dict]) -> float:\n    \"\"\"Re-read ratio: how often does the agent re-read files it already read?\n\n    High re-reads = agent lost context of what it saw = context degradation.\n    Returns 0.0-1.0.\n    \"\"\"\n    reads = [s['file_path'] for s in signals\n             if s.get('tool') == 'Read' and s.get('file_path')]\n\n    if len(reads) < 3:\n        return 0.0\n\n    seen = set()\n    rereads = 0\n    for fp in reads:\n        if fp in seen:\n            rereads += 1\n        seen.add(fp)\n\n    return min(1.0, rereads / max(len(reads), 1))\n\n\ndef compute_error_density(signals: list[dict]) -> float:\n    \"\"\"Error density: ratio of failed tool calls in recent window.\n\n    High error rate = agent is struggling/confused.\n    Returns 0.0-1.0.\n    \"\"\"\n    outcomes = [s for s in signals if 'success' in s]\n    if not outcomes:\n        return 0.0\n\n    errors = sum(1 for s in outcomes if not s['success'])\n    return min(1.0, errors / max(len(outcomes), 1))\n\n\ndef extract_signal_from_pre_tool(hook_input: dict) -> dict | None:\n    \"\"\"Extract a signal from PreToolUse hook JSON input.\n\n    Returns a signal dict to append, or None if not relevant.\n    \"\"\"\n    tool = hook_input.get('tool_name', '')\n    tool_input = hook_input.get('tool_input', {})\n\n    # Extract file path from various tool inputs\n    file_path = (\n        tool_input.get('file_path')\n        or tool_input.get('path')\n        or ''\n    )\n\n    # For Bash, try to extract paths from command\n    if tool == 'Bash' and not file_path:\n        cmd = tool_input.get('command', '')\n        # Don't log bash commands as file signals\n        return {'tool': 'Bash', 'event': 'pre'}\n\n    if tool in ('Read', 'Edit', 'Write', 'Glob', 'Grep'):\n        return {\n            'tool': tool,\n            'event': 'pre',\n            'file_path': _normalize_path(file_path)\n        }\n\n    return {'tool': tool, 'event': 'pre'}\n\n\ndef extract_signal_from_post_tool(hook_input: dict) -> dict | None:\n    \"\"\"Extract a signal from PostToolUse hook JSON input.\n\n    Captures success/failure for error density computation.\n    \"\"\"\n    tool = hook_input.get('tool_name', '')\n    tool_input = hook_input.get('tool_input', {})\n    response = hook_input.get('tool_response', {})\n\n    file_path = (\n        tool_input.get('file_path')\n        or tool_input.get('path')\n        or ''\n    )\n\n    # Determine success/failure\n    success = True\n    if isinstance(response, dict):\n        # Check for common error indicators\n        if response.get('error') or response.get('is_error'):\n            success = False\n        # Bash exit code\n        if 'exit_code' in response and response['exit_code'] != 0:\n            success = False\n    elif isinstance(response, str):\n        # String responses with error markers\n        if response.startswith('Error:') or response.startswith('error:'):\n            success = False\n\n    return {\n        'tool': tool,\n        'event': 'post',\n        'file_path': _normalize_path(file_path),\n        'success': success\n    }\n\n\ndef _normalize_path(file_path: str) -> str:\n    \"\"\"Normalize file path to relative form for consistent comparison.\"\"\"\n    if not file_path:\n        return ''\n    p = Path(file_path)\n    # Convert absolute paths to relative if within CWD\n    try:\n        return str(p.relative_to(Path.cwd()))\n    except ValueError:\n        return str(p)\n\n\ndef _tail(filepath: str, n: int) -> list[str]:\n    \"\"\"Read last n lines from a file efficiently.\"\"\"\n    try:\n        with open(filepath, 'rb') as f:\n            # Seek to end\n            f.seek(0, 2)\n            size = f.tell()\n            if size == 0:\n                return []\n\n            # Read backwards in chunks\n            chunk_size = min(size, n * 500)  # ~500 bytes per line estimate\n            f.seek(max(0, size - chunk_size))\n            data = f.read().decode('utf-8', errors='replace')\n            lines = data.strip().split('\\n')\n            return lines[-n:]\n    except OSError:\n        return []\n\n\ndef get_session_stats(project_dir: str) -> dict:\n    \"\"\"Get summary stats from signal log for diagnostics.\"\"\"\n    signals = read_recent_signals(project_dir, limit=100)\n    if not signals:\n        return {'total_signals': 0}\n\n    tools = {}\n    files_read = set()\n    rereads = 0\n    errors = 0\n    total_outcomes = 0\n    seen_reads = set()\n\n    for s in signals:\n        tool = s.get('tool', 'unknown')\n        tools[tool] = tools.get(tool, 0) + 1\n\n        fp = s.get('file_path', '')\n        if s.get('tool') == 'Read' and fp:\n            if fp in seen_reads:\n                rereads += 1\n            seen_reads.add(fp)\n            files_read.add(fp)\n\n        if 'success' in s:\n            total_outcomes += 1\n            if not s['success']:\n                errors += 1\n\n    return {\n        'total_signals': len(signals),\n        'tool_calls': tools,\n        'unique_files_read': len(files_read),\n        'rereads': rereads,\n        'errors': errors,\n        'total_outcomes': total_outcomes,\n        'error_rate': errors / max(total_outcomes, 1)\n    }\n"
  },
  {
    "path": "scripts/mnemos/store.py",
    "content": "\"\"\"SQLite storage layer for Mnemos MnemoGraph.\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport sqlite3\nfrom pathlib import Path\n\nfrom .models import CheckpointNode, FatigueState, MnemoNode, _now\n\nMNEMOS_DIR = '.mnemos'\nDB_NAME = 'mnemo.db'\n\nSCHEMA = \"\"\"\nCREATE TABLE IF NOT EXISTS mnemo_nodes (\n    id TEXT PRIMARY KEY,\n    type TEXT NOT NULL,\n    task_id TEXT NOT NULL,\n    content TEXT NOT NULL,\n    summary TEXT,\n    activation_weight REAL DEFAULT 1.0,\n    status TEXT DEFAULT 'active',\n    origin TEXT DEFAULT 'agent_generated',\n    confidence REAL DEFAULT 1.0,\n    scope_tags TEXT DEFAULT '[]',\n    links TEXT DEFAULT '[]',\n    created_at TEXT NOT NULL,\n    last_accessed TEXT NOT NULL,\n    access_count INTEGER DEFAULT 0\n);\n\nCREATE TABLE IF NOT EXISTS checkpoints (\n    id TEXT PRIMARY KEY,\n    task_id TEXT NOT NULL,\n    goal TEXT NOT NULL,\n    active_constraints TEXT DEFAULT '[]',\n    active_results TEXT DEFAULT '[]',\n    current_subgoal TEXT DEFAULT '',\n    working_memory TEXT DEFAULT '',\n    fatigue_at_checkpoint REAL DEFAULT 0.0,\n    git_state TEXT DEFAULT '{}',\n    icpg_state TEXT,\n    node_summary TEXT DEFAULT '{}',\n    created_at TEXT NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS fatigue_log (\n    id INTEGER PRIMARY KEY AUTOINCREMENT,\n    token_utilization REAL,\n    scope_scatter REAL,\n    reread_ratio REAL,\n    error_density REAL,\n    composite_score REAL,\n    state TEXT,\n    computed_at TEXT NOT NULL\n);\n\nCREATE INDEX IF NOT EXISTS idx_mnemo_type ON mnemo_nodes(type);\nCREATE INDEX IF NOT EXISTS idx_mnemo_task ON mnemo_nodes(task_id);\nCREATE INDEX IF NOT EXISTS idx_mnemo_status ON mnemo_nodes(status);\nCREATE INDEX IF NOT EXISTS idx_mnemo_weight ON mnemo_nodes(activation_weight);\nCREATE INDEX IF NOT EXISTS idx_checkpoint_task ON checkpoints(task_id);\nCREATE INDEX IF NOT EXISTS idx_fatigue_time ON fatigue_log(computed_at);\n\"\"\"\n\n\nclass MnemosStore:\n    \"\"\"SQLite-backed storage for the MnemoGraph.\"\"\"\n\n    def __init__(self, project_dir: str = '.'):\n        self.project_dir = Path(project_dir).resolve()\n        self.mnemos_dir = self.project_dir / MNEMOS_DIR\n        self.db_path = self.mnemos_dir / DB_NAME\n\n    def init_db(self) -> None:\n        \"\"\"Create .mnemos/ directory and initialize schema.\"\"\"\n        self.mnemos_dir.mkdir(parents=True, exist_ok=True)\n        gitignore = self.mnemos_dir / '.gitignore'\n        if not gitignore.exists():\n            gitignore.write_text('*\\n')\n        with self._conn() as conn:\n            conn.executescript(SCHEMA)\n\n    def exists(self) -> bool:\n        return self.db_path.exists()\n\n    def _conn(self) -> sqlite3.Connection:\n        conn = sqlite3.connect(str(self.db_path))\n        conn.row_factory = sqlite3.Row\n        conn.execute('PRAGMA journal_mode=WAL')\n        conn.execute('PRAGMA foreign_keys=ON')\n        return conn\n\n    # --- MnemoNode CRUD ---\n\n    def create_node(self, node: MnemoNode) -> str:\n        with self._conn() as conn:\n            conn.execute(\n                \"\"\"INSERT INTO mnemo_nodes\n                   (id, type, task_id, content, summary, activation_weight,\n                    status, origin, confidence, scope_tags, links,\n                    created_at, last_accessed, access_count)\n                   VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)\"\"\",\n                (\n                    node.id, node.type, node.task_id, node.content,\n                    node.summary, node.activation_weight, node.status,\n                    node.origin, node.confidence,\n                    json.dumps(node.scope_tags), json.dumps(node.links),\n                    node.created_at, node.last_accessed, node.access_count\n                )\n            )\n        return node.id\n\n    def get_node(self, node_id: str) -> MnemoNode | None:\n        with self._conn() as conn:\n            row = conn.execute(\n                'SELECT * FROM mnemo_nodes WHERE id = ?', (node_id,)\n            ).fetchone()\n        return self._row_to_node(row) if row else None\n\n    def get_active_nodes(self, task_id: str | None = None) -> list[MnemoNode]:\n        with self._conn() as conn:\n            if task_id:\n                rows = conn.execute(\n                    \"SELECT * FROM mnemo_nodes WHERE status = 'active' \"\n                    \"AND task_id = ? ORDER BY activation_weight DESC\",\n                    (task_id,)\n                ).fetchall()\n            else:\n                rows = conn.execute(\n                    \"SELECT * FROM mnemo_nodes WHERE status = 'active' \"\n                    \"ORDER BY activation_weight DESC\"\n                ).fetchall()\n        return [self._row_to_node(r) for r in rows]\n\n    def get_by_type(\n        self, node_type: str, status: str = 'active'\n    ) -> list[MnemoNode]:\n        with self._conn() as conn:\n            rows = conn.execute(\n                'SELECT * FROM mnemo_nodes WHERE type = ? AND status = ? '\n                'ORDER BY activation_weight DESC',\n                (node_type, status)\n            ).fetchall()\n        return [self._row_to_node(r) for r in rows]\n\n    def nodes_for_scope(self, scope_tags: list[str]) -> list[MnemoNode]:\n        \"\"\"Get active nodes whose scope_tags overlap with given tags.\"\"\"\n        active = self.get_active_nodes()\n        return [\n            n for n in active\n            if set(n.scope_tags) & set(scope_tags)\n        ]\n\n    def nodes_above_weight(self, threshold: float) -> list[MnemoNode]:\n        with self._conn() as conn:\n            rows = conn.execute(\n                \"SELECT * FROM mnemo_nodes WHERE status = 'active' \"\n                \"AND activation_weight >= ? ORDER BY activation_weight DESC\",\n                (threshold,)\n            ).fetchall()\n        return [self._row_to_node(r) for r in rows]\n\n    def update_node_status(self, node_id: str, status: str) -> None:\n        with self._conn() as conn:\n            conn.execute(\n                'UPDATE mnemo_nodes SET status = ? WHERE id = ?',\n                (status, node_id)\n            )\n\n    def update_node_weight(self, node_id: str, weight: float) -> None:\n        with self._conn() as conn:\n            conn.execute(\n                'UPDATE mnemo_nodes SET activation_weight = ? WHERE id = ?',\n                (weight, node_id)\n            )\n\n    def compress_node(self, node_id: str, summary: str) -> None:\n        \"\"\"Compress a node: replace content with summary, set status.\"\"\"\n        with self._conn() as conn:\n            conn.execute(\n                \"UPDATE mnemo_nodes SET status = 'compressed', \"\n                \"summary = ?, content = '' WHERE id = ?\",\n                (summary, node_id)\n            )\n\n    def evict_node(self, node_id: str) -> None:\n        \"\"\"Evict a node: set status, clear content.\"\"\"\n        with self._conn() as conn:\n            conn.execute(\n                \"UPDATE mnemo_nodes SET status = 'evicted', \"\n                \"content = '', summary = NULL WHERE id = ?\",\n                (node_id,)\n            )\n\n    def touch_node(self, node_id: str) -> None:\n        \"\"\"Update last_accessed and increment access_count.\"\"\"\n        with self._conn() as conn:\n            conn.execute(\n                'UPDATE mnemo_nodes SET last_accessed = ?, '\n                'access_count = access_count + 1 WHERE id = ?',\n                (_now(), node_id)\n            )\n\n    def decay_weights(self, factor: float = 0.95) -> int:\n        \"\"\"Apply exponential decay to all active node weights.\n\n        Returns count of nodes decayed.\n        \"\"\"\n        with self._conn() as conn:\n            cursor = conn.execute(\n                \"UPDATE mnemo_nodes SET activation_weight = \"\n                \"MAX(0.01, activation_weight * ?) \"\n                \"WHERE status = 'active' AND type NOT IN \"\n                \"('goal', 'constraint', 'checkpoint', 'handoff')\",\n                (factor,)\n            )\n            return cursor.rowcount\n\n    # --- Checkpoint CRUD ---\n\n    def save_checkpoint(self, cp: CheckpointNode) -> str:\n        with self._conn() as conn:\n            conn.execute(\n                \"\"\"INSERT INTO checkpoints\n                   (id, task_id, goal, active_constraints, active_results,\n                    current_subgoal, working_memory, fatigue_at_checkpoint,\n                    git_state, icpg_state, node_summary, created_at)\n                   VALUES (?,?,?,?,?,?,?,?,?,?,?,?)\"\"\",\n                (\n                    cp.id, cp.task_id, cp.goal,\n                    json.dumps(cp.active_constraints),\n                    json.dumps(cp.active_results),\n                    cp.current_subgoal, cp.working_memory,\n                    cp.fatigue_at_checkpoint,\n                    json.dumps(cp.git_state),\n                    json.dumps(cp.icpg_state) if cp.icpg_state else None,\n                    json.dumps(cp.node_summary),\n                    cp.created_at\n                )\n            )\n        return cp.id\n\n    def get_latest_checkpoint(\n        self, task_id: str | None = None\n    ) -> CheckpointNode | None:\n        with self._conn() as conn:\n            if task_id:\n                row = conn.execute(\n                    'SELECT * FROM checkpoints WHERE task_id = ? '\n                    'ORDER BY created_at DESC LIMIT 1',\n                    (task_id,)\n                ).fetchone()\n            else:\n                row = conn.execute(\n                    'SELECT * FROM checkpoints '\n                    'ORDER BY created_at DESC LIMIT 1'\n                ).fetchone()\n        return self._row_to_checkpoint(row) if row else None\n\n    # --- Fatigue log ---\n\n    def log_fatigue(self, fatigue: FatigueState) -> None:\n        with self._conn() as conn:\n            conn.execute(\n                \"\"\"INSERT INTO fatigue_log\n                   (token_utilization, scope_scatter, reread_ratio,\n                    error_density, composite_score, state, computed_at)\n                   VALUES (?,?,?,?,?,?,?)\"\"\",\n                (\n                    fatigue.token_utilization, fatigue.scope_scatter,\n                    fatigue.reread_ratio, fatigue.error_density,\n                    fatigue.composite_score, fatigue.state,\n                    fatigue.computed_at\n                )\n            )\n\n    def get_fatigue_history(self, limit: int = 20) -> list[FatigueState]:\n        with self._conn() as conn:\n            rows = conn.execute(\n                'SELECT * FROM fatigue_log ORDER BY computed_at DESC '\n                'LIMIT ?', (limit,)\n            ).fetchall()\n        return [self._row_to_fatigue(r) for r in rows]\n\n    # --- Stats ---\n\n    def get_stats(self) -> dict:\n        with self._conn() as conn:\n            total = conn.execute(\n                'SELECT COUNT(*) FROM mnemo_nodes'\n            ).fetchone()[0]\n            active = conn.execute(\n                \"SELECT COUNT(*) FROM mnemo_nodes WHERE status = 'active'\"\n            ).fetchone()[0]\n            compressed = conn.execute(\n                \"SELECT COUNT(*) FROM mnemo_nodes WHERE status = 'compressed'\"\n            ).fetchone()[0]\n            evicted = conn.execute(\n                \"SELECT COUNT(*) FROM mnemo_nodes WHERE status = 'evicted'\"\n            ).fetchone()[0]\n            checkpoints = conn.execute(\n                'SELECT COUNT(*) FROM checkpoints'\n            ).fetchone()[0]\n            fatigue_entries = conn.execute(\n                'SELECT COUNT(*) FROM fatigue_log'\n            ).fetchone()[0]\n\n            # Type breakdown\n            type_rows = conn.execute(\n                \"SELECT type, COUNT(*) as cnt FROM mnemo_nodes \"\n                \"WHERE status = 'active' GROUP BY type\"\n            ).fetchall()\n            by_type = {r['type']: r['cnt'] for r in type_rows}\n\n        return {\n            'total_nodes': total,\n            'active': active,\n            'compressed': compressed,\n            'evicted': evicted,\n            'checkpoints': checkpoints,\n            'fatigue_entries': fatigue_entries,\n            'by_type': by_type\n        }\n\n    # --- iCPG Bridge ---\n\n    def load_from_icpg(self, icpg_store, task_id: str = 'icpg-bridge') -> dict:\n        \"\"\"Import active iCPG ReasonNodes as GoalNodes/ConstraintNodes.\n\n        Returns stats: {goals_imported, constraints_imported}.\n        \"\"\"\n        stats = {'goals_imported': 0, 'constraints_imported': 0}\n\n        reasons = icpg_store.list_reasons()\n        for reason in reasons:\n            if reason.status in ('rejected', 'abandoned'):\n                continue\n\n            # ReasonNode -> GoalNode\n            goal_node = MnemoNode(\n                type='goal',\n                task_id=task_id,\n                content=f'{reason.goal} [iCPG:{reason.id[:8]}]',\n                origin='loaded',\n                scope_tags=reason.scope,\n                confidence=1.0\n            )\n            self.create_node(goal_node)\n            stats['goals_imported'] += 1\n\n            # Invariants/Postconditions -> ConstraintNodes\n            for inv in reason.invariants:\n                cn = MnemoNode(\n                    type='constraint',\n                    task_id=task_id,\n                    content=f'INV: {inv} [from: {reason.goal[:40]}]',\n                    origin='loaded',\n                    scope_tags=reason.scope,\n                    links=[goal_node.id]\n                )\n                self.create_node(cn)\n                stats['constraints_imported'] += 1\n\n            for post in reason.postconditions:\n                cn = MnemoNode(\n                    type='constraint',\n                    task_id=task_id,\n                    content=f'POST: {post} [from: {reason.goal[:40]}]',\n                    origin='loaded',\n                    scope_tags=reason.scope,\n                    links=[goal_node.id]\n                )\n                self.create_node(cn)\n                stats['constraints_imported'] += 1\n\n        return stats\n\n    # --- Row converters ---\n\n    @staticmethod\n    def _row_to_node(row: sqlite3.Row) -> MnemoNode:\n        return MnemoNode(\n            id=row['id'],\n            type=row['type'],\n            task_id=row['task_id'],\n            content=row['content'],\n            summary=row['summary'],\n            activation_weight=row['activation_weight'],\n            status=row['status'],\n            origin=row['origin'],\n            confidence=row['confidence'],\n            scope_tags=json.loads(row['scope_tags']),\n            links=json.loads(row['links']),\n            created_at=row['created_at'],\n            last_accessed=row['last_accessed'],\n            access_count=row['access_count']\n        )\n\n    @staticmethod\n    def _row_to_checkpoint(row: sqlite3.Row) -> CheckpointNode:\n        return CheckpointNode(\n            id=row['id'],\n            task_id=row['task_id'],\n            goal=row['goal'],\n            active_constraints=json.loads(row['active_constraints']),\n            active_results=json.loads(row['active_results']),\n            current_subgoal=row['current_subgoal'],\n            working_memory=row['working_memory'],\n            fatigue_at_checkpoint=row['fatigue_at_checkpoint'],\n            git_state=json.loads(row['git_state']),\n            icpg_state=(\n                json.loads(row['icpg_state'])\n                if row['icpg_state'] else None\n            ),\n            node_summary=json.loads(row['node_summary']),\n            created_at=row['created_at']\n        )\n\n    @staticmethod\n    def _row_to_fatigue(row: sqlite3.Row) -> FatigueState:\n        return FatigueState(\n            token_utilization=row['token_utilization'],\n            scope_scatter=row['scope_scatter'],\n            reread_ratio=row['reread_ratio'],\n            error_density=row['error_density'],\n            composite_score=row['composite_score'],\n            state=row['state'],\n            computed_at=row['computed_at']\n        )\n"
  },
  {
    "path": "scripts/polyphony/__init__.py",
    "content": "\"\"\"Polyphony — Multi-agent orchestration for Maggy.\"\"\"\n\n__version__ = '0.1.0'\n"
  },
  {
    "path": "scripts/polyphony/__main__.py",
    "content": "\"\"\"CLI entry point for Polyphony.\n\nUsage:\n    polyphony init          Create ~/.polyphony/ with config files\n    polyphony spawn <title> Create and route a task\n    polyphony status        Show current task states\n    polyphony cleanup       Remove completed workspaces\n\"\"\"\n\nfrom __future__ import annotations\n\nimport argparse\nimport sys\nfrom pathlib import Path\n\nfrom . import __version__\nfrom .config import (\n    default_config_dir,\n    load_agents,\n    load_config,\n    load_identities,\n    load_routing,\n)\nfrom .store import PolyphonyStore\n\n\ndef cmd_init(args: argparse.Namespace) -> int:\n    \"\"\"Create config directory with templates.\"\"\"\n    cfg_dir = default_config_dir()\n    cfg_dir.mkdir(parents=True, exist_ok=True)\n    print(f\"Initialized {cfg_dir}\")\n    return 0\n\n\ndef cmd_status(args: argparse.Namespace) -> int:\n    \"\"\"Show task states from the store.\"\"\"\n    cfg = load_config()\n    store_dir = Path(cfg.get(\"workspace_root\", \"~/.polyphony\"))\n    store_dir = store_dir.expanduser()\n    store = PolyphonyStore(store_dir)\n    store.init_db()\n    tasks = store.list_tasks()\n    if not tasks:\n        print(\"No tasks.\")\n        return 0\n    for t in tasks:\n        print(f\"  [{t.state:12s}] {t.id[:8]}  {t.title}\")\n    return 0\n\n\ndef cmd_spawn(args: argparse.Namespace) -> int:\n    \"\"\"Create a task from CLI.\"\"\"\n    from .models import Task\n    from .store import PolyphonyStore\n\n    cfg = load_config()\n    store_dir = Path(cfg.get(\"workspace_root\", \"~/.polyphony\"))\n    store_dir = store_dir.expanduser()\n    store = PolyphonyStore(store_dir)\n    store.init_db()\n    task = Task(\n        title=args.title,\n        source=\"local\",\n        source_ref=\"cli\",\n        task_type=args.type,\n    )\n    store.save_task(task)\n    print(f\"Created task {task.id[:8]}: {task.title}\")\n    return 0\n\n\ndef build_parser() -> argparse.ArgumentParser:\n    \"\"\"Build the CLI argument parser.\"\"\"\n    parser = argparse.ArgumentParser(\n        prog=\"polyphony\",\n        description=\"Multi-agent orchestration\",\n    )\n    parser.add_argument(\n        \"--version\", action=\"version\",\n        version=f\"polyphony {__version__}\",\n    )\n    sub = parser.add_subparsers(dest=\"command\")\n\n    sub.add_parser(\"init\", help=\"Initialize config\")\n    sub.add_parser(\"status\", help=\"Show task states\")\n\n    spawn_p = sub.add_parser(\"spawn\", help=\"Create a task\")\n    spawn_p.add_argument(\"title\", help=\"Task title\")\n    spawn_p.add_argument(\n        \"--type\", default=\"feature\",\n        help=\"Task type\",\n    )\n\n    sub.add_parser(\"cleanup\", help=\"Remove workspaces\")\n    return parser\n\n\ndef main() -> int:\n    \"\"\"CLI entry point.\"\"\"\n    parser = build_parser()\n    args = parser.parse_args()\n\n    dispatch = {\n        \"init\": cmd_init,\n        \"status\": cmd_status,\n        \"spawn\": cmd_spawn,\n    }\n\n    handler = dispatch.get(args.command)\n    if handler is None:\n        parser.print_help()\n        return 1\n    return handler(args)\n\n\nif __name__ == \"__main__\":\n    sys.exit(main())\n"
  },
  {
    "path": "scripts/polyphony/adapters/__init__.py",
    "content": "\"\"\"Agent adapters for Polyphony (§8).\n\nRegistry of adapter classes by agent_type name.\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom .claude import ClaudeAdapter\nfrom .codex import CodexAdapter\nfrom .kimi import KimiAdapter\n\n_REGISTRY: dict[str, type] = {\n    \"claude\": ClaudeAdapter,\n    \"codex\": CodexAdapter,\n    \"kimi\": KimiAdapter,\n}\n\n\ndef get_adapter(agent_type: str):\n    \"\"\"Get adapter instance by agent type name.\"\"\"\n    cls = _REGISTRY.get(agent_type)\n    if cls is None:\n        raise KeyError(agent_type)\n    return cls()\n\n\ndef list_adapters() -> list[str]:\n    \"\"\"Return registered adapter names.\"\"\"\n    return list(_REGISTRY.keys())\n"
  },
  {
    "path": "scripts/polyphony/adapters/claude.py",
    "content": "\"\"\"Claude Code adapter (§8.1).\n\nBuilds CLI command: claude -p <prompt> --output-format stream-json\nParses stream-json events for completion/quota detection.\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom ..models import AgentProfile, RunSpec\n\n\nclass ClaudeAdapter:\n    \"\"\"Adapter for Claude Code CLI.\"\"\"\n\n    def build_command(\n        self,\n        profile: AgentProfile,\n        run_spec: RunSpec,\n    ) -> list[str]:\n        \"\"\"Build claude CLI command list.\"\"\"\n        parts = profile.cli_command.split()\n        parts += [\"--output-format\", \"stream-json\"]\n        if run_spec.max_turns:\n            parts += [\"--max-turns\", str(run_spec.max_turns)]\n        return parts\n\n    def detect_completion(self, event: dict) -> bool:\n        \"\"\"Check if event signals task completion.\"\"\"\n        return event.get(\"type\") == \"result\"\n\n    def detect_quota(self, text: str) -> bool:\n        \"\"\"Check if output indicates quota/rate limit.\"\"\"\n        lower = text.lower()\n        return \"rate limit\" in lower or \"quota\" in lower\n"
  },
  {
    "path": "scripts/polyphony/adapters/codex.py",
    "content": "\"\"\"Codex CLI adapter (§8.2).\n\nBuilds CLI command: codex exec --full-auto <prompt>\nParses NDJSON events for completion/quota detection.\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom ..models import AgentProfile, RunSpec\n\n\nclass CodexAdapter:\n    \"\"\"Adapter for OpenAI Codex CLI.\"\"\"\n\n    def build_command(\n        self,\n        profile: AgentProfile,\n        run_spec: RunSpec,\n    ) -> list[str]:\n        \"\"\"Build codex CLI command list.\"\"\"\n        parts = profile.cli_command.split()\n        if \"--full-auto\" not in parts:\n            parts.append(\"--full-auto\")\n        return parts\n\n    def detect_completion(self, event: dict) -> bool:\n        \"\"\"Check if event signals task completion.\"\"\"\n        return event.get(\"status\") == \"completed\"\n\n    def detect_quota(self, text: str) -> bool:\n        \"\"\"Check if output indicates quota/rate limit.\"\"\"\n        lower = text.lower()\n        return \"quota\" in lower or \"rate limit\" in lower\n"
  },
  {
    "path": "scripts/polyphony/adapters/kimi.py",
    "content": "\"\"\"Kimi CLI adapter (§8.3).\n\nBuilds CLI command: kimi --print -y <prompt>\nStub until Kimi headless mode stabilizes.\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom ..models import AgentProfile, RunSpec\n\n\nclass KimiAdapter:\n    \"\"\"Adapter for Moonshot Kimi CLI.\"\"\"\n\n    def build_command(\n        self,\n        profile: AgentProfile,\n        run_spec: RunSpec,\n    ) -> list[str]:\n        \"\"\"Build kimi CLI command list.\"\"\"\n        parts = profile.cli_command.split()\n        return parts\n\n    def detect_completion(self, event: dict) -> bool:\n        \"\"\"Check if event signals task completion.\"\"\"\n        return event.get(\"done\") is True\n\n    def detect_quota(self, text: str) -> bool:\n        \"\"\"Check if output indicates quota/rate limit.\"\"\"\n        lower = text.lower()\n        return \"rate limit\" in lower or \"quota\" in lower\n"
  },
  {
    "path": "scripts/polyphony/config.py",
    "content": "\"\"\"Configuration loading for Polyphony (spec §11).\"\"\"\n\nfrom __future__ import annotations\n\nfrom pathlib import Path\n\nimport yaml\n\nfrom .models import AgentProfile, Identity\n\nDEFAULTS = {\n    \"workspace_root\": \"~/polyphony/workspaces\",\n    \"mirror_root\": \"~/polyphony/mirrors\",\n    \"poll_interval\": \"30s\",\n    \"max_concurrent_agents\": 8,\n    \"event_idle_timeout\": \"5m\",\n}\n\nDEFAULT_ROUTING = {\n    \"rules\": [],\n    \"default\": {\n        \"agent\": \"claude\",\n        \"model\": \"sonnet-4-6\",\n        \"fallback\": [],\n    },\n}\n\n\ndef default_config_dir() -> Path:\n    return Path.home() / \".polyphony\"\n\n\ndef load_config(config_dir: Path) -> dict:\n    \"\"\"Load config.yaml, merging with defaults.\"\"\"\n    cfg = dict(DEFAULTS)\n    path = Path(config_dir) / \"config.yaml\"\n    if path.exists():\n        with open(path) as f:\n            loaded = yaml.safe_load(f) or {}\n        cfg.update(loaded)\n    return cfg\n\n\ndef load_identities(config_dir: Path) -> list[Identity]:\n    \"\"\"Load identities.yaml into Identity objects.\"\"\"\n    path = Path(config_dir) / \"identities.yaml\"\n    if not path.exists():\n        return []\n    with open(path) as f:\n        data = yaml.safe_load(f) or {}\n    return [\n        Identity(\n            name=item[\"name\"],\n            volumes=item.get(\"volumes\", {}),\n            api_keys=item.get(\"api_keys\", {}),\n            cost_ceiling_usd_per_day=item.get(\n                \"cost_ceiling_usd_per_day\"\n            ),\n        )\n        for item in data.get(\"identities\", [])\n    ]\n\n\ndef load_agents(config_dir: Path) -> list[AgentProfile]:\n    \"\"\"Load agents.yaml into AgentProfile objects.\"\"\"\n    path = Path(config_dir) / \"agents.yaml\"\n    if not path.exists():\n        return []\n    with open(path) as f:\n        data = yaml.safe_load(f) or {}\n    return [\n        AgentProfile(\n            name=item[\"name\"],\n            agent_type=item[\"agent_type\"],\n            cli_command=item[\"cli_command\"],\n            context_window_tokens=item.get(\n                \"context_window_tokens\", 200000\n            ),\n            strengths=item.get(\"strengths\", []),\n            event_protocol=item.get(\"event_protocol\", \"ndjson\"),\n        )\n        for item in data.get(\"agents\", [])\n    ]\n\n\ndef load_routing(config_dir: Path) -> dict:\n    \"\"\"Load routing.yaml, merging with defaults.\"\"\"\n    routing = dict(DEFAULT_ROUTING)\n    path = Path(config_dir) / \"routing.yaml\"\n    if not path.exists():\n        return routing\n    with open(path) as f:\n        data = yaml.safe_load(f) or {}\n    if \"rules\" in data:\n        routing[\"rules\"] = data[\"rules\"]\n    if \"default\" in data:\n        routing[\"default\"] = data[\"default\"]\n    return routing\n"
  },
  {
    "path": "scripts/polyphony/events.py",
    "content": "\"\"\"Structured event parsing from container stdout (§8 events).\n\nParses NDJSON and stream-json output into TaskEvent objects.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nfrom dataclasses import dataclass, field\nfrom datetime import datetime, timezone\n\n\ndef _now() -> str:\n    return datetime.now(timezone.utc).isoformat()\n\n\n@dataclass\nclass TaskEvent:\n    \"\"\"A single parsed event from agent output.\"\"\"\n\n    kind: str\n    data: dict = field(default_factory=dict)\n    timestamp: str = field(default_factory=_now)\n\n    @classmethod\n    def from_dict(cls, d: dict) -> TaskEvent:\n        \"\"\"Create from a dictionary.\"\"\"\n        return cls(\n            kind=d.get(\"kind\", \"unknown\"),\n            data=d.get(\"data\", {}),\n            timestamp=d.get(\"timestamp\", _now()),\n        )\n\n\ndef parse_ndjson_line(line: str) -> dict | None:\n    \"\"\"Parse a single NDJSON line. Returns None on failure.\"\"\"\n    stripped = line.strip()\n    if not stripped:\n        return None\n    try:\n        return json.loads(stripped)\n    except (json.JSONDecodeError, ValueError):\n        return None\n\n\ndef parse_stream_json(lines: list[str]) -> list[dict]:\n    \"\"\"Parse multiple NDJSON lines, skipping invalid ones.\"\"\"\n    results: list[dict] = []\n    for line in lines:\n        parsed = parse_ndjson_line(line)\n        if parsed is not None:\n            results.append(parsed)\n    return results\n\n\ndef classify_event(data: dict) -> TaskEvent:\n    \"\"\"Classify a parsed JSON object into a TaskEvent.\"\"\"\n    event_type = data.get(\"type\", \"unknown\")\n    return TaskEvent(kind=event_type, data=data)\n"
  },
  {
    "path": "scripts/polyphony/identity.py",
    "content": "\"\"\"Identity broker — credential resolution (spec §7).\n\nResolves named identities to volume mounts and env overlays\nfor container provisioning.\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom .models import Identity\n\n\ndef resolve_identity(\n    name: str,\n    identities: list[Identity],\n) -> Identity:\n    \"\"\"Find identity by name. Raises KeyError if missing.\"\"\"\n    for identity in identities:\n        if identity.name == name:\n            return identity\n    raise KeyError(name)\n\n\ndef build_volume_mounts(\n    identity: Identity,\n    agent_type: str,\n) -> list[str]:\n    \"\"\"Build Docker -v mount strings for an agent type.\"\"\"\n    path = identity.volumes.get(agent_type)\n    if path is None:\n        return []\n    return [f\"{path}:/home/worker/{path}:ro\"]\n\n\ndef build_env_overlay(identity: Identity) -> dict[str, str]:\n    \"\"\"Build env vars from identity api_keys.\n\n    api_keys maps logical name -> env var name.\n    Returns {env_var_name: env_var_name} for docker --env pass-through.\n    \"\"\"\n    if not identity.api_keys:\n        return {}\n    return {v: v for v in identity.api_keys.values()}\n\n\ndef validate_identity(identity: Identity) -> list[str]:\n    \"\"\"Return list of validation errors (empty = valid).\"\"\"\n    errors: list[str] = []\n    if not identity.name:\n        errors.append(\"name is required\")\n    if not identity.volumes:\n        errors.append(\"At least one volume is required\")\n    return errors\n"
  },
  {
    "path": "scripts/polyphony/models.py",
    "content": "\"\"\"Data models for Polyphony (spec §3).\"\"\"\n\nfrom __future__ import annotations\n\nimport uuid\nfrom dataclasses import asdict, dataclass, field\nfrom datetime import datetime, timezone\n\n\ndef _now() -> str:\n    return datetime.now(timezone.utc).isoformat()\n\n\ndef _uuid() -> str:\n    return str(uuid.uuid4())\n\n\n# --- Task types (§5.1) ---\nTASK_TYPES = (\n    \"research\", \"bugfix\", \"feature\", \"refactor\",\n    \"migration\", \"docs\", \"review\",\n)\n\n# --- Risk levels (§5.1) ---\nRISK_LEVELS = (\"low\", \"medium\", \"high\")\n\n# --- Scope levels (§5.1) ---\nSCOPES = (\n    \"single_file\", \"single_module\",\n    \"multi_module\", \"multi_repo\",\n)\n\n# --- Result statuses ---\nRESULT_STATUSES = (\n    \"succeeded\", \"failed\", \"quota\", \"timeout\", \"crash\",\n)\n\n\n@dataclass\nclass Task:\n    \"\"\"A unit of work from a work source (§3.1).\"\"\"\n\n    title: str\n    source: str\n    source_ref: str\n    id: str = field(default_factory=_uuid)\n    state: str = \"discovered\"\n    task_type: str = \"feature\"\n    scope: list[str] = field(default_factory=list)\n    risk: str = \"low\"\n    context_tokens: int = 0\n    requires_web: bool = False\n    run_spec_id: str | None = None\n    metadata: dict = field(default_factory=dict)\n    created_at: str = field(default_factory=_now)\n    updated_at: str = field(default_factory=_now)\n\n    def to_dict(self) -> dict:\n        return asdict(self)\n\n\n@dataclass\nclass Identity:\n    \"\"\"Named credential bundle (§3.2).\"\"\"\n\n    name: str\n    volumes: dict[str, str] = field(default_factory=dict)\n    api_keys: dict[str, str] = field(default_factory=dict)\n    cost_ceiling_usd_per_day: float | None = None\n\n\n@dataclass\nclass AgentProfile:\n    \"\"\"Agent harness profile (§3.3).\"\"\"\n\n    name: str\n    agent_type: str\n    cli_command: str\n    context_window_tokens: int = 200000\n    strengths: list[str] = field(default_factory=list)\n    event_protocol: str = \"ndjson\"\n    auth_path: str = \"\"\n\n\n@dataclass\nclass RunSpec:\n    \"\"\"Immutable execution spec for one attempt (§3.4).\"\"\"\n\n    task_id: str\n    agent: str\n    identity: str\n    workspace: str\n    image: str\n    id: str = field(default_factory=_uuid)\n    attempt: int = 1\n    model: str = \"\"\n    fallback: list[str] = field(default_factory=list)\n    max_turns: int = 25\n    allowed_paths: list[str] = field(default_factory=list)\n    proof_of_work: list[str] = field(default_factory=list)\n    env_overlay: dict[str, str] = field(default_factory=dict)\n    volume_mounts: list[str] = field(default_factory=list)\n    hooks_pre: list[str] = field(default_factory=list)\n    hooks_post: list[str] = field(default_factory=list)\n    deadline_seconds: int = 1800\n\n\n@dataclass\nclass Result:\n    \"\"\"Outcome of a single run attempt (§3.5).\"\"\"\n\n    task_id: str\n    run_spec_id: str\n    agent: str\n    status: str\n    id: str = field(default_factory=_uuid)\n    turns: int = 0\n    duration_seconds: int = 0\n    cost_usd: float | None = None\n    artifacts: dict[str, str] = field(default_factory=dict)\n    events: list[dict] = field(default_factory=list)\n    completed_at: str = field(default_factory=_now)\n"
  },
  {
    "path": "scripts/polyphony/orchestrator.py",
    "content": "\"\"\"Supervisor loop (§4 orchestrator).\n\ndiscover -> claim -> route -> provision -> run -> verify -> land\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom pathlib import Path\n\nfrom .models import (\n    AgentProfile, Identity, Result, RunSpec, Task,\n)\nfrom .state_machine import transition\nfrom .store import PolyphonyStore\n\n\ndef discover_tasks(store: PolyphonyStore) -> list[Task]:\n    \"\"\"Find tasks in 'discovered' state.\"\"\"\n    return store.list_tasks(state=\"discovered\")\n\n\ndef claim_task(\n    task: Task,\n    store: PolyphonyStore,\n) -> Task:\n    \"\"\"Transition task to 'claimed' and persist.\"\"\"\n    claimed = transition(task, \"claimed\")\n    store.save_task(claimed)\n    return claimed\n\n\ndef provision_workspace(\n    task: Task,\n    base_dir: Path,\n    ref: str,\n) -> Path:\n    \"\"\"Create workspace for task. Returns path.\"\"\"\n    return _create_ws(task, base_dir, ref)\n\n\ndef run_agent(run_spec: RunSpec) -> Result:\n    \"\"\"Execute agent in container. Returns Result.\"\"\"\n    return _execute_container(run_spec)\n\n\ndef verify_result(result: Result) -> bool:\n    \"\"\"Check if result passes proof-of-work.\"\"\"\n    return result.status == \"succeeded\"\n\n\nclass Orchestrator:\n    \"\"\"Main supervisor that drives the task lifecycle.\"\"\"\n\n    def __init__(\n        self,\n        store: PolyphonyStore,\n        agents: list[AgentProfile],\n        policy: dict,\n        identities: list[Identity] | None = None,\n    ):\n        self._store = store\n        self._agents = agents\n        self._policy = policy\n        self._identities = identities or []\n\n    def step(self) -> int:\n        \"\"\"Run one orchestration cycle. Returns tasks processed.\"\"\"\n        tasks = discover_tasks(self._store)\n        count = 0\n        for task in tasks:\n            claim_task(task, self._store)\n            count += 1\n        return count\n\n\ndef _create_ws(\n    task: Task,\n    base_dir: Path,\n    ref: str,\n) -> Path:\n    \"\"\"Placeholder for workspace creation. Mockable.\"\"\"\n    from .workspace import create_workspace\n    return create_workspace(\n        base_dir=base_dir,\n        task_id=task.id,\n        attempt=1,\n        repo_url=\"\",\n        ref=ref,\n    )\n\n\ndef _execute_container(run_spec: RunSpec) -> Result:\n    \"\"\"Placeholder for container execution. Mockable.\"\"\"\n    return Result(\n        task_id=run_spec.task_id,\n        run_spec_id=run_spec.id,\n        agent=run_spec.agent,\n        status=\"failed\",\n    )\n"
  },
  {
    "path": "scripts/polyphony/pyproject.toml",
    "content": "[build-system]\nrequires = [\"setuptools>=68.0\"]\nbuild-backend = \"setuptools.build_meta\"\n\n[project]\nname = \"polyphony\"\nversion = \"0.1.0\"\ndescription = \"Multi-agent orchestration for Maggy\"\nrequires-python = \">=3.11\"\ndependencies = [\"pyyaml>=6.0\"]\n\n[project.scripts]\npolyphony = \"polyphony.__main__:main\"\n"
  },
  {
    "path": "scripts/polyphony/router.py",
    "content": "\"\"\"Pure routing function (spec §5.2-5.6).\n\nroute(task, agents, policy) -> RunSpec\nFirst matching rule wins. Falls back to default.\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom .models import AgentProfile, RunSpec, Task\n\n\ndef route(\n    task: Task,\n    agents: list[AgentProfile],\n    policy: dict,\n    identity: str = \"\",\n) -> RunSpec:\n    \"\"\"Route a task to an agent. Returns a RunSpec.\"\"\"\n    agent = select_agent(task, agents, policy)\n    fallback = _get_fallback(task, policy)\n    return RunSpec(\n        task_id=task.id,\n        agent=agent.name,\n        identity=identity,\n        workspace=\"\",\n        image=\"\",\n        fallback=fallback,\n    )\n\n\ndef select_agent(\n    task: Task,\n    agents: list[AgentProfile],\n    policy: dict,\n) -> AgentProfile:\n    \"\"\"Select agent by first matching rule, or default.\"\"\"\n    agent_map = {a.name: a for a in agents}\n    for rule in policy.get(\"rules\", []):\n        if match_rule(task, rule):\n            name = rule[\"agent\"]\n            if name in agent_map:\n                return agent_map[name]\n    default_name = policy[\"default\"][\"agent\"]\n    return agent_map[default_name]\n\n\ndef match_rule(task: Task, rule: dict) -> bool:\n    \"\"\"Check if a task matches a rule's predicates.\"\"\"\n    match = rule.get(\"match\", {})\n    for field, expected in match.items():\n        actual = getattr(task, field, None)\n        if isinstance(expected, list):\n            if actual not in expected:\n                return False\n        elif actual != expected:\n            return False\n    return True\n\n\ndef _get_fallback(task: Task, policy: dict) -> list[str]:\n    \"\"\"Get fallback chain for a task's route.\"\"\"\n    for rule in policy.get(\"rules\", []):\n        if match_rule(task, rule):\n            return rule.get(\"fallback\", [])\n    return policy[\"default\"].get(\"fallback\", [])\n"
  },
  {
    "path": "scripts/polyphony/runtime.py",
    "content": "\"\"\"Docker container runtime (§8 worker).\n\nCreate, start, stop, remove containers via subprocess calls.\nAll Docker commands go through _run_docker for easy mocking.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport re\nimport subprocess\n\nfrom .models import RunSpec\n\n\ndef build_docker_args(run_spec: RunSpec) -> list[str]:\n    \"\"\"Build docker create argument list from RunSpec.\"\"\"\n    safe_name = re.sub(r\"[^\\w\\-]\", \"-\", run_spec.task_id)\n    name = f\"polyphony-{safe_name}-{run_spec.attempt}\"\n\n    args = [\"docker\", \"create\", \"--name\", name]\n\n    # Workspace mount\n    args += [\"-v\", f\"{run_spec.workspace}:/workspace\"]\n\n    # Identity volume mounts\n    for mount in run_spec.volume_mounts:\n        args += [\"-v\", mount]\n\n    # Environment variables\n    for key, val in run_spec.env_overlay.items():\n        args += [\"-e\", f\"{key}={val}\"]\n\n    args.append(run_spec.image)\n    return args\n\n\ndef create_container(run_spec: RunSpec) -> str:\n    \"\"\"Create a Docker container. Returns container ID.\"\"\"\n    args = build_docker_args(run_spec)\n    result = _run_docker(args)\n    if result.returncode != 0:\n        raise RuntimeError(result.stderr.strip())\n    return result.stdout.strip()\n\n\ndef start_container(container_id: str) -> None:\n    \"\"\"Start a created container.\"\"\"\n    _run_docker([\"docker\", \"start\", container_id])\n\n\ndef stop_container(\n    container_id: str,\n    timeout: int | None = None,\n) -> None:\n    \"\"\"Stop a running container.\"\"\"\n    cmd = [\"docker\", \"stop\"]\n    if timeout is not None:\n        cmd += [\"-t\", str(timeout)]\n    cmd.append(container_id)\n    _run_docker(cmd)\n\n\ndef remove_container(container_id: str) -> None:\n    \"\"\"Remove a container.\"\"\"\n    _run_docker([\"docker\", \"rm\", container_id])\n\n\ndef container_logs(container_id: str) -> str:\n    \"\"\"Get container stdout/stderr logs.\"\"\"\n    result = _run_docker([\"docker\", \"logs\", container_id])\n    return result.stdout\n\n\ndef wait_container(container_id: str) -> int:\n    \"\"\"Wait for container to exit. Returns exit code.\"\"\"\n    result = _run_docker(\n        [\"docker\", \"wait\", container_id],\n    )\n    return int(result.stdout.strip())\n\n\ndef _run_docker(cmd: list[str]) -> subprocess.CompletedProcess:\n    \"\"\"Run a docker command. Thin wrapper for mocking.\"\"\"\n    return subprocess.run(\n        cmd,\n        capture_output=True,\n        text=True,\n        check=False,\n    )\n"
  },
  {
    "path": "scripts/polyphony/scoring.py",
    "content": "\"\"\"5-dimension complexity scoring (spec §5.1).\n\nFormalizes the cross-agent-delegation rubric:\n  cyclomatic, fan_out, security, concurrency, domain\nEach dimension scores 0-2. Total 0-10.\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom .models import Task\n\nDIMENSIONS = (\n    \"cyclomatic\", \"fan_out\", \"security\",\n    \"concurrency\", \"domain\",\n)\n\nSEC_KEYWORDS = frozenset({\n    \"auth\", \"org_id\", \"user_id\", \"pii\",\n    \"rls\", \"billing\", \"payment\", \"secret\",\n    \"token\", \"session\", \"csrf\", \"xss\",\n})\n\nCONCURRENCY_KEYWORDS = frozenset({\n    \"asyncio.lock\", \"for update\", \"transaction\",\n    \"session.begin\", \"mutex\", \"semaphore\",\n    \"atomic\", \"lock\",\n})\n\n\ndef score_task(task: Task) -> int:\n    \"\"\"Total complexity score (0-10).\"\"\"\n    return (\n        score_cyclomatic(task)\n        + score_fan_out(task)\n        + score_security(task)\n        + score_concurrency(task)\n        + score_domain(task)\n    )\n\n\ndef score_cyclomatic(task: Task) -> int:\n    \"\"\"0-2 based on LOC and scope size.\"\"\"\n    loc = task.metadata.get(\"loc\", 0)\n    n_files = len(task.scope)\n    if loc >= 50 or n_files >= 5:\n        return 2\n    if loc >= 10 or n_files >= 2:\n        return 1\n    return 0\n\n\ndef score_fan_out(task: Task) -> int:\n    \"\"\"0-2 based on number of callers.\"\"\"\n    callers = task.metadata.get(\"callers\", 0)\n    if callers >= 11:\n        return 2\n    if callers >= 3:\n        return 1\n    return 0\n\n\ndef score_security(task: Task) -> int:\n    \"\"\"0-2 based on security keyword presence.\"\"\"\n    keywords = _extract_keywords(task)\n    hits = keywords & SEC_KEYWORDS\n    if len(hits) >= 2:\n        return 2\n    if len(hits) >= 1:\n        return 1\n    return 0\n\n\ndef score_concurrency(task: Task) -> int:\n    \"\"\"0-2 based on concurrency keyword presence.\"\"\"\n    keywords = _extract_keywords(task)\n    hits = keywords & CONCURRENCY_KEYWORDS\n    if len(hits) >= 2:\n        return 2\n    if len(hits) >= 1:\n        return 1\n    return 0\n\n\ndef score_domain(task: Task) -> int:\n    \"\"\"0-2 based on risk + task type heuristic.\"\"\"\n    if task.risk == \"high\":\n        return 2\n    if task.risk == \"medium\" or task.task_type == \"refactor\":\n        return 1\n    return 0\n\n\ndef _extract_keywords(task: Task) -> set[str]:\n    \"\"\"Collect keywords from metadata and title.\"\"\"\n    kw = set()\n    for k in task.metadata.get(\"keywords\", []):\n        kw.add(k.lower())\n    for word in task.title.lower().split():\n        kw.add(word)\n    return kw\n"
  },
  {
    "path": "scripts/polyphony/sources/__init__.py",
    "content": "\"\"\"Work sources for Polyphony (§2).\n\nRegistry of task source implementations.\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom .local import LocalSource\nfrom .github import GitHubSource\n\n_REGISTRY: dict[str, type] = {\n    \"local\": LocalSource,\n    \"github\": GitHubSource,\n}\n\n\ndef get_source(kind: str, **kwargs):\n    \"\"\"Get source instance by kind name.\"\"\"\n    cls = _REGISTRY.get(kind)\n    if cls is None:\n        raise KeyError(kind)\n    return cls(**kwargs)\n\n\ndef list_sources() -> list[str]:\n    \"\"\"Return registered source names.\"\"\"\n    return list(_REGISTRY.keys())\n"
  },
  {
    "path": "scripts/polyphony/sources/github.py",
    "content": "\"\"\"GitHub Issues work source (§2).\n\nPolls GitHub Issues via `gh api` for tasks labeled agent-ready.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport subprocess\n\nfrom ..models import Task\n\n\nclass GitHubSource:\n    \"\"\"GitHub Issues as task source.\"\"\"\n\n    def __init__(\n        self,\n        repo: str = \"\",\n        label_filter: str = \"agent-ready\",\n    ):\n        self._repo = repo\n        self._label = label_filter\n\n    def poll(self) -> list[Task]:\n        \"\"\"Fetch open issues matching the label filter.\"\"\"\n        cmd = [\n            \"gh\", \"api\",\n            f\"repos/{self._repo}/issues\",\n            \"--jq\", \".\",\n            \"-q\", f\"label:{self._label}\",\n        ]\n        result = _run_gh(cmd)\n        if result.returncode != 0:\n            return []\n        try:\n            issues = json.loads(result.stdout)\n        except (json.JSONDecodeError, ValueError):\n            return []\n        return [self._issue_to_task(i) for i in issues]\n\n    def _issue_to_task(self, issue: dict) -> Task:\n        \"\"\"Convert a GitHub issue dict to a Task.\"\"\"\n        return Task(\n            title=issue.get(\"title\", \"\"),\n            source=\"github\",\n            source_ref=f\"{self._repo}#{issue.get('number', '')}\",\n        )\n\n\ndef _run_gh(cmd: list[str]) -> subprocess.CompletedProcess:\n    \"\"\"Run a gh CLI command. Thin wrapper for mocking.\"\"\"\n    return subprocess.run(\n        cmd,\n        capture_output=True,\n        text=True,\n        check=False,\n    )\n"
  },
  {
    "path": "scripts/polyphony/sources/local.py",
    "content": "\"\"\"Local SQLite task queue (§2).\n\nSimple task queue backed by a SQLite database file.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport sqlite3\nfrom pathlib import Path\n\nfrom ..models import Task\n\n\nclass LocalSource:\n    \"\"\"File-based local task queue.\"\"\"\n\n    def __init__(self, db_path: Path | None = None):\n        self._path = db_path or Path(\"~/.polyphony/queue.db\")\n        self._path = Path(str(self._path).strip())\n        self._init_db()\n\n    def _init_db(self) -> None:\n        self._path.parent.mkdir(parents=True, exist_ok=True)\n        con = sqlite3.connect(str(self._path))\n        con.execute(\n            \"CREATE TABLE IF NOT EXISTS tasks (\"\n            \"  id TEXT PRIMARY KEY,\"\n            \"  title TEXT NOT NULL,\"\n            \"  task_type TEXT DEFAULT 'feature',\"\n            \"  risk TEXT DEFAULT 'low',\"\n            \"  claimed INTEGER DEFAULT 0\"\n            \")\"\n        )\n        con.commit()\n        con.close()\n\n    def add_task(\n        self,\n        title: str,\n        task_type: str = \"feature\",\n        risk: str = \"low\",\n    ) -> Task:\n        \"\"\"Add a task to the local queue.\"\"\"\n        task = Task(\n            title=title,\n            source=\"local\",\n            source_ref=\"local\",\n            task_type=task_type,\n            risk=risk,\n        )\n        con = sqlite3.connect(str(self._path))\n        con.execute(\n            \"INSERT INTO tasks (id, title, task_type, risk)\"\n            \" VALUES (?, ?, ?, ?)\",\n            (task.id, task.title, task.task_type, task.risk),\n        )\n        con.commit()\n        con.close()\n        return task\n\n    def poll(self) -> list[Task]:\n        \"\"\"Return unclaimed tasks.\"\"\"\n        con = sqlite3.connect(str(self._path))\n        cur = con.execute(\n            \"SELECT id, title, task_type, risk\"\n            \" FROM tasks WHERE claimed = 0\"\n        )\n        tasks = []\n        for row in cur.fetchall():\n            tasks.append(Task(\n                id=row[0],\n                title=row[1],\n                source=\"local\",\n                source_ref=\"local\",\n                task_type=row[2],\n                risk=row[3],\n            ))\n        con.close()\n        return tasks\n\n    def mark_claimed(self, task_id: str) -> None:\n        \"\"\"Mark a task as claimed.\"\"\"\n        con = sqlite3.connect(str(self._path))\n        con.execute(\n            \"UPDATE tasks SET claimed = 1 WHERE id = ?\",\n            (task_id,),\n        )\n        con.commit()\n        con.close()\n"
  },
  {
    "path": "scripts/polyphony/state_machine.py",
    "content": "\"\"\"Task state machine for Polyphony (spec §4).\"\"\"\n\nfrom __future__ import annotations\n\nfrom .models import Task, _now\n\nTASK_STATES = (\n    \"discovered\", \"claimed\", \"routed\", \"provisioned\",\n    \"running\", \"verifying\", \"landed\", \"failed\", \"blocked\",\n)\n\nTRANSITIONS: dict[str, tuple[str, ...]] = {\n    \"discovered\": (\"claimed\",),\n    \"claimed\": (\"routed\",),\n    \"routed\": (\"provisioned\",),\n    \"provisioned\": (\"running\",),\n    \"running\": (\"verifying\", \"failed\"),\n    \"verifying\": (\"landed\", \"failed\"),\n    \"failed\": (\"claimed\", \"blocked\"),\n}\n\nTERMINAL_STATES = (\"landed\", \"blocked\")\n\n\ndef can_transition(current: str, target: str) -> bool:\n    \"\"\"Check if a state transition is valid.\"\"\"\n    allowed = TRANSITIONS.get(current, ())\n    return target in allowed\n\n\ndef transition(task: Task, target: str) -> Task:\n    \"\"\"Transition a task to a new state. Raises on invalid.\"\"\"\n    if not can_transition(task.state, target):\n        msg = f\"Invalid transition: {task.state} -> {target}\"\n        raise ValueError(msg)\n    task.state = target\n    task.updated_at = _now()\n    return task\n\n\ndef is_terminal(state: str) -> bool:\n    \"\"\"Check if a state is terminal (no further transitions).\"\"\"\n    return state in TERMINAL_STATES\n"
  },
  {
    "path": "scripts/polyphony/store.py",
    "content": "\"\"\"SQLite storage layer for Polyphony.\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport sqlite3\nfrom pathlib import Path\n\nfrom .models import Result, RunSpec, Task, _now\n\nDB_NAME = \"orchestrator.db\"\n\nSCHEMA = \"\"\"\nCREATE TABLE IF NOT EXISTS tasks (\n    id TEXT PRIMARY KEY,\n    title TEXT NOT NULL,\n    source TEXT NOT NULL,\n    source_ref TEXT NOT NULL,\n    state TEXT NOT NULL DEFAULT 'discovered',\n    task_type TEXT DEFAULT 'feature',\n    scope TEXT DEFAULT '[]',\n    risk TEXT DEFAULT 'low',\n    context_tokens INTEGER DEFAULT 0,\n    requires_web INTEGER DEFAULT 0,\n    run_spec_id TEXT,\n    metadata TEXT DEFAULT '{}',\n    created_at TEXT NOT NULL,\n    updated_at TEXT NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS run_specs (\n    id TEXT PRIMARY KEY,\n    task_id TEXT NOT NULL,\n    agent TEXT NOT NULL,\n    identity TEXT NOT NULL,\n    workspace TEXT NOT NULL,\n    image TEXT NOT NULL,\n    attempt INTEGER DEFAULT 1,\n    model TEXT DEFAULT '',\n    fallback TEXT DEFAULT '[]',\n    max_turns INTEGER DEFAULT 25,\n    allowed_paths TEXT DEFAULT '[]',\n    proof_of_work TEXT DEFAULT '[]',\n    env_overlay TEXT DEFAULT '{}',\n    volume_mounts TEXT DEFAULT '[]',\n    deadline_seconds INTEGER DEFAULT 1800\n);\n\nCREATE TABLE IF NOT EXISTS results (\n    id TEXT PRIMARY KEY,\n    task_id TEXT NOT NULL,\n    run_spec_id TEXT NOT NULL,\n    agent TEXT NOT NULL,\n    status TEXT NOT NULL,\n    turns INTEGER DEFAULT 0,\n    duration_seconds INTEGER DEFAULT 0,\n    cost_usd REAL,\n    artifacts TEXT DEFAULT '{}',\n    events TEXT DEFAULT '[]',\n    completed_at TEXT NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS state_log (\n    id INTEGER PRIMARY KEY AUTOINCREMENT,\n    task_id TEXT NOT NULL,\n    from_state TEXT NOT NULL,\n    to_state TEXT NOT NULL,\n    timestamp TEXT NOT NULL\n);\n\"\"\"\n\n\nclass PolyphonyStore:\n    \"\"\"SQLite-backed persistence for Polyphony.\"\"\"\n\n    def __init__(self, base_dir: Path) -> None:\n        self.base_dir = Path(base_dir)\n        self.db_path = self.base_dir / DB_NAME\n\n    def init_db(self) -> None:\n        self.base_dir.mkdir(parents=True, exist_ok=True)\n        self._write_gitignore()\n        conn = self._connect()\n        conn.executescript(SCHEMA)\n        conn.close()\n\n    def _connect(self) -> sqlite3.Connection:\n        conn = sqlite3.connect(str(self.db_path))\n        conn.execute(\"PRAGMA journal_mode=WAL\")\n        conn.execute(\"PRAGMA foreign_keys=ON\")\n        conn.row_factory = sqlite3.Row\n        return conn\n\n    def _write_gitignore(self) -> None:\n        gi = self.base_dir / \".gitignore\"\n        if not gi.exists():\n            gi.write_text(\"*\\n\")\n\n    # --- Task CRUD ---\n\n    def save_task(self, task: Task) -> None:\n        conn = self._connect()\n        conn.execute(\n            \"INSERT OR REPLACE INTO tasks VALUES \"\n            \"(?,?,?,?,?,?,?,?,?,?,?,?,?,?)\",\n            (\n                task.id, task.title, task.source,\n                task.source_ref, task.state, task.task_type,\n                json.dumps(task.scope), task.risk,\n                task.context_tokens, int(task.requires_web),\n                task.run_spec_id, json.dumps(task.metadata),\n                task.created_at, task.updated_at,\n            ),\n        )\n        conn.commit()\n        conn.close()\n\n    def get_task(self, task_id: str) -> Task | None:\n        conn = self._connect()\n        row = conn.execute(\n            \"SELECT * FROM tasks WHERE id=?\", (task_id,),\n        ).fetchone()\n        conn.close()\n        return self._row_to_task(row) if row else None\n\n    def list_tasks(self, state: str | None = None) -> list[Task]:\n        conn = self._connect()\n        if state:\n            rows = conn.execute(\n                \"SELECT * FROM tasks WHERE state=?\", (state,),\n            ).fetchall()\n        else:\n            rows = conn.execute(\"SELECT * FROM tasks\").fetchall()\n        conn.close()\n        return [self._row_to_task(r) for r in rows]\n\n    def _row_to_task(self, row: sqlite3.Row) -> Task:\n        return Task(\n            id=row[\"id\"], title=row[\"title\"],\n            source=row[\"source\"], source_ref=row[\"source_ref\"],\n            state=row[\"state\"], task_type=row[\"task_type\"],\n            scope=json.loads(row[\"scope\"]), risk=row[\"risk\"],\n            context_tokens=row[\"context_tokens\"],\n            requires_web=bool(row[\"requires_web\"]),\n            run_spec_id=row[\"run_spec_id\"],\n            metadata=json.loads(row[\"metadata\"]),\n            created_at=row[\"created_at\"],\n            updated_at=row[\"updated_at\"],\n        )\n\n    # --- RunSpec CRUD ---\n\n    def save_run_spec(self, rs: RunSpec) -> None:\n        conn = self._connect()\n        conn.execute(\n            \"INSERT OR REPLACE INTO run_specs VALUES \"\n            \"(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)\",\n            (\n                rs.id, rs.task_id, rs.agent, rs.identity,\n                rs.workspace, rs.image, rs.attempt, rs.model,\n                json.dumps(rs.fallback), rs.max_turns,\n                json.dumps(rs.allowed_paths),\n                json.dumps(rs.proof_of_work),\n                json.dumps(rs.env_overlay),\n                json.dumps(rs.volume_mounts),\n                rs.deadline_seconds,\n            ),\n        )\n        conn.commit()\n        conn.close()\n\n    def get_run_spec(self, rs_id: str) -> RunSpec | None:\n        conn = self._connect()\n        row = conn.execute(\n            \"SELECT * FROM run_specs WHERE id=?\", (rs_id,),\n        ).fetchone()\n        conn.close()\n        return self._row_to_run_spec(row) if row else None\n\n    def _row_to_run_spec(self, row: sqlite3.Row) -> RunSpec:\n        return RunSpec(\n            id=row[\"id\"], task_id=row[\"task_id\"],\n            agent=row[\"agent\"], identity=row[\"identity\"],\n            workspace=row[\"workspace\"], image=row[\"image\"],\n            attempt=row[\"attempt\"], model=row[\"model\"],\n            fallback=json.loads(row[\"fallback\"]),\n            max_turns=row[\"max_turns\"],\n            allowed_paths=json.loads(row[\"allowed_paths\"]),\n            proof_of_work=json.loads(row[\"proof_of_work\"]),\n            env_overlay=json.loads(row[\"env_overlay\"]),\n            volume_mounts=json.loads(row[\"volume_mounts\"]),\n            deadline_seconds=row[\"deadline_seconds\"],\n        )\n\n    # --- Result CRUD ---\n\n    def save_result(self, result: Result) -> None:\n        conn = self._connect()\n        conn.execute(\n            \"INSERT OR REPLACE INTO results VALUES \"\n            \"(?,?,?,?,?,?,?,?,?,?,?)\",\n            (\n                result.id, result.task_id, result.run_spec_id,\n                result.agent, result.status, result.turns,\n                result.duration_seconds, result.cost_usd,\n                json.dumps(result.artifacts),\n                json.dumps(result.events),\n                result.completed_at,\n            ),\n        )\n        conn.commit()\n        conn.close()\n\n    def get_result(self, result_id: str) -> Result | None:\n        conn = self._connect()\n        row = conn.execute(\n            \"SELECT * FROM results WHERE id=?\", (result_id,),\n        ).fetchone()\n        conn.close()\n        return self._row_to_result(row) if row else None\n\n    def list_results(self, task_id: str) -> list[Result]:\n        conn = self._connect()\n        rows = conn.execute(\n            \"SELECT * FROM results WHERE task_id=?\",\n            (task_id,),\n        ).fetchall()\n        conn.close()\n        return [self._row_to_result(r) for r in rows]\n\n    def _row_to_result(self, row: sqlite3.Row) -> Result:\n        return Result(\n            id=row[\"id\"], task_id=row[\"task_id\"],\n            run_spec_id=row[\"run_spec_id\"],\n            agent=row[\"agent\"], status=row[\"status\"],\n            turns=row[\"turns\"],\n            duration_seconds=row[\"duration_seconds\"],\n            cost_usd=row[\"cost_usd\"],\n            artifacts=json.loads(row[\"artifacts\"]),\n            events=json.loads(row[\"events\"]),\n            completed_at=row[\"completed_at\"],\n        )\n\n    # --- State log ---\n\n    def log_transition(self, task_id: str, from_s: str, to_s: str) -> None:\n        conn = self._connect()\n        conn.execute(\n            \"INSERT INTO state_log (task_id, from_state, to_state, timestamp) \"\n            \"VALUES (?,?,?,?)\",\n            (task_id, from_s, to_s, _now()),\n        )\n        conn.commit()\n        conn.close()\n\n    def get_state_log(self, task_id: str) -> list[dict]:\n        conn = self._connect()\n        rows = conn.execute(\n            \"SELECT * FROM state_log WHERE task_id=? \"\n            \"ORDER BY id\",\n            (task_id,),\n        ).fetchall()\n        conn.close()\n        return [\n            {\n                \"from_state\": r[\"from_state\"],\n                \"to_state\": r[\"to_state\"],\n                \"timestamp\": r[\"timestamp\"],\n            }\n            for r in rows\n        ]\n"
  },
  {
    "path": "scripts/polyphony/workspace.py",
    "content": "\"\"\"Workspace manager — per-task git clone lifecycle (spec §6).\n\nEach task+attempt gets an isolated directory with a full git clone.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport re\nimport shutil\nimport subprocess\nfrom pathlib import Path\n\n\ndef workspace_path(\n    base_dir: Path,\n    task_id: str,\n    attempt: int,\n) -> Path:\n    \"\"\"Build workspace directory path, sanitizing task_id.\"\"\"\n    safe_id = re.sub(r\"[^\\w\\-.]\", \"_\", task_id)\n    return base_dir / safe_id / str(attempt)\n\n\ndef create_workspace(\n    base_dir: Path,\n    task_id: str,\n    attempt: int,\n    repo_url: str,\n    ref: str,\n    mirror_path: Path | None = None,\n) -> Path:\n    \"\"\"Clone repo into workspace and checkout ref.\"\"\"\n    ws = workspace_path(base_dir, task_id, attempt)\n    ws.mkdir(parents=True, exist_ok=True)\n\n    clone_cmd = [\"git\", \"clone\"]\n    if mirror_path and mirror_path.exists():\n        clone_cmd += [\n            \"--reference\", str(mirror_path),\n            \"--dissociate\",\n        ]\n    clone_cmd += [repo_url, str(ws)]\n    _run_git(clone_cmd)\n\n    checkout_cmd = [\"git\", \"-C\", str(ws), \"checkout\", ref]\n    _run_git(checkout_cmd)\n\n    return ws\n\n\ndef cleanup_workspace(ws_path: Path) -> None:\n    \"\"\"Remove workspace directory. No error if missing.\"\"\"\n    if ws_path.exists():\n        shutil.rmtree(ws_path)\n\n\ndef list_workspaces(base_dir: Path) -> list[Path]:\n    \"\"\"List all workspace directories under base_dir.\"\"\"\n    if not base_dir.exists():\n        return []\n    result: list[Path] = []\n    for task_dir in sorted(base_dir.iterdir()):\n        if task_dir.is_dir():\n            for attempt_dir in sorted(task_dir.iterdir()):\n                if attempt_dir.is_dir():\n                    result.append(attempt_dir)\n    return result\n\n\ndef _run_git(cmd: list[str]) -> subprocess.CompletedProcess:\n    \"\"\"Run a git command. Thin wrapper for mocking.\"\"\"\n    return subprocess.run(\n        cmd,\n        capture_output=True,\n        text=True,\n        check=False,\n    )\n"
  },
  {
    "path": "scripts/skill_lint/__init__.py",
    "content": "\"\"\"skill_lint -- Quality gates for Maggy skills.\"\"\"\n\nfrom __future__ import annotations\n\n__version__ = '0.1.0'\n\nfrom dataclasses import dataclass\nfrom enum import Enum\n\n\nclass Severity(Enum):\n    ERROR = 'error'\n    WARNING = 'warning'\n    INFO = 'info'\n\n\n@dataclass\nclass Finding:\n    rule_id: str\n    severity: Severity\n    message: str\n    line: int | None = None\n    suggestion: str | None = None\n"
  },
  {
    "path": "scripts/skill_lint/__main__.py",
    "content": "\"\"\"CLI entry point for skill-lint -- Quality gates for Maggy skills.\"\"\"\n\nfrom __future__ import annotations\n\nimport argparse\nimport sys\nfrom pathlib import Path\n\nfrom . import Severity, __version__\nfrom . import content, frontmatter, references, report, spec\n\n\nCHECKERS = [frontmatter, spec, content, references]\n\n\ndef discover_skills(skills_dir: Path, skill_filter: str | None = None) -> list[Path]:\n    \"\"\"Find all skill directories under skills_dir.\"\"\"\n    if not skills_dir.is_dir():\n        return []\n\n    dirs = sorted(\n        d for d in skills_dir.iterdir()\n        if d.is_dir() and not d.name.startswith('.')\n    )\n\n    if skill_filter:\n        dirs = [d for d in dirs if d.name == skill_filter]\n\n    return dirs\n\n\ndef lint_skill(skill_dir: Path, skills_dir: Path) -> list:\n    \"\"\"Run all checkers on a single skill, return findings.\"\"\"\n    from . import Finding\n    skill_path = skill_dir / 'SKILL.md'\n    findings: list[Finding] = []\n\n    for checker in CHECKERS:\n        findings.extend(checker.check(skill_path, skill_dir, skills_dir))\n\n    return findings\n\n\ndef severity_from_str(s: str) -> Severity:\n    \"\"\"Convert string to Severity enum.\"\"\"\n    mapping = {\n        'error': Severity.ERROR,\n        'warning': Severity.WARNING,\n        'info': Severity.INFO,\n    }\n    result = mapping.get(s.lower())\n    if result is None:\n        raise ValueError(f'Unknown severity: {s}')\n    return result\n\n\ndef main(argv: list[str] | None = None) -> int:\n    parser = argparse.ArgumentParser(\n        prog='skill-lint',\n        description='Quality gates for Maggy skills'\n    )\n    parser.add_argument(\n        '--version', action='version', version=f'skill-lint {__version__}'\n    )\n    parser.add_argument(\n        'skills_dir',\n        help='Path to skills/ directory'\n    )\n    parser.add_argument(\n        '--format', dest='output_format', default='text',\n        choices=['text', 'json'],\n        help='Output format (default: text)'\n    )\n    parser.add_argument(\n        '--severity', default='info',\n        choices=['error', 'warning', 'info'],\n        help='Minimum severity to show (default: info)'\n    )\n    parser.add_argument(\n        '--skill', default=None,\n        help='Lint a single skill by directory name'\n    )\n    parser.add_argument(\n        '--fail-on', dest='fail_on', default='error',\n        choices=['error', 'warning', 'info'],\n        help='Exit 1 if findings at this severity or above (default: error)'\n    )\n\n    args = parser.parse_args(argv)\n\n    skills_dir = Path(args.skills_dir).resolve()\n    if not skills_dir.is_dir():\n        print(f'Error: {args.skills_dir} is not a directory', file=sys.stderr)\n        return 2\n\n    skill_dirs = discover_skills(skills_dir, args.skill)\n    if not skill_dirs:\n        if args.skill:\n            print(f'Error: skill \"{args.skill}\" not found in {skills_dir}', file=sys.stderr)\n            return 2\n        print(f'Error: no skill directories found in {skills_dir}', file=sys.stderr)\n        return 2\n\n    # Run linting\n    results: dict[str, list] = {}\n    for skill_dir in skill_dirs:\n        findings = lint_skill(skill_dir, skills_dir)\n        results[skill_dir.name] = findings\n\n    # Format output\n    min_severity = severity_from_str(args.severity)\n    if args.output_format == 'json':\n        output = report.format_json(results, min_severity)\n    else:\n        output = report.format_text(results, min_severity)\n\n    print(output)\n\n    # Determine exit code\n    fail_severity = severity_from_str(args.fail_on)\n    severity_order = [Severity.ERROR, Severity.WARNING, Severity.INFO]\n    severity_rank = {s: i for i, s in enumerate(severity_order)}\n    fail_rank = severity_rank[fail_severity]\n\n    has_failures = any(\n        any(\n            severity_rank[f.severity] <= fail_rank\n            for f in findings\n        )\n        for findings in results.values()\n    )\n\n    return 1 if has_failures else 0\n\n\nif __name__ == '__main__':\n    sys.exit(main())\n"
  },
  {
    "path": "scripts/skill_lint/content.py",
    "content": "\"\"\"Content quality checks (CQ001-CQ006).\"\"\"\n\nfrom __future__ import annotations\n\nimport re\nfrom pathlib import Path\n\nfrom . import Finding, Severity\n\n# ASCII art box characters (outside code blocks)\nASCII_ART_RE = re.compile(\n    r'[╔╗╚╝╠╣╦╩╬║═│┌┐└┘├┤┬┴┼─┃━┏┓┗┛┣┫┳┻╋]'\n    r'|[+|]{2,}\\s*[-=]{3,}'\n    r'|[-=]{3,}\\s*[+|]{2,}'\n    r'|^\\s*[+][\\-+]{3,}[+]\\s*$'\n    r'|^\\s*[|].*[|]\\s*$'\n)\n\nVAGUE_PHRASES = [\n    'follow best practices',\n    'ensure quality',\n    'as appropriate',\n    'when necessary',\n    'use proper',\n    'handle appropriately',\n    'do the right thing',\n    'be careful',\n    'use common sense',\n    'as needed',\n]\n\nFILLER_WORDS_RE = re.compile(\n    r'\\b(MANDATORY|NON-NEGOTIABLE|ABSOLUTELY|CRITICAL|MUST ALWAYS|'\n    r'NEVER EVER|UNDER NO CIRCUMSTANCES|WITHOUT EXCEPTION|'\n    r'ZERO TOLERANCE|NO EXCEPTIONS)\\b',\n    re.IGNORECASE\n)\n\nSTALE_LOAD_RE = re.compile(r'\\*?Load with:\\s+\\S+\\.md\\*?', re.IGNORECASE)\n\n\ndef _in_code_block(lines: list[str], target_idx: int) -> bool:\n    \"\"\"Check if a line index is inside a fenced code block.\"\"\"\n    in_fence = False\n    for i, line in enumerate(lines):\n        if line.strip().startswith('```'):\n            in_fence = not in_fence\n        if i == target_idx:\n            return in_fence\n    return False\n\n\ndef check(skill_path: Path, skill_dir: Path, skills_dir: Path) -> list[Finding]:\n    \"\"\"Run content quality checks on a single skill.\"\"\"\n    findings: list[Finding] = []\n\n    if not skill_path.exists():\n        return findings\n\n    content = skill_path.read_text(encoding='utf-8')\n    lines = content.split('\\n')\n\n    # Check for inline suppression in first 10 lines\n    suppressed: set[str] = set()\n    for line in lines[:10]:\n        if '<!-- skill-lint: disable=' in line:\n            start = line.index('disable=') + 8\n            end = line.index('-->', start) if '-->' in line[start:] else len(line)\n            rules = line[start:end].strip().rstrip(' >')\n            for rule in rules.split(','):\n                suppressed.add(rule.strip())\n\n    # CQ001: no ASCII art boxes outside code blocks\n    if 'CQ001' not in suppressed:\n        ascii_art_lines = []\n        for i, line in enumerate(lines):\n            if not _in_code_block(lines, i) and ASCII_ART_RE.search(line):\n                ascii_art_lines.append(i + 1)\n        if ascii_art_lines:\n            sample = ascii_art_lines[:3]\n            findings.append(Finding(\n                rule_id='CQ001',\n                severity=Severity.WARNING,\n                message=f'ASCII art detected outside code blocks (lines: {sample})',\n                line=ascii_art_lines[0],\n                suggestion='Remove decorative ASCII art to save tokens'\n            ))\n\n    # CQ002: no vague phrases\n    if 'CQ002' not in suppressed:\n        vague_found = []\n        for i, line in enumerate(lines):\n            if _in_code_block(lines, i):\n                continue\n            lower = line.lower()\n            for phrase in VAGUE_PHRASES:\n                if phrase in lower:\n                    vague_found.append((i + 1, phrase))\n        if vague_found:\n            sample = vague_found[:3]\n            phrases = ', '.join(f'\"{p}\" (L{n})' for n, p in sample)\n            findings.append(Finding(\n                rule_id='CQ002',\n                severity=Severity.INFO,\n                message=f'Vague phrases found: {phrases}',\n                line=vague_found[0][0],\n                suggestion='Replace vague guidance with specific, actionable instructions'\n            ))\n\n    # CQ003: filler intensity <= 2 per 100 lines\n    if 'CQ003' not in suppressed:\n        filler_count = 0\n        for i, line in enumerate(lines):\n            if not _in_code_block(lines, i):\n                filler_count += len(FILLER_WORDS_RE.findall(line))\n        if len(lines) > 0:\n            intensity = (filler_count / len(lines)) * 100\n            if intensity > 2:\n                findings.append(Finding(\n                    rule_id='CQ003',\n                    severity=Severity.WARNING,\n                    message=f'Filler intensity {intensity:.1f} per 100 lines (max: 2.0)',\n                    suggestion='Reduce emphatic language (MANDATORY, NON-NEGOTIABLE, etc.)'\n                ))\n\n    # CQ004: >= 1 code block per 50 lines of content\n    if 'CQ004' not in suppressed:\n        code_blocks = content.count('```') // 2\n        content_lines = len([l for l in lines if l.strip()])\n        if content_lines >= 50:\n            expected = content_lines / 50\n            if code_blocks < expected:\n                findings.append(Finding(\n                    rule_id='CQ004',\n                    severity=Severity.WARNING,\n                    message=f'{code_blocks} code blocks for {content_lines} content lines '\n                            f'(expected >= {int(expected)})',\n                    suggestion='Add concrete code examples to illustrate patterns'\n                ))\n\n    # CQ005: no stale \"Load with:\" references\n    if 'CQ005' not in suppressed:\n        for i, line in enumerate(lines):\n            if not _in_code_block(lines, i) and STALE_LOAD_RE.search(line):\n                findings.append(Finding(\n                    rule_id='CQ005',\n                    severity=Severity.WARNING,\n                    message=f'Stale \"Load with:\" reference at line {i + 1}',\n                    line=i + 1,\n                    suggestion='Remove stale loading instructions'\n                ))\n                break  # One finding is enough\n\n    # CQ006: H1 heading present after frontmatter\n    if 'CQ006' not in suppressed:\n        # Find end of frontmatter\n        in_fm = False\n        fm_end = 0\n        for i, line in enumerate(lines):\n            if line.strip() == '---':\n                if not in_fm:\n                    in_fm = True\n                else:\n                    fm_end = i\n                    break\n\n        has_h1 = False\n        for line in lines[fm_end:]:\n            if line.strip().startswith('# '):\n                has_h1 = True\n                break\n\n        if not has_h1:\n            findings.append(Finding(\n                rule_id='CQ006',\n                severity=Severity.WARNING,\n                message='No H1 heading found after frontmatter',\n                suggestion='Add a top-level heading: # Skill Name'\n            ))\n\n    return findings\n"
  },
  {
    "path": "scripts/skill_lint/frontmatter.py",
    "content": "\"\"\"Frontmatter validation checks (FM001-FM009).\"\"\"\n\nfrom __future__ import annotations\n\nimport re\nfrom pathlib import Path\n\nfrom . import Finding, Severity\n\n\ndef parse_frontmatter(content: str) -> tuple[dict[str, str], int]:\n    \"\"\"Parse YAML frontmatter from between first --- pair.\n\n    Returns (fields_dict, end_line_number).\n    Only parses simple key: value pairs and YAML inline arrays [a, b].\n    \"\"\"\n    lines = content.split('\\n')\n    if not lines or lines[0].strip() != '---':\n        return {}, 0\n\n    fields: dict[str, str] = {}\n    end_line = 0\n    for i, line in enumerate(lines[1:], start=2):\n        if line.strip() == '---':\n            end_line = i\n            break\n        match = re.match(r'^(\\w[\\w-]*)\\s*:\\s*(.*)', line)\n        if match:\n            key = match.group(1).strip()\n            value = match.group(2).strip()\n            # Strip surrounding quotes\n            if len(value) >= 2 and value[0] in ('\"', \"'\") and value[-1] == value[0]:\n                value = value[1:-1]\n            fields[key] = value\n\n    return fields, end_line\n\n\nNAME_PATTERN = re.compile(r'^[a-z][a-z0-9]*(-[a-z0-9]+)*$')\n\n\ndef check(skill_path: Path, skill_dir: Path, skills_dir: Path) -> list[Finding]:\n    \"\"\"Run all frontmatter checks on a single skill.\"\"\"\n    findings: list[Finding] = []\n    content = skill_path.read_text(encoding='utf-8')\n    dir_name = skill_dir.name\n\n    # FM001: frontmatter delimiters present\n    lines = content.split('\\n')\n    if not lines or lines[0].strip() != '---':\n        findings.append(Finding(\n            rule_id='FM001',\n            severity=Severity.ERROR,\n            message='SKILL.md missing YAML frontmatter (must start with ---)',\n            line=1,\n            suggestion='Add frontmatter: ---\\\\nname: ' + dir_name + '\\\\ndescription: ...\\\\n---'\n        ))\n        return findings  # Can't check other rules without frontmatter\n\n    fields, end_line = parse_frontmatter(content)\n    if end_line == 0:\n        findings.append(Finding(\n            rule_id='FM001',\n            severity=Severity.ERROR,\n            message='YAML frontmatter not closed (missing second ---)',\n            line=1,\n            suggestion='Add closing --- after frontmatter fields'\n        ))\n        return findings\n\n    # FM002: name field present\n    name = fields.get('name', '').strip()\n    if not name:\n        findings.append(Finding(\n            rule_id='FM002',\n            severity=Severity.ERROR,\n            message=\"'name' field missing or empty in frontmatter\",\n            line=None,\n            suggestion=f'Add: name: {dir_name}'\n        ))\n\n    # FM003: description field present\n    desc = fields.get('description', '').strip()\n    if not desc:\n        findings.append(Finding(\n            rule_id='FM003',\n            severity=Severity.ERROR,\n            message=\"'description' field missing or empty in frontmatter\",\n            line=None,\n            suggestion='Add: description: One-line description of what this skill does'\n        ))\n\n    # FM004: name matches directory name\n    if name and name != dir_name:\n        findings.append(Finding(\n            rule_id='FM004',\n            severity=Severity.ERROR,\n            message=f\"name '{name}' does not match directory name '{dir_name}'\",\n            line=None,\n            suggestion=f'Change to: name: {dir_name}'\n        ))\n\n    # FM005: name format (lowercase, hyphens, 1-64 chars)\n    if name:\n        if len(name) > 64:\n            findings.append(Finding(\n                rule_id='FM005',\n                severity=Severity.ERROR,\n                message=f'name is {len(name)} chars (max 64)',\n                line=None\n            ))\n        elif not NAME_PATTERN.match(name):\n            findings.append(Finding(\n                rule_id='FM005',\n                severity=Severity.ERROR,\n                message=f\"name '{name}' must be lowercase alphanumeric with hyphens\",\n                line=None,\n                suggestion='Use only lowercase letters, numbers, and hyphens'\n            ))\n\n    # FM006: description length\n    if desc:\n        if len(desc) > 1024:\n            findings.append(Finding(\n                rule_id='FM006',\n                severity=Severity.WARNING,\n                message=f'description is {len(desc)} chars (max 1024)',\n                line=None,\n                suggestion='Shorten description to under 1024 characters'\n            ))\n\n    # FM007: when-to-use present\n    if 'when-to-use' not in fields:\n        findings.append(Finding(\n            rule_id='FM007',\n            severity=Severity.WARNING,\n            message=\"'when-to-use' field missing\",\n            line=None,\n            suggestion='Add: when-to-use: When to activate this skill'\n        ))\n\n    # FM008: user-invocable present\n    if 'user-invocable' not in fields:\n        findings.append(Finding(\n            rule_id='FM008',\n            severity=Severity.INFO,\n            message=\"'user-invocable' field missing\",\n            line=None,\n            suggestion='Add: user-invocable: true|false'\n        ))\n\n    # FM009: effort field valid\n    effort = fields.get('effort', '').strip()\n    if effort and effort not in ('low', 'medium', 'high'):\n        findings.append(Finding(\n            rule_id='FM009',\n            severity=Severity.INFO,\n            message=f\"effort '{effort}' is not one of: low, medium, high\",\n            line=None\n        ))\n    elif not effort:\n        findings.append(Finding(\n            rule_id='FM009',\n            severity=Severity.INFO,\n            message=\"'effort' field missing\",\n            line=None,\n            suggestion='Add: effort: low|medium|high'\n        ))\n\n    return findings\n"
  },
  {
    "path": "scripts/skill_lint/pyproject.toml",
    "content": "[project]\nname = \"skill-lint\"\nversion = \"0.1.0\"\ndescription = \"Quality gates for Maggy skills\"\nrequires-python = \">=3.10\"\ndependencies = []\n\n[project.optional-dependencies]\nskills-ref = [\"skills-ref>=0.1.0\"]\n\n[project.scripts]\nskill-lint = \"skill_lint.__main__:main\"\n\n[build-system]\nrequires = [\"setuptools>=68.0\"]\nbuild-backend = \"setuptools.build_meta\"\n"
  },
  {
    "path": "scripts/skill_lint/references.py",
    "content": "\"\"\"Cross-reference checks (RI001-RI002).\"\"\"\n\nfrom __future__ import annotations\n\nimport re\nfrom pathlib import Path\n\nfrom . import Finding, Severity\n\n# Match skill references like: skills/base, skills/security, .claude/skills/llm-patterns\nSKILL_REF_RE = re.compile(\n    r'(?:\\.claude/)?skills/([a-z][a-z0-9-]+)'\n)\n\n\ndef check(skill_path: Path, skill_dir: Path, skills_dir: Path) -> list[Finding]:\n    \"\"\"Run cross-reference checks on a single skill.\"\"\"\n    findings: list[Finding] = []\n\n    if not skill_path.exists():\n        return findings\n\n    content = skill_path.read_text(encoding='utf-8')\n    dir_name = skill_dir.name\n\n    # RI001: cross-skill name references resolve to existing dirs\n    existing_skills = {\n        d.name for d in skills_dir.iterdir()\n        if d.is_dir() and not d.name.startswith('.')\n    }\n\n    referenced = set()\n    for match in SKILL_REF_RE.finditer(content):\n        ref_name = match.group(1)\n        if ref_name != dir_name:\n            referenced.add(ref_name)\n\n    broken = referenced - existing_skills\n    if broken:\n        findings.append(Finding(\n            rule_id='RI001',\n            severity=Severity.WARNING,\n            message=f'Broken skill references: {\", \".join(sorted(broken))}',\n            suggestion='Fix or remove references to non-existent skills'\n        ))\n\n    # RI002: skill listed in README skills table\n    readme_path = skills_dir.parent / 'README.md'\n    if readme_path.exists():\n        readme = readme_path.read_text(encoding='utf-8')\n        # Check if skill name appears in README (in a table or list)\n        if dir_name not in readme:\n            findings.append(Finding(\n                rule_id='RI002',\n                severity=Severity.INFO,\n                message=f'Skill \"{dir_name}\" not found in README.md',\n                suggestion='Add skill to the skills table in README.md'\n            ))\n\n    return findings\n"
  },
  {
    "path": "scripts/skill_lint/report.py",
    "content": "\"\"\"Output formatters for skill-lint results.\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nfrom collections import defaultdict\n\nfrom . import Finding, Severity\n\n\ndef format_text(\n    results: dict[str, list[Finding]],\n    min_severity: Severity = Severity.INFO\n) -> str:\n    \"\"\"Format findings as human-readable text grouped by severity then skill.\"\"\"\n    severity_order = [Severity.ERROR, Severity.WARNING, Severity.INFO]\n    severity_rank = {s: i for i, s in enumerate(severity_order)}\n    min_rank = severity_rank[min_severity]\n\n    # Group by severity\n    by_severity: dict[Severity, dict[str, list[Finding]]] = defaultdict(\n        lambda: defaultdict(list)\n    )\n\n    total_errors = 0\n    total_warnings = 0\n    total_info = 0\n\n    for skill_name, findings in sorted(results.items()):\n        for f in findings:\n            if severity_rank[f.severity] <= min_rank:\n                by_severity[f.severity][skill_name].append(f)\n                if f.severity == Severity.ERROR:\n                    total_errors += 1\n                elif f.severity == Severity.WARNING:\n                    total_warnings += 1\n                else:\n                    total_info += 1\n\n    lines: list[str] = []\n    total_skills = len(results)\n    clean_skills = sum(1 for fs in results.values() if not fs)\n\n    for sev in severity_order:\n        if sev not in by_severity:\n            continue\n        if severity_rank[sev] > min_rank:\n            continue\n\n        lines.append(f'\\n=== {sev.value.upper()} ===')\n        for skill_name, findings in sorted(by_severity[sev].items()):\n            lines.append(f'\\n  {skill_name}/')\n            for f in findings:\n                loc = f'L{f.line}' if f.line else ''\n                lines.append(f'    [{f.rule_id}] {f.message} {loc}'.rstrip())\n                if f.suggestion:\n                    lines.append(f'      -> {f.suggestion}')\n\n    # Summary\n    lines.append(f'\\n--- Summary ---')\n    lines.append(f'Skills scanned: {total_skills}')\n    lines.append(f'Clean: {clean_skills}')\n    lines.append(f'Errors: {total_errors}  Warnings: {total_warnings}  Info: {total_info}')\n\n    return '\\n'.join(lines)\n\n\ndef format_json(\n    results: dict[str, list[Finding]],\n    min_severity: Severity = Severity.INFO\n) -> str:\n    \"\"\"Format findings as JSON.\"\"\"\n    severity_order = [Severity.ERROR, Severity.WARNING, Severity.INFO]\n    severity_rank = {s: i for i, s in enumerate(severity_order)}\n    min_rank = severity_rank[min_severity]\n\n    total_errors = 0\n    total_warnings = 0\n    total_info = 0\n\n    skills_out: dict[str, dict] = {}\n    for skill_name, findings in sorted(results.items()):\n        filtered = [\n            f for f in findings\n            if severity_rank[f.severity] <= min_rank\n        ]\n        skill_findings = []\n        for f in filtered:\n            entry = {\n                'rule_id': f.rule_id,\n                'severity': f.severity.value,\n                'message': f.message,\n            }\n            if f.line is not None:\n                entry['line'] = f.line\n            if f.suggestion:\n                entry['suggestion'] = f.suggestion\n            skill_findings.append(entry)\n\n            if f.severity == Severity.ERROR:\n                total_errors += 1\n            elif f.severity == Severity.WARNING:\n                total_warnings += 1\n            else:\n                total_info += 1\n\n        skills_out[skill_name] = {\n            'findings': skill_findings,\n            'error_count': sum(1 for f in filtered if f.severity == Severity.ERROR),\n            'warning_count': sum(1 for f in filtered if f.severity == Severity.WARNING),\n        }\n\n    output = {\n        'summary': {\n            'total_skills': len(results),\n            'clean_skills': sum(\n                1 for fs in results.values() if not fs\n            ),\n            'errors': total_errors,\n            'warnings': total_warnings,\n            'info': total_info,\n        },\n        'skills': skills_out,\n    }\n\n    return json.dumps(output, indent=2)\n"
  },
  {
    "path": "scripts/skill_lint/spec.py",
    "content": "\"\"\"Spec compliance checks (SP001-SP003, SR001).\"\"\"\n\nfrom __future__ import annotations\n\nfrom pathlib import Path\n\nfrom . import Finding, Severity\n\n\ndef check(skill_path: Path, skill_dir: Path, skills_dir: Path) -> list[Finding]:\n    \"\"\"Run spec compliance checks on a single skill.\"\"\"\n    findings: list[Finding] = []\n\n    # SP001: SKILL.md exists\n    if not skill_path.exists():\n        findings.append(Finding(\n            rule_id='SP001',\n            severity=Severity.ERROR,\n            message='SKILL.md not found in skill directory',\n            suggestion='Create SKILL.md with frontmatter and content'\n        ))\n        return findings\n\n    content = skill_path.read_text(encoding='utf-8')\n    lines = content.split('\\n')\n    line_count = len(lines)\n\n    # Check for inline suppression in first 10 lines\n    suppressed: set[str] = set()\n    for line in lines[:10]:\n        if '<!-- skill-lint: disable=' in line:\n            # Extract rule IDs: <!-- skill-lint: disable=SP002,SP003 -->\n            start = line.index('disable=') + 8\n            end = line.index('-->', start) if '-->' in line[start:] else len(line)\n            rules = line[start:end].strip().rstrip(' >')\n            for rule in rules.split(','):\n                suppressed.add(rule.strip())\n\n    # SP002: under 500 lines\n    if line_count > 500 and 'SP002' not in suppressed:\n        findings.append(Finding(\n            rule_id='SP002',\n            severity=Severity.WARNING,\n            message=f'SKILL.md is {line_count} lines (limit: 500)',\n            suggestion='Split into focused sections; move reference material to companion files'\n        ))\n\n    # SP003: under 300 lines (ideal)\n    if line_count > 300 and line_count <= 500 and 'SP003' not in suppressed:\n        findings.append(Finding(\n            rule_id='SP003',\n            severity=Severity.INFO,\n            message=f'SKILL.md is {line_count} lines (ideal: under 300)',\n            suggestion='Consider trimming for better token efficiency'\n        ))\n\n    # SR001: skills-ref validate (if installed)\n    try:\n        from skills_ref import validate as sr_validate\n        problems = sr_validate(str(skill_dir))\n        if problems:\n            for p in problems[:5]:\n                findings.append(Finding(\n                    rule_id='SR001',\n                    severity=Severity.WARNING,\n                    message=f'skills-ref: {p}',\n                ))\n    except ImportError:\n        pass  # skills-ref not installed, skip\n\n    return findings\n"
  },
  {
    "path": "skills/aeo-optimization/SKILL.md",
    "content": "---\nname: aeo-optimization\ndescription: AI Engine Optimization - semantic triples, page templates, content clusters for AI citations\nwhen-to-use: When optimizing content for AI engine discovery and citations\nuser-invocable: false\neffort: medium\n---\n\n# AI Engine Optimization (AEO) Skill\n\n\n**Purpose:** Optimize content for AI engines (ChatGPT, Claude, Perplexity, Google AI Overviews) so your brand gets cited in AI-generated answers.\n\n**Source:** Based on [HubSpot's AEO Guide](https://www.hubspot.com/aeo) and industry best practices.\n\n---\n\n## Why AEO Matters Now\n\n```\n┌────────────────────────────────────────────────────────────────┐\n│  THE GREAT DECOUPLING                                          │\n│  ────────────────────────────────────────────────────────────  │\n│  Impressions ≠ Clicks anymore.                                 │\n│  AI engines compile answers from multiple sources.             │\n│  More buyer journey happens inside chat experiences.           │\n│  58% of Google searches = zero clicks (AI overviews).          │\n├────────────────────────────────────────────────────────────────┤\n│  THE OPPORTUNITY                                               │\n│  ────────────────────────────────────────────────────────────  │\n│  Shape what AI engines say about your category and product.    │\n│  Get cited as the authoritative source.                        │\n│  Best answer > Best page ranking.                              │\n└────────────────────────────────────────────────────────────────┘\n```\n\n**Key Stats:**\n- 70% of consumers use ChatGPT for searches\n- 47% of Google queries show AI overviews\n- Average ChatGPT prompt: 23 words (vs 4.2 for Google)\n- AEO market: $886M (2024) → $7.3B (2031)\n\n---\n\n## How AI Engines Choose Answers\n\nAI engines use three main signals to select content for answers:\n\n### 1. Consensus\n\nFacts that appear across multiple credible sources get trusted and reused.\n\n**How to build consensus:**\n- Repeat key facts consistently across your own pages\n- Use same terminology as industry leaders\n- Link to and from authoritative external sources\n- Create internal content clusters that reinforce each other\n\n### 2. Information Gain\n\nNet-new insight beats generic advice. AI engines prefer content that adds value.\n\n**How to add information gain:**\n- Original research and data\n- Concrete examples with specifics\n- Clear point of view (not fence-sitting)\n- Expert quotes with credentials\n- Case studies with metrics\n\n### 3. Entities & Structure\n\nClear entities and tidy structure reduce ambiguity and boost quotability.\n\n**How to optimize structure:**\n- Use semantic triples (Subject → Verb → Object)\n- Clear headings with entity names\n- Schema markup (Article, FAQ, Product)\n- Short, scannable paragraphs (2-4 sentences)\n\n---\n\n## Semantic Triples (Critical for AEO)\n\n**What they are:** Compact facts that AI engines (and humans) can't misread.\n\n**Pattern:** `[Subject]` `[verb]` `[object]`.\n\n### Examples\n\n```\n✅ GOOD (clear triples):\n- HubSpot CRM syncs contact and company data.\n- Lead Scoring assigns priority based on engagement.\n- Workflows trigger email sequences from events.\n\n❌ BAD (vague, no clear entity):\n- The system helps with various tasks.\n- It can do many things for users.\n- This improves overall performance.\n```\n\n### Triple Checklist\n\nFor every key claim, ask:\n- [ ] Is the subject a clear entity (product, feature, brand)?\n- [ ] Is the verb specific and active?\n- [ ] Is the object concrete and measurable?\n\n---\n\n## Paragraph Pattern (Feature → How → Outcome)\n\nEvery substantive paragraph should follow this structure:\n\n```\n[Feature] helps [User/Role] with [Job].\nIt [mechanism/inputs] to [process].\nTeams see [metric/result] in [timeframe/context].\n\nTriples:\n- [Subject] [verb] [object].\n- [Subject] [verb] [object].\n```\n\n### Example\n\n```markdown\nLead Scoring helps sales teams prioritize prospects. It combines\npage views, email engagement, and firmographic data to assign a\nnumeric score, then auto-enrolls high scorers into follow-up\nsequences. Reps focus on qualified accounts and book 40% more\nmeetings.\n\n- Lead Scoring assigns scores from engagement data.\n- High scorers trigger automated follow-up sequences.\n```\n\n---\n\n## Page Templates\n\n### Template 1: Category Explainer\n\n**Goal:** Define the category, tie it to your product, earn citations.\n\n```markdown\n# What is [Category]? — [1-2 line value promise]\n\n## What is [Category]? (~80 words)\n[Plain definition in everyday language. Name adjacent entities.]\n\nTriples:\n1. [Subject] [verb] [object].\n2. [Subject] [verb] [object].\n\n## Why it matters now (~60 words)\n[One paragraph. Mention shift to answers over links; tie to buyer outcomes.]\n\n## How to apply it (3-5 bullets)\n- [Action 1]\n- [Action 2]\n- [Action 3]\n\n## FAQ\n**Q: [Question]?**\nA: [~1 sentence answer]\n\n**Q: [Question]?**\nA: [~1 sentence answer]\n\n**Q: [Question]?**\nA: [~1 sentence answer]\n\n---\n**Links:** [Category hub] | [Product/Feature] | [Credible source 1] | [Credible source 2]\n**CTA:** [Demo / Template / Signup]\n**Schema:** Article + FAQ. Author + last updated.\n```\n\n---\n\n### Template 2: Product & Feature Page\n\n**Goal:** Clarify capability, fit, and next step; reinforce category linkage.\n\n```markdown\n# [Product/Feature] — [Outcome in 3-5 words]\n\n**[Product/Feature] enables [Outcome] for [User/Role].**\n\n## [Feature Area 1]\n[2-4 sentences using Feature → How → Outcome]\n\nTriples:\n1. [Subject] [verb] [object].\n2. [Subject] [verb] [object].\n\n## [Feature Area 2]\n[2-4 sentences using Feature → How → Outcome]\n\nTriples:\n1. [Subject] [verb] [object].\n2. [Subject] [verb] [object].\n\n## [Feature Area 3]\n[2-4 sentences using Feature → How → Outcome]\n\nTriples:\n1. [Subject] [verb] [object].\n2. [Subject] [verb] [object].\n\n## FAQ\n**Q: [Question]?**\nA: [~1 sentence]\n\n**Q: [Question]?**\nA: [~1 sentence]\n\n**Q: [Question]?**\nA: [~1 sentence]\n\n---\n**Links:** Back to [Category Explainer] | Forward to [Demo/Trial]\n**Proof:** [Benchmark/Analyst/Customer proof]\n**Notes:** Requirements/limits (pricing tier, integrations)\n**Schema:** Article + FAQ. Author + last updated.\n```\n\n---\n\n### Template 3: Comparison / Alternatives Page\n\n**Goal:** Help readers decide with clear criteria; earn fair citations.\n\n```markdown\n# [Product] vs. [Alternative] — Which fits [Use case]?\n\n## Comparison Table\n\n| Criterion | [Product] | [Alt A] | [Alt B] | Source |\n|-----------|-----------|---------|---------|--------|\n| [Feature/Limit] | [value] | [value] | [value] | [link] |\n| [Requirement] | [value] | [value] | [value] | [link] |\n| [Best for] | [value] | [value] | [value] | [link] |\n\n*Source-back all claims in the table or footnotes.*\n\n## Fit Statements\n\n1. **[Product]** suits [Team/Use case] when [Condition].\n2. **[Alt A]** fits [Team/Use case] when [Condition].\n3. **[Alt B]** works for [Team/Use case] when [Condition].\n\n---\n**Links:** [Category Explainer] | [Feature pages]\n**CTA:** [Try / Demo / Talk to Sales]\n**Schema:** Article. Author + last updated.\n```\n\n---\n\n### Template 4: Use Case / Industry Page\n\n**Goal:** Connect product to outcomes in a context readers recognize.\n\n```markdown\n# [Industry/Use Case] — [Outcome KPI]\n\n**Teams reduce [Metric] by [Y%] in [Timeframe].**\n\n## Mini Case Study\n[Company/Role] used [Product/Feature] to [Action], resulting in\n[Metric improvement] within [Timeframe].\n\n## How It Works\n\n### [Feature 1]\n[Feature → How → Outcome paragraph]\n\nTriples:\n1. [Subject] [verb] [object].\n2. [Subject] [verb] [object].\n\n### [Feature 2]\n[Feature → How → Outcome paragraph]\n\nTriples:\n1. [Subject] [verb] [object].\n2. [Subject] [verb] [object].\n\n## Who Uses This\n**Roles:** [Role 1], [Role 2], [Role 3]\n**Workflows:** [Workflow 1], [Workflow 2]\n**Integrations:** [Integration 1], [Integration 2]\n\n---\n**Links:** [Product/Feature pages] | [Supporting blog]\n**CTA:** [Industry template / Demo variant]\n**Schema:** Article. Author + last updated.\n```\n\n---\n\n### Template 5: Supporting Blog Post\n\n**Goal:** Add information gain and support your content cluster.\n\n```markdown\n# [Topic] — [Specific promise]\n\n## Opening (~60-80 words)\n[State the problem. Align terminology with Category Explainer. Preview outcome.]\n\n## [Section 1 Heading] (~120 words max)\n[Feature → How → Outcome]\n\nTriples:\n1. [Subject] [verb] [object].\n2. [Subject] [verb] [object].\n\n**Internal link:** [Related page]\n**External citation:** [Credible source]\n\n## [Section 2 Heading] (~120 words max)\n[Feature → How → Outcome]\n\nTriples:\n1. [Subject] [verb] [object].\n2. [Subject] [verb] [object].\n\n**Internal link:** [Related page]\n**External citation:** [Credible source]\n\n## Key Takeaway\n[1-2 lines summarizing the main point]\n\n**CTA:** [Single primary action]\n\n---\n**Schema:** Article. Author + last updated.\n```\n\n---\n\n## Site-Wide Trust Signals\n\n### Required on Every Page\n\n| Element | Implementation |\n|---------|----------------|\n| **Schema markup** | Article + FAQ (if FAQ exists) |\n| **Author attribution** | Name, bio, credentials, photo |\n| **Last updated date** | Visible, machine-readable |\n| **Internal links** | 3-5 per page (upstream/downstream) |\n| **External citations** | 1-2 credible sources per section |\n| **Single CTA** | Demo, template, or signup (repeated once near end) |\n\n### Schema Implementation\n\n```html\n<!-- Article Schema -->\n<script type=\"application/ld+json\">\n{\n  \"@context\": \"https://schema.org\",\n  \"@type\": \"Article\",\n  \"headline\": \"[Page Title]\",\n  \"author\": {\n    \"@type\": \"Person\",\n    \"name\": \"[Author Name]\",\n    \"url\": \"[Author Bio URL]\"\n  },\n  \"datePublished\": \"[ISO Date]\",\n  \"dateModified\": \"[ISO Date]\",\n  \"publisher\": {\n    \"@type\": \"Organization\",\n    \"name\": \"[Company]\",\n    \"logo\": \"[Logo URL]\"\n  }\n}\n</script>\n\n<!-- FAQ Schema (if FAQ section exists) -->\n<script type=\"application/ld+json\">\n{\n  \"@context\": \"https://schema.org\",\n  \"@type\": \"FAQPage\",\n  \"mainEntity\": [\n    {\n      \"@type\": \"Question\",\n      \"name\": \"[Question 1]\",\n      \"acceptedAnswer\": {\n        \"@type\": \"Answer\",\n        \"text\": \"[Answer 1]\"\n      }\n    },\n    {\n      \"@type\": \"Question\",\n      \"name\": \"[Question 2]\",\n      \"acceptedAnswer\": {\n        \"@type\": \"Answer\",\n        \"text\": \"[Answer 2]\"\n      }\n    }\n  ]\n}\n</script>\n```\n\n---\n\n## Content Cluster Architecture\n\n```\n                    ┌─────────────────────┐\n                    │  Category Explainer │\n                    │   \"What is AEO?\"    │\n                    └──────────┬──────────┘\n                               │\n        ┌──────────────────────┼──────────────────────┐\n        │                      │                      │\n        ▼                      ▼                      ▼\n┌───────────────┐    ┌───────────────┐    ┌───────────────┐\n│ Product Page  │    │ Product Page  │    │ Product Page  │\n│  \"Feature A\"  │    │  \"Feature B\"  │    │  \"Feature C\"  │\n└───────┬───────┘    └───────┬───────┘    └───────┬───────┘\n        │                    │                    │\n        ▼                    ▼                    ▼\n┌───────────────┐    ┌───────────────┐    ┌───────────────┐\n│  Blog Post    │    │  Use Case     │    │  Comparison   │\n│  (supports)   │    │  (industry)   │    │  (vs. alt)    │\n└───────────────┘    └───────────────┘    └───────────────┘\n```\n\n**Linking Rules:**\n- Category Explainer links DOWN to all product pages\n- Product pages link UP to Category Explainer\n- Product pages link ACROSS to related features\n- Blog posts link UP to Product pages\n- Comparison pages link to Category Explainer + relevant Product pages\n\n---\n\n## AEO Writing Checklist\n\n### Per-Paragraph Checklist\n\n- [ ] Follows Feature → How → Outcome pattern\n- [ ] Contains 2-4 sentences (scannable)\n- [ ] Includes 1-2 semantic triples\n- [ ] Names specific entities (not vague \"it\" or \"this\")\n- [ ] Uses active voice verbs\n\n### Per-Section Checklist\n\n- [ ] Has 1 internal link (upstream or downstream)\n- [ ] Has 1 external citation (credible source)\n- [ ] Section heading names an entity\n- [ ] ~120 words max\n\n### Per-Page Checklist\n\n- [ ] H1 contains primary entity + value promise\n- [ ] Opening claim is a semantic triple\n- [ ] 3-5 internal links total\n- [ ] 1-2 external citations total\n- [ ] Mini-FAQ with 3 questions (if applicable)\n- [ ] Single primary CTA\n- [ ] Schema markup (Article + FAQ)\n- [ ] Author name + bio link\n- [ ] Last updated date visible\n\n### Site-Wide Checklist\n\n- [ ] Category Explainer exists for each key category\n- [ ] Product pages link back to Category Explainer\n- [ ] Content cluster architecture documented\n- [ ] Author bio pages exist with credentials\n- [ ] Consistent terminology across all pages\n\n---\n\n## Measuring AEO Success\n\n### Key Metrics\n\n| Metric | How to Track |\n|--------|--------------|\n| **AI citations** | Manual checks in ChatGPT, Claude, Perplexity |\n| **Brand mentions in AI** | Search \"[brand] + [category]\" in AI engines |\n| **Share of answer** | How often you're cited vs competitors |\n| **LLM traffic** | GA4 referral from chatgpt.com, claude.ai, perplexity.ai |\n| **Impressions-to-clicks gap** | GSC impressions vs actual clicks |\n\n### Tools\n\n- **HubSpot AEO Grader** - Grade your brand's AI visibility\n- **Google Analytics 4** - Track LLM referral traffic\n- **Google Search Console** - Monitor impressions vs clicks gap\n- **Manual AI queries** - Regularly test your brand in AI engines\n\n---\n\n## Common AEO Mistakes\n\n| Mistake | Fix |\n|---------|-----|\n| Vague language (\"it helps with things\") | Use specific entities and triples |\n| No clear structure | Use Feature → How → Outcome |\n| Missing schema | Add Article + FAQ schema |\n| No author attribution | Add author name, bio, credentials |\n| Generic content | Add original data, examples, POV |\n| Orphan pages | Link into content cluster |\n| Fence-sitting (\"it depends\") | Take a clear position |\n| No external citations | Add 1-2 credible sources per section |\n\n---\n\n## AEO vs Traditional SEO\n\n| Aspect | Traditional SEO | AEO |\n|--------|-----------------|-----|\n| **Goal** | Rank on page 1 | Get cited in AI answers |\n| **Success metric** | Click-through rate | Share of answer |\n| **Content focus** | Keywords | Entities + facts |\n| **Structure** | Headers for scanning | Triples for extraction |\n| **Links** | Backlinks for authority | Citations for consensus |\n| **Updates** | Periodic refresh | Continuous accuracy |\n\n---\n\n## Quick Reference\n\n### Semantic Triple Pattern\n```\n[Entity/Product] [active verb] [concrete object/result].\n```\n\n### Paragraph Pattern\n```\n[Feature] helps [User] with [Job].\nIt [mechanism] to [process].\nTeams see [result] in [timeframe].\n```\n\n### Page Minimums\n- 3-5 internal links\n- 1-2 external citations per section\n- 3 FAQ questions with schema\n- Author + last updated\n- Single CTA\n\n### Content Hierarchy\n1. Category Explainer (top)\n2. Product/Feature pages (middle)\n3. Use case / Comparison / Blog (supporting)\n"
  },
  {
    "path": "skills/agent-teams/SKILL.md",
    "content": "---\nname: agent-teams\ndescription: Claude Code Agent Teams - default team-based development with strict TDD pipeline enforcement\nwhen-to-use: When spawning agent teams for parallel feature development with TDD pipeline\nuser-invocable: false\neffort: high\n---\n\n# Agent Teams Skill\n\n\n**Purpose:** Every project initialized with Maggy runs as a coordinated team of AI agents. This is the default workflow, not optional. Teams enforce a strict TDD pipeline where no step can be skipped.\n\n**Setup:** Agent definitions go in `.claude/agents/` with proper frontmatter (name, description, model, tools, disallowedTools, maxTurns, effort). See agent files for the format.\n\n---\n\n## Core Principle\n\nEvery feature follows an immutable pipeline enforced by task dependencies:\n\n```\n┌─────────────────────────────────────────────────────────────────┐\n│  STRICT FEATURE PIPELINE (IMMUTABLE)                            │\n│  ──────────────────────────────────────────────────────────────  │\n│                                                                  │\n│  1. SPEC        Write feature specification                      │\n│       ↓         (Feature Agent)                                  │\n│  2. REVIEW      Quality Agent reviews spec completeness          │\n│       ↓         (Quality Agent)                                  │\n│  3. TESTS       Write failing tests for all acceptance criteria  │\n│       ↓         (Feature Agent)                                  │\n│  4. RED VERIFY  Quality Agent confirms ALL tests FAIL            │\n│       ↓         (Quality Agent)                                  │\n│  5. IMPLEMENT   Write minimum code to pass tests                 │\n│       ↓         (Feature Agent)                                  │\n│  6. GREEN VERIFY Quality Agent confirms ALL tests PASS + coverage│\n│       ↓         (Quality Agent)                                  │\n│  7. VALIDATE    Lint + type check + full test suite              │\n│       ↓         (Feature Agent)                                  │\n│  8. CODE REVIEW Multi-engine review, block on Critical/High      │\n│       ↓         (Code Review Agent)                              │\n│  9. SECURITY    OWASP scan, secrets detection, dependency audit  │\n│       ↓         (Security Agent)                                 │\n│  10. BRANCH+PR  Create feature branch, stage files, create PR    │\n│                 (Merger Agent)                                    │\n│                                                                  │\n│  No step can be skipped. Task dependencies enforce ordering.     │\n│  Quality Agent verifies RED/GREEN transitions.                   │\n│  Code Review + Security Agents gate the merge path.              │\n│  Merger Agent handles branching and PR creation.                 │\n└─────────────────────────────────────────────────────────────────┘\n```\n\n---\n\n## Default Agent Roster\n\nEvery project spawns 5 permanent agents + N feature agents:\n\n```\n┌─────────────────────────────────────────────────────────────────┐\n│  DEFAULT TEAM ROSTER                                             │\n│  ──────────────────────────────────────────────────────────────  │\n│                                                                  │\n│  PERMANENT AGENTS (always present)                               │\n│  ─────────────────────────────────                               │\n│  Team Lead        Orchestration, task breakdown, assignment      │\n│                   Uses delegate mode - NEVER writes code         │\n│                                                                  │\n│  Quality Agent    TDD verification (RED/GREEN phases)            │\n│                   Coverage gates (>= 80%)                        │\n│                   Spec completeness review                       │\n│                                                                  │\n│  Security Agent   OWASP scanning, secrets detection              │\n│                   Dependency audit, .env validation               │\n│                   Blocks on Critical/High                        │\n│                                                                  │\n│  Code Review Agent  Multi-engine code review                     │\n│                     Claude / Codex / Gemini / All                │\n│                     Blocks on Critical/High                      │\n│                                                                  │\n│  Merger Agent     Creates feature branches                       │\n│                   Stages feature-specific files only              │\n│                   Creates PRs via gh CLI                          │\n│                   NEVER merges - only creates PRs                │\n│                                                                  │\n│  DYNAMIC AGENTS (one per feature)                                │\n│  ────────────────────────────────                                │\n│  Feature Agent    Implements one feature end-to-end              │\n│  (x N features)   Follows strict pipeline above                  │\n│                   Uses Ralph loops for implementation             │\n│                                                                  │\n└─────────────────────────────────────────────────────────────────┘\n```\n\n| Agent | Role | Plan Mode | Can Edit Code |\n|-------|------|-----------|---------------|\n| team-lead | Orchestration, task breakdown, assignment | No (delegate mode) | No |\n| quality-agent | TDD verification, coverage gates | Yes | No (read-only) |\n| security-agent | OWASP scanning, secrets detection | Yes | No (read-only) |\n| review-agent | Multi-engine code review | Yes | No (read-only) |\n| merger-agent | Branch creation, PR management | No | No (git only) |\n| feature-{name} | Feature implementation (one per feature) | No | Yes |\n\n---\n\n## Team Lead Responsibilities\n\nThe Team Lead is the orchestrator. It NEVER writes code.\n\n1. Read `_project_specs/features/*.md` to identify all features\n2. Break each feature into the 10-task dependency chain (see below)\n3. Spawn one feature agent per feature\n4. Assign initial tasks (spec-writing) to feature agents\n5. Monitor TaskList continuously for progress and blockers\n6. Handle blocked tasks and reassignment\n7. Coordinate cross-feature dependencies\n8. Send `shutdown_request` to all agents when all PRs are created\n9. Clean up the team when done\n\n**Delegate mode is mandatory.** The team lead uses only:\n- TeamCreate, TaskCreate, TaskUpdate, TaskList, TaskGet\n- SendMessage (message, broadcast, shutdown_request)\n- Read, Glob, Grep (for monitoring)\n\n---\n\n## Feature Agent Workflow (MANDATORY)\n\nEach feature agent MUST follow this exact sequence. Task dependencies enforce ordering - a feature agent cannot start step N+1 until step N is marked complete and verified.\n\n### Step 1: Write Spec\n- Create `_project_specs/features/{feature-name}.md`\n- Include: description, acceptance criteria, test cases table, dependencies\n- Follow the atomic TODO format from base.md skill\n- Mark task complete -> Quality Agent reviews\n\n### Step 2: Write Tests (RED Phase)\n- Write test files based on spec's test cases table\n- Tests MUST cover ALL acceptance criteria\n- Import modules that don't exist yet (they will fail)\n- Mark task complete -> Quality Agent verifies tests EXIST and FAIL\n\n### Step 3: Wait for RED Verification\n- Quality Agent runs tests and verifies ALL new tests fail\n- If any test passes without implementation -> rewrite tests\n- Quality Agent marks verification complete -> unlocks implementation\n\n### Step 4: Implement (GREEN Phase)\n- Write minimum code to make all tests pass\n- Follow simplicity rules from base.md (20 lines/function, 200 lines/file, 3 params)\n- Use Ralph loops (`/ralph-loop`) for iterative implementation\n- Run tests after implementation - ALL must pass\n- Mark task complete -> Quality Agent verifies tests pass\n\n### Step 5: Wait for GREEN Verification\n- Quality Agent runs full test suite and checks coverage\n- Coverage must be >= 80%\n- If tests fail or coverage insufficient -> fix and re-request\n- Quality Agent marks verification complete -> unlocks validation\n\n### Step 6: Validate\n- Run linter (ESLint / Ruff)\n- Run type checker (TypeScript / mypy)\n- Run full test suite with coverage\n- Fix any issues\n- Mark task complete -> unlocks code review\n\n### Step 7: Wait for Code Review\n- Code Review Agent runs `/code-review` on changed files\n- If Critical or High issues -> fix and re-request review\n- Code Review Agent marks complete -> unlocks security scan\n\n### Step 8: Wait for Security Scan\n- Security Agent runs security checks\n- If Critical or High issues -> fix and re-request scan\n- Security Agent marks complete -> unlocks merge\n\n### Step 9: Wait for Branch + PR\n- Merger Agent creates feature branch, stages files, creates PR\n- Feature is complete when PR is created\n\n---\n\n## Task Dependency Chain Model\n\nFor each feature \"X\", the team lead creates these 10 tasks with strict ordering:\n\n```\n┌────────────────────────────────────────────────────────────────┐\n│  TASK CHAIN FOR FEATURE \"X\"                                     │\n│                                                                  │\n│  Task 1:  X-spec                                                │\n│           owner: feature-X                                       │\n│           blockedBy: (none)                                      │\n│           ↓                                                      │\n│  Task 2:  X-spec-review                                         │\n│           owner: quality-agent                                   │\n│           blockedBy: X-spec                                      │\n│           ↓                                                      │\n│  Task 3:  X-tests                                               │\n│           owner: feature-X                                       │\n│           blockedBy: X-spec-review                               │\n│           ↓                                                      │\n│  Task 4:  X-tests-fail-verify                                   │\n│           owner: quality-agent                                   │\n│           blockedBy: X-tests                                     │\n│           ↓                                                      │\n│  Task 5:  X-implement                                           │\n│           owner: feature-X                                       │\n│           blockedBy: X-tests-fail-verify                         │\n│           ↓                                                      │\n│  Task 6:  X-tests-pass-verify                                   │\n│           owner: quality-agent                                   │\n│           blockedBy: X-implement                                 │\n│           ↓                                                      │\n│  Task 7:  X-validate                                            │\n│           owner: feature-X                                       │\n│           blockedBy: X-tests-pass-verify                         │\n│           ↓                                                      │\n│  Task 8:  X-code-review                                         │\n│           owner: review-agent                                    │\n│           blockedBy: X-validate                                  │\n│           ↓                                                      │\n│  Task 9:  X-security-scan                                       │\n│           owner: security-agent                                  │\n│           blockedBy: X-code-review                               │\n│           ↓                                                      │\n│  Task 10: X-branch-pr                                           │\n│           owner: merger-agent                                    │\n│           blockedBy: X-security-scan                             │\n└────────────────────────────────────────────────────────────────┘\n```\n\n### Parallel Feature Execution\n\nMultiple features run their chains in parallel. Shared agents process tasks as they unblock:\n\n```\nFeature: auth         Feature: dashboard      Feature: payments\n  auth-spec             dash-spec               pay-spec\n  auth-spec-review      dash-spec-review        pay-spec-review\n  auth-tests            dash-tests              pay-tests\n  auth-fail-verify      dash-fail-verify        pay-fail-verify\n  auth-implement        dash-implement          pay-implement\n  auth-pass-verify      dash-pass-verify        pay-pass-verify\n  auth-validate         dash-validate           pay-validate\n  auth-code-review      dash-code-review        pay-code-review\n  auth-security         dash-security           pay-security\n  auth-branch-pr        dash-branch-pr          pay-branch-pr\n       |                     |                       |\n       v                     v                       v\n   [All chains run simultaneously]\n   [Quality Agent handles all verify tasks as they unblock]\n   [Review Agent handles all review tasks as they unblock]\n   [Security Agent handles all scan tasks as they unblock]\n   [Merger Agent handles all branch-pr tasks as they unblock]\n```\n\n---\n\n## Inter-Agent Communication\n\n### Direct Messages (for targeted work)\n```\nFeature Agent -> Quality Agent:  \"Tests written for auth, ready for RED verify\"\nQuality Agent -> Feature Agent:  \"All 7 tests fail as expected. Proceed to implement\"\nFeature Agent -> Review Agent:   \"Implementation complete, ready for code review\"\nReview Agent  -> Feature Agent:  \"2 High issues found: [details]. Fix before proceeding\"\nSecurity Agent -> Merger Agent:  \"Security scan passed for auth feature\"\nMerger Agent  -> Team Lead:      \"PR #42 created for auth feature\"\n```\n\n### Task List (source of truth for state)\n- All agents check TaskList after completing work\n- Quality Agent claims verification tasks automatically\n- Review Agent claims code-review tasks automatically\n- Security Agent claims security-scan tasks automatically\n- Merger Agent claims branch-pr tasks automatically\n\n### Broadcast (rare - blocking issues only)\n- Team Lead -> All: \"Blocking dependency found between auth and dashboard\"\n- Security Agent -> All: \"Critical vulnerability in shared dependency\"\n\n---\n\n## Feature Agent Spawning\n\nThe team lead spawns one feature agent per feature:\n\n1. Read `_project_specs/features/*.md`\n2. For each feature spec, spawn a feature agent:\n   - name: `feature-{feature-name}`\n   - Uses `.claude/agents/feature.md` definition\n   - Spawn prompt includes the feature name and spec location\n3. Create the full 10-task dependency chain for that feature\n4. Assign the spec-writing task to the feature agent\n\n### Example\nIf project has 3 features: auth, dashboard, payments\n- Spawn: `feature-auth`, `feature-dashboard`, `feature-payments`\n- Create 30 tasks total (10 per feature)\n- Each feature agent starts with their spec task\n- All 3 work in parallel\n\n---\n\n## Branch and PR Strategy\n\n**One branch per feature. One PR per feature.**\n\n```\nBranch naming:  feature/{feature-name}\nPR title:       feat({feature-name}): {short description}\nPR body:        Generated from spec + test results + review + security results\n```\n\nThe Merger Agent:\n1. `git checkout main && git pull origin main`\n2. `git checkout -b feature/{feature-name}`\n3. Stages ONLY files changed for this feature (never `git add -A`)\n4. Commits with descriptive message including verification results\n5. `git push -u origin feature/{feature-name}`\n6. `gh pr create` with full template including:\n   - Summary from feature spec\n   - Test results from quality verification\n   - Code review summary from review agent\n   - Security scan results from security agent\n   - Checklist of all pipeline steps completed\n\n---\n\n## Quality Gates\n\n### Workflow Enforcement (via task dependencies)\n- Task dependencies make it **structurally impossible** to skip steps\n- A feature agent cannot see \"implement\" until quality agent completes \"tests-fail-verify\"\n- This is the primary enforcement mechanism\n\n### Cross-Agent Verification (trust but verify)\n- Quality agent independently runs tests (doesn't trust feature agent's report)\n- Security agent independently scans (doesn't trust review agent)\n- Merger agent verifies all predecessor tasks are complete before branching\n\n### Blocking Rules\n- Quality Agent: blocks if tests don't fail (RED) or don't pass (GREEN) or coverage < 80%\n- Code Review Agent: blocks on Critical or High severity issues\n- Security Agent: blocks on Critical or High severity findings\n- Merger Agent: refuses to branch if any predecessor task is incomplete\n\n---\n\n## Integration with Existing Skills\n\n| Existing Skill | How Agent Teams Uses It |\n|----------------|------------------------|\n| base.md | TDD workflow, atomic todos, simplicity rules - all agents follow |\n| code-review.md | Review Agent executes `/code-review` per this skill |\n| security.md | Security Agent follows OWASP patterns from this skill |\n| session-management.md | Each agent maintains its own session state |\n| iterative-development.md | Feature agents use Stop hook TDD loops for implementation |\n| project-tooling.md | Merger Agent uses `gh` CLI for branches and PRs |\n| team-coordination.md | Superseded by agent-teams for automated coordination |\n| **icpg.md** | **Team lead creates ReasonNodes. Feature agents query constraints/risk. Quality agent checks drift. PreToolUse hook injects context. Stop hook auto-records symbols.** |\n| code-graph.md | Feature agents use graph for symbol lookup alongside iCPG for intent context |\n\n---\n\n## Environment Setup\n\n### Required Setting\n```json\n// settings.json or environment\n{\n  \"env\": {\n    \"agent teams (via .claude/agents/ definitions)\": \"1\"\n  }\n}\n```\n\n### Project Structure (created by /initialize-project)\n```\n.claude/\n  agents/            # Agent definitions (from agent-teams skill)\n    team-lead.md\n    quality.md\n    security.md\n    code-review.md\n    merger.md\n    feature.md\n  skills/\n    agent-teams/     # This skill\n      SKILL.md\n      agents/        # Agent definition templates\n    base/\n    code-review/\n    security/\n    ...\n```\n\n---\n\n## Spawning the Team\n\n### Automatic (via /initialize-project)\nAfter project setup completes, Phase 6 asks for features and spawns the team automatically.\n\n### Manual (via /spawn-team)\nFor existing projects: run `/spawn-team` to spawn the team from existing feature specs.\n\n---\n\n## Container Isolation (Polyphony)\n\nWhen Docker/OrbStack is available, feature agents run in Polyphony containers by default. The team lead and shared agents (quality, security, review, merger) still run natively — they only read and coordinate.\n\n### What changes with Polyphony\n\n| Aspect | Without Polyphony | With Polyphony |\n|--------|-------------------|----------------|\n| Feature agents | Shared filesystem | Own container + git branch |\n| File conflicts | Team lead must serialize | Impossible (isolated clones) |\n| Test execution | Shared, can interfere | Independent per container |\n| Branch strategy | Merger agent creates branches | Each container has its own branch |\n\n### How it works\n\n1. `/spawn-team` detects Docker + polyphony CLI\n2. For each feature, runs `polyphony spawn \"$FEATURE\" --type feature`\n3. Polyphony creates a container with its own git clone + branch\n4. Agent CLI starts inside the container\n5. On completion, changes are on a dedicated branch ready for PR\n\n### Fallback\n\nIf Docker is not available, `/spawn-team` falls back to the native Agent tool (shared filesystem). A note is printed:\n> \"Running without container isolation (Docker not found). Agents share the workspace.\"\n\n---\n\n## Limitations\n\n- **Experimental feature** - Agent teams require the experimental env var\n- **No nested teams** - Teammates cannot spawn sub-teams\n- **One team per session** - Clean up before starting a new team\n- **No session resumption** - If session dies, re-run `/spawn-team` (tasks persist)\n- **File conflicts** - Features sharing files must be serialized by team lead (unless using Polyphony containers)\n- **Token cost** - Each agent is a separate Claude instance (5 + N instances)\n"
  },
  {
    "path": "skills/agent-teams/agents/code-review.md",
    "content": "---\nname: review-agent\ndescription: Performs code reviews on completed features - checks security, performance, architecture, code quality. Blocks on Critical/High.\nmodel: sonnet\ntools: [Read, Glob, Grep, Bash, TaskUpdate, TaskList, TaskGet, SendMessage]\ndisallowedTools: [Write, Edit]\nmaxTurns: 20\neffort: high\n---\n\n# Code Review Agent\n\nYou perform code reviews on completed features.\n\n## Review Protocol\n\nFor each `{name}-code-review` task:\n\n1. Identify changed files via `git diff main --name-only`\n2. Review for: security vulnerabilities, performance issues (N+1, memory leaks), architecture problems (coupling, SOLID), code quality (simplicity rules, DRY, dead code), test quality (behavior tests, edge cases, isolation)\n3. Categorize findings by severity (Critical/High/Medium/Low)\n\n## Blocking Rules\n\nIf Critical or High issues found:\n1. Message feature agent with file:line, description, and suggested fix\n2. Do NOT mark complete\n3. Wait for fixes, then re-review\n\nIf only Medium/Low: mark complete, message security-agent.\n\n## Rules\n\n- Read-only: review code, do NOT fix it\n- Block on Critical and High, no exceptions\n- Process tasks in order (lowest task ID first)\n"
  },
  {
    "path": "skills/agent-teams/agents/feature.md",
    "content": "---\nname: feature-agent\ndescription: Implements one feature end-to-end following the strict TDD pipeline - spec, tests, implementation, validation.\nmodel: inherit\ntools: [Read, Write, Edit, Bash, Glob, Grep, TaskUpdate, TaskList, TaskGet, SendMessage]\nmaxTurns: 40\neffort: high\n---\n\n# Feature Agent\n\nYou implement one specific feature following the strict TDD pipeline.\n\n## Your Steps (enforced by task dependencies)\n\n1. **SPEC** — Write `_project_specs/features/{name}.md` with description, acceptance criteria, test cases table, dependencies\n2. *Wait for quality-agent spec review*\n3. **TESTS (RED)** — Write test files covering ALL acceptance criteria. Tests MUST fail.\n4. *Wait for quality-agent RED verification*\n5. **PRE-IMPLEMENT** — Before coding:\n   - Run `icpg query constraints <scope-files>` to understand invariants\n   - Run `icpg query risk <key-symbol>` for fragile symbols\n   - Write feature name to `.icpg/.current-intent` (enables auto-recording)\n6. **IMPLEMENT (GREEN)** — Write minimum code to pass all tests. Follow simplicity rules (20 lines/function, 200 lines/file, 3 params max). PreToolUse hook auto-injects intent context before every edit.\n7. **POST-IMPLEMENT** — After tests pass:\n   - Run `icpg record --reason <intent-id> --base main` (or auto via Stop hook)\n   - Run `icpg drift check` to verify no unintended scope drift\n8. *Wait for quality-agent GREEN verification*\n9. **VALIDATE** — Run linter, type checker, full test suite with coverage.\n10. *Wait for code review and security scan*\n\n## Rules\n\n- Always write tests before implementation (TDD is mandatory)\n- Always check constraints and risk before implementing (iCPG is mandatory)\n- Follow simplicity rules from project CLAUDE.md\n- If blocked by environment issues (DB down, missing API key), message team-lead\n- Mark tasks complete only when the work is actually done\n- Process tasks in order following the pipeline\n"
  },
  {
    "path": "skills/agent-teams/agents/merger.md",
    "content": "---\nname: merger-agent\ndescription: Creates feature branches and PRs for completed features via gh CLI. Never merges - only creates PRs.\nmodel: sonnet\ntools: [Read, Glob, Grep, Bash, TaskUpdate, TaskList, TaskGet, SendMessage]\ndisallowedTools: [Write, Edit]\nmaxTurns: 15\neffort: medium\n---\n\n# Merger Agent\n\nYou handle git branching and PR creation. You NEVER merge - you only create PRs.\n\n## Protocol\n\nFor each `{name}-branch-pr` task:\n\n1. `git checkout main && git pull origin main`\n2. `git checkout -b feature/{feature-name}`\n3. Stage ONLY files related to this feature (never `git add -A`)\n4. Commit with: `feat({feature-name}): {description}`\n5. `git push -u origin feature/{feature-name}`\n6. `gh pr create` with summary, test results, review results, security results, pipeline checklist\n7. `git checkout main`\n8. Message team-lead with PR URL\n\n## Gathering Results\n\nBefore creating PR, use TaskGet to read predecessor tasks for:\n- Test count and coverage from `{name}-tests-pass-verify`\n- Review summary from `{name}-code-review`\n- Security summary from `{name}-security-scan`\n\n## Rules\n\n- Never merge PRs, only create them\n- Never force push\n- Never use `git add -A` or `git add .`\n- One branch per feature, one PR per feature\n- Process tasks in order (lowest task ID first)\n"
  },
  {
    "path": "skills/agent-teams/agents/quality.md",
    "content": "---\nname: quality-agent\ndescription: Enforces TDD discipline - verifies specs are complete, tests fail before implementation, tests pass after implementation, coverage >= 80%\nmodel: sonnet\ntools: [Read, Glob, Grep, Bash, TaskUpdate, TaskList, TaskGet, SendMessage]\ndisallowedTools: [Write, Edit]\nmaxTurns: 30\neffort: high\n---\n\n# Quality Agent\n\nYou enforce TDD discipline. You verify that specs are complete, tests fail before implementation, and tests pass after implementation. You are read-only for source code.\n\n## Verification Protocols\n\n### Spec Review (`{name}-spec-review`)\n\nRead `_project_specs/features/{name}.md` and verify:\n- Has clear description\n- Has numbered acceptance criteria\n- Has test cases table (Test, Input, Expected Output)\n- Has dependencies listed\n- Criteria are testable, not vague\n\nIf incomplete: message feature agent with what's missing. Do NOT mark complete.\n\n### RED Phase (`{name}-tests-fail-verify`)\n\n1. Run the project's test command\n2. ALL new tests must FAIL (not error from imports — actual test failures)\n3. Every spec test case must have a corresponding test\n\nIf tests pass: message feature agent to rewrite tests.\nIf tests fail: mark complete, message feature agent to proceed.\n\n### GREEN Phase (`{name}-tests-pass-verify`)\n\n1. Run full test suite (not just new tests)\n2. ALL tests must pass\n3. Coverage >= 80%\n4. **iCPG drift check**: Run `icpg drift check` to verify no unintended scope drift\n\nIf tests fail or coverage insufficient: message feature agent with details.\nIf drift detected: message feature agent with drift dimensions and severity.\nIf all pass and no drift: mark complete, message feature agent to proceed.\n\n### Spec-Intent Alignment (`{name}-spec-review`)\n\nDuring spec review, also verify:\n- The feature's ReasonNode exists in iCPG (`icpg query context` on scope files)\n- Scope in spec matches scope in ReasonNode\n- No DUPLICATES edges flagged for this intent\n\n## Rules\n\n- You are read-only: run tests and icpg queries, do NOT fix code\n- Mark tasks complete only when verification passes\n- Process tasks in order (lowest task ID first)\n- Report drift events with specific dimensions and severity\n"
  },
  {
    "path": "skills/agent-teams/agents/security.md",
    "content": "---\nname: security-agent\ndescription: Performs security analysis on completed features - OWASP scanning, secrets detection, dependency audit. Blocks on Critical/High.\nmodel: sonnet\ntools: [Read, Glob, Grep, Bash, TaskUpdate, TaskList, TaskGet, SendMessage]\ndisallowedTools: [Write, Edit]\nmaxTurns: 20\neffort: high\n---\n\n# Security Agent\n\nYou perform security analysis on completed features before they can be merged.\n\n## Security Scan Protocol\n\nFor each `{name}-security-scan` task:\n\n### 1. Identify Changed Files\nUse `git diff main --name-only` to identify feature files.\n\n### 2. Secrets Detection\nCheck for: hardcoded API keys (sk-, pk_, api_key, secret), passwords, tokens, connection strings with credentials, .env committed to git.\n\n### 3. OWASP Top 10\nCheck for: SQL injection (raw queries with string interpolation), XSS (innerHTML with user input), broken auth (missing auth on protected routes), insecure crypto (MD5/SHA1 for passwords), SSRF (user-controlled URLs), path traversal, mass assignment, missing rate limits on auth.\n\n### 4. Dependency Audit\nRun `npm audit` or `safety check`. Flag known vulnerabilities.\n\n### 5. Environment Variables\nVerify no secrets in VITE_*, NEXT_PUBLIC_*, REACT_APP_* vars.\n\n## Severity and Blocking\n\n| Severity | Action |\n|----------|--------|\n| Critical | Block merge. Must fix. |\n| High | Block merge. Should fix. |\n| Medium | Advisory. Can merge. |\n| Low | Informational. |\n\nIf Critical/High found: message feature agent with file:line references and fix suggestions. Do NOT mark complete.\nIf clean: mark complete, message merger-agent.\n\n## Rules\n\n- Read-only: scan code, do NOT fix it\n- Block on Critical and High, no exceptions\n- Process tasks in order (lowest task ID first)\n"
  },
  {
    "path": "skills/agent-teams/agents/team-lead.md",
    "content": "---\nname: team-lead\ndescription: Orchestrates the agent team - creates tasks, spawns feature agents, monitors progress. Never writes code.\nmodel: sonnet\ntools: [Read, Glob, Grep, TaskCreate, TaskUpdate, TaskList, TaskGet, SendMessage, TeamCreate]\ndisallowedTools: [Write, Edit, Bash]\nmaxTurns: 50\neffort: high\n---\n\n# Team Lead Agent\n\nYou orchestrate work. You do NOT implement.\n\n## Responsibilities\n\n1. Read `_project_specs/features/*.md` to identify all features\n2. **iCPG: Check for duplicates** — run `icpg query prior \"<feature goal>\"` before creating tasks. If >0.75 similarity, warn user.\n3. **iCPG: Create ReasonNode** — for each feature, run `icpg create \"<goal>\" --scope <files> --owner feature-{name} --type task`\n4. For each feature, create the full 10-task dependency chain\n5. Spawn one feature agent per feature\n6. Assign initial tasks (spec-writing) to feature agents\n7. Monitor TaskList continuously for progress and blockers\n8. Handle blocked tasks and reassign if needed\n9. Coordinate cross-feature dependencies (serialize features sharing files)\n10. When all PRs are created, send `shutdown_request` to all agents\n\n## Task Chain Template (per feature)\n\nFor each feature `{name}`, create these tasks with `addBlockedBy` dependencies:\n\n1. `{name}-spec` — owner: feature-{name}\n2. `{name}-spec-review` — owner: quality-agent, blockedBy: [1]\n3. `{name}-tests` — owner: feature-{name}, blockedBy: [2]\n4. `{name}-tests-fail-verify` — owner: quality-agent, blockedBy: [3]\n5. `{name}-implement` — owner: feature-{name}, blockedBy: [4]\n6. `{name}-tests-pass-verify` — owner: quality-agent, blockedBy: [5]\n7. `{name}-validate` — owner: feature-{name}, blockedBy: [6]\n8. `{name}-code-review` — owner: review-agent, blockedBy: [7]\n9. `{name}-security-scan` — owner: security-agent, blockedBy: [8]\n10. `{name}-branch-pr` — owner: merger-agent, blockedBy: [9]\n\n## Cross-Feature Dependencies\n\nIf two features share files:\n1. Add `addBlockedBy` from the second feature's implement task to the first feature's branch-pr task\n2. Message both feature agents about the serialization\n\n## Completion Protocol\n\nWhen all `{name}-branch-pr` tasks are completed:\n1. Verify all PRs created via `gh pr list`\n2. Send broadcast: \"All features complete. Shutting down team.\"\n3. Send `shutdown_request` to each agent\n"
  },
  {
    "path": "skills/agentic-development/SKILL.md",
    "content": "---\nname: agentic-development\ndescription: Build AI agents with Pydantic AI (Python) and Claude SDK (Node.js)\nwhen-to-use: When building AI agents, tool-using LLM systems, or agentic workflows\nuser-invocable: false\neffort: high\n---\n\n# Agentic Development Skill\n\n\nFor building autonomous AI agents that perform multi-step tasks with tools.\n\n**Sources:** [Claude Agent SDK](https://docs.anthropic.com/en/docs/agents-and-tools/claude-agent-sdk) | [Anthropic Claude Code Best Practices](https://www.anthropic.com/engineering/claude-code-best-practices) | [Pydantic AI](https://ai.pydantic.dev/) | [Google Gemini Agent Development](https://developers.googleblog.com/en/building-agents-google-gemini-open-source-frameworks/) | [OpenAI Building Agents](https://developers.openai.com/tracks/building-agents/)\n\n---\n\n## Framework Selection by Language\n\n| Language/Framework | Default | Why |\n|-------------------|---------|-----|\n| **Python** | **Pydantic AI** | Type-safe, Pydantic validation, multi-model, production-ready |\n| **Node.js / Next.js** | **Claude Agent SDK** | Official Anthropic SDK, tools, multi-agent, native streaming |\n\n### Python: Pydantic AI (Default)\n```python\nfrom pydantic_ai import Agent\nfrom pydantic import BaseModel\n\nclass SearchResult(BaseModel):\n    title: str\n    url: str\n    summary: str\n\nagent = Agent(\n    'claude-sonnet-4-20250514',\n    result_type=list[SearchResult],\n    system_prompt='You are a research assistant.',\n)\n\n# Type-safe result\nresult = await agent.run('Find articles about AI agents')\nfor item in result.data:\n    print(f\"{item.title}: {item.url}\")\n```\n\n### Node.js / Next.js: Claude Agent SDK (Default)\n```typescript\nimport Anthropic from \"@anthropic-ai/sdk\";\n\nconst client = new Anthropic();\n\n// Define tools\nconst tools: Anthropic.Tool[] = [\n  {\n    name: \"web_search\",\n    description: \"Search the web for information\",\n    input_schema: {\n      type: \"object\",\n      properties: {\n        query: { type: \"string\", description: \"Search query\" },\n      },\n      required: [\"query\"],\n    },\n  },\n];\n\n// Agentic loop\nasync function runAgent(prompt: string) {\n  const messages: Anthropic.MessageParam[] = [\n    { role: \"user\", content: prompt },\n  ];\n\n  while (true) {\n    const response = await client.messages.create({\n      model: \"claude-sonnet-4-20250514\",\n      max_tokens: 4096,\n      tools,\n      messages,\n    });\n\n    // Check for tool use\n    if (response.stop_reason === \"tool_use\") {\n      const toolUse = response.content.find((b) => b.type === \"tool_use\");\n      if (toolUse) {\n        const result = await executeTool(toolUse.name, toolUse.input);\n        messages.push({ role: \"assistant\", content: response.content });\n        messages.push({\n          role: \"user\",\n          content: [{ type: \"tool_result\", tool_use_id: toolUse.id, content: result }],\n        });\n        continue;\n      }\n    }\n\n    // Done - return final response\n    return response.content.find((b) => b.type === \"text\")?.text;\n  }\n}\n```\n\n---\n\n## Core Principle\n\n**Plan first, act incrementally, verify always.**\n\nAgents that research and plan before executing consistently outperform those that jump straight to action. Break complex tasks into verifiable steps, use tools judiciously, and maintain clear state throughout execution.\n\n---\n\n## Agent Architecture\n\n### Three Components (OpenAI)\n```\n┌─────────────────────────────────────────────────┐\n│                    AGENT                        │\n├─────────────────────────────────────────────────┤\n│  Model (Brain)      │ LLM for reasoning &       │\n│                     │ decision-making           │\n├─────────────────────┼───────────────────────────┤\n│  Tools (Arms/Legs)  │ APIs, functions, external │\n│                     │ systems for action        │\n├─────────────────────┼───────────────────────────┤\n│  Instructions       │ System prompts defining   │\n│  (Rules)            │ behavior & boundaries     │\n└─────────────────────┴───────────────────────────┘\n```\n\n### Project Structure\n```\nproject/\n├── src/\n│   ├── agents/\n│   │   ├── orchestrator.ts    # Main agent coordinator\n│   │   ├── specialized/       # Task-specific agents\n│   │   │   ├── researcher.ts\n│   │   │   ├── coder.ts\n│   │   │   └── reviewer.ts\n│   │   └── base.ts            # Shared agent interface\n│   ├── tools/\n│   │   ├── definitions/       # Tool schemas\n│   │   ├── implementations/   # Tool logic\n│   │   └── registry.ts        # Tool discovery\n│   ├── prompts/\n│   │   ├── system/            # Agent instructions\n│   │   └── templates/         # Task templates\n│   └── memory/\n│       ├── conversation.ts    # Short-term context\n│       └── persistent.ts      # Long-term storage\n├── tests/\n│   ├── agents/                # Agent behavior tests\n│   ├── tools/                 # Tool unit tests\n│   └── evals/                 # End-to-end evaluations\n└── skills/                    # Agent skills (Anthropic pattern)\n    ├── skill-name/\n    │   ├── instructions.md\n    │   ├── scripts/\n    │   └── resources/\n```\n\n---\n\n## Workflow Pattern: Explore-Plan-Execute-Verify\n\n### 1. Explore Phase\n```typescript\n// Gather context before acting\nasync function explore(task: Task): Promise<Context> {\n  const relevantFiles = await agent.searchCodebase(task.query);\n  const existingPatterns = await agent.analyzePatterns(relevantFiles);\n  const dependencies = await agent.identifyDependencies(task);\n\n  return { relevantFiles, existingPatterns, dependencies };\n}\n```\n\n### 2. Plan Phase (Critical)\n```typescript\n// Plan explicitly before execution\nasync function plan(task: Task, context: Context): Promise<Plan> {\n  const prompt = `\n    Task: ${task.description}\n    Context: ${JSON.stringify(context)}\n\n    Create a step-by-step plan. For each step:\n    1. What action to take\n    2. What tools to use\n    3. How to verify success\n    4. What could go wrong\n\n    Output JSON with steps array.\n  `;\n\n  return await llmCall({ prompt, schema: PlanSchema });\n}\n```\n\n### 3. Execute Phase\n```typescript\n// Execute with verification at each step\nasync function execute(plan: Plan): Promise<Result[]> {\n  const results: Result[] = [];\n\n  for (const step of plan.steps) {\n    // Execute single step\n    const result = await executeStep(step);\n\n    // Verify before continuing\n    if (!await verify(step, result)) {\n      // Self-correct or escalate\n      const corrected = await selfCorrect(step, result);\n      if (!corrected.success) {\n        return handleFailure(step, results);\n      }\n    }\n\n    results.push(result);\n  }\n\n  return results;\n}\n```\n\n### 4. Verify Phase\n```typescript\n// Independent verification prevents overfitting\nasync function verify(step: Step, result: Result): Promise<boolean> {\n  // Run tests if available\n  if (step.testCommand) {\n    const testResult = await runCommand(step.testCommand);\n    if (!testResult.success) return false;\n  }\n\n  // Use LLM to verify against criteria\n  const verification = await llmCall({\n    prompt: `\n      Step: ${step.description}\n      Expected: ${step.successCriteria}\n      Actual: ${JSON.stringify(result)}\n\n      Does the result satisfy the success criteria?\n      Respond with { \"passes\": boolean, \"reasoning\": string }\n    `,\n    schema: VerificationSchema\n  });\n\n  return verification.passes;\n}\n```\n\n---\n\n## Tool Design\n\n### Tool Definition Pattern\n```typescript\n// tools/definitions/file-operations.ts\nimport { z } from 'zod';\n\nexport const ReadFileTool = {\n  name: 'read_file',\n  description: 'Read contents of a file. Use before modifying any file.',\n  parameters: z.object({\n    path: z.string().describe('Absolute path to the file'),\n    startLine: z.number().optional().describe('Start line (1-indexed)'),\n    endLine: z.number().optional().describe('End line (1-indexed)'),\n  }),\n  // Risk level for guardrails (OpenAI pattern)\n  riskLevel: 'low' as const,\n};\n\nexport const WriteFileTool = {\n  name: 'write_file',\n  description: 'Write content to a file. Always read first to understand context.',\n  parameters: z.object({\n    path: z.string().describe('Absolute path to the file'),\n    content: z.string().describe('Complete file content'),\n  }),\n  riskLevel: 'medium' as const,\n  // Require confirmation for high-risk operations\n  requiresConfirmation: true,\n};\n```\n\n### Tool Implementation\n```typescript\n// tools/implementations/file-operations.ts\nexport async function readFile(\n  params: z.infer<typeof ReadFileTool.parameters>\n): Promise<ToolResult> {\n  try {\n    const content = await fs.readFile(params.path, 'utf-8');\n    const lines = content.split('\\n');\n\n    const start = (params.startLine ?? 1) - 1;\n    const end = params.endLine ?? lines.length;\n\n    return {\n      success: true,\n      data: lines.slice(start, end).join('\\n'),\n      metadata: { totalLines: lines.length }\n    };\n  } catch (error) {\n    return {\n      success: false,\n      error: `Failed to read file: ${error.message}`\n    };\n  }\n}\n```\n\n### Prefer Built-in Tools (OpenAI)\n```typescript\n// Use platform-provided tools when available\nconst agent = createAgent({\n  tools: [\n    // Built-in tools (handled by platform)\n    { type: 'web_search' },\n    { type: 'code_interpreter' },\n\n    // Custom tools only when needed\n    { type: 'function', function: customDatabaseTool },\n  ],\n});\n```\n\n---\n\n## Multi-Agent Patterns\n\n### Single Agent (Default)\nUse one agent for most tasks. Multiple agents add complexity.\n\n### Agent-as-Tool Pattern (OpenAI)\n```typescript\n// Expose specialized agents as callable tools\nconst researchAgent = createAgent({\n  name: 'researcher',\n  instructions: 'You research topics and return structured findings.',\n  tools: [webSearchTool, documentReadTool],\n});\n\nconst mainAgent = createAgent({\n  tools: [\n    {\n      type: 'function',\n      function: {\n        name: 'research_topic',\n        description: 'Delegate research to specialized agent',\n        parameters: ResearchQuerySchema,\n        handler: async (query) => researchAgent.run(query),\n      },\n    },\n  ],\n});\n```\n\n### Handoff Pattern (OpenAI)\n```typescript\n// One-way transfer between agents\nconst customerServiceAgent = createAgent({\n  tools: [\n    // Handoff to specialist when needed\n    {\n      name: 'transfer_to_billing',\n      description: 'Transfer to billing specialist for payment issues',\n      handler: async (context) => {\n        return { handoff: 'billing_agent', context };\n      },\n    },\n  ],\n});\n```\n\n### When to Use Multiple Agents\n- Separate task domains with non-overlapping tools\n- Different authorization levels needed\n- Complex workflows with clear handoff points\n- Parallel execution of independent subtasks\n\n---\n\n## Memory & State\n\n### Conversation Memory\n```typescript\n// memory/conversation.ts\ninterface ConversationMemory {\n  messages: Message[];\n  maxTokens: number;\n\n  add(message: Message): void;\n  getContext(): Message[];\n  summarize(): Promise<string>;\n}\n\n// Maintain state across tool calls (Gemini pattern)\ninterface AgentState {\n  thoughtSignature?: string;  // Encrypted reasoning state\n  conversationId: string;     // For shared memory\n  currentPlan?: Plan;\n  completedSteps: Step[];\n}\n```\n\n### Persistent Memory\n```typescript\n// memory/persistent.ts\ninterface PersistentMemory {\n  // Store learnings across sessions\n  store(key: string, value: any): Promise<void>;\n  retrieve(key: string): Promise<any>;\n\n  // Semantic search over past interactions\n  search(query: string, limit: number): Promise<Memory[]>;\n}\n```\n\n---\n\n## Guardrails & Safety\n\n### Multi-Layer Protection (OpenAI)\n```typescript\n// guards/index.ts\ninterface GuardrailConfig {\n  // Input validation\n  inputClassifier: (input: string) => Promise<SafetyResult>;\n\n  // Output validation\n  outputValidator: (output: string) => Promise<SafetyResult>;\n\n  // Tool risk assessment\n  toolRiskLevels: Record<string, 'low' | 'medium' | 'high'>;\n\n  // Actions requiring human approval\n  humanInTheLoop: string[];\n}\n\nasync function executeWithGuardrails(\n  agent: Agent,\n  input: string,\n  config: GuardrailConfig\n): Promise<Result> {\n  // 1. Check input safety\n  const inputCheck = await config.inputClassifier(input);\n  if (!inputCheck.safe) {\n    return { blocked: true, reason: inputCheck.reason };\n  }\n\n  // 2. Execute with tool monitoring\n  const result = await agent.run(input, {\n    beforeTool: async (tool, params) => {\n      const risk = config.toolRiskLevels[tool.name];\n      if (risk === 'high' || config.humanInTheLoop.includes(tool.name)) {\n        return await requestHumanApproval(tool, params);\n      }\n      return { approved: true };\n    },\n  });\n\n  // 3. Validate output\n  const outputCheck = await config.outputValidator(result.output);\n  if (!outputCheck.safe) {\n    return { blocked: true, reason: outputCheck.reason };\n  }\n\n  return result;\n}\n```\n\n### Scope Enforcement (OpenAI)\n```typescript\n// Agent must stay within defined scope\nconst agentInstructions = `\nYou are a customer service agent for Acme Corp.\n\nSCOPE BOUNDARIES (non-negotiable):\n- Only answer questions about Acme products and services\n- Never provide legal, medical, or financial advice\n- Never access or modify data outside your authorized scope\n- If a request is out of scope, politely decline and explain why\n\nIf you cannot complete a task within scope, notify the user\nand request explicit approval before proceeding.\n`;\n```\n\n---\n\n## Model Selection\n\n### Match Model to Task\n| Task Complexity | Recommended Model | Notes |\n|-----------------|-------------------|-------|\n| Simple, fast | gpt-5-mini, claude-haiku | Low latency |\n| General purpose | gpt-4.1, claude-sonnet | Balance |\n| Complex reasoning | o4-mini, claude-opus | Higher accuracy |\n| Deep planning | gpt-5 + reasoning, ultrathink | Maximum capability |\n\n### Gemini-Specific\n```typescript\n// Use thinking_level for reasoning depth\nconst response = await gemini.generate({\n  model: 'gemini-3',\n  thinking_level: 'high',  // For complex planning\n  temperature: 1.0,        // Optimized for reasoning engine\n});\n\n// Preserve thought state across tool calls\nconst nextResponse = await gemini.generate({\n  thoughtSignature: response.thoughtSignature,  // Required for function calling\n  // ... rest of params\n});\n```\n\n### Claude-Specific (Thinking Modes)\n```typescript\n// Trigger extended thinking with keywords\nconst thinkingLevels = {\n  'think': 'standard analysis',\n  'think hard': 'deeper reasoning',\n  'think harder': 'extensive analysis',\n  'ultrathink': 'maximum reasoning budget',\n};\n\nconst prompt = `\nThink hard about this problem before proposing a solution.\n\nTask: ${task.description}\n`;\n```\n\n---\n\n## Testing Agents\n\n### Unit Tests (Tools)\n```typescript\ndescribe('readFile tool', () => {\n  it('reads file content correctly', async () => {\n    const result = await readFile({ path: '/test/file.txt' });\n    expect(result.success).toBe(true);\n    expect(result.data).toContain('expected content');\n  });\n});\n```\n\n### Behavior Tests (Agent Decisions)\n```typescript\ndescribe('agent planning', () => {\n  it('creates plan before executing file modifications', async () => {\n    const trace = await agent.runWithTrace('Refactor the auth module');\n\n    // Verify planning happened first\n    const firstToolCall = trace.toolCalls[0];\n    expect(firstToolCall.name).toBe('read_file');\n\n    // Verify no writes without reads\n    const writeIndex = trace.toolCalls.findIndex(t => t.name === 'write_file');\n    const readIndex = trace.toolCalls.findIndex(t => t.name === 'read_file');\n    expect(readIndex).toBeLessThan(writeIndex);\n  });\n});\n```\n\n### Evaluation Tests\n```typescript\n// Run nightly, not in regular CI\ndescribe('Agent Accuracy (Eval)', () => {\n  const testCases = loadTestCases('./evals/coding-tasks.json');\n\n  it.each(testCases)('completes $name correctly', async (testCase) => {\n    const result = await agent.run(testCase.input);\n\n    // Verify against expected outcomes\n    expect(result.filesModified).toEqual(testCase.expectedFiles);\n    expect(await runTests(testCase.testCommand)).toBe(true);\n  }, 120000);\n});\n```\n\n---\n\n## Pydantic AI Patterns (Python Default)\n\n### Project Structure (Python)\n```\nproject/\n├── src/\n│   ├── agents/\n│   │   ├── __init__.py\n│   │   ├── researcher.py       # Research agent\n│   │   ├── coder.py            # Coding agent\n│   │   └── orchestrator.py     # Main coordinator\n│   ├── tools/\n│   │   ├── __init__.py\n│   │   ├── web.py              # Web search tools\n│   │   ├── files.py            # File operations\n│   │   └── database.py         # DB queries\n│   ├── models/\n│   │   ├── __init__.py\n│   │   └── schemas.py          # Pydantic models\n│   └── deps.py                 # Dependencies\n├── tests/\n│   ├── test_agents.py\n│   └── test_tools.py\n└── pyproject.toml\n```\n\n### Agent with Tools\n```python\nfrom pydantic_ai import Agent, RunContext\nfrom pydantic import BaseModel\nfrom httpx import AsyncClient\n\nclass SearchResult(BaseModel):\n    title: str\n    url: str\n    snippet: str\n\nclass ResearchDeps(BaseModel):\n    http_client: AsyncClient\n    api_key: str\n\nresearch_agent = Agent(\n    'claude-sonnet-4-20250514',\n    deps_type=ResearchDeps,\n    result_type=list[SearchResult],\n    system_prompt='You are a research assistant. Use tools to find information.',\n)\n\n@research_agent.tool\nasync def web_search(ctx: RunContext[ResearchDeps], query: str) -> list[dict]:\n    \"\"\"Search the web for information.\"\"\"\n    response = await ctx.deps.http_client.get(\n        'https://api.search.com/search',\n        params={'q': query},\n        headers={'Authorization': f'Bearer {ctx.deps.api_key}'},\n    )\n    return response.json()['results']\n\n@research_agent.tool\nasync def read_webpage(ctx: RunContext[ResearchDeps], url: str) -> str:\n    \"\"\"Read and extract content from a webpage.\"\"\"\n    response = await ctx.deps.http_client.get(url)\n    return response.text[:5000]  # Truncate for context\n\n# Usage\nasync def main():\n    async with AsyncClient() as client:\n        deps = ResearchDeps(http_client=client, api_key='...')\n        result = await research_agent.run(\n            'Find recent articles about LLM agents',\n            deps=deps,\n        )\n        for item in result.data:\n            print(f\"- {item.title}\")\n```\n\n### Structured Output with Validation\n```python\nfrom pydantic import BaseModel, Field\nfrom pydantic_ai import Agent\n\nclass CodeReview(BaseModel):\n    summary: str = Field(description=\"Brief summary of the review\")\n    issues: list[str] = Field(description=\"List of issues found\")\n    suggestions: list[str] = Field(description=\"Improvement suggestions\")\n    approval: bool = Field(description=\"Whether code is approved\")\n    confidence: float = Field(ge=0, le=1, description=\"Confidence score\")\n\nreview_agent = Agent(\n    'claude-sonnet-4-20250514',\n    result_type=CodeReview,\n    system_prompt='Review code for quality, security, and best practices.',\n)\n\n# Result is validated Pydantic model\nresult = await review_agent.run(f\"Review this code:\\n```python\\n{code}\\n```\")\nif result.data.approval:\n    print(\"Code approved!\")\nelse:\n    for issue in result.data.issues:\n        print(f\"Issue: {issue}\")\n```\n\n### Multi-Agent Coordination\n```python\nfrom pydantic_ai import Agent\n\n# Specialized agents\nplanner = Agent('claude-sonnet-4-20250514', system_prompt='Create detailed plans.')\nexecutor = Agent('claude-sonnet-4-20250514', system_prompt='Execute tasks precisely.')\nreviewer = Agent('claude-sonnet-4-20250514', system_prompt='Review and verify work.')\n\nasync def orchestrate(task: str):\n    # 1. Plan\n    plan = await planner.run(f\"Create a plan for: {task}\")\n\n    # 2. Execute each step\n    results = []\n    for step in plan.data.steps:\n        result = await executor.run(f\"Execute: {step}\")\n        results.append(result.data)\n\n    # 3. Review\n    review = await reviewer.run(\n        f\"Review the results:\\nTask: {task}\\nResults: {results}\"\n    )\n\n    return review.data\n```\n\n### Streaming Responses\n```python\nfrom pydantic_ai import Agent\n\nagent = Agent('claude-sonnet-4-20250514')\n\nasync def stream_response(prompt: str):\n    async with agent.run_stream(prompt) as response:\n        async for chunk in response.stream():\n            print(chunk, end='', flush=True)\n\n    # Get final structured result\n    result = await response.get_data()\n    return result\n```\n\n### Testing Agents\n```python\nimport pytest\nfrom pydantic_ai import Agent\nfrom pydantic_ai.models.test import TestModel\n\n@pytest.fixture\ndef test_agent():\n    return Agent(\n        TestModel(),  # Mock model for testing\n        result_type=str,\n    )\n\nasync def test_agent_response(test_agent):\n    result = await test_agent.run('Test prompt')\n    assert result.data is not None\n\n# Test with specific responses\nasync def test_with_mock_response():\n    model = TestModel()\n    model.seed_response('Expected output')\n\n    agent = Agent(model)\n    result = await agent.run('Any prompt')\n    assert result.data == 'Expected output'\n```\n\n---\n\n## Skills Pattern (Anthropic)\n\n### Skill Structure\n```\nskills/\n└── code-review/\n    ├── instructions.md      # How to perform code reviews\n    ├── scripts/\n    │   └── run-linters.sh   # Supporting scripts\n    └── resources/\n        └── checklist.md     # Review checklist\n```\n\n### instructions.md Example\n```markdown\n# Code Review Skill\n\n## When to Use\nActivate this skill when asked to review code, PRs, or diffs.\n\n## Process\n1. Read the changed files completely\n2. Run linters: `./scripts/run-linters.sh`\n3. Check against resources/checklist.md\n4. Provide structured feedback\n\n## Output Format\n- Summary (1-2 sentences)\n- Issues found (severity: critical/major/minor)\n- Suggestions for improvement\n- Approval recommendation\n```\n\n### Loading Skills Dynamically\n```typescript\nasync function loadSkill(skillName: string): Promise<Skill> {\n  const skillPath = `./skills/${skillName}`;\n  const instructions = await fs.readFile(`${skillPath}/instructions.md`, 'utf-8');\n  const scripts = await glob(`${skillPath}/scripts/*`);\n  const resources = await glob(`${skillPath}/resources/*`);\n\n  return {\n    name: skillName,\n    instructions,\n    scripts: scripts.map(s => ({ name: path.basename(s), path: s })),\n    resources: await Promise.all(resources.map(loadResource)),\n  };\n}\n```\n\n---\n\n## Anti-Patterns\n\n- **No planning before execution** - Agents that jump to action make more errors\n- **Monolithic agents** - One agent with 50 tools becomes confused\n- **No verification** - Agents must verify their own work\n- **Hardcoded tool sequences** - Let the model decide tool order\n- **Missing guardrails** - All agents need safety boundaries\n- **No state management** - Lose context across tool calls\n- **Testing only happy paths** - Test failures and edge cases\n- **Ignoring model differences** - Reasoning models need different prompts\n- **No cost tracking** - Agentic workflows can be expensive\n- **Full automation without oversight** - Human-in-the-loop for critical actions\n\n---\n\n## Quick Reference\n\n### Agent Development Checklist\n- [ ] Define clear agent scope and boundaries\n- [ ] Design tools with explicit schemas and risk levels\n- [ ] Implement explore-plan-execute-verify workflow\n- [ ] Add multi-layer guardrails\n- [ ] Set up conversation and persistent memory\n- [ ] Write behavior and evaluation tests\n- [ ] Configure appropriate model for task complexity\n- [ ] Add human-in-the-loop for high-risk operations\n- [ ] Monitor token usage and costs\n- [ ] Document skills and instructions\n\n### Thinking Triggers (Claude)\n```\n\"think\"        → Standard analysis\n\"think hard\"   → Deeper reasoning\n\"think harder\" → Extensive analysis\n\"ultrathink\"   → Maximum reasoning\n```\n\n### Gemini Settings\n```\nthinking_level: \"high\" | \"low\"\ntemperature: 1.0 (keep at 1.0 for reasoning)\nthoughtSignature: <pass back for function calling>\n```\n"
  },
  {
    "path": "skills/ai-models/SKILL.md",
    "content": "---\nname: ai-models\ndescription: Latest AI models reference - Claude, OpenAI, Gemini, Eleven Labs, Replicate\nwhen-to-use: When choosing models, comparing capabilities, or referencing model specs\nuser-invocable: true\neffort: low\n---\n\n# AI Models Reference Skill\n\n\n**Last Updated: December 2025**\n\n## Philosophy\n\n**Use the right model for the job.** Bigger isn't always better - match model capabilities to task requirements. Consider cost, latency, and accuracy tradeoffs.\n\n## Model Selection Matrix\n\n| Task | Recommended | Why |\n|------|-------------|-----|\n| Complex reasoning | Claude Opus 4.5, o3, Gemini 3 Pro | Highest accuracy |\n| Fast chat/completion | Claude Haiku, GPT-4.1 mini, Gemini Flash | Low latency, cheap |\n| Code generation | Claude Sonnet 4.5, Codestral, GPT-4.1 | Strong coding |\n| Vision/images | Claude Sonnet, GPT-4o, Gemini 3 Pro | Multimodal |\n| Embeddings | text-embedding-3-small, Voyage | Cost-effective |\n| Voice synthesis | Eleven Labs v3, OpenAI TTS | Natural sounding |\n| Image generation | FLUX.2, DALL-E 3, SD 3.5 | Different styles |\n\n---\n\n## Anthropic (Claude)\n\n### Documentation\n- **API Docs**: https://docs.anthropic.com\n- **Models Overview**: https://docs.anthropic.com/en/docs/about-claude/models/overview\n- **Pricing**: https://www.anthropic.com/pricing\n\n### Latest Models (December 2025)\n\n```typescript\nconst CLAUDE_MODELS = {\n  // Flagship - highest capability\n  opus: 'claude-opus-4-5-20251101',\n\n  // Balanced - best for most tasks\n  sonnet: 'claude-sonnet-4-5-20250929',\n\n  // Previous generation (still excellent)\n  opus4: 'claude-opus-4-20250514',\n  sonnet4: 'claude-sonnet-4-20250514',\n\n  // Fast & cheap - high volume tasks\n  haiku: 'claude-haiku-3-5-20241022',\n} as const;\n```\n\n### Usage\n```typescript\nimport Anthropic from '@anthropic-ai/sdk';\n\nconst anthropic = new Anthropic({\n  apiKey: process.env.ANTHROPIC_API_KEY,\n});\n\nconst response = await anthropic.messages.create({\n  model: 'claude-sonnet-4-5-20250929',\n  max_tokens: 1024,\n  messages: [\n    { role: 'user', content: 'Hello, Claude!' }\n  ],\n});\n```\n\n### Model Selection\n```\nclaude-opus-4-5-20251101 (Opus 4.5)\n├── Best for: Complex analysis, research, nuanced writing\n├── Context: 200K tokens\n├── Cost: $5/$25 per 1M tokens (input/output)\n└── Use when: Accuracy matters most\n\nclaude-sonnet-4-5-20250929 (Sonnet 4.5)\n├── Best for: Code, general tasks, balanced performance\n├── Context: 200K tokens\n├── Cost: $3/$15 per 1M tokens\n└── Use when: Default choice for most applications\n\nclaude-haiku-3-5-20241022 (Haiku 3.5)\n├── Best for: Classification, extraction, high-volume\n├── Context: 200K tokens\n├── Cost: $0.25/$1.25 per 1M tokens\n└── Use when: Speed and cost matter most\n```\n\n---\n\n## OpenAI\n\n### Documentation\n- **API Docs**: https://platform.openai.com/docs\n- **Models**: https://platform.openai.com/docs/models\n- **Pricing**: https://openai.com/pricing\n\n### Latest Models (December 2025)\n\n```typescript\nconst OPENAI_MODELS = {\n  // GPT-5 series (latest)\n  gpt5: 'gpt-5.2',\n  gpt5Mini: 'gpt-5-mini',\n\n  // GPT-4.1 series (recommended for most)\n  gpt41: 'gpt-4.1',\n  gpt41Mini: 'gpt-4.1-mini',\n  gpt41Nano: 'gpt-4.1-nano',\n\n  // Reasoning models (o-series)\n  o3: 'o3',\n  o3Pro: 'o3-pro',\n  o4Mini: 'o4-mini',\n\n  // Legacy but still useful\n  gpt4o: 'gpt-4o',           // Still has audio support\n  gpt4oMini: 'gpt-4o-mini',\n\n  // Embeddings\n  embeddingSmall: 'text-embedding-3-small',\n  embeddingLarge: 'text-embedding-3-large',\n\n  // Image generation\n  dalle3: 'dall-e-3',\n  gptImage: 'gpt-image-1',\n\n  // Audio\n  tts: 'tts-1',\n  ttsHd: 'tts-1-hd',\n  whisper: 'whisper-1',\n} as const;\n```\n\n### Usage\n```typescript\nimport OpenAI from 'openai';\n\nconst openai = new OpenAI({\n  apiKey: process.env.OPENAI_API_KEY,\n});\n\n// Chat completion\nconst response = await openai.chat.completions.create({\n  model: 'gpt-4.1',\n  messages: [\n    { role: 'user', content: 'Hello!' }\n  ],\n});\n\n// With vision\nconst visionResponse = await openai.chat.completions.create({\n  model: 'gpt-4.1',\n  messages: [\n    {\n      role: 'user',\n      content: [\n        { type: 'text', text: 'What is in this image?' },\n        { type: 'image_url', image_url: { url: 'https://...' } },\n      ],\n    },\n  ],\n});\n\n// Embeddings\nconst embedding = await openai.embeddings.create({\n  model: 'text-embedding-3-small',\n  input: 'Your text here',\n});\n```\n\n### Model Selection\n```\no3 / o3-pro\n├── Best for: Math, coding, complex multi-step reasoning\n├── Context: 200K tokens\n├── Cost: Premium pricing\n└── Use when: Hardest problems, need chain-of-thought\n\ngpt-4.1\n├── Best for: General tasks, coding, instruction following\n├── Context: 1M tokens (!)\n├── Cost: Lower than GPT-4o\n└── Use when: Default choice, replaces GPT-4o\n\ngpt-4.1-mini / gpt-4.1-nano\n├── Best for: High-volume, cost-sensitive\n├── Context: 1M tokens\n├── Cost: Very low\n└── Use when: Simple tasks at scale\n\no4-mini\n├── Best for: Fast reasoning at low cost\n├── Context: 200K tokens\n├── Cost: Budget reasoning\n└── Use when: Need reasoning but cost-conscious\n```\n\n---\n\n## Google (Gemini)\n\n### Documentation\n- **API Docs**: https://ai.google.dev/docs\n- **Models**: https://ai.google.dev/gemini-api/docs/models/gemini\n- **Pricing**: https://ai.google.dev/pricing\n\n### Latest Models (December 2025)\n\n```typescript\nconst GEMINI_MODELS = {\n  // Gemini 3 (Latest)\n  gemini3Pro: 'gemini-3-pro-preview',\n  gemini3ProImage: 'gemini-3-pro-image-preview',\n  gemini3Flash: 'gemini-3-flash-preview',\n\n  // Gemini 2.5 (Stable)\n  gemini25Pro: 'gemini-2.5-pro',\n  gemini25Flash: 'gemini-2.5-flash',\n  gemini25FlashLite: 'gemini-2.5-flash-lite',\n\n  // Specialized\n  gemini25FlashTTS: 'gemini-2.5-flash-preview-tts',\n  gemini25FlashAudio: 'gemini-2.5-flash-native-audio-preview-12-2025',\n\n  // Previous generation\n  gemini2Flash: 'gemini-2.0-flash',\n} as const;\n```\n\n### Usage\n```typescript\nimport { GoogleGenerativeAI } from '@google/generative-ai';\n\nconst genAI = new GoogleGenerativeAI(process.env.GOOGLE_API_KEY);\nconst model = genAI.getGenerativeModel({ model: 'gemini-2.5-flash' });\n\nconst result = await model.generateContent('Hello!');\nconst response = result.response.text();\n\n// With vision\nconst visionModel = genAI.getGenerativeModel({ model: 'gemini-2.5-pro' });\nconst imagePart = {\n  inlineData: {\n    data: base64Image,\n    mimeType: 'image/jpeg',\n  },\n};\nconst result = await visionModel.generateContent(['Describe this:', imagePart]);\n```\n\n### Model Selection\n```\ngemini-3-pro-preview\n├── Best for: \"Best model in the world for multimodal\"\n├── Context: 2M tokens\n├── Cost: Premium\n└── Use when: Need absolute best quality\n\ngemini-2.5-pro\n├── Best for: State-of-the-art thinking, complex tasks\n├── Context: 2M tokens\n├── Cost: $1.25/$5 per 1M tokens\n└── Use when: Long context, complex reasoning\n\ngemini-2.5-flash\n├── Best for: Fast, balanced performance\n├── Context: 1M tokens\n├── Cost: $0.075/$0.30 per 1M tokens\n└── Use when: Speed and cost matter\n\ngemini-2.5-flash-lite\n├── Best for: Ultra-fast, lowest cost\n├── Context: 1M tokens\n├── Cost: $0.04/$0.15 per 1M tokens\n└── Use when: High volume, simple tasks\n```\n\n---\n\n## Eleven Labs (Voice)\n\n### Documentation\n- **API Docs**: https://elevenlabs.io/docs\n- **Models**: https://elevenlabs.io/docs/models\n- **Pricing**: https://elevenlabs.io/pricing\n\n### Latest Models (December 2025)\n\n```typescript\nconst ELEVENLABS_MODELS = {\n  // Latest - highest quality (alpha)\n  v3: 'eleven_v3',\n\n  // Production ready\n  multilingualV2: 'eleven_multilingual_v2',\n  turboV2_5: 'eleven_turbo_v2_5',\n\n  // Ultra-low latency\n  flashV2_5: 'eleven_flash_v2_5',\n  flashV2: 'eleven_flash_v2', // English only\n} as const;\n```\n\n### Usage\n```typescript\nimport { ElevenLabsClient } from 'elevenlabs';\n\nconst elevenlabs = new ElevenLabsClient({\n  apiKey: process.env.ELEVENLABS_API_KEY,\n});\n\n// Text to speech\nconst audio = await elevenlabs.textToSpeech.convert('voice-id', {\n  text: 'Hello, world!',\n  model_id: 'eleven_turbo_v2_5',\n  voice_settings: {\n    stability: 0.5,\n    similarity_boost: 0.75,\n  },\n});\n\n// Stream audio (for real-time)\nconst audioStream = await elevenlabs.textToSpeech.convertAsStream('voice-id', {\n  text: 'Streaming audio...',\n  model_id: 'eleven_flash_v2_5',\n});\n```\n\n### Model Selection\n```\neleven_v3 (Alpha)\n├── Best for: Highest quality, emotional range\n├── Latency: ~1s+ (not for real-time)\n├── Languages: 74\n└── Use when: Quality over speed, pre-rendered\n\neleven_turbo_v2_5\n├── Best for: Balanced quality and speed\n├── Latency: ~250-300ms\n├── Languages: 32\n└── Use when: Good quality with reasonable latency\n\neleven_flash_v2_5\n├── Best for: Real-time, conversational AI\n├── Latency: <75ms\n├── Languages: 32\n└── Use when: Live voice agents, chatbots\n```\n\n---\n\n## Replicate\n\n### Documentation\n- **API Docs**: https://replicate.com/docs\n- **Models**: https://replicate.com/explore\n- **Pricing**: https://replicate.com/pricing\n\n### Popular Models (December 2025)\n\n```typescript\nconst REPLICATE_MODELS = {\n  // FLUX.2 (Latest - November 2025)\n  flux2Pro: 'black-forest-labs/flux-2-pro',\n  flux2Flex: 'black-forest-labs/flux-2-flex',\n  flux2Dev: 'black-forest-labs/flux-2-dev',\n\n  // FLUX.1 (Still excellent)\n  flux11Pro: 'black-forest-labs/flux-1.1-pro',\n  fluxKontext: 'black-forest-labs/flux-kontext', // Image editing\n  fluxSchnell: 'black-forest-labs/flux-schnell',\n\n  // Video\n  stableVideo4D: 'stability-ai/sv4d-2.0',\n\n  // Audio\n  musicgen: 'meta/musicgen',\n\n  // LLMs (if needed outside main providers)\n  llama: 'meta/llama-3.2-90b-vision',\n} as const;\n```\n\n### Usage\n```typescript\nimport Replicate from 'replicate';\n\nconst replicate = new Replicate({\n  auth: process.env.REPLICATE_API_TOKEN,\n});\n\n// Image generation with FLUX.2\nconst output = await replicate.run('black-forest-labs/flux-2-pro', {\n  input: {\n    prompt: 'A serene mountain landscape at sunset',\n    aspect_ratio: '16:9',\n    output_format: 'webp',\n  },\n});\n\n// Image editing with Kontext\nconst edited = await replicate.run('black-forest-labs/flux-kontext', {\n  input: {\n    image: 'https://...',\n    prompt: 'Change the sky to sunset colors',\n  },\n});\n```\n\n### Model Selection\n```\nflux-2-pro\n├── Best for: Highest quality, up to 4MP\n├── Speed: ~6s\n├── Cost: $0.015 + per megapixel\n└── Use when: Professional quality needed\n\nflux-2-flex\n├── Best for: Fine details, typography\n├── Speed: ~22s\n├── Cost: $0.06 per megapixel\n└── Use when: Need precise control\n\nflux-2-dev (Open source)\n├── Best for: Fast generation\n├── Speed: ~2.5s\n├── Cost: $0.012 per megapixel\n└── Use when: Speed over quality\n\nflux-kontext\n├── Best for: Image editing with text\n├── Speed: Variable\n├── Cost: Per run\n└── Use when: Edit existing images\n```\n\n---\n\n## Stability AI\n\n### Documentation\n- **API Docs**: https://platform.stability.ai/docs/api-reference\n- **Models**: https://stability.ai/stable-image\n- **Pricing**: https://platform.stability.ai/pricing\n\n### Latest Models (December 2025)\n\n```typescript\nconst STABILITY_MODELS = {\n  // Image generation\n  sd35Large: 'sd3.5-large',\n  sd35LargeTurbo: 'sd3.5-large-turbo',\n  sd3Medium: 'sd3-medium',\n\n  // Video\n  sv4d: 'sv4d-2.0', // Stable Video 4D 2.0\n\n  // Upscaling\n  upscale: 'esrgan-v1-x2plus',\n} as const;\n```\n\n### Usage\n```typescript\nconst response = await fetch(\n  'https://api.stability.ai/v2beta/stable-image/generate/sd3',\n  {\n    method: 'POST',\n    headers: {\n      'Content-Type': 'application/json',\n      Authorization: `Bearer ${process.env.STABILITY_API_KEY}`,\n    },\n    body: JSON.stringify({\n      prompt: 'A futuristic city at night',\n      output_format: 'webp',\n      aspect_ratio: '16:9',\n      model: 'sd3.5-large',\n    }),\n  }\n);\n```\n\n---\n\n## Mistral AI\n\n### Documentation\n- **API Docs**: https://docs.mistral.ai\n- **Models**: https://docs.mistral.ai/getting-started/models\n- **Pricing**: https://mistral.ai/technology/#pricing\n\n### Latest Models (December 2025)\n\n```typescript\nconst MISTRAL_MODELS = {\n  // Flagship\n  large: 'mistral-large-latest',  // Points to 2411\n\n  // Medium tier\n  medium: 'mistral-medium-2505',  // Medium 3\n\n  // Small/Fast\n  small: 'mistral-small-2506',    // Small 3.2\n\n  // Code specialized\n  codestral: 'codestral-2508',\n  devstral: 'devstral-medium-2507',\n\n  // Reasoning (Magistral)\n  magistralMedium: 'magistral-medium-2507',\n  magistralSmall: 'magistral-small-2507',\n\n  // Audio\n  voxtral: 'voxtral-small-2507',\n\n  // OCR\n  ocr: 'mistral-ocr-2505',\n} as const;\n```\n\n### Usage\n```typescript\nimport MistralClient from '@mistralai/mistralai';\n\nconst client = new MistralClient(process.env.MISTRAL_API_KEY);\n\nconst response = await client.chat({\n  model: 'mistral-large-latest',\n  messages: [{ role: 'user', content: 'Hello!' }],\n});\n\n// Code completion with Codestral\nconst codeResponse = await client.chat({\n  model: 'codestral-2508',\n  messages: [{ role: 'user', content: 'Write a Python function to...' }],\n});\n```\n\n### Model Selection\n```\nmistral-large-latest (123B params)\n├── Best for: Complex reasoning, knowledge tasks\n├── Context: 128K tokens\n└── Use when: Need high capability\n\ncodestral-2508\n├── Best for: Code generation, 80+ languages\n├── Speed: 2.5x faster than predecessor\n└── Use when: Code-focused tasks\n\nmagistral-medium-2507\n├── Best for: Multi-step reasoning\n├── Specialty: Transparent chain-of-thought\n└── Use when: Need reasoning traces\n```\n\n---\n\n## Voyage AI (Embeddings)\n\n### Documentation\n- **API Docs**: https://docs.voyageai.com\n- **Models**: https://docs.voyageai.com/docs/embeddings\n- **Pricing**: https://www.voyageai.com/pricing\n\n### Latest Models (December 2025)\n\n```typescript\nconst VOYAGE_MODELS = {\n  // General purpose\n  large2: 'voyage-large-2',\n  large2Instruct: 'voyage-large-2-instruct',\n\n  // Code specialized\n  code2: 'voyage-code-2',\n  code3: 'voyage-code-3',\n\n  // Multilingual\n  multilingual2: 'voyage-multilingual-2',\n\n  // Domain specific\n  law2: 'voyage-law-2',\n  finance2: 'voyage-finance-2',\n} as const;\n```\n\n### Usage\n```typescript\nconst response = await fetch('https://api.voyageai.com/v1/embeddings', {\n  method: 'POST',\n  headers: {\n    'Content-Type': 'application/json',\n    Authorization: `Bearer ${process.env.VOYAGE_API_KEY}`,\n  },\n  body: JSON.stringify({\n    model: 'voyage-code-3',\n    input: ['Your code to embed'],\n  }),\n});\n\nconst { data } = await response.json();\nconst embedding = data[0].embedding;\n```\n\n---\n\n## Quick Reference\n\n### Cost Comparison (per 1M tokens, approx.)\n\n| Provider | Cheap | Mid | Premium |\n|----------|-------|-----|---------|\n| Anthropic | $0.25 (Haiku) | $3 (Sonnet 4.5) | $5 (Opus 4.5) |\n| OpenAI | $0.15 (4.1-nano) | $2 (4.1) | $15+ (o3) |\n| Google | $0.04 (Flash-lite) | $0.08 (Flash) | $1.25 (Pro) |\n| Mistral | $0.25 (Small) | $2.70 (Medium) | $8 (Large) |\n\n### Best For Each Task\n\n```\nReasoning/Analysis    → Claude Opus 4.5, o3, Gemini 3 Pro\nCode Generation       → Claude Sonnet 4.5, Codestral 2508, GPT-4.1\nFast Responses        → Claude Haiku, GPT-4.1-mini, Gemini Flash\nLong Context          → Gemini 2.5 Pro (2M), GPT-4.1 (1M), Claude (200K)\nVision                → GPT-4.1, Claude Sonnet, Gemini 3 Pro\nEmbeddings            → Voyage code-3, text-embedding-3-small\nVoice Synthesis       → Eleven Labs v3/flash, OpenAI TTS\nImage Generation      → FLUX.2 Pro, DALL-E 3, SD 3.5\nVideo Generation      → Stable Video 4D 2.0, Runway\nImage Editing         → FLUX Kontext, gpt-image-1\n```\n\n### Environment Variables Template\n```bash\n# .env.example (NEVER commit actual keys)\n\n# LLMs\nANTHROPIC_API_KEY=sk-ant-...\nOPENAI_API_KEY=sk-...\nGOOGLE_API_KEY=AI...\nMISTRAL_API_KEY=...\n\n# Media\nELEVENLABS_API_KEY=...\nREPLICATE_API_TOKEN=r8_...\nSTABILITY_API_KEY=sk-...\n\n# Embeddings\nVOYAGE_API_KEY=pa-...\n```\n\n### Model Update Checklist\n```\nWhen models update:\n□ Check official changelog/blog\n□ Update model ID strings\n□ Test with existing prompts\n□ Compare output quality\n□ Check pricing changes\n□ Update context limits if changed\n```\n\n---\n\n## Sources\n\n- [Anthropic Models](https://docs.anthropic.com/en/docs/about-claude/models/overview)\n- [OpenAI Models](https://platform.openai.com/docs/models)\n- [OpenAI o3 Announcement](https://openai.com/index/introducing-o3-and-o4-mini/)\n- [GPT-4.1 Announcement](https://openai.com/index/gpt-4-1/)\n- [Google Gemini Models](https://ai.google.dev/gemini-api/docs/models/gemini)\n- [Eleven Labs Models](https://elevenlabs.io/docs/models)\n- [Replicate FLUX.2](https://replicate.com/blog/run-flux-2-on-replicate)\n- [Mistral Models](https://docs.mistral.ai/getting-started/models)\n- [Voyage AI](https://docs.voyageai.com)\n"
  },
  {
    "path": "skills/android-java/SKILL.md",
    "content": "---\nname: android-java\ndescription: Android Java development with MVVM, ViewBinding, and Espresso testing\nwhen-to-use: When working on Android Java source files\nuser-invocable: false\npaths: [\"**/*.java\", \"android/**\", \"**/build.gradle\"]\neffort: medium\n---\n\n# Android Java Skill\n\n\n---\n\n## Project Structure\n\n```\nproject/\n├── app/\n│   ├── src/\n│   │   ├── main/\n│   │   │   ├── java/com/example/app/\n│   │   │   │   ├── data/           # Data layer\n│   │   │   │   │   ├── local/      # Room database, SharedPreferences\n│   │   │   │   │   ├── remote/     # Retrofit services, API clients\n│   │   │   │   │   └── repository/ # Repository implementations\n│   │   │   │   ├── di/             # Dependency injection (Hilt/Dagger)\n│   │   │   │   ├── domain/         # Business logic\n│   │   │   │   │   ├── model/      # Domain models\n│   │   │   │   │   ├── repository/ # Repository interfaces\n│   │   │   │   │   └── usecase/    # Use cases\n│   │   │   │   ├── ui/             # Presentation layer\n│   │   │   │   │   ├── feature/    # Feature screens\n│   │   │   │   │   │   ├── FeatureActivity.java\n│   │   │   │   │   │   ├── FeatureFragment.java\n│   │   │   │   │   │   └── FeatureViewModel.java\n│   │   │   │   │   └── common/     # Shared UI components\n│   │   │   │   └── App.java        # Application class\n│   │   │   ├── res/\n│   │   │   │   ├── layout/\n│   │   │   │   ├── values/\n│   │   │   │   └── drawable/\n│   │   │   └── AndroidManifest.xml\n│   │   ├── test/                   # Unit tests\n│   │   └── androidTest/            # Instrumentation tests\n│   └── build.gradle\n├── build.gradle                    # Project-level build file\n├── gradle.properties\n├── settings.gradle\n└── CLAUDE.md\n```\n\n---\n\n## Gradle Configuration\n\n### App-level build.gradle\n```groovy\nplugins {\n    id 'com.android.application'\n}\n\nandroid {\n    namespace 'com.example.app'\n    compileSdk 34\n\n    defaultConfig {\n        applicationId \"com.example.app\"\n        minSdk 24\n        targetSdk 34\n        versionCode 1\n        versionName \"1.0\"\n\n        testInstrumentationRunner \"androidx.test.runner.AndroidJUnitRunner\"\n    }\n\n    buildTypes {\n        release {\n            minifyEnabled true\n            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'\n        }\n    }\n\n    compileOptions {\n        sourceCompatibility JavaVersion.VERSION_17\n        targetCompatibility JavaVersion.VERSION_17\n    }\n\n    buildFeatures {\n        viewBinding true\n    }\n}\n\ndependencies {\n    // AndroidX\n    implementation 'androidx.core:core:1.12.0'\n    implementation 'androidx.appcompat:appcompat:1.6.1'\n    implementation 'com.google.android.material:material:1.11.0'\n    implementation 'androidx.constraintlayout:constraintlayout:2.1.4'\n\n    // Lifecycle\n    implementation 'androidx.lifecycle:lifecycle-viewmodel:2.7.0'\n    implementation 'androidx.lifecycle:lifecycle-livedata:2.7.0'\n\n    // Testing\n    testImplementation 'junit:junit:4.13.2'\n    testImplementation 'org.mockito:mockito-core:5.8.0'\n    androidTestImplementation 'androidx.test.ext:junit:1.1.5'\n    androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'\n}\n```\n\n---\n\n## Architecture Patterns\n\n### MVVM with ViewModel\n```java\n// ViewModel - holds UI state, survives configuration changes\npublic class UserViewModel extends ViewModel {\n    private final UserRepository repository;\n    private final MutableLiveData<User> user = new MutableLiveData<>();\n    private final MutableLiveData<Boolean> loading = new MutableLiveData<>(false);\n    private final MutableLiveData<String> error = new MutableLiveData<>();\n\n    public UserViewModel(UserRepository repository) {\n        this.repository = repository;\n    }\n\n    public LiveData<User> getUser() {\n        return user;\n    }\n\n    public LiveData<Boolean> isLoading() {\n        return loading;\n    }\n\n    public LiveData<String> getError() {\n        return error;\n    }\n\n    public void loadUser(String userId) {\n        loading.setValue(true);\n        repository.getUser(userId, new Callback<User>() {\n            @Override\n            public void onSuccess(User result) {\n                user.setValue(result);\n                loading.setValue(false);\n            }\n\n            @Override\n            public void onError(String message) {\n                error.setValue(message);\n                loading.setValue(false);\n            }\n        });\n    }\n}\n```\n\n### Repository Pattern\n```java\n// Repository interface (domain layer)\npublic interface UserRepository {\n    void getUser(String userId, Callback<User> callback);\n    void saveUser(User user, Callback<Void> callback);\n}\n\n// Repository implementation (data layer)\npublic class UserRepositoryImpl implements UserRepository {\n    private final UserApi api;\n    private final UserDao dao;\n\n    public UserRepositoryImpl(UserApi api, UserDao dao) {\n        this.api = api;\n        this.dao = dao;\n    }\n\n    @Override\n    public void getUser(String userId, Callback<User> callback) {\n        // Try cache first, then network\n        User cached = dao.getUserById(userId);\n        if (cached != null) {\n            callback.onSuccess(cached);\n            return;\n        }\n        api.getUser(userId).enqueue(new retrofit2.Callback<User>() {\n            @Override\n            public void onResponse(Call<User> call, Response<User> response) {\n                if (response.isSuccessful() && response.body() != null) {\n                    dao.insert(response.body());\n                    callback.onSuccess(response.body());\n                } else {\n                    callback.onError(\"Failed to load user\");\n                }\n            }\n\n            @Override\n            public void onFailure(Call<User> call, Throwable t) {\n                callback.onError(t.getMessage());\n            }\n        });\n    }\n}\n```\n\n---\n\n## Activity & Fragment Patterns\n\n### Activity with ViewBinding\n```java\npublic class MainActivity extends AppCompatActivity {\n    private ActivityMainBinding binding;\n    private MainViewModel viewModel;\n\n    @Override\n    protected void onCreate(Bundle savedInstanceState) {\n        super.onCreate(savedInstanceState);\n        binding = ActivityMainBinding.inflate(getLayoutInflater());\n        setContentView(binding.getRoot());\n\n        viewModel = new ViewModelProvider(this).get(MainViewModel.class);\n        setupObservers();\n        setupListeners();\n    }\n\n    private void setupObservers() {\n        viewModel.getUser().observe(this, user -> {\n            binding.userName.setText(user.getName());\n        });\n\n        viewModel.isLoading().observe(this, isLoading -> {\n            binding.progressBar.setVisibility(isLoading ? View.VISIBLE : View.GONE);\n        });\n    }\n\n    private void setupListeners() {\n        binding.refreshButton.setOnClickListener(v -> {\n            viewModel.loadUser(getCurrentUserId());\n        });\n    }\n\n    @Override\n    protected void onDestroy() {\n        super.onDestroy();\n        binding = null;\n    }\n}\n```\n\n### Fragment with ViewBinding\n```java\npublic class UserFragment extends Fragment {\n    private FragmentUserBinding binding;\n    private UserViewModel viewModel;\n\n    @Override\n    public View onCreateView(LayoutInflater inflater, ViewGroup container,\n                             Bundle savedInstanceState) {\n        binding = FragmentUserBinding.inflate(inflater, container, false);\n        return binding.getRoot();\n    }\n\n    @Override\n    public void onViewCreated(View view, Bundle savedInstanceState) {\n        super.onViewCreated(view, savedInstanceState);\n        viewModel = new ViewModelProvider(requireActivity()).get(UserViewModel.class);\n        setupObservers();\n    }\n\n    private void setupObservers() {\n        viewModel.getUser().observe(getViewLifecycleOwner(), user -> {\n            binding.userName.setText(user.getName());\n        });\n    }\n\n    @Override\n    public void onDestroyView() {\n        super.onDestroyView();\n        binding = null;  // Prevent memory leaks\n    }\n}\n```\n\n---\n\n## Testing\n\n### Unit Tests with JUnit & Mockito\n```java\n@RunWith(MockitoJUnitRunner.class)\npublic class UserViewModelTest {\n    @Mock\n    private UserRepository repository;\n\n    @Rule\n    public InstantTaskExecutorRule instantTaskExecutorRule = new InstantTaskExecutorRule();\n\n    private UserViewModel viewModel;\n\n    @Before\n    public void setup() {\n        viewModel = new UserViewModel(repository);\n    }\n\n    @Test\n    public void loadUser_success_updatesUserLiveData() {\n        // Arrange\n        User expectedUser = new User(\"1\", \"John Doe\");\n        doAnswer(invocation -> {\n            Callback<User> callback = invocation.getArgument(1);\n            callback.onSuccess(expectedUser);\n            return null;\n        }).when(repository).getUser(eq(\"1\"), any());\n\n        // Act\n        viewModel.loadUser(\"1\");\n\n        // Assert\n        assertEquals(expectedUser, viewModel.getUser().getValue());\n        assertFalse(viewModel.isLoading().getValue());\n    }\n\n    @Test\n    public void loadUser_error_updatesErrorLiveData() {\n        // Arrange\n        doAnswer(invocation -> {\n            Callback<User> callback = invocation.getArgument(1);\n            callback.onError(\"Network error\");\n            return null;\n        }).when(repository).getUser(eq(\"1\"), any());\n\n        // Act\n        viewModel.loadUser(\"1\");\n\n        // Assert\n        assertEquals(\"Network error\", viewModel.getError().getValue());\n        assertFalse(viewModel.isLoading().getValue());\n    }\n}\n```\n\n### Instrumentation Tests with Espresso\n```java\n@RunWith(AndroidJUnit4.class)\npublic class MainActivityTest {\n    @Rule\n    public ActivityScenarioRule<MainActivity> activityRule =\n            new ActivityScenarioRule<>(MainActivity.class);\n\n    @Test\n    public void userName_isDisplayed() {\n        onView(withId(R.id.userName))\n                .check(matches(isDisplayed()));\n    }\n\n    @Test\n    public void refreshButton_click_triggersRefresh() {\n        onView(withId(R.id.refreshButton))\n                .perform(click());\n\n        onView(withId(R.id.progressBar))\n                .check(matches(isDisplayed()));\n    }\n\n    @Test\n    public void userList_scrollToItem_displaysCorrectly() {\n        onView(withId(R.id.userList))\n                .perform(RecyclerViewActions.scrollToPosition(10));\n\n        onView(withText(\"User 10\"))\n                .check(matches(isDisplayed()));\n    }\n}\n```\n\n---\n\n## GitHub Actions\n\n```yaml\nname: Android CI\n\non:\n  push:\n    branches: [main]\n  pull_request:\n    branches: [main]\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Set up JDK 17\n        uses: actions/setup-java@v4\n        with:\n          java-version: '17'\n          distribution: 'temurin'\n\n      - name: Setup Gradle\n        uses: gradle/actions/setup-gradle@v3\n\n      - name: Grant execute permission for gradlew\n        run: chmod +x gradlew\n\n      - name: Run Lint\n        run: ./gradlew lint\n\n      - name: Run Unit Tests\n        run: ./gradlew testDebugUnitTest\n\n      - name: Build Debug APK\n        run: ./gradlew assembleDebug\n\n      - name: Upload APK\n        uses: actions/upload-artifact@v4\n        with:\n          name: debug-apk\n          path: app/build/outputs/apk/debug/app-debug.apk\n\n  instrumentation-tests:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Set up JDK 17\n        uses: actions/setup-java@v4\n        with:\n          java-version: '17'\n          distribution: 'temurin'\n\n      - name: Enable KVM\n        run: |\n          echo 'KERNEL==\"kvm\", GROUP=\"kvm\", MODE=\"0666\", OPTIONS+=\"static_node=kvm\"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules\n          sudo udevadm control --reload-rules\n          sudo udevadm trigger --name-match=kvm\n\n      - name: Run Instrumentation Tests\n        uses: reactivecircus/android-emulator-runner@v2\n        with:\n          api-level: 29\n          script: ./gradlew connectedDebugAndroidTest\n```\n\n---\n\n## Lint Configuration\n\n### lint.xml\n```xml\n<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<lint>\n    <!-- Treat these as errors -->\n    <issue id=\"HardcodedText\" severity=\"error\" />\n    <issue id=\"MissingTranslation\" severity=\"error\" />\n    <issue id=\"UnusedResources\" severity=\"warning\" />\n\n    <!-- Memory leak detection -->\n    <issue id=\"StaticFieldLeak\" severity=\"error\" />\n\n    <!-- Security -->\n    <issue id=\"HardcodedDebugMode\" severity=\"error\" />\n    <issue id=\"AllowBackup\" severity=\"warning\" />\n\n    <!-- Performance -->\n    <issue id=\"ViewHolder\" severity=\"error\" />\n    <issue id=\"Overdraw\" severity=\"warning\" />\n\n    <!-- Ignore for tests -->\n    <issue id=\"InvalidPackage\">\n        <ignore path=\"**/test/**\" />\n        <ignore path=\"**/androidTest/**\" />\n    </issue>\n</lint>\n```\n\n### build.gradle lint options\n```groovy\nandroid {\n    lint {\n        abortOnError true\n        warningsAsErrors false\n        checkReleaseBuilds true\n        xmlReport true\n        htmlReport true\n    }\n}\n```\n\n---\n\n## Common Patterns\n\n### Null-Safe Callbacks\n```java\n// Define callback interface\npublic interface Callback<T> {\n    void onSuccess(T result);\n    void onError(String message);\n}\n\n// Use with null checks\npublic void fetchData(Callback<Data> callback) {\n    if (callback == null) return;\n\n    try {\n        Data result = performFetch();\n        callback.onSuccess(result);\n    } catch (Exception e) {\n        callback.onError(e.getMessage());\n    }\n}\n```\n\n### Safe Context Usage\n```java\n// Use application context for long-lived objects\npublic class DataManager {\n    private final Context appContext;\n\n    public DataManager(Context context) {\n        // Always use application context to prevent Activity leaks\n        this.appContext = context.getApplicationContext();\n    }\n}\n\n// Check for null context in callbacks\nprivate void updateUI() {\n    Context context = getContext();\n    if (context == null || !isAdded()) return;\n    // Safe to use context\n}\n```\n\n### Thread-Safe Singleton\n```java\npublic class ApiClient {\n    private static volatile ApiClient instance;\n    private final Retrofit retrofit;\n\n    private ApiClient() {\n        retrofit = new Retrofit.Builder()\n                .baseUrl(BASE_URL)\n                .addConverterFactory(GsonConverterFactory.create())\n                .build();\n    }\n\n    public static ApiClient getInstance() {\n        if (instance == null) {\n            synchronized (ApiClient.class) {\n                if (instance == null) {\n                    instance = new ApiClient();\n                }\n            }\n        }\n        return instance;\n    }\n}\n```\n\n---\n\n## Android Anti-Patterns\n\n- ❌ **Context leaks** - Never hold Activity/Fragment references in static fields or singletons\n- ❌ **Memory leaks in callbacks** - Always use WeakReference or clear callbacks in onDestroy\n- ❌ **UI updates on background thread** - Always post to main thread for UI changes\n- ❌ **Hardcoded strings** - Use string resources for all user-visible text\n- ❌ **God Activities** - Keep Activities under 200 lines, extract logic to ViewModels\n- ❌ **NetworkOnMainThreadException** - Never perform network calls on main thread\n- ❌ **Ignoring lifecycle** - Always respect Activity/Fragment lifecycle states\n- ❌ **Blocking the main thread** - Keep main thread operations under 16ms\n- ❌ **Not handling configuration changes** - Use ViewModel to survive rotation\n- ❌ **Hardcoded dimensions** - Use dp/sp units and dimension resources\n- ❌ **Deep view hierarchies** - Keep layout depth under 10 levels, use ConstraintLayout\n- ❌ **Not closing resources** - Always close Cursor, InputStream, database connections\n\n"
  },
  {
    "path": "skills/android-kotlin/SKILL.md",
    "content": "---\nname: android-kotlin\ndescription: Android Kotlin development with Coroutines, Jetpack Compose, Hilt, and MockK testing\nwhen-to-use: When working on Android Kotlin source files\nuser-invocable: false\npaths: [\"**/*.kt\", \"**/*.kts\", \"android/**\", \"**/build.gradle.kts\"]\neffort: medium\n---\n\n# Android Kotlin Skill\n\n\n---\n\n## Project Structure\n\n```\nproject/\n├── app/\n│   ├── src/\n│   │   ├── main/\n│   │   │   ├── kotlin/com/example/app/\n│   │   │   │   ├── data/               # Data layer\n│   │   │   │   │   ├── local/          # Room database\n│   │   │   │   │   ├── remote/         # Retrofit/Ktor services\n│   │   │   │   │   └── repository/     # Repository implementations\n│   │   │   │   ├── di/                 # Hilt modules\n│   │   │   │   ├── domain/             # Business logic\n│   │   │   │   │   ├── model/          # Domain models\n│   │   │   │   │   ├── repository/     # Repository interfaces\n│   │   │   │   │   └── usecase/        # Use cases\n│   │   │   │   ├── ui/                 # Presentation layer\n│   │   │   │   │   ├── feature/        # Feature screens\n│   │   │   │   │   │   ├── FeatureScreen.kt      # Compose UI\n│   │   │   │   │   │   └── FeatureViewModel.kt\n│   │   │   │   │   ├── components/     # Reusable Compose components\n│   │   │   │   │   └── theme/          # Material theme\n│   │   │   │   └── App.kt              # Application class\n│   │   │   ├── res/\n│   │   │   └── AndroidManifest.xml\n│   │   ├── test/                       # Unit tests\n│   │   └── androidTest/                # Instrumentation tests\n│   └── build.gradle.kts\n├── build.gradle.kts                    # Project-level build file\n├── gradle.properties\n├── settings.gradle.kts\n└── CLAUDE.md\n```\n\n---\n\n## Gradle Configuration (Kotlin DSL)\n\n### App-level build.gradle.kts\n```kotlin\nplugins {\n    id(\"com.android.application\")\n    id(\"org.jetbrains.kotlin.android\")\n    id(\"com.google.dagger.hilt.android\")\n    id(\"com.google.devtools.ksp\")\n}\n\nandroid {\n    namespace = \"com.example.app\"\n    compileSdk = 34\n\n    defaultConfig {\n        applicationId = \"com.example.app\"\n        minSdk = 24\n        targetSdk = 34\n        versionCode = 1\n        versionName = \"1.0\"\n\n        testInstrumentationRunner = \"androidx.test.runner.AndroidJUnitRunner\"\n    }\n\n    buildTypes {\n        release {\n            isMinifyEnabled = true\n            proguardFiles(\n                getDefaultProguardFile(\"proguard-android-optimize.txt\"),\n                \"proguard-rules.pro\"\n            )\n        }\n    }\n\n    compileOptions {\n        sourceCompatibility = JavaVersion.VERSION_17\n        targetCompatibility = JavaVersion.VERSION_17\n    }\n\n    kotlinOptions {\n        jvmTarget = \"17\"\n    }\n\n    buildFeatures {\n        compose = true\n    }\n\n    composeOptions {\n        kotlinCompilerExtensionVersion = \"1.5.8\"\n    }\n}\n\ndependencies {\n    // Compose BOM\n    val composeBom = platform(\"androidx.compose:compose-bom:2024.01.00\")\n    implementation(composeBom)\n    implementation(\"androidx.compose.ui:ui\")\n    implementation(\"androidx.compose.ui:ui-tooling-preview\")\n    implementation(\"androidx.compose.material3:material3\")\n    implementation(\"androidx.activity:activity-compose:1.8.2\")\n    implementation(\"androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0\")\n\n    // Coroutines\n    implementation(\"org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3\")\n\n    // Hilt\n    implementation(\"com.google.dagger:hilt-android:2.50\")\n    ksp(\"com.google.dagger:hilt-compiler:2.50\")\n    implementation(\"androidx.hilt:hilt-navigation-compose:1.1.0\")\n\n    // Room\n    implementation(\"androidx.room:room-runtime:2.6.1\")\n    implementation(\"androidx.room:room-ktx:2.6.1\")\n    ksp(\"androidx.room:room-compiler:2.6.1\")\n\n    // Testing\n    testImplementation(\"junit:junit:4.13.2\")\n    testImplementation(\"io.mockk:mockk:1.13.9\")\n    testImplementation(\"org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3\")\n    testImplementation(\"app.cash.turbine:turbine:1.0.0\")\n    androidTestImplementation(\"androidx.test.ext:junit:1.1.5\")\n    androidTestImplementation(\"androidx.compose.ui:ui-test-junit4\")\n    debugImplementation(\"androidx.compose.ui:ui-tooling\")\n    debugImplementation(\"androidx.compose.ui:ui-test-manifest\")\n}\n```\n\n---\n\n## Kotlin Coroutines & Flow\n\n### ViewModel with StateFlow\n```kotlin\n@HiltViewModel\nclass UserViewModel @Inject constructor(\n    private val getUserUseCase: GetUserUseCase,\n    private val savedStateHandle: SavedStateHandle\n) : ViewModel() {\n\n    private val _uiState = MutableStateFlow(UserUiState())\n    val uiState: StateFlow<UserUiState> = _uiState.asStateFlow()\n\n    private val userId: String = checkNotNull(savedStateHandle[\"userId\"])\n\n    init {\n        loadUser()\n    }\n\n    fun loadUser() {\n        viewModelScope.launch {\n            _uiState.update { it.copy(isLoading = true) }\n\n            getUserUseCase(userId)\n                .catch { e ->\n                    _uiState.update {\n                        it.copy(isLoading = false, error = e.message)\n                    }\n                }\n                .collect { user ->\n                    _uiState.update {\n                        it.copy(isLoading = false, user = user, error = null)\n                    }\n                }\n        }\n    }\n\n    fun clearError() {\n        _uiState.update { it.copy(error = null) }\n    }\n}\n\ndata class UserUiState(\n    val user: User? = null,\n    val isLoading: Boolean = false,\n    val error: String? = null\n)\n```\n\n### Repository with Flow\n```kotlin\ninterface UserRepository {\n    fun getUser(userId: String): Flow<User>\n    fun observeUsers(): Flow<List<User>>\n    suspend fun saveUser(user: User)\n}\n\nclass UserRepositoryImpl @Inject constructor(\n    private val api: UserApi,\n    private val dao: UserDao,\n    private val dispatcher: CoroutineDispatcher = Dispatchers.IO\n) : UserRepository {\n\n    override fun getUser(userId: String): Flow<User> = flow {\n        // Emit cached data first\n        dao.getUserById(userId)?.let { emit(it) }\n\n        // Fetch from network and update cache\n        val remoteUser = api.getUser(userId)\n        dao.insert(remoteUser)\n        emit(remoteUser)\n    }.flowOn(dispatcher)\n\n    override fun observeUsers(): Flow<List<User>> =\n        dao.observeAllUsers().flowOn(dispatcher)\n\n    override suspend fun saveUser(user: User) = withContext(dispatcher) {\n        api.saveUser(user)\n        dao.insert(user)\n    }\n}\n```\n\n---\n\n## Jetpack Compose\n\n### Screen with ViewModel\n```kotlin\n@Composable\nfun UserScreen(\n    viewModel: UserViewModel = hiltViewModel(),\n    onNavigateBack: () -> Unit\n) {\n    val uiState by viewModel.uiState.collectAsStateWithLifecycle()\n\n    UserScreenContent(\n        uiState = uiState,\n        onRefresh = viewModel::loadUser,\n        onErrorDismiss = viewModel::clearError,\n        onNavigateBack = onNavigateBack\n    )\n}\n\n@Composable\nprivate fun UserScreenContent(\n    uiState: UserUiState,\n    onRefresh: () -> Unit,\n    onErrorDismiss: () -> Unit,\n    onNavigateBack: () -> Unit\n) {\n    Scaffold(\n        topBar = {\n            TopAppBar(\n                title = { Text(\"User Profile\") },\n                navigationIcon = {\n                    IconButton(onClick = onNavigateBack) {\n                        Icon(Icons.AutoMirrored.Filled.ArrowBack, \"Back\")\n                    }\n                }\n            )\n        }\n    ) { padding ->\n        Box(\n            modifier = Modifier\n                .fillMaxSize()\n                .padding(padding)\n        ) {\n            when {\n                uiState.isLoading -> {\n                    CircularProgressIndicator(\n                        modifier = Modifier.align(Alignment.Center)\n                    )\n                }\n                uiState.user != null -> {\n                    UserContent(user = uiState.user)\n                }\n            }\n\n            uiState.error?.let { error ->\n                Snackbar(\n                    modifier = Modifier.align(Alignment.BottomCenter),\n                    action = {\n                        TextButton(onClick = onErrorDismiss) {\n                            Text(\"Dismiss\")\n                        }\n                    }\n                ) {\n                    Text(error)\n                }\n            }\n        }\n    }\n}\n```\n\n---\n\n## Sealed Classes for State\n\n### Result Wrapper\n```kotlin\nsealed interface Result<out T> {\n    data class Success<T>(val data: T) : Result<T>\n    data class Error(val exception: Throwable) : Result<Nothing>\n    data object Loading : Result<Nothing>\n}\n\nfun <T> Result<T>.getOrNull(): T? = (this as? Result.Success)?.data\n\ninline fun <T, R> Result<T>.map(transform: (T) -> R): Result<R> = when (this) {\n    is Result.Success -> Result.Success(transform(data))\n    is Result.Error -> this\n    is Result.Loading -> this\n}\n```\n\n---\n\n## Testing with MockK & Turbine\n\n### ViewModel Tests\n```kotlin\n@OptIn(ExperimentalCoroutinesApi::class)\nclass UserViewModelTest {\n\n    @get:Rule\n    val mainDispatcherRule = MainDispatcherRule()\n\n    private val getUserUseCase: GetUserUseCase = mockk()\n    private val savedStateHandle = SavedStateHandle(mapOf(\"userId\" to \"123\"))\n\n    private lateinit var viewModel: UserViewModel\n\n    @Before\n    fun setup() {\n        viewModel = UserViewModel(getUserUseCase, savedStateHandle)\n    }\n\n    @Test\n    fun `loadUser success updates state with user`() = runTest {\n        val user = User(\"123\", \"John Doe\", \"john@example.com\")\n        coEvery { getUserUseCase(\"123\") } returns flowOf(user)\n\n        viewModel.uiState.test {\n            val initial = awaitItem()\n            assertFalse(initial.isLoading)\n\n            viewModel.loadUser()\n\n            val loading = awaitItem()\n            assertTrue(loading.isLoading)\n\n            val success = awaitItem()\n            assertFalse(success.isLoading)\n            assertEquals(user, success.user)\n        }\n    }\n}\n\nclass MainDispatcherRule(\n    private val dispatcher: TestDispatcher = UnconfinedTestDispatcher()\n) : TestWatcher() {\n    override fun starting(description: Description) {\n        Dispatchers.setMain(dispatcher)\n    }\n    override fun finished(description: Description) {\n        Dispatchers.resetMain()\n    }\n}\n```\n\n---\n\n## GitHub Actions\n\n```yaml\nname: Android Kotlin CI\n\non:\n  push:\n    branches: [main]\n  pull_request:\n    branches: [main]\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Set up JDK 17\n        uses: actions/setup-java@v4\n        with:\n          java-version: '17'\n          distribution: 'temurin'\n\n      - name: Setup Gradle\n        uses: gradle/actions/setup-gradle@v3\n\n      - name: Run Detekt\n        run: ./gradlew detekt\n\n      - name: Run Ktlint\n        run: ./gradlew ktlintCheck\n\n      - name: Run Unit Tests\n        run: ./gradlew testDebugUnitTest\n\n      - name: Build Debug APK\n        run: ./gradlew assembleDebug\n```\n\n---\n\n## Lint Configuration\n\n### detekt.yml\n```yaml\nbuild:\n  maxIssues: 0\n\ncomplexity:\n  LongMethod:\n    threshold: 20\n  LongParameterList:\n    functionThreshold: 4\n  TooManyFunctions:\n    thresholdInFiles: 10\n\nstyle:\n  MaxLineLength:\n    maxLineLength: 120\n  WildcardImport:\n    active: true\n\ncoroutines:\n  GlobalCoroutineUsage:\n    active: true\n```\n\n---\n\n## Kotlin Anti-Patterns\n\n- ❌ **Blocking coroutines on Main** - Never use `runBlocking` on main thread\n- ❌ **GlobalScope usage** - Use structured concurrency with viewModelScope/lifecycleScope\n- ❌ **Collecting flows in init** - Use `repeatOnLifecycle` or `collectAsStateWithLifecycle`\n- ❌ **Mutable state exposure** - Expose `StateFlow` not `MutableStateFlow`\n- ❌ **Not handling exceptions in flows** - Always use `catch` operator\n- ❌ **Lateinit for nullable** - Use `lazy` or nullable with `?`\n- ❌ **Hardcoded dispatchers** - Inject dispatchers for testability\n- ❌ **Not using sealed classes** - Prefer sealed for finite state sets\n- ❌ **Side effects in Composables** - Use `LaunchedEffect`/`SideEffect`\n- ❌ **Unstable Compose parameters** - Use stable/immutable types or `@Stable`\n\n"
  },
  {
    "path": "skills/aws-aurora/SKILL.md",
    "content": "---\nname: aws-aurora\ndescription: AWS Aurora Serverless v2, RDS Proxy, Data API, connection pooling\nwhen-to-use: When working with AWS Aurora/RDS databases\nuser-invocable: false\npaths: [\"**/rds*\", \"**/aurora*\", \"serverless.*\", \"template.yaml\"]\neffort: medium\n---\n\n# AWS Aurora Skill\n\n\nAmazon Aurora is a MySQL/PostgreSQL-compatible relational database with serverless scaling, high availability, and enterprise features.\n\n**Sources:** [Aurora Docs](https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/) | [Serverless v2](https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/aurora-serverless-v2.html) | [RDS Proxy](https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/rds-proxy.html)\n\n---\n\n## Core Principle\n\n**Use RDS Proxy for serverless, Data API for simplicity, connection pooling always.**\n\nAurora excels at ACID-compliant workloads. For serverless architectures (Lambda), always use RDS Proxy or Data API to handle connection management. Never open raw connections from Lambda functions.\n\n---\n\n## Aurora Options\n\n| Option | Best For |\n|--------|----------|\n| **Aurora Serverless v2** | Variable workloads, auto-scaling (0.5-128 ACUs) |\n| **Aurora Provisioned** | Predictable workloads, maximum performance |\n| **Aurora Global** | Multi-region, disaster recovery |\n| **Data API** | Serverless without VPC, simple HTTP access |\n| **RDS Proxy** | Connection pooling for Lambda, high concurrency |\n\n---\n\n## Connection Strategies\n\n### Strategy 1: RDS Proxy (Recommended for Lambda)\n```\nLambda → RDS Proxy → Aurora\n         (pool)\n```\n- Connection pooling and reuse\n- Automatic failover handling\n- IAM authentication support\n- Works with existing SQL clients\n\n### Strategy 2: Data API (Simplest for Serverless)\n```\nLambda → Data API (HTTP) → Aurora\n```\n- No VPC required\n- No connection management\n- Higher latency per query\n- Limited to Aurora Serverless\n\n### Strategy 3: Direct Connection (Not for Lambda)\n```\nApp Server → Aurora\n(persistent connection)\n```\n- Only for long-running servers (ECS, EC2)\n- Manage connection pool yourself\n- Not suitable for serverless\n\n---\n\n## RDS Proxy Setup\n\n### Create Proxy (AWS Console/CDK)\n```typescript\n// CDK example\nimport * as rds from 'aws-cdk-lib/aws-rds';\n\nconst proxy = new rds.DatabaseProxy(this, 'Proxy', {\n  proxyTarget: rds.ProxyTarget.fromCluster(cluster),\n  secrets: [cluster.secret!],\n  vpc,\n  securityGroups: [proxySecurityGroup],\n  requireTLS: true,\n  idleClientTimeout: cdk.Duration.minutes(30),\n  maxConnectionsPercent: 90,\n  maxIdleConnectionsPercent: 10,\n  borrowTimeout: cdk.Duration.seconds(30)\n});\n```\n\n### Connect via Proxy (TypeScript/Node.js)\n```typescript\n// lib/db.ts\nimport { Pool } from 'pg';\nimport { Signer } from '@aws-sdk/rds-signer';\n\nconst signer = new Signer({\n  hostname: process.env.RDS_PROXY_ENDPOINT!,\n  port: 5432,\n  username: process.env.DB_USER!,\n  region: process.env.AWS_REGION!\n});\n\n// IAM authentication\nasync function getPool(): Promise<Pool> {\n  const token = await signer.getAuthToken();\n\n  return new Pool({\n    host: process.env.RDS_PROXY_ENDPOINT,\n    port: 5432,\n    database: process.env.DB_NAME,\n    user: process.env.DB_USER,\n    password: token,\n    ssl: { rejectUnauthorized: true },\n    max: 1,  // Single connection for Lambda\n    idleTimeoutMillis: 120000,\n    connectionTimeoutMillis: 10000\n  });\n}\n\n// Usage in Lambda\nlet pool: Pool | null = null;\n\nexport async function handler(event: any) {\n  if (!pool) {\n    pool = await getPool();\n  }\n\n  const result = await pool.query('SELECT * FROM users WHERE id = $1', [event.userId]);\n  return result.rows[0];\n}\n```\n\n### Proxy Configuration Best Practices\n```bash\n# Key settings for Lambda workloads\nMaxConnectionsPercent: 90        # Use most of DB connections\nMaxIdleConnectionsPercent: 10    # Keep some idle for bursts\nConnectionBorrowTimeout: 30s     # Wait for available connection\nIdleClientTimeout: 30min         # Close idle proxy connections\n\n# Monitor these CloudWatch metrics:\n# - DatabaseConnectionsCurrentlyBorrowed\n# - DatabaseConnectionsCurrentlySessionPinned\n# - QueryDatabaseResponseLatency\n```\n\n---\n\n## Data API (HTTP-based)\n\n### Enable Data API\n```bash\n# Must be Aurora Serverless\naws rds modify-db-cluster \\\n  --db-cluster-identifier my-cluster \\\n  --enable-http-endpoint\n```\n\n### TypeScript with Data API Client v2\n```bash\nnpm install data-api-client\n```\n\n```typescript\n// lib/db.ts\nimport DataAPIClient from 'data-api-client';\n\nconst db = DataAPIClient({\n  secretArn: process.env.DB_SECRET_ARN!,\n  resourceArn: process.env.DB_CLUSTER_ARN!,\n  database: process.env.DB_NAME!,\n  region: process.env.AWS_REGION!\n});\n\n// Simple query\nconst users = await db.query('SELECT * FROM users WHERE active = :active', {\n  active: true\n});\n\n// Insert with returning\nconst result = await db.query(\n  'INSERT INTO users (email, name) VALUES (:email, :name) RETURNING *',\n  { email: 'user@test.com', name: 'Test User' }\n);\n\n// Transaction\nconst transaction = await db.transaction();\ntry {\n  await transaction.query('UPDATE accounts SET balance = balance - :amount WHERE id = :from', {\n    amount: 100, from: 1\n  });\n  await transaction.query('UPDATE accounts SET balance = balance + :amount WHERE id = :to', {\n    amount: 100, to: 2\n  });\n  await transaction.commit();\n} catch (error) {\n  await transaction.rollback();\n  throw error;\n}\n```\n\n### Python with boto3\n```python\n# requirements.txt\nboto3>=1.34.0\n\n# db.py\nimport boto3\nimport os\n\nrds_data = boto3.client('rds-data')\n\nCLUSTER_ARN = os.environ['DB_CLUSTER_ARN']\nSECRET_ARN = os.environ['DB_SECRET_ARN']\nDATABASE = os.environ['DB_NAME']\n\n\ndef execute_sql(sql: str, parameters: list = None):\n    \"\"\"Execute SQL via Data API.\"\"\"\n    params = {\n        'resourceArn': CLUSTER_ARN,\n        'secretArn': SECRET_ARN,\n        'database': DATABASE,\n        'sql': sql\n    }\n\n    if parameters:\n        params['parameters'] = parameters\n\n    return rds_data.execute_statement(**params)\n\n\ndef get_user(user_id: int):\n    result = execute_sql(\n        'SELECT * FROM users WHERE id = :id',\n        [{'name': 'id', 'value': {'longValue': user_id}}]\n    )\n    return result.get('records', [])\n\n\ndef create_user(email: str, name: str):\n    result = execute_sql(\n        'INSERT INTO users (email, name) VALUES (:email, :name) RETURNING *',\n        [\n            {'name': 'email', 'value': {'stringValue': email}},\n            {'name': 'name', 'value': {'stringValue': name}}\n        ]\n    )\n    return result.get('generatedFields')\n\n\n# Transaction\ndef transfer_funds(from_id: int, to_id: int, amount: float):\n    transaction = rds_data.begin_transaction(\n        resourceArn=CLUSTER_ARN,\n        secretArn=SECRET_ARN,\n        database=DATABASE\n    )\n    transaction_id = transaction['transactionId']\n\n    try:\n        execute_sql(\n            'UPDATE accounts SET balance = balance - :amount WHERE id = :id',\n            [\n                {'name': 'amount', 'value': {'doubleValue': amount}},\n                {'name': 'id', 'value': {'longValue': from_id}}\n            ]\n        )\n\n        execute_sql(\n            'UPDATE accounts SET balance = balance + :amount WHERE id = :id',\n            [\n                {'name': 'amount', 'value': {'doubleValue': amount}},\n                {'name': 'id', 'value': {'longValue': to_id}}\n            ]\n        )\n\n        rds_data.commit_transaction(\n            resourceArn=CLUSTER_ARN,\n            secretArn=SECRET_ARN,\n            transactionId=transaction_id\n        )\n    except Exception as e:\n        rds_data.rollback_transaction(\n            resourceArn=CLUSTER_ARN,\n            secretArn=SECRET_ARN,\n            transactionId=transaction_id\n        )\n        raise e\n```\n\n---\n\n## Prisma with Aurora\n\n### Setup (VPC Connection via RDS Proxy)\n```bash\nnpm install prisma @prisma/client\nnpx prisma init\n```\n\n```prisma\n// prisma/schema.prisma\ngenerator client {\n  provider = \"prisma-client-js\"\n}\n\ndatasource db {\n  provider = \"postgresql\"\n  url      = env(\"DATABASE_URL\")\n}\n\nmodel User {\n  id        Int      @id @default(autoincrement())\n  email     String   @unique\n  name      String\n  posts     Post[]\n  createdAt DateTime @default(now())\n  updatedAt DateTime @updatedAt\n}\n\nmodel Post {\n  id        Int      @id @default(autoincrement())\n  title     String\n  content   String?\n  published Boolean  @default(false)\n  author    User     @relation(fields: [authorId], references: [id])\n  authorId  Int\n  createdAt DateTime @default(now())\n}\n```\n\n### Environment\n```bash\n# Use RDS Proxy endpoint\nDATABASE_URL=\"postgresql://user:password@proxy-endpoint.proxy-xxx.region.rds.amazonaws.com:5432/mydb?schema=public&connection_limit=1\"\n```\n\n### Lambda Handler with Prisma\n```typescript\n// handlers/users.ts\nimport { PrismaClient } from '@prisma/client';\n\n// Reuse client across invocations\nlet prisma: PrismaClient | null = null;\n\nfunction getPrisma(): PrismaClient {\n  if (!prisma) {\n    prisma = new PrismaClient({\n      datasources: {\n        db: { url: process.env.DATABASE_URL }\n      }\n    });\n  }\n  return prisma;\n}\n\nexport async function handler(event: any) {\n  const db = getPrisma();\n\n  const users = await db.user.findMany({\n    include: { posts: true },\n    take: 10\n  });\n\n  return {\n    statusCode: 200,\n    body: JSON.stringify(users)\n  };\n}\n```\n\n---\n\n## Aurora Serverless v2\n\n### Capacity Configuration\n```typescript\n// CDK\nconst cluster = new rds.DatabaseCluster(this, 'Cluster', {\n  engine: rds.DatabaseClusterEngine.auroraPostgres({\n    version: rds.AuroraPostgresEngineVersion.VER_15_4\n  }),\n  serverlessV2MinCapacity: 0.5,  // Minimum ACUs\n  serverlessV2MaxCapacity: 16,   // Maximum ACUs\n  writer: rds.ClusterInstance.serverlessV2('writer'),\n  readers: [\n    rds.ClusterInstance.serverlessV2('reader', { scaleWithWriter: true })\n  ],\n  vpc,\n  vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }\n});\n```\n\n### Capacity Guidelines\n\n| Workload | Min ACUs | Max ACUs |\n|----------|----------|----------|\n| Dev/Test | 0.5 | 2 |\n| Small Production | 2 | 8 |\n| Medium Production | 4 | 32 |\n| Large Production | 8 | 128 |\n\n### Handle Scale-to-Zero Wake-up\n```typescript\n// Data API Client v2 handles this automatically\n// For direct connections, implement retry logic:\n\nimport { Pool } from 'pg';\n\nasync function queryWithRetry(\n  pool: Pool,\n  sql: string,\n  params: any[],\n  maxRetries = 3\n): Promise<any> {\n  for (let attempt = 1; attempt <= maxRetries; attempt++) {\n    try {\n      return await pool.query(sql, params);\n    } catch (error: any) {\n      // Aurora Serverless waking up\n      if (error.code === 'ETIMEDOUT' || error.message?.includes('Communications link failure')) {\n        if (attempt === maxRetries) throw error;\n        // Exponential backoff\n        await new Promise(resolve => setTimeout(resolve, Math.pow(2, attempt) * 1000));\n        continue;\n      }\n      throw error;\n    }\n  }\n}\n```\n\n---\n\n## Migrations\n\n### Using Prisma Migrate\n```bash\n# Development (creates migration)\nnpx prisma migrate dev --name add_users_table\n\n# Production (apply migrations)\nnpx prisma migrate deploy\n\n# Generate client\nnpx prisma generate\n```\n\n### CI/CD Migration Script\n```yaml\n# .github/workflows/deploy.yml\n- name: Run migrations\n  run: |\n    # Connect via bastion or use a migration Lambda\n    npx prisma migrate deploy\n  env:\n    DATABASE_URL: ${{ secrets.DATABASE_URL }}\n```\n\n### Migration Lambda\n```typescript\n// lambdas/migrate.ts\nimport { execSync } from 'child_process';\n\nexport async function handler() {\n  try {\n    execSync('npx prisma migrate deploy', {\n      env: {\n        ...process.env,\n        DATABASE_URL: process.env.DATABASE_URL\n      },\n      stdio: 'inherit'\n    });\n    return { statusCode: 200, body: 'Migrations applied' };\n  } catch (error) {\n    console.error('Migration failed:', error);\n    throw error;\n  }\n}\n```\n\n---\n\n## Connection Pooling (Non-Lambda)\n\n### PgBouncer Sidecar (ECS/EKS)\n```yaml\n# docker-compose.yml\nservices:\n  app:\n    build: .\n    environment:\n      DATABASE_URL: postgresql://user:pass@pgbouncer:6432/mydb\n\n  pgbouncer:\n    image: edoburu/pgbouncer\n    environment:\n      DATABASE_URL: postgresql://user:pass@aurora-endpoint:5432/mydb\n      POOL_MODE: transaction\n      MAX_CLIENT_CONN: 1000\n      DEFAULT_POOL_SIZE: 20\n```\n\n### Application-Level Pooling\n```typescript\n// For long-running servers (not Lambda)\nimport { Pool } from 'pg';\n\nconst pool = new Pool({\n  host: process.env.DB_HOST,\n  port: 5432,\n  database: process.env.DB_NAME,\n  user: process.env.DB_USER,\n  password: process.env.DB_PASSWORD,\n  max: 20,                  // Max connections\n  idleTimeoutMillis: 30000, // Close idle after 30s\n  connectionTimeoutMillis: 10000\n});\n\n// Use pool for all queries\nexport async function query(sql: string, params?: any[]) {\n  const client = await pool.connect();\n  try {\n    return await client.query(sql, params);\n  } finally {\n    client.release();\n  }\n}\n```\n\n---\n\n## Monitoring\n\n### Key CloudWatch Metrics\n```\n# Aurora\n- CPUUtilization\n- DatabaseConnections\n- FreeableMemory\n- ServerlessDatabaseCapacity (ACUs)\n- AuroraReplicaLag\n\n# RDS Proxy\n- DatabaseConnectionsCurrentlyBorrowed\n- DatabaseConnectionsCurrentlySessionPinned\n- QueryDatabaseResponseLatency\n- ClientConnectionsReceived\n```\n\n### Performance Insights\n```bash\n# Enable via console or CLI\naws rds modify-db-cluster \\\n  --db-cluster-identifier my-cluster \\\n  --enable-performance-insights \\\n  --performance-insights-retention-period 7\n```\n\n---\n\n## Security\n\n### IAM Database Authentication\n```typescript\nimport { Signer } from '@aws-sdk/rds-signer';\n\nconst signer = new Signer({\n  hostname: process.env.DB_HOST!,\n  port: 5432,\n  username: 'iam_user',\n  region: 'us-east-1'\n});\n\nconst token = await signer.getAuthToken();\n\n// Use token as password (valid for 15 minutes)\nconst pool = new Pool({\n  host: process.env.DB_HOST,\n  user: 'iam_user',\n  password: token,\n  ssl: true\n});\n```\n\n### Secrets Manager Rotation\n```typescript\nimport { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';\n\nconst client = new SecretsManagerClient({ region: 'us-east-1' });\n\nasync function getDbCredentials() {\n  const response = await client.send(\n    new GetSecretValueCommand({ SecretId: process.env.DB_SECRET_ARN })\n  );\n  return JSON.parse(response.SecretString!);\n}\n```\n\n---\n\n## CLI Quick Reference\n\n```bash\n# Cluster operations\naws rds describe-db-clusters\naws rds create-db-cluster --engine aurora-postgresql --db-cluster-identifier my-cluster\naws rds delete-db-cluster --db-cluster-identifier my-cluster --skip-final-snapshot\n\n# Serverless v2\naws rds modify-db-cluster \\\n  --db-cluster-identifier my-cluster \\\n  --serverless-v2-scaling-configuration MinCapacity=0.5,MaxCapacity=16\n\n# Data API\naws rds-data execute-statement \\\n  --resource-arn $CLUSTER_ARN \\\n  --secret-arn $SECRET_ARN \\\n  --database mydb \\\n  --sql \"SELECT * FROM users\"\n\n# Proxy\naws rds describe-db-proxies\naws rds create-db-proxy --db-proxy-name my-proxy --engine-family POSTGRESQL ...\n\n# Snapshots\naws rds create-db-cluster-snapshot --db-cluster-identifier my-cluster --db-cluster-snapshot-identifier backup-1\naws rds restore-db-cluster-from-snapshot --db-cluster-identifier restored --snapshot-identifier backup-1\n```\n\n---\n\n## Anti-Patterns\n\n- **Direct Lambda→Aurora connections** - Always use RDS Proxy or Data API\n- **No connection limits** - Set `max: 1` for Lambda, use pooling for servers\n- **Ignoring cold starts** - Serverless v2 needs time to scale; keep minimum ACUs for production\n- **No read replicas** - Offload reads to replicas for heavy workloads\n- **Missing IAM auth** - Use IAM over static passwords when possible\n- **No retry logic** - Handle transient errors from scaling/failover\n- **Over-provisioned capacity** - Use Serverless v2 for variable workloads\n- **Skipping Secrets Manager** - Never hardcode credentials\n"
  },
  {
    "path": "skills/aws-dynamodb/SKILL.md",
    "content": "---\nname: aws-dynamodb\ndescription: AWS DynamoDB single-table design, GSI patterns, SDK v3 TypeScript/Python\nwhen-to-use: When working with DynamoDB tables or AWS SDK data operations\nuser-invocable: false\npaths: [\"**/dynamodb*\", \"**/dynamo*\", \"serverless.*\", \"template.yaml\"]\neffort: medium\n---\n\n# AWS DynamoDB Skill\n\n\nDynamoDB is a fully managed NoSQL database designed for single-digit millisecond performance at any scale. Master single-table design and access pattern modeling.\n\n**Sources:** [DynamoDB Docs](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/) | [SDK v3](https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/client/dynamodb/) | [Best Practices](https://aws.amazon.com/blogs/database/single-table-vs-multi-table-design-in-amazon-dynamodb/)\n\n---\n\n## Core Principle\n\n**Design for access patterns, not entities. Think access-pattern-first.**\n\nDynamoDB requires you to know your queries before designing your schema. Model around how you'll access data, not how data relates. Single-table design stores multiple entity types in one table using generic key attributes.\n\n---\n\n## Key Concepts\n\n| Concept | Description |\n|---------|-------------|\n| **Partition Key (PK)** | Primary key attribute - determines data distribution |\n| **Sort Key (SK)** | Optional secondary key for range queries within partition |\n| **GSI** | Global Secondary Index - alternate partition/sort keys |\n| **LSI** | Local Secondary Index - same partition, different sort |\n| **Item** | Single record (max 400 KB) |\n| **Attribute** | Field within an item |\n\n---\n\n## Single-Table Design\n\n### Why Single Table?\n- Fetch related data in single query\n- Reduce round trips and costs\n- Enable transactions across entity types\n- Simplify operations (backup, restore, IAM)\n\n### Generic Key Pattern\n```typescript\n// Instead of entity-specific keys:\n// userId, orderId, productId\n\n// Use generic keys that work for all entities:\ninterface BaseItem {\n  PK: string;   // Partition Key\n  SK: string;   // Sort Key\n  GSI1PK?: string;  // First GSI partition key\n  GSI1SK?: string;  // First GSI sort key\n  EntityType: string;\n  // ... entity-specific attributes\n}\n```\n\n### Example: E-commerce Schema\n```typescript\n// Users\n{ PK: 'USER#123', SK: 'PROFILE', EntityType: 'User', name: 'John', email: 'john@test.com' }\n{ PK: 'USER#123', SK: 'ADDRESS#1', EntityType: 'Address', street: '123 Main', city: 'NYC' }\n\n// Orders for user (1:N relationship)\n{ PK: 'USER#123', SK: 'ORDER#2024-001', EntityType: 'Order', total: 99.99, status: 'shipped' }\n{ PK: 'USER#123', SK: 'ORDER#2024-002', EntityType: 'Order', total: 49.99, status: 'pending' }\n\n// Order details (query by order ID using GSI)\n{ PK: 'USER#123', SK: 'ORDER#2024-001', GSI1PK: 'ORDER#2024-001', GSI1SK: 'ORDER', ... }\n{ PK: 'ORDER#2024-001', SK: 'ITEM#1', GSI1PK: 'ORDER#2024-001', GSI1SK: 'ITEM#1', productId: 'PROD#456', qty: 2 }\n\n// Products\n{ PK: 'PROD#456', SK: 'PRODUCT', EntityType: 'Product', name: 'Widget', price: 29.99 }\n```\n\n### Access Patterns Covered\n```\n1. Get user profile          → Query PK='USER#123', SK='PROFILE'\n2. Get user with addresses   → Query PK='USER#123', SK begins_with 'ADDRESS'\n3. Get all user orders       → Query PK='USER#123', SK begins_with 'ORDER'\n4. Get order by ID           → Query GSI1, PK='ORDER#2024-001'\n5. Get order with items      → Query GSI1, PK='ORDER#2024-001'\n6. Get product details       → Query PK='PROD#456', SK='PRODUCT'\n```\n\n---\n\n## SDK v3 Setup (TypeScript)\n\n### Install Dependencies\n```bash\nnpm install @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb\n```\n\n### Client Configuration\n```typescript\n// lib/dynamodb.ts\nimport { DynamoDBClient } from '@aws-sdk/client-dynamodb';\nimport { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';\n\nconst client = new DynamoDBClient({\n  region: process.env.AWS_REGION || 'us-east-1',\n  // For local development with DynamoDB Local\n  ...(process.env.DYNAMODB_LOCAL && {\n    endpoint: 'http://localhost:8000',\n    credentials: { accessKeyId: 'local', secretAccessKey: 'local' }\n  })\n});\n\n// Document client for simplified operations\nexport const docClient = DynamoDBDocumentClient.from(client, {\n  marshallOptions: {\n    removeUndefinedValues: true,  // Important: match v2 behavior\n    convertClassInstanceToMap: true\n  },\n  unmarshallOptions: {\n    wrapNumbers: false\n  }\n});\n\nexport const TABLE_NAME = process.env.DYNAMODB_TABLE || 'MyTable';\n```\n\n### Type Definitions\n```typescript\n// types/dynamodb.ts\nexport interface BaseItem {\n  PK: string;\n  SK: string;\n  GSI1PK?: string;\n  GSI1SK?: string;\n  EntityType: string;\n  createdAt: string;\n  updatedAt: string;\n}\n\nexport interface User extends BaseItem {\n  EntityType: 'User';\n  userId: string;\n  email: string;\n  name: string;\n}\n\nexport interface Order extends BaseItem {\n  EntityType: 'Order';\n  orderId: string;\n  userId: string;\n  total: number;\n  status: 'pending' | 'paid' | 'shipped' | 'delivered';\n}\n\n// Key builders\nexport const keys = {\n  user: (userId: string) => ({\n    PK: `USER#${userId}`,\n    SK: 'PROFILE'\n  }),\n  userOrders: (userId: string) => ({\n    PK: `USER#${userId}`,\n    SKPrefix: 'ORDER#'\n  }),\n  order: (userId: string, orderId: string) => ({\n    PK: `USER#${userId}`,\n    SK: `ORDER#${orderId}`,\n    GSI1PK: `ORDER#${orderId}`,\n    GSI1SK: 'ORDER'\n  })\n};\n```\n\n---\n\n## CRUD Operations\n\n### Put Item (Create/Update)\n```typescript\nimport { PutCommand } from '@aws-sdk/lib-dynamodb';\nimport { docClient, TABLE_NAME } from './dynamodb';\nimport { User, keys } from './types';\n\nasync function createUser(userId: string, data: { email: string; name: string }): Promise<User> {\n  const now = new Date().toISOString();\n  const item: User = {\n    ...keys.user(userId),\n    EntityType: 'User',\n    userId,\n    email: data.email,\n    name: data.name,\n    createdAt: now,\n    updatedAt: now\n  };\n\n  await docClient.send(new PutCommand({\n    TableName: TABLE_NAME,\n    Item: item,\n    ConditionExpression: 'attribute_not_exists(PK)'  // Prevent overwrite\n  }));\n\n  return item;\n}\n```\n\n### Get Item (Read)\n```typescript\nimport { GetCommand } from '@aws-sdk/lib-dynamodb';\n\nasync function getUser(userId: string): Promise<User | null> {\n  const result = await docClient.send(new GetCommand({\n    TableName: TABLE_NAME,\n    Key: keys.user(userId)\n  }));\n\n  return (result.Item as User) || null;\n}\n```\n\n### Query (List/Search)\n```typescript\nimport { QueryCommand } from '@aws-sdk/lib-dynamodb';\n\n// Get all orders for a user\nasync function getUserOrders(userId: string): Promise<Order[]> {\n  const result = await docClient.send(new QueryCommand({\n    TableName: TABLE_NAME,\n    KeyConditionExpression: 'PK = :pk AND begins_with(SK, :sk)',\n    ExpressionAttributeValues: {\n      ':pk': `USER#${userId}`,\n      ':sk': 'ORDER#'\n    },\n    ScanIndexForward: false  // Newest first\n  }));\n\n  return (result.Items as Order[]) || [];\n}\n\n// Query GSI by order ID\nasync function getOrderById(orderId: string): Promise<Order | null> {\n  const result = await docClient.send(new QueryCommand({\n    TableName: TABLE_NAME,\n    IndexName: 'GSI1',\n    KeyConditionExpression: 'GSI1PK = :pk',\n    ExpressionAttributeValues: {\n      ':pk': `ORDER#${orderId}`\n    }\n  }));\n\n  return (result.Items?.[0] as Order) || null;\n}\n\n// Paginated query\nasync function getUserOrdersPaginated(\n  userId: string,\n  pageSize: number = 20,\n  lastKey?: Record<string, any>\n): Promise<{ items: Order[]; lastKey?: Record<string, any> }> {\n  const result = await docClient.send(new QueryCommand({\n    TableName: TABLE_NAME,\n    KeyConditionExpression: 'PK = :pk AND begins_with(SK, :sk)',\n    ExpressionAttributeValues: {\n      ':pk': `USER#${userId}`,\n      ':sk': 'ORDER#'\n    },\n    Limit: pageSize,\n    ExclusiveStartKey: lastKey\n  }));\n\n  return {\n    items: (result.Items as Order[]) || [],\n    lastKey: result.LastEvaluatedKey\n  };\n}\n```\n\n### Update Item\n```typescript\nimport { UpdateCommand } from '@aws-sdk/lib-dynamodb';\n\nasync function updateUser(userId: string, updates: Partial<Pick<User, 'name' | 'email'>>): Promise<User> {\n  // Build update expression dynamically\n  const updateParts: string[] = ['#updatedAt = :updatedAt'];\n  const names: Record<string, string> = { '#updatedAt': 'updatedAt' };\n  const values: Record<string, any> = { ':updatedAt': new Date().toISOString() };\n\n  if (updates.name !== undefined) {\n    updateParts.push('#name = :name');\n    names['#name'] = 'name';\n    values[':name'] = updates.name;\n  }\n\n  if (updates.email !== undefined) {\n    updateParts.push('#email = :email');\n    names['#email'] = 'email';\n    values[':email'] = updates.email;\n  }\n\n  const result = await docClient.send(new UpdateCommand({\n    TableName: TABLE_NAME,\n    Key: keys.user(userId),\n    UpdateExpression: `SET ${updateParts.join(', ')}`,\n    ExpressionAttributeNames: names,\n    ExpressionAttributeValues: values,\n    ReturnValues: 'ALL_NEW',\n    ConditionExpression: 'attribute_exists(PK)'  // Must exist\n  }));\n\n  return result.Attributes as User;\n}\n\n// Atomic counter increment\nasync function incrementOrderCount(userId: string): Promise<void> {\n  await docClient.send(new UpdateCommand({\n    TableName: TABLE_NAME,\n    Key: keys.user(userId),\n    UpdateExpression: 'SET orderCount = if_not_exists(orderCount, :zero) + :inc',\n    ExpressionAttributeValues: {\n      ':zero': 0,\n      ':inc': 1\n    }\n  }));\n}\n```\n\n### Delete Item\n```typescript\nimport { DeleteCommand } from '@aws-sdk/lib-dynamodb';\n\nasync function deleteUser(userId: string): Promise<void> {\n  await docClient.send(new DeleteCommand({\n    TableName: TABLE_NAME,\n    Key: keys.user(userId),\n    ConditionExpression: 'attribute_exists(PK)'\n  }));\n}\n```\n\n---\n\n## Batch Operations\n\n### Batch Write (Up to 25 items)\n```typescript\nimport { BatchWriteCommand } from '@aws-sdk/lib-dynamodb';\n\nasync function batchCreateItems(items: BaseItem[]): Promise<void> {\n  // DynamoDB allows max 25 items per batch\n  const chunks = [];\n  for (let i = 0; i < items.length; i += 25) {\n    chunks.push(items.slice(i, i + 25));\n  }\n\n  for (const chunk of chunks) {\n    await docClient.send(new BatchWriteCommand({\n      RequestItems: {\n        [TABLE_NAME]: chunk.map(item => ({\n          PutRequest: { Item: item }\n        }))\n      }\n    }));\n  }\n}\n```\n\n### Batch Get (Up to 100 items)\n```typescript\nimport { BatchGetCommand } from '@aws-sdk/lib-dynamodb';\n\nasync function batchGetUsers(userIds: string[]): Promise<User[]> {\n  const result = await docClient.send(new BatchGetCommand({\n    RequestItems: {\n      [TABLE_NAME]: {\n        Keys: userIds.map(id => keys.user(id))\n      }\n    }\n  }));\n\n  return (result.Responses?.[TABLE_NAME] as User[]) || [];\n}\n```\n\n---\n\n## Transactions\n\n### TransactWrite (Atomic Multi-Item)\n```typescript\nimport { TransactWriteCommand } from '@aws-sdk/lib-dynamodb';\n\nasync function createOrderWithItems(\n  userId: string,\n  orderId: string,\n  orderData: { total: number },\n  items: { productId: string; quantity: number }[]\n): Promise<void> {\n  const now = new Date().toISOString();\n\n  const transactItems = [\n    // Create order\n    {\n      Put: {\n        TableName: TABLE_NAME,\n        Item: {\n          ...keys.order(userId, orderId),\n          EntityType: 'Order',\n          orderId,\n          userId,\n          total: orderData.total,\n          status: 'pending',\n          createdAt: now,\n          updatedAt: now\n        },\n        ConditionExpression: 'attribute_not_exists(PK)'\n      }\n    },\n    // Update user's order count\n    {\n      Update: {\n        TableName: TABLE_NAME,\n        Key: keys.user(userId),\n        UpdateExpression: 'SET orderCount = if_not_exists(orderCount, :zero) + :inc',\n        ExpressionAttributeValues: { ':zero': 0, ':inc': 1 }\n      }\n    },\n    // Add order items\n    ...items.map((item, index) => ({\n      Put: {\n        TableName: TABLE_NAME,\n        Item: {\n          PK: `ORDER#${orderId}`,\n          SK: `ITEM#${index}`,\n          GSI1PK: `ORDER#${orderId}`,\n          GSI1SK: `ITEM#${index}`,\n          EntityType: 'OrderItem',\n          productId: item.productId,\n          quantity: item.quantity,\n          createdAt: now\n        }\n      }\n    }))\n  ];\n\n  await docClient.send(new TransactWriteCommand({\n    TransactItems: transactItems\n  }));\n}\n```\n\n---\n\n## GSI Patterns\n\n### Sparse Index\n```typescript\n// Only items with GSI1PK attribute appear in the index\n// Useful for \"featured\" or \"flagged\" items\n\n// Featured products (only some products have GSI1PK)\n{ PK: 'PROD#1', SK: 'PRODUCT', GSI1PK: 'FEATURED', GSI1SK: 'PROD#1', ... }  // In index\n{ PK: 'PROD#2', SK: 'PRODUCT', ... }  // Not in index (no GSI1PK)\n\n// Query featured products\nconst featured = await docClient.send(new QueryCommand({\n  TableName: TABLE_NAME,\n  IndexName: 'GSI1',\n  KeyConditionExpression: 'GSI1PK = :pk',\n  ExpressionAttributeValues: { ':pk': 'FEATURED' }\n}));\n```\n\n### Inverted Index (GSI)\n```typescript\n// Main table: User -> Orders (PK=USER#, SK=ORDER#)\n// GSI: Orders by status (GSI1PK=STATUS#, GSI1SK=ORDER#)\n\n{ PK: 'USER#123', SK: 'ORDER#001', GSI1PK: 'STATUS#pending', GSI1SK: 'ORDER#001', ... }\n{ PK: 'USER#456', SK: 'ORDER#002', GSI1PK: 'STATUS#shipped', GSI1SK: 'ORDER#002', ... }\n\n// Get all pending orders across all users\nconst pending = await docClient.send(new QueryCommand({\n  TableName: TABLE_NAME,\n  IndexName: 'GSI1',\n  KeyConditionExpression: 'GSI1PK = :pk',\n  ExpressionAttributeValues: { ':pk': 'STATUS#pending' }\n}));\n```\n\n### Multi-Attribute Composite Keys (Nov 2025+)\n```typescript\n// New feature: Up to 4 attributes per partition/sort key\n// No more synthetic keys like \"TOURNAMENT#WINTER2024#REGION#NA-EAST\"\n\n// Table definition (IaC)\nconst table = {\n  AttributeDefinitions: [\n    { AttributeName: 'tournament', AttributeType: 'S' },\n    { AttributeName: 'region', AttributeType: 'S' },\n    { AttributeName: 'score', AttributeType: 'N' }\n  ],\n  GlobalSecondaryIndexes: [{\n    IndexName: 'TournamentRegionIndex',\n    KeySchema: [\n      { AttributeName: 'tournament', KeyType: 'HASH' },  // Composite PK part 1\n      { AttributeName: 'region', KeyType: 'HASH' },      // Composite PK part 2\n      { AttributeName: 'score', KeyType: 'RANGE' }\n    ]\n  }]\n};\n```\n\n---\n\n## Python (boto3)\n\n### Setup\n```python\n# requirements.txt\nboto3>=1.34.0\n\n# db.py\nimport boto3\nfrom boto3.dynamodb.conditions import Key, Attr\nimport os\n\ndynamodb = boto3.resource(\n    'dynamodb',\n    region_name=os.getenv('AWS_REGION', 'us-east-1'),\n    endpoint_url=os.getenv('DYNAMODB_LOCAL_ENDPOINT')  # For local dev\n)\n\ntable = dynamodb.Table(os.getenv('DYNAMODB_TABLE', 'MyTable'))\n```\n\n### Operations\n```python\nfrom datetime import datetime\nfrom typing import Optional, List\nfrom decimal import Decimal\n\ndef create_user(user_id: str, email: str, name: str) -> dict:\n    now = datetime.utcnow().isoformat()\n    item = {\n        'PK': f'USER#{user_id}',\n        'SK': 'PROFILE',\n        'EntityType': 'User',\n        'userId': user_id,\n        'email': email,\n        'name': name,\n        'createdAt': now,\n        'updatedAt': now\n    }\n\n    table.put_item(\n        Item=item,\n        ConditionExpression='attribute_not_exists(PK)'\n    )\n    return item\n\n\ndef get_user(user_id: str) -> Optional[dict]:\n    response = table.get_item(\n        Key={'PK': f'USER#{user_id}', 'SK': 'PROFILE'}\n    )\n    return response.get('Item')\n\n\ndef get_user_orders(user_id: str) -> List[dict]:\n    response = table.query(\n        KeyConditionExpression=Key('PK').eq(f'USER#{user_id}') & Key('SK').begins_with('ORDER#'),\n        ScanIndexForward=False\n    )\n    return response.get('Items', [])\n\n\ndef update_user(user_id: str, **updates) -> dict:\n    update_parts = ['#updatedAt = :updatedAt']\n    names = {'#updatedAt': 'updatedAt'}\n    values = {':updatedAt': datetime.utcnow().isoformat()}\n\n    for key, value in updates.items():\n        update_parts.append(f'#{key} = :{key}')\n        names[f'#{key}'] = key\n        values[f':{key}'] = value\n\n    response = table.update_item(\n        Key={'PK': f'USER#{user_id}', 'SK': 'PROFILE'},\n        UpdateExpression=f'SET {\", \".join(update_parts)}',\n        ExpressionAttributeNames=names,\n        ExpressionAttributeValues=values,\n        ReturnValues='ALL_NEW'\n    )\n    return response['Attributes']\n\n\ndef delete_user(user_id: str) -> None:\n    table.delete_item(\n        Key={'PK': f'USER#{user_id}', 'SK': 'PROFILE'}\n    )\n```\n\n---\n\n## Local Development\n\n### DynamoDB Local\n```bash\n# Docker\ndocker run -d -p 8000:8000 amazon/dynamodb-local\n\n# Create table locally\naws dynamodb create-table \\\n  --endpoint-url http://localhost:8000 \\\n  --table-name MyTable \\\n  --attribute-definitions \\\n    AttributeName=PK,AttributeType=S \\\n    AttributeName=SK,AttributeType=S \\\n    AttributeName=GSI1PK,AttributeType=S \\\n    AttributeName=GSI1SK,AttributeType=S \\\n  --key-schema \\\n    AttributeName=PK,KeyType=HASH \\\n    AttributeName=SK,KeyType=RANGE \\\n  --global-secondary-indexes \\\n    'IndexName=GSI1,KeySchema=[{AttributeName=GSI1PK,KeyType=HASH},{AttributeName=GSI1SK,KeyType=RANGE}],Projection={ProjectionType=ALL}' \\\n  --billing-mode PAY_PER_REQUEST\n```\n\n### NoSQL Workbench\nAWS provides [NoSQL Workbench](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/workbench.html) for visual data modeling and querying.\n\n---\n\n## CLI Quick Reference\n\n```bash\n# Table operations\naws dynamodb create-table --cli-input-json file://table.json\naws dynamodb describe-table --table-name MyTable\naws dynamodb delete-table --table-name MyTable\n\n# Item operations\naws dynamodb put-item --table-name MyTable --item '{\"PK\":{\"S\":\"USER#1\"},\"SK\":{\"S\":\"PROFILE\"}}'\naws dynamodb get-item --table-name MyTable --key '{\"PK\":{\"S\":\"USER#1\"},\"SK\":{\"S\":\"PROFILE\"}}'\naws dynamodb delete-item --table-name MyTable --key '{\"PK\":{\"S\":\"USER#1\"},\"SK\":{\"S\":\"PROFILE\"}}'\n\n# Query\naws dynamodb query --table-name MyTable \\\n  --key-condition-expression \"PK = :pk\" \\\n  --expression-attribute-values '{\":pk\":{\"S\":\"USER#1\"}}'\n\n# Scan (avoid in production)\naws dynamodb scan --table-name MyTable --limit 10\n```\n\n---\n\n## Anti-Patterns\n\n- **Scan operations** - Always use Query with proper key conditions\n- **Hot partitions** - Distribute writes with high-cardinality partition keys\n- **Large items** - Keep items under 400KB; use S3 for large data\n- **Too many GSIs** - Each GSI duplicates data; design carefully\n- **Ignoring capacity** - Monitor consumed capacity, use on-demand for variable loads\n- **No condition expressions** - Always validate with ConditionExpression\n- **Fetching all attributes** - Use ProjectionExpression to limit data\n- **Multi-table design without reason** - Single-table is preferred unless access patterns don't overlap\n"
  },
  {
    "path": "skills/azure-cosmosdb/SKILL.md",
    "content": "---\nname: azure-cosmosdb\ndescription: Azure Cosmos DB partition keys, consistency levels, change feed, SDK patterns\nwhen-to-use: When working with Azure Cosmos DB\nuser-invocable: false\npaths: [\"**/cosmos*\", \"**/azure*\"]\neffort: medium\n---\n\n## Core Principle\n\n**Choose partition key wisely, design for your access patterns, understand consistency tradeoffs.**\n\nCosmos DB distributes data across partitions. Your partition key choice determines scalability, performance, and cost. Design for even distribution and query efficiency.\n\n---\n\n## Cosmos DB APIs\n\n| API | Use Case |\n|-----|----------|\n| **NoSQL (Core)** | Document database, most flexible |\n| **MongoDB** | MongoDB wire protocol compatible |\n| **PostgreSQL** | Distributed PostgreSQL (Citus) |\n| **Apache Cassandra** | Wide-column store |\n| **Apache Gremlin** | Graph database |\n| **Table** | Key-value (Azure Table Storage compatible) |\n\nThis skill focuses on **NoSQL (Core) API** - the most common choice.\n\n---\n\n## Key Concepts\n\n| Concept | Description |\n|---------|-------------|\n| **Container** | Collection of items (like a table) |\n| **Item** | Single document/record (JSON) |\n| **Partition Key** | Determines data distribution |\n| **Logical Partition** | Items with same partition key |\n| **Physical Partition** | Storage unit (max 50GB, 10K RU/s) |\n| **RU (Request Unit)** | Throughput currency |\n\n---\n\n## Partition Key Design\n\n### Good Partition Keys\n```typescript\n// High cardinality, even distribution, used in queries\n\n// E-commerce: userId for user data\n{ \"id\": \"order-123\", \"userId\": \"user-456\", ... }  // PK: /userId\n\n// Multi-tenant: tenantId\n{ \"id\": \"doc-1\", \"tenantId\": \"tenant-abc\", ... }  // PK: /tenantId\n\n// IoT: deviceId for telemetry\n{ \"id\": \"reading-1\", \"deviceId\": \"device-789\", ... }  // PK: /deviceId\n\n// Logs: synthetic key (date + category)\n{ \"id\": \"log-1\", \"partitionKey\": \"2024-01-15_errors\", ... }  // PK: /partitionKey\n```\n\n### Hierarchical Partition Keys\n```typescript\n// For multi-level distribution (e.g., tenant → user)\n// Container created with: /tenantId, /userId\n\n{\n  \"id\": \"order-123\",\n  \"tenantId\": \"acme-corp\",\n  \"userId\": \"user-456\",\n  \"items\": [...]\n}\n\n// Query within tenant and user efficiently\n```\n\n### Bad Partition Keys\n```typescript\n// Avoid:\n// - Low cardinality (status, type, boolean)\n// - Monotonically increasing (timestamp, auto-increment)\n// - Frequently updated fields\n// - Fields not used in queries\n\n// Bad: Only 3 values → hot partitions\n{ \"status\": \"pending\" | \"completed\" | \"cancelled\" }\n\n// Bad: All writes go to latest partition\n{ \"timestamp\": \"2024-01-15T10:30:00Z\" }\n```\n\n---\n\n## SDK Setup (TypeScript)\n\n### Install\n```bash\nnpm install @azure/cosmos\n```\n\n### Initialize Client\n```typescript\n// lib/cosmosdb.ts\nimport { CosmosClient, Database, Container } from '@azure/cosmos';\n\nconst endpoint = process.env.COSMOS_ENDPOINT!;\nconst key = process.env.COSMOS_KEY!;\nconst databaseId = process.env.COSMOS_DATABASE!;\n\nconst client = new CosmosClient({ endpoint, key });\n\n// Or with connection string\n// const client = new CosmosClient(process.env.COSMOS_CONNECTION_STRING!);\n\nexport const database: Database = client.database(databaseId);\n\nexport function getContainer(containerId: string): Container {\n  return database.container(containerId);\n}\n```\n\n### Type Definitions\n```typescript\n// types/cosmos.ts\nexport interface BaseItem {\n  id: string;\n  _ts?: number;      // Auto-generated timestamp\n  _etag?: string;    // For optimistic concurrency\n}\n\nexport interface User extends BaseItem {\n  userId: string;    // Partition key\n  email: string;\n  name: string;\n  createdAt: string;\n  updatedAt: string;\n}\n\nexport interface Order extends BaseItem {\n  userId: string;    // Partition key\n  orderId: string;\n  items: OrderItem[];\n  total: number;\n  status: 'pending' | 'paid' | 'shipped' | 'delivered';\n  createdAt: string;\n}\n\nexport interface OrderItem {\n  productId: string;\n  name: string;\n  quantity: number;\n  price: number;\n}\n```\n\n---\n\n## CRUD Operations\n\n### Create Item\n```typescript\nimport { getContainer } from './cosmosdb';\nimport { User } from './types';\n\nconst usersContainer = getContainer('users');\n\nasync function createUser(data: Omit<User, 'id' | 'createdAt' | 'updatedAt'>): Promise<User> {\n  const now = new Date().toISOString();\n  const user: User = {\n    id: crypto.randomUUID(),\n    ...data,\n    createdAt: now,\n    updatedAt: now\n  };\n\n  const { resource } = await usersContainer.items.create(user);\n  return resource as User;\n}\n```\n\n### Read Item (Point Read)\n```typescript\n// Most efficient read - requires id AND partition key\nasync function getUser(userId: string, id: string): Promise<User | null> {\n  try {\n    const { resource } = await usersContainer.item(id, userId).read<User>();\n    return resource || null;\n  } catch (error: any) {\n    if (error.code === 404) return null;\n    throw error;\n  }\n}\n\n// If id equals partition key value\nasync function getUserById(userId: string): Promise<User | null> {\n  try {\n    const { resource } = await usersContainer.item(userId, userId).read<User>();\n    return resource || null;\n  } catch (error: any) {\n    if (error.code === 404) return null;\n    throw error;\n  }\n}\n```\n\n### Query Items\n```typescript\n// Query within partition (efficient)\nasync function getUserOrders(userId: string): Promise<Order[]> {\n  const ordersContainer = getContainer('orders');\n\n  const { resources } = await ordersContainer.items\n    .query<Order>({\n      query: 'SELECT * FROM c WHERE c.userId = @userId ORDER BY c.createdAt DESC',\n      parameters: [{ name: '@userId', value: userId }]\n    })\n    .fetchAll();\n\n  return resources;\n}\n\n// Cross-partition query (use sparingly)\nasync function getOrdersByStatus(status: string): Promise<Order[]> {\n  const ordersContainer = getContainer('orders');\n\n  const { resources } = await ordersContainer.items\n    .query<Order>({\n      query: 'SELECT * FROM c WHERE c.status = @status',\n      parameters: [{ name: '@status', value: status }]\n    })\n    .fetchAll();\n\n  return resources;\n}\n\n// Paginated query\nasync function getOrdersPaginated(\n  userId: string,\n  pageSize: number = 10,\n  continuationToken?: string\n): Promise<{ items: Order[]; continuationToken?: string }> {\n  const ordersContainer = getContainer('orders');\n\n  const queryIterator = ordersContainer.items.query<Order>(\n    {\n      query: 'SELECT * FROM c WHERE c.userId = @userId ORDER BY c.createdAt DESC',\n      parameters: [{ name: '@userId', value: userId }]\n    },\n    {\n      maxItemCount: pageSize,\n      continuationToken\n    }\n  );\n\n  const { resources, continuationToken: nextToken } = await queryIterator.fetchNext();\n\n  return {\n    items: resources,\n    continuationToken: nextToken\n  };\n}\n```\n\n### Update Item\n```typescript\n// Replace entire item\nasync function updateUser(userId: string, id: string, updates: Partial<User>): Promise<User> {\n  const existing = await getUser(userId, id);\n  if (!existing) throw new Error('User not found');\n\n  const updated: User = {\n    ...existing,\n    ...updates,\n    updatedAt: new Date().toISOString()\n  };\n\n  const { resource } = await usersContainer.item(id, userId).replace(updated);\n  return resource as User;\n}\n\n// Partial update (patch operations)\nasync function patchUser(userId: string, id: string, operations: any[]): Promise<User> {\n  const { resource } = await usersContainer.item(id, userId).patch(operations);\n  return resource as User;\n}\n\n// Usage:\nawait patchUser('user-123', 'user-123', [\n  { op: 'set', path: '/name', value: 'New Name' },\n  { op: 'set', path: '/updatedAt', value: new Date().toISOString() },\n  { op: 'incr', path: '/loginCount', value: 1 }\n]);\n```\n\n### Delete Item\n```typescript\nasync function deleteUser(userId: string, id: string): Promise<void> {\n  await usersContainer.item(id, userId).delete();\n}\n```\n\n### Optimistic Concurrency (ETags)\n```typescript\nasync function updateUserWithETag(\n  userId: string,\n  id: string,\n  updates: Partial<User>,\n  etag: string\n): Promise<User> {\n  const existing = await getUser(userId, id);\n  if (!existing) throw new Error('User not found');\n\n  const updated: User = {\n    ...existing,\n    ...updates,\n    updatedAt: new Date().toISOString()\n  };\n\n  try {\n    const { resource } = await usersContainer.item(id, userId).replace(updated, {\n      accessCondition: { type: 'IfMatch', condition: etag }\n    });\n    return resource as User;\n  } catch (error: any) {\n    if (error.code === 412) {\n      throw new Error('Document was modified by another process');\n    }\n    throw error;\n  }\n}\n```\n\n---\n\n## Consistency Levels\n\n| Level | Guarantees | Latency | Use Case |\n|-------|-----------|---------|----------|\n| **Strong** | Linearizable reads | Highest | Financial, inventory |\n| **Bounded Staleness** | Consistent within bounds | High | Leaderboards, counters |\n| **Session** | Read your writes | Medium | User sessions (default) |\n| **Consistent Prefix** | Ordered reads | Low | Social feeds |\n| **Eventual** | No ordering guarantee | Lowest | Analytics, logs |\n\n### Set Consistency Per Request\n```typescript\n// Override default consistency\nconst { resource } = await usersContainer.item(id, userId).read<User>({\n  consistencyLevel: 'Strong'\n});\n\n// For queries\nconst { resources } = await container.items.query(\n  { query: 'SELECT * FROM c' },\n  { consistencyLevel: 'BoundedStaleness' }\n).fetchAll();\n```\n\n---\n\n## Batch Operations\n\n### Transactional Batch (Same Partition)\n```typescript\nasync function createOrderWithItems(userId: string, order: Order, items: any[]): Promise<void> {\n  const ordersContainer = getContainer('orders');\n\n  const operations = [\n    { operationType: 'Create' as const, resourceBody: order },\n    ...items.map(item => ({\n      operationType: 'Create' as const,\n      resourceBody: { ...item, userId, orderId: order.orderId }\n    }))\n  ];\n\n  const { result } = await ordersContainer.items.batch(operations, userId);\n\n  // Check if any operation failed\n  if (result.some(r => r.statusCode >= 400)) {\n    throw new Error('Batch operation failed');\n  }\n}\n```\n\n### Bulk Operations\n```typescript\n// For large-scale imports (not transactional)\nasync function bulkImportUsers(users: User[]): Promise<void> {\n  const operations = users.map(user => ({\n    operationType: 'Create' as const,\n    resourceBody: user,\n    partitionKey: user.userId\n  }));\n\n  // Process in chunks\n  const chunkSize = 100;\n  for (let i = 0; i < operations.length; i += chunkSize) {\n    const chunk = operations.slice(i, i + chunkSize);\n    await usersContainer.items.bulk(chunk);\n  }\n}\n```\n\n---\n\n## Change Feed\n\n### Process Changes\n```typescript\nimport { ChangeFeedStartFrom } from '@azure/cosmos';\n\nasync function processChangeFeed(): Promise<void> {\n  const container = getContainer('orders');\n\n  const changeFeedIterator = container.items.changeFeed({\n    changeFeedStartFrom: ChangeFeedStartFrom.Beginning()\n  });\n\n  while (changeFeedIterator.hasMoreResults) {\n    const { result: items, statusCode } = await changeFeedIterator.fetchNext();\n\n    if (statusCode === 304) {\n      // No new changes\n      await sleep(1000);\n      continue;\n    }\n\n    for (const item of items) {\n      console.log('Changed item:', item);\n      // Process the change...\n    }\n  }\n}\n\n// For production, use Change Feed Processor with lease container\n```\n\n### Change Feed Processor Pattern\n```typescript\nasync function startChangeFeedProcessor(): Promise<void> {\n  const sourceContainer = getContainer('orders');\n  const leaseContainer = getContainer('leases');\n\n  const changeFeedProcessor = sourceContainer.items.changeFeed\n    .for(item => {\n      // Process each change\n      console.log('Processing:', item);\n    })\n    .withLeaseContainer(leaseContainer)\n    .build();\n\n  await changeFeedProcessor.start();\n}\n```\n\n---\n\n## Python SDK\n\n### Install\n```bash\npip install azure-cosmos\n```\n\n### Setup and Operations\n```python\n# cosmos_db.py\nimport os\nfrom azure.cosmos import CosmosClient, PartitionKey\nfrom azure.cosmos.exceptions import CosmosResourceNotFoundError\nfrom typing import Optional, List\nfrom datetime import datetime\nimport uuid\n\n# Initialize client\nendpoint = os.environ['COSMOS_ENDPOINT']\nkey = os.environ['COSMOS_KEY']\ndatabase_name = os.environ['COSMOS_DATABASE']\n\nclient = CosmosClient(endpoint, key)\ndatabase = client.get_database_client(database_name)\n\n\ndef get_container(container_name: str):\n    return database.get_container_client(container_name)\n\n\n# CRUD Operations\nusers_container = get_container('users')\n\n\ndef create_user(email: str, name: str, user_id: str = None) -> dict:\n    user_id = user_id or str(uuid.uuid4())\n    now = datetime.utcnow().isoformat()\n\n    user = {\n        'id': user_id,\n        'userId': user_id,  # Partition key\n        'email': email,\n        'name': name,\n        'createdAt': now,\n        'updatedAt': now\n    }\n\n    return users_container.create_item(user)\n\n\ndef get_user(user_id: str) -> Optional[dict]:\n    try:\n        return users_container.read_item(item=user_id, partition_key=user_id)\n    except CosmosResourceNotFoundError:\n        return None\n\n\ndef query_users(email_domain: str) -> List[dict]:\n    query = \"SELECT * FROM c WHERE CONTAINS(c.email, @domain)\"\n    parameters = [{'name': '@domain', 'value': email_domain}]\n\n    return list(users_container.query_items(\n        query=query,\n        parameters=parameters,\n        enable_cross_partition_query=True\n    ))\n\n\ndef update_user(user_id: str, **updates) -> dict:\n    user = get_user(user_id)\n    if not user:\n        raise ValueError('User not found')\n\n    user.update(updates)\n    user['updatedAt'] = datetime.utcnow().isoformat()\n\n    return users_container.replace_item(item=user_id, body=user)\n\n\ndef delete_user(user_id: str) -> None:\n    users_container.delete_item(item=user_id, partition_key=user_id)\n\n\n# Paginated query\ndef get_users_paginated(page_size: int = 10, continuation_token: str = None):\n    query = \"SELECT * FROM c ORDER BY c.createdAt DESC\"\n\n    items = users_container.query_items(\n        query=query,\n        enable_cross_partition_query=True,\n        max_item_count=page_size,\n        continuation_token=continuation_token\n    )\n\n    page = items.by_page()\n    results = list(next(page))\n\n    return {\n        'items': results,\n        'continuation_token': page.continuation_token\n    }\n```\n\n---\n\n## Indexing\n\n### Custom Indexing Policy\n```json\n{\n  \"indexingMode\": \"consistent\",\n  \"automatic\": true,\n  \"includedPaths\": [\n    { \"path\": \"/userId/?\" },\n    { \"path\": \"/status/?\" },\n    { \"path\": \"/createdAt/?\" }\n  ],\n  \"excludedPaths\": [\n    { \"path\": \"/content/*\" },\n    { \"path\": \"/_etag/?\" }\n  ],\n  \"compositeIndexes\": [\n    [\n      { \"path\": \"/userId\", \"order\": \"ascending\" },\n      { \"path\": \"/createdAt\", \"order\": \"descending\" }\n    ]\n  ]\n}\n```\n\n### Create Container with Index\n```typescript\nawait database.containers.createIfNotExists({\n  id: 'orders',\n  partitionKey: { paths: ['/userId'] },\n  indexingPolicy: {\n    indexingMode: 'consistent',\n    includedPaths: [\n      { path: '/userId/?' },\n      { path: '/status/?' },\n      { path: '/createdAt/?' }\n    ],\n    excludedPaths: [\n      { path: '/*' }  // Exclude all by default\n    ]\n  }\n});\n```\n\n---\n\n## Throughput Management\n\n### Provisioned Throughput\n```typescript\n// Container level\nawait database.containers.createIfNotExists({\n  id: 'orders',\n  partitionKey: { paths: ['/userId'] },\n  throughput: 1000  // RU/s\n});\n\n// Scale throughput\nconst container = database.container('orders');\nawait container.throughput.replace(2000);\n```\n\n### Autoscale\n```typescript\nawait database.containers.createIfNotExists({\n  id: 'orders',\n  partitionKey: { paths: ['/userId'] },\n  maxThroughput: 10000  // Auto-scales 10% to 100%\n});\n```\n\n### Serverless\n```typescript\n// No throughput configuration needed\n// Pay per request (good for dev/test, intermittent workloads)\nawait database.containers.createIfNotExists({\n  id: 'orders',\n  partitionKey: { paths: ['/userId'] }\n  // No throughput = serverless\n});\n```\n\n---\n\n## CLI Quick Reference\n\n```bash\n# Azure CLI\naz cosmosdb create --name myaccount --resource-group mygroup\naz cosmosdb sql database create --account-name myaccount --name mydb --resource-group mygroup\naz cosmosdb sql container create \\\n  --account-name myaccount \\\n  --database-name mydb \\\n  --name orders \\\n  --partition-key-path /userId \\\n  --throughput 400\n\n# Query\naz cosmosdb sql query --account-name myaccount --database-name mydb \\\n  --container-name orders --query \"SELECT * FROM c\"\n\n# Keys\naz cosmosdb keys list --name myaccount --resource-group mygroup\naz cosmosdb keys list --name myaccount --resource-group mygroup --type connection-strings\n```\n\n---\n\n## Cost Optimization\n\n| Strategy | Impact |\n|----------|--------|\n| **Right partition key** | Avoid hot partitions (wasted RUs) |\n| **Index only what you query** | Reduce write RU cost |\n| **Use point reads** | 1 RU vs 3+ RU for queries |\n| **Serverless for dev/test** | Pay per request |\n| **Autoscale for production** | Scale down during low traffic |\n| **TTL for temporary data** | Auto-delete old items |\n\n### Time-to-Live (TTL)\n```typescript\n// Enable TTL on container\nawait database.containers.createIfNotExists({\n  id: 'sessions',\n  partitionKey: { paths: ['/userId'] },\n  defaultTtl: 3600  // 1 hour\n});\n\n// Per-item TTL\nconst session = {\n  id: 'session-123',\n  userId: 'user-456',\n  ttl: 1800  // Override: 30 minutes\n};\n```\n\n---\n\n## Anti-Patterns\n\n- **Bad partition key** - Low cardinality causes hot partitions\n- **Cross-partition queries** - Expensive; design for single-partition queries\n- **Over-indexing** - Increases write cost; index only queried paths\n- **Large items** - Max 2MB; store blobs in Azure Blob Storage\n- **Ignoring RU cost** - Monitor and optimize expensive queries\n- **Strong consistency everywhere** - Use Session (default) unless required\n- **No retry logic** - Handle 429 (throttling) with exponential backoff\n- **Missing TTL** - Set TTL for temporary/session data\n"
  },
  {
    "path": "skills/base/SKILL.md",
    "content": "---\nname: base\ndescription: Universal coding patterns, constraints, TDD workflow, atomic todos\nwhen-to-use: Always loaded as foundation for all projects - TDD workflow, simplicity rules, atomic todos\nuser-invocable: false\neffort: medium\n---\n\n# Base Skill - Universal Patterns\n\n## Core Principle\n\nComplexity is the enemy. Every line of code is a liability. The goal is software simple enough that any engineer (or AI) can understand the entire system in one session.\n\n---\n\n## Simplicity Rules\n\nThese limits apply to every file created or modified.\n\n### Function Level\n- **Maximum 20 lines per function** - if longer, decompose IMMEDIATELY\n- **Maximum 3 parameters per function** - if more, use an options object or decompose\n- **Maximum 2 levels of nesting** - flatten with early returns or extract functions\n- **Single responsibility** - each function does exactly one thing\n- **Descriptive names over comments** - if you need a comment to explain what, rename it\n\n### File Level\n- **Maximum 200 lines per file** - if longer, split by responsibility BEFORE continuing\n- **Maximum 10 functions per file** - keeps cognitive load manageable\n- **One export focus per file** - a file should have one primary purpose\n\n### Module Level\n- **Maximum 3 levels of directory nesting** - flat is better than nested\n- **Clear boundaries** - each module has a single public interface\n- **No circular dependencies** - ever\n\n### Enforcement Protocol\n\n**Before completing ANY file:**\n1. Count total lines - if > 200, STOP and split\n2. Count functions - if > 10, STOP and split\n3. Check each function length - if any > 20 lines, STOP and decompose\n4. Check parameter counts - if any > 3, STOP and refactor\n\n**If limits are exceeded during development:**\n```\n⚠️ FILE SIZE VIOLATION DETECTED\n\n[filename] has [X] lines (limit: 200)\n\nSplitting into:\n- [filename-a].ts - [responsibility A]\n- [filename-b].ts - [responsibility B]\n```\n\n**Never defer refactoring.** Fix violations immediately, not \"later\".\n\n---\n\n## Architectural Patterns\n\n### Functional Core, Imperative Shell\n- Pure functions for business logic - no side effects, deterministic\n- Side effects only at boundaries - API calls, database, file system at edges\n- Data in, data out - functions transform data, they don't mutate state\n\n### Composition Over Inheritance\n- No inheritance deeper than 1 level - prefer interfaces/composition\n- Small, composable utilities - build complex from simple\n- Dependency injection - pass dependencies, don't import them directly\n\n### Error Handling\n- Fail fast, fail loud - errors surface immediately\n- No silent failures - every error is logged or thrown\n- Design APIs where misuse is impossible\n\n---\n\n## Testing Philosophy\n\n- **100% coverage on business logic** - the functional core\n- **Integration tests for boundaries** - API endpoints, database operations\n- **No untested code merges** - CI blocks without passing tests\n- **Test behavior, not implementation** - tests survive refactoring\n- **Each test runs in isolation** - no interdependence\n\n---\n\n## Anti-Patterns (Never Do This)\n\n- ❌ Global state\n- ❌ Magic numbers/strings - use named constants\n- ❌ Deep nesting - flatten or extract\n- ❌ Long parameter lists - use objects\n- ❌ Comments explaining \"what\" - code should be self-documenting\n- ❌ Dead code - delete it, git remembers\n- ❌ Copy-paste duplication - extract to shared function\n- ❌ God objects/files - split by responsibility\n- ❌ Circular dependencies\n- ❌ Premature optimization\n- ❌ Large PRs - small, focused changes only\n- ❌ Mixing refactoring with features - separate commits\n\n---\n\n## Documentation Structure\n\nEvery project must have clear separation between code docs and project specs:\n\n```\nproject/\n├── docs/                      # Code documentation\n│   ├── architecture.md        # System design decisions\n│   ├── api.md                 # API reference (if applicable)\n│   └── setup.md               # Development setup guide\n├── _project_specs/            # Project specifications\n│   ├── overview.md            # Project vision and goals\n│   ├── features/              # Feature specifications\n│   │   ├── feature-a.md\n│   │   └── feature-b.md\n│   ├── todos/                 # Atomic todos tracking\n│   │   ├── active.md          # Current sprint/focus\n│   │   ├── backlog.md         # Future work\n│   │   └── completed.md       # Done items (for reference)\n│   ├── session/               # Session state (see session-management.md)\n│   │   ├── current-state.md   # Live session state\n│   │   ├── decisions.md       # Key decisions log\n│   │   ├── code-landmarks.md  # Important code locations\n│   │   └── archive/           # Past session summaries\n│   └── prompts/               # LLM prompt specifications (if AI-first)\n└── CLAUDE.md                  # Claude instructions (references skills)\n```\n\n### What Goes Where\n\n| Location | Content |\n|----------|---------|\n| `docs/` | Technical documentation, API refs, setup guides |\n| `_project_specs/` | Business logic, features, requirements, todos |\n| `_project_specs/session/` | Session state, decisions, context for resumability |\n| `CLAUDE.md` | Claude-specific instructions and skill references |\n\n---\n\n## Atomic Todos\n\nAll work is tracked as atomic todos with validation and test criteria.\n\n### Todo Format (Required)\n```markdown\n## [TODO-001] Short descriptive title\n\n**Status:** pending | in-progress | blocked | done\n**Priority:** high | medium | low\n**Estimate:** XS | S | M | L | XL\n\n### Description\nOne paragraph describing what needs to be done.\n\n### Acceptance Criteria\n- [ ] Criterion 1 - specific, measurable\n- [ ] Criterion 2 - specific, measurable\n\n### Validation\nHow to verify this is complete:\n- Manual: [steps to manually test]\n- Automated: [test file/command that validates this]\n\n### Test Cases\n| Input | Expected Output | Notes |\n|-------|-----------------|-------|\n| ... | ... | ... |\n\n### Dependencies\n- Depends on: [TODO-xxx] (if any)\n- Blocks: [TODO-yyy] (if any)\n\n### TDD Execution Log\n| Phase | Command | Result | Timestamp |\n|-------|---------|--------|-----------|\n| RED | `[test command]` | - | - |\n| GREEN | `[test command]` | - | - |\n| VALIDATE | `[lint && typecheck && test --coverage]` | - | - |\n| COMPLETE | Moved to completed.md | - | - |\n```\n\n### Todo Rules\n1. **Atomic** - Each todo is a single, completable unit of work\n2. **Testable** - Every todo has validation criteria and test cases\n3. **Sized** - If larger than \"M\", break it down further\n4. **Independent** - Minimize dependencies between todos\n5. **Tracked** - Move between active.md → completed.md when done\n\n### Todo Execution Workflow (TDD - Mandatory)\n\n**Every todo MUST follow this exact workflow. No exceptions.**\n\n```\n┌─────────────────────────────────────────────────────────────┐\n│  1. RED: Write Tests First                                  │\n│     └─ Create test file(s) based on Test Cases table        │\n│     └─ Tests should cover all acceptance criteria           │\n│     └─ Run tests → ALL MUST FAIL (proves tests are valid)   │\n├─────────────────────────────────────────────────────────────┤\n│  2. GREEN: Implement the Feature                            │\n│     └─ Write minimum code to make tests pass                │\n│     └─ Follow simplicity rules (20 lines/function, etc.)    │\n│     └─ Run tests → ALL MUST PASS                            │\n├─────────────────────────────────────────────────────────────┤\n│  3. VALIDATE: Quality Gates                                 │\n│     └─ Run linter (auto-fix if possible)                    │\n│     └─ Run type checker (tsc/mypy/pyright)                  │\n│     └─ Run full test suite with coverage                    │\n│     └─ Verify coverage threshold (≥80%)                     │\n├─────────────────────────────────────────────────────────────┤\n│  4. COMPLETE: Mark Done                                     │\n│     └─ Only after ALL validations pass                      │\n│     └─ Move todo to completed.md                            │\n│     └─ Checkpoint session state                             │\n└─────────────────────────────────────────────────────────────┘\n```\n\n#### Execution Commands by Stack\n\n**Node.js/TypeScript:**\n```bash\n# 1. RED - Run tests (expect failures)\nnpm test -- --grep \"todo-description\"\n\n# 2. GREEN - Run tests (expect pass)\nnpm test -- --grep \"todo-description\"\n\n# 3. VALIDATE - Full quality check\nnpm run lint && npm run typecheck && npm test -- --coverage\n```\n\n**Python:**\n```bash\n# 1. RED - Run tests (expect failures)\npytest -k \"todo_description\" -v\n\n# 2. GREEN - Run tests (expect pass)\npytest -k \"todo_description\" -v\n\n# 3. VALIDATE - Full quality check\nruff check . && mypy . && pytest --cov --cov-fail-under=80\n```\n\n**React/Next.js:**\n```bash\n# 1. RED - Run tests (expect failures)\nnpm test -- --testPathPattern=\"ComponentName\"\n\n# 2. GREEN - Run tests (expect pass)\nnpm test -- --testPathPattern=\"ComponentName\"\n\n# 3. VALIDATE - Full quality check\nnpm run lint && npm run typecheck && npm test -- --coverage --watchAll=false\n```\n\n#### Blocking Conditions\n\n**NEVER mark a todo as complete if:**\n- ❌ Tests were not written first (skipped RED phase)\n- ❌ Tests did not fail initially (invalid tests)\n- ❌ Any test is failing\n- ❌ Linter has errors (warnings may be acceptable)\n- ❌ Type checker has errors\n- ❌ Coverage dropped below threshold\n\n**If blocked by failures:**\n```markdown\n## [TODO-042] - BLOCKED\n\n**Blocking Reason:** [Lint error in X / Test failure in Y / Coverage at 75%]\n**Action Required:** [Specific fix needed]\n```\n\n### Bug Fix Workflow (TDD - Mandatory)\n\n**When a user reports a bug, NEVER jump to fixing it directly.**\n\n```\n┌─────────────────────────────────────────────────────────────┐\n│  1. DIAGNOSE: Identify the Test Gap                         │\n│     └─ Run existing tests - do any fail?                    │\n│     └─ If tests pass but bug exists → tests are incomplete  │\n│     └─ Document: \"Test gap: [what was missed]\"              │\n├─────────────────────────────────────────────────────────────┤\n│  2. RED: Write a Failing Test for the Bug                   │\n│     └─ Create test that reproduces the exact bug            │\n│     └─ Test should FAIL with current code                   │\n│     └─ This proves the test catches the bug                 │\n├─────────────────────────────────────────────────────────────┤\n│  3. GREEN: Fix the Bug                                      │\n│     └─ Write minimum code to make the test pass             │\n│     └─ Run test → must PASS now                             │\n├─────────────────────────────────────────────────────────────┤\n│  4. VALIDATE: Full Quality Check                            │\n│     └─ Run ALL tests (not just the new one)                 │\n│     └─ Run linter and type checker                          │\n│     └─ Verify no regression in coverage                     │\n└─────────────────────────────────────────────────────────────┘\n```\n\n#### Bug Report Todo Format\n\n```markdown\n## [BUG-001] Short description of the bug\n\n**Status:** pending\n**Priority:** high\n**Reported:** [how user reported it / reproduction steps]\n\n### Bug Description\nWhat is happening vs. what should happen.\n\n### Reproduction Steps\n1. Step one\n2. Step two\n3. Observe: [incorrect behavior]\n4. Expected: [correct behavior]\n\n### Test Gap Analysis\n- Existing test coverage: [list relevant test files]\n- Gap identified: [what the tests missed]\n- New test needed: [describe the test to add]\n\n### Test Cases for Bug\n| Input | Current (Bug) | Expected (Fixed) |\n|-------|---------------|------------------|\n| ... | ... | ... |\n\n### TDD Execution Log\n| Phase | Command | Result | Timestamp |\n|-------|---------|--------|-----------|\n| DIAGNOSE | `npm test` | All pass (gap!) | - |\n| RED | `npm test -- --grep \"bug description\"` | 1 test failed ✓ | - |\n| GREEN | `npm test -- --grep \"bug description\"` | 1 test passed ✓ | - |\n| VALIDATE | `npm run lint && npm run typecheck && npm test -- --coverage` | Pass ✓ | - |\n```\n\n#### Bug Fix Anti-Patterns\n\n- ❌ **Fixing without a test** - Bug will likely return\n- ❌ **Writing test after fix** - Can't prove test catches the bug\n- ❌ **Skipping test gap analysis** - Misses why tests didn't catch it\n- ❌ **Only testing the fix** - Must run full test suite for regressions\n\n### Example Atomic Todo\n```markdown\n## [TODO-042] Add email validation to signup form\n\n**Status:** pending\n**Priority:** high\n**Estimate:** S\n\n### Description\nValidate email format on the signup form before submission. Show inline error if invalid.\n\n### Acceptance Criteria\n- [ ] Email field shows error for invalid format\n- [ ] Error clears when user fixes the email\n- [ ] Form cannot submit with invalid email\n- [ ] Valid emails pass through without error\n\n### Validation\n- Manual: Enter \"notanemail\" in signup form, verify error appears\n- Automated: `npm test -- --grep \"email validation\"`\n\n### Test Cases\n| Input | Expected Output | Notes |\n|-------|-----------------|-------|\n| user@example.com | Valid, no error | Standard email |\n| user@sub.example.com | Valid, no error | Subdomain |\n| notanemail | Invalid, show error | No @ symbol |\n| user@ | Invalid, show error | No domain |\n| @example.com | Invalid, show error | No local part |\n\n### Dependencies\n- Depends on: [TODO-041] Signup form component\n- Blocks: [TODO-045] Signup flow integration test\n\n### TDD Execution Log\n| Phase | Command | Result | Timestamp |\n|-------|---------|--------|-----------|\n| RED | `npm test -- --grep \"email validation\"` | 5 tests failed ✓ | - |\n| GREEN | `npm test -- --grep \"email validation\"` | 5 tests passed ✓ | - |\n| VALIDATE | `npm run lint && npm run typecheck && npm test -- --coverage` | Pass, 84% coverage ✓ | - |\n| COMPLETE | Moved to completed.md | ✓ | - |\n```\n\n---\n\n## Credentials Management \nWhen a project needs API keys, always ask the user for their centralized access file first.\n\n### Workflow\n```\n1. Ask: \"Do you have an access keys file? (e.g., ~/Documents/Access.txt)\"\n2. Read and parse the file for known key patterns\n3. Validate keys are working\n4. Create project .env with found keys\n5. Report missing keys and where to get them\n```\n\n### Key Patterns to Detect\n| Service | Pattern | Env Variable |\n|---------|---------|--------------|\n| OpenAI | `sk-proj-*` | `OPENAI_API_KEY` |\n| Claude | `sk-ant-*` | `ANTHROPIC_API_KEY` |\n| Render | `rnd_*` | `RENDER_API_KEY` |\n| Replicate | `r8_*` | `REPLICATE_API_TOKEN` |\n| Reddit | client_id + secret | `REDDIT_CLIENT_ID`, `REDDIT_CLIENT_SECRET` |\n\nSee `credentials.md` for full parsing logic and validation commands.\n\n---\n\n## Security \nEvery project must meet these security requirements. See `security.md` skill for detailed patterns.\n\n### Essential Security Checks\n1. **No secrets in code** - Use environment variables, never commit secrets\n2. **`.env` in `.gitignore`** - Always, no exceptions\n3. **No secrets in client-exposed env vars** - Never use `VITE_*`, `NEXT_PUBLIC_*` for secrets\n4. **Validate all input** - Use Zod/Pydantic at API boundaries\n5. **Parameterized queries only** - No string concatenation for SQL\n6. **Hash passwords properly** - bcrypt with 12+ rounds\n7. **Dependency scanning** - npm audit / safety check must pass\n\n### Required Files\n- `.gitignore` with secrets patterns\n- `.env.example` with all required vars (no values)\n- `scripts/security-check.sh` for pre-commit validation\n\n### Security in CI\nEvery PR must pass:\n- Secret scanning (detect-secrets / trufflehog)\n- Dependency audit (npm audit / safety)\n- Static analysis (CodeQL)\n\n---\n\n## Quality Gates \n### Coverage Threshold\n- **Minimum 80% code coverage** - CI must fail below this\n- Business logic (core/) should aim for 100%\n- Integration tests cover boundaries\n\n### Pre-Commit Hooks\nAll projects must have pre-commit hooks that run:\n1. Linting (auto-fix where possible)\n2. Type checking\n3. Tests (at minimum, affected tests)\n\nThis catches issues before they hit CI, saving time and keeping the main branch clean.\n\n---\n\n## Session Management \nMaintain context for resumability. See `session-management.md` for full details.\n\n### Core Rule: Checkpoint at Natural Breakpoints\n\nAfter completing any task, ask:\n1. **Decision made?** → Log to `_project_specs/session/decisions.md`\n2. **>10 tool calls?** → Full checkpoint to `current-state.md`\n3. **Major feature done?** → Archive to `session/archive/`\n4. **Otherwise** → Quick update to `current-state.md`\n\n### Session Start\n1. Read `_project_specs/session/current-state.md`\n2. Check `_project_specs/todos/active.md`\n3. Continue from documented \"Next Steps\"\n\n### Session End\n1. Archive current session\n2. Update `current-state.md` with handoff notes\n3. Ensure next steps are specific and actionable\n\n---\n\n## Response Format\n\nWhen implementing features (following TDD):\n1. **Clarify requirements** if ambiguous\n2. **Propose structure** - outline before code\n3. **Write tests FIRST** - based on test cases table (RED phase)\n4. **Run tests to verify they fail** - proves tests are valid\n5. **Implement minimum code** to make tests pass (GREEN phase)\n6. **Run full validation** - lint, typecheck, coverage (VALIDATE phase)\n7. **Flag complexity** - warn if approaching limits\n8. **Checkpoint after completing** - update session state, log TDD execution\n\n**TDD is non-negotiable.** Tests must exist and fail before any implementation begins.\n\nWhen you notice code violating these rules, **stop and refactor** before continuing.\n\n---\n\n## Automatic TDD Loops (via Stop Hook)\n\nThe Stop hook in `.claude/settings.json` runs tests after each response. If tests fail, the failure output is fed back to Claude automatically. No manual intervention needed.\n\nSee the `iterative-development` skill for setup details.\n\n### How It Works\n\n1. You ask Claude to implement something\n2. Claude writes tests + implementation\n3. Stop hook runs tests automatically\n4. If failures: output fed back to Claude, it fixes and tries again\n5. If all pass: Claude stops, work is done\n\n### When It Activates\n\n| Task Type | TDD Loop? |\n|-----------|-----------|\n| New feature | Yes - tests run after each response |\n| Bug fix | Yes - write failing test first |\n| Refactoring | Yes - existing tests catch regressions |\n| Simple question/explanation | No - no code changes |\n| One-line fix | No - trivial change |\n"
  },
  {
    "path": "skills/cloudflare-d1/SKILL.md",
    "content": "---\nname: cloudflare-d1\ndescription: Cloudflare D1 SQLite database with Workers, Drizzle ORM, migrations\nwhen-to-use: When working with Cloudflare D1 or Workers\nuser-invocable: false\npaths: [\"wrangler.toml\", \"src/worker*\", \"**/d1/**\"]\neffort: medium\n---\n\n# Cloudflare D1 Skill\n\n\nCloudflare D1 is a serverless SQLite database designed for Cloudflare Workers with global distribution and zero cold starts.\n\n**Sources:** [D1 Docs](https://developers.cloudflare.com/d1/) | [Drizzle + D1](https://orm.drizzle.team/docs/connect-cloudflare-d1) | [Wrangler CLI](https://developers.cloudflare.com/workers/wrangler/)\n\n---\n\n## Core Principle\n\n**SQLite at the edge, migrations in version control, Drizzle for type safety.**\n\nD1 brings SQLite's simplicity to serverless. Design for horizontal scale (multiple small databases) rather than vertical (one large database). Use Drizzle ORM for type-safe queries and migrations.\n\n---\n\n## D1 Stack\n\n| Component | Purpose |\n|-----------|---------|\n| **D1** | Serverless SQLite database |\n| **Workers** | Edge runtime for your application |\n| **Wrangler** | CLI for development and deployment |\n| **Drizzle ORM** | Type-safe ORM with migrations |\n| **Drizzle Kit** | Migration tooling |\n| **Hono** | Lightweight web framework (optional) |\n\n---\n\n## Project Setup\n\n### Create Worker Project\n```bash\n# Create new project\nnpm create cloudflare@latest my-app -- --template \"worker-typescript\"\ncd my-app\n\n# Install dependencies\nnpm install drizzle-orm\nnpm install -D drizzle-kit\n```\n\n### Create D1 Database\n```bash\n# Create database (creates both local and remote)\nnpx wrangler d1 create my-database\n\n# Output:\n# [[d1_databases]]\n# binding = \"DB\"\n# database_name = \"my-database\"\n# database_id = \"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx\"\n```\n\n### Configure wrangler.toml\n```toml\nname = \"my-app\"\nmain = \"src/index.ts\"\ncompatibility_date = \"2024-01-01\"\n\n[[d1_databases]]\nbinding = \"DB\"\ndatabase_name = \"my-database\"\ndatabase_id = \"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx\"\nmigrations_dir = \"drizzle\"\nmigrations_table = \"drizzle_migrations\"\n```\n\n### Generate TypeScript Types\n```bash\n# Generate env types from wrangler.toml\nnpx wrangler types\n\n# Creates worker-configuration.d.ts:\n# interface Env {\n#   DB: D1Database;\n# }\n```\n\n---\n\n## Drizzle ORM Setup\n\n### Schema Definition\n```typescript\n// src/db/schema.ts\nimport { sqliteTable, text, integer, real, blob } from 'drizzle-orm/sqlite-core';\nimport { sql } from 'drizzle-orm';\n\nexport const users = sqliteTable('users', {\n  id: integer('id').primaryKey({ autoIncrement: true }),\n  email: text('email').notNull().unique(),\n  name: text('name').notNull(),\n  role: text('role', { enum: ['user', 'admin'] }).default('user'),\n  createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`),\n  updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`)\n});\n\nexport const posts = sqliteTable('posts', {\n  id: integer('id').primaryKey({ autoIncrement: true }),\n  title: text('title').notNull(),\n  content: text('content'),\n  authorId: integer('author_id').references(() => users.id),\n  published: integer('published', { mode: 'boolean' }).default(false),\n  viewCount: integer('view_count').default(0),\n  createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`)\n});\n\nexport const tags = sqliteTable('tags', {\n  id: integer('id').primaryKey({ autoIncrement: true }),\n  name: text('name').notNull().unique()\n});\n\nexport const postTags = sqliteTable('post_tags', {\n  postId: integer('post_id').references(() => posts.id),\n  tagId: integer('tag_id').references(() => tags.id)\n});\n\n// Type exports\nexport type User = typeof users.$inferSelect;\nexport type NewUser = typeof users.$inferInsert;\nexport type Post = typeof posts.$inferSelect;\nexport type NewPost = typeof posts.$inferInsert;\n```\n\n### Drizzle Config\n```typescript\n// drizzle.config.ts\nimport { defineConfig } from 'drizzle-kit';\n\nexport default defineConfig({\n  schema: './src/db/schema.ts',\n  out: './drizzle',\n  dialect: 'sqlite',\n  driver: 'd1-http',\n  dbCredentials: {\n    accountId: process.env.CLOUDFLARE_ACCOUNT_ID!,\n    databaseId: process.env.CLOUDFLARE_DATABASE_ID!,\n    token: process.env.CLOUDFLARE_D1_TOKEN!\n  }\n});\n```\n\n### Database Client\n```typescript\n// src/db/index.ts\nimport { drizzle } from 'drizzle-orm/d1';\nimport * as schema from './schema';\n\nexport function createDb(d1: D1Database) {\n  return drizzle(d1, { schema });\n}\n\nexport type Database = ReturnType<typeof createDb>;\nexport * from './schema';\n```\n\n---\n\n## Migration Workflow\n\n### Generate Migration\n```bash\n# Generate migration from schema changes\nnpx drizzle-kit generate\n\n# Output: drizzle/0000_initial.sql\n```\n\n### Apply Migrations Locally\n```bash\n# Apply to local D1\nnpx wrangler d1 migrations apply my-database --local\n\n# Or via Drizzle\nnpx drizzle-kit migrate\n```\n\n### Apply Migrations to Production\n```bash\n# Apply to remote D1\nnpx wrangler d1 migrations apply my-database --remote\n\n# Preview first (dry run)\nnpx wrangler d1 migrations apply my-database --remote --dry-run\n```\n\n### Migration File Example\n```sql\n-- drizzle/0000_initial.sql\nCREATE TABLE `users` (\n  `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,\n  `email` text NOT NULL,\n  `name` text NOT NULL,\n  `role` text DEFAULT 'user',\n  `created_at` text DEFAULT CURRENT_TIMESTAMP,\n  `updated_at` text DEFAULT CURRENT_TIMESTAMP\n);\n\nCREATE UNIQUE INDEX `users_email_unique` ON `users` (`email`);\n\nCREATE TABLE `posts` (\n  `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,\n  `title` text NOT NULL,\n  `content` text,\n  `author_id` integer REFERENCES `users`(`id`),\n  `published` integer DEFAULT false,\n  `view_count` integer DEFAULT 0,\n  `created_at` text DEFAULT CURRENT_TIMESTAMP\n);\n```\n\n---\n\n## Worker Implementation\n\n### Basic Worker with Hono\n```typescript\n// src/index.ts\nimport { Hono } from 'hono';\nimport { createDb, users, posts } from './db';\nimport { eq, desc } from 'drizzle-orm';\n\ntype Bindings = {\n  DB: D1Database;\n};\n\nconst app = new Hono<{ Bindings: Bindings }>();\n\n// Middleware to inject db\napp.use('*', async (c, next) => {\n  c.set('db', createDb(c.env.DB));\n  await next();\n});\n\n// List users\napp.get('/users', async (c) => {\n  const db = c.get('db');\n  const allUsers = await db.select().from(users);\n  return c.json(allUsers);\n});\n\n// Get user by ID\napp.get('/users/:id', async (c) => {\n  const db = c.get('db');\n  const id = parseInt(c.req.param('id'));\n\n  const user = await db.select().from(users).where(eq(users.id, id)).get();\n\n  if (!user) {\n    return c.json({ error: 'User not found' }, 404);\n  }\n  return c.json(user);\n});\n\n// Create user\napp.post('/users', async (c) => {\n  const db = c.get('db');\n  const body = await c.req.json<{ email: string; name: string }>();\n\n  const result = await db.insert(users).values({\n    email: body.email,\n    name: body.name\n  }).returning();\n\n  return c.json(result[0], 201);\n});\n\n// Update user\napp.put('/users/:id', async (c) => {\n  const db = c.get('db');\n  const id = parseInt(c.req.param('id'));\n  const body = await c.req.json<Partial<{ email: string; name: string }>>();\n\n  const result = await db.update(users)\n    .set({ ...body, updatedAt: new Date().toISOString() })\n    .where(eq(users.id, id))\n    .returning();\n\n  if (result.length === 0) {\n    return c.json({ error: 'User not found' }, 404);\n  }\n  return c.json(result[0]);\n});\n\n// Delete user\napp.delete('/users/:id', async (c) => {\n  const db = c.get('db');\n  const id = parseInt(c.req.param('id'));\n\n  const result = await db.delete(users).where(eq(users.id, id)).returning();\n\n  if (result.length === 0) {\n    return c.json({ error: 'User not found' }, 404);\n  }\n  return c.json({ deleted: true });\n});\n\nexport default app;\n```\n\n### Raw D1 API (Without ORM)\n```typescript\n// src/index.ts\nexport default {\n  async fetch(request: Request, env: Env): Promise<Response> {\n    const url = new URL(request.url);\n\n    if (url.pathname === '/users' && request.method === 'GET') {\n      const { results } = await env.DB.prepare(\n        'SELECT * FROM users ORDER BY created_at DESC'\n      ).all();\n      return Response.json(results);\n    }\n\n    if (url.pathname === '/users' && request.method === 'POST') {\n      const body = await request.json() as { email: string; name: string };\n\n      const result = await env.DB.prepare(\n        'INSERT INTO users (email, name) VALUES (?, ?) RETURNING *'\n      ).bind(body.email, body.name).first();\n\n      return Response.json(result, { status: 201 });\n    }\n\n    return new Response('Not Found', { status: 404 });\n  }\n};\n```\n\n---\n\n## Query Patterns\n\n### Select Queries\n```typescript\nimport { eq, and, or, like, gt, desc, asc, count, sql } from 'drizzle-orm';\n\n// Basic select\nconst allPosts = await db.select().from(posts);\n\n// Select specific columns\nconst titles = await db.select({ id: posts.id, title: posts.title }).from(posts);\n\n// Where clause\nconst published = await db.select().from(posts).where(eq(posts.published, true));\n\n// Multiple conditions\nconst recentPublished = await db.select().from(posts).where(\n  and(\n    eq(posts.published, true),\n    gt(posts.createdAt, '2024-01-01')\n  )\n);\n\n// OR conditions\nconst featured = await db.select().from(posts).where(\n  or(\n    eq(posts.viewCount, 1000),\n    like(posts.title, '%featured%')\n  )\n);\n\n// Order and limit\nconst topPosts = await db.select()\n  .from(posts)\n  .orderBy(desc(posts.viewCount))\n  .limit(10);\n\n// Pagination\nconst page2 = await db.select()\n  .from(posts)\n  .orderBy(desc(posts.createdAt))\n  .limit(10)\n  .offset(10);\n\n// Count\nconst postCount = await db.select({ count: count() }).from(posts);\n```\n\n### Joins\n```typescript\n// Inner join\nconst postsWithAuthors = await db.select({\n  post: posts,\n  author: users\n})\n.from(posts)\n.innerJoin(users, eq(posts.authorId, users.id));\n\n// Left join\nconst allPostsWithAuthors = await db.select()\n  .from(posts)\n  .leftJoin(users, eq(posts.authorId, users.id));\n\n// Many-to-many via junction table\nconst postsWithTags = await db.select({\n  post: posts,\n  tag: tags\n})\n.from(posts)\n.leftJoin(postTags, eq(posts.id, postTags.postId))\n.leftJoin(tags, eq(postTags.tagId, tags.id));\n```\n\n### Insert, Update, Delete\n```typescript\n// Insert single\nconst newUser = await db.insert(users).values({\n  email: 'user@example.com',\n  name: 'John Doe'\n}).returning();\n\n// Insert multiple\nawait db.insert(users).values([\n  { email: 'a@test.com', name: 'Alice' },\n  { email: 'b@test.com', name: 'Bob' }\n]);\n\n// Upsert (insert or update on conflict)\nawait db.insert(users)\n  .values({ email: 'user@test.com', name: 'New Name' })\n  .onConflictDoUpdate({\n    target: users.email,\n    set: { name: 'New Name' }\n  });\n\n// Update\nawait db.update(posts)\n  .set({ published: true })\n  .where(eq(posts.id, 1));\n\n// Update with increment\nawait db.update(posts)\n  .set({ viewCount: sql`${posts.viewCount} + 1` })\n  .where(eq(posts.id, 1));\n\n// Delete\nawait db.delete(posts).where(eq(posts.id, 1));\n```\n\n### Transactions\n```typescript\n// D1 supports transactions via batch\nconst results = await db.batch([\n  db.insert(users).values({ email: 'a@test.com', name: 'A' }),\n  db.insert(users).values({ email: 'b@test.com', name: 'B' }),\n  db.update(posts).set({ published: true }).where(eq(posts.id, 1))\n]);\n\n// Raw D1 batch\nconst batchResults = await env.DB.batch([\n  env.DB.prepare('INSERT INTO users (email, name) VALUES (?, ?)').bind('a@test.com', 'A'),\n  env.DB.prepare('INSERT INTO users (email, name) VALUES (?, ?)').bind('b@test.com', 'B')\n]);\n```\n\n---\n\n## Local Development\n\n### Start Dev Server\n```bash\n# Local development with D1\nnpx wrangler dev\n\n# With specific port\nnpx wrangler dev --port 8787\n```\n\n### Database Management\n```bash\n# Execute SQL locally\nnpx wrangler d1 execute my-database --local --command \"SELECT * FROM users\"\n\n# Execute SQL file\nnpx wrangler d1 execute my-database --local --file ./seed.sql\n\n# Open SQLite shell\nnpx wrangler d1 execute my-database --local --command \".tables\"\n```\n\n### Drizzle Studio\n```bash\n# Run Drizzle Studio for visual DB management\nnpx drizzle-kit studio\n```\n\n### Seed Data\n```sql\n-- seed.sql\nINSERT INTO users (email, name, role) VALUES\n  ('admin@example.com', 'Admin User', 'admin'),\n  ('user@example.com', 'Test User', 'user');\n\nINSERT INTO posts (title, content, author_id, published) VALUES\n  ('First Post', 'Hello World!', 1, true),\n  ('Draft Post', 'Work in progress...', 1, false);\n```\n\n```bash\n# Seed local database\nnpx wrangler d1 execute my-database --local --file ./seed.sql\n```\n\n---\n\n## Multi-Environment Setup\n\n### wrangler.toml\n```toml\nname = \"my-app\"\nmain = \"src/index.ts\"\ncompatibility_date = \"2024-01-01\"\n\n# Development\n[env.dev]\n[[env.dev.d1_databases]]\nbinding = \"DB\"\ndatabase_name = \"my-database-dev\"\ndatabase_id = \"dev-database-id\"\n\n# Staging\n[env.staging]\n[[env.staging.d1_databases]]\nbinding = \"DB\"\ndatabase_name = \"my-database-staging\"\ndatabase_id = \"staging-database-id\"\n\n# Production\n[env.production]\n[[env.production.d1_databases]]\nbinding = \"DB\"\ndatabase_name = \"my-database-prod\"\ndatabase_id = \"prod-database-id\"\n```\n\n### Deploy to Environments\n```bash\n# Deploy to staging\nnpx wrangler deploy --env staging\n\n# Deploy to production\nnpx wrangler deploy --env production\n\n# Apply migrations to staging\nnpx wrangler d1 migrations apply my-database-staging --remote --env staging\n```\n\n---\n\n## Testing\n\n### Integration Tests\n```typescript\n// tests/api.test.ts\nimport { unstable_dev } from 'wrangler';\nimport type { UnstableDevWorker } from 'wrangler';\nimport { describe, beforeAll, afterAll, it, expect } from 'vitest';\n\ndescribe('API', () => {\n  let worker: UnstableDevWorker;\n\n  beforeAll(async () => {\n    worker = await unstable_dev('src/index.ts', {\n      experimental: { disableExperimentalWarning: true }\n    });\n  });\n\n  afterAll(async () => {\n    await worker.stop();\n  });\n\n  it('should list users', async () => {\n    const res = await worker.fetch('/users');\n    expect(res.status).toBe(200);\n    const data = await res.json();\n    expect(Array.isArray(data)).toBe(true);\n  });\n\n  it('should create user', async () => {\n    const res = await worker.fetch('/users', {\n      method: 'POST',\n      headers: { 'Content-Type': 'application/json' },\n      body: JSON.stringify({ email: 'test@test.com', name: 'Test' })\n    });\n    expect(res.status).toBe(201);\n  });\n});\n```\n\n---\n\n## CLI Quick Reference\n\n```bash\n# Database\nwrangler d1 create <name>                    # Create database\nwrangler d1 list                             # List databases\nwrangler d1 info <name>                      # Database info\nwrangler d1 delete <name>                    # Delete database\n\n# Migrations\nwrangler d1 migrations list <name>           # List migrations\nwrangler d1 migrations apply <name> --local  # Apply locally\nwrangler d1 migrations apply <name> --remote # Apply to production\n\n# SQL execution\nwrangler d1 execute <name> --command \"SQL\"   # Run SQL\nwrangler d1 execute <name> --file ./file.sql # Run SQL file\nwrangler d1 execute <name> --local           # Run on local\nwrangler d1 execute <name> --remote          # Run on production\n\n# Development\nwrangler dev                                 # Start local server\nwrangler types                               # Generate TypeScript types\nwrangler deploy                              # Deploy to production\n\n# Drizzle\ndrizzle-kit generate                         # Generate migrations\ndrizzle-kit migrate                          # Apply migrations\ndrizzle-kit studio                           # Open Drizzle Studio\ndrizzle-kit push                             # Push schema (dev only)\n```\n\n---\n\n## D1 Limits & Considerations\n\n| Limit | Value |\n|-------|-------|\n| **Database size** | 10 GB max |\n| **Row size** | 1 MB max |\n| **SQL statement** | 100 KB max |\n| **Batch size** | 1000 statements |\n| **Reads per day (free)** | 5 million |\n| **Writes per day (free)** | 100,000 |\n\n---\n\n## Anti-Patterns\n\n- **Single large database** - Design for multiple smaller databases (per-tenant)\n- **No migrations** - Always version control schema changes\n- **Raw SQL everywhere** - Use Drizzle for type safety\n- **No connection to remote** - Always test against real D1 before deploy\n- **Large blobs in D1** - Use R2 for file storage\n- **Complex joins** - D1 is SQLite; keep queries simple\n- **No batching** - Use batch for multiple operations\n- **Ignoring limits** - Monitor usage on free tier\n"
  },
  {
    "path": "skills/code-deduplication/SKILL.md",
    "content": "---\nname: code-deduplication\ndescription: Prevent semantic code duplication with capability index and check-before-write\nwhen-to-use: Before creating new utility functions or shared code\nuser-invocable: false\neffort: medium\n---\n\n# Code Deduplication Skill\n\n\n**Purpose:** Prevent semantic duplication and code bloat. Maintain a capability index so Claude always knows what exists before writing something new.\n\n---\n\n## Core Philosophy\n\n```\n┌─────────────────────────────────────────────────────────────────┐\n│  CHECK BEFORE YOU WRITE                                         │\n│  ─────────────────────────────────────────────────────────────  │\n│  AI doesn't copy/paste - it reimplements.                       │\n│  The problem isn't duplicate code, it's duplicate PURPOSE.      │\n│                                                                 │\n│  Before writing ANY new function:                               │\n│  1. Check CODE_INDEX.md for existing capabilities               │\n│  2. Search codebase for similar functionality                   │\n│  3. Extend existing code if possible                            │\n│  4. Only create new if nothing suitable exists                  │\n├─────────────────────────────────────────────────────────────────┤\n│  AFTER WRITING: Update the index immediately.                   │\n│  PERIODICALLY: Run /audit-duplicates to catch overlap.          │\n└─────────────────────────────────────────────────────────────────┘\n```\n\n---\n\n## Code Index Structure\n\nMaintain `CODE_INDEX.md` in project root, organized by **capability** not file location:\n\n```markdown\n# Code Index\n\n*Last updated: [timestamp]*\n*Run `/update-code-index` to regenerate*\n\n## Quick Reference\n\n| Category | Count | Location |\n|----------|-------|----------|\n| Date/Time | 5 functions | src/utils/dates.ts |\n| Validation | 8 functions | src/utils/validate.ts |\n| API Clients | 12 functions | src/api/*.ts |\n| Auth | 6 functions | src/auth/*.ts |\n\n---\n\n## Date/Time Operations\n\n| Function | Location | Does What | Params |\n|----------|----------|-----------|--------|\n| `formatDate()` | utils/dates.ts:15 | Formats Date → \"Jan 15, 2024\" | `(date: Date, format?: string)` |\n| `formatRelative()` | utils/dates.ts:32 | Formats Date → \"2 days ago\" | `(date: Date)` |\n| `parseDate()` | utils/dates.ts:48 | Parses string → Date | `(str: string, format?: string)` |\n| `isExpired()` | auth/tokens.ts:22 | Checks if timestamp past now | `(timestamp: number)` |\n| `addDays()` | utils/dates.ts:61 | Adds days to date | `(date: Date, days: number)` |\n\n---\n\n## Validation\n\n| Function | Location | Does What | Params |\n|----------|----------|-----------|--------|\n| `isEmail()` | utils/validate.ts:10 | Validates email format | `(email: string)` |\n| `isPhone()` | utils/validate.ts:25 | Validates phone with country | `(phone: string, country?: string)` |\n| `isURL()` | utils/validate.ts:42 | Validates URL format | `(url: string)` |\n| `isUUID()` | utils/validate.ts:55 | Validates UUID v4 | `(id: string)` |\n| `sanitizeHTML()` | utils/sanitize.ts:12 | Strips XSS from input | `(html: string)` |\n| `sanitizeSQL()` | utils/sanitize.ts:28 | Escapes SQL special chars | `(input: string)` |\n\n---\n\n## String Operations\n\n| Function | Location | Does What | Params |\n|----------|----------|-----------|--------|\n| `slugify()` | utils/strings.ts:8 | Converts to URL slug | `(str: string)` |\n| `truncate()` | utils/strings.ts:20 | Truncates with ellipsis | `(str: string, len: number)` |\n| `capitalize()` | utils/strings.ts:32 | Capitalizes first letter | `(str: string)` |\n| `pluralize()` | utils/strings.ts:40 | Adds s/es correctly | `(word: string, count: number)` |\n\n---\n\n## API Clients\n\n| Function | Location | Does What | Returns |\n|----------|----------|-----------|---------|\n| `fetchUser()` | api/users.ts:15 | GET /users/:id | `Promise<User>` |\n| `fetchUsers()` | api/users.ts:28 | GET /users with pagination | `Promise<User[]>` |\n| `createUser()` | api/users.ts:45 | POST /users | `Promise<User>` |\n| `updateUser()` | api/users.ts:62 | PATCH /users/:id | `Promise<User>` |\n| `deleteUser()` | api/users.ts:78 | DELETE /users/:id | `Promise<void>` |\n\n---\n\n## Error Handling\n\n| Function/Class | Location | Does What |\n|----------------|----------|-----------|\n| `AppError` | utils/errors.ts:5 | Base error class with code |\n| `ValidationError` | utils/errors.ts:20 | Input validation failures |\n| `NotFoundError` | utils/errors.ts:32 | Resource not found |\n| `handleAsync()` | utils/errors.ts:45 | Wraps async route handlers |\n| `errorMiddleware()` | middleware/error.ts:10 | Express error handler |\n\n---\n\n## Hooks (React)\n\n| Hook | Location | Does What |\n|------|----------|-----------|\n| `useAuth()` | hooks/useAuth.ts | Auth state + login/logout |\n| `useUser()` | hooks/useUser.ts | Current user data |\n| `useDebounce()` | hooks/useDebounce.ts | Debounces value changes |\n| `useLocalStorage()` | hooks/useLocalStorage.ts | Persisted state |\n| `useFetch()` | hooks/useFetch.ts | Data fetching with loading/error |\n\n---\n\n## Components (React)\n\n| Component | Location | Does What |\n|-----------|----------|-----------|\n| `Button` | components/Button.tsx | Styled button with variants |\n| `Input` | components/Input.tsx | Form input with validation |\n| `Modal` | components/Modal.tsx | Dialog overlay |\n| `Toast` | components/Toast.tsx | Notification popup |\n| `Spinner` | components/Spinner.tsx | Loading indicator |\n```\n\n---\n\n## File Header Format\n\nEvery file should have a summary header:\n\n### TypeScript/JavaScript\n\n```typescript\n/**\n * @file User authentication utilities\n * @description Handles login, logout, session management, and token refresh.\n *\n * Key exports:\n * - login(email, password) - Authenticates user, returns tokens\n * - logout() - Clears session and tokens\n * - refreshToken() - Gets new access token\n * - validateSession() - Checks if session is valid\n *\n * @see src/api/auth.ts for API endpoints\n * @see src/hooks/useAuth.ts for React hook\n */\n\nimport { ... } from '...';\n```\n\n### Python\n\n```python\n\"\"\"\nUser authentication utilities.\n\nHandles login, logout, session management, and token refresh.\n\nKey exports:\n    - login(email, password) - Authenticates user, returns tokens\n    - logout() - Clears session and tokens\n    - refresh_token() - Gets new access token\n    - validate_session() - Checks if session is valid\n\nSee Also:\n    - src/api/auth.py for API endpoints\n    - src/services/user.py for user operations\n\"\"\"\n\nfrom typing import ...\n```\n\n---\n\n## Function Documentation\n\nEvery function needs a one-line summary:\n\n### TypeScript\n\n```typescript\n/**\n * Formats a date into a human-readable relative string.\n * Examples: \"2 minutes ago\", \"yesterday\", \"3 months ago\"\n */\nexport function formatRelative(date: Date): string {\n  // ...\n}\n\n/**\n * Validates email format and checks for disposable domains.\n * Returns true for valid non-disposable emails.\n */\nexport function isValidEmail(email: string): boolean {\n  // ...\n}\n```\n\n### Python\n\n```python\ndef format_relative(date: datetime) -> str:\n    \"\"\"Formats a date into a human-readable relative string.\n\n    Examples: \"2 minutes ago\", \"yesterday\", \"3 months ago\"\n    \"\"\"\n    ...\n\ndef is_valid_email(email: str) -> bool:\n    \"\"\"Validates email format and checks for disposable domains.\n\n    Returns True for valid non-disposable emails.\n    \"\"\"\n    ...\n```\n\n---\n\n## Check Before Write Process\n\n### Before Creating ANY New Function\n\n```\n┌─────────────────────────────────────────────────────────────────┐\n│  BEFORE WRITING NEW CODE                                        │\n│  ─────────────────────────────────────────────────────────────  │\n│                                                                 │\n│  1. DESCRIBE what you need in plain English                     │\n│     \"I need to format a date as relative time\"                  │\n│                                                                 │\n│  2. CHECK CODE_INDEX.md                                         │\n│     Search for: date, time, format, relative                    │\n│     → Found: formatRelative() in utils/dates.ts                 │\n│                                                                 │\n│  3. EVALUATE if existing code works                             │\n│     - Does it do what I need? → Use it                          │\n│     - Close but not quite? → Extend it                          │\n│     - Nothing suitable? → Create new, update index              │\n│                                                                 │\n│  4. If extending, check for breaking changes                    │\n│     - Add optional params, don't change existing behavior       │\n│     - Update tests for new functionality                        │\n└─────────────────────────────────────────────────────────────────┘\n```\n\n### Decision Tree\n\n```\nNeed new functionality\n        │\n        ▼\nCheck CODE_INDEX.md for similar\n        │\n        ├─► Found exact match ──────► USE IT\n        │\n        ├─► Found similar ──────────► Can it be extended?\n        │                                   │\n        │                    ┌──────────────┴──────────────┐\n        │                    ▼                             ▼\n        │               Yes: Extend                   No: Create new\n        │               (add params)                  (update index)\n        │\n        └─► Nothing found ──────────► Create new (update index)\n```\n\n---\n\n## Common Duplication Patterns\n\n### Pattern 1: Utility Function Reimplementation\n\n❌ **Bad:** Creating `validateEmail()` when `isEmail()` exists\n```typescript\n// DON'T: This already exists as isEmail()\nfunction validateEmail(email: string): boolean {\n  return /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(email);\n}\n```\n\n✅ **Good:** Check index first, use existing\n```typescript\nimport { isEmail } from '@/utils/validate';\n\nif (isEmail(userInput)) { ... }\n```\n\n### Pattern 2: Slightly Different Versions\n\n❌ **Bad:** Multiple date formatters with slight variations\n```typescript\n// In file A\nfunction formatDate(d: Date) { return d.toLocaleDateString(); }\n\n// In file B\nfunction displayDate(d: Date) { return d.toLocaleDateString('en-US'); }\n\n// In file C\nfunction showDate(d: Date) { return d.toLocaleDateString('en-US', { month: 'short' }); }\n```\n\n✅ **Good:** One function with options\n```typescript\n// utils/dates.ts\nfunction formatDate(d: Date, options?: { locale?: string; format?: 'short' | 'long' }) {\n  const locale = options?.locale ?? 'en-US';\n  const formatOpts = options?.format === 'short'\n    ? { month: 'short', day: 'numeric' }\n    : { month: 'long', day: 'numeric', year: 'numeric' };\n  return d.toLocaleDateString(locale, formatOpts);\n}\n```\n\n### Pattern 3: Inline Logic That Should Be Extracted\n\n❌ **Bad:** Same validation logic scattered across files\n```typescript\n// In signup.ts\nif (!email || !email.includes('@') || email.length < 5) { ... }\n\n// In profile.ts\nif (!email || !email.includes('@') || email.length < 5) { ... }\n\n// In invite.ts\nif (!email || !email.includes('@') || email.length < 5) { ... }\n```\n\n✅ **Good:** Extract once, import everywhere\n```typescript\n// utils/validate.ts\nexport const isEmail = (email: string) =>\n  email && email.includes('@') && email.length >= 5;\n\n// Everywhere else\nimport { isEmail } from '@/utils/validate';\nif (!isEmail(email)) { ... }\n```\n\n---\n\n## Periodic Audit\n\nRun `/audit-duplicates` periodically to catch semantic overlap:\n\n### Audit Checklist\n\n- [ ] **Utility functions**: Any functions doing similar things?\n- [ ] **API calls**: Multiple ways to fetch same data?\n- [ ] **Validation**: Scattered inline validation logic?\n- [ ] **Error handling**: Inconsistent error patterns?\n- [ ] **Components**: Similar UI components that could merge?\n- [ ] **Hooks**: Custom hooks with overlapping logic?\n\n### Audit Output Format\n\n```markdown\n## Duplicate Audit - [DATE]\n\n### 🔴 High Priority (Merge These)\n\n1. **Date formatting** - 3 similar functions found\n   - `formatDate()` in utils/dates.ts\n   - `displayDate()` in components/Header.tsx\n   - `showDate()` in pages/Profile.tsx\n   - **Action:** Consolidate into utils/dates.ts\n\n2. **Email validation** - Inline logic in 5 files\n   - signup.ts:42\n   - profile.ts:28\n   - invite.ts:15\n   - settings.ts:67\n   - admin.ts:33\n   - **Action:** Extract to utils/validate.ts\n\n### 🟡 Medium Priority (Consider Merging)\n\n1. **User fetching** - 2 different patterns\n   - `fetchUser()` in api/users.ts\n   - `getUser()` in services/user.ts\n   - **Action:** Decide on one pattern\n\n### 🟢 Low Priority (Monitor)\n\n1. **Button components** - 3 variants exist\n   - May be intentional for different use cases\n   - **Action:** Document the differences\n```\n\n---\n\n## Vector DB Integration (Optional)\n\nFor large codebases (100+ files), add vector search:\n\n### Setup with ChromaDB\n\n```python\n# scripts/index_codebase.py\nimport chromadb\nfrom chromadb.utils import embedding_functions\n\n# Initialize\nclient = chromadb.PersistentClient(path=\"./.chroma\")\nef = embedding_functions.DefaultEmbeddingFunction()\ncollection = client.get_or_create_collection(\"code_index\", embedding_function=ef)\n\n# Index a function\ncollection.add(\n    documents=[\"Formats a date into human-readable relative string like '2 days ago'\"],\n    metadatas=[{\"function\": \"formatRelative\", \"file\": \"utils/dates.ts\", \"line\": 32}],\n    ids=[\"formatRelative\"]\n)\n\n# Search before writing\nresults = collection.query(\n    query_texts=[\"format date as relative time\"],\n    n_results=5\n)\n# Returns: formatRelative in utils/dates.ts - 0.92 similarity\n```\n\n### Setup with LanceDB (Lighter)\n\n```python\n# scripts/index_codebase.py\nimport lancedb\n\ndb = lancedb.connect(\"./.lancedb\")\n\n# Create table\ndata = [\n    {\"function\": \"formatRelative\", \"file\": \"utils/dates.ts\", \"description\": \"Formats date as relative time\"},\n    {\"function\": \"isEmail\", \"file\": \"utils/validate.ts\", \"description\": \"Validates email format\"},\n]\ntable = db.create_table(\"code_index\", data)\n\n# Search\nresults = table.search(\"validate email address\").limit(5).to_list()\n```\n\n### When to Use Vector DB\n\n| Codebase Size | Recommendation |\n|---------------|----------------|\n| < 50 files | Markdown index only |\n| 50-200 files | Markdown + periodic audit |\n| 200+ files | Add vector DB |\n| 500+ files | Vector DB essential |\n\n---\n\n## Claude Instructions\n\n### At Session Start\n\n1. Read `CODE_INDEX.md` if it exists\n2. Note the categories and key functions available\n3. Keep this context for the session\n\n### Before Writing New Code\n\n1. **Pause and check**: \"Does something like this exist?\"\n2. Search CODE_INDEX.md for similar capabilities\n3. If unsure, search the codebase: `grep -r \"functionName\\|similar_term\" src/`\n4. Only create new if confirmed nothing suitable exists\n\n### After Writing New Code\n\n1. **Immediately update CODE_INDEX.md**\n2. Add file header if new file\n3. Add function docstring\n4. Commit index update with code\n\n### When User Says \"Add X functionality\"\n\n```\nBefore implementing, let me check if we already have something similar...\n\n[Checks CODE_INDEX.md]\n\nFound: `existingFunction()` in utils/file.ts does something similar.\nOptions:\n1. Use existing function as-is\n2. Extend it with new capability\n3. Create new (if truly different use case)\n\nWhich approach would you prefer?\n```\n\n---\n\n## Quick Reference\n\n### Update Index Command\n```bash\n/update-code-index\n```\n\n### Audit Command\n```bash\n/audit-duplicates\n```\n\n### File Header Template\n```typescript\n/**\n * @file [Short description]\n * @description [What this file does]\n *\n * Key exports:\n * - function1() - [what it does]\n * - function2() - [what it does]\n */\n```\n\n### Function Template\n```typescript\n/**\n * [One line description of what it does]\n */\nexport function name(params): ReturnType {\n```\n\n### Index Entry Template\n```markdown\n| `functionName()` | path/file.ts:line | Does what in plain English | `(params)` |\n```\n"
  },
  {
    "path": "skills/code-graph/SKILL.md",
    "content": "---\nname: code-graph\ndescription: AST-based code graph for fast symbol lookup, dependency analysis, and blast radius via codebase-memory-mcp MCP server\nwhen-to-use: \"Before reading files — query the graph first for symbol lookup, call tracing, and blast radius\"\nuser-invocable: false\neffort: medium\n---\n\n# Code Graph Skill\n\n\n**Purpose:** Use the code graph (codebase-memory-mcp) for sub-millisecond\nsymbol lookup, function search, dependency analysis, and blast radius\ndetection. This replaces brute-force grep and file reading for code\nnavigation.\n\n---\n\n## Core Principle\n\n**Graph first, file second.** Before reading files or grepping, query the\ncode graph. Only read full files when you need to modify them or need\ncontext beyond what the graph provides.\n\n**Consider graph when planning.** When planning any change — feature,\nrefactor, bug fix — start by querying the graph to understand scope,\ndependencies, and blast radius. This applies to thinking and planning\nphases, not just implementation. Grep is still the right tool for\nsearching string literals, log messages, config values, and content\nthat lives outside code structure.\n\n```\n┌────────────────────────────────────────────────────────────────┐\n│  GRAPH FIRST, FILE SECOND                                      │\n│  ─────────────────────────────────────────────────────────────│\n│  The code graph indexes your entire codebase as a persistent   │\n│  knowledge graph. Claude queries it via MCP for instant         │\n│  symbol lookup, dependency chains, and blast radius — instead   │\n│  of reading hundreds of files.                                 │\n│                                                                │\n│  14 MCP tools │ 64 languages │ sub-ms queries │ zero deps      │\n│  ~99% fewer tokens for navigation vs brute-force file reads    │\n├────────────────────────────────────────────────────────────────┤\n│  AUTO-UPDATED                                                  │\n│  ─────────────────────────────────────────────────────────────│\n│  File watcher keeps graph in sync. Post-commit hook ensures    │\n│  freshness. No manual rebuild needed.                          │\n└────────────────────────────────────────────────────────────────┘\n```\n\n---\n\n## When to Use Graph vs Direct Read\n\n| Task | Use Graph Tool | Use Direct Read? |\n|------|---------------|------------------|\n| Find function/class definition | `search_graph` | No |\n| Get function signature + docs | `get_code_snippet` | No |\n| Find all callers of a function | `trace_call_path` | No |\n| Trace dependency chain | `query_graph` | No |\n| Determine blast radius of change | `detect_changes` | No |\n| Understand project architecture | `get_architecture` | No |\n| Search for code patterns | `search_code` | No |\n| Read full implementation to modify | `search_graph` to locate, then Read file | Yes |\n| Understand business logic context | `get_code_snippet` for overview, then Read | Yes |\n\n**Rule:** If a graph tool can answer the question, use it. Only open files\nwhen you need the full source to make edits.\n\n---\n\n## Available MCP Tools\n\n### Indexing & Status\n\n| Tool | Purpose | When to Use |\n|------|---------|-------------|\n| `index_repository` | Build/rebuild graph for a project | First setup, or after major restructure |\n| `index_status` | Check if graph is current | Before querying, if unsure of freshness |\n| `list_projects` | List all indexed projects | Multi-project navigation |\n\n### Querying & Navigation\n\n| Tool | Purpose | When to Use |\n|------|---------|-------------|\n| `search_graph` | Find symbols by name (fuzzy) | \"Find auth-related functions\" |\n| `search_code` | Text search across indexed codebase | \"Find TODO comments\", pattern matching |\n| `get_code_snippet` | Get source code for a specific symbol | Need signature, docstring, implementation |\n| `get_graph_schema` | Understand graph structure and relationships | Exploring what data is available |\n| `query_graph` | Run structured graph queries | Complex dependency/relationship queries |\n\n### Analysis\n\n| Tool | Purpose | When to Use |\n|------|---------|-------------|\n| `trace_call_path` | Trace caller/callee chains | \"Who calls sendEmail?\", \"What does init() trigger?\" |\n| `detect_changes` | Identify changed files and blast radius | Before/after code changes, PR review |\n| `get_architecture` | High-level module/package structure | Onboarding, understanding project layout |\n\n### Management\n\n| Tool | Purpose | When to Use |\n|------|---------|-------------|\n| `delete_project` | Remove a project from the graph | Cleanup, project restructure |\n| `manage_adr` | Architecture decision records | Document architectural decisions |\n| `ingest_traces` | Import runtime traces | Performance analysis, dead code detection |\n\n---\n\n## Workflow: Before Any Code Change\n\n```\n0. PLAN       → get_architecture + search_graph to understand scope before planning\n1. LOCATE     → search_graph to find the symbol\n2. UNDERSTAND → get_code_snippet for context\n3. BLAST      → detect_changes to assess impact\n4. TRACE      → trace_call_path to find all affected callers\n5. CHANGE     → Read file, make edit\n6. VERIFY     → detect_changes again to confirm scope\n```\n\n**Step 0 applies to planning, not just coding.** When the user asks you to\nplan a feature, refactor, or fix — query the graph first to understand\nwhat exists, what depends on what, and what the scope looks like. This\nprevents plans based on wrong assumptions about the codebase.\n\n**Never skip step 3.** Blast radius analysis prevents unexpected breakage\nfrom changes to shared code.\n\n---\n\n## Graph Data & Freshness\n\nThe graph stays fresh automatically through 3 layers — no manual rebuild needed:\n\n| Layer | Trigger | What Happens |\n|-------|---------|-------------|\n| **File watcher** | Every file save | codebase-memory-mcp detects changes and re-indexes affected files in real-time |\n| **Auto-index** | Session start | `auto_index: true` ensures graph is current when Claude Code starts |\n| **Post-commit hook** | Every `git commit` | Touches `.code-graph/.needs-update` marker — file watcher picks it up (~10ms, non-blocking) |\n\n**You do NOT need to manually re-index** unless you do a major restructure\n(rename entire directories, switch branches with massive diffs). In that\ncase: `index_repository` once, then the 3 layers keep it fresh.\n\n- **Storage**: `.code-graph/` directory (auto-created, gitignored)\n- **MCP config**: `.mcp.json` at project root (committed, shared with team)\n\n---\n\n## MCP Configuration\n\nThe code graph MCP server is configured in `.mcp.json` at project root:\n\n```json\n{\n  \"mcpServers\": {\n    \"codebase-memory\": {\n      \"command\": \"codebase-memory-mcp\",\n      \"args\": []\n    }\n  }\n}\n```\n\n**Installation:** `~/.claude/install-graph-tools.sh`\n\n---\n\n## Decision Framework\n\n```\nNeed to find a symbol/function?\n  → search_graph (sub-ms, structured result)\n  → NOT: grep -r \"functionName\" (slow, unstructured)\n\nNeed to understand dependencies?\n  → query_graph or trace_call_path (complete, traversable)\n  → NOT: manually reading import statements\n\nNeed to assess change impact?\n  → detect_changes (comprehensive, instant)\n  → NOT: searching for usages manually across files\n\nNeed to understand architecture?\n  → get_architecture (high-level overview)\n  → NOT: reading every directory listing\n\nNeed to read/modify code?\n  → search_graph to locate, then Read the specific file\n  → NOT: reading entire directories hoping to find it\n```\n\n---\n\n## Anti-Patterns\n\n| Anti-Pattern | Do This Instead |\n|-------------|-----------------|\n| Grepping for function names | `search_graph` with the function name |\n| Reading entire files to find a signature | `get_code_snippet` for the specific symbol |\n| Manually tracing import chains | `trace_call_path` or `query_graph` |\n| Making changes without checking impact | `detect_changes` before every edit to shared code |\n| Reading all files in a directory | `get_architecture` for structure, `search_graph` for specifics |\n| Ignoring graph staleness warnings | Check `index_status`, re-index if needed |\n| Re-indexing on every query | Trust the file watcher; only manual re-index after major restructure |\n"
  },
  {
    "path": "skills/code-review/SKILL.md",
    "content": "---\nname: code-review\ndescription: Mandatory code reviews via /code-review before commits and deploys\nwhen-to-use: When user asks to review code, before commits, or when /code-review is invoked\nuser-invocable: true\nallowed-tools: [Read, Glob, Grep, Bash]\neffort: high\n---\n\n# Code Review Skill\n\n\n**Purpose:** Enforce automated code reviews as a mandatory guardrail before every commit and deployment. Choose between Claude, OpenAI Codex, Google Gemini, or multiple engines for comprehensive analysis.\n\n---\n\n## Review Engine Choice\n\nWhen running `/code-review`, users can choose their preferred review engine:\n\n```\n┌─────────────────────────────────────────────────────────────────┐\n│  CODE REVIEW - Choose Your Engine                               │\n├─────────────────────────────────────────────────────────────────┤\n│                                                                 │\n│  ○ Claude (default)                                             │\n│    Built-in, no extra setup, full conversation context          │\n│                                                                 │\n│  ○ OpenAI Codex CLI                                             │\n│    GPT-5.2-Codex specialized for code review, 88% detection     │\n│    Requires: npm install -g @openai/codex                       │\n│                                                                 │\n│  ○ Google Gemini CLI                                            │\n│    Gemini 2.5 Pro with 1M token context, free tier available    │\n│    Requires: npm install -g @google/gemini-cli                  │\n│                                                                 │\n│  ○ Dual Engine (any two)                                        │\n│    Run two engines, compare findings, catch more issues         │\n│                                                                 │\n│  ○ All Three (maximum coverage)                                 │\n│    Run Claude + Codex + Gemini for critical/security code       │\n│                                                                 │\n└─────────────────────────────────────────────────────────────────┘\n```\n\n### Engine Comparison\n\n| Aspect | Claude | Codex | Gemini | Multi-Engine |\n|--------|--------|-------|--------|--------------|\n| **Setup** | None | npm + OpenAI API | npm + Google Account | All setups |\n| **Speed** | Fast | Fast | Fast | 2-3x time |\n| **Context** | Conversation | Fresh per review | 1M tokens | N/A |\n| **Detection** | Good | 88% (best) | 63.8% SWE-Bench | Combined |\n| **Free Tier** | N/A | Limited | 1,000/day | Varies |\n| **Best for** | Quick reviews | High accuracy | Large codebases | Critical code |\n\n### Set Default Engine\n\n```toml\n# ~/.claude/settings.toml or project CLAUDE.md\n[code-review]\ndefault_engine = \"claude\"  # Options: claude, codex, gemini, dual, all\n```\n\n### Usage Examples\n\n```bash\n# Use default engine\n/code-review\n\n# Explicitly choose engine\n/code-review --engine claude\n/code-review --engine codex\n/code-review --engine gemini\n\n# Dual engine (pick any two)\n/code-review --engine claude,codex\n/code-review --engine claude,gemini\n/code-review --engine codex,gemini\n\n# All three engines\n/code-review --engine all\n\n# Quick shortcuts\n/code-review              # Uses default\n/code-review --codex      # Use Codex\n/code-review --gemini     # Use Gemini\n/code-review --all        # All three engines\n```\n\n---\n\n## Multi-Engine Output\n\nWhen using multiple engines, findings are compared and deduplicated:\n\n### Dual Engine Example\n\n```\n┌─────────────────────────────────────────────────────────────────┐\n│  CODE REVIEW RESULTS - DUAL ENGINE (Claude + Codex)             │\n├─────────────────────────────────────────────────────────────────┤\n│                                                                 │\n│  ✅ AGREED (Found by both):                                     │\n│  🔴 SQL injection in auth.ts:45                                 │\n│  🟡 Missing error handling in api.ts:112                        │\n│                                                                 │\n│  🔷 CLAUDE ONLY:                                                │\n│  🟠 Potential race condition in worker.ts:89                    │\n│  🟢 Consider extracting helper function                         │\n│                                                                 │\n│  🔶 CODEX ONLY:                                                 │\n│  🟠 Memory leak - unclosed stream in upload.ts:34               │\n│  🟡 N+1 query pattern in orders.ts:156                          │\n│                                                                 │\n├─────────────────────────────────────────────────────────────────┤\n│  SUMMARY                                                        │\n│  Agreed: 2 | Claude only: 2 | Codex only: 2                     │\n│  Critical: 1 | High: 2 | Medium: 2 | Low: 1                     │\n│  Status: ❌ BLOCKED - Fix critical/high issues                  │\n└─────────────────────────────────────────────────────────────────┘\n```\n\n### Triple Engine Example (All Three)\n\n```\n┌─────────────────────────────────────────────────────────────────┐\n│  CODE REVIEW RESULTS - TRIPLE ENGINE                            │\n├─────────────────────────────────────────────────────────────────┤\n│                                                                 │\n│  ✅ UNANIMOUS (All 3 found):                                    │\n│  🔴 SQL injection in auth.ts:45                                 │\n│                                                                 │\n│  ✅ MAJORITY (2 of 3 found):                                    │\n│  🟠 Memory leak - unclosed stream in upload.ts:34 (Codex+Gemini)│\n│  🟡 Missing error handling in api.ts:112 (Claude+Codex)         │\n│                                                                 │\n│  🔷 CLAUDE ONLY:                                                │\n│  🟠 Potential race condition in worker.ts:89                    │\n│                                                                 │\n│  🔶 CODEX ONLY:                                                 │\n│  🟡 N+1 query pattern in orders.ts:156                          │\n│                                                                 │\n│  🟢 GEMINI ONLY:                                                │\n│  🟡 Consider using batch API for better performance             │\n│  🟢 Type could be more specific in types.ts:23                  │\n│                                                                 │\n├─────────────────────────────────────────────────────────────────┤\n│  SUMMARY                                                        │\n│  Unanimous: 1 | Majority: 2 | Single: 5                         │\n│  Critical: 1 | High: 2 | Medium: 3 | Low: 2                     │\n│  Status: ❌ BLOCKED - Fix critical/high issues                  │\n└─────────────────────────────────────────────────────────────────┘\n```\n\n### When to Use Each Mode\n\n| Mode | Use When |\n|------|----------|\n| **Single (Claude)** | Quick in-flow reviews, exploration |\n| **Single (Codex)** | CI/CD automation, high accuracy needed |\n| **Single (Gemini)** | Large codebases (100+ files), free tier |\n| **Dual** | Important PRs, pre-merge reviews |\n| **Triple (All)** | Security-critical code, payment systems, auth |\n\n---\n\n## Core Philosophy\n\n```\n┌─────────────────────────────────────────────────────────────────┐\n│  CODE REVIEW IS NON-NEGOTIABLE                                  │\n│  ─────────────────────────────────────────────────────────────  │\n│                                                                 │\n│  Every commit must pass code review.                            │\n│  Every PR must be reviewed before merge.                        │\n│  Every deployment must include review sign-off.                 │\n│                                                                 │\n│  AI catches what humans miss. Humans catch what AI misses.      │\n│  Together: fewer bugs, cleaner code, better security.           │\n├─────────────────────────────────────────────────────────────────┤\n│  INVOKE: /code-review                                           │\n│  PLUGIN: code-review@claude-plugins-official                    │\n└─────────────────────────────────────────────────────────────────┘\n```\n\n---\n\n## When to Run Code Review\n\n### Mandatory Review Points\n\n| Trigger | Action | Command |\n|---------|--------|---------|\n| **Before commit** | Review staged changes | `/code-review` |\n| **Before PR** | Review all changes vs base | `/code-review` |\n| **Before merge** | Final review of PR | `/code-review` |\n| **Before deploy** | Review deployment diff | `/code-review` |\n\n### Automatic Integration\n\n**Run code review automatically before every commit:**\n\n```\n┌─────────────────────────────────────────────────────────────────┐\n│  COMMIT WORKFLOW                                                │\n│  ─────────────────────────────────────────────────────────────  │\n│                                                                 │\n│  1. Write code                                                  │\n│  2. Run tests (TDD - must pass)                                 │\n│  3. Run /code-review  ← MANDATORY                               │\n│  4. Address critical/high issues                                │\n│  5. Commit                                                      │\n│  6. Push                                                        │\n│                                                                 │\n│  Skip step 3? ❌ NO COMMIT ALLOWED                              │\n└─────────────────────────────────────────────────────────────────┘\n```\n\n---\n\n## Using the Code Review Plugin\n\n### Basic Usage\n\n```bash\n# Review current changes\n/code-review\n\n# Review specific files\n/code-review src/auth/*.ts\n\n# Review a PR\n/code-review --pr 123\n\n# Review with specific focus\n/code-review --focus security\n/code-review --focus performance\n/code-review --focus architecture\n```\n\n### Review Categories\n\nThe code review plugin analyzes:\n\n| Category | What It Checks |\n|----------|----------------|\n| **Security** | Vulnerabilities, injection risks, auth issues, secrets |\n| **Performance** | N+1 queries, memory leaks, inefficient algorithms |\n| **Architecture** | Design patterns, SOLID principles, coupling |\n| **Code Quality** | Readability, complexity, duplication |\n| **Best Practices** | Language idioms, framework conventions |\n| **Testing** | Coverage gaps, test quality, edge cases |\n| **Documentation** | Missing docs, outdated comments |\n\n### Severity Levels\n\n| Level | Action Required | Can Commit? |\n|-------|-----------------|-------------|\n| 🔴 **Critical** | Must fix immediately | ❌ NO |\n| 🟠 **High** | Should fix before commit | ❌ NO |\n| 🟡 **Medium** | Fix soon, can commit | ✅ YES |\n| 🟢 **Low** | Nice to have | ✅ YES |\n| ℹ️ **Info** | Suggestions only | ✅ YES |\n\n---\n\n## Pre-Commit Hook Integration\n\n### Install Pre-Commit Hook\n\n```bash\n#!/bin/bash\n# .git/hooks/pre-commit\n\necho \"🔍 Running code review...\"\n\n# Run Claude code review on staged files\nSTAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\\.(ts|tsx|js|jsx|py|go|rs)$')\n\nif [ -n \"$STAGED_FILES\" ]; then\n    # Invoke code review (requires claude CLI)\n    claude --print \"/code-review $STAGED_FILES\" > /tmp/code-review-result.txt 2>&1\n\n    # Check for critical/high issues\n    if grep -q \"🔴\\|Critical\\|🟠\\|High\" /tmp/code-review-result.txt; then\n        echo \"❌ Code review found critical/high issues:\"\n        cat /tmp/code-review-result.txt\n        echo \"\"\n        echo \"Fix these issues before committing.\"\n        exit 1\n    fi\n\n    echo \"✅ Code review passed\"\nfi\n\nexit 0\n```\n\n### Make Hook Executable\n\n```bash\nchmod +x .git/hooks/pre-commit\n```\n\n---\n\n## Codex CLI Setup (For Codex/Both Modes)\n\nIf you want to use Codex or Both modes, install the Codex CLI:\n\n```bash\n# Prerequisites: Node.js 22+\nnode --version  # Must be 22+\n\n# Install Codex CLI\nnpm install -g @openai/codex\n\n# Authenticate (choose one):\n# Option 1: ChatGPT subscription (Plus, Pro, Team, Enterprise)\ncodex  # Follow prompts to sign in\n\n# Option 2: API key\nexport OPENAI_API_KEY=sk-proj-...\n```\n\n### Verify Installation\n\n```bash\n# Check Codex is installed\ncodex --version\n\n# Test review\ncodex\n> /review\n```\n\nSee `codex-review.md` skill for full Codex documentation.\n\n---\n\n## Gemini CLI Setup (For Gemini/Multi-Engine Modes)\n\nIf you want to use Gemini or multi-engine modes, install the Gemini CLI:\n\n```bash\n# Prerequisites: Node.js 20+\nnode --version  # Must be 20+\n\n# Install Gemini CLI\nnpm install -g @google/gemini-cli\n\n# Or via Homebrew (macOS)\nbrew install gemini-cli\n\n# Install Code Review extension\ngemini extensions install https://github.com/gemini-cli-extensions/code-review\n```\n\n### Authenticate\n\n```bash\n# Option 1: Google Account (recommended, 1000 req/day free)\ngemini  # Follow browser login prompts\n\n# Option 2: API key (100 req/day free)\nexport GEMINI_API_KEY=\"your-key-from-aistudio.google.com\"\n```\n\n### Verify Installation\n\n```bash\n# Check Gemini is installed\ngemini --version\n\n# List extensions\ngemini extensions list\n\n# Test review\ngemini\n> /code-review\n```\n\nSee `gemini-review.md` skill for full Gemini documentation.\n\n---\n\n## CI/CD Integration\n\n### GitHub Actions - Claude Only\n\n```yaml\n# .github/workflows/code-review.yml\nname: Code Review\n\non:\n  pull_request:\n    types: [opened, synchronize, reopened]\n\njobs:\n  code-review:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n\n      - name: Get changed files\n        id: changed-files\n        run: |\n          echo \"files=$(git diff --name-only origin/${{ github.base_ref }}...HEAD | tr '\\n' ' ')\" >> $GITHUB_OUTPUT\n\n      - name: Run Claude Code Review\n        env:\n          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}\n        run: |\n          npx @anthropic-ai/claude-code --print \"/code-review ${{ steps.changed-files.outputs.files }}\" > review.md\n\n      - name: Post Review Comment\n        uses: actions/github-script@v7\n        with:\n          script: |\n            const fs = require('fs');\n            const review = fs.readFileSync('review.md', 'utf8');\n\n            github.rest.issues.createComment({\n              owner: context.repo.owner,\n              repo: context.repo.repo,\n              issue_number: context.issue.number,\n              body: `## 🔍 Claude Code Review\\n\\n${review}`\n            });\n\n      - name: Check for Critical Issues\n        run: |\n          if grep -q \"Critical\\|🔴\" review.md; then\n            echo \"❌ Critical issues found\"\n            exit 1\n          fi\n```\n\n### GitHub Actions - Codex Only\n\n```yaml\n# .github/workflows/codex-review.yml\nname: Codex Code Review\n\non:\n  pull_request:\n\njobs:\n  review:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n\n      - name: Codex Review\n        uses: openai/codex-action@main\n        with:\n          openai_api_key: ${{ secrets.OPENAI_API_KEY }}\n          model: gpt-5.2-codex\n          safety_strategy: drop-sudo\n```\n\n### GitHub Actions - Both Engines\n\n```yaml\n# .github/workflows/dual-review.yml\nname: Dual Code Review\n\non:\n  pull_request:\n\njobs:\n  claude-review:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n\n      - name: Claude Review\n        env:\n          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}\n        run: |\n          npx @anthropic-ai/claude-code --print \"/code-review\" > claude-review.md\n\n      - uses: actions/upload-artifact@v4\n        with:\n          name: claude-review\n          path: claude-review.md\n\n  codex-review:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n\n      - uses: actions/setup-node@v4\n        with:\n          node-version: '22'\n\n      - name: Install Codex\n        run: npm install -g @openai/codex\n\n      - name: Codex Review\n        env:\n          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}\n        run: |\n          codex exec --full-auto --sandbox read-only \\\n            --output-last-message codex-review.md \\\n            \"Review this code for bugs, security issues, and quality problems\"\n\n      - uses: actions/upload-artifact@v4\n        with:\n          name: codex-review\n          path: codex-review.md\n\n  combine-reviews:\n    needs: [claude-review, codex-review]\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/download-artifact@v4\n\n      - name: Combine Reviews\n        run: |\n          echo \"## 🔍 Dual Code Review Results\" > combined-review.md\n          echo \"\" >> combined-review.md\n          echo \"### Claude Findings\" >> combined-review.md\n          cat claude-review/claude-review.md >> combined-review.md\n          echo \"\" >> combined-review.md\n          echo \"### Codex Findings\" >> combined-review.md\n          cat codex-review/codex-review.md >> combined-review.md\n\n      - name: Post Combined Review\n        uses: actions/github-script@v7\n        with:\n          script: |\n            const fs = require('fs');\n            const review = fs.readFileSync('combined-review.md', 'utf8');\n            github.rest.issues.createComment({\n              owner: context.repo.owner,\n              repo: context.repo.repo,\n              issue_number: context.issue.number,\n              body: review\n            });\n```\n\n### GitHub Actions - Gemini Only\n\n```yaml\n# .github/workflows/gemini-review.yml\nname: Gemini Code Review\n\non:\n  pull_request:\n    types: [opened, synchronize]\n\njobs:\n  review:\n    runs-on: ubuntu-latest\n    permissions:\n      contents: read\n      pull-requests: write\n\n    steps:\n      - uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n\n      - name: Setup Node.js\n        uses: actions/setup-node@v4\n        with:\n          node-version: '20'\n\n      - name: Install Gemini CLI\n        run: npm install -g @google/gemini-cli\n\n      - name: Run Review\n        env:\n          GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}\n        run: |\n          # Get diff\n          git diff origin/${{ github.base_ref }}...HEAD > diff.txt\n\n          # Run Gemini review\n          gemini -p \"Review this pull request diff for bugs, security issues, and code quality problems. Be specific about file names and line numbers.\n\n          $(cat diff.txt)\" > review.md\n\n      - name: Post Review Comment\n        uses: actions/github-script@v7\n        with:\n          script: |\n            const fs = require('fs');\n            const review = fs.readFileSync('review.md', 'utf8');\n            github.rest.issues.createComment({\n              owner: context.repo.owner,\n              repo: context.repo.repo,\n              issue_number: context.issue.number,\n              body: `## 🤖 Gemini Code Review\\n\\n${review}`\n            });\n\n      - name: Check for Critical Issues\n        run: |\n          if grep -qi \"critical\\|security vulnerability\\|injection\" review.md; then\n            echo \"❌ Critical issues found\"\n            exit 1\n          fi\n```\n\n### GitHub Actions - All Three Engines\n\n```yaml\n# .github/workflows/triple-review.yml\nname: Triple Engine Code Review\n\non:\n  pull_request:\n\njobs:\n  claude-review:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n\n      - name: Claude Review\n        env:\n          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}\n        run: |\n          npx @anthropic-ai/claude-code --print \"/code-review\" > claude-review.md\n\n      - uses: actions/upload-artifact@v4\n        with:\n          name: claude-review\n          path: claude-review.md\n\n  codex-review:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n\n      - uses: actions/setup-node@v4\n        with:\n          node-version: '22'\n\n      - name: Install Codex\n        run: npm install -g @openai/codex\n\n      - name: Codex Review\n        env:\n          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}\n        run: |\n          codex exec --full-auto --sandbox read-only \\\n            --output-last-message codex-review.md \\\n            \"Review this code for bugs, security issues, and quality problems\"\n\n      - uses: actions/upload-artifact@v4\n        with:\n          name: codex-review\n          path: codex-review.md\n\n  gemini-review:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n\n      - uses: actions/setup-node@v4\n        with:\n          node-version: '20'\n\n      - name: Install Gemini CLI\n        run: npm install -g @google/gemini-cli\n\n      - name: Gemini Review\n        env:\n          GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}\n        run: |\n          git diff origin/${{ github.base_ref }}...HEAD > diff.txt\n          gemini -p \"Review this code diff for bugs, security, and quality issues:\n          $(cat diff.txt)\" > gemini-review.md\n\n      - uses: actions/upload-artifact@v4\n        with:\n          name: gemini-review\n          path: gemini-review.md\n\n  combine-reviews:\n    needs: [claude-review, codex-review, gemini-review]\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/download-artifact@v4\n\n      - name: Combine Reviews\n        run: |\n          echo \"## 🔍 Triple Engine Code Review Results\" > combined-review.md\n          echo \"\" >> combined-review.md\n          echo \"### 🟣 Claude Findings\" >> combined-review.md\n          cat claude-review/claude-review.md >> combined-review.md\n          echo \"\" >> combined-review.md\n          echo \"---\" >> combined-review.md\n          echo \"### 🟢 Codex Findings\" >> combined-review.md\n          cat codex-review/codex-review.md >> combined-review.md\n          echo \"\" >> combined-review.md\n          echo \"---\" >> combined-review.md\n          echo \"### 🔵 Gemini Findings\" >> combined-review.md\n          cat gemini-review/gemini-review.md >> combined-review.md\n\n      - name: Post Combined Review\n        uses: actions/github-script@v7\n        with:\n          script: |\n            const fs = require('fs');\n            const review = fs.readFileSync('combined-review.md', 'utf8');\n            github.rest.issues.createComment({\n              owner: context.repo.owner,\n              repo: context.repo.repo,\n              issue_number: context.issue.number,\n              body: review\n            });\n\n      - name: Check Critical Issues\n        run: |\n          # Fail if any engine found critical issues\n          if grep -qi \"critical\\|🔴\" combined-review.md; then\n            echo \"❌ Critical issues found by at least one engine\"\n            exit 1\n          fi\n```\n\n---\n\n## Review Checklist\n\n### Before Every Commit\n\n- [ ] Run `/code-review` on staged changes\n- [ ] No critical (🔴) issues\n- [ ] No high (🟠) issues\n- [ ] Security concerns addressed\n- [ ] Performance issues considered\n\n### Before Every PR\n\n- [ ] Full code review of all changes\n- [ ] All critical/high issues resolved\n- [ ] Tests added for new functionality\n- [ ] Documentation updated if needed\n\n### Before Every Deployment\n\n- [ ] Final review of deployment diff\n- [ ] Security scan passed\n- [ ] No new vulnerabilities introduced\n- [ ] Rollback plan documented\n\n---\n\n## Common Review Findings\n\n### Security Issues (Always Fix)\n\n| Issue | Example | Fix |\n|-------|---------|-----|\n| SQL Injection | `query = f\"SELECT * FROM users WHERE id = {id}\"` | Use parameterized queries |\n| XSS | `innerHTML = userInput` | Sanitize or use textContent |\n| Secrets in code | `apiKey = \"sk-xxx\"` | Use environment variables |\n| Missing auth | Unprotected endpoints | Add authentication middleware |\n| Insecure crypto | MD5/SHA1 for passwords | Use bcrypt/argon2 |\n\n### Performance Issues (Should Fix)\n\n| Issue | Example | Fix |\n|-------|---------|-----|\n| N+1 queries | Loop with individual queries | Use batch/eager loading |\n| Memory leak | Unclosed connections | Use connection pooling |\n| Missing index | Slow queries | Add database indexes |\n| Large payload | Fetching unused fields | Select only needed fields |\n| No pagination | Loading all records | Implement pagination |\n\n### Code Quality (Nice to Fix)\n\n| Issue | Example | Fix |\n|-------|---------|-----|\n| Long function | 100+ lines | Extract into smaller functions |\n| Deep nesting | 5+ levels | Early returns, extract methods |\n| Magic numbers | `if (status === 3)` | Use named constants |\n| Duplicate code | Copy-pasted blocks | Extract shared function |\n| Missing types | `any` everywhere | Add proper TypeScript types |\n\n---\n\n## Integration with TDD Workflow\n\n```\n┌─────────────────────────────────────────────────────────────────┐\n│  TDD + CODE REVIEW WORKFLOW                                     │\n│  ─────────────────────────────────────────────────────────────  │\n│                                                                 │\n│  1. RED: Write failing tests                                    │\n│  2. GREEN: Write code to pass tests                             │\n│  3. REFACTOR: Clean up code                                     │\n│  4. REVIEW: Run /code-review  ← NEW STEP                        │\n│  5. FIX: Address critical/high issues                           │\n│  6. VALIDATE: Lint + TypeCheck + Coverage                       │\n│  7. COMMIT: Only after review passes                            │\n│                                                                 │\n│  Review catches what tests miss:                                │\n│  - Security vulnerabilities                                     │\n│  - Performance issues                                           │\n│  - Architecture problems                                        │\n│  - Code maintainability                                         │\n└─────────────────────────────────────────────────────────────────┘\n```\n\n---\n\n## Review Response Template\n\nWhen code review finds issues, respond with:\n\n```markdown\n## Code Review Results\n\n### 🔴 Critical Issues (Must Fix)\n1. **SQL Injection in userController.ts:45**\n   - Issue: User input directly interpolated into query\n   - Fix: Use parameterized query\n   - Code: `db.query('SELECT * FROM users WHERE id = $1', [userId])`\n\n### 🟠 High Issues (Should Fix)\n1. **Missing authentication on /api/admin endpoints**\n   - Issue: Admin routes accessible without auth\n   - Fix: Add auth middleware\n\n### 🟡 Medium Issues (Fix Soon)\n1. **N+1 query in getOrders function**\n   - Consider eager loading or batch query\n\n### 🟢 Low Issues (Nice to Have)\n1. **Consider extracting validation logic to separate file**\n\n### ✅ Strengths\n- Good test coverage\n- Clear function names\n- Proper error handling\n\n### 📊 Summary\n- Critical: 1 | High: 1 | Medium: 1 | Low: 1\n- **Status: ❌ BLOCKED** - Fix critical/high issues before commit\n```\n\n---\n\n## Claude Instructions\n\n### When to Invoke Code Review\n\nClaude should automatically suggest or run code review:\n\n1. **After completing a feature** → \"Let me run a code review before we commit\"\n2. **Before creating a PR** → \"Running code review on all changes\"\n3. **When user says \"commit\"** → \"First, let me review the changes\"\n4. **After fixing bugs** → \"Reviewing the fix for any issues\"\n\n### Review Focus Areas\n\nPrioritize review based on change type:\n\n| Change Type | Focus Areas |\n|-------------|-------------|\n| Auth/Security code | Security, input validation, crypto |\n| Database code | SQL injection, N+1, transactions |\n| API endpoints | Auth, rate limiting, validation |\n| Frontend code | XSS, state management, performance |\n| Infrastructure | Secrets, permissions, logging |\n\n---\n\n## Quick Reference\n\n### Commands\n\n```bash\n# Basic review\n/code-review\n\n# Review specific files\n/code-review src/auth.ts src/users.ts\n\n# Review with focus\n/code-review --focus security\n\n# Review PR\n/code-review --pr 123\n```\n\n### Severity Actions\n\n```\n🔴 Critical → STOP. Fix now. No commit.\n🟠 High     → STOP. Fix now. No commit.\n🟡 Medium   → Note it. Fix soon. Can commit.\n🟢 Low      → Optional. Nice to have.\nℹ️ Info     → FYI only.\n```\n\n### Workflow\n\n```\nCode → Test → Review → Fix → Commit → Push → PR → Review → Merge → Deploy\n              ↑                              ↑                    ↑\n           /code-review                /code-review          /code-review\n```\n"
  },
  {
    "path": "skills/codex-review/SKILL.md",
    "content": "---\nname: codex-review\ndescription: OpenAI Codex CLI code review with GPT-5.2-Codex, CI/CD integration\nwhen-to-use: When user requests Codex-powered code review or multi-engine review\nuser-invocable: true\neffort: medium\n---\n\n# OpenAI Codex Code Review Skill\n\n\nUse OpenAI's Codex CLI for specialized code review with GPT-5.2-Codex - trained specifically for detecting bugs, security flaws, and code quality issues.\n\n**Sources:** [Codex CLI](https://developers.openai.com/codex/cli/) | [GitHub](https://github.com/openai/codex) | [Code Review Cookbook](https://cookbook.openai.com/examples/codex/build_code_review_with_codex_sdk)\n\n---\n\n## Why Codex for Code Review?\n\n| Feature | Benefit |\n|---------|---------|\n| **GPT-5.2-Codex** | Specialized training for code review |\n| **88% detection rate** | Bugs, security flaws, style issues (LiveCodeBench) |\n| **Structured output** | JSON schema for consistent findings |\n| **GitHub native** | `@codex review` in PR comments |\n| **Headless mode** | CI/CD automation without TUI |\n\n---\n\n## Installation\n\n### Prerequisites\n\n```bash\n# Check Node.js version (requires 22+)\nnode --version\n\n# Install Node.js 22 if needed\n# macOS\nbrew install node@22\n\n# Or via nvm\nnvm install 22\nnvm use 22\n```\n\n### Install Codex CLI\n\n```bash\n# Via npm (recommended)\nnpm install -g @openai/codex\n\n# Via Homebrew (macOS)\nbrew install --cask codex\n\n# Verify installation\ncodex --version\n```\n\n### Authentication\n\n**Option 1: ChatGPT Subscription** (Plus, Pro, Team, Edu, Enterprise)\n```bash\ncodex\n# Follow prompts to sign in with ChatGPT account\n```\n\n**Option 2: OpenAI API Key**\n```bash\n# Set environment variable\nexport OPENAI_API_KEY=sk-proj-...\n\n# Or add to shell profile\necho 'export OPENAI_API_KEY=sk-proj-...' >> ~/.zshrc\n\n# Run Codex\ncodex\n```\n\n### Shell Completions (Optional)\n\n```bash\n# Bash\ncodex completion bash >> ~/.bashrc\n\n# Zsh\ncodex completion zsh >> ~/.zshrc\n\n# Fish\ncodex completion fish > ~/.config/fish/completions/codex.fish\n```\n\n---\n\n## Interactive Code Review\n\n### Launch Review Mode\n\n```bash\n# Start Codex\ncodex\n\n# In the TUI, type:\n/review\n```\n\n### Review Presets\n\n| Preset | Use Case |\n|--------|----------|\n| **Review against base branch** | Before opening PR - diffs against upstream |\n| **Review uncommitted changes** | Before committing - staged + unstaged + untracked |\n| **Review a commit** | Analyze specific SHA from history |\n| **Custom instructions** | e.g., \"Focus on security vulnerabilities\" |\n\n### Example Session\n\n```\n$ codex\n> /review\n\nSelect review type:\n❯ Review against a base branch\n  Review uncommitted changes\n  Review a commit\n  Custom review instructions\n\nSelect base branch: main\n\nReviewing changes...\n\n┌─────────────────────────────────────────────────────────────┐\n│ CODE REVIEW FINDINGS                                        │\n├─────────────────────────────────────────────────────────────┤\n│ 🔴 CRITICAL: SQL Injection vulnerability                    │\n│    File: src/api/users.ts:45                                │\n│    Issue: User input directly interpolated in query         │\n│    Fix: Use parameterized queries                           │\n├─────────────────────────────────────────────────────────────┤\n│ 🟠 HIGH: Missing authentication check                       │\n│    File: src/api/admin.ts:23                                │\n│    Issue: Admin endpoint accessible without auth            │\n│    Fix: Add requireAuth middleware                          │\n├─────────────────────────────────────────────────────────────┤\n│ 🟡 MEDIUM: Inefficient database query                       │\n│    File: src/services/orders.ts:89                          │\n│    Issue: N+1 query pattern in loop                         │\n│    Fix: Use batch query or JOIN                             │\n└─────────────────────────────────────────────────────────────┘\n```\n\n---\n\n## Headless Mode (Automation)\n\n### Basic Usage\n\n```bash\n# Simple review\ncodex exec \"review the code for bugs and security issues\"\n\n# Review with JSON output\ncodex exec --json \"review uncommitted changes\" > review.json\n\n# Save final message to file\ncodex exec --output-last-message review.txt \"review the diff against main\"\n```\n\n### Full Automation (CI/CD)\n\n```bash\n# Full auto mode (use only in isolated runners!)\ncodex exec \\\n  --full-auto \\\n  --json \\\n  --output-last-message findings.txt \\\n  --sandbox read-only \\\n  -m gpt-5.2-codex \\\n  \"Review this code for bugs, security issues, and performance problems\"\n```\n\n### Structured Output with Schema\n\n```bash\n# Define output schema\ncat > review-schema.json << 'EOF'\n{\n  \"type\": \"object\",\n  \"properties\": {\n    \"findings\": {\n      \"type\": \"array\",\n      \"items\": {\n        \"type\": \"object\",\n        \"properties\": {\n          \"severity\": { \"enum\": [\"critical\", \"high\", \"medium\", \"low\"] },\n          \"title\": { \"type\": \"string\" },\n          \"file\": { \"type\": \"string\" },\n          \"line\": { \"type\": \"integer\" },\n          \"description\": { \"type\": \"string\" },\n          \"suggestion\": { \"type\": \"string\" }\n        },\n        \"required\": [\"severity\", \"title\", \"file\", \"description\"]\n      }\n    },\n    \"summary\": { \"type\": \"string\" },\n    \"approved\": { \"type\": \"boolean\" }\n  },\n  \"required\": [\"findings\", \"summary\", \"approved\"]\n}\nEOF\n\n# Run with schema validation\ncodex exec \\\n  --output-schema review-schema.json \\\n  --output-last-message review.json \\\n  \"Review the staged changes and output findings\"\n```\n\n---\n\n## GitHub Integration\n\n### Option 1: PR Comment Trigger\n\nIn any pull request, add a comment:\n```\n@codex review\n```\n\nCodex will respond with a standard GitHub code review.\n\n### Option 2: GitHub Action\n\n```yaml\n# .github/workflows/codex-review.yml\nname: Codex Code Review\n\non:\n  pull_request:\n    types: [opened, synchronize]\n\njobs:\n  review:\n    runs-on: ubuntu-latest\n    permissions:\n      contents: read\n      pull-requests: write\n\n    steps:\n      - uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n\n      - name: Codex Review\n        uses: openai/codex-action@main\n        with:\n          openai_api_key: ${{ secrets.OPENAI_API_KEY }}\n          model: gpt-5.2-codex\n          safety_strategy: drop-sudo\n```\n\n### Option 3: Manual Headless in CI\n\n```yaml\n# .github/workflows/codex-review.yml\nname: Codex Code Review\n\non:\n  pull_request:\n\njobs:\n  review:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n\n      - uses: actions/setup-node@v4\n        with:\n          node-version: '22'\n\n      - name: Install Codex CLI\n        run: npm install -g @openai/codex\n\n      - name: Run Review\n        env:\n          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}\n        run: |\n          # Get diff\n          git diff origin/${{ github.base_ref }}...HEAD > diff.txt\n\n          # Run Codex review\n          codex exec \\\n            --full-auto \\\n            --sandbox read-only \\\n            --output-last-message review.md \\\n            \"Review this git diff for bugs, security issues, and code quality: $(cat diff.txt)\"\n\n      - name: Post Review Comment\n        uses: actions/github-script@v7\n        with:\n          script: |\n            const fs = require('fs');\n            const review = fs.readFileSync('review.md', 'utf8');\n            github.rest.issues.createComment({\n              owner: context.repo.owner,\n              repo: context.repo.repo,\n              issue_number: context.issue.number,\n              body: `## 🤖 Codex Code Review\\n\\n${review}`\n            });\n```\n\n---\n\n## GitLab CI/CD\n\n```yaml\n# .gitlab-ci.yml\ncodex-review:\n  image: node:22\n  stage: review\n  script:\n    - npm install -g @openai/codex\n    - |\n      codex exec \\\n        --full-auto \\\n        --sandbox read-only \\\n        --output-last-message review.md \\\n        \"Review the merge request changes for bugs and security issues\"\n    - cat review.md\n  artifacts:\n    paths:\n      - review.md\n  rules:\n    - if: $CI_PIPELINE_SOURCE == \"merge_request_event\"\n```\n\n---\n\n## Jenkins Pipeline\n\n```groovy\npipeline {\n    agent any\n\n    environment {\n        OPENAI_API_KEY = credentials('openai-api-key')\n    }\n\n    stages {\n        stage('Install Codex') {\n            steps {\n                sh 'npm install -g @openai/codex'\n            }\n        }\n\n        stage('Code Review') {\n            steps {\n                sh '''\n                    codex exec \\\n                      --full-auto \\\n                      --sandbox read-only \\\n                      --output-last-message review.md \\\n                      \"Review the code changes for bugs and security issues\"\n                '''\n            }\n        }\n\n        stage('Publish Results') {\n            steps {\n                archiveArtifacts artifacts: 'review.md'\n                script {\n                    def review = readFile('review.md')\n                    echo \"Code Review Results:\\n${review}\"\n                }\n            }\n        }\n    }\n}\n```\n\n---\n\n## Configuration\n\n### Config File\n\n```toml\n# ~/.codex/config.toml\n\n[model]\ndefault = \"gpt-5.2-codex\"  # Best for code review\n\n[sandbox]\ndefault = \"read-only\"  # Safe for reviews\n\n[review]\n# Custom review instructions applied to all reviews\ninstructions = \"\"\"\nFocus on:\n1. Security vulnerabilities (OWASP Top 10)\n2. Performance issues (N+1 queries, memory leaks)\n3. Error handling gaps\n4. Type safety issues\n\"\"\"\n```\n\n### Per-Project Config\n\n```toml\n# .codex/config.toml (in project root)\n\n[review]\ninstructions = \"\"\"\nThis is a Python FastAPI project. Focus on:\n- Async/await correctness\n- Pydantic model validation\n- SQL injection via SQLAlchemy\n- Authentication/authorization gaps\n\"\"\"\n```\n\n---\n\n## CLI Quick Reference\n\n```bash\n# Interactive\ncodex                          # Start TUI\n/review                        # Open review presets\n\n# Headless\ncodex exec \"prompt\"            # Non-interactive execution\ncodex exec --json \"prompt\"     # JSON output\ncodex exec --full-auto \"prompt\"  # No approval prompts\n\n# Key Flags\n--output-last-message FILE     # Save response to file\n--output-schema FILE           # Validate against JSON schema\n--sandbox read-only            # Restrict file access\n-m gpt-5.2-codex              # Use best review model\n--json                         # Machine-readable output\n\n# Resume\ncodex exec resume SESSION_ID   # Continue previous session\n```\n\n---\n\n## Comparison: Claude vs Codex Review\n\n| Aspect | Claude (Built-in) | Codex CLI |\n|--------|-------------------|-----------|\n| **Setup** | None (already in Claude Code) | Install CLI + auth |\n| **Model** | Claude | GPT-5.2-Codex (specialized) |\n| **Context** | Full conversation context | Fresh context per review |\n| **Integration** | Native | GitHub, GitLab, Jenkins |\n| **Output** | Markdown | JSON schema support |\n| **Best for** | Quick reviews, in-flow | CI/CD, critical PRs |\n\n---\n\n## Security Considerations\n\n### CI/CD Safety\n\n```yaml\n# Always use these flags in CI/CD:\n--sandbox read-only           # Prevent file modifications\n--safety-strategy drop-sudo   # Revoke elevated permissions\n```\n\n### API Key Protection\n\n```yaml\n# GitHub Actions - use secrets\nenv:\n  OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}\n\n# Never hardcode keys\n# Never echo keys in logs\n```\n\n### Public Repositories\n\nFor public repos, use `drop-sudo` safety strategy to prevent Codex from reading its own API key during execution.\n\n---\n\n## Troubleshooting\n\n| Issue | Solution |\n|-------|----------|\n| `codex: command not found` | Run `npm install -g @openai/codex` |\n| `Node.js version error` | Upgrade to Node.js 22+ |\n| `Authentication failed` | Re-run `codex` and sign in again |\n| `API key invalid` | Check `OPENAI_API_KEY` env var |\n| `Timeout in CI` | Add `--timeout 300` flag |\n| `Rate limited` | Reduce frequency or upgrade plan |\n\n---\n\n## Anti-Patterns\n\n- **Using `--dangerously-bypass-approvals-and-sandbox` casually** - Only in isolated CI runners\n- **Exposing API keys in logs** - Use secrets management\n- **Skipping sandbox in CI** - Always use `--sandbox read-only`\n- **Ignoring findings** - Review and address or document exceptions\n- **Running on every commit** - Use on PRs only to save costs\n"
  },
  {
    "path": "skills/commit-hygiene/SKILL.md",
    "content": "---\nname: commit-hygiene\ndescription: Atomic commits, PR size limits, commit thresholds, stacked PRs\nwhen-to-use: When committing code, creating PRs, or when change set is growing large\nuser-invocable: false\neffort: low\n---\n\n# Commit Hygiene Skill\n\n\n**Purpose:** Keep commits atomic, PRs reviewable, and git history clean. Advise when it's time to commit before changes become too large.\n\n---\n\n## Core Philosophy\n\n```\n┌─────────────────────────────────────────────────────────────────┐\n│  ATOMIC COMMITS                                                  │\n│  ─────────────────────────────────────────────────────────────  │\n│  One logical change per commit.                                  │\n│  Each commit should be self-contained and deployable.            │\n│  If you need \"and\" to describe it, split it.                     │\n├─────────────────────────────────────────────────────────────────┤\n│  SMALL PRS WIN                                                   │\n│  ─────────────────────────────────────────────────────────────  │\n│  < 400 lines changed = reviewed in < 1 hour                      │\n│  > 1000 lines = likely rubber-stamped or abandoned               │\n│  Smaller PRs = faster reviews, fewer bugs, easier reverts        │\n├─────────────────────────────────────────────────────────────────┤\n│  COMMIT EARLY, COMMIT OFTEN                                      │\n│  ─────────────────────────────────────────────────────────────  │\n│  Working code? Commit it.                                        │\n│  Test passing? Commit it.                                        │\n│  Don't wait for \"done\" - commit at every stable point.           │\n└─────────────────────────────────────────────────────────────────┘\n```\n\n---\n\n## Commit Size Thresholds\n\n### Warning Thresholds (Time to Commit!)\n\n| Metric | Yellow Zone | Red Zone | Action |\n|--------|-------------|----------|--------|\n| **Files changed** | 5-10 files | > 10 files | Commit NOW |\n| **Lines added** | 150-300 lines | > 300 lines | Commit NOW |\n| **Lines deleted** | 100-200 lines | > 200 lines | Commit NOW |\n| **Total changes** | 250-400 lines | > 400 lines | Commit NOW |\n| **Time since last commit** | 30-60 min | > 60 min | Consider committing |\n\n### Ideal Commit Size\n\n```\n┌─────────────────────────────────────────────────────────────────┐\n│  IDEAL COMMIT                                                    │\n│  ─────────────────────────────────────────────────────────────  │\n│  Files: 1-5                                                      │\n│  Lines: 50-200 total changes                                     │\n│  Scope: Single logical unit of work                              │\n│  Message: Describes ONE thing                                    │\n└─────────────────────────────────────────────────────────────────┘\n```\n\n---\n\n## Check Current State (Run Frequently)\n\n### Quick Status Check\n\n```bash\n# See what's changed (staged + unstaged)\ngit status --short\n\n# Count files and lines changed\ngit diff --stat\ngit diff --cached --stat  # Staged only\n\n# Get totals\ngit diff --shortstat\n# Example output: 8 files changed, 245 insertions(+), 32 deletions(-)\n```\n\n### Detailed Change Analysis\n\n```bash\n# Full diff summary with file names\ngit diff --stat HEAD\n\n# Just the numbers\ngit diff --numstat HEAD | awk '{add+=$1; del+=$2} END {print \"+\"add\" -\"del\" total:\"add+del}'\n\n# Files changed count\ngit status --porcelain | wc -l\n```\n\n### Pre-Commit Check Script\n\n```bash\n#!/bin/bash\n# scripts/check-commit-size.sh\n\n# Thresholds\nMAX_FILES=10\nMAX_LINES=400\nWARN_FILES=5\nWARN_LINES=200\n\n# Get stats\nFILES=$(git status --porcelain | wc -l | tr -d ' ')\nSTATS=$(git diff --shortstat HEAD 2>/dev/null)\nINSERTIONS=$(echo \"$STATS\" | grep -oE '[0-9]+ insertion' | grep -oE '[0-9]+' || echo 0)\nDELETIONS=$(echo \"$STATS\" | grep -oE '[0-9]+ deletion' | grep -oE '[0-9]+' || echo 0)\nTOTAL=$((INSERTIONS + DELETIONS))\n\necho \"📊 Current changes: $FILES files, +$INSERTIONS -$DELETIONS ($TOTAL total lines)\"\n\n# Check thresholds\nif [ \"$FILES\" -gt \"$MAX_FILES\" ] || [ \"$TOTAL\" -gt \"$MAX_LINES\" ]; then\n    echo \"🔴 RED ZONE: Commit immediately! Changes are too large.\"\n    echo \"   Consider splitting into multiple commits.\"\n    exit 1\nelif [ \"$FILES\" -gt \"$WARN_FILES\" ] || [ \"$TOTAL\" -gt \"$WARN_LINES\" ]; then\n    echo \"🟡 WARNING: Changes getting large. Commit soon.\"\n    exit 0\nelse\n    echo \"🟢 OK: Changes are within healthy limits.\"\n    exit 0\nfi\n```\n\n---\n\n## When to Commit\n\n### Commit Triggers (Any One = Commit)\n\n| Trigger | Example |\n|---------|---------|\n| **Test passes** | Just got a test green → commit |\n| **Feature complete** | Finished a function → commit |\n| **Refactor done** | Renamed variable across files → commit |\n| **Bug fixed** | Fixed the issue → commit |\n| **Before switching context** | About to work on something else → commit |\n| **Clean compile** | Code compiles/lints clean → commit |\n| **Threshold hit** | > 5 files or > 200 lines → commit |\n\n### Commit Immediately If\n\n- ✅ Tests are passing after being red\n- ✅ You're about to make a \"big change\"\n- ✅ You've been coding for 30+ minutes\n- ✅ You're about to try something risky\n- ✅ The current state is \"working\"\n\n### Don't Wait For\n\n- ❌ \"Perfect\" code\n- ❌ All features done\n- ❌ Full test coverage\n- ❌ Code review from yourself\n- ❌ Documentation complete\n\n---\n\n## Atomic Commit Patterns\n\n### Good Atomic Commits\n\n```\n✅ \"Add email validation to signup form\"\n   - 3 files: validator.ts, signup.tsx, signup.test.ts\n   - 120 lines changed\n   - Single purpose: email validation\n\n✅ \"Fix null pointer in user lookup\"\n   - 2 files: userService.ts, userService.test.ts\n   - 25 lines changed\n   - Single purpose: fix one bug\n\n✅ \"Refactor: Extract PaymentProcessor class\"\n   - 4 files: payment.ts → paymentProcessor.ts + types\n   - 180 lines changed\n   - Single purpose: refactoring\n```\n\n### Bad Commits (Too Large)\n\n```\n❌ \"Add authentication, fix bugs, update styles\"\n   - 25 files changed\n   - 800 lines changed\n   - Multiple purposes mixed\n\n❌ \"WIP\"\n   - Unknown scope\n   - No clear purpose\n   - Hard to review/revert\n\n❌ \"Updates\"\n   - 15 files changed\n   - Mix of features, fixes, refactors\n   - Impossible to review properly\n```\n\n---\n\n## Splitting Large Changes\n\n### Strategy 1: By Layer\n\n```\nInstead of one commit with:\n  - API endpoint + database migration + frontend + tests\n\nSplit into:\n  1. \"Add users table migration\"\n  2. \"Add User model and repository\"\n  3. \"Add GET /users endpoint\"\n  4. \"Add UserList component\"\n  5. \"Add integration tests for user flow\"\n```\n\n### Strategy 2: By Feature Slice\n\n```\nInstead of one commit with:\n  - All CRUD operations for users\n\nSplit into:\n  1. \"Add create user functionality\"\n  2. \"Add read user functionality\"\n  3. \"Add update user functionality\"\n  4. \"Add delete user functionality\"\n```\n\n### Strategy 3: Refactor First\n\n```\nInstead of:\n  - Feature + refactoring mixed\n\nSplit into:\n  1. \"Refactor: Extract validation helpers\" (no behavior change)\n  2. \"Add email validation using new helpers\" (new feature)\n```\n\n### Strategy 4: By Risk Level\n\n```\nInstead of:\n  - Safe changes + risky changes together\n\nSplit into:\n  1. \"Update dependencies\" (safe, isolated)\n  2. \"Migrate to new API version\" (risky, separate)\n```\n\n---\n\n## PR Size Guidelines\n\n### Optimal PR Size\n\n| Metric | Optimal | Acceptable | Too Large |\n|--------|---------|------------|-----------|\n| **Files** | 1-10 | 10-20 | > 20 |\n| **Lines changed** | 50-200 | 200-400 | > 400 |\n| **Commits** | 1-5 | 5-10 | > 10 |\n| **Review time** | < 30 min | 30-60 min | > 60 min |\n\n### PR Size vs Defect Rate\n\n```\n┌─────────────────────────────────────────────────────────────────┐\n│  RESEARCH FINDINGS (Google, Microsoft studies)                  │\n│  ─────────────────────────────────────────────────────────────  │\n│  PRs < 200 lines: 15% defect rate                               │\n│  PRs 200-400 lines: 23% defect rate                             │\n│  PRs > 400 lines: 40%+ defect rate                              │\n│                                                                 │\n│  Review quality drops sharply after 200-400 lines.              │\n│  Large PRs get \"LGTM\" rubber stamps, not real reviews.          │\n└─────────────────────────────────────────────────────────────────┘\n```\n\n### When PR is Too Large\n\n```bash\n# Check PR size before creating\ngit diff main --stat\ngit diff main --shortstat\n\n# If too large, consider:\n# 1. Split into multiple PRs (stacked PRs)\n# 2. Create feature flag and merge incrementally\n# 3. Use draft PR for early feedback\n```\n\n---\n\n## Commit Message Format\n\n### Structure\n\n```\n<type>: <description> (50 chars max)\n\n[optional body - wrap at 72 chars]\n\n[optional footer]\n```\n\n### Types\n\n| Type | Use For |\n|------|---------|\n| `feat` | New feature |\n| `fix` | Bug fix |\n| `refactor` | Code change that neither fixes nor adds |\n| `test` | Adding/updating tests |\n| `docs` | Documentation only |\n| `style` | Formatting, no code change |\n| `chore` | Build, config, dependencies |\n\n### Examples\n\n```\nfeat: Add email validation to signup form\n\nfix: Prevent null pointer in user lookup\n\nrefactor: Extract PaymentProcessor class\n\ntest: Add integration tests for checkout flow\n\nchore: Update dependencies to latest versions\n```\n\n---\n\n## Git Workflow Integration\n\n### Pre-Commit Hook for Size Check\n\n```bash\n#!/bin/bash\n# .git/hooks/pre-commit\n\nMAX_LINES=400\nMAX_FILES=15\n\nFILES=$(git diff --cached --name-only | wc -l | tr -d ' ')\nSTATS=$(git diff --cached --shortstat)\nINSERTIONS=$(echo \"$STATS\" | grep -oE '[0-9]+ insertion' | grep -oE '[0-9]+' || echo 0)\nDELETIONS=$(echo \"$STATS\" | grep -oE '[0-9]+ deletion' | grep -oE '[0-9]+' || echo 0)\nTOTAL=$((INSERTIONS + DELETIONS))\n\nif [ \"$TOTAL\" -gt \"$MAX_LINES\" ]; then\n    echo \"❌ Commit too large: $TOTAL lines (max: $MAX_LINES)\"\n    echo \"   Consider splitting into smaller commits.\"\n    echo \"   Use 'git add -p' for partial staging.\"\n    exit 1\nfi\n\nif [ \"$FILES\" -gt \"$MAX_FILES\" ]; then\n    echo \"❌ Too many files: $FILES (max: $MAX_FILES)\"\n    echo \"   Consider splitting into smaller commits.\"\n    exit 1\nfi\n\necho \"✅ Commit size OK: $FILES files, $TOTAL lines\"\n```\n\n### Partial Staging (Split Large Changes)\n\n```bash\n# Stage specific hunks interactively\ngit add -p\n\n# Stage specific files\ngit add path/to/specific/file.ts\n\n# Stage with preview\ngit add -N file.ts  # Intent to add\ngit diff            # See what would be added\ngit add file.ts     # Actually add\n```\n\n### Unstage If Too Large\n\n```bash\n# Unstage everything\ngit reset HEAD\n\n# Unstage specific files\ngit reset HEAD path/to/file.ts\n\n# Stage just what you need for THIS commit\ngit add -p\n```\n\n---\n\n## Claude Integration\n\n### Periodic Check During Development\n\n**Claude should run this check after every significant change:**\n\n```bash\n# Quick status\ngit diff --shortstat HEAD\n```\n\n**Thresholds for Claude to advise committing:**\n\n| Condition | Claude Action |\n|-----------|---------------|\n| > 5 files changed | Suggest: \"Consider committing current changes\" |\n| > 200 lines changed | Suggest: \"Changes are getting large, commit recommended\" |\n| > 10 files OR > 400 lines | Warn: \"⚠️ Commit now before changes become unmanageable\" |\n| Test just passed | Suggest: \"Good checkpoint - commit these passing tests\" |\n| Refactoring complete | Suggest: \"Refactoring done - commit before adding features\" |\n\n### Claude Commit Reminder Messages\n\n```\n📊 Status: 7 files changed, +180 -45 (225 total)\n💡 Approaching commit threshold. Consider committing current work.\n\n---\n\n📊 Status: 12 files changed, +320 -80 (400 total)\n⚠️ Changes are large! Commit now to keep PRs reviewable.\n   Suggested commit: \"feat: Add user authentication flow\"\n\n---\n\n📊 Status: 3 files changed, +85 -10 (95 total)\n✅ Tests passing. Good time to commit!\n   Suggested commit: \"fix: Validate email format on signup\"\n```\n\n---\n\n## Stacked PRs (For Large Features)\n\nWhen a feature is genuinely large, use stacked PRs:\n\n```\n┌─────────────────────────────────────────────────────────────────┐\n│  STACKED PR PATTERN                                             │\n│  ─────────────────────────────────────────────────────────────  │\n│                                                                 │\n│  main ─────────────────────────────────────────────────────────│\n│    └── PR #1: Database schema (200 lines) ← Review first       │\n│         └── PR #2: API endpoints (250 lines) ← Review second   │\n│              └── PR #3: Frontend (300 lines) ← Review third    │\n│                                                                 │\n│  Each PR is reviewable independently.                           │\n│  Merge in order: #1 → #2 → #3                                   │\n└─────────────────────────────────────────────────────────────────┘\n```\n\n### Creating Stacked PRs\n\n```bash\n# Create base branch\ngit checkout -b feature/auth-schema\n# ... make changes ...\ngit commit -m \"feat: Add users table schema\"\ngit push -u origin feature/auth-schema\ngh pr create --base main --title \"feat: Add users table schema\"\n\n# Create next branch FROM the first\ngit checkout -b feature/auth-api\n# ... make changes ...\ngit commit -m \"feat: Add authentication API endpoints\"\ngit push -u origin feature/auth-api\ngh pr create --base feature/auth-schema --title \"feat: Add auth API endpoints\"\n\n# And so on...\n```\n\n---\n\n## Checklist\n\n### Before Every Commit\n\n- [ ] Changes are for ONE logical purpose\n- [ ] Tests pass (if applicable)\n- [ ] Lint/typecheck pass\n- [ ] < 10 files changed\n- [ ] < 400 lines total\n- [ ] Commit message describes ONE thing\n\n### Before Creating PR\n\n- [ ] Total lines < 400 (ideal < 200)\n- [ ] All commits are atomic\n- [ ] No \"WIP\" or \"fixup\" commits\n- [ ] PR title describes the change\n- [ ] Description explains why, not just what\n\n### Red Flags (Stop and Split)\n\n- ❌ Commit message needs \"and\"\n- ❌ > 10 files in one commit\n- ❌ > 400 lines in one commit\n- ❌ Mix of features, fixes, and refactors\n- ❌ \"I'll clean this up later\"\n\n---\n\n## Quick Reference\n\n### Thresholds\n\n```\nFiles:  ≤ 5 = 🟢  |  6-10 = 🟡  |  > 10 = 🔴\nLines:  ≤ 200 = 🟢  |  201-400 = 🟡  |  > 400 = 🔴\nTime:   ≤ 30min = 🟢  |  30-60min = 🟡  |  > 60min = 🔴\n```\n\n### Commands\n\n```bash\n# Quick status\ngit diff --shortstat HEAD\n\n# Detailed file list\ngit diff --stat HEAD\n\n# Partial staging\ngit add -p\n\n# Check before PR\ngit diff main --shortstat\n```\n\n### Commit Now If\n\n- ✅ Tests just passed\n- ✅ > 200 lines changed\n- ✅ > 5 files changed\n- ✅ About to switch tasks\n- ✅ Current state is \"working\"\n"
  },
  {
    "path": "skills/cpg-analysis/SKILL.md",
    "content": "---\nname: cpg-analysis\ndescription: Deep code property graph analysis with Joern CPG (AST+CFG+PDG) and CodeQL for control flow, data flow, taint analysis, and security auditing\nwhen-to-use: \"When deep code analysis is needed — control flow, data flow, taint tracking, or security auditing\"\nuser-invocable: true\neffort: high\n---\n\n# CPG Analysis Skill\n\n\n**Purpose:** Deep code analysis beyond AST. Use Joern for full Code\nProperty Graph (control flow, data flow, program dependencies) and CodeQL\nfor interprocedural taint analysis and vulnerability detection.\n\n**These are opt-in tools.** They require Docker/JVM (Joern) or CodeQL CLI.\nUse codebase-memory-mcp (Tier 1, always-on) for everyday navigation.\nUse these for deep analysis when Tier 1 is not enough.\n\n```\n┌────────────────────────────────────────────────────────────────┐\n│  CODE PROPERTY GRAPH = AST + CFG + CDG + DDG + PDG             │\n│  ─────────────────────────────────────────────────────────────│\n│  AST  = Abstract Syntax Tree (structure)                       │\n│  CFG  = Control Flow Graph (execution paths)                   │\n│  CDG  = Control Dependency Graph (conditional dependencies)    │\n│  DDG  = Data Dependency Graph (data flow between statements)   │\n│  PDG  = Program Dependency Graph (CDG + DDG combined)          │\n│                                                                │\n│  Tier 2 (Joern): Full CPG with 40+ query tools                │\n│  Tier 3 (CodeQL): Interprocedural taint + security queries     │\n└────────────────────────────────────────────────────────────────┘\n```\n\n---\n\n## Tier Selection Guide\n\n```\nSimple symbol lookup, dependency trace, blast radius?\n  → Tier 1: codebase-memory-mcp (always on, sub-ms)\n\nControl flow paths, data flow, dead code, complex refactoring?\n  → Tier 2: Joern CPG (on-demand, seconds)\n\nSecurity audit, taint analysis, vulnerability detection?\n  → Tier 3: CodeQL (on-demand, seconds to minutes)\n\nFull security review before release?\n  → All three tiers in sequence\n```\n\n---\n\n## Tier 2: Joern CPG (CodeBadger MCP)\n\n### When to Use Joern\n\n| Scenario | Why Joern | Tier 1 Can't Do This |\n|----------|-----------|---------------------|\n| Trace data flow through functions | Full DDG traversal | Tier 1 has no data flow |\n| Understanding control flow paths | CFG analysis with branch conditions | Tier 1 has no CFG |\n| Finding dead/unreachable code | PDG reachability analysis | Tier 1 only detects unused exports |\n| Complex refactoring impact | Cross-function dependency chains | Tier 1 limited to call graph |\n| Auditing third-party library usage | Deep call chain traversal | Tier 1 stops at import boundary |\n| Understanding exception flow | CFG includes throw/catch paths | Tier 1 ignores exceptions |\n\n### Key MCP Tools (Joern/CodeBadger)\n\n| Tool | Purpose | Example Query |\n|------|---------|---------------|\n| `generate_cpg` | Build CPG for project | First-time setup or after major changes |\n| `get_cpg_status` | Check CPG build status | Verify CPG is ready before querying |\n| `run_cpgql_query` | Run arbitrary CPGQL queries | `cpg.method(\"login\").callOut.code.l` |\n| `get_cpgql_syntax_help` | Query language reference | When unsure about query syntax |\n| `get_cfg` | Control flow graph for a method | Understand execution paths in a function |\n| `list_methods` | List all methods in project | Overview of available functions |\n| `get_method_source` | Get source code of a method | Read specific function source |\n| `list_calls` | List calls from/to a method | Caller/callee analysis |\n| `get_call_graph` | Full call graph visualization | Understand call chains |\n| `get_type_definition` | Type/class definitions | Understand type hierarchy |\n\n### Supported Languages (Joern)\n\nJava, Scala, C/C++, Python, JavaScript, TypeScript, PHP, Ruby, Go,\nKotlin, Swift, Lua\n\n**Not supported:** Rust (use CodeQL for Rust)\n\n### MCP Configuration (Joern)\n\n```json\n{\n  \"mcpServers\": {\n    \"codebadger\": {\n      \"url\": \"http://localhost:4242/mcp\",\n      \"type\": \"http\"\n    }\n  }\n}\n```\n\n### Prerequisites\n\n- Docker (for Joern backend)\n- Python 3.10+ (for MCP server)\n- Install: `~/.claude/install-graph-tools.sh --joern`\n\n### Common CPGQL Queries\n\n```scala\n// Find all methods that handle user input\ncpg.method.where(_.parameter.name(\".*input.*|.*request.*\")).name.l\n\n// Trace data flow from parameter to return\ncpg.method(\"processPayment\").parameter.reachableBy(cpg.method(\"processPayment\").methodReturn).l\n\n// Find methods with high cyclomatic complexity\ncpg.method.where(_.controlStructure.size > 10).name.l\n\n// Dead code: methods with no callers\ncpg.method.where(_.callIn.size == 0).filter(_.name != \"main\").name.l\n\n// Exception flow: methods that can throw but callers don't catch\ncpg.method.where(_.ast.isThrow.size > 0).callIn.method.filter(_.ast.isTry.size == 0).name.l\n```\n\n---\n\n## Tier 3: CodeQL\n\n### When to Use CodeQL\n\n| Scenario | Why CodeQL | Other Tiers Can't Do This |\n|----------|-----------|--------------------------|\n| Security audit before release | Interprocedural taint analysis | Joern has basic taint, CodeQL is deeper |\n| Reviewing auth/payment code | Data flow from source to sink | Cross-function, cross-file taint |\n| PR security review | Targeted vulnerability scan | Pre-built OWASP query packs |\n| Compliance checking | CWE/OWASP pattern matching | Curated security query suites |\n| Rust security analysis | Full Rust support | Joern doesn't support Rust |\n\n### Key MCP Tools (CodeQL)\n\n| Tool | Purpose |\n|------|---------|\n| `run_query` | Execute a CodeQL query against the database |\n| `find_definitions` | Locate symbol definitions |\n| `find_references` | Find all references to a symbol |\n| `get_results` | Parse BQRS (Binary Query Result Sets) |\n\n### Supported Languages (CodeQL)\n\nC/C++, C#, Go, Java, Kotlin, JavaScript, TypeScript, Python, Ruby,\nSwift, **Rust**\n\n### MCP Configuration (CodeQL)\n\n```json\n{\n  \"mcpServers\": {\n    \"codeql\": {\n      \"command\": \"codeql-mcp\",\n      \"args\": [\"--database\", \".code-graph/codeql-db\"]\n    }\n  }\n}\n```\n\n### Prerequisites\n\n- CodeQL CLI (`brew install codeql` on macOS)\n- Install: `~/.claude/install-graph-tools.sh --codeql`\n\n### Common CodeQL Patterns\n\n```ql\n// SQL injection: user input flows to SQL query\nimport python\nfrom DataFlow::PathNode source, DataFlow::PathNode sink\nwhere TaintTracking::hasFlowPath(source, sink)\n  and source instanceof RemoteFlowSource\n  and sink instanceof SqlExecution\nselect sink, source, sink, \"SQL injection from $@.\", source, \"user input\"\n\n// Unvalidated redirect\nfrom DataFlow::PathNode source, DataFlow::PathNode sink\nwhere source instanceof RemoteFlowSource\n  and sink instanceof RedirectSink\nselect sink, \"Unvalidated redirect from user input\"\n```\n\n---\n\n## Combined Workflow: Deep Analysis\n\nWhen performing security review or complex refactoring, use all tiers:\n\n```\n1. SCOPE       → Tier 1: detect_changes / get_architecture\n                 Identify files and modules in scope\n\n2. STRUCTURE   → Tier 1: search_graph / trace_call_path\n                 Map the call graph and dependencies\n\n3. FLOW        → Tier 2: get_cfg / run_cpgql_query\n                 Analyze control flow and data flow paths\n\n4. SECURITY    → Tier 3: run_query with taint analysis\n                 Check for vulnerabilities in data paths\n\n5. REPORT      → Combine findings from all tiers\n                 Prioritize: Critical > High > Medium > Low\n```\n\n---\n\n## Anti-Patterns\n\n| Anti-Pattern | Do This Instead |\n|-------------|-----------------|\n| Using Joern/CodeQL for simple symbol lookup | Use Tier 1 `search_graph` (sub-ms vs seconds) |\n| Running full CPG build on every commit | Build CPG on-demand; use Tier 1 for continuous monitoring |\n| Querying Joern without checking `get_cpg_status` | Always verify CPG is built and current before querying |\n| Running CodeQL without a specific security question | Have a hypothesis first; CodeQL queries are expensive |\n| Ignoring Tier 1 blast radius before deep analysis | Always scope with Tier 1 first, then go deep on flagged areas |\n| Using CodeQL for non-security structural queries | Use Joern CPGQL for structural/flow queries; CodeQL for security |\n"
  },
  {
    "path": "skills/credentials/SKILL.md",
    "content": "---\nname: credentials\ndescription: Centralized API key management from Access.txt\nwhen-to-use: When setting up a new project that needs API keys or environment variables\nuser-invocable: false\neffort: low\n---\n\n# Credentials Management Skill\n\n\nFor securely loading API keys from a centralized access file and configuring project environments.\n\n---\n\n## Credentials File Discovery\n\n**REQUIRED**: When a project needs API keys, ask the user:\n\n```\nI need API credentials for [service]. Do you have a centralized access keys file?\n\nPlease provide the path (e.g., ~/Documents/Access.txt) or type 'manual' to enter keys directly.\n```\n\n### Default Locations to Check\n\n```bash\n~/Documents/Access.txt\n~/Access.txt\n~/.secrets/keys.txt\n~/.credentials.txt\n```\n\n---\n\n## Supported File Formats\n\nThe credentials file can use any of these formats:\n\n### Format 1: Colon-separated\n```\nRender API: rnd_xxxxx\nOpenAI API: sk-proj-xxxxx\nClaude API: sk-ant-xxxxx\nReddit client id: xxxxx\nReddit secret: xxxxx\n```\n\n### Format 2: Key=Value\n```\nRENDER_API_KEY=rnd_xxxxx\nOPENAI_API_KEY=sk-proj-xxxxx\nANTHROPIC_API_KEY=sk-ant-xxxxx\n```\n\n### Format 3: Mixed/Informal\n```\nReddit api access:\nclient id Y1FgKALKmb6f6UxFtyMXfA\nand secret is -QLoYdxMqOJkYrgk5KeGPa6Ps6vIiQ\n```\n\n---\n\n## Key Identification Patterns\n\nUse these patterns to identify keys in the file:\n\n| Service | Pattern | Env Variable |\n|---------|---------|--------------|\n| OpenAI | `sk-proj-*` or `sk-*` | `OPENAI_API_KEY` |\n| Claude/Anthropic | `sk-ant-*` | `ANTHROPIC_API_KEY` |\n| Render | `rnd_*` | `RENDER_API_KEY` |\n| Eleven Labs | `sk_*` (not sk-ant/sk-proj) | `ELEVEN_LABS_API_KEY` |\n| Replicate | `r8_*` | `REPLICATE_API_TOKEN` |\n| Supabase | URL + `eyJ*` (JWT) | `SUPABASE_URL`, `SUPABASE_ANON_KEY`, `SUPABASE_SERVICE_ROLE_KEY` |\n| Reddit | client_id + secret pair | `REDDIT_CLIENT_ID`, `REDDIT_CLIENT_SECRET` |\n| GitHub | `ghp_*` or `github_pat_*` | `GITHUB_TOKEN` |\n| Vercel | `*_*` (from vercel.com) | `VERCEL_TOKEN` |\n| Stripe (Test) | `sk_test_*`, `pk_test_*` | `STRIPE_SECRET_KEY`, `STRIPE_PUBLISHABLE_KEY` |\n| Stripe (Live) | `sk_live_*`, `pk_live_*` | `STRIPE_SECRET_KEY`, `STRIPE_PUBLISHABLE_KEY` |\n| Stripe Webhook | `whsec_*` | `STRIPE_WEBHOOK_SECRET` |\n| Twilio | `SK*` + Account SID | `TWILIO_API_KEY`, `TWILIO_ACCOUNT_SID` |\n| SendGrid | `SG.*` | `SENDGRID_API_KEY` |\n| AWS | `AKIA*` + secret | `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` |\n| PostHog | `phc_*` | `POSTHOG_API_KEY`, `NEXT_PUBLIC_POSTHOG_KEY` |\n\n---\n\n## Parsing Credentials File\n\nWhen reading the user's access file, extract keys using these rules:\n\n```python\n# Python parsing logic\nimport re\nfrom pathlib import Path\n\ndef parse_credentials_file(file_path: str) -> dict[str, str]:\n    \"\"\"Parse various credential file formats.\"\"\"\n    content = Path(file_path).expanduser().read_text()\n    credentials = {}\n\n    # Pattern matching for known key formats\n    patterns = {\n        'OPENAI_API_KEY': r'sk-proj-[A-Za-z0-9_-]+',\n        'ANTHROPIC_API_KEY': r'sk-ant-[A-Za-z0-9_-]+',\n        'RENDER_API_KEY': r'rnd_[A-Za-z0-9]+',\n        'REPLICATE_API_TOKEN': r'r8_[A-Za-z0-9]+',\n        'ELEVEN_LABS_API_KEY': r'sk_[a-f0-9]{40,}',\n        'GITHUB_TOKEN': r'ghp_[A-Za-z0-9]+|github_pat_[A-Za-z0-9_]+',\n        'STRIPE_SECRET_KEY': r'sk_(live|test)_[A-Za-z0-9]+',\n        'STRIPE_PUBLISHABLE_KEY': r'pk_(live|test)_[A-Za-z0-9]+',\n        'STRIPE_WEBHOOK_SECRET': r'whsec_[A-Za-z0-9]+',\n        'POSTHOG_API_KEY': r'phc_[A-Za-z0-9]+',\n    }\n\n    # Supabase requires special handling (URL + JWT tokens)\n    supabase_url = re.search(r'https://[a-z0-9]+\\.supabase\\.co', content)\n    anon_key = re.search(r'anon[^:]*:\\s*(eyJ[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+)', content, re.I)\n    service_role = re.search(r'service.?role[^:]*:\\s*(eyJ[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+)', content, re.I)\n\n    if supabase_url:\n        credentials['SUPABASE_URL'] = supabase_url.group(0)\n    if anon_key:\n        credentials['SUPABASE_ANON_KEY'] = anon_key.group(1)\n    if service_role:\n        credentials['SUPABASE_SERVICE_ROLE_KEY'] = service_role.group(1)\n\n    for env_var, pattern in patterns.items():\n        match = re.search(pattern, content)\n        if match:\n            credentials[env_var] = match.group(0)\n\n    # Reddit requires special handling (client_id + secret pair)\n    reddit_id = re.search(r'client.?id[:\\s]+([A-Za-z0-9_-]+)', content, re.I)\n    reddit_secret = re.search(r'secret[:\\s]+([A-Za-z0-9_-]+)', content, re.I)\n    if reddit_id:\n        credentials['REDDIT_CLIENT_ID'] = reddit_id.group(1)\n    if reddit_secret:\n        credentials['REDDIT_CLIENT_SECRET'] = reddit_secret.group(1)\n\n    return credentials\n```\n\n```typescript\n// TypeScript parsing logic\nfunction parseCredentialsFile(content: string): Record<string, string> {\n  const credentials: Record<string, string> = {};\n\n  const patterns: Record<string, RegExp> = {\n    OPENAI_API_KEY: /sk-proj-[A-Za-z0-9_-]+/,\n    ANTHROPIC_API_KEY: /sk-ant-[A-Za-z0-9_-]+/,\n    RENDER_API_KEY: /rnd_[A-Za-z0-9]+/,\n    REPLICATE_API_TOKEN: /r8_[A-Za-z0-9]+/,\n    ELEVEN_LABS_API_KEY: /sk_[a-f0-9]{40,}/,\n    GITHUB_TOKEN: /ghp_[A-Za-z0-9]+|github_pat_[A-Za-z0-9_]+/,\n    STRIPE_SECRET_KEY: /sk_(live|test)_[A-Za-z0-9]+/,\n    STRIPE_PUBLISHABLE_KEY: /pk_(live|test)_[A-Za-z0-9]+/,\n    STRIPE_WEBHOOK_SECRET: /whsec_[A-Za-z0-9]+/,\n    POSTHOG_API_KEY: /phc_[A-Za-z0-9]+/,\n  };\n\n  for (const [envVar, pattern] of Object.entries(patterns)) {\n    const match = content.match(pattern);\n    if (match) credentials[envVar] = match[0];\n  }\n\n  // Reddit pair\n  const redditId = content.match(/client.?id[:\\s]+([A-Za-z0-9_-]+)/i);\n  const redditSecret = content.match(/secret[:\\s]+([A-Za-z0-9_-]+)/i);\n  if (redditId) credentials.REDDIT_CLIENT_ID = redditId[1];\n  if (redditSecret) credentials.REDDIT_CLIENT_SECRET = redditSecret[1];\n\n  return credentials;\n}\n```\n\n---\n\n## Validation Commands\n\nAfter extracting keys, validate them:\n\n### OpenAI\n```bash\ncurl -s -o /dev/null -w \"%{http_code}\" \\\n  -H \"Authorization: Bearer $OPENAI_API_KEY\" \\\n  https://api.openai.com/v1/models\n# 200 = valid\n```\n\n### Anthropic/Claude\n```bash\ncurl -s -o /dev/null -w \"%{http_code}\" \\\n  -H \"x-api-key: $ANTHROPIC_API_KEY\" \\\n  -H \"anthropic-version: 2023-06-01\" \\\n  https://api.anthropic.com/v1/models\n# 200 = valid\n```\n\n### Render\n```bash\ncurl -s -o /dev/null -w \"%{http_code}\" \\\n  -H \"Authorization: Bearer $RENDER_API_KEY\" \\\n  https://api.render.com/v1/services\n# 200 = valid\n```\n\n### Reddit\n```bash\n# Get OAuth token first\nTOKEN=$(curl -s -X POST \\\n  -u \"$REDDIT_CLIENT_ID:$REDDIT_CLIENT_SECRET\" \\\n  -d \"grant_type=client_credentials\" \\\n  -A \"CredentialTest/1.0\" \\\n  https://www.reddit.com/api/v1/access_token | jq -r '.access_token')\n# Non-null token = valid\n```\n\n### Replicate\n```bash\ncurl -s -o /dev/null -w \"%{http_code}\" \\\n  -H \"Authorization: Token $REPLICATE_API_TOKEN\" \\\n  https://api.replicate.com/v1/models\n# 200 = valid\n```\n\n---\n\n## Project Setup Workflow\n\nWhen initializing a project that needs API keys:\n\n### Step 1: Ask for Credentials File\n```\nThis project needs the following API keys:\n- ANTHROPIC_API_KEY (for Claude)\n- SUPABASE_URL and SUPABASE_ANON_KEY\n\nDo you have an access keys file? Please provide the path:\n```\n\n### Step 2: Read and Parse\n```python\n# Read the file\ncredentials = parse_credentials_file(\"~/Documents/Access.txt\")\n\n# Show what was found\nprint(\"Found credentials:\")\nfor key, value in credentials.items():\n    masked = value[:8] + \"...\" + value[-4:]\n    print(f\"  {key}: {masked}\")\n```\n\n### Step 3: Validate Keys\n```\nValidating credentials...\n✓ ANTHROPIC_API_KEY: Valid\n✓ REDDIT_CLIENT_ID: Valid\n✗ SUPABASE_URL: Not found in file\n```\n\n### Step 4: Create .env File\n```bash\n# Write to project .env\ncat > .env << EOF\n# Auto-generated from ~/Documents/Access.txt\nANTHROPIC_API_KEY=sk-ant-xxx...\nREDDIT_CLIENT_ID=xxx...\nREDDIT_CLIENT_SECRET=xxx...\nEOF\n\n# Add to .gitignore if not present\necho \".env\" >> .gitignore\n```\n\n### Step 5: Report Missing Keys\n```\nMissing credentials that need manual setup:\n- SUPABASE_URL: Get from supabase.com/dashboard/project/[ref]/settings/api\n- SUPABASE_ANON_KEY: Same location as above\n\nWould you like me to open these URLs?\n```\n\n---\n\n## Service-Specific Setup Guides\n\n### Reddit (from Access.txt)\n```\nFound in your access file:\n- REDDIT_CLIENT_ID: Y1FgKA...\n- REDDIT_CLIENT_SECRET: -QLoYd...\n\nAlso needed (add to Access.txt or enter manually):\n- REDDIT_USER_AGENT: YourApp/1.0 by YourUsername\n```\n\n### Supabase (typically not in file)\n```\nSupabase credentials are project-specific. Get them from:\nhttps://supabase.com/dashboard/project/[your-ref]/settings/api\n\nRequired:\n- SUPABASE_URL\n- SUPABASE_ANON_KEY\n- SUPABASE_SERVICE_ROLE_KEY (for admin operations)\n```\n\n---\n\n## Security Rules\n\n- **NEVER** commit Access.txt or its path to git\n- **NEVER** log full API keys - always mask middle characters\n- **ALWAYS** add `.env` to `.gitignore`\n- **ALWAYS** use environment variables, never hardcode keys\n- **VALIDATE** keys before using them in production setup\n\n---\n\n## Quick Reference\n\n```bash\n# Check if credentials file exists\nls -la ~/Documents/Access.txt\n\n# Common env var names\nOPENAI_API_KEY\nANTHROPIC_API_KEY\nRENDER_API_KEY\nREDDIT_CLIENT_ID\nREDDIT_CLIENT_SECRET\nREPLICATE_API_TOKEN\nELEVEN_LABS_API_KEY\nSUPABASE_URL\nSUPABASE_ANON_KEY\nGITHUB_TOKEN\n```\n\n### Prompt Template\n```\nI need API credentials for this project.\n\nDo you have a centralized access keys file (like ~/Documents/Access.txt)?\n\nIf yes, provide the path and I'll:\n1. Read and parse your keys\n2. Validate they're working\n3. Set up your project's .env file\n4. Tell you which keys are missing\n```\n"
  },
  {
    "path": "skills/cross-agent-delegation/SKILL.md",
    "content": "---\nname: cross-agent-delegation\ndescription: Cross-agent task routing — Codex auto-review, Kimi delegation by complexity score (iCPG + Claude reasoning), iCPG + Mnemos mandatory for all agents\nwhen-to-use: Always loaded when multiple AI CLI tools are available (Claude, Kimi, Codex)\nuser-invocable: false\neffort: medium\n---\n\n# Cross-Agent Delegation\n\nClaude Code orchestrates task routing to Kimi and Codex. The user interacts with Claude only — delegation happens behind the scenes.\n\n---\n\n## Tool Detection\n\nAt session start, detect available tools:\n\n```bash\ncommand -v kimi &>/dev/null && HAS_KIMI=true || HAS_KIMI=false\ncommand -v codex &>/dev/null && HAS_CODEX=true || HAS_CODEX=false\n```\n\n---\n\n## Codex Auto-Review (Stop Hook — Automatic)\n\nWhen Codex is installed, a Stop hook reviews code after tests pass:\n\n1. TDD loop check runs tests\n2. `codex-auto-review.sh` runs Codex on the diff\n3. Critical/High findings feed back to Claude (exit 2)\n4. Clean reviews pass through (exit 0)\n\n**Fully automatic.** No user or Claude action needed.\n\n---\n\n## Kimi Delegation (Claude Orchestrates)\n\nWhen Kimi is installed and the task complexity is bounded, Claude delegates directly — the user does not need to run anything.\n\n### Step 1: Score complexity, not file count\n\nFile count is a poor proxy for delegation risk. A 1-file change to an authz path is harder than a 12-file rename. Score the task on five dimensions, each 0-2, sourced from iCPG signals plus Claude's semantic reasoning:\n\n| Dimension | 0 (low) | 1 (medium) | 2 (high) | Source |\n|---|---|---|---|---|\n| **Cyclomatic / surface depth** | <10 LOC, no branches | 10-50 LOC, ≤3 branches | 50+ LOC or nested control flow | iCPG `query_graph` over function bodies |\n| **Fan-out (consumer blast radius)** | 0-2 callers | 3-10 callers | 11+ callers | iCPG `trace_path(<symbol>, mode=callers)` |\n| **Crosses a security boundary** (SEC-006, auth, PII, RLS, org-scope, billing, payments) | None | Tangential | Direct read or write | iCPG SEC-* / R-063 tags + grep for `org_id`, `user_id`, `auth`, `pii` |\n| **Concurrency / transactional** | Pure / sync | Async only | Locks, transactions, atomic claims, `FOR UPDATE`, `asyncio.Lock`, `session.begin` | iCPG concurrency flags + grep |\n| **Domain invariants required** | None / well-documented inline | Some implicit (need to read 1-2 files) | Heavy (cross-doc, ADR-bound, RFC-bound) | Claude reasoning + iCPG ADR linkage |\n\n```bash\n# Auto-collect signals\nicpg query blast <scope> --format json    # fan-out, async flags, sec tags\ngrep -rE \"org_id|user_id|auth|pii\"  <file>  # cheap sec heuristic if iCPG flags absent\ngrep -rE \"asyncio.Lock|FOR UPDATE|session.begin\" <file>  # concurrency heuristic\n```\n\n### Step 2: Sum → routing\n\n| Total score | Route | Rationale |\n|---|---|---|\n| **0-3** | Kimi solo | Bounded surface, no security/concurrency/cross-doc concerns |\n| **4-6** | Kimi → Codex auto-review (no user prompt) | Real risk, but not so high that we need full Claude context — Codex catches what Kimi might miss |\n| **7-10** | Claude handles directly | Cross-cutting / security-critical / concurrency-heavy — needs full context |\n\n### Step 3: Floor — trivial-case shortcut\n\nTo skip iCPG-query cost on truly trivial work:\n\n```bash\n# If <2 files changed AND no SEC/auth/PII/concurrency keyword in diff,\n# → auto-Kimi without scoring.\nFILES=$(git diff --name-only | wc -l)\nHAS_RISK_KEYWORDS=$(git diff | grep -ciE \"org_id|auth|pii|asyncio|FOR UPDATE|transaction|session\\.begin\" || true)\nif [ \"$FILES\" -lt 2 ] && [ \"$HAS_RISK_KEYWORDS\" -eq 0 ]; then\n  AUTO_KIMI=true\nfi\n```\n\nThis handles the trivial-rename / typo-fix case without paying the iCPG round-trip.\n\n### When NOT to Delegate (overrides scoring)\n\n- User explicitly asked Claude to do it\n- Cross-service changes (API + frontend + database) — needs full context regardless of score\n- Production hotfix on a release branch — cross-tool review latency is too high\n- Score 7+ in any single dimension (one critical axis is enough to keep Claude in the loop)\n\n### Step 4: Delegate via Bash\n\nClaude writes a mnemos checkpoint, then runs Kimi headless:\n\n```bash\n# 1. Save current context to disk\nmnemos checkpoint --force\n\n# 2. Get context summary for Kimi\nCONTEXT=$(mnemos resume 2>/dev/null)\n\n# 3. Get constraints for target files\nCONSTRAINTS=$(icpg query constraints <target-file> 2>/dev/null)\n\n# 4. Run Kimi headless with full context\nkimi --print -y -w . -p \"\n## Context (from mnemos checkpoint)\n$CONTEXT\n\n## Constraints (from iCPG)\n$CONSTRAINTS\n\n## Task\n<specific task description>\n\n## Rules\n- Run tests after changes\n- Record changes: icpg record --base main\n- Write checkpoint when done: mnemos checkpoint --force\n\"\n```\n\n### Step 4: Read Results\n\nAfter Kimi finishes, Claude:\n\n```bash\n# Read what Kimi did\nmnemos resume          # Kimi's checkpoint\nicpg status            # Kimi's recorded symbols\ngit diff               # Kimi's file changes\n```\n\n### When NOT to Delegate\n\n- Security-sensitive code (auth, crypto, payments)\n- Cross-service changes (API + frontend + database)\n- Refactors that touch shared interfaces\n- User explicitly asked Claude to do it\n\n---\n\n## iCPG — Mandatory for All Agents\n\nBefore ANY code change, Claude runs these (and includes results when delegating):\n\n### Pre-Task Queries\n\n```bash\n# 1. Duplicate check — already done?\nicpg query prior \"<goal>\"\n\n# 2. Constraints — what invariants apply?\nicpg query constraints <file-path>\n\n# 3. Risk — is this symbol fragile?\nicpg query risk <symbol-name>\n```\n\n### After Code Changes\n\n```bash\nicpg record --reason <id> --base main\nicpg drift check\n```\n\n---\n\n## Mnemos — Mandatory for All Agents\n\n### At Task Start\n\n```bash\nmnemos add goal \"<task description>\"\n```\n\n### At Sub-Goal Boundaries\n\n```bash\nmnemos checkpoint\n```\n\n### At Task End (auto-handled by Stop hook)\n\n```bash\nmnemos checkpoint --force\n```\n\n### Context Transfer Between Tools\n\nThe checkpoint is the bridge. Claude writes it, Kimi reads it:\n\n```bash\n# Claude saves state\nmnemos checkpoint --force\n\n# Kimi (or Codex) reads state\nmnemos resume\n```\n\nThe checkpoint contains: goal, constraints, recent files, git state, fatigue level.\n\n---\n\n## Full Orchestration Flow\n\n```\nTASK ARRIVES (user tells Claude)\n    |\n    v\n[1] Claude: icpg query prior \"<goal>\"     ← Already done?\n[2] Claude: trivial-case shortcut         ← <2 files & no risk keywords?\n    |\n    +-- YES + Kimi installed -----> AUTO-KIMI (no scoring)\n    |\n    +-- NO ↓\n    v\n[3] Claude: score complexity (5 dims × 0-2, iCPG + reasoning)\n    |\n    +-- score 0-3   ----> KIMI SOLO PATH\n    |   [a] mnemos checkpoint --force\n    |   [b] kimi --print -y -p \"...\"\n    |   [c] mnemos resume + git diff\n    |   [d] Continue in Claude\n    |\n    +-- score 4-6   ----> KIMI + CODEX REVIEW PATH\n    |   [a] mnemos checkpoint --force\n    |   [b] kimi --print -y -p \"...\"\n    |   [c] codex review --uncommitted    ← Auto-review the diff\n    |   [d] If P0/P1 findings: re-prompt Kimi with findings\n    |   [e] Once clean: continue in Claude\n    |\n    +-- score 7-10  ----> CLAUDE DIRECT PATH (full context)\n    |\n    v\n[4] icpg query constraints <files>         ← Invariants\n[5] icpg query risk <symbols>              ← Fragility\n[6] mnemos add goal \"<task>\"               ← Track in memory\n    |\n    v\n[7] IMPLEMENT (TDD: RED -> GREEN)\n    |\n    v\n[8]  Stop: tdd-loop-check.sh               ← Tests pass?\n[9]  Stop: codex-auto-review.sh            ← Codex reviews diff\n[10] Stop: icpg-stop-record.sh             ← Record symbols\n[11] Stop: mnemos-checkpoint.sh            ← Save memory\n```\n"
  },
  {
    "path": "skills/database-schema/SKILL.md",
    "content": "---\nname: database-schema\ndescription: Schema awareness - read before coding, type generation, prevent column errors\nwhen-to-use: Before writing any database queries or modifying data models\nuser-invocable: false\npaths: [\"**/schema.*\", \"**/migrations/**\", \"**/models/**\", \"**/*.prisma\", \"**/drizzle/**\"]\neffort: medium\n---\n\n# Database Schema Awareness Skill\n\n\n**Problem:** Claude forgets schema details mid-session - wrong column names, missing fields, incorrect types. TDD catches this at runtime, but we can prevent it earlier.\n\n---\n\n## Core Rule: Read Schema Before Writing Database Code\n\n**MANDATORY: Before writing ANY code that touches the database:**\n\n```\n┌─────────────────────────────────────────────────────────────┐\n│  1. READ the schema file (see locations below)              │\n│  2. VERIFY columns/types you're about to use exist          │\n│  3. REFERENCE schema in your response when writing queries  │\n│  4. TYPE-CHECK using generated types (Drizzle/Prisma/etc)   │\n└─────────────────────────────────────────────────────────────┘\n```\n\n**If schema file doesn't exist → CREATE IT before proceeding.**\n\n---\n\n## Schema File Locations (By Stack)\n\n| Stack | Schema Location | Type Generation |\n|-------|-----------------|-----------------|\n| **Drizzle** | `src/db/schema.ts` or `drizzle/schema.ts` | Built-in TypeScript |\n| **Prisma** | `prisma/schema.prisma` | `npx prisma generate` |\n| **Supabase** | `supabase/migrations/*.sql` + types | `supabase gen types typescript` |\n| **SQLAlchemy** | `app/models/*.py` or `src/models.py` | Pydantic models |\n| **TypeORM** | `src/entities/*.ts` | Decorators = types |\n| **Raw SQL** | `schema.sql` or `migrations/` | Manual types required |\n\n### Schema Reference File (Recommended)\n\nCreate `_project_specs/schema-reference.md` for quick lookup:\n\n```markdown\n# Database Schema Reference\n\n*Auto-generated or manually maintained. Claude: READ THIS before database work.*\n\n## Tables\n\n### users\n| Column | Type | Nullable | Default | Notes |\n|--------|------|----------|---------|-------|\n| id | uuid | NO | gen_random_uuid() | PK |\n| email | text | NO | - | Unique |\n| name | text | YES | - | Display name |\n| created_at | timestamptz | NO | now() | - |\n| updated_at | timestamptz | NO | now() | - |\n\n### orders\n| Column | Type | Nullable | Default | Notes |\n|--------|------|----------|---------|-------|\n| id | uuid | NO | gen_random_uuid() | PK |\n| user_id | uuid | NO | - | FK → users.id |\n| status | text | NO | 'pending' | enum: pending/paid/shipped/delivered |\n| total_cents | integer | NO | - | Amount in cents |\n| created_at | timestamptz | NO | now() | - |\n\n## Relationships\n- users 1:N orders (user_id)\n\n## Enums\n- order_status: pending, paid, shipped, delivered\n```\n\n---\n\n## Pre-Code Checklist (Database Work)\n\nBefore writing any database code, Claude MUST:\n\n```markdown\n### Schema Verification Checklist\n- [ ] Read schema file: `[path to schema]`\n- [ ] Columns I'm using exist: [list columns]\n- [ ] Types match my code: [list type mappings]\n- [ ] Relationships are correct: [list FKs]\n- [ ] Nullable fields handled: [list nullable columns]\n```\n\n**Example in practice:**\n\n```markdown\n### Schema Verification for TODO-042 (Add order history endpoint)\n\n- [x] Read schema: `src/db/schema.ts`\n- [x] Columns exist: orders.id, orders.user_id, orders.status, orders.total_cents, orders.created_at\n- [x] Types: id=uuid→string, total_cents=integer→number, status=text→OrderStatus enum\n- [x] Relationships: orders.user_id → users.id (many-to-one)\n- [x] Nullable: none of these columns are nullable\n```\n\n---\n\n## Type Generation Commands\n\n### Drizzle (TypeScript)\n\n```typescript\n// Schema defines types automatically\n// src/db/schema.ts\nimport { pgTable, uuid, text, integer, timestamp } from 'drizzle-orm/pg-core';\n\nexport const users = pgTable('users', {\n  id: uuid('id').primaryKey().defaultRandom(),\n  email: text('email').notNull().unique(),\n  name: text('name'),\n  createdAt: timestamp('created_at').notNull().defaultNow(),\n});\n\nexport const orders = pgTable('orders', {\n  id: uuid('id').primaryKey().defaultRandom(),\n  userId: uuid('user_id').notNull().references(() => users.id),\n  status: text('status').notNull().default('pending'),\n  totalCents: integer('total_cents').notNull(),\n  createdAt: timestamp('created_at').notNull().defaultNow(),\n});\n\n// Inferred types - USE THESE\nexport type User = typeof users.$inferSelect;\nexport type NewUser = typeof users.$inferInsert;\nexport type Order = typeof orders.$inferSelect;\nexport type NewOrder = typeof orders.$inferInsert;\n```\n\n### Prisma\n\n```prisma\n// prisma/schema.prisma\nmodel User {\n  id        String   @id @default(uuid())\n  email     String   @unique\n  name      String?\n  orders    Order[]\n  createdAt DateTime @default(now()) @map(\"created_at\")\n\n  @@map(\"users\")\n}\n\nmodel Order {\n  id         String   @id @default(uuid())\n  userId     String   @map(\"user_id\")\n  user       User     @relation(fields: [userId], references: [id])\n  status     String   @default(\"pending\")\n  totalCents Int      @map(\"total_cents\")\n  createdAt  DateTime @default(now()) @map(\"created_at\")\n\n  @@map(\"orders\")\n}\n```\n\n```bash\n# Generate types after schema changes\nnpx prisma generate\n```\n\n### Supabase\n\n```bash\n# Generate TypeScript types from live database\nsupabase gen types typescript --local > src/types/database.ts\n\n# Or from remote\nsupabase gen types typescript --project-id your-project-id > src/types/database.ts\n```\n\n```typescript\n// Use generated types\nimport { Database } from '@/types/database';\n\ntype User = Database['public']['Tables']['users']['Row'];\ntype NewUser = Database['public']['Tables']['users']['Insert'];\ntype Order = Database['public']['Tables']['orders']['Row'];\n```\n\n### SQLAlchemy (Python)\n\n```python\n# app/models/user.py\nfrom sqlalchemy import Column, String, DateTime\nfrom sqlalchemy.dialects.postgresql import UUID\nfrom sqlalchemy.sql import func\nfrom app.db import Base\nimport uuid\n\nclass User(Base):\n    __tablename__ = \"users\"\n\n    id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)\n    email = Column(String, nullable=False, unique=True)\n    name = Column(String, nullable=True)\n    created_at = Column(DateTime(timezone=True), server_default=func.now())\n\n    # Relationships\n    orders = relationship(\"Order\", back_populates=\"user\")\n```\n\n```python\n# app/schemas/user.py - Pydantic for API validation\nfrom pydantic import BaseModel, EmailStr\nfrom uuid import UUID\nfrom datetime import datetime\n\nclass UserBase(BaseModel):\n    email: EmailStr\n    name: str | None = None\n\nclass UserCreate(UserBase):\n    pass\n\nclass User(UserBase):\n    id: UUID\n    created_at: datetime\n\n    class Config:\n        from_attributes = True\n```\n\n---\n\n## Schema-Aware TDD Workflow\n\nExtend the standard TDD workflow for database work:\n\n```\n┌─────────────────────────────────────────────────────────────┐\n│  0. SCHEMA: Read and verify schema before anything else     │\n│     └─ Read schema file                                     │\n│     └─ Complete Schema Verification Checklist               │\n│     └─ Note any missing columns/tables needed               │\n├─────────────────────────────────────────────────────────────┤\n│  1. RED: Write tests that use correct column names          │\n│     └─ Import generated types                               │\n│     └─ Use type-safe queries in tests                       │\n│     └─ Tests should fail on logic, NOT schema errors        │\n├─────────────────────────────────────────────────────────────┤\n│  2. GREEN: Implement with type-safe queries                 │\n│     └─ Use ORM types, not raw strings                       │\n│     └─ TypeScript/mypy catches column mismatches            │\n├─────────────────────────────────────────────────────────────┤\n│  3. VALIDATE: Type check catches schema drift               │\n│     └─ tsc --noEmit / mypy catches wrong columns            │\n│     └─ Tests validate runtime behavior                      │\n└─────────────────────────────────────────────────────────────┘\n```\n\n---\n\n## Common Schema Mistakes (And How to Prevent)\n\n| Mistake | Example | Prevention |\n|---------|---------|------------|\n| Wrong column name | `user.userName` vs `user.name` | Read schema, use generated types |\n| Wrong type | `totalCents` as string | Type generation catches this |\n| Missing nullable check | `user.name!` when nullable | Schema shows nullable fields |\n| Wrong FK relationship | `order.userId` vs `order.user_id` | Check schema column names |\n| Missing column | Using `user.avatar` that doesn't exist | Read schema before coding |\n| Wrong enum value | `status: 'complete'` vs `'completed'` | Document enums in schema reference |\n\n### Type-Safe Query Examples\n\n**Drizzle (catches errors at compile time):**\n```typescript\n// ✅ Correct - uses schema-defined columns\nconst user = await db.select().from(users).where(eq(users.email, email));\n\n// ❌ Wrong - TypeScript error: 'userName' doesn't exist\nconst user = await db.select().from(users).where(eq(users.userName, email));\n```\n\n**Prisma (catches errors at compile time):**\n```typescript\n// ✅ Correct\nconst user = await prisma.user.findUnique({ where: { email } });\n\n// ❌ Wrong - TypeScript error\nconst user = await prisma.user.findUnique({ where: { userName: email } });\n```\n\n**Raw SQL (NO protection - avoid):**\n```typescript\n// ❌ Dangerous - no type checking, easy to get wrong\nconst result = await db.query('SELECT * FROM users WHERE user_name = $1', [email]);\n// Should be 'email' not 'user_name' - won't catch until runtime\n```\n\n---\n\n## Migration Workflow\n\nWhen schema changes are needed:\n\n```\n┌─────────────────────────────────────────────────────────────┐\n│  1. Update schema file (Drizzle/Prisma/SQLAlchemy)          │\n├─────────────────────────────────────────────────────────────┤\n│  2. Generate migration                                       │\n│     └─ Drizzle: npx drizzle-kit generate                    │\n│     └─ Prisma: npx prisma migrate dev --name add_column     │\n│     └─ Supabase: supabase migration new add_column          │\n├─────────────────────────────────────────────────────────────┤\n│  3. Regenerate types                                         │\n│     └─ Prisma: npx prisma generate                          │\n│     └─ Supabase: supabase gen types typescript              │\n├─────────────────────────────────────────────────────────────┤\n│  4. Update schema-reference.md                               │\n├─────────────────────────────────────────────────────────────┤\n│  5. Run type check - find all broken code                    │\n│     └─ npm run typecheck                                    │\n├─────────────────────────────────────────────────────────────┤\n│  6. Fix type errors, update tests, run full validation       │\n└─────────────────────────────────────────────────────────────┘\n```\n\n---\n\n## Session Start Protocol\n\n**When starting a session that involves database work:**\n\n1. Read schema file immediately\n2. Read `_project_specs/schema-reference.md` if exists\n3. Note in session state what tables/columns are relevant\n4. Reference schema explicitly when writing code\n\n**Session state example:**\n```markdown\n## Current Session - Database Context\n\n**Schema read:** ✓ src/db/schema.ts\n**Tables in scope:** users, orders, order_items\n**Key columns:**\n- users: id, email, name, created_at\n- orders: id, user_id, status, total_cents\n- order_items: id, order_id, product_id, quantity, price_cents\n```\n\n---\n\n## Anti-Patterns\n\n- ❌ **Guessing column names** - Always read schema first\n- ❌ **Using raw SQL strings** - Use ORM with type generation\n- ❌ **Hardcoding without verification** - Check schema before using any column\n- ❌ **Ignoring type errors** - Schema drift shows up as type errors\n- ❌ **Not regenerating types** - After migration, always regenerate\n- ❌ **Assuming nullable** - Check schema for nullable columns\n\n---\n\n## Checklist\n\n### Setup\n- [ ] Schema file exists in standard location\n- [ ] Type generation configured\n- [ ] `_project_specs/schema-reference.md` created\n- [ ] Types regenerate on schema change\n\n### Per-Task\n- [ ] Schema read before writing database code\n- [ ] Schema Verification Checklist completed\n- [ ] Using generated types (not raw strings)\n- [ ] Type check passes (catches column errors)\n- [ ] Tests use correct schema\n"
  },
  {
    "path": "skills/existing-repo/SKILL.md",
    "content": "---\nname: existing-repo\ndescription: Analyze existing repositories, maintain structure, setup guardrails and best practices\nwhen-to-use: When working with an existing codebase for the first time or adding guardrails\nuser-invocable: true\nallowed-tools: [Read, Glob, Grep, Bash]\neffort: high\n---\n\n# Existing Repository Skill\n\n\nFor working with existing codebases - analyze structure, respect conventions, and set up proper guardrails without breaking anything.\n\n**Sources:** [Husky](https://typicode.github.io/husky/) | [lint-staged](https://github.com/lint-staged/lint-staged) | [pre-commit](https://pre-commit.com/) | [commitlint](https://commitlint.js.org/)\n\n---\n\n## Core Principle\n\n**Understand before modifying.** Existing repos have conventions, patterns, and history. Your job is to work within them, not reorganize them.\n\n---\n\n## Phase 1: Repository Analysis\n\n**ALWAYS run this analysis first when joining an existing repo.**\n\n### 1.1 Basic Detection\n\n```bash\n# Check git status\ngit remote -v 2>/dev/null\ngit branch -a 2>/dev/null\ngit log --oneline -5 2>/dev/null\n\n# Check for existing configs\nls -la .* 2>/dev/null | head -20\nls *.json *.toml *.yaml *.yml 2>/dev/null\n```\n\n### 1.2 Tech Stack Detection\n\n```bash\n# JavaScript/TypeScript\nls package.json tsconfig.json 2>/dev/null\n\n# Python\nls pyproject.toml setup.py requirements*.txt 2>/dev/null\n\n# Mobile\nls pubspec.yaml 2>/dev/null          # Flutter\nls android/build.gradle 2>/dev/null   # Android\nls ios/*.xcodeproj 2>/dev/null        # iOS\n\n# Other\nls Cargo.toml 2>/dev/null             # Rust\nls go.mod 2>/dev/null                 # Go\nls Gemfile 2>/dev/null                # Ruby\n```\n\n### 1.3 Repo Structure Type\n\n| Pattern | Detection | Meaning |\n|---------|-----------|---------|\n| **Monorepo** | `packages/`, `apps/`, `workspaces` in package.json | Multiple projects, shared tooling |\n| **Full-Stack Monolith** | `frontend/` + `backend/` in same repo | Single team, tightly coupled |\n| **Separate Concerns** | Only frontend OR backend code | Split repos, separate deploys |\n| **Microservices** | Multiple `service-*` or domain dirs | Distributed architecture |\n\n```bash\n# Detect repo structure type\nif [ -d \"packages\" ] || [ -d \"apps\" ]; then\n    echo \"MONOREPO detected\"\nelif [ -d \"frontend\" ] && [ -d \"backend\" ]; then\n    echo \"FULL-STACK MONOLITH detected\"\nelif [ -d \"src\" ] || [ -d \"app\" ]; then\n    # Check if it's frontend or backend\n    grep -q \"react\\|vue\\|angular\" package.json 2>/dev/null && echo \"FRONTEND detected\"\n    grep -q \"fastapi\\|express\\|django\" package.json pyproject.toml 2>/dev/null && echo \"BACKEND detected\"\nfi\n```\n\n### 1.4 Directory Mapping\n\n```bash\n# Get directory structure (max 3 levels)\nfind . -type d -maxdepth 3 \\\n    -not -path \"*/node_modules/*\" \\\n    -not -path \"*/.git/*\" \\\n    -not -path \"*/venv/*\" \\\n    -not -path \"*/__pycache__/*\" \\\n    -not -path \"*/dist/*\" \\\n    -not -path \"*/build/*\" \\\n    2>/dev/null | head -50\n\n# Identify key directories\nfor dir in src app lib core services api routes components pages hooks utils models; do\n    [ -d \"$dir\" ] && echo \"Found: $dir/\"\ndone\n```\n\n### 1.5 Entry Points\n\n```bash\n# Find main entry points\nls index.ts index.js main.ts main.py app.py server.ts server.js 2>/dev/null\ncat package.json 2>/dev/null | grep -A1 '\"main\"'\ncat pyproject.toml 2>/dev/null | grep -A1 'scripts'\n```\n\n---\n\n## Phase 2: Convention Detection\n\n**Identify and document existing patterns before making changes.**\n\n### 2.1 Code Style\n\n```bash\n# Check for formatters\nls .prettierrc* .editorconfig .eslintrc* biome.json 2>/dev/null  # JS/TS\nls pyproject.toml | xargs grep -l \"ruff\\|black\\|isort\" 2>/dev/null  # Python\n\n# Check indent style from existing files\nhead -20 src/**/*.ts 2>/dev/null | grep \"^\\s\" | head -1  # tabs vs spaces\n```\n\n### 2.2 Testing Setup\n\n```bash\n# JS/TS testing\ngrep -l \"jest\\|vitest\\|mocha\\|playwright\" package.json 2>/dev/null\nls jest.config.* vitest.config.* playwright.config.* 2>/dev/null\n\n# Python testing\ngrep -l \"pytest\\|unittest\" pyproject.toml 2>/dev/null\nls pytest.ini conftest.py 2>/dev/null\n\n# Test directories\nls -d tests/ test/ __tests__/ spec/ 2>/dev/null\n```\n\n### 2.3 CI/CD Setup\n\n```bash\n# Check existing workflows\nls -la .github/workflows/ 2>/dev/null\nls .gitlab-ci.yml Jenkinsfile .circleci/ 2>/dev/null\n\n# Check deploy configs\nls vercel.json render.yaml fly.toml railway.json Dockerfile 2>/dev/null\n```\n\n### 2.4 Documentation Style\n\n```bash\n# Find README pattern\nhead -30 README.md 2>/dev/null\n\n# Find existing docs\nls -la docs/ documentation/ wiki/ 2>/dev/null\nls CONTRIBUTING.md CHANGELOG.md 2>/dev/null\n```\n\n---\n\n## Phase 3: Guardrails Audit\n\n**Check what guardrails exist and what's missing.**\n\n### 3.1 Pre-commit Hooks Status\n\n```bash\n# Check for hook managers\nls .husky/ 2>/dev/null && echo \"Husky installed\"\nls .pre-commit-config.yaml 2>/dev/null && echo \"pre-commit framework installed\"\nls .git/hooks/pre-commit 2>/dev/null && echo \"Manual pre-commit hook exists\"\n\n# Check what hooks run\ncat .husky/pre-commit 2>/dev/null\ncat .pre-commit-config.yaml 2>/dev/null\n```\n\n### 3.2 Linting Status\n\n```bash\n# JS/TS linting\ngrep -q \"eslint\" package.json && echo \"ESLint configured\"\ngrep -q \"biome\" package.json && echo \"Biome configured\"\nls .eslintrc* biome.json 2>/dev/null\n\n# Python linting\ngrep -q \"ruff\" pyproject.toml && echo \"Ruff configured\"\ngrep -q \"flake8\" pyproject.toml setup.cfg && echo \"Flake8 configured\"\n```\n\n### 3.3 Type Checking Status\n\n```bash\n# TypeScript\nls tsconfig.json 2>/dev/null && echo \"TypeScript configured\"\ngrep \"strict\" tsconfig.json 2>/dev/null\n\n# Python type checking\ngrep -q \"mypy\" pyproject.toml && echo \"mypy configured\"\ngrep -q \"pyright\" pyproject.toml && echo \"pyright configured\"\nls py.typed 2>/dev/null\n```\n\n### 3.4 Commit Message Enforcement\n\n```bash\n# commitlint\nls commitlint.config.* 2>/dev/null && echo \"commitlint configured\"\ncat .husky/commit-msg 2>/dev/null\ngrep \"conventional\" package.json 2>/dev/null\n```\n\n### 3.5 Security Scanning\n\n```bash\n# Check for security tools\ngrep -q \"detect-secrets\\|trufflehog\" .pre-commit-config.yaml package.json 2>/dev/null\nls .github/workflows/*.yml | xargs grep -l \"security\\|audit\" 2>/dev/null\n```\n\n---\n\n## Phase 4: Guardrails Setup\n\n**Only add missing guardrails. Never overwrite existing configurations.**\n\n### 4.1 JavaScript/TypeScript Projects\n\n#### Husky + lint-staged (if not present)\n\n```bash\n# Check if already installed\nif [ ! -d \".husky\" ]; then\n    # Install Husky\n    npm install -D husky lint-staged\n    npx husky init\n\n    # Create pre-commit hook\n    echo 'npx lint-staged' > .husky/pre-commit\n    chmod +x .husky/pre-commit\nfi\n```\n\n**lint-staged config** (add to package.json if missing):\n\n```json\n{\n  \"lint-staged\": {\n    \"*.{ts,tsx,js,jsx}\": [\n      \"eslint --fix\",\n      \"prettier --write\"\n    ],\n    \"*.{json,md,yml,yaml}\": [\n      \"prettier --write\"\n    ]\n  }\n}\n```\n\n#### ESLint (if not present)\n\n```bash\n# Check if eslint exists\nif ! grep -q \"eslint\" package.json; then\n    npm install -D eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin\nfi\n```\n\n**eslint.config.js** (ESLint 9+ flat config):\n\n```javascript\nimport eslint from '@eslint/js'\nimport tseslint from 'typescript-eslint'\n\nexport default tseslint.config(\n  eslint.configs.recommended,\n  ...tseslint.configs.recommended,\n  {\n    rules: {\n      '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],\n      '@typescript-eslint/explicit-function-return-type': 'off',\n      'no-console': ['warn', { allow: ['warn', 'error'] }]\n    }\n  },\n  {\n    ignores: ['dist/', 'node_modules/', 'coverage/']\n  }\n)\n```\n\n#### Prettier (if not present)\n\n```bash\nif ! grep -q \"prettier\" package.json; then\n    npm install -D prettier\nfi\n```\n\n**.prettierrc** (respect existing style or use sensible defaults):\n\n```json\n{\n  \"semi\": false,\n  \"singleQuote\": true,\n  \"trailingComma\": \"es5\",\n  \"tabWidth\": 2,\n  \"printWidth\": 100\n}\n```\n\n#### commitlint (if not present)\n\n```bash\nif [ ! -f \"commitlint.config.js\" ]; then\n    npm install -D @commitlint/cli @commitlint/config-conventional\n    echo \"npx commitlint --edit \\$1\" > .husky/commit-msg\n    chmod +x .husky/commit-msg\nfi\n```\n\n**commitlint.config.js**:\n\n```javascript\nexport default {\n  extends: ['@commitlint/config-conventional'],\n  rules: {\n    'type-enum': [\n      2,\n      'always',\n      ['feat', 'fix', 'docs', 'style', 'refactor', 'test', 'chore', 'ci', 'perf', 'revert']\n    ],\n    'subject-case': [2, 'always', 'lower-case'],\n    'subject-max-length': [2, 'always', 72]\n  }\n}\n```\n\n### 4.2 Python Projects\n\n#### pre-commit framework (if not present)\n\n```bash\n# Install pre-commit\nif [ ! -f \".pre-commit-config.yaml\" ]; then\n    pip install pre-commit\n    pre-commit install\nfi\n```\n\n**.pre-commit-config.yaml**:\n\n```yaml\nrepos:\n  # Ruff - linting and formatting (replaces black, isort, flake8)\n  - repo: https://github.com/astral-sh/ruff-pre-commit\n    rev: v0.14.13\n    hooks:\n      - id: ruff\n        args: [--fix, --exit-non-zero-on-fix]\n      - id: ruff-format\n\n  # Type checking\n  - repo: https://github.com/pre-commit/mirrors-mypy\n    rev: v1.16.0\n    hooks:\n      - id: mypy\n        additional_dependencies: [types-requests]\n        args: [--ignore-missing-imports]\n\n  # Security\n  - repo: https://github.com/Yelp/detect-secrets\n    rev: v1.5.0\n    hooks:\n      - id: detect-secrets\n        args: ['--baseline', '.secrets.baseline']\n\n  # General\n  - repo: https://github.com/pre-commit/pre-commit-hooks\n    rev: v5.0.0\n    hooks:\n      - id: trailing-whitespace\n      - id: end-of-file-fixer\n      - id: check-yaml\n      - id: check-added-large-files\n      - id: check-merge-conflict\n\n  # Commit messages\n  - repo: https://github.com/compilerla/conventional-pre-commit\n    rev: v4.0.0\n    hooks:\n      - id: conventional-pre-commit\n        stages: [commit-msg]\n```\n\n#### pyproject.toml additions (if not present)\n\n```toml\n[tool.ruff]\ntarget-version = \"py312\"\nline-length = 100\n\n[tool.ruff.lint]\nselect = [\n    \"E\",   # pycodestyle errors\n    \"F\",   # pyflakes\n    \"I\",   # isort\n    \"B\",   # flake8-bugbear\n    \"UP\",  # pyupgrade\n    \"S\",   # flake8-bandit (security)\n]\nignore = [\"E501\"]  # line length handled by formatter\n\n[tool.mypy]\npython_version = \"3.12\"\nstrict = true\nignore_missing_imports = true\n\n[tool.pytest.ini_options]\ntestpaths = [\"tests\"]\naddopts = \"-v --cov=src --cov-report=term-missing --cov-fail-under=80\"\n```\n\n### 4.3 Branch Protection (Document for User)\n\nRecommend these GitHub branch protection rules:\n\n```markdown\n## Recommended Branch Protection (main branch)\n\n1. **Require pull request before merging**\n   - Require 1 approval\n   - Dismiss stale reviews on new commits\n\n2. **Require status checks**\n   - Lint\n   - Type check\n   - Tests\n   - Security scan\n\n3. **Require signed commits** (optional but recommended)\n\n4. **Do not allow bypassing above settings**\n```\n\n---\n\n## Phase 5: Structure Preservation Rules\n\n### NEVER Do These\n\n- **Don't reorganize directory structure** - Work within existing patterns\n- **Don't rename files for \"consistency\"** - Match existing naming conventions\n- **Don't add new patterns** - Use patterns already in the codebase\n- **Don't change import styles** - Match existing (relative vs absolute, etc.)\n- **Don't change formatting** - Match existing style or use existing formatter config\n- **Don't add new dependencies lightly** - Check if equivalent exists\n\n### ALWAYS Do These\n\n- **Read existing code first** - Understand patterns before writing new code\n- **Match existing conventions** - Naming, structure, error handling\n- **Use existing utilities** - Don't reinvent what exists\n- **Follow existing test patterns** - Match test file naming and structure\n- **Preserve existing configs** - Only add, don't modify unless fixing bugs\n\n### Convention Detection Checklist\n\nBefore writing any code, identify:\n\n| Convention | Example | Where to Check |\n|------------|---------|----------------|\n| Naming | camelCase vs snake_case | Existing file names |\n| File structure | feature/ vs type/ | Directory layout |\n| Export style | default vs named | Existing modules |\n| Error handling | throw vs return Error | Existing functions |\n| Logging | console vs logger | Existing code |\n| Testing | describe/it vs test() | Existing tests |\n| Comments | JSDoc vs inline | Existing code |\n\n---\n\n## Phase 6: Analysis Report Template\n\nAfter running analysis, generate this report:\n\n```markdown\n# Repository Analysis Report\n\n## Overview\n- **Repo Type**: [Monorepo | Full-Stack | Frontend | Backend | Microservices]\n- **Primary Language**: [TypeScript | Python | ...]\n- **Framework**: [React | FastAPI | ...]\n- **Age**: [X commits, Y contributors]\n\n## Directory Structure\n```\n[tree output]\n```\n\n## Tech Stack\n| Category | Technology | Config File |\n|----------|------------|-------------|\n| Language | TypeScript | tsconfig.json |\n| Framework | React | - |\n| Testing | Vitest | vitest.config.ts |\n| Linting | ESLint | eslint.config.js |\n| Formatting | Prettier | .prettierrc |\n\n## Guardrails Status\n\n### Present\n- [x] ESLint configured\n- [x] Prettier configured\n- [x] TypeScript strict mode\n\n### Missing (Recommended)\n- [ ] Pre-commit hooks (Husky + lint-staged)\n- [ ] Commit message validation (commitlint)\n- [ ] Security scanning in CI\n\n## Conventions Detected\n| Pattern | Observed | Example |\n|---------|----------|---------|\n| Naming | camelCase | `getUserById.ts` |\n| Imports | Absolute | `@/components/Button` |\n| Testing | Colocated | `Button.test.tsx` |\n| Exports | Named | `export { Button }` |\n\n## Recommendations\n1. Add Husky + lint-staged for pre-commit hooks\n2. Add commitlint for conventional commits\n3. Add security workflow to GitHub Actions\n\n## Files to Review First\n- `src/index.ts` - Main entry point\n- `src/utils/` - Shared utilities\n- `tests/setup.ts` - Test configuration\n```\n\n---\n\n## Gradual Implementation Strategy\n\nDon't add all guardrails at once. Follow this timeline:\n\n| Week | Focus | Why |\n|------|-------|-----|\n| 1 | Formatting (Prettier/Ruff) | Non-breaking, easy wins |\n| 2 | Linting (ESLint/Ruff) | Catches obvious issues |\n| 3 | Pre-commit hooks | Automates week 1-2 |\n| 4 | Commit message validation | Team consistency |\n| 5 | Type checking strictness | Catches runtime errors |\n| 6 | Security scanning | Catches vulnerabilities |\n\n---\n\n## Working with Separate Repos\n\nWhen frontend and backend are in separate repos:\n\n### Frontend Repo Setup\n\n```bash\n# Clone and analyze\ngit clone [frontend-repo]\ncd frontend\n\n# Run analysis\n# Expect: React/Vue/Angular, no backend code\n\n# Add frontend-specific guardrails\n# - Husky + lint-staged\n# - ESLint + Prettier\n# - Component testing (Vitest/Jest)\n```\n\n### Backend Repo Setup\n\n```bash\n# Clone and analyze\ngit clone [backend-repo]\ncd backend\n\n# Run analysis\n# Expect: FastAPI/Express/Django, no frontend code\n\n# Add backend-specific guardrails\n# - pre-commit framework\n# - Ruff + mypy\n# - API testing (pytest/Jest)\n```\n\n### Cross-Repo Coordination\n\n| Concern | Solution |\n|---------|----------|\n| Shared types | Generate from OpenAPI spec |\n| API contracts | Contract testing (Pact) |\n| Deployments | Coordinate via CI/CD triggers |\n| Versioning | Semantic versioning on both |\n\n---\n\n## Anti-Patterns\n\n- **Adding unused guardrails** - Only add what the team will use\n- **Strict rules on day 1** - Introduce gradually\n- **Blocking on warnings** - Start permissive, tighten over time\n- **Ignoring existing patterns** - Work with what exists\n- **Over-engineering** - Simple rules > complex systems\n- **Skipping the analysis phase** - Always understand before changing\n\n---\n\n## Quick Reference: Detection Commands\n\n```bash\n# One-liner repo analysis\necho \"=== Repo Type ===\" && \\\nls -d packages apps frontend backend 2>/dev/null || echo \"Standard repo\" && \\\necho \"=== Tech Stack ===\" && \\\nls *.json *.toml *.yaml 2>/dev/null && \\\necho \"=== Existing Guardrails ===\" && \\\nls .husky .pre-commit-config.yaml .eslintrc* 2>/dev/null || echo \"None detected\" && \\\necho \"=== Entry Points ===\" && \\\nls index.* main.* app.* server.* 2>/dev/null\n```\n"
  },
  {
    "path": "skills/firebase/SKILL.md",
    "content": "---\nname: firebase\ndescription: Firebase Firestore, Auth, Storage, real-time listeners, security rules\nwhen-to-use: When working with Firebase services\nuser-invocable: false\npaths: [\"**/firebase*\", \"firestore.rules\", \"storage.rules\", \"firebase.json\"]\neffort: medium\n---\n\n# Firebase Skill\n\n\nFirebase/Firestore patterns for web and mobile applications with real-time data, offline support, and security rules.\n\n**Sources:** [Firebase Docs](https://firebase.google.com/docs) | [Firestore Best Practices](https://firebase.google.com/docs/firestore/best-practices) | [Security Rules](https://firebase.google.com/docs/rules)\n\n---\n\n## Core Principle\n\n**Denormalize with purpose, secure with rules, scale horizontally.**\n\nFirestore is a document database - embrace denormalization for read efficiency. Security rules are your server-side validation. Design for your access patterns.\n\n---\n\n## Firebase Stack\n\n| Service | Purpose |\n|---------|---------|\n| **Firestore** | NoSQL document database with real-time sync |\n| **Authentication** | User auth, OAuth, anonymous sessions |\n| **Storage** | File uploads with security rules |\n| **Functions** | Serverless backend (Node.js) |\n| **Hosting** | Static site + CDN |\n| **Extensions** | Pre-built solutions (Stripe, Algolia, etc.) |\n\n---\n\n## Project Setup\n\n### Install Firebase CLI\n```bash\n# Install globally\nnpm install -g firebase-tools\n\n# Login\nfirebase login\n\n# Initialize in project\nfirebase init\n```\n\n### Initialize with Emulators\n```bash\nfirebase init emulators\n\n# Start local development\nfirebase emulators:start\n```\n\n### Project Structure\n```\nproject/\n├── firebase.json           # Firebase config\n├── firestore.rules         # Security rules\n├── firestore.indexes.json  # Composite indexes\n├── storage.rules           # Storage security rules\n└── functions/              # Cloud Functions\n    ├── src/\n    ├── package.json\n    └── tsconfig.json\n```\n\n---\n\n## Firestore Data Modeling\n\n### Document Structure\n```typescript\n// Good: Flat documents with all needed data\ninterface Post {\n  id: string;\n  title: string;\n  content: string;\n  authorId: string;\n  authorName: string;      // Denormalized for display\n  authorAvatar: string;    // Denormalized\n  tags: string[];\n  likeCount: number;       // Aggregated counter\n  createdAt: Timestamp;\n  updatedAt: Timestamp;\n}\n\n// Collection: posts/{postId}\n```\n\n### When to Use Subcollections\n```typescript\n// Use subcollections for:\n// 1. Unbounded lists (comments, messages)\n// 2. Data with different access patterns\n// 3. Data that grows independently\n\n// posts/{postId}/comments/{commentId}\ninterface Comment {\n  id: string;\n  text: string;\n  authorId: string;\n  authorName: string;\n  createdAt: Timestamp;\n}\n```\n\n### Data Model Patterns\n\n```typescript\n// Pattern 1: Embedded data (bounded, always needed)\ninterface User {\n  id: string;\n  email: string;\n  profile: {\n    displayName: string;\n    bio: string;\n    avatar: string;\n  };\n  settings: {\n    notifications: boolean;\n    theme: 'light' | 'dark';\n  };\n}\n\n// Pattern 2: Reference with denormalization\ninterface Order {\n  id: string;\n  userId: string;\n  userEmail: string;  // Denormalized for display\n  items: OrderItem[]; // Embedded (bounded)\n  total: number;\n  status: 'pending' | 'paid' | 'shipped';\n}\n\n// Pattern 3: Aggregation documents\n// Keep counters in parent document\ninterface Channel {\n  id: string;\n  name: string;\n  memberCount: number;  // Updated via Cloud Function\n  messageCount: number;\n}\n```\n\n---\n\n## TypeScript SDK (Modular v9+)\n\n### Initialize Firebase\n```typescript\n// lib/firebase.ts\nimport { initializeApp, getApps } from 'firebase/app';\nimport { getFirestore, connectFirestoreEmulator } from 'firebase/firestore';\nimport { getAuth, connectAuthEmulator } from 'firebase/auth';\nimport { getStorage, connectStorageEmulator } from 'firebase/storage';\n\nconst firebaseConfig = {\n  apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY,\n  authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,\n  projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,\n  storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET,\n  messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID,\n  appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID\n};\n\n// Initialize only once\nconst app = getApps().length === 0 ? initializeApp(firebaseConfig) : getApps()[0];\n\nexport const db = getFirestore(app);\nexport const auth = getAuth(app);\nexport const storage = getStorage(app);\n\n// Connect to emulators in development\nif (process.env.NODE_ENV === 'development') {\n  connectFirestoreEmulator(db, 'localhost', 8080);\n  connectAuthEmulator(auth, 'http://localhost:9099');\n  connectStorageEmulator(storage, 'localhost', 9199);\n}\n```\n\n### CRUD Operations\n```typescript\nimport {\n  collection,\n  doc,\n  getDoc,\n  getDocs,\n  addDoc,\n  setDoc,\n  updateDoc,\n  deleteDoc,\n  query,\n  where,\n  orderBy,\n  limit,\n  startAfter,\n  serverTimestamp,\n  Timestamp\n} from 'firebase/firestore';\nimport { db } from './firebase';\n\n// Create\nasync function createPost(data: Omit<Post, 'id' | 'createdAt' | 'updatedAt'>) {\n  const docRef = await addDoc(collection(db, 'posts'), {\n    ...data,\n    createdAt: serverTimestamp(),\n    updatedAt: serverTimestamp()\n  });\n  return docRef.id;\n}\n\n// Read single document\nasync function getPost(postId: string): Promise<Post | null> {\n  const docSnap = await getDoc(doc(db, 'posts', postId));\n  if (!docSnap.exists()) return null;\n  return { id: docSnap.id, ...docSnap.data() } as Post;\n}\n\n// Query with filters\nasync function getPostsByAuthor(authorId: string, pageSize = 10) {\n  const q = query(\n    collection(db, 'posts'),\n    where('authorId', '==', authorId),\n    orderBy('createdAt', 'desc'),\n    limit(pageSize)\n  );\n  const snapshot = await getDocs(q);\n  return snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() } as Post));\n}\n\n// Pagination\nasync function getNextPage(lastDoc: Post, pageSize = 10) {\n  const q = query(\n    collection(db, 'posts'),\n    orderBy('createdAt', 'desc'),\n    startAfter(lastDoc.createdAt),\n    limit(pageSize)\n  );\n  const snapshot = await getDocs(q);\n  return snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() } as Post));\n}\n\n// Update\nasync function updatePost(postId: string, data: Partial<Post>) {\n  await updateDoc(doc(db, 'posts', postId), {\n    ...data,\n    updatedAt: serverTimestamp()\n  });\n}\n\n// Delete\nasync function deletePost(postId: string) {\n  await deleteDoc(doc(db, 'posts', postId));\n}\n```\n\n### Real-time Listeners\n```typescript\nimport { onSnapshot, QuerySnapshot, DocumentSnapshot } from 'firebase/firestore';\n\n// Listen to single document\nfunction subscribeToPost(\n  postId: string,\n  onData: (post: Post | null) => void,\n  onError: (error: Error) => void\n) {\n  return onSnapshot(\n    doc(db, 'posts', postId),\n    (snapshot: DocumentSnapshot) => {\n      if (!snapshot.exists()) {\n        onData(null);\n        return;\n      }\n      onData({ id: snapshot.id, ...snapshot.data() } as Post);\n    },\n    onError\n  );\n}\n\n// Listen to collection with query\nfunction subscribeToPosts(\n  authorId: string,\n  onData: (posts: Post[]) => void,\n  onError: (error: Error) => void\n) {\n  const q = query(\n    collection(db, 'posts'),\n    where('authorId', '==', authorId),\n    orderBy('createdAt', 'desc')\n  );\n\n  return onSnapshot(\n    q,\n    (snapshot: QuerySnapshot) => {\n      const posts = snapshot.docs.map(doc => ({\n        id: doc.id,\n        ...doc.data()\n      } as Post));\n      onData(posts);\n    },\n    onError\n  );\n}\n\n// React hook example\nfunction usePost(postId: string) {\n  const [post, setPost] = useState<Post | null>(null);\n  const [loading, setLoading] = useState(true);\n  const [error, setError] = useState<Error | null>(null);\n\n  useEffect(() => {\n    const unsubscribe = subscribeToPost(\n      postId,\n      (data) => {\n        setPost(data);\n        setLoading(false);\n      },\n      (err) => {\n        setError(err);\n        setLoading(false);\n      }\n    );\n    return unsubscribe;\n  }, [postId]);\n\n  return { post, loading, error };\n}\n```\n\n### Offline Persistence (Web)\n```typescript\nimport { enableIndexedDbPersistence, enableMultiTabIndexedDbPersistence } from 'firebase/firestore';\n\n// Enable offline persistence (call once at startup)\nasync function enableOffline() {\n  try {\n    // Single tab\n    await enableIndexedDbPersistence(db);\n\n    // OR multi-tab (recommended)\n    await enableMultiTabIndexedDbPersistence(db);\n  } catch (err: any) {\n    if (err.code === 'failed-precondition') {\n      // Multiple tabs open, only works in one\n      console.warn('Persistence only available in one tab');\n    } else if (err.code === 'unimplemented') {\n      // Browser doesn't support\n      console.warn('Persistence not supported');\n    }\n  }\n}\n\n// Check if data is from cache\nonSnapshot(docRef, (snapshot) => {\n  const source = snapshot.metadata.fromCache ? 'cache' : 'server';\n  console.log(`Data from ${source}`);\n\n  if (snapshot.metadata.hasPendingWrites) {\n    console.log('Local changes pending sync');\n  }\n});\n```\n\n---\n\n## Security Rules\n\n### Basic Rules Structure\n```javascript\n// firestore.rules\nrules_version = '2';\nservice cloud.firestore {\n  match /databases/{database}/documents {\n\n    // Helper functions\n    function isAuthenticated() {\n      return request.auth != null;\n    }\n\n    function isOwner(userId) {\n      return request.auth.uid == userId;\n    }\n\n    function isAdmin() {\n      return request.auth.token.admin == true;\n    }\n\n    // Posts collection\n    match /posts/{postId} {\n      // Anyone can read published posts\n      allow read: if resource.data.status == 'published';\n\n      // Only authenticated users can create\n      allow create: if isAuthenticated()\n        && request.resource.data.authorId == request.auth.uid\n        && request.resource.data.keys().hasAll(['title', 'content', 'authorId']);\n\n      // Only author can update\n      allow update: if isOwner(resource.data.authorId)\n        && request.resource.data.authorId == resource.data.authorId; // Can't change author\n\n      // Only author or admin can delete\n      allow delete: if isOwner(resource.data.authorId) || isAdmin();\n\n      // Comments subcollection\n      match /comments/{commentId} {\n        allow read: if true;\n        allow create: if isAuthenticated();\n        allow update, delete: if isOwner(resource.data.authorId);\n      }\n    }\n\n    // User profiles\n    match /users/{userId} {\n      allow read: if true;\n      allow create: if isAuthenticated() && isOwner(userId);\n      allow update: if isOwner(userId);\n      allow delete: if false; // Never allow delete\n    }\n\n    // Private user data\n    match /users/{userId}/private/{document=**} {\n      allow read, write: if isOwner(userId);\n    }\n  }\n}\n```\n\n### Data Validation in Rules\n```javascript\nmatch /posts/{postId} {\n  function isValidPost() {\n    let data = request.resource.data;\n    return data.title is string\n      && data.title.size() >= 3\n      && data.title.size() <= 100\n      && data.content is string\n      && data.content.size() <= 50000\n      && data.tags is list\n      && data.tags.size() <= 5;\n  }\n\n  allow create: if isAuthenticated() && isValidPost();\n  allow update: if isOwner(resource.data.authorId) && isValidPost();\n}\n```\n\n### Test Rules Locally\n```bash\n# Install emulators\nfirebase emulators:start\n\n# Run rules tests\nnpm test\n```\n\n```typescript\n// tests/firestore.rules.test.ts\nimport { assertFails, assertSucceeds, initializeTestEnvironment } from '@firebase/rules-unit-testing';\n\ndescribe('Firestore Rules', () => {\n  let testEnv: RulesTestEnvironment;\n\n  beforeAll(async () => {\n    testEnv = await initializeTestEnvironment({\n      projectId: 'test-project',\n      firestore: { rules: fs.readFileSync('firestore.rules', 'utf8') }\n    });\n  });\n\n  test('unauthenticated users cannot write', async () => {\n    const unauthedDb = testEnv.unauthenticatedContext().firestore();\n    await assertFails(\n      setDoc(doc(unauthedDb, 'posts/test'), { title: 'Test' })\n    );\n  });\n\n  test('users can only update own posts', async () => {\n    const aliceDb = testEnv.authenticatedContext('alice').firestore();\n    const bobDb = testEnv.authenticatedContext('bob').firestore();\n\n    // Create as Alice\n    await assertSucceeds(\n      setDoc(doc(aliceDb, 'posts/test'), { title: 'Test', authorId: 'alice' })\n    );\n\n    // Bob cannot update\n    await assertFails(\n      updateDoc(doc(bobDb, 'posts/test'), { title: 'Hacked' })\n    );\n  });\n});\n```\n\n---\n\n## Authentication\n\n### Email/Password Auth\n```typescript\nimport {\n  createUserWithEmailAndPassword,\n  signInWithEmailAndPassword,\n  signOut,\n  onAuthStateChanged,\n  User\n} from 'firebase/auth';\nimport { auth } from './firebase';\n\n// Sign up\nasync function signUp(email: string, password: string) {\n  const credential = await createUserWithEmailAndPassword(auth, email, password);\n  return credential.user;\n}\n\n// Sign in\nasync function signIn(email: string, password: string) {\n  const credential = await signInWithEmailAndPassword(auth, email, password);\n  return credential.user;\n}\n\n// Sign out\nasync function logout() {\n  await signOut(auth);\n}\n\n// Auth state listener\nfunction onAuthChange(callback: (user: User | null) => void) {\n  return onAuthStateChanged(auth, callback);\n}\n```\n\n### OAuth Providers\n```typescript\nimport {\n  GoogleAuthProvider,\n  signInWithPopup,\n  signInWithRedirect\n} from 'firebase/auth';\n\nconst googleProvider = new GoogleAuthProvider();\n\nasync function signInWithGoogle() {\n  try {\n    const result = await signInWithPopup(auth, googleProvider);\n    return result.user;\n  } catch (error) {\n    // Handle errors\n    throw error;\n  }\n}\n```\n\n---\n\n## Cloud Functions\n\n### Basic HTTP Function\n```typescript\n// functions/src/index.ts\nimport { onRequest } from 'firebase-functions/v2/https';\nimport { onDocumentCreated } from 'firebase-functions/v2/firestore';\nimport { initializeApp } from 'firebase-admin/app';\nimport { getFirestore } from 'firebase-admin/firestore';\n\ninitializeApp();\nconst db = getFirestore();\n\n// HTTP endpoint\nexport const helloWorld = onRequest((request, response) => {\n  response.json({ message: 'Hello from Firebase!' });\n});\n\n// Firestore trigger\nexport const onPostCreated = onDocumentCreated('posts/{postId}', async (event) => {\n  const snapshot = event.data;\n  if (!snapshot) return;\n\n  const post = snapshot.data();\n\n  // Update author's post count\n  await db.doc(`users/${post.authorId}`).update({\n    postCount: FieldValue.increment(1)\n  });\n});\n```\n\n### Callable Functions\n```typescript\n// Backend\nimport { onCall, HttpsError } from 'firebase-functions/v2/https';\n\nexport const createPost = onCall(async (request) => {\n  // Auth check\n  if (!request.auth) {\n    throw new HttpsError('unauthenticated', 'Must be logged in');\n  }\n\n  const { title, content } = request.data;\n\n  // Validation\n  if (!title || title.length < 3) {\n    throw new HttpsError('invalid-argument', 'Title must be at least 3 characters');\n  }\n\n  // Create post\n  const postRef = await db.collection('posts').add({\n    title,\n    content,\n    authorId: request.auth.uid,\n    createdAt: FieldValue.serverTimestamp()\n  });\n\n  return { postId: postRef.id };\n});\n\n// Frontend\nimport { getFunctions, httpsCallable } from 'firebase/functions';\n\nconst functions = getFunctions();\nconst createPostFn = httpsCallable(functions, 'createPost');\n\nasync function createPost(title: string, content: string) {\n  const result = await createPostFn({ title, content });\n  return result.data as { postId: string };\n}\n```\n\n---\n\n## Batch Operations & Transactions\n\n### Batch Writes\n```typescript\nimport { writeBatch, doc } from 'firebase/firestore';\n\nasync function batchUpdate(updates: { id: string; data: any }[]) {\n  const batch = writeBatch(db);\n\n  updates.forEach(({ id, data }) => {\n    batch.update(doc(db, 'posts', id), data);\n  });\n\n  await batch.commit(); // Atomic\n}\n```\n\n### Transactions\n```typescript\nimport { runTransaction, doc, increment } from 'firebase/firestore';\n\nasync function likePost(postId: string, userId: string) {\n  await runTransaction(db, async (transaction) => {\n    const postRef = doc(db, 'posts', postId);\n    const likeRef = doc(db, 'posts', postId, 'likes', userId);\n\n    const postSnap = await transaction.get(postRef);\n    if (!postSnap.exists()) throw new Error('Post not found');\n\n    const likeSnap = await transaction.get(likeRef);\n    if (likeSnap.exists()) throw new Error('Already liked');\n\n    transaction.set(likeRef, { createdAt: serverTimestamp() });\n    transaction.update(postRef, { likeCount: increment(1) });\n  });\n}\n```\n\n---\n\n## Indexes\n\n### Composite Indexes\n```json\n// firestore.indexes.json\n{\n  \"indexes\": [\n    {\n      \"collectionGroup\": \"posts\",\n      \"queryScope\": \"COLLECTION\",\n      \"fields\": [\n        { \"fieldPath\": \"authorId\", \"order\": \"ASCENDING\" },\n        { \"fieldPath\": \"createdAt\", \"order\": \"DESCENDING\" }\n      ]\n    },\n    {\n      \"collectionGroup\": \"posts\",\n      \"queryScope\": \"COLLECTION\",\n      \"fields\": [\n        { \"fieldPath\": \"tags\", \"arrayConfig\": \"CONTAINS\" },\n        { \"fieldPath\": \"createdAt\", \"order\": \"DESCENDING\" }\n      ]\n    }\n  ]\n}\n```\n\n```bash\n# Deploy indexes\nfirebase deploy --only firestore:indexes\n```\n\n---\n\n## CLI Quick Reference\n\n```bash\n# Project setup\nfirebase login                       # Authenticate\nfirebase init                        # Initialize project\nfirebase projects:list               # List projects\n\n# Emulators\nfirebase emulators:start             # Start all emulators\nfirebase emulators:start --only firestore,auth  # Specific emulators\n\n# Deploy\nfirebase deploy                      # Deploy everything\nfirebase deploy --only firestore     # Deploy rules + indexes\nfirebase deploy --only functions     # Deploy functions\nfirebase deploy --only hosting       # Deploy hosting\n\n# Functions\ncd functions && npm run build        # Build TypeScript\nfirebase functions:log               # View logs\n```\n\n---\n\n## Anti-Patterns\n\n- **No security rules** - Always write rules, never use test mode in production\n- **Deep nesting** - Keep documents flat, max 2-3 levels\n- **Large documents** - Max 1MB, split if larger\n- **Unbounded arrays** - Use subcollections for lists that grow\n- **No offline handling** - Enable persistence for mobile/PWA\n- **Reading all fields** - Use field masks or Firestore Lite\n- **Ignoring indexes** - Check console for missing index errors\n- **No emulator testing** - Always test rules before deploy\n"
  },
  {
    "path": "skills/flutter/SKILL.md",
    "content": "---\nname: flutter\ndescription: Flutter development with Riverpod state management, Freezed, go_router, and mocktail testing\nwhen-to-use: When working on Flutter/Dart code\nuser-invocable: false\npaths: [\"**/*.dart\", \"pubspec.yaml\", \"lib/**\", \"test/**\"]\neffort: medium\n---\n\n# Flutter Skill\n\n\n---\n\n## Project Structure\n\n```\nproject/\n├── lib/\n│   ├── core/                           # Core utilities\n│   │   ├── constants/                  # App constants\n│   │   ├── extensions/                 # Dart extensions\n│   │   ├── router/                     # go_router configuration\n│   │   │   └── app_router.dart\n│   │   └── theme/                      # App theme\n│   │       └── app_theme.dart\n│   ├── data/                           # Data layer\n│   │   ├── models/                     # Freezed data models\n│   │   ├── repositories/               # Repository implementations\n│   │   └── services/                   # API services\n│   ├── domain/                         # Domain layer\n│   │   ├── entities/                   # Business entities\n│   │   └── repositories/               # Repository interfaces\n│   ├── presentation/                   # UI layer\n│   │   ├── common/                     # Shared widgets\n│   │   ├── features/                   # Feature modules\n│   │   │   └── feature_name/\n│   │   │       ├── providers/          # Riverpod providers\n│   │   │       ├── widgets/            # Feature-specific widgets\n│   │   │       └── feature_screen.dart\n│   │   └── providers/                  # Global providers\n│   ├── main.dart\n│   └── app.dart\n├── test/\n│   ├── unit/                           # Unit tests\n│   ├── widget/                         # Widget tests\n│   └── integration/                    # Integration tests\n├── pubspec.yaml\n├── analysis_options.yaml\n└── CLAUDE.md\n```\n\n---\n\n## Riverpod State Management\n\n### Provider Types\n```dart\n// Simple value provider\nfinal appNameProvider = Provider<String>((ref) => 'My App');\n\n// StateProvider for simple mutable state\nfinal counterProvider = StateProvider<int>((ref) => 0);\n\n// NotifierProvider for complex state logic\nfinal userProvider = NotifierProvider<UserNotifier, User?>(() => UserNotifier());\n\n// AsyncNotifierProvider for async operations\nfinal usersProvider = AsyncNotifierProvider<UsersNotifier, List<User>>(\n  () => UsersNotifier(),\n);\n\n// FutureProvider for simple async data\nfinal configProvider = FutureProvider<Config>((ref) async {\n  return await ref.watch(configServiceProvider).loadConfig();\n});\n\n// StreamProvider for real-time data\nfinal messagesProvider = StreamProvider<List<Message>>((ref) {\n  return ref.watch(messageServiceProvider).watchMessages();\n});\n\n// Family providers for parameterized data\nfinal userByIdProvider = FutureProvider.family<User, String>((ref, userId) async {\n  return await ref.watch(userRepositoryProvider).getUser(userId);\n});\n```\n\n### Notifier Pattern\n```dart\n@riverpod\nclass Users extends _$Users {\n  @override\n  Future<List<User>> build() async {\n    return await _fetchUsers();\n  }\n\n  Future<List<User>> _fetchUsers() async {\n    final repository = ref.read(userRepositoryProvider);\n    return await repository.getUsers();\n  }\n\n  Future<void> refresh() async {\n    state = const AsyncLoading();\n    state = await AsyncValue.guard(() => _fetchUsers());\n  }\n\n  Future<void> addUser(User user) async {\n    final repository = ref.read(userRepositoryProvider);\n    await repository.addUser(user);\n    ref.invalidateSelf();\n  }\n}\n```\n\n### AsyncValue Handling\n```dart\nclass UsersScreen extends ConsumerWidget {\n  const UsersScreen({super.key});\n\n  @override\n  Widget build(BuildContext context, WidgetRef ref) {\n    final usersAsync = ref.watch(usersProvider);\n\n    return usersAsync.when(\n      data: (users) => UsersList(users: users),\n      loading: () => const Center(child: CircularProgressIndicator()),\n      error: (error, stack) => ErrorDisplay(\n        error: error,\n        onRetry: () => ref.invalidate(usersProvider),\n      ),\n    );\n  }\n}\n\n// Pattern matching alternative\nWidget build(BuildContext context, WidgetRef ref) {\n  final usersAsync = ref.watch(usersProvider);\n\n  return switch (usersAsync) {\n    AsyncData(:final value) => UsersList(users: value),\n    AsyncLoading() => const LoadingIndicator(),\n    AsyncError(:final error) => ErrorDisplay(error: error),\n  };\n}\n```\n\n### ref Methods\n```dart\n// watch - rebuilds when provider changes\nfinal users = ref.watch(usersProvider);\n\n// read - one-time read, no rebuild\nvoid onButtonPressed() {\n  ref.read(counterProvider.notifier).state++;\n}\n\n// listen - react to changes without rebuild\nref.listen(authProvider, (previous, next) {\n  if (next == null) {\n    context.go('/login');\n  }\n});\n\n// invalidate - force refresh\nref.invalidate(usersProvider);\n\n// keepAlive - prevent auto-dispose\nfinal link = ref.keepAlive();\n// Later: link.close() to allow disposal\n```\n\n---\n\n## Freezed Data Models\n\n### Model Definition\n```dart\nimport 'package:freezed_annotation/freezed_annotation.dart';\n\npart 'user.freezed.dart';\npart 'user.g.dart';\n\n@freezed\nclass User with _$User {\n  const factory User({\n    required String id,\n    required String name,\n    required String email,\n    @Default(false) bool isActive,\n    DateTime? createdAt,\n  }) = _User;\n\n  factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);\n}\n\n// Union types for states\n@freezed\nsealed class AuthState with _$AuthState {\n  const factory AuthState.initial() = _Initial;\n  const factory AuthState.loading() = _Loading;\n  const factory AuthState.authenticated(User user) = _Authenticated;\n  const factory AuthState.unauthenticated() = _Unauthenticated;\n  const factory AuthState.error(String message) = _Error;\n}\n```\n\n### Using Freezed Unions\n```dart\nWidget build(BuildContext context, WidgetRef ref) {\n  final authState = ref.watch(authProvider);\n\n  return authState.when(\n    initial: () => const SplashScreen(),\n    loading: () => const LoadingScreen(),\n    authenticated: (user) => HomeScreen(user: user),\n    unauthenticated: () => const LoginScreen(),\n    error: (message) => ErrorScreen(message: message),\n  );\n}\n```\n\n---\n\n## go_router Navigation\n\n### Router Configuration\n```dart\nfinal routerProvider = Provider<GoRouter>((ref) {\n  final authState = ref.watch(authProvider);\n\n  return GoRouter(\n    initialLocation: '/',\n    refreshListenable: authState,\n    redirect: (context, state) {\n      final isLoggedIn = authState.valueOrNull != null;\n      final isLoggingIn = state.matchedLocation == '/login';\n\n      if (!isLoggedIn && !isLoggingIn) return '/login';\n      if (isLoggedIn && isLoggingIn) return '/';\n      return null;\n    },\n    routes: [\n      GoRoute(\n        path: '/',\n        builder: (context, state) => const HomeScreen(),\n        routes: [\n          GoRoute(\n            path: 'user/:id',\n            builder: (context, state) => UserScreen(\n              userId: state.pathParameters['id']!,\n            ),\n          ),\n        ],\n      ),\n      GoRoute(\n        path: '/login',\n        builder: (context, state) => const LoginScreen(),\n      ),\n    ],\n    errorBuilder: (context, state) => ErrorScreen(error: state.error),\n  );\n});\n```\n\n### Navigation\n```dart\n// Navigate to route\ncontext.go('/user/123');\n\n// Push onto stack\ncontext.push('/user/123');\n\n// Pop current route\ncontext.pop();\n\n// Replace current route\ncontext.pushReplacement('/home');\n\n// Named routes\ncontext.goNamed('user', pathParameters: {'id': '123'});\n```\n\n---\n\n## Widget Patterns\n\n### ConsumerWidget vs ConsumerStatefulWidget\n```dart\n// Stateless with Riverpod\nclass UserCard extends ConsumerWidget {\n  const UserCard({super.key, required this.userId});\n\n  final String userId;\n\n  @override\n  Widget build(BuildContext context, WidgetRef ref) {\n    final user = ref.watch(userByIdProvider(userId));\n    return user.when(\n      data: (user) => Card(child: Text(user.name)),\n      loading: () => const CardSkeleton(),\n      error: (e, _) => ErrorCard(error: e),\n    );\n  }\n}\n\n// Stateful with Riverpod\nclass SearchScreen extends ConsumerStatefulWidget {\n  const SearchScreen({super.key});\n\n  @override\n  ConsumerState<SearchScreen> createState() => _SearchScreenState();\n}\n\nclass _SearchScreenState extends ConsumerState<SearchScreen> {\n  final _controller = TextEditingController();\n\n  @override\n  void dispose() {\n    _controller.dispose();\n    super.dispose();\n  }\n\n  @override\n  Widget build(BuildContext context) {\n    final results = ref.watch(searchProvider(_controller.text));\n    return Column(\n      children: [\n        TextField(\n          controller: _controller,\n          onChanged: (_) => setState(() {}),\n        ),\n        Expanded(child: SearchResults(results: results)),\n      ],\n    );\n  }\n}\n```\n\n### HookConsumerWidget (with flutter_hooks)\n```dart\nclass AnimatedCounter extends HookConsumerWidget {\n  const AnimatedCounter({super.key});\n\n  @override\n  Widget build(BuildContext context, WidgetRef ref) {\n    final controller = useAnimationController(duration: const Duration(milliseconds: 300));\n    final count = ref.watch(counterProvider);\n\n    useEffect(() {\n      controller.forward(from: 0);\n      return null;\n    }, [count]);\n\n    return ScaleTransition(\n      scale: controller,\n      child: Text('$count'),\n    );\n  }\n}\n```\n\n---\n\n## Testing with Mocktail\n\n### Unit Tests\n```dart\nimport 'package:flutter_test/flutter_test.dart';\nimport 'package:mocktail/mocktail.dart';\nimport 'package:riverpod/riverpod.dart';\n\nclass MockUserRepository extends Mock implements UserRepository {}\n\nvoid main() {\n  late MockUserRepository mockRepository;\n  late ProviderContainer container;\n\n  setUp(() {\n    mockRepository = MockUserRepository();\n    container = ProviderContainer(\n      overrides: [\n        userRepositoryProvider.overrideWithValue(mockRepository),\n      ],\n    );\n  });\n\n  tearDown(() {\n    container.dispose();\n  });\n\n  test('usersProvider returns list of users', () async {\n    final users = [User(id: '1', name: 'John', email: 'john@example.com')];\n    when(() => mockRepository.getUsers()).thenAnswer((_) async => users);\n\n    final result = await container.read(usersProvider.future);\n\n    expect(result, equals(users));\n    verify(() => mockRepository.getUsers()).called(1);\n  });\n}\n```\n\n### Widget Tests\n```dart\nvoid main() {\n  testWidgets('UserCard displays user name', (tester) async {\n    final user = User(id: '1', name: 'John', email: 'john@example.com');\n\n    await tester.pumpWidget(\n      ProviderScope(\n        overrides: [\n          userByIdProvider('1').overrideWith((_) => AsyncData(user)),\n        ],\n        child: const MaterialApp(home: UserCard(userId: '1')),\n      ),\n    );\n\n    expect(find.text('John'), findsOneWidget);\n  });\n\n  testWidgets('UserCard shows loading indicator', (tester) async {\n    await tester.pumpWidget(\n      ProviderScope(\n        overrides: [\n          userByIdProvider('1').overrideWith((_) => const AsyncLoading()),\n        ],\n        child: const MaterialApp(home: UserCard(userId: '1')),\n      ),\n    );\n\n    expect(find.byType(CircularProgressIndicator), findsOneWidget);\n  });\n}\n```\n\n---\n\n## pubspec.yaml\n\n```yaml\nname: my_app\ndescription: A Flutter application\npublish_to: 'none'\nversion: 1.0.0+1\n\nenvironment:\n  sdk: '>=3.2.0 <4.0.0'\n\ndependencies:\n  flutter:\n    sdk: flutter\n\n  # State management\n  flutter_riverpod: ^2.4.9\n  riverpod_annotation: ^2.3.3\n\n  # Data models\n  freezed_annotation: ^2.4.1\n  json_annotation: ^4.8.1\n\n  # Navigation\n  go_router: ^13.0.0\n\n  # Networking\n  dio: ^5.4.0\n\n  # Storage\n  shared_preferences: ^2.2.2\n\n  # Utils\n  intl: ^0.19.0\n\ndev_dependencies:\n  flutter_test:\n    sdk: flutter\n\n  # Code generation\n  build_runner: ^2.4.8\n  freezed: ^2.4.6\n  json_serializable: ^6.7.1\n  riverpod_generator: ^2.3.9\n\n  # Testing\n  mocktail: ^1.0.2\n\n  # Linting\n  flutter_lints: ^3.0.1\n```\n\n---\n\n## GitHub Actions\n\n```yaml\nname: Flutter CI\n\non:\n  push:\n    branches: [main]\n  pull_request:\n    branches: [main]\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - uses: subosito/flutter-action@v2\n        with:\n          flutter-version: '3.16.0'\n          channel: 'stable'\n          cache: true\n\n      - name: Install dependencies\n        run: flutter pub get\n\n      - name: Generate code\n        run: dart run build_runner build --delete-conflicting-outputs\n\n      - name: Analyze\n        run: flutter analyze --fatal-infos\n\n      - name: Run tests\n        run: flutter test --coverage\n\n      - name: Build APK\n        run: flutter build apk --release\n```\n\n---\n\n## analysis_options.yaml\n\n```yaml\ninclude: package:flutter_lints/flutter.yaml\n\nanalyzer:\n  exclude:\n    - \"**/*.g.dart\"\n    - \"**/*.freezed.dart\"\n  errors:\n    invalid_annotation_target: ignore\n  language:\n    strict-casts: true\n    strict-inference: true\n    strict-raw-types: true\n\nlinter:\n  rules:\n    - always_declare_return_types\n    - avoid_dynamic_calls\n    - avoid_print\n    - avoid_type_to_string\n    - cancel_subscriptions\n    - close_sinks\n    - prefer_const_constructors\n    - prefer_const_declarations\n    - prefer_final_locals\n    - require_trailing_commas\n    - unawaited_futures\n    - use_super_parameters\n```\n\n---\n\n## Flutter Anti-Patterns\n\n- ❌ **Provider without autoDispose** - Use `.autoDispose` to prevent memory leaks\n- ❌ **watch in callbacks** - Use `ref.read()` in onPressed/callbacks, not `ref.watch()`\n- ❌ **Business logic in widgets** - Move to Notifiers/providers\n- ❌ **Mutable state in providers** - Use Freezed for immutable models\n- ❌ **Not using AsyncValue** - Handle loading/error states with `when()`\n- ❌ **setState with Riverpod** - Use providers for shared state\n- ❌ **Passing ref to functions** - Keep ref usage within widgets/providers\n- ❌ **Deeply nested Consumer** - Use ConsumerWidget instead\n- ❌ **Not using family for params** - Use `.family` for parameterized providers\n- ❌ **Global GoRouter instance** - Use Provider for router with redirect logic\n- ❌ **BuildContext across async** - Store values before await, not context\n- ❌ **Ignoring dispose** - Clean up controllers in ConsumerStatefulWidget\n\n"
  },
  {
    "path": "skills/gemini-review/SKILL.md",
    "content": "---\nname: gemini-review\ndescription: Google Gemini CLI code review with Gemini 2.5 Pro, 1M token context, CI/CD integration\nwhen-to-use: When user requests Gemini-powered code review or needs large-context review\nuser-invocable: true\neffort: medium\n---\n\n# Google Gemini Code Review Skill\n\n\nUse Google's Gemini CLI for code review with Gemini 2.5 Pro - featuring a massive 1M token context window that can analyze entire repositories at once.\n\n**Sources:** [Gemini CLI](https://github.com/google-gemini/gemini-cli) | [Code Review Extension](https://github.com/gemini-cli-extensions/code-review) | [Gemini Code Assist](https://codeassist.google/) | [GitHub Action](https://github.com/google-github-actions/run-gemini-cli)\n\n---\n\n## Why Gemini for Code Review?\n\n| Feature | Benefit |\n|---------|---------|\n| **Gemini 2.5 Pro** | State-of-the-art reasoning for code |\n| **1M token context** | Entire repositories fit - no chunking needed |\n| **Free tier** | 1,000 requests/day with Google account |\n| **Consistent output** | Clean formatting, predictable structure |\n| **GitHub native** | Gemini Code Assist app for auto PR reviews |\n\n### Benchmark Performance\n\n| Benchmark | Score | Notes |\n|-----------|-------|-------|\n| SWE-Bench Verified | 63.8% | Agentic coding benchmark |\n| Qodo PR Benchmark | 56.3% | PR review quality |\n| LiveCodeBench v5 | 70.4% | Code generation |\n| WebDev Arena | #1 | Web development |\n\n---\n\n## Installation\n\n### Prerequisites\n\n```bash\n# Check Node.js version (requires 20+)\nnode --version\n\n# Install Node.js 20 if needed\n# macOS\nbrew install node@20\n\n# Or via nvm\nnvm install 20\nnvm use 20\n```\n\n### Install Gemini CLI\n\n```bash\n# Via npm (recommended)\nnpm install -g @google/gemini-cli\n\n# Via Homebrew (macOS)\nbrew install gemini-cli\n\n# Or run without installing\nnpx @google/gemini-cli\n\n# Verify installation\ngemini --version\n```\n\n### Install Code Review Extension\n\n```bash\n# Requires Gemini CLI v0.4.0+\ngemini extensions install https://github.com/gemini-cli-extensions/code-review\n\n# Verify extension\ngemini extensions list\n```\n\n---\n\n## Authentication\n\n### Option 1: Google Account (Recommended)\n\n**Free tier: 1,000 requests/day, 60 requests/min**\n\n```bash\n# Run gemini and follow browser login\ngemini\n\n# Select: \"Login with Google Account\"\n# Opens browser for OAuth\n```\n\nThis gives you access to Gemini 2.5 Pro with the full 1M token context window.\n\n### Option 2: Gemini API Key\n\n**Free tier: 100 requests/day**\n\n```bash\n# Get API key from https://aistudio.google.com/apikey\n\n# Set environment variable\nexport GEMINI_API_KEY=\"your-api-key\"\n\n# Or add to shell profile\necho 'export GEMINI_API_KEY=\"your-api-key\"' >> ~/.zshrc\n\n# Run Gemini\ngemini\n```\n\n### Option 3: Vertex AI (Enterprise)\n\n```bash\n# For Google Cloud projects\nexport GOOGLE_API_KEY=\"your-api-key\"\nexport GOOGLE_GENAI_USE_VERTEXAI=true\nexport GOOGLE_CLOUD_PROJECT=\"your-project-id\"\n\ngemini\n```\n\n---\n\n## Interactive Code Review\n\n### Using the Code Review Extension\n\n```bash\n# Start Gemini CLI\ngemini\n\n# Run code review on current branch\n/code-review\n```\n\nThe extension analyzes:\n- Code changes on your current branch\n- Identifies quality issues\n- Suggests fixes\n\n### Manual Review Prompts\n\n```bash\n# In interactive mode\ngemini\n\n# Then ask:\n> Review the changes in this branch for bugs and security issues\n> Analyze src/api/users.ts for potential vulnerabilities\n> What are the code quality issues in the last 3 commits?\n```\n\n---\n\n## Headless Mode (Automation)\n\n### Basic Usage\n\n```bash\n# Simple prompt execution\ngemini -p \"Review the code changes for bugs and security issues\"\n\n# With JSON output (for parsing)\ngemini -p \"Review the changes\" --output-format json\n\n# Stream JSON events (real-time)\ngemini -p \"Review and fix issues\" --output-format stream-json\n\n# Specify model\ngemini -m gemini-2.5-pro -p \"Deep code review of this PR\"\n```\n\n### Full CI/CD Example\n\n```bash\n# Get diff and review\ngit diff origin/main...HEAD > diff.txt\n\ngemini -p \"Review this code diff for:\n1. Security vulnerabilities\n2. Performance issues\n3. Code quality problems\n4. Missing error handling\n\nDiff:\n$(cat diff.txt)\n\" --output-format json > review.json\n```\n\n### Session Tracking\n\n```bash\n# Track token usage and costs\ngemini -p \"Review changes\" --session-summary metrics.json\n\n# View metrics\ncat metrics.json\n```\n\n---\n\n## GitHub Integration\n\n### Option 1: Gemini Code Assist App (Easiest)\n\nInstall from [GitHub Marketplace](https://github.com/marketplace/gemini-code-assist):\n\n1. Go to GitHub Marketplace → Gemini Code Assist\n2. Click \"Install\" and select repositories\n3. PRs automatically get reviewed when opened\n\n**Commands in PR comments:**\n```\n/gemini review     # Request code review\n/gemini summary    # Get PR summary\n/gemini help       # Show available commands\n```\n\n**Quota:**\n- Free: 33 PRs/day\n- Enterprise: 100+ PRs/day\n\n### Option 2: GitHub Action\n\n```yaml\n# .github/workflows/gemini-review.yml\nname: Gemini Code Review\n\non:\n  pull_request:\n    types: [opened, synchronize]\n\njobs:\n  review:\n    runs-on: ubuntu-latest\n    permissions:\n      contents: read\n      pull-requests: write\n\n    steps:\n      - uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n\n      - name: Setup Node.js\n        uses: actions/setup-node@v4\n        with:\n          node-version: '20'\n\n      - name: Install Gemini CLI\n        run: npm install -g @google/gemini-cli\n\n      - name: Run Review\n        env:\n          GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}\n        run: |\n          # Get diff\n          git diff origin/${{ github.base_ref }}...HEAD > diff.txt\n\n          # Run Gemini review\n          gemini -p \"Review this pull request diff for bugs, security issues, and code quality problems. Be specific about file names and line numbers.\n\n          $(cat diff.txt)\" > review.md\n\n      - name: Post Review Comment\n        uses: actions/github-script@v7\n        with:\n          script: |\n            const fs = require('fs');\n            const review = fs.readFileSync('review.md', 'utf8');\n            github.rest.issues.createComment({\n              owner: context.repo.owner,\n              repo: context.repo.repo,\n              issue_number: context.issue.number,\n              body: `## 🤖 Gemini Code Review\\n\\n${review}`\n            });\n```\n\n### Option 3: Official GitHub Action\n\n```yaml\n# .github/workflows/gemini-review.yml\nname: Gemini Code Review\n\non:\n  pull_request:\n    types: [opened, synchronize]\n  issue_comment:\n    types: [created]\n\njobs:\n  review:\n    runs-on: ubuntu-latest\n    permissions:\n      contents: read\n      pull-requests: write\n      issues: write\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Run Gemini CLI\n        uses: google-github-actions/run-gemini-cli@v1\n        with:\n          gemini_api_key: ${{ secrets.GEMINI_API_KEY }}\n          prompt: \"Review this pull request for code quality, security issues, and potential bugs.\"\n```\n\n**On-demand commands in comments:**\n```\n@gemini-cli /review\n@gemini-cli explain this code change\n@gemini-cli write unit tests for this component\n```\n\n---\n\n## GitLab CI/CD\n\n```yaml\n# .gitlab-ci.yml\ngemini-review:\n  image: node:20\n  stage: review\n  script:\n    - npm install -g @google/gemini-cli\n    - |\n      gemini -p \"Review the merge request changes for bugs, security issues, and code quality\" > review.md\n    - cat review.md\n  artifacts:\n    paths:\n      - review.md\n  rules:\n    - if: $CI_PIPELINE_SOURCE == \"merge_request_event\"\n  variables:\n    GEMINI_API_KEY: $GEMINI_API_KEY\n```\n\n---\n\n## Configuration\n\n### Global Config\n\n```bash\n# ~/.gemini/settings.json\n{\n  \"model\": \"gemini-2.5-pro\",\n  \"theme\": \"dark\",\n  \"sandbox\": true\n}\n```\n\n### Project Config (GEMINI.md)\n\nCreate a `GEMINI.md` file in your project root for project-specific context:\n\n```markdown\n# Project Context for Gemini\n\n## Tech Stack\n- TypeScript with strict mode\n- React 18 with hooks\n- FastAPI backend\n- PostgreSQL database\n\n## Code Review Focus Areas\n1. Type safety - ensure proper TypeScript types\n2. React hooks rules - check for dependency array issues\n3. SQL injection - verify parameterized queries\n4. Authentication - check all endpoints have proper auth\n\n## Conventions\n- Use camelCase for variables\n- Use PascalCase for components\n- All API errors should use AppError class\n```\n\n---\n\n## CLI Quick Reference\n\n```bash\n# Interactive\ngemini                          # Start interactive mode\n/code-review                    # Run code review extension\n\n# Headless\ngemini -p \"prompt\"              # Single prompt, exit\ngemini -p \"prompt\" --output-format json   # JSON output\ngemini -m gemini-2.5-flash -p \"prompt\"    # Use faster model\n\n# Extensions\ngemini extensions list          # List installed\ngemini extensions install URL   # Install extension\ngemini extensions update        # Update all\n\n# Key Flags\n--output-format json            # Structured output\n--output-format stream-json     # Real-time events\n--session-summary FILE          # Track metrics\n-m MODEL                        # Select model\n```\n\n---\n\n## Comparison: Claude vs Codex vs Gemini\n\n| Aspect | Claude | Codex CLI | Gemini CLI |\n|--------|--------|-----------|------------|\n| **Setup** | None (built-in) | npm + OpenAI API | npm + Google Account |\n| **Model** | Claude | GPT-5.2-Codex | Gemini 2.5 Pro |\n| **Context** | Conversation | Fresh per review | 1M tokens (huge!) |\n| **Free Tier** | N/A | Limited | 1,000/day |\n| **Best For** | Quick reviews | High accuracy | Large codebases |\n| **GitHub Native** | No | @codex | Gemini Code Assist |\n\n### When to Use Each\n\n| Scenario | Recommended Engine |\n|----------|-------------------|\n| Quick in-flow review | Claude |\n| Critical security review | Codex (88% detection) |\n| Large codebase (100+ files) | Gemini (1M context) |\n| Free automated reviews | Gemini |\n| Multiple perspectives | All three (dual/triple engine) |\n\n---\n\n## Troubleshooting\n\n| Issue | Solution |\n|-------|----------|\n| `gemini: command not found` | `npm install -g @google/gemini-cli` |\n| `Node.js version error` | Upgrade to Node.js 20+ |\n| `Authentication failed` | Re-run `gemini` and login again |\n| `Extension not found` | `gemini extensions install https://github.com/gemini-cli-extensions/code-review` |\n| `Rate limited` | Wait or upgrade to Vertex AI |\n| `Hangs in CI` | Ensure `DEBUG` env var is not set |\n\n---\n\n## Anti-Patterns\n\n- **Skipping authentication setup** - Always configure before CI/CD\n- **Using API key in logs** - Use secrets management\n- **Ignoring context limits** - Even 1M tokens has limits for huge monorepos\n- **Running on every commit** - Use on PRs only to save quota\n- **Not setting project context** - Add GEMINI.md for better reviews\n"
  },
  {
    "path": "skills/icpg/SKILL.md",
    "content": "---\nname: icpg\ndescription: Intent-Augmented Code Property Graph — tracks WHY code exists via ReasonNodes with formal contracts, 6-dimension drift detection, and 3 canonical pre-task queries for autonomous development\nwhen-to-use: \"Before any code change — query the reason graph for intent, constraints, and risk\"\nuser-invocable: false\neffort: high\n---\n\n# iCPG Skill (Intent-Augmented Code Property Graph)\n\n\n**Purpose:** Add a Reason Graph layer on top of code structure so every\nfunction, class, and module is traceable to the goal that created it,\nthe agent or human that owns it, and whether it's still doing what it\nwas supposed to do.\n\n```\n┌────────────────────────────────────────────────────────────────┐\n│  iCPG = AST + CFG + PDG + RG (Reason Graph)                    │\n│  ─────────────────────────────────────────────────────────────│\n│  AST  = Abstract Syntax Tree (structure)      ← existing       │\n│  CFG  = Control Flow Graph (execution paths)  ← existing       │\n│  PDG  = Program Dependency Graph              ← existing       │\n│  RG   = Reason Graph (WHY layer)              ← THIS SKILL     │\n│                                                                │\n│  The RG stores ReasonNodes (goals/tasks), links them to code   │\n│  symbols via typed edges, enforces contracts (DbC), and        │\n│  detects when code drifts from its original purpose.           │\n│                                                                │\n│  Storage: .icpg/reason.db (SQLite, per-project, gitignored)   │\n│  CLI: icpg init | create | record | query | drift | bootstrap │\n└────────────────────────────────────────────────────────────────┘\n```\n\n---\n\n## Core Principle\n\n**Intent first, code second.** Before writing or modifying code, query\nthe reason graph to understand WHY existing code was written, WHAT\nconstraints it must preserve, and WHETHER your change duplicates prior\nwork.\n\n---\n\n## The 3 Canonical Pre-Task Queries\n\n**Every agent MUST run these before writing code:**\n\n| # | Query | Command | What It Answers |\n|---|-------|---------|-----------------|\n| 1 | **search_prior_work** | `icpg query prior \"<goal>\"` | Has this been attempted before? Prevents duplication. |\n| 2 | **get_constraints** | `icpg query constraints <file>` | What invariants apply to files I'll touch? Prevents breakage. |\n| 3 | **get_risk_profile** | `icpg query risk <symbol>` | Is this symbol fragile? Drift history, ownership changes. |\n\n---\n\n## ReasonNode — The Core Primitive\n\nEach ReasonNode captures a stated purpose with a formal contract:\n\n```\nid              UUID\ngoal            Natural language: what is this trying to achieve\ndecision_type   business_goal | arch_decision | task | workaround | constraint | patch\nscope           Files/modules expected to be touched\nowner           Human or agent accountable\nstatus          proposed | executing | fulfilled | drifted | abandoned\nsource          manual | commit | inferred | agent-session\n\nFORMAL CONTRACT (Design by Contract):\n  preconditions    What must be true before this intent executes\n  postconditions   What must be true when fulfilled\n  invariants       What must remain true throughout and after\n```\n\n**Drift = predicate failure.** A symbol has drifted when its current\nbehavior no longer satisfies the postconditions of the ReasonNode that\ncreated it, or when an invariant is violated.\n\n---\n\n## Six Edge Types\n\n```\nCREATES      Reason  → Symbol   (this intent created this function)\nMODIFIES     Reason  → Symbol   (this intent changed this function)\nREQUIRES     Reason  → Reason   (B depends on A being done first)\nDUPLICATES   Reason  → Reason   (these two goals overlap)\nVALIDATED_BY Reason  → Test     (this test proves the intent was satisfied)\nDRIFTS_FROM  Symbol  → Reason   (this symbol no longer does what it was made for)\n```\n\n---\n\n## 6-Dimension Drift Model\n\n| Dimension | What It Means | Detection |\n|-----------|--------------|-----------|\n| **Spec drift** | Symbol checksum changed without a MODIFIES edge | Compare stored vs current checksum |\n| **Decision drift** | Postconditions no longer hold | Evaluate predicates against codebase |\n| **Ownership drift** | >3 different owners without coherent oversight | Count unique owners on edges |\n| **Test drift** | VALIDATED_BY tests missing or failing | Check test file existence + run |\n| **Usage drift** | Symbol used outside original scope | Grep for imports beyond scope |\n| **Dependency drift** | Downstream REQUIRES reasons have drifted | Traverse REQUIRES edges |\n\nRun `icpg drift check` to scan all dimensions. Each produces a 0-1 severity score.\n\n---\n\n## CLI Reference\n\n### Setup\n```bash\nicpg init                          # Create .icpg/ and database\nicpg bootstrap --days 90           # Infer ReasonNodes from git history\nicpg bootstrap --days 90 --no-llm  # Without LLM (commit-message only)\n```\n\n### Create & Record\n```bash\nicpg create \"Add JWT auth\" --scope src/auth/ --owner feature-auth --type task\nicpg record --reason <id> --base main         # Record symbols from git diff\nicpg record --reason <id> --edge-type MODIFIES # Record as modifications\n```\n\n### Query (the 3 canonical queries)\n```bash\nicpg query prior \"user authentication\"     # 1. Duplicate detection\nicpg query constraints src/auth/service.ts  # 2. Invariants for file\nicpg query risk validateToken              # 3. Symbol risk profile\nicpg query context src/auth/service.ts     # All intents for a file\nicpg query blast <reason-id>               # Full blast radius\n```\n\n### Drift\n```bash\nicpg drift check          # Full scan across all dimensions\nicpg drift resolve <id>   # Mark drift event resolved\n```\n\n### Status\n```bash\nicpg status               # Stats: reasons, symbols, edges, drift\n```\n\n---\n\n## Storage\n\nPer-project, gitignored, zero infrastructure:\n\n```\n.icpg/\n  reason.db       SQLite database (4 tables: reasons, symbols, edges, drift_events)\n  .gitignore      Contains: *\n  chroma/         ChromaDB vectors (if chromadb installed)\n  tfidf_cache.json  TF-IDF fallback cache\n  .current-intent   Marker file for active intent (used by Stop hook)\n```\n\nInstall options:\n```bash\npip install ./scripts/icpg            # Core (zero deps)\npip install \"./scripts/icpg[vectors]\"  # + ChromaDB for duplicate detection\npip install \"./scripts/icpg[all]\"      # + ChromaDB + scikit-learn + openai\n```\n\n---\n\n## Workflow: Before Any Code Change\n\n```\n0. INTENT       → icpg create (or identify existing intent)\n1. DEDUP        → icpg query prior (check for duplicate work)\n2. CONSTRAINTS  → icpg query constraints (understand invariants)\n3. RISK         → icpg query risk (check fragile symbols)\n4. LOCATE       → search_graph to find symbols (code-graph skill)\n5. CHANGE       → Make the edit (PreToolUse hook shows context)\n6. RECORD       → icpg record (link symbols to intent)\n7. DRIFT CHECK  → icpg drift check (verify no unintended drift)\n8. VERIFY       → Run tests, lint, typecheck\n```\n\n**Step 0 is non-negotiable for autonomous agents.** Every change must\nbe linked to a stated purpose. Without an intent, there's nothing to\nmeasure drift against.\n\n---\n\n## Hook Integration\n\n### PreToolUse Hook (automatic context injection)\n\nAdd to `.claude/settings.json`:\n```json\n{\n  \"hooks\": {\n    \"PreToolUse\": [{\n      \"matcher\": \"Edit|Write\",\n      \"hooks\": [{\n        \"type\": \"command\",\n        \"command\": \"scripts/icpg-pre-edit.sh\",\n        \"timeout\": 3,\n        \"statusMessage\": \"Checking intent context...\"\n      }]\n    }]\n  }\n}\n```\n\nBefore every file edit, agents see:\n```\n═══ iCPG CONTEXT ═══\nINTENTS for src/auth/service.ts:\n  [>] a1b2c3d4 — User authentication with JWT tokens\n      Owner: feature-auth | Status: executing\n      Invariants: 2\nCONSTRAINTS for src/auth/service.ts:\n  From intent: User authentication with JWT tokens\n    INV: file_exists(\"src/auth/middleware.ts\")\n    POST: test_exists(\"src/auth/__tests__/service.test.ts\")\nPRESERVE function signatures unless your task requires changing them.\n═══════════════════\n```\n\n### Stop Hook (automatic symbol recording)\n\nAfter implementation passes tests, auto-records symbols:\n```json\n{\n  \"hooks\": {\n    \"Stop\": [{\n      \"hooks\": [\n        {\"type\": \"command\", \"command\": \"scripts/tdd-loop-check.sh\", \"timeout\": 60},\n        {\"type\": \"command\", \"command\": \"scripts/icpg-stop-record.sh\", \"timeout\": 5}\n      ]\n    }]\n  }\n}\n```\n\n---\n\n## Agent Teams Integration\n\n### Updated Pipeline (agent-teams + iCPG)\n\n```\n 0. INTENT       Team lead creates ReasonNode from feature spec\n 0b. DEDUP       icpg query prior — check for duplicate intents\n 1. SPEC         Feature agent writes spec\n 2. SPEC-REVIEW  Quality agent reviews spec + intent alignment\n 3. TESTS (RED)  Feature agent writes tests\n 4. RED-VERIFY   Quality agent verifies tests fail\n 5. IMPLEMENT    Feature agent codes (PreEdit hook shows context)\n 5b. RECORD      Auto-record symbols → intent (Stop hook)\n 5c. DRIFT-CHECK Quality agent verifies no scope drift\n 6. GREEN-VERIFY Quality agent verifies tests pass + coverage\n 7. VALIDATE     Lint + typecheck + full suite\n 8. CODE-REVIEW  Review agent (sees intent context per file)\n 9. SECURITY     Security agent\n10. BRANCH-PR    Merger agent (PR includes intent traceability)\n```\n\n### Agent Responsibilities\n\n| Agent | iCPG Action |\n|-------|-------------|\n| **Team Lead** | `icpg create` when creating task chains. `icpg query prior` to check duplicates. |\n| **Feature Agent** | `icpg query constraints` before implementing. Writes `.icpg/.current-intent` for auto-recording. |\n| **Quality Agent** | `icpg drift check` during GREEN verify. Verifies scope alignment. |\n| **Review Agent** | Sees intent context via PreToolUse hook when reviewing files. |\n| **Merger Agent** | Includes intent traceability in PR description. |\n\n---\n\n## Bootstrapping from Git History\n\nFor existing codebases, infer ReasonNodes from commit history:\n\n```bash\nicpg bootstrap --days 90 --verbose\n```\n\nThis will:\n1. Get commits from last 90 days\n2. Cluster by temporal proximity (2-hour window)\n3. Infer intent via LLM (Claude or OpenAI) or commit message parsing\n4. Create ReasonNodes with `source: \"inferred\"`, `confidence: 0.6-0.8`\n5. Extract symbols from changed files, create CREATES edges\n6. Run duplicate detection against existing ReasonNodes\n\n**Quality note:** Inferred intents are marked low-confidence. Review and\npromote high-value ones manually.\n\n---\n\n## Contract Predicates\n\nPredicates are structured assertions over codebase state:\n\n```\nfile_exists(\"src/auth/middleware.ts\")\ntest_exists(\"src/auth/__tests__/service.test.ts\")\nsymbol_count(\"src/auth/\") <= 15\nfunction_signature(\"validateToken\") == \"(token: string) => Promise<User>\"\n```\n\nContracts can be:\n- **Hand-authored** for high-risk ReasonNodes\n- **LLM-inferred** via `icpg create --infer-contracts`\n- **Heuristic** (scope → file_exists, test → test_exists)\n\n---\n\n## Anti-Patterns\n\n| Anti-Pattern | Do This Instead |\n|-------------|-----------------|\n| Coding without stating intent | `icpg create` before every non-trivial change |\n| Assuming your change is isolated | `icpg query constraints` + `icpg query risk` first |\n| Rebuilding what already exists | `icpg query prior` to check for prior work |\n| Leaving intent in 'executing' forever | Update status to 'fulfilled' when done |\n| Ignoring drift events | `icpg drift check` weekly, resolve or create new intents |\n| Storing full source in symbols | Store signature + checksum only — read source from files |\n| Skipping bootstrap on existing repos | `icpg bootstrap --days 90` to build initial graph |\n"
  },
  {
    "path": "skills/iterative-development/SKILL.md",
    "content": "---\nname: iterative-development\ndescription: TDD iteration loops using Claude Code Stop hooks - runs tests after each response, feeds failures back automatically\nwhen-to-use: When setting up or configuring TDD loops via Stop hooks\nuser-invocable: false\neffort: medium\n---\n\n# Iterative Development Skill (Stop Hook TDD Loops)\n\n\n**Concept:** Claude Code's Stop hook fires right before Claude finishes a response. Exit code 2 feeds stderr back to the model and continues the conversation. This creates a real TDD loop without any plugins.\n\n---\n\n## How It Actually Works\n\nClaude Code has a **Stop hook** that runs when Claude is about to conclude its response. If the hook script exits with code 2, its stderr is shown to the model and the conversation continues automatically.\n\n```\n┌─────────────────────────────────────────────────────────────┐\n│  1. User asks Claude to implement a feature                 │\n├─────────────────────────────────────────────────────────────┤\n│  2. Claude writes tests + implementation                    │\n├─────────────────────────────────────────────────────────────┤\n│  3. Claude finishes its response                            │\n├─────────────────────────────────────────────────────────────┤\n│  4. Stop hook runs: executes tests, lint, typecheck         │\n├─────────────────────────────────────────────────────────────┤\n│  5a. All pass (exit 0) → Claude stops, work is done         │\n│  5b. Failures (exit 2) → stderr fed back to Claude          │\n├─────────────────────────────────────────────────────────────┤\n│  6. Claude sees failures, fixes code, response ends         │\n├─────────────────────────────────────────────────────────────┤\n│  7. Stop hook runs again → repeat until green or max tries  │\n└─────────────────────────────────────────────────────────────┘\n```\n\n**Key insight:** No fake plugins, no `/ralph-loop` command. The hook is real Claude Code infrastructure that runs automatically.\n\n---\n\n## Setup: Stop Hook Configuration\n\nAdd this to your project's `.claude/settings.json`:\n\n```json\n{\n  \"hooks\": {\n    \"Stop\": [\n      {\n        \"hooks\": [\n          {\n            \"type\": \"command\",\n            \"command\": \"scripts/tdd-loop-check.sh\",\n            \"timeout\": 60,\n            \"statusMessage\": \"Running tests...\"\n          }\n        ]\n      }\n    ]\n  }\n}\n```\n\n### The TDD Loop Check Script\n\nCreate `scripts/tdd-loop-check.sh` in your project:\n\n```bash\n#!/bin/bash\n# TDD Loop Check - runs after each Claude response\n# Exit 0 = all good, Claude stops\n# Exit 2 = failures, stderr fed back to Claude to fix\n\nMAX_ITERATIONS=25\nITERATION_FILE=\".claude/.tdd-iteration-count\"\n\n# Track iteration count\nif [ -f \"$ITERATION_FILE\" ]; then\n    count=$(cat \"$ITERATION_FILE\")\n    count=$((count + 1))\nelse\n    count=1\nfi\necho \"$count\" > \"$ITERATION_FILE\"\n\n# Safety: stop after max iterations\nif [ \"$count\" -ge \"$MAX_ITERATIONS\" ]; then\n    rm -f \"$ITERATION_FILE\"\n    echo \"Max iterations ($MAX_ITERATIONS) reached. Stopping loop.\" >&2\n    exit 0\nfi\n\n# Skip if no test files exist yet\nif ! find . -name \"*.test.*\" -o -name \"*.spec.*\" -o -name \"test_*\" 2>/dev/null | grep -q .; then\n    rm -f \"$ITERATION_FILE\"\n    exit 0\nfi\n\n# Run tests\nTEST_OUTPUT=$(npm test 2>&1) || {\n    echo \"ITERATION $count/$MAX_ITERATIONS - Tests failing:\" >&2\n    echo \"$TEST_OUTPUT\" | tail -30 >&2\n    echo \"\" >&2\n    echo \"Fix the failing tests and try again.\" >&2\n    exit 2\n}\n\n# Run lint (if configured)\nif [ -f \"package.json\" ] && grep -q '\"lint\"' package.json; then\n    LINT_OUTPUT=$(npm run lint 2>&1) || {\n        echo \"ITERATION $count/$MAX_ITERATIONS - Lint errors:\" >&2\n        echo \"$LINT_OUTPUT\" | tail -20 >&2\n        echo \"\" >&2\n        echo \"Fix lint errors and try again.\" >&2\n        exit 2\n    }\nfi\n\n# Run typecheck (if configured)\nif [ -f \"tsconfig.json\" ]; then\n    TYPE_OUTPUT=$(npx tsc --noEmit 2>&1) || {\n        echo \"ITERATION $count/$MAX_ITERATIONS - Type errors:\" >&2\n        echo \"$TYPE_OUTPUT\" | tail -20 >&2\n        echo \"\" >&2\n        echo \"Fix type errors and try again.\" >&2\n        exit 2\n    }\nfi\n\n# All green - reset counter and let Claude stop\nrm -f \"$ITERATION_FILE\"\nexit 0\n```\n\n### Python Variant\n\n```bash\n#!/bin/bash\n# Python TDD Loop Check\n\nMAX_ITERATIONS=25\nITERATION_FILE=\".claude/.tdd-iteration-count\"\n\nif [ -f \"$ITERATION_FILE\" ]; then\n    count=$(cat \"$ITERATION_FILE\")\n    count=$((count + 1))\nelse\n    count=1\nfi\necho \"$count\" > \"$ITERATION_FILE\"\n\nif [ \"$count\" -ge \"$MAX_ITERATIONS\" ]; then\n    rm -f \"$ITERATION_FILE\"\n    echo \"Max iterations ($MAX_ITERATIONS) reached.\" >&2\n    exit 0\nfi\n\nif ! find . -name \"test_*\" -o -name \"*_test.py\" 2>/dev/null | grep -q .; then\n    rm -f \"$ITERATION_FILE\"\n    exit 0\nfi\n\nTEST_OUTPUT=$(pytest -v 2>&1) || {\n    echo \"ITERATION $count/$MAX_ITERATIONS - Tests failing:\" >&2\n    echo \"$TEST_OUTPUT\" | tail -30 >&2\n    exit 2\n}\n\nif command -v ruff &>/dev/null; then\n    LINT_OUTPUT=$(ruff check . 2>&1) || {\n        echo \"ITERATION $count/$MAX_ITERATIONS - Lint errors:\" >&2\n        echo \"$LINT_OUTPUT\" | tail -20 >&2\n        exit 2\n    }\nfi\n\nif command -v mypy &>/dev/null; then\n    TYPE_OUTPUT=$(mypy . 2>&1) || {\n        echo \"ITERATION $count/$MAX_ITERATIONS - Type errors:\" >&2\n        echo \"$TYPE_OUTPUT\" | tail -20 >&2\n        exit 2\n    }\nfi\n\nrm -f \"$ITERATION_FILE\"\nexit 0\n```\n\n---\n\n## Additional Hooks for Quality Enforcement\n\n### PreToolUse Hook: Lint Before File Writes\n\nRuns a linter before any Write/Edit lands:\n\n```json\n{\n  \"hooks\": {\n    \"PreToolUse\": [\n      {\n        \"matcher\": \"Write|Edit\",\n        \"hooks\": [\n          {\n            \"type\": \"command\",\n            \"command\": \"scripts/pre-write-lint.sh\",\n            \"timeout\": 10,\n            \"statusMessage\": \"Checking code quality...\"\n          }\n        ]\n      }\n    ]\n  }\n}\n```\n\n### SessionStart Hook: Auto-Inject Context\n\nRuns at session start to inject project info:\n\n```json\n{\n  \"hooks\": {\n    \"SessionStart\": [\n      {\n        \"hooks\": [\n          {\n            \"type\": \"command\",\n            \"command\": \"echo 'TDD loop active. Tests run automatically after each response. Fix failures to continue.'\",\n            \"statusMessage\": \"Loading project context...\"\n          }\n        ]\n      }\n    ]\n  }\n}\n```\n\n---\n\n## Core Philosophy\n\n```\n┌─────────────────────────────────────────────────────────────┐\n│  ITERATION > PERFECTION                                     │\n│  ─────────────────────────────────────────────────────────  │\n│  Don't aim for perfect on first try.                        │\n│  Let the loop refine the work. Each iteration builds on     │\n│  previous attempts visible in files and git history.        │\n├─────────────────────────────────────────────────────────────┤\n│  FAILURES ARE DATA                                          │\n│  ─────────────────────────────────────────────────────────  │\n│  Failed tests, lint errors, type mismatches are signals.    │\n│  The Stop hook feeds them directly to Claude as context.    │\n├─────────────────────────────────────────────────────────────┤\n│  CLEAR COMPLETION CRITERIA                                  │\n│  ─────────────────────────────────────────────────────────  │\n│  The hook defines \"done\": tests pass, lint clean, types ok. │\n│  No ambiguity about when to stop.                           │\n└─────────────────────────────────────────────────────────────┘\n```\n\n---\n\n## Error Classification\n\nNot all failures should loop. The hook script should distinguish:\n\n| Type | Examples | Action |\n|------|----------|--------|\n| **Code Error** | Logic bug, wrong assertion, type mismatch | Exit 2 → loop continues |\n| **Access Error** | Missing API key, DB connection refused | Exit 0 → stop, report to user |\n| **Environment Error** | Missing package, wrong runtime version | Exit 0 → stop, report to user |\n\nThe sample scripts above handle this — they only exit 2 for test/lint/type failures, not for environment issues.\n\n---\n\n## When to Use TDD Loops\n\n### Good For\n| Use Case | Why |\n|----------|-----|\n| Feature development | Tests provide clear pass/fail signal |\n| Bug fixes | Write failing test, fix, loop until green |\n| Refactoring | Existing tests catch regressions |\n| API development | Each endpoint independently testable |\n\n### Not Good For\n| Use Case | Why |\n|----------|-----|\n| UI/UX work | Requires human judgment |\n| One-shot operations | No iteration needed |\n| Unclear requirements | No clear \"done\" criteria |\n| Subjective design | No objective success metric |\n\n---\n\n## Disabling the Loop\n\nTo temporarily disable the TDD loop for a session:\n\n1. Remove or rename the Stop hook in `.claude/settings.json`\n2. Or set `MAX_ITERATIONS=1` in the script\n3. Or delete `scripts/tdd-loop-check.sh`\n\nThe hook only fires if the script exists and is configured.\n\n---\n\n## Gitignore Additions\n\n```gitignore\n# TDD loop state\n.claude/.tdd-iteration-count\n```\n"
  },
  {
    "path": "skills/klaviyo/SKILL.md",
    "content": "---\nname: klaviyo\ndescription: Klaviyo email/SMS marketing - profiles, events, flows, segmentation\nwhen-to-use: When integrating Klaviyo for email/SMS marketing\nuser-invocable: false\neffort: medium\n---\n\n# Klaviyo E-Commerce Marketing Skill\n\n\nFor integrating Klaviyo email/SMS marketing - customer profiles, event tracking, campaigns, flows, and segmentation.\n\n**Sources:** [Klaviyo API Docs](https://developers.klaviyo.com/en/docs) | [API Reference](https://developers.klaviyo.com/en/reference/api-overview)\n\n---\n\n## Why Klaviyo\n\n| Feature | Benefit |\n|---------|---------|\n| **E-commerce Native** | Built for online stores, deep integrations |\n| **Event-Based** | Trigger flows from any customer action |\n| **Segmentation** | Advanced filtering on behavior + properties |\n| **Email + SMS** | Unified platform for both channels |\n| **Analytics** | Revenue attribution per campaign |\n\n---\n\n## API Basics\n\n### Base URLs\n\n| Type | URL |\n|------|-----|\n| Server-side (Private) | `https://a.klaviyo.com/api` |\n| Client-side (Public) | `https://a.klaviyo.com/client` |\n\n### Authentication\n\n```typescript\n// Server-side: Private API Key\nconst headers = {\n  \"Authorization\": \"Klaviyo-API-Key pk_xxxxxxxxxxxxxxxxxxxxxxxx\",\n  \"Content-Type\": \"application/json\",\n  \"revision\": \"2024-10-15\",  // API version\n};\n\n// Client-side: Public API Key (6 characters)\nconst publicKey = \"XXXXXX\";  // Company ID\n// Use as query param: ?company_id=XXXXXX\n```\n\n### API Key Scopes\n\n| Scope | Access |\n|-------|--------|\n| Read-only | View data only |\n| Full | Read + write (default) |\n| Custom | Specific permissions |\n\n---\n\n## Installation\n\n### Node.js\n\n```bash\nnpm install klaviyo-api\n```\n\n```typescript\n// lib/klaviyo.ts\nimport { ApiClient, EventsApi, ProfilesApi, ListsApi } from \"klaviyo-api\";\n\nconst client = new ApiClient();\nclient.setApiKey(process.env.KLAVIYO_PRIVATE_KEY!);\n\nexport const eventsApi = new EventsApi(client);\nexport const profilesApi = new ProfilesApi(client);\nexport const listsApi = new ListsApi(client);\n```\n\n### Python\n\n```bash\npip install klaviyo-api\n```\n\n```python\n# lib/klaviyo.py\nfrom klaviyo_api import KlaviyoAPI\n\nklaviyo = KlaviyoAPI(\n    api_key=os.environ[\"KLAVIYO_PRIVATE_KEY\"],\n    max_delay=60,\n    max_retries=3\n)\n```\n\n### Direct HTTP (Any Language)\n\n```typescript\n// lib/klaviyo.ts\nconst KLAVIYO_BASE_URL = \"https://a.klaviyo.com/api\";\n\nasync function klaviyoRequest(\n  endpoint: string,\n  method: \"GET\" | \"POST\" | \"PATCH\" | \"DELETE\" = \"GET\",\n  body?: object\n) {\n  const response = await fetch(`${KLAVIYO_BASE_URL}${endpoint}`, {\n    method,\n    headers: {\n      Authorization: `Klaviyo-API-Key ${process.env.KLAVIYO_PRIVATE_KEY}`,\n      \"Content-Type\": \"application/json\",\n      revision: \"2024-10-15\",\n    },\n    body: body ? JSON.stringify(body) : undefined,\n  });\n\n  if (!response.ok) {\n    const error = await response.json();\n    throw new Error(`Klaviyo API error: ${JSON.stringify(error)}`);\n  }\n\n  return response.json();\n}\n```\n\n---\n\n## Profiles (Customers)\n\n### Create/Update Profile\n\n```typescript\n// Upsert profile (create or update)\nasync function upsertProfile(data: ProfileInput) {\n  return klaviyoRequest(\"/profiles\", \"POST\", {\n    data: {\n      type: \"profile\",\n      attributes: {\n        email: data.email,\n        phone_number: data.phone, // E.164 format: +1234567890\n        first_name: data.firstName,\n        last_name: data.lastName,\n        properties: {\n          // Custom properties\n          lifetime_value: data.ltv,\n          plan: data.plan,\n          signup_source: data.source,\n        },\n        location: {\n          city: data.city,\n          region: data.state,\n          country: data.country,\n          zip: data.zip,\n        },\n      },\n    },\n  });\n}\n```\n\n```python\n# Python\ndef upsert_profile(data):\n    return klaviyo.Profiles.create_or_update_profile({\n        \"data\": {\n            \"type\": \"profile\",\n            \"attributes\": {\n                \"email\": data[\"email\"],\n                \"first_name\": data[\"first_name\"],\n                \"last_name\": data[\"last_name\"],\n                \"properties\": {\n                    \"plan\": data.get(\"plan\"),\n                }\n            }\n        }\n    })\n```\n\n### Get Profile\n\n```typescript\nasync function getProfileByEmail(email: string) {\n  const response = await klaviyoRequest(\n    `/profiles?filter=equals(email,\"${email}\")`\n  );\n  return response.data[0];\n}\n\nasync function getProfileById(profileId: string) {\n  return klaviyoRequest(`/profiles/${profileId}`);\n}\n```\n\n### Update Profile Properties\n\n```typescript\nasync function updateProfileProperties(\n  profileId: string,\n  properties: Record<string, any>\n) {\n  return klaviyoRequest(`/profiles/${profileId}`, \"PATCH\", {\n    data: {\n      type: \"profile\",\n      id: profileId,\n      attributes: {\n        properties,\n      },\n    },\n  });\n}\n\n// Usage\nawait updateProfileProperties(\"profile_id\", {\n  last_purchase_date: new Date().toISOString(),\n  total_orders: 5,\n  vip_status: true,\n});\n```\n\n---\n\n## Events (Tracking)\n\n### Track Event (Server-Side)\n\n```typescript\nasync function trackEvent(data: EventInput) {\n  return klaviyoRequest(\"/events\", \"POST\", {\n    data: {\n      type: \"event\",\n      attributes: {\n        profile: {\n          data: {\n            type: \"profile\",\n            attributes: {\n              email: data.email,\n              // or phone_number, or external_id\n            },\n          },\n        },\n        metric: {\n          data: {\n            type: \"metric\",\n            attributes: {\n              name: data.eventName,\n            },\n          },\n        },\n        properties: data.properties,\n        value: data.value, // For revenue tracking\n        unique_id: data.uniqueId, // Deduplication\n        time: data.timestamp || new Date().toISOString(),\n      },\n    },\n  });\n}\n```\n\n### Common E-Commerce Events\n\n```typescript\n// Viewed Product\nawait trackEvent({\n  email: customer.email,\n  eventName: \"Viewed Product\",\n  properties: {\n    ProductID: product.id,\n    ProductName: product.name,\n    ProductURL: product.url,\n    ImageURL: product.image,\n    Price: product.price,\n    Categories: product.categories,\n  },\n});\n\n// Added to Cart\nawait trackEvent({\n  email: customer.email,\n  eventName: \"Added to Cart\",\n  properties: {\n    ProductID: product.id,\n    ProductName: product.name,\n    Quantity: quantity,\n    Price: product.price,\n    CartTotal: cart.total,\n    ItemNames: cart.items.map(i => i.name),\n  },\n  value: product.price * quantity,\n});\n\n// Started Checkout\nawait trackEvent({\n  email: customer.email,\n  eventName: \"Started Checkout\",\n  properties: {\n    CheckoutURL: checkout.url,\n    ItemCount: cart.itemCount,\n    Categories: cart.categories,\n    ItemNames: cart.items.map(i => i.name),\n  },\n  value: cart.total,\n});\n\n// Placed Order\nawait trackEvent({\n  email: customer.email,\n  eventName: \"Placed Order\",\n  properties: {\n    OrderId: order.id,\n    ItemCount: order.itemCount,\n    Categories: order.categories,\n    ItemNames: order.items.map(i => i.name),\n    Items: order.items.map(i => ({\n      ProductID: i.productId,\n      ProductName: i.name,\n      Quantity: i.quantity,\n      Price: i.price,\n      ImageURL: i.image,\n      ProductURL: i.url,\n    })),\n    BillingAddress: order.billingAddress,\n    ShippingAddress: order.shippingAddress,\n  },\n  value: order.total,\n  uniqueId: order.id, // Prevent duplicate orders\n});\n\n// Fulfilled Order\nawait trackEvent({\n  email: customer.email,\n  eventName: \"Fulfilled Order\",\n  properties: {\n    OrderId: order.id,\n    TrackingNumber: fulfillment.trackingNumber,\n    TrackingURL: fulfillment.trackingUrl,\n    Carrier: fulfillment.carrier,\n  },\n});\n\n// Cancelled Order\nawait trackEvent({\n  email: customer.email,\n  eventName: \"Cancelled Order\",\n  properties: {\n    OrderId: order.id,\n    Reason: cancellation.reason,\n  },\n  value: -order.total, // Negative value for refunds\n});\n```\n\n### Client-Side Tracking (JavaScript)\n\n```html\n<!-- Add to your site -->\n<script async src=\"https://static.klaviyo.com/onsite/js/klaviyo.js?company_id=XXXXXX\"></script>\n\n<script>\n  // Identify user\n  klaviyo.identify({\n    email: \"customer@example.com\",\n    first_name: \"John\",\n    last_name: \"Doe\",\n  });\n\n  // Track event\n  klaviyo.track(\"Viewed Product\", {\n    ProductID: \"prod_123\",\n    ProductName: \"Blue T-Shirt\",\n    Price: 29.99,\n  });\n\n  // Track with value\n  klaviyo.track(\"Added to Cart\", {\n    ProductID: \"prod_123\",\n    ProductName: \"Blue T-Shirt\",\n    Price: 29.99,\n    $value: 29.99,  // Revenue tracking\n  });\n</script>\n```\n\n---\n\n## Lists & Segments\n\n### Add Profile to List\n\n```typescript\nasync function addToList(listId: string, emails: string[]) {\n  return klaviyoRequest(`/lists/${listId}/relationships/profiles`, \"POST\", {\n    data: emails.map(email => ({\n      type: \"profile\",\n      attributes: { email },\n    })),\n  });\n}\n\n// By profile ID\nasync function addProfileToList(listId: string, profileId: string) {\n  return klaviyoRequest(`/lists/${listId}/relationships/profiles`, \"POST\", {\n    data: [{ type: \"profile\", id: profileId }],\n  });\n}\n```\n\n### Remove from List\n\n```typescript\nasync function removeFromList(listId: string, profileId: string) {\n  return klaviyoRequest(\n    `/lists/${listId}/relationships/profiles`,\n    \"DELETE\",\n    {\n      data: [{ type: \"profile\", id: profileId }],\n    }\n  );\n}\n```\n\n### Get List Members\n\n```typescript\nasync function getListMembers(listId: string, cursor?: string) {\n  const params = new URLSearchParams({\n    \"page[size]\": \"100\",\n  });\n  if (cursor) {\n    params.set(\"page[cursor]\", cursor);\n  }\n\n  return klaviyoRequest(`/lists/${listId}/profiles?${params}`);\n}\n```\n\n### Create List\n\n```typescript\nasync function createList(name: string) {\n  return klaviyoRequest(\"/lists\", \"POST\", {\n    data: {\n      type: \"list\",\n      attributes: { name },\n    },\n  });\n}\n```\n\n---\n\n## Campaigns\n\n### Get Campaigns\n\n```typescript\nasync function getCampaigns(status?: \"draft\" | \"scheduled\" | \"sent\") {\n  const params = new URLSearchParams();\n  if (status) {\n    params.set(\"filter\", `equals(status,\"${status}\")`);\n  }\n\n  return klaviyoRequest(`/campaigns?${params}`);\n}\n```\n\n### Get Campaign Performance\n\n```typescript\nasync function getCampaignMetrics(campaignId: string) {\n  return klaviyoRequest(\n    `/campaign-recipient-estimations/${campaignId}`,\n    \"GET\"\n  );\n}\n```\n\n---\n\n## Flows (Automations)\n\n### Get Flows\n\n```typescript\nasync function getFlows() {\n  return klaviyoRequest(\"/flows\");\n}\n\nasync function getFlowById(flowId: string) {\n  return klaviyoRequest(`/flows/${flowId}`);\n}\n```\n\n### Common Flow Triggers\n\n| Flow Type | Trigger Event |\n|-----------|---------------|\n| Welcome Series | Added to List |\n| Abandoned Cart | Added to Cart + No Purchase |\n| Browse Abandon | Viewed Product + No Cart |\n| Post-Purchase | Placed Order |\n| Winback | No Order in X Days |\n| Review Request | Fulfilled Order |\n\n---\n\n## Webhooks\n\n### Create Webhook\n\n```typescript\nasync function createWebhook(data: WebhookInput) {\n  return klaviyoRequest(\"/webhooks\", \"POST\", {\n    data: {\n      type: \"webhook\",\n      attributes: {\n        name: data.name,\n        endpoint_url: data.url,\n        secret_key: data.secret,\n        topics: data.topics, // e.g., [\"profile.created\", \"event.created\"]\n      },\n    },\n  });\n}\n```\n\n### Webhook Topics\n\n| Topic | Trigger |\n|-------|---------|\n| `profile.created` | New profile created |\n| `profile.updated` | Profile properties changed |\n| `profile.merged` | Profiles merged |\n| `event.created` | New event tracked |\n| `list.member.added` | Profile added to list |\n| `list.member.removed` | Profile removed from list |\n\n### Verify Webhook Signature\n\n```typescript\nimport crypto from \"crypto\";\n\nfunction verifyKlaviyoWebhook(\n  payload: string,\n  signature: string,\n  secret: string\n): boolean {\n  const expectedSignature = crypto\n    .createHmac(\"sha256\", secret)\n    .update(payload)\n    .digest(\"base64\");\n\n  return crypto.timingSafeEqual(\n    Buffer.from(signature),\n    Buffer.from(expectedSignature)\n  );\n}\n\n// Express handler\napp.post(\"/webhooks/klaviyo\", (req, res) => {\n  const signature = req.headers[\"klaviyo-webhook-signature\"] as string;\n\n  if (!verifyKlaviyoWebhook(JSON.stringify(req.body), signature, WEBHOOK_SECRET)) {\n    return res.status(401).json({ error: \"Invalid signature\" });\n  }\n\n  const { type, data } = req.body;\n\n  switch (type) {\n    case \"profile.created\":\n      handleNewProfile(data);\n      break;\n    case \"event.created\":\n      handleNewEvent(data);\n      break;\n  }\n\n  res.status(200).json({ received: true });\n});\n```\n\n---\n\n## Rate Limits\n\n| Window | Limit |\n|--------|-------|\n| Burst | 75 requests/second |\n| Steady | 700 requests/minute |\n\n### Handle Rate Limiting\n\n```typescript\nasync function klaviyoRequestWithRetry(\n  endpoint: string,\n  method: \"GET\" | \"POST\" | \"PATCH\" | \"DELETE\" = \"GET\",\n  body?: object,\n  retries = 3\n): Promise<any> {\n  for (let attempt = 0; attempt < retries; attempt++) {\n    const response = await fetch(`${KLAVIYO_BASE_URL}${endpoint}`, {\n      method,\n      headers: {\n        Authorization: `Klaviyo-API-Key ${process.env.KLAVIYO_PRIVATE_KEY}`,\n        \"Content-Type\": \"application/json\",\n        revision: \"2024-10-15\",\n      },\n      body: body ? JSON.stringify(body) : undefined,\n    });\n\n    if (response.status === 429) {\n      const retryAfter = parseInt(response.headers.get(\"Retry-After\") || \"5\");\n      await new Promise(r => setTimeout(r, retryAfter * 1000));\n      continue;\n    }\n\n    if (!response.ok) {\n      throw new Error(`Klaviyo error: ${response.status}`);\n    }\n\n    return response.json();\n  }\n\n  throw new Error(\"Max retries exceeded\");\n}\n```\n\n---\n\n## Pagination\n\n```typescript\nasync function getAllProfiles() {\n  const profiles = [];\n  let cursor: string | undefined;\n\n  do {\n    const params = new URLSearchParams({ \"page[size]\": \"100\" });\n    if (cursor) {\n      params.set(\"page[cursor]\", cursor);\n    }\n\n    const response = await klaviyoRequest(`/profiles?${params}`);\n    profiles.push(...response.data);\n\n    cursor = response.links?.next\n      ? new URL(response.links.next).searchParams.get(\"page[cursor]\")\n      : undefined;\n  } while (cursor);\n\n  return profiles;\n}\n```\n\n---\n\n## Filtering & Sorting\n\n```typescript\n// Filter by date\nconst recentEvents = await klaviyoRequest(\n  `/events?filter=greater-than(datetime,2024-01-01T00:00:00Z)`\n);\n\n// Filter by property\nconst vipProfiles = await klaviyoRequest(\n  `/profiles?filter=equals(properties.vip_status,true)`\n);\n\n// Multiple filters (AND)\nconst filtered = await klaviyoRequest(\n  `/profiles?filter=and(equals(properties.plan,\"pro\"),greater-than(properties.ltv,1000))`\n);\n\n// Sorting\nconst sorted = await klaviyoRequest(\n  `/profiles?sort=-created`  // Descending by created date\n);\n\n// Sparse fieldsets (only return specific fields)\nconst sparse = await klaviyoRequest(\n  `/profiles?fields[profile]=email,first_name,properties`\n);\n```\n\n---\n\n## Integration Patterns\n\n### E-Commerce Order Sync\n\n```typescript\n// After order is placed\nasync function syncOrderToKlaviyo(order: Order) {\n  // 1. Upsert customer profile\n  await upsertProfile({\n    email: order.customerEmail,\n    firstName: order.customerFirstName,\n    lastName: order.customerLastName,\n    phone: order.customerPhone,\n  });\n\n  // 2. Update lifetime metrics\n  await updateProfileProperties(\n    await getProfileIdByEmail(order.customerEmail),\n    {\n      last_order_date: new Date().toISOString(),\n      total_orders: order.customerOrderCount,\n      lifetime_value: order.customerLifetimeValue,\n    }\n  );\n\n  // 3. Track order event\n  await trackEvent({\n    email: order.customerEmail,\n    eventName: \"Placed Order\",\n    properties: {\n      OrderId: order.id,\n      Items: order.items,\n      // ... other properties\n    },\n    value: order.total,\n    uniqueId: order.id,\n  });\n}\n```\n\n### Subscription Status Sync\n\n```typescript\n// When subscription changes\nasync function syncSubscriptionStatus(user: User, status: string) {\n  await updateProfileProperties(user.klaviyoProfileId, {\n    subscription_status: status,\n    subscription_plan: user.plan,\n    subscription_updated_at: new Date().toISOString(),\n  });\n\n  await trackEvent({\n    email: user.email,\n    eventName: `Subscription ${status}`,\n    properties: {\n      plan: user.plan,\n      mrr: user.mrr,\n    },\n    value: status === \"cancelled\" ? -user.mrr : user.mrr,\n  });\n}\n```\n\n---\n\n## Environment Variables\n\n```bash\n# .env\nKLAVIYO_PRIVATE_KEY=pk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\nKLAVIYO_PUBLIC_KEY=XXXXXX\nKLAVIYO_WEBHOOK_SECRET=your_webhook_secret\n```\n\nAdd to `credentials.md`:\n```python\n'KLAVIYO_PRIVATE_KEY': r'pk_[a-f0-9]{32}',\n'KLAVIYO_PUBLIC_KEY': r'[A-Z0-9]{6}',\n```\n\n---\n\n## Checklist\n\n### Setup\n\n- [ ] Klaviyo account created\n- [ ] Private API key generated\n- [ ] Public API key noted (company ID)\n- [ ] API revision set in headers\n\n### Integration\n\n- [ ] Profile sync on signup/update\n- [ ] Key events tracked (view, cart, order)\n- [ ] Order events include Items array\n- [ ] Revenue tracked with $value\n- [ ] Unique IDs for deduplication\n\n### Testing\n\n- [ ] Test profile creation\n- [ ] Test event tracking\n- [ ] Verify events in Klaviyo dashboard\n- [ ] Test webhook delivery\n- [ ] Test rate limit handling\n\n---\n\n## Anti-Patterns\n\n- **Missing email/phone** - Every profile needs at least one identifier\n- **Duplicate events** - Use unique_id for orders/transactions\n- **Missing Items array** - Required for product recommendations\n- **Client-side only** - Server-side tracking is more reliable\n- **Ignoring rate limits** - Implement exponential backoff\n- **Hardcoded API keys** - Use environment variables\n- **Missing revenue tracking** - Include $value for ROI attribution\n"
  },
  {
    "path": "skills/llm-patterns/SKILL.md",
    "content": "---\nname: llm-patterns\ndescription: AI-first application patterns, LLM testing, prompt management\nwhen-to-use: When building apps where LLMs handle core logic - classification, extraction, generation\nuser-invocable: false\neffort: medium\n---\n\n# LLM Patterns Skill\n\n\nFor AI-first applications where LLMs handle logical operations.\n\n---\n\n## Core Principle\n\n**LLM for logic, code for plumbing.**\n\nUse LLMs for:\n- Classification, extraction, summarization\n- Decision-making with natural language reasoning\n- Content generation and transformation\n- Complex conditional logic that would be brittle in code\n\nUse traditional code for:\n- Data validation (Zod/Pydantic)\n- API routing and HTTP handling\n- Database operations\n- Authentication/authorization\n- Orchestration and error handling\n\n---\n\n## Project Structure\n\n```\nproject/\n├── src/\n│   ├── core/\n│   │   ├── prompts/           # Prompt templates\n│   │   │   ├── classify.ts\n│   │   │   └── extract.ts\n│   │   ├── llm/               # LLM client and utilities\n│   │   │   ├── client.ts      # LLM client wrapper\n│   │   │   ├── schemas.ts     # Response schemas (Zod)\n│   │   │   └── index.ts\n│   │   └── services/          # Business logic using LLM\n│   ├── infra/\n│   └── ...\n├── tests/\n│   ├── unit/\n│   ├── integration/\n│   └── llm/                   # LLM-specific tests\n│       ├── fixtures/          # Saved responses for deterministic tests\n│       ├── evals/             # Evaluation test suites\n│       └── mocks/             # Mock LLM responses\n└── _project_specs/\n    └── prompts/               # Prompt specifications\n```\n\n---\n\n## LLM Client Pattern\n\n### Typed LLM Wrapper\n```typescript\n// core/llm/client.ts\nimport Anthropic from '@anthropic-ai/sdk';\nimport { z } from 'zod';\n\nconst client = new Anthropic();\n\ninterface LLMCallOptions<T> {\n  prompt: string;\n  schema: z.ZodSchema<T>;\n  model?: string;\n  maxTokens?: number;\n}\n\nexport async function llmCall<T>({\n  prompt,\n  schema,\n  model = 'claude-sonnet-4-20250514',\n  maxTokens = 1024,\n}: LLMCallOptions<T>): Promise<T> {\n  const response = await client.messages.create({\n    model,\n    max_tokens: maxTokens,\n    messages: [{ role: 'user', content: prompt }],\n  });\n\n  const text = response.content[0].type === 'text'\n    ? response.content[0].text\n    : '';\n\n  // Parse and validate response\n  const parsed = JSON.parse(text);\n  return schema.parse(parsed);\n}\n```\n\n### Structured Outputs\n```typescript\n// core/llm/schemas.ts\nimport { z } from 'zod';\n\nexport const ClassificationSchema = z.object({\n  category: z.enum(['support', 'sales', 'feedback', 'other']),\n  confidence: z.number().min(0).max(1),\n  reasoning: z.string(),\n});\n\nexport type Classification = z.infer<typeof ClassificationSchema>;\n```\n\n---\n\n## Prompt Patterns\n\n### Template Functions\n```typescript\n// core/prompts/classify.ts\nexport function classifyTicketPrompt(ticket: string): string {\n  return `Classify this support ticket into one of these categories:\n- support: Technical issues or help requests\n- sales: Pricing, plans, or purchase inquiries\n- feedback: Suggestions or complaints\n- other: Anything else\n\nRespond with JSON:\n{\n  \"category\": \"...\",\n  \"confidence\": 0.0-1.0,\n  \"reasoning\": \"brief explanation\"\n}\n\nTicket:\n${ticket}`;\n}\n```\n\n### Prompt Versioning\n```typescript\n// core/prompts/index.ts\nexport const PROMPTS = {\n  classify: {\n    v1: classifyTicketPromptV1,\n    v2: classifyTicketPromptV2,  // improved accuracy\n    current: classifyTicketPromptV2,\n  },\n} as const;\n```\n\n---\n\n## Testing LLM Calls\n\n### 1. Unit Tests with Mocks (Fast, Deterministic)\n```typescript\n// tests/llm/mocks/classify.mock.ts\nexport const mockClassifyResponse = {\n  category: 'support',\n  confidence: 0.95,\n  reasoning: 'User is asking for help with login',\n};\n\n// tests/unit/services/ticket.test.ts\nimport { classifyTicket } from '../../../src/core/services/ticket';\nimport { mockClassifyResponse } from '../../llm/mocks/classify.mock';\n\n// Mock the LLM client\nvi.mock('../../../src/core/llm/client', () => ({\n  llmCall: vi.fn().mockResolvedValue(mockClassifyResponse),\n}));\n\ndescribe('classifyTicket', () => {\n  it('returns classification for ticket', async () => {\n    const result = await classifyTicket('I cannot log in');\n\n    expect(result.category).toBe('support');\n    expect(result.confidence).toBeGreaterThan(0.9);\n  });\n});\n```\n\n### 2. Fixture Tests (Deterministic, Tests Parsing)\n```typescript\n// tests/llm/fixtures/classify.fixtures.json\n{\n  \"support_ticket\": {\n    \"input\": \"I can't reset my password\",\n    \"expected_category\": \"support\",\n    \"raw_response\": \"{\\\"category\\\":\\\"support\\\",\\\"confidence\\\":0.98,\\\"reasoning\\\":\\\"Password reset is a support issue\\\"}\"\n  }\n}\n\n// tests/llm/classify.fixture.test.ts\nimport fixtures from './fixtures/classify.fixtures.json';\nimport { ClassificationSchema } from '../../src/core/llm/schemas';\n\ndescribe('Classification Response Parsing', () => {\n  Object.entries(fixtures).forEach(([name, fixture]) => {\n    it(`parses ${name} correctly`, () => {\n      const parsed = JSON.parse(fixture.raw_response);\n      const result = ClassificationSchema.parse(parsed);\n\n      expect(result.category).toBe(fixture.expected_category);\n    });\n  });\n});\n```\n\n### 3. Evaluation Tests (Slow, Run in CI nightly)\n```typescript\n// tests/llm/evals/classify.eval.test.ts\nimport { classifyTicket } from '../../../src/core/services/ticket';\n\nconst TEST_CASES = [\n  { input: 'How much does the pro plan cost?', expected: 'sales' },\n  { input: 'The app crashes when I click save', expected: 'support' },\n  { input: 'You should add dark mode', expected: 'feedback' },\n  { input: 'What time is it in Tokyo?', expected: 'other' },\n];\n\ndescribe('Classification Accuracy (Eval)', () => {\n  // Skip in regular CI, run nightly\n  const runEvals = process.env.RUN_LLM_EVALS === 'true';\n\n  it.skipIf(!runEvals)('achieves >90% accuracy on test set', async () => {\n    let correct = 0;\n\n    for (const testCase of TEST_CASES) {\n      const result = await classifyTicket(testCase.input);\n      if (result.category === testCase.expected) correct++;\n    }\n\n    const accuracy = correct / TEST_CASES.length;\n    expect(accuracy).toBeGreaterThan(0.9);\n  }, 60000); // 60s timeout for LLM calls\n});\n```\n\n---\n\n## GitHub Actions for LLM Tests\n\n```yaml\n# .github/workflows/quality.yml (add to existing)\njobs:\n  quality:\n    # ... existing steps ...\n\n    - name: Run Tests (with LLM mocks)\n      run: npm run test:coverage\n\n  llm-evals:\n    runs-on: ubuntu-latest\n    # Run nightly or on-demand\n    if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Setup Node\n        uses: actions/setup-node@v4\n        with:\n          node-version: '20'\n\n      - name: Install dependencies\n        run: npm ci\n\n      - name: Run LLM Evals\n        run: npm run test:evals\n        env:\n          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}\n          RUN_LLM_EVALS: 'true'\n```\n\n---\n\n## Cost & Performance Tracking\n\n```typescript\n// core/llm/client.ts - add tracking\ninterface LLMMetrics {\n  model: string;\n  inputTokens: number;\n  outputTokens: number;\n  latencyMs: number;\n  cost: number;\n}\n\nexport async function llmCallWithMetrics<T>(\n  options: LLMCallOptions<T>\n): Promise<{ result: T; metrics: LLMMetrics }> {\n  const start = Date.now();\n\n  const response = await client.messages.create({...});\n\n  const metrics: LLMMetrics = {\n    model: options.model,\n    inputTokens: response.usage.input_tokens,\n    outputTokens: response.usage.output_tokens,\n    latencyMs: Date.now() - start,\n    cost: calculateCost(response.usage, options.model),\n  };\n\n  // Log or send to monitoring\n  console.log('[LLM]', metrics);\n\n  return { result: parsed, metrics };\n}\n```\n\n---\n\n## LLM Anti-Patterns\n\n- ❌ Hardcoded prompts in business logic - use prompt templates\n- ❌ No schema validation on LLM responses - always use Zod\n- ❌ Testing with live LLM calls in CI - use mocks for unit tests\n- ❌ No cost tracking - monitor token usage\n- ❌ Ignoring latency - LLM calls are slow, design for async\n- ❌ No fallback for LLM failures - handle timeouts and errors\n- ❌ Prompts without version control - track prompt changes\n- ❌ No evaluation suite - measure accuracy over time\n- ❌ Using LLM for deterministic logic - use code for validation, auth, math\n- ❌ Giant monolithic prompts - compose smaller focused prompts\n"
  },
  {
    "path": "skills/maggy/SKILL.md",
    "content": "---\nname: maggy\ndescription: Maggy is a local AI engineering command center. AI-prioritized inbox across issue trackers (GitHub Issues/Asana), one-click TDD execute with iCPG context enrichment, daily competitor intelligence briefing.\nwhen-to-use: \"When you want a persistent dashboard to triage tickets and spawn Claude Code runs against any repo\"\nuser-invocable: true\neffort: medium\n---\n\n# Maggy Skill\n\n**Maggy** is a generic, local AI engineering command center. Install once, point it at your team's issue tracker and codebases, and get:\n\n- **AI-prioritized inbox** — ranks open issues by urgency, OKR alignment, and recency\n- **One-click Execute** — spawns Claude Code locally with iCPG context injected\n- **Competitor intelligence** — daily AI briefing on your competitive landscape\n- **No hardcoding** — works for any team, any stack, any issue tracker\n\n### ⚠️ Execute permission model (important)\n\nExecute currently runs `claude -p --dangerously-skip-permissions` so the TDD\npipeline isn't blocked waiting on approval prompts (subprocess has no terminal).\nThat flag **grants Claude full permission to write/edit files and run shell\ncommands** inside the target codebase, and the prompt it receives includes\ncontent from the issue tracker (which any team member can author).\n\n**Hardening already in place:**\n- `working_dir` is validated against the list of codebase roots in\n  `~/.maggy/config.yaml` — Claude can't be pointed at arbitrary filesystem paths.\n- Only tickets from your configured trackers reach Execute; no public-internet\n  input flows into the prompt.\n\n**Roadmap:** move the unconditional flag behind per-codebase config\n(`auto_approve: true|false`) so privileged execution becomes opt-in.\nUntil then, treat Execute like `git pull && make` on any ticket you push\nthe button for — only run it on repos you own, against tickets from\nauthors you trust.\n\n```\n┌──────────────────────────────────────────────────────────────┐\n│  maggy               ──────────────┐                          │\n│  ├── skills/         ← installed globally → ~/.claude/       │\n│  ├── commands/       ← installed globally → ~/.claude/       │\n│  ├── scripts/icpg/   ← used by Maggy for context enrichment  │\n│  └── maggy/          ← dashboard: run `./install.sh` to use  │\n│      ├── src/                                                │\n│      │   ├── providers/   ← GitHub / Asana / Linear          │\n│      │   ├── services/    ← inbox, competitor, executor      │\n│      │   └── api/         ← FastAPI routes                   │\n│      └── install.sh                                          │\n└──────────────────────────────────────────────────────────────┘\n```\n\n---\n\n## When Maggy Helps\n\n| Scenario                                 | How Maggy helps                               |\n|------------------------------------------|-----------------------------------------------|\n| Morning triage of 50 open issues         | AI ranks them; top items stay top             |\n| Implementing a ticket                    | `Execute` → iCPG-enriched TDD pipeline        |\n| \"What are competitors shipping?\"         | Daily briefing + filterable news feed         |\n| Multiple repos per team                  | Auto-picks right repo based on ticket content |\n| New team onboarding                      | Configure via `/maggy-init`, no code writing  |\n\n---\n\n## Install and Configure\n\n```bash\n# One-time install\ncd $(cat ~/.claude/.bootstrap-dir)/maggy\n./install.sh\n\n# Configure\n# Edit ~/.maggy/config.yaml — see maggy/config.example.yaml for the schema\n\n# Credentials\nexport GITHUB_TOKEN=ghp_...\nexport ANTHROPIC_API_KEY=sk-ant-...\n\n# Run\npython3 -m src.main\n\n# Or from Claude Code:\n#   /maggy-init    # interactive wizard\n#   /maggy         # launch dashboard\n```\n\n---\n\n## Provider Abstraction\n\nMaggy services never see GitHub/Asana directly — they talk to an `IssueTrackerProvider` Protocol. Drop-in swap between:\n\n- `GitHubIssuesProvider` — scans multiple repos, aggregates open issues, maps \"done\" → closed\n- `AsanaProvider` — queries projects, respects workspace scope\n- `LinearProvider` — stub for future\n\nThe same inbox, Execute pipeline, and Competitor features work with any provider.\n\n---\n\n## Execute Pipeline\n\nWhen you click Execute on a ticket:\n\n1. Maggy queries the configured iCPG for relevant symbols, blast radius, and prior intents\n2. Picks the right working directory based on ticket keywords + configured codebases\n3. Spawns `claude -p --dangerously-skip-permissions` in that directory\n4. Runs analyze → write failing tests → implement\n5. Captures output in a session you can follow in the Sessions tab\n\nBecause the spawned Claude Code runs in the target repo, it picks up:\n- That repo's `CLAUDE.md`\n- Your global `~/.claude/CLAUDE.md`\n- All bootstrap skills\n- `.claude/hooks/`, `.mcp.json`\n\nSo Execute gets the full bootstrap experience — not a stripped-down version.\n\n---\n\n## Competitor Intelligence\n\nGeneric — works for any domain:\n\n1. Configure `competitors.categories: [\"fintech\", \"embedded-finance\"]` in `~/.maggy/config.yaml`\n2. Click Discover — Claude identifies 12-18 competitors (market leaders, AI-first challengers, vertical specialists)\n3. Maggy monitors their RSS blogs + Google News daily\n4. Daily briefing is generated once per day (cached), regeneratable on demand\n\n---\n\n## Not Included\n\nMaggy MVP is focused. Not shipped:\n\n- Meeting bot (voice)\n- Slack integration\n- P2P network + session handoff\n- Self-improvement (`/improve-maggy`)\n- Linear provider (stub only)\n\nThese are v2 work.\n\n---\n\n## Files\n\n- `maggy/PLAN.md` — architecture rationale\n- `maggy/README.md` — user docs\n- `maggy/src/providers/base.py` — IssueTrackerProvider Protocol\n- `maggy/src/services/executor.py` — TDD pipeline\n- `maggy/src/services/competitor.py` — discovery + briefing\n- `maggy/src/services/inbox.py` — AI prioritization\n- `commands/maggy.md` — `/maggy` launcher\n- `commands/maggy-init.md` — `/maggy-init` setup wizard\n"
  },
  {
    "path": "skills/medusa/SKILL.md",
    "content": "---\nname: medusa\ndescription: Medusa headless commerce - modules, workflows, API routes, admin UI\nwhen-to-use: When building with Medusa commerce platform\nuser-invocable: false\neffort: medium\n---\n\n# Medusa E-Commerce Skill\n\n\nFor building headless e-commerce with Medusa - open-source, Node.js native, fully customizable.\n\n**Sources:** [Medusa Docs](https://docs.medusajs.com) | [API Reference](https://docs.medusajs.com/api/store) | [GitHub](https://github.com/medusajs/medusa)\n\n---\n\n## Why Medusa\n\n| Feature | Benefit |\n|---------|---------|\n| **Open Source** | Self-host, no vendor lock-in, MIT license |\n| **Node.js Native** | TypeScript, familiar stack, easy to customize |\n| **Headless** | Any frontend (Next.js, Remix, mobile) |\n| **Modular** | Use only what you need, extend anything |\n| **Built-in Admin** | Dashboard included, customizable |\n\n---\n\n## Quick Start\n\n### Prerequisites\n\n```bash\n# Required\nnode --version  # v20+ LTS\ngit --version\n# PostgreSQL running locally or remote\n```\n\n### Create New Project\n\n```bash\n# Scaffold new Medusa application\nnpx create-medusa-app@latest my-store\n\n# This creates:\n# - Medusa backend\n# - PostgreSQL database (auto-configured)\n# - Admin dashboard\n# - Optional: Next.js storefront\n\ncd my-store\nnpm run dev\n```\n\n### Access Points\n\n| URL | Purpose |\n|-----|---------|\n| `http://localhost:9000` | Backend API |\n| `http://localhost:9000/app` | Admin dashboard |\n| `http://localhost:8000` | Storefront (if installed) |\n\n### Create Admin User\n\n```bash\nnpx medusa user -e admin@example.com -p supersecret\n```\n\n---\n\n## Project Structure\n\n```\nmedusa-store/\n├── src/\n│   ├── admin/                    # Admin UI customizations\n│   │   ├── widgets/              # Dashboard widgets\n│   │   └── routes/               # Custom admin pages\n│   ├── api/                      # Custom API routes\n│   │   ├── store/                # Public storefront APIs\n│   │   │   └── custom/\n│   │   │       └── route.ts\n│   │   └── admin/                # Admin APIs\n│   │       └── custom/\n│   │           └── route.ts\n│   ├── jobs/                     # Scheduled tasks\n│   ├── modules/                  # Custom business logic\n│   ├── workflows/                # Multi-step processes\n│   ├── subscribers/              # Event listeners\n│   └── links/                    # Module relationships\n├── .medusa/                      # Auto-generated (don't edit)\n├── medusa-config.ts              # Configuration\n├── package.json\n└── tsconfig.json\n```\n\n---\n\n## Configuration\n\n### medusa-config.ts\n\n```typescript\nimport { defineConfig, loadEnv } from \"@medusajs/framework/utils\";\n\nloadEnv(process.env.NODE_ENV || \"development\", process.cwd());\n\nexport default defineConfig({\n  projectConfig: {\n    databaseUrl: process.env.DATABASE_URL,\n    http: {\n      storeCors: process.env.STORE_CORS || \"http://localhost:8000\",\n      adminCors: process.env.ADMIN_CORS || \"http://localhost:9000\",\n      authCors: process.env.AUTH_CORS || \"http://localhost:9000\",\n    },\n    redisUrl: process.env.REDIS_URL,\n  },\n  admin: {\n    disable: false,\n    backendUrl: process.env.MEDUSA_BACKEND_URL || \"http://localhost:9000\",\n  },\n  modules: [\n    // Add custom modules here\n  ],\n});\n```\n\n### Environment Variables\n\n```bash\n# .env\nDATABASE_URL=postgresql://user:pass@localhost:5432/medusa\nREDIS_URL=redis://localhost:6379\n\n# CORS (comma-separated for multiple origins)\nSTORE_CORS=http://localhost:8000\nADMIN_CORS=http://localhost:9000\n\n# Backend URL\nMEDUSA_BACKEND_URL=http://localhost:9000\n\n# JWT Secrets\nJWT_SECRET=your-super-secret-jwt-key\nCOOKIE_SECRET=your-super-secret-cookie-key\n```\n\n---\n\n## Custom API Routes\n\n### Store API (Public)\n\n```typescript\n// src/api/store/hello/route.ts\nimport type { MedusaRequest, MedusaResponse } from \"@medusajs/framework/http\";\n\nexport async function GET(\n  req: MedusaRequest,\n  res: MedusaResponse\n) {\n  res.json({\n    message: \"Hello from custom store API!\",\n  });\n}\n\n// Accessible at: GET /store/hello\n```\n\n### Admin API (Protected)\n\n```typescript\n// src/api/admin/analytics/route.ts\nimport type { MedusaRequest, MedusaResponse } from \"@medusajs/framework/http\";\nimport { Modules } from \"@medusajs/framework/utils\";\n\nexport async function GET(\n  req: MedusaRequest,\n  res: MedusaResponse\n) {\n  const orderService = req.scope.resolve(Modules.ORDER);\n\n  const orders = await orderService.listOrders({\n    created_at: {\n      $gte: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000), // Last 30 days\n    },\n  });\n\n  const totalRevenue = orders.reduce(\n    (sum, order) => sum + (order.total || 0),\n    0\n  );\n\n  res.json({\n    orderCount: orders.length,\n    totalRevenue,\n  });\n}\n\n// Accessible at: GET /admin/analytics (requires auth)\n```\n\n### Route with Parameters\n\n```typescript\n// src/api/store/products/[id]/reviews/route.ts\nimport type { MedusaRequest, MedusaResponse } from \"@medusajs/framework/http\";\n\nexport async function GET(\n  req: MedusaRequest,\n  res: MedusaResponse\n) {\n  const { id } = req.params;\n\n  // Fetch reviews for product\n  const reviews = await getReviewsForProduct(id);\n\n  res.json({ reviews });\n}\n\nexport async function POST(\n  req: MedusaRequest,\n  res: MedusaResponse\n) {\n  const { id } = req.params;\n  const { rating, comment, customerId } = req.body;\n\n  const review = await createReview({\n    productId: id,\n    rating,\n    comment,\n    customerId,\n  });\n\n  res.status(201).json({ review });\n}\n\n// Accessible at:\n// GET  /store/products/:id/reviews\n// POST /store/products/:id/reviews\n```\n\n### Middleware\n\n```typescript\n// src/api/middlewares.ts\nimport { defineMiddlewares } from \"@medusajs/framework/http\";\nimport { authenticate } from \"@medusajs/framework/http\";\n\nexport default defineMiddlewares({\n  routes: [\n    {\n      matcher: \"/store/protected/*\",\n      middlewares: [authenticate(\"customer\", [\"session\", \"bearer\"])],\n    },\n    {\n      matcher: \"/admin/*\",\n      middlewares: [authenticate(\"user\", [\"session\", \"bearer\"])],\n    },\n  ],\n});\n```\n\n---\n\n## Modules (Custom Business Logic)\n\n### Create Custom Module\n\n```typescript\n// src/modules/reviews/index.ts\nimport { Module } from \"@medusajs/framework/utils\";\nimport ReviewModuleService from \"./service\";\n\nexport const REVIEW_MODULE = \"reviewModuleService\";\n\nexport default Module(REVIEW_MODULE, {\n  service: ReviewModuleService,\n});\n```\n\n```typescript\n// src/modules/reviews/service.ts\nimport { MedusaService } from \"@medusajs/framework/utils\";\n\nclass ReviewModuleService extends MedusaService({}) {\n  async createReview(data: CreateReviewInput) {\n    // Implementation\n  }\n\n  async getProductReviews(productId: string) {\n    // Implementation\n  }\n\n  async getAverageRating(productId: string) {\n    // Implementation\n  }\n}\n\nexport default ReviewModuleService;\n```\n\n### Register Module\n\n```typescript\n// medusa-config.ts\nimport { REVIEW_MODULE } from \"./src/modules/reviews\";\n\nexport default defineConfig({\n  // ...\n  modules: [\n    {\n      resolve: \"./src/modules/reviews\",\n      options: {},\n    },\n  ],\n});\n```\n\n### Use Module in API\n\n```typescript\n// src/api/store/products/[id]/reviews/route.ts\nimport { REVIEW_MODULE } from \"../../../modules/reviews\";\n\nexport async function GET(req: MedusaRequest, res: MedusaResponse) {\n  const { id } = req.params;\n  const reviewService = req.scope.resolve(REVIEW_MODULE);\n\n  const reviews = await reviewService.getProductReviews(id);\n  const averageRating = await reviewService.getAverageRating(id);\n\n  res.json({ reviews, averageRating });\n}\n```\n\n---\n\n## Workflows\n\n### Define Workflow\n\n```typescript\n// src/workflows/create-order-with-notification/index.ts\nimport {\n  createWorkflow,\n  createStep,\n  StepResponse,\n} from \"@medusajs/framework/workflows-sdk\";\nimport { Modules } from \"@medusajs/framework/utils\";\n\nconst createOrderStep = createStep(\n  \"create-order\",\n  async (input: CreateOrderInput, { container }) => {\n    const orderService = container.resolve(Modules.ORDER);\n\n    const order = await orderService.createOrders(input);\n\n    return new StepResponse(order, order.id);\n  },\n  // Compensation (rollback) function\n  async (orderId, { container }) => {\n    const orderService = container.resolve(Modules.ORDER);\n    await orderService.deleteOrders([orderId]);\n  }\n);\n\nconst sendNotificationStep = createStep(\n  \"send-notification\",\n  async (order: Order, { container }) => {\n    const notificationService = container.resolve(\"notificationService\");\n\n    await notificationService.send({\n      to: order.email,\n      template: \"order-confirmation\",\n      data: { order },\n    });\n\n    return new StepResponse({ sent: true });\n  }\n);\n\nexport const createOrderWithNotificationWorkflow = createWorkflow(\n  \"create-order-with-notification\",\n  (input: CreateOrderInput) => {\n    const order = createOrderStep(input);\n    const notification = sendNotificationStep(order);\n\n    return { order, notification };\n  }\n);\n```\n\n### Execute Workflow\n\n```typescript\n// In an API route\nimport { createOrderWithNotificationWorkflow } from \"../../../workflows/create-order-with-notification\";\n\nexport async function POST(req: MedusaRequest, res: MedusaResponse) {\n  const { result } = await createOrderWithNotificationWorkflow(req.scope).run({\n    input: req.body,\n  });\n\n  res.json(result);\n}\n```\n\n---\n\n## Subscribers (Event Listeners)\n\n### Create Subscriber\n\n```typescript\n// src/subscribers/order-placed.ts\nimport type { SubscriberArgs, SubscriberConfig } from \"@medusajs/framework\";\n\nexport default async function orderPlacedHandler({\n  event,\n  container,\n}: SubscriberArgs<{ id: string }>) {\n  const orderId = event.data.id;\n\n  console.log(`Order placed: ${orderId}`);\n\n  // Send notification, update analytics, etc.\n  const notificationService = container.resolve(\"notificationService\");\n  await notificationService.sendOrderConfirmation(orderId);\n}\n\nexport const config: SubscriberConfig = {\n  event: \"order.placed\",\n};\n```\n\n### Common Events\n\n| Event | Trigger |\n|-------|---------|\n| `order.placed` | New order created |\n| `order.updated` | Order modified |\n| `order.canceled` | Order cancelled |\n| `order.completed` | Order fulfilled |\n| `customer.created` | New customer registered |\n| `product.created` | New product added |\n| `product.updated` | Product modified |\n| `inventory.updated` | Stock changed |\n\n---\n\n## Scheduled Jobs\n\n```typescript\n// src/jobs/sync-inventory.ts\nimport type { MedusaContainer } from \"@medusajs/framework\";\n\nexport default async function syncInventoryJob(container: MedusaContainer) {\n  const inventoryService = container.resolve(\"inventoryService\");\n\n  console.log(\"Running inventory sync...\");\n\n  await inventoryService.syncFromExternalSource();\n\n  console.log(\"Inventory sync complete\");\n}\n\nexport const config = {\n  name: \"sync-inventory\",\n  schedule: \"0 */6 * * *\", // Every 6 hours\n};\n```\n\n---\n\n## Admin UI Customization\n\n### Custom Widget\n\n```tsx\n// src/admin/widgets/sales-overview.tsx\nimport { defineWidgetConfig } from \"@medusajs/admin-sdk\";\nimport { Container, Heading, Text } from \"@medusajs/ui\";\n\nconst SalesOverviewWidget = () => {\n  return (\n    <Container>\n      <Heading level=\"h2\">Sales Overview</Heading>\n      <Text>Your custom sales data here...</Text>\n    </Container>\n  );\n};\n\nexport const config = defineWidgetConfig({\n  zone: \"order.list.before\", // Where to show the widget\n});\n\nexport default SalesOverviewWidget;\n```\n\n### Widget Zones\n\n| Zone | Location |\n|------|----------|\n| `order.list.before` | Before order list |\n| `order.details.after` | After order details |\n| `product.list.before` | Before product list |\n| `product.details.after` | After product details |\n| `customer.list.before` | Before customer list |\n\n### Custom Admin Route\n\n```tsx\n// src/admin/routes/analytics/page.tsx\nimport { defineRouteConfig } from \"@medusajs/admin-sdk\";\nimport { Container, Heading } from \"@medusajs/ui\";\nimport { ChartBar } from \"@medusajs/icons\";\n\nconst AnalyticsPage = () => {\n  return (\n    <Container>\n      <Heading level=\"h1\">Analytics Dashboard</Heading>\n      {/* Your analytics charts */}\n    </Container>\n  );\n};\n\nexport const config = defineRouteConfig({\n  label: \"Analytics\",\n  icon: ChartBar,\n});\n\nexport default AnalyticsPage;\n```\n\n---\n\n## Store API (Built-in)\n\n### Products\n\n```typescript\n// Frontend: Fetch products\nconst response = await fetch(\"http://localhost:9000/store/products\");\nconst { products } = await response.json();\n\n// With filters\nconst response = await fetch(\n  \"http://localhost:9000/store/products?\" +\n  new URLSearchParams({\n    category_id: \"cat_123\",\n    limit: \"20\",\n    offset: \"0\",\n  })\n);\n```\n\n### Cart\n\n```typescript\n// Create cart\nconst { cart } = await fetch(\"http://localhost:9000/store/carts\", {\n  method: \"POST\",\n  headers: { \"Content-Type\": \"application/json\" },\n  body: JSON.stringify({\n    region_id: \"reg_123\",\n  }),\n}).then(r => r.json());\n\n// Add item\nawait fetch(`http://localhost:9000/store/carts/${cart.id}/line-items`, {\n  method: \"POST\",\n  headers: { \"Content-Type\": \"application/json\" },\n  body: JSON.stringify({\n    variant_id: \"variant_123\",\n    quantity: 1,\n  }),\n});\n\n// Complete cart (create order)\nconst { order } = await fetch(\n  `http://localhost:9000/store/carts/${cart.id}/complete`,\n  { method: \"POST\" }\n).then(r => r.json());\n```\n\n### Customer Authentication\n\n```typescript\n// Register\nawait fetch(\"http://localhost:9000/store/customers\", {\n  method: \"POST\",\n  headers: { \"Content-Type\": \"application/json\" },\n  body: JSON.stringify({\n    email: \"customer@example.com\",\n    password: \"password123\",\n    first_name: \"John\",\n    last_name: \"Doe\",\n  }),\n});\n\n// Login\nconst { token } = await fetch(\"http://localhost:9000/store/auth/token\", {\n  method: \"POST\",\n  headers: { \"Content-Type\": \"application/json\" },\n  body: JSON.stringify({\n    email: \"customer@example.com\",\n    password: \"password123\",\n  }),\n}).then(r => r.json());\n\n// Authenticated request\nawait fetch(\"http://localhost:9000/store/customers/me\", {\n  headers: {\n    Authorization: `Bearer ${token}`,\n  },\n});\n```\n\n---\n\n## Payment Integration\n\n### Stripe Setup\n\n```bash\nnpm install @medusajs/payment-stripe\n```\n\n```typescript\n// medusa-config.ts\nexport default defineConfig({\n  modules: [\n    {\n      resolve: \"@medusajs/payment-stripe\",\n      options: {\n        apiKey: process.env.STRIPE_API_KEY,\n      },\n    },\n  ],\n});\n```\n\n### In Admin\n\n1. Go to Settings → Regions\n2. Add Stripe as payment provider\n3. Configure for each region\n\n---\n\n## Deployment\n\n### Railway\n\n```bash\n# Install Railway CLI\nnpm install -g @railway/cli\n\n# Login and deploy\nrailway login\nrailway init\nrailway up\n```\n\n### Render\n\n```yaml\n# render.yaml\nservices:\n  - type: web\n    name: medusa-backend\n    runtime: node\n    plan: starter\n    buildCommand: npm install && npm run build\n    startCommand: npm run start\n    envVars:\n      - key: NODE_ENV\n        value: production\n      - key: DATABASE_URL\n        fromDatabase:\n          name: medusa-db\n          property: connectionString\n      - key: JWT_SECRET\n        generateValue: true\n      - key: COOKIE_SECRET\n        generateValue: true\n\ndatabases:\n  - name: medusa-db\n    plan: starter\n```\n\n### Docker\n\n```dockerfile\nFROM node:20-alpine\n\nWORKDIR /app\n\nCOPY package*.json ./\nRUN npm ci --only=production\n\nCOPY . .\nRUN npm run build\n\nEXPOSE 9000\nCMD [\"npm\", \"run\", \"start\"]\n```\n\n---\n\n## CLI Commands\n\n```bash\n# Development\nnpm run dev                    # Start dev server\n\n# Database\nnpx medusa db:migrate          # Run migrations\nnpx medusa db:sync             # Sync schema\n\n# Users\nnpx medusa user -e email -p pass  # Create admin user\n\n# Build\nnpm run build                  # Build for production\nnpm run start                  # Start production server\n```\n\n---\n\n## Checklist\n\n### Setup\n\n- [ ] PostgreSQL database configured\n- [ ] Redis configured (optional but recommended)\n- [ ] Admin user created\n- [ ] CORS origins configured\n- [ ] JWT/Cookie secrets set\n\n### Customization\n\n- [ ] Custom modules for business logic\n- [ ] Custom API routes for frontend\n- [ ] Subscribers for event handling\n- [ ] Workflows for complex operations\n\n### Deployment\n\n- [ ] Environment variables configured\n- [ ] Database migrations run\n- [ ] HTTPS enabled\n- [ ] Admin URL secured\n\n---\n\n## Anti-Patterns\n\n- **Editing .medusa folder** - Auto-generated, will be overwritten\n- **Direct database access** - Use services and modules\n- **Skipping workflows for complex ops** - Workflows provide rollback\n- **Hardcoding URLs** - Use environment variables\n- **Ignoring TypeScript errors** - Framework relies on types\n"
  },
  {
    "path": "skills/mnemos/SKILL.md",
    "content": "---\nname: mnemos\ndescription: Task-scoped memory lifecycle — typed MnemoGraph prevents lossy context compaction by treating facts/decisions/code-refs/handoffs as distinct node types with per-type eviction policies\nwhen-to-use: \"When you need durable working memory across compactions — checkpoint decisions, preserve task handoffs, or audit what was remembered\"\nuser-invocable: false\neffort: high\n---\n\n# Mnemos — Task-Scoped Memory Lifecycle\n\n## What It Does\n\nMnemos prevents lossy context compaction from destroying the structured knowledge you need most. It treats your working memory as a **typed graph** (MnemoGraph) where different types of knowledge have different eviction policies:\n\n- **GoalNodes** and **ConstraintNodes** are NEVER evicted — they survive all compaction\n- **ResultNodes** are compressed (summary kept) before eviction\n- **ContextNodes** are evictable when their activation weight drops\n- **CheckpointNodes** persist to disk for session resume\n\n## Fatigue Model\n\nMnemos monitors 4 dimensions of \"agent fatigue\" — all passively observed from hook data, no manual input needed:\n\n| Dimension | Weight | Signal Source | What It Measures |\n|-----------|--------|--------------|-----------------|\n| Token utilization | 0.40 | Statusline JSON | How full the context window is |\n| Scope scatter | 0.25 | PreToolUse file paths | How many directories the agent is bouncing between |\n| Re-read ratio | 0.20 | PreToolUse Read calls | How often the agent re-reads files it already read (context loss) |\n| Error density | 0.15 | PostToolUse outcomes | What fraction of tool calls are failing (agent struggling) |\n\nFatigue states and actions:\n\n| State | Score | Action |\n|-------|-------|--------|\n| FLOW | 0.0–0.4 | Normal operation |\n| COMPRESS | 0.4–0.6 | Micro-consolidation runs (compress 3 ResultNodes, evict 1 cold ContextNode) |\n| PRE-SLEEP | 0.6–0.75 | Checkpoint written, consolidation runs |\n| REM | 0.75–0.9 | Emergency checkpoint, consider wrapping up |\n| EMERGENCY | 0.9+ | Checkpoint written, hand off immediately |\n\n## How To Use\n\n### Automatic (hooks handle everything):\n1. **Statusline** writes `fatigue.json` on every API call\n2. **PreToolUse** hook reads fatigue before every edit, auto-checkpoints at 0.60+\n3. **PreCompact** hook writes emergency checkpoint, compaction marker, and tells summarizer what to preserve\n4. **Post-Compaction Injection** (PreToolUse, no matcher) detects the compaction marker on the first tool call after compaction and re-injects the full checkpoint into context\n5. **SessionStart** hook loads last checkpoint on new session resume\n\n### Post-Compaction Recovery (Two-Layer Defense):\nWhen Claude Code compacts the context (~83% full), Mnemos uses two layers:\n- **Layer 1**: PreCompact outputs strong preservation instructions with inline checkpoint content for the summarizer\n- **Layer 2**: After compaction, the first tool call triggers `mnemos-post-compact-inject.sh` which detects the `.mnemos/just-compacted` marker and re-injects the full checkpoint. This is the guaranteed path — it doesn't depend on the summarizer.\n\nThe result: after compaction, you'll see a \"CONTEXT RESTORED AFTER COMPACTION\" block with your goal, constraints, what you were working on, and progress. Resume from there.\n\n### Manual CLI:\n```bash\nmnemos init                    # Initialize .mnemos/\nmnemos status                  # Show node counts + fatigue\nmnemos fatigue                 # Detailed fatigue breakdown\nmnemos checkpoint --force      # Write checkpoint now\nmnemos resume                  # Output checkpoint for context\nmnemos consolidate             # Run micro-consolidation\nmnemos nodes --type goal       # List active GoalNodes\nmnemos add goal \"Build auth\"   # Add a GoalNode\nmnemos bridge-icpg             # Import iCPG ReasonNodes\n```\n\n## Agent Instructions\n\nWhen working on a task:\n\n1. **Create a GoalNode** at the start: `mnemos add goal \"what you're trying to achieve\" --task-id session-1`\n2. **Add ConstraintNodes** for invariants: `mnemos add constraint \"API backward compatibility\" --scope src/api/`\n3. **Check fatigue** before long operations: `mnemos fatigue`\n4. **Checkpoint at sub-goal boundaries**: `mnemos checkpoint`\n5. **On session resume**: the SessionStart hook automatically loads your checkpoint\n\n## iCPG Integration\n\nMnemos bridges with iCPG (Intent-Augmented Code Property Graph):\n- `mnemos bridge-icpg` imports active ReasonNodes as GoalNodes\n- Postconditions/invariants become ConstraintNodes\n- Checkpoint includes iCPG state (active intent, unresolved drift)\n\n## Storage\n\nEverything lives in `.mnemos/` (gitignored):\n- `mnemo.db` — SQLite MnemoGraph\n- `fatigue.json` — Live token metrics (updated per API call by statusline)\n- `signals.jsonl` — Behavioral signal log (appended by PreToolUse + PostToolUse hooks)\n- `checkpoint-latest.json` — Most recent checkpoint\n- `checkpoints/` — Archived checkpoints\n"
  },
  {
    "path": "skills/ms-teams-apps/SKILL.md",
    "content": "---\nname: ms-teams-apps\ndescription: Microsoft Teams bots and AI agents - Claude/OpenAI, Adaptive Cards, Graph API\nwhen-to-use: When building Microsoft Teams bots, tabs, or message extensions\nuser-invocable: false\neffort: medium\n---\n\n# Microsoft Teams Apps Skill\n\n\n**Purpose:** Build AI-powered agents and apps for Microsoft Teams. Create conversational bots, message extensions, and intelligent assistants that integrate with LLMs like OpenAI and Claude.\n\n---\n\n## Architecture Overview\n\n```\n┌─────────────────────────────────────────────────────────────────┐\n│  TEAMS APP TYPES                                                 │\n│  ─────────────────────────────────────────────────────────────  │\n│                                                                 │\n│  1. AI AGENTS (Bots)                                            │\n│     Conversational apps powered by LLMs                         │\n│     Handle messages, commands, and actions                      │\n│                                                                 │\n│  2. MESSAGE EXTENSIONS                                          │\n│     Search external systems, insert cards into messages         │\n│     Action commands with modal dialogs                          │\n│                                                                 │\n│  3. TABS                                                        │\n│     Embedded web applications inside Teams                      │\n│     Personal, channel, or meeting tabs                          │\n│                                                                 │\n│  4. WEBHOOKS & CONNECTORS                                       │\n│     Incoming: Post messages to channels                         │\n│     Outgoing: Respond to @mentions                              │\n├─────────────────────────────────────────────────────────────────┤\n│  SDK LANDSCAPE (2025)                                           │\n│  ─────────────────────────────────────────────────────────────  │\n│  Teams SDK v2: Primary SDK for Teams-only apps                  │\n│  M365 Agents SDK: Multi-channel (Teams, Outlook, Copilot)       │\n│  Teams Toolkit: VS Code extension for development               │\n└─────────────────────────────────────────────────────────────────┘\n```\n\n---\n\n## Quick Start\n\n### Install Teams CLI\n\n```bash\nnpm install -g @microsoft/teams.cli\n```\n\n### Create New Project\n\n```bash\n# TypeScript (Recommended)\nnpx @microsoft/teams.cli new typescript my-agent --template echo\n\n# Python\nnpx @microsoft/teams.cli new python my-agent --template echo\n\n# C#\nnpx @microsoft/teams.cli new csharp my-agent --template echo\n```\n\n### Project Structure\n\n```\nmy-agent/\n├── src/\n│   ├── index.ts              # Entry point\n│   ├── app.ts                # App configuration\n│   └── handlers/\n│       ├── message.ts        # Message handlers\n│       └── commands.ts       # Command handlers\n├── appPackage/\n│   ├── manifest.json         # App manifest\n│   ├── color.png             # App icon (192x192)\n│   └── outline.png           # Outline icon (32x32)\n├── .env                      # Environment variables\n├── teamsapp.yml              # Teams Toolkit config\n└── package.json\n```\n\n---\n\n## App Manifest\n\n### Basic Manifest Structure\n\n```json\n{\n  \"$schema\": \"https://developer.microsoft.com/json-schemas/teams/v1.17/MicrosoftTeams.schema.json\",\n  \"manifestVersion\": \"1.17\",\n  \"version\": \"1.0.0\",\n  \"id\": \"{{APP_ID}}\",\n  \"developer\": {\n    \"name\": \"Your Company\",\n    \"websiteUrl\": \"https://yourcompany.com\",\n    \"privacyUrl\": \"https://yourcompany.com/privacy\",\n    \"termsOfUseUrl\": \"https://yourcompany.com/terms\"\n  },\n  \"name\": {\n    \"short\": \"AI Assistant\",\n    \"full\": \"AI Assistant for Teams\"\n  },\n  \"description\": {\n    \"short\": \"Your AI-powered assistant\",\n    \"full\": \"An intelligent assistant that helps you with tasks using AI.\"\n  },\n  \"icons\": {\n    \"color\": \"color.png\",\n    \"outline\": \"outline.png\"\n  },\n  \"accentColor\": \"#5558AF\",\n  \"bots\": [\n    {\n      \"botId\": \"{{BOT_ID}}\",\n      \"scopes\": [\"personal\", \"team\", \"groupChat\"],\n      \"supportsFiles\": false,\n      \"isNotificationOnly\": false,\n      \"commandLists\": [\n        {\n          \"scopes\": [\"personal\", \"team\", \"groupChat\"],\n          \"commands\": [\n            {\n              \"title\": \"help\",\n              \"description\": \"Show available commands\"\n            },\n            {\n              \"title\": \"ask\",\n              \"description\": \"Ask the AI a question\"\n            }\n          ]\n        }\n      ]\n    }\n  ],\n  \"permissions\": [\"identity\", \"messageTeamMembers\"],\n  \"validDomains\": [\"*.azurewebsites.net\"]\n}\n```\n\n### Manifest with Message Extensions\n\n```json\n{\n  \"composeExtensions\": [\n    {\n      \"botId\": \"{{BOT_ID}}\",\n      \"commands\": [\n        {\n          \"id\": \"searchQuery\",\n          \"type\": \"query\",\n          \"title\": \"Search\",\n          \"description\": \"Search for information\",\n          \"initialRun\": true,\n          \"parameters\": [\n            {\n              \"name\": \"query\",\n              \"title\": \"Search query\",\n              \"description\": \"Enter your search terms\",\n              \"inputType\": \"text\"\n            }\n          ]\n        },\n        {\n          \"id\": \"createTask\",\n          \"type\": \"action\",\n          \"title\": \"Create Task\",\n          \"description\": \"Create a new task\",\n          \"fetchTask\": true,\n          \"context\": [\"compose\", \"commandBox\", \"message\"]\n        }\n      ]\n    }\n  ]\n}\n```\n\n---\n\n## AI Agent Development\n\n### Basic Bot with Teams SDK v2\n\n```typescript\n// src/app.ts\nimport { App, HttpPlugin, DevtoolsPlugin } from '@microsoft/teams.ai';\nimport { OpenAIModel, ActionPlanner, PromptManager } from '@microsoft/teams.ai';\n\n// Configure the AI model\nconst model = new OpenAIModel({\n  azureApiKey: process.env.AZURE_OPENAI_API_KEY!,\n  azureDefaultDeployment: process.env.AZURE_OPENAI_DEPLOYMENT!,\n  azureEndpoint: process.env.AZURE_OPENAI_ENDPOINT!,\n  // Or use OpenAI directly:\n  // apiKey: process.env.OPENAI_API_KEY!,\n  // defaultModel: 'gpt-4'\n});\n\n// Configure prompts\nconst prompts = new PromptManager({\n  promptsFolder: './src/prompts'\n});\n\n// Create action planner\nconst planner = new ActionPlanner({\n  model,\n  prompts,\n  defaultPrompt: 'chat'\n});\n\n// Create the app\nconst app = new App({\n  plugins: [\n    new HttpPlugin(),\n    new DevtoolsPlugin()\n  ],\n  ai: {\n    planner\n  }\n});\n\n// Handle messages\napp.on('message', async (context, state) => {\n  // AI automatically handles the conversation\n  // The planner uses the 'chat' prompt to generate responses\n});\n\n// Handle specific commands\napp.message('/help', async (context, state) => {\n  await context.sendActivity({\n    type: 'message',\n    text: 'Available commands:\\n- /help - Show this message\\n- /ask [question] - Ask me anything'\n  });\n});\n\n// Start the app\napp.start();\n```\n\n### Prompt Configuration\n\n```yaml\n# src/prompts/chat/config.json\n{\n  \"schema\": 1.1,\n  \"description\": \"AI Assistant for Teams\",\n  \"type\": \"completion\",\n  \"completion\": {\n    \"model\": \"gpt-4\",\n    \"max_tokens\": 1000,\n    \"temperature\": 0.7,\n    \"top_p\": 1\n  }\n}\n```\n\n```text\n# src/prompts/chat/skprompt.txt\nYou are an AI assistant for Microsoft Teams. You help users with their questions and tasks.\n\nCurrent conversation:\n{{$history}}\n\nUser: {{$input}}\nAssistant:\n```\n\n---\n\n## Integrating Claude/Anthropic\n\n### Claude-Powered Teams Bot\n\n```typescript\n// src/claude-bot.ts\nimport { App, HttpPlugin } from '@microsoft/teams.ai';\nimport Anthropic from '@anthropic-ai/sdk';\n\nconst anthropic = new Anthropic({\n  apiKey: process.env.ANTHROPIC_API_KEY!\n});\n\nconst app = new App({\n  plugins: [new HttpPlugin()]\n});\n\n// Conversation history store\nconst conversations = new Map<string, Anthropic.MessageParam[]>();\n\napp.on('message', async (context, state) => {\n  const userId = context.activity.from.id;\n  const userMessage = context.activity.text;\n\n  // Get or initialize conversation history\n  if (!conversations.has(userId)) {\n    conversations.set(userId, []);\n  }\n  const history = conversations.get(userId)!;\n\n  // Add user message to history\n  history.push({ role: 'user', content: userMessage });\n\n  // Show typing indicator\n  await context.sendActivity({ type: 'typing' });\n\n  try {\n    // Call Claude API\n    const response = await anthropic.messages.create({\n      model: 'claude-sonnet-4-20250514',\n      max_tokens: 1024,\n      system: `You are an AI assistant integrated into Microsoft Teams.\n        Help users with their questions and tasks.\n        Be concise and helpful. Use markdown formatting when appropriate.\n        Current user: ${context.activity.from.name}`,\n      messages: history\n    });\n\n    const assistantMessage = response.content[0].type === 'text'\n      ? response.content[0].text\n      : '';\n\n    // Add assistant response to history\n    history.push({ role: 'assistant', content: assistantMessage });\n\n    // Keep history manageable (last 20 messages)\n    if (history.length > 20) {\n      history.splice(0, history.length - 20);\n    }\n\n    // Send response\n    await context.sendActivity({\n      type: 'message',\n      text: assistantMessage\n    });\n\n  } catch (error) {\n    console.error('Claude API error:', error);\n    await context.sendActivity({\n      type: 'message',\n      text: 'Sorry, I encountered an error processing your request.'\n    });\n  }\n});\n\n// Clear conversation command\napp.message('/clear', async (context, state) => {\n  const userId = context.activity.from.id;\n  conversations.delete(userId);\n  await context.sendActivity('Conversation cleared. Starting fresh!');\n});\n\napp.start();\n```\n\n### Claude with Tools/Function Calling\n\n```typescript\n// src/claude-agent.ts\nimport Anthropic from '@anthropic-ai/sdk';\n\nconst anthropic = new Anthropic();\n\n// Define tools the agent can use\nconst tools: Anthropic.Tool[] = [\n  {\n    name: 'search_knowledge_base',\n    description: 'Search the company knowledge base for information',\n    input_schema: {\n      type: 'object' as const,\n      properties: {\n        query: {\n          type: 'string',\n          description: 'The search query'\n        }\n      },\n      required: ['query']\n    }\n  },\n  {\n    name: 'create_task',\n    description: 'Create a new task in the task management system',\n    input_schema: {\n      type: 'object' as const,\n      properties: {\n        title: { type: 'string', description: 'Task title' },\n        description: { type: 'string', description: 'Task description' },\n        assignee: { type: 'string', description: 'Person to assign the task to' },\n        due_date: { type: 'string', description: 'Due date in YYYY-MM-DD format' }\n      },\n      required: ['title']\n    }\n  },\n  {\n    name: 'get_calendar',\n    description: 'Get calendar events for a user',\n    input_schema: {\n      type: 'object' as const,\n      properties: {\n        user: { type: 'string', description: 'User email or name' },\n        days: { type: 'number', description: 'Number of days to look ahead' }\n      },\n      required: ['user']\n    }\n  }\n];\n\n// Tool implementations\nasync function executeTools(toolName: string, toolInput: any): Promise<string> {\n  switch (toolName) {\n    case 'search_knowledge_base':\n      // Implement your search logic\n      return `Found 3 results for \"${toolInput.query}\":\\n1. Document A\\n2. Document B\\n3. Document C`;\n\n    case 'create_task':\n      // Implement task creation (e.g., call Microsoft Graph API)\n      return `Task created: \"${toolInput.title}\"`;\n\n    case 'get_calendar':\n      // Implement calendar lookup\n      return `Calendar for ${toolInput.user}: 2 meetings today`;\n\n    default:\n      return 'Unknown tool';\n  }\n}\n\n// Agent loop with tool use\nasync function runAgent(userMessage: string): Promise<string> {\n  let messages: Anthropic.MessageParam[] = [\n    { role: 'user', content: userMessage }\n  ];\n\n  while (true) {\n    const response = await anthropic.messages.create({\n      model: 'claude-sonnet-4-20250514',\n      max_tokens: 1024,\n      system: 'You are a helpful Teams assistant. Use tools when needed to help users.',\n      tools,\n      messages\n    });\n\n    // Check if we need to use tools\n    if (response.stop_reason === 'tool_use') {\n      const toolResults: Anthropic.MessageParam[] = [];\n\n      for (const content of response.content) {\n        if (content.type === 'tool_use') {\n          const result = await executeTools(content.name, content.input);\n          toolResults.push({\n            role: 'user',\n            content: [{\n              type: 'tool_result',\n              tool_use_id: content.id,\n              content: result\n            }]\n          });\n        }\n      }\n\n      messages.push({ role: 'assistant', content: response.content });\n      messages.push(...toolResults);\n      continue;\n    }\n\n    // Return final text response\n    const textContent = response.content.find(c => c.type === 'text');\n    return textContent?.text || 'No response';\n  }\n}\n```\n\n---\n\n## Adaptive Cards\n\n### Basic Adaptive Card\n\n```typescript\n// src/cards/welcome-card.ts\nimport { CardFactory } from 'botbuilder';\n\nexport function createWelcomeCard(userName: string) {\n  return CardFactory.adaptiveCard({\n    type: 'AdaptiveCard',\n    $schema: 'http://adaptivecards.io/schemas/adaptive-card.json',\n    version: '1.5',\n    body: [\n      {\n        type: 'TextBlock',\n        text: `Welcome, ${userName}!`,\n        size: 'Large',\n        weight: 'Bolder'\n      },\n      {\n        type: 'TextBlock',\n        text: 'I\\'m your AI assistant. How can I help you today?',\n        wrap: true\n      },\n      {\n        type: 'ActionSet',\n        actions: [\n          {\n            type: 'Action.Submit',\n            title: 'Get Started',\n            data: { action: 'getStarted' }\n          },\n          {\n            type: 'Action.Submit',\n            title: 'View Help',\n            data: { action: 'help' }\n          }\n        ]\n      }\n    ]\n  });\n}\n```\n\n### AI Response Card with Actions\n\n```typescript\n// src/cards/ai-response-card.ts\nexport function createAIResponseCard(\n  question: string,\n  answer: string,\n  sources?: string[]\n) {\n  return {\n    type: 'AdaptiveCard',\n    $schema: 'http://adaptivecards.io/schemas/adaptive-card.json',\n    version: '1.5',\n    body: [\n      {\n        type: 'Container',\n        style: 'emphasis',\n        items: [\n          {\n            type: 'TextBlock',\n            text: 'Your Question',\n            size: 'Small',\n            weight: 'Bolder'\n          },\n          {\n            type: 'TextBlock',\n            text: question,\n            wrap: true\n          }\n        ]\n      },\n      {\n        type: 'Container',\n        items: [\n          {\n            type: 'TextBlock',\n            text: 'AI Response',\n            size: 'Small',\n            weight: 'Bolder'\n          },\n          {\n            type: 'TextBlock',\n            text: answer,\n            wrap: true\n          }\n        ]\n      },\n      ...(sources && sources.length > 0 ? [{\n        type: 'Container',\n        items: [\n          {\n            type: 'TextBlock',\n            text: 'Sources',\n            size: 'Small',\n            weight: 'Bolder'\n          },\n          ...sources.map(source => ({\n            type: 'TextBlock',\n            text: `• ${source}`,\n            size: 'Small'\n          }))\n        ]\n      }] : [])\n    ],\n    actions: [\n      {\n        type: 'Action.Submit',\n        title: '👍 Helpful',\n        data: { action: 'feedback', value: 'positive' }\n      },\n      {\n        type: 'Action.Submit',\n        title: '👎 Not Helpful',\n        data: { action: 'feedback', value: 'negative' }\n      },\n      {\n        type: 'Action.Submit',\n        title: 'Ask Follow-up',\n        data: { action: 'followUp' }\n      }\n    ]\n  };\n}\n```\n\n### Form Card for User Input\n\n```typescript\n// src/cards/task-form-card.ts\nexport function createTaskFormCard() {\n  return {\n    type: 'AdaptiveCard',\n    $schema: 'http://adaptivecards.io/schemas/adaptive-card.json',\n    version: '1.5',\n    body: [\n      {\n        type: 'TextBlock',\n        text: 'Create New Task',\n        size: 'Large',\n        weight: 'Bolder'\n      },\n      {\n        type: 'Input.Text',\n        id: 'taskTitle',\n        label: 'Task Title',\n        isRequired: true,\n        placeholder: 'Enter task title'\n      },\n      {\n        type: 'Input.Text',\n        id: 'taskDescription',\n        label: 'Description',\n        isMultiline: true,\n        placeholder: 'Enter task description'\n      },\n      {\n        type: 'Input.ChoiceSet',\n        id: 'priority',\n        label: 'Priority',\n        choices: [\n          { title: 'High', value: 'high' },\n          { title: 'Medium', value: 'medium' },\n          { title: 'Low', value: 'low' }\n        ],\n        value: 'medium'\n      },\n      {\n        type: 'Input.Date',\n        id: 'dueDate',\n        label: 'Due Date'\n      }\n    ],\n    actions: [\n      {\n        type: 'Action.Submit',\n        title: 'Create Task',\n        data: { action: 'createTask' }\n      },\n      {\n        type: 'Action.Submit',\n        title: 'Cancel',\n        data: { action: 'cancel' }\n      }\n    ]\n  };\n}\n```\n\n---\n\n## Microsoft Graph Integration\n\n### Setup Graph Client\n\n```typescript\n// src/graph/client.ts\nimport { Client } from '@microsoft/microsoft-graph-client';\nimport { TokenCredentialAuthenticationProvider } from '@microsoft/microsoft-graph-client/authProviders/azureTokenCredentials';\nimport { ClientSecretCredential } from '@azure/identity';\n\nexport function createGraphClient() {\n  const credential = new ClientSecretCredential(\n    process.env.AZURE_TENANT_ID!,\n    process.env.AZURE_CLIENT_ID!,\n    process.env.AZURE_CLIENT_SECRET!\n  );\n\n  const authProvider = new TokenCredentialAuthenticationProvider(credential, {\n    scopes: ['https://graph.microsoft.com/.default']\n  });\n\n  return Client.initWithMiddleware({ authProvider });\n}\n```\n\n### Common Graph Operations\n\n```typescript\n// src/graph/operations.ts\nimport { Client } from '@microsoft/microsoft-graph-client';\n\nexport class GraphOperations {\n  constructor(private client: Client) {}\n\n  // Get user profile\n  async getUserProfile(userId: string) {\n    return this.client.api(`/users/${userId}`).get();\n  }\n\n  // Get user's calendar events\n  async getCalendarEvents(userId: string, days: number = 7) {\n    const startDate = new Date().toISOString();\n    const endDate = new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString();\n\n    return this.client\n      .api(`/users/${userId}/calendarView`)\n      .query({\n        startDateTime: startDate,\n        endDateTime: endDate\n      })\n      .select('subject,start,end,location')\n      .orderby('start/dateTime')\n      .get();\n  }\n\n  // Send email\n  async sendEmail(\n    fromUserId: string,\n    to: string,\n    subject: string,\n    body: string\n  ) {\n    return this.client.api(`/users/${fromUserId}/sendMail`).post({\n      message: {\n        subject,\n        body: { contentType: 'HTML', content: body },\n        toRecipients: [{ emailAddress: { address: to } }]\n      }\n    });\n  }\n\n  // Create Teams meeting\n  async createMeeting(\n    userId: string,\n    subject: string,\n    startTime: string,\n    endTime: string,\n    attendees: string[]\n  ) {\n    return this.client.api(`/users/${userId}/onlineMeetings`).post({\n      subject,\n      startDateTime: startTime,\n      endDateTime: endTime,\n      participants: {\n        attendees: attendees.map(email => ({\n          upn: email,\n          role: 'attendee'\n        }))\n      }\n    });\n  }\n\n  // Post message to channel\n  async postToChannel(teamId: string, channelId: string, message: string) {\n    return this.client\n      .api(`/teams/${teamId}/channels/${channelId}/messages`)\n      .post({\n        body: { content: message }\n      });\n  }\n}\n```\n\n---\n\n## Authentication\n\n### SSO with Teams SDK\n\n```typescript\n// src/auth.ts\nimport { App } from '@microsoft/teams.ai';\n\nconst app = new App({\n  // ... other config\n});\n\napp.on('message', async ({ userGraph, isSignedIn, send, signin }) => {\n  // Check if user is signed in\n  if (!isSignedIn) {\n    // Initiate sign-in flow\n    await signin();\n    return;\n  }\n\n  // User is signed in, access Graph API\n  const me = await userGraph.call({\n    method: 'GET',\n    path: '/me'\n  });\n\n  await send(`Hello, ${me.displayName}!`);\n});\n```\n\n### Manual OAuth Flow\n\n```typescript\n// src/auth/oauth.ts\nimport { OAuthPrompt, OAuthPromptSettings } from 'botbuilder-dialogs';\n\nconst oauthSettings: OAuthPromptSettings = {\n  connectionName: process.env.OAUTH_CONNECTION_NAME!,\n  text: 'Please sign in to continue',\n  title: 'Sign In',\n  timeout: 300000 // 5 minutes\n};\n\n// In your dialog\nasync function handleAuth(context, state) {\n  const tokenResponse = await context.adapter.getUserToken(\n    context,\n    oauthSettings.connectionName\n  );\n\n  if (!tokenResponse?.token) {\n    // No token, show sign-in card\n    await context.sendActivity({\n      attachments: [\n        CardFactory.oauthCard(\n          oauthSettings.connectionName,\n          oauthSettings.title,\n          oauthSettings.text\n        )\n      ]\n    });\n    return null;\n  }\n\n  return tokenResponse.token;\n}\n```\n\n---\n\n## RAG (Retrieval-Augmented Generation)\n\n### Vector Search with Azure AI Search\n\n```typescript\n// src/rag/azure-search.ts\nimport { SearchClient, AzureKeyCredential } from '@azure/search-documents';\n\nconst searchClient = new SearchClient(\n  process.env.AZURE_SEARCH_ENDPOINT!,\n  process.env.AZURE_SEARCH_INDEX!,\n  new AzureKeyCredential(process.env.AZURE_SEARCH_KEY!)\n);\n\nexport async function searchKnowledgeBase(\n  query: string,\n  topK: number = 5\n): Promise<string[]> {\n  const results = await searchClient.search(query, {\n    top: topK,\n    select: ['content', 'title', 'source'],\n    queryType: 'semantic',\n    semanticConfiguration: 'default'\n  });\n\n  const documents: string[] = [];\n  for await (const result of results.results) {\n    documents.push(`${result.document.title}: ${result.document.content}`);\n  }\n\n  return documents;\n}\n```\n\n### RAG-Enhanced Claude Response\n\n```typescript\n// src/rag/claude-rag.ts\nimport Anthropic from '@anthropic-ai/sdk';\nimport { searchKnowledgeBase } from './azure-search';\n\nconst anthropic = new Anthropic();\n\nexport async function getRAGResponse(userQuery: string): Promise<string> {\n  // 1. Search knowledge base\n  const relevantDocs = await searchKnowledgeBase(userQuery);\n\n  // 2. Build context\n  const context = relevantDocs.join('\\n\\n---\\n\\n');\n\n  // 3. Generate response with context\n  const response = await anthropic.messages.create({\n    model: 'claude-sonnet-4-20250514',\n    max_tokens: 1024,\n    system: `You are a helpful assistant for Teams. Answer questions based on the provided context.\nIf the context doesn't contain relevant information, say so and provide a general response.\nAlways cite your sources when using information from the context.`,\n    messages: [\n      {\n        role: 'user',\n        content: `Context:\\n${context}\\n\\nQuestion: ${userQuery}`\n      }\n    ]\n  });\n\n  return response.content[0].type === 'text' ? response.content[0].text : '';\n}\n```\n\n---\n\n## Deployment\n\n### Azure Bot Service Setup\n\n```bash\n# Create resource group\naz group create --name rg-teams-bot --location eastus\n\n# Create App Service plan\naz appservice plan create \\\n  --name asp-teams-bot \\\n  --resource-group rg-teams-bot \\\n  --sku B1 \\\n  --is-linux\n\n# Create Web App\naz webapp create \\\n  --name my-teams-bot \\\n  --resource-group rg-teams-bot \\\n  --plan asp-teams-bot \\\n  --runtime \"NODE:18-lts\"\n\n# Create Bot Channels Registration\naz bot create \\\n  --resource-group rg-teams-bot \\\n  --name my-teams-bot \\\n  --kind registration \\\n  --endpoint https://my-teams-bot.azurewebsites.net/api/messages \\\n  --sku F0\n\n# Enable Teams channel\naz bot msteams create \\\n  --name my-teams-bot \\\n  --resource-group rg-teams-bot\n```\n\n### Environment Variables\n\n```bash\n# .env\n# Azure Bot\nBOT_ID=your-bot-id\nBOT_PASSWORD=your-bot-password\nBOT_TENANT_ID=your-tenant-id\n\n# Azure OpenAI\nAZURE_OPENAI_API_KEY=your-key\nAZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com\nAZURE_OPENAI_DEPLOYMENT=gpt-4\n\n# Or OpenAI\nOPENAI_API_KEY=sk-xxx\n\n# Or Anthropic\nANTHROPIC_API_KEY=sk-ant-xxx\n\n# Microsoft Graph\nAZURE_CLIENT_ID=your-client-id\nAZURE_CLIENT_SECRET=your-client-secret\nAZURE_TENANT_ID=your-tenant-id\n\n# Azure AI Search (for RAG)\nAZURE_SEARCH_ENDPOINT=https://your-search.search.windows.net\nAZURE_SEARCH_KEY=your-key\nAZURE_SEARCH_INDEX=knowledge-base\n```\n\n### Docker Deployment\n\n```dockerfile\n# Dockerfile\nFROM node:18-alpine\n\nWORKDIR /app\n\nCOPY package*.json ./\nRUN npm ci --only=production\n\nCOPY . .\nRUN npm run build\n\nEXPOSE 3978\n\nCMD [\"node\", \"dist/index.js\"]\n```\n\n```yaml\n# docker-compose.yml\nversion: '3.8'\n\nservices:\n  teams-bot:\n    build: .\n    ports:\n      - \"3978:3978\"\n    environment:\n      - BOT_ID=${BOT_ID}\n      - BOT_PASSWORD=${BOT_PASSWORD}\n      - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}\n    restart: unless-stopped\n```\n\n### Teams Toolkit Deployment\n\n```bash\n# Login to Azure\nnpx teamsfx account login azure\n\n# Provision resources\nnpx teamsfx provision --env dev\n\n# Deploy\nnpx teamsfx deploy --env dev\n\n# Publish to Teams\nnpx teamsfx publish --env dev\n```\n\n---\n\n## Testing\n\n### Local Testing with ngrok\n\n```bash\n# Start ngrok tunnel\nngrok http 3978\n\n# Update manifest with ngrok URL\n# Bot endpoint: https://xxxx.ngrok.io/api/messages\n```\n\n### Teams Toolkit Local Debug\n\n```bash\n# Start local debugging (opens Teams with your app)\nnpx teamsfx preview --local\n```\n\n### Unit Testing\n\n```typescript\n// tests/bot.test.ts\nimport { TestAdapter, TurnContext } from 'botbuilder';\nimport { createWelcomeCard } from '../src/cards/welcome-card';\n\ndescribe('Bot Tests', () => {\n  let adapter: TestAdapter;\n\n  beforeEach(() => {\n    adapter = new TestAdapter();\n  });\n\n  test('should respond to hello', async () => {\n    await adapter\n      .send('hello')\n      .assertReply((activity) => {\n        expect(activity.text).toContain('Hello');\n      });\n  });\n\n  test('should create welcome card', () => {\n    const card = createWelcomeCard('John');\n    expect(card.content.body[0].text).toContain('John');\n  });\n});\n```\n\n---\n\n## Best Practices\n\n### Conversation Design\n\n```\n┌─────────────────────────────────────────────────────────────────┐\n│  CONVERSATION UX GUIDELINES                                     │\n│  ─────────────────────────────────────────────────────────────  │\n│                                                                 │\n│  1. GREET INTELLIGENTLY                                         │\n│     - Welcome new users with onboarding card                    │\n│     - Return users get quick access to recent actions           │\n│                                                                 │\n│  2. HANDLE ERRORS GRACEFULLY                                    │\n│     - Never show stack traces to users                          │\n│     - Provide clear recovery options                            │\n│     - Log errors for debugging                                  │\n│                                                                 │\n│  3. USE CARDS FOR RICH CONTENT                                  │\n│     - Adaptive Cards for forms and structured data              │\n│     - Hero Cards for simple actions                             │\n│     - Keep cards concise and actionable                         │\n│                                                                 │\n│  4. TYPING INDICATORS                                           │\n│     - Show typing for long operations                           │\n│     - Provide progress updates for very long tasks              │\n│                                                                 │\n│  5. CONTEXT AWARENESS                                           │\n│     - Remember conversation history                             │\n│     - Personalize based on user preferences                     │\n│     - Respect team/channel context                              │\n└─────────────────────────────────────────────────────────────────┘\n```\n\n### Security Checklist\n\n- [ ] Validate all incoming messages\n- [ ] Use App-Only auth for Graph API when possible\n- [ ] Never log sensitive user data\n- [ ] Implement rate limiting\n- [ ] Use managed identity in Azure\n- [ ] Rotate secrets regularly\n- [ ] Enable audit logging\n\n### Performance Tips\n\n| Tip | Description |\n|-----|-------------|\n| Cache Graph tokens | Token refresh is expensive |\n| Stream long responses | Use typing indicator + chunked responses |\n| Index knowledge base | Pre-embed documents for RAG |\n| Use connection pooling | Reuse HTTP connections |\n| Compress payloads | Gzip large card responses |\n\n---\n\n## Project Templates\n\n### AI Assistant Template\n\n```typescript\n// Complete AI assistant with Claude\nimport { App, HttpPlugin } from '@microsoft/teams.ai';\nimport Anthropic from '@anthropic-ai/sdk';\nimport { createWelcomeCard } from './cards/welcome-card';\nimport { createAIResponseCard } from './cards/ai-response-card';\n\nconst anthropic = new Anthropic();\nconst app = new App({ plugins: [new HttpPlugin()] });\nconst conversations = new Map<string, Anthropic.MessageParam[]>();\n\n// Welcome new users\napp.conversationUpdate('membersAdded', async (context) => {\n  for (const member of context.activity.membersAdded || []) {\n    if (member.id !== context.activity.recipient.id) {\n      await context.sendActivity({\n        attachments: [createWelcomeCard(member.name || 'User')]\n      });\n    }\n  }\n});\n\n// Handle messages\napp.on('message', async (context) => {\n  const userId = context.activity.from.id;\n  const userMessage = context.activity.text;\n\n  // Initialize or get conversation\n  if (!conversations.has(userId)) {\n    conversations.set(userId, []);\n  }\n  const history = conversations.get(userId)!;\n  history.push({ role: 'user', content: userMessage });\n\n  // Show typing\n  await context.sendActivity({ type: 'typing' });\n\n  // Get AI response\n  const response = await anthropic.messages.create({\n    model: 'claude-sonnet-4-20250514',\n    max_tokens: 1024,\n    system: 'You are a helpful Teams assistant.',\n    messages: history\n  });\n\n  const answer = response.content[0].type === 'text'\n    ? response.content[0].text\n    : '';\n\n  history.push({ role: 'assistant', content: answer });\n\n  // Send rich card response\n  await context.sendActivity({\n    attachments: [{\n      contentType: 'application/vnd.microsoft.card.adaptive',\n      content: createAIResponseCard(userMessage, answer)\n    }]\n  });\n});\n\n// Handle card actions\napp.on('adaptiveCard/action', async (context) => {\n  const action = context.activity.value?.action;\n\n  switch (action) {\n    case 'feedback':\n      // Log feedback\n      console.log('Feedback:', context.activity.value);\n      await context.sendActivity('Thanks for your feedback!');\n      break;\n    case 'followUp':\n      await context.sendActivity('What would you like to know more about?');\n      break;\n  }\n});\n\napp.start();\n```\n\n---\n\n## Troubleshooting\n\n| Issue | Cause | Fix |\n|-------|-------|-----|\n| Bot not responding | Endpoint unreachable | Check ngrok/Azure URL in manifest |\n| Auth failures | Token expired/invalid | Refresh OAuth connection |\n| Cards not rendering | Invalid schema | Validate at adaptivecards.io/designer |\n| Graph 403 errors | Missing permissions | Check app registration permissions |\n| Slow responses | API latency | Add typing indicator, consider streaming |\n\n---\n\n## Resources\n\n- [Teams SDK Documentation](https://microsoft.github.io/teams-sdk/)\n- [Teams Platform Docs](https://learn.microsoft.com/en-us/microsoftteams/platform/)\n- [Adaptive Cards Designer](https://adaptivecards.io/designer/)\n- [Microsoft Graph Explorer](https://developer.microsoft.com/en-us/graph/graph-explorer)\n- [Teams Toolkit](https://learn.microsoft.com/en-us/microsoftteams/platform/toolkit/teams-toolkit-fundamentals)\n- [Bot Framework Emulator](https://github.com/Microsoft/BotFramework-Emulator)\n"
  },
  {
    "path": "skills/nodejs-backend/SKILL.md",
    "content": "---\nname: nodejs-backend\ndescription: Node.js backend patterns with Express/Fastify, repositories\nwhen-to-use: When working on Node.js backend code - API routes, middleware, server setup\nuser-invocable: false\npaths: [\"src/api/**\", \"src/routes/**\", \"src/server/**\", \"src/middleware/**\", \"server/**\", \"api/**\"]\neffort: medium\n---\n\n# Node.js Backend Skill\n\n\n---\n\n## Project Structure\n\n```\nproject/\n├── src/\n│   ├── core/                   # Pure business logic\n│   │   ├── types.ts            # Domain types\n│   │   ├── errors.ts           # Domain errors\n│   │   └── services/           # Pure functions\n│   │       ├── user.ts\n│   │       └── order.ts\n│   ├── infra/                  # Side effects\n│   │   ├── http/               # HTTP layer\n│   │   │   ├── server.ts       # Server setup\n│   │   │   ├── routes/         # Route handlers\n│   │   │   └── middleware/     # Express middleware\n│   │   ├── db/                 # Database\n│   │   │   ├── client.ts       # DB connection\n│   │   │   ├── repositories/   # Data access\n│   │   │   └── migrations/     # Schema migrations\n│   │   └── external/           # Third-party APIs\n│   ├── config/                 # Configuration\n│   │   └── index.ts            # Env vars, validated\n│   └── index.ts                # Entry point\n├── tests/\n│   ├── unit/\n│   └── integration/\n├── package.json\n└── CLAUDE.md\n```\n\n---\n\n## API Design\n\n### Route Handler Pattern\n```typescript\n// routes/users.ts\nimport { Router } from 'express';\nimport { z } from 'zod';\nimport { createUser } from '../../core/services/user';\nimport { UserRepository } from '../db/repositories/user';\n\nconst CreateUserSchema = z.object({\n  email: z.string().email(),\n  name: z.string().min(1).max(100),\n});\n\nexport function createUserRoutes(userRepo: UserRepository): Router {\n  const router = Router();\n\n  router.post('/', async (req, res, next) => {\n    try {\n      const input = CreateUserSchema.parse(req.body);\n      const user = await createUser(input, userRepo);\n      res.status(201).json(user);\n    } catch (error) {\n      next(error);\n    }\n  });\n\n  return router;\n}\n```\n\n### Dependency Injection at Composition Root\n```typescript\n// index.ts\nimport { createApp } from './infra/http/server';\nimport { createDbClient } from './infra/db/client';\nimport { UserRepository } from './infra/db/repositories/user';\nimport { createUserRoutes } from './infra/http/routes/users';\n\nasync function main(): Promise<void> {\n  const db = await createDbClient();\n  const userRepo = new UserRepository(db);\n  \n  const app = createApp({\n    userRoutes: createUserRoutes(userRepo),\n  });\n  \n  app.listen(3000);\n}\n```\n\n---\n\n## Error Handling\n\n### Domain Errors\n```typescript\n// core/errors.ts\nexport class DomainError extends Error {\n  constructor(\n    message: string,\n    public readonly code: string,\n    public readonly statusCode: number = 400\n  ) {\n    super(message);\n    this.name = 'DomainError';\n  }\n}\n\nexport class NotFoundError extends DomainError {\n  constructor(resource: string, id: string) {\n    super(`${resource} with id ${id} not found`, 'NOT_FOUND', 404);\n  }\n}\n\nexport class ValidationError extends DomainError {\n  constructor(message: string) {\n    super(message, 'VALIDATION_ERROR', 400);\n  }\n}\n```\n\n### Global Error Handler\n```typescript\n// middleware/errorHandler.ts\nimport { ErrorRequestHandler } from 'express';\nimport { DomainError } from '../../core/errors';\nimport { ZodError } from 'zod';\n\nexport const errorHandler: ErrorRequestHandler = (err, req, res, next) => {\n  if (err instanceof DomainError) {\n    return res.status(err.statusCode).json({\n      error: { code: err.code, message: err.message },\n    });\n  }\n\n  if (err instanceof ZodError) {\n    return res.status(400).json({\n      error: { code: 'VALIDATION_ERROR', details: err.errors },\n    });\n  }\n\n  console.error('Unexpected error:', err);\n  return res.status(500).json({\n    error: { code: 'INTERNAL_ERROR', message: 'Something went wrong' },\n  });\n};\n```\n\n---\n\n## Database Patterns\n\n### Repository Pattern\n```typescript\n// db/repositories/user.ts\nimport { Kysely } from 'kysely';\nimport { Database, User } from '../types';\n\nexport class UserRepository {\n  constructor(private db: Kysely<Database>) {}\n\n  async findById(id: string): Promise<User | null> {\n    return this.db\n      .selectFrom('users')\n      .where('id', '=', id)\n      .selectAll()\n      .executeTakeFirst() ?? null;\n  }\n\n  async create(data: Omit<User, 'id' | 'createdAt'>): Promise<User> {\n    return this.db\n      .insertInto('users')\n      .values(data)\n      .returningAll()\n      .executeTakeFirstOrThrow();\n  }\n}\n```\n\n### Transactions\n```typescript\nasync function transferFunds(\n  fromId: string,\n  toId: string,\n  amount: number,\n  db: Kysely<Database>\n): Promise<void> {\n  await db.transaction().execute(async (trx) => {\n    await trx\n      .updateTable('accounts')\n      .set((eb) => ({ balance: eb('balance', '-', amount) }))\n      .where('id', '=', fromId)\n      .execute();\n\n    await trx\n      .updateTable('accounts')\n      .set((eb) => ({ balance: eb('balance', '+', amount) }))\n      .where('id', '=', toId)\n      .execute();\n  });\n}\n```\n\n---\n\n## Configuration\n\n### Validated Config\n```typescript\n// config/index.ts\nimport { z } from 'zod';\n\nconst ConfigSchema = z.object({\n  NODE_ENV: z.enum(['development', 'production', 'test']),\n  PORT: z.coerce.number().default(3000),\n  DATABASE_URL: z.string().url(),\n  API_KEY: z.string().min(1),\n});\n\nexport type Config = z.infer<typeof ConfigSchema>;\n\nexport function loadConfig(): Config {\n  return ConfigSchema.parse(process.env);\n}\n```\n\n---\n\n## Testing\n\n### Unit Tests (Core)\n```typescript\n// tests/unit/services/user.test.ts\nimport { createUser } from '../../../src/core/services/user';\n\ndescribe('createUser', () => {\n  it('creates user with valid data', async () => {\n    const mockRepo = {\n      create: jest.fn().mockResolvedValue({ id: '1', email: 'test@example.com' }),\n      findByEmail: jest.fn().mockResolvedValue(null),\n    };\n\n    const result = await createUser({ email: 'test@example.com', name: 'Test' }, mockRepo);\n\n    expect(result.email).toBe('test@example.com');\n    expect(mockRepo.create).toHaveBeenCalledTimes(1);\n  });\n});\n```\n\n### Integration Tests (API)\n```typescript\n// tests/integration/users.test.ts\nimport request from 'supertest';\nimport { createTestApp, createTestDb } from '../helpers';\n\ndescribe('POST /users', () => {\n  let app: Express;\n  let db: TestDb;\n\n  beforeAll(async () => {\n    db = await createTestDb();\n    app = createTestApp(db);\n  });\n\n  afterAll(async () => {\n    await db.destroy();\n  });\n\n  it('creates user and returns 201', async () => {\n    const response = await request(app)\n      .post('/users')\n      .send({ email: 'new@example.com', name: 'New User' });\n\n    expect(response.status).toBe(201);\n    expect(response.body.email).toBe('new@example.com');\n  });\n});\n```\n\n---\n\n## Node.js Anti-Patterns\n\n- ❌ Callback hell - use async/await\n- ❌ Unhandled promise rejections - always catch or let error handler catch\n- ❌ Blocking the event loop - offload heavy computation\n- ❌ Secrets in code - use environment variables\n- ❌ SQL string concatenation - use parameterized queries\n- ❌ No input validation - validate at API boundary\n- ❌ Console.log in production - use proper logger\n- ❌ No graceful shutdown - handle SIGTERM\n- ❌ Monolithic route files - split by resource\n"
  },
  {
    "path": "skills/playwright-testing/SKILL.md",
    "content": "---\nname: playwright-testing\ndescription: E2E testing with Playwright - Page Objects, cross-browser, CI/CD\nwhen-to-use: When writing or debugging E2E tests with Playwright\nuser-invocable: true\npaths: [\"**/e2e/**\", \"**/*.spec.ts\", \"**/playwright/**\", \"playwright.config.*\"]\neffort: medium\n---\n\n# Playwright E2E Testing Skill\n\n\nFor end-to-end testing of web applications with Playwright - cross-browser, fast, reliable.\n\n**Sources:** [Playwright Best Practices](https://playwright.dev/docs/best-practices) | [Playwright Docs](https://playwright.dev/docs/intro) | [Better Stack Guide](https://betterstack.com/community/guides/testing/playwright-best-practices/)\n\n---\n\n## Setup\n\n### Installation\n\n```bash\n# New project\nnpm init playwright@latest\n\n# Existing project\nnpm install -D @playwright/test\nnpx playwright install\n```\n\n### Configuration\n\n```typescript\n// playwright.config.ts\nimport { defineConfig, devices } from '@playwright/test';\n\nexport default defineConfig({\n  testDir: './e2e',\n  fullyParallel: true,\n  forbidOnly: !!process.env.CI,\n  retries: process.env.CI ? 2 : 0,\n  workers: process.env.CI ? 1 : undefined,\n  reporter: [\n    ['html'],\n    ['list'],\n    process.env.CI ? ['github'] : ['line'],\n  ],\n\n  use: {\n    baseURL: process.env.BASE_URL || 'http://localhost:3000',\n    trace: 'on-first-retry',\n    screenshot: 'only-on-failure',\n    video: 'retain-on-failure',\n  },\n\n  projects: [\n    // Auth setup - runs once before all tests\n    { name: 'setup', testMatch: /.*\\.setup\\.ts/ },\n\n    {\n      name: 'chromium',\n      use: { ...devices['Desktop Chrome'] },\n      dependencies: ['setup'],\n    },\n    {\n      name: 'firefox',\n      use: { ...devices['Desktop Firefox'] },\n      dependencies: ['setup'],\n    },\n    {\n      name: 'webkit',\n      use: { ...devices['Desktop Safari'] },\n      dependencies: ['setup'],\n    },\n    // Mobile viewports\n    {\n      name: 'mobile-chrome',\n      use: { ...devices['Pixel 5'] },\n      dependencies: ['setup'],\n    },\n    {\n      name: 'mobile-safari',\n      use: { ...devices['iPhone 12'] },\n      dependencies: ['setup'],\n    },\n  ],\n\n  // Start dev server before tests\n  webServer: {\n    command: 'npm run dev',\n    url: 'http://localhost:3000',\n    reuseExistingServer: !process.env.CI,\n    timeout: 120 * 1000,\n  },\n});\n```\n\n---\n\n## Project Structure\n\n```\nproject/\n├── e2e/\n│   ├── fixtures/\n│   │   ├── auth.fixture.ts      # Auth fixtures\n│   │   └── test.fixture.ts      # Extended test with fixtures\n│   ├── pages/\n│   │   ├── base.page.ts         # Base page object\n│   │   ├── login.page.ts        # Login page object\n│   │   ├── dashboard.page.ts    # Dashboard page object\n│   │   └── index.ts             # Export all pages\n│   ├── tests/\n│   │   ├── auth.spec.ts         # Auth tests\n│   │   ├── dashboard.spec.ts    # Dashboard tests\n│   │   └── checkout.spec.ts     # Checkout flow tests\n│   ├── utils/\n│   │   ├── helpers.ts           # Test helpers\n│   │   └── test-data.ts         # Test data factories\n│   └── auth.setup.ts            # Global auth setup\n├── playwright.config.ts\n└── .auth/                        # Stored auth state (gitignored)\n```\n\n---\n\n## Locator Strategy (Priority Order)\n\nUse locators that mirror how users interact with the page:\n\n```typescript\n// ✅ BEST: Role-based (accessible, resilient)\npage.getByRole('button', { name: 'Submit' })\npage.getByRole('textbox', { name: 'Email' })\npage.getByRole('link', { name: 'Sign up' })\npage.getByRole('heading', { name: 'Welcome' })\n\n// ✅ GOOD: User-facing text\npage.getByLabel('Email address')\npage.getByPlaceholder('Enter your email')\npage.getByText('Welcome back')\npage.getByTitle('Profile settings')\n\n// ✅ GOOD: Test IDs (stable, explicit)\npage.getByTestId('submit-button')\npage.getByTestId('user-avatar')\n\n// ⚠️ AVOID: CSS selectors (brittle)\npage.locator('.btn-primary')\npage.locator('#submit')\n\n// ❌ NEVER: XPath (extremely brittle)\npage.locator('//div[@class=\"container\"]/button[1]')\n```\n\n### Chaining Locators\n\n```typescript\n// Narrow down to specific section\nconst form = page.getByRole('form', { name: 'Login' });\nawait form.getByRole('textbox', { name: 'Email' }).fill('user@example.com');\nawait form.getByRole('button', { name: 'Submit' }).click();\n\n// Filter within a list\nconst productCard = page.getByTestId('product-card')\n  .filter({ hasText: 'Pro Plan' });\nawait productCard.getByRole('button', { name: 'Buy' }).click();\n```\n\n---\n\n## Page Object Model\n\n### Base Page\n\n```typescript\n// e2e/pages/base.page.ts\nimport { Page, Locator } from '@playwright/test';\n\nexport abstract class BasePage {\n  constructor(protected page: Page) {}\n\n  async navigate(path: string = '/') {\n    await this.page.goto(path);\n  }\n\n  async waitForPageLoad() {\n    await this.page.waitForLoadState('networkidle');\n  }\n\n  // Common elements\n  get header() {\n    return this.page.getByRole('banner');\n  }\n\n  get footer() {\n    return this.page.getByRole('contentinfo');\n  }\n\n  // Common actions\n  async clickNavLink(name: string) {\n    await this.header.getByRole('link', { name }).click();\n  }\n}\n```\n\n### Page Implementation\n\n```typescript\n// e2e/pages/login.page.ts\nimport { Page, expect } from '@playwright/test';\nimport { BasePage } from './base.page';\n\nexport class LoginPage extends BasePage {\n  readonly emailInput: Locator;\n  readonly passwordInput: Locator;\n  readonly submitButton: Locator;\n  readonly errorMessage: Locator;\n\n  constructor(page: Page) {\n    super(page);\n    this.emailInput = page.getByLabel('Email');\n    this.passwordInput = page.getByLabel('Password');\n    this.submitButton = page.getByRole('button', { name: 'Sign in' });\n    this.errorMessage = page.getByRole('alert');\n  }\n\n  async goto() {\n    await this.navigate('/login');\n  }\n\n  async login(email: string, password: string) {\n    await this.emailInput.fill(email);\n    await this.passwordInput.fill(password);\n    await this.submitButton.click();\n  }\n\n  async expectError(message: string) {\n    await expect(this.errorMessage).toContainText(message);\n  }\n\n  async expectLoggedIn() {\n    await expect(this.page).toHaveURL(/.*dashboard/);\n  }\n}\n```\n\n```typescript\n// e2e/pages/dashboard.page.ts\nimport { Page, Locator, expect } from '@playwright/test';\nimport { BasePage } from './base.page';\n\nexport class DashboardPage extends BasePage {\n  readonly welcomeHeading: Locator;\n  readonly userMenu: Locator;\n  readonly logoutButton: Locator;\n\n  constructor(page: Page) {\n    super(page);\n    this.welcomeHeading = page.getByRole('heading', { name: /welcome/i });\n    this.userMenu = page.getByTestId('user-menu');\n    this.logoutButton = page.getByRole('button', { name: 'Logout' });\n  }\n\n  async goto() {\n    await this.navigate('/dashboard');\n  }\n\n  async logout() {\n    await this.userMenu.click();\n    await this.logoutButton.click();\n  }\n\n  async expectWelcome(name: string) {\n    await expect(this.welcomeHeading).toContainText(name);\n  }\n}\n```\n\n### Export All Pages\n\n```typescript\n// e2e/pages/index.ts\nexport { BasePage } from './base.page';\nexport { LoginPage } from './login.page';\nexport { DashboardPage } from './dashboard.page';\n```\n\n---\n\n## Authentication\n\n### Global Auth Setup\n\n```typescript\n// e2e/auth.setup.ts\nimport { test as setup, expect } from '@playwright/test';\nimport path from 'path';\n\nconst authFile = path.join(__dirname, '../.auth/user.json');\n\nsetup('authenticate', async ({ page }) => {\n  // Go to login page\n  await page.goto('/login');\n\n  // Login with test credentials\n  await page.getByLabel('Email').fill(process.env.TEST_USER_EMAIL!);\n  await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD!);\n  await page.getByRole('button', { name: 'Sign in' }).click();\n\n  // Wait for auth to complete\n  await expect(page).toHaveURL(/.*dashboard/);\n\n  // Save auth state for reuse\n  await page.context().storageState({ path: authFile });\n});\n```\n\n### Using Auth in Tests\n\n```typescript\n// playwright.config.ts\nexport default defineConfig({\n  projects: [\n    { name: 'setup', testMatch: /.*\\.setup\\.ts/ },\n    {\n      name: 'chromium',\n      use: {\n        ...devices['Desktop Chrome'],\n        storageState: '.auth/user.json',\n      },\n      dependencies: ['setup'],\n    },\n  ],\n});\n```\n\n### Tests Without Auth\n\n```typescript\n// e2e/tests/public.spec.ts\nimport { test } from '@playwright/test';\n\n// Override to skip auth\ntest.use({ storageState: { cookies: [], origins: [] } });\n\ntest('homepage loads for anonymous users', async ({ page }) => {\n  await page.goto('/');\n  await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible();\n});\n```\n\n---\n\n## Writing Tests\n\n### Basic Test Structure\n\n```typescript\n// e2e/tests/auth.spec.ts\nimport { test, expect } from '@playwright/test';\nimport { LoginPage } from '../pages';\n\ntest.describe('Authentication', () => {\n  test.beforeEach(async ({ page }) => {\n    // Skip stored auth for login tests\n    await page.context().clearCookies();\n  });\n\n  test('successful login redirects to dashboard', async ({ page }) => {\n    const loginPage = new LoginPage(page);\n\n    await loginPage.goto();\n    await loginPage.login('user@example.com', 'password123');\n    await loginPage.expectLoggedIn();\n  });\n\n  test('invalid credentials show error', async ({ page }) => {\n    const loginPage = new LoginPage(page);\n\n    await loginPage.goto();\n    await loginPage.login('wrong@example.com', 'wrongpass');\n    await loginPage.expectError('Invalid email or password');\n  });\n\n  test('empty form shows validation errors', async ({ page }) => {\n    const loginPage = new LoginPage(page);\n\n    await loginPage.goto();\n    await loginPage.submitButton.click();\n\n    await expect(page.getByText('Email is required')).toBeVisible();\n    await expect(page.getByText('Password is required')).toBeVisible();\n  });\n});\n```\n\n### User Flow Tests\n\n```typescript\n// e2e/tests/checkout.spec.ts\nimport { test, expect } from '@playwright/test';\n\ntest.describe('Checkout Flow', () => {\n  test('complete purchase flow', async ({ page }) => {\n    // 1. Browse products\n    await page.goto('/products');\n    await page.getByTestId('product-card')\n      .filter({ hasText: 'Pro Plan' })\n      .getByRole('button', { name: 'Add to cart' })\n      .click();\n\n    // 2. View cart\n    await page.getByRole('link', { name: 'Cart' }).click();\n    await expect(page.getByText('Pro Plan')).toBeVisible();\n    await expect(page.getByTestId('cart-total')).toContainText('$29.99');\n\n    // 3. Checkout\n    await page.getByRole('button', { name: 'Checkout' }).click();\n\n    // 4. Fill payment (use Stripe test card)\n    const stripeFrame = page.frameLocator('iframe[name*=\"stripe\"]');\n    await stripeFrame.getByPlaceholder('Card number').fill('4242424242424242');\n    await stripeFrame.getByPlaceholder('MM / YY').fill('12/30');\n    await stripeFrame.getByPlaceholder('CVC').fill('123');\n\n    // 5. Complete purchase\n    await page.getByRole('button', { name: 'Pay now' }).click();\n\n    // 6. Verify success\n    await expect(page).toHaveURL(/.*success/);\n    await expect(page.getByRole('heading', { name: 'Thank you' })).toBeVisible();\n  });\n});\n```\n\n---\n\n## Assertions\n\n### Web-First Assertions (Auto-Wait)\n\n```typescript\n// ✅ These wait and retry automatically\nawait expect(page.getByRole('button')).toBeVisible();\nawait expect(page.getByRole('button')).toBeEnabled();\nawait expect(page.getByRole('button')).toHaveText('Submit');\nawait expect(page).toHaveURL('/dashboard');\nawait expect(page).toHaveTitle(/Dashboard/);\n\n// ❌ Avoid manual waits\nawait page.waitForTimeout(3000);  // NEVER do this\n```\n\n### Soft Assertions\n\n```typescript\n// Continue test even if assertion fails\nawait expect.soft(page.getByTestId('price')).toHaveText('$29.99');\nawait expect.soft(page.getByTestId('stock')).toHaveText('In Stock');\n\n// Fail at end if any soft assertions failed\n```\n\n### Common Assertions\n\n```typescript\n// Visibility\nawait expect(locator).toBeVisible();\nawait expect(locator).toBeHidden();\nawait expect(locator).toBeAttached();\n\n// Text content\nawait expect(locator).toHaveText('exact text');\nawait expect(locator).toContainText('partial');\nawait expect(locator).toHaveValue('input value');\n\n// State\nawait expect(locator).toBeEnabled();\nawait expect(locator).toBeDisabled();\nawait expect(locator).toBeChecked();\nawait expect(locator).toBeFocused();\n\n// Count\nawait expect(locator).toHaveCount(5);\n\n// Page\nawait expect(page).toHaveURL('/dashboard');\nawait expect(page).toHaveTitle('Dashboard | App');\nawait expect(page).toHaveScreenshot('dashboard.png');\n```\n\n---\n\n## Mocking & Network\n\n### Mock API Responses\n\n```typescript\ntest('shows error when API fails', async ({ page }) => {\n  // Mock API to return error\n  await page.route('**/api/users', (route) => {\n    route.fulfill({\n      status: 500,\n      body: JSON.stringify({ error: 'Server error' }),\n    });\n  });\n\n  await page.goto('/users');\n  await expect(page.getByText('Failed to load users')).toBeVisible();\n});\n\ntest('displays user data from API', async ({ page }) => {\n  // Mock successful response\n  await page.route('**/api/users', (route) => {\n    route.fulfill({\n      status: 200,\n      contentType: 'application/json',\n      body: JSON.stringify([\n        { id: 1, name: 'John Doe', email: 'john@example.com' },\n        { id: 2, name: 'Jane Doe', email: 'jane@example.com' },\n      ]),\n    });\n  });\n\n  await page.goto('/users');\n  await expect(page.getByText('John Doe')).toBeVisible();\n  await expect(page.getByText('Jane Doe')).toBeVisible();\n});\n```\n\n### Wait for API Calls\n\n```typescript\ntest('submits form and shows success', async ({ page }) => {\n  await page.goto('/contact');\n\n  // Fill form\n  await page.getByLabel('Name').fill('John');\n  await page.getByLabel('Email').fill('john@example.com');\n  await page.getByLabel('Message').fill('Hello!');\n\n  // Wait for API call on submit\n  const responsePromise = page.waitForResponse('**/api/contact');\n  await page.getByRole('button', { name: 'Send' }).click();\n\n  const response = await responsePromise;\n  expect(response.status()).toBe(200);\n\n  await expect(page.getByText('Message sent!')).toBeVisible();\n});\n```\n\n---\n\n## Visual Testing\n\n```typescript\n// Full page screenshot\nawait expect(page).toHaveScreenshot('homepage.png');\n\n// Element screenshot\nawait expect(page.getByTestId('chart')).toHaveScreenshot('chart.png');\n\n// With options\nawait expect(page).toHaveScreenshot('dashboard.png', {\n  maxDiffPixels: 100,\n  mask: [page.getByTestId('timestamp')], // Ignore dynamic content\n});\n```\n\n---\n\n## CI/CD Integration\n\n### GitHub Actions\n\n```yaml\n# .github/workflows/e2e.yml\nname: E2E Tests\n\non:\n  push:\n    branches: [main]\n  pull_request:\n    branches: [main]\n\njobs:\n  test:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n\n      - uses: actions/setup-node@v4\n        with:\n          node-version: 20\n          cache: 'npm'\n\n      - name: Install dependencies\n        run: npm ci\n\n      - name: Install Playwright browsers\n        run: npx playwright install --with-deps chromium\n\n      - name: Run E2E tests\n        run: npx playwright test --project=chromium\n        env:\n          BASE_URL: ${{ secrets.STAGING_URL }}\n          TEST_USER_EMAIL: ${{ secrets.TEST_USER_EMAIL }}\n          TEST_USER_PASSWORD: ${{ secrets.TEST_USER_PASSWORD }}\n\n      - uses: actions/upload-artifact@v4\n        if: failure()\n        with:\n          name: playwright-report\n          path: playwright-report/\n          retention-days: 7\n```\n\n### Run Specific Tests\n\n```bash\n# Run all tests\nnpx playwright test\n\n# Run specific file\nnpx playwright test e2e/tests/auth.spec.ts\n\n# Run tests with tag\nnpx playwright test --grep @critical\n\n# Run in headed mode (debug)\nnpx playwright test --headed\n\n# Run specific browser\nnpx playwright test --project=chromium\n\n# Debug mode\nnpx playwright test --debug\n\n# Show HTML report\nnpx playwright show-report\n```\n\n---\n\n## Test Data\n\n### Factories\n\n```typescript\n// e2e/utils/test-data.ts\nimport { faker } from '@faker-js/faker';\n\nexport const createUser = (overrides = {}) => ({\n  email: faker.internet.email(),\n  password: faker.internet.password({ length: 12 }),\n  name: faker.person.fullName(),\n  ...overrides,\n});\n\nexport const createProduct = (overrides = {}) => ({\n  name: faker.commerce.productName(),\n  price: faker.commerce.price({ min: 10, max: 100 }),\n  description: faker.commerce.productDescription(),\n  ...overrides,\n});\n```\n\n### Environment Variables\n\n```bash\n# .env.test\nBASE_URL=http://localhost:3000\nTEST_USER_EMAIL=test@example.com\nTEST_USER_PASSWORD=testpassword123\n```\n\n---\n\n## Debugging\n\n### Trace Viewer\n\n```typescript\n// Enable in config for failures\nuse: {\n  trace: 'on-first-retry',\n}\n\n// View traces\nnpx playwright show-trace trace.zip\n```\n\n### Debug Mode\n\n```bash\n# Step through test\nnpx playwright test --debug\n\n# Pause at specific point\nawait page.pause();  // In test code\n```\n\n### VS Code Extension\n\nInstall \"Playwright Test for VS Code\" for:\n- Run tests from editor\n- Debug with breakpoints\n- Pick locators visually\n- Watch mode\n\n---\n\n## Dead Link Detection (REQUIRED)\n\n**Every project MUST include dead link detection tests.** Run these on every deployment.\n\n### Link Validator Test\n\n```typescript\n// e2e/tests/links.spec.ts\nimport { test, expect } from '@playwright/test';\n\nconst PAGES_TO_CHECK = ['/', '/about', '/pricing', '/blog', '/contact'];\n\ntest.describe('Dead Link Detection', () => {\n  for (const pagePath of PAGES_TO_CHECK) {\n    test(`no dead links on ${pagePath}`, async ({ page, request }) => {\n      await page.goto(pagePath);\n\n      // Get all links on the page\n      const links = await page.locator('a[href]').all();\n      const hrefs = await Promise.all(\n        links.map(link => link.getAttribute('href'))\n      );\n\n      // Filter to internal and absolute external links\n      const uniqueLinks = [...new Set(hrefs.filter(Boolean))] as string[];\n\n      for (const href of uniqueLinks) {\n        // Skip mailto, tel, and anchor links\n        if (href.startsWith('mailto:') || href.startsWith('tel:') || href.startsWith('#')) {\n          continue;\n        }\n\n        // Build full URL\n        const url = href.startsWith('http') ? href : new URL(href, page.url()).href;\n\n        // Check link status\n        const response = await request.get(url, {\n          timeout: 10000,\n          ignoreHTTPSErrors: true,\n        });\n\n        expect(\n          response.ok(),\n          `Dead link found on ${pagePath}: ${href} returned ${response.status()}`\n        ).toBeTruthy();\n      }\n    });\n  }\n});\n```\n\n### Comprehensive Link Crawler\n\n```typescript\n// e2e/tests/site-links.spec.ts\nimport { test, expect, Page, APIRequestContext } from '@playwright/test';\n\ninterface LinkResult {\n  url: string;\n  status: number;\n  foundOn: string;\n}\n\nasync function checkAllLinks(\n  page: Page,\n  request: APIRequestContext,\n  startUrl: string\n): Promise<LinkResult[]> {\n  const visited = new Set<string>();\n  const results: LinkResult[] = [];\n  const toVisit = [startUrl];\n  const baseUrl = new URL(startUrl).origin;\n\n  while (toVisit.length > 0) {\n    const currentUrl = toVisit.pop()!;\n    if (visited.has(currentUrl)) continue;\n    visited.add(currentUrl);\n\n    try {\n      await page.goto(currentUrl);\n      const links = await page.locator('a[href]').all();\n\n      for (const link of links) {\n        const href = await link.getAttribute('href');\n        if (!href || href.startsWith('#') || href.startsWith('mailto:') || href.startsWith('tel:')) {\n          continue;\n        }\n\n        const fullUrl = href.startsWith('http') ? href : new URL(href, currentUrl).href;\n\n        // Check link\n        const response = await request.get(fullUrl, {\n          timeout: 10000,\n          ignoreHTTPSErrors: true,\n        });\n\n        results.push({\n          url: fullUrl,\n          status: response.status(),\n          foundOn: currentUrl,\n        });\n\n        // Add internal links to queue\n        if (fullUrl.startsWith(baseUrl) && !visited.has(fullUrl)) {\n          toVisit.push(fullUrl);\n        }\n      }\n    } catch (error) {\n      results.push({\n        url: currentUrl,\n        status: 0,\n        foundOn: 'navigation',\n      });\n    }\n  }\n\n  return results;\n}\n\ntest('no dead links on entire site', async ({ page, request, baseURL }) => {\n  const results = await checkAllLinks(page, request, baseURL!);\n  const deadLinks = results.filter(r => r.status >= 400 || r.status === 0);\n\n  if (deadLinks.length > 0) {\n    console.error('Dead links found:');\n    deadLinks.forEach(link => {\n      console.error(`  ${link.url} (${link.status}) - found on ${link.foundOn}`);\n    });\n  }\n\n  expect(deadLinks, `Found ${deadLinks.length} dead links`).toHaveLength(0);\n});\n```\n\n### Image Link Validation\n\n```typescript\n// e2e/tests/images.spec.ts\nimport { test, expect } from '@playwright/test';\n\ntest('no broken images on homepage', async ({ page, request }) => {\n  await page.goto('/');\n\n  const images = await page.locator('img[src]').all();\n\n  for (const img of images) {\n    const src = await img.getAttribute('src');\n    if (!src) continue;\n\n    const url = src.startsWith('http') ? src : new URL(src, page.url()).href;\n\n    // Skip data URLs\n    if (url.startsWith('data:')) continue;\n\n    const response = await request.get(url);\n    expect(\n      response.ok(),\n      `Broken image: ${src}`\n    ).toBeTruthy();\n\n    // Verify it's actually an image\n    const contentType = response.headers()['content-type'];\n    expect(\n      contentType?.startsWith('image/'),\n      `${src} is not an image (${contentType})`\n    ).toBeTruthy();\n  }\n});\n```\n\n### CI Integration for Link Checking\n\n```yaml\n# .github/workflows/link-check.yml\nname: Link Check\n\non:\n  schedule:\n    - cron: '0 6 * * 1'  # Weekly on Monday\n  push:\n    branches: [main]\n\njobs:\n  link-check:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: actions/setup-node@v4\n        with:\n          node-version: 20\n      - run: npm ci\n      - run: npx playwright install chromium\n      - run: npx playwright test e2e/tests/links.spec.ts --project=chromium\n        env:\n          BASE_URL: ${{ secrets.PRODUCTION_URL }}\n```\n\n---\n\n## Anti-Patterns\n\n- **Hardcoded waits** - Use auto-waiting assertions instead\n- **CSS/XPath selectors** - Use role/text/testid locators\n- **Testing third-party sites** - Mock external dependencies\n- **Shared state between tests** - Each test must be isolated\n- **Missing awaits** - Use ESLint rule `no-floating-promises`\n- **Flaky time-based tests** - Mock dates/times\n- **Testing implementation details** - Test user-visible behavior\n- **Huge test files** - Split by feature/page\n\n---\n\n## Quick Reference\n\n```bash\n# Install\nnpm init playwright@latest\n\n# Run tests\nnpx playwright test\nnpx playwright test --headed\nnpx playwright test --project=chromium\nnpx playwright test --grep @smoke\n\n# Debug\nnpx playwright test --debug\nnpx playwright show-report\nnpx playwright show-trace trace.zip\n\n# Generate tests\nnpx playwright codegen localhost:3000\n```\n\n### Package.json Scripts\n\n```json\n{\n  \"scripts\": {\n    \"test:e2e\": \"playwright test\",\n    \"test:e2e:headed\": \"playwright test --headed\",\n    \"test:e2e:debug\": \"playwright test --debug\",\n    \"test:e2e:report\": \"playwright show-report\",\n    \"test:e2e:codegen\": \"playwright codegen\"\n  }\n}\n```\n"
  },
  {
    "path": "skills/polyphony/SKILL.md",
    "content": "---\nname: polyphony\ndescription: Multi-agent orchestration with container-isolated workspaces — each agent session runs in its own Docker container with independent git branches\nwhen-to-use: Always loaded when container isolation is available (Docker/OrbStack installed). Default for /spawn-team.\nuser-invocable: false\neffort: high\n---\n\n# Polyphony — Multi-Agent Orchestration\n\nContainer-isolated workspaces for parallel agent execution. Each agent gets its own Docker container with a full git clone on its own branch. No conflicts, independent tests, clean PRs.\n\n---\n\n## Architecture (6 Layers)\n\n1. **Work Source** — Tasks from GitHub Issues (`gh api`) or local SQLite queue\n2. **Orchestrator** — Supervisor loop: discover -> claim -> route -> provision -> run -> verify -> land\n3. **Router** — Pure function: Task x Policy -> RunSpec (5-dimension complexity scoring)\n4. **Identity Broker** — Resolves named credentials to volume mounts + env overlays\n5. **Workspace Manager** — Per-task `git clone --reference`, branch checkout, cleanup\n6. **Worker Runtime** — Docker container create/start/stop/logs lifecycle\n\n---\n\n## Task Lifecycle\n\n```\nDISCOVERED -> CLAIMED -> ROUTED -> PROVISIONED -> RUNNING -> VERIFYING -> LANDED\n                                                     |           |\n                                                     v           v\n                                                   FAILED --> BLOCKED\n                                                     |\n                                                     v\n                                                   CLAIMED (retry)\n```\n\n---\n\n## Prerequisites\n\n- Docker or OrbStack installed and running\n- At least one agent CLI available (Claude, Codex, or Kimi)\n- CLI subscriptions configured (not API keys)\n\nCheck:\n```bash\ncommand -v docker &>/dev/null || command -v orbctl &>/dev/null\n```\n\n---\n\n## Configuration\n\nAll config lives in `~/.polyphony/`:\n\n| File | Purpose |\n|------|---------|\n| `config.yaml` | Workspace root, poll interval, max concurrency |\n| `identities.yaml` | Named credential bundles with volume paths |\n| `agents.yaml` | Agent profiles (CLI commands, strengths) |\n| `routing.yaml` | Routing rules and fallback chains |\n\nInitialize with: `polyphony init`\n\n---\n\n## Routing Rules\n\nRules are evaluated top-down; first match wins. Each rule has a `match` predicate and an `agent` target.\n\n```yaml\nrules:\n  - match: { task_type: docs, risk: low }\n    agent: kimi\n  - match: { task_type: bugfix }\n    agent: codex\n  - match: { risk: high }\n    agent: claude\ndefault:\n  agent: claude\n  fallback: [codex, kimi]\n```\n\n---\n\n## Complexity Scoring (5 Dimensions)\n\nEach dimension scores 0-2. Total 0-10.\n\n| Dimension | Source |\n|-----------|--------|\n| Cyclomatic depth | LOC + scope size |\n| Fan-out | Number of callers |\n| Security boundary | Auth/PII keywords |\n| Concurrency | Lock/transaction keywords |\n| Domain invariants | Risk level + task type |\n\nRouting thresholds:\n- **0-3**: Delegate to Kimi solo\n- **4-6**: Kimi + Codex review\n- **7-10**: Claude direct\n\n---\n\n## Container Isolation\n\nEach task gets:\n- Its own Docker container from `polyphony-worker:latest`\n- A full git clone at `/workspace` (not a worktree)\n- Auth volumes mounted read-only (e.g., `~/.claude:/home/worker/.claude:ro`)\n- Independent test execution\n- Its own branch for PRs\n\n---\n\n## CLI Commands\n\n```bash\npolyphony init                    # Create ~/.polyphony/ with config templates\npolyphony spawn \"Fix auth bug\"    # Create and route a task\npolyphony status                  # Show task states\npolyphony cleanup                 # Remove completed workspaces\n```\n\n---\n\n## Integration with Existing Skills\n\n- **cross-agent-delegation**: Uses Polyphony's complexity scoring for routing decisions\n- **agent-teams**: Uses Polyphony's workspace isolation instead of shared directories\n- **spawn-team**: Uses Polyphony's container provisioning for feature agents\n"
  },
  {
    "path": "skills/posthog-analytics/SKILL.md",
    "content": "---\nname: posthog-analytics\ndescription: PostHog analytics, event tracking, feature flags, dashboards\nwhen-to-use: When adding analytics, feature flags, or event tracking with PostHog\nuser-invocable: false\neffort: medium\n---\n\n# PostHog Analytics Skill\n\n\nFor implementing product analytics with PostHog - event tracking, user identification, feature flags, and project-specific dashboards.\n\n**Sources:** [PostHog Docs](https://posthog.com/docs) | [Product Analytics](https://posthog.com/docs/product-analytics) | [Feature Flags](https://posthog.com/docs/feature-flags)\n\n---\n\n## Philosophy\n\n**Measure what matters, not everything.**\n\nAnalytics should answer specific questions:\n- Are users getting value? (activation, retention)\n- Where do users struggle? (funnels, drop-offs)\n- What features drive engagement? (feature usage)\n- Is the product growing? (acquisition, referrals)\n\nDon't track everything. Track what informs decisions.\n\n---\n\n## Installation\n\n### Next.js (App Router)\n\n```bash\nnpm install posthog-js\n```\n\n```typescript\n// lib/posthog.ts\nimport posthog from 'posthog-js';\n\nexport function initPostHog() {\n  if (typeof window !== 'undefined' && !posthog.__loaded) {\n    posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, {\n      api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST || 'https://us.i.posthog.com',\n      person_profiles: 'identified_only', // Only create profiles for identified users\n      capture_pageview: false, // We'll handle this manually for SPA\n      capture_pageleave: true,\n      loaded: (posthog) => {\n        if (process.env.NODE_ENV === 'development') {\n          posthog.debug();\n        }\n      },\n    });\n  }\n  return posthog;\n}\n\nexport { posthog };\n```\n\n```typescript\n// app/providers.tsx\n'use client';\n\nimport { useEffect } from 'react';\nimport { usePathname, useSearchParams } from 'next/navigation';\nimport { initPostHog, posthog } from '@/lib/posthog';\n\nexport function PostHogProvider({ children }: { children: React.ReactNode }) {\n  const pathname = usePathname();\n  const searchParams = useSearchParams();\n\n  useEffect(() => {\n    initPostHog();\n  }, []);\n\n  // Track pageviews\n  useEffect(() => {\n    if (pathname) {\n      let url = window.origin + pathname;\n      if (searchParams.toString()) {\n        url += `?${searchParams.toString()}`;\n      }\n      posthog.capture('$pageview', { $current_url: url });\n    }\n  }, [pathname, searchParams]);\n\n  return <>{children}</>;\n}\n```\n\n```typescript\n// app/layout.tsx\nimport { PostHogProvider } from './providers';\n\nexport default function RootLayout({ children }: { children: React.ReactNode }) {\n  return (\n    <html lang=\"en\">\n      <body>\n        <PostHogProvider>\n          {children}\n        </PostHogProvider>\n      </body>\n    </html>\n  );\n}\n```\n\n### React (Vite/CRA)\n\n```typescript\n// src/posthog.ts\nimport posthog from 'posthog-js';\n\nposthog.init(import.meta.env.VITE_POSTHOG_KEY, {\n  api_host: import.meta.env.VITE_POSTHOG_HOST || 'https://us.i.posthog.com',\n  person_profiles: 'identified_only',\n});\n\nexport { posthog };\n```\n\n```typescript\n// src/main.tsx\nimport { PostHogProvider } from 'posthog-js/react';\nimport { posthog } from './posthog';\n\nReactDOM.createRoot(document.getElementById('root')!).render(\n  <PostHogProvider client={posthog}>\n    <App />\n  </PostHogProvider>\n);\n```\n\n### Python (FastAPI/Flask)\n\n```bash\npip install posthog\n```\n\n```python\n# analytics/posthog_client.py\nimport posthog\nfrom functools import lru_cache\n\n@lru_cache()\ndef get_posthog():\n    posthog.project_api_key = os.environ[\"POSTHOG_API_KEY\"]\n    posthog.host = os.environ.get(\"POSTHOG_HOST\", \"https://us.i.posthog.com\")\n    posthog.debug = os.environ.get(\"ENV\") == \"development\"\n    return posthog\n\n# Usage\ndef track_event(user_id: str, event: str, properties: dict = None):\n    ph = get_posthog()\n    ph.capture(\n        distinct_id=user_id,\n        event=event,\n        properties=properties or {}\n    )\n\ndef identify_user(user_id: str, properties: dict):\n    ph = get_posthog()\n    ph.identify(user_id, properties)\n```\n\n### Node.js (Express/Hono)\n\n```bash\nnpm install posthog-node\n```\n\n```typescript\n// lib/posthog.ts\nimport { PostHog } from 'posthog-node';\n\nconst posthog = new PostHog(process.env.POSTHOG_API_KEY!, {\n  host: process.env.POSTHOG_HOST || 'https://us.i.posthog.com',\n});\n\n// Flush on shutdown\nprocess.on('SIGTERM', () => posthog.shutdown());\n\nexport { posthog };\n\n// Usage\nexport function trackEvent(userId: string, event: string, properties?: Record<string, any>) {\n  posthog.capture({\n    distinctId: userId,\n    event,\n    properties,\n  });\n}\n\nexport function identifyUser(userId: string, properties: Record<string, any>) {\n  posthog.identify({\n    distinctId: userId,\n    properties,\n  });\n}\n```\n\n---\n\n## Environment Variables\n\n```bash\n# .env.local (Next.js) - SAFE: These are meant to be public\nNEXT_PUBLIC_POSTHOG_KEY=phc_xxxxxxxxxxxxxxxxxxxx\nNEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com\n\n# .env (Backend) - Keep private\nPOSTHOG_API_KEY=phc_xxxxxxxxxxxxxxxxxxxx\nPOSTHOG_HOST=https://us.i.posthog.com\n```\n\nAdd to `credentials.md` patterns:\n```python\n'POSTHOG_API_KEY': r'phc_[A-Za-z0-9]+',\n```\n\n---\n\n## User Identification\n\n### When to Identify\n\n```typescript\n// Identify on signup\nasync function handleSignup(email: string, name: string) {\n  const user = await createUser(email, name);\n\n  posthog.identify(user.id, {\n    email: user.email,\n    name: user.name,\n    created_at: user.createdAt,\n    plan: 'free',\n  });\n\n  posthog.capture('user_signed_up', {\n    signup_method: 'email',\n  });\n}\n\n// Identify on login\nasync function handleLogin(email: string) {\n  const user = await authenticateUser(email);\n\n  posthog.identify(user.id, {\n    email: user.email,\n    name: user.name,\n    plan: user.plan,\n    last_login: new Date().toISOString(),\n  });\n\n  posthog.capture('user_logged_in');\n}\n\n// Reset on logout\nfunction handleLogout() {\n  posthog.capture('user_logged_out');\n  posthog.reset(); // Clears identity\n}\n```\n\n### User Properties\n\n```typescript\n// Standard properties to track\ninterface UserProperties {\n  // Identity\n  email: string;\n  name: string;\n\n  // Lifecycle\n  created_at: string;\n  plan: 'free' | 'pro' | 'enterprise';\n\n  // Engagement\n  onboarding_completed: boolean;\n  feature_count: number;\n\n  // Business\n  company_name?: string;\n  company_size?: string;\n  industry?: string;\n}\n\n// Update properties when they change\nposthog.capture('$set', {\n  $set: { plan: 'pro' },\n});\n```\n\n---\n\n## Event Tracking Patterns\n\n### Event Naming Convention\n\n```typescript\n// Format: [object]_[action]\n// Use snake_case, past tense for actions\n\n// ✅ Good event names\n'user_signed_up'\n'feature_created'\n'subscription_upgraded'\n'onboarding_completed'\n'invite_sent'\n'file_uploaded'\n'search_performed'\n'checkout_started'\n'payment_completed'\n\n// ❌ Bad event names\n'click'           // Too vague\n'ButtonClick'     // Not snake_case\n'user signup'     // Spaces\n'creatingFeature' // Not past tense\n```\n\n### Core Events by Category\n\n```typescript\n// === AUTHENTICATION ===\nposthog.capture('user_signed_up', {\n  signup_method: 'google' | 'email' | 'github',\n  referral_source: 'organic' | 'paid' | 'referral',\n});\n\nposthog.capture('user_logged_in', {\n  login_method: 'google' | 'email' | 'magic_link',\n});\n\nposthog.capture('user_logged_out');\n\nposthog.capture('password_reset_requested');\n\n// === ONBOARDING ===\nposthog.capture('onboarding_started');\n\nposthog.capture('onboarding_step_completed', {\n  step_name: 'profile' | 'preferences' | 'first_action',\n  step_number: 1,\n  total_steps: 3,\n});\n\nposthog.capture('onboarding_completed', {\n  duration_seconds: 120,\n  steps_skipped: 0,\n});\n\nposthog.capture('onboarding_skipped', {\n  skipped_at_step: 2,\n});\n\n// === FEATURE USAGE ===\nposthog.capture('feature_used', {\n  feature_name: 'export' | 'share' | 'duplicate',\n  context: 'dashboard' | 'editor',\n});\n\nposthog.capture('[resource]_created', {\n  resource_type: 'project' | 'document' | 'team',\n  // Resource-specific properties\n});\n\nposthog.capture('[resource]_updated', {\n  resource_type: 'project',\n  fields_changed: ['name', 'description'],\n});\n\nposthog.capture('[resource]_deleted', {\n  resource_type: 'project',\n});\n\n// === BILLING ===\nposthog.capture('pricing_page_viewed', {\n  current_plan: 'free',\n});\n\nposthog.capture('checkout_started', {\n  plan: 'pro',\n  billing_period: 'monthly' | 'annual',\n  price: 29,\n});\n\nposthog.capture('subscription_upgraded', {\n  from_plan: 'free',\n  to_plan: 'pro',\n  mrr_change: 29,\n});\n\nposthog.capture('subscription_downgraded', {\n  from_plan: 'pro',\n  to_plan: 'free',\n  reason: 'too_expensive' | 'missing_features' | 'not_using',\n});\n\nposthog.capture('subscription_cancelled', {\n  plan: 'pro',\n  reason: 'string',\n  feedback: 'string',\n});\n\n// === ERRORS ===\nposthog.capture('error_occurred', {\n  error_type: 'api_error' | 'validation_error' | 'network_error',\n  error_message: 'string',\n  error_code: 'string',\n  page: '/dashboard',\n});\n```\n\n### React Hook for Tracking\n\n```typescript\n// hooks/useTrack.ts\nimport { useCallback } from 'react';\nimport { posthog } from '@/lib/posthog';\n\nexport function useTrack() {\n  const track = useCallback((event: string, properties?: Record<string, any>) => {\n    posthog.capture(event, {\n      ...properties,\n      timestamp: new Date().toISOString(),\n    });\n  }, []);\n\n  return { track };\n}\n\n// Usage\nfunction CreateProjectButton() {\n  const { track } = useTrack();\n\n  const handleCreate = async () => {\n    track('project_creation_started');\n\n    try {\n      const project = await createProject();\n      track('project_created', {\n        project_id: project.id,\n        template_used: project.template,\n      });\n    } catch (error) {\n      track('project_creation_failed', {\n        error_message: error.message,\n      });\n    }\n  };\n\n  return <button onClick={handleCreate}>Create Project</button>;\n}\n```\n\n---\n\n## Feature Flags\n\n### Setup\n\n```typescript\n// Check feature flag (client-side)\nimport { useFeatureFlagEnabled } from 'posthog-js/react';\n\nfunction NewFeature() {\n  const showNewUI = useFeatureFlagEnabled('new-dashboard-ui');\n\n  if (showNewUI) {\n    return <NewDashboard />;\n  }\n  return <OldDashboard />;\n}\n\n// With payload\nimport { useFeatureFlagPayload } from 'posthog-js/react';\n\nfunction PricingPage() {\n  const pricingConfig = useFeatureFlagPayload('pricing-experiment');\n  // pricingConfig = { price: 29, showAnnual: true }\n\n  return <Pricing config={pricingConfig} />;\n}\n```\n\n### Server-Side (Next.js)\n\n```typescript\n// app/dashboard/page.tsx\nimport { PostHog } from 'posthog-node';\nimport { cookies } from 'next/headers';\n\nasync function getFeatureFlags(userId: string) {\n  const posthog = new PostHog(process.env.POSTHOG_API_KEY!);\n\n  const flags = await posthog.getAllFlags(userId);\n  await posthog.shutdown();\n\n  return flags;\n}\n\nexport default async function Dashboard() {\n  const cookieStore = cookies();\n  const userId = cookieStore.get('user_id')?.value;\n\n  const flags = await getFeatureFlags(userId);\n\n  return (\n    <div>\n      {flags['new-dashboard'] && <NewFeature />}\n    </div>\n  );\n}\n```\n\n### A/B Testing\n\n```typescript\n// Track experiment exposure\nfunction ExperimentComponent() {\n  const variant = useFeatureFlagEnabled('checkout-experiment');\n\n  useEffect(() => {\n    posthog.capture('experiment_viewed', {\n      experiment: 'checkout-experiment',\n      variant: variant ? 'test' : 'control',\n    });\n  }, [variant]);\n\n  return variant ? <NewCheckout /> : <OldCheckout />;\n}\n```\n\n---\n\n## Project-Specific Dashboards\n\n### SaaS Product\n\n```markdown\n## Essential SaaS Dashboards\n\n### 1. Acquisition Dashboard\n**Questions answered:** Where do users come from? What converts?\n\nInsights to create:\n- [ ] Signups by source (daily/weekly trend)\n- [ ] Signup conversion rate by landing page\n- [ ] Time from first visit to signup\n- [ ] Signup funnel: Visit → Signup Page → Form Start → Complete\n\n### 2. Activation Dashboard\n**Questions answered:** Are new users getting value?\n\nInsights to create:\n- [ ] Onboarding completion rate\n- [ ] Time to first key action\n- [ ] Activation rate (% reaching \"aha moment\" in first 7 days)\n- [ ] Drop-off by onboarding step\n- [ ] Feature adoption in first session\n\n### 3. Engagement Dashboard\n**Questions answered:** How are users using the product?\n\nInsights to create:\n- [ ] DAU/WAU/MAU trends\n- [ ] Feature usage heatmap\n- [ ] Session duration distribution\n- [ ] Actions per session\n- [ ] Power users vs casual users\n\n### 4. Retention Dashboard\n**Questions answered:** Are users coming back?\n\nInsights to create:\n- [ ] Retention cohorts (D1, D7, D30)\n- [ ] Churn rate by plan\n- [ ] Reactivation rate\n- [ ] Last action before churn\n- [ ] Features correlated with retention\n\n### 5. Revenue Dashboard\n**Questions answered:** Is the business growing?\n\nInsights to create:\n- [ ] MRR trend\n- [ ] Upgrades vs downgrades\n- [ ] Trial to paid conversion\n- [ ] Revenue by plan\n- [ ] LTV by acquisition source\n```\n\n### E-Commerce\n\n```markdown\n## Essential E-Commerce Dashboards\n\n### 1. Conversion Funnel\nInsights to create:\n- [ ] Full funnel: Browse → PDP → Add to Cart → Checkout → Purchase\n- [ ] Cart abandonment rate\n- [ ] Checkout drop-off by step\n- [ ] Payment failure rate\n\n### 2. Product Performance\nInsights to create:\n- [ ] Product views → purchases (by product)\n- [ ] Add to cart rate by category\n- [ ] Search → purchase correlation\n- [ ] Cross-sell effectiveness\n\n### 3. Customer Dashboard\nInsights to create:\n- [ ] Repeat purchase rate\n- [ ] Average order value trend\n- [ ] Customer lifetime value\n- [ ] Purchase frequency distribution\n```\n\n### Content/Media\n\n```markdown\n## Essential Content Dashboards\n\n### 1. Consumption Dashboard\nInsights to create:\n- [ ] Content views by type\n- [ ] Read/watch completion rate\n- [ ] Time on content\n- [ ] Scroll depth distribution\n\n### 2. Engagement Dashboard\nInsights to create:\n- [ ] Shares by content\n- [ ] Comments per article\n- [ ] Save/bookmark rate\n- [ ] Return visits to same content\n\n### 3. Growth Dashboard\nInsights to create:\n- [ ] New vs returning visitors\n- [ ] Email signup rate\n- [ ] Referral traffic sources\n```\n\n### AI/LLM Application\n\n```markdown\n## Essential AI App Dashboards\n\n### 1. Usage Dashboard\nInsights to create:\n- [ ] Queries per user per day\n- [ ] Token usage distribution\n- [ ] Response time p50/p95\n- [ ] Error rate by query type\n\n### 2. Quality Dashboard\nInsights to create:\n- [ ] User feedback (thumbs up/down)\n- [ ] Regeneration rate (user asked for new response)\n- [ ] Edit rate (user modified AI output)\n- [ ] Follow-up query rate\n\n### 3. Cost Dashboard\nInsights to create:\n- [ ] Token cost per user\n- [ ] Cost by model\n- [ ] Cost by feature\n- [ ] Efficiency trends (value/cost)\n```\n\n---\n\n## Creating Dashboards\n\n### Using PostHog MCP\n\n```markdown\nWhen setting up analytics for a project:\n\n1. First, check existing dashboards:\n   - Use `dashboards-get-all` to list current dashboards\n\n2. Create project-appropriate dashboards:\n   - Use `dashboard-create` with descriptive name\n\n3. Create insights for each dashboard:\n   - Use `query-run` to test queries\n   - Use `insight-create-from-query` to save\n   - Use `add-insight-to-dashboard` to organize\n\n4. Set up key funnels:\n   - Signup funnel\n   - Onboarding funnel\n   - Purchase/conversion funnel\n```\n\n### Dashboard Creation Workflow\n\n```typescript\n// Example: Creating SaaS dashboards via MCP\n\n// 1. Create dashboard\nconst dashboard = await mcp_posthog_dashboard_create({\n  name: \"Activation Metrics\",\n  description: \"Track new user activation and onboarding\",\n  tags: [\"saas\", \"activation\"],\n});\n\n// 2. Create insights\nconst signupFunnel = await mcp_posthog_query_run({\n  query: {\n    kind: \"InsightVizNode\",\n    source: {\n      kind: \"FunnelsQuery\",\n      series: [\n        { kind: \"EventsNode\", event: \"user_signed_up\", name: \"Signed Up\" },\n        { kind: \"EventsNode\", event: \"onboarding_started\", name: \"Started Onboarding\" },\n        { kind: \"EventsNode\", event: \"onboarding_completed\", name: \"Completed Onboarding\" },\n        { kind: \"EventsNode\", event: \"first_value_action\", name: \"First Value\" },\n      ],\n      dateRange: { date_from: \"-30d\" },\n    },\n  },\n});\n\n// 3. Save and add to dashboard\nconst insight = await mcp_posthog_insight_create_from_query({\n  name: \"Signup to Activation Funnel\",\n  query: signupFunnel.query,\n  favorited: true,\n});\n\nawait mcp_posthog_add_insight_to_dashboard({\n  insightId: insight.id,\n  dashboardId: dashboard.id,\n});\n```\n\n---\n\n## Privacy & Compliance\n\n### GDPR Compliance\n\n```typescript\n// Opt-out handling\nexport function handleCookieConsent(consent: boolean) {\n  if (consent) {\n    posthog.opt_in_capturing();\n  } else {\n    posthog.opt_out_capturing();\n  }\n}\n\n// Check consent status\nconst hasConsent = posthog.has_opted_in_capturing();\n\n// Initialize with consent check\nposthog.init(key, {\n  opt_out_capturing_by_default: true, // Require explicit opt-in\n  respect_dnt: true, // Respect Do Not Track\n});\n```\n\n### Data to Never Track\n\n```typescript\n// ❌ NEVER track these\nposthog.capture('event', {\n  password: '...',           // Credentials\n  credit_card: '...',        // Payment info\n  ssn: '...',                // Government IDs\n  medical_info: '...',       // Health data\n  full_address: '...',       // Detailed location\n});\n\n// ✅ OK to track\nposthog.capture('event', {\n  country: 'US',             // General location\n  plan: 'pro',               // Product info\n  feature_used: 'export',    // Usage\n});\n```\n\n### Property Sanitization\n\n```typescript\n// lib/analytics.ts\nconst SENSITIVE_KEYS = ['password', 'token', 'secret', 'credit', 'ssn'];\n\nfunction sanitizeProperties(props: Record<string, any>): Record<string, any> {\n  return Object.fromEntries(\n    Object.entries(props).filter(([key]) =>\n      !SENSITIVE_KEYS.some(sensitive => key.toLowerCase().includes(sensitive))\n    )\n  );\n}\n\nexport function safeCapture(event: string, properties?: Record<string, any>) {\n  posthog.capture(event, sanitizeProperties(properties || {}));\n}\n```\n\n---\n\n## Testing Analytics\n\n### Development Mode\n\n```typescript\n// Disable in development\nif (process.env.NODE_ENV === 'development') {\n  posthog.opt_out_capturing();\n  // Or use debug mode\n  posthog.debug();\n}\n```\n\n### E2E Testing\n\n```typescript\n// playwright/fixtures.ts\nimport { test as base } from '@playwright/test';\n\nexport const test = base.extend({\n  page: async ({ page }, use) => {\n    // Mock PostHog to capture events\n    await page.addInitScript(() => {\n      window.capturedEvents = [];\n      window.posthog = {\n        capture: (event, props) => {\n          window.capturedEvents.push({ event, props });\n        },\n        identify: () => {},\n        reset: () => {},\n      };\n    });\n    await use(page);\n  },\n});\n\n// In tests\ntest('tracks signup event', async ({ page }) => {\n  await page.goto('/signup');\n  await page.fill('[name=email]', 'test@example.com');\n  await page.click('button[type=submit]');\n\n  const events = await page.evaluate(() => window.capturedEvents);\n  expect(events).toContainEqual({\n    event: 'user_signed_up',\n    props: expect.objectContaining({ signup_method: 'email' }),\n  });\n});\n```\n\n---\n\n## Debugging\n\n### PostHog Toolbar\n\n```typescript\n// Enable toolbar for debugging\nposthog.init(key, {\n  // ...\n  loaded: (posthog) => {\n    if (process.env.NODE_ENV === 'development') {\n      posthog.debug();\n      // Toolbar available via PostHog dashboard\n    }\n  },\n});\n```\n\n### Event Debugging\n\n```typescript\n// Log all events in development\nposthog.init(key, {\n  _onCapture: (eventName, eventData) => {\n    if (process.env.NODE_ENV === 'development') {\n      console.log('PostHog Event:', eventName, eventData);\n    }\n  },\n});\n```\n\n---\n\n## Quick Reference\n\n### Event Checklist by User Lifecycle\n\n```markdown\n## Must-Track Events\n\n### Acquisition\n- [ ] `page_viewed` (automatic with capture_pageview)\n- [ ] `user_signed_up`\n- [ ] `user_logged_in`\n\n### Activation\n- [ ] `onboarding_started`\n- [ ] `onboarding_step_completed`\n- [ ] `onboarding_completed`\n- [ ] `first_[key_action]` (your \"aha moment\")\n\n### Engagement\n- [ ] `[feature]_used`\n- [ ] `[resource]_created`\n- [ ] `search_performed`\n- [ ] `invite_sent`\n\n### Revenue\n- [ ] `pricing_page_viewed`\n- [ ] `checkout_started`\n- [ ] `subscription_upgraded`\n- [ ] `subscription_cancelled`\n\n### Retention\n- [ ] `session_started`\n- [ ] `feature_[x]_used` (power features)\n```\n\n### Dashboard Templates\n\n| Project Type | Key Dashboards |\n|--------------|----------------|\n| **SaaS** | Acquisition, Activation, Engagement, Retention, Revenue |\n| **E-Commerce** | Conversion Funnel, Product Performance, Customer LTV |\n| **Content** | Consumption, Engagement, Growth |\n| **AI/LLM** | Usage, Quality, Cost |\n| **Mobile App** | Installs, Onboarding, DAU/MAU, Crashes |\n\n### Properties to Always Include\n\n```typescript\n// Auto-enriched by PostHog\n$current_url\n$browser\n$device_type\n$os\n\n// Add these yourself\nuser_plan       // 'free' | 'pro' | 'enterprise'\nuser_role       // 'admin' | 'member'\ncompany_id      // For B2B\nfeature_context // Where in the app\n```\n"
  },
  {
    "path": "skills/project-tooling/SKILL.md",
    "content": "---\nname: project-tooling\ndescription: gh, vercel, supabase, render CLI and deployment platform setup\nwhen-to-use: When setting up deployment, CI/CD, or when CLI tools are needed\nuser-invocable: false\neffort: low\n---\n\n# Project Tooling Skill\n\n\nStandard CLI tools for project infrastructure management.\n\n---\n\n## Required CLI Tools\n\nBefore starting any project, verify these tools are installed and authenticated:\n\n### 1. GitHub CLI (gh)\n```bash\n# Verify installation\ngh --version\n\n# Verify authentication\ngh auth status\n\n# If not authenticated:\ngh auth login\n```\n\n### 2. Vercel CLI\n```bash\n# Verify installation\nvercel --version\n\n# Verify authentication\nvercel whoami\n\n# If not authenticated:\nvercel login\n```\n\n### 3. Supabase CLI\n```bash\n# Verify installation\nsupabase --version\n\n# Verify authentication (check if linked to a project or logged in)\nsupabase projects list\n\n# If not authenticated:\nsupabase login\n```\n\n### 4. Render CLI (optional - for Render deployments)\n```bash\n# Verify installation\nrender --version\n\n# If using Render API instead:\n# Ensure RENDER_API_KEY is set in environment\n```\n\n---\n\n## Validation Script\n\nRun this at project initialization to verify all tools:\n\n```bash\n#!/bin/bash\n# scripts/verify-tooling.sh\n\nset -e\n\necho \"Verifying project tooling...\"\n\n# GitHub CLI\nif command -v gh &> /dev/null; then\n  if gh auth status &> /dev/null; then\n    echo \"✓ GitHub CLI authenticated\"\n  else\n    echo \"✗ GitHub CLI not authenticated. Run: gh auth login\"\n    exit 1\n  fi\nelse\n  echo \"✗ GitHub CLI not installed. Run: brew install gh\"\n  exit 1\nfi\n\n# Vercel CLI\nif command -v vercel &> /dev/null; then\n  if vercel whoami &> /dev/null; then\n    echo \"✓ Vercel CLI authenticated\"\n  else\n    echo \"✗ Vercel CLI not authenticated. Run: vercel login\"\n    exit 1\n  fi\nelse\n  echo \"✗ Vercel CLI not installed. Run: npm i -g vercel\"\n  exit 1\nfi\n\n# Supabase CLI\nif command -v supabase &> /dev/null; then\n  if supabase projects list &> /dev/null; then\n    echo \"✓ Supabase CLI authenticated\"\n  else\n    echo \"✗ Supabase CLI not authenticated. Run: supabase login\"\n    exit 1\n  fi\nelse\n  echo \"✗ Supabase CLI not installed. Run: brew install supabase/tap/supabase\"\n  exit 1\nfi\n\necho \"\"\necho \"All tools verified!\"\n```\n\n---\n\n## GitHub Repository Setup\n\n### Create New Repository\n```bash\n# Create and push in one command\ngh repo create <repo-name> --private --source=. --remote=origin --push\n\n# Or public:\ngh repo create <repo-name> --public --source=. --remote=origin --push\n```\n\n### Connect Existing Repository\n```bash\n# If repo exists on GitHub but not linked locally\ngh repo clone <owner>/<repo>\n\n# Or add remote to existing local project\ngit remote add origin https://github.com/<owner>/<repo>.git\ngit push -u origin main\n```\n\n### Repository Settings\n```bash\n# Enable branch protection on main\ngh api repos/{owner}/{repo}/branches/main/protection -X PUT \\\n  -F required_status_checks='{\"strict\":true,\"contexts\":[\"quality\"]}' \\\n  -F enforce_admins=false \\\n  -F required_pull_request_reviews='{\"required_approving_review_count\":1}'\n\n# Set default branch\ngh repo edit --default-branch main\n```\n\n---\n\n## Vercel Deployment\n\n### Link Project\n```bash\n# Link current directory to Vercel project\nvercel link\n\n# Or create new project\nvercel\n```\n\n### Environment Variables\n```bash\n# Add environment variable\nvercel env add ANTHROPIC_API_KEY production\n\n# Pull env vars to local .env\nvercel env pull .env.local\n```\n\n### Deploy\n```bash\n# Deploy to preview\nvercel\n\n# Deploy to production\nvercel --prod\n```\n\n---\n\n## Supabase Setup\n\n### Create New Project\n```bash\n# Create project (interactive)\nsupabase projects create <project-name> --org-id <org-id>\n\n# Link local to remote\nsupabase link --project-ref <project-ref>\n```\n\n### Local Development\n```bash\n# Start local Supabase\nsupabase start\n\n# Stop local Supabase\nsupabase stop\n\n# Reset database (apply all migrations fresh)\nsupabase db reset\n```\n\n### Migrations\n```bash\n# Create new migration\nsupabase migration new <migration-name>\n\n# Apply migrations to remote\nsupabase db push\n\n# Pull remote schema to local\nsupabase db pull\n```\n\n### Generate Types\n```bash\n# Generate TypeScript types from schema\nsupabase gen types typescript --local > src/types/database.ts\n\n# Or from remote\nsupabase gen types typescript --project-id <ref> > src/types/database.ts\n```\n\n---\n\n## Render Setup (API-based)\n\n### Environment\n```bash\n# Set API key\nexport RENDER_API_KEY=<your-api-key>\n```\n\n### Common Operations via API\n```bash\n# List services\ncurl -H \"Authorization: Bearer $RENDER_API_KEY\" \\\n  https://api.render.com/v1/services\n\n# Trigger deploy\ncurl -X POST -H \"Authorization: Bearer $RENDER_API_KEY\" \\\n  https://api.render.com/v1/services/<service-id>/deploys\n\n# Get deploy status\ncurl -H \"Authorization: Bearer $RENDER_API_KEY\" \\\n  https://api.render.com/v1/services/<service-id>/deploys/<deploy-id>\n```\n\n---\n\n## Package.json Scripts\n\nAdd these scripts for common operations:\n\n```json\n{\n  \"scripts\": {\n    \"verify-tools\": \"./scripts/verify-tooling.sh\",\n    \"deploy:preview\": \"vercel\",\n    \"deploy:prod\": \"vercel --prod\",\n    \"db:start\": \"supabase start\",\n    \"db:stop\": \"supabase stop\",\n    \"db:reset\": \"supabase db reset\",\n    \"db:migrate\": \"supabase db push\",\n    \"db:types\": \"supabase gen types typescript --local > src/types/database.ts\"\n  }\n}\n```\n\n---\n\n## CI/CD Integration\n\n### GitHub Actions with Vercel\n```yaml\n# .github/workflows/deploy.yml\nname: Deploy\n\non:\n  push:\n    branches: [main]\n  pull_request:\n    branches: [main]\n\njobs:\n  deploy:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Deploy to Vercel\n        uses: amondnet/vercel-action@v25\n        with:\n          vercel-token: ${{ secrets.VERCEL_TOKEN }}\n          vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}\n          vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}\n          vercel-args: ${{ github.ref == 'refs/heads/main' && '--prod' || '' }}\n```\n\n### GitHub Actions with Supabase\n```yaml\n# .github/workflows/migrate.yml\nname: Migrate Database\n\non:\n  push:\n    branches: [main]\n    paths:\n      - 'supabase/migrations/**'\n\njobs:\n  migrate:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Setup Supabase CLI\n        uses: supabase/setup-cli@v1\n        with:\n          version: latest\n\n      - name: Push migrations\n        run: supabase db push\n        env:\n          SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}\n          SUPABASE_DB_PASSWORD: ${{ secrets.SUPABASE_DB_PASSWORD }}\n```\n\n---\n\n## Deployment Platform Setup\n\n**REQUIRED**: When initializing a project, always create todos for deployment platform connection based on the stack.\n\n### Platform Selection by Stack\n\n| Stack | Default Platform | Action Required |\n|-------|-----------------|-----------------|\n| Next.js / Node.js | **Vercel** | Connect Git repo to Vercel |\n| Python (FastAPI, Flask) | **Render** | Connect Git repo to Render, get API key |\n| Static sites | **Vercel** or **Cloudflare Pages** | Connect Git repo |\n\n### Vercel: Connect Git Repository\n\nWhen Vercel is the deployment platform, create this todo:\n```\nTODO: Connect Git repository to Vercel for automatic deployments\n```\n\nSteps:\n```bash\n# Option 1: Via CLI\nvercel link\nvercel git connect\n\n# Option 2: Via Dashboard (recommended for first setup)\n# 1. Go to vercel.com/new\n# 2. Import Git repository\n# 3. Configure project settings\n# 4. Deploy\n```\n\nAfter connecting:\n- Push to `main` → Production deploy\n- Push to other branches → Preview deploy\n- PRs get deploy previews automatically\n\n### Render: Connect Git Repository (Python)\n\nWhen Render is the deployment platform for Python projects:\n\n**Step 1: Ask user for Render API key**\n```\nBefore proceeding, please provide your Render API key.\nGet it from: https://dashboard.render.com/u/settings/api-keys\n\nStore it securely - we'll add it to your environment.\n```\n\n**Step 2: Create todos**\n```\nTODO: Get Render API key from user\nTODO: Connect Git repository to Render\nTODO: Configure Render service (web service or background worker)\nTODO: Set environment variables on Render\n```\n\n**Step 3: Connect via Dashboard (recommended)**\n```bash\n# 1. Go to dashboard.render.com/create\n# 2. Select \"Web Service\" for APIs, \"Background Worker\" for async\n# 3. Connect your GitHub/GitLab repository\n# 4. Configure:\n#    - Name: <project-name>\n#    - Runtime: Python 3\n#    - Build Command: pip install -r requirements.txt\n#    - Start Command: uvicorn main:app --host 0.0.0.0 --port $PORT\n```\n\n**Step 4: Store API key for CI/CD**\n```bash\n# Add to GitHub secrets for CI/CD\ngh secret set RENDER_API_KEY\n\n# Or add to local env\necho \"RENDER_API_KEY=<your-key>\" >> .env\n```\n\n**Step 5: Configure render.yaml (optional - Infrastructure as Code)**\n```yaml\n# render.yaml\nservices:\n  - type: web\n    name: <project-name>-api\n    runtime: python\n    buildCommand: pip install -r requirements.txt\n    startCommand: uvicorn main:app --host 0.0.0.0 --port $PORT\n    envVars:\n      - key: PYTHON_VERSION\n        value: \"3.11\"\n      - key: DATABASE_URL\n        fromDatabase:\n          name: <project-name>-db\n          property: connectionString\n\ndatabases:\n  - name: <project-name>-db\n    plan: free\n```\n\n### Deployment Checklist Template\n\nAdd to project todos when setting up deployment:\n\n```markdown\n## Deployment Setup\n- [ ] Create Git repository (gh repo create)\n- [ ] Choose deployment platform (Vercel/Render/other)\n- [ ] Connect Git to deployment platform\n- [ ] Configure environment variables\n- [ ] Set up CI/CD workflow\n- [ ] Verify preview deployments work\n- [ ] Configure production domain\n```\n\n---\n\n## Tooling Anti-Patterns\n\n- ❌ Hardcoded secrets - use CLI env management or GitHub secrets\n- ❌ Manual deployments - automate via CI/CD\n- ❌ Skipping local Supabase - always develop locally first\n- ❌ Direct production database changes - use migrations\n- ❌ No branch protection - require PR reviews and CI checks\n- ❌ Missing environment separation - keep dev/staging/prod separate\n"
  },
  {
    "path": "skills/pwa-development/SKILL.md",
    "content": "---\nname: pwa-development\ndescription: Progressive Web Apps - service workers, caching strategies, offline, Workbox\nwhen-to-use: When building PWA features - service workers, caching, offline support\nuser-invocable: false\npaths: [\"**/sw.*\", \"**/service-worker.*\", \"**/workbox-config.*\", \"**/manifest.json\"]\neffort: medium\n---\n\n# PWA Development Skill\n\n\n**Purpose:** Build Progressive Web Apps that work offline, install like native apps, and deliver fast, reliable experiences across all devices.\n\n---\n\n## Core PWA Requirements\n\n```\n┌─────────────────────────────────────────────────────────────────┐\n│  THE THREE PILLARS OF PWA                                       │\n│  ─────────────────────────────────────────────────────────────  │\n│                                                                 │\n│  1. HTTPS                                                       │\n│     Required for service workers and security.                  │\n│     localhost allowed for development.                          │\n│                                                                 │\n│  2. SERVICE WORKER                                              │\n│     JavaScript that runs in background.                         │\n│     Enables offline, caching, push notifications.               │\n│                                                                 │\n│  3. WEB APP MANIFEST                                            │\n│     JSON file describing app metadata.                          │\n│     Enables installation and app-like experience.               │\n├─────────────────────────────────────────────────────────────────┤\n│  INSTALLABILITY CRITERIA (Chrome)                               │\n│  ─────────────────────────────────────────────────────────────  │\n│  • HTTPS (or localhost)                                         │\n│  • Service worker with fetch handler                            │\n│  • Web app manifest with: name, icons (192px + 512px),          │\n│    start_url, display: standalone/fullscreen/minimal-ui         │\n└─────────────────────────────────────────────────────────────────┘\n```\n\n---\n\n## Web App Manifest\n\n### Required Fields\n\n```json\n{\n  \"name\": \"My Progressive Web App\",\n  \"short_name\": \"MyPWA\",\n  \"description\": \"A description of what the app does\",\n  \"start_url\": \"/\",\n  \"display\": \"standalone\",\n  \"background_color\": \"#ffffff\",\n  \"theme_color\": \"#000000\",\n  \"icons\": [\n    {\n      \"src\": \"/icons/icon-192.png\",\n      \"sizes\": \"192x192\",\n      \"type\": \"image/png\"\n    },\n    {\n      \"src\": \"/icons/icon-512.png\",\n      \"sizes\": \"512x512\",\n      \"type\": \"image/png\"\n    },\n    {\n      \"src\": \"/icons/icon-512-maskable.png\",\n      \"sizes\": \"512x512\",\n      \"type\": \"image/png\",\n      \"purpose\": \"maskable\"\n    }\n  ]\n}\n```\n\n### Enhanced Manifest (Full Features)\n\n```json\n{\n  \"name\": \"My Progressive Web App\",\n  \"short_name\": \"MyPWA\",\n  \"description\": \"A full-featured PWA\",\n  \"start_url\": \"/?source=pwa\",\n  \"scope\": \"/\",\n  \"display\": \"standalone\",\n  \"orientation\": \"portrait-primary\",\n  \"background_color\": \"#ffffff\",\n  \"theme_color\": \"#3367D6\",\n  \"dir\": \"ltr\",\n  \"lang\": \"en\",\n  \"categories\": [\"productivity\", \"utilities\"],\n\n  \"icons\": [\n    { \"src\": \"/icons/icon-72.png\", \"sizes\": \"72x72\", \"type\": \"image/png\" },\n    { \"src\": \"/icons/icon-96.png\", \"sizes\": \"96x96\", \"type\": \"image/png\" },\n    { \"src\": \"/icons/icon-128.png\", \"sizes\": \"128x128\", \"type\": \"image/png\" },\n    { \"src\": \"/icons/icon-144.png\", \"sizes\": \"144x144\", \"type\": \"image/png\" },\n    { \"src\": \"/icons/icon-152.png\", \"sizes\": \"152x152\", \"type\": \"image/png\" },\n    { \"src\": \"/icons/icon-192.png\", \"sizes\": \"192x192\", \"type\": \"image/png\" },\n    { \"src\": \"/icons/icon-384.png\", \"sizes\": \"384x384\", \"type\": \"image/png\" },\n    { \"src\": \"/icons/icon-512.png\", \"sizes\": \"512x512\", \"type\": \"image/png\" },\n    { \"src\": \"/icons/icon-maskable.png\", \"sizes\": \"512x512\", \"type\": \"image/png\", \"purpose\": \"maskable\" }\n  ],\n\n  \"screenshots\": [\n    {\n      \"src\": \"/screenshots/desktop.png\",\n      \"sizes\": \"1280x720\",\n      \"type\": \"image/png\",\n      \"form_factor\": \"wide\"\n    },\n    {\n      \"src\": \"/screenshots/mobile.png\",\n      \"sizes\": \"750x1334\",\n      \"type\": \"image/png\",\n      \"form_factor\": \"narrow\"\n    }\n  ],\n\n  \"shortcuts\": [\n    {\n      \"name\": \"New Item\",\n      \"short_name\": \"New\",\n      \"description\": \"Create a new item\",\n      \"url\": \"/new?source=shortcut\",\n      \"icons\": [{ \"src\": \"/icons/shortcut-new.png\", \"sizes\": \"192x192\" }]\n    }\n  ],\n\n  \"share_target\": {\n    \"action\": \"/share\",\n    \"method\": \"POST\",\n    \"enctype\": \"multipart/form-data\",\n    \"params\": {\n      \"title\": \"title\",\n      \"text\": \"text\",\n      \"url\": \"url\",\n      \"files\": [{ \"name\": \"files\", \"accept\": [\"image/*\"] }]\n    }\n  },\n\n  \"protocol_handlers\": [\n    {\n      \"protocol\": \"web+myapp\",\n      \"url\": \"/handle?url=%s\"\n    }\n  ],\n\n  \"file_handlers\": [\n    {\n      \"action\": \"/open-file\",\n      \"accept\": {\n        \"text/plain\": [\".txt\"]\n      }\n    }\n  ]\n}\n```\n\n### Manifest Checklist\n\n- [ ] `name` and `short_name` defined\n- [ ] `start_url` set (use query param for analytics)\n- [ ] `display` set to `standalone` or `fullscreen`\n- [ ] Icons: 192x192 and 512x512 minimum\n- [ ] Maskable icon included for Android adaptive icons\n- [ ] `theme_color` matches app design\n- [ ] `background_color` for splash screen\n- [ ] Screenshots for richer install UI (optional)\n- [ ] Shortcuts for quick actions (optional)\n\n---\n\n## Service Worker Patterns\n\n### Basic Service Worker\n\n```javascript\n// sw.js\nconst CACHE_NAME = 'app-cache-v1';\nconst STATIC_ASSETS = [\n  '/',\n  '/index.html',\n  '/styles/main.css',\n  '/scripts/app.js',\n  '/offline.html'\n];\n\n// Install: Cache static assets\nself.addEventListener('install', (event) => {\n  event.waitUntil(\n    caches.open(CACHE_NAME)\n      .then((cache) => cache.addAll(STATIC_ASSETS))\n      .then(() => self.skipWaiting())\n  );\n});\n\n// Activate: Clean old caches\nself.addEventListener('activate', (event) => {\n  event.waitUntil(\n    caches.keys()\n      .then((keys) => Promise.all(\n        keys\n          .filter((key) => key !== CACHE_NAME)\n          .map((key) => caches.delete(key))\n      ))\n      .then(() => self.clients.claim())\n  );\n});\n\n// Fetch: Serve from cache, fall back to network\nself.addEventListener('fetch', (event) => {\n  event.respondWith(\n    caches.match(event.request)\n      .then((cached) => cached || fetch(event.request))\n      .catch(() => caches.match('/offline.html'))\n  );\n});\n```\n\n### Registration\n\n```javascript\n// main.js\nif ('serviceWorker' in navigator) {\n  window.addEventListener('load', async () => {\n    try {\n      const registration = await navigator.serviceWorker.register('/sw.js', {\n        scope: '/'\n      });\n      console.log('SW registered:', registration.scope);\n    } catch (error) {\n      console.error('SW registration failed:', error);\n    }\n  });\n}\n```\n\n---\n\n## Caching Strategies\n\n### Strategy Selection Guide\n\n| Strategy | Use Case | Description |\n|----------|----------|-------------|\n| **Cache First** | Static assets (CSS, JS, images) | Check cache, fall back to network |\n| **Network First** | API responses, dynamic content | Try network, fall back to cache |\n| **Stale While Revalidate** | Semi-static content (avatars, articles) | Serve cache immediately, update in background |\n| **Network Only** | Non-cacheable requests (analytics) | Always use network |\n| **Cache Only** | Offline-only assets | Only serve from cache |\n\n### Cache First (Offline First)\n\n```javascript\n// Best for: Static assets that rarely change\nself.addEventListener('fetch', (event) => {\n  if (event.request.destination === 'image' ||\n      event.request.destination === 'style' ||\n      event.request.destination === 'script') {\n    event.respondWith(\n      caches.match(event.request)\n        .then((cached) => {\n          if (cached) return cached;\n          return fetch(event.request).then((response) => {\n            const clone = response.clone();\n            caches.open(CACHE_NAME).then((cache) => {\n              cache.put(event.request, clone);\n            });\n            return response;\n          });\n        })\n    );\n  }\n});\n```\n\n### Network First (Fresh First)\n\n```javascript\n// Best for: API data, frequently updated content\nself.addEventListener('fetch', (event) => {\n  if (event.request.url.includes('/api/')) {\n    event.respondWith(\n      fetch(event.request)\n        .then((response) => {\n          const clone = response.clone();\n          caches.open(CACHE_NAME).then((cache) => {\n            cache.put(event.request, clone);\n          });\n          return response;\n        })\n        .catch(() => caches.match(event.request))\n    );\n  }\n});\n```\n\n### Stale While Revalidate\n\n```javascript\n// Best for: Content that's okay to be slightly outdated\nself.addEventListener('fetch', (event) => {\n  if (event.request.url.includes('/articles/')) {\n    event.respondWith(\n      caches.open(CACHE_NAME).then((cache) => {\n        return cache.match(event.request).then((cached) => {\n          const fetchPromise = fetch(event.request).then((response) => {\n            cache.put(event.request, response.clone());\n            return response;\n          });\n          return cached || fetchPromise;\n        });\n      })\n    );\n  }\n});\n```\n\n---\n\n## Workbox (Recommended)\n\n### Why Workbox?\n\n- Battle-tested caching strategies\n- Precaching with revision management\n- Background sync for offline forms\n- Automatic cache cleanup\n- TypeScript support\n\n### Installation\n\n```bash\nnpm install workbox-webpack-plugin  # Webpack\nnpm install @vite-pwa/vite-plugin   # Vite\n```\n\n### Workbox with Vite\n\n```javascript\n// vite.config.js\nimport { VitePWA } from 'vite-plugin-pwa';\n\nexport default {\n  plugins: [\n    VitePWA({\n      registerType: 'autoUpdate',\n      includeAssets: ['favicon.ico', 'robots.txt', 'apple-touch-icon.png'],\n      manifest: {\n        name: 'My App',\n        short_name: 'App',\n        theme_color: '#ffffff',\n        icons: [\n          { src: 'pwa-192x192.png', sizes: '192x192', type: 'image/png' },\n          { src: 'pwa-512x512.png', sizes: '512x512', type: 'image/png' }\n        ]\n      },\n      workbox: {\n        globPatterns: ['**/*.{js,css,html,ico,png,svg}'],\n        runtimeCaching: [\n          {\n            urlPattern: /^https:\\/\\/api\\.example\\.com\\/.*/i,\n            handler: 'NetworkFirst',\n            options: {\n              cacheName: 'api-cache',\n              expiration: {\n                maxEntries: 100,\n                maxAgeSeconds: 60 * 60 * 24 // 24 hours\n              }\n            }\n          },\n          {\n            urlPattern: /\\.(?:png|jpg|jpeg|svg|gif)$/,\n            handler: 'CacheFirst',\n            options: {\n              cacheName: 'image-cache',\n              expiration: {\n                maxEntries: 50,\n                maxAgeSeconds: 60 * 60 * 24 * 30 // 30 days\n              }\n            }\n          }\n        ]\n      }\n    })\n  ]\n};\n```\n\n### Workbox Manual Service Worker\n\n```javascript\n// sw.js\nimport { precacheAndRoute } from 'workbox-precaching';\nimport { registerRoute } from 'workbox-routing';\nimport { CacheFirst, NetworkFirst, StaleWhileRevalidate } from 'workbox-strategies';\nimport { ExpirationPlugin } from 'workbox-expiration';\nimport { CacheableResponsePlugin } from 'workbox-cacheable-response';\n\n// Precache static assets (generated by build tool)\nprecacheAndRoute(self.__WB_MANIFEST);\n\n// Cache images\nregisterRoute(\n  ({ request }) => request.destination === 'image',\n  new CacheFirst({\n    cacheName: 'images',\n    plugins: [\n      new CacheableResponsePlugin({ statuses: [0, 200] }),\n      new ExpirationPlugin({\n        maxEntries: 60,\n        maxAgeSeconds: 30 * 24 * 60 * 60 // 30 days\n      })\n    ]\n  })\n);\n\n// Cache API responses\nregisterRoute(\n  ({ url }) => url.pathname.startsWith('/api/'),\n  new NetworkFirst({\n    cacheName: 'api-responses',\n    plugins: [\n      new CacheableResponsePlugin({ statuses: [0, 200] }),\n      new ExpirationPlugin({\n        maxEntries: 100,\n        maxAgeSeconds: 24 * 60 * 60 // 24 hours\n      })\n    ]\n  })\n);\n\n// Cache page navigations\nregisterRoute(\n  ({ request }) => request.mode === 'navigate',\n  new NetworkFirst({\n    cacheName: 'pages',\n    plugins: [\n      new CacheableResponsePlugin({ statuses: [0, 200] })\n    ]\n  })\n);\n```\n\n---\n\n## Offline Experience\n\n### Offline Page\n\n```html\n<!-- offline.html -->\n<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n  <meta charset=\"UTF-8\">\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n  <title>Offline - App Name</title>\n  <style>\n    body {\n      font-family: system-ui, sans-serif;\n      display: flex;\n      align-items: center;\n      justify-content: center;\n      min-height: 100vh;\n      margin: 0;\n      background: #f5f5f5;\n    }\n    .offline-content {\n      text-align: center;\n      padding: 2rem;\n    }\n    .offline-icon { font-size: 4rem; }\n    h1 { color: #333; }\n    p { color: #666; }\n    button {\n      background: #3367D6;\n      color: white;\n      border: none;\n      padding: 0.75rem 1.5rem;\n      border-radius: 4px;\n      cursor: pointer;\n      font-size: 1rem;\n    }\n  </style>\n</head>\n<body>\n  <div class=\"offline-content\">\n    <div class=\"offline-icon\">📡</div>\n    <h1>You're offline</h1>\n    <p>Check your connection and try again.</p>\n    <button onclick=\"location.reload()\">Retry</button>\n  </div>\n</body>\n</html>\n```\n\n### Offline Detection\n\n```javascript\n// Online/offline status handling\nfunction updateOnlineStatus() {\n  const status = navigator.onLine ? 'online' : 'offline';\n  document.body.dataset.connectionStatus = status;\n\n  if (!navigator.onLine) {\n    showNotification('You are offline. Some features may be unavailable.');\n  }\n}\n\nwindow.addEventListener('online', updateOnlineStatus);\nwindow.addEventListener('offline', updateOnlineStatus);\nupdateOnlineStatus();\n```\n\n### Background Sync (Queue Offline Actions)\n\n```javascript\n// sw.js with Workbox\nimport { BackgroundSyncPlugin } from 'workbox-background-sync';\nimport { registerRoute } from 'workbox-routing';\nimport { NetworkOnly } from 'workbox-strategies';\n\nconst bgSyncPlugin = new BackgroundSyncPlugin('formQueue', {\n  maxRetentionTime: 24 * 60 // Retry for 24 hours\n});\n\nregisterRoute(\n  ({ url }) => url.pathname === '/api/submit',\n  new NetworkOnly({\n    plugins: [bgSyncPlugin]\n  }),\n  'POST'\n);\n```\n\n```javascript\n// main.js - Queue form submission\nasync function submitForm(data) {\n  try {\n    const response = await fetch('/api/submit', {\n      method: 'POST',\n      headers: { 'Content-Type': 'application/json' },\n      body: JSON.stringify(data)\n    });\n    return response.json();\n  } catch (error) {\n    // Will be retried by background sync when online\n    showNotification('Saved offline. Will sync when connected.');\n  }\n}\n```\n\n---\n\n## App-Like Features\n\n### Install Prompt\n\n```javascript\nlet deferredPrompt;\n\nwindow.addEventListener('beforeinstallprompt', (e) => {\n  e.preventDefault();\n  deferredPrompt = e;\n  showInstallButton();\n});\n\nasync function installApp() {\n  if (!deferredPrompt) return;\n\n  deferredPrompt.prompt();\n  const { outcome } = await deferredPrompt.userChoice;\n\n  console.log(`User ${outcome === 'accepted' ? 'accepted' : 'dismissed'} install`);\n  deferredPrompt = null;\n  hideInstallButton();\n}\n\nwindow.addEventListener('appinstalled', () => {\n  console.log('App installed');\n  deferredPrompt = null;\n});\n```\n\n### Detecting Standalone Mode\n\n```javascript\n// Check if running as installed PWA\nfunction isInstalledPWA() {\n  return window.matchMedia('(display-mode: standalone)').matches ||\n         window.navigator.standalone === true; // iOS\n}\n\n// Listen for display mode changes\nwindow.matchMedia('(display-mode: standalone)')\n  .addEventListener('change', (e) => {\n    console.log('Display mode:', e.matches ? 'standalone' : 'browser');\n  });\n```\n\n### Push Notifications\n\n```javascript\n// Request permission\nasync function requestNotificationPermission() {\n  const permission = await Notification.requestPermission();\n  if (permission === 'granted') {\n    await subscribeToPush();\n  }\n  return permission;\n}\n\n// Subscribe to push\nasync function subscribeToPush() {\n  const registration = await navigator.serviceWorker.ready;\n  const subscription = await registration.pushManager.subscribe({\n    userVisibleOnly: true,\n    applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY)\n  });\n\n  // Send subscription to server\n  await fetch('/api/push/subscribe', {\n    method: 'POST',\n    headers: { 'Content-Type': 'application/json' },\n    body: JSON.stringify(subscription)\n  });\n}\n\n// sw.js - Handle push events\nself.addEventListener('push', (event) => {\n  const data = event.data.json();\n  event.waitUntil(\n    self.registration.showNotification(data.title, {\n      body: data.body,\n      icon: '/icons/icon-192.png',\n      badge: '/icons/badge-72.png',\n      data: { url: data.url }\n    })\n  );\n});\n\n// Handle notification click\nself.addEventListener('notificationclick', (event) => {\n  event.notification.close();\n  event.waitUntil(\n    clients.openWindow(event.notification.data.url)\n  );\n});\n```\n\n### Share Target\n\n```javascript\n// sw.js - Handle share target\nself.addEventListener('fetch', (event) => {\n  if (event.request.url.endsWith('/share') &&\n      event.request.method === 'POST') {\n    event.respondWith((async () => {\n      const formData = await event.request.formData();\n      const title = formData.get('title');\n      const text = formData.get('text');\n      const url = formData.get('url');\n\n      // Store or process shared content\n      // Redirect to app with shared data\n      return Response.redirect(`/?shared=true&title=${encodeURIComponent(title)}`);\n    })());\n  }\n});\n```\n\n---\n\n## Performance Optimization\n\n### Critical Rendering Path\n\n```html\n<!-- Inline critical CSS -->\n<style>\n  /* Critical above-the-fold styles */\n</style>\n\n<!-- Preload important resources -->\n<link rel=\"preload\" href=\"/fonts/main.woff2\" as=\"font\" type=\"font/woff2\" crossorigin>\n<link rel=\"preload\" href=\"/scripts/app.js\" as=\"script\">\n\n<!-- Defer non-critical CSS -->\n<link rel=\"stylesheet\" href=\"/styles/main.css\" media=\"print\" onload=\"this.media='all'\">\n<noscript><link rel=\"stylesheet\" href=\"/styles/main.css\"></noscript>\n```\n\n### Image Optimization\n\n```html\n<!-- Responsive images -->\n<img\n  src=\"/images/hero-800.webp\"\n  srcset=\"\n    /images/hero-400.webp 400w,\n    /images/hero-800.webp 800w,\n    /images/hero-1200.webp 1200w\n  \"\n  sizes=\"(max-width: 600px) 400px, (max-width: 1200px) 800px, 1200px\"\n  alt=\"Hero image\"\n  loading=\"lazy\"\n  decoding=\"async\"\n>\n\n<!-- Modern formats with fallback -->\n<picture>\n  <source srcset=\"/images/hero.avif\" type=\"image/avif\">\n  <source srcset=\"/images/hero.webp\" type=\"image/webp\">\n  <img src=\"/images/hero.jpg\" alt=\"Hero image\" loading=\"lazy\">\n</picture>\n```\n\n### Code Splitting\n\n```javascript\n// Dynamic imports for route-based splitting\nconst routes = {\n  '/': () => import('./pages/Home.js'),\n  '/about': () => import('./pages/About.js'),\n  '/settings': () => import('./pages/Settings.js')\n};\n\nasync function loadPage(path) {\n  const loader = routes[path];\n  if (loader) {\n    const module = await loader();\n    return module.default;\n  }\n}\n```\n\n---\n\n## Testing PWA\n\n### Lighthouse Audit\n\n```bash\n# Run Lighthouse from CLI\nnpx lighthouse https://your-app.com --view\n\n# Key metrics to check:\n# - PWA badge (installable, offline-ready)\n# - Performance score\n# - Best practices\n# - Accessibility\n```\n\n### Manual Testing Checklist\n\n- [ ] **Installability**\n  - [ ] Install prompt appears on desktop Chrome\n  - [ ] Can be added to home screen on mobile\n  - [ ] App opens in standalone mode after install\n\n- [ ] **Offline Support**\n  - [ ] App loads when offline (airplane mode)\n  - [ ] Cached pages display correctly\n  - [ ] Offline fallback page shows for uncached routes\n  - [ ] Background sync works when coming back online\n\n- [ ] **Performance**\n  - [ ] First Contentful Paint < 1.8s\n  - [ ] Largest Contentful Paint < 2.5s\n  - [ ] Time to Interactive < 3.8s\n  - [ ] Cumulative Layout Shift < 0.1\n\n- [ ] **Service Worker**\n  - [ ] SW registers successfully\n  - [ ] Static assets cached on install\n  - [ ] SW updates correctly (new version)\n  - [ ] No stale cache issues\n\n- [ ] **Manifest**\n  - [ ] All required fields present\n  - [ ] Icons display correctly\n  - [ ] Theme color applied\n  - [ ] Splash screen shows on launch\n\n### Testing Service Worker Updates\n\n```javascript\n// Force update check\nif ('serviceWorker' in navigator) {\n  navigator.serviceWorker.ready.then((registration) => {\n    registration.update();\n  });\n}\n\n// Listen for updates\nnavigator.serviceWorker.addEventListener('controllerchange', () => {\n  // New service worker activated\n  window.location.reload();\n});\n```\n\n---\n\n## Project Structure\n\n```\nproject/\n├── public/\n│   ├── manifest.json           # Web app manifest\n│   ├── sw.js                   # Service worker (if not bundled)\n│   ├── offline.html            # Offline fallback page\n│   ├── robots.txt\n│   └── icons/\n│       ├── icon-72.png\n│       ├── icon-96.png\n│       ├── icon-128.png\n│       ├── icon-144.png\n│       ├── icon-152.png\n│       ├── icon-192.png\n│       ├── icon-384.png\n│       ├── icon-512.png\n│       ├── icon-maskable.png   # For adaptive icons\n│       ├── apple-touch-icon.png\n│       └── favicon.ico\n├── src/\n│   ├── sw.js                   # Service worker source (if bundled)\n│   ├── pwa/\n│   │   ├── install.js          # Install prompt handling\n│   │   ├── offline.js          # Offline detection\n│   │   └── push.js             # Push notification handling\n│   └── ...\n└── tests/\n    └── pwa/\n        ├── manifest.test.js\n        ├── sw.test.js\n        └── offline.test.js\n```\n\n---\n\n## Common Mistakes\n\n| Mistake | Fix |\n|---------|-----|\n| Missing maskable icon | Add icon with `\"purpose\": \"maskable\"` |\n| No offline fallback | Create `offline.html` and cache it |\n| Cache never expires | Use `ExpirationPlugin` with Workbox |\n| SW caches too aggressively | Use appropriate strategies per resource type |\n| No update mechanism | Implement `skipWaiting()` + reload prompt |\n| Broken install prompt | Ensure manifest meets all criteria |\n| No HTTPS in production | Configure SSL certificate |\n| Large cache size | Set `maxEntries` and `maxAgeSeconds` |\n| Stale API responses | Use `NetworkFirst` for dynamic data |\n| Missing start_url tracking | Add query param: `/?source=pwa` |\n\n---\n\n## PWA Development Checklist\n\n### Before Launch\n\n- [ ] HTTPS configured (production)\n- [ ] Manifest complete with all required fields\n- [ ] Icons in all required sizes (192, 512, maskable)\n- [ ] Service worker registered and working\n- [ ] Offline page created and cached\n- [ ] Cache strategies defined for all resource types\n- [ ] Install prompt handling implemented\n- [ ] Lighthouse PWA audit passes\n\n### After Launch\n\n- [ ] Monitor cache sizes\n- [ ] Test SW updates don't break app\n- [ ] Track PWA installs via analytics\n- [ ] Test on multiple devices/browsers\n- [ ] Monitor Core Web Vitals\n- [ ] Set up push notification flow (if needed)\n\n---\n\n## Framework-Specific Guides\n\n### Next.js\n\n```bash\nnpm install next-pwa\n```\n\n```javascript\n// next.config.js\nconst withPWA = require('next-pwa')({\n  dest: 'public',\n  disable: process.env.NODE_ENV === 'development'\n});\n\nmodule.exports = withPWA({\n  // Your Next.js config\n});\n```\n\n### Create React App\n\n```bash\n# CRA 4+ has PWA support built-in\nnpx create-react-app my-pwa --template cra-template-pwa\n```\n\n### Vite (Any Framework)\n\n```bash\nnpm install vite-plugin-pwa -D\n```\n\nSee Workbox with Vite section above for configuration.\n\n---\n\n## Quick Reference\n\n### Caching Strategy Cheat Sheet\n\n```\nStatic Assets (CSS, JS, images)     → Cache First\nAPI Responses                        → Network First\nUser-generated content              → Stale While Revalidate\nAnalytics, non-cacheable            → Network Only\nOffline-only assets                 → Cache Only\n```\n\n### Manifest Minimum Requirements\n\n```json\n{\n  \"name\": \"App Name\",\n  \"short_name\": \"App\",\n  \"start_url\": \"/\",\n  \"display\": \"standalone\",\n  \"icons\": [\n    { \"src\": \"/icon-192.png\", \"sizes\": \"192x192\", \"type\": \"image/png\" },\n    { \"src\": \"/icon-512.png\", \"sizes\": \"512x512\", \"type\": \"image/png\" }\n  ]\n}\n```\n\n### Service Worker Lifecycle\n\n```\n1. Register → 2. Install → 3. Activate → 4. Fetch\n     ↓              ↓            ↓           ↓\n  Load app    Cache assets  Clean old   Serve requests\n                            caches      from cache/network\n```\n"
  },
  {
    "path": "skills/python/SKILL.md",
    "content": "---\nname: python\ndescription: Python development with ruff, mypy, pytest - TDD and type safety\nwhen-to-use: When working on Python files\nuser-invocable: false\npaths: [\"**/*.py\", \"pyproject.toml\", \"setup.py\", \"requirements*.txt\"]\neffort: medium\n---\n\n# Python Skill\n\n\n---\n\n## Type Hints\n\n- Use type hints on all function signatures\n- Use `typing` module for complex types\n- Run `mypy --strict` in CI\n\n```python\ndef process_user(user_id: int, options: dict[str, Any] | None = None) -> User:\n    ...\n```\n\n---\n\n## Project Structure\n\n```\nproject/\n├── src/\n│   └── package_name/\n│       ├── __init__.py\n│       ├── core/           # Pure business logic\n│       │   ├── __init__.py\n│       │   ├── models.py   # Pydantic models / dataclasses\n│       │   └── services.py # Pure functions\n│       ├── infra/          # Side effects\n│       │   ├── __init__.py\n│       │   ├── api.py      # FastAPI routes\n│       │   └── db.py       # Database operations\n│       └── utils/          # Shared utilities\n├── tests/\n│   ├── unit/\n│   └── integration/\n├── pyproject.toml\n└── CLAUDE.md\n```\n\n---\n\n## Tooling (Required)\n\n```toml\n# pyproject.toml\n[tool.ruff]\nline-length = 100\nselect = [\"E\", \"F\", \"I\", \"N\", \"W\", \"UP\"]\n\n[tool.mypy]\nstrict = true\n\n[tool.pytest.ini_options]\ntestpaths = [\"tests\"]\naddopts = \"--cov=src --cov-report=term-missing --cov-fail-under=80\"\n```\n\n---\n\n## Testing with Pytest\n\n```python\n# tests/unit/test_services.py\nimport pytest\nfrom package_name.core.services import calculate_total\n\nclass TestCalculateTotal:\n    def test_returns_sum_of_items(self):\n        # Arrange\n        items = [{\"price\": 10}, {\"price\": 20}]\n        \n        # Act\n        result = calculate_total(items)\n        \n        # Assert\n        assert result == 30\n\n    def test_returns_zero_for_empty_list(self):\n        assert calculate_total([]) == 0\n\n    def test_raises_on_invalid_item(self):\n        with pytest.raises(ValueError):\n            calculate_total([{\"invalid\": \"item\"}])\n```\n\n---\n\n## GitHub Actions\n\n```yaml\nname: Python Quality Gate\n\non: [push, pull_request]\n\njobs:\n  quality:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      \n      - name: Setup Python\n        uses: actions/setup-python@v5\n        with:\n          python-version: '3.12'\n          \n      - name: Install dependencies\n        run: |\n          pip install -e \".[dev]\"\n          \n      - name: Lint (Ruff)\n        run: ruff check .\n        \n      - name: Format Check (Ruff)\n        run: ruff format --check .\n        \n      - name: Type Check (mypy)\n        run: mypy src/\n        \n      - name: Test with Coverage\n        run: pytest\n```\n\n---\n\n## Pre-Commit Hooks\n\n```yaml\n# .pre-commit-config.yaml\nrepos:\n  - repo: https://github.com/astral-sh/ruff-pre-commit\n    rev: v0.8.0\n    hooks:\n      - id: ruff\n        args: [--fix]\n      - id: ruff-format\n\n  - repo: https://github.com/pre-commit/mirrors-mypy\n    rev: v1.13.0\n    hooks:\n      - id: mypy\n        additional_dependencies: [pydantic]\n        args: [--strict]\n\n  - repo: local\n    hooks:\n      - id: pytest\n        name: pytest\n        entry: pytest tests/unit -x --tb=short\n        language: system\n        pass_filenames: false\n        always_run: true\n```\n\nInstall and setup:\n```bash\npip install pre-commit\npre-commit install\n```\n\n---\n\n## Patterns\n\n### Pydantic for Data Validation\n```python\nfrom pydantic import BaseModel, Field\n\nclass CreateUserRequest(BaseModel):\n    email: str = Field(..., min_length=5)\n    name: str = Field(..., max_length=100)\n```\n\n### Dependency Injection\n```python\n# Don't import dependencies directly in business logic\n# Pass them in\n\n# Bad\nfrom .db import database\ndef get_user(user_id: int) -> User:\n    return database.fetch(user_id)\n\n# Good\ndef get_user(user_id: int, db: Database) -> User:\n    return db.fetch(user_id)\n```\n\n### Result Pattern (No Exceptions in Core)\n```python\nfrom dataclasses import dataclass\n\n@dataclass\nclass Result[T]:\n    value: T | None\n    error: str | None\n    \n    @property\n    def is_ok(self) -> bool:\n        return self.error is None\n```\n\n---\n\n## Python Anti-Patterns\n\n- ❌ `from module import *`\n- ❌ Mutable default arguments\n- ❌ Bare `except:` clauses\n- ❌ Using `type: ignore` without explanation\n- ❌ Global variables for state\n- ❌ Classes when functions suffice\n"
  },
  {
    "path": "skills/react-native/SKILL.md",
    "content": "---\nname: react-native\ndescription: React Native mobile patterns, platform-specific code\nwhen-to-use: When working on React Native mobile app code\nuser-invocable: false\npaths: [\"**/*.tsx\", \"**/*.jsx\", \"ios/**\", \"android/**\", \"app.json\"]\neffort: medium\n---\n\n# React Native Skill\n\n\n---\n\n## Project Structure\n\n```\nproject/\n├── src/\n│   ├── core/                   # Pure business logic (no React)\n│   │   ├── types.ts\n│   │   └── services/\n│   ├── components/             # Reusable UI components\n│   │   ├── Button/\n│   │   │   ├── Button.tsx\n│   │   │   ├── Button.test.tsx\n│   │   │   └── index.ts\n│   │   └── index.ts            # Barrel export\n│   ├── screens/                # Screen components\n│   │   ├── Home/\n│   │   │   ├── HomeScreen.tsx\n│   │   │   ├── useHome.ts      # Screen-specific hook\n│   │   │   └── index.ts\n│   │   └── index.ts\n│   ├── navigation/             # Navigation configuration\n│   ├── hooks/                  # Shared custom hooks\n│   ├── store/                  # State management\n│   └── utils/                  # Utilities\n├── __tests__/\n├── android/\n├── ios/\n└── CLAUDE.md\n```\n\n---\n\n## Component Patterns\n\n### Functional Components Only\n```typescript\n// Good - simple, testable\ninterface ButtonProps {\n  label: string;\n  onPress: () => void;\n  disabled?: boolean;\n}\n\nexport function Button({ label, onPress, disabled = false }: ButtonProps): JSX.Element {\n  return (\n    <Pressable onPress={onPress} disabled={disabled}>\n      <Text>{label}</Text>\n    </Pressable>\n  );\n}\n```\n\n### Extract Logic to Hooks\n```typescript\n// useHome.ts - all logic here\nexport function useHome() {\n  const [items, setItems] = useState<Item[]>([]);\n  const [loading, setLoading] = useState(false);\n\n  const refresh = useCallback(async () => {\n    setLoading(true);\n    const data = await fetchItems();\n    setItems(data);\n    setLoading(false);\n  }, []);\n\n  return { items, loading, refresh };\n}\n\n// HomeScreen.tsx - pure presentation\nexport function HomeScreen(): JSX.Element {\n  const { items, loading, refresh } = useHome();\n  \n  return (\n    <ItemList items={items} loading={loading} onRefresh={refresh} />\n  );\n}\n```\n\n### Props Interface Always Explicit\n```typescript\n// Always define props interface, even if simple\ninterface ItemCardProps {\n  item: Item;\n  onPress: (id: string) => void;\n}\n\nexport function ItemCard({ item, onPress }: ItemCardProps): JSX.Element {\n  ...\n}\n```\n\n---\n\n## State Management\n\n### Local State First\n```typescript\n// Start with useState, escalate only when needed\nconst [value, setValue] = useState('');\n```\n\n### Zustand for Global State (if needed)\n```typescript\n// store/useAppStore.ts\nimport { create } from 'zustand';\n\ninterface AppState {\n  user: User | null;\n  setUser: (user: User | null) => void;\n}\n\nexport const useAppStore = create<AppState>((set) => ({\n  user: null,\n  setUser: (user) => set({ user }),\n}));\n```\n\n### React Query for Server State\n```typescript\n// hooks/useItems.ts\nimport { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';\n\nexport function useItems() {\n  return useQuery({\n    queryKey: ['items'],\n    queryFn: fetchItems,\n  });\n}\n\nexport function useCreateItem() {\n  const queryClient = useQueryClient();\n  \n  return useMutation({\n    mutationFn: createItem,\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['items'] });\n    },\n  });\n}\n```\n\n---\n\n## Testing\n\n### Component Testing with React Native Testing Library\n```typescript\nimport { render, fireEvent } from '@testing-library/react-native';\nimport { Button } from './Button';\n\ndescribe('Button', () => {\n  it('calls onPress when pressed', () => {\n    const onPress = jest.fn();\n    const { getByText } = render(<Button label=\"Click me\" onPress={onPress} />);\n    \n    fireEvent.press(getByText('Click me'));\n    \n    expect(onPress).toHaveBeenCalledTimes(1);\n  });\n\n  it('does not call onPress when disabled', () => {\n    const onPress = jest.fn();\n    const { getByText } = render(<Button label=\"Click me\" onPress={onPress} disabled />);\n    \n    fireEvent.press(getByText('Click me'));\n    \n    expect(onPress).not.toHaveBeenCalled();\n  });\n});\n```\n\n### Hook Testing\n```typescript\nimport { renderHook, act } from '@testing-library/react-hooks';\nimport { useCounter } from './useCounter';\n\ndescribe('useCounter', () => {\n  it('increments counter', () => {\n    const { result } = renderHook(() => useCounter());\n    \n    act(() => {\n      result.current.increment();\n    });\n    \n    expect(result.current.count).toBe(1);\n  });\n});\n```\n\n---\n\n## Platform-Specific Code\n\n### Use Platform.select Sparingly\n```typescript\nimport { Platform } from 'react-native';\n\nconst styles = StyleSheet.create({\n  shadow: Platform.select({\n    ios: {\n      shadowColor: '#000',\n      shadowOffset: { width: 0, height: 2 },\n      shadowOpacity: 0.1,\n    },\n    android: {\n      elevation: 2,\n    },\n  }),\n});\n```\n\n### Separate Files for Complex Differences\n```\nComponent/\n├── Component.tsx          # Shared logic\n├── Component.ios.tsx      # iOS-specific\n├── Component.android.tsx  # Android-specific\n└── index.ts\n```\n\n---\n\n## React Native Anti-Patterns\n\n- ❌ Inline styles - use StyleSheet.create\n- ❌ Logic in render - extract to hooks\n- ❌ Deep component nesting - flatten hierarchy\n- ❌ Anonymous functions in props - use useCallback\n- ❌ Index as key in lists - use stable IDs\n- ❌ Direct state mutation - always use setter\n- ❌ Mixing business logic with UI - keep core/ pure\n- ❌ Ignoring TypeScript errors - fix them\n- ❌ Large components - split into smaller pieces\n"
  },
  {
    "path": "skills/react-web/SKILL.md",
    "content": "---\nname: react-web\ndescription: React web development with hooks, React Query, Zustand\nwhen-to-use: When working on React web components or pages\nuser-invocable: false\npaths: [\"**/*.tsx\", \"**/*.jsx\", \"src/components/**\", \"src/pages/**\", \"src/app/**\"]\neffort: medium\n---\n\n# React Web Skill\n\n\n---\n\n## Test-First Development (MANDATORY)\n\n**CRITICAL: Tests MUST be written BEFORE implementation code. This is non-negotiable for frontend components.**\n\n### The TFD Workflow\n\n```\n1. Write test file first → Defines expected behavior\n2. Run test (it fails) → Confirms test is valid\n3. Write minimal code → Just enough to pass\n4. Run test (it passes) → Validates implementation\n5. Refactor if needed → Tests catch regressions\n```\n\n### Component Development Order\n\n```bash\n# CORRECT ORDER - Test first\n1. Create Button.test.tsx    # Write tests for expected behavior\n2. Run tests (they fail)     # npm test -- Button\n3. Create Button.tsx         # Implement to pass tests\n4. Run tests (they pass)     # Verify implementation\n5. Create Button.module.css  # Style after logic works\n\n# WRONG ORDER - Never do this\n1. Create Button.tsx         # ❌ No tests exist yet\n2. Create Button.module.css  # ❌ Still no tests\n3. \"I'll add tests later\"    # ❌ Tests never get written\n```\n\n### Test File Structure (Create First)\n\n```typescript\n// Button.test.tsx - CREATE THIS FIRST\nimport { render, screen, fireEvent } from '@testing-library/react';\nimport { Button } from './Button';\n\ndescribe('Button', () => {\n  // Define ALL expected behaviors upfront\n  describe('rendering', () => {\n    it('renders with label', () => {\n      render(<Button label=\"Click me\" onClick={() => {}} />);\n      expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument();\n    });\n\n    it('applies variant class', () => {\n      render(<Button label=\"Click\" onClick={() => {}} variant=\"secondary\" />);\n      expect(screen.getByRole('button')).toHaveClass('secondary');\n    });\n  });\n\n  describe('interactions', () => {\n    it('calls onClick when clicked', () => {\n      const onClick = vi.fn();\n      render(<Button label=\"Click me\" onClick={onClick} />);\n      fireEvent.click(screen.getByRole('button'));\n      expect(onClick).toHaveBeenCalledTimes(1);\n    });\n\n    it('does not call onClick when disabled', () => {\n      const onClick = vi.fn();\n      render(<Button label=\"Click me\" onClick={onClick} disabled />);\n      fireEvent.click(screen.getByRole('button'));\n      expect(onClick).not.toHaveBeenCalled();\n    });\n  });\n\n  describe('accessibility', () => {\n    it('has correct aria attributes when disabled', () => {\n      render(<Button label=\"Click\" onClick={() => {}} disabled />);\n      expect(screen.getByRole('button')).toHaveAttribute('aria-disabled', 'true');\n    });\n  });\n});\n```\n\n### Hook Test First Pattern\n\n```typescript\n// useCounter.test.ts - CREATE THIS FIRST\nimport { renderHook, act } from '@testing-library/react';\nimport { useCounter } from './useCounter';\n\ndescribe('useCounter', () => {\n  it('starts at initial value', () => {\n    const { result } = renderHook(() => useCounter(5));\n    expect(result.current.count).toBe(5);\n  });\n\n  it('increments', () => {\n    const { result } = renderHook(() => useCounter());\n    act(() => result.current.increment());\n    expect(result.current.count).toBe(1);\n  });\n\n  it('decrements', () => {\n    const { result } = renderHook(() => useCounter(5));\n    act(() => result.current.decrement());\n    expect(result.current.count).toBe(4);\n  });\n\n  it('resets to initial value', () => {\n    const { result } = renderHook(() => useCounter(10));\n    act(() => result.current.increment());\n    act(() => result.current.reset());\n    expect(result.current.count).toBe(10);\n  });\n});\n```\n\n### Enforcement Checklist\n\nBefore writing ANY component/hook implementation:\n\n- [ ] Test file exists: `Component.test.tsx`\n- [ ] All expected behaviors have test cases\n- [ ] Tests run and FAIL (proves tests are valid)\n- [ ] Only THEN create implementation file\n\n**If tests are skipped, Claude MUST:**\n```\n⚠️ TEST-FIRST VIOLATION\n\nCannot create [Component].tsx - no test file exists.\n\nCreating [Component].test.tsx first with tests for:\n- Rendering with required props\n- User interactions\n- Edge cases\n- Accessibility\n```\n\n---\n\n## Project Structure\n\n```\nproject/\n├── src/\n│   ├── core/                   # Pure business logic (no React)\n│   │   ├── types.ts\n│   │   └── services/\n│   ├── components/             # Reusable UI components\n│   │   ├── Button/\n│   │   │   ├── Button.tsx\n│   │   │   ├── Button.test.tsx\n│   │   │   ├── Button.module.css  # or .styles.ts\n│   │   │   └── index.ts\n│   │   └── index.ts            # Barrel export\n│   ├── pages/                  # Route-level components\n│   │   ├── Home/\n│   │   │   ├── HomePage.tsx\n│   │   │   ├── useHome.ts      # Page-specific hook\n│   │   │   └── index.ts\n│   │   └── index.ts\n│   ├── hooks/                  # Shared custom hooks\n│   ├── store/                  # State management\n│   ├── api/                    # API client and queries\n│   ├── utils/                  # Utilities\n│   ├── App.tsx\n│   └── main.tsx\n├── tests/\n│   ├── unit/\n│   └── e2e/\n├── public/\n├── package.json\n├── tsconfig.json\n├── vite.config.ts              # or next.config.js\n└── CLAUDE.md\n```\n\n---\n\n## Component Patterns\n\n### Functional Components Only\n```typescript\n// Good - simple, testable\ninterface ButtonProps {\n  label: string;\n  onClick: () => void;\n  disabled?: boolean;\n  variant?: 'primary' | 'secondary';\n}\n\nexport function Button({\n  label,\n  onClick,\n  disabled = false,\n  variant = 'primary'\n}: ButtonProps): JSX.Element {\n  return (\n    <button\n      className={styles[variant]}\n      onClick={onClick}\n      disabled={disabled}\n    >\n      {label}\n    </button>\n  );\n}\n```\n\n### Extract Logic to Hooks\n```typescript\n// useHome.ts - all logic here\nexport function useHome() {\n  const [items, setItems] = useState<Item[]>([]);\n  const [loading, setLoading] = useState(false);\n\n  const refresh = useCallback(async () => {\n    setLoading(true);\n    const data = await fetchItems();\n    setItems(data);\n    setLoading(false);\n  }, []);\n\n  useEffect(() => {\n    refresh();\n  }, [refresh]);\n\n  return { items, loading, refresh };\n}\n\n// HomePage.tsx - pure presentation\nexport function HomePage(): JSX.Element {\n  const { items, loading, refresh } = useHome();\n\n  if (loading) return <Spinner />;\n\n  return <ItemList items={items} onRefresh={refresh} />;\n}\n```\n\n### Props Interface Always Explicit\n```typescript\n// Always define props interface, even if simple\ninterface ItemCardProps {\n  item: Item;\n  onClick: (id: string) => void;\n}\n\nexport function ItemCard({ item, onClick }: ItemCardProps): JSX.Element {\n  return (\n    <div onClick={() => onClick(item.id)}>\n      <h3>{item.title}</h3>\n    </div>\n  );\n}\n```\n\n---\n\n## State Management\n\n### Local State First\n```typescript\n// Start with useState, escalate only when needed\nconst [value, setValue] = useState('');\n```\n\n### Zustand for Global State (if needed)\n```typescript\n// store/useAppStore.ts\nimport { create } from 'zustand';\n\ninterface AppState {\n  user: User | null;\n  theme: 'light' | 'dark';\n  setUser: (user: User | null) => void;\n  toggleTheme: () => void;\n}\n\nexport const useAppStore = create<AppState>((set) => ({\n  user: null,\n  theme: 'light',\n  setUser: (user) => set({ user }),\n  toggleTheme: () => set((state) => ({\n    theme: state.theme === 'light' ? 'dark' : 'light'\n  })),\n}));\n```\n\n### React Query for Server State\n```typescript\n// api/queries/useItems.ts\nimport { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';\nimport { itemsApi } from '../client';\n\nexport function useItems() {\n  return useQuery({\n    queryKey: ['items'],\n    queryFn: itemsApi.getAll,\n    staleTime: 5 * 60 * 1000, // 5 minutes\n  });\n}\n\nexport function useCreateItem() {\n  const queryClient = useQueryClient();\n\n  return useMutation({\n    mutationFn: itemsApi.create,\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['items'] });\n    },\n  });\n}\n```\n\n---\n\n## Routing\n\n### React Router (Vite/CRA)\n```typescript\n// App.tsx\nimport { BrowserRouter, Routes, Route } from 'react-router-dom';\n\nexport function App(): JSX.Element {\n  return (\n    <BrowserRouter>\n      <Routes>\n        <Route path=\"/\" element={<HomePage />} />\n        <Route path=\"/items/:id\" element={<ItemPage />} />\n        <Route path=\"*\" element={<NotFoundPage />} />\n      </Routes>\n    </BrowserRouter>\n  );\n}\n```\n\n### Protected Routes\n```typescript\ninterface ProtectedRouteProps {\n  children: JSX.Element;\n}\n\nfunction ProtectedRoute({ children }: ProtectedRouteProps): JSX.Element {\n  const { user } = useAppStore();\n  const location = useLocation();\n\n  if (!user) {\n    return <Navigate to=\"/login\" state={{ from: location }} replace />;\n  }\n\n  return children;\n}\n```\n\n---\n\n## Styling\n\n### CSS Modules (Preferred)\n```typescript\n// Button.module.css\n.primary {\n  background: var(--color-primary);\n  color: white;\n}\n\n.secondary {\n  background: transparent;\n  border: 1px solid var(--color-primary);\n}\n\n// Button.tsx\nimport styles from './Button.module.css';\n\n<button className={styles.primary}>Click</button>\n```\n\n### Tailwind (Alternative)\n```typescript\n// Use consistent patterns, extract repeated combinations\nconst buttonVariants = {\n  primary: 'bg-blue-500 text-white hover:bg-blue-600',\n  secondary: 'bg-transparent border border-blue-500 text-blue-500',\n} as const;\n\n<button className={buttonVariants[variant]}>{label}</button>\n```\n\n---\n\n## Forms\n\n### React Hook Form + Zod\n```typescript\nimport { useForm } from 'react-hook-form';\nimport { zodResolver } from '@hookform/resolvers/zod';\nimport { z } from 'zod';\n\nconst schema = z.object({\n  email: z.string().email('Invalid email'),\n  password: z.string().min(8, 'Password must be at least 8 characters'),\n});\n\ntype FormData = z.infer<typeof schema>;\n\nexport function LoginForm(): JSX.Element {\n  const { register, handleSubmit, formState: { errors } } = useForm<FormData>({\n    resolver: zodResolver(schema),\n  });\n\n  const onSubmit = (data: FormData) => {\n    // handle submit\n  };\n\n  return (\n    <form onSubmit={handleSubmit(onSubmit)}>\n      <input {...register('email')} />\n      {errors.email && <span>{errors.email.message}</span>}\n\n      <input type=\"password\" {...register('password')} />\n      {errors.password && <span>{errors.password.message}</span>}\n\n      <button type=\"submit\">Login</button>\n    </form>\n  );\n}\n```\n\n---\n\n## Testing\n\n### Component Testing with React Testing Library\n```typescript\nimport { render, screen, fireEvent } from '@testing-library/react';\nimport { Button } from './Button';\n\ndescribe('Button', () => {\n  it('calls onClick when clicked', () => {\n    const onClick = vi.fn();\n    render(<Button label=\"Click me\" onClick={onClick} />);\n\n    fireEvent.click(screen.getByText('Click me'));\n\n    expect(onClick).toHaveBeenCalledTimes(1);\n  });\n\n  it('does not call onClick when disabled', () => {\n    const onClick = vi.fn();\n    render(<Button label=\"Click me\" onClick={onClick} disabled />);\n\n    fireEvent.click(screen.getByText('Click me'));\n\n    expect(onClick).not.toHaveBeenCalled();\n  });\n\n  it('applies correct variant class', () => {\n    render(<Button label=\"Click\" onClick={() => {}} variant=\"secondary\" />);\n\n    expect(screen.getByRole('button')).toHaveClass('secondary');\n  });\n});\n```\n\n### Hook Testing\n```typescript\nimport { renderHook, act, waitFor } from '@testing-library/react';\nimport { useCounter } from './useCounter';\n\ndescribe('useCounter', () => {\n  it('increments counter', () => {\n    const { result } = renderHook(() => useCounter());\n\n    act(() => {\n      result.current.increment();\n    });\n\n    expect(result.current.count).toBe(1);\n  });\n});\n```\n\n### E2E with Playwright\n```typescript\n// tests/e2e/login.spec.ts\nimport { test, expect } from '@playwright/test';\n\ntest('user can login', async ({ page }) => {\n  await page.goto('/login');\n\n  await page.fill('[name=\"email\"]', 'test@example.com');\n  await page.fill('[name=\"password\"]', 'password123');\n  await page.click('button[type=\"submit\"]');\n\n  await expect(page).toHaveURL('/dashboard');\n  await expect(page.getByText('Welcome')).toBeVisible();\n});\n```\n\n---\n\n## Performance\n\n### Memoization\n```typescript\n// Memoize expensive components\nconst ItemList = memo(function ItemList({ items }: ItemListProps) {\n  return items.map(item => <ItemCard key={item.id} item={item} />);\n});\n\n// Memoize callbacks passed to children\nconst handleClick = useCallback((id: string) => {\n  setSelectedId(id);\n}, []);\n\n// Memoize expensive computations\nconst sortedItems = useMemo(() => {\n  return [...items].sort((a, b) => a.name.localeCompare(b.name));\n}, [items]);\n```\n\n### Code Splitting\n```typescript\n// Lazy load routes\nconst ItemPage = lazy(() => import('./pages/Item'));\n\n<Suspense fallback={<Spinner />}>\n  <Route path=\"/items/:id\" element={<ItemPage />} />\n</Suspense>\n```\n\n---\n\n## React Web Anti-Patterns\n\n- ❌ Inline functions in JSX - use useCallback\n- ❌ Logic in render - extract to hooks\n- ❌ Deep component nesting - flatten hierarchy\n- ❌ Index as key in lists - use stable IDs\n- ❌ Direct state mutation - always use setter\n- ❌ Prop drilling > 2 levels - use context or state management\n- ❌ useEffect for derived state - use useMemo\n- ❌ Fetching in useEffect - use React Query\n- ❌ Mixing business logic with UI - keep core/ pure\n- ❌ Large components (>100 lines) - split into smaller pieces\n- ❌ CSS in JS objects - use CSS modules or Tailwind\n- ❌ Ignoring TypeScript errors - fix them\n"
  },
  {
    "path": "skills/reddit-ads/SKILL.md",
    "content": "---\nname: reddit-ads\ndescription: Reddit Ads API - campaigns, targeting, conversions, agentic optimization\nwhen-to-use: When building Reddit ad campaign management or optimization tools\nuser-invocable: false\neffort: medium\n---\n\n# Reddit Ads API Skill\n\n\n**Purpose:** Automate Reddit advertising campaigns using the Reddit Ads API. Create, manage, and optimize campaigns, ad groups, and ads programmatically.\n\n---\n\n## API Overview\n\n```\n┌─────────────────────────────────────────────────────────────────┐\n│  REDDIT ADS API HIERARCHY                                        │\n│  ─────────────────────────────────────────────────────────────  │\n│                                                                 │\n│  Account                                                        │\n│    └── Campaign (objective, budget, schedule)                   │\n│         └── Ad Group (targeting, bidding, placement)            │\n│              └── Ad (creative, headline, CTA)                   │\n│                                                                 │\n│  + Custom Audiences (customer lists, lookalikes)                │\n│  + Conversions API (track events server-side)                   │\n├─────────────────────────────────────────────────────────────────┤\n│  BASE URL: https://ads-api.reddit.com/api/v2.0                  │\n│  DOCS: https://ads-api.reddit.com/docs/                         │\n│  RATE LIMIT: 1 request per second                               │\n│  AUTH: OAuth 2.0 with Bearer token                              │\n└─────────────────────────────────────────────────────────────────┘\n```\n\n---\n\n## Authentication\n\n### Step 1: Create Reddit Developer App\n\n1. Go to https://www.reddit.com/prefs/apps/\n2. Click \"Create App\" or \"Create Another App\"\n3. Fill in:\n   - **Name:** Your app name\n   - **Type:** Select `script` for server-side automation\n   - **Redirect URI:** Your callback URL (e.g., `https://yourapp.com/callback`)\n4. Note your **Client ID** (under app name) and **Client Secret**\n\n### Step 2: Authorization Flow\n\n```javascript\n// Node.js OAuth2 flow\nconst REDDIT_CLIENT_ID = process.env.REDDIT_ADS_CLIENT_ID;\nconst REDDIT_CLIENT_SECRET = process.env.REDDIT_ADS_CLIENT_SECRET;\nconst REDIRECT_URI = 'https://yourapp.com/callback';\n\n// Step 1: Generate authorization URL\nfunction getAuthorizationUrl(state) {\n  const scopes = 'adsread,adsedit,history';\n  return `https://www.reddit.com/api/v1/authorize?` +\n    `client_id=${REDDIT_CLIENT_ID}` +\n    `&response_type=code` +\n    `&state=${state}` +\n    `&redirect_uri=${encodeURIComponent(REDIRECT_URI)}` +\n    `&duration=permanent` +\n    `&scope=${scopes}`;\n}\n\n// Step 2: Exchange code for tokens\nasync function getAccessToken(authorizationCode) {\n  const credentials = Buffer.from(\n    `${REDDIT_CLIENT_ID}:${REDDIT_CLIENT_SECRET}`\n  ).toString('base64');\n\n  const response = await fetch('https://www.reddit.com/api/v1/access_token', {\n    method: 'POST',\n    headers: {\n      'Authorization': `Basic ${credentials}`,\n      'Content-Type': 'application/x-www-form-urlencoded',\n      'User-Agent': 'YourApp/1.0.0'\n    },\n    body: new URLSearchParams({\n      grant_type: 'authorization_code',\n      code: authorizationCode,\n      redirect_uri: REDIRECT_URI\n    })\n  });\n\n  return response.json();\n  // Returns: { access_token, refresh_token, expires_in, scope }\n}\n\n// Step 3: Refresh token when expired\nasync function refreshAccessToken(refreshToken) {\n  const credentials = Buffer.from(\n    `${REDDIT_CLIENT_ID}:${REDDIT_CLIENT_SECRET}`\n  ).toString('base64');\n\n  const response = await fetch('https://www.reddit.com/api/v1/access_token', {\n    method: 'POST',\n    headers: {\n      'Authorization': `Basic ${credentials}`,\n      'Content-Type': 'application/x-www-form-urlencoded',\n      'User-Agent': 'YourApp/1.0.0'\n    },\n    body: new URLSearchParams({\n      grant_type: 'refresh_token',\n      refresh_token: refreshToken\n    })\n  });\n\n  return response.json();\n}\n```\n\n### Python OAuth2 Flow\n\n```python\nimport requests\nimport base64\nimport os\n\nREDDIT_CLIENT_ID = os.environ['REDDIT_ADS_CLIENT_ID']\nREDDIT_CLIENT_SECRET = os.environ['REDDIT_ADS_CLIENT_SECRET']\nREDIRECT_URI = 'https://yourapp.com/callback'\nUSER_AGENT = 'YourApp/1.0.0'\n\ndef get_authorization_url(state: str) -> str:\n    \"\"\"Generate OAuth authorization URL.\"\"\"\n    scopes = 'adsread,adsedit,history'\n    return (\n        f\"https://www.reddit.com/api/v1/authorize?\"\n        f\"client_id={REDDIT_CLIENT_ID}\"\n        f\"&response_type=code\"\n        f\"&state={state}\"\n        f\"&redirect_uri={REDIRECT_URI}\"\n        f\"&duration=permanent\"\n        f\"&scope={scopes}\"\n    )\n\ndef get_access_token(authorization_code: str) -> dict:\n    \"\"\"Exchange authorization code for access token.\"\"\"\n    credentials = base64.b64encode(\n        f\"{REDDIT_CLIENT_ID}:{REDDIT_CLIENT_SECRET}\".encode()\n    ).decode()\n\n    response = requests.post(\n        'https://www.reddit.com/api/v1/access_token',\n        headers={\n            'Authorization': f'Basic {credentials}',\n            'User-Agent': USER_AGENT\n        },\n        data={\n            'grant_type': 'authorization_code',\n            'code': authorization_code,\n            'redirect_uri': REDIRECT_URI\n        }\n    )\n    return response.json()\n\ndef refresh_access_token(refresh_token: str) -> dict:\n    \"\"\"Refresh expired access token.\"\"\"\n    credentials = base64.b64encode(\n        f\"{REDDIT_CLIENT_ID}:{REDDIT_CLIENT_SECRET}\".encode()\n    ).decode()\n\n    response = requests.post(\n        'https://www.reddit.com/api/v1/access_token',\n        headers={\n            'Authorization': f'Basic {credentials}',\n            'User-Agent': USER_AGENT\n        },\n        data={\n            'grant_type': 'refresh_token',\n            'refresh_token': refresh_token\n        }\n    )\n    return response.json()\n```\n\n### Required Scopes\n\n| Scope | Access Level |\n|-------|--------------|\n| `adsread` | Read campaigns, ad groups, ads, reports |\n| `adsedit` | Create/update campaigns, ad groups, ads |\n| `history` | Access account history |\n\n---\n\n## Reddit Ads Client\n\n### Node.js Client\n\n```typescript\n// lib/reddit-ads-client.ts\ninterface RedditAdsConfig {\n  accessToken: string;\n  accountId: string;\n}\n\nclass RedditAdsClient {\n  private baseUrl = 'https://ads-api.reddit.com/api/v2.0';\n  private accessToken: string;\n  private accountId: string;\n\n  constructor(config: RedditAdsConfig) {\n    this.accessToken = config.accessToken;\n    this.accountId = config.accountId;\n  }\n\n  private async request<T>(\n    method: string,\n    endpoint: string,\n    body?: object\n  ): Promise<T> {\n    const url = `${this.baseUrl}${endpoint}`;\n\n    const response = await fetch(url, {\n      method,\n      headers: {\n        'Authorization': `Bearer ${this.accessToken}`,\n        'Content-Type': 'application/json',\n        'User-Agent': 'YourApp/1.0.0'\n      },\n      body: body ? JSON.stringify(body) : undefined\n    });\n\n    if (!response.ok) {\n      const error = await response.json();\n      throw new Error(`Reddit Ads API Error: ${JSON.stringify(error)}`);\n    }\n\n    return response.json();\n  }\n\n  // Account\n  async getAccount() {\n    return this.request('GET', `/accounts/${this.accountId}`);\n  }\n\n  // Campaigns\n  async getCampaigns() {\n    return this.request('GET', `/accounts/${this.accountId}/campaigns`);\n  }\n\n  async getCampaign(campaignId: string) {\n    return this.request('GET', `/accounts/${this.accountId}/campaigns/${campaignId}`);\n  }\n\n  async createCampaign(campaign: CampaignCreate) {\n    return this.request('POST', `/accounts/${this.accountId}/campaigns`, campaign);\n  }\n\n  async updateCampaign(campaignId: string, updates: Partial<CampaignCreate>) {\n    return this.request('PUT', `/accounts/${this.accountId}/campaigns/${campaignId}`, updates);\n  }\n\n  // Ad Groups\n  async getAdGroups(campaignId?: string) {\n    const endpoint = campaignId\n      ? `/accounts/${this.accountId}/campaigns/${campaignId}/ad_groups`\n      : `/accounts/${this.accountId}/ad_groups`;\n    return this.request('GET', endpoint);\n  }\n\n  async getAdGroup(adGroupId: string) {\n    return this.request('GET', `/accounts/${this.accountId}/ad_groups/${adGroupId}`);\n  }\n\n  async createAdGroup(adGroup: AdGroupCreate) {\n    return this.request('POST', `/accounts/${this.accountId}/ad_groups`, adGroup);\n  }\n\n  async updateAdGroup(adGroupId: string, updates: Partial<AdGroupCreate>) {\n    return this.request('PUT', `/accounts/${this.accountId}/ad_groups/${adGroupId}`, updates);\n  }\n\n  // Ads\n  async getAds(adGroupId?: string) {\n    const endpoint = adGroupId\n      ? `/accounts/${this.accountId}/ad_groups/${adGroupId}/ads`\n      : `/accounts/${this.accountId}/ads`;\n    return this.request('GET', endpoint);\n  }\n\n  async createAd(ad: AdCreate) {\n    return this.request('POST', `/accounts/${this.accountId}/ads`, ad);\n  }\n\n  async updateAd(adId: string, updates: Partial<AdCreate>) {\n    return this.request('PUT', `/accounts/${this.accountId}/ads/${adId}`, updates);\n  }\n\n  // Reports\n  async getReport(reportRequest: ReportRequest) {\n    return this.request('POST', `/accounts/${this.accountId}/reports`, reportRequest);\n  }\n\n  // Custom Audiences\n  async getCustomAudiences() {\n    return this.request('GET', `/accounts/${this.accountId}/custom_audiences`);\n  }\n\n  async createCustomAudience(audience: CustomAudienceCreate) {\n    return this.request('POST', `/accounts/${this.accountId}/custom_audiences`, audience);\n  }\n}\n\nexport default RedditAdsClient;\n```\n\n### Python Client\n\n```python\n# lib/reddit_ads_client.py\nimport requests\nfrom typing import Optional, Dict, Any, List\nfrom dataclasses import dataclass\n\n@dataclass\nclass RedditAdsConfig:\n    access_token: str\n    account_id: str\n\nclass RedditAdsClient:\n    BASE_URL = 'https://ads-api.reddit.com/api/v2.0'\n\n    def __init__(self, config: RedditAdsConfig):\n        self.access_token = config.access_token\n        self.account_id = config.account_id\n        self.session = requests.Session()\n        self.session.headers.update({\n            'Authorization': f'Bearer {self.access_token}',\n            'Content-Type': 'application/json',\n            'User-Agent': 'YourApp/1.0.0'\n        })\n\n    def _request(\n        self,\n        method: str,\n        endpoint: str,\n        json: Optional[Dict] = None\n    ) -> Dict[str, Any]:\n        url = f\"{self.BASE_URL}{endpoint}\"\n        response = self.session.request(method, url, json=json)\n        response.raise_for_status()\n        return response.json()\n\n    # Account\n    def get_account(self) -> Dict:\n        return self._request('GET', f'/accounts/{self.account_id}')\n\n    # Campaigns\n    def get_campaigns(self) -> List[Dict]:\n        return self._request('GET', f'/accounts/{self.account_id}/campaigns')\n\n    def get_campaign(self, campaign_id: str) -> Dict:\n        return self._request('GET', f'/accounts/{self.account_id}/campaigns/{campaign_id}')\n\n    def create_campaign(self, campaign: Dict) -> Dict:\n        return self._request('POST', f'/accounts/{self.account_id}/campaigns', json=campaign)\n\n    def update_campaign(self, campaign_id: str, updates: Dict) -> Dict:\n        return self._request('PUT', f'/accounts/{self.account_id}/campaigns/{campaign_id}', json=updates)\n\n    # Ad Groups\n    def get_ad_groups(self, campaign_id: Optional[str] = None) -> List[Dict]:\n        endpoint = (\n            f'/accounts/{self.account_id}/campaigns/{campaign_id}/ad_groups'\n            if campaign_id\n            else f'/accounts/{self.account_id}/ad_groups'\n        )\n        return self._request('GET', endpoint)\n\n    def create_ad_group(self, ad_group: Dict) -> Dict:\n        return self._request('POST', f'/accounts/{self.account_id}/ad_groups', json=ad_group)\n\n    def update_ad_group(self, ad_group_id: str, updates: Dict) -> Dict:\n        return self._request('PUT', f'/accounts/{self.account_id}/ad_groups/{ad_group_id}', json=updates)\n\n    # Ads\n    def get_ads(self, ad_group_id: Optional[str] = None) -> List[Dict]:\n        endpoint = (\n            f'/accounts/{self.account_id}/ad_groups/{ad_group_id}/ads'\n            if ad_group_id\n            else f'/accounts/{self.account_id}/ads'\n        )\n        return self._request('GET', endpoint)\n\n    def create_ad(self, ad: Dict) -> Dict:\n        return self._request('POST', f'/accounts/{self.account_id}/ads', json=ad)\n\n    # Reports\n    def get_report(self, report_request: Dict) -> Dict:\n        return self._request('POST', f'/accounts/{self.account_id}/reports', json=report_request)\n\n    # Custom Audiences\n    def get_custom_audiences(self) -> List[Dict]:\n        return self._request('GET', f'/accounts/{self.account_id}/custom_audiences')\n\n    def create_custom_audience(self, audience: Dict) -> Dict:\n        return self._request('POST', f'/accounts/{self.account_id}/custom_audiences', json=audience)\n```\n\n---\n\n## API Endpoints Reference\n\n### Account Endpoints\n\n| Method | Endpoint | Description |\n|--------|----------|-------------|\n| GET | `/accounts/{account_id}` | Get account details |\n| GET | `/accounts/{account_id}/funding` | Get funding information |\n\n### Campaign Endpoints\n\n| Method | Endpoint | Description |\n|--------|----------|-------------|\n| GET | `/accounts/{account_id}/campaigns` | List all campaigns |\n| GET | `/accounts/{account_id}/campaigns/{campaign_id}` | Get campaign by ID |\n| POST | `/accounts/{account_id}/campaigns` | Create campaign |\n| PUT | `/accounts/{account_id}/campaigns/{campaign_id}` | Update campaign |\n| DELETE | `/accounts/{account_id}/campaigns/{campaign_id}` | Delete campaign |\n\n### Ad Group Endpoints\n\n| Method | Endpoint | Description |\n|--------|----------|-------------|\n| GET | `/accounts/{account_id}/ad_groups` | List all ad groups |\n| GET | `/accounts/{account_id}/ad_groups/{ad_group_id}` | Get ad group by ID |\n| POST | `/accounts/{account_id}/ad_groups` | Create ad group |\n| PUT | `/accounts/{account_id}/ad_groups/{ad_group_id}` | Update ad group |\n| DELETE | `/accounts/{account_id}/ad_groups/{ad_group_id}` | Delete ad group |\n\n### Ad Endpoints\n\n| Method | Endpoint | Description |\n|--------|----------|-------------|\n| GET | `/accounts/{account_id}/ads` | List all ads |\n| GET | `/accounts/{account_id}/ads/{ad_id}` | Get ad by ID |\n| POST | `/accounts/{account_id}/ads` | Create ad |\n| PUT | `/accounts/{account_id}/ads/{ad_id}` | Update ad |\n| DELETE | `/accounts/{account_id}/ads/{ad_id}` | Delete ad |\n\n### Custom Audience Endpoints\n\n| Method | Endpoint | Description |\n|--------|----------|-------------|\n| GET | `/accounts/{account_id}/custom_audiences` | List custom audiences |\n| POST | `/accounts/{account_id}/custom_audiences` | Create custom audience |\n| PUT | `/accounts/{account_id}/custom_audiences/{audience_id}` | Update audience |\n| DELETE | `/accounts/{account_id}/custom_audiences/{audience_id}` | Delete audience |\n\n### Report Endpoints\n\n| Method | Endpoint | Description |\n|--------|----------|-------------|\n| POST | `/accounts/{account_id}/reports` | Generate report |\n\n---\n\n## Campaign Creation\n\n### Campaign Objectives\n\n| Objective | Use Case |\n|-----------|----------|\n| `BRAND_AWARENESS` | Build brand recognition and reach |\n| `TRAFFIC` | Drive clicks to website/landing page |\n| `CONVERSIONS` | Track and optimize for conversions |\n| `VIDEO_VIEWS` | Maximize video view engagement |\n| `APP_INSTALLS` | Drive mobile app installations |\n| `CATALOG_SALES` | Promote product catalog items |\n\n### Budget Types\n\n| Type | Description |\n|------|-------------|\n| `DAILY` | Average daily spend (may vary slightly) |\n| `LIFETIME` | Total spend over campaign duration |\n\n### Campaign Create Example\n\n```typescript\ninterface CampaignCreate {\n  name: string;\n  objective: 'BRAND_AWARENESS' | 'TRAFFIC' | 'CONVERSIONS' | 'VIDEO_VIEWS' | 'APP_INSTALLS';\n  is_enabled: boolean;\n  budget_type: 'DAILY' | 'LIFETIME';\n  budget_total_amount_micros: number; // Amount in micros (1 USD = 1,000,000 micros)\n  start_time: string; // ISO 8601 format\n  end_time?: string; // ISO 8601 format (optional)\n}\n\n// Create a traffic campaign with $50/day budget\nconst campaign: CampaignCreate = {\n  name: 'Q1 2025 Traffic Campaign',\n  objective: 'TRAFFIC',\n  is_enabled: true,\n  budget_type: 'DAILY',\n  budget_total_amount_micros: 50_000_000, // $50\n  start_time: '2025-01-15T00:00:00Z',\n  end_time: '2025-03-31T23:59:59Z'\n};\n\nconst result = await client.createCampaign(campaign);\n```\n\n```python\n# Python example\ncampaign = {\n    'name': 'Q1 2025 Traffic Campaign',\n    'objective': 'TRAFFIC',\n    'is_enabled': True,\n    'budget_type': 'DAILY',\n    'budget_total_amount_micros': 50_000_000,  # $50\n    'start_time': '2025-01-15T00:00:00Z',\n    'end_time': '2025-03-31T23:59:59Z'\n}\n\nresult = client.create_campaign(campaign)\n```\n\n---\n\n## Ad Group Creation\n\n### Bidding Strategies\n\n| Strategy | Description | Use Case |\n|----------|-------------|----------|\n| `LOWEST_COST` | Maximize conversions within budget | Best for most campaigns |\n| `COST_CAP` | Set average CPC cap | Control cost per result |\n| `MANUAL` | Set strict CPC/CPM bid | Maximum control |\n\n### Targeting Options\n\n| Targeting Type | Description |\n|----------------|-------------|\n| `communities` | Target specific subreddits |\n| `interests` | Target by interest categories |\n| `keywords` | Target by keyword engagement |\n| `devices` | Target by device type |\n| `locations` | Target by geography |\n| `custom_audiences` | Target uploaded customer lists |\n\n### Ad Group Create Example\n\n```typescript\ninterface AdGroupCreate {\n  name: string;\n  campaign_id: string;\n  is_enabled: boolean;\n  bid_strategy: 'LOWEST_COST' | 'COST_CAP' | 'MANUAL';\n  bid_amount_micros?: number; // For COST_CAP or MANUAL\n  goal_type: 'CLICKS' | 'IMPRESSIONS' | 'CONVERSIONS';\n  goal_value_micros?: number;\n  targeting: {\n    communities?: string[]; // Subreddit names without r/\n    interests?: string[];\n    keywords?: string[];\n    geo_locations?: {\n      countries?: string[];\n      regions?: string[];\n      cities?: string[];\n    };\n    devices?: ('DESKTOP' | 'MOBILE' | 'TABLET')[];\n    custom_audience_ids?: string[];\n  };\n  start_time?: string;\n  end_time?: string;\n}\n\n// Create ad group targeting specific subreddits\nconst adGroup: AdGroupCreate = {\n  name: 'Tech Enthusiasts - Subreddit Targeting',\n  campaign_id: 'campaign_123',\n  is_enabled: true,\n  bid_strategy: 'LOWEST_COST',\n  goal_type: 'CLICKS',\n  targeting: {\n    communities: [\n      'technology',\n      'gadgets',\n      'programming',\n      'webdev',\n      'startups'\n    ],\n    geo_locations: {\n      countries: ['US', 'CA', 'GB']\n    },\n    devices: ['DESKTOP', 'MOBILE']\n  },\n  start_time: '2025-01-15T00:00:00Z'\n};\n\nconst result = await client.createAdGroup(adGroup);\n```\n\n```python\n# Python example\nad_group = {\n    'name': 'Tech Enthusiasts - Subreddit Targeting',\n    'campaign_id': 'campaign_123',\n    'is_enabled': True,\n    'bid_strategy': 'LOWEST_COST',\n    'goal_type': 'CLICKS',\n    'targeting': {\n        'communities': [\n            'technology',\n            'gadgets',\n            'programming',\n            'webdev',\n            'startups'\n        ],\n        'geo_locations': {\n            'countries': ['US', 'CA', 'GB']\n        },\n        'devices': ['DESKTOP', 'MOBILE']\n    },\n    'start_time': '2025-01-15T00:00:00Z'\n}\n\nresult = client.create_ad_group(ad_group)\n```\n\n---\n\n## Ad Creation\n\n### Ad Types\n\n| Type | Description |\n|------|-------------|\n| `LINK` | Link ad with image/video |\n| `TEXT` | Text-only promoted post |\n| `VIDEO` | Video ad |\n| `CAROUSEL` | Multiple images/cards |\n| `PRODUCT` | Product catalog ad |\n\n### Call-to-Action Options\n\n| CTA | Use Case |\n|-----|----------|\n| `SHOP_NOW` | E-commerce |\n| `SIGN_UP` | Lead generation |\n| `LEARN_MORE` | Information |\n| `DOWNLOAD` | App/content download |\n| `INSTALL` | App install |\n| `GET_QUOTE` | Services |\n| `CONTACT_US` | B2B/Services |\n| `APPLY_NOW` | Jobs/Finance |\n| `BOOK_NOW` | Travel/Services |\n| `WATCH_NOW` | Video content |\n| `SUBSCRIBE` | Newsletters/SaaS |\n| `GET_OFFER` | Promotions |\n| `SEE_MENU` | Restaurants |\n\n### Ad Create Example\n\n```typescript\ninterface AdCreate {\n  name: string;\n  ad_group_id: string;\n  is_enabled: boolean;\n  type: 'LINK' | 'TEXT' | 'VIDEO' | 'CAROUSEL';\n  headline: string; // Max 300 characters\n  body?: string;\n  url: string;\n  display_url?: string;\n  call_to_action: string;\n  thumbnail_url?: string; // For image/video ads\n  video_url?: string; // For video ads\n}\n\n// Create a link ad\nconst ad: AdCreate = {\n  name: 'Product Launch Ad - v1',\n  ad_group_id: 'ad_group_456',\n  is_enabled: true,\n  type: 'LINK',\n  headline: 'Introducing Our Revolutionary New Product',\n  body: 'Discover how our latest innovation can transform your workflow. Join 10,000+ satisfied customers.',\n  url: 'https://yoursite.com/product?utm_source=reddit&utm_medium=paid',\n  display_url: 'yoursite.com/product',\n  call_to_action: 'LEARN_MORE',\n  thumbnail_url: 'https://yoursite.com/images/ad-creative.jpg'\n};\n\nconst result = await client.createAd(ad);\n```\n\n```python\n# Python example\nad = {\n    'name': 'Product Launch Ad - v1',\n    'ad_group_id': 'ad_group_456',\n    'is_enabled': True,\n    'type': 'LINK',\n    'headline': 'Introducing Our Revolutionary New Product',\n    'body': 'Discover how our latest innovation can transform your workflow. Join 10,000+ satisfied customers.',\n    'url': 'https://yoursite.com/product?utm_source=reddit&utm_medium=paid',\n    'display_url': 'yoursite.com/product',\n    'call_to_action': 'LEARN_MORE',\n    'thumbnail_url': 'https://yoursite.com/images/ad-creative.jpg'\n}\n\nresult = client.create_ad(ad)\n```\n\n---\n\n## Conversions API\n\n### Event Types\n\n| Event Type | Description |\n|------------|-------------|\n| `PAGE_VISIT` | Page view |\n| `VIEW_CONTENT` | Product/content view |\n| `SEARCH` | Search action |\n| `ADD_TO_CART` | Add to cart |\n| `ADD_TO_WISHLIST` | Add to wishlist |\n| `PURCHASE` | Completed purchase |\n| `LEAD` | Lead submission |\n| `SIGN_UP` | Account creation |\n| `CUSTOM` | Custom event |\n\n### Conversion Event Structure\n\n```typescript\ninterface ConversionEvent {\n  event_at: number; // Unix timestamp in milliseconds\n  event_type: {\n    tracking_type: string;\n    custom_event_name?: string; // For CUSTOM type\n  };\n  user: {\n    email?: string; // SHA256 hashed, lowercase\n    phone_number?: string; // SHA256 hashed, E.164 format\n    external_id?: string;\n    ip_address?: string;\n    user_agent?: string;\n    aaid?: string; // Android Advertising ID\n    idfa?: string; // iOS IDFA\n  };\n  event_metadata?: {\n    item_count?: number;\n    value_decimal?: number;\n    currency?: string;\n    conversion_id: string; // Unique event ID\n    products?: Array<{\n      id: string;\n      name?: string;\n      category?: string;\n    }>;\n  };\n  click_id?: string; // Reddit click ID for attribution\n}\n```\n\n### Send Conversion Events\n\n```typescript\nimport crypto from 'crypto';\n\nfunction hashPII(value: string): string {\n  return crypto\n    .createHash('sha256')\n    .update(value.toLowerCase().trim())\n    .digest('hex');\n}\n\nasync function sendConversionEvent(\n  accessToken: string,\n  pixelId: string,\n  event: ConversionEvent\n) {\n  const response = await fetch(\n    `https://ads-api.reddit.com/api/v2.0/conversions/events/${pixelId}`,\n    {\n      method: 'POST',\n      headers: {\n        'Authorization': `Bearer ${accessToken}`,\n        'Content-Type': 'application/json'\n      },\n      body: JSON.stringify({\n        events: [event],\n        test_mode: false // Set true for testing\n      })\n    }\n  );\n\n  return response.json();\n}\n\n// Example: Track a purchase\nconst purchaseEvent: ConversionEvent = {\n  event_at: Date.now(),\n  event_type: {\n    tracking_type: 'PURCHASE'\n  },\n  user: {\n    email: hashPII('customer@example.com'),\n    ip_address: '192.168.1.1',\n    user_agent: 'Mozilla/5.0...'\n  },\n  event_metadata: {\n    conversion_id: 'order_12345',\n    value_decimal: 99.99,\n    currency: 'USD',\n    item_count: 2,\n    products: [\n      { id: 'SKU001', name: 'Product A', category: 'Electronics' },\n      { id: 'SKU002', name: 'Product B', category: 'Electronics' }\n    ]\n  },\n  click_id: 'reddit_click_id_from_url' // From rdt_cid parameter\n};\n\nawait sendConversionEvent(accessToken, 'pixel_123', purchaseEvent);\n```\n\n```python\nimport hashlib\nimport time\nimport requests\n\ndef hash_pii(value: str) -> str:\n    \"\"\"SHA256 hash PII data.\"\"\"\n    return hashlib.sha256(value.lower().strip().encode()).hexdigest()\n\ndef send_conversion_event(\n    access_token: str,\n    pixel_id: str,\n    events: list[dict],\n    test_mode: bool = False\n) -> dict:\n    \"\"\"Send conversion events to Reddit.\"\"\"\n    response = requests.post(\n        f'https://ads-api.reddit.com/api/v2.0/conversions/events/{pixel_id}',\n        headers={\n            'Authorization': f'Bearer {access_token}',\n            'Content-Type': 'application/json'\n        },\n        json={\n            'events': events,\n            'test_mode': test_mode\n        }\n    )\n    response.raise_for_status()\n    return response.json()\n\n# Example: Track a purchase\npurchase_event = {\n    'event_at': int(time.time() * 1000),\n    'event_type': {\n        'tracking_type': 'PURCHASE'\n    },\n    'user': {\n        'email': hash_pii('customer@example.com'),\n        'ip_address': '192.168.1.1',\n        'user_agent': 'Mozilla/5.0...'\n    },\n    'event_metadata': {\n        'conversion_id': 'order_12345',\n        'value_decimal': 99.99,\n        'currency': 'USD',\n        'item_count': 2,\n        'products': [\n            {'id': 'SKU001', 'name': 'Product A', 'category': 'Electronics'},\n            {'id': 'SKU002', 'name': 'Product B', 'category': 'Electronics'}\n        ]\n    },\n    'click_id': 'reddit_click_id_from_url'\n}\n\nresult = send_conversion_event(access_token, 'pixel_123', [purchase_event])\n```\n\n### Important Notes\n\n- Events must occur within **last 7 days** to be processed\n- Maximum **500 events per batch** request\n- Include `click_id` when available for better attribution\n- Use `test_mode: true` for testing without affecting campaigns\n\n---\n\n## Custom Audiences\n\n### Audience Types\n\n| Type | Description |\n|------|-------------|\n| `CUSTOMER_LIST` | Upload hashed emails/phone/MAIDs |\n| `WEBSITE_VISITORS` | Pixel-based retargeting |\n| `LOOKALIKE` | Similar to source audience |\n\n### Create Customer List Audience\n\n```typescript\ninterface CustomAudienceCreate {\n  name: string;\n  type: 'CUSTOMER_LIST';\n  description?: string;\n  users: Array<{\n    email_sha256?: string;\n    maid_sha256?: string; // Mobile Advertising ID\n  }>;\n}\n\n// Create audience from customer emails\nconst audience: CustomAudienceCreate = {\n  name: 'High Value Customers Q4 2024',\n  type: 'CUSTOMER_LIST',\n  description: 'Customers with LTV > $500',\n  users: customerEmails.map(email => ({\n    email_sha256: hashPII(email)\n  }))\n};\n\nconst result = await client.createCustomAudience(audience);\n```\n\n### Minimum Audience Size\n\n- **1,000 matched users** minimum to be usable for targeting\n- Match rates displayed as ranges for privacy\n\n---\n\n## Reporting\n\n### Report Request\n\n```typescript\ninterface ReportRequest {\n  start_date: string; // YYYY-MM-DD\n  end_date: string; // YYYY-MM-DD\n  level: 'ACCOUNT' | 'CAMPAIGN' | 'AD_GROUP' | 'AD';\n  metrics: string[];\n  dimensions?: string[];\n  filters?: {\n    campaign_ids?: string[];\n    ad_group_ids?: string[];\n  };\n}\n\n// Get campaign performance report\nconst report = await client.getReport({\n  start_date: '2025-01-01',\n  end_date: '2025-01-31',\n  level: 'CAMPAIGN',\n  metrics: [\n    'impressions',\n    'clicks',\n    'spend',\n    'ctr',\n    'cpc',\n    'conversions',\n    'conversion_rate',\n    'cpa'\n  ],\n  dimensions: ['date']\n});\n```\n\n### Available Metrics\n\n| Metric | Description |\n|--------|-------------|\n| `impressions` | Total impressions |\n| `clicks` | Total clicks |\n| `spend` | Total spend (in account currency) |\n| `ctr` | Click-through rate |\n| `cpc` | Cost per click |\n| `cpm` | Cost per 1,000 impressions |\n| `conversions` | Total conversions |\n| `conversion_rate` | Conversions / Clicks |\n| `cpa` | Cost per acquisition |\n| `video_views` | Video view count |\n| `video_completions` | Videos watched to completion |\n\n---\n\n## Environment Variables\n\n```bash\n# .env\nREDDIT_ADS_CLIENT_ID=your_client_id\nREDDIT_ADS_CLIENT_SECRET=your_client_secret\nREDDIT_ADS_ACCOUNT_ID=t2_xxxxx\nREDDIT_ADS_ACCESS_TOKEN=your_access_token\nREDDIT_ADS_REFRESH_TOKEN=your_refresh_token\nREDDIT_ADS_PIXEL_ID=your_pixel_id\n```\n\n---\n\n## Best Practices\n\n### Campaign Structure\n\n```\n┌─────────────────────────────────────────────────────────────────┐\n│  RECOMMENDED STRUCTURE                                          │\n│  ─────────────────────────────────────────────────────────────  │\n│                                                                 │\n│  Campaign (by objective/product line)                           │\n│  ├── Ad Group: Subreddit Targeting - Tech                      │\n│  │   ├── Ad: Headline A + Image 1                              │\n│  │   └── Ad: Headline B + Image 1                              │\n│  ├── Ad Group: Subreddit Targeting - Business                  │\n│  │   ├── Ad: Headline A + Image 1                              │\n│  │   └── Ad: Headline B + Image 1                              │\n│  └── Ad Group: Interest Targeting - Entrepreneurs              │\n│      ├── Ad: Headline A + Image 2                              │\n│      └── Ad: Headline B + Image 2                              │\n│                                                                 │\n│  • Separate ad groups by targeting type                         │\n│  • Test 2-3 ad variations per ad group                          │\n│  • Use clear naming conventions                                 │\n└─────────────────────────────────────────────────────────────────┘\n```\n\n### Naming Conventions\n\n```\nCampaign:  [Objective] - [Product/Brand] - [Date Range]\n           Example: TRAFFIC - ProductX - Q1-2025\n\nAd Group:  [Targeting Type] - [Audience Description]\n           Example: Subreddits - Tech Enthusiasts\n\nAd:        [Headline Type] - [Creative Version]\n           Example: Problem-Solution - Image-A\n```\n\n### Rate Limiting\n\n- **1 request per second** limit\n- Implement exponential backoff for retries\n- Batch operations where possible\n\n```typescript\nasync function rateLimitedRequest<T>(\n  fn: () => Promise<T>,\n  retries = 3\n): Promise<T> {\n  for (let i = 0; i < retries; i++) {\n    try {\n      await new Promise(resolve => setTimeout(resolve, 1000)); // 1 second delay\n      return await fn();\n    } catch (error: any) {\n      if (error.status === 429 && i < retries - 1) {\n        const delay = Math.pow(2, i) * 1000;\n        await new Promise(resolve => setTimeout(resolve, delay));\n        continue;\n      }\n      throw error;\n    }\n  }\n  throw new Error('Max retries exceeded');\n}\n```\n\n---\n\n## Complete Workflow Example\n\n```typescript\n// Full campaign creation workflow\nasync function createRedditAdCampaign(\n  client: RedditAdsClient,\n  config: {\n    campaignName: string;\n    dailyBudget: number;\n    targetSubreddits: string[];\n    headline: string;\n    body: string;\n    landingUrl: string;\n    imageUrl: string;\n  }\n) {\n  // 1. Create Campaign\n  const campaign = await client.createCampaign({\n    name: config.campaignName,\n    objective: 'TRAFFIC',\n    is_enabled: false, // Start paused for review\n    budget_type: 'DAILY',\n    budget_total_amount_micros: config.dailyBudget * 1_000_000,\n    start_time: new Date().toISOString()\n  });\n\n  console.log(`Created campaign: ${campaign.id}`);\n\n  // 2. Create Ad Group with targeting\n  const adGroup = await client.createAdGroup({\n    name: `${config.campaignName} - Subreddit Targeting`,\n    campaign_id: campaign.id,\n    is_enabled: true,\n    bid_strategy: 'LOWEST_COST',\n    goal_type: 'CLICKS',\n    targeting: {\n      communities: config.targetSubreddits,\n      geo_locations: { countries: ['US'] },\n      devices: ['DESKTOP', 'MOBILE']\n    }\n  });\n\n  console.log(`Created ad group: ${adGroup.id}`);\n\n  // 3. Create Ad\n  const ad = await client.createAd({\n    name: `${config.campaignName} - Ad v1`,\n    ad_group_id: adGroup.id,\n    is_enabled: true,\n    type: 'LINK',\n    headline: config.headline,\n    body: config.body,\n    url: config.landingUrl,\n    call_to_action: 'LEARN_MORE',\n    thumbnail_url: config.imageUrl\n  });\n\n  console.log(`Created ad: ${ad.id}`);\n\n  return { campaign, adGroup, ad };\n}\n\n// Usage\nconst result = await createRedditAdCampaign(client, {\n  campaignName: 'Product Launch - Jan 2025',\n  dailyBudget: 50, // $50/day\n  targetSubreddits: ['technology', 'gadgets', 'programming'],\n  headline: 'Introducing the Future of Development',\n  body: 'Join 50,000+ developers using our tool to ship faster.',\n  landingUrl: 'https://yoursite.com?utm_source=reddit',\n  imageUrl: 'https://yoursite.com/ad-image.jpg'\n});\n```\n\n---\n\n## Testing\n\n### Test Checklist\n\n- [ ] OAuth flow completes successfully\n- [ ] Token refresh works before expiry\n- [ ] Campaign creates with correct budget\n- [ ] Ad group targeting is applied correctly\n- [ ] Ad creative displays properly\n- [ ] Conversion events tracked (use test_mode)\n- [ ] Reports return expected metrics\n- [ ] Rate limiting handled gracefully\n- [ ] Error responses handled properly\n\n### Mock API for Development\n\n```typescript\n// test/mocks/reddit-ads-mock.ts\nimport { rest } from 'msw';\n\nexport const redditAdsMocks = [\n  rest.post('https://www.reddit.com/api/v1/access_token', (req, res, ctx) => {\n    return res(ctx.json({\n      access_token: 'mock_access_token',\n      refresh_token: 'mock_refresh_token',\n      expires_in: 3600,\n      scope: 'adsread adsedit history'\n    }));\n  }),\n\n  rest.get('https://ads-api.reddit.com/api/v2.0/accounts/:accountId', (req, res, ctx) => {\n    return res(ctx.json({\n      id: req.params.accountId,\n      name: 'Test Account',\n      currency: 'USD'\n    }));\n  }),\n\n  rest.post('https://ads-api.reddit.com/api/v2.0/accounts/:accountId/campaigns', (req, res, ctx) => {\n    return res(ctx.json({\n      id: 'campaign_mock_123',\n      ...req.body\n    }));\n  })\n];\n```\n\n---\n\n## Troubleshooting\n\n| Error | Cause | Fix |\n|-------|-------|-----|\n| `401 Unauthorized` | Invalid/expired token | Refresh access token |\n| `403 Forbidden` | Account not whitelisted | Contact Reddit Ads support |\n| `429 Too Many Requests` | Rate limit exceeded | Implement backoff, slow down |\n| `400 Bad Request` | Invalid payload | Check required fields, data types |\n| `Audience too small` | < 1,000 matched users | Add more users to audience |\n\n---\n\n---\n\n## Agentic Optimization Service\n\n### Architecture Overview\n\n```\n┌─────────────────────────────────────────────────────────────────┐\n│  AGENTIC REDDIT ADS OPTIMIZER                                   │\n│  ─────────────────────────────────────────────────────────────  │\n│                                                                 │\n│  ┌─────────────┐    ┌─────────────┐    ┌─────────────┐         │\n│  │  Scheduler  │───▶│  Analyzer   │───▶│  Optimizer  │         │\n│  │  (Cron)     │    │  (AI/LLM)   │    │  (Actions)  │         │\n│  └─────────────┘    └─────────────┘    └─────────────┘         │\n│         │                  │                  │                 │\n│         ▼                  ▼                  ▼                 │\n│  ┌─────────────┐    ┌─────────────┐    ┌─────────────┐         │\n│  │  Fetch      │    │  Decide     │    │  Execute    │         │\n│  │  Reports    │    │  Strategy   │    │  Changes    │         │\n│  └─────────────┘    └─────────────┘    └─────────────┘         │\n│                                                                 │\n│  Loop: Every 4-6 hours                                          │\n│  Actions: Pause losers, scale winners, adjust bids, rotate ads  │\n└─────────────────────────────────────────────────────────────────┘\n```\n\n### Background Service (Node.js)\n\n```typescript\n// services/reddit-ads-optimizer.ts\nimport Anthropic from '@anthropic-ai/sdk';\nimport { CronJob } from 'cron';\nimport RedditAdsClient from '../lib/reddit-ads-client';\n\ninterface OptimizationConfig {\n  accountId: string;\n  accessToken: string;\n  refreshToken: string;\n  // Thresholds\n  minCTR: number;           // Pause ads below this CTR (e.g., 0.005 = 0.5%)\n  maxCPA: number;           // Pause ads above this CPA\n  minImpressions: number;   // Min impressions before decisions (e.g., 1000)\n  budgetScaleFactor: number; // Scale winning ad groups by this factor (e.g., 1.5)\n  // Optimization settings\n  optimizationGoal: 'CLICKS' | 'CONVERSIONS' | 'ROAS';\n  checkIntervalHours: number;\n}\n\ninterface PerformanceData {\n  campaignId: string;\n  adGroupId: string;\n  adId: string;\n  impressions: number;\n  clicks: number;\n  spend: number;\n  conversions: number;\n  ctr: number;\n  cpc: number;\n  cpa: number;\n  roas: number;\n}\n\nclass RedditAdsOptimizerService {\n  private client: RedditAdsClient;\n  private anthropic: Anthropic;\n  private config: OptimizationConfig;\n  private cronJob: CronJob | null = null;\n\n  constructor(config: OptimizationConfig) {\n    this.config = config;\n    this.client = new RedditAdsClient({\n      accessToken: config.accessToken,\n      accountId: config.accountId\n    });\n    this.anthropic = new Anthropic();\n  }\n\n  // Start the background optimization service\n  start() {\n    const cronSchedule = `0 */${this.config.checkIntervalHours} * * *`;\n\n    this.cronJob = new CronJob(cronSchedule, async () => {\n      console.log(`[${new Date().toISOString()}] Running optimization cycle...`);\n      await this.runOptimizationCycle();\n    });\n\n    this.cronJob.start();\n    console.log(`Reddit Ads Optimizer started. Running every ${this.config.checkIntervalHours} hours.`);\n  }\n\n  stop() {\n    if (this.cronJob) {\n      this.cronJob.stop();\n      console.log('Reddit Ads Optimizer stopped.');\n    }\n  }\n\n  // Main optimization cycle\n  async runOptimizationCycle() {\n    try {\n      // 1. Fetch performance data\n      const performanceData = await this.fetchPerformanceData();\n\n      // 2. Analyze with AI agent\n      const recommendations = await this.analyzeWithAgent(performanceData);\n\n      // 3. Execute optimizations\n      await this.executeOptimizations(recommendations);\n\n      // 4. Log results\n      await this.logOptimizationResults(recommendations);\n\n    } catch (error) {\n      console.error('Optimization cycle failed:', error);\n      await this.sendAlert('Optimization cycle failed', error);\n    }\n  }\n\n  // Fetch last 24h performance data\n  private async fetchPerformanceData(): Promise<PerformanceData[]> {\n    const endDate = new Date();\n    const startDate = new Date(endDate.getTime() - 24 * 60 * 60 * 1000);\n\n    const report = await this.client.getReport({\n      start_date: startDate.toISOString().split('T')[0],\n      end_date: endDate.toISOString().split('T')[0],\n      level: 'AD',\n      metrics: [\n        'impressions', 'clicks', 'spend', 'conversions',\n        'ctr', 'cpc', 'cpa', 'conversion_value'\n      ]\n    });\n\n    return report.data.map((row: any) => ({\n      campaignId: row.campaign_id,\n      adGroupId: row.ad_group_id,\n      adId: row.ad_id,\n      impressions: row.impressions,\n      clicks: row.clicks,\n      spend: row.spend,\n      conversions: row.conversions || 0,\n      ctr: row.ctr,\n      cpc: row.cpc,\n      cpa: row.cpa || 0,\n      roas: row.conversion_value ? row.conversion_value / row.spend : 0\n    }));\n  }\n\n  // AI-powered analysis and decision making\n  private async analyzeWithAgent(data: PerformanceData[]): Promise<OptimizationRecommendation[]> {\n    const prompt = `You are a Reddit Ads optimization agent. Analyze the following campaign performance data and recommend specific actions.\n\n## Performance Data (Last 24 Hours)\n${JSON.stringify(data, null, 2)}\n\n## Optimization Configuration\n- Goal: ${this.config.optimizationGoal}\n- Min CTR threshold: ${this.config.minCTR * 100}%\n- Max CPA threshold: $${this.config.maxCPA}\n- Min impressions for decisions: ${this.config.minImpressions}\n- Budget scale factor for winners: ${this.config.budgetScaleFactor}x\n\n## Your Task\nAnalyze each ad/ad group and recommend ONE action per item:\n1. PAUSE - Poor performers (low CTR, high CPA, no conversions after sufficient impressions)\n2. SCALE - Winners (high CTR, low CPA, good ROAS) - increase budget\n3. ADJUST_BID - Moderate performers - suggest bid adjustment\n4. KEEP - Insufficient data or acceptable performance\n5. ROTATE_CREATIVE - Good targeting but ad fatigue (declining CTR over time)\n\nReturn a JSON array of recommendations:\n[\n  {\n    \"adId\": \"string\",\n    \"adGroupId\": \"string\",\n    \"action\": \"PAUSE|SCALE|ADJUST_BID|KEEP|ROTATE_CREATIVE\",\n    \"reason\": \"Brief explanation\",\n    \"newBidMicros\": number (optional, for ADJUST_BID),\n    \"budgetMultiplier\": number (optional, for SCALE)\n  }\n]\n\nBe aggressive with pausing poor performers to protect budget. Be conservative with scaling (only clear winners).`;\n\n    const response = await this.anthropic.messages.create({\n      model: 'claude-sonnet-4-20250514',\n      max_tokens: 4096,\n      messages: [{ role: 'user', content: prompt }]\n    });\n\n    const content = response.content[0];\n    if (content.type !== 'text') throw new Error('Unexpected response type');\n\n    // Extract JSON from response\n    const jsonMatch = content.text.match(/\\[[\\s\\S]*\\]/);\n    if (!jsonMatch) throw new Error('No JSON found in response');\n\n    return JSON.parse(jsonMatch[0]);\n  }\n\n  // Execute the AI recommendations\n  private async executeOptimizations(recommendations: OptimizationRecommendation[]) {\n    for (const rec of recommendations) {\n      try {\n        switch (rec.action) {\n          case 'PAUSE':\n            await this.client.updateAd(rec.adId, { is_enabled: false });\n            console.log(`Paused ad ${rec.adId}: ${rec.reason}`);\n            break;\n\n          case 'SCALE':\n            const adGroup = await this.client.getAdGroup(rec.adGroupId);\n            const currentBudget = adGroup.budget_total_amount_micros;\n            const newBudget = Math.round(currentBudget * (rec.budgetMultiplier || this.config.budgetScaleFactor));\n            await this.client.updateAdGroup(rec.adGroupId, {\n              budget_total_amount_micros: newBudget\n            });\n            console.log(`Scaled ad group ${rec.adGroupId} budget to ${newBudget / 1_000_000}: ${rec.reason}`);\n            break;\n\n          case 'ADJUST_BID':\n            if (rec.newBidMicros) {\n              await this.client.updateAdGroup(rec.adGroupId, {\n                bid_amount_micros: rec.newBidMicros\n              });\n              console.log(`Adjusted bid for ${rec.adGroupId} to ${rec.newBidMicros / 1_000_000}: ${rec.reason}`);\n            }\n            break;\n\n          case 'ROTATE_CREATIVE':\n            // Flag for creative refresh (implement your creative rotation logic)\n            console.log(`Creative rotation needed for ${rec.adId}: ${rec.reason}`);\n            await this.flagForCreativeRefresh(rec.adId);\n            break;\n\n          case 'KEEP':\n            // No action needed\n            break;\n        }\n      } catch (error) {\n        console.error(`Failed to execute ${rec.action} for ${rec.adId}:`, error);\n      }\n    }\n  }\n\n  private async flagForCreativeRefresh(adId: string) {\n    // Implement: Add to queue, notify team, or auto-generate new creative\n  }\n\n  private async logOptimizationResults(recommendations: OptimizationRecommendation[]) {\n    const summary = {\n      timestamp: new Date().toISOString(),\n      totalRecommendations: recommendations.length,\n      actions: {\n        paused: recommendations.filter(r => r.action === 'PAUSE').length,\n        scaled: recommendations.filter(r => r.action === 'SCALE').length,\n        bidAdjusted: recommendations.filter(r => r.action === 'ADJUST_BID').length,\n        creativeRotation: recommendations.filter(r => r.action === 'ROTATE_CREATIVE').length,\n        kept: recommendations.filter(r => r.action === 'KEEP').length\n      }\n    };\n    console.log('Optimization Summary:', JSON.stringify(summary, null, 2));\n    // Store in database for historical analysis\n  }\n\n  private async sendAlert(subject: string, error: any) {\n    // Implement: Send email/Slack notification\n  }\n}\n\ninterface OptimizationRecommendation {\n  adId: string;\n  adGroupId: string;\n  action: 'PAUSE' | 'SCALE' | 'ADJUST_BID' | 'KEEP' | 'ROTATE_CREATIVE';\n  reason: string;\n  newBidMicros?: number;\n  budgetMultiplier?: number;\n}\n\nexport default RedditAdsOptimizerService;\n```\n\n### Background Service (Python)\n\n```python\n# services/reddit_ads_optimizer.py\nimport anthropic\nimport schedule\nimport time\nimport json\nfrom datetime import datetime, timedelta\nfrom typing import List, Dict, Any, Optional\nfrom dataclasses import dataclass\nfrom enum import Enum\n\nfrom lib.reddit_ads_client import RedditAdsClient, RedditAdsConfig\n\nclass OptimizationAction(Enum):\n    PAUSE = \"PAUSE\"\n    SCALE = \"SCALE\"\n    ADJUST_BID = \"ADJUST_BID\"\n    KEEP = \"KEEP\"\n    ROTATE_CREATIVE = \"ROTATE_CREATIVE\"\n\n@dataclass\nclass OptimizationConfig:\n    account_id: str\n    access_token: str\n    refresh_token: str\n    min_ctr: float = 0.005  # 0.5%\n    max_cpa: float = 50.0\n    min_impressions: int = 1000\n    budget_scale_factor: float = 1.5\n    optimization_goal: str = \"CONVERSIONS\"\n    check_interval_hours: int = 4\n\n@dataclass\nclass PerformanceData:\n    campaign_id: str\n    ad_group_id: str\n    ad_id: str\n    impressions: int\n    clicks: int\n    spend: float\n    conversions: int\n    ctr: float\n    cpc: float\n    cpa: float\n    roas: float\n\n@dataclass\nclass OptimizationRecommendation:\n    ad_id: str\n    ad_group_id: str\n    action: OptimizationAction\n    reason: str\n    new_bid_micros: Optional[int] = None\n    budget_multiplier: Optional[float] = None\n\nclass RedditAdsOptimizerService:\n    def __init__(self, config: OptimizationConfig):\n        self.config = config\n        self.client = RedditAdsClient(RedditAdsConfig(\n            access_token=config.access_token,\n            account_id=config.account_id\n        ))\n        self.anthropic = anthropic.Anthropic()\n        self._running = False\n\n    def start(self):\n        \"\"\"Start the background optimization service.\"\"\"\n        self._running = True\n\n        # Schedule optimization runs\n        schedule.every(self.config.check_interval_hours).hours.do(\n            self.run_optimization_cycle\n        )\n\n        print(f\"Reddit Ads Optimizer started. Running every {self.config.check_interval_hours} hours.\")\n\n        # Run immediately on start\n        self.run_optimization_cycle()\n\n        # Keep running\n        while self._running:\n            schedule.run_pending()\n            time.sleep(60)\n\n    def stop(self):\n        \"\"\"Stop the optimization service.\"\"\"\n        self._running = False\n        print(\"Reddit Ads Optimizer stopped.\")\n\n    def run_optimization_cycle(self):\n        \"\"\"Main optimization cycle.\"\"\"\n        print(f\"[{datetime.now().isoformat()}] Running optimization cycle...\")\n\n        try:\n            # 1. Fetch performance data\n            performance_data = self._fetch_performance_data()\n\n            # 2. Analyze with AI agent\n            recommendations = self._analyze_with_agent(performance_data)\n\n            # 3. Execute optimizations\n            self._execute_optimizations(recommendations)\n\n            # 4. Log results\n            self._log_optimization_results(recommendations)\n\n        except Exception as e:\n            print(f\"Optimization cycle failed: {e}\")\n            self._send_alert(\"Optimization cycle failed\", str(e))\n\n    def _fetch_performance_data(self) -> List[PerformanceData]:\n        \"\"\"Fetch last 24h performance data.\"\"\"\n        end_date = datetime.now()\n        start_date = end_date - timedelta(days=1)\n\n        report = self.client.get_report({\n            'start_date': start_date.strftime('%Y-%m-%d'),\n            'end_date': end_date.strftime('%Y-%m-%d'),\n            'level': 'AD',\n            'metrics': [\n                'impressions', 'clicks', 'spend', 'conversions',\n                'ctr', 'cpc', 'cpa', 'conversion_value'\n            ]\n        })\n\n        return [\n            PerformanceData(\n                campaign_id=row['campaign_id'],\n                ad_group_id=row['ad_group_id'],\n                ad_id=row['ad_id'],\n                impressions=row['impressions'],\n                clicks=row['clicks'],\n                spend=row['spend'],\n                conversions=row.get('conversions', 0),\n                ctr=row['ctr'],\n                cpc=row['cpc'],\n                cpa=row.get('cpa', 0),\n                roas=row.get('conversion_value', 0) / row['spend'] if row['spend'] > 0 else 0\n            )\n            for row in report.get('data', [])\n        ]\n\n    def _analyze_with_agent(self, data: List[PerformanceData]) -> List[OptimizationRecommendation]:\n        \"\"\"AI-powered analysis and decision making.\"\"\"\n\n        prompt = f\"\"\"You are a Reddit Ads optimization agent. Analyze the following campaign performance data and recommend specific actions.\n\n## Performance Data (Last 24 Hours)\n{json.dumps([vars(d) for d in data], indent=2)}\n\n## Optimization Configuration\n- Goal: {self.config.optimization_goal}\n- Min CTR threshold: {self.config.min_ctr * 100}%\n- Max CPA threshold: ${self.config.max_cpa}\n- Min impressions for decisions: {self.config.min_impressions}\n- Budget scale factor for winners: {self.config.budget_scale_factor}x\n\n## Your Task\nAnalyze each ad/ad group and recommend ONE action per item:\n1. PAUSE - Poor performers (low CTR, high CPA, no conversions after sufficient impressions)\n2. SCALE - Winners (high CTR, low CPA, good ROAS) - increase budget\n3. ADJUST_BID - Moderate performers - suggest bid adjustment\n4. KEEP - Insufficient data or acceptable performance\n5. ROTATE_CREATIVE - Good targeting but ad fatigue (declining CTR over time)\n\nReturn a JSON array of recommendations:\n[\n  {{\n    \"ad_id\": \"string\",\n    \"ad_group_id\": \"string\",\n    \"action\": \"PAUSE|SCALE|ADJUST_BID|KEEP|ROTATE_CREATIVE\",\n    \"reason\": \"Brief explanation\",\n    \"new_bid_micros\": number (optional, for ADJUST_BID),\n    \"budget_multiplier\": number (optional, for SCALE)\n  }}\n]\n\nBe aggressive with pausing poor performers to protect budget. Be conservative with scaling (only clear winners).\"\"\"\n\n        response = self.anthropic.messages.create(\n            model=\"claude-sonnet-4-20250514\",\n            max_tokens=4096,\n            messages=[{\"role\": \"user\", \"content\": prompt}]\n        )\n\n        content = response.content[0].text\n\n        # Extract JSON from response\n        import re\n        json_match = re.search(r'\\[[\\s\\S]*\\]', content)\n        if not json_match:\n            raise ValueError(\"No JSON found in response\")\n\n        recommendations_data = json.loads(json_match.group())\n\n        return [\n            OptimizationRecommendation(\n                ad_id=r['ad_id'],\n                ad_group_id=r['ad_group_id'],\n                action=OptimizationAction(r['action']),\n                reason=r['reason'],\n                new_bid_micros=r.get('new_bid_micros'),\n                budget_multiplier=r.get('budget_multiplier')\n            )\n            for r in recommendations_data\n        ]\n\n    def _execute_optimizations(self, recommendations: List[OptimizationRecommendation]):\n        \"\"\"Execute the AI recommendations.\"\"\"\n        for rec in recommendations:\n            try:\n                if rec.action == OptimizationAction.PAUSE:\n                    self.client.update_ad(rec.ad_id, {'is_enabled': False})\n                    print(f\"Paused ad {rec.ad_id}: {rec.reason}\")\n\n                elif rec.action == OptimizationAction.SCALE:\n                    ad_group = self.client.get_ad_group(rec.ad_group_id)\n                    current_budget = ad_group['budget_total_amount_micros']\n                    multiplier = rec.budget_multiplier or self.config.budget_scale_factor\n                    new_budget = int(current_budget * multiplier)\n                    self.client.update_ad_group(rec.ad_group_id, {\n                        'budget_total_amount_micros': new_budget\n                    })\n                    print(f\"Scaled ad group {rec.ad_group_id} budget to ${new_budget / 1_000_000}: {rec.reason}\")\n\n                elif rec.action == OptimizationAction.ADJUST_BID:\n                    if rec.new_bid_micros:\n                        self.client.update_ad_group(rec.ad_group_id, {\n                            'bid_amount_micros': rec.new_bid_micros\n                        })\n                        print(f\"Adjusted bid for {rec.ad_group_id}: {rec.reason}\")\n\n                elif rec.action == OptimizationAction.ROTATE_CREATIVE:\n                    print(f\"Creative rotation needed for {rec.ad_id}: {rec.reason}\")\n                    self._flag_for_creative_refresh(rec.ad_id)\n\n            except Exception as e:\n                print(f\"Failed to execute {rec.action} for {rec.ad_id}: {e}\")\n\n    def _flag_for_creative_refresh(self, ad_id: str):\n        \"\"\"Flag ad for creative refresh.\"\"\"\n        # Implement: Add to queue, notify team, or auto-generate new creative\n        pass\n\n    def _log_optimization_results(self, recommendations: List[OptimizationRecommendation]):\n        \"\"\"Log optimization results.\"\"\"\n        summary = {\n            'timestamp': datetime.now().isoformat(),\n            'total_recommendations': len(recommendations),\n            'actions': {\n                'paused': len([r for r in recommendations if r.action == OptimizationAction.PAUSE]),\n                'scaled': len([r for r in recommendations if r.action == OptimizationAction.SCALE]),\n                'bid_adjusted': len([r for r in recommendations if r.action == OptimizationAction.ADJUST_BID]),\n                'creative_rotation': len([r for r in recommendations if r.action == OptimizationAction.ROTATE_CREATIVE]),\n                'kept': len([r for r in recommendations if r.action == OptimizationAction.KEEP]),\n            }\n        }\n        print(f\"Optimization Summary: {json.dumps(summary, indent=2)}\")\n\n    def _send_alert(self, subject: str, error: str):\n        \"\"\"Send alert notification.\"\"\"\n        # Implement: Send email/Slack notification\n        pass\n\n\n# Entry point for running as background service\nif __name__ == \"__main__\":\n    import os\n\n    config = OptimizationConfig(\n        account_id=os.environ['REDDIT_ADS_ACCOUNT_ID'],\n        access_token=os.environ['REDDIT_ADS_ACCESS_TOKEN'],\n        refresh_token=os.environ['REDDIT_ADS_REFRESH_TOKEN'],\n        min_ctr=0.005,\n        max_cpa=50.0,\n        min_impressions=1000,\n        budget_scale_factor=1.5,\n        optimization_goal=\"CONVERSIONS\",\n        check_interval_hours=4\n    )\n\n    optimizer = RedditAdsOptimizerService(config)\n    optimizer.start()\n```\n\n### Docker Deployment\n\n```dockerfile\n# Dockerfile\nFROM python:3.11-slim\n\nWORKDIR /app\n\nCOPY requirements.txt .\nRUN pip install --no-cache-dir -r requirements.txt\n\nCOPY . .\n\nCMD [\"python\", \"services/reddit_ads_optimizer.py\"]\n```\n\n```yaml\n# docker-compose.yml\nversion: '3.8'\n\nservices:\n  reddit-ads-optimizer:\n    build: .\n    container_name: reddit-ads-optimizer\n    restart: unless-stopped\n    environment:\n      - REDDIT_ADS_CLIENT_ID=${REDDIT_ADS_CLIENT_ID}\n      - REDDIT_ADS_CLIENT_SECRET=${REDDIT_ADS_CLIENT_SECRET}\n      - REDDIT_ADS_ACCOUNT_ID=${REDDIT_ADS_ACCOUNT_ID}\n      - REDDIT_ADS_ACCESS_TOKEN=${REDDIT_ADS_ACCESS_TOKEN}\n      - REDDIT_ADS_REFRESH_TOKEN=${REDDIT_ADS_REFRESH_TOKEN}\n      - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}\n    volumes:\n      - ./logs:/app/logs\n    logging:\n      driver: \"json-file\"\n      options:\n        max-size: \"10m\"\n        max-file: \"3\"\n```\n\n### Optimization Strategies\n\n```\n┌─────────────────────────────────────────────────────────────────┐\n│  AGENTIC OPTIMIZATION STRATEGIES                                │\n├─────────────────────────────────────────────────────────────────┤\n│                                                                 │\n│  1. PERFORMANCE-BASED PAUSING                                   │\n│     ─────────────────────────────────────────────────────────  │\n│     IF impressions > 1000 AND ctr < 0.3% → PAUSE               │\n│     IF impressions > 500 AND conversions = 0 → PAUSE           │\n│     IF cpa > 2x target → PAUSE                                  │\n│                                                                 │\n│  2. WINNER SCALING                                              │\n│     ─────────────────────────────────────────────────────────  │\n│     IF ctr > 1% AND cpa < target AND conversions > 5           │\n│     → SCALE budget by 1.5x                                      │\n│     Cap at 3x original budget to manage risk                    │\n│                                                                 │\n│  3. BID OPTIMIZATION                                            │\n│     ─────────────────────────────────────────────────────────  │\n│     IF position low AND ctr good → INCREASE bid 10-20%         │\n│     IF cpa high but converting → DECREASE bid 10-15%           │\n│                                                                 │\n│  4. CREATIVE FATIGUE DETECTION                                  │\n│     ─────────────────────────────────────────────────────────  │\n│     IF ctr declining 3 consecutive days → ROTATE_CREATIVE      │\n│     IF frequency > 3 → ROTATE_CREATIVE                          │\n│                                                                 │\n│  5. BUDGET REALLOCATION                                         │\n│     ─────────────────────────────────────────────────────────  │\n│     Move budget from paused ads to scaled winners              │\n│     Maintain total daily budget cap                             │\n└─────────────────────────────────────────────────────────────────┘\n```\n\n### Advanced: Multi-Agent Optimization\n\n```typescript\n// services/multi-agent-optimizer.ts\nimport Anthropic from '@anthropic-ai/sdk';\n\ninterface AgentRole {\n  name: string;\n  systemPrompt: string;\n}\n\nconst AGENTS: AgentRole[] = [\n  {\n    name: 'Performance Analyst',\n    systemPrompt: `You analyze Reddit Ads performance data. Identify:\n    - Top performers (high CTR, low CPA, good ROAS)\n    - Poor performers (low CTR, high CPA, no conversions)\n    - Trends (improving, declining, stable)\n    Output structured analysis with confidence scores.`\n  },\n  {\n    name: 'Budget Strategist',\n    systemPrompt: `You optimize budget allocation across campaigns.\n    Given performance analysis, recommend:\n    - Budget increases for winners (max 50% increase)\n    - Budget decreases for losers\n    - Reallocation between ad groups\n    Protect total budget while maximizing ROI.`\n  },\n  {\n    name: 'Creative Director',\n    systemPrompt: `You evaluate ad creative performance.\n    Identify ads with:\n    - Creative fatigue (declining engagement)\n    - High potential but poor execution\n    - A/B test winners\n    Recommend creative refreshes and new variations.`\n  },\n  {\n    name: 'Risk Manager',\n    systemPrompt: `You ensure optimization safety.\n    Review recommendations and flag:\n    - Overly aggressive scaling\n    - Insufficient data for decisions\n    - Budget concentration risk\n    - Compliance concerns\n    Approve, modify, or reject recommendations.`\n  }\n];\n\nclass MultiAgentOptimizer {\n  private anthropic: Anthropic;\n\n  constructor() {\n    this.anthropic = new Anthropic();\n  }\n\n  async runAgentPipeline(performanceData: any) {\n    let context = { performanceData };\n\n    // Run agents in sequence, each building on previous output\n    for (const agent of AGENTS) {\n      const response = await this.anthropic.messages.create({\n        model: 'claude-sonnet-4-20250514',\n        max_tokens: 4096,\n        system: agent.systemPrompt,\n        messages: [{\n          role: 'user',\n          content: `Previous context:\\n${JSON.stringify(context, null, 2)}\\n\\nProvide your analysis and recommendations.`\n        }]\n      });\n\n      context = {\n        ...context,\n        [agent.name.toLowerCase().replace(' ', '_')]: response.content[0]\n      };\n    }\n\n    return context;\n  }\n}\n```\n\n### Monitoring Dashboard Data\n\n```typescript\n// api/optimization-stats.ts\ninterface OptimizationStats {\n  period: string;\n  totalOptimizations: number;\n  actionBreakdown: {\n    paused: number;\n    scaled: number;\n    bidAdjusted: number;\n    creativeRotated: number;\n  };\n  performanceImpact: {\n    ctrChange: number;\n    cpaChange: number;\n    roasChange: number;\n    spendEfficiency: number;\n  };\n  budgetSaved: number;\n  revenueIncreased: number;\n}\n\nasync function getOptimizationStats(\n  startDate: Date,\n  endDate: Date\n): Promise<OptimizationStats> {\n  // Query optimization logs and performance data\n  // Calculate before/after metrics\n  // Return aggregated stats\n}\n```\n\n---\n\n## Resources\n\n- [Reddit Ads API Docs](https://ads-api.reddit.com/docs/)\n- [Reddit Developer Portal](https://www.reddit.com/prefs/apps/)\n- [Reddit Ads Help Center](https://business.reddithelp.com/s/article/Reddit-Ads-API)\n- [OAuth2 Documentation](https://www.reddit.com/dev/api/oauth/)\n"
  },
  {
    "path": "skills/reddit-api/SKILL.md",
    "content": "---\nname: reddit-api\ndescription: Reddit API with PRAW (Python) and Snoowrap (Node.js)\nwhen-to-use: When building Reddit integrations or bots\nuser-invocable: false\neffort: medium\n---\n\n# Reddit API Skill\n\n\nFor integrating Reddit data into applications - fetching posts, comments, subreddits, and user data.\n\n**Sources:** [Reddit API Docs](https://www.reddit.com/dev/api/) | [OAuth2 Wiki](https://github.com/reddit-archive/reddit/wiki/oauth2) | [PRAW Docs](https://praw.readthedocs.io/)\n\n---\n\n## Setup\n\n### 1. Create Reddit App\n\n1. Go to https://www.reddit.com/prefs/apps\n2. Click \"Create App\" or \"Create Another App\"\n3. Fill in:\n   - **Name**: Your app name\n   - **App type**:\n     - `script` - For personal use / bots you control\n     - `web app` - For server-side apps with user auth\n     - `installed app` - For mobile/desktop apps\n   - **Redirect URI**: `http://localhost:8000/callback` (for dev)\n4. Note your `client_id` (under app name) and `client_secret`\n\n### 2. Environment Variables\n\n```bash\n# .env\nREDDIT_CLIENT_ID=your_client_id\nREDDIT_CLIENT_SECRET=your_client_secret\nREDDIT_USER_AGENT=YourApp/1.0 by YourUsername\nREDDIT_USERNAME=your_username        # For script apps only\nREDDIT_PASSWORD=your_password        # For script apps only\n```\n\n**User-Agent Format**: `<platform>:<app_id>:<version> (by /u/<username>)`\n\n---\n\n## Rate Limits\n\n| Tier | Limit | Notes |\n|------|-------|-------|\n| OAuth authenticated | 100 QPM | Per OAuth client ID |\n| Non-authenticated | Blocked | Must use OAuth |\n\n- Limits averaged over 10-minute window\n- Include `User-Agent` header to avoid blocks\n- Respect `X-Ratelimit-*` response headers\n\n---\n\n## Python: PRAW (Recommended)\n\n### Installation\n\n```bash\npip install praw\n# or\nuv add praw\n```\n\n### Script App (Personal Use / Bots)\n\n```python\nimport praw\nfrom pydantic_settings import BaseSettings\n\nclass RedditSettings(BaseSettings):\n    reddit_client_id: str\n    reddit_client_secret: str\n    reddit_user_agent: str\n    reddit_username: str\n    reddit_password: str\n\n    class Config:\n        env_file = \".env\"\n\nsettings = RedditSettings()\n\nreddit = praw.Reddit(\n    client_id=settings.reddit_client_id,\n    client_secret=settings.reddit_client_secret,\n    user_agent=settings.reddit_user_agent,\n    username=settings.reddit_username,\n    password=settings.reddit_password,\n)\n\n# Verify authentication\nprint(f\"Logged in as: {reddit.user.me()}\")\n```\n\n### Read-Only (No User Auth)\n\n```python\nimport praw\n\nreddit = praw.Reddit(\n    client_id=\"your_client_id\",\n    client_secret=\"your_client_secret\",\n    user_agent=\"YourApp/1.0 by YourUsername\",\n)\n\n# Read-only mode - can browse, can't post/vote\nreddit.read_only = True\n```\n\n### Common Operations\n\n```python\n# Get subreddit posts\nsubreddit = reddit.subreddit(\"python\")\n\n# Hot posts\nfor post in subreddit.hot(limit=10):\n    print(f\"{post.title} - {post.score} upvotes\")\n\n# New posts\nfor post in subreddit.new(limit=10):\n    print(post.title)\n\n# Search posts\nfor post in subreddit.search(\"pydantic\", limit=5):\n    print(post.title)\n\n# Get specific post\nsubmission = reddit.submission(id=\"abc123\")\nprint(submission.title)\nprint(submission.selftext)\n\n# Get comments\nsubmission.comments.replace_more(limit=0)  # Flatten comment tree\nfor comment in submission.comments.list():\n    print(f\"{comment.author}: {comment.body[:100]}\")\n```\n\n### Posting & Voting (Requires Auth)\n\n```python\n# Submit text post\nsubreddit = reddit.subreddit(\"test\")\nsubmission = subreddit.submit(\n    title=\"Test Post\",\n    selftext=\"This is the body of my post.\"\n)\n\n# Submit link post\nsubmission = subreddit.submit(\n    title=\"Check this out\",\n    url=\"https://example.com\"\n)\n\n# Vote\nsubmission.upvote()\nsubmission.downvote()\nsubmission.clear_vote()\n\n# Comment\nsubmission.reply(\"Great post!\")\n\n# Reply to comment\ncomment = reddit.comment(id=\"xyz789\")\ncomment.reply(\"I agree!\")\n```\n\n### Streaming (Real-time)\n\n```python\n# Stream new posts\nfor post in reddit.subreddit(\"python\").stream.submissions():\n    print(f\"New post: {post.title}\")\n    # Process post...\n\n# Stream new comments\nfor comment in reddit.subreddit(\"python\").stream.comments():\n    print(f\"New comment by {comment.author}: {comment.body[:50]}\")\n```\n\n### User Data\n\n```python\n# Get user info\nuser = reddit.redditor(\"spez\")\nprint(f\"Karma: {user.link_karma + user.comment_karma}\")\n\n# User's posts\nfor post in user.submissions.new(limit=5):\n    print(post.title)\n\n# User's comments\nfor comment in user.comments.new(limit=5):\n    print(comment.body[:100])\n```\n\n---\n\n## TypeScript / Node.js: Snoowrap\n\n### Installation\n\n```bash\nnpm install snoowrap\n# or\npnpm add snoowrap\n```\n\n### Setup\n\n```typescript\nimport Snoowrap from \"snoowrap\";\n\nconst reddit = new Snoowrap({\n  userAgent: \"YourApp/1.0 by YourUsername\",\n  clientId: process.env.REDDIT_CLIENT_ID!,\n  clientSecret: process.env.REDDIT_CLIENT_SECRET!,\n  username: process.env.REDDIT_USERNAME!,\n  password: process.env.REDDIT_PASSWORD!,\n});\n\n// Configure rate limiting\nreddit.config({\n  requestDelay: 1000,  // 1 second between requests\n  continueAfterRatelimitError: true,\n});\n```\n\n### Common Operations\n\n```typescript\n// Get hot posts from subreddit\nconst posts = await reddit.getSubreddit(\"typescript\").getHot({ limit: 10 });\nposts.forEach((post) => {\n  console.log(`${post.title} - ${post.score} upvotes`);\n});\n\n// Search posts\nconst results = await reddit.getSubreddit(\"programming\").search({\n  query: \"typescript\",\n  sort: \"relevance\",\n  time: \"month\",\n  limit: 10,\n});\n\n// Get specific post\nconst submission = await reddit.getSubmission(\"abc123\").fetch();\nconsole.log(submission.title);\n\n// Get comments\nconst comments = await submission.comments.fetchAll();\ncomments.forEach((comment) => {\n  console.log(`${comment.author.name}: ${comment.body.slice(0, 100)}`);\n});\n```\n\n### Posting\n\n```typescript\n// Submit text post\nconst post = await reddit.getSubreddit(\"test\").submitSelfpost({\n  title: \"Test Post\",\n  text: \"This is the body.\",\n});\n\n// Submit link\nconst linkPost = await reddit.getSubreddit(\"test\").submitLink({\n  title: \"Check this out\",\n  url: \"https://example.com\",\n});\n\n// Vote and comment\nawait post.upvote();\nawait post.reply(\"Great post!\");\n```\n\n---\n\n## Direct API (No Library)\n\n### Python with httpx\n\n```python\nimport httpx\nimport base64\nfrom pydantic import BaseModel\n\nclass RedditClient:\n    def __init__(self, client_id: str, client_secret: str, user_agent: str):\n        self.client_id = client_id\n        self.client_secret = client_secret\n        self.user_agent = user_agent\n        self.access_token: str | None = None\n        self.client = httpx.AsyncClient()\n\n    async def authenticate(self) -> None:\n        \"\"\"Get application-only OAuth token.\"\"\"\n        auth = base64.b64encode(\n            f\"{self.client_id}:{self.client_secret}\".encode()\n        ).decode()\n\n        response = await self.client.post(\n            \"https://www.reddit.com/api/v1/access_token\",\n            headers={\n                \"Authorization\": f\"Basic {auth}\",\n                \"User-Agent\": self.user_agent,\n            },\n            data={\n                \"grant_type\": \"client_credentials\",\n            },\n        )\n        response.raise_for_status()\n        self.access_token = response.json()[\"access_token\"]\n\n    async def get_posts(self, subreddit: str, sort: str = \"hot\", limit: int = 10) -> list[dict]:\n        \"\"\"Get posts from a subreddit.\"\"\"\n        if not self.access_token:\n            await self.authenticate()\n\n        response = await self.client.get(\n            f\"https://oauth.reddit.com/r/{subreddit}/{sort}\",\n            headers={\n                \"Authorization\": f\"Bearer {self.access_token}\",\n                \"User-Agent\": self.user_agent,\n            },\n            params={\"limit\": limit},\n        )\n        response.raise_for_status()\n        return [post[\"data\"] for post in response.json()[\"data\"][\"children\"]]\n\n    async def close(self) -> None:\n        await self.client.aclose()\n\n\n# Usage\nasync def main():\n    client = RedditClient(\n        client_id=\"your_id\",\n        client_secret=\"your_secret\",\n        user_agent=\"YourApp/1.0\",\n    )\n    try:\n        posts = await client.get_posts(\"python\", limit=5)\n        for post in posts:\n            print(f\"{post['title']} - {post['score']} upvotes\")\n    finally:\n        await client.close()\n```\n\n### TypeScript with fetch\n\n```typescript\ninterface RedditPost {\n  title: string;\n  score: number;\n  url: string;\n  selftext: string;\n  author: string;\n  created_utc: number;\n}\n\nclass RedditClient {\n  private accessToken: string | null = null;\n\n  constructor(\n    private clientId: string,\n    private clientSecret: string,\n    private userAgent: string\n  ) {}\n\n  async authenticate(): Promise<void> {\n    const auth = Buffer.from(`${this.clientId}:${this.clientSecret}`).toString(\"base64\");\n\n    const response = await fetch(\"https://www.reddit.com/api/v1/access_token\", {\n      method: \"POST\",\n      headers: {\n        Authorization: `Basic ${auth}`,\n        \"User-Agent\": this.userAgent,\n        \"Content-Type\": \"application/x-www-form-urlencoded\",\n      },\n      body: \"grant_type=client_credentials\",\n    });\n\n    const data = await response.json();\n    this.accessToken = data.access_token;\n  }\n\n  async getPosts(subreddit: string, sort = \"hot\", limit = 10): Promise<RedditPost[]> {\n    if (!this.accessToken) await this.authenticate();\n\n    const response = await fetch(\n      `https://oauth.reddit.com/r/${subreddit}/${sort}?limit=${limit}`,\n      {\n        headers: {\n          Authorization: `Bearer ${this.accessToken}`,\n          \"User-Agent\": this.userAgent,\n        },\n      }\n    );\n\n    const data = await response.json();\n    return data.data.children.map((child: any) => child.data);\n  }\n}\n```\n\n---\n\n## OAuth2 Web Flow (User Authorization)\n\nFor apps where users log in with their Reddit account:\n\n```python\nfrom fastapi import FastAPI, Request\nfrom fastapi.responses import RedirectResponse\nimport httpx\nimport secrets\n\napp = FastAPI()\nstate_store: dict[str, bool] = {}\n\nREDDIT_CLIENT_ID = \"your_client_id\"\nREDDIT_CLIENT_SECRET = \"your_client_secret\"\nREDIRECT_URI = \"http://localhost:8000/callback\"\n\n@app.get(\"/login\")\nasync def login():\n    state = secrets.token_urlsafe(16)\n    state_store[state] = True\n\n    auth_url = (\n        f\"https://www.reddit.com/api/v1/authorize\"\n        f\"?client_id={REDDIT_CLIENT_ID}\"\n        f\"&response_type=code\"\n        f\"&state={state}\"\n        f\"&redirect_uri={REDIRECT_URI}\"\n        f\"&duration=permanent\"\n        f\"&scope=identity read submit vote\"\n    )\n    return RedirectResponse(auth_url)\n\n@app.get(\"/callback\")\nasync def callback(code: str, state: str):\n    if state not in state_store:\n        return {\"error\": \"Invalid state\"}\n    del state_store[state]\n\n    # Exchange code for token\n    async with httpx.AsyncClient() as client:\n        response = await client.post(\n            \"https://www.reddit.com/api/v1/access_token\",\n            auth=(REDDIT_CLIENT_ID, REDDIT_CLIENT_SECRET),\n            data={\n                \"grant_type\": \"authorization_code\",\n                \"code\": code,\n                \"redirect_uri\": REDIRECT_URI,\n            },\n            headers={\"User-Agent\": \"YourApp/1.0\"},\n        )\n\n    tokens = response.json()\n    # Store tokens securely, associate with user session\n    return {\"access_token\": tokens[\"access_token\"][:10] + \"...\"}\n```\n\n---\n\n## Available Scopes\n\n| Scope | Description |\n|-------|-------------|\n| `identity` | Access username and signup date |\n| `read` | Access posts and comments |\n| `submit` | Submit links and comments |\n| `vote` | Upvote/downvote content |\n| `edit` | Edit posts and comments |\n| `history` | Access voting history |\n| `subscribe` | Manage subreddit subscriptions |\n| `mysubreddits` | Access subscribed subreddits |\n| `privatemessages` | Access private messages |\n| `save` | Save/unsave content |\n\nFull list: https://www.reddit.com/api/v1/scopes\n\n---\n\n## Project Structure\n\n```\nproject/\n├── src/\n│   ├── reddit/\n│   │   ├── __init__.py\n│   │   ├── client.py         # Reddit client wrapper\n│   │   ├── models.py         # Pydantic models for posts/comments\n│   │   └── scraper.py        # Data collection logic\n│   └── main.py\n├── .env\n└── pyproject.toml\n```\n\n---\n\n## Pydantic Models\n\n```python\nfrom pydantic import BaseModel\nfrom datetime import datetime\n\nclass RedditPost(BaseModel):\n    id: str\n    title: str\n    author: str\n    subreddit: str\n    score: int\n    upvote_ratio: float\n    url: str\n    selftext: str\n    created_utc: datetime\n    num_comments: int\n    is_self: bool\n\n    @classmethod\n    def from_praw(cls, submission) -> \"RedditPost\":\n        return cls(\n            id=submission.id,\n            title=submission.title,\n            author=str(submission.author),\n            subreddit=submission.subreddit.display_name,\n            score=submission.score,\n            upvote_ratio=submission.upvote_ratio,\n            url=submission.url,\n            selftext=submission.selftext,\n            created_utc=datetime.fromtimestamp(submission.created_utc),\n            num_comments=submission.num_comments,\n            is_self=submission.is_self,\n        )\n\nclass RedditComment(BaseModel):\n    id: str\n    author: str\n    body: str\n    score: int\n    created_utc: datetime\n    parent_id: str\n    is_submitter: bool\n```\n\n---\n\n## Anti-Patterns\n\n- **No User-Agent** - Reddit blocks requests without proper User-Agent\n- **Ignoring rate limits** - Respect 100 QPM, check `X-Ratelimit-*` headers\n- **Storing credentials in code** - Use environment variables\n- **Not handling `MoreComments`** - Use `replace_more()` in PRAW\n- **Polling instead of streaming** - Use `.stream` for real-time data\n- **No error handling** - Handle 429 (rate limit), 403 (forbidden), 404 (not found)\n\n---\n\n## Quick Reference\n\n```bash\n# PRAW installation\npip install praw\n\n# Snoowrap installation\nnpm install snoowrap\n\n# Test authentication\npython -c \"import praw; r = praw.Reddit(...); print(r.user.me())\"\n```\n\n### Endpoints\n\n| Operation | Endpoint |\n|-----------|----------|\n| Auth token | `POST https://www.reddit.com/api/v1/access_token` |\n| API requests | `https://oauth.reddit.com/...` |\n| Subreddit posts | `GET /r/{subreddit}/{sort}` |\n| Submission | `GET /comments/{id}` |\n| User info | `GET /user/{username}/about` |\n| Submit post | `POST /api/submit` |\n| Vote | `POST /api/vote` |\n"
  },
  {
    "path": "skills/security/SKILL.md",
    "content": "---\nname: security\ndescription: OWASP security patterns, secrets management, security testing\nwhen-to-use: When writing code that handles auth, user input, API keys, or when security review is requested\nuser-invocable: true\nallowed-tools: [Read, Glob, Grep, Bash]\neffort: high\n---\n\n# Security Skill\n\n\nSecurity best practices and automated security testing for all projects.\n\n---\n\n## Core Principle\n\n**Security is not optional.** Every project must pass security checks before merge. Assume all input is malicious, all secrets will leak if committed, and all dependencies have vulnerabilities.\n\n---\n\n## Required Security Setup\n\n### 1. Gitignore (Non-Negotiable)\n\nEvery project must have these in `.gitignore`:\n\n```gitignore\n# Environment files - NEVER commit\n.env\n.env.*\n!.env.example\n\n# Secrets\n*.pem\n*.key\n*.p12\n*.pfx\ncredentials.json\nsecrets.json\n*-credentials.json\nservice-account*.json\n\n# IDE and OS\n.idea/\n.vscode/settings.json\n.DS_Store\nThumbs.db\n\n# Dependencies\nnode_modules/\n__pycache__/\n*.pyc\n.venv/\nvenv/\n\n# Build outputs\ndist/\nbuild/\n*.egg-info/\n\n# Logs that might contain sensitive data\n*.log\nlogs/\n```\n\n### 2. Environment Variables\n\n**Create `.env.example`** with all required vars (no values):\n```bash\n# .env.example - Copy to .env and fill in values\n\n# Server-side only (NEVER prefix with VITE_ or NEXT_PUBLIC_)\nDATABASE_URL=\nANTHROPIC_API_KEY=\nSUPABASE_SERVICE_ROLE_KEY=\n\n# Client-side safe (public, non-sensitive)\nVITE_SUPABASE_URL=\nVITE_SUPABASE_ANON_KEY=\n```\n\n### Frontend Environment Variables (Critical!)\n\n**NEVER put secrets in client-exposed env vars:**\n\n| Framework | Client-Exposed Prefix | Server-Only |\n|-----------|----------------------|-------------|\n| Vite | `VITE_*` | No prefix |\n| Next.js | `NEXT_PUBLIC_*` | No prefix |\n| Create React App | `REACT_APP_*` | N/A (no server) |\n\n```typescript\n// WRONG - Secret exposed to browser bundle!\nconst apiKey = import.meta.env.VITE_ANTHROPIC_API_KEY;\n\n// CORRECT - Only public values client-side\nconst supabaseUrl = import.meta.env.VITE_SUPABASE_URL;\n\n// CORRECT - Secrets stay server-side only\n// In API route or server function:\nconst apiKey = process.env.ANTHROPIC_API_KEY;\n```\n\n**Vercel Environment Variables:**\n- In Vercel dashboard, secrets without `VITE_` prefix are server-only\n- Only `VITE_*` vars are bundled into client code\n- Always verify in browser devtools → Sources → your bundle that secrets aren't exposed\n\n**Validate environment at startup:**\n```typescript\n// config/env.ts\nimport { z } from 'zod';\n\nconst envSchema = z.object({\n  DATABASE_URL: z.string().url(),\n  ANTHROPIC_API_KEY: z.string().min(1),\n  NODE_ENV: z.enum(['development', 'production', 'test']),\n});\n\nexport const env = envSchema.parse(process.env);\n```\n\n```python\n# config/env.py\nfrom pydantic_settings import BaseSettings\n\nclass Settings(BaseSettings):\n    database_url: str\n    anthropic_api_key: str\n    environment: str = \"development\"\n\n    class Config:\n        env_file = \".env\"\n\nsettings = Settings()\n```\n\n---\n\n## Security Tests\n\n### Pre-Commit Security Checks\n\nAdd to pre-commit hooks:\n\n**For all projects:**\n```yaml\n# .pre-commit-config.yaml (add to existing)\nrepos:\n  # Detect secrets\n  - repo: https://github.com/Yelp/detect-secrets\n    rev: v1.4.0\n    hooks:\n      - id: detect-secrets\n        args: ['--baseline', '.secrets.baseline']\n\n  # Check for security issues in dependencies\n  - repo: local\n    hooks:\n      - id: security-check\n        name: security-check\n        entry: ./scripts/security-check.sh\n        language: script\n        pass_filenames: false\n```\n\n**TypeScript/JavaScript:**\n```json\n// package.json scripts\n{\n  \"scripts\": {\n    \"security:audit\": \"npm audit --audit-level=high\",\n    \"security:secrets\": \"npx secretlint '**/*'\",\n    \"security:deps\": \"npx better-npm-audit audit\"\n  }\n}\n```\n\n**Python:**\n```bash\n# Add to dev dependencies\npip install safety bandit\n\n# Commands\nsafety check           # Check dependencies for vulnerabilities\nbandit -r src/        # Static security analysis\n```\n\n### Security Check Script\n\nCreate `scripts/security-check.sh`:\n\n```bash\n#!/bin/bash\nset -e\n\necho \"Running security checks...\"\n\n# Check for secrets in staged files\necho \"Checking for secrets...\"\nif command -v detect-secrets &> /dev/null; then\n  detect-secrets scan --baseline .secrets.baseline\nfi\n\n# Check .env is not staged\nif git diff --cached --name-only | grep -E '^\\.env$|^\\.env\\.' | grep -v '\\.example$'; then\n  echo \"ERROR: .env file is staged for commit!\"\n  exit 1\nfi\n\n# Check for common secret patterns in staged files\nSTAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM)\nif echo \"$STAGED_FILES\" | xargs grep -l -E '(password|secret|api_key|apikey|token|private_key)\\s*[:=]\\s*[\"\\047][^\"\\047]+[\"\\047]' 2>/dev/null; then\n  echo \"ERROR: Possible secrets found in staged files!\"\n  exit 1\nfi\n\n# Language-specific checks\nif [ -f \"package.json\" ]; then\n  echo \"Checking npm dependencies...\"\n  npm audit --audit-level=high || echo \"Warning: npm audit found issues\"\nfi\n\nif [ -f \"pyproject.toml\" ] || [ -f \"requirements.txt\" ]; then\n  echo \"Checking Python dependencies...\"\n  if command -v safety &> /dev/null; then\n    safety check || echo \"Warning: safety found issues\"\n  fi\nfi\n\necho \"Security checks passed!\"\n```\n\n```bash\nchmod +x scripts/security-check.sh\n```\n\n---\n\n## GitHub Actions Security Workflow\n\nCreate `.github/workflows/security.yml`:\n\n```yaml\nname: Security\n\non:\n  push:\n    branches: [main]\n  pull_request:\n    branches: [main]\n  schedule:\n    # Run weekly on Monday at 9am UTC\n    - cron: '0 9 * * 1'\n\njobs:\n  secrets-scan:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n\n      - name: Detect secrets\n        uses: trufflesecurity/trufflehog@main\n        with:\n          path: ./\n          base: ${{ github.event.pull_request.base.sha }}\n          head: ${{ github.event.pull_request.head.sha }}\n\n  dependency-audit:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n\n      # Node.js projects\n      - name: Setup Node\n        if: hashFiles('package.json') != ''\n        uses: actions/setup-node@v4\n        with:\n          node-version: '20'\n\n      - name: Install dependencies\n        if: hashFiles('package.json') != ''\n        run: npm ci\n\n      - name: NPM Audit\n        if: hashFiles('package.json') != ''\n        run: npm audit --audit-level=high\n\n      # Python projects\n      - name: Setup Python\n        if: hashFiles('pyproject.toml') != '' || hashFiles('requirements.txt') != ''\n        uses: actions/setup-python@v5\n        with:\n          python-version: '3.12'\n\n      - name: Install safety\n        if: hashFiles('pyproject.toml') != '' || hashFiles('requirements.txt') != ''\n        run: pip install safety\n\n      - name: Safety check\n        if: hashFiles('pyproject.toml') != '' || hashFiles('requirements.txt') != ''\n        run: safety check\n\n  codeql:\n    runs-on: ubuntu-latest\n    permissions:\n      security-events: write\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Initialize CodeQL\n        uses: github/codeql-action/init@v3\n        with:\n          languages: ${{ hashFiles('package.json') != '' && 'javascript-typescript' || 'python' }}\n\n      - name: Autobuild\n        uses: github/codeql-action/autobuild@v3\n\n      - name: Perform CodeQL Analysis\n        uses: github/codeql-action/analyze@v3\n```\n\n---\n\n## Input Validation (OWASP Top 10)\n\n### 1. SQL Injection Prevention\n\n**Never use string concatenation:**\n```typescript\n// BAD - SQL injection vulnerable\nconst user = await db.query(`SELECT * FROM users WHERE id = ${userId}`);\n\n// GOOD - Parameterized query\nconst user = await db.query('SELECT * FROM users WHERE id = $1', [userId]);\n\n// GOOD - Using ORM (Kysely, Prisma, Drizzle)\nconst user = await db.selectFrom('users').where('id', '=', userId).execute();\n```\n\n```python\n# BAD - SQL injection vulnerable\ncursor.execute(f\"SELECT * FROM users WHERE id = {user_id}\")\n\n# GOOD - Parameterized query\ncursor.execute(\"SELECT * FROM users WHERE id = %s\", (user_id,))\n\n# GOOD - Using ORM (SQLAlchemy)\nuser = session.query(User).filter(User.id == user_id).first()\n```\n\n### 2. XSS Prevention\n\n```typescript\n// Always sanitize user input before rendering\nimport DOMPurify from 'dompurify';\n\n// BAD - XSS vulnerable\nelement.innerHTML = userInput;\n\n// GOOD - Sanitized\nelement.innerHTML = DOMPurify.sanitize(userInput);\n\n// BEST - Use framework's built-in escaping (React does this by default)\nreturn <div>{userInput}</div>;  // Safe in React\n\n// DANGER - Bypasses React's protection\nreturn <div dangerouslySetInnerHTML={{ __html: userInput }} />;  // Avoid!\n```\n\n### 3. Input Validation at Boundaries\n\n```typescript\n// Validate ALL external input with Zod\nimport { z } from 'zod';\n\nconst CreateUserSchema = z.object({\n  email: z.string().email().max(255),\n  name: z.string().min(1).max(100).regex(/^[a-zA-Z\\s]+$/),\n  age: z.number().int().min(0).max(150),\n});\n\n// In route handler\napp.post('/users', async (req, res) => {\n  const result = CreateUserSchema.safeParse(req.body);\n  if (!result.success) {\n    return res.status(400).json({ error: result.error });\n  }\n  // result.data is now typed and validated\n});\n```\n\n### 4. Path Traversal Prevention\n\n```typescript\nimport path from 'path';\n\n// BAD - Path traversal vulnerable\nconst filePath = `./uploads/${req.params.filename}`;\n\n// GOOD - Validate and sanitize path\nconst filename = path.basename(req.params.filename);  // Strips ../\nconst filePath = path.join('./uploads', filename);\n\n// Verify it's still within allowed directory\nif (!filePath.startsWith(path.resolve('./uploads'))) {\n  throw new Error('Invalid path');\n}\n```\n\n---\n\n## Authentication & Authorization\n\n### JWT Best Practices\n\n```typescript\nimport jwt from 'jsonwebtoken';\n\n// Token generation\nfunction generateToken(userId: string): string {\n  return jwt.sign(\n    { sub: userId },\n    process.env.JWT_SECRET!,\n    {\n      expiresIn: '15m',      // Short-lived access tokens\n      algorithm: 'HS256',\n    }\n  );\n}\n\n// Token verification\nfunction verifyToken(token: string): { sub: string } {\n  return jwt.verify(token, process.env.JWT_SECRET!, {\n    algorithms: ['HS256'],   // Explicitly specify allowed algorithms\n  }) as { sub: string };\n}\n```\n\n### Password Hashing\n\n```typescript\nimport bcrypt from 'bcrypt';\n\nconst SALT_ROUNDS = 12;  // Minimum 10, recommended 12+\n\nasync function hashPassword(password: string): Promise<string> {\n  return bcrypt.hash(password, SALT_ROUNDS);\n}\n\nasync function verifyPassword(password: string, hash: string): Promise<boolean> {\n  return bcrypt.compare(password, hash);\n}\n```\n\n```python\nfrom passlib.context import CryptContext\n\npwd_context = CryptContext(schemes=[\"bcrypt\"], deprecated=\"auto\")\n\ndef hash_password(password: str) -> str:\n    return pwd_context.hash(password)\n\ndef verify_password(password: str, hashed: str) -> bool:\n    return pwd_context.verify(password, hashed)\n```\n\n### Rate Limiting\n\n```typescript\nimport rateLimit from 'express-rate-limit';\n\nconst limiter = rateLimit({\n  windowMs: 15 * 60 * 1000,  // 15 minutes\n  max: 100,                   // 100 requests per window\n  standardHeaders: true,\n  legacyHeaders: false,\n});\n\n// Apply to auth routes\napp.use('/api/auth', rateLimit({\n  windowMs: 60 * 1000,  // 1 minute\n  max: 5,                // 5 attempts per minute\n  message: 'Too many login attempts, please try again later',\n}));\n```\n\n---\n\n## Security Headers\n\n```typescript\nimport helmet from 'helmet';\n\napp.use(helmet({\n  contentSecurityPolicy: {\n    directives: {\n      defaultSrc: [\"'self'\"],\n      scriptSrc: [\"'self'\"],\n      styleSrc: [\"'self'\", \"'unsafe-inline'\"],\n      imgSrc: [\"'self'\", \"data:\", \"https:\"],\n    },\n  },\n  hsts: {\n    maxAge: 31536000,\n    includeSubDomains: true,\n  },\n}));\n```\n\n---\n\n## Security Testing Checklist\n\nRun before every release:\n\n```markdown\n## Security Checklist\n\n### Secrets & Environment\n- [ ] No secrets in code (run detect-secrets)\n- [ ] .env files in .gitignore\n- [ ] .env.example exists with all required vars\n- [ ] Environment validated at startup\n\n### Dependencies\n- [ ] npm audit / safety check passes\n- [ ] No known vulnerabilities in dependencies\n- [ ] Dependencies up to date (Dependabot enabled)\n\n### Input Validation\n- [ ] All API inputs validated with schema (Zod/Pydantic)\n- [ ] File uploads restricted by type and size\n- [ ] Path traversal prevented\n\n### Authentication\n- [ ] Passwords hashed with bcrypt (12+ rounds)\n- [ ] JWTs use short expiration\n- [ ] Rate limiting on auth endpoints\n- [ ] Session tokens rotated on login\n\n### Database\n- [ ] Parameterized queries only\n- [ ] Least privilege database user\n- [ ] Connection strings not logged\n\n### Headers & CORS\n- [ ] Security headers enabled (helmet)\n- [ ] CORS restricted to known origins\n- [ ] HTTPS only in production\n\n### Logging\n- [ ] No secrets in logs\n- [ ] No PII in logs (or properly masked)\n- [ ] Failed auth attempts logged\n```\n\n---\n\n## Security Anti-Patterns\n\n- ❌ Secrets in `VITE_*`, `NEXT_PUBLIC_*`, or `REACT_APP_*` env vars (client-exposed!)\n- ❌ Secrets in code or config files committed to git\n- ❌ .env files without .gitignore entry\n- ❌ String concatenation for SQL queries\n- ❌ `dangerouslySetInnerHTML` without sanitization\n- ❌ `eval()` or `new Function()` with user input\n- ❌ Passwords stored as plain text or weak hash (MD5, SHA1)\n- ❌ JWTs with no expiration or very long expiration\n- ❌ No rate limiting on authentication endpoints\n- ❌ Logging sensitive data (passwords, tokens, PII)\n- ❌ Using `*` for CORS origins in production\n- ❌ Ignoring npm audit / safety check warnings\n- ❌ Running as root / admin in production\n- ❌ Hardcoded credentials for any environment\n- ❌ Disabling SSL/TLS verification\n"
  },
  {
    "path": "skills/session-management/SKILL.md",
    "content": "---\nname: session-management\ndescription: Context preservation, tiered summarization, resumability\nwhen-to-use: At session checkpoints, after completing major tasks, or when resuming work\nuser-invocable: false\neffort: low\n---\n\n# Session Management Skill\n\n\nFor maintaining context across long development sessions and enabling seamless resume after breaks.\n\n---\n\n## Core Principle\n\n**Checkpoint at natural breakpoints, resume instantly.**\n\nLong development sessions risk context loss. Proactively document state, decisions, and progress so any session can resume exactly where it left off - whether returning after a break or hitting context limits.\n\n---\n\n## Tiered Summarization Rules\n\n### Tier 1: Quick Update (current-state.md only)\n**Trigger**: After completing any small task or todo item\n**Action**: Update \"Active Task\", \"Progress\", and \"Next Steps\" sections\n**Time**: ~30 seconds\n\n### Tier 2: Full Checkpoint (current-state.md + decisions.md)\n**Trigger**:\n- After completing a feature or significant change\n- After any architectural/library decision\n- After ~20 tool calls during active work\n- When switching to a different area of the codebase\n\n**Action**:\n1. Update full current-state.md\n2. Log any decisions to decisions.md\n3. Update files being modified table\n\n### Tier 3: Session Archive (archive/ + full checkpoint)\n**Trigger**:\n- End of work session\n- Completing a major feature/milestone\n- Before a significant context shift\n- When context feels heavy (~50+ tool calls)\n\n**Action**:\n1. Create archive entry: `archive/YYYY-MM-DD[-topic].md`\n2. Full checkpoint\n3. Clear verbose notes from current-state.md\n4. Update code-landmarks.md if new patterns introduced\n\n### Decision Heuristic\n```\n┌─────────────────────────────────────────────────────┐\n│ After completing work, ask:                         │\n├─────────────────────────────────────────────────────┤\n│ Was a decision made?        → Log to decisions.md   │\n│ Task took >10 tool calls?   → Full Checkpoint       │\n│ Major feature complete?     → Archive               │\n│ Ending session?             → Archive + Handoff     │\n│ Otherwise                   → Quick Update          │\n└─────────────────────────────────────────────────────┘\n```\n\n---\n\n## Session State Structure\n\nCreate `_project_specs/session/` directory:\n\n```\n_project_specs/\n└── session/\n    ├── current-state.md      # Live session state (update frequently)\n    ├── decisions.md          # Key decisions log (append-only)\n    ├── code-landmarks.md     # Important code locations\n    └── archive/              # Past session summaries\n        └── 2025-01-15.md\n```\n\n---\n\n## Current State File\n\n**`_project_specs/session/current-state.md`** - Update every 15-20 minutes or after significant progress.\n\n```markdown\n# Current Session State\n\n*Last updated: 2025-01-15 14:32*\n\n## Active Task\n[One sentence: what are we working on right now]\n\nExample: Implementing user authentication flow with JWT tokens\n\n## Current Status\n- **Phase**: [exploring | planning | implementing | testing | debugging | refactoring]\n- **Progress**: [X of Y steps complete, or percentage]\n- **Blocking Issues**: [None, or describe blockers]\n\n## Context Summary\n[2-3 sentences summarizing the current state of work]\n\nExample: Created auth middleware and login endpoint. JWT signing works.\nCurrently implementing token refresh logic. Need to add refresh token\nrotation for security.\n\n## Files Being Modified\n| File | Status | Notes |\n|------|--------|-------|\n| src/auth/middleware.ts | Done | JWT verification |\n| src/auth/refresh.ts | In Progress | Token rotation |\n| src/auth/types.ts | Done | Token interfaces |\n\n## Next Steps\n1. [ ] Complete refresh token rotation in refresh.ts\n2. [ ] Add token blacklist for logout\n3. [ ] Write integration tests for auth flow\n\n## Key Context to Preserve\n- Using RS256 algorithm (not HS256) per security requirements\n- Refresh tokens stored in HttpOnly cookies\n- Access tokens: 15 min, Refresh tokens: 7 days\n\n## Resume Instructions\nTo continue this work:\n1. Read src/auth/refresh.ts - currently at line 45\n2. The rotateRefreshToken() function needs error handling\n3. Check decisions.md for why we chose RS256 over HS256\n```\n\n---\n\n## Decision Log\n\n**`_project_specs/session/decisions.md`** - Append-only log of architectural and implementation decisions.\n\n```markdown\n# Decision Log\n\nTrack key decisions for future reference. Never delete entries.\n\n---\n\n## [2025-01-15] JWT Algorithm Choice\n\n**Decision**: Use RS256 instead of HS256 for JWT signing\n\n**Context**: Implementing authentication system\n\n**Options Considered**:\n1. HS256 (symmetric) - Simpler, single secret\n2. RS256 (asymmetric) - Public/private key pair\n\n**Choice**: RS256\n\n**Reasoning**:\n- Allows token verification without exposing signing key\n- Better for microservices (services only need public key)\n- Industry standard for production systems\n\n**Trade-offs**:\n- Slightly more complex key management\n- Larger token size\n\n**References**:\n- src/auth/keys/ - Key storage\n- docs/security.md - Security architecture\n\n---\n\n## [2025-01-14] Database Schema Approach\n\n**Decision**: Use Drizzle ORM with PostgreSQL\n\n**Context**: Setting up data layer\n\n**Options Considered**:\n1. Prisma - Popular, good DX\n2. Drizzle - Type-safe, SQL-like\n3. Raw SQL - Maximum control\n\n**Choice**: Drizzle\n\n**Reasoning**:\n- Better TypeScript inference than Prisma\n- More transparent SQL generation\n- Lighter weight, faster cold starts\n\n**References**:\n- src/db/schema.ts - Schema definitions\n- src/db/migrations/ - Migration files\n```\n\n---\n\n## Code Landmarks\n\n**`_project_specs/session/code-landmarks.md`** - Important code locations for quick reference.\n\n```markdown\n# Code Landmarks\n\nQuick reference to important parts of the codebase.\n\n## Entry Points\n| Location | Purpose |\n|----------|---------|\n| src/index.ts | Main application entry |\n| src/api/routes.ts | API route definitions |\n| src/workers/index.ts | Background job entry |\n\n## Core Business Logic\n| Location | Purpose |\n|----------|---------|\n| src/core/auth/ | Authentication system |\n| src/core/billing/ | Payment processing |\n| src/core/workflows/ | Main workflow engine |\n\n## Configuration\n| Location | Purpose |\n|----------|---------|\n| src/config/index.ts | Environment config |\n| src/config/features.ts | Feature flags |\n| drizzle.config.ts | Database config |\n\n## Key Patterns\n| Pattern | Example Location | Notes |\n|---------|------------------|-------|\n| Service Layer | src/services/user.ts | Business logic encapsulation |\n| Repository | src/repos/user.ts | Data access abstraction |\n| Middleware | src/middleware/auth.ts | Request processing |\n\n## Testing\n| Location | Purpose |\n|----------|---------|\n| tests/unit/ | Unit tests |\n| tests/integration/ | API tests |\n| tests/e2e/ | End-to-end tests |\n| tests/fixtures/ | Test data |\n\n## Gotchas & Non-Obvious Behavior\n| Location | Issue | Notes |\n|----------|-------|-------|\n| src/utils/date.ts | Timezone handling | Always use UTC internally |\n| src/api/middleware.ts:45 | Auth bypass | Skip auth for health checks |\n| src/db/pool.ts | Connection limit | Max 10 connections in dev |\n```\n\n---\n\n## CLAUDE.md Session Rules\n\nAdd this section to CLAUDE.md:\n\n```markdown\n## Session Management\n\n**IMPORTANT**: Follow session-management.md skill. Update session state at natural breakpoints.\n\n### After Every Task Completion\nAsk yourself:\n1. Was a decision made? → Log to `decisions.md`\n2. Did this take >10 tool calls? → Full checkpoint to `current-state.md`\n3. Is a major feature complete? → Create archive entry\n4. Otherwise → Quick update to `current-state.md`\n\n### Checkpoint Triggers\n**Quick Update** (current-state.md):\n- After any todo completion\n- After small changes\n\n**Full Checkpoint** (current-state.md + decisions.md):\n- After significant changes\n- After ~20 tool calls\n- After any decision\n- When switching focus areas\n\n**Archive** (archive/ + full checkpoint):\n- End of session\n- Major feature complete\n- Context feels heavy\n\n### Session Start Protocol\nWhen beginning work:\n1. Read `_project_specs/session/current-state.md`\n2. Check `_project_specs/todos/active.md`\n3. Review recent `decisions.md` entries if needed\n4. Continue from \"Next Steps\"\n\n### Session End Protocol\nBefore ending or when context limit approaches:\n1. Create archive: `_project_specs/session/archive/YYYY-MM-DD.md`\n2. Update current-state.md with handoff format\n3. Ensure next steps are specific and actionable\n```\n\n---\n\n## Compression Strategies\n\n### When to Compress (Tier 3 Archive)\n\n| Trigger | Action |\n|---------|--------|\n| ~50+ tool calls | Summarize progress, archive verbose notes |\n| Major feature complete | Archive feature details, update landmarks |\n| Context shift | Summarize previous context, archive, start fresh |\n| End of session | Full session handoff with archive |\n\n### What to Keep vs Archive\n\n**Keep in active context:**\n- Current task and immediate next steps\n- Active file list with status\n- Blocking issues\n- Key decisions affecting current work\n\n**Archive/summarize:**\n- Exploration paths that didn't work out\n- Detailed debugging traces (keep conclusion only)\n- Verbose error messages (keep root cause only)\n- Research notes (keep recommendations only)\n\n### Compression Template\n\nWhen compressing, use this format:\n\n```markdown\n## Compressed Context - [Topic]\n\n**Summary**: [1-2 sentences]\n\n**Key Findings**:\n- [Bullet points of important discoveries]\n\n**Decisions Made**:\n- [Reference to decisions.md entries]\n\n**Relevant Code**:\n- [File:line references]\n\n**Archived Details**: [Link to archive file if created]\n```\n\n---\n\n## Session Archive\n\nAfter significant work or at session end, create archive:\n\n**`_project_specs/session/archive/YYYY-MM-DD[-topic].md`**\n\n```markdown\n# Session Archive: [Date] - [Topic]\n\n## Summary\n[Paragraph summarizing what was accomplished]\n\n## Tasks Completed\n- [TODO-XXX] Description - Done\n- [TODO-YYY] Description - Done\n\n## Key Decisions\n- [Reference decisions.md entries made this session]\n\n## Code Changes\n| File | Change Type | Description |\n|------|-------------|-------------|\n| src/auth/login.ts | Created | Login endpoint |\n| src/auth/types.ts | Modified | Added RefreshToken type |\n\n## Tests Added\n- tests/auth/login.test.ts - Login flow tests\n- tests/auth/refresh.test.ts - Token refresh tests\n\n## Open Items Carried Forward\n- [Anything not finished, now in active.md]\n\n## Session Stats\n- Duration: ~3 hours\n- Tool calls: ~120\n- Files modified: 8\n- Tests added: 12\n```\n\n---\n\n## Integration with Todo System\n\n### Link Todos to Sessions\n\nIn active todos, reference session context:\n\n```markdown\n## [TODO-042] Implement token refresh\n\n**Status:** in-progress\n**Session Context:** See current-state.md\n\n### Progress Notes\n- 2025-01-15: Started implementation, base structure done\n- 2025-01-15: Added rotation logic, need error handling\n```\n\n### Auto-Update on Todo Completion\n\nWhen completing a todo:\n1. Mark todo complete in active.md\n2. Update current-state.md progress\n3. Log any decisions made\n4. Update code-landmarks.md if new patterns introduced\n\n---\n\n## Quick Commands\n\nAdd to project scripts or aliases:\n\n```bash\n# Show current session state\nalias session-status=\"cat _project_specs/session/current-state.md\"\n\n# Quick edit session state\nalias session-edit=\"$EDITOR _project_specs/session/current-state.md\"\n\n# View recent decisions\nalias decisions=\"tail -100 _project_specs/session/decisions.md\"\n\n# Create session archive\nsession-archive() {\n  cp _project_specs/session/current-state.md \\\n     \"_project_specs/session/archive/$(date +%Y-%m-%d).md\"\n  echo \"Archived to _project_specs/session/archive/$(date +%Y-%m-%d).md\"\n}\n```\n\n---\n\n## Enforcement Mechanisms\n\n### 1. CLAUDE.md as Entry Point\nCLAUDE.md must reference session-management.md in the Skills section. Claude reads CLAUDE.md first, which directs it to follow session rules.\n\n### 2. Session File Headers with Reminders\nInclude enforcement reminders in session file headers:\n\n**current-state.md header:**\n```markdown\n<!--\nCHECKPOINT RULES (from session-management.md):\n- Quick update: After any todo completion\n- Full checkpoint: After ~20 tool calls or decisions\n- Archive: End of session or major feature complete\n-->\n```\n\n### 3. Self-Check Questions\nAfter completing any task, Claude should ask:\n```\n□ Did I make a decision? → Log it\n□ Did this take >10 tool calls? → Full checkpoint\n□ Is a feature complete? → Archive\n□ Am I ending/switching context? → Archive + handoff\n```\n\n### 4. Session Start Verification\nWhen starting a session, Claude must:\n1. Check if `current-state.md` exists and read it\n2. Announce what it found: \"Resuming from: [last state]\"\n3. Confirm next steps before proceeding\n\n### 5. Periodic Self-Audit\nEvery ~20 tool calls, Claude should check:\n- Is current-state.md up to date?\n- Are there unlogged decisions?\n- Is context getting heavy?\n\n### 6. User Prompts\nUsers can enforce by asking:\n- \"Update session state\" → Triggers checkpoint\n- \"What's the current state?\" → Claude reads and reports\n- \"End session\" → Triggers archive + handoff\n- \"Resume from last session\" → Claude reads state files first\n\n---\n\n## Anti-Patterns\n\n- **No state tracking** - Flying blind, can't resume\n- **Overly verbose state** - Keep it scannable, not a novel\n- **Stale state files** - Update regularly or they become useless\n- **Missing decisions** - Future you won't remember why\n- **No code landmarks** - Wastes time re-discovering the codebase\n- **Never archiving** - Session files become cluttered\n- **Ignoring compression signals** - Context overload degrades performance\n- **Skipping checkpoint after decisions** - Key context lost\n- **No handoff at session end** - Next session starts blind\n\n---\n\n## Quick Reference\n\n### Checkpoint Decision Tree\n```\nTask completed?\n    │\n    ├── Decision made? ──────────────────→ Log to decisions.md\n    │\n    ├── >10 tool calls OR significant? ──→ Full Checkpoint\n    │\n    ├── Major feature done? ─────────────→ Archive\n    │\n    └── Otherwise ───────────────────────→ Quick Update\n```\n\n### Files at a Glance\n| File | Update Frequency | Purpose |\n|------|------------------|---------|\n| current-state.md | Every task | Live state, next steps |\n| decisions.md | When deciding | Architectural choices |\n| code-landmarks.md | When patterns change | Code navigation |\n| archive/*.md | End of session/feature | Historical record |\n"
  },
  {
    "path": "skills/shopify-apps/SKILL.md",
    "content": "---\nname: shopify-apps\ndescription: Shopify app development - Remix, Admin API, checkout extensions\nwhen-to-use: When building Shopify apps or extensions\nuser-invocable: false\neffort: medium\n---\n\n# Shopify App Development Skill\n\n\nFor building Shopify apps using Remix, the Shopify App framework, and checkout UI extensions.\n\n**Sources:** [Shopify Dev Docs](https://shopify.dev/docs/apps) | [Shopify CLI](https://shopify.dev/docs/apps/tools/cli) | [Admin API](https://shopify.dev/docs/api/admin-graphql)\n\n---\n\n## Prerequisites\n\n### Required Accounts & Tools\n\n```bash\n# 1. Shopify Partner Account (free)\n# Sign up at: https://partners.shopify.com\n\n# 2. Development Store\n# Create in Partner Dashboard → Stores → Add store → Development store\n\n# 3. Shopify CLI\nnpm install -g @shopify/cli\n\n# 4. Node.js 18.20+ or 20.10+\nnode --version\n```\n\n### Partner Dashboard Setup\n\n1. Create Partner account at partners.shopify.com\n2. Create a development store for testing\n3. Create an app in Partner Dashboard → Apps → Create app\n4. Note your API key and API secret\n\n---\n\n## Quick Start\n\n### Scaffold New App\n\n```bash\n# Create new Shopify app with Remix\nshopify app init\n\n# Answer prompts:\n# - App name\n# - Template: Remix (recommended)\n# - Language: JavaScript or TypeScript\n\n# Start development\ncd your-app-name\nshopify app dev\n```\n\n### Project Structure\n\n```\nshopify-app/\n├── app/\n│   ├── routes/\n│   │   ├── app._index/          # Main app page\n│   │   │   └── route.jsx\n│   │   ├── app.jsx              # App layout with Polaris\n│   │   ├── auth.$.jsx           # Auth catch-all\n│   │   ├── auth.login/          # Login page\n│   │   │   └── route.jsx\n│   │   ├── webhooks.app.uninstalled.jsx\n│   │   ├── webhooks.app.scopes_update.jsx\n│   │   └── webhooks.gdpr.jsx    # GDPR compliance (REQUIRED)\n│   ├── shopify.server.js        # Shopify app config\n│   ├── db.server.js             # Prisma client\n│   └── entry.server.jsx\n├── extensions/                   # Checkout/theme extensions\n│   └── my-extension/\n│       ├── src/\n│       │   └── index.tsx\n│       ├── shopify.extension.toml\n│       └── package.json\n├── prisma/\n│   └── schema.prisma            # Session storage\n├── shopify.app.toml             # App configuration\n├── package.json\n└── vite.config.js\n```\n\n---\n\n## App Configuration\n\n### shopify.app.toml\n\n```toml\n# App configuration - managed by Shopify CLI\nclient_id = \"your-api-key\"\nname = \"Your App Name\"\nhandle = \"your-app-handle\"\napplication_url = \"https://your-app.onrender.com\"\nembedded = true\n\n[webhooks]\napi_version = \"2025-01\"\n\n# Required: App lifecycle webhooks\n[[webhooks.subscriptions]]\ntopics = [\"app/uninstalled\"]\nuri = \"/webhooks/app/uninstalled\"\n\n[[webhooks.subscriptions]]\ntopics = [\"app/scopes_update\"]\nuri = \"/webhooks/app/scopes_update\"\n\n# Required: GDPR compliance webhooks\n[[webhooks.subscriptions]]\ncompliance_topics = [\n  \"customers/data_request\",\n  \"customers/redact\",\n  \"shop/redact\",\n]\nuri = \"/webhooks/gdpr\"\n\n[access_scopes]\nscopes = \"read_products,write_products\"\n\n[auth]\nredirect_urls = [\n  \"https://your-app.onrender.com/auth/callback\",\n  \"https://your-app.onrender.com/auth/shopify/callback\",\n]\n\n[pos]\nembedded = false\n\n[build]\ndev_store_url = \"your-dev-store.myshopify.com\"\nautomatically_update_urls_on_dev = true\n```\n\n### shopify.server.js\n\n```javascript\nimport \"@shopify/shopify-app-remix/adapters/node\";\nimport {\n  ApiVersion,\n  AppDistribution,\n  shopifyApp,\n} from \"@shopify/shopify-app-remix/server\";\nimport { PrismaSessionStorage } from \"@shopify/shopify-app-session-storage-prisma\";\nimport { prisma } from \"./db.server\";\n\nconst shopify = shopifyApp({\n  apiKey: process.env.SHOPIFY_API_KEY,\n  apiSecretKey: process.env.SHOPIFY_API_SECRET || \"\",\n  apiVersion: ApiVersion.January25,\n  scopes: process.env.SCOPES?.split(\",\"),\n  appUrl: process.env.SHOPIFY_APP_URL || \"\",\n  authPathPrefix: \"/auth\",\n  sessionStorage: new PrismaSessionStorage(prisma),\n  distribution: AppDistribution.AppStore,\n  future: {\n    unstable_newEmbeddedAuthStrategy: true,\n    removeRest: true,  // Use GraphQL only\n  },\n});\n\nexport default shopify;\nexport const apiVersion = ApiVersion.January25;\nexport const addDocumentResponseHeaders = shopify.addDocumentResponseHeaders;\nexport const authenticate = shopify.authenticate;\nexport const unauthenticated = shopify.unauthenticated;\nexport const login = shopify.login;\nexport const registerWebhooks = shopify.registerWebhooks;\nexport const sessionStorage = shopify.sessionStorage;\n```\n\n---\n\n## Authentication\n\n### Route Protection\n\n```javascript\n// app/routes/app._index/route.jsx\nimport { json } from \"@remix-run/node\";\nimport { useLoaderData } from \"@remix-run/react\";\nimport { authenticate } from \"../../shopify.server\";\n\nexport const loader = async ({ request }) => {\n  // This authenticates the request and redirects to login if needed\n  const { admin, session } = await authenticate.admin(request);\n\n  // Now you have access to admin API and session\n  const shop = session.shop;\n\n  return json({ shop });\n};\n\nexport default function Index() {\n  const { shop } = useLoaderData();\n  return <div>Connected to: {shop}</div>;\n}\n```\n\n### Webhook Authentication\n\n```javascript\n// app/routes/webhooks.app.uninstalled.jsx\nimport { authenticate } from \"../shopify.server\";\nimport { prisma } from \"../db.server\";\n\nexport const action = async ({ request }) => {\n  const { shop, topic } = await authenticate.webhook(request);\n\n  console.log(`Received ${topic} webhook for ${shop}`);\n\n  // Clean up shop data on uninstall\n  await prisma.session.deleteMany({ where: { shop } });\n\n  return new Response(null, { status: 200 });\n};\n```\n\n---\n\n## GraphQL Admin API\n\n### Basic Query Pattern\n\n```javascript\n// app/shopify/adminApi.server.js\nexport async function getShopId(admin) {\n  const response = await admin.graphql(`\n    query getShopId {\n      shop {\n        id\n        name\n        email\n        myshopifyDomain\n      }\n    }\n  `);\n\n  const data = await response.json();\n  return data.data?.shop;\n}\n```\n\n### Query with Variables\n\n```javascript\nexport async function getProducts(admin, first = 10) {\n  const response = await admin.graphql(`\n    query getProducts($first: Int!) {\n      products(first: $first) {\n        edges {\n          node {\n            id\n            title\n            status\n            variants(first: 5) {\n              edges {\n                node {\n                  id\n                  price\n                  inventoryQuantity\n                }\n              }\n            }\n          }\n        }\n        pageInfo {\n          hasNextPage\n          endCursor\n        }\n      }\n    }\n  `, {\n    variables: { first }\n  });\n\n  const data = await response.json();\n  return data.data?.products?.edges.map(e => e.node);\n}\n```\n\n### Mutations\n\n```javascript\nexport async function createProduct(admin, input) {\n  const response = await admin.graphql(`\n    mutation createProduct($input: ProductInput!) {\n      productCreate(input: $input) {\n        product {\n          id\n          title\n        }\n        userErrors {\n          field\n          message\n        }\n      }\n    }\n  `, {\n    variables: {\n      input: {\n        title: input.title,\n        descriptionHtml: input.description,\n        status: \"DRAFT\"\n      }\n    }\n  });\n\n  const data = await response.json();\n  const result = data.data?.productCreate;\n\n  if (result?.userErrors?.length > 0) {\n    throw new Error(result.userErrors.map(e => e.message).join(\", \"));\n  }\n\n  return result?.product;\n}\n```\n\n### Metafields (App Settings Storage)\n\n```javascript\n// Get metafield\nexport async function getMetafield(admin, namespace, key) {\n  const response = await admin.graphql(`\n    query getShopMetafield($namespace: String!, $key: String!) {\n      shop {\n        id\n        metafield(namespace: $namespace, key: $key) {\n          id\n          value\n        }\n      }\n    }\n  `, {\n    variables: { namespace, key }\n  });\n\n  const data = await response.json();\n  const metafield = data.data?.shop?.metafield;\n\n  return {\n    shopId: data.data?.shop?.id,\n    value: metafield?.value ? JSON.parse(metafield.value) : null,\n  };\n}\n\n// Set metafield\nexport async function setMetafield(admin, namespace, key, value, shopId) {\n  const response = await admin.graphql(`\n    mutation CreateMetafield($metafields: [MetafieldsSetInput!]!) {\n      metafieldsSet(metafields: $metafields) {\n        metafields {\n          id\n          namespace\n          key\n          value\n        }\n        userErrors {\n          field\n          message\n        }\n      }\n    }\n  `, {\n    variables: {\n      metafields: [{\n        namespace,\n        key,\n        type: \"json\",\n        value: JSON.stringify(value),\n        ownerId: shopId,\n      }]\n    }\n  });\n\n  const data = await response.json();\n  const errors = data.data?.metafieldsSet?.userErrors;\n\n  if (errors?.length > 0) {\n    throw new Error(errors.map(e => e.message).join(\", \"));\n  }\n\n  return data.data?.metafieldsSet?.metafields?.[0];\n}\n```\n\n---\n\n## GDPR Compliance (REQUIRED)\n\n**All Shopify apps MUST handle GDPR webhooks.** This is required for App Store approval.\n\n```javascript\n// app/routes/webhooks.gdpr.jsx\nimport { authenticate } from \"../shopify.server\";\n\nexport const action = async ({ request }) => {\n  const { topic, shop, session } = await authenticate.webhook(request);\n\n  console.log(`Received ${topic} webhook for ${shop}`);\n\n  switch (topic) {\n    case \"customers/data_request\":\n      // Return any customer data you store\n      // If you don't store customer data, return empty\n      return json({ customer_data: null });\n\n    case \"customers/redact\":\n      // Delete customer data\n      // Example: await deleteCustomerData(payload.customer.id);\n      return json({ success: true });\n\n    case \"shop/redact\":\n      // Delete all shop data (48 hours after uninstall)\n      // Clean up metafields, database records, etc.\n      if (session) {\n        const { admin } = await authenticate.admin(request);\n        await admin.graphql(`\n          mutation metafieldDelete($input: MetafieldsDeleteInput!) {\n            metafieldsDelete(input: $input) {\n              deletedId\n            }\n          }\n        `, {\n          variables: {\n            input: {\n              namespace: \"your_app\",\n              key: \"settings\",\n              ownerType: \"SHOP\"\n            }\n          }\n        });\n      }\n      return json({ success: true });\n\n    default:\n      return json({ error: \"Unhandled topic\" }, { status: 400 });\n  }\n};\n```\n\n---\n\n## UI with Polaris\n\n### App Layout\n\n```javascript\n// app/routes/app.jsx\nimport { Outlet } from \"@remix-run/react\";\nimport { AppProvider } from \"@shopify/polaris\";\nimport \"@shopify/polaris/build/esm/styles.css\";\nimport polarisTranslations from \"@shopify/polaris/locales/en.json\";\n\nexport default function App() {\n  return (\n    <AppProvider i18n={polarisTranslations}>\n      <Outlet />\n    </AppProvider>\n  );\n}\n```\n\n### Settings Page Pattern\n\n```javascript\n// app/routes/app._index/route.jsx\nimport { useState } from \"react\";\nimport { json } from \"@remix-run/node\";\nimport { useActionData, useLoaderData, useSubmit } from \"@remix-run/react\";\nimport {\n  Page,\n  Layout,\n  Card,\n  FormLayout,\n  TextField,\n  Select,\n  Banner,\n  Button,\n} from \"@shopify/polaris\";\nimport { authenticate } from \"../../shopify.server\";\nimport { getMetafield, setMetafield, getShopId } from \"../../shopify/adminApi.server\";\n\nexport const loader = async ({ request }) => {\n  const { admin } = await authenticate.admin(request);\n  const { shopId, value } = await getMetafield(admin, \"your_app\", \"settings\");\n  return json({ shopId, settings: value });\n};\n\nexport const action = async ({ request }) => {\n  const { admin } = await authenticate.admin(request);\n  const formData = await request.formData();\n\n  const settings = {\n    apiKey: formData.get(\"apiKey\"),\n    enabled: formData.get(\"enabled\") === \"true\",\n  };\n\n  try {\n    const shopId = await getShopId(admin);\n    await setMetafield(admin, \"your_app\", \"settings\", settings, shopId.id);\n    return json({ success: true, message: \"Settings saved!\" });\n  } catch (error) {\n    return json({ error: error.message }, { status: 500 });\n  }\n};\n\nexport default function Settings() {\n  const { settings } = useLoaderData();\n  const actionData = useActionData();\n  const submit = useSubmit();\n\n  const [formState, setFormState] = useState({\n    apiKey: settings?.apiKey || \"\",\n    enabled: settings?.enabled ?? true,\n  });\n\n  const handleSubmit = () => {\n    const formData = new FormData();\n    formData.append(\"apiKey\", formState.apiKey);\n    formData.append(\"enabled\", String(formState.enabled));\n    submit(formData, { method: \"post\" });\n  };\n\n  return (\n    <Page\n      title=\"App Settings\"\n      primaryAction={{\n        content: \"Save\",\n        onAction: handleSubmit,\n      }}\n    >\n      <Layout>\n        {actionData?.message && (\n          <Layout.Section>\n            <Banner tone=\"success\">{actionData.message}</Banner>\n          </Layout.Section>\n        )}\n\n        {actionData?.error && (\n          <Layout.Section>\n            <Banner tone=\"critical\">{actionData.error}</Banner>\n          </Layout.Section>\n        )}\n\n        <Layout.Section>\n          <Card>\n            <FormLayout>\n              <TextField\n                label=\"API Key\"\n                value={formState.apiKey}\n                onChange={(value) => setFormState({ ...formState, apiKey: value })}\n                autoComplete=\"off\"\n              />\n\n              <Select\n                label=\"Enable Integration\"\n                options={[\n                  { label: \"Enabled\", value: \"true\" },\n                  { label: \"Disabled\", value: \"false\" },\n                ]}\n                value={String(formState.enabled)}\n                onChange={(value) =>\n                  setFormState({ ...formState, enabled: value === \"true\" })\n                }\n              />\n            </FormLayout>\n          </Card>\n        </Layout.Section>\n      </Layout>\n    </Page>\n  );\n}\n```\n\n---\n\n## Checkout UI Extensions\n\n### Extension Configuration\n\n```toml\n# extensions/my-extension/shopify.extension.toml\napi_version = \"2025-01\"\n\n[[extensions]]\nname = \"My Checkout Extension\"\nhandle = \"my-checkout-extension\"\ntype = \"ui_extension\"\n\n[[extensions.targeting]]\nmodule = \"./src/index.tsx\"\ntarget = \"purchase.thank-you.block.render\"\n\n[extensions.capabilities]\napi_access = true\nnetwork_access = true\n\n# Access app metafields in extension\n[[extensions.metafields]]\nnamespace = \"your_app\"\nkey = \"settings\"\n```\n\n### Extension Target Locations\n\n| Target | Location |\n|--------|----------|\n| `purchase.thank-you.block.render` | Thank you page |\n| `purchase.checkout.block.render` | Checkout page |\n| `customer-account.order-status.block.render` | Order status |\n| `customer-account.page.render` | Customer account pages |\n| `admin.product-details.block.render` | Admin product page |\n\n### Extension Component\n\n```tsx\n// extensions/my-extension/src/index.tsx\nimport {\n  reactExtension,\n  useShop,\n  useAppMetafields,\n  useApi,\n  View,\n  BlockStack,\n  Heading,\n  Text,\n  Button,\n  Spinner,\n} from \"@shopify/ui-extensions-react/checkout\";\n\nexport default reactExtension(\"purchase.thank-you.block.render\", () => (\n  <Extension />\n));\n\nfunction Extension() {\n  const shop = useShop();\n  const { orderConfirmation } = useApi();\n  const order = orderConfirmation.current.order;\n\n  // Access app metafields\n  const metafields = useAppMetafields({\n    namespace: \"your_app\",\n    key: \"settings\"\n  });\n\n  const settings = metafields[0]?.metafield?.value\n    ? JSON.parse(metafields[0].metafield.value)\n    : null;\n\n  if (!settings?.enabled) {\n    return null;\n  }\n\n  return (\n    <View border=\"base\" padding=\"base\">\n      <BlockStack>\n        <Heading level={2}>Thank You!</Heading>\n        <Text>Order #{order.id} confirmed</Text>\n        <Text appearance=\"subdued\">\n          Shop: {shop.myshopifyDomain}\n        </Text>\n      </BlockStack>\n    </View>\n  );\n}\n```\n\n### Extension with External API\n\n```tsx\n// extensions/my-extension/src/hooks/useExternalApi.ts\nimport { useState, useEffect } from \"react\";\n\nexport function useExternalApi(surveyId: string) {\n  const [data, setData] = useState(null);\n  const [loading, setLoading] = useState(true);\n  const [error, setError] = useState(null);\n\n  useEffect(() => {\n    if (!surveyId) {\n      setLoading(false);\n      return;\n    }\n\n    fetch(`https://api.example.com/surveys/${surveyId}`)\n      .then(res => res.json())\n      .then(data => {\n        setData(data);\n        setLoading(false);\n      })\n      .catch(err => {\n        setError(err);\n        setLoading(false);\n      });\n  }, [surveyId]);\n\n  return { data, loading, error };\n}\n```\n\n---\n\n## Database (Prisma)\n\n### Session Storage Schema\n\n```prisma\n// prisma/schema.prisma\ngenerator client {\n  provider = \"prisma-client-js\"\n}\n\ndatasource db {\n  provider = \"postgresql\"  // or \"sqlite\" for dev\n  url      = env(\"DATABASE_URL\")\n}\n\n// Required for Shopify session storage\nmodel Session {\n  id            String    @id\n  shop          String\n  state         String\n  isOnline      Boolean   @default(false)\n  scope         String?\n  expires       DateTime?\n  accessToken   String\n  userId        BigInt?\n  firstName     String?\n  lastName      String?\n  email         String?\n  accountOwner  Boolean   @default(false)\n  locale        String?\n  collaborator  Boolean?  @default(false)\n  emailVerified Boolean?  @default(false)\n\n  @@index([shop])\n}\n\n// Your app's custom models\nmodel AppSettings {\n  id        String   @id @default(uuid())\n  shop      String   @unique\n  settings  Json\n  createdAt DateTime @default(now())\n  updatedAt DateTime @updatedAt\n}\n```\n\n### Database Client\n\n```javascript\n// app/db.server.js\nimport { PrismaClient } from \"@prisma/client\";\n\nlet prisma;\n\nif (process.env.NODE_ENV === \"production\") {\n  prisma = new PrismaClient();\n} else {\n  // Prevent multiple instances in development\n  if (!global.__prisma) {\n    global.__prisma = new PrismaClient();\n  }\n  prisma = global.__prisma;\n}\n\nexport { prisma };\n```\n\n---\n\n## Deployment\n\n### Environment Variables\n\n```bash\n# .env (DO NOT COMMIT)\nSHOPIFY_API_KEY=your_api_key\nSHOPIFY_API_SECRET=your_api_secret\nSCOPES=read_products,write_products\nSHOPIFY_APP_URL=https://your-app.onrender.com\nDATABASE_URL=postgresql://...\n```\n\n### Render Deployment\n\n```yaml\n# render.yaml\nservices:\n  - type: web\n    name: shopify-app\n    runtime: node\n    plan: starter\n    buildCommand: npm install && npm run setup && npm run build\n    startCommand: npm run start\n    envVars:\n      - key: NODE_ENV\n        value: production\n      - key: DATABASE_URL\n        fromDatabase:\n          name: shopify-db\n          property: connectionString\n      - key: SHOPIFY_API_KEY\n        sync: false\n      - key: SHOPIFY_API_SECRET\n        sync: false\n      - key: SCOPES\n        sync: false\n      - key: SHOPIFY_APP_URL\n        sync: false\n\ndatabases:\n  - name: shopify-db\n    plan: starter\n```\n\n### Deploy Commands\n\n```bash\n# Deploy app to Shopify\nshopify app deploy\n\n# This:\n# 1. Builds extensions\n# 2. Uploads to Shopify\n# 3. Creates new app version\n```\n\n---\n\n## Common Scopes\n\n| Scope | Access |\n|-------|--------|\n| `read_products` | View products |\n| `write_products` | Create/edit products |\n| `read_orders` | View orders |\n| `write_orders` | Create/edit orders |\n| `read_customers` | View customers |\n| `write_customers` | Create/edit customers |\n| `read_checkouts` | View checkout data |\n| `write_checkouts` | Modify checkout |\n| `read_themes` | View themes |\n| `write_themes` | Modify themes |\n| `read_content` | View metafields/files |\n| `write_content` | Modify metafields/files |\n\n---\n\n## CLI Commands\n\n```bash\n# Development\nshopify app dev                    # Start dev server with tunnel\nshopify app dev --reset            # Reset app config\n\n# Configuration\nshopify app config link            # Link to existing app\nshopify app config use             # Switch config\nshopify app env show               # Show env vars\n\n# Extensions\nshopify app generate extension     # Create new extension\nshopify app build                  # Build all extensions\n\n# Deployment\nshopify app deploy                 # Deploy to Shopify\nshopify app versions list          # List app versions\n\n# Store\nshopify app open                   # Open app in dev store\n```\n\n---\n\n## Testing\n\n### Unit Tests\n\n```javascript\n// __tests__/adminApi.test.js\nimport { describe, it, expect, vi } from 'vitest';\nimport { getShopId, setMetafield } from '../app/shopify/adminApi.server';\n\ndescribe('Admin API', () => {\n  it('gets shop ID', async () => {\n    const mockAdmin = {\n      graphql: vi.fn().mockResolvedValue({\n        json: () => Promise.resolve({\n          data: { shop: { id: 'gid://shopify/Shop/123' } }\n        })\n      })\n    };\n\n    const result = await getShopId(mockAdmin);\n    expect(result.id).toBe('gid://shopify/Shop/123');\n  });\n});\n```\n\n### E2E with Playwright\n\n```typescript\n// e2e/app.spec.ts\nimport { test, expect } from '@playwright/test';\n\ntest('app settings page loads', async ({ page }) => {\n  // Note: Requires authenticated session\n  await page.goto('/app');\n\n  await expect(page.getByRole('heading', { name: /settings/i })).toBeVisible();\n  await expect(page.getByLabel('API Key')).toBeVisible();\n});\n\ntest('saves settings successfully', async ({ page }) => {\n  await page.goto('/app');\n\n  await page.fill('[name=\"apiKey\"]', 'test-key-123');\n  await page.click('button:has-text(\"Save\")');\n\n  await expect(page.getByText('Settings saved')).toBeVisible();\n});\n```\n\n---\n\n## Rate Limits\n\n### GraphQL Cost-Based Limits\n\n```javascript\n// Check rate limit status in response\nconst response = await admin.graphql(`\n  query {\n    shop { name }\n  }\n`);\n\nconst data = await response.json();\n\n// Rate limit info in extensions\nconst throttleStatus = data.extensions?.cost?.throttleStatus;\n// {\n//   maximumAvailable: 1000,\n//   currentlyAvailable: 950,\n//   restoreRate: 50  // points per second\n// }\n```\n\n### Handling Throttling\n\n```javascript\nasync function graphqlWithRetry(admin, query, variables, maxRetries = 3) {\n  for (let attempt = 0; attempt < maxRetries; attempt++) {\n    const response = await admin.graphql(query, { variables });\n    const data = await response.json();\n\n    if (data.errors?.some(e => e.extensions?.code === 'THROTTLED')) {\n      const waitTime = Math.pow(2, attempt) * 1000; // Exponential backoff\n      await new Promise(resolve => setTimeout(resolve, waitTime));\n      continue;\n    }\n\n    return data;\n  }\n  throw new Error('Max retries exceeded');\n}\n```\n\n---\n\n## Checklist\n\n### Before Development\n\n- [ ] Partner account created\n- [ ] Development store created\n- [ ] App created in Partner Dashboard\n- [ ] Shopify CLI installed\n- [ ] App scaffolded with Remix template\n\n### Before Submission\n\n- [ ] GDPR webhooks implemented (customers/data_request, customers/redact, shop/redact)\n- [ ] App uninstall webhook cleans up data\n- [ ] No hardcoded API keys\n- [ ] Error handling for all API calls\n- [ ] Rate limit handling\n- [ ] Responsive UI (works on mobile admin)\n- [ ] Polaris components used consistently\n- [ ] Extension targets correct surfaces\n- [ ] Privacy policy URL configured\n- [ ] App listing completed\n\n### Security\n\n- [ ] Session tokens validated\n- [ ] Webhook HMAC verification (handled by SDK)\n- [ ] No sensitive data in client-side code\n- [ ] Environment variables for all secrets\n- [ ] HTTPS enforced\n\n---\n\n## Anti-Patterns\n\n- **REST API usage** - Use GraphQL Admin API (REST is deprecated)\n- **Storing secrets in metafields** - Use environment variables\n- **Ignoring rate limits** - Implement exponential backoff\n- **Skipping GDPR webhooks** - Required for App Store\n- **Large GraphQL queries** - Paginate, query only needed fields\n- **Polling for updates** - Use webhooks instead\n- **Custom auth flow** - Use Shopify's OAuth flow via SDK\n"
  },
  {
    "path": "skills/site-architecture/SKILL.md",
    "content": "---\nname: site-architecture\ndescription: Technical SEO - robots.txt, sitemap, meta tags, Core Web Vitals\nwhen-to-use: When setting up site architecture, meta tags, or technical SEO\nuser-invocable: false\npaths: [\"**/robots.txt\", \"**/sitemap*\", \"**/*.html\", \"public/**\"]\neffort: medium\n---\n\n# Site Architecture Skill\n\n\nFor technical website structure that enables discovery by search engines AND AI crawlers (GPTBot, ClaudeBot, PerplexityBot).\n\n---\n\n## Philosophy\n\n**Content is king. Architecture is the kingdom.**\n\nGreat content buried in poor architecture won't be discovered. This skill covers the technical foundation that makes your content findable by:\n- Google, Bing (traditional search)\n- GPTBot (ChatGPT), ClaudeBot, PerplexityBot (AI assistants)\n- Social platforms (Open Graph, Twitter Cards)\n\n---\n\n## robots.txt\n\n### Basic Template\n\n```txt\n# robots.txt\n\n# Allow all crawlers by default\nUser-agent: *\nAllow: /\nDisallow: /api/\nDisallow: /admin/\nDisallow: /private/\nDisallow: /_next/\nDisallow: /cdn-cgi/\n\n# Sitemap location\nSitemap: https://yoursite.com/sitemap.xml\n\n# Crawl delay (optional - be careful, not all bots respect this)\n# Crawl-delay: 1\n```\n\n### AI Bot Configuration\n\n```txt\n# robots.txt with AI bot rules\n\n# === SEARCH ENGINES ===\nUser-agent: Googlebot\nAllow: /\n\nUser-agent: Bingbot\nAllow: /\n\n# === AI ASSISTANTS (Allow for discovery) ===\nUser-agent: GPTBot\nAllow: /\n\nUser-agent: ChatGPT-User\nAllow: /\n\nUser-agent: Claude-Web\nAllow: /\n\nUser-agent: ClaudeBot\nAllow: /\n\nUser-agent: PerplexityBot\nAllow: /\n\nUser-agent: Amazonbot\nAllow: /\n\nUser-agent: anthropic-ai\nAllow: /\n\nUser-agent: Google-Extended\nAllow: /\n\n# === BLOCK AI TRAINING (Optional - block training, allow chat) ===\n# Uncomment these if you want to be cited but not used for training\n# User-agent: CCBot\n# Disallow: /\n\n# User-agent: GPTBot\n# Disallow: /  # Blocks both chat and training\n\n# === BLOCK SCRAPERS ===\nUser-agent: AhrefsBot\nDisallow: /\n\nUser-agent: SemrushBot\nDisallow: /\n\nUser-agent: MJ12bot\nDisallow: /\n\n# === DEFAULT ===\nUser-agent: *\nAllow: /\nDisallow: /api/\nDisallow: /admin/\nDisallow: /auth/\nDisallow: /private/\nDisallow: /*.json$\nDisallow: /*?*\n\nSitemap: https://yoursite.com/sitemap.xml\n```\n\n### Next.js robots.txt\n\n```typescript\n// app/robots.ts\nimport { MetadataRoute } from 'next';\n\nexport default function robots(): MetadataRoute.Robots {\n  const baseUrl = process.env.NEXT_PUBLIC_URL || 'https://yoursite.com';\n\n  return {\n    rules: [\n      {\n        userAgent: '*',\n        allow: '/',\n        disallow: ['/api/', '/admin/', '/private/', '/_next/'],\n      },\n      {\n        userAgent: 'GPTBot',\n        allow: '/',\n      },\n      {\n        userAgent: 'ClaudeBot',\n        allow: '/',\n      },\n      {\n        userAgent: 'PerplexityBot',\n        allow: '/',\n      },\n    ],\n    sitemap: `${baseUrl}/sitemap.xml`,\n  };\n}\n```\n\n---\n\n## Sitemap\n\n### XML Sitemap Template\n\n```xml\n<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\"\n        xmlns:news=\"http://www.google.com/schemas/sitemap-news/0.9\"\n        xmlns:image=\"http://www.google.com/schemas/sitemap-image/1.1\">\n  <url>\n    <loc>https://yoursite.com/</loc>\n    <lastmod>2025-01-15</lastmod>\n    <changefreq>weekly</changefreq>\n    <priority>1.0</priority>\n  </url>\n  <url>\n    <loc>https://yoursite.com/pricing</loc>\n    <lastmod>2025-01-10</lastmod>\n    <changefreq>monthly</changefreq>\n    <priority>0.9</priority>\n  </url>\n  <url>\n    <loc>https://yoursite.com/blog/article-slug</loc>\n    <lastmod>2025-01-12</lastmod>\n    <changefreq>monthly</changefreq>\n    <priority>0.8</priority>\n    <image:image>\n      <image:loc>https://yoursite.com/images/article-image.jpg</image:loc>\n    </image:image>\n  </url>\n</urlset>\n```\n\n### Next.js Dynamic Sitemap\n\n```typescript\n// app/sitemap.ts\nimport { MetadataRoute } from 'next';\n\nexport default async function sitemap(): Promise<MetadataRoute.Sitemap> {\n  const baseUrl = process.env.NEXT_PUBLIC_URL || 'https://yoursite.com';\n\n  // Static pages\n  const staticPages = [\n    { url: '/', priority: 1.0, changeFrequency: 'weekly' as const },\n    { url: '/pricing', priority: 0.9, changeFrequency: 'monthly' as const },\n    { url: '/about', priority: 0.8, changeFrequency: 'monthly' as const },\n    { url: '/contact', priority: 0.7, changeFrequency: 'yearly' as const },\n  ];\n\n  // Dynamic pages (e.g., blog posts)\n  const posts = await getBlogPosts(); // Your data fetching function\n  const blogPages = posts.map((post) => ({\n    url: `/blog/${post.slug}`,\n    lastModified: new Date(post.updatedAt),\n    changeFrequency: 'monthly' as const,\n    priority: 0.8,\n  }));\n\n  return [\n    ...staticPages.map((page) => ({\n      url: `${baseUrl}${page.url}`,\n      lastModified: new Date(),\n      changeFrequency: page.changeFrequency,\n      priority: page.priority,\n    })),\n    ...blogPages.map((page) => ({\n      url: `${baseUrl}${page.url}`,\n      lastModified: page.lastModified,\n      changeFrequency: page.changeFrequency,\n      priority: page.priority,\n    })),\n  ];\n}\n```\n\n### Sitemap Index (Large Sites)\n\n```xml\n<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<sitemapindex xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n  <sitemap>\n    <loc>https://yoursite.com/sitemap-pages.xml</loc>\n    <lastmod>2025-01-15</lastmod>\n  </sitemap>\n  <sitemap>\n    <loc>https://yoursite.com/sitemap-blog.xml</loc>\n    <lastmod>2025-01-14</lastmod>\n  </sitemap>\n  <sitemap>\n    <loc>https://yoursite.com/sitemap-products.xml</loc>\n    <lastmod>2025-01-13</lastmod>\n  </sitemap>\n</sitemapindex>\n```\n\n---\n\n## Meta Tags\n\n### Essential Meta Tags\n\n```html\n<head>\n  <!-- Basic -->\n  <meta charset=\"UTF-8\">\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n  <title>Page Title | Brand Name</title>\n  <meta name=\"description\" content=\"Compelling 150-160 character description with keywords and CTA.\">\n\n  <!-- Canonical (prevent duplicate content) -->\n  <link rel=\"canonical\" href=\"https://yoursite.com/current-page\">\n\n  <!-- Language -->\n  <html lang=\"en\">\n  <meta name=\"language\" content=\"English\">\n\n  <!-- Robots -->\n  <meta name=\"robots\" content=\"index, follow\">\n  <meta name=\"googlebot\" content=\"index, follow\">\n\n  <!-- Author -->\n  <meta name=\"author\" content=\"Author Name\">\n\n  <!-- Favicon -->\n  <link rel=\"icon\" href=\"/favicon.ico\" sizes=\"any\">\n  <link rel=\"icon\" href=\"/icon.svg\" type=\"image/svg+xml\">\n  <link rel=\"apple-touch-icon\" href=\"/apple-touch-icon.png\">\n  <link rel=\"manifest\" href=\"/manifest.webmanifest\">\n</head>\n```\n\n### Open Graph (Social Sharing)\n\n```html\n<!-- Open Graph / Facebook -->\n<meta property=\"og:type\" content=\"website\">\n<meta property=\"og:url\" content=\"https://yoursite.com/page\">\n<meta property=\"og:title\" content=\"Page Title - Brand\">\n<meta property=\"og:description\" content=\"Description for social sharing (can be longer).\">\n<meta property=\"og:image\" content=\"https://yoursite.com/og-image.jpg\">\n<meta property=\"og:image:width\" content=\"1200\">\n<meta property=\"og:image:height\" content=\"630\">\n<meta property=\"og:site_name\" content=\"Brand Name\">\n<meta property=\"og:locale\" content=\"en_US\">\n\n<!-- Article-specific (for blog posts) -->\n<meta property=\"og:type\" content=\"article\">\n<meta property=\"article:published_time\" content=\"2025-01-15T08:00:00Z\">\n<meta property=\"article:modified_time\" content=\"2025-01-20T10:00:00Z\">\n<meta property=\"article:author\" content=\"https://yoursite.com/team/author\">\n<meta property=\"article:section\" content=\"Technology\">\n<meta property=\"article:tag\" content=\"AI, SEO, Content\">\n```\n\n### Twitter Cards\n\n```html\n<!-- Twitter -->\n<meta name=\"twitter:card\" content=\"summary_large_image\">\n<meta name=\"twitter:site\" content=\"@yourbrand\">\n<meta name=\"twitter:creator\" content=\"@authorhandle\">\n<meta name=\"twitter:title\" content=\"Page Title\">\n<meta name=\"twitter:description\" content=\"Description for Twitter (max 200 chars).\">\n<meta name=\"twitter:image\" content=\"https://yoursite.com/twitter-image.jpg\">\n```\n\n### Next.js Metadata\n\n```typescript\n// app/layout.tsx\nimport { Metadata } from 'next';\n\nexport const metadata: Metadata = {\n  metadataBase: new URL('https://yoursite.com'),\n  title: {\n    default: 'Brand Name',\n    template: '%s | Brand Name',\n  },\n  description: 'Your default site description.',\n  keywords: ['keyword1', 'keyword2', 'keyword3'],\n  authors: [{ name: 'Brand Name', url: 'https://yoursite.com' }],\n  creator: 'Brand Name',\n  publisher: 'Brand Name',\n  robots: {\n    index: true,\n    follow: true,\n    googleBot: {\n      index: true,\n      follow: true,\n      'max-video-preview': -1,\n      'max-image-preview': 'large',\n      'max-snippet': -1,\n    },\n  },\n  openGraph: {\n    type: 'website',\n    locale: 'en_US',\n    url: 'https://yoursite.com',\n    siteName: 'Brand Name',\n    title: 'Brand Name',\n    description: 'Your site description.',\n    images: [\n      {\n        url: '/og-image.jpg',\n        width: 1200,\n        height: 630,\n        alt: 'Brand Name',\n      },\n    ],\n  },\n  twitter: {\n    card: 'summary_large_image',\n    site: '@yourbrand',\n    creator: '@yourbrand',\n  },\n  verification: {\n    google: 'google-verification-code',\n    yandex: 'yandex-verification-code',\n  },\n};\n\n// app/blog/[slug]/page.tsx\nexport async function generateMetadata({ params }): Promise<Metadata> {\n  const post = await getPost(params.slug);\n\n  return {\n    title: post.title,\n    description: post.excerpt,\n    openGraph: {\n      title: post.title,\n      description: post.excerpt,\n      type: 'article',\n      publishedTime: post.publishedAt,\n      modifiedTime: post.updatedAt,\n      authors: [post.author.name],\n      images: [post.coverImage],\n    },\n  };\n}\n```\n\n---\n\n## URL Structure\n\n### Best Practices\n\n```markdown\n✅ GOOD URLs:\n/blog/ai-seo-best-practices\n/products/pro-plan\n/pricing\n/about/team\n\n❌ BAD URLs:\n/blog?id=123\n/p/12345\n/index.php?page=about\n/Products/Pro_Plan (inconsistent casing)\n```\n\n### URL Guidelines\n\n| Rule | Example |\n|------|---------|\n| Lowercase only | `/blog/my-post` not `/Blog/My-Post` |\n| Hyphens not underscores | `/my-page` not `/my_page` |\n| No trailing slashes | `/about` not `/about/` |\n| Descriptive slugs | `/pricing` not `/p` |\n| No query params for content | `/blog/post-title` not `/blog?id=123` |\n| Max 3-4 levels deep | `/blog/category/post` |\n\n### Redirect Configuration\n\n```typescript\n// next.config.js\nmodule.exports = {\n  async redirects() {\n    return [\n      // Redirect old URLs to new\n      {\n        source: '/old-page',\n        destination: '/new-page',\n        permanent: true, // 301 redirect\n      },\n      // Redirect with wildcard\n      {\n        source: '/blog/old/:slug',\n        destination: '/articles/:slug',\n        permanent: true,\n      },\n      // Trailing slash redirect\n      {\n        source: '/:path+/',\n        destination: '/:path+',\n        permanent: true,\n      },\n    ];\n  },\n};\n```\n\n---\n\n## Canonical URLs\n\n### Implementation\n\n```html\n<!-- Always include canonical, even for primary URL -->\n<link rel=\"canonical\" href=\"https://yoursite.com/current-page\">\n```\n\n### When to Use\n\n```markdown\n✅ USE CANONICAL:\n- Every page (even if only version exists)\n- Paginated content (point to page 1 or use rel=prev/next)\n- URL parameters that don't change content (?utm_source=...)\n- HTTP vs HTTPS (canonical to HTTPS)\n- www vs non-www (pick one, canonical to it)\n\nExample: /products?sort=price should canonical to /products\n```\n\n### Next.js Canonical\n\n```typescript\n// Automatic in metadata\nexport const metadata: Metadata = {\n  alternates: {\n    canonical: '/current-page',\n  },\n};\n```\n\n---\n\n## Security Headers\n\n### Essential Headers\n\n```typescript\n// next.config.js\nconst securityHeaders = [\n  {\n    key: 'X-DNS-Prefetch-Control',\n    value: 'on',\n  },\n  {\n    key: 'Strict-Transport-Security',\n    value: 'max-age=63072000; includeSubDomains; preload',\n  },\n  {\n    key: 'X-Frame-Options',\n    value: 'SAMEORIGIN',\n  },\n  {\n    key: 'X-Content-Type-Options',\n    value: 'nosniff',\n  },\n  {\n    key: 'Referrer-Policy',\n    value: 'strict-origin-when-cross-origin',\n  },\n  {\n    key: 'Permissions-Policy',\n    value: 'camera=(), microphone=(), geolocation=()',\n  },\n];\n\nmodule.exports = {\n  async headers() {\n    return [\n      {\n        source: '/:path*',\n        headers: securityHeaders,\n      },\n    ];\n  },\n};\n```\n\n---\n\n## Core Web Vitals\n\n### Target Metrics\n\n| Metric | Good | Needs Improvement | Poor |\n|--------|------|-------------------|------|\n| LCP (Largest Contentful Paint) | ≤2.5s | ≤4.0s | >4.0s |\n| INP (Interaction to Next Paint) | ≤200ms | ≤500ms | >500ms |\n| CLS (Cumulative Layout Shift) | ≤0.1 | ≤0.25 | >0.25 |\n\n### Optimization Checklist\n\n```markdown\n## LCP (Loading)\n- [ ] Optimize largest image (WebP, proper sizing)\n- [ ] Preload critical assets\n- [ ] Use CDN for static assets\n- [ ] Enable compression (gzip/brotli)\n- [ ] Minimize render-blocking resources\n\n## INP (Interactivity)\n- [ ] Minimize JavaScript execution time\n- [ ] Break up long tasks\n- [ ] Use web workers for heavy computation\n- [ ] Optimize event handlers\n- [ ] Lazy load non-critical JS\n\n## CLS (Visual Stability)\n- [ ] Set dimensions on images/videos\n- [ ] Reserve space for dynamic content\n- [ ] Avoid inserting content above existing\n- [ ] Use transform for animations\n- [ ] Preload fonts\n```\n\n### Next.js Performance\n\n```typescript\n// Image optimization\nimport Image from 'next/image';\n\n<Image\n  src=\"/hero.jpg\"\n  alt=\"Hero image\"\n  width={1200}\n  height={630}\n  priority // Preload for LCP\n  placeholder=\"blur\"\n  blurDataURL={blurDataUrl}\n/>\n\n// Font optimization\nimport { Inter } from 'next/font/google';\n\nconst inter = Inter({\n  subsets: ['latin'],\n  display: 'swap', // Prevent FOIT\n});\n\n// Dynamic imports\nimport dynamic from 'next/dynamic';\n\nconst HeavyComponent = dynamic(() => import('./HeavyComponent'), {\n  loading: () => <Skeleton />,\n  ssr: false, // Client-only if needed\n});\n```\n\n---\n\n## Internal Linking\n\n### Structure\n\n```markdown\n## Link Architecture\n\nHomepage\n├── /pricing (1 click)\n├── /features (1 click)\n├── /blog (1 click)\n│   ├── /blog/category-1 (2 clicks)\n│   │   └── /blog/category-1/post (3 clicks)\n│   └── /blog/category-2 (2 clicks)\n└── /about (1 click)\n\nRule: Every page within 3 clicks of homepage\n```\n\n### Best Practices\n\n```markdown\n✅ DO:\n- Use descriptive anchor text\n- Link contextually within content\n- Create hub pages for topics\n- Link to related content at end of posts\n- Use breadcrumbs for navigation\n\n❌ AVOID:\n- \"Click here\" as anchor text\n- Orphan pages (no internal links)\n- Too many links per page (>100)\n- Broken internal links\n- Redirect chains\n```\n\n### Breadcrumbs\n\n```typescript\n// components/Breadcrumbs.tsx\nimport Link from 'next/link';\n\ninterface BreadcrumbItem {\n  name: string;\n  href: string;\n}\n\nexport function Breadcrumbs({ items }: { items: BreadcrumbItem[] }) {\n  const jsonLd = {\n    '@context': 'https://schema.org',\n    '@type': 'BreadcrumbList',\n    itemListElement: items.map((item, index) => ({\n      '@type': 'ListItem',\n      position: index + 1,\n      name: item.name,\n      item: `https://yoursite.com${item.href}`,\n    })),\n  };\n\n  return (\n    <>\n      <script\n        type=\"application/ld+json\"\n        dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}\n      />\n      <nav aria-label=\"Breadcrumb\">\n        <ol className=\"flex gap-2\">\n          {items.map((item, index) => (\n            <li key={item.href}>\n              {index > 0 && <span>/</span>}\n              <Link href={item.href}>{item.name}</Link>\n            </li>\n          ))}\n        </ol>\n      </nav>\n    </>\n  );\n}\n```\n\n---\n\n## AI Crawler Handling\n\n### Known AI Crawlers\n\n| Bot | User Agent | Purpose |\n|-----|------------|---------|\n| GPTBot | `GPTBot` | ChatGPT web browsing |\n| ChatGPT-User | `ChatGPT-User` | ChatGPT user browsing |\n| ClaudeBot | `ClaudeBot` | Claude web access |\n| Claude-Web | `Claude-Web` | Claude web features |\n| PerplexityBot | `PerplexityBot` | Perplexity search |\n| Google-Extended | `Google-Extended` | Gemini/Bard training |\n| Amazonbot | `Amazonbot` | Alexa/Amazon AI |\n| CCBot | `CCBot` | Common Crawl (AI training) |\n\n### Allow AI Discovery, Block Training (Optional)\n\n```txt\n# robots.txt\n\n# Allow GPTBot for ChatGPT browsing\nUser-agent: GPTBot\nAllow: /\n\n# Block CCBot (used for training datasets)\nUser-agent: CCBot\nDisallow: /\n\n# Block Google AI training, allow search\nUser-agent: Google-Extended\nDisallow: /\n```\n\n### AI-Specific Meta Tags\n\n```html\n<!-- Block AI training but allow indexing -->\n<meta name=\"robots\" content=\"index, follow, max-image-preview:large\">\n\n<!-- Opt out of AI training (proposed standard) -->\n<meta name=\"ai-training\" content=\"disallow\">\n```\n\n---\n\n## Structured Data Placement\n\n### Where to Add Schema\n\n```html\n<!-- Option 1: In <head> with JSON-LD (recommended) -->\n<head>\n  <script type=\"application/ld+json\">\n    {\n      \"@context\": \"https://schema.org\",\n      \"@type\": \"Organization\",\n      \"name\": \"Your Company\"\n    }\n  </script>\n</head>\n\n<!-- Option 2: Before closing </body> -->\n<body>\n  <!-- Page content -->\n  <script type=\"application/ld+json\">\n    { \"@context\": \"https://schema.org\", ... }\n  </script>\n</body>\n```\n\n### Multiple Schema Per Page\n\n```html\n<head>\n  <!-- Organization (site-wide) -->\n  <script type=\"application/ld+json\">\n    { \"@context\": \"https://schema.org\", \"@type\": \"Organization\", ... }\n  </script>\n\n  <!-- BreadcrumbList (navigation) -->\n  <script type=\"application/ld+json\">\n    { \"@context\": \"https://schema.org\", \"@type\": \"BreadcrumbList\", ... }\n  </script>\n\n  <!-- Article (page-specific) -->\n  <script type=\"application/ld+json\">\n    { \"@context\": \"https://schema.org\", \"@type\": \"Article\", ... }\n  </script>\n\n  <!-- FAQPage (if FAQ section exists) -->\n  <script type=\"application/ld+json\">\n    { \"@context\": \"https://schema.org\", \"@type\": \"FAQPage\", ... }\n  </script>\n</head>\n```\n\n---\n\n## Project Structure\n\n```\nproject/\n├── public/\n│   ├── robots.txt              # Or generate dynamically\n│   ├── sitemap.xml             # Or generate dynamically\n│   ├── favicon.ico\n│   ├── icon.svg\n│   ├── apple-touch-icon.png\n│   ├── og-image.jpg            # Default OG image (1200x630)\n│   └── manifest.webmanifest\n├── app/\n│   ├── layout.tsx              # Global metadata\n│   ├── robots.ts               # Dynamic robots.txt\n│   ├── sitemap.ts              # Dynamic sitemap\n│   └── [page]/\n│       └── page.tsx            # Page-specific metadata\n├── components/\n│   ├── SchemaMarkup.tsx\n│   ├── Breadcrumbs.tsx\n│   └── MetaTags.tsx\n└── lib/\n    ├── schema.ts               # Schema generators\n    └── seo.ts                  # SEO utilities\n```\n\n---\n\n## Verification & Submission\n\n### Search Console Setup\n\n```bash\n# Verify ownership methods\n1. HTML file upload (google*.html to public/)\n2. Meta tag (add to <head>)\n3. DNS TXT record\n4. Google Analytics (if already installed)\n```\n\n### Submit Sitemap\n\n```markdown\n1. Google Search Console\n   - Sitemaps → Add new sitemap → yoursite.com/sitemap.xml\n\n2. Bing Webmaster Tools\n   - Sitemaps → Submit sitemap\n\n3. Yandex Webmaster (if relevant)\n   - Indexing → Sitemap files\n```\n\n---\n\n## Checklist\n\n```markdown\n## Technical SEO Checklist\n\n### robots.txt\n- [ ] Allow search engines\n- [ ] Allow AI bots (GPTBot, ClaudeBot, PerplexityBot)\n- [ ] Block admin/private areas\n- [ ] Include sitemap reference\n- [ ] Test with Google's robots.txt tester\n\n### Sitemap\n- [ ] Include all indexable pages\n- [ ] Exclude noindex pages\n- [ ] Include lastmod dates\n- [ ] Submit to Search Console\n- [ ] Auto-update on content changes\n\n### Meta Tags\n- [ ] Unique title per page (50-60 chars)\n- [ ] Unique description per page (150-160 chars)\n- [ ] Canonical URL on every page\n- [ ] Open Graph tags\n- [ ] Twitter Card tags\n\n### URL Structure\n- [ ] Lowercase, hyphenated\n- [ ] Descriptive slugs\n- [ ] No query params for content\n- [ ] 301 redirects for moved content\n- [ ] No broken links\n\n### Performance\n- [ ] LCP < 2.5s\n- [ ] INP < 200ms\n- [ ] CLS < 0.1\n- [ ] HTTPS enabled\n- [ ] Security headers configured\n\n### Structured Data\n- [ ] Organization schema (homepage)\n- [ ] BreadcrumbList (all pages)\n- [ ] Article schema (blog posts)\n- [ ] FAQ schema (FAQ sections)\n- [ ] Validate with Rich Results Test\n```\n\n---\n\n## Quick Reference\n\n### File Checklist\n\n```\npublic/\n├── robots.txt          ✓ Required\n├── sitemap.xml         ✓ Required\n├── favicon.ico         ✓ Required\n├── og-image.jpg        ✓ Required (1200x630)\n└── manifest.json       ○ Recommended\n```\n\n### Meta Tag Lengths\n\n| Tag | Length |\n|-----|--------|\n| Title | 50-60 characters |\n| Description | 150-160 characters |\n| OG Title | 60-90 characters |\n| OG Description | 200 characters |\n| Twitter Description | 200 characters |\n\n### Image Sizes\n\n| Image | Dimensions |\n|-------|------------|\n| OG Image | 1200 x 630 |\n| Twitter Image | 1200 x 628 |\n| Favicon | 32 x 32 |\n| Apple Touch Icon | 180 x 180 |\n"
  },
  {
    "path": "skills/supabase/SKILL.md",
    "content": "---\nname: supabase\ndescription: Core Supabase CLI, migrations, RLS, Edge Functions\nwhen-to-use: When working with Supabase - database, auth, storage, or edge functions\nuser-invocable: false\npaths: [\"supabase/**\", \"**/supabase.*\", \"**/.env*\"]\neffort: medium\n---\n\n# Supabase Core Skill\n\n\nCore concepts, CLI workflow, and patterns common to all Supabase projects.\n\n**Sources:** [Supabase Docs](https://supabase.com/docs) | [Supabase CLI](https://supabase.com/docs/guides/local-development/cli/getting-started)\n\n---\n\n## Core Principle\n\n**Local-first, migrations in version control, never touch production directly.**\n\nDevelop locally with the Supabase CLI, capture all changes as migrations, and deploy through CI/CD.\n\n---\n\n## Supabase Stack\n\n| Service | Purpose |\n|---------|---------|\n| **Database** | PostgreSQL with extensions |\n| **Auth** | User authentication, OAuth providers |\n| **Storage** | File storage with RLS |\n| **Edge Functions** | Serverless Deno functions |\n| **Realtime** | WebSocket subscriptions |\n| **Vector** | AI embeddings (pgvector) |\n\n---\n\n## CLI Setup\n\n### Install & Login\n```bash\n# macOS\nbrew install supabase/tap/supabase\n\n# npm (alternative)\nnpm install -g supabase\n\n# Login\nsupabase login\n```\n\n### Initialize Project\n```bash\n# In your project directory\nsupabase init\n\n# Creates:\n# supabase/\n# ├── config.toml      # Local config\n# ├── seed.sql         # Seed data\n# └── migrations/      # SQL migrations\n```\n\n### Link to Remote\n```bash\n# Get project ref from dashboard URL: https://supabase.com/dashboard/project/<ref>\nsupabase link --project-ref <project-id>\n\n# Pull existing schema\nsupabase db pull\n```\n\n### Start Local Stack\n```bash\nsupabase start\n\n# Output:\n# API URL: http://localhost:54321\n# GraphQL URL: http://localhost:54321/graphql/v1\n# DB URL: postgresql://postgres:postgres@localhost:54322/postgres\n# Studio URL: http://localhost:54323\n# Anon key: eyJ...\n# Service role key: eyJ...\n```\n\n---\n\n## Migration Workflow\n\n### Option 1: Dashboard + Diff (Quick Prototyping)\n```bash\n# 1. Make changes in local Studio (localhost:54323)\n# 2. Generate migration from diff\nsupabase db diff -f <migration_name>\n\n# 3. Review generated SQL\ncat supabase/migrations/*_<migration_name>.sql\n\n# 4. Reset to test\nsupabase db reset\n```\n\n### Option 2: Write Migrations Directly (Recommended)\n```bash\n# 1. Create empty migration\nsupabase migration new create_users_table\n\n# 2. Edit the migration file\n# supabase/migrations/<timestamp>_create_users_table.sql\n\n# 3. Apply locally\nsupabase db reset\n```\n\n### Option 3: ORM Migrations (Best DX)\nUse Drizzle (TypeScript) or SQLAlchemy (Python) - see framework-specific skills.\n\n### Deploy Migrations\n```bash\n# Push to remote (staging/production)\nsupabase db push\n\n# Check migration status\nsupabase migration list\n```\n\n---\n\n## Database Patterns\n\n### Enable RLS on All Tables\n```sql\n-- Always enable RLS\nALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;\n\n-- Default deny - must create policies\nCREATE POLICY \"Users can view own profile\"\n  ON public.profiles\n  FOR SELECT\n  USING (auth.uid() = id);\n```\n\n### Common RLS Policies\n```sql\n-- Public read\nCREATE POLICY \"Public read access\"\n  ON public.posts FOR SELECT\n  USING (true);\n\n-- Authenticated users only\nCREATE POLICY \"Authenticated users can insert\"\n  ON public.posts FOR INSERT\n  WITH CHECK (auth.role() = 'authenticated');\n\n-- Owner access\nCREATE POLICY \"Users can update own records\"\n  ON public.posts FOR UPDATE\n  USING (auth.uid() = user_id);\n\n-- Admin access (using custom claim)\nCREATE POLICY \"Admins have full access\"\n  ON public.posts FOR ALL\n  USING (auth.jwt() ->> 'role' = 'admin');\n```\n\n### Link to auth.users\n```sql\n-- Profile table linked to auth\nCREATE TABLE public.profiles (\n  id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,\n  username TEXT UNIQUE NOT NULL,\n  avatar_url TEXT,\n  created_at TIMESTAMPTZ DEFAULT NOW()\n);\n\n-- Auto-create profile on signup\nCREATE OR REPLACE FUNCTION public.handle_new_user()\nRETURNS TRIGGER AS $$\nBEGIN\n  INSERT INTO public.profiles (id, username)\n  VALUES (NEW.id, NEW.email);\n  RETURN NEW;\nEND;\n$$ LANGUAGE plpgsql SECURITY DEFINER;\n\nCREATE TRIGGER on_auth_user_created\n  AFTER INSERT ON auth.users\n  FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();\n```\n\n---\n\n## Seed Data\n\n### supabase/seed.sql\n```sql\n-- Runs on `supabase db reset`\n-- Use ON CONFLICT for idempotency\n\nINSERT INTO public.profiles (id, username, avatar_url)\nVALUES\n  ('d0e1f2a3-b4c5-6d7e-8f9a-0b1c2d3e4f5a', 'testuser', null),\n  ('a1b2c3d4-e5f6-7a8b-9c0d-1e2f3a4b5c6d', 'admin', null)\nON CONFLICT (id) DO NOTHING;\n```\n\n---\n\n## Environment Variables\n\n### Required Variables\n```bash\n# Public (safe for client-side)\nSUPABASE_URL=https://xxxxx.supabase.co\nSUPABASE_ANON_KEY=eyJ...\n\n# Private (server-side only - NEVER expose)\nSUPABASE_SERVICE_ROLE_KEY=eyJ...\nDATABASE_URL=postgresql://postgres.[ref]:[password]@aws-0-region.pooler.supabase.com:6543/postgres\n```\n\n### Local vs Production\n```bash\n# .env.local (local development)\nSUPABASE_URL=http://localhost:54321\nSUPABASE_ANON_KEY=<from supabase start>\nDATABASE_URL=postgresql://postgres:postgres@localhost:54322/postgres\n\n# .env.production (remote)\nSUPABASE_URL=https://xxxxx.supabase.co\nSUPABASE_ANON_KEY=<from dashboard>\nDATABASE_URL=<connection pooler URL>\n```\n\n### Connection Pooling\n```bash\n# Transaction mode (recommended for serverless)\n# Add ?pgbouncer=true to URL\nDATABASE_URL=postgresql://...@pooler.supabase.com:6543/postgres?pgbouncer=true\n\n# Session mode (for migrations, long transactions)\nDATABASE_URL=postgresql://...@pooler.supabase.com:5432/postgres\n```\n\n---\n\n## Edge Functions\n\n### Create Function\n```bash\nsupabase functions new hello-world\n```\n\n### Basic Structure\n```typescript\n// supabase/functions/hello-world/index.ts\nimport { serve } from 'https://deno.land/std@0.168.0/http/server.ts';\n\nserve(async (req) => {\n  const { name } = await req.json();\n\n  return new Response(\n    JSON.stringify({ message: `Hello ${name}!` }),\n    { headers: { 'Content-Type': 'application/json' } }\n  );\n});\n```\n\n### With Auth Context\n```typescript\nimport { serve } from 'https://deno.land/std@0.168.0/http/server.ts';\nimport { createClient } from 'https://esm.sh/@supabase/supabase-js@2';\n\nserve(async (req) => {\n  const supabase = createClient(\n    Deno.env.get('SUPABASE_URL') ?? '',\n    Deno.env.get('SUPABASE_ANON_KEY') ?? '',\n    {\n      global: {\n        headers: { Authorization: req.headers.get('Authorization')! },\n      },\n    }\n  );\n\n  const { data: { user } } = await supabase.auth.getUser();\n\n  if (!user) {\n    return new Response('Unauthorized', { status: 401 });\n  }\n\n  return new Response(JSON.stringify({ user_id: user.id }));\n});\n```\n\n### Deploy\n```bash\n# Serve locally\nsupabase functions serve\n\n# Deploy single function\nsupabase functions deploy hello-world\n\n# Deploy all\nsupabase functions deploy\n```\n\n---\n\n## Storage\n\n### Create Bucket (in migration)\n```sql\nINSERT INTO storage.buckets (id, name, public)\nVALUES ('avatars', 'avatars', true);\n\n-- Storage policies\nCREATE POLICY \"Avatar images are publicly accessible\"\n  ON storage.objects FOR SELECT\n  USING (bucket_id = 'avatars');\n\nCREATE POLICY \"Users can upload own avatar\"\n  ON storage.objects FOR INSERT\n  WITH CHECK (\n    bucket_id = 'avatars' AND\n    auth.uid()::text = (storage.foldername(name))[1]\n  );\n```\n\n---\n\n## CLI Quick Reference\n\n```bash\n# Lifecycle\nsupabase start                   # Start local stack\nsupabase stop                    # Stop local stack\nsupabase status                  # Show status & credentials\n\n# Database\nsupabase db reset                # Reset + migrations + seed\nsupabase db push                 # Push to remote\nsupabase db pull                 # Pull remote schema\nsupabase db diff -f <name>       # Generate migration from diff\nsupabase db lint                 # Check for issues\n\n# Migrations\nsupabase migration new <name>    # Create migration\nsupabase migration list          # List migrations\nsupabase migration up            # Apply pending (remote)\n\n# Functions\nsupabase functions new <name>    # Create function\nsupabase functions serve         # Local dev\nsupabase functions deploy        # Deploy all\n\n# Types\nsupabase gen types typescript --local > types/database.ts\n\n# Project\nsupabase link --project-ref <id> # Link to remote\nsupabase projects list           # List projects\n```\n\n---\n\n## CI/CD Template\n\n```yaml\n# .github/workflows/supabase.yml\nname: Supabase CI/CD\n\non:\n  push:\n    branches: [main]\n  pull_request:\n\nenv:\n  SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}\n  SUPABASE_DB_PASSWORD: ${{ secrets.SUPABASE_DB_PASSWORD }}\n  SUPABASE_PROJECT_ID: ${{ secrets.SUPABASE_PROJECT_ID }}\n\njobs:\n  test:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: supabase/setup-cli@v1\n\n      - name: Start Supabase\n        run: supabase start\n\n      - name: Run migrations\n        run: supabase db reset\n\n      - name: Lint database\n        run: supabase db lint\n\n  deploy:\n    needs: test\n    if: github.ref == 'refs/heads/main'\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: supabase/setup-cli@v1\n\n      - name: Link project\n        run: supabase link --project-ref $SUPABASE_PROJECT_ID\n\n      - name: Push migrations\n        run: supabase db push\n\n      - name: Deploy functions\n        run: supabase functions deploy\n```\n\n---\n\n## Anti-Patterns\n\n- **Direct production changes** - Always use migrations\n- **Disabled RLS** - Enable on all user-data tables\n- **Service key in client** - Never expose service role key\n- **No connection pooling** - Use pooler for serverless\n- **Committing .env** - Add to .gitignore\n- **Skipping migration review** - Always check generated SQL\n- **No seed data** - Use seed.sql for consistent local dev\n"
  },
  {
    "path": "skills/supabase-nextjs/SKILL.md",
    "content": "---\nname: supabase-nextjs\ndescription: Next.js with Supabase and Drizzle ORM\nwhen-to-use: When building a Next.js app with Supabase backend\nuser-invocable: false\npaths: [\"src/app/**\", \"src/db/**\", \"supabase/**\"]\neffort: medium\n---\n\n# Supabase + Next.js Skill\n\n\nNext.js App Router patterns with Supabase Auth and Drizzle ORM.\n\n**Sources:** [Supabase Next.js Guide](https://supabase.com/docs/guides/auth/server-side/nextjs) | [Drizzle + Supabase](https://supabase.com/docs/guides/database/drizzle)\n\n---\n\n## Core Principle\n\n**Drizzle for queries, Supabase for auth/storage, server components by default.**\n\nUse Drizzle ORM for type-safe database access. Use Supabase client for auth, storage, and realtime. Prefer server components; use client components only when needed.\n\n---\n\n## Project Structure\n\n```\nproject/\n├── src/\n│   ├── app/\n│   │   ├── (auth)/\n│   │   │   ├── login/page.tsx\n│   │   │   ├── signup/page.tsx\n│   │   │   └── callback/route.ts\n│   │   ├── (dashboard)/\n│   │   │   └── page.tsx\n│   │   ├── api/\n│   │   │   └── [...]/route.ts\n│   │   ├── layout.tsx\n│   │   └── page.tsx\n│   ├── components/\n│   │   ├── auth/\n│   │   └── ui/\n│   ├── db/\n│   │   ├── index.ts              # Drizzle client\n│   │   ├── schema.ts             # Schema definitions\n│   │   └── queries/              # Query functions\n│   ├── lib/\n│   │   ├── supabase/\n│   │   │   ├── client.ts         # Browser client\n│   │   │   ├── server.ts         # Server client\n│   │   │   └── middleware.ts     # Auth middleware helper\n│   │   └── auth.ts               # Auth helpers\n│   └── middleware.ts             # Next.js middleware\n├── supabase/\n│   ├── migrations/\n│   └── config.toml\n├── drizzle.config.ts\n└── .env.local\n```\n\n---\n\n## Setup\n\n### Install Dependencies\n```bash\nnpm install @supabase/supabase-js @supabase/ssr drizzle-orm postgres\nnpm install -D drizzle-kit\n```\n\n### Environment Variables\n```bash\n# .env.local\nNEXT_PUBLIC_SUPABASE_URL=http://localhost:54321\nNEXT_PUBLIC_SUPABASE_ANON_KEY=<from supabase start>\n\n# Server-side only\nSUPABASE_SERVICE_ROLE_KEY=<from supabase start>\nDATABASE_URL=postgresql://postgres:postgres@localhost:54322/postgres\n```\n\n---\n\n## Drizzle Setup\n\n### drizzle.config.ts\n```typescript\nimport { defineConfig } from 'drizzle-kit';\n\nexport default defineConfig({\n  schema: './src/db/schema.ts',\n  out: './supabase/migrations',\n  dialect: 'postgresql',\n  dbCredentials: {\n    url: process.env.DATABASE_URL!,\n  },\n  schemaFilter: ['public'],\n});\n```\n\n### src/db/index.ts\n```typescript\nimport { drizzle } from 'drizzle-orm/postgres-js';\nimport postgres from 'postgres';\nimport * as schema from './schema';\n\nconst client = postgres(process.env.DATABASE_URL!, {\n  prepare: false, // Required for Supabase connection pooling\n});\n\nexport const db = drizzle(client, { schema });\n```\n\n### src/db/schema.ts\n```typescript\nimport {\n  pgTable,\n  uuid,\n  text,\n  timestamp,\n  boolean,\n} from 'drizzle-orm/pg-core';\n\nexport const profiles = pgTable('profiles', {\n  id: uuid('id').primaryKey(), // References auth.users\n  email: text('email').notNull(),\n  name: text('name'),\n  avatarUrl: text('avatar_url'),\n  createdAt: timestamp('created_at').defaultNow().notNull(),\n  updatedAt: timestamp('updated_at').defaultNow().notNull(),\n});\n\nexport const posts = pgTable('posts', {\n  id: uuid('id').primaryKey().defaultRandom(),\n  authorId: uuid('author_id').references(() => profiles.id).notNull(),\n  title: text('title').notNull(),\n  content: text('content'),\n  published: boolean('published').default(false),\n  createdAt: timestamp('created_at').defaultNow().notNull(),\n});\n\n// Type exports\nexport type Profile = typeof profiles.$inferSelect;\nexport type NewProfile = typeof profiles.$inferInsert;\nexport type Post = typeof posts.$inferSelect;\nexport type NewPost = typeof posts.$inferInsert;\n```\n\n---\n\n## Supabase Clients\n\n### src/lib/supabase/client.ts (Browser)\n```typescript\nimport { createBrowserClient } from '@supabase/ssr';\n\nexport function createClient() {\n  return createBrowserClient(\n    process.env.NEXT_PUBLIC_SUPABASE_URL!,\n    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!\n  );\n}\n```\n\n### src/lib/supabase/server.ts (Server Components/Actions)\n```typescript\nimport { createServerClient } from '@supabase/ssr';\nimport { cookies } from 'next/headers';\n\nexport async function createClient() {\n  const cookieStore = await cookies();\n\n  return createServerClient(\n    process.env.NEXT_PUBLIC_SUPABASE_URL!,\n    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,\n    {\n      cookies: {\n        getAll() {\n          return cookieStore.getAll();\n        },\n        setAll(cookiesToSet) {\n          try {\n            cookiesToSet.forEach(({ name, value, options }) =>\n              cookieStore.set(name, value, options)\n            );\n          } catch {\n            // Called from Server Component - ignore\n          }\n        },\n      },\n    }\n  );\n}\n```\n\n### src/lib/supabase/middleware.ts (For Middleware)\n```typescript\nimport { createServerClient } from '@supabase/ssr';\nimport { NextResponse, type NextRequest } from 'next/server';\n\nexport async function updateSession(request: NextRequest) {\n  let supabaseResponse = NextResponse.next({ request });\n\n  const supabase = createServerClient(\n    process.env.NEXT_PUBLIC_SUPABASE_URL!,\n    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,\n    {\n      cookies: {\n        getAll() {\n          return request.cookies.getAll();\n        },\n        setAll(cookiesToSet) {\n          cookiesToSet.forEach(({ name, value }) =>\n            request.cookies.set(name, value)\n          );\n          supabaseResponse = NextResponse.next({ request });\n          cookiesToSet.forEach(({ name, value, options }) =>\n            supabaseResponse.cookies.set(name, value, options)\n          );\n        },\n      },\n    }\n  );\n\n  // Refresh session\n  const { data: { user } } = await supabase.auth.getUser();\n\n  return { supabaseResponse, user };\n}\n```\n\n---\n\n## Middleware\n\n### src/middleware.ts\n```typescript\nimport { type NextRequest, NextResponse } from 'next/server';\nimport { updateSession } from '@/lib/supabase/middleware';\n\nconst publicRoutes = ['/', '/login', '/signup', '/auth/callback'];\n\nexport async function middleware(request: NextRequest) {\n  const { supabaseResponse, user } = await updateSession(request);\n\n  const isPublicRoute = publicRoutes.some(route =>\n    request.nextUrl.pathname.startsWith(route)\n  );\n\n  // Redirect unauthenticated users to login\n  if (!user && !isPublicRoute) {\n    const url = request.nextUrl.clone();\n    url.pathname = '/login';\n    url.searchParams.set('redirectTo', request.nextUrl.pathname);\n    return NextResponse.redirect(url);\n  }\n\n  // Redirect authenticated users away from auth pages\n  if (user && (request.nextUrl.pathname === '/login' || request.nextUrl.pathname === '/signup')) {\n    return NextResponse.redirect(new URL('/dashboard', request.url));\n  }\n\n  return supabaseResponse;\n}\n\nexport const config = {\n  matcher: [\n    '/((?!_next/static|_next/image|favicon.ico|.*\\\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',\n  ],\n};\n```\n\n---\n\n## Auth Helpers\n\n### src/lib/auth.ts\n```typescript\nimport { redirect } from 'next/navigation';\nimport { createClient } from '@/lib/supabase/server';\n\nexport async function getUser() {\n  const supabase = await createClient();\n  const { data: { user } } = await supabase.auth.getUser();\n  return user;\n}\n\nexport async function requireAuth() {\n  const user = await getUser();\n  if (!user) {\n    redirect('/login');\n  }\n  return user;\n}\n\nexport async function requireGuest() {\n  const user = await getUser();\n  if (user) {\n    redirect('/dashboard');\n  }\n}\n```\n\n---\n\n## Auth Pages\n\n### src/app/(auth)/login/page.tsx\n```typescript\nimport { requireGuest } from '@/lib/auth';\nimport { LoginForm } from '@/components/auth/login-form';\n\nexport default async function LoginPage() {\n  await requireGuest();\n\n  return (\n    <div className=\"flex min-h-screen items-center justify-center\">\n      <LoginForm />\n    </div>\n  );\n}\n```\n\n### src/components/auth/login-form.tsx\n```typescript\n'use client';\n\nimport { useState } from 'react';\nimport { useRouter } from 'next/navigation';\nimport { createClient } from '@/lib/supabase/client';\n\nexport function LoginForm() {\n  const [email, setEmail] = useState('');\n  const [password, setPassword] = useState('');\n  const [error, setError] = useState<string | null>(null);\n  const [loading, setLoading] = useState(false);\n  const router = useRouter();\n\n  const handleSubmit = async (e: React.FormEvent) => {\n    e.preventDefault();\n    setLoading(true);\n    setError(null);\n\n    const supabase = createClient();\n    const { error } = await supabase.auth.signInWithPassword({\n      email,\n      password,\n    });\n\n    if (error) {\n      setError(error.message);\n      setLoading(false);\n      return;\n    }\n\n    router.push('/dashboard');\n    router.refresh();\n  };\n\n  return (\n    <form onSubmit={handleSubmit} className=\"space-y-4 w-full max-w-sm\">\n      <div>\n        <label htmlFor=\"email\">Email</label>\n        <input\n          id=\"email\"\n          type=\"email\"\n          value={email}\n          onChange={(e) => setEmail(e.target.value)}\n          required\n        />\n      </div>\n      <div>\n        <label htmlFor=\"password\">Password</label>\n        <input\n          id=\"password\"\n          type=\"password\"\n          value={password}\n          onChange={(e) => setPassword(e.target.value)}\n          required\n        />\n      </div>\n      {error && <p className=\"text-red-500\">{error}</p>}\n      <button type=\"submit\" disabled={loading}>\n        {loading ? 'Signing in...' : 'Sign In'}\n      </button>\n    </form>\n  );\n}\n```\n\n### src/app/(auth)/callback/route.ts\n```typescript\nimport { createClient } from '@/lib/supabase/server';\nimport { NextResponse } from 'next/server';\n\nexport async function GET(request: Request) {\n  const { searchParams, origin } = new URL(request.url);\n  const code = searchParams.get('code');\n  const next = searchParams.get('next') ?? '/dashboard';\n\n  if (code) {\n    const supabase = await createClient();\n    const { error } = await supabase.auth.exchangeCodeForSession(code);\n\n    if (!error) {\n      return NextResponse.redirect(`${origin}${next}`);\n    }\n  }\n\n  return NextResponse.redirect(`${origin}/login?error=auth_error`);\n}\n```\n\n---\n\n## Server Actions\n\n### src/app/actions/posts.ts\n```typescript\n'use server';\n\nimport { revalidatePath } from 'next/cache';\nimport { redirect } from 'next/navigation';\nimport { db } from '@/db';\nimport { posts, NewPost } from '@/db/schema';\nimport { requireAuth } from '@/lib/auth';\nimport { eq } from 'drizzle-orm';\n\nexport async function createPost(formData: FormData) {\n  const user = await requireAuth();\n\n  const title = formData.get('title') as string;\n  const content = formData.get('content') as string;\n\n  const [post] = await db.insert(posts).values({\n    authorId: user.id,\n    title,\n    content,\n  }).returning();\n\n  revalidatePath('/dashboard');\n  redirect(`/posts/${post.id}`);\n}\n\nexport async function updatePost(id: string, formData: FormData) {\n  const user = await requireAuth();\n\n  const title = formData.get('title') as string;\n  const content = formData.get('content') as string;\n\n  await db.update(posts)\n    .set({ title, content })\n    .where(eq(posts.id, id));\n\n  revalidatePath(`/posts/${id}`);\n}\n\nexport async function deletePost(id: string) {\n  const user = await requireAuth();\n\n  await db.delete(posts).where(eq(posts.id, id));\n\n  revalidatePath('/dashboard');\n  redirect('/dashboard');\n}\n```\n\n---\n\n## Data Fetching\n\n### src/db/queries/posts.ts\n```typescript\nimport { db } from '@/db';\nimport { posts, profiles } from '@/db/schema';\nimport { eq, desc, and } from 'drizzle-orm';\n\nexport async function getPublishedPosts(limit = 10) {\n  return db\n    .select({\n      id: posts.id,\n      title: posts.title,\n      content: posts.content,\n      author: profiles.name,\n      createdAt: posts.createdAt,\n    })\n    .from(posts)\n    .innerJoin(profiles, eq(posts.authorId, profiles.id))\n    .where(eq(posts.published, true))\n    .orderBy(desc(posts.createdAt))\n    .limit(limit);\n}\n\nexport async function getUserPosts(userId: string) {\n  return db\n    .select()\n    .from(posts)\n    .where(eq(posts.authorId, userId))\n    .orderBy(desc(posts.createdAt));\n}\n\nexport async function getPostById(id: string) {\n  const [post] = await db\n    .select()\n    .from(posts)\n    .where(eq(posts.id, id))\n    .limit(1);\n\n  return post ?? null;\n}\n```\n\n### In Server Components\n```typescript\n// src/app/dashboard/page.tsx\nimport { requireAuth } from '@/lib/auth';\nimport { getUserPosts } from '@/db/queries/posts';\n\nexport default async function DashboardPage() {\n  const user = await requireAuth();\n  const posts = await getUserPosts(user.id);\n\n  return (\n    <div>\n      <h1>Your Posts</h1>\n      {posts.map((post) => (\n        <article key={post.id}>\n          <h2>{post.title}</h2>\n          <p>{post.content}</p>\n        </article>\n      ))}\n    </div>\n  );\n}\n```\n\n---\n\n## Storage\n\n### Upload Component\n```typescript\n'use client';\n\nimport { useState } from 'react';\nimport { createClient } from '@/lib/supabase/client';\n\nexport function AvatarUpload({ userId }: { userId: string }) {\n  const [uploading, setUploading] = useState(false);\n\n  const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {\n    const file = e.target.files?.[0];\n    if (!file) return;\n\n    setUploading(true);\n    const supabase = createClient();\n\n    const fileExt = file.name.split('.').pop();\n    const filePath = `${userId}/avatar.${fileExt}`;\n\n    const { error } = await supabase.storage\n      .from('avatars')\n      .upload(filePath, file, { upsert: true });\n\n    if (error) {\n      console.error('Upload error:', error);\n    }\n\n    setUploading(false);\n  };\n\n  return (\n    <input\n      type=\"file\"\n      accept=\"image/*\"\n      onChange={handleUpload}\n      disabled={uploading}\n    />\n  );\n}\n```\n\n### Get Public URL\n```typescript\nimport { createClient } from '@/lib/supabase/server';\n\nexport async function getAvatarUrl(userId: string) {\n  const supabase = await createClient();\n\n  const { data } = supabase.storage\n    .from('avatars')\n    .getPublicUrl(`${userId}/avatar.png`);\n\n  return data.publicUrl;\n}\n```\n\n---\n\n## Realtime\n\n### Client Component with Subscription\n```typescript\n'use client';\n\nimport { useEffect, useState } from 'react';\nimport { createClient } from '@/lib/supabase/client';\nimport { Post } from '@/db/schema';\n\nexport function RealtimePosts({ initialPosts }: { initialPosts: Post[] }) {\n  const [posts, setPosts] = useState(initialPosts);\n\n  useEffect(() => {\n    const supabase = createClient();\n\n    const channel = supabase\n      .channel('posts')\n      .on(\n        'postgres_changes',\n        { event: '*', schema: 'public', table: 'posts' },\n        (payload) => {\n          if (payload.eventType === 'INSERT') {\n            setPosts((prev) => [payload.new as Post, ...prev]);\n          } else if (payload.eventType === 'DELETE') {\n            setPosts((prev) => prev.filter((p) => p.id !== payload.old.id));\n          } else if (payload.eventType === 'UPDATE') {\n            setPosts((prev) =>\n              prev.map((p) => (p.id === payload.new.id ? payload.new as Post : p))\n            );\n          }\n        }\n      )\n      .subscribe();\n\n    return () => {\n      supabase.removeChannel(channel);\n    };\n  }, []);\n\n  return (\n    <ul>\n      {posts.map((post) => (\n        <li key={post.id}>{post.title}</li>\n      ))}\n    </ul>\n  );\n}\n```\n\n---\n\n## OAuth Providers\n\n### src/components/auth/oauth-buttons.tsx\n```typescript\n'use client';\n\nimport { createClient } from '@/lib/supabase/client';\n\nexport function OAuthButtons() {\n  const handleOAuth = async (provider: 'google' | 'github') => {\n    const supabase = createClient();\n\n    await supabase.auth.signInWithOAuth({\n      provider,\n      options: {\n        redirectTo: `${window.location.origin}/auth/callback`,\n      },\n    });\n  };\n\n  return (\n    <div className=\"space-y-2\">\n      <button onClick={() => handleOAuth('google')}>\n        Continue with Google\n      </button>\n      <button onClick={() => handleOAuth('github')}>\n        Continue with GitHub\n      </button>\n    </div>\n  );\n}\n```\n\n---\n\n## Sign Out\n\n### Server Action\n```typescript\n// src/app/actions/auth.ts\n'use server';\n\nimport { redirect } from 'next/navigation';\nimport { createClient } from '@/lib/supabase/server';\n\nexport async function signOut() {\n  const supabase = await createClient();\n  await supabase.auth.signOut();\n  redirect('/login');\n}\n```\n\n### Sign Out Button\n```typescript\n'use client';\n\nimport { signOut } from '@/app/actions/auth';\n\nexport function SignOutButton() {\n  return (\n    <form action={signOut}>\n      <button type=\"submit\">Sign Out</button>\n    </form>\n  );\n}\n```\n\n---\n\n## Anti-Patterns\n\n- **Using Supabase client for DB queries** - Use Drizzle for type-safety\n- **Fetching in client components** - Prefer server components\n- **Not using middleware for auth** - Session refresh is critical\n- **Calling `cookies()` synchronously** - Must await in Next.js 15+\n- **Service key in client** - Never expose, server-only\n- **Missing revalidatePath** - Always revalidate after mutations\n- **Not handling auth errors** - Show user-friendly messages\n"
  },
  {
    "path": "skills/supabase-node/SKILL.md",
    "content": "---\nname: supabase-node\ndescription: Express/Hono with Supabase and Drizzle ORM\nwhen-to-use: When building a Node.js backend with Supabase\nuser-invocable: false\npaths: [\"src/api/**\", \"src/routes/**\", \"supabase/**\"]\neffort: medium\n---\n\n# Supabase + Node.js Skill\n\n\nExpress/Hono patterns with Supabase Auth and Drizzle ORM.\n\n**Sources:** [Supabase JS Client](https://supabase.com/docs/reference/javascript/introduction) | [Drizzle ORM](https://orm.drizzle.team/)\n\n---\n\n## Core Principle\n\n**Drizzle for queries, Supabase for auth/storage, middleware for validation.**\n\nUse Drizzle ORM for type-safe database access. Use Supabase client for auth verification, storage, and realtime. Express or Hono for the API layer.\n\n---\n\n## Project Structure\n\n```\nproject/\n├── src/\n│   ├── routes/\n│   │   ├── index.ts             # Route aggregator\n│   │   ├── auth.ts\n│   │   ├── posts.ts\n│   │   └── users.ts\n│   ├── middleware/\n│   │   ├── auth.ts              # JWT validation\n│   │   ├── error.ts             # Error handler\n│   │   └── validate.ts          # Request validation\n│   ├── db/\n│   │   ├── index.ts             # Drizzle client\n│   │   ├── schema.ts            # Schema definitions\n│   │   └── queries/             # Query functions\n│   ├── lib/\n│   │   ├── supabase.ts          # Supabase client\n│   │   └── config.ts            # Environment config\n│   ├── types/\n│   │   └── express.d.ts         # Express type extensions\n│   └── index.ts                 # App entry point\n├── supabase/\n│   ├── migrations/\n│   └── config.toml\n├── drizzle.config.ts\n├── package.json\n├── tsconfig.json\n└── .env\n```\n\n---\n\n## Setup\n\n### Install Dependencies\n```bash\nnpm install express cors helmet dotenv @supabase/supabase-js drizzle-orm postgres zod\nnpm install -D typescript @types/express @types/cors @types/node tsx drizzle-kit\n```\n\n### package.json Scripts\n```json\n{\n  \"scripts\": {\n    \"dev\": \"tsx watch src/index.ts\",\n    \"build\": \"tsc\",\n    \"start\": \"node dist/index.js\",\n    \"db:generate\": \"drizzle-kit generate\",\n    \"db:push\": \"drizzle-kit push\",\n    \"db:studio\": \"drizzle-kit studio\"\n  }\n}\n```\n\n### Environment Variables\n```bash\n# .env\nPORT=3000\nNODE_ENV=development\n\n# Supabase\nSUPABASE_URL=http://localhost:54321\nSUPABASE_ANON_KEY=<from supabase start>\nSUPABASE_SERVICE_ROLE_KEY=<from supabase start>\n\n# Database\nDATABASE_URL=postgresql://postgres:postgres@localhost:54322/postgres\n```\n\n---\n\n## Configuration\n\n### src/lib/config.ts\n```typescript\nimport { z } from 'zod';\nimport dotenv from 'dotenv';\n\ndotenv.config();\n\nconst envSchema = z.object({\n  PORT: z.string().default('3000'),\n  NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),\n  SUPABASE_URL: z.string().url(),\n  SUPABASE_ANON_KEY: z.string(),\n  SUPABASE_SERVICE_ROLE_KEY: z.string(),\n  DATABASE_URL: z.string(),\n});\n\nexport const config = envSchema.parse(process.env);\n```\n\n---\n\n## Database Setup\n\n### drizzle.config.ts\n```typescript\nimport { defineConfig } from 'drizzle-kit';\nimport { config } from './src/lib/config';\n\nexport default defineConfig({\n  schema: './src/db/schema.ts',\n  out: './supabase/migrations',\n  dialect: 'postgresql',\n  dbCredentials: {\n    url: config.DATABASE_URL,\n  },\n  schemaFilter: ['public'],\n});\n```\n\n### src/db/index.ts\n```typescript\nimport { drizzle } from 'drizzle-orm/postgres-js';\nimport postgres from 'postgres';\nimport * as schema from './schema';\nimport { config } from '../lib/config';\n\nconst client = postgres(config.DATABASE_URL, {\n  prepare: false, // Required for Supabase pooling\n});\n\nexport const db = drizzle(client, { schema });\n```\n\n### src/db/schema.ts\n```typescript\nimport {\n  pgTable,\n  uuid,\n  text,\n  timestamp,\n  boolean,\n} from 'drizzle-orm/pg-core';\n\nexport const profiles = pgTable('profiles', {\n  id: uuid('id').primaryKey(),\n  email: text('email').notNull(),\n  name: text('name'),\n  avatarUrl: text('avatar_url'),\n  createdAt: timestamp('created_at').defaultNow().notNull(),\n  updatedAt: timestamp('updated_at').defaultNow().notNull(),\n});\n\nexport const posts = pgTable('posts', {\n  id: uuid('id').primaryKey().defaultRandom(),\n  authorId: uuid('author_id').references(() => profiles.id).notNull(),\n  title: text('title').notNull(),\n  content: text('content'),\n  published: boolean('published').default(false),\n  createdAt: timestamp('created_at').defaultNow().notNull(),\n});\n\n// Type exports\nexport type Profile = typeof profiles.$inferSelect;\nexport type NewProfile = typeof profiles.$inferInsert;\nexport type Post = typeof posts.$inferSelect;\nexport type NewPost = typeof posts.$inferInsert;\n```\n\n---\n\n## Supabase Client\n\n### src/lib/supabase.ts\n```typescript\nimport { createClient, SupabaseClient, User } from '@supabase/supabase-js';\nimport { config } from './config';\n\n// Client with anon key (respects RLS)\nexport const supabase = createClient(\n  config.SUPABASE_URL,\n  config.SUPABASE_ANON_KEY\n);\n\n// Admin client (bypasses RLS)\nexport const supabaseAdmin = createClient(\n  config.SUPABASE_URL,\n  config.SUPABASE_SERVICE_ROLE_KEY,\n  {\n    auth: {\n      autoRefreshToken: false,\n      persistSession: false,\n    },\n  }\n);\n\n// Verify JWT and get user\nexport async function verifyToken(token: string): Promise<User | null> {\n  const { data: { user }, error } = await supabase.auth.getUser(token);\n\n  if (error || !user) {\n    return null;\n  }\n\n  return user;\n}\n```\n\n---\n\n## Type Extensions\n\n### src/types/express.d.ts\n```typescript\nimport { User } from '@supabase/supabase-js';\n\ndeclare global {\n  namespace Express {\n    interface Request {\n      user?: User;\n    }\n  }\n}\n\nexport {};\n```\n\n---\n\n## Middleware\n\n### src/middleware/auth.ts\n```typescript\nimport { Request, Response, NextFunction } from 'express';\nimport { verifyToken } from '../lib/supabase';\n\nexport async function requireAuth(\n  req: Request,\n  res: Response,\n  next: NextFunction\n) {\n  const authHeader = req.headers.authorization;\n\n  if (!authHeader?.startsWith('Bearer ')) {\n    return res.status(401).json({ error: 'Missing authorization header' });\n  }\n\n  const token = authHeader.split(' ')[1];\n  const user = await verifyToken(token);\n\n  if (!user) {\n    return res.status(401).json({ error: 'Invalid token' });\n  }\n\n  req.user = user;\n  next();\n}\n\n// Optional auth - continues even without token\nexport async function optionalAuth(\n  req: Request,\n  res: Response,\n  next: NextFunction\n) {\n  const authHeader = req.headers.authorization;\n\n  if (authHeader?.startsWith('Bearer ')) {\n    const token = authHeader.split(' ')[1];\n    req.user = await verifyToken(token) ?? undefined;\n  }\n\n  next();\n}\n```\n\n### src/middleware/error.ts\n```typescript\nimport { Request, Response, NextFunction } from 'express';\n\nexport class AppError extends Error {\n  constructor(\n    public statusCode: number,\n    message: string\n  ) {\n    super(message);\n    this.name = 'AppError';\n  }\n}\n\nexport function errorHandler(\n  err: Error,\n  req: Request,\n  res: Response,\n  next: NextFunction\n) {\n  console.error(err);\n\n  if (err instanceof AppError) {\n    return res.status(err.statusCode).json({ error: err.message });\n  }\n\n  return res.status(500).json({ error: 'Internal server error' });\n}\n```\n\n### src/middleware/validate.ts\n```typescript\nimport { Request, Response, NextFunction } from 'express';\nimport { z, ZodSchema } from 'zod';\n\nexport function validate<T extends ZodSchema>(schema: T) {\n  return (req: Request, res: Response, next: NextFunction) => {\n    try {\n      req.body = schema.parse(req.body);\n      next();\n    } catch (error) {\n      if (error instanceof z.ZodError) {\n        return res.status(400).json({\n          error: 'Validation failed',\n          details: error.errors,\n        });\n      }\n      next(error);\n    }\n  };\n}\n```\n\n---\n\n## Routes\n\n### src/routes/auth.ts\n```typescript\nimport { Router } from 'express';\nimport { z } from 'zod';\nimport { supabase } from '../lib/supabase';\nimport { validate } from '../middleware/validate';\n\nconst router = Router();\n\nconst signUpSchema = z.object({\n  email: z.string().email(),\n  password: z.string().min(8),\n});\n\nconst signInSchema = z.object({\n  email: z.string().email(),\n  password: z.string(),\n});\n\nrouter.post('/signup', validate(signUpSchema), async (req, res, next) => {\n  try {\n    const { email, password } = req.body;\n\n    const { data, error } = await supabase.auth.signUp({\n      email,\n      password,\n    });\n\n    if (error) {\n      return res.status(400).json({ error: error.message });\n    }\n\n    return res.status(201).json({\n      user: data.user,\n      session: data.session,\n    });\n  } catch (error) {\n    next(error);\n  }\n});\n\nrouter.post('/signin', validate(signInSchema), async (req, res, next) => {\n  try {\n    const { email, password } = req.body;\n\n    const { data, error } = await supabase.auth.signInWithPassword({\n      email,\n      password,\n    });\n\n    if (error) {\n      return res.status(401).json({ error: 'Invalid credentials' });\n    }\n\n    return res.json({\n      user: data.user,\n      session: data.session,\n    });\n  } catch (error) {\n    next(error);\n  }\n});\n\nrouter.post('/signout', async (req, res) => {\n  await supabase.auth.signOut();\n  return res.json({ message: 'Signed out' });\n});\n\nrouter.post('/refresh', async (req, res, next) => {\n  try {\n    const { refresh_token } = req.body;\n\n    const { data, error } = await supabase.auth.refreshSession({\n      refresh_token,\n    });\n\n    if (error) {\n      return res.status(401).json({ error: 'Invalid refresh token' });\n    }\n\n    return res.json({\n      session: data.session,\n    });\n  } catch (error) {\n    next(error);\n  }\n});\n\nexport default router;\n```\n\n### src/routes/posts.ts\n```typescript\nimport { Router } from 'express';\nimport { z } from 'zod';\nimport { eq, desc } from 'drizzle-orm';\nimport { db } from '../db';\nimport { posts, Post } from '../db/schema';\nimport { requireAuth, optionalAuth } from '../middleware/auth';\nimport { validate } from '../middleware/validate';\nimport { AppError } from '../middleware/error';\n\nconst router = Router();\n\nconst createPostSchema = z.object({\n  title: z.string().min(1).max(200),\n  content: z.string().optional(),\n  published: z.boolean().default(false),\n});\n\nconst updatePostSchema = createPostSchema.partial();\n\n// List all published posts\nrouter.get('/', optionalAuth, async (req, res, next) => {\n  try {\n    const result = await db\n      .select()\n      .from(posts)\n      .where(eq(posts.published, true))\n      .orderBy(desc(posts.createdAt));\n\n    return res.json(result);\n  } catch (error) {\n    next(error);\n  }\n});\n\n// List user's posts\nrouter.get('/me', requireAuth, async (req, res, next) => {\n  try {\n    const result = await db\n      .select()\n      .from(posts)\n      .where(eq(posts.authorId, req.user!.id))\n      .orderBy(desc(posts.createdAt));\n\n    return res.json(result);\n  } catch (error) {\n    next(error);\n  }\n});\n\n// Get single post\nrouter.get('/:id', async (req, res, next) => {\n  try {\n    const [post] = await db\n      .select()\n      .from(posts)\n      .where(eq(posts.id, req.params.id))\n      .limit(1);\n\n    if (!post) {\n      throw new AppError(404, 'Post not found');\n    }\n\n    return res.json(post);\n  } catch (error) {\n    next(error);\n  }\n});\n\n// Create post\nrouter.post('/', requireAuth, validate(createPostSchema), async (req, res, next) => {\n  try {\n    const [post] = await db\n      .insert(posts)\n      .values({\n        ...req.body,\n        authorId: req.user!.id,\n      })\n      .returning();\n\n    return res.status(201).json(post);\n  } catch (error) {\n    next(error);\n  }\n});\n\n// Update post\nrouter.patch('/:id', requireAuth, validate(updatePostSchema), async (req, res, next) => {\n  try {\n    const [post] = await db\n      .update(posts)\n      .set(req.body)\n      .where(eq(posts.id, req.params.id))\n      .returning();\n\n    if (!post) {\n      throw new AppError(404, 'Post not found');\n    }\n\n    return res.json(post);\n  } catch (error) {\n    next(error);\n  }\n});\n\n// Delete post\nrouter.delete('/:id', requireAuth, async (req, res, next) => {\n  try {\n    const [post] = await db\n      .delete(posts)\n      .where(eq(posts.id, req.params.id))\n      .returning();\n\n    if (!post) {\n      throw new AppError(404, 'Post not found');\n    }\n\n    return res.status(204).send();\n  } catch (error) {\n    next(error);\n  }\n});\n\nexport default router;\n```\n\n### src/routes/index.ts\n```typescript\nimport { Router } from 'express';\nimport authRoutes from './auth';\nimport postRoutes from './posts';\n\nconst router = Router();\n\nrouter.use('/auth', authRoutes);\nrouter.use('/posts', postRoutes);\n\nexport default router;\n```\n\n---\n\n## Main Application\n\n### src/index.ts\n```typescript\nimport express from 'express';\nimport cors from 'cors';\nimport helmet from 'helmet';\nimport routes from './routes';\nimport { errorHandler } from './middleware/error';\nimport { config } from './lib/config';\n\nconst app = express();\n\n// Security middleware\napp.use(helmet());\napp.use(cors());\napp.use(express.json());\n\n// Health check\napp.get('/health', (req, res) => {\n  res.json({ status: 'healthy' });\n});\n\n// API routes\napp.use('/api', routes);\n\n// Error handler (must be last)\napp.use(errorHandler);\n\napp.listen(config.PORT, () => {\n  console.log(`Server running on port ${config.PORT}`);\n});\n\nexport default app;\n```\n\n---\n\n## Query Functions\n\n### src/db/queries/posts.ts\n```typescript\nimport { db } from '../index';\nimport { posts, profiles } from '../schema';\nimport { eq, desc, and } from 'drizzle-orm';\n\nexport async function getPublishedPosts(limit = 10) {\n  return db\n    .select({\n      id: posts.id,\n      title: posts.title,\n      content: posts.content,\n      author: profiles.name,\n      createdAt: posts.createdAt,\n    })\n    .from(posts)\n    .innerJoin(profiles, eq(posts.authorId, profiles.id))\n    .where(eq(posts.published, true))\n    .orderBy(desc(posts.createdAt))\n    .limit(limit);\n}\n\nexport async function getUserPosts(userId: string) {\n  return db\n    .select()\n    .from(posts)\n    .where(eq(posts.authorId, userId))\n    .orderBy(desc(posts.createdAt));\n}\n\nexport async function getPostById(id: string) {\n  const [post] = await db\n    .select()\n    .from(posts)\n    .where(eq(posts.id, id))\n    .limit(1);\n\n  return post ?? null;\n}\n\nexport async function createPost(data: {\n  title: string;\n  content?: string;\n  authorId: string;\n  published?: boolean;\n}) {\n  const [post] = await db.insert(posts).values(data).returning();\n  return post;\n}\n```\n\n---\n\n## Storage\n\n### Upload Endpoint\n```typescript\nimport multer from 'multer';\nimport { supabase } from '../lib/supabase';\n\nconst upload = multer({ storage: multer.memoryStorage() });\n\nrouter.post(\n  '/avatar',\n  requireAuth,\n  upload.single('file'),\n  async (req, res, next) => {\n    try {\n      if (!req.file) {\n        throw new AppError(400, 'No file uploaded');\n      }\n\n      const fileExt = req.file.originalname.split('.').pop();\n      const filePath = `${req.user!.id}/avatar.${fileExt}`;\n\n      const { error } = await supabase.storage\n        .from('avatars')\n        .upload(filePath, req.file.buffer, {\n          contentType: req.file.mimetype,\n          upsert: true,\n        });\n\n      if (error) {\n        throw new AppError(500, 'Upload failed');\n      }\n\n      const { data } = supabase.storage\n        .from('avatars')\n        .getPublicUrl(filePath);\n\n      return res.json({ url: data.publicUrl });\n    } catch (error) {\n      next(error);\n    }\n  }\n);\n```\n\n---\n\n## Hono Alternative\n\nFor edge deployments or lighter weight:\n\n### src/index.ts (Hono)\n```typescript\nimport { Hono } from 'hono';\nimport { cors } from 'hono/cors';\nimport { jwt } from 'hono/jwt';\nimport { db } from './db';\nimport { posts } from './db/schema';\nimport { eq, desc } from 'drizzle-orm';\nimport { config } from './lib/config';\n\nconst app = new Hono();\n\napp.use('/*', cors());\n\n// Public routes\napp.get('/posts', async (c) => {\n  const result = await db\n    .select()\n    .from(posts)\n    .where(eq(posts.published, true))\n    .orderBy(desc(posts.createdAt));\n\n  return c.json(result);\n});\n\n// Protected routes\napp.use('/api/*', async (c, next) => {\n  const auth = c.req.header('Authorization');\n  if (!auth?.startsWith('Bearer ')) {\n    return c.json({ error: 'Unauthorized' }, 401);\n  }\n  // Verify with Supabase...\n  await next();\n});\n\napp.post('/api/posts', async (c) => {\n  const body = await c.req.json();\n  const [post] = await db.insert(posts).values(body).returning();\n  return c.json(post, 201);\n});\n\nexport default app;\n```\n\n---\n\n## Testing\n\n### tests/setup.ts\n```typescript\nimport { beforeAll, afterAll, beforeEach } from 'vitest';\nimport { db } from '../src/db';\nimport { posts, profiles } from '../src/db/schema';\n\nbeforeAll(async () => {\n  // Setup test database\n});\n\nbeforeEach(async () => {\n  // Clean tables\n  await db.delete(posts);\n  await db.delete(profiles);\n});\n\nafterAll(async () => {\n  // Cleanup\n});\n```\n\n### tests/posts.test.ts\n```typescript\nimport { describe, it, expect } from 'vitest';\nimport request from 'supertest';\nimport app from '../src/index';\n\ndescribe('Posts API', () => {\n  it('should list published posts', async () => {\n    const res = await request(app)\n      .get('/api/posts')\n      .expect(200);\n\n    expect(Array.isArray(res.body)).toBe(true);\n  });\n\n  it('should require auth to create post', async () => {\n    await request(app)\n      .post('/api/posts')\n      .send({ title: 'Test' })\n      .expect(401);\n  });\n});\n```\n\n---\n\n## Anti-Patterns\n\n- **Using Supabase client for DB queries** - Use Drizzle\n- **Sync JWT validation** - Keep it async\n- **No input validation** - Use Zod middleware\n- **Missing error handling** - Use centralized error handler\n- **Hardcoded secrets** - Use environment variables\n- **No request logging** - Add morgan or pino\n- **Blocking the event loop** - Use async throughout\n- **Service key in responses** - Never expose\n"
  },
  {
    "path": "skills/supabase-python/SKILL.md",
    "content": "---\nname: supabase-python\ndescription: FastAPI with Supabase and SQLAlchemy/SQLModel\nwhen-to-use: When building a Python/FastAPI app with Supabase backend\nuser-invocable: false\npaths: [\"**/*.py\", \"supabase/**\"]\neffort: medium\n---\n\n# Supabase + Python Skill\n\n\nFastAPI patterns with Supabase Auth and SQLAlchemy/SQLModel for database access.\n\n**Sources:** [Supabase Python Client](https://supabase.com/docs/reference/python/introduction) | [SQLModel](https://sqlmodel.tiangolo.com/)\n\n---\n\n## Core Principle\n\n**SQLAlchemy/SQLModel for queries, Supabase for auth/storage.**\n\nUse SQLAlchemy or SQLModel for type-safe database access. Use supabase-py for auth, storage, and realtime. FastAPI for the API layer.\n\n---\n\n## Project Structure\n\n```\nproject/\n├── src/\n│   ├── api/\n│   │   ├── __init__.py\n│   │   ├── routes/\n│   │   │   ├── __init__.py\n│   │   │   ├── auth.py\n│   │   │   ├── posts.py\n│   │   │   └── users.py\n│   │   └── deps.py              # Dependencies (auth, db)\n│   ├── core/\n│   │   ├── __init__.py\n│   │   ├── config.py            # Settings\n│   │   └── security.py          # Auth helpers\n│   ├── db/\n│   │   ├── __init__.py\n│   │   ├── session.py           # Database session\n│   │   └── models.py            # SQLModel models\n│   ├── services/\n│   │   ├── __init__.py\n│   │   └── supabase.py          # Supabase client\n│   └── main.py                  # FastAPI app\n├── supabase/\n│   ├── migrations/\n│   └── config.toml\n├── alembic/                     # Alembic migrations (alternative)\n├── alembic.ini\n├── pyproject.toml\n└── .env\n```\n\n---\n\n## Setup\n\n### Install Dependencies\n```bash\npip install fastapi uvicorn supabase python-dotenv sqlmodel asyncpg alembic\n```\n\n### pyproject.toml\n```toml\n[project]\nname = \"my-app\"\nversion = \"0.1.0\"\ndependencies = [\n    \"fastapi>=0.109.0\",\n    \"uvicorn[standard]>=0.27.0\",\n    \"supabase>=2.0.0\",\n    \"python-dotenv>=1.0.0\",\n    \"sqlmodel>=0.0.14\",\n    \"asyncpg>=0.29.0\",\n    \"alembic>=1.13.0\",\n    \"pydantic-settings>=2.0.0\",\n]\n\n[project.optional-dependencies]\ndev = [\n    \"pytest>=7.0.0\",\n    \"pytest-asyncio>=0.23.0\",\n    \"httpx>=0.26.0\",\n]\n```\n\n### Environment Variables\n```bash\n# .env\nSUPABASE_URL=http://localhost:54321\nSUPABASE_ANON_KEY=<from supabase start>\nSUPABASE_SERVICE_ROLE_KEY=<from supabase start>\nDATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:54322/postgres\n```\n\n---\n\n## Configuration\n\n### src/core/config.py\n```python\nfrom pydantic_settings import BaseSettings\nfrom functools import lru_cache\n\n\nclass Settings(BaseSettings):\n    # Supabase\n    supabase_url: str\n    supabase_anon_key: str\n    supabase_service_role_key: str\n\n    # Database\n    database_url: str\n\n    # App\n    debug: bool = False\n\n    class Config:\n        env_file = \".env\"\n        env_file_encoding = \"utf-8\"\n\n\n@lru_cache\ndef get_settings() -> Settings:\n    return Settings()\n```\n\n---\n\n## Database Setup\n\n### src/db/session.py\n```python\nfrom sqlalchemy.ext.asyncio import create_async_engine, AsyncSession\nfrom sqlalchemy.orm import sessionmaker\nfrom src.core.config import get_settings\n\nsettings = get_settings()\n\nengine = create_async_engine(\n    settings.database_url,\n    echo=settings.debug,\n    pool_pre_ping=True,\n)\n\nAsyncSessionLocal = sessionmaker(\n    engine,\n    class_=AsyncSession,\n    expire_on_commit=False,\n)\n\n\nasync def get_db() -> AsyncSession:\n    async with AsyncSessionLocal() as session:\n        try:\n            yield session\n        finally:\n            await session.close()\n```\n\n### src/db/models.py\n```python\nfrom datetime import datetime\nfrom typing import Optional\nfrom uuid import UUID, uuid4\nfrom sqlmodel import SQLModel, Field\n\n\nclass ProfileBase(SQLModel):\n    email: str\n    name: Optional[str] = None\n    avatar_url: Optional[str] = None\n\n\nclass Profile(ProfileBase, table=True):\n    __tablename__ = \"profiles\"\n\n    id: UUID = Field(primary_key=True)  # References auth.users\n    created_at: datetime = Field(default_factory=datetime.utcnow)\n    updated_at: datetime = Field(default_factory=datetime.utcnow)\n\n\nclass ProfileCreate(ProfileBase):\n    id: UUID\n\n\nclass ProfileRead(ProfileBase):\n    id: UUID\n    created_at: datetime\n\n\nclass PostBase(SQLModel):\n    title: str\n    content: Optional[str] = None\n    published: bool = False\n\n\nclass Post(PostBase, table=True):\n    __tablename__ = \"posts\"\n\n    id: UUID = Field(default_factory=uuid4, primary_key=True)\n    author_id: UUID = Field(foreign_key=\"profiles.id\")\n    created_at: datetime = Field(default_factory=datetime.utcnow)\n\n\nclass PostCreate(PostBase):\n    pass\n\n\nclass PostRead(PostBase):\n    id: UUID\n    author_id: UUID\n    created_at: datetime\n```\n\n---\n\n## Supabase Client\n\n### src/services/supabase.py\n```python\nfrom supabase import create_client, Client\nfrom src.core.config import get_settings\n\nsettings = get_settings()\n\n\ndef get_supabase_client() -> Client:\n    \"\"\"Get Supabase client with anon key (respects RLS).\"\"\"\n    return create_client(\n        settings.supabase_url,\n        settings.supabase_anon_key\n    )\n\n\ndef get_supabase_admin() -> Client:\n    \"\"\"Get Supabase client with service role (bypasses RLS).\"\"\"\n    return create_client(\n        settings.supabase_url,\n        settings.supabase_service_role_key\n    )\n```\n\n---\n\n## Auth Dependencies\n\n### src/api/deps.py\n```python\nfrom typing import Annotated\nfrom fastapi import Depends, HTTPException, status\nfrom fastapi.security import HTTPBearer, HTTPAuthorizationCredentials\nfrom sqlalchemy.ext.asyncio import AsyncSession\nfrom supabase import Client\n\nfrom src.db.session import get_db\nfrom src.services.supabase import get_supabase_client\n\nsecurity = HTTPBearer()\n\n\nasync def get_current_user(\n    credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)],\n) -> dict:\n    \"\"\"Validate JWT and return user.\"\"\"\n    supabase = get_supabase_client()\n\n    try:\n        # Verify token with Supabase\n        user = supabase.auth.get_user(credentials.credentials)\n        if not user or not user.user:\n            raise HTTPException(\n                status_code=status.HTTP_401_UNAUTHORIZED,\n                detail=\"Invalid token\",\n            )\n        return user.user\n    except Exception as e:\n        raise HTTPException(\n            status_code=status.HTTP_401_UNAUTHORIZED,\n            detail=\"Invalid token\",\n        )\n\n\n# Type alias for dependency injection\nCurrentUser = Annotated[dict, Depends(get_current_user)]\nDbSession = Annotated[AsyncSession, Depends(get_db)]\n```\n\n---\n\n## API Routes\n\n### src/api/routes/auth.py\n```python\nfrom fastapi import APIRouter, HTTPException, status\nfrom pydantic import BaseModel, EmailStr\n\nfrom src.services.supabase import get_supabase_client\n\nrouter = APIRouter(prefix=\"/auth\", tags=[\"auth\"])\n\n\nclass SignUpRequest(BaseModel):\n    email: EmailStr\n    password: str\n\n\nclass SignInRequest(BaseModel):\n    email: EmailStr\n    password: str\n\n\nclass AuthResponse(BaseModel):\n    access_token: str\n    refresh_token: str\n    user_id: str\n\n\n@router.post(\"/signup\", response_model=AuthResponse)\nasync def sign_up(request: SignUpRequest):\n    supabase = get_supabase_client()\n\n    try:\n        response = supabase.auth.sign_up({\n            \"email\": request.email,\n            \"password\": request.password,\n        })\n\n        if response.user is None:\n            raise HTTPException(\n                status_code=status.HTTP_400_BAD_REQUEST,\n                detail=\"Signup failed\",\n            )\n\n        return AuthResponse(\n            access_token=response.session.access_token,\n            refresh_token=response.session.refresh_token,\n            user_id=str(response.user.id),\n        )\n    except Exception as e:\n        raise HTTPException(\n            status_code=status.HTTP_400_BAD_REQUEST,\n            detail=str(e),\n        )\n\n\n@router.post(\"/signin\", response_model=AuthResponse)\nasync def sign_in(request: SignInRequest):\n    supabase = get_supabase_client()\n\n    try:\n        response = supabase.auth.sign_in_with_password({\n            \"email\": request.email,\n            \"password\": request.password,\n        })\n\n        return AuthResponse(\n            access_token=response.session.access_token,\n            refresh_token=response.session.refresh_token,\n            user_id=str(response.user.id),\n        )\n    except Exception as e:\n        raise HTTPException(\n            status_code=status.HTTP_401_UNAUTHORIZED,\n            detail=\"Invalid credentials\",\n        )\n\n\n@router.post(\"/signout\")\nasync def sign_out():\n    supabase = get_supabase_client()\n    supabase.auth.sign_out()\n    return {\"message\": \"Signed out\"}\n```\n\n### src/api/routes/posts.py\n```python\nfrom uuid import UUID\nfrom fastapi import APIRouter, HTTPException, status\nfrom sqlmodel import select\n\nfrom src.api.deps import CurrentUser, DbSession\nfrom src.db.models import Post, PostCreate, PostRead\n\nrouter = APIRouter(prefix=\"/posts\", tags=[\"posts\"])\n\n\n@router.get(\"/\", response_model=list[PostRead])\nasync def list_posts(\n    db: DbSession,\n    published_only: bool = True,\n):\n    query = select(Post)\n    if published_only:\n        query = query.where(Post.published == True)\n    query = query.order_by(Post.created_at.desc())\n\n    result = await db.execute(query)\n    return result.scalars().all()\n\n\n@router.get(\"/me\", response_model=list[PostRead])\nasync def list_my_posts(\n    db: DbSession,\n    user: CurrentUser,\n):\n    query = select(Post).where(Post.author_id == UUID(user.id))\n    result = await db.execute(query)\n    return result.scalars().all()\n\n\n@router.post(\"/\", response_model=PostRead, status_code=status.HTTP_201_CREATED)\nasync def create_post(\n    db: DbSession,\n    user: CurrentUser,\n    post_in: PostCreate,\n):\n    post = Post(\n        **post_in.model_dump(),\n        author_id=UUID(user.id),\n    )\n    db.add(post)\n    await db.commit()\n    await db.refresh(post)\n    return post\n\n\n@router.get(\"/{post_id}\", response_model=PostRead)\nasync def get_post(\n    db: DbSession,\n    post_id: UUID,\n):\n    result = await db.execute(select(Post).where(Post.id == post_id))\n    post = result.scalar_one_or_none()\n\n    if not post:\n        raise HTTPException(\n            status_code=status.HTTP_404_NOT_FOUND,\n            detail=\"Post not found\",\n        )\n\n    return post\n\n\n@router.delete(\"/{post_id}\", status_code=status.HTTP_204_NO_CONTENT)\nasync def delete_post(\n    db: DbSession,\n    user: CurrentUser,\n    post_id: UUID,\n):\n    result = await db.execute(\n        select(Post).where(Post.id == post_id, Post.author_id == UUID(user.id))\n    )\n    post = result.scalar_one_or_none()\n\n    if not post:\n        raise HTTPException(\n            status_code=status.HTTP_404_NOT_FOUND,\n            detail=\"Post not found\",\n        )\n\n    await db.delete(post)\n    await db.commit()\n```\n\n---\n\n## Main Application\n\n### src/main.py\n```python\nfrom fastapi import FastAPI\nfrom fastapi.middleware.cors import CORSMiddleware\n\nfrom src.api.routes import auth, posts\n\napp = FastAPI(title=\"My API\")\n\n# CORS\napp.add_middleware(\n    CORSMiddleware,\n    allow_origins=[\"*\"],  # Configure for production\n    allow_credentials=True,\n    allow_methods=[\"*\"],\n    allow_headers=[\"*\"],\n)\n\n# Routes\napp.include_router(auth.router, prefix=\"/api\")\napp.include_router(posts.router, prefix=\"/api\")\n\n\n@app.get(\"/health\")\nasync def health_check():\n    return {\"status\": \"healthy\"}\n```\n\n---\n\n## Alembic Migrations\n\n### Initialize Alembic\n```bash\nalembic init alembic\n```\n\n### alembic/env.py (key changes)\n```python\nfrom src.db.models import SQLModel\nfrom src.core.config import get_settings\n\nsettings = get_settings()\n\n# Use async engine\nconfig.set_main_option(\"sqlalchemy.url\", settings.database_url)\n\ntarget_metadata = SQLModel.metadata\n\n\ndef run_migrations_online():\n    # For async\n    import asyncio\n    from sqlalchemy.ext.asyncio import create_async_engine\n\n    connectable = create_async_engine(settings.database_url)\n\n    async def do_run_migrations():\n        async with connectable.connect() as connection:\n            await connection.run_sync(do_run_migrations_sync)\n\n    def do_run_migrations_sync(connection):\n        context.configure(\n            connection=connection,\n            target_metadata=target_metadata,\n        )\n        with context.begin_transaction():\n            context.run_migrations()\n\n    asyncio.run(do_run_migrations())\n```\n\n### Migration Commands\n```bash\n# Create migration\nalembic revision --autogenerate -m \"create posts table\"\n\n# Apply migrations\nalembic upgrade head\n\n# Rollback\nalembic downgrade -1\n```\n\n---\n\n## Storage\n\n### Upload File\n```python\nfrom fastapi import UploadFile\nfrom src.services.supabase import get_supabase_client\n\n\nasync def upload_avatar(user_id: str, file: UploadFile) -> str:\n    supabase = get_supabase_client()\n\n    file_content = await file.read()\n    file_path = f\"{user_id}/avatar.{file.filename.split('.')[-1]}\"\n\n    response = supabase.storage.from_(\"avatars\").upload(\n        file_path,\n        file_content,\n        {\"content-type\": file.content_type, \"upsert\": \"true\"},\n    )\n\n    # Get public URL\n    url = supabase.storage.from_(\"avatars\").get_public_url(file_path)\n    return url\n```\n\n### Download File\n```python\ndef get_avatar_url(user_id: str) -> str:\n    supabase = get_supabase_client()\n    return supabase.storage.from_(\"avatars\").get_public_url(f\"{user_id}/avatar.png\")\n```\n\n---\n\n## Realtime (Async)\n\n```python\nimport asyncio\nfrom supabase import create_client\n\n\nasync def listen_to_posts():\n    supabase = create_client(\n        settings.supabase_url,\n        settings.supabase_anon_key\n    )\n\n    def handle_change(payload):\n        print(f\"Change received: {payload}\")\n\n    channel = supabase.channel(\"posts\")\n    channel.on_postgres_changes(\n        event=\"*\",\n        schema=\"public\",\n        table=\"posts\",\n        callback=handle_change,\n    ).subscribe()\n\n    # Keep listening\n    while True:\n        await asyncio.sleep(1)\n```\n\n---\n\n## Testing\n\n### tests/conftest.py\n```python\nimport pytest\nfrom httpx import AsyncClient, ASGITransport\nfrom sqlalchemy.ext.asyncio import create_async_engine, AsyncSession\nfrom sqlalchemy.orm import sessionmaker\n\nfrom src.main import app\nfrom src.db.session import get_db\nfrom src.db.models import SQLModel\n\nTEST_DATABASE_URL = \"postgresql+asyncpg://postgres:postgres@localhost:54322/postgres_test\"\n\nengine = create_async_engine(TEST_DATABASE_URL)\nTestingSessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)\n\n\n@pytest.fixture(scope=\"function\")\nasync def db_session():\n    async with engine.begin() as conn:\n        await conn.run_sync(SQLModel.metadata.create_all)\n\n    async with TestingSessionLocal() as session:\n        yield session\n\n    async with engine.begin() as conn:\n        await conn.run_sync(SQLModel.metadata.drop_all)\n\n\n@pytest.fixture\nasync def client(db_session):\n    async def override_get_db():\n        yield db_session\n\n    app.dependency_overrides[get_db] = override_get_db\n\n    async with AsyncClient(\n        transport=ASGITransport(app=app),\n        base_url=\"http://test\",\n    ) as ac:\n        yield ac\n\n    app.dependency_overrides.clear()\n```\n\n### tests/test_posts.py\n```python\nimport pytest\nfrom httpx import AsyncClient\n\n\n@pytest.mark.asyncio\nasync def test_list_posts(client: AsyncClient):\n    response = await client.get(\"/api/posts/\")\n    assert response.status_code == 200\n    assert isinstance(response.json(), list)\n```\n\n---\n\n## Running the App\n\n```bash\n# Development\nuvicorn src.main:app --reload --port 8000\n\n# Production\nuvicorn src.main:app --host 0.0.0.0 --port 8000 --workers 4\n```\n\n---\n\n## Anti-Patterns\n\n- **Using Supabase client for DB queries** - Use SQLAlchemy/SQLModel\n- **Sync database calls** - Use async with asyncpg\n- **Hardcoded credentials** - Use environment variables\n- **No connection pooling** - asyncpg handles this\n- **Missing auth dependency** - Always validate JWT\n- **Not closing sessions** - Use context managers\n- **Blocking I/O in async** - Use async libraries\n"
  },
  {
    "path": "skills/team-coordination/SKILL.md",
    "content": "---\nname: team-coordination\ndescription: Multi-person projects - shared state, todo claiming, handoffs\nwhen-to-use: When multiple developers are working on the same repo\nuser-invocable: false\neffort: low\n---\n\n# Team Coordination Skill\n\n\n**Purpose:** Enable multiple Claude Code sessions across a team to coordinate and work together without conflicts. Manages shared state, todo claiming, decision syncing, and session awareness.\n\n---\n\n## Core Philosophy\n\n```\n┌─────────────────────────────────────────────────────────────────┐\n│  TEAM CLAUDE CODE                                               │\n│  ─────────────────────────────────────────────────────────────  │\n│  Multiple devs, multiple Claude sessions, one codebase.         │\n│  Coordination > Speed. Communication > Assumptions.             │\n│                                                                 │\n│  Before you start: Check who's working on what.                 │\n│  Before you claim: Make sure nobody else has it.                │\n│  Before you decide: Check if it's already decided.              │\n│  Before you push: Pull and sync state.                          │\n└─────────────────────────────────────────────────────────────────┘\n```\n\n---\n\n## Team State Structure\n\nWhen a project becomes multi-person, create this structure:\n\n```\n_project_specs/\n├── team/\n│   ├── state.md              # Who's working on what right now\n│   ├── contributors.md       # Team members and their focus areas\n│   └── handoffs/             # Notes when passing work to others\n│       └── [feature]-handoff.md\n├── session/\n│   ├── current-state.md      # YOUR session state (personal)\n│   ├── decisions.md          # SHARED - architectural decisions\n│   └── code-landmarks.md     # SHARED - important code locations\n└── todos/\n    ├── active.md             # SHARED - with claim annotations\n    ├── backlog.md            # SHARED\n    └── completed.md          # SHARED\n```\n\n---\n\n## Team State File\n\n**`_project_specs/team/state.md`:**\n\n```markdown\n# Team State\n\n*Last synced: [timestamp]*\n\n## Active Sessions\n\n| Contributor | Working On | Started | Files Touched | Status |\n|-------------|------------|---------|---------------|--------|\n| @alice | TODO-042: Add auth | 2024-01-15 10:30 | src/auth/* | 🟢 Active |\n| @bob | TODO-038: Fix checkout | 2024-01-15 09:00 | src/cart/* | 🟡 Paused |\n| - | - | - | - | - |\n\n## Claimed Todos\n\n| Todo | Claimed By | Since | ETA |\n|------|------------|-------|-----|\n| TODO-042 | @alice | 2024-01-15 | Today |\n| TODO-038 | @bob | 2024-01-14 | Tomorrow |\n\n## Recently Completed (Last 48h)\n\n| Todo | Completed By | When | PR |\n|------|--------------|------|-----|\n| TODO-037 | @alice | 2024-01-14 | #123 |\n\n## Conflicts to Watch\n\n| Area | Contributors | Notes |\n|------|--------------|-------|\n| src/auth/* | @alice, @carol | Carol needs auth for TODO-045, coordinate |\n\n## Announcements\n\n- [2024-01-15] @alice: Refactoring auth module, avoid touching until EOD\n- [2024-01-14] @bob: New env var required: STRIPE_WEBHOOK_SECRET\n```\n\n---\n\n## Contributors File\n\n**`_project_specs/team/contributors.md`:**\n\n```markdown\n# Contributors\n\n## Team Members\n\n| Handle | Name | Focus Areas | Timezone | Status |\n|--------|------|-------------|----------|--------|\n| @alice | Alice Smith | Backend, Auth | EST | Active |\n| @bob | Bob Jones | Frontend, Payments | PST | Active |\n| @carol | Carol White | DevOps, Infra | GMT | Part-time |\n\n## Ownership\n\n| Area | Primary | Backup | Notes |\n|------|---------|--------|-------|\n| Authentication | @alice | @bob | All auth changes need @alice review |\n| Payments | @bob | @alice | Stripe integration |\n| Infrastructure | @carol | @alice | Deploy scripts, CI/CD |\n| Database | @alice | @carol | Migrations need sign-off |\n\n## Communication\n\n- Slack: #project-name\n- PRs: Always tag area owner for review\n- Urgent: DM on Slack\n\n## Working Hours Overlap\n\n```\nEST:  |████████████████████|\nPST:  |   ████████████████████|\nGMT:  |████████████|\n      6am        12pm       6pm       12am EST\n\nBest overlap: 9am-12pm EST (all three)\n```\n```\n\n---\n\n## Workflow\n\n### Starting a Session\n\n```\n┌─────────────────────────────────────────────────────────────────┐\n│  START SESSION CHECKLIST                                        │\n│  ─────────────────────────────────────────────────────────────  │\n│  1. git pull origin main                                        │\n│  2. Read _project_specs/team/state.md                           │\n│  3. Check claimed todos - don't take what's claimed             │\n│  4. Claim your todo in active.md                                │\n│  5. Update state.md with your session                           │\n│  6. Push state changes before starting work                     │\n│  7. Start working                                               │\n└─────────────────────────────────────────────────────────────────┘\n```\n\n### Claiming a Todo\n\nIn `active.md`, add claim annotation:\n\n```markdown\n## [TODO-042] Add email validation\n\n**Status:** in-progress\n**Claimed:** @alice (2024-01-15 10:30 EST)\n**ETA:** Today\n\n...\n```\n\n### During Work\n\n- Update `state.md` if you touch new files\n- Check `decisions.md` before making architectural choices\n- If you make a decision, add it to `decisions.md` immediately\n- Push state updates every 1-2 hours (keeps team in sync)\n\n### Ending a Session\n\n```\n┌─────────────────────────────────────────────────────────────────┐\n│  END SESSION CHECKLIST                                          │\n│  ─────────────────────────────────────────────────────────────  │\n│  1. Commit your work (even if WIP)                              │\n│  2. Update your current-state.md                                │\n│  3. Update team state.md (status → Paused or Done)              │\n│  4. If passing to someone: create handoff note                  │\n│  5. Unclaim todo if abandoning                                  │\n│  6. Push everything                                             │\n└─────────────────────────────────────────────────────────────────┘\n```\n\n### Creating a Handoff\n\nWhen passing work to another team member, create:\n\n**`_project_specs/team/handoffs/auth-feature-handoff.md`:**\n\n```markdown\n# Handoff: Auth Feature (TODO-042)\n\n**From:** @alice\n**To:** @bob\n**Date:** 2024-01-15\n\n## Status\n\n70% complete. Core auth flow works, need to add:\n- [ ] Password reset flow\n- [ ] Email verification\n\n## What's Done\n\n- Login/logout working\n- JWT tokens implemented\n- Session management done\n\n## What's Left\n\n1. Password reset - see src/auth/reset.ts (skeleton exists)\n2. Email verification - need to integrate SendGrid\n\n## Key Decisions Made\n\n- Using JWT not sessions (see decisions.md)\n- Tokens expire in 7 days\n- Refresh tokens stored in httpOnly cookies\n\n## Watch Out For\n\n- The `validateToken` function has a weird edge case with expired tokens\n- Don't touch `authMiddleware.ts` - it's fragile rn\n\n## Files to Start With\n\n1. src/auth/reset.ts - password reset\n2. src/email/verification.ts - email flow\n3. tests/auth.test.ts - add tests here\n\n## Questions?\n\nSlack me @alice if stuck\n```\n\n---\n\n## Conflict Prevention\n\n### File-Level Awareness\n\nBefore modifying a file, check state.md for who's touching what:\n\n```markdown\n## Active Sessions\n\n| Contributor | Working On | Started | Files Touched | Status |\n|-------------|------------|---------|---------------|--------|\n| @alice | TODO-042 | ... | src/auth/*, src/middleware/* | 🟢 Active |\n```\n\nIf you need to touch `src/auth/*` and Alice is working there:\n1. Check if it's truly conflicting (same file? same functions?)\n2. Coordinate via Slack before proceeding\n3. Add a note to \"Conflicts to Watch\" section\n\n### Pre-Push Check\n\nBefore pushing, always:\n\n```bash\ngit pull origin main\n# Resolve any conflicts\ngit push\n```\n\n### PR Tagging\n\nAlways tag area owners in PRs:\n\n```markdown\n## PR: Add password reset flow\n\nImplements TODO-042\n\ncc: @alice (auth owner), @bob (reviewer)\n\n### Changes\n- Added password reset endpoint\n- Added email templates\n\n### Testing\n- [ ] Unit tests pass\n- [ ] Manual testing done\n```\n\n---\n\n## Decision Syncing\n\n### Before Making a Decision\n\n1. Pull latest `decisions.md`\n2. Check if decision already exists\n3. If similar decision exists, follow it (consistency > preference)\n4. If new decision needed, add it and push immediately\n\n### Decision Format\n\n```markdown\n## [2024-01-15] JWT vs Sessions for Auth (@alice)\n\n**Decision:** Use JWT tokens\n**Context:** Need auth for API and mobile app\n**Options:**\n1. Sessions - simpler, server-side state\n2. JWT - stateless, works for mobile\n**Choice:** JWT\n**Reasoning:** Mobile app needs stateless auth, JWT works across platforms\n**Trade-offs:** Token revocation is harder, need refresh token strategy\n**Approved by:** @bob, @carol\n```\n\n---\n\n## Commands\n\n### Check Team State\n\n```bash\n# See who's working on what\ncat _project_specs/team/state.md\n\n# Quick active sessions check\ngrep \"🟢 Active\" _project_specs/team/state.md\n```\n\n### Claim a Todo\n\n1. Edit `_project_specs/todos/active.md`\n2. Add claim annotation to todo\n3. Update `_project_specs/team/state.md`\n4. Commit and push\n\n### Release a Claim\n\n1. Remove claim annotation from todo\n2. Update state.md (remove from Claimed Todos)\n3. Commit and push\n\n---\n\n## Git Hooks for Teams\n\n### Pre-Push Hook Addition\n\nAdd team state sync check to pre-push:\n\n```bash\n# In .git/hooks/pre-push (add to existing)\n\n# Check if team state is current\necho \"🔄 Checking team state...\"\ngit fetch origin main --quiet\n\nLOCAL_STATE=$(git show HEAD:_project_specs/team/state.md 2>/dev/null | md5)\nREMOTE_STATE=$(git show origin/main:_project_specs/team/state.md 2>/dev/null | md5)\n\nif [ \"$LOCAL_STATE\" != \"$REMOTE_STATE\" ]; then\n    echo \"⚠️  Team state has changed on remote!\"\n    echo \"   Run: git pull origin main\"\n    echo \"   Then check _project_specs/team/state.md for updates\"\n    # Warning only, don't block\nfi\n```\n\n---\n\n## Claude Instructions\n\n### At Session Start\n\nWhen user starts a session in a team project:\n\n1. Check for `_project_specs/team/state.md`\n2. If exists, read it and report:\n   - Who's currently active\n   - What todos are claimed\n   - Any conflicts to watch\n   - Recent announcements\n\n3. Ask what they want to work on\n4. Check if it's already claimed\n5. Help them claim and update state\n\n### During Session\n\n- Before touching files, check if someone else is working there\n- Before making decisions, check decisions.md\n- Remind user to update state periodically\n\n### At Session End\n\n- Prompt user to update state.md\n- Ask if they need to create a handoff\n- Remind them to push state changes\n\n---\n\n## Single → Multi-Person Conversion\n\nWhen a project needs team coordination:\n\n1. Run `/check-contributors`\n2. Create `_project_specs/team/` structure\n3. Initialize `state.md` and `contributors.md`\n4. Add claim annotations to active todos\n5. Update CLAUDE.md to reference team-coordination.md skill\n\n---\n\n## Quick Reference\n\n### Status Icons\n\n```\n🟢 Active - Currently working\n🟡 Paused - Stepped away, will return\n🔴 Blocked - Needs help/waiting on something\n⚪ Offline - Not working today\n```\n\n### Claim Format\n\n```markdown\n**Claimed:** @handle (YYYY-MM-DD HH:MM TZ)\n```\n\n### Daily Standup Template\n\n```markdown\n## Standup [DATE]\n\n### @alice\n- Yesterday: Finished TODO-042 auth flow\n- Today: Starting TODO-045 password reset\n- Blockers: None\n\n### @bob\n- Yesterday: Fixed checkout bug\n- Today: Payment webhook integration\n- Blockers: Need STRIPE_WEBHOOK_SECRET from @carol\n```\n\n---\n\n## Checklist\n\n### Starting Work\n- [ ] `git pull origin main`\n- [ ] Read `team/state.md`\n- [ ] Check todo not claimed\n- [ ] Claim todo in `active.md`\n- [ ] Update `state.md`\n- [ ] Push state changes\n\n### Ending Work\n- [ ] Commit all changes\n- [ ] Update `current-state.md`\n- [ ] Update `team/state.md`\n- [ ] Create handoff if needed\n- [ ] Push everything\n"
  },
  {
    "path": "skills/ticket-craft/SKILL.md",
    "content": "---\nname: ticket-craft\ndescription: Create Jira/Asana/Linear tickets optimized for Claude Code execution - AI-native ticket writing\nwhen-to-use: When creating tickets, breaking down epics, or writing specs for AI agent execution\nuser-invocable: true\neffort: medium\n---\n\n# Ticket Craft Skill\n\n*Write software tickets that AI agents can execute autonomously.*\n\n**Purpose:** Define a ticket format that combines software engineering best practices (INVEST, Given-When-Then, Definition of Ready) with Claude Code-specific context requirements. Every ticket created with this skill is \"Claude Code Ready\" - meaning an agent can pick it up and execute it without asking clarifying questions.\n\n**Works with:** Jira, Asana, Linear, GitHub Issues, or any ticket system.\n\n---\n\n## Core Principle\n\n```\n┌─────────────────────────────────────────────────────────────────┐\n│  A TICKET IS A PROMPT                                            │\n│  ──────────────────────────────────────────────────────────────  │\n│                                                                  │\n│  Traditional tickets are written for humans who can:             │\n│  - Ask clarifying questions in Slack                             │\n│  - Draw on institutional knowledge                               │\n│  - Infer intent from vague descriptions                          │\n│                                                                  │\n│  AI agents cannot do any of this.                                │\n│                                                                  │\n│  Every ticket must be SELF-CONTAINED:                            │\n│  - Explicit file references (not \"the auth module\")              │\n│  - Pattern references (not \"follow our conventions\")             │\n│  - Verification criteria (not \"make sure it works\")              │\n│  - Constraints (not just what to do, but what NOT to do)         │\n│  - Test commands (not \"run the tests\")                           │\n│                                                                  │\n│  If Claude Code can execute it without asking a question,        │\n│  the ticket is ready. If it can't, it's not.                     │\n└─────────────────────────────────────────────────────────────────┘\n```\n\n---\n\n## The INVEST+C Criteria\n\nStandard INVEST plus **C for Claude-Ready**:\n\n| Criterion | Question | Fails If... |\n|-----------|----------|-------------|\n| **I** - Independent | Can this be completed without waiting on another ticket? | Blocked by undocumented dependencies |\n| **N** - Negotiable | Is there room to adjust implementation approach? | Over-specifies implementation details |\n| **V** - Valuable | Can you articulate who benefits and how? | No clear user or business value |\n| **E** - Estimable | Does the team understand enough to size it? | Too vague or too large to estimate |\n| **S** - Small | Can one person finish this in 1-3 days? | More than 5 acceptance criteria |\n| **T** - Testable | Can you write a pass/fail test for it? | Uses vague language like \"fast\" or \"good UX\" |\n| **C** - Claude-Ready | Can an AI agent execute this without clarifying questions? | Missing file refs, patterns, verification, or constraints |\n\n---\n\n## Ticket Types\n\n### 1. Feature Ticket\n\n```markdown\n## [PROJ-XXX] {Verb} {Feature} for {User}\n\n**Type:** Feature\n**Priority:** {Critical | High | Medium | Low}\n**Points:** {1 | 2 | 3 | 5 | 8}\n**Labels:** {frontend, backend, api, database, etc.}\n**Epic:** {Parent epic}\n\n---\n\n### User Story\nAs a {specific persona},\nI want to {specific action},\nso that {measurable benefit}.\n\n### Background\n{1-2 paragraphs on why this matters. Link to product brief, user research,\nor business justification. Include any relevant metrics or user feedback.}\n\n### Acceptance Criteria\n\n**AC1: {Happy path scenario}**\nGiven {precondition},\nwhen {action},\nthen {expected result}.\n\n**AC2: {Edge case / error scenario}**\nGiven {precondition},\nwhen {action},\nthen {expected result}.\n\n**AC3: {Boundary condition}**\nGiven {precondition},\nwhen {action},\nthen {expected result}.\n\n### Out of Scope\n- {Explicitly state what this ticket does NOT include}\n- {Prevents scope creep and keeps ticket small}\n\n---\n\n### Claude Code Context\n\n#### Relevant Files (read these first)\n- `src/services/example.ts` - Existing service to extend\n- `src/models/example.ts` - Data model definition\n- `src/api/routes/example.ts` - Existing endpoint patterns to follow\n\n#### Pattern Reference\nFollow the pattern in `src/services/user.ts` for service layer implementation.\nFollow the pattern in `src/api/routes/users.ts` for route definition.\nFollow the pattern in `tests/services/user.test.ts` for test structure.\n\n#### Database Changes\n- {Table to create/modify, columns, types}\n- {Migration file location: `supabase/migrations/` or `prisma/migrations/`}\n- {RLS policies if using Supabase}\n\n#### API Contract\n```\nPOST /api/{resource}\nRequest: { field1: string, field2: number }\nResponse: { id: string, field1: string, created_at: string }\nError: { error: string, code: number }\n```\n\n#### Constraints\n- Do NOT modify {specific files or modules}\n- Do NOT add new dependencies without approval\n- Follow existing error handling in `src/core/exceptions.ts`\n- {Any performance budgets: response time < 200ms, bundle size < 50KB}\n\n#### Verification\n```bash\n# Run specific tests\nnpm test -- --grep \"{feature name}\"\n\n# Lint check\nnpm run lint\n\n# Type check\nnpm run typecheck\n\n# Full validation\nnpm test -- --coverage\n```\n\n#### Environment Variables\n- Existing: {list vars already in .env that are relevant}\n- New required: {list any new vars needed}\n\n---\n\n### Dependencies\n- Blocked by: {PROJ-XXX} ({brief description})\n- Blocks: {PROJ-YYY} ({brief description})\n\n### Design\n- Mockup: {link to Figma/design if applicable}\n```\n\n---\n\n### 2. Bug Ticket\n\n```markdown\n## [BUG-XXX] Fix: {Component} - {Symptom}\n\n**Type:** Bug\n**Priority:** {Critical | High | Medium | Low}\n**Points:** {1 | 2 | 3 | 5}\n**Labels:** {regression, ux-bug, data-bug, security-bug}\n**Severity:** {Blocks users | Degrades experience | Cosmetic}\n\n---\n\n### Bug Summary\n{One sentence: what is broken and who is affected.}\n\n### Environment\n- Browser/OS: {e.g., Chrome 120 / macOS 14.2}\n- Environment: {Production | Staging | Local}\n- User type: {Anonymous | Authenticated | Admin}\n- First observed: {date}\n\n### Steps to Reproduce\n1. {Navigate to / perform action}\n2. {Perform next action}\n3. {Perform next action}\n4. **Observe:** {incorrect behavior}\n\n### Expected Behavior\n{What should happen instead.}\n\n### Actual Behavior\n{What actually happens. Include error messages, console output, screenshots.}\n\n### Impact\n- Users affected: {percentage or count}\n- Frequency: {every time | intermittent | specific conditions}\n- Workaround: {exists / none}\n\n---\n\n### Claude Code Context\n\n#### Suspected Root Cause\n{Where the bug likely lives, if known.}\n- File: `src/components/LoginForm.tsx:87`\n- Issue: `isSubmitting` state set to `true` on validation error but never reset\n\n#### Relevant Files\n- `src/components/LoginForm.tsx` - Form component with the bug\n- `tests/components/LoginForm.test.tsx` - Existing tests (gap here)\n- `src/hooks/useAuth.ts` - Auth hook used by the form\n\n#### Test Gap Analysis\n- Existing tests cover: {what's currently tested}\n- Missing test: {what test would have caught this bug}\n\n#### Bug Fix Workflow (TDD)\n1. Write a failing test that reproduces the bug\n2. Verify the test fails (confirms the bug exists)\n3. Fix the bug with minimum code change\n4. Verify the test passes\n5. Run full test suite to check for regressions\n\n#### Verification\n```bash\n# Run the specific test\nnpm test -- --grep \"LoginForm submit\"\n\n# Run related tests\nnpm test -- src/components/LoginForm.test.tsx\n\n# Full regression check\nnpm test\n```\n\n#### Constraints\n- Fix the bug only - do NOT refactor surrounding code\n- Do NOT change the component's public API\n- Ensure all existing tests continue to pass\n```\n\n---\n\n### 3. Tech Debt Ticket\n\n```markdown\n## [TECH-XXX] Refactor: {Area} - {Improvement}\n\n**Type:** Tech Debt\n**Priority:** {High | Medium | Low}\n**Points:** {3 | 5 | 8}\n**Labels:** {refactor, performance, maintainability, testing}\n\n---\n\n### Problem Statement\n{What is wrong with the current implementation and why it matters.\nInclude concrete pain points: slow CI, frequent bugs, developer confusion.}\n\n### Current State\n- File: `{path}` ({N} lines)\n- Test coverage: {X}%\n- Cyclomatic complexity: {N}\n- Related bugs: {PROJ-XXX, PROJ-YYY}\n- Pain frequency: {how often this causes issues}\n\n### Proposed Change\n{What specifically should change and why this approach.}\n\n### Acceptance Criteria\n- [ ] {Specific structural change completed}\n- [ ] All existing tests pass without modifying test assertions\n- [ ] No public API changes (existing consumers unaffected)\n- [ ] Test coverage >= {X}%\n- [ ] {Measurable improvement metric}\n\n### Risk Assessment\n- Risk level: {Low | Medium | High}\n- Mitigation: {run full regression, deploy behind flag, etc.}\n\n### Business Justification\n{Why this is worth doing now. E.g., \"Reduces average bug fix time from 4h to 1h\"\nor \"Enables upcoming feature PROJ-XXX which requires clean separation.\"}\n\n---\n\n### Claude Code Context\n\n#### Relevant Files\n- `{file}` - Current implementation to refactor\n- `{test file}` - Existing tests (must not break)\n- `{dependent file}` - Consumer of the API being refactored\n\n#### Pattern Reference\nFollow the pattern established in `{good example file}` for the new structure.\n\n#### Constraints\n- Do NOT change public APIs or exports\n- Do NOT modify test assertions (tests should pass as-is)\n- Do NOT introduce new dependencies\n- Keep backwards compatibility\n\n#### Verification\n```bash\n# Existing tests must pass unchanged\nnpm test\n\n# No type errors\nnpm run typecheck\n\n# Lint clean\nnpm run lint\n\n# Coverage target\nnpm test -- --coverage\n```\n```\n\n---\n\n### 4. Epic Breakdown Ticket\n\n```markdown\n## [EPIC-XXX] {Epic Name}\n\n**Type:** Epic\n**Priority:** {Critical | High | Medium}\n**Target:** {Sprint/milestone}\n\n---\n\n### Objective\n{One paragraph: what this epic achieves and why it matters.}\n\n### Success Metrics\n- {Measurable outcome 1}\n- {Measurable outcome 2}\n\n### User Workflows\n{The user journey this epic covers, broken into steps.}\n1. {Step 1: Discovery/Entry}\n2. {Step 2: Core Action}\n3. {Step 3: Completion/Result}\n\n### Ticket Breakdown\n\n| # | Ticket | Type | Points | Dependencies |\n|---|--------|------|--------|-------------|\n| 1 | {title} | Feature | 3 | None |\n| 2 | {title} | Feature | 5 | #1 |\n| 3 | {title} | Feature | 3 | None |\n| 4 | {title} | Feature | 2 | #2, #3 |\n| 5 | {title} | Tech Debt | 3 | None |\n\n### Slicing Strategy\n{How the epic was broken down. Reference the technique used.}\n\n### Agent Team Mapping\n{If using agent teams, how features map to agents.}\n- Feature Agent 1: Tickets #1, #2\n- Feature Agent 2: Tickets #3, #4\n- Parallel execution: #1 and #3 can run simultaneously\n- Sequential: #2 depends on #1, #4 depends on #2 and #3\n```\n\n---\n\n## Epic Slicing Techniques\n\nWhen breaking an epic into tickets, use one of these strategies:\n\n| Technique | When to Use | Example |\n|-----------|-------------|---------|\n| **By workflow step** | Clear user journey | Browse > Play > Save > Share |\n| **By data variation** | Multiple data types | Text posts, images, videos |\n| **By user role** | Different permissions | Anonymous, authenticated, admin |\n| **By CRUD** | Data operations | Create, Read, Update, Delete |\n| **Happy path first** | Incremental delivery | Success flow first, then errors |\n| **By boundary** | System integration | Frontend, API, database separately |\n\n### Rules of Thumb\n- Each ticket: **1-3 days** of work for one developer/agent\n- More than **5 acceptance criteria** = split the ticket\n- More than **8 story points** = definitely split\n- Every ticket should be **independently deployable** (even behind a flag)\n- Order tickets: **simplest, most foundational first**\n\n---\n\n## The Claude Code Ready Checklist\n\nBefore a ticket is ready for an AI agent to execute, verify:\n\n```\n┌─────────────────────────────────────────────────────────────────┐\n│  CLAUDE CODE READY CHECKLIST                                     │\n│  ──────────────────────────────────────────────────────────────  │\n│                                                                  │\n│  CONTEXT                                                         │\n│  ☐ Relevant files listed with full paths                         │\n│  ☐ Pattern reference points to a real file to follow             │\n│  ☐ API contract defined (request/response shapes)                │\n│  ☐ Database changes specified (tables, columns, migrations)      │\n│  ☐ Environment variables listed (existing + new)                 │\n│                                                                  │\n│  SCOPE                                                           │\n│  ☐ Out of Scope section explicitly states what NOT to do         │\n│  ☐ Constraints section lists files/modules NOT to modify         │\n│  ☐ Ticket covers one logical change (atomic)                     │\n│  ☐ Estimable at ≤ 5 story points                                │\n│                                                                  │\n│  VERIFICATION                                                    │\n│  ☐ Test command provided (exact command, not \"run tests\")        │\n│  ☐ Lint command provided                                         │\n│  ☐ Typecheck command provided                                    │\n│  ☐ Acceptance criteria are Given-When-Then or checkboxed         │\n│  ☐ Each criterion is independently pass/fail testable            │\n│                                                                  │\n│  QUALITY                                                         │\n│  ☐ Title is imperative verb + object + context                   │\n│  ☐ Title under 80 characters                                     │\n│  ☐ Description explains WHY, not just WHAT                       │\n│  ☐ 2-5 acceptance criteria (not more)                            │\n│  ☐ No vague language (\"fast\", \"good UX\", \"clean\")               │\n│                                                                  │\n│  If any box is unchecked, the ticket is NOT ready.               │\n└─────────────────────────────────────────────────────────────────┘\n```\n\n---\n\n## Anti-Patterns (Never Do These)\n\n### 1. The Title-Only Ticket\n```\nTitle: Fix login\nDescription: (empty)\n```\n**Why it fails:** No context, no acceptance criteria, no file references. Claude Code will guess and likely guess wrong.\n\n### 2. The Novel\n```\nTitle: Implement new onboarding\nDescription: (3 pages mixing UI, backend, analytics, email, and future ideas)\n```\n**Why it fails:** Not small, not independent. Agent teams can't parallelize this. Split into 5+ tickets.\n\n### 3. The Vague Requirement\n```\nAcceptance Criteria:\n- Should be fast\n- UX should be good\n- Should work on mobile\n```\n**Why it fails:** Unmeasurable, untestable. Replace with: \"Response time < 200ms\", \"Passes WCAG 2.1 AA\", \"No horizontal scroll at 320px viewport.\"\n\n### 4. The Over-Specified Solution\n```\nTitle: Use Redis to cache user sessions\nDescription: Install Redis, configure connection pooling, set TTL to 3600...\n```\n**Why it fails:** Prescribes the solution instead of the problem. Should describe \"Session lookups take 500ms, need < 50ms\" and let the agent choose the approach.\n\n### 5. The Missing Files Ticket\n```\nDescription: Update the auth module to support OAuth.\n```\n**Why it fails for AI:** \"The auth module\" could be 20 files. Claude Code needs: `src/services/auth.ts`, `src/middleware/auth.ts`, `src/routes/auth.ts` - specific paths.\n\n### 6. The No-Verification Ticket\n```\nAcceptance Criteria:\n- OAuth login works\n- Users can sign in with Google\n```\n**Why it fails:** No test command, no verification steps. Claude Code performs dramatically better when it can verify its own work.\n\n---\n\n## Good vs Bad Examples\n\n### Bad: Vague Feature Ticket\n```\nTitle: Add rate limiting to the API\nDescription: We need rate limiting on our endpoints.\n```\n\n### Good: Claude Code Ready Feature Ticket\n```\nTitle: Add sliding window rate limiter to /api/generate endpoint\n\nUser Story:\nAs an API consumer, I want requests to be rate-limited\nso that the service remains available under heavy load.\n\nAcceptance Criteria:\nAC1: Given an authenticated user making requests,\n     when they exceed 10 requests per minute,\n     then return 429 with Retry-After header.\n\nAC2: Given a rate-limited user,\n     when the window expires,\n     then requests succeed again.\n\nAC3: Given an unauthenticated request,\n     when it hits /api/generate,\n     then return 401 (rate limiting only applies to authed users).\n\nClaude Code Context:\n- Pattern: Follow `src/middleware/throttle.ts` for middleware structure\n- File: Create `src/middleware/rateLimit.ts`\n- Test: Create `tests/middleware/rateLimit.test.ts`\n- Route: Modify `src/api/routes/generate.ts` to add middleware\n- Constraint: Do NOT modify existing middleware or other endpoints\n\nVerification:\n  npm test -- --grep \"rate-limit\"\n  npm run lint\n  npm run typecheck\n```\n\n---\n\n## Mapping Tickets to Agent Teams\n\nWhen using the agent-teams workflow, tickets map directly to the 10-task pipeline:\n\n| Ticket Section | Maps To | Agent |\n|---------------|---------|-------|\n| Title + Description | Task 1: `{name}-spec` | Feature Agent |\n| Acceptance Criteria | Task 3: `{name}-tests` | Feature Agent (writes tests from AC) |\n| Pattern Reference | Task 5: `{name}-implement` | Feature Agent (follows pattern) |\n| Verification section | Task 6-7: verify + validate | Quality Agent + Feature Agent |\n| Constraints | Enforced throughout | All agents |\n| Claude Code Context | Loaded at start | Feature Agent reads first |\n\n### Ticket → Agent Team Flow\n```\n1. Create ticket using templates above\n2. Ticket becomes the feature spec in _project_specs/features/\n3. Team Lead reads spec, creates 10-task dependency chain\n4. Feature Agent uses ticket's Claude Code Context to start\n5. Quality Agent uses ticket's Acceptance Criteria to verify\n6. Review Agent reviews against ticket's Constraints\n7. Security Agent scans based on ticket's scope\n8. Merger Agent creates PR referencing the ticket ID\n```\n\n---\n\n## Ticket Title Conventions\n\n| Type | Format | Example |\n|------|--------|---------|\n| Feature | `Add {feature} for {user}` | Add episode bookmarking for listeners |\n| Enhancement | `Improve {what} in {where}` | Improve search performance in episode feed |\n| Bug | `Fix: {Component} - {Symptom}` | Fix: PlayerBar - audio stops on tab switch |\n| Tech Debt | `Refactor: {Area} - {Goal}` | Refactor: AuthService - extract token management |\n| Security | `Security: {What} in {Where}` | Security: add input sanitization to comment API |\n| Chore | `Chore: {What}` | Chore: upgrade React from 18 to 19 |\n\n**Rules:**\n- Start with an imperative verb (Add, Fix, Improve, Refactor, Remove)\n- Under 80 characters\n- Include the component/area affected\n- Be specific enough to distinguish from other tickets\n\n---\n\n## Story Points for AI Agents\n\nAI agents estimate differently than humans. Use this calibration:\n\n| Points | Scope | Agent Time | Example |\n|--------|-------|-----------|---------|\n| **1** | Single file, < 20 lines changed | ~5 min | Fix a typo, update a config value |\n| **2** | 1-2 files, straightforward | ~15 min | Add a field to a form, update an API response |\n| **3** | 2-4 files, clear path | ~30 min | New API endpoint following existing pattern |\n| **5** | 4-8 files, some decisions | ~1 hour | New feature with tests, models, and routes |\n| **8** | 8+ files, complex | ~2 hours | Integration with external service, new data model |\n| **13** | Too large, split required | - | Full authentication system, major refactor |\n\n**Rule:** If > 5 points, consider splitting. If 13, always split.\n\n---\n\n## Integration with Ticket Systems\n\n### Jira\n- Use custom field \"Claude Code Context\" for the AI-specific section\n- Use labels: `claude-ready`, `needs-context`, `ai-blocked`\n- Link tickets with \"blocks/blocked by\" for dependency chains\n\n### Asana\n- Use custom fields for Priority, Points, Type\n- Use subtasks for the 10-task pipeline steps\n- Use tags: `claude-ready`, `needs-refinement`\n\n### Linear\n- Use issue templates with the Claude Code Context section built-in\n- Use labels for ticket type and claude-readiness\n- Use projects to group tickets into epics\n\n### GitHub Issues\n- Use issue templates (`.github/ISSUE_TEMPLATE/`)\n- Use labels: `feature`, `bug`, `tech-debt`, `claude-ready`\n- Use milestones for epics\n\n---\n\n## Command: /create-ticket\n\nWhen the user asks to create a ticket, follow this workflow:\n\n### Step 1: Gather Context\nAsk the user:\n1. What type? (Feature / Bug / Tech Debt)\n2. Brief description of what needs to be done\n3. Which part of the codebase is involved?\n\n### Step 2: Auto-Detect Context\n- Read the relevant files to understand current implementation\n- Identify the pattern to follow from existing code\n- Find existing tests to understand test conventions\n- Check for related files that might be affected\n\n### Step 3: Generate Ticket\nUse the appropriate template above, filling in:\n- All Claude Code Context fields (auto-detected)\n- Acceptance criteria (derived from description)\n- Verification commands (from project's CLAUDE.md or package.json)\n- Constraints (based on codebase analysis)\n\n### Step 4: Validate with Checklist\nRun the Claude Code Ready Checklist against the generated ticket.\nFlag any unchecked items for the user to address.\n\n### Step 5: Output\nPresent the ticket in the template format, ready to paste into Jira/Asana/Linear.\n\n---\n\n## Definition of Ready (for Sprint)\n\nA ticket can enter a sprint when:\n\n- [ ] Passes INVEST+C criteria\n- [ ] Claude Code Ready Checklist is complete\n- [ ] Dependencies are identified and unblocked\n- [ ] Story points assigned\n- [ ] Design/mockups attached (if applicable)\n- [ ] Acceptance criteria reviewed by team\n\n## Definition of Done\n\nA ticket is done when:\n\n- [ ] All acceptance criteria verified (pass/fail)\n- [ ] Tests written and passing\n- [ ] Code reviewed (no Critical/High issues)\n- [ ] Security scan passed\n- [ ] Lint and typecheck clean\n- [ ] Coverage >= 80% for new code\n- [ ] PR created with full pipeline results\n- [ ] Documentation updated (if applicable)\n"
  },
  {
    "path": "skills/typescript/SKILL.md",
    "content": "---\nname: typescript\ndescription: TypeScript strict mode with eslint and jest\nwhen-to-use: When working on TypeScript files\nuser-invocable: false\npaths: [\"**/*.ts\", \"**/*.tsx\", \"tsconfig*.json\"]\neffort: medium\n---\n\n# TypeScript Skill\n\n\n---\n\n## Strict Mode (Non-Negotiable)\n\n```json\n// tsconfig.json\n{\n  \"compilerOptions\": {\n    \"strict\": true,\n    \"noImplicitAny\": true,\n    \"strictNullChecks\": true,\n    \"noUnusedLocals\": true,\n    \"noUnusedParameters\": true,\n    \"noImplicitReturns\": true,\n    \"esModuleInterop\": true,\n    \"skipLibCheck\": true,\n    \"forceConsistentCasingInFileNames\": true\n  }\n}\n```\n\n---\n\n## Project Structure\n\n```\nproject/\n├── src/\n│   ├── core/               # Pure business logic\n│   │   ├── types.ts        # Domain types/interfaces\n│   │   ├── services/       # Pure functions\n│   │   └── index.ts        # Public API\n│   ├── infra/              # Side effects\n│   │   ├── api/            # HTTP handlers\n│   │   ├── db/             # Database operations\n│   │   └── external/       # Third-party integrations\n│   └── utils/              # Shared utilities\n├── tests/\n│   ├── unit/\n│   └── integration/\n├── package.json\n├── tsconfig.json\n└── CLAUDE.md\n```\n\n---\n\n## Tooling (Required)\n\n```json\n// package.json scripts\n{\n  \"scripts\": {\n    \"lint\": \"eslint src/ --ext .ts,.tsx\",\n    \"typecheck\": \"tsc --noEmit\",\n    \"test\": \"jest\",\n    \"test:coverage\": \"jest --coverage\",\n    \"format\": \"prettier --write 'src/**/*.ts'\"\n  }\n}\n```\n\n```javascript\n// eslint.config.js\nimport eslint from '@eslint/js';\nimport tseslint from 'typescript-eslint';\n\nexport default tseslint.config(\n  eslint.configs.recommended,\n  ...tseslint.configs.strictTypeChecked,\n  {\n    rules: {\n      '@typescript-eslint/no-explicit-any': 'error',\n      '@typescript-eslint/explicit-function-return-type': 'error',\n      'max-lines-per-function': ['error', 20],\n      'max-depth': ['error', 2],\n      'max-params': ['error', 3],\n    }\n  }\n);\n```\n\n---\n\n## Testing with Jest\n\n```typescript\n// tests/unit/services/user.test.ts\nimport { calculateTotal } from '../../../src/core/services/pricing';\n\ndescribe('calculateTotal', () => {\n  it('returns sum of item prices', () => {\n    // Arrange\n    const items = [{ price: 10 }, { price: 20 }];\n\n    // Act\n    const result = calculateTotal(items);\n\n    // Assert\n    expect(result).toBe(30);\n  });\n\n  it('returns zero for empty array', () => {\n    expect(calculateTotal([])).toBe(0);\n  });\n\n  it('throws on invalid item', () => {\n    expect(() => calculateTotal([{ invalid: 'item' }])).toThrow();\n  });\n});\n```\n\n---\n\n## GitHub Actions\n\n```yaml\nname: TypeScript Quality Gate\n\non: [push, pull_request]\n\njobs:\n  quality:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      \n      - name: Setup Node\n        uses: actions/setup-node@v4\n        with:\n          node-version: '20'\n          \n      - name: Install dependencies\n        run: npm ci\n        \n      - name: Lint\n        run: npm run lint\n        \n      - name: Type Check\n        run: npm run typecheck\n        \n      - name: Test with Coverage\n        run: npm run test:coverage\n        \n      - name: Coverage Threshold (80%)\n        run: npm run test:coverage -- --coverageThreshold='{\"global\":{\"branches\":80,\"functions\":80,\"lines\":80,\"statements\":80}}'\n```\n\n---\n\n## Pre-Commit Hooks\n\nUsing Husky + lint-staged:\n\n```bash\nnpm install -D husky lint-staged\nnpx husky init\n```\n\n```json\n// package.json\n{\n  \"lint-staged\": {\n    \"*.{ts,tsx}\": [\n      \"eslint --fix\",\n      \"prettier --write\"\n    ]\n  }\n}\n```\n\n```bash\n# .husky/pre-commit\nnpx lint-staged\nnpx tsc --noEmit\nnpm run test -- --onlyChanged --passWithNoTests\n```\n\nThis runs on every commit:\n1. ESLint + Prettier on staged files\n2. Type check entire project\n3. Tests for changed files only\n\n---\n\n## Type Patterns\n\n### Discriminated Unions for Results\n```typescript\ntype Result<T> =\n  | { ok: true; value: T }\n  | { ok: false; error: string };\n\nfunction parseUser(data: unknown): Result<User> {\n  // Type-safe error handling without exceptions\n}\n```\n\n### Branded Types for IDs\n```typescript\ntype UserId = string & { readonly brand: unique symbol };\ntype OrderId = string & { readonly brand: unique symbol };\n\n// Can't accidentally pass UserId where OrderId expected\nfunction getOrder(orderId: OrderId): Order { ... }\n```\n\n### Const Assertions for Literals\n```typescript\nconst STATUSES = ['pending', 'active', 'closed'] as const;\ntype Status = typeof STATUSES[number]; // 'pending' | 'active' | 'closed'\n```\n\n### Zod for Runtime Validation\n```typescript\nimport { z } from 'zod';\n\nconst UserSchema = z.object({\n  email: z.string().email(),\n  name: z.string().min(1).max(100),\n});\n\ntype User = z.infer<typeof UserSchema>;\n```\n\n---\n\n## TypeScript Anti-Patterns\n\n- ❌ `any` type - use `unknown` and narrow\n- ❌ Type assertions (`as`) - use type guards\n- ❌ Non-null assertions (`!`) - handle null explicitly\n- ❌ `@ts-ignore` without explanation\n- ❌ Enums - use const objects or union types\n- ❌ Classes for data - use interfaces/types\n- ❌ Default exports - use named exports\n"
  },
  {
    "path": "skills/ui-mobile/SKILL.md",
    "content": "---\nname: ui-mobile\ndescription: Mobile UI patterns - React Native, iOS/Android, touch targets\nwhen-to-use: When building mobile UI components\nuser-invocable: false\npaths: [\"**/*.tsx\", \"**/*.jsx\", \"ios/**\", \"android/**\", \"**/*.dart\"]\neffort: medium\n---\n\n# Mobile UI Design Skill (React Native)\n\n\n---\n\n## MANDATORY: Mobile Accessibility Standards\n\n**These rules are NON-NEGOTIABLE. Every UI element must pass these checks.**\n\n### 1. Touch Targets (CRITICAL)\n```typescript\n// MINIMUM 44x44 points for ALL interactive elements\nconst MINIMUM_TOUCH_SIZE = 44;\n\n// EVERY button, link, icon button must meet this\nconst styles = StyleSheet.create({\n  button: {\n    minHeight: MINIMUM_TOUCH_SIZE,\n    minWidth: MINIMUM_TOUCH_SIZE,\n    paddingVertical: 12,\n    paddingHorizontal: 16,\n  },\n  iconButton: {\n    width: MINIMUM_TOUCH_SIZE,\n    height: MINIMUM_TOUCH_SIZE,\n    justifyContent: 'center',\n    alignItems: 'center',\n  },\n});\n\n// NEVER DO THIS:\nstyle={{ height: 30 }}  // ✗ TOO SMALL\nstyle={{ padding: 4 }}  // ✗ RESULTS IN TINY TARGET\n```\n\n### 2. Color Contrast (CRITICAL)\n```typescript\n// WCAG 2.1 AA: 4.5:1 for text, 3:1 for large text/UI\n\n// SAFE COMBINATIONS:\nconst colors = {\n  // Light mode\n  textPrimary: '#000000',     // on white = 21:1 ✓\n  textSecondary: '#374151',   // gray-700 on white = 9.2:1 ✓\n\n  // Dark mode\n  textPrimaryDark: '#FFFFFF', // on gray-900 = 16:1 ✓\n  textSecondaryDark: '#E5E7EB', // gray-200 on gray-900 = 11:1 ✓\n};\n\n// FORBIDDEN - FAILS CONTRAST:\n// ✗ '#9CA3AF' (gray-400) on white = 2.6:1\n// ✗ '#6B7280' (gray-500) on '#111827' = 4.0:1\n// ✗ Any text below 4.5:1 ratio\n```\n\n### 3. Visibility Rules\n```typescript\n// ALL BUTTONS MUST HAVE visible boundaries\n\n// PRIMARY: Solid background with contrasting text\n<Pressable style={styles.primaryButton}>\n  <Text style={{ color: '#FFFFFF' }}>Submit</Text>\n</Pressable>\n\nconst styles = StyleSheet.create({\n  primaryButton: {\n    backgroundColor: '#1F2937', // gray-800\n    paddingVertical: 16,\n    paddingHorizontal: 24,\n    borderRadius: 12,\n    minHeight: 44,\n  },\n});\n\n// SECONDARY: Visible background\n<Pressable style={styles.secondaryButton}>\n  <Text style={{ color: '#1F2937' }}>Cancel</Text>\n</Pressable>\n\nconst styles = StyleSheet.create({\n  secondaryButton: {\n    backgroundColor: '#F3F4F6', // gray-100\n    minHeight: 44,\n  },\n});\n\n// GHOST: MUST have visible border\n<Pressable style={styles.ghostButton}>\n  <Text style={{ color: '#374151' }}>Skip</Text>\n</Pressable>\n\nconst styles = StyleSheet.create({\n  ghostButton: {\n    borderWidth: 1,\n    borderColor: '#D1D5DB', // gray-300\n    minHeight: 44,\n  },\n});\n\n// NEVER CREATE invisible buttons:\n// ✗ backgroundColor: 'transparent' without border\n// ✗ Text color matching background\n```\n\n### 4. Accessibility Labels (REQUIRED)\n```tsx\n// EVERY interactive element needs accessibility props\n\n// Buttons\n<Pressable\n  accessible={true}\n  accessibilityRole=\"button\"\n  accessibilityLabel=\"Submit form\"\n  accessibilityHint=\"Double tap to submit your information\"\n>\n  <Text>Submit</Text>\n</Pressable>\n\n// Icon buttons (NO visible text = MUST have label)\n<Pressable\n  accessible={true}\n  accessibilityRole=\"button\"\n  accessibilityLabel=\"Close menu\"\n>\n  <CloseIcon />\n</Pressable>\n\n// Images\n<Image\n  accessible={true}\n  accessibilityRole=\"image\"\n  accessibilityLabel=\"User profile photo\"\n  source={...}\n/>\n```\n\n### 5. Focus/Selection States\n```tsx\n// EVERY Pressable needs visible pressed state\n<Pressable\n  style={({ pressed }) => [\n    styles.button,\n    pressed && styles.buttonPressed,\n  ]}\n>\n  {children}\n</Pressable>\n\nconst styles = StyleSheet.create({\n  button: {\n    backgroundColor: '#1F2937',\n  },\n  buttonPressed: {\n    opacity: 0.7,\n    // OR\n    backgroundColor: '#374151',\n  },\n});\n```\n\n---\n\n## Core Philosophy\n\n**Mobile UI is about touch, speed, and focus.** No hover states, smaller screens, thumb-friendly targets. Design for one-handed use and interruption recovery.\n\n## Platform Differences\n\n### iOS vs Android\n```typescript\nimport { Platform } from 'react-native';\n\n// Platform-specific values\nconst styles = StyleSheet.create({\n  shadow: Platform.select({\n    ios: {\n      shadowColor: '#000',\n      shadowOffset: { width: 0, height: 2 },\n      shadowOpacity: 0.1,\n      shadowRadius: 8,\n    },\n    android: {\n      elevation: 4,\n    },\n  }),\n\n  // iOS uses SF Pro, Android uses Roboto\n  text: {\n    fontFamily: Platform.OS === 'ios' ? 'System' : 'Roboto',\n  },\n});\n```\n\n### Design Language\n```\niOS (Human Interface Guidelines)\n─────────────────────────────────\n- Flat design with subtle depth\n- SF Symbols for icons\n- Large titles (34pt)\n- Rounded corners (10-14pt)\n- Blue as default tint\n\nAndroid (Material Design 3)\n─────────────────────────────────\n- Material You dynamic color\n- Outlined/filled icons\n- Medium titles (22pt)\n- Rounded corners (12-28pt)\n- Primary color from theme\n```\n\n## Spacing System\n\n### 4px Base Grid\n```typescript\n// React Native spacing - consistent scale\nconst spacing = {\n  xs: 4,\n  sm: 8,\n  md: 16,\n  lg: 24,\n  xl: 32,\n  '2xl': 48,\n} as const;\n\n// Usage\nconst styles = StyleSheet.create({\n  container: {\n    padding: spacing.md,\n    gap: spacing.sm,\n  },\n});\n```\n\n### Safe Areas\n```tsx\nimport { useSafeAreaInsets } from 'react-native-safe-area-context';\n\nconst Screen = ({ children }) => {\n  const insets = useSafeAreaInsets();\n\n  return (\n    <View style={{\n      flex: 1,\n      paddingTop: insets.top,\n      paddingBottom: insets.bottom,\n      paddingLeft: Math.max(insets.left, 16),\n      paddingRight: Math.max(insets.right, 16),\n    }}>\n      {children}\n    </View>\n  );\n};\n```\n\n## Typography\n\n### Type Scale\n```typescript\nconst typography = {\n  // Large titles (iOS style)\n  largeTitle: {\n    fontSize: 34,\n    fontWeight: '700' as const,\n    letterSpacing: 0.37,\n  },\n\n  // Section headers\n  title: {\n    fontSize: 22,\n    fontWeight: '700' as const,\n    letterSpacing: 0.35,\n  },\n\n  // Card titles\n  headline: {\n    fontSize: 17,\n    fontWeight: '600' as const,\n    letterSpacing: -0.41,\n  },\n\n  // Body text\n  body: {\n    fontSize: 17,\n    fontWeight: '400' as const,\n    letterSpacing: -0.41,\n    lineHeight: 22,\n  },\n\n  // Secondary text\n  callout: {\n    fontSize: 16,\n    fontWeight: '400' as const,\n    letterSpacing: -0.32,\n  },\n\n  // Small labels\n  caption: {\n    fontSize: 12,\n    fontWeight: '400' as const,\n    letterSpacing: 0,\n  },\n};\n```\n\n## Color System\n\n### Semantic Colors\n```typescript\n// Use semantic names, not literal colors\nconst colors = {\n  // Backgrounds\n  background: '#FFFFFF',\n  backgroundSecondary: '#F2F2F7',\n  backgroundTertiary: '#FFFFFF',\n\n  // Surfaces\n  surface: '#FFFFFF',\n  surfaceElevated: '#FFFFFF',\n\n  // Text\n  label: '#000000',\n  labelSecondary: '#3C3C43', // 60% opacity\n  labelTertiary: '#3C3C43',  // 30% opacity\n\n  // Actions\n  primary: '#007AFF',\n  destructive: '#FF3B30',\n  success: '#34C759',\n  warning: '#FF9500',\n\n  // Separators\n  separator: '#3C3C43', // 29% opacity\n  opaqueSeparator: '#C6C6C8',\n};\n\n// Dark mode variants\nconst darkColors = {\n  background: '#000000',\n  backgroundSecondary: '#1C1C1E',\n  label: '#FFFFFF',\n  labelSecondary: '#EBEBF5', // 60% opacity\n  separator: '#545458',\n};\n```\n\n### Dynamic Colors (React Native)\n```tsx\nimport { useColorScheme } from 'react-native';\n\nconst useColors = () => {\n  const scheme = useColorScheme();\n  return scheme === 'dark' ? darkColors : colors;\n};\n\n// Usage\nconst MyComponent = () => {\n  const colors = useColors();\n  return (\n    <View style={{ backgroundColor: colors.background }}>\n      <Text style={{ color: colors.label }}>Hello</Text>\n    </View>\n  );\n};\n```\n\n## Touch Targets\n\n### Minimum Sizes\n```typescript\n// CRITICAL: Minimum 44pt touch targets\nconst touchable = {\n  minHeight: 44,\n  minWidth: 44,\n};\n\n// Button with proper sizing\nconst styles = StyleSheet.create({\n  button: {\n    minHeight: 44,\n    paddingHorizontal: 16,\n    paddingVertical: 12,\n    justifyContent: 'center',\n    alignItems: 'center',\n  },\n\n  // Icon button (square)\n  iconButton: {\n    width: 44,\n    height: 44,\n    justifyContent: 'center',\n    alignItems: 'center',\n  },\n\n  // List row\n  listRow: {\n    minHeight: 44,\n    paddingVertical: 12,\n    paddingHorizontal: 16,\n  },\n});\n```\n\n### Touch Feedback\n```tsx\nimport { Pressable } from 'react-native';\n\n// iOS-style opacity feedback\nconst Button = ({ children, onPress }) => (\n  <Pressable\n    onPress={onPress}\n    style={({ pressed }) => [\n      styles.button,\n      pressed && { opacity: 0.7 },\n    ]}\n  >\n    {children}\n  </Pressable>\n);\n\n// Android-style ripple\nconst AndroidButton = ({ children, onPress }) => (\n  <Pressable\n    onPress={onPress}\n    android_ripple={{\n      color: 'rgba(0, 0, 0, 0.1)',\n      borderless: false,\n    }}\n    style={styles.button}\n  >\n    {children}\n  </Pressable>\n);\n```\n\n## Component Patterns\n\n### Cards\n```tsx\nconst Card = ({ children, style }) => (\n  <View style={[styles.card, style]}>\n    {children}\n  </View>\n);\n\nconst styles = StyleSheet.create({\n  card: {\n    backgroundColor: '#FFFFFF',\n    borderRadius: 12,\n    padding: 16,\n    ...Platform.select({\n      ios: {\n        shadowColor: '#000',\n        shadowOffset: { width: 0, height: 2 },\n        shadowOpacity: 0.08,\n        shadowRadius: 8,\n      },\n      android: {\n        elevation: 2,\n      },\n    }),\n  },\n});\n```\n\n### Buttons\n```tsx\n// Primary button\nconst PrimaryButton = ({ title, onPress, disabled }) => (\n  <Pressable\n    onPress={onPress}\n    disabled={disabled}\n    style={({ pressed }) => [\n      styles.primaryButton,\n      pressed && styles.primaryButtonPressed,\n      disabled && styles.buttonDisabled,\n    ]}\n  >\n    <Text style={styles.primaryButtonText}>{title}</Text>\n  </Pressable>\n);\n\nconst styles = StyleSheet.create({\n  primaryButton: {\n    backgroundColor: '#007AFF',\n    borderRadius: 12,\n    paddingVertical: 16,\n    paddingHorizontal: 24,\n    alignItems: 'center',\n  },\n  primaryButtonPressed: {\n    backgroundColor: '#0056B3',\n  },\n  primaryButtonText: {\n    color: '#FFFFFF',\n    fontSize: 17,\n    fontWeight: '600',\n  },\n  buttonDisabled: {\n    opacity: 0.5,\n  },\n});\n\n// Secondary button\nconst SecondaryButton = ({ title, onPress }) => (\n  <Pressable\n    onPress={onPress}\n    style={({ pressed }) => [\n      styles.secondaryButton,\n      pressed && { opacity: 0.7 },\n    ]}\n  >\n    <Text style={styles.secondaryButtonText}>{title}</Text>\n  </Pressable>\n);\n```\n\n### Input Fields\n```tsx\nconst TextField = ({ label, value, onChangeText, error }) => {\n  const [focused, setFocused] = useState(false);\n\n  return (\n    <View style={styles.textFieldContainer}>\n      {label && (\n        <Text style={styles.textFieldLabel}>{label}</Text>\n      )}\n      <TextInput\n        value={value}\n        onChangeText={onChangeText}\n        onFocus={() => setFocused(true)}\n        onBlur={() => setFocused(false)}\n        style={[\n          styles.textField,\n          focused && styles.textFieldFocused,\n          error && styles.textFieldError,\n        ]}\n        placeholderTextColor=\"#8E8E93\"\n      />\n      {error && (\n        <Text style={styles.errorText}>{error}</Text>\n      )}\n    </View>\n  );\n};\n\nconst styles = StyleSheet.create({\n  textFieldContainer: {\n    gap: 8,\n  },\n  textFieldLabel: {\n    fontSize: 15,\n    fontWeight: '500',\n    color: '#3C3C43',\n  },\n  textField: {\n    backgroundColor: '#F2F2F7',\n    borderRadius: 10,\n    paddingHorizontal: 16,\n    paddingVertical: 14,\n    fontSize: 17,\n    color: '#000000',\n    borderWidth: 2,\n    borderColor: 'transparent',\n  },\n  textFieldFocused: {\n    borderColor: '#007AFF',\n    backgroundColor: '#FFFFFF',\n  },\n  textFieldError: {\n    borderColor: '#FF3B30',\n  },\n  errorText: {\n    fontSize: 13,\n    color: '#FF3B30',\n  },\n});\n```\n\n### Lists\n```tsx\n// Grouped list (iOS Settings style)\nconst GroupedList = ({ sections }) => (\n  <ScrollView style={styles.groupedList}>\n    {sections.map((section, i) => (\n      <View key={i} style={styles.section}>\n        {section.title && (\n          <Text style={styles.sectionHeader}>{section.title}</Text>\n        )}\n        <View style={styles.sectionContent}>\n          {section.items.map((item, j) => (\n            <React.Fragment key={j}>\n              {j > 0 && <View style={styles.separator} />}\n              <Pressable\n                style={({ pressed }) => [\n                  styles.listRow,\n                  pressed && { backgroundColor: '#E5E5EA' },\n                ]}\n                onPress={item.onPress}\n              >\n                <Text style={styles.listRowText}>{item.title}</Text>\n                <ChevronRight color=\"#C7C7CC\" />\n              </Pressable>\n            </React.Fragment>\n          ))}\n        </View>\n      </View>\n    ))}\n  </ScrollView>\n);\n\nconst styles = StyleSheet.create({\n  groupedList: {\n    flex: 1,\n    backgroundColor: '#F2F2F7',\n  },\n  section: {\n    marginTop: 35,\n  },\n  sectionHeader: {\n    fontSize: 13,\n    fontWeight: '400',\n    color: '#6D6D72',\n    textTransform: 'uppercase',\n    marginLeft: 16,\n    marginBottom: 8,\n  },\n  sectionContent: {\n    backgroundColor: '#FFFFFF',\n    borderRadius: 10,\n    marginHorizontal: 16,\n    overflow: 'hidden',\n  },\n  listRow: {\n    flexDirection: 'row',\n    alignItems: 'center',\n    justifyContent: 'space-between',\n    paddingVertical: 12,\n    paddingHorizontal: 16,\n    minHeight: 44,\n  },\n  separator: {\n    height: StyleSheet.hairlineWidth,\n    backgroundColor: '#C6C6C8',\n    marginLeft: 16,\n  },\n});\n```\n\n## Navigation Patterns\n\n### Bottom Tab Bar\n```tsx\n// Proper bottom tab sizing\nconst tabBarStyle = {\n  height: Platform.OS === 'ios' ? 83 : 65, // Account for home indicator\n  paddingBottom: Platform.OS === 'ios' ? 34 : 10,\n  paddingTop: 10,\n  backgroundColor: '#F8F8F8',\n  borderTopWidth: StyleSheet.hairlineWidth,\n  borderTopColor: '#C6C6C8',\n};\n\n// Tab item\nconst TabItem = ({ icon, label, active }) => (\n  <View style={styles.tabItem}>\n    <Icon name={icon} color={active ? '#007AFF' : '#8E8E93'} size={24} />\n    <Text style={[\n      styles.tabLabel,\n      { color: active ? '#007AFF' : '#8E8E93' }\n    ]}>\n      {label}\n    </Text>\n  </View>\n);\n```\n\n### Header\n```tsx\n// Large title header (iOS)\nconst LargeTitleHeader = ({ title, rightAction }) => {\n  const insets = useSafeAreaInsets();\n\n  return (\n    <View style={[styles.header, { paddingTop: insets.top }]}>\n      <View style={styles.headerContent}>\n        <Text style={styles.largeTitle}>{title}</Text>\n        {rightAction}\n      </View>\n    </View>\n  );\n};\n\nconst styles = StyleSheet.create({\n  header: {\n    backgroundColor: '#F8F8F8',\n    borderBottomWidth: StyleSheet.hairlineWidth,\n    borderBottomColor: '#C6C6C8',\n  },\n  headerContent: {\n    flexDirection: 'row',\n    justifyContent: 'space-between',\n    alignItems: 'center',\n    paddingHorizontal: 16,\n    paddingBottom: 8,\n  },\n  largeTitle: {\n    fontSize: 34,\n    fontWeight: '700',\n    letterSpacing: 0.37,\n  },\n});\n```\n\n## Animations\n\n### Native Driver Animations\n```tsx\nimport { Animated } from 'react-native';\n\n// Always use native driver when possible\nconst fadeIn = (value: Animated.Value) => {\n  Animated.timing(value, {\n    toValue: 1,\n    duration: 200,\n    useNativeDriver: true, // CRITICAL for performance\n  }).start();\n};\n\n// Spring for natural feel\nconst bounce = (value: Animated.Value) => {\n  Animated.spring(value, {\n    toValue: 1,\n    damping: 15,\n    stiffness: 150,\n    useNativeDriver: true,\n  }).start();\n};\n```\n\n### Reanimated for Complex Animations\n```tsx\nimport Animated, {\n  useSharedValue,\n  useAnimatedStyle,\n  withSpring,\n} from 'react-native-reanimated';\n\nconst AnimatedCard = ({ children }) => {\n  const scale = useSharedValue(1);\n\n  const animatedStyle = useAnimatedStyle(() => ({\n    transform: [{ scale: scale.value }],\n  }));\n\n  const onPressIn = () => {\n    scale.value = withSpring(0.95);\n  };\n\n  const onPressOut = () => {\n    scale.value = withSpring(1);\n  };\n\n  return (\n    <Pressable onPressIn={onPressIn} onPressOut={onPressOut}>\n      <Animated.View style={[styles.card, animatedStyle]}>\n        {children}\n      </Animated.View>\n    </Pressable>\n  );\n};\n```\n\n## Loading States\n\n### Skeleton Loader\n```tsx\nconst SkeletonLoader = ({ width, height, borderRadius = 4 }) => {\n  const opacity = useSharedValue(0.3);\n\n  useEffect(() => {\n    opacity.value = withRepeat(\n      withSequence(\n        withTiming(1, { duration: 500 }),\n        withTiming(0.3, { duration: 500 })\n      ),\n      -1,\n      false\n    );\n  }, []);\n\n  const animatedStyle = useAnimatedStyle(() => ({\n    opacity: opacity.value,\n  }));\n\n  return (\n    <Animated.View\n      style={[\n        { width, height, borderRadius, backgroundColor: '#E5E5EA' },\n        animatedStyle,\n      ]}\n    />\n  );\n};\n```\n\n### Activity Indicator\n```tsx\nimport { ActivityIndicator } from 'react-native';\n\n// Use platform-native indicator\n<ActivityIndicator size=\"large\" color=\"#007AFF\" />\n\n// Button with loading state\nconst LoadingButton = ({ loading, title, onPress }) => (\n  <Pressable\n    onPress={onPress}\n    disabled={loading}\n    style={styles.button}\n  >\n    {loading ? (\n      <ActivityIndicator color=\"#FFFFFF\" />\n    ) : (\n      <Text style={styles.buttonText}>{title}</Text>\n    )}\n  </Pressable>\n);\n```\n\n## Accessibility\n\n### VoiceOver / TalkBack\n```tsx\n// Accessible button\n<Pressable\n  onPress={onPress}\n  accessible={true}\n  accessibilityRole=\"button\"\n  accessibilityLabel=\"Submit form\"\n  accessibilityHint=\"Double tap to submit your information\"\n>\n  <Text>Submit</Text>\n</Pressable>\n\n// Accessible image\n<Image\n  source={icon}\n  accessible={true}\n  accessibilityRole=\"image\"\n  accessibilityLabel=\"User profile picture\"\n/>\n\n// Group related elements\n<View\n  accessible={true}\n  accessibilityRole=\"summary\"\n  accessibilityLabel={`${name}, ${role}, ${status}`}\n>\n  <Text>{name}</Text>\n  <Text>{role}</Text>\n  <Text>{status}</Text>\n</View>\n```\n\n### Dynamic Type (iOS)\n```tsx\nimport { PixelRatio } from 'react-native';\n\n// Scale fonts with system settings\nconst fontScale = PixelRatio.getFontScale();\nconst scaledFontSize = (size: number) => size * fontScale;\n\n// Or use allowFontScaling\n<Text allowFontScaling={true} style={{ fontSize: 17 }}>\n  This text scales with system settings\n</Text>\n```\n\n## Anti-Patterns\n\n### Never Do\n```\n✗ Touch targets smaller than 44pt\n✗ Text smaller than 12pt\n✗ Hover states (no hover on mobile)\n✗ Fixed heights that break with large text\n✗ Ignoring safe areas\n✗ Heavy shadows on Android (use elevation)\n✗ White text on light backgrounds without checking contrast\n✗ Non-native animations (JS-driven transforms)\n✗ Ignoring platform conventions (iOS vs Android)\n✗ Inline styles everywhere (use StyleSheet.create)\n```\n\n### Common Mistakes\n```tsx\n// ✗ Hardcoded dimensions that break accessibility\nstyle={{ height: 40 }}  // Text might be larger\n\n// ✓ Minimum height with padding\nstyle={{ minHeight: 44, paddingVertical: 12 }}\n\n// ✗ Shadow on Android\nshadowColor: '#000'  // Won't work\n\n// ✓ Platform-specific\n...Platform.select({\n  ios: { shadowColor: '#000', ... },\n  android: { elevation: 4 },\n})\n\n// ✗ Fixed status bar height\npaddingTop: 44\n\n// ✓ Use safe area\npaddingTop: insets.top\n```\n\n## Quick Reference\n\n### Mobile Defaults\n```\nTouch targets: 44pt minimum\nFont sizes: 12pt min, 17pt body, 34pt large title\nBorder radius: 10-14pt (iOS), 12-28pt (Android)\nSpacing: 4/8/16/24/32 grid\nAnimations: 200-300ms, native driver\nShadow: iOS shadowOpacity 0.08-0.15, Android elevation 2-8\n```\n\n### Premium Feel Checklist\n```\n□ All touch targets 44pt+\n□ Consistent spacing (4pt grid)\n□ Platform-appropriate styling\n□ Safe area handling\n□ Native animations (60fps)\n□ Proper loading states\n□ Dark mode support\n□ Accessibility labels\n□ Haptic feedback on actions\n□ Pull-to-refresh where appropriate\n```\n"
  },
  {
    "path": "skills/ui-testing/SKILL.md",
    "content": "---\nname: ui-testing\ndescription: Visual testing - catch invisible buttons, broken layouts, contrast\nwhen-to-use: When writing visual or accessibility tests for UI components\nuser-invocable: false\npaths: [\"**/*.test.tsx\", \"**/*.spec.tsx\", \"**/*.stories.*\"]\neffort: medium\n---\n\n# UI Verification Skill\n\n*Load with: ui-web.md or ui-mobile.md*\n\n## Purpose\n\nQuick verification that generated UI meets accessibility standards. Run these checks after creating any new UI components.\n\n---\n\n## Pre-Flight Checklist\n\n### Before Shipping ANY UI:\n\n```markdown\n## Visibility Check\n- [ ] All buttons have visible background OR border\n- [ ] No text is same color as its background\n- [ ] All text meets 4.5:1 contrast ratio\n- [ ] Ghost/text buttons have visible borders\n\n## Touch/Click Targets\n- [ ] All buttons are minimum 44px height\n- [ ] Icon buttons are minimum 44x44px\n- [ ] Adequate spacing between clickable elements\n\n## States\n- [ ] Hover states visible (web)\n- [ ] Pressed states visible (mobile)\n- [ ] Focus rings on keyboard navigation\n- [ ] Disabled states visually distinct (opacity 0.5)\n- [ ] Loading states show indicators\n\n## Dark Mode (if applicable)\n- [ ] Text readable on dark backgrounds\n- [ ] Borders visible in dark mode\n- [ ] No gray-400 text on dark backgrounds\n\n## Responsive (web)\n- [ ] No horizontal scroll on mobile (320px)\n- [ ] Content readable at all breakpoints\n- [ ] Touch targets adequate on mobile\n```\n\n---\n\n## Quick Contrast Check\n\n### Use Browser DevTools\n```\n1. Right-click element → Inspect\n2. In Styles panel, click on color value\n3. Look for contrast ratio display\n4. Must show ✓ for AA compliance (4.5:1 for text)\n```\n\n### Online Tools\n- https://webaim.org/resources/contrastchecker/\n- https://coolors.co/contrast-checker\n\n### Tailwind Safe Combinations\n\n```\nLIGHT MODE (on white bg):\n✓ text-gray-900  (#111827) = 16:1\n✓ text-gray-800  (#1F2937) = 12:1\n✓ text-gray-700  (#374151) = 9:1\n✓ text-gray-600  (#4B5563) = 6:1\n✗ text-gray-500  (#6B7280) = 4.6:1 (barely)\n✗ text-gray-400  (#9CA3AF) = 2.6:1 (FAILS)\n\nDARK MODE (on gray-900 bg):\n✓ text-white     (#FFFFFF) = 16:1\n✓ text-gray-100  (#F3F4F6) = 13:1\n✓ text-gray-200  (#E5E7EB) = 11:1\n✓ text-gray-300  (#D1D5DB) = 8:1\n✗ text-gray-400  (#9CA3AF) = 5:1 (barely)\n✗ text-gray-500  (#6B7280) = 3:1 (FAILS)\n```\n\n---\n\n## Common Fixes\n\n### Invisible Button\n```tsx\n// PROBLEM: No visible boundary\n<button className=\"text-gray-500\">Click</button>\n\n// FIX: Add background OR border\n<button className=\"bg-gray-100 text-gray-900 px-4 py-3 rounded-lg\">\n  Click\n</button>\n// OR\n<button className=\"border border-gray-300 text-gray-700 px-4 py-3 rounded-lg\">\n  Click\n</button>\n```\n\n### Low Contrast Text\n```tsx\n// PROBLEM: Light gray on white\n<p className=\"text-gray-400\">Secondary text</p>\n\n// FIX: Use darker gray\n<p className=\"text-gray-600\">Secondary text</p>\n```\n\n### Missing Focus State\n```tsx\n// PROBLEM: Focus removed without replacement\n<button className=\"outline-none\">Submit</button>\n\n// FIX: Add visible focus ring\n<button className=\"outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2\">\n  Submit\n</button>\n```\n\n### Small Touch Target\n```tsx\n// PROBLEM: Too small for fingers\n<button className=\"p-1 text-sm\">×</button>\n\n// FIX: Minimum 44px\n<button className=\"w-11 h-11 flex items-center justify-center\">×</button>\n```\n\n### Dark Mode Broken\n```tsx\n// PROBLEM: Same colors in both modes\n<p className=\"text-gray-400\">Text</p>\n\n// FIX: Adjust for dark mode\n<p className=\"text-gray-600 dark:text-gray-300\">Text</p>\n```\n\n---\n\n## Automated Checks (Optional)\n\n### ESLint Plugin\n```bash\nnpm install -D eslint-plugin-jsx-a11y\n```\n\n```json\n// .eslintrc\n{\n  \"extends\": [\"plugin:jsx-a11y/recommended\"]\n}\n```\n\n### Playwright Quick Test\n```typescript\n// e2e/accessibility.spec.ts\nimport { test, expect } from '@playwright/test';\nimport AxeBuilder from '@axe-core/playwright';\n\ntest('no accessibility violations', async ({ page }) => {\n  await page.goto('/');\n  const results = await new AxeBuilder({ page }).analyze();\n  expect(results.violations).toEqual([]);\n});\n```\n\n---\n\n## When to Use Full Testing\n\nAdd comprehensive visual testing (Playwright screenshots, Storybook) when:\n- Building a component library\n- Multiple developers on UI\n- Frequent UI changes\n- Design system enforcement needed\n\nFor solo projects or MVPs, the checklist above is sufficient.\n"
  },
  {
    "path": "skills/ui-web/SKILL.md",
    "content": "---\nname: ui-web\ndescription: Web UI - glassmorphism, Tailwind, dark mode, accessibility\nwhen-to-use: When building or styling web UI components\nuser-invocable: false\npaths: [\"**/*.tsx\", \"**/*.jsx\", \"**/*.css\", \"**/*.scss\", \"tailwind.config.*\"]\neffort: medium\n---\n\n# UI Design Skill (Web)\n\n\n---\n\n## MANDATORY: WCAG 2.1 AA Compliance\n\n**These rules are NON-NEGOTIABLE. Every UI element must pass these checks.**\n\n### 1. Color Contrast (CRITICAL)\n```\nText Contrast Requirements:\n├── Normal text (<18px): 4.5:1 minimum\n├── Large text (≥18px bold or ≥24px): 3:1 minimum\n├── UI components (buttons, inputs): 3:1 minimum\n└── Focus indicators: 3:1 minimum\n\nFORBIDDEN COLOR COMBINATIONS:\n✗ gray-400 on white (#9CA3AF on #FFFFFF = 2.6:1) - FAILS\n✗ gray-500 on white (#6B7280 on #FFFFFF = 4.6:1) - BARELY PASSES\n✗ white on yellow - FAILS\n✗ light blue on white - USUALLY FAILS\n\nSAFE COLOR COMBINATIONS:\n✓ gray-700 on white (#374151 on #FFFFFF = 9.2:1)\n✓ gray-600 on white (#4B5563 on #FFFFFF = 6.4:1)\n✓ gray-900 on white (#111827 on #FFFFFF = 16:1)\n✓ white on gray-900, blue-600, green-700\n```\n\n### 2. Visibility Rules (CRITICAL)\n```\nALL BUTTONS MUST HAVE:\n✓ Visible background color OR visible border (min 1px)\n✓ Text color that contrasts with background\n✓ Minimum height: 44px (touch target)\n✓ Padding: at least px-4 py-2\n\nNEVER CREATE:\n✗ Buttons with transparent background AND no border\n✗ Text same color as background\n✗ Ghost buttons without visible borders\n✗ White text on light backgrounds\n✗ Dark text on dark backgrounds\n```\n\n### 3. Required Element Styles\n```tsx\n// EVERY button needs visible boundaries\n// PRIMARY: solid background\n<button className=\"bg-gray-900 text-white px-4 py-3 rounded-lg\">\n  Primary\n</button>\n\n// SECONDARY: visible background\n<button className=\"bg-gray-100 text-gray-900 px-4 py-3 rounded-lg\">\n  Secondary\n</button>\n\n// GHOST: MUST have visible border\n<button className=\"border border-gray-300 text-gray-700 px-4 py-3 rounded-lg\">\n  Ghost\n</button>\n\n// NEVER DO THIS:\n<button className=\"text-gray-500\">Invisible Button</button> // ✗ NO BOUNDARY\n<button className=\"bg-white text-white\">Hidden</button>     // ✗ NO CONTRAST\n```\n\n### 4. Focus States (REQUIRED)\n```tsx\n// EVERY interactive element needs visible focus\nclassName=\"focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2\"\n\n// NEVER remove focus without replacement\nclassName=\"outline-none\" // ✗ FORBIDDEN without ring replacement\n```\n\n### 5. Dark Mode Contrast\n```\nWhen implementing dark mode:\n├── Text must be light (gray-100 to white) on dark backgrounds\n├── Borders must be visible (gray-700 or lighter)\n├── Never use gray-400 text on gray-900 bg (fails contrast)\n└── Test BOTH modes before shipping\n\nSAFE DARK MODE TEXT:\n✓ text-white on bg-gray-900\n✓ text-gray-100 on bg-gray-800\n✓ text-gray-200 on bg-gray-900\n\nUNSAFE (FAILS CONTRAST):\n✗ text-gray-500 on bg-gray-900 (2.4:1)\n✗ text-gray-400 on bg-gray-800 (3.1:1)\n```\n\n---\n\n## Core Philosophy\n\n**Beautiful UI is not decoration - it's communication.** Every visual choice should serve clarity, hierarchy, and user confidence. Default to elegance and restraint.\n\n## Design Principles\n\n### 1. Visual Hierarchy\n```\nPrimary Action    → Bold, high contrast, prominent\nSecondary Action  → Subtle, lower contrast\nTertiary/Links    → Minimal, text-style\n```\n\n### 2. Spacing System (8px Grid)\n```typescript\n// Tailwind spacing scale - USE CONSISTENTLY\nconst spacing = {\n  xs: 'p-1',      // 4px  - tight internal\n  sm: 'p-2',      // 8px  - compact\n  md: 'p-4',      // 16px - default\n  lg: 'p-6',      // 24px - comfortable\n  xl: 'p-8',      // 32px - spacious\n  '2xl': 'p-12',  // 48px - section gaps\n};\n\n// Rule: More whitespace = more premium feel\n// Rule: Consistent spacing > perfect spacing\n```\n\n### 3. Typography Scale\n```typescript\n// Limit to 3-4 font sizes per page\nconst typography = {\n  hero: 'text-4xl md:text-5xl font-bold tracking-tight',\n  heading: 'text-2xl md:text-3xl font-semibold',\n  subheading: 'text-lg md:text-xl font-medium',\n  body: 'text-base leading-relaxed',\n  caption: 'text-sm text-gray-500',\n};\n\n// Rule: Never use more than 2 font families\n// Rule: Line height 1.5-1.7 for body text\n```\n\n## Glassmorphism (Web)\n\n### Base Glass Card\n```tsx\n// Modern glass effect - use sparingly for emphasis\nconst GlassCard = ({ children, className = '' }) => (\n  <div className={`\n    backdrop-blur-xl\n    bg-white/10\n    border border-white/20\n    rounded-2xl\n    shadow-xl\n    shadow-black/5\n    ${className}\n  `}>\n    {children}\n  </div>\n);\n```\n\n### Glass Variants\n```tsx\n// Light mode glass\nconst lightGlass = `\n  backdrop-blur-xl\n  bg-white/70\n  border border-white/50\n  shadow-lg shadow-gray-200/50\n`;\n\n// Dark mode glass\nconst darkGlass = `\n  backdrop-blur-xl\n  bg-gray-900/70\n  border border-white/10\n  shadow-xl shadow-black/20\n`;\n\n// Frosted sidebar\nconst frostedSidebar = `\n  backdrop-blur-2xl\n  bg-gradient-to-b from-white/80 to-white/60\n  border-r border-white/30\n`;\n\n// Floating action glass\nconst floatingGlass = `\n  backdrop-blur-md\n  bg-white/90\n  rounded-full\n  shadow-lg shadow-black/10\n  border border-white/50\n`;\n```\n\n### When to Use Glassmorphism\n```\n✓ Hero sections with image backgrounds\n✓ Floating cards over gradients\n✓ Modal overlays\n✓ Navigation bars (subtle)\n✓ Feature highlights\n\n✗ Every card (overuse kills the effect)\n✗ Text-heavy content areas\n✗ Forms (reduces contrast)\n✗ Data tables\n```\n\n## Color System\n\n### Semantic Colors\n```typescript\nconst colors = {\n  // Actions\n  primary: 'bg-blue-600 hover:bg-blue-700',\n  secondary: 'bg-gray-100 hover:bg-gray-200 text-gray-900',\n  danger: 'bg-red-600 hover:bg-red-700',\n  success: 'bg-green-600 hover:bg-green-700',\n\n  // Surfaces\n  background: 'bg-gray-50 dark:bg-gray-950',\n  surface: 'bg-white dark:bg-gray-900',\n  elevated: 'bg-white dark:bg-gray-800 shadow-lg',\n\n  // Text\n  textPrimary: 'text-gray-900 dark:text-white',\n  textSecondary: 'text-gray-600 dark:text-gray-400',\n  textMuted: 'text-gray-400 dark:text-gray-500',\n};\n```\n\n### Gradient Backgrounds\n```tsx\n// Subtle mesh gradient (modern, premium)\nconst meshGradient = `\n  bg-gradient-to-br\n  from-blue-50 via-white to-purple-50\n  dark:from-gray-950 dark:via-gray-900 dark:to-gray-950\n`;\n\n// Vibrant hero gradient\nconst heroGradient = `\n  bg-gradient-to-r\n  from-blue-600 via-purple-600 to-pink-600\n`;\n\n// Subtle radial glow\nconst radialGlow = `\n  bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))]\n  from-blue-200/40 via-transparent to-transparent\n`;\n```\n\n## Component Patterns\n\n### Buttons\n```tsx\n// Primary button - bold, confident\nconst PrimaryButton = ({ children, ...props }) => (\n  <button\n    className=\"\n      px-6 py-3\n      bg-gray-900 dark:bg-white\n      text-white dark:text-gray-900\n      font-medium\n      rounded-xl\n      transition-all duration-200\n      hover:bg-gray-800 dark:hover:bg-gray-100\n      hover:shadow-lg hover:shadow-gray-900/20\n      active:scale-[0.98]\n      disabled:opacity-50 disabled:cursor-not-allowed\n    \"\n    {...props}\n  >\n    {children}\n  </button>\n);\n\n// Secondary button - subtle\nconst SecondaryButton = ({ children, ...props }) => (\n  <button\n    className=\"\n      px-6 py-3\n      bg-gray-100 dark:bg-gray-800\n      text-gray-900 dark:text-white\n      font-medium\n      rounded-xl\n      transition-all duration-200\n      hover:bg-gray-200 dark:hover:bg-gray-700\n      active:scale-[0.98]\n    \"\n    {...props}\n  >\n    {children}\n  </button>\n);\n\n// Ghost button - minimal\nconst GhostButton = ({ children, ...props }) => (\n  <button\n    className=\"\n      px-4 py-2\n      text-gray-600 dark:text-gray-400\n      font-medium\n      rounded-lg\n      transition-colors duration-200\n      hover:text-gray-900 dark:hover:text-white\n      hover:bg-gray-100 dark:hover:bg-gray-800\n    \"\n    {...props}\n  >\n    {children}\n  </button>\n);\n```\n\n### Cards\n```tsx\n// Clean card with subtle elevation\nconst Card = ({ children, className = '' }) => (\n  <div className={`\n    bg-white dark:bg-gray-900\n    rounded-2xl\n    border border-gray-200 dark:border-gray-800\n    shadow-sm\n    hover:shadow-md\n    transition-shadow duration-300\n    ${className}\n  `}>\n    {children}\n  </div>\n);\n\n// Interactive card\nconst InteractiveCard = ({ children, onClick }) => (\n  <button\n    onClick={onClick}\n    className=\"\n      w-full text-left\n      bg-white dark:bg-gray-900\n      rounded-2xl\n      border border-gray-200 dark:border-gray-800\n      p-6\n      transition-all duration-300\n      hover:border-gray-300 dark:hover:border-gray-700\n      hover:shadow-lg\n      hover:-translate-y-1\n      active:scale-[0.99]\n    \"\n  >\n    {children}\n  </button>\n);\n```\n\n### Input Fields\n```tsx\nconst Input = ({ label, error, ...props }) => (\n  <div className=\"space-y-2\">\n    {label && (\n      <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300\">\n        {label}\n      </label>\n    )}\n    <input\n      className={`\n        w-full px-4 py-3\n        bg-gray-50 dark:bg-gray-800\n        border-2 rounded-xl\n        text-gray-900 dark:text-white\n        placeholder-gray-400 dark:placeholder-gray-500\n        transition-all duration-200\n        focus:outline-none focus:ring-0\n        ${error\n          ? 'border-red-500 focus:border-red-500'\n          : 'border-transparent focus:border-blue-500 focus:bg-white dark:focus:bg-gray-900'\n        }\n      `}\n      {...props}\n    />\n    {error && (\n      <p className=\"text-sm text-red-500\">{error}</p>\n    )}\n  </div>\n);\n```\n\n## Micro-Interactions\n\n### Transitions\n```typescript\n// Standard transitions - ALWAYS use\nconst transitions = {\n  fast: 'transition-all duration-150',      // Hover states\n  normal: 'transition-all duration-200',    // Most interactions\n  slow: 'transition-all duration-300',      // Card hovers, modals\n  spring: 'transition-all duration-500 ease-out', // Page transitions\n};\n\n// Rule: Everything interactive should transition\n// Rule: 150-300ms feels responsive, >500ms feels slow\n```\n\n### Hover Effects\n```tsx\n// Scale on hover (buttons, cards)\nclassName=\"hover:scale-105 active:scale-95 transition-transform\"\n\n// Lift on hover (cards)\nclassName=\"hover:-translate-y-1 hover:shadow-xl transition-all\"\n\n// Glow on hover (CTAs)\nclassName=\"hover:shadow-lg hover:shadow-blue-500/25 transition-shadow\"\n\n// Border highlight (inputs, cards)\nclassName=\"hover:border-gray-300 transition-colors\"\n```\n\n### Loading States\n```tsx\n// Skeleton loader\nconst Skeleton = ({ className = '' }) => (\n  <div className={`\n    animate-pulse\n    bg-gray-200 dark:bg-gray-800\n    rounded-lg\n    ${className}\n  `} />\n);\n\n// Spinner\nconst Spinner = ({ size = 'md' }) => (\n  <div className={`\n    animate-spin rounded-full\n    border-2 border-gray-200 dark:border-gray-700\n    border-t-blue-600\n    ${size === 'sm' ? 'w-4 h-4' : size === 'lg' ? 'w-8 h-8' : 'w-6 h-6'}\n  `} />\n);\n\n// Button loading state\n<button disabled className=\"relative\">\n  <span className=\"opacity-0\">Submit</span>\n  <Spinner className=\"absolute inset-0 m-auto\" />\n</button>\n```\n\n## Layout Patterns\n\n### Container\n```tsx\n// Consistent max-width and padding\nconst Container = ({ children, className = '' }) => (\n  <div className={`\n    max-w-7xl mx-auto\n    px-4 sm:px-6 lg:px-8\n    ${className}\n  `}>\n    {children}\n  </div>\n);\n```\n\n### Section Spacing\n```tsx\n// Consistent vertical rhythm\nconst Section = ({ children }) => (\n  <section className=\"py-16 md:py-24\">\n    <Container>{children}</Container>\n  </section>\n);\n```\n\n### Grid Systems\n```tsx\n// Feature grid\n<div className=\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6\">\n  {features.map(f => <FeatureCard key={f.id} {...f} />)}\n</div>\n\n// Bento grid (modern asymmetric)\n<div className=\"grid grid-cols-2 md:grid-cols-4 gap-4\">\n  <div className=\"col-span-2 row-span-2\">Large</div>\n  <div className=\"col-span-1\">Small</div>\n  <div className=\"col-span-1\">Small</div>\n  <div className=\"col-span-2\">Medium</div>\n</div>\n```\n\n## Dark Mode\n\n### Implementation\n```tsx\n// Always design for both modes\n// Use CSS variables or Tailwind dark: prefix\n\n// Theme toggle\nconst ThemeToggle = () => {\n  const [dark, setDark] = useState(false);\n\n  useEffect(() => {\n    document.documentElement.classList.toggle('dark', dark);\n  }, [dark]);\n\n  return (\n    <button onClick={() => setDark(!dark)}>\n      {dark ? <SunIcon /> : <MoonIcon />}\n    </button>\n  );\n};\n```\n\n### Color Pairing\n```\nLight Mode          Dark Mode\n─────────────────────────────────\nwhite               gray-950\ngray-50             gray-900\ngray-100            gray-800\ngray-200            gray-700\ngray-900 (text)     white (text)\ngray-600 (secondary) gray-400\nblue-600            blue-500\n```\n\n## Accessibility\n\n### Contrast Requirements\n```\nWCAG AA: 4.5:1 for normal text, 3:1 for large text\nWCAG AAA: 7:1 for normal text, 4.5:1 for large text\n\n// Test: Use browser devtools or contrast checker\n// Rule: Never use gray-400 on white for body text\n```\n\n### Focus States\n```tsx\n// Always visible focus rings\nclassName=\"\n  focus:outline-none\n  focus-visible:ring-2\n  focus-visible:ring-blue-500\n  focus-visible:ring-offset-2\n\"\n\n// Never remove focus styles without replacement\n// ✗ outline-none (alone)\n// ✓ outline-none + focus-visible:ring\n```\n\n### Screen Readers\n```tsx\n// Visually hidden but accessible\nconst srOnly = \"absolute w-px h-px p-0 -m-px overflow-hidden whitespace-nowrap border-0\";\n\n// Icon buttons need labels\n<button aria-label=\"Close menu\">\n  <XIcon className=\"w-6 h-6\" />\n</button>\n\n// Announce dynamic content\n<div role=\"status\" aria-live=\"polite\">\n  {message}\n</div>\n```\n\n## Anti-Patterns\n\n### Never Do\n```\n✗ More than 3 font sizes on a page\n✗ Random spacing values (use 8px grid)\n✗ Pure black (#000) on pure white (#fff)\n✗ Colored text on colored backgrounds without checking contrast\n✗ Animations longer than 500ms for UI elements\n✗ Glassmorphism everywhere\n✗ Drop shadows on everything\n✗ Gradients on text (hard to read)\n✗ Auto-playing animations that can't be stopped\n✗ Removing focus indicators\n✗ Gray text below 4.5:1 contrast\n✗ Tiny click targets (< 44px)\n```\n\n### Common Mistakes\n```tsx\n// ✗ Too many shadows\nclassName=\"shadow-sm shadow-md shadow-lg\" // Pick ONE\n\n// ✗ Inconsistent rounding\nclassName=\"rounded-sm rounded-lg rounded-2xl\" // System: sm, lg, xl, 2xl\n\n// ✗ Competing focal points\n// One primary CTA per viewport\n\n// ✗ Over-decorated\n// If it doesn't serve function, remove it\n```\n\n## Quick Reference\n\n### Modern Defaults\n```tsx\n// Border radius: 12-16px (rounded-xl to rounded-2xl)\n// Shadow: subtle (shadow-sm to shadow-md)\n// Font: Inter, SF Pro, system-ui\n// Primary: Near-black or brand color\n// Transitions: 200ms ease-out\n// Spacing: 8px grid (Tailwind default)\n```\n\n### Premium Feel Checklist\n```\n□ Generous whitespace\n□ Subtle shadows (not harsh)\n□ Smooth transitions on all interactions\n□ Consistent border radius\n□ Limited color palette (2-3 colors max)\n□ Typography hierarchy (3 sizes max)\n□ High-quality imagery\n□ Micro-interactions on hover/focus\n□ Dark mode support\n```\n"
  },
  {
    "path": "skills/user-journeys/SKILL.md",
    "content": "---\nname: user-journeys\ndescription: User experience flows - journey mapping, UX validation, error recovery\nwhen-to-use: When mapping user flows, validating UX, or designing error recovery\nuser-invocable: false\neffort: medium\n---\n\n# User Journeys Skill\n\n\nFor defining and testing real user experiences - not just specs, but actual flows humans take through your application.\n\n---\n\n## Philosophy\n\n**Specs test features. Journeys test experiences.**\n\nA feature can pass all specs but still deliver a terrible experience. User journeys capture:\n- How users actually navigate (not how we think they should)\n- Emotional states at each step (frustrated, confused, delighted)\n- Recovery from mistakes (users will make them)\n- Real-world conditions (slow networks, interruptions, distractions)\n\n---\n\n## Journey Documentation Structure\n\n```\n_project_specs/\n├── journeys/\n│   ├── _template.md              # Journey template\n│   ├── critical/                 # Must-work journeys (revenue, core value)\n│   │   ├── signup-to-first-value.md\n│   │   ├── checkout-purchase.md\n│   │   └── login-to-dashboard.md\n│   ├── common/                   # Frequent user paths\n│   │   ├── browse-and-search.md\n│   │   ├── update-profile.md\n│   │   └── invite-team-member.md\n│   └── edge-cases/               # Error recovery, unusual paths\n│       ├── payment-failure-retry.md\n│       ├── session-timeout-recovery.md\n│       └── offline-reconnection.md\n```\n\n---\n\n## Journey Template\n\n```markdown\n# Journey: [Name]\n\n## Overview\n| Attribute | Value |\n|-----------|-------|\n| **Priority** | Critical / High / Medium |\n| **User Type** | New / Returning / Admin |\n| **Frequency** | Daily / Weekly / One-time |\n| **Success Metric** | Conversion rate, time to complete, drop-off rate |\n\n## User Goal\nWhat is the user trying to accomplish? Write from their perspective.\n\n> \"I want to [goal] so that I can [benefit].\"\n\n## Preconditions\n- User state (logged in, has subscription, first visit)\n- Data state (has items in cart, has team members)\n- Environment (mobile, desktop, slow connection)\n\n## Journey Steps\n\n### Step 1: [Entry Point]\n**User Action:** What the user does\n**System Response:** What they should see/experience\n**Success Criteria:**\n- [ ] Page loads in < 2 seconds\n- [ ] Primary CTA is immediately visible\n- [ ] User understands what to do next\n\n**Potential Friction:**\n- Slow load time → Show skeleton/loader\n- Unclear CTA → A/B test copy variations\n\n---\n\n### Step 2: [Next Action]\n**User Action:** ...\n**System Response:** ...\n**Success Criteria:**\n- [ ] ...\n\n**Potential Friction:**\n- ...\n\n---\n\n## Error Scenarios\n\n### E1: [Error Name]\n**Trigger:** What causes this error\n**User Sees:** Error message/state\n**Recovery Path:** How user gets back on track\n**Test:** How to verify recovery works\n\n## Metrics to Track\n- Time to complete journey\n- Drop-off rate at each step\n- Error rate and recovery rate\n- User satisfaction (if surveyed)\n\n## E2E Test Reference\nLink to Playwright test: `e2e/tests/journeys/[name].spec.ts`\n```\n\n---\n\n## Critical Journey Examples\n\n### Signup to First Value\n\n```markdown\n# Journey: Signup to First Value\n\n## Overview\n| Attribute | Value |\n|-----------|-------|\n| **Priority** | Critical |\n| **User Type** | New |\n| **Frequency** | One-time |\n| **Success Metric** | % reaching \"aha moment\" within 5 min |\n\n## User Goal\n> \"I want to try this product quickly to see if it solves my problem.\"\n\n## Preconditions\n- First visit to site\n- No account\n- Came from landing page or ad\n\n## Journey Steps\n\n### Step 1: Landing Page\n**User Action:** Clicks \"Get Started Free\" or \"Try Now\"\n**System Response:** Signup form appears (modal or new page)\n**Success Criteria:**\n- [ ] CTA visible above fold\n- [ ] No distracting elements\n- [ ] Clear value proposition visible\n\n**Potential Friction:**\n- Too many form fields → Reduce to email + password only\n- Social login missing → Add Google/GitHub options\n\n### Step 2: Account Creation\n**User Action:** Enters email and password (or uses social login)\n**System Response:**\n- Creates account\n- Sends verification email (don't block on it)\n- Redirects to onboarding\n\n**Success Criteria:**\n- [ ] Account created in < 3 seconds\n- [ ] No email verification wall (verify later)\n- [ ] Clear next step shown\n\n**Potential Friction:**\n- Email already exists → Offer login link\n- Weak password → Show requirements inline, not after submit\n\n### Step 3: Onboarding (Quick Win)\n**User Action:** Completes 1-2 setup questions\n**System Response:**\n- Personalizes experience\n- Shows progress indicator\n- Leads to first action\n\n**Success Criteria:**\n- [ ] Max 3 questions\n- [ ] Skip option available\n- [ ] < 60 seconds total\n\n**Potential Friction:**\n- Too many questions → User abandons\n- No skip option → User feels trapped\n\n### Step 4: First Value (Aha Moment)\n**User Action:** Completes core action (creates first X, sees first result)\n**System Response:**\n- Celebrates success\n- Shows value delivered\n- Suggests next step\n\n**Success Criteria:**\n- [ ] User experiences core value\n- [ ] Completion feels rewarding\n- [ ] Clear path to continue\n\n## Error Scenarios\n\n### E1: Email Already Registered\n**Trigger:** User tries existing email\n**User Sees:** \"Already have an account? Log in or reset password\"\n**Recovery Path:** Click to login or reset\n**Test:** `signup-existing-email.spec.ts`\n\n### E2: Social Login Fails\n**Trigger:** OAuth provider error\n**User Sees:** \"Couldn't connect. Try email signup or try again.\"\n**Recovery Path:** Email signup form shown as fallback\n**Test:** `social-login-failure.spec.ts`\n\n## Metrics to Track\n- Signup → First Value: Target < 5 min\n- Drop-off at each step\n- Social vs email signup ratio\n- Skip rate on onboarding\n```\n\n---\n\n### Checkout Purchase\n\n```markdown\n# Journey: Checkout Purchase\n\n## Overview\n| Attribute | Value |\n|-----------|-------|\n| **Priority** | Critical (Revenue) |\n| **User Type** | Any |\n| **Frequency** | Variable |\n| **Success Metric** | Checkout completion rate |\n\n## User Goal\n> \"I want to pay quickly and securely without surprises.\"\n\n## Journey Steps\n\n### Step 1: Cart Review\n**User Action:** Views cart before checkout\n**System Response:**\n- Shows all items with images, prices\n- Shows subtotal, taxes, shipping\n- Clear \"Checkout\" CTA\n\n**Success Criteria:**\n- [ ] No hidden fees revealed later\n- [ ] Easy to modify quantities\n- [ ] Saved items visible\n\n### Step 2: Checkout Start\n**User Action:** Clicks \"Checkout\"\n**System Response:**\n- Shows checkout form or redirect to payment\n- Progress indicator (Step 1 of 3)\n- Order summary sidebar\n\n**Success Criteria:**\n- [ ] Guest checkout option\n- [ ] Express checkout (Apple/Google Pay) prominent\n- [ ] Form fields pre-filled if logged in\n\n### Step 3: Payment\n**User Action:** Enters payment info\n**System Response:**\n- Secure input fields (Stripe/payment provider)\n- Real-time validation\n- Clear \"Pay $XX\" button\n\n**Success Criteria:**\n- [ ] Card validation inline, not after submit\n- [ ] Multiple payment options\n- [ ] Security indicators visible\n\n### Step 4: Confirmation\n**User Action:** Submits payment\n**System Response:**\n- Processing indicator\n- Success page with order details\n- Email confirmation sent\n\n**Success Criteria:**\n- [ ] Confirmation within 5 seconds\n- [ ] Order number clearly visible\n- [ ] Next steps clear (shipping, access, etc.)\n\n## Error Scenarios\n\n### E1: Payment Declined\n**Trigger:** Card declined by processor\n**User Sees:** \"Payment declined. Please try another card.\"\n**Recovery Path:**\n- Stay on payment step\n- Pre-fill other fields\n- Offer alternative payment methods\n**Test:** `payment-declined-recovery.spec.ts`\n\n### E2: Session Timeout During Checkout\n**Trigger:** User away too long\n**User Sees:** Cart preserved, re-auth required\n**Recovery Path:**\n- Quick login\n- Return to same checkout step\n- Cart contents intact\n**Test:** `checkout-session-timeout.spec.ts`\n```\n\n---\n\n## Journey Testing with Playwright\n\n### Journey Test Structure\n\n```typescript\n// e2e/tests/journeys/signup-to-value.spec.ts\nimport { test, expect } from '@playwright/test';\n\ntest.describe('Journey: Signup to First Value', () => {\n  test.describe.configure({ mode: 'serial' }); // Run in order\n\n  test('Step 1: Landing page has clear CTA', async ({ page }) => {\n    await page.goto('/');\n\n    // CTA visible above fold without scrolling\n    const cta = page.getByRole('button', { name: /get started|try free/i });\n    await expect(cta).toBeVisible();\n    await expect(cta).toBeInViewport();\n  });\n\n  test('Step 2: Can create account quickly', async ({ page }) => {\n    await page.goto('/');\n    await page.getByRole('button', { name: /get started/i }).click();\n\n    // Minimal fields\n    await expect(page.getByLabel('Email')).toBeVisible();\n    await expect(page.getByLabel('Password')).toBeVisible();\n\n    // Complete signup\n    const startTime = Date.now();\n    await page.getByLabel('Email').fill('newuser@example.com');\n    await page.getByLabel('Password').fill('SecurePass123!');\n    await page.getByRole('button', { name: /sign up|create/i }).click();\n\n    // Should reach onboarding quickly\n    await expect(page).toHaveURL(/onboarding|welcome|setup/);\n    expect(Date.now() - startTime).toBeLessThan(5000); // < 5 seconds\n  });\n\n  test('Step 3: Onboarding is skippable', async ({ page }) => {\n    // ... login as new user ...\n    await page.goto('/onboarding');\n\n    // Skip option exists\n    const skipButton = page.getByRole('button', { name: /skip/i });\n    await expect(skipButton).toBeVisible();\n  });\n\n  test('Step 4: Can reach first value in < 5 min', async ({ page }) => {\n    // Full journey timing\n    const journeyStart = Date.now();\n\n    // ... complete full journey ...\n\n    // Verify first value delivered\n    await expect(page.getByText(/success|created|done/i)).toBeVisible();\n\n    // Total time check\n    const totalTime = (Date.now() - journeyStart) / 1000 / 60; // minutes\n    expect(totalTime).toBeLessThan(5);\n  });\n});\n```\n\n### Error Recovery Tests\n\n```typescript\n// e2e/tests/journeys/checkout-recovery.spec.ts\nimport { test, expect } from '@playwright/test';\n\ntest.describe('Journey: Checkout Error Recovery', () => {\n  test('recovers from payment decline gracefully', async ({ page }) => {\n    // Setup: Add item to cart, go to checkout\n    await page.goto('/products');\n    await page.getByTestId('add-to-cart').first().click();\n    await page.getByRole('link', { name: 'Checkout' }).click();\n\n    // Use Stripe test card that declines\n    const stripeFrame = page.frameLocator('iframe[name*=\"stripe\"]');\n    await stripeFrame.getByPlaceholder('Card number').fill('4000000000000002');\n    await stripeFrame.getByPlaceholder('MM / YY').fill('12/30');\n    await stripeFrame.getByPlaceholder('CVC').fill('123');\n\n    await page.getByRole('button', { name: /pay/i }).click();\n\n    // Verify friendly error\n    await expect(page.getByText(/declined|try another/i)).toBeVisible();\n\n    // Verify still on checkout (not kicked out)\n    await expect(page).toHaveURL(/checkout/);\n\n    // Verify can try again with different card\n    await stripeFrame.getByPlaceholder('Card number').fill('4242424242424242');\n    await page.getByRole('button', { name: /pay/i }).click();\n\n    // Should succeed now\n    await expect(page).toHaveURL(/success|confirmation/);\n  });\n\n  test('preserves cart after session timeout', async ({ page, context }) => {\n    // Add items to cart\n    await page.goto('/products');\n    await page.getByTestId('add-to-cart').first().click();\n\n    // Clear session (simulate timeout)\n    await context.clearCookies();\n\n    // Return to site\n    await page.goto('/cart');\n\n    // Cart should be preserved (local storage or recovered)\n    await expect(page.getByTestId('cart-item')).toHaveCount(1);\n  });\n});\n```\n\n---\n\n## User Experience Validation\n\n### UX Checklist per Journey Step\n\n```markdown\n## UX Validation Checklist\n\n### Clarity\n- [ ] User knows where they are (breadcrumbs, progress)\n- [ ] User knows what to do next (clear CTA)\n- [ ] User knows what just happened (feedback)\n\n### Speed\n- [ ] Page loads < 2 seconds\n- [ ] Actions complete < 3 seconds\n- [ ] Progress shown for longer operations\n\n### Forgiveness\n- [ ] Mistakes are easy to undo\n- [ ] Errors explain what went wrong\n- [ ] Recovery path is clear\n\n### Accessibility\n- [ ] Keyboard navigation works\n- [ ] Screen reader announces changes\n- [ ] Focus management correct\n- [ ] Color contrast sufficient\n\n### Mobile\n- [ ] Touch targets >= 44px\n- [ ] No horizontal scroll\n- [ ] Forms don't zoom unexpectedly\n- [ ] Works on slow 3G\n```\n\n### Automated UX Checks\n\n```typescript\n// e2e/utils/ux-validators.ts\nimport { Page, expect } from '@playwright/test';\n\nexport async function validatePageLoad(page: Page, maxMs = 2000) {\n  const timing = await page.evaluate(() => {\n    const nav = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;\n    return nav.loadEventEnd - nav.startTime;\n  });\n  expect(timing).toBeLessThan(maxMs);\n}\n\nexport async function validateCTAVisible(page: Page, ctaText: RegExp) {\n  const cta = page.getByRole('button', { name: ctaText });\n  await expect(cta).toBeVisible();\n  await expect(cta).toBeInViewport();\n}\n\nexport async function validateNoLayoutShift(page: Page) {\n  const cls = await page.evaluate(() => {\n    return new Promise<number>((resolve) => {\n      let clsValue = 0;\n      const observer = new PerformanceObserver((list) => {\n        for (const entry of list.getEntries()) {\n          if (!(entry as any).hadRecentInput) {\n            clsValue += (entry as any).value;\n          }\n        }\n      });\n      observer.observe({ type: 'layout-shift', buffered: true });\n      setTimeout(() => {\n        observer.disconnect();\n        resolve(clsValue);\n      }, 1000);\n    });\n  });\n  expect(cls).toBeLessThan(0.1); // Good CLS score\n}\n\nexport async function validateAccessibility(page: Page) {\n  // Check focus visible on interactive elements\n  const buttons = page.getByRole('button');\n  const count = await buttons.count();\n\n  for (let i = 0; i < Math.min(count, 5); i++) {\n    await buttons.nth(i).focus();\n    await expect(buttons.nth(i)).toBeFocused();\n  }\n}\n```\n\n---\n\n## Journey Metrics Dashboard\n\nTrack journey health with these metrics:\n\n```typescript\n// lib/journey-metrics.ts\ninterface JourneyMetric {\n  journey: string;\n  step: string;\n  timestamp: Date;\n  duration: number;\n  success: boolean;\n  userId?: string;\n}\n\n// Track in your analytics (PostHog, Mixpanel, etc.)\nexport function trackJourneyStep(metric: JourneyMetric) {\n  analytics.track('journey_step', {\n    journey_name: metric.journey,\n    step_name: metric.step,\n    duration_ms: metric.duration,\n    success: metric.success,\n  });\n}\n\n// Example usage in app\nconst journeyStart = Date.now();\n// ... user completes step ...\ntrackJourneyStep({\n  journey: 'signup_to_value',\n  step: 'account_creation',\n  timestamp: new Date(),\n  duration: Date.now() - journeyStart,\n  success: true,\n});\n```\n\n---\n\n## Common Journey Patterns\n\n### Progressive Disclosure Journey\nUser sees simple view first, complexity revealed as needed.\n\n```markdown\nStep 1: Show basic options only\nStep 2: \"Advanced\" expands more options\nStep 3: Expert mode unlocks everything\n```\n\n### Guided Setup Journey\nHand-hold new users through initial configuration.\n\n```markdown\nStep 1: Welcome + single choice\nStep 2: Core preference\nStep 3: Optional integrations (skippable)\nStep 4: First action with guidance\nStep 5: Success + remove training wheels\n```\n\n### Recovery Journey\nUser returns after failure or abandonment.\n\n```markdown\nStep 1: Recognize returning user\nStep 2: Restore previous state\nStep 3: Acknowledge what happened\nStep 4: Offer clear path forward\nStep 5: Complete original goal\n```\n\n---\n\n## Anti-Patterns\n\n- **Happy path only** - Test error recovery, not just success\n- **Spec-driven testing** - Test user goals, not features\n- **Ignoring time** - Measure how long journeys take\n- **Desktop-only** - Test mobile journeys separately\n- **Skipping emotions** - Consider user frustration points\n- **No metrics** - Track journey completion and drop-off\n- **Static journeys** - Update as user behavior evolves\n\n---\n\n## Quick Reference\n\n### Journey Priorities\n| Priority | Criteria | Test Frequency |\n|----------|----------|----------------|\n| Critical | Revenue, core value | Every deploy |\n| High | Daily user actions | Daily |\n| Medium | Weekly features | Weekly |\n| Low | Edge cases | On change |\n\n### Package.json Scripts\n\n```json\n{\n  \"scripts\": {\n    \"test:journeys\": \"playwright test e2e/tests/journeys/\",\n    \"test:journeys:critical\": \"playwright test e2e/tests/journeys/critical/\",\n    \"test:journeys:report\": \"playwright show-report\"\n  }\n}\n```\n\n### Journey Documentation Checklist\n- [ ] User goal clearly stated\n- [ ] All steps documented\n- [ ] Success criteria per step\n- [ ] Error scenarios covered\n- [ ] Recovery paths defined\n- [ ] Metrics identified\n- [ ] E2E test linked\n"
  },
  {
    "path": "skills/web-content/SKILL.md",
    "content": "---\nname: web-content\ndescription: SEO and AI discovery (GEO) - schema, ChatGPT/Perplexity optimization\nwhen-to-use: When creating web content that needs SEO and AI discoverability\nuser-invocable: false\neffort: medium\n---\n\n# Web Content Skill\n\n\nFor creating web content optimized for both traditional SEO and AI discovery (ChatGPT, Perplexity, Claude, Gemini).\n\n**Sources:** [GEO Complete Guide](https://skale.so/marketing/geo/) | [AI Search SEO](https://www.gravitatedesign.com/blog/ai-search-seo/) | [LLM Optimization](https://surferseo.com/blog/llm-optimization-seo/) | [Generative Engine Optimization](https://www.siddharthbharath.com/generative-engine-optimization/)\n\n---\n\n## Philosophy\n\n**SEO gets clicks. GEO gets citations.**\n\nTraditional SEO optimizes for Google rankings. Generative Engine Optimization (GEO) optimizes for being cited by AI assistants. Modern content needs both:\n\n- **SEO**: Rank on search results pages\n- **GEO**: Be cited in AI-generated answers (ChatGPT, Perplexity, Claude, Gemini)\n\nAI traffic grew 1,200% between July 2024 and February 2025. Google's search share dropped below 90% for the first time in a decade. Optimize for both.\n\n---\n\n## Content Structure for AI + SEO\n\n### The Golden Rule\n\n**Write for humans, structure for machines.**\n\nAI systems prefer:\n- Short, clear, fact-based content\n- Clean formatting (headers, bullets, tables)\n- Standalone sections that can be quoted\n- Direct answers to questions\n\n---\n\n## Page Types & Templates\n\n### Homepage\n\n```markdown\n## Homepage Structure\n\n### Above the Fold\n- **Headline**: Clear value proposition (what you do + for whom)\n- **Subheadline**: How you deliver that value\n- **Primary CTA**: One clear action\n- **Trust signals**: Logos, testimonials, stats\n\n### Content Sections\n1. **Problem Statement**: Pain point you solve\n2. **Solution Overview**: How you solve it (3-4 key features)\n3. **Social Proof**: Testimonials, case studies, logos\n4. **How It Works**: 3-step process (simple)\n5. **Pricing Preview**: Or link to pricing page\n6. **FAQ Section**: 5-7 common questions (GEO gold)\n7. **Final CTA**: Repeat primary action\n\n### Schema Required\n- Organization schema (name, logo, founding date, social links)\n- WebSite schema with SearchAction\n- FAQ schema for questions section\n```\n\n### Product/Service Page\n\n```markdown\n## Product Page Structure\n\n### Hero Section\n- **Product Name**: Clear, descriptive\n- **One-line Description**: What it does in 10 words or less\n- **Key Benefit**: Primary value proposition\n- **CTA**: Buy/Try/Demo\n\n### Content Sections\n1. **TL;DR Box**: 3-5 bullet summary (AI-quotable)\n2. **Problem → Solution**: What problem, how solved\n3. **Features Grid**: 4-6 features with icons\n4. **Comparison Table**: vs. alternatives (GEO loves these)\n5. **Use Cases**: Who uses it and how\n6. **Testimonials**: Real names, photos, companies\n7. **Pricing**: Clear tiers if applicable\n8. **FAQ**: Product-specific questions\n\n### Schema Required\n- Product schema (name, description, price, availability)\n- Review schema (aggregate rating)\n- FAQ schema\n- BreadcrumbList schema\n```\n\n### Blog Post / Article\n\n```markdown\n## Blog Post Structure\n\n### Opening (First 100 words)\n- **TL;DR**: Direct answer to the title's question\n- **What you'll learn**: Bullet list of takeaways\n- This section should be quotable standalone\n\n### Body Structure\n- **H2 sections**: Main topics (5-7 per article)\n- **H3 subsections**: Supporting points\n- **Bullet lists**: For scanability\n- **Stat boxes**: Highlight key numbers\n- **Comparison tables**: When comparing options\n\n### Content Elements\n- Definition boxes (\"What is X?\")\n- Step-by-step instructions\n- Code examples (if technical)\n- Original statistics/research\n- Expert quotes with attribution\n\n### Closing\n- **Summary**: Key takeaways (bulleted)\n- **Next steps**: What reader should do\n- **Related content**: Internal links\n\n### Metadata Required\n- Author name + bio + photo\n- Publication date\n- Last updated date (visible!)\n- Reading time\n- Article schema with author\n```\n\n### FAQ Page\n\n```markdown\n## FAQ Page Structure\n\n### Organization\n- Group questions by category\n- Most common questions first\n- Direct, concise answers\n- Link to detailed pages for more info\n\n### Question Format\nQ: [Exact question users ask]\nA: [Direct answer in first sentence, then elaboration]\n\n### Schema Required\n- FAQPage schema (critical for AI discovery)\n- Each Q&A as Question/Answer schema\n```\n\n### Landing Page\n\n```markdown\n## Landing Page Structure\n\n### Single Focus\n- One offer\n- One audience\n- One CTA (repeated)\n\n### Sections\n1. **Headline**: Benefit-focused, specific\n2. **Problem Agitation**: Pain points\n3. **Solution**: Your offer\n4. **Proof**: Testimonials, stats, logos\n5. **Features**: 3-5 key benefits\n6. **Objection Handling**: FAQ or guarantee\n7. **CTA**: Clear, urgent\n\n### No Navigation\n- Remove header nav (reduce exits)\n- Single path: read → convert\n```\n\n---\n\n## AI-Optimized Content Formats\n\n### TL;DR Boxes\n\n```html\n<div class=\"tldr-box\">\n  <h3>TL;DR</h3>\n  <ul>\n    <li>Key point 1 with specific detail</li>\n    <li>Key point 2 with number/stat</li>\n    <li>Key point 3 with actionable insight</li>\n  </ul>\n</div>\n```\n\nPlace at top of articles. AI systems extract these for summaries.\n\n### Definition Blocks\n\n```markdown\n## What is [Term]?\n\n[Term] is [concise definition in one sentence]. It [what it does] by [how it works].\n\n**Key characteristics:**\n- Characteristic 1\n- Characteristic 2\n- Characteristic 3\n```\n\nStart with \"What is X?\" - AI systems look for this pattern.\n\n### Comparison Tables\n\n```markdown\n| Feature | Product A | Product B | Our Product |\n|---------|-----------|-----------|-------------|\n| Price | $99/mo | $149/mo | $79/mo |\n| Feature 1 | ✓ | ✗ | ✓ |\n| Feature 2 | ✗ | ✓ | ✓ |\n| Best For | Enterprise | Startups | SMBs |\n```\n\nAI loves structured comparisons. Include in product and review pages.\n\n### Stat Boxes\n\n```html\n<div class=\"stat-box\">\n  <span class=\"stat-number\">73%</span>\n  <span class=\"stat-label\">of users prefer AI search for complex queries</span>\n  <span class=\"stat-source\">Source: Adobe Analytics, 2024</span>\n</div>\n```\n\nOriginal statistics with sources get cited by AI.\n\n### Step-by-Step Guides\n\n```markdown\n## How to [Do Thing]\n\n### Step 1: [Action Verb] [Object]\n[Explanation of what to do]\n\n**Example:**\n[Concrete example]\n\n### Step 2: [Action Verb] [Object]\n[Explanation]\n\n### Step 3: [Action Verb] [Object]\n[Explanation]\n\n**Result:** [What user achieves]\n```\n\nUse HowTo schema markup for these.\n\n---\n\n## Schema Markup (Critical for AI)\n\n### Organization Schema\n\n```json\n{\n  \"@context\": \"https://schema.org\",\n  \"@type\": \"Organization\",\n  \"name\": \"Your Company\",\n  \"url\": \"https://yoursite.com\",\n  \"logo\": \"https://yoursite.com/logo.png\",\n  \"foundingDate\": \"2020\",\n  \"description\": \"One sentence description\",\n  \"sameAs\": [\n    \"https://twitter.com/yourcompany\",\n    \"https://linkedin.com/company/yourcompany\",\n    \"https://github.com/yourcompany\"\n  ],\n  \"contactPoint\": {\n    \"@type\": \"ContactPoint\",\n    \"email\": \"hello@yoursite.com\",\n    \"contactType\": \"customer service\"\n  }\n}\n```\n\n### Article Schema\n\n```json\n{\n  \"@context\": \"https://schema.org\",\n  \"@type\": \"Article\",\n  \"headline\": \"Article Title\",\n  \"description\": \"Meta description\",\n  \"image\": \"https://yoursite.com/article-image.jpg\",\n  \"author\": {\n    \"@type\": \"Person\",\n    \"name\": \"Author Name\",\n    \"url\": \"https://yoursite.com/team/author-name\",\n    \"jobTitle\": \"Role at Company\",\n    \"sameAs\": [\n      \"https://linkedin.com/in/author\",\n      \"https://twitter.com/author\"\n    ]\n  },\n  \"publisher\": {\n    \"@type\": \"Organization\",\n    \"name\": \"Your Company\",\n    \"logo\": {\n      \"@type\": \"ImageObject\",\n      \"url\": \"https://yoursite.com/logo.png\"\n    }\n  },\n  \"datePublished\": \"2025-01-15\",\n  \"dateModified\": \"2025-01-20\"\n}\n```\n\n### FAQ Schema\n\n```json\n{\n  \"@context\": \"https://schema.org\",\n  \"@type\": \"FAQPage\",\n  \"mainEntity\": [\n    {\n      \"@type\": \"Question\",\n      \"name\": \"What is your product?\",\n      \"acceptedAnswer\": {\n        \"@type\": \"Answer\",\n        \"text\": \"Direct answer here. Keep concise but complete.\"\n      }\n    },\n    {\n      \"@type\": \"Question\",\n      \"name\": \"How much does it cost?\",\n      \"acceptedAnswer\": {\n        \"@type\": \"Answer\",\n        \"text\": \"Pricing starts at $X/month for basic plan...\"\n      }\n    }\n  ]\n}\n```\n\n### Product Schema\n\n```json\n{\n  \"@context\": \"https://schema.org\",\n  \"@type\": \"Product\",\n  \"name\": \"Product Name\",\n  \"description\": \"Product description\",\n  \"image\": \"https://yoursite.com/product.jpg\",\n  \"brand\": {\n    \"@type\": \"Brand\",\n    \"name\": \"Your Company\"\n  },\n  \"offers\": {\n    \"@type\": \"Offer\",\n    \"price\": \"29.99\",\n    \"priceCurrency\": \"USD\",\n    \"availability\": \"https://schema.org/InStock\"\n  },\n  \"aggregateRating\": {\n    \"@type\": \"AggregateRating\",\n    \"ratingValue\": \"4.8\",\n    \"reviewCount\": \"127\"\n  }\n}\n```\n\n### HowTo Schema\n\n```json\n{\n  \"@context\": \"https://schema.org\",\n  \"@type\": \"HowTo\",\n  \"name\": \"How to Set Up Your Account\",\n  \"description\": \"Step-by-step guide to getting started\",\n  \"step\": [\n    {\n      \"@type\": \"HowToStep\",\n      \"name\": \"Create account\",\n      \"text\": \"Go to signup page and enter your email\"\n    },\n    {\n      \"@type\": \"HowToStep\",\n      \"name\": \"Verify email\",\n      \"text\": \"Click the link in the verification email\"\n    }\n  ]\n}\n```\n\n---\n\n## Platform-Specific Optimization\n\n### ChatGPT Optimization\n\n```markdown\n✅ DO:\n- TL;DR sections at top of articles\n- Consistent formatting (headers, bullets)\n- Named authors with credentials\n- Original research and statistics\n- Multi-intent content (covers related questions)\n\n❌ AVOID:\n- Thin content without substance\n- Missing author attribution\n- Outdated information (no dates)\n```\n\n### Perplexity Optimization\n\n```markdown\n✅ DO:\n- Original statistics with sources\n- Comparison tables and structured data\n- Clean URL slugs (/topic-name not /p=123)\n- Short, declarative statements\n- Images, charts, diagrams\n- YouTube videos (Perplexity shows these)\n\n❌ AVOID:\n- Generic content without unique insights\n- Missing citations/sources\n- Poor URL structure\n```\n\n### Claude Optimization\n\n```markdown\n✅ DO:\n- Well-structured, logical content\n- Clear definitions and explanations\n- Technical accuracy\n- Balanced perspectives\n- Proper citations\n\n❌ AVOID:\n- Misleading or sensational content\n- Missing context\n- Outdated technical information\n```\n\n### Gemini Optimization\n\n```markdown\n✅ DO:\n- Rich schema markup\n- Detailed image alt-text\n- YouTube content (Google-owned)\n- Multimedia (video, audio with transcripts)\n\n❌ AVOID:\n- Missing structured data\n- Images without alt-text\n- Text-only content\n```\n\n---\n\n## E-E-A-T for AI Discovery\n\n### Experience\n- First-person case studies\n- \"We tested X and found Y\"\n- Original screenshots and data\n- User testimonials with real details\n\n### Expertise\n- Author bios with credentials\n- Link to author's other work\n- Industry-specific terminology\n- Technical depth appropriate to topic\n\n### Authoritativeness\n- Backlinks from trusted sources\n- Mentions in industry publications\n- Citations from other experts\n- Social proof (followers, engagement)\n\n### Trustworthiness\n- Contact information visible\n- About page with team details\n- Privacy policy and terms\n- Secure site (HTTPS)\n- Accurate, up-to-date info\n\n---\n\n## Content Freshness\n\n### Visible Dates (Required)\n\n```html\n<article>\n  <header>\n    <h1>Article Title</h1>\n    <div class=\"meta\">\n      <span class=\"author\">By John Smith</span>\n      <span class=\"published\">Published: January 15, 2025</span>\n      <span class=\"updated\">Last updated: January 20, 2025</span>\n    </div>\n  </header>\n</article>\n```\n\nAI systems prefer recent content. Show dates prominently.\n\n### Update Schedule\n\n| Content Type | Update Frequency |\n|--------------|------------------|\n| Product pages | On feature changes |\n| Pricing | Immediately on change |\n| Blog posts | Quarterly review |\n| Statistics | When new data available |\n| Guides | Semi-annually |\n\n---\n\n## Analytics for AI Traffic\n\n### GA4 Regex Filter\n\n```regex\n.*chatgpt\\.com.*|.*perplexity\\.ai.*|.*gemini\\.google\\.com.*|.*copilot\\.microsoft\\.com.*|.*openai\\.com.*|.*claude\\.ai.*|.*poe\\.com.*|.*you\\.com.*|.*phind\\.com.*\n```\n\n### Track AI Referrals\n\n```javascript\n// Check for AI referrer\nconst aiReferrers = [\n  'chatgpt.com',\n  'chat.openai.com',\n  'perplexity.ai',\n  'claude.ai',\n  'gemini.google.com',\n  'copilot.microsoft.com',\n  'poe.com',\n  'you.com',\n  'phind.com'\n];\n\nconst referrer = document.referrer;\nconst isAIReferral = aiReferrers.some(ai => referrer.includes(ai));\n\nif (isAIReferral) {\n  analytics.track('ai_referral', {\n    source: referrer,\n    page: window.location.pathname\n  });\n}\n```\n\n### Survey for AI Discovery\n\nAdd to forms:\n```markdown\nHow did you hear about us?\n- [ ] Google Search\n- [ ] ChatGPT\n- [ ] Perplexity\n- [ ] Claude\n- [ ] Social Media\n- [ ] Referral\n- [ ] Other\n```\n\n---\n\n## Content Checklist\n\n### Before Publishing\n\n```markdown\n## SEO Checklist\n- [ ] Title tag (50-60 chars) with primary keyword\n- [ ] Meta description (150-160 chars) with CTA\n- [ ] URL slug is clean and descriptive\n- [ ] H1 matches title intent\n- [ ] H2/H3 hierarchy is logical\n- [ ] Images have descriptive alt-text\n- [ ] Internal links to related content\n- [ ] External links to authoritative sources\n\n## GEO Checklist\n- [ ] TL;DR or summary at top\n- [ ] Direct answer to main question in first paragraph\n- [ ] Stat boxes with sources\n- [ ] Comparison tables where applicable\n- [ ] FAQ section with schema\n- [ ] Author name, bio, and credentials\n- [ ] Publication and last-updated dates visible\n- [ ] Schema markup validated\n- [ ] Content can be quoted standalone\n- [ ] Original insights or data included\n```\n\n### Schema Validation\n\n```bash\n# Validate schema markup\n# Use: https://validator.schema.org/\n# Or: https://search.google.com/test/rich-results\n```\n\n---\n\n## Project Structure\n\n```\nproject/\n├── content/\n│   ├── pages/\n│   │   ├── home.md\n│   │   ├── about.md\n│   │   ├── pricing.md\n│   │   └── contact.md\n│   ├── blog/\n│   │   ├── post-1.md\n│   │   └── post-2.md\n│   └── legal/\n│       ├── privacy.md\n│       └── terms.md\n├── components/\n│   ├── SchemaMarkup.tsx\n│   ├── TLDRBox.tsx\n│   ├── StatBox.tsx\n│   ├── FAQSection.tsx\n│   └── AuthorBio.tsx\n└── lib/\n    └── schema.ts           # Schema generators\n```\n\n---\n\n## Anti-Patterns\n\n- **No dates** - AI deprioritizes undated content\n- **Anonymous content** - No author = no E-E-A-T\n- **Walls of text** - Break up with headers, bullets, boxes\n- **Generic content** - Add original insights, data, opinions\n- **Missing schema** - Invisible to structured data crawlers\n- **Outdated info** - Update quarterly minimum\n- **No FAQ** - Missing easy GEO win\n- **Poor URL structure** - Use /topic-name not /p=12345\n\n---\n\n## Quick Reference\n\n### Content Formats AI Loves\n1. TL;DR summaries\n2. Definition boxes (\"What is X?\")\n3. Comparison tables\n4. Step-by-step guides\n5. FAQ sections\n6. Stat boxes with sources\n7. Listicles with numbers\n\n### Required Schema by Page Type\n\n| Page Type | Schema |\n|-----------|--------|\n| Homepage | Organization, WebSite |\n| Blog Post | Article, Author, FAQ |\n| Product | Product, Review, FAQ |\n| FAQ | FAQPage |\n| How-to | HowTo |\n| About | Organization, Person |\n"
  },
  {
    "path": "skills/web-payments/SKILL.md",
    "content": "---\nname: web-payments\ndescription: Stripe Checkout, subscriptions, webhooks, customer portal\nwhen-to-use: When implementing payments, subscriptions, or Stripe integration\nuser-invocable: false\neffort: high\n---\n\n# Web Payments Skill (Stripe)\n\n\nFor integrating Stripe payments into web applications - one-time payments, subscriptions, and checkout flows.\n\n**Sources:** [Stripe Checkout](https://docs.stripe.com/payments/checkout) | [Payment Element Best Practices](https://docs.stripe.com/payments/payment-element/best-practices) | [Building Solid Stripe Integrations](https://stripe.dev/blog/building-solid-stripe-integrations-developers-guide-success) | [Subscriptions](https://docs.stripe.com/billing/subscriptions/build-subscriptions)\n\n---\n\n## Setup\n\n### 1. Create Stripe Account\n1. Go to https://dashboard.stripe.com/register\n2. Complete business verification\n3. Get API keys from https://dashboard.stripe.com/apikeys\n\n### 2. Environment Variables\n```bash\n# .env\nSTRIPE_SECRET_KEY=sk_test_xxx          # Server-side only\nSTRIPE_PUBLISHABLE_KEY=pk_test_xxx     # Client-side safe\nSTRIPE_WEBHOOK_SECRET=whsec_xxx        # For webhook verification\n\n# Production\nSTRIPE_SECRET_KEY=sk_live_xxx\nSTRIPE_PUBLISHABLE_KEY=pk_live_xxx\n```\n\n### 3. Install SDK\n```bash\n# Node.js\nnpm install stripe @stripe/stripe-js\n\n# Python\npip install stripe\n```\n\n---\n\n## Integration Options\n\n| Method | Best For | Complexity |\n|--------|----------|------------|\n| **Checkout (Hosted)** | Quick setup, Stripe-hosted page | Low |\n| **Checkout (Embedded)** | Custom site, embedded form | Low |\n| **Payment Element** | Full customization, complex flows | Medium |\n| **Custom Form** | Complete control (rare) | High |\n\n**Recommendation**: Start with Checkout, migrate to Payment Element if needed.\n\n---\n\n## Stripe Checkout (Recommended)\n\n### Server: Create Checkout Session\n\n#### Node.js / Next.js\n```typescript\n// app/api/checkout/route.ts (Next.js App Router)\nimport Stripe from \"stripe\";\nimport { NextResponse } from \"next/server\";\n\nconst stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);\n\nexport async function POST(request: Request) {\n  const { priceId, mode = \"payment\" } = await request.json();\n\n  try {\n    const session = await stripe.checkout.sessions.create({\n      mode: mode as \"payment\" | \"subscription\",\n      payment_method_types: [\"card\"],\n      line_items: [\n        {\n          price: priceId,\n          quantity: 1,\n        },\n      ],\n      success_url: `${process.env.NEXT_PUBLIC_URL}/success?session_id={CHECKOUT_SESSION_ID}`,\n      cancel_url: `${process.env.NEXT_PUBLIC_URL}/canceled`,\n      // Optional: Link to existing customer\n      // customer: customerId,\n      // Optional: Collect shipping\n      // shipping_address_collection: { allowed_countries: [\"US\", \"CA\"] },\n      // Optional: Add metadata for tracking\n      metadata: {\n        userId: \"user_123\",\n        source: \"pricing_page\",\n      },\n    });\n\n    return NextResponse.json({ sessionId: session.id, url: session.url });\n  } catch (error) {\n    console.error(\"Stripe error:\", error);\n    return NextResponse.json({ error: \"Failed to create session\" }, { status: 500 });\n  }\n}\n```\n\n#### Python / FastAPI\n```python\n# app/api/checkout.py\nimport stripe\nfrom fastapi import APIRouter, HTTPException\nfrom pydantic import BaseModel\nimport os\n\nstripe.api_key = os.environ[\"STRIPE_SECRET_KEY\"]\nrouter = APIRouter()\n\nclass CheckoutRequest(BaseModel):\n    price_id: str\n    mode: str = \"payment\"  # or \"subscription\"\n\n@router.post(\"/api/checkout\")\nasync def create_checkout_session(request: CheckoutRequest):\n    try:\n        session = stripe.checkout.Session.create(\n            mode=request.mode,\n            payment_method_types=[\"card\"],\n            line_items=[{\n                \"price\": request.price_id,\n                \"quantity\": 1,\n            }],\n            success_url=f\"{os.environ['APP_URL']}/success?session_id={{CHECKOUT_SESSION_ID}}\",\n            cancel_url=f\"{os.environ['APP_URL']}/canceled\",\n            metadata={\n                \"user_id\": \"user_123\",\n            },\n        )\n        return {\"session_id\": session.id, \"url\": session.url}\n    except stripe.error.StripeError as e:\n        raise HTTPException(status_code=400, detail=str(e))\n```\n\n### Client: Redirect to Checkout\n\n```typescript\n// components/CheckoutButton.tsx\n\"use client\";\n\nimport { loadStripe } from \"@stripe/stripe-js\";\n\nconst stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!);\n\nexport function CheckoutButton({ priceId }: { priceId: string }) {\n  const handleCheckout = async () => {\n    const response = await fetch(\"/api/checkout\", {\n      method: \"POST\",\n      headers: { \"Content-Type\": \"application/json\" },\n      body: JSON.stringify({ priceId }),\n    });\n\n    const { url } = await response.json();\n\n    // Redirect to Stripe Checkout\n    window.location.href = url;\n  };\n\n  return (\n    <button onClick={handleCheckout}>\n      Subscribe Now\n    </button>\n  );\n}\n```\n\n---\n\n## Embedded Checkout\n\nFor keeping users on your site:\n\n```typescript\n// components/EmbeddedCheckout.tsx\n\"use client\";\n\nimport { useEffect, useState } from \"react\";\nimport { loadStripe } from \"@stripe/stripe-js\";\nimport {\n  EmbeddedCheckoutProvider,\n  EmbeddedCheckout,\n} from \"@stripe/react-stripe-js\";\n\nconst stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!);\n\nexport function EmbeddedCheckoutForm({ priceId }: { priceId: string }) {\n  const [clientSecret, setClientSecret] = useState(\"\");\n\n  useEffect(() => {\n    fetch(\"/api/checkout/embedded\", {\n      method: \"POST\",\n      headers: { \"Content-Type\": \"application/json\" },\n      body: JSON.stringify({ priceId }),\n    })\n      .then((res) => res.json())\n      .then((data) => setClientSecret(data.clientSecret));\n  }, [priceId]);\n\n  if (!clientSecret) return <div>Loading...</div>;\n\n  return (\n    <EmbeddedCheckoutProvider stripe={stripePromise} options={{ clientSecret }}>\n      <EmbeddedCheckout />\n    </EmbeddedCheckoutProvider>\n  );\n}\n```\n\nServer endpoint for embedded:\n```typescript\n// app/api/checkout/embedded/route.ts\nexport async function POST(request: Request) {\n  const { priceId } = await request.json();\n\n  const session = await stripe.checkout.sessions.create({\n    mode: \"subscription\",\n    line_items: [{ price: priceId, quantity: 1 }],\n    ui_mode: \"embedded\",\n    return_url: `${process.env.NEXT_PUBLIC_URL}/success?session_id={CHECKOUT_SESSION_ID}`,\n  });\n\n  return NextResponse.json({ clientSecret: session.client_secret });\n}\n```\n\n---\n\n## Webhooks (Critical)\n\n**Never trust client-side data**. Always verify payments via webhooks.\n\n### Webhook Endpoint\n\n```typescript\n// app/api/webhooks/stripe/route.ts\nimport Stripe from \"stripe\";\nimport { headers } from \"next/headers\";\n\nconst stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);\nconst webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;\n\nexport async function POST(request: Request) {\n  const body = await request.text();\n  const signature = headers().get(\"stripe-signature\")!;\n\n  let event: Stripe.Event;\n\n  // Verify webhook signature\n  try {\n    event = stripe.webhooks.constructEvent(body, signature, webhookSecret);\n  } catch (err) {\n    console.error(\"Webhook signature verification failed\");\n    return new Response(\"Invalid signature\", { status: 400 });\n  }\n\n  // Handle events\n  switch (event.type) {\n    case \"checkout.session.completed\": {\n      const session = event.data.object as Stripe.Checkout.Session;\n      await handleCheckoutComplete(session);\n      break;\n    }\n    case \"customer.subscription.created\":\n    case \"customer.subscription.updated\": {\n      const subscription = event.data.object as Stripe.Subscription;\n      await handleSubscriptionUpdate(subscription);\n      break;\n    }\n    case \"customer.subscription.deleted\": {\n      const subscription = event.data.object as Stripe.Subscription;\n      await handleSubscriptionCanceled(subscription);\n      break;\n    }\n    case \"invoice.payment_failed\": {\n      const invoice = event.data.object as Stripe.Invoice;\n      await handlePaymentFailed(invoice);\n      break;\n    }\n    default:\n      console.log(`Unhandled event type: ${event.type}`);\n  }\n\n  // Return 200 quickly - process async if needed\n  return new Response(\"OK\", { status: 200 });\n}\n\nasync function handleCheckoutComplete(session: Stripe.Checkout.Session) {\n  const userId = session.metadata?.userId;\n  const customerId = session.customer as string;\n  const subscriptionId = session.subscription as string;\n\n  // Update your database\n  await db.user.update({\n    where: { id: userId },\n    data: {\n      stripeCustomerId: customerId,\n      stripeSubscriptionId: subscriptionId,\n      subscriptionStatus: \"active\",\n    },\n  });\n}\n```\n\n### Python Webhook\n```python\n# app/api/webhooks.py\nimport stripe\nfrom fastapi import APIRouter, Request, HTTPException\n\nrouter = APIRouter()\n\n@router.post(\"/api/webhooks/stripe\")\nasync def stripe_webhook(request: Request):\n    payload = await request.body()\n    sig_header = request.headers.get(\"stripe-signature\")\n\n    try:\n        event = stripe.Webhook.construct_event(\n            payload, sig_header, os.environ[\"STRIPE_WEBHOOK_SECRET\"]\n        )\n    except ValueError:\n        raise HTTPException(status_code=400, detail=\"Invalid payload\")\n    except stripe.error.SignatureVerificationError:\n        raise HTTPException(status_code=400, detail=\"Invalid signature\")\n\n    # Handle events\n    if event[\"type\"] == \"checkout.session.completed\":\n        session = event[\"data\"][\"object\"]\n        await handle_checkout_complete(session)\n    elif event[\"type\"] == \"customer.subscription.deleted\":\n        subscription = event[\"data\"][\"object\"]\n        await handle_subscription_canceled(subscription)\n\n    return {\"status\": \"success\"}\n```\n\n### Key Webhook Events\n\n| Event | When | Action |\n|-------|------|--------|\n| `checkout.session.completed` | Payment successful | Provision access |\n| `customer.subscription.created` | New subscription | Store subscription ID |\n| `customer.subscription.updated` | Plan change | Update plan in DB |\n| `customer.subscription.deleted` | Canceled | Revoke access |\n| `invoice.payment_failed` | Payment failed | Notify user, retry |\n| `invoice.paid` | Renewal successful | Extend access |\n\n---\n\n## Products & Prices\n\n### Create via Dashboard (Recommended)\n1. Go to https://dashboard.stripe.com/products\n2. Create product with name, description\n3. Add price(s) - one-time or recurring\n4. Copy Price ID (`price_xxx`)\n\n### Create via API\n```typescript\n// One-time product\nconst product = await stripe.products.create({\n  name: \"Pro Plan\",\n  description: \"Full access to all features\",\n});\n\nconst price = await stripe.prices.create({\n  product: product.id,\n  unit_amount: 2999, // $29.99 in cents\n  currency: \"usd\",\n});\n\n// Subscription product\nconst subscriptionPrice = await stripe.prices.create({\n  product: product.id,\n  unit_amount: 999, // $9.99/month\n  currency: \"usd\",\n  recurring: {\n    interval: \"month\",\n  },\n});\n```\n\n---\n\n## Customer Portal\n\nLet users manage their subscriptions:\n\n```typescript\n// app/api/portal/route.ts\nexport async function POST(request: Request) {\n  const { customerId } = await request.json();\n\n  const session = await stripe.billingPortal.sessions.create({\n    customer: customerId,\n    return_url: `${process.env.NEXT_PUBLIC_URL}/settings`,\n  });\n\n  return NextResponse.json({ url: session.url });\n}\n```\n\nConfigure portal at: https://dashboard.stripe.com/settings/billing/portal\n\n---\n\n## Subscriptions\n\n### Create Subscription with Trial\n```typescript\nconst session = await stripe.checkout.sessions.create({\n  mode: \"subscription\",\n  line_items: [{ price: priceId, quantity: 1 }],\n  subscription_data: {\n    trial_period_days: 14,\n    // Cancel if no payment method after trial\n    trial_settings: {\n      end_behavior: { missing_payment_method: \"cancel\" },\n    },\n  },\n  success_url: successUrl,\n  cancel_url: cancelUrl,\n});\n```\n\n### Check Subscription Status\n```typescript\n// lib/subscription.ts\nexport async function getSubscriptionStatus(customerId: string) {\n  const subscriptions = await stripe.subscriptions.list({\n    customer: customerId,\n    status: \"all\",\n    limit: 1,\n  });\n\n  if (subscriptions.data.length === 0) {\n    return { status: \"none\", plan: null };\n  }\n\n  const subscription = subscriptions.data[0];\n  return {\n    status: subscription.status,\n    plan: subscription.items.data[0].price.id,\n    currentPeriodEnd: new Date(subscription.current_period_end * 1000),\n    cancelAtPeriodEnd: subscription.cancel_at_period_end,\n  };\n}\n```\n\n---\n\n## Testing\n\n### Test Cards\n| Card Number | Scenario |\n|-------------|----------|\n| `4242424242424242` | Success |\n| `4000000000000002` | Declined |\n| `4000002500003155` | Requires 3D Secure |\n| `4000000000009995` | Insufficient funds |\n\n### Stripe CLI for Webhooks\n```bash\n# Install CLI\nbrew install stripe/stripe-cli/stripe\n\n# Login\nstripe login\n\n# Forward webhooks to local server\nstripe listen --forward-to localhost:3000/api/webhooks/stripe\n\n# Trigger test events\nstripe trigger checkout.session.completed\nstripe trigger customer.subscription.deleted\n```\n\n---\n\n## Project Structure\n\n```\nproject/\n├── app/\n│   ├── api/\n│   │   ├── checkout/\n│   │   │   └── route.ts          # Create checkout session\n│   │   ├── portal/\n│   │   │   └── route.ts          # Customer portal\n│   │   └── webhooks/\n│   │       └── stripe/\n│   │           └── route.ts      # Webhook handler\n│   ├── pricing/\n│   │   └── page.tsx              # Pricing page\n│   ├── success/\n│   │   └── page.tsx              # Post-checkout success\n│   └── settings/\n│       └── page.tsx              # Manage subscription\n├── lib/\n│   ├── stripe.ts                 # Stripe client\n│   └── subscription.ts           # Subscription helpers\n└── .env.local\n```\n\n---\n\n## Security Best Practices\n\n### Non-Negotiable Rules\n1. **Server-side only for secrets** - Never expose `STRIPE_SECRET_KEY`\n2. **Always verify webhooks** - Check signature before processing\n3. **Idempotency** - Store webhook event IDs, skip duplicates\n4. **Use metadata** - Track user IDs, sources for debugging\n5. **Handle all states** - Success, failure, pending, canceled\n\n### Idempotent Webhook Handler\n```typescript\nconst processedEvents = new Set<string>(); // Use Redis in production\n\nexport async function POST(request: Request) {\n  // ... verify signature ...\n\n  // Skip duplicate events\n  if (processedEvents.has(event.id)) {\n    return new Response(\"Already processed\", { status: 200 });\n  }\n  processedEvents.add(event.id);\n\n  // Process event...\n}\n```\n\n### Amount Handling\n```typescript\n// Always use cents (smallest currency unit)\nconst priceInCents = 2999; // $29.99\n\n// Helper functions\nconst toCents = (dollars: number) => Math.round(dollars * 100);\nconst toDollars = (cents: number) => cents / 100;\n\n// Display\nconst displayPrice = (cents: number) =>\n  new Intl.NumberFormat(\"en-US\", {\n    style: \"currency\",\n    currency: \"USD\",\n  }).format(toDollars(cents));\n```\n\n---\n\n## Common Patterns\n\n### Pricing Page\n```typescript\n// app/pricing/page.tsx\nconst plans = [\n  {\n    name: \"Starter\",\n    price: \"$9/mo\",\n    priceId: \"price_starter_monthly\",\n    features: [\"Feature 1\", \"Feature 2\"],\n  },\n  {\n    name: \"Pro\",\n    price: \"$29/mo\",\n    priceId: \"price_pro_monthly\",\n    features: [\"Everything in Starter\", \"Feature 3\", \"Feature 4\"],\n    popular: true,\n  },\n];\n\nexport default function PricingPage() {\n  return (\n    <div className=\"grid md:grid-cols-2 gap-8\">\n      {plans.map((plan) => (\n        <div key={plan.name} className={plan.popular ? \"border-blue-500\" : \"\"}>\n          <h3>{plan.name}</h3>\n          <p>{plan.price}</p>\n          <ul>\n            {plan.features.map((f) => <li key={f}>{f}</li>)}\n          </ul>\n          <CheckoutButton priceId={plan.priceId} />\n        </div>\n      ))}\n    </div>\n  );\n}\n```\n\n### Protect Routes by Subscription\n```typescript\n// middleware.ts\nimport { getSubscriptionStatus } from \"@/lib/subscription\";\n\nexport async function middleware(request: NextRequest) {\n  const session = await getSession();\n\n  if (request.nextUrl.pathname.startsWith(\"/pro\")) {\n    const { status } = await getSubscriptionStatus(session.stripeCustomerId);\n\n    if (status !== \"active\" && status !== \"trialing\") {\n      return NextResponse.redirect(new URL(\"/pricing\", request.url));\n    }\n  }\n}\n```\n\n---\n\n## Anti-Patterns\n\n- **Hardcoding API keys** - Use environment variables\n- **Client-side payment creation** - Always create PaymentIntent/Session server-side\n- **Skipping webhook verification** - Always verify signatures\n- **Processing duplicate webhooks** - Implement idempotency\n- **Floating-point currency math** - Use integers (cents)\n- **Trusting client data** - Verify everything server-side\n- **Ignoring failed payments** - Handle `invoice.payment_failed`\n- **No error handling** - Catch and handle Stripe errors\n\n---\n\n## Quick Reference\n\n```bash\n# Install\nnpm install stripe @stripe/stripe-js @stripe/react-stripe-js\n\n# Stripe CLI\nstripe login\nstripe listen --forward-to localhost:3000/api/webhooks/stripe\nstripe trigger checkout.session.completed\n\n# Test mode prefix\nsk_test_xxx  # Secret key\npk_test_xxx  # Publishable key\n\n# Live mode prefix\nsk_live_xxx\npk_live_xxx\n```\n\n### Key Endpoints\n| Endpoint | Purpose |\n|----------|---------|\n| `POST /api/checkout` | Create checkout session |\n| `POST /api/portal` | Customer billing portal |\n| `POST /api/webhooks/stripe` | Handle Stripe events |\n\n### Environment Variables\n```bash\nSTRIPE_SECRET_KEY=sk_test_xxx\nSTRIPE_PUBLISHABLE_KEY=pk_test_xxx\nSTRIPE_WEBHOOK_SECRET=whsec_xxx\nNEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_xxx\n```\n"
  },
  {
    "path": "skills/woocommerce/SKILL.md",
    "content": "---\nname: woocommerce\ndescription: WooCommerce REST API - products, orders, customers, webhooks\nwhen-to-use: When integrating with WooCommerce stores\nuser-invocable: false\neffort: medium\n---\n\n# WooCommerce Development Skill\n\n\nFor integrating with WooCommerce stores via REST API - products, orders, customers, webhooks, and custom extensions.\n\n**Sources:** [WooCommerce REST API](https://woocommerce.github.io/woocommerce-rest-api-docs/) | [Developer Docs](https://developer.woocommerce.com/docs/)\n\n---\n\n## Prerequisites\n\n### Store Requirements\n\n```bash\n# WooCommerce store must have:\n# 1. WordPress with WooCommerce plugin installed\n# 2. HTTPS enabled (required for API auth)\n# 3. Permalinks set to anything except \"Plain\"\n#    WordPress Admin → Settings → Permalinks → Post name (recommended)\n```\n\n### Generate API Keys\n\n1. Go to **WooCommerce → Settings → Advanced → REST API**\n2. Click **Add key**\n3. Set Description, User (admin), and Permissions (Read/Write)\n4. Click **Generate API key**\n5. Copy **Consumer Key** and **Consumer Secret** (shown only once)\n\n---\n\n## API Basics\n\n### Base URL\n\n```\nhttps://your-store.com/wp-json/wc/v3/\n```\n\n### Authentication\n\n```typescript\n// Node.js - Basic Auth (recommended)\nconst WooCommerceRestApi = require(\"@woocommerce/woocommerce-rest-api\").default;\n\nconst api = new WooCommerceRestApi({\n  url: \"https://your-store.com\",\n  consumerKey: process.env.WC_CONSUMER_KEY,\n  consumerSecret: process.env.WC_CONSUMER_SECRET,\n  version: \"wc/v3\"\n});\n```\n\n```python\n# Python\nfrom woocommerce import API\n\nwcapi = API(\n    url=\"https://your-store.com\",\n    consumer_key=os.environ[\"WC_CONSUMER_KEY\"],\n    consumer_secret=os.environ[\"WC_CONSUMER_SECRET\"],\n    version=\"wc/v3\"\n)\n```\n\n### Query String Auth (Fallback)\n\n```bash\n# Only use if Basic Auth fails (some hosting configurations)\ncurl https://your-store.com/wp-json/wc/v3/products \\\n  ?consumer_key=ck_xxx&consumer_secret=cs_xxx\n```\n\n---\n\n## Installation\n\n### Node.js\n\n```bash\nnpm install @woocommerce/woocommerce-rest-api\n```\n\n```typescript\n// lib/woocommerce.ts\nimport WooCommerceRestApi from \"@woocommerce/woocommerce-rest-api\";\n\nconst api = new WooCommerceRestApi({\n  url: process.env.WC_STORE_URL!,\n  consumerKey: process.env.WC_CONSUMER_KEY!,\n  consumerSecret: process.env.WC_CONSUMER_SECRET!,\n  version: \"wc/v3\",\n  queryStringAuth: false, // Set true for HTTP (dev only)\n});\n\nexport default api;\n```\n\n### Python\n\n```bash\npip install woocommerce\n```\n\n```python\n# lib/woocommerce.py\nimport os\nfrom woocommerce import API\n\nwcapi = API(\n    url=os.environ[\"WC_STORE_URL\"],\n    consumer_key=os.environ[\"WC_CONSUMER_KEY\"],\n    consumer_secret=os.environ[\"WC_CONSUMER_SECRET\"],\n    version=\"wc/v3\",\n    timeout=30\n)\n```\n\n---\n\n## Products\n\n### List Products\n\n```typescript\n// Node.js\nasync function getProducts(page = 1, perPage = 20) {\n  const response = await api.get(\"products\", {\n    page,\n    per_page: perPage,\n    status: \"publish\",\n  });\n  return response.data;\n}\n\n// With filters\nasync function searchProducts(search: string, category?: number) {\n  const response = await api.get(\"products\", {\n    search,\n    category: category || undefined,\n    orderby: \"popularity\",\n    order: \"desc\",\n  });\n  return response.data;\n}\n```\n\n```python\n# Python\ndef get_products(page=1, per_page=20):\n    response = wcapi.get(\"products\", params={\n        \"page\": page,\n        \"per_page\": per_page,\n        \"status\": \"publish\"\n    })\n    return response.json()\n```\n\n### Get Single Product\n\n```typescript\nasync function getProduct(productId: number) {\n  const response = await api.get(`products/${productId}`);\n  return response.data;\n}\n```\n\n### Create Product\n\n```typescript\nasync function createProduct(data: ProductInput) {\n  const response = await api.post(\"products\", {\n    name: data.name,\n    type: \"simple\", // simple, variable, grouped, external\n    regular_price: data.price.toString(),\n    description: data.description,\n    short_description: data.shortDescription,\n    categories: data.categoryIds.map(id => ({ id })),\n    images: data.images.map(url => ({ src: url })),\n    manage_stock: true,\n    stock_quantity: data.stockQuantity,\n    status: \"publish\",\n  });\n  return response.data;\n}\n```\n\n### Update Product\n\n```typescript\nasync function updateProduct(productId: number, data: Partial<ProductInput>) {\n  const response = await api.put(`products/${productId}`, data);\n  return response.data;\n}\n\n// Update stock only\nasync function updateStock(productId: number, quantity: number) {\n  const response = await api.put(`products/${productId}`, {\n    stock_quantity: quantity,\n  });\n  return response.data;\n}\n```\n\n### Delete Product\n\n```typescript\nasync function deleteProduct(productId: number, force = false) {\n  // force: true = permanent delete, false = move to trash\n  const response = await api.delete(`products/${productId}`, {\n    force,\n  });\n  return response.data;\n}\n```\n\n### Variable Products\n\n```typescript\n// Create variable product\nasync function createVariableProduct(data: VariableProductInput) {\n  // 1. Create product with type \"variable\"\n  const product = await api.post(\"products\", {\n    name: data.name,\n    type: \"variable\",\n    attributes: [\n      {\n        name: \"Size\",\n        visible: true,\n        variation: true,\n        options: [\"Small\", \"Medium\", \"Large\"],\n      },\n      {\n        name: \"Color\",\n        visible: true,\n        variation: true,\n        options: [\"Red\", \"Blue\"],\n      },\n    ],\n  });\n\n  // 2. Create variations\n  for (const variant of data.variants) {\n    await api.post(`products/${product.data.id}/variations`, {\n      regular_price: variant.price.toString(),\n      stock_quantity: variant.stock,\n      attributes: [\n        { name: \"Size\", option: variant.size },\n        { name: \"Color\", option: variant.color },\n      ],\n    });\n  }\n\n  return product.data;\n}\n\n// Get variations\nasync function getVariations(productId: number) {\n  const response = await api.get(`products/${productId}/variations`);\n  return response.data;\n}\n```\n\n---\n\n## Orders\n\n### List Orders\n\n```typescript\nasync function getOrders(params: OrderQueryParams = {}) {\n  const response = await api.get(\"orders\", {\n    page: params.page || 1,\n    per_page: params.perPage || 20,\n    status: params.status || \"any\", // pending, processing, completed, etc.\n    after: params.after, // ISO date string\n    before: params.before,\n  });\n  return response.data;\n}\n\n// Get recent orders\nasync function getRecentOrders(days = 7) {\n  const after = new Date();\n  after.setDate(after.getDate() - days);\n\n  const response = await api.get(\"orders\", {\n    after: after.toISOString(),\n    orderby: \"date\",\n    order: \"desc\",\n  });\n  return response.data;\n}\n```\n\n### Get Single Order\n\n```typescript\nasync function getOrder(orderId: number) {\n  const response = await api.get(`orders/${orderId}`);\n  return response.data;\n}\n```\n\n### Create Order\n\n```typescript\nasync function createOrder(data: OrderInput) {\n  const response = await api.post(\"orders\", {\n    payment_method: \"stripe\",\n    payment_method_title: \"Credit Card\",\n    set_paid: false,\n    billing: {\n      first_name: data.customer.firstName,\n      last_name: data.customer.lastName,\n      email: data.customer.email,\n      phone: data.customer.phone,\n      address_1: data.billing.address1,\n      city: data.billing.city,\n      state: data.billing.state,\n      postcode: data.billing.postcode,\n      country: data.billing.country,\n    },\n    shipping: {\n      first_name: data.customer.firstName,\n      last_name: data.customer.lastName,\n      address_1: data.shipping.address1,\n      city: data.shipping.city,\n      state: data.shipping.state,\n      postcode: data.shipping.postcode,\n      country: data.shipping.country,\n    },\n    line_items: data.items.map(item => ({\n      product_id: item.productId,\n      variation_id: item.variationId,\n      quantity: item.quantity,\n    })),\n    shipping_lines: [\n      {\n        method_id: \"flat_rate\",\n        method_title: \"Flat Rate\",\n        total: data.shippingCost.toString(),\n      },\n    ],\n  });\n  return response.data;\n}\n```\n\n### Update Order Status\n\n```typescript\nasync function updateOrderStatus(orderId: number, status: OrderStatus) {\n  const response = await api.put(`orders/${orderId}`, {\n    status, // pending, processing, on-hold, completed, cancelled, refunded, failed\n  });\n  return response.data;\n}\n\n// Add order note\nasync function addOrderNote(orderId: number, note: string, customerNote = false) {\n  const response = await api.post(`orders/${orderId}/notes`, {\n    note,\n    customer_note: customerNote, // true = visible to customer\n  });\n  return response.data;\n}\n```\n\n### Order Statuses\n\n| Status | Description |\n|--------|-------------|\n| `pending` | Awaiting payment |\n| `processing` | Payment received, awaiting fulfillment |\n| `on-hold` | Awaiting action (stock, payment confirmation) |\n| `completed` | Order fulfilled |\n| `cancelled` | Cancelled by admin or customer |\n| `refunded` | Refunded |\n| `failed` | Payment failed |\n\n---\n\n## Customers\n\n### List Customers\n\n```typescript\nasync function getCustomers(params: CustomerQueryParams = {}) {\n  const response = await api.get(\"customers\", {\n    page: params.page || 1,\n    per_page: params.perPage || 20,\n    role: \"customer\",\n    orderby: \"registered_date\",\n    order: \"desc\",\n  });\n  return response.data;\n}\n\n// Search customers\nasync function searchCustomers(email: string) {\n  const response = await api.get(\"customers\", {\n    email,\n  });\n  return response.data;\n}\n```\n\n### Create Customer\n\n```typescript\nasync function createCustomer(data: CustomerInput) {\n  const response = await api.post(\"customers\", {\n    email: data.email,\n    first_name: data.firstName,\n    last_name: data.lastName,\n    username: data.email.split(\"@\")[0],\n    billing: {\n      first_name: data.firstName,\n      last_name: data.lastName,\n      email: data.email,\n      phone: data.phone,\n      address_1: data.address1,\n      city: data.city,\n      state: data.state,\n      postcode: data.postcode,\n      country: data.country,\n    },\n    shipping: {\n      // Same as billing or different\n    },\n  });\n  return response.data;\n}\n```\n\n### Update Customer\n\n```typescript\nasync function updateCustomer(customerId: number, data: Partial<CustomerInput>) {\n  const response = await api.put(`customers/${customerId}`, data);\n  return response.data;\n}\n```\n\n---\n\n## Webhooks\n\n### Create Webhook\n\n```typescript\nasync function createWebhook(topic: string, deliveryUrl: string) {\n  const response = await api.post(\"webhooks\", {\n    name: `Webhook for ${topic}`,\n    topic, // order.created, order.updated, product.created, etc.\n    delivery_url: deliveryUrl,\n    status: \"active\",\n    secret: process.env.WC_WEBHOOK_SECRET,\n  });\n  return response.data;\n}\n```\n\n### Webhook Topics\n\n| Topic | Trigger |\n|-------|---------|\n| `order.created` | New order placed |\n| `order.updated` | Order status/details changed |\n| `order.deleted` | Order deleted |\n| `product.created` | New product created |\n| `product.updated` | Product updated |\n| `product.deleted` | Product deleted |\n| `customer.created` | New customer registered |\n| `customer.updated` | Customer updated |\n| `coupon.created` | New coupon created |\n\n### Verify Webhook Signature\n\n```typescript\n// Express.js webhook handler\nimport crypto from \"crypto\";\n\nfunction verifyWooCommerceWebhook(req: Request): boolean {\n  const signature = req.headers[\"x-wc-webhook-signature\"] as string;\n  const payload = JSON.stringify(req.body);\n\n  const expectedSignature = crypto\n    .createHmac(\"sha256\", process.env.WC_WEBHOOK_SECRET!)\n    .update(payload)\n    .digest(\"base64\");\n\n  return crypto.timingSafeEqual(\n    Buffer.from(signature),\n    Buffer.from(expectedSignature)\n  );\n}\n\n// Route handler\napp.post(\"/webhooks/woocommerce\", (req, res) => {\n  if (!verifyWooCommerceWebhook(req)) {\n    return res.status(401).json({ error: \"Invalid signature\" });\n  }\n\n  const topic = req.headers[\"x-wc-webhook-topic\"];\n  const payload = req.body;\n\n  switch (topic) {\n    case \"order.created\":\n      handleNewOrder(payload);\n      break;\n    case \"order.updated\":\n      handleOrderUpdate(payload);\n      break;\n    // ... other topics\n  }\n\n  res.status(200).json({ received: true });\n});\n```\n\n```python\n# Python/Flask webhook handler\nimport hmac\nimport hashlib\nimport base64\n\n@app.route(\"/webhooks/woocommerce\", methods=[\"POST\"])\ndef woocommerce_webhook():\n    signature = request.headers.get(\"X-WC-Webhook-Signature\")\n    payload = request.get_data()\n\n    expected = base64.b64encode(\n        hmac.new(\n            os.environ[\"WC_WEBHOOK_SECRET\"].encode(),\n            payload,\n            hashlib.sha256\n        ).digest()\n    ).decode()\n\n    if not hmac.compare_digest(signature, expected):\n        return {\"error\": \"Invalid signature\"}, 401\n\n    topic = request.headers.get(\"X-WC-Webhook-Topic\")\n    data = request.json\n\n    if topic == \"order.created\":\n        handle_new_order(data)\n    elif topic == \"order.updated\":\n        handle_order_update(data)\n\n    return {\"received\": True}, 200\n```\n\n---\n\n## Categories & Tags\n\n### List Categories\n\n```typescript\nasync function getCategories() {\n  const response = await api.get(\"products/categories\", {\n    per_page: 100,\n    orderby: \"name\",\n  });\n  return response.data;\n}\n\n// Create category\nasync function createCategory(name: string, parentId?: number) {\n  const response = await api.post(\"products/categories\", {\n    name,\n    parent: parentId || 0,\n  });\n  return response.data;\n}\n```\n\n### List Tags\n\n```typescript\nasync function getTags() {\n  const response = await api.get(\"products/tags\", {\n    per_page: 100,\n  });\n  return response.data;\n}\n```\n\n---\n\n## Coupons\n\n### Create Coupon\n\n```typescript\nasync function createCoupon(data: CouponInput) {\n  const response = await api.post(\"coupons\", {\n    code: data.code,\n    discount_type: data.type, // percent, fixed_cart, fixed_product\n    amount: data.amount.toString(),\n    individual_use: true,\n    exclude_sale_items: false,\n    minimum_amount: data.minimumAmount?.toString(),\n    maximum_amount: data.maximumAmount?.toString(),\n    usage_limit: data.usageLimit,\n    usage_limit_per_user: 1,\n    date_expires: data.expiresAt, // ISO date string\n  });\n  return response.data;\n}\n```\n\n---\n\n## Reports\n\n### Sales Report\n\n```typescript\nasync function getSalesReport(period = \"month\") {\n  const response = await api.get(\"reports/sales\", {\n    period, // day, week, month, year\n  });\n  return response.data;\n}\n\n// Top sellers\nasync function getTopSellers(period = \"month\") {\n  const response = await api.get(\"reports/top_sellers\", {\n    period,\n  });\n  return response.data;\n}\n```\n\n---\n\n## Pagination\n\n### Handle Large Datasets\n\n```typescript\nasync function getAllProducts() {\n  const allProducts = [];\n  let page = 1;\n  const perPage = 100;\n\n  while (true) {\n    const response = await api.get(\"products\", {\n      page,\n      per_page: perPage,\n    });\n\n    allProducts.push(...response.data);\n\n    // Check headers for total pages\n    const totalPages = parseInt(response.headers[\"x-wp-totalpages\"]);\n    if (page >= totalPages) break;\n\n    page++;\n  }\n\n  return allProducts;\n}\n```\n\n### Pagination Headers\n\n| Header | Description |\n|--------|-------------|\n| `X-WP-Total` | Total number of items |\n| `X-WP-TotalPages` | Total number of pages |\n\n---\n\n## Error Handling\n\n```typescript\nimport WooCommerceRestApi from \"@woocommerce/woocommerce-rest-api\";\n\nasync function safeApiCall<T>(\n  operation: () => Promise<{ data: T }>\n): Promise<T> {\n  try {\n    const response = await operation();\n    return response.data;\n  } catch (error: any) {\n    if (error.response) {\n      // API returned an error\n      const { status, data } = error.response;\n\n      switch (status) {\n        case 400:\n          throw new Error(`Bad request: ${data.message}`);\n        case 401:\n          throw new Error(\"Invalid API credentials\");\n        case 404:\n          throw new Error(\"Resource not found\");\n        case 429:\n          // Rate limited - wait and retry\n          await new Promise(r => setTimeout(r, 5000));\n          return safeApiCall(operation);\n        default:\n          throw new Error(`API error: ${data.message}`);\n      }\n    }\n    throw error;\n  }\n}\n\n// Usage\nconst products = await safeApiCall(() => api.get(\"products\"));\n```\n\n---\n\n## Environment Variables\n\n```bash\n# .env\nWC_STORE_URL=https://your-store.com\nWC_CONSUMER_KEY=ck_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\nWC_CONSUMER_SECRET=cs_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\nWC_WEBHOOK_SECRET=your_webhook_secret\n```\n\nAdd to `credentials.md`:\n```python\n'WC_CONSUMER_KEY': r'ck_[a-f0-9]{40}',\n'WC_CONSUMER_SECRET': r'cs_[a-f0-9]{40}',\n```\n\n---\n\n## Checklist\n\n### Before Integration\n\n- [ ] WooCommerce plugin installed and activated\n- [ ] HTTPS enabled on store\n- [ ] Permalinks set to non-Plain setting\n- [ ] API keys generated with appropriate permissions\n- [ ] Webhook secret configured\n\n### Security\n\n- [ ] API keys stored in environment variables\n- [ ] Webhook signatures verified\n- [ ] HTTPS used for all API calls\n- [ ] Rate limiting handled\n\n### Testing\n\n- [ ] Test API connection\n- [ ] Test product CRUD operations\n- [ ] Test order creation/updates\n- [ ] Test webhook delivery\n- [ ] Test pagination for large datasets\n\n---\n\n## Anti-Patterns\n\n- **Plain permalinks** - API won't work without pretty permalinks\n- **HTTP in production** - Always use HTTPS\n- **Ignoring rate limits** - WooCommerce may throttle requests\n- **Large single requests** - Use pagination for bulk operations\n- **Storing keys in code** - Use environment variables\n- **Skipping webhook verification** - Always verify signatures\n"
  },
  {
    "path": "skills/workspace/SKILL.md",
    "content": "---\nname: workspace\ndescription: Dynamic multi-repo and monorepo awareness for Claude Code. Analyze workspace topology, track API contracts, and maintain cross-repo context.\nwhen-to-use: When working across multiple repos or in a monorepo with shared dependencies\nuser-invocable: true\neffort: high\n---\n\n# Workspace Analysis Skill\n\n> Dynamic multi-repo and monorepo awareness for Claude Code. Analyze workspace topology, track API contracts, and maintain cross-repo context.\n\n## The Problem\n\nWhen you have separate frontend/backend repos (or monorepo with multiple apps), Claude Code operates in isolation. It doesn't know:\n\n- API contracts between modules/repos\n- Shared types and interfaces\n- Full system architecture\n- Cross-repo dependencies\n- What changed in other parts of the system\n\nThis leads to:\n- Duplicate type definitions\n- API contract mismatches\n- Breaking changes not caught until runtime\n- Claude reimplementing things that exist elsewhere\n\n---\n\n## Solution: Dynamic Workspace Analysis\n\nInstead of static manifests that get stale, Claude dynamically analyzes the workspace and generates context artifacts that stay fresh through hooks.\n\n```\n┌─────────────────────────────────────────────────────────────────┐\n│  WORKSPACE ANALYSIS SYSTEM                                       │\n├─────────────────────────────────────────────────────────────────┤\n│                                                                  │\n│  /analyze-workspace (Full Analysis - ~2 min)                     │\n│  ├── Topology discovery (monorepo vs multi-repo)                │\n│  ├── Dependency graph (who calls whom)                          │\n│  ├── Contract extraction (OpenAPI, GraphQL, types)              │\n│  └── Key file identification (what to load when)                │\n│                                                                  │\n│  /sync-contracts (Incremental - ~15 sec)                         │\n│  ├── Check contract source files for changes                    │\n│  ├── Update CONTRACTS.md with diffs                             │\n│  └── Validate consistency                                       │\n│                                                                  │\n│  Hooks (Automatic)                                               │\n│  ├── Session start: Staleness advisory (~5 sec)                 │\n│  ├── Post-commit: Auto-sync if contracts changed (~15 sec)      │\n│  └── Pre-push: Validation gate (~10 sec)                        │\n│                                                                  │\n└─────────────────────────────────────────────────────────────────┘\n```\n\n---\n\n## Workspace Classification\n\n### Detection Patterns\n\n| Type | Indicators | File Access |\n|------|------------|-------------|\n| **Monorepo** | pnpm-workspace.yaml, nx.json, turbo.json, lerna.json | Direct (same tree) |\n| **Multi-repo** | Sibling directories with separate .git | Via symlinks or paths |\n| **Hybrid** | Monorepo + external repo dependencies | Mixed |\n| **Single** | One app, no workspace config | N/A (use existing-repo) |\n\n### Monorepo Detection\n\n```bash\n# Check for monorepo indicators\nls package.json pnpm-workspace.yaml lerna.json nx.json turbo.json 2>/dev/null\nls apps/ packages/ services/ libs/ modules/ 2>/dev/null\n```\n\n### Multi-Repo Detection\n\n```bash\n# Check sibling directories for related repos\nls -la ../*.git 2>/dev/null\ncat ../*/.git/config 2>/dev/null | grep \"url\"\n\n# Look for naming patterns\nls .. | grep -E \"(frontend|backend|api|web|shared|common)\"\n```\n\n### Polyglot Detection\n\n```bash\n# Find all package manifests\nfind . -maxdepth 4 -name \"package.json\" -o -name \"pyproject.toml\" \\\n  -o -name \"go.mod\" -o -name \"Cargo.toml\" -o -name \"pom.xml\" \\\n  -o -name \"build.gradle\" -o -name \"Gemfile\"\n```\n\n---\n\n## Analysis Protocol\n\n### Phase 1: Topology Discovery (~30 seconds)\n\nDetermine workspace structure:\n\n```markdown\n## Discovery Checklist\n\n1. [ ] Identify workspace root\n2. [ ] Classify workspace type (monorepo/multi-repo/hybrid/single)\n3. [ ] List all modules/apps/packages\n4. [ ] Detect tech stack per module\n5. [ ] Identify entry points per module\n```\n\n**Module Detection Pattern:**\n\n```\nworkspace-root/\n├── apps/           → Application modules\n│   ├── web/        → Frontend app\n│   └── api/        → Backend app\n├── packages/       → Shared packages\n│   ├── ui/         → Component library\n│   ├── types/      → Shared types\n│   └── db/         → Database layer\n├── services/       → Microservices\n└── libs/           → Internal libraries\n```\n\n### Phase 2: Dependency Graph (~60 seconds)\n\nFor each module, map:\n\n**1. Internal Dependencies**\n```bash\n# TypeScript/JavaScript\ngrep -r \"from ['\\\"]@\" --include=\"*.ts\" --include=\"*.tsx\" | head -50\ngrep -r \"workspace:\" package.json\n\n# Python\ngrep -r \"from \\.\" --include=\"*.py\" | head -50\n```\n\n**2. API Relationships**\n```bash\n# Find API calls\ngrep -rE \"fetch|axios|httpx|requests\\.\" --include=\"*.ts\" --include=\"*.py\" | \\\n  grep -E \"/api|localhost|127\\.0\\.0\\.1\" | head -30\n```\n\n**3. Database Connections**\n```bash\n# Find DB access patterns\ngrep -rE \"prisma|drizzle|sqlalchemy|sequelize|typeorm\" --include=\"*.ts\" --include=\"*.py\"\n```\n\n### Phase 3: Contract Extraction (~45 seconds)\n\nIdentify and parse API contracts:\n\n| Contract Type | Detection | Extraction |\n|---------------|-----------|------------|\n| **OpenAPI** | openapi.json, swagger.yaml, /docs endpoint | Parse paths, schemas |\n| **GraphQL** | schema.graphql, *.gql, /graphql endpoint | Parse types, queries, mutations |\n| **tRPC** | trpc router files, @trpc/* imports | Parse router definitions |\n| **Protobuf** | *.proto files | Parse services, messages |\n| **TypeScript** | Shared .d.ts, exported interfaces | Parse exported types |\n| **Pydantic** | schemas/, models/ with BaseModel | Parse model definitions |\n| **Zod** | schemas/ with z.object | Parse schema definitions |\n\n**Contract Source Priority:**\n\n1. Generated specs (openapi.json) - most accurate\n2. Schema definitions (Pydantic, Zod) - source of truth\n3. Type exports (TypeScript .d.ts) - consumer contracts\n4. Inferred from code - last resort\n\n### Phase 4: Key File Identification (~30 seconds)\n\nIdentify files Claude MUST know about for each context:\n\n| Category | Detection Pattern | Token Priority |\n|----------|-------------------|----------------|\n| **Route definitions** | `**/routes/**`, `**/api/**`, `@app.get`, `@router` | HIGH |\n| **Type definitions** | `**/types/**`, `*.d.ts`, `schemas/`, `models/` | HIGH |\n| **Config** | `.env.example`, `config/`, `settings.py` | MEDIUM |\n| **Entry points** | `main.ts`, `index.ts`, `app.py`, `server.py` | MEDIUM |\n| **API clients** | `**/api/client*`, `**/lib/api*` | HIGH |\n| **Database schema** | `schema/`, `migrations/`, `prisma/schema.prisma` | MEDIUM |\n| **Tests** | `__tests__/`, `*_test.py`, `*.spec.ts` | LOW (on-demand) |\n\n---\n\n## Generated Artifacts\n\nAll artifacts go in `_project_specs/workspace/`:\n\n```\n_project_specs/workspace/\n├── TOPOLOGY.md           # What modules exist, their roles\n├── CONTRACTS.md          # API specs, shared types (summarized)\n├── DEPENDENCY_GRAPH.md   # Who calls whom (visual + list)\n├── KEY_FILES.md          # What to load for each context\n├── CROSS_REPO_INDEX.md   # Capabilities across all modules\n└── .contract-sources     # Files to monitor for changes\n```\n\n### TOPOLOGY.md Format\n\n```markdown\n# Workspace Topology\n\nGenerated: 2026-01-20T14:32:00Z\nAnalyzer: maggy/workspace-analysis\nWorkspace Type: Monorepo (Turborepo)\n\n## Overview\n\n```\n┌─────────────────────────────────────────────────┐\n│ apps/web (Next.js) ←→ apps/api (FastAPI)        │\n│      ↓                      ↓                   │\n│ packages/shared-types ← packages/db             │\n└─────────────────────────────────────────────────┘\n```\n\n## Modules\n\n### apps/web\n- **Path**: /apps/web\n- **Tech**: Next.js 14, TypeScript, TailwindCSS\n- **Role**: Customer-facing dashboard\n- **Consumes**: apps/api (REST), packages/shared-types\n- **Entry**: src/app/layout.tsx\n- **Key files**:\n  - `src/lib/api/client.ts` - API client (187 lines)\n  - `src/types/` - Frontend-specific types (12 files)\n- **Token estimate**: ~15K (full), ~4K (summarized)\n\n### apps/api\n- **Path**: /apps/api\n- **Tech**: FastAPI, Python 3.12, SQLAlchemy\n- **Role**: REST API, business logic\n- **Exposes**: OpenAPI at /docs (47 endpoints)\n- **Consumes**: packages/db\n- **Entry**: app/main.py\n- **Key files**:\n  - `app/routes/` - All endpoints (8 routers)\n  - `app/schemas/` - Pydantic models (23 files)\n  - `openapi.json` - Generated spec\n- **Token estimate**: ~22K (full), ~6K (summarized)\n\n### packages/shared-types\n- **Path**: /packages/shared-types\n- **Tech**: TypeScript\n- **Role**: Shared type definitions\n- **Consumed by**: apps/web, apps/api (codegen)\n- **Key files**:\n  - `src/index.ts` - All exports (340 lines)\n- **Token estimate**: ~3K\n\n### packages/db\n- **Path**: /packages/db\n- **Tech**: Drizzle ORM, TypeScript\n- **Role**: Database schema, migrations\n- **Consumed by**: apps/api\n- **Key files**:\n  - `schema/` - Table definitions (8 files)\n  - `migrations/` - Migration history (23 files)\n- **Token estimate**: ~8K (full), ~2K (schema only)\n```\n\n### CONTRACTS.md Format\n\n```markdown\n# API Contracts\n\nGenerated: 2026-01-20T14:32:00Z\nLast sync: 2026-01-20T16:45:00Z\nSources: 3 files monitored\n\n## REST API: apps/api → apps/web\n\n### Endpoints Summary (47 total)\n\n| Domain | Count | Key Endpoints |\n|--------|-------|---------------|\n| /api/auth | 5 | POST /login, POST /register, POST /refresh |\n| /api/users | 6 | GET /me, PATCH /me, GET /:id |\n| /api/campaigns | 8 | CRUD + POST /bulk, GET /analytics |\n| /api/analytics | 12 | GET /dashboard, GET /timeseries, GET /funnel |\n| /api/settings | 4 | GET /, PATCH /, GET /integrations |\n\n### Key Types\n\n```typescript\n// Campaign domain (from apps/api/app/schemas/campaign.py)\ninterface Campaign {\n  id: string;\n  name: string;\n  status: 'draft' | 'active' | 'paused' | 'completed';\n  budget: number;\n  target_audience: TargetAudience;\n  created_at: string;\n  updated_at: string;\n}\n\ninterface CampaignCreate {\n  name: string;\n  budget: number;\n  target_audience?: TargetAudience;\n}\n\n// Auth domain (from apps/api/app/schemas/auth.py)\ninterface User {\n  id: string;\n  email: string;\n  name: string;\n  role: 'user' | 'admin';\n}\n\ninterface TokenPair {\n  access_token: string;\n  refresh_token: string;\n  expires_in: number;\n}\n```\n\n### Contract Validation Status\n\n| Check | Status | Details |\n|-------|--------|---------|\n| OpenAPI matches routes | ✅ | 47/47 endpoints documented |\n| Types match schemas | ✅ | All Pydantic models exported |\n| Frontend types current | ⚠️ | 2 types need regeneration |\n\n## Shared Types: packages/shared-types\n\n### Exported Types (34 total)\n\n| Category | Types | Used By |\n|----------|-------|---------|\n| Domain models | Campaign, User, Analytics | web, api |\n| API responses | ApiResponse<T>, PaginatedResponse<T> | web |\n| Utilities | DateRange, FilterParams | web, api |\n\n## Database Schema: packages/db\n\n### Tables (12 total)\n\n| Table | Key Columns | Relations |\n|-------|-------------|-----------|\n| users | id, email, name, role | campaigns, sessions |\n| campaigns | id, user_id, name, status | analytics, targets |\n| analytics | id, campaign_id, date, metrics | campaigns |\n```\n\n### DEPENDENCY_GRAPH.md Format\n\n```markdown\n# Dependency Graph\n\nGenerated: 2026-01-20T14:32:00Z\n\n## Visual Overview\n\n```\n                    ┌─────────────────┐\n                    │  packages/db    │\n                    │  (Drizzle ORM)  │\n                    └────────┬────────┘\n                             │\n                             ▼\n┌─────────────────┐   ┌─────────────────┐\n│    apps/web     │◄──│    apps/api     │\n│   (Next.js)     │   │   (FastAPI)     │\n└────────┬────────┘   └────────┬────────┘\n         │                     │\n         ▼                     ▼\n┌─────────────────────────────────────────┐\n│        packages/shared-types            │\n│           (TypeScript)                  │\n└─────────────────────────────────────────┘\n```\n\n## Dependency Matrix\n\n| Module | Depends On | Depended By |\n|--------|------------|-------------|\n| apps/web | shared-types, apps/api (runtime) | - |\n| apps/api | shared-types (codegen), db | apps/web |\n| packages/shared-types | - | apps/web, apps/api |\n| packages/db | - | apps/api |\n\n## Import Analysis\n\n### apps/web imports:\n```\n@repo/shared-types: 23 files\napps/api (via fetch): 15 files\n```\n\n### apps/api imports:\n```\npackages/db: 12 files\npackages/shared-types (codegen): 8 files\n```\n\n## API Call Graph\n\n```\napps/web                          apps/api\n─────────                         ────────\nsrc/lib/api/client.ts ──────────► app/routes/auth.py\n  └── login()          POST /api/auth/login\n  └── register()       POST /api/auth/register\n\nsrc/app/campaigns/page.tsx ─────► app/routes/campaigns.py\n  └── getCampaigns()   GET /api/campaigns\n  └── createCampaign() POST /api/campaigns\n```\n```\n\n### KEY_FILES.md Format\n\n```markdown\n# Key Files by Context\n\n## Context: Frontend API Integration\n**When**: Modifying API calls, response handling, or API types in frontend\n\nLoad these files (~8K tokens):\n```\napps/web/src/lib/api/client.ts       # API client implementation\napps/web/src/types/api.d.ts          # Frontend API types\napps/api/openapi.json                # Full API spec (or summary)\npackages/shared-types/src/index.ts   # Shared type definitions\n```\n\n## Context: Backend Endpoint Development\n**When**: Adding/modifying API endpoints\n\nLoad these files (~12K tokens):\n```\napps/api/app/routes/                 # Existing route patterns\napps/api/app/schemas/                # Pydantic models (relevant domain)\napps/api/app/dependencies/           # Auth, DB dependencies\npackages/db/schema/                  # Relevant table definitions\n```\n\n## Context: Database Changes\n**When**: Schema modifications, migrations, queries\n\nLoad these files (~6K tokens):\n```\npackages/db/schema/                  # All table definitions\npackages/db/migrations/              # Last 5 migrations\napps/api/app/models/                 # ORM model usage\n```\n\n## Context: Shared Types\n**When**: Modifying interfaces used across modules\n\nLoad these files (~4K tokens):\n```\npackages/shared-types/src/           # Type source files\napps/web/src/types/api.d.ts          # Consumer (frontend)\napps/api/app/schemas/                # Source (backend)\n```\n\n## Context: Authentication\n**When**: Auth flow, sessions, tokens\n\nLoad these files (~5K tokens):\n```\napps/api/app/routes/auth.py          # Auth endpoints\napps/api/app/dependencies/auth.py    # Auth middleware\napps/web/src/lib/auth/               # Frontend auth handling\npackages/shared-types/src/auth.ts    # Auth types\n```\n\n## Load-on-Demand Triggers\n\n| Claude detects... | Load additionally |\n|-------------------|-------------------|\n| \"check the API contract\" | Full OpenAPI spec |\n| Import from another module | That module's exports |\n| Database query pattern | Full schema definitions |\n| Test failure in other module | That module's test files |\n| \"breaking change\" | Both sides of the contract |\n```\n\n### CROSS_REPO_INDEX.md Format\n\n```markdown\n# Cross-Repository Capability Index\n\nGenerated: 2026-01-20T14:32:00Z\n\n## Capabilities by Domain\n\n### Authentication\n| Capability | Location | Module | Type |\n|------------|----------|--------|------|\n| Login user | POST /api/auth/login | apps/api | endpoint |\n| Register user | POST /api/auth/register | apps/api | endpoint |\n| Refresh token | POST /api/auth/refresh | apps/api | endpoint |\n| Auth context | src/contexts/AuthContext.tsx | apps/web | component |\n| Auth hook | src/hooks/useAuth.ts | apps/web | hook |\n| User type | src/auth.ts | shared-types | type |\n| Session type | src/auth.ts | shared-types | type |\n\n### Campaigns\n| Capability | Location | Module | Type |\n|------------|----------|--------|------|\n| List campaigns | GET /api/campaigns | apps/api | endpoint |\n| Create campaign | POST /api/campaigns | apps/api | endpoint |\n| Campaign CRUD | app/routes/campaigns.py | apps/api | router |\n| Campaign form | src/components/CampaignForm.tsx | apps/web | component |\n| Campaign type | src/campaign.ts | shared-types | type |\n| campaigns table | schema/campaigns.ts | packages/db | table |\n\n### Analytics\n| Capability | Location | Module | Type |\n|------------|----------|--------|------|\n| Dashboard data | GET /api/analytics/dashboard | apps/api | endpoint |\n| Timeseries | GET /api/analytics/timeseries | apps/api | endpoint |\n| Analytics hook | src/hooks/useAnalytics.ts | apps/web | hook |\n| Chart components | src/components/charts/ | apps/web | components |\n\n## Search Index\n\nBefore implementing new functionality, search this index:\n\n```\nQ: \"How do I get the current user?\"\nA: Use useAuth() hook from apps/web/src/hooks/useAuth.ts\n   Or GET /api/users/me endpoint from apps/api\n\nQ: \"Where are campaign types defined?\"\nA: Source of truth: packages/shared-types/src/campaign.ts\n   Backend schema: apps/api/app/schemas/campaign.py\n   Frontend types: apps/web/src/types/api.d.ts (generated)\n\nQ: \"How do I add a new API endpoint?\"\nA: Pattern in apps/api/app/routes/campaigns.py\n   Register in apps/api/app/routes/__init__.py\n   Add types to packages/shared-types\n   Regenerate frontend types\n```\n```\n\n---\n\n## Token Budget Management\n\n### Context Limits\n\n```\n┌─────────────────────────────────────────────────────────────────┐\n│  TOKEN BUDGET ALLOCATION                                         │\n├─────────────────────────────────────────────────────────────────┤\n│  Total context: ~200K tokens                                     │\n│  Reserve for output: ~50K tokens                                 │\n│  Working budget: ~150K tokens                                    │\n├─────────────────────────────────────────────────────────────────┤\n│  P0 (Must have):     50K │ Current module (full)                │\n│  P1 (Should have):   40K │ Directly related modules (summary)   │\n│  P2 (Nice to have):  30K │ Contracts + shared types             │\n│  P3 (If room):       20K │ Decisions, todos, history            │\n│  Buffer:             10K │ Dynamic loading during session       │\n└─────────────────────────────────────────────────────────────────┘\n```\n\n### Automatic Summarization\n\nWhen loading cross-module context, summarize:\n\n| Content Type | Full Load Threshold | Summarization Strategy |\n|--------------|---------------------|------------------------|\n| OpenAPI spec | < 50 endpoints | Endpoints + key types only |\n| Type files | < 30 types | Exported types only |\n| Route files | < 200 lines | Signatures + docstrings |\n| Config files | < 50 lines | Keys only (no values/secrets) |\n| Test files | Never full | Only on explicit request |\n\n### Context Loading Strategy\n\n```\n┌─────────────────────────────────────────────────────────────────┐\n│  CONTEXT LOADING HIERARCHY                                       │\n├─────────────────────────────────────────────────────────────────┤\n│  Level 1: Always loaded (~5K tokens)                             │\n│  ├── TOPOLOGY.md (workspace structure)                          │\n│  ├── CONTRACTS.md (API summary)                                 │\n│  └── CROSS_REPO_INDEX.md (capability search)                    │\n│                                                                  │\n│  Level 2: Loaded based on current file (~15K tokens)            │\n│  ├── KEY_FILES.md recommendations for current context           │\n│  ├── Related module summaries                                   │\n│  └── Relevant type definitions                                  │\n│                                                                  │\n│  Level 3: On-demand expansion (variable)                        │\n│  ├── Full OpenAPI spec (when \"check API contract\")              │\n│  ├── Full type files (when modifying interfaces)                │\n│  └── Other module's full files (when cross-repo change)         │\n└─────────────────────────────────────────────────────────────────┘\n```\n\n---\n\n## Multi-Repo File Access\n\nFor multi-repo workspaces (separate .git directories):\n\n### Option 1: Sibling Directory Convention (Recommended)\n\n```\n~/code/\n├── myapp-frontend/     # git repo\n├── myapp-backend/      # git repo\n├── myapp-shared/       # git repo\n└── .workspace/         # workspace config (optional)\n    └── myapp.yaml\n```\n\nClaude accesses via relative paths: `../myapp-backend/`\n\n### Option 2: Workspace Symlinks\n\n```bash\n# In frontend repo\nmkdir -p .workspace/repos\nln -s ../../myapp-backend .workspace/repos/backend\nln -s ../../myapp-shared .workspace/repos/shared\n```\n\n### Option 3: Git Submodules\n\n```bash\n# Add related repos as submodules (read-only)\ngit submodule add --depth 1 ../myapp-shared .workspace/shared\n```\n\n### File Access Rules\n\n```markdown\n## Multi-Repo Access Protocol\n\nWHEN accessing files from another repo:\n1. Use relative paths from workspace root\n2. Read-only access (never modify other repos)\n3. Cache contract files locally in _project_specs/workspace/cache/\n4. Log cross-repo reads in decisions.md\n\nBEFORE making cross-repo changes:\n1. Document the change in BOTH repos' decisions.md\n2. Create linked todos in BOTH repos\n3. Implement in dependency order (shared → backend → frontend)\n```\n\n---\n\n## Cross-Repo Change Detection\n\nWhen Claude detects changes that affect other modules:\n\n```\n┌─────────────────────────────────────────────────────────────────┐\n│  ⚠️  CROSS-REPO CHANGE DETECTED                                  │\n├─────────────────────────────────────────────────────────────────┤\n│  This change affects: apps/api                                   │\n│  Specifically: Endpoint POST /api/campaigns expects new field    │\n│                                                                  │\n│  Impact Analysis:                                                │\n│  ├── apps/web/src/lib/api/client.ts - needs update              │\n│  ├── packages/shared-types/src/campaign.ts - needs new field    │\n│  └── apps/api/app/schemas/campaign.py - source of change        │\n│                                                                  │\n│  Recommended Order:                                              │\n│  1. Update packages/shared-types first (source of truth)         │\n│  2. Update apps/api schema                                       │\n│  3. Regenerate frontend types                                    │\n│  4. Update apps/web API client                                   │\n│  5. Run /sync-contracts                                          │\n│                                                                  │\n│  [Proceed with guidance] [Load full context] [Cancel]            │\n└─────────────────────────────────────────────────────────────────┘\n```\n\n### Change Impact Patterns\n\n| Change Type | Impacts | Action |\n|-------------|---------|--------|\n| New API endpoint | Frontend client, types | Add to both, sync contracts |\n| Modified response | Frontend types, tests | Regenerate types, update tests |\n| New required field | All consumers | Breaking change protocol |\n| Renamed field | All consumers | Migration + deprecation |\n| New shared type | Consumers on next use | Export from shared-types |\n| Schema migration | API models, queries | Run migration, verify queries |\n\n---\n\n## Contract Freshness System\n\n### Staleness Detection\n\n```bash\n# .contract-sources file (auto-generated)\n# Files that define contracts - monitored for changes\n\n# OpenAPI specs\napps/api/openapi.json\napps/api/docs/openapi.yaml\n\n# Type definitions\npackages/shared-types/src/index.ts\npackages/shared-types/src/api.ts\n\n# Pydantic schemas\napps/api/app/schemas/*.py\n\n# Database schema\npackages/db/schema/*.ts\n```\n\n### Freshness Tiers\n\n| Tier | Trigger | Action | Time | Blocking |\n|------|---------|--------|------|----------|\n| 1 | Session start | Staleness check | ~5s | No |\n| 2 | Post-commit | Auto-sync if contracts changed | ~15s | No |\n| 3 | Pre-push | Validation gate | ~10s | Yes (bypassable) |\n| 4 | PR opened | CI validation | ~30s | Yes |\n| 5 | Weekly cron | Full re-analysis | ~2min | No |\n\n### Freshness Indicators\n\n```markdown\n## Contract Status (shown in CONTRACTS.md header)\n\nLast full analysis: 2026-01-18T10:00:00Z\nLast sync: 2026-01-20T14:32:00Z\nStaleness: 🟢 Fresh (synced 2 hours ago)\n\n## Confidence Levels\n\n🟢 Fresh     - Synced within 24 hours, no source changes\n🟡 Stale     - Sources changed since last sync\n🔴 Outdated  - Over 7 days since last analysis\n⚠️  Drift    - Validation found inconsistencies\n```\n\n---\n\n## Integration with Existing Skills\n\n### With existing-repo.md\n\n`workspace.md` calls `existing-repo.md` analysis for each module:\n\n```markdown\n## Module Analysis Delegation\n\nFor each module in workspace:\n1. Run existing-repo analysis on that module\n2. Extract: tech stack, conventions, guardrails status\n3. Aggregate into TOPOLOGY.md\n4. Don't duplicate - reference existing-repo output\n```\n\n### With session-management.md\n\n```markdown\n## Session State Integration\n\nWorkspace context files are part of session state:\n- TOPOLOGY.md → structural context (rarely changes)\n- CONTRACTS.md → API context (check freshness each session)\n- KEY_FILES.md → loading guidance (static reference)\n\nOn session start:\n1. Load _project_specs/workspace/*.md into context\n2. Check contract freshness\n3. Advise if sync needed\n```\n\n### With code-review.md\n\n```markdown\n## Cross-Repo Review Checks\n\nWhen reviewing code that touches contracts:\n\n1. Check if change affects other modules\n2. Verify contract consistency\n3. Flag if CONTRACTS.md needs update\n4. Warn about breaking changes\n\nAdd to review output:\n### 🔗 Cross-Repo Impact\n- [ ] This change affects: apps/web (API client)\n- [ ] Contract update needed: Yes\n- [ ] Breaking change: No\n```\n\n---\n\n## Commands\n\n### /analyze-workspace\n\nFull workspace analysis - run on first setup or major changes.\n\nSee `commands/analyze-workspace.md` for full specification.\n\n### /sync-contracts\n\nLightweight incremental contract update - run frequently.\n\nSee `commands/sync-contracts.md` for full specification.\n\n### /workspace-status\n\nQuick status check:\n\n```\n📊 Workspace Status: myapp\n\nType: Monorepo (Turborepo)\nModules: 4 (2 apps, 2 packages)\nContracts: 🟢 Fresh (synced 2h ago)\nToken estimate: 45K / 150K budget\n\nQuick actions:\n  /sync-contracts     - Update contracts\n  /analyze-workspace  - Full refresh\n```\n\n---\n\n## CI/CD Integration\n\n### GitHub Actions: Contract Validation\n\n```yaml\n# .github/workflows/contracts.yml\nname: Contract Validation\n\non:\n  pull_request:\n    paths:\n      - 'apps/api/**'\n      - 'packages/shared-types/**'\n      - 'packages/db/schema/**'\n\njobs:\n  validate:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Check contract freshness\n        run: |\n          CHANGED=$(git diff --name-only origin/main HEAD | \\\n            grep -E \"openapi|schema|types\" || true)\n\n          if [ -n \"$CHANGED\" ]; then\n            echo \"Contract sources changed:\"\n            echo \"$CHANGED\"\n\n            if ! git diff --name-only origin/main HEAD | grep -q \"CONTRACTS.md\"; then\n              echo \"::error::Contract sources changed but CONTRACTS.md not updated\"\n              echo \"Run /sync-contracts before merging\"\n              exit 1\n            fi\n          fi\n\n      - name: Validate consistency\n        run: |\n          if [ -f \"apps/api/openapi.json\" ]; then\n            ENDPOINTS=$(jq -r '.paths | keys | length' apps/api/openapi.json)\n            DOCUMENTED=$(grep -c \"^| /\" _project_specs/workspace/CONTRACTS.md || echo 0)\n\n            if [ \"$ENDPOINTS\" != \"$DOCUMENTED\" ]; then\n              echo \"::warning::Endpoint count mismatch\"\n            fi\n          fi\n```\n\n### Pre-commit Hook\n\n```bash\n#!/bin/bash\n# hooks/pre-commit-contracts\n\nWORKSPACE_DIR=\"_project_specs/workspace\"\n[ ! -f \"$WORKSPACE_DIR/.contract-sources\" ] && exit 0\n\n# Check if staged files include contract sources\nSTAGED=$(git diff --cached --name-only)\nCONTRACT_SOURCES=$(cat \"$WORKSPACE_DIR/.contract-sources\")\n\nfor source in $CONTRACT_SOURCES; do\n  if echo \"$STAGED\" | grep -q \"$source\"; then\n    echo \"📝 Contract source staged: $source\"\n    echo \"Remember to run /sync-contracts before pushing\"\n  fi\ndone\n```\n\n---\n\n## Troubleshooting\n\n### \"Workspace not detected\"\n\n```bash\n# Check for workspace indicators\nls -la package.json pnpm-workspace.yaml turbo.json nx.json 2>/dev/null\n\n# If multi-repo, check sibling directories\nls -la ../\n\n# Manual classification\n/analyze-workspace --type monorepo\n/analyze-workspace --type multi-repo --repos \"../backend,../shared\"\n```\n\n### \"Contract sync failed\"\n\n```bash\n# Check contract sources exist\ncat _project_specs/workspace/.contract-sources\n\n# Verify file access\nfor f in $(cat .contract-sources); do\n  ls -la \"$f\" 2>/dev/null || echo \"Missing: $f\"\ndone\n\n# Force full refresh\n/analyze-workspace --force\n```\n\n### \"Token budget exceeded\"\n\n```bash\n# Check current estimates\n/workspace-status\n\n# Reduce context loading\n# Edit KEY_FILES.md to prioritize\n# Or work on one module at a time\n```\n\n### \"Cross-repo access denied\"\n\n```bash\n# Check paths are correct\nls ../backend/  # or wherever related repo is\n\n# Set up symlinks if needed\nmkdir -p .workspace/repos\nln -s ../../backend .workspace/repos/backend\n\n# Or configure in workspace\n/analyze-workspace --repo-path backend=../myapp-backend\n```\n"
  },
  {
    "path": "templates/AGENTS.md",
    "content": "# AGENTS.md\n\n## Personality\n\nYou are a brilliant engineer who also happens to be genuinely funny. Think dry wit, clever observations, and well-timed one-liners. You:\n\n- Drop a joke or witty remark naturally into your responses (not forced, not every single line)\n- Use self-deprecating humor about AI when it fits (\"I've reviewed 500 lines of code and my only complaint is that I can't drink coffee while doing it\")\n- Make cheeky comments about bad code patterns (\"Ah yes, a 400-line function. Bold choice. I admire the confidence.\")\n- Celebrate wins with personality (\"Tests passing. Chef's kiss. Gordon Ramsay would weep.\")\n- Keep the humor punchy, never at the user's expense, and never let it get in the way of actually being helpful\n- Match energy: if the user is stressed about a deadline, read the room. If they're vibing, vibe back.\n- No dad jokes. No \"as an AI\" disclaimers. No cringe. Think more \"witty coworker\" than \"corporate chatbot trying to be relatable.\"\n\n## Skills\n@.agents/skills/base/SKILL.md\n@.agents/skills/iterative-development/SKILL.md\n@.agents/skills/security/SKILL.md\n@.agents/skills/cross-agent-delegation/SKILL.md\n\n## Project Context\n- Language: [e.g., TypeScript]\n- Framework: [e.g., Next.js 14 (App Router)]\n- Database: [e.g., Supabase/PostgreSQL]\n- ORM: [e.g., Drizzle]\n- Testing: [e.g., Vitest]\n- Auth: [e.g., Supabase Auth]\n\n## Commands\n[npm test]                     # run tests\n[npm run test:coverage]        # tests with coverage\n[npm run lint]                 # lint\n[npm run typecheck]            # type check\n[npm run dev]                  # local dev server\n\n## Project Structure\n[Fill in after project setup, e.g.:]\nsrc/\n  app/           # Pages / routes\n  components/    # UI components\n  lib/           # Shared utilities\n  db/\n    schema.ts    # Database schema — read before any DB code\n    migrations/  # Database migrations\n  api/           # API route handlers\n\n## Key Decisions\n[Document settled architectural choices so the agent doesn't re-litigate them, e.g.:]\n- [ORM choice and why]\n- [Auth approach]\n- [State management approach]\n- [Branch strategy: feature branches off main, squash merge via PR]\n- [Environment variables validated at startup via src/lib/env.ts]\n\n## Conventions\n[Document patterns the agent should follow, e.g.:]\n- Colocated tests: Component.test.tsx next to Component.tsx\n- API routes return { data, error } shape\n- Database queries go through src/db/queries/ — never raw SQL in routes\n- Use existing utilities before creating new ones — check src/lib/ first\n\n## Cross-Agent Workflow\n\n### Codex Auto-Review (Stop Hook)\nAfter tests pass, Codex automatically reviews changes for bugs/security.\nCritical/High findings feed back to the agent for fixing. Requires: `codex` CLI installed.\n\n### Kimi Delegation (Token Optimization)\nThe orchestrating agent delegates to Kimi automatically:\n- Blast radius <= 3 files: Delegate to Kimi via `kimi --print -y -p \"...\"`\n- Blast radius 4-8 files: Ask user, then delegate or handle directly\n- Blast radius > 8 files: Handle directly (needs full context)\nContext is passed via `mnemos checkpoint` + `mnemos resume` (not raw conversation).\n\n### iCPG (Always-On for All Agents)\nBefore ANY code change in ANY tool (Claude, Kimi, Codex):\n1. `icpg query prior \"<goal>\"` — check for duplicate work\n2. `icpg query constraints <file>` — check invariants\n3. `icpg query risk <symbol>` — check fragility\n\n### Mnemos (Always-On for All Agents)\nAll agents use Mnemos for memory management:\n- `mnemos add goal \"<task>\"` at task start\n- `mnemos checkpoint` at sub-goal boundaries\n- Session hooks auto-manage fatigue and checkpoints\n\n## Don't\n- Don't modify .env files\n- Don't add packages without checking if existing deps cover the need\n- Don't put secrets in client-exposed env vars (NEXT_PUBLIC_*, VITE_*)\n- Don't skip the test phase\n"
  },
  {
    "path": "templates/CLAUDE.local.md",
    "content": "# CLAUDE.local.md - Private Developer Overrides\n# This file is NOT checked into git. Use it for personal preferences.\n\n# Uncomment and customize what applies to you:\n\n# ## My Preferences\n# - I prefer verbose explanations over terse responses\n# - Skip type annotations in my PRs\n# - I'm new to this codebase, explain more context\n\n# ## Local Environment\n# - My local DB runs on port 5433\n# - Use `pnpm` instead of `npm` for my setup\n\n# ## Override Quality Gates\n# - Allow 30 lines per function (I prefer fewer files)\n# - Skip coverage check for prototype work\n"
  },
  {
    "path": "templates/CLAUDE.md",
    "content": "# CLAUDE.md\n\n## Personality\n\nYou are a brilliant engineer who also happens to be genuinely funny. Think dry wit, clever observations, and well-timed one-liners. You:\n\n- Drop a joke or witty remark naturally into your responses (not forced, not every single line)\n- Use self-deprecating humor about AI when it fits (\"I've reviewed 500 lines of code and my only complaint is that I can't drink coffee while doing it\")\n- Make cheeky comments about bad code patterns (\"Ah yes, a 400-line function. Bold choice. I admire the confidence.\")\n- Celebrate wins with personality (\"Tests passing. Chef's kiss. Gordon Ramsay would weep.\")\n- Keep the humor punchy, never at the user's expense, and never let it get in the way of actually being helpful\n- Match energy: if the user is stressed about a deadline, read the room. If they're vibing, vibe back.\n- No dad jokes. No \"as an AI\" disclaimers. No cringe. Think more \"witty coworker\" than \"corporate chatbot trying to be relatable.\"\n\n## Skills\n@.claude/skills/base/SKILL.md\n@.claude/skills/iterative-development/SKILL.md\n@.claude/skills/security/SKILL.md\n@.claude/skills/mnemos/SKILL.md\n@.claude/skills/cross-agent-delegation/SKILL.md\n@.claude/skills/polyphony/SKILL.md\n\n## Project Context\n- Language: [e.g., TypeScript]\n- Framework: [e.g., Next.js 14 (App Router)]\n- Database: [e.g., Supabase/PostgreSQL]\n- ORM: [e.g., Drizzle]\n- Testing: [e.g., Vitest]\n- Auth: [e.g., Supabase Auth]\n\n## Commands\n[npm test]                     # run tests\n[npm run test:coverage]        # tests with coverage\n[npm run lint]                 # lint\n[npm run typecheck]            # type check\n[npm run dev]                  # local dev server\n\n## Project Structure\n[Fill in after project setup, e.g.:]\nsrc/\n  app/           # Pages / routes\n  components/    # UI components\n  lib/           # Shared utilities\n  db/\n    schema.ts    # Database schema — read before any DB code\n    migrations/  # Database migrations\n  api/           # API route handlers\n\n## Key Decisions\n[Document settled architectural choices so Claude doesn't re-litigate them, e.g.:]\n- [ORM choice and why]\n- [Auth approach]\n- [State management approach]\n- [Branch strategy: feature branches off main, squash merge via PR]\n- [Environment variables validated at startup via src/lib/env.ts]\n\n## Conventions\n[Document patterns Claude should follow, e.g.:]\n- Colocated tests: Component.test.tsx next to Component.tsx\n- API routes return { data, error } shape\n- Database queries go through src/db/queries/ — never raw SQL in routes\n- Use existing utilities before creating new ones — check src/lib/ first\n\n## Cross-Agent Workflow\n\n### Codex Auto-Review (Stop Hook)\nAfter tests pass, Codex automatically reviews changes for bugs/security.\nCritical/High findings feed back to Claude for fixing. Requires: `codex` CLI installed.\n\n### Kimi Delegation (Token Optimization)\nClaude orchestrates Kimi delegation automatically:\n- Blast radius <= 3 files: Claude delegates to Kimi via `kimi --print -y -p \"...\"`\n- Blast radius 4-8 files: Claude asks user, then delegates or handles directly\n- Blast radius > 8 files: Claude handles it (needs full context)\nContext is passed via `mnemos checkpoint` + `mnemos resume` (not raw conversation).\n\n### Container Isolation (Polyphony)\nWhen Docker is available, each feature agent runs in its own container with an independent git branch.\n- `/spawn-team` uses Polyphony by default (fallback to native agents if no Docker)\n- `polyphony status` to see running agents\n- `polyphony cleanup` after completion\n\n### iCPG (Always-On for All Agents)\nBefore ANY code change in ANY tool (Claude, Kimi, Codex):\n1. `icpg query prior \"<goal>\"` — check for duplicate work\n2. `icpg query constraints <file>` — check invariants\n3. `icpg query risk <symbol>` — check fragility\n\n### Mnemos (Always-On for All Agents)\nAll agents use Mnemos for memory management:\n- `mnemos add goal \"<task>\"` at task start\n- `mnemos checkpoint` at sub-goal boundaries\n- Session hooks auto-manage fatigue and checkpoints\n\n## Don't\n- Don't modify .env files\n- Don't add packages without checking if existing deps cover the need\n- Don't put secrets in client-exposed env vars (NEXT_PUBLIC_*, VITE_*)\n- Don't skip the test phase\n"
  },
  {
    "path": "templates/Dockerfile.polyphony",
    "content": "FROM python:3.12-slim AS base\nRUN apt-get update && apt-get install -y --no-install-recommends \\\n    git curl ca-certificates gnupg && rm -rf /var/lib/apt/lists/*\n\n# Node.js 20 (JS/TS projects)\nRUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \\\n    apt-get install -y nodejs && rm -rf /var/lib/apt/lists/*\n\n# GitHub CLI\nRUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \\\n    | gpg --dearmor -o /usr/share/keyrings/githubcli.gpg && \\\n    echo \"deb [signed-by=/usr/share/keyrings/githubcli.gpg] \\\n    https://cli.github.com/packages stable main\" \\\n    > /etc/apt/sources.list.d/github-cli.list && \\\n    apt-get update && apt-get install -y gh && rm -rf /var/lib/apt/lists/*\n\nRUN useradd -m -s /bin/bash worker\nUSER worker\nWORKDIR /workspace\n\n# Auth mounted at runtime: -v ~/.claude:/home/worker/.claude:ro\n# Agent CLI installed via volume or ARG at build time\n"
  },
  {
    "path": "templates/codex-auto-review.sh",
    "content": "#!/bin/bash\n# codex-auto-review.sh — Stop hook: auto-review with Codex after tests pass\n# Exit 0 = pass (no issues or codex not installed)\n# Exit 2 = critical/high issues found (feeds back to Claude)\n#\n# Install: copy to .claude/scripts/codex-auto-review.sh\n# Requires: codex CLI (npm i -g @openai/codex)\n\nset -uo pipefail\n\nREVIEW_FILE=\"/tmp/codex-review-$$.txt\"\n\ncheck_codex() {\n    command -v codex &>/dev/null\n}\n\nget_changed_files() {\n    git diff --name-only HEAD 2>/dev/null\n    git diff --cached --name-only 2>/dev/null\n}\n\nhas_changes() {\n    local files\n    files=$(get_changed_files | sort -u | grep -cE '\\.(ts|tsx|js|jsx|py|go|rs|java|kt)$' || true)\n    [ \"$files\" -gt 0 ]\n}\n\nrun_codex_review() {\n    local diff_content\n    diff_content=$(git diff HEAD 2>/dev/null; git diff --cached 2>/dev/null)\n    [ -z \"$diff_content\" ] && return 0\n\n    # Truncate diff to avoid token limits (keep first 8000 chars)\n    local truncated\n    truncated=$(echo \"$diff_content\" | head -c 8000)\n\n    codex exec \\\n        --full-auto \\\n        --sandbox read-only \\\n        --output-last-message \"$REVIEW_FILE\" \\\n        \"Review this diff for critical bugs and security issues only. Be concise. Flag only Critical or High severity: $truncated\" \\\n        2>/dev/null\n}\n\ncheck_findings() {\n    [ -f \"$REVIEW_FILE\" ] || return 0\n\n    if grep -qiE 'critical|🔴|security vulnerability|injection' \"$REVIEW_FILE\"; then\n        echo \"CODEX AUTO-REVIEW: Critical issues found:\" >&2\n        cat \"$REVIEW_FILE\" >&2\n        rm -f \"$REVIEW_FILE\"\n        return 2\n    fi\n\n    if grep -qiE '🟠|high severity' \"$REVIEW_FILE\"; then\n        echo \"CODEX AUTO-REVIEW: High severity issues:\" >&2\n        cat \"$REVIEW_FILE\" >&2\n        rm -f \"$REVIEW_FILE\"\n        return 2\n    fi\n\n    rm -f \"$REVIEW_FILE\"\n    return 0\n}\n\nmain() {\n    # Skip if codex not installed\n    check_codex || exit 0\n\n    # Skip if no code changes\n    has_changes || exit 0\n\n    # Run review\n    run_codex_review || exit 0\n\n    # Check for critical/high findings\n    check_findings\n    exit $?\n}\n\nmain\n"
  },
  {
    "path": "templates/config.toml",
    "content": "# Agent CLI Configuration\n# Compatible with Kimi CLI and OpenAI Codex CLI\n# Generated from Maggy settings.json hooks\n#\n# Kimi: copy to ~/.kimi/config.toml or .kimi/config.toml\n# Codex: copy to ~/.codex/config.toml or .codex/config.toml\n\n# ─── Skills merge (Kimi-specific) ───────────────────────────\n# Kimi reads skills from ~/.kimi/, ~/.claude/, ~/.codex/\n# merge_all_available_skills = true  # default: merge all brands\n\n# ─── Hook: Pre-Compact (Mnemos checkpoint) ──────────────────\n[[hooks]]\nevent = \"PreCompact\"\ncommand = \"\"\"\nif [ -x \".claude/scripts/mnemos-pre-compact.sh\" ]; then\n  exec \".claude/scripts/mnemos-pre-compact.sh\"\nfi\nif [ -x \"$HOME/.claude/templates/mnemos-pre-compact.sh\" ]; then\n  exec \"$HOME/.claude/templates/mnemos-pre-compact.sh\"\nfi\nexit 0\n\"\"\"\ntimeout = 8\n\n# ─── Hook: Pre-Edit (Mnemos fatigue + intent check) ────────\n[[hooks]]\nevent = \"PreToolUse\"\nmatcher = \"Edit|Write|StrReplaceFile|WriteFile\"\ncommand = \"\"\"\nif [ -x \".claude/scripts/mnemos-pre-edit.sh\" ]; then\n  exec \".claude/scripts/mnemos-pre-edit.sh\"\nfi\nif [ -x \"$HOME/.claude/templates/mnemos-pre-edit.sh\" ]; then\n  exec \"$HOME/.claude/templates/mnemos-pre-edit.sh\"\nfi\nexit 0\n\"\"\"\ntimeout = 5\n\n# ─── Hook: Post-Compact Restore ────────────────────────────\n[[hooks]]\nevent = \"PreToolUse\"\ncommand = \"\"\"\nif [ -x \".claude/scripts/mnemos-post-compact-inject.sh\" ]; then\n  exec \".claude/scripts/mnemos-post-compact-inject.sh\"\nfi\nif [ -x \"$HOME/.claude/templates/mnemos-post-compact-inject.sh\" ]; then\n  exec \"$HOME/.claude/templates/mnemos-post-compact-inject.sh\"\nfi\nexit 0\n\"\"\"\ntimeout = 2\n\n# ─── Hook: Post-Tool (Mnemos logging) ──────────────────────\n[[hooks]]\nevent = \"PostToolUse\"\ncommand = \"\"\"\nif [ -x \".claude/scripts/mnemos-post-tool.sh\" ]; then\n  exec \".claude/scripts/mnemos-post-tool.sh\"\nfi\nif [ -x \"$HOME/.claude/templates/mnemos-post-tool.sh\" ]; then\n  exec \"$HOME/.claude/templates/mnemos-post-tool.sh\"\nfi\nexit 0\n\"\"\"\ntimeout = 1\n\n# ─── Hook: TDD Loop Check (Stop) ───────────────────────────\n[[hooks]]\nevent = \"Stop\"\ncommand = \"\"\"\nif [ -x \".claude/scripts/tdd-loop-check.sh\" ]; then\n  exec \".claude/scripts/tdd-loop-check.sh\"\nfi\nif [ -x \"$HOME/.claude/templates/tdd-loop-check.sh\" ]; then\n  exec \"$HOME/.claude/templates/tdd-loop-check.sh\"\nfi\nexit 0\n\"\"\"\ntimeout = 60\n\n# ─── Hook: Codex Auto-Review (Stop) ──────────────────────────\n[[hooks]]\nevent = \"Stop\"\ncommand = \"\"\"\nif command -v codex &>/dev/null; then\n  if [ -x \".claude/scripts/codex-auto-review.sh\" ]; then\n    exec \".claude/scripts/codex-auto-review.sh\"\n  elif [ -x \"$HOME/.claude/templates/codex-auto-review.sh\" ]; then\n    exec \"$HOME/.claude/templates/codex-auto-review.sh\"\n  fi\nfi\nexit 0\n\"\"\"\ntimeout = 120\n\n# ─── Hook: ICPG Stop Record ────────────────────────────────\n[[hooks]]\nevent = \"Stop\"\ncommand = \"\"\"\nif [ -x \".claude/scripts/icpg-stop-record.sh\" ]; then\n  exec \".claude/scripts/icpg-stop-record.sh\"\nfi\nif [ -x \"$HOME/.claude/templates/icpg-stop-record.sh\" ]; then\n  exec \"$HOME/.claude/templates/icpg-stop-record.sh\"\nfi\nexit 0\n\"\"\"\ntimeout = 5\n\n# ─── Hook: Mnemos Stop Checkpoint ──────────────────────────\n[[hooks]]\nevent = \"Stop\"\ncommand = \"\"\"\nif [ -x \".claude/scripts/mnemos-stop-checkpoint.sh\" ]; then\n  exec \".claude/scripts/mnemos-stop-checkpoint.sh\"\nfi\nif [ -x \"$HOME/.claude/templates/mnemos-stop-checkpoint.sh\" ]; then\n  exec \"$HOME/.claude/templates/mnemos-stop-checkpoint.sh\"\nfi\nexit 0\n\"\"\"\ntimeout = 5\n\n# ─── Hook: Session Start (Mnemos restore) ──────────────────\n[[hooks]]\nevent = \"SessionStart\"\ncommand = \"\"\"\nif [ -x \".claude/scripts/mnemos-session-start.sh\" ]; then\n  exec \".claude/scripts/mnemos-session-start.sh\"\nfi\nif [ -x \"$HOME/.claude/templates/mnemos-session-start.sh\" ]; then\n  exec \"$HOME/.claude/templates/mnemos-session-start.sh\"\nfi\nexit 0\n\"\"\"\ntimeout = 5\n"
  },
  {
    "path": "templates/icpg-pre-edit.sh",
    "content": "#!/bin/bash\n# iCPG PreToolUse Hook — injects intent context before Edit/Write operations.\n#\n# Shows the agent: what intents exist for this file, what invariants apply,\n# and the risk profile of symbols being modified.\n#\n# Install: add to .claude/settings.json under hooks.PreToolUse\n# Timeout: 3 seconds max — never blocks\n\n# Skip if icpg not installed or no DB\nif ! command -v icpg &>/dev/null && ! python -m icpg --version &>/dev/null 2>&1; then\n    exit 0\nfi\n\nif [ ! -f \".icpg/reason.db\" ]; then\n    exit 0\nfi\n\n# Extract file path from tool input\n# Claude Code passes tool input as JSON via stdin for PreToolUse hooks\nFILE_PATH=\"\"\nif [ -n \"$CLAUDE_TOOL_INPUT\" ]; then\n    FILE_PATH=$(echo \"$CLAUDE_TOOL_INPUT\" | python3 -c \"\nimport sys, json\ntry:\n    data = json.load(sys.stdin)\n    print(data.get('file_path', data.get('path', '')))\nexcept:\n    pass\n\")\nfi\n\nif [ -z \"$FILE_PATH\" ]; then\n    exit 0\nfi\n\n# Run icpg binary or module\nICPG_CMD=\"icpg\"\nif ! command -v icpg &>/dev/null; then\n    ICPG_CMD=\"python -m icpg\"\nfi\n\n# Query context, constraints, and drift (file-scoped fast check)\nCONTEXT=$($ICPG_CMD query context \"$FILE_PATH\")\nCONSTRAINTS=$($ICPG_CMD query constraints \"$FILE_PATH\")\nDRIFT=$($ICPG_CMD drift file \"$FILE_PATH\")\n\n# Only output if we have something\nif [ -n \"$CONTEXT\" ] || [ -n \"$CONSTRAINTS\" ] || [ -n \"$DRIFT\" ]; then\n    echo \"═══ iCPG CONTEXT ═══\"\n    [ -n \"$CONTEXT\" ] && echo \"$CONTEXT\"\n    [ -n \"$CONSTRAINTS\" ] && echo -e \"\\n$CONSTRAINTS\"\n    [ -n \"$DRIFT\" ] && echo -e \"\\n$DRIFT\"\n    echo \"PRESERVE function signatures unless your task requires changing them.\"\n    echo \"═══════════════════\"\nfi\n\nexit 0\n"
  },
  {
    "path": "templates/icpg-stop-record.sh",
    "content": "#!/bin/bash\n# iCPG Stop Hook Extension — auto-records symbols after implementation.\n#\n# Reads .icpg/.current-intent to know which ReasonNode is active.\n# If set, records symbols from git diff to that intent.\n#\n# Chain this AFTER tdd-loop-check.sh in the Stop hook:\n#   tdd-loop-check runs first → if tests pass → this records symbols\n\n# Skip if no active intent\nCURRENT_INTENT=$(cat .icpg/.current-intent 2>/dev/null)\nif [ -z \"$CURRENT_INTENT\" ]; then\n    exit 0\nfi\n\n# Skip if icpg not available\nICPG_CMD=\"\"\nif command -v icpg &>/dev/null; then\n    ICPG_CMD=\"icpg\"\nelif python -m icpg --version &>/dev/null 2>&1; then\n    ICPG_CMD=\"python -m icpg\"\nelse\n    exit 0\nfi\n\n# Record symbols from current diff\nOUTPUT=$($ICPG_CMD record --reason \"$CURRENT_INTENT\" --base main 2>&1)\nif [ $? -eq 0 ]; then\n    echo \"iCPG: $OUTPUT\" >&2\nfi\n\nexit 0\n"
  },
  {
    "path": "templates/mnemos-post-compact-inject.sh",
    "content": "#!/bin/bash\n# Mnemos Post-Compaction Injection — Layer 2 of task restoration.\n#\n# This is a PreToolUse hook with NO matcher (fires on ALL tool calls).\n# It detects when compaction just occurred and re-injects the full checkpoint\n# into Claude's context, ensuring the task can be resumed seamlessly.\n#\n# Fast path: ~5ms when no compaction happened (just a file existence check).\n# Slow path: ~100ms when injecting checkpoint (only fires once after compaction).\n#\n# How it works:\n#   1. PreCompact hook writes \".mnemos/just-compacted\" marker\n#   2. This hook checks for that marker on every tool call\n#   3. If marker exists and is fresh (<5 min), inject checkpoint and delete marker\n#   4. Marker deletion is atomic (rename) to prevent parallel injection\n#\n# Install: add to .claude/settings.json under hooks.PreToolUse (no matcher)\n\n# ─── Fast path: no compaction marker = exit immediately ───\n\n[ -f \".mnemos/just-compacted\" ] || exit 0\n\n# ─── Validate marker is fresh and atomically consume it ───\n\nCONSUMED=$(python3 -c \"\nimport json, time, os\n\nmarker = '.mnemos/just-compacted'\nconsumed = '.mnemos/just-compacted.consumed'\n\ntry:\n    with open(marker) as f:\n        data = json.load(f)\n    age = time.time() - data.get('timestamp', 0)\n    if age > 300:\n        # Stale marker (>5 min), just delete it\n        os.unlink(marker)\n        print('stale')\n    else:\n        # Fresh marker — atomically consume it\n        os.rename(marker, consumed)\n        try:\n            os.unlink(consumed)\n        except:\n            pass\n        print('consumed')\nexcept FileNotFoundError:\n    # Another hook already consumed it (parallel tool calls)\n    print('already_consumed')\nexcept Exception:\n    print('error')\n\")\n\n# Only inject if we successfully consumed the marker\nif [ \"$CONSUMED\" != \"consumed\" ]; then\n    exit 0\nfi\n\n# ─── Inject checkpoint into Claude's context ───\n\nSCRIPT_DIR=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\n\npython3 -c \"\nimport sys, json\nsys.path.insert(0, '${SCRIPT_DIR%/templates}/scripts')\n\ntry:\n    from mnemos.checkpoint import format_for_post_compact_injection\n    output = format_for_post_compact_injection('.')\n    if output:\n        print(output)\n    else:\n        print('=== MNEMOS: Compaction detected but no checkpoint found. ===')\n        print('Previous context was lost. Ask the user what they were working on.')\nexcept Exception as e:\n    # Fallback: try to read checkpoint JSON directly\n    try:\n        with open('.mnemos/checkpoint-latest.json') as f:\n            data = json.load(f)\n        print('=== MNEMOS: CONTEXT RESTORED AFTER COMPACTION ===')\n        print()\n        print('Compaction just occurred. Resume from this checkpoint:')\n        print()\n        print(f'## Goal')\n        print(data.get('goal', 'No goal recorded'))\n        print()\n        constraints = data.get('active_constraints', [])\n        if constraints:\n            print('## Active Constraints (DO NOT VIOLATE)')\n            for c in constraints:\n                print(f'- {c}')\n            print()\n        narrative = data.get('task_narrative', '')\n        if narrative:\n            print(f'## What You Were Working On')\n            print(narrative)\n            print()\n        print('=== Resume work from this checkpoint. ===')\n    except:\n        print('=== MNEMOS: Compaction detected but checkpoint unreadable. ===')\n        print('Ask the user what they were working on.')\n\"\n\nexit 0\n"
  },
  {
    "path": "templates/mnemos-post-tool.sh",
    "content": "#!/bin/bash\n# Mnemos PostToolUse Hook — logs tool outcomes + auto-feeds token signal.\n#\n# 1. Logs success/failure signal to .mnemos/signals.jsonl (error density)\n# 2. If fatigue.json is stale (>60s), estimates context usage from JSONL\n#\n# Receives JSON on stdin with tool_name, tool_input, tool_response.\n# Install: add to .claude/settings.json under hooks.PostToolUse\n# Timeout: 1 second max — never blocks\n\n# Skip if no .mnemos directory\nif [ ! -d \".mnemos\" ]; then\n    exit 0\nfi\n\n# Read hook input from stdin\nHOOK_INPUT=$(cat)\n\nif [ -z \"$HOOK_INPUT\" ]; then\n    exit 0\nfi\n\n# Log signal + update fatigue.json if stale\npython3 -c \"\nimport json, sys, time, os, glob\n\ntry:\n    data = json.loads('''$(echo \"$HOOK_INPUT\" | sed \"s/'/'\\\\\\\\''/g\")''')\nexcept:\n    sys.exit(0)\n\ntool = data.get('tool_name', '')\ntool_input = data.get('tool_input', {})\nresponse = data.get('tool_response', {})\n\n# Extract file path\nfp = tool_input.get('file_path', '') or tool_input.get('path', '')\n\n# Determine success\nsuccess = True\nif isinstance(response, dict):\n    if response.get('error') or response.get('is_error'):\n        success = False\n    if 'exit_code' in response and response['exit_code'] != 0:\n        success = False\nelif isinstance(response, str):\n    if response.startswith('Error:') or 'error' in response[:50].lower():\n        success = False\n\n# Append signal\nsignal = {\n    'tool': tool,\n    'event': 'post',\n    'file_path': fp,\n    'success': success,\n    'ts': time.time()\n}\n\nos.makedirs('.mnemos', exist_ok=True)\nwith open('.mnemos/signals.jsonl', 'a') as f:\n    f.write(json.dumps(signal) + '\\n')\n\n# ─── Auto-feed token signal from JSONL if fatigue.json is stale ───\n\nfatigue_path = '.mnemos/fatigue.json'\nstale = True\ntry:\n    with open(fatigue_path) as f:\n        fd = json.load(f)\n    # Fresh if updated within last 60 seconds (statusline is feeding it)\n    if time.time() - fd.get('timestamp', 0) < 60:\n        stale = False\nexcept:\n    pass\n\nif stale:\n    # Find the most recent session JSONL\n    home = os.path.expanduser('~')\n    cwd = os.getcwd()\n    # Claude Code project hash: path with / replaced by -\n    project_key = cwd.replace('/', '-')\n    if project_key.startswith('-'):\n        pass  # expected\n    project_dir = os.path.join(home, '.claude', 'projects', project_key)\n\n    if not os.path.isdir(project_dir):\n        # Try parent directories (Claude Code may use git root)\n        for parent in [os.path.dirname(cwd), os.path.dirname(os.path.dirname(cwd))]:\n            pk = parent.replace('/', '-')\n            pd = os.path.join(home, '.claude', 'projects', pk)\n            if os.path.isdir(pd):\n                project_dir = pd\n                break\n\n    try:\n        jsonl_files = sorted(\n            glob.glob(os.path.join(project_dir, '*.jsonl')),\n            key=os.path.getmtime, reverse=True\n        )\n        if jsonl_files:\n            # Read the last line of the most recent JSONL\n            with open(jsonl_files[0], 'rb') as f:\n                # Seek to end, scan backwards for last newline\n                f.seek(0, 2)\n                pos = f.tell()\n                if pos > 0:\n                    # Read last 8KB (enough for one JSON entry)\n                    read_size = min(8192, pos)\n                    f.seek(pos - read_size)\n                    chunk = f.read().decode('utf-8', errors='replace')\n                    lines = chunk.strip().split('\\n')\n                    last_line = lines[-1]\n                    entry = json.loads(last_line)\n                    usage = entry.get('message', {}).get('usage', {})\n                    if usage:\n                        input_tok = usage.get('input_tokens', 0)\n                        cache_read = usage.get('cache_read_input_tokens', 0)\n                        cache_create = usage.get('cache_creation_input_tokens', 0)\n                        total_in_context = input_tok + cache_read + cache_create\n                        # Opus/Sonnet context window = 200k\n                        context_limit = 200000\n                        # JSONL tokens overestimate actual context by ~25%\n                        # due to cache overhead. Apply correction factor.\n                        correction = 0.75\n                        used_pct = min(100.0, (total_in_context * correction / context_limit) * 100)\n                        fatigue_data = {\n                            'used_percentage': round(used_pct, 1),\n                            'remaining_percentage': round(100 - used_pct, 1),\n                            'used_tokens': total_in_context,\n                            'total_tokens': context_limit,\n                            'remaining_tokens': max(0, context_limit - total_in_context),\n                            'timestamp': time.time(),\n                            'source': 'jsonl_estimate'\n                        }\n                        with open(fatigue_path, 'w') as f:\n                            json.dump(fatigue_data, f)\n    except:\n        pass  # Best effort — don't block the hook\n\"\n\nexit 0\n"
  },
  {
    "path": "templates/mnemos-pre-compact.sh",
    "content": "#!/bin/bash\n# Mnemos PreCompact Hook — emergency checkpoint + typed preservation + compaction marker.\n#\n# TWO-LAYER DEFENSE against lossy compaction:\n#   Layer 1 (this script): Write emergency checkpoint, output strong preservation\n#           instructions with inline content for the summarizer.\n#   Layer 2 (mnemos-post-compact-inject.sh): After compaction, the first tool call\n#           re-injects the full checkpoint. See that script for details.\n#\n# The marker file (.mnemos/just-compacted) bridges the two layers.\n#\n# Install: add to .claude/settings.json under hooks.PreCompact\n# This EXTENDS (not replaces) the existing pre-compact.sh\n\nSCRIPT_DIR=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\n\n# ─── 1. Write emergency checkpoint with task narrative ───\n\nMNEMOS_CMD=\"\"\nif command -v mnemos &>/dev/null; then\n    MNEMOS_CMD=\"mnemos\"\nelif PYTHONPATH=\"${SCRIPT_DIR%/templates}/scripts\" python3 -m mnemos --version &>/dev/null 2>&1; then\n    MNEMOS_CMD=\"PYTHONPATH=${SCRIPT_DIR%/templates}/scripts python3 -m mnemos\"\nfi\n\nif [ -n \"$MNEMOS_CMD\" ]; then\n    eval $MNEMOS_CMD checkpoint --force &>/dev/null\nfi\n\n# ─── 2. Write compaction marker for Layer 2 detection ───\n\npython3 -c \"\nimport json, time, os\nos.makedirs('.mnemos', exist_ok=True)\nwith open('.mnemos/just-compacted', 'w') as f:\n    json.dump({'timestamp': time.time(), 'reason': 'pre_compact_hook'}, f)\n\"\n\n# ─── 3. Build inline checkpoint content for summarizer ───\n# Use a temp Python script to avoid bash escaping issues with f-strings\n\nCHECKPOINT_CONTENT=\"\"\nif [ -f \".mnemos/checkpoint-latest.json\" ]; then\n    TMPSCRIPT=$(mktemp /tmp/mnemos-precompact-XXXXXX.py)\n    cat > \"$TMPSCRIPT\" << 'PYSCRIPT'\nimport json, sys, os\ntry:\n    with open('.mnemos/checkpoint-latest.json') as f:\n        data = json.load(f)\n    lines = []\n    goal = data.get('goal', '')\n    if goal:\n        lines.append('GOAL: ' + goal)\n    for c in data.get('active_constraints', []):\n        lines.append('CONSTRAINT: ' + c)\n    narrative = data.get('task_narrative', '')\n    if narrative:\n        lines.append('ACTIVITY: ' + narrative)\n    subgoal = data.get('current_subgoal', '')\n    if subgoal:\n        lines.append('CURRENT TASK: ' + subgoal)\n    working = data.get('working_memory', '')\n    if working:\n        lines.append('WORKING MEMORY: ' + working[:300])\n    for r in data.get('active_results', [])[:5]:\n        lines.append('RESULT: ' + r)\n    files = data.get('recent_files', [])[:8]\n    if files:\n        file_parts = []\n        for entry in files:\n            p = entry.get('path', '?')\n            e = entry.get('edits', 0)\n            r = entry.get('reads', 0)\n            parts = []\n            if e:\n                parts.append('edited ' + str(e) + 'x')\n            if r:\n                parts.append('read ' + str(r) + 'x')\n            file_parts.append(p + ' (' + ', '.join(parts) + ')')\n        lines.append('FILES: ' + '; '.join(file_parts))\n    git = data.get('git_state', {})\n    if git.get('branch'):\n        lines.append('GIT: branch=' + git['branch'])\n        uncommitted = git.get('uncommitted', [])\n        if uncommitted:\n            lines.append('UNCOMMITTED: ' + ', '.join(uncommitted[:5]))\n    print('\\n'.join(lines))\nexcept Exception as e:\n    print('Error: ' + str(e), file=sys.stderr)\nPYSCRIPT\n    CHECKPOINT_CONTENT=$(python3 \"$TMPSCRIPT\")\n    rm -f \"$TMPSCRIPT\"\nfi\n\n# ─── 4. Extract typed preservation priorities from MnemoGraph ───\n\nMNEMOS_PRIORITIES=\"\"\nif [ -f \".mnemos/mnemo.db\" ]; then\n    TMPSCRIPT2=$(mktemp /tmp/mnemos-priorities-XXXXXX.py)\n    cat > \"$TMPSCRIPT2\" << PYSCRIPT\nimport json, sys\nsys.path.insert(0, '${SCRIPT_DIR%/templates}/scripts')\n\ntry:\n    from mnemos.store import MnemosStore\n    store = MnemosStore('.')\n    if not store.exists():\n        sys.exit(0)\n\n    goals = store.get_by_type('goal')\n    constraints = store.get_by_type('constraint')\n    working = store.get_by_type('working')\n    results = store.get_by_type('result')\n\n    lines = []\n    if goals:\n        lines.append('GOAL (NEVER DROP):')\n        for g in goals[:5]:\n            lines.append('  - ' + g.content[:200])\n\n    if constraints:\n        lines.append('CONSTRAINTS (NEVER DROP):')\n        for c in constraints[:10]:\n            lines.append('  - ' + c.content[:200])\n\n    if working:\n        lines.append('CURRENT TASK (HIGH PRIORITY):')\n        for w in working[:3]:\n            lines.append('  - ' + w.content[:200])\n\n    if results:\n        lines.append('RESULTS (KEEP SUMMARIES):')\n        for r in results[:5]:\n            summary = r.summary or r.content[:100]\n            lines.append('  - ' + summary)\n\n    print('\\n'.join(lines))\nexcept Exception:\n    pass\nPYSCRIPT\n    MNEMOS_PRIORITIES=$(python3 \"$TMPSCRIPT2\")\n    rm -f \"$TMPSCRIPT2\"\nfi\n\n# ─── 5. Output preservation instructions for summarizer ───\n# Everything to stdout becomes additional instructions for the compaction prompt\n\ncat <<'INSTRUCTIONS'\n## CRITICAL: Mnemos Task State Preservation\n\nAn emergency checkpoint has been saved to disk (.mnemos/checkpoint-latest.json).\nA post-compaction injection hook will re-inject this checkpoint after compaction.\n\nHowever, your summary should ALSO preserve the following task state. Include\nthis section VERBATIM in your summary output under a \"## Mnemos Task State\" heading:\n\nINSTRUCTIONS\n\nif [ -n \"$CHECKPOINT_CONTENT\" ]; then\ncat <<INSTRUCTIONS\n\n### Mnemos Task State (INCLUDE VERBATIM IN SUMMARY)\n\n$CHECKPOINT_CONTENT\n\nINSTRUCTIONS\nfi\n\ncat <<'INSTRUCTIONS'\n\n### Typed Eviction Policies\n\n**NEVER EVICT** (include verbatim in summary):\n- GoalNodes: The task's primary objective — without this the agent cannot continue\n- ConstraintNodes: Invariants and contracts that must not be violated\n\n**COMPRESS BUT KEEP** (include summary, not full content):\n- WorkingNodes: Current in-progress reasoning\n- ResultNodes: Completed sub-task results (keep summaries)\n\n**OK TO DROP** (can be re-derived from disk):\n- ContextNodes: File contents, tool outputs\n- Full tool call results (keep only findings)\n- Exploration that led nowhere\n\nINSTRUCTIONS\n\nif [ -n \"$MNEMOS_PRIORITIES\" ]; then\ncat <<INSTRUCTIONS\n\n### Active Memory Nodes (from MnemoGraph)\n\n$MNEMOS_PRIORITIES\n\nThese nodes represent the agent's active working memory. The summarizer\nMUST preserve Goal and Constraint nodes VERBATIM in the output.\n\nINSTRUCTIONS\nfi\n\n# ─── 6. Run existing pre-compact.sh if present ───\n\nif [ -f \"$SCRIPT_DIR/pre-compact.sh\" ]; then\n    bash \"$SCRIPT_DIR/pre-compact.sh\"\nfi\n\nexit 0\n"
  },
  {
    "path": "templates/mnemos-pre-edit.sh",
    "content": "#!/bin/bash\n# Mnemos PreToolUse Hook — fatigue-aware pre-edit with iCPG context.\n#\n# 1. Logs file path to signals.jsonl (for scope scatter + re-read tracking)\n# 2. Reads fatigue from observable signals + token data\n# 3. Auto-checkpoint when fatigue >= 0.60\n# 4. Auto-consolidation when fatigue >= 0.40\n# 5. Injects iCPG context, constraints, drift\n#\n# Install: add to .claude/settings.json under hooks.PreToolUse\n# Timeout: 5 seconds max\n\n# ─── Read hook input from stdin ───\n\nHOOK_INPUT=$(cat)\n\n# ─── Extract file path and tool name ───\n\nFILE_PATH=\"\"\nTOOL_NAME=\"\"\nif [ -n \"$HOOK_INPUT\" ]; then\n    eval $(echo \"$HOOK_INPUT\" | python3 -c \"\nimport sys, json\ntry:\n    data = json.load(sys.stdin)\n    fp = data.get('tool_input', {}).get('file_path', '') or data.get('tool_input', {}).get('path', '')\n    tn = data.get('tool_name', '')\n    print(f'FILE_PATH=\\\"{fp}\\\"')\n    print(f'TOOL_NAME=\\\"{tn}\\\"')\nexcept:\n    print('FILE_PATH=\\\"\\\"')\n    print('TOOL_NAME=\\\"\\\"')\n\")\nfi\n\nif [ -z \"$FILE_PATH\" ]; then\n    exit 0\nfi\n\n# ─── Log signal for fatigue computation ───\n\nif [ -d \".mnemos\" ] || [ -f \".mnemos/fatigue.json\" ]; then\n    python3 -c \"\nimport json, time, os\nos.makedirs('.mnemos', exist_ok=True)\nsignal = {\n    'tool': '$TOOL_NAME',\n    'event': 'pre',\n    'file_path': '$FILE_PATH',\n    'ts': time.time()\n}\nwith open('.mnemos/signals.jsonl', 'a') as f:\n    f.write(json.dumps(signal) + '\\n')\n\"\nfi\n\n# ─── Fatigue check (full model from observable signals) ───\n\nFATIGUE_WARNING=\"\"\nif [ -f \".mnemos/fatigue.json\" ]; then\n    FATIGUE_ACTION=$(python3 -c \"\nimport json, sys\nsys.path.insert(0, 'scripts')\n\ntry:\n    from mnemos.fatigue import compute_fatigue, read_fatigue_file\n    data = read_fatigue_file('.')\n    if not data:\n        print('flow')\n        sys.exit(0)\n\n    fatigue = compute_fatigue(data, '.')\n    print(fatigue.state)\nexcept Exception:\n    # Fallback: just use token utilization\n    try:\n        with open('.mnemos/fatigue.json') as f:\n            data = json.load(f)\n        used = data.get('used_percentage', 0)\n        if used >= 90: print('emergency')\n        elif used >= 75: print('rem')\n        elif used >= 60: print('pre_sleep')\n        elif used >= 40: print('compress')\n        else: print('flow')\n    except:\n        print('flow')\n\")\n\n    # Auto-checkpoint at pre_sleep or higher\n    if [ \"$FATIGUE_ACTION\" = \"pre_sleep\" ] || [ \"$FATIGUE_ACTION\" = \"rem\" ] || [ \"$FATIGUE_ACTION\" = \"emergency\" ]; then\n        # Write checkpoint in background (don't block the hook)\n        if command -v mnemos &>/dev/null; then\n            mnemos checkpoint --force &>/dev/null &\n        elif python3 -m mnemos --version &>/dev/null 2>&1; then\n            PYTHONPATH=scripts python3 -m mnemos checkpoint --force &>/dev/null &\n        fi\n\n        if [ \"$FATIGUE_ACTION\" = \"emergency\" ]; then\n            FATIGUE_WARNING=\"EMERGENCY: Context 90%+ full. Checkpoint written. Finish current task and hand off.\"\n        elif [ \"$FATIGUE_ACTION\" = \"rem\" ]; then\n            FATIGUE_WARNING=\"WARNING: Context 75%+ full. Checkpoint written. Consider wrapping up.\"\n        else\n            FATIGUE_WARNING=\"NOTICE: Context 60%+ full. Checkpoint written. Keep changes focused.\"\n        fi\n    fi\n\n    # Auto-consolidate at compress or higher\n    if [ \"$FATIGUE_ACTION\" = \"compress\" ] || [ \"$FATIGUE_ACTION\" = \"pre_sleep\" ] || [ \"$FATIGUE_ACTION\" = \"rem\" ]; then\n        if command -v mnemos &>/dev/null; then\n            mnemos consolidate &>/dev/null &\n        elif python3 -m mnemos --version &>/dev/null 2>&1; then\n            PYTHONPATH=scripts python3 -m mnemos consolidate &>/dev/null &\n        fi\n    fi\nfi\n\n# ─── iCPG context ───\n\nCONTEXT=\"\"\nCONSTRAINTS=\"\"\nDRIFT=\"\"\n\nif command -v icpg &>/dev/null || python3 -m icpg --version &>/dev/null 2>&1; then\n    if [ -f \".icpg/reason.db\" ]; then\n        ICPG_CMD=\"icpg\"\n        if ! command -v icpg &>/dev/null; then\n            ICPG_CMD=\"python3 -m icpg\"\n        fi\n\n        CONTEXT=$($ICPG_CMD query context \"$FILE_PATH\")\n        CONSTRAINTS=$($ICPG_CMD query constraints \"$FILE_PATH\")\n        DRIFT=$($ICPG_CMD drift file \"$FILE_PATH\")\n    fi\nfi\n\n# ─── Output ───\n\nHAS_OUTPUT=\"\"\n[ -n \"$FATIGUE_WARNING\" ] && HAS_OUTPUT=\"1\"\n[ -n \"$CONTEXT\" ] && HAS_OUTPUT=\"1\"\n[ -n \"$CONSTRAINTS\" ] && HAS_OUTPUT=\"1\"\n[ -n \"$DRIFT\" ] && HAS_OUTPUT=\"1\"\n\nif [ -n \"$HAS_OUTPUT\" ]; then\n    echo \"--- Mnemos + iCPG Context ---\"\n\n    if [ -n \"$FATIGUE_WARNING\" ]; then\n        echo \"$FATIGUE_WARNING\"\n        echo \"\"\n    fi\n\n    [ -n \"$CONTEXT\" ] && echo \"$CONTEXT\"\n    [ -n \"$CONSTRAINTS\" ] && echo -e \"\\n$CONSTRAINTS\"\n    [ -n \"$DRIFT\" ] && echo -e \"\\n$DRIFT\"\n\n    if [ -n \"$CONTEXT\" ] || [ -n \"$CONSTRAINTS\" ]; then\n        echo \"PRESERVE function signatures unless your task requires changing them.\"\n    fi\n\n    echo \"---\"\nfi\n\nexit 0\n"
  },
  {
    "path": "templates/mnemos-session-start.sh",
    "content": "#!/bin/bash\n# Mnemos SessionStart Hook — loads checkpoint on session resume.\n#\n# Checks for .mnemos/checkpoint-latest.json and injects it into context.\n# Also bridges iCPG state if available.\n#\n# Install: add to .claude/settings.json under hooks.SessionStart\n\n# ─── Load checkpoint if exists ───\n\nif [ -f \".mnemos/checkpoint-latest.json\" ]; then\n    MNEMOS_CMD=\"\"\n    if command -v mnemos &>/dev/null; then\n        MNEMOS_CMD=\"mnemos\"\n    elif python3 -m mnemos --version &>/dev/null 2>&1; then\n        MNEMOS_CMD=\"python3 -m mnemos\"\n    fi\n\n    if [ -n \"$MNEMOS_CMD\" ]; then\n        RESUME_OUTPUT=$($MNEMOS_CMD resume 2>/dev/null)\n        if [ -n \"$RESUME_OUTPUT\" ]; then\n            echo \"=== MNEMOS SESSION RESUME ===\"\n            echo \"$RESUME_OUTPUT\"\n            echo \"\"\n            echo \"You are resuming from a previous session checkpoint.\"\n            echo \"Review the goal and constraints above before proceeding.\"\n            echo \"=============================\"\n        fi\n    fi\nfi\n\n# ─── Bridge iCPG if available and Mnemos DB exists ───\n\nif [ -f \".icpg/reason.db\" ] && [ -f \".mnemos/mnemo.db\" ]; then\n    MNEMOS_CMD=\"\"\n    if command -v mnemos &>/dev/null; then\n        MNEMOS_CMD=\"mnemos\"\n    elif python3 -m mnemos --version &>/dev/null 2>&1; then\n        MNEMOS_CMD=\"python3 -m mnemos\"\n    fi\n\n    if [ -n \"$MNEMOS_CMD\" ]; then\n        # Bridge in background — don't block session start\n        $MNEMOS_CMD bridge-icpg &>/dev/null &\n    fi\nfi\n\n# ─── Show iCPG status if available ───\n\nif [ -f \".icpg/reason.db\" ]; then\n    ICPG_CMD=\"\"\n    if command -v icpg &>/dev/null; then\n        ICPG_CMD=\"icpg\"\n    elif python3 -m icpg --version &>/dev/null 2>&1; then\n        ICPG_CMD=\"python3 -m icpg\"\n    fi\n\n    if [ -n \"$ICPG_CMD\" ]; then\n        STATUS=$($ICPG_CMD status 2>/dev/null)\n        if [ -n \"$STATUS\" ]; then\n            echo \"\"\n            echo \"=== iCPG STATUS ===\"\n            echo \"$STATUS\"\n            echo \"===================\"\n        fi\n    fi\nfi\n\nexit 0\n"
  },
  {
    "path": "templates/mnemos-statusline.sh",
    "content": "#!/bin/bash\n# Mnemos Statusline Script — receives context JSON on stdin every API call.\n#\n# 1. Writes fatigue.json for hooks to read (always)\n# 2. Delegates display to ccusage statusline if available (cost + context)\n# 3. Falls back to simple context % display if ccusage not installed\n#\n# Auto-configured by Mnemos via settings.json statusLine.\n# Input (stdin JSON): context_window.used_percentage, remaining_percentage, etc.\n\n# Read JSON from stdin — must capture before any piping\nINPUT=$(cat)\n\nif [ -z \"$INPUT\" ]; then\n    exit 0\nfi\n\n# ─── Step 1: Write fatigue.json (always, fast) ───\n\npython3 -c \"\nimport json, time, os, sys\n\nos.makedirs('.mnemos', exist_ok=True)\n\nraw = '''$(echo \"$INPUT\" | sed \"s/'/'\\\\\\\\''/g\")'''\n\ntry:\n    data = json.loads(raw)\nexcept:\n    data = {}\n\ncw = data.get('context_window', {})\nused_pct = cw.get('used_percentage', 0)\nremaining_pct = cw.get('remaining_percentage', 100)\nctx_size = cw.get('context_window_size', 200000)\n\n# Token counts are under current_usage (not top-level)\ncu = cw.get('current_usage', {})\nused_tokens = (cu.get('input_tokens', 0)\n    + cu.get('cache_creation_input_tokens', 0)\n    + cu.get('cache_read_input_tokens', 0))\nremaining_tokens = max(0, ctx_size - int(ctx_size * used_pct / 100))\n\nfatigue = {\n    'used_percentage': used_pct,\n    'remaining_percentage': remaining_pct,\n    'used_tokens': used_tokens,\n    'total_tokens': ctx_size,\n    'remaining_tokens': remaining_tokens,\n    'total_input_tokens': cw.get('total_input_tokens', 0),\n    'total_output_tokens': cw.get('total_output_tokens', 0),\n    'timestamp': time.time(),\n    'source': 'statusline'\n}\nwith open('.mnemos/fatigue.json', 'w') as f:\n    json.dump(fatigue, f)\n\"\n\n# ─── Step 2: Display — prefer ccusage, fallback to simple ───\n\nif command -v ccusage &>/dev/null; then\n    # ccusage statusline gets the same JSON, shows cost + context + burn rate\n    echo \"$INPUT\" | ccusage statusline 2>/dev/null\n    if [ $? -eq 0 ]; then\n        exit 0\n    fi\nfi\n\n# Try npx ccusage (slower, only if ccusage not globally installed)\nif command -v npx &>/dev/null; then\n    echo \"$INPUT\" | npx --yes ccusage statusline 2>/dev/null\n    if [ $? -eq 0 ]; then\n        exit 0\n    fi\nfi\n\n# Fallback: simple context display\npython3 -c \"\nimport json\ntry:\n    data = json.loads('''$(echo \"$INPUT\" | sed \"s/'/'\\\\\\\\''/g\")''')\n    cw = data.get('context_window', {})\n    used = cw.get('used_percentage', 0)\n    if used >= 90: s = ' EMERGENCY'\n    elif used >= 75: s = ' WARNING'\n    elif used >= 60: s = ' NOTICE'\n    elif used >= 40: s = ' ~'\n    else: s = ''\n    print(f'Ctx:{used:.0f}%{s}')\nexcept:\n    print('Ctx:?%')\n\"\n\nexit 0\n"
  },
  {
    "path": "templates/mnemos-stop-checkpoint.sh",
    "content": "#!/bin/bash\n# Mnemos Stop Hook — writes incremental checkpoint when agent stops.\n#\n# Captures final session state so the next session can resume cleanly.\n#\n# Install: add to .claude/settings.json under hooks.Stop\n\nMNEMOS_CMD=\"\"\nif command -v mnemos &>/dev/null; then\n    MNEMOS_CMD=\"mnemos\"\nelif python3 -m mnemos --version &>/dev/null 2>&1; then\n    MNEMOS_CMD=\"python3 -m mnemos\"\nfi\n\nif [ -z \"$MNEMOS_CMD\" ]; then\n    exit 0\nfi\n\n# Only checkpoint if Mnemos is initialized\nif [ ! -f \".mnemos/mnemo.db\" ]; then\n    exit 0\nfi\n\n# Write checkpoint\n$MNEMOS_CMD checkpoint --force &>/dev/null\n\nexit 0\n"
  },
  {
    "path": "templates/polyphony-agents.yaml",
    "content": "agents:\n  - name: claude-opus\n    agent_type: claude\n    cli_command: \"claude -p\"\n    context_window_tokens: 200000\n    strengths: [long_context, research, architecture]\n    event_protocol: stream-json\n    auth_path: ~/.claude\n\n  - name: codex-default\n    agent_type: codex\n    cli_command: \"codex exec\"\n    context_window_tokens: 192000\n    strengths: [code, testing]\n    event_protocol: ndjson\n    auth_path: ~/.codex\n\n  - name: kimi-default\n    agent_type: kimi\n    cli_command: \"kimi --print -y\"\n    context_window_tokens: 128000\n    strengths: [code, fast_iteration]\n    event_protocol: ndjson\n    auth_path: ~/.kimi\n"
  },
  {
    "path": "templates/polyphony-config.yaml",
    "content": "workspace_root: ~/polyphony/workspaces\nmirror_root: ~/polyphony/mirrors\npoll_interval: 30s\nmax_concurrent_agents: 8\nevent_idle_timeout: 5m\n\nwork_sources:\n  - kind: github\n    repo: owner/repo\n    label_filter: \"agent-ready\"\n  - kind: local\n    db: ~/polyphony/queue.db\n\nidentities_file: ~/.polyphony/identities.yaml\nagent_profiles_file: ~/.polyphony/agents.yaml\nrouting_file: ~/.polyphony/routing.yaml\n"
  },
  {
    "path": "templates/polyphony-identities.yaml",
    "content": "identities:\n  - name: protaige\n    volumes:\n      claude: ~/.claude\n      codex: ~/.codex\n      kimi: ~/.kimi\n    cost_ceiling_usd_per_day: 50\n"
  },
  {
    "path": "templates/polyphony-routing.yaml",
    "content": "routing:\n  rules:\n    - match: { task_type: research, requires_web: true }\n      agent: claude-opus\n      fallback: [codex-default]\n\n    - match: { task_type: feature, risk: [low, medium] }\n      agent: codex-default\n      fallback: [claude-opus]\n\n    - match: { task_type: [bugfix, docs], scope: single_file }\n      agent: kimi-default\n\n    - match: { task_type: refactor, risk: high }\n      agent: claude-opus\n\n  default:\n    agent: claude-opus\n    fallback: [codex-default, kimi-default]\n"
  },
  {
    "path": "templates/pre-compact.sh",
    "content": "#!/bin/bash\n# PreCompact Hook — injects project-specific preservation instructions\n# into the compaction summarizer so it keeps what actually matters.\n#\n# How it works:\n#   Claude Code's PreCompact hook runs right before compaction.\n#   Stdout from this script becomes custom instructions for the summarizer.\n#   Exit 0 = instructions accepted. Exit 2 = block compaction (don't use).\n#\n# The built-in summarizer uses a generic 9-section template.\n# This hook tells it: \"for THIS project, prioritize these specific things.\"\n\n# ─── Detect project context ───\n\nPROJECT_TYPE=\"\"\nSCHEMA_FILE=\"\"\nTEST_CMD=\"\"\nKEY_DIRS=\"\"\n\n# Detect tech stack\nif [ -f \"package.json\" ]; then\n    PROJECT_TYPE=\"javascript\"\n    if [ -f \"tsconfig.json\" ]; then\n        PROJECT_TYPE=\"typescript\"\n    fi\n    if grep -q '\"next\"' package.json 2>/dev/null; then\n        PROJECT_TYPE=\"$PROJECT_TYPE/nextjs\"\n    elif grep -q '\"react\"' package.json 2>/dev/null; then\n        PROJECT_TYPE=\"$PROJECT_TYPE/react\"\n    elif grep -q '\"express\\|fastify\"' package.json 2>/dev/null; then\n        PROJECT_TYPE=\"$PROJECT_TYPE/node-backend\"\n    fi\n    TEST_CMD=\"npm test\"\nfi\n\nif [ -f \"pyproject.toml\" ] || [ -f \"setup.py\" ]; then\n    PROJECT_TYPE=\"python\"\n    if grep -q \"fastapi\" pyproject.toml 2>/dev/null; then\n        PROJECT_TYPE=\"python/fastapi\"\n    elif grep -q \"django\" pyproject.toml 2>/dev/null; then\n        PROJECT_TYPE=\"python/django\"\n    fi\n    TEST_CMD=\"pytest\"\nfi\n\nif [ -f \"pubspec.yaml\" ]; then\n    PROJECT_TYPE=\"flutter\"\n    TEST_CMD=\"flutter test\"\nfi\n\n# Find schema files\nfor f in src/db/schema.ts prisma/schema.prisma drizzle/schema.ts supabase/migrations models.py src/models; do\n    if [ -e \"$f\" ]; then\n        SCHEMA_FILE=\"$f\"\n        break\n    fi\ndone\n\n# Find key directories\nKEY_DIRS=\"\"\nfor d in src/api src/routes src/app/api api routes server/routes; do\n    if [ -d \"$d\" ]; then\n        KEY_DIRS=\"$KEY_DIRS $d\"\n    fi\ndone\n\n# ─── Gather live project state ───\n\n# Git state\nGIT_BRANCH=\"\"\nGIT_CHANGES=\"\"\nif command -v git &>/dev/null && git rev-parse --git-dir &>/dev/null 2>&1; then\n    GIT_BRANCH=$(git branch --show-current 2>/dev/null)\n    GIT_CHANGES=$(git diff --name-only 2>/dev/null | head -15)\n    GIT_STAGED=$(git diff --cached --name-only 2>/dev/null | head -10)\nfi\n\n# CLAUDE.md key decisions (if they exist)\nKEY_DECISIONS=\"\"\nif [ -f \"CLAUDE.md\" ]; then\n    # Extract the Key Decisions section\n    KEY_DECISIONS=$(sed -n '/^## Key Decisions/,/^## /p' CLAUDE.md | head -20 | tail -n +2)\nfi\n\n# ─── Output custom instructions for the summarizer ───\n# Everything sent to stdout becomes additional instructions for the compaction prompt\n\ncat <<INSTRUCTIONS\n## Project-Specific Preservation Priorities\n\nThis is a $PROJECT_TYPE project. When summarizing, prioritize preserving:\n\n### 1. Architectural Decisions (HIGHEST PRIORITY)\nPreserve the EXACT reasoning behind architectural choices, not just the choice itself.\nIf the conversation discussed why we chose X over Y, keep the \"why\" verbatim.\nINSTRUCTIONS\n\nif [ -n \"$KEY_DECISIONS\" ]; then\ncat <<INSTRUCTIONS\n\nThese are the project's settled decisions — reference them by name in the summary:\n$KEY_DECISIONS\nINSTRUCTIONS\nfi\n\nif [ -n \"$SCHEMA_FILE\" ]; then\ncat <<INSTRUCTIONS\n\n### 2. Database Schema Context\nSchema file: $SCHEMA_FILE\nPreserve ALL discussion about schema changes, column names, relationships,\nmigration decisions, and data model reasoning. These are expensive to re-derive.\nINSTRUCTIONS\nfi\n\nif [ -n \"$KEY_DIRS\" ]; then\ncat <<INSTRUCTIONS\n\n### 3. API Contract Details\nAPI directories:$KEY_DIRS\nPreserve exact endpoint paths, request/response shapes, status codes,\nand validation rules discussed. These affect multiple consumers.\nINSTRUCTIONS\nfi\n\ncat <<INSTRUCTIONS\n\n### 4. Error Context\nWhen summarizing errors and fixes, preserve:\n- The EXACT error message (not paraphrased)\n- The file and line number\n- What fix was applied and why\n- Whether the fix was verified (tests passing)\n\n### 5. Current Work State\nINSTRUCTIONS\n\nif [ -n \"$GIT_BRANCH\" ]; then\n    echo \"Branch: $GIT_BRANCH\"\nfi\n\nif [ -n \"$GIT_CHANGES\" ]; then\ncat <<INSTRUCTIONS\nUncommitted changes:\n$GIT_CHANGES\nINSTRUCTIONS\nfi\n\nif [ -n \"$GIT_STAGED\" ]; then\ncat <<INSTRUCTIONS\nStaged for commit:\n$GIT_STAGED\nINSTRUCTIONS\nfi\n\ncat <<INSTRUCTIONS\n\n### 6. Test Status\nPreserve the last known test state — which tests pass, which fail, what coverage was.\nTest command: ${TEST_CMD:-\"unknown\"}\n\n### 7. What NOT to Summarize\n- Don't preserve exploration that led nowhere (dead ends)\n- Don't preserve full file contents that can be re-read from disk\n- Don't preserve tool result formatting — just the key findings\n- Compress repeated test-fix-test cycles into: \"Fixed X by doing Y, tests now pass\"\nINSTRUCTIONS\n\nexit 0\n"
  },
  {
    "path": "templates/settings.json",
    "content": "{\n  \"statusLine\": {\n    \"type\": \"command\",\n    \"command\": \"scripts/mnemos-statusline.sh\",\n    \"padding\": 0\n  },\n  \"permissions\": {\n    \"allow\": [\n      \"Bash(npm test *)\",\n      \"Bash(npm run lint *)\",\n      \"Bash(npm run typecheck *)\",\n      \"Bash(npx tsc *)\",\n      \"Bash(npx jest *)\",\n      \"Bash(npx vitest *)\",\n      \"Bash(pytest *)\",\n      \"Bash(ruff *)\",\n      \"Bash(mypy *)\",\n      \"Bash(eslint *)\",\n      \"Bash(git status *)\",\n      \"Bash(git diff *)\",\n      \"Bash(git log *)\",\n      \"Bash(git branch *)\",\n      \"Bash(gh pr *)\",\n      \"Bash(gh issue *)\",\n      \"Bash(ls *)\",\n      \"Bash(cat *)\",\n      \"Bash(head *)\",\n      \"Bash(wc *)\",\n      \"Bash(icpg *)\",\n      \"Bash(python -m icpg *)\",\n      \"Bash(mnemos *)\",\n      \"Bash(python -m mnemos *)\",\n      \"Bash(polyphony *)\",\n      \"Bash(python -m polyphony *)\",\n      \"Bash(docker ps *)\",\n      \"Bash(docker logs *)\"\n    ],\n    \"deny\": [\n      \"Bash(rm -rf *)\",\n      \"Bash(git push --force *)\",\n      \"Bash(git reset --hard *)\",\n      \"Write(.env)\",\n      \"Write(.env.*)\",\n      \"Edit(.env)\",\n      \"Edit(.env.*)\"\n    ]\n  },\n  \"hooks\": {\n    \"PreCompact\": [\n      {\n        \"hooks\": [\n          {\n            \"type\": \"command\",\n            \"command\": \"if [ -x \\\".claude/scripts/mnemos-pre-compact.sh\\\" ]; then exec \\\".claude/scripts/mnemos-pre-compact.sh\\\"; fi; if [ -x \\\"$HOME/.claude/templates/mnemos-pre-compact.sh\\\" ]; then exec \\\"$HOME/.claude/templates/mnemos-pre-compact.sh\\\"; fi; echo \\\"[maggy] hook script 'mnemos-pre-compact.sh' not installed \\u2014 run <maggy>/install.sh (one-time) or touch .claude/scripts/mnemos-pre-compact.sh to silence\\\" >&2; exit 0\",\n            \"timeout\": 8,\n            \"statusMessage\": \"Writing emergency checkpoint + compaction priorities...\"\n          }\n        ]\n      }\n    ],\n    \"PreToolUse\": [\n      {\n        \"hooks\": [\n          {\n            \"type\": \"command\",\n            \"command\": \"if [ -x \\\".claude/scripts/mnemos-post-compact-inject.sh\\\" ]; then exec \\\".claude/scripts/mnemos-post-compact-inject.sh\\\"; fi; if [ -x \\\"$HOME/.claude/templates/mnemos-post-compact-inject.sh\\\" ]; then exec \\\"$HOME/.claude/templates/mnemos-post-compact-inject.sh\\\"; fi; echo \\\"[maggy] hook script 'mnemos-post-compact-inject.sh' not installed \\u2014 run <maggy>/install.sh (one-time) or touch .claude/scripts/mnemos-post-compact-inject.sh to silence\\\" >&2; exit 0\",\n            \"timeout\": 2,\n            \"statusMessage\": \"Checking for post-compaction restore...\"\n          }\n        ]\n      },\n      {\n        \"matcher\": \"Edit|Write\",\n        \"hooks\": [\n          {\n            \"type\": \"command\",\n            \"command\": \"if [ -x \\\".claude/scripts/mnemos-pre-edit.sh\\\" ]; then exec \\\".claude/scripts/mnemos-pre-edit.sh\\\"; fi; if [ -x \\\"$HOME/.claude/templates/mnemos-pre-edit.sh\\\" ]; then exec \\\"$HOME/.claude/templates/mnemos-pre-edit.sh\\\"; fi; echo \\\"[maggy] hook script 'mnemos-pre-edit.sh' not installed \\u2014 run <maggy>/install.sh (one-time) or touch .claude/scripts/mnemos-pre-edit.sh to silence\\\" >&2; exit 0\",\n            \"timeout\": 5,\n            \"statusMessage\": \"Checking fatigue + intent context...\"\n          }\n        ]\n      }\n    ],\n    \"PostToolUse\": [\n      {\n        \"hooks\": [\n          {\n            \"type\": \"command\",\n            \"command\": \"if [ -x \\\".claude/scripts/mnemos-post-tool.sh\\\" ]; then exec \\\".claude/scripts/mnemos-post-tool.sh\\\"; fi; if [ -x \\\"$HOME/.claude/templates/mnemos-post-tool.sh\\\" ]; then exec \\\"$HOME/.claude/templates/mnemos-post-tool.sh\\\"; fi; echo \\\"[maggy] hook script 'mnemos-post-tool.sh' not installed \\u2014 run <maggy>/install.sh (one-time) or touch .claude/scripts/mnemos-post-tool.sh to silence\\\" >&2; exit 0\",\n            \"timeout\": 1,\n            \"statusMessage\": \"Logging tool outcome...\"\n          }\n        ]\n      }\n    ],\n    \"Stop\": [\n      {\n        \"hooks\": [\n          {\n            \"type\": \"command\",\n            \"command\": \"if [ -x \\\".claude/scripts/tdd-loop-check.sh\\\" ]; then exec \\\".claude/scripts/tdd-loop-check.sh\\\"; fi; if [ -x \\\"$HOME/.claude/templates/tdd-loop-check.sh\\\" ]; then exec \\\"$HOME/.claude/templates/tdd-loop-check.sh\\\"; fi; echo \\\"[maggy] hook script 'tdd-loop-check.sh' not installed \\u2014 run <maggy>/install.sh (one-time) or touch .claude/scripts/tdd-loop-check.sh to silence\\\" >&2; exit 0\",\n            \"timeout\": 60,\n            \"statusMessage\": \"Running tests...\"\n          },\n          {\n            \"type\": \"command\",\n            \"command\": \"if command -v codex &>/dev/null; then if [ -x \\\".claude/scripts/codex-auto-review.sh\\\" ]; then exec \\\".claude/scripts/codex-auto-review.sh\\\"; elif [ -x \\\"$HOME/.claude/templates/codex-auto-review.sh\\\" ]; then exec \\\"$HOME/.claude/templates/codex-auto-review.sh\\\"; fi; fi; exit 0\",\n            \"timeout\": 120,\n            \"statusMessage\": \"Codex reviewing changes...\"\n          },\n          {\n            \"type\": \"command\",\n            \"command\": \"if [ -x \\\".claude/scripts/icpg-stop-record.sh\\\" ]; then exec \\\".claude/scripts/icpg-stop-record.sh\\\"; fi; if [ -x \\\"$HOME/.claude/templates/icpg-stop-record.sh\\\" ]; then exec \\\"$HOME/.claude/templates/icpg-stop-record.sh\\\"; fi; echo \\\"[maggy] hook script 'icpg-stop-record.sh' not installed \\u2014 run <maggy>/install.sh (one-time) or touch .claude/scripts/icpg-stop-record.sh to silence\\\" >&2; exit 0\",\n            \"timeout\": 5,\n            \"statusMessage\": \"Recording symbols to intent graph...\"\n          },\n          {\n            \"type\": \"command\",\n            \"command\": \"if [ -x \\\".claude/scripts/mnemos-stop-checkpoint.sh\\\" ]; then exec \\\".claude/scripts/mnemos-stop-checkpoint.sh\\\"; fi; if [ -x \\\"$HOME/.claude/templates/mnemos-stop-checkpoint.sh\\\" ]; then exec \\\"$HOME/.claude/templates/mnemos-stop-checkpoint.sh\\\"; fi; echo \\\"[maggy] hook script 'mnemos-stop-checkpoint.sh' not installed \\u2014 run <maggy>/install.sh (one-time) or touch .claude/scripts/mnemos-stop-checkpoint.sh to silence\\\" >&2; exit 0\",\n            \"timeout\": 5,\n            \"statusMessage\": \"Writing session checkpoint...\"\n          }\n        ]\n      }\n    ],\n    \"SessionStart\": [\n      {\n        \"hooks\": [\n          {\n            \"type\": \"command\",\n            \"command\": \"if [ -x \\\".claude/scripts/mnemos-session-start.sh\\\" ]; then exec \\\".claude/scripts/mnemos-session-start.sh\\\"; fi; if [ -x \\\"$HOME/.claude/templates/mnemos-session-start.sh\\\" ]; then exec \\\"$HOME/.claude/templates/mnemos-session-start.sh\\\"; fi; echo \\\"[maggy] hook script 'mnemos-session-start.sh' not installed \\u2014 run <maggy>/install.sh (one-time) or touch .claude/scripts/mnemos-session-start.sh to silence\\\" >&2; exit 0\",\n            \"timeout\": 5,\n            \"statusMessage\": \"Loading session checkpoint + project context...\"\n          }\n        ]\n      }\n    ]\n  }\n}\n"
  },
  {
    "path": "templates/tdd-loop-check.sh",
    "content": "#!/bin/bash\n# TDD Loop Check - Claude Code Stop hook script\n# Runs after each Claude response. Exit 0 = done, Exit 2 = failures fed back to Claude.\n#\n# Install: copy to scripts/tdd-loop-check.sh in your project\n# Configure: add Stop hook in .claude/settings.json (see iterative-development skill)\n\nMAX_ITERATIONS=25\nITERATION_FILE=\".claude/.tdd-iteration-count\"\nmkdir -p .claude\n\n# Track iteration count\nif [ -f \"$ITERATION_FILE\" ]; then\n    count=$(cat \"$ITERATION_FILE\")\n    count=$((count + 1))\nelse\n    count=1\nfi\necho \"$count\" > \"$ITERATION_FILE\"\n\n# Safety: stop after max iterations\nif [ \"$count\" -ge \"$MAX_ITERATIONS\" ]; then\n    rm -f \"$ITERATION_FILE\"\n    echo \"Max iterations ($MAX_ITERATIONS) reached. Stopping loop.\" >&2\n    exit 0\nfi\n\n# Skip if no test files exist yet\nif ! find . -name \"*.test.*\" -o -name \"*.spec.*\" -o -name \"test_*\" | grep -q .; then\n    rm -f \"$ITERATION_FILE\"\n    exit 0\nfi\n\n# Detect project type and run tests\nif [ -f \"package.json\" ]; then\n    TEST_OUTPUT=$(npm test 2>&1) || {\n        echo \"ITERATION $count/$MAX_ITERATIONS - Tests failing:\" >&2\n        echo \"$TEST_OUTPUT\" | tail -30 >&2\n        echo \"\" >&2\n        echo \"Fix the failing tests and try again.\" >&2\n        exit 2\n    }\n\n    # Lint\n    if grep -q '\"lint\"' package.json; then\n        LINT_OUTPUT=$(npm run lint 2>&1) || {\n            echo \"ITERATION $count/$MAX_ITERATIONS - Lint errors:\" >&2\n            echo \"$LINT_OUTPUT\" | tail -20 >&2\n            exit 2\n        }\n    fi\n\n    # Typecheck\n    if [ -f \"tsconfig.json\" ]; then\n        TYPE_OUTPUT=$(npx tsc --noEmit 2>&1) || {\n            echo \"ITERATION $count/$MAX_ITERATIONS - Type errors:\" >&2\n            echo \"$TYPE_OUTPUT\" | tail -20 >&2\n            exit 2\n        }\n    fi\n\nelif [ -f \"pyproject.toml\" ] || [ -f \"setup.py\" ]; then\n    TEST_OUTPUT=$(pytest -v 2>&1) || {\n        echo \"ITERATION $count/$MAX_ITERATIONS - Tests failing:\" >&2\n        echo \"$TEST_OUTPUT\" | tail -30 >&2\n        exit 2\n    }\n\n    if command -v ruff &>/dev/null; then\n        LINT_OUTPUT=$(ruff check . 2>&1) || {\n            echo \"ITERATION $count/$MAX_ITERATIONS - Lint errors:\" >&2\n            echo \"$LINT_OUTPUT\" | tail -20 >&2\n            exit 2\n        }\n    fi\n\n    if command -v mypy &>/dev/null; then\n        TYPE_OUTPUT=$(mypy . 2>&1) || {\n            echo \"ITERATION $count/$MAX_ITERATIONS - Type errors:\" >&2\n            echo \"$TYPE_OUTPUT\" | tail -20 >&2\n            exit 2\n        }\n    fi\nfi\n\n# All green - reset counter\nrm -f \"$ITERATION_FILE\"\nexit 0\n"
  },
  {
    "path": "tests/test_cross_agent.py",
    "content": "\"\"\"Tests for cross-agent intelligence (Codex auto-review, Kimi delegation, iCPG + Mnemos).\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport os\nfrom pathlib import Path\n\nimport pytest\n\nREPO_ROOT = Path(__file__).parent.parent\n\n\nclass TestCodexAutoReview:\n    \"\"\"Tests for templates/codex-auto-review.sh.\"\"\"\n\n    def test_script_exists(self) -> None:\n        path = REPO_ROOT / \"templates\" / \"codex-auto-review.sh\"\n        assert path.exists()\n\n    def test_script_is_executable(self) -> None:\n        path = REPO_ROOT / \"templates\" / \"codex-auto-review.sh\"\n        assert os.access(path, os.X_OK)\n\n    def test_script_has_shebang(self) -> None:\n        path = REPO_ROOT / \"templates\" / \"codex-auto-review.sh\"\n        content = path.read_text()\n        assert content.startswith(\"#!/bin/bash\")\n\n    def test_script_checks_codex_installed(self) -> None:\n        path = REPO_ROOT / \"templates\" / \"codex-auto-review.sh\"\n        content = path.read_text()\n        assert \"command -v codex\" in content\n\n    def test_script_uses_exit_codes(self) -> None:\n        path = REPO_ROOT / \"templates\" / \"codex-auto-review.sh\"\n        content = path.read_text()\n        assert \"exit 0\" in content\n        assert \"return 2\" in content\n\n\nclass TestCrossAgentDelegation:\n    \"\"\"Tests for skills/cross-agent-delegation/SKILL.md.\"\"\"\n\n    def test_skill_exists(self) -> None:\n        path = REPO_ROOT / \"skills\" / \"cross-agent-delegation\" / \"SKILL.md\"\n        assert path.exists()\n\n    def test_skill_has_frontmatter(self) -> None:\n        path = REPO_ROOT / \"skills\" / \"cross-agent-delegation\" / \"SKILL.md\"\n        content = path.read_text()\n        assert content.startswith(\"---\")\n        assert \"name: cross-agent-delegation\" in content\n\n    def test_skill_references_icpg(self) -> None:\n        path = REPO_ROOT / \"skills\" / \"cross-agent-delegation\" / \"SKILL.md\"\n        content = path.read_text()\n        assert \"icpg\" in content.lower()\n        assert \"icpg query prior\" in content\n        assert \"icpg query constraints\" in content\n        assert \"icpg query risk\" in content\n\n    def test_skill_references_mnemos(self) -> None:\n        path = REPO_ROOT / \"skills\" / \"cross-agent-delegation\" / \"SKILL.md\"\n        content = path.read_text()\n        assert \"mnemos\" in content.lower()\n        assert \"mnemos add goal\" in content\n        assert \"mnemos checkpoint\" in content\n\n    def test_skill_has_complexity_scoring_rules(self) -> None:\n        path = REPO_ROOT / \"skills\" / \"cross-agent-delegation\" / \"SKILL.md\"\n        content = path.read_text()\n        assert \"0-3\" in content\n        assert \"4-6\" in content\n        assert \"7-10\" in content\n\n    def test_skill_has_tool_detection(self) -> None:\n        path = REPO_ROOT / \"skills\" / \"cross-agent-delegation\" / \"SKILL.md\"\n        content = path.read_text()\n        assert \"command -v kimi\" in content\n        assert \"command -v codex\" in content\n\n\nclass TestSettingsJsonHook:\n    \"\"\"Tests for codex-auto-review hook in settings.json.\"\"\"\n\n    def test_settings_has_codex_review_hook(self) -> None:\n        path = REPO_ROOT / \"templates\" / \"settings.json\"\n        data = json.loads(path.read_text())\n        stop_hooks = data[\"hooks\"][\"Stop\"][0][\"hooks\"]\n        commands = [h[\"command\"] for h in stop_hooks]\n        assert any(\"codex-auto-review\" in cmd for cmd in commands)\n\n    def test_codex_hook_after_tdd(self) -> None:\n        path = REPO_ROOT / \"templates\" / \"settings.json\"\n        data = json.loads(path.read_text())\n        stop_hooks = data[\"hooks\"][\"Stop\"][0][\"hooks\"]\n        commands = [h[\"command\"] for h in stop_hooks]\n        tdd_idx = next(\n            i for i, c in enumerate(commands) if \"tdd-loop-check\" in c\n        )\n        codex_idx = next(\n            i for i, c in enumerate(commands) if \"codex-auto-review\" in c\n        )\n        assert codex_idx > tdd_idx\n\n    def test_codex_hook_before_icpg(self) -> None:\n        path = REPO_ROOT / \"templates\" / \"settings.json\"\n        data = json.loads(path.read_text())\n        stop_hooks = data[\"hooks\"][\"Stop\"][0][\"hooks\"]\n        commands = [h[\"command\"] for h in stop_hooks]\n        codex_idx = next(\n            i for i, c in enumerate(commands) if \"codex-auto-review\" in c\n        )\n        icpg_idx = next(\n            i for i, c in enumerate(commands) if \"icpg-stop-record\" in c\n        )\n        assert codex_idx < icpg_idx\n\n    def test_codex_hook_has_timeout(self) -> None:\n        path = REPO_ROOT / \"templates\" / \"settings.json\"\n        data = json.loads(path.read_text())\n        stop_hooks = data[\"hooks\"][\"Stop\"][0][\"hooks\"]\n        codex_hook = next(\n            h for h in stop_hooks if \"codex-auto-review\" in h[\"command\"]\n        )\n        assert codex_hook[\"timeout\"] == 120\n\n\nclass TestConfigTomlHook:\n    \"\"\"Tests for codex-auto-review hook in config.toml.\"\"\"\n\n    def test_config_toml_has_codex_hook(self) -> None:\n        path = REPO_ROOT / \"templates\" / \"config.toml\"\n        content = path.read_text()\n        assert \"codex-auto-review\" in content\n\n    def test_config_toml_codex_hook_timeout(self) -> None:\n        path = REPO_ROOT / \"templates\" / \"config.toml\"\n        content = path.read_text()\n        # Find the codex-auto-review block and check timeout\n        lines = content.splitlines()\n        in_codex_block = False\n        for line in lines:\n            if \"Codex Auto-Review\" in line:\n                in_codex_block = True\n            if in_codex_block and line.startswith(\"timeout\"):\n                assert \"120\" in line\n                break\n\n\nclass TestTemplateSkillRefs:\n    \"\"\"Tests for skill references in templates.\"\"\"\n\n    def test_claude_md_has_delegation_skill(self) -> None:\n        path = REPO_ROOT / \"templates\" / \"CLAUDE.md\"\n        content = path.read_text()\n        assert \"cross-agent-delegation/SKILL.md\" in content\n\n    def test_agents_md_has_delegation_skill(self) -> None:\n        path = REPO_ROOT / \"templates\" / \"AGENTS.md\"\n        content = path.read_text()\n        assert \"cross-agent-delegation/SKILL.md\" in content\n\n    def test_claude_md_has_workflow_section(self) -> None:\n        path = REPO_ROOT / \"templates\" / \"CLAUDE.md\"\n        content = path.read_text()\n        assert \"## Cross-Agent Workflow\" in content\n        assert \"Codex Auto-Review\" in content\n        assert \"Kimi Delegation\" in content\n\n    def test_agents_md_has_workflow_section(self) -> None:\n        path = REPO_ROOT / \"templates\" / \"AGENTS.md\"\n        content = path.read_text()\n        assert \"## Cross-Agent Workflow\" in content\n        assert \"Codex Auto-Review\" in content\n        assert \"Kimi Delegation\" in content\n\n\nclass TestInitializeProjectRef:\n    \"\"\"Tests for cross-agent-delegation in initialize-project.md.\"\"\"\n\n    def test_init_copies_delegation_skill(self) -> None:\n        path = REPO_ROOT / \"commands\" / \"initialize-project.md\"\n        content = path.read_text()\n        assert \"cross-agent-delegation/\" in content\n"
  },
  {
    "path": "tests/test_cross_tool.py",
    "content": "\"\"\"Tests for cross-tool (Claude/Kimi/Codex) compatibility.\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nimport subprocess\nfrom pathlib import Path\n\nimport pytest\n\nREPO_ROOT = Path(__file__).parent.parent\n\n\nclass TestDetectAgents:\n    \"\"\"Tests for scripts/detect-agents.sh.\"\"\"\n\n    def test_script_exists_and_executable(self) -> None:\n        script = REPO_ROOT / \"scripts\" / \"detect-agents.sh\"\n        assert script.exists()\n        assert os.access(script, os.X_OK)\n\n    def test_outputs_valid_format(self) -> None:\n        script = REPO_ROOT / \"scripts\" / \"detect-agents.sh\"\n        result = subprocess.run(\n            [str(script)],\n            capture_output=True,\n            text=True,\n            timeout=10,\n        )\n        assert result.returncode == 0\n        valid_tools = {\"claude\", \"kimi\", \"codex\", \"docker\", \"orbstack\", \"polyphony\"}\n        for line in result.stdout.strip().splitlines():\n            assert line in valid_tools\n\n\nclass TestInstallSkills:\n    \"\"\"Tests for scripts/install-skills.sh.\"\"\"\n\n    def test_script_exists_and_executable(self) -> None:\n        script = REPO_ROOT / \"scripts\" / \"install-skills.sh\"\n        assert script.exists()\n        assert os.access(script, os.X_OK)\n\n    def test_copies_skills_to_target(self, tmp_path: Path) -> None:\n        script = REPO_ROOT / \"scripts\" / \"install-skills.sh\"\n        target = tmp_path / \"target-skills\"\n\n        result = subprocess.run(\n            [str(script), str(target)],\n            capture_output=True,\n            text=True,\n            timeout=30,\n        )\n        assert result.returncode == 0\n        assert target.exists()\n\n        # Should have at least 'base' skill\n        base_skill = target / \"base\" / \"SKILL.md\"\n        assert base_skill.exists()\n\n    def test_no_args_shows_usage(self) -> None:\n        script = REPO_ROOT / \"scripts\" / \"install-skills.sh\"\n        result = subprocess.run(\n            [str(script)],\n            capture_output=True,\n            text=True,\n            timeout=10,\n        )\n        assert result.returncode != 0\n\n\nclass TestTemplates:\n    \"\"\"Tests for cross-tool templates.\"\"\"\n\n    def test_agents_md_exists(self) -> None:\n        path = REPO_ROOT / \"templates\" / \"AGENTS.md\"\n        assert path.exists()\n\n    def test_agents_md_has_skills_section(self) -> None:\n        path = REPO_ROOT / \"templates\" / \"AGENTS.md\"\n        content = path.read_text()\n        assert \"## Skills\" in content\n        assert \"SKILL.md\" in content\n\n    def test_config_toml_exists(self) -> None:\n        path = REPO_ROOT / \"templates\" / \"config.toml\"\n        assert path.exists()\n\n    def test_config_toml_has_hooks(self) -> None:\n        path = REPO_ROOT / \"templates\" / \"config.toml\"\n        content = path.read_text()\n        assert \"[[hooks]]\" in content\n        assert 'event = \"Stop\"' in content\n        assert 'event = \"SessionStart\"' in content\n\n    def test_agents_md_has_conventions(self) -> None:\n        path = REPO_ROOT / \"templates\" / \"AGENTS.md\"\n        content = path.read_text()\n        assert \"## Conventions\" in content\n        assert \"## Don't\" in content\n\n\nclass TestSyncAgentsCommand:\n    \"\"\"Tests for commands/sync-agents.md.\"\"\"\n\n    def test_command_exists(self) -> None:\n        path = REPO_ROOT / \"commands\" / \"sync-agents.md\"\n        assert path.exists()\n\n    def test_command_has_phases(self) -> None:\n        path = REPO_ROOT / \"commands\" / \"sync-agents.md\"\n        content = path.read_text()\n        assert \"## Phase 1\" in content\n        assert \"## Phase 2\" in content\n        assert \"detect-agents.sh\" in content\n"
  },
  {
    "path": "tests/test_polyphony_adapters.py",
    "content": "\"\"\"Tests for Polyphony agent adapters (§8.1-8.3).\"\"\"\n\nimport pytest\nfrom polyphony.adapters import get_adapter, list_adapters\nfrom polyphony.adapters.claude import ClaudeAdapter\nfrom polyphony.adapters.codex import CodexAdapter\nfrom polyphony.adapters.kimi import KimiAdapter\nfrom polyphony.models import AgentProfile, RunSpec\n\n\n@pytest.fixture\ndef claude_profile():\n    return AgentProfile(\n        name=\"claude-opus\",\n        agent_type=\"claude\",\n        cli_command=\"claude -p\",\n        strengths=[\"long_context\"],\n        event_protocol=\"stream-json\",\n    )\n\n\n@pytest.fixture\ndef codex_profile():\n    return AgentProfile(\n        name=\"codex-default\",\n        agent_type=\"codex\",\n        cli_command=\"codex exec\",\n        strengths=[\"code\"],\n        event_protocol=\"ndjson\",\n    )\n\n\n@pytest.fixture\ndef kimi_profile():\n    return AgentProfile(\n        name=\"kimi-default\",\n        agent_type=\"kimi\",\n        cli_command=\"kimi --print -y\",\n        strengths=[\"code\"],\n        event_protocol=\"ndjson\",\n    )\n\n\n@pytest.fixture\ndef run_spec():\n    return RunSpec(\n        task_id=\"T-1\",\n        agent=\"claude-opus\",\n        identity=\"protaige\",\n        workspace=\"/workspace\",\n        image=\"polyphony-worker:latest\",\n        max_turns=10,\n        env_overlay={\"ANTHROPIC_API_KEY\": \"ANTHROPIC_API_KEY\"},\n        volume_mounts=[\"~/.claude:/home/worker/.claude:ro\"],\n    )\n\n\nclass TestRegistry:\n    def test_list_adapters(self):\n        names = list_adapters()\n        assert \"claude\" in names\n        assert \"codex\" in names\n        assert \"kimi\" in names\n\n    def test_get_claude_adapter(self):\n        adapter = get_adapter(\"claude\")\n        assert isinstance(adapter, ClaudeAdapter)\n\n    def test_get_codex_adapter(self):\n        adapter = get_adapter(\"codex\")\n        assert isinstance(adapter, CodexAdapter)\n\n    def test_get_kimi_adapter(self):\n        adapter = get_adapter(\"kimi\")\n        assert isinstance(adapter, KimiAdapter)\n\n    def test_unknown_adapter_raises(self):\n        with pytest.raises(KeyError, match=\"gemini\"):\n            get_adapter(\"gemini\")\n\n\nclass TestClaudeAdapter:\n    def test_build_command(self, claude_profile, run_spec):\n        adapter = ClaudeAdapter()\n        cmd = adapter.build_command(claude_profile, run_spec)\n        assert \"claude\" in cmd[0]\n        assert \"-p\" in cmd\n        assert \"--output-format\" in cmd\n        assert \"stream-json\" in cmd\n\n    def test_prompt_included(self, claude_profile, run_spec):\n        adapter = ClaudeAdapter()\n        run_spec.env_overlay[\"PROMPT\"] = \"Fix the bug\"\n        cmd = adapter.build_command(claude_profile, run_spec)\n        cmd_str = \" \".join(cmd)\n        assert \"claude\" in cmd_str\n\n    def test_detect_completion(self):\n        adapter = ClaudeAdapter()\n        assert adapter.detect_completion({\"type\": \"result\"}) is True\n        assert adapter.detect_completion({\"type\": \"message\"}) is False\n\n    def test_detect_quota(self):\n        adapter = ClaudeAdapter()\n        assert adapter.detect_quota(\"rate limit exceeded\") is True\n        assert adapter.detect_quota(\"all good\") is False\n\n\nclass TestCodexAdapter:\n    def test_build_command(self, codex_profile, run_spec):\n        adapter = CodexAdapter()\n        cmd = adapter.build_command(codex_profile, run_spec)\n        assert \"codex\" in cmd[0]\n        assert \"exec\" in cmd\n        assert \"--full-auto\" in cmd\n\n    def test_detect_completion(self):\n        adapter = CodexAdapter()\n        assert adapter.detect_completion({\"status\": \"completed\"}) is True\n        assert adapter.detect_completion({\"status\": \"running\"}) is False\n\n    def test_detect_quota(self):\n        adapter = CodexAdapter()\n        assert adapter.detect_quota(\"quota exceeded\") is True\n        assert adapter.detect_quota(\"running\") is False\n\n\nclass TestKimiAdapter:\n    def test_build_command(self, kimi_profile, run_spec):\n        adapter = KimiAdapter()\n        cmd = adapter.build_command(kimi_profile, run_spec)\n        assert \"kimi\" in cmd[0]\n        assert \"--print\" in cmd\n        assert \"-y\" in cmd\n\n    def test_detect_completion(self):\n        adapter = KimiAdapter()\n        assert adapter.detect_completion({\"done\": True}) is True\n        assert adapter.detect_completion({\"done\": False}) is False\n\n    def test_detect_quota(self):\n        adapter = KimiAdapter()\n        assert adapter.detect_quota(\"rate limit\") is True\n        assert adapter.detect_quota(\"ok\") is False\n"
  },
  {
    "path": "tests/test_polyphony_config.py",
    "content": "\"\"\"Tests for Polyphony config loading (§11).\"\"\"\n\nimport pytest\nfrom polyphony.config import (\n    load_config,\n    load_identities,\n    load_agents,\n    load_routing,\n    default_config_dir,\n)\nfrom polyphony.models import Identity, AgentProfile\n\n\nclass TestDefaultConfigDir:\n    def test_returns_path(self):\n        d = default_config_dir()\n        assert str(d).endswith(\".polyphony\")\n\n\nclass TestLoadConfig:\n    def test_missing_dir_returns_defaults(self, tmp_path):\n        cfg = load_config(tmp_path / \"nonexistent\")\n        assert \"workspace_root\" in cfg\n        assert \"poll_interval\" in cfg\n        assert \"max_concurrent_agents\" in cfg\n\n    def test_loads_yaml(self, tmp_path):\n        cfg_file = tmp_path / \"config.yaml\"\n        cfg_file.write_text(\n            \"workspace_root: /custom/path\\n\"\n            \"max_concurrent_agents: 4\\n\"\n        )\n        cfg = load_config(tmp_path)\n        assert cfg[\"workspace_root\"] == \"/custom/path\"\n        assert cfg[\"max_concurrent_agents\"] == 4\n\n    def test_defaults_fill_missing_keys(self, tmp_path):\n        cfg_file = tmp_path / \"config.yaml\"\n        cfg_file.write_text(\"workspace_root: /x\\n\")\n        cfg = load_config(tmp_path)\n        assert \"poll_interval\" in cfg\n\n\nclass TestLoadIdentities:\n    def test_missing_file_returns_empty(self, tmp_path):\n        ids = load_identities(tmp_path)\n        assert ids == []\n\n    def test_loads_identities(self, tmp_path):\n        f = tmp_path / \"identities.yaml\"\n        f.write_text(\n            \"identities:\\n\"\n            \"  - name: test\\n\"\n            \"    volumes:\\n\"\n            \"      claude: ~/.claude\\n\"\n        )\n        ids = load_identities(tmp_path)\n        assert len(ids) == 1\n        assert isinstance(ids[0], Identity)\n        assert ids[0].name == \"test\"\n        assert ids[0].volumes[\"claude\"] == \"~/.claude\"\n\n\nclass TestLoadAgents:\n    def test_missing_file_returns_empty(self, tmp_path):\n        agents = load_agents(tmp_path)\n        assert agents == []\n\n    def test_loads_agents(self, tmp_path):\n        f = tmp_path / \"agents.yaml\"\n        f.write_text(\n            \"agents:\\n\"\n            \"  - name: claude-opus\\n\"\n            \"    agent_type: claude\\n\"\n            \"    cli_command: claude -p\\n\"\n        )\n        agents = load_agents(tmp_path)\n        assert len(agents) == 1\n        assert isinstance(agents[0], AgentProfile)\n        assert agents[0].name == \"claude-opus\"\n\n\nclass TestLoadRouting:\n    def test_missing_file_returns_defaults(self, tmp_path):\n        r = load_routing(tmp_path)\n        assert \"rules\" in r\n        assert \"default\" in r\n\n    def test_loads_routing(self, tmp_path):\n        f = tmp_path / \"routing.yaml\"\n        f.write_text(\n            \"rules:\\n\"\n            \"  - match: {task_type: bugfix}\\n\"\n            \"    agent: kimi\\n\"\n            \"default:\\n\"\n            \"  agent: claude\\n\"\n        )\n        r = load_routing(tmp_path)\n        assert len(r[\"rules\"]) == 1\n        assert r[\"default\"][\"agent\"] == \"claude\"\n"
  },
  {
    "path": "tests/test_polyphony_events.py",
    "content": "\"\"\"Tests for Polyphony event parsing (§8 events).\"\"\"\n\nimport json\nimport pytest\nfrom polyphony.events import (\n    TaskEvent,\n    parse_ndjson_line,\n    parse_stream_json,\n    classify_event,\n)\n\n\nclass TestTaskEvent:\n    def test_create(self):\n        ev = TaskEvent(\n            kind=\"message\",\n            data={\"text\": \"hello\"},\n        )\n        assert ev.kind == \"message\"\n        assert ev.data[\"text\"] == \"hello\"\n        assert ev.timestamp != \"\"\n\n    def test_from_dict(self):\n        ev = TaskEvent.from_dict({\n            \"kind\": \"result\",\n            \"data\": {\"status\": \"ok\"},\n            \"timestamp\": \"2025-01-01T00:00:00\",\n        })\n        assert ev.kind == \"result\"\n        assert ev.timestamp == \"2025-01-01T00:00:00\"\n\n\nclass TestParseNdjsonLine:\n    def test_valid_json(self):\n        line = '{\"type\": \"message\", \"content\": \"hello\"}'\n        result = parse_ndjson_line(line)\n        assert result[\"type\"] == \"message\"\n\n    def test_empty_line(self):\n        assert parse_ndjson_line(\"\") is None\n\n    def test_whitespace_line(self):\n        assert parse_ndjson_line(\"   \\n\") is None\n\n    def test_invalid_json(self):\n        assert parse_ndjson_line(\"not json\") is None\n\n    def test_strips_whitespace(self):\n        line = '  {\"key\": \"value\"}  \\n'\n        result = parse_ndjson_line(line)\n        assert result[\"key\"] == \"value\"\n\n\nclass TestParseStreamJson:\n    def test_parses_multiple_lines(self):\n        lines = [\n            '{\"type\": \"message\", \"text\": \"a\"}',\n            '{\"type\": \"result\", \"status\": \"ok\"}',\n        ]\n        events = parse_stream_json(lines)\n        assert len(events) == 2\n        assert events[0][\"type\"] == \"message\"\n        assert events[1][\"type\"] == \"result\"\n\n    def test_skips_invalid_lines(self):\n        lines = [\n            '{\"type\": \"message\"}',\n            \"not json\",\n            '{\"type\": \"result\"}',\n        ]\n        events = parse_stream_json(lines)\n        assert len(events) == 2\n\n    def test_empty_input(self):\n        assert parse_stream_json([]) == []\n\n\nclass TestClassifyEvent:\n    def test_result_event(self):\n        ev = classify_event({\"type\": \"result\", \"status\": \"ok\"})\n        assert ev.kind == \"result\"\n\n    def test_message_event(self):\n        ev = classify_event({\"type\": \"message\", \"text\": \"hi\"})\n        assert ev.kind == \"message\"\n\n    def test_error_event(self):\n        ev = classify_event({\"type\": \"error\", \"message\": \"fail\"})\n        assert ev.kind == \"error\"\n\n    def test_unknown_event(self):\n        ev = classify_event({\"foo\": \"bar\"})\n        assert ev.kind == \"unknown\"\n\n    def test_preserves_data(self):\n        data = {\"type\": \"result\", \"status\": \"ok\", \"extra\": 42}\n        ev = classify_event(data)\n        assert ev.data == data\n"
  },
  {
    "path": "tests/test_polyphony_identity.py",
    "content": "\"\"\"Tests for Polyphony identity broker (§7).\"\"\"\n\nimport pytest\nfrom polyphony.models import Identity\nfrom polyphony.identity import (\n    resolve_identity,\n    build_volume_mounts,\n    build_env_overlay,\n    validate_identity,\n)\n\n\n@pytest.fixture\ndef identities():\n    return [\n        Identity(\n            name=\"protaige\",\n            volumes={\"claude\": \"~/.claude\", \"codex\": \"~/.codex\"},\n            api_keys={\"anthropic\": \"ANTHROPIC_API_KEY\"},\n        ),\n        Identity(\n            name=\"personal\",\n            volumes={\"kimi\": \"~/.kimi\"},\n        ),\n    ]\n\n\nclass TestResolveIdentity:\n    def test_finds_by_name(self, identities):\n        found = resolve_identity(\"protaige\", identities)\n        assert found.name == \"protaige\"\n\n    def test_missing_raises(self, identities):\n        with pytest.raises(KeyError, match=\"unknown\"):\n            resolve_identity(\"unknown\", identities)\n\n\nclass TestBuildVolumeMounts:\n    def test_mounts_for_claude(self, identities):\n        mounts = build_volume_mounts(identities[0], \"claude\")\n        assert len(mounts) == 1\n        assert \"~/.claude\" in mounts[0]\n        assert \":ro\" in mounts[0]\n\n    def test_no_mount_for_missing_agent(self, identities):\n        mounts = build_volume_mounts(identities[1], \"claude\")\n        assert mounts == []\n\n\nclass TestBuildEnvOverlay:\n    def test_env_from_api_keys(self, identities):\n        env = build_env_overlay(identities[0])\n        assert \"ANTHROPIC_API_KEY\" in env\n\n    def test_empty_when_no_keys(self, identities):\n        env = build_env_overlay(identities[1])\n        assert env == {}\n\n\nclass TestValidateIdentity:\n    def test_valid(self, identities):\n        errors = validate_identity(identities[0])\n        assert errors == []\n\n    def test_missing_name(self):\n        i = Identity(name=\"\", volumes={\"claude\": \"~/.claude\"})\n        errors = validate_identity(i)\n        assert any(\"name\" in e for e in errors)\n\n    def test_missing_volumes(self):\n        i = Identity(name=\"test\", volumes={})\n        errors = validate_identity(i)\n        assert any(\"volume\" in e.lower() for e in errors)\n"
  },
  {
    "path": "tests/test_polyphony_models.py",
    "content": "\"\"\"Tests for Polyphony data models (§3 of spec).\"\"\"\n\nimport pytest\nfrom polyphony.models import (\n    TASK_TYPES,\n    RISK_LEVELS,\n    SCOPES,\n    Task,\n    Identity,\n    AgentProfile,\n    RunSpec,\n    Result,\n    _now,\n    _uuid,\n)\n\n\nclass TestHelpers:\n    def test_now_returns_iso_string(self):\n        ts = _now()\n        assert \"T\" in ts\n        assert \"+\" in ts or \"Z\" in ts\n\n    def test_uuid_returns_unique(self):\n        a, b = _uuid(), _uuid()\n        assert a != b\n        assert len(a) == 36\n\n\nclass TestTaskConstants:\n    def test_task_types(self):\n        expected = {\n            \"research\", \"bugfix\", \"feature\",\n            \"refactor\", \"migration\", \"docs\", \"review\",\n        }\n        assert set(TASK_TYPES) == expected\n\n    def test_risk_levels(self):\n        assert set(RISK_LEVELS) == {\"low\", \"medium\", \"high\"}\n\n    def test_scopes(self):\n        expected = {\n            \"single_file\", \"single_module\",\n            \"multi_module\", \"multi_repo\",\n        }\n        assert set(SCOPES) == expected\n\n\nclass TestTask:\n    def test_create_minimal(self):\n        t = Task(\n            title=\"Fix login bug\",\n            source=\"github\",\n            source_ref=\"owner/repo#42\",\n        )\n        assert t.title == \"Fix login bug\"\n        assert t.source == \"github\"\n        assert len(t.id) == 36\n        assert t.state == \"discovered\"\n        assert t.task_type == \"feature\"\n        assert t.risk == \"low\"\n\n    def test_defaults(self):\n        t = Task(title=\"x\", source=\"local\", source_ref=\"1\")\n        assert t.scope == []\n        assert t.context_tokens == 0\n        assert t.requires_web is False\n        assert t.run_spec_id is None\n        assert t.metadata == {}\n\n    def test_to_dict(self):\n        t = Task(title=\"x\", source=\"local\", source_ref=\"1\")\n        d = t.to_dict()\n        assert d[\"title\"] == \"x\"\n        assert \"id\" in d\n        assert \"created_at\" in d\n\n\nclass TestIdentity:\n    def test_create(self):\n        i = Identity(\n            name=\"protaige\",\n            volumes={\"claude\": \"~/.claude\"},\n        )\n        assert i.name == \"protaige\"\n        assert i.volumes[\"claude\"] == \"~/.claude\"\n        assert i.api_keys == {}\n        assert i.cost_ceiling_usd_per_day is None\n\n    def test_with_api_keys(self):\n        i = Identity(\n            name=\"test\",\n            volumes={},\n            api_keys={\"anthropic\": \"ANTHROPIC_API_KEY\"},\n        )\n        assert i.api_keys[\"anthropic\"] == \"ANTHROPIC_API_KEY\"\n\n\nclass TestAgentProfile:\n    def test_create(self):\n        a = AgentProfile(\n            name=\"claude-opus\",\n            agent_type=\"claude\",\n            cli_command=\"claude -p\",\n        )\n        assert a.name == \"claude-opus\"\n        assert a.context_window_tokens == 200000\n        assert a.strengths == []\n\n    def test_event_protocol_default(self):\n        a = AgentProfile(\n            name=\"x\",\n            agent_type=\"claude\",\n            cli_command=\"claude -p\",\n        )\n        assert a.event_protocol == \"ndjson\"\n\n\nclass TestRunSpec:\n    def test_create(self):\n        r = RunSpec(\n            task_id=\"t1\",\n            agent=\"claude-opus\",\n            identity=\"protaige\",\n            workspace=\"/tmp/ws\",\n            image=\"polyphony/claude:latest\",\n        )\n        assert r.task_id == \"t1\"\n        assert r.attempt == 1\n        assert r.max_turns == 25\n        assert r.deadline_seconds == 1800\n        assert r.allowed_paths == []\n        assert r.proof_of_work == []\n\n    def test_immutable_concept(self):\n        \"\"\"RunSpec fields have defaults; verify they're set.\"\"\"\n        r = RunSpec(\n            task_id=\"t1\",\n            agent=\"x\",\n            identity=\"y\",\n            workspace=\"/w\",\n            image=\"img\",\n        )\n        assert len(r.id) == 36\n\n\nclass TestResult:\n    def test_create(self):\n        r = Result(\n            task_id=\"t1\",\n            run_spec_id=\"rs1\",\n            agent=\"claude-opus\",\n            status=\"succeeded\",\n        )\n        assert r.status == \"succeeded\"\n        assert r.turns == 0\n        assert r.duration_seconds == 0\n        assert r.cost_usd is None\n        assert r.events == []\n        assert r.artifacts == {}\n\n    def test_status_values(self):\n        for s in (\"succeeded\", \"failed\", \"quota\", \"timeout\", \"crash\"):\n            r = Result(\n                task_id=\"t\",\n                run_spec_id=\"r\",\n                agent=\"a\",\n                status=s,\n            )\n            assert r.status == s\n"
  },
  {
    "path": "tests/test_polyphony_orchestrator.py",
    "content": "\"\"\"Tests for Polyphony orchestrator (§4 supervisor loop).\"\"\"\n\nimport pytest\nfrom unittest.mock import patch, MagicMock\nfrom pathlib import Path\nfrom polyphony.orchestrator import (\n    Orchestrator,\n    discover_tasks,\n    claim_task,\n    provision_workspace,\n    run_agent,\n    verify_result,\n)\nfrom polyphony.models import (\n    Task, AgentProfile, Identity, RunSpec, Result,\n)\nfrom polyphony.store import PolyphonyStore\n\n\n@pytest.fixture\ndef store(tmp_path):\n    s = PolyphonyStore(tmp_path)\n    s.init_db()\n    return s\n\n\n@pytest.fixture\ndef task():\n    return Task(\n        title=\"Fix auth bug\",\n        source=\"local\",\n        source_ref=\"local\",\n        task_type=\"bugfix\",\n        risk=\"medium\",\n    )\n\n\n@pytest.fixture\ndef agents():\n    return [\n        AgentProfile(\n            name=\"claude-opus\",\n            agent_type=\"claude\",\n            cli_command=\"claude -p\",\n            strengths=[\"long_context\"],\n        ),\n    ]\n\n\n@pytest.fixture\ndef policy():\n    return {\n        \"rules\": [],\n        \"default\": {\n            \"agent\": \"claude-opus\",\n            \"fallback\": [],\n        },\n    }\n\n\n@pytest.fixture\ndef identities():\n    return [\n        Identity(\n            name=\"protaige\",\n            volumes={\"claude\": \"~/.claude\"},\n        ),\n    ]\n\n\nclass TestDiscoverTasks:\n    def test_returns_tasks(self, store, task):\n        store.save_task(task)\n        found = discover_tasks(store)\n        assert len(found) == 1\n        assert found[0].id == task.id\n\n    def test_empty_store(self, store):\n        assert discover_tasks(store) == []\n\n\nclass TestClaimTask:\n    def test_transitions_to_claimed(self, store, task):\n        store.save_task(task)\n        claimed = claim_task(task, store)\n        assert claimed.state == \"claimed\"\n\n    def test_updates_store(self, store, task):\n        store.save_task(task)\n        claim_task(task, store)\n        stored = store.get_task(task.id)\n        assert stored.state == \"claimed\"\n\n\nclass TestProvisionWorkspace:\n    @patch(\"polyphony.orchestrator._create_ws\")\n    def test_returns_path(self, mock_ws, tmp_path, task):\n        ws_path = tmp_path / \"ws\"\n        ws_path.mkdir()\n        mock_ws.return_value = ws_path\n        result = provision_workspace(task, tmp_path, \"main\")\n        assert result == ws_path\n\n    @patch(\"polyphony.orchestrator._create_ws\")\n    def test_calls_create(self, mock_ws, tmp_path, task):\n        mock_ws.return_value = tmp_path\n        provision_workspace(task, tmp_path, \"main\")\n        assert mock_ws.called\n\n\nclass TestRunAgent:\n    @patch(\"polyphony.orchestrator._execute_container\")\n    def test_returns_result(self, mock_exec, task):\n        mock_exec.return_value = Result(\n            task_id=task.id,\n            run_spec_id=\"rs-1\",\n            agent=\"claude-opus\",\n            status=\"succeeded\",\n        )\n        run_spec = RunSpec(\n            task_id=task.id,\n            agent=\"claude-opus\",\n            identity=\"protaige\",\n            workspace=\"/ws\",\n            image=\"polyphony-worker:latest\",\n        )\n        result = run_agent(run_spec)\n        assert result.status == \"succeeded\"\n\n    @patch(\"polyphony.orchestrator._execute_container\")\n    def test_handles_failure(self, mock_exec, task):\n        mock_exec.return_value = Result(\n            task_id=task.id,\n            run_spec_id=\"rs-1\",\n            agent=\"claude-opus\",\n            status=\"failed\",\n        )\n        run_spec = RunSpec(\n            task_id=task.id,\n            agent=\"claude-opus\",\n            identity=\"protaige\",\n            workspace=\"/ws\",\n            image=\"polyphony-worker:latest\",\n        )\n        result = run_agent(run_spec)\n        assert result.status == \"failed\"\n\n\nclass TestVerifyResult:\n    def test_succeeded_passes(self):\n        result = Result(\n            task_id=\"T-1\",\n            run_spec_id=\"rs-1\",\n            agent=\"claude-opus\",\n            status=\"succeeded\",\n        )\n        assert verify_result(result) is True\n\n    def test_failed_fails(self):\n        result = Result(\n            task_id=\"T-1\",\n            run_spec_id=\"rs-1\",\n            agent=\"claude-opus\",\n            status=\"failed\",\n        )\n        assert verify_result(result) is False\n\n\nclass TestOrchestrator:\n    def test_init(self, store, agents, policy, identities):\n        orch = Orchestrator(\n            store=store,\n            agents=agents,\n            policy=policy,\n            identities=identities,\n        )\n        assert orch is not None\n\n    def test_has_step(self, store, agents, policy, identities):\n        orch = Orchestrator(\n            store=store,\n            agents=agents,\n            policy=policy,\n            identities=identities,\n        )\n        assert hasattr(orch, \"step\")\n"
  },
  {
    "path": "tests/test_polyphony_router.py",
    "content": "\"\"\"Tests for Polyphony router (§5.2-5.6).\"\"\"\n\nimport pytest\nfrom polyphony.models import Task, AgentProfile, RunSpec\nfrom polyphony.router import route, select_agent, match_rule\n\n\n@pytest.fixture\ndef agents():\n    return [\n        AgentProfile(\n            name=\"claude-opus\",\n            agent_type=\"claude\",\n            cli_command=\"claude -p\",\n            strengths=[\"long_context\", \"research\"],\n        ),\n        AgentProfile(\n            name=\"codex-default\",\n            agent_type=\"codex\",\n            cli_command=\"codex exec\",\n            strengths=[\"code\"],\n        ),\n        AgentProfile(\n            name=\"kimi-default\",\n            agent_type=\"kimi\",\n            cli_command=\"kimi --print -y\",\n            strengths=[\"code\"],\n        ),\n    ]\n\n\n@pytest.fixture\ndef policy():\n    return {\n        \"rules\": [\n            {\n                \"match\": {\"task_type\": \"docs\", \"risk\": \"low\"},\n                \"agent\": \"kimi-default\",\n            },\n            {\n                \"match\": {\"task_type\": \"bugfix\"},\n                \"agent\": \"codex-default\",\n            },\n            {\n                \"match\": {\"risk\": \"high\"},\n                \"agent\": \"claude-opus\",\n            },\n        ],\n        \"default\": {\n            \"agent\": \"claude-opus\",\n            \"fallback\": [\"codex-default\", \"kimi-default\"],\n        },\n    }\n\n\nclass TestMatchRule:\n    def test_matches_single_field(self):\n        task = Task(\n            title=\"x\", source=\"local\", source_ref=\"1\",\n            task_type=\"docs\",\n        )\n        rule = {\"match\": {\"task_type\": \"docs\"}}\n        assert match_rule(task, rule) is True\n\n    def test_no_match(self):\n        task = Task(\n            title=\"x\", source=\"local\", source_ref=\"1\",\n            task_type=\"feature\",\n        )\n        rule = {\"match\": {\"task_type\": \"docs\"}}\n        assert match_rule(task, rule) is False\n\n    def test_matches_multiple_fields(self):\n        task = Task(\n            title=\"x\", source=\"local\", source_ref=\"1\",\n            task_type=\"docs\", risk=\"low\",\n        )\n        rule = {\"match\": {\"task_type\": \"docs\", \"risk\": \"low\"}}\n        assert match_rule(task, rule) is True\n\n    def test_partial_match_fails(self):\n        task = Task(\n            title=\"x\", source=\"local\", source_ref=\"1\",\n            task_type=\"docs\", risk=\"high\",\n        )\n        rule = {\"match\": {\"task_type\": \"docs\", \"risk\": \"low\"}}\n        assert match_rule(task, rule) is False\n\n\nclass TestSelectAgent:\n    def test_selects_by_rule(self, agents, policy):\n        task = Task(\n            title=\"Fix readme\", source=\"local\",\n            source_ref=\"1\", task_type=\"docs\", risk=\"low\",\n        )\n        agent = select_agent(task, agents, policy)\n        assert agent.name == \"kimi-default\"\n\n    def test_falls_to_default(self, agents, policy):\n        task = Task(\n            title=\"New feature\", source=\"local\",\n            source_ref=\"1\", task_type=\"feature\", risk=\"medium\",\n        )\n        agent = select_agent(task, agents, policy)\n        assert agent.name == \"claude-opus\"\n\n    def test_high_risk_matches_claude(self, agents, policy):\n        task = Task(\n            title=\"Refactor auth\", source=\"local\",\n            source_ref=\"1\", task_type=\"refactor\", risk=\"high\",\n        )\n        agent = select_agent(task, agents, policy)\n        assert agent.name == \"claude-opus\"\n\n\nclass TestRoute:\n    def test_returns_run_spec(self, agents, policy):\n        task = Task(\n            title=\"Fix bug\", source=\"github\",\n            source_ref=\"o/r#1\", task_type=\"bugfix\",\n        )\n        rs = route(task, agents, policy, identity=\"test\")\n        assert isinstance(rs, RunSpec)\n        assert rs.task_id == task.id\n        assert rs.agent == \"codex-default\"\n        assert rs.identity == \"test\"\n\n    def test_run_spec_has_fallback(self, agents, policy):\n        task = Task(\n            title=\"New feature\", source=\"local\",\n            source_ref=\"1\", task_type=\"feature\",\n        )\n        rs = route(task, agents, policy, identity=\"test\")\n        # default rule has fallback\n        assert isinstance(rs.fallback, list)\n"
  },
  {
    "path": "tests/test_polyphony_runtime.py",
    "content": "\"\"\"Tests for Polyphony Docker runtime (§8 worker).\"\"\"\n\nimport pytest\nfrom unittest.mock import patch, MagicMock\nfrom polyphony.runtime import (\n    create_container,\n    start_container,\n    stop_container,\n    remove_container,\n    container_logs,\n    wait_container,\n    build_docker_args,\n)\nfrom polyphony.models import RunSpec\n\n\n@pytest.fixture\ndef run_spec():\n    return RunSpec(\n        task_id=\"T-1\",\n        agent=\"claude-opus\",\n        identity=\"protaige\",\n        workspace=\"/tmp/ws/T-1/1\",\n        image=\"polyphony-worker:latest\",\n        env_overlay={\"API_KEY\": \"API_KEY\"},\n        volume_mounts=[\"~/.claude:/home/worker/.claude:ro\"],\n        deadline_seconds=600,\n    )\n\n\nclass TestBuildDockerArgs:\n    def test_includes_image(self, run_spec):\n        args = build_docker_args(run_spec)\n        assert \"polyphony-worker:latest\" in args\n\n    def test_includes_volumes(self, run_spec):\n        args = build_docker_args(run_spec)\n        assert \"-v\" in args\n        # Collect all -v values\n        volumes = []\n        for i, a in enumerate(args):\n            if a == \"-v\" and i + 1 < len(args):\n                volumes.append(args[i + 1])\n        assert any(\n            \"~/.claude:/home/worker/.claude:ro\" in v\n            for v in volumes\n        )\n\n    def test_includes_env(self, run_spec):\n        args = build_docker_args(run_spec)\n        assert \"-e\" in args\n\n    def test_includes_workspace_mount(self, run_spec):\n        args = build_docker_args(run_spec)\n        arg_str = \" \".join(args)\n        assert \"/tmp/ws/T-1/1\" in arg_str\n\n    def test_container_name(self, run_spec):\n        args = build_docker_args(run_spec)\n        assert \"--name\" in args\n\n\nclass TestCreateContainer:\n    @patch(\"polyphony.runtime._run_docker\")\n    def test_creates_container(self, mock_docker, run_spec):\n        mock_docker.return_value = MagicMock(\n            returncode=0, stdout=\"container_id_123\\n\",\n        )\n        cid = create_container(run_spec)\n        assert cid == \"container_id_123\"\n        assert mock_docker.called\n\n    @patch(\"polyphony.runtime._run_docker\")\n    def test_failure_raises(self, mock_docker, run_spec):\n        mock_docker.return_value = MagicMock(\n            returncode=1, stderr=\"error\",\n        )\n        with pytest.raises(RuntimeError, match=\"error\"):\n            create_container(run_spec)\n\n\nclass TestStartContainer:\n    @patch(\"polyphony.runtime._run_docker\")\n    def test_starts(self, mock_docker):\n        mock_docker.return_value = MagicMock(returncode=0)\n        start_container(\"abc123\")\n        mock_docker.assert_called_once()\n        cmd = mock_docker.call_args[0][0]\n        assert \"start\" in cmd\n        assert \"abc123\" in cmd\n\n\nclass TestStopContainer:\n    @patch(\"polyphony.runtime._run_docker\")\n    def test_stops(self, mock_docker):\n        mock_docker.return_value = MagicMock(returncode=0)\n        stop_container(\"abc123\")\n        cmd = mock_docker.call_args[0][0]\n        assert \"stop\" in cmd\n\n    @patch(\"polyphony.runtime._run_docker\")\n    def test_stop_with_timeout(self, mock_docker):\n        mock_docker.return_value = MagicMock(returncode=0)\n        stop_container(\"abc123\", timeout=30)\n        cmd = mock_docker.call_args[0][0]\n        assert \"-t\" in cmd\n        assert \"30\" in cmd\n\n\nclass TestRemoveContainer:\n    @patch(\"polyphony.runtime._run_docker\")\n    def test_removes(self, mock_docker):\n        mock_docker.return_value = MagicMock(returncode=0)\n        remove_container(\"abc123\")\n        cmd = mock_docker.call_args[0][0]\n        assert \"rm\" in cmd\n        assert \"abc123\" in cmd\n\n\nclass TestContainerLogs:\n    @patch(\"polyphony.runtime._run_docker\")\n    def test_returns_logs(self, mock_docker):\n        mock_docker.return_value = MagicMock(\n            returncode=0,\n            stdout=\"line1\\nline2\\n\",\n        )\n        logs = container_logs(\"abc123\")\n        assert logs == \"line1\\nline2\\n\"\n\n\nclass TestWaitContainer:\n    @patch(\"polyphony.runtime._run_docker\")\n    def test_returns_exit_code(self, mock_docker):\n        mock_docker.return_value = MagicMock(\n            returncode=0, stdout=\"0\\n\",\n        )\n        code = wait_container(\"abc123\")\n        assert code == 0\n\n    @patch(\"polyphony.runtime._run_docker\")\n    def test_nonzero_exit(self, mock_docker):\n        mock_docker.return_value = MagicMock(\n            returncode=0, stdout=\"1\\n\",\n        )\n        code = wait_container(\"abc123\")\n        assert code == 1\n"
  },
  {
    "path": "tests/test_polyphony_scoring.py",
    "content": "\"\"\"Tests for Polyphony complexity scoring (§5.1).\"\"\"\n\nimport pytest\nfrom polyphony.models import Task\nfrom polyphony.scoring import (\n    DIMENSIONS,\n    score_task,\n    score_cyclomatic,\n    score_fan_out,\n    score_security,\n    score_concurrency,\n    score_domain,\n)\n\n\n@pytest.fixture\ndef simple_task():\n    return Task(\n        title=\"Fix typo in README\",\n        source=\"local\",\n        source_ref=\"1\",\n        task_type=\"docs\",\n        scope=[\"README.md\"],\n        risk=\"low\",\n    )\n\n\n@pytest.fixture\ndef complex_task():\n    return Task(\n        title=\"Refactor auth with async locks\",\n        source=\"github\",\n        source_ref=\"owner/repo#99\",\n        task_type=\"refactor\",\n        scope=[\"src/auth/middleware.ts\", \"src/auth/session.ts\"],\n        risk=\"high\",\n        metadata={\n            \"keywords\": [\"auth\", \"org_id\", \"asyncio.Lock\"],\n            \"loc\": 200,\n            \"callers\": 15,\n        },\n    )\n\n\nclass TestDimensions:\n    def test_five_dimensions(self):\n        assert len(DIMENSIONS) == 5\n\n    def test_dimension_names(self):\n        expected = {\n            \"cyclomatic\", \"fan_out\", \"security\",\n            \"concurrency\", \"domain\",\n        }\n        assert set(DIMENSIONS) == expected\n\n\nclass TestScoreCyclomatic:\n    def test_small_scope(self, simple_task):\n        assert score_cyclomatic(simple_task) == 0\n\n    def test_large_scope(self, complex_task):\n        assert score_cyclomatic(complex_task) >= 1\n\n\nclass TestScoreFanOut:\n    def test_no_callers(self, simple_task):\n        assert score_fan_out(simple_task) == 0\n\n    def test_many_callers(self, complex_task):\n        assert score_fan_out(complex_task) == 2\n\n\nclass TestScoreSecurity:\n    def test_no_security_keywords(self, simple_task):\n        assert score_security(simple_task) == 0\n\n    def test_auth_keywords(self, complex_task):\n        assert score_security(complex_task) >= 1\n\n\nclass TestScoreConcurrency:\n    def test_no_concurrency(self, simple_task):\n        assert score_concurrency(simple_task) == 0\n\n    def test_async_locks(self, complex_task):\n        assert score_concurrency(complex_task) >= 1\n\n\nclass TestScoreDomain:\n    def test_docs_task(self, simple_task):\n        assert score_domain(simple_task) == 0\n\n    def test_high_risk_refactor(self, complex_task):\n        assert score_domain(complex_task) >= 1\n\n\nclass TestScoreTask:\n    def test_simple_task_low(self, simple_task):\n        total = score_task(simple_task)\n        assert 0 <= total <= 3\n\n    def test_complex_task_high(self, complex_task):\n        total = score_task(complex_task)\n        assert total >= 4\n\n    def test_score_range(self, simple_task):\n        total = score_task(simple_task)\n        assert 0 <= total <= 10\n\n    def test_returns_dict_with_breakdown(self, simple_task):\n        \"\"\"score_task returns (total, breakdown) tuple.\"\"\"\n        result = score_task(simple_task)\n        assert isinstance(result, int)\n"
  },
  {
    "path": "tests/test_polyphony_sources.py",
    "content": "\"\"\"Tests for Polyphony work sources (§2).\"\"\"\n\nimport json\nimport pytest\nfrom unittest.mock import patch, MagicMock\nfrom pathlib import Path\nfrom polyphony.sources import get_source, list_sources\nfrom polyphony.sources.local import LocalSource\nfrom polyphony.sources.github import GitHubSource\nfrom polyphony.models import Task\n\n\nclass TestRegistry:\n    def test_list_sources(self):\n        names = list_sources()\n        assert \"local\" in names\n        assert \"github\" in names\n\n    def test_get_local_source(self):\n        src = get_source(\"local\")\n        assert isinstance(src, LocalSource)\n\n    def test_get_github_source(self):\n        src = get_source(\"github\")\n        assert isinstance(src, GitHubSource)\n\n    def test_unknown_raises(self):\n        with pytest.raises(KeyError, match=\"jira\"):\n            get_source(\"jira\")\n\n\nclass TestLocalSource:\n    def test_add_and_poll(self, tmp_path):\n        src = LocalSource(db_path=tmp_path / \"queue.db\")\n        src.add_task(\"Fix typo\", task_type=\"docs\", risk=\"low\")\n        tasks = src.poll()\n        assert len(tasks) == 1\n        assert tasks[0].title == \"Fix typo\"\n        assert tasks[0].source == \"local\"\n\n    def test_poll_empty(self, tmp_path):\n        src = LocalSource(db_path=tmp_path / \"queue.db\")\n        assert src.poll() == []\n\n    def test_mark_claimed(self, tmp_path):\n        src = LocalSource(db_path=tmp_path / \"queue.db\")\n        src.add_task(\"Task A\")\n        tasks = src.poll()\n        src.mark_claimed(tasks[0].id)\n        # After claiming, poll should not return it\n        remaining = src.poll()\n        assert len(remaining) == 0\n\n    def test_multiple_tasks(self, tmp_path):\n        src = LocalSource(db_path=tmp_path / \"queue.db\")\n        src.add_task(\"Task A\")\n        src.add_task(\"Task B\")\n        src.add_task(\"Task C\")\n        tasks = src.poll()\n        assert len(tasks) == 3\n\n\nclass TestGitHubSource:\n    @patch(\"polyphony.sources.github._run_gh\")\n    def test_poll_returns_tasks(self, mock_gh):\n        issues = [\n            {\n                \"number\": 42,\n                \"title\": \"Fix auth bug\",\n                \"labels\": [{\"name\": \"agent-ready\"}],\n            },\n        ]\n        mock_gh.return_value = MagicMock(\n            returncode=0,\n            stdout=json.dumps(issues),\n        )\n        src = GitHubSource(repo=\"owner/repo\")\n        tasks = src.poll()\n        assert len(tasks) == 1\n        assert tasks[0].title == \"Fix auth bug\"\n        assert tasks[0].source == \"github\"\n        assert \"42\" in tasks[0].source_ref\n\n    @patch(\"polyphony.sources.github._run_gh\")\n    def test_poll_empty(self, mock_gh):\n        mock_gh.return_value = MagicMock(\n            returncode=0, stdout=\"[]\",\n        )\n        src = GitHubSource(repo=\"owner/repo\")\n        assert src.poll() == []\n\n    @patch(\"polyphony.sources.github._run_gh\")\n    def test_poll_gh_failure(self, mock_gh):\n        mock_gh.return_value = MagicMock(\n            returncode=1, stderr=\"auth failed\",\n        )\n        src = GitHubSource(repo=\"owner/repo\")\n        # Should return empty, not crash\n        assert src.poll() == []\n\n    @patch(\"polyphony.sources.github._run_gh\")\n    def test_label_filter(self, mock_gh):\n        mock_gh.return_value = MagicMock(\n            returncode=0, stdout=\"[]\",\n        )\n        src = GitHubSource(\n            repo=\"owner/repo\",\n            label_filter=\"polyphony\",\n        )\n        src.poll()\n        cmd = mock_gh.call_args[0][0]\n        cmd_str = \" \".join(cmd)\n        assert \"polyphony\" in cmd_str\n"
  },
  {
    "path": "tests/test_polyphony_state.py",
    "content": "\"\"\"Tests for Polyphony state machine (§4 lifecycle).\"\"\"\n\nimport pytest\nfrom polyphony.models import Task\nfrom polyphony.state_machine import (\n    TASK_STATES,\n    TRANSITIONS,\n    can_transition,\n    transition,\n    is_terminal,\n)\n\n\nclass TestConstants:\n    def test_all_states_present(self):\n        expected = {\n            \"discovered\", \"claimed\", \"routed\", \"provisioned\",\n            \"running\", \"verifying\", \"landed\", \"failed\", \"blocked\",\n        }\n        assert set(TASK_STATES) == expected\n\n    def test_transitions_keys_are_valid_states(self):\n        for state in TRANSITIONS:\n            assert state in TASK_STATES\n\n\nclass TestCanTransition:\n    def test_discovered_to_claimed(self):\n        assert can_transition(\"discovered\", \"claimed\") is True\n\n    def test_claimed_to_routed(self):\n        assert can_transition(\"claimed\", \"routed\") is True\n\n    def test_routed_to_provisioned(self):\n        assert can_transition(\"routed\", \"provisioned\") is True\n\n    def test_provisioned_to_running(self):\n        assert can_transition(\"provisioned\", \"running\") is True\n\n    def test_running_to_verifying(self):\n        assert can_transition(\"running\", \"verifying\") is True\n\n    def test_running_to_failed(self):\n        assert can_transition(\"running\", \"failed\") is True\n\n    def test_verifying_to_landed(self):\n        assert can_transition(\"verifying\", \"landed\") is True\n\n    def test_verifying_to_failed(self):\n        assert can_transition(\"verifying\", \"failed\") is True\n\n    def test_failed_to_claimed_retry(self):\n        assert can_transition(\"failed\", \"claimed\") is True\n\n    def test_failed_to_blocked(self):\n        assert can_transition(\"failed\", \"blocked\") is True\n\n    def test_invalid_discovered_to_running(self):\n        assert can_transition(\"discovered\", \"running\") is False\n\n    def test_invalid_landed_to_anything(self):\n        assert can_transition(\"landed\", \"claimed\") is False\n        assert can_transition(\"landed\", \"failed\") is False\n\n    def test_invalid_same_state(self):\n        assert can_transition(\"claimed\", \"claimed\") is False\n\n\nclass TestTransition:\n    def test_valid_transition_updates_state(self):\n        t = Task(title=\"x\", source=\"local\", source_ref=\"1\")\n        assert t.state == \"discovered\"\n        t2 = transition(t, \"claimed\")\n        assert t2.state == \"claimed\"\n\n    def test_invalid_transition_raises(self):\n        t = Task(title=\"x\", source=\"local\", source_ref=\"1\")\n        with pytest.raises(ValueError, match=\"Invalid transition\"):\n            transition(t, \"running\")\n\n    def test_transition_updates_timestamp(self):\n        t = Task(title=\"x\", source=\"local\", source_ref=\"1\")\n        old_ts = t.updated_at\n        t2 = transition(t, \"claimed\")\n        assert t2.updated_at >= old_ts\n\n\nclass TestIsTerminal:\n    def test_landed_is_terminal(self):\n        assert is_terminal(\"landed\") is True\n\n    def test_blocked_is_terminal(self):\n        assert is_terminal(\"blocked\") is True\n\n    def test_discovered_not_terminal(self):\n        assert is_terminal(\"discovered\") is False\n\n    def test_running_not_terminal(self):\n        assert is_terminal(\"running\") is False\n\n    def test_failed_not_terminal(self):\n        assert is_terminal(\"failed\") is False\n"
  },
  {
    "path": "tests/test_polyphony_store.py",
    "content": "\"\"\"Tests for Polyphony SQLite store.\"\"\"\n\nimport pytest\nfrom polyphony.models import Task, RunSpec, Result\nfrom polyphony.store import PolyphonyStore\n\n\n@pytest.fixture\ndef store(tmp_path):\n    s = PolyphonyStore(tmp_path)\n    s.init_db()\n    return s\n\n\n@pytest.fixture\ndef sample_task():\n    return Task(\n        title=\"Fix bug\",\n        source=\"github\",\n        source_ref=\"owner/repo#1\",\n    )\n\n\nclass TestInit:\n    def test_creates_db(self, tmp_path):\n        s = PolyphonyStore(tmp_path)\n        s.init_db()\n        assert (tmp_path / \"orchestrator.db\").exists()\n\n    def test_creates_gitignore(self, tmp_path):\n        s = PolyphonyStore(tmp_path)\n        s.init_db()\n        gi = tmp_path / \".gitignore\"\n        assert gi.exists()\n        assert \"*\" in gi.read_text()\n\n    def test_idempotent(self, tmp_path):\n        s = PolyphonyStore(tmp_path)\n        s.init_db()\n        s.init_db()  # no error\n\n\nclass TestTaskCRUD:\n    def test_save_and_get(self, store, sample_task):\n        store.save_task(sample_task)\n        loaded = store.get_task(sample_task.id)\n        assert loaded is not None\n        assert loaded.title == \"Fix bug\"\n        assert loaded.source == \"github\"\n\n    def test_get_missing_returns_none(self, store):\n        assert store.get_task(\"nonexistent\") is None\n\n    def test_list_tasks(self, store):\n        t1 = Task(title=\"A\", source=\"local\", source_ref=\"1\")\n        t2 = Task(title=\"B\", source=\"local\", source_ref=\"2\")\n        store.save_task(t1)\n        store.save_task(t2)\n        tasks = store.list_tasks()\n        assert len(tasks) == 2\n\n    def test_list_tasks_by_state(self, store, sample_task):\n        store.save_task(sample_task)\n        found = store.list_tasks(state=\"discovered\")\n        assert len(found) == 1\n        empty = store.list_tasks(state=\"running\")\n        assert len(empty) == 0\n\n    def test_update_task(self, store, sample_task):\n        store.save_task(sample_task)\n        sample_task.state = \"claimed\"\n        store.save_task(sample_task)\n        loaded = store.get_task(sample_task.id)\n        assert loaded.state == \"claimed\"\n\n\nclass TestRunSpecCRUD:\n    def test_save_and_get(self, store):\n        rs = RunSpec(\n            task_id=\"t1\",\n            agent=\"claude\",\n            identity=\"protaige\",\n            workspace=\"/tmp/ws\",\n            image=\"img:latest\",\n        )\n        store.save_run_spec(rs)\n        loaded = store.get_run_spec(rs.id)\n        assert loaded is not None\n        assert loaded.agent == \"claude\"\n\n    def test_get_missing(self, store):\n        assert store.get_run_spec(\"nope\") is None\n\n\nclass TestResultCRUD:\n    def test_save_and_get(self, store):\n        r = Result(\n            task_id=\"t1\",\n            run_spec_id=\"rs1\",\n            agent=\"claude\",\n            status=\"succeeded\",\n        )\n        store.save_result(r)\n        loaded = store.get_result(r.id)\n        assert loaded is not None\n        assert loaded.status == \"succeeded\"\n\n    def test_list_results_by_task(self, store):\n        r1 = Result(\n            task_id=\"t1\",\n            run_spec_id=\"rs1\",\n            agent=\"claude\",\n            status=\"failed\",\n        )\n        r2 = Result(\n            task_id=\"t1\",\n            run_spec_id=\"rs2\",\n            agent=\"kimi\",\n            status=\"succeeded\",\n        )\n        store.save_result(r1)\n        store.save_result(r2)\n        results = store.list_results(task_id=\"t1\")\n        assert len(results) == 2\n\n\nclass TestStateLog:\n    def test_log_transition(self, store, sample_task):\n        store.save_task(sample_task)\n        store.log_transition(\n            sample_task.id, \"discovered\", \"claimed\",\n        )\n        log = store.get_state_log(sample_task.id)\n        assert len(log) == 1\n        assert log[0][\"from_state\"] == \"discovered\"\n        assert log[0][\"to_state\"] == \"claimed\"\n"
  },
  {
    "path": "tests/test_polyphony_workspace.py",
    "content": "\"\"\"Tests for Polyphony workspace manager (§6).\"\"\"\n\nimport pytest\nfrom unittest.mock import patch, MagicMock\nfrom pathlib import Path\nfrom polyphony.workspace import (\n    workspace_path,\n    create_workspace,\n    cleanup_workspace,\n    list_workspaces,\n)\n\n\nclass TestWorkspacePath:\n    def test_creates_path(self, tmp_path):\n        p = workspace_path(tmp_path, \"TASK-1\", 1)\n        assert \"TASK-1\" in str(p)\n        assert \"1\" in str(p)\n\n    def test_sanitizes_id(self, tmp_path):\n        p = workspace_path(tmp_path, \"owner/repo#42\", 1)\n        # No slashes in directory name\n        assert \"/\" not in p.name\n\n\nclass TestCreateWorkspace:\n    @patch(\"polyphony.workspace._run_git\")\n    def test_clones_repo(self, mock_git, tmp_path):\n        mock_git.return_value = MagicMock(returncode=0)\n        ws = create_workspace(\n            base_dir=tmp_path,\n            task_id=\"T-1\",\n            attempt=1,\n            repo_url=\"https://github.com/o/r.git\",\n            ref=\"main\",\n        )\n        assert ws.exists()\n        assert mock_git.called\n\n    @patch(\"polyphony.workspace._run_git\")\n    def test_checks_out_branch(self, mock_git, tmp_path):\n        mock_git.return_value = MagicMock(returncode=0)\n        create_workspace(\n            base_dir=tmp_path,\n            task_id=\"T-2\",\n            attempt=1,\n            repo_url=\"https://github.com/o/r.git\",\n            ref=\"feature/auth\",\n        )\n        calls = [str(c) for c in mock_git.call_args_list]\n        assert any(\"checkout\" in c for c in calls)\n\n    @patch(\"polyphony.workspace._run_git\")\n    def test_uses_mirror_when_available(self, mock_git, tmp_path):\n        mock_git.return_value = MagicMock(returncode=0)\n        mirror = tmp_path / \"mirror\" / \"repo.git\"\n        mirror.mkdir(parents=True)\n        create_workspace(\n            base_dir=tmp_path,\n            task_id=\"T-3\",\n            attempt=1,\n            repo_url=\"https://github.com/o/r.git\",\n            ref=\"main\",\n            mirror_path=mirror,\n        )\n        calls = [str(c) for c in mock_git.call_args_list]\n        assert any(\"dissociate\" in c for c in calls)\n\n\nclass TestCleanupWorkspace:\n    def test_removes_directory(self, tmp_path):\n        ws = tmp_path / \"workspace\"\n        ws.mkdir()\n        (ws / \"file.txt\").write_text(\"x\")\n        cleanup_workspace(ws)\n        assert not ws.exists()\n\n    def test_missing_dir_no_error(self, tmp_path):\n        cleanup_workspace(tmp_path / \"nope\")\n\n\nclass TestListWorkspaces:\n    def test_lists_dirs(self, tmp_path):\n        (tmp_path / \"T-1\" / \"1\").mkdir(parents=True)\n        (tmp_path / \"T-2\" / \"1\").mkdir(parents=True)\n        ws = list_workspaces(tmp_path)\n        assert len(ws) >= 2\n\n    def test_empty_base(self, tmp_path):\n        assert list_workspaces(tmp_path) == []\n"
  },
  {
    "path": "tests/test_session_detect.py",
    "content": "\"\"\"Tests for multi-CLI session detection.\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nfrom pathlib import Path\nfrom unittest.mock import patch\n\nfrom maggy.services.session_detect import (\n    detect_all,\n    detect_claude,\n    detect_codex,\n    detect_kimi,\n)\n\n_MOD = \"maggy.services.session_detect._home\"\n\n\ndef _patch_home(tmp_path):\n    return patch(_MOD, return_value=tmp_path)\n\n\ndef test_detect_claude_from_history(tmp_path):\n    \"\"\"Finds Claude session by matching working dir.\"\"\"\n    hist = tmp_path / \".claude\" / \"history.jsonl\"\n    hist.parent.mkdir(parents=True)\n    entry = {\"project\": \"/tmp/proj\", \"sessionId\": \"c-123\"}\n    hist.write_text(json.dumps(entry) + \"\\n\")\n    with _patch_home(tmp_path):\n        result = detect_claude(\"/tmp/proj\")\n    assert result is not None\n    assert result.cli == \"claude\"\n    assert result.session_id == \"c-123\"\n\n\ndef test_detect_claude_no_match(tmp_path):\n    \"\"\"Returns None when no matching dir in history.\"\"\"\n    hist = tmp_path / \".claude\" / \"history.jsonl\"\n    hist.parent.mkdir(parents=True)\n    entry = {\"project\": \"/other\", \"sessionId\": \"x\"}\n    hist.write_text(json.dumps(entry) + \"\\n\")\n    with _patch_home(tmp_path):\n        assert detect_claude(\"/tmp/proj\") is None\n\n\ndef test_detect_claude_missing_file():\n    \"\"\"Returns None when history.jsonl doesn't exist.\"\"\"\n    with _patch_home(Path(\"/nonexistent_detect_xyz\")):\n        assert detect_claude(\"/tmp/proj\") is None\n\n\ndef test_detect_kimi_from_state(tmp_path):\n    \"\"\"Finds Kimi session from kimi.json work_dirs.\"\"\"\n    kimi_f = tmp_path / \".kimi\" / \"kimi.json\"\n    kimi_f.parent.mkdir(parents=True)\n    data = {\"work_dirs\": [\n        {\"path\": \"/tmp/proj\", \"last_session_id\": \"k-1\"},\n    ]}\n    kimi_f.write_text(json.dumps(data))\n    with _patch_home(tmp_path):\n        result = detect_kimi(\"/tmp/proj\")\n    assert result is not None\n    assert result.cli == \"kimi\"\n    assert result.session_id == \"k-1\"\n\n\ndef test_detect_kimi_null_session(tmp_path):\n    \"\"\"Returns None when last_session_id is null.\"\"\"\n    kimi_f = tmp_path / \".kimi\" / \"kimi.json\"\n    kimi_f.parent.mkdir(parents=True)\n    data = {\"work_dirs\": [\n        {\"path\": \"/tmp/proj\", \"last_session_id\": None},\n    ]}\n    kimi_f.write_text(json.dumps(data))\n    with _patch_home(tmp_path):\n        assert detect_kimi(\"/tmp/proj\") is None\n\n\ndef test_detect_kimi_no_file():\n    with _patch_home(Path(\"/nonexistent_detect_xyz\")):\n        assert detect_kimi(\"/tmp/proj\") is None\n\n\ndef test_detect_codex_from_session(tmp_path):\n    \"\"\"Finds Codex session from rollout session file.\"\"\"\n    sess = tmp_path / \".codex\" / \"sessions\" / \"2026\" / \"05\"\n    sess.mkdir(parents=True)\n    meta = {\n        \"type\": \"session_meta\",\n        \"payload\": {\"id\": \"cx-1\", \"cwd\": \"/tmp/proj\"},\n    }\n    (sess / \"rollout-test.jsonl\").write_text(\n        json.dumps(meta) + \"\\n\",\n    )\n    with _patch_home(tmp_path):\n        result = detect_codex(\"/tmp/proj\")\n    assert result is not None\n    assert result.cli == \"codex\"\n    assert result.session_id == \"cx-1\"\n\n\ndef test_detect_codex_no_dir():\n    with _patch_home(Path(\"/nonexistent_detect_xyz\")):\n        assert detect_codex(\"/tmp/proj\") is None\n\n\ndef test_detect_all_aggregates(tmp_path):\n    \"\"\"detect_all gathers results from all CLIs.\"\"\"\n    hist = tmp_path / \".claude\" / \"history.jsonl\"\n    hist.parent.mkdir(parents=True)\n    entry = {\"project\": \"/tmp/p\", \"sessionId\": \"s1\"}\n    hist.write_text(json.dumps(entry) + \"\\n\")\n    with _patch_home(tmp_path):\n        result = detect_all(\"/tmp/p\")\n    clis = [s.cli for s in result.sessions]\n    assert \"claude\" in clis\n\n\ndef test_detect_all_empty(tmp_path):\n    \"\"\"detect_all returns empty when nothing found.\"\"\"\n    with _patch_home(tmp_path):\n        result = detect_all(\"/tmp/p\")\n    assert result.sessions == []\n"
  },
  {
    "path": "tests/test_skill_lint.py",
    "content": "\"\"\"Unit tests for skill-lint.\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport sys\nimport tempfile\nfrom pathlib import Path\n\nimport pytest\n\n# Add scripts/ to path so we can import skill_lint\nsys.path.insert(0, str(Path(__file__).parent.parent / 'scripts'))\n\nfrom skill_lint import Finding, Severity\nfrom skill_lint.frontmatter import check as fm_check, parse_frontmatter\nfrom skill_lint.spec import check as sp_check\nfrom skill_lint.content import check as cq_check\nfrom skill_lint.references import check as ri_check\nfrom skill_lint.report import format_json, format_text\nfrom skill_lint.__main__ import main\n\n\n@pytest.fixture\ndef skills_dir(tmp_path: Path) -> Path:\n    \"\"\"Create a temporary skills directory.\"\"\"\n    skills = tmp_path / 'skills'\n    skills.mkdir()\n    return skills\n\n\ndef _make_skill(skills_dir: Path, name: str, content: str) -> tuple[Path, Path]:\n    \"\"\"Create a skill directory with SKILL.md content. Returns (skill_dir, skill_path).\"\"\"\n    skill_dir = skills_dir / name\n    skill_dir.mkdir()\n    skill_path = skill_dir / 'SKILL.md'\n    skill_path.write_text(content, encoding='utf-8')\n    return skill_dir, skill_path\n\n\n# --- parse_frontmatter ---\n\nclass TestParseFrontmatter:\n    def test_valid_frontmatter(self):\n        content = '---\\nname: test-skill\\ndescription: A test\\n---\\n# Content'\n        fields, end_line = parse_frontmatter(content)\n        assert fields['name'] == 'test-skill'\n        assert fields['description'] == 'A test'\n        assert end_line == 4\n\n    def test_no_frontmatter(self):\n        content = '# Just content\\nNo frontmatter here'\n        fields, end_line = parse_frontmatter(content)\n        assert fields == {}\n        assert end_line == 0\n\n    def test_unclosed_frontmatter(self):\n        content = '---\\nname: broken\\n'\n        fields, end_line = parse_frontmatter(content)\n        assert end_line == 0\n\n    def test_quoted_values(self):\n        content = '---\\nname: \"quoted-name\"\\ndescription: \\'single\\'\\n---\\n'\n        fields, _ = parse_frontmatter(content)\n        assert fields['name'] == 'quoted-name'\n        assert fields['description'] == 'single'\n\n\n# --- FM checks ---\n\nclass TestFrontmatter:\n    def test_no_frontmatter(self, skills_dir):\n        _, path = _make_skill(skills_dir, 'bad-skill', '# No frontmatter\\n')\n        findings = fm_check(path, skills_dir / 'bad-skill', skills_dir)\n        assert any(f.rule_id == 'FM001' for f in findings)\n\n    def test_missing_name(self, skills_dir):\n        _, path = _make_skill(skills_dir, 'test', '---\\ndescription: hello\\n---\\n')\n        findings = fm_check(path, skills_dir / 'test', skills_dir)\n        assert any(f.rule_id == 'FM002' for f in findings)\n\n    def test_missing_description(self, skills_dir):\n        _, path = _make_skill(skills_dir, 'test', '---\\nname: test\\n---\\n')\n        findings = fm_check(path, skills_dir / 'test', skills_dir)\n        assert any(f.rule_id == 'FM003' for f in findings)\n\n    def test_name_mismatch(self, skills_dir):\n        _, path = _make_skill(skills_dir, 'real-name', '---\\nname: wrong-name\\ndescription: x\\n---\\n')\n        findings = fm_check(path, skills_dir / 'real-name', skills_dir)\n        assert any(f.rule_id == 'FM004' for f in findings)\n\n    def test_invalid_name_format(self, skills_dir):\n        _, path = _make_skill(skills_dir, 'Test_Bad', '---\\nname: Test_Bad\\ndescription: x\\n---\\n')\n        findings = fm_check(path, skills_dir / 'Test_Bad', skills_dir)\n        assert any(f.rule_id == 'FM005' for f in findings)\n\n    def test_clean_skill(self, skills_dir):\n        content = (\n            '---\\n'\n            'name: good-skill\\n'\n            'description: A well-formed skill\\n'\n            'when-to-use: When testing\\n'\n            'user-invocable: true\\n'\n            'effort: low\\n'\n            '---\\n'\n            '# Good Skill\\n'\n        )\n        _, path = _make_skill(skills_dir, 'good-skill', content)\n        findings = fm_check(path, skills_dir / 'good-skill', skills_dir)\n        assert len(findings) == 0\n\n\n# --- SP checks ---\n\nclass TestSpec:\n    def test_missing_skill_md(self, skills_dir):\n        skill_dir = skills_dir / 'empty-skill'\n        skill_dir.mkdir()\n        findings = sp_check(skill_dir / 'SKILL.md', skill_dir, skills_dir)\n        assert any(f.rule_id == 'SP001' for f in findings)\n\n    def test_over_500_lines(self, skills_dir):\n        content = '---\\nname: big\\n---\\n' + '\\n'.join(f'line {i}' for i in range(550))\n        _, path = _make_skill(skills_dir, 'big', content)\n        findings = sp_check(path, skills_dir / 'big', skills_dir)\n        assert any(f.rule_id == 'SP002' for f in findings)\n\n    def test_between_300_500(self, skills_dir):\n        content = '---\\nname: medium\\n---\\n' + '\\n'.join(f'line {i}' for i in range(350))\n        _, path = _make_skill(skills_dir, 'medium', content)\n        findings = sp_check(path, skills_dir / 'medium', skills_dir)\n        assert any(f.rule_id == 'SP003' for f in findings)\n\n    def test_inline_suppression(self, skills_dir):\n        content = (\n            '---\\n'\n            '<!-- skill-lint: disable=SP002 -->\\n'\n            'name: big\\n'\n            '---\\n'\n            + '\\n'.join(f'line {i}' for i in range(550))\n        )\n        _, path = _make_skill(skills_dir, 'big', content)\n        findings = sp_check(path, skills_dir / 'big', skills_dir)\n        assert not any(f.rule_id == 'SP002' for f in findings)\n\n\n# --- CQ checks ---\n\nclass TestContent:\n    def test_ascii_art_detected(self, skills_dir):\n        content = '---\\nname: arty\\ndescription: x\\n---\\n# Arty\\n╔══════╗\\n║ box  ║\\n╚══════╝\\n'\n        _, path = _make_skill(skills_dir, 'arty', content)\n        findings = cq_check(path, skills_dir / 'arty', skills_dir)\n        assert any(f.rule_id == 'CQ001' for f in findings)\n\n    def test_ascii_art_in_code_block_ok(self, skills_dir):\n        content = '---\\nname: code-art\\ndescription: x\\n---\\n# Code\\n```\\n╔══════╗\\n║ ok   ║\\n╚══════╝\\n```\\n'\n        _, path = _make_skill(skills_dir, 'code-art', content)\n        findings = cq_check(path, skills_dir / 'code-art', skills_dir)\n        assert not any(f.rule_id == 'CQ001' for f in findings)\n\n    def test_vague_phrases(self, skills_dir):\n        content = '---\\nname: vague\\ndescription: x\\n---\\n# Vague\\nYou should follow best practices.\\n'\n        _, path = _make_skill(skills_dir, 'vague', content)\n        findings = cq_check(path, skills_dir / 'vague', skills_dir)\n        assert any(f.rule_id == 'CQ002' for f in findings)\n\n    def test_filler_intensity(self, skills_dir):\n        # 10 filler words in 20 lines = 50 per 100 lines (way over 2)\n        filler_lines = '\\n'.join(\n            'This is MANDATORY and NON-NEGOTIABLE' for _ in range(10)\n        )\n        content = f'---\\nname: filler\\ndescription: x\\n---\\n# Filler\\n{filler_lines}\\n'\n        _, path = _make_skill(skills_dir, 'filler', content)\n        findings = cq_check(path, skills_dir / 'filler', skills_dir)\n        assert any(f.rule_id == 'CQ003' for f in findings)\n\n    def test_stale_load_ref(self, skills_dir):\n        content = '---\\nname: stale\\ndescription: x\\n---\\n# Stale\\n*Load with: base.md*\\n'\n        _, path = _make_skill(skills_dir, 'stale', content)\n        findings = cq_check(path, skills_dir / 'stale', skills_dir)\n        assert any(f.rule_id == 'CQ005' for f in findings)\n\n    def test_no_h1_heading(self, skills_dir):\n        content = '---\\nname: headless\\ndescription: x\\n---\\nNo heading here.\\n'\n        _, path = _make_skill(skills_dir, 'headless', content)\n        findings = cq_check(path, skills_dir / 'headless', skills_dir)\n        assert any(f.rule_id == 'CQ006' for f in findings)\n\n\n# --- RI checks ---\n\nclass TestReferences:\n    def test_broken_skill_ref(self, skills_dir):\n        content = '---\\nname: linker\\ndescription: x\\n---\\n# Linker\\nSee skills/nonexistent-skill for details.\\n'\n        _, path = _make_skill(skills_dir, 'linker', content)\n        findings = ri_check(path, skills_dir / 'linker', skills_dir)\n        assert any(f.rule_id == 'RI001' for f in findings)\n\n    def test_valid_skill_ref(self, skills_dir):\n        _make_skill(skills_dir, 'target', '---\\nname: target\\n---\\n')\n        content = '---\\nname: linker\\ndescription: x\\n---\\n# Linker\\nSee skills/target for details.\\n'\n        _, path = _make_skill(skills_dir, 'linker', content)\n        findings = ri_check(path, skills_dir / 'linker', skills_dir)\n        assert not any(f.rule_id == 'RI001' for f in findings)\n\n\n# --- Report ---\n\nclass TestReport:\n    def test_text_format(self, skills_dir):\n        findings = [\n            Finding('FM001', Severity.ERROR, 'Missing frontmatter'),\n            Finding('SP002', Severity.WARNING, 'Too long'),\n        ]\n        results = {'test-skill': findings}\n        text = format_text(results)\n        assert 'ERROR' in text\n        assert 'WARNING' in text\n        assert 'test-skill' in text\n\n    def test_json_format(self, skills_dir):\n        findings = [\n            Finding('FM001', Severity.ERROR, 'Missing frontmatter'),\n        ]\n        results = {'test-skill': findings}\n        output = format_json(results)\n        data = json.loads(output)\n        assert data['summary']['errors'] == 1\n        assert 'test-skill' in data['skills']\n\n\n# --- CLI ---\n\nclass TestCLI:\n    def test_version(self, capsys):\n        with pytest.raises(SystemExit) as exc:\n            main(['--version'])\n        assert exc.value.code == 0\n\n    def test_missing_dir(self):\n        ret = main(['/nonexistent/path'])\n        assert ret == 2\n\n    def test_single_skill(self, skills_dir):\n        content = (\n            '---\\n'\n            'name: clean\\n'\n            'description: A clean skill\\n'\n            'when-to-use: Always\\n'\n            'user-invocable: true\\n'\n            'effort: low\\n'\n            '---\\n'\n            '# Clean Skill\\n'\n            '\\n```python\\nprint(\"hello\")\\n```\\n'\n        )\n        _make_skill(skills_dir, 'clean', content)\n        ret = main(['--skill', 'clean', str(skills_dir)])\n        assert ret == 0\n\n    def test_fail_on_warning(self, skills_dir):\n        content = '---\\nname: big\\ndescription: x\\n---\\n' + '\\n'.join(f'line {i}' for i in range(550))\n        _make_skill(skills_dir, 'big', content)\n        ret = main(['--fail-on', 'warning', '--skill', 'big', str(skills_dir)])\n        assert ret == 1\n"
  },
  {
    "path": "tests/validate-structure.sh",
    "content": "#!/bin/bash\n# validate-structure.sh - Validates Maggy structure matches Claude Code requirements\n# Run with: ./tests/validate-structure.sh\n# Exit codes: 0 = all pass, 1 = failures\n\nset -uo pipefail\n# Note: not using -e so we can collect all failures\n\nSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\nROOT_DIR=\"$(dirname \"$SCRIPT_DIR\")\"\nSKILLS_DIR=\"$ROOT_DIR/skills\"\nCOMMANDS_DIR=\"$ROOT_DIR/commands\"\nHOOKS_DIR=\"$ROOT_DIR/hooks\"\n\n# Colors\nRED='\\033[0;31m'\nGREEN='\\033[0;32m'\nYELLOW='\\033[1;33m'\nNC='\\033[0m' # No Color\n\nPASS_COUNT=0\nFAIL_COUNT=0\nWARN_COUNT=0\n\npass() {\n    echo -e \"${GREEN}✓${NC} $1\"\n    ((PASS_COUNT++))\n}\n\nfail() {\n    echo -e \"${RED}✗${NC} $1\"\n    ((FAIL_COUNT++))\n}\n\nwarn() {\n    echo -e \"${YELLOW}⚠${NC} $1\"\n    ((WARN_COUNT++))\n}\n\nheader() {\n    echo \"\"\n    echo \"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\"\n    echo \" $1\"\n    echo \"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\"\n}\n\n# ============================================================================\n# TEST 1: Skills Structure\n# Each skill must be a FOLDER containing SKILL.md (not a flat .md file)\n# ============================================================================\ntest_skills_structure() {\n    header \"TEST: Skills Folder Structure\"\n\n    if [ ! -d \"$SKILLS_DIR\" ]; then\n        fail \"Skills directory does not exist: $SKILLS_DIR\"\n        return\n    fi\n\n    local skill_count=0\n    local valid_count=0\n    local flat_files=0\n\n    # Check for flat .md files (WRONG structure)\n    shopt -s nullglob\n    for file in \"$SKILLS_DIR\"/*.md; do\n        if [ -f \"$file\" ]; then\n            flat_files=$((flat_files + 1))\n            fail \"Flat .md file found (should be folder): $(basename \"$file\")\"\n        fi\n    done\n    shopt -u nullglob\n\n    if [ \"$flat_files\" -eq 0 ]; then\n        pass \"No flat .md files in skills/ (correct)\"\n    fi\n\n    # Check for folders with SKILL.md (CORRECT structure)\n    for skill_dir in \"$SKILLS_DIR\"/*/; do\n        if [ -d \"$skill_dir\" ]; then\n            skill_count=$((skill_count + 1))\n            local skill_name=$(basename \"$skill_dir\")\n\n            if [ -f \"$skill_dir/SKILL.md\" ]; then\n                valid_count=$((valid_count + 1))\n                pass \"Skill '$skill_name' has SKILL.md\"\n            else\n                fail \"Skill '$skill_name' missing SKILL.md\"\n            fi\n        fi\n    done\n\n    echo \"\"\n    echo \"Skills found: $skill_count folders, $flat_files flat files\"\n\n    if [ \"$flat_files\" -gt 0 ] && [ \"$skill_count\" -eq 0 ]; then\n        fail \"Skills use flat .md structure - must be folders with SKILL.md\"\n    fi\n}\n\n# ============================================================================\n# TEST 2: SKILL.md YAML Frontmatter\n# Each SKILL.md must have YAML frontmatter with 'name' and 'description'\n# ============================================================================\ntest_skill_frontmatter() {\n    header \"TEST: SKILL.md YAML Frontmatter\"\n\n    for skill_dir in \"$SKILLS_DIR\"/*/; do\n        if [ -d \"$skill_dir\" ] && [ -f \"$skill_dir/SKILL.md\" ]; then\n            local skill_name=$(basename \"$skill_dir\")\n            local skill_file=\"$skill_dir/SKILL.md\"\n\n            # Check for YAML frontmatter (starts with ---)\n            if head -1 \"$skill_file\" | grep -q \"^---$\"; then\n                # Extract frontmatter\n                local frontmatter=$(sed -n '/^---$/,/^---$/p' \"$skill_file\" | head -20)\n\n                # Check for 'name:' field\n                if echo \"$frontmatter\" | grep -q \"^name:\"; then\n                    pass \"Skill '$skill_name' has 'name' field\"\n                else\n                    fail \"Skill '$skill_name' missing 'name' in frontmatter\"\n                fi\n\n                # Check for 'description:' field\n                if echo \"$frontmatter\" | grep -q \"^description:\"; then\n                    pass \"Skill '$skill_name' has 'description' field\"\n                else\n                    fail \"Skill '$skill_name' missing 'description' in frontmatter\"\n                fi\n            else\n                fail \"Skill '$skill_name' missing YAML frontmatter (must start with ---)\"\n            fi\n        fi\n    done\n\n    # Also check flat files that shouldn't exist\n    shopt -s nullglob\n    for file in \"$SKILLS_DIR\"/*.md; do\n        if [ -f \"$file\" ]; then\n            warn \"Flat file '$(basename \"$file\")' cannot be validated (wrong structure)\"\n        fi\n    done\n    shopt -u nullglob\n}\n\n# ============================================================================\n# TEST 3: Commands Structure\n# Commands should be .md files in commands/\n# ============================================================================\ntest_commands_structure() {\n    header \"TEST: Commands Structure\"\n\n    if [ ! -d \"$COMMANDS_DIR\" ]; then\n        fail \"Commands directory does not exist: $COMMANDS_DIR\"\n        return\n    fi\n\n    local cmd_count=0\n    for cmd_file in \"$COMMANDS_DIR\"/*.md; do\n        if [ -f \"$cmd_file\" ]; then\n            cmd_count=$((cmd_count + 1))\n            local cmd_name=$(basename \"$cmd_file\" .md)\n            pass \"Command found: $cmd_name\"\n        fi\n    done\n\n    if [ \"$cmd_count\" -eq 0 ]; then\n        fail \"No commands found in $COMMANDS_DIR\"\n    else\n        echo \"\"\n        echo \"Total commands: $cmd_count\"\n    fi\n}\n\n# ============================================================================\n# TEST 4: Hooks Structure (checks ALL hooks dynamically)\n# ============================================================================\ntest_hooks_structure() {\n    header \"TEST: Hooks Structure\"\n\n    if [ ! -d \"$HOOKS_DIR\" ]; then\n        warn \"Hooks directory does not exist: $HOOKS_DIR\"\n        return\n    fi\n\n    local hook_count=0\n    shopt -s nullglob\n    for hook_file in \"$HOOKS_DIR\"/*; do\n        if [ -f \"$hook_file\" ]; then\n            hook_count=$((hook_count + 1))\n            local hook_name=$(basename \"$hook_file\")\n\n            pass \"Hook found: $hook_name\"\n\n            if [ -x \"$hook_file\" ]; then\n                pass \"Hook '$hook_name' is executable\"\n            else\n                fail \"Hook '$hook_name' is NOT executable\"\n            fi\n\n            # Check hook has shebang\n            if head -1 \"$hook_file\" | grep -q \"^#!\"; then\n                pass \"Hook '$hook_name' has shebang\"\n            else\n                warn \"Hook '$hook_name' missing shebang\"\n            fi\n        fi\n    done\n    shopt -u nullglob\n\n    if [ \"$hook_count\" -eq 0 ]; then\n        warn \"No hooks found in $HOOKS_DIR\"\n    else\n        echo \"\"\n        echo \"Total hooks: $hook_count\"\n    fi\n\n    # Also check installed hooks\n    local installed_hooks_dir=\"$HOME/.claude/hooks\"\n    if [ -d \"$installed_hooks_dir\" ]; then\n        echo \"\"\n        echo \"Checking installed hooks (~/.claude/hooks/):\"\n        local installed_count=0\n        for hook_file in \"$installed_hooks_dir\"/*; do\n            if [ -f \"$hook_file\" ]; then\n                installed_count=$((installed_count + 1))\n                local hook_name=$(basename \"$hook_file\")\n                if [ -x \"$hook_file\" ]; then\n                    pass \"Installed hook '$hook_name' is executable\"\n                else\n                    fail \"Installed hook '$hook_name' is NOT executable\"\n                fi\n            fi\n        done\n        echo \"Installed hooks: $installed_count\"\n    fi\n}\n\n# ============================================================================\n# TEST 5: Install Script\n# ============================================================================\ntest_install_script() {\n    header \"TEST: Install Script\"\n\n    if [ -f \"$ROOT_DIR/install.sh\" ]; then\n        pass \"install.sh exists\"\n\n        if [ -x \"$ROOT_DIR/install.sh\" ]; then\n            pass \"install.sh is executable\"\n        else\n            fail \"install.sh is not executable\"\n        fi\n\n        # Check that it references correct structure\n        if grep -q \"SKILL.md\" \"$ROOT_DIR/install.sh\"; then\n            pass \"install.sh references SKILL.md structure\"\n        else\n            warn \"install.sh may not handle SKILL.md structure\"\n        fi\n    else\n        fail \"install.sh missing\"\n    fi\n}\n\n# ============================================================================\n# TEST 6: Installed Skills (checks ~/.claude/skills/)\n# ============================================================================\ntest_installed_skills() {\n    header \"TEST: Installed Skills (~/.claude/skills/)\"\n\n    local installed_dir=\"$HOME/.claude/skills\"\n\n    if [ ! -d \"$installed_dir\" ]; then\n        warn \"No skills installed at $installed_dir\"\n        return\n    fi\n\n    local folder_count=0\n    local flat_count=0\n\n    # Count folders with SKILL.md\n    for skill_dir in \"$installed_dir\"/*/; do\n        if [ -d \"$skill_dir\" ] && [ -f \"$skill_dir/SKILL.md\" ]; then\n            folder_count=$((folder_count + 1))\n        fi\n    done\n\n    # Count flat .md files\n    shopt -s nullglob\n    for file in \"$installed_dir\"/*.md; do\n        if [ -f \"$file\" ]; then\n            flat_count=$((flat_count + 1))\n        fi\n    done\n    shopt -u nullglob\n\n    if [ \"$folder_count\" -gt 0 ]; then\n        pass \"Found $folder_count properly structured skills\"\n    fi\n\n    if [ \"$flat_count\" -gt 0 ]; then\n        fail \"Found $flat_count flat .md files (wrong structure)\"\n    fi\n\n    echo \"\"\n    echo \"Installed: $folder_count folder skills, $flat_count flat files\"\n}\n\n# ============================================================================\n# TEST 7: README Documentation\n# ============================================================================\ntest_readme() {\n    header \"TEST: README Documentation\"\n\n    if [ -f \"$ROOT_DIR/README.md\" ]; then\n        pass \"README.md exists\"\n\n        # Check for key sections\n        if grep -q \"Quick Start\\|Quick Install\" \"$ROOT_DIR/README.md\"; then\n            pass \"README has Quick Start section\"\n        else\n            warn \"README missing Quick Start section\"\n        fi\n\n        if grep -q \"Skills Included\\|What's Included\" \"$ROOT_DIR/README.md\"; then\n            pass \"README has Skills listing\"\n        else\n            warn \"README missing Skills listing\"\n        fi\n    else\n        fail \"README.md missing\"\n    fi\n}\n\n# ============================================================================\n# TEST 8: Scripts Structure\n# ============================================================================\ntest_scripts_structure() {\n    header \"TEST: Scripts Structure\"\n\n    local scripts_dir=\"$ROOT_DIR/scripts\"\n\n    if [ ! -d \"$scripts_dir\" ]; then\n        warn \"Scripts directory does not exist: $scripts_dir\"\n        return\n    fi\n\n    local script_count=0\n    shopt -s nullglob\n    for script_file in \"$scripts_dir\"/*.sh; do\n        if [ -f \"$script_file\" ]; then\n            script_count=$((script_count + 1))\n            local script_name=$(basename \"$script_file\")\n\n            pass \"Script found: $script_name\"\n\n            if [ -x \"$script_file\" ]; then\n                pass \"Script '$script_name' is executable\"\n            else\n                fail \"Script '$script_name' is NOT executable\"\n            fi\n        fi\n    done\n    shopt -u nullglob\n\n    if [ \"$script_count\" -eq 0 ]; then\n        warn \"No scripts found in $scripts_dir\"\n    else\n        echo \"\"\n        echo \"Total scripts: $script_count\"\n    fi\n}\n\n# ============================================================================\n# QUICK MODE - Essential checks only (for initialize-project)\n# ============================================================================\nquick_validate() {\n    echo \"\"\n    echo \"🔍 Quick validation of Maggy installation...\"\n    echo \"\"\n\n    local errors=0\n\n    # Check skills directory exists and has content\n    if [ -d \"$HOME/.claude/skills\" ]; then\n        local skill_count=$(find \"$HOME/.claude/skills\" -maxdepth 1 -type d 2>/dev/null | wc -l)\n        local flat_count=$(find \"$HOME/.claude/skills\" -maxdepth 1 -name \"*.md\" -type f 2>/dev/null | wc -l)\n\n        if [ \"$flat_count\" -gt 0 ]; then\n            echo -e \"${RED}✗${NC} Skills use flat .md structure (need folder/SKILL.md)\"\n            errors=$((errors + 1))\n        elif [ \"$skill_count\" -gt 1 ]; then\n            echo -e \"${GREEN}✓${NC} Skills installed ($((skill_count - 1)) skills)\"\n        else\n            echo -e \"${YELLOW}⚠${NC} No skills found in ~/.claude/skills/\"\n        fi\n    else\n        echo -e \"${RED}✗${NC} Skills directory missing (~/.claude/skills/)\"\n        errors=$((errors + 1))\n    fi\n\n    # Check commands\n    if [ -d \"$HOME/.claude/commands\" ]; then\n        local cmd_count=$(find \"$HOME/.claude/commands\" -name \"*.md\" -type f 2>/dev/null | wc -l)\n        if [ \"$cmd_count\" -gt 0 ]; then\n            echo -e \"${GREEN}✓${NC} Commands installed ($cmd_count commands)\"\n        else\n            echo -e \"${YELLOW}⚠${NC} No commands found\"\n        fi\n    else\n        echo -e \"${RED}✗${NC} Commands directory missing (~/.claude/commands/)\"\n        errors=$((errors + 1))\n    fi\n\n    # Check hooks\n    if [ -d \"$HOME/.claude/hooks\" ]; then\n        local hook_count=$(find \"$HOME/.claude/hooks\" -type f 2>/dev/null | wc -l)\n        if [ \"$hook_count\" -gt 0 ]; then\n            echo -e \"${GREEN}✓${NC} Hooks installed ($hook_count hooks)\"\n        else\n            echo -e \"${YELLOW}⚠${NC} No hooks found\"\n        fi\n    else\n        echo -e \"${YELLOW}⚠${NC} Hooks directory missing (~/.claude/hooks/)\"\n    fi\n\n    echo \"\"\n    if [ \"$errors\" -gt 0 ]; then\n        echo -e \"${RED}Bootstrap has issues. Run full validation:${NC}\"\n        echo \"  $ROOT_DIR/tests/validate-structure.sh\"\n        return 1\n    else\n        echo -e \"${GREEN}Bootstrap installation OK${NC}\"\n        return 0\n    fi\n}\n\n# ============================================================================\n# MAIN\n# ============================================================================\ntest_cross_tool_templates() {\n    header \"CROSS-TOOL TEMPLATES\"\n\n    # AGENTS.md template\n    if [ -f \"$ROOT_DIR/templates/AGENTS.md\" ]; then\n        pass \"templates/AGENTS.md exists\"\n        if grep -q \"## Skills\" \"$ROOT_DIR/templates/AGENTS.md\"; then\n            pass \"AGENTS.md has Skills section\"\n        else\n            fail \"AGENTS.md missing Skills section\"\n        fi\n    else\n        fail \"templates/AGENTS.md missing\"\n    fi\n\n    # config.toml template\n    if [ -f \"$ROOT_DIR/templates/config.toml\" ]; then\n        pass \"templates/config.toml exists\"\n        if grep -q '\\[\\[hooks\\]\\]' \"$ROOT_DIR/templates/config.toml\"; then\n            pass \"config.toml has [[hooks]] sections\"\n        else\n            fail \"config.toml missing [[hooks]] sections\"\n        fi\n    else\n        fail \"templates/config.toml missing\"\n    fi\n\n    # Cross-tool scripts\n    for script in detect-agents.sh install-skills.sh; do\n        if [ -f \"$ROOT_DIR/scripts/$script\" ]; then\n            pass \"scripts/$script exists\"\n            if [ -x \"$ROOT_DIR/scripts/$script\" ]; then\n                pass \"scripts/$script is executable\"\n            else\n                fail \"scripts/$script is not executable\"\n            fi\n        else\n            fail \"scripts/$script missing\"\n        fi\n    done\n\n    # sync-agents command\n    if [ -f \"$ROOT_DIR/commands/sync-agents.md\" ]; then\n        pass \"commands/sync-agents.md exists\"\n    else\n        fail \"commands/sync-agents.md missing\"\n    fi\n}\n\n# ============================================================================\nshow_help() {\n    echo \"Usage: $(basename \"$0\") [OPTIONS]\"\n    echo \"\"\n    echo \"Validates Maggy structure matches Claude Code requirements.\"\n    echo \"\"\n    echo \"Options:\"\n    echo \"  --quick     Quick validation (for initialize-project)\"\n    echo \"  --full      Full validation (default)\"\n    echo \"  --help      Show this help\"\n    echo \"\"\n    echo \"Exit codes:\"\n    echo \"  0 = All validations passed\"\n    echo \"  1 = Validation failures found\"\n}\n\nmain() {\n    local mode=\"full\"\n\n    while [[ $# -gt 0 ]]; do\n        case $1 in\n            --quick|-q)\n                mode=\"quick\"\n                shift\n                ;;\n            --full|-f)\n                mode=\"full\"\n                shift\n                ;;\n            --help|-h)\n                show_help\n                exit 0\n                ;;\n            *)\n                echo \"Unknown option: $1\"\n                show_help\n                exit 1\n                ;;\n        esac\n    done\n\n    if [ \"$mode\" = \"quick\" ]; then\n        quick_validate\n        exit $?\n    fi\n\n    # Full validation\n    echo \"\"\n    echo \"╔════════════════════════════════════════════════════════════╗\"\n    echo \"║     MAGGY STRUCTURE VALIDATION                              ║\"\n    echo \"╚════════════════════════════════════════════════════════════╝\"\n    echo \"\"\n    echo \"Validating: $ROOT_DIR\"\n\n    test_skills_structure\n    test_skill_frontmatter\n    test_commands_structure\n    test_hooks_structure\n    test_scripts_structure\n    test_install_script\n    test_installed_skills\n    test_readme\n    test_cross_tool_templates\n\n    header \"SUMMARY\"\n    echo \"\"\n    echo -e \"${GREEN}Passed:${NC}  $PASS_COUNT\"\n    echo -e \"${RED}Failed:${NC}  $FAIL_COUNT\"\n    echo -e \"${YELLOW}Warnings:${NC} $WARN_COUNT\"\n    echo \"\"\n\n    if [ \"$FAIL_COUNT\" -gt 0 ]; then\n        echo -e \"${RED}VALIDATION FAILED${NC} - $FAIL_COUNT issues need fixing\"\n        exit 1\n    else\n        echo -e \"${GREEN}VALIDATION PASSED${NC}\"\n        exit 0\n    fi\n}\n\nmain \"$@\"\n"
  }
]