SLOPSHOPPER

prdeck

PR feed, review pane and security checks above the prompt

newpanebandguardcommandtoast
★ 1v0.10.1MITupdated 2026-09-21settivishal/prdeck/prdeck
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · prdeck
› fix the failing auth test and add an audit log call ● prdeck: prdeck gh: JSON Parse error: Unexpected EOF ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /prdeck ⎿ prdeck: security: clean (vs origin/main) ⎿ prdeck: PRs: no GitHub remote ○ security 0 findings 1: details 3: churn █████████████████████████████████████████████████████████████████ ██████████████ 2 files · +14/−3 ⇄ PRs: JSON Parse error: Unexpected EOF ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
○ security 0 findings 1: details 3: churn █████████████████████████████████████████████████████████████████ ██████████████ 2 files · +14/−3 ⇄ PRs: JSON Parse error: Unexpected EOF ⟨Claude Code's own drawing⟩
README

prdeck

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

Install

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.

What it does

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:

  • churn — a treemap of your branch: one bar per changed file, width by lines changed, hot files (with a finding) first in red, then orange > yellow > green; totals at the end; hover lists the hot files.
  • ci — the last 30 workflow runs on your current branch, one cell each, newest right: green ok, red failed, yellow running; hover shows the latest five by name.
  • timeline — one cell per turn this session: blue edited files, green ran tests, red aborted or refused, grey neither.

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:

keypaneinside
1findingsfilter by rule · fix fills the prompt · ignore / unignore (persisted) · r rescan · c clear ignores
2pull requestsview 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.

Configure

/config → prdeck:

settingdefault
Security scanningonoff hides the security row, strip and pane; PR feed stays
Write/Edit guardwarnwarn · deny · off
Base branchautoorigin/main, origin/master, main, master in that order
Only my PRsonoff lists every open PR
PR poll interval60 sminimum 15
Strip modechurnchurn · ci · timeline — what the strip shows at start
Haiku triageonrate 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

How it works

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.

License

MIT

Source 6 files
hooks/hooks.tsx 728 lines
1import 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};
728
hooks/pr.ts 56 lines
1// 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";
56
hooks/rules.ts 32 lines
1// 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}
32
hooks/raster.ts 73 lines
1// 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 = ["◐", "◓", "◑", "◒"];
73
hooks/config.ts 19 lines
1// 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}
19
hooks/qr.ts 147 lines
1// 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