Skip to content

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.

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.

  1. 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.md
    docs/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 it

    exit 0 — it worked

  2. 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 TASK
    roadkeep: 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 TASK

    exit 2 — refused — what has to change is the input, not the files

  3. 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 --existing
    created  /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 change

    exit 0 — it worked

    roadkeep.toml afterwards
    prefix = "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"]
  4. 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 lint
    docs/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

  5. 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"]
  6. 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 lint
    docs/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

  7. The mechanical half, repaired. Only the derived is touched — an annotation, a marker's codepoint, whitespace — because the tool never writes prose.

    $ roadkeep lint --fix
    docs/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

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

  9. 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 improvements
    Block 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.md

    exit 0 — it worked

  10. 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  live

    exit 0 — it worked

    docs/ROADMAP.md afterwards
    # 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. → §PROJ4
  11. And 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

  12. 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 open

    exit 0 — it worked

    docs/ROADMAP.md afterwards
    # 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.
  13. 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 HEAD
    docs/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

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.