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
174 changes: 174 additions & 0 deletions .claude/gh-budget.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
# The GitHub API budget: which `gh` command to reach for

**This is the single source for the rule. Skills and hooks LINK here; they do not
restate it.** An earlier copy of this doctrine lived inside one skill only, and
that skill then violated its own rule a hundred lines above where the rule was
written. Restating is how that happened, so do not restate it.

## The one fact that drives everything

GitHub scores two independent budgets, and `gh` routes to them in a way that is
not obvious from the command name.

| Budget | Size | Scored in | Who spends it |
|---|---|---|---|
| GraphQL | 5000 / hour | **points**, roughly `ceil(nodes/100)` per query | every `gh` porcelain command, and all of Projects V2 |
| REST core | 5000 / hour | requests | `gh api <path>` |
| REST search | 30 / minute | requests | `gh api search/...` |

Points are the trap. One query that walks a large connection can cost hundreds
of points, so a handful of careless calls exhausts the hour while the request
COUNT still looks tiny.

**Every `gh` porcelain command below issues `POST /graphql` and nothing else**,
each verified with `GH_DEBUG=api`:

Reads: `gh issue view` `gh issue list` `gh pr view` `gh pr list` `gh pr diff`
`gh pr checks` `gh pr status` `gh search issues` `gh label list`

Writes: `gh issue create` `gh issue edit` `gh issue close` `gh issue comment`

The reads are essentially the whole read surface an agent uses, so left alone a
session spends its entire GitHub budget on the small expensive pool and never
touches the large cheap one. The writes are lower volume, but a skill whose whole
deliverable IS a write (filing an issue, writing a plan into an issue body,
recording a research note) is blocked outright once the budget is gone, which is
worse than being slow.

**Do not extend this list by assuming a family routes one way.** `gh label
create` looks like it belongs next to `gh label list` and does not: it issues a
plain `POST /repos/{owner}/{repo}/labels`. Measure with `GH_DEBUG=api` before
adding a command, or you will forbid a working REST call for no benefit.

## The rule

**Reserve GraphQL for the two things only GraphQL can do. Everything else is
`gh api` over REST.**

Only these genuinely require GraphQL:

1. **Projects V2**, which has no REST API at all (the board, its fields, its
item status).
2. **`resolveReviewThread`**, since REST cannot resolve a review thread.

## Substitutions

| Instead of | Use |
|---|---|
| `gh issue view N --json ...` | `gh api repos/{owner}/{repo}/issues/N` |
| `gh issue view N --comments` | `gh api "repos/{owner}/{repo}/issues/N/comments?per_page=100"` |
| `gh issue list --state open` | `gh api "repos/{owner}/{repo}/issues?state=open&per_page=100"` |
| `gh issue list --label X` | `gh api "repos/{owner}/{repo}/issues?labels=X&state=all&per_page=100"` |
| `gh pr view N --json ...` | `gh api repos/{owner}/{repo}/pulls/N` |
| `gh pr list --state merged --head BR` | `gh api "repos/{owner}/{repo}/pulls?state=closed&head={owner}:BR"` then filter `merged_at != null` |
| `gh pr diff N` | `gh api repos/{owner}/{repo}/pulls/N -H "Accept: application/vnd.github.diff"` |
| `gh search issues ...` | `gh api "search/issues?q=..."` |
| `gh label list` | `gh api "repos/{owner}/{repo}/labels?per_page=100"` |
| `gh issue create` | `gh api -X POST repos/{owner}/{repo}/issues --input body.json` |
| `gh issue edit N --body-file F` | `jq -n --rawfile b F '{body:$b}' > p.json` then `gh api -X PATCH repos/{owner}/{repo}/issues/N --input p.json` |
| `gh issue edit N --add-label X` | `gh api -X POST repos/{owner}/{repo}/issues/N/labels -f 'labels[]=X'` |
| `gh issue edit N --add-assignee U` | `gh api -X POST repos/{owner}/{repo}/issues/N/assignees -f 'assignees[]=U'` |
| `gh issue comment N --body-file F` | `jq -n --rawfile b F '{body:$b}' > c.json` then `gh api -X POST repos/{owner}/{repo}/issues/N/comments --input c.json` |
| `gh issue close N --reason completed` | `gh api -X PATCH repos/{owner}/{repo}/issues/N -f state=closed -f state_reason=completed` |

Build every body payload with `jq -n --rawfile` and send it with `--input`. An
issue body is full of backticks and `$`, and interpolating it into a shell string
runs them as command substitution, which has already silently eaten every code
reference out of a posted review in this repo.

`{owner}` and `{repo}` expand from the current repository, in the path AND in a
query string, and resolve to nothing when there is no remote, so a call in a
test harness fails closed rather than erroring.

## Commands that deliberately stay on the porcelain

`gh pr merge`, `gh pr create`, and `gh pr checks` all route to GraphQL and stay
that way. They run ONCE per PR, so their cost is noise, and each has a specific
reason that outweighs it. Do not "finish the job" by converting them.

(`gh label create` is on the porcelain too, but for a different reason: it is
already REST underneath, so there is nothing to convert. See the measurement
warning above.)

**`gh pr merge`.** Two PostToolUse hooks detect a merge by matching the literal
string `gh pr merge` in the command. Rewriting it to `gh api -X PUT .../merge`
silently stops both firing, so merged worktrees leak and the global CLI is never
updated. If it ever has to change, the regexes in
`.claude/hooks/cleanup-merged-worktree.sh` and
`.claude/hooks/release-global-update.sh` change in the same commit.

**`gh pr create`.** The porcelain resolves the base, head, and repo from local
git state. The REST equivalent needs all three passed explicitly and is easy to
get subtly wrong.

**`gh pr checks`.** This one is the merge gate, and it merges check-runs AND
legacy commit statuses into a single verdict. A hand-rolled replacement has to
read `commits/<sha>/check-runs` and `commits/<sha>/status` and combine them, and
would have to keep doing so correctly forever. The combined-status endpoint also
reports `state: "pending"` when a commit has NO statuses at all, which on this
repo (10 check-runs, 0 statuses, measured) makes the obvious one-call version
report a fully green PR as pending. A cheap call on a path where a miss lets a
red build onto `main` is not worth optimizing.

## Four traps in the REST replacements

1. **`GET /repos/{o}/{r}/issues` returns pull requests too.** On this repo that
is 4 PRs mixed into 24 open "issues". Filter with
`select(has("pull_request") | not)` or you will report PRs as issues.
2. **REST paginates at 30 by default.** Pass `per_page=100`, and `--paginate`
where completeness matters. This is the REST-side echo of the
`--limit 20000` trap and truncates just as silently.
3. **A `gh` on PATH may print a banner to STDOUT.** A wrapper that announces
itself before exec'ing the real binary (a mise shim does this) puts that text
inside every `$(gh ...)` capture. In a script, ask for ONE scalar with `--jq`
and take the last line, rather than capturing JSON and parsing it. JSON with
a line in front of it does not parse at all, so this fails loudly in tests
and silently in a hook.

**The banner is emitted INCONSISTENTLY**, only when the wrapper re-resolves
its tool, so it is present on one call and absent on the next within a single
session. Never strip it by POSITION. `tail -n +2` on multi-line output
silently deletes the first real line whenever the banner did not appear, and
on an issue-body round trip that means the body comes back a line short with
no error anywhere. Strip it by PATTERN (`sed '1{/^mise /d}'`) or, better,
avoid the problem: send bodies with `--input <file>` and read them back to a
file, so no multi-line payload ever rides a shell capture.
4. **`gh api rate_limit` is free.** It does not consume budget, so a step about
to do heavy board work can check first and degrade or report honestly rather
than failing mid-run.

## Projects V2, the part that must stay on GraphQL

The board is the one legitimate GraphQL consumer, so spend its points well.

**Never dump the whole board to answer a question about ONE issue.** Measured on
2026-08-08, against this board:

| Call | Cost |
|---|---|
| `gh project item-list 1 --owner webjsdev --limit 20000` | **631 points** |
| the single-issue node lookup below | **1 point** |

That is 13% of the hourly budget for one dump, and the skills used to call it
two to four times per invocation. Reach the item id from the ISSUE node instead.
It aliases, so a whole batch still costs one request, and the result is stable
enough to cache for the session:

```sh
gh api graphql -f query='query($n:Int!){repository(owner:"webjsdev",name:"webjs"){
issue(number:$n){projectItems(first:5){nodes{id project{number}
fieldValueByName(name:"Status"){...on ProjectV2ItemFieldSingleSelectValue{name}}}}}}}' -F n=<N>
```

**Never call the full-board dump in a loop.** One whole-board read is legitimate,
in `webjs-list-todos`, which renders every card. Even there, select only the
fields being rendered rather than `fieldValues(first:100)`.

**Do not re-resolve the static ids.** The project id, the Status field id, and
its option ids never change. They live in `.claude/gh-ids.env`; source that file
instead of spending three GraphQL round trips per run rediscovering constants.
Refresh it with the command written at the top of that file if the board schema
ever changes.

**Keep polling off GraphQL entirely.** Waiting on background work means repeated
reads, and those belong on the REST issues endpoint.
31 changes: 31 additions & 0 deletions .claude/gh-ids.env
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Static GitHub Projects V2 ids for https://github.com/orgs/webjsdev/projects/1
#
# These never change. Resolving them costs three GraphQL round trips, and the
# skills used to spend them on every single run rediscovering constants, so they
# are cached here instead. Source this file rather than re-resolving:
#
# source .claude/gh-ids.env
#
# Projects V2 is GraphQL-only and the budget is scored in POINTS, so this is one
# of the few places those points are genuinely unavoidable. Spend them once.
# See .claude/gh-budget.md for the full rule.
#
# REFRESH (only if the board schema changes, which it effectively never does):
#
# gh api graphql -f query='query{organization(login:"webjsdev"){
# projectV2(number:1){id field(name:"Status"){
# ...on ProjectV2SingleSelectField{id options{id name}}}}}}'
#
# Verified 2026-08-08.

PROJECT_ID=PVT_kwDOERfAXc4BZDhV
STATUS_FIELD_ID=PVTSSF_lADOERfAXc4BZDhVzhUE7nE

# Status options. Only the two the skills actually set are kept here, because an
# id nothing reads is either dead weight or a sign that the skill meant to use it
# is still carrying its own hard-coded copy, which is what this file exists to
# stop. Todo is the default a new card lands in and Done is set by the `Closes
# #N` automation, so no skill sets either. The refresh command above lists every
# option if one is ever needed.
STATUS_READY=ad471dd5
STATUS_IN_PROGRESS=47fc9ee4
18 changes: 17 additions & 1 deletion .claude/hooks/cleanup-merged-worktree.sh
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,25 @@ is_merged() {
if git merge-base --is-ancestor "refs/heads/$br" "$base" 2>/dev/null; then return 0; fi
# A merged GitHub PR for this head branch (squash merges, which are NOT an
# ancestor of base). Network; skipped when gh is absent or unauthenticated.
#
# REST, not `gh pr list`. Every `gh pr *` porcelain command goes through the
# GraphQL API, whose budget is scored in POINTS and which agent sessions here
# routinely exhaust; when it is spent this lookup returns nothing, the branch
# reads as unmerged, and the worktree leaks, which is the exact failure this
# hook exists to prevent. The REST pulls endpoint is a separate budget.
# `{owner}`/`{repo}` expand from the current repo, and resolve to nothing when
# there is no remote (the test harness), so the call fails closed to the
# ancestor check above rather than erroring.
#
# Read the number through `grep -E '^[0-9]+$'` rather than trusting the whole
# capture: a `gh` earlier on PATH may be a wrapper that prints a banner to
# STDOUT before exec'ing the real binary, which would otherwise land inside
# this variable.
if command -v gh >/dev/null 2>&1; then
local n
n=$(gh pr list --state merged --head "$br" --json number --jq '.[0].number' 2>/dev/null | grep -E '^[0-9]+$' || true)
n=$(gh api "repos/{owner}/{repo}/pulls?state=closed&head={owner}:$br&per_page=100" \
--jq '[.[] | select(.merged_at != null)] | .[0].number // empty' 2>/dev/null \
| grep -E '^[0-9]+$' || true)
[ -n "$n" ] && return 0
fi
return 1
Expand Down
17 changes: 12 additions & 5 deletions .claude/hooks/release-global-update.sh
Original file line number Diff line number Diff line change
Expand Up @@ -39,16 +39,23 @@ command -v gh >/dev/null 2>&1 || exit 0
num=$(printf '%s' "$cmd" | grep -oE 'gh pr merge[[:space:]]+#?[0-9]+' | grep -oE '[0-9]+' | head -1)
if [ -z "$num" ]; then exit 0; fi

info=$(gh pr view "$num" --json headRefName,title 2>/dev/null || true)
if [ -z "$info" ]; then exit 0; fi
head=$(printf '%s' "$info" | jq -r '.headRefName // ""' 2>/dev/null || true)
title=$(printf '%s' "$info" | jq -r '.title // ""' 2>/dev/null || true)
# REST, not `gh pr view`. Every `gh pr *` porcelain command goes through the
# GraphQL API, whose budget is scored in POINTS and which agent sessions here
# routinely exhaust; when it is spent this fetch returns nothing and the reminder
# silently never fires. The REST pulls endpoint is a separate budget.
#
# Ask for the ONE field this hook reads and take the LAST line, rather than
# capturing JSON and parsing it here. A `gh` earlier on PATH may be a wrapper
# that prints a banner to STDOUT before exec'ing the real binary; prepended to
# JSON that breaks the parse outright, while a scalar survives `tail -n1`.
title=$(gh api "repos/{owner}/{repo}/pulls/$num" --jq '.title // empty' 2>/dev/null | tail -n1)
if [ -z "$title" ]; then exit 0; fi

# A real release PR carries the canonical "chore: release <pkgs>" title (the
# release process always titles it exactly that). Match the TITLE, not the
# branch prefix: a `chore/release-*` branch that is NOT a package release (a hook
# tweak, a doc change) would otherwise fire a false reminder with nothing to
# publish. `head` is unused now but kept in the fetch for future signals.
# publish.
if ! printf '%s' "$title" | grep -qiE '^chore: release '; then exit 0; fi

read -r -d '' MSG <<'EOF' || true
Expand Down
48 changes: 32 additions & 16 deletions .claude/skills/webjs-file-issue/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,23 +47,34 @@ If the user's description is very thin (e.g. "track adding dark mode as a todo")

2. **Create the issue AND assign it to vivek7405.** Every WebJs issue is assigned to the owner (vivek7405) at creation so the project board shows ownership at a glance.

Write the grounded body to a scratch file first, then pass it with
`--body-file`. Do NOT pass it as `--body "..."`: an issue body is full of
backticks and `$` characters, and an unquoted shell string runs them as
command substitution, silently eating whatever they contained. This has
already bitten this repo through a sibling flag: a review posted with
`gh api -f body=` lost every code reference in it and had to be deleted
and reposted. Same mechanism, different flag.
Write the grounded body to a scratch file first, then read it into the
request with `jq -n --rawfile` so it never passes through the shell at all.
Do NOT interpolate it into a shell string: an issue body is full of backticks
and `$` characters, and an unquoted string runs them as command substitution,
silently eating whatever they contained. This has already bitten this repo:
a review posted with `gh api -f body=` lost every code reference in it and
had to be deleted and reposted.

```sh
# write the body to a scratch path first (any disposable location)
gh issue create --repo webjsdev/webjs \
--title 'dogfood: the router drops the second click' \
--label bug \
--assignee vivek7405 \
--body-file /tmp/issue-body.md
# write the body to a scratch path first (any disposable location), then
# build the request with jq so nothing is interpolated into a shell string
jq -n --rawfile body /tmp/issue-body.md \
'{title:"dogfood: the router drops the second click", body:$body,
labels:["bug"], assignees:["vivek7405"]}' > /tmp/issue.json
gh api -X POST repos/webjsdev/webjs/issues --input /tmp/issue.json \
--jq '"\(.number) \(.html_url)"'
```

REST rather than `gh issue create`, which goes through GraphQL. This is not
theoretical: filing #1339 failed on `gh issue create` with
`API rate limit already exceeded` and went through over REST unchanged. The
skill that files the work must not be the thing a spent budget blocks. See
`.claude/gh-budget.md`.

Building the payload with `jq -n --rawfile` also settles the quoting problem
the paragraph above describes, since the body never passes through the shell
at all.

A file path is used here rather than a heredoc on purpose. A heredoc whose
`EOF` terminator is indented (which it will be, pasted from any nested
context like this one) does not terminate: bash swallows the rest of the
Expand Down Expand Up @@ -130,6 +141,11 @@ For a thin placeholder (user just wants the line item tracked, explicitly deferr

## Failure handling

- If `gh issue create` fails (auth, label missing, network): surface the error and offer to retry with adjusted args.
- If `gh project item-add` fails after the issue was created: report the partial state ("issue #N created but not on board yet") and offer to add it manually.
- If the user's description seems to duplicate an existing open issue: search the board first with `gh project item-list 1 --owner webjsdev --format json --limit 20000` and ask whether to file anyway or use the existing one.
- If the create call fails (auth, label missing, network): surface the error and offer to retry with adjusted args.
- If `gh project item-add` fails after the issue was created: report the partial state ("issue #N created but not on board yet") and offer to add it manually. Projects V2 is GraphQL-only, so this is the one step here a spent GraphQL budget can genuinely block; check `gh api rate_limit` (free) and report the reset time rather than leaving the issue silently off the board.
- If the user's description seems to duplicate an existing open issue: search over REST and ask whether to file anyway or use the existing one. Search the ISSUES, not the board: it matches bodies as well as titles, so it is the better dedupe, and it costs nothing from the GraphQL budget.

```sh
gh api "search/issues?q=repo:webjsdev/webjs+is:issue+<keywords>&per_page=10" \
--jq '.items[] | "#\(.number) [\(.state)] \(.title)"'
```
17 changes: 17 additions & 0 deletions .claude/skills/webjs-list-todos/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,23 @@ The webjsdev/webjs project tracks work on the GitHub Project at https://github.c
gh project item-list 1 --owner webjsdev --format json --limit 20000
```

**This is the ONE place a whole-board dump is legitimate**, because this skill
renders every card. Everywhere else, reach a single card from its issue node
instead; see `.claude/gh-budget.md`.

The dump is expensive even so. It paginates the entire board (past 500 items)
at 100 per page, and the GraphQL budget is scored in points rather than
requests, so it is worth several hundred points of the hourly 5000. Two rules
follow, and neither is optional:

- **Call it ONCE per invocation and reuse the result.** Never in a loop, and
never a second time to answer a follow-up about one card.
- **Do not re-run it to check one issue.** If the user asks about a specific
number after this ran, use the single-node lookup in `.claude/gh-budget.md`.

`--limit 20000` is load-bearing, not defensive: the default page is 30, so
without it the board silently truncates and buckets come back wrong.

2. **Bucket by Status** (Todo, In progress, Done) and pretty-print each bucket with issue numbers and titles. If the user explicitly asked for only one bucket (e.g. "what's in progress"), filter accordingly.

3. **Default presentation.** Show Todo and In progress always. Show Done only if the user asks for it, or if both Todo and In progress are empty.
Expand Down
Loading
Loading