Skip to content

Repository files navigation

roadkeep — a schema at the point of insertion

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.


The problem, measured

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.

Why a linter was the wrong answer

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.

What makes it different

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:

  1. Enforced at the write path, not the review. The refusal happens before the prose exists. Everything else in this space reports afterwards.
  2. 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.
  3. 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.
  4. 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.

The six laws

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.

Status

The table below is not written by handroadkeep 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.

Install

Python ≥3.11, zero runtime dependenciesargparse 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 commit

Run it as a gate

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

Run it as a Claude Code plugin

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 ends

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

Non-goals

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.

This repository is the conformance fixture

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.

About

Keeps a project's roadmap, changelog, improvements and strategy files to one format - a schema at the point of insertion, not a linter after the fact. Python CLI + Claude Code plugin.

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Used by

Contributors

Languages