From ec4a7c2da78ce35637c6c00a5b9189b85393e240 Mon Sep 17 00:00:00 2001 From: Eric Tech <146783360+EricTechPro@users.noreply.github.com> Date: Sat, 3 Oct 2026 11:03:00 -0700 Subject: [PATCH 1/4] =?UTF-8?q?=F0=9F=94=92=20[security]=20approval:=20bin?= =?UTF-8?q?d=20human=20sign-off=20to=20reviewed=20code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Verify trusted request and human reply for the current PR head and steps. - Refresh stale blocked requests without treating them as approved. - Pass 34 offline safety suites and independent review. --- install.sh | 2 +- scripts/super-board-approval.py | 248 ++++++++++++++++++ scripts/super-board-deps.sh | 17 +- scripts/super-board-merge-gate.sh | 48 +++- scripts/super-board-merge-policy.py | 6 +- scripts/super-board-setup.py | 2 +- scripts/super-board-wave-plan.sh | 13 +- .../super-board/references/block-template.md | 45 +++- .../super-board/references/config-schema.json | 6 +- skills/super-board/references/run-workflow.md | 13 +- skills/super-board/references/run.md | 29 +- .../references/writing-standard.md | 9 + skills/super-review/SKILL.md | 6 +- tests/test-deps.sh | 8 +- tests/test-merge-gate.sh | 96 ++++++- tests/test-wave-plan.sh | 7 +- tests/test_approval.py | 217 +++++++++++++++ 17 files changed, 704 insertions(+), 68 deletions(-) create mode 100644 scripts/super-board-approval.py create mode 100644 tests/test_approval.py diff --git a/install.sh b/install.sh index 65c2a4d1..e642ee63 100755 --- a/install.sh +++ b/install.sh @@ -145,7 +145,7 @@ copy_file() { fi } -for script in super-board-run.sh super-board-gh-guard.sh super-board-status.py super-board-wave-plan.sh super-board-deps.sh super-board-preflight.sh super-board-merge-gate.sh super-board-merge-policy.py super-board-env-check.sh super-board-agents-md.py super-board-settings.py super-board-setup.py super-board-usage.sh super-board-pr-body.sh super-review-file-refactor.sh super-qa-file-bug.sh super-board-stop.sh; do +for script in super-board-run.sh super-board-gh-guard.sh super-board-status.py super-board-wave-plan.sh super-board-deps.sh super-board-preflight.sh super-board-merge-gate.sh super-board-merge-policy.py super-board-approval.py super-board-env-check.sh super-board-agents-md.py super-board-settings.py super-board-setup.py super-board-usage.sh super-board-pr-body.sh super-review-file-refactor.sh super-qa-file-bug.sh super-board-stop.sh; do if [ -f "$REPO_ROOT/scripts/$script" ]; then copy_file "$REPO_ROOT/scripts/$script" "$TARGET/.claude/bin/$script" chmod +x "$TARGET/.claude/bin/$script" diff --git a/scripts/super-board-approval.py b/scripts/super-board-approval.py new file mode 100644 index 00000000..c48ce87e --- /dev/null +++ b/scripts/super-board-approval.py @@ -0,0 +1,248 @@ +#!/usr/bin/env python3 +"""Read-only, head-bound human approval shared by the planner and merge gate. + +A trusted request pins the code and human steps BEFORE a human replies `done`. +Labels only describe UI state. They never authorize a merge. No GitHub writes. +""" +from __future__ import annotations + +import argparse +from datetime import datetime +import hashlib +import json +import re +import subprocess +import sys + +PREFIX = "approval-request: " +SHA = re.compile(r"[0-9a-f]{40}\Z") +SCOPE = re.compile(r"[0-9a-f]{64}\Z") +BLOCK = re.compile(r"Reason tag:[^\n]*πŸ™‹|^approval-request:", re.M) +DONE = re.compile(r"\s*done[.!]?\s*\Z", re.I) + + +def scope_for(plan): + # Exclude UI-only `done`; a label change must not change what was approved. + relevant = {key: plan.get(key, []) for key in ("human", "migrations", "run", "needs_you")} + return hashlib.sha256(json.dumps(relevant, sort_keys=True, separators=(",", ":")).encode()).hexdigest() + + +def request_for(repo, pr, head, scope): + return {"repo": repo.lower(), "pr": int(pr), "head": head, "scope": scope} + + +def marker(request): + return PREFIX + json.dumps(request, sort_keys=True, separators=(",", ":")) + + +def parse_request(comment): + lines = [line for line in (comment.get("body") or "").splitlines() if line.startswith(PREFIX)] + if len(lines) != 1: + return None + try: + value = json.loads(lines[0][len(PREFIX):]) + if (not isinstance(value, dict) or not isinstance(value.get("repo"), str) + or not re.fullmatch(r"[^/\s]+/[^/\s]+", value["repo"]) + or type(value.get("pr")) is not int or value["pr"] < 1 + or not SHA.fullmatch(value.get("head", "")) + or not SCOPE.fullmatch(value.get("scope", ""))): + return None + return value + except (ValueError, TypeError): + return None + + +def timestamp(value): + if not isinstance(value, str): + raise ValueError("missing comment timestamp") + result = datetime.fromisoformat(value.replace("Z", "+00:00")) + if result.tzinfo is None: + raise ValueError("comment timestamp lacks timezone") + return result + + +def trusted_blocks(comments, permission): + result = [] + for block in comments: + if not BLOCK.search(block.get("body") or ""): + continue + user = block.get("user") or {} + if not user.get("login") or user.get("type") not in ("User", "Bot"): + raise ValueError("requester identity could not be verified") + if permission(user["login"]) in ("write", "maintain", "admin"): + result.append(block) + return result + + +def validate(comments, expected, permission): + """Pure verdict; permission(login) is a current repository permission lookup.""" + def hold(why): + return {"approved": False, "why": why} + + if not SHA.fullmatch(expected.get("head", "")): + return hold("current PR head is unavailable") + try: + blocks = trusted_blocks(comments, permission) + if not blocks: + return hold("no request from a trusted repository collaborator") + # Any later human block supersedes the old request, including one with + # missing/malformed metadata. Equal-second requests are ambiguous: hold. + latest_time = max(timestamp(c.get("created_at")) for c in blocks) + latest = [c for c in blocks if timestamp(c.get("created_at")) == latest_time] + if len(latest) != 1: + return hold("ambiguous newest human request") + request_comment = latest[0] + request = parse_request(request_comment) + if request is None or any(request.get(k) != v for k, v in expected.items()): + return hold("the latest request does not cover the current code and human steps") + if timestamp(request_comment.get("updated_at")) != latest_time: + return hold("approval request was edited; post a fresh request") + requester = request_comment.get("user") or {} + if not requester.get("login") or permission(requester["login"]) not in ("write", "maintain", "admin"): + return hold("requester authority could not be verified") + if not isinstance(request_comment.get("source"), int) or not request_comment.get("id"): + return hold("request identity is missing") + for comment in comments: + if (not comment.get("id") or not DONE.fullmatch(comment.get("body") or "") + or comment.get("source") != request_comment.get("source")): + continue + created = timestamp(comment.get("created_at")) + if created <= latest_time or timestamp(comment.get("updated_at")) != created: + continue + author = comment.get("user") or {} + if (author.get("type") == "User" and author.get("login") + and permission(author["login"]) in ("write", "maintain", "admin")): + return {"approved": True, "why": "trusted human confirmed the current request", + "request": request, "requestId": request_comment.get("id"), + "approvalId": comment.get("id"), "approver": author["login"]} + return hold("waiting for a trusted human to comment done on the current request") + except (ValueError, TypeError, KeyError): + return hold("approval evidence is incomplete or unreadable") + + +class GitHub: + def __init__(self, repo): + if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repo): + raise ValueError("missing or invalid repository") + self.repo = repo.lower() + self.permissions = {} + + @staticmethod + def read(*args): + result = subprocess.run(["gh", *args], capture_output=True, text=True, timeout=30) + if result.returncode: + raise ValueError("GitHub evidence could not be read") + return json.loads(result.stdout) + + def permission(self, login): + if not re.fullmatch(r"[A-Za-z0-9_-]+(?:\[bot\])?", login): + return None + if login not in self.permissions: + value = self.read("api", f"repos/{self.repo}/collaborators/{login}/permission") + self.permissions[login] = value.get("permission") + return self.permissions[login] + + def comments(self, number): + pages = self.read("api", "--paginate", "--slurp", + f"repos/{self.repo}/issues/{number}/comments?per_page=100") + if not isinstance(pages, list) or not pages or any(not isinstance(p, list) for p in pages): + raise ValueError("unreadable comments") + if any(not isinstance(c, dict) for page in pages for c in page): + raise ValueError("unreadable comment") + return [dict(comment, source=number) for page in pages for comment in page] + + def pr(self, number): + # Paginate linked issues too: an omitted newer request must never make + # an older approval authoritative. Remote-repository links are ignored. + query = '''query($owner:String!,$repo:String!,$pr:Int!,$endCursor:String){ + repository(owner:$owner,name:$repo){pullRequest(number:$pr){headRefOid state + closingIssuesReferences(first:100,after:$endCursor){ + pageInfo{hasNextPage endCursor} nodes{number repository{nameWithOwner}} + }}}}''' + owner, name = self.repo.split("/") + pages = self.read("api", "graphql", "--paginate", "--slurp", "-f", f"query={query}", + "-F", f"owner={owner}", "-F", f"repo={name}", "-F", f"pr={number}") + heads, issues = set(), set() + if not isinstance(pages, list) or not pages: + raise ValueError("unreadable PR") + for page in pages: + if page.get("errors"): + raise ValueError("incomplete PR response") + pr = page["data"]["repository"]["pullRequest"] + if pr["state"] != "OPEN": + raise ValueError("PR is not open") + heads.add(pr["headRefOid"]) + links = pr["closingIssuesReferences"] + for issue in links["nodes"]: + if issue["repository"]["nameWithOwner"].lower() == self.repo: + issues.add(issue["number"]) + if links["pageInfo"]["hasNextPage"] or len(heads) != 1: + raise ValueError("PR changed or issue links are incomplete") + head = heads.pop() + if not SHA.fullmatch(head): + raise ValueError("unreadable PR head") + return head, issues + + def check(self, number, head=None, scope=None, issue=None): + current_head, issues = self.pr(number) + if issue is not None and issue not in issues: + return {"approved": False, "why": "request issue is not linked to this PR"} + if head is not None and current_head != head: + return {"approved": False, "headChanged": True, "why": "PR head changed after review"} + comments = [] + for source in sorted(issues | {number}): + comments.extend(self.comments(source)) + expected = {"repo": self.repo, "pr": number, "head": current_head} + if scope is not None: + expected["scope"] = scope + return validate(comments, expected, self.permission) + + +def main(): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--repo", required=True) + ap.add_argument("--pr", type=int) + ap.add_argument("--head") + ap.add_argument("--plan", help="JSON plan from merge-policy") + ap.add_argument("--request", action="store_true", help="print a pinned request; no network or writes") + ap.add_argument("--deps", action="store_true", help="enrich the dependency graph on stdin") + args = ap.parse_args() + if args.request: + if not args.pr or not args.head or not SHA.fullmatch(args.head) or not args.plan: + ap.error("request requires --pr, full --head and --plan") + print(marker(request_for(args.repo, args.pr, args.head, scope_for(json.loads(args.plan))))) + return + if args.deps: + graph = json.load(sys.stdin) + github = GitHub(args.repo) if args.repo else None + for entry in graph.values(): + entry["needsYouDone"] = False + entry["approvalRefresh"] = False + if not entry.get("needsYou") or github is None: + continue + try: + comments = github.comments(entry["number"]) + blocks = trusted_blocks(comments, github.permission) + latest = max(blocks, key=lambda c: timestamp(c.get("created_at"))) if blocks else {} + request = parse_request(latest) + if request is None or request["repo"] != github.repo: + continue + result = github.check(request["pr"], head=request["head"], issue=entry["number"]) + entry["needsYouDone"] = result["approved"] + entry["approvalRefresh"] = result.get("headChanged", False) + entry["approvalWhy"] = result["why"] + except (ValueError, KeyError, TypeError, OSError, subprocess.SubprocessError): + entry["approvalWhy"] = "approval evidence could not be verified" + print(json.dumps(graph)) + return + if not args.pr or not args.head or not args.plan: + ap.error("check requires --pr, --head and --plan") + try: + result = GitHub(args.repo).check(args.pr, args.head, scope_for(json.loads(args.plan))) + except (ValueError, KeyError, TypeError, OSError, subprocess.SubprocessError): + result = {"approved": False, "why": "approval evidence could not be verified"} + print(json.dumps(result)) + + +if __name__ == "__main__": + main() diff --git a/scripts/super-board-deps.sh b/scripts/super-board-deps.sh index c30eff3b..523e9611 100755 --- a/scripts/super-board-deps.sh +++ b/scripts/super-board-deps.sh @@ -48,9 +48,9 @@ # # `needsYou` β€” the newest Block comment carries the πŸ™‹ reason tag, or the issue # has the `needs-you` label: a human-only command is waiting (block-template.md). -# `needsYouDone` β€” the human said it is done: the issue has the `needs-you:done` -# label, or a comment AFTER that πŸ™‹ comment reads just "done". The wave planner -# reports these in `resume`; they go back to Review and the merge gate re-runs. +# `needsYouDone` β€” verified by super-board-approval.py: a trusted human replied +# done after a pinned request for the current PR head. Labels are never authority. +# Saved --from payloads stay offline and cannot assert verified human approval. # A πŸ™‹ card stays humanGated either way β€” only the resume path moves it. # # `runnable` is true only when the line parses, no blocker is still open, AND the @@ -93,6 +93,8 @@ else echo "could not read issues for $REPO" >&2; exit 69; } fi +APPROVAL_REPO="$REPO" +[ -z "$FROM" ] || APPROVAL_REPO="" echo "$ISSUES" | jq --arg only "$ONLY" ' # ---- the parser --------------------------------------------------------- # Everything after the LAST "## Blocked by" heading, stopping at the next @@ -144,10 +146,7 @@ echo "$ISSUES" | jq --arg only "$ONLY" ' def needs_at: ( bodies | to_entries | map(select(.value | test("Reason tag:[^\n]*πŸ™‹"))) | last | .key ) // null; def needs_you: (label_names | index("needs-you") != null) or (needs_at != null); - def needs_you_done: - (label_names | index("needs-you:done") != null) - or ( needs_at as $at | $at != null - and ( bodies[($at + 1):] | any(.[]; test("^[ \t\n]*done[.!]?[ \t\n]*$"; "i")) ) ); + ( [ .[] | .number ] ) as $open | ( if $only == "" then null else ($only / "," | map(tonumber)) end ) as $filter @@ -190,7 +189,7 @@ echo "$ISSUES" | jq --arg only "$ONLY" ' runnable: ($p.parseable and ($p.human_gated | not) and (($stillOpen | length) == 0)), why: $p.why, needsYou: ($i | needs_you), - needsYouDone: (($i | needs_you) and ($i | needs_you_done)) } + needsYouDone: false } ] | ( if $filter == null then . else map(select(.number as $n | $filter | index($n))) end ) - | INDEX(.number | tostring)' + | INDEX(.number | tostring)' | python3 "$(dirname "${BASH_SOURCE[0]}")/super-board-approval.py" --deps --repo "$APPROVAL_REPO" diff --git a/scripts/super-board-merge-gate.sh b/scripts/super-board-merge-gate.sh index 91d46a01..63e145d8 100755 --- a/scripts/super-board-merge-gate.sh +++ b/scripts/super-board-merge-gate.sh @@ -81,14 +81,14 @@ # review" β€” or merge_policy.default "human"). Stdout lists each # `human-gate: β€” `. Nothing ran, nothing merged; the # card β†’ Blocked with the πŸ™‹ template: the human reviews and merges it, or -# comments "done" (label needs-you:done) to approve β€” the next wave re-runs +# comments "done" after its pinned request to approve β€” the next wave re-runs # the gate, which then skips the policy check and merges. # 8 πŸ™‹ needs you β€” the PR has migrations for a database the robot may not # touch (merge_policy β†’ migrations.allowed_envs), an allowed migrate command # failed, or a human-only step is declared (`needs-you:` line in the PR body, # or migrations.human_steps). Stdout lists the exact commands as # `needs-you: ` lines. Card β†’ Blocked with the πŸ™‹ template; once the -# PR carries the `needs-you:done` label the next wave re-runs the gate and it +# current request has verified trusted-human approval, the gate re-runs and # merges. # # MERGE POLICY AND MIGRATIONS (config, all optional β€” defaults shown in @@ -218,14 +218,27 @@ PLAN=$(python3 "$HERE/super-board-merge-policy.py" --config "$CONFIG" --meta "$M echo "human-gate: policy β€” could not classify the PR (unreadable metadata)" say "merge policy could not be evaluated β€” a human merges this one"; exit 7; } +# The label is display-only. A request posted before `done` pins the exact head +# and policy/commands. Re-read trusted approval evidence; never mint it here. +APPROVED=false +approval_check() { + APPROVAL=$(python3 "$HERE/super-board-approval.py" --repo "$REPO" --pr "$PR" \ + --head "$HEAD_SHA" --plan "$PLAN") || APPROVAL='{"approved":false}' + APPROVED=$(echo "$APPROVAL" | jq -r '.approved // false') +} +approval_request() { + python3 "$HERE/super-board-approval.py" --request --repo "$REPO" --pr "$PR" \ + --head "$HEAD_SHA" --plan "$PLAN" +} HUMAN=$(echo "$PLAN" | jq -r '.human[] | "human-gate: \(.category) β€” \(.why)"') -if [ -n "$HUMAN" ] && [ "$(echo "$PLAN" | jq -r '.done')" = "true" ]; then - say "needs-you:done is on the PR β€” a human approved the policy gate ($(echo "$PLAN" | jq -r '[.human[].category] | join(", ")'))" - HUMAN="" +if [ -n "$HUMAN" ] || [ "$(echo "$PLAN" | jq '.needs_you | length')" -gt 0 ]; then + approval_check fi -if [ -n "$HUMAN" ]; then +if [ -n "$HUMAN" ] && [ "$APPROVED" != true ]; then echo "$HUMAN" - say "merge policy: a human merges this PR"; exit 7 + echo "$PLAN" | jq -r '.needs_you[] | "needs-you: " + .' + approval_request + say "merge policy: waiting for a trusted human to approve this exact head"; exit 7 fi git -C "$REPO_PATH" fetch origin "$HEAD_REF" "$BASE" --quiet @@ -275,15 +288,32 @@ if [ "$(echo "$PLAN" | jq '.migrations | length')" -gt 0 ]; then done < <(echo "$PLAN" | jq -r '.run[] | [.env, .cmd] | @tsv') fi if [ "${#NEEDS[@]}" -gt 0 ]; then - if [ "$(echo "$PLAN" | jq -r '.done')" = "true" ] && [ "$(echo "$PLAN" | jq '.needs_you | length')" -eq "${#NEEDS[@]}" ]; then - say "needs-you:done is on the PR β€” the human steps are confirmed; merging" + if [ "$APPROVED" = true ] && [ "$(echo "$PLAN" | jq '.needs_you | length')" -eq "${#NEEDS[@]}" ]; then + say "a trusted human confirmed the current head and human steps" else for n in "${NEEDS[@]}"; do echo "needs-you: $n"; done + approval_request say "πŸ™‹ needs you before this merges β€” card β†’ Blocked with the commands above" exit 8 fi fi +# An approval can be superseded while verification/migrations run. Re-read just +# before merging; GitHub independently pins the code with --match-head-commit. +if [ "$APPROVED" = true ]; then + # Code remains pinned; body-declared human steps and config can change without + # a commit. Reclassify them too instead of reusing the old approval scope. + gh pr view "$PR" ${REPO:+--repo "$REPO"} --json labels,files,additions,deletions,body > "$META" 2>/dev/null || echo '{}' > "$META" + PLAN=$(python3 "$HERE/super-board-merge-policy.py" --config "$CONFIG" --meta "$META" --diff "$DIFF") || { + say "human approval scope could not be refreshed"; exit 7; } + approval_check + if [ "$APPROVED" != true ]; then + approval_request + say "human approval changed during verification; keep the card Blocked" + if [ -n "$HUMAN" ]; then exit 7; else exit 8; fi + fi +fi + # ---- the merge ------------------------------------------------------------- if [ "$DRY" -eq 1 ]; then say "dry run: would squash-merge PR #${PR}" diff --git a/scripts/super-board-merge-policy.py b/scripts/super-board-merge-policy.py index 52484a42..167a71eb 100755 --- a/scripts/super-board-merge-policy.py +++ b/scripts/super-board-merge-policy.py @@ -17,7 +17,8 @@ "done": false } `human` non-empty β†’ the gate exits 7 (a human merges). `needs_you` non-empty β†’ -exit 8 unless `done` (the PR carries the `needs-you:done` label). Exit 2 on +exit 8 unless the gate verifies a head-bound approval. `done` stays false for +compatibility: labels never supply authority. Exit 2 on unreadable metadata, so the gate fails safe to "human". Rules, from references/config-schema.json β†’ merge_policy / migrations: @@ -68,7 +69,6 @@ "**/migrations/*.sql", "db/migrate/**", "alembic/versions/**", ] DEFAULT_ALLOWED_ENVS = ["test", "staging"] -DONE_LABEL = "needs-you:done" DEFAULT_AUTO_MAX_LINES = 400 DEFAULT_SIZE_EXCLUDE = [ # lockfiles @@ -193,7 +193,7 @@ def main() -> int: needs.append(m.group(1).strip("`")) print(json.dumps({"human": human, "migrations": mig_files, "run": run, - "needs_you": needs, "done": DONE_LABEL in labels})) + "needs_you": needs, "done": False})) return 0 diff --git a/scripts/super-board-setup.py b/scripts/super-board-setup.py index 6f0b4ebf..92632b75 100755 --- a/scripts/super-board-setup.py +++ b/scripts/super-board-setup.py @@ -65,7 +65,7 @@ OLD_SKILL_DIRS = ["super-refine", "cleanup-wt", "arch-loop"] BIN = ["super-board-run.sh", "super-board-gh-guard.sh", "super-board-status.py", "super-board-wave-plan.sh", "super-board-deps.sh", "super-board-preflight.sh", "super-board-merge-gate.sh", - "super-board-merge-policy.py", "super-board-env-check.sh", "super-board-agents-md.py", + "super-board-merge-policy.py", "super-board-approval.py", "super-board-env-check.sh", "super-board-agents-md.py", "super-board-settings.py", "super-board-setup.py", "super-board-usage.sh", "super-board-pr-body.sh", "super-review-file-refactor.sh", "super-qa-file-bug.sh", "super-board-stop.sh"] WORKFLOWS = ["super-board-wave.js", "ui-refine-loop.js"] diff --git a/scripts/super-board-wave-plan.sh b/scripts/super-board-wave-plan.sh index 426a1761..35881fb6 100755 --- a/scripts/super-board-wave-plan.sh +++ b/scripts/super-board-wave-plan.sh @@ -67,6 +67,7 @@ # { "cards": [ {"number":10,"status":"Review","title":"…","lane":"review","labels":[]} ], # "sweep": [ {"number":37,"title":"…","clearedBy":[32]} ], # "resume": [ {"number":51,"title":"…"} ], +# "refreshApproval": [ {"number":52,"title":"…","why":"PR head changed after review"} ], # "flag": [ {"number":82,"title":"…","why":"…"} ], # "stranded": [ {"number":44,"title":"…"} ] } set -euo pipefail @@ -166,15 +167,19 @@ echo "$ITEMS" | jq --argjson cols "$COLUMNS" --argjson cap "$MAX_WORKERS" --argj | { number, title, clearedBy: (dep(.number) | .blockers) } ], - # πŸ™‹ Blocked cards whose human step is confirmed done (needs-you:done - # label, or a "done" comment after the πŸ™‹ block). The orchestrator moves - # them to Review and labels the PR needs-you:done; the Reviewer re-runs - # the merge gate, which re-verifies and merges. + # Verified current-head human approval from the dependency helper. The + # Reviewer independently rechecks it; labels are only UI state. resume: [ $all[] | select(.status == "Blocked") | select(dep(.number) != null and (dep(.number).needsYouDone // false)) | { number, title } ], + # Changed code while Blocked needs a new review/request, never approval. + refreshApproval: [ $all[] + | select(.status == "Blocked") + | select(dep(.number).approvalRefresh // false) + | { number, title, why: dep(.number).approvalWhy } ], + # Cards the graph could not read. Left where they are, reported so the # orchestrator can ask for the line to be fixed. flag: [ $all[] diff --git a/skills/super-board/references/block-template.md b/skills/super-board/references/block-template.md index 4edadfc8..49af3e61 100644 --- a/skills/super-board/references/block-template.md +++ b/skills/super-board/references/block-template.md @@ -20,7 +20,7 @@ record of what they were waiting for was English prose in a comment nobody re-re ## Required Block comment template (mandatory on every transition into Blocked) -The bot must write a structured comment on **both the issue and the PR** (if a PR exists) explaining *why* it moved the card and *what it couldn't safely decide*. Format: +The bot must write a structured comment on **both the issue and the PR** (if a PR exists) explaining *why* it moved the card and *what it couldn't safely decide*. Exception: a πŸ™‹ merge approval uses one canonical issue request below; the PR links to it without duplicating its machine lines. Format: ``` [] [blocker] πŸ›‘ blocked Β· @@ -101,14 +101,15 @@ an allowed migrate command that failed, a declared human step), or any lane that Checklist first: the person sees what to do before why. The why and the evidence fold away. Merge gate exit 7 (a human merges) has one item before `done`: `- [ ] Review and merge PR #

-(or comment done to approve it)`. +(or comment done to approve it)`. Include any `needs-you:` commands printed with +that policy hold too: the approval covers those human steps as well as the code. ``` [reviewer] [blocker] πŸ™‹ Your turn on # β€” - [ ] Run `<exact command 1, copy-paste ready>` on the **<env>** database - [ ] <exact command 2, if any> - [ ] Comment `done` here -After `done`, the next wave moves the card to Review and merges it. +After a trusted human confirms this version, the next wave returns it to Review for verification. <details><summary>Why, and what I checked</summary> @@ -116,6 +117,7 @@ PR #<P> <one line β€” e.g. "adds prisma/migrations/0042_add_plan">. <env> isn't Tests green on <base>@<sha>; migrated <test, staging>. Evidence: <the gate's `needs-you:` lines, verbatim> Reason tag: πŸ™‹ needs you Β· Owner: <Eric | repo admin> +approval-request: <copy the gate's exact JSON here> blocked-by: - </details> ``` @@ -126,7 +128,7 @@ Example (what the person sees on GitHub): > - [ ] Run `npx prisma migrate deploy` on the **live** database > - [ ] Comment `done` here > -> After `done`, the next wave moves the card to Review and merges it. +> After a trusted human confirms this version, the next wave returns it to Review for verification. > β–Έ Why, and what I checked The `Reason tag:` and `blocked-by: -` lines stay (inside the fold): the planner reads them @@ -137,12 +139,35 @@ never paraphrase ("run the migration on prod"). A placeholder such as `<your liv means the config has no command for that env β€” say so in the fold and name the config key (`migrations.commands.live`). -**Resume.** The wave planner's `resume` list carries every πŸ™‹ card whose human said `done` (a -comment after the πŸ™‹ block that reads just `done`, or the `needs-you:done` label). The orchestrator -moves it to **Review** and puts `needs-you:done` on the PR; the Reviewer re-runs the merge gate, -which re-checks the head, re-verifies against the base, skips the merge-policy check (the human -approved), re-runs the allowed migrations, and merges. -A command that still fails puts the card straight back here. +**Approval request.** Copy the gate's `approval-request:` line verbatim into this +issue comment. It records the full PR head and the exact policy/human steps being +confirmed. The issue must be linked by the PR's closing reference (`Closes #N`). +For a standalone PR, post the canonical request on the PR instead. Keep a single +request location: the PR's status comment links to the issue request and does not +repeat `Reason tag: πŸ™‹` or `approval-request:`. Never edit a request after posting; +post a new one when the code, steps or actual human question changes. Do not +repost the same pending request on every poll or retry. Generic credential or +product blocks without a PR remain manual; a bare `done` does not automate them. + +**Resume.** A human with current repository write, maintain or admin permission +comments `done` after the newest request, in the same thread. This supports a solo +owner approving their own PR. The planner verifies identity, permission, linked +PR and current full head before putting the card in `resume`; the Reviewer then +re-runs the merge gate. The gate also verifies the current policy and human steps, +re-verifies against the base, re-runs allowed migrations, and rechecks approval +just before merging. Changed code, a newer human block, edited evidence, missing +permissions or unreadable GitHub evidence keep it on hold. A still-failing command +also sends it back here. Old bare comments and `needs-you:done` labels never count; +legacy blocked cards need one fresh pinned request and a fresh human reply. Move +those legacy cards to Review once to generate it. If a PR changes while still +Blocked, the planner's `refreshApproval` sends it to Review to create the new +request automatically; it does not mark the new code approved. + +**Trust boundary.** GitHub Bot accounts cannot supply human approval. GitHub cannot +distinguish a person from an agent using that person's token; agents must never +write the human's `done` response. The final approval check is a fresh snapshot; +GitHub atomically protects the head at merge, but does not lock issue comments. +A human can still merge a PR manually under the repository's normal rules. ## Hard rule diff --git a/skills/super-board/references/config-schema.json b/skills/super-board/references/config-schema.json index eee5d395..fb0c0897 100644 --- a/skills/super-board/references/config-schema.json +++ b/skills/super-board/references/config-schema.json @@ -151,9 +151,9 @@ // (πŸ™‹ needs you β†’ Blocked, block-template.md) instead when: target_env is not // in allowed_envs, an allowed command fails, target_env is allowed but has no // command, or human_steps is non-empty. A `needs-you: <command>` line in the - // PR body does the same for any PR. The `needs-you:done` PR label (or a - // "done" comment, which the orchestrator turns into the label) clears the - // human steps; the next wave re-verifies and merges. + // PR body does the same for any PR. A trusted human comments "done" after a + // pinned approval request for the current head and steps (block-template.md). + // The gate rechecks that evidence; needs-you:done labels never authorize it. // In one line, for the user: the robot migrates the DBs you allow to test its // work; a live database, or a destructive schema change, waits for you. "migrations": { diff --git a/skills/super-board/references/run-workflow.md b/skills/super-board/references/run-workflow.md index 5a0e85d5..691bbaea 100644 --- a/skills/super-board/references/run-workflow.md +++ b/skills/super-board/references/run-workflow.md @@ -84,13 +84,16 @@ Repeat until a done condition or halt gate fires: cut off mid-lane. The legacy `claude-p` dispatcher does not run this guard. 2. **Plan the wave** β€” `bash .claude/bin/super-board-wave-plan.sh --config <config-path>` β†’ - The planner returns `cards`, `sweep`, `resume`, `flag` and `stranded`. **Act on - `sweep`, `resume`, `flag` and `stranded` BEFORE launching** (run.md β†’ "The wave-start + The planner returns `cards`, `sweep`, `resume`, `refreshApproval`, `flag` and `stranded`. **Act on + `sweep`, `resume`, `refreshApproval`, `flag` and `stranded` BEFORE launching** (run.md β†’ "The wave-start sweep"): move every swept card to `Ready` with a comment naming what cleared - it, move every `resume` card (πŸ™‹ needs you, human said done) to `Review` and - label its PR `needs-you:done`, comment on every flagged card asking for its + it, move every `resume` card (verified human approval of the current pinned + request) to `Review`; `needs-you:done` is display-only. Move `refreshApproval` + cards to `Review` solely to review the changed head and post a fresh human request; + clear their stale `needs-you:done` display labels. They have no approval to merge. + Comment on flagged cards asking for their `## Blocked by` line to be fixed, and return every stranded Building card to - `Ready` (below). Swept, resumed and stranded cards are NOT in this pass's + `Ready` (below). Swept, resumed, approval-refresh and stranded cards are NOT in this pass's `cards`; they join the next wave. **Stranded Building cards.** A card in Building with no assignee between diff --git a/skills/super-board/references/run.md b/skills/super-board/references/run.md index 4219669c..4b703868 100644 --- a/skills/super-board/references/run.md +++ b/skills/super-board/references/run.md @@ -449,16 +449,18 @@ on the base branch**. category and evidence; `To unblock` = "[ ] review PR #<P> and merge it yourself" OR "[ ] comment `done` to approve β€” the next wave merges it"; label `needs-you`, `blocked-by: -`. A human merge moves the card to Done the - usual way; a `done` brings it back through `resume`, and the gate skips the - policy check once the PR carries `needs-you:done`. + usual way; a `done` brings it back through `resume`, and the gate clears the + policy hold only with a verified trusted approval of the current request. 8 β†’ πŸ™‹ needs you. Stdout carries one `needs-you: <command>` line per human step (migration for an env outside `migrations.allowed_envs`, an allowed migrate command that failed, a `needs-you:` line in the PR body, `migrations.human_steps`). Card Review β†’ **Blocked** with the πŸ™‹ template (block-template.md β†’ "πŸ™‹ Needs you"), the commands copied verbatim into `To unblock`, label `needs-you` on issue and PR, `blocked-by: -`. When the - human comments `done` (or labels `needs-you:done`), the wave planner's - `resume` list brings it back to Review and this gate re-runs. + human comments `done` after the current pinned request, the wave planner + verifies their permission and the head before resuming Review. For exits + 7 and 8, copy the gate's `approval-request:` line into the canonical issue + block; link it from the PR without duplicating the request. β†’ do NOT leave a card in Review on exit 2, 3, 5, 7 or 8. A card left in Review is re-picked next tick and re-reviewed forever, which is the re-dispatch waste tracked in issue #10. Exits 4 and 6 are the exceptions: 4 simply queued, and @@ -648,7 +650,7 @@ usual template β€” and, per Β§4, a `blocked-by:` line. ### The wave-start sweep -Before planning any wave, `super-board-wave-plan.sh` reports four lists the orchestrator must act on +Before planning any wave, `super-board-wave-plan.sh` reports five lists the orchestrator must act on **before** launching: - **`sweep`** β€” `Blocked` cards whose blockers have all closed. Move each to `Ready` and comment @@ -657,12 +659,17 @@ Before planning any wave, `super-board-wave-plan.sh` reports four lists the orch so a wave stopped mid-build leaves them there forever. Remove any leftover build worktree, keep the branch, move the card to `Ready`, and comment naming the branch. The legacy dispatcher does the same once at start (`reclaim_stranded_building`). -- **`resume`** β€” πŸ™‹ `Blocked` cards (merge gate exit 7 or 8) whose human step is confirmed: the `needs-you:done` label, or a - comment after the πŸ™‹ block that reads just `done`. Move each to **Review** (not Ready β€” the code - was already reviewed), add `needs-you:done` to its PR, and comment `↩️ back to Review β€” human step - confirmed; the merge gate re-verifies and merges. Next: Reviewer.` The Reviewer re-runs the gate, - which re-checks the head and the build, re-runs the allowed migrations and merges, or sends it - straight back to Blocked if a command still fails. +- **`resume`** β€” πŸ™‹ `Blocked` cards whose newest pinned request has a later `done` + from a human with verified write, maintain or admin permission, for the current + PR head. Move each to **Review**; `needs-you:done` may be kept as a display label + only. Do not create or copy approval evidence. The Reviewer re-runs the gate, + which independently checks the request, current policy/steps and head, verifies + the build, and runs allowed migrations. New code or a newer request needs fresh + human approval; a label or old bare comment cannot bypass that hold. +- **`refreshApproval`** β€” a PR head changed while its card was still `Blocked`. + Move it to **Review** and remove stale `needs-you:done` display labels. The Reviewer + reviews the new code and lets the gate produce a new pinned request, then returns + it to **Blocked** for a fresh human reply. This is not an approved resume. - **`flag`** β€” cards whose `## Blocked by` section could not be parsed. Leave them where they are and comment asking for the line to be fixed, quoting the `why`. Never guess: a card treated as free on an unreadable line gets built against a base that does not have what it needs. diff --git a/skills/super-board/references/writing-standard.md b/skills/super-board/references/writing-standard.md index 123fe614..17973968 100644 --- a/skills/super-board/references/writing-standard.md +++ b/skills/super-board/references/writing-standard.md @@ -345,3 +345,12 @@ Tables plus CAPS rules (NEVER / DON'T / ALWAYS). No prose paragraphs. The manage - Numbers over adjectives: "1.2 s", not "fast". - Active voice: "QA found", not "it was found". - No em-dash chains. No rhetorical questions. No closing summary line. + +## Human approval evidence + +A πŸ™‹ merge hold has one canonical request comment on the linked issue (or on the PR +when it has no linked issue). Copy the gate's `approval-request:` line verbatim into +that comment; it pins the repository, PR, full head and policy/human-step scope. +Other threads link to that request without repeating its machine lines. A trusted +human replies `done` in that same thread. Labels are status display, never approval. +Do not edit the request or approval comment; changed code or steps need a fresh request. diff --git a/skills/super-review/SKILL.md b/skills/super-review/SKILL.md index 3aba4e72..963bc6d3 100644 --- a/skills/super-review/SKILL.md +++ b/skills/super-review/SKILL.md @@ -349,8 +349,10 @@ two complete builds. In order, no shortcuts: human-only step. Card β†’ **Blocked** with the πŸ™‹ template (`block-template.md` β†’ "πŸ™‹ Needs you"), the gate's `needs-you:` commands copied verbatim into `To unblock`, label `needs-you`, `blocked-by: -`. After the human - comments `done` (or labels `needs-you:done`), the next wave moves it back to Review - and you re-run the gate; it re-verifies and merges. + comments `done` after the current pinned request, the next wave verifies their + permission and the head, then moves it to Review. Re-run the gate; labels do not + authorize it. For exits 7 and 8, copy `approval-request:` into the canonical + issue block and link it from the PR (no duplicate request); see block-template.md. - The rule in one line: the robot migrates the databases it was allowed to test its work; live databases, money, auth and destructive schema changes wait for a person. 3. **Confirm the merge landed** β€” never trust the merge command's exit code: diff --git a/tests/test-deps.sh b/tests/test-deps.sh index efefcf9d..235675ef 100755 --- a/tests/test-deps.sh +++ b/tests/test-deps.sh @@ -104,11 +104,11 @@ get 24 '.blockers == [2] and .runnable == false' || fail "#24: the body should s get 1 '.blockers == [2]' || fail "a payload without a comments key must still parse" # 18 β€” πŸ™‹ needs you. A πŸ™‹ Block comment marks the card needsYou and keeps it -# human-gated; "done" in a LATER comment, or the needs-you:done label, -# marks it needsYouDone. A "done" written before the block is history. +# human-gated. Bare done comments and labels cannot prove head/authority; +# only the live approval helper can mark needsYouDone (test_approval.py). get 25 '.needsYou == true and .needsYouDone == false and .runnable == false' || fail "#25 waits on a human" -get 26 '.needsYouDone == true' || fail "#26: a later 'Done' comment confirms the human step" -get 27 '.needsYouDone == true' || fail "#27: the needs-you:done label confirms the human step" +get 26 '.needsYouDone == false' || fail "#26: a bare done with no verified head/request cannot authorize" +get 27 '.needsYouDone == false' || fail "#27: a label alone cannot authorize" get 28 '.needsYouDone == false' || fail "#28: a 'done' before the πŸ™‹ block must not count" get 26 '.runnable == false' || fail "#26: done goes through resume, never the Ready sweep" get 2 '.needsYou == false and .needsYouDone == false' || fail "an ordinary card is not needsYou" diff --git a/tests/test-merge-gate.sh b/tests/test-merge-gate.sh index 79ab847a..0ad16b71 100755 --- a/tests/test-merge-gate.sh +++ b/tests/test-merge-gate.sh @@ -110,12 +110,27 @@ printf '%s\n' "$*" >> "$GH_LOG" case "$1 $2" in "pr view") case "$*" in *labels*) + if [ -n "${STUB_META_AFTER:-}" ] && [ -f "$STUB_VERIFIED" ]; then printf '%s\n' "$STUB_META_AFTER"; exit 0; fi if [ -n "${STUB_META:-}" ]; then printf '%s\n' "$STUB_META"; else echo '{"files":[],"labels":[]}'; fi exit 0 ;; esac oid="$STUB_OID"; grep -q '^pr merge' "$GH_LOG" && oid="${STUB_OID_AFTER:-$STUB_OID}" echo "feat $oid" ;; "pr merge") exit "${STUB_MERGE_RC:-0}" ;; "pr diff") printf '%b' "${STUB_DIFF:-}" ;; + "api graphql") + [ "${STUB_APPROVAL:-0}" = 1 ] || exit 1 + jq -n --arg head "$STUB_OID" '[{data:{repository:{pullRequest:{headRefOid:$head,state:"OPEN",closingIssuesReferences:{pageInfo:{hasNextPage:false,endCursor:null},nodes:[{number:42,repository:{nameWithOwner:"x/y"}}]}}}}}]' ;; + "api --paginate") + [ "${STUB_APPROVAL:-0}" = 1 ] || exit 1 + case "$*" in + */issues/42/comments*) cat "$STUB_COMMENTS" ;; + */issues/1/comments*) echo '[[]]' ;; + *) exit 1 ;; + esac ;; + "api repos/x/y/collaborators/owner/permission") + [ "${STUB_PERMISSION_FAIL:-0}" = 0 ] || exit 1 + echo '{"permission":"admin"}' ;; + *) exit 1 ;; esac STUB chmod +x "$TMP/bin/gh" @@ -259,10 +274,81 @@ RC=0; STUB_OID="$SHA" STUB_META="$(meta "" db/x.sql)" STUB_DIFF='-DROP TABLE use [ "$RC" -eq 0 ] || fail "a removed DROP TABLE must not gate, got $RC" teardown -# 16b β€” a human approved the policy gate (needs-you:done): the gate merges. +# 16b β€” a stale UI label cannot approve a policy gate. head_setup RC=0; STUB_OID="$SHA" STUB_META="$(meta money,needs-you:done src/x.ts)" gate --expect-head "$SHA" >/dev/null 2>&1 || RC=$? -[ "$RC" -eq 0 ] || fail "needs-you:done should clear the policy gate, got $RC" +[ "$RC" -eq 7 ] || fail "a needs-you:done label alone must not authorize a policy bypass, got $RC" +teardown + +# 16c β€” a trusted done confirms the request emitted for the exact current head. +# The request exists before the human comment; processing never binds old done +# to whatever head happens to exist now. +head_setup +export STUB_APPROVAL=1 STUB_COMMENTS="$TMP/comments.json" +echo '[[]]' > "$STUB_COMMENTS" +OUT=$(STUB_OID="$SHA" STUB_META="$(meta money src/x.ts)" gate --expect-head "$SHA" 2>/dev/null || true) +REQUEST=$(echo "$OUT" | sed -n 's/^approval-request: //p') +[ -n "$REQUEST" ] || fail "a hold must emit a pinned request" +make_approval() { + jq -n --argjson request "$REQUEST" '[[ + {id:1,body:("Reason tag: πŸ™‹ needs you\napproval-request: " + ($request|tojson)),created_at:"2026-10-03T00:00:01Z",updated_at:"2026-10-03T00:00:01Z",user:{login:"owner",type:"User"}}, + {id:2,body:"done",created_at:"2026-10-03T00:00:02Z",updated_at:"2026-10-03T00:00:02Z",user:{login:"owner",type:"User"}} + ]]' > "$STUB_COMMENTS" +} +make_approval +RC=0; STUB_OID="$SHA" STUB_META="$(meta money src/x.ts)" gate --expect-head "$SHA" >/dev/null 2>&1 || RC=$? +[ "$RC" -eq 0 ] || fail "current trusted approval must merge, got $RC" +: > "$GH_LOG" +# A later code version cannot reuse the same human's done. +REQUEST=$(echo "$REQUEST" | jq '.head = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"') +make_approval +RC=0; STUB_OID="$SHA" STUB_META="$(meta money,needs-you:done src/x.ts)" gate --expect-head "$SHA" >/dev/null 2>&1 || RC=$? +[ "$RC" -eq 7 ] || fail "old-head approval must hold, got $RC" +grep -q '^pr merge' "$GH_LOG" && fail "old approval must never reach merge" +# Neither unreadable authority nor a newer human block can authorize. +REQUEST=$(echo "$REQUEST" | jq --arg head "$SHA" '.head=$head') +make_approval +RC=0; STUB_PERMISSION_FAIL=1 STUB_OID="$SHA" STUB_META="$(meta money src/x.ts)" gate --expect-head "$SHA" >/dev/null 2>&1 || RC=$? +[ "$RC" -eq 7 ] || fail "unreadable authority must hold, got $RC" +jq '.[0] += [{id:3,body:"Reason tag: πŸ™‹ new decision",created_at:"2026-10-03T00:00:03Z",updated_at:"2026-10-03T00:00:03Z",user:{login:"owner",type:"User"}}]' "$STUB_COMMENTS" > "$TMP/new.json" +mv "$TMP/new.json" "$STUB_COMMENTS" +RC=0; STUB_OID="$SHA" STUB_META="$(meta money,needs-you:done src/x.ts)" gate --expect-head "$SHA" >/dev/null 2>&1 || RC=$? +[ "$RC" -eq 7 ] || fail "new human block must revoke prior approval, got $RC" +# Verify takes time: a same-head body edit adds a new human step during it. +make_approval +export STUB_VERIFIED="$TMP/verified" +jq --arg cmd "touch $STUB_VERIFIED" '.verify_commands=[$cmd]' "$TMP/c.json" > "$TMP/new.json" +mv "$TMP/new.json" "$TMP/c.json" +RC=0; STUB_META_AFTER="$(meta money src/x.ts 'needs-you: run a new command')" STUB_OID="$SHA" STUB_META="$(meta money src/x.ts)" gate --expect-head "$SHA" >/dev/null 2>&1 || RC=$? +[ "$RC" -eq 7 ] || fail "changed human steps during verify must revoke approval, got $RC" +grep -q '^pr merge' "$GH_LOG" && fail "stale approval after verification must not merge" +unset STUB_APPROVAL STUB_COMMENTS STUB_VERIFIED +teardown + +# 16d β€” head-bound approval also covers a declared human-only command; label +# changes cannot grant it, and a later human request during verification revokes it. +head_setup +export STUB_APPROVAL=1 STUB_COMMENTS="$TMP/comments.json" +echo '[[]]' > "$STUB_COMMENTS" +OUT=$(STUB_OID="$SHA" STUB_META="$(meta '' src/x.ts 'needs-you: confirm live setting')" gate --expect-head "$SHA" 2>/dev/null || true) +REQUEST=$(echo "$OUT" | sed -n 's/^approval-request: //p') +make_approval +RC=0; STUB_OID="$SHA" STUB_META="$(meta '' src/x.ts 'needs-you: confirm live setting')" gate --expect-head "$SHA" >/dev/null 2>&1 || RC=$? +[ "$RC" -eq 0 ] || fail "trusted confirmation of current human step must merge, got $RC" +: > "$GH_LOG" +cat > "$TMP/new-block.py" <<'NEWBLOCK' +import json, os +p=os.environ["STUB_COMMENTS"] +x=json.load(open(p)) +x[0].append({"id":3,"body":"Reason tag: πŸ™‹ a new human decision","created_at":"2026-10-03T00:00:03Z","updated_at":"2026-10-03T00:00:03Z","user":{"login":"owner","type":"User"}}) +with open(p,"w") as f: json.dump(x,f) +NEWBLOCK +jq --arg cmd "python3 $TMP/new-block.py" '.verify_commands=[$cmd]' "$TMP/c.json" > "$TMP/new.json" +mv "$TMP/new.json" "$TMP/c.json" +RC=0; STUB_OID="$SHA" STUB_META="$(meta '' src/x.ts 'needs-you: confirm live setting')" gate --expect-head "$SHA" >/dev/null 2>&1 || RC=$? +[ "$RC" -eq 8 ] || fail "a newer human request during verification must hold, got $RC" +grep -q '^pr merge' "$GH_LOG" && fail "revoked human approval must not reach merge" +unset STUB_APPROVAL STUB_COMMENTS teardown # 17 β€” custom categories replace the defaults: with always_human = {} a money @@ -295,12 +381,12 @@ echo "$OUT" | grep -q "^needs-you: npm run db:migrate:live" || fail "exit 8 must grep -q '^pr merge' "$GH_LOG" && fail "no merge while a human step is open" teardown -# 20 β€” the human ran it and the PR carries needs-you:done: re-verify, merge. +# 20 β€” a UI label cannot confirm that human-only steps ran. head_setup cfgset ".migrations = {allowed_envs: [\"staging\"], target_env: \"live\", commands: {staging: \"true\", live: \"npm run db:migrate:live\"}}" RC=0; STUB_OID="$SHA" STUB_META="$(meta needs-you:done prisma/migrations/2026/migration.sql)" gate --expect-head "$SHA" >/dev/null 2>&1 || RC=$? -[ "$RC" -eq 0 ] || fail "needs-you:done should let the gate merge, got $RC" +[ "$RC" -eq 8 ] || fail "a needs-you:done label alone must not confirm human steps, got $RC" teardown # 21 β€” an allowed migrate command that FAILS is a needs-you, and needs-you:done @@ -344,4 +430,4 @@ pol "$(meta "" a/b/migrations/1.sql)" | jq -e '.migrations | length == 1' > pol "$(meta "" migrations/old/1.sql)" | jq -e '.migrations | length == 0' >/dev/null || fail "* must not cross a /" rm -rf "$T25" -echo "PASS: test-merge-gate.sh (27 scenarios)" +echo "PASS: test-merge-gate.sh (merge, policy and approval scenarios)" diff --git a/tests/test-wave-plan.sh b/tests/test-wave-plan.sh index 516e187f..fa5d1a81 100755 --- a/tests/test-wave-plan.sh +++ b/tests/test-wave-plan.sh @@ -148,4 +148,9 @@ echo "$OUT" | jq -e '[.resume[].number] == [20]' >/dev/null \ echo "$OUT" | jq -e '[.sweep[].number, .cards[].number] | (index(20) == null and index(21) == null)' >/dev/null \ || fail "πŸ™‹ cards must not be swept to Ready or dispatched" -echo "PASS: test-wave-plan.sh (18 scenarios)" +# 19 β€” a moved head while Blocked goes to request refresh, never approved resume. +OUT19=$("$PLAN" --config <(echo "$NOCAP") --items "$ITEMS" --deps <(jq '.["21"].approvalRefresh=true | .["21"].approvalWhy="PR head changed"' "$DEPS")) +echo "$OUT19" | jq -e '[.refreshApproval[].number] == [21]' >/dev/null || fail "stale approval must get a fresh request" +echo "$OUT19" | jq -e '[.resume[].number, .cards[].number, .sweep[].number] | index(21) == null' >/dev/null || fail "refresh is not permission to merge or build" + +echo "PASS: test-wave-plan.sh (19 scenarios)" diff --git a/tests/test_approval.py b/tests/test_approval.py new file mode 100644 index 00000000..9b4fcce6 --- /dev/null +++ b/tests/test_approval.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python3 +"""Offline approval lifecycle tests: no board writes, workers or databases.""" +import importlib.util +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location("approval", ROOT / "scripts/super-board-approval.py") +approval = importlib.util.module_from_spec(spec) +spec.loader.exec_module(approval) +HEAD = "a" * 40 +PLAN = {"human": [{"category": "auth", "why": "login"}], "needs_you": ["run live migration"]} +REQUEST = approval.request_for("x/y", 9, HEAD, approval.scope_for(PLAN)) + + +def comment(body, second, login="owner", actor="User", source=3): + at = f"2026-10-03T00:00:{second:02d}Z" + return {"id": second, "body": body, "created_at": at, "updated_at": at, + "user": {"login": login, "type": actor}, "source": source} + + +def evidence(): + return [comment("Reason tag: πŸ™‹ needs you\n" + approval.marker(REQUEST), 1), comment("done", 2)] + + +class ApprovalTests(unittest.TestCase): + def check(self, comments=None, expected=None, permissions=None): + return approval.validate(comments if comments is not None else evidence(), expected or REQUEST, + lambda login: (permissions or {"owner": "admin"}).get(login)) + + def test_current_request_allows_solo_owner(self): + self.assertTrue(self.check()["approved"]) + + def test_old_approval_never_covers_new_head(self): + self.assertFalse(self.check(expected=dict(REQUEST, head="b" * 40))["approved"]) + + def test_done_before_processing_does_not_get_rebound_to_new_head(self): + records = evidence() + self.assertFalse(self.check(records, dict(REQUEST, head="c" * 40))["approved"]) + + def test_changed_human_steps_revoke_same_head(self): + newer = dict(REQUEST, scope=approval.scope_for(dict(PLAN, needs_you=["different operation"]))) + self.assertFalse(self.check(expected=newer)["approved"]) + + def test_label_only_or_bare_done_is_not_approval(self): + self.assertFalse(self.check([comment("done", 2)])["approved"]) + + def test_new_block_invalidates_old_done_even_without_marker(self): + self.assertFalse(self.check(evidence() + [comment("Reason tag: πŸ™‹ needs a new decision", 3)])["approved"]) + + def test_new_request_requires_new_done(self): + newer = evidence() + [comment(approval.marker(REQUEST), 3)] + self.assertFalse(self.check(newer)["approved"]) + newer.append(comment("Done!", 4)) + self.assertTrue(self.check(newer)["approved"]) + + def test_wrong_pr_or_repository_is_rejected(self): + for key, value in (("pr", 10), ("repo", "other/repo")): + self.assertFalse(self.check(expected=dict(REQUEST, **{key: value}))["approved"]) + + def test_untrusted_approval_and_request_are_rejected(self): + for index in (0, 1): + records = evidence() + records[index]["user"]["login"] = "visitor" + self.assertFalse(self.check(records)["approved"]) + self.assertFalse(self.check(permissions={"owner": "read"})["approved"]) + + def test_verified_non_writer_cannot_replace_trusted_request(self): + records = evidence() + [comment("Reason tag: πŸ™‹ forged request", 3, login="visitor")] + self.assertTrue(self.check(records, permissions={"owner": "admin", "visitor": "read"})["approved"]) + + def test_bot_done_is_not_a_human_approval(self): + records = evidence() + records[1]["user"]["type"] = "Bot" + self.assertFalse(self.check(records)["approved"]) + + def test_missing_actor_or_head_holds(self): + records = evidence() + del records[1]["user"]["type"] + self.assertFalse(self.check(records)["approved"]) + self.assertFalse(self.check(expected=dict(REQUEST, head=""))["approved"]) + + def test_edited_request_or_done_holds(self): + for index in (0, 1): + records = evidence() + records[index]["updated_at"] = "2026-10-03T00:00:05Z" + self.assertFalse(self.check(records)["approved"]) + + def test_done_on_another_thread_does_not_confirm_request(self): + records = evidence() + records[1]["source"] = 9 + self.assertFalse(self.check(records)["approved"]) + + def test_new_pr_request_invalidates_issue_approval(self): + self.assertFalse(self.check(evidence() + [comment(approval.marker(REQUEST), 3, source=9)])["approved"]) + + def test_ambiguous_same_second_requests_hold(self): + self.assertFalse(self.check(evidence() + [comment(approval.marker(REQUEST), 1, source=9)])["approved"]) + + def test_repeat_checks_do_not_invalidate_approval(self): + records = evidence() + self.assertEqual(self.check(records), self.check(records)) + self.assertTrue(self.check(records)["approved"]) + + def test_ui_done_label_does_not_change_scope(self): + self.assertEqual(approval.scope_for(PLAN), approval.scope_for(dict(PLAN, done=True))) + + def test_missing_time_and_malformed_marker_hold(self): + records = evidence() + del records[0]["created_at"] + self.assertFalse(self.check(records)["approved"]) + records = evidence() + records[0]["body"] = "approval-request: {broken" + self.assertFalse(self.check(records)["approved"]) + + +class GitHubTests(unittest.TestCase): + def setUp(self): + self.github = approval.GitHub("x/y") + self.pr_page = {"data": {"repository": {"pullRequest": {"headRefOid": HEAD, "state": "OPEN", + "closingIssuesReferences": {"pageInfo": {"hasNextPage": False, "endCursor": None}, + "nodes": [{"number": 3, "repository": {"nameWithOwner": "x/y"}}]}}}}} + self.calls = [] + self.records = evidence() + self.permission_error = False + def read(*args): + self.calls.append(args) + if "graphql" in args: + return [self.pr_page] + if "collaborators" in args[-1]: + if self.permission_error: + raise ValueError("unavailable") + return {"permission": "admin"} + if "/issues/3/" in args[-1]: + # Approval is on page 2; taking only the first page would hold. + return [[self.records[0]], self.records[1:]] + if "/issues/9/" in args[-1]: + return [[]] + raise AssertionError(args) + self.github.read = read + + def test_paginated_comments_and_current_permission_are_used(self): + self.assertTrue(self.github.check(9, HEAD, REQUEST["scope"])["approved"]) + self.assertTrue(any("--paginate" in c for c in self.calls)) + self.assertTrue(any("/collaborators/owner/permission" in c[-1] for c in self.calls)) + + def test_changed_remote_head_holds(self): + self.pr_page["data"]["repository"]["pullRequest"]["headRefOid"] = "b" * 40 + self.assertFalse(self.github.check(9, HEAD, REQUEST["scope"])["approved"]) + + def test_incomplete_link_pagination_is_not_accepted(self): + self.pr_page["data"]["repository"]["pullRequest"]["closingIssuesReferences"]["pageInfo"]["hasNextPage"] = True + with self.assertRaises(ValueError): + self.github.check(9, HEAD, REQUEST["scope"]) + + def test_unlinked_issue_cannot_supply_approval(self): + self.assertFalse(self.github.check(9, HEAD, REQUEST["scope"], issue=77)["approved"]) + + def test_unreadable_permission_never_approves(self): + self.permission_error = True + self.assertFalse(self.github.check(9, HEAD, REQUEST["scope"])["approved"]) + + +class DependencyIntegrationTests(unittest.TestCase): + def test_live_planner_resume_requires_current_trusted_request(self): + with tempfile.TemporaryDirectory() as td: + work = Path(td) + records = evidence() + records.append(comment("Reason tag: πŸ™‹ forged request", 3, login="visitor")) + issue = {"number": 3, "title": "Login", "state": "OPEN", "body": "## Blocked by\n- None.", + "labels": {"nodes": [{"name": "needs-you:done"}, {"name": "needs-you"}]}, + "comments": {"nodes": [{"body": c["body"]} for c in records]}} + fixture = {"records": records, "head": HEAD, "issue": issue} + fixture_path = work / "fixture.json" + stub = work / "gh" + stub.write_text('''#!/usr/bin/env python3 +import json, os, sys +f=json.load(open(os.environ["APPROVAL_FIXTURE"])) +args=" ".join(sys.argv[1:]) +if "issues(states:OPEN" in args: + out=[{"data":{"repository":{"issues":{"nodes":[f["issue"]]}}}}] +elif "graphql" in args: + out=[{"data":{"repository":{"pullRequest":{"headRefOid":f["head"],"state":"OPEN","closingIssuesReferences":{"pageInfo":{"hasNextPage":False},"nodes":[{"number":3,"repository":{"nameWithOwner":"x/y"}}]}}}}}] +elif "/issues/3/comments" in args: + out=[f["records"]] +elif "/issues/9/comments" in args: + out=[[]] +elif "/collaborators/owner/permission" in args: + out={"permission":"admin"} +elif "/collaborators/visitor/permission" in args: + out={"permission":"read"} +else: + sys.exit(1) +print(json.dumps(out)) +''') + stub.chmod(0o755) + env = dict(os.environ, PATH=str(work) + os.pathsep + os.environ["PATH"], APPROVAL_FIXTURE=str(fixture_path)) + def deps(): + fixture_path.write_text(json.dumps(fixture)) + run = subprocess.run([str(ROOT / "scripts/super-board-deps.sh"), "--repo", "x/y"], env=env, + capture_output=True, text=True, check=True) + return json.loads(run.stdout)["3"] + self.assertTrue(deps()["needsYouDone"], "current approval resumes even after an outsider fake block") + fixture["head"] = "b" * 40 + self.assertFalse(deps()["needsYouDone"], "new code must never inherit old done or label") + self.assertTrue(deps().get("approvalRefresh"), "stale request must be sent for fresh review/request") + fixture["head"] = HEAD + fixture["records"].append(comment("Reason tag: πŸ™‹ a new maintainer request", 4)) + self.assertFalse(deps()["needsYouDone"], "a newer maintainer request must block resume") + + +if __name__ == "__main__": + unittest.main() From 39ce91bbb3656aa0aa914400ca051be9b6094836 Mon Sep 17 00:00:00 2001 From: Eric Tech <146783360+EricTechPro@users.noreply.github.com> Date: Sat, 3 Oct 2026 11:24:36 -0700 Subject: [PATCH 2/4] =?UTF-8?q?=F0=9F=90=9B=20[fix]=20github:=20pause=20ru?= =?UTF-8?q?ns=20when=20required=20reads=20fail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Retry required reads up to three times and reject incomplete evidence. - Preserve a shared local halt until explicit checked recovery. - Stop dispatch and unsafe writes; reconcile uncertain merge outcomes once. - Pass 36 safety suites and independent review. --- CLAUDE.md | 2 + install.sh | 2 +- scripts/super-board-approval.py | 19 +- scripts/super-board-deps.sh | 8 +- scripts/super-board-gh-guard.sh | 6 +- scripts/super-board-github-read.py | 341 ++++++++++++++++++ scripts/super-board-merge-gate.sh | 41 ++- scripts/super-board-pr-body.sh | 6 +- scripts/super-board-preflight.sh | 13 +- scripts/super-board-run.sh | 103 ++++-- scripts/super-board-wave-plan.sh | 7 +- scripts/super-qa-file-bug.sh | 32 +- scripts/super-review-file-refactor.sh | 41 ++- skills/super-board/references/run-workflow.md | 29 ++ skills/super-board/references/run.md | 21 +- .../scripts/super-collect-file.sh | 3 + skills/super-review/SKILL.md | 5 + tests/test-collect-file.sh | 15 +- tests/test-file-bug.sh | 25 +- tests/test-file-refactor.sh | 21 +- tests/test-merge-gate.sh | 75 +++- tests/test-pr-body.sh | 1 + tests/test-preflight.sh | 4 +- tests/test-run-gates.sh | 3 +- tests/test-workflow-halt.sh | 48 +++ tests/test_approval.py | 4 +- tests/test_github_reads.py | 223 ++++++++++++ workflows/super-board-wave.js | 33 +- 28 files changed, 997 insertions(+), 134 deletions(-) create mode 100644 scripts/super-board-github-read.py create mode 100644 tests/test-workflow-halt.sh create mode 100644 tests/test_github_reads.py diff --git a/CLAUDE.md b/CLAUDE.md index fbc9e40d..5c36c871 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,6 +29,8 @@ If a problem surfaces during the run, the orchestrator's reply is: "I saw X. Wan Workers (`super-build`, `super-qa`, `super-review`) share the dispatcher's `gh` token bucket. They MUST: +- Required GitHub reads use `.claude/bin/super-board-github-read.py`; exit 79 stops the run. +- Check its `--check` before GitHub writes, migrations, merges, and new dispatch; preserve work when halted. - Source `.claude/bin/super-board-gh-guard.sh` (`scripts/` in this repo) at worker start. - Call `sb_gh_guard_check 200` before any burst of `gh` calls. - Prefer local `git blame` / `git log` over `gh api graphql` for any sub-agent that doesn't need fresh state. diff --git a/install.sh b/install.sh index e642ee63..0e764365 100755 --- a/install.sh +++ b/install.sh @@ -145,7 +145,7 @@ copy_file() { fi } -for script in super-board-run.sh super-board-gh-guard.sh super-board-status.py super-board-wave-plan.sh super-board-deps.sh super-board-preflight.sh super-board-merge-gate.sh super-board-merge-policy.py super-board-approval.py super-board-env-check.sh super-board-agents-md.py super-board-settings.py super-board-setup.py super-board-usage.sh super-board-pr-body.sh super-review-file-refactor.sh super-qa-file-bug.sh super-board-stop.sh; do +for script in super-board-github-read.py super-board-run.sh super-board-gh-guard.sh super-board-status.py super-board-wave-plan.sh super-board-deps.sh super-board-preflight.sh super-board-merge-gate.sh super-board-merge-policy.py super-board-approval.py super-board-env-check.sh super-board-agents-md.py super-board-settings.py super-board-setup.py super-board-usage.sh super-board-pr-body.sh super-review-file-refactor.sh super-qa-file-bug.sh super-board-stop.sh; do if [ -f "$REPO_ROOT/scripts/$script" ]; then copy_file "$REPO_ROOT/scripts/$script" "$TARGET/.claude/bin/$script" chmod +x "$TARGET/.claude/bin/$script" diff --git a/scripts/super-board-approval.py b/scripts/super-board-approval.py index c48ce87e..2f161e96 100644 --- a/scripts/super-board-approval.py +++ b/scripts/super-board-approval.py @@ -7,6 +7,8 @@ from __future__ import annotations import argparse +import importlib.util +from pathlib import Path from datetime import datetime import hashlib import json @@ -14,6 +16,10 @@ import subprocess import sys +_reader_spec = importlib.util.spec_from_file_location("github_read", Path(__file__).with_name("super-board-github-read.py")) +github_read = importlib.util.module_from_spec(_reader_spec) +_reader_spec.loader.exec_module(github_read) + PREFIX = "approval-request: " SHA = re.compile(r"[0-9a-f]{40}\Z") SCOPE = re.compile(r"[0-9a-f]{64}\Z") @@ -129,10 +135,9 @@ def __init__(self, repo): @staticmethod def read(*args): - result = subprocess.run(["gh", *args], capture_output=True, text=True, timeout=30) - if result.returncode: - raise ValueError("GitHub evidence could not be read") - return json.loads(result.stdout) + kind = ("approval" if "graphql" in args else "comments" if "--paginate" in args + else "permission" if args[-1].endswith("/permission") else "json") + return github_read.read(list(args), kind)[1] def permission(self, login): if not re.fullmatch(r"[A-Za-z0-9_-]+(?:\[bot\])?", login): @@ -245,4 +250,8 @@ def main(): if __name__ == "__main__": - main() + try: + main() + except github_read.ReadHalted as error: + print(f"GitHub read halt: {error}", file=sys.stderr) + sys.exit(github_read.HALTED) diff --git a/scripts/super-board-deps.sh b/scripts/super-board-deps.sh index 523e9611..38d8449d 100755 --- a/scripts/super-board-deps.sh +++ b/scripts/super-board-deps.sh @@ -59,6 +59,7 @@ # The sweep must never free one of those; only a person can. set -euo pipefail +HERE=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) REPO=""; LIMIT="200"; ONLY=""; FROM="" while [ $# -gt 0 ]; do case "$1" in @@ -80,7 +81,7 @@ else # lives in a COMMENT and the REST list cannot return comments. One query gets # bodies and comments together; `gh issue view` per issue would be one REST # call each, and a 35-card board burns that budget for no reason. - ISSUES=$(gh api graphql --paginate --slurp -f query=' + PAGES=$(python3 "$HERE/super-board-github-read.py" --kind issues -- api graphql --paginate --slurp -f query=' query($owner:String!,$repo:String!,$endCursor:String){ repository(owner:$owner,name:$repo){ issues(states:OPEN,first:100,after:$endCursor){ @@ -88,9 +89,8 @@ else nodes{ number title body state labels(first:30){ nodes{ name } } comments(last:8){ nodes{ body } } } } } - }' -F owner="${REPO%%/*}" -F repo="${REPO##*/}" 2>/dev/null \ - | jq '[ .[].data.repository.issues.nodes[] ]') || { - echo "could not read issues for $REPO" >&2; exit 69; } + }' -F owner="${REPO%%/*}" -F repo="${REPO##*/}") || exit $? + ISSUES=$(printf '%s' "$PAGES" | jq '[ .[].data.repository.issues.nodes[] ]') fi APPROVAL_REPO="$REPO" diff --git a/scripts/super-board-gh-guard.sh b/scripts/super-board-gh-guard.sh index 03f0fa55..dbb03d4b 100755 --- a/scripts/super-board-gh-guard.sh +++ b/scripts/super-board-gh-guard.sh @@ -22,12 +22,16 @@ SB_GH_GUARD_BUDGET_DEFAULT=150 # per-worker soft cap on gh calls SB_GH_GUARD_SUBAGENT_BUDGET=50 # per adversarial-mode sub-agent cap SB_GH_GUARD_STATE_FILE="${SB_GH_GUARD_STATE_FILE:-${TMPDIR:-/tmp}/super-board-gh-budget-$$}" +SB_GITHUB_READ="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/super-board-github-read.py" +sb_gh_required_read() { local kind="$1"; shift; python3 "$SB_GITHUB_READ" --kind "$kind" -- "$@"; } +sb_gh_halt_check() { python3 "$SB_GITHUB_READ" --check; } + sb_gh_guard_check() { # Sleep until GraphQL quota recovers. Also checks REST. # Arg 1: optional minimum-remaining threshold (default 200). local min="${1:-$SB_GH_GUARD_MIN_REMAINING_DEFAULT}" local payload graphql_remaining graphql_reset rest_remaining now wait - payload=$(gh api rate_limit 2>/dev/null || echo '{"resources":{"graphql":{"remaining":5000,"reset":0},"core":{"remaining":5000,"reset":0}}}') + payload=$(sb_gh_required_read quota api rate_limit) || return $? graphql_remaining=$(echo "$payload" | jq -r '.resources.graphql.remaining // 5000') rest_remaining=$(echo "$payload" | jq -r '.resources.core.remaining // 5000') diff --git a/scripts/super-board-github-read.py b/scripts/super-board-github-read.py new file mode 100644 index 00000000..91538e58 --- /dev/null +++ b/scripts/super-board-github-read.py @@ -0,0 +1,341 @@ +#!/usr/bin/env python3 +"""Bounded, read-only GitHub evidence. Exit 79 stops this repository's board run. + +A logical read gets three total attempts. Success resets its own sequence; other +reads cannot reset it. Permanent query/auth errors stop on the first attempt. +The halt file is shared through the main checkout, including linked worktrees. +Only an explicit new run clears it with --resume after preserving the old reason. +Mutations never enter this retry helper. +""" +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import re +import subprocess +import sys +import time + +HALTED = 79 + + +class ReadHalted(Exception): + pass + + +def halt_path(): + override = os.environ.get('SB_GITHUB_HALT_FILE') + if override: + return Path(override) + result = subprocess.run(['git', 'rev-parse', '--git-common-dir'], capture_output=True, + text=True, timeout=5, cwd=os.environ.get('SB_REPO_PATH')) + cwd = Path(os.environ.get('SB_REPO_PATH') or Path.cwd()) + common = Path(result.stdout.strip()) + root = (cwd / common).resolve().parent if result.returncode == 0 else cwd + return root / '.claude/super-board/github-halt.json' + + +def check_halt(): + if halt_path().exists(): + raise ReadHalted('GitHub reads are halted; inspect the saved reason and explicitly restart the run') + + +def halt(reason, attempts, operation=None): + path = halt_path() + path.parent.mkdir(parents=True, exist_ok=True) + # Exclusive creation keeps the first failure's evidence when workers trip together. + try: + with path.open('x') as stream: + json.dump({'reason': reason, 'attempts': attempts, 'at': int(time.time()), 'operation': operation}, stream) + except FileExistsError: + pass + raise ReadHalted(reason) + + +def read_only(args): + if len(args) < 2: + return False + if args[0] in ('pr', 'issue', 'project', 'repo'): + return args[1] in ('view', 'list', 'diff', 'checks', 'item-list', 'field-list') + if args[0] != 'api': + return False + if any(a == '--input' or a.startswith('--input=') for a in args): + return False + if 'graphql' in args: + # GraphQL queries use POST, but only a literal query may be retried. + queries = [arg[6:] for arg in args if arg.startswith('query=')] + return (len(queries) == 1 and re.match(r'\s*query\b', queries[0]) is not None + and re.search(r'\b(?:mutation|subscription)\b', queries[0]) is None + and not any(a.startswith('operationName=') for a in args)) + for i, arg in enumerate(args): + if arg in ('-X', '--method') and (i + 1 == len(args) or args[i + 1] != 'GET'): + return False + if arg.startswith('-X') and arg not in ('-X', '-XGET'): + return False + if arg.startswith(('-f', '-F')): + return False + if arg.startswith('--method=') and arg != '--method=GET': + return False + if arg in ('-f', '-F', '--field', '--raw-field', '--input') or arg.startswith(('--field=', '--raw-field=', '--input=')): + return False + return True + + +def permanent_error(text): + if re.search(r'rate limit|secondary rate', text, re.I): + return False + return re.search(r'Cannot query field|Unknown (?:argument|type)|Syntax Error|Parse error on|' + r'GRAPHQL_VALIDATION_FAILED|undefinedField|variableRequiresValidType|' + r'HTTP 40[134]|Bad credentials|requires authentication|Resource not accessible|' + r'Could not resolve to (?:a|an) |missing required scopes', text, re.I) is not None + + +def require(condition, why): + if not condition: + raise ValueError(why) + + +def no_errors(value): + pages = value if isinstance(value, list) else [value] + require(not any(isinstance(p, dict) and p.get('errors') for p in pages), 'GraphQL returned errors with incomplete data') + + +def validate(text, kind, meta=None): + if kind == 'scalar': + require(bool(text.strip()), 'required value missing') + return text + if kind == 'issue-number': + require(not text.strip() or re.fullmatch(r'[1-9][0-9]*', text.strip()), 'invalid issue lookup result') + return text + if kind == 'head': + require(re.fullmatch(r'\S+ [0-9a-f]{40}\s*', text) is not None, 'missing PR branch or full head') + return text + if kind == 'state': + require(text.strip() in ('OPEN', 'CLOSED'), 'missing issue state') + return text + if kind == 'diff': + require(isinstance(meta, dict), 'diff metadata missing') + files = meta['files'] + if not files: + require(not text.strip() and meta['additions'] == meta['deletions'] == 0, 'diff and file list disagree') + return text + lines = text.splitlines() + require(sum(line.startswith('diff --git ') for line in lines) == len(files), 'diff file list incomplete') + added = removed = 0 + old_left = new_left = 0 + for line in lines: + if line.startswith('@@ '): + require(old_left == new_left == 0, 'diff hunk truncated') + hunk = re.match(r'@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@', line) + require(hunk is not None, 'diff hunk malformed') + old_left = int(hunk[2] or 1) + new_left = int(hunk[4] or 1) + elif old_left or new_left: + if line.startswith('\\ No newline'): + continue + require(bool(line) and line[0] in ' +-', 'diff hunk data missing') + if line[0] != '+': old_left -= 1 + if line[0] != '-': new_left -= 1 + if line[0] == '+': added += 1 + if line[0] == '-': removed += 1 + require(old_left >= 0 and new_left >= 0, 'diff hunk length disagrees') + elif line and not line.startswith(('diff --git ', 'index ', '--- ', '+++ ', + 'new file mode ', 'deleted file mode ', 'old mode ', 'new mode ', + 'similarity index ', 'dissimilarity index ', 'rename from ', 'rename to ', + 'copy from ', 'copy to ', 'Binary files ', '\\ No newline')): + raise ValueError('unexpected content outside diff hunks') + require(old_left == new_left == 0, 'diff hunk truncated') + require(added == meta['additions'] and removed == meta['deletions'], 'diff line counts incomplete') + return text + value = json.loads(text) + no_errors(value) + if kind == 'metadata': + require(isinstance(value, dict), 'PR metadata is not an object') + require(isinstance(value.get('files'), list) and isinstance(value.get('labels'), list) + and isinstance(value.get('body'), str), 'PR policy fields missing') + require(all(type(value.get(k)) is int and value[k] >= 0 for k in ('additions', 'deletions', 'changedFiles')), 'PR counts missing') + require(value['changedFiles'] == len(value['files']), 'PR file list truncated') + require(all(isinstance(f, dict) and isinstance(f.get('path'), str) and f['path'] for f in value['files']), 'PR paths missing') + require(all(isinstance(l, dict) and isinstance(l.get('name'), str) for l in value['labels']), 'PR labels missing') + elif kind == 'items': + require(isinstance(value, dict) and isinstance(value.get('items'), list), 'board items missing') + require(type(value.get('totalCount')) is int and value['totalCount'] == len(value['items']), 'board item list incomplete') + require(all(isinstance(i, dict) and isinstance(i.get('id'), str) and i['id'] and isinstance(i.get('content'), dict) for i in value['items']), 'board item data missing') + for item in value['items']: + content = item['content'] + require(content.get('type') in ('Issue', 'PullRequest', 'DraftIssue') and isinstance(content.get('title'), str), 'board content identity missing') + if content['type'] != 'DraftIssue': + require(type(content.get('number')) is int and content['number'] > 0, 'board issue number missing') + elif kind == 'issues': + require(isinstance(value, list) and bool(value), 'dependency pages missing') + seen = set() + for index, page in enumerate(value): + connection = page['data']['repository']['issues'] + nodes, info = connection['nodes'], connection['pageInfo'] + require(isinstance(nodes, list) and type(info['hasNextPage']) is bool, 'dependency page malformed') + require(info['hasNextPage'] == (index < len(value) - 1), 'dependency pagination incomplete') + if info['hasNextPage']: + require(isinstance(info.get('endCursor'), str) and info['endCursor'] and info['endCursor'] not in seen, 'dependency cursor repeated or missing') + seen.add(info['endCursor']) + require(all(isinstance(n, dict) and type(n.get('number')) is int and isinstance(n.get('body'), str) + and isinstance(n.get('title'), str) and n.get('state') == 'OPEN' + and isinstance(n.get('comments', {}).get('nodes'), list) + and isinstance(n.get('labels', {}).get('nodes'), list) for n in nodes), 'dependency issue fields missing') + elif kind == 'quota': + resources = value['resources'] + for bucket in ('graphql', 'core'): + require(all(type(resources[bucket].get(k)) is int and resources[bucket][k] >= 0 + for k in ('remaining', 'reset')), 'quota data missing') + elif kind == 'projects': + require(isinstance(value.get('projects'), list), 'project list missing') + elif kind == 'project': + require(isinstance(value, dict) and isinstance(value.get('id'), str) and value['id'], 'project ID missing') + elif kind == 'fields': + require(isinstance(value.get('fields'), list) and any(f.get('name') == 'Status' and f.get('id') and isinstance(f.get('options'), list) for f in value['fields']), 'Status field missing') + elif kind == 'status-field': + data = value['data'] + owner = data.get('user') or data.get('organization') + field = owner['projectV2']['field'] + require(isinstance(field.get('id'), str) and field['id'] and isinstance(field.get('options'), list) + and all(isinstance(o, dict) and o.get('id') and o.get('name') for o in field['options']), 'Status field missing') + elif kind == 'permission': + require(value.get('permission') in ('none', 'read', 'triage', 'write', 'maintain', 'admin'), 'collaborator permission missing') + elif kind == 'comments': + require(isinstance(value, list) and bool(value) and all(isinstance(p, list) for p in value), 'comment pages missing') + require(all(isinstance(c, dict) and type(c.get('id')) is int and isinstance(c.get('body'), str) + and isinstance(c.get('user'), dict) and c.get('created_at') and c.get('updated_at') + for page in value for c in page), 'comment fields missing') + elif kind == 'approval': + require(isinstance(value, list) and bool(value), 'approval pages missing') + heads = set() + for i, page in enumerate(value): + pr = page['data']['repository']['pullRequest'] + require(re.fullmatch(r'[0-9a-f]{40}', pr['headRefOid']) and pr['state'] in ('OPEN', 'CLOSED', 'MERGED'), 'PR identity missing') + heads.add(pr['headRefOid']) + links = pr['closingIssuesReferences'] + require(type(links['pageInfo']['hasNextPage']) is bool and links['pageInfo']['hasNextPage'] == (i < len(value) - 1), 'approval pagination incomplete') + require(isinstance(links['nodes'], list), 'linked issues missing') + require(len(heads) == 1, 'PR changed during approval read') + elif kind == 'merge-state': + require(value.get('state') in ('OPEN', 'CLOSED', 'MERGED') and re.fullmatch(r'[0-9a-f]{40}', value.get('headRefOid', '')), 'merge outcome unreadable') + if value['state'] == 'MERGED': + require(re.fullmatch(r'[0-9a-f]{40}', (value.get('mergeCommit') or {}).get('oid', '')), 'merged commit missing') + elif kind == 'body': + require(isinstance(value.get('body'), str) and re.fullmatch(r'[0-9a-f]{40}', value.get('headRefOid', '')), 'PR body/head missing') + elif kind == 'dedupe': + require(isinstance(value, list) and len(value) < 200, 'dedupe list missing or capped; fetch complete issue history before creating') + require(all(isinstance(i, dict) and type(i.get('number')) is int and i['number'] > 0 and isinstance(i.get('body'), str) for i in value), 'dedupe issue fields missing') + elif kind in ('prs-open', 'prs-merged', 'issues-closed', 'issue'): + entries = [value] if kind == 'issue' else value + require(isinstance(entries, list), 'required issue/PR list missing') + for entry in entries: + require(isinstance(entry, dict) and type(entry.get('number')) is int and entry['number'] > 0 + and isinstance(entry.get('title'), str) and isinstance(entry.get('body'), str), 'issue/PR identity or body missing') + if kind.startswith('prs-'): + require(isinstance(entry.get('url'), str), 'PR URL missing') + if kind == 'prs-open': + require(isinstance(entry.get('headRefName'), str) and entry['headRefName'] and isinstance(entry.get('files'), list) + and all(isinstance(f, dict) and isinstance(f.get('path'), str) and f['path'] for f in entry['files']), 'open PR branch/files missing') + if kind == 'issues-closed': + require(entry.get('stateReason') in ('COMPLETED', 'NOT_PLANNED', 'REOPENED', None) and 'stateReason' in entry, 'closed issue reason missing') + elif kind == 'object': + require(isinstance(value, dict) and bool(value), 'required object missing') + elif kind == 'array': + require(isinstance(value, list), 'required list missing') + return value + + +def read(args, kind='json', meta=None, recovering=False): + if not read_only(args): + raise ValueError('retry helper accepts read-only GitHub commands only') + if not recovering: + check_halt() + operation = {'args': args, 'kind': kind, 'meta': meta} + last = 'GitHub read failed' + delay = float(os.environ.get('SB_GITHUB_RETRY_DELAY', '1')) + for attempt in range(1, 4): + if not recovering: + check_halt() + try: + result = subprocess.run(['gh', *args], capture_output=True, text=True, timeout=30) + diagnostic = result.stderr + if result.returncode: + diagnostic += result.stdout + elif kind != 'diff': + try: + payload = json.loads(result.stdout) + diagnostic += json.dumps([p.get('errors', []) for p in (payload if isinstance(payload, list) else [payload]) if isinstance(p, dict)]) + except ValueError: + pass + if permanent_error(diagnostic): + halt('GitHub rejected a required read (query, authentication, or permission error); fix it before restarting', attempt, operation) + if result.returncode: + status = re.search(r'HTTP [0-9]{3}', result.stderr) + last = 'GitHub required read failed' + (f' ({status[0]})' if status else '') + else: + try: + value = validate(result.stdout, kind, meta) + if not recovering: + check_halt() + return result.stdout, value + except (ValueError, KeyError, TypeError, AttributeError, IndexError) as error: + last = f'GitHub required read returned incomplete evidence: {error}' + except (OSError, subprocess.SubprocessError): + last = 'GitHub required read timed out or could not start' + if attempt < 3: + print(f'[github-read] {last}; retry {attempt + 1}/3', file=sys.stderr) + time.sleep(max(0, delay) * attempt) + halt(f'{last}; stopped after 3 failed attempts', 3, operation) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--kind', choices=('json','scalar','issue-number','head','state','diff','metadata','items','issues','quota','projects','project','fields','status-field','permission','comments','approval','merge-state','body','dedupe','prs-open','prs-merged','issues-closed','issue','object','array')) + parser.add_argument('--meta') + parser.add_argument('--check', action='store_true') + parser.add_argument('--halt', help='record an unresolved mutation outcome and stop; never retry it') + parser.add_argument('--resume', action='store_true') + parser.add_argument('command', nargs=argparse.REMAINDER) + args = parser.parse_args() + command = args.command[1:] if args.command[:1] == ['--'] else args.command + try: + if args.halt: + halt(args.halt, 1) + if args.resume: + path = halt_path() + if path.exists(): + record = json.loads(path.read_text()) + operation = record.get('operation') + if not isinstance(operation, dict): + raise ReadHalted('saved failure needs manual outcome reconciliation before restart') + if command: + if args.kind != operation['kind']: + raise ValueError('replacement recovery read must validate the same evidence kind') + meta = json.loads(Path(args.meta).read_text()) if args.meta else operation.get('meta') + if args.kind == 'diff': + validate(json.dumps(meta), 'metadata') + read(command, args.kind, meta, recovering=True) + else: + read(operation['args'], operation['kind'], operation.get('meta'), recovering=True) + read(['api', 'rate_limit'], 'quota', recovering=True) + path.replace(path.with_name(f'github-halt-{time.time_ns()}.json')) + return 0 + if args.check: + check_halt() + return 0 + meta = json.loads(Path(args.meta).read_text()) if args.meta else None + output, _ = read(command, args.kind or 'json', meta) + sys.stdout.write(output) + return 0 + except ReadHalted as error: + print(f'[github-read] HALT: {error}. Saved at {halt_path()}', file=sys.stderr) + return HALTED + except (ValueError, OSError) as error: + print(f'[github-read] {error}', file=sys.stderr) + return 64 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/scripts/super-board-merge-gate.sh b/scripts/super-board-merge-gate.sh index 63e145d8..5a6a1722 100755 --- a/scripts/super-board-merge-gate.sh +++ b/scripts/super-board-merge-gate.sh @@ -71,7 +71,7 @@ # Exit codes, all meaningful to the caller: # 0 merged # 2 verification failed β€” branch needs a rebase pass, NOT a Blocked card -# 3 merge refused by GitHub after a green verification (branch protection, checks) +# 3 PR is closed without a merge; no merge attempted # 4 could not take the lock within the timeout # 5 the base could not be merged in (real conflict) β€” needs a rebase pass # 6 the PR head is not the commit that was reviewed β€” review evidence is void, @@ -83,6 +83,8 @@ # card β†’ Blocked with the πŸ™‹ template: the human reviews and merges it, or # comments "done" after its pinned request to approve β€” the next wave re-runs # the gate, which then skips the policy check and merges. +# 79 required GitHub evidence unavailable: local run halt; preserve card and +# approval state, stop dispatch, and explicitly restart after diagnosis. # 8 πŸ™‹ needs you β€” the PR has migrations for a database the robot may not # touch (merge_policy β†’ migrations.allowed_envs), an allowed migrate command # failed, or a human-only step is declared (`needs-you:` line in the PR body, @@ -105,6 +107,8 @@ # and migrations run after verification, inside the lock, right before the # merge. `--dry-run` reports both and runs neither. set -euo pipefail +HERE=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +GITHUB_READ="$HERE/super-board-github-read.py" CONFIG=""; PR=""; LOCK_TIMEOUT=1800; STALE_AFTER=""; DRY=0; EXPECT_HEAD=""; SUBJECT=""; MSG_FILE="" while [ $# -gt 0 ]; do @@ -139,6 +143,8 @@ CONFIG_JSON=$(cat "$CONFIG") BASE=$(echo "$CONFIG_JSON" | jq -r '.base_branch // "main"') REPO=$(echo "$CONFIG_JSON" | jq -r '.repo.remote // ""' | sed -E 's#^https?://github\.com/##; s#\.git$##') REPO_PATH=$(echo "$CONFIG_JSON" | jq -r '.repo.path // "."') +export SB_REPO_PATH="$REPO_PATH" +python3 "$GITHUB_READ" --check # Read with a while-loop rather than `mapfile`: macOS ships bash 3.2 and every # other script in this repo runs there, so this one does too. VERIFY=() @@ -190,10 +196,18 @@ trap cleanup EXIT # ---- the head guard -------------------------------------------------------- # Review evidence belongs to one commit. If the branch moved after the Reviewer # passed it, the tests it reran and the diff it read describe something else. -pr_head() { gh pr view "$PR" ${REPO:+--repo "$REPO"} --json headRefName,headRefOid \ +pr_head() { python3 "$GITHUB_READ" --kind head -- pr view "$PR" ${REPO:+--repo "$REPO"} --json headRefName,headRefOid \ -q '.headRefName + " " + .headRefOid'; } -read -r HEAD_REF HEAD_SHA <<<"$(pr_head)" +HEAD=$(pr_head) || exit $? +read -r HEAD_REF HEAD_SHA <<<"$HEAD" [ -n "${HEAD_SHA:-}" ] || { say "could not read the PR head from GitHub"; exit 1; } +pr_state() { python3 "$GITHUB_READ" --kind merge-state -- pr view "$PR" ${REPO:+--repo "$REPO"} --json state,mergeCommit,headRefOid; } +STATE=$(pr_state) || exit $? +if [ "$(echo "$STATE" | jq -r .state)" = MERGED ]; then + say "already merged; no verification, migration, or merge is repeated" + exit 0 +fi +[ "$(echo "$STATE" | jq -r .state)" = OPEN ] || { say "PR is closed; refusing merge"; exit 3; } if [ -n "$EXPECT_HEAD" ]; then case "$HEAD_SHA" in "$EXPECT_HEAD"*) : ;; @@ -212,8 +226,8 @@ fi HERE=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) # Outside $SCRATCH: `git worktree add` below needs that directory empty. META=$(mktemp); DIFF=$(mktemp) -gh pr view "$PR" ${REPO:+--repo "$REPO"} --json labels,files,additions,deletions,body > "$META" 2>/dev/null || echo '{}' > "$META" -gh pr diff "$PR" ${REPO:+--repo "$REPO"} > "$DIFF" 2>/dev/null || : > "$DIFF" +python3 "$GITHUB_READ" --kind metadata -- pr view "$PR" ${REPO:+--repo "$REPO"} --json labels,files,additions,deletions,changedFiles,body > "$META" +python3 "$GITHUB_READ" --kind diff --meta "$META" -- pr diff "$PR" ${REPO:+--repo "$REPO"} > "$DIFF" PLAN=$(python3 "$HERE/super-board-merge-policy.py" --config "$CONFIG" --meta "$META" --diff "$DIFF") || { echo "human-gate: policy β€” could not classify the PR (unreadable metadata)" say "merge policy could not be evaluated β€” a human merges this one"; exit 7; } @@ -224,6 +238,7 @@ APPROVED=false approval_check() { APPROVAL=$(python3 "$HERE/super-board-approval.py" --repo "$REPO" --pr "$PR" \ --head "$HEAD_SHA" --plan "$PLAN") || APPROVAL='{"approved":false}' + python3 "$GITHUB_READ" --check || exit $? APPROVED=$(echo "$APPROVAL" | jq -r '.approved // false') } approval_request() { @@ -269,6 +284,8 @@ else say "verified green against ${BASE} (${#VERIFY[@]} command(s))" fi +python3 "$GITHUB_READ" --check + # ---- migrations (inside the lock, after the build proof) ------------------- # Run the configured migrate command for every allowed env, from the verified # scratch tree. Collect, never stop early, so a human gets every command at once. @@ -281,6 +298,7 @@ if [ "$(echo "$PLAN" | jq '.migrations | length')" -gt 0 ]; then [ -n "$env" ] || continue if [ "$DRY" -eq 1 ]; then say "dry run: would migrate ${env}: ${cmd}"; continue; fi say "migrate ${env}: ${cmd}" + python3 "$GITHUB_READ" --check if ! ( cd "$SCRATCH" && eval "$cmd" ) >"$SCRATCH/.migrate.log" 2>&1; then say "FAILED: migrate ${env}"; tail -20 "$SCRATCH/.migrate.log" >&2 NEEDS+=("${cmd} # ${env}: failed in the merge gate β€” fix, run it, then mark done") @@ -303,7 +321,7 @@ fi if [ "$APPROVED" = true ]; then # Code remains pinned; body-declared human steps and config can change without # a commit. Reclassify them too instead of reusing the old approval scope. - gh pr view "$PR" ${REPO:+--repo "$REPO"} --json labels,files,additions,deletions,body > "$META" 2>/dev/null || echo '{}' > "$META" + python3 "$GITHUB_READ" --kind metadata -- pr view "$PR" ${REPO:+--repo "$REPO"} --json labels,files,additions,deletions,changedFiles,body > "$META" PLAN=$(python3 "$HERE/super-board-merge-policy.py" --config "$CONFIG" --meta "$META" --diff "$DIFF") || { say "human approval scope could not be refreshed"; exit 7; } approval_check @@ -322,6 +340,7 @@ fi # --match-head-commit makes GitHub refuse if anything was pushed after HEAD_SHA, # including during the verification run above. +python3 "$GITHUB_READ" --check if gh pr merge "$PR" ${REPO:+--repo "$REPO"} --squash --delete-branch \ ${SUBJECT:+--subject "$SUBJECT"} ${MSG_FILE:+--body-file "$MSG_FILE"} \ --match-head-commit "$HEAD_SHA" 2>&1 | tail -3 >&2; then @@ -334,11 +353,15 @@ if gh pr merge "$PR" ${REPO:+--repo "$REPO"} --squash --delete-branch \ fi exit 0 fi -read -r _ NOW_SHA <<<"$(pr_head 2>/dev/null || echo "? ?")" +STATE=$(pr_state) || exit $? +if [ "$(echo "$STATE" | jq -r .state)" = MERGED ]; then + say "merge response was lost; GitHub confirms the merge commit" + exit 0 +fi +NOW_SHA=$(echo "$STATE" | jq -r .headRefOid) if [ "$NOW_SHA" != "$HEAD_SHA" ]; then say "head moved during the gate: verified ${HEAD_SHA}, PR head is now ${NOW_SHA}" say "the review evidence is void β€” send the card back to Review" exit 6 fi -say "GitHub refused the merge after a green verification (branch protection or a required check)" -exit 3 +python3 "$GITHUB_READ" --halt "Merge response failed and GitHub has not confirmed a merge; reconcile PR #${PR} before restarting. No merge was retried." diff --git a/scripts/super-board-pr-body.sh b/scripts/super-board-pr-body.sh index c59aa463..63e4ac94 100755 --- a/scripts/super-board-pr-body.sh +++ b/scripts/super-board-pr-body.sh @@ -70,7 +70,8 @@ SRC="${BODY_FILE:-$APPEND_FILE}" [ -r "$SRC" ] || die "cannot read $SRC" 66 [ "$DRY" -eq 1 ] || [ -n "$HEAD" ] || die "--expect-head <sha> is required (the PR head your edit is based on)" 64 -META=$(gh pr view "$PR" ${REPO:+--repo "$REPO"} --json headRefOid,body 2>/dev/null) || die "cannot read PR #$PR" 70 +GITHUB_READ="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/super-board-github-read.py" +META=$(python3 "$GITHUB_READ" --kind body -- pr view "$PR" ${REPO:+--repo "$REPO"} --json headRefOid,body ) || exit $? NOW=$(printf '%s' "$META" | jq -r '.headRefOid // empty') if [ -n "$HEAD" ]; then # Short or full sha both work: the PR head must start with what you passed. @@ -144,5 +145,6 @@ PY if [ "$DRY" -eq 1 ]; then cat "$NEW_TMP"; exit 0; fi # $(…) drops trailing newlines, which GitHub does not keep either. if [ "$(cat "$OLD_TMP")" = "$(cat "$NEW_TMP")" ]; then echo unchanged; exit 0; fi -gh pr edit "$PR" ${REPO:+--repo "$REPO"} --body-file "$NEW_TMP" >/dev/null 2>&1 || die "gh pr edit failed for #$PR" 70 +python3 "$GITHUB_READ" --check || exit $? +gh pr edit "$PR" ${REPO:+--repo "$REPO"} --body-file "$NEW_TMP" >/dev/null 2>&1 || { python3 "$GITHUB_READ" --halt "PR body update outcome unknown for #$PR; read the body before retrying"; exit 79; } echo updated diff --git a/scripts/super-board-preflight.sh b/scripts/super-board-preflight.sh index 1a56dca5..93c716f9 100755 --- a/scripts/super-board-preflight.sh +++ b/scripts/super-board-preflight.sh @@ -39,7 +39,7 @@ # "evidence": ["#31 merged: Resolves #14"], "blockedBy": [31], # "conflictWith": [52], "candidates": [{"ref":"#40","kind":"open-pr","sim":0.4,"title":"…"}] } } # -# Exit 0 ok Β· 64 usage Β· 69 could not read GitHub (callers skip the card this wave; +# Exit 0 ok Β· 64 usage Β· 79 required GitHub evidence unavailable (pause the run; # a blind pre-flight never says proceed). set -euo pipefail @@ -61,19 +61,18 @@ HERE=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) if [ -f "$HERE/super-board-gh-guard.sh" ]; then # shellcheck source=/dev/null . "$HERE/super-board-gh-guard.sh" - sb_gh_guard_check 200 || true + sb_gh_guard_check 200 || exit $? fi -blind() { echo "pre-flight blind: could not read $1 for $REPO" >&2; exit 69; } -MERGED=$(gh pr list --repo "$REPO" --state merged --limit 100 --json number,title,body,url 2>/dev/null) || blind "merged PRs" -OPEN=$(gh pr list --repo "$REPO" --state open --limit 100 --json number,title,body,url,headRefName,files 2>/dev/null) || blind "open PRs" -CLOSED=$(gh issue list --repo "$REPO" --state closed --limit 100 --json number,title,body,stateReason 2>/dev/null) || blind "closed issues" +MERGED=$(python3 "$HERE/super-board-github-read.py" --kind prs-merged -- pr list --repo "$REPO" --state merged --limit 100 --json number,title,body,url ) || exit $? +OPEN=$(python3 "$HERE/super-board-github-read.py" --kind prs-open -- pr list --repo "$REPO" --state open --limit 100 --json number,title,body,url,headRefName,files ) || exit $? +CLOSED=$(python3 "$HERE/super-board-github-read.py" --kind issues-closed -- issue list --repo "$REPO" --state closed --limit 100 --json number,title,body,stateReason ) || exit $? CARDS="[]" [ -n "$INFLIGHT" ] && CARDS=$(cat "$INFLIGHT") TARGETS="[]" for n in ${ISSUES//,/ }; do - one=$(gh issue view "$n" --repo "$REPO" --json number,title,body 2>/dev/null) || blind "issue #$n" + one=$(python3 "$HERE/super-board-github-read.py" --kind issue -- issue view "$n" --repo "$REPO" --json number,title,body ) || exit $? TARGETS=$(jq -c --argjson one "$one" '. + [$one]' <<<"$TARGETS") done diff --git a/scripts/super-board-run.sh b/scripts/super-board-run.sh index 7df59bb7..5a9429d9 100755 --- a/scripts/super-board-run.sh +++ b/scripts/super-board-run.sh @@ -31,6 +31,15 @@ # (no subshell, no `exec` of a native PE), so `$!` names the worker itself rather than a bash stub. set -euo pipefail +SB_GITHUB_READ="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/super-board-github-read.py" +gh_checkpoint() { python3 "$SB_GITHUB_READ" --check || exit $?; } +gh_write_once() { + gh_checkpoint + gh "$@" || { + python3 "$SB_GITHUB_READ" --halt "GitHub $1 $2 response failed; reconcile the write before restarting" >&2 || true + exit 79 + } +} # ───────────────────────────── args + paths ───────────────────────────── # SB_LIB_ONLY=1 sources this file for its helpers only (gate tests). Config discovery, @@ -109,7 +118,9 @@ log() { printf '[%s] %s\n' "$(date +%H:%M:%S)" "$*" | tee -a "$RUN_MANIFEST"; } PROJECT_ITEMS_JSON="" fetch_project_items() { # One gh call per tick; all column lookups read from this cache. - PROJECT_ITEMS_JSON=$(gh project item-list "$PROJECT_NUMBER" --owner "$PROJECT_OWNER" --format json --limit 500 2>/dev/null || echo '{"items":[]}') + local fresh + fresh=$(python3 "$SB_GITHUB_READ" --kind items -- project item-list "$PROJECT_NUMBER" --owner "$PROJECT_OWNER" --format json --limit 500) || return $? + PROJECT_ITEMS_JSON="$fresh" } column_count() { @@ -125,17 +136,9 @@ STATUS_OPTIONS_JSON="" resolve_status_field() { [ -n "$STATUS_FIELD_ID" ] && return 0 local payload - payload=$(gh api graphql -f query=' - query($owner:String!, $number:Int!) { - user(login:$owner) { projectV2(number:$number) { field(name:"Status") { - ... on ProjectV2SingleSelectField { id options { id name } } } } } - organization(login:$owner) { projectV2(number:$number) { field(name:"Status") { - ... on ProjectV2SingleSelectField { id options { id name } } } } } - }' -f owner="$PROJECT_OWNER" -F number="$PROJECT_NUMBER" 2>/dev/null) || return 1 - STATUS_FIELD_ID=$(echo "$payload" | jq -r ' - (.data.user.projectV2.field.id // .data.organization.projectV2.field.id // "")') - STATUS_OPTIONS_JSON=$(echo "$payload" | jq -c ' - (.data.user.projectV2.field.options // .data.organization.projectV2.field.options // [])') + payload=$(python3 "$SB_GITHUB_READ" --kind fields -- project field-list "$PROJECT_NUMBER" --owner "$PROJECT_OWNER" --format json) || return $? + STATUS_FIELD_ID=$(echo "$payload" | jq -r '.fields[] | select(.name == "Status") | .id') + STATUS_OPTIONS_JSON=$(echo "$payload" | jq -c '.fields[] | select(.name == "Status") | .options') [ -n "$STATUS_FIELD_ID" ] && [ "$STATUS_FIELD_ID" != "null" ] } @@ -152,28 +155,31 @@ status_option_id() { set_card_status() { # $1 = project item id, $2 = target status name. Returns non-zero if unresolvable. local item_id="$1" name="$2" opt_id - resolve_status_field || { log "⚠ could not resolve Status field β€” skipping reconcile"; return 1; } + resolve_status_field || { gh_checkpoint; log "⚠ could not resolve Status field β€” skipping reconcile"; return 1; } opt_id=$(status_option_id "$name") || { log "⚠ Status option '${name}' not present on the board β€” skipping reconcile"; return 1; } - gh project item-edit --id "$item_id" --project-id "$(project_node_id)" \ + local project_id + project_id=$(project_node_id) || return $? + gh_checkpoint + gh_write_once project item-edit --id "$item_id" --project-id "$project_id" \ --field-id "$STATUS_FIELD_ID" --single-select-option-id "$opt_id" >/dev/null 2>&1 } PROJECT_NODE_ID="" project_node_id() { if [ -z "$PROJECT_NODE_ID" ]; then - PROJECT_NODE_ID=$(gh project view "$PROJECT_NUMBER" --owner "$PROJECT_OWNER" --format json 2>/dev/null \ - | jq -r '.id // ""') + local payload + payload=$(python3 "$SB_GITHUB_READ" --kind project -- project view "$PROJECT_NUMBER" --owner "$PROJECT_OWNER" --format json) || return $? + PROJECT_NODE_ID=$(echo "$payload" | jq -r .id) fi echo "$PROJECT_NODE_ID" } issue_is_open() { # $1 = issue number. Returns 0 when OPEN, 1 when CLOSED. - # Unknown/erroring lookups return 0 (open) so a transient gh failure never - # silently reconciles a live card into Done. + # Unreadable is distinct from both OPEN and CLOSED: exit 79 halts the run. local state - state=$(gh issue view "$1" --json state -q '.state' 2>/dev/null) || return 0 + state=$(python3 "$SB_GITHUB_READ" --kind state -- issue view "$1" --json state -q '.state') || return 79 [ "$state" != "CLOSED" ] } @@ -203,10 +209,14 @@ top_card_in_column() { while IFS=$'\t' read -r issue item_id; do [ -n "$issue" ] || continue issue_locked "$issue" && continue - if ! issue_is_open "$issue"; then + local issue_rc=0 + issue_is_open "$issue" || issue_rc=$? + [ "$issue_rc" -le 1 ] || exit "$issue_rc" + if [ "$issue_rc" -ne 0 ]; then log "reconciled closed #${issue} -> Done (was '${col}') β€” dispatching 0 workers for it" - set_card_status "$item_id" "Done" || log " ↳ reconcile of #${issue} could not be written to the board" - [ -n "$BOT_LOGIN" ] && gh issue edit "$issue" --remove-assignee "$BOT_LOGIN" >/dev/null 2>&1 || true + set_card_status "$item_id" "Done" || { gh_checkpoint; log " ↳ reconcile of #${issue} could not be written to the board"; return 1; } + gh_checkpoint + [ -n "$BOT_LOGIN" ] && gh_write_once issue edit "$issue" --remove-assignee "$BOT_LOGIN" >/dev/null 2>&1 || true rm -f "$INFLIGHT_DIR/$issue" continue fi @@ -240,6 +250,7 @@ read_lock() { issue_locked() { # Returns 0 if the issue has a live in-flight lock; cleans stale locks. + gh_checkpoint local issue="$1" lock="$INFLIGHT_DIR/$1" [ -f "$lock" ] || return 1 read_lock "$issue" @@ -258,7 +269,7 @@ lane_idle() { gh_rate_guard() { # Sleep until rate limit resets if GraphQL remaining < 200. local payload remaining reset now wait - payload=$(gh api rate_limit 2>/dev/null || echo '{"resources":{"graphql":{"remaining":5000,"reset":0}}}') + payload=$(python3 "$SB_GITHUB_READ" --kind quota -- api rate_limit) || return $? remaining=$(echo "$payload" | jq -r '.resources.graphql.remaining // 5000') if [ "$remaining" -lt 200 ]; then reset=$(echo "$payload" | jq -r '.resources.graphql.reset // 0') @@ -279,7 +290,8 @@ try_claim_assignee() { # idempotent for self-assign; on race-loss, gh returns non-zero and we skip. local issue="$1" [ -z "$BOT_LOGIN" ] && return 0 - gh issue edit "$issue" --add-assignee "$BOT_LOGIN" >/dev/null 2>&1 || { + gh_checkpoint + gh_write_once issue edit "$issue" --add-assignee "$BOT_LOGIN" >/dev/null 2>&1 || { log "claim failed on #${issue} (race or gh api error) β€” skipping this tick" return 1 } @@ -295,7 +307,11 @@ dispatch_lane() { fi # Authoritative closed-issue gate (issue #10). top_card_in_column also filters and # reconciles, but every dispatch path funnels through here β€” one guard, one place. - if ! issue_is_open "$issue"; then + gh_checkpoint + local issue_rc=0 + issue_is_open "$issue" || issue_rc=$? + [ "$issue_rc" -le 1 ] || exit "$issue_rc" + if [ "$issue_rc" -ne 0 ]; then log "skip dispatch lane=${lane} issue=#${issue} β€” issue is CLOSED" return 0 fi @@ -316,6 +332,7 @@ dispatch_lane() { printf '=== dispatch lane=%s issue=#%s at %s ===\n' "$lane" "$issue" "$(date -u +%FT%TZ)" printf 'prompt: %s\n\n' "$prompt" } >> "$worker_log" + gh_checkpoint nohup claude -p "$prompt" >> "$worker_log" 2>&1 & pid=$! DISPATCH_COUNT=$((DISPATCH_COUNT + 1)) @@ -361,12 +378,15 @@ check_lane_zombie() { [ "$cur" = "$col" ] && found=1 done if [ "$found" -eq 0 ]; then + gh_checkpoint log "πŸ’€ zombie ${lane} worker on #${issue} (pid=${pid}) β€” card moved to '${cur}'; killing" kill "$pid" 2>/dev/null || true sleep 1 kill -9 "$pid" 2>/dev/null || true + gh_checkpoint + [ -n "$BOT_LOGIN" ] && gh_write_once issue edit "$issue" --remove-assignee "$BOT_LOGIN" >/dev/null 2>&1 || true + gh_checkpoint rm -f "$INFLIGHT_DIR/$issue" - [ -n "$BOT_LOGIN" ] && gh issue edit "$issue" --remove-assignee "$BOT_LOGIN" >/dev/null 2>&1 || true case "$lane" in build) BUILD_PID=""; BUILD_ISSUE="" ;; qa) QA_PID=""; QA_ISSUE="" ;; @@ -431,6 +451,7 @@ reap_finished_locks() { # The assignee remove is idempotent β€” no-op if the worker exited cleanly. local lock issue for lock in "$INFLIGHT_DIR"/*; do + gh_checkpoint [ -f "$lock" ] || continue issue=$(basename "$lock") # Issue locks only: basenames are issue numbers. Anything else (e.g. the @@ -439,14 +460,17 @@ reap_finished_locks() { case "$issue" in *[!0-9]*|'') continue ;; esac read_lock "$issue" if [ -z "$PID" ] || ! kill -0 "$PID" 2>/dev/null; then - rm -f "$lock" - REAP_COUNT=$((REAP_COUNT + 1)) + gh_checkpoint if [ -n "$BOT_LOGIN" ]; then - gh issue edit "$issue" --remove-assignee "$BOT_LOGIN" >/dev/null 2>&1 || true + gh_checkpoint + gh_write_once issue edit "$issue" --remove-assignee "$BOT_LOGIN" >/dev/null 2>&1 || true log "reaped stale lock + swept assignee on #${issue} (pid=${PID:-empty})" else log "reaped stale lock for #${issue} (pid=${PID:-empty})" fi + gh_checkpoint + rm -f "$lock" + REAP_COUNT=$((REAP_COUNT + 1)) fi done } @@ -505,12 +529,14 @@ reclaim_stranded_building() { fi fi if ! set_card_status "$item_id" "Ready"; then + gh_checkpoint log "⚠ stranded #${issue} in Building β€” could not move it to Ready; drag it by hand" continue fi - [ -n "$BOT_LOGIN" ] && gh issue edit "$issue" --remove-assignee "$BOT_LOGIN" >/dev/null 2>&1 || true + [ -n "$BOT_LOGIN" ] && gh_write_once issue edit "$issue" --remove-assignee "$BOT_LOGIN" >/dev/null 2>&1 || true # Comment format: writing-standard.md Β§ 4. - gh issue comment "$issue" --body "[orchestrator] [report] ↩️ back to Ready Β· stranded in Building + gh_checkpoint + gh_write_once issue comment "$issue" --body "[orchestrator] [report] ↩️ back to Ready Β· stranded in Building Did: found no live worker; the last run stopped mid-build βœ… Done: card moved to Ready Β· branch ${branch:-none found}${branch:+ kept} Next: builder (next tick${branch:+, continues on the branch})" >/dev/null 2>&1 || true @@ -536,6 +562,18 @@ if [ "${SB_LIB_ONLY:-0}" = "1" ]; then return 0 2>/dev/null || exit 0 fi +# A read failure stops only workers launched by this dispatcher. Keep their +# locks, worktrees, claims, and approval evidence for explicit recovery. +halt_cleanup() { + if ! python3 "$SB_GITHUB_READ" --check >/dev/null 2>&1; then + for pid in "${BUILD_PID:-}" "${QA_PID:-}" "${REVIEW_PID:-}"; do + [ -z "$pid" ] || kill -TERM "$pid" 2>/dev/null || true + done + fi +} +trap halt_cleanup EXIT +gh_checkpoint + # ───────────────────────────── preconditions ───────────────────────────── log "super-board run started β€” config=${CONFIG_SLUG} base=${BASE_BRANCH} tick=${TICK_SECONDS}s max_workers=${MAX_WORKERS} no_progress_cycles=${NO_PROGRESS_CYCLES} max_dispatches=${MAX_DISPATCHES}" @@ -589,6 +627,7 @@ QA_PID=""; QA_ISSUE="" REVIEW_PID=""; REVIEW_ISSUE="" while true; do + gh_checkpoint # Workflow-backend mutual exclusion, re-checked every tick: the startup # check alone leaves a TOCTOU window where a workflow run starting at the # same moment as this dispatcher is never detected by either side. @@ -694,6 +733,8 @@ while true; do fi fi + gh_checkpoint + # ── Landed-work halt gate (issue #8). # Progress = the Done/Skipped set changed since the last dispatch cycle. Lane # occupancy is deliberately NOT part of this condition: the runaway this gate diff --git a/scripts/super-board-wave-plan.sh b/scripts/super-board-wave-plan.sh index 35881fb6..831771aa 100755 --- a/scripts/super-board-wave-plan.sh +++ b/scripts/super-board-wave-plan.sh @@ -86,6 +86,9 @@ done # Read the config ONCE β€” $CONFIG may be a process substitution (test mode), # which is a FIFO and cannot be read twice. CONFIG_JSON=$(cat "$CONFIG") +HERE=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +export SB_REPO_PATH=$(echo "$CONFIG_JSON" | jq -r '.repo.path // "."') +python3 "$HERE/super-board-github-read.py" --check VARIANT=$(echo "$CONFIG_JSON" | jq -r '.variant // ""') # 0 or absent = unlimited. A wave is sized by the dependency graph, not a knob. MAX_WORKERS=$(echo "$CONFIG_JSON" | jq -r '.max_workers // 0') @@ -95,7 +98,7 @@ NUMBER=$(echo "$CONFIG_JSON" | jq -r '.project.number') if [ -n "$ITEMS_FILE" ]; then ITEMS=$(cat "$ITEMS_FILE") else - ITEMS=$(gh project item-list "$NUMBER" --owner "$OWNER" --format json --limit 500) + ITEMS=$(python3 "$HERE/super-board-github-read.py" --kind items -- project item-list "$NUMBER" --owner "$OWNER" --format json --limit 500) fi # `variant` was removed in v3.0.0 (labels route cards now). Absent or "full" is @@ -119,7 +122,7 @@ else # A graph we cannot fetch is not a graph of zero blockers. Fail rather than # plan a wave that treats every card as free. DEPS=$("$HERE/super-board-deps.sh" --repo "$REPO" --limit 300) || { - echo "could not derive the dependency graph for ${REPO}" >&2; exit 69; } + rc=$?; echo "could not derive the dependency graph for ${REPO}" >&2; exit "$rc"; } fi # Review extras used to be gated behind human_approves_merge, because concurrent diff --git a/scripts/super-qa-file-bug.sh b/scripts/super-qa-file-bug.sh index 556a8149..921c7dac 100755 --- a/scripts/super-qa-file-bug.sh +++ b/scripts/super-qa-file-bug.sh @@ -141,17 +141,18 @@ if [ -z "$FINGERPRINT" ]; then | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-|-$//g')" fi +GITHUB_READ="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/super-board-github-read.py" +python3 "$GITHUB_READ" --check || exit $? + # --- project resolution ----------------------------------------------------- OWNER="${SUPER_QA_PROJECT_OWNER:-}" if [ -z "$OWNER" ]; then - OWNER=$(gh repo view --json owner -q .owner.login 2>/dev/null) \ - || die "cannot resolve project owner: set SUPER_QA_PROJECT_OWNER or run inside a repo" 70 + OWNER=$(python3 "$GITHUB_READ" --kind scalar -- repo view --json owner -q .owner.login) || exit $? fi PROJECT_TITLE="${SUPER_QA_PROJECT_TITLE:-Super Ultimate QA}" TARGET_COLUMN="${SUPER_QA_TARGET_OPTION_NAME:-Bug}" -PROJECT_LIST=$(gh project list --owner "$OWNER" --format json 2>/dev/null) \ - || die "cannot list projects for owner ${OWNER}" 70 +PROJECT_LIST=$(python3 "$GITHUB_READ" --kind projects -- project list --owner "$OWNER" --format json) || exit $? NUMBER=$(echo "$PROJECT_LIST" | jq -r --arg t "$PROJECT_TITLE" \ '[.projects[] | select((.title // "" | ascii_downcase) == ($t | ascii_downcase))] | first | .number // empty') # Do NOT silently fall back to the repo's primary project β€” its columns mean @@ -159,14 +160,14 @@ NUMBER=$(echo "$PROJECT_LIST" | jq -r --arg t "$PROJECT_TITLE" \ [ -n "$NUMBER" ] || die "no project titled '${PROJECT_TITLE}' under ${OWNER} β€” create one, or set SUPER_QA_PROJECT_TITLE" 70 # --- dedupe ----------------------------------------------------------------- -EXISTING=$(gh issue list --label "source:qa" --state open --limit 200 \ - --json number,body \ - --jq "[.[] | select(.body != null and (.body | contains(\"${FINGERPRINT}\"))) | .number] | first // empty" 2>/dev/null || true) +ISSUES_JSON=$(python3 "$GITHUB_READ" --kind dedupe -- issue list --label "source:qa" --state open --limit 200 --json number,body) || exit $? +EXISTING=$(printf '%s' "$ISSUES_JSON" | jq -r --arg fingerprint "$FINGERPRINT" '[.[] | select(.body | contains($fingerprint)) | .number] | first // empty') if [ -n "$EXISTING" ]; then + python3 "$GITHUB_READ" --check || exit $? gh issue comment "$EXISTING" --body "Seen again${ITER:+ on iteration ${ITER}}. -${BODY_RAW}" >/dev/null 2>&1 || echo "warn: could not comment on existing #${EXISTING}" >&2 +${BODY_RAW}" >/dev/null 2>&1 || { python3 "$GITHUB_READ" --halt "Comment outcome unknown for #${EXISTING}; inspect before retrying"; exit 79; } echo "$EXISTING" exit 0 fi @@ -230,13 +231,15 @@ if [ -n "$SUGGESTED_SKILL" ]; then LABELS+=("skill:${SUGGESTED_SKILL}"); fi # `gh issue create` hard-fails on an unknown label, which would lose the finding # entirely. Create them best-effort first. for l in "${LABELS[@]}"; do + python3 "$GITHUB_READ" --check || exit $? gh label create "$l" --color D93F0B --force >/dev/null 2>&1 || true done LABEL_ARGS=() for l in "${LABELS[@]}"; do LABEL_ARGS+=(--label "$l"); done +python3 "$GITHUB_READ" --check || exit $? ISSUE_URL=$(gh issue create --title "$FULL_TITLE" --body-file "$BODY_TMP" "${LABEL_ARGS[@]}") \ - || die "gh issue create failed" 70 + || { python3 "$GITHUB_READ" --halt "Issue creation response failed; reconcile fingerprint ${FINGERPRINT} before retrying"; exit 79; } ISSUE_N=$(basename "$ISSUE_URL") # --- promote onto the board ------------------------------------------------- @@ -244,9 +247,10 @@ ISSUE_N=$(basename "$ISSUE_URL") # exit 71 tells the caller "filed, needs a manual move" rather than losing it. promote() { local item_id project_id field_json field_id option_id - item_id=$(gh project item-add "$NUMBER" --owner "$OWNER" --url "$ISSUE_URL" --format json --jq '.id') || return 1 - project_id=$(gh project view "$NUMBER" --owner "$OWNER" --format json --jq '.id') || return 1 - field_json=$(gh project field-list "$NUMBER" --owner "$OWNER" --format json) || return 1 + python3 "$GITHUB_READ" --check || return $? + item_id=$(gh project item-add "$NUMBER" --owner "$OWNER" --url "$ISSUE_URL" --format json --jq '.id') || { python3 "$GITHUB_READ" --halt "Project item add outcome unknown for issue #${ISSUE_N}; reconcile before retrying"; return 79; } + project_id=$(python3 "$GITHUB_READ" --kind scalar -- project view "$NUMBER" --owner "$OWNER" --format json --jq '.id') || return $? + field_json=$(python3 "$GITHUB_READ" --kind fields -- project field-list "$NUMBER" --owner "$OWNER" --format json) || return $? field_id=$(echo "$field_json" | jq -r '.fields[] | select(.name=="Status") | .id') # The requested name first, then the conventional aliases. A QA bug arrives # WITH a repro and evidence, so Ready is an honest fallback for it β€” unlike a @@ -266,13 +270,15 @@ promote() { fi done [ -n "$option_id" ] && [ "$option_id" != "null" ] || { echo "warn: no '${TARGET_COLUMN}' column and no alias (Bug, Ready, Todo, Backlog, Triage) on the Status field" >&2; return 1; } + python3 "$GITHUB_READ" --check || return $? gh project item-edit --id "$item_id" --project-id "$project_id" \ - --field-id "$field_id" --single-select-option-id "$option_id" >/dev/null || return 1 + --field-id "$field_id" --single-select-option-id "$option_id" >/dev/null || { python3 "$GITHUB_READ" --halt "Project column move outcome unknown for issue #${ISSUE_N}; reconcile before retrying"; return 79; } } if promote; then echo "$ISSUE_N" else + python3 "$GITHUB_READ" --check || { echo "$ISSUE_N"; exit 79; } echo "warn: #${ISSUE_N} filed but not moved to '${TARGET_COLUMN}' on ${OWNER}/${NUMBER} β€” manual move required" >&2 echo "$ISSUE_N" exit 71 diff --git a/scripts/super-review-file-refactor.sh b/scripts/super-review-file-refactor.sh index 4e87d047..0267e602 100755 --- a/scripts/super-review-file-refactor.sh +++ b/scripts/super-review-file-refactor.sh @@ -3,9 +3,9 @@ # # Super Review's gate is merge-or-bounce. A shallow module in an otherwise-correct # diff is a future ticket, not a reason to hold a green PR β€” so this script files -# the card and gets out of the way. It NEVER fails the review: every failure below -# the issue-create call degrades to a warning on stderr, because a board-placement -# hiccup must not strand a mergeable PR in Review. +# the card and gets out of the way. Missing optional column configuration is a +# warning; unavailable required evidence or an unknown write outcome pauses the +# run (79) while preserving any issue already created. No mutation is retried. # # Cards land in Backlog, not Ready. A refactor the reviewer noticed has had no human # eyes on it and no acceptance criteria; auto-promoting it to Ready would feed the @@ -74,22 +74,24 @@ else REPO_FLAG=() fi +GITHUB_READ="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/super-board-github-read.py" +python3 "$GITHUB_READ" --check || exit $? + # --- dedupe ----------------------------------------------------------------- # The fingerprint is stamped into the body as an HTML comment so it survives # edits to the prose and stays invisible on the rendered issue. STAMP="<!-- super-review-fingerprint: ${FINGERPRINT} -->" -EXISTING=$(gh issue list "${REPO_FLAG[@]}" \ - --label "source:review" --state open --limit 200 \ - --json number,body \ - --jq "[.[] | select(.body != null and (.body | contains(\"${FINGERPRINT}\"))) | .number] | first // empty" 2>/dev/null || true) +ISSUES_JSON=$(python3 "$GITHUB_READ" --kind dedupe -- issue list "${REPO_FLAG[@]}" --label "source:review" --state open --limit 200 --json number,body) || exit $? +EXISTING=$(printf '%s' "$ISSUES_JSON" | jq -r --arg fingerprint "$FINGERPRINT" '[.[] | select(.body | contains($fingerprint)) | .number] | first // empty') if [ -n "$EXISTING" ]; then # Same shape problem, seen again on a later PR. Add the sighting, don't stack cards. + python3 "$GITHUB_READ" --check || exit $? gh issue comment "$EXISTING" "${REPO_FLAG[@]}" \ --body "Seen again during review${PR:+ of #${PR}}. $(cat "$BODY_FILE")" >/dev/null 2>&1 \ - || echo "warn: could not comment on existing #${EXISTING}" >&2 + || { python3 "$GITHUB_READ" --halt "Comment outcome unknown for #${EXISTING}; inspect before retrying"; exit 79; } echo "$EXISTING" exit 0 fi @@ -166,6 +168,7 @@ if [ -n "$AREA" ]; then LABELS+=("area:${AREA}"); fi # Labels may not exist yet on a fresh repo. Create them best-effort; `gh issue # create` hard-fails on an unknown label, which would lose the finding entirely. for l in "${LABELS[@]}"; do + python3 "$GITHUB_READ" --check || exit $? gh label create "$l" "${REPO_FLAG[@]}" --color BFD4F2 --force >/dev/null 2>&1 || true done @@ -175,17 +178,18 @@ for l in "${LABELS[@]}"; do LABEL_ARGS+=(--label "$l"); done # Title: `♻️ [refactor] <scope>: <title>`; scope = --area, else the fingerprint's module. SCOPE="${AREA:-${FINGERPRINT%%|*}}" SCOPE=$(echo "${SCOPE:-code}" | tr '[:upper:]' '[:lower:]' | sed -E 's#[^a-z0-9._/-]+#-#g; s#^-+|-+$##g') +python3 "$GITHUB_READ" --check || exit $? ISSUE_URL=$(gh issue create "${REPO_FLAG[@]}" \ --title "♻️ [refactor] ${SCOPE:-code}: ${TITLE}" \ --body-file "$BODY_TMP" \ - "${LABEL_ARGS[@]}") + "${LABEL_ARGS[@]}") || { python3 "$GITHUB_READ" --halt "Issue creation response failed; reconcile fingerprint ${FINGERPRINT} before retrying"; exit 79; } ISSUE_N=$(basename "$ISSUE_URL") # --- place on the board ----------------------------------------------------- # The holding column is deliberately NOT in the config's managed `columns` list, # so the board may name it something else. Add the card either way and only then -# try to set Status. Never exit non-zero from here down. +# try to set Status. Exit 71 means filed but placement failed; 79 is a paused run. # # A CARD WITH NO STATUS IS NOT HARMLESS, which the first version of this comment # claimed. Observed on a real board on 2026-08-20: the board's holding column was @@ -197,9 +201,10 @@ ISSUE_N=$(basename "$ISSUE_URL") place_card() { local item_id project_id field_json field_id option_id candidate local -a candidates - item_id=$(gh project item-add "$NUMBER" --owner "$OWNER" --url "$ISSUE_URL" --format json --jq '.id') || return 1 - project_id=$(gh project view "$NUMBER" --owner "$OWNER" --format json --jq '.id') || return 1 - field_json=$(gh project field-list "$NUMBER" --owner "$OWNER" --format json) || return 1 + python3 "$GITHUB_READ" --check || return $? + item_id=$(gh project item-add "$NUMBER" --owner "$OWNER" --url "$ISSUE_URL" --format json --jq '.id') || { python3 "$GITHUB_READ" --halt "Project item add outcome unknown for issue #${ISSUE_N}; reconcile before retrying"; return 79; } + project_id=$(python3 "$GITHUB_READ" --kind scalar -- project view "$NUMBER" --owner "$OWNER" --format json --jq '.id') || return $? + field_json=$(python3 "$GITHUB_READ" --kind fields -- project field-list "$NUMBER" --owner "$OWNER" --format json) || return $? field_id=$(echo "$field_json" | jq -r '.fields[] | select(.name=="Status") | .id') # The requested name first, then the conventional aliases for "not started". # Ordered by intent: a holding column beats Ready, because a card filed by a @@ -226,8 +231,9 @@ place_card() { done if [ -n "$option_id" ] && [ "$option_id" != "null" ]; then + python3 "$GITHUB_READ" --check || return $? gh project item-edit --id "$item_id" --project-id "$project_id" \ - --field-id "$field_id" --single-select-option-id "$option_id" >/dev/null || return 1 + --field-id "$field_id" --single-select-option-id "$option_id" >/dev/null || { python3 "$GITHUB_READ" --halt "Project column move outcome unknown for issue #${ISSUE_N}; reconcile before retrying"; return 79; } else echo "warn: this board has no '${COLUMN}' column and none of the usual aliases" >&2 echo "warn: (Backlog, Todo, To do, Triage, Inbox) β€” #${ISSUE_N} is on the board with NO status," >&2 @@ -235,6 +241,11 @@ place_card() { fi } -place_card || echo "warn: filed #${ISSUE_N} but could not place it on project ${OWNER}/${NUMBER}" >&2 +if ! place_card; then + echo "$ISSUE_N" + python3 "$GITHUB_READ" --check || exit 79 + echo "warn: filed #${ISSUE_N} but could not place it on project ${OWNER}/${NUMBER}; inspect before retrying" >&2 + exit 71 +fi echo "$ISSUE_N" diff --git a/skills/super-board/references/run-workflow.md b/skills/super-board/references/run-workflow.md index 691bbaea..2655a993 100644 --- a/skills/super-board/references/run-workflow.md +++ b/skills/super-board/references/run-workflow.md @@ -8,6 +8,35 @@ workers. Lane lifecycles, branch/PR model, comment cadence, Block templates, halt gates, and done conditions are all inherited from `run.md` unchanged. +## Required GitHub evidence and a paused run + +Required reads use `.claude/bin/super-board-github-read.py --kind <shape> -- <gh args>`. +Three total attempts means the first attempt plus two retries of that same read; +unrelated successful calls do not reset its failures. Invalid GraphQL queries, +authentication, and permission errors stop immediately. Do not retry mutations. + +Exit **79**, a workflow result with `halted: true`, or a failed `--check` ends this +run. Check the helper's `--check` before claims, board/comment writes, each new +lane/wave, and all migration/merge steps. The marker is shared through the main +checkout's `.claude/super-board/github-halt.json`, including linked worktrees. +Cancel active workflow agents using local workflow controls; do not launch more +lanes. Already-running agents stop at their next checkpoint; this is not an +instantaneous process-wide kill. Preserve worktrees, claims, card statuses, and +approval evidence. Do not move cards to Done/Blocked or release claims using an +unavailable GitHub API. Report the local saved reason and what remains in flight. + +After fixing access or the invalid query, explicitly resume with the helper's +`--resume`. It rechecks the failed read before clearing the marker and archives +the original reason. Failed recovery leaves the pause intact. When the caller's +query was corrected or PR metadata changed, supply its current read explicitly: +`--resume --kind <same-shape> [--meta <fresh-meta.json>] -- <corrected gh read args>`. +The replacement must pass the same evidence validator; the broken saved query +is retained in the archived record. This is a read-only recovery check. An uncertain write +has no automatic recovery probe: inspect the remote outcome first, then archive +the halt record explicitly; never rerun a migration/write as a health check. Then follow normal +run preflight/reconcile, inspect preserved worktrees, and start a fresh wave. +Do not schedule automatic retries of the stopped run. + ## Orchestrator delegation contract (NON-NEGOTIABLE, adapted) The interactive session that runs this backend is the orchestrator. It: diff --git a/skills/super-board/references/run.md b/skills/super-board/references/run.md index 4b703868..e06228a6 100644 --- a/skills/super-board/references/run.md +++ b/skills/super-board/references/run.md @@ -4,6 +4,19 @@ **Where the legacy runner runs:** headless. Spawned as a `nohup`-backgrounded process; the current Claude session exits immediately after dispatch. The runner script (`scripts/super-board-run.sh`) is a pure shell while-loop that dispatches one `claude -p` worker per lane and never holds Claude session state. Each `claude -p` worker is its own short-lived headless context β€” load this file at the start of every lane. +## Required GitHub reads can pause a run + +Use `.claude/bin/super-board-github-read.py` for required API evidence. It retries +one failed read at most twice (three total attempts); invalid queries or missing +authority halt immediately. Exit **79** means stop dispatch and pause the current +run. Never substitute an empty board, OPEN issue, full quota, or empty diff. +Check the helper's `--check` before each write, migration, or merge. Preserve +worktrees, claims, card/approval state, and the shared local halt record. Report +the reason locally when GitHub cannot accept a comment. Do not blindly retry a +mutation: a lost response may conceal a successful write. Confirm its remote +outcome once service is available before resuming. Recovery and workflow +checkpoint limitations: [run-workflow.md](run-workflow.md#required-github-evidence-and-a-paused-run). + ## Orchestrator delegation contract (NON-NEGOTIABLE) **Super-board is an autonomous trader. The interactive Claude session that invokes `super-board run` is an orchestrator, NOT a worker.** Its only jobs are: @@ -262,7 +275,7 @@ The agent may turn a `proceed` into a `hold` on evidence. It may turn a mechanic | `hold` β€” too big | move card to Blocked | `❓` Β· "too big β€” likely over <cap> changed lines / spans <areas>" Β· suggests splitting with `/to-tickets` into vertical slices, each under the cap Β· `blocked-by: -` | | `sequence` β€” `blockedBy` non-empty | move card to Blocked | `⏳` Β· the overlapping PR and files Β· `blocked-by: <blockedBy>` β€” the wave-start sweep frees it | | `sequence` β€” `blockedBy` empty | proceed (card stays Ready), comment `⚠️ expected conflict with PR #<P> on <files> β€” the merge gate will rebase` | β€” | -| `skipped` | script exit 69 (pre-flight blind): leave the card in Ready, untouched, retried next wave | β€” | +| `halted` | script exit 79: required evidence unavailable; leave the card untouched, stop this run, resume explicitly after recovery | β€” | A card the pre-flight agent returns no verdict for is treated as `skipped` (a verdict, not a column), never built unchecked. `qa` cards are not pre-flighted: nothing is built. @@ -736,7 +749,7 @@ Workers share the dispatcher's gh-auth token bucket. The dispatcher's `gh_rate_g Before releasing the claim assignee and exiting, every worker MUST verify: - [ ] Issue comment AND PR comment both written (per "Commenting cadence" above). -- [ ] Card column move's mutation returned success. **Do NOT re-query `gh project item-list` for verification** β€” trust the mutation exit code (the 500-item GraphQL refetch was the per-worker quota tax; see `rate-limit-etiquette.md` Β§3). If the mutation returned non-zero, call `sb_gh_guard_check 200`, retry the move ONCE, and if it still fails, leave the assignee in place and write a halt comment. +- [ ] Card column move's mutation returned success. **Do NOT re-query `gh project item-list` for verification** β€” trust the mutation exit code (the 500-item GraphQL refetch was the per-worker quota tax; see `rate-limit-etiquette.md` Β§3). If the mutation returned non-zero, preserve its unknown outcome, leave the assignee in place, and pause the run locally. Reconcile the remote card state before any repeat; never blindly retry a write or depend on a successful halt comment. - [ ] Claim assignee released (`gh issue edit --remove-assignee <bot_identity>`) and the descriptive `loop:in-*` label removed. - [ ] On failure handoff: `root-cause-hash:` line is present in the PR handoff comment (per "Root-cause hash" above). - [ ] On Block/Skip exit: the full template from `block-template.md` is populated on BOTH the issue and the PR (if a PR exists); the reason emoji is one of the nine in the vocabulary table (πŸ” πŸ’³ πŸ”‘ ❓ πŸ›‘ πŸ§‘ 🀷 πŸ“¦ 🎨). @@ -749,12 +762,12 @@ A worker that cannot satisfy this checklist must NOT release its claim. It eithe During the 2026-05-21 production run, several workers exited cleanly (process terminated, in-flight lock reaped) but **had not moved their card to the next column**. The dispatcher correctly re-dispatched (lane idle + card still in source column = re-fire) β€” but each retry burned a full lane cycle (~10 min) before the next worker tried again. The #382 Reviewer took **5 attempts Γ— ~10 min = ~50 min** to move a card that the first attempt should have moved. Suspected causes: -- Worker hit a transient gh API error on the column-move mutation, didn't retry the mutation, exited "cleanly" thinking it had moved the card. +- Worker lost the response to a column move and exited "cleanly" without confirming its outcome. Preserve the unknown outcome and pause; do not claim success or blindly repeat the mutation. - Worker's super-build/super-qa/super-review skill silently caught the move error and proceeded to assignee-release without surfacing the failure. **Mitigation for now:** the dispatcher's `reap_finished_locks` + assignee sweep + re-dispatch keep the pipeline rolling, so this is a wall-clock issue, not a correctness issue. A worker that doesn't move the card will eventually have another worker do it. -**Real fix (TODO):** require workers to call `sb_gh_guard_check` (or equivalent retry-with-backoff) around the column-move mutation, and to write a `move-mutation-result: ok|err|skipped` line in the PR handoff comment so the dispatcher can log retries and budget for them. See follow-up issue (file via `/super-board run`-time review). +**Required behavior:** check the shared halt record before a column move, attempt the mutation once, and preserve `move-mutation-result: ok|unknown|skipped` in the handoff. An uncertain outcome pauses the run for read-only reconciliation; it must never trigger blind replay. ### Known issue β€” lane-zombie workers (added 2026-05-24, auto-remediated) diff --git a/skills/super-collect/scripts/super-collect-file.sh b/skills/super-collect/scripts/super-collect-file.sh index a6c59097..0149a9a4 100755 --- a/skills/super-collect/scripts/super-collect-file.sh +++ b/skills/super-collect/scripts/super-collect-file.sh @@ -201,6 +201,9 @@ case "$TYPE" in esac cat "$ERR" >&2; rm -f "$ERR" N=$(echo "$OUT" | tail -1) +# A child may have created the issue before a required placement read failed. +# Preserve its identity without tagging or making further writes after the halt. +if [ "$RC" -eq 79 ]; then [ -z "$N" ] || echo "$N"; exit 79; fi case "$N" in ''|*[!0-9]*) die "filer failed (exit ${RC})" "$([ "$RC" -ne 0 ] && echo "$RC" || echo 70)" ;; esac tag "$N" "source:collect" "collect:${SOURCE}" ${EXTRA_LABELS[@]+"${EXTRA_LABELS[@]}"} echo "$N" diff --git a/skills/super-review/SKILL.md b/skills/super-review/SKILL.md index 963bc6d3..67b795c8 100644 --- a/skills/super-review/SKILL.md +++ b/skills/super-review/SKILL.md @@ -356,6 +356,11 @@ two complete builds. In order, no shortcuts: - The rule in one line: the robot migrates the databases it was allowed to test its work; live databases, money, auth and destructive schema changes wait for a person. 3. **Confirm the merge landed** β€” never trust the merge command's exit code: + **Exit 79 β€” required GitHub evidence unavailable:** stop the current run. Leave + the card, approval, claims, and worktree intact. Report the local halt reason; + do not classify missing data as safe, bounce it to Build, or post a new approval + request. Explicit recovery is in run-workflow.md β†’ Required GitHub evidence. + `gh pr view <PR> --json state,mergeCommit` must report `MERGED` plus a commit sha, and that sha must be an ancestor of the base branch (`git merge-base --is-ancestor <sha> origin/<base>`). diff --git a/tests/test-collect-file.sh b/tests/test-collect-file.sh index e93882fb..2cd0fc90 100644 --- a/tests/test-collect-file.sh +++ b/tests/test-collect-file.sh @@ -12,6 +12,7 @@ set -uo pipefail REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" SCRIPT="$REPO_ROOT/skills/super-collect/scripts/super-collect-file.sh" WORK="$(mktemp -d)" +export SB_GITHUB_RETRY_DELAY=0 SB_GITHUB_HALT_FILE="$WORK/halt.json" trap 'rm -rf "$WORK"' EXIT PASS=0; FAIL=0 @@ -77,7 +78,7 @@ verify_commands run without npm ci; install before typecheck. MD # gh stub. Env switches: -# HITS β€” JSON array the issue-list dedupe query sees ([{number,state,body}]) +# STUB_HITS β€” JSON array the issue-list dedupe query sees ([{number,state,body}]) # STATUS_OPTS β€” Status option names on the board cat > "$WORK/gh" <<'STUB' #!/usr/bin/env bash @@ -87,7 +88,9 @@ case "$1 ${2:-}" in "issue list") jq_expr=""; prev="" for a in "$@"; do [ "$prev" = "--jq" ] && jq_expr="$a"; prev="$a"; done - echo "${HITS:-[]}" | jq -r "$jq_expr" ;; + hits="${STUB_HITS:-[]}" + case "$*" in *"--state open"*) hits=$(echo "$hits" | jq '[.[] | select(.state == "OPEN")]');; esac + if [ -n "$jq_expr" ]; then echo "$hits" | jq -r "$jq_expr"; else echo "$hits"; fi ;; "issue view") echo "https://github.com/acme/app/issues/55" ;; "issue create") prev="" @@ -144,13 +147,13 @@ has "falls back to another holding column" "$OUT" "|Todo" is "no holding column refuses" 65 "$(STATUS_OPTS="Ready QA Done" run "${BUG[@]}" >/dev/null; echo $?)" echo "── dedupe" -OUT=$(HITS='[{"number":301,"state":"OPEN","body":"x err|sentry|4411 y"}]' run "${BUG[@]}") +OUT=$(STUB_HITS='[{"number":301,"state":"OPEN","body":"x err|sentry|4411 y"}]' run "${BUG[@]}") is "open hit is a duplicate" "duplicate|#301|bug|Checkout 500 on empty cart" "$OUT" -OUT=$(HITS='[{"number":301,"state":"OPEN","body":"err|sentry|4411"}]' run "${BUG[@]}" --yes) +OUT=$(STUB_HITS='[{"number":301,"state":"OPEN","body":"err|sentry|4411"}]' run "${BUG[@]}" --yes) is "--yes on duplicate returns existing" "301" "$OUT" has "comments instead of filing" "$(cat "$GH_LOG")" "issue comment 301" lacks "no duplicate card" "$(cat "$GH_LOG")" "issue create" -OUT=$(HITS='[{"number":88,"state":"CLOSED","body":"prs|merge-gate|lockfile-drift"}]' run "${FIX[@]}") +OUT=$(STUB_HITS='[{"number":88,"state":"CLOSED","body":"prs|merge-gate|lockfile-drift"}]' run "${FIX[@]}") has "closed-only hit is a recurrence" "$OUT" "recurrence|#88" echo "── filing a bug through super-qa-file-bug.sh" @@ -163,7 +166,7 @@ has "stamps the fingerprint" "$(cat "$BODY_LOG")" "super-collect-fingerprint: e has "labels source:collect" "$(cat "$GH_LOG")" "source:collect" echo "── filing a prs fix (weak-body bypass, own section check)" -OUT=$(HITS='[{"number":88,"state":"CLOSED","body":"prs|merge-gate|lockfile-drift"}]' run "${FIX[@]}" --yes) +OUT=$(STUB_HITS='[{"number":88,"state":"CLOSED","body":"prs|merge-gate|lockfile-drift"}]' run "${FIX[@]}" --yes) is "returns the new issue" "412" "$OUT" has "files as tech-debt" "$(cat "$GH_LOG")" "tech-debt" has "names the recurrence" "$(cat "$BODY_LOG")" "#88" diff --git a/tests/test-file-bug.sh b/tests/test-file-bug.sh index a297d6bc..ab5b5d20 100755 --- a/tests/test-file-bug.sh +++ b/tests/test-file-bug.sh @@ -11,6 +11,7 @@ set -uo pipefail REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" SCRIPT="$REPO_ROOT/scripts/super-qa-file-bug.sh" WORK="$(mktemp -d)" +export SB_GITHUB_RETRY_DELAY=0 SB_GITHUB_HALT_FILE="$WORK/halt.json" trap 'rm -rf "$WORK"' EXIT PASS=0 @@ -86,8 +87,10 @@ cat > "$WORK/gh" <<'STUB' printf '%s\n' "$*" >> "$GH_LOG" case "$1 ${2:-}" in "repo view") echo "acme" ;; - "issue list") echo "${DEDUPE_HIT:-}" ;; - "issue comment") exit 0 ;; + "issue list") [ -z "${FAIL_DEDUPE:-}" ] || exit 1; if [ -n "${DEDUPE_HIT:-}" ]; then + jq -n --argjson n "$DEDUPE_HIT" '[{number:$n,body:"imports|tc-1|silent-drop OrderIntake|shallow outage|test"}]' + else echo '[]'; fi ;; + "issue comment") [ -z "${FAIL_COMMENT:-}" ] || exit 1; exit 0 ;; "issue create") for a in "$@"; do [ -f "$a" ] && cp "$a" "$BODY_CAPTURE" 2>/dev/null @@ -185,11 +188,23 @@ is "returns the existing issue" "55" "$OUT" has "comments the new sighting" "$(cat "$GH_LOG")" "issue comment 55" hasnt "files no duplicate card" "$(cat "$GH_LOG")" "issue create" -echo "── exit 71 contract" +echo "── uncertain placement pauses and preserves the created issue" OUT=$(FAIL_ITEMADD=1 run "${BASE[@]}"; echo "rc=$?") has "number still reaches stdout" "$OUT" "77" -has "signals promote failure" "$OUT" "rc=71" -has "says a manual move is needed" "$(cat "$WORK/err")" "manual move required" +has "signals promote failure" "$OUT" "rc=79" +has "says reconciliation is needed" "$(cat "$WORK/err")" "reconcile before retrying" + +echo "── required-read and uncertain-write failure" +rm -f "$SB_GITHUB_HALT_FILE" +OUT=$(FAIL_DEDUPE=1 run --title "Read outage" --body-file "$WORK/good.md" --fingerprint "outage|test"); RC=$? +is "three failed dedupe reads halt" 79 "$RC" +case "$(cat "$GH_LOG")" in *"issue create"*|*"issue comment"*) bad "unreadable dedupe never writes" "no writes" "write attempted" ;; *) ok "unreadable dedupe never writes" ;; esac +is "three dedupe attempts" 3 "$(grep -c '^issue list' "$GH_LOG")" +rm -f "$SB_GITHUB_HALT_FILE" +OUT=$(FAIL_COMMENT=1 DEDUPE_HIT=301 run --title "Write outage" --body-file "$WORK/good.md" --fingerprint "outage|test"); RC=$? +is "uncertain comment outcome halts" 79 "$RC" +is "comment is attempted once" 1 "$(grep -c '^issue comment' "$GH_LOG")" +is "failed comment does not report success ID" "" "$OUT" printf '\n%s passed, %s failed\n' "$PASS" "$FAIL" [ "$FAIL" -eq 0 ] diff --git a/tests/test-file-refactor.sh b/tests/test-file-refactor.sh index e75de286..18811963 100755 --- a/tests/test-file-refactor.sh +++ b/tests/test-file-refactor.sh @@ -11,6 +11,7 @@ set -uo pipefail REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" SCRIPT="$REPO_ROOT/scripts/super-review-file-refactor.sh" WORK="$(mktemp -d)" +export SB_GITHUB_RETRY_DELAY=0 SB_GITHUB_HALT_FILE="$WORK/halt.json" trap 'rm -rf "$WORK"' EXIT PASS=0 @@ -36,8 +37,10 @@ cat > "$WORK/gh" <<'STUB' #!/usr/bin/env bash printf '%s\n' "$*" >> "$GH_LOG" case "$1 ${2:-}" in - "issue list") echo "${DEDUPE_HIT:-}" ;; - "issue comment") exit 0 ;; + "issue list") [ -z "${FAIL_DEDUPE:-}" ] || exit 1; if [ -n "${DEDUPE_HIT:-}" ]; then + jq -n --argjson n "$DEDUPE_HIT" '[{number:$n,body:"imports|tc-1|silent-drop OrderIntake|shallow outage|test"}]' + else echo '[]'; fi ;; + "issue comment") [ -z "${FAIL_COMMENT:-}" ] || exit 1; exit 0 ;; "issue create") # Keep the rendered body. The command log holds a temp path, not its # contents, so without this a test cannot see what was actually filed. @@ -171,7 +174,19 @@ has "reports the multi-word alias intact" "$(cat "$WORK/err")" "filed #412 into OUT=$(DEDUPE_HIT="" FAIL_ITEMADD=1 run --config "$WORK/config.json" --title "Board down" \ --body-file "$WORK/body.md" --fingerprint "X|z") is "survives a board failure" "412" "$OUT" -has "warns about placement" "$(cat "$WORK/err")" "could not place it" +has "warns about placement" "$(cat "$WORK/err")" "outcome unknown" + +echo "── required-read and uncertain-write failure" +rm -f "$SB_GITHUB_HALT_FILE" +OUT=$(FAIL_DEDUPE=1 run --config "$WORK/config.json" --title "Read outage" --body-file "$WORK/body.md" --fingerprint "outage|test"); RC=$? +is "three failed dedupe reads halt" 79 "$RC" +case "$(cat "$GH_LOG")" in *"issue create"*|*"issue comment"*) bad "unreadable dedupe never writes" "no writes" "write attempted" ;; *) ok "unreadable dedupe never writes" ;; esac +is "three dedupe attempts" 3 "$(grep -c '^issue list' "$GH_LOG")" +rm -f "$SB_GITHUB_HALT_FILE" +OUT=$(FAIL_COMMENT=1 DEDUPE_HIT=301 run --config "$WORK/config.json" --title "Write outage" --body-file "$WORK/body.md" --fingerprint "outage|test"); RC=$? +is "uncertain comment outcome halts" 79 "$RC" +is "comment is attempted once" 1 "$(grep -c '^issue comment' "$GH_LOG")" +is "failed comment does not report success ID" "" "$OUT" printf '\n%s passed, %s failed\n' "$PASS" "$FAIL" [ "$FAIL" -eq 0 ] diff --git a/tests/test-merge-gate.sh b/tests/test-merge-gate.sh index 0ad16b71..5e68382f 100755 --- a/tests/test-merge-gate.sh +++ b/tests/test-merge-gate.sh @@ -16,6 +16,11 @@ fail() { echo "FAIL: $1" >&2; exit 1; } setup() { # $1 = verify_commands JSON array TMP=$(mktemp -d) + export SB_GITHUB_HALT_FILE="$TMP/halt.json" SB_GITHUB_RETRY_DELAY=0 + mkdir -p "$TMP/bin" + printf '#!/usr/bin/env bash\nexit 1\n' > "$TMP/bin/gh" + chmod +x "$TMP/bin/gh" + export PATH="$TMP/bin:$PATH" mkdir -p "$TMP/.claude/super-board/inflight" printf '{"base_branch":"staging","repo":{"path":"%s","remote":"https://github.com/x/y.git"},"verify_commands":%s}' \ "$TMP" "${1:-[]}" > "$TMP/c.json" @@ -109,19 +114,38 @@ head_setup() { printf '%s\n' "$*" >> "$GH_LOG" case "$1 $2" in "pr view") + case "$*" in *state,mergeCommit*) + [ "${STUB_STATE_FAIL:-0}" = 0 ] || { grep -q '^pr merge' "$GH_LOG" && exit 1; } + state="${STUB_STATE:-OPEN}"; oid="$STUB_OID" + if grep -q '^pr merge' "$GH_LOG"; then state="${STUB_STATE_AFTER:-OPEN}"; oid="${STUB_OID_AFTER:-$STUB_OID}"; fi + jq -n --arg state "$state" --arg head "$oid" '{state:$state,headRefOid:$head,mergeCommit:(if $state=="MERGED" then {oid:$head} else null end)}'; exit 0 ;; + esac case "$*" in *labels*) if [ -n "${STUB_META_AFTER:-}" ] && [ -f "$STUB_VERIFIED" ]; then printf '%s\n' "$STUB_META_AFTER"; exit 0; fi - if [ -n "${STUB_META:-}" ]; then printf '%s\n' "$STUB_META"; else echo '{"files":[],"labels":[]}'; fi + if [ -n "${STUB_META:-}" ]; then printf '%s\n' "$STUB_META"; else echo '{"files":[],"labels":[],"body":"","additions":0,"deletions":0,"changedFiles":0}'; fi exit 0 ;; esac oid="$STUB_OID"; grep -q '^pr merge' "$GH_LOG" && oid="${STUB_OID_AFTER:-$STUB_OID}" echo "feat $oid" ;; "pr merge") exit "${STUB_MERGE_RC:-0}" ;; - "pr diff") printf '%b' "${STUB_DIFF:-}" ;; + "pr diff") + [ "${STUB_DIFF_FAIL:-0}" = 0 ] || exit 1 + if [ -n "${STUB_DIFF:-}" ]; then printf '%b' "$STUB_DIFF"; exit 0; fi + python3 - <<'DIFF' +import json, os +m = json.loads(os.environ.get('STUB_META') or '{"files":[],"additions":0,"deletions":0}') +for i, f in enumerate(m.get('files', [])): + p = f['path']; print(f'diff --git a/{p} b/{p}\n--- a/{p}\n+++ b/{p}') + added = m.get('additions', 0) if i == 0 else 0 + removed = m.get('deletions', 0) if i == 0 else 0 + if added or removed: + print(f'@@ -1,{removed} +1,{added} @@') + print(''.join('-old\n' for _ in range(removed)) + ''.join('+new\n' for _ in range(added)), end='') +DIFF + ;; "api graphql") - [ "${STUB_APPROVAL:-0}" = 1 ] || exit 1 jq -n --arg head "$STUB_OID" '[{data:{repository:{pullRequest:{headRefOid:$head,state:"OPEN",closingIssuesReferences:{pageInfo:{hasNextPage:false,endCursor:null},nodes:[{number:42,repository:{nameWithOwner:"x/y"}}]}}}}}]' ;; "api --paginate") - [ "${STUB_APPROVAL:-0}" = 1 ] || exit 1 + [ "${STUB_APPROVAL:-0}" = 1 ] || { echo '[[]]'; exit 0; } case "$*" in */issues/42/comments*) cat "$STUB_COMMENTS" ;; */issues/1/comments*) echo '[[]]' ;; @@ -135,7 +159,7 @@ esac STUB chmod +x "$TMP/bin/gh" } -gate() { PATH="$TMP/bin:$PATH" "$GATE" --config "$TMP/c.json" --pr 1 --lock-timeout 6 "$@"; } +gate() { SB_GITHUB_RETRY_DELAY=0 SB_GITHUB_HALT_FILE="$TMP/halt.json" PATH="$TMP/bin:$PATH" "$GATE" --config "$TMP/c.json" --pr 1 --lock-timeout 6 "$@"; } # 8 β€” head moved after review: the reviewed sha no longer matches the PR head. # Exit 6, and no merge is attempted on evidence gathered for another commit. @@ -166,14 +190,26 @@ teardown # 10 β€” a push lands during verification: GitHub refuses the pinned merge and the # head has moved, so this is void evidence (6), not branch protection (3). head_setup -RC=0; STUB_OID="$SHA" STUB_OID_AFTER=cafef00d STUB_MERGE_RC=1 gate --expect-head "$SHA" >/dev/null 2>&1 || RC=$? +RC=0; STUB_OID="$SHA" STUB_OID_AFTER=cafef00dcafef00dcafef00dcafef00dcafef00d STUB_MERGE_RC=1 gate --expect-head "$SHA" >/dev/null 2>&1 || RC=$? [ "$RC" -eq 6 ] || fail "a head that moved mid-gate should exit 6, got $RC" teardown # 11 β€” same refusal with the head unchanged is still GitHub saying no (3). head_setup RC=0; STUB_OID="$SHA" STUB_MERGE_RC=1 gate --expect-head "$SHA" >/dev/null 2>&1 || RC=$? -[ "$RC" -eq 3 ] || fail "a refusal with an unchanged head should exit 3, got $RC" +[ "$RC" -eq 79 ] || fail "unconfirmed merge must pause for reconciliation, got $RC" +teardown + +# A lost merge response is reconciled, never blindly repeated. +head_setup +RC=0; STUB_OID="$SHA" STUB_STATE_AFTER=MERGED STUB_MERGE_RC=1 gate --expect-head "$SHA" >/dev/null 2>&1 || RC=$? +[ "$RC" -eq 0 ] || fail "GitHub-confirmed lost merge response must report merged" +[ "$(grep -c '^pr merge' "$GH_LOG")" -eq 1 ] || fail "merge mutation must only run once" +teardown +head_setup +RC=0; STUB_OID="$SHA" STUB_STATE_FAIL=1 STUB_MERGE_RC=1 gate --expect-head "$SHA" >/dev/null 2>&1 || RC=$? +[ "$RC" -eq 79 ] || fail "unreadable merge outcome must halt" +[ "$(grep -c '^pr merge' "$GH_LOG")" -eq 1 ] || fail "unknown merge outcome must not replay mutation" teardown # 12 β€” post-merge cleanup: when the cleanup-wt hook is installed the gate runs it with @@ -194,7 +230,7 @@ head_setup mkdir -p "$TMP/.claude/hooks" printf 'open("%s/cleanup.ran","w")\n' "$TMP" > "$TMP/.claude/hooks/cleanup-wt.py" RC=0; STUB_OID="$SHA" STUB_MERGE_RC=1 gate --expect-head "$SHA" >/dev/null 2>&1 || RC=$? -[ "$RC" -eq 3 ] || fail "a refused merge should still exit 3, got $RC" +[ "$RC" -eq 79 ] || fail "an unconfirmed merge should halt, got $RC" [ ! -f "$TMP/cleanup.ran" ] || fail "cleanup must not run when the merge was refused" teardown @@ -205,9 +241,19 @@ meta() { # $1 labels csv, $2 files csv, $3 body, $4 size jq -cn --arg l "$1" --arg f "$2" --arg b "${3:-}" --argjson n "${4:-1}" \ '{labels: ($l | split(",") | map(select(. != "") | {name: .})), files: ($f | split(",") | map(select(. != "") | {path: .})), - additions: $n, deletions: 0, body: $b}' + additions: $n, deletions: 0, body: $b} | .changedFiles = (.files | length)' } +# Required policy evidence: an unavailable diff must halt before migrations or merge. +head_setup +cfgset ".migrations = {allowed_envs: [\"staging\"], target_env: \"staging\", commands: {staging: \"touch $TMP/ran\"}}" +RC=0; SB_GITHUB_RETRY_DELAY=0 STUB_DIFF_FAIL=1 STUB_OID="$SHA" STUB_META="$(meta "" supabase/migrations/1.sql)" gate --expect-head "$SHA" >/dev/null 2>&1 || RC=$? +[ "$RC" -eq 79 ] || fail "failed required diff must halt run (79), got $RC" +[ ! -f "$TMP/ran" ] || fail "no migration may run without policy evidence" +grep -q '^pr merge' "$GH_LOG" && fail "no merge may run without policy evidence" +[ "$(grep -c '^pr diff' "$GH_LOG")" -eq 3 ] || fail "exactly three failed read attempts" +teardown + # 14 β€” a money label: a human merges. Exit 7, the category is named, no merge. head_setup RC=0; OUT=$(STUB_OID="$SHA" STUB_META="$(meta money src/x.ts)" gate --expect-head "$SHA" 2>/dev/null) || RC=$? @@ -251,7 +297,7 @@ BIG=$(jq -cn '{labels: [], body: "", additions: 2300, deletions: 0, files: [ {path: "package-lock.json", additions: 900, deletions: 0}, {path: "src/__snapshots__/x.test.ts.snap", additions: 500, deletions: 0}, {path: "src/api/__generated__/types.ts", additions: 400, deletions: 0}, - {path: "db/migrations/001_add.sql", additions: 200, deletions: 0}]}') + {path: "db/migrations/001_add.sql", additions: 200, deletions: 0}], changedFiles: 5}') cfgset '.migrations = {allowed_envs: ["test"], target_env: "test", commands: {test: "true"}}' RC=0; STUB_OID="$SHA" STUB_META="$BIG" gate --expect-head "$SHA" >/dev/null 2>&1 || RC=$? [ "$RC" -eq 0 ] || fail "300 counted lines (rest excluded) should merge, got $RC" @@ -268,9 +314,9 @@ teardown # 16 β€” a destructive keyword counts in an ADDED line only. Deleting a DROP TABLE # is not adding one. head_setup -RC=0; STUB_OID="$SHA" STUB_META="$(meta "" db/x.sql)" STUB_DIFF='+DROP TABLE users;\n' gate --expect-head "$SHA" >/dev/null 2>&1 || RC=$? +RC=0; STUB_OID="$SHA" STUB_META="$(meta "" db/x.sql)" STUB_DIFF='diff --git a/src/x.ts b/src/x.ts\n--- a/src/x.ts\n+++ b/src/x.ts\n@@ -0,0 +1 @@\n+DROP TABLE users;\n' gate --expect-head "$SHA" >/dev/null 2>&1 || RC=$? [ "$RC" -eq 7 ] || fail "an added DROP TABLE should exit 7 (schema), got $RC" -RC=0; STUB_OID="$SHA" STUB_META="$(meta "" db/x.sql)" STUB_DIFF='-DROP TABLE users;\n' gate --expect-head "$SHA" >/dev/null 2>&1 || RC=$? +RC=0; STUB_OID="$SHA" STUB_META="$(meta "" db/x.sql | jq ' .additions=0 | .deletions=1')" STUB_DIFF='diff --git a/db/x.sql b/db/x.sql\n--- a/db/x.sql\n+++ b/db/x.sql\n@@ -1 +0,0 @@\n-DROP TABLE users;\n' gate --expect-head "$SHA" >/dev/null 2>&1 || RC=$? [ "$RC" -eq 0 ] || fail "a removed DROP TABLE must not gate, got $RC" teardown @@ -309,7 +355,8 @@ grep -q '^pr merge' "$GH_LOG" && fail "old approval must never reach merge" REQUEST=$(echo "$REQUEST" | jq --arg head "$SHA" '.head=$head') make_approval RC=0; STUB_PERMISSION_FAIL=1 STUB_OID="$SHA" STUB_META="$(meta money src/x.ts)" gate --expect-head "$SHA" >/dev/null 2>&1 || RC=$? -[ "$RC" -eq 7 ] || fail "unreadable authority must hold, got $RC" +[ "$RC" -eq 79 ] || fail "unreadable authority must halt, got $RC" +rm -f "$SB_GITHUB_HALT_FILE" # next test starts a separate recovered run jq '.[0] += [{id:3,body:"Reason tag: πŸ™‹ new decision",created_at:"2026-10-03T00:00:03Z",updated_at:"2026-10-03T00:00:03Z",user:{login:"owner",type:"User"}}]' "$STUB_COMMENTS" > "$TMP/new.json" mv "$TMP/new.json" "$STUB_COMMENTS" RC=0; STUB_OID="$SHA" STUB_META="$(meta money,needs-you:done src/x.ts)" gate --expect-head "$SHA" >/dev/null 2>&1 || RC=$? @@ -409,7 +456,7 @@ teardown # 23 β€” metadata the gate cannot read fails safe to a human (7), never to merge. head_setup RC=0; STUB_OID="$SHA" STUB_META='not json' gate --expect-head "$SHA" >/dev/null 2>&1 || RC=$? -[ "$RC" -eq 7 ] || fail "unreadable PR metadata should exit 7, got $RC" +[ "$RC" -eq 79 ] || fail "unreadable PR metadata should halt (79), got $RC" teardown # 24 β€” dry run: migrations are reported, never executed. diff --git a/tests/test-pr-body.sh b/tests/test-pr-body.sh index 485be40b..aa474d71 100644 --- a/tests/test-pr-body.sh +++ b/tests/test-pr-body.sh @@ -8,6 +8,7 @@ set -uo pipefail REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" SCRIPT="$REPO_ROOT/scripts/super-board-pr-body.sh" WORK="$(mktemp -d)" +export SB_GITHUB_RETRY_DELAY=0 SB_GITHUB_HALT_FILE="$WORK/halt.json" trap 'rm -rf "$WORK"' EXIT PASS=0; FAIL=0 diff --git a/tests/test-preflight.sh b/tests/test-preflight.sh index 49d4c0e4..75701a7d 100755 --- a/tests/test-preflight.sh +++ b/tests/test-preflight.sh @@ -11,7 +11,7 @@ PRE="$(pwd)/../scripts/super-board-preflight.sh" fail() { echo "FAIL: $1" >&2; exit 1; } -TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT +TMP=$(mktemp -d); export SB_GITHUB_RETRY_DELAY=0 SB_GITHUB_HALT_FILE="$TMP/halt.json"; trap 'rm -rf "$TMP"' EXIT mkdir -p "$TMP/bin" "$TMP/fx"; export GH_LOG="$TMP/gh.log" FX="$TMP/fx" cat > "$TMP/bin/gh" <<'STUB' #!/usr/bin/env bash @@ -100,7 +100,7 @@ echo "$OUT8b" | jq -e '.["26"].verdict == "proceed"' >/dev/null \ # 9 β€” a blind pre-flight never says proceed: gh failing exits 69. RC=0; PATH="$TMP/bin:$PATH" "$PRE" --repo o/r --issues 99 >/dev/null 2>&1 || RC=$? -[ "$RC" -eq 69 ] || fail "an unreadable issue should exit 69, got $RC" +[ "$RC" -eq 79 ] || fail "an unreadable issue should halt (79), got $RC" RC=0; "$PRE" --repo o/r >/dev/null 2>&1 || RC=$? [ "$RC" -eq 64 ] || fail "missing --issues should exit 64, got $RC" diff --git a/tests/test-run-gates.sh b/tests/test-run-gates.sh index 65fae6dc..f641ed15 100755 --- a/tests/test-run-gates.sh +++ b/tests/test-run-gates.sh @@ -28,6 +28,7 @@ case "$1 ${2:-}" in [ "$n" = "$3" ] && { echo "CLOSED"; exit 0; } done echo "OPEN" ;; + "project field-list") echo '{"fields":[{"id":"PVTSSF_stub","name":"Status","options":[{"id":"opt_ready","name":"Ready"},{"id":"opt_done","name":"Done"},{"id":"opt_review","name":"Review"}]}]}' ;; "project item-edit") exit 0 ;; "project view") echo '{"id":"PVT_stub"}' ;; "issue edit") exit 0 ;; @@ -45,7 +46,7 @@ STUB chmod +x "$STUB_DIR/gh" export PATH="$STUB_DIR:$PATH" export GH_LOG="$STUB_DIR/gh.log" -export CLOSED_ISSUES="" +export CLOSED_ISSUES="" SB_GITHUB_RETRY_DELAY=0 SB_GITHUB_HALT_FILE="$STUB_DIR/halt.json" # ── Load the dispatcher's helpers without starting a run. export SB_LIB_ONLY=1 diff --git a/tests/test-workflow-halt.sh b/tests/test-workflow-halt.sh new file mode 100644 index 00000000..443899bd --- /dev/null +++ b/tests/test-workflow-halt.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# Offline workflow boundary: a read halt ends queued lane chains and the wave. +set -euo pipefail +cd "$(dirname "$0")" +node - ../workflows/super-board-wave.js <<'JS' +const fs = require('fs') +const source = fs.readFileSync(process.argv[2], 'utf8').replace(/^export const meta/m, 'const meta') +const AsyncFunction = (async () => {}).constructor +async function run(cards, failAt) { + const calls = [] + const agent = async (_, options) => { + calls.push(options.label) + if (options.label.startsWith('preflight:')) { + return { verdicts: cards.filter(c => c.status === 'Ready').map(c => ({ number: c.number, + verdict: failAt === 'preflight' ? 'halted' : 'proceed', detail: 'required GitHub read failed' })) } + } + if (options.label.startsWith('classify:')) { + return {kind:'feature',complexity:'low',status: failAt === 'classify' ? 'halted' : 'ok'} + } + return { status: options.label.startsWith(failAt + ':') ? 'halted' : 'advanced', column:'QA', detail:'result' } + } + const pipeline = async (items, ...stages) => { + const results = [] + // Sequential scheduling makes queued work visible after a sibling trips the run. + for (const item of items) { + let result = await stages[0](item) + for (const stage of stages.slice(1)) result = await stage(result, item) + results.push(result) + } + return results + } + const output = await new AsyncFunction('args','agent','pipeline','log',source)( + {configPath:'fixture.json',cards}, agent,pipeline,()=>{}) + return {calls, output} +} +const cards = [1,2].map(number => ({number,title:'Task',status:'Ready'})) +;(async () => { + for (const failed of ['preflight','classify','build','qa']) { + const {calls,output} = await run(cards,failed) + if (!output.halted) throw Error(failed + ' must halt wave') + if (calls.includes('review:#1') || calls.includes('build:#2')) throw Error('dispatched after halt: ' + calls) + if (output.cards.some(c => c.finalStatus !== 'halted')) throw Error('halted cards must preserve stop status') + } + const {output} = await run(cards,'never') + if (output.halted || output.cards.some(c => c.finalStatus !== 'advanced')) throw Error('healthy workflow changed') + console.log('PASS: workflow halt propagation (5 scenarios)') +})().catch(e => {console.error(e);process.exit(1)}) +JS diff --git a/tests/test_approval.py b/tests/test_approval.py index 9b4fcce6..26148543 100644 --- a/tests/test_approval.py +++ b/tests/test_approval.py @@ -182,7 +182,7 @@ def test_live_planner_resume_requires_current_trusted_request(self): f=json.load(open(os.environ["APPROVAL_FIXTURE"])) args=" ".join(sys.argv[1:]) if "issues(states:OPEN" in args: - out=[{"data":{"repository":{"issues":{"nodes":[f["issue"]]}}}}] + out=[{"data":{"repository":{"issues":{"nodes":[f["issue"]],"pageInfo":{"hasNextPage":False,"endCursor":None}}}}}] elif "graphql" in args: out=[{"data":{"repository":{"pullRequest":{"headRefOid":f["head"],"state":"OPEN","closingIssuesReferences":{"pageInfo":{"hasNextPage":False},"nodes":[{"number":3,"repository":{"nameWithOwner":"x/y"}}]}}}}}] elif "/issues/3/comments" in args: @@ -198,7 +198,7 @@ def test_live_planner_resume_requires_current_trusted_request(self): print(json.dumps(out)) ''') stub.chmod(0o755) - env = dict(os.environ, PATH=str(work) + os.pathsep + os.environ["PATH"], APPROVAL_FIXTURE=str(fixture_path)) + env = dict(os.environ, PATH=str(work) + os.pathsep + os.environ["PATH"], APPROVAL_FIXTURE=str(fixture_path), SB_GITHUB_RETRY_DELAY="0", SB_GITHUB_HALT_FILE=str(work / "halt.json")) def deps(): fixture_path.write_text(json.dumps(fixture)) run = subprocess.run([str(ROOT / "scripts/super-board-deps.sh"), "--repo", "x/y"], env=env, diff --git a/tests/test_github_reads.py b/tests/test_github_reads.py new file mode 100644 index 00000000..0b4ccc1e --- /dev/null +++ b/tests/test_github_reads.py @@ -0,0 +1,223 @@ +#!/usr/bin/env python3 +"""Offline CLI contract: bounded required reads, validated evidence, local stop.""" +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +HELPER = ROOT / 'scripts/super-board-github-read.py' + + +class RequiredReadTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.root = Path(self.temp.name) + self.log = self.root / 'calls' + self.halt = self.root / 'halt.json' + gh = self.root / 'gh' + gh.write_text('''#!/usr/bin/env python3 +import json, os, pathlib, sys +p = pathlib.Path(os.environ['READ_CALLS']) +n = int(p.read_text()) if p.exists() else 0 +p.write_text(str(n + 1)) +responses = json.loads(os.environ['READ_RESPONSES']) +r = responses[min(n, len(responses)-1)] +print(r.get('out', ''), end='') +print(r.get('err', ''), file=sys.stderr) +sys.exit(r.get('rc', 0)) +''') + gh.chmod(0o755) + self.env = dict(os.environ, PATH=f'{self.root}:{os.environ["PATH"]}', + READ_CALLS=str(self.log), SB_GITHUB_HALT_FILE=str(self.halt), + SB_GITHUB_RETRY_DELAY='0') + + def tearDown(self): + self.temp.cleanup() + + def read(self, responses, kind='json', command=None): + self.env['READ_RESPONSES'] = json.dumps(responses) + return subprocess.run(['python3', str(HELPER), '--kind', kind, '--', + *(command or ['api', 'rate_limit'])], + env=self.env, text=True, capture_output=True, cwd=self.root) + + def test_three_failed_attempts_halt_without_fabricated_output(self): + r = self.read([{'rc': 1, 'err': 'HTTP 503 unavailable'}]) + self.assertEqual(r.returncode, 79, r.stderr) + self.assertEqual(self.log.read_text(), '3') + self.assertEqual(r.stdout, '') + self.assertEqual(json.loads(self.halt.read_text())['attempts'], 3) + again = self.read([{'out': '{}'}]) + self.assertEqual(again.returncode, 79) + self.assertEqual(self.log.read_text(), '3', 'halted run must not read again') + + def test_recovery_resets_each_logical_read(self): + response = [{'rc': 1}, {'out': '{}'}, {'rc': 1}, {'rc': 1}, {'out': '{}'}] + self.assertEqual(self.read(response).returncode, 0) + self.assertEqual(self.read(response).returncode, 0) + self.assertEqual(self.log.read_text(), '5') + self.assertFalse(self.halt.exists()) + + def test_permanent_graphql_error_is_not_retried(self): + r = self.read([{'rc': 1, 'err': 'GraphQL: Cannot query field wrong on type Query'}], + command=['api', 'graphql', '-f', 'query=query { wrong }']) + self.assertEqual(r.returncode, 79) + self.assertEqual(self.log.read_text(), '1') + + def test_successful_exit_with_malformed_or_partial_payload_halts(self): + for out, kind in [('not json', 'json'), ('{"resources":{"graphql":null,"core":null}}', 'quota'), ('{"unexpected":true}', 'issue'), ('[{}]', 'prs-open'), ('{"items":[{"id":"I","content":{}}],"totalCount":1}', 'items'), ('{"items":[]}', 'items'), + ('{"items":[],"totalCount":1}', 'items'), + ('{"data":{},"errors":[{"message":"partial"}]}', 'json')]: + with self.subTest(out=out): + self.halt.unlink(missing_ok=True) + self.log.unlink(missing_ok=True) + r = self.read([{'out': out}], kind) + self.assertEqual(r.returncode, 79) + self.assertEqual(self.log.read_text(), '3') + self.assertEqual(r.stdout, '') + + def test_no_mutation_can_be_retried(self): + for command in [['pr', 'merge', '1'], ['issue', 'comment', '1'], + ['api', 'graphql', '-f', 'query=mutation { deleteIssue }'], + ['api', 'graphql', '-f', 'query=query Safe { viewer { login } } mutation Unsafe { deleteIssue }', '-f', 'operationName=Unsafe'], + ['api', 'repos/x/y/issues', '-XPOST'], + ['api', 'repos/x/y/issues', '-f', 'title=hi']]: + with self.subTest(command=command): + r = self.read([{'out': '{}'}], command=command) + self.assertEqual(r.returncode, 64) + self.assertFalse(self.log.exists()) + + def test_clean_empty_board_is_valid(self): + r = self.read([{'out': '{"items":[],"totalCount":0}'}], 'items') + self.assertEqual(r.returncode, 0, r.stderr) + self.assertEqual(self.log.read_text(), '1') + + def test_incomplete_dependency_pages_are_not_a_graph(self): + page = {'data': {'repository': {'issues': { + 'nodes': [], 'pageInfo': {'hasNextPage': True, 'endCursor': 'next'}}}}} + r = self.read([{'out': json.dumps([page])}], 'issues') + self.assertEqual(r.returncode, 79) + self.assertEqual(self.log.read_text(), '3') + + def test_failed_recovery_keeps_original_reason(self): + self.assertEqual(self.read([{'rc': 1}]).returncode, 79) + original = self.halt.read_text() + result = subprocess.run(['python3', str(HELPER), '--resume'], env=self.env, capture_output=True) + self.assertEqual(result.returncode, 79) + self.assertEqual(self.halt.read_text(), original) + + def test_legacy_read_failure_preserves_snapshot_and_prevents_dispatch(self): + self.env['READ_RESPONSES'] = json.dumps([{'rc': 1}]) + script = """SB_LIB_ONLY=1 . "$1" +set +e +PROJECT_ITEMS_JSON='{"items":[{"id":"preserved"}]}' +fetch_project_items; rc=$? +printf '%s %s\n' "$rc" "$PROJECT_ITEMS_JSON" +dispatch_lane build 7 +""" + result = subprocess.run(['bash', '-c', script, 'test', str(ROOT / 'scripts/super-board-run.sh')], + cwd=self.root, env=self.env, text=True, capture_output=True) + self.assertEqual(result.returncode, 79, result.stderr) + self.assertIn('79 {"items":[{"id":"preserved"}]}', result.stdout) + self.assertEqual(self.log.read_text(), '3', 'no claim/worker or extra API call after halt') + + def test_unknown_issue_state_never_means_open(self): + self.env['READ_RESPONSES'] = json.dumps([{'rc': 1}]) + script = 'SB_LIB_ONLY=1 . "$1"; issue_is_open 7' + result = subprocess.run(['bash', '-c', script, 'test', str(ROOT / 'scripts/super-board-run.sh')], + cwd=self.root, env=self.env, capture_output=True) + self.assertEqual(result.returncode, 79) + self.assertEqual(self.log.read_text(), '3') + + def test_missing_quota_never_means_full_allowance(self): + self.env['READ_RESPONSES'] = json.dumps([{'rc': 1}]) + result = subprocess.run(['bash', '-c', '. "$1"; sb_gh_guard_check 200', 'test', + str(ROOT / 'scripts/super-board-gh-guard.sh')], cwd=self.root, + env=self.env, capture_output=True) + self.assertEqual(result.returncode, 79) + self.assertEqual(self.log.read_text(), '3') + + def test_truncated_diff_is_not_complete_evidence(self): + metadata = self.root / 'meta.json' + metadata.write_text(json.dumps({'files':[{'path':'x.sql'}], 'additions':2,'deletions':0})) + self.env['READ_RESPONSES'] = json.dumps([{'out':'diff --git a/x.sql b/x.sql\n@@ -0,0 +1,2 @@\n+one\n'}]) + result = subprocess.run(['python3', str(HELPER), '--kind', 'diff', '--meta', str(metadata), + '--', 'pr', 'diff', '1'], env=self.env, cwd=self.root, capture_output=True) + self.assertEqual(result.returncode, 79) + self.assertEqual(result.stdout, b'') + self.assertEqual(self.log.read_text(), '3') + + def test_corrected_query_can_resume_without_erasing_original_failure(self): + bad = ['api', 'graphql', '-f', 'query=query { wrong }'] + self.assertEqual(self.read([{'rc':1,'err':'Cannot query field wrong'}], command=bad).returncode,79) + original = self.halt.read_text() + self.env['READ_RESPONSES'] = json.dumps([{'out':'{"resources":{"graphql":{"remaining":5000,"reset":0},"core":{"remaining":5000,"reset":0}}}'}]) + result = subprocess.run(['python3',str(HELPER),'--resume','--kind','json','--', + 'api','graphql','-f','query=query { viewer { login } }'], + env=self.env,cwd=self.root,capture_output=True) + self.assertEqual(result.returncode,0,result.stderr) + self.assertFalse(self.halt.exists()) + records = list(self.root.glob('github-halt-*.json')) + self.assertEqual(len(records),1) + self.assertEqual(records[0].read_text(),original) + + def test_failed_closed_card_reconcile_keeps_claim_and_lock(self): + lock = self.root / 'inflight'; lock.mkdir() + # No lock initially: the card is eligible for the closed-card reconcile path. + self.env['READ_RESPONSES'] = json.dumps([{'out':'CLOSED'}, {'rc':1}]) + script = """SB_LIB_ONLY=1 . "$1" +INFLIGHT_DIR="$2/inflight"; RUN_MANIFEST="$2/run.log"; BOT_LOGIN=robot +STATUS_FIELD_ID=field; STATUS_OPTIONS_JSON='[{"id":"done","name":"Done"}]' +PROJECT_ITEMS_JSON='{"items":[{"id":"item","status":"Review","content":{"number":7,"type":"Issue","assignees":[]}}]}' +top_card_in_column Review +""" + result = subprocess.run(['bash','-c',script,'test',str(ROOT / 'scripts/super-board-run.sh'),str(self.root)], + env=self.env,cwd=self.root,capture_output=True) + self.assertEqual(result.returncode,79,result.stderr) + self.assertEqual(self.log.read_text(),'4','one state read, three failed project reads, no claim release') + + def test_dedupe_capped_or_malformed_data_is_never_no_match(self): + for data in [[{}], [{'number': n+1, 'body':'old'} for n in range(200)]]: + self.halt.unlink(missing_ok=True); self.log.unlink(missing_ok=True) + result = self.read([{'out':json.dumps(data)}],'dedupe', ['issue','list','--json','number,body']) + self.assertEqual(result.returncode,79) + self.assertEqual(result.stdout,'') + self.assertEqual(self.log.read_text(),'3') + + def test_legacy_mutation_failure_pauses_without_retry_or_lock_removal(self): + inflight = self.root / 'inflight'; inflight.mkdir() + lock = inflight / '7'; lock.write_text('PID=\nLANE=build\n') + self.env['READ_RESPONSES'] = json.dumps([{'rc':1}]) + script = 'SB_LIB_ONLY=1 . "$1"; INFLIGHT_DIR="$2/inflight"; RUN_MANIFEST="$2/run.log"; BOT_LOGIN=robot; reap_finished_locks' + result = subprocess.run(['bash','-c',script,'test',str(ROOT / 'scripts/super-board-run.sh'),str(self.root)], + env=self.env,cwd=self.root,capture_output=True) + self.assertEqual(result.returncode,79) + self.assertEqual(self.log.read_text(),'1') + self.assertTrue(self.halt.exists()) + self.assertTrue(lock.exists()) + + def test_worktrees_share_halt_and_explicit_restart_preserves_reason(self): + self.env.pop('SB_GITHUB_HALT_FILE') + main = self.root / 'main'; main.mkdir() + subprocess.run(['git', 'init', '-q', str(main)], check=True) + subprocess.run(['git', '-C', str(main), '-c', 'user.name=test', '-c', 'user.email=test@test', + 'commit', '--allow-empty', '-qm', 'initial'], check=True) + linked = self.root / 'linked' + subprocess.run(['git', '-C', str(main), 'worktree', 'add', '-q', '--detach', str(linked)], check=True) + self.env['SB_REPO_PATH'] = str(linked) + self.assertEqual(self.read([{'rc': 1}]).returncode, 79) + marker = main / '.claude/super-board/github-halt.json' + self.assertTrue(marker.exists()) + self.env['SB_REPO_PATH'] = str(main) + self.assertEqual(self.read([{'out': '{}'}]).returncode, 79) + self.env['READ_RESPONSES'] = json.dumps([{'out': '{"resources":{"graphql":{"remaining":5000,"reset":0},"core":{"remaining":5000,"reset":0}}}'}]) + subprocess.run(['python3', str(HELPER), '--resume'], env=self.env, check=True) + self.assertFalse(marker.exists()) + self.assertEqual(len(list(marker.parent.glob('github-halt-*.json'))), 1) + self.assertEqual(self.read([{'out': '{}'}]).returncode, 0) + + +if __name__ == '__main__': + unittest.main() diff --git a/workflows/super-board-wave.js b/workflows/super-board-wave.js index eae9740e..d9f867f4 100644 --- a/workflows/super-board-wave.js +++ b/workflows/super-board-wave.js @@ -39,10 +39,14 @@ if (input.tier && !['low', 'medium', 'high'].includes(input.tier)) { throw new Error(`super-board-wave: unknown tier "${input.tier}" β€” use low | medium | high`) } +let halted = false +const READ_FAILURE = `Required GitHub reads use .claude/bin/super-board-github-read.py: three total attempts, exit 79 means run halted. Check --check before any GitHub write, migration, or merge. On exit 79 preserve worktree/card/approval/claims, make no more GitHub calls, and report status=halted (preflight verdict=halted). Never retry a mutation. Config: ${input.configPath}.` + const CLASSIFY_SCHEMA = { type: 'object', properties: { kind: { type: 'string', enum: ['feature', 'bug', 'qa'] }, + status: { type: 'string', enum: ['ok', 'halted'] }, complexity: { type: 'string', enum: ['low', 'medium', 'high'] }, }, required: ['kind', 'complexity'], @@ -51,7 +55,7 @@ const CLASSIFY_SCHEMA = { const STAGE_SCHEMA = { type: 'object', properties: { - status: { type: 'string', enum: ['advanced', 'bounced', 'blocked', 'human-gate', 'failed'] }, + status: { type: 'string', enum: ['advanced', 'bounced', 'blocked', 'human-gate', 'failed', 'halted'] }, column: { type: 'string' }, detail: { type: 'string' }, prUrl: { type: 'string' }, @@ -111,6 +115,7 @@ const REVIEW_MEMORY = [ ] const lanePrompt = (lane, card) => [ + READ_FAILURE, `Run ${LANE[lane].skill} on issue #${card.number} ("${card.title}") for a super-board workflow wave.`, `Read .claude/skills/super-board/references/run.md β†’ "${LANE[lane].section}" lifecycle and follow it EXACTLY:`, `create your own worktree under .claude/worktrees/, work on the issue branch, post the required PR/issue comments,`, @@ -125,6 +130,7 @@ const lanePrompt = (lane, card) => [ `- status=advanced β†’ card moved forward (Buildingβ†’QA, QAβ†’Review, Reviewβ†’Done/merged)`, `- status=bounced β†’ card moved backward (QA fail β†’ Ready, Reviewer bounce β†’ Ready/QA)`, `- status=blocked or human-gate β†’ you wrote the Block template and moved the card to Blocked`, + `- status=halted β†’ required service evidence is unavailable; leave card state unchanged and stop the run`, `- status=failed β†’ you could not complete the lifecycle (say why in detail)`, `column = the column the card is in when you exit. detail = one line. Include prUrl/branch when they exist.`, ].join('\n') @@ -147,6 +153,11 @@ const tierFor = (cls) => (cls ? ladder[cls.complexity] : undefined) const classifyModel = (input.tier || 'medium') === 'high' ? 'sonnet' : 'haiku' const runLane = async (lane, card, model, history) => { + if (halted) { + const result = { status: 'halted', column: card.status, detail: 'run halted by a required GitHub read failure' } + history.push({ lane, ...result }) + return result + } const r = await agent(lanePrompt(lane, card), { label: `${lane}:#${card.number}`, phase: LANE[lane].phase, @@ -154,6 +165,7 @@ const runLane = async (lane, card, model, history) => { ...(model ? { model } : {}), }) const result = r || { status: 'failed', column: 'unknown', detail: `${lane} agent returned no result` } + if (result.status === 'halted') halted = true history.push({ lane, ...result }) return result } @@ -177,7 +189,7 @@ const PREFLIGHT_SCHEMA = { type: 'object', properties: { number: { type: 'integer' }, - verdict: { type: 'string', enum: ['proceed', 'hold', 'sequence', 'skipped'] }, + verdict: { type: 'string', enum: ['proceed', 'hold', 'sequence', 'skipped', 'halted'] }, column: { type: 'string' }, detail: { type: 'string' }, }, @@ -190,6 +202,7 @@ const PREFLIGHT_SCHEMA = { const PREFLIGHT_BATCH = 5 const preflightModel = (input.tier || 'medium') === 'low' ? 'haiku' : 'sonnet' const preflightPrompt = (batch) => [ + READ_FAILURE, `Builder pre-flight for issues ${batch.map((c) => `#${c.number} ("${c.title}")`).join(', ')}. Config: ${input.configPath}.`, `Read .claude/skills/super-board/references/run.md β†’ "Builder pre-flight" and follow it EXACTLY. You write no code.`, `Other cards in this wave: ${JSON.stringify(input.cards.map(({ number, status, title }) => ({ number, status, title })))}`, @@ -210,7 +223,10 @@ await Promise.all(batches.map(async (batch) => { model: preflightModel, schema: PREFLIGHT_SCHEMA, }) - for (const v of (r && r.verdicts) || []) verdicts.set(v.number, v) + for (const v of (r && r.verdicts) || []) { + verdicts.set(v.number, v) + if (v.verdict === 'halted') halted = true + } })) // sequence with nothing to wait on = proceed with a conflict note (the agent posted it). const preflightGo = (card) => { @@ -222,8 +238,8 @@ const preflightExit = (card) => { { verdict: 'skipped', detail: 'pre-flight returned no verdict β€” not built unchecked; retried next wave' } return { lane: 'preflight', - status: v.verdict === 'skipped' ? 'failed' : 'blocked', - column: v.column || (v.verdict === 'skipped' ? 'Ready' : 'Blocked'), + status: v.verdict === 'halted' ? 'halted' : v.verdict === 'skipped' ? 'failed' : 'blocked', + column: v.column || (['skipped', 'halted'].includes(v.verdict) ? 'Ready' : 'Blocked'), detail: `${v.verdict}: ${v.detail}`, } } @@ -232,11 +248,12 @@ const results = await pipeline( input.cards, // Stage 1: classify cards entering at Ready (router for model tiering) async (card) => { + if (halted) return { card, cls: null } if (card.status !== 'Ready') return { card, cls: null } const labels = labelsOf(card) const typed = ['qa', 'bug', 'feature'].find((l) => labels.includes(l)) const cls = await agent( - `Read GitHub issue #${card.number} ("${card.title}") β€” body and all comments β€” using gh issue view. ` + + READ_FAILURE + '\n' + `Read GitHub issue #${card.number} ("${card.title}") β€” body and all comments β€” using gh issue view. ` + `Classify it: kind (feature|bug|qa) and complexity (low|medium|high) judged by the scope of change required. ` + (typed ? `Its label says "${typed}": return kind "${typed}" β€” the label routes the card, you never override it.` @@ -244,6 +261,7 @@ const results = await pipeline( `which only a person sets β€” and add that label: gh issue edit ${card.number} --add-label <kind>.`), { label: `classify:#${card.number}`, phase: 'Classify', model: classifyModel, schema: CLASSIFY_SCHEMA } ) + if (cls && cls.status === 'halted') halted = true return { card, cls } }, // Stage 2: lane chain β€” entry point depends on the card's current column. @@ -251,6 +269,7 @@ const results = await pipeline( // from wherever it landed (the board is the loop state, not this script). async (prev, card) => { const history = [] + if (halted) return { number: card.number, history: [{ lane: 'none', status: 'halted', column: card.status, detail: 'run halted; preserve current work and resume explicitly' }] } const model = tierFor(prev && prev.cls) let at = card.status @@ -296,4 +315,4 @@ const summary = results.filter(Boolean).map((r) => { }) log(`wave complete: ${summary.length} cards β€” ` + summary.map((s) => `#${s.number}=${s.finalStatus}@${s.column}`).join(', ')) -return { cards: summary } +return { cards: summary, halted } From 3668990209065b441ff121a8c36b2a73cdcb945a Mon Sep 17 00:00:00 2001 From: Eric Tech <146783360+EricTechPro@users.noreply.github.com> Date: Sat, 3 Oct 2026 11:28:58 -0700 Subject: [PATCH 3/4] =?UTF-8?q?=F0=9F=90=9B=20[fix]=20setup:=20detect=20a?= =?UTF-8?q?=20missing=20GitHub=20reader?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Include the required reader in setup checks and repair. - Cover removal and repair of both safety helpers. - Pass setup and install checks plus independent delta review. --- scripts/super-board-setup.py | 2 +- tests/test-setup.sh | 13 ++++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/scripts/super-board-setup.py b/scripts/super-board-setup.py index 92632b75..534e2218 100755 --- a/scripts/super-board-setup.py +++ b/scripts/super-board-setup.py @@ -63,7 +63,7 @@ } SKILLS = ["super-board", "super-build", "super-qa", "super-review", "super-collect", "visual", "ui-refine-loop"] OLD_SKILL_DIRS = ["super-refine", "cleanup-wt", "arch-loop"] -BIN = ["super-board-run.sh", "super-board-gh-guard.sh", "super-board-status.py", "super-board-wave-plan.sh", +BIN = ["super-board-github-read.py", "super-board-run.sh", "super-board-gh-guard.sh", "super-board-status.py", "super-board-wave-plan.sh", "super-board-deps.sh", "super-board-preflight.sh", "super-board-merge-gate.sh", "super-board-merge-policy.py", "super-board-approval.py", "super-board-env-check.sh", "super-board-agents-md.py", "super-board-settings.py", "super-board-setup.py", "super-board-usage.sh", "super-board-pr-body.sh", diff --git a/tests/test-setup.sh b/tests/test-setup.sh index 84df003c..4799e69c 100644 --- a/tests/test-setup.sh +++ b/tests/test-setup.sh @@ -30,6 +30,17 @@ echo "$OUT" | q '.missing == [] and .git == true and .upgraded == false' || fail TXT=$(python3 "$SETUP" check --root "$T" --text || true) echo "$TXT" | grep -q "βœ“ super-board skills, scripts and board engine present" || fail "text report should list what is present: $TXT" +# 1b β€” missing safety helpers must trigger a repair even on a current installation. +for helper in super-board-approval.py super-board-github-read.py; do + rm "$T/.claude/bin/$helper" + OUT=$(python3 "$SETUP" check --root "$T" || true) + echo "$OUT" | q --arg helper "script $helper" '.missing | index($helper)' \ + || fail "missing $helper must be reported: $OUT" + OUT=$(python3 "$SETUP" fix --root "$T" --no-helpers || true) + [ -x "$T/.claude/bin/$helper" ] || fail "repair must restore $helper" + echo "$OUT" | q '.missing == []' || fail "repair must leave no missing scripts: $OUT" +done + # 2 β€” upgrade: an older super-board (VERSION 1.8.2, removed skill folders, a qa-only # config with Skipped, old collect keys, telegram) is detected and upgraded with # no question, backed up first, and listed as "Upgraded for you". @@ -209,4 +220,4 @@ q '[.options["2"][].name] == ["Backlog","Ready","Building","QA","Review","Blocke || 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)" +echo "PASS: test-setup.sh (10 scenarios)" From f6cac7d195a7336ddba95daea5a4352a5fa50b76 Mon Sep 17 00:00:00 2001 From: Eric Tech <146783360+EricTechPro@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:25:50 -0700 Subject: [PATCH 4/4] =?UTF-8?q?=F0=9F=90=9B=20[fix]=20collect:=20honor=20r?= =?UTF-8?q?epository=20pauses=20on=20duplicate=20and=20adopt=20writes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve the shared halt helper before router-owned paths and recheck before each comment, label, and project mutation. Preserve known issue numbers and exit79 on mid-run pauses, including placement. Offline regression: unfixed router fails12pause assertions; fixed router passes66assertions covering preexisting pauses, read-time pauses, labeling, placement, and issue identity. Co-Authored-By: Codex <noreply@openai.com> --- .../scripts/super-collect-file.sh | 46 +++++++++++------ tests/test-collect-file.sh | 50 +++++++++++++++++++ 2 files changed, 81 insertions(+), 15 deletions(-) diff --git a/skills/super-collect/scripts/super-collect-file.sh b/skills/super-collect/scripts/super-collect-file.sh index 0149a9a4..c84265f9 100755 --- a/skills/super-collect/scripts/super-collect-file.sh +++ b/skills/super-collect/scripts/super-collect-file.sh @@ -28,6 +28,7 @@ # --yes β†’ the issue number (new, existing on a dedupe hit, or adopted). # Exits: 0 ok Β· 64 bad args Β· 65 no holding column Β· 66 unreadable/weak body # 70 gh failure Β· 71 filed but not placed (number still on stdout) +# 79 repository paused (existing issue number still on stdout after discovery) set -uo pipefail CONFIG=""; TYPE=""; SOURCE=""; TITLE=""; BODY_FILE=""; FP="" @@ -82,6 +83,20 @@ REMOTE=$(jq -r '.repo.remote // empty' "$CONFIG") REPO_FLAG=() [ -z "$REMOTE" ] || REPO_FLAG=(-R "$(echo "$REMOTE" | sed -E 's#(git@github\.com:|https://github\.com/)##; s#\.git$##')") +BIN="${SUPER_BOARD_BIN:-}" +if [ -z "$BIN" ]; then + # Installed: .claude/skills/super-collect/scripts β†’ .claude/bin. Pack: skills/… β†’ scripts/. + ROOT3=$(cd "$(dirname "$0")/../../.." 2>/dev/null && pwd) + for d in ".claude/bin" "$ROOT3/bin" "$ROOT3/scripts"; do + [ -x "$d/super-qa-file-bug.sh" ] && { BIN="$d"; break; } + done +fi +[ -n "$BIN" ] || die "cannot find super-qa-file-bug.sh β€” set SUPER_BOARD_BIN or run install.sh" 70 + +# Router-owned duplicate/adopt writes share the same halt as the child filers. +GITHUB_READ="$BIN/super-board-github-read.py" +python3 "$GITHUB_READ" --check || exit $? + # --- body sections ---------------------------------------------------------- # The ticket format is writing-standard.md Β§ 3. super-qa-file-bug.sh enforces the # full bug shape (lettered steps, the 12-row Evidence table). Features and @@ -119,26 +134,36 @@ done place() { # $1 = issue url local item_id project_id field_id option_id + python3 "$GITHUB_READ" --check || return $? item_id=$(gh project item-add "$NUMBER" --owner "$OWNER" --url "$1" --format json --jq '.id') || return 1 + python3 "$GITHUB_READ" --check || return $? project_id=$(gh project view "$NUMBER" --owner "$OWNER" --format json --jq '.id') || return 1 field_id=$(echo "$FIELDS" | jq -r '.fields[] | select(.name=="Status") | .id') option_id=$(echo "$FIELDS" | jq -r --arg c "$HOLD" '.fields[] | select(.name=="Status") | .options[] | select(.name==$c) | .id') + python3 "$GITHUB_READ" --check || return $? gh project item-edit --id "$item_id" --project-id "$project_id" \ --field-id "$field_id" --single-select-option-id "$option_id" >/dev/null } tag() { # $1 = issue number, rest = labels; best-effort local n="$1"; shift - for l in "$@"; do gh label create "$l" ${REPO_FLAG[@]+"${REPO_FLAG[@]}"} --color 5319E7 --force >/dev/null 2>&1 || true; done - for l in "$@"; do gh issue edit "$n" ${REPO_FLAG[@]+"${REPO_FLAG[@]}"} --add-label "$l" >/dev/null 2>&1 || echo "warn: could not label #${n} ${l}" >&2; done + for l in "$@"; do + python3 "$GITHUB_READ" --check || return $? + gh label create "$l" ${REPO_FLAG[@]+"${REPO_FLAG[@]}"} --color 5319E7 --force >/dev/null 2>&1 || true + done + for l in "$@"; do + python3 "$GITHUB_READ" --check || return $? + gh issue edit "$n" ${REPO_FLAG[@]+"${REPO_FLAG[@]}"} --add-label "$l" >/dev/null 2>&1 || echo "warn: could not label #${n} ${l}" >&2 + done + return 0 } # --- adopt ------------------------------------------------------------------ if [ -n "$ADOPT" ]; then if [ "$YES" -ne 1 ]; then echo "would-adopt|#${ADOPT}|${TYPE}|${HOLD}"; exit 0; fi URL=$(gh issue view "$ADOPT" ${REPO_FLAG[@]+"${REPO_FLAG[@]}"} --json url --jq .url 2>/dev/null) || die "cannot read issue #${ADOPT}" 70 - tag "$ADOPT" "$TYPE" "source:collect" ${EXTRA_LABELS[@]+"${EXTRA_LABELS[@]}"} - place "$URL" || { echo "warn: #${ADOPT} not placed in '${HOLD}'" >&2; echo "$ADOPT"; exit 71; } + tag "$ADOPT" "$TYPE" "source:collect" ${EXTRA_LABELS[@]+"${EXTRA_LABELS[@]}"} || { RC=$?; echo "$ADOPT"; exit "$RC"; } + place "$URL" || { RC=$?; echo "$ADOPT"; [ "$RC" -eq 79 ] && exit 79; echo "warn: #${ADOPT} not placed in '${HOLD}'" >&2; exit 71; } echo "$ADOPT"; exit 0 fi @@ -153,6 +178,7 @@ CLOSED_HIT=$(echo "$HITS" | awk '$2!="OPEN" && $1!=""{print $1; exit}') if [ -n "$OPEN_HIT" ]; then if [ "$YES" -ne 1 ]; then echo "duplicate|#${OPEN_HIT}|${TYPE}|${TITLE}"; exit 0; fi + python3 "$GITHUB_READ" --check || { RC=$?; echo "$OPEN_HIT"; exit "$RC"; } gh issue comment "$OPEN_HIT" ${REPO_FLAG[@]+"${REPO_FLAG[@]}"} --body "Seen again by super-collect (${SOURCE}). $(cat "$BODY_FILE")" >/dev/null 2>&1 || echo "warn: could not comment on #${OPEN_HIT}" >&2 @@ -177,16 +203,6 @@ trap 'rm -f "$BODY_TMP"' EXIT echo "<!-- super-collect-source: ${SOURCE} -->" } > "$BODY_TMP" -BIN="${SUPER_BOARD_BIN:-}" -if [ -z "$BIN" ]; then - # Installed: .claude/skills/super-collect/scripts β†’ .claude/bin. Pack: skills/… β†’ scripts/. - ROOT3=$(cd "$(dirname "$0")/../../.." 2>/dev/null && pwd) - for d in ".claude/bin" "$ROOT3/bin" "$ROOT3/scripts"; do - [ -x "$d/super-qa-file-bug.sh" ] && { BIN="$d"; break; } - done -fi -[ -n "$BIN" ] || die "cannot find super-qa-file-bug.sh β€” set SUPER_BOARD_BIN or run install.sh" 70 - ERR=$(mktemp) case "$TYPE" in refactor) @@ -205,6 +221,6 @@ N=$(echo "$OUT" | tail -1) # Preserve its identity without tagging or making further writes after the halt. if [ "$RC" -eq 79 ]; then [ -z "$N" ] || echo "$N"; exit 79; fi case "$N" in ''|*[!0-9]*) die "filer failed (exit ${RC})" "$([ "$RC" -ne 0 ] && echo "$RC" || echo 70)" ;; esac -tag "$N" "source:collect" "collect:${SOURCE}" ${EXTRA_LABELS[@]+"${EXTRA_LABELS[@]}"} +tag "$N" "source:collect" "collect:${SOURCE}" ${EXTRA_LABELS[@]+"${EXTRA_LABELS[@]}"} || { RC=$?; echo "$N"; exit "$RC"; } echo "$N" exit "$RC" diff --git a/tests/test-collect-file.sh b/tests/test-collect-file.sh index 2cd0fc90..38b9fe4a 100644 --- a/tests/test-collect-file.sh +++ b/tests/test-collect-file.sh @@ -83,6 +83,9 @@ MD cat > "$WORK/gh" <<'STUB' #!/usr/bin/env bash printf '%s\n' "$*" >> "$GH_LOG" +if [ "${STUB_PAUSE_AFTER:-}" = "$1 ${2:-}" ]; then + printf '{"reason":"another worker paused during discovery"}\n' > "$SB_GITHUB_HALT_FILE" +fi case "$1 ${2:-}" in "repo view") echo "acme" ;; "issue list") @@ -196,6 +199,53 @@ is "adopt returns the issue" "55" "$OUT" lacks "adopt files nothing new" "$(cat "$GH_LOG")" "issue create" has "adopt places in Backlog" "$(cat "$GH_LOG")" "opt_Backlog" +echo "── duplicate and adopt stop when the repository is paused" +for path in duplicate adopt; do + printf '{"reason":"another worker paused GitHub writes"}\n' > "$SB_GITHUB_HALT_FILE" + RC=0 + if [ "$path" = duplicate ]; then + STUB_HITS='[{"number":301,"state":"OPEN","body":"err|sentry|4411"}]' run "${BUG[@]}" --yes >/dev/null || RC=$? + else + run --adopt 55 --type feature --yes >/dev/null || RC=$? + fi + is "$path reports the paused run" 79 "$RC" + WRITES=$(awk '/^(issue (create|comment|edit)|label create|project (item-add|item-edit)) /' "$GH_LOG") + is "$path makes no GitHub mutations while paused" "" "$WRITES" + rm -f "$SB_GITHUB_HALT_FILE" +done + +echo "── a pause discovered during a read stops the next write" +for path in duplicate adopt; do + RC=0 + if [ "$path" = duplicate ]; then + OUT=$(STUB_PAUSE_AFTER="issue list" STUB_HITS='[{"number":301,"state":"OPEN","body":"err|sentry|4411"}]' run "${BUG[@]}" --yes) || RC=$? + EXPECTED=301 + else + OUT=$(STUB_PAUSE_AFTER="issue view" run --adopt 55 --type feature --yes) || RC=$? + EXPECTED=55 + fi + is "$path propagates a pause after its read" 79 "$RC" + is "$path preserves the existing issue identity after a pause" "$EXPECTED" "$OUT" + WRITES=$(awk '/^(issue (create|comment|edit)|label create|project (item-add|item-edit)) /' "$GH_LOG") + is "$path makes no mutations after the discovery pause" "" "$WRITES" + rm -f "$SB_GITHUB_HALT_FILE" +done + +echo "── pauses during adoption stop labeling and placement" +RC=0 +OUT=$(STUB_PAUSE_AFTER="label create" run --adopt 55 --type feature --yes) || RC=$? +is "adoption pauses between label writes" 79 "$RC" +is "mid-label pause preserves adopted issue" 55 "$OUT" +COUNT=$(awk '/^(issue (create|comment|edit)|label create|project (item-add|item-edit)) / {n++} END {print n+0}' "$GH_LOG") +is "only the first pre-pause label mutation happened" 1 "$COUNT" +rm -f "$SB_GITHUB_HALT_FILE" +RC=0 +OUT=$(STUB_PAUSE_AFTER="project view" run --adopt 55 --type feature --yes) || RC=$? +is "placement pause stays exit 79" 79 "$RC" +is "placement pause preserves adopted issue" 55 "$OUT" +lacks "placement pause prevents a column write" "$(cat "$GH_LOG")" "project item-edit" +rm -f "$SB_GITHUB_HALT_FILE" + echo echo "passed: $PASS failed: $FAIL" [ "$FAIL" -eq 0 ]