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…

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
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:package.json dev/start/serve/preview scripts (run with your lockfile's package manager), .claude/launch.json and Procfile~/.claude/dev-servers/<project>/.Port 4000 is held by node (pid 123, up 2h, in /Users/me/other).servers: :4000 :5173.lsof and ps every 15s.Puts files you just downloaded into your prompt with one click.
~/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].@"/Users/you/Downloads/paper.pdf" mentions in your prompt. Dismiss hides those files./downloads lists the 10 newest files, numbered. /downloads attach 1 3 (or 2-4) adds those, and /downloads clear dismisses everything new.Settings: folder (~/Downloads), extensions, pollSeconds (5), maxAgeMinutes (120).
Sets effort per message, so you don't have to switch it by hand.
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.effort: low (routine).Settings: routineEffort (low), deepEffort (max), routinePattern, deepPattern, avoidModel, fallbackModel (opus), stickyTurns (2), freeSwitchTokens (30000), cacheTtlMinutes (60).
A Jobs pane for long-running work: training runs, downloads, extractions.
nohup … > log & launches by itself./watch <log> [label] adds any other log file./ and /Volumes/* (the NAS).jobs: 2 running · 1 stalled./jobs opens the pane, /unwatch <label|done|all> removes jobs.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.
Memory, swap and GPU on the status line. It refuses heavy local jobs when the Mac can't take them.
RAM tight 12% free · swap 7.9/8G · top python 31G · GPU 87%./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.modal run, ssh), tests (pytest) and installs are never treated as heavy.overnight_|nightly_run\.sh.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.
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./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.~/.claude/mods/monitor/<date>/<session>.jsonl, flushed every minute and at session end, with secrets masked and old days removed after 30 days.$.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./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).
Keeps an eye on Modal so idle GPU containers don't burn credits.
Modal: 1 running (2 containers). Deployed apps with no containers cost nothing, so they stay off it.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.budgetToday where the Modal CLI supports billing report (1.3.3+, Team/Enterprise workspaces). Otherwise /modal says why spend isn't shown.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.Does the "merged #219, clean up branches and start #214" round trip for you, and surfaces CI failures with their logs.
gh pr create Claude runs. It polls gh pr view every 60s.PRs: #219 ✓ · #220 CI… · #221 ✗. Toasts when CI fails (with the failing check names) or passes.git fetch --prune, switch to the default branch (only from the PR's own branch) and git pull --ff-only.--force, never other branches.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.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.
Search everything you've done with coding agents, from Claude or from /recall. It replaces the broken agent-memory plugin.
/remember notes. Routine (scheduled) runs are left out unless you add routines:include to a query.grep and cat are kept but ranked low.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./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>.Last session here (2d ago): "…" · PR #99 · 3 open tasks [Recap].#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.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.~/.claude/recall/index.db, readable only by you, and never goes in a repo.~/.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./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./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).
Catches Claude up on the repo when a session starts, so you don't have to ask "check the recent commits/PRs and issues".
owner, todo, P0 or blockedmain ↑1 · 3 changed · PRs #123 ✗ #124 ✓ · 2 owner issues · 2 stale branches · last commit 2h ago. Hide dismisses it./brief re-gathers now and prints the full summary.git and gh. The band refreshes after a turn at most every 2 minutes.Settings: focus labels, refresh minutes, and whether to brief Claude.
Keeps scheduled routines (daily digests, newsletters) from silently stalling while you're away.
AskUserQuestion, you get a Mac notification and a toast, and the status line shows routine: daily-report · waiting on you 3m.Routine daily-report finished after 23m · waited on you 2 times.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.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./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.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.~/.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).
Keeps your "always / never / don't / from now on" instructions alive across compaction.
Keep as a standing order? [Project] [This session] [No]. Nothing is saved without a click.~/.claude/standing-orders/<repo>.json and apply to every session in that repo. Session orders and your active /goal last for the session./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).Stops keys and passwords from going into a prompt, and so into your transcripts, and turns them into env vars instead.
$NAME references, placeholders, plain URLs, paths, git SHAs and ordinary prose about passwords.…vxrm) with a suggested name such as OPENDATALAB_SECRET_ACCESS_KEY, which you can edit:export NAME='…' to ~/.zshrc (reusing an existing identical export) and replaces the secret in your prompt with $NAME./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.Settings: enabled (on), extraPatterns (a regex), zshrcPath (~/.zshrc).
Makes Claude's open commands hand 3D files to the right slicer.
full.?spectrum|snapmaker-only|-fs\.3mf$|-u1[-.], orAn 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.
/slice <file> [bambu|snapmaker|orca] opens a file yourself, with the same rules.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.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.
--plugin-dir once per mod, e.g. claude --plugin-dir ~/Projects/claude-mods/job-watch --plugin-dir ~/Projects/claude-mods/pr-autopilot~/.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" } } ``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)hooks/register.tsx 272 lines1import { 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}
272hooks/brief.ts 419 lines1import 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}
419types/index.d.ts 47 lines1/** 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