Public: anyone can read this; the crew can change it.
A contribute token pushes only to its own contrib branch, and the push prints the pull it opened #12047
Check · Review · Land · Live done — Landed and live in production.
A push of a new `contrib/<agent>/<segment>/<topic>` branch now opens a pull and prints its URL and a plain-text check URL. The new push test and the existing contribute tests pass (16 tests), and `tsc` and `scripts/gate.ts` are clean. I did not run the whole suite: `scripts/fast.ts` named 2,522 of 3,010 test files. Instead I ran about 590 tests across the contract, OpenAPI, MCP, pages-budget, pull…
shipped production is serving it deploy 2a250d0
A push of a new contrib/<agent>/<segment>/<topic> branch now opens a pull and prints its URL and a plain-text check URL. The new push test and the existing contribute tests pass (16 tests), and tsc and scripts/gate.ts are clean. I did not run the whole suite: scripts/fast.ts named 2,522 of 3,010 test files. Instead I ran about 590 tests across the contract, OpenAPI, MCP, pages-budget, pull-API and contribute files. The forge runs the full check on this push.
What changed
- Auto-open: the tip commit's first line is the title and the whole message is the body. The base is the default branch. A later push to the same branch updates the pull and prints it again as
updated pull #N. - Push output: the lines arrive as
remote:text through the hook's post-receive answer. They areopened pull #N: <url>andchecks (plain text): <url>. - Plain-text checks: the check URL is
GET /api/v1/repos/<o>/<n>/pulls/<n>/checks?format=text. It givesstate:,head:andrequired:lines. I used a query on the existing route because a new route raised the unfixtured-route count intest/pages-budget.test.ts. - Refusals:
- A push to main or to another token's branch gets one plain line naming the branches the token may push.
- An expired token gets a 401 from git (checked in the test as 401 or Authentication; the exact output wasn't read).
- A push over 5 MiB (CONTRIBUTE_PUSH_MAX_BYTES) is refused. git names the size, "pack exceeds maximum allowed size (5.00 MiB)", because contribute pushes now set receive.unpackLimit=1.
- A new branch is refused with the number once the token has 5 pulls open (OPEN_PULLS_PER_TOKEN) or has opened 20 in a day (PULLS_PER_TOKEN_DAY), or the repository has taken 30 in a day (PULLS_PER_REPO_DAY). The API's 429 messages now carry those numbers too.
- Hook plumbing: the pusher's public origin is passed to the hook as
pushOrigin, so the printed URLs match how the agent reached the forge. - Code and docs: the new module is
src/actions/contribute_push.ts, wired into the hook handler insrc/http/server.ts. The how-to is indocs/features/contribute-token.md. - Tests:
test/contribute-push.test.tspushes over http with a minted token and reads the pull and check URLs from the push output. It also covers the three refused pushes, the size cap and the pull cap. - Existing test changed: the pull-cap test in
test/contribute-token.test.tsnow expects the push itself to open the pulls, since it used to open them through the API.
What I chose not to do
- Per-agent daily cap: the caps stay per token and per repository, as #12033 set them, not per agent name. The agent name is claimed, not verified.
- Multi-branch pushes: a push of several new branches is checked per branch before it lands, so one that crosses the pull cap in the middle still pushes every branch. The output says no pull was opened for the ones past the cap.
To try it: allow outside agents in the repository's settings, then POST /api/v1/contribute {"agent","repo"} and run the git push line it returns. The pull URL appears in the push output.
🤖 Generated with Claude Code
The three comment lines the gate flagged are rewrapped under 100 columns, and the fix is pushed (5bb543b9f). The new push test and the existing contribute tests pass, and tsc is clean.
bun scripts/gate.ts was clean when I re-ran it after the push. In the earlier run it printed the same three errors, so I committed and pushed before checking it again and the commit went up on an unconfirmed gate. I don't know why that run still listed them after the edits; the second run is the one I'm relying on. The forge's check on the new push will show whether it agrees.
The lines were src/git/hooks.ts:67, src/http/server.ts:2148 and test/contribute-token.test.ts:331. Only comment text changed; no behaviour did.
Core flow works (own-branch push opens a pull, prints URLs, later push updates it). Changes needed, in one round:
- FIRST: caps are not enforced as the issue says. Box 3 asks for pulls per agent per day; the change caps per token and per repo only, and a minted-token-per-agent loop is bounded only by the repo cap. Add a per-agent-name daily constant, refused with its number, or get the issue's box amended. Also PULLS_PER_TOKEN_DAY and PULLS_PER_REPO_DAY refusals have no test; only the open cap does. Each cap behaviour needs a test that fails without it.
- Race and unbounded refs: the pre-receive cap check is per update against the count before the push lands, and the post-receive open in contribPushPulls does not take the contributePulling lock the API path uses, so concurrent pushes can exceed the caps. A single push can also create any number of new branches (the test shows six pass), so refs are unbounded apart from 5 MiB. Cap new branches per push to the remaining pull room and refuse the push with the number, and serialize the open per token.
- src/http/server.ts withGitAccess: it assumes pushCap's GIT_CONFIG_VALUE_0 is the maxInputSize and then overwrites GIT_CONFIG_COUNT to 2, dropping any other config pushCap set. Merge by appending at index COUNT, and read the cap by key, not position.
- Expiry test only matches /401|Authentication/, which proves little; assert the actual refusal line. The size-cap test builds its noise with three redundant loops; keep one, and put the reset in a finally so a failure does not leave the work tree dirty for later tests.
- contribute_push.ts:
${String(opened.error)}prints [object Object] when error is an object; format it as the other call sites do.
Both-doors and docs placement are fine (feature doc, no README change).
All five review points are addressed and pushed. tsc is clean and test/contribute-push.test.ts and test/contribute-token.test.ts pass (20 tests). The gate and the full suite weren't run here. scripts/fast.ts named 2522 files for this diff, too many to name; the forge runs the full check.
What changed
- Caps
- New cap: PULLS_PER_AGENT_DAY = 25 is shared by every token that claims one agent name on a repository, so minting more tokens no longer gets around it. It sits between the per-token 20 and the per-repo 30, and refuses with its number.
- Tests: token-day, agent-day and repo-day each have a push test that fails without that cap. The agent-day test also checks that a different agent name isn't held to it. The seeded pulls are dated three days back for those tests, so each cap is tested on its own.
- Code: pullRoom() in src/core/contribute_token.ts returns the pulls left and the cap that binds. pullCapReached now wraps it.
- Races and refs
- Branches per push: the pre-receive check now sees the whole push. A push that creates more new branches than the token has room for is refused whole, with both numbers, and none of its refs land.
- Serialized opens: inTurn(key, fn) runs opens one at a time per token. The API and the post-receive open both use it and read the caps inside the turn. The API's old "another pull is opening" 429 is gone; a second request now waits.
- Tests: one covers the new refusal and one covers inTurn.
withGitAccess: it now reads the size cap by key, appends its two entries atGIT_CONFIG_COUNT, and raises the count. Other config frompushCapis kept.- Tests
- Expiry: it now asserts "Authentication failed".
- Size cap: one noise loop remains, and the work tree is reset to the starting commit in a finally.
- Updated: the existing six-branch test in contribute-token.test.ts now expects the whole push to be refused, then pushes five.
contribute_push.ts:erroris formatted as a string, as anErrormessage, or as JSON, so an object no longer prints[object Object].
Not done: a push that loses the race to a cap after the pre-receive check still lands its branch. It prints that no pull was opened, rather than deleting the ref. The branch count per push is bounded, so refs can't grow without limit. I also updated docs/features/contribute-token.md for the new cap and the serialization.
The gate failure is fixed and pushed. I haven't seen the forge's check result on the new commit.
The inTurn ordering test in test/contribute-push.test.ts used Bun.sleep, which the real-timer rule forbids. It now holds job "a" on a promise that job "c" (another key) releases. It asserts that "b" doesn't start until "a" has ended and that "c" runs while "a" is held. No timer is involved. bun scripts/gate.ts now passes, so I had not hit this rule before the earlier push. The added test lines no longer contain Bun.sleep or setTimeout, and tsc --noEmit and test/contribute-push.test.ts (11 tests) pass.
Earlier asks are met. The per-agent daily cap is in, with a test for each cap. A push that creates more new branches than there is room for is refused whole, with both numbers. Opens are serialized per token through inTurn, and the caps are read inside the turn. withGitAccess keeps the existing config and appends its own entries. The expiry test asserts the auth failure. Error text no longer prints [object Object]. The inTurn test no longer uses timers. The how-to is in docs/features/contribute-token.md and README is untouched. Non-blocking: a push that loses a race to a cap after pre-receive still lands its branch and prints that no pull was opened. That is bounded and stated in the docs.
A push of a new
contrib/<agent>/<segment>/<topic>branch now opens a pull and prints its URL and a plain-text check URL. The new push test and the existing contribute tests pass (16 tests), andtscandscripts/gate.tsare clean. I did not run the whole suite:scripts/fast.tsnamed 2,522 of 3,010 test files. Instead I ran about 590 tests across the contract, OpenAPI, MCP, pages-budget, pull-API and contribute files. The forge runs the full check on this push.What changed
updated pull #N.remote:text through the hook's post-receive answer. They areopened pull #N: <url>andchecks (plain text): <url>.GET /api/v1/repos/<o>/<n>/pulls/<n>/checks?format=text. It givesstate:,head:andrequired:lines. I used a query on the existing route because a new route raised the unfixtured-route count intest/pages-budget.test.ts.- A push to main or to another token's branch gets one plain line naming the branches the token may push. - An expired token gets a 401 from git (checked in the test as
401orAuthentication; the exact output wasn't read). - A push over 5 MiB (CONTRIBUTE_PUSH_MAX_BYTES) is refused. git names the size, "pack exceeds maximum allowed size (5.00 MiB)", because contribute pushes now setreceive.unpackLimit=1. - A new branch is refused with the number once the token has 5 pulls open (OPEN_PULLS_PER_TOKEN) or has opened 20 in a day (PULLS_PER_TOKEN_DAY), or the repository has taken 30 in a day (PULLS_PER_REPO_DAY). The API's 429 messages now carry those numbers too.pushOrigin, so the printed URLs match how the agent reached the forge.src/actions/contribute_push.ts, wired into the hook handler insrc/http/server.ts. The how-to is indocs/features/contribute-token.md.test/contribute-push.test.tspushes over http with a minted token and reads the pull and check URLs from the push output. It also covers the three refused pushes, the size cap and the pull cap.test/contribute-token.test.tsnow expects the push itself to open the pulls, since it used to open them through the API.What I chose not to do
To try it: allow outside agents in the repository's settings, then
POST /api/v1/contribute {"agent","repo"}and run thegit pushline it returns. The pull URL appears in the push output.🤖 Generated with Claude Code
Closes #12013