repowise Documentation

Codebase intelligence for AI and humans: code health scores, auto-generated docs, git analytics, dead code detection, and architectural decisions via MCP.

RAW Doc

Docker & Self-Hosted Deployment Verified OCI

Deploy repowise on any server, homelab, or cloud container platform.

1-Click Terminal Command
bash (docker run)
docker run -d --name repowise -p 7337:7337 --restart always repowise-dev/repowise:latest
Configured Services & Ports
⚙️ repowise
image: build locally
🔌 Ports: 127.0.0.1:7337:7337, 127.0.0.1:3000:3000
docker-compose.yml
yaml (docker-compose.yml)
services:
  repowise:
    build:
      context: ..
      dockerfile: docker/Dockerfile
    ports:
      - "127.0.0.1:7337:7337"   # API — loopback only by default
      - "127.0.0.1:3000:3000"   # Web UI — loopback only by default
    volumes:
      # Mount the .repowise directory from your indexed repo
      - ${REPOWISE_DATA:-./data}:/data
      # Mount repo directory as read-only to prevent write-based attacks
      - type: bind
        source: ${REPO_PATH:?Set REPO_PATH to your repository directory}
        target: /repos
        read_only: true
    environment:
      - REPOWISE_DB_URL=sqlite+aiosqlite:////data/wiki.db
      - REPOWISE_EMBEDDER=${REPOWISE_EMBEDDER:-mock}
      - REPOWISE_HOST=0.0.0.0
      # SECURITY: Set this to enable API authentication (required for non-loopback)
      - REPOWISE_API_KEY=${REPOWISE_API_KEY:?Set REPOWISE_API_KEY for Docker deployments}
      # Set your LLM provider API key
      - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
      - OPENAI_API_KEY=${OPENAI_API_KEY:-}
      - GEMINI_API_KEY=${GEMINI_API_KEY:-}
    restart: unless-stopped

README

repowise documentation

Index your codebase once. Your agent stops greping, your team stops guessing
which PR is dangerous, and both get their answers from the same place.

New here? Quickstart gets you indexed and connected to
your agent in under five minutes, with no API key.


Pick your path

You are Start here Then
A developer wiring up an AI agent Quickstart Supported agents · MCP tools · Hooks · Distill
Living in an editor VS Code extension Codex · opencode
A team lead watching what ships Change risk Code health · Bug history
Running many repos Workspaces Worktrees · Auto-sync
Evaluating repowise to buy it Commercial Security & compliance · Benchmarks
Contributing Architecture CONTRIBUTING

Get started

Doc What it covers
start/QUICKSTART.md Install, index your repo, and connect your agent in under 5 minutes
start/USER_GUIDE.md The everyday guide: how the pieces fit and the workflows they support
start/DASHBOARD.md Every view in the local web dashboard, and what each one answers

Connect your AI agent

Doc What it covers
agent/INTEGRATIONS.md Which agents are supported at what depth, generated from the code, plus the recipe for adding one
agent/MCP_TOOLS.md The ten task-shaped tools, what each answers, and worked multi-tool examples
agent/HOOKS.md Proactive delivery: context and warnings that arrive without the agent asking
agent/DISTILL.md repowise distill: compress noisy command output before your agent reads it
agent/VSCODE.md The VS Code extension: health in the gutter, risk before you push, dashboards in the editor
agent/CODEX.md Wiring repowise into the Codex CLI
agent/OPENCODE.md Wiring repowise into opencode

The intelligence layers

Doc What it covers
layers/INTELLIGENCE_LAYERS.md Overview of the five layers: graph, git, docs, decisions, code health
layers/CODE_HEALTH.md Defect risk, maintainability, and performance from 49 deterministic detectors
layers/REFACTORING.md Concrete, graph-aware refactoring plans (Extract Class, Move Method, Break Cycle)
layers/CHANGE_RISK.md Score any commit or base..HEAD range 0-10 for defect risk
layers/BUG_HISTORY.md Which files and symbols actually get bug-fixed, and how recently
layers/TEST_INTELLIGENCE.md Coverage ingestion, untested hotspots, and running only the tests a diff touches
layers/DECISIONS.md Architectural decisions mined from your repo and from your own agent sessions
layers/DEAD_CODE.md Unreachable files, unused exports, and zombie packages by confidence tier
layers/LANGUAGE_SUPPORT.md What works per language, across 18 parsed languages and 13 at the Full tier
layers/WIKI.md The generated wiki: page types, what update re-renders, styles, output language

Scale it

Doc What it covers
scale/WORKSPACES.md Multi-repo intelligence: cross-repo contracts, co-changes, and federated MCP
scale/WORKTREES.md Linked git worktrees seed their index from the base checkout, with no flags
scale/AUTO_SYNC.md Keep the index fresh automatically on every commit
../docker/README.md Running repowise in Docker
../examples/ Copy-paste walkthroughs (Codex setup and more)

Reference

Doc What it covers
reference/CLI_REFERENCE.md Every command and flag
reference/CONFIG.md .repowise/config.yaml, health-rules.json, and environment variables
reference/COMPUTED_GLOSSARY.md Definitions for every computed metric and term repowise reports
reference/TELEMETRY.md What anonymous telemetry collects, and how to turn it off
reference/UPGRADING.md Notes for upgrading between versions
CHANGELOG.md Release history

Evidence

Doc What it covers
BENCHMARKS.md Agent-efficiency, distillation, and defect-prediction results, each with what it does not show

Teams & business

Doc What it covers
business/COMMERCIAL.md Hosted tier, enterprise (on-prem, SSO/SCIM), and commercial licensing
business/SECURITY_COMPLIANCE.md What leaves your machine, what gets stored, and the answers your security team wants

Architecture & internals

How repowise is built, for contributors and the curious.

Doc What it covers
architecture/ARCHITECTURE.md The system: package layout, pipelines, MCP server
architecture/code-health.md Health internals: marker computation and calibrated weights
architecture/graph-algorithms.md Every graph algorithm, the intuition plus the math
architecture/language-support.md The language pipeline and how the tiers work
architecture/chat.md Codebase chat: agent loop, streaming, artifact panel
architecture/structurizr-export.md Export the architecture as Structurizr DSL and render it anywhere
architecture/editor-files.md How CLAUDE.md and AGENTS.md get generated
architecture/deep-dives.md Systems not covered elsewhere
architecture/pluggable-storage.md The capability seams: storage, graph, vector, CLI, MCP
design/theme-tokens.md Resolved design tokens and the WCAG contrast matrix

Architecture/ARCHITECTURE

repowise Architecture

repowise is an open-source, self-hostable codebase documentation engine. It generates
a structured, hierarchical wiki for any codebase, keeps it accurate as code changes,
and exposes everything through an MCP server so AI coding assistants can query it
in real time.

This document covers how the system is built, why each piece exists, and how
they fit together. Read this before contributing.

Package READMEs

For per-package detail (installation, full API reference, all CLI flags, file maps):

Package README What it covers
packages/core packages/core/README.md Ingestion, generation, persistence, providers — all key classes with code examples
packages/cli packages/cli/README.md CLI entrypoints and flags; full surface in CLI_REFERENCE.md
packages/server packages/server/README.md All REST API endpoints, 11 MCP tools, webhook setup, scheduler jobs
packages/web packages/web/README.md Every frontend file with purpose — API client, hooks, components, pages

Table of Contents

  1. System Overview
  2. Repository Structure
  3. The Three Stores
  4. Provider Abstraction Layer
  5. Init Path — First-Time Documentation
  6. Maintenance Path — Keeping Docs in Sync
  7. Git Intelligence
  8. Dead Code Detection
  9. Decision Intelligence
  10. MCP Server
  11. REST API and Web UI
  12. Codebase Chat
  13. Data Flow Diagrams
  14. Key Design Decisions
  15. Editor File Generation
  16. Adding a New Language
  17. Adding a New LLM Provider

1. System Overview

text
/* Detailed source-code truncated for AI context efficiency. */

repowise has two operational modes that share the same core engine but follow
different strategies:

  • Init — first-time documentation of an existing codebase. May take minutes
    to hours on large repos. Resumable. Supports batch API for cheaper generation.
  • Maintenance — incremental updates triggered by git commits. Runs in seconds
    to minutes. Uses change propagation through the dependency graph to only regenerate
    what actually changed.

2. Repository Structure

text
/* Detailed source-code truncated for AI context efficiency. */

3. The Three Stores

repowise uses three separate storage systems. They are not redundant — each answers
a fundamentally different kind of question that the other two cannot answer efficiently.

3.1 SQL Store (SQLAlchemy + SQLite / PostgreSQL)

Answers: what exists, what changed, when.

The source of truth for all structured data. SQLite in development and single-server
deployments; PostgreSQL for multi-worker production deployments. The schema is identical
for both — SQLAlchemy abstracts the difference.

Key tables:

Table Purpose
repos Registered repositories, sync state, provider config
wiki_pages All generated wiki pages with content, metadata, confidence score, and a short LLM-extracted summary (1–3 sentences) used by get_context to keep responses bounded
page_versions Full version history of every page (for diff view)
symbols Symbol index: every function, class, method across all files
answer_cache Memoised get_answer responses keyed by (repository_id, question_hash) plus the provider/model used. Repeated questions return at zero LLM cost; cache entries are invalidated by repository re-indexing.
generation_jobs Job state machine with checkpoint fields for resumability
webhook_events Every received webhook event (deduplication, audit, retry)
symbol_rename_history Detected renames for auditing and targeted text patching
graph_nodes / graph_edges SQLite-backed graph for repos exceeding 30K nodes
git_metadata Per-file git history: commit counts, ownership, co-change partners, hotspot/stable flags
dead_code_findings Dead code findings: unreachable files, unused exports, zombie packages

If you delete the SQL store, you lose everything and must re-run repowise init.

3.2 Vector Store (LanceDB embedded / pgvector)

Answers: what is semantically similar to this query.

repowise uses a VectorStore abstraction with two backends, selected automatically
based on the configured SQL backend:

LanceDB (default — SQLite mode)
LanceDB runs embedded as a library — no separate server process. Data is stored in
.repowise/lancedb/ using the Lance columnar format. This makes self-hosting trivial
and keeps the Docker setup simple. LanceDB is significantly faster than ChromaDB on
both write throughput (batch embedding) and ANN query latency, and it requires no
C++ build tools to install.

pgvector (PostgreSQL mode)
When repowise is configured with a PostgreSQL database, the wiki_pages table gains
an embedding vector(N) column via the pgvector PostgreSQL extension. Embeddings
are stored directly in the same SQL database — no second storage system required.
Vector similarity search uses <=> (cosine distance) with an HNSW index. This is
the preferred backend for multi-worker production deployments.

Every generated wiki page is embedded and stored immediately after generation.
The vector store is used in two distinct ways:

During generation (RAG context): When generating a wiki page for file A, the
ContextAssembler queries the vector store with A's exported symbol names to find
pages for files that A imports from. These are included as context in the generation
prompt. Each generated page is aware of what its dependencies actually do —
not just their names. See Section 5.4 for details.

During search and MCP queries: The search_codebase MCP tool and the web UI
search page use the vector store to find the most semantically relevant pages for a
natural language query. This is better than full-text search for questions like
"how does authentication work?" or "where is rate limiting handled?".

If you delete the vector store (LanceDB directory or pgvector embeddings), search
quality degrades and generation context becomes shallower — rebuild it by running
repowise reindex which re-embeds all existing SQL pages into LanceDB using
the configured embedder (Gemini or OpenAI). No LLM calls — only embedding API calls.

3.3 Graph Store (NetworkX / SQLite-backed)

Answers: how are things connected, and how important are they.

The dependency graph is a two-tier directed graph where file nodes represent source
files and symbol nodes represent individual functions, classes, and methods. Edges
include imports, DEFINES, HAS_METHOD, CALLS (with confidence 0.0–1.0),
inherits, implements, and co_changes. CALLS edges are built by CallResolver
using 3-tier resolution; all others are built by GraphBuilder during AST ingestion.

It is built by the ASTParser + GraphBuilder + CallResolver during ingestion and
persisted to .repowise/graph.json (for repos ≤ 30K nodes) or the graph_nodes/graph_edges
SQL tables (for larger repos, using the networkit library as a drop-in). The graph_nodes
table includes a kind column (file, symbol, package, external) and confidence column;
graph_edges includes a confidence column for CALLS edges (Alembic migration 0015).

The graph is used for:

  • Generation ordering — topological sort determines what to generate first
    (files with no dependents get generated before files that import them, so the
    richer context is available via RAG when the importing file is generated)
  • Change propagation — when a file changes, walk the graph to find all
    pages that reference its symbols and mark them as stale
  • PageRank — runs on file_subgraph() (file + package nodes only) to identify
    the most central files; these get "spotlight" wiki pages and richer generation prompts
  • SCC detection — circular dependency clusters require a special generation
    strategy (see Section 5.3)
  • Co-change edges — temporal coupling from git history. Files that frequently
    change together (but may have no import relationship) get co_changes edges.
    These participate in change propagation and are shown in the graph visualization
    as dashed purple lines. They do NOT affect PageRank.
  • Dead code detection — files with in_degree == 0 (no importers) are
    candidates for unreachable file detection
  • MCP get_dependency_path tool (opt-in) — answers "how is module A connected to module B?"
  • D3 graph visualization in the web UI

If you delete the graph, repowise loses change propagation and generation ordering.
It can be rebuilt from scratch by re-parsing the source files.


4. Provider Abstraction Layer

Every LLM call in the entire system goes through LLMProvider. No provider SDK
is ever imported from business logic packages.

text
┌─────────────────┐
                    │   LLMProvider   │  (abstract base class)
                    │                 │
                    │  generate()     │
                    │  generate_stream│
                    │  embed()        │
                    │  generate_batch │  (optional, default = sequential)
                    │  estimate_cost  │  (optional, returns None if unknown)
                    └────────┬────────┘
                             │
          ┌──────────────────┼──────────────────┬──────────────────┐
          ▼                  ▼                  ▼                  ▼
  AnthropicProvider   OpenAIProvider     OllamaProvider    LiteLLMProvider
  (claude-*)          (gpt-*, any        (any local model,  (100+ providers,
  batch API + prompt   OpenAI-compat      fully offline,     optional dep)
  caching support)     endpoint)          no API key)

4.1 Rate Limiter

Each provider instance wraps a RateLimiter using a token-bucket algorithm with
two independent buckets: requests-per-minute (RPM) and tokens-per-minute (TPM).

Before every API call, the limiter acquires from both buckets. On a 429 response,
it calls on_rate_limit_error() which applies exponential backoff and temporarily
reduces the refill rate. This is transparent to all callers.

Default limits are configured per provider in .repowise/config.yaml and can be
adjusted for users with higher API tiers.

4.2 Prompt Caching

For Anthropic: the ContextAssembler marks the system prompt + shared repository
context with cache-control breakpoints. Across the hundreds of file-page generation
calls during a large init, this shared prefix is only billed once. Cost reduction
on large repos is typically 60–90%.

Note: An older Anthropic Message Batches / --no-batch init path is no
longer part of the product. Init uses concurrent streaming requests under the
rate limiter; there is no batch_mode config key and no --no-batch flag.

4.3 Adding a Provider

Implement LLMProvider, add an entry to LANGUAGE_CONFIGS in providers/registry.py,
and add a section to .repowise/config.yaml. See Section 12.


5. Init Path — First-Time Documentation

repowise init runs when documenting a codebase for the first time. It is the
expensive, one-time operation that builds the full wiki from scratch.

5.1 File Traversal

FileTraverser walks the repository tree and produces a FileInfo for every
file that should be documented. It respects six layers of exclusion, applied in
priority order:

  1. .gitignore (parsed with pathspec, not simple glob matching)
  2. Root .repowiseIgnore (same syntax, user-defined at repo root)
  3. Per-directory .repowiseIgnore — loaded from each directory visited during
    the os.walk. Patterns are relative to the directory containing the file (like
    git's per-directory .gitignore). A spec per directory is loaded once and cached
    for the traversal. Example: generated/ in src/.repowiseIgnore skips
    src/generated/ without affecting other directories with the same name.
  4. extra_exclude_patterns (constructor param) — additional gitignore-style
    patterns passed at runtime from --exclude/-x CLI flags or
    repo.settings["exclude_patterns"] (set via Web UI or REST API PATCH). Applied
    to both directory pruning (entire subtree skipped) and individual file filtering.
  5. Hardcoded blocklist (node_modules, .git, __pycache__, dist, build,
    *.lock, *.min.js, generated protobuf files, etc.)
  6. Auto-detection of generated files (files with // Code generated headers)
  7. Binary files (detected by null bytes in first 8KB)
  8. Files over max_file_size_kb (default: 500KB)

Constructor signature:

python
FileTraverser(
    repo_root: Path,
    *,
    max_file_size_kb: int = 500,
    extra_ignore_filename: str = ".repowiseIgnore",
    extra_exclude_patterns: list[str] | None = None,
)

Where extra_exclude_patterns comes from:

Source How patterns reach traverser
repowise init -x vendor/ -x 'src/gen/**' Merged with config.yaml exclude_patterns, passed directly
repowise update Read from .repowise/config.yaml exclude_patterns
Web UI Excluded Paths section Saved to repo.settings["exclude_patterns"] via REST API
Server sync job Read from repo.settings.get("exclude_patterns", [])

The traverser also detects monorepo structure by looking for multiple
pyproject.toml, package.json, go.mod, or Cargo.toml files at depth 1–2.
When detected, each package is documented as a semi-independent unit with
cross-package edges tracked in the graph.

Each FileInfo is tagged with: language, is_test, is_config, is_api_contract,
is_entry_point, git_hash. These tags influence generation priority and prompt choice.

Test files are first-class wiki targets. The page generator includes any file
tagged is_test=True that has at least one extracted symbol, even if the file's
PageRank is near zero (which is typical: nothing imports test files back, so
graph-centrality metrics never select them on their own). Test files answer
questions of the form "what test exercises X" / "where is Y verified", and
the doc layer is the right place to surface those. Users who want to exclude
tests from the wiki entirely can pass --skip-tests to repowise init.

5.2 AST Parsing

ASTParser is a single class that handles all supported languages. There are no
per-language subclasses.

How it works:

  1. Calls get_parser(language) from tree-sitter-languages to get the right grammar
  2. Parses the source into a tree-sitter parse tree
  3. Loads the corresponding queries/<language>.scm file (cached after first load)
  4. Runs tree-sitter queries to extract symbols, imports, and exports
  5. Maps results through LANGUAGE_CONFIGS[language] for language-specific rules
    (visibility keywords, entry point patterns, etc.)
  6. Returns a ParsedFile with a consistent shape regardless of language

The .scm query files use standard tree-sitter S-expression syntax with
consistent capture name conventions across all languages:

  • @symbol.def — the full symbol node
  • @symbol.name — the name identifier
  • @symbol.params — parameter list
  • @symbol.return_type — return type annotation
  • @symbol.docstring — existing docstring (expanded, not repeated, by the LLM)
  • @import.statement — full import node
  • @import.module — the module being imported

Adding a new language = write one .scm file + add one entry to LANGUAGE_CONFIGS.
No changes to ASTParser itself. See Section 11.

Special handlers live in core/special_handlers/ and are separate from ASTParser.
They handle file types that are not programming languages (OpenAPI specs, Dockerfiles,
GitHub Actions YAML, Makefiles, Protobuf, GraphQL schemas). They produce the same
ParsedFile shape but use purpose-built parsers (PyYAML, graphql-core, etc.)
instead of tree-sitter.

5.3 Dependency Graph Construction

GraphBuilder takes all ParsedFile outputs and builds a networkx.DiGraph.

Node types:

  • file — every source file
  • symbol — every function, class, method, interface, etc. (added via add_file())
  • package — every package/module directory
  • external — third-party packages (lightweight node, not fully documented)

Edge types:

  • imports — file A imports from file B
  • DEFINES — file A defines symbol B
  • HAS_METHOD — class A has method B
  • CALLS — symbol A calls symbol B (with confidence score 0.0–1.0)
  • inherits — class A extends class B
  • implements — class A implements interface B
  • instantiates — code in A creates an instance of B
  • references — looser reference (type annotation, generic, etc.)
  • re-exports — A re-exports symbols from B (barrel files)
  • inter_package — edge crossing package boundary (monorepos)
  • co_changes — A and B frequently change in the same commit (from git history,
    added by GitIndexer after graph construction). Weight = co-change count.
    Filtered out of PageRank but included in change propagation and visualization.

Call resolution is handled by the CallResolver module (ingestion/call_resolver.py),
which runs after the static import graph is built. It operates in three tiers:

  1. Same-file resolution (confidence 0.95) — call target defined in the same file
  2. Import-scoped resolution (confidence 0.85–0.93) — target matched via named bindings
    from the file's import list
  3. Global unique match (confidence 0.50) — target is unique across the whole repo

Call sites are extracted by tree-sitter for all 14 supported languages (Python, TypeScript,
JavaScript, Go, Rust, Java, C++, C, Kotlin, Ruby, C#, Swift, Scala, PHP) using per-language .scm query files. Results are stored
as CallSite dataclasses and become CALLS edges in the graph.

Named binding resolution (NamedBinding dataclass in ingestion/models.py) ensures
that aliased imports, barrel re-exports, and namespace imports resolve to the correct
definition site. The parser's _extract_import_bindings() produces bindings for each
import statement, and GraphBuilder.build() populates Import.resolved_file from them.
Barrel files (__init__.py, index.ts) are followed one hop to resolve re-exports.

Two-tier graph isolation:

Symbol nodes and their DEFINES/HAS_METHOD/CALLS edges are stored in the same
DiGraph as file nodes, but file_subgraph() returns a view containing only file
and package nodes. All file-level metrics (PageRank, betweenness, SCCs, Louvain)
run on this subgraph so that the large number of symbol nodes does not distort centrality
scores.

After graph construction, the builder computes:

  • PageRank — nodes with high PageRank are central and well-connected.
    Used to decide generation priority and which symbols get spotlight pages.
  • Strongly Connected Components (SCCs) — groups of files with circular imports.
    Logged as warnings. Require special generation handling (see below).
  • Betweenness centrality — identifies "bridge" symbols whose removal would
    disconnect the graph. These are the most critical to document well.
  • Community detection (Louvain) — discovers logical modules even when the
    directory structure doesn't reflect them. These communities become module pages.

Circular dependency handling:

Files in an SCC cannot be generated in strict bottom-up order because each one
depends on the others. Strategy:

  1. Generate each SCC member with reduced context (own source + signatures of
    cycle partners only, no full docs)
  2. Generate a dedicated SCC wiki page explaining the cycle and how to navigate it
  3. In a second pass, upgrade each member's page with cross-references to the others

Graph scalability:

For repos with fewer than 30K nodes, the graph lives in memory as a NetworkX
DiGraph and is serialized to .repowise/graph.json.

For repos exceeding 30K nodes (configurable via graph_backend: sqlite), repowise
switches to networkit with SQLite backing via the graph_nodes and graph_edges
tables. Only the subgraph needed for each operation is loaded into memory. The
API is identical — this is transparent to all callers.

5.4 Context Assembly

This is the most important quality driver in the system. The ContextAssembler
builds the prompt context for each generation call — it does not just dump the
raw source file.

For a given file page, it assembles context in this priority order (dropping
lower-priority items if the token budget is exceeded):

  1. Source code — full source (or chunked if file is large)
  2. Symbol signatures — all symbols extracted from this file with their
    signatures and existing docstrings
  3. Graph context — PageRank score, cluster membership, which module this
    belongs to, entry point status
  4. Git context — ownership, significant commit messages explaining why code
    evolved, hotspot/stable classification, co-change partners. This transforms
    documentation from "here is what this code does" into "here is what it does
    and why it was written this way." Git context is the last thing dropped when
    over budget — it is more valuable than import summaries.
  5. Import summaries — for each file this file imports from: the summary of
    that file's already-generated wiki page (if available), or just its public
    API signatures (if not yet generated)
  6. RAG context — vector store similarity search (LanceDB or pgvector) using this file's top exported
    symbols as the query. Returns the top 3 most relevant already-generated pages.
    This propagates understanding upward: AuthService's page will know what
    UserRepository actually does, not just that it imports from it.
  7. Co-change context — wiki pages for co-change partners (files that change
    together without an import relationship). Reveals hidden coupling.
  8. Dead code findings — symbols in this file flagged as unused (if any).
    Listed in the generation prompt so the LLM notes them as cleanup candidates.
  9. Reverse import context — which files import this file, and what they
    use from it. Helps the LLM understand how this file is used in practice.

Token budget: the total assembled context targets 12K tokens, leaving room for
the generation output. Items are dropped in reverse priority order when over budget.
The source code is never dropped — it is chunked instead if too large.

Large file chunking:

Files over large_file_threshold_kb (default: 100KB) are handled differently.
Rather than passing the full source, repowise:

  1. Extracts public symbol signatures only for the top-level context
  2. Generates one sub-page per major class or function group
  3. Synthesizes a file page from the sub-pages
    Pages generated from chunks are tagged chunked: true in metadata.

5.5 Hierarchical Generation Order

Generation must follow a strict dependency-aware order. This is not optional —
generating a module page before its file pages means the module page has no
content to draw from.

text
Level 0: External API contracts (OpenAPI, proto, GraphQL)
         → self-contained, no dependencies on other pages

Level 1: Symbol spotlight pages (top 10% by PageRank)
         → short, fast, parallelizable
         → high-PageRank symbols get richer documentation

Level 2: File pages
         → uses: source + symbol docs + import summaries (from RAG)
         → parallelizable within this level
         → SCC members generate with stubs, upgraded in second pass

Level 3: SCC pages
         → must wait for all member files to complete Level 2

Level 4: Module/package pages
         → uses: all file pages within the module

Level 5: Cross-package relationship pages (monorepos only)
         → uses: all package pages

Level 6: Repository overview + architecture diagram
         → uses: all module pages + graph metrics

Level 7: Config/infra pages (Dockerfile, CI YAML, Makefile, etc.)
         → references code pages, so comes after all code docs

Level 8: Index pages (symbol index, search index)
         → built from all completed pages

Within each level, up to concurrent_jobs tasks run in parallel using
asyncio.Semaphore. The generation engine never exceeds this limit regardless
of how large the repo is.

Each generated page gets a confidence_score of 1.0 when first created.

5.6 Resumable Jobs

Init on a 50K-file repo can take 30–90 minutes. A crash or network interruption
should not require starting over.

The JobSystem persists checkpoint state after every completed page:

  • checkpoint_level — which generation level is currently active
  • checkpoint_file_index — position within the current level
  • completed_page_ids — list of already-generated page IDs
  • failed_page_ids — pages that failed (retried on resume)

On repowise init --resume, the set of already-written pages is read from the
vector store rather than from the checkpoint, because the store is the one
record that survives a killed process. This needs a job system and a store; with
neither, a resumed run falls back to regenerating everything. Those page ids are
skipped before their
coroutine is built, so a resumed run spends nothing on them, and the match is on
page id alone: a run resumed under a different provider still keeps what the
previous one wrote. Persistence is told which ids were skipped so the stale-page
sweep does not mistake "deliberately kept" for "no longer produced".

repowise init is fully idempotent. Running it twice produces the same result.
Running it after a partial previous run completes only the remaining pages.

5.7 Shared Persistence (`pipeline/persist.py`)

The persistence logic for storing a PipelineResult into the database (graph nodes,
edges, symbols, pages, git metadata, dead code findings, decision records) was
extracted from the CLI's init_cmd.py into core/pipeline/persist.py. Both the
CLI and the server's background job executor call persist_pipeline_result() — zero
duplication.

FTS indexing is intentionally excluded from this function. Callers must run it
separately after the session closes to avoid SQLite write-lock conflicts.

5.8 Background Job Executor (`server/job_executor.py`)

The server can now run full pipeline jobs in the background, triggered by the
POST /api/repos/{id}/sync and POST /api/repos/{id}/full-resync endpoints.

execute_job() is the single entry point, launched via asyncio.create_task():

  1. Marks the job as running
  2. Resolves the LLM provider from server config
  3. Runs run_pipeline() with a JobProgressCallback that writes progress to the
    GenerationJob table (the SSE stream endpoint polls this table)
  4. Persists results via persist_pipeline_result()
  5. Marks the job as completed (or failed on error)

Progress updates are batched (every 5 items) to avoid per-item DB overhead. Before
writing the final job status, all in-flight progress tasks are drained to prevent a
late running update from overwriting completed.

Concurrent pipeline runs on the same repository are prevented at the endpoint level
(returns HTTP 409 if a pending/running job already exists).

5.9 Async Pipeline Improvements

The pipeline orchestrator now keeps the event loop responsive during CPU-bound work:

  • File I/O uses asyncio.wrap_future() instead of blocking as_completed()
  • Graph building runs in a thread via asyncio.to_thread()
  • The parse loop yields control every 50 files with asyncio.sleep(0)
  • Thread pool shutdown is non-blocking via asyncio.to_thread()

6. Maintenance Path — Keeping Docs in Sync

repowise update runs after a git push (triggered by webhook, GitHub Action, or
polling fallback). It is fast, targeted, and avoids regenerating pages that
haven't actually changed in a meaningful way.

6.1 Change Detection

ChangeDetector uses GitPython to compute the diff between last_sync_commit
and HEAD:

python
changed_files = differ.get_changed_files(repo_path, since_commit="a1b2c3d")
# → ChangedFiles(added=[...], modified=[...], deleted=[...], renamed=[...])

Renamed files get special treatment: repowise updates all source_files references
in existing wiki pages and regenerates the file page (since the path context has
changed), but does not regenerate pages that only reference the file's symbols
(symbol names don't include paths).

6.2 Symbol Rename Detection

ChangeDetector.detect_symbol_renames() uses a heuristic to identify when a
symbol was renamed rather than deleted-and-recreated:

  • Same kind (function → function)
  • Similar signature (Levenshtein distance under threshold)
  • Git blame on the new file confirms line provenance

When a rename is detected, repowise applies a targeted text patch to all pages
that only mention the old name (cheaper than full regeneration), and fully
regenerates pages that document the old symbol.

All detected renames are stored in symbol_rename_history for audit purposes.

6.3 Affected Page Computation

For each changed file, ChangeDetector.get_affected_pages() walks the dependency
graph to find all pages that need updating:

text
Changed file → find its symbols
            → find all wiki pages that document those symbols (direct)
            → find all wiki pages that reference those symbols (1-hop: inherits/calls)
            → find all wiki pages that reference referencing pages (2-hop: looser reference)
            → apply cascade budget

Cascade budget:

A change to a central utility module (e.g., utils.py imported by 200 files) would
naively require regenerating 200+ pages on every push. This is too expensive.

The cascade_budget config option (default: 30 pages per maintenance run) caps the
number of pages fully regenerated per push. Pages beyond the budget:

  • Have their confidence_score decayed (see below)
  • Are added to the background staleness queue
  • Are regenerated by the nightly background job

6.4 Confidence Score System

Every wiki page has a confidence_score (float 0.0–1.0) representing how fresh
it is relative to the source code:

Score Status Badge Action
≥ 0.80 fresh green none
0.60–0.79 stale yellow queued for background regen
0.30–0.59 outdated red auto-queued, warning shown
< 0.30 unusable red (prominent) force-regenerated immediately

Decay rules applied on each maintenance run:

  • Source file directly changed: score *= 0.85
  • Referenced symbol changed (1-hop: calls/inherits): score *= 0.95
  • Referenced symbol changed (2-hop: looser reference): score *= 0.98
  • Co-change partner changed (no import relationship): score *= 0.97
  • Page beyond cascade budget (time-based, 1 week since change): score *= 0.90

Git-informed decay modifiers (applied multiplicatively on top of base decay):

  • Hotspot file (is_hotspot=True) → decays faster: direct *= 0.94, 1-hop *= 0.95
  • Stable file (is_stable=True) → decays slower: direct *= 1.03
  • Large change in commit message ("rewrite", "refactor", "migrate") → hard decay:
    direct *= 0.71, 1-hop *= 0.84
  • Cosmetic change in commit message ("typo", "lint", "format") → soft decay:
    direct *= 1.12

Pages start at 1.0 and decay over time. Regeneration resets to 1.0.

6.5 Webhook Reliability

Webhooks can be missed when the repowise server is down during a push. repowise
uses a two-layer sync strategy:

Layer 1 (real-time): GitHub/GitLab webhook → POST /api/webhooks/github →
signature verification → store in webhook_events → enqueue GenerationJob.
Response is always 200 OK immediately — processing is async.

Layer 2 (polling fallback): APScheduler job runs every 15 minutes (configurable).
Compares repos.last_sync_commit against actual HEAD via GitHub API or GitPython.
If they differ, triggers an incremental update for the missing commits.

The combination ensures: even if a webhook is missed entirely, docs are at most
polling_interval minutes stale for the default branch.

6.6 Background Staleness Resolution

APScheduler runs a nightly job (configurable via cron expression) to:

  1. Query all pages where confidence_score < staleness_regen_threshold (default: 0.60)
  2. Sort by confidence_score ASC (most stale first)
  3. Regenerate up to background_regen_budget pages (default: 100)
  4. Log token usage and cost

This ensures no page stays stale indefinitely regardless of cascade budget constraints.

6.7 PR Documentation Preview

On pull_request webhook events, repowise runs ChangeDetector in dry-run mode
and posts a GitHub PR comment listing:

  • Pages that will be regenerated (with links to current versions)
  • Pages that will have confidence decay
  • New pages that will be created (new files)
  • Pages that will be deleted (deleted files)
  • Estimated token cost for this PR's docs update

On merge, the actual incremental update runs.


7. Git Intelligence

repowise mines git history to make documentation significantly richer and more useful.
The GitIndexer runs once during repowise init (after graph construction, before
generation) and incrementally during repowise update. All git features degrade
gracefully when git metadata is unavailable — they simply skip git-enriched context.

7.1 GitIndexer (`packages/core/ingestion/git_indexer.py`)

The GitIndexer class mines git history into the git_metadata SQL table. For each
tracked file, it computes:

  • Commit volume — total, last 90 days, last 30 days (churn signals)
  • Timeline — first and last commit dates (file age)
  • Ownership — primary owner from git blame (who wrote the most lines),
    top 3 contributors by commit count
  • Significant commits — up to 50 meaningful commit messages (filtered: no merges,
    no dependency bumps, no chore/ci, messages > 20 chars). These explain why
    the code evolved this way and are included in generation prompts.
  • Co-change partners — files that changed in the same commit >= 3 times, even
    without an import relationship. Reveals hidden structural coupling.
  • Derived signals — is_hotspot (top-quartile decayed churn AND absolute
    activity floors: >= 3 commits in 90d with real line movement), is_stable
    (>10 commits, 0 in 90 days), churn_percentile (0.0–1.0)

Performance targets:

  • 3,000 files, 10K commits → < 3 minutes
  • 50,000 files, 100K commits → < 20 minutes (uses shallow history for large repos)
  • Uses asyncio.Semaphore(20) to parallelize git log calls across files

7.2 How Git Intelligence Enhances Each Component

Generation prompts: The file_page.j2 template includes optional git context
blocks (gated on {% if git_metadata %}). These add ownership attribution, evolution
context from significant commits, hotspot/stable warnings, and co-change partner
documentation. The LLM uses commit messages to explain why code is structured as
it is, not just what it does.

Generation ordering: Within each level, files are sorted by priority: entry
points first, then hotspots, then high PageRank, then high commit count. This
ensures the most important files generate first, making them available as RAG
context for less important files.

Generation depth: Files can be auto-upgraded to "thorough" depth if they are
hotspots, have > 100 commits with > 10 in 90 days, have >= 8 significant commits,
or have co-change partners. Conversely, stable + low PageRank + low commit count
files can be auto-downgraded to "minimal". Controlled by git.depth_auto_upgrade.

Maintenance prompts: When regenerating during repowise update, the specific
commit that triggered the update (SHA, author, message, diff) is included in the
prompt. This produces targeted updates rather than full rewrites.

Confidence decay: Git signals modify the base confidence decay multiplicatively.
Hotspot files decay faster (bugs are more likely). Stable files decay slower. Large
changes ("rewrite", "refactor", "migrate" in commit message) trigger aggressive
decay. Cosmetic changes ("typo", "lint", "format") trigger softer decay.

Co-change edges: After GitIndexer runs, co-change edges (edge_type="co_changes")
are added to the dependency graph. They participate in change propagation (when file
A changes, its co-change partners get mild confidence decay at factor 0.97) and are
visible in the graph visualization as dashed purple lines.

CLAUDE.md generation: repowise generate-claude-md includes sections for
hotspots, stable core files, ownership map, and hidden coupling pairs.

7.3 Module and Repo-Level Git Summaries

Module pages include a team ownership summary: who maintains the most files, who
was most active recently. Repo overview pages include codebase health signals:
hotspot count, stable file count, top churn files, oldest file.


8. Dead Code Detection

repowise detects unreachable files, unused exports, and zombie packages using
graph traversal and SQL queries. No LLM calls — the analysis completes in < 10
seconds for any repo size.

8.1 DeadCodeAnalyzer (`packages/core/analysis/dead_code.py`)

The analyzer runs after GitIndexer during init (Step 3.6) and optionally during
repowise update. It produces findings with confidence scores:

Finding types:

  • unreachable_file — file with in_degree == 0, not an entry point, test, or config
  • unused_export — public symbol with no incoming edges (but file IS imported)
  • unused_internal — private symbol with no calls edges from same file
  • zombie_package — monorepo package with no incoming inter_package edges

Confidence scoring (conservative — when in doubt, do NOT flag):

  • Unreachable + no commits in 90d + last commit > 1 year → 1.0
  • Unreachable + no commits in 90d + last commit > 6 months → 0.9
  • Unreachable + no commits in 90d + last commit > 90 days → 0.8
  • Unreachable + no commits in 90d + file under 30 days old → 0.55
  • Unreachable + no commits in 90d, no commit date to age it by → 0.7
  • Unreachable but recently touched → 0.4
  • safe_to_delete only set at confidence >= SAFE_CONFIDENCE_THRESHOLD (0.7),
    and NOT for files matching dynamic patterns (*Plugin*, *Handler*,
    *Adapter*, *Middleware*), and NOT for paths carrying a runtime-load risk
    factor. The rungs above are an evidence scale, not the tier boundaries; the
    boundaries live in core/analysis/dead_code/risk_factors.py

Never flagged as dead:

  • __init__.py public re-exports
  • @pytest.fixture, @pytest.mark.* symbols
  • Files matching *migrations*, *schema*, *seed*
  • TypeScript .d.ts files
  • Files where is_api_contract == True
  • Files named in the whitelist key of the analyzer's config argument (an API
    parameter; no CLI flag or config file populates it today)
  • Symbols matching config.dead_code.dynamic_patterns

8.2 Dead Code in Generation Prompts

The file_page.j2 template includes an optional block for dead code findings.
When present, the LLM notes unused symbols as cleanup candidates in the
documentation, including confidence percentages and safety assessment.

8.3 CLI: `repowise dead-code`

text
repowise dead-code [PATH]
  --min-confidence FLOAT    (default: 0.4)
  --safe-only               only show safe_to_delete=True findings
  --kind TYPE               filter by kind
  --package PKG             filter by monorepo package
  --format table|json|md
  --output FILE

repowise dead-code resolve [FINDING_ID]
  --status acknowledged|resolved|false_positive
  --note "reason"

8.4 Integration with Other Components

Dead code findings are stored in the dead_code_findings SQL table with a status
field (open / acknowledged / resolved / false_positive). The web UI shows
a dedicated dead code page with sortable tables, confidence sliders, and resolve
buttons. The graph visualization supports a "show dead code" filter that adds red
dashed borders to dead code nodes.


9. Decision Intelligence

The Decision Intelligence layer captures architectural decisions — the why behind how
the system is built, what alternatives were rejected, and what constraints exist. While documentation
describes what, decisions capture why.

Capture Sources

Decisions are extracted from four sources, each with a different confidence level:

Source Confidence Status Trigger
Inline markers (# WHY:, # DECISION:, # TRADEOFF:, # ADR:, # RATIONALE:, # REJECTED:) 0.95 active File scanning during init/update
Git archaeology (commit messages with migration/refactor signals) 0.70–0.85 proposed Init only, reuses git layer data
README/docs mining (implicit decisions in prose) 0.60 proposed Init only, LLM extraction
CLI capture (repowise decision add) 1.00 active Manual entry

Data Model

Stored in the decision_records SQL table. JSON arrays for alternatives, consequences,
affected files, modules, tags, and evidence commits. Deduplication key:
(repository_id, title, source, evidence_file).

Staleness Tracking

Decisions have a staleness_score (0.0 = fresh, 1.0 = very stale) computed during
repowise init and recomputed on every repowise update. Staleness rises when affected
files receive commits after the decision was recorded. Decisions with staleness_score > 0.5
are flagged as stale.

MCP Tools

  • get_why(query?) — three modes: natural language search over decisions, path-based lookup for decisions governing a file, or no-arg for decision health dashboard (stale decisions, ungoverned hotspots, proposed decisions needing review)
  • get_context(targets, include?) — includes decisions governing each target in its response

CLI Commands

text
repowise decision add        # interactive capture
repowise decision list       # tabular list with filters
repowise decision show <id>  # full detail
repowise decision confirm    # proposed → active
repowise decision dismiss    # dismiss proposed (sticky tombstone)
repowise decision deprecate  # active → deprecated
repowise decision health     # health summary

Key Files

File Purpose
core/analysis/decision_extractor.py All 4 capture sources + staleness computation
core/persistence/models.py DecisionRecord ORM model
core/persistence/crud.py 8 decision CRUD functions
server/mcp_server/tool_why.py MCP tool get_why (3-mode: search, path, health dashboard)
server/routers/decisions.py REST API endpoints
cli/commands/decision_cmd.py CLI command group (7 subcommands)

10. MCP Server

The MCP server is repowise's most valuable integration feature. It exposes the
entire wiki as a set of queryable tools that any MCP-compatible AI assistant
can call in real time.

Instead of an AI assistant reading 40 source files to understand a codebase,
it calls get_overview() and gets a structured, always-current architecture summary.
Instead of calling 5 tools one at a time, it calls get_context(["src/auth/service.py", "AuthService"]) and gets docs, ownership, decisions, and freshness for all targets in one call.

The server is implemented using the MCP Python SDK
and supports two transports:

  • stdio — for Claude Code, Cursor, Cline (add to their MCP config)
  • SSE — for web-based MCP clients (served on port 7338)

Tools (11 default in single-repo mode)

Canonical reference: docs/agent/MCP_TOOLS.md.
A single-repo server advertises 11 tools by default (ten flagship +
list_repos). Workspace mode adds get_architecture and get_blast_radius.
Four more are registered but opt-in (get_dependency_path,
get_execution_flows, generate_refactoring_code, get_conformance).

Tool What it answers When to call
get_overview Architecture summary, module map, entry points. First call when exploring an unfamiliar codebase.
get_answer One-call RAG: confidence-gated synthesis with cited answers. First call on any code question.
get_context(targets, include?) Docs, ownership, history, decisions, freshness for files/modules/symbols. Before reading or modifying specific code.
get_symbol Resolve a qualified symbol id to source body, signature, and docstring. When the question names a specific class, function, or method.
search_codebase(query) Hybrid symbol / path / wiki search. When you don't know where something lives.
get_risk(targets) Hotspot score, dependents, co-change partners, risk summary per target. Before modifying indexed files.
get_change_risk(revspec?) Live commit / range defect score from the diff itself. Before merging a commit or PR range.
get_why(query?) Architectural decisions and git archaeology. Before making architectural changes.
get_dead_code Dead/unused code findings sorted by confidence. Before cleanup tasks.
get_health Code-health marker scores (defect / maintainability / performance). Self-check before a PR or refactor.
list_repos Repo aliases this server is serving. Discover repo= targets (especially in a workspace).

Auto-generated Config

repowise init registers the MCP server directly where it can: Claude Code
(~/.claude/settings.json), Claude Desktop (if installed), VS Code
(.vscode/mcp.json), and the repo-shared .mcp.json. It also writes
.repowise/mcp.json for clients configured by hand. The completion panel says
which client was wired up and how Cursor or Codex connect (repowise mcp .).


11. REST API and Web UI

REST API (FastAPI)

Served on port 7337 alongside the web UI. All endpoints are prefixed with /api/.

Key routers:

  • /api/repos — register repos, trigger sync, full-resync (now launches background pipeline jobs with concurrent-run prevention)
  • /api/pages — read pages, version history, force-regenerate single page
  • /api/search — semantic (LanceDB or pgvector) and full-text (SQLite FTS5 / PostgreSQL tsvector) search
  • /api/jobs — job status, SSE stream for live progress updates
  • /api/symbols — symbol lookup, dependency path queries
  • /api/graph — graph export in D3-compatible JSON format
  • /api/webhooks/github — GitHub webhook handler with HMAC verification
  • /api/webhooks/gitlab — GitLab webhook handler
  • /api/repos/{id}/git-metadata — per-file git metadata
  • /api/repos/{id}/hotspots — high-churn + high-complexity files
  • /api/repos/{id}/ownership — ownership breakdown (file/module/package granularity)
  • /api/repos/{id}/co-changes — co-change partners for a file
  • /api/repos/{id}/git-summary — aggregate git health signals for dashboard
  • /api/repos/{id}/dead-code — dead code findings (GET list, POST trigger analysis)
  • /api/repos/{id}/dead-code/summary — aggregate dead code stats
  • /api/dead-code/{finding_id} — PATCH to resolve/acknowledge findings
  • /api/repos/{id}/claude-md — GET preview of generated CLAUDE.md section (JSON, no disk write)
  • /api/repos/{id}/claude-md/generate — POST to regenerate and write CLAUDE.md to disk
  • /health — liveness + readiness (checks DB + provider)
  • /metrics — Prometheus-compatible metrics (job counts, token totals, stale count)

Server lifecycle:

  • On startup, any jobs left in running state from a previous server instance are
    automatically reset to failed (crash recovery).
  • Background pipeline tasks are tracked in app.state.background_tasks to prevent
    garbage collection of asyncio.Task references.

Authentication is optional. Set REPOWISE_API_KEY to require bearer token auth on
all non-/health endpoints. Default (no key set): fully open, suitable for local use.

Web UI (Next.js 15)

Served from the same port as the API. All routes under /:

Route Content
/ Dashboard: all repos, recent jobs, stale page counts, token usage
/repos/[id] Repo layout with file tree sidebar
/repos/[id]/overview Overview dashboard — health score ring, attention panel, language donut, ownership treemap, hotspots mini, decisions timeline, module minimap, quick actions, active job banner
/repos/[id]/wiki/[...slug] Individual wiki page with MDX rendering
/repos/[id]/search Semantic search results
/repos/[id]/graph D3 force-directed dependency graph
/repos/[id]/symbols Full symbol index, sortable by PageRank
/repos/[id]/coverage Documentation coverage metrics
/repos/[id]/ownership Ownership treemap — files colored by primary owner, sized by LOC
/repos/[id]/hotspots Hotspot list — top 20 files with churn + complexity bars
/repos/[id]/dead-code Dead code report — three tabs: Files, Exports, Internals
/repos/[id]/decisions Architectural decision records
/repos/[id]/chat Codebase chat with streaming LLM responses
/settings Provider config, polling interval, cascade budget

Key rendering behavior:

Every backtick identifier in a wiki page that matches a known symbol becomes a
hover card showing the symbol's signature, file, and confidence score, plus a
link to the symbol's wiki page. This is built by post-processing the MDX content
client-side after initial render.

Mermaid diagrams are rendered lazily (only when scrolled into viewport) using
mermaid.js initialized with the repowise design theme.

Code blocks use Shiki for syntax highlighting. Each block has a "View in source"
button that deep-links to the relevant line in GitHub/GitLab.

The <GenerationProgress> component connects to /api/jobs/{id}/stream via SSE
and shows a live progress display during generation (pages done/total, current
file, tokens used, estimated cost, estimated time remaining).


12. Codebase Chat

repowise includes an interactive chat interface that lets users ask questions about
their codebase and receive answers grounded in the wiki, dependency graph, git
history, and architectural decisions. The chat agent uses whichever LLM provider
the user has configured and has access to 7 tools from the MCP surface
(see chat.md — not the full 11-tool MCP default).

See docs/architecture/chat.md for the full technical reference covering the
backend agentic loop, SSE streaming protocol, provider abstraction extensions,
database schema, frontend component architecture, and artifact rendering system.

Key design points:

  • Provider-agnostic — the chat agent goes through the same provider abstraction
    as documentation generation. A ChatProvider protocol extends BaseProvider with
    stream_chat() for streaming + tool use without breaking existing callers.
  • Tool reuse — the 7 chat tools are called directly as Python functions (no
    subprocess round-trip). Tool schemas are defined once in chat_tools.py and
    fed to both the LLM and the executor.
  • SSE streaming — POST /api/repos/{repo_id}/chat/messages runs the agentic
    loop and streams back Server-Sent Events (text_delta, tool_start,
    tool_result, done, error).
  • Conversation persistence — chat history is stored in conversations and
    chat_messages tables, allowing replay and continuation across page refreshes.
  • Artifact panel — tool results with rich content (wiki pages, Mermaid diagrams,
    search results, risk reports) open in a slide-in artifact panel that reuses
    existing frontend components.

13. Data Flow Diagrams

Init flow

text
/* Detailed source-code truncated for AI context efficiency. */

Maintenance flow (on git push)

text
git push
    │
    ├── GitHub webhook → POST /api/webhooks/github
    │        │ (verified, stored in webhook_events)
    │        ▼
    │   GenerationJob created (type: incremental)
    │
    └── (fallback) APScheduler polls every 15min
             │ (if HEAD != last_sync_commit)
             ▼
        GenerationJob created (type: incremental)

GenerationJob runs:
    │
    ▼
ChangeDetector.get_changed_files(since=last_sync_commit)
    │
    ├── Renamed files → update source_files refs in SQL
    ▼
GitIndexer.index_changed_files(changed_file_paths)
    │ re-indexes only changed files + their co-change partners
    │ updates co-change edges in graph
    ▼
ChangeDetector.detect_symbol_renames()
    │
    ▼
ChangeDetector.get_affected_pages(cascade_budget=30)
    │ includes co-change partners in staleness propagation
    │
    ├── regenerate: list[page_id]  → full regeneration (up to cascade_budget)
    ├── rename_patch: list[page_id] → targeted text find-and-replace
    └── decay_only: list[page_id]  → confidence score decay, add to staleness queue
    │
    ▼
For each page in `regenerate`:
    ContextAssembler → LLMProvider → update SQL + VectorStore + Graph
    │
    ▼
For each page in `rename_patch`:
    scan content_md for old symbol name → replace → update SQL
    │
    ▼
For each page in `decay_only`:
    confidence_score *= decay_factor
    regen_queued = True
    │
    ▼
repos.last_sync_commit = HEAD
state.json updated

Server-triggered pipeline flow

text
Web UI "Sync" / "Full Re-index" button
    │
    ▼
POST /api/repos/{id}/sync (or /full-resync)
    │
    ├── Check: no pending/running job for this repo (else → 409)
    ▼
Create GenerationJob (status=pending, commit)
    │
    ▼
asyncio.create_task(execute_job(job_id, app_state))
    │  (strong ref in app.state.background_tasks)
    ▼
execute_job():
    ├── Mark job "running"
    ├── Resolve LLM provider from server config
    ├── run_pipeline() with JobProgressCallback
    │       └── writes progress to GenerationJob table every 5 items
    ├── persist_pipeline_result() (shared with CLI)
    ├── FTS index new pages
    ├── drain_and_stop() progress tasks
    └── Mark job "completed" (or "failed" on error)

MCP query flow

text
Claude Code: "how does the auth module work?"
    │
    ▼
MCP client calls repowise tool: search_codebase(query="auth module")
    │
    ▼
VectorStore similarity search (LanceDB or pgvector) → top 5 page IDs
    │
    ▼
SQL: fetch page content for those IDs
    │
    ▼
Return: list[{title, content_md snippet, relevance_score, confidence_score}]
    │
    ▼
Claude Code has structured, current documentation
instead of reading 40 files

14. Key Design Decisions

Three-layer folder exclusion, not one monolithic list

repowise offers three complementary ways to skip paths during traversal, each solving
a different problem:

  1. Root .repowiseIgnore — project-wide exclusions committed to the repo (like
    .gitignore). Shared across all users, version-controlled.

  2. Per-directory .repowiseIgnore — exclusions that only apply within a subtree.
    Lets a monorepo package owner exclude its own generated output without polluting
    the root ignore file. Patterns are relative to the directory, matching git semantics.
    Specs are loaded once per directory during os.walk and cached by absolute path;
    the root spec is pre-seeded in the cache to avoid reading it twice.

  3. extra_exclude_patterns (runtime) — patterns injected without touching any
    file on disk: from --exclude/-x CLI flags (dev workflow), from
    config.yaml exclude_patterns (persisted per repo), or from
    repo.settings["exclude_patterns"] (Web UI / REST API). This lets teams configure
    exclusions through the UI without git access to the target repo.

All three layers use pathspec with gitwildmatch semantics — the same library used
for .gitignore parsing — so the full gitignore syntax works everywhere.

`save_config()` round-trips YAML, not overwrites

The original save_config() wrote a fixed three-key file (provider, model,
embedder). This meant any other keys — such as exclude_patterns set via the Web UI
— would be silently dropped the next time repowise init ran. The updated function
loads the existing config, merges in the new values, then writes the result back.
This ensures all config sources (CLI, Web UI, REST API, manual edits) coexist safely.

One `ASTParser` class, not one per language

Per-language differences live in .scm query files and LANGUAGE_CONFIGS dict entries.
This means adding a new language requires no changes to Python business logic — just
a new .scm file and a config entry. The ASTParser class itself never has
if lang == "python" branches.

LanceDB (embedded) + pgvector, not ChromaDB

ChromaDB was the original choice but has notable drawbacks: slow write throughput on
large repos, heavy C++ build dependencies (chroma-hnswlib), and a bloated dependency
tree. LanceDB replaces it as the default embedded vector store:

  • No build step — pure Python wheel, no C++ compiler required on Windows
  • Faster writes — Lance columnar format is optimised for batch appends; embedding
    50K pages during repowise init is measurably faster
  • Faster queries — IVF-PQ and HNSW index support with sub-millisecond ANN search
  • Simpler data model — tables are Arrow-native; filtering by repo_id or page_type
    uses SQL-style predicates alongside the vector search, no separate metadata store needed
  • Zero server process — same embedded story as ChromaDB: data lives in
    .repowise/lancedb/, no container needed

When PostgreSQL is already in use (multi-worker prod), pgvector is preferred because
it eliminates the second storage system entirely: embeddings live as a vector column
in the existing wiki_pages table, queries are plain SQL with <=> cosine distance,
and backup/restore is a single pg_dump. The HNSW index (CREATE INDEX ... USING hnsw)
gives query latency on par with LanceDB at typical repowise dataset sizes.

The VectorStore abstraction in packages/core/src/repowise/core/persistence/vector.py
selects the backend at startup based on DATABASE_URL: SQLite → LanceDB, PostgreSQL → pgvector.

NetworkX + SQLite fallback, not Neo4j

NetworkX is a Python library that runs in-process. Neo4j is a separate server
(Java process, GBs of RAM, authentication setup). For a graph that is derived
from source code (rebuildable at any time), the operational overhead of Neo4j
is unjustified. NetworkX handles tens of thousands of nodes comfortably.

The SQLite-backed fallback using networkit handles repos that genuinely exceed
in-memory limits without adding a server dependency.

Confidence scores, not binary "fresh/stale"

A binary fresh/stale flag would require either: (a) marking everything stale on
every push (triggering full regen, expensive), or (b) only marking directly changed
pages (missing transitive effects). The confidence score model captures the gradient:
directly changed pages decay sharply, indirectly referenced pages decay gently.
Users can see at a glance how much to trust each page.

Jinja2 prompts, not hardcoded strings

All LLM prompts are Jinja2 templates in packages/core/queries/prompts/. Users can
override any prompt by placing a file with the same name in .repowise/prompts/.
This lets power users tune generation quality without forking the project.

Cascade budget

Without a budget, changing a central utility (imported by 200 files) would
regenerate 200+ pages on every push. With a budget (default: 30), repowise
regenerates the highest-PageRank affected pages immediately and defers the rest
to the nightly background job. No page stays stale indefinitely; the nightly job
catches everything the cascade budget missed.

Git metadata in generation prompts, not just for display

The highest-value use of git metadata is enriching the LLM's generation context —
not just showing ownership in a sidebar. By including significant commit messages
in the prompt, the LLM can explain why code is structured a certain way (e.g.,
"this was refactored in March 2024 to separate auth concerns from the request
pipeline"). This context is unavailable from static analysis alone.

Co-change edges as a separate graph layer

Co-change relationships are temporal coupling — they cannot be detected by AST
parsing. repowise adds them as co_changes edges after GitIndexer runs, but
deliberately filters them out of PageRank computation (they would skew it
artificially toward files that are often edited together for process reasons, not
architectural ones). They participate in change propagation and visualization only.

Conservative dead code detection

repowise's dead code detector errs heavily toward false negatives. safe_to_delete
is set to True only at confidence >= 0.7 and after excluding dynamically-loaded
patterns. Dead code analysis is pure graph traversal + SQL (no LLM calls), so it
completes in seconds and can be re-run cheaply. repowise surfaces candidates —
humans decide before deleting anything.

Async-first throughout

All database operations use async SQLAlchemy with aiosqlite. The event loop
is never blocked. This matters during generation: the LLM call, the DB write,
and the vector store embed (LanceDB or pgvector) can overlap with the next file's context assembly.


15. Editor File Generation

See architecture/editor-files.md for the complete reference covering
architecture, all data sources, how the marker-merge system works, and how to add
support for a new editor file (cursor.md, copilot-instructions.md, etc.).

Quick summary: repowise can generate and maintain AI-editor configuration files
(CLAUDE.md, cursor.md, etc.) from the already-indexed codebase data — no LLM calls.
The system uses HTML comment markers to split the file into a user-owned section and
a Repowise-managed section. The user section is never touched.

The feature runs automatically after repowise init and repowise update, and can
also be run standalone:

text
repowise generate-claude-md [PATH]

Config opt-out:

yaml
# .repowise/config.yaml
editor_files:
  claude_md: false

Key files:

File Purpose
core/generation/editor_files/base.py BaseEditorFileGenerator — marker-merge logic shared by all editor-file generators
core/generation/editor_files/data.py EditorFileData frozen dataclass — the data contract between fetcher and template
core/generation/editor_files/fetcher.py EditorFileDataFetcher — queries DB for architecture summary, modules, hotspots, decisions
core/generation/editor_files/tech_stack.py Filesystem scan for languages, frameworks, build commands
core/generation/editor_files/claude_md.py ClaudeMdGenerator — 30-line subclass that binds filename + template
core/generation/templates/claude_md.j2 Jinja2 template for the Repowise-managed section
cli/commands/claude_md_cmd.py repowise generate-claude-md CLI command
server/routers/claude_md.py GET/POST /api/repos/{id}/claude-md REST endpoints

16. Adding a New Language

  1. Write packages/core/queries/<language>.scm

    Use tree-sitter S-expression syntax. Follow the capture name conventions:
    @symbol.def, @symbol.name, @symbol.params, @symbol.return_type,
    @symbol.docstring, @import.statement, @import.module, @import.names.

    Check the tree-sitter playground for your language's node type names:
    https://tree-sitter.github.io/tree-sitter/playground

  2. Add a LanguageConfig entry to LANGUAGE_CONFIGS in parser.py

    python
    "mylang": LanguageConfig(
        symbol_node_types={
            "function_definition": "function",
            "class_definition": "class",
        },
        import_node_types=["import_statement"],
        export_node_types=[],
        visibility_fn=lambda name, mods: "private" if name.startswith("_") else "public",
        entry_point_patterns=["main.ml", "app.ml"],
    ),
  3. Add a LanguageSpec to LanguageRegistry in ingestion/languages/registry.py

    This registers the language's identity data (extensions, entry points, manifest files,
    builtin calls, heritage node types, etc.) centrally.

  4. Add the grammar dependency to pyproject.toml

    toml
    "tree-sitter-mylang>=0.23,<1",
  5. Add test files to tests/fixtures/sample_repo/

    At minimum: one file with a function, one with a class, one with imports.

  6. (Optional) Add per-language extractors for bindings, heritage, visibility, docstrings,
    and a dedicated import resolver in resolvers/mylang.py.

  7. Run pytest tests/unit/test_parser.py -k mylang to verify extraction.

  8. Open a PR. That's it — no other changes needed.


17. Adding a New LLM Provider

  1. Create packages/core/providers/<name>.py

    Subclass LLMProvider and implement:

    • generate(request: GenerationRequest) -> GenerationResponse
    • generate_stream(request: GenerationRequest) -> AsyncIterator[str]
    • embed(request: EmbedRequest) -> EmbedResponse
    • name property

    Optionally override:

    • supports_batch → True if the provider has a batch API
    • generate_batch(requests) -> list[GenerationResponse]
    • estimate_cost(input_tokens, output_tokens) -> float
  2. Register in providers/registry.py

    python
    case "myprovider": return MyProvider(config)
  3. Add config section to .repowise/config.yaml docs

    yaml
    myprovider:
      api_key: ${MYPROVIDER_API_KEY}
      base_url: https://api.myprovider.com/v1
  4. Add default rate limits to PROVIDER_DEFAULT_LIMITS in rate_limiter.py

  5. Add a MockProvider fixture for tests if the provider has unique response formats

  6. Update CONTRIBUTING.md with the new provider's environment variables


Appendix: Configuration Reference

Full configuration with defaults (.repowise/config.yaml):

text
/* Detailed source-code truncated for AI context efficiency. */

Architecture/Chat

Codebase Chat — Technical Reference

The codebase chat feature lets users have an interactive conversation with their
codebase. The agent uses whichever LLM provider the user has configured, has
access to 7 tools from the MCP surface (a curated chat subset — not the full
11-tool MCP default), and streams responses back to the browser in real time
showing tool calls as they happen and rendering results in an artifact panel.


Table of Contents

  1. Architecture Overview
  2. Database Schema
  3. ChatProvider Protocol
  4. Tool Registry
  5. Provider Configuration
  6. SSE Streaming Protocol
  7. Agentic Loop
  8. REST API Endpoints
  9. Frontend Architecture
  10. Provider-Specific Notes

1. Architecture Overview

text
User types question
        |
        v
POST /api/repos/{repo_id}/chat/messages
        |
        v
+------ Chat Router (SSE stream) ------+
|                                       |
|  1. Create/load conversation          |
|  2. Save user message to DB           |
|  3. Build LLM message history         |
|  4. Call provider.stream_chat()  <----+---- tool_executor callback
|        |                              |
|        v                              |
|  5. Stream text_delta events -------> SSE to browser
|  6. On tool_start:                    |
|     - Execute tool (or provider       |
|       executes internally)            |
|     - Emit tool_result event -------> SSE to browser
|  7. If tool calls found:             |
|     - Append to history, loop to 4   |
|  8. If no tool calls:                |
|     - Save assistant message to DB   |
|     - Emit done event                |
+---------------------------------------+

The agentic loop is in the chat router for most providers (OpenAI, Anthropic,
Ollama, LiteLLM). For Gemini, the loop runs inside stream_chat() using native
Content objects to preserve thought signatures. The router passes a
tool_executor callback that Gemini calls internally.


2. Database Schema

Two tables added in migration 0005_chat_conversations.py:

`conversations`

Column Type Notes
id String(32) PK UUID hex
repository_id String(32) FK CASCADE delete
title Text Auto-generated from first 6 words
created_at DateTime(tz)
updated_at DateTime(tz) Auto-updated on new messages

Index: ix_conversations_repo_updated on (repository_id, updated_at)

`chat_messages`

Column Type Notes
id String(32) PK UUID hex
conversation_id String(32) FK CASCADE delete
role String(32) user or assistant
content_json Text JSON blob (see below)
created_at DateTime(tz)

Index: ix_chat_messages_conv_created on (conversation_id, created_at)

Message content format

User messages:

json
{"text": "What does the auth module do?"}

Assistant messages:

json
{
  "text": "The auth module handles...",
  "tool_calls": [
    {
      "id": "call_abc123",
      "name": "get_context",
      "arguments": {"targets": ["src/auth"]},
      "result": { ... }
    }
  ]
}

3. ChatProvider Protocol

Defined in packages/core/src/repowise/core/providers/base.py.

The existing BaseProvider.generate() is untouched. A new ChatProvider
protocol class (using typing.Protocol + @runtime_checkable) adds streaming
chat with tool use as an opt-in capability.

python
@runtime_checkable
class ChatProvider(Protocol):
    def stream_chat(
        self,
        messages: list[dict],          # OpenAI-format message list
        tools: list[dict],             # OpenAI-format tool definitions
        system_prompt: str,
        max_tokens: int = 8192,
        temperature: float = 0.7,
        request_id: str | None = None,
        tool_executor: Any | None = None,  # async callable(name, args) -> dict
    ) -> AsyncIterator[ChatStreamEvent]: ...

Supporting dataclasses:

  • ChatToolCall(id, name, arguments) — a tool call the LLM wants to make
  • ChatStreamEvent(type, text?, tool_call?, tool_result_data?, stop_reason?, input_tokens, output_tokens) — a single event in the stream

Event types:

type Populated fields Meaning
text_delta text Incremental text token(s)
tool_start tool_call LLM wants to call a tool
tool_result tool_call, tool_result_data Tool executed (by provider internally)
usage input_tokens, output_tokens Token usage update
stop stop_reason Generation ended (end_turn, tool_use, max_tokens)

Implementations: Anthropic, OpenAI, Gemini, Ollama, LiteLLM. All accept the
tool_executor parameter; only Gemini uses it (for thought signature handling).


4. Tool Registry

Defined in packages/server/src/repowise/server/chat_tools.py.

Single source of truth for chat tool schemas and execution. Imports 7 MCP
tool functions from repowise.server.mcp_server (the chat agent does not
advertise the full MCP default surface — no get_answer, get_symbol,
get_health, or list_repos here).

python
TOOL_REGISTRY: dict[str, ToolDef]  # name -> ToolDef(name, description, parameters, function, artifact_type)

Key functions:

Function Purpose
get_tool_schemas_for_llm() Returns OpenAI-format tool definitions for the LLM
execute_tool(name, args) Runs a tool and ensures JSON-serializable output
get_artifact_type(name) Maps tool name to frontend artifact type
init_tool_state(...) Bridges FastAPI app state to MCP module globals

Tool to artifact type mapping (7 tools):

Tool Artifact Type
get_overview overview
get_context wiki_page
get_risk risk_report
get_change_risk risk_report
get_why decisions
search_codebase search_results
get_dead_code dead_code

5. Provider Configuration

Defined in packages/server/src/repowise/server/provider_config.py.

API keys and active provider/model selection are stored in a server-side
provider_config.json file. Environment variables take precedence over stored
keys.

Resolution order for API keys:

  1. Environment variable (e.g. GEMINI_API_KEY, ANTHROPIC_API_KEY)
  2. Stored key in provider_config.json

Active provider resolution:

  1. Explicitly set via PATCH /api/providers/active
  2. Auto-detect from first configured provider

Provider catalog: Gemini, Anthropic, OpenAI, Ollama (local, no key), LiteLLM.


6. SSE Streaming Protocol

The chat endpoint returns Content-Type: text/event-stream. Each event is:

text
event: data
data: {"type": "...", ...}

Event shapes:

jsonc
// Incremental text from the LLM
{"type": "text_delta", "text": "The auth module..."}

// LLM wants to call a tool
{"type": "tool_start", "tool_id": "call_123", "tool_name": "get_context", "input": {"targets": ["src/auth"]}}

// Tool execution completed
{"type": "tool_result", "tool_id": "call_123", "tool_name": "get_context", "summary": "Context for 1 target(s)", "artifact": {"type": "wiki_page", "data": {...}}}

// Stream complete
{"type": "done", "conversation_id": "abc123", "message_id": "def456"}

// Error
{"type": "error", "message": "Provider error: ..."}

Headers: Cache-Control: no-cache, X-Accel-Buffering: no, Connection: keep-alive

Retry: retry: 3000 sent at stream start.

Terminal event: every stream ends with done or error on the data
channel. useChat switches on type and nothing else, so an event sent on a
different channel, or without a type, is dropped: the client then sees a
stream that simply stopped mid-answer. The client settles its own state when
the reader ends without a terminal event, but the server still owes it one.


7. Agentic Loop

The loop runs up to 10 iterations per request.

text
for each iteration:
    1. Call provider.stream_chat(messages, tools, system_prompt, tool_executor)
    2. Collect text_delta events -> stream to client
    3. Collect tool_start events -> stream to client
    4. Collect tool_result events (from internal execution) -> stream to client
    5. If there are pending tool calls (not internally executed):
       a. Execute each tool
       b. Emit tool_result to client
       c. Append assistant + tool results to message history
       d. Continue loop
    6. If no tool calls: break

After the loop, the assistant message (text + all tool calls with results) is
saved to the database and a done event is emitted.


8. REST API Endpoints

Chat

Method Path Description
POST /api/repos/{repo_id}/chat/messages SSE stream — send a message and get a streaming response
GET /api/repos/{repo_id}/chat/conversations List conversations for a repo
GET /api/repos/{repo_id}/chat/conversations/{id} Get conversation with all messages
DELETE /api/repos/{repo_id}/chat/conversations/{id} Delete a conversation

POST body:

json
{
  "message": "What does the auth module do?",
  "conversation_id": null,
  "provider": null,
  "model": null
}

conversation_id — omit or null to start a new conversation.
provider / model — optional per-request overrides.

Providers

Method Path Description
GET /api/providers List all providers with status and active selection
PATCH /api/providers/active Set active provider and model
POST /api/providers/{id}/key Store an API key
DELETE /api/providers/{id}/key Remove an API key

9. Frontend Architecture

API Layer (`src/lib/api/`)

  • chat.ts — listConversations, getConversation, deleteConversation, postChatMessage (returns raw Response for SSE reading)
  • providers.ts — getProviders, setActiveProvider, addProviderKey, removeProviderKey

Hooks (`src/lib/hooks/`)

  • useChat(repoId) — full chat state machine. Uses fetch + ReadableStream (not EventSource, which is GET-only). Manages messages, streaming state, conversation ID, error handling, and abort control. Exposes sendMessage, loadConversation, reset.
  • useProviders() — SWR wrapper for provider management. Exposes providers, activeProvider, activeModel, activate, saveKey, removeKey.

Components (`src/components/chat/`)

Component Purpose
ChatInterface Main container — empty state (greeting + suggestions + model selector) and active state (message list + input)
ChatMessage Renders user bubble or assistant message (tool blocks + markdown)
ChatMarkdown Client-side markdown renderer using react-markdown + remark-gfm with design token styling
ToolCallBlock Inline tool call visualization — running (spinner), done collapsed (checkmark + summary), done expanded (input/output JSON)
ArtifactPanel Right slide-in panel with tabs for multiple artifacts. Renders by type: markdown, Mermaid diagrams, search results, raw JSON
ModelSelector Compact popover for switching provider/model and adding API keys inline
ConversationHistory Dropdown listing past conversations with delete and new-conversation actions

Page Structure

The repo landing page (/repos/[id]) is the chat interface:

  • Compact header (repo name + commit badge + branch badge)
  • ChatInterface filling remaining viewport height
  • Sidebar nav item updated from "Overview" to "Chat"

All other repo sub-pages (graph, wiki, coverage, etc.) are unchanged.


10. Provider-Specific Notes

Anthropic

Uses client.messages.stream() with native Anthropic message format. Converts
OpenAI-format messages to Anthropic format (tool results as user role with
tool_result content blocks, tool calls as tool_use content blocks). The
agentic loop runs in the chat router.

OpenAI

Uses client.chat.completions.create(stream=True). Native OpenAI format —
minimal conversion needed. Tool call fragments are accumulated across stream
chunks and emitted as complete tool_start events. The agentic loop runs in
the chat router.

Gemini

Uses client.models.generate_content() (non-streaming, in a thread pool).
Runs the agentic loop internally via the tool_executor callback to
preserve thought_signature on function call parts. Gemini's API requires
these signatures when replaying function calls in conversation history; the
OpenAI-format round-trip through the router would lose them. Native Content
objects are used throughout the internal loop.

Ollama

Uses the OpenAI-compatible endpoint (localhost:11434/v1) via AsyncOpenAI.
Same streaming pattern as OpenAI. The agentic loop runs in the chat router.

LiteLLM

Uses litellm.acompletion(stream=True). OpenAI-compatible streaming. The
agentic loop runs in the chat router.


Architecture/Code Health

Code Health: Architecture & Internals

Companion to the user-facing docs/layers/CODE_HEALTH.md. This
document is for contributors: where every piece lives, how data flows from
parsed source to the dashboard, and the extension points for adding
markers, languages, coverage formats, or alerts.

TL;DR. Health analysis is a deterministic, zero-LLM Python pipeline:
tree-sitter walks every file once -> markers vote -> scores aggregate per
category -> results land in four SQLAlchemy tables. The MCP server, CLI,
and Next.js dashboard all read from those tables: no JSON cache, no
intermediate files, no LLM in the loop.


1. Layer overview

Code Health is the fifth intelligence layer in Repowise, alongside Graph,
Git, Docs, and Decisions. It reads from Graph and Git but never modifies
them. Its only writes are to its own four tables.

text
/* Detailed source-code truncated for AI context efficiency. */

Three architectural rules govern the whole layer:

  1. Zero LLM. Every marker is AST, git, or coverage math.
  2. No JSON caches. SQLite is the single source of truth; everything
    else reads from it.
  3. No new runtime dependencies. Pure Python over tree-sitter (already
    in tree). No lizard, no jscpd, no Node.

2. Where things live

Python: `packages/core/src/repowise/core/`

text
/* Detailed source-code truncated for AI context efficiency. */

Persistence

text
core/persistence/
├── models.py                       # HealthFinding, HealthFileMetric, HealthSnapshot, CoverageFile
└── crud.py                         # save_/upsert_/get_ health functions
core/alembic/versions/
└── 000X_health_tables.py           # migration that created the four tables

Pipeline wiring

text
core/pipeline/
├── orchestrator.py                 # _run_health_analysis(): builds module_map, runs analyzer
└── persist.py                      # persist_pipeline_result(): writes findings/metrics/snapshot

CLI

text
cli/src/repowise/cli/commands/
├── health_cmd.py                   # repowise health [--trend|--refactoring-targets|--module]
├── status_cmd.py                   # `Health: 7.4 (avg) · 6.2 (hotspots) · 2.1 (worst: ...)`
└── update_cmd.py                   # incremental path: HealthAnalyzer.analyze(changed_files=...)

Server: MCP + REST

text
server/src/repowise/server/
├── mcp_server/
│   ├── tool_health.py              # @mcp.tool get_health(targets, include, repo, limit)
│   ├── tool_risk.py                # enriched: health_score, top_biomarkers, coverage_pct
│   ├── tool_context.py             # include=["health"]: score, top 2 biomarkers, suggestion
│   └── tool_overview.py            # code_health block with KPIs
└── routers/
    └── code_health.py              # /api/repos/{id}/health/{overview,files,coverage,
                                    # refactoring-targets,modules,findings}

Web dashboard

text
packages/ui/src/health/             # shared React components (used by web + future hosted frontend)
├── kpi-cards.tsx
├── file-table.tsx
├── biomarker-list.tsx
├── coverage-bar.tsx
├── module-coverage-list.tsx
├── untested-hotspot-warning.tsx
├── refactoring-card.tsx
├── refactoring-target-list.tsx
├── health-badge.tsx               # score pill, colored by the 3 health bands
├── health-distribution-bar.tsx    # NLOC-weighted Alert/Warning/Healthy split
├── trend-chart.tsx                # repo KPI history (3 series)
├── file-trend-chart.tsx           # single file's score-over-time + delta + declining flag
├── sparkline.tsx                  # compact inline series (drawer trend)
└── module-rollup-list.tsx

packages/web/src/app/repos/[id]/health/
├── page.tsx                        # KPIs + lowest-scoring files + per-module rollup
├── coverage/page.tsx               # /health/coverage view
└── refactoring-targets/page.tsx    # /health/refactoring-targets view

packages/web/src/components/health/
└── health-risks-panel.tsx          # sidecar panel on Hotspots/Ownership/Graph pages

Tests

text
tests/unit/health/                  # 99+ tests
├── test_complexity_walker.py       # per-language CCN/nesting assertions
├── test_biomarkers.py
├── test_structural_biomarkers.py   # bumpy_road, large_method, primitive_obsession
├── test_coverage_biomarkers.py     # untested_hotspot, coverage_gap
├── test_organizational_biomarkers.py
├── test_dry_violation.py
├── test_duplication.py             # tokenizer, hash, detector
├── test_coverage_parsers.py        # LCOV/Cobertura/Clover/JSON
├── test_scoring.py                 # category caps, clamping
├── test_scoring_snapshot.py        # stability snapshot: locks caps + deductions
├── test_health_config.py           # .repowise/health-rules.json
├── test_trends.py                  # diff_snapshots, declining/predicted alerts
├── test_signals.py                 # file_signals join + no-signal/normalization
├── test_churn_complexity.py        # churn × complexity point shaping + sort + filtering
└── test_suggestions.py

tests/integration/
├── test_health_coverage_integration.py
└── test_health_perf_benchmark.py   # 30 s budget on 3,000-file synthetic repo (slow)

3. The pipeline (init path)

repowise init runs run_pipeline() in core/pipeline/orchestrator.py.
_run_health_analysis() is a phase in that orchestrator, called between
_run_dead_code_analysis() and _run_decision_extraction(). It does four
things:

  1. Builds a {file_path: community label} map from the graph's community
    detection so HealthFileMetric.module is populated (module rollups are
    never NULL).
  2. Loads per-file override rules from .repowise/health-rules.json via
    HealthConfig.load(repo_path) (a no-op when the file is absent) and
    resolves them to per-file disabled sets with to_analyzer_config().
  3. Constructs the HealthAnalyzer with everything it needs: the NetworkX
    graph (for dependents), git_meta_map (hotspot bit, owners, co-change, bus
    factor), the parsed_files from the AST phase, and the module map.
  4. Picks the sync or parallel path by repo size. tree-sitter releases the GIL
    during parsing, so on repos with >= 500 parsed files analyze_async()
    (asyncio gather over worker threads) gives a real wall-clock speedup;
    smaller repos run analyze() on a single thread.

The returned report rides on PipelineResult.health_report. Then
core/pipeline/persist.py writes it in one session: save_health_metrics,
save_health_findings (only when there are findings), and a
save_health_snapshot carrying the three KPIs plus a {path: score} map for
trend tracking (rolling 50-row window per repo), and a second
{path: total_deduction} map covering only the files whose score is held at
the floor. Both maps come from trends.snapshot_file_maps, which the other two
snapshot writers (repowise health and repowise upgrade) also call — a repo
whose writers disagreed would get a history whose depth changed depending on
which command last wrote it.


4. Inside `HealthAnalyzer.analyze()`

A single pass over the parsed file list. For each file the analyzer:

  1. Walks the AST (_walk): walk_file(language, source) returns a
    FileComplexity of functions and classes. Each FunctionComplexity carries
    name, line range, nloc, ccn, max nesting, cognitive complexity, bumps, and
    param count; each ClassComplexity carries method count, total nloc, the
    method list, LCOM4, max method ccn, and field count.
  2. Populates symbol complexity (_populate_symbol_complexity): writes
    max(ccn) into Symbol.complexity_estimate as a side effect, so the
    ContextAssembler symbol ranker benefits even when a caller never touches
    the health tables.
  3. Evaluates the file (_evaluate_file): builds a FileContext (nloc,
    has_test_file, module, per-function and per-class metrics, the per-file
    git_meta, graph in-degree as dependents_count, the repo-wide p80 of
    in-degree used as the brain_method floor, coverage fields when ingested,
    and the file's clone slice), runs detect_all(), scores with score_file(),
    and attaches per-finding impacts.

After the loop, compute_kpis() runs over the metrics and the set of hotspot
paths (git_meta_map[path]["is_hotspot"]), and the analyzer returns a
HealthReport(findings, metrics, kpis).

Duplication runs once up-front (it is cross-file by nature); each
FileContext gets a slice of the global clone report. The dry_violation
marker reads ctx.clones and ranks pairs by co-change frequency from
git_meta_map[path]["co_change_partners_json"], so active clones rank higher
than dormant ones.


5. The markers and their categories

Each marker is a stateless class implementing the Biomarker Protocol from
biomarkers/base.py: a name ("brain_method", "nested_complexity", ...), a
category (see scoring.CATEGORY_CAPS), and a detect(ctx: FileContext) method
returning a list of BiomarkerResults.

The full roster

biomarkers/registry.py registers 49 detectors; counting the three
governance findings written by the additive pass (governance.py) there
are 52 marker ids. They divide by what each is permitted to affect:

Group Count Scores into
Defect-scoring 26 defect (8 of them also maintainability)
Performance 20 performance only
SQL 3 maintainability only
Governance 3 nothing — the finding surfaces, the score is untouched

The authority is scoring._BIOMARKER_DIMENSIONS. Any biomarker not listed
there defaults into defect, which is why every sql_* and every performance
name must be listed explicitly: an omission would silently break the defect
golden guarantee (§6).

Defect categories and caps

Category Cap Markers
Organizational −3.5 developer_congestion, knowledge_loss, hidden_coupling, function_hotspot, code_age_volatility, ownership_risk, churn_risk, change_entropy, co_change_scatter, prior_defect, ungoverned_hotspot†, stale_governance†, contradictory_decision†
Structural complexity −2.5 brain_method, low_cohesion, god_class, nested_complexity, bumpy_road, complex_conditional
Test coverage −2.0 untested_hotspot, coverage_gap
Test coverage gradient −2.0 coverage_gradient
Size & complexity −1.5 complex_method, large_method, primitive_obsession
Duplication −1.0 dry_violation
Test quality −0.5 large_assertion_block, duplicated_assertion_block
Error handling −0.5 error_handling

† The three governance markers carry a category and a weight, but the pass that
writes them runs after scoring completes and never touches
HealthFileMetric.score — so in practice they never deduct. They are counted
in the table above because scoring.py maps them, not because they move a
number.

The maintainability dimension has its own independent tables
(_MAINTAINABILITY_CATEGORY, caps: structural_complexity 4.0,
size_and_complexity 2.0, duplication 2.0, error_handling 2.0, sql 2.0), and the
performance dimension a single performance category capped at 2.0. See §6.

large_assertion_block and duplicated_assertion_block are the two
test-quality smells (see §5.3). They fire only on test files and sit in
a deliberately small category so a noisy test can't dominate its own score.
large_method is now gated on a minimal CCN floor (≥ 2) so a long-but-flat
body (a big data literal) reads as layout, not a complexity smell: a small
step toward decoupling the score from raw file size.

ownership_risk (long-run minor-contributor dispersion, Bird et al.) and
churn_risk (size-normalized relative churn, Nagappan-Ball) are git-only
process signals computed from top_authors_json / lines_added_90d /
churn_percentile: fields the git indexer already produces. change_entropy
(Hassan's History Complexity Metric) and co_change_scatter (breadth of
co-change coupling, D'Ambros) are likewise git-only and read the
change_entropy / change_entropy_pct fields (see §5.1) and
co_change_partners_json. knowledge_loss is activity-gated so
abandoned-but-stable files (the survivor effect) no longer fire.

prior_defect (recent bug-fix history, Ostrand-Weyuker / Kim's "bug cache")
is the other git-only process signal: the count of bug-fix commits touching a
file in the trailing ~6-month window, read from prior_defect_count. The
git indexer classifies a commit as a fix with the same keyword rule the
defect benchmark labels fixes with
(_constants.is_fix_commit), counts only
non-merge commits inside the window, and anchors the window to the index's
as_of reference (REPOWISE_GIT_WINDOW_ANCHOR): so scoring a historical T0
checkout measures the fixes before T0, never leaking the post-T0 fixes that
form the benchmark's labels. It carries a neutral (1.0) weight by design:
on the calibration corpus prior-defect history is largely redundant with the
existing process signals (correlation ≈ +0.59 with change_entropy, +0.38 with
churn; calibrated coefficient ≈ +0.02, effort-aware Popt gain within bootstrap
noise), so it is not boosted as a predictor. It ships for its explanatory
value, not for a measured accuracy lift: "this file was bug-fixed N times
recently" is immediately actionable, and it uniquely flags a few files the
other signals miss.

coverage_gradient makes the test-coverage signal continuous. The two
binary coverage gates (untested_hotspot, coverage_gap) only fire below hard
thresholds (≈40–60% line coverage), so on a well-tested codebase (where most
files sit at 85–99%) the score is effectively blind to coverage even though the
uncovered fraction still carries defect signal. coverage_gradient deducts
health in direct proportion to that fraction: 4.0 × (1 − line_coverage_pct/100)
health points, clamped by its category cap (binding at ≥50% uncovered). It uses
the deduction override on BiomarkerResult (a continuous magnitude that
replaces the discrete severity-to-deduction table for that finding) so it stays
linear and per-finding attributable (the health_impact contract holds). It
is silent when no coverage report was ingested (line_coverage_pct is None):
absent coverage is never imputed as uncovered. It lives in its own capped
category (test_coverage_gradient, −2.0) so the additive continuous signal
neither squeezes nor is squeezed by the binary gates, and it skips test files.
Calibrated offline against the defect corpus, it recovers +0.043 corpus AUC
[95% CI +0.023, +0.061]
on the covered subset (≈65% of the continuous-feature
ceiling), Popt-neutral, and is exactly zero on repos without ingested coverage:
a purely additive improvement.

low_cohesion (LCOM4) and god_class are the two class-level
structural smells. They read ctx.class_metrics, the per-class aggregates
the walker now emits alongside per-function metrics (see §5.2).
brain_method's centrality gate is language-agnostic: instead of a
fixed dependents ≥ 8, it fires when a file is in the repo's top quintile
of connected files (repo_dependents_p80, computed once per analyze) or
clears the absolute hub bar of 8: so it no longer goes silent on
sparse-graph languages (TS barrels, Rust) whose in-degrees are lower than
Python's.

5.1 Change-entropy git-layer fields

change_entropy is computed during the single FULL-tier co-change walk
(ingestion/git_indexer/co_change.py::compute_co_changes_and_entropy): no
extra git log subprocess. For each commit touching a set of tracked files
F (with 2 ≤ |F| ≤ 30; wider commits are dropped as noise, Hassan's filter),
the commit's entropy is log2(|F|), distributed uniformly (1/|F| per file)
and decayed with the same τ=180d half-life as co-change. The decay-weighted sum
per file is git_meta["change_entropy"]. enrich.compute_percentiles then
derives change_entropy_pct by ranking only files with positive entropy
(zero-entropy files, the ESSENTIAL tier or files only ever changed alone,
keep pct 0.0 so the marker stays silent). Both fields are persisted on
git_metadata (migration 0025) and the additive-reconcile path back-fills
them on legacy DBs.

5.2 Class-level walker metrics (LCOM4)

The complexity walker emits a ClassComplexity per class-like node for
languages that opt in (LanguageNodeMap.class_kinds non-empty: Python,
TS/JS, Java, Kotlin, Rust impl, C++, C#; Go has no grouping node). LCOM4 is the number of
connected components in the graph whose nodes are the class's methods and
whose edges link methods that share an instance field or call one another.
Member references are detected per-language via self/this/$this
member-access nodes. Safety valve: a class with no detected member
references (a static utility, or an unmapped language) reports lcom4 = 1
("no signal") rather than len(methods), so adding a language can only
turn signal on, never produce a false-positive flood. See
complexity/README.md for the full heuristic and its limits.

error_handling is the advisory maintainability marker: swallowed
catches (an empty/comment-only catch / except: pass body), Python
catch-all except: / except Exception:, Rust .unwrap() / .expect() /
panic-family macros, and Go's empty if err != nil {} or blank-identifier
discard of a call's error. The walker collects each occurrence (with its
line) in a whole-tree pass (module-level code included) reusing the
LanguageNodeMap catch kinds for the seven catch-shaped languages and
dedicated recognizers for Rust/Go; an unsupported language or parse failure
yields no hits ("no signal", never a guess). The marker emits one LOW
finding per occurrence (0.15 after its floored 0.5 weight) in its own
error_handling category capped at −0.5, mirroring test_quality's
advisory framing. It is deliberately excluded from the defect-weight
calibration
: on the 21-repo / 9-language T0 benchmark it is AUC-neutral
(OOF delta ≈ 0, CI crosses zero) but size-orthogonal and the least redundant
signal tested, and it ships because users expect except: pass flagged:
bounded so it can never move a file by more than half a point.

5.3 Assertion-block walker metrics (test-quality)

The same single walker pass records assertion_blocks on each
FunctionComplexity: runs of ≥ 2 consecutive assertion statements, each
(start_line, end_line, count). A statement counts as an assertion when it
is a bare assert (LanguageNodeMap.assert_kinds) or its expression is a
call (assert_call_kinds) whose callee name starts with assert or
expect: covering assertEqual / assert_eq! / expect(...).toBe(...)
across xUnit and BDD styles. Opt-in per language, with all nine full-tier
languages (Python, TS/JS, Java, Kotlin, Go, Rust, C++, C#) mapped;
a language that maps neither field simply emits no blocks.
large_assertion_block flags a single run ≥ 15; duplicated_assertion_block
intersects the clone report with assertion spans. Both gate on
coverage.is_test_file(path) so production code is never touched.

biomarkers/registry.py is an explicit list, not auto-discovery:
keeps the registration order deterministic and lets tests inject extras
via registered_biomarkers(extra=...).


6. Scoring (`scoring.py`)

Every file starts at 10.0. Each finding contributes a per-severity
deduction (low=0.3, medium=0.7, high=1.2, critical=2.0), scaled by the
marker's calibrated weight multiplier (§6.1). score_file() then groups the
weighted findings by category, sums the raw deductions per category, and either
accepts the sum or, when it exceeds the cap, scales every per-finding deduction
in that category proportionally so the total equals the cap. This keeps the
UI's "this finding cost you X points" honest after capping. The final score is
clamped to [1.0, 10.0]. So even ten critical structural findings can drive
structural complexity down by at most 3.5 points, not 20.

The per-finding scaled deduction lands on HealthFinding.health_impact
via attach_impacts(): that's what the dashboard's "−2.0" badge shows.

Snapshot tests in tests/unit/health/test_scoring_snapshot.py lock the
category caps, severity deductions, marker-to-category mapping, and two
known-fixture scores. A retune intentionally requires updating the
snapshot in the same PR.

6.1 Calibrated weight multipliers

scoring._BIOMARKER_WEIGHT_MULTIPLIER lets the strongest empirical predictors
deduct more than the uniform severity table alone allows. The multipliers are
calibrated offline against a defect corpus, not hand-tuned: each file is
scored at the pre-window commit (T0, no leakage) and an L2-logistic regression,
with NLOC as an explicit control, fits each marker's defect lift beyond file
size. The runtime stays deterministic; only the learned constants ship. The
full calibration, with confidence intervals, is published in the
benchmark report
and reproduced by local-stash/calibrate_health_weights.py.

Weight Markers Rationale
1.8 co_change_scatter Strongest calibrated predictor.
1.51 change_entropy History Complexity Metric; second strongest.
1.38 ownership_risk Long-run minor-contributor dispersion.
1.34 nested_complexity Strongest structural predictor.
1.1–1.33 remaining structural complexity / size markers Moderate calibrated lift.
1.3 / 1.2 / 1.1 untested_hotspot / churn_risk / code_age_volatility Coverage-dependent and rarely-firing; keep prior weights the corpus could not fairly measure.
1.0 prior_defect Neutral by design: largely redundant with the other process signals, kept for its explanatory value.
0.5 (floored) developer_congestion, dry_violation, low_cohesion, brain_method, primitive_obsession, bumpy_road Fire widely but proved weak under leakage-free scoring; kept as maintainability and parity signals, not disabled.
0.4 (de-rated) knowledge_loss Weakest of the floored set.

The same marker stream feeds the maintainability signal under an
independent, expert-set weight table (the floored smells deduct at full weight
there), and the performance signal under its own bounded performance
category. The three signals share one scoring kernel against separate
weight/category/cap tables and never feed back into each other; see the
user guide
for what each signal surfaces and why the overall score stays the defect score.


7. KPIs

Three repo-level numbers, computed in compute_kpis():

  • Hotspot Health: NLOC-weighted average over files where
    git_meta_map[path]["is_hotspot"] is true.
  • Average Health: NLOC-weighted average over all files.
  • Worst Performer: lowest-scoring file + its score.

These flow into HealthSnapshot rows (rolling 50 per repo) and feed the
CLI status one-liner, the get_overview() MCP block, and the dashboard
KPI cards.


State-free: callers pass an oldest-first list of snapshot rows. Two
alerts:

  • Declining Health: current is ≥ DECLINE_THRESHOLD (default 0.5)
    below the snapshot DECLINE_LOOKBACK (5) positions back. Fires on the
    6th+ snapshot.
  • Predicted Decline: the three most recent snapshots are each
    strictly below the one before. Magnitude is not required; direction is
    the signal.

recent_kpis(history, limit=10) returns a newest-first serialised view
for the CLI table and MCP get_health(include=["trend"]) response.

Per-file trajectory

Snapshots also store a compact {path: score} map (per_file_scores_json),
so the same window yields a single file's score-over-time series:

  • file_score_series(history, path): oldest-first FileTrendPoints,
    skipping snapshots that don't carry the file. Returns [] below two
    points (silent on thin history). This is the exact function the PR bot
    reuses for its in-comment sparkline.
  • file_trend(history, path): wraps the series with current / previous
    / delta and a declining flag (the per-file mirror of the alerts above:
    ≥ DECLINE_THRESHOLD below the lookback point, or
    PREDICTED_DECLINE_CONSECUTIVE consecutive drops). snapshot_count is the
    full window size so a young repo is distinguishable from a file missing in
    older snapshots.

Both are state-free; the server serialises FileTrend via
_file_trend_to_dict and embeds it in the file-detail health block, the
health-breakdown response, and the standalone trend route (§13).

Below the floor

The stored score is clamped to [SCORE_FLOOR, SCORE_MAX], so files 12.9 and
9.1 points deep both persist as 1.0 and their series is flat however much of
the work gets done. The second snapshot map (per_file_deductions_json) keeps
the pre-clamp deduction for exactly those files — for every other file the
deduction is SCORE_MAX - score, so there is nothing to store.

Each point therefore carries unclamped_score alongside score:
SCORE_MAX - deduction where the snapshot recorded one, and score otherwise.
It is the series _file_declining runs on, so declining describes the line
that can actually move, and a file getting worse below the floor now trips it.

Snapshots written before this existed have no deduction map. Their floored
files stay flat, which is correct — the depth was never measured, and inventing
one would be worse than a flat line.

Per-file signals (`signals.py`)

The same state-free pattern, applied to the git-layer + topology fields we
already persist but only buried inside marker detail cards (or omitted
entirely). file_signals(git_meta, degrees) joins one GitMetadata row with
the file's graph degree into a FileSignals grouped as Process
(prior_defect_count, change_entropy_pct normalized 0-100, 90-day line
churn, age_days), People (recent vs all-time owner + commit share), and
Topology (in_degree / out_degree). No recompute: pure surfacing.

The honesty rule mirrors the trend: a field is None only when its source
row
is absent (no git history means process/people silent; not a graph node
means topology silent), never imputed; a genuine prior_defect_count of 0 is kept
as a real signal. The server serialises it via _file_signals_to_dict and
embeds it in the file-detail health block and the breakdown response (§13); MCP
attaches a null-dropped copy to the get_context health block (§12). Mirrored
as FileSignals in @repowise-dev/types/health; rendered by the shared
file-signals-panel.tsx in both the drawer and the file-page Health tab.


9. Incremental analysis: the `repowise update` path

Full re-analysis would be wasteful on commit-sized diffs, so
HealthAnalyzer.analyze() accepts a changed_files set. When it is present:
duplication still runs full-repo (a changed file's clone partner may be
unchanged); the per-file loop skips files not in changed_files; and the KPIs
are not recomputed, since the subset would bias them. The dashboard
recomputes KPIs from the merged DB rows instead.

update_cmd.py builds the changed-files set from
change_detector.get_changed_files(), runs the analyzer, and persists through a
helper that uses the upsert variants (upsert_health_metrics,
upsert_health_findings, scoped to the changed paths) so unchanged files keep
their existing rows. The full-init writers (save_health_findings,
save_health_metrics) still use delete-then-insert: simpler, and the cost is
amortised across the whole repowise init.


10. Persistence schema

Four tables, all in the repo's .repowise/wiki.db. Foreign-keyed to
repositories.id with ON DELETE CASCADE.

`health_findings`

One row per marker hit. Lifecycle: open → acknowledged | resolved | false_positive (matches Dead Code). Bulk-deleted-and-rewritten on full
init; selectively upserted on repowise update.

Column Notes
id UUID PK
repository_id FK
file_path indexed
biomarker_type brain_method, nested_complexity, ...
severity low / medium / high / critical
function_name nullable for file-level findings
line_start, line_end nullable
details_json per-marker evidence (CCN values, clone span, etc.)
health_impact per-finding scaled deduction
reason one-line summary string
status lifecycle
created_at, updated_at datetime

`health_file_metrics`

One row per file (unique on (repository_id, file_path)). Read directly
by the dashboard's file table.

Column Notes
score 1.0–10.0 final
max_ccn, max_nesting, nloc aggregate function metrics
duplication_pct percent of NLOC covered by clones; nullable
has_test_file paired or heuristic
line_coverage_pct, branch_coverage_pct nullable
module community label from graph; falls back to top-level dir
updated_at datetime

`health_snapshots`

KPI + per-file score history. Rolling delete on insert keeps the latest
50 per repo (HEALTH_SNAPSHOT_RETENTION in crud.py).

`coverage_files`

Per-file coverage, overwritten on every coverage add run. Carries the
explicit covered_lines_json array so the coverage_gap marker can
flag the exact uncovered surface, not just the percent.


11. CLI surface

packages/cli/src/repowise/cli/commands/health_cmd.py. Mirrors the
dead-code command's Click structure.

bash
repowise health                            # KPIs + lowest-scoring files + findings
repowise health --file path/to/x.py        # deep-dive one file
repowise health --module packages/server   # restrict to a directory prefix
repowise health --refactoring-targets      # ranked by impact / effort
repowise health --trend                    # last 10 snapshots + active alerts
repowise coverage add coverage.lcov        # ingest coverage; can repeat
repowise coverage add coverage.xml --format cobertura
repowise health --format json | jq ...

repowise status queries the same tables for a one-line summary:

text
Health: 7.4 (avg) · 6.2 (hotspots) · 2.1 (worst: packages/server/.../app.py)

repowise update is unchanged from the user's perspective: health is
silently re-scored for changed files only.


12. MCP surface

`get_health(targets?, include?, repo?, limit?)`

Defined in tool_health.py. Modes:

  • Dashboard mode (targets=None): returns repo-level KPIs (with the
    repo band) + the NLOC-weighted distribution across the 3 bands +
    worst_files (top N lowest-scoring) + top_findings + a per-module
    modules rollup.
  • Targeted mode (targets=[...]): returns full metrics +
    findings for the listed paths, plus a per-file trends block (compact
    score series + current + delta + declining) for any target with at
    least two snapshots of history. Targets prefixed module:foo expand to
    the file set in that module.

include flags layer richer data:

Flag Adds
"biomarkers" full findings list (already present in target mode)
"coverage" per-file coverage rows + summary
"refactoring" deterministic suggestion text on every finding
"trend" snapshot diff + alerts + last 10 KPI rows

Enrichments on existing tools

  • get_risk(targets): each per-target row carries health_score,
    top_biomarkers, coverage_pct, branch_coverage_pct.
  • get_context(targets, include=["health"]): per-file score,
    max_ccn, max_nesting, nloc, module, duplication_pct, top
    2 markers (each with a suggestion string), a coverage block, and a
    null-dropped signals block (process/people/topology, see §8).
  • get_overview(): adds a code_health block: avg, repo band, hotspot,
    worst performer, open finding count, and the NLOC-weighted distribution.

Every response carries the standard _meta envelope via build_meta().


13. REST surface

packages/server/src/repowise/server/routers/code_health.py. All under
/api/repos/{repo_id}/health/:

Route Returns
GET /overview summary (with repo band) + distribution + lowest-scoring files + top findings + module rollup
GET /badge.svg self-rendered flat SVG health badge (color + N.N/10, no letter)
GET /badge.json Shields.io endpoint-badge payload (schemaVersion/label/message/color/band)
GET /files per-file metrics
GET /files/breakdown one file's metric + score breakdown + findings + suggestions + per-file trend + signals
GET /files/trend one file's score-over-time series + current delta + declining flag (?file_path=)
GET /trend repo KPI history + alerts + last-two-snapshot per-file deltas
GET /findings findings list (filterable by biomarker_type, severity, file_path)
GET /coverage coverage summary + per-file rows
POST /coverage ingest a coverage report (used by some CI integrations)
GET /refactoring-targets ranked by total_impact / effort_bucket
GET /churn-complexity churn × complexity scatter points (one per churned file: commit_count_90d, max_ccn, nloc, score, churn_percentile)
GET /modules NLOC-weighted module rollup table

Auth is the standard verify_api_key dependency from
server/deps.py.


14. Web dashboard

Three routes under /repos/[id]/health/:

Route What it shows
/health KPI cards, lowest-scoring file table, top findings, per-module rollup (added in Phase 4)
/health/coverage Coverage summary, untested-hotspot warnings, module-level bars, per-file drill-down
/health/refactoring-targets Cards sorted by impact-per-effort, each with severity, marker, score, NLOC, effort bucket, deterministic suggestion

Plus a sidecar HealthRisksPanel on the Hotspots, Ownership, and Graph
pages: surfaces the lowest-scoring files inline without touching the
shared table/graph components. The Hotspots & churn tab carries the
ChurnComplexityQuadrant (fed by GET /churn-complexity), toggleable in
place with the existing churn × bus-factor scatter; the file Health tab
carries the per-function "Functions by churn" blame table.

All visual primitives live in packages/ui/src/health/ so the hosted
frontend/ repo (separate git checkout) can reuse them: port is mostly
data fetching + auth.


15. CLAUDE.md integration

The auto-generated CLAUDE.md includes a ## Code health section when
the health tables are populated. The block is intentionally short;
filter rules in core/generation/editor_files/data.py:

  • Score ≤ 5.0 and file is a hotspot
  • Any Brain Method in a file with > 10 dependents
  • Any Untested Hotspot
  • DRY violations > 70 % similarity
  • Declining trend (> 1.0 drop in last 5 snapshots)

Everything else is filtered out so the CLAUDE.md doesn't drown a fresh
agent in noise. The Jinja stanza lives in
core/generation/templates/claude_md.j2.


16. Configuration: `.repowise/health-rules.json`

.repowise/health-rules.json is user-authored (the only JSON file in the
layer) and is loaded by HealthConfig.load(repo_path). It carries repo-wide and
per-path disabled_biomarkers and severity_overrides, keyed by an fnmatch
glob over the repo-relative POSIX path (path, with path_glob and glob as
accepted aliases). to_analyzer_config(file_paths) resolves the globs to
per-file disabled sets, which the engine honors in _evaluate_file(). The
schema and examples are in the
user guide.


17. Performance

Plan §4 P4.6 targets < 30 s on a 3,000-file synthetic repo. The
parallel path in HealthAnalyzer.analyze_async() parallelises tree-sitter
parsing across worker threads (asyncio.gather + asyncio.to_thread).
tree-sitter releases the GIL on parse, so this scales on single-process
CPython.

The orchestrator chooses the parallel path automatically when
len(parsed_files) >= 500. The benchmark lives at
tests/integration/test_health_perf_benchmark.py and is marked slow
(opt-in via pytest -m slow or make health-bench).

Other perf notes:

  • Duplication is O(total_tokens). Bucket walk is near-linear on
    repos with low duplication.
  • Walker re-parses files because ParsedFile doesn't retain a
    tree-sitter Tree across the ingestion boundary. Acceptable (~1 ms
    per file); switching to a shared parse cache is a Phase 5 stretch.
  • No N² loops in scoring. Category aggregation is O(findings).

18. Testing

Suite What it locks
tests/unit/health/test_complexity_walker.py Per-language CCN, nesting, cognitive assertions on handcrafted fixtures
tests/unit/health/test_.py Each marker: positive in two languages + one negative
tests/unit/health/test_duplication.py Tokenizer normalization, rolling-hash determinism, co-change weighting
tests/unit/health/test_coverage_parsers.py LCOV / Cobertura / Clover / repowise-JSON happy paths + edge cases
tests/unit/health/test_scoring.py Deduction caps, clamping, KPI math
tests/unit/health/test_scoring_snapshot.py Stability guard: caps, severity table, marker-to-category mapping, two known fixture scores
tests/unit/health/test_trends.py Declining + predicted alerts, ordering, per-file series + file_trend
tests/unit/health/test_signals.py file_signals join: no-signal vs real-zero, entropy 0-1 to 0-100, owner handoff
tests/unit/health/test_churn_complexity.py churn_complexity_points: no-churn omission, complexity never filters, danger-product sort, percentile scaling
tests/unit/health/test_suggestions.py Suggestion strings keyed correctly
tests/unit/health/test_health_config.py .repowise/health-rules.json parsing + glob matching
tests/integration/test_health_coverage_integration.py End-to-end LCOV -> analyzer -> coverage_gap fires
tests/integration/test_health_perf_benchmark.py 30 s budget on 3,000 synthetic files (-m slow)

99 unit tests + 2 integration tests at time of writing. Run with
make health-check.


19. Extension points

Add a marker

  1. New file under biomarkers/ implementing the Biomarker Protocol.
  2. Append to _DETECTOR_FACTORIES in biomarkers/registry.py.
  3. Add the marker-to-category mapping in
    scoring._BIOMARKER_CATEGORY.
  4. Add a suggestion template in suggestions._TEMPLATES.
  5. Add at least three test cases (two positive in different languages,
    one negative).
  6. Update biomarkers/README.md's "Registered v1 detectors" list.

Add a language to the complexity walker

Add one LanguageNodeMap entry to complexity/languages.py mapping the
language's tree-sitter control-flow node-type names to abstract BRANCH
/ LOOP / TRY / BOOLEAN_OP categories. Add a fixture under
tests/fixtures/lang_samples/<lang>/. No .scm files needed: those
are owned by the ingestion parser.

Add a coverage format

Drop a parser under coverage/ returning a CoverageReport. Route to it
from coverage/detector.parse. Stdlib-only (no extra XML libraries).

Add a per-file override

Users (not contributors) author .repowise/health-rules.json. To add
a new override key (beyond disabled_biomarkers), extend
HealthConfig and thread it through to_analyzer_config() ->
engine._evaluate_file().


20. Where the layer deliberately stops

A short list of things the v1 layer does not do, by design. Future
phases may revisit; the constraints kept v1 shippable.

  • No LLM-generated suggestions. suggestions.py is static
    templates. An optional LLM mode is Phase 5, gated behind an explicit
    flag.
  • No symbol-level scoring. Score lives at the file granularity to
    match how engineers think about refactor units. Symbol-level CCN
    still feeds the file score via function_metrics.
  • No complexity_estimate propagation backfill. The walker writes
    the field as a side effect during the current run; old indexes don't
    get touched until a re-index.
  • No predictive ML on trends. Predicted Decline is a 3-snapshot
    direction check, not a model. (Commit-level change risk is a separate,
    shipped surface: the analysis/change_risk/ package behind
    repowise risk scores a commit or base..head range with a calibrated
    logistic model.)
  • No letter grade. The 1–10 score is the single number. The only
    categorical layer is the 3 defect-backed bands (Healthy/Warning/Alert,
    grading.py); a letter on top would be a third overlapping scale with
    arbitrary cliffs. The legacy 4-step scoreBand in ui/health/tokens.ts
    is retained only as a finer color ramp for file-table pills, not a
    labeling scheme: surfaced band labels and the distribution use the 3
    bands.

21. Quick lookup: where do I edit X?

I want to... Edit...
Tweak a category cap scoring.CATEGORY_CAPS (snapshot test will fail; update it)
Tweak a severity deduction scoring._SEVERITY_DEDUCTION (ditto)
Add a new marker biomarkers/*.py, registry.py, scoring.py, suggestions.py
Change the suggestion text for a marker suggestions._TEMPLATES
Adjust the trend-alert threshold trends.DECLINE_THRESHOLD / DECLINE_LOOKBACK
Change snapshot retention crud.HEALTH_SNAPSHOT_RETENTION
Add a new MCP include flag tool_health.py: append handling near the existing "coverage" / "refactoring" branches
Add a new REST route routers/code_health.py: auth is wired at the router level
Add a new dashboard view new file under packages/web/src/app/repos/[id]/health/, primitives under packages/ui/src/health/
Add a CLI flag packages/cli/src/repowise/cli/commands/health_cmd.py
Wire the analyzer into a new entry point call HealthAnalyzer.analyze() directly; persist via the upsert variants if your caller is incremental

See also


Architecture/Deep Dives

Repowise Deep Dives — Complete Guide

This document covers systems that are referenced but not fully explained in architecture-guide.md and graph-algorithms-guide.md. Each section is self-contained with full intuition, implementation details, and the math behind the algorithms.


Table of Contents

  1. Dead Code Detection
  2. Decision Records (ADR) System
  3. Search and Vector Store Internals
  4. Incremental Updates and Webhooks
  5. Change Cascade Algorithm

1. Dead Code Detection

What problem does it solve?

Every codebase accumulates files and functions that nothing uses anymore. A refactor removes the last caller of old_parser.py but nobody deletes the file. Over months, these dead files pile up — increasing maintenance burden, confusing new developers, and inflating CI times.

Repowise's dead code analyzer finds these automatically using pure graph traversal + git metadata. No LLM calls. Runs in under 10 seconds.

The four detection strategies

Strategy 1: Unreachable Files

Question: "Is anything importing this file?"

Algorithm:

text
For each node in the dependency graph:
    Skip if: external package, non-code language, test file, fixture directory

    if not is_file_reachable(node):
        Nothing can get to this file → candidate for dead code

is_file_reachable is the single predicate for "can anything get to this file",
shared with the repo-overview assembler so the two cannot disagree. It rescues
entry points, API contracts, never-flag paths (__init__.py, config files,
migrations, shell scripts), bundler-alias shims and the package-granular
languages, and otherwise asks whether any dependency edge points at the file.
That last part is not a raw in_degree: a co-change edge ("these two files were
committed together"), a file's own symbols and a self-import are all excluded.

Why an import edge alone isn't enough:

Consider plugin_auth.py. Nothing imports it directly because the plugin framework loads it dynamically at runtime via importlib.import_module(). The graph doesn't capture dynamic imports as edges because they don't appear in the AST as static import statements.

Repowise handles this with multiple layers of filtering, including dynamic import detection — files in the same package as importlib.import_module() or __import__() calls automatically get reduced confidence scores:

Layer 1 — Structural exclusions (never flagged):

Exclusion Why
Entry points (main.py, index.ts, app.py) They're where execution starts, nothing imports them
Test files Tests import production code, not the other way around
__init__.py Package initializers, loaded by Python automatically
Config files (setup.py, next.config.js, vite.config.ts) Loaded by frameworks
Migrations (*migrations*) Run by migration tools, not imported
Schema/seed files Data definitions, not code
Fixture directories (fixtures/, testdata/, sample_repo/) Test data, not production code
Non-code languages (JSON, YAML, Markdown, SQL, Terraform) No import semantics
API contracts (proto, graphql marked is_api_contract) Consumed by code generators

Layer 2 — Dynamic pattern matching:

python
_DEFAULT_DYNAMIC_PATTERNS = (
    "*Plugin",       # Plugin discovery systems
    "*Handler",      # Event handler registration
    "*Adapter",      # Adapter patterns
    "*Middleware",    # Middleware chains
    "*Mixin",        # Mixin classes
    "*Command",      # CLI/management commands
    "register_*",    # Registration functions
    "on_*",          # Event callbacks
    "*_view",        # Django/Flask views
    "*_endpoint",    # API endpoints
    "*_route",       # Route handlers
    "*_callback",    # Callback functions
    "*_signal",      # Signal handlers
    "*_task",        # Background tasks
)

Files matching these patterns aren't marked safe_to_delete even if confidence is high, because they're likely loaded dynamically.

Layer 2b — Framework decorator awareness:

Functions decorated with framework-specific route/endpoint decorators are never flagged as dead code:

  • Flask: @app.route, @blueprint.route, @app.before_request, @app.errorhandler, etc.
  • FastAPI: @app.get, @app.post, @router.get, @app.on_event, @app.middleware, etc.
  • Django: @admin.register, @receiver, @login_required, etc.

Layer 2c — Dynamic import detection:

Files in the same package as importlib.import_module() or __import__() calls automatically receive a reduced confidence score (capped at 0.4), since they may be loaded dynamically at runtime.

Layer 3 — Confidence scoring with git metadata:

This is where it gets interesting. A file with zero importers might be dead, or it might be actively used via dynamic loading. Git history helps distinguish:

text
if no_commits_in_90_days AND last_commit_over_364_days_ago:
    confidence = 1.0     # Almost certainly dead — untouched for a year+

elif no_commits_in_90_days AND last_commit_over_179_days_ago:
    confidence = 0.9

elif no_commits_in_90_days AND last_commit_over_89_days_ago:
    confidence = 0.8

elif no_commits_in_90_days AND file_under_30_days_old:
    confidence = 0.55    # Recently created — may be work in progress

elif no_commits_in_90_days:
    confidence = 0.7     # No recent activity, and no commit date to age it by

else:  # has recent commits
    confidence = 0.4     # Suspicious but uncertain
    reasoning: Nobody imports it BUT someone is actively changing it
              (maybe dynamically loaded, maybe a script run manually)

The rungs are an evidence scale, not tier boundaries. The high/medium tier cuts
are SAFE_CONFIDENCE_THRESHOLD and RISK_CAP_CONFIDENCE in
core/analysis/dead_code/risk_factors.py, which every comparison reads by name.

Intuition: If a file is truly dead, it stops receiving commits. Active files — even dynamically loaded ones — still get bug fixes and updates. The combination of in_degree=0 (structural signal) and no-recent-commits (behavioral signal) gives high confidence.

safe_to_delete computation:

text
safe_to_delete = (confidence >= SAFE_CONFIDENCE_THRESHOLD)   # 0.7
                 AND (not matches_dynamic_patterns)
                 AND (no runtime-load risk factors on the path)

A file is only marked safe to delete if we're confident it's dead, it doesn't look like a plugin/handler/adapter that might be dynamically loaded, and its path doesn't look like config / bootstrap / database / environment / script / runtime-asset code. That last condition is re-derived at read time by effective_safe_to_delete, so a finding persisted before the risk factors existed is still evaluated against them.

Strategy 2: Unused Exports

Question: "Is this public function/class imported by anything?"

This is more granular than unreachable files. A file might be imported, but specific exports within it might be unused.

Algorithm:

text
For each file in the graph:
    Skip if: external, non-code, test, fixture, never-flag pattern

    For each PUBLIC symbol in the file:
        Skip if: has framework decorator (pytest.fixture, pytest.mark)
        Skip if: matches dynamic pattern (*Handler, register_*, etc.)

        has_importers = False
        For each file that imports this file (predecessors):
            Check edge's imported_names list
            if symbol_name in imported_names OR "*" in imported_names:
                has_importers = True
                break

        if not has_importers:
            → This export is unused

Edge data is key here. Each edge in the graph stores imported_names — the specific names imported across that edge. For example:

python
# In service.py:
from auth import login, validate_token

# Edge: service.py → auth.py, imported_names = ["login", "validate_token"]

If auth.py also exports reset_password but no edge's imported_names includes it, then reset_password is an unused export.

Confidence scoring for unused exports:

text
if symbol_name ends with _DEPRECATED, _LEGACY, or _COMPAT:
    confidence = 0.3     # Already marked as legacy by developer

elif file_has_other_importers:  # file is used, but this symbol isn't
    confidence = 1.0     # Very suspicious — file is active but this export isn't

else:  # file itself has no importers
    confidence = 0.7     # File and symbol both unused

Why the file-imported distinction matters:

If auth.py is imported by 10 files but none of them import reset_password, that's a strong signal — developers actively use this file but skip this function. Confidence = 1.0.

If auth.py itself has zero importers, the unused export is less interesting — the whole file is dead, and the unused export is just a consequence. Confidence = 0.7.

safe_to_delete for exports:

text
safe = (confidence >= 0.7) AND (complexity_estimate < 5)

Low-complexity symbols (simple functions, constants) are safer to remove than complex ones that might have non-obvious side effects.

Strategy 3: Unused Internals

Question: "Is this private function called within its own file?"

Status: Not implemented (returns empty list). The comment says "Higher false positive rate — off by default."

Why it's hard: Private functions might be called via string dispatch, decorators, metaclasses, or closures that the AST parser doesn't trace. False positives here are more disruptive than for public symbols because developers expect private functions to be internal and are less likely to question the analyzer.

Strategy 4: Zombie Packages

Question: "Does any other package in this monorepo actually use this package?"

Algorithm:

text
# Group files by top-level directory (= package)
packages = group_by(all_files, first_path_segment)

# Only applies to monorepos (2+ packages)
if len(packages) < 2: return []

For each package:
    has_external_importers = False
    For each file in this package:
        For each predecessor (file importing this one):
            if predecessor is from a DIFFERENT package:
                has_external_importers = True
                break

    if not has_external_importers:
        → This package is a zombie (nothing outside it uses it)

Example:

text
packages/
├── auth/          # imported by api/ and cli/
│   ├── login.py
│   └── jwt.py
├── api/           # imported by cli/
│   └── routes.py
├── cli/           # entry point, imports auth/ and api/
│   └── main.py
└── legacy-reports/ # NOTHING outside this package imports it
    ├── generator.py
    └── formatter.py

legacy-reports/ is a zombie package. Its files might import each other internally, but no other package depends on it.

Confidence: Always 0.5 (medium). Packages might be used as standalone entry points, scripts, or tooling that the dependency graph doesn't capture.

safe_to_delete: Always False. Deleting an entire package is too risky for automatic recommendation.

How findings flow to the user

text
DeadCodeAnalyzer.analyze()
    │
    ├── _detect_unreachable_files()    → findings with confidence + safe_to_delete
    ├── _detect_unused_exports()       → findings with confidence + safe_to_delete
    ├── _detect_zombie_packages()      → findings at 0.5 confidence, never safe
    │
    ▼ apply min_confidence filter (default 0.4)
    │
    ▼ persist to dead_code_findings table
    │
    ├── REST API: /api/dead-code (filter by kind, confidence, status)
    ├── MCP Tool: get_dead_code (grouped into tiers: high/medium/low)
    └── Web UI: tabbed view with bulk resolve/acknowledge/false-positive

The MCP tool groups findings into three action tiers:

Tier Confidence Action
High (>= 0.8) Almost certainly dead Start here. Safe quick wins.
Medium (0.5 - 0.8) Probably dead Review with team before deleting.
Low (< 0.5) Suspicious Investigate — might be dynamic loading.

Incremental dead code analysis

repowise update runs the same repo-wide analyze() a full index runs, and persists the whole result.

Scoping the detectors to the changed files is not available here, because dead code is a cross-file property: removing the last import of a module makes that module dead, and the module is not in the change set. An earlier analyze_partial() filtered the repo-wide report down to the changed files before persisting it, and the effect was that any file a change had made dead — or brought back to life — kept its previous verdict until someone re-indexed from scratch.

What the update path does have to be careful about is confidence, which is scored per file from git metadata. An update re-indexes git metadata for the changed files only, and a file with no metadata is indistinguishable from a file with no commits, so it would score 0.7 with safe_to_delete=True however actively it is committed to. The analyzer is therefore also handed the persisted per-file git fields, and the report carries authoritative_paths: the set of files it was actually able to score. Persistence replaces findings for those files and leaves every other file's stored verdict alone, so a partial or failed metadata read narrows what gets written rather than overwriting the index with guesses.


2. Decision Records (ADR) System

What problem does it solve?

Code tells you what the system does. Comments sometimes tell you how. But almost nothing tells you why — why was this approach chosen over alternatives? What constraints forced this design? What was the tradeoff?

When those decisions live only in someone's head or a forgotten Slack thread, every new developer has to reverse-engineer the reasoning. Worse, they might unknowingly undo a deliberate tradeoff, reintroducing a problem that was already solved.

Repowise's decision system automatically discovers architectural decisions from four sources, stores them as structured records, tracks their staleness as code evolves, and surfaces them when developers need context.

How decisions are discovered

Source 1: Inline Markers (confidence 0.95)

Developers sometimes leave breadcrumbs in code:

python
# WHY: We use bcrypt instead of argon2 because our deployment target
# doesn't have the argon2 C bindings available.
password_hash = bcrypt.hashpw(password, bcrypt.gensalt())

# DECISION: Rate limiting is done at the application layer, not the
# load balancer, because we need per-user limits, not per-IP.

The extractor scans source files for regex markers:

text
# WHY: ...
# DECISION: ...
# TRADEOFF: ...
# ADR: ...
# RATIONALE: ...
# REJECTED: ...

When found, it captures a ±20-line context window around the marker and sends it to the LLM for structuring into a decision record (title, context, decision, rationale, alternatives, consequences).

Why 0.95 confidence? The developer explicitly wrote a decision marker. The intent is unambiguous. 0.95 instead of 1.0 because the LLM structuring might misinterpret the context.

Source 2: Git Archaeology (confidence 0.70-0.85)

Most decisions aren't marked in code. They're implicit in commit messages:

text
commit abc123: "Migrate from REST to GraphQL for the admin API — reduces
round trips from 12 to 3 for the dashboard view"

commit def456: "Switch from moment.js to date-fns — moment is 300KB,
date-fns is 15KB with tree-shaking"

The extractor scores commits by decision signal keywords:

text
"migrate", "switch to", "replace", "refactor to", "deprecate",
"remove", "adopt", "introduce", "upgrade", "rewrite", "extract",
"split", "convert", "transition", "revert"

Commits with these keywords are batched (groups of 5) and sent to the LLM to identify which ones represent actual architectural decisions vs routine changes.

Why variable confidence (0.70-0.85)? Git commits are noisier than inline markers. A commit saying "migrate database" could be a major architectural decision or just a routine migration script. The LLM assesses this and assigns confidence.

Source 3: README Mining (confidence 0.60)

Documentation files often contain architectural rationale:

markdown
## Architecture

We use a message queue between the API and worker services because...

### Why SQLite?

For single-tenant deployments, PostgreSQL is overkill. SQLite gives us...

The extractor processes README.md, ARCHITECTURE.md, CONTRIBUTING.md, DESIGN.md, DECISIONS.md, and docs/*.md (up to 10 files, 50KB each).

Why 0.60 confidence? README content is often aspirational or outdated. It describes what the code should be, not necessarily what it is today. Lower confidence reflects this uncertainty.

Source 4: CLI Capture (confidence 1.0)

bash
repowise decision add

Interactive prompt for manual entry. The developer directly states the decision — no extraction uncertainty.

All four sources run in parallel via asyncio.gather(). If one source fails (e.g., LLM timeout during git archaeology), the others still complete.

Decision data model

Each decision record stores:

text
DecisionRecord
├── title: "Migrate admin API from REST to GraphQL"
├── status: "active" | "proposed" | "deprecated" | "superseded"
├── context: "Dashboard required 12 API calls to render..."
├── decision: "Use GraphQL for the admin API"
├── rationale: "Reduces round trips from 12 to 3..."
├── alternatives: ["Keep REST with batching", "Use gRPC"]
├── consequences: ["Need GraphQL schema maintenance", "Client complexity increases"]
├── affected_files: ["src/admin/schema.py", "src/admin/resolvers.py"]
├── affected_modules: ["admin"]
├── tags: ["api", "performance"]
├── source: "git_archaeology"
├── evidence_file: "src/admin/schema.py"
├── evidence_commits: ["abc123"]
├── confidence: 0.80
├── staleness_score: 0.15
└── superseded_by: null

Deduplication key: (repository_id, title, source, evidence_file). The same decision discovered from two sources (e.g., inline marker + readme mention) creates separate records intentionally — this preserves provenance and lets you see where each piece of evidence came from.

Staleness computation

Decisions go stale when the code they govern changes but the decision itself doesn't get updated. The staleness algorithm detects this drift.

Per-file score:

For each file in the decision's affected_files:

text
if file has no git metadata:
    file_score = 1.0    (can't verify → assume stale)

elif file's last commit is BEFORE the decision was created:
    file_score = 0.0    (file hasn't changed since decision was made → still fresh)

else:  # file changed AFTER the decision
    base = min(1.0,  (commit_count_90d / 15) × 0.7
                    + (age_days / 365) × 0.3)

    conflict_boost = 0.0
    For each significant commit AFTER the decision:
        if commit message contains conflict keywords:
            ("replace", "remove", "deprecate", "migrate away",
             "drop", "revert", "undo", "disable", "eliminate")
            AND shares 2+ meaningful words with decision text:
                conflict_boost = 0.3

    file_score = min(1.0, base + conflict_boost)

Breaking down the base score:

  • 70% weight on recent activity: commit_count_90d / 15. If 15+ commits in 90 days, this maxes out at 1.0. Files with heavy churn since the decision was made are likely to have drifted from the original intent.

  • 30% weight on age: age_days / 365. Decisions older than a year get a staleness penalty simply because codebases evolve. Even without heavy churn, a year-old decision might not reflect current reality.

The conflict boost:

The most interesting part. If a commit message after the decision contains words like "replace", "remove", "deprecate" AND shares meaningful words with the decision text itself, that's a strong signal that someone is actively working against the decision.

Example:

text
Decision: "Use bcrypt for password hashing" (created 2025-06-01)
Commit (2026-01-15): "Replace bcrypt with argon2 for password hashing"

The commit contains "replace" (conflict keyword) and shares "bcrypt",
"password", "hashing" with the decision text.
→ conflict_boost = 0.3
→ This decision is likely stale (someone replaced what it decided)

Aggregate score: Average across all affected files, rounded to 3 decimals.

Interpretation:

  • 0.0 - 0.3: Fresh. Code hasn't materially changed since the decision.
  • 0.3 - 0.5: Moderate. Some drift, worth a review.
  • 0.5 - 1.0: Stale. High churn and/or explicit contradictory commits.

Ungoverned hotspot detection

Question: "Which files change a lot but have no documented decisions explaining why?"

text
hotspot_files = files where churn_percentile >= 0.75
                AND commit_count_90d >= 3
                AND (temporal_hotspot_score >= 0.5 OR commit_count_90d >= 8)

governed_files = union of all affected_files across active decisions

ungoverned_hotspots = hotspot_files - governed_files

These are the most dangerous files in the codebase: they change frequently (risky) and nobody has documented why they're designed the way they are (opaque). New developers are most likely to introduce bugs here.

Alignment scoring

When you query get_why("src/auth/login.py"), the system computes an alignment score — how well-governed is this file?

Algorithm:

text
1. Find all decisions governing this file
   (file in affected_files OR module in affected_modules)

2. If no decisions → score = "none"
   "This file is ungoverned — no documented rationale."

3. Count statuses:
   active_count = decisions with status "active"
   deprecated_count = decisions with status "deprecated" or "superseded"
   stale_count = decisions with staleness_score > 0.5
   proposed_count = decisions with status "proposed"

4. Compute sibling coverage:
   sibling_files = other files in the same directory
   sibling_decisions = decisions governing siblings
   coverage = |shared_decisions| / |sibling_decisions|

5. Score decision tree:
   ┌─ All deprecated, no active → "low" (technical debt)
   ├─ >= 50% stale             → "low" (rationale may be invalid)
   ├─ Has active + sibling_coverage >= 0.5 → "high" (well-governed)
   ├─ Has active + sibling_coverage < 0.5  → "medium" (unique pattern)
   ├─ Has active, no siblings  → "high"
   ├─ Only proposed            → "medium" (unreviewed)
   └─ Mixed                    → "medium"

Why sibling coverage matters:

If auth/login.py is governed by a decision about "Use JWT for authentication" and its sibling auth/jwt.py is also governed by the same decision, that's a well-structured module where files share consistent architectural direction. Coverage >= 50% → "high" alignment.

If auth/login.py has a unique decision that no sibling shares, it might be an outlier — the decision applies narrowly, or the file doesn't fit the module's pattern. Coverage < 50% → "medium."

Origin story

When you look up a file's decisions, the system also builds an origin story — a narrative reconstruction of how this file came to be:

text
Origin Story for src/auth/login.py:

Created:     2024-03-15 (732 days ago)
Created by:  Alice Chen (47% of commits)
Last change: 2026-03-20 by Bob Kim
Commits:     89 total, 12 in last 90 days

Key commits:
  - abc123 (2024-03-15): "Initial auth module with JWT" → [Alice]
  - def456 (2024-08-22): "Migrate from session cookies to JWT" → [Alice]
  - ghi789 (2025-11-03): "Add MFA support to login flow" → [Bob]

Linked decisions:
  - "Use JWT for authentication" (active, confidence 0.85)
    Evidence commits: abc123, def456 (messages share "JWT" keyword)
  - "Add multi-factor authentication" (active, confidence 0.75)
    Evidence commits: ghi789 (message shares "MFA" keyword)

Commit-decision linkage works by keyword overlap: if a commit message shares 2+ meaningful words (after removing stop words) with a decision's text, they're linked as evidence.

Decision lifecycle

text
proposed → active → deprecated
             ↓           ↓
          superseded ← superseded_by link

CLI commands:
  repowise decision add        → creates with status "active"
  repowise decision confirm    → proposed → active
  repowise decision deprecate  → sets status "deprecated"
  repowise decision dismiss    → dismisses a proposal (sticky; never re-proposed)
  repowise decision health     → shows stale, ungoverned, proposed

3. Search and Vector Store Internals

If you search for "authentication" with keyword matching, you find pages containing the word "authentication." But you miss pages about "login flow", "credential validation", or "session management" — concepts that are semantically identical but use different words.

Vector search solves this by comparing meaning, not characters.

How vector search works — from text to numbers

Step 1: Embedding. Convert text to a vector (list of numbers):

text
"authentication module"  →  [0.12, -0.45, 0.78, ..., 0.03]  (1536 numbers)
"login credential check" →  [0.11, -0.43, 0.80, ..., 0.05]  (1536 numbers)
"database connection"    →  [0.67, 0.22, -0.15, ..., 0.91]  (1536 numbers)

The embedding model (OpenAI, Gemini, or mock) maps semantically similar text to nearby vectors. "Authentication" and "login" end up close together. "Database connection" ends up far away.

Step 2: Normalize. All vectors are L2-normalized to unit length:

text
normalized = vector / ||vector||

where ||vector|| = sqrt(v[0]² + v[1]² + ... + v[n]²)

After normalization, every vector has length 1.0. This is crucial because it makes cosine similarity equal to the dot product, which is cheaper to compute:

text
cosine_similarity(a, b) = (a · b) / (||a|| × ||b||)

If ||a|| = 1 and ||b|| = 1, then:
cosine_similarity(a, b) = a · b = Σ(a[i] × b[i])

Step 3: Store. Save each vector alongside its page_id and metadata.

Step 4: Search. Embed the query, compute similarity against all stored vectors, return top-k.

The three vector store implementations

InMemoryVectorStore

Simplest implementation. Stores vectors in a Python dict:

python
_store: dict[page_id] → (vector, metadata)

Search computes cosine similarity against every vector:

python
def _cosine(a, b):
    dot = sum(x * y for x, y in zip(a, b))
    norm_a = sqrt(sum(x * x for x in a))
    norm_b = sqrt(sum(x * x for x in b))
    return dot / (norm_a * norm_b) if (norm_a * norm_b) > 0 else 0.0

Time complexity: O(N × D) per search, where N = number of pages, D = vector dimensions.

For 55 pages with 1536 dimensions, this is ~84,000 multiplications per search — trivial. For 100,000 pages, you'd want something smarter. That's where LanceDB comes in.

LanceDBVectorStore

Embedded vector database stored as local files in .repowise/lancedb/. Uses Apache Arrow columnar format with an IVF-PQ (Inverted File Index with Product Quantization) index for fast approximate nearest neighbor search.

Schema:

text
page_id:         string
vector:          list<float32>[dim]
title:           string
page_type:       string
target_path:     string
content_snippet: string (first 200 chars)

Upsert strategy:

python
# LanceDB 0.12+: atomic merge_insert
table.merge_insert("page_id")
    .when_matched_update_all()      # existing page → update vector + metadata
    .when_not_matched_insert_all()  # new page → insert
    .execute([row])

# Fallback for older LanceDB: two operations
table.delete(f"page_id = '{safe_id}'")    # delete old
table.add([row])                           # insert new

Why merge_insert matters: Without it, there's a window between delete and add where the page doesn't exist. If a search runs during that window, it misses the page. merge_insert is atomic — the old and new versions are swapped in one operation.

PgVectorStore

Uses PostgreSQL's pgvector extension. Stores embeddings directly in the wiki_pages table:

sql
-- Upsert
UPDATE wiki_pages SET embedding = CAST('[0.12,-0.45,...]' AS vector) WHERE id = 'page_id';

-- Search (cosine distance operator <=>)
SELECT id, title, content, page_type, target_path,
       1 - (embedding <=> CAST('[0.11,-0.43,...]' AS vector)) AS score
FROM wiki_pages
WHERE embedding IS NOT NULL
ORDER BY embedding <=> CAST('[0.11,-0.43,...]' AS vector)
LIMIT 10;

The <=> operator computes cosine distance (0 = identical, 2 = opposite). Subtracting from 1 converts to similarity.

Why raw SQL instead of ORM? The embedding column is a pgvector type, which isn't declared in the SQLAlchemy ORM model. This keeps the models dialect-neutral (they work with both SQLite and PostgreSQL). The pgvector column is added by an Alembic migration that only runs on PostgreSQL.

Full-text search (FTS)

Vector search is powerful but slow (needs embeddings, API calls). Full-text search is fast and works with exact keywords.

SQLite FTS5

Index creation:

sql
CREATE VIRTUAL TABLE page_fts USING fts5(
    page_id UNINDEXED,    -- stored but not searchable
    title,                 -- searchable
    content                -- searchable
);

Query construction:

The user's query is transformed into an FTS5 MATCH expression:

text
Input: "Python decorator pattern"

Step 1: Tokenize → ["python", "decorator", "pattern"]
Step 2: Remove stop words → ["python", "decorator", "pattern"] (none removed)
Step 3: Add prefix matching → "python"* OR "decorator"* OR "pattern"*

The * suffix enables prefix matching: "auth"* matches "authentication", "authorization", "authoring".

OR between terms gives broad recall — a page matching any term is returned. FTS5's built-in BM25 ranking naturally boosts pages matching more terms.

Another example:

text
Input: "the async await system"

Step 1: Tokenize → ["the", "async", "await", "system"]
Step 2: Remove stop words → ["async", "await", "system"] (removes "the")
Step 3: → "async"* OR "await"* OR "system"*

127 stop words are removed (a, an, the, is, are, was, were, be, been, have, has, had, do, does, did, will, would, etc.).

Edge case: If all words are stop words (e.g., "a the is"), the query falls back to exact phrase matching: "a the is".

PostgreSQL

Uses to_tsvector('english', ...) for document representation and plainto_tsquery('english', ...) for queries. PostgreSQL handles stemming (running → run), stop word removal, and ranking via ts_rank().

A GIN index makes searches fast without scanning every row.

Search ranking in the MCP tool

The MCP search_codebase tool applies additional ranking on top of raw search scores:

Step 1: Try semantic search (vector store, 8-second timeout)

Step 2: Fallback to FTS if semantic fails or returns empty

Step 3: Freshness boost — recently modified files rank higher:

text
if file has commits in last 30 days: recency = 1.0
elif file has commits in last 90 days: recency = 0.5
else: recency = 0.0

boosted_score = raw_score × (1 + 0.2 × recency)

A file modified yesterday gets a 20% boost. A file untouched for a year gets no boost.

Why boost freshness? If you're searching for "authentication" and two pages match equally, the one that was recently updated is more likely to be accurate and relevant to current development.

Step 4: Normalize confidence relative to the best result:

text
confidence = relevance_score / max_relevance_score

The top result gets confidence ≈ 1.0. Other results are proportionally lower. This gives the user a relative quality signal without needing to interpret raw cosine similarity values.


4. Incremental Updates and Webhooks

The problem

Initial documentation generation is expensive — potentially hundreds of LLM calls. After that, you want to keep docs fresh as code changes, but regenerating everything on every commit is wasteful and slow.

Repowise's incremental update system regenerates only what changed and its dependencies.

Three triggers

Trigger 1: CLI update command

bash
repowise update

Reads .repowise/state.json to find the last synced commit, diffs against current HEAD, and regenerates affected pages.

Trigger 2: Filesystem watcher

bash
repowise watch --debounce 2000

Uses watchdog library to monitor the filesystem. When files change:

  1. Add changed paths to a set
  2. Start a debounce timer (default 2 seconds)
  3. If more changes arrive, reset the timer
  4. When the timer fires (filesystem quiet for 2 seconds), run repowise update

Why debounce? Saving a file in an editor often triggers multiple filesystem events (write, metadata change, backup file creation). Without debouncing, each save would trigger 3-4 redundant update runs. The 2-second quiet period waits until all save-related events settle.

Trigger 3: Webhooks

GitHub and GitLab can send POST requests when code is pushed:

GitHub verification:

text
Webhook arrives with:
  Body: {...}
  Header: X-Hub-Signature-256: sha256=abc123...

Server computes:
  expected = HMAC-SHA256(secret, body)

Verification:
  hmac.compare_digest(expected, received)  # constant-time comparison

compare_digest is critical for security — it takes the same time regardless of where the strings differ, preventing timing attacks that could leak the secret byte by byte.

GitLab verification:

text
Header: X-Gitlab-Token: my-secret-token
Compare: hmac.compare_digest(expected_token, received_token)

Simpler — just a shared token, no HMAC computation.

After verification:

text
1. Store raw webhook event in database (audit trail)
2. Find matching repository by URL
3. Create GenerationJob:
   - status: "pending"
   - mode: "incremental"
   - config: {before: "old_commit_sha", after: "new_commit_sha"}
4. Link webhook event to job

The update pipeline

text
1. LOAD STATE
   Read last_sync_commit from .repowise/state.json
   │
2. DIFF
   ChangeDetector.get_changed_files(last_sync_commit, HEAD)
   → List of FileDiff objects (added, deleted, modified, renamed)
   │
3. RE-INGEST
   Re-run FileTraverser + ASTParser + GraphBuilder on entire repo
   (Need fresh graph to compute cascades correctly)
   │
4. RE-INDEX GIT
   GitIndexer.index_changed_files() — only for changed files
   Update churn percentiles, hotspot flags
   │
5. DETECT DECISIONS
   Scan changed files for inline decision markers (WHY:, DECISION:, etc.)
   Update decision staleness scores
   │
6. CASCADE ANALYSIS
   ChangeDetector.get_affected_pages(file_diffs, graph, cascade_budget)
   → regenerate: pages to fully regenerate (budget-limited)
   → rename_patch: pages with symbol renames (text replacement)
   → decay_only: pages to mark stale without regeneration
   │
7. GENERATE
   PageGenerator.generate_all() with only affected files
   │
8. PERSIST
   Upsert pages, git metadata, decisions
   Update FTS index, vector store
   Update CLAUDE.md if enabled
   │
9. SAVE STATE
   Write current HEAD to .repowise/state.json

SSE progress streaming

Long-running jobs report progress via Server-Sent Events:

text
Client: GET /api/jobs/{job_id}/stream
        Accept: text/event-stream

Server: (every 1 second)
        event: progress
        data: {"job_id": "abc", "status": "running", "completed_pages": 12, "total_pages": 55}

        event: progress
        data: {"job_id": "abc", "status": "running", "completed_pages": 25, "total_pages": 55}

        ...

        event: done
        data: {"job_id": "abc", "status": "completed", "completed_pages": 55, "total_pages": 55}

The server checks for client disconnection each iteration and stops streaming when the client goes away. Headers include Cache-Control: no-cache and X-Accel-Buffering: no (prevents nginx from buffering the stream).

Scheduler polling fallback

Webhooks can fail (network issues, misconfiguration, GitHub outages). The scheduler runs two background jobs every 15 minutes:

Job 1: Staleness checker — finds stale/expired pages across all repos and logs them.

Job 2: Polling fallback — for each local repo, compares the stored HEAD commit against actual git rev-parse HEAD. If they differ, a webhook was missed. (Currently logs only; full auto-sync is future work.)


5. Change Cascade Algorithm

The problem

Changing utils.py doesn't just affect utils.py's documentation. Every file that imports utils.py might now have incorrect documentation too — it might reference old function names, outdated behavior, or changed signatures.

But propagating changes through the entire dependency graph could trigger hundreds of regenerations. You need a smart cascade with a budget.

The algorithm

text
Input:
  file_diffs: list of changed files with their old/new parsed versions
  graph: dependency graph (directed, import edges)
  cascade_budget: max pages to fully regenerate (adaptive, hard cap 50)

Output:
  regenerate: set of page IDs for full LLM regeneration
  rename_patch: set of page IDs needing text replacement (for renames)
  decay_only: set of page IDs to mark stale without regeneration

Step 1: Direct changes

text
directly_changed = {file.path for file in file_diffs}

These always get regenerated (they changed, their docs are definitely wrong).

Step 2: 1-hop cascade (reverse dependencies)

text
one_hop = set()
for file in directly_changed:
    for predecessor in graph.predecessors(file):
        # predecessor imports file → predecessor's docs may reference file
        one_hop.add(predecessor)
one_hop -= directly_changed  # don't double-count

Example:

text
auth.py changed
graph.predecessors("auth.py") = ["main.py", "middleware.py", "api.py"]

one_hop = {"main.py", "middleware.py", "api.py"}

These files import auth.py. If auth.py renamed a function, their documentation might reference the old name.

Step 3: Symbol rename detection

For each changed file, compare old and new parsed versions to detect renames:

text
Old file symbols: [calculate, Config, validate]
New file symbols: [compute, Config, validate]

"calculate" removed, "compute" added, both are functions
Name similarity = SequenceMatcher("calculate", "compute").ratio() = 0.63
Line proximity = start lines within ±5 → bonus = 0.2
Combined = 0.63 + 0.2 = 0.83 (above threshold 0.65)

→ Detected rename: calculate → compute

Files referencing the old name need a text patch (string replacement in the existing doc) rather than a full regeneration.

Step 4: Co-change decay

text
co_change = set()
for file in directly_changed:
    for partner in graph edges with edge_type="co_changes":
        if partner is file's co-change partner:
            co_change.add(partner)
co_change -= directly_changed
co_change -= one_hop

Files that frequently change alongside the modified files are marked for decay — their docs might be stale but the evidence is weaker (correlation, not causation).

Step 5: 2-hop weak cascade (for renames only)

text
two_hop = set()
for file in one_hop:
    if file has symbol renames:
        for predecessor in graph.predecessors(file):
            two_hop.add(predecessor)
two_hop -= directly_changed
two_hop -= one_hop

If a rename propagated to a 1-hop file, files importing that 1-hop file might also need updating. But this is speculative — marked for decay only.

Step 6: Budget application

text
candidates = directly_changed ∪ one_hop
sorted_by_pagerank = sort(candidates, key=pagerank, descending)

regenerate = sorted_by_pagerank[:cascade_budget]
decay_only = sorted_by_pagerank[cascade_budget:] ∪ two_hop ∪ co_change

Why sort by PageRank? If the budget is 50 and there are 80 candidates, you want to regenerate the 50 most important files first. A high-PageRank file is imported by many others — if its documentation is wrong, the error propagates further. Low-PageRank leaf files can wait.

Adaptive budget: The budget is no longer fixed. compute_adaptive_budget() scales it based on change magnitude:

Files changed Budget
0 0
1 10
2-5 30
6+ min(n × 3, 50)

Hard cap at 50. Users can override with --cascade-budget N.

Worked example

text
Codebase: 200 files, adaptive cascade_budget = 30 (3 files changed)

Developer changes: config.py

graph.predecessors("config.py") = [
    "auth.py", "api.py", "db.py", "cache.py", "logger.py",
    "middleware.py", "scheduler.py", "worker.py", "mailer.py",
    "validator.py", "serializer.py", "router.py"
]  # 12 files

directly_changed = {"config.py"}                       # 1 file
one_hop = {12 files above}                             # 12 files
candidates = 1 + 12 = 13 files  (under budget of 30)

config.py renamed: LOG_LEVEL → LOGGING_LEVEL
→ rename_patch candidates: files importing LOG_LEVEL

co_change partners of config.py: ["docker-compose.yml", ".env.example"]
→ decay_only

Result:
  regenerate: 13 pages (all fit within budget)
  rename_patch: pages referencing LOG_LEVEL
  decay_only: docker-compose.yml, .env.example docs

Now imagine config.py is imported by 80 files:

text
candidates = 1 + 80 = 81 files  (over budget of 30)

Sort by PageRank:
  Top 30: main.py, auth.py, api.py, ... (highest PageRank)
  Remaining 51: leaf files, utilities, tests

Result:
  regenerate: 30 pages (budget-limited, most important first)
  decay_only: 51 pages (confidence decayed, regenerated on next run)

The 51 files that didn't make the cut have their confidence score reduced. They'll show as "stale" in the UI and be prioritized for regeneration on the next update.

Confidence decay

Pages in decay_only don't get regenerated but their freshness score changes:

text
confidence decays linearly:
  1.0 at generation time → 0.0 after expiry_threshold_days (default 30)

freshness_status:
  "fresh" if hash matches AND age < 7 days
  "stale" if hash changed OR age >= 7 days
  "expired" if age >= 30 days (forces regeneration on next run)

This creates a natural queue: pages that got bumped from the cascade budget eventually hit "expired" status and get regenerated in a future update cycle, ensuring nothing stays stale forever.


Architecture/Editor Files

Editor File Generation

repowise can generate and maintain AI-editor configuration files — CLAUDE.md,
cursor.md, and similar — from the already-indexed codebase data. No LLM calls
are made. All content is derived from the repowise SQL database and filesystem
manifests.


Table of Contents

  1. Why this exists
  2. How it works
  3. The two-section file structure
  4. What goes into the Repowise section
  5. Data sources
  6. File reference
  7. CLI usage
  8. REST API
  9. Configuration
  10. Adding a new editor file

1. Why this exists

Claude Code reads CLAUDE.md on every session start but does not proactively
call MCP tools — even when an MCP server is configured. Users have to explicitly
tell Claude Code to "use MCP" every time.

By embedding codebase intelligence and MCP workflow guidance directly into
CLAUDE.md, Claude Code treats it as project context and naturally reaches for
Repowise tools without being prompted.

The same principle applies to Cursor's .cursor/rules / cursor.md, GitHub
Copilot's .github/copilot-instructions.md, and any other file an AI editor
auto-loads at session start.


2. How it works

text
repowise init (or update)
    │
    ▼
All wiki pages and git metadata already persisted in DB
    │
    ▼
EditorFileDataFetcher
  Queries DB for: architecture summary, top modules by PageRank,
  entry points, hotspot files, active decision records
  Scans filesystem for: tech stack, build commands
  Returns: EditorFileData (frozen dataclass, no DB types)
    │
    ▼
ClaudeMdGenerator.write(repo_path, data)
  Renders claude_md.j2 template with EditorFileData
  Reads existing CLAUDE.md (if any)
  Merges: user content preserved, Repowise section replaced
  Writes atomically (temp file + rename)
    │
    ▼
CLAUDE.md written to repo root

Key properties:

  • Runs after _persist() completes, so all data is current
  • Best-effort in repowise update — never fails the command
  • Instant — no LLM calls, typically < 200ms
  • Idempotent — re-running produces identical output if data hasn't changed
  • Deterministic — lists sorted by stable keys, with path asc as the final
    tiebreaker. Most lists lead on PageRank desc; entry points deliberately do
    not, and rank on execution-start evidence instead.

3. The two-section file structure

markdown
# CLAUDE.md




[user's own content — untouched by repowise]


## Codebase Intelligence — myrepo (Repowise)

[auto-generated content]

Merge rules (applied in BaseEditorFileGenerator.write()):

Situation Action
No CLAUDE.md exists Create with user placeholder + Repowise section
File exists, no markers Append Repowise section at bottom; leave existing content untouched
File exists with markers Replace only the content between REPOWISE:START and REPOWISE:END

The regex used for marker replacement:

python
pattern = re.escape(MARKER_START) + r".*?" + re.escape(MARKER_END)
re.sub(pattern, new_wrapped_content, existing, flags=re.DOTALL)

This is the entire preservation strategy. The only way to lose user content is
to manually insert text inside the REPOWISE:START / REPOWISE:END block,
which the placeholder comment warns against.


4. What goes into the Repowise section

The section has three parts. Total target length: 150–250 lines. Conciseness
is intentional — research shows AI assistants ignore bloated configuration files.

Part 1: Codebase Intelligence

Auto-generated from indexed data. Updates on every repowise update.

Sub-section Source Cap
Architecture summary First 4 sentences from repo_overview wiki page 4 sentences
Key Modules module_page pages sorted by PageRank desc, joined with git_metadata for owner Top 10
Entry Points The curated kg_project_meta list; otherwise graph_nodes where is_entry_point=True, ranked on execution-start evidence Top 10
Tech Stack Filesystem scan (package.json, pyproject.toml, Cargo.toml, go.mod, etc.) All detected
Hotspots git_metadata where is_hotspot=True, sorted by churn_percentile desc Top 5

Part 2: MCP Tools Workflow Guide

Static — the same text for every repo. Hardcoded in claude_md.j2. Teaches
Claude Code when to call each MCP tool using natural workflow framing rather than
imperatives.

This is the most important part. The phrasing matters: "Starting a new task?
Call get_overview() first" is more effective than "ALWAYS call get_overview()
before doing anything."

Part 3: Codebase Conventions

Auto-generated from indexed data.

Sub-section Source Cap
Architectural Decisions decision_records where status='active', sorted by staleness_score asc Top 8
Commands Filesystem scan: package.json scripts, Makefile targets, pyproject.toml pytest/ruff All detected

5. Data sources

Database queries (in `EditorFileDataFetcher`)

Architecture summary (_get_architecture_summary)

python
crud.list_pages(session, repo_id, page_type="repo_overview", limit=1)
# → extracts first 4 sentences, strips markdown headers/code fences

Key modules (_get_key_modules)

python
SELECT page, GraphNode.pagerank, GraphNode.symbol_count
FROM wiki_pages
JOIN graph_nodes ON graph_nodes.node_id = wiki_pages.target_path
WHERE wiki_pages.page_type = 'module_page'
  AND wiki_pages.repository_id = :repo_id
ORDER BY graph_nodes.pagerank DESC NULLS LAST
LIMIT 10
# → owner resolved via separate git_metadata lookup

Entry points (_get_entry_points)

The curated kg_project_meta.entry_points_json list wins when the curation
pass has run. Otherwise the raw flag is read unbounded and ranked in Python,
because the ordering cannot be expressed as an ORDER BY:

python
SELECT node_id, pagerank, betweenness FROM graph_nodes
WHERE repository_id = :repo_id AND is_entry_point = TRUE
# then rank_entry_points(...): conventional entry name, then shallower path,
# then centrality as a tiebreak; sliced to 10 after ranking.

PageRank deliberately does not lead here. Centrality rewards fan-in, so it
floats a widely-imported barrel above the real front door — see
generation/entry_points.py.

Hotspots (_get_hotspots)

python
SELECT file_path, churn_percentile, commit_count_90d, primary_owner_name
FROM git_metadata
WHERE repository_id = :repo_id AND is_hotspot = TRUE
ORDER BY churn_percentile DESC, file_path ASC   -- deterministic tie-break
LIMIT 5

Active decisions (_get_decisions)

python
SELECT * FROM decision_records
WHERE repository_id = :repo_id AND status = 'active'
ORDER BY staleness_score ASC
LIMIT 8
# → uses first 100 chars of rationale field

Average confidence (_get_avg_confidence)

python
SELECT AVG(confidence) FROM wiki_pages WHERE repository_id = :repo_id

Filesystem scan (in `tech_stack.py`)

detect_tech_stack(repo_path) scans the repo root for:

File Detects
package.json Node.js, TypeScript, React, Next.js, Vue, Express, Prisma, Tailwind, …
pyproject.toml / setup.py Python, FastAPI, Django, Flask, SQLAlchemy, Celery, …
Cargo.toml Rust
go.mod Go (extracts version from go X.Y directive)
pom.xml / build.gradle Java / Kotlin + Maven / Gradle
Gemfile Ruby
composer.json PHP
Dockerfile Docker
docker-compose.yml Docker Compose

detect_build_commands(repo_path) returns a dict with keys from:
build, test, lint, dev, format, typecheck.

Priority: package.json scripts → pyproject.toml → Makefile. Each key is
only set once — the first source wins.


6. File reference

text
packages/core/src/repowise/core/generation/editor_files/
├── __init__.py          Exports: ClaudeMdGenerator, EditorFileData, EditorFileDataFetcher
├── base.py              BaseEditorFileGenerator — marker-merge logic, Jinja2 setup, atomic write
├── data.py              Frozen dataclasses: EditorFileData, TechStackItem, KeyModule,
│                        HotspotFile, DecisionSummary
├── fetcher.py           EditorFileDataFetcher — all DB queries + filesystem calls
├── tech_stack.py        detect_tech_stack(), detect_build_commands()
└── claude_md.py         ClaudeMdGenerator — filename, marker_tag, template_name, user_placeholder

packages/core/src/repowise/core/generation/templates/
└── claude_md.j2         Jinja2 template for the Repowise-managed section

packages/cli/src/repowise/cli/commands/
├── claude_md_cmd.py     `repowise generate-claude-md` command
└── init_cmd.py          _maybe_generate_claude_md(), _write_claude_md_async() helpers

packages/server/src/repowise/server/routers/
└── claude_md.py         GET/POST /api/repos/{repo_id}/claude-md

tests/unit/generation/
├── test_editor_file_base.py     Marker-merge logic, idempotency, file structure
├── test_editor_file_fetcher.py  DB query correctness with in-memory SQLite
└── test_tech_stack.py           Filesystem detection with tmp_path fixtures

Class hierarchy

text
BaseEditorFileGenerator   (base.py)
│   filename: str         — abstract property
│   marker_tag: str       — abstract property
│   template_name: str    — abstract property
│   user_placeholder: str — abstract property
│   render(data) → str
│   write(repo_path, data) → Path
│   render_full(repo_path, data) → str
│
└── ClaudeMdGenerator     (claude_md.py)
        filename = "CLAUDE.md"
        marker_tag = "REPOWISE"
        template_name = "claude_md.j2"
        user_placeholder = "# CLAUDE.md\n\n\n"

Data flow

text
EditorFileDataFetcher.fetch()
    │  AsyncSession + repo_id + repo_path
    │
    ├── crud.get_repository()          → repo.name
    ├── _get_architecture_summary()    → str (2-4 sentences)
    ├── _get_key_modules()             → list[KeyModule]
    ├── _get_entry_points()            → list[str]
    ├── detect_tech_stack()            → list[TechStackItem]
    ├── _get_hotspots()                → list[HotspotFile]
    ├── _get_decisions()               → list[DecisionSummary]
    ├── detect_build_commands()        → dict[str, str]
    └── _get_avg_confidence()          → float
    │
    ▼
EditorFileData (frozen dataclass)
    │
    ▼
BaseEditorFileGenerator.render(data)
    │  Jinja2 template rendered with data
    ▼
str (managed section content, without markers)
    │
    ▼
BaseEditorFileGenerator.write(repo_path, data)
    │  Wraps with markers, merges with existing file
    ▼
Path (written file)

7. CLI usage

text
repowise generate-claude-md [PATH]
  PATH          Repo root to generate for (default: current directory)
  --output FILE Write to a custom path instead of CLAUDE.md in repo root
  --stdout      Print generated content to stdout (does not write a file)

Examples:

bash
# Generate CLAUDE.md for the current directory
repowise generate-claude-md .

# Preview what would be written without touching the file
repowise generate-claude-md . --stdout

# Write to a custom path
repowise generate-claude-md /path/to/repo --output /tmp/preview.md

Auto-generation during init and update:

repowise init generates CLAUDE.md after the persistence phase completes.
repowise update regenerates it (best-effort) after each incremental sync.

Both can be disabled:

bash
# Skip CLAUDE.md on this init run and persist the preference to config
repowise init --no-claude-md .

Project-local files vs. global registration:

init writes in two different places, and the flags split along that line.

Project-local, all inside the repo, versionable, one set per repo:
.repowise/mcp.json, .mcp.json, .claude/CLAUDE.md, AGENTS.md,
.vscode/mcp.json, .vscode/extensions.json, .codex/. Three have an opt-out
flag (--no-claude-md, --agents, --codex) and the VS Code pair is
prompt-gated in an interactive run. Only .repowise/mcp.json and the root
.mcp.json are written unconditionally.

Machine-wide, outside the repo, one shared copy for every repo you index, all
written by register_editor_clients() in editor_setup.py:

  • the repowise MCP entry in ~/.claude/settings.json and in Claude Desktop's
    config
  • the Claude Code PostToolUse and SessionStart hooks
  • env.ENABLE_TOOL_SEARCH in ~/.claude/settings.json (skipped for repos on
    the lean MCP tool profile, and never overwritten if you already set it)

Only the Claude integration implements register_client; Codex and VS Code
read project-local config, so theirs are no-ops.

The distill command-rewrite hook is machine-wide too, but it is offered
separately (offer_distill_rewrite_hook) because it is strictly opt-in.

--no-editor-setup turns off both groups: the machine-wide registrations
above, including the rewrite hook offer, and the project-local files
(.mcp.json, .claude/CLAUDE.md, .vscode/mcp.json,
.vscode/extensions.json). Only .repowise/ is written.

bash
# Index the repo, write nothing into it and nothing outside it
repowise init --no-editor-setup --yes .

It used to cover the machine-wide half only, and said so — which meant there
was no combination of flags that indexed a repo without writing four files into
the working tree, since VS Code had no opt-out flag of its own. Issue #1499 is
the report of exactly that. One switch, one meaning.

.repowise/mcp.json is the one deliberate exception and is written either way.
No editor reads it unless pointed at it, and it is what repowise mcp . prints
— so skipping it would mean opting out of editor setup also opted out of ever
opting back in.

Reach for it whenever the checkout or the binary running init is temporary:
a scratch clone, a release smoke test from a throwaway venv, a git worktree, a
benchmark loop over many repos. Each config holds a single repowise MCP
key, so a second init replaces the entry rather than adding one beside it,
and the breakage only shows up later, when the path it now points at is gone
and the MCP server quietly stops loading. init prints a notice when it is
about to repoint an existing entry, but the flag is how you avoid it.

Two exceptions, so the flags do not cancel each other out. --no-editor-setup --no-distill-hook still records the distill.commands.enabled: false opt-out in
this repo's config.yaml. That record is repo-local, and it is the only thing
that gates an already-installed global rewrite hook off here.

The same reasoning covers the instruction files: --no-editor-setup --no-claude-md still records editor_files.claude_md: false, and likewise for
--no-agents-md. Those flags mean "never generate this file", not "skip it this
once", and the generator declining on its way past used to be the only thing
that wrote the preference down — so suppressing the writes would also have
suppressed the memory of the refusal, and the next repowise update would have
generated the file anyway. A preference is not a write.

REPOWISE_SKIP_EDITOR_SETUP=1 is the same switch as an env var, which is the
better fit for CI and sandboxes where no one is passing flags by hand. Either
source disables setup; the flag never re-enables what the env var turned off.
Neither is persisted to config.yaml: this is a per-run decision about your
machine, not a property of the repo, so a later init without the flag
registers normally.


8. REST API

`GET /api/repos/{repo_id}/claude-md`

Returns the generated Repowise section as JSON. Does not write to disk.
Useful for web UI preview.

json
{
  "content": "## Codebase Intelligence — myrepo (Repowise)\n...",
  "generated_at": "2026-03-28",
  "repo_name": "myrepo",
  "sections": ["Architecture", "Key Modules", "Entry Points", "Tech Stack",
               "Hotspots (High Churn)", "Repowise MCP Tools", "Codebase Conventions"]
}

`POST /api/repos/{repo_id}/claude-md/generate`

Regenerates CLAUDE.md and writes it to the repository's local_path on disk.
Returns 422 if local_path is not accessible from the server.

json
{
  "status": "generated",
  "path": "/home/user/myrepo/CLAUDE.md",
  "generated_at": "2026-03-28"
}

9. Configuration

yaml
# .repowise/config.yaml
editor_files:
  claude_md: true     # default: true. Set false to disable entirely.

The --no-claude-md CLI flag sets editor_files.claude_md: false in
config.yaml and all future repowise update runs will skip it.


10. Adding a new editor file

Example: adding cursor.md support.

Step 1 — Create the subclass

packages/core/src/repowise/core/generation/editor_files/cursor_md.py

python
from .base import BaseEditorFileGenerator

class CursorMdGenerator(BaseEditorFileGenerator):
    filename = "cursor.md"
    marker_tag = "REPOWISE"
    template_name = "cursor_md.j2"
    user_placeholder = (
        "# cursor.md\n\n"
        "\n"
    )

That's the entire subclass. All merge logic, atomic write, Jinja2 setup, and
render() / write() / render_full() are inherited from BaseEditorFileGenerator.

Step 2 — Create the template

packages/core/src/repowise/core/generation/templates/cursor_md.j2

The template receives data (an EditorFileData instance). All fields are
the same as claude_md.j2 — the fetcher is shared. Write cursor-specific
framing around the same data.

Minimal starting point:

jinja2
## Project Context (Repowise)
Last indexed: {{ data.indexed_at }}.

{% if data.architecture_summary %}
### Architecture
{{ data.architecture_summary }}
{% endif %}

### Key Files
{% for ep in data.entry_points %}
- `{{ ep }}`
{% endfor %}

Step 3 — Add config key

yaml
# .repowise/config.yaml
editor_files:
  claude_md: true
  cursor_md: true    # NEW

Step 4 — Export from the subpackage

packages/core/src/repowise/core/generation/editor_files/__init__.py

python
from .cursor_md import CursorMdGenerator  # add this line

Step 5 — Hook into init and update

In _maybe_generate_claude_md() (or extract a more generic
_maybe_generate_editor_files() helper):

python
# After existing CLAUDE.md generation
if cfg.get("editor_files", {}).get("cursor_md", False):  # default off
    from repowise.core.generation.editor_files import CursorMdGenerator
    CursorMdGenerator().write(repo_path, data)

Since data is already fetched by this point (reuse from CLAUDE.md generation),
the cursor.md write costs only the template render — no extra DB queries.

Step 6 — Add a REST endpoint (optional)

Follow packages/server/src/repowise/server/routers/claude_md.py exactly.
Swap ClaudeMdGenerator for CursorMdGenerator. Register the router in app.py.

Step 7 — Add tests

Follow tests/unit/generation/test_editor_file_base.py. The _TestGenerator
fixture already tests BaseEditorFileGenerator behavior — add a test that
instantiates CursorMdGenerator directly to verify the filename/marker_tag/
template_name properties and that the template renders without error.


What you do NOT need to do

  • Write any DB queries — EditorFileDataFetcher and EditorFileData are shared.
  • Write any file I/O or merge logic — BaseEditorFileGenerator handles everything.
  • Register a new CLI command — use generate-claude-md as a reference if you
    want a standalone command, but it is not required.
  • Update the ORM schema — no new tables needed.

The only required artifacts for a new editor file are:

  1. A 10–30 line subclass (*.py)
  2. A Jinja2 template (*.j2)