Issue tracking and the roadmap board
This page is the whole of how work is tracked here. $start-task reads it before opening or placing an issue and $finish-change before linking a pull request to one, which is every point at which the board is written.
Work is tracked as GitHub issues on the MailFathom roadmap project board (project number 4, owner Krzysztof318), which is the owner's view of progress. The board reflects the repository; it never becomes a second source of truth. Where an ADR under docs/decisions/ governs a change, it remains authoritative for what that change must do, and the issue links to it instead of restating it.
The repository is public and the board is not. Project 4 is reachable only by the owner and by whoever the owner has granted access to, so every rule below that reads or writes it is a rule for a session that holds that access rather than for a role. The two are not the same question: the owner may grant a contributor read or write on the board without granting anything on the repository, and a clone of MailFathom made without write access reaches neither. So the access is probed rather than inferred — { user(login: "Krzysztof318") { projectV2(number: 4) { viewerCanUpdate } } } answers it in one call, true meaning write, false meaning read, and a null project beside a NOT_FOUND error meaning neither. The project number belongs to its owner's namespace, so that login is how anyone addresses this board, including somebody who will turn out to have no access to it; GitHub then hides the project rather than refusing it, which is why the reply to no permission is worded as does not exist and why reading it as a mistyped number is the wrong conclusion. Without write, gh project item-edit fails rather than degrading, so it is not attempted and nothing here asks for it. The issues themselves are public and are where a contribution is discussed; what stays private is the owner's ordering of them. No public file links the board, for the same reason: a URL that answers 404 for everyone but one person is worse than no URL.
This repository is worked by agents. Issues are opened, filled in, labeled, placed on the board, and closed by an agent rather than by a person, so the conventions below are the whole mechanism rather than a description of one. Nothing here is tidied up afterwards by hand: an issue that arrives without its label and its board fields simply stops being visible in the views the owner reads. Apply every rule on this page as part of opening the issue, decide the values from the rules given, and state in the task brief what was set. Ask the owner only where a rule below says the choice is theirs.
Every rule below is therefore written from the position that an agent opened the issue. A public repository also receives issues and pull requests nobody here opened, which none of those rules reached; Issues and pull requests from outside the project governs those.
Four questions, four mechanisms
Each question has exactly one owner, and no mechanism answers a question another one already answers. Adding a second mechanism for a question that already has one is the failure mode this structure exists to prevent.
| Question | Mechanism | Decided by |
|---|---|---|
| What kind of work is this? | A type:* label |
the rules under Labels |
| Which release does it ship in? | The milestone | the rules under Milestones |
| Where is it in its lifecycle? | The board's Status field |
the built-in board workflows and Fathom review, never by hand — except Blocked, which only a hand writes |
| What is being worked next, and what is deliberately not being worked? | The board's Queue field |
the rules under Board fields; the owner chooses Next, and the skill that opens a pull request also writes it — $finish-change for ordinary work, $prepare-release for a release |
An open pull request moves both of the bottom two rows, and that is not the duplication this table forbids. Status: In progress is the lifecycle fact and Queue: Next is what puts the item in front of the owner; they stay separate answers because a project view filters fields with AND and can therefore read only one of them. Asking Now for what the owner queued or what is in flight is not expressible, so the two conditions have to meet in one field for either to be visible there at all.
The issue that governs a change
- Every change starts from an issue. Identify it during
$start-task, before editing files, and name it in the task brief. Read whatever governs the change first — the ADR context, the architecture draft, or both — because an issue body is written from it. - An issue names whatever governs it: the ADR under
docs/decisions/, the architecture draft underspecs/, or the issue it follows from. None of those is what entitles work to exist, so an issue nothing backs — a feature as readily as maintenance, an ADR consequence, or a defect — is opened on exactly the same terms. Where nothing governs it, its own body is the governing text and there is nothing further to declare: that nothing is linked is already visible to anyone reading it. - Do not open a second issue for work an existing issue already covers. Extend the existing issue when scope grows and record why.
Issue content
Every issue body carries two or three user stories and a condensed acceptance list.
Do not copy the text of an ADR or of the architecture draft into an issue. That text is the contract, and a duplicated copy goes stale silently.
Express dependencies as issue references so the board shows them as links.
Nothing on the board schedules work. The owner works alone at irregular times, so order is recorded and timing is not. There is no date, deadline, day-estimate, sprint, or capacity field, and none is to be added: the two the board once carried accumulated no value on any item across its whole history, which is what a field for a question nobody asks looks like. Do not read
Sizeas one either — it estimates a diff, not a duration.Use a parent issue only where it carries something no other field can. A parent standing over the issues a release needs answers the question the milestone already answers, and one standing over a theme does what
Areadoes, so both create a second hierarchy next to the roadmap and neither is worth having. What earns a parent is one feature large enough that it had to be split into several issues, whose parts then have an order between them: which piece gates the rest, which two can run in either order, and what has to be true across all of them before the feature is done. The milestone cannot say that,Areacannot say it, and dependency references say it only to somebody who opens every child.#332is the worked example — one mailbox credential, four issues, one gate. Where the feature is small enough to be one issue, or where its parts have no order, the references each body already carries are enough, and a parent adds a place to keep up to date instead. Opening a parent also settles which milestone the parent itself takes, which is a decision rather than a step, and the rules under Milestones are where it is made.A parent carries the
parentlabel, applied in the same pass that links its children. Nothing else makes one findable from the board: the documented view qualifiers carry nothing that asks which issues have children, and the one sub-issue qualifier among them —parent-issue:OWNER/REPO#NUMBER— lists the children of a parent whose number the reader already knew. The label is not what makes an issue a parent, though. The sub-issue links are, they remain the only source of truth for the hierarchy, and where the two disagree the links are right and the label is stale. That is also why the label adds no fifth mechanism beside the four questions above: it answers none of them, and mirrors a structure GitHub already records for the one reader that cannot see it, which is a board view. Link each child by itsidrather than by its number, which is the part of the call worth reading twice:child_id=$(gh api repos/Krzysztof318/MailFathom/issues/<child-number> --jq .id) gh api repos/Krzysztof318/MailFathom/issues/<parent-number>/sub_issues -F sub_issue_id="$child_id"-Fis what sends the id as a number, which is the type the endpoint takes;-fwould send the same digits as a string.A parent's title begins
[P]. The label answers the same question, but only where labels are rendered: an issue list, a search result, a notification, and a reference from another body all show a title on its own, and a parent read there as an ordinary issue is picked up as work instead of as the thing that groups it. The prefix is applied when the parent is opened, in the same pass as the label and the child links, and it is the whole of the convention — no other kind of issue carries a title prefix, so[P]never has to be told apart from a second one.The hierarchy is at most two levels deep: a parent, a sub-parent beneath it, and the issues that do the work beneath that. The middle level exists for a feature large enough that one of its own parts split again, and nothing smaller earns one — a feature whose parts are ordinary issues has a parent and no sub-parent, which is the normal shape and stays it. A sub-parent is a parent under every rule on this page: the
parentlabel, the[P]prefix, theQueuerule under Board fields, and the milestone rule above all read on it exactly as they read on the parent above it. It is a child in one respect only, which is that it is linked under that parent by the same call. Nothing nests below a sub-parent's children, and a feature that appears to need a third level is two features that each need a parent.
Labels
Every issue carries exactly one type:* label and nothing else is required. The type names what the work produces, which is a property of the work itself, so it is chosen when the issue is opened and then left alone; it does not track progress and it never changes because circumstances did.
Several changes match more than one description — a defect in database wiring, a documentation-only change to this contract — so the table is a precedence list, not a menu. Read it top to bottom and take the first row that fits.
| Label | Use it for |
|---|---|
type:decision |
Work whose deliverable is a decision: an ADR, a policy, or a measurement that settles a question |
type:defect |
Something already implemented behaves incorrectly, whatever part of the system it lives in |
type:docs |
Documentation only, under docs/, README, or the architecture draft's prose. AGENTS.md and .agents/skills/ are the workflow contract, not documentation, and belong to the next row |
type:workflow |
Repository tooling, CI, verification scripts, the release process, and this workflow contract |
type:infra |
Orchestration, database wiring, telemetry, build and packaging plumbing |
type:feature |
Any remaining production-code change: a feature, a refactor, or hardening |
type:decision marks work only the owner can settle, and the Decisions view is read as a queue of that debt. It says what the issue produces, so it belongs on the issue that decides, not on the issues waiting for the answer — those keep the type of what they will eventually build and say they are waiting through Queue: Needs decision. Encoding one state in both places would leave the type stale the moment the decision landed.
type:spec is historical and no new issue takes it. It marked work backed by a numbered specification, and the working specifications those issues were written against are gone; the label stays on GitHub because the issues carrying it are a record of work that happened, and an issue that would once have taken it now takes the row that names what the work produces.
The remaining labels are flags, applied only when they are true: blocked when an issue waits on something outside itself, applied together with Status: Blocked for the reason Status transitions gives, security when a change needs a security review before it merges, parent on an issue whose sub-issues deliver one feature between them, and good first issue or help wanted on work the project would rather someone else took. shipped is historical, marking the six issues written retrospectively for work that predates the roadmap; never apply it to new work.
security is the one flag among those that decides something beyond how the issue reads. Apply pull request rules carries it onto every pull request whose body refers to the issue — the one that closes it, and equally one that only names it as related work — and Fathom review reads it there: the security rubric is applied to the whole change and the costlier model performs that pass. Labels the change earns and What the security label decides carry both halves. So applying it to an issue is a decision about how the work will be reviewed, not only about where it sits on the board.
agent:claimed
agent:claimed says a session has this issue in hand. It is the one label that describes the session rather than the work, and it is applied at the moment work on the issue genuinely begins — the worktree is being made for it, the implementation is starting — by $start-task, at the step named there. Reading an issue is not taking it, so triage does not apply it, and neither does planning, estimating, or answering a question about one; a marker that meant somebody looked at this would be worth nothing to the reader it exists for.
It is never removed. A session that ended is not a reason to clear it, and it stays through the close, so it reads as a session has had this in hand rather than as a session is running now. That is the weaker of the two claims and deliberately so: a label meaning the stronger one would be wrong from every session that stopped without clearing it, and nothing here would notice, whereas the weaker claim cannot go stale because what it records already happened.
It answers none of the four questions above, which is what lets it stand beside them rather than duplicating one. Status is the lifecycle fact, the built-in workflows own it, and it moves when a pull request does; this moves hours earlier, when the work starts. The board is also private, so Status answers where is this for one reader while the label answers is anyone on this on a public issue list, without opening the issue and without the board. Nothing reads it in return — no workflow, script, or skill branches on it — so applying it by hand starts nothing and removing it stops nothing.
Writing a label is write access to this repository, so this belongs to the owner's checkout with the type:* label and the milestone. An agent working from a fork does not apply it and nothing is missing when it does not: the session that eventually picks the issue up is the one that claims it.
Milestones
A milestone answers which release an issue ships in, and nothing else. Its name is a version number, so a milestone is never opened for a feature, a theme, or a date, and a body of work that spans releases is a parent issue rather than a milestone of its own. An issue with no milestone is deliberately outside the current release rather than merely unsorted, which is what makes the absence of one meaningful — and it means that on every issue here, parents included.
One release is being worked at a time, and its milestone declares no scope in advance: what ships in it is whatever is placed in it. That is the opposite of how 0.1.0 — first public release, the only one written the other way, was, and the difference decides how a new issue is placed. A milestone that describes its own contents can be tested against — an issue either is or is not something that release cannot ship without — and one that accumulates cannot, because the description is the placement rather than a rule for it. Which version that is gets read from the milestone list rather than stated here, because a page naming it would be wrong from the moment the next release is cut: it is the lowest version among the open milestones, and a higher one open beside it is a target rather than a second release in progress, for the reason the parent rule below gives. /milestones returns the open ones by default, so the whole question is one call — sorted as versions rather than by creation order, because a target opened for a parent can precede the release before it:
gh api repos/Krzysztof318/MailFathom/milestones --jq 'map(.title) | sort_by(split(".") | map(tonumber)) | first'
So a new issue takes no milestone by default, and that stays a decision rather than an omission: the absence means deliberately outside the release, everywhere on this page and without exception. Placing an issue in the current release's milestone is what defines that release, so it is the owner's call.
The owner's call can be standing rather than per issue. They may decide that new work of some kind — code and documentation, say — goes into the current release's milestone until they say otherwise, and an agent then assigns it without asking, because the decision has already been taken and asking again re-litigates it. What an agent must never do is infer such a decision from the shape of the work, from what a neighbouring issue carries, or from the milestone being open. Absent a standing decision the default above holds and the milestone stays empty.
A parent issue carries the milestone of the release that completes it, which is the one its last child ships in. That is the owner's decision, made when the parent is opened rather than read off the children afterwards, and it is the same decision as how the feature will be delivered: in one release, or in stages across several. Where every child ships in the same release, that release is both the first and the last, the parent carries it, and the Release <version> view reads whole; #332 is the worked example. Where the children are spread over several, the target is the later release rather than the one already delivering the first half of it — a milestone answers which release ships this, and a feature delivered in stages ships when its last part does. Each child still carries the milestone of the release that ships that child, so the earlier releases hold the parts they deliver and the parent names the point the feature is done.
A target further out is why more than one milestone can be open. The release a parent completes in has to exist before the parent can name it, so that milestone is created when the parent is placed rather than when the current release closes. That leaves which release is this in with exactly one answer, which is the thing a single open milestone protected: every issue still carries at most one, and the release being worked is still one milestone, the lowest version among the open ones. What a higher one holds is work already accepted and already placed past that release, rather than a second release accumulating scope in parallel. An agent never chooses the target and never infers it from the children — it asks, exactly as it does for the milestone itself, and a standing decision about milestones does not settle this one, because which release finishes a feature is a separate call from whether new work goes into the current release at all.
The next release's milestone is therefore created in either of two places, which is why $prepare-release creates it if it does not already exist rather than unconditionally. That skill is the other one: cutting a release opens the next milestone, opens the issue tracking that release in it, moves whatever is still open in the one being released into it, and closes the one being released. What is still open when a release is cut is scope the owner is deciding about, so it moves rather than being closed on their behalf; an item they would rather drop is closed as not planned on its own issue. The issue tracking the release is the one thing that does not move: it is open and carries that milestone at the moment the move happens, so it is exactly what a query for what to move returns, and it closes there once the version-bump pull request merges — after the tag, because a release is finished when main names the next version rather than when the changelog merged. The release being worked therefore never lacks the issue that closes it: the step that hands the milestone to the next release opens that issue in it too, whether it created the milestone or found one a parent had already opened as its target — a milestone standing further out is a target for work, and the issue that closes it arrives when its release becomes the one being worked.
Board fields
The board carries three single-select fields beyond Status. Set Area on every issue. Set Queue on every open issue. Set Size when the issue is opened, from the scope its own body describes.
Areagroups every item by the part of the system it belongs to. Nine values, and each is a place rather than a phase, which is what lets the grouping survive the release that produced the work in it:AreaWhat belongs to it Configuration & secrets Configuration binding and validation, secret references, cryptographic material, listeners Mail synchronization IMAP sessions, folders, flags, transport security, and writes to the remote mailbox Storage & retention Persistence, schema, the content store, retention, and deletion Retrieval & embeddings Chunking, vectors, indexes, ranking, and search Agents & answering Chat providers, the agent, ask_mail, and the bounds on what leaves the processAutomation Rules, the job model, executions, and classification MCP surface Tools, the protocol, and transport authentication Platform Repository tooling, CI, verification, dependencies, and telemetry Release Packaging, distribution, versioning, and user documentation — never which release ships it, because that is the milestone's question Two of those boundaries are decisions rather than descriptions. Retrieval and answering are separate because the two parent issues that own them draw that line already, so a parent's children never land in two areas. And there is no security area, because
securityis a label: encoding one state in two places is exactly the duplication Four questions, four mechanisms exists to prevent.The values are deliberately places rather than delivery phases. A grouping by phase names what delivered a piece of work rather than the part of the system it lives in, so it ages out the moment that phase ships — which is what happened, and what this field was corrected from.
Queueis the ordering signal, and a new issue takes one of the values belowNextwithout asking.Lateris the default: accepted scope not yet started.Needs decisionsays this issue waits on an answer rather than on effort; name thetype:decisionissue that produces the answer, or state that none exists yet.Parkedrecords a review outcome or a side question that carries no commitment to act — something the project decided about and may return to, which is why it never stands in for work the project has declined or for an issue nobody has read yet. A parent issue takes one of those values by the same rules as any other issue, including a parent whose children span releases. That it groups other work rather than doing any is said twice already — by theparentlabel and by the[P]prefix its title carries — and aQueuevalue saying it a third time answers none of the four questions this field owns while making every reader of the field learn a value that orders nothing. So the field says of a parent exactly what it says of anything else:Laterwhile its children are in flight,Needs decisionwhere the feature waits on an answer,Parkedwhere the project is not committed to it, andNextwhere the owner wants the whole feature in front of them, which spends one of the five slots below in the same way the issue it groups would.Nextmeans in the owner's field of view now, and it has three writers. The owner sets it to mean ready to start, and at most five open issues hold it that way; the cap is what keeps the value a decision rather than a copy of everything already accepted.$finish-changesets it as well, on the issue its pull request closes, and$prepare-releaseon the issue tracking the release it just opened two pull requests for — that skill never invokes$finish-change, so the write is its own — both so that work already in flight is legible in the view the owner reads instead of only in the pull request list. Those sit outside the cap: an agent opening a pull request is not choosing what to start next and must never spend one of the five slots that decision uses. A closed issue keeps whateverQueuevalue it had and stops counting, which is why neither kind has to be cleared on merge and why every view that readsQueuefiltersis:open.Sizemeasures the pull request in changed lines, additions plus deletions, including tests and documentation. The ranges are contiguous and leave no gap:Sunder 1000,Mfrom 1000 to 2499,Lfrom 2500 to 4999,XLfrom 5000 up and to be split before it starts. Read a written line estimate through a factor of five, because that is what nine merged pull requests measured against the estimates they were opened on — a median of 5.0, ranging from 2.6 to 7.3, never below. An estimate of 600 lines is anL.Lis the normal size of a substantial unit of work here, so anXLis a genuine warning rather than a large-sounding label.The value is set when the issue is opened, as an estimate, and corrected against the diff the pull request actually produced. An estimate that turns out wrong is what makes the next one better, whereas an empty field says nothing and cannot be wrong — so the field is filled from the acceptance list the body already carries rather than left for a planning pass that never happens separately here. A
Sizethat was never revised after the merge is the ordinary case and needs no action; one that was revised two steps is worth a sentence on the issue saying what the estimate missed.A parent issue takes
XLand keeps it. Its size is the sum of its children rather than a diff of its own, and that sum is what puts it over the threshold, so the value reads as this is delivered in pieces on exactly the issues that already are. The warningXLcarries elsewhere — split this before starting — is answered on a parent by the children themselves, which is what makes it the one place the value is not a problem to solve.
The built-in workflows set Status and nothing else, so a newly opened issue reaches the board with no Area, no Queue, and no Size. Setting all three is part of opening the issue:
gh project field-list 4 --owner Krzysztof318 --format json # field ids and option ids
gh project item-list 4 --owner Krzysztof318 --format json # item id for the issue
gh project item-edit --project-id <project-id> --id <item-id> \
--field-id <field-id> --single-select-option-id <option-id>
Each field is a separate call, so one can land while another fails. A project view filters fields with AND and cannot ask for a missing Area or a missing Queue in one expression, which is why the Triage view catches only the untouched case. Audit all three after placing an issue, and whenever the board is worth trusting:
gh project item-list 4 --owner Krzysztof318 --format json --limit 400 \
| jq -r '.items[] | select(.status != "Done")
| select(.area == null or .queue == null or .size == null)
| "\(.content.number) area=\(.area) queue=\(.queue) size=\(.size)"'
A missing Area is also visible without running anything: the Roadmap view groups by Area, so an unplaced item sits in its own group at the end of the board.
Views
A view holds no state. Every one of them is a filter over fields that already exist, which is why the set below adds nothing to the four mechanisms and why no view can be left out of date by an agent forgetting a step.
| View | Filter | What it answers |
|---|---|---|
Now |
queue:Next -status:Done |
what is in front of the owner |
Roadmap |
is:open -queue:Parked |
everything the project intends to build, grouped by Area |
Backlog |
is:open queue:Parked |
what it has considered and not committed to |
Release <version> |
milestone:"<version>" |
one release each |
Parent features |
is:open label:parent |
the features, with Sub-issues progress as a column |
Decisions |
is:open label:"type:decision" |
the answers the owner owes |
Triage |
is:open no:queue |
the inbox for issues the project did not open |
All |
-status:Done |
everything open, unfiltered, for when a query is easier than a view |
Now groups by Status, and that grouping is what separates the field's two writers without a second field: what the owner queued waits in Todo, and what a pull request carried in sits in In progress, because the same event that set Queue also moved Status there. A review moves it to In review while it reads and then on to Changes requested or to Ready to merge, and a merge into main that leaves an approved change unmergeable moves it to Conflicts, so the view reads left to right as start it, finish it, wait for the review, answer it, rebase it, merge it — and the column an item sits in says which of those the owner is being asked for. Blocked sits between the first two of those, where an item that stopped moving is read before the ones still moving rather than after them.
Roadmap and Backlog are two readings of Queue, not two mechanisms. The line between them falls at Parked and nowhere else: Later and Needs decision are both on the roadmap, because an issue waiting on an answer is fully intended and merely blocked, and a parent whose feature is delivered over several releases sits there under whichever value it carries, because a feature arriving in stages is the roadmap rather than an exception to it. That is also what keeps the word roadmap honest — after the filter, the view holds only work the project means to do.
Parent features is in table layout so that Sub-issues progress reads as a column beside Area and the milestone. It is the way into the parents, because the qualifiers a view filters on ask what an item carries rather than what hangs beneath it, and it filters is:open for the ordinary reason that a parent whose children are all delivered is closed with them. A sub-parent appears there beside the parent above it, since it carries the same label, and its own progress column is what makes the middle level worth reading.
Triage catches an arrival that carries no board fields, because none of the rules here reached its author; Issues and pull requests from outside the project is what empties it. An item the project itself opened never belongs there, because an agent sets Queue as part of opening an issue. Every view that reads Queue filters is:open, so no Next value outlives its issue and a closed one never occupies one of the owner's five slots.
A view's filter and layout are writable through the GraphQL API, in two calls — gh project cannot create one, and createProjectV2View takes no filter, so it lands on the updateProjectV2View that follows. Its grouping is not writable at all: ProjectV2ViewConfigurationInput carries visible fields and nothing else, so a view that has to group by Area is grouped by hand in the interface once and then left alone.
Issues and pull requests from outside the project
An issue the project did not open arrives with no type:* label, no Area, no Queue, and no milestone, because none of the rules above reached its author. That is the expected shape of an arrival rather than a defect in it, and it is not corrected by inventing values at a glance.
The absence of a type:* label is what marks an issue untriaged, because an agent always sets one. Triage is therefore a state a reader can see without a field, a label, or a board column existing to announce it, which is why none was added: the four questions still have four mechanisms, and has anyone read this is answered by whether the first of them was ever asked.
Triage is one pass over the issue and it is not implementation. Read it, then either place it or end it:
- Place it. Assign exactly one
type:*label, anArea, aQueue, and a milestone if the rules above assign one, by the same rules that govern an issue the project opened.Lateris the value a placed arrival takes, and triage never assignsNext: that choice stays the owner's whoever opened the issue, and the other way into it is a pull request that does not exist yet. What the reporter asked for does not decide the label: a report that names a defect istype:defecteven when it was written as a feature request. - End it. Close it as
not plannedand state the reason on the issue.Parkedis not that, for the reason theQueuerules give.
A question is not a unit of work and does not become one by arriving as an issue. Move it to Discussions and close the issue with a link, rather than giving it a type:* label so the board has somewhere to put it. Discussions carries Q&A for questions, Ideas for proposals that are not yet scope, and Announcements for what the project says; a discussion that turns out to be work is converted to an issue and then triaged like any other.
A pull request the project did not open is read in a fixed order, so a change is refused for the cheapest reason first: the required checks, then Protected paths, which refuses a change from anyone but the owner to .github/, .config/, .agents/, .claude/, or docs/decisions/, to an .editorconfig, .gitattributes, .worktreeinclude, AGENTS.md, or CLAUDE.md at any depth, or to the repository-root CHANGELOG.md, Directory.Build.props, LICENSE, NOTICE, NuGet.config, or global.json — and which names the paths it found either way, so an allowed change says which of them it moved. Only then comes the code-owner review the main ruleset requires. Nothing precedes those, and in particular no acknowledgement gate does: section 5 of Apache-2.0 puts a contribution under the project's license by the act of submitting it, so a check asking a contributor to state that it does adds a step to every first contribution and establishes nothing the license did not already establish. CONTRIBUTING.md says so where a contributor reads it. Fathom review runs on a fork only when a maintainer applies the fathom-review label — a fork's own pushes never start one — so a contributor waiting on that verdict is waiting on a decision rather than on a queue. A pull request whose author has stopped answering is closed with a comment saying so, and the issue it addressed keeps its own Queue value. Nothing does that automatically: at this project's volume, machinery that closes a contribution nobody read would cost more than the stale pull requests it removes.
Linking a pull request to its issue
Every pull request body contains
Closes #<issue>for the issue it completes, so merging closes the issue and the board moves the item toDone.A release is the one unit of work that is two pull requests, and both carry the tracking issue in their titles. Only the version-bump one carries the
Closesline, because the release is finished whenmainnames the next version rather than when the changelog merged; the changelog pull request references the issue without closing it.$prepare-releaseopens both and is where that shape is stated, and it writes theQueue: Nextbelow itself rather than through$finish-change, which it never invokes.Add the reference when the pull request is created.
$finish-changetreats a pull request without an issue reference as an incomplete gate.gh pr editfails against this repository with a Projects-classic GraphQL error and silently drops the edit. Patch a pull request body through the REST API instead:gh api repos/<owner>/<repo>/pulls/<number> -X PATCH -f body="$(cat body.md)"Once the pull request exists, set
Queue: Nexton the issue it closes, through the samegh project item-editcall that placed the issue. Do this for every pull request, whether the issue was opened for this task or had been sitting inLaterfor weeks, and treat a value that did not land as an incomplete gate rather than as a detail to fix later. Nothing else writes the field afterwards: the issue keepsNextuntil the merge closes it out of every view that readsQueue.That write skips an issue carrying the
parentlabel. A pull request closes the issue that does the work rather than the parent grouping it, so aClosesreference pointing at a parent is a defect to correct in the pull request body rather than an issue to move toNext— which is the one case where not settingNextis the correct outcome rather than a gate that failed.
Writing it from the skill is not a shortcut past the automation. The board's built-in workflows set Status and nothing else, so no project automation reaches a custom single-select field, and the one workflow that does reach this board writes Status too — Fathom review, with the credential Status transitions describes. Queue stays with the skill anyway, and the reason is which event the write belongs to rather than what could perform it: Next is set because a pull request now exists, which is a step in opening it, and the skill is already there holding a token that already talks to this board. Moving it into a workflow would put a second writer on a field one already owns, for a field no event GitHub raises describes. The cost is unchanged: a pull request opened by neither $finish-change nor $prepare-release moves nothing in Queue, which for a repository whose pull requests are all opened by agents is a smaller gap than a second mechanism would be.
Status transitions
- The board's
Statusfield hasTodo,In progress,Blocked,In review,Changes requested,Conflicts,Ready to merge, andDone, in that order, which is the order a view groups them in. - The board's built-in workflows own the transitions that follow an event GitHub raises:
Auto-add to projectplaces a newly opened issue on the board andAuto-add sub-issues to projectplaces one opened beneath a parent,Item added to projectputs either inTodo,Pull request linked to issuemoves it toIn progress,Code review approvedmoves it toReady to merge,Code changes requestedmoves it toChanges requested, andPull request merged,Auto-close issue, andItem closedcarry it toDone. Those are the names the board itself uses, which is what a reader checking whether one is enabled will look for. Do not set those statuses by hand; a manual status that contradicts the automation hides the real state. - The two review workflows fire on a review's state, and neither state is produced here.
Code review approvedreadsAPPROVEDandCode changes requestedreadsREQUEST_CHANGES;Fathom reviewsubmitsCOMMENTunder aNEEDS CHANGESheading, deliberately, so that a reviewer reporting no status check cannot block a merge, and GitHub does not let the author of a pull request review their own — which is every pull request the project opens. They stay enabled because they cost nothing and are correct the day a human reviewer submits either state, but nothing in the ordinary flow reaches them. - So
Fathom reviewwrites both values itself, in a job of its own after it has published a review, on every issue the pull request's body closes.Changes requestedfollows a review carrying findings andReady to mergefollows an approval, which makes the value the newest verdict rather than the first one, and makes it a verdict a reader can go and look at rather than a state nothing submitted.docs/operations/agent-workflow.md§ What the verdict moves on the board holds the mechanism. In reviewis that same workflow saying a review is running, written on the same issues as the review starts and replaced by the verdict minutes later. It writes over every other status, including one a built-in workflow set, because what the board said before the review started is what the review has now replaced — and a run that publishes no verdict leaves the item there, which reads as a review asked for and answered by nothing.DoneandBlockedare the exception at both ends of the review, for the reason the entry below gives.Ready to mergeandChanges requestedare review verdicts rather than further phases of the work, which is why they are a pair: an approval moves an item into the first and a later pass with findings moves it into the second. Together they separate a pull request waiting on the owner's merge from one waiting on the agent that has to answer it, and both from work still being written — a distinctionQueuecannot draw, because every one of those items is legitimatelyNext.In reviewseparates the fourth case from all three: waiting on the reviewer itself.Conflictssays the change no longer merges, and it is written byApply pull request rulesrather than by a review. It sits besideChanges requestedbecause it says the same thing about who acts next — the agent owes a rebase rather than an answer — and it is written fromReady to mergeand from nowhere else. That column is the one claiming a change is waiting on nothing but the owner pressing the button, so a conflict is news there and only there; an item still being written, blocked, or done says nothing about whether the conflict is new. The rule runs on a push tomain, because a merge is what makes another branch stop merging and GitHub raises no event on the branch it happened to. Nothing writes the reverse transition: the rebase that resolves the conflict is a push, the push starts a review, and the verdict leavesConflictsthe same way it leavesChanges requested. Where no review runs — a draft, or the automatic ceiling reached — the item stays there until one does. The board status the state earns holds the mechanism.Blockedis the one status a hand writes, and the exception is narrow: it says the issue is stopped by something outside the project — an upstream outage, a GitHub Actions incident, an answer owed by somebody else — which is a fact no event on the pull request carries and therefore one no automation can derive.Fathom reviewrefuses to write over it for that reason, at both ends of a review and exactly as it refuses to write overDone. A built-in workflow will still replace it on the next event, which is correct: a push, a link, or a merge is proof that whatever stopped the work has stopped stopping it.Blockedstands beside theblockedlabel rather than replacing it, for the reasonagent:claimedstands besideStatus: the board is private and the issue is public. The status is the column the owner reads, the label is how the same fact reaches an issue list, a search result, and a notification, and the two are applied in one act. Where they disagree, the one that was set later is right and the other is stale — say what blocked it on the issue as well, because neither a red column nor a label says what is being waited on.- What a workflow needs to write this board is a classic token with the
projectscope, held as theBOARD_PROJECT_TOKENrepository secret. The board belongs to a user rather than to an organization, and that is the only credential that reaches one: no GitHub App permission covers a user's Projects v2, and a fine-grained token carries noProjectsscope at the account level. The scope is account-wide, so the secret is write access to every project the owner has, and what contains it is where it is held rather than how it is scoped — one job that checks out only the base commit, runs no model, and receives its input as a string.Fathom reviewskips the write and stays green while the secret is absent, so removing the token disables the board write rather than breaking the review. Statusrecords what has happened andQueuerecords what is intended, which is why neither substitutes for the other. Work that stalls keeps whateverStatusthe automation gave it and moves toLaterorParkedinQueue.- Automation does not add an issue that is already closed when it is created. Add a retrospective
shippedissue to the board explicitly and set it toDone. - When work stops without merging, say so on the issue and leave the status to the automation rather than moving the card.