SLOPSHOPPER

pr-tracker

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

newbandguardprocesstimer
v0.1.2MITupdated 2026-10-11VibeCodyH/claude-craft/mods/pr-tracker
A shopper browsing a rack in a slop shop
README

claude-craft

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.

Quick start

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.

What's in it

ComponentRuns asDoes
memoryhooks + CLILint, recall, and staleness detection for the auto-memory store.
delegationhooksCaps subagent fan-out; nudges mechanical sweeps toward a cheaper executor.
agentcallCLIOne calling convention for every agent CLI you have installed.
crunchCLI + libraryBulk LLM lane: strongest free cloud model first, your own box as the floor.
protected-reposhookBlocks pushes and PRs on repos where a human runs every push.
rotatehooksSwaps logins at a usage threshold, via swappy.
skillsskilldelegate: when to hand work to an executor, and how.
mods/plugin install, see ModsBands and panes in Claude Code's own UI: blast radius, prod tripwire, PR tracker, usage limits.

Memory

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.

ToolRuns asDoes
memory-lint.mjsmanual / CIReports dangling [[wikilinks]], index drift (broken + orphaned entries), and likely duplicates.
memory-preflight.mjsUserPromptSubmit hookInjects strongly-relevant prior memories into context when the user's prompt is on-topic.
memcheck.mjsinvoked by the agentScores a proposed approach against the store, so a recorded ★decision outranks a fresh idea.
memory-staleness.mjsSessionStart hookRecomputes hashes for file-pinned memories and reports only drift.
memory-lib.mjslibraryShared 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.

Measured signal/noise

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:

TierRecallFalse-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%.

Conventions the tools understand

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".

Delegation

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:

  • Overall: at most 4 subagents per 10 minutes.
  • Premium: at most 2 expensive-model subagents in the same 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.

agentcall

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:

  • codex streams its entire reasoning trace without -o FILE. One 4-file PR produced 152KB.
  • grok's -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.
  • opencode denies tool calls without --auto, with the same failure mode.
  • coderabbit exits 0 whether it found problems or never ran. Read the type:"complete" record.
  • agy auto-denies its own tools, so it can never fetch its own input, and refuses any security-framed prompt. It is a decent text cruncher on stdin and not a reviewer.

--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.

crunch

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.

Protected-repo push guard

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.

Usage-limit auto-rotate

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:

  1. No polling, no API calls. Claude Code already hands your statusline the official 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.

  1. Mid-session swaps take effect. Claude Code picks up credentials changed on disk on the session's next action (changelog 2.1.186 fixed the stale-cache case), so the hook swaps the moment a turn crosses the line — rescuing the session that is running hot, and migrating any other running sessions on their next action too.
  1. It picks a destination instead of rotating blindly. The snapshot only describes the account you are on, so 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

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.

ModDoesOptions
blast-radiusFor 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-tripwireA 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-trackerPins 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-bandOne 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-youGives 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-stepWhen 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-titlesDraws 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.

Manual install

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.

Tests

npm test
npm run bench

Each mod has its own suite:

claude plugin validate mods/usage-band
claude plugin test mods/usage-band

License

MIT

Source 3 files
hooks/register.ts 171 lines
1import { 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}
171
hooks/pr.ts 127 lines
1import 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}
127
types/index.d.ts 23 lines
1export 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