Adopting a backlog you already have
The interesting repository is not an empty one. It is the one with a ROADMAP.md somebody has
been keeping by hand for two years, written under rules nobody wrote down — and that is the
adoption with the most friction and the least prose written about it.
Every command and every line of output below came out of a real run. A throwaway repository is built with a genuinely drifted roadmap in it, the adoption is executed in order, and the output is captured. Nothing here is pasted from memory, so a message whose wording changed fails the build rather than misleading you halfway through.
The refusals are the point. Being refused on a file that has always been there is the moment somebody decides a tool is not worth the trouble, and what the refusal says is the whole difference. Two steps below are refusals, and one of them is the command everybody reaches for first.
The repository we start with
Section titled “The repository we start with”Three lines, one of them carrying a paragraph where a sentence belongs, none of them with a dependency annotation, and ids under a prefix this tool has never seen. That is not a contrived fixture — it is what a hand-kept backlog looks like.
Measure before changing anything. This is the number the decision should be made against — how many of the lines already there a limit would refuse, and what the spread is. Nothing is written.
$ roadkeep adopt docs/ROADMAP.mddocs/ROADMAP.md prefix PROJ (inferred from the ids), refs by id, read as a ledger read 1 line(s), 1 conform, 2 would change blocks A symptom longest 34 of 120 utf-16-code-units, 0 over why longest 40 of 200 utf-16-code-units, 0 over line longest 95 of 320 utf-16-code-units, 0 over scope docs/CHANGELOG.md, docs/IMPROVEMENTS.md not read: deps and pointers resolve across files, so neither was checked here — `lint` is the answer once they are declared gains 4 the format would add and this project has not declared: pause no deferred store, so `defer` refuses and there is no door for *not now*: add `deferred = "<path>"` under [files], or a line set aside has to be retired, which is terminal — the id cannot come back and the design goes non-goals `[non_goals]` not governed, so the roadmap's other bullet is prose the gate does not read: what may not be proposed is stated and unenforced, which is the arrangement every limit here exists to replace queue no `## Priority` section, so `pick` offers the lowest ready id: order is derived from the numbers, and work nobody wants next is offered first whenever its id happens to be lowest — a cost a long backlog feels decisions no decisions file, so a constraint that outlives the work explaining it has nowhere governed to go: `ship --decides` refuses, and an ADR is kept by hand or not at all — which is the convention every schema here replaces serves 64138 characters once at connect, if this project serves the tools: `[tools]` is where a ceiling on that is declared marker 📋 on 2 line(s), declared by nothing in [markers] unparsed 2: 📋 is not a marker this project declares (✅ 🗑 ✅): the line reads as prose and no count sees itexit 0 — it worked
The wrong command, shown because it is the one everybody reaches for first. `init` scaffolds, and this project already has the file — so it refuses without writing anything and names the read that measures what is there.
$ roadkeep init --prefix TASKroadkeep: 3 path(s) already exist and nothing was written: /your/project/docs/ROADMAP.md, /your/project/docs/CHANGELOG.md, /your/project/docs/IMPROVEMENTS.md — `adopt docs/ROADMAP.md` (and 2 more) reports what an existing backlog must change, and `init --existing` declares the files that are already there If roadkeep itself is what is wrong here, capture it before the session ends: python scripts/roadkeep.py report --symptom "…" --why "…" -- -C /your/project init --prefix TASKexit 2 — refused — what has to change is the input, not the files
Which the refusal above named. The files that are already there are **declared** rather than written, and only what is missing is scaffolded — so the roadmap this project has kept by hand is not touched. The prefix is read off the ids it already carries and the blocks off its own headings, which is what makes this a command rather than a file somebody types.
$ roadkeep init --existingcreated /your/project/roadkeep.toml adopted /your/project/docs/ROADMAP.md adopted /your/project/docs/CHANGELOG.md adopted /your/project/docs/IMPROVEMENTS.md prefix PROJ (read off the ids already written) 1 written, 3 declared, blocks A: `python scripts/roadkeep.py lint` reports what the backlog must changeexit 0 — it worked
roadkeep.tomlafterwardsprefix = "PROJ" # how a rationale section is addressed: "id" derives the pointer from the # task's own id, "outline" keeps a hand-numbered anchor ref_scheme = "id" # what jumps the queue, in order: an id or a "Block X", and nothing else priority = [] [files] roadmap = "docs/ROADMAP.md" changelog = "docs/CHANGELOG.md" improvements = "docs/IMPROVEMENTS.md" [limits] # characters, counted in UTF-16 code units — the stricter of the two counts, so a # line these accept is one a gate written in Java, C# or JavaScript accepts too symptom = 120 why = 200 line = 320 # a section is prose, so its budget is words; prose is the width one is filled to, # counted in code points — it is a wrapping column and nothing refuses it section = 250 prose = 88 [markers] open = ["📋", "💭", "⏳", "🛠"] shipped = "✅" retired = "🗑" [claims] # minutes a claim on a line reads as held, before a later caller steps over it held = 60 # the roadmap's other list: declared at all means governed, and `lead` and `why` # are the two limits a bullet is held to — 80 and 320 characters unless set here [non_goals] # what must be **true** for a block to be finished, where the list above says what is # not built — the same two limits, declared separately: a project may govern one list # and not the other [criteria] # what has to be *present* to finish a line — hardware, an account, somebody's time. # Not a dep: `pick` offers a line needing one only to a caller that says it has it, # with `--have`. Uncomment and name your own; an undeclared word is refused. # [requirements] # declared = ["hardware"]The first run, and it reports one finding about the **rule** rather than a list about the lines. This backlog was written without a dependency annotation, so every bullet fails a grammar that expects one — and a file where nothing survives is not one somebody hand-edited.
$ roadkeep lintdocs/ROADMAP.md grammar.unreadable the grammar for roadmap fails on 3 of 3 bullets: this is the rule and not the lines, and a file where none survives is not one somebody hand-edited — no [grammar] is declared, so the file was written under another format python scripts/roadkeep.py show … — a declared grammar cannot read back what it writes; read any one line, compare it with the rendering the report carries, then correct `[grammar.<role>]` — the id is the author's because every line in the file is equally evidence 1 problem(s) in 0 line(s), 0 section(s) across 3 file(s): grammar.unreadable 1 (in /your/project)exit 1 — the gate says no — something in the files drifted
The one hand edit left, which the finding asked for by name. `[grammar]` is how a project says which fields its records actually carry — the sixth law applied to the shape of a line, not just to its limits.
roadkeep.toml[grammar.roadmap] drop = ["deps"]Now the real work list. Every finding names the file, the line and the command that closes it — and the exit code is 1, which is what makes this a gate rather than advice.
$ roadkeep lintdocs/ROADMAP.md:5 ref.missing PROJ1: every task points at its rationale section python scripts/roadkeep.py section add PROJ1 --title … — the design this line would point at does not exist yet; the heading and the prose are yours, and the anchor is the id python scripts/roadkeep.py lint --fix — then the pointer, which this scheme derives once there is a section for it to resolve to docs/ROADMAP.md:5 why.no-terminator PROJ1: why is a sentence: end it python scripts/roadkeep.py amend PROJ1 --why … — the why ends in a stop; nothing here adds one, because where it goes is the sentence's docs/ROADMAP.md:8 ref.missing PROJ2: every task points at its rationale section python scripts/roadkeep.py section add PROJ2 --title … — the design this line would point at does not exist yet; the heading and the prose are yours, and the anchor is the id python scripts/roadkeep.py lint --fix — then the pointer, which this scheme derives once there is a section for it to resolve to docs/ROADMAP.md:9 ref.missing PROJ3: every task points at its rationale section python scripts/roadkeep.py section add PROJ3 --title … — the design this line would point at does not exist yet; the heading and the prose are yours, and the anchor is the id python scripts/roadkeep.py lint --fix — then the pointer, which this scheme derives once there is a section for it to resolve to docs/ROADMAP.md:9 status.shipped PROJ3: ✅ belongs in the changelog, not the roadmap python scripts/roadkeep.py ship PROJ3 — the shipped marker never belongs in the roadmap — `ship` is the transaction that moves the line docs/CHANGELOG.md block.unorganised declares no block heading at all, and docs/ROADMAP.md plans 3 open line(s) under 1 of them: every ship here refuses until one exists — `block add A --title "<its title>" --organise changelog` python scripts/roadkeep.py block add A --title … --organise changelog — the ledger is organised by nothing, so the first heading opens it 6 problem(s) in 3 line(s), 0 section(s) across 3 file(s): ref.missing 3 block.unorganised 1 status.shipped 1 why.no-terminator 1 (in /your/project)exit 1 — the gate says no — something in the files drifted
The mechanical half, repaired. Only the derived is touched — an annotation, a marker's codepoint, whitespace — because the tool never writes prose.
$ roadkeep lint --fixdocs/ROADMAP.md:5 ref.missing PROJ1: every task points at its rationale section python scripts/roadkeep.py section add PROJ1 --title … — the design this line would point at does not exist yet; the heading and the prose are yours, and the anchor is the id python scripts/roadkeep.py lint --fix — then the pointer, which this scheme derives once there is a section for it to resolve to docs/ROADMAP.md:5 why.no-terminator PROJ1: why is a sentence: end it python scripts/roadkeep.py amend PROJ1 --why … — the why ends in a stop; nothing here adds one, because where it goes is the sentence's docs/ROADMAP.md:8 ref.missing PROJ2: every task points at its rationale section python scripts/roadkeep.py section add PROJ2 --title … — the design this line would point at does not exist yet; the heading and the prose are yours, and the anchor is the id python scripts/roadkeep.py lint --fix — then the pointer, which this scheme derives once there is a section for it to resolve to docs/ROADMAP.md:9 ref.missing PROJ3: every task points at its rationale section python scripts/roadkeep.py section add PROJ3 --title … — the design this line would point at does not exist yet; the heading and the prose are yours, and the anchor is the id python scripts/roadkeep.py lint --fix — then the pointer, which this scheme derives once there is a section for it to resolve to docs/ROADMAP.md:9 status.shipped PROJ3: ✅ belongs in the changelog, not the roadmap python scripts/roadkeep.py ship PROJ3 — the shipped marker never belongs in the roadmap — `ship` is the transaction that moves the line docs/CHANGELOG.md block.unorganised declares no block heading at all, and docs/ROADMAP.md plans 3 open line(s) under 1 of them: every ship here refuses until one exists — `block add A --title "<its title>" --organise changelog` python scripts/roadkeep.py block add A --title … --organise changelog — the ledger is organised by nothing, so the first heading opens it 6 problem(s) in 3 line(s), 0 section(s) across 3 file(s): ref.missing 3 block.unorganised 1 status.shipped 1 why.no-terminator 1 (in /your/project)exit 1 — the gate says no — something in the files drifted
What the tool now reads out of a file it did not write. The lines it could not parse are reported rather than silently dropped, which is how a filtered listing is stopped from looking complete when it is not.
$ roadkeep list- 📋 **PROJ1** **Search is slow** — We should look at making the search faster because users - 📋 **PROJ2** **Login sometimes fails after a deploy** — Nobody has reproduced it. - ✅ **PROJ3** **The parser was two versions behind** — Upgraded, and the vendored copy removed.exit 0 — it worked
The roadmap declares this block and the two new files do not, so the first `ship` would have nowhere to put its entry and the first design nowhere to go. One call declares it wherever it is missing and says which files it touched. A heading is declared and never invented by a write — a write that invented one would file the text where nothing looks for it.
$ roadkeep block add A --title Search --organise changelog --organise improvementsBlock A declared: Search docs/CHANGELOG.md :3 ## Block A — Search docs/IMPROVEMENTS.md:3 ## Block A — Search not docs/ROADMAP.md: already declares it stage git add -- docs/CHANGELOG.md docs/IMPROVEMENTS.mdexit 0 — it worked
The first line written through the tool, with its design in the same call. The id is derived, the pointer is derived, the annotation is derived — and the format is checked here rather than reported later.
$ roadkeep add --block A --symptom The index rebuild blocks every write for most of a minute --why Search is the feature tenants notice first, and a rebuild that holds the write lock makes the whole application look down while it runs. --section The rebuild holds the write lock --section-body The rebuild takes an exclusive lock for its whole run, so every write queues behind it. Nothing about the index needs that: the writes it blocks are to unrelated tables, and the lock is the one line nobody revisited when the tenants got larger.- 📋 **PROJ4** **The index rebuild blocks every write for most of a minute** — Search is the feature tenants notice first, and a rebuild that holds the write lock makes the whole application look down while it runs. → §PROJ4 design §PROJ4 → docs/IMPROVEMENTS.md:5 44 words stage git add -- docs/ROADMAP.md docs/IMPROVEMENTS.md event PROJ4 Block A liveexit 0 — it worked
docs/ROADMAP.mdafterwards# Roadmap ## Block A — Search - 📋 **PROJ1** **Search is slow** — We should look at making the search faster because users have complained about it several times now, and the index rebuild takes most of a minute on the larger tenants, which we think is the root cause but have not confirmed yet. - 📋 **PROJ2** **Login sometimes fails after a deploy** — Nobody has reproduced it. - ✅ **PROJ3** **The parser was two versions behind** — Upgraded, and the vendored copy removed. - 📋 **PROJ4** **The index rebuild blocks every write for most of a minute** — Search is the feature tenants notice first, and a rebuild that holds the write lock makes the whole application look down while it runs. → §PROJ4And the refusal. It arrives **before** the prose is composed to fill a line that would not fit — it names the limit, where the limit is declared, how much to cut, and that the rest belongs in the rationale rather than being compressed away. Exit code 2: what has to change is the input, not the file.
$ roadkeep add --block A --symptom Search is slow and this sentence is deliberately far longer than the limit this project just declared for itself, which is the whole point --why Because a refusal is what an adopter meets first, and what it says is what decides whether the tool is worth the trouble.roadkeep: refused, nothing written: symptom: 138 characters, limit is 120 (roadkeep.toml:18 [limits].symptom): delete 18 characters — about 3 words; the remainder belongs in the improvements section rather than compressed away [symptom.too-long] foresee python scripts/roadkeep.py budget --symptom <draft> (measures the same draft against the same limit and writes nothing) If roadkeep itself is what is wrong here, capture it before the session ends: python scripts/roadkeep.py report --symptom "…" --why "…" -- -C /your/project add --block A --symptom 'Search is slow and this sentence is deliberately far longer than the limit this project just declared for itself, which is the whole point' --why 'Because a refusal is what an adopter meets first, and what it says is what decides whether the tool is worth the trouble.'exit 2 — refused — what has to change is the input, not the files
And the door out. One write moves the line into the ledger, drops the rationale section the design lived in, and re-derives every annotation that was waiting on it — so the files never describe a state that did not ship.
$ roadkeep ship PROJ4 --why The rebuild takes a shared lock and writes to a side index, so nothing queues behind it.PROJ4 → docs/CHANGELOG.md:5 under Block A removed docs/ROADMAP.md:10 dropped §PROJ4 (5-9) from docs/IMPROVEMENTS.md stage git add -- docs/CHANGELOG.md docs/ROADMAP.md docs/IMPROVEMENTS.md event PROJ4 Block A live Block A has 3 openexit 0 — it worked
docs/ROADMAP.mdafterwards# Roadmap ## Block A — Search - 📋 **PROJ1** **Search is slow** — We should look at making the search faster because users have complained about it several times now, and the index rebuild takes most of a minute on the larger tenants, which we think is the root cause but have not confirmed yet. - 📋 **PROJ2** **Login sometimes fails after a deploy** — Nobody has reproduced it. - ✅ **PROJ3** **The parser was two versions behind** — Upgraded, and the vendored copy removed.The answer to standing debt. A repository adopting the tool has years of lines that were never written against these rules, and being refused by all of them at once is where an adoption stops. This forgives what was already there by name and gates only what this tree adds.
$ roadkeep lint --baseline HEADdocs/ROADMAP.md, docs/CHANGELOG.md, docs/IMPROVEMENTS.md: 4 line(s), 0 section(s), clean against HEAD (5 standing, 1 resolved) (in /your/project)exit 0 — it worked
What that run showed
Section titled “What that run showed”Measure before you change anything. adopt reads a file this tool does not own yet and
says how many of its lines a limit would refuse. The decision is then a number rather than a
hope.
The refusal names the command, and the command is init --existing. A plain init
scaffolds the configuration and the files it declares, so it will not touch a project that
already has them — and rather than leaving you to write roadkeep.toml yourself, it names the
flag that declares what is there and scaffolds only what is missing. The prefix comes off the
ids the roadmap already carries and the blocks off its own headings, so nothing is renumbered
and no heading is invented.
One hand edit is left, and it is honest. roadkeep.toml is extended once when the gate
reports that the grammar — not the lines — is what does not fit. That step is not a command,
and pretending otherwise would have been the fiction this page exists to avoid.
The gate reports the rule before it reports the lines. A file where no bullet parses is
not one somebody hand-edited; it is one written under a different format, and [grammar] is
how a project says which fields its records actually carry.
--baseline is the answer to standing debt. A repository adopting this tool has years of
lines that were never written against these rules, and being refused by all of them at once is
where an adoption stops. A baseline forgives what was already there by name and gates only
what the working tree adds — which is what lets the gate go into CI on the first day rather
than after a cleanup nobody has time for.
The first ordinary session is where the real friction is. Not the install: the first write
made by hand out of habit, and the denial that follows. Above, the line and its design are
written in one call, the refusal that follows names the limit and how much to cut, and ship
moves the line, drops the design and re-derives every annotation in a single write.