RFD 100: In-Repo Ticket Tracking
- Status: Implemented
- Category: Process
- Authors: Jean Mertz git@jeanmertz.com
- Date: 2026-08-05
- Extends: RFD 001, RFD 041
- Extended by: RFD 102
- Summary: Lightweight markdown-based work items tracked in-repo, displacing GitHub issues from assistant context with kanban board and RFD promotion mechanics.
Summary
This document establishes tickets: lightweight work items tracked as markdown files in the repository, alongside RFDs. Tickets carry comments, sit on a kanban board, and can be imported from GitHub issues. They take over the work that RFDs have been absorbing but were never meant to hold.
Motivation
The RFD process works for what it was designed for. It has also become the only place to write anything down, and the corpus shows it: 98 published RFDs and 58 drafts, with one-pagers sitting next to real designs. RFD 068 is 585 words. RFD 065 is 4638. Two drafts share a title.
When "significant architectural change" is the bar and there is no lower rung, everything climbs to the top rung.
GitHub Issues are the obvious lower rung, and they stay — outside contributors should not have to clone a repository to file a bug. But assistants grep the repository constantly and rarely search GitHub, so issues sit outside the context that actually gets read. They are also absent from git grep and detached from the commits that resolve them.
What a Ticket Is
A ticket is a unit of work: a bug, a feature, a chore. It records what needs doing and the discussion around it.
| RFD | Ticket | |
|---|---|---|
| Question | What should we build, and why? | What needs doing? |
| Contains | A design and its rationale | A description and a discussion |
| Lifetime | Permanent record | Closed when the work is done |
| Deletable | No | Yes |
Write a ticket when the work is clear enough to start, an RFD when it needs a design first. The signal is whether there is a decision to argue about: "the tool call header misaligns below 80 columns" is a ticket even if the fix is subtle; "how should tool output be bounded?" is an RFD even if the implementation is trivial.
A ticket whose discussion turns into a disagreement about approach is promoted to an RFD draft — the pressure valve that keeps design work out of tickets without pushing task work back into RFDs.
Unlike RFDs, tickets can be deleted — a ticket carrying false claims or imported spam is removed outright, so that nothing reads it as true. References to a deleted ticket render as dangling.
TIP
RFD 102 replaces the counter with a time-ordered random id, so that two checkouts allocate without coordinating. Ids become collision-resistant rather than never-reused.
Ticket Format
A ticket is a single markdown file at docs/ticket/<id>-slug.md. The canonical reference form is T-02wt0kx; tooling accepts T02wt0kx, the bare body, and any case. The id's shape and allocation are specified in RFD 102.
The filename carries the id and the document does not, so there is one place that names a ticket and nothing to keep in step. Renaming a ticket is a rename.
Description and full discussion live in that one file, so reading a ticket is one fs_read_file, one cat, or one page on the website.
# Tool call header misaligned
- **Status**: In Progress
- **Kind**: Bug
- **Authors**: Jean Mertz <git@jeanmertz.com>
- **Date**: 2026-08-05
- **Implements**: 095
- **Label**: client=cli
- **Label**: package=jp_printer
The header renders one column left of the body when `style.parameters` is
`function_call` and the terminal is narrower than 80 columns.
## Comments
-----
- **From**: jean
- **Date**: 2026-08-05T14:03:11Z
Reproduced at 72 columns. Not at 80.
-----
- **From**: jp
- **Date**: 2026-08-05T14:31:02Z
- **Re**: #1
The wrap calculation in `jp_printer` uses the pre-indent width:
```rust
let available = width - indent;
```
-----Metadata
Tickets use the same - **Key**: Value idiom as RFDs, for the same reason: it parses with a three-line regex and renders as visible content.
| Field | Required | Values |
|---|---|---|
Status | yes | Todo, In Progress, Done |
Kind | yes | Bug, Feature, Chore |
Authors | yes | |
Date | yes | YYYY-MM-DD |
Blocked by | no | T-02wt0m3, or free text |
Implements | no | The RFD this ticket implements |
Promoted to | no | The RFD this ticket became |
GitHub | no | #123, set by import |
Label | no | One key=value pair, repeatable |
There is no close date. The ticket_* tooling reads it from git history, so the file never carries a timestamp that can go stale.
Labels
A label is a key=value annotation grouping tickets by area of the system, orthogonally to Kind: Kind says what type of work a ticket is, a label says what part of the system it lands in. A ticket carries any number of them, or none, and a key may repeat: a ticket spanning two crates carries package=jp_cli and package=jp_config.
The field is written once per pair rather than joining them onto one line:
- **Label**: client=cli
- **Label**: package=jp_cli
- **Label**: package=jp_configThat is not cosmetic. Every markdown file in the repository is formatted by comfort, which wraps a list item at 80 columns; a single line listing several long values would be wrapped onto an indented continuation that the metadata parser reads as the start of the description. One pair per line means no line grows with the number of labels. The formatter also escapes what would otherwise be emphasis, so package=jp_cli is written package=jp\_cli and every reader undoes that.
The shape, the key grammar, and the on-disk invariants come from jp_label, the same Labels conversation labels use (RFD 101); the closed vocabulary below is the ticket board's own, declared through that crate's Vocabulary.
The vocabulary is closed and lives in docs/ticket/.labels.json, next to the tickets and to .board.json. That file is the only source of truth for which labels exist; nothing here enumerates them. It declares one entry per key, with the values that key accepts:
{
"<key>": {
"description": "<what the key covers>",
"values": ["<value>"],
"retired": ["<value>"]
}
}A closed set is the point. Free-text labels accumulate near-synonyms and grouping stops working. A write naming a key or value the file doesn't declare is refused, and the refusal names the ones that can be added.
Retiring is not deleting. A retired value stays valid on a ticket that already carries it and can be listed again on a write to that ticket, so relabelling an old ticket never forces its history to be rewritten. It cannot be added anywhere new. Deleting the entry outright is the other option, and it turns every ticket carrying that value into a docs-build failure until they are fixed.
Reading is liberal throughout: a ticket lists the labels it carries as written, and the docs build reports one the vocabulary doesn't declare rather than hiding it.
Filtering takes a pair or a bare key: --label package=jp_cli matches that value, --label package matches any value under the key. Repeating the flag narrows further, so each term is an additional requirement.
Labelling replaces the whole set rather than merging: jp ticket label <id> --label client=cli is what the ticket ends up with, and no arguments clears it. A retried call therefore lands the same way twice. Because the check is against the ticket as well as the vocabulary, listing a retired value the ticket already has is how you keep it.
The ticket_* tools advertise the accepted tokens as a JSON Schema enum, so the assistant is offered the set rather than guessing at it. ticket_create is offered only what it may add; ticket_label and ticket_list also get retired values, because replacing a whole set means naming a retired value to keep it, and finding what still carries one means filtering on it. The tool declarations cannot read .labels.json themselves, so the tokens are mirrored into .jp/mcp/tools/ticket/labels.toml by just ticket-labels-sync. The mirror is a hint rather than a contract: writes are validated against .labels.json, so a stale mirror degrades to a corrective round trip and never to a wrong write, and editing the vocabulary never fails a test. It is a stopgap, and goes when config values can reference a file directly.
An import never sets labels. The repository owns the metadata block, so an imported issue is labelled here by whoever triages it.
Comments
Comments are separated by a line of five or more dashes and open with their own metadata block. From is a short handle (jean, jp); imported GitHub comments use gh:username.
The structure of the file is append-only: comment blocks are never removed or reordered, and a new comment is a pure append at EOF. Block contents are freely editable, as on GitHub — descriptions grow as information arrives, and authors fix their own comments. Deleting a comment replaces its body with a marker naming the reason (deleted, off-topic, spam) and keeps the block.
Replies are recorded as - **Re**: #1, referencing a comment by its 1-based position. A reply always targets a comment on the same ticket, so it names no id. Storage stays flat; the website and terminal render the thread. Positions are not identifiers: two branches appending concurrently can shift one, and a reply then points at the wrong comment. Accepted rather than solved; stable ids are the fix if it ever bites.
Parsing
A comment boundary is a line of five or more dashes at column zero, followed by a blank line, followed by a metadata block containing both From and Date. Content inside fenced code blocks is skipped. Everything before the first boundary is the description.
The ## Comments heading is decorative. Tooling inserts it before the first comment and never consults it when parsing, so a heading of that name anywhere in a description or comment body is harmless.
The Board
Tickets sit on a kanban board with three columns:
Todo → In Progress → DoneColumns are stages of work; priority is the vertical order within a column. Todo is therefore the prioritized work queue, read top-down, and it holds triaged and untriaged work alike — an imported bug report that may not be a bug still belongs there.
Blocked is a metadata field rendered as a badge, not a column: a blocked ticket is still at whatever stage it reached, and a column would lose that.
Done is ordered like any other column, newest first, and the board view shows only the head of it. Full history lives in the ticket index.
Two pieces of state, kept apart:
- Status lives in the ticket file. It is semantic and belongs to the ticket.
- Order within a column lives in a single board file. It is relational and belongs to no individual ticket.
Encoding rank as a ticket field would mean every drag rewrites a dozen files. Same split the RFD priority board already uses.
Relationship to RFDs
Tickets and RFDs are separate document kinds with separate numbering and separate lifecycles. They meet in three places.
An accepted RFD gets a tracking ticket, optionally
When an RFD is accepted, rfd-promote offers to file its tracking ticket: one ticket carrying Implements: 045 and the label type=tracking, with the phases of the Implementation Plan as its description. A prompt, not an automatic step — the same shape as the existing tracking-issue prompt — because acceptance records an agreed direction, not a commitment to start building. Whoever accepts the prompt reviews the ticket before it lands.
The same operation runs when the RFD board's box is checked, so an RFD that was accepted without a ticket gets one at the moment work starts. It is idempotent: an existing tracking ticket is reused, never duplicated.
A phase that needs its own card is filed separately and carries Implements: 045 without the label. The fan-out is a decision made when the work is picked up, not at acceptance, where the phases are still a plan.
This replaces the GitHub tracking issue from RFD 041 §2.
Tickets promote to RFDs
A ticket whose discussion turns into a design question is promoted: the tooling seeds an RFD draft from the ticket, and the ticket closes as Done with Promoted to pointing at the draft. The work item is finished; the work moved.
The RFD board keeps ordering; inDevelopment moves to the ticket
The inDevelopment flag leaves docs/rfd/priority.json. "Someone is currently writing code for this" is a property of a work item, not of a design document — it was on the RFD board only because tickets did not exist.
Each RFD gets at most one tracking ticket: a ticket carrying Implements: NNN and the label type=tracking, holding the implementation plan as its description. It plays the role a tracking issue plays elsewhere; a phase that needs its own card gets a separate ticket, which carries Implements: NNN without the label.
The flag becomes derived from that one ticket: an RFD is in development when its tracking ticket sits in the In Progress column. Phase tickets never light the flag.
The RFD board is a control surface over that ticket, not a second store, the same way dragging a card between columns is. Checking an RFD's box creates the tracking ticket if it doesn't exist and sets it to In Progress; unchecking sets it back to Todo. No status history is written to the ticket — the file's git history already dates every change. The box is offered from Accepted onward, which is where implementation begins: a draft or an RFD still under discussion is something to prioritise finishing, not to start building.
RFD priority ordering stays. The two boards rank different things:
| RFD priority board | Ticket board | |
|---|---|---|
| Question | What should we design next? | What are we building now? |
| Holds | Ranked design pipeline | Work items that exist |
Most of the RFD backlog is not a work item — nobody has said "this needs doing" about it beyond "we should think about it" — so a ticket per RFD would flood the board.
GitHub Issues
GitHub Issues remain the front door for outside contributors. They are imported as tickets, one way only.
Each import replaces the ticket's content — title, description, comments — wholesale, and never touches the metadata block. GitHub owns what was written on GitHub; the repository owns Status, Kind, Blocked by, and Implements, so an imported ticket can be triaged and moved across the board without the next import undoing it.
Imported tickets are read-only for discussion: replies go on GitHub and arrive on the next import, so there is no divergence to reconcile and nothing is ever written back.
Imported text is untrusted input. The site compiles markdown as Vue, so imported bodies are escaped before they reach the working tree and are rendered as data, never as page source.
Alternatives
Git objects instead of files. PR #872 proposes issues as per-writer, append-only operation logs under a dedicated ref namespace, folded deterministically so concurrent edits never conflict. It is the stronger data model and the wrong fit here: refs are unreachable by fs_read_file and git grep, invisible to the site build, and absent from pull-request diffs — the three properties this design exists to provide. Its conflict-freedom solves offline editing across replicas that cannot see each other, which is not this project. The cost accepted instead is merge conflicts on the board file and on concurrent appends.
It also keeps priority in the store, where a re-rank rewrites one object per issue rather than one board file, and tombstones rather than deletes.
Non-Goals
- Assignees and milestones. Add them when the absence hurts. Labels landed later, once grouping by area started to matter; see Labels.
- Label hierarchies, colours, or per-label board columns. A label is a name and a description.
- A search DSL.
just ticket-grepover markdown files. - Write-back to GitHub.
- Notifications. The board is the notification.
- Stable comment ids. Positions are good enough until they are not.
- Replacing RFDs. This lowers the bar for entry; it does not remove the top rung.
Risks and Open Questions
Two boards may be one too many. If the RFD board goes stale once tickets exist, fold RFDs into the ticket board as cards and retire
priority.json.Ticket sprawl replaces RFD sprawl. Mitigated only by tickets being deletable and closed tickets leaving the board — neither true of RFDs.
Agent comment volume. A ticket with forty comments is a large file to read. The answer is closing tickets sooner, not splitting the file.
Implementation Plan
This document fixes the file format, the vocabulary, the board semantics, and the relationship to RFDs. How the tooling gets there is not fixed.
- Format and lifecycle.
docs/ticket/, the comment parser, and recipes to create, comment, close, and list. - Assistant tools.
ticket_*tools through.jp/mcp/tools/. The point of the exercise. - Website and board. A
/ticket/index and the kanban board. Retire theinDevelopmentfield and derive it from the tracking ticket, which the RFD board's checkbox moves. - GitHub import. One-way, issues and comments.
- RFD seams. The tracking-ticket prompt and ticket-to-RFD promotion, which needs an idempotent retry story. Amend RFD 001 and RFD 041, including 041's stale claim that tracking issues are created at Discussion, and add a TIP to 041 once this RFD holds a permanent number.
- Shared substrate. Extract the duplicated metadata parsing, id allocation, and board state — if the duplication has proven itself.