backlog
A backlog that lives in the repository, in markdown, stays workable past a few hundred items, and is groomed against each piece of work before that work is finalized.
The problem
A single append-only list works until it doesn't. Measured on one real corpus before adoption: 3,622 lines, 247 items, 85 sections. Every pass over it read the whole file to answer one question about a handful of items, and every concurrent branch collided on the same trailing lines.
Zero-config defaults
With no configuration the skill looks for docs/backlog/index.md; the detail files live in the
sibling directory named after the index's stem (docs/backlog/index/). Work items are referred to
generically — a story, task or epic are only examples of the vocabulary a repo may use.
Configuration
Per-repo overrides live in .claude/claude-skills.json under the backlog key. Every key is
optional.
| key | default | meaning |
|---|---|---|
indexPath |
docs/backlog/index.md |
The index file. The detail directory is its sibling named after the stem (deferred-work.md → deferred-work/). Keep an existing name when a tool appends to it by that name. |
paths |
{} |
Related planning documents the grooming pass may consult, e.g. { "status": "docs/status.yaml", "roadmap": "docs/roadmap.md" }. Read only what is listed; never guess at others. |
workItemVocab |
["story", "task", "epic"] |
Words the repo uses for units of work; used in the report headings. |
orchestratorSkills |
[] |
Skills (namespaced, e.g. /workflow:something) that call grooming as a discrete step before scoping. |
generators |
[] |
Tools or skills that append entries to the index directly (see Generators that append to the index). |
{
"backlog": {
"indexPath": "docs/planning/deferred-work.md",
"paths": { "status": "docs/planning/sprint-status.yaml" },
"workItemVocab": ["ticket", "milestone"],
"generators": ["code-review"]
}
}
The shape
<dir>/index.md the index: one entry per item, plus a `detail:` pointer
<dir>/index/ one file per item — frontmatter + the full record
A detail file:
---
id: dw-014
summary: 'One sentence stating the item.'
status: open
trigger: 'the first change that touches the audit vocabulary' # deferred-work policy only
---
- **The full record**, verbatim — evidence, anchors, measured numbers, the shape of the fix.
Sub-bullets are context for the parent, not separate items.
Required on every item: id, summary, status. Everything else is free — the validator ignores
fields it does not know, so a repo can carry priority, size, owner or anything else without
changing this skill.
The index carries summary and detail: and never repeats the frontmatter fields, so the two
cannot disagree.
Reading it
node scripts/backlog.mjs docs/backlog/index.md # open items, one line each
node scripts/backlog.mjs docs/backlog/index.md --all # include DONE / KILLED
node scripts/backlog.mjs docs/backlog/index.md --json # for tooling
Frontmatter only — bodies are never read. On the corpus above that is 390 lines against 3,622, with closed items filtered rather than skimmed past. Open an item's detail file once you have selected it.
Never Read a large index whole. A monolithic index that has outgrown the Read tool's limit
(roughly 256 KB / 25k tokens) fails the read and wastes a retry. grep/rg for the section
header or item you need, or Read with offset/limit on the matched line range; the same goes
for a bulky spec. Pruning trigger: if the file is over the limit because of accumulated
**DONE** / **KILLED** / closed-section entries, flag it to the user for archiving those into an
archive file next to the index — a bloated backlog costs every downstream reader a failed read.
Policies
A policy says what makes an item actionable, and nothing else. It is declared in the index's own frontmatter, so it is visible where you already look and there is no second config file to find:
---
policy: deferred-work
---
| policy | an item is actionable when | requires |
|---|---|---|
| (none — a plain backlog) | you pick it | id, summary, status |
deferred-work |
its trigger has fired |
the above, plus trigger on every open item |
An unrecognised policy is refused, not ignored — a typo must not silently drop the rules the author was relying on.
The deferred-work policy
For work parked with a reason rather than queued: debt, follow-ups, review deferrals. Every open item sorts into exactly one bucket (see Grooming).
Two rules that matter more than the buckets:
- A deferred entry is not a trustworthy primary source. It records what was true when written. Verify against the code before promoting — an item can be silently already-done.
- An open item with no
triggercannot be classified. It is not "keep-deferred", it is untriaged, and the fix is to give it a trigger. Expect many on first adoption: the corpus above had 97 of 194 open items without one, which the monolith hid and this surfaces.
Wiring grooming into a workflow
Grooming only happens if something makes it happen. Whatever you wire it into — a story-creation
hook, a PR template, a checklist, a skill listed under orchestratorSkills — that gate names
WHEN and WHERE. It never restates WHAT.
The buckets, the untriaged rule and the not-a-primary-source rule live in this file. A gate that copies them creates two homes for one contract, and the copy drifts silently because nothing type-checks prose. Measured on the first adoption: the gate restated the policy in 51 lines, and its copy of the untriaged count was wrong twice before anyone noticed.
A gate that fits in a paragraph, and does not:
Groom the backlog before scoping this work, using `/workflow:backlog`.
READ ITS `deferred-work` POLICY AND GROOMING SECTIONS AND FOLLOW THEM: the four buckets, what
makes an item untriaged rather than keep-deferred, and why a deferred entry is not a trustworthy
primary source all live there, and are deliberately not repeated here.
<this repo's index, read through scripts/backlog.mjs rather than by opening the file>
REPORT ONLY. This pass never edits the backlog; promotions and kills are applied by whoever
scopes the work, after the gate.
Keep in the gate only what is genuinely the workflow's and not the backlog's — that report-only division of labour, and any repo-local rule about where an agent may write. Everything else is a pointer.
Then guard it, because its failure is silent. A gate that is deleted, emptied, renamed, or given a syntax error does not fail loudly — grooming simply stops happening and the backlog rots with nothing to notice. On the first adoption the predecessor's own tooling promised a fallback gate and never checked it, so the promise pointed at an empty list for months. Whatever holds your gate, assert that it still resolves to a live step, and that it still points here.
Guard the structure too, with the same validator. Ids come from the working tree, so two branches
that each take the next free id collide when one merges the other. Git reports at most a conflict
in the index. scripts/validate.mjs names that (DUPLICATE_ID), along with pointers to nothing
and files nothing points at. Run it in CI with --defects-only:
node scripts/validate.mjs <index.md> --defects-only
That fails only on a structural defect. It still prints the grooming states (NO_TRIGGER,
UNPROMOTED_APPENDS, RAW_APPEND), but it does not fail on them. Each state is work waiting for a
person, not drift, and a build must not block on a judgment that nobody made yet. STATES in the
script is that list. A code that the scanner adds later counts as a defect until it is listed there.
Grooming
Grooming classifies every open item against the piece of work being scoped and returns a report. It never mutates the index — the user (or the scoping step) applies the changes.
When to fire
- Proactively, before a new unit of work is drafted or finalized — that is when promotions land cheaply instead of being noticed mid-implementation.
- Explicitly: "groom the backlog", "check deferred work", "review pending items".
- As a discrete step inside any skill listed in
orchestratorSkills. - At the start of a larger unit (an epic, a milestone) to find items that belong in its first cross-cutting task.
When NOT to fire
- Mid-implementation of active work (creates churn).
- A bug fix or one-line edit — grooming is for work at story/task scale.
- A retrospective — that is a different pass with a different question.
Inputs (always read fresh)
| Input | Purpose |
|---|---|
| the index + detail files | the backlog |
| the work being scoped | its draft spec if one exists; else the section of the roadmap that names it; else ask |
paths.* from config |
status file, roadmap, whatever the repo keeps — only what is listed |
Buckets
| bucket | when | action |
|---|---|---|
| promote-now | the trigger fired, or the work touches the same code path / feature folder and the item is small (1–2 files) | fold into scope as a task |
| promote-separately | trigger fired but the item is cross-cutting (lint rule, test infra, schema-wide) or too large for this work | surface as its own piece of work, or as the first cross-cutting task of the larger unit |
| keep-deferred | the trigger has not occurred | leave it; say which trigger is being waited on |
| kill | contradicted by the current code, decision superseded, later work covered it | status: KILLED (date), name what superseded it |
Decision algorithm (first match wins)
- Already addressed? Item contradicted by current code → kill (note what superseded it).
- Trigger satisfied? Yes and it is this work → promote-now; yes but cross-cutting → promote-separately; no → continue.
- Same code path / feature folder? Yes and small → promote-now; yes but large or cross-cutting → promote-separately; no → continue.
- Otherwise → keep-deferred, noting the trigger being waited on.
Defaults when uncertain
- Not sure whether an item should fire → keep-deferred with a note. False-promotion churn costs more than a false deferral.
- Not sure whether an item is already closed → check the closed items and any closed section; if still unsure, leave it and flag it in the report.
- Item references files or features that no longer exist → kill candidate, but ask the user to verify.
Constraints
- Never modify the index or a detail file during grooming — report only.
- Every promotion or kill carries a one-line rationale — never classify without showing work.
- Never classify items in a closed section or with a closed status.
- Never discover new items by grepping the codebase — the backlog is the source of truth for what is deferred.
Report format
Return inline markdown — do not write to disk. Use the repo's workItemVocab in place of "work".
# Grooming Report — <work id>: <title>
Date: YYYY-MM-DD
Source: `<indexPath>`
Work being scoped: <link or summary>
## Promote now (N items)
### 1. <item summary>
- **From:** <section header or detail file>
- **Why promote:** <one sentence — which trigger fired or which file overlap>
- **Suggested task:** <one line, ready to paste into the task list>
## Promote separately (M items)
### 1. <item summary>
- **From:** <…>
- **Why separately:** <one sentence — cross-cutting or scope mismatch>
- **Suggested task:** <one line>
## Keep deferred (P items)
### 1. <item summary — first 80 chars + ellipsis>
- **From:** <…>
- **Trigger we're waiting on:** <what would un-defer this>
## Kill recommendations (Q items)
### 1. <item summary>
- **From:** <…>
- **Why:** <what superseded it>
- **Suggested change:** `status: KILLED (YYYY-MM-DD) — <reason>` in the detail file
## Summary
- Items reviewed: <total>
- Promoted now: N
- Promoted separately: M
- Kept deferred: P
- Recommended for kill: Q
- Items skipped (already closed): <count>
### Paste-ready additions for <work id>
- [ ] <task bullet>
### Paste-ready additions for the separate / cross-cutting task
- [ ] <task bullet>
Generators that append to the index
Some tools — a code-review skill, a build step, a story-validation pass — append entries to the
index directly rather than creating detail files. List them under generators so the grooming
pass knows to expect their shape. That is why the index keeps its path and stays
hand-maintained rather than generated: a generated index would silently eat those appends on
the next regeneration.
Appends come in two shapes, and which one a repo gets depends on the generator and its installed version — check what the repo actually runs, do not assume the newest:
keyed - source_spec: `<spec>` bare - <one bullet per finding, with description>
summary: <one sentence>
evidence: <why this is real>
Either shape has no detail file, so no status and no trigger. Treat it as open and
untriaged; grooming promotes it into a detail file. scripts/validate.mjs reports both
(UNPROMOTED_APPENDS for the keyed shape, RAW_APPEND for the bare one) — it keyed only on the
keyed shape until a repo running a bare-bullet generator showed that an append could land in the
index with nothing reporting it at all.
The closed vocabulary (DONE, KILLED, CLOSED, SUPERSEDED, RETIRED, RESOLVED) is shared
by backlog.mjs, validate.mjs, verify-migration.mjs and migrate.py. It is corpus data: if
your ledger retires items with another word, add it to all four, or those items read as open.
Generator conventions (optional)
Generators that write to a monolithic index tend to organise it with section headers and inline status markers. None of these are required by this skill; when the repo's generators use them, the grooming pass parses them as follows:
| pattern | handling |
|---|---|
## Deferred from: code review of <work-id> [Group X] (YYYY-MM-DD) |
items inside are deferrals from that review; [Group X] is a batch label |
## Deferred from: <validation pass> of <work-id> (YYYY-MM-DD) |
same, from a validation pass |
## Post-<unit> N: <topic> |
evaluate only once the current unit is ≥ N |
## <work-id> spike: <topic> |
evaluate only if the spike is complete |
## <work-id> scope note (...) |
informational; classify normally |
## Architectural questions for <work-id> |
promote-now if that work is being scoped; else promote-separately if now blocking |
## Closed by <work-id> |
skip the whole section |
a bullet wrapped in ~~…~~ |
closed; skip |
a bullet containing **DONE (YYYY-MM-DD)** or **KILLED (YYYY-MM-DD)** |
closed; skip |
Item granularity: each top-level bullet is one item; sub-bullets are context for the parent, never classified independently.
Adopting it
Migrate to a scratch directory, prove nothing was lost, then move it into place at indexPath.
The source is the only copy of the thing you are checking against, so never migrate over it.
LEDGER=<old-monolith.md>; OUT=$(mktemp -d)
python3 scripts/migrate.py "$LEDGER" "$OUT" # index.md + index/
node scripts/verify-migration.mjs "$LEDGER" "$OUT" # ← the gate. non-zero = do not commit
node scripts/validate.mjs "$OUT/index.md" # structure, pointers, policy
Pass --index-name <name>.md to migrate.py and verify-migration.mjs to keep the name a
generator already appends to (the detail directory takes the stem).
migrate.py is lossless for items by construction — every item's block is written verbatim into
its detail file, and frontmatter is derived from that block, never invented. A field the source does
not state is emitted empty and counted, so gaps are visible rather than guessed. It also prints what
it refuses to decide: untriaged items, statuses it inferred, and open items under a retired heading.
Each of those lines is a task, not a statistic. A record need not contain a bullet — a section whose
heading is its own text is folded into an item, one per ### run or one for the whole section, and
the folded count is printed. It writes policy: deferred-work into the index; delete that line to
run a plain backlog.
verify-migration.mjs asks the two questions the counts cannot. Did all of the source's content
reach the output? — every item body verbatim and whole, and every other non-blank line by a
trimmed match, which is content-level rather than byte-level: re-indentation is not loss. On the
one measured corpus the bodies read a clean 251/251 while 23 section intros and one whole item
were on the floor. Does a second extractor agree on WHICH items are closed? — the two disagreed
44 against 40, and diffing them as sets rather than sizes is what turned a plausible near-match
into four named items.
Both of those fired on the first real adoption. reference/adopting.md is the runbook — the
order, what to do with each reported count, and the two decisions to make explicitly.
reference/migration-traps.md is why: twelve silent corruptions, each live against a real ledger —
including one the gate itself could not see, because both extractors were missing the same word,
and one where the gate exited 0 without running at all.