diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json
index 6d91ddd..ed8a2cf 100644
--- a/.claude-plugin/plugin.json
+++ b/.claude-plugin/plugin.json
@@ -1,6 +1,6 @@
{
"name": "super-board",
- "version": "3.0.4",
+ "version": "3.0.5",
"description": "Autonomous GitHub Project board runner. Primary skills: super-board, super-build, super-qa, super-review, super-collect. Secondary skills: visual, git-sync, ui-refine-loop. Installed as a plugin it ships the skills only; run /super-board onboard to add the guard hooks, .claude/bin scripts and workflows (get.sh / install.sh install all of it in one go).",
"author": {
"name": "Eric Tech"
diff --git a/README.md b/README.md
index 320f4d9..ccd95a2 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@
8 skills (5 you type, 3 the board runs) ยท 9 commands ยท 6 guard hooks
-
+
diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md
index 035fa49..52fdf39 100644
--- a/RELEASE-NOTES.md
+++ b/RELEASE-NOTES.md
@@ -1,5 +1,22 @@
# Release notes
+## v3.0.5 โ 2026-10-03
+
+Migrating a board no longer leaves its cards with no status.
+
+- ๐ **Every option rewrite is undone.** Rewriting the Status options gives every option a
+ new id and clears the Status of every card. `board-migrate --prune-empty` rewrote them
+ without restoring, so a live board's cards all fell to "No status". The restore now runs
+ after any rewrite, whatever triggered it.
+- ๐พ **Snapshot on disk first.** Before the first rewrite, every card's status is saved to
+ `.claude/super-board/backup/board--.json` (path in `status_backup`), so a run
+ that dies mid-way can still be put back.
+- ๐ **Snapshot that cannot be empty by accident.** When `gh project item-list` surfaces no
+ `status` key on any card, statuses are read over GraphQL instead; item reads now go past
+ 500 cards.
+- ๐งช **Regressions.** Offline scenarios cover prune-empty on a board in use, the GraphQL
+ fallback, and a dry run that writes nothing.
+
## v3.0.4 โ 2026-10-03
The skill map reads from left to right, with nested skill families and clearer drill-down
diff --git a/VERSION b/VERSION
index b0f2dcb..eca690e 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-3.0.4
+3.0.5
diff --git a/scripts/super-board-setup.py b/scripts/super-board-setup.py
index 880d70b..a4afc9b 100755
--- a/scripts/super-board-setup.py
+++ b/scripts/super-board-setup.py
@@ -33,6 +33,9 @@
creates the three labels, maps old type labels, labels every card `qa`
on a board that was "qa-only" (--qa-all), moves Skipped cards to Done, removes the Skipped
option, and puts back any card status the option rewrite cleared. Cards are never lost.
+Before the first option rewrite it saves every card's status to
+.claude/super-board/backup/board--.json, so a run that dies mid-way can still
+be put back by hand.
Exit: 0 ok ยท 1 check found red items ยท 2 a gh call failed ยท 64 usage ยท 66 pack not found.
Stdlib only.
@@ -537,10 +540,44 @@ def item_labels(it):
return {(x.get("name") if isinstance(x, dict) else str(x)).lower() for x in raw}
-def board_migrate(owner, number, repo, qa_all=False, dry=False, prune_empty=False):
- """Bring one GitHub Project to the v3 shape. Never removes a card."""
+ITEM_STATUS_Q = ("query($id:ID!,$after:String){node(id:$id){... on ProjectV2{items(first:100,after:$after){"
+ "pageInfo{hasNextPage endCursor} nodes{id fieldValueByName(name:\"Status\"){"
+ "... on ProjectV2ItemFieldSingleSelectValue{name}}}}}}}")
+
+
+def board_items(owner, number, pid):
+ """Every card and its Status, as {item_id: status-or-None} beside the raw items.
+
+ `gh project item-list` names the Status column by its lowercased field name
+ (`status`) and leaves the key out on a card with no status. When no card carries
+ the key at all, it is read again over GraphQL (fieldValueByName "Status") rather
+ than trusted: a snapshot of all-None would make the restore a silent no-op."""
+ items = gh_json("project", "item-list", str(number), "--owner", owner, "--format", "json",
+ "--limit", "5000").get("items", [])
+ if items and not any("status" in it for it in items):
+ statuses, after = {}, None
+ while True:
+ page = gql(ITEM_STATUS_Q, {"id": pid, "after": after})["data"]["node"]["items"]
+ for n in page["nodes"]:
+ statuses[n["id"]] = (n.get("fieldValueByName") or {}).get("name")
+ if not page["pageInfo"]["hasNextPage"]:
+ break
+ after = page["pageInfo"]["endCursor"]
+ for it in items:
+ if statuses.get(it["id"]):
+ it["status"] = statuses[it["id"]]
+ return items, {it["id"]: it.get("status") for it in items}
+
+
+def board_migrate(owner, number, repo, qa_all=False, dry=False, prune_empty=False, root=None):
+ """Bring one GitHub Project to the v3 shape. Never removes a card.
+
+ updateProjectV2Field with singleSelectOptions replaces the whole option list: every
+ option gets a new id and GitHub clears the Status of EVERY card on the board. So every
+ rewrite is preceded by a snapshot on disk and followed by a restore, whatever path
+ triggered it (added columns, --prune-empty, Skipped removal)."""
res = {"added_columns": [], "labels_created": [], "labels_mapped": 0, "qa_labelled": 0,
- "skipped_moved": 0, "skipped_removed": False, "restored": 0}
+ "skipped_moved": 0, "skipped_removed": False, "restored": 0, "status_backup": None}
proj = gh_json("project", "view", str(number), "--owner", owner, "--format", "json")
pid, res["url"] = proj.get("id"), proj.get("url")
field = status_field(owner, number)
@@ -548,8 +585,7 @@ def board_migrate(owner, number, repo, qa_all=False, dry=False, prune_empty=Fals
raise RuntimeError("project has no Status field")
fid = field["id"]
opts = gql(OPTIONS_Q, {"id": fid})["data"]["node"]["options"]
- items = gh_json("project", "item-list", str(number), "--owner", owner, "--format", "json", "--limit", "500").get("items", [])
- snapshot = {it["id"]: it.get("status") for it in items}
+ items, snapshot = board_items(owner, number, pid)
names_low = {o["name"].lower(): o for o in opts}
in_use = {it.get("status") for it in items}
@@ -572,9 +608,23 @@ def option_inputs(keep_skipped):
return out_
has_skipped = "skipped" in names_low
+ rewrote = False
+
+ def rewrite(keep_skipped):
+ nonlocal rewrote
+ if not rewrote:
+ # On disk before the first rewrite: a crash after it still leaves a way back.
+ bdir = os.path.join(root or os.getcwd(), ".claude", "super-board", "backup")
+ os.makedirs(bdir, exist_ok=True)
+ path = os.path.join(bdir, f"board-{number}-{dt.datetime.now().strftime('%Y%m%d-%H%M%S')}.json")
+ write_json(path, snapshot)
+ res["status_backup"] = path
+ rewrote = True
+ got = gql(UPDATE_M, {"id": fid, "opts": option_inputs(keep_skipped)})
+ return got["data"]["updateProjectV2Field"]["projectV2Field"]["options"]
+
if (res["added_columns"] or prune_empty) and not dry:
- opts_now = gql(UPDATE_M, {"id": fid, "opts": option_inputs(keep_skipped=True)})
- opts_now = opts_now["data"]["updateProjectV2Field"]["projectV2Field"]["options"]
+ opts_now = rewrite(keep_skipped=True)
else:
opts_now = opts
ids = {o["name"].lower(): o["id"] for o in opts_now}
@@ -615,18 +665,17 @@ def set_status(item_id, name):
snapshot[it["id"]] = "Done"
res["skipped_moved"] += 1
if not dry:
- opts_now = gql(UPDATE_M, {"id": fid, "opts": option_inputs(keep_skipped=False)})
- opts_now = opts_now["data"]["updateProjectV2Field"]["projectV2Field"]["options"]
+ opts_now = rewrite(keep_skipped=False)
ids = {o["name"].lower(): o["id"] for o in opts_now}
res["skipped_removed"] = True
- # Rewriting options can clear a card's status. Put every one back.
- if not dry and (res["added_columns"] or has_skipped):
- now = gh_json("project", "item-list", str(number), "--owner", owner, "--format", "json", "--limit", "500").get("items", [])
- for it in now:
- want = snapshot.get(it["id"])
- if want and it.get("status") != want and want.lower() in ids:
- set_status(it["id"], want)
+ # Rewriting options clears card statuses. After ANY rewrite, put every one back.
+ if rewrote and not dry:
+ _, now = board_items(owner, number, pid)
+ for item_id, have in now.items():
+ want = snapshot.get(item_id)
+ if want and have != want and want.lower() in ids:
+ set_status(item_id, want)
res["restored"] += 1
return res
@@ -690,7 +739,7 @@ def main(argv):
if not (owner and number and repo):
return 64
qa_all = "--qa-all" in rest or bool(cfg.get("_qa_all"))
- res = board_migrate(owner, int(number), repo, qa_all, dry, "--prune-empty" in rest)
+ res = board_migrate(owner, int(number), repo, qa_all, dry, "--prune-empty" in rest, root)
if cfg_path and cfg.pop("_qa_all", None) is not None and not dry:
write_json(cfg_path, cfg)
return out(res)
diff --git a/skills/super-board/VERSION b/skills/super-board/VERSION
index b0f2dcb..eca690e 100644
--- a/skills/super-board/VERSION
+++ b/skills/super-board/VERSION
@@ -1 +1 @@
-3.0.4
+3.0.5
diff --git a/tests/test-setup.sh b/tests/test-setup.sh
index 84df003..4428815 100644
--- a/tests/test-setup.sh
+++ b/tests/test-setup.sh
@@ -116,7 +116,12 @@ elif a[:2] == ["project", "field-list"]:
elif a[:2] == ["project", "view"]:
print(json.dumps({"id": "P" + a[2], "url": "https://github.com/users/eric/projects/" + a[2]}))
elif a[:2] == ["project", "item-list"]:
- print(json.dumps({"items": st["items"]}))
+ items = st["items"]
+ if st.get("hide_status"): # a gh that does not surface the Status column
+ items = [{k: v for k, v in it.items() if k != "status"} for it in items]
+ else: # like gh: no key on a card with no status
+ items = [{k: v for k, v in it.items() if not (k == "status" and v is None)} for it in items]
+ print(json.dumps({"items": items}))
elif a[:2] == ["project", "item-edit"]:
item, opt = a[a.index("--id") + 1], a[a.index("--single-select-option-id") + 1]
name = next(o["name"] for o in st["options"]["2"] if o["id"] == opt)
@@ -125,7 +130,12 @@ elif a[:2] == ["project", "item-edit"]:
save()
elif a[:2] == ["api", "graphql"]:
body = json.loads(sys.stdin.read())
- if body["query"].startswith("query"):
+ if "fieldValueByName" in body["query"]:
+ nodes = [{"id": it["id"], "fieldValueByName": {"name": it["status"]} if it.get("status") else None}
+ for it in st["items"]]
+ print(json.dumps({"data": {"node": {"items": {"pageInfo": {"hasNextPage": False, "endCursor": None},
+ "nodes": nodes}}}}))
+ elif body["query"].startswith("query"):
print(json.dumps({"data": {"node": {"options": st["options"]["2"]}}}))
else:
st["gen"] = st.get("gen", 0) + 1
@@ -135,7 +145,7 @@ elif a[:2] == ["api", "graphql"]:
names = {o["name"] for o in new}
# Like the real API can: rewriting options clears statuses (here: every card in "Ready").
for it in st["items"]:
- if it.get("status") == "Ready" or it.get("status") not in names: it["status"] = None
+ if st.get("wipe_all") or it.get("status") == "Ready" or it.get("status") not in names: it["status"] = None
save()
print(json.dumps({"data": {"updateProjectV2Field": {"projectV2Field": {"options": [{"id": o["id"], "name": o["name"]} for o in new]}}}}))
elif a[:2] == ["label", "list"]:
@@ -185,7 +195,7 @@ JSON
: > "$GH_LOG"
CFG="$WORK/books.json"
echo '{"project":{"owner":"eric","number":2},"repo":{"remote":"https://github.com/eric/books.git"},"_qa_all":true}' > "$CFG"
-OUT=$(PATH="$WORK/bin:$PATH" python3 "$SETUP" board-migrate --config "$CFG")
+OUT=$(PATH="$WORK/bin:$PATH" python3 "$SETUP" board-migrate --config "$CFG" --root "$WORK")
echo "$OUT" | q '.added_columns == ["Backlog","Building"] and .labels_created == ["qa","feature"]' || fail "columns/labels wrong: $OUT"
echo "$OUT" | q '.labels_mapped == 1 and .qa_labelled == 1 and .skipped_moved == 1 and .skipped_removed == true' \
|| fail "mapping / qa-all / Skipped counts wrong: $OUT"
@@ -196,6 +206,8 @@ q '[.options["2"][].name] == ["Backlog","Ready","Building","QA","Review","Blocke
q '[.items[] | {(.id): .status}] | add == {"I1":"Done","I2":"Ready","I3":"Ready","I4":"Review"}' "$GH_STATE" \
|| fail "every card must keep its column (Skipped โ Done): $(jq -c .items "$GH_STATE")"
echo "$OUT" | q '.restored >= 2' || fail "cleared statuses must be restored: $OUT"
+q '. == {"I1":"Skipped","I2":"Ready","I3":"Ready","I4":"Review"}' "$(echo "$OUT" | jq -r .status_backup)" \
+ || fail "the pre-rewrite snapshot is saved to disk: $OUT"
q 'has("_qa_all") | not' "$CFG" || fail "the qa-all marker is dropped once applied"
# 9 โ a brand-new board: --prune-empty swaps GitHub's default Todo / In Progress for the seven.
@@ -204,9 +216,57 @@ cat > "$GH_STATE" <<'JSON'
"options":{"2":[{"id":"t","name":"Todo","color":"GRAY","description":""},{"id":"p","name":"In Progress","color":"YELLOW","description":""},
{"id":"d","name":"Done","color":"GREEN","description":""}]},"items":[]}
JSON
-OUT=$(PATH="$WORK/bin:$PATH" python3 "$SETUP" board-migrate --owner eric --number 2 --repo eric/books --prune-empty)
+OUT=$(PATH="$WORK/bin:$PATH" python3 "$SETUP" board-migrate --owner eric --number 2 --repo eric/books --prune-empty --root "$WORK")
q '[.options["2"][].name] == ["Backlog","Ready","Building","QA","Review","Blocked","Done"]' "$GH_STATE" \
|| fail "a new board should end with exactly the seven: $(jq -c '.options["2"]' "$GH_STATE")"
echo "$OUT" | q '.labels_created == []' || fail "existing labels are not created again: $OUT"
-echo "PASS: test-setup.sh (9 scenarios)"
+# 10 โ --prune-empty on a live board that already has the seven: the option rewrite wipes
+# EVERY card's status (as GitHub does), and the restore must still run โ it used to
+# run only for added columns or Skipped, so 114 cards fell to "No status".
+cat > "$GH_STATE" <<'JSON'
+{"projects":[],"labels":["qa","bug","feature"],"wipe_all":true,
+ "options":{"2":[{"id":"k","name":"Backlog","color":"GRAY","description":""},{"id":"r","name":"Ready","color":"BLUE","description":""},
+ {"id":"u","name":"Building","color":"YELLOW","description":""},{"id":"q","name":"QA","color":"ORANGE","description":""},
+ {"id":"v","name":"Review","color":"PURPLE","description":""},{"id":"b","name":"Blocked","color":"RED","description":""},
+ {"id":"d","name":"Done","color":"GREEN","description":""},{"id":"t","name":"Todo","color":"GRAY","description":""}]},
+ "items":[{"id":"I1","status":"Done","content":{"type":"Issue","number":1}},
+ {"id":"I2","status":"Building","content":{"type":"Issue","number":2}},
+ {"id":"I3","status":"Review","content":{"type":"Issue","number":3}},
+ {"id":"I4","status":null,"content":{"type":"Issue","number":4}}]}
+JSON
+: > "$GH_LOG"
+OUT=$(PATH="$WORK/bin:$PATH" python3 "$SETUP" board-migrate --owner eric --number 2 --repo eric/books --prune-empty --root "$WORK")
+q '[.options["2"][].name] == ["Backlog","Ready","Building","QA","Review","Blocked","Done"]' "$GH_STATE" \
+ || fail "the unused Todo should be pruned: $(jq -c '.options["2"]' "$GH_STATE")"
+q '[.items[] | {(.id): .status}] | add == {"I1":"Done","I2":"Building","I3":"Review","I4":null}' "$GH_STATE" \
+ || fail "prune-empty must put every wiped status back: $(jq -c .items "$GH_STATE")"
+echo "$OUT" | q '.restored == 3' || fail "three cards should be restored: $OUT"
+BK=$(echo "$OUT" | jq -r .status_backup)
+case "$BK" in "$WORK/.claude/super-board/backup/board-2-"*.json) ;; *) fail "backup path wrong: $BK" ;; esac
+q '. == {"I1":"Done","I2":"Building","I3":"Review","I4":null}' "$BK" || fail "backup must hold the pre-rewrite statuses: $(cat "$BK")"
+
+# 11 โ a gh that does not surface `status`: the snapshot falls back to GraphQL
+# fieldValueByName("Status"), so the restore still has something to restore.
+jq '.items = [{"id":"I1","status":"Done"},{"id":"I2","status":"Building"}] | .hide_status = true
+ | .options["2"] += [{"id":"t","name":"Todo","color":"GRAY","description":""}]' "$GH_STATE" > "$WORK/s" && mv "$WORK/s" "$GH_STATE"
+OUT=$(PATH="$WORK/bin:$PATH" python3 "$SETUP" board-migrate --owner eric --number 2 --repo eric/books --prune-empty --root "$WORK")
+q '[.items[] | {(.id): .status}] | add == {"I1":"Done","I2":"Building"}' "$GH_STATE" \
+ || fail "statuses read over GraphQL must be restored: $(jq -c .items "$GH_STATE") $OUT"
+echo "$OUT" | q '.restored == 2' || fail "two cards restored via the GraphQL snapshot: $OUT"
+
+# 12 โ --dry-run writes nothing: no option rewrite, no card move, no label, no backup file.
+jq '.items = [{"id":"I1","status":"Skipped","content":{"type":"Issue","number":1}},
+ {"id":"I2","status":"Ready","labels":["build"],"content":{"type":"Issue","number":2}}]
+ | .hide_status = false | .labels = [] | del(.gen)
+ | .options["2"] = [{"id":"r","name":"Ready","color":"BLUE","description":""},{"id":"s","name":"Skipped","color":"GRAY","description":""}]' \
+ "$GH_STATE" > "$WORK/s" && mv "$WORK/s" "$GH_STATE"
+cp "$GH_STATE" "$WORK/before.json"; : > "$GH_LOG"; rm -rf "$WORK/dry"; mkdir -p "$WORK/dry"
+OUT=$(PATH="$WORK/bin:$PATH" python3 "$SETUP" board-migrate --owner eric --number 2 --repo eric/books --prune-empty --qa-all --dry-run --root "$WORK/dry")
+cmp -s "$GH_STATE" "$WORK/before.json" || fail "dry-run changed board state: $(cat "$GH_STATE")"
+! grep -Eq '^(project item-edit|label create|issue edit)' "$GH_LOG" || fail "dry-run made a write: $(cat "$GH_LOG")"
+[ "$(grep -c '^api graphql' "$GH_LOG")" = 1 ] || fail "dry-run should only read options over GraphQL: $(cat "$GH_LOG")"
+[ ! -e "$WORK/dry/.claude" ] || fail "dry-run must not write a backup file"
+echo "$OUT" | q '.status_backup == null and .restored == 0 and .added_columns != []' || fail "dry-run report wrong: $OUT"
+
+echo "PASS: test-setup.sh (12 scenarios)"