PR feed, review pane and security checks above the prompt

A Claude Code mod that puts your pull requests and security findings above the prompt. Review, approve, merge — without leaving the terminal.
● security 2 high · 1 low (+1) 1: details
██████████████ ████ ██ 5 files · +172/−29 ← strip: churn / ci / timeline, 3 cycles
⇄ 3 PRs · #42 fix login · ✓4 ◐1 · changes requested (+1 new) 2: PRs
claude plugin marketplace add settivishal/prdeck
claude plugin install prdeck@prdeck
Requires Claude Code 2.1.278+ and, for the PR row, gh logged in (gh auth login). Repos without a GitHub remote get the security row only.
Security row — scans your branch against its base (merge-base, working tree and untracked files included) for secrets, private keys, eval, shell exec, SQL string concat, unsafe deserialization, innerHTML, TLS verification off, chmod 777, plain http://. Colour is the worst severity; (+N) means the last turn added findings.
Strip — the bar under the security row; 3 cycles three views, hover any for its legend:
When the branch goes from findings to clean: confetti. Pending checks spin. Hover ⇄ to peek at the top three PRs without opening the pane.
Haiku triage — every regex hit is rated by Haiku once (high / med / low / false positive) and cached; false positives vanish from the bar, the reason shows in the pane. Off in /config if you'd rather not spend the tokens.
Git guard — git commit / git push run by Claude with high findings open get a warning in the permission dialog (or a refusal with deny).
Model awareness — open findings ride along as context on the first message, so Claude avoids adding more and fixes them when touching those files.
Write guard — when Claude is about to Write or Edit a line that hits a rule, the permission dialog shows ⚠ prdeck: eval at line 3. Set it to deny to block the call outright.
PR row — open PRs from gh, newest first, with check status and review decision. Toast when a new PR appears.
Panes — press the digit with an empty prompt:
| key | pane | inside |
|---|---|---|
1 | findings | filter by rule · fix fills the prompt · ignore / unignore (persisted) · r rescan · c clear ignores |
2 | pull requests | view a PR → i info (description, checks) · d diff (j/k files, l comment on a line) · v reviews and inline threads · q QR code of the PR · g ask Claude to review · o checkout · a approve · x request changes · c comment · m merge · z close · f mine/all · b back |
Merge and close ask for confirmation first.
Review threads — inline review comments show with path:line under reviews; l on the diff tab posts one on the current file (<line>: <text>).
Checkout — o runs gh pr checkout; the security row and heat strip then describe that PR.
Avatars — the PR author's GitHub avatar in the pane header on kitty and Ghostty; @login elsewhere.
Merge streak — clean merges (no findings in the diff) count up: 🔥7 in the pane header, longer confetti. A merge with findings resets it.
/prdeck — prints findings and open PRs, and hands the model the raw lists as context. Follow with "fix the high ones" or "summarise PR #42".
QR to phone — q on a PR, or /prdeck qr <url> for any link up to 106 characters, draws a scannable QR in a pane. Built-in encoder, nothing sent anywhere; read it from the couch.
Big PRs — past 4000 changed lines the diff tab shows a hint instead of fetching (gh pr diff N); info and reviews still load.
Inline ignore — a line containing sec-ignore is never flagged.
/config → prdeck:
| setting | default | |
|---|---|---|
| Security scanning | on | off hides the security row, strip and pane; PR feed stays |
| Write/Edit guard | warn | warn · deny · off |
| Base branch | auto | origin/main, origin/master, main, master in that order |
| Only my PRs | on | off lists every open PR |
| PR poll interval | 60 s | minimum 15 |
| Strip mode | churn | churn · ci · timeline — what the strip shows at start |
| Haiku triage | on | rate hits with Haiku, drop false positives |
| Extra rules file | — | path to a JSON array: [{ "name": "todo", "pattern": "TODO", "flags": "i", "sev": "low" }], merged with the built-ins |
One hooks module, no daemon, no tokens. git and gh run as you through Claude Code's process API; the scan is regex over the diff; the UI is drawn by Claude Code's own render hooks (AbovePrompt, Pane). Nothing leaves your machine except what gh sends to GitHub when you press an action.
MIT
hooks/hooks.tsx 728 lines1import type { EngineInterface, Register } from "claude-code";
2import { parseDiff, capHunks, checkSummary, checkGlyphs, decision, type Pr, type PrDetail, type DiffFile, type ReviewComment } from "./pr.ts";
3import { BUILTIN, compileRules, type Rule, type Sev } from "./rules.ts";
4import { heatStrip, confetti, dotRow, DOT, SPIN } from "./raster.ts";
5import { cfg, readConfig, STRIP_MODES, type StripMode } from "./config.ts";
6import { qrRaster } from "./qr.ts";
7
8const PANE = "pr-security";
9// ponytail: regex scan; swap in $.model when noise gets loud
10let RULES: Rule[] = compileRules(BUILTIN).rules;
11
12// ponytail: fixed skip list; make it a userConfig field if someone asks
13const SKIP = /(^|\/)(node_modules|dist|build|vendor|tests?|__tests__|\.git)\/|(^|\/)(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|Cargo\.lock|poetry\.lock|go\.sum)$|\.(md|min\.js|map|snap|svg|lock)$/;
14
15export type Finding = { file: string; line: number; rule: string; sev: Sev; text: string };
16type State = { base: string; files: number; findings: Finding[]; error?: string; scanning: boolean; at: number; prev: number; churn: { file: string; add: number; del: number; lines: number }[]; fx: number };
17const FX_FRAMES = 15; // 1.5 s of confetti at the 100 ms tick
18
19let state: State = { base: "?", files: 0, findings: [], scanning: false, at: 0, prev: 0, churn: [], fx: 0 };
20let spin = 0;
21// strip modes: churn (treemap), ci (last runs on this branch), timeline (one cell per turn)
22let stripMode: StripMode = "churn";
23type Run = { conclusion: string | null; status: string; name: string };
24let ci: { branch: string; runs: Run[] } = { branch: "", runs: [] };
25type Turn = { edits: number; tests: number; aborted: boolean };
26const turns: Turn[] = [];
27let cur: Turn = { edits: 0, tests: 0, aborted: false };
28let ignored = new Set<string>();
29let filter = "all";
30
31// line-number free so the ignore survives edits above it
32export const ignoreKey = (f: Finding) => `${f.file}:${f.rule}:${f.text}`;
33// haiku verdicts by finding key: fp drops the finding, else its severity wins over the rule's
34type Verdict = { sev: Sev | "fp"; why: string };
35let triage = new Map<string, Verdict>();
36const verdict = (f: Finding): Finding | null => {
37 const v = triage.get(ignoreKey(f));
38 return !v ? f : v.sev === "fp" ? null : { ...f, sev: v.sev };
39};
40const live = () => state.findings.filter(f => !ignored.has(ignoreKey(f))).map(verdict).filter((f): f is Finding => f !== null);
41let streak = 0;
42const avatars = new Map<string, string>(); // login -> png path, fetched once per session
43const SEV_COLOR: Record<Sev, string> = { high: "error", med: "warning", low: "text" };
44
45export function scanText(file: string, text: string, startLine = 1): Finding[] {
46 const out: Finding[] = [];
47 text.split("\n").forEach((raw, i) => {
48 if (raw.includes("sec-ignore")) return;
49 for (const { name, re, sev } of RULES) if (re.test(raw)) out.push({ file, line: startLine + i, rule: name, sev, text: raw.trim() });
50 });
51 return out;
52}
53
54export function scanDiff(diff: string): { files: number; findings: Finding[] } {
55 const findings: Finding[] = [];
56 const files = new Set<string>();
57 let file = "", line = 0;
58 for (const raw of diff.split("\n")) {
59 if (raw.startsWith("+++ b/")) { file = raw.slice(6); files.add(file); continue; }
60 if (SKIP.test(file)) continue;
61 if (raw.startsWith("@@")) { line = Number(/\+(\d+)/.exec(raw)?.[1] ?? 0) - 1; continue; }
62 if (!raw.startsWith("+") || raw.startsWith("+++")) continue;
63 line++;
64 findings.push(...scanText(file, raw.slice(1), line));
65 }
66 return { files: files.size, findings };
67}
68
69async function pickBase($: EngineInterface): Promise<string> {
70 for (const b of cfg.base ? [cfg.base] : ["origin/main", "origin/master", "main", "master"]) {
71 const { exitCode } = await $.process.run(["git", "rev-parse", "--verify", "-q", b]);
72 if (exitCode === 0) return b;
73 }
74 throw new Error("no base branch");
75}
76
77async function loadRules($: EngineInterface): Promise<void> {
78 if (!cfg.rulesFile) return;
79 try {
80 const { rules, errors } = compileRules(JSON.parse(await $.fs.read(cfg.rulesFile)));
81 RULES = [...compileRules(BUILTIN).rules, ...rules];
82 for (const m of errors) $.ui.log(`prdeck rulesFile: ${m}`);
83 } catch (err) {
84 $.ui.log(`prdeck rulesFile: ${String((err as Error).message ?? err)}`);
85 }
86}
87
88async function runTriage($: EngineInterface): Promise<void> {
89 if (!cfg.triage) return;
90 const todo = state.findings.filter(f => !triage.has(ignoreKey(f))).slice(0, 40);
91 if (!todo.length) return;
92 const list = todo.map((f, i) => `${i}. [${f.rule}] ${f.file}:${f.line}: ${f.text.slice(0, 200)}`).join("\n");
93 try {
94 const reply = await $.model.complete({
95 model: "haiku", maxTokens: 2000,
96 system: "You triage regex hits from a security scanner. Answer ONLY a JSON array of {\"i\":number,\"sev\":\"high\"|\"med\"|\"low\"|\"fp\",\"why\":string}. fp = not a real issue (test fixture, comment, safe usage, placeholder). why is at most 8 words.",
97 prompt: list,
98 });
99 const arr = JSON.parse(reply.slice(reply.indexOf("["), reply.lastIndexOf("]") + 1)) as { i: number; sev: string; why: string }[];
100 for (const v of arr) {
101 const f = todo[v.i];
102 const sev = (["high", "med", "low", "fp"] as const).find(x => x === v.sev);
103 if (f && sev) triage.set(ignoreKey(f), { sev, why: String(v.why ?? "").slice(0, 60) });
104 }
105 if (triage.size > 500) triage = new Map([...triage].slice(-500));
106 await $.store.set("triage", [...triage]);
107 $.ui.invalidate("ui.render");
108 $.ui.invalidate("prompt.context");
109 } catch (err) {
110 $.ui.log(`prdeck triage: ${String((err as Error).message ?? err).split("\n")[0]}`, { to: "debug" });
111 }
112}
113
114async function scan($: EngineInterface): Promise<void> {
115 if (state.scanning) return;
116 state.scanning = true;
117 $.ui.invalidate("ui.render");
118 try {
119 const base = await pickBase($);
120 const mb = await $.process.run(["git", "merge-base", base, "HEAD"]);
121 if (mb.exitCode !== 0) throw new Error(mb.stderr.trim() || "merge-base failed");
122 // working tree vs merge-base: staged, unstaged and committed changes alike
123 const { stdout } = await $.process.run(["git", "diff", "--unified=0", mb.stdout.trim()]);
124 const r = scanDiff(stdout);
125 const numstat = await $.process.run(["git", "diff", "--numstat", mb.stdout.trim()]);
126 const churn = numstat.stdout.split("\n").filter(Boolean).map(l => {
127 const [a = "0", d = "0", ...rest] = l.split("\t");
128 return { file: rest.join("\t"), add: Number(a) || 0, del: Number(d) || 0, lines: (Number(a) || 0) + (Number(d) || 0) };
129 }).filter(f => !SKIP.test(f.file));
130 const untracked = (await $.process.run(["git", "ls-files", "--others", "--exclude-standard"])).stdout.split("\n").filter(f => f && !SKIP.test(f));
131 for (const f of untracked) r.findings.push(...scanText(f, await $.fs.read(f)));
132 for (const f of untracked) { const n = (await $.fs.read(f)).split("\n").length; churn.push({ file: f, add: n, del: 0, lines: n }); }
133 const wasDirty = live().length > 0;
134 state = { ...state, base, files: r.files + untracked.length, findings: r.findings, churn, error: undefined };
135 if (wasDirty && live().length === 0) state.fx = FX_FRAMES + Math.min(streak, 10) * 3; // just went clean: party, longer on a streak
136 $.ui.invalidate("prompt.context");
137 void runTriage($);
138 } catch (err) {
139 const error = String((err as Error).message ?? err).split("\n")[0] ?? "";
140 if (error !== state.error) $.ui.log(`prdeck: ${error}`, { to: "debug" });
141 state = { ...state, error };
142 }
143 state = { ...state, scanning: false, at: Date.now() };
144 $.ui.invalidate("ui.render");
145}
146
147async function togglePane($: EngineInterface): Promise<void> {
148 const isOpen = (await $.ui.panes()).some(p => p.id === PANE);
149 if (isOpen) await $.ui.close({ id: PANE });
150 else await $.ui.open({ id: PANE, title: "PR security", focus: true, closeOnEscape: true });
151}
152
153async function setIgnored($: EngineInterface, key: string | null, on: boolean): Promise<void> {
154 if (key === null) ignored.clear();
155 else if (on) ignored.add(key);
156 else ignored.delete(key);
157 await $.store.set("ignored", [...ignored]);
158 $.ui.invalidate("ui.render");
159}
160
161function fillFix($: EngineInterface, f: Finding): void {
162 void $.prompt.fill({ text: `Fix security finding [${f.rule}] at ${f.file}:${f.line}:\n${f.text}`, mode: "replace" });
163}
164
165function guard($: EngineInterface, id: string, file: string, text: string): { deny: string } | undefined {
166 if (!cfg.security || cfg.guard === "off" || SKIP.test(file)) return;
167 const hits = scanText(file, text);
168 if (!hits.length) return;
169 const h = hits[0]!;
170 const msg = `⚠ pr-security: ${h.rule} at line ${h.line}${hits.length > 1 ? ` (+${hits.length - 1} more)` : ""}`;
171 $.ui.notice(id, msg);
172 return cfg.guard === "deny" ? { deny: msg } : undefined;
173}
174
175// ---------- PR feed (gh) ----------
176const PR_PANE = "prs";
177const QR_PANE = "qr";
178let qrText = "";
179const MAX_DIFF_LINES = 4000; // past this, gh pr diff runs into the timeout; show a hint instead
180type PrState = {
181 repo?: string; list: Pr[]; seen: Set<number>; error?: string; rawError?: string; busy?: string; diffNote?: string;
182 selected?: number; detail?: PrDetail; diff?: DiffFile[]; diffText?: string; threads?: ReviewComment[]; fileIdx: number;
183 tab: "info" | "diff" | "reviews" | "qr"; compose?: "comment" | "changes" | "line"; mine: boolean; bodyChunks: number;
184};
185let pr: PrState = { list: [], seen: new Set(), fileIdx: 0, tab: "info", mine: true, bodyChunks: 1 };
186const BODY_CHUNK = 6; // lines of description shown per "more"
187
188async function gh($: EngineInterface, args: string[], timeoutMs = 30_000): Promise<string> {
189 const r = await $.process.run(["gh", ...args], { timeoutMs });
190 if (r.exitCode !== 0) throw new Error(r.stderr.trim().split("\n")[0] || `gh ${args[0]} failed`);
191 return r.stdout;
192}
193
194async function fetchList($: EngineInterface): Promise<void> {
195 try {
196 if (!pr.repo) pr.repo = JSON.parse(await gh($, ["repo", "view", "--json", "nameWithOwner"])).nameWithOwner;
197 const list: Pr[] = JSON.parse(await gh($, ["pr", "list", "--state", "open", "--limit", "30", ...(pr.mine ? ["--author", "@me"] : []), "--json",
198 "number,title,author,headRefName,baseRefName,isDraft,updatedAt,reviewDecision,statusCheckRollup,additions,deletions,changedFiles,url"]));
199 list.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
200 if (pr.seen.size === 0 && pr.list.length === 0) { // first load: nothing is "new"
201 const stored = (await $.store.get("pr-seen")) as number[] | undefined;
202 pr.seen = new Set(stored ?? list.map(p => p.number));
203 }
204 for (const p of list) if (!pr.seen.has(p.number)) $.ui.toast(`New PR #${p.number}: ${p.title}`);
205 pr = { ...pr, list, error: undefined };
206 void fetchRuns($);
207 } catch (err) {
208 const raw = String((err as Error).message ?? err);
209 const error = /no git remotes|not a git repository|could not determine/i.test(raw) ? "" // not a GitHub repo: no row
210 : /auth login|not logged|gh auth/i.test(raw) ? "gh not logged in — run: gh auth login"
211 : /ENOENT|not found|cannot start|No such file/i.test(raw) ? "gh not installed — https://cli.github.com"
212 : raw.split("\n")[0] ?? "gh failed";
213 if (raw !== pr.rawError) $.ui.log(`prdeck gh: ${raw}`, { to: "debug" });
214 pr = { ...pr, error, rawError: raw, list: [] };
215 }
216 $.ui.invalidate("ui.render");
217}
218
219async function fetchRuns($: EngineInterface): Promise<void> {
220 try {
221 const branch = (await $.process.run(["git", "branch", "--show-current"])).stdout.trim();
222 if (!branch) return;
223 const runs: Run[] = JSON.parse(await gh($, ["run", "list", "--branch", branch, "--limit", "30", "--json", "conclusion,status,name"]));
224 ci = { branch, runs: runs.reverse() }; // oldest first, newest at the right edge
225 } catch { ci = { ...ci, runs: [] }; }
226 $.ui.invalidate("ui.render");
227}
228
229async function markSeen($: EngineInterface): Promise<void> {
230 for (const p of pr.list) pr.seen.add(p.number);
231 await $.store.set("pr-seen", [...pr.seen]);
232 $.ui.invalidate("ui.render");
233}
234
235async function fetchDetail($: EngineInterface, n: number): Promise<void> {
236 pr = { ...pr, selected: n, busy: "loading", detail: undefined, diff: undefined, fileIdx: 0, tab: "info", compose: undefined, bodyChunks: 1 };
237 $.ui.invalidate("ui.render");
238 try {
239 const p = pr.list.find(x => x.number === n);
240 const lines = (p?.additions ?? 0) + (p?.deletions ?? 0);
241 const tooBig = lines > MAX_DIFF_LINES;
242 const [view, diffText, threads] = await Promise.all([
243 gh($, ["pr", "view", String(n), "--json", "body,mergeable,mergeStateStatus,statusCheckRollup,reviews,comments,files,headRefOid"]),
244 tooBig ? Promise.resolve("") : gh($, ["pr", "diff", String(n)], 60_000),
245 gh($, ["api", `repos/${pr.repo}/pulls/${n}/comments`, "--paginate"]).then(t => JSON.parse(t) as ReviewComment[]).catch(() => [] as ReviewComment[]),
246 ]);
247 pr = { ...pr, detail: JSON.parse(view), diff: parseDiff(diffText), diffText, threads, busy: undefined,
248 diffNote: tooBig ? `diff too large (${lines} lines) — run: gh pr diff ${n}` : undefined };
249 if (p && !avatars.has(p.author.login)) void fetchAvatar($, p.author.login);
250 } catch (err) {
251 pr = { ...pr, busy: undefined, error: String((err as Error).message ?? err) };
252 }
253 $.ui.invalidate("ui.render");
254}
255
256async function fetchAvatar($: EngineInterface, login: string): Promise<void> {
257 const file = `/tmp/prdeck-avatar-${login}.png`;
258 const r = await $.process.run(["curl", "-sL", "-o", file, `https://github.com/${login}.png?size=64`]).catch(() => ({ exitCode: 1 }));
259 if (r.exitCode === 0) { avatars.set(login, file); $.ui.invalidate("ui.render"); }
260}
261
262async function lineComment($: EngineInterface, n: number, path: string, spec: string): Promise<void> {
263 const m = /^\s*(\d+)\s*[:\s]\s*(.+)$/s.exec(spec);
264 if (!m || !pr.detail) { $.ui.toast("format: <line>: <comment>"); return; }
265 try {
266 await gh($, ["api", "-X", "POST", `repos/${pr.repo}/pulls/${n}/comments`, "-f", `body=${m[2]}`, "-f", `path=${path}`,
267 "-F", `line=${m[1]}`, "-f", "side=RIGHT", "-f", `commit_id=${pr.detail.headRefOid}`]);
268 $.ui.toast(`#${n}: comment on ${path}:${m[1]} posted`);
269 await fetchDetail($, n);
270 } catch (err) {
271 $.ui.toast(`comment failed: ${String((err as Error).message ?? err)}`);
272 }
273}
274
275async function checkout($: EngineInterface, n: number): Promise<void> {
276 pr = { ...pr, busy: "checkout" };
277 $.ui.invalidate("ui.render");
278 try { await gh($, ["pr", "checkout", String(n)]); $.ui.toast(`checked out #${n}`); void scan($); }
279 catch (err) { $.ui.toast(`checkout failed: ${String((err as Error).message ?? err)}`); }
280 pr = { ...pr, busy: undefined };
281 $.ui.invalidate("ui.render");
282}
283
284async function prAction($: EngineInterface, args: string[], label: string, confirm?: string): Promise<void> {
285 const n = pr.selected;
286 if (n === undefined) return;
287 if (confirm) {
288 const a = await $.ui.ask(confirm, [label, "Cancel"]).catch(() => "Cancel");
289 if (a !== label) return;
290 }
291 pr = { ...pr, busy: label.toLowerCase(), compose: undefined };
292 $.ui.invalidate("ui.render");
293 try {
294 await gh($, ["pr", ...args, String(n)]);
295 if (label === "Merge") {
296 const clean = !pr.diffText || scanDiff(pr.diffText).findings.length === 0;
297 streak = clean ? streak + 1 : 0;
298 await $.store.set("streak", streak);
299 $.ui.toast(clean ? `#${n} merged clean — streak ${streak} 🔥` : `#${n} merged with findings — streak reset`);
300 } else $.ui.toast(`#${n}: ${label} done`);
301 } catch (err) {
302 $.ui.toast(`#${n}: ${label} failed: ${String((err as Error).message ?? err)}`);
303 }
304 pr = { ...pr, busy: undefined };
305 await fetchList($);
306 if (pr.list.some(p => p.number === n)) await fetchDetail($, n);
307 else pr = { ...pr, selected: undefined, detail: undefined, diff: undefined };
308 $.ui.invalidate("ui.render");
309}
310
311async function togglePrPane($: EngineInterface): Promise<void> {
312 const isOpen = (await $.ui.panes()).some(p => p.id === PR_PANE);
313 if (isOpen) await $.ui.close({ id: PR_PANE });
314 else { await $.ui.open({ id: PR_PANE, title: "Pull requests", focus: true, closeOnEscape: true }); void markSeen($); }
315}
316
317function fillReview($: EngineInterface, n: number, title: string): void {
318 const sec = pr.diffText ? scanDiff(pr.diffText).findings : [];
319 const notes = sec.length ? `\nprdeck flagged:\n${sec.map(f => `- ${f.file}:${f.line} [${f.rule}] ${f.text}`).join("\n")}` : "";
320 void $.prompt.fill({ mode: "replace", text: `Review PR #${n} "${title}": run \`gh pr diff ${n}\`, check correctness and security, list findings with file:line, then say whether to approve.${notes}` });
321}
322
323// 4: /prdeck report: what the person sees, and the raw list only the model reads
324function report(): { text: string; context: string[] } {
325 const fs = cfg.security ? live() : [];
326 const lines = cfg.security ? [`security: ${sevCounts(fs).map(([s, c]) => `${c} ${s}`).join(", ") || "clean"} (vs ${state.base})`] : [];
327 for (const f of fs.slice(0, 15)) lines.push(` ${f.file}:${f.line} [${f.sev}] ${f.rule}`);
328 if (fs.length > 15) lines.push(` … ${fs.length - 15} more`);
329 lines.push(pr.repo ? `PRs (${pr.mine ? "mine" : "all"}): ${pr.list.length} open` : "PRs: no GitHub remote");
330 for (const p of pr.list) lines.push(` #${p.number} ${p.title} ${p.headRefName}→${p.baseRefName} ${checkGlyphs(p.statusCheckRollup)} ${decision(p.reviewDecision)}`);
331 const context = [
332 `prdeck security findings (JSON): ${JSON.stringify(fs)}`,
333 `prdeck open PRs (JSON): ${JSON.stringify(pr.list.map(p => ({ number: p.number, title: p.title, head: p.headRefName, base: p.baseRefName, url: p.url, review: p.reviewDecision })))}`,
334 ];
335 return { text: lines.join("\n"), context };
336}
337
338function setPr(patch: Partial<PrState>, $: EngineInterface): void {
339 pr = { ...pr, ...patch };
340 $.ui.invalidate("ui.render");
341}
342
343const sevCounts = (fs: Finding[]) => (["high", "med", "low"] as Sev[])
344 .map(s => [s, fs.filter(f => f.sev === s).length] as const)
345 .filter(([, n]) => n > 0);
346
347export const register: Register = (on, options) => {
348 readConfig(options);
349 stripMode = cfg.strip;
350 pr = { ...pr, mine: cfg.mine };
351 on("session.start", async ($, e, next) => {
352 ignored = new Set(((await $.store.get("ignored")) as string[] | undefined) ?? []);
353 triage = new Map(((await $.store.get("triage")) as [string, Verdict][] | undefined) ?? []);
354 streak = Number((await $.store.get("streak")) ?? 0) || 0;
355 await $.command.register({ name: "prdeck", description: "Security findings and open PRs, with the raw lists handed to the model." });
356 if (cfg.security) {
357 await loadRules($);
358 void scan($);
359 $.clock.every(30_000, () => void scan($));
360 }
361 void fetchList($);
362 let tick = 0;
363 $.clock.every(100, () => { // pending-check spinner (every 2nd tick), confetti frames
364 tick++;
365 const pending = pr.list.some(p => checkSummary(p.statusCheckRollup).pending);
366 if (pending && tick % 2 === 0) spin = (spin + 1) % SPIN.length;
367 const animating = state.fx > 0;
368 if (animating) state.fx--; // last frame paints at fx=0, then the strip comes back
369 if ((pending && tick % 2 === 0) || animating) $.ui.invalidate("ui.render");
370 });
371 $.clock.every(cfg.pollSeconds * 1000, () => void fetchList($));
372 return next(e);
373 });
374 on("command.run", { command: "prdeck" }, async ($, e, next) => {
375 const m = /^\s*qr\s+(\S.*)$/.exec(e.args);
376 if (!m) return report();
377 qrText = m[1]!.trim();
378 await $.ui.open({ id: QR_PANE, title: "QR", focus: true, closeOnEscape: true });
379 return { text: qrText.length > 106 ? `too long for a QR (${qrText.length} > 106 chars)` : `QR for ${qrText} — Esc closes` };
380 });
381 on("turn.start", ($, e, next) => { state.prev = live().length; cur = { edits: 0, tests: 0, aborted: false }; return next(e); });
382 on("turn.complete", async ($, e, next) => {
383 turns.push({ ...cur, aborted: e.isAborted || e.reason === "refusal" });
384 if (turns.length > 200) turns.shift();
385 if (cfg.security) void scan($);
386 void fetchList($);
387 return next(e);
388 });
389
390 on("tool.call", { tool: "Write" }, ($, e, next) => { cur.edits++; return guard($, e.tool_use_id, e.file_path, e.content) ?? next(e); });
391 on("tool.call", { tool: "Edit" }, ($, e, next) => { cur.edits++; return guard($, e.tool_use_id, e.file_path, e.new_string) ?? next(e); });
392 on("tool.call", { tool: "Bash" }, ($, e, next) => {
393 if (/\b(pytest|jest|vitest|mocha|cargo test|go test|npm test|pnpm test|yarn test|bun test|tsx .*\.test\.)/.test(e.command)) cur.tests++;
394 if (!cfg.security || cfg.guard === "off" || !/\bgit\s+(push|commit)\b/.test(e.command)) return next(e);
395 const high = live().filter(f => f.sev === "high");
396 if (!high.length) return next(e);
397 const msg = `⚠ prdeck: ${high.length} high finding${high.length === 1 ? "" : "s"} open (${high[0]!.file}:${high[0]!.line} ${high[0]!.rule})`;
398 $.ui.notice(e.tool_use_id, msg);
399 return cfg.guard === "deny" ? { deny: msg } : next(e);
400 });
401 on("prompt.context", async ($, e, next) => {
402 const fs = cfg.security ? live() : [];
403 if (!fs.length) return next(e);
404 const text = `prdeck security findings on this branch (do not add more; fix when touching these files):\n` +
405 fs.slice(0, 30).map(f => `- ${f.file}:${f.line} [${f.sev}] ${f.rule}`).join("\n");
406 return next({ ...e, blocks: [...e.blocks, { name: "prdeck", text }] });
407 });
408
409 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
410 if (e.props.hasSurvey || e.surface !== "terminal") return next(e); // the band is terminal-only; narrows the table for Raster
411 const { Box, Text, Button, Raster } = $.ui.resolve(e);
412 const below = await next(e); // other plugins' band rows (tamaclaude) stack under ours
413 const fs = live();
414 const n = fs.length;
415 const w = Math.max(1, e.props.bodyColumns - 2);
416 const hotFiles = new Set(fs.map(f => f.file));
417 const heat = state.churn.map(c => ({ lines: c.lines, hot: hotFiles.has(c.file) }));
418 // one strip, three datasets; `3` cycles
419 let cells: { cells: string; columns: number }, label: string, legend: string, hover: { key: string; text: string; color?: string }[] = [];
420 const labelW = (t: string) => Math.max(1, w - t.length - 2);
421 if (stripMode === "ci") {
422 const runColor = (r: Run) => r.status !== "completed" ? DOT.pending : ["success", "neutral", "skipped"].includes(r.conclusion ?? "") ? DOT.ok : DOT.fail;
423 const ok = ci.runs.filter(r => runColor(r) === DOT.ok).length, fail = ci.runs.filter(r => runColor(r) === DOT.fail).length, pend = ci.runs.length - ok - fail;
424 label = ci.runs.length ? `ci ${ci.branch} · ${ok}✓ ${fail}✗${pend ? ` ${pend}●` : ""}` : `ci ${ci.branch || "?"} · no runs`;
425 cells = dotRow(ci.runs.map(runColor), labelW(label));
426 legend = "one cell per workflow run on this branch, newest right · green ok · red failed · yellow running";
427 hover = ci.runs.slice(-5).reverse().map((r, i) => ({ key: `run:${i}`, text: `${r.name}: ${r.conclusion ?? r.status}`, color: runColor(r) === DOT.fail ? "error" : undefined }));
428 } else if (stripMode === "timeline") {
429 const all = [...turns, cur];
430 const turnColor = (t: Turn) => t.aborted ? DOT.abort : t.tests ? DOT.test : t.edits ? DOT.edit : DOT.idle;
431 const edits = turns.reduce((n, t) => n + t.edits, 0), tests = turns.reduce((n, t) => n + t.tests, 0);
432 label = `turn ${turns.length + 1} · ${edits} edits · ${tests} test runs`;
433 cells = dotRow(all.map(turnColor), labelW(label));
434 legend = "one cell per turn · blue edited files · green ran tests · red aborted or refused · grey neither";
435 } else {
436 const totals = state.churn.reduce((t, c) => ({ add: t.add + c.add, del: t.del + c.del }), { add: 0, del: 0 });
437 label = `${state.churn.length} files · +${totals.add}/−${totals.del}`;
438 cells = heatStrip(heat, labelW(label));
439 legend = "red finding · orange >100 lines · yellow >20 · green small · width ∝ churn";
440 hover = state.churn.filter(c => hotFiles.has(c.file)).slice(0, 5).map(c => ({ key: `hot:${c.file}`, text: `${c.file} +${c.add}/−${c.del}`, color: "error" }));
441 }
442 const strip = state.fx > 0
443 ? <Raster key="fx" columns={w} rows={1} cells={confetti(w, FX_FRAMES - state.fx, FX_FRAMES)} />
444 : cells.columns ? (
445 <Box key="strip" flexDirection="column">
446 <Box gap={2}>
447 <Raster key="stripcells" columns={cells.columns} rows={1} cells={cells.cells} />
448 <Text dimColor>{label}</Text>
449 </Box>
450 <Box display="none" hover={{ display: "flex" }} flexDirection="column">
451 {hover.map(hv => <Text key={hv.key} color={hv.color} wrap="truncate">{hv.text}</Text>)}
452 <Text dimColor>{legend}</Text>
453 </Box>
454 </Box>
455 ) : null;
456 const color = state.error ? "warning" : n ? "error" : "success";
457 const delta = n - state.prev;
458 const counts = sevCounts(fs).map(([s, c]) => `${c} ${s}`).join(" · ") || "0 findings";
459 const newest = pr.list[0];
460 const unseen = pr.list.filter(p => !pr.seen.has(p.number)).length;
461 const prColor = pr.list.some(p => p.reviewDecision === "CHANGES_REQUESTED" || checkSummary(p.statusCheckRollup).fail) ? "error"
462 : pr.list.some(p => p.isDraft || checkSummary(p.statusCheckRollup).pending) ? "warning" : "success";
463 const prRow = pr.error ? (
464 <Box gap={1}><Text color="warning" bold>⇄</Text><Text dimColor wrap="truncate">{`PRs: ${pr.error}`}</Text></Box>
465 ) : !pr.repo ? null : (
466 <Box key="prrow" flexDirection="column">
467 <Box gap={1}>
468 <Text color={prColor} bold>⇄</Text>
469 <Text color={prColor} wrap="truncate">
470 {newest
471 ? `${pr.list.length} PR${pr.list.length === 1 ? "" : "s"} · #${newest.number} ${newest.title} · ${checkGlyphs(newest.statusCheckRollup).replace("●", SPIN[spin]!)} · ${decision(newest.reviewDecision)}`
472 : "no open PRs"}
473 </Text>
474 {unseen > 0 ? <Text color="error" bold>{`(+${unseen} new)`}</Text> : null}
475 <Button key="prs" plain dimColor hotkey="2" onPress={() => void togglePrPane($)}>PRs</Button>
476 </Box>
477 <Box display="none" hover={{ display: "flex" }} flexDirection="column" paddingLeft={2}>
478 {pr.list.slice(0, 3).map(p => (
479 <Text key={`hv:${p.number}`} dimColor wrap="truncate">{`#${p.number} ${p.title} ${p.headRefName}→${p.baseRefName} ${checkGlyphs(p.statusCheckRollup)} ${decision(p.reviewDecision)}`}</Text>
480 ))}
481 </Box>
482 </Box>
483 );
484 const secRows = !cfg.security ? null : (
485 <Box flexDirection="column">
486 <Box gap={1}>
487 <Text color={color} bold>{n ? "●" : "○"}</Text>
488 <Text color={color} wrap="truncate">
489 {state.error ? `security: ${state.error}` : `security ${counts}`}
490 </Text>
491 {delta > 0 ? <Text color="error" bold>{`(+${delta})`}</Text> : null}
492 <Button key="details" plain dimColor hotkey="1" onPress={() => void togglePane($)}>details</Button>
493 <Button key="strip-mode" plain dimColor hotkey="3" onPress={() => { stripMode = STRIP_MODES[(STRIP_MODES.indexOf(stripMode) + 1) % STRIP_MODES.length]!; $.ui.invalidate("ui.render"); }}>{stripMode}</Button>
494 </Box>
495 {strip ? <Box paddingLeft={2}>{strip}</Box> : null}
496 </Box>
497 );
498 return <Box flexDirection="column">{secRows}{prRow}{below}</Box>;
499 });
500
501 on("ui.render", { component: "Pane" }, ($, e, next) => {
502 if (e.requestId !== QR_PANE) return next(e);
503 const els = $.ui.resolve(e);
504 const q = "Raster" in els ? qrRaster(qrText) : null;
505 return (
506 <els.Box flexDirection="column">
507 {q && "Raster" in els ? <els.Raster key="qr" columns={q.columns} rows={q.rows} cells={q.cells} /> : null}
508 <els.Text dimColor wrap="truncate">{q ? qrText : `cannot draw: ${qrText.length > 106 ? "too long" : "terminal only"} — ${qrText}`}</els.Text>
509 </els.Box>
510 );
511 });
512
513 on("ui.render", { component: "Pane" }, ($, e, next) => {
514 if (e.requestId !== PR_PANE) return next(e);
515 const els = $.ui.resolve(e);
516 const { Box, Text, Button, Markdown, Code } = els;
517 const Input = "Input" in els ? els.Input : null; // mobile has no Input
518 const w = e.props.bodyColumns;
519 const busy = pr.busy ? <Text dimColor>{` ${pr.busy}…`}</Text> : null;
520 const cur = pr.list.find(p => p.number === pr.selected);
521
522 if (!cur) return (
523 <Box flexDirection="column" width={w}>
524 <Box gap={1}><Text bold>{`${pr.list.length} open · ${pr.mine ? "mine" : "all"}`}</Text>{busy}{pr.error ? <Text color="error">{pr.error}</Text> : null}</Box>
525 {pr.list.map(p => (
526 <Box key={`pr:${p.number}`} gap={1}>
527 <Box flexGrow={1} flexShrink={1} minWidth={0}>
528 <Text wrap="truncate" dimColor={p.isDraft}>
529 <Text bold>{`#${p.number} `}</Text>{`${p.title} `}<Text color="cyan">{`${p.headRefName}→${p.baseRefName} `}</Text>
530 <Text color={checkSummary(p.statusCheckRollup).fail ? "error" : "success"}>{checkGlyphs(p.statusCheckRollup)}</Text>
531 </Text>
532 </Box>
533 <Box flexShrink={0}><Button key={`view:${p.number}`} plain dimColor onPress={() => void fetchDetail($, p.number)}>view</Button></Box>
534 </Box>
535 ))}
536 <Box gap={2} marginTop={1}>
537 <Button key="pr-refresh" plain dimColor hotkey="r" onPress={() => void fetchList($)}>refresh</Button>
538 <Button key="pr-mine" plain dimColor hotkey="f" onPress={() => { pr = { ...pr, mine: !pr.mine }; void fetchList($); }}>{pr.mine ? "show all" : "show mine"}</Button>
539 <Text dimColor>Esc close</Text>
540 </Box>
541 </Box>
542 );
543
544 const d = pr.detail;
545 const file = pr.diff?.[pr.fileIdx];
546 const cap = file ? capHunks(file.hunks) : undefined;
547 const sec = pr.diffText ? scanDiff(pr.diffText).findings : [];
548 const tab = (id: PrState["tab"], key: string, label: string) => (
549 <Button key={`tab:${id}`} plain hotkey={key} dimColor={pr.tab !== id} onPress={() => setPr({ tab: id, compose: undefined }, $)}>{label}</Button>
550 );
551 return (
552 <Box flexDirection="column" width={w}>
553 <Box gap={1}>
554 {"Image" in els && avatars.has(cur.author.login)
555 ? <els.Image key="avatar" source={{ file: avatars.get(cur.author.login)!, format: "png" }} columns={4} rows={2} alt={`@${cur.author.login}`} />
556 : null}
557 <Text bold wrap="truncate">{`#${cur.number} ${cur.title}`}</Text>{busy}
558 {streak > 0 ? <Text color="warning">{`🔥${streak}`}</Text> : null}
559 </Box>
560 <Text dimColor wrap="truncate">
561<Text color="cyan" bold>{cur.headRefName}</Text>{" → "}<Text color="cyan" bold>{cur.baseRefName}</Text>{` ${decision(cur.reviewDecision)}`}
562 {d ? ` ${d.mergeable.toLowerCase()}` : ""}
563 {sec.length ? <Text color="error">{` sec: ${sec.length}`}</Text> : ""}
564 </Text>
565 <Box gap={2}>{tab("info", "i", "info")}{tab("diff", "d", "diff")}{tab("reviews", "v", "reviews")}{tab("qr", "q", "qr")}
566 <Button key="back" plain dimColor hotkey="b" onPress={() => setPr({ selected: undefined, detail: undefined, diff: undefined, compose: undefined }, $)}>back</Button>
567 </Box>
568 {pr.tab === "qr" ? (() => {
569 const q = "Raster" in els ? qrRaster(cur.url) : null;
570 return (
571 <Box flexDirection="column" marginTop={1}>
572 {q && "Raster" in els ? <els.Raster key="qr" columns={q.columns} rows={q.rows} cells={q.cells} /> : <Text dimColor>{cur.url}</Text>}
573 <Text dimColor wrap="truncate">{`scan to open ${cur.url} on your phone`}</Text>
574 </Box>
575 );
576 })() : !d ? <Text dimColor>loading…</Text> : pr.tab === "info" ? (
577 <Box flexDirection="column" marginTop={1}>
578 {(() => {
579 const lines = (d.body?.trim() || "_no description_").split("\n");
580 const shown = lines.slice(0, pr.bodyChunks * BODY_CHUNK);
581 const left = lines.length - shown.length;
582 return (
583 <Box flexDirection="column">
584 <Markdown text={shown.join("\n")} />
585 {left > 0 || pr.bodyChunks > 1 ? (
586 <Box gap={2}>
587 {left > 0 ? <Button key="more" plain dimColor hotkey="e" onPress={() => setPr({ bodyChunks: pr.bodyChunks + 1 }, $)}>{`more (${left} lines)`}</Button> : null}
588 {pr.bodyChunks > 1 ? <Button key="less" plain dimColor hotkey="w" onPress={() => setPr({ bodyChunks: 1 }, $)}>less</Button> : null}
589 </Box>
590 ) : null}
591 </Box>
592 );
593 })()}
594 <Text bold>checks</Text>
595 {(d.statusCheckRollup ?? []).map((c, i) => {
596 const v = (c.conclusion || c.state || "pending").toLowerCase();
597 const col = ["success", "neutral", "skipped"].includes(v) ? "success" : ["failure", "error", "timed_out", "cancelled"].includes(v) ? "error" : "warning";
598 return <Text key={`c:${i}`} color={col} wrap="truncate">{` ${c.name || c.context || "?"}: ${v}`}</Text>;
599 })}
600 </Box>
601 ) : pr.tab === "diff" ? (
602 <Box flexDirection="column" marginTop={1}>
603 <Box gap={1}>
604 <Button key="prev" plain dimColor hotkey="k" onPress={() => setPr({ fileIdx: Math.max(0, pr.fileIdx - 1) }, $)}>‹</Button>
605 <Text wrap="truncate">{file ? `${pr.fileIdx + 1}/${pr.diff!.length} ${file.file}` : pr.diffNote ?? "no diff"}</Text>
606 <Button key="next" plain dimColor hotkey="j" onPress={() => setPr({ fileIdx: Math.min((pr.diff?.length ?? 1) - 1, pr.fileIdx + 1) }, $)}>›</Button>
607 </Box>
608 {file ? <Button key="linecomment" plain dimColor hotkey="l" onPress={() => setPr({ compose: "line" }, $)}>comment on line</Button> : null}
609 {cap ? <Code format="diff" path={file!.file} source={cap.source} wrap="truncate-end" /> : null}
610 {cap?.dropped ? <Text dimColor>{`… truncated, ${cap.dropped} more lines`}</Text> : null}
611 </Box>
612 ) : (
613 <Box flexDirection="column" marginTop={1}>
614 {d.reviews.length + d.comments.length + (pr.threads?.length ?? 0) === 0 ? <Text dimColor>none</Text> : null}
615 {(pr.threads ?? []).map((t, i) => (
616 <Box key={`t:${i}`} flexDirection="column">
617 <Text bold>{`@${t.user.login} `}<Text color="cyan">{`${t.path}${t.line ? `:${t.line}` : ""}`}</Text></Text>
618 <Markdown text={t.body} />
619 </Box>
620 ))}
621 {d.reviews.map((r, i) => (
622 <Box key={`r:${i}`} flexDirection="column">
623 <Text bold>{`@${r.author.login} ${r.state.toLowerCase()} `}<Text dimColor>{r.submittedAt.slice(0, 10)}</Text></Text>
624 {r.body?.trim() ? <Markdown text={r.body} /> : null}
625 </Box>
626 ))}
627 {d.comments.map((c, i) => (
628 <Box key={`m:${i}`} flexDirection="column">
629 <Text bold>{`@${c.author.login} `}<Text dimColor>{c.createdAt.slice(0, 10)}</Text></Text>
630 <Markdown text={c.body} />
631 </Box>
632 ))}
633 </Box>
634 )}
635 {pr.compose === "line" && Input && file ? (
636 <Input key="line" label={`${file.file} <line>: <comment>`} autoFocus
637 onSubmit={(v: string) => { setPr({ compose: undefined }, $); void lineComment($, cur.number, file.file, v); }} />
638 ) : null}
639 {pr.compose && pr.compose !== "line" && Input ? (
640 <Input key="body" label={pr.compose === "changes" ? "request changes" : "comment"} autoFocus
641 onSubmit={(v: string) => void prAction($, ["review", pr.compose === "changes" ? "--request-changes" : "--comment", "-b", v], pr.compose === "changes" ? "Request changes" : "Comment")} />
642 ) : null}
643 <Box gap={2} marginTop={1}>
644 <Button key="claude" plain dimColor hotkey="g" onPress={() => fillReview($, cur.number, cur.title)}>ask claude</Button>
645 <Button key="checkout" plain dimColor hotkey="o" onPress={() => void checkout($, cur.number)}>checkout</Button>
646 <Button key="approve" plain dimColor hotkey="a" onPress={() => void prAction($, ["review", "--approve"], "Approve")}>approve</Button>
647 <Button key="changes" plain dimColor hotkey="x" onPress={() => setPr({ compose: "changes" }, $)}>request changes</Button>
648 <Button key="comment" plain dimColor hotkey="c" onPress={() => setPr({ compose: "comment" }, $)}>comment</Button>
649 <Button key="merge" plain dimColor hotkey="m" onPress={() => void prAction($, ["merge", "--squash"], "Merge", `Squash-merge #${cur.number} into ${cur.baseRefName}?`)}>merge</Button>
650 <Button key="close" plain dimColor hotkey="z" onPress={() => void prAction($, ["close"], "Close", `Close #${cur.number} without merging?`)}>close</Button>
651 </Box>
652 </Box>
653 );
654 });
655
656 on("ui.render", { component: "Pane" }, ($, e, next) => {
657 if (e.requestId !== PANE || !cfg.security) return next(e);
658 const els = $.ui.resolve(e);
659 const { Box, Text, Button } = els;
660 const Select = "Select" in els ? els.Select : null; // mobile has no Select
661 const all = state.findings;
662 const rules = [...new Set(all.map(f => f.rule))];
663 const shown = live().filter(f => filter === "all" || f.rule === filter);
664 const hidden = all.filter(f => ignored.has(ignoreKey(f)));
665 const files = [...new Set(shown.map(f => f.file))];
666 const w = e.props.bodyColumns;
667 return (
668 <Box flexDirection="column" width={w}>
669 <Box gap={1}>
670 {Select ? (
671 <Select key="rule" label="rule" value={filter}
672 options={[{ value: "all", label: "all" }, ...rules.map(r => ({ value: r, label: r }))]}
673 onSelect={(v: string) => { filter = v; $.ui.invalidate("ui.render"); }} />
674 ) : null}
675 <Text dimColor>{`${shown.length} shown · ${hidden.length} ignored`}</Text>
676 </Box>
677 {files.map(file => (
678 <Box key={file} flexDirection="column" marginTop={1}>
679 <Text bold>{file}</Text>
680 {shown.filter(f => f.file === file).map(f => {
681 const k = ignoreKey(f);
682 return (
683 <Box key={k} gap={1}>
684 <Box flexGrow={1} flexShrink={1} minWidth={0}>
685 <Text wrap="truncate">
686 <Text dimColor>{`L${f.line} `}</Text>
687 <Text color={SEV_COLOR[f.sev]}>{`[${f.sev}] `}</Text>
688 {`${f.rule}: ${f.text}`}
689 {triage.get(k)?.why ? <Text dimColor>{` — ${triage.get(k)!.why}`}</Text> : null}
690 </Text>
691 </Box>
692 <Box flexShrink={0} gap={1}>
693 <Button key={`fix:${k}`} plain dimColor onPress={() => fillFix($, f)}>fix</Button>
694 <Button key={`ign:${k}`} plain dimColor onPress={() => void setIgnored($, k, true)}>ignore</Button>
695 </Box>
696 </Box>
697 );
698 })}
699 </Box>
700 ))}
701 {hidden.length ? (
702 <Box flexDirection="column" marginTop={1}>
703 <Text dimColor bold>{`ignored (${hidden.length})`}</Text>
704 {hidden.map(f => {
705 const k = ignoreKey(f);
706 return (
707 <Box key={`h:${k}`} gap={1}>
708 <Box flexGrow={1} flexShrink={1} minWidth={0}>
709 <Text dimColor wrap="truncate">{`${f.file}:${f.line} ${f.rule}: ${f.text}`}</Text>
710 </Box>
711 <Box flexShrink={0}>
712 <Button key={`un:${k}`} plain dimColor onPress={() => void setIgnored($, k, false)}>unignore</Button>
713 </Box>
714 </Box>
715 );
716 })}
717 </Box>
718 ) : null}
719 <Box gap={2} marginTop={1}>
720 <Button key="p-rescan" plain dimColor hotkey="r" onPress={() => void scan($)}>rescan</Button>
721 <Button key="p-clear" plain dimColor hotkey="c" onPress={() => void setIgnored($, null, false)}>clear ignores</Button>
722 <Text dimColor>Esc close</Text>
723 </Box>
724 </Box>
725 );
726 });
727};
728hooks/pr.ts 56 lines1// Pure helpers for the PR pane: no `$` here (the validator wants `$` only in hooks.tsx).
2
3export type Check = { name?: string; context?: string; conclusion?: string; state?: string; status?: string };
4export type Pr = {
5 number: number; title: string; author: { login: string }; headRefName: string; baseRefName: string;
6 isDraft: boolean; updatedAt: string; reviewDecision: string; statusCheckRollup: Check[];
7 additions: number; deletions: number; changedFiles: number; url: string;
8};
9export type ReviewComment = { path: string; line?: number | null; body: string; user: { login: string } };
10export type PrDetail = {
11 body: string; mergeable: string; mergeStateStatus: string; statusCheckRollup: Check[]; headRefOid: string;
12 reviews: { author: { login: string }; state: string; body: string; submittedAt: string }[];
13 comments: { author: { login: string }; body: string; createdAt: string }[];
14 files: { path: string; additions: number; deletions: number }[];
15};
16export type DiffFile = { file: string; hunks: string };
17
18export function parseDiff(text: string): DiffFile[] {
19 const out: DiffFile[] = [];
20 for (const chunk of text.split(/^(?=diff --git )/m)) {
21 const m = /^diff --git a\/.+? b\/(.+)$/m.exec(chunk);
22 if (!m || /^Binary files .* differ$/m.test(chunk)) continue;
23 const at = chunk.indexOf("\n@@");
24 if (at < 0) continue;
25 out.push({ file: m[1]!, hunks: chunk.slice(at + 1).replace(/\n$/, "") });
26 }
27 return out;
28}
29
30// Code element takes at most 10000 chars; cut at a line boundary and say how much went
31export function capHunks(hunks: string, max = 9500): { source: string; dropped: number } {
32 if (hunks.length <= max) return { source: hunks, dropped: 0 };
33 const cut = hunks.lastIndexOf("\n", max);
34 const source = hunks.slice(0, cut > 0 ? cut : max);
35 return { source, dropped: hunks.slice(source.length).split("\n").length - 1 };
36}
37
38export function checkSummary(rollup: Check[] | null | undefined): { ok: number; fail: number; pending: number } {
39 const s = { ok: 0, fail: 0, pending: 0 };
40 for (const c of rollup ?? []) {
41 const v = (c.conclusion || c.state || "").toUpperCase();
42 if (v === "SUCCESS" || v === "NEUTRAL" || v === "SKIPPED") s.ok++;
43 else if (v === "FAILURE" || v === "ERROR" || v === "TIMED_OUT" || v === "CANCELLED" || v === "ACTION_REQUIRED") s.fail++;
44 else s.pending++;
45 }
46 return s;
47}
48
49export const checkGlyphs = (rollup: Check[] | null | undefined): string => {
50 const { ok, fail, pending } = checkSummary(rollup);
51 return [ok && `✓${ok}`, fail && `✗${fail}`, pending && `●${pending}`].filter(Boolean).join(" ") || "no checks";
52};
53
54export const decision = (d: string): string =>
55 ({ APPROVED: "approved", CHANGES_REQUESTED: "changes requested", REVIEW_REQUIRED: "review required" } as Record<string, string>)[d] ?? "no review";
56hooks/rules.ts 32 lines1// Built-in scan rules. Extra rules come from the `rulesFile` setting: a JSON array of
2// { "name": "...", "pattern": "regex source", "flags": "i", "sev": "high" | "med" | "low" }.
3export type Sev = "high" | "med" | "low";
4export type Rule = { name: string; re: RegExp; sev: Sev };
5export type RuleJson = { name: string; pattern: string; flags?: string; sev: Sev };
6
7export const BUILTIN: RuleJson[] = [
8 { name: "secret", pattern: "(api[_-]?key|secret|password|token)\\s*[:=]\\s*['\"][^'\"]{8,}", flags: "i", sev: "high" },
9 { name: "private key", pattern: "-----BEGIN [A-Z ]*PRIVATE KEY-----", sev: "high" },
10 { name: "eval", pattern: "\\beval\\s*\\(|new Function\\s*\\(", sev: "high" },
11 { name: "shell exec", pattern: "\\b(exec|execSync|spawn)\\s*\\(|subprocess\\.(call|run|Popen)\\(.*shell\\s*=\\s*True|os\\.system\\(", sev: "high" },
12 { name: "sql concat", pattern: "(SELECT|INSERT|UPDATE|DELETE)\\b[^;\\n]*(\\+\\s*\\w|\\$\\{|%s|\\.format\\()", flags: "i", sev: "high" },
13 { name: "pickle/yaml", pattern: "pickle\\.loads?\\(|yaml\\.load\\((?!.*SafeLoader)", sev: "high" },
14 { name: "innerHTML", pattern: "dangerouslySetInnerHTML|\\.innerHTML\\s*=", sev: "med" }, // sec-ignore
15 { name: "tls off", pattern: "rejectUnauthorized\\s*:\\s*false|verify\\s*=\\s*False|InsecureSkipVerify\\s*:\\s*true", sev: "med" },
16 { name: "chmod 777", pattern: "chmod\\s+(-R\\s+)?777|0o?777\\b", sev: "med" }, // sec-ignore
17 { name: "http url", pattern: "['\"]http://(?!localhost|127\\.0\\.0\\.1)", sev: "low" },
18];
19
20// Compiles rule JSON; returns the good ones and a message per bad one.
21export function compileRules(json: unknown): { rules: Rule[]; errors: string[] } {
22 const rules: Rule[] = [], errors: string[] = [];
23 if (!Array.isArray(json)) return { rules, errors: ["rules must be a JSON array"] };
24 json.forEach((r: Partial<RuleJson>, i) => {
25 const sev = (["high", "med", "low"] as const).find(s => s === r.sev);
26 if (typeof r.name !== "string" || typeof r.pattern !== "string" || !sev) { errors.push(`rule ${i}: need name, pattern, sev`); return; }
27 try { rules.push({ name: r.name, re: new RegExp(r.pattern, r.flags ?? ""), sev }); }
28 catch (e) { errors.push(`rule "${r.name}": ${(e as Error).message}`); }
29 });
30 return { rules, errors };
31}
32hooks/raster.ts 73 lines1// Raster cell packing and the two little pictures the band draws: a heat strip and confetti.
2const DEFAULT = 0x01000000; // terminal's own colour
3const BLOCK = 0x2588, SPACE = 0x20;
4
5export type Cell = [cp: number, fg: number, bg?: number];
6
7export function pack(cells: Cell[]): string {
8 const words = new Uint32Array(cells.length * 3);
9 cells.forEach(([cp, fg, bg], i) => { words[i * 3] = cp; words[i * 3 + 1] = fg; words[i * 3 + 2] = bg ?? DEFAULT; });
10 return b64(new Uint8Array(words.buffer));
11}
12
13// Uint8Array.toBase64 is Node 26+; the plugin runtime has it, CI's Node may not
14const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
15function b64(bytes: Uint8Array): string {
16 const native = (bytes as Uint8Array & { toBase64?: () => string }).toBase64;
17 if (native) return native.call(bytes);
18 let out = "";
19 for (let i = 0; i < bytes.length; i += 3) {
20 const a = bytes[i]!, b = bytes[i + 1], c = bytes[i + 2];
21 const n = (a << 16) | ((b ?? 0) << 8) | (c ?? 0);
22 out += B64[n >> 18]! + B64[(n >> 12) & 63]! + (b === undefined ? "=" : B64[(n >> 6) & 63]!) + (c === undefined ? "=" : B64[n & 63]!);
23 }
24 return out;
25}
26
27export type HeatFile = { lines: number; hot: boolean };
28const churnColor = (lines: number, hot: boolean) => hot ? 0xef4444 : lines < 20 ? 0x22c55e : lines < 100 ? 0xeab308 : 0xf97316;
29
30// A treemap strip: hot files first, then by churn; each file's width is proportional to its
31// lines changed (at least one cell); a blank separator between files when there is room.
32export function heatStrip(files: HeatFile[], columns: number): { cells: string; columns: number } {
33 const sorted = [...files].sort((a, b) => Number(b.hot) - Number(a.hot) || b.lines - a.lines);
34 const n = Math.min(sorted.length, columns);
35 const shown = sorted.slice(0, n);
36 const gaps = n * 2 - 1 <= columns ? n - 1 : 0;
37 const room = columns - gaps;
38 const total = shown.reduce((s, f) => s + Math.max(1, f.lines), 0) || 1;
39 // proportional widths, min 1, then trim the widest until it fits
40 const widths = shown.map(f => Math.max(1, Math.round((Math.max(1, f.lines) / total) * room)));
41 while (widths.reduce((a, b) => a + b, 0) > room) widths[widths.indexOf(Math.max(...widths))]!--;
42 const cells: Cell[] = [];
43 shown.forEach((f, i) => {
44 if (i && gaps) cells.push([SPACE, DEFAULT]);
45 for (let k = 0; k < widths[i]!; k++) cells.push([BLOCK, churnColor(f.lines, f.hot)]);
46 });
47 return { cells: pack(cells), columns: cells.length };
48}
49
50// one cell per item, newest last; more items than columns keeps the newest
51export function dotRow(colors: number[], columns: number): { cells: string; columns: number } {
52 const shown = colors.slice(-columns);
53 return { cells: pack(shown.map(c => [BLOCK, c])), columns: shown.length };
54}
55export const DOT = { ok: 0x22c55e, fail: 0xef4444, pending: 0xeab308, edit: 0x3b82f6, test: 0x22c55e, abort: 0xef4444, idle: 0x475569 };
56
57const CONFETTI = [0xef4444, 0xf97316, 0xeab308, 0x22c55e, 0x3b82f6, 0xa855f7, 0xec4899];
58const GLYPHS = [0x2022, 0x2736, 0x25cf, 0x2731, 0x25a0, 0x2666]; // • ✶ ● ✱ ■ ♦
59
60// frame t of a 1-row burst: density fades as t grows; deterministic so tests can pin it
61export function confetti(columns: number, t: number, frames: number): string {
62 const cells: Cell[] = [];
63 let seed = t * 7919 + 17;
64 const rnd = () => (seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff;
65 const density = 0.6 * (1 - t / frames);
66 for (let i = 0; i < columns; i++) {
67 cells.push(rnd() < density ? [GLYPHS[Math.floor(rnd() * GLYPHS.length)]!, CONFETTI[Math.floor(rnd() * CONFETTI.length)]!] : [SPACE, DEFAULT]);
68 }
69 return pack(cells);
70}
71
72export const SPIN = ["◐", "◓", "◑", "◒"];
73hooks/config.ts 19 lines1// Plugin settings, filled from plugin.json userConfig at register(). Shared by every module.
2export type Guard = "warn" | "deny" | "off";
3export type StripMode = "churn" | "ci" | "timeline";
4export const STRIP_MODES: StripMode[] = ["churn", "ci", "timeline"];
5export const cfg = { guard: "warn" as Guard, base: "", mine: true, pollSeconds: 60, rulesFile: "", triage: true, security: true, strip: "churn" as StripMode };
6
7export function readConfig(options: Readonly<Record<string, unknown>>): void {
8 Object.assign(cfg, {
9 guard: (["warn", "deny", "off"] as const).find(g => g === options.guard) ?? "warn",
10 base: typeof options.base === "string" ? options.base.trim() : "",
11 mine: options.mine !== false,
12 pollSeconds: Math.max(15, Number(options.pollSeconds) || 60),
13 rulesFile: typeof options.rulesFile === "string" ? options.rulesFile.trim() : "",
14 triage: options.triage !== false,
15 security: options.security !== false,
16 strip: STRIP_MODES.find(m => m === options.strip) ?? "churn",
17 });
18}
19hooks/qr.ts 147 lines1// Minimal QR encoder: byte mode, error correction L, versions 1-5 (up to 106 bytes), best of 8 masks.
2// Enough for a URL; anything longer returns null. ISO 18004, nothing clever.
3import { pack, type Cell } from "./raster.ts";
4
5const CAP = [0, 17, 32, 53, 78, 106]; // max bytes per version at EC L
6const DATA_CW = [0, 19, 34, 55, 80, 108]; // data codewords (single block at EC L)
7const EC_CW = [0, 7, 10, 15, 20, 26];
8const ALIGN = [0, 0, 18, 22, 26, 30]; // alignment centre for v2..v5
9
10// GF(256) with 0x11d
11const EXP = new Uint8Array(512), LOG = new Uint8Array(256);
12for (let i = 0, x = 1; i < 255; i++) { EXP[i] = x; LOG[x] = i; x <<= 1; if (x & 0x100) x ^= 0x11d; }
13for (let i = 255; i < 512; i++) EXP[i] = EXP[i - 255]!;
14const mul = (a: number, b: number) => (a && b) ? EXP[LOG[a]! + LOG[b]!]! : 0;
15
16function ecBytes(data: number[], n: number): number[] {
17 let gen = [1];
18 for (let i = 0; i < n; i++) {
19 const next = new Array(gen.length + 1).fill(0);
20 gen.forEach((g, j) => { next[j] ^= g; next[j + 1] ^= mul(g, EXP[i]!); });
21 gen = next;
22 }
23 const res = new Array(n).fill(0);
24 for (const d of data) {
25 const f = d ^ res.shift()!; res.push(0);
26 if (f) gen.slice(1).forEach((g, j) => { res[j] ^= mul(g, f); });
27 }
28 return res;
29}
30
31function formatBits(mask: number): number { // EC L (01) + mask -> 5 bits, BCH(15,5), then the fixed XOR
32 const d = 0b01000 | mask; let r = d << 10;
33 for (let i = 14; i >= 10; i--) if (r >> i & 1) r ^= 0x537 << (i - 10);
34 return ((d << 10) | r) ^ 0x5412;
35}
36
37export function qrMatrix(text: string): boolean[][] | null {
38 const bytes = [...new TextEncoder().encode(text)];
39 const v = CAP.findIndex((c, i) => i > 0 && bytes.length <= c);
40 if (v < 1) return null;
41 const size = 17 + 4 * v;
42
43 // bit stream: mode 0100, count, bytes, terminator, pad
44 const bits: number[] = [];
45 const push = (val: number, n: number) => { for (let i = n - 1; i >= 0; i--) bits.push(val >> i & 1); };
46 push(4, 4); push(bytes.length, 8); bytes.forEach(b => push(b, 8));
47 const total = DATA_CW[v]! * 8;
48 push(0, Math.min(4, total - bits.length));
49 while (bits.length % 8) bits.push(0);
50 const data: number[] = [];
51 for (let i = 0; i < bits.length; i += 8) data.push(parseInt(bits.slice(i, i + 8).join(""), 2));
52 for (let p = 0xec; data.length < DATA_CW[v]!; p ^= 0xfd) data.push(p);
53 const cw = [...data, ...ecBytes(data, EC_CW[v]!)];
54
55 const m: boolean[][] = Array.from({ length: size }, () => Array(size).fill(false));
56 const fn: boolean[][] = Array.from({ length: size }, () => Array(size).fill(false)); // function-module mask
57 const set = (r: number, c: number, on: boolean) => { m[r]![c] = on; fn[r]![c] = true; };
58 const finder = (r0: number, c0: number) => {
59 for (let r = -1; r <= 7; r++) for (let c = -1; c <= 7; c++) {
60 const rr = r0 + r, cc = c0 + c;
61 if (rr < 0 || cc < 0 || rr >= size || cc >= size) continue;
62 const on = r >= 0 && r <= 6 && c >= 0 && c <= 6 && (r === 0 || r === 6 || c === 0 || c === 6 || (r >= 2 && r <= 4 && c >= 2 && c <= 4));
63 set(rr, cc, on);
64 }
65 };
66 finder(0, 0); finder(0, size - 7); finder(size - 7, 0);
67 for (let i = 8; i < size - 8; i++) { set(6, i, i % 2 === 0); set(i, 6, i % 2 === 0); }
68 if (v >= 2) {
69 const a = ALIGN[v]!;
70 for (let r = -2; r <= 2; r++) for (let c = -2; c <= 2; c++) set(a + r, a + c, Math.max(Math.abs(r), Math.abs(c)) !== 1);
71 }
72 set(size - 8, 8, true); // dark module
73 // reserve the format areas so data skips them; written per mask below
74 for (let i = 0; i < 15; i++) { placeFormat(size, i, false, (r, c, b) => set(r, c, b)); }
75 // data placement: zigzag column pairs, alternating upward / downward
76 const order: [number, number][] = [];
77 let up = true;
78 for (let right = size - 1; right >= 1; right -= 2, up = !up) {
79 if (right === 6) right = 5;
80 for (let i = 0; i < size; i++) {
81 const r = up ? size - 1 - i : i;
82 for (const c of [right, right - 1]) if (!fn[r]![c]) order.push([r, c]);
83 }
84 }
85 const stream = cw.flatMap(b => Array.from({ length: 8 }, (_, i) => b >> (7 - i) & 1));
86 const MASKS: ((r: number, c: number) => boolean)[] = [
87 (r, c) => (r + c) % 2 === 0, (r) => r % 2 === 0, (_, c) => c % 3 === 0, (r, c) => (r + c) % 3 === 0,
88 (r, c) => (Math.floor(r / 2) + Math.floor(c / 3)) % 2 === 0, (r, c) => (r * c) % 2 + (r * c) % 3 === 0,
89 (r, c) => ((r * c) % 2 + (r * c) % 3) % 2 === 0, (r, c) => ((r + c) % 2 + (r * c) % 3) % 2 === 0,
90 ];
91 let best: boolean[][] | null = null, bestScore = Infinity;
92 for (let mask = 0; mask < 8; mask++) {
93 const g = m.map(row => [...row]);
94 order.forEach(([r, c], i) => { g[r]![c] = ((stream[i] ?? 0) === 1) !== MASKS[mask]!(r, c); });
95 const f = formatBits(mask);
96 for (let i = 0; i < 15; i++) placeFormat(size, i, (f >> (14 - i) & 1) === 1, (r, c, b) => { g[r]![c] = b; });
97 const score = penalty(g);
98 if (score < bestScore) { bestScore = score; best = g; }
99 }
100 return best;
101}
102
103// format info, both copies; i counts from the most significant bit
104function placeFormat(size: number, i: number, bit: boolean, set: (r: number, c: number, b: boolean) => void): void {
105 if (i < 6) set(8, i, bit); else if (i < 8) set(8, i + 1, bit); else if (i === 8) set(7, 8, bit); else set(14 - i, 8, bit);
106 if (i < 7) set(size - 1 - i, 8, bit); else set(8, size - 15 + i, bit);
107}
108
109// ISO 18004 mask penalty: runs, 2x2 blocks, finder-like patterns, dark balance
110function penalty(g: boolean[][]): number {
111 const n = g.length; let p = 0;
112 const line = (get: (i: number, j: number) => boolean) => {
113 for (let i = 0; i < n; i++) {
114 let run = 1;
115 for (let j = 1; j <= n; j++) {
116 if (j < n && get(i, j) === get(i, j - 1)) run++;
117 else { if (run >= 5) p += run - 2; run = 1; }
118 }
119 const bits = Array.from({ length: n }, (_, j) => get(i, j) ? 1 : 0).join("");
120 for (const pat of ["10111010000", "00001011101"]) for (let k = bits.indexOf(pat); k >= 0; k = bits.indexOf(pat, k + 1)) p += 40;
121 }
122 };
123 line((i, j) => g[i]![j]!); line((i, j) => g[j]![i]!);
124 for (let r = 0; r < n - 1; r++) for (let c = 0; c < n - 1; c++) {
125 const v = g[r]![c]; if (g[r]![c + 1] === v && g[r + 1]![c] === v && g[r + 1]![c + 1] === v) p += 3;
126 }
127 const dark = g.flat().filter(Boolean).length;
128 p += Math.floor(Math.abs(dark * 100 / (n * n) - 50) / 5) * 10;
129 return p;
130}
131
132// two modules per cell with half blocks, four-module quiet zone, explicit black on white
133export function qrRaster(text: string): { cells: string; columns: number; rows: number } | null {
134 const m = qrMatrix(text);
135 if (!m) return null;
136 const q = 4, n = m.length + q * 2;
137 const at = (r: number, c: number) => { const rr = r - q, cc = c - q; return rr >= 0 && cc >= 0 && rr < m.length && cc < m.length && m[rr]![cc]!; };
138 const rows = Math.ceil(n / 2);
139 const cells: Cell[] = [];
140 const W = 0xffffff, B = 0x000000;
141 for (let r = 0; r < rows; r++) for (let c = 0; c < n; c++) {
142 const top = at(2 * r, c), bot = 2 * r + 1 < n ? at(2 * r + 1, c) : false;
143 cells.push(top === bot ? [0x2588, top ? B : W, top ? B : W] : [0x2580, top ? B : W, bot ? B : W]); // █ or ▀ (fg top, bg bottom)
144 }
145 return { cells: pack(cells), columns: n, rows };
146}
147