A Claude Code plugin · Python ≥3.11 · zero dependencies · Apache-2.0

Claude Code should spend its context on the work,
not on policing your docs.

roadkeep turns the format of your ROADMAP.md, CHANGELOG.md, IMPROVEMENTS.md, STRATEGY.md, DEFERRED.md and DECISIONS.md into a schema a command enforces. So keeping them stops being something the model has to remember, read and judge, and becomes what a tool does: procedural, deterministic, and paid for once.

One call writes the line. One exit code decides whether anything drifted.

🧠 Context stays on the task, not on the format🗂 Your existing Markdown — no migration🚫 Zero runtime dependencies Hand-edits denied at the source🔧 64 MCP tools, schemas derived from your config
Why it exists

Keeping docs is work Claude Code should not be paying for

Every rule an agent has to remember, every file it has to read to answer a question, and every judgement a reviewer then has to re-check is context and wall-clock spent on bookkeeping. roadkeep moves all three into a subprocess.

What a turn needs
Without roadkeep
With roadkeep
“What should I work on next?”
Read the backlog end to end — ~5k tokens in this repository, every time it is asked
brief returns the line, its rationale, deps, blockers and the non-goals in one tool result
“What is the format here?”
Rules in a resident instruction file — the one measured below reached 186 KB, ≈46k tokens on every turn
A trigger-loaded skill: read on the turns that write a governed file, zero on the turns that don't
“Is this line valid?”
The model judges the prose, then a human judges the model — two soft verdicts
lint exits 0 or 1 and names file:line:column — no judgement, no sampling, same answer every run
“This line is too long.”
Delete a turn of finished prose and write it again — the expensive half of the turn is already spent
The field is refused before the sentence exists: exit 2 names the limit, and nothing was written
“Can I trust that answer?”
Re-open the file to verify what the last command said — paying the read anyway
Every --json answer carries which file and which line it came from, so auditing costs nothing
“Ship it.”
Three hand-edits across three files, plus every dependent's annotation — and one of them gets forgotten
ship <id>: one call, all of them or none, dependents re-annotated

Same repository, same rules. What changes is who pays: a context window, or a dependency-free subprocess that answers in milliseconds and is done thinking about it.

Where the tokens actually go

Every backlog rots the same way

Not from neglect — from an author who knows more than the line allows, and a rule that only a reviewer holds. When that author is a model, the rot is billed twice: once to write it, once to read it back.

One task, T1, written both ways — hand-edited on the left, through add on the right. Same repository, same rule, same intent.

BY HAND A format nobody enforces
- 📋 T1 Refactor the cache layer. We looked at three
  options here and the reason we went with the second is
  that the first would require touching the scheduler,
  which nobody owns anymore, and the third needs a
  migration. Note that Marc raised the same concern last
  quarter, and the benchmark he attached is still the
  best evidence we have.
  • 396 characters against a one-sentence rule — and nothing objected.
  • Named after its fix, so the line can never be falsified — only abandoned.
  • The rationale landed where the reader is, so every later turn re-reads it.
  • Finding this task again means reading the file: ~5k tokens per ask.
THROUGH add A format that is a schema
$ roadkeep add --block A --symptom "…" --why "…"
roadkeep: refused, nothing written:
  why: 305 characters, limit is 200
  why: why is one sentence; a second is the signal
       it belongs in the section this line points at
  line: rendered line is 396, limit is 320
                                    exit 2

$ roadkeep add --block A --symptom "…" --why "…"
- 📋 T1 (deps: —) Every read of the cache layer
  serialises through one lock — the second option
  avoids touching the scheduler nobody owns, and
  needs no migration. → §T1
event    T1  Block A  open
  • 185 characters, refused twice before it existed — the retry cost a field, not a turn.
  • The marker, the (deps: —) annotation and the → §T1 pointer are derived.
  • The rationale went to the file the pointer names, where nothing loads it by accident.
  • The event line is the whole payload a hook needs — no file to re-read.
“The saving is the analysis,
not the characters.”

A linter reports after the prose exists — the tokens are spent and the author is asked to delete work they just did. A field with maxLength: 200 refuses first. Same rule, two orders of magnitude cheaper, and it turns an analytical act (“is this too long, and what would I cut?”) into a procedural one (“call add”).

Inside Claude Code

Four surfaces, so the cheap path is the correct one

An agent bypasses any format with one Edit, because Edit is cheaper than reading a --help. The plugin removes that trade instead of losing it: the tool call arrives pre-described, and the hand-edit comes back naming the command that replaces it.

PreToolUse — Edit on docs/ROADMAP.md
Edit refused: docs/ROADMAP.md is this project's
roadmap, and roadkeep owns its writes.

Call instead, from the project root:
  roadkeep add --block <x> --symptom "…" --why "…"
        a new task line, fields refused at input
  roadkeep status <id> <marker>
        a marker, and only in this file
  roadkeep ship <id>
        ledger entry, line gone, section dropped
1 · the hook

A denial that costs one line, not a turn

A refusal with no next step is one an agent routes around, burning the turn to find its own way in. This one is the instruction — command and flags included — so complying is less work than retrying. A second hook runs lint before the turn ends, so drift is caught by the agent that can still fix it instead of by a human, tomorrow.

2 · the MCP server

The schema arrives with the tool

64 tools over stdio — the whole write path and the whole query surface — with an input schema derived from your roadkeep.toml: maxLength is this project's field limits, enum its markers, pattern its id shape. No flags to recall, no round trip to a usage string — a wrong argument is refused by the protocol. Every description is held to a character budget lint enforces, so the surface cannot quietly grow into the context it was meant to save.

3 · the skill

Rules that load only on the turns that need them

The whole write path lives in a trigger-loaded skill, read when a governed file is in play and costing nothing on every other turn. It ships with the plugin, so the standard is the same text in every project rather than a paragraph each repo re-invents in the file that always loads.

4 · the slash commands

Silence is the allow, and every failure allows

/roadkeep:add, :ship, :pick and :lint are there for the person driving. The guard behind all of it returns only deny, never allow, so it never waves through the permission rules you set — and a broken config, bad JSON or a missing path lets the write through: a guard that denied on its own errors would turn one typo into a repository nobody can edit.

Four surfaces, one engine: every one dispatches through the same parser a terminal uses, so there is one set of refusals to trust and one place a rule can change. A fifth is for the person in the editor — a VS Code extension showing the backlog as a tree, ready before blocked, with the blocker named. It carries no rule of its own: every row is a payload roadkeep printed, so a project whose prefix, markers or limits differ needs nothing changed in it.

Strengths

Eight ways a turn gets cheaper

Each one is a property the tool can be held to — a test, an exit code or a schema, not a promise in a README.

The whole point

Ask instead of read

Every question is a command — pick, brief, deps, show, stats, origin — answering inside one tool result. Finding one ready task by reading the backlog cost ~5k tokens here; brief starts the same task and adds the deps, the blockers and the non-goals.

🔌
Nothing resident

The rules cost nothing on the turns that don't write

The write path is a trigger-loaded skill, not a paragraph in an instruction file that every turn pays for. And the instruction files themselves get a line and byte budget lint enforces — because the one measured below reached 186 KB while declaring a limit about itself.

🎯
Deterministic, not judged

The verdict is an exit code

lint exits 0 or 1 and names file:line:column. The model does not decide whether the file conforms, and does not need to be trusted about it: same file, same answer, every run. Every query takes --json carrying file and line, so nothing is re-read to be believed.

🛑
Refused, not reviewed

The cheapest refusal is the one before the prose

Fields are validated at insertion and the write is all-or-nothing: an over-length why exits 2 naming the length and the limit, and nothing reaches the file. A retry costs a field. A review costs the turn that wrote it.

🧾
One call, three files

No multi-file edit to get right

ship <id> writes the ledger entry, clears the roadmap line, drops the rationale section and re-annotates every dependent — all of them or none. Four edits an agent would otherwise plan, execute and verify become one command and one event line.

🪶
No setup turn

argparse and tomllib. That is the whole stack.

Zero runtime dependencies — not click, not pydantic. Nothing to resolve means one uvx line runs the gate with no install and no checkout, and it governs the Markdown you already have: no migration, no store, no service.

📐
The config is governed too

A limit is declared against the reading that decides it

config prints every table, key and default your roadkeep.toml may carry. govern <key> <n> takes the reading and the number in one call — and refuses a limit the corpus already breaks, because one whose first act is a finding is one somebody lowers, reads the report and raises again. --because "…" keeps your argument in comments above the number, and the read hands it back.

🤝
More than one session

Two agents on one tree do not collide

claim says which lines a worker is holding and what its commit owns; every write prints the git add -- line for exactly what it wrote. So a second session's work is not swept into your commit, and a line somebody else is on comes back named rather than silently overwritten. merge is git's own driver for a governed file: entries by id, so two branches appending under one heading is two additions and not a conflict.

The differentiator

Everything else in this space reports. roadkeep refuses.

Linters, kanban files, ADR sets — all of them are read after the text exists. That is one position on a timeline, and for a model it is the expensive one: the report arrives when the output tokens are already spent.

roadkeep is the only one that sits at the write path, which is also the only position from which an agent can be constrained cheaply. And a line that never got long is a line nothing has to re-read — the saving compounds on every later turn, not just the one that wrote it.

the timeline of one line
roadkeep add     ← refuses here.  paid: one field

   ├─ the turn writes the prose
   ├─ Vale / markdownlint  ← reports here
   ├─ pull request review  ← reports here
   └─ …6 months of backlog ← nobody reports
                    paid: the turn — then paid again
                          to read it back, every turn
Honest comparison

The space around this is not empty

So the comparison is a narrow one, and each of these is good at what it does.

ToolWhat it does wellWhy roadkeep is not it
markdownlintStructure and style of MarkdownExplicitly not prose — it will never tell you a sentence is too long
ValeProse rules and style guidesA linter: it reports after the text exists, which is the cost being avoided
Backlog.md, taskmd, the markdown-task familyMature task management, kanban, MCPOne .md file per task, with acceptance criteria and DoD — more room, and more room invites more prose
ADR / MADRRationale that survives; superseded is never deletedroadkeep keeps the decision too — one line, not one file, written by the ship that took it. An ADR set grows monotonically; that curve is the 539 KB below
Jira, Linear, GitHub IssuesPlanning across a company, at scaleA backlog that lives in a service is one an agent cannot grep — and every read is a round trip

roadkeep composes with all five. It owns four files; it asks for nothing else.

How it works

A whole task is four calls

Nothing to learn and nothing to remember — a shape: ask what to work on, write the line, ship it, prove nothing drifted.

1

brief

What to work on and everything it costs to start it — line, rationale, deps resolved, blockers, non-goals.

2

add

Compose the line. The field is refused, never the sentence — and the id, pointer and annotations are derived.

3

ship <id>

Three edits across three files, all of them or none, plus every dependent's annotation.

4

lint

Exit 1 when anything drifted, naming file:line:column. That exit code is the contract.

the same task T1, from picked to gated
$ roadkeep brief        # the whole start of a task, in one tool result
T1  Block A  📋  ready  docs/ROADMAP.md:5
  picked   lowest ready id
  symptom  Every read of the cache layer serialises through one lock
  why      the second option avoids touching the scheduler nobody owns, and needs no migration.
  unblocks 1 of 1 open: T2
  not      No cache rewrite                 # the non-goals, every time
  not      No new dependency

### §T1 One lock, three options              # its rationale, inlined

One lock serialises every read, so the second option is chosen because it avoids
the scheduler nobody owns and needs no migration.

$ roadkeep ship T1      # three files, one transaction
T1 → docs/CHANGELOG.md:5 under Block A
  removed  docs/ROADMAP.md:5
  dropped  §T1 (5-9) from docs/IMPROVEMENTS.md
  derived  T2 (dep annotations re-derived)
  event    T1  Block A  open

$ roadkeep lint         # the gate, before the turn ends
docs/ROADMAP.md, docs/CHANGELOG.md, docs/IMPROVEMENTS.md:
2 line(s), 1 section(s), clean            # exit 0

That is a task started without opening a file. Every command takes --json carrying which file and which line the answer came from — because an answer an agent cannot audit gets verified by reading the file, which is the cost the command existed to remove.

The decision, kept

The rationale is deleted. The decision it reached is not.

ship drops the design section — that is the point, and it is why IMPROVEMENTS.md does not become the 539 KB below. But the verdict inside it outlives the work, and it used to go wherever the author happened to put it.

one transaction, four files
$ roadkeep ship T1 --why "…" --decides "…"
T1 → docs/CHANGELOG.md:5 under Block A
  removed  docs/ROADMAP.md:5
  dropped  §T1 (5-8) from docs/IMPROVEMENTS.md
  decided  docs/DECISIONS.md:5
  stage    git add -- docs/CHANGELOG.md docs/DECISIONS.md
                      docs/ROADMAP.md docs/IMPROVEMENTS.md
  event    T1  Block A  live

$ roadkeep supersede T1 --by T2
docs/DECISIONS.md:5  T1 superseded by T2
  - 🗑 T1 Every read of the cache layer serialises
    through one lock — Reads go through a striped lock;
    the scheduler is not touched (superseded by T2).
  kept     T2 stands and T1 is history — nothing
           in this file is ever deleted
DECISIONS.md · a governed role

An ADR that is one line, written by the command that took it

A decision cannot be filed after the fact, because after the fact is when nobody remembers it — so it is taken with the same call that deletes the design, in the same transaction. supersede marks one replaced by another and deletes nothing: the row stays, marked 🗑, carrying the id that replaced it. reversals reads them back — what this ledger already decided and undid, with the argument.

And it is optional. declare decisions adds the role to a project already configured; a repository that wants none never sees it.

The problem, measured

This did not start as an idea

It started as three readings from a real production repository where every one of these files declared a format, none of them enforced it, and an agent paid for all three.

docs/ROADMAP.md
142 words
average, across 92 task lines — worst line 1406 characters
Declared rule: one sentence per task.
agents.md
186 KB
46k tokens, loaded on every single turn
Declared rule: an index, nothing more.
docs/IMPROVEMENTS.md
539 KB
in a sibling project — rationale that was never dropped
Declared rule: rationale for unshipped work.

The finding that decided the design: six of the eight worst lines were written in the session that then diagnosed the problem.

This is not inattention. An author — human or model — who has the whole design in working memory will write it down where the reader is. An instruction to be terse does not survive the moment its author knows more than the line allows. So roadkeep also holds a byte and line budget on the instruction files nobody edits on purpose.

Get started

Two commands, and the repository carries the rest

Python ≥3.11 and nothing to resolve. Nothing is installed and nothing joins your PATH — and it reads the Markdown you already have, so there is no migration to plan.

1

Install the plugin, in Claude Code

Hook, MCP server, skill and slash commands — the enforcement point an agent cannot route around, and the schema it calls with. The package ships inside it, so there is no second thing to install: roadkeep guard and roadkeep mcp are the CLI, and the plugin already carries it.

/plugin marketplace add alegauss/roadkeep
/plugin install roadkeep@alegauss

From a shell instead, the same two with claude plugin … --scope project write both declarations into that repository's .claude/settings.json — commit it and every clone is wired, with no per-machine step.

2

Measure before you commit to it

adopt runs the schema over the backlog you already have and reports the delta: what parses, the longest symptom, why and rendered line against their limits, the markers to declare. It writes nothing and never exits 1 — an estimate that is a gate is one you took too late. It runs before the project is governed, so it is the one step that wants a shell.

uvx roadkeep adopt docs/ROADMAP.md --prefix XX
uvx roadkeep adopt docs/IMPROVEMENTS.md --sections --with docs/STRATEGY.md

Both halves, because both are limits you have to declare — and the numbers [limits] gets set from come from your corpus rather than copied from this one.

No uv on the machine? The plugin you just installed carries the same engine, so nothing has to be fetched: python ~/.claude/plugins/marketplaces/alegauss/scripts/roadkeep.py adopt ….

3

Declare the format once

init writes roadkeep.toml — your prefix, paths, markers and limits — and the governed files it declares. No starter task and no prose: a title, the blocks you name, and where the non-goals go. On a repository with no backlog yet, this is the only step of the two you need.

uvx roadkeep init --prefix XX --block "A — <label>"

Everything a task needs afterwards is already in the tools the plugin installed — add, status, ship, brief, pick, lint — validating against the schema roadkeep.toml just declared. No shell, no PATH.

Nothing here is one-way. declare <role> adds a governed file to a project that is already configured — a strategy document, a deferred list, the decisions above — writing its file and the one key, and leaving every other byte of your config alone. govern moves the numbers afterwards, against the reading that decides each one.

4

Make it a gate

The same command in CI and at the commit — a gate that runs in one place is a gate with a documented bypass. --fix repairs only what the format derives and leaves every editorial finding to a human.

.github/workflows/gate.yml · .pre-commit-config.yaml
- uses: alegauss/roadkeep@v0.2.0 # the action this repo ships

repos:
  - repo: https://github.com/alegauss/roadkeep
    rev: v0.2.0                   # a release tag; main tracks unreleased
    hooks:
      - id: roadkeep-lint          # or roadkeep-lint-fix

Or keep it to one command in an existing pipeline, with no plugin and no checkout: uvx roadkeep lint — exit 0 clean, 1 drifted. It is on PyPI, so that line resolves a name and not a URL, and pip install roadkeep pulls the package alone — zero runtime dependencies means there is nothing else to resolve.

What each verb takes, what every finding code means and what an adoption actually printed are in the documentation area — generated from this parser and this gate rather than written beside them, so a page cannot state a flag the tool does not answer. Evaluation comes before installation, so it reads without one.

The six laws

A change that breaks one is wrong, even if requested

L4 is the one people try to relax first. A generator that writes the symptom for you would reintroduce exactly the drift this exists to stop.

L1

The format is a schema, enforced where the text is created; lint is only the backstop.

L2

The store is the repository — Markdown, greppable, diffable. No database, no service.

L3

Round-trip or don't write — parse → render → byte-identical.

L4

The tool never writes prose — it validates and renders.

L5

Query instead of read — every question is a command.

L6

Configuration, not convention — prefix, paths, markers and limits are per project.

Non-goals

These are binding, and half the point

What a tool refuses to become is the reason it stays small enough to trust.

No web UI and no server. Files and a CLI; the MCP server binds nothing and stores nothing.

No issue-tracker sync. A backlog that lives in a service is one an agent cannot grep.

No model and no prompts inside the tool. It validates and renders; it never writes the symptom or the rationale.

No dates, quarters or estimates. A marker is maturity, not a schedule.

Proof, not a promise

The format is proven by the artefact, not asserted in a README

roadkeep lint must pass on this repository's own docs/, under this repository's own roadkeep.toml, and the test suite asserts it. A limit that cannot express these lines is the wrong limit, not a set of wrong lines — and round-trip is a property test over real roadmaps, including two this project does not own: parse → render → byte-identical, or the write is refused.

And when the tool is what is wrong, the refusal says so: every failure prints the report line that files it — the argv, the config, the governed files — as facts a replay re-runs against whatever tree is there later. A bug report that is a paragraph is one nobody can reproduce; this one is an input.

Give Claude Code its context back

One config file, four commands, and a plugin that makes calling them cheaper than editing the file by hand. The format stops being something a turn spends tokens remembering, writing and checking, and becomes something a subprocess decides — while your Markdown stays greppable, diffable and readable by anyone who never installs this.