# I don't write codebase documentation anymore

> Source: <https://rogs.me/2026/09/i-dont-write-codebase-documentation-anymore/>
> Published: 2026-09-30 16:28:32+00:00

Hello everyone 👋

Confession time: in 10+ years of writing software, I have never kept documentation up to date. Not once.

And I’ve tried! Confluence spaces, GitHub wikis, a `docs/` folder, READMEs that
start strong and stop being true three sprints later. It always goes the same
way. Someone writes a nice page, the code moves, nobody touches the page, and
six months later a new person reads it, believes it, and loses an afternoon.
Updating docs always felt like a chore I owed someone, and I pay chores about
as reliably as you’d expect.

A while back I started reading some really cool wikis that a paid AI service
had generated for a few projects. Architecture overviews, flow diagrams, a page
for every subsystem, all built from the code. I loved them. What I didn’t love
was that they lived on someone else’s platform, and I couldn’t shape what they
said or how they said it. So I thought: *I want my own*. In my repo, in plain
markdown, maintained by an agent, updated on every push.

So I built it. One of my projects now has a 95-page wiki: about 134,000 words
and 67 Mermaid diagrams. I didn’t write a single one of those pages, and it
updates itself every time something lands on `main`.

This post is the full shebang: what the wiki looks like, how it works, every part that broke along the way, what it’s still bad at, and the two files you need to copy to get it in your own repo (both are at the end of the post, complete).

Quick caveat before we start, because the title is doing some heavy lifting:
what I stopped writing is *codebase* documentation, the pages that describe
what the code does. There’s still a small set of docs the wiki doesn’t touch,
and I’ll get to those near the end.

## What it looks like

The wiki lives in `docs/wiki/` inside the repo, and it’s two levels deep, never
more:

```
docs/wiki/
  README.md                  the index: every reader starts here
  architecture-overview.md   root pages: stuff that spans more than one section
  getting-started.md
  api/
    README.md                a section "hub": directory map, a diagram, a table of pages
    app-and-routes.md        a "leaf": one seam of the code
    auth-and-sessions.md
    ...
  web/
    README.md
    ...
  workers/
  operations/
  .outline.json              which source files each page owns
  .wiki-state.json           the commit the wiki was last generated from
```

(The real one has eight sections, but they’re very specific to the project, so I’m keeping this one generic.)

The project is a client one, so I can’t show you the real pages. Here’s the top of a leaf page, lightly trimmed:

```
> Auto-generated by the wiki skill from commit `20c2b08` on 2026-09-28. Do not
> edit by hand; changes will be overwritten.

# App Bootstrap, Routes and the Error Envelope

The FastAPI application is assembled in `backend/main.py`: `create_app` builds
the app with the `ClerkAuthMiddleware` and the aggregated router, and the
`lifespan` function verifies Clerk settings, constructs every provider-backed
service that was not injected for tests, and fills an empty Clerk mirror before
serving. [...]

## Key files

| Path                     | Role                                                    |
|--------------------------+---------------------------------------------------------|
| backend/main.py          | `create_app` and `lifespan`: middleware, router, ...     |
| backend/routes/errors.py | Registers the three exception handlers that render ...   |
| backend/exceptions.py    | `ErrorCode` vocabulary and the `ApiError` family ...     |
```

After that you get sections on how the app gets built, the route families, the
error format, and a Mermaid diagram of the startup sequence. At the bottom
there’s a `## Related` section linking back to the hub and to the sibling pages
it depends on. Every page has the same shape, and that turned out to be a big
deal for the readers.

## Who actually reads this?

Humans and agents.

For humans, it’s been great for onboarding. When someone new joins the project
I send them to the index and the “Reading order” list at the bottom of it, and
they get a tour of the codebase that matches what’s on `main` right now. Much
better than whatever Confluence page someone last updated when they felt
guilty.

But honestly, the agents are the heavy users. The coding agents working on
this repo read it *all the time*. Instead of grepping around for ten minutes
to figure out how auth works, an agent reads the index, jumps to the right
hub, and loads the one leaf page it needs. It’s a shortcut into the codebase,
and a cheap one, since a leaf page is small enough to load whole.

The index even has a section written just for them:

```
## For agents

The lookup path is this index, then a section hub, then a leaf. Each hub's
directory map names the page owning each part of its tree, and
`docs/wiki/.outline.json` maps every source path to the page that describes it.
```

## How it works: two files

The whole thing is two files:

| File | What it is | 
|---|---|
| `.claude/skills/wiki/SKILL.md` | The instructions: layout, page templates, size rules, three modes, a self-check. About 230 lines of prose, zero code. | 
| `.github/workflows/wiki.yml` | The plumbing: triggers, model choice, the commit step, and a check for runs that got cut off. | 

I split them on purpose. The skill never touches git: it reads code and writes
markdown, and that’s it. The workflow never decides what a page says: it runs
the skill and commits whatever changed. Because of that, you can drop the skill
into any repo, or run it by hand with `/wiki full` in Claude Code, and it
doesn’t care who’s calling it.

On every push to `main`, GitHub Actions starts a headless Claude Code session
with exactly one prompt: `/wiki incremental`. The session reads the diff since
the last wiki run, figures out which pages describe the files that changed,
rewrites those pages from the current code, and exits. Then a plain shell step
commits `docs/wiki/` back to `main`.

### Pages follow seams

The first big decision was how to cut a codebase into pages. The skill calls
the unit a *seam*: a boundary the code already has, like a package, a service,
a pipeline stage, or a bunch of modules that always change together. Pages
follow seams, and never file types or a fixed template like “one page for
models, one for views”.

The skill also has to find the seams on its own, from `git ls-files` and the
code. There’s no hardcoded list of directory names anywhere. On my repo it came
up with eight sections, including some splits I never asked for: it broke the
backend into the core service, identity, the answer pipeline, and the test
suites.

### The two state files

These two files are what make incremental updates possible.

`.outline.json` is the page map. Each page lists `covers` (the globs that make
the page suspect when they change) and `seeds` (one to five files to start
reading from):

```
{
  "file": "api/app-and-routes.md",
  "title": "App Bootstrap, Routes and the Error Envelope",
  "covers": ["backend/main.py", "backend/routes/**", "backend/exceptions.py", "..."],
  "seeds": ["backend/main.py", "backend/routes/__init__.py"]
}
```

The rule is that every tracked file belongs to exactly one page, and the most specific glob wins. So when a file changes, there’s always exactly one page to re-check. If a file doesn’t match any page, that’s a hole in the outline, and the run has to fix it by extending a page or adding a new one.

`.wiki-state.json` is one line:

```
{"last_generated_sha": "a60e3827...", "generated_at": "2026-09-29T14:11:31Z", "mode": "incremental"}
```

The skill always writes this file last, and that one rule is the whole crash-safety story. If a run dies halfway (timeout, API error, whatever), the old SHA is still there, so the next run computes the same diff and does the work again. The work gets done a bit later, but it gets done.

### Three modes

`incremental` runs on every push. It diffs `last_generated_sha..HEAD`, maps
each changed file to its page, and rewrites each affected page *from scratch*
using the current code. The diff only tells it where to look, and the old page
is just a checklist of topics to re-verify, so a page always reads like it was
written today (no “this was changed to…” edit logs). Then it rewrites the
hubs and index tables that changed.

`audit` runs every Monday at 03:23 UTC. Ten incremental runs can each
correctly decide “nothing to change here” and still, together, leave a page
wrong. So once a week the audit re-derives the whole outline, fixes the
structure (splits, merges, new pages, deleted pages), and checks every page’s
main claims against the code, oldest page first. A page that passes keeps its
banner untouched, so a clean audit produces no diff at all.

`full` runs when I ask for it, or automatically when the state files are
missing. It writes the outline first, then the leaves, the hubs, the root
pages, and the index last, so every level describes pages that actually exist.

Incremental also bumps itself up to an audit when the diff touches more than ~40% of the tracked files, or when the recorded SHA doesn’t exist anymore (someone rewrote history).

Oh, and the 03:23 is on purpose. GitHub’s scheduler gets hammered at the top of the hour, so an odd minute dodges the delay.

### Keeping it honest

The skill’s top priority is literally written as “accuracy beats coverage”. A wiki that confidently describes code that doesn’t exist anymore is worse than no wiki, because humans and agents both trust it. So a big chunk of the skill is rules about that:

- Every claim has to be checked by reading the code in the checkout.
- Every page starts with a banner with the commit and date it was generated from, so you always know how fresh it is.
- Code is referenced by path and symbol, and never pasted in. Anything over ~10 lines is out; the reader has the repo.
- Present tense only. No roadmaps, no history, no ticket numbers. If the code looks buggy, the page describes what it does, and doesn’t guess what the author meant.
- A fact lives on exactly one page, and every other page links to it.
- Secret values never show up, even ones someone committed by accident. The page names the config key and that’s it.
- Pages have size limits: leaves are 300 to 1,500 words and *must* split past
2,500, hubs stay under ~600, and the index under ~800.

Before it writes the state file, every run does a self-check: links and anchors resolve, navigation works both ways (leaf to hub, hub to index), every file has exactly one owner, the outline matches what’s on disk, and a few greps make sure there are no em dashes, no “now / recently / no longer”, no ticket references, and no bold-label bullets.

Yes, I banned em dashes in my generated docs. I have strong feelings about em dashes.

## The workflow

Here’s the flow:

```
   push to main        Monday 03:23 UTC      manual dispatch
        │                     │               (pick a mode)
        ▼                     │                     │
  ┌────────────┐              │                     │
  │  dispatch  │  gh workflow run wiki.yml          │
  │    job     │──────────────┐                     │
  └────────────┘              ▼                     ▼
                       ┌──────────────────────────────────┐
                       │ wiki job                         │
                       │  checkout (full history)         │
                       │  claude-code-action: /wiki <mode>│
                       └────────────────┬─────────────────┘
                                        ▼
                         did THIS run write the state file?
                          │                            │
                         yes                           no
                          ▼                            ▼
               commit docs/wiki, rebase,      commit partial pages,
               push to main                   fail the job (red run)
```

Some of these details took way more effort than they look like they should.

### Re-dispatching the push

[claude-code-action](https://github.com/anthropics/claude-code-action) doesn’t accept `push` events. It throws
`Unsupported event type: push` and that’s the end of that. So a tiny
`dispatch` job (it runs for a few seconds) re-fires every push as a
`workflow_dispatch`, which is one of only two event types the default
`GITHUB_TOKEN` is allowed to trigger.

Then there’s a second wall: the action refuses to run for bots by default, and
a run dispatched by `GITHUB_TOKEN` shows up as `github-actions[bot]`. So the
workflow sets `allowed_bots: github-actions` to let exactly that one bot
through.

### Avoiding the infinite loop

The wiki commits to `main`. That’s a push to `main`. Which would trigger the
wiki… 🤔

It doesn’t, for two separate reasons. `paths-ignore: docs/wiki/**` means a push
that only touches the wiki doesn’t trigger the workflow, and the commit is
pushed with `GITHUB_TOKEN`, which GitHub never lets trigger push-based runs.
Either one would be enough. I like having both.

### The newest run wins

A concurrency group cancels a run in progress when a newer push comes in, so
the wiki is always generated from the latest `main`. This is safe thanks to the
state-file-last rule: whatever the cancelled run didn’t finish, the new run
redoes.

### Different models for different modes

Incremental runs happen on every push, so they should be cheap. Audits and full runs make the structural decisions, so they get the stronger setup. The models and the audit effort level are all repo variables, which means switching models is a settings change and not a commit.

Everything goes through [Lazer Proxy](https://lazertechnologies.com/) (that’s the `ANTHROPIC_BASE_URL` line
in the workflow). Right now both slots run GLM 5.3. Audits and full runs get
`max` effort, and incremental runs use the model’s default effort (which, now
that I’m writing it down, I should probably bump to `high` lol).

### The commit step

The commit step is plain shell, and it always runs, even when the Claude step
fails or times out, so generated pages never get thrown away. It commits as
`claude[bot]` with `[skip ci]`, then rebases onto the latest `main` and retries
up to three times, because `main` has probably moved during a run that can take
over an hour.

It also checks whether the run actually finished. It compares the state file’s SHA and timestamp with the time the run started, and if this run didn’t write the state file (and the diff wasn’t empty), it still commits the partial pages, since the next run redoes that diff anyway, but then it exits 1 so I get a red run.

That check exists because of the worst bug in this whole project.

## What broke along the way

### Three trigger designs in one day

The very first day was all about getting it to run on push at all. I started
with claude-code-action, hit the `push` rejection, and switched to calling the
CLI directly (`claude -p "/wiki incremental"`) in one job with a normal push
trigger. That worked, and honestly it’s simpler! But it meant owning the CLI
install and version pin myself, and losing the action’s run reports and GitHub
integration. So the same day, I went back to the action and built the
re-dispatch job.

There’s a research doc in the repo comparing every option I looked at:
`workflow_run` chaining, `repository_dispatch`, schedule-only, the raw CLI, and
dispatch plus `allowed_bots`. The `workflow_run` option was sneaky. My CI
workflows only run when their own project’s files change, so a push that only
touched the README wouldn’t trigger any of them, and the wiki would silently
skip that push. The workflow header links to that doc, so the next person who
opens `wiki.yml` and thinks “why is this so weird?” gets an answer.

### The first layout was flat

The first version of the skill wrote one flat level of pages. Later I
restructured it into index, hubs, and leaves, and added
`"outline_version": 2` to the outline. An old outline without that field
forces a full regeneration automatically, so the upgrade happened on the next
push without me doing anything.

That regeneration was also the last time I touched the wiki by hand. Of the 111
commits to `docs/wiki/`, 105 are from the bot. My 6 are merge commits, one
feature commit that happened to touch the directory, one hand edit on day one,
and that regeneration.

### Eleven green runs that did nothing

My favorite bug, in the “I want to throw my laptop into the sea” sense.

A big batch of changes landed at once, and the diff grew past ~100 files. At that size, the model decided (on its own, nobody asked it to) that the smart move was to hand the page rewrites to subagents. Which is very reasonable! In an interactive session that’s exactly what you’d want.

But in a headless run, subagents launch asynchronously, and the process exits when the main turn ends. So every run went like this: spawn a bunch of agents, then end the turn with some version of “the agents are still running, I’ll resume when they complete”. The process exited, the job reported success, the commit step committed whatever was written so far (between 1 and 12 files per run), and the state SHA never moved.

This happened eleven times in a row, and all eleven runs were green 🙃 The diff snowballed to about 245 files before one run happened to do everything inline and finish.

The fix was three changes:

1. `--disallowedTools Agent` in the workflow, so the model can’t spawn
subagents at all.
2. A line in the skill: do every step in your own turn and never hand page writing to subagents or background tasks, because a headless run ends when your turn ends.
3. The “did this run write the state file?” check from above, so a run like that shows up red.

The third one is the one I care about most. The subagent thing was a bug, sure, but what really bothered me was eleven green checkmarks telling me everything was fine.

### Nineteen refused commands

The next problem showed up on an audit that split a page in two and then couldn’t delete the old one.

Without an explicit allow rule, a headless Claude Code session refuses any
shell command it can’t prove is safe. That includes `git -C`, anything with a
pipe, small Python helpers, and the `rm` that deletes a page dropped from the
outline. That audit lost 19 commands this way. The skill has an
`allowed-tools` list in its frontmatter, but those rules match on command
prefixes, so `rm docs/wiki/foo.md` was allowed and the exact same delete with
an absolute path wasn’t.

Two fixes here. The skill now tells the model to always run commands from the
repo root with relative paths, never with `cd` or `git -C` in front. And the
workflow allows Bash outright. That second one is a judgment call: it’s fine
for me because the prompt is a constant string, the repo is private, and the
job token can only write repo contents, so there’s nothing untrusted for the
sandbox to protect against. If your repo is public, think twice before you
copy that line.

## The numbers

| Measure | Value | 
|---|---|
| Pages | 95: 1 index, 4 root pages, 8 section hubs, 82 leaves | 
| Words | ~134,000 | 
| Mermaid diagrams | 67 | 
| Commits to `docs/wiki/` | 111 (105 by the bot) | 
| Workflow runs | 136: 101 succeeded, 28 cancelled, 7 failed | 

Most of the cancellations are by design: a newer push cancelled an older run.

Here’s what a normal incremental run looks like. The diff touched 24 of the repo’s 1,029 tracked files. The run took 23 minutes and 162 turns, cost $5.29, and changed 25 files in the wiki: 14 pages rewritten, 4 new pages (existing pages had grown past 2,500 words and had to split), plus the hubs and the index. At the end it left a summary in the CI log, including a list of pages that need the next audit to clean them up.

On models: an audit on Claude Opus got killed at the
50-minute timeout I had back then without finishing. I switched to GLM 5.3 at
`max` effort, and the first audit on that setup finished in 13 minutes for
about $4.30.

What I don’t have is a monthly total, sadly. The wiki shares its API key with
the Claude workflows that review our PRs in CI, so the spend on that key is
both of them mixed together, and I can’t tell how much of it is the wiki. The
cost also depends on how big each diff is and how often you push, so your
mileage will vary a lot. If you want a rough number for your repo, multiply a
few dollars per run by your pushes to `main`, then add one audit per week. I’m
giving the wiki workflow its own API key so I can track exactly what it spends,
and I’ll update the post when I have real numbers.

## What it’s still bad at

It’s not perfect, so here’s where it falls short.

### Walls of text

This is the big one, and it’s what I’m tuning next. The skill says paragraphs are at most ~120 words and hubs stay under ~600. My architecture overview has a paragraph that’s 524 words long 😅 Several hubs are between 870 and 1,050 words, and a dozen leaves are past the 1,500-word “you should split this” line.

My theory: the model follows the hard rules that the self-check measures (it
*always* splits a page past 2,500 words) and treats the soft ones as
suggestions. Agents don’t mind a wall of text. Humans very much do. I think the
fix is turning more of the soft rules into self-check failures, because
everything the self-check measures gets fixed, and everything it doesn’t
measure slowly drifts.

### It’s sometimes wrong

I’ve caught pages getting things wrong. I don’t lose sleep over it, because it fixes itself: the next time anyone touches those files, the page gets rewritten from the code, and even if nobody does, the Monday audit re-checks every page. When a human-written page is wrong, it stays wrong until a human notices. This one is on a timer.

I also want to be upfront about what I *haven’t* done: nobody has fact-checked
the wiki sentence by sentence. The one independent check I ran confirmed the
structure was right: the outline matched what’s on disk, the links worked, and
no page was behind the files it covers. That tells me the wiki is structurally
sound, but it doesn’t tell me every sentence is true. For the content, I’m
trusting the verify-against-code rule, the self-check, and the weekly audit.
So far that’s been good enough for me, but it’s a bet, and you should know
it’s a bet.

And one that made me laugh: the run summary at the end of each CI log (the one thing the self-check doesn’t grep) is full of em dashes and bold-label bullets. Apparently the rules only count when someone is checking.

## What the wiki doesn’t write

Back to the caveat from the beginning.

The wiki describes what the code does *right now*. It can’t know intent: why we
picked this design, what we decided in a meeting, how an operator should run a
data refresh. So the repo still has a small set of docs that live outside the
wiki:

- `docs/adr/` , the architecture decision records, with the “why” behind the
big calls.
- `docs/handbook/` , with an operator guide, a runbook, and an architecture
reference for maintainers.
- `CONTEXT.md` , the domain glossary (including the words we*don’t* use).
- `AGENTS.md` , the condensed project context for coding agents.

I don’t write these by hand either, to be clear. I write them with AI. The difference is that I’m driving: the decision or the procedure comes from me (or the team), and the AI helps me turn it into a doc. The wiki doesn’t need me at all.

The handbook also has a rule, backed by an ADR: if you change an admin page or the data refresh workflow, you update the handbook page that describes it in the same pull request. That rule lives in the agent rules too, so the coding agents follow it.

My favorite detail: the wiki has a page *about* the handbook, explaining what
it is, why it’s maintained outside the wiki, and which wiki pages describe the
code behind each handbook doc. The wiki documents the docs it isn’t allowed to
touch, which I find hilarious.

## Stop writing the docs a machine can write

OK, opinion time.

Docs that describe code are basically a build artifact. We don’t hand-write compiled binaries or minified JS, we generate them from the source every time the source changes. For “how does this work” docs, the source is the code. And now we have something that can read the code and write decent pages about it for a few dollars a run. Asking a person to keep those pages in sync by hand is how you end up with a Confluence graveyard again.

Docs about decisions, intent, and procedure are different. Those come from people, so a person should be in the loop, with AI helping. And that set turned out to be way smaller than I expected: a handful of files and two directories, versus 95 pages I never have to think about.

## Show me the code

Both files are below, complete. To use them in your repo:

1. Copy `SKILL.md` to`.claude/skills/wiki/SKILL.md` and`wiki.yml` to`.github/workflows/wiki.yml` . Make sure`docs/wiki/` isn’t git-ignored.
2. Set up model access. My workflow uses a `LAZER_PROXY_API_KEY` secret and a`LAZER_PROXY_BASE_URL` variable because everything goes through our proxy.
If you’re calling Anthropic directly, put your API key in a secret, point`anthropic_api_key` at it, and delete the`ANTHROPIC_BASE_URL` line. If you
use another gateway, point that line at it instead. The model and effort
variables are optional. Without them, the workflow uses Sonnet for
incremental runs and Opus for audits.
3. If `main` is protected, let the workflow push (a GitHub App or a ruleset
bypass), or change the commit step to open a PR.
4. If your repo is public, rethink two choices I made for a private repo:
`show_full_output: true` (it dumps the whole transcript into the public run
log) and allowing Bash outright.

Then push something! The first run finds no state file and switches to `full`
by itself. On a big repo that first run takes a while, so go get a coffee ☕

You can also skip CI entirely: open Claude Code in your repo and type
`/wiki full`.

### SKILL.md

```
---
name: wiki
description: Generate and maintain an agentic codebase wiki in docs/wiki/ (browsable markdown pages with Mermaid diagrams). Use this skill whenever the user asks to build, update, sync, audit, or regenerate the project wiki, codebase documentation, or architecture docs, or whenever it is invoked as /wiki. Also use it when asked "document this codebase" or "keep the wiki up to date".
allowed-tools: Read, Grep, Glob, Write, Edit, Bash(git log:*), Bash(git diff:*), Bash(git show:*), Bash(git ls-files:*), Bash(git rev-parse:*), Bash(date:*), Bash(wc:*), Bash(ls:*), Bash(cat:*), Bash(grep:*), Bash(find:*), Bash(jq:*), Bash(python3:*), Bash(rm docs/wiki/:*)
---

# Codebase Wiki Generator

Maintain a browsable markdown wiki describing this repository in `docs/wiki/`. The wiki is machine-owned: every run may rewrite any page, so treat existing pages as prior output, not as human work to preserve. Write files only. Never run `git add`, `git commit`, or `git push`; committing is the caller's job (CI or the human).

Accuracy beats coverage. Every claim in a page must be something you verified by reading the code in this checkout. A wiki that confidently describes code that no longer exists is worse than no wiki, because readers (human and agent) trust it as context.

Readers are humans browsing on GitHub and agents loading pages as context. Both navigate the same path: index, then section hub, then leaf. Every rule below exists to keep that path short and every page on it accurate.

## Invocation and mode selection

The invocation is `/wiki [mode]` where mode is `incremental`, `audit`, or `full`. Rules:

1. Run `full` regardless of the requested mode when any of these hold: `docs/wiki/.wiki-state.json` does not exist; `docs/wiki/.outline.json` is missing, unparseable, or lacks `"outline_version": 2`.
2. If no mode is given, run `incremental`.
3. In `incremental` mode, if `last_generated_sha` is missing or is not a commit in this repo (`git rev-parse --verify <sha>^{commit}` fails, for example after a history rewrite), there is no diff base: escalate to `audit` and say so in your summary. Audit verifies every page against the current code, which covers whatever the lost diff would have shown.
4. In `incremental` mode, if the diff since the last run touches more than ~40% of tracked source files, escalate to `audit` and say so in your summary.

Runs are time-bounded and the caller may cancel one. Write `.wiki-state.json` last, so a cut-off run leaves the previous SHA in place and the next run re-processes the same diff.

Do every step in your own turn. Never hand page writing to subagents or background tasks: a headless run ends when your turn ends, so work still running elsewhere is lost while the run reports success. Run shell commands from the repository root with repo-relative paths, never prefixed with `cd` or `git -C`: tool allowlists match command prefixes, so `rm docs/wiki/<path>` is permitted where the same removal by absolute path is refused.

## Layout

The wiki is two levels deep: root and sections.

| Path | Role |
|---|---|
| `docs/wiki/README.md` | Index. The only page every reader starts from. |
| `docs/wiki/<page>.md` | Root page. Spans more than one section. Always present: `architecture-overview.md`, `getting-started.md`. |
| `docs/wiki/<section>/README.md` | Hub. The landing page for one seam. GitHub renders it when a reader browses into the directory. |
| `docs/wiki/<section>/<page>.md` | Leaf. One seam within the section. |
| `docs/wiki/.outline.json` | The page map: the contract that makes incremental updates possible. |
| `docs/wiki/.wiki-state.json` | Run state. |

A **seam** is a boundary the code already has: a package, a service, a subsystem, a pipeline stage, a bounded set of modules that change together. Pages follow seams, never file types or a generic template.

Sections are earned. A section exists when one seam yields two or more leaves. A repo whose whole outline is three to six leaves has no section directories: the index is the only hub and the leaves sit beside it at the root. A seam that fits on one page gets `<section>/README.md` alone, hub and leaf in one file. There is never a third level: if a section wants one, raise the abstraction of its leaves instead. Decide all of this from `git ls-files` and the code, never from a fixed list of directory names.

## `.outline.json`

``` json
{
  "outline_version": 2,
  "root_pages": [
    {
      "file": "architecture-overview.md",
      "title": "Architecture Overview",
      "covers": ["README.md", "AGENTS.md"],
      "seeds": ["README.md"]
    }
  ],
  "sections": [
    {
      "dir": "api",
      "title": "API Service",
      "covers": ["api/**"],
      "pages": [
        {
          "file": "api/auth-and-sessions.md",
          "title": "Auth and Sessions",
          "covers": ["api/src/auth/**", "api/src/middleware/session.*"],
          "seeds": ["api/src/auth/service.*"]
        }
      ]
    }
  ]
}
```

- `covers` on a leaf or root page is the set of paths/globs whose changes make that page suspect.
- `covers` on a section is the fallback for the whole seam: any file in the section's tree that no leaf claims (configs, lockfiles, READMEs, one-file directories) belongs to the hub.
- `seeds` are the 1 to 5 files to start reading from when writing the page.
- A section with a single page has `pages: []`; its `README.md` is written from the section's own `covers` and `seeds` (add `seeds` on the section in that case).

**Ownership rule:** every non-excluded tracked path resolves to exactly one page. Resolution is most-specific-glob-wins (the longest matching pattern). Leaf `covers` inside a section must not overlap each other; a path matching two leaves is a defect to fix in the outline, and the self-check reports it.

**Order of writes:** a page is written to disk before its entry is added to `.outline.json`, and a page dropped from the outline is deleted from disk in the same step. A cut-off run must never leave an outline entry without its page, or a page without its entry.

Two shapes the outline takes. A small single-service repo:

```
docs/wiki/
  README.md
  architecture-overview.md
  getting-started.md
  request-pipeline.md
  persistence.md
  background-jobs.md
```

A monorepo with two packages, one of which fits on a page:

```
docs/wiki/
  README.md
  architecture-overview.md
  getting-started.md
  web/
    README.md              hub: directory map, diagram, page table
    routing-and-shell.md
    auth.md
    rendering.md
    tooling-and-tests.md
  worker/README.md         hub and leaf in one file
  operations/
    README.md
    ci-workflows.md
    agent-configuration.md
```

`.wiki-state.json`:

``` json
{"last_generated_sha": "<full sha>", "generated_at": "<ISO 8601 UTC>", "mode": "incremental"}
```

Get the SHA with `git rev-parse HEAD`. Rewrite this file at the end of every successful run; audit and full runs rewrite it even when no page changed. The caller reads its SHA and `generated_at` to tell a finished run from a cut-off one.

## Page sizing and splitting

A page is one seam, sized so a reader finishes it in one sitting and an agent can load it whole:

- A leaf covers typically 3 to 15 project-authored files and runs 300 to 1,500 words. Over 1,500 words is a split candidate; over 2,500 words splits in the same run, whatever the mode, with the new leaf added to the outline and the hub.
- A leaf has at most 7 H2 sections, and its H2s share one concern. Concerns are stack-neutral: bootstrap and configuration; auth and access control; request handling and routing; domain logic; data model and persistence; UI and rendering; external integrations; safety and compliance; tooling and tests. A page whose H2s straddle two concerns splits along that line. Read the concerns off the code; the list above is a vocabulary, not a template.
- A paragraph is at most ~120 words. Inventories (test files, routes, config keys, commands, environment variables) are tables.
- A section with more than ~8 leaves splits into two sections. A one-file directory is a row in the hub's directory map, never a page.
- Hubs run under ~600 words. The index runs under ~800.
- Vendored and generated directories get one row in the hub's directory map and one sentence on how they are produced, plus the local maintenance policy when the repo documents one (in its agent rules or README). Read that policy; never assume one.

## Page templates

Every page starts with this banner, values filled in:

``` markdown
> Auto-generated by the wiki skill from commit `<short sha>` on <YYYY-MM-DD>. Do not edit by hand; changes will be overwritten.
```

**Index** (`docs/wiki/README.md`), in order:

1. Banner, H1, overview: what the project is and how it runs, two paragraphs at most.
2. One table per section (and one for root pages) with columns `Page | Summary | Key paths`. Summary is one sentence; key paths are the two or three directories the page is about.
3. `## Reading order`: a numbered list of 4 to 6 pages for a newcomer.
4. `## For agents`: two sentences stating that the lookup path is index, hub, leaf, and that `.outline.json` maps source paths to pages.

**Hub** (`docs/wiki/<section>/README.md`), in order:

1. Banner, H1, orientation: what the seam is and where it lives, one paragraph.
2. `## Directory map`: a table `Path | What lives there | Page` with one row per top-level subdirectory and config file of the seam. Vendored and generated directories are rows too, marked as such. The Page column links the leaf that owns the row, or says "this page".
3. One Mermaid diagram of the seam: module dependencies or the main flow through it.
4. `## Pages`: a table `Page | Summary`.
5. `## Cross-cutting`: links to the root pages and other sections this seam touches.
6. A final line linking back to the index: `Back to [the index](../README.md).`

**Leaf** (`docs/wiki/<section>/<page>.md`, and root pages), in order:

1. Banner, H1, orientation: one paragraph on what this seam does and where its code lives.
2. `## Key files`: a table `Path | Role` of the 3 to 10 files that matter most, each path a relative link into the repo (one `../` per directory level between the page and the repo root, so from a section directory it is `../../path/to/file`).
3. H2 sections describing purpose, structure, key flows, and interactions. A Mermaid diagram wherever the page describes a flow, a state machine, or a handoff between components; a sentence wherever a sentence is enough.
4. `## Related`: links to the hub, the sibling pages this page references, and the pages in other sections it depends on. Root pages link the index here instead of a hub.

## Page conventions

- Reference code by path and symbol (`src/auth/service.py`, `SessionStore.refresh()`), never with large pasted code blocks. Snippets over ~10 lines defeat the purpose; the reader has the repo.
- Link pages with relative links (`[Auth](auth.md)`, `[Worker](../worker/README.md)`), anchors allowed (` auth.md#session-refresh`).
- Describe what the code does today, in the present tense, as if the page were written fresh this run. Roadmaps, intent, and history belong in commits and tickets. If behavior looks like a bug, describe the behavior, not your guess about what was meant.
- **Single source.** A fact lives on exactly one page; other pages link to it. When two pages both need a fact, the owner is the page whose `covers` includes the file that defines it.
- Never copy values of secrets, tokens, API keys, connection strings, or `.env` contents into a page, even values found committed in the repo. Name the config key, never the value.

Writing style: plain, specific, low ceremony. Concretely:

- Punctuate with commas, colons, semicolons, periods, and parentheses. Em dashes are banned; the self-check greps for them.
- Say what the thing does: "X does Y" over "X serves as / is responsible for Y". Show that something is simple rather than asserting it.
- Plain vocabulary: delve, leverage, robust, seamless, streamline, comprehensive, and "plays a crucial role" are banned, as are bold-label bullets (`**Performance**: ...`) and negative parallelism ("it's not X, it's Y").

## What to exclude

Skip vendored dependencies, generated code, lockfiles, build output, fixtures/snapshots, minified bundles, and `docs/wiki/` itself. Use `git ls-files` as the source of truth for what is tracked, then apply judgment: if a directory is clearly machine-written (codegen output, migrations dumps, registry-pulled components), document that it exists and how it is produced, not its contents.

## Full mode

1. Inventory: `git ls-files`, apply exclusions, read the README and obvious entrypoints (main modules, app factories, CLI definitions, CI config) to understand what the project is.
2. Write `.outline.json`: find the seams, decide which earn sections, assign `covers` so the ownership rule holds, pick `seeds`.
3. Write each leaf and single-page section: start from its seeds, then explore with Read/Grep/Glob (follow imports, find callers, check config defaults) until you can describe the seam's purpose, structure, key flows, and interactions. Verify claims against code you actually read. Apply the sizing rules as you go; split before writing rather than after.
4. Write each hub from its finished leaves, then the root pages, then the index last, so each reflects the pages that exist.
5. Remove any `docs/wiki/**/*.md` not in the new outline (`rm docs/wiki/<path>`); an emptied directory may stay. Run the self-check. Write `.wiki-state.json`.

## Incremental mode

1. Read `.wiki-state.json` and `.outline.json`. Compute changes: `git diff --name-status <last_generated_sha>..HEAD -- . ':(exclude)docs/wiki'`. If the diff is empty, update nothing (you may still rewrite `.wiki-state.json`) and report a no-op. Commits after `last_generated_sha` that touch only `docs/wiki/` are not evidence that their diff was processed: a cut-off run commits partial pages without advancing the state file. The state file is the only record of what was processed; never narrow the diff by reasoning about those commits.
2. Resolve each changed path to its owning page via the ownership rule. A path that resolves to no page means the outline has a gap: assign it to the best-fitting existing page (extend its `covers`) or, if it belongs to a genuinely new seam, add a page or section.
3. For each affected page: read the diff for its files (`git diff <last_sha>..HEAD -- <paths>`) to learn where to look, then rewrite the whole page from the current code, using the old page only as a checklist of topics to re-verify. A page is a fresh description, never an edit log. Renames and deletions are reflected; a page whose entire subject was deleted is removed from disk and from the outline. Apply the sizing rules: a page that grew past its bounds splits now.
4. Rewrite the hub of every section whose leaves changed (directory map and page table included). Rewrite the index tables whenever any page was written, added, removed, or retitled. Both are short; keeping them current on every run is what stops the index going stale.
5. Run the self-check. Write `.wiki-state.json`.

## Audit mode

The weekly safety net. Incremental runs can each correctly conclude "no page change needed" while ten of them together leave a page wrong. Audit exists to catch that drift plus structural rot.

1. Re-derive an outline from the current tree as if running full mode, but don't write it yet. Compare with `.outline.json`: seams that grew enough to deserve their own page or section, pages whose subject shrank or vanished, tracked paths no page owns, leaves whose `covers` overlap, pages outside the sizing bounds. Apply the structural fixes (add/split/merge/remove pages; new or split pages are written as in full mode). Structural decisions are sticky: a split or merge made by an earlier run stands unless the code moved or a hard bound is exceeded, so audits do not oscillate on word counts alone.
2. For every surviving page, oldest banner SHA first: confirm each path in `covers` still exists, then verify the page's main claims against the current code (entry points it names, flows it describes, config keys it references). Rewrite what drifted. A page that passes keeps its banner untouched, so a clean audit produces no churn; say which pages passed.
3. Rewrite hubs and the index if the page set changed. Run the self-check. Write `.outline.json` and `.wiki-state.json`.

## Self-check

Run before writing `.wiki-state.json` in every mode. Fix what fails; anything you cannot fix goes in the summary.

| Check | How |
|---|---|
| Links resolve | For every `](<path>.md` target, Glob the file relative to the linking page; for anchors, confirm a heading in the target produces that slug. |
| Navigation is two-way | Every leaf is in its hub's page table and links the hub in `## Related`; every hub and root page is in the index; every hub links the index. |
| Present tense | Grep pages for `—`, for `\b(now|recently|no longer|previously|used to)\b`, for ticket and PR references (`\b[A-Z]{2,}-\d+\b`, `#\d+\b`), and for bold-label bullets (`^- \*\*[^*]+\*\*:`). Rewrite each hit. |
| Sizes | `wc -w` on every page against the bounds in Page sizing. |
| Ownership | Every tracked, non-excluded path resolves to exactly one page. Enumerate with `git ls-files <glob>` per pattern; list gaps and overlaps. |
| Outline matches disk | Every page in `.outline.json` exists on disk, and every `docs/wiki/**/*.md` (index and hubs included) is in the outline. Delete strays with `rm docs/wiki/<path>`; never leave a redirect stub in place of a deletion. |
| Banners | Every page written this run carries `git rev-parse --short HEAD` and today's date. |

## Reporting

End every run with a short summary: mode actually run (and why, if escalated); the page tree with word counts; pages created, updated, deleted, and verified unchanged; self-check results (gaps, overlaps, unresolvable links); whether `.wiki-state.json` was written; and anything that needs a human (huge uncovered directory, suspected bug, unparseable state). Keep it to a screen; it lands in a CI log.
```

### wiki.yml

```
# Generated codebase wiki (docs/wiki/), maintained by the /wiki skill.
#
# Runs anthropics/claude-code-action. The action doesn't accept push events,
# so a push to main re-enters this workflow as a workflow_dispatch (one of
# the two event types the default GITHUB_TOKEN may still trigger), and
# allowed_bots lets that GITHUB_TOKEN-dispatched run pass the action's
# human-actor check. Full rationale and the failure history:
# docs/agents/research/claude-automation-on-push.md
#
# Triggers:
#   - push to main, re-dispatched (incremental: diff since last wiki commit)
#   - Mondays 03:23 UTC (audit: re-derive outline, catch drift)
#   - manual dispatch with a mode override
#
# Loop safety (both hold, either alone is enough):
#   - paths-ignore: pushes that only touch docs/wiki/ don't trigger this
#   - the commit step pushes with the default GITHUB_TOKEN, and GitHub never
#     creates push-triggered runs for events caused by that token
#
# Setup per repo:
#   1. Copy .claude/skills/wiki/ and this file into the repo, and make sure
#      docs/wiki/ is not git-ignored (this repo ignores docs/* with exceptions).
#      The skill writes a two-level tree (index, section hubs, leaf pages) and
#      escalates to a full run on its own when docs/wiki/.outline.json is
#      missing or predates the current outline format.
#   2. Set the LAZER_PROXY_API_KEY secret and the LAZER_PROXY_BASE_URL Actions
#      variable (org-level recommended so repos don't each need a copy).
#      Optional model overrides: LAZER_PROXY_WIKI_MODEL for incremental runs
#      (cheap, every push) and LAZER_PROXY_WIKI_FULL_MODEL for full and audit
#      runs (structure and verification decisions, worth a stronger model).
#      LAZER_PROXY_WIKI_FULL_EFFORT (low, medium, high, xhigh, max) sets the
#      effort level for full and audit runs; unset leaves the model default.
#      Installing the Claude GitHub App is optional: without it the action
#      falls back to the job's default GITHUB_TOKEN.
#   3. If main is a protected branch, allow this workflow to push (GitHub App
#      or Actions bypass in the ruleset) or switch the commit step to a PR.

name: Wiki

on:
  push:
    branches: [main]
    paths-ignore:
      - "docs/wiki/**"
  schedule:
    - cron: "23 3 * * 1" # Mondays 03:23 UTC; odd minute to dodge the top-of-hour delay
  workflow_dispatch:
    inputs:
      mode:
        description: Generation mode
        type: choice
        options: [incremental, audit, full]
        default: incremental

jobs:
  # claude-code-action validates the event type and fails on push
  # ("Unsupported event type: push"), so a push re-enters this workflow as a
  # workflow_dispatch. Dispatching with the default GITHUB_TOKEN works:
  # workflow_dispatch and repository_dispatch are the two events exempt from
  # GitHub's no-retrigger rule for that token.
  dispatch:
    if: github.event_name == 'push'
    runs-on: ubuntu-latest
    permissions:
      actions: write
    steps:
      - run: gh workflow run wiki.yml --ref main -f mode=incremental
        env:
          GH_TOKEN: ${{ github.token }}
          GH_REPO: ${{ github.repository }}

  wiki:
    if: github.event_name != 'push'
    runs-on: ubuntu-latest
    # A newer run cancels an in-progress one, so the wiki is always generated
    # from the latest main. Cancelling mid-run is safe: the skill writes
    # .wiki-state.json last, so the replacement run re-processes the same
    # diff. Job-level (not workflow-level) so the seconds-long dispatch job
    # doesn't churn the group.
    concurrency:
      group: wiki-generate
      cancel-in-progress: true
    timeout-minutes: 85
    permissions:
      contents: write
      id-token: write
    env:
      WIKI_MODE: ${{ inputs.mode || (github.event_name == 'schedule' && 'audit') || 'incremental' }}
    steps:
      - uses: actions/checkout@v7.0.1
        with:
          # Full history: incremental mode diffs against the SHA recorded
          # in docs/wiki/.wiki-state.json, which can be arbitrarily old.
          fetch-depth: 0

      # The commit step compares this against the state file's generated_at
      # to tell a state file this run wrote from one left over from before.
      - id: start
        run: echo "at=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"

      - uses: anthropics/claude-code-action@v1.0.235
        # Below the job's 85-minute limit so the commit step (if: !cancelled())
        # still has time to push whatever was generated when a run hits this
        # ceiling. An audit of the current tree needs more than 50 minutes.
        # Committing a truncated run is safe: the skill writes
        # .wiki-state.json last, so a killed run leaves the previous SHA in
        # place and the next run re-processes the same diff.
        timeout-minutes: 75
        with:
          anthropic_api_key: ${{ secrets.LAZER_PROXY_API_KEY }}
          # Dispatched runs are initiated by GITHUB_TOKEN, which the action's
          # human-actor check sees as the github-actions bot.
          allowed_bots: github-actions
          # Stream Claude's full transcript into the run log. By default the
          # action prints only the init and final-result messages, so a
          # multi-minute generation looks stalled. The prompt is a constant
          # string against our own repo, and the repo is private, so there is
          # no untrusted output to hide.
          show_full_output: true
          prompt: "/wiki ${{ env.WIKI_MODE }}"
          # Incremental runs happen on every push and only touch the pages a
          # diff points at; full and audit runs decide the page structure and
          # verify every page, so they get the stronger model.
          #
          # Agent is disallowed because subagents launch asynchronously and a
          # headless run ends with the main turn: the model would hand page
          # rewrites to subagents, end its turn to wait for them, and the run
          # would exit "success" having committed almost nothing. Eleven runs
          # did exactly that on 2026-09-15.
          #
          # Bash is allowed outright. Without an allow rule the action runs in
          # default permission mode, where a headless session refuses any
          # command it cannot statically clear: git diffs prefixed with cd or
          # -C, python and node helpers, pipelines, and the rm that removes a
          # page dropped from the outline (an audit on 2026-09-16 lost 19
          # commands this way and could not delete a split page). The prompt
          # is a constant, the repo is private, and the job token can only
          # write repo contents, so there is nothing for the sandbox to guard.
          claude_args: |
            --model ${{ env.WIKI_MODE == 'incremental' && (vars.LAZER_PROXY_WIKI_MODEL || 'claude-sonnet-5') || (vars.LAZER_PROXY_WIKI_FULL_MODEL || 'claude-opus-5') }}
            --allowedTools "Read,Write,Edit,Glob,Grep,Bash"
            --disallowedTools Agent
            ${{ env.WIKI_MODE != 'incremental' && vars.LAZER_PROXY_WIKI_FULL_EFFORT && format('--effort {0}', vars.LAZER_PROXY_WIKI_FULL_EFFORT) || '' }}
        env:
          # Routes all inference through Lazer Proxy (org-level Actions variable).
          ANTHROPIC_BASE_URL: ${{ vars.LAZER_PROXY_BASE_URL }}

      - name: Commit wiki updates
        # Run even when the Claude step fails or times out, so generated
        # changes aren't dropped; the job still reports the step failure.
        if: ${{ !cancelled() }}
        env:
          RUN_STARTED_AT: ${{ steps.start.outputs.at }}
        run: |
          # The skill writes .wiki-state.json last, so a state file this run
          # did not write means the run was cut off before it finished
          # (timeout, API error, or the model ending its turn early). The
          # only legitimate skip is an incremental run whose diff was empty.
          # The partial pages are still committed below, because the next
          # run re-processes the same diff, but the job fails so the gap is
          # visible instead of buried in a green run.
          head="$(git rev-parse HEAD)"
          previous="$(git show HEAD:docs/wiki/.wiki-state.json 2>/dev/null | jq -r '.last_generated_sha // empty')"
          recorded="$(jq -r '.last_generated_sha // empty' docs/wiki/.wiki-state.json 2>/dev/null || true)"
          generated="$(jq -r '.generated_at // empty' docs/wiki/.wiki-state.json 2>/dev/null || true)"
          state_written=true
          if [ "$recorded" != "$head" ] || [ -z "$generated" ] || [[ "$generated" < "$RUN_STARTED_AT" ]]; then
            state_written=false
          fi
          run_incomplete=false
          if [ "$state_written" = false ]; then
            if [ "$WIKI_MODE" != incremental ] || [ -z "$previous" ] \
               || ! git rev-parse --verify --quiet "${previous}^{commit}" >/dev/null \
               || ! git diff --quiet "$previous" HEAD -- . ':(exclude)docs/wiki'; then
              run_incomplete=true
              echo "::error::docs/wiki/.wiki-state.json records '${recorded:-nothing}' at '${generated:-no time}' but this ${WIKI_MODE} run started at ${RUN_STARTED_AT} from ${head}; the wiki run did not finish." >&2
            fi
          fi
          if [ -n "$(git status --porcelain docs/wiki)" ]; then
            # Author only; the push still uses GITHUB_TOKEN, which is what
            # the loop-safety rule above depends on.
            git config user.name "claude[bot]"
            git config user.email "209825114+claude[bot]@users.noreply.github.com"
            git add docs/wiki
            git commit -m "docs(wiki): update generated wiki [skip ci]"
            # Main may have moved during the (up to 75-minute) Claude run.
            # Rebase onto the latest main and retry; our commit only touches
            # docs/wiki, so conflicts are only possible against another wiki
            # commit, which the concurrency group already serializes.
            pushed=false
            for attempt in 1 2 3; do
              if git pull --rebase origin main && git push origin main; then
                pushed=true
                break
              fi
              git rebase --abort 2>/dev/null || true
              sleep 10
            done
            if [ "$pushed" != true ]; then
              echo "Failed to push wiki updates after 3 attempts." >&2
              exit 1
            fi
          else
            echo "No wiki changes."
          fi
          if [ "$run_incomplete" = true ]; then
            exit 1
          fi
```

## Was it worth it?

Yes. This started as an itch: I liked someone else’s generated wikis and I wanted one that was mine. Now it’s the first thing I send to new people on the project, and the thing every agent in the repo reads before touching anything. It cost me one weird day of GitHub Actions trigger archaeology, eleven green runs that lied to me, and a few dollars per push.

I still need to fix the walls of text. When I do, I’ll update the skill in this post.

If you set this up in your own repo, [let me know](https://rogs.me/contact) how it goes! I’m really
curious to see what seams it finds in codebases that aren’t mine.

See you in the next one!
