agentleFS
maxwellsdm1867maxwellsdm1867/wheeler/CLAUDE.md

wheeler

CLAUDE.md12 starsChanged 3 months agoMITView on GitHub

Wheeler is a Python package that turns Claude Code into a provenance-tracked research assistant. It is not an agent framework: there is no orchestration layer. Claude Code is the orchestrator; Wheeler provides (a) MCP tools that mutate a Neo4j knowledge graph, and (b) /wh:* slash commands that act as mode-restricted system prompts. Everything runs locally on a Max subscription via claude -p subprocess. No API keys are used, ever. Version is 0.16.1. 3648 passing tests plus 3 optional Torch checks…

  • Reads credentials
  • Installs packages

The file

We split this CLAUDE.md into the rules it gives an agent

8 rules. About 0 are common, 1 is distinctive, and 7 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈1

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 7

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Every mutation (add_finding, link_nodes, set_tier, etc.) writes three places through wheeler/tools/graph_tools/__init__.py::execute_tool():

    line 94
  2. Very specific:

    New mutation tools must route through execute_tool(): Do not write directly to the backend or to files.

    line 102
  3. Very specific:

    If layers drift (graph deleted but file survives, or vice versa), graph_consistency_check detects it and repair=True reconciles using …

    line 104
  4. Very specific:

    All share mcp_shared.py for trace ID generation, the @_logged decorator, config loading, and backend access.

    line 143
  5. Very specific:

    Every node carries content_version (1 at creation, +1 on each update_node or set_tier) and content_hash.

    line 155
  6. Very specific:

    Neo4j sessions don't allow concurrent queries: Never asyncio.gather inside a session.

    line 257
  7. Distinctive:

    Never use em dashes: Use colons, commas, periods, or parentheses.

    line 265
  8. Very specific:

    If .plans/STATE.md exists, read it.

    line 271

≈ “Common” is an estimate: at least 1 in 5 of the 26,980 CLAUDE.md files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
CLAUDE.md

CLAUDE.md

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

What this codebase is

Wheeler is a Python package that turns Claude Code into a provenance-tracked research assistant. It is not an agent framework: there is no orchestration layer. Claude Code is the orchestrator; Wheeler provides (a) MCP tools that mutate a Neo4j knowledge graph, and (b) /wh:* slash commands that act as mode-restricted system prompts. Everything runs locally on a Max subscription via claude -p subprocess. No API keys are used, ever.

Version is 0.16.1. 3648 passing tests plus 3 optional Torch checks (see the suite for the current count), 57 MCP tools across the four split servers. The legacy monolith has been removed.

Wheeler now runs on two agent hosts, Claude Code and OpenAI Codex, and ships as a plugin named wh rather than by copying files into ~/.claude/. The act bodies are served over MCP (get_act) so they exist in exactly one place; each host gets generated SKILL.md stubs. See "Multi-host distribution" below.

Commands

# Environment (Python 3.11+).  uv is the primary workflow; the pip path
# remains supported for contributors without uv installed.
uv sync --extra dev            # creates .venv/, installs from uv.lock
# or:
source .venv/bin/activate
pip install -e ".[test]"       # core + test deps (fastembed + numpy are in core)
pip install -e ".[dev]"        # full toolchain: tests + ruff + mypy + build
pip install -e ".[search]"     # legacy no-op (fastembed+numpy already in core)

# Tests
python -m pytest tests/ -q                                   # full suite, quiet
python -m pytest tests/test_merge.py -v                      # one file
python -m pytest tests/test_merge.py::TestExecuteMerge -v    # one class
python -m pytest tests/ -k "consistency"                     # by keyword
python -m pytest tests/e2e/ -v                               # e2e, needs running Neo4j
python -m pytest tests/e2e/test_graph_first_acts.py -v       # graph-first act workflows
python -m pytest tests/e2e/test_plan_lifecycle.py -v         # plan lifecycle + triple-write

# E2E test philosophy: simulate the actual act workflow against live Neo4j.
# Each test walks through the exact tool-call sequence an act prescribes:
#   1. WRITE to graph (add_plan, ensure_artifact, add_finding, link_nodes)
#   2. READ back via the same query the act uses (query_plans, query_notes)
#   3. Verify graph state with direct Cypher (status, relationships, properties)
#   4. Verify triple-write artifacts on disk (knowledge/*.json, synthesis/*.md)
# This catches drift between what the act prompt says and what the graph
# actually stores. If an act says "call query_plans(status=approved)" but
# the query returns nothing because the status enum changed, the e2e test
# fails. Unit tests with FakeBackend can't catch that.
#
# TRAP when adding a live-Neo4j test late in the suite: graph_tools._get_backend()
# caches ONE module-level backend for the whole process, and each backend owns its
# own circuit breaker. Earlier tests push MagicMock values through that shared
# instance, which trips the breaker OPEN for 60s, so a live test placed after them
# fails with `circuit_open` while passing in isolation. Clear `_backend_instance`
# and call `invalidate_async_driver()` in your fixture's setup AND teardown.
# See tests/test_triple_write_project_root.py for the pattern.
#
# Also: the suite is sensitive to concurrent writers on one Neo4j. A red full-suite
# run in the asta/llmsr integration tests is worth re-running before treating it as
# a regression; those tests write and read back, so a second writer looks like a bug.

# Lint + type check (run by pre-commit hook)
.venv/bin/ruff check wheeler/
.venv/bin/mypy wheeler/ --ignore-missing-imports

# Git hooks (strongly recommended — block commits with API key leaks, broken tests, lint errors)
wh hooks install    # copies .githooks/pre-commit + pre-push into .git/hooks/
wh hooks test       # runs pre-commit checks without committing

# Headless Claude runs (for background tasks)
wh queue "prompt"   # sonnet, 10 turns, structured JSON log to .logs/
wh quick "prompt"   # haiku, 3 turns, one-shot
wh dream            # graph consolidation (promotes tiers, detects communities, flags stale)

# MCP servers (launched by Claude Code via .mcp.json — not typically invoked by hand)
python -m wheeler.mcp_core         # split: health/context/search/cypher/schema (14)
python -m wheeler.mcp_query        # split: read-only query_* (11)
python -m wheeler.mcp_mutations    # split: add_*, link, unlink, delete, merge, update, register_batch, lessons (22)
python -m wheeler.mcp_ops          # split: staleness, citations, consistency, ops (10)

The wheeler and wheeler-tools console scripts both point at the Typer CLI (wheeler.tools.cli:app): wheeler show F-3a2b, wheeler graph status, wheeler validate ..., wheeler install, etc.

Architecture (what you need to hold in your head)

The four-layer model

ACTS         .claude/commands/wh/*.md      slash commands = system prompts
FILE SYSTEM  .notes/, .plans/, docs/       prose artifacts live as real files
SYNTHESIS    synthesis/*.md                Obsidian-compatible, auto-generated
GRAPH        knowledge/*.json + Neo4j      metadata + relationships only

The graph is an index over files, not a document store. A node stores id, type, tier, title (~100 chars), path, timestamps, and filterable metadata. Full content lives in knowledge/{id}.json. Human-browsable rendering lives in synthesis/{id}.md with YAML frontmatter and Obsidian [[backlinks]]. When a query needs content, it reads the JSON file; when it needs connections, it queries Neo4j.

Triple-write (load-bearing invariant)

Every mutation (add_finding, link_nodes, set_tier, etc.) writes three places through wheeler/tools/graph_tools/__init__.py::execute_tool():

  1. Neo4j graph node (via GraphBackend ABC)
  2. knowledge/{id}.json (atomic tmp-rename)
  3. synthesis/{id}.md (atomic tmp-rename, Obsidian-compatible; skipped when synthesis_enabled: false, since nothing in Wheeler reads this layer back)

Plus an embedding in .wheeler/embeddings/ if search is enabled, plus a WriteReceipt in .wheeler/repair_queue.jsonl if any layer fails, plus a trace_id in .wheeler/request_log.jsonl for correlation.

New mutation tools must route through execute_tool(). Do not write directly to the backend or to files. The triple-write + receipt + trace_id + embedding wiring all lives in that dispatch path.

If layers drift (graph deleted but file survives, or vice versa), graph_consistency_check detects it and repair=True reconciles using whichever layer survived as the source of truth.

The strict layering

models.py, config.py           <- zero internal deps (leaf nodes)
  |
knowledge/store.py, render.py  <- models only
  |
graph/*                        <- models + config
  |
provenance.py, merge.py,
consistency.py, contracts.py,
communities.py, write_receipt  <- config ± models ± graph (layer 2-3)
  |
tools/graph_tools/*            <- graph + knowledge (lazy imports to knowledge/)
  |
mcp_core/query/mutations/ops,  <- everything
tools/cli.py

Rules:

  • Never add imports to models.py or config.py. They are the foundation.
  • Lazy imports in tools/graph_tools/__init__.py are intentional to break cycles with knowledge/ and provenance. Keep them lazy.
  • The split servers lazily import consistency, communities, contracts, merge, search.retrieval, depscanner to keep server startup fast. Do not promote these to top-level.
  • knowledge/migrate.py is the one documented cross-layer exception: it imports from graph/ at top level because it bridges the two by design.
  • validation/ledger.py has a lazy upward import to tools.graph_tools.execute_tool to persist ledger entries via the same triple-write path. This is the one real layering violation; keep it lazy.

See ARCHITECTURE.md for the full per-module dependency map.

The MCP tool surface (split servers are canonical)

The underlying implementation in wheeler/tools/graph_tools/ is exposed through four split FastMCP servers, one per role:

  • mcp_core.py — health, status, context, search, schema, raw cypher
  • mcp_query.py — read-only typed listings (query_*)
  • mcp_mutations.py — writes (add_*, link_nodes, update_node, set_tier, etc.)
  • mcp_ops.py — validators, scanners, consistency (validate_citations, etc.)

All share mcp_shared.py for trace ID generation, the @_logged decorator, config loading, and backend access. The authoritative implementation of every tool lives in tools/graph_tools/, not in any mcp_*.py file. The server files are thin FastMCP wrappers that call execute_tool().

When you add a new MCP tool, register it in the split server that matches its role. Do not add it to any other server. The role map is: read-only listings → mcp_query; writes that change graph nodes → mcp_mutations; validators / scanners / consistency operations → mcp_ops; everything else (search, raw cypher, schema, health) → mcp_core.

The legacy wheeler/mcp_server.py monolith has been deleted (v0.14.0+). Its 50 tools were all already covered by the four split servers, which carry 57. tests/test_mcp_surface.py now guards the surface directly: per-server tool counts, no duplicate names across servers, every tool described, and a test_monolith_is_gone check so it cannot be reintroduced. Each server also has its own test file (tests/test_mcp_{core,query,mutations,ops,shared}.py).

Content versions and disclosure

Every node carries content_version (1 at creation, +1 on each update_node or set_tier) and content_hash; the pre-change state is snapshotted to knowledge/versions/<id>/v<n>.json. Edges record the endpoint versions they were made against, citations may pin one ([F-3a2b@2], validated as outdated when the node moved on), and show_node can read a version, answer if_changed_since, and list one hop of neighbors with a moved flag. Listings return pointers by default (WHEELER_DISCLOSURE=pointer, chosen by evals/disclosure); show_node is the deep read. See ARCHITECTURE.md "Content versions" and "Disclosure levels".

Provenance, stability, and staleness

Wheeler uses W3C PROV-DM relationships: USED, WAS_GENERATED_BY, WAS_DERIVED_FROM, WAS_INFORMED_BY, WAS_ATTRIBUTED_TO, WAS_ASSOCIATED_WITH, plus 8 Wheeler semantic relationships (SUPPORTS, CONTRADICTS, CITES, APPEARS_IN, RELEVANT_TO, AROSE_FROM, DEPENDS_ON, CONTAINS).

Every node carries a stability score (0.0–1.0) set by type and tier (Paper=0.9, primary Dataset=1.0, LLM-generated Finding=0.3, etc.). When a script's SHA-256 hash stops matching disk, detect_and_propagate_stale() in provenance.py traverses WAS_GENERATED_BY|USED*2..N transitively with exponential decay (default 0.8 per hop) and marks downstream nodes stale=true. The Cypher is entity-to-entity so max_edges = max_hops * 2 to account for each hop crossing WAS_GENERATED_BY and then USED.

Retrieval is multi-channel RRF

search_findings fuses four channels via Reciprocal Rank Fusion:

  1. Semantic (fastembed cosine on BAAI/bge-small-en-v1.5, stored in .wheeler/embeddings/)
  2. Keyword (graph substring queries over metadata)
  3. Temporal (recency boost via updated timestamp)
  4. Fulltext (Neo4j fulltext index on _search_text, populated via triple-write in v0.6.0)

Any channel can be unavailable (no embeddings, no fulltext index, empty graph) and the survivors still produce results. search_context (v0.6.0) wraps this and expands each seed via 1-hop (all rels) + 2-hop (PROV only) so callers get provenance chains alongside matches.

Infrastructure hardening

Seven patterns you may see referenced across the code. The first six shipped in v0.6.0; transient retry was added later, alongside remote-Neo4j (Aura) support.

  • Circuit breaker (graph/circuit_breaker.py): 3-state breaker on Neo4j. After 3 consecutive failures it OPENs and fails fast in <1ms. After 60s it HALF_OPENs for a probe. Wraps every backend call. Never bypass.
  • Transient retry (graph/driver.py::run_with_retry): retries transient Neo4j failures, which a remote database produces and a local one does not. Takes an async factory so each attempt opens its own session, which is how it honours the no-concurrent-queries rule rather than working around it. Composes with the breaker rather than sitting beside it: the breaker is checked before every attempt and CircuitOpenError propagates instead of being retried. Classification is delegated to is_deterministic_neo4j_error() and the driver's own is_retryable(), never duplicated. Neo4jBackend wraps it as _retry (safe to replay) and _once (must not be); see wheeler/graph/CLAUDE.md for the per-method decision table and the rule for a new method.
  • Consistency checker (consistency.py): detects drift between graph ↔ JSON ↔ synthesis, repairs from surviving layer.
  • Trace IDs (mcp_shared.py, request_log.py): every MCP call gets a unique trace_id threaded through the log. Correlate multi-step operations by grouping on it.
  • Write receipts (write_receipt.py): WriteReceipt tracks which layers succeeded per triple-write. Incomplete writes go to .wheeler/repair_queue.jsonl.
  • Change log on nodes (models.ChangeEntry): NodeBase.change_log: list[ChangeEntry] records field-level diffs on set_tier and invalidation propagation. Default is empty list for backward compat.
  • Task contracts (contracts.py): handoff tasks declare required output nodes, required links, citation pass rate; validate_task_contract checks them at reconvene.

Acts = slash commands = system prompts

Each .claude/commands/wh/*.md file is a Claude Code slash command. YAML frontmatter sets allowed-tools (which enforces per-mode tool access); the markdown body IS the system prompt. Mode enforcement (CHAT read-only, WRITE strict citations, EXECUTE full access) is entirely in the frontmatter.

wheeler/acts.py now reads these files at runtime (from the _data/commands/ mirror) and serves them over MCP via list_acts / get_act in mcp_core, so the bodies exist in exactly one place across both hosts. mode and orchestration are derived from allowed-tools rather than declared, so the two can never disagree: ops grant -> execute, mutations grant -> write, else chat; Agent/Task*/Team* -> subagents, Skill -> skill-dispatch. get_act(name, host=...) returns the body byte-identical plus a host-specific orchestration_note (Claude Code gets Agent/TeamCreate; Codex gets spawn_agent/send_input and is told the note supersedes any Claude tool the body names). _data/commands/ holds 41 .md files but CLAUDE.md is the authoring guide, so there are 40 acts.

/wh:start is a user-invoked router: it analyzes task intent and invokes the best /wh:* command via the Skill tool. Individual commands have narrow trigger descriptions requiring Wheeler/knowledge-graph vocabulary, so they auto-fire for unambiguous research actions but not for general coding.

The same command files exist twice: in .claude/commands/wh/ (what Claude Code actually loads from this repo) and in wheeler/_data/commands/ (what the PyPI package ships to users via wheeler install). Keep these two trees in sync. Edit the .claude/commands/wh/ tree, then run python -c "from wheeler.installer import sync_data; sync_data()" to regenerate the _data mirror byte-for-byte. Tests check both paths (tests/test_installer.py::test_package_data_in_sync, tests/test_context_routing.py, tests/test_routing.py::TestTreeSync).

The wheeler-brief skill (.claude/skills/wheeler-brief/, versioned with the repo via a .gitignore negation; other dev skills stay ignored) renders self-contained HTML research briefs (question, bulleted execution plan, data sources, figure mockups paired with actual results). It is invoked from /wh:plan after approval and /wh:execute after the completion summary; the act step is conditional ("if the skill is available, else skip silently") so shipped users without the skill are unaffected. The renderer is stdlib-only and unit-tested in tests/test_render_brief.py (which skips when the skill dir is absent).

Multi-host distribution: the wh plugin

Wheeler installs as a plugin on both Claude Code and Codex. wheeler install (writing into ~/.claude/) still works but is the legacy path.

/plugin marketplace add maxwellsdm1867/wheeler   &&  /plugin install wh@wheeler      # Claude Code
codex plugin marketplace add maxwellsdm1867/wheeler && codex plugin add wh@wheeler   # Codex

wheeler/build_plugin.py generates the whole tree from wheeler/_data/commands/ and _data/agents/. Regenerate with python -m wheeler.build_plugin; never hand-edit the output. tests/test_build_plugin.py::test_committed_tree_matches_generator is the drift guard. Emitted: .claude-plugin/, .codex-plugin/, skills/<act>/SKILL.md (40), agents/, hooks/, codex-profiles/, and .mcp-plugin.json.

Five host facts that are load-bearing, all established by canary against the real CLIs rather than by asking an agent (which confabulates its own tool list):

  1. The plugin MUST stay named wh. Claude Code namespaces plugin skills as /<plugin>:<skill>, so wh + skills/plan/ yields /wh:plan, unchanged for existing users. Skill dirs therefore strip the wh: prefix from the act's name:.
  2. Plugin MCP servers are namespaced too: wheeler_core inside plugin wh exposes mcp__plugin_wh_wheeler_core__<tool>, not the bare form. Since allowed-tools is an allowlist matched on the full id, the generator emits both spellings for every grant. Copying an act's allowed-tools verbatim would deny every Wheeler tool and leave the skill inert while looking correct in review.
  3. allowed-tools survives in plugin skills, which is how CHAT/WRITE/EXECUTE enforcement keeps working on Claude Code. Codex accepts the key but does not enforce it, so Codex gets codex-profiles/*.config.toml instead, where mode is enforced by which servers are registered. Those profiles must write enabled = false explicitly for out-of-mode servers: Codex merges profiles recursively, so omitting a server does not disable it.
  4. Frontmatter must open the file. A leading HTML comment makes the whole block invisible, silently dropping description and allowed-tools. The generated-file banner goes after it.
  5. The legacy ~/.claude/commands/wh/*.md tree fully shadows same-named plugin skills. Both installed at once means users silently keep the stale file copies. wheeler install now refuses when the plugin is present, wheeler migrate-to-plugin does the swap, and wheeler doctor reports the collision.

MCP servers launch via uvx --from wheeler==<version> wheeler-core-mcp, so there is no install step: neither host can run a postinstall hook. Measured cost is 13 s once to populate the uv cache, then ~370 ms per launch, which matches running the console script directly.

Service integrations (wheeler/integrations/) and adding a new service

External research tools land in the graph as provenance-tracked nodes via a sandwich: a /wh:<provider>-<tool> act reads graph context and shapes the request (--used source ids), the tool's own CLI runs (owning its auth and retries), and one deterministic marshal-out ingest module writes the result back through execute_tool (the only graph writer; lazy import, like validation/ledger.py). The split servers stay graph-side; integrations/asta/transport.py is the single subprocess boundary. AllenAI Asta is instance #1: four adapters (Paper Finder, Semantic Scholar, Theorizer, Literature Reports) routed by /wh:asta, read from the registry (integrations/registry.py: the bundled catalog services.default.yaml plus the .wheeler/services/ enabled folder curated by wheeler services enable/disable). Each call is one Execution with a TRUTHFUL status: the external-call failsafe (job_outcome gate + mark_execution_failed/mark_execution_completed + wheeler integrate record-failure) means a failed or partial job is recorded as failed with no fabricated outputs. Provenance is three parts: USED inputs, WAS_GENERATED_BY outputs (Papers excepted, they are reference entities), and the semantic edges wired in the act (judgment, not the parser). The Research Assistant is the LONG-HORIZON exception: its loop runs for hours and one harvest lands a whole batch, so it also (a) renders harvest.html on every ingest, (b) tags every node with custom_batch, and (c) marks each decision-bearing node custom_review_state=undiscussed instead of demanding a dozen endorsements inline. /wh:discuss <batch> reads that queue via query_review_queue and walks it with the scientist later. Any future batch adapter should reuse those three. See wheeler/integrations/asta/CLAUDE.md, ARCHITECTURE.md "Service Integrations", and docs/asta-engine-spec.md.

To add a new external service, use the wheeler-service-creator skill: do NOT hand-write an adapter. The skill (.claude/skills/wheeler-service-creator/) reads the codebase, asks the scientist the design decisions, and scaffolds the four pieces (registry contract, marshal-out ingest with the failsafe baked in, marshal-in act with the semantic-wiring + record-failure steps, and a live-Neo4j test stub) from a contract. Its bundled assets/audit_service.py auditor then checks data-safety (the e2e teardown deletes only by per-run e2e_tag), both provenance sides, the failsafe, and conventions. The loop is: run the creator -> capture one real output -> fill the parser -> audit -> adversarial review -> land. Hand-writing an adapter drops a provenance edge or the failsafe; the skill does not.

Non-obvious constraints

  • Neo4j sessions don't allow concurrent queries. Never asyncio.gather inside a session; run sequentially. Backend helpers serialize inside a single session.
  • Community Edition isolation is simulated via a _wheeler_project property on every node. When config.neo4j.project_tag is set, all MATCH/CREATE queries are namespace-scoped in Python. Enterprise/Aura users get real database isolation via the backend config.
  • ANTHROPIC_API_KEY is actively unset by bin/wh before every headless run. The pre-commit hook greps for ANTHROPIC_API_KEY, api.anthropic.com, import anthropic, from anthropic import, anthropic.Anthropic(), and sk-ant-* patterns in staged content. Never add code that imports anthropic.
  • fastembed downloads a 33MB model on first use. This happens at EmbeddingStore.__init__, not at package import. pip install wheeler does not trigger the download.
  • Graduated disclosure: wheeler/, wheeler/graph/, wheeler/knowledge/, wheeler/tools/, wheeler/integrations/asta/, wheeler/integrations/llmsr/, and .claude/commands/wh/ each have their own CLAUDE.md. Read the relevant one before editing that subtree. They are the authoritative per-directory reference.

Style rules that apply to Wheeler-generated writing

  • Never use em dashes. Use colons, commas, periods, or parentheses.
  • Citations are strict in write/execute modes, flexible in chat/discuss/plan. Claims about our data cite [F-xxxx], methods cite [P-xxxx], interpretations are marked, speculation is free.
  • Never do the scientist's thinking. Sharpen the question, flag sparse graph areas, ask questions rather than pad thin answers. Task routing: SCIENTIST does math/judgment, WHEELER does grinding/drafts, PAIR does walkthroughs.

On startup

If .plans/STATE.md exists, read it. Call graph_context for recent findings before substantial work.

See ARCHITECTURE.md for the complete technical spec (930+ lines: module dependency map, MCP tool listing per server, PROV schema, hardening patterns, design decisions).

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.