cd /news/ai-agents/i-don-t-write-codebase-documentation… · home › topics › ai-agents › article
[ARTICLE · art-142659] src=rogs.me ↗ pub= topic=ai-agents verified=true sentiment=↑ positive

I don't write codebase documentation anymore

A developer reports building an agent-maintained codebase wiki that generated a 95-page, roughly 134,000-word documentation set with 67 Mermaid diagrams for one project, updating automatically on every push to main. The wiki lives in docs/wiki/ inside the repository in plain markdown, with an .outline.json file mapping which source files each page owns and a .wiki-state.json file recording the commit it was last generated from, and the author says the two configuration files needed to replicate it are included at the end of the post.

read44 min views2 publishedSep 30, 2026

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.


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 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 (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 wedon’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 andwiki.yml to.github/workflows/wiki.yml . Make suredocs/wiki/ isn’t git-ignored.
  2. Set up model access. My workflow uses a LAZER_PROXY_API_KEY secret and aLAZER_PROXY_BASE_URL variable because everything goes through our proxy. If you’re calling Anthropic directly, put your API key in a secret, pointanthropic_api_key at it, and delete theANTHROPIC_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/:*)
---


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  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:

{"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:

> 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
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

#

#

#

#

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: 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 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: fetch-depth: 0

  - id: start
    run: echo "at=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"

  - uses: anthropics/claude-code-action@v1.0.235
    timeout-minutes: 75
    with:
      anthropic_api_key: ${{ secrets.LAZER_PROXY_API_KEY }}
      allowed_bots: github-actions
      show_full_output: true
      prompt: "/wiki ${{ env.WIKI_MODE }}"
      #
      #
      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:
      ANTHROPIC_BASE_URL: ${{ vars.LAZER_PROXY_BASE_URL }}

  - name: Commit wiki updates
    if: ${{ !cancelled() }}
    env:
      RUN_STARTED_AT: ${{ steps.start.outputs.at }}
    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
        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]"
        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!
── more in #ai-agents 4 stories · sorted by recency
── more on @github 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/i-don-t-write-codeba…] indexed:0 read:44min 2026-09-30 · —