From 8a78c7420e3b6fe701fbe614fb9ea9adce483bc8 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 23 Sep 2026 00:12:35 -0400 Subject: [PATCH 1/5] docs(skills): stop board lookups from trusting item-list --limit (#2451) gh project item-list truncates silently past --limit, and board #28 now holds more than the 500 the lookups asked for, so existing cards read as missing. Issue cards are now looked up from the issue's projectItems, which is independent of board size. Draft-card lookups and whole-board dumps (GHSA lookup, snapshot, recovery, triage sweep and audit) use --limit 2000 and assert .items|length == .totalCount, failing closed on a truncated listing or a failed gh call. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: cliffhall --- .claude/skills/board-ops/SKILL.md | 102 +++++++++++++++++++-------- .claude/skills/issue-triage/SKILL.md | 26 +++++-- 2 files changed, 91 insertions(+), 37 deletions(-) diff --git a/.claude/skills/board-ops/SKILL.md b/.claude/skills/board-ops/SKILL.md index 3a863334e5..ddd17ce53c 100644 --- a/.claude/skills/board-ops/SKILL.md +++ b/.claude/skills/board-ops/SKILL.md @@ -26,6 +26,28 @@ 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. +## Finding a card without trusting `--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. + +- **An issue's card is looked up from the issue**, which is independent of board + size — see [Move an existing card](#move-an-existing-card). +- **A draft card or a whole-board dump** (the GHSA lookup, the snapshot, the + recovery dump, `/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. @@ -33,18 +55,21 @@ through the card of the issue it closes. 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 lookups below -cannot find one.** Every `select(.content.repository==… and .content.number==…)` -matches nothing against a draft, and `item-add --url` has no URL to be given. -Look it up by **title** instead, then feed that item id to `item-edit` or -`item-delete` exactly as usual: +⚠️ **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: ```sh GHSA=GHSA-xxxx-yyyy-zzzz # the advisory's real id -ITEM_ID=$(gh project item-list 28 --owner modelcontextprotocol --format json --limit 500 \ - --jq '.items[] | select(.content.type=="DraftIssue") - | select(.content.title | startswith("['"$GHSA"']")) | .id') -[ -n "$ITEM_ID" ] || echo "no draft card titled [$GHSA] on #28" >&2 +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 @@ -145,21 +170,30 @@ gh project item-edit --project-id PVT_kwDOCt2Azc4BA5sz --id "$ITEM_ID" \ ### Move an existing card -Look the item id up by issue number rather than re-adding it. Keep `--limit` -above the board's item count (~265 as of 2026-08-01) — past it `item-list` -truncates **silently**, `select` matches nothing, and `item-edit --id ""` fails -with an opaque node-resolution error rather than saying the limit was too low. - -⚠️ **Filter by repository, not by number alone.** These are **org** projects and -issue numbers are **repo-local**, so an unfiltered `select` can match another -repo's issue that happens to share the number — board #11 really does carry a -`modelcontextprotocol/servers` card — and then moves or deletes the wrong card, -or passes two ids at once (Copilot). +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`](#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 +#11, swap in its node id `PVT_kwDOCt2Azc4BA5sz`. + +Check the id is non-empty before using it: `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". ```sh -ITEM_ID=$(gh project item-list 28 --owner modelcontextprotocol --format json --limit 500 \ - --jq '.items[] | select(.content.repository=="modelcontextprotocol/inspector" - and .content.number==) | .id') +N= +ITEM_ID=$(gh api graphql -F n="$N" -f query='query($n:Int!){ + repository(owner:"modelcontextprotocol",name:"inspector"){issue(number:$n){ + projectItems(first:20){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 # e.g. Status → In Review, when its PR opens gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" \ --field-id PVTSSF_lADOCt2Azc4BJVxtzg5iI8c --single-select-option-id 159c8a02 @@ -172,10 +206,8 @@ not planned / obsolete / superseded shipped nothing, so its card is **deleted**, not parked in Done: ```sh -ITEM_ID=$(gh project item-list 28 --owner modelcontextprotocol --format json --limit 500 \ - --jq '.items[] | select(.content.repository=="modelcontextprotocol/inspector" - and .content.number==) | .id') -gh project item-delete 28 --owner modelcontextprotocol --id "$ITEM_ID" +# ITEM_ID from the issue-side lookup in "Move an existing card" above. +[ -n "$ITEM_ID" ] && gh project item-delete 28 --owner modelcontextprotocol --id "$ITEM_ID" ``` Deleting the card removes it from the board only — **the issue itself is @@ -220,7 +252,8 @@ Safe alternatives, in order of preference: **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` before and after and diff; don't just + `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 @@ -242,11 +275,17 @@ PR (Copilot). ```sh BOARD_TMP=$(mktemp -d) -gh project item-list 28 --owner modelcontextprotocol --format json --limit 600 \ +gh project item-list 28 --owner modelcontextprotocol --format json --limit 2000 \ > "$BOARD_TMP/board-snapshot.json" -echo "snapshot: $BOARD_TMP/board-snapshot.json" # note the path; you need it to recover +# 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"; } ``` +Note the printed path; you need it to recover. + ### Recovering from a deleted option This has happened twice — once via the API (~197 items, reconstructed by @@ -264,8 +303,11 @@ and pass the Priority field id `PVTSSF_lADOCt2Azc4BJVxtzg5iJE4`. BOARD_TMP=${BOARD_TMP:-$(mktemp -d)} # 1. Which cards lost their value, and what did they hold? -gh project item-list 28 --owner modelcontextprotocol --format json --limit 600 \ +gh project item-list 28 --owner modelcontextprotocol --format json --limit 2000 \ > "$BOARD_TMP/board-broken.json" +jq -e '(.items | length) == .totalCount' "$BOARD_TMP/board-broken.json" >/dev/null \ + || { echo "board-broken.json INCOMPLETE — raise --limit and re-run" >&2 + rm -f "$BOARD_TMP/board-broken.json"; } # so the steps below fail, not undercount jq -r '[.items[]|select(.status==null)|.id]' "$BOARD_TMP/board-broken.json" \ > "$BOARD_TMP/lost-ids.json" jq -r --slurpfile L "$BOARD_TMP/lost-ids.json" '($L[0]) as $lost diff --git a/.claude/skills/issue-triage/SKILL.md b/.claude/skills/issue-triage/SKILL.md index 0fa0f112fd..5904e080b2 100644 --- a/.claude/skills/issue-triage/SKILL.md +++ b/.claude/skills/issue-triage/SKILL.md @@ -51,13 +51,19 @@ double-boarded (a real defect a past sweep introduced — #1929 reproduced it). D=$(mktemp -d) gh issue list --repo modelcontextprotocol/inspector --state open --limit 1000 \ --json number,milestone > "$D/open.json" -# Union of BOTH boards, filtered to this repo — org boards can hold other repos' issues. +# item-list truncates SILENTLY past --limit (and a failed call writes nothing), and a +# missing card reads as an "unboarded" issue that then gets double-carded — so an +# incomplete dump is deleted, and the steps below fail on the missing file. for P in 28 11; do - gh project item-list $P --owner modelcontextprotocol --format json --limit 700 \ - | jq '[.items[] | select(.content.type=="Issue" - and .content.repository=="modelcontextprotocol/inspector") - | .content.number]' -done | jq -s 'add' > "$D/boarded.json" + gh project item-list $P --owner modelcontextprotocol --format json --limit 2000 > "$D/b$P.json" + jq -e '(.items | length) == .totalCount' "$D/b$P.json" >/dev/null \ + || { echo "board #$P listing INCOMPLETE — raise --limit and re-run" >&2; rm -f "$D/b$P.json"; } +done +# Union of BOTH boards, filtered to this repo — org boards can hold other repos' issues. +jq -s '[.[].items[] | select(.content.type=="Issue" + and .content.repository=="modelcontextprotocol/inspector") + | .content.number]' "$D/b28.json" "$D/b11.json" > "$D/boarded.json" \ + || rm -f "$D/boarded.json" # Prints the destination too: milestoned already → Todo, otherwise → Incoming. jq -r --slurpfile b "$D/boarded.json" \ '.[] | select(.number as $n | ($b[0]|index($n))|not) @@ -218,8 +224,14 @@ D=$(mktemp -d); R=modelcontextprotocol/inspector # the last check below reads closed issues' state reasons. gh issue list --repo $R --state all --limit 2000 \ --json number,state,stateReason,labels,milestone > "$D/i.json" +# item-list truncates SILENTLY past --limit (and a failed call writes nothing); an +# incomplete dump would make every check below lie, so it is deleted and the audit +# fails on the missing file instead. for P in 28 11; do gh project item-list $P --owner modelcontextprotocol \ - --format json --limit 700 > "$D/b$P.json"; done + --format json --limit 2000 > "$D/b$P.json" + jq -e '(.items | length) == .totalCount' "$D/b$P.json" >/dev/null \ + || { echo "board #$P listing INCOMPLETE — raise --limit and re-run" >&2; rm -f "$D/b$P.json"; } +done jq -nr --slurpfile o "$D/i.json" --slurpfile a "$D/b28.json" --slurpfile b "$D/b11.json" --arg R "$R" ' ($o[0] | map({key:(.number|tostring), value:{st:.state, sr:(.stateReason // ""), lab:[.labels[].name], ms:(.milestone.title // null)}}) | from_entries) as $M From eabbfc4d2d5b60db444a546ceefa00fe7de12d22 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 23 Sep 2026 00:33:11 -0400 Subject: [PATCH 2/5] docs(skills): fail closed on a missing ITEM_ID and an incomplete dump (#2452 review) Clear ITEM_ID before the GHSA lookup so a failed listing cannot leave an earlier id in place; guard item-edit and item-delete on a non-empty id, and split the issue-side lookup from the Status edit so the delete recipe does not inherit it; end every dump guard with false so an incomplete dump returns non-zero; widen projectItems to first:100. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: cliffhall --- .claude/skills/board-ops/SKILL.md | 36 ++++++++++++++++++++-------- .claude/skills/issue-triage/SKILL.md | 4 ++-- 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/.claude/skills/board-ops/SKILL.md b/.claude/skills/board-ops/SKILL.md index ddd17ce53c..35b57011ec 100644 --- a/.claude/skills/board-ops/SKILL.md +++ b/.claude/skills/board-ops/SKILL.md @@ -62,6 +62,7 @@ Look it up by **title** in the full listing instead, then feed that item id to ```sh 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") @@ -180,23 +181,33 @@ through the repository also means the issue number cannot match another repo's issue — board #11 really does carry a `modelcontextprotocol/servers` card. For #11, swap in its node id `PVT_kwDOCt2Azc4BA5sz`. -Check the id is non-empty before using it: `item-edit --id ""` fails with an +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". +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. ```sh N= ITEM_ID=$(gh api graphql -F n="$N" -f query='query($n:Int!){ repository(owner:"modelcontextprotocol",name:"inspector"){issue(number:$n){ - projectItems(first:20){nodes{id project{id}}}}}}' \ + 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 -# e.g. Status → In Review, when its PR opens -gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" \ - --field-id PVTSSF_lADOCt2Azc4BJVxtzg5iI8c --single-select-option-id 159c8a02 +``` + +Then edit it — e.g. Status → In Review, when its PR opens: + +```sh +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 ``` ### Delete a card @@ -206,8 +217,13 @@ not planned / obsolete / superseded shipped nothing, so its card is **deleted**, not parked in Done: ```sh -# ITEM_ID from the issue-side lookup in "Move an existing card" above. -[ -n "$ITEM_ID" ] && gh project item-delete 28 --owner modelcontextprotocol --id "$ITEM_ID" +# 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 @@ -281,7 +297,7 @@ gh project item-list 28 --owner modelcontextprotocol --format json --limit 2000 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"; } + rm -f "$BOARD_TMP/board-snapshot.json"; false; } ``` Note the printed path; you need it to recover. @@ -307,7 +323,7 @@ gh project item-list 28 --owner modelcontextprotocol --format json --limit 2000 > "$BOARD_TMP/board-broken.json" jq -e '(.items | length) == .totalCount' "$BOARD_TMP/board-broken.json" >/dev/null \ || { echo "board-broken.json INCOMPLETE — raise --limit and re-run" >&2 - rm -f "$BOARD_TMP/board-broken.json"; } # so the steps below fail, not undercount + rm -f "$BOARD_TMP/board-broken.json"; false; } # so the steps below fail, not undercount jq -r '[.items[]|select(.status==null)|.id]' "$BOARD_TMP/board-broken.json" \ > "$BOARD_TMP/lost-ids.json" jq -r --slurpfile L "$BOARD_TMP/lost-ids.json" '($L[0]) as $lost diff --git a/.claude/skills/issue-triage/SKILL.md b/.claude/skills/issue-triage/SKILL.md index 5904e080b2..ba34eca105 100644 --- a/.claude/skills/issue-triage/SKILL.md +++ b/.claude/skills/issue-triage/SKILL.md @@ -57,7 +57,7 @@ gh issue list --repo modelcontextprotocol/inspector --state open --limit 1000 \ for P in 28 11; do gh project item-list $P --owner modelcontextprotocol --format json --limit 2000 > "$D/b$P.json" jq -e '(.items | length) == .totalCount' "$D/b$P.json" >/dev/null \ - || { echo "board #$P listing INCOMPLETE — raise --limit and re-run" >&2; rm -f "$D/b$P.json"; } + || { echo "board #$P listing INCOMPLETE — raise --limit and re-run" >&2; rm -f "$D/b$P.json"; false; } done # Union of BOTH boards, filtered to this repo — org boards can hold other repos' issues. jq -s '[.[].items[] | select(.content.type=="Issue" @@ -230,7 +230,7 @@ gh issue list --repo $R --state all --limit 2000 \ for P in 28 11; do gh project item-list $P --owner modelcontextprotocol \ --format json --limit 2000 > "$D/b$P.json" jq -e '(.items | length) == .totalCount' "$D/b$P.json" >/dev/null \ - || { echo "board #$P listing INCOMPLETE — raise --limit and re-run" >&2; rm -f "$D/b$P.json"; } + || { echo "board #$P listing INCOMPLETE — raise --limit and re-run" >&2; rm -f "$D/b$P.json"; false; } done jq -nr --slurpfile o "$D/i.json" --slurpfile a "$D/b28.json" --slurpfile b "$D/b11.json" --arg R "$R" ' ($o[0] | map({key:(.number|tostring), value:{st:.state, sr:(.stateReason // ""), From 4964bb6231de4491fd00aa0d8d87ec480e68054f Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 23 Sep 2026 00:41:33 -0400 Subject: [PATCH 3/5] docs(skills): name every id a #11 card needs, not just the lookup's (#2452 review) Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: cliffhall --- .claude/skills/board-ops/SKILL.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.claude/skills/board-ops/SKILL.md b/.claude/skills/board-ops/SKILL.md index 35b57011ec..ce9b4f58e6 100644 --- a/.claude/skills/board-ops/SKILL.md +++ b/.claude/skills/board-ops/SKILL.md @@ -178,8 +178,13 @@ depend on how many items the board holds (see [Finding a card without trusting 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 -#11, swap in its node id `PVT_kwDOCt2Azc4BA5sz`. +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](#v1-board-11-ids); 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 From 68dc5f53510296c07681daec3fd684d6d952028f Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 23 Sep 2026 00:49:48 -0400 Subject: [PATCH 4/5] docs(skills): gate the recovery steps on a complete dump (#2452 review) A deleted dump still let the redirect create an empty lost-ids.json, so the re-apply loop ran zero times and exited 0. lost-ids.json is now written only inside the completeness check, and step 3 refuses to run without it. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: cliffhall --- .claude/skills/board-ops/SKILL.md | 39 +++++++++++++++++++------------ 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/.claude/skills/board-ops/SKILL.md b/.claude/skills/board-ops/SKILL.md index ce9b4f58e6..5acbcf98ab 100644 --- a/.claude/skills/board-ops/SKILL.md +++ b/.claude/skills/board-ops/SKILL.md @@ -323,28 +323,37 @@ 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? +# 1. Which cards lost their value, and what did they hold? lost-ids.json is +# written ONLY from a complete dump — step 3 refuses to run without it, so an +# incomplete dump cannot become a re-apply loop that silently does nothing. +rm -f "$BOARD_TMP/lost-ids.json" gh project item-list 28 --owner modelcontextprotocol --format json --limit 2000 \ > "$BOARD_TMP/board-broken.json" -jq -e '(.items | length) == .totalCount' "$BOARD_TMP/board-broken.json" >/dev/null \ - || { echo "board-broken.json INCOMPLETE — raise --limit and re-run" >&2 - rm -f "$BOARD_TMP/board-broken.json"; false; } # so the steps below fail, not undercount -jq -r '[.items[]|select(.status==null)|.id]' "$BOARD_TMP/board-broken.json" \ - > "$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" +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" +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. -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 - sleep 0.4 -done +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 + 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 From 24b1a12f0db0b8cc41e421761eedec30b3159b3f Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 23 Sep 2026 00:54:46 -0400 Subject: [PATCH 5/5] docs(skills): refuse to re-apply without a usable snapshot (#2452 review) The snapshot is the only record of what the orphaned cards held, and an incomplete one is now deleted, so step 1 drops lost-ids.json when the snapshot report cannot run. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: cliffhall --- .claude/skills/board-ops/SKILL.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.claude/skills/board-ops/SKILL.md b/.claude/skills/board-ops/SKILL.md index 5acbcf98ab..c34f1c1652 100644 --- a/.claude/skills/board-ops/SKILL.md +++ b/.claude/skills/board-ops/SKILL.md @@ -324,8 +324,9 @@ and pass the Priority field id `PVTSSF_lADOCt2Azc4BJVxtzg5iJE4`. BOARD_TMP=${BOARD_TMP:-$(mktemp -d)} # 1. Which cards lost their value, and what did they hold? lost-ids.json is -# written ONLY from a complete dump — step 3 refuses to run without it, so an -# incomplete dump cannot become a re-apply loop that silently does nothing. +# 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" @@ -335,7 +336,9 @@ if jq -e '(.items | length) == .totalCount' "$BOARD_TMP/board-broken.json" >/dev 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" + "$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"