/birdseye — the whole workspace, honestly
Requires: git, plus the GitHub CLI (gh) authenticated — gh auth status
should pass. Remote state is read through the API because local tracking refs go
stale, so without gh the push/merge answers are guesses.
Workspace root: $ARGUMENTS if given, otherwise the directory the session was started from.
Walk every git repo under the root and come back with one action list, not a status dump. The deliverable answers three questions per repo and nothing else:
- Is there uncommitted work that should be committed? (vs. build artifacts that should be ignored)
- Is there a branch that should be merged? (vs. one that is deliberately waiting)
- Is there a commit that should be pushed?
Anything with no answer to all three gets one line in a "clean" table. Resist writing paragraphs about healthy repos — the value here is the short list of things that need the user's hands.
What this is NOT
- Not a portfolio review. No PUSH/HOLD/PARK/KILL, no "should this exist." That's a monthly exercise. This is mechanical repo hygiene and can run any day.
- Not a code review. Judging whether the code is good is out of scope. Judging whether it builds and passes tests is in scope, because that's what decides mergeable vs. needs-a-human.
- Not a fixer, by default. Report first, then execute the items the user picks.
Pushing is in scope. One "go" on an action list authorizes the whole list — commits,
merges, branch deletions, and the pushes that finish them. Do not split pushes out into a
second approval round: a repo left with local-only commits at the end of a sweep is an
unfinished sweep, and re-asking per repo is the friction this skill exists to remove.
Push to each repo's own default branch, which is not uniformly
main(see Phase 2). Still the user's call, and worth pausing for: a force-push, a push that rewrites published history, or anything touching a repo they didn't name.
Phase 0 — Enumerate
ROOT=<workspace root> # the argument above if given, else the session's starting directory
for d in "$ROOT"/*/; do [ -e "$d/.git" ] && echo "$d"; done
Include notes/docs repos if any live under the root (they are real repos and do get left dirty).
Note non-git dirs separately and briefly — a directory of worktrees belongs to its parent repo,
not an orphan, and a loose scripts/ folder is just files. Don't propose git init for either.
Phase 1 — Local state, one pass
Per repo collect: current branch, last commit (short + date), git status --porcelain count and
contents, git branch -vv, stash count, git worktree list.
Do not trust git status -sb ahead/behind. It compares against remote-tracking refs that can be
days stale, and it will report "in sync" for a repo whose remote has moved. Phase 2 exists for this.
Phase 2 — True remote state (the part that matters)
Push/fetch works — check, don't assume. Start with ssh-add -l. If it lists an identity, plain
git push / git fetch / git ls-remote all work normally and you need none of the workarounds
below. Only when it reports no identities is anything special required, and even then the fix is
the user running ssh-add ~/.ssh/<key> interactively (see Phase 6) — ten seconds, not an outage.
Fallback when the key is locked and you can't ask: gh holds its own keyring token, so borrow it.
Note that a global url.git@github.com:.insteadOf https://github.com/ rewrite drags even an
explicit HTTPS URL back onto SSH, and a -c url...insteadOf counter-rewrite does not override
it — you have to bypass the global config outright:
GIT_CONFIG_GLOBAL=/dev/null git -C <repo> \
-c credential.helper='!gh auth git-credential' \
push https://github.com/<owner>/<repo>.git <branch>
The same form works for fetch and ls-remote (verified 2026-07-29 across four repos). Never
report "push auth is down" as a blocker — between ssh-add and this, there is always a way
through. Reading remote state through the API is still fine and cheaper:
gh api repos/<owner>/<repo>/branches/<default> --jq .commit.sha
Compare that SHA to git rev-parse <default> locally. Three outcomes:
- equal → in sync
- local SHA not on remote (
gh api repos/<o>/<r>/commits/<sha>→ 422) and remote SHA not in local log → diverged; inspect both sides before recommending anything - local contains the remote SHA → unpushed commits
Check the default branch per repo — it is not uniformly main. Older repos often use
master. Get it from git symbolic-ref refs/remotes/origin/HEAD or the API's default_branch,
never by assumption.
When a repo diverges, say which files each side touched. A nightly bot commit to a generated
health.json against a local TASKS.md edit is a trivial pull --rebase; describing it as
"diverged" without that detail makes a 10-second fix sound like surgery.
Phase 3 — Judge agent branches by their verdict, not their name
This phase applies when an agent runner (a nightly/overnight rig, a scheduled coding agent) writes
branches into these repos. Its branches (overnight/*, agent/*, or whatever prefix it uses) are
not neglect — they are the designed output of the runner. A well-built merge gate merges into
local main only when the reviewer verdict is approve with an empty merge checklist and the
project's verify command passes; pushing is never automated. A branch sitting unmerged is the system
working: it's the morning-coffee queue.
So don't guess. Read the runner's run report (wherever it writes one, e.g.
<runner>/runs/<YYYY-MM-DD>/run.json). Per task it should carry the task lead, the branch, the
reviewer verdict, a failed flag, and — critically — a top-level warnings[] that states the exact
reason a merge gate declined. That string is usually the whole answer ("approved but not
auto-merged — uncommitted local changes overlap the branch (…)").
Triage:
| Verdict | Meaning | Action |
|---|---|---|
approve, not merged |
gate was blocked by something mechanical | the actionable merge — clear the blocker, merge |
unreviewed + failed |
builder session errored before review | verify it yourself (Phase 4), then merge or discard |
| absent / parked | run died or was auto-parked | needs a human; don't recommend a merge |
Also list agent branches already merged into the default branch
(git branch --merged <default>) — those are pure cruft and safe to delete. A nightly runner
accumulates one per night.
Phase 3.5 — Is the agent rig itself healthy?
Only if a runner exists (see Phase 3). The run report says what happened last night; these two say whether tonight will happen at all. Both have silently wasted whole nights, so check them even when every repo is clean.
Weekly token ledger. If the runner tracks budget (e.g. run.json carrying
budget: {night, window, weekly} and a state/ledger.json), note that a nightly cap is often
advisory: a first-task-per-project exemption lets a night overshoot it. The weekly ledger is the cap
that actually binds, and blowing it stands down entire nights:
node -e 'const l=JSON.parse(require("fs").readFileSync("state/ledger.json","utf8"));
const t=l.entries.filter(e=>Date.now()-e.at<7*864e5).reduce((s,e)=>s+e.tokens,0);
console.log(t.toLocaleString(),"of weekly cap");'
Report it as nights of headroom left at last night's burn, not a percentage — "0.8 nights left" lands, "78% used" does not.
Permission denials. Grep the run report for permission_denials. A persona repeatedly denied
the same command is burning its turn budget rediscovering the denial and will die on max_turns
with an unreviewed verdict — which then reads as "the code is suspect" when the real fault is a
prompt/allowlist mismatch. Name the denied command in the report; that's the fix, not more turns.
Phase 4 — Verify before you judge an unreviewed branch
verdict: unreviewed means the reviewer never ran, not the code is bad. The two are constantly
confused, and the cost of the confusion is throwing away good work. Build and test it:
- Rust →
cargo check && cargo test - Node → the project's own verify/test script
Report the result as evidence. "Compiles clean, 67/67 tests pass" turns a scary-looking
wip: auto-saved uncommitted builder output (run cut short) commit into an easy merge decision.
Note when the auto-save commit message is junk even though the diff is good — that's a squash/
reword on merge, not a reason to discard.
Phase 5 — Classify uncommitted work
Three buckets, and the distinction is the point:
- Commit it — real authored change. Read the diff; if it carries a comment explaining a bug and a fix, it's finished work someone forgot to commit.
- Ignore it — build/dev artifacts. Check the repo's existing
.gitignorephilosophy before proposing a commit. A repo that explicitly keeps heavy binaries local means an 8 MB compiled plugin belongs in.gitignore, not in a commit. - Discard it — a working-tree change that is worse than what's committed. Actually diff it.
A dirty
status.jsonwhose last field is truncated mid-word is a clobbered write, and the fix isgit checkout --, not a commit. This is exactly the class of thing that blocks a merge gate, so it shows up as a cause, not just a symptom.
Phase 6 — Report
Lead with blockers that gate everything else — but only after proving they're real. "Push auth is
down" was reported as one on the first run and was actually a locked keychain: ssh-add ~/.ssh/<key> cleared it in ten seconds. Prove a credential is unusable interactively before
you call it a blocker, and prefer the Phase 2 incantation over declaring one at all.
Then an ordered action list, most-consequential first, each item naming the repo, the concrete
command(s), and the one-line why. Then a compact clean-repos table. Then any structural oddity worth
a decision but not urgent — e.g. a repo whose default branch is no longer its trunk (a feature branch
~59 commits ahead of a master that hasn't moved in months, with the live deploy building from the
feature branch). Flag those as "confirm, then fix", never auto-fix.
Close by asking which items to execute. Then execute only those.
Phase 7 — Re-verify immediately before acting (this sweep races other sessions)
If the user keeps one long-lived session per project, then between reporting the action list and executing it, a project session can do the work itself. Measured on the first run: in the eight minutes after the report, other sessions merged an approved branch in one repo, committed a parser fix in another, merged and groomed two more, and pruned five stale agent branches.
The first symptom is incoherence, not a clean error — a git diff on a file inspected moments ago
returns empty, and git rev-parse master origin/master dies with "Needed a single revision"
mid-command. So, per repo, immediately before touching it:
git fetch(or the API SHA check) andgit status --porcelainagain.- If the state moved, check whether it moved to where you were going.
git reflog --date=isogives timestamps and the operation, andgit diff origin/<default>...<default> --statconfirms the landed content matches what you'd have merged. If it matches, the only work left is usually the push. Never force your original plan onto a repo that already got there. - Say plainly in the report that the change landed from outside the session. Claiming credit for another session's merge is the failure mode here.
Known shapes worth checking every run
Keep a short per-workspace list of recurring shapes and check it each sweep. Patterns seen so far:
- A repo that accumulates one merged agent branch per night: prune them. If the same repo
regenerates a data file twice (the agent locally and a GitHub Actions cron pushing its own
refresh), they collide on every push. Resolve with
git checkout --theirs <file>— CI's run is the canonical one — and don't mistake it for a real conflict. - A nightly that writes a status file into the repo (e.g.
.dashboard/status.jsonplus an untracked report): the tracked one routinely blocks its own merge gate. Check what is and isn't gitignored. - Two repos that share a hand-written parser shape (a dashboard's roadmap parser and the runner's copy of it): a fix to one often sits uncommitted in the other.
- Long-lived scratch worktrees each ~40 commits ahead of the default branch: if the experiment they served is settled, ask whether to prune.