repowise

GitHub

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

RAW Doc

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.

<div align="center">
<img src="../.github/assets/one-index.svg" alt="One index producing code health, a dependency graph, git history, generated docs, architectural decisions, and ten MCP tools" width="100%" />
</div>

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 signalsis_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 streamingPOST /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_batchTrue 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.tslistConversations, getConversation, deleteConversation, postChatMessage (returns raw Response for SSE reading)
- providers.tsgetProviders, 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_<biomarker>.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

- docs/layers/CODE_HEALTH.md: user-facing guide.
- packages/core/src/repowise/core/analysis/health/README.md: developer overview at the layer root.
- Sub-package READMEs under complexity/, coverage/, duplication/, biomarkers/.
- docs/architecture/graph-algorithms.md: the graph layer health depends on.

---

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.tomlMakefile. 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)

---