Skip to content

Latest commit

 

History

134 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

prutil

A terminal dashboard for the GitHub pull requests you have open, across every repository your account can see.

The list on the left shows each pull request with a coloured dot for its CI state, its age, repository, branches, number, review state and diff size. The pane on the right shows the selected pull request's WATCH summary and individual GitHub Actions checks. When the detail pane narrows, check names are shortened before they disappear: workflow labels go first, then durations if needed to keep the start of the name visible.

What prutil can do

prutil acts in two ways: on its own, for the pull requests you have asked it to watch, and when you press a key.

On its own, for every watched pull request (◉):

  • New review feedback is sent to the coding agent working on that pull request.
  • Once every check has finished and some have failed, the failed checks are sent to the agent, once per head commit.
  • If no agent is on the pull request, one is set up in a fresh worktree (herdr.fallback: new, the default), but only over branches you trust: your own, ones you adopted, and security.trusted_authors.
  • Anything written by somebody outside your trust boundary, or hidden in a comment, holds the pull request: nothing is sent until you say so.
  • Once every check on a new head commit has passed, the checks passed comment is posted, such as /deploy staging, if you armed it on that pull request with P.
  • It stops watching a pull request once it sees it merged or closed.

And, if you switch them on: new pull requests you open are watched without pressing w, and your desktop tells you when a pull request is approved or its checks pass.

When you press a key, on the selected pull request:

  • w watches or stops watching it.
  • W sends its open review feedback to an agent now, F its failed checks, and N only the feedback it has not sent before.
  • R posts a comment, /gemini review by default, to ask a review bot for a review. prutil never posts it on its own.
  • P arms or disarms the checks passed comment on a watched pull request.
  • + adopts somebody else's pull request so the rest of this works on it too.

prutil itself posts nothing to GitHub except the R and P comments. Replies, commits and re-run checks come from the agent.

Feature Trigger Switched by Default
Watch a pull request w per pull request not watched
Watch new pull requests on its own, every 5 minutes and after r watch.auto_watch (New PR watching) off
Watch new drafts too with the above watch.auto_watch_drafts (Draft PR watching) off
Send review feedback to an agent on its own when watched; W now; N now, new feedback only herdr.prompt, herdr.skill on when watched
Treat your own comments as feedback on its own when watched watch.self_review for all of them; watch.self_test_marker for one off; marker on
Send failed checks to an agent on its own when watched, once every check has finished; F now; W on a failed check herdr.check_prompt on when watched
Set up a workspace and agent when none is on the pull request any send that finds no agent herdr.fallback: new, none or repo; W always may new
Hold work from untrusted commenters every send; a second press of W or F sends once anyway security.trusted_associations, security.trusted_authors on
Require a sandboxed agent every automatic send security.require_sandbox on
Post an AI review comment R, pressed twice review.comment, review.repos /gemini review
Post a comment when checks pass on its own once armed with P, once per head commit checks_passed.comment, checks_passed.repos off, no comment
Desktop notification on approval on its own, every 2 minutes notifications.events.approved, notifications.interval on
Desktop notification when checks pass on its own, every 2 minutes notifications.events.checks_passed off
Adopt somebody else's pull request + to adopt, - to release per pull request none
Auto-refresh a per press off
Record sends without making them -dry-run herdr.dry_run off

Every setting in the table can be changed in the settings pane, s, as well as in config.yaml.

Words prutil uses

Word Means
watch prutil polls the pull request and acts on what it finds. Nothing to do with watching a repository on GitHub.
auto-watch watching new pull requests without pressing w. Not the same as auto-refresh (a), which only reloads the screen.
feedback an unresolved review thread whose newest comment is not yours.
send giving feedback or failed checks to a coding agent through herdr. The log, handoffs.jsonl, calls each one a handoff.
agent a coding agent, such as Claude Code, running in a herdr pane.
held prutil found a comment it will not send unasked, from somebody outside the trust boundary or with hidden text in it.
notification a desktop notification from your operating system, such as an approval.
toast herdr's own notification that a send happened, herdr.toast.
check a CI check on the pull request's head commit; the rollup is GitHub's verdict over all of them.

Self-healing pull requests

prutil can turn a pull request into a lightweight feedback loop instead of a static page you have to keep checking. Watch a pull request and it polls for new review feedback and failed checks, deduplicates work it has already handed off, and routes actionable work to the matching coding-agent workspace through herdr. The agent triages the feedback, separates real issues from noise (including automated review noise), makes a focused fix, pushes a follow-up commit, and lets GitHub run the checks again.

The loop is deliberately human-in-the-loop: prutil observes and coordinates; the agent proposes changes through normal commits and pull-request feedback, while people retain control of review, merge, retries and ambiguous decisions.

sequenceDiagram
    participant GitHub
    participant prutil
    participant Herdr
    participant Agent as Coding agent

    GitHub-->>prutil: New review feedback or failed checks
    prutil->>GitHub: Poll and identify actionable changes
    prutil->>Herdr: Route feedback to the matching repository and branch
    Herdr->>Agent: Start or notify the appropriate workspace
    Agent->>Agent: Triage feedback and decide what to fix
    Agent->>GitHub: Push a focused follow-up commit
    GitHub-->>prutil: Re-run checks and expose new feedback
    prutil-->>Herdr: Continue the loop when more action is needed
Loading

Requirements

  • Go 1.26 or newer, if you are building from source.

  • The gh CLI, installed and logged in:

    gh auth login

prutil shells out to gh, so it uses your existing gh credentials and never stores a token of its own. If you would rather use a personal access token, export GH_TOKEN (or GITHUB_TOKEN) and gh will pick it up. GH_HOST selects a GitHub Enterprise host.

Install

task install     # tidy, vet, lint, test, build and go install

or, without go-task:

go install github.com/relloyd/prutil/cmd/prutil@latest

Usage

prutil
prutil -limit 20
prutil -query 'is:open is:pr author:@me org:acme sort:created-desc'
Flag Default Meaning
-query is:open is:pr author:@me archived:false sort:created-desc the GitHub search used to find pull requests
-limit 100 how many pull requests to load
-closed-query is:pr author:@me is:closed archived:false sort:updated-desc the search used by the recently closed view
-closed-per-repo 3 how many closed pull requests any one repository contributes
-closed-repo-limit 30 how many repositories the closed view may query individually
-skip-auth-check false skip the gh auth status check at startup
-dry-run false record what would be sent to a coding agent without sending it
-mouse true click to select and scroll with the wheel; -mouse=false leaves the terminal its own wheel and drag-to-select
-version print the version and exit

Reading a row

▌ ● ◉ ↗ ⇄ #42 acme/widgets                    3d old
  Retry the GraphQL search on 502
  fix/retry → main  DRAFT  by alice          +40 -3
  APPROVED  ✓12  2 open threads              upd 5m
Mark Means
● the checks: green passed, red failed, amber still running
○ GitHub reports no checks for the head commit
◉ watched, and being polled (w)
◎ watched, but not being polled: it has gone quiet, or is not in the list. r wakes it
↗ the checks passed comment is armed (P)
⇄ adopted from somebody else (+); the author follows by
N open threads review feedback waiting on a watched pull request

The header counts the same marks across the list: ◉ 3 ◎ 1 watched · ↗ 1 on pass · next poll 45s · ⇄ 2 adopted. ? lists them too, under ROW MARKS, so typing a mark there says what it is.

Keys

Key Action
left click select a pull request in the list
j / k or ↓ / ↑ move within the focused pane; in detail, select WATCH or CHECKS
g / G or home / end jump to the first or last item
l or → focus detail from the list, or drill into the selected detail section
h, ← or esc go back one level
enter open the selected pull request, or the selected check, in your browser; on the WATCH heading, drill in as l does
y or c copy the selected pull request's URL, or the selected check's, to the clipboard
r refresh from GitHub
a auto-refresh: reload every 30s, five times over. press again to add five more
w watch the selected open pull request, or stop watching it
W send the selected pull request's open review feedback to a coding agent now, creating one when needed; on a failed check, send the failed checks
F send the selected pull request's failed checks to a coding agent now, without waiting for running checks
R post the configured AI review comment, such as /gemini review, on the selected open pull request. press twice to confirm
P on a watched pull request, post the configured checks passed comment, such as /deploy staging, each time its checks pass on a new commit. press again to stop
N read the selected open pull request's review threads now and send only the feedback not sent before
tab switch between your open and your recently closed pull requests
+ adopt a pull request somebody else opened: pick a recent repository and one of its pull requests, or paste a URL. it joins your open list and its author is trusted on it
- release the selected adopted pull request: it stops being watched and its author stops being trusted on it. press twice to confirm
s or , open settings: desktop notifications, what is watched, poll timing, PR comments, coding agent and security
? open the shortcut overlay: type to filter, enter to run the highlighted shortcut, esc or ? to close
q or ctrl+c quit

Below 80 columns the two panes collapse into one: the list fills the terminal, clicking a row selects it, l swaps to detail, and h swaps back. When WATCH is available, select it above CHECKS and press l or enter again to see its full schedule, activity and handoff details.

The footer has one line, so it lists the actions and leaves moving about to the arrow keys. ? opens an overlay listing every shortcut with a sentence on what it does. Start typing to fuzzy-filter it (agent, copy, or a key such as W), move with ↑ and ↓ or half a page at a time with ctrl+d and ctrl+u, press enter to run the highlighted shortcut, and esc to return. While it is open, keys go to the filter, so q types rather than quits; ctrl+c still quits.

Copying uses whichever clipboard program your platform provides: pbcopy on macOS, clip on Windows, and wl-copy, xclip or xsel on Linux, whichever is installed first. If none is, prutil says which ones it looked for.

Desktop notifications

prutil can tell you when one of your open pull requests changes, with a notification from your operating system, so you can leave it running in a terminal you are not looking at. It raises two today:

Notification When
Pull request approved GitHub's review decision turns to approved, or, in a repository without review rules, the pull request gets its first approval
Checks passed every check on the head commit has passed, after prutil saw them running or failing, or on a commit it had not seen. Off by default

Approval is on by default. Press s (or ,) to open the settings pane, where you can view and edit all prutil configuration options. In the pane:

  • space toggles boolean settings on or off.
  • + / - steps poll intervals and durations.
  • ← / → cycles enum options (such as agent fallback strategies).
  • enter enters inline editing for strings and custom numbers, launches $EDITOR for prompt templates, or opens sub-panes for repository mappings and discovery roots.
  • d resets the selected setting to its default value.
  • tab / shift+tab jumps between settings sections.
  • t sends a test desktop notification.
  • esc closes the pane.

A change is saved to config.yaml as soon as you make it: prutil changes that one value in place and leaves the rest of the file, your comments included, exactly as it was. A configuration that does not parse, or that is written in a shape prutil does not edit (such as a flow mapping), is left alone, and the pane reports why.

While any notification is on, prutil reads every open pull request every two minutes (Notification poll interval in s, notifications.interval), using the watcher's cheap query: one request, and one rate limit point, per hundred pull requests. A watched pull request is also read on the watcher's own schedule, and a refresh reads the whole list, so either may notice a change sooner. The first reading of each pull request after prutil starts only records where it stands; a pull request approved while prutil was not running is not announced.

These are separate from herdr.toast, which is herdr's own notification of a handoff. New review feedback and failed checks are what the watcher hands to an agent, so they are not duplicated here.

Notifications are shown with osascript on macOS, notify-send (libnotify) on Linux, and Windows PowerShell on Windows. macOS files osascript's notifications under Script Editor, so if the test notification does not appear, allow notifications for Script Editor in System Settings › Notifications. The title and body are passed as arguments rather than as part of a script, and control and invisible formatting characters are removed from them first, since anybody who can open a pull request chooses its title.

Auto-refresh

a reloads the view on screen every 30 seconds, five times, and then stops. That is two and a half minutes of watching a pull request's checks turn green without touching the keyboard. Press a again at any point during the run and another five reloads are added, so a long CI run is a matter of topping the counter up rather than holding a mode open. The header counts down what is left, and once it runs out prutil is back to refreshing only when you press r.

Each automatic reload is the same work r does, so it costs the same one request for the list plus the checks it warms.

Timers and focus

While the terminal has focus, the times on screen keep counting. Each second: next poll in the header, next in in the watch section, how long a running check has been going, and how long the watcher's current operation has been under way, which is how you tell a handoff waiting on an agent from a hung one. Each minute: the ages, such as upd 3m and opened 2h ago. When nothing on screen counts, nothing wakes prutil at all.

When the terminal loses focus, prutil stops redrawing for the clocks, and draws once more the moment focus returns, with the right times: none of them is stored, each is worked out from the clock as it is drawn. A load that is still running keeps its spinner going, and the watcher polls as usual; only the redraws for the timers stop.

This relies on the terminal reporting focus changes, which Ghostty does, and herdr passes on to the pane. tmux passes them on only with set -g focus-events on. A terminal that never reports focus is treated as always focused: the timers keep moving, at a cost of one redraw a second. A key press counts as proof of focus, so a report that goes missing corrects itself as soon as you type.

Watching, and handing work to an agent

w marks a pull request as watched. The row grows a ◉, the header counts how many are marked, and the mark survives quitting: it is kept in prutil's own directory, not in the terminal. Marking one is available in the open list only, because polling addresses a pull request by the node id that list came with; taking a mark off works from any view.

The header's tally reads ◉ 3 ◎ 1 watched · next poll 45s. The filled count is how many watched pull requests prutil is still asking GitHub about; the hollow one, shown only when it is not zero, is how many are still armed but not being asked about — either they have gone dormant (see below) or they are not in the list prutil is holding, so there is nothing to address a poll to. Together they add up to everything w has marked, and a row carries the same glyph as the half of the tally it belongs to. next poll is how long until the soonest of the active ones is read again.

A watched pull request stops being watched on its own once prutil sees it merged or closed in the recently closed view, and says which ones it retired. It waits to be shown a finished pull request rather than inferring one: the open list is narrowed by -query and -limit, so a pull request can drop out of it and still be open. Until that view is next loaded a finished pull request stays armed, counted in the hollow half of the tally; nothing polls it, so it costs no requests. The only other things that disarm without w are an adopted pull request finishing, and another agent replying on one you watch; both are described under Adopting somebody else's pull request.

From then on prutil watches that pull request for review feedback, and when some appears it gives it to a coding agent through herdr. Open feedback means a review thread that is neither resolved nor already answered by you, so a conversation you have had the last word in is left alone. W does the same thing on demand, for a pull request you have not armed or one you want looked at again now.

Watching also monitors the pull-request check rollup. When the rollup fails, prutil fetches the individual checks and waits until every check is terminal. It then sends all failed checks together to the agent working on the pull request, so the agent can decide whether they share a cause. The default check prompt asks the agent to fix failures related to the pull request with a follow-up commit, re-trigger unrelated failures with gh, and ask for human assistance when it has already retried an unchanged check or is unsure what to do. When no agent is on the pull request, herdr.fallback decides, as it does for review feedback: under the default new prutil sets a workspace up and starts one.

F sends the same failed checks immediately, using the failures currently known, even while other checks are pending, and even if they were sent for this head commit before. Like the watcher, it follows herdr.fallback when no agent is on the pull request. When a failed check is selected, W sends the same thing, and may set a workspace up whatever herdr.fallback says.

Automatic investigations are recorded against the head commit, so a restart does not resend the same failure. Pushing a new head allows a new investigation. Stopping and starting the watch clears that remembered head and intentionally allows the current failures to be investigated again.

To test watcher delivery with a code-line comment of your own, put this exact marker in the thread's newest comment's Markdown source:

<!-- prutil:test -->

GitHub hides the marker when it renders the comment. While that thread remains unresolved, prutil treats it as feedback even though you wrote the latest comment. The marker only works when it is in a comment written by the authenticated viewer, and only in the thread's newest comment, so that a thread somebody has since replied to falls off the list rather than being handed over for as long as it stays open. It does not opt in an ordinary pull-request conversation comment, a marker written by another reviewer, or an older reply that is no longer the latest. Normal duplicate suppression still applies, so the thread is handed over again only when it gains a new latest comment.

Watching new pull requests automatically

watch.auto_watch, auto-watch for short, watches every pull request you open from the moment it is switched on, so a new one is on the loop without anybody pressing w. It is off by default; s toggles it as New PR watching under WATCHING, where the two settings below it live too: Draft PR watching (auto_watch_drafts) and New PR search interval (auto_watch_interval). The header reads · new PR watching while it is on.

Nothing else re-reads the open list on its own — the watcher and the notifications only ask about pull requests prutil already has — so while auto-watch is on the list is searched for new ones every auto_watch_interval (5 minutes by default, at least one), and after every r and a too. That interval only finds them: once a pull request is watched it is polled on the watcher's own schedule, not this one. A pull request is watched when:

  • you opened it, and the list's own search found it. Somebody else's that a custom -query lists, and one you adopted, are left alone;
  • it was created after auto-watch was switched on. Everything already open stays as you had it, and switching it off and on again starts afresh;
  • auto-watch has not watched it before. Stop watching one with w and it stays stopped;
  • it is not a draft, unless watch.auto_watch_drafts is on. A draft is watched once it is marked ready for review, because a draft's checks failing is usually you still pushing.

At most five are armed per read, and the rest on the next, because a watched pull request with failing checks can start an agent and a stack of pull requests opened at once should not be a burst of them. Each one is named on the status line and in its watch activity.

Posting a comment when checks pass

P arms a watched pull request to have a comment posted on it each time every check on its head commit passes. The comment is whatever your tooling answers: /deploy staging for a deployment bot, say, so a pull request reaches a dev or staging environment as soon as CI is green without anybody going back to it. prutil posts the comment itself, through gh, as R does; no agent is involved.

Set the comment first, as Checks passed comment under PR COMMENTS in s (checks_passed.comment). Checks passed comment per repository (checks_passed.repos) gives one repository its own, or "" to switch it off there. With no comment configured, P says so rather than arming anything.

  • It is posted once per head commit. Push again and it is posted again once the new commit's checks pass; a poll that finds the same commit still green posts nothing.
  • It stays armed, across restarts, until you press P again or stop watching the pull request. Stopping the watch, by w or any of the ways prutil stops one on its own, disarms it too.
  • Pressing P when the checks have already passed on the current commit asks for a second press, because it would post at once.
  • It is never posted while the pull request is held. A deployment runs the pull request's code, so the same trust boundary that keeps feedback from an agent keeps this comment back too, and prutil waits until it has read the review threads before posting. Once the hold clears it is posted at the next poll.
  • -dry-run and herdr.dry_run record what would have been posted, in the WATCH activity, without posting it.

It costs nothing to wait for: the watcher already reads each watched pull request's check rollup. An armed pull request carries ↗ beside its ◉, the header counts them as ↗ 1 on pass, and the WATCH section shows when checks pass: post … and which commit it was last posted for.

Your own review comments as feedback

watch.self_review turns every unresolved review comment you wrote into feedback, without needing the test marker in each one. It is off by default; s toggles it under WATCHING and saves the change.

Either way, a reply your agent left must not read back as fresh feedback, or the same work goes round again. The default prompt asks the agent to end every review reply with this line:

<!-- prutil:agent -->

prutil skips a thread whose newest comment carries that marker and was posted by your own account. A reviewer writing it, deliberately or by quoting an agent that did, changes nothing.

The prompt lives in config.yaml, which prutil writes once on first run and never rewrites, and a prompt naming a herdr.skill is a slash command that says nothing about replies at all. So prutil adds the instruction itself to any prompt that renders without the marker: an installation from before the marker existed, and a prompt you wrote yourself, both still ask for it. Write the marker into your own prompt if you would rather word the request yourself.

N is a diagnostic trigger for the automatic path. It asks GitHub for the selected open pull request's review threads and sends only feedback prutil has not handed over before, so it is useful for confirming the normal handoff behaviour without waiting for the watcher to spot a change. Like the watcher, it follows herdr.fallback when no agent is working on the pull request.

prutil picks the agent rather than asking you to. It lists the agents herdr knows about and asks git what each one's working directory is working on. An agent qualifies when prutil set its workspace up for the pull request, when its branch tracks the pull request's head branch or has the same name, or when its checkout holds the pull request's latest commit. Branch names are never compared for a likeness: fix/retry and feat/retry are different work, and so are main and chore/sync-main. When more than one agent qualifies, the stronger evidence wins, then the one ready for input. The prompt carries a warning only when there is something true to say: the checkout is behind the pull request, or holds its commits on a branch that will not reach it.

What happens when no agent qualifies is herdr.fallback's to decide:

fallback When nothing is working on the pull request
new (default) set a workspace up for it and start an agent there, the way W does
none send nothing; the herdr notification and handoffs.jsonl name the agents prutil passed over
repo hand the work to any agent in the repository, with the mismatch spelled out in the prompt

Under new and none an agent busy with other work is never interrupted. repo is the setting that allows it, and prefers an agent that really is on the pull request whenever there is one. The terminal prutil is itself running in is never given work. If the agent is busy prutil waits for it to finish, up to fifteen minutes, and if it is stuck at a prompt of its own nothing is sent at all. An agent prutil has just started is given a moment to come up, and is prompted again if it does not react; an agent that was already running is prompted once.

W is also the explicit consent to set up a workspace when no suitable agent exists. It resolves a local checkout, fetches the pull request's pull/<number>/head ref, reopens an existing matching herdr worktree when it can, or creates a no-focus worktree workspace and starts the configured agent there. Set herdr.agent_kind to the herdr agent kind to start (for example claude or copilot); it is required whenever prutil starts an agent, and without it a handoff with nothing to hand to reports that no local agent was available. The watcher, F and N set a workspace up the same way under the default fallback: new; set fallback: none to keep creation to W alone.

Nothing is ever handed over twice. Each thread prutil sends is remembered against the comment it ended on, so pressing W again on a review whose comments you have decided not to act on costs one GitHub request and sends nobody anything. That is what makes it safe to leave a pull request watched.

Every attempt is recorded, sent or not, one JSON object per line, in handoffs.jsonl beside the configuration. Start with -dry-run, which writes that log and shows the status line without mutating a repository, worktree, workspace, or agent.

The selected pull request's detail pane includes a WATCH section when it has watch or handoff activity. It shows the current state-machine tier, cadence and next check; work currently in flight; a bounded activity feed from this TUI session; and the three most recent durable handoff attempts for that pull request. The activity feed resets when prutil exits; handoffs.jsonl is the cross-session record.

What the watching costs

Watching a pull request is two questions, asked at very different rates.

The first is cheap and batched. One GraphQL request covers every watched pull request at once, whatever repositories they are spread across, and reads only enough to notice that something moved: the head commit, the check state, the last-updated time, the conversation-comment total, and the total review-thread count. It is one rate limit point per request, and one request per hundred pull requests you have marked, which is where GitHub caps the node lookup it uses.

The second is the expensive one, and it is asked only of the pull requests the first one flagged, or of one that has gone five polls without being asked. That second part matters: a reply inside an existing review thread moves neither count, so a counter on its own would miss it.

That precise read covers the first 100 code-review threads. For each one it reads the opening comment's author and body plus the newest comment's author, id and body. The newest body is fetched only so the self-test marker can be used in the viewer's latest reply; arbitrary historical replies are not scanned. The top-level pull-request conversation-comment count is only a change signal; it is not a code-review thread and is never handed to an agent.

How often the first question is asked depends on what the pull request is doing:

The pull request Asked about
has checks running every 30 seconds
has nothing in progress after 2 minutes, then 4, 8, 16, and 30
has been given to an agent after 10 minutes, then 20, 40, and 60
has not changed for about two hours not at all

Anything at all changing puts a pull request back to the top of that ladder. A pull request prutil has stopped asking about is still armed, and its ◉ turns hollow to say so; r wakes it, along with everything else, while R (posting the AI review comment) wakes that specific pull request.

Configuration

prutil reads config.yaml from $PRUTIL_HOME, else $XDG_CONFIG_HOME/prutil, else ~/.config/prutil. On first startup it creates a complete editable template there. The file is yours: the only thing prutil ever writes back is a setting you change in its settings pane, one value at a time. Every key remains optional, and these are the defaults:

herdr:
  agent_kind: claude        # required whenever prutil starts an agent
  skill: pr-comment-triage  # the skill the default prompt invokes
  wait_for_idle: 15m        # how long to wait for a busy agent
  dry_run: false
  toast: true               # show a herdr notification alongside each handoff
  fallback: new             # no agent on the pull request: "new" sets one up,
                            # "none" reports it, "repo" uses any agent in the repo
  # Optional separate Go template for failed-check investigations. It receives
  # Repo, Number, URL, Title, HeadRef, BaseRef, Checks and Note.
  check_prompt: "..."
watch:
  active_interval: 30s      # while checks are still running
  base_interval: 2m         # once nothing is in progress
  max_interval: 30m         # where the backoff stops growing
  notified_interval: 10m    # after a handoff, when an agent is at work
  max_notified_interval: 60m
  idle_interval: 10s        # how often a busy agent is re-read
  dormant_after: 3          # polls at the cap before prutil stops asking
  force_precise_every: 5    # polls before the expensive question is asked anyway
  self_review: false        # treat every unresolved comment of yours as feedback
  auto_watch: false         # watch every pull request you open from now on
  auto_watch_drafts: false  # include drafts before they are ready for review
  auto_watch_interval: 5m   # how often the open list is re-read to find them
  self_test_marker: "<!-- prutil:test -->"  # "" turns it off
review:
  comment: "/gemini review"  # comment posted by R to trigger an AI review; "" turns it off
  repos:
    acme/widgets: "@coderabbitai review"  # optional per-repository override
checks_passed:
  comment: ""                # posted by a pull request armed with P once its checks pass; "" is off
  repos:
    acme/widgets: "/deploy staging"  # optional per-repository override
notifications:
  interval: 2m              # how often every open pull request is read while one is on
  events:
    approved: true          # a pull request is approved; s in prutil toggles it
    checks_passed: false    # every check on a pull request's head commit passed
repos:
  acme/widgets: ~/src/widgets  # optional explicit checkout for W
discovery:
  roots:
    - ~/src                    # optional roots scanned after repos misses
security:
  trusted_associations:        # whose feedback may reach an agent unasked
    - OWNER
    - COLLABORATOR
  trusted_authors:
    - "gemini-code-assist[bot]"  # [bot] matches only a GitHub App
  require_sandbox: true        # automatic handoffs only to sandboxed agents

Discovery roots are walked four levels deep, and a checkout's origin remote is read from its own .git/config before git is asked about it, so pointing at a directory of a hundred repositories costs a hundred small file reads rather than several hundred forked processes. A root such as ~/.filetree/worktrees can find checkouts under repository/branch. When you press W or F and no agent is already on the pull request, prutil reopens an existing worktree on the pull request's head branch if its repository and HEAD commit match. Automatic provisioning still creates or reuses only prutil's own worktrees.

With skill set, the prompt is /<skill> <pull request url>. Without it, prutil spells the job out instead. Either can be replaced with herdr.prompt, a Go template given Repo, Number, URL, Title, HeadRef, BaseRef, Skill, UnresolvedCount, NewCount and Note. Failed-check handoffs use herdr.check_prompt, whose Checks value contains the failed check entries and whose default prompt is designed for deciding between a follow-up commit, a retry, and human assistance.

security is the trust boundary between whoever can comment on a pull request and the agent that acts on what they wrote. On a public repository that is any GitHub account, so feedback is handed over unasked only when everyone who has spoken in every unresolved thread is you, an author whose GitHub authorAssociation is listed, or a login in trusted_authors. Anything else is held: prutil sends nothing, records it, and tells you who caused it.

Both lists are editable from the settings pane (s, then the SECURITY section): enter opens the list, a adds an entry and d removes the selected one, saved to config.yaml as you go. An association that GitHub never reports, or a name that is not a login, is refused with an explanation rather than quietly trusting nobody.

MEMBER is not a default. In a large organisation it means only that somebody belongs to it, which implies no write access at all; add it if yours is small enough for membership to mean something. An entry ending in [bot] matches only a GitHub App, so a person registering that name as their login does not inherit its trust. Writing a key as [] is honoured as written and trusts nobody by that route, which is stricter than leaving it out.

A pull request is held for a second reason too: a comment carrying text github.com does not render. Tag characters, zero-width and bidi controls can put a paragraph of instructions into a comment that looks empty to you and reads normally to an agent, so prutil holds the pull request whoever wrote them — a trusted reviewer's account is exactly the one worth taking. HTML comments only count in somebody else's prose, since review bots use them as metadata and prutil's own markers are HTML comments. Emoji are safe: the zero-width joiner every family and profession emoji is built from is exempt between two emoji, and nowhere else.

prutil also will not create a workspace over a branch that is not yours. W and the automatic fallback: new path check out pull/<number>/head and start an agent in it, and an agent started in somebody else's checkout loads that repository's own settings, hooks and instruction files — hooks run outside any sandbox. Only your own pull requests, ones you have adopted with +, and trusted_authors are provisioned over; -query can list anyone's, which is when this matters. An agent you have already checked out there yourself still takes the work, because that is your own choice rather than prutil's.

A held pull request holds its failed checks with it, because the agent a check investigation starts reads the same pull request. Resolving the thread on GitHub releases the hold at the next poll; otherwise W and F both act on it after a second press that names what it is waving through. That press covers one send, however much is wrong with the pull request, and is not remembered: the next attempt asks again, and nothing you wave through puts the pull request back on the automatic loop.

Separately, every value prutil puts into a prompt is cleaned first: control and format characters are dropped, single-line fields stay on one line, and a failed check's link is kept only when it points back at the same GitHub the pull request came from. A prompt carrying a control character is refused outright rather than typed into an agent's terminal, so a herdr.prompt template of your own containing one will fail the handoff and say so.

prutil only acts automatically on a pull request whose review threads it has actually read, so the first failed-check handoff after starting prutil waits one polling interval while it reads them. The same wait applies when GitHub refuses that read: not knowing who has commented leaves the automatic paths shut rather than open. W and F are your own key presses and do not wait.

Sandboxed agents

A Claude Code agent that prutil starts runs inside Claude's own sandbox. Its shell commands can write only inside its worktree, reach only the domains the policy allows, and cannot reach herdr's socket, so a talked-into agent cannot type into your other panes. It cannot ask its way out, either: the sandbox is strict. What it can still do is what the work needs: commit, run the tests, push, and reply on threads with gh.

The policy is sandbox/claude-settings.json in prutil's directory. prutil writes it the first time it starts a Claude agent and never again, so it is yours to edit. Before each start, prutil asks Claude what the policy gives the agent, and refuses to start it if the answer is not sandboxed. Two things are added at each start rather than kept in your file:

  • the SSH agent's socket, which moves every time a Mac boots;
  • git rules that send the pull request owner's repositories over HTTPS, with gh as the credential helper. SSH cannot get out of Claude's sandbox on macOS. The rules apply to that agent's session only; your own git configuration, including any rewrite of HTTPS to SSH, is not touched. If your organisation enforces SSO, your gh token needs authorising for it, as your SSH key did.

With security.require_sandbox on, which is the default, automatic handoffs to Claude, Copilot CLI, and agy require a contained agent. prutil passes over an uncontained agent on the pull request rather than starting another beside it; W and F can still hand work to it explicitly.

Copilot CLI (1.0.88) starts with --experimental --sandbox and strips common AWS token variables from shell and MCP environments. GitHub tokens stay available so gh can push and reply. agy (1.2.11) starts with --sandbox. Neither has a sandbox status command: prutil checks their flags and their existing, user-owned settings; it does not change vendor files or relocate sign-in.

For Copilot, set sandbox.allowBypass: false, sandbox.userPolicy.network.allowLocalNetwork: false, and include absolute paths to ~/.ssh, ~/.aws, ~/.gnupg, ~/.netrc, ~/.config/herdr and ~/.config/prutil in sandbox.userPolicy.filesystem.deniedPaths.

For agy, set enableTerminalSandbox: true, toolPermission: "proceed-in-sandbox", and restricted read_url(domain) entries in permissions.allow; do not allow unsandboxed or wildcard URL rules. With a missing or permissive policy, automatic handoffs are refused until you configure it.

These checks are not proof of OS isolation: a live agent must still verify secret reads, network access, and herdr socket denial on your machine.

The WATCH history says what each handoff went to, for example claude w3:p1 · sandboxed, strict. A sandboxed Claude agent's push goes over HTTPS through the sandbox's proxy; that has been checked with a real handoff, from finding the clone to the agent's reply on the thread. If a push fails for you, set require_sandbox: false and say what the agent reported.

Explicit repos entries win. When none exists, prutil checks its private repos.json cache, then the directories your herdr panes are working in, and then scans discovery.roots, validating every candidate against its origin remote before it can be used. So a shell open in a clone is enough for prutil to find it, with nothing configured; the checkout it finds is remembered in repos.json after the pane has gone. A clone is preferred to a worktree of it. Stale cache paths are ignored and refreshed. GitHub poll intervals are clamped to fifteen seconds at the shortest, so a typo cannot turn a dashboard into a load test.

Adopting somebody else's pull request

Taking over work somebody else started is common enough to have a key. + opens a prompt that starts on a list of repositories: the ones you have browsed or adopted from before, most recent first, then every repository in your open and closed lists. Type to filter it, or type an owner/repo it does not offer, and press enter to see what other people have open there. Pick one, filter by title or author, or type its number, and enter looks it up: prutil shows whose it is, its branch, and whether its head is in a fork that will not let you push. enter once more adopts it. esc steps back a stage at a time.

Pasting a pull request's URL, or typing owner/repo#12, skips straight to that last step from anywhere in the prompt.

An adopted pull request sits in the open list with your own, sorted with them, and is marked ⇄ with by <author> on its row. The header counts them — ⇄ 2 adopted — so one you have forgotten about is never out of sight. prutil remembers them between runs, reading them back by id on every load, and releases one on its own once it has merged or closed.

Adopting a pull request trusts its author on that pull request and nowhere else. Their review comments there are not held, and prutil will create a workspace over their branch when you ask it to, exactly as it does for your own. It does not add them to trusted_authors, and a pull request whose author GitHub has since renamed or lost is not one you agreed to, so it is treated as a stranger's again. Everybody else commenting on it goes through the trust boundary as usual.

It is not watched until you press w, and w asks twice, naming the author: whoever opened it may still have a prutil of their own watching it. Two watchers on one pull request would each see the other's agent replies as fresh feedback from a person and answer them, forever. So an agent's reply that somebody other than you posted — it carries <!-- prutil:agent --> — holds the pull request, on your own pull requests as well as adopted ones. The first time one appears on a pull request you are watching, prutil stops watching it, says so, and records it in the handoff log; w asks before starting again, and once you have said yes, only a newer reply from another agent stops it. W and F still work after a second press. Only one of the two sides needs this for the exchange to stop, so it works against an older prutil too.

- releases the selected adopted pull request after a second press: it stops being watched, its author stops being trusted on it, and it leaves the list unless it is also one of yours.

Views

tab switches the list between your open pull requests and your recently closed ones. Each view keeps its own cursor and scroll position, and the closed view is not fetched until the first time you show it.

The closed view is grouped: no repository contributes more than -closed-per-repo rows, so a single busy repository cannot fill the screen and hide everywhere else you have been working.

If GitHub refuses some of those per-repository queries, which a very large organisation can provoke, the header says how many repositories were unreachable and the rest of the list is shown anyway. Press r to try again.

Note that -closed-query inherits archived:false from the open view. If most of your history is in repositories that have since been archived, drop that qualifier to see it:

prutil -closed-query='is:pr author:@me is:closed sort:updated-desc'

How it stays quick

The headline list is one GraphQL request, which includes the check rollup that colours each dot, so the first screen appears after a single round trip. The individual checks are fetched afterwards, in the background for the top of the list and on demand as you move, with at most four gh processes at a time. Both are cached until you press r.

The closed view usually costs one request too. It sweeps your closed pull requests newest first, and when that sweep reaches the end of the search it already holds everything, so the grouping is exact and nothing more is fetched. Only when the sweep runs out of room first does it go further. It reads a little further down the same search, collecting nothing but repository names, then queries the ones that came up short directly. Nothing enumerates an organisation's repositories, so the work does not grow with the size of the organisations you belong to. Those per-repo searches are batched under GraphQL aliases, so three repositories cost one request and one rate limit point.

Batches are small on purpose. GitHub gives a single GraphQL document about ten seconds before returning a 502, and that budget is spent faster in a large organisation, so the closed view trades round trips for headroom.

About

A simple TUI to display the status of open PRs, watch for feedback / comments and notify Herdr agents to triage and reduce PR cycle time. Entirely vibe-coded.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Contributors

Languages