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.
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.
brief returns the line, its rationale, deps, blockers and the non-goals in one tool resultlint exits 0 or 1 and names file:line:column — no judgement, no sampling, same answer every run--json answer carries which file and which line it came from, so auditing costs nothingship <id>: one call, all of them or none, dependents re-annotatedSame 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.
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.
- 📋 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.
$ 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
(deps: —) annotation and the → §T1 pointer are derived.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”).
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.
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
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.
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.
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.
/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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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
So the comparison is a narrow one, and each of these is good at what it does.
| Tool | What it does well | Why roadkeep is not it |
|---|---|---|
| markdownlint | Structure and style of Markdown | Explicitly not prose — it will never tell you a sentence is too long |
| Vale | Prose rules and style guides | A linter: it reports after the text exists, which is the cost being avoided |
| Backlog.md, taskmd, the markdown-task family | Mature task management, kanban, MCP | One .md file per task, with acceptance criteria and DoD — more room, and more room invites more prose |
| ADR / MADR | Rationale that survives; superseded is never deleted | roadkeep 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 Issues | Planning across a company, at scale | A 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.
Nothing to learn and nothing to remember — a shape: ask what to work on, write the line, ship it, prove nothing drifted.
What to work on and everything it costs to start it — line, rationale, deps resolved, blockers, non-goals.
Compose the line. The field is refused, never the sentence — and the id, pointer and annotations are derived.
Three edits across three files, all of them or none, plus every dependent's annotation.
Exit 1 when anything drifted, naming file:line:column. That exit code is the contract.
$ 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.
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.
$ 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
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.
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.
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.
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.
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@alegaussFrom 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.
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 XXuvx roadkeep adopt docs/IMPROVEMENTS.md --sections --with docs/STRATEGY.mdBoth 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 ….
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.
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.
- 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.
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.
The format is a schema, enforced where the text is created; lint is only the backstop.
The store is the repository — Markdown, greppable, diffable. No database, no service.
Round-trip or don't write — parse → render → byte-identical.
The tool never writes prose — it validates and renders.
Query instead of read — every question is a command.
Configuration, not convention — prefix, paths, markers and limits are per project.
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.
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.
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.