๐๏ธ Runbook โ the GitHub board's epics โ
Read it when you add a ticket to the board, create or retire an epic, wonder why a card is in the wrong column, or need to know which view answers your question.
The board is the roadmap/chantier view; the iterion native board (studio /board) stays the bots' operational surface. The working contract โ session phases and the claim rule โ is in AGENTS.md; the statuses, fields and views are this page's mechanics.
๐งญ Which view answers what โ
Nine views, split by audience. The epic views carry label:epic; the ticket views carry -label:epic โ an epic sitting in the Kanban's In progress column is noise, and there are 28 of them (2026-09-22 โ the count moves; scripts/board-epics-sync.sh is what tells you the current one, and whether the two representations still agree).
The strategy โ the epics themselves:
| view | question it answers |
|---|---|
| ๐ฆ Chantier state | Where do we stand, and what needs attention first? Board, columns = State of play. The one-screen read. |
| ๐ Epic map | What just landed, and what is next, on each chantier? Table: state of play, Latest, Next, priority, progress. |
The work โ tickets:
| view | question it answers |
|---|---|
| ๐ฏ Work by epic | What is live on each objective? Board, columns = Epic, open work only. |
| ๐ Kanban | the day-to-day board, by status |
| ๐ In flight | who is on what โ In progress + Blocked |
| ๐ฅ Triage | the Inbox to empty at session start (Phase A) |
| ๐งฉ Unassigned | Did anything escape? no:epic. It must read 0. |
Reference: ๐ All items (the raw table โ give it a filterQuery to slice by epic) and ๐บ Roadmap.
Two axes โ Status and State of play โ
They answer different questions, and using one for the other is what made the board unreadable the first time round.
Statusโ is someone on it? Its vocabulary is a claim lifecycle, written for tickets: Inbox โ Planned โ In progress โ Blocked โ Done, wherePlannedmeans "triaged, ready to pick up" andIn progressmeans "claimed by a session".State of playโ how far is it? ๐ด blocked ยท ๐ gap identified ยท ๐ก running, watch it ยท ๐ข humming ยท โช design stage. It grades evidence, not ambition, and it is the axis that actually separates one chantier from another.
For an epic, Status is almost always In progress. A chantier with an รฉtat d'avancement is not "ready to pick up", whatever its ticket flow looks like โ Credentials had 27 delivered tickets while sitting in Planned, which is simply false. An epic is Planned only if genuinely nothing has been done, and Blocked only when a named decision is pending (say whose it is in the body).
So do not read Status to prioritise epics: it is near-uniform by design. Read State of play, which is why ๐ฆ Chantier state exists. Status on an epic still earns its keep for one thing: it keeps the epic out of the day-to-day Triage and Kanban views.
For a ticket, Status keeps its ordinary claim meaning and State of play is left empty.
Priority is an epic-level field. Every epic carries one; most open tickets do not, and that is deliberate rather than neglected. Ranking 77 open tickets against each other is fake precision โ what actually decides the next move is which chantier matters (its Priority) and what its Next says. Give a ticket a priority when something makes it urgent on its own; leave it empty otherwise. An empty column here is not a backlog to fill.
๐ญ Latest and Next โ the two lines that make it readable โ
Two text fields, on epics only, are what turn ๐ Epic map from an inventory into a briefing:
Latestโ the most recent proven advance, dated:2026-09-17 ยท #1164 โ lot 3: import and the multi-file compilation unit.Nextโ the very next step, one line, naming its ticket when there is one. When the next step is a decision, say so and say whose:DECISION, operator's: โฆ.
They are the freshest thing on the board and therefore the first to rot. Two habits keep them true, and they cost seconds:
- Closing a ticket under an epic โ update that epic's
Latest. - Picking up the next one โ update its
Next.
A Latest older than the last release is itself a signal worth reading: either the chantier is genuinely quiet, or nobody is maintaining its line. Don't guess between the two โ go and look.
Why there is no "ambition" layer above epics โ
It was considered and deliberately not built. The board already carries Area (engine ยท bots ยท cloud/ops ยท studio ยท docs) and State of play, and the latter partitions every epic into five scannable columns โ the grouping need is met. Adding a third classification to answer "what landed, what's next" would be solving a content problem with a taxonomy, which is how this board became unreadable the first time. Latest and Next answer it directly.
Revisit if and only if the epic list genuinely stops being scannable โ the seam goes in at the second variant, not the fifth: a real second grouping need, named, not an anticipated one.
๐งฑ How membership is represented โ and why twice โ
An item belongs to an epic in two places, deliberately:
- Sub-issues are the authority. Native, they survive off the board, they give each epic a progress rollup in the repo, and agents that read issues (Codex, pi, Revi) see them without opening the project.
- The
Epicsingle-select field is a projection. The views need it: a board's columns accept a single-select but never "Parent issue", a filter can match a field but not a parent, and GitHub's Board+swimlane-by-parent rendering drops cards silently (community #193324, unresolved, correlated with sub-issue count). An overview built on that path would lie without saying so.
A view is a read model, never a second source of truth (philosophy #5). The field is re-derived from the parent by:
scripts/board-epics-sync.sh # report
scripts/board-epics-sync.sh --apply # fix the projection โ the parent wins
task board:epics:sync # same, through the TaskfileIt is idempotent: a second consecutive run reports board is consistent and writes nothing. That second run is the proof, not the first.
The rule it enforces holds at any depth: an item's Epic equals its parent's Epic. When the parent is an epic issue that is the epic itself; when the parent is an ordinary ticket (a legitimate two-level chain โ #1209 โ #1072 โ the Connectors epic) it is whatever that ticket carries.
Sub-issues are attached to OPEN work only. Closed items carry the field without a parent โ reported as field-only, which is the expected state. Two reasons: GitHub caps a parent at 100 sub-issues and 8 levels of nesting, and a progress bar that counts history means nothing. With open-only, the bar means what is left.
โ Adding a ticket โ
- Open the issue as usual.
- Add it to the board, set
Epic,Status,AreaandMode(the planned dev mode:dogfoodordirect). - Attach it as a sub-issue of its epic (
gh issue editin the UI, oraddSubIssue). task board:epics:syncโ it must sayboard is consistent.- Closing it later? Update its epic's
Latest. Picking up the next one? Update itsNext.
Step 3 is the one that gets skipped, and the sync script is what catches it. It already earned its keep on its first run: a ticket created by another session while the sweep was running showed up as NO EPIC, and two items mis-filed by the bulk classifier showed up as DIVERGENT.
๐ Adding an epic โ
- Create the issue titled
epic: <emoji> <objective>, labelepic, body using the template below. - Add an option to the project's
Epicfield. โ ๏ธupdateProjectV2Fieldmust re-send every existing option WITH itsidโ omit an id and every item holding that option is silently cleared. The schema says so, and it is the one irreversible mistake here. - Put the epic on the board and give it the new option as its own
Epicvalue โ that is howboard-epics-sync.shlearns the mapping, so no epic list lives in the script. Set itsState of playto match the mark in its body, and itsStatustoIn progress(orBlocked, with the decision named) โ see the two axes. - Add its row to state-of-the-art.md, and link that page back from the epic body. Both ends, same change.
โ๏ธ The shape of an epic body โ
epic: is an objective container. A multi-page specification is a design: ticket that an epic consumes as a sub-issue (#1006, #1165) โ two very different objects sharing one word is part of what made the board hard to read.
## Objective โ one paragraph, in product terms
## Where we stand โ <date>
**Status: ๐ข humming** โ ๐ข humming ยท ๐ก running, watch it
๐ gap identified ยท ๐ด blocked ยท โช design stage
- evidence, each linked to its proof (run, check, PR, schedule, doc)
- and, honestly, whether the state is *measured* or *derived from the tickets*
## What is settled โ the acquis, N closed tickets, the 3โ5 that matter
## What is left โ GitHub renders the sub-issues
## What blocks โ named, with the pending arbitration if there is oneThe marks grade evidence, not ambition; the vocabulary is shared with state-of-the-art.md.
โ ๏ธ What the API cannot do โ
Everything above is scriptable โ fields, options, item values, sub-issues, view creation (name, layout, filter, visibleFieldIds) โ with one exception:
ProjectV2ViewConfigurationInput exposes only visibleFieldIds. A view's group-by / column field is not settable through the API. After creating a board view you must open it and set it by hand:
view tab โ View options โ Column field (board) or Group by (table) โ pick Epic โ Save.
Two views depend on it: ๐ฆ Chantier state (column field = State of play) and ๐ฏ Work by epic (column field = Epic). Keeping it to two is why there is no third grouped view โ the same read is one filterQuery away on ๐ All items.
Do not try to automate it with Playwright โ measured, 2026-09-18. The configuration sub-panels of the View options menu render
Uh oh! There was an error while loading, reproducibly, while the management half (Rename / Move / Duplicate / Delete / Generate chart / Export) renders fine in the same menu. It is not the API (the schema has no group-by input at all), not auth (signed in, and the other half works), and not the network (no request fails โ the only 404 is an unrelated Copilot entitlement probe). It is a client-side render failure in the automation browser. A normal browser is fine, and the setting is six clicks.
Verify by reading it back rather than trusting the UI:
gh api graphql -f query='query{organization(login:"SocialGouv"){projectV2(number:203){
views(first:20){nodes{number name layout
groupByFields(first:3){nodes{... on ProjectV2FieldCommon{name}}}
verticalGroupByFields(first:3){nodes{... on ProjectV2FieldCommon{name}}}}}}}}'