# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## What Is Loom

Multi-agent project orchestration system. PostgreSQL is the source of truth, Redis is a cache and event bus, FastMCP server provides Claude Code integration. Distributed via `pipx install loom-agents`.

## Commands

```bash
uv sync                          # Install dependencies
uv run pytest tests/ -v          # Run all tests (needs Docker for testcontainers)
uv run pytest tests/integration/test_e2e.py::test_full_agent_workflow -v  # Single test
uv run python -m loom.mcp        # Run MCP server directly (stdio transport)
uv run python -m loom init       # Scaffold a project
uv run python -m loom up         # Start Postgres + Redis, run migrations
uv run python -m loom down       # Stop containers
uv run python -m loom status     # Project overview
```

**Note:** Always use `uv run python -m loom` instead of `uv run loom` — the console script is unreliable due to editable install `.pth` file issues with Python 3.13.

Tests require Docker Desktop running — testcontainers spins up real Postgres 16 + Redis 7 containers automatically.

## Architecture

### Write Path (every task mutation)

```
MCP tool (mcp/tools.py)
  → graph/store.py writes to Postgres (ACID)
  → graph/cache.py syncs to Redis hash + status sets
  → bus/publisher.py publishes to Redis Stream + pub/sub
  → graph/deps.py checks if blocked dependents are now unblocked
```

Postgres is always written first. If it fails, nothing else happens. If Redis sync fails, the cache is stale but self-corrects on next read or MCP restart.

### Read Path

```
MCP tool calls graph/cache.py
  → reads Redis sorted set / hash (fast path)
  → on cache miss: reads Postgres via graph/store.py, syncs to Redis
```

### Module Contracts (enforced, not optional)

| Module | Rule |
|--------|------|
| `graph/store.py` | **ONLY** writer to Postgres for task data |
| `graph/cache.py` | **ONLY** reader from Redis for task data; falls back to store.py |
| `bus/channels.py` | **ALL** Redis key patterns; no string literals like `"loom:tasks:ready"` elsewhere |
| `mcp/tools.py` | Each tool **≤15 lines**; thin coordinators only, business logic in graph/ or bus/ |
| `db/migrations/` | **Never** modify existing migration files; always add new numbered files |

### MCP Tool Pattern

Every tool follows this exact structure:
```python
@mcp.tool()
async def loom_xxx(ctx: Context, ...args) -> dict:
    app = _ctx(ctx)                              # Get AppContext from lifespan
    result = await store.some_op(app.pool, ...)  # Postgres write
    await cache.sync_task(app.redis, result)     # Redis sync
    await publish_event(app.redis, ...)          # Event publish
    return result.model_dump(mode="json")        # Serialized response
```

### MCP Server Lifespan

`mcp/server.py` manages async resources: creates asyncpg pool → runs migrations → connects Redis → rebuilds cache from Postgres → yields `AppContext(pool, redis, project_id)` → cleanup. Tools access it via `ctx.request_context.lifespan_context`.

### Task Claiming

Uses `SELECT FOR UPDATE SKIP LOCKED` in Postgres — purpose-built for concurrent job queues. No application-level retries needed.

### Dependency Resolution

`graph/deps.py` runs after every task completion:
- Finds dependents of the completed task
- If all their deps are now done, transitions blocked → pending and adds to ready queue
- Epic auto-completion: if all children of an epic are done, the epic is marked done

## Key Design Decisions

- **Pydantic BaseModel** (not dataclass) for Task and all models — better serialization for MCP
- `Task.from_record(dict, depends_on)` converts asyncpg Records; `depends_on` is populated from `task_deps` table separately
- Redis hashes store strings — dict/list fields are JSON-serialized in `cache.py`
- Config loads 3 layers: `~/.loom/config.yaml` → `.loom/config.yaml` → `LOOM_*` env vars
- Task IDs: `loom-{secrets.token_hex(4)}` (8 hex chars)
- Priority scores for Redis sorted set: p0=300, p1=200, p2=100

## Testing

Real integration tests with testcontainers (no mocks). Fixtures in `tests/conftest.py`:
- `pool` — function-scoped asyncpg pool, data cleaned between tests
- `redis_conn` — function-scoped Redis, flushed between tests
- `project` — creates a test project, returns project_id string

## Current Phase

Phases 1-7 complete, plus post-phase improvements and I15 (Orchestrator Resilience). 760 tests passing (0 failures, 1 skipped).

Key modules added in Phase 7 (Orchestration Reliability):
- `loom/graph/store.py` — `reset_task()`, `get_ready_count()` (now 30 async functions)
- `loom/skills/merge.py` — heartbeat during tests, branch validation, phase checkpointing (`MergePhase` enum)
- `loom/orchestration/escalation.py` — per-task dedup guard (Redis SETNX with TTL)
- `loom/orchestration/loop.py` — defensive config access, escalation rate limiting, staleness detection
- `loom/graph/cache.py` — `set_task_progress()`, `get_task_progress()` for agent progress tracking
- `loom/config.py` — `escalation_dedup_ttl_seconds`, `max_escalations_per_tick`, `enrichment_concurrency`
- `loom/cli.py` — `loom reset` command, `--optimize-parallel` flag, `--enrich-concurrency` flag

Post-Phase 7: Parallel enrichment pipeline — `_enrich_tasks()` in `loom/skills/decomposer.py` now uses `asyncio.Semaphore` + `asyncio.gather()` to run up to `enrichment_concurrency` (default 5) tasks concurrently. Within each task, `write_task_context` runs first, then `define_done` + `estimate_complexity` run in parallel. Reduces enrichment from ~20 minutes to ~2-3 minutes for a 30-task graph.

I15 (Orchestrator Resilience): File-based completion markers, CLI self-report commands (done/fail/heartbeat with full parity to MCP tools), recovery protocol for orphaned tasks, and `loom_recover` MCP tool. Key new files: `loom/markers.py`, `loom/recovery.py`. CLI additions: `loom heartbeat`, `loom scan`, `loom recover`. Helper: `generate_agent_prompt()` in `cli.py`.
