gh recipes for the Inspector project boards — add a card, move its Status, set or re-score its Priority, delete a card, and recover from a deleted single-select
复制下面这句话,粘贴给 Claude Code、Codex、Cursor 等 AI 编程工具,它会读取安装说明并在你确认后完成安装。
请阅读 https://ai.atlankj.com/install/asset/gh-board-ops-fe3020d04188 ,按照其中的说明把「board-ops」安装到你(当前 AI 工具)中。执行前先告诉我将运行的命令和写入的位置,等我确认。
查看 AI 将读取的安装说明正在读取 GitHub 原文…
内容来自 GitHub 原始文件,由原作者维护。在 GitHub 查看
The rules about what a card's Status and Priority should be live in
AGENTS.md under Issue-driven Work Style. This skill is
the mechanics: the exact gh calls, the IDs, and the ways the board can be
damaged.
Related: /issue-create (the five-step create flow), /issue-triage (sweeping
unboarded issues in, the priority rubric, the board audit).
| Version label | Board | Owner | Has a Priority field? |
|---|---|---|---|
v2 | #28 | modelcontextprotocol | Yes |
v1 | #11 | modelcontextprotocol | No |
Both are org projects, so every command takes --owner modelcontextprotocol.
The two projects have their own field and option IDs and none of them are
interchangeable — a #28 id passed to #11 is rejected with "option Id does not
belong to the field", so the mistake is at least loud.
--limit⚠️ gh project item-list --limit N truncates silently. Past N it returns
the first N items with no error and no warning, so a select over the result
matches nothing and a card that exists reads as missing. Board #28 passed 500
items in September 2026 — double the figure quoted here two months earlier — and
the old --limit 500 lookups reported a carded issue as unboarded and 16 GHSA
drafts as absent in one session (#2451). A limit is a guess about the board's
size; don't make the recipes depend on it being right.
/issue-triage's sweep and audit) genuinely needs the full
listing. Those recipes use a limit with headroom and compare the result's
.items | length against the .totalCount that item-list --format json
also returns, so a truncated listing fails loudly instead of passing as
complete. The check also catches a failed gh call, whose empty output has
neither key. Where a later step reads the dump from a file, an incomplete dump
is deleted, so that step fails on the missing file rather than running on
partial data.Only issues go on a board — never PRs, never draft cards. A PR is tracked through the card of the issue it closes.
The one exception is a GitHub security advisory, tracked by a draft card
titled [GHSA-xxxx-yyyy-zzzz] - … because a real issue would disclose it before
a fix exists. The flow is /security-advisory.
⚠️ A draft card has no repository and no issue number, so the issue-side
lookup below cannot find one, and item-add --url has no URL to be given.
Look it up by title in the full listing instead, then feed that item id to
item-edit or item-delete exactly as usual:
GHSA=GHSA-xxxx-yyyy-zzzz # the advisory's real id
ITEM_ID= # never let an earlier lookup's id survive a failed one
BOARD=$(gh project item-list 28 --owner modelcontextprotocol --format json --limit 2000)
if jq -e '(.items | length) == .totalCount' <<<"$BOARD" >/dev/null; then
ITEM_ID=$(jq -r '.items[] | select(.content.type=="DraftIssue")
| select(.content.title | startswith("['"$GHSA"']")) | .id' <<<"$BOARD")
[ -n "$ITEM_ID" ] || echo "no draft card titled [$GHSA] on #28" >&2
else
echo "item-list incomplete or failed — raise --limit; not concluding anything" >&2
fi
Match on the bracketed GHSA id, not on words from the summary — a summary is
free text and two advisories can share one. Advisory drafts live on #28 only;
/issue-triage's audit reports one found anywhere else.
The project node id and the field ids are stable. The option ids are not — they are regenerated whenever a single-select field's option list is edited (see the hazard below). If any option id here is rejected, re-fetch:
# Swap "Status" for "Priority" to fetch the other field's options.
gh project field-list 28 --owner modelcontextprotocol --format json \
| jq '.fields[] | select(.name=="Status") | .options'
| Thing | ID |
|---|---|
| Project node ID | PVT_kwDOCt2Azc4BJVxt |
| Status field ID | PVTSSF_lADOCt2Azc4BJVxtzg5iI8c |
| Priority field ID | PVTSSF_lADOCt2Azc4BJVxtzg5iJE4 |
Status option IDs (--single-select-option-id) — last verified 2026-08-01.
| Status | Option ID | Means |
|---|---|---|
| Incoming | 721a3d4c | Arrived unboarded, awaiting review — no milestone |
| Todo | fbdaf21e | Approved (a milestone was assigned) |
| In Progress | 195df262 | Active work, whatever the surface |
| In Review | 159c8a02 | A PR is open |
| Done | 259d6aab | Shipped — a merged PR, or a parent whose last sub-issue closed |
Priority option IDs — last verified 2026-08-01. Derive the level with the
rubric in /issue-triage; don't eyeball it.
| Priority | Option ID | Rubric total |
|---|---|---|
| Urgent | 79628723 | 12+ |
| High | 0a877460 | 9–11 |
| Medium | da944a9c | 6–8 |
| Low | d67ac7ce | ≤5 |
The v1 line takes security fixes only, so this board sees little traffic — but a v1 issue still gets a card, and the same Incoming/Todo split applies.
| Thing | ID |
|---|---|
| Project node ID | PVT_kwDOCt2Azc4BA5sz |
| Status field ID | PVTSSF_lADOCt2Azc4BA5szzgzkS-g |
Status option IDs — last verified 2026-08-01.
| Status | Option ID |
|---|---|
| Incoming | 831820cf |
| Todo | f75ad846 |
| In Progress | 47fc9ee4 |
| In Review | 0439b2bf |
| Done | 98236657 |
There is no Priority field on this board — the priority rubric is v2-only. Don't try to set one here; the field id doesn't exist.
# Prints the item id (PVTI_…); capture it.
ITEM_ID=$(gh project item-add 28 --owner modelcontextprotocol --url <issue-url> --format json --jq '.id')
# Status → Todo (an issue you filed through the create flow is approved by definition)
gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" \
--field-id PVTSSF_lADOCt2Azc4BJVxtzg5iI8c --single-select-option-id fbdaf21e
# Priority → Medium
gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" \
--field-id PVTSSF_lADOCt2Azc4BJVxtzg5iJE4 --single-select-option-id da944a9c
Each item-edit sets one field, so setting both takes two calls — there is
no combined form.
For an issue swept in at triage, the only difference is Status → Incoming
(721a3d4c) and that you do not set a milestone.
For v1, the same shape against board #11:
ITEM_ID=$(gh project item-add 11 --owner modelcontextprotocol --url <issue-url> --format json --jq '.id')
gh project item-edit --project-id PVT_kwDOCt2Azc4BA5sz --id "$ITEM_ID" \
--field-id PVTSSF_lADOCt2Azc4BA5szzgzkS-g --single-select-option-id f75ad846
Look the item id up from the issue rather than re-adding it. An issue's
projectItems lists the cards it has on every board, so the lookup does not
depend on how many items the board holds (see Finding a card without trusting
--limit). Select the card by the
board's node id, not its number: project numbers are per-owner, and an issue
can also sit on a user-owned project that happens to be numbered 28. Querying
through the repository also means the issue number cannot match another repo's
issue — board #11 really does carry a modelcontextprotocol/servers card.
For a v1 card on #11, swap every #28 id, not just the lookup's. #11's node
id PVT_kwDOCt2Azc4BA5sz goes in both the lookup's select and the edit's
--project-id; the edit also takes #11's own Status field
PVTSSF_lADOCt2Azc4BA5szzgzkS-g and an option id from its
table; and a delete is item-delete 11.
The mutation runs only on a non-empty id: item-edit --id "" fails with an
opaque node-resolution error rather than saying the card was not found. The
|| ITEM_ID= matters too — on a GraphQL error (a number that is a PR, not an
issue; a rate limit) gh api still prints the raw error JSON to stdout, which
would otherwise land in ITEM_ID as a non-empty "id". first:100 is the
connection's maximum page; it counts the boards one issue is on, not the cards
on a board, so it has no board-size exposure.
N=<ISSUE_NUMBER>
ITEM_ID=$(gh api graphql -F n="$N" -f query='query($n:Int!){
repository(owner:"modelcontextprotocol",name:"inspector"){issue(number:$n){
projectItems(first:100){nodes{id project{id}}}}}}' \
--jq '.data.repository.issue.projectItems.nodes[]
| select(.project.id=="PVT_kwDOCt2Azc4BJVxt") | .id') || ITEM_ID=
[ -n "$ITEM_ID" ] || echo "#$N has no card on #28 (or the lookup failed)" >&2
Then edit it — e.g. Status → In Review, when its PR opens:
if [ -n "$ITEM_ID" ]; then
gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" \
--field-id PVTSSF_lADOCt2Azc4BJVxtzg5iI8c --single-select-option-id 159c8a02
else
echo "no ITEM_ID — nothing edited" >&2
fi
Done means the work shipped. An issue closed as duplicate / won't fix /
not planned / obsolete / superseded shipped nothing, so its card is deleted,
not parked in Done:
# ITEM_ID from the issue-side LOOKUP block in "Move an existing card" above —
# the lookup only, not the item-edit that follows it.
if [ -n "$ITEM_ID" ]; then
gh project item-delete 28 --owner modelcontextprotocol --id "$ITEM_ID"
else
echo "no ITEM_ID — nothing deleted" >&2
fi
Deleting the card removes it from the board only — the issue itself is untouched, keeps its labels and comments, and stays searchable and linkable forever. Nothing is lost; the board simply stops claiming the work was delivered. Done is read as the record of what a milestone actually delivered, so a duplicate sitting there makes that record wrong in a way nobody can detect later.
The close reason is the machine-readable form of the same distinction.
gh issue close --reason accepts only completed and not planned, so
duplicate must be set through the API:
gh api repos/modelcontextprotocol/inspector/issues/<N> -X PATCH \
-f state=closed -f state_reason=duplicate
(or "Mark as duplicate" in the web UI, which additionally records a duplicate-of link).
Never add, rename, or remove an option on a single-select board field (Status
or Priority) with the updateProjectV2Field GraphQL mutation unless you pass
every existing option's id. That mutation does a full replace of the
option list: resending options by name/color/description without their ids
makes GitHub delete all existing options and mint new ones, which orphans
that field's value on every card on the board and invalidates every option
id in the tables above. This has happened once, on Status (~197 items
reconstructed by inference).
Safe alternatives, in order of preference:
gh api graphql the current options with their
ids, then call updateProjectV2Field echoing back every existing option
including its id, appending only the new one.
ProjectV2SingleSelectFieldOptionInput.id is an optional String, so a mixed
list works. Verify afterward that no card lost its value — snapshot
gh project item-list … --format json --limit 2000 before and after, check
each is complete the way the snapshot below does, and diff; don't just
spot-check. Send those dumps to $BOARD_TMP too, for the reason above.Both the Incoming Status option and the Urgent/High/Medium/Low Priority
options were added this way (#1891), with the before/after diff confirming all
264 cards kept their Status.
gh project item-add and gh project item-edit are always safe — they set a
card's value and never touch the field schema.
One command, and it is the difference between a five-minute restore and reconstructing statuses by inference:
⚠️ Write it outside the repo. The boards are private,
so a snapshot is a full dump of item IDs and every card's Status and Priority.
Left in the working tree it is one git add -A away from being published in a
PR (Copilot).
BOARD_TMP=$(mktemp -d)
gh project item-list 28 --owner modelcontextprotocol --format json --limit 2000 \
> "$BOARD_TMP/board-snapshot.json"
# A truncated snapshot cannot restore the cards it dropped — refuse to proceed on one.
jq -e '(.items | length) == .totalCount' "$BOARD_TMP/board-snapshot.json" >/dev/null \
&& echo "snapshot: $BOARD_TMP/board-snapshot.json" \
|| { echo "SNAPSHOT INCOMPLETE — raise --limit and retake it before editing options" >&2
rm -f "$BOARD_TMP/board-snapshot.json"; false; }
Note the printed path; you need it to recover.
This has happened twice — once via the API (~197 items, reconstructed by
inference) and once via the UI (the Done column, 247 items, restored from a
snapshot in minutes). With a snapshot the recovery is mechanical.
The recipe below is written for a deleted Status option. For a deleted
Priority option it is the same three steps with two substitutions: read
.priority instead of .status (gh project item-list --format json exposes
each single-select field under its lowercased name, so both keys are present),
and pass the Priority field id PVTSSF_lADOCt2Azc4BJVxtzg5iJE4.
# 0. Same temp dir the snapshot went to — keep every dump out of the worktree.
BOARD_TMP=${BOARD_TMP:-$(mktemp -d)}
# 1. Which cards lost their value, and what did they hold? lost-ids.json is
# kept ONLY when the dump is complete AND the snapshot reports what those cards
# held — step 3 refuses to run without it, so neither a truncated dump nor a
# missing snapshot can turn into a silent no-op or an unconfirmed re-apply.
rm -f "$BOARD_TMP/lost-ids.json"
gh project item-list 28 --owner modelcontextprotocol --format json --limit 2000 \
> "$BOARD_TMP/board-broken.json"
if jq -e '(.items | length) == .totalCount' "$BOARD_TMP/board-broken.json" >/dev/null; then
jq -r '[.items[]|select(.status==null)|.id]' "$BOARD_TMP/board-broken.json" \
> "$BOARD_TMP/lost-ids.json" || rm -f "$BOARD_TMP/lost-ids.json"
jq -r --slurpfile L "$BOARD_TMP/lost-ids.json" '($L[0]) as $lost
| [.items[] | select(.id as $i | $lost|index($i)) | .status // "(none)"]
| group_by(.) | map({s:.[0],c:length}) | .[] | "was \(.s): \(.c)"' \
"$BOARD_TMP/board-snapshot.json" \
|| { echo "no usable snapshot — cannot confirm what these cards held; not re-applying" >&2
rm -f "$BOARD_TMP/lost-ids.json"; }
else
echo "board-broken.json INCOMPLETE — raise --limit and re-run step 1" >&2
rm -f "$BOARD_TMP/board-broken.json"
fi
# 2. Recreate the option, echoing every surviving option's id (see above).
# NOTE: the recreated option gets a NEW id — the deleted one never comes back.
# 3. Re-apply it to the orphaned cards.
if [ -s "$BOARD_TMP/lost-ids.json" ]; then
for id in $(jq -r '.[]' "$BOARD_TMP/lost-ids.json"); do
gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$id" \
--field-id PVTSSF_lADOCt2Azc4BJVxtzg5iI8c --single-select-option-id <NEW_OPTION_ID>
sleep 0.4
done
else
echo "no lost-ids.json — step 1 did not complete; nothing re-applied" >&2
fi
Step 1's grouping is the safety check: confirm the orphaned set is exactly the cards that held the deleted option, so you don't overwrite a card someone legitimately moved in the meantime.
Because the recreated option carries a new id, the tables above and every
reference to it must be updated in the same change — grep the old id across
the repo. The Done id has been 248a3910 and is now 259d6aab for exactly
this reason.
An issue page shows two fields named Priority, and they are unrelated. Ours is the one under Projects → Inspector V2.
| Where it appears | What it is | Ours? |
|---|---|---|
| Projects → Inspector V2 → Priority | The project board field on #28 (PVTSSF_lADOCt2Azc4BJVxtzg5iJE4) | ✅ Yes |
| Fields → Priority (above Projects) | A GitHub issue field, IFSS_kgDOAdAWeg, defined at the org level and shared by every repo in it | ❌ No |
Nothing syncs them, in either direction, and they will happily disagree.
Never delete the org-level field — it belongs to the whole org. Don't set it
either; a value there is a reporter's opinion and feeds the rubric as a capped
+1 signal bonus and nothing more (see /issue-triage).