Triage a GitHub repo's open-issue backlog, or just the issues filed since the last pass, into implement, feature, postpone, or clarify, and hand users' requests for new capabilities to product intake. Verify each claim against the current default branch, comment and label every verdict, group issues that share one design, and dispatch parallel implementer agents (one branch and PR per issue, each following github-issue-resolve). Then link the PRs and hand landing to github-pr-triage. Catches ...
Installs into .claude/skills of the current project.
Are you the author of Github Issue Triage?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/shipmill-github-issue-triage)
---
name: github-issue-triage
description: Triage a GitHub repo's open-issue backlog, or just the issues filed since the last pass, into implement, feature, postpone, or clarify, and hand users' requests for new capabilities to product intake. Verify each claim against the current default branch, comment and label every verdict, group issues that share one design, and dispatch parallel implementer agents (one branch and PR per issue, each following github-issue-resolve). Then link the PRs and hand landing to github-pr-triage. Catches issues fixed but left open and issues closed by a commit that only quoted "Fixes #N". Use when the user asks to "triage issues", "triage the new issues", "review and triage https://github.com/<o>/<r>/issues", "decide implement, postpone or clarify", or to work a backlog. Not for one named issue (use github-issue-resolve) or for reviewing and merging PRs (use github-pr-triage).
---
# GitHub Issue Triage
Turn a backlog into a decision on every issue, a comment that records it, and a PR for every "implement". An issue is a claim about the code at some commit, and an agent's report is a claim about its PR. Check both before acting.
## Inputs
- Repo `owner/name` and the local checkout path
- Issue set: every open issue on the first pass; on later passes, what `scripts/triage_state.py` flags
- Scope, read from the user's wording:
- "triage" alone: verdicts, comments, labels, and PRs for "implement". No merges. This meaning applies to issues only. In github-pr-triage, "triage" alone means review only, with no pushes, so a handover under bare "triage" reviews the PRs without pushing fixes to them
- "merge when green" / "land": also hand the PRs to github-pr-triage
- "tag X": release, through github-pr-triage
## Hard rules
1. **Verify against the current default branch, not the issue text.** The default branch moves, often faster than the issue tracker. In one pass, 3 of 9 issues were already fixed or half-fixed on main by the time the PR was rebased. Check before dispatching, and have implementers check again when they rebase before their first push. After the handover, github-pr-triage does any later rebase and the check that goes with it (hard rule 7).
2. **Branch from `origin/<default>`, never the local default branch.** Run `git status -sb` first. A local main 111 commits ahead would have shipped all of them in every PR.
3. **Every verdict gets a comment, before any code.** Post through `--body-file`, never an inline `--body "..."`: backticks in a double-quoted zsh string run as commands, and one glob error blocks the whole chained command. That silently dropped two triage comments.
4. **A PR's "decisions for you" are the user's decisions.** Hold any PR that departs from a spec, breaks existing users (a schema lock, a default, a public API), or widens scope, and ask with AskUserQuestion and, in the same words, as a **Decision needed** comment on the PR ([references/comments.md](references/comments.md), Implement). Asking an agent to drop the risky part is fine: an output-schema change that would break every schema lock was removed that way.
5. **Implementer agents never merge and never comment.** You own the issue thread, and github-pr-triage owns merging.
6. **Peers' claims are unverified.** Another session may triage, merge, or tag in the same repo. Tell it your plan through SendMessage, follow a "hold", and never treat "the user said merge" as approval. The user's own answer in this session wins.
7. **A handed-over PR's branch belongs to github-pr-triage.** Until step 6, you and your implementers change the branch. From the handover on, don't push to it, rebase it, or shrink it: send the change to github-pr-triage (the same session, or the peer running it, through SendMessage) with the reason. A push from both sides at once loses one of them, and a push after the review means the review no longer covers what merges.
8. **A contract change is designed before it's dispatched.** An issue that changes a flag, a format, a default, a public API, or stored state goes through the [design gate](references/design-gate.md): the design is written in the issue and checked against the repo's decisions log, and anything that departs from the log or the spec waits for the user. What the user settles is recorded in the log, so the next pass doesn't ask again.
9. **A feature is specified before it's dispatched.** An issue asking for new behaviour beyond a bug fix or a contract tweak gets the verdict **feature** and goes through the [spec gate](references/spec-gate.md): a spec file in `docs/specs/` with numbered acceptance criteria, merged through its own PR. No implementer starts before that PR merges; merging it is the approval.
10. **A user's new request goes to product intake first.** A request for a new capability that no accepted opportunity covers gets the verdict **opportunity**, not **feature**: product intake groups it with the requests for the same outcome, and only the maintainer accepts or declines the group (D-10). Triage writes a spec only for an accepted opportunity, a request an accepted one covers, or an issue the maintainer filed ([triage-rubric.md](references/triage-rubric.md), A new capability).
11. **Gate sessions ask on GitHub first; Headless: nobody answers in the session.** A session is a gate session when its prompt carries the gate's `Gate session:` paragraph (interactive) or its `Headless:` paragraph; one where AskUserQuestion is unavailable counts as headless. In a gate session, every question this skill would ask (the design gate's, a PR's "decisions for you") follows the [needs-decision protocol](references/needs-decision.md) before anything else: a marked comment mentioning the user and the `needs-decision` label. An interactive gate session then also asks with AskUserQuestion, and on an answer there posts it on the item, removes the label, and goes on; a headless one leaves the item while the rest moves on. A denied tool call is such a decision too, unless it chained commands that are each on the list: a headless session runs one allowlisted command per Bash call, so it reruns those as separate calls ([A denied tool call is a decision](references/needs-decision.md#a-denied-tool-call-is-a-decision)); tell implementer agents the same in their brief. A session the user started by hand asks with AskUserQuestion only. An answered item is work again: remove the label before acting on the reply.
12. **Write as the App when the repo has one.** When `.github/shipmill.toml` sets `[agents] app_id`, every `gh` call that writes (an issue, a comment, a label, a pull request, a review, a merge, a release) runs as `shipmill gh <the same arguments>` from the repo's checkout, in every session, gated or started by hand, so its author is the App's bot, not the maintainer (spec 012, D-14). When `shipmill` isn't on PATH, and in a headless gate session, whose allowlist has `Bash(uvx *)` and no bare `shipmill`, run `uvx --from git+https://github.com/shipmill/shipmill@v0 shipmill gh ...`. Reads may keep plain `gh`. A `shipmill gh` that exits 2 is a stop: report its message and never retry the write with plain `gh`. Tell implementer agents the same in their brief.
## Judgment versus scripts
Where each issue stands is a deterministic question, so a script answers it. What to do about it is judgment.
| Script | Answers | Run it |
|---|---|---|
| `scripts/triage_state.py <owner/repo> [--closed N]` | Which issues are NEW, NEEDS_PR, IN_PROGRESS, DONE_NOT_CLOSED, BLOCKED or UNBLOCKED (waiting on an upstream issue, a spec PR, or a build issue's dependency), UNFILLED (a `Depends on #{Bk}` placeholder never filled in), SPEC_REFUSED (the spec PR closed unmerged), POSTPONED or REVISIT (postponed before the newest stable tag), and which recent closes are SUSPECT_CLOSE. NEEDS_DECISION: labelled `needs-decision` and its question has no reply ([needs-decision.md](references/needs-decision.md); `--bot-login` names the App the question comes from); DECIDED once a trusted person replied, read before any other state and an action. With `--trusted-only`, an issue whose author is no OWNER, MEMBER, or COLLABORATOR (nor the `--bot-login`) reads UNTRUSTED (D-16). Neither is an action. With `--wip N`, the room left under a WIP limit and the issues ready to start. A person's item (labelled `human`) reads NO_ASSIGNEE, HANDOFF_DUE, WITH_PERSON (not an action), or VERIFY_DUE, and one closed by hand VERIFY_CLOSED ([Chains](#chains)). Exit 1 when any need action | At the start of every pass and before the report |
| `scripts/chains.py link\|show` | Writes a link in both forms (`link A --blocked-by B`, `link P --child C`); prints the chain an issue belongs to, each item's executor and state, and NO_GATE where nothing will take a ready item up (`show`) ([Chains](#chains)) | `link` whenever an issue must wait on another, `show` when a chain stalls |
| `scripts/specs.py new\|check\|find\|criteria\|coverage\|split` | Which specs (`S-NNN`) cover a path or area (`find`); writes the next spec from the template (`new`); validates every spec's sections, status, criterion ids, the decisions it names, and that each criterion of an approved or built spec is in exactly one build issue (`check`); prints a spec's acceptance criteria (`criteria NNN`); lists the tests that prove each criterion of the built specs, exit 1 when one has none (`coverage`); proposes an approved spec's build issues, with their criteria and `Depends on` lines (`split NNN`) | `find` and `new` in the spec gate, `split` when a spec merges, `criteria` in every implementer brief for a spec's build issue, `coverage` in CI and when a spec reaches `built` |
| `scripts/decisions.py check\|find\|add` | Which settled rules (`D-n`) a change touches (`find` with its paths or areas); records a new one with the next id and links what it supersedes; `check` validates the log | `find` in the design gate and in every implementer brief; `add` when the user settles something |
Run it with `uv run --no-project python <skill>/scripts/triage_state.py`. It recognises a triage comment by its `Triage:` prefix (`--marker`) and a deferral by the `postponed` label (`--postponed-label`). A merged PR saying only "Part of #N" links the issue without making it DONE_NOT_CLOSED: a split issue whose rest is postponed reads POSTPONED. A hand-closed issue with the `release-blocker` label (`--hold-label`) is a release hold, not a SUSPECT_CLOSE. Nor is an incident closed as completed by hand (the `incident` label, `--incident-label`): it closes once the environment is healthy, and github-ship-watch's POSTMORTEM_DUE follows it up. The script can't read the shipmill config, so pass `--incident-label` when `[operate] incident_label` isn't `incident`. It links a PR to an issue by a closing keyword in the PR's title or body, because GitHub's own `willCloseTarget` misses some. It treats an issue as blocked when it has a `blocked` label, or when a sentence of a comment line saying "on hold", "blocked", or "waits on" links an issue or pull request (`owner/repo#N` or its URL in that sentence, or a plain `#N` of the same repo right after "waits on", "depends on", "blocked by", "decided in", or the like; a plain `#N` elsewhere on the line is context, not a hold), or, once the issue is triaged, when a line of its body starts with "Depends on" and names an issue (`owner/repo#N`, or `#N` in the same repo: a build issue split from a spec), or GitHub records an issue it is blocked by (`blockedBy`) or a sub-issue, in any repo ([Chains](#chains)); it then checks whether that issue has closed or that pull request merged. A pull request closed without merging (a refused spec) reads SPEC_REFUSED until a newer triage comment decides again. Everything else is judgment: the verdict, the grouping, the brief, and whether a report's decision needs the user.
When `.github/shipmill.toml` sets `[agents] app_id`, the App's needs-decision questions count only with `--bot-login '<slug>[bot]'`; without it, an App question the maintainer answered still reads NEEDS_DECISION, and with `--trusted-only` an issue the App opened reads UNTRUSTED. Pass it to every `triage_state.py` and `watch_state.py` run, quoted, since zsh globs the brackets otherwise. A gate session's prompt names the login; run by hand, `shipmill gate --dry-run` prints it (`would launch as <slug>[bot]`).
## Workflow
### 1. Take stock
```bash
uv run --no-project python <skill>/scripts/triage_state.py <owner/repo> --closed 40
git fetch origin
git status -sb
git worktree list
gh label list -R <owner/repo>
```
With `[agents] app_id` set, the first line is `triage_state.py <owner/repo> --closed 40 --bot-login '<slug>[bot]'` ([Judgment versus scripts](#judgment-versus-scripts)).
Also check:
- The release phase: `git for-each-ref --sort=-creatordate --count=3 refs/tags` and the version in the manifest. It sets the bar in the rubric
- The check commands, from AGENTS.md, CLAUDE.md, or the CI workflow
- Other sessions on this repo, with ListAgents
Read every flagged issue in full, comments included: `gh issue view <n> --json title,body,comments,labels,state`.
An issue labelled `shipmill-upgrade` whose first line is a `<!-- shipmill-upgrade: <id> <version> -->` marker is a config upgrade shipmill proposed (spec 014): github-ship-watch asks the maintainer about it and opens its pull request, so triage posts no verdict on it, and `watch_state.py` keeps it out of ISSUES. That holds when `triage_state.py` reads it DECIDED too: its answer is github-ship-watch's to take up (its UPGRADE_PENDING row), so leave the reply and the `needs-decision` label alone.
When the repo runs product intake (it has an `opportunity` label), also run `uv run --no-project python <product-intake>/scripts/intake_state.py <owner/repo>` from the checkout. It lists the opportunities a new request may fall under, and its ACCEPTED rows are features waiting for the spec gate: under "intake and triage" or when the user asks, take them into this pass.
### 2. Decide
Apply [references/triage-rubric.md](references/triage-rubric.md) to each issue. For each one:
- Reproduce the claim on the current default branch, and grep for work already there
- Pick a verdict: **implement**, **feature**, **opportunity**, **postpone**, **clarify**, **duplicate**, or **won't fix**. A **feature** is new behaviour beyond a bug fix or a contract tweak that the maintainer already wants; it waits for a merged spec (step 2b). A user's request for a new capability that no accepted opportunity covers is an **opportunity**: comment the hand-off and leave the grouping to product intake, which never builds anything itself
- Group issues that are one mechanism, such as two env-alias issues, or a producer, a transport, and a consumer of one pipeline. Comment the shared plan on each
- Split an issue when one part is a small fix and the rest is a feature. Ship the part and postpone the rest, saying so on the issue
### 2b. Design gate and spec gate
For each **feature**, run the [spec gate](references/spec-gate.md) (an **opportunity** skips it: no spec until the maintainer accepts): `specs.py find` for a spec that already covers it, then `specs.py new`, the criteria from the issue and the code, `specs.py check`, and a spec PR whose body says "Spec for #N". The verdict comment links the spec PR on a hold line, so `triage_state.py` reads the issue BLOCKED until the spec merges and UNBLOCKED after. No implementer starts on it in this pass.
For each **implement** that changes a contract (see [references/design-gate.md](references/design-gate.md) for the list), run `decisions.py find` on the areas it touches, write the design into the triage comment, and ask the user before dispatch when it departs from a `D-n` entry or the spec, or when two designs would look different to users. Batch these questions into one AskUserQuestion call per pass. Record every answer of the pass with `decisions.py add` in one docs PR, opened before dispatch, so parallel PRs don't each claim the next `D-n` (design-gate.md, Recording a decision).
### 3. Comment and label
Use the templates in [references/comments.md](references/comments.md). Create the `postponed` label once if it's missing. Mark a held issue in a way the script can read: the hold comment names the upstream issue as `owner/repo#N` on its "On hold" line (see comments.md), or add the `blocked` label. Answer an issue's open questions in its comment: the reporter is waiting on them. A question you can settle from the code is part of your job. For a question only a spec or upstream maintainer can settle, file an issue there and link it.
### 4. Dispatch implementers
Fill in [references/implementer-brief.md](references/implementer-brief.md): one agent per group of issues that touch the same files, two issues per agent at most, each issue on its own branch and PR. Launch them all in one message (`isolation: "worktree"`, `subagent_type: general-purpose`). Name each issue's specific risks in the brief. A generic "fix it" gets a generic fix.
Headless (the prompt carries the `Headless:` paragraph, or AskUserQuestion is unavailable), launch the implementers with `run_in_background: false`, still all in one message so they run in parallel, or wait for every notification before ending the turn. Claude Code can stop a `claude -p` session's background agents once its turn ends: 1 of 11 PRs landed that way (#246).
A spec's build issues go in dependency order (spec-gate.md, Dispatch the build): dispatch the ones whose dependencies are closed, and stack a dependent on its dependency's open PR when that saves a wait. When the config has `[roadmap] wip = N` (#70 adds it to the config schema; until then the config refuses a `[roadmap]` table, so don't add one), run `triage_state.py --wip N` and start no more than the room it reports; without that setting there is no limit.
### 5. Vet each report
For each PR, check:
- The diff matches the claim, and each behaviour change is named
- Everything under "decisions for you". Apply hard rule 4, and send a revision through SendMessage to the same agent, since it keeps its context. When the user's answer sets a rule for later work, record it in the pass's decisions PR (design-gate.md, Recording a decision), not in the agent's PR
- The diff against the design in the triage comment: a departure the report doesn't name is a finding
- Whether the default branch already covers part of the issue. If so, shrink the PR and retitle it. Do this before step 6; once the PR is handed over, ask github-pr-triage to do it (hard rule 7)
Then comment the PR link on the issue, with what it does and any trade-off. When a PR is replaced (a peer's rebase with fixes), point the issue at the new one. A PR covering part of an issue says "Part of #N" in its body; the script counts that as a link.
- **An agent finds an unrelated bug** (a stress test exposing a secret leak in a CI PR): file it as its own issue, and have the agent split the fix into its own PR. Stack the original PR on it if it depends on the fix. A fix hidden in an unrelated PR gets no review of its own
- **An agent can't be resumed** (SendMessage reports no transcript, or its worktree was removed): brief a fresh agent with the PR number, its branch, and the change. Don't redo the work in your own context
- **A spec limit an agent reports** (a schema that rejects a key): keep the PR within the schema, and file the gap on the spec repo with the requirement IDs and a proposed shape. When that spec issue closes, the issue shows UNBLOCKED; implement the rest then
### 6. Land (only if asked)
Hand the PRs to github-pr-triage with the user's scope. It owns the CI gate, rebases, CHANGELOG conflicts, flaky-CI diagnosis, merge order, and releases. Don't merge from this skill. Its report ends with a handback list of refused and held PRs, each with its issue; step 7 acts on it.
### 7. Close the loop
Re-run `triage_state.py`, with step 1's `--bot-login '<slug>[bot]'` when `[agents] app_id` is set. New issues arrive during a pass: 16 did in one session. For each flag:
- **DONE_NOT_CLOSED:** close it, citing the PR. A rebase-merge doesn't always fire "Fixes #N"
- **SUSPECT_CLOSE:** reopen it if the fix hasn't landed, and explain why. For example, a commit message quoting `--body="Fixes #12"` closed #12 before its rule existed
- **UNBLOCKED:** the upstream decision landed. Read it, update the plan on the issue, and resume or re-dispatch the held PR. When it waited on a spec PR, follow spec-gate.md, When the spec merges: link the spec in the issue body, split the spec into build issues with `specs.py split`, file them in order, link them with `chains.py link` (each dependency `--blocked-by`, each build issue a `--child` of the feature issue), and record them in the spec's Issues section. A build issue reads UNBLOCKED when its last dependency closes: comment **implement** and dispatch it. A feature issue reads UNBLOCKED when its last build issue closes: confirm the spec says `status: built` with every criterion verified (spec-gate.md, Verify the whole spec), doing that in a docs PR if the last build PR didn't, then close the feature issue. Any other parent held only by its children: close it with a comment listing each child and how it closed (comments.md, A parent's close)
- **NO_ASSIGNEE, HANDOFF_DUE, VERIFY_DUE, VERIFY_CLOSED:** a person's item; act as [Chains](#chains) says. WITH_PERSON needs nothing: never mention the person again while it waits
- **UNFILLED:** a build issue's body still says `Depends on #{Bk}`, a `specs.py split` key never replaced. Edit the body to name the number of the issue filed for that key
- **UNBLOCKED, on an accepted opportunity:** a request triage held on the opportunity it falls under (comments.md, Feature) unblocks when that opportunity closes. Close the request, citing the opportunity and the PR that built it
- **SPEC_REFUSED:** the spec PR closed without merging. Decide again in a new triage comment: revise the spec in a new PR with a new hold line, postpone, or won't fix (spec-gate.md, step 6)
- **REVISIT:** a stable release shipped after the issue was postponed. Decide again under the new release phase, and drop the `postponed` label if it's now **implement**
- **DECIDED:** a trusted person answered the item's needs-decision question. Read the reply, remove the `needs-decision` label, and act on it ([needs-decision.md](references/needs-decision.md), Taking up an answered item): a link to an open issue in the reply is context, not a hold, unless the reply says the item waits on it
- **NEW:** start another pass if the user asked for continuous triage; otherwise list them in the report
Then work through github-pr-triage's handback list. `triage_state.py` can't flag these: a refused PR left open still reads IN_PROGRESS. For each refused PR, comment the refusal on its issue (the reason, and what would change it) and decide again:
- Re-dispatch with a new brief that addresses the reason
- Postpone, or ask the reporter to clarify
- Won't fix
When the new plan drops the PR, close it with `gh pr close <n> --delete-branch` and a comment linking the issue, so the issue reads NEEDS_PR or POSTPONED on the next pass and the dead branch doesn't stay on GitHub. A PR closed for any other reason keeps its branch, since it may be reopened. For a held PR, the comment names the decision the user owes; leave the verdict as it is.
**Continuous intake:** issues keep arriving while you work. When the user wants the backlog kept current, offer `/loop 30m` (or `/schedule` for daily) running `triage_state.py` and triaging only what it flags. Don't start a loop unasked.
Clean up only the worktrees your agents created, and only once their PRs have merged. A pushed branch isn't enough: an implementer revises its PR through SendMessage, and an agent whose worktree is gone can't be resumed, so a review finding or a rebase after removal needs a fresh agent briefed from scratch. After a merge, confirm the commits landed (`landed.py`), then remove the worktree and its local branch. The worktrees a session leaves behind once it exits, its agents' included, are the gate's prune's to remove (D-12): `shipmill gate` runs `shipmill worktrees --prune` on each tick and removes only what landed, so a later pass leaves them alone.
## Chains
Work that spans issues, repos, and people is a chain: each item waits on the ones before it, and each repo's triage advances its own part (spec 017).
- **Two link forms, read as one set.** A `Depends on owner/repo#N` line of the body, and GitHub's own relations: an issue it is blocked by (`blockedBy`) and its sub-issues. Write a link with `chains.py link <owner/repo#N> --blocked-by <owner/repo#M>`, or `chains.py link <owner/repo#P> --child <owner/repo#C>` for a sub-issue: it adds the relation and the `Depends on` line, says `already linked` when both are there, and `text only: <error>` when the forge refuses the relation, the line holding the issue by itself. A relation set in the UI holds too; never copy it into text
- **A person's item.** An issue labelled `human` is done by its assignees, never by an implementer; it counts as triaged from the start, and its `## Check` section (a command or a URL, and what it must return) is how its result is checked. Its states and what a session does for each are in [triage-rubric.md](references/triage-rubric.md), A person's item, and the comments in [comments.md](references/comments.md), A person's item: NO_ASSIGNEE asks the maintainer who does it; HANDOFF_DUE posts the hand-off; WITH_PERSON waits; VERIFY_DUE and VERIFY_CLOSED run the `## Check`, and a check the session can't run goes to the maintainer through the [needs-decision protocol](references/needs-decision.md)
- **A parent** (an issue with sub-issues, in any repo) reads BLOCKED while a child is open, with `children K/N closed` first in its note, and UNBLOCKED once the last closes: close it, listing each child
- **Where a chain stalls.** `chains.py show <owner/repo#N>` prints every item the issue waits on and every item that waits on it, each with its executor (`agent` or `person: @login`) and state (CLOSED, BLOCKED, READY); a READY item whose repo has no `[agents]` table reads NO_GATE, since no gate will take it up. github-ship-watch reports those as CHAIN_NO_GATE. Triage reads an upstream item's state, never its text, and each repo's gate checks D-16 on its own items
## Report
Lead with a table: issue, verdict, and PR or plan. List the requests handed to product intake together, so the next intake pass can be started from the report. Then list the decisions the user must make, each with a recommendation. Give each bug as its concrete failing input and what happened. Say what's unverified, for example a Windows-only fix tested only in CI. Mention newly filed issues you haven't triaged yet.
## Improve the skill
When a pass goes wrong, add the lesson to the matching reference, or to Hard rules if it must never recur. Examples: a verdict the code contradicted, a dropped comment, an agent that merged, a wrongly closed issue.