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 | Retired |
|---|---|---|---|
| A — The model (a task is data before it is a line) | 0 | 43 | 2 |
| B — Authoring (insert, never hand-edit) | 3 | 149 | 2 |
| C — Query (consult without reading the file) | 5 | 98 | 1 |
| D — The gate | 2 | 175 | 2 |
| E — Adoption | 3 | 86 | 1 |
| F — The Claude Code plugin (the guardrail at the agent boundary) | 0 | 108 | 1 |
| G — The editor surface (the backlog where the file is open) | 0 | 11 | 0 |
| H — The tool's own shape (what one verb costs to change) | 1 | 6 | 0 |
| Total | 14 | 676 | 9 |
Next ready:
- 🛠 RK1171 (deps: RK1169 ✅, RK1170 ✅) build_parser is one 2,000-line function, so a verb's flags are edited in the file the whole surface is in — cli.py was split by layer for size and grew back to 2,260 code lines, so the cut that holds is per verb, which the two deps make a move and not a rewrite. → §RK1171
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.
Open work, and where to look:
docs/ROADMAP.md — the count
and the next ready line are in the derived block above, so this sentence states neither. The
same file carries the non-goals, which is the half of it that binds: brief prints them
with every task, and they are what a proposal is checked against before it becomes a line.
Four projects are adopted and governed — this one, Shio, Turing and Dumont — each with its own
roadkeep.toml and the measurement that produced it. A fifth candidate was measured and
retired rather than adopted: its tasks are wrapped paragraphs with their deps on a line of
their own, and reading those is a second grammar rather than a configuration key. That refusal
is the one worth reading, because a tool that stretched to fit it would have stopped being
able to promise the round-trip.
Nothing is installed and nothing is added to PATH. The plugin ships this package, so the
only thing the machine needs is a Python ≥3.11 interpreter — which is what runs the tool
either way. Run these in the project you want governed:
claude plugin marketplace add alegauss/roadkeep --scope project
claude plugin install roadkeep@alegauss --scope project--scope project writes both declarations into that repository's .claude/settings.json:
Commit that file and every clone is wired — no per-machine step, no OS-specific path. What
arrives with it: the hook that denies a hand-edit and names the tool, the twelve MCP tools
whose input schema is your project's schema, the four /roadkeep:* commands, and the skill
that loads only when a governed file is in play (~300 tokens per session, all in).
Then declare the format once. init and adopt run before the project is governed, so they
are the one thing that wants a shell — the plugin's own copy answers, with no install:
# the plugin's launcher, wherever the marketplace was cloned
R=~/.claude/plugins/marketplaces/alegauss/scripts/roadkeep.py
python $R adopt docs/ROADMAP.md --prefix SH # measures first: what would change, and where
python $R adopt docs/IMPROVEMENTS.md --sections --with docs/STRATEGY.md # the other half
python $R init # writes roadkeep.toml and the files it declaresBoth halves, because both are limits you have to declare. The backlog run reports the longest
symptom, why and rendered line; the --sections run reports the longest section in words
and the width your prose is already wrapped to — the numbers [limits] gets set from, taken
from your corpus rather than copied from this one. --with names the other prose files, and
is the one measure that is about a set: two outlines written apart both start at I, so the
address they share is a finding no per-file read can make and the first one lint would.
Everything a task needs afterwards is already in the tools the plugin installed: add,
status, ship, retire, record_add, record_drop, section_add, section_drop,
brief, pick, list,
deps, lint. No shell, no PATH, and the schema each of them validates against is the one
roadkeep.toml just declared.
Viglet Shio is the reference adoption: 80 task
lines, a 618 KB ledger of 233 entries written years before this tool, and a roadkeep.toml
that declares exactly what that history is — [ledger] for the two slots its lines never
carried, [limits.changelog] for a file whose median line is 1038 characters, and
[rules.changelog] for the two prose rules history cannot obey.
A project adopting an unreleased version runs a sibling checkout rather than the published
plugin — and then the hook, the tools and the skill arrive with nothing, because those ship
with the plugin. install writes them itself, translated from the files the plugin carries,
with the launcher's path as the only substituted fact:
python ../roadkeep/scripts/roadkeep.py -C . install
python ../roadkeep/scripts/roadkeep.py -C . install --check # in CI: still in step?.mcp.json, the guard on its three hook events in .claude/settings.json, a verbatim
.claude/skills/roadkeep/SKILL.md, and — only where the repository already has workflows — a
job calling the action above. The skill is refreshed on every run and --check exits 1
once it drifts, which is what a vendored copy otherwise has nothing to keep it in step with.
Declarations are merged, so what another tool wrote in either file survives; the workflow is
written once and tuned by you thereafter. The fifth surface is named and not written: the line
in CONTRIBUTING.md is prose about your project, and this tool writes none (L4).
Claude Code on the web has no
/plugin command and never installs a marketplace plugin — a cloud session reads only what the
repository commits (.claude/settings.json, .mcp.json, .claude/skills/) and a setup script.
So the two commands at the top of this section wire nothing there: the hook, the server and
the skill all ride with a plugin the environment does not install, and an agent with no guard
falls back to hand-editing the governed files — the one drift this tool exists to refuse, now
in the one place it cannot see itself doing it ("No roadkeep plugin/hooks → I'll track via git
log" is the tell).
The fix is the checkout path made portable: commit a small launcher and point the hook and the
server at it instead of at ${CLAUDE_PLUGIN_ROOT}. It resolves an engine at run time —
$ROADKEEP_HOME, an installed plugin, a sibling ../roadkeep, a cached clone, or a fresh
git clone — and runs the same guard and mcp entry points the plugin would, so one
wiring holds on a laptop and in the cloud:
// .claude/settings.json — the guard on its three events, launcher instead of the plugin root
"hooks": { "PreToolUse": [{ "matcher": "Edit|MultiEdit|NotebookEdit|Write|Bash",
"hooks": [{ "type": "command",
"command": "python \"${CLAUDE_PROJECT_DIR}/.claude/hooks/roadkeep-launch.py\" guard" }] }] }
// .mcp.json — the same launcher, `mcp` mode, gives `mcp__roadkeep__*` where the plugin cannot
"mcpServers": { "roadkeep": { "command": "python",
"args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/roadkeep-launch.py", "mcp"] } }Two rules keep the committed launcher from ever making a machine worse than the plugin did, and
both are the guard's: it defers to the plugin when one is installed, so no deny message
doubles on the laptop that already has it, and it no-ops on exit 0 when no engine is found,
so a repository without roadkeep is never blocked by a hook that cannot answer. The mcp mode
keeps neither — a stdio server cannot stand down, since the exit that would say so is what the
harness reads as a crash.
Viglet Shio carries the reference copy at
.claude/hooks/roadkeep-launch.py.
uvx roadkeep lint # no install, no checkout
pip install roadkeep
pip install git+https://github.com/alegauss/roadkeep # an unreleased commitPython ≥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 above viable: there is nothing to resolve.
roadkeep 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, a queue entry whose task has left — 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.
Every finding names the command that closes it, and roadkeep repair runs the ones that
need no decision — the mechanical pass, then each complete argv, re-reading the report between
writes because a repair moves the line numbers after it. That matters most for the caller this
ships for: the guard denies an agent the hand edit, so a report whose remedy lived in English
cost a turn per finding to act on. What still needs a sentence you have to write, or a choice
between two doors, is printed with both and never guessed at — the tool does not write prose.
repair --dry-run prints the commands and runs none of them, and roadkeep explain <code>
answers what a code means — the class, what produces it, and its doors — which is the one
question the report could never answer, since a finding is about a line and a code is about a
kind. With no argument it lists them all, which is a vocabulary this tool had never published.
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 = 125, bytes = 8400 } # 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.
// hooks/hooks.json, shipped in this repository — `roadkeep guard` answers both events
"PreToolUse": [{ "matcher": "Edit|MultiEdit|NotebookEdit|Write", … }] // deny, and say what to call
"Stop": [{ … }] // `lint`, before the turn endsA 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.
The denial is one surface of four; the other three are what make calling the command cheaper
than typing it. The plugin ships an MCP server — roadkeep mcp, JSON-RPC on stdio, no
port and no state — exposing add, ship, pick and lint as tools whose input schema is
derived: maxLength is this project's symptom and why limits, enum is its declared
markers, pattern is its id shape, and the description is the subcommand's own help. So a
misspelt --deps is refused by the protocol with the arguments that exist, instead of costing
a round trip to a usage string, and every call is dispatched through the same parser a
terminal uses — one engine, one refusal.
The same four are slash commands — /roadkeep:add, /roadkeep:ship, /roadkeep:pick,
/roadkeep:lint — for the person driving the standard, who reads /help and not a JSON
Schema. /roadkeep:add F | what does not work | one sentence. passes those words verbatim:
the command files are written so that every instruction about the user's text is a
prohibition, because a prompt that said "write a concise symptom" would have moved the prose
generation one file to the left while keeping L4's letter. That is asserted, not intended —
tests/test_commands.py refuses the phrasing.
The fourth is a skill, skills/roadkeep/SKILL.md, holding which command to call, what it
derives, the two rules a schema cannot check, and how work is picked. It is a skill and not a
paragraph in the
project's instruction file because instructions are loaded on every turn, including the ones
that touch no roadmap — the budget above exists because that is how the 186 KB happened. The
skill is read when a governed file is in play and costs nothing otherwise, and it ships with
the plugin, so the standard is the same text in every project rather than a copy per repo.
All four install with the two commands at the top of this file, and both surfaces the harness
starts run python "${CLAUDE_PLUGIN_ROOT}/scripts/roadkeep.py" — the package the plugin
already copied. There is no console script to install and no PATH entry to add, because a
plugin that installs cleanly and then starts nothing is the failure that taught this (RK57).
One more property makes the gate usable on a repository that adopts the tool late: the Stop
hook judges only the lines the working tree changed. Shio joined with 278 findings in it,
and a gate that blocked the end of every turn over somebody else's history is a gate that gets
switched off — so lint, the pre-commit hook and the Action still judge every line, and the
hook answers the narrower question it was installed to answer (RK60).
These are binding, and half the point. Check before proposing work:
- No web UI and no server. Files and a CLI — the MCP server above is one stdio process speaking JSON-RPC to the CLI, which 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 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.
