SLOPSHOPPER

repo-brief

Gathers the repo's state at session start (branch, ahead/behind, uncommitted work, recent commits, open PRs with CI, focus-label issues, stale branches) with…

newbandcommandpromptprocess
v0.1.0no licenseupdated 2026-10-07joeldg/claude-mods/repo-brief
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · repo-brief
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /brief ⎿ repo-brief: Repo state just now (gathered by the repo-brief mod): ⎿ repo-brief: Branch: none, HEAD is detached ⎿ repo-brief: Working tree: clean ⎿ repo-brief: PRs and issues: not gathered (gh unavailable, or the remote is not GitHub) HEAD@detached · clean Hide ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
HEAD@detached · clean Hide
README

claude-mods

Claude Code mods (function-hook plugins) I find helpful. Each folder is one plugin.

Requires a Claude Code build with function-hook plugins (2.1.289 or newer). machine-guard reads macOS tools (sysctl, memory_pressure, ioreg).

git clone https://github.com/joeldg/claude-mods ~/Projects/claude-mods

dev-servers

A pane of your project's running dev servers, so you don't have to ask Claude to restart them.

  • /servers opens the Servers pane:
  • running servers whose working folder is in this repo: name, port, pid and uptime, with Restart, Stop and Log
  • known start commands that aren't running, with Start: package.json dev/start/serve/preview scripts (run with your lockfile's package manager), .claude/launch.json and Procfile
  • a count of other listeners on the Mac
  • Only button presses start or stop anything:
  • Start runs the command detached, logging to ~/.claude/dev-servers/<project>/.
  • Stop sends SIGTERM. If the server ignores it, pressing again within 10s force-stops it.
  • Restart stops the server, waits for the port to free up, then starts it.
  • Before any signal, it checks the pid still runs the same command.
  • When a command fails with "address already in use", Claude gets a note (and you a toast) naming the holder, e.g. Port 4000 is held by node (pid 123, up 2h, in /Users/me/other).
  • Status line: servers: :4000 :5173.
  • Makes no model calls: lsof and ps every 15s.

downloads-drop

Puts files you just downloaded into your prompt with one click.

  • Watches ~/Downloads (top level). When a new file arrives (PDF, Markdown, images, 3MF/STL/OBJ, zip, video…), a band appears above the prompt: New in Downloads: paper.pdf, model-b.3mf · 2m ago [Attach] [Dismiss].
  • Attach puts @"/Users/you/Downloads/paper.pdf" mentions in your prompt. Dismiss hides those files.
  • Waits until a file has finished downloading (skips partial downloads and files still growing), and ignores hidden and zero-byte files.
  • /downloads lists the 10 newest files, numbered. /downloads attach 1 3 (or 2-4) adds those, and /downloads clear dismisses everything new.
  • Stacks with other mods' bands (repo-brief, standing-orders, secret-guard) instead of hiding them.
  • Makes no model calls.

Settings: folder (~/Downloads), extensions, pollSeconds (5), maxAgeMinutes (120).

effort-router

Sets effort per message, so you don't have to switch it by hand.

  • Git chores ("merged", "#219 merged", "commit and push", "push it", "open a PR", "close the issue") run at low effort and come back faster.
  • Deep asks (audit, review, plan, design, investigate, root cause, "why does…", "figure out") run at max.
  • Everything else, including approvals like "yes", "go ahead" and "continue" and anything that starts new work ("merged 219, go ahead with #214"), keeps your session's own effort.
  • Prompt cache: changing effort makes the whole conversation get cached again. So it never switches mid-turn, raises effort at once, and over a large, warm cache lowers it only after 2 routine turns in a row. In small contexts, or once the cache has lapsed, it switches right away.
  • Model guard: set avoidModel (a regex such as fable) to send those requests to fallbackModel instead, subagents included.
  • /route shows the last decision and the session's counts. /route off and /route on toggle it; /route deep and /route routine force the next turn.
  • Status line while a turn is routed: effort: low (routine).
  • Makes no model calls.

Settings: routineEffort (low), deepEffort (max), routinePattern, deepPattern, avoidModel, fallbackModel (opus), stickyTurns (2), freeSwitchTokens (30000), cacheTtlMinutes (60).

job-watch

A Jobs pane for long-running work: training runs, downloads, extractions.

  • Picks up background Bash tasks and detached nohup … > log & launches by itself.
  • /watch <log> [label] adds any other log file.
  • Shows progress, ETA and the last log line, and flags a job as stalled when its log goes quiet.
  • Shows free space on / and /Volumes/* (the NAS).
  • Toasts when a job finishes or stalls. The status line shows jobs: 2 running · 1 stalled.
  • /jobs opens the pane, /unwatch <label|done|all> removes jobs.
  • Makes no model calls: it reads logs with tail, checks processes with ps, and runs df.

Settings (in /config): stall minutes (10), refresh seconds (10), how long finished jobs stay (120 min), auto-open (on), which disks to show.

machine-guard

Memory, swap and GPU on the status line. It refuses heavy local jobs when the Mac can't take them.

  • Status line: RAM tight 12% free · swap 7.9/8G · top python 31G · GPU 87%.
  • It refuses heavy jobs (training, inference, rendering, extraction, Blender, Docker, ffmpeg) when:
  • macOS reports critical memory pressure, or
  • the Mac is reserved with /busy.

When memory is only tight, the job runs and Claude gets a note to start one heavy job at a time.

  • /busy 3h training a vision model reserves the Mac in every Claude session. /busy off lifts it. The reservation lives in ~/.claude/machine-guard.json, so a training script can write it too: ``bash echo '{"reason":"overnight training","until":'$(( ($(date +%s) + 8*3600) * 1000 ))'}' > ~/.claude/machine-guard.json ``
  • /guard shows what it sees. /guard pause 15m lets heavy jobs through in this session; /guard on resumes the guard.
  • Remote runs (modal run, ssh), tests (pytest) and installs are never treated as heavy.
  • Add your own heavy commands with the "Also heavy" setting (a regex), e.g. overnight_|nightly_run\.sh.

mod-monitor

Watches how the other mods behave in real use, without changing them. It is listed first in CLAUDE_CODE_PLUGIN_DIRS, so the other mods' hooks run beneath it.

  • Failures: any mod hook that throws, times out or rejects, read from the hook chain's results (next.trace), with the mod's name, the event and how long it ran. Slow hooks (over 1.5 s) are recorded too. The first failure of each mod in a session raises a toast.
  • What each mod did: its toasts ("#219 merged → …", "Blocked: …"), status-line changes, the mod commands you used (never their arguments), and failed subprocesses (a burst of 5 in 10 minutes raises a toast). Git checks run outside a repository are logged as expected, not as failures. It also records model calls (the only usage the mods cost: /second-opinion, /recall ask) and file writes (folders only, never contents).
  • /mods: a pane with one row per mod: ✓ active, ⚠ failing, ✗ not seen this session, · seen but idle. Each row shows today's counts and last activity, with Details for its recent events. It also says which mods it can't see, if any of them run above it.
  • /mods report [24h|7d|30d]: a per-mod report across all sessions, also written to ~/.claude/mods/monitor/report-latest.md for a scheduled review or Claude to read.
  • /mods failures [7d]: failures and failed subprocesses only.
  • Logs: ~/.claude/mods/monitor/<date>/<session>.jsonl, flushed every minute and at session end, with secrets masked and old days removed after 30 days.
  • Makes no model calls and adds no measurable latency.
  • Error lines mods log themselves ($.ui.log with wording like "failed" or "could not"): shown in Details and in /mods failures. Three in an hour mark the mod ⚠ and raise one toast. That is how effort-router's per-request hook, which runs inside the response stream where no monitor should sit, reports a failure. It also always sends the request on unchanged.
  • Transcript-row hooks (secret-guard masks /secrets records there) are watched for failures and slow runs, but not counted per run.

Settings: alerts (on), slowMs (1500), watchRender (on), watchCommands (on; off stops "mod-monitor" appearing beside other mods' command output), watchAppend (on), retentionDays (30), flushSeconds (60).

modal-meter

Keeps an eye on Modal so idle GPU containers don't burn credits.

  • Status line while containers run: Modal: 1 running (2 containers). Deployed apps with no containers cost nothing, so they stay off it.
  • A toast when an app has had containers up longer than alertMinutes (30), repeated at most every 30 minutes.
  • /modal opens a pane of apps with state, containers and uptime. Stop asks for Confirm, then runs modal app stop. Nothing is stopped any other way.
  • Shows today's spend and alerts on a budgetToday where the Modal CLI supports billing report (1.3.3+, Team/Enterprise workspaces). Otherwise /modal says why spend isn't shown.
  • Finds the CLI as modal or python3 -m modal. It checks PATH first rather than running a command that can only fail, and stays silent when Modal isn't set up.
  • Makes no model calls: only the Modal CLI, every 60s.

pr-autopilot

Does the "merged #219, clean up branches and start #214" round trip for you, and surfaces CI failures with their logs.

  • Watches your open PRs in the session's repo: it adopts them at session start, and picks up every gh pr create Claude runs. It polls gh pr view every 60s.
  • Status line: PRs: #219 ✓ · #220 CI… · #221 ✗. Toasts when CI fails (with the failing check names) or passes.
  • When a PR merges, it cleans up with plain local git, then toasts the outcome and suggests carrying on (Tab to accept):
  • git fetch --prune, switch to the default branch (only from the PR's own branch) and git pull --ff-only.
  • Never with uncommitted changes, never --force, never other branches.
  • Deletes the local branch only if it points at exactly the commit GitHub merged, so nothing local is lost. It also leaves a branch checked out in another worktree alone.
  • A merge seen mid-turn is cleaned up when the turn ends, so git never races Claude.
  • When you mention failing CI ("#258 is failing", "CI failed, fix it"), your message goes to Claude with gh pr checks and the tail of the failed log attached, so you don't paste it.
  • /prs lists watched PRs. /prs watch <n|url> and /prs forget <n|all> add and remove them.
  • Makes no model calls: only gh and git, at about one GitHub API call per open PR per minute.

Settings:

  • pollSeconds (60)
  • attachCiLogs (on)
  • logLines (120)
  • deleteRemoteBranch (off): deletes the branch on GitHub too, only while it still points at the merged commit. GitHub's own "Automatically delete head branches" setting does the same job.

It never closes issues; put "Closes #N" in PR bodies for that.

recall

Search everything you've done with coding agents, from Claude or from /recall. It replaces the broken agent-memory plugin.

  • What it searches: Claude Code sessions, Codex sessions, subagent and workflow runs, Claude's memory files, standing orders, second-opinion reviews, and your /remember notes. Routine (scheduled) runs are left out unless you add routines:include to a query.
  • What it keeps: prompts, answers, compaction summaries, session titles, commands, files touched, commits, PRs, issues, URLs, tasks and decisions (what you approved or ruled out). Read-only look-ups like grep and cat are kept but ranked low.
  • History survives cleanup: extracts stay searchable after Claude Code deletes old transcripts.
  • Claude searches it itself with four read-only tools, search, expand, recap and list, which run without permission prompts. It checks them when you say "like last time" or "what did we decide", and before asking you something you already settled.
  • Commands:
  • /recall <query> opens a pane of hits grouped by session. Open shows the conversation around a hit, Attach sends it with your next message, and Copy resume command copies claude --resume <id>.
  • /recall last [n] recaps your last session in this repo: last asks, last answer, PRs, commits, open tasks and decisions. Send to Claude attaches it.
  • /recall timeline [7d|30d|90d] [all]
  • /recall decisions|commands|files|prs|commits|issues|urls|tasks|notes [query]
  • /recall ask <question> answers from your history with Haiku 4.5, citing sessions. It costs a little usage and sends the matching excerpts to the model.
  • /recall stats, /recall reindex, /recall forget session <id>|project <name>|before <date> (asks you to confirm), /recall help.
  • /remember <fact>, /remember list, /remember forget <ref>.
  • Bands:
  • Once per session: Last session here (2d ago): "…" · PR #99 · 3 open tasks [Recap].
  • When a prompt mentions #214, ABC-12, a file name or a quoted phrase seen in past sessions, a band offers what happened then. Nothing is sent unless you click.
  • Query syntax: words must all match. OR gives alternatives, "quotes" an exact phrase, and -word excludes. Filters: project:name, kind:decision, since:7d, until:2026-09-30, source:codex, routines:include.
  • Privacy:
  • The index lives at ~/.claude/recall/index.db, readable only by you, and never goes in a repo.
  • Secrets are masked before anything is stored: known token shapes, labelled values ("password: …"), the values of secret-named exports in ~/.zshrc, ~/.zprofile, ~/.bashrc and ~/.bash_profile, and any literal strings you list in ~/.claude/recall/redact.txt (one per line). Editing that list re-masks the existing index on the next update.
  • Cost: no model calls except /recall ask. The first index takes about 2 minutes in the background, with progress on the status line. After that it updates incrementally (about 1s) at session start and every 10 minutes.
  • Requires macOS's /usr/bin/python3 (Command Line Tools), whose SQLite has FTS5. Nothing else to install.

Settings: dbPath, python, sources, includeSubagents (on), includeRoutines (off), updateMinutes (10), relatedBand (on), lastSessionBand (on), maxResults (8), askModel (claude-haiku-4-5-20251001).

repo-brief

Catches Claude up on the repo when a session starts, so you don't have to ask "check the recent commits/PRs and issues".

  • Gathers in the background at session start:
  • branch, ahead/behind and uncommitted files
  • the last 8 commits
  • open PRs with CI ✓/✗/…
  • issues labelled owner, todo, P0 or blocked
  • stale branches (merged, or upstream gone)
  • A one-line band above the prompt, e.g. main ↑1 · 3 changed · PRs #123 ✗ #124 ✓ · 2 owner issues · 2 stale branches · last commit 2h ago. Hide dismisses it.
  • Claude gets the same summary once, in its first message, so the prompt cache stays warm. It refreshes after compaction.
  • /brief re-gathers now and prints the full summary.
  • Makes no model calls: only git and gh. The band refreshes after a turn at most every 2 minutes.

Settings: focus labels, refresh minutes, and whether to brief Claude.

routine-watch

Keeps scheduled routines (daily digests, newsletters) from silently stalling while you're away.

  • Knows a session is a routine from its scheduled-task prompt, and does nothing in your other sessions.
  • When a routine stops to wait for your OK on a permission prompt or an AskUserQuestion, you get a Mac notification and a toast, and the status line shows routine: daily-report · waiting on you 3m.
  • When a turn ends in an error, or the routine finishes, you get a notification: Routine daily-report finished after 23m · waited on you 2 times.
  • Phone push (optional): notifyCommand runs a command on the same events, e.g. curl -s -d {message} ntfy.sh/your-topic. {title} and {message} are filled in as single arguments, never through a shell.
  • allowWebReads (off by default): lets routines use WebFetch and WebSearch without asking. It only replaces a prompt; your deny rules still apply, and nothing else is ever auto-allowed.
  • /routine shows the routine's name, how long it has run, its waits, and the settings.
  • Makes no model calls.

second-opinion

A Fable review in the background, without switching your session's model. Each run is one Fable call against your usage.

  • /second-opinion: reviews recent work. On a feature branch that's the branch against the default branch; otherwise the last 12 commits, plus the diff and git status, capped at 60k characters.
  • Other forms:
  • /second-opinion commits 5
  • /second-opinion diff (uncommitted changes)
  • /second-opinion file docs/ADR-007.md
  • /second-opinion <question>: adds a question for Fable to answer first.
  • The command returns at once, and the status line shows second opinion: reviewing…. When the review is ready you get a toast, and a pane opens with it, ranked: wrong assumptions, bugs and risks, what's missing, what to do next.
  • Send to Claude attaches the review to your next prompt (once) and drafts "What do you agree with, and what would you act on?".
  • Reviews are saved in ~/.claude/second-opinions/<project>/. /second-opinion list lists them, and /second-opinion show [n] reopens one.

Settings: model (claude-fable-5-1), effort (high), maxContextChars (60000).

standing-orders

Keeps your "always / never / don't / from now on" instructions alive across compaction.

  • When you write an instruction like "never open bambu with full spectrum files", a band asks: Keep as a standing order? [Project] [This session] [No]. Nothing is saved without a click.
  • Project orders live in ~/.claude/standing-orders/<repo>.json and apply to every session in that repo. Session orders and your active /goal last for the session.
  • Claude gets them at the start of every conversation and again after each compaction or /clear, so the prompt cache isn't disturbed. A newly saved order also rides along once with your next message.
  • /orders lists them. /orders add [project|session] <text>, /orders forget <n>, /orders clear session|project, and /orders export (a Markdown block for CLAUDE.md).
  • Makes no model calls.

secret-guard

Stops keys and passwords from going into a prompt, and so into your transcripts, and turns them into env vars instead.

  • Catches known token shapes: AWS, GitHub, Anthropic, OpenAI, Slack, Google, Hugging Face, GitLab, npm, Stripe, private keys and bearer tokens.
  • Also catches labelled values ("password: …", "api key = …", "the wifi password is …") and the two-line "Access Key ID / Secret Access Key" paste.
  • Leaves alone $NAME references, placeholders, plain URLs, paths, git SHAs and ordinary prose about passwords.
  • On a hit, the prompt isn't sent and goes back in the box. A band shows the secret masked (…vxrm) with a suggested name such as OPENDATALAB_SECRET_ACCESS_KEY, which you can edit:
  • Save as env var appends export NAME='…' to ~/.zshrc (reusing an existing identical export) and replaces the secret in your prompt with $NAME.
  • Send anyway lets exactly that text through once.
  • Edit dismisses the band.
  • The value is never shown in toasts, status, state or the transcript, and /secrets test <text> output is masked too.
  • /secrets test <text> shows what would be caught. /secrets off and /secrets on toggle it for the session.
  • Makes no model calls.

Settings: enabled (on), extraPatterns (a regex), zshrcPath (~/.zshrc).

slicer-handoff

Makes Claude's open commands hand 3D files to the right slicer.

  • Full-spectrum files go to Snapmaker Orca. Bambu Studio and OrcaSlicer can't open them. A file counts as full-spectrum when:
  • its name or folder matches full.?spectrum|snapmaker-only|-fs\.3mf$|-u1[-.], or
  • its 3MF names a Full Spectrum filament profile.

An open -a BambuStudio … for one becomes open -b com.snapmaker.snapmaker-orca …, with the rest of the command untouched. You get a toast, and Claude gets a note so it doesn't try again.

  • Earlier windows close first. Before opening a file, it asks the running slicer to quit (a normal quit, never forced), so windows don't pile up. If one won't close, for example because it's waiting on a save prompt, it stops trying and tells Claude to leave it alone.
  • /slice <file> [bambu|snapmaker|orca] opens a file yourself, with the same rules.
  • Recognizes open -a <app>, open -a /Applications/X.app and open -b <bundle id>, including variables set earlier in the command (S=… && open -a BambuStudio "$S/x.3mf") and files copied in the same command.
  • Makes no model calls.

Settings:

  • closePrevious (on): turn it off if you keep your own slicer window open, since the quit request reaches your windows too.
  • fullSpectrumPattern (the regex above)
  • checkContents (on)

The quit request goes out when Claude issues the command, before any permission prompt for it.

Loading

  • One session from a terminal: pass --plugin-dir once per mod, e.g. claude --plugin-dir ~/Projects/claude-mods/job-watch --plugin-dir ~/Projects/claude-mods/pr-autopilot
  • Every session, including the desktop app: add to ~/.claude/settings.json. Put mod-monitor first so it sees the others; CLAUDE_CODE_PLUGIN_DIR_WATCH makes desktop sessions pick up edits and show mod failures: ``json { "env": { "CLAUDE_CODE_PLUGIN_DIR_WATCH": "1", "CLAUDE_CODE_PLUGIN_DIRS": "~/Projects/claude-mods/mod-monitor:~/Projects/claude-mods/job-watch:~/Projects/claude-mods/machine-guard:~/Projects/claude-mods/repo-brief:~/Projects/claude-mods/slicer-handoff:~/Projects/claude-mods/pr-autopilot:~/Projects/claude-mods/routine-watch:~/Projects/claude-mods/modal-meter:~/Projects/claude-mods/second-opinion:~/Projects/claude-mods/downloads-drop:~/Projects/claude-mods/dev-servers:~/Projects/claude-mods/standing-orders:~/Projects/claude-mods/effort-router:~/Projects/claude-mods/secret-guard:~/Projects/claude-mods/recall" } } ``

Checking

Run with Claude Code 2.1.289 or newer; older CLIs ignore per-test settings, so a few tests fall back to defaults.

claude plugin validate job-watch && claude plugin test job-watch
claude plugin validate machine-guard && claude plugin test machine-guard
claude plugin validate repo-brief && claude plugin test repo-brief
claude plugin validate slicer-handoff && claude plugin test slicer-handoff
claude plugin validate pr-autopilot && claude plugin test pr-autopilot
claude plugin validate routine-watch && claude plugin test routine-watch
claude plugin validate modal-meter && claude plugin test modal-meter
claude plugin validate second-opinion && claude plugin test second-opinion
claude plugin validate downloads-drop && claude plugin test downloads-drop
claude plugin validate dev-servers && claude plugin test dev-servers
claude plugin validate standing-orders && claude plugin test standing-orders
claude plugin validate effort-router && claude plugin test effort-router
claude plugin validate secret-guard && claude plugin test secret-guard
claude plugin validate recall && claude plugin test recall
claude plugin validate mod-monitor && claude plugin test mod-monitor
(cd recall/engine && /usr/bin/python3 -m unittest)
Source 3 files
hooks/register.tsx 272 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Snapshot } from '../types'
5import {
6  ISSUE_LIMIT,
7  PR_LIMIT,
8  bandParts,
9  branchNames,
10  countWorktrees,
11  emptySnapshot,
12  goneBranches,
13  isBlankTree,
14  isGitHubRemote,
15  parseFocusLabels,
16  parseIssues,
17  parseLog,
18  parsePrs,
19  parseStatus,
20  pickDefaultBranch,
21  summary,
22} from './brief'
23import type { BandPart } from './brief'
24
25type Engine = EngineInterface
26
27/** The name the brief renders under in the first message's context. */
28const BLOCK = 'repoBrief'
29
30const snapshot = atom({ plugin: 'repo-brief', key: 'snapshot' } as const, null)
31const isHidden = atom({ plugin: 'repo-brief', key: 'isHidden' } as const, false)
32
33/** How long each git and gh command may take: a background gather, and one the first message waits on. */
34type Timing = { gitMs: number; ghMs: number }
35const FULL: Timing = { gitMs: 10_000, ghMs: 15_000 }
36const QUICK: Timing = { gitMs: 3_000, ghMs: 5_000 }
37/** The longest the first message waits for a gather still under way. */
38const BRIEF_WAIT_MS = 5_000
39
40const LEAD_START =
41  'Repo state when this session started (gathered by the repo-brief mod; run git yourself for anything newer):'
42const LEAD_LATER =
43  'Repo state at its last refresh (gathered by the repo-brief mod; run git yourself for anything newer):'
44const LEAD_NOW = 'Repo state just now (gathered by the repo-brief mod):'
45
46type Config = { focusLabels: string[]; refreshMs: number; briefClaude: boolean }
47
48let config: Config = { focusLabels: ['owner', 'todo', 'P0', 'blocked'], refreshMs: 120_000, briefClaude: true }
49let inflight: Promise<Snapshot | null> | null = null
50let lastGatherAt = 0
51let hasBriefed = false
52
53type GitHub = Pick<Snapshot, 'ghOk' | 'prs' | 'issueCount' | 'focusIssues'>
54
55const NO_GITHUB: GitHub = { ghOk: false, prs: [], issueCount: 0, focusIssues: [] }
56
57/** A command's stdout, or null when it could not start, timed out or exited non-zero. */
58async function run($: Engine, cwd: string, argv: readonly string[], timeoutMs: number): Promise<string | null> {
59  const out = await $.process.run(argv, { cwd, timeoutMs }).catch(() => null)
60  return out && out.exitCode === 0 ? out.stdout : null
61}
62
63async function gatherGitHub($: Engine, cwd: string, timing: Timing): Promise<GitHub> {
64  const [prJson, issueJson] = await Promise.all([
65    run(
66      $,
67      cwd,
68      ['gh', 'pr', 'list', '--state', 'open', '--limit', String(PR_LIMIT), '--json', 'number,title,headRefName,isDraft,statusCheckRollup'],
69      timing.ghMs,
70    ),
71    run(
72      $,
73      cwd,
74      ['gh', 'issue', 'list', '--state', 'open', '--limit', String(ISSUE_LIMIT), '--json', 'number,title,labels'],
75      timing.ghMs,
76    ),
77  ])
78  const prs = prJson === null ? null : parsePrs(prJson)
79  if (prs === null) {
80    return NO_GITHUB
81  }
82  const issues = issueJson === null ? null : parseIssues(issueJson, config.focusLabels)
83  return { ghOk: true, prs, issueCount: issues?.count ?? 0, focusIssues: issues?.focus ?? [] }
84}
85
86async function collect($: Engine, timing: Timing): Promise<Snapshot | null> {
87  const previous = await read($, snapshot)
88  const cwd = await $.session.cwd()
89  const now = await $.clock.now()
90  lastGatherAt = now
91  const git = (...args: string[]) => run($, cwd, ['git', ...args], timing.gitMs)
92
93  const status = await $.process
94    .run(['git', '--no-optional-locks', 'status', '--porcelain=v1', '--branch'], { cwd, timeoutMs: timing.gitMs })
95    .catch(() => null)
96  if (status === null && previous !== null) {
97    return previous
98  }
99  if (status === null || status.exitCode !== 0) {
100    const outside = emptySnapshot(now)
101    await update($, snapshot, () => outside)
102    return outside
103  }
104
105  const parsed = parseStatus(status.stdout)
106  const [log, branchVv, symbolic, worktreeList, remote] = await Promise.all([
107    git('log', '-8', '--format=%h%x09%cr%x09%an%x09%s'),
108    git('branch', '-vv'),
109    git('symbolic-ref', '--short', 'refs/remotes/origin/HEAD'),
110    git('worktree', 'list', '--porcelain'),
111    git('remote', 'get-url', 'origin'),
112  ])
113  const candidates = symbolic?.trim()
114    ? null
115    : await git(
116        'for-each-ref',
117        '--format=%(refname:short)',
118        'refs/remotes/origin/main',
119        'refs/remotes/origin/master',
120        'refs/heads/main',
121        'refs/heads/master',
122      )
123  const defaultBranch = pickDefaultBranch(symbolic, candidates)
124  const [merged, github] = await Promise.all([
125    defaultBranch ? git('branch', '--merged', defaultBranch) : Promise.resolve(null),
126    isGitHubRemote(remote) ? gatherGitHub($, cwd, timing) : Promise.resolve(NO_GITHUB),
127  ])
128
129  const defaultName = defaultBranch?.replace(/^origin\//, '') ?? ''
130  const gone = goneBranches(branchVv ?? '').filter(name => name !== parsed.branch)
131  const stale = [...new Set([...gone, ...branchNames(merged ?? '', [defaultName])])]
132  const fresh: Snapshot = {
133    ...emptySnapshot(now, true),
134    ...parsed,
135    commits: parseLog(log ?? ''),
136    defaultBranch,
137    staleBranches: stale,
138    worktrees: countWorktrees(worktreeList ?? ''),
139    ...github,
140  }
141  await update($, snapshot, () => fresh)
142  return fresh
143}
144
145/** Gathers the repo's state into `snapshot`; a gather already under way is shared, not repeated. */
146function gather($: Engine, timing: Timing): Promise<Snapshot | null> {
147  if (!inflight) {
148    inflight = collect($, timing)
149      .catch(() => null)
150      .finally(() => {
151        inflight = null
152      })
153  }
154  return inflight
155}
156
157/** What `work` resolves to, or null once `ms` has passed first. */
158async function within<T>($: Engine, work: Promise<T>, ms: number): Promise<T | null> {
159  const stop = new AbortController()
160  const timeout = $.clock.sleep(ms, { signal: stop.signal }).then(
161    () => null,
162    () => null,
163  )
164  try {
165    return await Promise.race([work, timeout])
166  } finally {
167    stop.abort()
168  }
169}
170
171/** Starts a background gather when the last one is older than `refreshMinutes`. */
172async function refreshIfStale($: Engine) {
173  const now = await $.clock.now()
174  if (!inflight && now - lastGatherAt >= config.refreshMs) {
175    void gather($, FULL)
176  }
177}
178
179export const register: Register = (on, options) => {
180  const minutes = Number(options.refreshMinutes ?? 2)
181  config = {
182    focusLabels: parseFocusLabels(String(options.focusLabels ?? 'owner,todo,P0,blocked')),
183    refreshMs: Math.max(0.25, Number.isFinite(minutes) ? minutes : 2) * 60_000,
184    briefClaude: options.briefClaude !== false,
185  }
186  inflight = null
187  lastGatherAt = 0
188  hasBriefed = false
189
190  on('session.start', async ($, e, next) => {
191    await $.command.register({
192      name: 'brief',
193      description: 'Re-gather the repo brief (branch, uncommitted work, commits, PRs, focus issues) and show it',
194    })
195    void gather($, FULL)
196    return next(e)
197  })
198
199  on('prompt.context', async ($, e, next) => {
200    const result = await next(e)
201    if (!config.briefClaude) {
202      return result
203    }
204    const known = await read($, snapshot)
205    const current = known ?? (await within($, gather($, QUICK), BRIEF_WAIT_MS))
206    if (!current?.isRepo) {
207      return result
208    }
209    const text = summary(current, hasBriefed ? LEAD_LATER : LEAD_START, config.focusLabels)
210    hasBriefed = true
211    return { ...result, blocks: [...result.blocks.filter(block => block.name !== BLOCK), { name: BLOCK, text }] }
212  })
213
214  on('turn.complete', async ($, e, next) => {
215    await refreshIfStale($)
216    return next(e)
217  })
218
219  // Started before the compaction runs, so the context re-read after it finds a fresh snapshot.
220  on('session.compact', async ($, e, next) => {
221    await refreshIfStale($)
222    return next(e)
223  })
224
225  on('command.run', { command: 'brief' }, async $ => {
226    if (inflight) {
227      await inflight
228    }
229    const current = await gather($, FULL)
230    await update($, isHidden, () => false)
231    if (current === null) {
232      return { text: 'repo-brief: git did not answer, so the repo could not be read.' }
233    }
234    if (!current.isRepo) {
235      return { text: `repo-brief: ${await $.session.cwd()} is not inside a git repository.` }
236    }
237    return { text: summary(current, LEAD_NOW, config.focusLabels) }
238  })
239
240  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
241    const current = await read($, snapshot)
242    const hidden = await read($, isHidden)
243    if (e.props.hasSurvey || hidden || !current?.isRepo) {
244      return next(e)
245    }
246    const { Box, Text, Button } = $.ui.resolve(e)
247    const span = (part: BandPart) =>
248      part.color ? <Text color={part.color}>{part.text}</Text> : part.dim ? <Text dimColor>{part.text}</Text> : part.text
249
250    const band = (
251      <Box flexDirection="row" gap={1}>
252        <Box flexShrink={1}>
253          <Text wrap="truncate-end">{bandParts(current, config.focusLabels).map(span)}</Text>
254        </Box>
255        <Box flexShrink={0}>
256          <Button key="hide" label="Hide" plain dimColor onPress={() => update($, isHidden, () => true)} />
257        </Box>
258      </Box>
259    )
260    // Another plugin's band beneath stays, under this one.
261    const below = await next(e)
262    return isBlankTree(below) ? (
263      band
264    ) : (
265      <Box flexDirection="column">
266        {band}
267        {below}
268      </Box>
269    )
270  })
271}
272
hooks/brief.ts 419 lines
1import type { RenderElement } from 'claude-code'
2import type { Changed, Ci, Commit, FocusIssue, PullRequest, Snapshot } from '../types'
3
4/** How many changed files a snapshot keeps by name. */
5export const MAX_PATHS = 15
6/** How many open PRs and issues `gh` is asked for. */
7export const PR_LIMIT = 10
8export const ISSUE_LIMIT = 30
9/** The longest brief Claude is handed, in characters. */
10export const MAX_BRIEF = 2500
11
12const clip = (text: string, max: number): string => (text.length <= max ? text : `${text.slice(0, max - 1)}…`)
13
14const plural = (count: number, one: string, many = `${one}s`): string => `${count} ${count === 1 ? one : many}`
15
16/** A snapshot with nothing in it: what a folder outside any git work tree gets. */
17export const emptySnapshot = (at: number, isRepo = false): Snapshot => ({
18  at,
19  isRepo,
20  branch: null,
21  upstream: null,
22  isUpstreamGone: false,
23  ahead: 0,
24  behind: 0,
25  changed: { staged: 0, unstaged: 0, untracked: 0, conflicted: 0, total: 0 },
26  changedPaths: [],
27  commits: [],
28  defaultBranch: null,
29  staleBranches: [],
30  worktrees: 0,
31  ghOk: false,
32  prs: [],
33  issueCount: 0,
34  focusIssues: [],
35})
36
37export const parseFocusLabels = (text: string): string[] =>
38  text
39    .split(',')
40    .map(label => label.trim())
41    .filter(Boolean)
42
43export type Status = {
44  branch: string | null
45  upstream: string | null
46  isUpstreamGone: boolean
47  ahead: number
48  behind: number
49  changed: Changed
50  changedPaths: string[]
51}
52
53const CONFLICTS = new Set(['DD', 'AU', 'UD', 'UA', 'DU', 'AA', 'UU'])
54
55/**
56 * `git status --porcelain=v1 --branch`: the `## branch...upstream [ahead 1, behind 2]`
57 * header, then one `XY path` line per changed file.
58 */
59export const parseStatus = (output: string): Status => {
60  const lines = output.split('\n').filter(line => line.length > 0)
61  const header = lines[0]?.startsWith('## ') ? (lines.shift() ?? '').slice(3) : ''
62  let branch: string | null = null
63  let upstream: string | null = null
64  let isUpstreamGone = false
65  let ahead = 0
66  let behind = 0
67  const fresh = header.match(/^(?:No commits yet on|Initial commit on) (\S+)/)
68  if (fresh) {
69    branch = fresh[1] ?? null
70  } else if (header && !header.startsWith('HEAD (no branch)')) {
71    const found = header.match(/^(\S+?)(?:\.\.\.(\S+))?(?: \[(.+)\])?$/)
72    branch = found?.[1] ?? null
73    upstream = found?.[2] ?? null
74    const tracking = found?.[3] ?? ''
75    isUpstreamGone = tracking === 'gone'
76    ahead = Number(tracking.match(/ahead (\d+)/)?.[1] ?? 0)
77    behind = Number(tracking.match(/behind (\d+)/)?.[1] ?? 0)
78  }
79
80  const changed: Changed = { staged: 0, unstaged: 0, untracked: 0, conflicted: 0, total: 0 }
81  const changedPaths: string[] = []
82  for (const line of lines) {
83    const code = line.slice(0, 2)
84    if (code === '!!') {
85      continue
86    }
87    changed.total += 1
88    if (code === '??') {
89      changed.untracked += 1
90    } else if (CONFLICTS.has(code)) {
91      changed.conflicted += 1
92    } else {
93      if (code[0] !== ' ') {
94        changed.staged += 1
95      }
96      if (code[1] !== ' ') {
97        changed.unstaged += 1
98      }
99    }
100    if (changedPaths.length < MAX_PATHS) {
101      changedPaths.push(line)
102    }
103  }
104  return { branch, upstream, isUpstreamGone, ahead, behind, changed, changedPaths }
105}
106
107/** `git log --format=%h%x09%cr%x09%an%x09%s`: one commit per line. */
108export const parseLog = (output: string): Commit[] =>
109  output
110    .split('\n')
111    .map(line => line.split('\t'))
112    .filter(fields => fields.length >= 4 && fields[0])
113    .map(([sha = '', when = '', author = '', ...subject]) => ({ sha, when, author, subject: subject.join('\t') }))
114
115/** Local branches `git branch -vv` shows tracking an upstream that no longer exists (`[origin/x: gone]`). */
116export const goneBranches = (output: string): string[] =>
117  output
118    .split('\n')
119    .map(line => line.slice(2).match(/^([^\s(]\S*)\s+[0-9a-f]{4,}\s+(?:\([^)]*\)\s+)?\[[^\]]*: gone\]/)?.[1])
120    .filter((name): name is string => Boolean(name))
121
122/**
123 * Branch names from `git branch` output, leaving out the checked-out one (`* `), any checked
124 * out in another worktree (`+ `, work in use), a detached HEAD and the names in `exclude`.
125 */
126export const branchNames = (output: string, exclude: readonly string[]): string[] =>
127  output
128    .split('\n')
129    .filter(line => line.startsWith('  '))
130    .map(line => line.trim())
131    .filter(name => name.length > 0 && !name.startsWith('(') && !exclude.includes(name))
132
133/**
134 * The default branch to check merges against: `git symbolic-ref --short refs/remotes/origin/HEAD`
135 * when it answers, else the first of origin/main, origin/master, main, master that exists.
136 */
137export const pickDefaultBranch = (symbolicRef: string | null, existingRefs: string | null): string | null => {
138  const named = symbolicRef?.trim()
139  if (named) {
140    return named
141  }
142  const refs = (existingRefs ?? '').split('\n').map(ref => ref.trim())
143  return ['origin/main', 'origin/master', 'main', 'master'].find(ref => refs.includes(ref)) ?? null
144}
145
146/** Linked worktrees besides the main one, from `git worktree list --porcelain`. */
147export const countWorktrees = (output: string): number =>
148  Math.max(0, output.split('\n').filter(line => line.startsWith('worktree ')).length - 1)
149
150export const isGitHubRemote = (url: string | null): boolean => /github\.com[:/]/i.test(url ?? '')
151
152type Check = { status?: string | null; conclusion?: string | null; state?: string | null }
153
154const FAILED = new Set(['FAILURE', 'TIMED_OUT', 'CANCELLED', 'ERROR', 'STARTUP_FAILURE'])
155const WAITING = new Set(['PENDING', 'EXPECTED', 'QUEUED', 'IN_PROGRESS', 'WAITING', 'REQUESTED'])
156
157/** A PR's `statusCheckRollup` (CheckRuns and StatusContexts) as one mark: ✗ any failed, … any pending, ✓ the rest. */
158export const ciOf = (rollup: readonly Check[] | null | undefined): Ci => {
159  const checks = rollup ?? []
160  if (checks.length === 0) {
161    return ''
162  }
163  const outcome = (check: Check): string => (check.conclusion || check.state || '').toUpperCase()
164  if (checks.some(check => FAILED.has(outcome(check)))) {
165    return '✗'
166  }
167  const isPending = (check: Check): boolean => {
168    const status = (check.status ?? '').toUpperCase()
169    return (status !== '' && status !== 'COMPLETED') || WAITING.has(outcome(check)) || outcome(check) === ''
170  }
171  return checks.some(isPending) ? '…' : '✓'
172}
173
174const parseJson = (text: string): unknown => {
175  try {
176    return JSON.parse(text) as unknown
177  } catch {
178    return null
179  }
180}
181
182type RawPr = { number?: number; title?: string; headRefName?: string; isDraft?: boolean; statusCheckRollup?: Check[] }
183
184/** `gh pr list --json number,title,headRefName,isDraft,statusCheckRollup`; null when it is not that. */
185export const parsePrs = (json: string): PullRequest[] | null => {
186  const list = parseJson(json)
187  if (!Array.isArray(list)) {
188    return null
189  }
190  return (list as RawPr[])
191    .filter(pr => typeof pr.number === 'number')
192    .map(pr => ({
193      number: pr.number ?? 0,
194      title: pr.title ?? '',
195      branch: pr.headRefName ?? '',
196      ci: ciOf(pr.statusCheckRollup),
197      isDraft: pr.isDraft === true,
198    }))
199}
200
201type RawIssue = { number?: number; title?: string; labels?: { name?: string }[] }
202
203/** `gh issue list --json number,title,labels`: how many, and those carrying a focus label. */
204export const parseIssues = (
205  json: string,
206  focusLabels: readonly string[],
207): { count: number; focus: FocusIssue[] } | null => {
208  const list = parseJson(json)
209  if (!Array.isArray(list)) {
210    return null
211  }
212  const wanted = new Set(focusLabels.map(label => label.toLowerCase()))
213  const issues = (list as RawIssue[])
214    .filter(issue => typeof issue.number === 'number')
215    .map(issue => ({
216      number: issue.number ?? 0,
217      title: issue.title ?? '',
218      labels: (issue.labels ?? []).map(label => label.name ?? '').filter(Boolean),
219    }))
220  return { count: issues.length, focus: issues.filter(issue => issue.labels.some(label => wanted.has(label.toLowerCase()))) }
221}
222
223const AGO_UNITS: Record<string, string> = {
224  second: 's',
225  minute: 'm',
226  hour: 'h',
227  day: 'd',
228  week: 'w',
229  month: 'mo',
230  year: 'y',
231}
232
233/** `%cr`'s "2 hours ago" as "2h ago"; "2 years, 3 months ago" keeps its first unit. */
234export const shortAgo = (when: string): string => {
235  const found = when.match(/^(\d+) (second|minute|hour|day|week|month|year)s?\b/)
236  return found ? `${found[1]}${AGO_UNITS[found[2] ?? ''] ?? ''} ago` : when
237}
238
239/** The issues' first matching focus label, in the configured order, for the band's count. */
240const focusWord = (issues: readonly FocusIssue[], focusLabels: readonly string[]): string => {
241  const firstLabel = (issue: FocusIssue) =>
242    focusLabels.find(label => issue.labels.some(own => own.toLowerCase() === label.toLowerCase())) ?? 'focus'
243  const words = new Set(issues.map(firstLabel))
244  return words.size === 1 ? ([...words][0] ?? 'focus') : 'focus'
245}
246
247const ciWord: Record<Ci, string> = { '✓': 'CI passing', '✗': 'CI failing', '…': 'CI running', '': 'no CI' }
248
249type Limits = { paths: number; commits: number; prs: number; issues: number; stale: number }
250
251/** Each step lists fewer of everything, until the brief fits MAX_BRIEF. */
252const LIMITS: readonly Limits[] = [
253  { paths: 15, commits: 8, prs: 10, issues: 10, stale: 12 },
254  { paths: 8, commits: 6, prs: 6, issues: 6, stale: 6 },
255  { paths: 4, commits: 4, prs: 3, issues: 3, stale: 3 },
256]
257
258const more = (shown: number, total: number): string[] => (total > shown ? [`  … and ${total - shown} more`] : [])
259
260const branchLine = (s: Snapshot): string => {
261  if (s.branch === null) {
262    return `Branch: none, HEAD is detached${s.commits[0] ? ` at ${s.commits[0].sha}` : ''}`
263  }
264  if (s.upstream === null) {
265    return `Branch: ${s.branch} (no upstream: not pushed or not tracking)`
266  }
267  if (s.isUpstreamGone) {
268    return `Branch: ${s.branch}, its upstream ${s.upstream} is gone (deleted on the remote)`
269  }
270  const drift = [s.ahead > 0 && `ahead ${s.ahead}`, s.behind > 0 && `behind ${s.behind}`].filter(Boolean)
271  return `Branch: ${s.branch} tracking ${s.upstream}, ${drift.length > 0 ? drift.join(', ') : 'up to date'}`
272}
273
274const changedLine = (changed: Changed): string => {
275  if (changed.total === 0) {
276    return 'Working tree: clean'
277  }
278  const kinds = [
279    changed.staged > 0 && `${changed.staged} staged`,
280    changed.unstaged > 0 && `${changed.unstaged} unstaged`,
281    changed.untracked > 0 && `${changed.untracked} untracked`,
282    changed.conflicted > 0 && `${changed.conflicted} conflicted`,
283  ].filter(Boolean)
284  return `Uncommitted: ${plural(changed.total, 'file')} (${kinds.join(', ')})`
285}
286
287const briefWith = (s: Snapshot, lead: string, focusLabels: readonly string[], limits: Limits): string => {
288  const lines = [lead, branchLine(s), changedLine(s.changed)]
289  const paths = s.changedPaths.slice(0, limits.paths)
290  lines.push(...paths.map(path => `  ${clip(path, 90)}`), ...more(paths.length, s.changed.total))
291
292  if (s.commits.length > 0) {
293    lines.push('Recent commits:')
294    lines.push(
295      ...s.commits
296        .slice(0, limits.commits)
297        .map(commit => `  ${commit.sha} ${commit.when}, ${clip(commit.author, 20)}: ${clip(commit.subject, 72)}`),
298    )
299  }
300
301  if (s.ghOk) {
302    if (s.prs.length === 0) {
303      lines.push('Open PRs: none')
304    } else {
305      lines.push(`Open PRs (${s.prs.length}${s.prs.length >= PR_LIMIT ? '+' : ''}):`)
306      const prs = s.prs.slice(0, limits.prs)
307      lines.push(
308        ...prs.map(
309          pr =>
310            `  #${pr.number} ${pr.ci ? `${pr.ci} ` : ''}${ciWord[pr.ci]}: ${clip(pr.title, 70)} (${clip(pr.branch, 40)}${pr.isDraft ? ', draft' : ''})`,
311        ),
312        ...more(prs.length, s.prs.length),
313      )
314    }
315    const count = `${s.issueCount}${s.issueCount >= ISSUE_LIMIT ? '+' : ''}`
316    const labels = focusLabels.join(', ')
317    if (s.focusIssues.length === 0) {
318      lines.push(`Open issues: ${count}${labels ? `, none labelled ${labels}` : ''}`)
319    } else {
320      lines.push(`Open issues: ${count}; labelled ${labels} (${s.focusIssues.length}):`)
321      const issues = s.focusIssues.slice(0, limits.issues)
322      lines.push(
323        ...issues.map(issue => `  #${issue.number} ${clip(issue.title, 70)} [${issue.labels.join(', ')}]`),
324        ...more(issues.length, s.focusIssues.length),
325      )
326    }
327  } else {
328    lines.push('PRs and issues: not gathered (gh unavailable, or the remote is not GitHub)')
329  }
330
331  if (s.staleBranches.length > 0) {
332    const shown = s.staleBranches.slice(0, limits.stale)
333    const rest = s.staleBranches.length - shown.length
334    lines.push(
335      `Stale local branches (${s.staleBranches.length}; upstream gone or merged into ${s.defaultBranch ?? 'the default branch'}): ` +
336        shown.map(name => clip(name, 40)).join(', ') +
337        (rest > 0 ? `, +${rest} more` : ''),
338    )
339  }
340  if (s.worktrees > 0) {
341    lines.push(`Worktrees: ${s.worktrees} besides the main one`)
342  }
343  return lines.join('\n')
344}
345
346/** The plain-text brief: branch, uncommitted work, commits, PRs, focus issues, stale branches, under MAX_BRIEF. */
347export const summary = (s: Snapshot, lead: string, focusLabels: readonly string[]): string => {
348  let text = ''
349  for (const limits of LIMITS) {
350    text = briefWith(s, lead, focusLabels, limits)
351    if (text.length <= MAX_BRIEF) {
352      return text
353    }
354  }
355  return `${text.slice(0, text.lastIndexOf('\n', MAX_BRIEF - 2))}\n…`
356}
357
358export type BandPart = { text: string; color?: 'red' | 'yellow' | 'green'; dim?: boolean }
359
360const CI_COLOR: Record<Ci, BandPart['color']> = { '✗': 'red', '…': 'yellow', '✓': 'green', '': undefined }
361
362/** The band's one line, in pieces so the CI marks can be colored. */
363export const bandParts = (s: Snapshot, focusLabels: readonly string[]): BandPart[] => {
364  const groups: BandPart[][] = []
365
366  let head = s.branch ?? `HEAD@${s.commits[0]?.sha ?? 'detached'}`
367  if (s.branch !== null && s.upstream === null) {
368    head += ' (no upstream)'
369  } else if (s.isUpstreamGone) {
370    head += ' (upstream gone)'
371  }
372  head += `${s.ahead > 0 ? ` ↑${s.ahead}` : ''}${s.behind > 0 ? ` ↓${s.behind}` : ''}`
373  groups.push([{ text: head }])
374
375  groups.push([{ text: s.changed.total > 0 ? `${s.changed.total} changed` : 'clean' }])
376  if (s.changed.conflicted > 0) {
377    groups.push([{ text: `${s.changed.conflicted} conflicted`, color: 'red' }])
378  }
379
380  if (s.ghOk && s.prs.length > 0) {
381    const shown = s.prs.slice(0, 4)
382    const prs: BandPart[] = [{ text: 'PRs' }]
383    for (const pr of shown) {
384      prs.push({ text: ` #${pr.number}` })
385      if (pr.ci) {
386        prs.push({ text: ` ${pr.ci}`, color: CI_COLOR[pr.ci] })
387      }
388    }
389    if (s.prs.length > shown.length) {
390      prs.push({ text: ` +${s.prs.length - shown.length}` })
391    }
392    groups.push(prs)
393  }
394  if (s.ghOk && s.focusIssues.length > 0) {
395    groups.push([{ text: plural(s.focusIssues.length, `${focusWord(s.focusIssues, focusLabels)} issue`) }])
396  }
397  if (s.staleBranches.length > 0) {
398    groups.push([{ text: plural(s.staleBranches.length, 'stale branch', 'stale branches') }])
399  }
400  if (s.worktrees > 0) {
401    groups.push([{ text: plural(s.worktrees, 'worktree') }])
402  }
403  if (s.commits[0]) {
404    groups.push([{ text: `last commit ${shortAgo(s.commits[0].when)}` }])
405  }
406
407  return groups.flatMap((group, index) => (index === 0 ? group : [{ text: ' · ', dim: true }, ...group]))
408}
409
410export const bandLine = (parts: readonly BandPart[]): string => parts.map(part => part.text).join('')
411
412/** True for what the engine draws when no plugin draws the band, or an empty Box. */
413export function isBlankTree(tree: RenderElement): boolean {
414  if (tree.type === 'engine') {
415    return true
416  }
417  return tree.type === 'Box' && (tree.children ?? []).length === 0
418}
419
types/index.d.ts 47 lines
1/** A PR's checks summed up: any failed ✗, any still running …, all passed ✓, none at all ''. */
2export type Ci = '✓' | '✗' | '…' | ''
3
4export type Commit = { sha: string; when: string; author: string; subject: string }
5
6export type PullRequest = { number: number; title: string; branch: string; ci: Ci; isDraft: boolean }
7
8export type FocusIssue = { number: number; title: string; labels: string[] }
9
10/** Files `git status` lists, by kind; a file both staged and edited again counts in both. */
11export type Changed = { staged: number; unstaged: number; untracked: number; conflicted: number; total: number }
12
13export type Snapshot = {
14  at: number
15  /** False when the session's folder is not inside a git work tree: nothing else is gathered. */
16  isRepo: boolean
17  /** The checked-out branch; null when HEAD is detached. */
18  branch: string | null
19  /** The branch's upstream (`origin/main`); null when it has none. */
20  upstream: string | null
21  /** True when the upstream is configured but its remote branch was deleted. */
22  isUpstreamGone: boolean
23  ahead: number
24  behind: number
25  changed: Changed
26  /** The first changed files as `git status --porcelain` shows them (`XY path`). */
27  changedPaths: string[]
28  commits: Commit[]
29  /** The default branch the merged check ran against (`origin/main`); null when none was found. */
30  defaultBranch: string | null
31  /** Local branches whose upstream is gone, then ones already merged into the default branch. */
32  staleBranches: string[]
33  /** Linked worktrees besides the main one. */
34  worktrees: number
35  /** True when `gh` answered for a GitHub remote; PRs and issues are empty otherwise. */
36  ghOk: boolean
37  prs: PullRequest[]
38  issueCount: number
39  focusIssues: FocusIssue[]
40}
41
42declare module 'claude-code' {
43  interface PluginState {
44    'repo-brief': { snapshot: Snapshot | null; isHidden: boolean }
45  }
46}
47