Tracking the work - backlog, sprints, timeline
Three files answer the three questions people actually ask: what do we still owe, what are we doing right now, and when does it land. They are files rather than a board, so they are in the same place as the code, they diff, and an agent can read and update them without a token.
You do not maintain them by hand. Each has a skill that owns it, and this page is what to say and what to expect back.
What is where
Three files, all under docs/:
backlog.md- what do we still owe ourselves. Every repository has this one, however small.sprints/<team>/<sprint>.md- what did we commit to, and by when. Teams only.sprints/TIMELINE.md- when does it land. Teams only.
Why "teams only". The standard ships in two sizes, and a repository picks one when it adopts. Core is everything a repository needs whatever it is - one person, one weekend project, a company. Scale adds what only makes sense once several people work in the same repository, and sprints are the clearest example: a single maintainer already knows what they are doing this week, so a file recording it would be ceremony with no reader.
Nothing stops a solo repository turning sprints on. It just does not arrive by default, and you are not out of compliance for not having them.
The backlog is the pool
Everything the repository knows it owes itself: features, plus the specification, decision and documentation debt that adoption surfaced. Each row names the person it serves and what "done" would mean, because an item without a done condition never leaves.
Adding to it is the most common thing you will do, and the point is that it costs you nothing mid-task:
the export dies when it times out, there is no retry - not fixing it in this change,
write it down so we do not lose itIt lands with its source, the role that has to act, and what done looks like. You keep going.
What the pool looks like - ordered, top is next:
Every row carries two more columns than the view above shows, and they are the two that matter: why it is worth doing, so a row six months old can still justify itself, and done when, so it can actually leave. The assignee stays empty in the pool: an item nobody picked up is nobody's, and pretending otherwise is how a backlog becomes a list of quiet obligations.
Reading it back:
what is in the backlog for payments?
what did we write down during the adoption that nobody has picked up?This is a direct read, not a skill's output - the file already answers it, which is the point of it being a file. "Nobody has picked up" is answered trivially, since the pool's assignee is empty by definition. "During the adoption" is answered by the source column: every row names where it came from - onboarding, spec-delta, drift, decision or asked - so the answer survives the row being re-ordered or re-grouped, which pinning it to whichever epic the onboarding phase filed it under did not. That column exists because the claim "every item has a source" was made in three places while the row schema had nowhere to put one, and provenance folded into why is a convention rather than a field.
You do not have to use any of this
The in-repo backlog is optional. If your team already lives in a tracker, keep it: the standard's default posture is a tracker holding execution history while the repository holds the intents, joined by a one-way bridge (ADR-010).
- GitHub Issues - the default. Free, unlimited, already where the code is.
- Jira - use
jira-bridge. Jira Cloud is free up to ten users, so a small team pays nothing for the board either. - Linear - the same shape, with a free cap that can bite mid-project.
One honest limit: the convention has been proven against Jira only. Linear follows the same shape and has not been field-tested, which is one of the project's open questions.
What is offered here is the in-house alternative for teams who would rather not run a tracker at all. That is a preference, not a requirement, and nothing in the standard breaks if you keep your board.
How the Jira bridge actually works
It is real and running against a real board, not a plan. Worth reading in full even if you use a different tracker, because the shape is the part that transfers.
It only ever writes forward. The generator reads the repository and creates issues. It never reads Jira back into a spec, a backlog row or a sprint file. There is exactly one write in the other direction - a newly created issue key, persisted into front matter - and that exists so the next run can tell "already there" from "not yet", which is what makes it safe to run repeatedly.
It never edits an issue it did not just create. Not the summary, not the description, not the status, not the assignee. Somebody moved a card, renamed it, or assigned it to a person who is actually free - that is the board doing its job, and a generator that overwrote it would be a generator nobody is allowed to run twice.
It is dry-run by default. You see the plan, then you pass --apply.
What becomes what
| In the repository | In Jira | How it is keyed |
|---|---|---|
| a capability | an epic, named in front matter | referenced, never created - the bridge does not invent structure above itself |
| a backlog intent - a row with a why and a done-when | a Story under that epic, one per intent | the key in front matter; created once when absent, written back, reused ever after |
| a task the sprint broke that intent into | a Sub-task under that intent's Story | the task id in the summary, [T003] ..., plus a label - created only if no sub-task with that id exists |
| a decision the intent is waiting on | its own Story, Author ADR: <about>, blocking the intent's | its own front-matter key |
The unit choice is the whole design and it is the thing people get wrong. A Story is one backlog intent - something with a why, a done-when, and a size that fits inside a sprint. Not the capability (that is an epic, and one giant Story per module is a Story nobody can close), and not a task (a task is a one-line title, so a Story per task gives you a board of empty stories with no acceptance criteria between them).
The last row is the one that surprises people and the one that pays. A missing decision is not a sub-task - it is somebody else's work, usually somebody who does not read the repository, and it blocks the Story rather than sitting inside it. That is exactly the item that used to go missing between "the spec is blocked" and anyone outside the team finding out.
What a run looks like
Run it again five minutes later and every line reads exists. That is the property worth testing before you trust it with a board other people are looking at, and it is why the keys are written back rather than guessed from titles - a title gets edited, and a bridge that matches on titles duplicates the issue the first time somebody fixes a typo.
Where to get it
Take it from the console repository, where it runs as a skill against a live board. It is a short skill file plus the tracker's own API - deliberately small, because a bridge you intend to retire should not be an integration you have to maintain.
One thing to know before you read it: the running implementation was built against a repository using Spec Kit's vocabulary, so in the file the unit is called a user story and it is read out of the spec rather than out of a backlog row. The table above is the same mapping in this standard's words. What transfers unchanged is the shape - the epic is referenced, the sprint-sized thing is the Story, its breakdown is sub-tasks, blockers are their own Stories - and that is the part worth copying.
Why one-way and not sync. Two-way means conflict resolution - who wins when the repository and the board disagree - plus webhooks and state reconciliation. That is real, permanent cost for a mechanism whose entire purpose is to be removable. If you later want status flowing out of the repository, the cheap version is a read-only reflector (branch and pull-request state to the board), not a sync.
A sprint is what somebody actually picked up
A bounded stretch of work with an owner, a goal, and an end date everybody agreed to. One file per sprint, one directory per team.
Opening one moves chosen intents out of the pool:
let's start a sprint for dispatch - reassignment and the courier notification, two weeksWhat you get back - the commitment in a header, and the work as a board:
The file's header carries the commitment in three lines - goal, owner, target date - and the board is the rest of it.
Every intent names its current holder - not who will eventually do it, not who suggested it. An intent with an empty assignee is a gap you can see rather than one you find out about at the end.
A blocked item stays in doing and says what it is waiting on. blocked is the fourth status the schema declares and it gets no lane of its own, because "blocked" is not a place work sits - it is a thing that is true about work somebody is holding. What matters is the reference, and OVR-5 above carries it: waiting on NOTIF-6, which is a decision still in the pool. A board that dropped the reference, or crammed the row into todo without it, would lose the one fact worth looking at. sprint-guard checks the same reference from the other side - a block naming work that is finished, split or deleted is a row that looks legitimately stuck and is not.
Closing one is the step people skip and the one that pays:
the sprint is over - close itEach intent is checked against its definition of done, whatever did not finish returns to the pool, and the one measurement nobody can reconstruct afterwards - what actually finished inside the window - is recorded. Skipping the close does not save time; it destroys the only data the next forecast has.
The rule underneath: an intent is in the pool or in exactly one sprint. Never both, never two. A guard fails the build when that stops being true, because "what are we doing right now" stops being answerable the moment there are two answers.
The timeline says when, and says how much to trust it
when does billing ship? are we on track?What comes back depends on what the repository can honestly support:
With three or more closed sprints it projects from measured throughput - what your team actually finished, not what anyone estimated.
Below three there is no measured throughput, and it will not invent one. If items carry a size it describes the shape of what is left instead - heavier or lighter than the last sprint's mix - and says plainly that this is a ranking, not a date: a size letter is never converted into a duration, cold start included. If items carry no size either, it reports what is in flight and gives no date. Either way, the cold start gets no date - the only question is whether it also gets a shape.
Three is not a magic number. It is the point below which one unusual sprint dominates the average, and the file says so rather than hiding it.
That refusal is the whole design. A projection presented without its confidence is what teaches people to distrust plans, and once they do they stop reading the timeline and start asking in meetings - which is the state this replaced.
A real one, from a repository with three closed sprints behind it:
This sprint is running at less than half its own history and nobody has said so. Thirteen days in, two of six items are done - 0.15 items per day against a historical 0.28 to 0.38. The bar above uses the historical rate, which is the honest default for four remaining items. The dashed marker is what today's pace implies. Both are shown because the reader's question is not "what is the date" but "which number is this".
Note what it does there. It gives the honest projection and names the reading that contradicts it, with the arithmetic for both. That is the difference between a status report and a number somebody has to decide whether to believe.
One page, for the people who never open the repository
Markdown in a repository is the right home for this and the wrong reading surface for a sponsor, a client, or somebody who joined on Monday. scripts/generate-dashboard/ renders the three files - plus the decision records, the specs and the changelog - into one static page: what is in flight now, the sprints against a calendar, the pool, a handful of reports, the history, and every record with a search across it.
It is a projection, never a second place the work is tracked. It writes nothing back, and two people running it on the same commit get the same bytes.
node scripts/generate-dashboard/index.mjs # once, into _dashboard/index.html
node scripts/generate-dashboard/index.mjs --serve # rebuilds on change; the open page refreshes itself
--serve listens on localhost:9675 - loopback only, and a port nothing else wants, so it never takes the one your application is trying to use. Pass your own after the flag if it clashes anyway.
It keeps itself current. The page carries a fingerprint of the content it was built from and checks a small state.json beside it. When the work moves, the page reloads and keeps your place - unless you have a record open or a search half-typed, in which case it says so and lets you choose the moment. Nothing pulls, nothing rebases: a stale page is a display problem, and fixing it by moving somebody's branch would be a much worse one.
Where it may go depends on who may read the repository, because the page contains nothing the repository does not already contain.
- Public repository: publish it. GitHub Pages is enough, and being able to send someone a link instead of a status email is most of the point. This standard publishes its own.
- Private repository: not on GitHub Pages. Pages on a private repository is served publicly unless the organisation is on Enterprise Cloud with access control, so the default outcome is a private backlog on the open internet. GitHub Pages has no password of its own to put in front of it, and a password prompt written in the page's own JavaScript is theatre: the content has already reached the browser by the time it is asked.
What to do instead, in the order worth trying. The build is one self-contained HTML file - stylesheet and code inlined, no fonts, no CDN, not a single external request - which is what makes every option below cheap.
- Lock it with one password and publish it anywhere, Pages included.
--lockencrypts the page at build time and ships the ciphertext; the reader types the password and their browser decrypts it. Nothing on the host is readable without it, so a host with no authentication of its own is no longer the problem. The shipped workflow does this for you: set a repository secret calledDASHBOARD_PASSWORDand the publish step opens up even for a private repository, because what sits on the URL gives nothing away.
DASHBOARD_PASSWORD='…' node scripts/generate-dashboard/index.mjs --lock
AES-256-GCM, the key stretched from your passphrase with 600,000 rounds of PBKDF2. Be clear-eyed about what that buys: one shared secret, not per-person access. Changing who may read means changing the password and rebuilding, and anyone can take the ciphertext away and try passwords against it at their own pace - so use a passphrase worth attacking. The locked page also names nothing until it opens: the title says "Work" and the repository is not mentioned.
- Put it where your authentication already is. An internal wiki page, a shared drive, the static directory of an app that already sits behind your SSO. No new vendor, no new account, no new set of permissions to get wrong. Dropped somewhere with no
state.jsonbeside it the page stops self-refreshing and becomes a snapshot - the masthead still names the commit it was built from, so nobody mistakes it for live. - Leave it as the build artifact. The workflow uploads it on every push to
main, and only people who can read the repository can download it. Not a link you can send, which is the whole cost, but it is zero setup and it is already running. - Reach for a hosted identity gate when one password is genuinely not enough - when you need to remove one person's access without telling everyone else a new password, or to know who read what. Two are free at the size a private project actually is (checked August 2026; hosting terms move, so check again rather than trusting this paragraph):
- Azure Static Web Apps, Free plan. Authentication is built in - GitHub or Microsoft accounts - and one route rule closes the site:
{ "route": "/*", "allowedRoles": ["reader"] }instaticwebapp.config.json, with readers named through the invitation system (25 per app on Free). Azure writes the deployment workflow into your repository. - Cloudflare Pages behind Cloudflare Access. Free up to 50 users, policies by email address, email domain or your identity provider, and it protects the
*.pages.devURL, so you need no domain of your own to start.
- Azure Static Web Apps, Free plan. Authentication is built in - GitHub or Microsoft accounts - and one route rule closes the site:
Vercel and Netlify are the two people reach for first and both charge for this: Vercel's password protection is Enterprise or a paid add-on, and its free authentication admits only people signed into your Vercel team - seats for the very readers you built this for. Netlify moved site passwords to its paid tier for accounts created after September 2025.
Whichever you pick, weigh how it removes access, not how it grants it. Somebody leaves the project long after the interesting part of this decision is over.
The shipped workflow encodes exactly that: it builds on every push to main, uploads the page as an artifact, and reaches the publish step only when the repository is public, so turning Pages on cannot leak a private board by accident. Add --anonymise when the page should carry no colleague's name: assignees and the owner a sprint names are dropped at build time rather than hidden in the page. It is not redaction - prose you wrote by hand is reproduced as you wrote it, so a build that must carry no names needs the sources read too.
The output belongs in .gitignore. Committing it costs a large diff in every pull request and a conflict on every parallel branch, and buys nothing: the page is a function of the commit, so anybody can rebuild it byte for byte.
What this is not
It can replace your tracker, and for some teams it does. For the work the repository knows about - what is owed, what somebody picked up, when it lands - this is complete, and running without a board is a real option rather than a compromise.
What it does not try to be is the platform: permissions and portfolio views, reporting lines, the workflows of departments outside engineering. The page above covers the reading half of what a board is for; none of the rest is here. A tracker is much more than a list of work, and if you need that part, keep it and bridge it - that is not a failure of this, it is a different product.
And be honest with yourself that it is a change of mind, not a swap. The work lives where the code lives; you read it in a diff and argue about it in a pull request rather than dragging a card. Teams who take to it say it removed a standing meeting. Teams who do not, miss the board - and that is a legitimate reason to keep one.
Not estimation ceremony. Sizes are a cold-start fallback and a splitting trigger, nothing else. Once measured sprints exist, size stops being an input to any projection, and an item that does not finish inside its sprint is split rather than re-sized.
Not a history. A sprint file is state. What happened and why it mattered goes in the closing note, or in a decision record if it changed how you work.
Whose job this is
Mostly the product owner's, and the Product Owner page is where the rest of that job lives. But adding to the backlog belongs to whoever noticed the thing, which is usually a developer mid-change - and that is the point of it costing one sentence.
