Pins the PR this session is babysitting above the prompt: review verdict, checks, merge state.

Hooks, tools, one skill, and a set of mods for Claude Code. The hooks and tools cover the four things that quietly cost the most: a memory store that rots, subagent fan-out that eats a usage window, bulk work sent to an expensive model, and an agent pushing to a repo it should not.
Zero dependencies, plain Node (18+), ESM. The mods run inside Claude Code itself.
On macOS, install the two prerequisites first. agentcall is the one bash script here and it needs bash 4+; macOS ships 3.2, where expanding an empty array under set -u is a fatal error. Two adapters parse JSON output with jq. Everything else is Node and works as-is.
brew install bash jq
git clone https://github.com/VibeCodyH/claude-craft
cd claude-craft
node install.mjs --dry-run # show the plan
node install.mjs # copy the tools, merge the hook wiring
The installer reads your existing ~/.claude/settings.json, adds only the hook entries that are missing, and writes one timestamped backup before its first change. Re-running it is a no-op. If the file is not valid JSON it stops and tells you, rather than replacing config it cannot read.
Install a subset by name, and remove the wiring the same way:
node install.mjs --list
node install.mjs memory delegation
node install.mjs --uninstall rotate
Restart Claude Code afterward to pick up the hooks.
| Component | Runs as | Does |
|---|---|---|
memory | hooks + CLI | Lint, recall, and staleness detection for the auto-memory store. |
delegation | hooks | Caps subagent fan-out; nudges mechanical sweeps toward a cheaper executor. |
agentcall | CLI | One calling convention for every agent CLI you have installed. |
crunch | CLI + library | Bulk LLM lane: strongest free cloud model first, your own box as the floor. |
protected-repos | hook | Blocks pushes and PRs on repos where a human runs every push. |
rotate | hooks | Swaps logins at a usage threshold, via swappy. |
skills | skill | delegate: when to hand work to an executor, and how. |
mods | /plugin install, see Mods | Bands and panes in Claude Code's own UI: blast radius, prod tripwire, PR tracker, usage limits. |
Claude Code's auto-memory is a directory of markdown files plus a MEMORY.md index. It's great — until it rots. Links dangle after renames, the index drifts from the files, near-duplicate memories pile up, and a memory that pins "how deploy.sh works" goes silently stale the day deploy.sh changes. And even a healthy store only helps if the agent actually recalls the right memory before acting on a fresh (worse) idea.
| Tool | Runs as | Does |
|---|---|---|
memory-lint.mjs | manual / CI | Reports dangling [[wikilinks]], index drift (broken + orphaned entries), and likely duplicates. |
memory-preflight.mjs | UserPromptSubmit hook | Injects strongly-relevant prior memories into context when the user's prompt is on-topic. |
memcheck.mjs | invoked by the agent | Scores a proposed approach against the store, so a recorded ★decision outranks a fresh idea. |
memory-staleness.mjs | SessionStart hook | Recomputes hashes for file-pinned memories and reports only drift. |
memory-lib.mjs | library | Shared parser + scorer the rest import. |
The preflight/memcheck pair matters: a prompt hook only sees the user's words, so it can't catch a mistake born from the agent's own plan. memcheck is the other half — tell your agent (in CLAUDE.md) to run it against its own approach before committing to one.
# lint the current project's store (or any dir)
node ~/.claude/tools/memory-lint.mjs
node ~/.claude/tools/memory-lint.mjs --json --dir path/to/memory
# self-check an approach before pitching it
node ~/.claude/tools/memcheck.mjs "rewrite the dark mode cards to use a new surface color"
# pin a memory to file contents, then let SessionStart flag drift
node ~/.claude/tools/memory-staleness.mjs --bind src/deploy.sh
# - /abs/path/src/deploy.sh@1a2b3c4d5e6f <- paste into the memory under `binds:`
Both hooks are fail-safe: on any error they print nothing and exit 0. They will never block a prompt or a session.
Memory-repo benchmarks (LoCoMo, LongMemEval) measure QA accuracy over long chat histories — the wrong question for a hook that injects context into a coding agent. What actually matters there is: does it surface the right memory when the prompt is on-topic, and stay silent when it isn't? So this repo ships a labeled eval instead: a synthetic 18-memory store and 36 developer-realistic queries (24 on-topic incl. hard paraphrases, 12 off-topic), run through the exact shipped gating code:
| Tier | Recall | False-fire |
|---|---|---|
preflight (hook — every prompt) | 14/24 (58%) | 0/12 (0%) |
memcheck (agent-invoked) | 20/24 (83%) | 1/12 (8%) |
The asymmetry is the design: the hook fires on every prompt, so it is tuned to never inject noise; memcheck is asked for deliberately, so it trades a little noise for recall. The misses are paraphrase-heavy queries with few shared tokens — the known ceiling of a lexical scorer, and the price of zero dependencies. Reproduce with npm run bench (--verbose lists every miss); CI floors in the test suite pin false-fire at 0 and recall above 50%/75%.
One memory per file, frontmatter with name / description / metadata.type (user | feedback | project | reference), [[wikilinks]] between memories, and a MEMORY.md index of - Title — hook lines. A MEMORY-cold-index.md is treated as a second, deliberately-not-resident index tier. feedback/project memories get a scoring boost — they carry decisions.
Hash-pinned binds — a memory that describes a specific file can pin it:
binds:
- /abs/path/to/file@1a2b3c4d5e6f # first 12 hex of sha256(contents)
Shared team store — point memcheck at a git repo of memories your team maintains (--team <dir> or CLAUDE_TEAM_MEMORY_DIR). Results are tagged [local] / [team] / [both]; mirrored entries collapse with the team copy winning. Two engineers' Claude instances sharing one curated store of platform facts works better than it has any right to.
Domain acronyms — the scorer drops tokens under 4 chars except a keep-list (llm ssh api db …). Extend it for your domain: CLAUDE_MEM_KEEP_SHORT="k8s rds sqs".
Two failures cost real money, and both are reflexes rather than decisions. The first: a task looks parallel, so the agent spawns five subagents, each starting cold and re-deriving context the parent already had. The second: the agent hand-edits forty files itself, one at a time, while an executor CLI sits idle on its own quota.
delegation-guard.mjs (PreToolUse on Agent and Workflow) puts two hard caps on the first, in a rolling window:
The premium cap is the one that earns its keep. A spawn with no explicit model on the default agent type inherits the parent's model, which is normally the most expensive one you run, so an unqualified spawn counts as premium. Named specialist types with no model are exempt, because they carry their own default. A refused spawn is not recorded, so being stopped never extends the window.
delegation-nudge.mjs (PostToolUse on edits) handles the second. When the main loop touches 3 distinct files in 5 minutes, it prints a ready-to-run executor command for the rest of the sweep. It never blocks. It stays silent inside any repo listed in protected-repos.json, on the theory that a repo you must push by hand is a repo whose edits you want to have read yourself.
Every threshold is an environment variable. Defaults in parentheses:
CLAUDE_DELEGATION_CAP (4) CLAUDE_NUDGE_FILES (3)
CLAUDE_DELEGATION_PREMIUM_CAP (2) CLAUDE_NUDGE_WINDOW_MIN (5)
CLAUDE_DELEGATION_WINDOW_MIN (10) CLAUDE_NUDGE_COOLDOWN_MIN (8)
CLAUDE_PREMIUM_MODELS (opus,fable) CLAUDE_NUDGE_SKIP (colon-separated paths)
CLAUDE_WORKFLOW_POLICY (ask) CLAUDE_NUDGE_CMD (codex)
CLAUDE_EXECUTORS (named in the refusal message)
CLAUDE_WORKFLOW_POLICY=ask is the default because a workflow's own agent() calls fan out subagents, and a stock research workflow can spawn dozens of them before anyone notices. Set it to deny or allow once you know which workflows you run.
Every agent CLI has a different flag for "yes, you may use your tools" and a different way of emitting its final answer. Getting one of those wrong does not produce an error. It produces a confident review of a diff the model never opened. bin/agentcall normalizes them:
agentcall codex -d ~/src/app -m ro < review-prompt.md # same shape for every agent
agentcall --probe # which CLIs are installed and authenticated
agentcall --list # what each is good for, and its known traps
agentcall --print-command grok -d . "hi" # inspect the invocation, run nothing
agentcall --new mytool # scaffold an adapter for a new CLI
It ships adapters for codex, claude, grok, kimi, vibe, glm, agy, opencode, copilot, and coderabbit. Each one encodes a trap found by hitting it:
-o FILE. One 4-file PR produced 152KB.-p is --single and consumes the next argument, so the prompt has to go last. Without --always-approve its tool calls are cancelled silently, and the partial narration reads exactly like a short bad review.--auto, with the same failure mode.type:"complete" record.--print-command exists because every one of those bugs was invisible until something downstream looked wrong. A new CLI is a drop-in file at ~/.config/agentcall/agents.d/<name>.sh, not a patch to the script. A drop-in whose name matches a builtin overrides it, which is how you fix a recipe that upstream changed.
The delegate skill in skills/ is the policy layer over all of this: what to hand off, what to keep, how to write the spec, and how to review what comes back.
A lot of what gets pushed through a coding agent is not reasoning. It is summarize this transcript, classify these 400 rows, extract fields from this dump. That work wants a cheap model, and several vendors give away a good one. crunch walks them in order and falls through to a local OpenAI-compatible server (Ollama, LM Studio, llama.cpp, vLLM) when every cloud tier is missing, capped, or down.
crunch "write a one-line commit message for: added retry backoff"
git log --oneline -50 | crunch --task summarize
crunch --task classify --json "<text>"
crunch --local "<sensitive text>" # your box only, nothing leaves the machine
crunch --models cerebras # list the model ids a provider really serves
Chain: openrouter → cerebras → groq → zai → mistral → cloudflare → local. A provider with no key is skipped, so one key is enough to start. A provider that returns 429 is skipped for the rest of the run rather than costing a round trip on every remaining call of a bulk job. Every attempt logs to stderr, so a chain that is quietly landing on the local floor looks different from one that is working. Copy crunch.env.example to ~/.config/claude-craft/crunch.env and fill in what you have.
Free model slugs rotate. A slug that stops being free returns 404, which cascades to the next provider instead of failing loudly, so a chain can quietly get slower without breaking. Run crunch --models openrouter | grep ':free' and update CRUNCH_OPENROUTER_MODEL when that happens. GitHub Models was in this chain until 2026-08-07 and is not any more: its endpoint now answers 410 github_models_retirement_brownout. It was the best entry while it lasted, because it reused your gh auth login token and needed no key.
On the redactor. Input is scrubbed for credential shapes before any cloud request: API keys, tokens, JWTs, private key blocks, connection strings, and NAME=value pairs whose name says credential. It is not a PII scrubber and will not catch names, emails, addresses, or account numbers. There is a test asserting that, so the limit stays honest. For real PII use --local, which skips both the cloud chain and the redactor, because scrubbing would gut the data you kept local in order to work on.
Point CRUNCH_SCRUB_MODULE at your own ESM module exporting scrub(text) -> { text, redactions } to substitute a better one. If it fails to load or throws, crunch falls through to the local floor rather than sending text the intended guard never inspected.
Importable as a library from the checkout: import { crunch } from "<checkout>/tools/crunch.mjs". The installer puts the CLI on your PATH and deliberately does not keep a second copy under ~/.claude/tools, because two copies of the same file drift apart.
tools/protected-repo-guard.mjs enforces "the agent may work on this clone locally but NEVER pushes it" — useful when Claude works on an employer's or teammate's repo where a human must run every push and open every PR. A prose rule in CLAUDE.md can be argued with; a PreToolUse deny can't. It catches git push (by cwd, cd chains, -C, slug, or URL), gh pr create|merge, gh repo sync, and gh api writes — while leaving local commits, branches, and the issue/comment lane open.
Configure ~/.claude/protected-repos.json:
[{ "slug": "some-org/their-repo", "dir": "/abs/path/to/local/clone" }]
Fail-open by design: no config or a broken config protects nothing rather than bricking every Bash call. The guard is belt-and-suspenders over your prose rule, not a replacement for it.
tools/swappy-auto-rotate.mjs closes the loop on multi-account usage limits: when either subscription window (5-hour or 7-day) crosses a threshold (default 95%), a Stop hook rotates you to your next saved login automatically and sends a desktop notification. The swap itself is done by swappy — PiXeL16's excellent minimal Claude Code login rotator. Install and set that up first (swappy save <name> per account); this hook is just the trigger finger.
Three things make the design work:
rate_limits percentages every turn. Persist them from your statusline command script: // statusline receives the payload as JSON on stdin — add:
if (payload.rate_limits) {
fs.writeFileSync(path.join(os.homedir(), ".claude", "usage-snapshot.json"),
JSON.stringify({ ts: Date.now(), rate_limits: payload.rate_limits }));
}
If you rotate logins, also stamp which account the reading came from — the payload has no account field, so read oauthAccount.accountUuid out of ~/.claude.json and add it as acct. A session that has not made an API call since a swap keeps re-rendering the account it was on, and that snapshot is fresh by ts while being wrong about whose usage it is. With acct present the hook skips those runs instead of filing them under the wrong profile. It stays optional: no stamp means the old trust-the-snapshot behavior.
swappy next can move you from an account with hours of 5-hour headroom onto one that is 100% capped and kill the turn. (Observed: work at 7d 96% but 5h 9%, rotated onto a 5h-exhausted personal.) The hook keeps a per-profile ledger of the last usage seen for each login — it is running every turn anyway — and swaps with swappy use <name> to the account it knows is healthy.The rule that falls out of that is worth stating on its own: an unmeasured account is only worth gambling on when you are already stuck. If your current 5-hour window is still live, the hook only swaps to a profile the ledger knows is healthy; being 7-day-hot is not a reason to trade a working session for a coin flip. When nothing beats where you are it stays put, and says so only if you are genuinely capped — a correct "everything still works" is not worth a notification every cooldown period.
The installer wires it as both a Stop hook and an async PostToolUse hook. Why both: a Stop hook only fires between turns, and one marathon turn can ride from 90% to a hard limit without ever reaching Stop (field-tested: 98% before a human noticed). The statusline snapshot updates during turns, so the PostToolUse wiring catches the crossing after the next tool call; "async": true keeps it off the critical path, and the cooldown stamp already dedups the two wirings.
Guard rails: windows whose resets_at already passed are ignored (the limit reset while you were idle), malformed or >6h-old snapshots are ignored, and when every account is over the threshold a cooldown stamp (default 15 min) stops exhausted accounts from ping-ponging — the stamp is claimed before swapping so concurrent sessions can't double-rotate, and a failed swap backs off only 2 minutes instead of consuming the full cooldown. All subprocess calls are timeboxed so the hook can never stall a turn.
The ledger has its own rails, because a wrong entry there sends you to a dead account: a reading is only filed against the active profile if the snapshot is under 5 minutes old, recording pauses for 2 minutes after a swap (Claude Code has not re-read credentials yet, so the statusline is still reporting the account you just left), entries older than 8 days stop counting as evidence, and a recorded cap whose resets_at has since passed stops counting against that profile without needing to have been active to observe it. If swappy ls cannot be read at all the hook refuses to swap rather than guess. Tune via SWAPPY_GUARD_THRESHOLD, SWAPPY_GUARD_COOLDOWN_MIN, SWAPPY_GUARD_LEDGER, SWAPPY_BIN; test decisions safely with SWAPPY_GUARD_DRYRUN=1.
Mods hook into Claude Code's own interface: a band above the prompt, a pane beside the approval dialog, a tool the model can call. They need Claude Code 2.1.286 or later (mods are early access). Install each one by name:
/plugin install usage-band --marketplace VibeCodyH/claude-craft
Answer y to add the marketplace, then pick a scope. The options below are userConfig fields in each mod's plugin.json.
| Mod | Does | Options |
|---|---|---|
blast-radius | For rm -r, git reset --hard, git clean, force pushes, branch -D, discards and db:migrate:prod, runs the tools' own dry runs and shows what would be touched beside the approval prompt. It never approves or blocks. | wrappers: extra command prefixes to see through, like a wrapper script. |
prod-tripwire | A red PROD band while a command touches production, then a dim "last prod touch" line until your next prompt. Built-in patterns: db:migrate:prod, neonctl, Vercel --prod, gh pr merge. | patterns: your own label=regex entries, separated by ;;. |
pr-tracker | Pins the PR the session is babysitting: review verdict, checks, merge state. A PR joins when the session runs gh pr create, or gh pr edit --add-reviewer, or a watch-review.sh owner/repo N watcher line. Needs gh. | |
usage-band | One row above the prompt: Codex weekly limit (from ~/.codex/sessions), Claude 5-hour and 7-day limits, context left, and a clock. Shows % left with reset countdowns. | nextFile: a JSON file with next_date and next_text to show your next event. Empty hides it. |
needs-you | Gives Claude ask and resolve tools. Each question or command it needs from you sits in an amber pill above the prompt, with a copy button, until it's answered. | |
clipboard-step | When Claude puts a command on your clipboard, shows it above the prompt until you paste it, clear it, or send your next prompt. Wayland only (wl-copy / wl-paste). | |
yellow-titles | Draws bold spans in replies in yellow so titles stand out. Display only. |
For keeping secrets, emails and IPs out of the transcript, use Ray Amjad's secret-redactor. I run it alongside these.
If you would rather not run the installer, copy what you want and wire it yourself:
cp claude-craft/tools/*.mjs ~/.claude/tools/
cp -r claude-craft/skills/delegate ~/.claude/skills/
cp claude-craft/bin/agentcall claude-craft/tools/crunch.mjs ~/.local/bin/
{
"hooks": {
"UserPromptSubmit": [
{ "hooks": [{ "type": "command", "command": "node /abs/path/.claude/tools/memory-preflight.mjs" }] }
],
"SessionStart": [
{ "hooks": [{ "type": "command", "command": "node /abs/path/.claude/tools/memory-staleness.mjs" }] }
],
"PreToolUse": [
{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "node /abs/path/.claude/tools/protected-repo-guard.mjs" }] },
{ "matcher": "Agent|Workflow", "hooks": [{ "type": "command", "command": "node /abs/path/.claude/tools/delegation-guard.mjs" }] }
],
"PostToolUse": [
{ "matcher": "Edit|Write|MultiEdit", "hooks": [{ "type": "command", "command": "node /abs/path/.claude/tools/delegation-nudge.mjs" }] },
{ "matcher": "*", "hooks": [{ "type": "command", "command": "node /abs/path/.claude/tools/swappy-auto-rotate.mjs", "timeout": 15, "async": true }] }
],
"Stop": [
{ "matcher": "", "hooks": [{ "type": "command", "command": "node /abs/path/.claude/tools/swappy-auto-rotate.mjs", "timeout": 15 }] }
]
}
}
Use absolute paths. ~ in a hook command is not expanded on every platform, and a hook that fails to run fails silently.
npm test
npm run bench
Each mod has its own suite:
claude plugin validate mods/usage-band
claude plugin test mods/usage-band
MIT
hooks/register.ts 171 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement, Timer } from 'claude-code'
3
4import type { Pr } from '../types'
5import { FIELDS, JQ, detect, isRefresh, keyOf, summarize } from './pr'
6import type { Raw, Target } from './pr'
7
8// PR tracker: the PR this session is babysitting, pinned above the prompt with
9// its review verdict, checks and merge state. A PR joins when babysit starts
10// its watcher, when this session opens it, or when a review is re-requested;
11// it leaves 10 minutes after it merges or closes. Display only: it reads
12// `gh pr view` and never changes a command.
13
14const prs = atom({ plugin: 'pr-tracker', key: 'prs' } as const, [] as Pr[])
15
16const POLL_MS = 60_000
17const SETTLE_MS = 20_000 // a push's checks take a moment to register
18const HOLD_MS = 10 * 60_000
19const MAX_ROWS = 4
20
21const TONE = { good: 'green', bad: 'red', wait: 'yellow', dim: 'gray', done: 'magenta' } as const
22const DOT = { good: '●', bad: '✗', wait: '◐', dim: '○', done: '●' } as const
23
24// Module variables start over on a hot reload; the list in $.state does not,
25// so session.start (which fires again on reload) restarts the poll.
26let timer: Timer | null = null
27let busy = false
28
29function ensureTimer($: EngineInterface) {
30 timer ??= $.clock.every(POLL_MS, () => void pollAll($))
31}
32
33async function pollAll($: EngineInterface) {
34 if (busy) return
35 busy = true
36 try {
37 const list = await read($, prs)
38 if (list.length === 0) return
39 const fresh = await Promise.all(list.filter(p => p.endedAt === null).map(p => fetchOne($, p)))
40 const now = await $.clock.now()
41 await update($, prs, cur =>
42 cur
43 .map(p => fresh.find(f => keyOf(f) === keyOf(p)) ?? p)
44 .filter(p => p.endedAt === null || now - p.endedAt < HOLD_MS),
45 )
46 } finally {
47 busy = false
48 }
49}
50
51async function track($: EngineInterface, targets: Target[]) {
52 const have = new Set((await read($, prs)).map(keyOf))
53 const added = targets.filter(t => !have.has(keyOf(t))).map(blank)
54 if (added.length === 0) return
55 await update($, prs, cur => [...cur, ...added.filter(a => !cur.some(p => keyOf(p) === keyOf(a)))])
56 ensureTimer($)
57 const fresh = await Promise.all(added.map(p => fetchOne($, p)))
58 await update($, prs, cur => cur.map(p => fresh.find(f => keyOf(f) === keyOf(p)) ?? p))
59}
60
61export const register: Register = on => {
62 on('session.start', async ($, e, next) => {
63 if ((await read($, prs)).length > 0) {
64 ensureTimer($)
65 void pollAll($)
66 }
67 return next(e)
68 })
69
70 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
71 const command = String(e.command ?? '')
72 const maybeTrack = /watch-review\.sh|\bgh\s+pr\s+(create|edit)\b/.test(command)
73 const refresh = isRefresh(command)
74 if (!maybeTrack && !refresh) return next(e)
75
76 const ran = await next(e)
77 try {
78 if (ran.deny === undefined) {
79 const stdout = String((ran.result as { stdout?: unknown } | undefined)?.stdout ?? '')
80 if (maybeTrack) await track($, detect(command, stdout))
81 if (refresh && (await read($, prs)).length > 0) {
82 void pollAll($)
83 $.clock.after(SETTLE_MS, () => void pollAll($))
84 }
85 }
86 } catch (err) {
87 $.ui.log(`pr-tracker: ${err instanceof Error ? err.message : String(err)}`, { to: 'debug' })
88 }
89 return ran
90 }).catch(($, e, next) => next(e)) // display only: never stands between a command and its run
91
92 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
93 if (e.props.hasSurvey) return next(e)
94 const list = await read($, prs)
95 if (list.length === 0) return next(e)
96
97 const { Box, Text, Link } = $.ui.resolve(e)
98 const width = e.props.bodyColumns ?? 100
99 const rows = list.slice(-MAX_ROWS).map(p => {
100 const parts: unknown[] = [
101 Text({ dimColor: true, children: '⎇ ' }),
102 Link({ href: `https://github.com/${p.repo}/pull/${p.number}`, children: [`${short(p.repo)}#${p.number}`] }),
103 ]
104 if (p.error) {
105 parts.push(Text({ color: 'red', wrap: 'truncate-end', children: ` ⚠ ${p.error}` }))
106 } else if (!p.verdict) {
107 parts.push(Text({ dimColor: true, children: ' …' }))
108 } else {
109 parts.push(Text({ color: TONE[p.verdict.tone], children: ` ${DOT[p.verdict.tone]} ${p.verdict.label}` }))
110 const c = p.checks
111 if (c && p.state === 'OPEN') {
112 parts.push(Text({ dimColor: true, children: ' checks' }))
113 if (c.pass) parts.push(Text({ color: 'green', children: ` ${c.pass}✓` }))
114 if (c.fail) parts.push(Text({ color: 'red', children: ` ${c.fail}✗ ${c.failing.slice(0, 2).join(', ')}` }))
115 if (c.pending) parts.push(Text({ color: 'yellow', children: ` ${c.pending}◌` }))
116 }
117 if (p.merge === 'behind') parts.push(Text({ color: 'yellow', children: ' behind' }))
118 if (p.merge === 'conflicts') parts.push(Text({ color: 'red', bold: true, children: ' conflicts' }))
119 }
120 parts.push(Text({ dimColor: true, wrap: 'truncate-end', children: ` ${clip(p.title, Math.max(10, width - 70))}` }))
121 return Box({ flexDirection: 'row', paddingX: 1, children: parts as never })
122 })
123
124 const mine = Box({ key: 'pr-tracker', flexDirection: 'column', children: rows as never })
125 const below = await next(e)
126 return (below ? Box({ flexDirection: 'column', children: [mine, below] } as never) : mine) as RenderElement
127 })
128}
129
130async function fetchOne($: EngineInterface, p: Pr): Promise<Pr> {
131 try {
132 const r = await $.process.run(
133 ['gh', 'pr', 'view', String(p.number), '-R', p.repo, '--json', FIELDS, '--jq', JQ],
134 { timeoutMs: 20_000 },
135 )
136 if (r.exitCode !== 0) return { ...p, error: firstLine(r.stderr) || `gh exited ${r.exitCode}` }
137 const s = summarize(JSON.parse(r.stdout) as Raw)
138 const ended = s.state !== 'OPEN'
139 return { ...p, ...s, error: null, endedAt: ended ? (p.endedAt ?? (await $.clock.now())) : null }
140 } catch (err) {
141 return { ...p, error: firstLine(err instanceof Error ? err.message : String(err)) }
142 }
143}
144
145function blank(t: Target): Pr {
146 return {
147 repo: t.repo,
148 number: t.number,
149 title: '',
150 state: null,
151 verdict: null,
152 checks: null,
153 merge: null,
154 head: '',
155 error: null,
156 endedAt: null,
157 }
158}
159
160function short(repo: string): string {
161 return repo.split('/')[1] ?? repo
162}
163
164function firstLine(s: string): string {
165 return s.trim().split('\n')[0]?.slice(0, 120) ?? ''
166}
167
168function clip(s: string, n: number): string {
169 return s.length > n ? `${s.slice(0, Math.max(1, n - 1))}…` : s
170}
171hooks/pr.ts 127 lines1import type { Checks, Pr, Tone } from '../types'
2
3// Everything with no engine in it: which Bash lines mean "this session is
4// babysitting PR N", and how one `gh pr view` answer reads as a band row.
5
6export type Target = { repo: string; number: number }
7
8// What `gh pr view --json FIELDS --jq JQ` prints: review bodies dropped, the
9// last verdict-bearing review kept (COMMENTED and PENDING carry no verdict).
10export const FIELDS =
11 'title,state,isDraft,headRefOid,reviewDecision,mergeStateStatus,reviewRequests,statusCheckRollup,reviews'
12export const JQ =
13 '{title, state, isDraft, head: .headRefOid, merge: .mergeStateStatus,' +
14 ' requested: [.reviewRequests[] | .login // .name],' +
15 ' checks: [.statusCheckRollup[] | {name: (.name // .context), conclusion: (.conclusion // .state // "")}],' +
16 ' last: ([.reviews[] | select(.state != "COMMENTED" and .state != "PENDING")] | last' +
17 ' | if . then {by: .author.login, state, commit: .commit.oid} else null end)}'
18
19export type Raw = {
20 title: string
21 state: 'OPEN' | 'MERGED' | 'CLOSED'
22 isDraft: boolean
23 head: string
24 merge: string
25 requested: string[]
26 checks: Array<{ name: string; conclusion: string }>
27 last: { by: string; state: string; commit: string } | null
28}
29
30const PR_URL = /https:\/\/github\.com\/([\w.-]+\/[\w.-]+)\/pull\/(\d+)/
31const REPO_FLAG = /(?:^|\s)(?:-R|--repo)[=\s]+["']?([\w.-]+\/[\w.-]+)/
32
33// The three lines that mean babysitting: babysit's own watcher, a PR this
34// session just opened (gh prints its URL), and a review (re-)request.
35export function detect(command: string, stdout: string): Target[] {
36 const out: Target[] = []
37
38 for (const m of command.matchAll(/watch-review\.sh\s+["']?([\w.-]+\/[\w.-]+)["']?\s+(\d+)/g)) {
39 out.push({ repo: m[1]!, number: Number(m[2]) })
40 }
41
42 if (/\bgh\s+pr\s+create\b/.test(command)) {
43 const m = PR_URL.exec(stdout)
44 if (m) out.push({ repo: m[1]!, number: Number(m[2]) })
45 }
46
47 for (const m of command.matchAll(/\bgh\s+pr\s+edit\b([^;&|]*)/g)) {
48 const seg = m[1]!
49 if (!/--add-reviewer\b/.test(seg)) continue
50 const url = PR_URL.exec(seg)
51 if (url) {
52 out.push({ repo: url[1]!, number: Number(url[2]) })
53 continue
54 }
55 const num = seg.split(/\s+/).map(t => t.replace(/["']/g, '')).find(t => /^#?\d+$/.test(t))
56 const repo = REPO_FLAG.exec(seg)?.[1]
57 if (num && repo) out.push({ repo, number: Number(num.replace('#', '')) })
58 }
59
60 const seen = new Set<string>()
61 return out.filter(t => !seen.has(keyOf(t)) && seen.add(keyOf(t)))
62}
63
64// Lines after which a tracked PR has likely moved: look again.
65export function isRefresh(command: string): boolean {
66 return (
67 /\bgit\b[^;&|]*\bpush\b/.test(command) ||
68 /\bgh\s+pr\s+(edit|merge|comment|ready|update-branch|close|reopen)\b/.test(command) ||
69 /requested_reviewers/.test(command)
70 )
71}
72
73export function keyOf(t: Target): string {
74 return `${t.repo.toLowerCase()}#${t.number}`
75}
76
77const PASS = new Set(['SUCCESS', 'NEUTRAL', 'SKIPPED'])
78const FAIL = new Set(['FAILURE', 'ERROR', 'TIMED_OUT', 'CANCELLED', 'ACTION_REQUIRED', 'STARTUP_FAILURE'])
79
80export function summarize(raw: Raw): Pick<Pr, 'title' | 'state' | 'verdict' | 'checks' | 'merge' | 'head'> {
81 const checks: Checks = { pass: 0, fail: 0, pending: 0, failing: [] }
82 let reviewing = false
83 for (const c of raw.checks ?? []) {
84 const done = String(c.conclusion ?? '').toUpperCase()
85 if (PASS.has(done)) checks.pass += 1
86 else if (FAIL.has(done)) {
87 checks.fail += 1
88 checks.failing.push(c.name)
89 } else {
90 checks.pending += 1
91 // \b: `cadre-review` is the bot; "Vercel Preview Comments" is not.
92 if (/\breview/i.test(c.name)) reviewing = true
93 }
94 }
95 return {
96 title: raw.title,
97 state: raw.state,
98 head: raw.head.slice(0, 8),
99 checks,
100 merge: raw.merge === 'BEHIND' ? 'behind' : raw.merge === 'DIRTY' ? 'conflicts' : null,
101 verdict: verdictOf(raw, reviewing),
102 }
103}
104
105// A verdict counts only on the commit it reviewed: a push dismisses an
106// approval, so a review of an older head reads as "new commits".
107function verdictOf(raw: Raw, reviewing: boolean): { label: string; tone: Tone } {
108 if (raw.state === 'MERGED') return { label: 'merged', tone: 'done' }
109 if (raw.state === 'CLOSED') return { label: 'closed', tone: 'dim' }
110 if (raw.isDraft) return { label: 'draft', tone: 'dim' }
111 if (reviewing) return { label: 'bot reviewing', tone: 'wait' }
112
113 const last = raw.last
114 const requested = raw.requested ?? []
115 if (last && last.commit === raw.head) {
116 if (last.state === 'APPROVED') return { label: `approved by ${last.by}`, tone: 'good' }
117 if (last.state === 'CHANGES_REQUESTED') return { label: `changes requested by ${last.by}`, tone: 'bad' }
118 }
119 if (last && last.commit !== raw.head) {
120 return requested.includes(last.by)
121 ? { label: `awaiting ${last.by}`, tone: 'wait' }
122 : { label: `new commits, re-request ${last.by}`, tone: 'wait' }
123 }
124 if (requested.length) return { label: `awaiting ${requested.join(', ')}`, tone: 'wait' }
125 return { label: 'no review yet', tone: 'dim' }
126}
127types/index.d.ts 23 lines1export type Tone = 'good' | 'bad' | 'wait' | 'dim' | 'done'
2
3export type Checks = { pass: number; fail: number; pending: number; failing: string[] }
4
5export type Pr = {
6 repo: string
7 number: number
8 title: string
9 state: 'OPEN' | 'MERGED' | 'CLOSED' | null
10 verdict: { label: string; tone: Tone } | null
11 checks: Checks | null
12 merge: 'behind' | 'conflicts' | null
13 head: string
14 error: string | null
15 endedAt: number | null
16}
17
18declare module 'claude-code' {
19 interface PluginState {
20 'pr-tracker': { prs: Pr[] }
21 }
22}
23