A CLI that owns the writes to your ROADMAP.md, CHANGELOG.md, IMPROVEMENTS.md
and STRATEGY.md, so the format is a schema at the point of insertion instead of a
convention an author is asked to remember.
Shipped as a Claude Code plugin, because the author to constrain is usually an agent.
This did not start as an idea. It started as three readings from a real production repository, where all three files declared a format and none enforced it:
| Artefact | Declared rule | Actual reading |
|---|---|---|
docs/ROADMAP.md |
one sentence per task | 92 lines, 142 words average, worst 1406 characters |
agents.md |
index only, loaded every turn | grew to 186 KB (~46k tokens) |
docs/IMPROVEMENTS.md |
rationale for unshipped work | a sibling project's reached 539 KB |
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 where the reader is, and an instruction to be terse does not survive the moment its author knows more than the line allows.
A linter reports after the prose exists. By then the tokens are spent, and the author
is being asked to delete work they just did. A field with maxLength: 200 refuses at
the point of insertion, before a sentence is composed to fill it.
Same rule, two orders of magnitude cheaper — and it converts an analytical act ("is
this too long, and what would I cut?") into a procedural one ("call add").
The saving is the analysis, not the characters.
The space around this is not empty, and the honest comparison is narrow:
| What it does well | Why roadkeep is not it | |
|---|---|---|
| markdownlint | structure and style of Markdown | explicitly not prose — it will not tell you a sentence is too long |
| Vale | prose rules, style guides | a linter: it reports after the text exists, which is the cost being avoided |
| Backlog.md, taskmd, and the markdown-task-for-agents 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 never deleted | an ADR set grows monotonically; that curve is the 539 KB above |
Four properties are the actual differentiator:
- Enforced at the write path, not the review. The refusal happens before the prose exists. Everything else in this space reports afterwards.
- It governs the file you already have. One line in your existing
ROADMAP.md— not a new store you migrate into. A repository with a hand-written backlog is the target, not the obstacle. - Round-trip or refuse. Parse → render → byte-identical, or the tool declines to write the file at all. A tool that owns writes to a hand-edited file has to prove it cannot corrupt one.
- Query instead of read. Every question a maintainer asks the file is a command, so answering it costs no context. Reading a backlog end-to-end to find one ready task cost ~5k tokens in this very repository.
A change that breaks one is wrong even if requested. docs/IMPROVEMENTS.md §0.3 is authoritative.
| # | Law |
|---|---|
| L1 | The format is a schema, enforced where the text is created; lint is 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. |
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 table below is not written by hand — roadkeep export --readme derives it from
docs/, which is the point of RK39: a README that restates a backlog it cannot re-read is
stale from the first ship, and this one claimed "8 of 36" while four of the commands it
called unbuilt were already in the ledger.
| Block | Open | Shipped |
|---|---|---|
| A — The model (a task is data before it is a line) | 0 | 11 |
| B — Authoring (insert, never hand-edit) | 0 | 8 |
| C — Query (consult without reading the file) | 0 | 9 |
| D — The gate | 0 | 9 |
| E — Adoption | 1 | 3 |
| F — The Claude Code plugin (the guardrail at the agent boundary) | 4 | 1 |
| Total | 5 | 41 |
Next ready:
- 💭 RK21 (deps: RK20 ✅) A standard adopted by one project is a preference — roll out to Turing, Dumont and Cursarei, each with its own
roadmap.toml. → §RK21
Every command takes --json, which carries provenance — which file and 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.
Still open, and where to look: docs/ROADMAP.md. What is left of Block F is the packaging around the hook below: a skill, the commands, the MCP tools.
Python ≥3.11, zero runtime dependencies — argparse and tomllib, not click and
pydantic. A tool meant to run in someone else's CI pays for every dependency it takes,
and that is also what makes the first line below viable: there is nothing to resolve.
uvx roadkeep lint # no install, no checkout
pip install roadkeep
pip install git+https://github.com/alegauss/roadkeep # an unreleased commitroadkeep lint exits 1 on a file that drifted and 0 on one that did not — that exit
code is the whole contract, so every surface calls the same command rather than a copy of it.
A gate that runs in only one place is a gate with a documented bypass.
# .github/workflows/gate.yml — the action this repository ships
steps:
- uses: actions/checkout@v4
- uses: alegauss/roadkeep@main # with: {directory: .}
# .pre-commit-config.yaml — the same command, one step earlier
repos:
- repo: https://github.com/alegauss/roadkeep
rev: v0.1.0 # or main, to track an unreleased commit
hooks:
- id: roadkeep-lint # or roadkeep-lint-fix, which normalizes first--fix repairs only what the format derives — the dep annotation, the pointer, dep order,
an invisible codepoint, whitespace around a field — and leaves every editorial finding to a
human, which is what keeps a first run on a real backlog down to a report somebody reads.
Two things it reports without failing, because refusing them would fail an honest file
and a gate that gets bypassed is worth nothing: what a Block X dep expands to (one token
named 41 open tasks in the backlog measured above), and — with --since HEAD, which the
pre-commit hook passes — a rationale section edited while the line carrying its status was
not. The line is the only thing pick reads and the section is deleted on ship, so a
requirement written only into the reasoning cannot be picked, shipped, or kept.
The gate also holds the files nobody edits on purpose. An instruction file loaded on every
turn spends the resource this tool exists to protect, and agents.md reached 186 KB in
the project measured above while declaring a 150-line rule at the bottom of itself — so the
budget moves out of its prose and into the configuration, in both units a reader pays:
[budgets]
"agents.md" = { lines = 150, bytes = 11000 } # this repository's own, held by `lint`The store is Markdown (L2), so an agent can bypass the entire format with one Edit — and
will, because Edit is cheaper than reading a --help. A gate at the commit catches that a
whole turn of prose too late: the tokens are already spent, and the report asks for a
deletion. So the plugin installs the one enforcement point an agent cannot route around.
A write to a file some project's roadkeep.toml declares is denied with the command that
does it properly, flags included — a refusal that names no alternative is one an agent
routes around, and one that names the command makes the denial the cheapest path forward:
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> shipped: ledger entry, line gone, section dropped
Three properties are load-bearing, and each is a test rather than an intention. The config is
discovered from the file and not from the working directory, so one hook process answers
correctly for every repository a session touches. Silence is the allow — deny is the only
decision it ever returns, because allow in this protocol grants the write and would wave
through the permission rules you set for every file the tool has no opinion about. And every
failure allows: a broken roadkeep.toml, a payload that is not JSON, a tool input with no
path. A guard that denies on its own errors turns one typo into a repository nobody can edit,
and the gate is still there at the commit.
Bash is deliberately not matched: sed -i on the roadmap is a real bypass, and matching every
shell command to catch it taxes every command in the session. The Stop hook runs lint
instead — so the bypass is caught before the turn ends, by the agent that can still fix it.
These are binding, and half the point. Check before proposing work:
- No web UI and no server. Files and a CLI.
- 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 docs/ here. The format is proven by the artefact rather
than asserted in a README — including this one. A limit that cannot express these lines
is the wrong limit, not a set of wrong lines, and the test suite asserts it against
docs/ROADMAP.md under this repository's own roadkeep.toml.
