Watch · Pull requests

Find why a change was made, months later

A pull request record is one markdown file written on the branch before the pull request opens, so the squash merge carries it onto your mainline. The Pull requests tab indexes that directory per request, newest number first, with a filter box. Nothing here calls a model.

For: the engineer looking up why a change was made, and the maintainer who wants the reasoning in the repository rather than in an API

What it does, and why it helps

The record lives at .claude/brain/pull-requests/<n>.md, one file per pull request. It is authored on the pull request's own branch as NEW.md, because GitHub does not allocate the number until the pull request opens, and npm run pr-record renames it once it does. CI's brain-record check reads the tree: a numbered record must exist and no NEW.md may survive. Because the record is on the branch, a squash merge absorbs it onto the mainline with the change itself.

The tab is not a committed HTML page. Its registry row carries dir: "pull-requests", so a request for pull-requests.html resolves to a directory index rendered on the fly: a badge per record (#4523), the record's first heading as its title, and a filter box that matches the number and the title as you type. The count beside the box reads how many rows are showing. Clicking a row opens the whole record in the same shell.

What the tab lists is the tracked branch, not your working copy. RepoOps keeps a mirror worktree per repository pinned to <remote>/<branch> from repos.config.json, default main, and refreshes it on a background loop. Separately, the daemon imports your commits and records into the local event store each brain cycle, which is what fills the hosted Pull requests page when you turn cloud sync on.

The pain. The diff survives a merge. The reasoning does not. Three weeks later the question is not what changed but why this shape was picked, what was rejected, and which follow-up was deferred, and none of that is in the commit.

The point of view. Read the record before you judge the change. The reasoning belongs in the repository, on the branch that carries the change, so the same squash that lands the code lands the account of it and neither depends on a hosted API staying up.

What gets easier. Looking a change up. One screen lists every record the tracked branch holds, filters by number or title as you type, and opens the full record in place. Cold-starting on a repository, writing a standup line, and answering a review question all read the same file.

When it helps. A repository whose pull requests carry a record, on a machine where the aggregator mirrors that repository. The hosted page additionally needs the device bound, cloud sync on, and merged pull request events streamed.

Its limits. It indexes markdown files. It does not read the GitHub API, so a pull request with no record is not in the list, and a record whose heading is missing renders as untitled. It does not write, edit or revert a record. The archive sweep removes old records from the tree by design, so a number can be present in the index today and in the archive index tomorrow.

Understand it in 30 seconds

30.1 s, captions on. Narration: Microsoft Zira Desktop (provisional voice; an approved narration source is pending).Transcript
Read the narration
  1. 0:00 A change merged three weeks ago.
  2. 0:02 The diff survived. The reasoning did not.
  3. 0:06 Write the record on the branch before the PR opens.
  4. 0:09 The squash merge keeps it.
  5. 0:13 The tab indexes every record, newest number first, and filters as you type.
  6. 0:18 CI blocks a pull request that carries none.
  7. 0:23 Read the record before you judge the change.
  8. 0:25 Archived ones stay findable.

Synthetic example. Read the guide

Where to find it

Where to find it

  • Desktop: localhost:4000, then Attribution in the sidebar, then Pull requests under All tools, in the Pull requests, blame and graphs group.
  • Hosted: repoops.ai/team/pr-index, from Attribution in the sidebar, then Pull requests under All tools, in the Pull requests, blame and graphs group.
  • Keyboard: ⌘ K, then type “Pull requests”.

When to use it

Cold-starting on a change somebody else shipped

Situation. A regression points at a change that merged last month. The commit message is one line and the branch is gone.

What you do. Open the Pull requests tab for that repository and type the number, or a word from the title, into the filter box. Open the row.

What you see. The full record renders in the same shell: what the pull request did, how to test it, what it decided and what it deferred, as written on the branch before the merge.

What it establishes. The reasoning came from the repository, not from an API call. If the number is not in the list, it has been archived, and ARCHIVE-INDEX.md says which month holds it.

A first CI run reads red on brain-record

Situation. You pushed a branch with the record still named NEW.md, which is the normal state of a first push: the number did not exist when you wrote it.

What you do. Open the pull request, run npm run pr-record, then commit and push. It reads the number off the branch, renames the file, rewrites the heading and stages both sides of the rename.

What you see. The two fail-fast call sites print a warning and let the expensive jobs run. The required brain-gates check stays red until the numbered record is in the tree, then reads that the file exists.

What it establishes. The record merges under its own number. Staging only the new path would leave NEW.md tracked and the check would fail on a file you can no longer see, which is why the script stages the deletion too.

Before you start

Supported versions
RepoOps desktop v0.3.1, the release this guide was read against. The index needs no key and no network. The hosted page needs the team workspace on repoops.ai.
Where it runs
Local: the Pull requests tab under Attribution, rendered from .claude/brain/pull-requests/ in the repository mirror. Hosted: repoops.ai/team/pr-index, rendered from kind:"pr" events in the team event store. The two read different sources and can differ; the local one is the repository, the hosted one is what was streamed.
Permissions
Local: whatever can read the repository. Hosted: a signed-in member; rows are narrowed to the repositories that member may see, and a crafted ?repo= value can only narrow that set, never widen it.
Connections
None for the local index. The hosted page needs the device bound to the team and cloud sync on, and it only has rows the import tick has written and the pusher has shipped. The PR draft subtab needs an Anthropic key on the aggregator host.
Plan
The local tab has no plan gate. The hosted page opens from Attribution in the sidebar, then Pull requests under All tools, and its address works directly.

Configure it

  1. Write the record on the branch, as NEW.md.

    Author .claude/brain/pull-requests/NEW.md before the first push. Preflight accepts the placeholder because it cannot know the number either, so the push goes through. Guessing the number instead races every other session opening a pull request at the same moment.

  2. Rename it once the pull request is open.

    npm run pr-record reads the number with gh pr view, renames NEW.md to <n>.md, rewrites the # PR heading (it accepts # PR #NEW and # PR NEW, both of which are in the corpus), and stages both paths. Run it twice and the second run reports the record is already named and exits 0.

  3. Point the mirror at the branch you want indexed.

    The tab reads the mirror worktree, which is pinned to <remote>/<branch> from repos.config.json and defaults to origin/main. A record that has not landed on that branch is not in the index, however complete it is in your checkout.

  4. Leave the import tick on, or turn it off deliberately.

    The daemon imports a rolling 30-day window of commits and records into the local event store every brain cycle. That is what fills the hosted Pull requests page, the ship rate and the Today tile. REPOOPS_GIT_IMPORT_OFF=1 stops it; node scripts/git-events-importer.mjs --since=YYYY-MM-DD is the full backfill.

  5. Decide whether the record leaves the machine.

    Cloud sync is off by default and an unbound device is skipped outright. With both on, the pusher ships the redacted event store, and a pr event carries the whole record body. Turn it on in Settings under Cloud sync (hosted) only if you want your team to read these records.

  6. Sweep the directory when it gets long.

    npm run brain-forget prints the plan and writes nothing. npm run brain-forget -- --apply is the human-gated sweep: it keeps the newest 400 records and everything from the last 30 days, gzips the rest into a per-month archive, and regenerates ARCHIVE-INDEX.md from those archives.

SettingWhereA sensible choiceWhy it matters
branchrepos.config.json, per repositorymain (the default when the field is absent)The ref the mirror worktree is pinned to, so it decides which records the index can see at all.
REPOOPS_GIT_IMPORT_OFFthe data directory's .envunset (the tick runs)1 stops the daemon importing commits and PR records, which leaves the hosted index, ship rate and Today tile empty rather than wrong.
REPOOPS_GIT_IMPORT_WINDOW_DAYSthe data directory's .env30 (the default)How far back one tick walks. It matches the window every hosted tile reads; older history is the CLI backfill's job, not a tick's.
cloudSyncSettings, Cloud sync (hosted), Enable hosted cloud syncoff unless your team should read these recordsOff by default, and an unbound device is skipped with no network call. On, the pr event carrying the full record body goes to the team.
--keep-recentnpm run brain-forget400 (the default)How many of the newest records stay in the tree. A record stays if either bound covers it.
--keep-daysnpm run brain-forget30 (the default)The second bound. Together these keep roughly the window a person greps by hand, and the sweep is a human call over the report, never a cadence.
repoops.powerUserSettings, Advanced, Show power-user tabson if you want the PR draft subtab in the railThe PR draft composer sits under Pull requests at subgroup power-user, so it is held out of the default rail until this is on.
draftDailyCapTokensaccount settings, shared with session drafts20000 (the default), against draftMaxTokensPerSession 4000 per draftThe only spend anywhere near this tab. A draft over the cap returns 429 daily draft cap reached and calls nothing.
ⓘ
To stop or undo
The index itself has no switch: it is a render of a directory, and it shows nothing when the directory is empty. What you can stop is the rest. Turn cloud sync off in Settings and no record leaves the machine. Set REPOOPS_GIT_IMPORT_OFF=1 and no pr event is written. Delete a record file in git and the next mirror refresh drops it from the index.

What you should see

The normal case

Configuration. A repository whose pull requests carry records, mirrored on origin/main, cloud sync off.

Expect. The tab lists every record on that branch, newest number first, with a badge such as #4523, the record's first heading as the title, and a count reading how many pull requests are showing.

Verify. Type a number into Filter pull requests and the count falls to the rows that match. Click a row and the whole record renders at /brain/<repo>/pull-requests/<n>.md.

Nothing to index yet

Configuration. A repository with no .claude/brain/pull-requests/ directory, or one that exists and is empty.

Expect. With no directory, the tab renders the placeholder with its registered zero state: a filterable index of your PR records, and the action to add a PR record when a pull request lands. With an empty directory, it says no records in .claude/brain/pull-requests/ yet.

Verify. Add one record on a branch, merge it, and the row appears after the next mirror refresh. Nothing needs regenerating; the index is read per request.

The hosted index, with rows

Configuration. Device bound, cloud sync on, the import tick running, merged pull requests in the last 30 days.

Expect. repoops.ai/team/pr-index lists the 50 most recently merged pull requests over 30 days. Titles beginning docs or chore are dropped, as are pull requests authored by dependabot, dependabot[bot] or github-actions[bot]. A row expands to the branch, the opened date, the time to merge and the first 1,500 characters of the record.

Verify. The summary line reads the merged count, the ship rate per week, and the median hours to merge when an opened date exists. That count is the full in-window total read from at most the 5,000 most recent pr events, so it can run ahead of the 50 rows listed.

Data and cost

What is captured
The records themselves are files you write, committed to your repository. The import tick additionally writes one kind:"pr" event per record into .claude/brain/events/, carrying the number, title, branch, action (merged or open), merged and opened timestamps, author, the file path and the whole record body. Its cursor at .claude/brain/events/.git-importer-cursor.json is what makes a repeat tick a no-op.
Who can see it
Local by default: the index reads the mirror on this machine and makes no network call. With the device bound and cloud sync on, the pusher ships the event store to the team ingest endpoint, and the hosted page shows those rows to members narrowed to the repositories they may see. Events are redacted at write time by lib/events/writer.mjs, before the pusher ever reads them.
How long it is kept
In the tree, until you sweep. No cadence deletes a record and there is no per-repository retention knob. npm run brain-forget -- --apply is human-gated and keeps the newest 400 plus the last 30 days, gzips the rest into pull-requests/archive/<YYYY-MM>.jsonl.gz, and generates ARCHIVE-INDEX.md from those archives. A record git has never seen has no honest month and is never archived. Restore one with npm run brain-forget -- --restore <n>. Hosted: the page reads a 30-day window.
What leaves the machine
Nothing, until cloud sync is on and the device is bound; both are checked before any request is made. Then the redacted event store goes to the team ingest endpoint in gzipped batches, capped at 1 MB before compression and 5,000 events per tick, resuming from a cursor that only advances on an acked batch. The importer makes one best-effort gh pr list call, and only on a cycle where a record it has not already recorded as merged exists.
What it costs
The index costs nothing: no model call, no key, no network. The one spend under this tab is the PR draft subtab, which needs an Anthropic key on the aggregator host and charges 4,000 projected tokens per draft against a 20,000-token daily account cap shared with session drafts.

When the result differs

SymptomLikely causeNext action
The tab shows the placeholder rather than a list.The mirror has no .claude/brain/pull-requests/ directory for this repository.Check the repository carries records on the tracked branch, then wait for the mirror to refresh.
A pull request you remember is not in the list.It carried no record, its record never reached the tracked branch, or the record has been archived.Look the number up in .claude/brain/pull-requests/ARCHIVE-INDEX.md and restore it with npm run brain-forget -- --restore <n>.
A row reads (untitled).The record has no first-level heading, so there is nothing to use as a title.Add a # heading to that record. The index strips a redundant PR #N prefix from it.
An amber banner says this view may be stale.The repository mirror's last sync failed or its source is offline, so the tab is showing the last content that synced.The mirror retries on its own. If it persists, check the server log for the [mirror:<repo>] prefix.
CI is red on brain-record with a surviving NEW.md.The rename was skipped, which would merge a record nobody can find by number.Run npm run pr-record, then commit and push. The required check clears once the numbered record is in the tree.
The hosted page says no merged PRs yet.No pr event has been streamed: the device is unbound, cloud sync is off, the import tick is off, or nothing merged in 30 days.Bind the device, turn on cloud sync in Settings, leave REPOOPS_GIT_IMPORT_OFF unset, and backfill with node scripts/git-events-importer.mjs --since=YYYY-MM-DD if the history predates the window.
A hosted row reads branch not recorded or time to merge not recorded.The record carried no **Branch:** line, and git cannot answer when a squashed pull request opened.Add the lines to future records, or accept the null. A field nothing can supply stays null rather than being invented.
The hosted merged count is higher than the rows listed.The count is the full in-window total; the list is capped at 50 rows and the read at 5,000 events.Narrow with the repo filter chips, or read the local index, which lists every record on the branch.
Disable
Turn cloud sync off in Settings to stop anything leaving the machine, and set REPOOPS_GIT_IMPORT_OFF=1 to stop pr events being written at all. The local index has no switch to press: it renders whatever is in the directory, and an empty directory renders the empty state.
Roll back
Not provided. Nothing in the tab writes, edits or reverts a record; it is a read of .claude/brain/pull-requests/. A wrong record is fixed the way any file in your repository is fixed, in git. npm run pr-record -- <n> is the one write, and it only renames and retitles.
Revoke access
Disconnecting in the desktop app forgets the device binding and revokes the device token on the server, after which the pusher skips with unbound and no further event leaves this machine.
Delete
Local: delete the <n>.md file in git, and the index drops the row on the next mirror refresh. Hosted: not provided. No route deletes a streamed pr row from the team event store; the rows sit in the store behind repoops.ai/team/pr-index and the local copy is .claude/brain/events/<YYYY-MM-DD>.jsonl.

Maintenance evidence

Feature id
pull-requests (spine leaf pull-requests)
Owner
Brain log index views (docs/features/brain-log-views.md; the Pull requests and Sessions tabs share one renderer). The hosted index is W5 Hosted UI Parity with G094; the event producer is K4.A.2 with the G127 import tick. Guide: LDG-0717.
Supported product version
RepoOps v0.3.1
Last verified
2026-09-15, read against origin/main at 52366bb6d; labels read from the served tab source. The Pull requests tab has no public/*.html file, so its strings were read from lib/log-index.mjs, which renders it, and from lib/zero-states.mjs for the placeholder. The PR draft, Settings and hosted labels were read from public/pr-desc-draft.html, public/settings.html, website/components/team-sidebar.tsx and website/app/team/(home)/pr-index/page.tsx. Not checked on a running instance.
Example fixtures
lib/log-index.test.mjs (the index render, the missing directory, the title cache), scripts/pr-record.test.mjs (the rename, the heading rewrite, the staged deletion), scripts/git-events-importer.test.mjs and test/git-import-tick.test.mjs (the record parse, the merge marker, the cursor), lib/brain/corpus-forget.test.mjs (the sweep, the archive, the restore), website/lib/pr-events.test.ts and website/lib/role-views.test.ts (the merged predicate and the hosted filters).
Source references
lib/canonical-spine.json, lib/canonical-tabs.mjs, lib/log-index.mjs, lib/routes/mirror-files.mjs, lib/zero-states.mjs, lib/mirror.mjs, lib/events/git-import-tick.mjs, lib/brain/corpus-forget.mjs, lib/brain-cron.mjs, lib/sync/cloud-pusher.mjs, scripts/pr-record.mjs, scripts/git-events-importer.mjs, scripts/ci/brain-record-check.sh, public/settings.html, public/pr-desc-draft.html, website/app/team/(home)/pr-index/page.tsx, website/lib/role-views.ts, website/lib/pr-events.ts
Documentation review
Independent review requested on the slice pull request; not yet recorded.
Video review
Story script written 2026-09-15; render and review pending in the same slice.

Last updated