pr-review live: band, pane and spinner timer for a running review (Claude Code mod)

<img src="assets/icon.svg" width="88" height="88" alt="pr-review" />
<h1 align="center">pr-review</h1>
<a href="https://github.com/guimatheus92/pr-review/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/guimatheus92/pr-review/ci.yml?branch=main&label=CI&logo=githubactions&logoColor=white&labelColor=1e1e2e&color=22d3ee" alt="CI" /></a> <a href="https://github.com/guimatheus92/pr-review/releases"><img src="https://img.shields.io/github/v/release/guimatheus92/pr-review?label=release&logo=github&logoColor=white&labelColor=1e1e2e&color=8b5cf6" alt="release" /></a> <a href="LICENSE"><img src="https://img.shields.io/github/license/guimatheus92/pr-review?labelColor=1e1e2e&color=6366f1" alt="license" /></a> <img src="https://img.shields.io/badge/node-%3E%3D20-8b5cf6?logo=node.js&logoColor=white&labelColor=1e1e2e" alt="node >=20" />
<img src="https://img.shields.io/badge/plugin-Copilot%20CLI%20%C2%B7%20Claude%20Code-6366f1?labelColor=1e1e2e" alt="runs as a Copilot CLI or Claude Code plugin" /> <img src="https://img.shields.io/badge/reviews-GitHub%20%C2%B7%20Azure%20DevOps%20%C2%B7%20GitLab-22d3ee?labelColor=1e1e2e" alt="reviews GitHub, Azure DevOps and GitLab pull requests" />
pr-review is a plugin for Copilot CLI or Claude Code that reviews a pull request with parallel review passes — each one a skill drawn from synced skill packs or your own repo — inside a single agent session, retains every deduplicated finding locally, and posts every publication-eligible finding as a resolvable inline review comment: never a top-level comment. All findings are eligible by default. A thin Node CLI does the deterministic plumbing (gather, dedupe, post); the agents only review. When the codex CLI is installed, a Codex second-opinion reviewer runs alongside automatically (--no-codex opts out).
/pr-review https://github.com/org/repo/pull/123
/pr-review https://dev.azure.com/org/proj/_git/repo/pullrequest/456 --dry-run
<img src="assets/live-demo.gif" width="880" alt="A dry-run review of a pull request in Claude Code with the live mod: the band above the prompt shows the phase, the timer and one bar cell per reviewer; the pane groups every reviewer under its source; the findings close the clip" />
awesome-copilot, owasp, …) and in your repo's own skill dirs..claude/skills, .github/instructions, .agents/skills… files become authoritative project rules for every pass — no cap, never truncated. See add your own rules.--context-only..pr-review.yaml or repository MCP config is ignored too.--detach returns a run-id, status polls it, and --resume re-dispatches only what is missing under authenticated run state. Partial findings never post./pr-review-live opens the pane or answers in text. See live view.--runtime), same /pr-review.flowchart TD
U["/pr-review #lt;pr-url#gt;"] --> G["gather metadata, comments and diff (cached)<br/>early-exit gate: malformed or too large → exit 2 + error.txt"]
G --> L["load skills + detect stack<br/>repo dirs · skill packs · installed plugins<br/>(rules the PR itself changed are dropped as untrusted)"]
L --> P["select passes<br/>project skills → context in every pass<br/>pack passes by evidence tier (cap 6) + up to 2 installed-plugin passes + every baseline<br/>everything else → skills-index.md"]
P --> T{"anything to dispatch?<br/>(a docs-only PR keeps only glob/forced passes)"}
T -- "no pass matched" --> E0["exit 2 — nothing to review with<br/>(packs suggest hint)"]
T -- "docs-only, nothing doc-scoped" --> E0b["exit 0 — nothing to review"]
T -- yes --> D["one dispatch-only agent session (Copilot CLI or Claude Code)<br/>one task() per pass, companion and the PR-description check → attempt-N.json"]
D -. optional, parallel .-> C["Codex sibling (read-only)"]
D --> N["Node validates and promotes write-once raw-#lt;reviewer#gt;.json<br/>one automatic recovery session for missing or invalid reviewers only"]
C --> N
N -- still incomplete --> E2["exit 2 — partial findings never post<br/>(--resume makes the bounded final attempt)"]
N -- complete --> V["direct verifier session<br/>(only when Phase 1 has CRITICAL/HIGH findings)"]
V --> DD["dedupe all findings against original comments<br/>persist every final finding"]
DD --> EL["partition publication eligibility<br/>authenticated --publish-min-severity"]
EL -- "--dry-run" --> SUM["print every retained finding + eligibility counts"]
EL -- publish --> POST["post eligible pending findings only<br/>GitHub review · ADO threads · GitLab discussions"]
SUM --> EX["exit 0 · 1 (findings ≥ --fail-on) · 2"]
POST --> EX
Why a CLI, not just a skill? LLMs are unreliable at gathering metadata, deduplicating findings, and posting comments. The Node CLI handles those deterministic tasks; review passes only do the actual reviewing. The orchestrator session is dispatch-only — it cannot assemble findings, decide the verifier, or post. Node also prepares what a confined pass cannot fetch: the shared pr-context.md names every excluded file, digests a changed package-lock.json (packages that lost resolved/integrity, changed version or dev/optional flags), and lists where the declarations the PR touches are called. See the architecture for the full execution model.
Every run reports which skills it used — a progress brief at dispatch (N pass(es) · M project rule(s) · K on-demand) and a ## Skills section in the final summary (with a Skipped: S segment when any pass was skipped):
## Skills
**Passes:** 9 · **Project rules (in every pass):** 2 · **On-demand (index):** 41
| Pass | Matched by |
|---|---|
| awesome-copilot/go | glob |
| owasp/error-handling | baseline |
| … | … |
Same marketplace and plugin id in both hosts; the command differs, because the Copilot CLI has no /plugin.
Copilot CLI — in your shell:
copilot plugin marketplace add guimatheus92/pr-review
copilot plugin install pr-review@pr-review
Claude Code — slash commands inside a claude session:
/plugin marketplace add guimatheus92/pr-review
/plugin install pr-review@pr-review
No npm install needed. The plugin ships a pre-bundled dist/cli.cjs; the slash command runs it with node.
The slash command finds the bundle via $CLAUDE_PLUGIN_ROOT under Claude Code (falling back to ~/.copilot/installed-plugins/) and runs it with node. The plugin layout (commands/, skills/) loads in both hosts; the manifest lives in two places on purpose — .claude-plugin/plugin.json (Claude Code's canonical location) and a root plugin.json (which Copilot CLI requires). Only the Claude Code manifest declares the live-view mod (mods/live/, through its hooks field), so Copilot CLI and Claude Code older than 2.1.287 load the rest of the plugin and ignore it.
Copilot CLI exposes the command as /pr-review. Claude Code namespaces plugin commands as /pr-review:pr-review; since the plugin no longer ships agents, current Claude Code may also register the bare /pr-review alias. If your host doesn't, drop a personal command at ~/.claude/commands/pr-review.md as a fallback (personal commands have no namespace, so /pr-review resolves):
---
description: Bare alias for the pr-review plugin command.
argument-hint: "<pr-url> [flags]"
allowed-tools: ["Bash"]
---
Run the pr-review CLI in the background and poll it (you are plumbing, not the reviewer):
CLI=$(find ~/.claude/plugins/cache -name cli.cjs -path '*/pr-review/*/dist/*' -not -path '*/node_modules/*' 2>/dev/null | sort | tail -1) node "$CLI" review $ARGUMENTS --detach
If the output has a `run-id:`, poll `node "$CLI" status <run-id>` (~25s apart) until it prints the summary; otherwise print the output verbatim.
git clone https://github.com/guimatheus92/pr-review && cd pr-review
npm install && npm run build
# inside copilot (or claude — same slash commands; claude also accepts `claude plugin marketplace add ./` from the shell):
/plugin marketplace add .
/plugin install pr-review@pr-review
| Provider | Env var | Fallback |
|---|---|---|
| GitHub | GITHUB_TOKEN / GH_TOKEN / COPILOT_GITHUB_TOKEN | gh auth token |
| GitHub Enterprise Server | GH_ENTERPRISE_TOKEN / GITHUB_ENTERPRISE_TOKEN (github.com tokens are never sent to a GHES host) | gh auth token --hostname <host> |
| Azure DevOps | AZURE_DEVOPS_PAT / SYSTEM_ACCESSTOKEN / AZURE_DEVOPS_EXT_PAT (PAT), AZURE_DEVOPS_BEARER (bearer) | az account get-access-token |
| GitLab | GITLAB_TOKEN / GITLAB_ACCESS_TOKEN | glab config get token -h <host> |
--detach resolves the credential in the foreground and injects it into the background child's env, so the CLI/keyring fallbacks never have to run detached — a missing credential fails the launch immediately.
/pr-review https://github.com/<owner>/<repo>/pull/<number>
Posting line comments back to the PR is the default; add --dry-run to preview the findings instead.
https://github.com/<owner>/<repo>/pull/<number>
https://<ghes-host>/<owner>/<repo>/pull/<number> # GitHub Enterprise Server
https://dev.azure.com/<org>[/<project>]/_git/<repo>/pullrequest/<id>
https://<org>.visualstudio.com/[<collection>/][<project>/]_git/<repo>/pullrequest/<id> # legacy ADO
https://<server>/<collection>/<project>/_git/<repo>/pullrequest/<id> # Azure DevOps Server (best-effort)
https://gitlab.com/<group>[/<subgroup>]/<project>/-/merge_requests/<iid>
https://<gitlab-host>/<group>/<project>/-/merge_requests/<iid> # self-managed GitLab
Trailing paths, query strings, and fragments are ignored (…/pull/42/files?diff=split works). Self-hosted hosts must be mapped explicitly in the global config — a credential is only ever sent to a host you named, so a checkout-local hosts: map is ignored (the unrecognized-URL error prints the exact yaml to add):
# ~/.pr-review/config.yaml — global only; a hosts: map in .pr-review.yaml is ignored
hosts:
github.mycorp.com: github
tfs.corp.com: azuredevops
git.mycorp.com: gitlab
/pr-review <pr-url> # review with auto-discovered skills; posts line comments (default)
/pr-review <pr-url> --dry-run # preview findings without posting
/pr-review <pr-url> --skip owasp/logging # skip passes (full pack/skill or bare name; also: verifier, codex, pr-description)
/pr-review <pr-url> --context-only # prepare context + the pass files, print stack + pass table, don't dispatch
/pr-review <pr-url> --lang pt-BR # language for finding titles/bodies (default: en)
/pr-review <pr-url> --fail-on high # exit 1 if any high/critical finding survives dedupe
/pr-review <pr-url> --runtime claude # host the session in Claude Code instead of Copilot CLI
/pr-review <pr-url> --default-model <id> # request a runtime model instead of the normalized default
/pr-review <pr-url> --no-codex # skip the Codex second-opinion reviewer
/pr-review <pr-url> --no-companions # skip installed companion plugins (pr-review-toolkit, code-review)
/pr-review <pr-url> --detach # run in the background; poll with `pr-review status <run-id>`
Exit codes: 0 complete with no finding at/above --fail-on, 1 findings at/above --fail-on, 2 incomplete delivery or another operational failure. Partial findings never post. Without --fail-on, retained findings do not change the process status: exit 0 means the pipeline completed, not that the finding count is zero — configure --fail-on when the exit code must gate CI.
--publish-min-severity <severity> controls human-visible publication volume; --fail-on <severity> controls pipeline status. Neither changes what reviewers analyze. Complete delivery, the unchanged verifier, full deduplication, and persistence of every final finding precede publication filtering.
| Publication threshold (case-insensitive) | Eligible findings |
|---|---|
critical | CRITICAL |
high | CRITICAL, HIGH |
medium | CRITICAL, HIGH, MEDIUM |
low | Everything except NIT |
nit or omitted | Everything |
pr-review review "$PR_URL" --dry-run --publish-min-severity high
pr-review review "$PR_URL" --publish-min-severity high --fail-on medium
pr-review post "$PR_URL" --findings findings.json --publish-min-severity high
The first command is advisory and writes no comments. The second publishes HIGH+ while a MEDIUM finding still produces exit 1. All retained findings remain in pr-review-findings.json and the body-only local summary for reporting, research, and Hackathon adjudication. Suppressed findings are not skipped or dropped.
The summary reports Publication threshold: HIGH+ | Eligible: 3 / 11 | Suppressed: 8. The JSON adds publication: { minimumSeverity: "HIGH", eligibleCount: 3, suppressedCount: 8 } without changing finalFindings. Zero eligible findings record zero attempted posts; --fail-on still evaluates the full retained set.
Both flags are CLI-only. New schema-v2 plans authenticate the threshold and resume inherits it, including complete dry-run promotion. Explicit conflicts fail closed, even with --force-post. Older v1 runs remain publish-all and old binaries refuse v2 plans. Standalone post preserves its input bytes and uses only its explicit flag, never an implicit policy from artifact metadata. Omitted means publish all.
On a publish run (the default), every publication-eligible finding lands as a resolvable inline review thread — so each one can be discussed and resolved in place:
file:line in the comment body. On Azure DevOps, threads are posted at the reported file:line as-is (ADO threads are not limited to diff lines), and a finding with no location at all lands as a resolvable PR-level thread; a thread ADO rejects is reported as an error in the summary.--dry-run. A failed write is retried only after the PR has been read back — a failed write is not proof that nothing was written — and anything that still fails is reported as an error in the summary, never silently dropped.pr-review-summary.md in the run directory), never a comment on the PR.Every guarantee this tool makes, with why it exists and what verifies it, is in INVARIANTS.md. pr-review verify audits a finished run against the whole list.
There are no built-in reviewer agents. Every review pass is one skill applied by a generic agent inside the single orchestrator session, and the skills come from skill packs — git repos cloned to ~/.pr-review/packs/<name>/. Pass names are <pack>/<skill> (e.g. awesome-copilot/go, owasp/nodejs-security); repo skills keep their plain name.
Three packs are pre-configured:
| Pack | Source | Content | Baseline passes (run when nothing better matches) |
|---|---|---|---|
awesome-copilot | github/awesome-copilot | instructions/*.instructions.md, skills/*/SKILL.md | code-review-generic, security-and-owasp, performance-optimization, qa-engineering-best-practices, self-explanatory-code-commenting |
owasp | OWASP/CheatSheetSeries | cheatsheets/*.md | error-handling, logging |
anthropic-cybersecurity | mukul975/Anthropic-Cybersecurity-Skills | defensive-review skills (operational/offensive ones excluded) | none — mode: index, on-demand only, never a pass |
How passes are selected:
--force-skill passes. A code PR that matches zero passes exits 2 with a packs suggest hint.Manage packs from the CLI:
pr-review packs list # on-disk state, skill counts, commit, freshness
pr-review packs sync # clone/pull every pack + refresh the Linguist cache
pr-review packs add <owner/repo|url> # add a pack to the global config and clone it
pr-review packs suggest <tags...|pr-url> # search skills.sh by stack tags — suggest-only, never installs
The first review on a machine clones any missing packs automatically (needs git and network; failures are warnings, not errors; expect ~1–2 minutes once). Packs more than 30 days out of sync trigger a warning on every review — run packs sync.
Configure packs with the skill_packs: yaml key. Unlike every other list key (which appends across config levels), skill_packs replaces the whole list: skill_packs: [] disables packs entirely, and a list in a repo .pr-review.yaml overrides the global list. Entries are owner/repo shorthand or {git, name?, ref?, include?, exclude?, mode?, baseline?} (mode: index = on-demand only). See configuration.
Security note: packs are third-party prompt content read by agents with tool access. Pin ref: for reproducibility, and know the only install paths are packs add and editing skill_packs — packs suggest never installs anything.
Skip any pass with --skip <names> (full awesome-copilot/go or bare go; also verifier, codex, pr-description). The verifier remains a pipeline step: dispatched as a generic agent to reconcile across passes when phase 1 produces a CRITICAL/HIGH finding. The PR-description check (internal/pr-description) is the other brief pr-review ships: planned beside the skill passes on every review, it checks the PR's title and description against the diff and reports contradicted claims at MEDIUM or LOW.
Review rules live where your agent tools already keep skills — no separate folder, no duplication, no flags:
your-repo/
└── .claude/ # or .copilot/, .github/, .agents/
└── skills/
├── our-auth-conventions.md # injected into every pass when relevant
└── team-style-guide.md
skills-project.md — it overrides generic judgement), keeping its plain name. There is no numeric cap and skill bodies hooks/register.js 1022 lines1// pr-review live — a Claude Code mod that shows a running review as it happens.
2//
3// It is one more READER of the run directory (~/.pr-review/runs/<id>/): the same feeds
4// `pr-review status` reads, never `status` itself (which can reap processes), and it
5// writes nothing — not to the PR, not to the checkout, not to ~/.pr-review.
6//
7// What it draws (terminal and Desktop only; in the VS Code chat panel the hooks run but
8// nothing shows, and /pr-review-live answers in text; in `claude -p` it is inert):
9// AbovePrompt — three lines: PR + phase + timer; delivered counts; counts by source.
10// Pane — one row per reviewer (state, time, source), the project rules in context.
11// Spinner — ` · pr-review m:ss · delivered/planned…` while the run is live.
12// /pr-review-live [run-id | off] — attach (newest run by default) or detach.
13//
14// How a run gets attached — always a validated id that exists under the runs root:
15// the banner a `--detach` launch prints (`Review started in the background` + `run-id:`),
16// a `status <id>` poll or a `--resume <id>` typed into the Bash tool, or the command.
17//
18// Where the facts come from (src/util/progress.ts, src/dispatch/reviewer-progress.ts,
19// src/dispatch/delivery.ts, src/dispatch/pass-select.ts):
20// progress.ndjson phase + heartbeat {ts, phase, detail}
21// reviewer-progress.ndjson per-reviewer events {ts, kind, reviewer, attempt, …}
22// dispatch-plan.json the roster: reviewers[] {name, kind, source, matchedBy, maxAttempts}
23// delivery-state.json valid/invalid/missing, reviewerAttempts, runtimeAttempts[]
24// passes.json {name, source, matchedBy}[] — `context` rows are the project rules
25// error.txt the failure, when the run died before or without a terminal phase
26//
27// The run directory is written by the CLI, under the user's own account; the mod shows what
28// it finds there and decides nothing from it. `pr-review status` and the summary stay the
29// authoritative readings.
30//
31// The host reads on("<event>", …) and $.noun.method(…) from the source text, so every call
32// is spelled out in full and helpers that take $ are top-level functions.
33
34const PANE = 'pr-review-live';
35const REFRESH_EVERY_TICKS = 2; // one tick per second; the feeds change slower than that
36const HEARTBEAT_SILENCE_MS = 90_000; // 1.5x the 60 s `running` heartbeat the CLI appends
37const RUN_CEILING_MS = 2 * 60 * 60 * 1000; // stop polling a run that outlives any realistic review (30 min session, recovery, verifier)
38const STAMP_RE = /__(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z$/; // the UTC stamp ensureRunDir() ends a run id with
39const PACK_RE = /[\\/]\.pr-review[\\/]packs[\\/]([^\\/]+)[\\/]/; // <home>/.pr-review/packs/<pack>/… in a reviewer's source → the pack name
40const BANNER = 'Review started in the background'; // the first line a --detach launch prints (src/cli.ts)
41// The launch block the model runs prints a few `key: value` lines of its own before the CLI's
42// banner (`cli:`, `repo:`, `project skills discoverable:`) — the model composes that block
43// from commands/pr-review.md, so their names, order and count vary. A line that is not one of
44// those ends the preamble; a foreground summary starts with `# PR Review …`, never a key.
45const PREAMBLE_LINE_RE = /^[a-z][a-z0-9 -]{0,40}: .*$/;
46const PREAMBLE_MAX_LINES = 8;
47const RUN_ID_LINE_RE = /^ {2}run-id: (.+?)\r?$/m; // the banner's second line; an Azure DevOps id can hold a space
48const TERMINAL_PHASES = new Set(['done', 'error']);
49// C0, DEL and C1 controls, zero-width and bidi format characters, line/paragraph separators:
50// nothing from a skill name or a phase detail can move the cursor or reorder the terminal.
51const UNSAFE_RE = /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u2028-\u202e\u2060-\u2069\ufeff]/g;
52
53const GLYPH = {
54 pending: { glyph: '·', color: undefined, dim: true },
55 running: { glyph: '●', color: 'yellow', dim: false },
56 delivered: { glyph: '◐', color: 'yellow', dim: false },
57 done: { glyph: '✓', color: 'green', dim: false },
58 retry: { glyph: '↻', color: 'yellow', dim: false },
59 failed: { glyph: '✗', color: 'red', dim: false },
60};
61/** The bar above the prompt: one stretch of cells per reviewer, coloured by state; the legend uses the same colours. */
62const BAR = {
63 done: { cell: '█', color: 'green', dim: false },
64 delivered: { cell: '█', color: 'cyan', dim: false },
65 running: { cell: '▓', color: 'yellow', dim: false },
66 retry: { cell: '▓', color: 'magenta', dim: false },
67 failed: { cell: '█', color: 'red', dim: false },
68 pending: { cell: '░', color: undefined, dim: true },
69};
70const STATE_ORDER = ['done', 'delivered', 'running', 'retry', 'failed', 'pending'];
71const BAND_ROWS = 7; // border (2) + title, bar, legend, counts, sources: below this the band keeps title, bar and counts only
72
73let interactive = false;
74let surface = null;
75/**
76 * The attached run, or null. Module state: a reload drops it, and the next poll re-attaches.
77 * `state` is `live` (polling) until the run settles as `done`, `failed`, `preview` (a
78 * --context-only run) or `stale` (the polling ceiling); a settled run has no timer.
79 */
80let run = null;
81
82export function register(on) {
83 on('session.start', async ($, e, next) => {
84 interactive = e.isInteractive === true;
85 surface = e.surface ?? null;
86 // pr-review's own reviewer sessions are `claude -p` runs that load the user's plugins
87 // (INV-CTX-06 isolates Copilot only): there is nobody to draw for, so do nothing there.
88 if (!interactive) return next(e);
89 try {
90 await $.command.register({
91 name: 'pr-review-live',
92 description: 'Show the live progress of a running pr-review (band above the prompt + pane)',
93 argumentHint: '[run-id | off]',
94 immediate: true,
95 });
96 } catch (err) {
97 // A taken name or a host without commands: the band and pane still work.
98 debug($, 'command.register: ' + message(err));
99 }
100 return next(e);
101 });
102
103 on('session.end', async ($, e, next) => {
104 stopTimer(run);
105 run = null;
106 return next(e);
107 });
108
109 // An observer, never a gate: every command goes through unchanged, and a failure in the
110 // bookkeeping around it must neither block the command nor run it twice.
111 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
112 if (!interactive) return next(e);
113 const cmd = String(e.command ?? '');
114 try {
115 await attachFromCommand($, cmd);
116 } catch (err) {
117 debug($, 'attach from command: ' + message(err));
118 }
119 const result = await next(e);
120 try {
121 await attachFromBanner($, cmd, result);
122 } catch (err) {
123 debug($, 'attach from banner: ' + message(err));
124 }
125 return result;
126 }).catch(async ($, e, next) => {
127 // Fail open. `next` is replay-safe here: when the hook already called it, this resolves
128 // to the result Claude Code holds and runs nothing again; otherwise the command runs now.
129 return next(e);
130 });
131
132 on('command.run', { command: 'pr-review-live' }, async ($, e) => {
133 const arg = clean(String(e.args ?? '').trim());
134 try {
135 return await liveCommand($, arg);
136 } catch (err) {
137 debug($, 'command: ' + message(err));
138 return { text: 'pr-review live: could not run — ' + message(err) };
139 }
140 });
141
142 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
143 const r = run;
144 if (!r || e.props.hasSurvey) return next(e);
145 const { Box, Text } = $.ui.resolve(e);
146 const theirs = await next(e);
147 let mine;
148 try {
149 mine = band(Box, Text, r, e.props.bodyColumns || 80, e.props.maxRows || 10);
150 } catch (err) {
151 debug($, 'band: ' + message(err));
152 mine = Text({ dimColor: true, children: ['pr-review live: could not draw ' + clean(r.id) + ' — /pr-review-live off'] });
153 }
154 return theirs != null ? Box({ flexDirection: 'column', children: [mine, theirs] }) : mine;
155 });
156
157 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
158 if (e.requestId !== PANE) return next(e);
159 const r = run;
160 const { Box, Text } = $.ui.resolve(e);
161 if (!r) return Text({ dimColor: true, children: ['No pr-review run attached. /pr-review-live [run-id], or start a review.'] });
162 try {
163 return pane(Box, Text, r, e.props.bodyColumns || 80);
164 } catch (err) {
165 debug($, 'pane: ' + message(err));
166 return Text({ dimColor: true, children: ['pr-review live: could not draw ' + clean(r.id) + ' — /pr-review-live off'] });
167 }
168 });
169
170 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
171 const r = run;
172 if (!r || !isLive(r)) return next(e);
173 try {
174 const c = counts(r, reviewerRows(r));
175 return next({ ...e, props: { ...e.props, suffix: ' · pr-review ' + fmt(elapsedMs(r)) + ' · ' + c.delivered + '/' + c.planned + '…' } });
176 } catch (err) {
177 debug($, 'spinner: ' + message(err));
178 return next(e);
179 }
180 });
181}
182
183// The command
184
185async function liveCommand($, arg) {
186 if (arg === 'off') {
187 const id = run ? run.id : null;
188 await detachRun($);
189 return { text: id ? 'pr-review live: detached from ' + clean(id) : 'pr-review live: nothing was attached' };
190 }
191 let id = arg;
192 if (!id) {
193 id = await newestRunId($);
194 if (!id) {
195 const root = await runsRoot($);
196 return { text: root ? 'pr-review live: no run found under ' + root : 'pr-review live: no home directory (USERPROFILE or HOME) to find ~/.pr-review/runs' };
197 }
198 }
199 const problem = await attach($, id, false);
200 if (problem) return { text: 'pr-review live: ' + problem };
201 if (surface === 'terminal' || surface === 'desktop') {
202 let placed = true;
203 try {
204 const opened = await $.ui.open({ id: PANE, title: 'pr-review live', focus: true, closeOnEscape: true });
205 placed = !(opened && opened.isPlaced === false);
206 } catch (err) {
207 debug($, 'ui.open: ' + message(err));
208 placed = false;
209 }
210 // A pane the surface could not place (a narrow terminal, an older Desktop app) still
211 // leaves the band above the prompt; the text reply carries the same rows meanwhile.
212 return placed ? {} : { text: renderText(run) };
213 }
214 return { text: renderText(run) };
215}
216
217// Attach and poll
218
219/** `status <id>` polls and `--resume <id>` name the run up front, before the command runs. */
220async function attachFromCommand($, cmd) {
221 const status = /\bstatus\s+(?:"([^"]+)"|'([^']+)'|(\S+))/.exec(cmd);
222 if (status && (!run || !isLive(run))) {
223 const problem = await attach($, status[1] ?? status[2] ?? status[3], false);
224 if (problem) debug($, 'status poll: ' + problem);
225 }
226 const resume = /--resume\s+(?:"([^"]+)"|'([^']+)'|(\S+))/.exec(cmd);
227 if (resume) {
228 const problem = await attach($, resume[1] ?? resume[2] ?? resume[3], true);
229 if (problem) debug($, 'resume: ' + problem);
230 }
231}
232
233/** The output past the launch block's own `key: value` lines (and blank lines), at most PREAMBLE_MAX_LINES of them. */
234function pastPreamble(out) {
235 const lines = out.split('\n');
236 let i = 0;
237 while (i < lines.length && i < PREAMBLE_MAX_LINES) {
238 const line = lines[i].replace(/\r$/, '');
239 if (line.trim() !== '' && !PREAMBLE_LINE_RE.test(line)) break;
240 i += 1;
241 }
242 return lines.slice(i).join('\n');
243}
244
245/**
246 * A `--detach` launch prints a fixed banner first (src/cli.ts). Past the launch block's own
247 * `key: value` lines, only that banner at the start of stdout names a run. A foreground
248 * summary (`--resume`, `--context-only`) starts otherwise, and the finding bodies inside it
249 * are model output about PR content. Every reason not to attach goes to the debug file:
250 * a launch the band missed must be explainable from `claude --debug-file`.
251 */
252async function attachFromBanner($, cmd, result) {
253 if (!/\breview\b[\s\S]*--detach\b/.test(cmd)) return;
254 const out = pastPreamble(String((result && result.result && result.result.stdout) || ''));
255 if (!out.startsWith(BANNER)) {
256 debug($, 'launch without the detach banner at the start of its output: ' + firstLine(out));
257 return;
258 }
259 const id = RUN_ID_LINE_RE.exec(out);
260 if (!id) {
261 debug($, 'detach banner without a run-id line');
262 return;
263 }
264 const problem = await attach($, id[1], false);
265 if (problem) debug($, 'attach from banner: ' + problem);
266}
267
268/**
269 * A run id is one directory name under the runs root (`<provider>__<owner>__<repo>__<n>__<stamp>`,
270 * src/util/tmp.ts), so anything that could leave that directory, or hide in a terminal, is out.
271 */
272function validId(id) {
273 return (
274 id.length > 0 &&
275 id.length <= 255 &&
276 !id.includes('/') &&
277 !id.includes('\\') &&
278 !id.includes('..') &&
279 !id.startsWith('.') &&
280 !id.startsWith('-') &&
281 clean(id) === id
282 );
283}
284
285/** Follow one run. Resolves null once attached (or already followed live), else the problem, worded for the user. */
286async function attach($, id, resume) {
287 if (!validId(id)) return 'not a run id — ' + clean(id);
288 if (run && run.id === id && !resume && isLive(run)) return null;
289 const root = await runsRoot($);
290 if (!root) return 'no home directory (USERPROFILE or HOME) to find ~/.pr-review/runs';
291 const dir = root + sepOf(root) + id;
292 let exists = false;
293 try {
294 exists = await $.fs.exists(dir);
295 } catch (err) {
296 return 'cannot read ' + dir + ': ' + message(err);
297 }
298 if (!exists) return 'no run ' + id + ' under ' + root;
299 if (run && run.id === id && !resume && isLive(run)) return null;
300 stopTimer(run);
301 const r = {
302 id,
303 dir,
304 resume,
305 state: 'live',
306 startedAtMs: stampMs(id),
307 attachedAtMs: 0,
308 nowMs: 0,
309 endedAtMs: null,
310 ticks: 0,
311 timer: null,
312 plan: null,
313 passes: null,
314 delivery: null,
315 deliveryStamp: '',
316 progress: [],
317 reviewerProgress: [],
318 eventsAtAttach: -1,
319 sizes: {},
320 lastChangeMs: 0,
321 failure: null,
322 problems: new Map(),
323 };
324 run = r;
325 r.nowMs = await $.clock.now();
326 if (run !== r) return null;
327 r.attachedAtMs = r.nowMs;
328 r.lastChangeMs = r.nowMs;
329 await refresh($, r);
330 if (run !== r) return null;
331 if (isLive(r)) {
332 r.timer = $.clock.every(1000, () => {
333 tick($, r).catch((err) => {
334 debug($, 'tick: ' + message(err));
335 });
336 });
337 }
338 if (surface === 'terminal' || surface === 'desktop') {
339 try {
340 // Unasked, the pane waits for a wide terminal (>= 144 columns); the band shows anyway.
341 await $.ui.open({ id: PANE, title: 'pr-review live' });
342 } catch (err) {
343 // Nothing to do: the band carries the headline.
344 debug($, 'ui.open: ' + message(err));
345 }
346 }
347 $.ui.invalidate('ui.render');
348 return null;
349}
350
351async function detachRun($) {
352 stopTimer(run);
353 run = null;
354 try {
355 await $.ui.close({ id: PANE });
356 } catch (err) {
357 // Already closed.
358 debug($, 'ui.close: ' + message(err));
359 }
360 $.ui.invalidate('ui.render');
361}
362
363function isLive(r) {
364 return r.state === 'live';
365}
366
367/** The run is over for the mod: freeze the clock at `endedAtMs` and stop polling. */
368function settle(r, state, endedAtMs) {
369 r.state = state;
370 r.endedAtMs = endedAtMs;
371 stopTimer(r);
372}
373
374function stopTimer(r) {
375 if (r && r.timer) {
376 r.timer.cancel();
377 r.timer = null;
378 }
379}
380
381async function tick($, r) {
382 if (run !== r || !isLive(r)) return;
383 r.ticks += 1;
384 r.nowMs = await $.clock.now();
385 if (run !== r || !isLive(r)) return;
386 if (r.ticks % REFRESH_EVERY_TICKS === 0) {
387 try {
388 await refresh($, r);
389 r.problems.delete('refresh');
390 } catch (err) {
391 // Shown on the band until a refresh succeeds; the timer keeps trying.
392 r.problems.set('refresh', 'cannot refresh: ' + message(err));
393 }
394 }
395 if (run !== r) return;
396 if (isLive(r) && r.nowMs - r.attachedAtMs > RUN_CEILING_MS) settle(r, 'stale', r.nowMs);
397 $.ui.invalidate('ui.render');
398}
399
400async function refresh($, r) {
401 r.nowMs = await $.clock.now();
402 if (run !== r) return;
403 if (r.startedAtMs === null) {
404 try {
405 r.startedAtMs = (await $.fs.stat(pathOf(r, 'run.pid'))).mtimeMs;
406 } catch {
407 // No beacon yet; elapsed counts from the attach until it appears.
408 }
409 }
410 await readFeed($, r, 'progress.ndjson', 'progress');
411 await readFeed($, r, 'reviewer-progress.ndjson', 'reviewerProgress');
412 if (!r.plan) r.plan = await readJson($, r, 'dispatch-plan.json');
413 if (!r.passes) r.passes = await readJson($, r, 'passes.json');
414 await readDelivery($, r);
415 if (run !== r) return;
416 // A resume attaches before the CLI appends its `resume` line, over a feed that still ends
417 // in the previous attempt's `error`: only events written after the attach can finish it.
418 if (r.resume && r.eventsAtAttach < 0) r.eventsAtAttach = r.progress.length;
419 const fresh = !r.resume || r.progress.length > r.eventsAtAttach;
420 const last = lastEvent(r);
421 if (last && TERMINAL_PHASES.has(last.phase) && fresh) {
422 settle(r, last.phase === 'error' ? 'failed' : 'done', last.ts);
423 return;
424 }
425 const finalization = await freshStat($, r, 'finalization.json');
426 if (run !== r) return;
427 if (finalization) {
428 settle(r, 'done', last ? last.ts : finalization.mtimeMs);
429 return;
430 }
431 // The CLI writes error.txt where it fails (a gather, auth or runtime failure appends no
432 // terminal phase at all); one older than the newest progress line belongs to an attempt a
433 // resume already superseded.
434 const failure = await freshStat($, r, 'error.txt');
435 if (run !== r) return;
436 if (failure && !(last && failure.mtimeMs < last.ts)) {
437 const text = await readText($, r, 'error.txt');
438 if (run !== r) return;
439 r.failure = text === null ? 'see error.txt' : firstLine(text);
440 settle(r, 'failed', failure.mtimeMs);
441 return;
442 }
443 // A --context-only preview writes a summary with no plan and dispatches nothing. A plan
444 // that exists but cannot be read is a problem the band names, not a preview.
445 const dispatched = r.progress.some((ev) => ev.phase === 'dispatch');
446 if (!r.plan && !r.problems.has('dispatch-plan.json') && !dispatched && (await pathExists($, pathOf(r, 'pr-review-summary.md')))) {
447 if (run !== r) return;
448 settle(r, 'preview', last ? last.ts : r.nowMs);
449 }
450}
451
452/** Re-read an append-only NDJSON feed only when its size changed; skip a torn last line. */
453async function readFeed($, r, file, key) {
454 let stat;
455 try {
456 stat = await $.fs.stat(pathOf(r, file));
457 } catch {
458 return; // not written yet
459 }
460 if (r.sizes[file] === stat.size) return;
461 const text = await readText($, r, file);
462 if (text === null) return;
463 r.sizes[file] = stat.size;
464 r.lastChangeMs = r.nowMs;
465 const events = [];
466 for (const line of text.split('\n')) {
467 const t = line.trim();
468 if (!t) continue;
469 const ev = parseJson(t);
470 // A line that does not parse is the writer's, mid-line; the next size change brings the rest.
471 if (ev && typeof ev.ts === 'number') events.push(ev);
472 }
473 r[key] = events;
474}
475
476/** A write-once JSON artifact: null until it exists and parses (a torn write is retried). */
477async function readJson($, r, file) {
478 if (!(await pathExists($, pathOf(r, file)))) return null;
479 const text = await readText($, r, file);
480 return text === null ? null : parseJson(text);
481}
482
483/**
484 * delivery-state.json is replaced atomically; re-read on a new stamp. Only a parsed read of
485 * the canonical file retires the stamp — the backup is shown meanwhile, and the canonical
486 * file is read again next refresh.
487 */
488async function readDelivery($, r) {
489 let stat;
490 try {
491 stat = await $.fs.stat(pathOf(r, 'delivery-state.json'));
492 } catch {
493 return; // not written yet
494 }
495 const stamp = stat.mtimeMs + ':' + stat.size;
496 if (stamp === r.deliveryStamp) return;
497 const text = await readText($, r, 'delivery-state.json');
498 const state = text === null ? null : parseJson(text);
499 if (state && Array.isArray(state.planned)) {
500 r.delivery = state;
501 r.deliveryStamp = stamp;
502 r.lastChangeMs = r.nowMs;
503 return;
504 }
505 let backup = null;
506 try {
507 backup = parseJson(await $.fs.read(pathOf(r, '.delivery-state.json.bak')));
508 } catch {
509 backup = null; // no backup: keep what was shown
510 }
511 if (backup && Array.isArray(backup.planned)) r.delivery = backup;
512}
513
514/** The file's text, or null when it cannot be read — named on the band, since the caller saw it exist. */
515async function readText($, r, file) {
516 try {
517 const text = await $.fs.read(pathOf(r, file));
518 r.problems.delete(file);
519 return text;
520 } catch (err) {
521 r.problems.set(file, 'cannot read ' + file + ': ' + message(err));
522 return null;
523 }
524}
525
526/** The stat of a run artifact that counts for this attach: present, and written after a resume's attach. */
527async function freshStat($, r, file) {
528 let stat;
529 try {
530 stat = await $.fs.stat(pathOf(r, file));
531 } catch {
532 return null;
533 }
534 return !r.resume || stat.mtimeMs > r.attachedAtMs ? stat : null;
535}
536
537async function pathExists($, path) {
538 try {
539 return await $.fs.exists(path);
540 } catch {
541 return false;
542 }
543}
544
545/** `<home>/.pr-review/runs`, or null when the session has no home directory to find it under. */
546async function runsRoot($) {
547 const home = (await $.env.get('USERPROFILE')) || (await $.env.get('HOME'));
548 if (!home) return null;
549 const sep = sepOf(home);
550 return home + sep + '.pr-review' + sep + 'runs';
551}
552
553/** The newest run by the UTC stamp in its name (`<provider>__<owner>__<repo>__<n>__<stamp>`). */
554async function newestRunId($) {
555 const root = await runsRoot($);
556 if (!root) return null;
557 let entries;
558 try {
559 entries = await $.fs.list(root);
560 } catch (err) {
561 debug($, 'fs.list: ' + message(err));
562 return null;
563 }
564 const ids = entries.filter((x) => x.kind === 'dir' && STAMP_RE.test(x.name) && validId(x.name)).map((x) => x.name);
565 ids.sort((a, b) => (STAMP_RE.exec(a)[0] < STAMP_RE.exec(b)[0] ? -1 : 1));
566 return ids.length > 0 ? ids[ids.length - 1] : null;
567}
568
569/** A line for the debug log (`claude --debug-file`); never the transcript, never a throw. */
570function debug($, text) {
571 try {
572 Promise.resolve($.ui.log('pr-review live: ' + text, { to: 'debug' })).catch(() => {});
573 } catch {
574 // A host without a debug log: nothing to say it to.
575 }
576}
577
578// Derivation
579
580function clean(value) {
581 return String(value ?? '').replace(UNSAFE_RE, '');
582}
583
584function message(err) {
585 return firstLine(err && err.message ? err.message : String(err));
586}
587
588function firstLine(text) {
589 const line = clean(String(text).split('\n')[0]);
590 return line.length > 120 ? line.slice(0, 119) + '…' : line;
591}
592
593function parseJson(text) {
594 try {
595 return JSON.parse(text);
596 } catch {
597 return null;
598 }
599}
600
601function sepOf(path) {
602 return path.includes('\\') ? '\\' : '/';
603}
604
605function pathOf(r, file) {
606 return r.dir + sepOf(r.dir) + file;
607}
608
609function stampMs(id) {
610 const m = STAMP_RE.exec(id);
611 if (!m) return null;
612 const ms = Date.parse(m[1] + 'T' + m[2] + ':' + m[3] + ':' + m[4] + '.' + m[5] + 'Z');
613 return Number.isFinite(ms) ? ms : null;
614}
615
616function lastEvent(r) {
617 for (let i = r.progress.length - 1; i >= 0; i -= 1) {
618 if (r.progress[i].phase !== 'reap') return r.progress[i];
619 }
620 return null;
621}
622
623/** `owner/repo #n` from the plan (or the run id); a narrow band gets `repo #n`, a narrower one `#n`. */
624function prLabel(r, columns) {
625 const pr = r.plan && r.plan.pr;
626 const parts = r.id.split('__');
627 const fromId = parts.length >= 5;
628 const owner = pr && pr.owner ? pr.owner : fromId ? parts[1] : '';
629 const repo = pr && pr.repo ? pr.repo : fromId ? parts[2] : '';
630 const number = pr && pr.number != null ? pr.number : fromId ? parts[3] : '';
631 if (!repo) return clean(r.id);
632 if (columns === undefined || columns >= 96) return clean(owner + '/' + repo + ' #' + number);
633 if (columns >= 64) return clean(repo + ' #' + number);
634 return clean('#' + number);
635}
636
637/** Since the run started — or since the newest `resume` event, when the run was resumed — until it settled. */
638function elapsedMs(r) {
639 let start = r.startedAtMs ?? r.attachedAtMs;
640 for (let i = r.progress.length - 1; i >= 0; i -= 1) {
641 if (r.progress[i].phase === 'resume') {
642 start = Math.max(start, r.progress[i].ts);
643 break;
644 }
645 }
646 const end = r.endedAtMs ?? r.nowMs;
647 return Math.max(0, end - start);
648}
649
650function fmt(ms) {
651 if (!Number.isFinite(ms)) return '';
652 const s = Math.max(0, Math.floor(ms / 1000));
653 const m = Math.floor(s / 60) % 60;
654 const h = Math.floor(s / 3600);
655 const ss = String(s % 60).padStart(2, '0');
656 return h > 0 ? h + ':' + String(m).padStart(2, '0') + ':' + ss : m + ':' + ss;
657}
658
659/**
660 * Where a reviewer came from: the group the pane lists it under, and the match kind shown
661 * beside a pack row (`matchedBy` is src/dispatch/pass-select.ts's; a plugin, companion or
662 * repo rule says it all in the group).
663 */
664function sourceOf(reviewer) {
665 const name = clean(reviewer.name);
666 const source = clean(reviewer.source);
667 if (reviewer.kind === 'companion-agent') return { group: 'plugin ' + (source || 'companion'), tag: '' };
668 if (reviewer.matchedBy === 'plugin') return { group: 'plugin ' + name.split('/')[0], tag: '' };
669 const pack = PACK_RE.exec(source);
670 if (pack) return { group: 'pack ' + pack[1], tag: clean(reviewer.matchedBy ?? '') };
671 if (reviewer.matchedBy === 'forced') return { group: 'forced skill', tag: '' };
672 return { group: 'repo rule', tag: '' };
673}
674
675/** `<pack>/<skill>` and `<plugin>/<agent>` lose their prefix inside their group; a repo rule keeps its plain name. */
676function shortName(name, group) {
677 const shown = clean(name).replace(/^companion:/, '');
678 const slash = shown.indexOf('/');
679 return slash > 0 && (group.startsWith('pack ') || group.startsWith('plugin ')) ? shown.slice(slash + 1) : shown;
680}
681
682function has(list, name) {
683 return Array.isArray(list) && list.includes(name);
684}
685
686/** One reviewer's state from the delivery record and its newest batch. */
687function reviewerState(d, name, status, attempt, maxAttempts, seen) {
688 if (d && has(d.valid, name)) return 'done';
689 if (status === 'spawn-rejected') return 'failed';
690 if (status === 'completed') {
691 if (!has(d.invalid, name) && !has(d.missing, name)) return 'pending';
692 return attempt < maxAttempts && d.kind !== 'terminal-incomplete' ? 'retry' : 'failed';
693 }
694 if (status === 'started') return seen ? 'delivered' : 'running';
695 return 'pending';
696}
697
698/** One row per planned reviewer: state, time and source, from the plan, delivery and timeline. */
699function reviewerRows(r) {
700 const plan = r.plan;
701 if (!plan || !Array.isArray(plan.reviewers)) return [];
702 const d = r.delivery;
703 const firstSeen = new Map();
704 for (const ev of r.reviewerProgress) {
705 if (ev.kind === 'output-first-seen' && ev.reviewer) firstSeen.set(ev.reviewer + '#' + ev.attempt, ev.ts);
706 }
707 return plan.reviewers.map((reviewer) => {
708 const name = String(reviewer.name ?? '');
709 const attempt = (d && d.reviewerAttempts && d.reviewerAttempts[name]) || 0;
710 const batches = d && Array.isArray(d.runtimeAttempts) ? d.runtimeAttempts.filter((a) => has(a.reviewers, name)) : [];
711 const batch = batches.length > 0 ? batches[batches.length - 1] : null;
712 // Attempts written before lifecycle tracking carry no status: they are over unless the run says otherwise.
713 const status = batch ? batch.status || (d.kind === 'running' ? 'started' : 'completed') : null;
714 const seenTs = firstSeen.get(name + '#' + attempt);
715 const state = reviewerState(d, name, status, attempt, reviewer.maxAttempts || 1, seenTs !== undefined);
716 const startMs = batch ? Date.parse(batch.startedAt) : NaN;
717 let endMs = r.nowMs;
718 if (state === 'done' || state === 'delivered') endMs = seenTs ?? (batch ? Date.parse(batch.endedAt) : r.nowMs);
719 else if (state === 'failed' || state === 'retry') endMs = batch ? Date.parse(batch.endedAt) : r.nowMs;
720 const elapsed = state !== 'pending' && Number.isFinite(startMs) && Number.isFinite(endMs) ? fmt(endMs - startMs) : '';
721 const { group, tag } = sourceOf(reviewer);
722 return {
723 name,
724 short: shortName(name, group),
725 kind: reviewer.kind === 'companion-agent' ? 'companion' : 'pass',
726 state,
727 group,
728 tag,
729 source: group + (tag ? ' · ' + tag : ''),
730 elapsed,
731 };
732 });
733}
734
735/** The rows under their source, in order of first appearance. */
736function groupRows(rows) {
737 const groups = [];
738 for (const row of rows) {
739 const last = groups.find((g) => g.label === row.group);
740 if (last) last.rows.push(row);
741 else groups.push({ label: row.group, rows: [row] });
742 }
743 return groups;
744}
745
746/** One cell run per stretch of same-state reviewers, in plan order, `cells` wide per reviewer. */
747function barRuns(rows, cells) {
748 const runs = [];
749 for (const row of rows) {
750 const last = runs[runs.length - 1];
751 if (last && last.state === row.state) last.count += 1;
752 else runs.push({ state: row.state, count: 1 });
753 }
754 return runs.map((run) => ({ ...BAR[run.state], text: BAR[run.state].cell.repeat(run.count * cells) }));
755}
756
757/** Cells per reviewer so the bar and its count fit the room: 1 to 4. */
758function barCells(rowCount, inner) {
759 return Math.max(1, Math.min(4, Math.floor((inner - 20) / Math.max(1, rowCount))));
760}
761
762/** The states present, with their counts, in a fixed order. */
763function legend(rows) {
764 return STATE_ORDER.map((state) => ({ state, ...BAR[state], count: rows.filter((row) => row.state === state).length })).filter((item) => item.count > 0);
765}
766
767function tally(rows) {
768 return {
769 total: rows.length,
770 delivered: rows.filter((row) => row.state === 'done' || row.state === 'delivered').length,
771 valid: rows.filter((row) => row.state === 'done').length,
772 };
773}
774
775function counts(r, rows) {
776 const all = tally(rows);
777 const passes = rows.filter((row) => row.kind === 'pass');
778 const passesJson = Array.isArray(r.passes) ? r.passes : [];
779 return {
780 planned: all.total,
781 delivered: all.delivered,
782 valid: all.valid,
783 passes: tally(passes),
784 companions: tally(rows.filter((row) => row.kind === 'companion')),
785 packs: passes.filter((row) => row.source.startsWith('pack ')).length,
786 repoRules: passes.filter((row) => row.source === 'repo rule' || row.source === 'forced skill').length,
787 plugins: passes.filter((row) => row.source.startsWith('plugin ')).length,
788 context: passesJson.filter((p) => p && p.matchedBy === 'context'),
789 onDemand: passesJson.filter((p) => p && p.matchedBy === 'index').length,
790 };
791}
792
793function verifierLabel(r) {
794 const plan = r.plan;
795 if (plan && plan.verifier && plan.verifier.enabled === false) return 'verifier off';
796 const state = r.delivery && r.delivery.verifier ? r.delivery.verifier.state : 'not-evaluated';
797 const words = {
798 'not-evaluated': 'verifier pending',
799 'skipped-disabled': 'verifier off',
800 'skipped-no-severe': 'verifier skipped',
801 required: 'verifier ●',
802 valid: 'verifier ✓',
803 missing: 'verifier ✗',
804 invalid: 'verifier ✗',
805 };
806 return words[state] || 'verifier ' + clean(state);
807}
808
809function codexLabel(r) {
810 const plan = r.plan;
811 if (plan && plan.codex && plan.codex.enabled === false) return 'codex off';
812 const state = r.delivery && r.delivery.codex ? r.delivery.codex.state : 'pending';
813 const words = { disabled: 'codex off', pending: 'codex ●', valid: 'codex ✓', failed: 'codex ✗' };
814 return words[state] || 'codex ' + clean(state);
815}
816
817function headline(r) {
818 const last = lastEvent(r);
819 const time = fmt(elapsedMs(r));
820 const phase = last ? clean(last.phase) + (last.detail ? ' — ' + clean(last.detail) : '') : 'starting…';
821 let glyph = GLYPH.running;
822 let text;
823 if (r.state === 'preview') {
824 glyph = GLYPH.done;
825 text = time + ' · preview — no dispatch';
826 } else if (r.state === 'failed' && r.failure !== null) {
827 glyph = GLYPH.failed;
828 text = time + ' · failed — ' + r.failure + ' · pr-review status ' + clean(r.id);
829 } else if (r.state === 'failed') {
830 glyph = GLYPH.failed;
831 text = time + ' · ' + phase;
832 } else if (r.state === 'done') {
833 glyph = GLYPH.done;
834 text = time + ' · ' + phase;
835 } else if (r.state === 'stale') {
836 glyph = GLYPH.pending;
837 text = time + ' · ' + phase + ' · polling stopped after ' + fmt(RUN_CEILING_MS) + ', /pr-review-live ' + clean(r.id) + ' to reattach';
838 } else {
839 const silent = r.nowMs - r.lastChangeMs;
840 text = time + ' · ' + phase + (silent > HEARTBEAT_SILENCE_MS ? ' · no heartbeat for ' + fmt(silent) : '');
841 }
842 for (const problem of r.problems.values()) text += ' · ' + problem;
843 return { glyph, text };
844}
845
846function progressLine(label, t, settled) {
847 return settled ? label + ' ' + t.valid + '/' + t.total + ' ✓' : label + ' ' + t.delivered + '/' + t.total + ' delivered';
848}
849
850function countsLine(r, c) {
851 const settled = !isLive(r);
852 return progressLine('passes', c.passes, settled) + ' · ' + progressLine('companions', c.companions, settled) + ' · ' + codexLabel(r) + ' · ' + verifierLabel(r);
853}
854
855/** Where the roster came from; a zero is left out, the project rules in context are always said. */
856function sourcesLine(c) {
857 const parts = [];
858 if (c.packs > 0) parts.push('packs ' + c.packs);
859 if (c.repoRules > 0) parts.push('repo rules ' + c.repoRules);
860 if (c.plugins > 0) parts.push('plugins ' + c.plugins);
861 if (c.companions.total > 0) parts.push('companions ' + c.companions.total);
862 parts.push(c.context.length + ' project rules in every pass');
863 if (c.onDemand > 0) parts.push(c.onDemand + ' on-demand');
864 return parts.join(' · ');
865}
866
867/** `7/16 delivered` while the run is live, `16/16 ✓` once it settled. */
868function totalLabel(r, c) {
869 return isLive(r) ? c.delivered + '/' + c.planned + ' delivered' : c.valid + '/' + c.planned + ' ✓';
870}
871
872// Drawing
873
874/** The status: the state glyph in its colour, then the headline text, truncated to the room. */
875function statusRow(Box, Text, h) {
876 return Box({
877 flexDirection: 'row',
878 flexShrink: 1,
879 children: [
880 Text({ color: h.glyph.color, dimColor: h.glyph.dim, children: [h.glyph.glyph + ' '] }),
881 Text({ wrap: 'truncate-end', children: [h.text] }),
882 ],
883 });
884}
885
886/** The bar — one coloured run per stretch of same-state reviewers — and the delivered count beside it. */
887function barRow(Box, Text, r, rows, c, inner) {
888 const runs = barRuns(rows, barCells(rows.length, inner));
889 return Box({
890 flexDirection: 'row',
891 columnGap: 2,
892 children: [
893 Box({ flexDirection: 'row', flexShrink: 0, children: runs.map((run) => Text({ color: run.color, dimColor: run.dim, children: [run.text] })) }),
894 Text({ dimColor: true, wrap: 'truncate-end', children: [totalLabel(r, c)] }),
895 ],
896 });
897}
898
899/** `■ delivered 7 ■ running 9`: one square per state present, flowing onto a second row when narrow. */
900function legendRow(Box, Text, rows) {
901 return Box({
902 flexDirection: 'row',
903 flexWrap: 'wrap',
904 columnGap: 3,
905 children: legend(rows).map((item) =>
906 Box({
907 flexDirection: 'row',
908 flexShrink: 0,
909 children: [Text({ color: item.color, dimColor: item.dim, children: ['■ '] }), Text({ children: [item.state + ' ' + item.count] })],
910 }),
911 ),
912 });
913}
914
915/**
916 * The band above the prompt: a rounded box with the title and the status on one row, the
917 * bar, its legend, the counts and the sources. A band with fewer than BAND_ROWS rows of room
918 * keeps the title, the bar and the counts; a narrow one shortens the PR label.
919 */
920function band(Box, Text, r, columns, maxRows) {
921 const h = headline(r);
922 const rows = reviewerRows(r);
923 const c = counts(r, rows);
924 const inner = Math.max(20, columns - 4);
925 const compact = maxRows < BAND_ROWS;
926 const line = (children, props) => Text({ wrap: 'truncate-end', ...props, children });
927 const children = [
928 Box({
929 flexDirection: 'row',
930 justifyContent: 'space-between',
931 columnGap: 2,
932 children: [
933 Box({
934 flexDirection: 'row',
935 flexShrink: 0,
936 children: [Text({ color: h.glyph.color, dimColor: h.glyph.dim, children: ['◆ '] }), Text({ bold: true, children: ['pr-review · ' + prLabel(r, columns)] })],
937 }),
938 statusRow(Box, Text, h),
939 ],
940 }),
941 ];
942 if (rows.length > 0) children.push(barRow(Box, Text, r, rows, c, inner));
943 if (!compact && rows.length > 0) children.push(legendRow(Box, Text, rows));
944 children.push(line([countsLine(r, c)]));
945 if (!compact) children.push(line([sourcesLine(c) + ' · /pr-review-live'], { dimColor: true }));
946 return Box({ key: 'pr-review-band', flexDirection: 'column', borderStyle: 'round', borderDimColor: true, paddingX: 1, width: columns, children });
947}
948
949/**
950 * The pane: two header lines and the bar, then the reviewers grouped under their source —
951 * one row each with the state glyph, the time, the short name and how it matched — the
952 * siblings, and the project rules in context.
953 */
954function pane(Box, Text, r, columns) {
955 const h = headline(r);
956 const rows = reviewerRows(r);
957 const c = counts(r, rows);
958 const settled = !isLive(r);
959 const row = (x) => {
960 const g = GLYPH[x.state] || GLYPH.pending;
961 return Box({
962 flexDirection: 'row',
963 paddingLeft: 2,
964 columnGap: 2,
965 children: [
966 Box({ width: 7, flexShrink: 0, children: [Text({ color: g.color, dimColor: g.dim, children: [g.glyph + ' ' + x.elapsed.padStart(5)] })] }),
967 Box({ flexGrow: 1, flexShrink: 1, children: [Text({ wrap: 'truncate-end', children: [x.short] })] }),
968 ...(x.tag ? [Box({ flexShrink: 0, children: [Text({ dimColor: true, children: [x.tag] })] })] : []),
969 ],
970 });
971 };
972 const section = (title) => Text({ bold: true, children: [title] });
973 const grouped = (list) => groupRows(list).flatMap((g) => [Text({ dimColor: true, children: [' ' + g.label] }), ...g.rows.map(row)]);
974 const passes = rows.filter((x) => x.kind === 'pass');
975 const companions = rows.filter((x) => x.kind === 'companion');
976 const siblings = [];
977 if (r.plan && r.plan.codex && r.plan.codex.enabled) siblings.push(Text({ children: [' ' + codexLabel(r) + ' · codex exec (read-only sibling)'] }));
978 if (r.plan && r.plan.verifier && r.plan.verifier.enabled) siblings.push(Text({ children: [' ' + verifierLabel(r) + ' · only with HIGH/CRITICAL findings'] }));
979 const contextNames = c.context.map((p) => clean(p.name));
980 const contextText = contextNames.length === 0 ? 'none' : contextNames.slice(0, 3).join(', ') + (contextNames.length > 3 ? ', … +' + (contextNames.length - 3) : '');
981 return Box({
982 flexDirection: 'column',
983 children: [
984 Text({ bold: true, wrap: 'truncate-end', children: [prLabel(r)] }),
985 statusRow(Box, Text, h),
986 ...(rows.length > 0 ? [barRow(Box, Text, r, rows, c, Math.max(30, columns))] : []),
987 Text({ children: [' '] }),
988 section(progressLine('passes', c.passes, settled)),
989 ...grouped(passes),
990 section(progressLine('companions', c.companions, settled)),
991 ...(companions.length > 0 ? grouped(companions) : [Text({ dimColor: true, children: [' none installed for this runtime'] })]),
992 ...(siblings.length > 0 ? [section('siblings'), ...siblings] : []),
993 Text({ children: [' '] }),
994 Text({ children: ['context in every pass (' + c.context.length + '): ' + contextText] }),
995 Text({ children: ['on-demand (index): ' + c.onDemand] }),
996 Text({ dimColor: true, children: ['esc closes · /pr-review-live off detaches'] }),
997 ],
998 });
999}
1000
1001/** The pane as text, for an interactive surface that draws nothing (the VS Code chat panel). */
1002function renderText(r) {
1003 if (!r) return 'pr-review live: nothing attached';
1004 const h = headline(r);
1005 const rows = reviewerRows(r);
1006 const c = counts(r, rows);
1007 const settled = !isLive(r);
1008 const lines = ['pr-review · ' + prLabel(r) + ' · ' + h.glyph.glyph + ' ' + h.text];
1009 if (rows.length > 0) lines.push(barRuns(rows, barCells(rows.length, 80)).map((run) => run.text).join('') + ' ' + totalLabel(r, c));
1010 lines.push(countsLine(r, c), sourcesLine(c), '');
1011 const listed = (title, list) => {
1012 lines.push(title);
1013 for (const g of groupRows(list)) {
1014 lines.push(' ' + g.label);
1015 for (const x of g.rows) lines.push(' ' + (GLYPH[x.state] || GLYPH.pending).glyph + ' ' + x.elapsed.padStart(5) + ' ' + x.short + (x.tag ? ' ' + x.tag : ''));
1016 }
1017 };
1018 listed(progressLine('passes', c.passes, settled), rows.filter((x) => x.kind === 'pass'));
1019 listed(progressLine('companions', c.companions, settled), rows.filter((x) => x.kind === 'companion'));
1020 return lines.join('\n');
1021}
1022