Skip to content

Commit 8713761

Browse files
tooling: block fork PRs to upstream + fix web typecheck CI (#63)
- pr-target-guard + gh wrapper + hapi-pr-create-fork (post-tiann#971) - web devDeps: remark-stringify, vfile, @types/mdast for remark-repair-tables Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 64f7b22 commit 8713761

10 files changed

Lines changed: 339 additions & 4 deletions

File tree

bun.lock

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/tooling/README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,9 @@ Feature agents should **read the relevant doc below at session start**; meta bot
4040
| [pr-review-loop.md](./pr-review-loop.md) | Pre-PR verification + cold review; pre-push open-PR gate; post-push PR comment poll |
4141
| [pr-reply.md](./pr-reply.md) | `hapi-pr-reply` — atomic reply + `resolveReviewThread` for PR review comments. Mandatory for bot/reviewer thread responses (never `gh pr comment` for that) |
4242
| [cold-pr-review-rubric.md](./cold-pr-review-rubric.md) | Open-PR cold review bar (match upstream HAPI Bot severity) |
43-
| `scripts/tooling/hapi-pr-emoji-batch.sh` | Classify upstream PRs → ✅/🔁/⚠️ (parallel gh; `--table` for humans) |
43+
| `scripts/tooling/hapi-pr-create.sh` | Upstream PR gate (leak scan, fork-stage, defaults `--repo tiann/hapi`) |
44+
| `scripts/tooling/hapi-pr-create-fork.sh` | **Fork PR gate** — defaults `--repo heavygee/hapi`; use for tooling/docs (never bare `gh pr create`) |
45+
| `scripts/tooling/install-gh-wrapper.sh` | Install `~/.local/bin/gh` wrapper — blocks fork-only diffs targeting `tiann/hapi` (#971 class) |
4446
| `scripts/tooling/hapi-pr-session-emoji.sh` | Meta PR watcher sweep: rename HAPI session titles from batch classify (`--sweep`) |
4547
| `scripts/tooling/hapi-remote-agent-budget.sh` | Pre-flight before bulk remote agent spawns (count + mem/swap gates) |
4648

@@ -81,7 +83,7 @@ Enforce for **all** agents (IDE, Claude Code, Cursor CLI/HAPI):
8183
|-----------|---------------------------|
8284
| `~/coding/AGENTS.local.md` rules | Yes (if agent reads them) |
8385
| `~/.local/bin/git` wrapper on `git push origin` (open PR) | Yes (stderr STOP reminder; non-blocking) |
84-
| `~/.local/bin/gh` wrapper on `gh pr create` | Yes (stderr checklist) |
86+
| `~/.local/bin/gh` wrapper on `gh pr create` | Yes (stderr checklist + **blocks fork-only → tiann/hapi**) |
8587
| Claude `PreToolUse` on `gh pr create` + `git push origin*` | Claude only |
8688
| Claude `PostToolUse` post-push poll | Claude only |
8789
| Cursor IDE `beforeShellExecution` / `postToolUse` | IDE yes; headless `agent` **often no** (probe 2026-05-26) |

docs/tooling/pr-review-loop.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,13 @@ Two skills exist to counter this:
3030

3131
Both must be run and findings addressed before a PR is filed.
3232

33+
**Target repo (fork vs upstream):** In the HAPI mirror clone, `gh repo view` defaults to **`tiann/hapi`**. Bare `gh pr create` therefore opens upstream PRs — wrong for `docs/tooling/`, `scripts/tooling/`, etc. Use:
34+
35+
- **Fork:** `hapi-pr-create-fork --title ... --body-file ...` (or `gh pr create --repo heavygee/hapi ...`)
36+
- **Upstream product:** `hapi-pr-create --title ... --body-file ...`
37+
38+
The `~/.local/bin/gh` wrapper (install: `scripts/tooling/install-gh-wrapper.sh`) **refuses** `gh pr create``tiann/hapi` when the branch diff includes fork-only paths or the branch is `tooling/*`, `docs/*`, etc. Postmortem: accidental `tiann/hapi#971` (2026-06-24).
39+
3340
### Claude Code enforcement: PreToolUse hook
3441

3542
In `~/.claude/settings.json`, a PreToolUse hook fires when `gh pr create` is about

scripts/tooling/gh-wrapper.sh

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
#!/usr/bin/env bash
2+
# Canonical gh wrapper — install to ~/.local/bin/gh via install-gh-wrapper.sh
3+
#
4+
# 1. Pre-PR checklist on `gh pr create`
5+
# 2. Block fork-only PRs against tiann/hapi (2026-06-24 #971 class)
6+
#
7+
# Must precede /usr/bin/gh on PATH. Also intercepts gh-ll/gh-gavinc/gh-heavygee
8+
# because they exec gh internally.
9+
10+
set -euo pipefail
11+
12+
REAL_GH="${HAPI_REAL_GH:-/usr/bin/gh}"
13+
14+
if [[ "${1:-}" == "pr" && "${2:-}" == "create" ]]; then
15+
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" >&2
16+
echo " PRE-PR MANDATORY CHECKLIST" >&2
17+
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" >&2
18+
echo " Before filing this PR, confirm BOTH are done:" >&2
19+
echo " 1. /verification-before-completion — checks pass with evidence" >&2
20+
echo " 2. /requesting-code-review — cold diff read, findings addressed" >&2
21+
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" >&2
22+
23+
# Target guard when inside hapi fork clone
24+
if git rev-parse --show-toplevel >/dev/null 2>&1; then
25+
root="$(git rev-parse --show-toplevel)"
26+
if [[ -f "$root/scripts/tooling/lib/pr-target-guard.sh" ]]; then
27+
PR_TARGET_GUARD_ROOT="$root"
28+
# shellcheck source=/dev/null
29+
source "$root/scripts/tooling/lib/pr-target-guard.sh"
30+
repo="$(pr_target_resolve_repo "$@")"
31+
if block_msg="$(pr_target_upstream_block_reason "$repo" "$@")"; then
32+
echo "" >&2
33+
echo "$block_msg" >&2
34+
exit 2
35+
fi
36+
fi
37+
fi
38+
39+
if [ -t 0 ] && [ -t 2 ]; then
40+
echo " Press Enter to confirm, or Ctrl+C to cancel." >&2
41+
read -r
42+
fi
43+
fi
44+
45+
exec "$REAL_GH" "$@"
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
#!/usr/bin/env bash
2+
# hapi-pr-create-fork — gate + default target for fork PRs (heavygee/hapi:main).
3+
#
4+
# Use for docs/tooling, scripts/tooling, operator docs, cursor rules — anything
5+
# that must NOT go to tiann/hapi via bare `gh pr create`.
6+
#
7+
# Usage:
8+
# hapi-pr-create-fork --title "tooling: x" --body-file /tmp/body.md
9+
# hapi-pr-create-fork --title "tooling: x" --body "$(cat <<'EOF' ... EOF )"
10+
#
11+
# Upstream product PRs: hapi-pr-create (never this script).
12+
13+
set -euo pipefail
14+
15+
ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)"
16+
if [[ -z "$ROOT" ]]; then
17+
echo "hapi-pr-create-fork: not in a git repo" >&2
18+
exit 2
19+
fi
20+
cd "$ROOT"
21+
22+
WRAPPER_DIR="$(dirname "$(readlink -f "$0")")"
23+
# shellcheck source=lib/pr-target-guard.sh
24+
source "$WRAPPER_DIR/lib/pr-target-guard.sh"
25+
26+
FORK_REPO="${HAPI_FORK_PR_REPO:-heavygee/hapi}"
27+
BASE="${HAPI_FORK_PR_BASE:-main}"
28+
BRANCH="$(git rev-parse --abbrev-ref HEAD)"
29+
30+
if [[ -z "$BRANCH" || "$BRANCH" == "HEAD" ]]; then
31+
echo "hapi-pr-create-fork: detached HEAD — check out a branch first" >&2
32+
exit 2
33+
fi
34+
35+
ARGS=("$@")
36+
HAS_REPO=0
37+
HAS_BASE=0
38+
HAS_HEAD=0
39+
TITLE=""
40+
BODY_FILE=""
41+
BODY_TEXT=""
42+
43+
i=0
44+
while [[ $i -lt ${#ARGS[@]} ]]; do
45+
case "${ARGS[$i]}" in
46+
--title) TITLE="${ARGS[$((i + 1))]}"; i=$((i + 2));;
47+
--body-file) BODY_FILE="${ARGS[$((i + 1))]}"; i=$((i + 2));;
48+
--body) BODY_TEXT="${ARGS[$((i + 1))]}"; i=$((i + 2));;
49+
--repo|-R) HAS_REPO=1; i=$((i + 2));;
50+
--base) HAS_BASE=1; i=$((i + 2));;
51+
--head) HAS_HEAD=1; i=$((i + 2));;
52+
*) i=$((i + 1));;
53+
esac
54+
done
55+
56+
if [[ -z "$TITLE" ]]; then
57+
echo "hapi-pr-create-fork: --title is required" >&2
58+
exit 2
59+
fi
60+
if [[ -z "$BODY_FILE" && -z "$BODY_TEXT" ]]; then
61+
echo "hapi-pr-create-fork: --body-file or --body is required" >&2
62+
exit 2
63+
fi
64+
65+
if [[ $HAS_REPO -eq 0 ]]; then
66+
ARGS=("--repo" "$FORK_REPO" "${ARGS[@]}")
67+
fi
68+
if [[ $HAS_BASE -eq 0 ]]; then
69+
ARGS=("--base" "$BASE" "${ARGS[@]}")
70+
fi
71+
if [[ $HAS_HEAD -eq 0 ]]; then
72+
ARGS=("--head" "$BRANCH" "${ARGS[@]}")
73+
fi
74+
75+
echo "hapi-pr-create-fork: target ${FORK_REPO}:${BASE}${BRANCH}" >&2
76+
exec gh pr create "${ARGS[@]}"
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
#!/usr/bin/env bash
2+
# Install canonical gh wrapper (pre-PR checklist + fork upstream block) to ~/.local/bin/gh
3+
set -euo pipefail
4+
5+
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
6+
SRC="$REPO_ROOT/scripts/tooling/gh-wrapper.sh"
7+
DEST="${HAPI_GH_WRAPPER_DEST:-$HOME/.local/bin/gh}"
8+
REAL_GH="${HAPI_REAL_GH:-/usr/bin/gh}"
9+
10+
if [[ ! -f "$SRC" ]]; then
11+
echo "ERROR: missing $SRC" >&2
12+
exit 1
13+
fi
14+
if [[ ! -x "$REAL_GH" ]]; then
15+
echo "ERROR: real gh not found at $REAL_GH (set HAPI_REAL_GH)" >&2
16+
exit 1
17+
fi
18+
19+
mkdir -p "$(dirname "$DEST")"
20+
if [[ -f "$DEST" && ! -L "$DEST" ]] && ! grep -q 'gh-wrapper.sh\|PRE-PR MANDATORY CHECKLIST' "$DEST" 2>/dev/null; then
21+
cp -a "$DEST" "${DEST}.prev"
22+
echo "Backed up previous wrapper → ${DEST}.prev"
23+
fi
24+
25+
cp "$SRC" "$DEST"
26+
chmod +x "$DEST"
27+
28+
echo "Installed gh wrapper → $DEST"
29+
echo " - Pre-PR checklist on gh pr create"
30+
echo " - Blocks fork-only diffs / infra branches targeting tiann/hapi"
31+
echo " - Fork PRs: hapi-pr-create-fork --title ... --body-file ..."
32+
echo " - Upstream PRs: hapi-pr-create --title ... --body-file ..."
33+
echo ""
34+
echo "Verify: which gh → should be $DEST"

scripts/tooling/install-git-hooks.sh

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ HOOKS="$ROOT/scripts/tooling/git-hooks"
88
chmod +x "$HOOKS"/pre-commit "$HOOKS"/commit-msg "$HOOKS"/pre-push "$HOOKS"/post-merge 2>/dev/null || true
99
chmod +x "$ROOT/scripts/tooling/check-operator-leaks.sh" "$ROOT/scripts/tooling/gh-public-body-check.sh" 2>/dev/null || true
1010
chmod +x "$ROOT/scripts/tooling/check-stash-advisory.sh" 2>/dev/null || true
11-
chmod +x "$ROOT/scripts/tooling/hapi-branch-audit.sh" "$ROOT/scripts/tooling/hapi-pr-create.sh" 2>/dev/null || true
11+
chmod +x "$ROOT/scripts/tooling/hapi-branch-audit.sh" "$ROOT/scripts/tooling/hapi-pr-create.sh" "$ROOT/scripts/tooling/hapi-pr-create-fork.sh" 2>/dev/null || true
12+
chmod +x "$ROOT/scripts/tooling/install-gh-wrapper.sh" "$ROOT/scripts/tooling/gh-wrapper.sh" 2>/dev/null || true
1213
git -C "$ROOT" config core.hooksPath "$HOOKS"
1314
echo "Installed git hooksPath → $HOOKS"
1415
echo "Hooks: pre-commit, commit-msg, pre-push, post-merge (fork-private paths, secrets, branch hygiene)"
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
#!/usr/bin/env bash
2+
# Resolve gh pr create target repo and block fork-only diffs against tiann/hapi.
3+
# Sourced by gh-wrapper.sh and hapi-pr-create-fork.sh.
4+
#
5+
# Incident: heavygee/hapi tooling PR #62 filed as cross-repo tiann/hapi#971 (2026-06-24)
6+
# because `gh repo view` defaults to tiann/hapi in this clone.
7+
set -euo pipefail
8+
9+
PR_TARGET_GUARD_ROOT="${PR_TARGET_GUARD_ROOT:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
10+
11+
# Paths that must never ride a PR to upstream/main (fork canon / operator tooling).
12+
PR_TARGET_FORK_ONLY_PREFIXES=(
13+
'docs/tooling/'
14+
'docs/operator/'
15+
'docs/plans/'
16+
'scripts/tooling/'
17+
'.cursor/rules/'
18+
'CLAUDE.md'
19+
)
20+
21+
# Infra branch names — fork PRs only unless explicitly using hapi-pr-create with product diff.
22+
PR_TARGET_FORK_INFRA_BRANCH_RE='^(tooling/|docs/|driver/|garden/|chore/fork)'
23+
24+
pr_target_extract_repo_flag() {
25+
local arg next=0 repo=""
26+
for arg in "$@"; do
27+
if [[ $next -eq 1 ]]; then
28+
repo="$arg"
29+
next=0
30+
continue
31+
fi
32+
case "$arg" in
33+
--repo|-R)
34+
next=1
35+
;;
36+
esac
37+
done
38+
printf '%s' "$repo"
39+
}
40+
41+
pr_target_default_gh_repo() {
42+
gh repo view --json nameWithOwner --jq '.nameWithOwner' 2>/dev/null || true
43+
}
44+
45+
pr_target_resolve_repo() {
46+
local explicit
47+
explicit="$(pr_target_extract_repo_flag "$@")"
48+
if [[ -n "$explicit" ]]; then
49+
printf '%s' "$explicit"
50+
return 0
51+
fi
52+
pr_target_default_gh_repo
53+
}
54+
55+
pr_target_fork_only_paths_in_diff() {
56+
local range="${1:-upstream/main...HEAD}"
57+
local path prefix
58+
while IFS= read -r path; do
59+
[[ -z "$path" ]] && continue
60+
for prefix in "${PR_TARGET_FORK_ONLY_PREFIXES[@]}"; do
61+
if [[ "$path" == "$prefix"* || "$path" == "$prefix" ]]; then
62+
printf '%s\n' "$path"
63+
fi
64+
done
65+
done < <(git -C "$PR_TARGET_GUARD_ROOT" diff --name-only "$range" 2>/dev/null || true)
66+
}
67+
68+
pr_target_is_fork_infra_branch() {
69+
local branch="${1:-$(git -C "$PR_TARGET_GUARD_ROOT" rev-parse --abbrev-ref HEAD 2>/dev/null || true)}"
70+
[[ -z "$branch" || "$branch" == "HEAD" ]] && return 1
71+
[[ "$branch" =~ $PR_TARGET_FORK_INFRA_BRANCH_RE ]]
72+
}
73+
74+
# Print block reason to stdout; exit 0 if block, 1 if allow.
75+
pr_target_upstream_block_reason() {
76+
local repo="$1"
77+
shift
78+
[[ "$repo" != "tiann/hapi" ]] && return 1
79+
80+
local branch paths
81+
branch="$(git -C "$PR_TARGET_GUARD_ROOT" rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
82+
paths="$(pr_target_fork_only_paths_in_diff "upstream/main...HEAD" | sort -u)"
83+
84+
if [[ -n "$paths" ]]; then
85+
{
86+
echo "REFUSE: gh pr create targets tiann/hapi but this branch diff includes fork-only paths:"
87+
echo ""
88+
while IFS= read -r p; do
89+
[[ -z "$p" ]] && continue
90+
echo " - $p"
91+
done <<< "$paths"
92+
echo ""
93+
cat <<'EOF'
94+
Fork tooling/docs belong on heavygee/hapi main, not upstream.
95+
96+
Use instead:
97+
hapi-pr-create-fork --title "..." --body-file /tmp/body.md
98+
99+
Or explicitly (after verification):
100+
gh pr create --repo heavygee/hapi --base main ...
101+
102+
Upstream product PRs only:
103+
hapi-pr-create --title "..." --body-file /tmp/body.md
104+
105+
Postmortem: accidental tiann/hapi#971 (2026-06-24).
106+
EOF
107+
}
108+
return 0
109+
fi
110+
111+
if pr_target_is_fork_infra_branch "$branch"; then
112+
cat <<EOF
113+
REFUSE: gh pr create targets tiann/hapi from infra branch '$branch'.
114+
115+
Branches matching tooling/*, docs/*, driver/*, garden/* are fork-side only.
116+
117+
Use:
118+
hapi-pr-create-fork --title "..." --body-file /tmp/body.md
119+
EOF
120+
return 0
121+
fi
122+
123+
return 1
124+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
#!/usr/bin/env bash
2+
# Smoke-test pr-target-guard (fork-only diff must not target tiann/hapi).
3+
set -euo pipefail
4+
5+
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
6+
# shellcheck source=lib/pr-target-guard.sh
7+
source "$ROOT/scripts/tooling/lib/pr-target-guard.sh"
8+
9+
expect_block() {
10+
local label="$1"
11+
shift
12+
if reason="$(pr_target_upstream_block_reason "$@")"; then
13+
echo "OK block: $label"
14+
else
15+
echo "FAIL expected block: $label" >&2
16+
exit 1
17+
fi
18+
}
19+
20+
expect_allow() {
21+
local label="$1"
22+
shift
23+
if reason="$(pr_target_upstream_block_reason "$@")"; then
24+
echo "FAIL expected allow: $label" >&2
25+
echo "$reason" >&2
26+
exit 1
27+
else
28+
echo "OK allow: $label"
29+
fi
30+
}
31+
32+
# Simulate #971: tooling branch with scripts/tooling in diff vs upstream/main
33+
PR_TARGET_GUARD_ROOT="$ROOT"
34+
if git -C "$ROOT" merge-base --is-ancestor upstream/main HEAD 2>/dev/null; then
35+
expect_block 'fork tooling diff on default repo' tiann/hapi
36+
fi
37+
38+
expect_allow 'explicit fork repo' heavygee/hapi
39+
40+
echo "pr-target-guard.test.sh: OK"

0 commit comments

Comments
 (0)