Issue tracker: GitHub
Issues and specs for this repo live as GitHub issues.
Work state lives on the
CLHbid Delivery org project, which spans every
clhbid repo. Use the
CLI for all operations.
Language
The words the skills plan and report in. Every rule below is written in these terms.
Cycle: a fortnight-ish planning period that work is committed to. Committing an issue to a
cycle is a promise to finish it in that cycle. Avoid: sprint, milestone.
Epic: a top-level issue carrying the
issue type, too large for one cycle and delivered
through its children. An epic is never committed to a cycle; each child fits in one cycle and is
committed on its own.
Avoid: initiative, program, tracker issue, parent (every epic is a parent;
few parents are epics).
Committed unit: the issue whose
is set — a top-level issue, or a child of an epic. Its
descendants inherit the cycle and are never set directly.
Avoid: planned issue, scheduled issue.
Slipped: committed to a cycle and still open when it closed. Always a missed commitment, never
"progressing as expected". Avoid: in flight, carried over.
Progress: an epic's completed children over its total children. Avoid: percent complete,
velocity, burndown.
Business work: what the business plans and reads about — a top-level issue, or a child of an
epic. Avoid: ticket, story.
Conventions
-
Create an issue:
gh issue create --title "..." --body "..."
. Use a heredoc for multi-line
bodies. This does not apply an issue form — follow
Creating an issue from the org form.
-
Read, list, comment:
gh issue view <number> --comments
,
,
gh issue comment <number> --body "..."
.
requires
, so filtering a read means
dropping
and naming the fields:
gh issue view <number> --json number,title,labels --jq ...
-
Refer to an issue as , in prose and in tables alike. GitHub renders
that form as a link with a hovercard and shortens it to
when it is same-repo, so the
qualified form costs nothing to read. A
bare resolves against whichever repo hosts
the text it sits in — in an org discussion that is
, not the repo you meant —
and links silently to the wrong issue. GitHub also shares one number space across issues and pull
requests, so resolve an unqualified reference with
, falling back to
.
-
Set state: a project field, not a label — see
Status.
-
Close: an issue closes as
,
or
. The reason is the
record, so pick the one that matches and say why in a closing comment.
bash
# Completed — the work is done
gh issue close <number> --reason completed --comment "Shipped in #<pr>."
# Not planned — we are not delivering it, abandoned and deferred alike
gh issue close <number> --reason "not planned" --comment "Deferred to <date>; <owner> holds it."
# Duplicate — another issue carries the work
gh issue close <number> --duplicate-of <other-number> --comment "Tracked under #<other-number>."
- is the default when is omitted.
- is two words — is rejected. It is the whole record for a deferral
or an abandonment; nothing else is needed.
- sets the reason to and links the two issues, so the survivor's
thread becomes the history. Take the number of the issue that stays open.
-
Fix a close reason:
cannot set one, and
no-ops on an
already-closed issue. For
and
, PATCH it:
gh api --method PATCH repos/{owner}/{repo}/issues/{n} -f state=closed -f state_reason=not_planned
To record a duplicate after the fact, reopen and re-close:
gh issue reopen <number> && gh issue close <number> --duplicate-of <other-number>
.
Status
on the CLHbid Delivery project is the
single source of truth for what state an issue
is in.
| Status | Meaning |
|---|
| Backlog | The inbox — everything not yet routed. New issues land here |
| Waiting on input | We asked the reporter or the business something and cannot proceed until they answer |
| Ready for Agent | Fully specified, sliced to one pull request, and carrying an agent brief |
| Ready for Human | Needs a person — judgement, external systems, manual verification, or a pull request to review |
| In progress | Claimed and being worked |
| Done | Closed, for any reason (, and ) |
Everything else stays native and is never duplicated onto the board: assignee (who holds it),
linked pull requests (what is in review), sub-issues (decomposition), issue
dependencies (blocking), closed (finished).
Triage empties . Every issue arrives there and leaves by being routed to
,
or
, or by being closed. Work considered and
deliberately deferred goes back to
and is reconsidered next time — so
never
means "already dealt with", and a full inbox is the normal state rather than a backlog of triage
debt.
An empty is a healthy state, not a query to debug: nothing is blocked on the
business.
Prefer , which needs both halves of its row — fully specified
and already
sliced. Work that cannot be specified until someone decides something is
: a
question to answer, not work to schedule.
is for what no agent can finish. For
what counts as an agent brief, see
§ The agent brief.
Claiming is an assignee write. gh issue edit <n> --add-assignee @me
is the atomic first
write that stops two agents taking the same issue; setting
to
follows it.
An issue that is not on the board has no state and is invisible to every query below. Auto-add
workflows put newly-opened issues on the board; after creating one, give the workflow a moment and
then confirm it landed, adding anything that was missed.
Adding one issue adds its whole tree. Adding a parent pulls in every descendant, across
repository boundaries and including repos nobody had in scope. So planning being set on
committed units does not make the board that shallow — most of it is
descendants, and they arrive with
unset. Any group-by-
view therefore carries a
large
No Cycle bucket: descendants whose committed ancestor is planned, and epics, which never
carry one.
Setting Status
needs three IDs. Look them up once per session:
bash
# project id, Status field id, and the option id for each status
gh api graphql -f query='
query { organization(login: "clhbid") { projectV2(number: 4) {
id field(name: "Status") { ... on ProjectV2SingleSelectField { id options { id name } } } } } }'
# the item id for an issue (an issue may sit on several projects — take project 4)
gh api graphql -f query='
query($owner: String!, $repo: String!, $num: Int!) {
repository(owner: $owner, name: $repo) { issue(number: $num) {
projectItems(first: 10) { nodes { id project { number } } } } } }' \
-f owner="$(gh repo view --json owner --jq .owner.login)" \
-f repo="$(gh repo view --json name --jq .name)" \
-F num=<number> \
--jq '.data.repository.issue.projectItems.nodes[] | select(.project.number == 4) | .id'
Take the repo from the checkout, never a literal. The board spans every clhbid repo and issue
numbers are per-repo, so a pinned
resolves the wrong issue somewhere else — usually a
, but a number that exists in both repos returns a real item id for the wrong issue, and
accepts it.
Then:
bash
gh project item-edit --project-id <project-id> --id <item-id> \
--field-id <status-field-id> --single-select-option-id <option-id>
Read the value back afterwards — a wrong option id is accepted silently.
Triage roles
The skills speak in terms of five canonical triage roles. Here a role is a
value, not
a label —
Status has what each one means.
| Role in mattpocock/skills | Status here |
|---|
| Backlog |
| Waiting on input |
| Ready for Agent |
| Ready for Human |
| close with |
When a skill says "apply the AFK-ready triage label", set
to the value in this table.
and
have no counterpart in that vocabulary.
Business and delivery
Decomposition is a delivery concern. Work is broken into sub-issues so it can be tracked,
sliced and reviewed one pull request at a time — see
Decomposing work before Ready for Agent. The business
does not track work at that grain: a
top-level issue is the unit of business work, and its
children are how that work gets done. The one exception is an
epic, whose children are
each business work in their own right because each is committed to a cycle on its own.
The board carries both audiences as views —
📋 Delivery board is everything but epics,
💼 Business is
no:parent-issue -type:Epic
, and
🗺️ Epics is the epics with their progress.
The same split governs anything read outside the board:
- Reporting to the business — cycle notes, and anything else the business reads — is business
work only, counts included: top-level issues and the children of epics, with epics themselves
reported in their own section rather than in any cycle table. Add the filter below to
any recipe to get its business view, as the Planning view recipe does. A board view cannot
express it — project filters have no OR across qualifiers — which is why 💼 Business shows
epic children only through the cycle views.
- Dispatching and doing the work reads the leaves, because that is where a branch and a pull
request attach. The Agent frontier recipe is the example: it excludes anything with children.
Querying the board
Issue search cannot read project fields:
is not a qualifier and matches
nothing. Every state query therefore runs against the board with
— one query, filtered per use with
.
Find first. It sits beside this file, but where that is depends on how the
skill was installed. Resolve it once per session and reuse it:
bash
for p in ~/.claude/skills/issue-tracker/board.graphql \
~/.agents/skills/issue-tracker/board.graphql \
.claude/skills/issue-tracker/board.graphql; do
[ -f "$p" ] && BOARD_QUERY="$p" && break
done
If none of them exists, say so rather than guessing — every recipe below needs it.
bash
# Agent frontier — ready, leaf, unblocked, unclaimed
gh api graphql --paginate -F query=@"$BOARD_QUERY" --jq '
.data.organization.projectV2.items.nodes[]
| select(.content.state == "OPEN" and .status.name == "Ready for Agent"
and .content.assignees.totalCount == 0
and .content.subIssues.totalCount == 0
and .content.issueDependenciesSummary.blockedBy == 0)
| "\(.content.repository.name)#\(.content.number) \(.content.title)"'
# Needs triage — the inbox
... | select(.content.state == "OPEN" and .status.name == "Backlog")
# Needs business input
... | select(.content.state == "OPEN" and .status.name == "Waiting on input")
# In flight
... | select(.content.state == "OPEN" and .content.assignees.totalCount > 0)
# Business filter — top-level issues and epic children, never an epic itself. Goes at the head of
# the jq program, before `.data`, in any recipe that wants its business view
def business: .content.issueType.name != "Epic"
and (.content.parent == null or .content.parent.issueType.name == "Epic");
# Planning view — open business work
... | select(.content.state == "OPEN" and business)
# Epics — every open epic with its progress
... | select(.content.state == "OPEN" and .content.issueType.name == "Epic")
| "\(.content.repository.name)#\(.content.number) \(.content.subIssuesSummary.completed) of \(.content.subIssuesSummary.total) \(.content.title)"
# Epics ready to close — open, with every child closed. A person closes them
... | select(.content.state == "OPEN" and .content.issueType.name == "Epic"
and .content.subIssuesSummary.total > 0
and .content.subIssuesSummary.completed == .content.subIssuesSummary.total)
# Epic candidates — committed parents that are not epics. Read each one; the recipe only narrows
... | select(.content.state == "OPEN" and .content.issueType.name != "Epic"
and .content.parent == null and .content.subIssuesSummary.total > 0
and .cycle != null)
| "\(.content.repository.name)#\(.content.number) \(.content.subIssuesSummary.completed) of \(.content.subIssuesSummary.total) \(.content.title)"
# Status/state mismatch — the Item closed workflow missing one. Healthy result is empty
... | select((.content.state == "CLOSED" and .status.name != "Done")
or (.content.state == "OPEN" and .status.name == "Done"))
# Worked off-cycle — work that never got credited to the cycle it happened in
... | select((.content.state == "CLOSED"
and .content.closedAt >= env.CYCLE_START and .content.closedAt < env.CYCLE_END
and (.cycle.title // "") != env.CYCLE_TITLE)
or (.content.state == "OPEN" and .status.name == "In progress" and .cycle == null
and .content.updatedAt >= env.CYCLE_START))
# ...of which dormant — swap that last clause for uncycled In progress work nobody touched
and .content.updatedAt < env.CYCLE_START))
# Stale closed items — closed but never archived. Healthy result is empty
... | select(.content.state == "CLOSED"
and (.content.closedAt | fromdateiso8601) < (now - 4838400))
# Stale issues — business work untouched for eight weeks
... | select(.content.state == "OPEN" and business
and (.status.name | IN("Backlog", "Ready for Agent", "Ready for Human"))
and (.content.updatedAt | fromdateiso8601) < (now - 4838400))
# ...of which newly stale — add this clause to keep the ones not yet stale when the cycle began
and (.content.updatedAt | fromdateiso8601)
>= ((env.CYCLE_START | strptime("%Y-%m-%d") | mktime) - 4838400)
stands in for the full command above.
applies
per page, so filtering and
listing work as written; counting needs a pipe (
).
Archived items are invisible here. defaults to
archivedStates: [NOT_ARCHIVED]
, so every recipe reads the live board only. That is what makes the
stale-closed recipe re-runnable — it cannot see what it just archived — and it is why the
snapshot in
Cycles passes
archivedStates: [ARCHIVED, NOT_ARCHIVED]
explicitly.
Eight weeks is 4838400 seconds, and it is the definition of
stale — an item is stale on the
board, not in someone's judgement, and staleness is measured on business work. The cycle recipes
read
,
and
from the environment; set them from the iteration's
,
and
.
is
exclusive — an iteration ends the day before the meeting
that closes it, so a cycle titled
has
and
.
Newly stale is derived, never stored. It compares one
against two thresholds: stale
against today, and not-yet-stale against
. There is no baseline to snapshot at the end
of a cycle. Anchoring it to
instead would ask which items went stale between today and
today, and return nothing.
Pull requests are not board items, so the recipe below runs per repository against
instead, taking the issue from the
title prefix or the
branch prefix
(see
Commit convention):
bash
# Merged pull request, open issue — a closing reference that never fired, or none. Healthy result is empty
for repo in $(gh repo list clhbid --no-archived --json name,hasIssuesEnabled --jq '.[] | select(.hasIssuesEnabled) | .name'); do
open=$(gh issue list --repo "clhbid/$repo" --state open --limit 500 --json number --jq '.[].number')
gh pr list --repo "clhbid/$repo" --state merged --limit 200 --json number,title,headRefName,closingIssuesReferences \
--jq '.[] | ((.title | capture("^#?(?<n>[0-9]+)[: ]")?) // (.headRefName | capture("(^|/)(?<n>[0-9]+)-")?) // empty) as $m
| "\($m.n)\t\(.number)\t\(.closingIssuesReferences | length)\t\(.title)"' \
| while IFS=$'\t' read -r n pr refs title; do
grep -qx "$n" <<<"$open" && echo "$repo#$n open — merged in $repo#$pr, $refs closing reference(s) $title"
done
done
A closing reference fires only on a merge into the default branch. A row with a closing
reference and an open issue is a pull request that merged somewhere else — a stacked slice merged
into its parent before GitHub retargeted it, or anything merged into
or
.
A row with none had no
line, or a deliberate
/
. Either way the issue closes
by hand — as
, naming the pull request, per
Conventions — unless the pull
request was a partial and the issue is still live.
These recipes are canonical, and no API returns a view's contents — so a view can never be
queried directly, only mirrored:
| # | View | Filter | Purpose |
|---|
| 1 | 📋 Delivery board | | everything but epics, grouped by |
| 2 | 🎯 This cycle | | the running cycle |
| 3 | ⏭️ Next cycle | | what is planned next |
| 4 | 💼 Business | no:parent-issue -type:Epic
| top-level work, delivered and outstanding |
| 5 | 🤖 Frontier | status:"Ready for Agent" no:assignee -is:blocked
| mirrors the frontier recipe |
| 6 | ⏳ Waiting | status:"Waiting on input"
| mirrors the needs-business-input recipe |
| 8 | 📥 Triage | status:Backlog no:parent-issue
| the inbox, top-level only |
| 9 | 🗺️ Epics | | mirrors the epics recipe |
View numbers are stable and never reused after a delete, which is why 7 is missing. Only the
filter is worth asserting against this table: a view's name, columns and tab position belong to
whoever uses it, so a change there is intent rather than drift. The one column worth naming is
Sub-issues progress on 🗺️ Epics — a built-in field, and the progress bar the view exists for.
A filter is writable. takes
,
,
and
, so a drifted filter can be
repaired, not only reported. Grouping and sorting
are
readable —
exposes
,
and
— but not writable:
ProjectV2ViewConfigurationInput
takes only
.
Both board views group by
as swimlanes with
as the columns, and
an iteration with
no items renders no swimlane — so a completed cycle leaves the board once its items are archived,
and no filter is needed to hide one.
Cycles
A cycle is an iteration of the
field. An iteration
ends the day before the meeting that
closes it.
Read them from the field's
.
holds the closed ones; the
running cycle is the entry in
whose
is on or before today and whose
is after it, and the future cycles are the ones starting after it. Select by
date rather than by index — nothing guarantees the array's order.
is set on the
committed unit — a top-level issue, or a child of an
epic —
and its descendants inherit it, so a sub-issue with no
is planned, not missed. An epic's
own
is never set.
Three iterations are live at all times — the running cycle and two future ones — so planning
always has somewhere to put work deferred two meetings out.
The title carries the theme, and until one is agreed it is the date range as a placeholder
(
). Dates live in the iteration's own
and
.
Titles must be
unique: recovering from a configuration write resolves iterations by title, and two cycles sharing
one makes that ambiguous.
Writing the configuration clears the whole board
is the only mutation that touches
, and
every write
is a full replacement that regenerates every iteration id and clears every item's . Even an
identical configuration written back does it, so appending an iteration costs exactly what renaming
one does. The input also has
no , so any completed cycle not passed back
inside
is deleted outright.
Make one configuration write per cycle, folding every pending change into it — the new
iteration, the agreed end date, any theme titles — and wrap it:
- Snapshot every item's , keyed by . Archived items carry values
too, so this is its own query passing
archivedStates: [ARCHIVED, NOT_ARCHIVED]
—
is live-only and would silently skip them.
- Write once, passing all iterations, completed ones past-dated so GitHub re-sorts them
back into . The configuration's own cannot be read back — only
is exposed — so pass the earliest iteration's .
- Restore every snapshot value, resolving iterations by title, because every id changed.
- Read back and the restored count. An empty is the
failure signature; a count short of the snapshot means items were missed.
Epics
An epic is work too large for one cycle — see
Language. The hierarchy is
epic → issue → sub-issues, the first and last optional, and the epic layer is one deep: an
epic sits under nothing, and its children are the committed units. Work that turns out to span
cycles becomes an epic by being split into children that each fit one.
- The issue type is the marker, and the only issue type the skills key off. Set it with
gh api --method PATCH repos/{owner}/{repo}/issues/{n} -f type=Epic
; cannot,
so create the issue and PATCH it. Find them with gh issue list --search type:Epic
, or the
Epics recipe. If the org has no type, creating one is on the org
node, and it needs the scope that the default login lacks.
- Status walks the ordinary ladder, and so does each child, triaged like anything else.
while the epic still needs decomposing, once its children exist,
from the first child committed to a cycle, when a person closes it.
- is never set on the epic. Each child is a committed unit and carries its own — so a
child left open at cycle close slipped, and the epic is neither done nor slipped, only
progressing.
- Decompose before the first child is committed. Progress counts direct children only, so it
reads truthfully only when every child is a one-cycle piece. A child that will not fit a cycle
is split into two children of the epic, keeping the layer one deep.
- Closing is a judgement, so a person does it. The Epics ready to close recipe finds
candidates.
Deferring work
Deferring is a decision to record. Its form follows when the work comes back:
- A future cycle — set to one of the two ahead. The issue stays open and the board
carries it.
- A later date — comment with the decision, the owner, and when it returns, then close with
. The owner sets the calendar reminder; nothing in GitHub will raise it
for them.
- Unknown — leave it in for triage to reconsider.
Reopen rather than refile when it comes back, so the thread stays the history.
Labels
Labels never carry state. Two matter:
- — something is broken.
- — everything else: new features, and the tooling, config and refactor work we
used to call chores.
Issue templates apply exactly one of them, and
adds its own — see
Wayfinding operations.
Any other label is decoration — read it if you like, but nothing keys off it.
Creating an issue from the org form
Issue forms in
clhbid/.github/.github/ISSUE_TEMPLATE/
are the source of truth for what an issue
body contains.
Before
, fetch the form you need:
bash
gh api repos/clhbid/.github/contents/.github/ISSUE_TEMPLATE/<bug|enhancement>.yml \
--jq .content | base64 -d
Use each field's
as a
heading in the issue body, in the same order as the form. Fill
every required field, written with
. Omit optional fields when they do not apply.
This rule applies to any agent-authored body, including later edits.
Anything beyond the form's fields — acceptance criteria, interface notes, verification steps, or
scope boundaries outside the form itself — belongs in the issue's agent-brief comment (or a
triage-notes comment), not as extra body headings. See
§ The agent brief.
Creating this way still means one category label (
or
), no state label, and then
confirming the issue landed on
— see
Status.
Templates apply a category, never a state
Issue forms come from the org defaults in
under
.
A
person filing an issue should use one — they collect fields a triager otherwise has to ask for.
A form applies exactly one
category label (
or
) and
no state. Category
says what kind of thing an issue is;
says where it has got to — nothing about the category
implies a state.
When an agent creates an issue without the browser form, use
Creating an issue from the org form.
Commit convention
Commit subjects carry the issue number as a bare prefix —
. A trailing
is the
pull request, added by squash-merge, and is never the issue. When resolving the spec for
a diff, read the leading
and ignore the trailing
.
Decomposing work before Ready for Agent
Keep
1 issue = 1 branch = 1 PR
. If work is too large, split the
issue, not the pull request.
Size is a precondition of
. Work too large for a
cycle is an
epic,
split one level higher.
Smaller is better. ~1000 changed lines is the ceiling — excluding lockfiles, snapshots and
generated files — but it is a limit, not a target: a changeset that splits cleanly should be split
well below it, because a reviewer reads a small diff and skims a large one. If a plan crosses the
ceiling, simplify first, cutting documentation that fails the
deletion test before
anything else, then split. There is no CI gate for this.
- A valid slice is independently mergeable and green. Behaviour-neutral slices (rename,
extraction, refactor) count when they stand alone.
- Stack the pull requests when slices depend on each other. A pull request can target a branch
other than , and GitHub retargets it automatically when its base merges — so a dependent
slice can be opened and reviewed straight away instead of waiting for its parent to land.
- If an agent finds mid-flight that work is too large, it simplifies, creates sub-issues, links
them as children, sets the leaves to , opens a pull request for the work it has
finished — stacked on the previous slice where they depend on each other — and comments on the
original issue explaining the cut.
Pull requests as a triage surface
PRs as a request surface: no. (Set to if this repo treats external PRs as feature requests; reads this flag.) While it is
, external PRs are not triaged and no
state handling applies.
When a skill says…
- "publish to the issue tracker" — create a GitHub issue via
Creating an issue from the org form.
- "fetch the relevant ticket" —
gh issue view <number> --comments
.
Wayfinding operations
Used by
. The
map is a single issue with
child issues as tickets.
- Map: a single issue labelled , holding the Notes / Decisions-so-far / Fog body.
gh issue create --label wayfinder:map
.
- Child ticket: an issue linked to the map as a GitHub sub-issue ( on the sub-issues endpoint). Labels: (///). Once claimed, the ticket is assigned to the driving dev.
- Blocking: GitHub's native issue dependencies. Add an edge with
gh api --method POST repos/<owner>/<repo>/issues/<child>/dependencies/blocked_by -F issue_id=<blocker-db-id>
, where is the blocker's numeric database id (gh api repos/<owner>/<repo>/issues/<n> --jq .id
, not the or ). GitHub reports issue_dependencies_summary.blocked_by
(open blockers only — the live gate). A ticket is unblocked when every blocker is closed.
- Frontier query: list the map's open children, drop any with an open blocker or an assignee; first in map order wins.
- Claim:
gh issue edit <n> --add-assignee @me
— the session's first write.
- Resolve:
gh issue comment <n> --body "<answer>"
, then , then append a context pointer (gist + link) to the map's Decisions-so-far.
Traps
A wrong filter looks correct. GitHub ignores an unknown qualifier rather than erroring. It can
fail in either direction — a typo may return everything, while
in issue
search returns nothing — so a result count proves nothing on its own. Check a new filter against a
query whose answer you already know.
REST lies about parentage. gh issue view --json parent
errors outright, and
gh api repos/{owner}/{repo}/issues/{n} --jq .parent.number
returns
for a child exactly as
for a top-level issue. GraphQL's
answers truthfully, which is what
selects. Checking issue by issue costs a request each and gets
skipped under pressure, so
put (or ) in the query
itself.
Adding an item unarchives it. on an already-archived item silently
returns it to the live board. Archived items are invisible to a live-only read, so a
reopened
issue looks absent, gets re-added, and comes back out of the archive as a side effect.
A read-back can be stale. The project API is eventually consistent. Verify every write by
read-back, but treat a mismatch straight after a write as unconfirmed rather than failed — re-check
after a delay, and never re-issue the write on the strength of one disagreeing read.
issueDependenciesSummary.blockedBy
lags the same way — it read
immediately after a blocker was
linked and
shortly after — so the
Agent frontier recipe run straight after a write can hand
out an issue that is already blocked.