Skip to content

chore: cut agent gh CLI usage off the GraphQL rate limit - #1342

Merged
vivek7405 merged 5 commits into
mainfrom
chore/gh-graphql-budget
Aug 8, 2026
Merged

chore: cut agent gh CLI usage off the GraphQL rate limit#1342
vivek7405 merged 5 commits into
mainfrom
chore/gh-graphql-budget

Conversation

@vivek7405

@vivek7405 vivek7405 commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

Closes #1339

Agent sessions here exhaust the GitHub GraphQL rate limit and then cannot touch the project board. Measured at the start of this work:

graphql: 5000/5000 used, 0 remaining
core:      15/5000 used

Every read command the skills and hooks prescribe routes to POST /graphql, while the separate REST budget sits idle. This moves those reads to REST and reserves GraphQL for the two things only it can do, Projects V2 and resolveReviewThread.

Why this is a fix, not only an optimization

Both PostToolUse hooks that fire after gh pr merge were already broken when measured, because they read GitHub through gh pr list / gh pr view:

  • cleanup-merged-worktree.sh could not see squash-merged PRs, so every merged worktree leaked. That is precisely the failure the hook exists to prevent.
  • release-global-update.sh never fired its global-CLI reminder.

This was confirmed live rather than argued. The GraphQL budget reset mid-session at 13:56:59Z, and within seconds the cleanup hook swept two genuinely merged worktrees (docs/component-ownership merged as #1328, fix/llms-single-decode merged as #1331) that had been sitting there unremovable while the budget was spent. Both branches were clean and pushed, so nothing was lost, but they had been leaking exactly as described.

A second, independent bug surfaced while verifying the replacements. A gh earlier on PATH may be a wrapper that prints a banner to stdout before exec'ing the real binary (/home/vivek/.local/bin/gh does this locally, via mise use -g gh). That text lands inside any $(gh ...) capture. release-global-update.sh captured JSON and parsed it, so the banner broke the parse outright and the hook exited early with no reminder. The hooks now request a single scalar and take the last line, which is immune to it.

Measured cost

Call Cost
gh project item-list 1 --owner webjsdev --limit 20000 631 points
the single-issue projectItems node lookup that replaces it 1 point

The board holds 596 items. One dump is 13% of the hourly budget, and webjs-start-work called it up to four times per run to find one item id.

After the change, one full pass through every rewritten command cost 32 REST requests and left core at 4968/5000.

What changed

  • .claude/gh-budget.md, new, the single source for the rule, the substitution table, the traps, and the exceptions.
  • .claude/gh-ids.env, new, the project and Status field ids that never change. webjs-start-work re-resolved these on every run, three GraphQL round trips for constants.
  • Hooks: cleanup-merged-worktree.sh and release-global-update.sh read REST.
  • Skills: webjs-start-work, webjs-ready-for-dev, webjs-file-issue, webjs-list-todos, webjs-research-record.
  • AGENTS.md, a short cross-agent pointer, since Cursor and Copilot use gh too and never read .claude/.
  • .gitignore, to un-ignore the two new .claude/ files.

Two places mattered more than the rest. The reviewer prompt template in webjs-start-work is pasted into every reviewer in every round, so its two porcelain calls were the largest single consumer in the skill. And webjs-ready-for-dev already carried the correct doctrine, at lines 228-244, while opening at line 47 with the whole-board dump its own rule forbade. That is what a second copy of a rule does, so there is now one copy and the skills link to it.

Deliberately unchanged

Three commands stay on the porcelain, with the reasons recorded in the doctrine so they are not "finished" later:

  • gh pr merge, matched as a literal string by both hooks. Rewriting it to gh api -X PUT .../merge silently stops both firing.
  • gh pr create, which resolves base, head, and repo from local git state.
  • gh pr checks, the merge gate. It folds check-runs and legacy commit statuses into one verdict. The combined-status endpoint reports state: "pending" for a commit with no statuses, which on this repo (10 check-runs, 0 statuses, measured) makes the obvious one-call replacement 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.

Test plan

  • test/hooks/*.test.mjs 132/132
  • New test/hooks/gh-budget.test.mjs 5/5. It asserts no skill or hook prescribes a porcelain read, scanning fenced code blocks so prose may still name one while explaining why not to use it. It caught three call sites I had missed, including one inside a subagent prompt in webjs-ready-for-dev.
  • It also asserts both new .claude/ files are tracked. .gitignore carries a broad .claude/* rule, so the doctrine file was silently ignored on first commit and every skill reference would have dangled on a fresh clone.
  • Counterfactual at b68e3ed3: reverting only the two hook sources reds exactly the 5 affected tests. The three negative tests stay green, correctly, since they assert a banner is never mistaken for real data.
  • Every replacement verified against the live API before being written in, not assumed. Notably: GET /repos/{o}/{r}/issues returns PRs too (4 among 24 open), so gh issue list replacements must filter has("pull_request"); and {owner}/{repo} expand inside a query string and resolve to nothing with no remote, so a hook call fails closed to its git fallback.
  • Every rewritten command run end to end as written: issue read, node lookup, card move, comments, contract poll, research list, PR diff and metadata, board dump.

Doc surfaces

  • AGENTS.md: updated.
  • Docs site, marketing website, scaffold templates, MCP, editor plugins: N/A because this is agent tooling under .claude/, with no framework surface change.
  • Bun parity, dogfood apps: N/A because no packages/*/src is touched.

Not done

The subagent review cycle has not been run. Say the word and I will run a round.

@vivek7405 vivek7405 self-assigned this Aug 8, 2026
@vivek7405
vivek7405 force-pushed the chore/gh-graphql-budget branch from 70d565b to d8b04ba Compare August 8, 2026 15:41

@vivek7405 vivek7405 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Went back over the whole diff. The hooks and the tests hold up, and the measured numbers behind the board-dump claim check out. What I got wrong was the scope: I converted the reads, called it done, and left three skills half-converted, so each one ended up contradicting the doctrine it now links to. All four are fixed in e4ae0d2.

webjs-research-record was still filing, editing, commenting, and closing through the porcelain. One of those is gh issue create, which is a row in the substitution table this PR adds, so the file directly contradicted the rule it points at. It matters more than the call count suggests: writing the record is that skill's entire deliverable, so a spent budget blocks the whole job. That is the same argument I already used to convert webjs-file-issue, and I had simply not carried it across.

webjs-ready-for-dev had the same problem with its only mutation, and it also kept its own hard-coded copy of the board ids that .claude/gh-ids.env was added to own. That one stings, because a second copy of a constant drifts exactly the way a second copy of a rule does, and this is the file that already demonstrated that failure once with the budget rule itself.

Its batched item-id query piped gh stdout into a separate jq. A PATH wrapper that banners to stdout makes that parse fail and return nothing, silently, so every item id comes back empty and every card move no-ops. Pre-existing rather than introduced here, but it is the exact trap the new doctrine documents, sitting four lines from a block I was already editing, so I folded it in. It now uses gh --jq and filters on the expected shape.

And a stale line in webjs-file-issue still told you to pass --body-file when the command underneath had become jq -n --rawfile plus --input.

The guard test could not have caught any of this, because I had scoped it to reads. That was the real miss. It now covers the mutations too, rejects a hard-coded board id in any skill, and requires every id in gh-ids.env to have a consumer. Writing those three assertions turned up two more call sites I had missed by reading, which is a fair argument that the guard is doing more work than my own eyes were.

One consequence worth noting: STATUS_TODO and STATUS_DONE are gone from gh-ids.env. No skill sets either (Todo is where a new card lands, Done comes from the Closes #N automation), and the new assertion treats an id nothing reads as a hint that some skill is still holding its own copy. The refresh command in that file's header lists every option if one is ever needed.

Nothing must-fix left open.

@vivek7405 vivek7405 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Second pass, scoped to the fix commit. It caught a regression I introduced there, which is the thing I most wanted a second read for.

I broke the card move in webjs-ready-for-dev. Splitting source .claude/gh-ids.env into its own fenced block left the item-edit two paragraphs below reading three empty variables, because env vars do not survive between tool calls. Before that commit the ids were literals and the command worked, so I made it worse while doing the thing that was supposed to make it better. The block now keeps source, the ITEM lookup, and item-edit together, derives ITEM from the cached query instead of assuming it is bound, and guards on it. I ran the whole block end to end against #1339 to confirm: every id resolves, the card moves to Ready and back.

gh label create is not a GraphQL command. It issues a plain POST to the labels endpoint. I had added it to the banned list on the assumption that the gh label family routed like gh label list does, which forbade a working call for no benefit and forced a longer rewrite for nothing. Reverted. The banned list now carries the rule that made the rest of it correct: every entry is measured with GH_DEBUG=api, never inferred from its command family. gh label list also moves to the reads, which is what it is.

And the guard had grown past its own documentation, forbidding four commands that neither the doctrine nor AGENTS.md mentioned, so an agent blocked on gh issue comment would have been sent to a rule that never named it. That is exactly the drift this change exists to prevent, reappearing one level up. It mattered most in AGENTS.md, since that is the surface non-Claude agents read and .claude/ is not. Both now cover the writes, and there is a test asserting the guard can never again forbid something the docs do not explain.

The through-line in both passes is the same: I kept fixing the reads and leaving the writes, then documenting the reads and enforcing both. The two new assertions are aimed at that specific blind spot rather than at the individual bugs.

@vivek7405 vivek7405 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Third pass over the fix commit. Nothing functional broke this time, but four things I had just written contradicted each other, and one of them was on the surface where it does the most damage.

AGENTS.md was telling agents to convert the merge gate. It listed gh pr checks among the commands to move to REST, while the doctrine says converting that one is what lets a red build onto main, because the obvious replacement reports a green PR as pending. Non-Claude agents read AGENTS.md and never read .claude/, so that is the worst possible place for the two to disagree. The previous commit had added an assertion that everything the guard bans appears in AGENTS.md, and the direction that actually failed is the converse, which nothing checked. Both hold now, and the counterfactual reds.

Reverting the label create left two stale claims. The research-record preamble still said every call below it was REST, and both files still counted "three commands that stay on the porcelain" when four are now kept. Those four are not one category: three route to GraphQL and are kept anyway for reasons, while gh label create is kept because it is already REST underneath and there is nothing to convert. Recording them together was the thing that made the count wrong, so they are separated now.

And "run the whole thing as ONE block" over-reached. It reads as covering the batched item-id fetch as well as the per-card move, which would spend a whole query per card, the exact waste that section exists to prevent. Only the per-card move needs to be one invocation.

My read on why three passes each found something: the mechanism was right each time and the prose around it went stale, because every fix invalidated a claim written before it. The findings have moved from a broken command, to a guard that enforced the wrong set, to sentences disagreeing with each other, which is convergence rather than a change that keeps breaking. That is also why the last two commits added assertions instead of just edits: the guard now pins the read set, the write set, the board ids, the source-and-use blocks, and both directions of the AGENTS.md agreement, so this class stops depending on me noticing it.

The fixes in the latest commit have not themselves been reviewed.

Both PostToolUse hooks that fire after `gh pr merge` read GitHub through
`gh pr list` / `gh pr view`, and every `gh pr *` porcelain command goes
through the GraphQL API. That budget is scored in points rather than
requests, and agent sessions here exhaust it routinely, at which point
both hooks fail silently: the cleanup hook stops seeing squash-merged
PRs so every merged worktree leaks, and the release hook never fires its
global-CLI reminder. Both were failing this way when measured.

The REST pulls endpoint answers the same two questions on a separate
budget, so move them there.

Also stop capturing JSON into a shell variable and parsing it. A `gh`
earlier on PATH may be a wrapper that prints a banner to stdout before
exec'ing the real binary, and that text lands inside the capture and
breaks the parse. Asking for one scalar and taking the last line is
immune to it.

The squash-merge path had no test at all, because the harness makes gh a
no-op, so it grew a stub that answers the REST call.
Every `gh` porcelain read the skills prescribe issues POST /graphql, and
that budget is scored in points rather than requests. Sessions here spend
it entirely while the separate 5000/hour REST budget sits idle, measured
at 5000/5000 GraphQL used against 15/5000 REST.

Move the reads to `gh api`, and reserve GraphQL for the two things only
it can do: Projects V2, which has no REST API, and resolveReviewThread.

The board dump was the expensive half. `gh project item-list --limit
20000` measures at 631 points against this board, 13% of the hourly
budget, and webjs-start-work called it up to four times per run to find
one item id. The issue node answers the same question for 1 point, so
the per-issue lookups use that and only webjs-list-todos, which renders
every card, still reads the whole board.

Three commands deliberately stay on the porcelain, with reasons recorded
so they are not "finished" later. `gh pr merge` is matched as a literal
string by two hooks. `gh pr create` resolves base and head from local git
state. `gh pr checks` is the merge gate, and it folds check-runs and
legacy commit statuses into one verdict, where the combined-status
endpoint reports pending for a commit with no statuses, so the obvious
one-call replacement calls a green PR pending.

The doctrine lives in one file rather than in each skill. It was
previously restated inside webjs-ready-for-dev, which then opened with
the whole-board dump its own rule forbade, so the skills now link to it.

A guard test asserts no skill or hook prescribes a porcelain read, and
that the shared file is actually tracked, since .gitignore carries a
broad `.claude/*` rule that would otherwise swallow it silently.
The first pass converted the reads and left three skills half-done, so
each one contradicted the doctrine it now links.

webjs-research-record still filed, edited, commented, and closed through
the porcelain, including the one command the substitution table names
explicitly. Writing the record is that skill's entire deliverable, which
is the same argument already applied to webjs-file-issue, so it must not
be what a spent budget blocks.

webjs-ready-for-dev had the same problem with its only mutation, and
carried a second hard-coded copy of the board ids that gh-ids.env was
added to own. A second copy of a constant drifts the way a second copy
of a rule does, which is the failure this skill already demonstrated
once.

Its batched item-id query piped gh's stdout into a separate jq. A PATH
wrapper that banners to stdout makes that parse fail and return nothing,
silently, so every item id came back empty. It now uses gh's own --jq
and filters on the expected shape.

Drop STATUS_TODO and STATUS_DONE: no skill sets either, and an id
nothing reads is a hint that some skill still holds its own copy.

The guard only covered reads, so it could not have caught any of this.
It now covers the mutations, rejects a hard-coded board id in a skill,
and requires every id in gh-ids.env to have a consumer. It found two
more call sites while being written.
The previous commit put `source .claude/gh-ids.env` in its own fenced
block and the `item-edit` that reads it two paragraphs later. Env vars do
not survive between tool calls, so all three ids expanded to empty and
the card move ran with blank flags. Before that commit the ids were
literals and the command worked, so this was a regression, and the
sibling skill it was modelled on keeps source and consumer in one block
for exactly this reason. The block now also derives ITEM from the cached
query instead of assuming it is bound, and guards on it.

`gh label create` is NOT a GraphQL command. It issues a plain POST to
the labels endpoint, so banning it forbade a working call for no benefit
and the rewrite it forced was longer for nothing. Reverted, and the
banned list now carries the rule that every entry was measured with
GH_DEBUG=api rather than assumed from its command family. `gh label
list` moves to the reads, which is what it is.

The guard had also grown past its own documentation, forbidding four
commands that neither the doctrine nor AGENTS.md mentioned. That is the
drift this change exists to prevent, one level up, and it mattered most
in AGENTS.md, which is what non-Claude agents read. Both now cover the
writes, and a test asserts the guard can never again forbid something
the docs do not explain.

Verified by running the rewritten card-move block end to end: every id
resolves, the card moves and restores.
AGENTS.md listed `gh pr checks` among the commands to move to REST,
while the doctrine says converting it is what lets a red build onto
main, because the obvious replacement reports a green PR as pending.
That contradiction sat on the one surface non-Claude agents read, which
is the surface where it does the most damage.

The previous commit asserted that everything the guard bans appears in
AGENTS.md, but nothing asserted the converse, and the converse is the
direction that failed. Both now hold: AGENTS.md names the three
porcelain exceptions in their own paragraph, never in the convert list,
and a test checks both directions.

Two more contradictions from reverting the label create: the
research-record preamble still claimed every call below it was REST, and
both files still counted "three commands that stay on the porcelain"
when there are now four kept, for two different reasons. The label one
is kept because it is already REST underneath, which is not an exception
to the rule at all, so it is recorded separately from the three that
genuinely route to GraphQL.

And "run the whole thing as ONE block" was read as covering the batched
id fetch, which would re-run a whole query per card, the waste the skill
exists to prevent. Only the per-card move needs one invocation.
@vivek7405
vivek7405 force-pushed the chore/gh-graphql-budget branch from ef84f7d to 010f5dd Compare August 8, 2026 16:43
@vivek7405
vivek7405 marked this pull request as ready for review August 8, 2026 16:43
@vivek7405
vivek7405 merged commit e7eb5dc into main Aug 8, 2026
10 checks passed
@vivek7405
vivek7405 deleted the chore/gh-graphql-budget branch August 8, 2026 16:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

chore: cut agent gh CLI usage off the GraphQL rate limit

1 participant