Issue triage
Keep the board clean, healthy, and honest every day, and hand the safe work off to
the autopilot skill.
Resolve the board, repos and labels via
shared/config.md before anything else. With no board
configured, run the label half and skip every Project step.
The clean-board guarantee (§ 2): after any run, no open issue is off the board and none is unassigned — every one is area-labeled, routed to its DRI, and has a Track and Status. This pass is uncapped, so the guarantee holds even on a day the deep pass hits its cap.
The rule that makes this useful: every verdict is written to GitHub — labels and board fields — not just printed. The chat table is a receipt, not the output. A run that only prints a table has done nothing, because the next run and the autopilot both start with zero memory of it.
Modes
- Default — analyze, write to GitHub, print the receipt.
- Dry run ("dry run", "don't change anything", "preview") — analyze and print the exact mutations you would make, change nothing. Use this the first time, and any time the taxonomy changes.
Workflow
- [ ] 1. Pull the working set (open issues, every repo, + the board)
- [ ] 2. INTEGRITY PASS — uncapped, ALL open issues. Guarantee every one is:
on the board · has an area label · assigned to its DRI · has a Track ·
has a Status. This is the clean-board guarantee. (§ 2)
- [ ] 3. DEEP PASS — capped at 25 untriaged issues: category → effort → priority
→ duplicates → agent-ready gate. (§ 3–4)
- [ ] 4. Write it: labels, board fields, assignee, dup comments, `triaged` LAST
- [ ] 5. Print the receipt: integrity line + out-of-sweep line + summary table +
detail on P0/P1 + board-health line
Why split: keeping the board on-project and assigned is cheap and must be guaranteed every run. Categorizing and sizing needs to read each issue and open files — expensive, so it is capped. Decoupling them means the board comes out 0-unassigned / 0-off-project even on a day the deep pass hits its cap.
1. Working set
Repos that feed one board may sit under different owners, and issue numbers
collide across them — always carry the repo alongside the number, and write
cross-repo refs as owner/repo#N.
Nothing below is ambient — resolve each of these first. The repo list comes from
workflow.json → repos (absent → the repo you are in). 🚨 The board comes from
workflow.json → board FIRST, and only then from userConfig — this repo may not
feed the board your machine defaults to, and writing to the wrong one is silent. Run the
two-step resolution and report which layer answered:
shared/board.md § Resolution. board_fetch is a shell
function, not a binary — § Board queries in the same file. Repo list:
shared/config.md § Repo scope.
⚠️ Each command runs in a FRESH shell — nothing you set survives to the next block.
So every block below re-establishes SCRATCH and re-sources board_fetch rather than
trusting an earlier one. A bare $SCRATCH expands to empty and writes to /open.json,
which fails with read-only file system — measured.
SCRATCH="${SCRATCH:-${TMPDIR:-/tmp}}"
for R in <owner/repo> <owner/repo>; do # workflow.json -> repos, expanded literally
gh issue list --repo "$R" --state open --limit 1000 \
--json number,title,body,labels,assignees,createdAt,url \
--jq ".[] | {repo:\"$R\"} + ."
done > "$SCRATCH/open.json"
wc -l < "$SCRATCH/open.json" # prove the pull is non-empty before trusting it
🚨 --limit 1000 is mandatory, and the count above is not decoration. gh issue list defaults to 30 and truncates silently — and § 2's guarantee is uncapped, so a
truncated pull turns "0 off-board, 0 unassigned" into a false claim about the issues it
never saw. wc -l is the whole pull, so check it per repo when you sweep several —
any single repo returning exactly the limit is still truncated: raise it and re-pull.
Zero open issues is a legitimate answer — a new repo, or a genuinely clean one. Say
no open issues in <repos> — nothing to triage in one line and stop. Do not report an
empty board as a clean one; there is nothing to have cleaned.
# board_fetch is a SHELL FUNCTION defined in reference/board-query.md — paste both
# `board_gql` and `board_fetch` into THIS shell (or `.` a file holding them) before
# the call, or you get `command not found`.
SCRATCH="${SCRATCH:-${TMPDIR:-/tmp}}"
BOARD_JSON="$SCRATCH/board.json"
board_fetch "<board_owner>" "<board_number>" "$BOARD_JSON" # 3 points, not 102 — see config.md
⚠️ That is this run's only board fetch — see shared/board.md
§ Board queries. Every pass below is jq over $BOARD_JSON; the § 5 read-back is the one
deliberate exception, because it has to see state written after this pull.
First run on an existing backlog
§ 2 is uncapped by design, which on a repo with hundreds of open issues means hundreds
of item-add and label writes in one go — every one of them a notification to whoever
watches the repo.
On a first run against a repo with more than ~50 open issues, say the number and the
write count out loud before writing anything, and offer dry run instead. Then either
proceed with the user's OK, or narrow the first pass to a subset — the newest N, or one
area label — and let the rest drain over subsequent runs. The guarantee is per-run over
what the run covers; say which issues this run covered rather than implying the whole
backlog is clean.
Untriaged = open AND no triaged label. That label is the idempotency key —
without it every run re-litigates every open issue and spams the same comments. A
human who wants a re-triage removes triaged.
Also re-examine any issue that is triaged but has been edited or commented on
since the label was applied and is still unassigned — those are usually a scope
change that invalidated the old verdict.
The 25-issue cap applies to the DEEP pass only, never the integrity pass. When the
untriaged set exceeds 25, deep-triage the newest 25 — priority is not a reliable
sort key here, it is usually unset until this pass runs — and report N still untriaged — next run on the board-health line. The backlog drains over a few days; the board
stays clean the whole time.
2. Integrity pass — the clean-board guarantee (UNCAPPED)
Runs over every open issue in every repo, triaged or not, capped by nothing.
Per issue, ensure each — fill blanks only; never overwrite a human's choice:
| Invariant | How to satisfy |
|---|---|
| On the board | gh project item-add <board_number> --owner <board_owner> --url <url> |
| Has an area label | If missing, determine and apply it (§ 2a) from the areaLabels of the repo the issue is in. This is the root-cause fix — don't route around a missing label, add it. |
| Assigned to a DRI | From the area label via that repo's workflow.json → dri. Never leave an open issue unassigned. |
| Has a Track | Mirror the area label to the Track field, through that repo's trackForArea. |
🚨 The map is per repo. For a sibling in repos the three maps come from the
sibling's own workflow.json — its checkout, else the GitHub read in
shared/config.md § Repo scope — and never from this file. A
sibling whose file cannot be read gets the board add and the Status only; report its
unlabeled and unassigned issues in the receipt instead of routing them off this repo's
map.
| Has a Status | If none, set Todo. Never move an existing Status. |
🚨 Especially never move Hold — it means a human parked the card by choice, and
flipping it to Todo un-decides that. Hold is orthogonal to a blocked label: Hold =
won't do now; blocked = can't, with a "Blocked by: #n" pointer.
Because most open issues already carry an area label, this pass is cheap in aggregate: the only real judgment runs solely on the handful that lack one.
Two things this pass reports but does not change — a card's state is a conversation, not a silent edit:
| Observation | Action |
|---|---|
In Progress, no linked PR and no commit on a feat/<N>-* branch in 72h |
Report as stale — don't move it |
| Closed issue still Todo/In Progress | Set Done — this one is safe |
| A DRI now carrying >2 In Progress (from your assignments) | List under load warnings — still assign, don't drop the invariant |
2a. Determining a missing area label
Decide from the repo + title + existing labels — usually enough without opening
files. Apply exactly one, from the repo's own label set (gh label list).
If the title genuinely isn't enough, read the body — still bounded, only the few unlabeled issues reach here. Only if it is still unclassifiable does it fall to the lead as holding owner, flagged in the receipt as "needs area". That last resort should be near-empty; the goal is a real label, not a default dumping ground.
⚠️ The area label drives the assignee, so get boundaries between repos right. A
subject-matter word in a title does not override the repo: e.g. AI/classification work
inside a backend service is a backend issue, not an agents one, however it reads.
Each repo's own workflow.json carries its boundary rules beside dri (a
$comment_dri); read them from the file of the repo the issue is in and follow them.
The candidates for an issue are the areaLabels of its repo, never a sibling's.
3. Deep pass — categorize, size, prioritize (CAPPED at 25)
Runs only on untriaged issues, newest 25 first. Read the issue and its
comments before judging, and open the files it names. If gh issue view returns
empty (a transferred issue), read .content.body out of the board JSON instead.
By the time an issue reaches here it already has area, assignee, Track, Status and a board slot from § 2 — so this pass adds only the judgment-heavy attributes.
Optional — fan this pass out. The judgments below are independent per issue, so on a
large untriaged set they can run as parallel batched subagents instead of serially.
../../reference/workflow-fanout.md holds the
script, the batch build, and the boundaries that keep it from drifting into a second
implementation of this section. It returns verdicts only — § 5 still performs every
write, in § 5's order — and you still adjudicate § 4 yourself from the evidence it
returns.
Take that path only when the Workflow tool is in this session and the untriaged set is big enough to pay for its fixed overhead (that file names the threshold). Otherwise this section runs serially exactly as written, which is the contract either path has to satisfy. Its absence is a normal state; it is not worth a line in the receipt.
3a. Category → label
| Verdict | Label |
|---|---|
| Bug | bug |
| Feature request | enhancement |
| Improvement (refactor, perf, DX, cleanup of something that already works) | improvement |
| Question | question |
Exactly one category label per issue. Don't strip a category a human already set — if you disagree, leave theirs and note the disagreement in the receipt.
3b. Effort → label
Estimate against this codebase, not in the abstract. Open the files the issue names before deciding; an effort label you guessed is worse than none.
| Label | Means |
|---|---|
effort:easy |
One repo, files obvious from the issue text, an existing test/pattern to mirror, no schema or infra change. Roughly a focused sitting. |
effort:medium |
Multiple modules or a new pattern; needs a design call but the shape is clear. |
effort:hard |
Cross-repo, migration, infra, an unresolved product question, or an epic's worth of surface. |
3c. Priority → the board's Priority field
Critical→P0 · High→P1 · Medium→P2 · Low→P3.
- 🚨 P0 is code/technical-critical ONLY — prod broken, data loss, active security exposure, a customer blocked.
- Label caps come from
workflow.json→priorityCaps, a map of label → highest priority that label may carry. Absent, it means{"legal": "P1"}: legal, policy and contractual items take thelegallabel and cap at P1 no matter how urgent they read — a lawyer's deadline is not an outage. A repo that wants no caps sets{}; a repo with other human-gated categories adds them. Apply the cap after the verdict, and say in the receipt when it moved a priority. - Only fill blanks. If a Priority is set, leave it and put the disagreement in the receipt with one line of reasoning. Priority is a human negotiation; silently overwriting it destroys trust in the whole routine.
Resolve field and option ids from gh project field-list in the same run — never
hardcode them.
3d. Duplicates
Compare each new issue against all open issues in every repo — near-dupes across a repo boundary are common. One tracking issue per repo for the same feature is a legitimate pattern, not a dupe; the same work described twice is.
When confident: apply duplicate, comment Looks like a duplicate of owner/repo#N — <one line on why>. Closing is up to <assignee>. Never close an issue. When unsure:
no label, list it under "possible duplicates" in the receipt.
4. Gate — what earns the agent-ready label
This is the load-bearing part of the whole routine. The label is a promise that an
unattended agent can finish this issue and open a PR a human will want to review.
effort:easy is not sufficient — easy and safe-unattended are different questions.
Apply it only when every positive condition holds:
-
effort:easy - Exactly one repo, and you can name the files it touches — for a sibling in
repos, that means its checkout resolved and you read itsworkflow.jsonforagentReadyForbiddenPathsand its deploy-target doc; a sibling with no checkout is board-only this run and never gets this label - Acceptance criteria concrete enough to write a test against
- An existing test file or pattern to mirror — or it is a pure docs/copy change
- No open product/design question in the body or comments
…and none of these disqualifiers is present:
| Disqualifier | Why |
|---|---|
Labels blocked, epic, legal, compliance, security — or Status Hold |
Needs a human owner (or a human un-parking) by definition |
| Body says "Blocked by: #n" / "depends on" / "sequenced after" (unresolved) | Ordering constraint an agent will miss |
| Priority P0 | A P0 deserves a person right now, not a queue |
| Touches infrastructure, migrations, or CI workflow files | Human-gated, by kind. Concrete paths for this repo: workflow.json → agentReadyForbiddenPaths; the deploy-target doc § Infra and migrations names the apply commands and ordering |
| Involves secrets, env vars, runtime config, or a credential swap | ⚠️ A credential/env change needs a superset first or no revision can boot — and provisioning tools silently store empty or newline-suffixed values (secrets-and-ci.md); how this platform is verified is in the deploy-target doc § Secrets and env |
| Changes a store that another store derives from — a view, an index, a sync target; the deploy-target doc § Infra and migrations names them and their rebuild order | The derived store has to be rebuilt in order against the rollout, and an unattended run cannot sequence that. With no deploy-target doc, any schema change is this row |
| Needs a coordinated change in two repos | Two PRs, one breaking moment |
| Requires touching shared staging or prod | Merging the integration branch may deploy |
When in doubt, don't apply it. The cost of a missed easy issue is one day; the cost
of a bad agent-ready is a wrong PR landing on a teammate's review queue with your
name on it.
5. Write it
Integrity writes first, for every issue: add-to-board → area label if missing →
assignee if unassigned → Track if blank → Status Todo if none. Then per deep-pass
issue: category → effort → Priority if blank → agent-ready if gated → duplicate
comment if any → triaged last.
🚨 triaged goes on last, always. If the run dies halfway, an issue without it gets
picked up cleanly next time; an issue marked triaged before its labels landed is
silently lost forever.
⚠️ Pass each label as its own explicit -f "labels[]=…", never a split shell
variable — the label endpoint auto-creates any label that does not exist, so an
unsplit variable silently creates a junk label repo-wide. After any label-add loop,
read the labels back and assert none you added contain a space — GitHub's defaults
good first issue and help wanted do, measured. See
../../reference/shell-traps.md.
Don't post a per-issue "I triaged this" comment. Labels are the record; comments are for duplicates and for a genuine question to the DRI.
🚨 The receipt is a claim — verify the writes landed
gh api and gh mutations exit 0 on operations the server rejected, so a run that
looks clean can have written nothing. This run's whole value is that its verdicts reached
GitHub; a receipt reporting "0 unassigned" off attempted writes rather than confirmed
state is worse than no receipt, because the next run and the autopilot both trust it.
Before printing § 6, re-read the state you claim — one board pull and one issue list, after the writes, and count from those:
# Fresh shell: re-establish SCRATCH and re-source board_fetch (§ 1).
SCRATCH="${SCRATCH:-${TMPDIR:-/tmp}}"
# The one sanctioned second board fetch — board.json predates the writes:
board_fetch "<board_owner>" "<board_number>" "$SCRATCH/board-after.json"
gh issue list --repo <owner>/<repo> --state open --limit 1000 --json number,labels,assignees
🚨 Projects v2 writes are EVENTUALLY CONSISTENT — do not read back immediately.
MEASURED: six gh project item-add calls each returned a real item id and exit 0, and an
item-list run straight afterwards reported 0 cards. It reached 5 after ~20s and 6
after ~30s. Every write had succeeded. A naive read-back here reports total failure on a
run that worked, which is worse than not checking at all.
Verify the item, not the count. item-add returns the new item's id; resolving that
id proves the write landed even while the board's items connection still reports zero:
gh api graphql -f query='{node(id:"<PVTI_...>"){... on ProjectV2Item{
isArchived project{number} content{... on Issue{number}}}}}'
For counts, poll with backoff (a few tries over ~30s) and only then compare. If a
number still does not match, say which issue failed and why, loudly — that is the one
line a human needs. Other shapes of this trap:
../../reference/verification.md.
6. Receipt
Open with the integrity line — proof the guarantee held this run:
Board: N open · 0 off-project · 0 unassigned · N area-labels added · N newly assigned · N Status set
The two zeros are the point. If either is non-zero, something blocked the write — say which issue and why, loudly.
Then the out-of-sweep line, always, even when N is 0:
Out of sweep: N board cards from repos not in `repos` — <owner/repo#N, …> — left untouched; add the repo to `repos` or remove the card
The integrity guarantee is a claim about the swept repos only. Since 0.10.0 repos is the
issue-sweep set, so a board card whose repo is not in it is never swept, never labeled,
never assigned and never moved — by design, and this line is what stops a clean integrity
line from overstating. MEASURED 2026-09-08: a board carried an open, unlabeled, unassigned
card from a repo in neither sibling's repos; the run left it alone, correctly, and
mentioned it only by its own initiative — nothing in the receipt shape required the
mention, so a rewrite could drop it and the card would be invisible forever. Derive N
from the § 1 board fetch already on disk: compare each item's content.repository
(nameWithOwner) against repos, case-insensitively. An org transfer means the board's
stored owner can differ from the canonical one, so compare against what gh repo view --json nameWithOwner says for each entry in repos, or name the ambiguity in the line
rather than guessing either way. Never label, assign or move such a card.
Summary table — every issue that went through the deep pass this run:
| Issue | Repo | Title | Category | Effort | Priority | Assignee | Agent-ready | Notes |
|---|
Then detail only for P0 and P1: 2–4 lines each — what it is, why it is that priority, what it blocks, and the concrete next action with a named owner. This is the part a human reads at breakfast; make it worth the ink.
Close with a board-health line: N deep-triaged · N still untriaged (next run) · N agent-ready · N P0/P1 open · N without priority · N stale In-Progress · N possible dupes, plus any load warnings and anything you deliberately left alone.
On a quiet day — nothing off-board, nothing unassigned, no new untriaged — the receipt is the integrity line, the out-of-sweep line and one sentence, nothing else. A clean board should read clean.
⚠️ Report, don't accuse. A low or zero lane in any per-person view is usually allocation, not underperformance. Ask the lead before inferring.