Watches your open pull requests: CI pass/fail toasts, failing-check logs attached when you mention them, and the post-merge ritual (fetch --prune, switch…

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.ts 496 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, ProcessRunInit, Register } from 'claude-code'
3
4import type { WatchedPr } from '../types'
5import {
6 ciAttachment,
7 clip,
8 defaultBranchOf,
9 describePrs,
10 failingRunIds,
11 gitError,
12 mentionedPrs,
13 nameWithOwner,
14 parsePrUrls,
15 parseView,
16 repoFromRemote,
17 shortSha,
18 statusLine,
19 watchedFrom,
20 worktreeHolding,
21} from './pr'
22import type { PrRef, PrView } from './pr'
23
24type Engine = EngineInterface
25
26const prs = atom({ plugin: 'pr-autopilot', key: 'prs' } as const, [])
27
28const VIEW_FIELDS = 'number,url,state,title,headRefName,headRefOid,statusCheckRollup,mergedAt'
29const LIST_FIELDS = 'number,url,state,title,headRefName,headRefOid,statusCheckRollup'
30const CREATE = /\bgh\s+pr\s+create\b/
31/** Prompts the person wrote (typed, through Remote Control, or an SDK host's own turn). */
32const PERSONAL = new Set(['composer', 'bridge', 'sdk'])
33/** How many PRs are kept: merged ones beyond this are dropped first. */
34const KEEP = 30
35
36type Ran = { ok: boolean; out: string; err: string }
37type Outcome = { steps: string[]; issues: string[] }
38type Config = { pollMs: number; deleteRemoteBranch: boolean; attachCiLogs: boolean; logLines: number }
39
40let config: Config = { pollMs: 60_000, deleteRemoteBranch: false, attachCiLogs: true, logLines: 120 }
41let timer: { cancel: () => void } | null = null
42let polling: Promise<void> | null = null
43let isTurnRunning = false
44/** The status line last shown; null before the first. */
45let shownStatus: string | undefined | null = null
46
47const isSame = (a: Pick<WatchedPr, 'repo' | 'number'>, b: Pick<WatchedPr, 'repo' | 'number'>): boolean =>
48 a.number === b.number && a.repo.toLowerCase() === b.repo.toLowerCase()
49
50/** Keeps every open PR and the newest closed or merged ones, KEEP in all. */
51const capped = (list: readonly WatchedPr[]): WatchedPr[] => {
52 const out = [...list]
53 while (out.length > KEEP) {
54 const oldDone = out.findIndex(pr => pr.state !== 'open')
55 out.splice(oldDone >= 0 ? oldDone : 0, 1)
56 }
57 return out
58}
59
60async function exec($: Engine, argv: readonly string[], cwd?: string, timeoutMs = 30_000): Promise<Ran> {
61 const init: ProcessRunInit = {
62 timeoutMs,
63 env: { GIT_TERMINAL_PROMPT: '0', GH_PROMPT_DISABLED: '1' },
64 ...(cwd ? { cwd } : {}),
65 }
66 try {
67 const out = await $.process.run(argv, init)
68 return { ok: out.exitCode === 0, out: out.stdout, err: out.stderr }
69 } catch (error) {
70 return { ok: false, out: '', err: String(error) }
71 }
72}
73
74async function git($: Engine, cwd: string, args: readonly string[], timeoutMs = 30_000): Promise<Ran> {
75 return exec($, ['git', ...args], cwd, timeoutMs)
76}
77
78async function ghJson($: Engine, args: readonly string[], cwd?: string): Promise<unknown> {
79 const ran = await exec($, ['gh', ...args], cwd)
80 if (!ran.ok) {
81 return null
82 }
83 try {
84 return JSON.parse(ran.out) as unknown
85 } catch {
86 return null
87 }
88}
89
90async function viewPr($: Engine, ref: string, repo: string | null): Promise<PrView | null> {
91 return parseView(await ghJson($, ['pr', 'view', ref, ...(repo ? ['--repo', repo] : []), '--json', VIEW_FIELDS]))
92}
93
94async function debug($: Engine, line: string) {
95 await $.ui.log(`pr-autopilot: ${line}`, { to: 'debug' })
96}
97
98async function showStatus($: Engine) {
99 const line = statusLine(await read($, prs))
100 if (line !== shownStatus) {
101 shownStatus = line
102 $.ui.status(line)
103 }
104}
105
106function ensureTimer($: Engine) {
107 if (!timer) {
108 timer = $.clock.every(config.pollMs, () => void poll($))
109 }
110}
111
112async function patch($: Engine, pr: WatchedPr, changes: Partial<WatchedPr>) {
113 await update($, prs, list => list.map(one => (isSame(one, pr) ? { ...one, ...changes } : one)))
114}
115
116/** Watches a PR, or refreshes what is known of one already watched (its CI state stays the poll's to change). */
117async function remember($: Engine, ref: PrRef, view: PrView | null) {
118 const now = await $.clock.now()
119 const fresh = watchedFrom(ref, view, now)
120 await update($, prs, list => {
121 const known = list.find(one => isSame(one, ref))
122 if (!known) {
123 return capped([...list, fresh])
124 }
125 const facts = view ? { title: fresh.title, branch: fresh.branch, headSha: fresh.headSha, url: fresh.url } : {}
126 return list.map(one => (one === known ? { ...one, ...facts } : one))
127 })
128 ensureTimer($)
129}
130
131/** Adopts the person's open PRs in the session's repository, when it is a GitHub repository. */
132async function adopt($: Engine, cwd: string) {
133 try {
134 const inside = await git($, cwd, ['rev-parse', '--is-inside-work-tree'])
135 if (!inside.ok) {
136 return
137 }
138 const repo = nameWithOwner(await ghJson($, ['repo', 'view', '--json', 'nameWithOwner'], cwd))
139 if (!repo) {
140 return
141 }
142 const listed = await ghJson(
143 $,
144 ['pr', 'list', '--author', '@me', '--state', 'open', '--json', LIST_FIELDS, '--limit', '20'],
145 cwd,
146 )
147 for (const item of Array.isArray(listed) ? listed : []) {
148 const view = parseView(item)
149 if (view?.number) {
150 await remember($, { repo, number: view.number, url: view.url }, view)
151 }
152 }
153 } finally {
154 await showStatus($)
155 }
156}
157
158async function defaultBranch($: Engine, cwd: string, repo: string): Promise<string | null> {
159 const ref = await git($, cwd, ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'])
160 const name = ref.out.trim().replace(/^origin\//, '')
161 if (ref.ok && name) {
162 return name
163 }
164 return defaultBranchOf(await ghJson($, ['repo', 'view', repo, '--json', 'defaultBranchRef'], cwd))
165}
166
167/** Deletes the merged PR's local branch, only when it points at the PR's head and no worktree has it checked out. */
168async function deleteLocal($: Engine, cwd: string, pr: WatchedPr, base: string | null, out: Outcome) {
169 const branch = pr.branch
170 if (!branch || branch === base) {
171 return
172 }
173 const local = await git($, cwd, ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`])
174 if (!local.ok) {
175 out.steps.push(`no local branch ${branch}`)
176 return
177 }
178 const sha = local.out.trim()
179 if (!pr.headSha || sha !== pr.headSha) {
180 const why = pr.headSha
181 ? `local ${shortSha(sha)} is not the merged head ${shortSha(pr.headSha)}`
182 : 'the merged head is unknown'
183 out.steps.push(`branch ${branch} kept: ${why}`)
184 out.issues.push(`branch ${branch} kept: ${why}`)
185 return
186 }
187 const trees = await git($, cwd, ['worktree', 'list', '--porcelain'])
188 const holder = trees.ok ? worktreeHolding(trees.out, branch) : null
189 if (holder !== null) {
190 const why = `checked out in ${holder || 'a worktree'}`
191 out.steps.push(`branch ${branch} kept: ${why}`)
192 out.issues.push(`branch ${branch} is ${why}`)
193 return
194 }
195 const deleted = await git($, cwd, ['branch', '-D', branch])
196 if (deleted.ok) {
197 out.steps.push(`branch ${branch} deleted`)
198 } else {
199 out.steps.push(`branch ${branch} not deleted: ${gitError(deleted.err)}`)
200 out.issues.push(`branch ${branch} not deleted`)
201 }
202}
203
204/** Deletes the PR's branch on origin, when asked to and it still points at the merged head. */
205async function deleteRemote($: Engine, cwd: string, pr: WatchedPr, base: string | null, out: Outcome) {
206 const branch = pr.branch
207 if (!config.deleteRemoteBranch || !branch || branch === base || !pr.headSha) {
208 return
209 }
210 const listed = await git($, cwd, ['ls-remote', '--heads', 'origin', branch])
211 const line = listed.out.split('\n').find(one => one.trim().endsWith(`\trefs/heads/${branch}`))
212 if (!listed.ok || !line) {
213 return
214 }
215 if (line.split('\t')[0]?.trim() !== pr.headSha) {
216 out.steps.push(`origin/${branch} kept: it moved since the merge`)
217 out.issues.push(`origin/${branch} moved since the merge`)
218 return
219 }
220 const pushed = await git($, cwd, ['push', 'origin', '--delete', branch], 60_000)
221 if (pushed.ok) {
222 out.steps.push(`origin/${branch} deleted`)
223 } else {
224 out.steps.push(`origin/${branch} not deleted: ${gitError(pushed.err)}`)
225 out.issues.push(`origin/${branch} not deleted`)
226 }
227}
228
229/**
230 * The post-merge ritual in the session's folder, plain local git and safe: fetch --prune; switch
231 * to the default branch (only from the PR's branch) and pull it --ff-only, never with uncommitted
232 * changes; delete the PR's branch only when nothing on it would be lost; never --force.
233 */
234async function cleanup($: Engine, pr: WatchedPr): Promise<Outcome> {
235 const out: Outcome = { steps: [], issues: [] }
236 const cwd = await $.session.cwd()
237 const origin = await git($, cwd, ['remote', 'get-url', 'origin'])
238 const originRepo = origin.ok ? repoFromRemote(origin.out) : null
239 if (!originRepo || originRepo.toLowerCase() !== pr.repo.toLowerCase()) {
240 const where = originRepo ? `this folder is a clone of ${originRepo}` : `this folder is not a clone of ${pr.repo}`
241 out.steps.push(`cleanup skipped: ${where}`)
242 out.issues.push(`cleanup skipped: ${where}`)
243 return out
244 }
245
246 const fetched = await git($, cwd, ['fetch', '--prune', 'origin'], 120_000)
247 if (!fetched.ok) {
248 out.steps.push(`fetch failed: ${gitError(fetched.err)}`)
249 out.issues.push('fetch failed')
250 }
251 const base = await defaultBranch($, cwd, pr.repo)
252 const current = (await git($, cwd, ['branch', '--show-current'])).out.trim()
253 const status = await git($, cwd, ['status', '--porcelain', '--untracked-files=no'])
254 const isDirty = !status.ok || status.out.trim() !== ''
255
256 if (!base) {
257 out.steps.push('default branch unknown: did not switch or pull')
258 out.issues.push('the default branch is unknown')
259 } else if (current !== pr.branch && current !== base) {
260 out.steps.push(current ? `on ${current}: left as is` : 'detached HEAD: left as is')
261 } else if (isDirty) {
262 out.steps.push(`uncommitted changes: stayed on ${current}, did not switch or pull`)
263 out.issues.push(`uncommitted changes on ${current}`)
264 } else {
265 let head = current
266 if (current === pr.branch) {
267 const switched = await git($, cwd, ['switch', base])
268 if (switched.ok) {
269 head = base
270 } else {
271 out.steps.push(`could not switch to ${base}: ${gitError(switched.err)}`)
272 out.issues.push(`could not switch to ${base}`)
273 }
274 }
275 if (head === base) {
276 const pulled = await git($, cwd, ['pull', '--ff-only', 'origin', base], 120_000)
277 if (pulled.ok) {
278 out.steps.push(`${base} pulled`)
279 } else {
280 out.steps.push(`${base} not pulled: ${gitError(pulled.err)}`)
281 out.issues.push(`${base} not pulled`)
282 }
283 }
284 }
285
286 await deleteLocal($, cwd, pr, base, out)
287 await deleteRemote($, cwd, pr, base, out)
288 return out
289}
290
291async function finishMerged($: Engine, pr: WatchedPr) {
292 await patch($, pr, { cleaned: true })
293 const out = await cleanup($, pr)
294 const steps = out.steps.length > 0 ? out.steps.join(' · ') : 'nothing to clean up'
295 $.ui.toast(`#${pr.number} merged → ${steps}`, { timeoutMs: 12_000 })
296 const text =
297 out.issues.length === 0
298 ? `#${pr.number} is merged and cleaned up. Carry on with the next task.`
299 : `#${pr.number} is merged (${out.issues[0]}). Carry on with the next task.`
300 await $.prompt.suggest({ text }).catch(() => undefined)
301}
302
303/** Reads one open PR's state and checks, and says when CI flips or the PR is merged or closed. */
304async function refresh($: Engine, pr: WatchedPr) {
305 const view = await viewPr($, String(pr.number), pr.repo)
306 if (!view) {
307 return
308 }
309 const facts = { title: view.title || pr.title, branch: view.branch || pr.branch, headSha: view.headSha || pr.headSha }
310 if (view.state === 'MERGED') {
311 await patch($, pr, { ...facts, state: 'merged', cleaned: false })
312 return
313 }
314 if (view.state === 'CLOSED') {
315 await update($, prs, list => list.filter(one => !isSame(one, pr)))
316 $.ui.toast(`#${pr.number} was closed without merging; no longer watched`)
317 return
318 }
319 const { ci, failing, runIds } = view.rollup
320 await patch($, pr, { ...facts, ci, failing, runIds })
321 if (ci !== pr.ci && ci === 'fail') {
322 $.ui.toast(`CI failed on #${pr.number}: ${clip(failing.join(', '), 100)}`, { timeoutMs: 10_000 })
323 } else if (ci !== pr.ci && ci === 'pass') {
324 $.ui.toast(`CI passed on #${pr.number}`)
325 }
326}
327
328async function pollOnce($: Engine) {
329 for (const pr of await read($, prs)) {
330 if (pr.state === 'open') {
331 await refresh($, pr).catch((error: unknown) => debug($, `could not read #${pr.number}: ${String(error)}`))
332 }
333 }
334 // A merge seen mid-turn is cleaned up once the turn ends, so git never races the model's own commands.
335 if (!isTurnRunning) {
336 for (const pr of await read($, prs)) {
337 if (pr.state === 'merged' && !pr.cleaned) {
338 await finishMerged($, pr)
339 }
340 }
341 }
342 await showStatus($)
343}
344
345/** One poll at a time: a poll asked for while one runs waits for that one. */
346function poll($: Engine): Promise<void> {
347 if (!polling) {
348 polling = pollOnce($)
349 .catch((error: unknown) => debug($, `poll failed: ${String(error)}`))
350 .finally(() => {
351 polling = null
352 })
353 }
354 return polling
355}
356
357/** The context block for one PR the prompt says is failing: its checks and its failed log's tail. */
358async function ciContext($: Engine, number: number, pr: WatchedPr | null): Promise<string | null> {
359 const repo = pr?.repo ?? null
360 const repoArgs = repo ? ['--repo', repo] : []
361 // `gh pr checks` exits 1 when a check failed and 8 while one is pending: its table is on stdout either way.
362 const checks = await exec($, ['gh', 'pr', 'checks', String(number), ...repoArgs])
363 const runId = failingRunIds(checks.out)[0] ?? pr?.runIds[0] ?? null
364 const log = runId === null ? null : await exec($, ['gh', 'run', 'view', String(runId), ...repoArgs, '--log-failed'], undefined, 60_000)
365 return ciAttachment({
366 number,
367 repo,
368 checks: checks.out,
369 runId,
370 log: log?.ok ? log.out : '',
371 logLines: config.logLines,
372 })
373}
374
375const USAGE = 'Usage: /prs (list and refresh) · /prs watch <n|url> [owner/repo] · /prs forget <n|all>'
376
377export const register: Register = (on, options) => {
378 config = {
379 pollMs: Math.max(10, Number(options.pollSeconds ?? 60)) * 1000,
380 deleteRemoteBranch: options.deleteRemoteBranch === true,
381 attachCiLogs: options.attachCiLogs !== false,
382 logLines: Math.max(10, Math.min(2000, Number(options.logLines ?? 120))),
383 }
384 timer = null
385 polling = null
386 isTurnRunning = false
387 shownStatus = null
388
389 on('session.start', async ($, e, next) => {
390 await $.command.register({
391 name: 'prs',
392 description: 'Watched pull requests: CI state, and the cleanup after a merge',
393 argumentHint: '[watch <n> [owner/repo] | forget <n|all>]',
394 immediate: true,
395 })
396 ensureTimer($)
397 void adopt($, e.cwd).catch((error: unknown) => debug($, `could not adopt open PRs: ${String(error)}`))
398 return next(e)
399 })
400
401 on('turn.start', async ($, e, next) => {
402 isTurnRunning = true
403 return next(e)
404 })
405
406 on('turn.complete', async ($, e, next) => {
407 const done = await next(e)
408 if (e.agentId === undefined) {
409 isTurnRunning = false
410 const list = await read($, prs)
411 if (list.some(pr => pr.state === 'merged' && !pr.cleaned)) {
412 void poll($)
413 }
414 }
415 return done
416 })
417
418 // Watches the PR a `gh pr create` opened (or names as already existing).
419 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
420 const ran = await next(e)
421 if (ran.deny !== undefined || !CREATE.test(e.command)) {
422 return ran
423 }
424 try {
425 for (const ref of parsePrUrls(ran.text)) {
426 await remember($, ref, await viewPr($, String(ref.number), ref.repo))
427 }
428 await showStatus($)
429 } catch (error) {
430 await debug($, `could not watch the new PR: ${String(error)}`)
431 }
432 return ran
433 })
434
435 // Hands the model the failing checks and the failed log's tail when the person mentions a failing PR.
436 on('prompt.submit', async ($, e, next) => {
437 if (!config.attachCiLogs || !PERSONAL.has(e.origin.kind)) {
438 return next(e)
439 }
440 const blocks: string[] = []
441 try {
442 const list = await read($, prs)
443 for (const number of mentionedPrs(e.text, list)) {
444 const matches = list.filter(pr => pr.number === number)
445 const pr = matches.find(one => one.state === 'open') ?? matches[0] ?? null
446 const block = await ciContext($, number, pr)
447 if (block) {
448 blocks.push(block)
449 }
450 }
451 } catch (error) {
452 await debug($, `could not attach CI logs: ${String(error)}`)
453 }
454 return blocks.length > 0 ? next({ ...e, context: [...(e.context ?? []), ...blocks] }) : next(e)
455 })
456
457 on('command.run', { command: 'prs' }, async ($, e) => {
458 const [verb = '', target = '', repoArg = ''] = e.args.trim().split(/\s+/)
459 if (verb === 'watch') {
460 const fromUrl = parsePrUrls(target)[0]
461 const number = fromUrl?.number ?? Number(target.replace(/^#/, ''))
462 if (!Number.isInteger(number) || number <= 0) {
463 return { text: USAGE }
464 }
465 const repo = fromUrl?.repo ?? (repoArg || null)
466 const view = await viewPr($, String(number), repo)
467 const resolved = repo ?? (view ? parsePrUrls(view.url)[0]?.repo : undefined)
468 if (!view || !resolved) {
469 return { text: `pr-autopilot: gh found no PR #${number} in ${repo ?? "this folder's repository"}.` }
470 }
471 await remember($, { repo: resolved, number, url: view.url }, view)
472 await poll($)
473 const watched = (await read($, prs)).find(pr => isSame(pr, { repo: resolved, number }))
474 return { text: watched ? `Watching ${describePrs([watched])}` : `Watching #${number}.` }
475 }
476 if (verb === 'forget') {
477 if (!target) {
478 return { text: USAGE }
479 }
480 const before = await read($, prs)
481 const number = Number(target.replace(/^#/, ''))
482 const keep = target === 'all' ? [] : before.filter(pr => pr.number !== number)
483 await update($, prs, list => list.filter(pr => keep.some(one => isSame(one, pr))))
484 await showStatus($)
485 const removed = before.length - keep.length
486 return { text: removed > 0 ? `Stopped watching ${removed} PR(s).` : `#${target} is not watched.` }
487 }
488 if (verb !== '') {
489 return { text: USAGE }
490 }
491 ensureTimer($)
492 await poll($)
493 return { text: describePrs(await read($, prs)) }
494 })
495}
496hooks/pr.ts 335 lines1import type { Ci, PrState, WatchedPr } from '../types'
2
3const ANSI = /\u001b\[[0-9;?]*[A-Za-z]/g
4
5const text = (value: unknown): string => (typeof value === 'string' ? value : '')
6
7export const clip = (value: string, max: number): string =>
8 value.length <= max ? value : `${value.slice(0, max - 1)}…`
9
10export const shortSha = (sha: string): string => sha.slice(0, 7)
11
12export type PrRef = { repo: string; number: number; url: string }
13
14const PR_URL = /https:\/\/github\.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)\/pull\/(\d+)/g
15
16/** Every GitHub pull request URL in the text (what `gh pr create` prints), each once, in order. */
17export const parsePrUrls = (output: string | undefined): PrRef[] => {
18 const found: PrRef[] = []
19 for (const match of (output ?? '').matchAll(PR_URL)) {
20 const repo = `${match[1]}/${match[2]}`
21 const number = Number(match[3])
22 if (!found.some(ref => ref.repo === repo && ref.number === number)) {
23 found.push({ repo, number, url: `https://github.com/${repo}/pull/${number}` })
24 }
25 }
26 return found
27}
28
29/** `owner/name` from a GitHub remote URL (https, ssh or scp-like); null for another host. */
30export const repoFromRemote = (url: string): string | null => {
31 const found = url.trim().match(/github\.com[:/]+([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?\/?$/)
32 return found ? `${found[1]}/${found[2]}` : null
33}
34
35/** `gh repo view --json nameWithOwner`: `{ "nameWithOwner": "owner/name" }`. */
36export const nameWithOwner = (json: unknown): string | null => {
37 const value = json && typeof json === 'object' ? text((json as Record<string, unknown>).nameWithOwner) : ''
38 return /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(value) ? value : null
39}
40
41/** `gh repo view --json defaultBranchRef`: `{ "defaultBranchRef": { "name": "main" } }`. */
42export const defaultBranchOf = (json: unknown): string | null => {
43 const ref = json && typeof json === 'object' ? (json as Record<string, unknown>).defaultBranchRef : null
44 const name = ref && typeof ref === 'object' ? text((ref as Record<string, unknown>).name) : ''
45 return name || null
46}
47
48export type Check = { name: string; state: string; url: string }
49
50const FAILED = new Set(['FAILURE', 'TIMED_OUT', 'CANCELLED', 'ACTION_REQUIRED', 'ERROR', 'STARTUP_FAILURE'])
51const PASSED = new Set(['SUCCESS', 'NEUTRAL', 'SKIPPED', 'STALE'])
52
53/**
54 * One `statusCheckRollup` entry as a check: a CheckRun `{ name, status, conclusion, detailsUrl }`
55 * (its status while it runs, its conclusion once completed) or a StatusContext `{ context, state, targetUrl }`.
56 */
57const checkOf = (entry: unknown): Check | null => {
58 if (!entry || typeof entry !== 'object') {
59 return null
60 }
61 const e = entry as Record<string, unknown>
62 if (e.__typename === 'StatusContext' || (e.context !== undefined && e.name === undefined)) {
63 return { name: text(e.context), state: text(e.state).toUpperCase(), url: text(e.targetUrl) }
64 }
65 const status = text(e.status).toUpperCase()
66 const state = status && status !== 'COMPLETED' ? status : text(e.conclusion).toUpperCase()
67 return { name: text(e.name), state, url: text(e.detailsUrl) }
68}
69
70/** The GitHub Actions run id in a check's details URL (`…/actions/runs/123/job/456`). */
71export const runIdOf = (url: string): number | null => {
72 const found = url.match(/\/actions\/runs\/(\d+)/)
73 return found ? Number(found[1]) : null
74}
75
76export type Rollup = { ci: Ci; failing: string[]; runIds: number[] }
77
78/**
79 * A PR's checks as a whole: any failed check fails it, then any check not yet passed keeps it
80 * pending (queued, running, waiting, or a state this does not know); all passed or skipped
81 * passes it; no checks at all is `none`.
82 */
83export const summarizeRollup = (rollup: unknown): Rollup => {
84 const checks = (Array.isArray(rollup) ? rollup : [])
85 .map(checkOf)
86 .filter((check): check is Check => check !== null)
87 if (checks.length === 0) {
88 return { ci: 'none', failing: [], runIds: [] }
89 }
90 const failed = checks.filter(check => FAILED.has(check.state))
91 const failing = [...new Set(failed.map(check => check.name || 'unnamed check'))]
92 const runIds = [...new Set(failed.map(check => runIdOf(check.url)).filter((id): id is number => id !== null))]
93 const ci: Ci = failed.length > 0 ? 'fail' : checks.some(check => !PASSED.has(check.state)) ? 'pending' : 'pass'
94 return { ci, failing, runIds }
95}
96
97export type PrView = {
98 number: number | null
99 url: string
100 /** `OPEN`, `MERGED` or `CLOSED`. */
101 state: string
102 title: string
103 branch: string
104 headSha: string
105 rollup: Rollup
106}
107
108/** One PR as `gh pr view --json …` (or one entry of `gh pr list --json …`) prints it; null when it is not one. */
109export const parseView = (json: unknown): PrView | null => {
110 if (!json || typeof json !== 'object' || Array.isArray(json)) {
111 return null
112 }
113 const v = json as Record<string, unknown>
114 const state = text(v.state).toUpperCase()
115 if (!state) {
116 return null
117 }
118 return {
119 number: typeof v.number === 'number' ? v.number : null,
120 url: text(v.url),
121 state,
122 title: text(v.title),
123 branch: text(v.headRefName),
124 headSha: text(v.headRefOid),
125 rollup: summarizeRollup(v.statusCheckRollup),
126 }
127}
128
129const stateOf = (view: PrView): PrState =>
130 view.state === 'MERGED' ? 'merged' : view.state === 'CLOSED' ? 'closed' : 'open'
131
132/** A newly watched PR, from what gh said of it (or only its URL when gh said nothing). */
133export const watchedFrom = (ref: PrRef, view: PrView | null, now: number): WatchedPr => ({
134 number: ref.number,
135 repo: ref.repo,
136 url: view?.url || ref.url,
137 title: view?.title ?? '',
138 branch: view?.branch ?? '',
139 headSha: view?.headSha ?? '',
140 ci: view ? view.rollup.ci : 'pending',
141 failing: view?.rollup.failing ?? [],
142 runIds: view?.rollup.runIds ?? [],
143 state: view ? stateOf(view) : 'open',
144 cleaned: false,
145 addedAt: now,
146})
147
148const MARK: Record<Ci, string> = { pass: '✓', fail: '✗', pending: 'CI…', none: '' }
149
150/** `PRs: #219 ✓ · #220 CI… · #221 ✗` over the open PRs; undefined when none is open. */
151export const statusLine = (prs: readonly WatchedPr[]): string | undefined => {
152 const open = prs.filter(pr => pr.state === 'open')
153 return open.length > 0 ? `PRs: ${open.map(pr => `#${pr.number} ${MARK[pr.ci]}`.trim()).join(' · ')}` : undefined
154}
155
156/** What `/prs` prints: one line per watched PR. */
157export const describePrs = (prs: readonly WatchedPr[]): string => {
158 if (prs.length === 0) {
159 return 'No PRs are watched. A PR opened with `gh pr create` is picked up by itself; /prs watch <n> adds one.'
160 }
161 return prs
162 .map(pr => {
163 const state =
164 pr.state === 'merged' ? (pr.cleaned ? 'merged, cleaned up' : 'merged, cleanup pending') : pr.state
165 const ci =
166 pr.state !== 'open'
167 ? ''
168 : pr.ci === 'fail'
169 ? `CI failing: ${pr.failing.join(', ') || 'unknown check'}`
170 : pr.ci === 'pass'
171 ? 'CI passed'
172 : pr.ci === 'pending'
173 ? 'CI running'
174 : 'no CI checks'
175 const facts = [state, ci, pr.branch].filter(Boolean).join(' · ')
176 return `#${pr.number} ${pr.repo} · ${facts}${pr.title ? ` · ${pr.title}` : ''}`
177 })
178 .join('\n')
179}
180
181const CI_WORD = /\b(?:fail(?:s|ed|ing|ures?)?|checks|ci|lint(?:s|ing|er)?)\b/i
182const PR_REF = /(?:#|\bPR\s*#?\s*|\/pull\/)(\d{1,7})\b/gi
183const BARE_NUMBER = /(?:^|[^#\w/.-])(\d{1,7})\b/g
184const CI_FAILED =
185 /\b(?:ci|checks?|build|lint)\b[^.!?\n]{0,40}?\b(?:fail(?:s|ed|ing|ures?)?|red|broken)\b|\bfail(?:s|ed|ing|ures?)?\b[^.!?\n]{0,40}?\b(?:ci|checks)\b/i
186
187/**
188 * The PRs whose failing CI the person's prompt is about, at most two: a `#258` (or `PR 258`)
189 * in a clause that speaks of failing, checks, CI or lint, a bare `258` there when PR 258 is
190 * watched, and with no number at all, "CI failed" means the watched PRs failing now, newest first.
191 */
192export const mentionedPrs = (
193 prompt: string,
194 watched: readonly Pick<WatchedPr, 'number' | 'ci' | 'state' | 'addedAt'>[],
195): number[] => {
196 const known = new Set(watched.map(pr => pr.number))
197 const found: number[] = []
198 const add = (number: number) => {
199 if (!found.includes(number)) {
200 found.push(number)
201 }
202 }
203 for (const clause of prompt.split(/[.!?;](?=\s|$)|\n+/)) {
204 if (!CI_WORD.test(clause)) {
205 continue
206 }
207 for (const match of clause.matchAll(PR_REF)) {
208 add(Number(match[1]))
209 }
210 for (const match of clause.matchAll(BARE_NUMBER)) {
211 const number = Number(match[1])
212 if (known.has(number)) {
213 add(number)
214 }
215 }
216 }
217 if (found.length === 0 && CI_FAILED.test(prompt)) {
218 watched
219 .filter(pr => pr.state === 'open' && pr.ci === 'fail')
220 .slice()
221 .sort((a, b) => b.addedAt - a.addedAt)
222 .forEach(pr => add(pr.number))
223 }
224 return found.slice(0, 2)
225}
226
227/** Run ids of the failing rows of `gh pr checks` (tab-separated: name, bucket, elapsed, link, description). */
228export const failingRunIds = (checks: string): number[] => {
229 const ids = checks
230 .split('\n')
231 .map(line => line.split('\t'))
232 .filter(columns => (columns[1] ?? '').trim() === 'fail')
233 .map(columns => runIdOf(columns[3] ?? ''))
234 .filter((id): id is number => id !== null)
235 return [...new Set(ids)]
236}
237
238/** A timestamp GitHub puts after the job and step columns of `gh run view --log-failed`. */
239const STAMP = /^([^\t]*\t[^\t]*\t)\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d+)?Z ?/
240
241/**
242 * The last `count` lines of a log, colors, carriage returns, BOMs and per-line timestamps taken
243 * out, and the runner's post-job cleanup after the last `##[error]` dropped (it is noise).
244 */
245export const tailLines = (log: string, count: number): string => {
246 let lines = log
247 .replace(ANSI, '')
248 .replace(/\uFEFF/g, '')
249 .replace(/\r\n?/g, '\n')
250 .split('\n')
251 .map(line => line.replace(STAMP, '$1').trimEnd())
252 let lastError = -1
253 lines.forEach((line, index) => {
254 if (line.includes('##[error]')) {
255 lastError = index
256 }
257 })
258 const cleanupAt = lastError < 0 ? -1 : lines.findIndex((line, index) => index > lastError && /Post job cleanup\.?$/.test(line))
259 if (cleanupAt > 0) {
260 lines = lines.slice(0, cleanupAt)
261 }
262 while (lines.length > 0 && lines[lines.length - 1] === '') {
263 lines.pop()
264 }
265 return lines.slice(-Math.max(1, Math.round(count))).join('\n')
266}
267
268/** The end of a text that fits in `room` characters, cut at a line start. */
269const keepEnd = (value: string, room: number): string => {
270 if (value.length <= room) {
271 return value
272 }
273 const end = value.slice(value.length - Math.max(0, room - 2))
274 const lineStart = end.indexOf('\n')
275 return `…\n${lineStart >= 0 ? end.slice(lineStart + 1) : end}`
276}
277
278export const MAX_ATTACHMENT = 12_000
279const MAX_CHECKS = 3_000
280
281export type Attachment = {
282 number: number
283 /** `owner/name`, or null when gh resolved it from the working directory. */
284 repo: string | null
285 /** `gh pr checks` output. */
286 checks: string
287 runId: number | null
288 /** `gh run view --log-failed` output. */
289 log: string
290 logLines: number
291}
292
293/** The context block handed to the model beside the prompt, under MAX_ATTACHMENT characters; null when there is nothing to say. */
294export const ciAttachment = (a: Attachment): string | null => {
295 const repoArgs = a.repo ? ` --repo ${a.repo}` : ''
296 const intro = `pr-autopilot: the prompt mentions failing CI on #${a.number}${a.repo ? ` (${a.repo})` : ''}, so here is what gh reports.`
297 const parts: string[] = []
298 const checks = a.checks.replace(ANSI, '').trim()
299 if (checks) {
300 parts.push(`Checks (\`gh pr checks ${a.number}${repoArgs}\`):\n${clip(checks, MAX_CHECKS)}`)
301 }
302 const log = a.runId === null ? '' : tailLines(a.log, a.logLines)
303 if (a.runId !== null && log) {
304 const head = (lines: number) =>
305 `Last ${lines} lines of the failed log (\`gh run view ${a.runId}${repoArgs} --log-failed\`):\n`
306 const used = intro.length + parts.reduce((sum, part) => sum + part.length + 2, 0) + 2
307 const kept = keepEnd(log, MAX_ATTACHMENT - used - head(log.split('\n').length).length)
308 parts.push(head(kept.split('\n').filter(line => line !== '…').length) + kept)
309 }
310 return parts.length > 0 ? [intro, ...parts].join('\n\n') : null
311}
312
313/** The worktree (its path) that has `branch` checked out, from `git worktree list --porcelain`; null when none has. */
314export const worktreeHolding = (porcelain: string, branch: string): string | null => {
315 let path = ''
316 for (const line of porcelain.split('\n')) {
317 if (line.startsWith('worktree ')) {
318 path = line.slice('worktree '.length)
319 } else if (line.trim() === `branch refs/heads/${branch}`) {
320 return path
321 }
322 }
323 return null
324}
325
326/** The line of git's stderr that says what went wrong (`fatal:`/`error:` first), short. */
327export const gitError = (stderr: string): string => {
328 const lines = stderr
329 .split('\n')
330 .map(line => line.trim())
331 .filter(Boolean)
332 const line = lines.find(one => /^(?:fatal|error):/.test(one)) ?? lines[0] ?? 'failed'
333 return clip(line.replace(/^(?:fatal|error):\s*/, ''), 80)
334}
335types/index.d.ts 32 lines1/** The PR's checks as a whole: any failure fails it, then anything still running keeps it pending. */
2export type Ci = 'pending' | 'pass' | 'fail' | 'none'
3
4export type PrState = 'open' | 'merged' | 'closed'
5
6export type WatchedPr = {
7 number: number
8 /** `owner/name` on GitHub. */
9 repo: string
10 url: string
11 title: string
12 /** The PR's head branch (`headRefName`). */
13 branch: string
14 /** The PR's head commit (`headRefOid`); a local branch is deleted only when it points here. */
15 headSha: string
16 ci: Ci
17 /** Names of the failing checks. */
18 failing: string[]
19 /** GitHub Actions run ids of the failing checks, from their details URLs. */
20 runIds: number[]
21 state: PrState
22 /** True once the post-merge cleanup has run (or was skipped) for this PR. */
23 cleaned: boolean
24 addedAt: number
25}
26
27declare module 'claude-code' {
28 interface PluginState {
29 'pr-autopilot': { prs: WatchedPr[] }
30 }
31}
32