app-dev/codebase-index/SKILL.md

name: codebase-index description: >- Build or incrementally update a repo's .codebase-index/ markdown mirror read by the nvim oil summary panel. Use when asked to index, reindex, or update the codebase index, or when a release/handoff step calls for refreshing it.

codebase-index

Maintain .codebase-index/, a markdown mirror of the repo. An nvim panel (codebase_index.lua in the dotfiles nvim config) shows these files while browsing the tree in oil, so every summary is read top-down in a narrow window: the first lines matter most.

Layout

  • The summary for path <rel> lives at .codebase-index/<rel>.md — for directories too: apps/foo.codebase-index/apps/foo.md, alongside the .codebase-index/apps/foo/ directory holding its children's entries.
  • The repo root's summary is .codebase-index/_root.md.
  • .codebase-index/.last-commit holds the HEAD hash of the last index run.

Content of each entry

Start with # <rel>, then a 2–3 sentence summary, then go deeper the further the reader scrolls:

  • Directories: one line per child, then architecture and flow notes.
  • Files: purpose and key exports, then notable implementation detail.

Write for someone browsing the tree who has never seen this code. No changelog narration — present tense, current state only.

Skip .git, .codebase-index, lockfiles, generated output, vendored code, and binary assets. Index every other directory and source file.

Workflow

  1. cd to the repo root (git rev-parse --show-toplevel).

  2. If .codebase-index/.last-commit exists, run git diff --name-status $(cat .codebase-index/.last-commit)..HEAD and only rewrite entries for changed paths and their ancestor directories, deleting .md files for deleted paths. Otherwise index the whole repo.

  3. Optional, when atlas is on PATH: scan once and keep the JSON to hand. It gives every entry's exports and its dependents without opening a file, which is the mechanical half of the two things each entry has to state.

    atlas scan . > "${TMPDIR:-/tmp}/index-scan.json"
    S="${TMPDIR:-/tmp}/index-scan.json"
    # key exports of one file
    jq -r --arg f "$REL" '.nodes[]|select(.id==$f)|.symbols[]|select(.exported)|"\(.kind) \(.name)"' "$S"
    # who depends on it, and what they take across the edge
    jq -r --arg f "$REL" '.edges[]|select(.kind=="imports" and .target==$f)|"\(.source) takes \((.symbols//[])|join(", "))"' "$S"
    # a directory's children, for the one-line-per-child list
    jq -r --arg d "$REL" '.edges[]|select(.kind=="contains" and .source==$d)|.target' "$S"
    

    Read the code for what a scan cannot know — why the thing is shaped the way it is, which is the part of a summary worth writing. Skip this step entirely when atlas is absent; nothing here depends on it.

  4. For a large full index, fan out subagents by top-level directory; each writes its subtree's entries plus the entry for the top-level directory itself, and the parent writes _root.md from their summaries. Pass each subagent the scan path from step 3 so it queries facts instead of rediscovering them.

    Keep working while they run: run the step 6 check on each subtree as it lands and start the _story.json draft from the subtrees already back; only _root.md has to wait for all of them. Intervene if a subagent goes off track or is missing context.

    Spend model on the entries that carry the repository, not evenly. The scan already knows which those are — size and how many things depend on a file, because a small module everything imports matters more than a long one nothing does:

    jq -r '
      ([.edges[]|select(.kind=="imports")]|group_by(.target)|map({t:.[0].target,n:length})|INDEX(.t)) as $fan
      | .nodes[] | select(.kind=="source" or .kind=="config" or .kind=="documentation") | . as $n
      | (($fan[$n.id].n) // 0) as $deps
      | (if $n.lines >= 600 or $deps >= 5 then "lead"
         elif $n.lines >= 60 then "body"
         else "leaf" end) + "\t" + $n.id
    ' "$S"
    

    lead entries, plus _root.md and _story.json always, get the most attention — they are read first and by the most people, and they are two files out of hundreds. body is the middle, which is most of the corpus. leaf summaries are nearly mechanical. On one 746-file repository that split was 88 / 457 / 201, and the 8% of files over 600 lines held 42% of all code.

    Do not pin tiers to model names. Spawn lead and body subagents with no model override — the inherited default is the newest model, and at lower effort it beats an older, cheaper model on cost per task. Reserve a cheaper alias for leaf batches only where step 6 shows their summaries hold; step 6 is what makes any cheaper tier safe. Step 3 has already stripped out the mechanical half — exports and dependents come from the scan — so what is left for any tier is judgment about what is non-obvious, which is the harder half, not the easier one.

  5. Write or refresh .codebase-index/_story.json — the narrative Codebase Atlas opens on. One index pass should leave Atlas with everything it needs: facts come from its own scan, per-module prose from the entries just written, and the story from this step. Skipping it leaves Atlas's landing view empty.

    Do it here rather than in a later pass because the understanding is already built: the agent that just summarized every module is the one positioned to say what the whole thing does and what travels between its parts. Follow the codebase-atlas skill for the schema and the authoring rules — an actor is a role rather than a directory, its role is also its column, and the outside-world actors that are not files at all are the point. Verify with atlas scan . | jq -r '.warnings[]'.

    On an incremental run, revisit the story only when the change moved something it names — a renamed module, a new surface, a capability that arrived or left. Ordinary edits inside a part it already describes do not move the narrative.

  6. Check the entries against the scan before trusting them. A summary names identifiers; the scan knows which exist. Flag any the file does not contain:

    jq -r '.nodes[]|select(.description and .symbols)|. as $n
      | ($n.description|[scan("`([A-Za-z_][A-Za-z0-9_]*)\\(\\)`")|.[0]])[]
      | . as $c | select([$n.symbols[].name]|index($c)|not)
      | "\($n.id)\t\($c)"' "$S" |
    while IFS=$'\t' read -r f name; do
      grep -q "\b$name\b" "$f" 2>/dev/null || echo "$f names $name() — absent from the file"
    done
    

    The two stages matter. A name missing from the symbol index is usually fine — a method on a returned object, or a local — so the grep second pass is what removes the false positives. What survives both is a hallucination or a name that outlived a rename. Treat a flag as a review candidate, not a failure, and rewrite the entry from the file. Run this whatever tier wrote the entry: on one repository it found four bad names among 647 entries, none of them written by a cheap model.

    Also verify the story: atlas scan . | jq -r '.warnings[]'.

  7. Before writing the hash, check the mirror against the tree rather than against the subagents' reports — every indexed path has an entry:

    git ls-files | while read -r f; do
      [ -e ".codebase-index/$f.md" ] || echo "missing $f"
    done
    

    What it prints is the skip list plus anything a subagent silently dropped; rewrite the latter. Then write the current HEAD hash to .codebase-index/.last-commit.

Notes

  • Incremental runs diff commits, so uncommitted work is invisible to them — run after merging/committing, not before.
  • Whether .codebase-index/ is committed is the repo's call; don't add it to .gitignore unprompted.
  • .codebase-index/ holds two artifacts with two lifecycles: the markdown mirror, regenerated per commit and tracked by .last-commit, and _story.json, written once and revised when the architecture moves. They are produced in one pass because the understanding is shared, but .last-commit says nothing about the story — Atlas validates that against the scan instead, and its staleness warning deliberately excludes it.