Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion install.sh

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Author explanation

Purpose
Install Super Board’s tools into a project.

What changed
Copy the new approval checker alongside the existing command helpers.

Why it matters
An installed project needs this checker for the planner and merge checks to use the new approval rules.

Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
248 changes: 248 additions & 0 deletions scripts/super-board-approval.py

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Author explanation

Purpose
Check whether this change has a valid human approval.

What changed
A shared helper checks which code and steps were approved, who replied, and when. It reads GitHub without changing the board.

Why it matters
When the AI edits an approved fix, the old answer cannot approve the new code. Repeated checks keep a valid approval when nothing has changed.

Original file line number Diff line number Diff line change
@@ -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")
Comment on lines +96 to +99

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Author explanation

Purpose
Make sure the human answered the current request.

What changed
The request must match the current code and required steps. An edited request is rejected too.

Why it matters
The question cannot be changed after the human has already answered it.

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"]}
Comment on lines +105 to +117

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Author explanation

Purpose
Accept a valid answer from someone allowed to approve.

What changed
Check write access or higher, reply time and the same issue or PR discussion. Ignore edited replies and GitHub bot accounts.

Why it matters
An outsider’s reply or an old done must not unlock the merge. An AI using a person’s GitHub account remains indistinguishable from that person.

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()
17 changes: 8 additions & 9 deletions scripts/super-board-deps.sh

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Author explanation

Purpose
Tell the planner what each task is waiting for.

What changed
Ask the approval checker whether a waiting task has a valid answer. Saved offline data, old comments and labels cannot claim that approval is verified.

Why it matters
A task only returns for review after its current approval is checked, instead of treating a leftover label as permission.

Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
48 changes: 39 additions & 9 deletions scripts/super-board-merge-gate.sh

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Author explanation

Purpose
Control the final checks and merge.

What changed
Replace label-based permission with the approval checker. Check approval again before the final merge.

Why it matters
An old label must not let the AI merge changed code that the human has not approved.

Original file line number Diff line number Diff line change
Expand Up @@ -81,14 +81,14 @@
# review" — or merge_policy.default "human"). Stdout lists each
# `human-gate: <category> — <evidence>`. 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: <command>` 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
Expand Down Expand Up @@ -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')
}
Comment on lines +223 to +228

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Author explanation

Purpose
Ask whether this code and its required human steps are approved.

What changed
Call the approval checker instead of trusting the label. A failed checker call leaves approval false.

Why it matters
The merge needs a verified human answer, not just a status label.

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
Comment on lines +237 to +241

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Author explanation

Purpose
Hold a change that still needs human approval.

What changed
Print the request for the current change and stop when that approval is missing.

Why it matters
Asking for permission never grants permission to merge.

fi

git -C "$REPO_PATH" fetch origin "$HEAD_REF" "$BASE" --quiet
Expand Down Expand Up @@ -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
Comment on lines +306 to +313

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Author explanation

Purpose
Check that approval is still valid after verification runs.

What changed
Read the current PR details again, recalculate the required human steps, and recheck the reply just before merging.

Why it matters
A new requirement or request can arrive while tests run. The earlier check must not silently cover it. This is a final snapshot; GitHub does not lock comments afterward.

fi
fi

# ---- the merge -------------------------------------------------------------
if [ "$DRY" -eq 1 ]; then
say "dry run: would squash-merge PR #${PR}"
Expand Down
Loading
Loading