Rendered from the source repository. Headings, examples, code, tables, links, and referenced images are preserved.
Issue tracker: GitHub
Issues and specs for this repo live as GitHub issues. Work state lives on the [CLHbid Delivery](https://github.com/orgs/clhbid/projects/4) org project, which spans every clhbid repo. Use the gh 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.
Category: what kind of thing an issue is, carried as its issue type — Bug, Enhancement or Epic, exactly one per issue. Avoid: label, kind.
Epic: a top-level issue carrying the Epic 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 Cycle 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 applies neither an issue form nor a type — follow Creating an issue from the org form.
- Read, list, comment:
gh issue view <number> --comments,gh issue list,
gh issue comment <number> --body "...". --jq requires --json, so filtering a read means dropping --comments and naming the fields: gh issue view <number> --json number,title,labels --jq ...
- Make an issue a sub-issue:
gh issue edit <child> --parent <parent-url>after creating it —
the REST create in Creating an issue from the org form takes no parent. The URL form reaches a parent in another repo, as an epic's children often are.
- Refer to an issue by its full reference — see References.
- Set state: a project field, not a label — see Status.
- Close: an issue closes as
completed,not plannedorduplicate. The reason is the
record, so pick the one that matches and say why in a closing comment.
# 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>."completedis the default when--reasonis omitted.not plannedis two words —not-plannedis rejected. It is the whole record for a deferral
or an abandonment; nothing else is needed.
--duplicate-ofsets the reason toduplicateand 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:
gh issue editcannot set one, andgh issue closeno-ops on an
already-closed issue. For completed and not planned, 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>.
References
Write every reference in full, as `<org>/<repo>#<number>`, in prose and in tables alike. GitHub renders it as a link with a hovercard and shortens it to #<number> when it is same-repo, so it costs nothing to read. A bare `#<number>` resolves against whichever repo hosts the text it sits in — in an org discussion that is clhbid/clhbid.com, not the repo you meant — and links silently to the wrong issue.
Read short references; write full ones. People use a bare #<number> and the aliases below, matched regardless of case. Resolve one before acting on it, and say which issue you resolved it to. When editing a doc or notes that hold short references, propose replacing them with full ones. Ask when an alias is unknown or could mean two issues, and suggest a short alias for any repository the table lacks.
| Alias | Repository |
|---|---|
LA | clhbid/CLHbid-LiveAuction |
com | clhbid/clhbid.com |
AC | clhbid/agent-context |
infra | clhbid/infrastructure |
TZ, hotpatch | clhbid/canadian-time-zone-hotpatch |
DF | clhbid/devcontainer-features |
gh, .github | clhbid/.github |
scripts | clhbid/clhbid-scripts |
E2I | clhbid/email-to-issue |
SFET | clhbid/salesforce-email-templates |
template | clhbid/repository-template |
Issues and pull requests share one number space, so resolve a number with gh pr view <n>, falling back to gh issue view <n>.
Status
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 (completed, not planned and duplicate) |
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 `Backlog`. Every issue arrives there and leaves by being routed to Ready for Agent, Ready for Human or Waiting on input, or by being closed. Work considered and deliberately deferred goes back to Backlog and is reconsidered next time — so Backlog never means "already dealt with", and a full inbox is the normal state rather than a backlog of triage debt.
An empty `Waiting on input` is a healthy state, not a query to debug: nothing is blocked on the business.
Prefer `Ready for Agent`, which needs both halves of its row — fully specified and already sliced. Work that cannot be specified until someone decides something is Waiting on input: a question to answer, not work to schedule. Ready for Human is for what no agent can finish. For what counts as an agent brief, see `afk-loop` § The agent brief.
Ask the business by email. A business decision is emailed as it comes up rather than saved for the cycle review, which is for what needs real discussion. The email says what is happening in plain language, the decision needed, the options each with its consequence, a recommendation and why, and a link to the issue.
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 Status to In progress 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 Cycle unset. Any group-by-Cycle view therefore carries a large No Cycle bucket: descendants whose committed ancestor is planned, and epics, which never carry one.
Setting Status
gh project item-edit needs three IDs. Look them up once per session:
# 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 repo= resolves the wrong issue somewhere else — usually a NOT_FOUND, but a number that exists in both repos returns a real item id for the wrong issue, and item-edit accepts it.
Then:
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 seven canonical triage roles: two category and five state. Here none is a label — a category role is an issue type (see Epics) and a state role is a `Status` value (Status has what each one means).
| Role in mattpocock/skills | Here |
|---|---|
bug | type Bug |
enhancement | type Enhancement |
needs-triage | Backlog |
needs-info | Waiting on input |
ready-for-agent | Ready for Agent |
ready-for-human | Ready for Human |
wontfix | close with --reason "not planned" |
When a skill says "apply the category", set the type; when it says "apply the AFK-ready triage label", set Status to the value in this table. In progress and Done 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.
Querying the board
Issue search cannot read project fields: status:"In progress" is not a qualifier and matches nothing. Every state query therefore runs against the board with `board.graphql` — one query, filtered per use with --jq.
Find `board.graphql` first. It sits beside this file, with `snapshot.graphql`, but where that is depends on how the skill was installed. Resolve them once per session and reuse them:
for d in ~/.claude/skills/issue-tracker \
~/.agents/skills/issue-tracker \
.claude/skills/issue-tracker; do
[ -f "$d/board.graphql" ] && BOARD_QUERY="$d/board.graphql" \
&& SNAPSHOT_QUERY="$d/snapshot.graphql" && break
doneIf none of them exists, say so rather than guessing — every recipe below needs it.
# 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)
# Security — every open issue labelled security
... | select(.content.state == "OPEN" and any(.content.labels.nodes[].name; . == "security"))
# 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. --paginate applies --jq per page, so filtering and listing work as written; counting needs a pipe (| wc -l).
Archived items are invisible here. ProjectV2.items 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 `snapshot.graphql` 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 CYCLE_TITLE, CYCLE_START and CYCLE_END from the environment; set them from the iteration's title, startDate and startDate + duration. CYCLE_END is exclusive — an iteration ends the day before the meeting that closes it, so a cycle titled 14 Aug → 28 Aug has CYCLE_START=2026-08-14 and CYCLE_END=2026-08-28.
Newly stale is derived, never stored. It compares one updatedAt against two thresholds: stale against today, and not-yet-stale against CYCLE_START. There is no baseline to snapshot at the end of a cycle. Anchoring it to CYCLE_END 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 gh pr list instead, taking the issue from the NNNN: title prefix or the NNNN- branch prefix (see Commit convention):
# 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
doneA 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 a branch such as scale-up. A row with none had no Closes line, or a deliberate Refs/Part of. Either way the issue closes by hand — as completed, 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 | -type:Epic | everything but epics, grouped by Status |
| 2 | 🎯 This cycle | cycle:@current | the running cycle |
| 3 | ⏭️ Next cycle | cycle:@next | 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 | type:Epic | mirrors the epics recipe |
| 13 | 🔒 Security | label:security is:open | mirrors the security recipe |
View numbers are stable and never reused after a delete, so the table's numbers have gaps. 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. updateProjectV2View takes name, layout, filter and configuration, so a drifted filter can be repaired, not only reported. Grouping and sorting are readable — ProjectV2View exposes groupByFields, verticalGroupByFields and sortByFields — but not writable: ProjectV2ViewConfigurationInput takes only visibleFieldIds.
Both board views group by Cycle as swimlanes with Status 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 Cycle field. An iteration ends the day before the meeting that closes it.
Read them from the field's configuration. completedIterations holds the closed ones; the running cycle is the entry in iterations whose startDate is on or before today and whose startDate + duration 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.
Cycle 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 Cycle is planned, not missed. An epic's own Cycle 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 (Sep 29 - Oct 12). Dates live in the iteration's own startDate and duration. Titles must be unique: the cycle recipes match on CYCLE_TITLE and the swimlanes are labelled by title, so two cycles sharing one read as one.
Writing the configuration clears the whole board
updateProjectV2Field is the only mutation that touches iterationConfiguration, and every write is a full replacement that regenerates every iteration id and clears every item's `Cycle`. Even an identical configuration written back does it, so appending an iteration costs exactly what renaming one does. The input also has no `completedIterations`, so any completed cycle not passed back inside iterations 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
Cycle, archived items included, with
`snapshot.graphql` — `board.graphql` is live-only and would silently skip them:
gh api graphql --paginate -F query=@"$SNAPSHOT_QUERY" \
--jq '.data.organization.projectV2.items.nodes[] | select(.cycle.startDate)' \
> /tmp/cycle-snapshot.jsonl- Write once, passing all iterations, completed ones past-dated so GitHub re-sorts them
back into completedIterations. The configuration's own startDate cannot be read back — only startDay is exposed — so pass the earliest iteration's startDate.
- Restore every snapshot value, resolving iterations by `startDate` — every id changed, and
a title may have moved to another date. An archived item cannot be edited, so unarchive it (unarchiveProjectV2Item), set its Cycle, and archive it again (archiveProjectV2Item).
- Read back
completedIterationsand the restored count. An emptycompletedIterationsis 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 `Epic` issue type is the marker. The org has three types —
Bug(something is broken),
Enhancement (everything else: new features, and the tooling, config and refactor work we used to call chores) and Epic — and every issue carries exactly one. Set any of them with gh api --method PATCH repos/{owner}/{repo}/issues/{n} -f type=<Bug|Enhancement|Epic>, or -f type= on the REST create call; gh issue create and gh issue edit have no type flag. Read it as issueType { name } in GraphQL or .type.name in REST. Find them with gh issue list --search type:Epic, or the Epics recipe. If the org lacks a type, creating one is createIssueType on the org node, and it needs the admin:org scope that the default gh login lacks.
- Status walks the ordinary ladder, and so does each child, triaged like anything else.
Backlog while the epic still needs decomposing, Ready for Human once its children exist, In progress from the first child committed to a cycle, Done when a person closes it.
- `Cycle` 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
Cycleto 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
--reason "not planned". The owner sets the calendar reminder; nothing in GitHub will raise it for them.
- Unknown — leave it in `Backlog` for triage to reconsider.
Reopen rather than refile when it comes back, so the thread stays the history.
Labels
Labels carry neither state nor category — state is `Status`, category is the issue type. Two kinds of label have meaning:
- `wayfinder:
** — the setwayfinder` applies; see
- `security` — any issue for a security vulnerability, hardening, or a security process change.
When you create or triage one, apply it as well as setting the type: gh issue edit <n> --add-label security, or -f 'labels[]=security' on the create call in Creating an issue from the org form. It is a label, not a type, because an issue has one type and a security finding can be a Bug or an Enhancement.
Any other label, bug and enhancement included, is decoration — read it if you like, but nothing keys off it.
Nothing syncs the vocabulary to repos, so a repo may lack a label. Check before applying it (gh label list --repo <owner>/<repo> --search <name>), and if it is missing create it with the name, colour and description from `labels.yml` in clhbid/agent-context:
gh api repos/clhbid/agent-context/contents/labels.yml --jq .content | base64 -d
gh label create <name> --repo <owner>/<repo> --color <color> --description "<description>"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 creating the issue, fetch the form you need:
gh api repos/clhbid/.github/contents/.github/ISSUE_TEMPLATE/<bug|enhancement>.yml \
--jq .content | base64 -dUse each field's label as a ### heading in the issue body, in the same order as the form. Fill every required field, written with writing-lean. 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 `afk-loop` § The agent brief.
Create it through the REST endpoint, which sets the type in the same call — gh issue create has no --type flag. The type matches the form: Bug for bug.yml, Enhancement for enhancement.yml.
gh api repos/<owner>/<repo>/issues -f title="..." -f body="..." -f type=<Bug|Enhancement> \
--jq .html_urlThat is one type, no state, and then confirming the issue landed on Backlog — see Status.
Templates apply a category, never a state
Issue forms come from the org defaults in clhbid/.github under .github/ISSUE_TEMPLATE/. A person filing an issue should use one — they collect fields a triager otherwise has to ask for.
A form applies exactly one category type (Bug or Enhancement) and no state. Category says what kind of thing an issue is; Status 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 — 2217: <subject>. A trailing (#NNNN) is the pull request, added by squash-merge, and is never the issue. When resolving the spec for a diff, read the leading NNNN: and ignore the trailing (#NNNN).
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; if two issues are one change, close one as a duplicate of the other. Size is a precondition of Ready for Agent. 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 writing-lean 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. A plan whose slices can't each merge green goes to Ready for Human, with a comment on why.
- Stack the pull requests when slices depend on each other. A pull request can target a branch
other than the default branch (main or development), 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 Ready for Agent, 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 `yes` if this repo treats external PRs as feature requests; `triage` reads this flag.) While it is no, external PRs are not triaged and no gh pr 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 wayfinder. The map is a single issue with child issues as tickets.
- Map: a single issue labelled
wayfinder:map, 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; see Conventions. Labels:
wayfinder:<type>(research/prototype/grilling/task). 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<blocker-db-id>is the blocker's numeric database id (gh api repos/<owner>/<repo>/issues/<n> --jq .id, not the#numberornode_id). GitHub reportsissue_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>", thengh issue close <n>, 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 status:"In progress" 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 null for a child exactly as for a top-level issue. GraphQL's Issue.parent answers truthfully, which is what `board.graphql` selects. Checking issue by issue costs a request each and gets skipped under pressure, so put `no:parent-issue` (or `.content.parent == null`) in the query itself.
Adding an item unarchives it. addProjectV2ItemById 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 0 immediately after a blocker was linked and 1 shortly after — so the Agent frontier recipe run straight after a write can hand out an issue that is already blocked.

